diff --git a/tests/Carina.Api.Tests/FeatureTest/MigrationRecordEndpointTests.cs b/tests/Carina.Api.Tests/Feature/Migration/GetMigrationRecordActionTests.cs similarity index 98% rename from tests/Carina.Api.Tests/FeatureTest/MigrationRecordEndpointTests.cs rename to tests/Carina.Api.Tests/Feature/Migration/GetMigrationRecordActionTests.cs index b1f52c214..e1c00731e 100644 --- a/tests/Carina.Api.Tests/FeatureTest/MigrationRecordEndpointTests.cs +++ b/tests/Carina.Api.Tests/Feature/Migration/GetMigrationRecordActionTests.cs @@ -3,9 +3,10 @@ using Carina.Domain.Migration; -namespace Carina.Api.Tests.FeatureTest; +namespace Carina.Api.Tests.Feature.Migration; -public sealed class MigrationRecordEndpointTests +[Trait("Category", "Feature")] +public sealed class GetMigrationRecordActionTests { private const string Record = "/api/migration/record"; diff --git a/tests/Carina.Api.Tests/FeatureTest/MigrationFeature.cs b/tests/Carina.Api.Tests/Feature/Migration/MigrationFeature.cs similarity index 98% rename from tests/Carina.Api.Tests/FeatureTest/MigrationFeature.cs rename to tests/Carina.Api.Tests/Feature/Migration/MigrationFeature.cs index b875a2364..4f9a21aaf 100644 --- a/tests/Carina.Api.Tests/FeatureTest/MigrationFeature.cs +++ b/tests/Carina.Api.Tests/Feature/Migration/MigrationFeature.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Text.Json; +using Carina.Api.Tests.FeatureTest; using Carina.Domain.Base; using Carina.Domain.Encodings; using Carina.Domain.Migration; @@ -12,7 +13,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -namespace Carina.Api.Tests.FeatureTest; +namespace Carina.Api.Tests.Feature.Migration; internal sealed class HeldMigrationRecord : IMigrationRecordRepository { diff --git a/tests/Carina.Api.Tests/FeatureTest/MigrationSurfaceTests.cs b/tests/Carina.Api.Tests/FeatureTest/MigrationSurfaceTests.cs deleted file mode 100644 index 5b65e3696..000000000 --- a/tests/Carina.Api.Tests/FeatureTest/MigrationSurfaceTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Carina.Api.Authentication; -using Carina.Api.Tests.Unit; - -using Microsoft.AspNetCore.Http; - -namespace Carina.Api.Tests.FeatureTest; - -public sealed class MigrationSurfaceTests(TestingWebApplicationFactory factory) - : IClassFixture -{ - private const string Root = "/api/migration"; - - [Fact] - public void TheMigrationSurfaceIsTheOneTheRecordIsReadThrough() - => Assert.Equal( - ["GET /api/migration/record"], - Migration().Select(surface => surface.ToString()).Order(StringComparer.Ordinal).ToArray()); - - [Fact] - public void NothingAnywhereElseOffersAMigrationOfItsOwn() - => Assert.Equal( - ["/api/migration/record"], - Inventory() - .Select(surface => surface.Pattern) - .Where(pattern => pattern.Contains("migration", StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .ToArray()); - - [Fact] - public void NothingUnderTheMigrationSurfaceIsAskedForOnAMethodThatWrites() - => Assert.All(Migration(), surface => Assert.True( - HttpMethods.IsGet(surface.Method), - $"{surface} reaches the record on a method that is not a read")); - - [Fact] - public void NothingUnderTheMigrationSurfaceChangesAnythingOrSaysItDestroys() - => Assert.All(Migration(), surface => Assert.Equal(EndpointEffect.Reading, surface.Effect)); - - [Fact] - public void NothingUnderTheMigrationSurfaceDeletes() - => Assert.Empty(EndpointRules.SurfacesThatDeleteUnder(Inventory(), Root)); - - [Fact] - public void TheRootIsReadAsAWholeSegmentAndNotAsLeadingLetters() - { - RoutedSurface[] inventory = - [ - new("POST", "/api/migrations-of-a-kind/run", EndpointEffect.Changing), - new("POST", "/api/migration/run", EndpointEffect.Changing), - ]; - - Assert.Equal( - ["POST /api/migration/run"], - inventory.Where(UnderTheRoot).Select(surface => surface.ToString()).ToArray()); - } - - private static bool UnderTheRoot(RoutedSurface surface) - => string.Equals(surface.Pattern, Root, StringComparison.Ordinal) - || surface.Pattern.StartsWith(Root + "/", StringComparison.Ordinal); - - private IEnumerable Migration() => Inventory().Where(UnderTheRoot); - - private IReadOnlyList Inventory() => RouteInventory.Of(factory); -} diff --git a/tests/Carina.Api.Tests/FeatureTest/RoutedSurfaceTests.cs b/tests/Carina.Api.Tests/FeatureTest/RoutedSurfaceTests.cs index ddc26cc21..6bd504802 100644 --- a/tests/Carina.Api.Tests/FeatureTest/RoutedSurfaceTests.cs +++ b/tests/Carina.Api.Tests/FeatureTest/RoutedSurfaceTests.cs @@ -17,6 +17,8 @@ public sealed class RoutedSurfaceTests(TestingWebApplicationFactory factory) { private const int TheSurfacesThisRepositoryHadWhenTheSweepWasWritten = 50; + private const string MigrationRoot = "/api/migration"; + [Fact] public void EveryRoutedSurfaceSaysWhetherItDestroys() { @@ -421,6 +423,51 @@ public void AskingForTheLedgerToBeCheckedDiscardsNothing() .Effect); } + [Fact] + public void TheMigrationSurfaceIsTheOneTheRecordIsReadThrough() + => Assert.Equal( + ["GET /api/migration/record"], + MigrationSurfaces().Select(surface => surface.ToString()).Order(StringComparer.Ordinal).ToArray()); + + [Fact] + public void NothingAnywhereElseOffersAMigrationOfItsOwn() + => Assert.Equal( + ["/api/migration/record"], + Inventory() + .Select(surface => surface.Pattern) + .Where(pattern => pattern.Contains("migration", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray()); + + [Fact] + public void NothingUnderTheMigrationSurfaceIsAskedForOnAMethodThatWrites() + => Assert.All(MigrationSurfaces(), surface => Assert.True( + HttpMethods.IsGet(surface.Method), + $"{surface} reaches the record on a method that is not a read")); + + [Fact] + public void NothingUnderTheMigrationSurfaceChangesAnythingOrSaysItDestroys() + => Assert.All(MigrationSurfaces(), surface => Assert.Equal(EndpointEffect.Reading, surface.Effect)); + + [Fact] + public void NothingUnderTheMigrationSurfaceDeletes() + => Assert.Empty(EndpointRules.SurfacesThatDeleteUnder(Inventory(), MigrationRoot)); + + [Fact] + public void TheRootIsReadAsAWholeSegmentAndNotAsLeadingLetters() + { + RoutedSurface[] inventory = + [ + new("POST", "/api/migrations-of-a-kind/run", EndpointEffect.Changing), + new("POST", "/api/migration/run", EndpointEffect.Changing), + ]; + + Assert.Equal( + ["POST /api/migration/run"], + inventory.Where(UnderTheMigrationRoot).Select(surface => surface.ToString()).ToArray()); + } + [Fact] public void TheInventoryHoldsTheSurfacesTheDocumentCannotDescribe() { @@ -511,5 +558,11 @@ public async Task APathThatMerelyLooksLikeAnEnumeratedSurfaceIsRefused(string pa Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + private static bool UnderTheMigrationRoot(RoutedSurface surface) + => string.Equals(surface.Pattern, MigrationRoot, StringComparison.Ordinal) + || surface.Pattern.StartsWith(MigrationRoot + "/", StringComparison.Ordinal); + + private IEnumerable MigrationSurfaces() => Inventory().Where(UnderTheMigrationRoot); + private IReadOnlyList Inventory() => RouteInventory.Of(factory); } diff --git a/tests/Carina.Conventions.Tests/TestLayoutRules.cs b/tests/Carina.Conventions.Tests/TestLayoutRules.cs index 2de6386e8..5476ddd28 100644 --- a/tests/Carina.Conventions.Tests/TestLayoutRules.cs +++ b/tests/Carina.Conventions.Tests/TestLayoutRules.cs @@ -32,7 +32,6 @@ public static class TestLayoutRules "Integrity", "Live", "Machines", - "Migration", "Net", "Persistence", "Playback", @@ -66,6 +65,8 @@ public static class TestLayoutRules new("Epg", "src/Carina.Api/Events/ProgrammeFeedReaders.cs"), new("Epg", "src/Carina.Api/Events/ProgrammeFeedStream.cs"), new("Logos", "src/Carina.Api/Logos/LogoDelivery.cs"), + new("Migration", "src/Carina.Db/CarryArguments.cs"), + new("Migration", "src/Carina.Db/CarrySaid.cs"), new("Notifications", "src/Carina.Api/Requests/NotificationRequests.cs"), new("Notifications", "src/Carina.Api/Services/NotificationDestinationService.cs"), new("Notifications", "src/Carina.Api/Services/NotificationPushService.cs"), @@ -111,6 +112,7 @@ public static class TestLayoutRules new("Channels", "LogoVisitSchema"), new("Channels", "ServiceReachSchema"), new("Channels", "StationLogoSchema"), + new("Migration", "MigrationRecordSchema"), new("Programmes", "ProgrammeSearchFoldingSchema"), new("Quality", "QualityTablesDroppedWholeSchema"), new("Recordings", "RecordingBroadcastGroupSchema"), diff --git a/tests/Carina.Db.Tests/MigrationRecordSchemaTests.cs b/tests/Carina.Db.Tests/DbIntegration/Infra/Migration/MigrationRecordSchemaTests.cs similarity index 91% rename from tests/Carina.Db.Tests/MigrationRecordSchemaTests.cs rename to tests/Carina.Db.Tests/DbIntegration/Infra/Migration/MigrationRecordSchemaTests.cs index fdd944d98..194e02f81 100644 --- a/tests/Carina.Db.Tests/MigrationRecordSchemaTests.cs +++ b/tests/Carina.Db.Tests/DbIntegration/Infra/Migration/MigrationRecordSchemaTests.cs @@ -1,8 +1,10 @@ using Carina.Domain.Migration; +using Carina.Infrastructure.Migration; +using Carina.Infrastructure.Persistence; using Npgsql; -namespace Carina.Db.Tests; +namespace Carina.Db.Tests.DbIntegration.Infra.Migration; [Collection(ConnectionEnvironmentCollection.Name)] [Trait("Category", "DbIntegration")] @@ -13,6 +15,8 @@ public sealed class MigrationRecordSchemaTests(MigratedScratchDatabase database) private const string Ended = "timestamptz '2026-09-08 03:04:00+00'"; + private static readonly CancellationToken Cancel = CancellationToken.None; + public static TheoryData Refusals => Named(MigrationRefusals.All.Select(refusal => refusal.ToString())); public static TheoryData Counted => @@ -337,6 +341,37 @@ await CountAsync( $"SELECT count(*) FROM migration_rule_proposal WHERE run_id = '{run}' AND rule_id IS NULL")); } + [Fact] + public async Task ASecondRunIsRefusedWhileTheFirstStillHoldsTheLease() + { + await using CarinaDbContext first = CarinaDbContextFactory.Create(database.ConnectionString); + await using CarinaDbContext second = CarinaDbContextFactory.Create(database.ConnectionString); + + IAsyncDisposable? held = await new MigrationLease(first).TakeAsync(Cancel); + + Assert.NotNull(held); + Assert.Null(await new MigrationLease(second).TakeAsync(Cancel)); + + await held.DisposeAsync(); + } + + [Fact] + public async Task TheNextRunGetsTheLeaseOnceTheFirstHasGivenItBack() + { + await using CarinaDbContext first = CarinaDbContextFactory.Create(database.ConnectionString); + await using CarinaDbContext second = CarinaDbContextFactory.Create(database.ConnectionString); + + IAsyncDisposable? held = await new MigrationLease(first).TakeAsync(Cancel); + Assert.NotNull(held); + await held.DisposeAsync(); + + IAsyncDisposable? next = await new MigrationLease(second).TakeAsync(Cancel); + + Assert.NotNull(next); + + await next.DisposeAsync(); + } + private static TheoryData Named(IEnumerable names) { TheoryData data = []; diff --git a/tests/Carina.Db.Tests/CarryArgumentsTests.cs b/tests/Carina.Db.Tests/Unit/Infra/Migration/CarryArgumentsTests.cs similarity index 95% rename from tests/Carina.Db.Tests/CarryArgumentsTests.cs rename to tests/Carina.Db.Tests/Unit/Infra/Migration/CarryArgumentsTests.cs index 20cdf79ce..78682abb7 100644 --- a/tests/Carina.Db.Tests/CarryArgumentsTests.cs +++ b/tests/Carina.Db.Tests/Unit/Infra/Migration/CarryArgumentsTests.cs @@ -1,7 +1,8 @@ using Carina.Domain.Migration; -namespace Carina.Db.Tests; +namespace Carina.Db.Tests.Unit.Infra.Migration; +[Trait("Category", "Unit")] public sealed class CarryArgumentsTests { [Fact] diff --git a/tests/Carina.Db.Tests/CarrySaidTests.cs b/tests/Carina.Db.Tests/Unit/Infra/Migration/CarrySaidTests.cs similarity index 99% rename from tests/Carina.Db.Tests/CarrySaidTests.cs rename to tests/Carina.Db.Tests/Unit/Infra/Migration/CarrySaidTests.cs index 326a972ff..693b1ed07 100644 --- a/tests/Carina.Db.Tests/CarrySaidTests.cs +++ b/tests/Carina.Db.Tests/Unit/Infra/Migration/CarrySaidTests.cs @@ -2,8 +2,9 @@ using Carina.Domain.Encodings; using Carina.Domain.Migration; -namespace Carina.Db.Tests; +namespace Carina.Db.Tests.Unit.Infra.Migration; +[Trait("Category", "Unit")] public sealed class CarrySaidTests { private static readonly DateTime Began = new(2026, 9, 8, 3, 0, 0, DateTimeKind.Utc); diff --git a/tests/Carina.Domain.Tests/Migration/MigrationRecordShapeTests.cs b/tests/Carina.Domain.Tests/Migration/MigrationRecordShapeTests.cs deleted file mode 100644 index 011ef8327..000000000 --- a/tests/Carina.Domain.Tests/Migration/MigrationRecordShapeTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -using Carina.Domain.Migration; - -using static Carina.Domain.Tests.Migration.MigrationFixtures; - -namespace Carina.Domain.Tests.Migration; - -public sealed class MigrationRecordShapeTests -{ - [Fact] - public void WhatAPopulationOfferedIsWhatItCarriedWhatItDidNotAndWhatNobodyCouldSay() - { - ArgumentException refused = Assert.Throws( - () => MigrationTally.Rehydrate(Run, MigrationPopulation.Recordings, 5, 2, 2, 0)); - - Assert.Contains("nobody could say", refused.Message, StringComparison.Ordinal); - } - - [Theory] - [InlineData(-1, 0, 0, 0)] - [InlineData(0, -1, 0, 1)] - [InlineData(0, 0, -1, 1)] - [InlineData(0, 0, 1, -1)] - public void ARunCountsNothingNegative(int offered, int carried, int notCarried, int unclassified) - { - Assert.Throws(() => MigrationTally.Rehydrate( - Run, - MigrationPopulation.Recordings, - offered, - carried, - notCarried, - unclassified)); - } - - [Fact] - public void TheProgrammeGuideIsNeverCountedAsRowsOfItsOwn() - { - Assert.Throws(() => MigrationTally.Rehydrate( - Run, - MigrationPopulation.ProgrammeGuide, - 0, - 0, - 0, - 0)); - } - - [Fact] - public void WhatTheNewSystemIsShortOfIsOneOfTheThingsTheRecordKnowsToName() - { - Assert.Throws( - () => MigrationLoss.Rehydrate(Run, (MigrationLossSubject)9, 1)); - } - - [Theory] - [InlineData(MigrationLossSubject.DuplicateAvoidance)] - [InlineData(MigrationLossSubject.EnclosedCharacters)] - [InlineData(MigrationLossSubject.DayBoundary)] - [InlineData(MigrationLossSubject.MarkWords)] - public void ALossIsSomethingCarriedThatArrivedDiminished(MigrationLossSubject subject) - { - Assert.Equal(subject, MigrationLoss.Rehydrate(Run, subject, 17).Subject); - } - - [Fact] - public void ALossCountsNothingNegative() - => Assert.Throws( - () => MigrationLoss.Rehydrate(Run, MigrationLossSubject.EnclosedCharacters, -1)); - - [Fact] - public void ALossOfNothingAtAllIsStillCountedRatherThanLeftUnsaid() - => Assert.Equal(0, MigrationLoss.Rehydrate(Run, MigrationLossSubject.DuplicateAvoidance, 0).Affected); - - [Fact] - public void TheRecordIsOnlyEverShortOfWhatTheRunKnowsHowToCount() - { - Assert.Equal(MigrationLossSubjects.All.Count, MigrationLoss.EveryOne(Run, new([], [], 0, 0, 0, 0)).Count); - } - - [Fact] - public void EveryLossSaysHowManyOfWhatWasCarriedItReaches() - { - MigrationAftermath aftermath = new([], [], 17, 48, 5, 2); - - IReadOnlyList told = MigrationLoss.EveryOne(Run, aftermath); - - Assert.Equal( - 17, - told.Single(loss => loss.Subject is MigrationLossSubject.DuplicateAvoidance).Affected); - Assert.Equal( - 48, - told.Single(loss => loss.Subject is MigrationLossSubject.EnclosedCharacters).Affected); - Assert.Equal( - 5, - told.Single(loss => loss.Subject is MigrationLossSubject.DayBoundary).Affected); - Assert.Equal( - 2, - told.Single(loss => loss.Subject is MigrationLossSubject.MarkWords).Affected); - } - - [Fact] - public void AJudgementThatCarriedHasNoLineAmongWhatWasLeftBehind() - { - Assert.Throws( - () => MigrationDetail.Of(Run, Carried(MigrationPopulation.Recordings, "7"))); - } - - [Fact] - public void ARunFinishesAfterItStarts() - { - Assert.Throws( - () => MigrationRun.Rehydrate(Run, Source, MigrationPass.Rehearsal, Ended, Began)); - } - - [Fact] - public void ARunKeepsItsTimesInOneTimeZone() - { - Assert.Throws(() => MigrationRun.Rehydrate( - Run, - Source, - MigrationPass.Rehearsal, - DateTime.SpecifyKind(Began, DateTimeKind.Local), - Ended)); - } - - [Fact] - public void ARunIsEitherARehearsalOrTheRealThing() - { - Assert.Throws( - () => MigrationRun.Rehydrate(Run, Source, (MigrationPass)9, Began, Ended)); - } - - [Fact] - public void ARunIdIsNeverEmpty() - { - Assert.Throws(() => new MigrationRunId(Guid.Empty)); - } - - [Fact] - public void ADetailIdIsNeverEmpty() - { - Assert.Throws(() => new MigrationDetailId(Guid.Empty)); - } - - [Fact] - public void AReasonIsOneTheRecordCanName() - { - Assert.Throws(() => MigrationVerdict.Refuse( - MigrationPopulation.Recordings, - "7", - (MigrationRefusal)99, - "a programme", - null, - null)); - } - - [Fact] - public void AJudgementNamesWhatItIsAbout() - { - Assert.Throws( - () => MigrationVerdict.Carry(MigrationPopulation.Recordings, string.Empty, "a programme", null, null)); - } - - [Fact] - public void AJudgementWeighsNothingSmallerThanEmpty() - { - Assert.Throws(() => MigrationVerdict.Carry( - MigrationPopulation.Recordings, - "7", - "a programme", - null, - -1)); - } -} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationRecordingArrivalTests.cs b/tests/Carina.Domain.Tests/Migration/MigrationRecordingArrivalTests.cs deleted file mode 100644 index 33c52023d..000000000 --- a/tests/Carina.Domain.Tests/Migration/MigrationRecordingArrivalTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Carina.Domain.Channels; -using Carina.Domain.Migration; -using Carina.Domain.Reservations; - -using static Carina.Domain.Tests.Migration.MigrationFixtures; - -namespace Carina.Domain.Tests.Migration; - -public sealed class MigrationRecordingArrivalTests -{ - [Fact(DisplayName = "BR-MS-001: a recording the source system says ended on the instant it began is not carried, as a shape this system cannot take")] - public void ARecordingThatLastsNoTimeIsNotCarried() - { - MigrationVerdict judged = MigrationClassifier.OnARecording( - new SourceRecording(7, "a programme", Began, Began, InReach, Programme), - AsBroadcast(7, "one.m2ts", 100), - OnDisk("one.m2ts", 90)); - - Assert.False(judged.Carried); - Assert.Equal(MigrationRefusal.Inexpressible, judged.Refusal); - Assert.Equal("a programme", judged.Note); - Assert.Equal(100, judged.Claimed); - Assert.Equal(90, judged.Observed); - } - - [Fact(DisplayName = "BR-MS-001: a recording that lasts a single tick is carried")] - public void ARecordingThatLastsATickIsCarried() - { - MigrationVerdict judged = MigrationClassifier.OnARecording( - new SourceRecording(7, "a programme", Began, Began.AddTicks(1), InReach, Programme), - AsBroadcast(7, "one.m2ts", 100), - OnDisk("one.m2ts", 100)); - - Assert.True(judged.Carried); - } - - [Fact(DisplayName = "BR-MS-001: a recording that names no programme and lasts no time is not carried because nothing identifies it")] - public void WhatIdentifiesARecordingIsAskedBeforeHowLongItLasts() - { - MigrationVerdict judged = MigrationClassifier.OnARecording( - new SourceRecording(7, "a programme", Began, Began, InReach, null), - AsBroadcast(7, "one.m2ts", 100), - OnDisk("one.m2ts", 100)); - - Assert.Equal(MigrationRefusal.Unidentifiable, judged.Refusal); - } - - [Fact(DisplayName = "BR-MS-001: a recording that lasts no time and has no file on the disk is refused for the missing file, which is what stops it first")] - public void AMissingFileIsAskedBeforeHowLongTheRecordingLasts() - { - MigrationVerdict judged = MigrationClassifier.OnARecording( - new SourceRecording(7, "a programme", Began, Began, InReach, Programme), - AsBroadcast(7, "one.m2ts", 100), - null); - - Assert.Equal(MigrationRefusal.FileMissing, judged.Refusal); - } - - [Fact(DisplayName = "a recording that names its service and programme names the programme as the guide here does, from the moment it began")] - public void ARecordingThatNamesItsServiceAndProgrammeNamesTheProgramme() - { - ProgrammeRef? programme = Recording(7).Programmed; - - Assert.Equal( - new ProgrammeRef(new NetworkId(32736), new ServiceId(1024), Programme, Began), - programme); - } - - [Fact(DisplayName = "a recording that names no service or no programme names no programme")] - public void ARecordingThatNamesNoServiceOrNoProgrammeNamesNone() - { - Assert.Null(Recording(7, null).Programmed); - Assert.Null(RecordingOfNoProgramme(7).Programmed); - } - - [Fact(DisplayName = "BR-MD-001: the file of a recording that is carried is the one kept as broadcast, and an encoded copy beside it is not")] - public void TheFileCarriedIsTheOneKeptAsBroadcast() - { - IReadOnlyDictionary carried = MigrationClassifier.AsBroadcastByRecording( - [Encoded(7, "one.mp4", 50), AsBroadcast(7, "one.m2ts", 100), Encoded(8, "two.mp4", 50)]); - - Assert.Single(carried); - Assert.Equal("one.m2ts", carried[7].Path); - } - - [Fact(DisplayName = "BR-MD-001: a recording the source system names two files as broadcast for is refused rather than carried twice")] - public void TwoFilesAsBroadcastForOneRecordingAreRefused() - => Assert.Throws(() => MigrationClassifier.AsBroadcastByRecording( - [AsBroadcast(7, "one.m2ts", 100), AsBroadcast(7, "again.m2ts", 100)])); -} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationRuleMarkWordsTests.cs b/tests/Carina.Domain.Tests/Migration/MigrationRuleMarkWordsTests.cs deleted file mode 100644 index a85194269..000000000 --- a/tests/Carina.Domain.Tests/Migration/MigrationRuleMarkWordsTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Carina.Domain.Migration; -using Carina.Domain.Programmes; - -using static Carina.Domain.Tests.Migration.MigrationFixtures; - -namespace Carina.Domain.Tests.Migration; - -public sealed class MigrationRuleMarkWordsTests -{ - [Fact] - public void ARuleLookingForTheNewMarkAndLeavingOutTheRerunLetterCarriesBothAsMarks() - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule( - 3, - Terms( - keyword: "[新]", - excluded: "再", - kinds: [SourceBroadcastKind.Terrestrial], - genres: [new SourceRuleGenre(7, 0)]), - SourceRuleReach.Plain), - Rescanned()); - - Assert.True(carried.Expressible); - Assert.True(carried.MarkWordsReplaced); - Assert.Equal("subgenre=7-0&mark=New&excludeMark=Rerun&type=IsdbT", carried.Query?.Value); - } - - [Theory] - [InlineData("\U0001F21F", "mark=New")] - [InlineData("[再]", "mark=Rerun")] - [InlineData("[終]", "mark=Final")] - [InlineData("【初】", "mark=Premiere")] - [InlineData("[HV]", "mark=HighDefinition")] - [InlineData("[SS]", "mark=SurroundStereo")] - [InlineData("[ppv]", "mark=PayPerView")] - [InlineData("[鍵]", "mark=ParentalLock")] - [InlineData("⚿", "mark=ParentalLock")] - [InlineData("字", "mark=Captioned")] - [InlineData("[字] [新]", "mark=Captioned&mark=New")] - [InlineData("[新][字]", "mark=Captioned&mark=New")] - public void AWordThatIsOnlyAMarkBecomesTheMark(string keyword, string expected) - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, Terms(keyword: keyword), SourceRuleReach.Plain), - Rescanned()); - - Assert.True(carried.MarkWordsReplaced); - Assert.Equal(expected, carried.Query?.Value); - } - - [Theory] - [InlineData("[新]アニメ", "keyword=%E3%82%A2%E3%83%8B%E3%83%A1&mark=New")] - [InlineData("アニメ\U0001F21F", "keyword=%E3%82%A2%E3%83%8B%E3%83%A1&mark=New")] - [InlineData("アニメ[新]特集", "keyword=%E3%82%A2%E3%83%8B%E3%83%A1%20%E7%89%B9%E9%9B%86&mark=New")] - [InlineData("[新] hill", "keyword=hill&mark=New")] - public void AMarkWrittenAmongWordsIsTakenOutAndTheWordsAroundItStay(string keyword, string expected) - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, Terms(keyword: keyword), SourceRuleReach.Plain), - Rescanned()); - - Assert.True(carried.MarkWordsReplaced); - Assert.Equal(expected, carried.Query?.Value); - } - - [Theory] - [InlineData("新宿 hill", "keyword=%E6%96%B0%E5%AE%BF%20hill")] - [InlineData("hill 再会", "keyword=hill%20%E5%86%8D%E4%BC%9A")] - [InlineData("S hill", "keyword=S%20hill")] - [InlineData("[hill]", "keyword=%5Bhill%5D")] - [InlineData("[新 hill", "keyword=%5B%E6%96%B0%20hill")] - public void LettersThatOnlyLookLikeAMarkStayWords(string keyword, string expected) - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, Terms(keyword: keyword), SourceRuleReach.Plain), - Rescanned()); - - Assert.False(carried.MarkWordsReplaced); - Assert.Equal(expected, carried.Query?.Value); - } - - [Fact] - public void AMarkLeftOutOfOtherFieldsThanTheWordsAreLookedForInStillCrossesOverBecauseAMarkHasNoField() - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule( - 3, - Terms( - keyword: "hill", - excluded: "[再]", - fields: SourceRuleFields.Title | SourceRuleFields.Summary, - excludedFields: SourceRuleFields.Title), - SourceRuleReach.Plain), - Rescanned()); - - Assert.True(carried.Expressible); - Assert.Equal("keyword=hill&excludeMark=Rerun", carried.Query?.Value); - } - - [Fact] - public void ARuleLeftWithOnlyMarksSaysNothingAboutWhereToLookForWords() - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, Terms(keyword: "[新]", fields: SourceRuleFields.Title), SourceRuleReach.Plain), - Rescanned()); - - Assert.Equal("mark=New", carried.Query?.Value); - } - - [Theory] - [InlineData("夏", "")] - [InlineData("hill", "再 夏")] - [InlineData("S", "")] - public void ARuleWhoseWordsWouldNotReadAsASearchIsNotCarriedRatherThanCarriedAndTurnedOff( - string keyword, - string excluded) - { - MigrationRuleConversion refused = MigrationRuleConversion.Of( - Rule(3, Terms(keyword: keyword, excluded: excluded), SourceRuleReach.Plain), - Rescanned()); - - Assert.Equal(MigrationRefusal.Inexpressible, refused.Refusal); - Assert.Null(refused.Query); - Assert.False(refused.MarkWordsReplaced); - } - - [Fact] - public void ARuleWithNoMarkWordReplacesNothing() - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, Terms(keyword: "hill", excluded: "repeat"), SourceRuleReach.Plain), - Rescanned()); - - Assert.False(carried.MarkWordsReplaced); - } - - [Fact] - public void TheRulesCarriedWithTheirMarkWordsReplacedAreCountedOverTheWholeLedger() - { - SourceLedger ledger = Ledger(rules: - [ - Rule(3, Terms(keyword: "[新]", excluded: "再"), SourceRuleReach.Plain), - Rule(4, Terms(keyword: "\U0001F21Fhill"), SourceRuleReach.Plain), - Rule(5, Terms(keyword: "hill"), SourceRuleReach.Plain), - Rule(6, Terms(keyword: "[新]"), SourceRuleReach.Plain with { UsesRegularExpression = true }), - Rule(7, Terms(keyword: "[新] 夏"), SourceRuleReach.Plain with { CaseSensitive = true }), - ]); - - Assert.Equal(2, MigrationRuleConversion.RulesWithMarkWordsReplaced(ledger, Rescanned())); - } - - [Fact] - public void EveryMarkCanBeAskedForByItsLettersInBrackets() - { - foreach (ProgrammeMarkSymbol symbol in ProgrammeMarks.Symbols.Where(symbol => symbol.Mark is not ProgrammeMark.ParentalLock)) - { - string letters = ProgrammeSearchText.Folded(symbol.Symbol); - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, Terms(keyword: $"[{letters}]"), SourceRuleReach.Plain), - Rescanned()); - - Assert.Equal($"mark={symbol.Mark}", carried.Query?.Value); - } - } -} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationSourceShapeTests.cs b/tests/Carina.Domain.Tests/Migration/MigrationSourceShapeTests.cs deleted file mode 100644 index 693aeb3c7..000000000 --- a/tests/Carina.Domain.Tests/Migration/MigrationSourceShapeTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Carina.Domain.Migration; - -using static Carina.Domain.Tests.Migration.MigrationFixtures; - -namespace Carina.Domain.Tests.Migration; - -public sealed class MigrationSourceShapeTests -{ - [Fact] - public void APathIsReadFromTheSourceOutputDirectoryDown() - { - Assert.Throws(() => new SourceFile("/elsewhere/one.m2ts", 100)); - Assert.Throws( - () => new SourceRecordingFile(7, "/elsewhere/one.m2ts", SourceFileKind.AsBroadcast, 100)); - } - - [Fact] - public void AFileIsNotSmallerThanEmpty() - { - Assert.Throws(() => new SourceFile("one.m2ts", -1)); - } - - [Fact] - public void ARowOfTheSourceSystemIsNumberedFromOne() - { - Assert.Throws(() => Recording(0, InReach)); - Assert.Throws( - () => new SourceRecordingFile(0, "one.m2ts", SourceFileKind.AsBroadcast, 100)); - } - - [Fact] - public void ARecordingEndsAfterItStarts() - { - Assert.Throws(() => new SourceRecording(7, "a programme", Ended, Began, InReach, Programme)); - } - - [Fact] - public void ATwoPartServiceIdentityIsTheSameWhicheverObjectHoldsIt() - { - Assert.Equal(ServiceKey.Of(32736, 1024), ServiceKey.Of(32736, 1024)); - Assert.NotEqual(ServiceKey.Of(32736, 1024), ServiceKey.Of(32736, 1025)); - } - - [Fact] - public void ASnapshotOfTheSourceHoldsTheFivePopulationsItRead() - { - SourceLedger read = Ledger( - recordings: [Recording(7)], - files: [AsBroadcast(7, "one.m2ts", 100)], - rules: [Rule(3)], - reservations: [Reservation(5, fromARule: true)], - channels: [Channel(11, SourceBroadcastKind.Terrestrial, InReach)]); - - Assert.Equal(Source, read.Name); - Assert.Single(read.Recordings); - Assert.Single(read.RecordingFiles); - Assert.Single(read.Rules); - Assert.Single(read.Reservations); - Assert.Single(read.ChannelDefinitions); - } -} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationSurveyTests.cs b/tests/Carina.Domain.Tests/Migration/MigrationSurveyTests.cs deleted file mode 100644 index cb28ff49f..000000000 --- a/tests/Carina.Domain.Tests/Migration/MigrationSurveyTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Carina.Domain.Migration; - -using static Carina.Domain.Tests.Migration.MigrationFixtures; - -namespace Carina.Domain.Tests.Migration; - -public sealed class MigrationSurveyTests -{ - [Fact] - public void EveryPopulationThatWasReadIsOfferedForJudgement() - { - MigrationRoll rolled = MigrationClassifier.Over( - Ledger( - recordings: [Recording(7)], - files: [AsBroadcast(7, "one.m2ts", 100)], - rules: [Rule(3)], - reservations: [Reservation(5, fromARule: true)], - channels: [Channel(11, SourceBroadcastKind.Terrestrial, InReach)]), - [OnDisk("one.m2ts", 100)], - Rescanned(InReach)); - - Assert.Equal(MigrationPopulations.Counted, rolled.Populations); - Assert.Equal(5, rolled.Verdicts.Count); - } - - [Fact] - public void TheWholeOfTheOutputDirectoryIsWalkedAndWhatNoRowNamesIsWrittenDown() - { - MigrationRoll rolled = MigrationClassifier.Over( - Ledger(recordings: [Recording(7)], files: [AsBroadcast(7, "one.m2ts", 100)]), - [OnDisk("one.m2ts", 100), OnDisk("notes.txt", 1_024), OnDisk("stray.m2ts", 700_000_000)], - Rescanned(InReach)); - - IReadOnlyList orphans = - [.. rolled.Verdicts.Where(verdict => verdict.Refusal is MigrationRefusal.Orphan)]; - - Assert.Equal(["notes.txt", "stray.m2ts"], orphans.Select(verdict => verdict.Subject)); - Assert.Equal(3, rolled.OfferedIn(MigrationPopulation.RecordingFiles)); - } - - [Fact] - public void AnOrphanIsWrittenDownAndNotCarried() - { - MigrationRoll rolled = MigrationClassifier.Over( - Ledger(), - [OnDisk("notes.txt", 1_024)], - Rescanned()); - - Assert.Single(rolled.Verdicts); - Assert.All(rolled.Verdicts, verdict => Assert.False(verdict.Carried)); - Assert.Equal(1, rolled.OfferedIn(MigrationPopulation.RecordingFiles)); - } - - [Fact] - public void ARecordingIsNeverAnsweredByAFileOfAnotherName() - { - MigrationRoll rolled = MigrationClassifier.Over( - Ledger(recordings: [Recording(7)], files: [AsBroadcast(7, "one.m2ts", 100)]), - [OnDisk("another.m2ts", 100)], - Rescanned(InReach)); - - MigrationVerdict recording = rolled.Verdicts.Single( - verdict => verdict.Population is MigrationPopulation.Recordings); - - Assert.Equal(MigrationRefusal.FileMissing, recording.Refusal); - } - - [Fact] - public void ARecordingCannotNameTwoFilesAsBroadcast() - { - ArgumentException refused = Assert.Throws(() => MigrationClassifier.Over( - Ledger( - recordings: [Recording(7)], - files: [AsBroadcast(7, "one.m2ts", 100), AsBroadcast(7, "two.m2ts", 100)]), - [], - Rescanned())); - - Assert.Contains("two files as broadcast", refused.Message, StringComparison.Ordinal); - } - - [Fact] - public void TwoRowsCannotClaimTheSameFile() - { - Assert.Throws(() => MigrationClassifier.Over( - Ledger(files: [AsBroadcast(7, "one.m2ts", 100), Encoded(8, "one.m2ts", 100)]), - [], - Rescanned())); - } - - [Fact] - public void ADirectoryCannotHoldTheSamePathTwice() - { - Assert.Throws(() => MigrationClassifier.Over( - Ledger(), - [OnDisk("one.m2ts", 100), OnDisk("one.m2ts", 100)], - Rescanned())); - } - - [Fact] - public void JudgementsComeBackInAnOrderThatDoesNotMoveBetweenRuns() - { - MigrationRoll rolled = MigrationClassifier.Over( - Ledger( - recordings: [Recording(9), Recording(7)], - files: [AsBroadcast(9, "b.m2ts", 100), AsBroadcast(7, "a.m2ts", 100)]), - [OnDisk("b.m2ts", 100), OnDisk("a.m2ts", 100)], - Rescanned(InReach)); - - Assert.Equal( - [ - (MigrationPopulation.Recordings, "7"), - (MigrationPopulation.Recordings, "9"), - (MigrationPopulation.RecordingFiles, "a.m2ts"), - (MigrationPopulation.RecordingFiles, "b.m2ts"), - ], - rolled.Verdicts.Select(verdict => (verdict.Population, verdict.Subject))); - } - - [Fact] - public void AnEmptyFileNeverReachesTheNewSystemAndIsWrittenDownOnBothSidesOfTheSameFact() - { - MigrationRoll rolled = MigrationClassifier.Over( - Ledger(recordings: [Recording(7)], files: [AsBroadcast(7, "one.m2ts", 17_000_000_000)]), - [OnDisk("one.m2ts", 0)], - Rescanned(InReach)); - - Assert.Equal(2, rolled.Verdicts.Count); - Assert.All(rolled.Verdicts, verdict => Assert.Equal(MigrationRefusal.ReallyEmpty, verdict.Refusal)); - } -} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationCarriageDomainServiceTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationCarriageDomainServiceTests.cs similarity index 99% rename from tests/Carina.Domain.Tests/Migration/MigrationCarriageDomainServiceTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationCarriageDomainServiceTests.cs index 2284da9a9..8400dab56 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationCarriageDomainServiceTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationCarriageDomainServiceTests.cs @@ -6,8 +6,9 @@ using static Carina.TestSupport.CarriedMigrationFixtures; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationCarriageDomainServiceTests { private static readonly CancellationToken Cancel = CancellationToken.None; diff --git a/tests/Carina.Domain.Tests/Migration/MigrationCensusTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationCensusTests.cs similarity index 98% rename from tests/Carina.Domain.Tests/Migration/MigrationCensusTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationCensusTests.cs index e7b8a390c..27c609c43 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationCensusTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationCensusTests.cs @@ -1,10 +1,11 @@ using Carina.Domain.Encodings; using Carina.Domain.Migration; -using static Carina.Domain.Tests.Migration.MigrationFixtures; +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationCensusTests { [Fact] diff --git a/tests/Carina.Domain.Tests/Migration/MigrationChannelProposalTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationChannelProposalTests.cs similarity index 94% rename from tests/Carina.Domain.Tests/Migration/MigrationChannelProposalTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationChannelProposalTests.cs index acd3a253b..8ccaaa889 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationChannelProposalTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationChannelProposalTests.cs @@ -1,9 +1,10 @@ using Carina.Domain.Migration; -using static Carina.Domain.Tests.Migration.MigrationFixtures; +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationChannelProposalTests { [Fact] diff --git a/tests/Carina.Domain.Tests/Migration/MigrationClassifierTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationClassifierTests.cs similarity index 51% rename from tests/Carina.Domain.Tests/Migration/MigrationClassifierTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationClassifierTests.cs index 0a71433b3..dc9e06d01 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationClassifierTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationClassifierTests.cs @@ -1,9 +1,10 @@ using Carina.Domain.Migration; -using static Carina.Domain.Tests.Migration.MigrationFixtures; +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationClassifierTests { [Fact] @@ -251,4 +252,189 @@ public void AChannelTheRescanFoundAgainIsCarried(SourceBroadcastKind kind) Assert.True(judged.Carried); } + + [Fact] + public void EveryPopulationThatWasReadIsOfferedForJudgement() + { + MigrationRoll rolled = MigrationClassifier.Over( + Ledger( + recordings: [Recording(7)], + files: [AsBroadcast(7, "one.m2ts", 100)], + rules: [Rule(3)], + reservations: [Reservation(5, fromARule: true)], + channels: [Channel(11, SourceBroadcastKind.Terrestrial, InReach)]), + [OnDisk("one.m2ts", 100)], + Rescanned(InReach)); + + Assert.Equal(MigrationPopulations.Counted, rolled.Populations); + Assert.Equal(5, rolled.Verdicts.Count); + } + + [Fact] + public void TheWholeOfTheOutputDirectoryIsWalkedAndWhatNoRowNamesIsWrittenDown() + { + MigrationRoll rolled = MigrationClassifier.Over( + Ledger(recordings: [Recording(7)], files: [AsBroadcast(7, "one.m2ts", 100)]), + [OnDisk("one.m2ts", 100), OnDisk("notes.txt", 1_024), OnDisk("stray.m2ts", 700_000_000)], + Rescanned(InReach)); + + IReadOnlyList orphans = + [.. rolled.Verdicts.Where(verdict => verdict.Refusal is MigrationRefusal.Orphan)]; + + Assert.Equal(["notes.txt", "stray.m2ts"], orphans.Select(verdict => verdict.Subject)); + Assert.Equal(3, rolled.OfferedIn(MigrationPopulation.RecordingFiles)); + } + + [Fact] + public void AnOrphanIsWrittenDownAndNotCarried() + { + MigrationRoll rolled = MigrationClassifier.Over( + Ledger(), + [OnDisk("notes.txt", 1_024)], + Rescanned()); + + Assert.Single(rolled.Verdicts); + Assert.All(rolled.Verdicts, verdict => Assert.False(verdict.Carried)); + Assert.Equal(1, rolled.OfferedIn(MigrationPopulation.RecordingFiles)); + } + + [Fact] + public void ARecordingIsNeverAnsweredByAFileOfAnotherName() + { + MigrationRoll rolled = MigrationClassifier.Over( + Ledger(recordings: [Recording(7)], files: [AsBroadcast(7, "one.m2ts", 100)]), + [OnDisk("another.m2ts", 100)], + Rescanned(InReach)); + + MigrationVerdict recording = rolled.Verdicts.Single( + verdict => verdict.Population is MigrationPopulation.Recordings); + + Assert.Equal(MigrationRefusal.FileMissing, recording.Refusal); + } + + [Fact] + public void ARecordingCannotNameTwoFilesAsBroadcast() + { + ArgumentException refused = Assert.Throws(() => MigrationClassifier.Over( + Ledger( + recordings: [Recording(7)], + files: [AsBroadcast(7, "one.m2ts", 100), AsBroadcast(7, "two.m2ts", 100)]), + [], + Rescanned())); + + Assert.Contains("two files as broadcast", refused.Message, StringComparison.Ordinal); + } + + [Fact] + public void TwoRowsCannotClaimTheSameFile() + { + Assert.Throws(() => MigrationClassifier.Over( + Ledger(files: [AsBroadcast(7, "one.m2ts", 100), Encoded(8, "one.m2ts", 100)]), + [], + Rescanned())); + } + + [Fact] + public void ADirectoryCannotHoldTheSamePathTwice() + { + Assert.Throws(() => MigrationClassifier.Over( + Ledger(), + [OnDisk("one.m2ts", 100), OnDisk("one.m2ts", 100)], + Rescanned())); + } + + [Fact] + public void JudgementsComeBackInAnOrderThatDoesNotMoveBetweenRuns() + { + MigrationRoll rolled = MigrationClassifier.Over( + Ledger( + recordings: [Recording(9), Recording(7)], + files: [AsBroadcast(9, "b.m2ts", 100), AsBroadcast(7, "a.m2ts", 100)]), + [OnDisk("b.m2ts", 100), OnDisk("a.m2ts", 100)], + Rescanned(InReach)); + + Assert.Equal( + [ + (MigrationPopulation.Recordings, "7"), + (MigrationPopulation.Recordings, "9"), + (MigrationPopulation.RecordingFiles, "a.m2ts"), + (MigrationPopulation.RecordingFiles, "b.m2ts"), + ], + rolled.Verdicts.Select(verdict => (verdict.Population, verdict.Subject))); + } + + [Fact] + public void AnEmptyFileNeverReachesTheNewSystemAndIsWrittenDownOnBothSidesOfTheSameFact() + { + MigrationRoll rolled = MigrationClassifier.Over( + Ledger(recordings: [Recording(7)], files: [AsBroadcast(7, "one.m2ts", 17_000_000_000)]), + [OnDisk("one.m2ts", 0)], + Rescanned(InReach)); + + Assert.Equal(2, rolled.Verdicts.Count); + Assert.All(rolled.Verdicts, verdict => Assert.Equal(MigrationRefusal.ReallyEmpty, verdict.Refusal)); + } + + [Fact(DisplayName = "BR-MS-001: a recording the source system says ended on the instant it began is not carried, as a shape this system cannot take")] + public void ARecordingThatLastsNoTimeIsNotCarried() + { + MigrationVerdict judged = MigrationClassifier.OnARecording( + new SourceRecording(7, "a programme", Began, Began, InReach, Programme), + AsBroadcast(7, "one.m2ts", 100), + OnDisk("one.m2ts", 90)); + + Assert.False(judged.Carried); + Assert.Equal(MigrationRefusal.Inexpressible, judged.Refusal); + Assert.Equal("a programme", judged.Note); + Assert.Equal(100, judged.Claimed); + Assert.Equal(90, judged.Observed); + } + + [Fact(DisplayName = "BR-MS-001: a recording that lasts a single tick is carried")] + public void ARecordingThatLastsATickIsCarried() + { + MigrationVerdict judged = MigrationClassifier.OnARecording( + new SourceRecording(7, "a programme", Began, Began.AddTicks(1), InReach, Programme), + AsBroadcast(7, "one.m2ts", 100), + OnDisk("one.m2ts", 100)); + + Assert.True(judged.Carried); + } + + [Fact(DisplayName = "BR-MS-001: a recording that names no programme and lasts no time is not carried because nothing identifies it")] + public void WhatIdentifiesARecordingIsAskedBeforeHowLongItLasts() + { + MigrationVerdict judged = MigrationClassifier.OnARecording( + new SourceRecording(7, "a programme", Began, Began, InReach, null), + AsBroadcast(7, "one.m2ts", 100), + OnDisk("one.m2ts", 100)); + + Assert.Equal(MigrationRefusal.Unidentifiable, judged.Refusal); + } + + [Fact(DisplayName = "BR-MS-001: a recording that lasts no time and has no file on the disk is refused for the missing file, which is what stops it first")] + public void AMissingFileIsAskedBeforeHowLongTheRecordingLasts() + { + MigrationVerdict judged = MigrationClassifier.OnARecording( + new SourceRecording(7, "a programme", Began, Began, InReach, Programme), + AsBroadcast(7, "one.m2ts", 100), + null); + + Assert.Equal(MigrationRefusal.FileMissing, judged.Refusal); + } + + [Fact(DisplayName = "BR-MD-001: the file of a recording that is carried is the one kept as broadcast, and an encoded copy beside it is not")] + public void TheFileCarriedIsTheOneKeptAsBroadcast() + { + IReadOnlyDictionary carried = MigrationClassifier.AsBroadcastByRecording( + [Encoded(7, "one.mp4", 50), AsBroadcast(7, "one.m2ts", 100), Encoded(8, "two.mp4", 50)]); + + Assert.Single(carried); + Assert.Equal("one.m2ts", carried[7].Path); + } + + [Fact(DisplayName = "BR-MD-001: a recording the source system names two files as broadcast for is refused rather than carried twice")] + public void TwoFilesAsBroadcastForOneRecordingAreRefused() + => Assert.Throws(() => MigrationClassifier.AsBroadcastByRecording( + [AsBroadcast(7, "one.m2ts", 100), AsBroadcast(7, "again.m2ts", 100)])); } diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailIdTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailIdTests.cs new file mode 100644 index 000000000..ad186c128 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailIdTests.cs @@ -0,0 +1,13 @@ +using Carina.Domain.Migration; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationDetailIdTests +{ + [Fact] + public void ADetailIdIsNeverEmpty() + { + Assert.Throws(() => new MigrationDetailId(Guid.Empty)); + } +} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationRecordReadingTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailQueryTests.cs similarity index 51% rename from tests/Carina.Domain.Tests/Migration/MigrationRecordReadingTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailQueryTests.cs index ef6abf40c..151c9d278 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationRecordReadingTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailQueryTests.cs @@ -1,29 +1,10 @@ using Carina.Domain.Migration; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; -public sealed class MigrationRecordReadingTests +[Trait("Category", "Unit")] +public sealed class MigrationDetailQueryTests { - [Fact] - public void EveryReasonForLeavingSomethingBehindIsNamedEvenWhenNothingWasLeftForIt() - { - IReadOnlyList counted = MigrationRefusalCount.EveryOne( - new Dictionary { [MigrationRefusal.Orphan] = 5 }); - - Assert.Equal( - [.. MigrationRefusals.All], - counted.Select(one => one.Refusal).ToArray()); - Assert.Equal(5, counted.Single(one => one.Refusal is MigrationRefusal.Orphan).Count); - Assert.All( - counted.Where(one => one.Refusal is not MigrationRefusal.Orphan), - one => Assert.Equal(0, one.Count)); - } - - [Fact] - public void AReasonTheRecordCannotNameIsRefusedRatherThanCountedAsOneOfTheSeven() - => Assert.Throws( - () => MigrationRefusalCount.EveryOne(new Dictionary { [(MigrationRefusal)99] = 1 })); - [Fact] public void AskingForNoPageAtAllReadsAsTheFirstOne() { diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailTests.cs new file mode 100644 index 000000000..7783b1157 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationDetailTests.cs @@ -0,0 +1,16 @@ +using Carina.Domain.Migration; + +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationDetailTests +{ + [Fact] + public void AJudgementThatCarriedHasNoLineAmongWhatWasLeftBehind() + { + Assert.Throws( + () => MigrationDetail.Of(Run, Carried(MigrationPopulation.Recordings, "7"))); + } +} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationFixtures.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationFixtures.cs similarity index 99% rename from tests/Carina.Domain.Tests/Migration/MigrationFixtures.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationFixtures.cs index 5c299c407..480445bfe 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationFixtures.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationFixtures.cs @@ -5,7 +5,7 @@ using Carina.Domain.Migration; using Carina.Domain.Programmes; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; internal static class MigrationFixtures { diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationLossTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationLossTests.cs new file mode 100644 index 000000000..825426b0c --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationLossTests.cs @@ -0,0 +1,62 @@ +using Carina.Domain.Migration; + +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationLossTests +{ + [Fact] + public void WhatTheNewSystemIsShortOfIsOneOfTheThingsTheRecordKnowsToName() + { + Assert.Throws( + () => MigrationLoss.Rehydrate(Run, (MigrationLossSubject)9, 1)); + } + + [Theory] + [InlineData(MigrationLossSubject.DuplicateAvoidance)] + [InlineData(MigrationLossSubject.EnclosedCharacters)] + [InlineData(MigrationLossSubject.DayBoundary)] + [InlineData(MigrationLossSubject.MarkWords)] + public void ALossIsSomethingCarriedThatArrivedDiminished(MigrationLossSubject subject) + { + Assert.Equal(subject, MigrationLoss.Rehydrate(Run, subject, 17).Subject); + } + + [Fact] + public void ALossCountsNothingNegative() + => Assert.Throws( + () => MigrationLoss.Rehydrate(Run, MigrationLossSubject.EnclosedCharacters, -1)); + + [Fact] + public void ALossOfNothingAtAllIsStillCountedRatherThanLeftUnsaid() + => Assert.Equal(0, MigrationLoss.Rehydrate(Run, MigrationLossSubject.DuplicateAvoidance, 0).Affected); + + [Fact] + public void TheRecordIsOnlyEverShortOfWhatTheRunKnowsHowToCount() + { + Assert.Equal(MigrationLossSubjects.All.Count, MigrationLoss.EveryOne(Run, new([], [], 0, 0, 0, 0)).Count); + } + + [Fact] + public void EveryLossSaysHowManyOfWhatWasCarriedItReaches() + { + MigrationAftermath aftermath = new([], [], 17, 48, 5, 2); + + IReadOnlyList told = MigrationLoss.EveryOne(Run, aftermath); + + Assert.Equal( + 17, + told.Single(loss => loss.Subject is MigrationLossSubject.DuplicateAvoidance).Affected); + Assert.Equal( + 48, + told.Single(loss => loss.Subject is MigrationLossSubject.EnclosedCharacters).Affected); + Assert.Equal( + 5, + told.Single(loss => loss.Subject is MigrationLossSubject.DayBoundary).Affected); + Assert.Equal( + 2, + told.Single(loss => loss.Subject is MigrationLossSubject.MarkWords).Affected); + } +} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationNoteTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationNoteTests.cs similarity index 95% rename from tests/Carina.Domain.Tests/Migration/MigrationNoteTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationNoteTests.cs index 4aee552e8..2ec3ca10f 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationNoteTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationNoteTests.cs @@ -1,7 +1,8 @@ using Carina.Domain.Migration; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationNoteTests { [Fact] diff --git a/tests/Carina.Domain.Tests/Migration/MigrationPreconditionsTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationPreconditionsTests.cs similarity index 97% rename from tests/Carina.Domain.Tests/Migration/MigrationPreconditionsTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationPreconditionsTests.cs index 7f9aa287e..7137d4f77 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationPreconditionsTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationPreconditionsTests.cs @@ -2,10 +2,11 @@ using Carina.Domain.Migration; using Carina.Domain.Recordings; -using static Carina.Domain.Tests.Migration.MigrationFixtures; +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationPreconditionsTests { private static readonly EncodeProfile Profile = EncodeProfile.Define( diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationRefusalCountTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRefusalCountTests.cs new file mode 100644 index 000000000..fdd3e31a7 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRefusalCountTests.cs @@ -0,0 +1,27 @@ +using Carina.Domain.Migration; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationRefusalCountTests +{ + [Fact] + public void EveryReasonForLeavingSomethingBehindIsNamedEvenWhenNothingWasLeftForIt() + { + IReadOnlyList counted = MigrationRefusalCount.EveryOne( + new Dictionary { [MigrationRefusal.Orphan] = 5 }); + + Assert.Equal( + [.. MigrationRefusals.All], + counted.Select(one => one.Refusal).ToArray()); + Assert.Equal(5, counted.Single(one => one.Refusal is MigrationRefusal.Orphan).Count); + Assert.All( + counted.Where(one => one.Refusal is not MigrationRefusal.Orphan), + one => Assert.Equal(0, one.Count)); + } + + [Fact] + public void AReasonTheRecordCannotNameIsRefusedRatherThanCountedAsOneOfTheSeven() + => Assert.Throws( + () => MigrationRefusalCount.EveryOne(new Dictionary { [(MigrationRefusal)99] = 1 })); +} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationReportTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationReportTests.cs similarity index 98% rename from tests/Carina.Domain.Tests/Migration/MigrationReportTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationReportTests.cs index 7bab830bb..0176777b6 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationReportTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationReportTests.cs @@ -2,10 +2,11 @@ using Carina.Domain.Encodings; using Carina.Domain.Migration; -using static Carina.Domain.Tests.Migration.MigrationFixtures; +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationReportTests { private static readonly MigrationRunId Another = new(new Guid("00000001-0000-0000-0000-000000000002")); diff --git a/tests/Carina.Domain.Tests/Migration/MigrationRuleConversionTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRuleConversionTests.cs similarity index 66% rename from tests/Carina.Domain.Tests/Migration/MigrationRuleConversionTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationRuleConversionTests.cs index b2e6db94e..303212acf 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationRuleConversionTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRuleConversionTests.cs @@ -3,10 +3,11 @@ using Carina.Domain.Migration; using Carina.Domain.Programmes; -using static Carina.Domain.Tests.Migration.MigrationFixtures; +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationRuleConversionTests { private static readonly SourceRuleReach Inexpressible = @@ -342,4 +343,161 @@ public void TheNameOfAConvertedRuleIsWhatTheSourceSystemCalledIt() Assert.Equal("an evening walk", carried.Name); } + + [Fact] + public void ARuleLookingForTheNewMarkAndLeavingOutTheRerunLetterCarriesBothAsMarks() + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule( + 3, + Terms( + keyword: "[新]", + excluded: "再", + kinds: [SourceBroadcastKind.Terrestrial], + genres: [new SourceRuleGenre(7, 0)]), + SourceRuleReach.Plain), + Rescanned()); + + Assert.True(carried.Expressible); + Assert.True(carried.MarkWordsReplaced); + Assert.Equal("subgenre=7-0&mark=New&excludeMark=Rerun&type=IsdbT", carried.Query?.Value); + } + + [Theory] + [InlineData("\U0001F21F", "mark=New")] + [InlineData("[再]", "mark=Rerun")] + [InlineData("[終]", "mark=Final")] + [InlineData("【初】", "mark=Premiere")] + [InlineData("[HV]", "mark=HighDefinition")] + [InlineData("[SS]", "mark=SurroundStereo")] + [InlineData("[ppv]", "mark=PayPerView")] + [InlineData("[鍵]", "mark=ParentalLock")] + [InlineData("⚿", "mark=ParentalLock")] + [InlineData("字", "mark=Captioned")] + [InlineData("[字] [新]", "mark=Captioned&mark=New")] + [InlineData("[新][字]", "mark=Captioned&mark=New")] + public void AWordThatIsOnlyAMarkBecomesTheMark(string keyword, string expected) + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, Terms(keyword: keyword), SourceRuleReach.Plain), + Rescanned()); + + Assert.True(carried.MarkWordsReplaced); + Assert.Equal(expected, carried.Query?.Value); + } + + [Theory] + [InlineData("[新]アニメ", "keyword=%E3%82%A2%E3%83%8B%E3%83%A1&mark=New")] + [InlineData("アニメ\U0001F21F", "keyword=%E3%82%A2%E3%83%8B%E3%83%A1&mark=New")] + [InlineData("アニメ[新]特集", "keyword=%E3%82%A2%E3%83%8B%E3%83%A1%20%E7%89%B9%E9%9B%86&mark=New")] + [InlineData("[新] hill", "keyword=hill&mark=New")] + public void AMarkWrittenAmongWordsIsTakenOutAndTheWordsAroundItStay(string keyword, string expected) + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, Terms(keyword: keyword), SourceRuleReach.Plain), + Rescanned()); + + Assert.True(carried.MarkWordsReplaced); + Assert.Equal(expected, carried.Query?.Value); + } + + [Theory] + [InlineData("新宿 hill", "keyword=%E6%96%B0%E5%AE%BF%20hill")] + [InlineData("hill 再会", "keyword=hill%20%E5%86%8D%E4%BC%9A")] + [InlineData("S hill", "keyword=S%20hill")] + [InlineData("[hill]", "keyword=%5Bhill%5D")] + [InlineData("[新 hill", "keyword=%5B%E6%96%B0%20hill")] + public void LettersThatOnlyLookLikeAMarkStayWords(string keyword, string expected) + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, Terms(keyword: keyword), SourceRuleReach.Plain), + Rescanned()); + + Assert.False(carried.MarkWordsReplaced); + Assert.Equal(expected, carried.Query?.Value); + } + + [Fact] + public void AMarkLeftOutOfOtherFieldsThanTheWordsAreLookedForInStillCrossesOverBecauseAMarkHasNoField() + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule( + 3, + Terms( + keyword: "hill", + excluded: "[再]", + fields: SourceRuleFields.Title | SourceRuleFields.Summary, + excludedFields: SourceRuleFields.Title), + SourceRuleReach.Plain), + Rescanned()); + + Assert.True(carried.Expressible); + Assert.Equal("keyword=hill&excludeMark=Rerun", carried.Query?.Value); + } + + [Fact] + public void ARuleLeftWithOnlyMarksSaysNothingAboutWhereToLookForWords() + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, Terms(keyword: "[新]", fields: SourceRuleFields.Title), SourceRuleReach.Plain), + Rescanned()); + + Assert.Equal("mark=New", carried.Query?.Value); + } + + [Theory] + [InlineData("夏", "")] + [InlineData("hill", "再 夏")] + [InlineData("S", "")] + public void ARuleWhoseWordsWouldNotReadAsASearchIsNotCarriedRatherThanCarriedAndTurnedOff( + string keyword, + string excluded) + { + MigrationRuleConversion refused = MigrationRuleConversion.Of( + Rule(3, Terms(keyword: keyword, excluded: excluded), SourceRuleReach.Plain), + Rescanned()); + + Assert.Equal(MigrationRefusal.Inexpressible, refused.Refusal); + Assert.Null(refused.Query); + Assert.False(refused.MarkWordsReplaced); + } + + [Fact] + public void ARuleWithNoMarkWordReplacesNothing() + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, Terms(keyword: "hill", excluded: "repeat"), SourceRuleReach.Plain), + Rescanned()); + + Assert.False(carried.MarkWordsReplaced); + } + + [Fact] + public void TheRulesCarriedWithTheirMarkWordsReplacedAreCountedOverTheWholeLedger() + { + SourceLedger ledger = Ledger(rules: + [ + Rule(3, Terms(keyword: "[新]", excluded: "再"), SourceRuleReach.Plain), + Rule(4, Terms(keyword: "\U0001F21Fhill"), SourceRuleReach.Plain), + Rule(5, Terms(keyword: "hill"), SourceRuleReach.Plain), + Rule(6, Terms(keyword: "[新]"), SourceRuleReach.Plain with { UsesRegularExpression = true }), + Rule(7, Terms(keyword: "[新] 夏"), SourceRuleReach.Plain with { CaseSensitive = true }), + ]); + + Assert.Equal(2, MigrationRuleConversion.RulesWithMarkWordsReplaced(ledger, Rescanned())); + } + + [Fact] + public void EveryMarkCanBeAskedForByItsLettersInBrackets() + { + foreach (ProgrammeMarkSymbol symbol in ProgrammeMarks.Symbols.Where(symbol => symbol.Mark is not ProgrammeMark.ParentalLock)) + { + string letters = ProgrammeSearchText.Folded(symbol.Symbol); + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, Terms(keyword: $"[{letters}]"), SourceRuleReach.Plain), + Rescanned()); + + Assert.Equal($"mark={symbol.Mark}", carried.Query?.Value); + } + } } diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationRunIdTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRunIdTests.cs new file mode 100644 index 000000000..98eb27e4f --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRunIdTests.cs @@ -0,0 +1,13 @@ +using Carina.Domain.Migration; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationRunIdTests +{ + [Fact] + public void ARunIdIsNeverEmpty() + { + Assert.Throws(() => new MigrationRunId(Guid.Empty)); + } +} diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationRunTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRunTests.cs new file mode 100644 index 000000000..9b41f8109 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationRunTests.cs @@ -0,0 +1,34 @@ +using Carina.Domain.Migration; + +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationRunTests +{ + [Fact] + public void ARunFinishesAfterItStarts() + { + Assert.Throws( + () => MigrationRun.Rehydrate(Run, Source, MigrationPass.Rehearsal, Ended, Began)); + } + + [Fact] + public void ARunKeepsItsTimesInOneTimeZone() + { + Assert.Throws(() => MigrationRun.Rehydrate( + Run, + Source, + MigrationPass.Rehearsal, + DateTime.SpecifyKind(Began, DateTimeKind.Local), + Ended)); + } + + [Fact] + public void ARunIsEitherARehearsalOrTheRealThing() + { + Assert.Throws( + () => MigrationRun.Rehydrate(Run, Source, (MigrationPass)9, Began, Ended)); + } +} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationStandingTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationStandingTests.cs similarity index 98% rename from tests/Carina.Domain.Tests/Migration/MigrationStandingTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationStandingTests.cs index d5e06bc04..b69ee58e0 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationStandingTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationStandingTests.cs @@ -1,8 +1,9 @@ using Carina.Domain.Encodings; using Carina.Domain.Migration; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationStandingTests { private static readonly MigrationRunId Run = new(new Guid("00000001-0000-0000-0000-000000000001")); diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationTallyTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationTallyTests.cs new file mode 100644 index 000000000..16a20f0c9 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationTallyTests.cs @@ -0,0 +1,46 @@ +using Carina.Domain.Migration; + +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationTallyTests +{ + [Fact] + public void WhatAPopulationOfferedIsWhatItCarriedWhatItDidNotAndWhatNobodyCouldSay() + { + ArgumentException refused = Assert.Throws( + () => MigrationTally.Rehydrate(Run, MigrationPopulation.Recordings, 5, 2, 2, 0)); + + Assert.Contains("nobody could say", refused.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(-1, 0, 0, 0)] + [InlineData(0, -1, 0, 1)] + [InlineData(0, 0, -1, 1)] + [InlineData(0, 0, 1, -1)] + public void ARunCountsNothingNegative(int offered, int carried, int notCarried, int unclassified) + { + Assert.Throws(() => MigrationTally.Rehydrate( + Run, + MigrationPopulation.Recordings, + offered, + carried, + notCarried, + unclassified)); + } + + [Fact] + public void TheProgrammeGuideIsNeverCountedAsRowsOfItsOwn() + { + Assert.Throws(() => MigrationTally.Rehydrate( + Run, + MigrationPopulation.ProgrammeGuide, + 0, + 0, + 0, + 0)); + } +} diff --git a/tests/Carina.Domain.Tests/Migration/MigrationTextLossTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationTextLossTests.cs similarity index 93% rename from tests/Carina.Domain.Tests/Migration/MigrationTextLossTests.cs rename to tests/Carina.Domain.Tests/Unit/Migration/MigrationTextLossTests.cs index e083ac61a..19c015428 100644 --- a/tests/Carina.Domain.Tests/Migration/MigrationTextLossTests.cs +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationTextLossTests.cs @@ -1,7 +1,8 @@ using Carina.Domain.Migration; -namespace Carina.Domain.Tests.Migration; +namespace Carina.Domain.Tests.Unit.Migration; +[Trait("Category", "Unit")] public sealed class MigrationTextLossTests { [Theory] diff --git a/tests/Carina.Domain.Tests/Unit/Migration/MigrationVerdictTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/MigrationVerdictTests.cs new file mode 100644 index 000000000..02c84438e --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/MigrationVerdictTests.cs @@ -0,0 +1,37 @@ +using Carina.Domain.Migration; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationVerdictTests +{ + [Fact] + public void AReasonIsOneTheRecordCanName() + { + Assert.Throws(() => MigrationVerdict.Refuse( + MigrationPopulation.Recordings, + "7", + (MigrationRefusal)99, + "a programme", + null, + null)); + } + + [Fact] + public void AJudgementNamesWhatItIsAbout() + { + Assert.Throws( + () => MigrationVerdict.Carry(MigrationPopulation.Recordings, string.Empty, "a programme", null, null)); + } + + [Fact] + public void AJudgementWeighsNothingSmallerThanEmpty() + { + Assert.Throws(() => MigrationVerdict.Carry( + MigrationPopulation.Recordings, + "7", + "a programme", + null, + -1)); + } +} diff --git a/tests/Carina.Domain.Tests/Unit/Migration/ServiceKeyTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/ServiceKeyTests.cs new file mode 100644 index 000000000..318f7d060 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/ServiceKeyTests.cs @@ -0,0 +1,14 @@ +using Carina.Domain.Migration; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class ServiceKeyTests +{ + [Fact] + public void ATwoPartServiceIdentityIsTheSameWhicheverObjectHoldsIt() + { + Assert.Equal(ServiceKey.Of(32736, 1024), ServiceKey.Of(32736, 1024)); + Assert.NotEqual(ServiceKey.Of(32736, 1024), ServiceKey.Of(32736, 1025)); + } +} diff --git a/tests/Carina.Domain.Tests/Unit/Migration/SourceFileTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/SourceFileTests.cs new file mode 100644 index 000000000..781033679 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/SourceFileTests.cs @@ -0,0 +1,13 @@ +using Carina.Domain.Migration; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class SourceFileTests +{ + [Fact] + public void AFileIsNotSmallerThanEmpty() + { + Assert.Throws(() => new SourceFile("one.m2ts", -1)); + } +} diff --git a/tests/Carina.Domain.Tests/Unit/Migration/SourceLedgerTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/SourceLedgerTests.cs new file mode 100644 index 000000000..fe59f8a3d --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/SourceLedgerTests.cs @@ -0,0 +1,27 @@ +using Carina.Domain.Migration; + +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class SourceLedgerTests +{ + [Fact] + public void ASnapshotOfTheSourceHoldsTheFivePopulationsItRead() + { + SourceLedger read = Ledger( + recordings: [Recording(7)], + files: [AsBroadcast(7, "one.m2ts", 100)], + rules: [Rule(3)], + reservations: [Reservation(5, fromARule: true)], + channels: [Channel(11, SourceBroadcastKind.Terrestrial, InReach)]); + + Assert.Equal(Source, read.Name); + Assert.Single(read.Recordings); + Assert.Single(read.RecordingFiles); + Assert.Single(read.Rules); + Assert.Single(read.Reservations); + Assert.Single(read.ChannelDefinitions); + } +} diff --git a/tests/Carina.Domain.Tests/Unit/Migration/SourcePathTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/SourcePathTests.cs new file mode 100644 index 000000000..ca15f81aa --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/SourcePathTests.cs @@ -0,0 +1,15 @@ +using Carina.Domain.Migration; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class SourcePathTests +{ + [Fact] + public void APathIsReadFromTheSourceOutputDirectoryDown() + { + Assert.Throws(() => new SourceFile("/elsewhere/one.m2ts", 100)); + Assert.Throws( + () => new SourceRecordingFile(7, "/elsewhere/one.m2ts", SourceFileKind.AsBroadcast, 100)); + } +} diff --git a/tests/Carina.Domain.Tests/Unit/Migration/SourceRecordingTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/SourceRecordingTests.cs new file mode 100644 index 000000000..c10a21b72 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/SourceRecordingTests.cs @@ -0,0 +1,34 @@ +using Carina.Domain.Channels; +using Carina.Domain.Migration; +using Carina.Domain.Reservations; + +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class SourceRecordingTests +{ + [Fact] + public void ARecordingEndsAfterItStarts() + { + Assert.Throws(() => new SourceRecording(7, "a programme", Ended, Began, InReach, Programme)); + } + + [Fact(DisplayName = "a recording that names its service and programme names the programme as the guide here does, from the moment it began")] + public void ARecordingThatNamesItsServiceAndProgrammeNamesTheProgramme() + { + ProgrammeRef? programme = Recording(7).Programmed; + + Assert.Equal( + new ProgrammeRef(new NetworkId(32736), new ServiceId(1024), Programme, Began), + programme); + } + + [Fact(DisplayName = "a recording that names no service or no programme names no programme")] + public void ARecordingThatNamesNoServiceOrNoProgrammeNamesNone() + { + Assert.Null(Recording(7, null).Programmed); + Assert.Null(RecordingOfNoProgramme(7).Programmed); + } +} diff --git a/tests/Carina.Domain.Tests/Unit/Migration/SourceRowTests.cs b/tests/Carina.Domain.Tests/Unit/Migration/SourceRowTests.cs new file mode 100644 index 000000000..aecc2f348 --- /dev/null +++ b/tests/Carina.Domain.Tests/Unit/Migration/SourceRowTests.cs @@ -0,0 +1,17 @@ +using Carina.Domain.Migration; + +using static Carina.Domain.Tests.Unit.Migration.MigrationFixtures; + +namespace Carina.Domain.Tests.Unit.Migration; + +[Trait("Category", "Unit")] +public sealed class SourceRowTests +{ + [Fact] + public void ARowOfTheSourceSystemIsNumberedFromOne() + { + Assert.Throws(() => Recording(0, InReach)); + Assert.Throws( + () => new SourceRecordingFile(0, "one.m2ts", SourceFileKind.AsBroadcast, 100)); + } +} diff --git a/tests/Carina.Infrastructure.Tests/Migration/AMigrationThroughTheRealLedgerTests.cs b/tests/Carina.Infrastructure.Tests/AMigrationThroughTheRealLedgerTests.cs similarity index 98% rename from tests/Carina.Infrastructure.Tests/Migration/AMigrationThroughTheRealLedgerTests.cs rename to tests/Carina.Infrastructure.Tests/AMigrationThroughTheRealLedgerTests.cs index 17458f165..f1205ebad 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/AMigrationThroughTheRealLedgerTests.cs +++ b/tests/Carina.Infrastructure.Tests/AMigrationThroughTheRealLedgerTests.cs @@ -5,6 +5,7 @@ using Carina.Infrastructure.Migration; using Carina.Infrastructure.Persistence; using Carina.Infrastructure.Persistence.Repositories; +using Carina.Infrastructure.Tests.Fixtures.Migration; using Carina.TestSupport; using Microsoft.EntityFrameworkCore; @@ -13,7 +14,7 @@ using static Carina.TestSupport.CarriedMigrationFixtures; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests; [Collection(RepositoryDatabaseCollection.Name)] [Trait("Category", "DbIntegration")] diff --git a/tests/Carina.Infrastructure.Tests/Migration/AShelfCarriedOverStaysUnmeasuredTests.cs b/tests/Carina.Infrastructure.Tests/AShelfCarriedOverStaysUnmeasuredTests.cs similarity index 99% rename from tests/Carina.Infrastructure.Tests/Migration/AShelfCarriedOverStaysUnmeasuredTests.cs rename to tests/Carina.Infrastructure.Tests/AShelfCarriedOverStaysUnmeasuredTests.cs index 74651755c..192b0e331 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/AShelfCarriedOverStaysUnmeasuredTests.cs +++ b/tests/Carina.Infrastructure.Tests/AShelfCarriedOverStaysUnmeasuredTests.cs @@ -17,7 +17,7 @@ using static Carina.TestSupport.CarriedMigrationFixtures; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests; [Collection(RepositoryDatabaseCollection.Name)] [Trait("Category", "DbIntegration")] diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationRecordRepositoryTests.cs b/tests/Carina.Infrastructure.Tests/DbIntegration/Domain/Migration/MigrationRecordRepositoryTests.cs similarity index 91% rename from tests/Carina.Infrastructure.Tests/Migration/MigrationRecordRepositoryTests.cs rename to tests/Carina.Infrastructure.Tests/DbIntegration/Domain/Migration/MigrationRecordRepositoryTests.cs index f8e27832a..e02c6424c 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationRecordRepositoryTests.cs +++ b/tests/Carina.Infrastructure.Tests/DbIntegration/Domain/Migration/MigrationRecordRepositoryTests.cs @@ -1,18 +1,16 @@ using Carina.Domain.Base; using Carina.Domain.Encodings; using Carina.Domain.Migration; -using Carina.Infrastructure.Migration; -using Carina.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Npgsql; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.DbIntegration.Domain.Migration; -[Collection(RepositoryDatabaseCollection.Name)] [Trait("Category", "DbIntegration")] -public sealed class MigrationRecordRepositoryTests(RepositoryDatabase database) +public sealed class MigrationRecordRepositoryTests(MigratedRepositoryDatabase database) + : IClassFixture, IAsyncLifetime { private static readonly DateTime At = new(2026, 9, 8, 3, 0, 0, DateTimeKind.Utc); @@ -20,6 +18,12 @@ public sealed class MigrationRecordRepositoryTests(RepositoryDatabase database) private static readonly CancellationToken Cancel = CancellationToken.None; + private readonly ServiceProvider container = database.Infrastructure(); + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() => await container.DisposeAsync(); + [Fact] public async Task ARunComesBackWithEverythingItSaidAndEverythingItLeftBehind() { @@ -364,19 +368,19 @@ public async Task ARunThatFinishesBeforeItStartsIsRefusedByTheDatabase() [Fact] public async Task SavingNoReportAtAllIsRefused() { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); await Assert.ThrowsAsync( - () => new MigrationRecordRepository(context).SaveAsync(null!, Cancel)); + () => RecordsIn(scope).SaveAsync(null!, Cancel)); } [Fact] public async Task ReadingNoRunAtAllIsRefused() { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); await Assert.ThrowsAsync( - () => new MigrationRecordRepository(context).ReadAsync(null!, Cancel)); + () => RecordsIn(scope).ReadAsync(null!, Cancel)); } [Fact] @@ -512,10 +516,10 @@ await SaveAsync(MigrationCensus.Taken( [Fact] public async Task APageOfLinesBelongingToNoRunIsRefused() { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); await Assert.ThrowsAsync( - () => new MigrationRecordRepository(context).ListDetailsAsync( + () => RecordsIn(scope).ListDetailsAsync( null!, MigrationDetailQuery.For(1, 1)!, Cancel)); @@ -524,10 +528,10 @@ await Assert.ThrowsAsync( [Fact] public async Task APageNobodyAskedForIsRefused() { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); await Assert.ThrowsAsync( - () => new MigrationRecordRepository(context).ListDetailsAsync( + () => RecordsIn(scope).ListDetailsAsync( MigrationRunId.New(), null!, Cancel)); @@ -607,49 +611,45 @@ private async Task CountAsync(string sql) return (long)(await asking.ExecuteScalarAsync())!; } - private async Task OpenAsync() - { - await using CarinaDbContext context = database.Open(); - NpgsqlConnection connection = new(context.Database.GetConnectionString()); - await connection.OpenAsync(); - - return connection; - } + private Task OpenAsync() => database.OpenAsync(); private async Task SaveAsync(MigrationReport report) { - await using CarinaDbContext context = database.Open(); - await new MigrationRecordRepository(context).SaveAsync(report, Cancel); + await using AsyncServiceScope scope = container.CreateAsyncScope(); + await RecordsIn(scope).SaveAsync(report, Cancel); } private async Task LatestAsync() { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); - return await new MigrationRecordRepository(context).LatestAsync(Cancel); + return await RecordsIn(scope).LatestAsync(Cancel); } private async Task ReadAsync(MigrationRunId id) { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); - return await new MigrationRecordRepository(context).ReadAsync(id, Cancel); + return await RecordsIn(scope).ReadAsync(id, Cancel); } private async Task SummariseAsync() { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); - return await new MigrationRecordRepository(context).SummariseAsync(Cancel); + return await RecordsIn(scope).SummariseAsync(Cancel); } private async Task> ListDetailsAsync(MigrationRunId id, int page, int perPage) { - await using CarinaDbContext context = database.Open(); + await using AsyncServiceScope scope = container.CreateAsyncScope(); - return await new MigrationRecordRepository(context).ListDetailsAsync( + return await RecordsIn(scope).ListDetailsAsync( id, MigrationDetailQuery.For(page, perPage)!, Cancel); } + + private static IMigrationRecordRepository RecordsIn(AsyncServiceScope scope) + => scope.ServiceProvider.GetRequiredService(); } diff --git a/tests/Carina.Infrastructure.Tests/Fixtures/MigrationFixtures.cs b/tests/Carina.Infrastructure.Tests/Fixtures/MigrationFixtures.cs index 7fee2f868..1b3f8d06e 100644 --- a/tests/Carina.Infrastructure.Tests/Fixtures/MigrationFixtures.cs +++ b/tests/Carina.Infrastructure.Tests/Fixtures/MigrationFixtures.cs @@ -1,3 +1,8 @@ +using Carina.Domain.Base; +using Carina.Domain.Migration; + +using static Carina.TestSupport.CarriedMigrationFixtures; + namespace Carina.Infrastructure.Tests.Fixtures.Migration; internal sealed class CarryLog @@ -6,3 +11,78 @@ internal sealed class CarryLog public int TapeEntryId { get; set; } } + +internal sealed class HeldMigrationRecords : IMigrationRecordRepository +{ + public List Saved { get; } = []; + + public Task SaveAsync(MigrationReport report, CancellationToken cancellationToken) + { + Saved.Add(report); + + return Task.CompletedTask; + } + + public Task LatestAsync(CancellationToken cancellationToken) + => Task.FromResult(Saved.LastOrDefault()?.Run); + + public Task ReadAsync(MigrationRunId runId, CancellationToken cancellationToken) + => Task.FromResult(Saved.FirstOrDefault(report => report.Run.Id.Equals(runId))); + + public Task SummariseAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task> ListDetailsAsync( + MigrationRunId runId, + MigrationDetailQuery query, + CancellationToken cancellationToken) + => throw new NotSupportedException(); +} + +internal sealed class OneAtATime : IMigrationLease +{ + public bool Held { get; set; } + + public int Taken { get; private set; } + + public int Returned { get; private set; } + + public Task TakeAsync(CancellationToken cancellationToken) + { + if (Held) + { + return Task.FromResult(null); + } + + Held = true; + Taken++; + + return Task.FromResult(new Hold(this)); + } + + private sealed class Hold(OneAtATime lease) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + lease.Held = false; + lease.Returned++; + + return ValueTask.CompletedTask; + } + } +} + +internal sealed class SourceOnDisk(string directory) : IMigrationSourceLedger, IMigrationSourceDirectory +{ + public Task ReadAsync(CancellationToken cancellationToken) + => Task.FromResult(Ledger( + [Recording(7), Recording(8)], + [AsBroadcast(7, "one.m2ts", 11), AsBroadcast(8, "empty.m2ts", 4096)])); + + public Task> ListAsync(CancellationToken cancellationToken) + => Task.FromResult>( + [.. Directory + .GetFiles(directory) + .Order(StringComparer.Ordinal) + .Select(file => OnDisk(Path.GetFileName(file), new FileInfo(file).Length))]); +} diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationOnRealFiles.cs b/tests/Carina.Infrastructure.Tests/Fixtures/MigrationOnRealFiles.cs similarity index 89% rename from tests/Carina.Infrastructure.Tests/Migration/MigrationOnRealFiles.cs rename to tests/Carina.Infrastructure.Tests/Fixtures/MigrationOnRealFiles.cs index 40918d2dc..58fdb9f68 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationOnRealFiles.cs +++ b/tests/Carina.Infrastructure.Tests/Fixtures/MigrationOnRealFiles.cs @@ -8,7 +8,7 @@ using static Carina.TestSupport.CarriedMigrationFixtures; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Fixtures.Migration; internal static class MigrationOnRealFiles { @@ -32,6 +32,34 @@ public static void LayDown(string directory) Write(directory, NothingLandedIn, 0); } + public static void RemoveAll(params string[] directories) + { + ArgumentNullException.ThrowIfNull(directories); + + List refused = []; + + foreach (string directory in directories) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (IOException removing) + { + refused.Add(removing); + } + catch (UnauthorizedAccessException removing) + { + refused.Add(removing); + } + } + + if (refused.Count > 0) + { + throw new AggregateException(refused); + } + } + private static void Write(string directory, string name, long size) { byte[] filling = new byte[size]; @@ -225,9 +253,5 @@ public Task RunAsync(MigrationPass pass) Lease, Clock).RunAsync(pass, Rescanned(), CancellationToken.None); - public void Dispose() - { - Directory.Delete(From, recursive: true); - Directory.Delete(Into, recursive: true); - } + public void Dispose() => MigrationOnRealFiles.RemoveAll(From, Into); } diff --git a/tests/Carina.Infrastructure.Tests/Migration/WhatAMigrationLeftBehindStaysOnItsOwnRecordTests.cs b/tests/Carina.Infrastructure.Tests/Integrity/WhatAMigrationLeftBehindStaysOnItsOwnRecordTests.cs similarity index 96% rename from tests/Carina.Infrastructure.Tests/Migration/WhatAMigrationLeftBehindStaysOnItsOwnRecordTests.cs rename to tests/Carina.Infrastructure.Tests/Integrity/WhatAMigrationLeftBehindStaysOnItsOwnRecordTests.cs index 7933ddb01..06da0fd87 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/WhatAMigrationLeftBehindStaysOnItsOwnRecordTests.cs +++ b/tests/Carina.Infrastructure.Tests/Integrity/WhatAMigrationLeftBehindStaysOnItsOwnRecordTests.cs @@ -2,11 +2,12 @@ using Carina.Domain.Migration; using Carina.Domain.Recordings; using Carina.Infrastructure.Migration; +using Carina.Infrastructure.Tests.Fixtures.Migration; using Carina.TestSupport; using static Carina.TestSupport.CarriedMigrationFixtures; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Integrity; public sealed class WhatAMigrationLeftBehindStaysOnItsOwnRecordTests : IDisposable { diff --git a/tests/Carina.Infrastructure.Tests/Migration/ARunThatWasInterruptedTests.cs b/tests/Carina.Infrastructure.Tests/Migration/ARunThatWasInterruptedTests.cs deleted file mode 100644 index 87a3004da..000000000 --- a/tests/Carina.Infrastructure.Tests/Migration/ARunThatWasInterruptedTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Carina.Domain.Migration; - -namespace Carina.Infrastructure.Tests.Migration; - -public sealed class ARunThatWasInterruptedTests : IDisposable -{ - private readonly ARunOverRealFiles run = new(); - - public void Dispose() => run.Dispose(); - - [Fact] - public async Task ARunThatDiesBeforeItLinksAnythingLeavesNoRowAndNoFileBehind() - { - run.Source.WhenWalking = new IOException("the machine went down before the run had read the disk"); - - await Assert.ThrowsAsync(() => run.RunAsync(MigrationPass.ForReal)); - - Assert.Empty(run.Records.Saved); - Assert.Empty(run.Recordings.Written); - Assert.Empty(run.Bench.Rules.Rules); - Assert.Empty(run.Bench.Jobs.Jobs); - Assert.Empty(Directory.GetFileSystemEntries(run.Into)); - Assert.Equal(run.Lease.Taken, run.Lease.Returned); - } - - [Fact] - public async Task ARunThatDiesBetweenALinkAndItsRowWritesNoRecordOfItself() - { - Interrupt(after: 1); - - await Assert.ThrowsAsync(() => run.RunAsync(MigrationPass.ForReal)); - - Assert.Empty(run.Records.Saved); - Assert.Single(run.Recordings.Written); - Assert.Equal(2, Directory.GetFiles(run.Into).Length); - Assert.Equal(run.Lease.Taken, run.Lease.Returned); - } - - [Fact] - public async Task EmptyingTheNewRootAndForgettingTheRowsIsAllItTakesAfterARunWasInterrupted() - { - IReadOnlyList before = WhatTheFilesystemSays.Under(run.From); - - Interrupt(after: 1); - await Assert.ThrowsAsync(() => run.RunAsync(MigrationPass.ForReal)); - - run.Recordings.WhenAdding = null; - run.Recordings.Written.Clear(); - run.Bench.Rules.Rules.Clear(); - run.Bench.Jobs.Jobs.Clear(); - - foreach (string stray in Directory.GetFiles(run.Into)) - { - File.Delete(stray); - } - - await run.RunAsync(MigrationPass.ForReal); - - IReadOnlyList source = WhatTheFilesystemSays.Under(run.From); - IReadOnlyList arrived = WhatTheFilesystemSays.Under(run.Into); - - Assert.Equal(2, run.Recordings.Written.Count); - Assert.Single(run.Records.Saved); - Assert.Equal(WhatTheFilesystemSays.Identities(before), WhatTheFilesystemSays.Identities(source)); - Assert.Equal( - arrived.Select(file => file.Inode).Order(), - source.Where(file => file.Links is 2).Select(file => file.Inode).Order()); - Assert.Equal( - run.Recordings.Written.Select(recording => recording.FileName.Value).Order(StringComparer.Ordinal), - arrived.Select(file => file.Name).Order(StringComparer.Ordinal)); - } - - private void Interrupt(int after) - { - run.Recordings.WrittenBeforeItRefuses = after; - run.Recordings.WhenAdding = new IOException("the machine went down part way through the run"); - } -} diff --git a/tests/Carina.Infrastructure.Tests/Migration/CarryingOverRealFilesTests.cs b/tests/Carina.Infrastructure.Tests/Migration/CarryingOverRealFilesTests.cs deleted file mode 100644 index c7ca285b4..000000000 --- a/tests/Carina.Infrastructure.Tests/Migration/CarryingOverRealFilesTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -using Carina.Domain.Migration; -using Carina.Domain.Recordings; -using Carina.Infrastructure.Migration; -using Carina.TestSupport; - -using static Carina.TestSupport.CarriedMigrationFixtures; - -namespace Carina.Infrastructure.Tests.Migration; - -public sealed class CarryingOverRealFilesTests : IDisposable -{ - private static readonly CancellationToken Cancel = CancellationToken.None; - - private readonly string from = Directory.CreateTempSubdirectory("carina-carrying-from").FullName; - - private readonly string into = Directory.CreateTempSubdirectory("carina-carrying-into").FullName; - - private readonly HandTurnedClock clock = new(new DateTimeOffset(2026, 9, 8, 5, 0, 0, TimeSpan.Zero)); - - private readonly HeldMigrationRecords records = new(); - - private readonly OneAtATime lease = new(); - - private readonly HeldMigratedRecordings recordings; - - private readonly MigrationBench bench; - - public CarryingOverRealFilesTests() - { - bench = new MigrationBench(clock); - recordings = new HeldMigratedRecordings(bench.Journal); - - File.WriteAllText(Path.Combine(from, "one.m2ts"), "a recording"); - File.WriteAllText(Path.Combine(from, "notes.txt"), "not a recording at all"); - File.WriteAllText(Path.Combine(from, "empty.m2ts"), string.Empty); - } - - public void Dispose() - { - Directory.Delete(from, recursive: true); - Directory.Delete(into, recursive: true); - } - - [Fact] - public async Task ARehearsalLeavesBothSidesExactlyAsItFoundThemHoweverOftenItIsRun() - { - IReadOnlyList before = Walked(from); - - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - - Assert.Equal(before, Walked(from)); - Assert.Empty(Directory.GetFileSystemEntries(into)); - Assert.Empty(recordings.Written); - Assert.Equal(3, records.Saved.Count); - } - - [Fact] - public async Task WhatIsCarriedArrivesAsALinkAndTheSourceIsUntouched() - { - IReadOnlyList before = Walked(from); - - await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); - - Recording written = recordings.Written.Single(); - - Assert.Equal(before, Walked(from)); - Assert.Equal([written.FileName.Value], Directory.GetFiles(into).Select(Path.GetFileName)); - - await File.AppendAllTextAsync(Path.Combine(from, "one.m2ts"), " that grew", Cancel); - - Assert.Equal( - "a recording that grew", - await File.ReadAllTextAsync(Path.Combine(into, written.FileName.Value), Cancel)); - } - - [Fact] - public async Task NothingTheRunRefusedEverAppearsInTheNewRoot() - { - await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); - - Assert.Single(Directory.GetFiles(into)); - Assert.DoesNotContain( - Directory.GetFiles(into).Select(Path.GetFileName), - name => name is "notes.txt" or "empty.m2ts"); - } - - [Fact] - public async Task EmptyingTheNewRootAndForgettingTheRowsIsAllItTakesToStartAgain() - { - await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); - - Assert.Single(recordings.Written); - - MigrationCarryRefusedException stopped = await Assert.ThrowsAsync( - () => Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel)); - - Assert.Contains("take out of the new root", stopped.Message, StringComparison.Ordinal); - Assert.DoesNotContain("delete the new root", stopped.Message, StringComparison.Ordinal); - - foreach (string carried in Directory.GetFiles(into)) - { - File.Delete(carried); - } - - recordings.Written.Clear(); - - await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); - - Assert.Single(recordings.Written); - Assert.Single(Directory.GetFiles(into)); - } - - private static IReadOnlyList Walked(string directory) - => [.. Directory - .GetFiles(directory) - .Order(StringComparer.Ordinal) - .Select(file => $"{Path.GetFileName(file)} {new FileInfo(file).Length} {File.GetLastWriteTimeUtc(file):O}")]; - - [Fact] - public async Task ANewRootThatWentAwayAfterItWasLookedAtIsWrittenDownRatherThanThrownOver() - { - string gone = Path.Combine(into, "gone-by-then"); - Directory.CreateDirectory(gone); - Directory.Delete(gone); - - await Passage(gone).RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - - MigrationReport written = records.Saved.Single(); - - Assert.Equal( - MigrationFinding.TheNewRootIsNotThere, - Found(written, MigrationStandingSubject.TheNewRoot)); - Assert.Equal( - MigrationFinding.TheNewRootDoesNotTakeALink, - Found(written, MigrationStandingSubject.CarryingIntoTheNewRoot)); - } - - [Fact] - public async Task WhatARehearsalForetellsAboutTheLinkIsWrittenDownWithTheRest() - { - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - - Assert.Equal( - MigrationFinding.TheCarryWouldBeAHardLink, - Found(records.Saved.Single(), MigrationStandingSubject.CarryingIntoTheNewRoot)); - } - - private static MigrationFinding Found(MigrationReport written, MigrationStandingSubject subject) - => written.Standings.Single(standing => standing.Subject == subject).Finding; - - private MigrationPassage Passage() => Passage(into); - - private MigrationPassage Passage(string root) - => new( - new SourceOnDisk(from), - new SourceOnDisk(from), - bench.Carriage(new HardLinkMigrationCarrier(from, root, Root), recordings), - records, - lease, - clock); -} - -internal sealed class SourceOnDisk(string directory) : IMigrationSourceLedger, IMigrationSourceDirectory -{ - public Task ReadAsync(CancellationToken cancellationToken) - => Task.FromResult(Ledger( - [Recording(7), Recording(8)], - [AsBroadcast(7, "one.m2ts", 11), AsBroadcast(8, "empty.m2ts", 4096)])); - - public Task> ListAsync(CancellationToken cancellationToken) - => Task.FromResult>( - [.. Directory - .GetFiles(directory) - .Order(StringComparer.Ordinal) - .Select(file => OnDisk(Path.GetFileName(file), new FileInfo(file).Length))]); -} diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationLeaseTests.cs b/tests/Carina.Infrastructure.Tests/Migration/MigrationLeaseTests.cs deleted file mode 100644 index 1b02ba181..000000000 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationLeaseTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Carina.Domain.Migration; -using Carina.Infrastructure.Migration; -using Carina.Infrastructure.Persistence; - -namespace Carina.Infrastructure.Tests.Migration; - -[Collection(RepositoryDatabaseCollection.Name)] -[Trait("Category", "DbIntegration")] -public sealed class MigrationLeaseTests(RepositoryDatabase database) -{ - private static readonly CancellationToken Cancel = CancellationToken.None; - - [Fact] - public async Task ASecondRunIsRefusedWhileTheFirstStillHoldsTheLease() - { - await using CarinaDbContext first = database.Open(); - await using CarinaDbContext second = database.Open(); - - IAsyncDisposable? held = await new MigrationLease(first).TakeAsync(Cancel); - - Assert.NotNull(held); - Assert.Null(await new MigrationLease(second).TakeAsync(Cancel)); - - await held.DisposeAsync(); - } - - [Fact] - public async Task TheNextRunGetsTheLeaseOnceTheFirstHasGivenItBack() - { - await using CarinaDbContext first = database.Open(); - await using CarinaDbContext second = database.Open(); - - IAsyncDisposable? held = await new MigrationLease(first).TakeAsync(Cancel); - Assert.NotNull(held); - await held.DisposeAsync(); - - IAsyncDisposable? next = await new MigrationLease(second).TakeAsync(Cancel); - - Assert.NotNull(next); - - await next.DisposeAsync(); - } -} diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationPassageTests.cs b/tests/Carina.Infrastructure.Tests/Migration/MigrationPassageTests.cs deleted file mode 100644 index 351a5a9aa..000000000 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationPassageTests.cs +++ /dev/null @@ -1,208 +0,0 @@ -using Carina.Domain.Base; -using Carina.Domain.Migration; -using Carina.Infrastructure.Migration; -using Carina.TestSupport; - -using static Carina.TestSupport.CarriedMigrationFixtures; - -namespace Carina.Infrastructure.Tests.Migration; - -public sealed class MigrationPassageTests -{ - private static readonly CancellationToken Cancel = CancellationToken.None; - - private readonly HandTurnedClock clock = new(new DateTimeOffset(2026, 9, 8, 5, 0, 0, TimeSpan.Zero)); - - private readonly ScriptedCarrier carrier; - - private readonly HeldMigratedRecordings recordings; - - private readonly HeldMigrationRecords records = new(); - - private readonly OneAtATime lease = new(); - - private readonly ReadOnlySource source = new(); - - private readonly MigrationBench bench; - - private readonly MigrationJournal journal; - - public MigrationPassageTests() - { - bench = new MigrationBench(clock); - journal = bench.Journal; - carrier = new ScriptedCarrier(journal); - recordings = new HeldMigratedRecordings(journal); - } - - [Fact] - public async Task ASecondStartWhileOneIsRunningIsRefused() - { - lease.Held = true; - - MigrationAlreadyRunningException refused = await Assert.ThrowsAsync( - () => Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel)); - - Assert.Contains("already running", refused.Message, StringComparison.Ordinal); - Assert.Equal(0, source.Reads); - Assert.Empty(journal.Steps); - Assert.Empty(records.Saved); - } - - [Fact] - public async Task TheLeaseIsGivenBackWhenTheRunIsOver() - { - await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); - - Assert.Equal(1, lease.Taken); - Assert.Equal(1, lease.Returned); - } - - [Fact] - public async Task TheLeaseIsGivenBackWhenTheRunStops() - { - carrier.Answers( - "one.m2ts", - new MigrationCarry( - MigrationCarryOutcome.NotOnTheSameFilesystem, - "a hard link cannot cross filesystems")); - - await Assert.ThrowsAsync( - () => Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel)); - - Assert.Equal(1, lease.Returned); - Assert.Empty(records.Saved); - } - - [Fact] - public async Task ARehearsalReadsTheSameThingsAndChangesNothing() - { - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - - Assert.Equal(1, source.Reads); - Assert.Equal(1, source.Walks); - Assert.Empty(journal.Steps); - Assert.Empty(recordings.Written); - } - - [Fact] - public async Task ARehearsalIsWrittenDownAsARehearsal() - { - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - - Assert.Equal(MigrationPass.Rehearsal, records.Saved.Single().Run.Pass); - Assert.Equal(Source, records.Saved.Single().Run.Source); - } - - [Fact] - public async Task ARehearsalCanBeRunAgainAndAgain() - { - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); - - Assert.Equal(3, records.Saved.Count); - Assert.Empty(journal.Steps); - Assert.Equal(3, lease.Returned); - } - - [Fact] - public async Task ARunForRealCarriesAndSaysSo() - { - await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); - - Assert.Equal(MigrationPass.ForReal, records.Saved.Single().Run.Pass); - Assert.Single(recordings.Written); - Assert.Equal(["link one.m2ts", $"row {carrier.Named.Single()}"], journal.Steps); - } - - private MigrationPassage Passage() - => new( - source, - source, - bench.Carriage(carrier, recordings), - records, - lease, - clock); -} - -internal sealed class ReadOnlySource : IMigrationSourceLedger, IMigrationSourceDirectory -{ - public int Reads { get; private set; } - - public int Walks { get; private set; } - - public Task ReadAsync(CancellationToken cancellationToken) - { - Reads++; - - return Task.FromResult(Ledger([Recording(7)], [AsBroadcast(7, "one.m2ts", 100)])); - } - - public Task> ListAsync(CancellationToken cancellationToken) - { - Walks++; - - return Task.FromResult>([OnDisk("one.m2ts", 100)]); - } -} - -internal sealed class HeldMigrationRecords : IMigrationRecordRepository -{ - public List Saved { get; } = []; - - public Task SaveAsync(MigrationReport report, CancellationToken cancellationToken) - { - Saved.Add(report); - - return Task.CompletedTask; - } - - public Task LatestAsync(CancellationToken cancellationToken) - => Task.FromResult(Saved.LastOrDefault()?.Run); - - public Task ReadAsync(MigrationRunId runId, CancellationToken cancellationToken) - => Task.FromResult(Saved.FirstOrDefault(report => report.Run.Id.Equals(runId))); - - public Task SummariseAsync(CancellationToken cancellationToken) - => throw new NotSupportedException(); - - public Task> ListDetailsAsync( - MigrationRunId runId, - MigrationDetailQuery query, - CancellationToken cancellationToken) - => throw new NotSupportedException(); -} - -internal sealed class OneAtATime : IMigrationLease -{ - public bool Held { get; set; } - - public int Taken { get; private set; } - - public int Returned { get; private set; } - - public Task TakeAsync(CancellationToken cancellationToken) - { - if (Held) - { - return Task.FromResult(null); - } - - Held = true; - Taken++; - - return Task.FromResult(new Hold(this)); - } - - private sealed class Hold(OneAtATime lease) : IAsyncDisposable - { - public ValueTask DisposeAsync() - { - lease.Held = false; - lease.Returned++; - - return ValueTask.CompletedTask; - } - } -} diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationRuleQueryReadsBackTests.cs b/tests/Carina.Infrastructure.Tests/Migration/MigrationRuleQueryReadsBackTests.cs deleted file mode 100644 index 154b6355f..000000000 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationRuleQueryReadsBackTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Numerics; - -using Carina.Domain.Migration; -using Carina.Domain.Programmes; -using Carina.Infrastructure.Programmes; - -using Puppis.Contracts; - -using static Carina.TestSupport.CarriedMigrationFixtures; - -namespace Carina.Infrastructure.Tests.Migration; - -public sealed class MigrationRuleQueryReadsBackTests -{ - [Fact] - public void WhatARuleWasConvertedIntoIsReadBackAsTheSearchItStandsFor() - { - SourceRule rule = new( - 3, - "hill", - true, - new SourceRuleTerms( - "hill upland", - "repeat", - SourceRuleFields.Title, - SourceRuleFields.Title, - [SourceBroadcastKind.Terrestrial], - [InReach], - [new SourceRuleGenre(9, null)], - SourceWeek.EveryDay), - SourceRuleReach.Plain); - - MigrationRuleConversion carried = MigrationRuleConversion.Of( - rule, - RescannedService.InReach(Rescanned())); - - ProgrammeSearch read = Assert.IsType( - ProgrammeSearchQuery.Read(carried.Query?.Value)); - - Assert.Equal(["hill", "upland"], read.Words); - Assert.Equal(["repeat"], read.ExcludedWords); - Assert.Equal([ProgrammeField.Title], read.Fields); - Assert.Equal([9], read.Genres); - Assert.Equal(TuneSystem.IsdbT, read.System); - Assert.Equal([new ProgrammeService(32736, 1024)], read.Channels); - } - - [Fact] - public void TheDaysAndTheSubGenreARuleNarrowsByReadBackAsTheSearchTheyStandFor() - { - SourceRule rule = new( - 3, - "hill", - true, - new SourceRuleTerms( - "hill", - string.Empty, - SourceRuleFields.Title | SourceRuleFields.Summary, - SourceRuleFields.Title | SourceRuleFields.Summary, - [], - [], - [new SourceRuleGenre(8, 2)], - 0b010_0010), - SourceRuleReach.Plain); - - MigrationRuleConversion carried = MigrationRuleConversion.Of( - rule, - RescannedService.InReach(Rescanned())); - - ProgrammeSearch read = Assert.IsType( - ProgrammeSearchQuery.Read(carried.Query?.Value)); - - Assert.Empty(read.Genres); - Assert.Equal([new ProgrammeGenre(8, 2)], read.SubGenres); - Assert.Equal([DayOfWeek.Monday, DayOfWeek.Friday], read.Days); - } - - [Fact] - public void NoWeekReadsBackAsMoreDaysThanTheSourceRuleNamed() - { - for (int days = 1; days <= SourceWeek.EveryDay; days++) - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, days: days), - RescannedService.InReach(Rescanned())); - - ProgrammeSearch read = Assert.IsType( - ProgrammeSearchQuery.Read(carried.Query?.Value)); - - foreach (DayOfWeek day in read.Days) - { - Assert.NotEqual(0, days & (1 << (int)day)); - } - - Assert.Equal( - BitOperations.PopCount((uint)days) is ProgrammeSearch.DaysInTheWeek - ? 0 - : BitOperations.PopCount((uint)days), - read.Days.Count); - } - } - - [Fact] - public void TheMarkWordsARuleWasConvertedFromReadBackAsTheMarksAndTheWordsBesideThem() - { - SourceRule rule = new( - 3, - "brand new", - true, - new SourceRuleTerms( - "[新]hill", - "再", - SourceRuleFields.Title | SourceRuleFields.Summary, - SourceRuleFields.Title | SourceRuleFields.Summary, - [SourceBroadcastKind.Terrestrial], - [], - [new SourceRuleGenre(7, 0)], - SourceWeek.EveryDay), - SourceRuleReach.Plain); - - MigrationRuleConversion carried = MigrationRuleConversion.Of( - rule, - RescannedService.InReach(Rescanned())); - - ProgrammeSearch read = Assert.IsType( - ProgrammeSearchQuery.Read(carried.Query?.Value)); - - Assert.Equal(["hill"], read.Words); - Assert.Empty(read.ExcludedWords); - Assert.Equal([ProgrammeMark.New], read.Marks); - Assert.Equal([ProgrammeMark.Rerun], read.ExcludedMarks); - Assert.Equal([new ProgrammeGenre(7, 0)], read.SubGenres); - } - - [Fact] - public void AKeywordCarryingTheCharactersOfAQueryReadsBackWhole() - { - MigrationRuleConversion carried = MigrationRuleConversion.Of( - Rule(3, keyword: "a&b=c"), - RescannedService.InReach(Rescanned())); - - ProgrammeSearch read = Assert.IsType( - ProgrammeSearchQuery.Read(carried.Query?.Value)); - - Assert.Equal(["a&b=c"], read.Words); - } -} diff --git a/tests/Carina.Infrastructure.Tests/Migration/TheDiskHoldsNoMoreAfterAMigrationTests.cs b/tests/Carina.Infrastructure.Tests/Migration/TheDiskHoldsNoMoreAfterAMigrationTests.cs deleted file mode 100644 index a6c0512a7..000000000 --- a/tests/Carina.Infrastructure.Tests/Migration/TheDiskHoldsNoMoreAfterAMigrationTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Carina.Domain.Migration; - -namespace Carina.Infrastructure.Tests.Migration; - -public sealed class TheDiskHoldsNoMoreAfterAMigrationTests : IDisposable -{ - private readonly ARunOverRealFiles run = new(); - - public void Dispose() => run.Dispose(); - - [Fact] - public async Task WhatWasCarriedIsOneFileUnderTwoNamesRatherThanTwoFiles() - { - await run.RunAsync(MigrationPass.ForReal); - - IReadOnlyList source = WhatTheFilesystemSays.Under(run.From); - IReadOnlyList arrived = WhatTheFilesystemSays.Under(run.Into); - - Assert.Equal(2, arrived.Count); - - foreach (string name in Carried) - { - FileStanding original = WhatTheFilesystemSays.Named(source, name); - FileStanding link = arrived.Single(file => file.Inode == original.Inode); - - Assert.Equal(2, original.Links); - Assert.Equal(2, link.Links); - Assert.Equal(original.Size, link.Size); - Assert.Equal(original.Digest, link.Digest); - Assert.NotEqual(original.Name, link.Name); - } - - Assert.Equal(1, WhatTheFilesystemSays.Named(source, MigrationOnRealFiles.NotARecording).Links); - Assert.Equal(1, WhatTheFilesystemSays.Named(source, MigrationOnRealFiles.NothingLandedIn).Links); - } - - [Fact] - public async Task TheDiskHoldsNoMoreBlocksAfterARunThanItHeldBeforeIt() - { - long before = Held(); - - await run.RunAsync(MigrationPass.ForReal); - - Assert.Equal(2, run.Recordings.Written.Count); - Assert.True(before > 0, "A run that carried nothing off a disk holding nothing would prove nothing."); - Assert.Equal(before, Held()); - } - - private static string[] Carried => [MigrationOnRealFiles.Carried, MigrationOnRealFiles.AlsoCarried]; - - private long Held() - => WhatTheFilesystemSays.BytesTheDiskHoldsFor( - WhatTheFilesystemSays.Under(run.From), - WhatTheFilesystemSays.Under(run.Into)); -} diff --git a/tests/Carina.Infrastructure.Tests/Migration/TheSourceOutlivesAMigrationTests.cs b/tests/Carina.Infrastructure.Tests/Migration/TheSourceOutlivesAMigrationTests.cs deleted file mode 100644 index 99b374171..000000000 --- a/tests/Carina.Infrastructure.Tests/Migration/TheSourceOutlivesAMigrationTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Carina.Domain.Migration; - -namespace Carina.Infrastructure.Tests.Migration; - -public sealed class TheSourceOutlivesAMigrationTests : IDisposable -{ - private readonly ARunOverRealFiles run = new(); - - public void Dispose() => run.Dispose(); - - [Fact] - public async Task ARunForRealRenamesCopiesAndDeletesNothingAndChangesNotOneByte() - { - IReadOnlyList before = WhatTheFilesystemSays.Under(run.From); - - await run.RunAsync(MigrationPass.ForReal); - - IReadOnlyList after = WhatTheFilesystemSays.Under(run.From); - - Assert.Equal(2, run.Recordings.Written.Count); - Assert.Equal(4, after.Count); - Assert.Equal(WhatTheFilesystemSays.Identities(before), WhatTheFilesystemSays.Identities(after)); - } - - [Fact] - public async Task ARehearsalRenamesCopiesAndDeletesNothingAndChangesNotOneByte() - { - IReadOnlyList before = WhatTheFilesystemSays.Under(run.From); - - await run.RunAsync(MigrationPass.Rehearsal); - - IReadOnlyList after = WhatTheFilesystemSays.Under(run.From); - - Assert.Single(run.Records.Saved); - Assert.Empty(run.Recordings.Written); - Assert.Empty(Directory.GetFileSystemEntries(run.Into)); - Assert.Equal(WhatTheFilesystemSays.Identities(before), WhatTheFilesystemSays.Identities(after)); - Assert.Equal(before.Select(file => file.Links), after.Select(file => file.Links)); - } -} diff --git a/tests/Carina.Infrastructure.Tests/Migration/HardLinkMigrationCarrierTests.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/HardLinkMigrationCarrierTests.cs similarity index 99% rename from tests/Carina.Infrastructure.Tests/Migration/HardLinkMigrationCarrierTests.cs rename to tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/HardLinkMigrationCarrierTests.cs index a729a20c4..fb3038d61 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/HardLinkMigrationCarrierTests.cs +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/HardLinkMigrationCarrierTests.cs @@ -2,8 +2,9 @@ using Carina.Domain.Recordings; using Carina.Infrastructure.Migration; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Unit.Infra.Migration; +[Trait("Category", "Unit")] public sealed class HardLinkMigrationCarrierTests : IDisposable { private static readonly CancellationToken Cancel = CancellationToken.None; diff --git a/tests/Carina.Infrastructure.Tests/Migration/LocalMigrationSourceDirectoryTests.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/LocalMigrationSourceDirectoryTests.cs similarity index 96% rename from tests/Carina.Infrastructure.Tests/Migration/LocalMigrationSourceDirectoryTests.cs rename to tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/LocalMigrationSourceDirectoryTests.cs index c077009b9..a126126aa 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/LocalMigrationSourceDirectoryTests.cs +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/LocalMigrationSourceDirectoryTests.cs @@ -1,8 +1,9 @@ using Carina.Domain.Migration; using Carina.Infrastructure.Migration; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Unit.Infra.Migration; +[Trait("Category", "Unit")] public sealed class LocalMigrationSourceDirectoryTests : IDisposable { private static readonly CancellationToken Cancel = CancellationToken.None; diff --git a/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationPassageTests.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationPassageTests.cs new file mode 100644 index 000000000..3b16eb8cb --- /dev/null +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationPassageTests.cs @@ -0,0 +1,435 @@ +using Carina.Domain.Migration; +using Carina.Domain.Recordings; +using Carina.Infrastructure.Migration; +using Carina.Infrastructure.Tests.Fixtures.Migration; +using Carina.TestSupport; + +using static Carina.TestSupport.CarriedMigrationFixtures; + +namespace Carina.Infrastructure.Tests.Unit.Infra.Migration; + +[Trait("Category", "Unit")] +public sealed class MigrationPassageTests : IDisposable +{ + private static readonly CancellationToken Cancel = CancellationToken.None; + + private static string[] Carried => [MigrationOnRealFiles.Carried, MigrationOnRealFiles.AlsoCarried]; + + private readonly HandTurnedClock clock = new(new DateTimeOffset(2026, 9, 8, 5, 0, 0, TimeSpan.Zero)); + + private readonly ScriptedCarrier carrier; + + private readonly HeldMigratedRecordings recordings; + + private readonly HeldMigrationRecords records = new(); + + private readonly OneAtATime lease = new(); + + private readonly ReadOnlySource readOnlySource = new(); + + private readonly MigrationBench bench; + + private readonly MigrationJournal journal; + + private readonly string from = Directory.CreateTempSubdirectory("carina-carrying-from").FullName; + + private readonly string into = Directory.CreateTempSubdirectory("carina-carrying-into").FullName; + + private readonly ARunOverRealFiles run = new(); + + public MigrationPassageTests() + { + bench = new MigrationBench(clock); + journal = bench.Journal; + carrier = new ScriptedCarrier(journal); + recordings = new HeldMigratedRecordings(journal); + + File.WriteAllText(Path.Combine(from, "one.m2ts"), "a recording"); + File.WriteAllText(Path.Combine(from, "notes.txt"), "not a recording at all"); + File.WriteAllText(Path.Combine(from, "empty.m2ts"), string.Empty); + } + + public void Dispose() + { + try + { + run.Dispose(); + } + finally + { + MigrationOnRealFiles.RemoveAll(from, into); + } + } + + [Fact] + public async Task ASecondStartWhileOneIsRunningIsRefused() + { + lease.Held = true; + + MigrationAlreadyRunningException refused = await Assert.ThrowsAsync( + () => Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel)); + + Assert.Contains("already running", refused.Message, StringComparison.Ordinal); + Assert.Equal(0, readOnlySource.Reads); + Assert.Empty(journal.Steps); + Assert.Empty(records.Saved); + } + + [Fact] + public async Task TheLeaseIsGivenBackWhenTheRunIsOver() + { + await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); + + Assert.Equal(1, lease.Taken); + Assert.Equal(1, lease.Returned); + } + + [Fact] + public async Task TheLeaseIsGivenBackWhenTheRunStops() + { + carrier.Answers( + "one.m2ts", + new MigrationCarry( + MigrationCarryOutcome.NotOnTheSameFilesystem, + "a hard link cannot cross filesystems")); + + await Assert.ThrowsAsync( + () => Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel)); + + Assert.Equal(1, lease.Returned); + Assert.Empty(records.Saved); + } + + [Fact] + public async Task ARehearsalReadsTheSameThingsAndChangesNothing() + { + await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + + Assert.Equal(1, readOnlySource.Reads); + Assert.Equal(1, readOnlySource.Walks); + Assert.Empty(journal.Steps); + Assert.Empty(recordings.Written); + } + + [Fact] + public async Task ARehearsalIsWrittenDownAsARehearsal() + { + await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + + Assert.Equal(MigrationPass.Rehearsal, records.Saved.Single().Run.Pass); + Assert.Equal(Source, records.Saved.Single().Run.Source); + } + + [Fact] + public async Task ARehearsalCanBeRunAgainAndAgain() + { + await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + await Passage().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + + Assert.Equal(3, records.Saved.Count); + Assert.Empty(journal.Steps); + Assert.Equal(3, lease.Returned); + } + + [Fact] + public async Task ARunForRealCarriesAndSaysSo() + { + await Passage().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); + + Assert.Equal(MigrationPass.ForReal, records.Saved.Single().Run.Pass); + Assert.Single(recordings.Written); + Assert.Equal(["link one.m2ts", $"row {carrier.Named.Single()}"], journal.Steps); + } + + [Fact] + public async Task ARehearsalLeavesBothSidesExactlyAsItFoundThemHoweverOftenItIsRun() + { + IReadOnlyList before = Walked(from); + + await PassageOverFiles().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + await PassageOverFiles().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + await PassageOverFiles().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + + Assert.Equal(before, Walked(from)); + Assert.Empty(Directory.GetFileSystemEntries(into)); + Assert.Empty(recordings.Written); + Assert.Equal(3, records.Saved.Count); + } + + [Fact] + public async Task WhatIsCarriedArrivesAsALinkAndTheSourceIsUntouched() + { + IReadOnlyList before = Walked(from); + + await PassageOverFiles().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); + + Recording written = recordings.Written.Single(); + + Assert.Equal(before, Walked(from)); + Assert.Equal([written.FileName.Value], Directory.GetFiles(into).Select(Path.GetFileName)); + + await File.AppendAllTextAsync(Path.Combine(from, "one.m2ts"), " that grew", Cancel); + + Assert.Equal( + "a recording that grew", + await File.ReadAllTextAsync(Path.Combine(into, written.FileName.Value), Cancel)); + } + + [Fact] + public async Task NothingTheRunRefusedEverAppearsInTheNewRoot() + { + await PassageOverFiles().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); + + Assert.Single(Directory.GetFiles(into)); + Assert.DoesNotContain( + Directory.GetFiles(into).Select(Path.GetFileName), + name => name is "notes.txt" or "empty.m2ts"); + } + + [Fact] + public async Task EmptyingTheNewRootAndForgettingTheRowsIsAllItTakesToStartAgain() + { + await PassageOverFiles().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); + + Assert.Single(recordings.Written); + + MigrationCarryRefusedException stopped = await Assert.ThrowsAsync( + () => PassageOverFiles().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel)); + + Assert.Contains("take out of the new root", stopped.Message, StringComparison.Ordinal); + Assert.DoesNotContain("delete the new root", stopped.Message, StringComparison.Ordinal); + + foreach (string carried in Directory.GetFiles(into)) + { + File.Delete(carried); + } + + recordings.Written.Clear(); + + await PassageOverFiles().RunAsync(MigrationPass.ForReal, Rescanned(), Cancel); + + Assert.Single(recordings.Written); + Assert.Single(Directory.GetFiles(into)); + } + + [Fact] + public async Task ANewRootThatWentAwayAfterItWasLookedAtIsWrittenDownRatherThanThrownOver() + { + string gone = Path.Combine(into, "gone-by-then"); + Directory.CreateDirectory(gone); + Directory.Delete(gone); + + await PassageOverFiles(gone).RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + + MigrationReport written = records.Saved.Single(); + + Assert.Equal( + MigrationFinding.TheNewRootIsNotThere, + Found(written, MigrationStandingSubject.TheNewRoot)); + Assert.Equal( + MigrationFinding.TheNewRootDoesNotTakeALink, + Found(written, MigrationStandingSubject.CarryingIntoTheNewRoot)); + } + + [Fact] + public async Task WhatARehearsalForetellsAboutTheLinkIsWrittenDownWithTheRest() + { + await PassageOverFiles().RunAsync(MigrationPass.Rehearsal, Rescanned(), Cancel); + + Assert.Equal( + MigrationFinding.TheCarryWouldBeAHardLink, + Found(records.Saved.Single(), MigrationStandingSubject.CarryingIntoTheNewRoot)); + } + + [Fact] + public async Task ARunThatDiesBeforeItLinksAnythingLeavesNoRowAndNoFileBehind() + { + run.Source.WhenWalking = new IOException("the machine went down before the run had read the disk"); + + await Assert.ThrowsAsync(() => run.RunAsync(MigrationPass.ForReal)); + + Assert.Empty(run.Records.Saved); + Assert.Empty(run.Recordings.Written); + Assert.Empty(run.Bench.Rules.Rules); + Assert.Empty(run.Bench.Jobs.Jobs); + Assert.Empty(Directory.GetFileSystemEntries(run.Into)); + Assert.Equal(run.Lease.Taken, run.Lease.Returned); + } + + [Fact] + public async Task ARunThatDiesBetweenALinkAndItsRowWritesNoRecordOfItself() + { + Interrupt(after: 1); + + await Assert.ThrowsAsync(() => run.RunAsync(MigrationPass.ForReal)); + + Assert.Empty(run.Records.Saved); + Assert.Single(run.Recordings.Written); + Assert.Equal(2, Directory.GetFiles(run.Into).Length); + Assert.Equal(run.Lease.Taken, run.Lease.Returned); + } + + [Fact] + public async Task EmptyingTheNewRootAndForgettingTheRowsIsAllItTakesAfterARunWasInterrupted() + { + IReadOnlyList before = WhatTheFilesystemSays.Under(run.From); + + Interrupt(after: 1); + await Assert.ThrowsAsync(() => run.RunAsync(MigrationPass.ForReal)); + + run.Recordings.WhenAdding = null; + run.Recordings.Written.Clear(); + run.Bench.Rules.Rules.Clear(); + run.Bench.Jobs.Jobs.Clear(); + + foreach (string stray in Directory.GetFiles(run.Into)) + { + File.Delete(stray); + } + + await run.RunAsync(MigrationPass.ForReal); + + IReadOnlyList source = WhatTheFilesystemSays.Under(run.From); + IReadOnlyList arrived = WhatTheFilesystemSays.Under(run.Into); + + Assert.Equal(2, run.Recordings.Written.Count); + Assert.Single(run.Records.Saved); + Assert.Equal(WhatTheFilesystemSays.Identities(before), WhatTheFilesystemSays.Identities(source)); + Assert.Equal( + arrived.Select(file => file.Inode).Order(), + source.Where(file => file.Links is 2).Select(file => file.Inode).Order()); + Assert.Equal( + run.Recordings.Written.Select(recording => recording.FileName.Value).Order(StringComparer.Ordinal), + arrived.Select(file => file.Name).Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task WhatWasCarriedIsOneFileUnderTwoNamesRatherThanTwoFiles() + { + await run.RunAsync(MigrationPass.ForReal); + + IReadOnlyList source = WhatTheFilesystemSays.Under(run.From); + IReadOnlyList arrived = WhatTheFilesystemSays.Under(run.Into); + + Assert.Equal(2, arrived.Count); + + foreach (string name in Carried) + { + FileStanding original = WhatTheFilesystemSays.Named(source, name); + FileStanding link = arrived.Single(file => file.Inode == original.Inode); + + Assert.Equal(2, original.Links); + Assert.Equal(2, link.Links); + Assert.Equal(original.Size, link.Size); + Assert.Equal(original.Digest, link.Digest); + Assert.NotEqual(original.Name, link.Name); + } + + Assert.Equal(1, WhatTheFilesystemSays.Named(source, MigrationOnRealFiles.NotARecording).Links); + Assert.Equal(1, WhatTheFilesystemSays.Named(source, MigrationOnRealFiles.NothingLandedIn).Links); + } + + [Fact] + public async Task TheDiskHoldsNoMoreBlocksAfterARunThanItHeldBeforeIt() + { + long before = Held(); + + await run.RunAsync(MigrationPass.ForReal); + + Assert.Equal(2, run.Recordings.Written.Count); + Assert.True(before > 0, "A run that carried nothing off a disk holding nothing would prove nothing."); + Assert.Equal(before, Held()); + } + + [Fact] + public async Task ARunForRealRenamesCopiesAndDeletesNothingAndChangesNotOneByte() + { + IReadOnlyList before = WhatTheFilesystemSays.Under(run.From); + + await run.RunAsync(MigrationPass.ForReal); + + IReadOnlyList after = WhatTheFilesystemSays.Under(run.From); + + Assert.Equal(2, run.Recordings.Written.Count); + Assert.Equal(4, after.Count); + Assert.Equal(WhatTheFilesystemSays.Identities(before), WhatTheFilesystemSays.Identities(after)); + } + + [Fact] + public async Task ARehearsalRenamesCopiesAndDeletesNothingAndChangesNotOneByte() + { + IReadOnlyList before = WhatTheFilesystemSays.Under(run.From); + + await run.RunAsync(MigrationPass.Rehearsal); + + IReadOnlyList after = WhatTheFilesystemSays.Under(run.From); + + Assert.Single(run.Records.Saved); + Assert.Empty(run.Recordings.Written); + Assert.Empty(Directory.GetFileSystemEntries(run.Into)); + Assert.Equal(WhatTheFilesystemSays.Identities(before), WhatTheFilesystemSays.Identities(after)); + Assert.Equal(before.Select(file => file.Links), after.Select(file => file.Links)); + } + + private static IReadOnlyList Walked(string directory) + => [.. Directory + .GetFiles(directory) + .Order(StringComparer.Ordinal) + .Select(file => $"{Path.GetFileName(file)} {new FileInfo(file).Length} {File.GetLastWriteTimeUtc(file):O}")]; + + private static MigrationFinding Found(MigrationReport written, MigrationStandingSubject subject) + => written.Standings.Single(standing => standing.Subject == subject).Finding; + + private MigrationPassage Passage() + => new( + readOnlySource, + readOnlySource, + bench.Carriage(carrier, recordings), + records, + lease, + clock); + + private MigrationPassage PassageOverFiles() => PassageOverFiles(into); + + private MigrationPassage PassageOverFiles(string root) + => new( + new SourceOnDisk(from), + new SourceOnDisk(from), + bench.Carriage(new HardLinkMigrationCarrier(from, root, Root), recordings), + records, + lease, + clock); + + private void Interrupt(int after) + { + run.Recordings.WrittenBeforeItRefuses = after; + run.Recordings.WhenAdding = new IOException("the machine went down part way through the run"); + } + + private long Held() + => WhatTheFilesystemSays.BytesTheDiskHoldsFor( + WhatTheFilesystemSays.Under(run.From), + WhatTheFilesystemSays.Under(run.Into)); +} + +internal sealed class ReadOnlySource : IMigrationSourceLedger, IMigrationSourceDirectory +{ + public int Reads { get; private set; } + + public int Walks { get; private set; } + + public Task ReadAsync(CancellationToken cancellationToken) + { + Reads++; + + return Task.FromResult(Ledger([Recording(7)], [AsBroadcast(7, "one.m2ts", 100)])); + } + + public Task> ListAsync(CancellationToken cancellationToken) + { + Walks++; + + return Task.FromResult>([OnDisk("one.m2ts", 100)]); + } +} diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationRootSettingsTests.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationRootSettingsTests.cs similarity index 97% rename from tests/Carina.Infrastructure.Tests/Migration/MigrationRootSettingsTests.cs rename to tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationRootSettingsTests.cs index 410b6289f..b5e482e3c 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationRootSettingsTests.cs +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationRootSettingsTests.cs @@ -1,7 +1,8 @@ using Carina.Infrastructure.Migration; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Unit.Infra.Migration; +[Trait("Category", "Unit")] public sealed class MigrationRootSettingsTests { [Fact] diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationSourceLedgerReaderTests.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationSourceLedgerReaderTests.cs similarity index 99% rename from tests/Carina.Infrastructure.Tests/Migration/MigrationSourceLedgerReaderTests.cs rename to tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationSourceLedgerReaderTests.cs index 9eae1067b..f904f0526 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationSourceLedgerReaderTests.cs +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationSourceLedgerReaderTests.cs @@ -1,8 +1,9 @@ using Carina.Domain.Migration; using Carina.Infrastructure.Migration; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Unit.Infra.Migration; +[Trait("Category", "Unit")] public sealed class MigrationSourceLedgerReaderTests { private static readonly CancellationToken Cancel = CancellationToken.None; diff --git a/tests/Carina.Infrastructure.Tests/Migration/MigrationSourceSettingsTests.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationSourceSettingsTests.cs similarity index 97% rename from tests/Carina.Infrastructure.Tests/Migration/MigrationSourceSettingsTests.cs rename to tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationSourceSettingsTests.cs index 81dab08ae..3d85decc7 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/MigrationSourceSettingsTests.cs +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/MigrationSourceSettingsTests.cs @@ -1,7 +1,8 @@ using Carina.Infrastructure.Migration; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Unit.Infra.Migration; +[Trait("Category", "Unit")] public sealed class MigrationSourceSettingsTests { private const string Complete = "Server=somewhere.invalid;Port=3306;Database=somedb;Uid=reader;Pwd=notreal"; diff --git a/tests/Carina.Infrastructure.Tests/Migration/StandInSourceDatabase.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/StandInSourceDatabase.cs similarity index 99% rename from tests/Carina.Infrastructure.Tests/Migration/StandInSourceDatabase.cs rename to tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/StandInSourceDatabase.cs index 3ea1239c3..b5ec56dcc 100644 --- a/tests/Carina.Infrastructure.Tests/Migration/StandInSourceDatabase.cs +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Migration/StandInSourceDatabase.cs @@ -5,7 +5,7 @@ using Carina.Infrastructure.Migration; -namespace Carina.Infrastructure.Tests.Migration; +namespace Carina.Infrastructure.Tests.Unit.Infra.Migration; internal sealed class StandInSourceDatabase : IMigrationSourceConnection { diff --git a/tests/Carina.Infrastructure.Tests/Unit/Infra/Programmes/ProgrammeSearchQueryTests.cs b/tests/Carina.Infrastructure.Tests/Unit/Infra/Programmes/ProgrammeSearchQueryTests.cs index 07b50f7e7..e4a4603db 100644 --- a/tests/Carina.Infrastructure.Tests/Unit/Infra/Programmes/ProgrammeSearchQueryTests.cs +++ b/tests/Carina.Infrastructure.Tests/Unit/Infra/Programmes/ProgrammeSearchQueryTests.cs @@ -1,11 +1,15 @@ using System.Globalization; +using System.Numerics; using Carina.Domain.Channels; +using Carina.Domain.Migration; using Carina.Domain.Programmes; using Carina.Infrastructure.Programmes; using Puppis.Contracts; +using static Carina.TestSupport.CarriedMigrationFixtures; + namespace Carina.Infrastructure.Tests.Unit.Infra.Programmes; [Trait("Category", "Unit")] @@ -275,6 +279,139 @@ public void TheLanguagesMeasuredHereAreOnesThatWouldTellIfTheCodeAskedTheThread( Assert.Equal("1,5", Spoken(new CultureInfo("de-DE"), () => 1.5.ToString("0.#", CultureInfo.CurrentCulture))); } + [Fact] + public void WhatARuleWasConvertedIntoIsReadBackAsTheSearchItStandsFor() + { + SourceRule rule = new( + 3, + "hill", + true, + new SourceRuleTerms( + "hill upland", + "repeat", + SourceRuleFields.Title, + SourceRuleFields.Title, + [SourceBroadcastKind.Terrestrial], + [InReach], + [new SourceRuleGenre(9, null)], + SourceWeek.EveryDay), + SourceRuleReach.Plain); + + MigrationRuleConversion carried = MigrationRuleConversion.Of( + rule, + RescannedService.InReach(Rescanned())); + + ProgrammeSearch read = Assert.IsType( + ProgrammeSearchQuery.Read(carried.Query?.Value)); + + Assert.Equal(["hill", "upland"], read.Words); + Assert.Equal(["repeat"], read.ExcludedWords); + Assert.Equal([ProgrammeField.Title], read.Fields); + Assert.Equal([9], read.Genres); + Assert.Equal(TuneSystem.IsdbT, read.System); + Assert.Equal([new ProgrammeService(32736, 1024)], read.Channels); + } + + [Fact] + public void TheDaysAndTheSubGenreARuleNarrowsByReadBackAsTheSearchTheyStandFor() + { + SourceRule rule = new( + 3, + "hill", + true, + new SourceRuleTerms( + "hill", + string.Empty, + SourceRuleFields.Title | SourceRuleFields.Summary, + SourceRuleFields.Title | SourceRuleFields.Summary, + [], + [], + [new SourceRuleGenre(8, 2)], + 0b010_0010), + SourceRuleReach.Plain); + + MigrationRuleConversion carried = MigrationRuleConversion.Of( + rule, + RescannedService.InReach(Rescanned())); + + ProgrammeSearch read = Assert.IsType( + ProgrammeSearchQuery.Read(carried.Query?.Value)); + + Assert.Empty(read.Genres); + Assert.Equal([new ProgrammeGenre(8, 2)], read.SubGenres); + Assert.Equal([DayOfWeek.Monday, DayOfWeek.Friday], read.Days); + } + + [Fact] + public void NoWeekReadsBackAsMoreDaysThanTheSourceRuleNamed() + { + for (int days = 1; days <= SourceWeek.EveryDay; days++) + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, days: days), + RescannedService.InReach(Rescanned())); + + ProgrammeSearch read = Assert.IsType( + ProgrammeSearchQuery.Read(carried.Query?.Value)); + + foreach (DayOfWeek day in read.Days) + { + Assert.NotEqual(0, days & (1 << (int)day)); + } + + Assert.Equal( + BitOperations.PopCount((uint)days) is ProgrammeSearch.DaysInTheWeek + ? 0 + : BitOperations.PopCount((uint)days), + read.Days.Count); + } + } + + [Fact] + public void TheMarkWordsARuleWasConvertedFromReadBackAsTheMarksAndTheWordsBesideThem() + { + SourceRule rule = new( + 3, + "brand new", + true, + new SourceRuleTerms( + "[新]hill", + "再", + SourceRuleFields.Title | SourceRuleFields.Summary, + SourceRuleFields.Title | SourceRuleFields.Summary, + [SourceBroadcastKind.Terrestrial], + [], + [new SourceRuleGenre(7, 0)], + SourceWeek.EveryDay), + SourceRuleReach.Plain); + + MigrationRuleConversion carried = MigrationRuleConversion.Of( + rule, + RescannedService.InReach(Rescanned())); + + ProgrammeSearch read = Assert.IsType( + ProgrammeSearchQuery.Read(carried.Query?.Value)); + + Assert.Equal(["hill"], read.Words); + Assert.Empty(read.ExcludedWords); + Assert.Equal([ProgrammeMark.New], read.Marks); + Assert.Equal([ProgrammeMark.Rerun], read.ExcludedMarks); + Assert.Equal([new ProgrammeGenre(7, 0)], read.SubGenres); + } + + [Fact] + public void AKeywordCarryingTheCharactersOfAQueryReadsBackWhole() + { + MigrationRuleConversion carried = MigrationRuleConversion.Of( + Rule(3, keyword: "a&b=c"), + RescannedService.InReach(Rescanned())); + + ProgrammeSearch read = Assert.IsType( + ProgrammeSearchQuery.Read(carried.Query?.Value)); + + Assert.Equal(["a&b=c"], read.Words); + } + private static string Spelt(ProgrammeSearch? search) => search is null ? "nothing"