feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user