using MBN_STOCK_WEBVIEW.LegacyApplication; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyOverseasSelectionWorkflowTests { private static readonly DateTimeOffset RetrievedAt = new(2026, 7, 15, 9, 30, 0, TimeSpan.FromHours(9)); [Fact] public void InitialSnapshotIsTypedImmutableAndKeepsFarPointFirstFixedRowActive() { var workflow = CreateWorkflow(); var snapshot = workflow.Current; Assert.Equal(0, snapshot.Revision); Assert.Equal(10, snapshot.FixedIndexTargets.Count); Assert.Equal(12, snapshot.Actions.Count); Assert.Equal("dow-jones", snapshot.SelectedFixedIndex?.TargetId); Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Industry.Status); Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Stock.Status); Assert.Empty(snapshot.Industry.Results); Assert.Empty(snapshot.Stock.Results); Assert.Equal(default, snapshot.MovingAverages); var targets = Assert.IsAssignableFrom>( snapshot.FixedIndexTargets); var actions = Assert.IsAssignableFrom>( snapshot.Actions); Assert.True(targets.IsReadOnly); Assert.True(actions.IsReadOnly); Assert.Throws(() => targets.RemoveAt(0)); Assert.Throws(() => actions.RemoveAt(0)); } [Fact] public async Task SearchesUseInjectedInterfacesNormalizeQueriesAndIssueGenerationBoundIds() { var industryItems = new List { new("Alpha", "Alpha Industry", "NAS@ALPHA"), new("Beta", "Beta Industry", "NAS@BETA") }; var stockItems = new List { new("Apple", "NAS@AAPL", "US"), new("Taiwan Semi", "TWS@2330", "TW") }; var industry = new RecordingIndustryService(industryItems); var stock = new RecordingStockService(stockItems); var workflow = new LegacyOverseasSelectionWorkflow(industry, stock); var industryState = await workflow.SearchIndustriesAsync(" alpha ", 25); var stockState = await workflow.SearchStocksAsync(" semi ", 30); industryItems.Clear(); stockItems.Clear(); Assert.Equal([("alpha", 25)], industry.Calls); Assert.Equal([("semi", 30)], stock.Calls); Assert.Equal(LegacyOverseasSearchStatus.Ready, industryState.Industry.Status); Assert.Equal("alpha", industryState.Industry.Query); Assert.Equal(RetrievedAt, industryState.Industry.RetrievedAt); Assert.Equal( ["overseas-industry-00000001-0001", "overseas-industry-00000001-0002"], industryState.Industry.Results.Select(row => row.SelectionId)); Assert.Equal( ["overseas-stock-00000001-0001", "overseas-stock-00000001-0002"], stockState.Stock.Results.Select(row => row.SelectionId)); Assert.Equal("Alpha", industryState.Industry.Selected?.KoreanName); Assert.Equal("Apple", stockState.Stock.Selected?.InputName); Assert.Equal(2, workflow.Current.Industry.ResultCount); Assert.Equal(2, workflow.Current.Stock.ResultCount); var industryRows = Assert.IsAssignableFrom>( workflow.Current.Industry.Results); var stockRows = Assert.IsAssignableFrom>( workflow.Current.Stock.Results); Assert.True(industryRows.IsReadOnly); Assert.True(stockRows.IsReadOnly); Assert.Throws(() => industryRows.RemoveAt(0)); Assert.Throws(() => stockRows.RemoveAt(0)); } [Fact] public async Task FirstInsertedSearchRowsBecomeTheActiveSourcesAtTheFullCompatibilityBound() { var industry = new RecordingIndustryService( [new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")]); var stock = new RecordingStockService( [new WorldStockSearchItem("Apple", "NAS@AAPL", "US")]); var workflow = new LegacyOverseasSelectionWorkflow(industry, stock); var industries = await workflow.SearchIndustriesAsync("alpha"); var stocks = await workflow.SearchStocksAsync("apple"); Assert.Equal("Alpha", industries.Industry.Selected?.KoreanName); Assert.Equal("Apple", stocks.Stock.Selected?.InputName); Assert.Equal( [("alpha", LegacyOverseasSelectionWorkflow.DefaultMaximumResults)], industry.Calls); Assert.Equal( [("apple", LegacyOverseasStockSearchService.MaximumResults)], stock.Calls); } [Fact] public async Task SelectionUsesCurrentOpaqueIdsAndARefreshInvalidatesOldIds() { var industry = new QueueIndustryService( IndustryResult( "", new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")), IndustryResult( "next", new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA"))); var stock = new QueueStockService( StockResult("", new WorldStockSearchItem("Apple", "NAS@AAPL", "US")), StockResult("next", new WorldStockSearchItem("Taiwan Semi", "TWS@2330", "TW"))); var workflow = new LegacyOverseasSelectionWorkflow(industry, stock); var firstIndustry = await workflow.SearchIndustriesAsync(""); var oldIndustryId = firstIndustry.Industry.Results[0].SelectionId; Assert.Equal( "Alpha", workflow.SelectIndustry(oldIndustryId).Industry.Selected?.KoreanName); var firstStock = await workflow.SearchStocksAsync(""); var oldStockId = firstStock.Stock.Results[0].SelectionId; Assert.Equal("Apple", workflow.SelectStock(oldStockId).Stock.Selected?.InputName); await workflow.SearchIndustriesAsync("next"); await workflow.SearchStocksAsync("next"); Assert.Equal("Beta", workflow.Current.Industry.Selected?.KoreanName); Assert.Equal("Taiwan Semi", workflow.Current.Stock.Selected?.InputName); Assert.Throws(() => workflow.SelectIndustry(oldIndustryId)); Assert.Throws(() => workflow.SelectStock(oldStockId)); Assert.StartsWith( "overseas-industry-00000002-", workflow.Current.Industry.Results[0].SelectionId, StringComparison.Ordinal); Assert.StartsWith( "overseas-stock-00000002-", workflow.Current.Stock.Results[0].SelectionId, StringComparison.Ordinal); } [Fact] public void FixedIndexActionsMaterializeExactAliasIdentityPageAndMovingAverages() { var workflow = CreateWorkflow(); workflow.SelectFixedIndex("germany-dax-30"); workflow.SetMovingAverages(ma5: true, ma20: false); var expected = new Dictionary(StringComparer.Ordinal) { ["index-candle-5d"] = ("8061", 5), ["index-candle-20d"] = ("8040", 20), ["index-candle-60d"] = ("8046", 60), ["index-candle-120d"] = ("8051", 120), ["index-candle-240d"] = ("8056", 240) }; foreach (var pair in expected) { var draft = workflow.MaterializePlaylistDraft(pair.Key); Assert.Equal("s8010", draft.BuilderKey); Assert.Equal(pair.Value.Alias, draft.SceneAlias); Assert.Equal([pair.Value.Alias], draft.Aliases); Assert.Equal(0, draft.PageSize); Assert.Equal("1/1", draft.InitialPageText); Assert.Equal("독일 DAX 30", draft.Title); Assert.Equal("chart", draft.Category); Assert.Equal("overseas", draft.Market); Assert.Equal(LegacyOverseasActionSource.FixedIndex, draft.Source); Assert.Equal( new LegacyOverseasPlaylistSelection( "FOREIGN_INDEX", "독일 DAX 30", "MA5", "PRICE", "XTR@DAX30|DE|0"), draft.Selection); Assert.Equal(new LegacyOverseasMovingAverageSelection(true, false), draft.MovingAverages); Assert.True(draft.IsEnabled); Assert.Equal(6, draft.FadeDuration); Assert.Equal( new LegacyOverseasPlaylistVisibleFields( "해외지수", "독일 DAX 30", "캔들그래프", $"{pair.Value.Days}일", "1/1"), draft.VisibleFields); } } [Fact] public async Task IndustryCurrentMaterializesRootWorkflowSelectionAndIgnoresMaOptions() { var workflow = CreateWorkflow( industries: [new("반도체", "US Semiconductor", "NAS@SOX")]); var searched = await workflow.SearchIndustriesAsync(""); workflow.SelectIndustry(searched.Industry.Results[0].SelectionId); workflow.SetMovingAverages(ma5: true, ma20: true); var draft = workflow.MaterializePlaylistDraft("industry-current"); Assert.Equal("s5001", draft.BuilderKey); Assert.Equal("5001", draft.SceneAlias); Assert.Equal(0, draft.PageSize); Assert.Equal("1/1", draft.InitialPageText); Assert.Equal("반도체", draft.Title); Assert.Equal("plate", draft.Category); Assert.Equal( new LegacyOverseasPlaylistSelection( "FOREIGN_INDUSTRY", "반도체", "1열판기본", "CURRENT", "US Semiconductor|NAS@SOX|US|0"), draft.Selection); Assert.Equal(default, draft.MovingAverages); Assert.Equal( new LegacyOverseasPlaylistVisibleFields( "해외업종", "반도체", "1열판기본", string.Empty, "1/1"), draft.VisibleFields); } [Fact] public async Task StockCurrentAndAllCandleActionsUseSelectedUsTwIdentity() { var workflow = CreateWorkflow( stocks: [new("Taiwan Semi", "TWS@2330", "TW")]); var searched = await workflow.SearchStocksAsync("semi"); workflow.SelectStock(searched.Stock.Results[0].SelectionId); workflow.SetMovingAverages(ma5: true, ma20: true); var current = workflow.MaterializePlaylistDraft("stock-current"); Assert.Equal("s5001", current.BuilderKey); Assert.Equal("5001", current.SceneAlias); Assert.Equal("stock", current.Category); Assert.Equal( new LegacyOverseasPlaylistSelection( "FOREIGN_STOCK", "Taiwan Semi", "1열판기본", "CURRENT", "TWS@2330|TW|1"), current.Selection); Assert.Equal(default, current.MovingAverages); Assert.Equal( new LegacyOverseasPlaylistVisibleFields( "해외종목", "Taiwan Semi", "1열판기본", string.Empty, "1/1"), current.VisibleFields); var expectedAliases = new Dictionary(StringComparer.Ordinal) { ["stock-candle-5d"] = "8061", ["stock-candle-20d"] = "8040", ["stock-candle-60d"] = "8046", ["stock-candle-120d"] = "8051", ["stock-candle-240d"] = "8056" }; foreach (var pair in expectedAliases) { var candle = workflow.MaterializePlaylistDraft(pair.Key); Assert.Equal("s8010", candle.BuilderKey); Assert.Equal(pair.Value, candle.SceneAlias); Assert.Equal("chart", candle.Category); Assert.Equal("MA5,MA20", candle.Selection.GraphicType); Assert.Equal("PRICE", candle.Selection.Subtype); Assert.Equal("TWS@2330|TW|1", candle.Selection.DataCode); Assert.Equal(new LegacyOverseasMovingAverageSelection(true, true), candle.MovingAverages); Assert.Equal( new LegacyOverseasPlaylistVisibleFields( "해외종목", "Taiwan Semi", "캔들그래프", $"{LegacyOverseasActionCatalog.GetAction(pair.Key).PeriodDays}일", "1/1"), candle.VisibleFields); } } [Fact] public async Task CurrentProviderPunctuationRemainsAnExactBoundStockIdentity() { var workflow = CreateWorkflow(stocks: [ new("Ampersand", "IDX@A&B", "US"), new("Parenthesized", "IDX@(A)", "US") ]); var searched = await workflow.SearchStocksAsync(string.Empty); Assert.Equal(LegacyOverseasSearchStatus.Ready, searched.Stock.Status); Assert.Equal(2, searched.Stock.Results.Count); workflow.SelectStock(searched.Stock.Results[0].SelectionId); Assert.Equal( "IDX@A&B|US|1", workflow.MaterializePlaylistDraft("stock-current").Selection.DataCode); workflow.SelectStock(searched.Stock.Results[1].SelectionId); Assert.Equal( "IDX@(A)|US|1", workflow.MaterializePlaylistDraft("stock-candle-5d").Selection.DataCode); } [Theory] [InlineData(false, false, "")] [InlineData(true, false, "MA5")] [InlineData(false, true, "MA20")] [InlineData(true, true, "MA5,MA20")] public async Task MovingAverageOptionsHaveExactClosedText( bool ma5, bool ma20, string expected) { var workflow = CreateWorkflow(stocks: [new("Apple", "NAS@AAPL", "US")]); var searched = await workflow.SearchStocksAsync(""); workflow.SelectStock(searched.Stock.Results[0].SelectionId); workflow.SetMovingAverages(ma5, ma20); var draft = workflow.MaterializePlaylistDraft("stock-candle-20d"); Assert.Equal(expected, draft.Selection.GraphicType); Assert.Equal(new LegacyOverseasMovingAverageSelection(ma5, ma20), draft.MovingAverages); } [Fact] public void ActionPrerequisitesUnknownIdsAndCallerOverridesFailClosed() { var workflow = CreateWorkflow(); Assert.Equal( "DJI@DJI", workflow.MaterializePlaylistDraft("index-candle-5d").Selection.DataCode.Split('|')[0]); Assert.Throws(() => workflow.MaterializePlaylistDraft("industry-current")); Assert.Throws(() => workflow.MaterializePlaylistDraft("stock-current")); Assert.Throws(() => workflow.MaterializePlaylistDraft("../../8010")); Assert.Throws(() => workflow.MaterializePlaylistDraft(null!)); Assert.Throws(() => workflow.SelectFixedIndex("DOW-JONES")); var methods = typeof(LegacyOverseasSelectionWorkflow).GetMethods() .Where(method => method.Name == nameof(LegacyOverseasSelectionWorkflow.MaterializePlaylistDraft)); var method = Assert.Single(methods); var parameter = Assert.Single(method.GetParameters()); Assert.Equal(typeof(string), parameter.ParameterType); } [Fact] public async Task NonUsTwSearchHitMaterializesAnExactAllNationIdentity() { var workflow = CreateWorkflow(stocks: [new("London Stock", "LNS@LON", "GB")]); var searched = await workflow.SearchStocksAsync("london"); workflow.SelectStock(Assert.Single(searched.Stock.Results).SelectionId); var availability = workflow.GetActionAvailability("stock-current"); Assert.True(availability.IsAvailable); Assert.Null(availability.Reason); var current = workflow.MaterializePlaylistDraft("stock-current"); var candle = workflow.MaterializePlaylistDraft("stock-candle-5d"); Assert.Equal("LNS@LON|GB|1", current.Selection.DataCode); Assert.Equal("LNS@LON|GB|1", candle.Selection.DataCode); } [Fact] public async Task ThreeLetterNationRowsRemainVisibleButFailClosedBeforePlaylistMaterialization() { var workflow = CreateWorkflow(stocks: [new("Unsupported", "LNS@UNSUPPORTED", "GBR")]); var searched = await workflow.SearchStocksAsync("unsupported"); Assert.Equal("Unsupported", searched.Stock.Selected?.InputName); var availability = workflow.GetActionAvailability("stock-current"); Assert.False(availability.IsAvailable); Assert.Equal("선택한 해외종목의 국가 코드는 두 글자여야 합니다.", availability.Reason); Assert.Throws(() => workflow.MaterializePlaylistDraft("stock-current")); } [Fact] public async Task SlowerIndustryResponseCannotReplaceOrInvalidateNewerState() { var industry = new ControlledIndustryService(); var workflow = new LegacyOverseasSelectionWorkflow(industry, new RecordingStockService([])); var older = workflow.SearchIndustriesAsync("old"); var newer = workflow.SearchIndustriesAsync("new"); industry.Complete( "new", IndustryResult( "new", new OverseasIndustryIndexItem("New", "New Industry", "NAS@NEW"))); var newerState = await newer; // The stale response is intentionally malformed. Generation gating must happen // before its envelope and identities are examined. industry.Complete( "old", new OverseasIndustryIndexSearchResult( "wrong", default, [new OverseasIndustryIndexItem(" bad ", "bad", "|")], false)); var olderState = await older; Assert.Equal("new", newerState.Industry.Query); Assert.Equal("new", olderState.Industry.Query); Assert.Equal("New", workflow.Current.Industry.Results[0].KoreanName); Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Industry.Status); Assert.Equal(3, workflow.Current.Revision); } [Fact] public async Task SlowerStockFaultCannotReplaceOrFaultNewerState() { var stock = new ControlledStockService(); var workflow = new LegacyOverseasSelectionWorkflow(new RecordingIndustryService([]), stock); var older = workflow.SearchStocksAsync("old"); var newer = workflow.SearchStocksAsync("new"); stock.Complete( "new", StockResult("new", new WorldStockSearchItem("New Stock", "NAS@NEW", "US"))); await newer; stock.Fail("old", new InvalidOperationException("stale provider failure")); var olderState = await older; Assert.Equal("new", olderState.Stock.Query); Assert.Equal("New Stock", olderState.Stock.Results[0].InputName); Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Stock.Status); Assert.Equal(3, workflow.Current.Revision); } [Fact] public async Task InvalidCurrentIndustryResponseFailsAtomicallyIntoErrorState() { var invalid = IndustryResult( "query", new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA"), new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")); var workflow = new LegacyOverseasSelectionWorkflow( new QueueIndustryService(invalid), new RecordingStockService([])); await Assert.ThrowsAsync(() => workflow.SearchIndustriesAsync("query")); Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Industry.Status); Assert.Empty(workflow.Current.Industry.Results); Assert.Null(workflow.Current.Industry.Selected); Assert.NotEmpty(workflow.Current.Industry.Error); Assert.Equal(2, workflow.Current.Revision); } [Fact] public async Task InvalidCurrentStockIdentityFailsAtomicallyIntoErrorState() { var invalid = StockResult( "query", new WorldStockSearchItem("London", "LNS@BAD", "gB")); var workflow = new LegacyOverseasSelectionWorkflow( new RecordingIndustryService([]), new QueueStockService(invalid)); await Assert.ThrowsAsync(() => workflow.SearchStocksAsync("query")); Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Stock.Status); Assert.Empty(workflow.Current.Stock.Results); Assert.Null(workflow.Current.Stock.Selected); Assert.NotEmpty(workflow.Current.Stock.Error); } [Fact] public async Task SearchBoundsUnsafeQueriesAndNullServicesAreRejectedBeforeCalls() { var industry = new RecordingIndustryService([]); var stock = new RecordingStockService([]); var workflow = new LegacyOverseasSelectionWorkflow(industry, stock); await Assert.ThrowsAsync(() => workflow.SearchIndustriesAsync("", 0)); await Assert.ThrowsAsync(() => workflow.SearchStocksAsync("", 501)); await Assert.ThrowsAsync(() => workflow.SearchIndustriesAsync(new string('x', 65))); await Assert.ThrowsAsync(() => workflow.SearchStocksAsync("bad\u0001query")); await Assert.ThrowsAsync(() => workflow.SearchStocksAsync(null!)); Assert.Empty(industry.Calls); Assert.Empty(stock.Calls); Assert.Throws(() => new LegacyOverseasSelectionWorkflow(null!, stock)); Assert.Throws(() => new LegacyOverseasSelectionWorkflow(industry, null!)); } [Fact] public async Task ClearOperationsAndRepeatedMaChoiceHaveStableRevisionSemantics() { var workflow = CreateWorkflow( industries: [new("Alpha", "Alpha Industry", "NAS@ALPHA")], stocks: [new("Apple", "NAS@AAPL", "US")]); workflow.SelectFixedIndex("dow-jones"); var industry = await workflow.SearchIndustriesAsync(""); workflow.SelectIndustry(industry.Industry.Results[0].SelectionId); var stock = await workflow.SearchStocksAsync(""); workflow.SelectStock(stock.Stock.Results[0].SelectionId); workflow.SetMovingAverages(true, false); var beforeClear = workflow.Current.Revision; workflow.ClearFixedIndexSelection(); workflow.ClearIndustrySelection(); workflow.ClearStockSelection(); var cleared = workflow.Current; Assert.Equal(beforeClear + 3, cleared.Revision); Assert.Null(cleared.SelectedFixedIndex); Assert.Null(cleared.Industry.Selected); Assert.Null(cleared.Stock.Selected); var stable = workflow.SetMovingAverages(true, false); Assert.Equal(cleared.Revision, stable.Revision); } private static LegacyOverseasSelectionWorkflow CreateWorkflow( IReadOnlyList? industries = null, IReadOnlyList? stocks = null) => new( new RecordingIndustryService(industries ?? []), new RecordingStockService(stocks ?? [])); private static OverseasIndustryIndexSearchResult IndustryResult( string query, params OverseasIndustryIndexItem[] items) => new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false); private static WorldStockSearchResult StockResult( string query, params WorldStockSearchItem[] items) => new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false); private sealed class RecordingIndustryService( IReadOnlyList items) : IOverseasIndustryIndexSearchService { public List<(string Query, int MaximumResults)> Calls { get; } = []; public Task SearchAsync( string query, int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Calls.Add((query, maximumResults)); return Task.FromResult(new OverseasIndustryIndexSearchResult( query, RetrievedAt, items, IsTruncated: false)); } } private sealed class RecordingStockService( IReadOnlyList items) : IOverseasStockSearchService { public List<(string Query, int MaximumResults)> Calls { get; } = []; public Task SearchAsync( string query, int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Calls.Add((query, maximumResults)); return Task.FromResult(new WorldStockSearchResult( query, RetrievedAt, items, IsTruncated: false)); } } private sealed class QueueIndustryService( params OverseasIndustryIndexSearchResult[] results) : IOverseasIndustryIndexSearchService { private readonly Queue _results = new(results); public Task SearchAsync( string query, int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(_results.Dequeue()); } } private sealed class QueueStockService( params WorldStockSearchResult[] results) : IOverseasStockSearchService { private readonly Queue _results = new(results); public Task SearchAsync( string query, int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(_results.Dequeue()); } } private sealed class ControlledIndustryService : IOverseasIndustryIndexSearchService { private readonly Dictionary> _pending = new(StringComparer.Ordinal); public Task SearchAsync( string query, int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) { var source = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); _pending.Add(query, source); return source.Task.WaitAsync(cancellationToken); } public void Complete(string query, OverseasIndustryIndexSearchResult result) => _pending[query].SetResult(result); } private sealed class ControlledStockService : IOverseasStockSearchService { private readonly Dictionary> _pending = new(StringComparer.Ordinal); public Task SearchAsync( string query, int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) { var source = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); _pending.Add(query, source); return source.Task.WaitAsync(cancellationToken); } public void Complete(string query, WorldStockSearchResult result) => _pending[query].SetResult(result); public void Fail(string query, Exception exception) => _pending[query].SetException(exception); } }