using System.Data; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; public sealed class LegacyManualFinancialWorkflowTests { [Fact] public async Task GraphE_stock_picker_uses_the_complete_finite_core_boundary() { var service = new FakeManualFinancialService(); var stockSearch = new FakeStockSearchService(); var workflow = new LegacyManualFinancialWorkflow( service, stockSearch, new LegacyManualFinancialWriteSafetyState()); await workflow.SearchStocksAsync( workflow.CreateInitial(ManualFinancialScreenKind.Sales), "Alpha"); Assert.Equal( LegacyStockSearchService.MaximumResults, LegacyManualFinancialWorkflow.MaximumStockResults); Assert.Equal( LegacyStockSearchService.MaximumResults, stockSearch.LastMaximumResults); } [Fact] public async Task Empty_GraphE_stock_search_is_the_original_quiet_no_op() { var stockSearch = new FakeStockSearchService( new StockSearchItem( StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001")); var workflow = new LegacyManualFinancialWorkflow( new FakeManualFinancialService(), stockSearch, new LegacyManualFinancialWriteSafetyState()); var initial = workflow.CreateInitial(ManualFinancialScreenKind.Sales); var unchanged = await workflow.SearchStocksAsync(initial, " "); Assert.Same(initial, unchanged); Assert.Equal(0, stockSearch.LastMaximumResults); Assert.Empty(unchanged.StockCandidates); Assert.Null(unchanged.LastMessage); Assert.Same(unchanged, workflow.SelectStock(unchanged, string.Empty)); } public static TheoryData< ManualFinancialScreenKind, string, string, string, string, string, string> Screens => new() { { ManualFinancialScreenKind.RevenueComposition, "revenue-composition", "주요매출 구성", "INPUT_PIE", "s5076", "5076", "REVENUE_COMPOSITION" }, { ManualFinancialScreenKind.GrowthMetrics, "growth-metrics", "성장성 지표", "INPUT_GROW", "s5079", "5079", "GROWTH_METRICS" }, { ManualFinancialScreenKind.Sales, "sales", "매출액", "INPUT_SELL", "s5080", "5080", "SALES" }, { ManualFinancialScreenKind.OperatingProfit, "operating-profit", "영업이익", "INPUT_PROFIT", "s5081", "5081", "OPERATING_PROFIT" } }; [Theory] [MemberData(nameof(Screens))] public void Registry_matches_every_GraphE_table_and_cut_contract( ManualFinancialScreenKind screen, string actionId, string label, string table, string builder, string code, string graphic) { var definition = LegacyManualFinancialWorkflow.GetScreen(screen); Assert.Equal(4, LegacyManualFinancialWorkflow.Screens.Count); Assert.Equal(actionId, definition.ActionId); Assert.Equal(label, definition.Label); Assert.Equal(table, definition.TableName); Assert.Equal(builder, definition.BuilderKey); Assert.Equal(code, definition.Code); Assert.Equal([code], definition.Aliases); Assert.Equal(graphic, definition.CanonicalGraphicType); Assert.Equal("DELETE_ALL_" + table, definition.DeleteAllConfirmationToken); } [Fact] public async Task Search_then_load_uses_opaque_identity_and_fresh_exact_read() { var original = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1); var refreshed = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha", 100), 2); var service = new FakeManualFinancialService(original) { GetOverride = _ => refreshed }; var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await workflow.SearchAsync(state, " Alpha "); Assert.Equal("Alpha", state.Query); Assert.Equal(LegacyManualFinancialWorkflow.MaximumListResults, service.LastMaximumResults); var row = Assert.Single(state.Rows); Assert.StartsWith("mf-row-", row.ResultId, StringComparison.Ordinal); Assert.Equal( ["Alpha", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"], row.PreviewValues); Assert.Null(state.SelectedRecord); Assert.False(state.IsDetailFresh); state = await workflow.LoadAsync(state, row.ResultId); Assert.True(state.IsDetailFresh); Assert.Equal(Version(2), state.SelectedRowVersion); Assert.Same(state.SelectedRecord, state.SelectedRecord); Assert.Equal(1, service.GetCalls); Assert.Equal("Alpha", service.LastGetIdentity?.StockName); Assert.Equal(Version(2), Assert.Single(state.Rows).RowVersion); } [Fact] public async Task Search_surfaces_isolated_legacy_row_count_without_hiding_valid_rows() { var valid = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1); var service = new FakeManualFinancialService(valid) { SearchOverride = (screen, query) => new ManualFinancialSearchResult( screen, query, DateTimeOffset.UtcNow, [valid], false) { RejectedItemCount = 3, UnreadableStockNames = ["Broken", "BadVersion", "Legacy"] } }; var workflow = Workflow(service); var state = await workflow.SearchAsync( workflow.CreateInitial(ManualFinancialScreenKind.Sales)); Assert.Equal(4, state.Rows.Count); Assert.Single(state.Rows, row => row.IsDataReadable); Assert.Equal(3, state.Rows.Count(row => !row.IsDataReadable)); Assert.Equal(3, state.ListRejectedItemCount); Assert.Contains("3행", state.LastMessage, StringComparison.Ordinal); } [Fact] public async Task Raw_GraphE_row_can_edit_delete_and_schedule_while_playout_stays_unready() { var table = RawSalesTable("Broken", "1Q_not-a-number"); var service = new LegacyManualFinancialScreenService( new StaticQueryExecutor(table), new NoCallMutationExecutor()); var workflow = new LegacyManualFinancialWorkflow( service, new FakeStockSearchService( new StockSearchItem( StockMarket.Kospi, DataSourceKind.Oracle, "Broken", "000001")), new LegacyManualFinancialWriteSafetyState()); var state = await workflow.SearchAsync( workflow.CreateInitial(ManualFinancialScreenKind.Sales)); var row = Assert.Single(state.Rows); Assert.True(row.IsDataReadable); Assert.False(row.IsPlayoutReady); Assert.Equal( ["Broken", "1Q_not-a-number", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60"], row.PreviewValues); Assert.Throws(() => ((IList)row.PreviewValues).Add("ROWID")); state = await workflow.LoadAsync(state, row.ResultId); Assert.True(state.IsRawDetailFresh); Assert.False(state.IsDetailFresh); Assert.False(state.IsPlayoutReady); Assert.NotNull(state.SelectedRawRecord); Assert.Null(state.SelectedRecord); state = await workflow.SearchStocksAsync(state, "Broken"); state = workflow.SelectStock(state, Assert.Single(state.StockCandidates).ResultId); Assert.True(state.CanEdit); Assert.True(state.CanSave); Assert.True(state.CanDelete); Assert.True(state.CanMaterializePlaylist); var replacement = new ManualFinancialRawRecord( state.SelectedRawRecord!.Identity, state.SelectedRawRecord.StorageValues.ToArray()); Assert.IsType( workflow.CreateRawUpdateRequest(state, replacement)); Assert.IsType( workflow.CreateDeleteRequest(state)); var draft = workflow.MaterializePlaylistDraft(state, "sales"); Assert.Null(draft.OperatorSnapshot); Assert.NotNull(draft.OperatorRawSnapshot); Assert.Equal("Broken", draft.CutSelection.Selection.Subject); } [Fact] public async Task GraphE_name_header_and_directional_find_are_CSharp_authoritative() { var service = new FakeManualFinancialService( Snapshot(Record(ManualFinancialScreenKind.Sales, "Gamma"), 1), Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 2), Snapshot(Record(ManualFinancialScreenKind.Sales, "Beta"), 3)); var workflow = Workflow(service); var state = await workflow.SearchAsync( workflow.CreateInitial(ManualFinancialScreenKind.Sales)); Assert.Equal(["Alpha", "Beta", "Gamma"], state.Rows.Select(row => row.StockName)); Assert.Equal(LegacyManualFinancialNameSortDirection.Ascending, state.NameSortDirection); state = workflow.FindByName( state, "a", LegacyManualFinancialFindDirection.Down, state.Revision); Assert.Equal("Alpha", state.Rows.Single(row => row.ResultId == state.SelectedRowId).StockName); state = workflow.FindByName( state, "a", LegacyManualFinancialFindDirection.Down, state.Revision); Assert.Equal("Beta", state.Rows.Single(row => row.ResultId == state.SelectedRowId).StockName); state = workflow.FindByName( state, "a", LegacyManualFinancialFindDirection.Up, state.Revision); Assert.Equal("Alpha", state.Rows.Single(row => row.ResultId == state.SelectedRowId).StockName); var staleGeneration = state.Revision; state = workflow.ToggleNameSort(state, staleGeneration); Assert.Equal(["Gamma", "Beta", "Alpha"], state.Rows.Select(row => row.StockName)); Assert.Equal(LegacyManualFinancialNameSortDirection.Descending, state.NameSortDirection); Assert.Throws(() => workflow.ToggleNameSort(state, staleGeneration)); } [Fact] public async Task Directional_find_matches_Hangul_initial_consonants() { var service = new FakeManualFinancialService( Snapshot(Record(ManualFinancialScreenKind.Sales, "가나다"), 1), Snapshot(Record(ManualFinancialScreenKind.Sales, "삼성전자"), 2), Snapshot(Record(ManualFinancialScreenKind.Sales, "현대차"), 3)); var workflow = Workflow(service); var state = await workflow.SearchAsync( workflow.CreateInitial(ManualFinancialScreenKind.Sales)); state = workflow.FindByName( state, " ㅅㅅㅈ ", LegacyManualFinancialFindDirection.Down, state.Revision); Assert.Equal("ㅅㅅㅈ", state.FindQuery); Assert.Equal( "삼성전자", state.Rows.Single(row => row.ResultId == state.SelectedRowId).StockName); state = workflow.FindByName( state, "ㄱㄴㄷ", LegacyManualFinancialFindDirection.Up, state.Revision); Assert.Equal( "가나다", state.Rows.Single(row => row.ResultId == state.SelectedRowId).StockName); } [Fact] public async Task Create_keeps_a_matching_row_inside_an_initial_consonant_filter() { const string stockName = "삼성전자"; var service = new FakeManualFinancialService { SearchOverride = (screen, query) => new ManualFinancialSearchResult( screen, query, DateTimeOffset.UtcNow, Array.Empty(), IsTruncated: false) }; var workflow = new LegacyManualFinancialWorkflow( service, new FakeStockSearchService(new StockSearchItem( StockMarket.Kospi, DataSourceKind.Oracle, stockName, "005930")), new LegacyManualFinancialWriteSafetyState()); var state = await workflow.SearchAsync( workflow.CreateInitial(ManualFinancialScreenKind.Sales), "ㅅㅅㅈ"); state = await workflow.SearchStocksAsync(state, stockName); state = workflow.SelectStock( state, Assert.Single(state.StockCandidates).ResultId); state = await workflow.ExecuteAsync( state, workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, stockName))); Assert.Equal("ㅅㅅㅈ", state.Query); Assert.Equal(stockName, Assert.Single(state.Rows).StockName); } [Theory] [MemberData(nameof(Screens))] public async Task Create_save_readback_and_action_id_only_draft_work_for_all_modes( ManualFinancialScreenKind screen, string actionId, string label, string table, string builder, string code, string graphic) { _ = table; var service = new FakeManualFinancialService(); var workflow = Workflow(service); var state = workflow.CreateInitial(screen); state = await VerifyStockAsync(workflow, state, "Alpha"); var request = workflow.CreateCreateRequest(state, Record(screen, "Alpha")); Assert.Equal(ManualFinancialMutationKind.Create, request.Kind); Assert.Equal(screen, request.Screen); Assert.Equal("Alpha", request.StockName); Assert.Matches("^[a-f0-9]{32}$", request.RequestId); state = await workflow.ExecuteAsync(state, request); var draft = workflow.MaterializePlaylistDraft(state, actionId); Assert.Equal(LegacyManualFinancialWorkflowStatus.WriteCommitted, state.Status); Assert.True(state.IsDetailFresh); Assert.Equal(1, service.MutationCalls); Assert.Equal(1, service.GetCalls); Assert.Equal(request.RequestId, state.LastRequestId); Assert.Equal(builder, draft.BuilderKey); Assert.Equal(code, draft.Code); Assert.Equal([code], draft.Aliases); Assert.Equal(label, draft.Detail); Assert.Equal("manual-financial", draft.Category); Assert.Equal(graphic, draft.CutSelection.Selection.GraphicType); Assert.Equal(builder, draft.CutSelection.BuilderKey); Assert.Equal(code, draft.CutSelection.CutCode); Assert.Equal(1, draft.CutSelection.CurrentPage); Assert.Equal(1, draft.CutSelection.TotalPages); Assert.Equal(state.SelectedRowVersion, draft.OperatorSnapshot!.RowVersion); Assert.Equal("KOSPI", draft.Market); Assert.Equal("000001", draft.StockCode); Assert.False(draft.IsNxt); } [Fact] public async Task Update_request_carries_selected_rowVersion_and_rebinds_fresh_version() { var service = new FakeManualFinancialService( Snapshot(Record(ManualFinancialScreenKind.GrowthMetrics, "Alpha"), 1)); var workflow = Workflow(service); var state = await LoadAndVerifyAsync( workflow, ManualFinancialScreenKind.GrowthMetrics, "Alpha"); var replacement = Record(ManualFinancialScreenKind.GrowthMetrics, "Alpha", 100); var request = workflow.CreateUpdateRequest(state, replacement); Assert.Equal(Version(1), request.ExpectedRowVersion); state = await workflow.ExecuteAsync(state, request); Assert.Equal(Version(11), state.SelectedRowVersion); Assert.Equal(Version(1), service.LastExpected?.RowVersion); Assert.Same(replacement.Identity, replacement.Identity); Assert.NotSame(replacement, request.Replacement); var draft = workflow.MaterializePlaylistDraft(state, "growth-metrics"); Assert.Equal(Version(11), draft.OperatorSnapshot!.RowVersion); Assert.Equal(1, service.MutationCalls); Assert.Equal(2, service.GetCalls); } [Fact] public async Task Delete_one_uses_optimistic_snapshot_and_clears_selection() { var service = new FakeManualFinancialService( Snapshot(Record(ManualFinancialScreenKind.OperatingProfit, "Alpha"), 1)); var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.OperatingProfit); state = await workflow.SearchAsync(state); state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId); var request = workflow.CreateDeleteRequest(state); Assert.Equal(Version(1), request.ExpectedRowVersion); state = await workflow.ExecuteAsync(state, request); Assert.Empty(state.Rows); Assert.Null(state.SelectedRecord); Assert.False(state.IsDetailFresh); Assert.Equal(Version(1), service.LastExpected?.RowVersion); Assert.Equal(ManualFinancialMutationKind.DeleteOne, state.LastReceipt?.Kind); } [Fact] public async Task Delete_all_requires_the_fixed_screen_token_and_accepts_zero_rows() { var service = new FakeManualFinancialService( Snapshot(Record(ManualFinancialScreenKind.RevenueComposition, "Alpha"), 1)); var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.RevenueComposition); state = await workflow.SearchAsync(state); Assert.Throws(() => workflow.CreateDeleteAllRequest(state, "DELETE_ALL_INPUT_SELL")); var request = workflow.CreateDeleteAllRequest( state, state.Definition.DeleteAllConfirmationToken); service.DeleteAllAffectedRows = 0; state = await workflow.ExecuteAsync(state, request); Assert.Equal("DELETE_ALL_INPUT_PIE", request.ConfirmationToken); Assert.Empty(state.Rows); Assert.Equal(0, state.LastReceipt?.AffectedRows); Assert.Equal(1, service.MutationCalls); } [Fact] public async Task Exact_stock_resolution_rejects_duplicate_master_names() { var service = new FakeManualFinancialService(); var stockSearch = new FakeStockSearchService( new(StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001"), new(StockMarket.Kosdaq, DataSourceKind.Oracle, "Alpha", "000002")); var workflow = new LegacyManualFinancialWorkflow( service, stockSearch, new LegacyManualFinancialWriteSafetyState()); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await workflow.SearchStocksAsync(state, "Alpha"); Assert.Throws(() => workflow.SelectStock(state, state.StockCandidates[0].ResultId)); } [Fact] public async Task Update_and_playlist_require_detail_bound_stock_verification() { var service = new FakeManualFinancialService( Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1)); var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await workflow.SearchAsync(state); state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId); Assert.Throws(() => workflow.CreateUpdateRequest(state, Record(ManualFinancialScreenKind.Sales, "Alpha"))); Assert.Throws(() => workflow.MaterializePlaylistDraft(state, "sales")); state = await VerifyStockAsync(workflow, state, "Alpha"); Assert.NotNull(workflow.CreateUpdateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha", 10))); Assert.NotNull(workflow.MaterializePlaylistDraft(state, "sales")); } [Fact] public async Task OutcomeUnknown_latches_process_write_quarantine_and_never_retries() { var service = new FakeManualFinancialService { MutationException = new ManualFinancialMutationException("timeout", outcomeUnknown: true) }; var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); var request = workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha")); var original = state; state = await workflow.ExecuteAsync(state, request); Assert.Equal(LegacyManualFinancialWorkflowStatus.OutcomeUnknown, state.Status); Assert.True(state.WritesQuarantined); Assert.Equal(1, service.MutationCalls); Assert.Throws(() => workflow.CreateCreateRequest( original, Record(ManualFinancialScreenKind.Sales, "Alpha"))); await Assert.ThrowsAsync(() => workflow.ExecuteAsync(original, request)); Assert.Equal(1, service.MutationCalls); Assert.True(workflow.CreateInitial(ManualFinancialScreenKind.Sales).WritesQuarantined); Assert.Throws(() => workflow.MaterializePlaylistDraft(state, "sales")); } [Fact] public async Task Malformed_write_receipt_is_quarantined_as_unknown() { var service = new FakeManualFinancialService { ReceiptOverride = (kind, screen, name, affectedRows) => new( Guid.NewGuid(), ManualFinancialMutationKind.DeleteOne, screen, name, DateTimeOffset.UtcNow, affectedRows) }; var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); var request = workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha")); state = await workflow.ExecuteAsync(state, request); Assert.Equal(LegacyManualFinancialWorkflowStatus.OutcomeUnknown, state.Status); Assert.True(state.WritesQuarantined); Assert.Equal(1, service.MutationCalls); Assert.Equal(0, service.GetCalls); } [Fact] public async Task Known_rollback_is_not_quarantined_but_request_stays_one_shot() { var service = new FakeManualFinancialService { MutationException = new ManualFinancialMutationRejectedException( ManualFinancialMutationRejectionReason.DuplicateIdentity, "rolled back") }; var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); var request = workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha")); var source = state; state = await workflow.ExecuteAsync(state, request); Assert.Equal(LegacyManualFinancialWorkflowStatus.WriteRejected, state.Status); Assert.False(state.WritesQuarantined); await Assert.ThrowsAsync(() => workflow.ExecuteAsync(source, request)); Assert.Equal(1, service.MutationCalls); } [Fact] public async Task Sibling_requests_from_one_snapshot_cannot_execute_twice() { var service = new FakeManualFinancialService(); var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); var first = workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha")); var sibling = workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha")); _ = await workflow.ExecuteAsync(state, first); await Assert.ThrowsAsync(() => workflow.ExecuteAsync(state, sibling)); Assert.Equal(1, service.MutationCalls); } [Fact] public async Task Pre_write_fresh_snapshot_cannot_materialize_after_update() { var service = new FakeManualFinancialService( Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1)); var workflow = Workflow(service); var stale = await LoadAndVerifyAsync( workflow, ManualFinancialScreenKind.Sales, "Alpha"); var request = workflow.CreateUpdateRequest( stale, Record(ManualFinancialScreenKind.Sales, "Alpha", 100)); var current = await workflow.ExecuteAsync(stale, request); Assert.Throws(() => workflow.MaterializePlaylistDraft(stale, "sales")); Assert.NotNull(workflow.MaterializePlaylistDraft(current, "sales")); } [Fact] public async Task Shared_process_safety_quarantines_new_workflow_instances() { var safety = new LegacyManualFinancialWriteSafetyState(); var firstService = new FakeManualFinancialService { MutationException = new ManualFinancialMutationException("timeout", outcomeUnknown: true) }; var first = Workflow(firstService, safety); var state = first.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(first, state, "Alpha"); state = await first.ExecuteAsync( state, first.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha"))); Assert.True(state.WritesQuarantined); var second = Workflow(new FakeManualFinancialService(), safety); var secondState = second.CreateInitial(ManualFinancialScreenKind.OperatingProfit); Assert.True(secondState.WritesQuarantined); Assert.Throws(() => second.CreateDeleteAllRequest( secondState, secondState.Definition.DeleteAllConfirmationToken)); } [Fact] public async Task Committed_write_without_readback_never_enables_action() { var service = new FakeManualFinancialService(); var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); var request = workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha")); service.GetException = new InvalidOperationException("read unavailable"); state = await workflow.ExecuteAsync(state, request); Assert.Equal(LegacyManualFinancialWorkflowStatus.ReadBackRequired, state.Status); Assert.NotNull(state.LastReceipt); Assert.Null(state.SelectedRecord); Assert.False(state.WritesQuarantined); Assert.Throws(() => workflow.MaterializePlaylistDraft(state, "sales")); } [Fact] public async Task Request_is_revision_bound_and_foreign_snapshots_fail_closed() { var firstService = new FakeManualFinancialService(); var first = Workflow(firstService); var state = first.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(first, state, "Alpha"); var request = first.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha")); var changed = first.BeginCreate(state); await Assert.ThrowsAsync(() => first.ExecuteAsync(changed, request)); Assert.Equal(0, firstService.MutationCalls); var second = Workflow(new FakeManualFinancialService()); Assert.Throws(() => second.BeginCreate(state)); await Assert.ThrowsAsync(() => second.SearchAsync(state)); } [Fact] public async Task Read_only_configuration_blocks_request_creation_before_executor() { var service = new FakeManualFinancialService { CanMutate = false }; var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); Assert.Throws(() => workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha"))); Assert.Equal(0, service.MutationCalls); } [Fact] public async Task Duplicate_list_names_remain_visible_but_invalid_unreadable_identity_is_rejected() { var duplicate = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1); var service = new FakeManualFinancialService { SearchOverride = (_, query) => new ManualFinancialSearchResult( ManualFinancialScreenKind.Sales, query, DateTimeOffset.UtcNow, [duplicate, duplicate], false) }; var workflow = Workflow(service); var duplicates = await workflow.SearchAsync( workflow.CreateInitial(ManualFinancialScreenKind.Sales)); Assert.Equal(2, duplicates.Rows.Count); service.SearchOverride = (_, query) => new ManualFinancialSearchResult( ManualFinancialScreenKind.Sales, query, DateTimeOffset.UtcNow, [], false) { RejectedItemCount = 1, UnreadableStockNames = [" invalid "] }; await Assert.ThrowsAsync(() => workflow.SearchAsync(workflow.CreateInitial(ManualFinancialScreenKind.Sales))); } [Fact] public async Task DTOs_and_snapshots_clone_mutable_input_collections() { var quarters = Enumerable.Range(1, 6) .Select(index => new ManualQuarterValue($"{index}Q", index)) .ToArray(); var record = new ManualSalesRecord( new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha"), quarters); var service = new FakeManualFinancialService(); var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); var request = workflow.CreateCreateRequest(state, record); quarters[0] = new ManualQuarterValue("BAD", 999); var cloned = Assert.IsType(request.Record); Assert.Equal("1Q", cloned.Quarters[0].Quarter); Assert.Throws(() => ((IList)cloned.Quarters).Add(new("7Q", 7))); state = await workflow.ExecuteAsync(state, request); Assert.Throws(() => ((IList)state.Rows).Clear()); var selected = Assert.IsType(state.SelectedRecord); Assert.Throws(() => ((IList)selected.Quarters).Clear()); } [Fact] public async Task Wrong_action_id_and_cross_screen_action_never_materialize() { var service = new FakeManualFinancialService(); var workflow = Workflow(service); var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales); state = await VerifyStockAsync(workflow, state, "Alpha"); state = await workflow.ExecuteAsync( state, workflow.CreateCreateRequest( state, Record(ManualFinancialScreenKind.Sales, "Alpha"))); Assert.Throws(() => workflow.MaterializePlaylistDraft(state, "s5080")); Assert.Throws(() => workflow.MaterializePlaylistDraft(state, "operating-profit")); } private static LegacyManualFinancialWorkflow Workflow( FakeManualFinancialService service, LegacyManualFinancialWriteSafetyState? writeSafety = null) => new( service, new FakeStockSearchService( new StockSearchItem( StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001")), writeSafety ?? new LegacyManualFinancialWriteSafetyState()); private static async Task VerifyStockAsync( LegacyManualFinancialWorkflow workflow, LegacyManualFinancialWorkflowSnapshot state, string stockName) { state = await workflow.SearchStocksAsync(state, stockName); return workflow.SelectStock(state, Assert.Single(state.StockCandidates).ResultId); } private static async Task LoadAndVerifyAsync( LegacyManualFinancialWorkflow workflow, ManualFinancialScreenKind screen, string stockName) { var state = workflow.CreateInitial(screen); state = await workflow.SearchAsync(state, stockName); state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId); return await VerifyStockAsync(workflow, state, stockName); } private static ManualFinancialSnapshot Snapshot(ManualFinancialRecord record, int version) => new(record, Version(version)); private static string Version(int value) => value.ToString("X64"); private static ManualFinancialRecord Record( ManualFinancialScreenKind screen, string stockName, int offset = 0) { var identity = new ManualFinancialIdentity(screen, stockName); return screen switch { ManualFinancialScreenKind.RevenueComposition => new ManualRevenueCompositionRecord( identity, "2026.07", [ new ManualRevenueSlice("반도체", "25", 25), new ManualRevenueSlice("모바일", "20", 20), null, null, null ]), ManualFinancialScreenKind.GrowthMetrics => new ManualGrowthMetricsRecord( identity, new ManualGrowthSeriesValues(1 + offset, 2, 3, 4), new ManualGrowthSeriesValues(5, 6, 7, 8), new ManualGrowthSeriesValues(9, 10, 11, 12), new ManualGrowthSeriesValues(13, 14, 15, 16), new ManualGrowthPeriodLabels("1Q", "2Q", "3Q", "4Q")), ManualFinancialScreenKind.Sales => new ManualSalesRecord( identity, Enumerable.Range(1, 6) .Select(index => new ManualQuarterValue( $"{index}Q", offset + index)) .ToArray()), ManualFinancialScreenKind.OperatingProfit => new ManualOperatingProfitRecord( identity, Enumerable.Range(1, 6) .Select(index => new ManualQuarterValue( $"{index}Q", offset + index)) .ToArray()), _ => throw new ArgumentOutOfRangeException(nameof(screen)) }; } private static DataTable RawSalesTable(string stockName, string firstValue) { var table = new DataTable(); foreach (var column in ManualFinancialCutContracts.Get( ManualFinancialScreenKind.Sales).StorageColumns) { table.Columns.Add(column, typeof(string)); } table.Columns.Add("ROW_VERSION", typeof(string)); table.Columns.Add("ROW_HANDLE", typeof(string)); table.Rows.Add( stockName, firstValue, "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60", Version(1), "AAAAAAAAAAAAAAAAAA"); return table; } private sealed class StaticQueryExecutor : IDataQueryExecutor { private readonly DataTable _table; internal StaticQueryExecutor(DataTable table) => _table = table; public DataTable Execute(DataSourceKind source, string tableName, string query) => throw new NotSupportedException(); public Task ExecuteAsync( DataSourceKind source, string tableName, string query, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ExecuteAsync( DataSourceKind source, string tableName, DataQuerySpec query, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(_table.Copy()); } } private sealed class NoCallMutationExecutor : IManualFinancialMutationExecutor { public Task ExecuteTransactionAsync( DataSourceKind source, ManualFinancialDbTransaction transaction, CancellationToken cancellationToken = default) => throw new InvalidOperationException("The test must not issue a real mutation."); } private sealed class FakeStockSearchService : IStockSearchService { private readonly IReadOnlyList _items; internal FakeStockSearchService(params StockSearchItem[] items) { _items = items; } internal int LastMaximumResults { get; private set; } public Task SearchAsync( string query, int maximumResults = LegacyStockSearchService.DefaultMaximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); LastMaximumResults = maximumResults; return Task.FromResult(new StockSearchResult( query, DateTimeOffset.UtcNow, _items.Take(maximumResults).ToArray(), _items.Count > maximumResults)); } } private sealed class FakeManualFinancialService : IManualFinancialScreenService { private readonly Dictionary<(ManualFinancialScreenKind Screen, string Name), ManualFinancialSnapshot> _rows = new(); private int _nextVersion = 10; internal FakeManualFinancialService(params ManualFinancialSnapshot[] rows) { foreach (var row in rows) { _rows[(row.Record.Identity.Screen, row.Record.Identity.StockName)] = row; } } public bool CanMutate { get; set; } = true; internal int MutationCalls { get; private set; } internal int GetCalls { get; private set; } internal int LastMaximumResults { get; private set; } internal ManualFinancialIdentity? LastGetIdentity { get; private set; } internal ManualFinancialSnapshot? LastExpected { get; private set; } internal Exception? MutationException { get; set; } internal Exception? GetException { get; set; } internal Func? GetOverride { get; set; } internal Func? SearchOverride { get; set; } internal Func< ManualFinancialMutationKind, ManualFinancialScreenKind, string, int, ManualFinancialMutationReceipt>? ReceiptOverride { get; set; } internal int DeleteAllAffectedRows { get; set; } public Task SearchAsync( ManualFinancialScreenKind screen, string query = "", int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); LastMaximumResults = maximumResults; if (SearchOverride is not null) { return Task.FromResult(SearchOverride(screen, query)); } var all = _rows.Values .Where(row => row.Record.Identity.Screen == screen) .Where(row => query.Length == 0 || row.Record.Identity.StockName.Contains( query, StringComparison.OrdinalIgnoreCase)) .OrderBy(row => row.Record.Identity.StockName, StringComparer.Ordinal) .ToArray(); return Task.FromResult(new ManualFinancialSearchResult( screen, query, DateTimeOffset.UtcNow, all.Take(maximumResults).ToArray(), all.Length > maximumResults)); } public Task GetAsync( ManualFinancialIdentity identity, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); GetCalls++; LastGetIdentity = identity; if (GetException is not null) { throw GetException; } if (GetOverride is not null) { return Task.FromResult(GetOverride(identity)); } return Task.FromResult(_rows[(identity.Screen, identity.StockName)]); } public Task CreateAsync( ManualFinancialRecord record, CancellationToken cancellationToken = default) { BeforeMutation(cancellationToken); var snapshot = new ManualFinancialSnapshot(record, Version(++_nextVersion)); _rows[(record.Identity.Screen, record.Identity.StockName)] = snapshot; return Task.FromResult(Receipt( ManualFinancialMutationKind.Create, record.Identity.Screen, record.Identity.StockName, 1)); } public Task UpdateAsync( ManualFinancialSnapshot expected, ManualFinancialRecord replacement, CancellationToken cancellationToken = default) { BeforeMutation(cancellationToken); LastExpected = expected; _rows[(replacement.Identity.Screen, replacement.Identity.StockName)] = new ManualFinancialSnapshot(replacement, Version(++_nextVersion)); return Task.FromResult(Receipt( ManualFinancialMutationKind.Update, replacement.Identity.Screen, replacement.Identity.StockName, 1)); } public Task DeleteAsync( ManualFinancialSnapshot expected, CancellationToken cancellationToken = default) { BeforeMutation(cancellationToken); LastExpected = expected; _rows.Remove(( expected.Record.Identity.Screen, expected.Record.Identity.StockName)); return Task.FromResult(Receipt( ManualFinancialMutationKind.DeleteOne, expected.Record.Identity.Screen, expected.Record.Identity.StockName, 1)); } public Task DeleteAllAsync( ManualFinancialScreenKind screen, CancellationToken cancellationToken = default) { BeforeMutation(cancellationToken); foreach (var key in _rows.Keys.Where(key => key.Screen == screen).ToArray()) { _rows.Remove(key); } return Task.FromResult(Receipt( ManualFinancialMutationKind.DeleteAll, screen, string.Empty, DeleteAllAffectedRows)); } private void BeforeMutation(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); MutationCalls++; if (MutationException is not null) { throw MutationException; } } private ManualFinancialMutationReceipt Receipt( ManualFinancialMutationKind kind, ManualFinancialScreenKind screen, string stockName, int affectedRows) => ReceiptOverride?.Invoke(kind, screen, stockName, affectedRows) ?? new( Guid.NewGuid(), kind, screen, stockName, DateTimeOffset.UtcNow, affectedRows); } }