using MBN_STOCK_WEBVIEW.LegacyApplication; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyIndustrySelectionWorkflowTests { [Fact] public async Task LoadMarketUsesServiceOrderAndCopiesRowsIntoImmutableSnapshot() { var source = KospiRows().ToList(); var service = new QueueIndustryService(source); var workflow = new LegacyIndustrySelectionWorkflow(service); var state = await workflow.LoadMarketAsync(IndustryMarket.Kospi); source.Clear(); Assert.Equal([IndustryMarket.Kospi], service.Markets); Assert.Equal(3, state.Industries.Count); Assert.Equal("전기전자", state.Industries[0].IndustryName); Assert.Equal(22, state.Actions.Count); Assert.Null(state.SelectedIndustry); Assert.Null(state.FirstIndustry); Assert.Null(state.SecondIndustry); Assert.Equal(string.Empty, state.FirstText); Assert.Equal(string.Empty, state.SecondText); var rows = Assert.IsAssignableFrom>(state.Industries); Assert.Throws(() => rows.RemoveAt(0)); var actions = Assert.IsAssignableFrom>(state.Actions); Assert.Throws(() => actions.RemoveAt(0)); } [Fact] public async Task LoadMarketPreservesDuplicateRowsAndSelectionUsesTheClickedIndex() { var workflow = new LegacyIndustrySelectionWorkflow(new QueueIndustryService( [ new IndustrySelection(IndustryMarket.Kospi, "동명이 업종", "013"), new IndustrySelection(IndustryMarket.Kospi, "동명이 업종", "014"), new IndustrySelection(IndustryMarket.Kospi, "다른 업종", "013") ])); var loaded = await workflow.LoadMarketAsync(IndustryMarket.Kospi); var selected = workflow.SelectIndustry(1); Assert.Equal(3, loaded.Industries.Count); Assert.Equal(["013", "014", "013"], loaded.Industries.Select(static row => row.IndustryCode)); Assert.Equal(1, selected.SelectedIndex); Assert.Equal("014", selected.SelectedIndustry?.IndustryCode); } [Fact] public async Task ListDoubleClickRotatesFirstIntoSecondBeforeAssigningNewFirst() { var workflow = await KospiWorkflowAsync(); var selectedOnly = workflow.SelectIndustry(1); Assert.Equal("운수장비", selectedOnly.SelectedIndustry?.IndustryName); Assert.Null(selectedOnly.FirstIndustry); var first = workflow.DoubleClickIndustry(0); Assert.Equal("전기전자", first.SelectedIndustry?.IndustryName); Assert.Equal("전기전자", first.FirstIndustry?.IndustryName); Assert.Null(first.SecondIndustry); var second = workflow.DoubleClickIndustry(2); Assert.Equal("화학", second.SelectedIndustry?.IndustryName); Assert.Equal("화학", second.FirstIndustry?.IndustryName); Assert.Equal("전기전자", second.SecondIndustry?.IndustryName); var third = workflow.DoubleClickIndustry(1); Assert.Equal("운수장비", third.FirstIndustry?.IndustryName); Assert.Equal("화학", third.SecondIndustry?.IndustryName); } [Fact] public async Task SwapMatchesUc2EvenWhenOnlyOneTextSlotHasAValue() { var workflow = await KospiWorkflowAsync(); workflow.DoubleClickIndustry(0); var swapped = workflow.SwapIndustries(); Assert.Null(swapped.FirstIndustry); Assert.Equal("전기전자", swapped.SecondIndustry?.IndustryName); Assert.Equal(string.Empty, swapped.FirstText); Assert.Equal("전기전자", swapped.SecondText); var restored = workflow.SwapIndustries(); Assert.Equal("전기전자", restored.FirstIndustry?.IndustryName); Assert.Null(restored.SecondIndustry); Assert.Equal("전기전자", restored.FirstText); Assert.Equal(string.Empty, restored.SecondText); } [Fact] public async Task EditableComparisonTextRemainsCSharpAuthoritativeAndAffectsOnlyPairAction() { var workflow = await KospiWorkflowAsync(); workflow.SelectIndustry(0); var first = workflow.SetComparisonText( LegacyIndustryComparisonSlot.First, "직접 입력 업종"); var second = workflow.SetComparisonText( LegacyIndustryComparisonSlot.Second, "또 다른 업종"); Assert.Null(first.FirstIndustry); Assert.Equal("직접 입력 업종", second.FirstText); Assert.Equal("또 다른 업종", second.SecondText); Assert.Equal("직접 입력 업종-또 다른 업종", workflow.MaterializePlaylistDraft("two-column").Selection.Subject); Assert.Equal("전기전자", workflow.MaterializePlaylistDraft("one-column").Selection.Subject); var swapped = workflow.SwapIndustries(); Assert.Equal("또 다른 업종", swapped.FirstText); Assert.Equal("직접 입력 업종", swapped.SecondText); workflow.SetComparisonText(LegacyIndustryComparisonSlot.First, "수동 보존값"); var rotated = workflow.DoubleClickIndustry(2); Assert.Equal("화학", rotated.FirstText); Assert.Equal("수동 보존값", rotated.SecondText); Assert.Equal("화학", rotated.FirstIndustry?.IndustryName); Assert.Null(rotated.SecondIndustry); } [Fact] public async Task EditableComparisonTextValidatesAtBridgeCompatibleBoundary() { var workflow = await KospiWorkflowAsync(); workflow.SelectIndustry(0); var maximum = new string('가', LegacyIndustrySelectionWorkflow.MaximumComparisonTextLength); Assert.Equal(maximum, workflow.SetComparisonText( LegacyIndustryComparisonSlot.First, maximum).FirstText); Assert.Throws(() => workflow.SetComparisonText( LegacyIndustryComparisonSlot.First, maximum + "가")); Assert.Throws(() => workflow.SetComparisonText( LegacyIndustryComparisonSlot.First, "업종\n이름")); Assert.Throws(() => workflow.SetComparisonText( LegacyIndustryComparisonSlot.First, "업종\ud83d\ude00")); workflow.SetComparisonText(LegacyIndustryComparisonSlot.First, "공백 아님"); workflow.SetComparisonText(LegacyIndustryComparisonSlot.Second, " "); Assert.Throws(() => workflow.MaterializePlaylistDraft("two-column")); workflow.SetComparisonText(LegacyIndustryComparisonSlot.Second, "하이-픈"); Assert.Throws(() => workflow.MaterializePlaylistDraft("two-column")); } [Fact] public async Task SameMarketRefreshReconcilesIdentityButMarketChangeClearsSelection() { var initial = KospiRows(); var refreshed = new[] { initial[1], initial[0], initial[2] }; var kosdaq = KosdaqRows(); var service = new QueueIndustryService(initial, refreshed, kosdaq); var workflow = new LegacyIndustrySelectionWorkflow(service); await workflow.LoadMarketAsync(IndustryMarket.Kospi); workflow.DoubleClickIndustry(0); workflow.DoubleClickIndustry(1); var reconciled = await workflow.LoadMarketAsync(IndustryMarket.Kospi); Assert.Equal(0, reconciled.SelectedIndex); Assert.Equal("운수장비", reconciled.SelectedIndustry?.IndustryName); Assert.Equal("운수장비", reconciled.FirstIndustry?.IndustryName); Assert.Equal("전기전자", reconciled.SecondIndustry?.IndustryName); Assert.Equal("운수장비", reconciled.FirstText); Assert.Equal("전기전자", reconciled.SecondText); var changed = await workflow.LoadMarketAsync(IndustryMarket.Kosdaq); Assert.Equal(IndustryMarket.Kosdaq, changed.Market); Assert.Null(changed.SelectedIndex); Assert.Null(changed.SelectedIndustry); Assert.Null(changed.FirstIndustry); Assert.Null(changed.SecondIndustry); Assert.Equal(string.Empty, changed.FirstText); Assert.Equal(string.Empty, changed.SecondText); } [Fact] public async Task InvalidIndexAndEmptyClearAreNoOps() { var workflow = await KospiWorkflowAsync(); var before = workflow.Current; Assert.Equal(before.Revision, workflow.SelectIndustry(-1).Revision); Assert.Equal(before.Revision, workflow.DoubleClickIndustry(3).Revision); Assert.Equal(before.Revision, workflow.ClearSelections().Revision); Assert.Equal(before.Revision, workflow.SwapIndustries().Revision); } [Fact] public async Task RepresentativeActionsMaterializeOnlyFromCatalogAndAuthoritativeState() { var workflow = await KospiWorkflowAsync(new FixedTimeProvider( new DateTimeOffset(2026, 7, 11, 10, 0, 0, TimeSpan.Zero))); workflow.DoubleClickIndustry(0); workflow.DoubleClickIndustry(1); var single = workflow.MaterializePlaylistDraft("one-column"); Assert.Equal("s5001", single.BuilderKey); Assert.Equal("5001", single.SceneAlias); Assert.Equal("운수장비", single.Title); Assert.Equal( new LegacyIndustrySceneSelectionDraft( "INDUSTRY_KOSPI", "운수장비", "1열판", "CURRENT", string.Empty), single.Selection); Assert.True(single.IsEnabled); Assert.Equal(6, single.FadeDuration); var pair = workflow.MaterializePlaylistDraft("two-column"); Assert.Equal("s8018", pair.BuilderKey); Assert.Equal("8018", pair.SceneAlias); Assert.Equal("운수장비-전기전자", pair.Selection.Subject); Assert.Equal("2열판", pair.Selection.GraphicType); var yield = workflow.MaterializePlaylistDraft("yield-120일"); Assert.Equal("s5086", yield.BuilderKey); Assert.Equal("5086", yield.SceneAlias); Assert.Equal("OneHundredTwentyDays", yield.Selection.Subtype); var candle = workflow.MaterializePlaylistDraft("candle-volume-240일"); Assert.Equal("s8010", candle.BuilderKey); Assert.Equal("8056", candle.SceneAlias); Assert.Equal("KOSPI_INDUSTRY", candle.Selection.GroupCode); Assert.Equal("VOLUME", candle.Selection.Subtype); var five = workflow.MaterializePlaylistDraft("five-row"); Assert.Equal("s5074", five.BuilderKey); Assert.Equal("5074", five.SceneAlias); Assert.Equal(5, five.PageSize); Assert.Equal("PAGED_DOMESTIC_KOSPI", five.Selection.GroupCode); Assert.Equal("INDEX", five.Selection.Subject); Assert.Equal("INDUSTRY", five.Selection.Subtype); Assert.Equal("2026-07-11", five.Selection.DataCode); Assert.Equal( "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52", five.RuntimeAssetSha256); var square = workflow.MaterializePlaylistDraft("square"); Assert.Equal("8001", square.SceneAlias); Assert.Equal("KOSPI", square.Selection.Subject); Assert.Equal("네모그래프", square.Selection.GraphicType); var sector = workflow.MaterializePlaylistDraft("sector"); Assert.Equal("5078", sector.SceneAlias); Assert.Equal("SECTOR_INDEX", sector.Selection.GraphicType); } [Fact] public async Task EveryKosdaqActionMaterializesWithItsExactAliasAndPageSize() { var service = new QueueIndustryService(KosdaqRows()); var workflow = new LegacyIndustrySelectionWorkflow( service, new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 0, 0, 0, TimeSpan.Zero))); await workflow.LoadMarketAsync(IndustryMarket.Kosdaq); workflow.DoubleClickIndustry(0); workflow.DoubleClickIndustry(1); foreach (var action in LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq)) { var draft = workflow.MaterializePlaylistDraft(action.ActionId); Assert.Equal(action.ActionId, draft.ActionId); Assert.Equal(action.BuilderKey, draft.BuilderKey); Assert.Equal(action.SceneAlias, draft.SceneAlias); Assert.Equal(action.PageSize, draft.PageSize); Assert.Equal(IndustryMarket.Kosdaq, draft.Market); } Assert.Equal("8032", workflow.MaterializePlaylistDraft("two-column").SceneAlias); Assert.Equal("8002", workflow.MaterializePlaylistDraft("square").SceneAlias); Assert.Equal( "PAGED_DOMESTIC_KOSDAQ", workflow.MaterializePlaylistDraft("five-row").Selection.GroupCode); } [Fact] public async Task SelectionPrerequisitesUnsafePairAndUnknownActionFailClosed() { var workflow = await KospiWorkflowAsync(); Assert.Throws(() => workflow.MaterializePlaylistDraft("one-column")); Assert.Throws(() => workflow.MaterializePlaylistDraft("two-column")); Assert.NotNull(workflow.MaterializePlaylistDraft("five-row")); Assert.Throws(() => workflow.MaterializePlaylistDraft("../../5001")); Assert.Throws(() => workflow.MaterializePlaylistDraft(null!)); var unsafePair = new LegacyIndustrySelectionWorkflow(new QueueIndustryService( [ new IndustrySelection(IndustryMarket.Kospi, "전기-전자", "013"), new IndustrySelection(IndustryMarket.Kospi, "운수장비", "015") ])); await unsafePair.LoadMarketAsync(IndustryMarket.Kospi); unsafePair.DoubleClickIndustry(0); unsafePair.DoubleClickIndustry(1); Assert.Throws(() => unsafePair.MaterializePlaylistDraft("two-column")); } [Fact] public async Task InvalidServiceRowsFailAtomicallyBeforeStateChanges() { var service = new QueueIndustryService( [ new IndustrySelection(IndustryMarket.Kosdaq, "반도체", "027") ]); var workflow = new LegacyIndustrySelectionWorkflow(service); await Assert.ThrowsAsync(() => workflow.LoadMarketAsync(IndustryMarket.Kospi)); Assert.Equal(0, workflow.Current.Revision); Assert.Null(workflow.Current.Market); Assert.Empty(workflow.Current.Industries); } [Fact] public async Task SlowerPreviousMarketResponseCannotReplaceOrFaultNewerMarketState() { var service = new ControlledIndustryService(); var workflow = new LegacyIndustrySelectionWorkflow(service); var older = workflow.LoadMarketAsync(IndustryMarket.Kospi); var newer = workflow.LoadMarketAsync(IndustryMarket.Kosdaq); service.Kosdaq.SetResult(KosdaqRows()); var newerState = await newer; // This row is invalid for the older KOSPI request. Because that request is // already stale, it must be ignored before it can disturb current state. service.Kospi.SetResult(KosdaqRows()); var olderResult = await older; Assert.Equal(IndustryMarket.Kosdaq, newerState.Market); Assert.Equal(IndustryMarket.Kosdaq, olderResult.Market); Assert.Equal(IndustryMarket.Kosdaq, workflow.Current.Market); Assert.Equal("반도체", workflow.Current.Industries[0].IndustryName); Assert.Equal(1, workflow.Current.Revision); } [Fact] public async Task ClearSelectionsClearsHighlightedAndBothPairSlots() { var workflow = await KospiWorkflowAsync(); workflow.DoubleClickIndustry(0); workflow.DoubleClickIndustry(1); var cleared = workflow.ClearSelections(); Assert.Null(cleared.SelectedIndex); Assert.Null(cleared.SelectedIndustry); Assert.Null(cleared.FirstIndustry); Assert.Null(cleared.SecondIndustry); } private static async Task KospiWorkflowAsync( TimeProvider? timeProvider = null) { var workflow = new LegacyIndustrySelectionWorkflow( new QueueIndustryService(KospiRows()), timeProvider); await workflow.LoadMarketAsync(IndustryMarket.Kospi); return workflow; } private static IndustrySelection[] KospiRows() => [ new(IndustryMarket.Kospi, "전기전자", "013"), new(IndustryMarket.Kospi, "운수장비", "015"), new(IndustryMarket.Kospi, "화학", "008") ]; private static IndustrySelection[] KosdaqRows() => [ new(IndustryMarket.Kosdaq, "반도체", "027"), new(IndustryMarket.Kosdaq, "IT부품", "029") ]; private sealed class QueueIndustryService( params IReadOnlyList[] results) : IIndustrySelectionService { private readonly Queue> _results = new(results); public List Markets { get; } = []; public Task> GetAsync( IndustryMarket market, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Markets.Add(market); if (_results.Count == 0) { throw new InvalidOperationException("No fake industry result remains."); } return Task.FromResult(_results.Dequeue()); } } private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider { public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; public override DateTimeOffset GetUtcNow() => utcNow; } private sealed class ControlledIndustryService : IIndustrySelectionService { public TaskCompletionSource> Kospi { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource> Kosdaq { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task> GetAsync( IndustryMarket market, CancellationToken cancellationToken = default) => market switch { IndustryMarket.Kospi => Kospi.Task.WaitAsync(cancellationToken), IndustryMarket.Kosdaq => Kosdaq.Task.WaitAsync(cancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(market), market, null) }; } }