using System.Text; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyComparisonWorkflowTests { private static readonly LegacyComparisonTarget Samsung = LegacyComparisonTarget.CreateKrxStock( LegacyComparisonDomesticMarket.Kospi, "삼성전자", "005930"); private static readonly LegacyComparisonTarget SkHynix = LegacyComparisonTarget.CreateKrxStock( LegacyComparisonDomesticMarket.Kospi, "SK하이닉스", "000660"); private static readonly LegacyComparisonTarget NxtSamsung = LegacyComparisonTarget.CreateNxtStock( LegacyComparisonDomesticMarket.Kospi, "삼성전자", "005930"); private static readonly LegacyComparisonTarget Apple = LegacyComparisonTarget.CreateWorldStock("APPLE", "AAPL", "US"); [Fact] public void InitialSnapshot_ClosesFixedTargetsAndRuntimeActions() { var controller = CreateController(); var snapshot = controller.Current; Assert.Equal(24, snapshot.FixedTargets.Count); Assert.Equal( new[] { "kospi", "kosdaq", "kospi200", "futures", "krx100", "won-dollar", "won-yen", "won-yuan", "won-euro", "dow", "nasdaq", "sp500", "germany-dax", "united-kingdom-ftse", "france-cac", "nikkei", "hang-seng", "taiwan-weighted", "singapore-straits-times", "thailand-set", "philippines-composite", "malaysia-klse", "indonesia-composite", "shanghai-composite" }, snapshot.FixedTargets.Select(target => target.SelectionId)); Assert.Equal( new[] { "two-column-expected", "two-column-current", "comparison-candle", "comparison-line", "return-5d", "return-1m", "return-3m", "return-6m", "return-12m" }, snapshot.Actions.Select(action => action.ActionId)); Assert.All(snapshot.Actions, action => Assert.False(action.IsAvailable)); } [Fact] public async Task SearchesAndSelection_ReproduceUc3RotateThenSwapRule() { var data = new FakeDataService { DomesticResults = [Samsung, NxtSamsung], WorldResults = [Apple] }; var controller = CreateController(data); var domestic = await controller.SearchDomesticAsync(" 삼성 "); Assert.Equal("삼성", data.DomesticQuery); Assert.Equal(LegacyStockSearchService.MaximumResults, data.DomesticMaximumResults); Assert.Equal( new[] { "삼성전자", "삼성전자(NXT)" }, domestic.DomesticSearch.Results.Select(result => result.DisplayName)); var selectedDomestic = controller.SelectDomesticResult( domestic.DomesticSearch.Results[0].SelectionId); Assert.Equal( domestic.DomesticSearch.Results[0].SelectionId, selectedDomestic.DomesticSearch.SelectedResultId); Assert.Null(selectedDomestic.FirstTarget); Assert.Null(selectedDomestic.SecondTarget); var first = controller.ChooseDomesticResult( domestic.DomesticSearch.Results[0].SelectionId); Assert.Equal("삼성전자", first.FirstTarget?.DisplayName); Assert.Null(first.SecondTarget); var second = controller.ChooseDomesticResult( domestic.DomesticSearch.Results[1].SelectionId); Assert.Equal("삼성전자(NXT)", second.FirstTarget?.DisplayName); Assert.Equal("삼성전자", second.SecondTarget?.DisplayName); var world = await controller.SearchWorldAsync(string.Empty); Assert.Equal(string.Empty, data.WorldQuery); Assert.Equal(LegacyWorldStockSearchService.MaximumResults, data.WorldMaximumResults); var selectedWorld = controller.SelectWorldResult( world.WorldSearch.Results[0].SelectionId); Assert.Equal( world.WorldSearch.Results[0].SelectionId, selectedWorld.WorldSearch.SelectedResultId); Assert.Equal(second.FirstTarget?.DisplayName, selectedWorld.FirstTarget?.DisplayName); var third = controller.ChooseWorldResult(world.WorldSearch.Results[0].SelectionId); Assert.Equal("APPLE", third.FirstTarget?.DisplayName); Assert.Equal("삼성전자(NXT)", third.SecondTarget?.DisplayName); var selectedFixed = controller.SelectFixedTarget("kospi"); Assert.Equal("kospi", selectedFixed.SelectedFixedTargetId); Assert.Equal(third.FirstTarget?.DisplayName, selectedFixed.FirstTarget?.DisplayName); var fixedTarget = controller.ChooseFixedTarget("kospi"); Assert.Equal("코스피 지수", fixedTarget.FirstTarget?.DisplayName); Assert.Equal("APPLE", fixedTarget.SecondTarget?.DisplayName); var swapped = controller.SwapDraftTargets(); Assert.Equal("APPLE", swapped.FirstTarget?.DisplayName); Assert.Equal("코스피 지수", swapped.SecondTarget?.DisplayName); } [Fact] public async Task Search_projects_row_level_isolation_without_rejecting_safe_results() { var data = new FakeDataService { DomesticResults = [Samsung], DomesticIsTruncated = true, DomesticIsolatedInvalidRowCount = 2, WorldResults = [Apple], WorldIsTruncated = true, WorldIsolatedInvalidRowCount = 16 }; var controller = CreateController(data); var domestic = await controller.SearchDomesticAsync(string.Empty); var world = await controller.SearchWorldAsync(string.Empty); Assert.Single(domestic.DomesticSearch.Results); Assert.True(domestic.DomesticSearch.IsTruncated); Assert.Equal(2, domestic.DomesticSearch.IsolatedInvalidRowCount); Assert.Single(world.WorldSearch.Results); Assert.True(world.WorldSearch.IsTruncated); Assert.Equal(16, world.WorldSearch.IsolatedInvalidRowCount); } [Fact] public async Task Comma_world_name_stays_visible_but_native_selection_and_playout_are_blocked() { var deferred = LegacyComparisonTarget.CreateWorldStock( "ACME, INC", "ACME", "US"); var data = new FakeDataService { WorldResults = [Apple, deferred], WorldDeferredUnsafeRowCount = 1 }; var controller = CreateController(data); var searched = await controller.SearchWorldAsync(string.Empty); var row = Assert.Single(searched.WorldSearch.Results, item => !item.CanSelect); Assert.Equal(2, searched.WorldSearch.Results.Count); Assert.Equal(1, searched.WorldSearch.DeferredUnsafeRowCount); Assert.Contains("쉼표", row.UnavailableReason, StringComparison.Ordinal); var selected = controller.SelectWorldResult(row.SelectionId); var chosen = controller.ChooseWorldResult(row.SelectionId); Assert.Null(selected.WorldSearch.SelectedResultId); Assert.Null(chosen.FirstTarget); Assert.Equal(LegacyOperatorStatusKind.Warning, chosen.StatusKind); Assert.Throws(() => LegacyComparisonWorkflowCatalog.Materialize( "two-column-current", deferred, Apple, "comparison-draft-safe-id")); } [Fact] public async Task Search_RejectsDuplicateOrWrongUniverseResults() { var duplicateData = new FakeDataService { DomesticResults = [Samsung, Samsung] }; var duplicateController = CreateController(duplicateData); await Assert.ThrowsAsync( () => duplicateController.SearchDomesticAsync("삼성")); Assert.Equal( LegacyComparisonSearchStatus.Error, duplicateController.Current.DomesticSearch.Status); Assert.Empty(duplicateController.Current.DomesticSearch.Results); var wrongUniverse = new FakeDataService { WorldResults = [Samsung] }; var wrongController = CreateController(wrongUniverse); await Assert.ThrowsAsync( () => wrongController.SearchWorldAsync("삼성")); Assert.Equal( LegacyComparisonSearchStatus.Error, wrongController.Current.WorldSearch.Status); } [Fact] public async Task LoadSavedPairs_ParsesStrictCp949AndDisplaysResolvedRows() { var data = new FakeDataService(); data.Resolve(Samsung, "삼성전자", "코스피"); data.Resolve(SkHynix, "SK하이닉스", "코스피"); data.Resolve(Apple, "APPLE", string.Empty); var store = new FakePairStore { Bytes = EncodeCp949( ("삼성전자", "코스피", "SK하이닉스", "코스피"), ("코스피 지수", string.Empty, "APPLE", string.Empty)) }; var controller = CreateController(data, store); var snapshot = await controller.LoadSavedPairsAsync(); Assert.False(snapshot.IsBusy); Assert.Equal(2, snapshot.SavedPairs.Count); Assert.Equal("삼성전자 ↔ SK하이닉스", snapshot.SavedPairs[0].DisplayLabel); Assert.Equal("코스피 지수 ↔ APPLE", snapshot.SavedPairs[1].DisplayLabel); Assert.True(snapshot.SavedPairs[0].IsSelected); Assert.Equal("comparison-pair-00000001", snapshot.SelectedPairId); Assert.Empty(store.Writes); Assert.Equal(3, data.ResolveRequests.Count); } [Fact] public async Task LoadSavedPairs_RejectsNonCp949NineFieldShapeWithoutChangingRows() { var store = new FakePairStore { Bytes = Encoding.UTF8.GetBytes("1^삼성전자,SK하이닉스^코스피^코스피^\n") }; var controller = CreateController(pairStore: store); await Assert.ThrowsAnyAsync(() => controller.LoadSavedPairsAsync()); Assert.Empty(controller.Current.SavedPairs); Assert.False(controller.Current.IsBusy); Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind); } [Fact] public async Task AddDraftPair_WritesOnlyThroughTypedStoreBoundary() { var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] }; var store = new FakePairStore(); var controller = CreateController(data, store); var search = await controller.SearchDomesticAsync(string.Empty); controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId); controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId); var snapshot = await controller.AddDraftPairAsync(); var write = Assert.Single(store.Writes); var row = Assert.Single(write); Assert.Equal( new LegacyComparisonPairWriteRow( 1, "SK하이닉스", "코스피", "삼성전자", "코스피"), row); Assert.Equal("SK하이닉스 ↔ 삼성전자", Assert.Single(snapshot.SavedPairs).DisplayLabel); Assert.False(snapshot.IsBusy); } [Fact] public async Task Uc3_AllowsExactDuplicatePairsAndMovesOrDeletesOnlyTheSelectedStableRow() { var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] }; var store = new FakePairStore(); var controller = CreateController(data, store); var search = await controller.SearchDomesticAsync(string.Empty); controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId); controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId); var firstAdd = await controller.AddDraftPairAsync(); var firstRowId = Assert.Single(firstAdd.SavedPairs).RowId; var secondAdd = await controller.AddDraftPairAsync(); var secondRowId = secondAdd.SavedPairs[1].RowId; Assert.NotEqual(firstRowId, secondRowId); Assert.Equal( new[] { firstRowId, secondRowId }, secondAdd.SavedPairs.Select(pair => pair.RowId)); Assert.All( secondAdd.SavedPairs, pair => Assert.Equal("SK하이닉스 ↔ 삼성전자", pair.DisplayLabel)); Assert.Collection( store.Writes[^1], row => Assert.Equal( new LegacyComparisonPairWriteRow( 1, "SK하이닉스", "코스피", "삼성전자", "코스피"), row), row => Assert.Equal( new LegacyComparisonPairWriteRow( 2, "SK하이닉스", "코스피", "삼성전자", "코스피"), row)); controller.SelectSavedPair(secondRowId); var moved = await controller.MoveSelectedPairAsync( LegacyComparisonPairMoveDirection.Up); Assert.Equal( new[] { secondRowId, firstRowId }, moved.SavedPairs.Select(pair => pair.RowId)); Assert.Equal(secondRowId, moved.SelectedPairId); Assert.True(moved.SavedPairs[0].IsSelected); Assert.False(moved.SavedPairs[1].IsSelected); var deleted = await controller.DeleteSelectedPairAsync(); var remaining = Assert.Single(deleted.SavedPairs); Assert.Equal(firstRowId, remaining.RowId); Assert.True(remaining.IsSelected); Assert.Equal(firstRowId, deleted.SelectedPairId); Assert.Single(store.Writes[^1]); } [Fact] public async Task Uc3_DuplicatePairSaveRoundTripPreservesPhysicalRowOrder() { var data = new FakeDataService(); data.Resolve(Samsung, "삼성전자", "코스피"); data.Resolve(SkHynix, "SK하이닉스", "코스피"); data.Resolve(Apple, "APPLE", string.Empty); var store = new FakePairStore { Bytes = EncodeCp949( ("삼성전자", "코스피", "SK하이닉스", "코스피"), ("삼성전자", "코스피", "SK하이닉스", "코스피"), ("코스피 지수", string.Empty, "APPLE", string.Empty)) }; var controller = CreateController(data, store); var loaded = await controller.LoadSavedPairsAsync(); var thirdRowId = loaded.SavedPairs[2].RowId; controller.SelectSavedPair(thirdRowId); var moved = await controller.MoveSelectedPairAsync( LegacyComparisonPairMoveDirection.Up); Assert.Equal( new[] { "삼성전자 ↔ SK하이닉스", "코스피 지수 ↔ APPLE", "삼성전자 ↔ SK하이닉스" }, moved.SavedPairs.Select(pair => pair.DisplayLabel)); Assert.Equal(new[] { 1, 2, 3 }, store.Writes[^1].Select(row => row.RowNumber)); var reloadedStore = new FakePairStore { Bytes = EncodeCp949(store.Writes[^1]) }; var reloaded = await CreateController(data, reloadedStore).LoadSavedPairsAsync(); Assert.Equal( moved.SavedPairs.Select(pair => pair.DisplayLabel), reloaded.SavedPairs.Select(pair => pair.DisplayLabel)); Assert.Equal(3, reloaded.SavedPairs.Select(pair => pair.RowId).Distinct().Count()); } [Fact] public async Task SavedPairMoveAndDelete_PreserveActiveRowAndPersistPhysicalOrder() { var data = new FakeDataService(); data.Resolve(Samsung, "삼성전자", "코스피"); data.Resolve(SkHynix, "SK하이닉스", "코스피"); data.Resolve(NxtSamsung, "삼성전자(NXT)", "코스피_NXT"); var store = new FakePairStore { Bytes = EncodeCp949( ("삼성전자", "코스피", "SK하이닉스", "코스피"), ("삼성전자(NXT)", "코스피_NXT", "삼성전자", "코스피")) }; var controller = CreateController(data, store); var loaded = await controller.LoadSavedPairsAsync(); controller.SelectSavedPair(loaded.SavedPairs[1].RowId); var moved = await controller.MoveSelectedPairAsync( LegacyComparisonPairMoveDirection.Up); Assert.Equal( new[] { "삼성전자(NXT) ↔ 삼성전자", "삼성전자 ↔ SK하이닉스" }, moved.SavedPairs.Select(pair => pair.DisplayLabel)); Assert.True(moved.SavedPairs[0].IsSelected); Assert.Equal("삼성전자(NXT)", store.Writes[^1][0].FirstName); var writesAtBoundary = store.Writes.Count; await controller.MoveSelectedPairAsync(LegacyComparisonPairMoveDirection.Up); Assert.Equal(writesAtBoundary, store.Writes.Count); var deleted = await controller.DeleteSelectedPairAsync(); Assert.Equal("삼성전자 ↔ SK하이닉스", Assert.Single(deleted.SavedPairs).DisplayLabel); Assert.True(deleted.SavedPairs[0].IsSelected); var empty = await controller.DeleteAllPairsAsync(); Assert.Empty(empty.SavedPairs); Assert.Null(empty.SelectedPairId); Assert.Empty(store.Writes[^1]); } [Fact] public async Task FailedWrite_RollsBackAuthoritativePairState() { var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] }; var store = new FakePairStore { ThrowOnWrite = true }; var controller = CreateController(data, store); var search = await controller.SearchDomesticAsync("삼성"); controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId); controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId); await Assert.ThrowsAsync(() => controller.AddDraftPairAsync()); Assert.Empty(controller.Current.SavedPairs); Assert.False(controller.Current.IsBusy); Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind); } [Fact] public async Task MaterializeSelectedAction_MapsAllNineClosedActionsFromActionIdOnly() { var controller = await CreateLoadedKrxPairControllerAsync(); Assert.Equal(9, controller.Current.Actions.Count); Assert.All(controller.Current.Actions, action => Assert.True(action.IsAvailable)); var method = typeof(LegacyComparisonWorkflowController).GetMethod( nameof(LegacyComparisonWorkflowController.MaterializeSelectedAction)); Assert.NotNull(method); Assert.Collection( method.GetParameters(), parameter => Assert.Equal("actionId", parameter.Name)); var drafts = controller.Current.Actions.ToDictionary( action => action.ActionId, action => controller.MaterializeSelectedAction(action.ActionId), StringComparer.Ordinal); Assert.Equal(9, drafts.Count); Assert.Equal(("s8018", "8032", "2열판", "EXPECTED", ""), Fields(drafts["two-column-expected"])); Assert.Equal(("s8018", "8032", "2열판", "CURRENT", ""), Fields(drafts["two-column-current"])); Assert.Equal(("s5026", "5026", "COMPARISON_CANDLE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-candle"])); Assert.Equal(("s5087", "5087", "COMPARISON_LINE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-line"])); Assert.Equal("FiveDays", drafts["return-5d"].Selection.Subtype); Assert.Equal("OneMonth", drafts["return-1m"].Selection.Subtype); Assert.Equal("ThreeMonths", drafts["return-3m"].Selection.Subtype); Assert.Equal("SixMonths", drafts["return-6m"].Selection.Subtype); Assert.Equal("TwelveMonths", drafts["return-12m"].Selection.Subtype); Assert.All(drafts.Where(pair => pair.Key.StartsWith("return-", StringComparison.Ordinal)), pair => { Assert.Equal("s5029", pair.Value.BuilderKey); Assert.Equal("5029", pair.Value.CutCode); Assert.Equal("RETURN_COMPARISON", pair.Value.Selection.GraphicType); }); Assert.All(drafts.Values, draft => { Assert.Equal("삼성전자,SK하이닉스", draft.Selection.Subject); Assert.Equal("legacy-comparison-workflow", draft.Source); Assert.Equal(6, draft.FadeDuration); Assert.Equal(new[] { draft.CutCode }, draft.Aliases); Assert.Equal(draft.DraftId, draft.ToLegacyPlaylistEntry().EntryId); }); Assert.Throws( () => controller.MaterializeSelectedAction("caller-builder-override")); } [Fact] public async Task FuturesExpectedAction_UsesClosedCurrentS5032CompatibilityBranch() { var store = new FakePairStore { Bytes = EncodeCp949( ("선물 지수", string.Empty, "코스피 지수", string.Empty)) }; var controller = CreateController(pairStore: store); await controller.LoadSavedPairsAsync(); var expected = controller.MaterializeSelectedAction("two-column-expected"); Assert.Equal("s5032", expected.BuilderKey); Assert.Equal("5032", expected.CutCode); Assert.Equal("EXPECTED", expected.Selection.Subtype); Assert.Equal("CURRENT", expected.EffectiveMode); Assert.NotNull(expected.Warning); Assert.False(controller.Current.Actions.Single( action => action.ActionId == "comparison-candle").IsAvailable); Assert.Throws( () => controller.MaterializeSelectedAction("comparison-candle")); } [Fact] public async Task Snapshots_RemainImmutableAfterLaterStateChanges() { var data = new FakeDataService { DomesticResults = [Samsung] }; var controller = CreateController(data); var before = controller.Current; var after = await controller.SearchDomesticAsync("삼성"); Assert.Empty(before.DomesticSearch.Results); Assert.Single(after.DomesticSearch.Results); var fixedCollection = Assert.IsAssignableFrom>( before.FixedTargets); Assert.Throws(() => fixedCollection.Add(before.FixedTargets[0])); var actionCollection = Assert.IsAssignableFrom>( before.Actions); Assert.Throws(() => actionCollection.Clear()); } [Fact] public async Task Import_preserves_current_order_appends_unseen_pairs_and_is_idempotent() { var data = new FakeDataService(); var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung); var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix); data.Resolve(Samsung, samsungStored.Name, samsungStored.Market); data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market); var store = new FakePairStore { Bytes = EncodeCp949( (samsungStored.Name, samsungStored.Market, skHynixStored.Name, skHynixStored.Market)) }; var importer = new FakeImporter(CreateImportResult( ImportKrx("kospi", Samsung.StockName!, Samsung.StockCode!), ImportKrx("kospi", SkHynix.StockName!, SkHynix.StockCode!), ImportWorld("APPLE", "AAPL", "US"), ImportMarket("Kospi"), ImportWorld("APPLE", "AAPL", "US"), ImportMarket("Kospi"))); var controller = CreateController(data, store, importer); await controller.LoadSavedPairsAsync(); var originalRowId = controller.Current.SavedPairs[0].RowId; var first = await controller.ImportLegacyPairsAsync(); Assert.Equal(2, first.SavedPairs.Count); Assert.Equal(originalRowId, first.SavedPairs[0].RowId); Assert.Equal("APPLE", first.SavedPairs[1].First.DisplayName); Assert.Equal("Kospi", first.SavedPairs[1].Second.Metadata); var receipt = Assert.IsType(first.LastImport); Assert.Equal(3, receipt.SourceRowCount); Assert.Equal(1, receipt.AddedPairCount); Assert.Equal(2, receipt.SkippedDuplicateCount); Assert.Equal(2, receipt.TotalPairCount); var write = Assert.Single(store.Writes); Assert.Equal(2, write.Count); Assert.Equal(Samsung.StockName, write[0].FirstName); Assert.Equal("APPLE", write[1].FirstName); var second = await controller.ImportLegacyPairsAsync(); Assert.Equal(2, second.SavedPairs.Count); Assert.Single(store.Writes); Assert.Equal(0, second.LastImport?.AddedPairCount); Assert.Equal(2, importer.Calls); } [Fact] public async Task Import_validates_every_resolved_target_before_writing() { var store = new FakePairStore(); var invalid = new LegacyComparisonImportTarget( LegacyComparisonImportTargetKind.KrxStock, Market: "kospi", StockName: Samsung.StockName, StockCode: "005930", Symbol: "forged-extra-field"); var importer = new FakeImporter(CreateImportResult( ImportWorld("APPLE", "AAPL", "US"), ImportMarket("Kospi"), invalid, ImportMarket("Kosdaq"))); var controller = CreateController(pairStore: store, importer: importer); await Assert.ThrowsAsync( () => controller.ImportLegacyPairsAsync()); Assert.Empty(store.Writes); Assert.Empty(controller.Current.SavedPairs); Assert.Null(controller.Current.LastImport); } [Fact] public async Task Import_store_failure_rolls_back_controller_state() { var data = new FakeDataService(); var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung); var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix); data.Resolve(Samsung, samsungStored.Name, samsungStored.Market); data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market); var store = new FakePairStore { Bytes = EncodeCp949( (samsungStored.Name, samsungStored.Market, skHynixStored.Name, skHynixStored.Market)), ThrowOnWrite = true }; var importer = new FakeImporter(CreateImportResult( ImportWorld("APPLE", "AAPL", "US"), ImportMarket("Kospi"))); var controller = CreateController(data, store, importer); await controller.LoadSavedPairsAsync(); var before = controller.Current.SavedPairs.Select(row => row.RowId).ToArray(); await Assert.ThrowsAsync(() => controller.ImportLegacyPairsAsync()); Assert.Equal(before, controller.Current.SavedPairs.Select(row => row.RowId)); Assert.Null(controller.Current.LastImport); Assert.Empty(store.Writes); } private static LegacyComparisonWorkflowController CreateController( FakeDataService? dataService = null, FakePairStore? pairStore = null, ILegacyComparisonPairImportService? importer = null) => new( dataService ?? new FakeDataService(), pairStore ?? new FakePairStore(), pairImporter: importer); private static LegacyComparisonImportResult CreateImportResult( params LegacyComparisonImportTarget[] targets) { Assert.True(targets.Length % 2 == 0); var pairs = targets.Chunk(2).Select((pair, index) => new LegacyComparisonImportPair(index + 1, pair[0], pair[1])).ToArray(); return new LegacyComparisonImportResult( new string('A', 64), pairs.Length, DateTimeOffset.Parse("2026-07-22T12:00:00+09:00"), Array.AsReadOnly(pairs)); } private static LegacyComparisonImportTarget ImportKrx( string market, string name, string code) => new( LegacyComparisonImportTargetKind.KrxStock, Market: market, StockName: name, StockCode: code); private static LegacyComparisonImportTarget ImportWorld( string name, string symbol, string nation) => new( LegacyComparisonImportTargetKind.WorldStock, InputName: name, Symbol: symbol, Nation: nation); private static LegacyComparisonImportTarget ImportMarket(string target) => new(LegacyComparisonImportTargetKind.MarketTarget, MarketTarget: target); private static async Task CreateLoadedKrxPairControllerAsync() { var data = new FakeDataService(); data.Resolve(Samsung, "삼성전자", "코스피"); data.Resolve(SkHynix, "SK하이닉스", "코스피"); var store = new FakePairStore { Bytes = EncodeCp949( ("삼성전자", "코스피", "SK하이닉스", "코스피")) }; var controller = CreateController(data, store); await controller.LoadSavedPairsAsync(); return controller; } private static ( string BuilderKey, string CutCode, string GraphicType, string Subtype, string DataCode) Fields(LegacyGenericPlaylistDraft draft) => ( draft.BuilderKey, draft.CutCode, draft.Selection.GraphicType, draft.Selection.Subtype, draft.Selection.DataCode); private static byte[] EncodeCp949( params (string First, string FirstMarket, string Second, string SecondMarket)[] rows) { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var encoding = Encoding.GetEncoding( 949, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); var lines = rows.Select((row, index) => string.Join( '^', (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture), $"{row.First},{row.Second}", row.FirstMarket, row.SecondMarket, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty)); return encoding.GetBytes(string.Join("\r\n", lines) + "\r\n"); } private static byte[] EncodeCp949(IReadOnlyList rows) => EncodeCp949(rows.Select(row => ( row.FirstName, row.FirstMarket, row.SecondName, row.SecondMarket)).ToArray()); private sealed class FakeDataService : ILegacyComparisonDataService, ILegacyComparisonDataDiagnosticsService { private readonly Dictionary _resolved = []; public IReadOnlyList DomesticResults { get; init; } = []; public IReadOnlyList WorldResults { get; init; } = []; public bool DomesticIsTruncated { get; init; } public bool WorldIsTruncated { get; init; } public int DomesticIsolatedInvalidRowCount { get; init; } public int WorldIsolatedInvalidRowCount { get; init; } public int DomesticDeferredUnsafeRowCount { get; init; } public int WorldDeferredUnsafeRowCount { get; init; } public string? DomesticQuery { get; private set; } public string? WorldQuery { get; private set; } public int DomesticMaximumResults { get; private set; } public int WorldMaximumResults { get; private set; } public List ResolveRequests { get; } = []; public void Resolve(LegacyComparisonTarget target, string name, string market) => _resolved[new LegacyComparisonStoredTargetReference(name, market)] = target; public Task> SearchDomesticAsync( string query, int maximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); DomesticQuery = query; DomesticMaximumResults = maximumResults; return Task.FromResult(DomesticResults); } public Task> SearchWorldAsync( string query, int maximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); WorldQuery = query; WorldMaximumResults = maximumResults; return Task.FromResult(WorldResults); } public Task SearchDomesticWithDiagnosticsAsync( string query, int maximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); DomesticQuery = query; DomesticMaximumResults = maximumResults; return Task.FromResult(new LegacyComparisonSearchData( DomesticResults, DomesticIsTruncated, DomesticIsolatedInvalidRowCount) { DeferredUnsafeRowCount = DomesticDeferredUnsafeRowCount }); } public Task SearchWorldWithDiagnosticsAsync( string query, int maximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); WorldQuery = query; WorldMaximumResults = maximumResults; return Task.FromResult(new LegacyComparisonSearchData( WorldResults, WorldIsTruncated, WorldIsolatedInvalidRowCount) { DeferredUnsafeRowCount = WorldDeferredUnsafeRowCount }); } public Task ResolveStoredTargetAsync( LegacyComparisonStoredTargetReference target, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ResolveRequests.Add(target); _resolved.TryGetValue(target, out var resolved); return Task.FromResult(resolved); } } private sealed class FakePairStore : ILegacyComparisonPairStore { public byte[]? Bytes { get; init; } public bool ThrowOnWrite { get; init; } public List> Writes { get; } = []; public Task ReadCp949Async(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(Bytes?.ToArray()); } public Task WriteCp949Async( IReadOnlyList rows, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (ThrowOnWrite) { throw new IOException("Synthetic comparison store failure."); } Writes.Add(Array.AsReadOnly(rows.ToArray())); return Task.CompletedTask; } } private sealed class FakeImporter(LegacyComparisonImportResult result) : ILegacyComparisonPairImportService { public int Calls { get; private set; } public Task ImportAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Calls++; return Task.FromResult(result); } } }