feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -1,5 +1,6 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
@@ -76,7 +77,7 @@ public sealed class LegacyComparisonWorkflowTests
var domestic = await controller.SearchDomesticAsync(" 삼성 ");
Assert.Equal("삼성", data.DomesticQuery);
Assert.Equal(200, data.DomesticMaximumResults);
Assert.Equal(LegacyStockSearchService.MaximumResults, data.DomesticMaximumResults);
Assert.Equal(
new[] { "삼성전자", "삼성전자(NXT)" },
domestic.DomesticSearch.Results.Select(result => result.DisplayName));
@@ -101,7 +102,7 @@ public sealed class LegacyComparisonWorkflowTests
var world = await controller.SearchWorldAsync(string.Empty);
Assert.Equal(string.Empty, data.WorldQuery);
Assert.Equal(100, data.WorldMaximumResults);
Assert.Equal(LegacyWorldStockSearchService.MaximumResults, data.WorldMaximumResults);
var selectedWorld = controller.SelectWorldResult(
world.WorldSearch.Results[0].SelectionId);
Assert.Equal(
@@ -124,6 +125,65 @@ public sealed class LegacyComparisonWorkflowTests
Assert.Equal("코스피 지수", swapped.SecondTarget?.DisplayName);
}
[Fact]
public async Task Search_projects_row_level_isolation_without_rejecting_safe_results()
{
var data = new FakeDataService
{
DomesticResults = [Samsung],
DomesticIsTruncated = true,
DomesticIsolatedInvalidRowCount = 2,
WorldResults = [Apple],
WorldIsTruncated = true,
WorldIsolatedInvalidRowCount = 16
};
var controller = CreateController(data);
var domestic = await controller.SearchDomesticAsync(string.Empty);
var world = await controller.SearchWorldAsync(string.Empty);
Assert.Single(domestic.DomesticSearch.Results);
Assert.True(domestic.DomesticSearch.IsTruncated);
Assert.Equal(2, domestic.DomesticSearch.IsolatedInvalidRowCount);
Assert.Single(world.WorldSearch.Results);
Assert.True(world.WorldSearch.IsTruncated);
Assert.Equal(16, world.WorldSearch.IsolatedInvalidRowCount);
}
[Fact]
public async Task Comma_world_name_stays_visible_but_native_selection_and_playout_are_blocked()
{
var deferred = LegacyComparisonTarget.CreateWorldStock(
"ACME, INC",
"ACME",
"US");
var data = new FakeDataService
{
WorldResults = [Apple, deferred],
WorldDeferredUnsafeRowCount = 1
};
var controller = CreateController(data);
var searched = await controller.SearchWorldAsync(string.Empty);
var row = Assert.Single(searched.WorldSearch.Results, item => !item.CanSelect);
Assert.Equal(2, searched.WorldSearch.Results.Count);
Assert.Equal(1, searched.WorldSearch.DeferredUnsafeRowCount);
Assert.Contains("쉼표", row.UnavailableReason, StringComparison.Ordinal);
var selected = controller.SelectWorldResult(row.SelectionId);
var chosen = controller.ChooseWorldResult(row.SelectionId);
Assert.Null(selected.WorldSearch.SelectedResultId);
Assert.Null(chosen.FirstTarget);
Assert.Equal(LegacyOperatorStatusKind.Warning, chosen.StatusKind);
Assert.Throws<InvalidOperationException>(() =>
LegacyComparisonWorkflowCatalog.Materialize(
"two-column-current",
deferred,
Apple,
"comparison-draft-safe-id"));
}
[Fact]
public async Task Search_RejectsDuplicateOrWrongUniverseResults()
{
@@ -473,10 +533,153 @@ public sealed class LegacyComparisonWorkflowTests
Assert.Throws<NotSupportedException>(() => actionCollection.Clear());
}
[Fact]
public async Task Import_preserves_current_order_appends_unseen_pairs_and_is_idempotent()
{
var data = new FakeDataService();
var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung);
var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix);
data.Resolve(Samsung, samsungStored.Name, samsungStored.Market);
data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market);
var store = new FakePairStore
{
Bytes = EncodeCp949(
(samsungStored.Name, samsungStored.Market,
skHynixStored.Name, skHynixStored.Market))
};
var importer = new FakeImporter(CreateImportResult(
ImportKrx("kospi", Samsung.StockName!, Samsung.StockCode!),
ImportKrx("kospi", SkHynix.StockName!, SkHynix.StockCode!),
ImportWorld("APPLE", "AAPL", "US"),
ImportMarket("Kospi"),
ImportWorld("APPLE", "AAPL", "US"),
ImportMarket("Kospi")));
var controller = CreateController(data, store, importer);
await controller.LoadSavedPairsAsync();
var originalRowId = controller.Current.SavedPairs[0].RowId;
var first = await controller.ImportLegacyPairsAsync();
Assert.Equal(2, first.SavedPairs.Count);
Assert.Equal(originalRowId, first.SavedPairs[0].RowId);
Assert.Equal("APPLE", first.SavedPairs[1].First.DisplayName);
Assert.Equal("Kospi", first.SavedPairs[1].Second.Metadata);
var receipt = Assert.IsType<LegacyComparisonImportReceipt>(first.LastImport);
Assert.Equal(3, receipt.SourceRowCount);
Assert.Equal(1, receipt.AddedPairCount);
Assert.Equal(2, receipt.SkippedDuplicateCount);
Assert.Equal(2, receipt.TotalPairCount);
var write = Assert.Single(store.Writes);
Assert.Equal(2, write.Count);
Assert.Equal(Samsung.StockName, write[0].FirstName);
Assert.Equal("APPLE", write[1].FirstName);
var second = await controller.ImportLegacyPairsAsync();
Assert.Equal(2, second.SavedPairs.Count);
Assert.Single(store.Writes);
Assert.Equal(0, second.LastImport?.AddedPairCount);
Assert.Equal(2, importer.Calls);
}
[Fact]
public async Task Import_validates_every_resolved_target_before_writing()
{
var store = new FakePairStore();
var invalid = new LegacyComparisonImportTarget(
LegacyComparisonImportTargetKind.KrxStock,
Market: "kospi",
StockName: Samsung.StockName,
StockCode: "005930",
Symbol: "forged-extra-field");
var importer = new FakeImporter(CreateImportResult(
ImportWorld("APPLE", "AAPL", "US"),
ImportMarket("Kospi"),
invalid,
ImportMarket("Kosdaq")));
var controller = CreateController(pairStore: store, importer: importer);
await Assert.ThrowsAsync<InvalidOperationException>(
() => controller.ImportLegacyPairsAsync());
Assert.Empty(store.Writes);
Assert.Empty(controller.Current.SavedPairs);
Assert.Null(controller.Current.LastImport);
}
[Fact]
public async Task Import_store_failure_rolls_back_controller_state()
{
var data = new FakeDataService();
var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung);
var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix);
data.Resolve(Samsung, samsungStored.Name, samsungStored.Market);
data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market);
var store = new FakePairStore
{
Bytes = EncodeCp949(
(samsungStored.Name, samsungStored.Market,
skHynixStored.Name, skHynixStored.Market)),
ThrowOnWrite = true
};
var importer = new FakeImporter(CreateImportResult(
ImportWorld("APPLE", "AAPL", "US"),
ImportMarket("Kospi")));
var controller = CreateController(data, store, importer);
await controller.LoadSavedPairsAsync();
var before = controller.Current.SavedPairs.Select(row => row.RowId).ToArray();
await Assert.ThrowsAsync<IOException>(() => controller.ImportLegacyPairsAsync());
Assert.Equal(before, controller.Current.SavedPairs.Select(row => row.RowId));
Assert.Null(controller.Current.LastImport);
Assert.Empty(store.Writes);
}
private static LegacyComparisonWorkflowController CreateController(
FakeDataService? dataService = null,
FakePairStore? pairStore = null) =>
new(dataService ?? new FakeDataService(), pairStore ?? new FakePairStore());
FakePairStore? pairStore = null,
ILegacyComparisonPairImportService? importer = null) =>
new(
dataService ?? new FakeDataService(),
pairStore ?? new FakePairStore(),
pairImporter: importer);
private static LegacyComparisonImportResult CreateImportResult(
params LegacyComparisonImportTarget[] targets)
{
Assert.True(targets.Length % 2 == 0);
var pairs = targets.Chunk(2).Select((pair, index) =>
new LegacyComparisonImportPair(index + 1, pair[0], pair[1])).ToArray();
return new LegacyComparisonImportResult(
new string('A', 64),
pairs.Length,
DateTimeOffset.Parse("2026-07-22T12:00:00+09:00"),
Array.AsReadOnly(pairs));
}
private static LegacyComparisonImportTarget ImportKrx(
string market,
string name,
string code) =>
new(
LegacyComparisonImportTargetKind.KrxStock,
Market: market,
StockName: name,
StockCode: code);
private static LegacyComparisonImportTarget ImportWorld(
string name,
string symbol,
string nation) =>
new(
LegacyComparisonImportTargetKind.WorldStock,
InputName: name,
Symbol: symbol,
Nation: nation);
private static LegacyComparisonImportTarget ImportMarket(string target) =>
new(LegacyComparisonImportTargetKind.MarketTarget, MarketTarget: target);
private static async Task<LegacyComparisonWorkflowController>
CreateLoadedKrxPairControllerAsync()
@@ -536,7 +739,9 @@ public sealed class LegacyComparisonWorkflowTests
row.SecondName,
row.SecondMarket)).ToArray());
private sealed class FakeDataService : ILegacyComparisonDataService
private sealed class FakeDataService :
ILegacyComparisonDataService,
ILegacyComparisonDataDiagnosticsService
{
private readonly Dictionary<LegacyComparisonStoredTargetReference, LegacyComparisonTarget>
_resolved = [];
@@ -545,6 +750,18 @@ public sealed class LegacyComparisonWorkflowTests
public IReadOnlyList<LegacyComparisonTarget> WorldResults { get; init; } = [];
public bool DomesticIsTruncated { get; init; }
public bool WorldIsTruncated { get; init; }
public int DomesticIsolatedInvalidRowCount { get; init; }
public int WorldIsolatedInvalidRowCount { get; init; }
public int DomesticDeferredUnsafeRowCount { get; init; }
public int WorldDeferredUnsafeRowCount { get; init; }
public string? DomesticQuery { get; private set; }
public string? WorldQuery { get; private set; }
@@ -580,6 +797,40 @@ public sealed class LegacyComparisonWorkflowTests
return Task.FromResult(WorldResults);
}
public Task<LegacyComparisonSearchData> SearchDomesticWithDiagnosticsAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
DomesticQuery = query;
DomesticMaximumResults = maximumResults;
return Task.FromResult(new LegacyComparisonSearchData(
DomesticResults,
DomesticIsTruncated,
DomesticIsolatedInvalidRowCount)
{
DeferredUnsafeRowCount = DomesticDeferredUnsafeRowCount
});
}
public Task<LegacyComparisonSearchData> SearchWorldWithDiagnosticsAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
WorldQuery = query;
WorldMaximumResults = maximumResults;
return Task.FromResult(new LegacyComparisonSearchData(
WorldResults,
WorldIsTruncated,
WorldIsolatedInvalidRowCount)
{
DeferredUnsafeRowCount = WorldDeferredUnsafeRowCount
});
}
public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference target,
CancellationToken cancellationToken = default)
@@ -619,4 +870,18 @@ public sealed class LegacyComparisonWorkflowTests
return Task.CompletedTask;
}
}
private sealed class FakeImporter(LegacyComparisonImportResult result)
: ILegacyComparisonPairImportService
{
public int Calls { get; private set; }
public Task<LegacyComparisonImportResult> ImportAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls++;
return Task.FromResult(result);
}
}
}

View File

@@ -142,6 +142,39 @@ public sealed class LegacyOperatorComparisonIntegrationTests
Assert.Empty(store.Writes);
}
[Fact]
public async Task LegacyComparisonImport_requires_explicit_confirmation_and_consumes_yes_once()
{
var store = new MemoryComparisonPairStore();
var importer = new StaticComparisonImporter();
var controller = new LegacyOperatorController(
new EmptyStockLookup(),
comparisonWorkflow: new LegacyComparisonWorkflowController(
new StaticComparisonDataService(),
store,
pairImporter: importer));
await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
var requested = controller.RequestLegacyComparisonPairImport();
Assert.True(requested.Dialog?.IsConfirmation);
Assert.Contains("없는 항목만", requested.Dialog?.Message, StringComparison.Ordinal);
Assert.Empty(store.Writes);
Assert.Equal(0, importer.Calls);
var confirmed = await controller.ConfirmDialogAsync();
Assert.Null(confirmed.Dialog);
Assert.Single(confirmed.Comparison!.SavedPairs);
Assert.Equal(1, confirmed.Comparison.LastImport?.AddedPairCount);
Assert.Single(store.Writes);
Assert.Equal(1, importer.Calls);
await controller.ConfirmDialogAsync();
Assert.Single(store.Writes);
Assert.Equal(1, importer.Calls);
}
private static async Task<(LegacyOperatorController Controller,
MemoryComparisonPairStore Store)> CreateControllerWithSavedPairAsync()
{
@@ -215,6 +248,30 @@ public sealed class LegacyOperatorComparisonIntegrationTests
return Task.CompletedTask;
}
}
private sealed class StaticComparisonImporter : ILegacyComparisonPairImportService
{
public int Calls { get; private set; }
public Task<LegacyComparisonImportResult> ImportAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls++;
return Task.FromResult(new LegacyComparisonImportResult(
new string('A', 64),
1,
DateTimeOffset.Parse("2026-07-22T12:00:00+09:00"),
[new LegacyComparisonImportPair(
1,
new LegacyComparisonImportTarget(
LegacyComparisonImportTargetKind.MarketTarget,
MarketTarget: "Kospi"),
new LegacyComparisonImportTarget(
LegacyComparisonImportTargetKind.MarketTarget,
MarketTarget: "Kosdaq"))]));
}
}
}
public sealed class LegacyComparisonProductionAdapterTests
@@ -238,6 +295,48 @@ public sealed class LegacyComparisonProductionAdapterTests
Assert.Equal(overseas, resolved);
}
[Fact]
public async Task DataAdapter_preserves_world_row_isolation_diagnostics()
{
var world = new StaticWorldLookup
{
Result = new WorldStockSearchResult(
string.Empty,
DateTimeOffset.UtcNow,
[new WorldStockSearchItem("APPLE", "AAPL", "US")],
true,
16)
};
var service = new LegacyComparisonDataService(new StaticStockLookup(), world);
var result = await service.SearchWorldWithDiagnosticsAsync(string.Empty, 100);
Assert.Single(result.Items);
Assert.True(result.IsTruncated);
Assert.Equal(16, result.IsolatedInvalidRowCount);
}
[Fact]
public async Task DataAdapter_keeps_comma_name_visible_as_one_deferred_row()
{
var world = new StaticWorldLookup
{
Result = new WorldStockSearchResult(
string.Empty,
DateTimeOffset.UtcNow,
[new WorldStockSearchItem("ACME, INC", "ACME", "US")],
false)
};
var service = new LegacyComparisonDataService(new StaticStockLookup(), world);
var result = await service.SearchWorldWithDiagnosticsAsync(string.Empty, 100);
var target = Assert.Single(result.Items);
Assert.False(target.CanSelect);
Assert.Equal(1, result.DeferredUnsafeRowCount);
Assert.Equal(0, result.IsolatedInvalidRowCount);
}
[Fact]
public async Task FileStoreRoundTripsStrictCp949WithAtomicTarget()
{
@@ -294,14 +393,16 @@ public sealed class LegacyComparisonProductionAdapterTests
private sealed class StaticWorldLookup : IWorldStockSearchService
{
public WorldStockSearchResult Result { get; init; } = new(
string.Empty,
DateTimeOffset.UtcNow,
[new WorldStockSearchItem("APPLE", "AAPL", "US")],
false);
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) => Task.FromResult(
new WorldStockSearchResult(
query.Trim(),
DateTimeOffset.UtcNow,
[new WorldStockSearchItem("APPLE", "AAPL", "US")],
false));
Result with { Query = query.Trim() });
}
}