feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyManualFinancialOperatorControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GraphE_result_double_click_fresh_loads_appends_once_and_closes_on_success()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
|
||||
|
||||
var activated = await controller.ActivateManualFinancialResultAsync(resultId);
|
||||
|
||||
Assert.Null(activated.ManualFinancial);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
Assert.Equal("5080", Assert.Single(activated.Playlist).ToPlayoutEntry().CutCode);
|
||||
Assert.Equal("activate-manual-financial-result", activated.CommandResult?.Command);
|
||||
Assert.Equal(resultId, activated.CommandResult?.TargetId);
|
||||
Assert.True(activated.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GraphE_result_double_click_rejects_identity_change_without_append_or_close()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1))
|
||||
{
|
||||
GetOverride = _ => Sales("Beta", 2)
|
||||
};
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
|
||||
|
||||
var rejected = await controller.ActivateManualFinancialResultAsync(resultId);
|
||||
|
||||
Assert.NotNull(rejected.ManualFinancial);
|
||||
Assert.Empty(rejected.Playlist);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
Assert.NotNull(rejected.Dialog);
|
||||
Assert.False(rejected.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_GraphE_row_loads_verifies_and_adds_CSharp_owned_playlist_row()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var rowId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
|
||||
var loaded = await controller.LoadManualFinancialAsync(rowId);
|
||||
|
||||
Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist);
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
var added = controller.ActivateManualFinancialAction("sales");
|
||||
var playlist = Assert.Single(added.Playlist);
|
||||
var entry = playlist.ToPlayoutEntry();
|
||||
Assert.Equal("5080", entry.CutCode);
|
||||
var selection = Assert.IsType<LegacySceneSelection>(entry.Selection);
|
||||
Assert.Equal("Alpha", selection.Subject);
|
||||
Assert.Equal("SALES", selection.GraphicType);
|
||||
Assert.Equal("1/1", playlist.PageText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GraphE_find_loads_fresh_detail_and_stale_sort_generation_opens_modal()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(
|
||||
ManualFinancialScreenKind.Sales);
|
||||
var generation = opened.ManualFinancial!.Revision;
|
||||
|
||||
var found = await controller.FindManualFinancialAsync(
|
||||
"Alpha",
|
||||
LegacyManualFinancialFindDirection.Down,
|
||||
generation);
|
||||
|
||||
Assert.Equal("Alpha", found.ManualFinancial!.SelectedRecord!.Identity.StockName);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
var sorted = controller.ToggleManualFinancialNameSort(
|
||||
found.ManualFinancial.Revision);
|
||||
var stale = controller.ToggleManualFinancialNameSort(
|
||||
found.ManualFinancial.Revision);
|
||||
Assert.NotNull(stale.Dialog);
|
||||
Assert.Equal(
|
||||
sorted.ManualFinancial!.NameSortDirection,
|
||||
stale.ManualFinancial!.NameSortDirection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_does_not_write_until_explicit_save_and_fresh_readback_completes()
|
||||
{
|
||||
var service = new FakeManualService();
|
||||
var controller = Controller(service);
|
||||
await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.OperatingProfit);
|
||||
controller.BeginManualFinancialCreate();
|
||||
var searched = await controller.SearchManualFinancialStocksAsync("Alpha");
|
||||
controller.SelectManualFinancialStock(
|
||||
Assert.Single(searched.ManualFinancial!.StockCandidates).ResultId);
|
||||
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
var saved = await controller.SaveManualFinancialAsync(Profit("Alpha", 10));
|
||||
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Equal(1, service.GetCalls);
|
||||
Assert.Equal(
|
||||
LegacyManualFinancialWorkflowStatus.WriteCommitted,
|
||||
saved.ManualFinancial!.Status);
|
||||
Assert.True(saved.ManualFinancial.CanMaterializePlaylist);
|
||||
Assert.DoesNotContain("password", saved.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_and_delete_all_are_only_reached_by_explicit_controller_methods()
|
||||
{
|
||||
var service = new FakeManualService(Sales("Alpha", 1));
|
||||
var controller = Controller(service);
|
||||
var opened = await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.Sales);
|
||||
await controller.LoadManualFinancialAsync(
|
||||
Assert.Single(opened.ManualFinancial!.Rows).ResultId);
|
||||
|
||||
Assert.Equal(0, service.MutationCalls);
|
||||
var deleted = await controller.DeleteManualFinancialAsync(all: false);
|
||||
Assert.Equal(1, service.MutationCalls);
|
||||
Assert.Empty(deleted.ManualFinancial!.Rows);
|
||||
|
||||
var all = await controller.DeleteManualFinancialAsync(all: true);
|
||||
Assert.Equal(2, service.MutationCalls);
|
||||
Assert.Empty(all.ManualFinancial!.Rows);
|
||||
}
|
||||
|
||||
private static LegacyOperatorController Controller(FakeManualService service)
|
||||
{
|
||||
var workflow = new LegacyManualFinancialWorkflow(
|
||||
service,
|
||||
new FakeStockService(),
|
||||
new LegacyManualFinancialWriteSafetyState());
|
||||
return new LegacyOperatorController(
|
||||
new EmptyStockLookup(),
|
||||
manualFinancialWorkflow: workflow);
|
||||
}
|
||||
|
||||
private static ManualFinancialSnapshot Sales(string stockName, int version) =>
|
||||
new(
|
||||
new ManualSalesRecord(
|
||||
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue($"{index}Q", index))
|
||||
.ToArray()),
|
||||
Version(version));
|
||||
|
||||
private static ManualOperatingProfitRecord Profit(string stockName, int offset) =>
|
||||
new(
|
||||
new ManualFinancialIdentity(
|
||||
ManualFinancialScreenKind.OperatingProfit,
|
||||
stockName),
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue($"{index}Q", offset + index))
|
||||
.ToArray());
|
||||
|
||||
private static string Version(int value) => value.ToString("X64");
|
||||
|
||||
private sealed class EmptyStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
|
||||
private sealed class FakeStockService : IStockSearchService
|
||||
{
|
||||
public Task<StockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new StockSearchResult(
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
[new StockSearchItem(
|
||||
StockMarket.Kospi,
|
||||
DataSourceKind.Oracle,
|
||||
"Alpha",
|
||||
"000001")],
|
||||
false));
|
||||
}
|
||||
|
||||
private sealed class FakeManualService : IManualFinancialScreenService
|
||||
{
|
||||
private readonly Dictionary<(ManualFinancialScreenKind, string), ManualFinancialSnapshot>
|
||||
_rows = new();
|
||||
private int _version = 10;
|
||||
|
||||
internal FakeManualService(params ManualFinancialSnapshot[] rows)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
_rows[(row.Record.Identity.Screen, row.Record.Identity.StockName)] = row;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
internal int MutationCalls { get; private set; }
|
||||
|
||||
internal int GetCalls { get; private set; }
|
||||
|
||||
internal Func<ManualFinancialIdentity, ManualFinancialSnapshot>? GetOverride
|
||||
{
|
||||
get;
|
||||
init;
|
||||
}
|
||||
|
||||
public Task<ManualFinancialSearchResult> SearchAsync(
|
||||
ManualFinancialScreenKind screen,
|
||||
string query = "",
|
||||
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = _rows.Values
|
||||
.Where(row => row.Record.Identity.Screen == screen)
|
||||
.Where(row => query.Length == 0 ||
|
||||
row.Record.Identity.StockName.Contains(
|
||||
query,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Take(maximumResults)
|
||||
.ToArray();
|
||||
return Task.FromResult(new ManualFinancialSearchResult(
|
||||
screen,
|
||||
query,
|
||||
DateTimeOffset.UtcNow,
|
||||
rows,
|
||||
false));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialSnapshot> GetAsync(
|
||||
ManualFinancialIdentity identity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetCalls++;
|
||||
return Task.FromResult(
|
||||
GetOverride?.Invoke(identity) ??
|
||||
_rows[(identity.Screen, identity.StockName)]);
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> CreateAsync(
|
||||
ManualFinancialRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MutationCalls++;
|
||||
_rows[(record.Identity.Screen, record.Identity.StockName)] =
|
||||
new ManualFinancialSnapshot(record, Version(++_version));
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.Create,
|
||||
record.Identity.Screen,
|
||||
record.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> UpdateAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
ManualFinancialRecord replacement,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MutationCalls++;
|
||||
_rows[(replacement.Identity.Screen, replacement.Identity.StockName)] =
|
||||
new ManualFinancialSnapshot(replacement, Version(++_version));
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.Update,
|
||||
replacement.Identity.Screen,
|
||||
replacement.Identity.StockName,
|
||||
1));
|
||||
}
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> DeleteAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MutationCalls++;
|
||||
_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)
|
||||
{
|
||||
MutationCalls++;
|
||||
var keys = _rows.Keys.Where(key => key.Item1 == screen).ToArray();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
_rows.Remove(key);
|
||||
}
|
||||
|
||||
return Task.FromResult(Receipt(
|
||||
ManualFinancialMutationKind.DeleteAll,
|
||||
screen,
|
||||
string.Empty,
|
||||
keys.Length));
|
||||
}
|
||||
|
||||
private static ManualFinancialMutationReceipt Receipt(
|
||||
ManualFinancialMutationKind kind,
|
||||
ManualFinancialScreenKind screen,
|
||||
string stockName,
|
||||
int affectedRows) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
kind,
|
||||
screen,
|
||||
stockName,
|
||||
DateTimeOffset.UtcNow,
|
||||
affectedRows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||
|
||||
public sealed class LegacyManualFinancialWorkflowTests
|
||||
{
|
||||
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.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 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));
|
||||
}
|
||||
|
||||
[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 Invalid_or_ambiguous_list_envelopes_are_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);
|
||||
|
||||
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 sealed class FakeStockSearchService : IStockSearchService
|
||||
{
|
||||
private readonly IReadOnlyList<StockSearchItem> _items;
|
||||
|
||||
internal FakeStockSearchService(params StockSearchItem[] items)
|
||||
{
|
||||
_items = items;
|
||||
}
|
||||
|
||||
public Task<StockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user