using System.Text; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; 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(200, 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(100, 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_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()); } private static LegacyComparisonWorkflowController CreateController( FakeDataService? dataService = null, FakePairStore? pairStore = null) => new(dataService ?? new FakeDataService(), pairStore ?? new FakePairStore()); 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 { private readonly Dictionary _resolved = []; public IReadOnlyList DomesticResults { get; init; } = []; public IReadOnlyList WorldResults { 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 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; } } }