feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

View File

@@ -0,0 +1,516 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyManualListsWorkflowTests
{
[Fact]
public void Construction_is_side_effect_free_and_snapshot_has_no_file_or_stock_code_authority()
{
var store = new FakeStore();
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
var snapshot = workflow.Current;
Assert.Equal(0, store.ReadNetCalls);
Assert.Equal(0, store.ReadViCalls);
Assert.Equal(0, store.WriteNetCalls);
Assert.Equal(0, store.WriteViCalls);
Assert.DoesNotContain(
typeof(LegacyManualListsSnapshot).GetProperties(),
property => property.Name.Contains("Path", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("Version", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(
typeof(LegacyManualViItemView).GetProperties(),
property => property.Name.Contains("Code", StringComparison.OrdinalIgnoreCase));
Assert.True(snapshot.IsAvailable);
}
[Fact]
public async Task FSell_saves_once_rereads_exact_five_rows_and_materializes_s5025()
{
var store = new FakeStore();
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
await workflow.OpenNetSellAsync(S5025ManualAudience.Foreign);
var row = workflow.Current.NetRows[0];
workflow.SetNetSellCell(row.RowId, LegacyManualNetSellField.LeftName, "Alpha");
var saved = await workflow.SaveNetSellAsync();
var draft = workflow.CreateNetSellPlaylistDraft();
Assert.Equal(1, store.WriteNetCalls);
Assert.Equal(2, store.ReadNetCalls);
Assert.True(saved.NetRowsAreFresh);
Assert.Equal("Alpha", saved.NetRows[0].LeftName);
Assert.Equal("5025", draft.SceneAlias);
Assert.Equal("FOREIGN", draft.Selection.GroupCode);
Assert.Equal("MANUAL_NET_SELL", draft.Selection.GraphicType);
Assert.Equal("1/1", draft.PageText);
}
[Fact]
public async Task FSell_ambiguous_failure_uses_one_readback_and_quarantines_on_mismatch()
{
var store = new FakeStore { ThrowNetWrite = true, PersistBeforeNetThrow = false };
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
await workflow.OpenNetSellAsync(S5025ManualAudience.Individual);
workflow.SetNetSellCell(
workflow.Current.NetRows[0].RowId,
LegacyManualNetSellField.LeftName,
"Changed");
var result = await workflow.SaveNetSellAsync();
var second = await workflow.SaveNetSellAsync();
Assert.True(result.WritesQuarantined);
Assert.True(second.WritesQuarantined);
Assert.Equal(1, store.WriteNetCalls);
Assert.Equal(2, store.ReadNetCalls);
Assert.False(result.CanSave);
}
[Fact]
public async Task VI_search_uses_opaque_ids_and_save_binds_fresh_versioned_s5074_reference()
{
var store = new FakeStore();
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
await workflow.OpenViAsync();
var searched = await workflow.SearchViAsync("삼성");
var result = Assert.Single(searched.SearchResults);
Assert.DoesNotContain("005930", result.ResultId, StringComparison.Ordinal);
var selected = workflow.SelectViSearchResult(result.ResultId);
Assert.Equal(result.ResultId, selected.SelectedSearchResultId);
Assert.Empty(selected.ViItems);
workflow.AddViSearchResult(result.ResultId);
var saved = await workflow.SaveViAsync();
var draft = workflow.CreateViPlaylistDraft();
Assert.Equal(1, store.WriteViCalls);
Assert.Equal(3, store.ReadViCalls);
Assert.True(saved.ViItemsAreFresh);
Assert.Equal(1, saved.ViPageCount);
Assert.Equal("5074", draft.SceneAlias);
Assert.Equal("PAGED_VI", draft.Selection.GroupCode);
Assert.Equal("@MBN_VI_SNAPSHOT_V1", draft.Selection.Subject);
Assert.Equal(store.Vi.RowVersion, draft.Selection.DataCode);
Assert.DoesNotContain("005930", draft.StockName, StringComparison.Ordinal);
}
[Fact]
public async Task VI_stale_preflight_never_writes_or_retries()
{
var store = new FakeStore();
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
await workflow.OpenViAsync();
var searched = await workflow.SearchViAsync("삼성");
workflow.AddViSearchResult(searched.SearchResults.Single().ResultId);
store.Vi = store.Vi with { RowVersion = new string('b', 64) };
var result = await workflow.SaveViAsync();
Assert.Equal(0, store.WriteViCalls);
Assert.False(result.ViItemsAreFresh);
Assert.False(result.WritesQuarantined);
Assert.Contains("변경", result.StatusMessage, StringComparison.Ordinal);
}
[Fact]
public async Task Import_outcome_unknown_latches_process_workflow_quarantine()
{
var store = new FakeStore { ImportOutcomeUnknown = true };
var workflow = new LegacyManualListsWorkflow(store, new FakeLookup());
var result = await workflow.ImportAsync();
Assert.True(result.WritesQuarantined);
Assert.False(result.CanSave);
Assert.Equal(1, store.ImportCalls);
}
[Fact]
public async Task Operator_controller_appends_only_native_materialized_manual_rows()
{
var store = new FakeStore();
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.OpenManualNetSellAsync(S5025ManualAudience.Institution);
var row = controller.Current.ManualLists!.NetRows[0];
controller.SetManualNetSellCell(
row.RowId,
LegacyManualNetSellField.RightAmount,
"-3,000");
await controller.SaveManualNetSellAsync();
var result = controller.AddManualNetSellPlaylistRow();
var playlist = Assert.Single(result.Playlist);
Assert.Equal("5025", playlist.SceneAlias);
Assert.Equal("INSTITUTION", playlist.PlayoutSelection!.GroupCode);
Assert.Equal("MANUAL_NET_SELL", playlist.PlayoutSelection.GraphicType);
Assert.Equal("1/1", playlist.PageText);
}
[Fact]
public async Task Original_ok_saves_verifies_adds_one_cut_and_closes_the_editor()
{
var store = new FakeStore();
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.OpenManualNetSellAsync(S5025ManualAudience.Foreign);
var result = await controller.ConfirmManualListsAsync();
Assert.Equal(1, store.WriteNetCalls);
Assert.Equal(2, store.ReadNetCalls);
Assert.False(result.ManualLists!.IsOpen);
var row = Assert.Single(result.Playlist);
Assert.Equal("5025", row.SceneAlias);
Assert.Equal("FOREIGN", row.PlayoutSelection!.GroupCode);
}
[Fact]
public async Task Original_ok_on_unknown_write_keeps_editor_open_and_never_adds_a_cut()
{
var store = new FakeStore
{
ThrowNetWrite = true,
PersistBeforeNetThrow = false
};
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.OpenManualNetSellAsync(S5025ManualAudience.Individual);
var row = controller.Current.ManualLists!.NetRows[0];
controller.SetManualNetSellCell(
row.RowId,
LegacyManualNetSellField.LeftAmount,
"999");
var result = await controller.ConfirmManualListsAsync();
Assert.Equal(1, store.WriteNetCalls);
Assert.Empty(result.Playlist);
Assert.True(result.ManualLists!.IsOpen);
Assert.True(result.ManualLists.WritesQuarantined);
}
[Theory]
[InlineData("fixed-245", S5025ManualAudience.Individual)]
[InlineData("fixed-246", S5025ManualAudience.Foreign)]
[InlineData("fixed-247", S5025ManualAudience.Institution)]
public async Task Original_manual_net_sell_tree_leaves_open_the_matching_editor(
string actionId,
S5025ManualAudience audience)
{
var store = new FakeStore();
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var result = await controller.ActivateFixedActionAsync(actionId);
Assert.True(result.ManualLists!.IsOpen);
Assert.Equal(LegacyManualListScreen.NetSell, result.ManualLists.Screen);
Assert.Equal(audience, result.ManualLists.Audience);
Assert.Empty(result.Playlist);
}
[Theory]
[InlineData("fixed-262")]
[InlineData("fixed-293")]
[InlineData("fixed-320")]
public async Task Original_manual_vi_tree_leaves_open_the_vi_editor(string actionId)
{
var store = new FakeStore
{
Vi = new LegacyManualViStoreSnapshot(
Enumerable.Range(1, 9)
.Select(index => new LegacyManualViStoredItem(
$"P{index:D6}",
$"VI종목{index}"))
.ToArray(),
new string('a', 64))
};
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var result = await controller.ActivateFixedActionAsync(actionId);
Assert.True(result.ManualLists!.IsOpen);
Assert.Equal(LegacyManualListScreen.Vi, result.ManualLists.Screen);
Assert.Equal(9, result.ManualLists.ViItems.Count);
Assert.Equal(2, result.ManualLists.ViPageCount);
Assert.True(result.ManualLists.ViItemsAreFresh);
Assert.True(result.ManualLists.CanSave);
Assert.True(result.ManualLists.CanMaterializePlaylist);
Assert.Empty(result.Playlist);
}
[Fact]
public async Task Fixed_parent_stages_three_FSell_children_and_commits_once_in_original_order()
{
var store = new FakeStore();
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var section = LegacyFixedActionCatalog.Default
.GetSections(LegacyFixedMarket.Index)[6];
var first = await controller.ActivateFixedSection(6);
Assert.Empty(first.Playlist);
Assert.Equal("fixed-245", first.FixedSectionBatch!.PendingActionId);
Assert.Equal(1, first.FixedSectionBatch.ManualStepNumber);
Assert.Equal(3, first.FixedSectionBatch.ManualStepCount);
Assert.Equal(S5025ManualAudience.Individual, first.ManualLists!.Audience);
var second = await controller.ConfirmManualListsAsync();
Assert.Empty(second.Playlist);
Assert.Equal("fixed-246", second.FixedSectionBatch!.PendingActionId);
Assert.Equal(S5025ManualAudience.Foreign, second.ManualLists!.Audience);
var third = await controller.ConfirmManualListsAsync();
Assert.Empty(third.Playlist);
Assert.Equal("fixed-247", third.FixedSectionBatch!.PendingActionId);
Assert.Equal(S5025ManualAudience.Institution, third.ManualLists!.Audience);
var completed = await controller.ConfirmManualListsAsync();
Assert.Null(completed.FixedSectionBatch);
Assert.False(completed.ManualLists!.IsOpen);
Assert.Equal(3, store.WriteNetCalls);
Assert.Equal(section.Actions.Count, completed.Playlist.Count);
Assert.Equal(
section.Actions.Select(action => action.Label),
completed.Playlist.Select(row => row.StockName));
Assert.Equal(
new[] { "INDIVIDUAL", "FOREIGN", "INSTITUTION" },
completed.Playlist.TakeLast(3).Select(row => row.PlayoutSelection!.GroupCode));
}
[Fact]
public async Task Fixed_parent_cancel_discards_all_playlist_staging_but_keeps_known_manual_save()
{
var store = new FakeStore();
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
await controller.ActivateFixedSection(6);
await controller.ConfirmManualListsAsync();
var cancelled = controller.CloseManualLists();
Assert.Equal(1, store.WriteNetCalls);
Assert.Empty(cancelled.Playlist);
Assert.Null(cancelled.FixedSectionBatch);
Assert.False(cancelled.ManualLists!.IsOpen);
Assert.Equal(LegacyOperatorStatusKind.Warning, cancelled.StatusKind);
Assert.Contains("아무 항목도 추가하지 않았습니다", cancelled.StatusMessage,
StringComparison.Ordinal);
}
[Fact]
public async Task Fixed_parent_outcome_unknown_keeps_same_step_and_never_retries_or_appends()
{
var store = new FakeStore
{
ThrowNetWrite = true,
PersistBeforeNetThrow = false
};
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var opened = await controller.ActivateFixedSection(6);
controller.SetManualNetSellCell(
opened.ManualLists!.NetRows[0].RowId,
LegacyManualNetSellField.LeftAmount,
"999");
var unknown = await controller.ConfirmManualListsAsync();
var blockedRepeat = await controller.ConfirmManualListsAsync();
Assert.Equal(1, store.WriteNetCalls);
Assert.Empty(unknown.Playlist);
Assert.Empty(blockedRepeat.Playlist);
Assert.Equal("fixed-245", blockedRepeat.FixedSectionBatch!.PendingActionId);
Assert.True(blockedRepeat.ManualLists!.IsOpen);
Assert.True(blockedRepeat.ManualLists.WritesQuarantined);
}
[Fact]
public async Task Fixed_parent_VI_child_resumes_and_commits_at_its_original_child_position()
{
var store = new FakeStore();
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var section = LegacyFixedActionCatalog.Default
.GetSections(LegacyFixedMarket.Index)[7];
var manualIndex = section.Actions.ToList().FindIndex(action => action.Id == "fixed-262");
var opened = await controller.ActivateFixedSection(7);
var searched = await controller.SearchManualViAsync("삼성");
controller.AddManualViSearchResult(
Assert.Single(searched.ManualLists!.SearchResults).ResultId);
var completed = await controller.ConfirmManualListsAsync();
Assert.Null(completed.FixedSectionBatch);
Assert.Equal(section.Actions.Count, completed.Playlist.Count);
Assert.Equal(
section.Actions.Take(manualIndex).Select(action => action.Label),
completed.Playlist.Take(manualIndex).Select(row => row.StockName));
Assert.Equal(
section.Actions.Skip(manualIndex + 1).Select(action => action.Label),
completed.Playlist.Skip(manualIndex + 1).Select(row => row.StockName));
Assert.Equal("5074", completed.Playlist[manualIndex].SceneAlias);
Assert.Equal("PAGED_VI", completed.Playlist[manualIndex].PlayoutSelection!.GroupCode);
Assert.Equal(1, store.WriteViCalls);
}
[Fact]
public async Task Fixed_parent_modal_contract_blocks_tab_and_manual_target_switches()
{
var store = new FakeStore();
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
await controller.ActivateFixedSection(6);
var obsoleteSave = await controller.SaveManualNetSellAsync();
var obsoleteAdd = controller.AddManualNetSellPlaylistRow();
var switchedManual = await controller.OpenManualNetSellAsync(
S5025ManualAudience.Institution);
var switchedTab = await controller.SelectTabAsync(LegacyOperatorTabId.Exchange);
Assert.Equal(0, store.WriteNetCalls);
Assert.Empty(obsoleteSave.Playlist);
Assert.Empty(obsoleteAdd.Playlist);
Assert.Equal(S5025ManualAudience.Individual, switchedManual.ManualLists!.Audience);
Assert.Equal(S5025ManualAudience.Individual, switchedTab.ManualLists!.Audience);
Assert.Equal(
LegacyOperatorTabId.Index,
Assert.Single(switchedTab.Tabs!, tab => tab.IsActive).Id);
Assert.Empty(switchedTab.Playlist);
}
private sealed class FakeLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
query,
["삼성전자"],
[new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"삼성전자",
"005930")]));
}
private sealed class FakeStore : ILegacyManualListStore
{
private readonly Dictionary<S5025ManualAudience, IReadOnlyList<S5025TrustedManualRow>>
_net = Enum.GetValues<S5025ManualAudience>().ToDictionary(
audience => audience,
_ => (IReadOnlyList<S5025TrustedManualRow>)Rows());
public LegacyManualListStoreAvailability Availability { get; } =
new(true, "READY", null);
public LegacyManualViStoreSnapshot Vi { get; set; } = new(
Array.Empty<LegacyManualViStoredItem>(),
new string('a', 64));
public int ReadNetCalls { get; private set; }
public int WriteNetCalls { get; private set; }
public int ReadViCalls { get; private set; }
public int WriteViCalls { get; private set; }
public int ImportCalls { get; private set; }
public bool ThrowNetWrite { get; init; }
public bool PersistBeforeNetThrow { get; init; }
public bool ImportOutcomeUnknown { get; init; }
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
ReadNetCalls++;
return Task.FromResult(_net[audience]);
}
public Task WriteNetSellAsync(
S5025ManualAudience audience,
IReadOnlyList<S5025TrustedManualRow> rows,
CancellationToken cancellationToken = default)
{
WriteNetCalls++;
if (!ThrowNetWrite || PersistBeforeNetThrow)
{
_net[audience] = rows.ToArray();
}
return ThrowNetWrite
? Task.FromException(new IOException("ambiguous"))
: Task.CompletedTask;
}
public Task<LegacyManualViStoreSnapshot> ReadViAsync(
CancellationToken cancellationToken = default)
{
ReadViCalls++;
return Task.FromResult(Vi);
}
public Task<LegacyManualViWriteReceipt> WriteViAsync(
IReadOnlyList<LegacyManualViStoredItem> items,
CancellationToken cancellationToken = default)
{
WriteViCalls++;
Vi = new LegacyManualViStoreSnapshot(items.ToArray(), new string('c', 64));
return Task.FromResult(new LegacyManualViWriteReceipt(Vi, true));
}
public Task<LegacyManualImportReceipt> ImportAsync(
CancellationToken cancellationToken = default)
{
ImportCalls++;
return ImportOutcomeUnknown
? Task.FromException<LegacyManualImportReceipt>(
new LegacyManualImportStoreException("ROLLBACK_FAILED", true))
: Task.FromResult(new LegacyManualImportReceipt(
1,
new string('d', 64),
DateTimeOffset.UnixEpoch,
15,
0));
}
private static S5025TrustedManualRow[] Rows() =>
Enumerable.Range(1, 5).Select(index => new S5025TrustedManualRow(
$"L{index}",
index.ToString(System.Globalization.CultureInfo.InvariantCulture),
$"R{index}",
(-index).ToString(System.Globalization.CultureInfo.InvariantCulture))).ToArray();
}
}