1167 lines
45 KiB
C#
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);
|
|
}
|
|
}
|