using System.Collections; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyThemeWorkflowTests { [Fact] public void Actions_AreClosedImmutableAndMatchCoreSceneCatalog() { Assert.Equal( ["five-row-current", "five-row-expected", "six-row-current", "twelve-row-current"], LegacyThemeWorkflow.Actions.Select(action => action.Id)); Assert.Equal([5, 5, 6, 12], LegacyThemeWorkflow.Actions.Select(action => action.PageSize)); Assert.Equal( [ LegacyThemeValueKind.Current, LegacyThemeValueKind.Expected, LegacyThemeValueKind.Current, LegacyThemeValueKind.Current ], LegacyThemeWorkflow.Actions.Select(action => action.ValueKind)); foreach (var action in LegacyThemeWorkflow.Actions) { var scene = Assert.Single( LegacySceneCatalog.Definitions, definition => definition.BuilderKey == action.BuilderKey); Assert.Single(action.Aliases); Assert.Equal(action.Code, action.Aliases[0]); Assert.Contains(action.Code, scene.CutAliases); Assert.Equal(action.PageSize, (int)scene.PageSize); } var mutable = Assert.IsAssignableFrom>( LegacyThemeWorkflow.Actions); Assert.True(mutable.IsReadOnly); Assert.Throws(() => mutable.RemoveAt(0)); Assert.Null(LegacyThemeWorkflow.FindAction(null)); Assert.Null(LegacyThemeWorkflow.FindAction("unknown")); Assert.Equal( ["INPUT_ORDER", "GAIN_DESC", "GAIN_ASC"], LegacyThemeWorkflow.Sorts.Select(sort => sort.Id)); Assert.Equal( ["입력순", "상승률순", "하락률순"], LegacyThemeWorkflow.Sorts.Select(sort => sort.Label)); var mutableSorts = Assert.IsAssignableFrom>( LegacyThemeWorkflow.Sorts); Assert.True(mutableSorts.IsReadOnly); Assert.Throws(() => mutableSorts.RemoveAt(0)); } [Fact] public void InitialSnapshot_IsImmutableAndUsesOriginalInputOrderDefault() { var workflow = new LegacyThemeWorkflow(new FakeThemeSelectionService()); var snapshot = workflow.CreateInitial(ThemeSession.AfterMarket); Assert.Equal(0, snapshot.Revision); Assert.Equal("", snapshot.Query); Assert.Equal(ThemeSession.AfterMarket, snapshot.NxtSession); Assert.Equal(LegacyThemeSort.InputOrder, snapshot.Sort); Assert.Equal("INPUT_ORDER", snapshot.SortId); Assert.Empty(snapshot.SearchResults); Assert.Null(snapshot.SelectedResultId); Assert.Null(snapshot.SelectedTheme); Assert.Empty(snapshot.PreviewItems); Assert.All(snapshot.ActionStates, state => { Assert.False(state.IsAvailable); Assert.Equal("theme-not-selected", state.UnavailableReason); }); Assert.All( typeof(LegacyThemeWorkflowSnapshot).GetProperties(), property => Assert.False(property.CanWrite)); Assert.Throws(() => workflow.CreateInitial(ThemeSession.NotApplicable)); } [Theory] [InlineData(0, ThemeSession.PreMarket)] [InlineData(12, ThemeSession.PreMarket)] [InlineData(13, ThemeSession.AfterMarket)] [InlineData(23, ThemeSession.AfterMarket)] public void CurrentNxtSession_UsesTheOriginalNativeLocalHourBoundary( int hour, ThemeSession expected) { var workflow = new LegacyThemeWorkflow( new FakeThemeSelectionService(), () => new DateTimeOffset(2026, 7, 18, hour, 0, 0, TimeSpan.FromHours(9))); Assert.Equal(expected, workflow.CurrentNxtSession); } [Fact] public async Task ResetForTabActivation_RestoresTheNewUc4Defaults() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var workflow = new LegacyThemeWorkflow( ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")])); var selected = workflow.SelectSort( await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket), "GAIN_DESC"); var reset = workflow.ResetForTabActivation(selected); Assert.Equal(selected.Revision + 1, reset.Revision); Assert.Equal(string.Empty, reset.Query); Assert.Equal(ThemeSession.PreMarket, reset.NxtSession); Assert.Equal(LegacyThemeSort.InputOrder, reset.Sort); Assert.Empty(reset.SearchResults); Assert.Null(reset.SelectedResultId); Assert.Null(reset.SelectedTheme); Assert.Empty(reset.PreviewItems); } [Fact] public async Task SearchAsync_NormalizesQueryAndPreservesProviderOrderWithinKrxThenNxt() { var service = new FakeThemeSelectionService { SearchHandler = (query, session) => SearchResult( query, session, Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Zulu", "00000002"), Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Alpha", "00000001"), Selector(ThemeMarket.Nxt, session, "Robotics", "00000003")) }; var workflow = new LegacyThemeWorkflow(service); var initial = workflow.CreateInitial(); var result = await workflow.SearchAsync( initial, " ai ", ThemeSession.PreMarket); var call = Assert.Single(service.SearchCalls); Assert.Equal("ai", call.Query); Assert.Equal(ThemeSession.PreMarket, call.Session); Assert.Equal(LegacyThemeWorkflow.MaximumSearchResults, call.MaximumResults); Assert.Equal(1, result.Revision); Assert.Equal("ai", result.Query); Assert.Collection( result.SearchResults, row => { Assert.Equal("theme-result-001", row.ResultId); Assert.Equal("Zulu", row.DisplayTitle); Assert.Equal(ThemeMarket.Krx, row.Market); Assert.Equal(ThemeSession.NotApplicable, row.Session); Assert.Equal(DataSourceKind.Oracle, row.Source); }, row => Assert.Equal("Alpha", row.DisplayTitle), row => { Assert.Equal("theme-result-003", row.ResultId); Assert.Equal("Robotics(NXT)", row.DisplayTitle); Assert.Equal(ThemeMarket.Nxt, row.Market); Assert.Equal(ThemeSession.PreMarket, row.Session); Assert.Equal(DataSourceKind.MariaDb, row.Source); }); Assert.Empty(initial.SearchResults); var mutable = Assert.IsAssignableFrom>(result.SearchResults); Assert.True(mutable.IsReadOnly); Assert.Throws(() => mutable.RemoveAt(0)); } [Fact] public async Task SearchAsync_RejectsUnsafeInputBeforeCallingService() { var service = new FakeThemeSelectionService(); var workflow = new LegacyThemeWorkflow(service); var snapshot = workflow.CreateInitial(); await Assert.ThrowsAsync(() => workflow.SearchAsync(snapshot, null!, ThemeSession.PreMarket)); await Assert.ThrowsAsync(() => workflow.SearchAsync(snapshot, "bad\u0000query", ThemeSession.PreMarket)); await Assert.ThrowsAsync(() => workflow.SearchAsync( snapshot, new string('A', LegacyThemeSelectionService.MaximumQueryLength + 1), ThemeSession.PreMarket)); await Assert.ThrowsAsync(() => workflow.SearchAsync(snapshot, "AI", ThemeSession.NotApplicable)); Assert.Empty(service.SearchCalls); } [Fact] public async Task SearchAsync_RejectsUntrustedServiceEnvelopeProviderAndIdentity() { var service = new FakeThemeSelectionService { SearchHandler = (_, session) => SearchResult( "wrong-query", session, Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001")) }; var workflow = new LegacyThemeWorkflow(service); var snapshot = workflow.CreateInitial(); await Assert.ThrowsAsync(() => workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket)); service.SearchHandler = (query, session) => SearchResult( query, session, new ThemeSelectorItem( Identity(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"), DataSourceKind.MariaDb)); await Assert.ThrowsAsync(() => workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket)); service.SearchHandler = (query, session) => SearchResult( query, session, Selector(ThemeMarket.Nxt, ThemeSession.AfterMarket, "AI", "00000001")); await Assert.ThrowsAsync(() => workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket)); service.SearchHandler = (query, session) => SearchResult( query, session, Selector(ThemeMarket.Nxt, session, "AI(NXT)", "00000001")); await Assert.ThrowsAsync(() => workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket)); service.SearchHandler = (query, session) => SearchResult( query, session, Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"), Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000002")); var duplicateTitles = await workflow.SearchAsync( snapshot, "AI", ThemeSession.PreMarket); Assert.Equal(2, duplicateTitles.SearchResults.Count); } [Fact] public async Task SearchAsync_RejectsProviderRowsThatWouldRequireReordering() { var service = new FakeThemeSelectionService { SearchHandler = (query, session) => SearchResult( query, session, Selector(ThemeMarket.Nxt, session, "NXT first", "00000002"), Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "KRX later", "00000001")) }; var workflow = new LegacyThemeWorkflow(service); var exception = await Assert.ThrowsAsync(() => workflow.SearchAsync(workflow.CreateInitial(), "", ThemeSession.PreMarket)); Assert.Contains("search provider order", exception.Message, StringComparison.Ordinal); } [Fact] public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var previewItems = new List { new(0, "P005930", "삼성전자"), new(2, "D035720", "카카오") }; var service = ServiceWithTheme(identity, previewItems); var workflow = new LegacyThemeWorkflow(service); var search = await workflow.SearchAsync( workflow.CreateInitial(), "AI", ThemeSession.PreMarket); var selected = await workflow.SelectAsync(search, "theme-result-001"); var call = Assert.Single(service.PreviewCalls); Assert.Equal(identity, call.Identity); Assert.Equal(LegacyThemeWorkflow.MaximumPreviewItems, call.MaximumItems); Assert.Equal(2, selected.Revision); Assert.Equal("theme-result-001", selected.SelectedResultId); Assert.Equal( new LegacySelectedThemeSummary( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", 2, false), selected.SelectedTheme); Assert.Equal(["삼성전자", "카카오"], selected.PreviewItems.Select(row => row.ItemName)); Assert.Null(search.SelectedTheme); Assert.All(selected.ActionStates, state => { Assert.True(state.IsAvailable); Assert.Null(state.UnavailableReason); }); previewItems.Add(new ThemeItemPreview(3, "P000001", "Later mutation")); Assert.Equal(2, selected.PreviewItems.Count); var mutable = Assert.IsAssignableFrom>(selected.PreviewItems); Assert.True(mutable.IsReadOnly); Assert.Throws(() => mutable.RemoveAt(0)); } [Fact] public async Task SearchAsync_ReplacesResultsAndClearsOnlyTheNewSnapshotSelection() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]); var workflow = new LegacyThemeWorkflow(service); var selected = workflow.SelectSort( await SearchAndSelectAsync(workflow), "GAIN_ASC"); service.SearchHandler = (query, session) => SearchResult(query, session); var replaced = await workflow.SearchAsync( selected, "new", ThemeSession.AfterMarket); Assert.Equal("new", replaced.Query); Assert.Equal(ThemeSession.AfterMarket, replaced.NxtSession); Assert.Equal(LegacyThemeSort.GainRateAscending, replaced.Sort); Assert.Empty(replaced.SearchResults); Assert.Null(replaced.SelectedTheme); Assert.Empty(replaced.PreviewItems); Assert.NotNull(selected.SelectedTheme); Assert.Single(selected.PreviewItems); } [Fact] public async Task SearchAsync_RetainsOnlyACorrelatedSelectionAndPreview() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]); var workflow = new LegacyThemeWorkflow(service); var selected = workflow.SelectSort(await SearchAndSelectAsync(workflow), "GAIN_DESC"); var refreshed = await workflow.SearchAsync( selected, "AI", ThemeSession.AfterMarket); Assert.Equal( new LegacySelectedThemeSummary( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", 1, false), refreshed.SelectedTheme); Assert.NotNull(refreshed.SelectedResultId); Assert.Single(refreshed.PreviewItems); Assert.Equal("삼성전자", refreshed.PreviewItems[0].ItemName); Assert.Equal(LegacyThemeSort.GainRateDescending, refreshed.Sort); Assert.Single(service.PreviewCalls); } [Fact] public async Task SelectAsync_RejectsUnknownResultWithoutCallingPreview() { var service = new FakeThemeSelectionService { SearchHandler = (query, session) => SearchResult( query, session, Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001")) }; var workflow = new LegacyThemeWorkflow(service); var search = await workflow.SearchAsync( workflow.CreateInitial(), "", ThemeSession.PreMarket); await Assert.ThrowsAsync(() => workflow.SelectAsync(search, "theme-result-999")); Assert.Empty(service.PreviewCalls); } [Fact] public async Task SelectAsync_RejectsUntrustedPreviewEnvelopeAndItems() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]); var workflow = new LegacyThemeWorkflow(service); var search = await workflow.SearchAsync( workflow.CreateInitial(), "", ThemeSession.PreMarket); service.PreviewHandler = _ => new ThemePreviewResult( identity with { ThemeCode = "00000002" }, DateTimeOffset.UtcNow, [new(0, "P005930", "삼성전자")], false); await Assert.ThrowsAsync(() => workflow.SelectAsync(search, "theme-result-001")); service.PreviewHandler = _ => PreviewResult( identity, [new(0, "X005930", "잘못된 시장")]); await Assert.ThrowsAsync(() => workflow.SelectAsync(search, "theme-result-001")); service.PreviewHandler = _ => PreviewResult( identity, [new(1, "P005930", "A"), new(1, "D035720", "B")]); await Assert.ThrowsAsync(() => workflow.SelectAsync(search, "theme-result-001")); service.PreviewHandler = _ => PreviewResult( identity, CreateItems(239, ThemeMarket.Krx), isTruncated: true); await Assert.ThrowsAsync(() => workflow.SelectAsync(search, "theme-result-001")); } [Fact] public async Task SelectSort_UsesClosedIdsAndPreservesImmutableSelection() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]); var workflow = new LegacyThemeWorkflow(service); var selected = await SearchAndSelectAsync(workflow); var changed = workflow.SelectSort(selected, "GAIN_DESC"); Assert.Equal(LegacyThemeSort.GainRateDescending, changed.Sort); Assert.Equal("GAIN_DESC", changed.SortId); Assert.Equal(selected.Revision + 1, changed.Revision); Assert.Equal(LegacyThemeSort.InputOrder, selected.Sort); Assert.Equal(selected.SelectedTheme, changed.SelectedTheme); Assert.Throws(() => workflow.SelectSort(selected, "gain_desc")); Assert.Throws(() => workflow.SelectSort(selected, " GAIN_DESC ")); Assert.Equal("입력순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.InputOrder)); Assert.Equal("상승률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateDescending)); Assert.Equal("하락률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateAscending)); } [Theory] [InlineData("INPUT_ORDER", "입력순")] [InlineData("GAIN_DESC", "상승률순")] [InlineData("GAIN_ASC", "하락률순")] public async Task MaterializePlaylistDraft_KrxBuildsAllFourClosedActions( string sortId, string sortLabel) { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, CreateItems(13, ThemeMarket.Krx)); var workflow = new LegacyThemeWorkflow(service); var selected = workflow.SelectSort(await SearchAndSelectAsync(workflow), sortId); foreach (var action in LegacyThemeWorkflow.Actions) { var draft = workflow.MaterializePlaylistDraft(selected, action.Id); Assert.Equal(action.Id, draft.ActionId); Assert.Equal(action.BuilderKey, draft.BuilderKey); Assert.Equal(action.Code, draft.Code); Assert.Equal(action.Aliases, draft.Aliases); Assert.Equal("AI", draft.Title); var expectedGraphicType = action.Id switch { "five-row-current" or "five-row-expected" => "5단 표그래프", "six-row-current" => "6종목 현재가", "twelve-row-current" => "12종목 현재가", _ => throw new InvalidOperationException() }; var expectedSubtype = $"테마-{(action.ValueKind == LegacyThemeValueKind.Expected ? "예상체결가" : "현재가")}({sortLabel})"; Assert.Equal(expectedGraphicType, draft.PlaylistGraphicType); Assert.Equal(expectedSubtype, draft.PlaylistSubtype); Assert.Equal(expectedSubtype, draft.Detail); Assert.Equal("plate", draft.Category); Assert.Equal("theme", draft.Market); Assert.Equal("PAGED_THEME", draft.Selection.GroupCode); Assert.Equal("AI", draft.Selection.Subject); Assert.Equal(sortId, draft.Selection.GraphicType); Assert.Equal( action.ValueKind == LegacyThemeValueKind.Expected ? "EXPECTED" : "CURRENT", draft.Selection.Subtype); Assert.Equal("00000001", draft.Selection.DataCode); Assert.Equal(action.PageSize, draft.PageSize); Assert.Equal($"1/{draft.PagePlan.PageCount}", draft.PagePlan.InitialPageText); Assert.InRange(draft.PagePlan.PageCount, 1, 20); } } [Theory] [InlineData("INPUT_ORDER")] [InlineData("GAIN_DESC")] [InlineData("GAIN_ASC")] public async Task MaterializePlaylistDraft_NxtKeepsSessionAndExpectedClickShowsOriginalWarning( string sortId) { var identity = Identity( ThemeMarket.Nxt, ThemeSession.AfterMarket, "Robotics", "00000002"); var service = ServiceWithTheme(identity, CreateItems(7, ThemeMarket.Nxt)); var workflow = new LegacyThemeWorkflow( service, () => new DateTimeOffset(2026, 7, 18, 14, 0, 0, TimeSpan.FromHours(9))); var selected = workflow.SelectSort( await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket), sortId); foreach (var actionId in new[] { "five-row-current", "six-row-current", "twelve-row-current" }) { var draft = workflow.MaterializePlaylistDraft(selected, actionId); Assert.Equal("Robotics(NXT)", draft.Title); Assert.Equal( new LegacyThemePlaylistSelection( "PAGED_NXT_THEME", "Robotics(NXT)", "AFTER_MARKET", sortId, "00000002"), draft.Selection); Assert.True(workflow.GetActionState(selected, actionId).IsAvailable); } var expectedState = workflow.GetActionState(selected, "five-row-expected"); Assert.True(expectedState.IsAvailable); Assert.Null(expectedState.UnavailableReason); var exception = Assert.Throws(() => workflow.MaterializePlaylistDraft(selected, "five-row-expected")); Assert.Equal("NXT는 데이터가 없습니다.", exception.Message); } [Fact] public async Task MaterializePlaylistDraft_NxtReevaluatesTheNativeHourAtActionTime() { var localNow = new DateTimeOffset( 2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9)); var identity = Identity( ThemeMarket.Nxt, ThemeSession.PreMarket, "Robotics", "00000002"); var workflow = new LegacyThemeWorkflow( ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Nxt)), () => localNow); var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket); localNow = localNow.AddHours(4); var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current"); Assert.Equal("AFTER_MARKET", draft.Selection.GraphicType); } [Theory] [InlineData("five-row-current", 1, 1, 1, false)] [InlineData("five-row-current", 5, 5, 1, false)] [InlineData("five-row-current", 6, 6, 2, false)] [InlineData("five-row-current", 100, 100, 20, false)] [InlineData("five-row-current", 101, 100, 20, true)] [InlineData("five-row-expected", 101, 100, 20, true)] [InlineData("six-row-current", 120, 120, 20, false)] [InlineData("six-row-current", 121, 120, 20, true)] [InlineData("twelve-row-current", 239, 239, 20, false)] [InlineData("twelve-row-current", 240, 240, 20, false)] public async Task MaterializePlaylistDraft_CalculatesPageNWithTwentyPageMaximum( string actionId, int itemCount, int accessibleCount, int pageCount, bool truncated) { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, CreateItems(itemCount, ThemeMarket.Krx)); var workflow = new LegacyThemeWorkflow(service); var selected = await SearchAndSelectAsync(workflow); var page = workflow.MaterializePlaylistDraft(selected, actionId).PagePlan; Assert.Equal(itemCount, page.PreviewItemCount); Assert.Equal(accessibleCount, page.AccessibleItemCount); Assert.Equal(pageCount, page.PageCount); Assert.Equal(20, page.MaximumPageCount); Assert.Equal(truncated, page.IsTruncated); Assert.Equal($"1/{pageCount}", page.InitialPageText); } [Fact] public async Task MaterializePlaylistDraft_PropagatesServiceTruncationAtPageTwenty() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme( identity, CreateItems(240, ThemeMarket.Krx), previewIsTruncated: true); var workflow = new LegacyThemeWorkflow(service); var selected = await SearchAndSelectAsync(workflow); var page = workflow.MaterializePlaylistDraft( selected, "twelve-row-current").PagePlan; Assert.Equal(20, page.PageCount); Assert.True(page.IsTruncated); Assert.True(selected.PreviewIsTruncated); } [Fact] public async Task MaterializePlaylistDraft_RejectsMissingEmptyUnknownAndForeignState() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, []); var workflow = new LegacyThemeWorkflow(service); var initial = workflow.CreateInitial(); Assert.Throws(() => workflow.MaterializePlaylistDraft(initial, "five-row-current")); var selected = await SearchAndSelectAsync(workflow); Assert.All(selected.ActionStates, state => { Assert.False(state.IsAvailable); Assert.Equal("theme-preview-is-empty", state.UnavailableReason); }); Assert.Throws(() => workflow.MaterializePlaylistDraft(selected, "five-row-current")); Assert.Throws(() => workflow.MaterializePlaylistDraft(selected, "s5074")); var other = new LegacyThemeWorkflow(new FakeThemeSelectionService()); Assert.Throws(() => other.MaterializePlaylistDraft(selected, "five-row-current")); } [Fact] public async Task SearchAndSelect_RespectPreCanceledTokensWithoutServiceCalls() { var identity = Identity( ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"); var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]); var workflow = new LegacyThemeWorkflow(service); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => workflow.SearchAsync( workflow.CreateInitial(), "", ThemeSession.PreMarket, cancellation.Token)); Assert.Empty(service.SearchCalls); var search = await workflow.SearchAsync( workflow.CreateInitial(), "", ThemeSession.PreMarket); await Assert.ThrowsAnyAsync(() => workflow.SelectAsync(search, "theme-result-001", cancellation.Token)); Assert.Empty(service.PreviewCalls); } private static async Task SearchAndSelectAsync( LegacyThemeWorkflow workflow, ThemeSession session = ThemeSession.PreMarket) { var search = await workflow.SearchAsync( workflow.CreateInitial(session), "", session); return await workflow.SelectAsync(search, "theme-result-001"); } private static FakeThemeSelectionService ServiceWithTheme( ThemeSelectionIdentity identity, IReadOnlyList items, bool previewIsTruncated = false) => new() { SearchHandler = (query, session) => SearchResult( query, session, new ThemeSelectorItem( identity, identity.Market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb)), PreviewHandler = _ => PreviewResult(identity, items, previewIsTruncated) }; private static ThemeSelectionIdentity Identity( ThemeMarket market, ThemeSession session, string title, string code) => new(market, session, code, title); private static ThemeSelectorItem Selector( ThemeMarket market, ThemeSession session, string title, string code) => new( Identity(market, session, title, code), market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb); private static ThemeSearchResult SearchResult( string query, ThemeSession session, params ThemeSelectorItem[] items) => new(query, session, DateTimeOffset.UtcNow, items, false); private static ThemePreviewResult PreviewResult( ThemeSelectionIdentity identity, IReadOnlyList items, bool isTruncated = false) => new(identity, DateTimeOffset.UtcNow, items, isTruncated); private static IReadOnlyList CreateItems( int count, ThemeMarket market) { var prefix = market == ThemeMarket.Krx ? 'P' : 'X'; return Enumerable.Range(0, count) .Select(index => new ThemeItemPreview( index, $"{prefix}{index + 1:000000}", $"Item {index + 1}")) .ToArray(); } private sealed record SearchCall( string Query, ThemeSession Session, int MaximumResults, CancellationToken CancellationToken); private sealed record PreviewCall( ThemeSelectionIdentity Identity, int MaximumItems, CancellationToken CancellationToken); private sealed class FakeThemeSelectionService : IThemeSelectionService { public Func SearchHandler { get; set; } = (query, session) => SearchResult(query, session); public Func PreviewHandler { get; set; } = identity => PreviewResult(identity, []); public List SearchCalls { get; } = []; public List PreviewCalls { get; } = []; public Task SearchAsync( string query, ThemeSession nxtSession, int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults, CancellationToken cancellationToken = default) { SearchCalls.Add(new SearchCall(query, nxtSession, maximumResults, cancellationToken)); return Task.FromResult(SearchHandler(query, nxtSession)); } public Task PreviewAsync( ThemeSelectionIdentity identity, int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems, CancellationToken cancellationToken = default) { PreviewCalls.Add(new PreviewCall(identity, maximumItems, cancellationToken)); return Task.FromResult(PreviewHandler(identity)); } } }