Files

1167 lines
45 KiB
C#

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<NotSupportedException>(() =>
((IList<string>)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<LegacyManualFinancialRawUpdateRequest>(
workflow.CreateRawUpdateRequest(state, replacement));
Assert.IsType<LegacyManualFinancialRawDeleteRequest>(
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<InvalidOperationException>(() =>
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<ManualFinancialSnapshot>(),
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<ArgumentException>(() =>
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<ArgumentException>(() =>
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<InvalidOperationException>(() =>
workflow.CreateUpdateRequest(state, Record(ManualFinancialScreenKind.Sales, "Alpha")));
Assert.Throws<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
workflow.CreateCreateRequest(
original,
Record(ManualFinancialScreenKind.Sales, "Alpha")));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
workflow.ExecuteAsync(original, request));
Assert.Equal(1, service.MutationCalls);
Assert.True(workflow.CreateInitial(ManualFinancialScreenKind.Sales).WritesQuarantined);
Assert.Throws<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<ArgumentException>(() =>
first.ExecuteAsync(changed, request));
Assert.Equal(0, firstService.MutationCalls);
var second = Workflow(new FakeManualFinancialService());
Assert.Throws<ArgumentException>(() => second.BeginCreate(state));
await Assert.ThrowsAsync<ArgumentException>(() =>
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<InvalidOperationException>(() =>
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<LegacyManualFinancialWorkflowDataException>(() =>
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<ManualSalesRecord>(request.Record);
Assert.Equal("1Q", cloned.Quarters[0].Quarter);
Assert.Throws<NotSupportedException>(() =>
((IList<ManualQuarterValue>)cloned.Quarters).Add(new("7Q", 7)));
state = await workflow.ExecuteAsync(state, request);
Assert.Throws<NotSupportedException>(() =>
((IList<LegacyManualFinancialListRow>)state.Rows).Clear());
var selected = Assert.IsType<ManualSalesRecord>(state.SelectedRecord);
Assert.Throws<NotSupportedException>(() =>
((IList<ManualQuarterValue>)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<ArgumentException>(() =>
workflow.MaterializePlaylistDraft(state, "s5080"));
Assert.Throws<ArgumentException>(() =>
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<LegacyManualFinancialWorkflowSnapshot> 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<LegacyManualFinancialWorkflowSnapshot> 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<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_table.Copy());
}
}
private sealed class NoCallMutationExecutor : IManualFinancialMutationExecutor
{
public Task<ManualFinancialDbTransactionResult> 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<StockSearchItem> _items;
internal FakeStockSearchService(params StockSearchItem[] items)
{
_items = items;
}
internal int LastMaximumResults { get; private set; }
public Task<StockSearchResult> 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<ManualFinancialIdentity, ManualFinancialSnapshot>? GetOverride { get; set; }
internal Func<ManualFinancialScreenKind, string, ManualFinancialSearchResult>?
SearchOverride
{ get; set; }
internal Func<
ManualFinancialMutationKind,
ManualFinancialScreenKind,
string,
int,
ManualFinancialMutationReceipt>? ReceiptOverride
{ get; set; }
internal int DeleteAllAffectedRows { get; set; }
public Task<ManualFinancialSearchResult> 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<ManualFinancialSnapshot> 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<ManualFinancialMutationReceipt> 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<ManualFinancialMutationReceipt> 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<ManualFinancialMutationReceipt> 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<ManualFinancialMutationReceipt> 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);
}
}