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() });
}
}

View File

@@ -110,6 +110,38 @@ public sealed class LegacyExpertWorkflowTests
Assert.Equal("1/0", draft.PageText);
}
[Fact]
public async Task NullBuyAmount_RemainsVisibleAndCanBeComposedForFreshPrepareValidation()
{
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha)),
Preview = (identity, _, _) => Task.FromResult(PreviewResult(identity,
[
new ExpertRecommendationPreview(0, "005930", "Samsung", null),
new ExpertRecommendationPreview(1, "035720", "Kakao", 42_000m)
]))
};
var controller = new LegacyExpertWorkflowController(service);
var search = await controller.SearchAsync(string.Empty);
var selected = await controller.SelectExpertAsync(search.Search.Results[0].SelectionId);
Assert.Equal(LegacyExpertRequestStatus.Ready, selected.Preview.Status);
Assert.Equal(2, selected.Preview.Items.Count);
Assert.Null(selected.Preview.Items[0].BuyAmount);
Assert.True(Assert.Single(selected.Actions).IsAvailable);
var draft = controller.MaterializeSelectedAction(
LegacyExpertWorkflowController.ActionId);
Assert.Equal(2, draft.ItemCount);
Assert.Equal(1, draft.PageCount);
Assert.Equal("1/1", draft.PageText);
Assert.Equal("0001", draft.ExpertCode);
Assert.Equal(Alpha.ExpertName, draft.ExpertName);
}
[Fact]
public async Task LateSearchResponse_CannotReplaceNewerExpertResult()
{

View File

@@ -0,0 +1,156 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyCutInfoSceneAliasResolverTests
{
[Theory]
[InlineData("2열판- 라인그래프", "50160")]
[InlineData("영업이익 1열판기본", "5081")]
[InlineData("(NXT) 1열판 기본", "N5001")]
[InlineData("1열판 상세 시가", "5006")]
[InlineData("종목별 비교분석_라인 그래프", "5087")]
[InlineData("매매동향 라인그래프", "5084")]
[InlineData("캔들그래프 120일 20일", "8051")]
public void TryResolve_PreservesOriginalOrderedPrecedence(string info, string expected)
{
Assert.True(LegacyCutInfoSceneAliasResolver.TryResolve(
info, string.Empty, string.Empty, string.Empty, out var alias));
Assert.Equal(expected, alias);
}
[Fact]
public void TryResolve_RemovesAsciiSpacesBeforeMatching()
{
Assert.True(LegacyCutInfoSceneAliasResolver.TryResolve(
" 종 목 별 비 교 분 석 _ 캔 들 그 래 프 ",
string.Empty,
string.Empty,
string.Empty,
out var alias));
Assert.Equal("5026", alias);
}
[Fact]
public void TryResolve_CombinesTheFourVisibleFieldsInOriginalOrder()
{
Assert.True(LegacyCutInfoSceneAliasResolver.TryResolve(
"코스피",
"삼성전자",
"1열판",
"기본",
out var alias));
Assert.Equal("5001", alias);
}
[Fact]
public void TryResolve_ImageGraphPreservesOriginalShortKeySpecialCase()
{
Assert.True(LegacyCutInfoSceneAliasResolver.TryResolve(
"영업이익",
"미국",
"이미지 그래프",
"매출액",
out var alias));
Assert.Equal("6001", alias);
}
[Fact]
public void TryResolve_DoesNotRemoveNonAsciiWhitespace()
{
Assert.False(LegacyCutInfoSceneAliasResolver.TryResolve(
"1\t열판기본",
string.Empty,
string.Empty,
string.Empty,
out var alias));
Assert.Equal(string.Empty, alias);
}
[Theory]
[InlineData("")]
[InlineData("등록되지 않은 컷")]
[InlineData("캔들그래프")]
[InlineData("2열판")]
public void TryResolve_RejectsUnknownOrIncompleteLegacyText(string info)
{
Assert.False(LegacyCutInfoSceneAliasResolver.TryResolve(
info, string.Empty, string.Empty, string.Empty, out var alias));
Assert.Equal(string.Empty, alias);
}
[Fact]
public void TryResolve_RejectsMissingField()
{
Assert.False(LegacyCutInfoSceneAliasResolver.TryResolve(
null!, string.Empty, string.Empty, string.Empty, out var alias));
Assert.Equal(string.Empty, alias);
}
[Theory]
[MemberData(nameof(EveryOriginalResult))]
public void TryResolve_ReturnsOnlyRegisteredSceneAliases(string info, string expected)
{
Assert.True(LegacyCutInfoSceneAliasResolver.TryResolve(
info, string.Empty, string.Empty, string.Empty, out var alias));
Assert.Equal(expected, alias);
Assert.NotEmpty(LegacySceneCatalog.FindByCutAlias(alias));
}
public static TheoryData<string, string> EveryOriginalResult => new()
{
{ "2열판-", "50160" },
{ "영업이익", "5081" },
{ "매출액", "5080" },
{ "성장성지표", "5079" },
{ "주요매출구성", "5076" },
{ "(NXT)1열판기본", "N5001" },
{ "1열판기본", "5001" },
{ "1열판상세시가", "5006" },
{ "1열판상세", "5011" },
{ "종목별비교분석_캔들그래프", "5026" },
{ "종목별비교분석_라인그래프", "5087" },
{ "종목별수익률비교", "5029" },
{ "수익률그래프", "5086" },
{ "캔들그래프일봉", "8035" },
{ "캔들그래프5일", "8061" },
{ "캔들그래프120일", "8051" },
{ "캔들그래프20일", "8040" },
{ "캔들그래프60일", "8046" },
{ "캔들그래프240일", "8056" },
{ "호가창", "8003" },
{ "거래원", "5037" },
{ "3열판", "5016" },
{ "미국증시지도", "5068" },
{ "유럽증시지도", "5070" },
{ "아시아증시지도", "5072" },
{ "글로벌증시지도", "8067" },
{ "미국업종", "5078" },
{ "섹터지수", "5078" },
{ "이미지그래프", "6001" },
{ "주체별매매동향", "5082" },
{ "매매동향표그래프", "5023" },
{ "개인매매동향", "5083" },
{ "기관매매동향", "5083" },
{ "외국인매매동향", "5083" },
{ "매매동향라인그래프", "5084" },
{ "매매동향막대그래프", "5024" },
{ "기관순매수현황", "6067" },
{ "프로그램매매", "5085" },
{ "순매도상위", "5025" },
{ "코스피네모그래프", "8001" },
{ "코스닥네모그래프", "8002" },
{ "5단표그래프", "5074" },
{ "6종목현재가", "5077" },
{ "12종목현재가", "5088" },
{ "지수선물2열판", "5032" },
{ "종목2열판", "8032" },
{ "코스피업종2열판", "8018" },
{ "코스닥업종2열판", "8032" },
{ "라인그래프", "50860" }
};
}

View File

@@ -1,3 +1,5 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
@@ -138,11 +140,29 @@ public sealed class LegacyOperatorControllerTests
"AI 테마",
"GAIN_DESC",
"CURRENT",
"00000001"),
"00000001",
"AI 테마"),
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.ToPlayoutEntry().Selection));
}
[Fact]
public async Task ThemeSearch_ReportsTheFiniteFiveThousandRowTruncationToTheOperator()
{
var controller = new LegacyOperatorController(
Lookup(),
themeWorkflow: new LegacyThemeWorkflow(new TruncatedThemeService()));
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
var searched = await controller.SearchThemesAsync("Theme");
Assert.Equal(5_000, searched.Theme!.SearchResults.Count);
Assert.True(searched.Theme.SearchIsTruncated);
Assert.Equal(LegacyOperatorStatusKind.Warning, searched.StatusKind);
Assert.Contains("5,000", searched.StatusMessage, StringComparison.Ordinal);
Assert.Contains("검색어", searched.StatusMessage, StringComparison.Ordinal);
}
[Fact]
public async Task NxtExpectedThemeAction_RemainsClickableAndShowsExactWarningWithoutAdding()
{
@@ -241,7 +261,8 @@ public sealed class LegacyOperatorControllerTests
"AI 테마(NXT)",
"PRE_MARKET",
"INPUT_ORDER",
"00000002"),
"00000002",
"AI 테마"),
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.ToPlayoutEntry().Selection));
}
@@ -283,7 +304,7 @@ public sealed class LegacyOperatorControllerTests
}
[Fact]
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRow()
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRowWithNullBuyAmount()
{
var controller = new LegacyOperatorController(
Lookup(),
@@ -299,6 +320,7 @@ public sealed class LegacyOperatorControllerTests
var row = Assert.Single(added.Playlist);
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
Assert.Equal("1/2", row.PageText);
Assert.Null(added.Expert!.Preview.Items[0].BuyAmount);
Assert.Equal("전문가", row.MarketText);
}
@@ -338,7 +360,7 @@ public sealed class LegacyOperatorControllerTests
var controller = new LegacyOperatorController(Lookup());
Assert.Equal(
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외종목", "전문가", "정지"],
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외시장", "전문가", "거래정지"],
controller.Current.Tabs!.Select(tab => tab.Label));
Assert.Equal(
LegacyOperatorTabId.Overseas,
@@ -417,8 +439,12 @@ public sealed class LegacyOperatorControllerTests
var leaf = controller.ActivateFixedAction(first.Id);
var row = Assert.Single(leaf.Playlist);
Assert.Equal("해외지수", row.MarketText);
Assert.Equal(first.Label, row.StockName);
var expected = OriginalFixedVisibleSelection(first);
Assert.Equal(expected.GroupCode, row.MarketText);
Assert.Equal(expected.Subject, row.StockName);
Assert.Equal(expected.GraphicType, row.GraphicType);
Assert.Equal(expected.Subtype, row.Subtype);
Assert.Equal(expected.DataCode, row.StockCode);
Assert.Equal(first.Code, row.ToPlayoutEntry().CutCode);
Assert.Equal(
first.Selection.Subject,
@@ -430,10 +456,154 @@ public sealed class LegacyOperatorControllerTests
var section = await controller.ActivateFixedSection(0);
Assert.Equal(1 + firstSection.Actions.Count, section.Playlist.Count);
Assert.Equal(
firstSection.Actions.Select(action => action.Label),
firstSection.Actions.Select(action =>
OriginalFixedVisibleSelection(action).Subject),
section.Playlist.Skip(1).Select(item => item.StockName));
}
[Fact]
public async Task EveryAvailableFixedActionUsesOriginalVisibleProjectionCanonicalPlayoutAndInitialPageN()
{
var catalog = LegacyFixedActionCatalog.Default;
var localNow = new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.FromHours(9));
var pagePlans = new RecordingPagePlanProvider();
var availableActions = catalog.Actions
.Where(candidate => candidate.Available)
.ToArray();
Assert.Equal(322, availableActions.Length);
var visibleIdentities = new HashSet<
(string GroupCode, string Subject, string GraphicType, string Subtype, string DataCode)>();
foreach (var action in availableActions)
{
var controller = new LegacyOperatorController(
Lookup(),
timeProvider: new FixedTimeProvider(localNow),
fixedPagePlanProvider: pagePlans);
controller.SelectTab(action.Market switch
{
LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas,
LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange,
LegacyFixedMarket.Index => LegacyOperatorTabId.Index,
_ => throw new ArgumentOutOfRangeException()
});
var state = await controller.ActivateFixedActionAsync(action.Id);
var row = Assert.Single(state.Playlist);
var expectedVisible = OriginalFixedVisibleSelection(action);
Assert.Equal(expectedVisible.GroupCode, row.MarketText);
Assert.Equal(expectedVisible.Subject, row.StockName);
Assert.Equal(expectedVisible.GraphicType, row.GraphicType);
Assert.Equal(expectedVisible.Subtype, row.Subtype);
Assert.Equal(expectedVisible.DataCode, row.StockCode);
Assert.Equal(action.Code switch
{
"5074" => "1/20",
"5077" or "5088" => "1/3",
_ => "1/1"
}, row.PageText);
Assert.Equal(action.Label, row.RawCutLabel);
Assert.Equal(action.Code, row.SceneAlias);
Assert.True(
visibleIdentities.Add((
row.MarketText,
row.StockName,
row.GraphicType,
row.Subtype,
row.StockCode)),
$"Duplicate original fixed selection: {action.Id}");
var expectedPlayout = catalog.Materialize(action.Id, localNow).Selection;
var actualPlayout = Assert.IsType<
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.PlayoutSelection);
Assert.Equal(expectedPlayout.GroupCode, actualPlayout.GroupCode);
Assert.Equal(expectedPlayout.Subject, actualPlayout.Subject);
Assert.Equal(expectedPlayout.GraphicType, actualPlayout.GraphicType);
Assert.Equal(expectedPlayout.Subtype, actualPlayout.Subtype);
Assert.Equal(expectedPlayout.DataCode, actualPlayout.DataCode);
}
Assert.Equal(availableActions.Length, visibleIdentities.Count);
Assert.Equal(78, pagePlans.Entries.Count);
Assert.Equal(
78,
pagePlans.Entries.Select(entry => entry.EntryId).Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public async Task FixedPagedActionPreservesOriginalOneSlashZeroForEmptyOrFailedPageRead()
{
var action = LegacyFixedActionCatalog.Default.Actions
.First(candidate => candidate.Available && candidate.Code == "5074");
var emptyPlans = new RecordingPagePlanProvider { FiveRowItemCount = 0 };
var emptyController = new LegacyOperatorController(
Lookup(),
fixedPagePlanProvider: emptyPlans);
emptyController.SelectTab(LegacyOperatorTabId.Index);
var empty = await emptyController.ActivateFixedActionAsync(action.Id);
Assert.Equal("1/0", Assert.Single(empty.Playlist).PageText);
Assert.Equal(LegacyOperatorStatusKind.Information, empty.StatusKind);
var failedPlans = new RecordingPagePlanProvider
{
Failure = new IOException("page read failed")
};
var failedController = new LegacyOperatorController(
Lookup(),
fixedPagePlanProvider: failedPlans);
failedController.SelectTab(LegacyOperatorTabId.Index);
var failed = await failedController.ActivateFixedActionAsync(action.Id);
Assert.Equal("1/0", Assert.Single(failed.Playlist).PageText);
Assert.Equal(LegacyOperatorStatusKind.Warning, failed.StatusKind);
Assert.Null(failed.Dialog);
}
[Fact]
public async Task FixedPagedActionCancellationDoesNotAppendAPlaylistRow()
{
var action = LegacyFixedActionCatalog.Default.Actions
.First(candidate => candidate.Available && candidate.Code == "5077");
var controller = new LegacyOperatorController(
Lookup(),
fixedPagePlanProvider: new RecordingPagePlanProvider());
controller.SelectTab(LegacyOperatorTabId.Index);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
controller.ActivateFixedActionAsync(action.Id, cancellation.Token));
Assert.Empty(controller.Current.Playlist);
}
[Fact]
public void Fixed8035UsesOriginalIndexFieldsWithoutChangingCanonicalSelection()
{
var controller = new LegacyOperatorController(Lookup());
controller.SelectTab(LegacyOperatorTabId.Index);
var state = controller.ActivateFixedAction("fixed-136");
var row = Assert.Single(state.Playlist);
Assert.Equal("코스피", row.MarketText);
Assert.Equal("지수", row.StockName);
Assert.Equal("캔들그래프", row.GraphicType);
Assert.Equal("일봉", row.Subtype);
Assert.Equal(string.Empty, row.StockCode);
Assert.Equal("8035", row.SceneAlias);
var selection = Assert.IsType<
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.PlayoutSelection);
Assert.Equal("KOSPI_INDEX", selection.GroupCode);
Assert.Equal("INDEX", selection.Subject);
Assert.Equal(string.Empty, selection.GraphicType);
Assert.Equal("PRICE", selection.Subtype);
Assert.Equal(string.Empty, selection.DataCode);
}
[Fact]
public void FixedCatalogRejectsCrossTabAndUnavailableActionsAtomically()
{
@@ -949,12 +1119,27 @@ public sealed class LegacyOperatorControllerTests
control: false,
shift: false);
var empty = controller.DeleteAllPlaylistRows();
var requested = controller.DeleteAllPlaylistRows();
Assert.Equal("송출 리스트를 모두 삭제하겠습니까?", requested.Dialog?.Message);
Assert.Equal("MmoneyCoder", requested.Dialog?.Caption);
Assert.True(requested.Dialog?.IsConfirmation);
Assert.Equal(3, requested.Playlist.Count);
var canceled = controller.CancelDialog();
Assert.Null(canceled.Dialog);
Assert.Equal(3, canceled.Playlist.Count);
controller.DeleteAllPlaylistRows();
var empty = await controller.ConfirmDialogAsync();
Assert.Null(empty.Dialog);
Assert.Empty(empty.Playlist);
var unchangedRevision = empty.Revision;
var stillEmpty = controller.DeleteAllPlaylistRows();
Assert.Equal(unchangedRevision, stillEmpty.Revision);
var requestedEmpty = controller.DeleteAllPlaylistRows();
Assert.True(requestedEmpty.Dialog?.IsConfirmation);
var stillEmpty = await controller.ConfirmDialogAsync();
Assert.Empty(stillEmpty.Playlist);
Assert.True(stillEmpty.Revision > unchangedRevision);
}
[Fact]
@@ -962,6 +1147,7 @@ public sealed class LegacyOperatorControllerTests
{
var controller = await ControllerWithPlaylistAsync(0, 1);
controller.DeleteAllPlaylistRows();
await controller.ConfirmDialogAsync();
controller.CutPointerDown(2, false, false, 1, 47, 10_000);
var state = controller.DropSelectedCutsOnPlaylist();
@@ -1082,6 +1268,61 @@ public sealed class LegacyOperatorControllerTests
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
}
private static LegacyFixedSelection OriginalFixedVisibleSelection(
LegacyFixedAction action)
{
var separator = action.Label.IndexOf('_');
var first = separator < 0 ? action.Label : action.Label[..separator];
var remainder = separator < 0
? string.Empty
: action.Label[(separator + 1)..];
if (action.Market is LegacyFixedMarket.Overseas or LegacyFixedMarket.Exchange)
{
return new LegacyFixedSelection(
action.Market == LegacyFixedMarket.Overseas ? "해외지수" : "환율",
first,
action.Section,
remainder,
string.Empty);
}
var indexGroup = action.Label.Contains("코스피_NXT", StringComparison.Ordinal)
? "코스피_NXT"
: action.Label.Contains("코스닥_NXT", StringComparison.Ordinal)
? "코스닥_NXT"
: action.Label.Contains("코스피", StringComparison.Ordinal)
? "코스피"
: action.Label.Contains("코스닥", StringComparison.Ordinal)
? "코스닥"
: "전체";
if (action.Section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
{
return new LegacyFixedSelection(
indexGroup,
"지수",
action.Section,
action.Label,
string.Empty);
}
if (string.Equals(action.Section, "매매동향", StringComparison.Ordinal))
{
return new LegacyFixedSelection(
indexGroup,
"지수",
first,
remainder,
string.Empty);
}
return new LegacyFixedSelection(
action.Section,
"지수",
first,
remainder,
string.Empty);
}
private static async Task<LegacyOperatorController> ControllerWithPlaylistAsync(
params int[] physicalCutRows)
{
@@ -1114,6 +1355,49 @@ public sealed class LegacyOperatorControllerTests
new LegacyStockIdentity(LegacyDomesticStockMarket.NxtKospi, "넥스트(NXT)", "A00001")
]));
private sealed class RecordingPagePlanProvider : ILegacyScenePagePlanProvider
{
public List<LegacyPlaylistEntry> Entries { get; } = [];
public int FiveRowItemCount { get; init; } = 101;
public int SixRowItemCount { get; init; } = 13;
public int TwelveRowItemCount { get; init; } = 25;
public Exception? Failure { get; init; }
public Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Entries.Add(entry);
if (Failure is not null)
{
return Task.FromException<LegacyScenePagePlan>(Failure);
}
var (builderKey, pageSize, itemCount) = entry.CutCode switch
{
"5074" => ("s5074", ScenePageSize.Five, FiveRowItemCount),
"5077" => ("s5077", ScenePageSize.Six, SixRowItemCount),
"5088" => ("s5088", ScenePageSize.Twelve, TwelveRowItemCount),
_ => throw new InvalidOperationException(
"Only a paged fixed action may request an initial page plan.")
};
var plan = Assert.IsType<ScenePagingPlan>(
ScenePaging.CreatePlan(itemCount, pageSize).Plan);
return Task.FromResult(new LegacyScenePagePlan(
builderKey,
itemCount,
pageSize,
plan.TotalPages,
plan.AccessibleItemCount,
plan.ExcludedItemCount > 0));
}
}
private sealed class FakeLookup : ILegacyStockLookup
{
private readonly LegacyStockSearchData _result;
@@ -1216,6 +1500,43 @@ public sealed class LegacyOperatorControllerTests
}
}
private sealed class TruncatedThemeService : IThemeSelectionService
{
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
Assert.Equal(5_000, maximumResults);
var rows = Enumerable.Range(0, maximumResults)
.Select(index => new ThemeSelectorItem(
new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture),
$"Theme {index}"),
DataSourceKind.Oracle))
.ToArray();
return Task.FromResult(new ThemeSearchResult(
query,
nxtSession,
DateTimeOffset.UtcNow,
rows,
IsTruncated: true));
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ThemePreviewResult(
identity,
DateTimeOffset.UtcNow,
[],
IsTruncated: false));
}
private sealed class StaticExpertService : IExpertSelectionService
{
private static readonly ExpertSelectionIdentity Identity = new("0001", "전문가A");
@@ -1242,7 +1563,7 @@ public sealed class LegacyOperatorControllerTests
index,
index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
$"추천{index}",
10_000m + index))
index == 1 ? null : 10_000m + index))
.ToArray(),
false));
}

View File

@@ -152,11 +152,11 @@ public sealed class LegacyOperatorPlayoutRequestTests
[InlineData("1/21")]
[InlineData("2/1")]
[InlineData("1 /2")]
public void CanonicalPagedGraphicRejectsInvalidOperatorPageText(string pageText)
public void CanonicalPagedGraphicDefersHistoricalInvalidPageText(string pageText)
{
var row = OperatorRow("5단 표그래프", pageText);
var entry = OperatorRow("5단 표그래프", pageText).ToPlayoutEntry();
Assert.Throws<InvalidOperationException>(row.ToPlayoutEntry);
Assert.Null(entry.PageNavigation);
}
[Fact]

View File

@@ -22,11 +22,11 @@ public sealed class LegacyOperatorSettingsTests
}
[Fact]
public void SceneValidation_RequiresOnlyTheBaselineSceneAndReturnsAnAbbreviation()
public void SceneValidation_RequiresEveryActiveSceneAndBuiltInAssetAndAllowsMissingExternalVideo()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
WriteValidSceneFiles(sceneDirectory);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
@@ -34,7 +34,7 @@ public sealed class LegacyOperatorSettingsTests
Assert.True(result.IsValid);
Assert.Equal(Path.GetFullPath(sceneDirectory), result.Folder!.CanonicalPath);
Assert.Equal("baseline", File.ReadAllText(
Assert.Equal("fixture-scene", File.ReadAllText(
Path.Combine(result.Folder.CanonicalPath, "5001.t2s")));
Assert.Contains("Cuts", result.Folder.AbbreviatedPath);
Assert.InRange(
@@ -42,9 +42,125 @@ public sealed class LegacyOperatorSettingsTests
1,
LegacyOperatorFolderValidator.DefaultDisplayCharacters);
Assert.False(result.Folder.DatabaseIniDetected);
Assert.Equal(
LegacyOperatorFolderValidator.OptionalExternalAssetRelativePaths.Count,
result.Folder.MissingOptionalExternalAssetCount);
Assert.Null(result.WarningMessage);
}
[Fact]
public void SceneValidation_BaselineOnlyIsRejectedInsteadOfBreakingOtherScenesAfterRestart()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("partial-Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
sceneDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileMissing,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, sceneDirectory);
}
[Fact]
public void SceneValidation_MissingOneActiveSceneOrBuiltInAssetIsRejected()
{
using var directory = new TemporaryDirectory();
var missingSceneDirectory = directory.CreateDirectory("missing-scene");
WriteValidSceneFiles(missingSceneDirectory);
File.Delete(Path.Combine(missingSceneDirectory, "5079.t2s"));
var missingScene = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
missingSceneDirectory);
var missingAssetDirectory = directory.CreateDirectory("missing-asset");
WriteValidSceneFiles(missingAssetDirectory);
File.Delete(Path.Combine(missingAssetDirectory, "Images", "KRX.png"));
var missingAsset = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
missingAssetDirectory);
Assert.False(missingScene.IsValid);
Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileMissing,
missingScene.Failure);
Assert.False(missingAsset.IsValid);
Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileMissing,
missingAsset.Failure);
}
[Fact]
public void SceneValidation_RejectsEmptyRequiredOrPresentOptionalFiles()
{
using var directory = new TemporaryDirectory();
var emptyRequiredDirectory = directory.CreateDirectory("empty-required");
WriteValidSceneFiles(emptyRequiredDirectory);
File.WriteAllBytes(Path.Combine(emptyRequiredDirectory, "5076.t2s"), []);
var emptyRequired = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
emptyRequiredDirectory);
var emptyOptionalDirectory = directory.CreateDirectory("empty-optional");
WriteValidSceneFiles(emptyOptionalDirectory);
var optionalPath = Path.Combine(emptyOptionalDirectory, "Video", "큐브배경.vrv");
Directory.CreateDirectory(Path.GetDirectoryName(optionalPath)!);
File.WriteAllBytes(optionalPath, []);
var emptyOptional = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
emptyOptionalDirectory);
Assert.False(emptyRequired.IsValid);
Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileUnsafe,
emptyRequired.Failure);
Assert.False(emptyOptional.IsValid);
Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileUnsafe,
emptyOptional.Failure);
}
[Fact]
public void SceneValidationContract_MatchesTheReachableCutRequirementManifest()
{
var manifestPath = Path.Combine(
FindRepositoryRoot(),
"scripts",
"LegacyCutRequirements.json");
using var document = JsonDocument.Parse(File.ReadAllBytes(manifestPath));
var root = document.RootElement;
var manifestScenes = root.GetProperty("activeAliases")
.EnumerateArray()
.Select(value => string.Concat(value.GetString(), ".t2s"))
.ToArray();
var manifestAssets = root.GetProperty("assets")
.EnumerateArray()
.Select(value => new
{
Kind = value.GetProperty("kind").GetString(),
Path = value.GetProperty("path").GetString()!
})
.ToArray();
Assert.Equal(
manifestScenes.OrderBy(value => value, StringComparer.Ordinal),
LegacyOperatorFolderValidator.RequiredSceneFileNames
.OrderBy(value => value, StringComparer.Ordinal));
Assert.Equal(
manifestAssets.Where(value => value.Kind != "Video")
.Select(value => value.Path)
.OrderBy(value => value, StringComparer.Ordinal),
LegacyOperatorFolderValidator.RequiredBuiltInAssetRelativePaths
.OrderBy(value => value, StringComparer.Ordinal));
Assert.Equal(
manifestAssets.Where(value => value.Kind == "Video")
.Select(value => value.Path)
.OrderBy(value => value, StringComparer.Ordinal),
LegacyOperatorFolderValidator.OptionalExternalAssetRelativePaths
.OrderBy(value => value, StringComparer.Ordinal));
}
[Fact]
public void SceneValidation_MissingBaselineFailsWithoutLeakingThePath()
{
@@ -229,7 +345,7 @@ public sealed class LegacyOperatorSettingsTests
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
WriteValidSceneFiles(sceneDirectory);
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
var backgroundDirectory = directory.CreateDirectory("Background");
@@ -395,6 +511,22 @@ public sealed class LegacyOperatorSettingsTests
WriteComparisonFile(resourceDirectory);
}
private static void WriteValidSceneFiles(string sceneDirectory)
{
foreach (var fileName in LegacyOperatorFolderValidator.RequiredSceneFileNames)
{
File.WriteAllText(Path.Combine(sceneDirectory, fileName), "fixture-scene");
}
foreach (var relativePath in
LegacyOperatorFolderValidator.RequiredBuiltInAssetRelativePaths)
{
var path = Path.Combine(sceneDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "fixture-asset");
}
}
private static void WriteFixedFile(
string resourceDirectory,
string fileName,
@@ -463,6 +595,21 @@ public sealed class LegacyOperatorSettingsTests
DecoderFallback.ExceptionFallback);
}
private static string FindRepositoryRoot()
{
for (var current = new DirectoryInfo(AppContext.BaseDirectory);
current is not null;
current = current.Parent)
{
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return current.FullName;
}
}
throw new DirectoryNotFoundException("Repository root was not found.");
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()

View File

@@ -86,6 +86,27 @@ public sealed class LegacyOverseasOperatorControllerTests
controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType);
}
[Fact]
public async Task DuplicateIndustryDisplayNamesKeepChosenTupleInRuntimePlaylist()
{
var controller = CreateController(industries:
[
new("Shared", "First Input", "NAS@ONE"),
new("Shared", "Second Input", "NAS@TWO")
]);
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
var searched = await controller.SearchOverseasIndustriesAsync(string.Empty);
controller.SelectOverseasIndustry(searched.Overseas!.Industry.Results[1].SelectionId);
controller.ActivateOverseasAction("industry-current");
var runtimeSelection = Assert.Single(
controller.CreatePlayoutRequest().Playlist).Selection!;
Assert.Equal("FOREIGN_INDUSTRY", runtimeSelection.GroupCode);
Assert.Equal("Shared", runtimeSelection.Subject);
Assert.Equal("Second Input|NAS@TWO|US|0", runtimeSelection.DataCode);
}
[Fact]
public async Task NonUsStockKeepsOneExactIdentityThroughBothActualUc5SceneAliases()
{

View File

@@ -146,6 +146,51 @@ public sealed class LegacyOverseasSelectionWorkflowTests
StringComparison.Ordinal);
}
[Fact]
public async Task DuplicateIndustryDisplayNamesRemainDistinctOpaqueSelections()
{
var workflow = CreateWorkflow(industries:
[
new OverseasIndustryIndexItem("Shared", "First Input", "NAS@ONE"),
new OverseasIndustryIndexItem("Shared", "Second Input", "NAS@TWO")
]);
var searched = await workflow.SearchIndustriesAsync(string.Empty);
Assert.Equal(2, searched.Industry.Results.Count);
Assert.Equal("Shared", searched.Industry.Results[0].KoreanName);
Assert.Equal("Shared", searched.Industry.Results[1].KoreanName);
Assert.NotEqual(
searched.Industry.Results[0].SelectionId,
searched.Industry.Results[1].SelectionId);
workflow.SelectIndustry(searched.Industry.Results[1].SelectionId);
var draft = workflow.MaterializePlaylistDraft("industry-current");
Assert.Equal("Second Input|NAS@TWO|US|0", draft.Selection.DataCode);
}
[Fact]
public async Task IsolatedStockRowsAreReportedWithoutRejectingValidSelections()
{
var stockResult = new WorldStockSearchResult(
string.Empty,
RetrievedAt,
[new WorldStockSearchItem("Apple", "NAS@AAPL", "US")],
IsTruncated: true,
IsolatedInvalidRowCount: 16);
var workflow = new LegacyOverseasSelectionWorkflow(
new RecordingIndustryService([]),
new QueueStockService(stockResult));
var snapshot = await workflow.SearchStocksAsync(string.Empty);
Assert.Equal(LegacyOverseasSearchStatus.Ready, snapshot.Stock.Status);
Assert.Single(snapshot.Stock.Results);
Assert.Equal(16, snapshot.Stock.IsolatedInvalidRowCount);
Assert.True(snapshot.Stock.IsTruncated);
}
[Fact]
public void FixedIndexActionsMaterializeExactAliasIdentityPageAndMovingAverages()
{
@@ -482,7 +527,7 @@ public sealed class LegacyOverseasSelectionWorkflowTests
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
workflow.SearchIndustriesAsync("", 0));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
workflow.SearchStocksAsync("", 501));
workflow.SearchStocksAsync("", LegacyOverseasSelectionWorkflow.MaximumResults + 1));
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SearchIndustriesAsync(new string('x', 65)));
await Assert.ThrowsAsync<ArgumentException>(() =>

View File

@@ -4,6 +4,85 @@ namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyStockCutInteractionParityTests
{
[Fact]
public async Task ThreeOrderedProgramClicksSelectThenAppendExactlyOneSafeTailRow()
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(
1,
control: false,
shift: false,
x: 2,
y: 24,
timestampMilliseconds: 1_000);
controller.CutPointerDown(
0,
control: false,
shift: false,
x: 2,
y: 1,
timestampMilliseconds: 10_000);
controller.CutPointerDown(
0,
control: false,
shift: false,
x: 2,
y: 1,
timestampMilliseconds: 10_046);
var state = controller.CutPointerDown(
0,
control: false,
shift: false,
x: 2,
y: 1,
timestampMilliseconds: 10_089);
var appended = Assert.Single(state.Playlist);
Assert.Equal(LegacyStockCutCatalog.Rows[0].RawLabel, appended.RawCutLabel);
Assert.Equal(
[0],
state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
}
[Fact]
public async Task ASecondDragInsideTheLegacyDoubleClickWindowAddsOnceOnPointerDownAndOnceOnDrop()
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, false, false, 2, 24, 1_000);
controller.DropSelectedCutsOnPlaylist();
controller.CutPointerDown(1, false, false, 2, 24, 1_313);
var state = controller.DropSelectedCutsOnPlaylist();
Assert.Equal(3, state.Playlist.Count);
Assert.All(
state.Playlist,
row => Assert.Equal(LegacyStockCutCatalog.Rows[1].RawLabel, row.RawCutLabel));
}
[Fact]
public async Task ASecondDragAtTheExactLegacyBoundaryAddsOnlyItsDrop()
{
var controller = new LegacyOperatorController(new QueryLookup());
await controller.SearchStocksAsync("first", LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(1, false, false, 2, 24, 1_000);
controller.DropSelectedCutsOnPlaylist();
controller.CutPointerDown(1, false, false, 2, 24, 1_500);
var state = controller.DropSelectedCutsOnPlaylist();
Assert.Equal(2, state.Playlist.Count);
Assert.All(
state.Playlist,
row => Assert.Equal(LegacyStockCutCatalog.Rows[1].RawLabel, row.RawCutLabel));
}
[Fact]
public async Task EmptySearchButtonPreservesResultsStockAndCutSelection()
{

View File

@@ -77,6 +77,10 @@ public sealed class LegacyThemeWorkflowTests
property => Assert.False(property.CanWrite));
Assert.Throws<ArgumentOutOfRangeException>(() =>
workflow.CreateInitial(ThemeSession.NotApplicable));
Assert.Equal(5_000, LegacyThemeWorkflow.MaximumSearchResults);
Assert.Equal(
LegacyThemeSelectionService.MaximumResults,
LegacyThemeWorkflow.MaximumSearchResults);
}
[Theory]
@@ -229,8 +233,43 @@ public sealed class LegacyThemeWorkflowTests
query,
session,
Selector(ThemeMarket.Nxt, session, "AI(NXT)", "00000001"));
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
var storedSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal("AI(NXT)", Assert.Single(storedSuffix.SearchResults).DisplayTitle);
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Nxt, session, "AI(NXT)(NXT)", "00000001"));
var repeatedNxtSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal(
"AI(NXT)(NXT)",
Assert.Single(repeatedNxtSuffix.SearchResults).Identity.ThemeTitle);
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI(NXT)", "00000001"));
var krxStoredSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal("AI(NXT)", Assert.Single(krxStoredSuffix.SearchResults).DisplayTitle);
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI(NXT)(NXT)", "00000001"));
var repeatedKrxSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal(ThemeMarket.Krx, Assert.Single(repeatedKrxSuffix.SearchResults).Market);
service.SearchHandler = (query, session) => SearchResult(
query,
@@ -263,6 +302,70 @@ public sealed class LegacyThemeWorkflowTests
Assert.Contains("search provider order", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_PreservesExactLegacyStoredTitlesWithEdgeSpaces()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, " KRX Theme ", "00000001"),
Selector(ThemeMarket.Nxt, session, " NXT Theme ", "00000002"))
};
var workflow = new LegacyThemeWorkflow(service);
var result = await workflow.SearchAsync(
workflow.CreateInitial(),
string.Empty,
ThemeSession.PreMarket);
Assert.Collection(
result.SearchResults,
row =>
{
Assert.Equal(" KRX Theme ", row.Identity.ThemeTitle);
Assert.Equal(" KRX Theme ", row.DisplayTitle);
Assert.Equal(ThemeMarket.Krx, row.Market);
},
row =>
{
Assert.Equal(" NXT Theme ", row.Identity.ThemeTitle);
Assert.Equal(" NXT Theme (NXT)", row.DisplayTitle);
Assert.Equal(ThemeMarket.Nxt, row.Market);
});
}
[Fact]
public async Task SearchAsync_AcceptsFiniteFiveThousandRowTruncationBoundary()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => new ThemeSearchResult(
query,
session,
DateTimeOffset.UtcNow,
Enumerable.Range(0, LegacyThemeWorkflow.MaximumSearchResults)
.Select(index => Selector(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
$"Theme {index}",
index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture)))
.ToArray(),
IsTruncated: true)
};
var workflow = new LegacyThemeWorkflow(service);
var snapshot = await workflow.SearchAsync(
workflow.CreateInitial(),
string.Empty,
ThemeSession.PreMarket);
Assert.Equal(5_000, snapshot.SearchResults.Count);
Assert.True(snapshot.SearchIsTruncated);
Assert.Equal("theme-result-5000", snapshot.SearchResults[^1].ResultId);
}
[Fact]
public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview()
{
@@ -515,6 +618,7 @@ public sealed class LegacyThemeWorkflowTests
action.ValueKind == LegacyThemeValueKind.Expected ? "EXPECTED" : "CURRENT",
draft.Selection.Subtype);
Assert.Equal("00000001", draft.Selection.DataCode);
Assert.Equal("AI", draft.Selection.ExactSubject);
Assert.Equal(action.PageSize, draft.PageSize);
Assert.Equal($"1/{draft.PagePlan.PageCount}", draft.PagePlan.InitialPageText);
Assert.InRange(draft.PagePlan.PageCount, 1, 20);
@@ -554,7 +658,8 @@ public sealed class LegacyThemeWorkflowTests
"Robotics(NXT)",
"AFTER_MARKET",
sortId,
"00000002"),
"00000002",
"Robotics"),
draft.Selection);
Assert.True(workflow.GetActionState(selected, actionId).IsAvailable);
}
@@ -567,6 +672,69 @@ public sealed class LegacyThemeWorkflowTests
Assert.Equal("NXT는 데이터가 없습니다.", exception.Message);
}
[Fact]
public async Task MaterializePlaylistDraft_NxtStoredSuffixIsNotDuplicated()
{
var identity = Identity(
ThemeMarket.Nxt,
ThemeSession.PreMarket,
"Robotics(NXT)",
"00000002");
var workflow = new LegacyThemeWorkflow(
ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Nxt)));
var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);
var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");
Assert.Equal("Robotics(NXT)", draft.Title);
Assert.Equal("Robotics(NXT)", draft.Selection.Subject);
Assert.Equal("Robotics(NXT)", draft.Selection.ExactSubject);
}
[Fact]
public async Task MaterializePlaylistDraft_KrxStoredNxtSuffixKeepsExplicitKrxRoute()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"Robotics(NXT)",
"00000002");
var workflow = new LegacyThemeWorkflow(
ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Krx)));
var selected = await SearchAndSelectAsync(workflow);
var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");
Assert.Equal("Robotics(NXT)", draft.Title);
Assert.Equal("PAGED_THEME", draft.Selection.GroupCode);
Assert.Equal("Robotics(NXT)", draft.Selection.Subject);
Assert.Equal("Robotics(NXT)", draft.Selection.ExactSubject);
}
[Fact]
public async Task SelectAsync_NxtEmptyActivePreviewRemainsSelectedButCannotAddOrPlayout()
{
var identity = Identity(
ThemeMarket.Nxt,
ThemeSession.PreMarket,
" NXT Theme",
"00000004");
var workflow = new LegacyThemeWorkflow(ServiceWithTheme(identity, []));
var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);
Assert.Equal(ThemeMarket.Nxt, selected.SelectedTheme!.Market);
Assert.Equal(" NXT Theme(NXT)", selected.SelectedTheme.DisplayTitle);
Assert.Empty(selected.PreviewItems);
Assert.All(selected.ActionStates, state =>
{
Assert.False(state.IsAvailable);
Assert.Equal("theme-preview-is-empty", state.UnavailableReason);
});
Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(selected, "five-row-current"));
}
[Fact]
public async Task MaterializePlaylistDraft_NxtReevaluatesTheNativeHourAtActionTime()
{

View File

@@ -1,3 +1,4 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
@@ -5,6 +6,106 @@ namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyManualFinancialOperatorControllerTests
{
[Fact]
public async Task GraphE_open_warns_when_invalid_legacy_rows_were_isolated()
{
var service = new FakeManualService(Sales("Alpha", 1))
{
RejectedItemCount = 2
};
var opened = await Controller(service).OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
Assert.Single(opened.ManualFinancial!.Rows);
Assert.Equal(2, opened.ManualFinancial.ListRejectedItemCount);
Assert.Equal(LegacyOperatorStatusKind.Warning, opened.StatusKind);
Assert.Contains("2행", opened.StatusMessage, StringComparison.Ordinal);
}
[Fact]
public async Task Strict_ready_GraphE_load_clears_previous_raw_row_warning()
{
var service = new LegacyManualFinancialScreenService(
new RawGraphEQueryExecutor());
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
var rawRow = opened.ManualFinancial!.Rows.Single(row => !row.IsPlayoutReady);
var readyRow = opened.ManualFinancial.Rows.Single(row => row.IsPlayoutReady);
var rawLoaded = await controller.LoadManualFinancialAsync(rawRow.ResultId);
Assert.False(rawLoaded.ManualFinancial!.IsPlayoutReady);
Assert.Equal(LegacyOperatorStatusKind.Warning, rawLoaded.StatusKind);
Assert.Contains("원문", rawLoaded.StatusMessage, StringComparison.Ordinal);
var readyLoaded = await controller.LoadManualFinancialAsync(readyRow.ResultId);
Assert.True(readyLoaded.ManualFinancial!.IsPlayoutReady);
Assert.Equal(LegacyOperatorStatusKind.Information, readyLoaded.StatusKind);
Assert.Contains("Alpha", readyLoaded.StatusMessage, StringComparison.Ordinal);
Assert.DoesNotContain("원문", readyLoaded.StatusMessage, StringComparison.Ordinal);
}
[Fact]
public async Task Unreadable_row_failure_clears_previous_detail_and_playout_authority()
{
var service = new FakeManualService(Sales("Alpha", 1))
{
RejectedItemCount = 1,
UnreadableStockNames = ["Broken"],
GetOverride = identity => identity.StockName == "Broken"
? throw new ManualFinancialDataException("Unreadable legacy row.")
: Sales("Alpha", 1)
};
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
var valid = opened.ManualFinancial!.Rows.Single(row => row.IsDataReadable);
var unreadable = opened.ManualFinancial.Rows.Single(row => !row.IsDataReadable);
var loaded = await controller.LoadManualFinancialAsync(valid.ResultId);
Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist);
var rejected = await controller.LoadManualFinancialAsync(unreadable.ResultId);
var failed = rejected.ManualFinancial!;
Assert.Equal(unreadable.ResultId, failed.SelectedRowId);
Assert.Null(failed.SelectedRecord);
Assert.False(failed.CanSave);
Assert.False(failed.CanDelete);
Assert.False(failed.CanMaterializePlaylist);
Assert.NotNull(rejected.Dialog);
var activated = await controller.ActivateManualFinancialResultAsync(
unreadable.ResultId);
Assert.Empty(activated.Playlist);
Assert.False(activated.CommandResult?.Succeeded);
Assert.Equal(unreadable.ResultId, activated.CommandResult?.TargetId);
}
[Fact]
public async Task Stale_result_handle_clears_previous_detail_before_it_is_rejected()
{
var service = new FakeManualService(Sales("Alpha", 1));
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
var valid = Assert.Single(opened.ManualFinancial!.Rows);
var loaded = await controller.LoadManualFinancialAsync(valid.ResultId);
Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist);
var rejected = await controller.LoadManualFinancialAsync("stale-result-id");
Assert.Null(rejected.ManualFinancial!.SelectedRowId);
Assert.Null(rejected.ManualFinancial.SelectedRecord);
Assert.False(rejected.ManualFinancial.CanSave);
Assert.False(rejected.ManualFinancial.CanDelete);
Assert.False(rejected.ManualFinancial.CanMaterializePlaylist);
Assert.NotNull(rejected.Dialog);
Assert.Equal(1, service.GetCalls);
}
[Fact]
public async Task GraphE_result_double_click_fresh_loads_appends_once_and_closes_on_success()
{
@@ -116,11 +217,96 @@ public sealed class LegacyManualFinancialOperatorControllerTests
LegacyManualFinancialWorkflowStatus.WriteCommitted,
saved.ManualFinancial!.Status);
Assert.True(saved.ManualFinancial.CanMaterializePlaylist);
Assert.Equal("저장되었습니다", saved.Dialog?.Message);
Assert.Equal("MmoneyCoder", saved.Dialog?.Caption);
Assert.False(saved.Dialog?.IsConfirmation);
Assert.DoesNotContain("password", saved.StatusMessage, StringComparison.OrdinalIgnoreCase);
var dismissed = controller.DismissDialog();
Assert.Null(dismissed.Dialog);
Assert.NotNull(dismissed.ManualFinancial);
}
[Fact]
public async Task Delete_and_delete_all_are_only_reached_by_explicit_controller_methods()
public async Task Empty_GraphE_stock_confirm_is_a_quiet_no_op()
{
var service = new FakeManualService();
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.OperatingProfit);
var creating = controller.BeginManualFinancialCreate();
var unchanged = await controller.SearchManualFinancialStocksAsync(" ");
Assert.Equal(creating.Revision, unchanged.Revision);
Assert.Equal(creating.ManualFinancial!.Revision, unchanged.ManualFinancial!.Revision);
Assert.Empty(unchanged.ManualFinancial.StockCandidates);
Assert.Null(unchanged.Dialog);
Assert.Equal(0, service.MutationCalls);
Assert.NotEqual(opened.Revision, unchanged.Revision);
var noSelection = controller.SelectManualFinancialStock(string.Empty);
Assert.Equal(unchanged.Revision, noSelection.Revision);
Assert.Null(noSelection.Dialog);
}
[Fact]
public async Task Known_GraphE_save_rejection_uses_specific_sanitized_dialog_and_keeps_editor()
{
const string safeMessage =
"The create precondition failed and the Oracle transaction was rolled back.";
var service = new FakeManualService
{
MutationException = new ManualFinancialMutationRejectedException(
ManualFinancialMutationRejectionReason.DuplicateIdentity,
safeMessage)
};
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);
var rejected = await controller.SaveManualFinancialAsync(Profit("Alpha", 10));
Assert.Equal(1, service.MutationCalls);
Assert.Equal(
LegacyManualFinancialWorkflowStatus.WriteRejected,
rejected.ManualFinancial!.Status);
Assert.Equal(safeMessage, rejected.StatusMessage);
Assert.Equal(safeMessage, rejected.Dialog?.Message);
Assert.Equal("MmoneyCoder", rejected.Dialog?.Caption);
Assert.DoesNotContain("DB연결", rejected.Dialog!.Message, StringComparison.Ordinal);
var dismissed = controller.DismissDialog();
Assert.Null(dismissed.Dialog);
Assert.NotNull(dismissed.ManualFinancial);
Assert.True(dismissed.ManualFinancial.CanSave);
}
[Fact]
public async Task Malformed_GraphE_editor_data_is_not_misreported_as_a_database_outage()
{
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);
var rejected = await controller.SaveManualFinancialAsync(
Assert.IsType<ManualSalesRecord>(Sales("Alpha", 1).Record));
Assert.NotNull(rejected.ManualFinancial);
Assert.NotNull(rejected.Dialog);
Assert.DoesNotContain("DB연결", rejected.Dialog!.Message, StringComparison.Ordinal);
Assert.Equal(0, service.MutationCalls);
}
[Fact]
public async Task Delete_and_delete_all_require_MmoneyCoder_confirmation_before_writing()
{
var service = new FakeManualService(Sales("Alpha", 1));
var controller = Controller(service);
@@ -129,16 +315,47 @@ public sealed class LegacyManualFinancialOperatorControllerTests
Assert.Single(opened.ManualFinancial!.Rows).ResultId);
Assert.Equal(0, service.MutationCalls);
var deleted = await controller.DeleteManualFinancialAsync(all: false);
var deleteRequested = await controller.DeleteManualFinancialAsync(all: false);
Assert.Equal("선택한 종목을 삭제하겠습니까?", deleteRequested.Dialog?.Message);
Assert.Equal("MmoneyCoder", deleteRequested.Dialog?.Caption);
Assert.True(deleteRequested.Dialog?.IsConfirmation);
Assert.Equal(0, service.MutationCalls);
var canceled = controller.CancelDialog();
Assert.Null(canceled.Dialog);
Assert.Equal(0, service.MutationCalls);
Assert.Single(canceled.ManualFinancial!.Rows);
await controller.DeleteManualFinancialAsync(all: false);
var deleted = await controller.ConfirmDialogAsync();
Assert.Equal(1, service.MutationCalls);
Assert.Empty(deleted.ManualFinancial!.Rows);
var all = await controller.DeleteManualFinancialAsync(all: true);
var allRequested = await controller.DeleteManualFinancialAsync(all: true);
Assert.Equal("모든 종목을 삭제하겠습니까?", allRequested.Dialog?.Message);
Assert.True(allRequested.Dialog?.IsConfirmation);
Assert.Equal(1, service.MutationCalls);
var all = await controller.ConfirmDialogAsync();
Assert.Equal(2, service.MutationCalls);
Assert.Empty(all.ManualFinancial!.Rows);
}
private static LegacyOperatorController Controller(FakeManualService service)
[Fact]
public async Task Delete_without_a_GraphE_row_selection_remains_a_silent_no_op()
{
var service = new FakeManualService(Sales("Alpha", 1));
var controller = Controller(service);
await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.Sales);
var result = await controller.DeleteManualFinancialAsync(all: false);
Assert.Null(result.Dialog);
Assert.Equal(0, service.MutationCalls);
Assert.Single(result.ManualFinancial!.Rows);
}
private static LegacyOperatorController Controller(IManualFinancialScreenService service)
{
var workflow = new LegacyManualFinancialWorkflow(
service,
@@ -214,6 +431,13 @@ public sealed class LegacyManualFinancialOperatorControllerTests
internal int GetCalls { get; private set; }
internal ManualFinancialMutationException? MutationException { get; init; }
internal int RejectedItemCount { get; init; }
internal IReadOnlyList<string> UnreadableStockNames { get; init; } =
Array.Empty<string>();
internal Func<ManualFinancialIdentity, ManualFinancialSnapshot>? GetOverride
{
get;
@@ -239,7 +463,11 @@ public sealed class LegacyManualFinancialOperatorControllerTests
query,
DateTimeOffset.UtcNow,
rows,
false));
false)
{
RejectedItemCount = RejectedItemCount,
UnreadableStockNames = UnreadableStockNames
});
}
public Task<ManualFinancialSnapshot> GetAsync(
@@ -257,6 +485,11 @@ public sealed class LegacyManualFinancialOperatorControllerTests
CancellationToken cancellationToken = default)
{
MutationCalls++;
if (MutationException is not null)
{
throw MutationException;
}
_rows[(record.Identity.Screen, record.Identity.StockName)] =
new ManualFinancialSnapshot(record, Version(++_version));
return Task.FromResult(Receipt(
@@ -272,6 +505,11 @@ public sealed class LegacyManualFinancialOperatorControllerTests
CancellationToken cancellationToken = default)
{
MutationCalls++;
if (MutationException is not null)
{
throw MutationException;
}
_rows[(replacement.Identity.Screen, replacement.Identity.StockName)] =
new ManualFinancialSnapshot(replacement, Version(++_version));
return Task.FromResult(Receipt(
@@ -327,4 +565,74 @@ public sealed class LegacyManualFinancialOperatorControllerTests
DateTimeOffset.UtcNow,
affectedRows);
}
private sealed class RawGraphEQueryExecutor : IDataQueryExecutor
{
private readonly DataTable _rows = CreateRows();
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new NotSupportedException();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!string.Equals(
tableName,
"MANUAL_FINANCIAL_SELL_RAW_GET",
StringComparison.Ordinal))
{
return Task.FromResult(_rows.Copy());
}
var stockName = Assert.IsType<string>(query.Parameters.Single(
parameter => string.Equals(
parameter.Name,
"stock_name",
StringComparison.OrdinalIgnoreCase)).Value);
var result = _rows.Clone();
var row = Assert.Single(_rows.AsEnumerable().Where(candidate =>
string.Equals(
candidate.Field<string>("STOCK_NAME"),
stockName,
StringComparison.Ordinal)));
result.ImportRow(row);
return Task.FromResult(result);
}
private static DataTable CreateRows()
{
var table = new DataTable();
foreach (var column in ManualFinancialCutContracts
.Get(ManualFinancialScreenKind.Sales)
.StorageColumns)
{
table.Columns.Add(column, typeof(string));
}
table.Columns.Add("ROW_VERSION", typeof(string));
table.Columns.Add("ROW_HANDLE", typeof(string));
table.Rows.Add(
"Alpha",
"1Q_10", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60",
Version(1),
"AAAAAAAAAAAAAAAA01");
table.Rows.Add(
"Broken",
"1Q_not-a-number", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60",
Version(2),
"AAAAAAAAAAAAAAAA02");
return table;
}
}
}

View File

@@ -1,9 +1,57 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyManualFinancialWorkflowTests
{
[Fact]
public async Task GraphE_stock_picker_uses_the_complete_finite_core_boundary()
{
var service = new FakeManualFinancialService();
var stockSearch = new FakeStockSearchService();
var workflow = new LegacyManualFinancialWorkflow(
service,
stockSearch,
new LegacyManualFinancialWriteSafetyState());
await workflow.SearchStocksAsync(
workflow.CreateInitial(ManualFinancialScreenKind.Sales),
"Alpha");
Assert.Equal(
LegacyStockSearchService.MaximumResults,
LegacyManualFinancialWorkflow.MaximumStockResults);
Assert.Equal(
LegacyStockSearchService.MaximumResults,
stockSearch.LastMaximumResults);
}
[Fact]
public async Task Empty_GraphE_stock_search_is_the_original_quiet_no_op()
{
var stockSearch = new FakeStockSearchService(
new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
"Alpha",
"000001"));
var workflow = new LegacyManualFinancialWorkflow(
new FakeManualFinancialService(),
stockSearch,
new LegacyManualFinancialWriteSafetyState());
var initial = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
var unchanged = await workflow.SearchStocksAsync(initial, " ");
Assert.Same(initial, unchanged);
Assert.Equal(0, stockSearch.LastMaximumResults);
Assert.Empty(unchanged.StockCandidates);
Assert.Null(unchanged.LastMessage);
Assert.Same(unchanged, workflow.SelectStock(unchanged, string.Empty));
}
public static TheoryData<
ManualFinancialScreenKind,
string,
@@ -93,6 +141,9 @@ public sealed class LegacyManualFinancialWorkflowTests
Assert.Equal(LegacyManualFinancialWorkflow.MaximumListResults, service.LastMaximumResults);
var row = Assert.Single(state.Rows);
Assert.StartsWith("mf-row-", row.ResultId, StringComparison.Ordinal);
Assert.Equal(
["Alpha", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"],
row.PreviewValues);
Assert.Null(state.SelectedRecord);
Assert.False(state.IsDetailFresh);
@@ -106,6 +157,89 @@ public sealed class LegacyManualFinancialWorkflowTests
Assert.Equal(Version(2), Assert.Single(state.Rows).RowVersion);
}
[Fact]
public async Task Search_surfaces_isolated_legacy_row_count_without_hiding_valid_rows()
{
var valid = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1);
var service = new FakeManualFinancialService(valid)
{
SearchOverride = (screen, query) => new ManualFinancialSearchResult(
screen,
query,
DateTimeOffset.UtcNow,
[valid],
false)
{
RejectedItemCount = 3,
UnreadableStockNames = ["Broken", "BadVersion", "Legacy"]
}
};
var workflow = Workflow(service);
var state = await workflow.SearchAsync(
workflow.CreateInitial(ManualFinancialScreenKind.Sales));
Assert.Equal(4, state.Rows.Count);
Assert.Single(state.Rows, row => row.IsDataReadable);
Assert.Equal(3, state.Rows.Count(row => !row.IsDataReadable));
Assert.Equal(3, state.ListRejectedItemCount);
Assert.Contains("3행", state.LastMessage, StringComparison.Ordinal);
}
[Fact]
public async Task Raw_GraphE_row_can_edit_delete_and_schedule_while_playout_stays_unready()
{
var table = RawSalesTable("Broken", "1Q_not-a-number");
var service = new LegacyManualFinancialScreenService(
new StaticQueryExecutor(table),
new NoCallMutationExecutor());
var workflow = new LegacyManualFinancialWorkflow(
service,
new FakeStockSearchService(
new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
"Broken",
"000001")),
new LegacyManualFinancialWriteSafetyState());
var state = await workflow.SearchAsync(
workflow.CreateInitial(ManualFinancialScreenKind.Sales));
var row = Assert.Single(state.Rows);
Assert.True(row.IsDataReadable);
Assert.False(row.IsPlayoutReady);
Assert.Equal(
["Broken", "1Q_not-a-number", "2Q_20", "3Q_30", "4Q_40", "5Q_50", "6Q_60"],
row.PreviewValues);
Assert.Throws<NotSupportedException>(() =>
((IList<string>)row.PreviewValues).Add("ROWID"));
state = await workflow.LoadAsync(state, row.ResultId);
Assert.True(state.IsRawDetailFresh);
Assert.False(state.IsDetailFresh);
Assert.False(state.IsPlayoutReady);
Assert.NotNull(state.SelectedRawRecord);
Assert.Null(state.SelectedRecord);
state = await workflow.SearchStocksAsync(state, "Broken");
state = workflow.SelectStock(state, Assert.Single(state.StockCandidates).ResultId);
Assert.True(state.CanEdit);
Assert.True(state.CanSave);
Assert.True(state.CanDelete);
Assert.True(state.CanMaterializePlaylist);
var replacement = new ManualFinancialRawRecord(
state.SelectedRawRecord!.Identity,
state.SelectedRawRecord.StorageValues.ToArray());
Assert.IsType<LegacyManualFinancialRawUpdateRequest>(
workflow.CreateRawUpdateRequest(state, replacement));
Assert.IsType<LegacyManualFinancialRawDeleteRequest>(
workflow.CreateDeleteRequest(state));
var draft = workflow.MaterializePlaylistDraft(state, "sales");
Assert.Null(draft.OperatorSnapshot);
Assert.NotNull(draft.OperatorRawSnapshot);
Assert.Equal("Broken", draft.CutSelection.Selection.Subject);
}
[Fact]
public async Task GraphE_name_header_and_directional_find_are_CSharp_authoritative()
{
@@ -193,7 +327,7 @@ public sealed class LegacyManualFinancialWorkflowTests
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(state.SelectedRowVersion, draft.OperatorSnapshot!.RowVersion);
Assert.Equal("KOSPI", draft.Market);
Assert.Equal("000001", draft.StockCode);
Assert.False(draft.IsNxt);
@@ -220,7 +354,7 @@ public sealed class LegacyManualFinancialWorkflowTests
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(Version(11), draft.OperatorSnapshot!.RowVersion);
Assert.Equal(1, service.MutationCalls);
Assert.Equal(2, service.GetCalls);
}
@@ -525,7 +659,7 @@ public sealed class LegacyManualFinancialWorkflowTests
}
[Fact]
public async Task Invalid_or_ambiguous_list_envelopes_are_rejected()
public async Task Duplicate_list_names_remain_visible_but_invalid_unreadable_identity_is_rejected()
{
var duplicate = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1);
var service = new FakeManualFinancialService
@@ -539,6 +673,21 @@ public sealed class LegacyManualFinancialWorkflowTests
};
var workflow = Workflow(service);
var duplicates = await workflow.SearchAsync(
workflow.CreateInitial(ManualFinancialScreenKind.Sales));
Assert.Equal(2, duplicates.Rows.Count);
service.SearchOverride = (_, query) => new ManualFinancialSearchResult(
ManualFinancialScreenKind.Sales,
query,
DateTimeOffset.UtcNow,
[],
false)
{
RejectedItemCount = 1,
UnreadableStockNames = [" invalid "]
};
await Assert.ThrowsAsync<LegacyManualFinancialWorkflowDataException>(() =>
workflow.SearchAsync(workflow.CreateInitial(ManualFinancialScreenKind.Sales)));
}
@@ -677,6 +826,65 @@ public sealed class LegacyManualFinancialWorkflowTests
};
}
private static DataTable RawSalesTable(string stockName, string firstValue)
{
var table = new DataTable();
foreach (var column in ManualFinancialCutContracts.Get(
ManualFinancialScreenKind.Sales).StorageColumns)
{
table.Columns.Add(column, typeof(string));
}
table.Columns.Add("ROW_VERSION", typeof(string));
table.Columns.Add("ROW_HANDLE", typeof(string));
table.Rows.Add(
stockName,
firstValue,
"2Q_20",
"3Q_30",
"4Q_40",
"5Q_50",
"6Q_60",
Version(1),
"AAAAAAAAAAAAAAAAAA");
return table;
}
private sealed class StaticQueryExecutor : IDataQueryExecutor
{
private readonly DataTable _table;
internal StaticQueryExecutor(DataTable table) => _table = table;
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
throw new NotSupportedException();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_table.Copy());
}
}
private sealed class NoCallMutationExecutor : IManualFinancialMutationExecutor
{
public Task<ManualFinancialDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
ManualFinancialDbTransaction transaction,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("The test must not issue a real mutation.");
}
private sealed class FakeStockSearchService : IStockSearchService
{
private readonly IReadOnlyList<StockSearchItem> _items;
@@ -686,12 +894,15 @@ public sealed class LegacyManualFinancialWorkflowTests
_items = items;
}
internal int LastMaximumResults { get; private set; }
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
LastMaximumResults = maximumResults;
return Task.FromResult(new StockSearchResult(
query,
DateTimeOffset.UtcNow,

View File

@@ -1,3 +1,4 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
@@ -97,6 +98,90 @@ public sealed class LegacyManualListsWorkflowTests
Assert.DoesNotContain("005930", draft.StockName, StringComparison.Ordinal);
}
[Fact]
public async Task VI_delete_all_requires_MmoneyCoder_confirmation_before_changing_the_draft()
{
var store = new FakeStore
{
Vi = new LegacyManualViStoreSnapshot(
[new LegacyManualViStoredItem("P005930", "삼성전자")],
new string('a', 64))
};
var lookup = new FakeLookup();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
await controller.OpenManualViAsync();
var requested = controller.DeleteAllManualViItems();
Assert.Equal("모두 삭제하겠습니까?", requested.Dialog?.Message);
Assert.Equal("MmoneyCoder", requested.Dialog?.Caption);
Assert.True(requested.Dialog?.IsConfirmation);
Assert.Single(requested.ManualLists!.ViItems);
var canceled = controller.CancelDialog();
Assert.Null(canceled.Dialog);
Assert.Single(canceled.ManualLists!.ViItems);
controller.DeleteAllManualViItems();
var confirmed = await controller.ConfirmDialogAsync();
Assert.Null(confirmed.Dialog);
Assert.Empty(confirmed.ManualLists!.ViItems);
}
[Fact]
public async Task VI_blank_search_loads_the_complete_lookup_generation()
{
var store = new FakeStore();
var lookup = new FakeLookup
{
DisplayNames = ["삼성전자", "카카오"],
MasterCatalog =
[
new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"삼성전자",
"005930"),
new LegacyStockIdentity(
LegacyDomesticStockMarket.Kosdaq,
"카카오",
"035720")
]
};
var workflow = new LegacyManualListsWorkflow(store, lookup);
await workflow.OpenViAsync();
var searched = await workflow.SearchViAsync(" ");
Assert.Equal(string.Empty, lookup.LastQuery);
Assert.Equal(2, searched.SearchResults.Count);
Assert.False(searched.SearchIsTruncated);
Assert.Equal(LegacyOperatorStatusKind.Information, searched.StatusKind);
}
[Fact]
public async Task VI_search_keeps_a_finite_ten_thousand_row_ui_boundary_and_warns()
{
var store = new FakeStore();
var lookup = new FakeLookup
{
DisplayNames = Enumerable.Repeat(
"삼성전자",
LegacyManualListsWorkflow.MaximumSearchResults + 1).ToArray()
};
var workflow = new LegacyManualListsWorkflow(store, lookup);
await workflow.OpenViAsync();
var searched = await workflow.SearchViAsync(string.Empty);
Assert.Equal(10_000, searched.SearchResults.Count);
Assert.True(searched.SearchIsTruncated);
Assert.Equal(LegacyOperatorStatusKind.Warning, searched.StatusKind);
Assert.Contains("10,000", searched.StatusMessage, StringComparison.Ordinal);
}
[Fact]
public async Task VI_stale_preflight_never_writes_or_retries()
{
@@ -295,7 +380,7 @@ public sealed class LegacyManualListsWorkflowTests
Assert.Equal(section.Actions.Count, completed.Playlist.Count);
Assert.Equal(
section.Actions.Select(action => action.Label),
completed.Playlist.Select(row => row.StockName));
completed.Playlist.Select(row => row.RawCutLabel));
Assert.Equal(
new[] { "INDIVIDUAL", "FOREIGN", "INSTITUTION" },
completed.Playlist.TakeLast(3).Select(row => row.PlayoutSelection!.GroupCode));
@@ -359,9 +444,11 @@ public sealed class LegacyManualListsWorkflowTests
{
var store = new FakeStore();
var lookup = new FakeLookup();
var pagePlans = new FiveRowPagePlanProvider();
var controller = new LegacyOperatorController(
lookup,
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup));
manualListsWorkflow: new LegacyManualListsWorkflow(store, lookup),
fixedPagePlanProvider: pagePlans);
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var section = LegacyFixedActionCatalog.Default
.GetSections(LegacyFixedMarket.Index)[7];
@@ -377,12 +464,16 @@ public sealed class LegacyManualListsWorkflowTests
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));
completed.Playlist.Take(manualIndex).Select(row => row.RawCutLabel));
Assert.Equal(
section.Actions.Skip(manualIndex + 1).Select(action => action.Label),
completed.Playlist.Skip(manualIndex + 1).Select(row => row.StockName));
completed.Playlist.Skip(manualIndex + 1).Select(row => row.RawCutLabel));
Assert.Equal("5074", completed.Playlist[manualIndex].SceneAlias);
Assert.Equal("PAGED_VI", completed.Playlist[manualIndex].PlayoutSelection!.GroupCode);
Assert.All(
completed.Playlist.Where((_, index) => index != manualIndex),
row => Assert.Equal("1/3", row.PageText));
Assert.Equal(section.Actions.Count - 1, pagePlans.CallCount);
Assert.Equal(1, store.WriteViCalls);
}
@@ -414,18 +505,51 @@ public sealed class LegacyManualListsWorkflowTests
Assert.Empty(switchedTab.Playlist);
}
private sealed class FiveRowPagePlanProvider : ILegacyScenePagePlanProvider
{
public int CallCount { get; private set; }
public Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
CallCount++;
Assert.Equal("5074", entry.CutCode);
return Task.FromResult(new LegacyScenePagePlan(
"s5074",
11,
ScenePageSize.Five,
3,
11,
false));
}
}
private sealed class FakeLookup : ILegacyStockLookup
{
public IReadOnlyList<string> DisplayNames { get; init; } = ["삼성전자"];
public IReadOnlyList<LegacyStockIdentity> MasterCatalog { get; init; } =
[
new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"삼성전자",
"005930")
];
public string? LastQuery { get; private set; }
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
CancellationToken cancellationToken = default)
{
LastQuery = query;
return Task.FromResult(new LegacyStockSearchData(
query,
["삼성전자"],
[new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"삼성전자",
"005930")]));
DisplayNames,
MasterCatalog));
}
}
private sealed class FakeStore : ILegacyManualListStore

View File

@@ -1,3 +1,4 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
@@ -77,7 +78,10 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
string actionId)
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var controller = CreateController(
persistence,
new FixedTimeProvider(
new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.FromHours(9))));
var action = LegacyFixedActionCatalog.Default.GetAction(actionId);
controller.SelectTab(action.Market switch
{
@@ -102,6 +106,106 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.Equal(before.Selection, after.Selection);
}
[Fact]
public async Task SaveFixed8035PersistsOriginalIndexSevenFields()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
controller.SelectTab(LegacyOperatorTabId.Index);
controller.ActivateFixedAction("fixed-136");
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
controller.SelectNamedPlaylist(definitionId);
var saved = await controller.SaveCurrentNamedPlaylistAsync();
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.CommittedFresh,
saved.NamedPlaylist?.LastMutationOutcome);
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.True(stored.IsEnabled);
Assert.Equal("코스피", stored.Selection.GroupCode);
Assert.Equal("지수", stored.Selection.Subject);
Assert.Equal("캔들그래프", stored.Selection.GraphicType);
Assert.Equal("일봉", stored.Selection.Subtype);
Assert.Equal(string.Empty, stored.Selection.DataCode);
Assert.Equal(new NamedPlaylistPageState(1, 1), stored.Page);
}
[Fact]
public async Task SaveFixedPagedActionPersistsInitialDatabasePageNBeforePlayout()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(
persistence,
fixedPagePlanProvider: new FixedSixRowPagePlanProvider());
controller.SelectTab(LegacyOperatorTabId.Index);
var action = LegacyFixedActionCatalog.Default.Actions
.First(candidate => candidate.Available && candidate.Code == "5077");
var added = await controller.ActivateFixedActionAsync(action.Id);
Assert.Equal("1/3", Assert.Single(added.Playlist).PageText);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
await controller.SaveCurrentNamedPlaylistAsync();
Assert.Equal(
new NamedPlaylistPageState(1, 3),
Assert.Single(persistence.LastReplaceItems!).Page);
}
[Theory]
[InlineData("코스피", "지수", "캔들그래프", "일봉")]
[InlineData("주요지수", "캔들그래프_일봉", "코스피", "코스피")]
public async Task LoadFixed8035RestoresOriginalAndCurrentStoredShapes(
string groupCode,
string subject,
string graphicType,
string subtype)
{
var persistence = new OperatorNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
string.Empty),
new NamedPlaylistPageState(1, 1))
]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var loaded = await controller.LoadNamedPlaylistByIdAsync(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
Assert.True(loaded.CommandResult?.Succeeded);
Assert.Null(loaded.Dialog);
var row = Assert.Single(loaded.Playlist);
Assert.True(row.IsSceneResolved);
Assert.Equal(groupCode, row.MarketText);
Assert.Equal(subject, row.StockName);
Assert.Equal(graphicType, row.GraphicType);
Assert.Equal(subtype, row.Subtype);
Assert.Equal("1/1", row.PageText);
Assert.Equal(string.Empty, row.StockCode);
Assert.Equal("8035", row.SceneAlias);
Assert.Equal(
new LegacySceneSelection(
"KOSPI_INDEX",
"INDEX",
string.Empty,
"PRICE",
string.Empty),
row.PlayoutSelection);
}
[Fact]
public async Task LoadSelected_ReplacesPlaylistWithTypedOriginalSevenFields()
{
@@ -141,7 +245,7 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
}
[Fact]
public async Task LoadSelected_UnknownSceneKeepsCurrentPlaylistAtomically()
public async Task LoadSelected_UnknownSceneLoadsRawRowAndDefersOnlyThatRowsPlayoutCheck()
{
var persistence = new OperatorNamedPlaylistPersistence(
[
@@ -167,12 +271,55 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
var after = await controller.LoadNamedPlaylistByIdAsync(definitionId);
Assert.Equal(before.RowId, Assert.Single(after.Playlist).RowId);
Assert.NotNull(after.Dialog);
Assert.Contains("1행", after.Dialog.Message, StringComparison.Ordinal);
Assert.Equal(LegacyOperatorStatusKind.Error, after.StatusKind);
var row = Assert.Single(after.Playlist);
Assert.NotEqual(before.RowId, row.RowId);
Assert.Equal("UNKNOWN-GROUP", row.MarketText);
Assert.Equal("알 수 없음", row.StockName);
Assert.Equal("UNKNOWN-GRAPHIC", row.GraphicType);
Assert.Equal("UNKNOWN-SUBTYPE", row.Subtype);
Assert.Equal("UNKNOWN-DATA", row.StockCode);
Assert.Equal(LegacyPlaylistEntry.UnresolvedCutCode, row.SceneAlias);
Assert.False(row.IsSceneResolved);
Assert.Null(after.Dialog);
Assert.Equal(LegacyOperatorStatusKind.Warning, after.StatusKind);
Assert.Equal("load-named-playlist-by-id", after.CommandResult?.Command);
Assert.False(after.CommandResult?.Succeeded);
Assert.True(after.CommandResult?.Succeeded);
Assert.Equal(
LegacyPlaylistEntry.UnresolvedCutCode,
Assert.Single(controller.CreatePlayoutRequest().Playlist).CutCode);
}
[Fact]
public async Task LoadSelected_RestoresOriginalFixedIndexPairFirstRow()
{
var persistence = new OperatorNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
"지수",
"코스피 지수,코스닥 지수",
"2열판",
string.Empty,
string.Empty),
new NamedPlaylistPageState(1, 1))
]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var loaded = await controller.LoadNamedPlaylistByIdAsync(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
var row = Assert.Single(loaded.Playlist);
Assert.True(loaded.CommandResult?.Succeeded);
Assert.Null(loaded.Dialog);
Assert.True(row.IsSceneResolved);
Assert.Equal("8032", row.SceneAlias);
Assert.Equal("INDEX", row.PlayoutSelection?.GroupCode);
Assert.Equal("Kospi,Kosdaq", row.PlayoutSelection?.Subject);
Assert.Equal("2열판", row.PlayoutSelection?.GraphicType);
Assert.Equal("CURRENT", row.PlayoutSelection?.Subtype);
}
[Theory]
@@ -488,6 +635,59 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
saved.NamedPlaylist?.LastMutationOutcome);
}
[Theory]
[InlineData(ThemeMarket.Krx, " Edge Theme ", " Edge Theme ")]
[InlineData(ThemeMarket.Nxt, "Robotics(NXT)", "Robotics(NXT)(NXT)")]
[InlineData(ThemeMarket.Nxt, "Robotics(NXT)(NXT)", "Robotics(NXT)(NXT)(NXT)")]
[InlineData(ThemeMarket.Nxt, " Edge Theme ", " Edge Theme (NXT)")]
public async Task Native_theme_schedule_save_fresh_load_retains_exact_database_title(
ThemeMarket market,
string exactTitle,
string expectedStoredSubject)
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var identity = new ThemeSelectionIdentity(
market,
market == ThemeMarket.Nxt
? ThemeSession.PreMarket
: ThemeSession.NotApplicable,
"00000002",
exactTitle);
var time = new DateTimeOffset(2026, 7, 21, 10, 0, 0, TimeSpan.FromHours(9));
var controller = new LegacyOperatorController(
new UnusedLookup(),
timeProvider: new FixedTimeProvider(time),
themeWorkflow: new LegacyThemeWorkflow(
new ExactThemeService(identity),
() => time),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
var theme = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
await controller.SelectThemeAsync(Assert.Single(theme.Theme!.SearchResults).ResultId);
controller.ActivateThemeAction("five-row-current");
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId;
controller.SelectNamedPlaylist(definitionId);
await controller.SaveCurrentNamedPlaylistAsync();
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.Equal(expectedStoredSubject, stored.Selection.Subject);
Assert.Equal(market == ThemeMarket.Nxt ? "테마_NXT" : "테마", stored.Selection.GroupCode);
Assert.Equal("00000002", stored.Selection.DataCode);
var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId);
var runtimeSelection = Assert.Single(
controller.CreatePlayoutRequest().Playlist).Selection!;
Assert.True(loaded.CommandResult?.Succeeded);
Assert.Equal(
market == ThemeMarket.Nxt ? "PAGED_NXT_THEME" : "PAGED_THEME",
runtimeSelection.GroupCode);
Assert.Equal(expectedStoredSubject, runtimeSelection.Subject);
Assert.Equal(exactTitle, runtimeSelection.ExactSubject);
Assert.Equal("00000002", runtimeSelection.DataCode);
}
[Fact]
public async Task NamedPlaylistIdentityDoesNotChangeWhenMovingAveragesChange()
{
@@ -546,6 +746,30 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command);
Assert.Equal(definitionId, saved.CommandResult?.TargetId);
Assert.True(saved.CommandResult?.Succeeded);
Assert.Null(saved.Dialog);
}
[Fact]
public async Task Confirm_button_save_projects_the_original_saved_MessageBox()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
var saved = await controller.SaveCurrentNamedPlaylistToAsync(
definitionId,
showSavedAlert: true);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.True(saved.CommandResult?.Succeeded);
Assert.Equal("저장되었습니다", saved.Dialog?.Message);
Assert.Equal("MmoneyCoder", saved.Dialog?.Caption);
Assert.False(saved.Dialog?.IsConfirmation);
var dismissed = controller.DismissDialog();
Assert.Null(dismissed.Dialog);
}
[Fact]
@@ -696,10 +920,32 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
}
private static LegacyOperatorController CreateController(
INamedPlaylistPersistenceService persistence) =>
INamedPlaylistPersistenceService persistence,
TimeProvider? timeProvider = null,
ILegacyScenePagePlanProvider? fixedPagePlanProvider = null) =>
new(
new UnusedLookup(),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
timeProvider: timeProvider,
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence),
fixedPagePlanProvider: fixedPagePlanProvider);
private sealed class FixedSixRowPagePlanProvider : ILegacyScenePagePlanProvider
{
public Task<LegacyScenePagePlan> CreatePagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Assert.Equal("5077", entry.CutCode);
return Task.FromResult(new LegacyScenePagePlan(
"s5077",
17,
ScenePageSize.Six,
3,
17,
false));
}
}
private sealed class UnusedLookup : ILegacyStockLookup
{
@@ -709,6 +955,63 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class FixedTimeProvider(DateTimeOffset localNow) : TimeProvider
{
private static readonly TimeZoneInfo KoreaStandardTime = TimeZoneInfo.CreateCustomTimeZone(
"NamedThemeFixedKst",
TimeSpan.FromHours(9),
"Named theme fixed KST",
"Named theme fixed KST");
public override DateTimeOffset GetUtcNow() => localNow.ToUniversalTime();
public override TimeZoneInfo LocalTimeZone => KoreaStandardTime;
}
private sealed class ExactThemeService : IThemeSelectionService
{
private readonly ThemeSelectionIdentity _identity;
public ExactThemeService(ThemeSelectionIdentity identity)
{
_identity = identity;
}
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var identity = _identity.Market == ThemeMarket.Nxt
? _identity with { Session = nxtSession }
: _identity;
return Task.FromResult(new ThemeSearchResult(
query,
nxtSession,
DateTimeOffset.UtcNow,
[new ThemeSelectorItem(
identity,
identity.Market == ThemeMarket.Krx
? DataSourceKind.Oracle
: DataSourceKind.MariaDb)],
false));
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ThemePreviewResult(
identity,
DateTimeOffset.UtcNow,
[new ThemeItemPreview(
0,
identity.Market == ThemeMarket.Krx ? "P005930" : "X005930",
"Samsung Electronics")],
false));
}
private sealed class OperatorNamedPlaylistPersistence : INamedPlaylistPersistenceService
{
private const string ProgramCode = "00000001";

View File

@@ -4,6 +4,93 @@ namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorCatalogWorkflowTests
{
[Theory]
[InlineData(LegacyOperatorCatalogEntity.Theme)]
[InlineData(LegacyOperatorCatalogEntity.Expert)]
public async Task Blank_stock_picker_search_restores_bounded_ThemeA_and_UC6_show_all(
LegacyOperatorCatalogEntity entity)
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
if (entity == LegacyOperatorCatalogEntity.Theme)
{
await workflow.OpenAsync(entity);
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
}
else
{
var opened = await workflow.OpenAsync(entity);
await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
}
var searched = await workflow.SearchStocksAsync(" ");
Assert.Equal(string.Empty, searched.StockQuery);
Assert.Single(searched.StockResults);
Assert.Equal(1, fixture.StockSearch.SearchAllCalls);
Assert.Equal(0, fixture.StockSearch.SearchCalls);
Assert.Equal(
LegacyStockSearchService.MaximumResults,
fixture.StockSearch.LastMaximumResults);
}
[Theory]
[InlineData("bad\0value")]
[InlineData("bad\nvalue")]
[InlineData("bad\u200Bvalue")]
public async Task Stock_picker_rejects_unsafe_blank_boundary_input_before_search(string query)
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
await Assert.ThrowsAsync<ArgumentException>(() => workflow.SearchStocksAsync(query));
Assert.Equal(0, fixture.StockSearch.SearchAllCalls);
Assert.Equal(0, fixture.StockSearch.SearchCalls);
}
[Fact]
public async Task Stock_picker_rejects_overlong_input_before_search()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
await Assert.ThrowsAsync<ArgumentException>(() => workflow.SearchStocksAsync(
new string('A', LegacyStockSearchService.MaximumQueryLength + 1)));
Assert.Equal(0, fixture.StockSearch.SearchAllCalls);
Assert.Equal(0, fixture.StockSearch.SearchCalls);
}
[Fact]
public async Task ThemeA_and_stock_picker_use_the_complete_finite_core_boundaries()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
await workflow.SearchStocksAsync("삼성");
Assert.Equal(
LegacyThemeSelectionService.MaximumResults,
LegacyOperatorCatalogWorkflowController.MaximumCatalogResults);
Assert.Equal(
LegacyThemeSelectionService.MaximumResults,
fixture.ThemeSelection.LastMaximumResults);
Assert.Equal(
LegacyStockSearchService.MaximumResults,
LegacyOperatorCatalogWorkflowController.MaximumStockResults);
Assert.Equal(
LegacyStockSearchService.MaximumResults,
fixture.StockSearch.LastMaximumResults);
}
[Fact]
public async Task ThemeA_create_uses_opaque_stock_selection_and_writes_only_on_explicit_save()
{
@@ -38,6 +125,120 @@ public sealed class LegacyOperatorCatalogWorkflowTests
Assert.False(saved.WritesQuarantined);
}
[Fact]
public async Task ThemeA_create_market_change_preserves_name_and_search_but_removes_incompatible_rows()
{
var fixture = new Fixture();
fixture.ThemePersistence.KrxNextCode = "00000009";
fixture.ThemePersistence.NxtNextCode = "00000027";
fixture.StockSearch.Items =
[
new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
"삼성전자",
"005930"),
new StockSearchItem(
StockMarket.NxtKospi,
DataSourceKind.MariaDb,
"삼성전자(NXT)",
"005930")
];
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
var stocks = await workflow.SearchStocksAsync("삼성");
var krxStock = Assert.Single(stocks.StockResults, static row => row.IsCompatible);
workflow.SelectStock(krxStock.ResultId);
var withDraft = workflow.AddSelectedStock(null);
var removedRowId = Assert.Single(withDraft.DraftRows).RowId;
await workflow.CheckThemeNameAvailabilityAsync("사용자 입력 테마");
var changed = await workflow.ChangeCreateThemeMarketAsync(
ThemeMarket.Nxt,
"사용자 입력 테마");
Assert.Equal(LegacyOperatorCatalogMode.Create, changed.Mode);
Assert.Equal(ThemeMarket.Nxt, changed.NewThemeMarket);
Assert.Equal("00000027", changed.CodeLabel);
Assert.Equal("사용자 입력 테마", changed.EditorName);
Assert.Empty(changed.DraftRows);
Assert.Null(changed.ActiveDraftRowId);
Assert.DoesNotContain(changed.DraftRows, row => row.RowId == removedRowId);
Assert.Equal("삼성", changed.StockQuery);
Assert.Equal(2, changed.StockResults.Count);
Assert.Single(changed.StockResults, static row => row.IsCompatible);
Assert.Null(changed.SelectedStockResultId);
Assert.Equal(LegacyThemeNameAvailability.NotChecked, changed.ThemeNameAvailability);
Assert.Equal(string.Empty, changed.CheckedThemeName);
Assert.Equal(LegacyOperatorCatalogStatusKind.Warning, changed.StatusKind);
Assert.Contains("1개", changed.StatusMessage, StringComparison.Ordinal);
Assert.Equal(ThemeMarket.Nxt, fixture.ThemePersistence.LastSuggestedMarket);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
}
[Fact]
public async Task ThemeA_create_market_change_failure_preserves_the_correlated_editor_atomically()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
var stocks = await workflow.SearchStocksAsync("삼성");
var stockId = Assert.Single(stocks.StockResults).ResultId;
workflow.SelectStock(stockId);
workflow.AddSelectedStock(null);
await workflow.CheckThemeNameAvailabilityAsync("기존 초안명");
var before = workflow.Current;
fixture.ThemePersistence.SuggestNextCodeException =
new InvalidOperationException("offline");
var failed = await workflow.ChangeCreateThemeMarketAsync(
ThemeMarket.Nxt,
"기존 초안명");
Assert.Equal(before.NewThemeMarket, failed.NewThemeMarket);
Assert.Equal(before.CodeLabel, failed.CodeLabel);
Assert.Equal(before.EditorName, failed.EditorName);
Assert.Equal(
before.DraftRows.Select(static row => row.RowId),
failed.DraftRows.Select(static row => row.RowId));
Assert.Equal(before.ActiveDraftRowId, failed.ActiveDraftRowId);
Assert.Equal(before.StockQuery, failed.StockQuery);
Assert.Equal(before.SelectedStockResultId, failed.SelectedStockResultId);
Assert.Equal(before.ThemeNameAvailability, failed.ThemeNameAvailability);
Assert.Equal(before.CheckedThemeName, failed.CheckedThemeName);
Assert.Equal(LegacyOperatorCatalogStatusKind.Error, failed.StatusKind);
Assert.Contains("변경하지 않았습니다", failed.StatusMessage, StringComparison.Ordinal);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
}
[Fact]
public async Task ThemeA_market_change_is_rejected_in_edit_mode_without_reading_a_new_code()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
var suggestCalls = fixture.ThemePersistence.SuggestNextCodeCalls;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
workflow.ChangeCreateThemeMarketAsync(
ThemeMarket.Nxt,
editing.EditorName));
var after = workflow.Current;
Assert.Equal(suggestCalls, fixture.ThemePersistence.SuggestNextCodeCalls);
Assert.Equal(editing.Revision, after.Revision);
Assert.Equal(editing.Mode, after.Mode);
Assert.Equal(editing.NewThemeMarket, after.NewThemeMarket);
Assert.Equal(editing.CodeLabel, after.CodeLabel);
Assert.Equal(editing.EditorName, after.EditorName);
Assert.Equal(
editing.DraftRows.Select(static row => row.RowId),
after.DraftRows.Select(static row => row.RowId));
}
[Fact]
public async Task ThemeA_existing_row_requires_fresh_complete_preview_for_edit_and_delete()
{
@@ -50,7 +251,8 @@ public sealed class LegacyOperatorCatalogWorkflowTests
Assert.Equal(LegacyOperatorCatalogMode.Edit, selected.Mode);
Assert.Equal("AI 반도체", selected.EditorName);
Assert.Single(selected.DraftRows);
Assert.Equal(1, fixture.ThemeSelection.PreviewCalls);
Assert.Equal(1, fixture.ThemePersistence.ReadStoredCalls);
Assert.Equal(0, fixture.ThemeSelection.PreviewCalls);
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
var deleted = await workflow.DeleteAsync();
@@ -59,6 +261,96 @@ public sealed class LegacyOperatorCatalogWorkflowTests
Assert.Equal(LegacyOperatorCatalogMode.List, deleted.Mode);
}
[Fact]
public async Task ThemeA_edit_preserves_every_stored_row_without_revalidating_inactive_rows()
{
var fixture = new Fixture();
fixture.ThemePersistence.StoredItems =
[
new ThemeCatalogItem(0, "P005930", "Active"),
new ThemeCatalogItem(7, "X999998", "Cross X"),
new ThemeCatalogItem(7, "T999999", "Cross T")
];
var workflow = fixture.CreateWorkflow();
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
Assert.Equal(
["Active", "Cross X", "Cross T"],
editing.DraftRows.Select(static row => row.DisplayName));
var saved = await workflow.SaveAsync(editing.EditorName);
Assert.Equal(LegacyOperatorCatalogMode.List, saved.Mode);
Assert.Equal(1, fixture.ThemePersistence.ReplaceCalls);
Assert.Equal(
["P005930", "X999998", "T999999"],
fixture.ThemePersistence.LastReplacedItems.Select(static item => item.ItemCode));
Assert.Equal(
[0, 1, 2],
fixture.ThemePersistence.LastReplacedItems.Select(static item => item.InputIndex));
Assert.Equal(0, fixture.IdentityValidation.ThemeCalls);
Assert.Equal(0, fixture.ThemeSelection.PreviewCalls);
}
[Fact]
public async Task ThemeA_edit_revalidates_only_rows_added_from_the_current_stock_picker()
{
var fixture = new Fixture();
fixture.ThemePersistence.StoredItems =
[
new ThemeCatalogItem(0, "P999999", "Inactive legacy")
];
var workflow = fixture.CreateWorkflow();
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
var stocks = await workflow.SearchStocksAsync("stock");
workflow.SelectStock(Assert.Single(stocks.StockResults).ResultId);
workflow.AddSelectedStock(null);
await workflow.SaveAsync(editing.EditorName);
Assert.Equal(1, fixture.IdentityValidation.ThemeCalls);
var validated = Assert.Single(fixture.IdentityValidation.LastThemeItems);
Assert.Equal(0, validated.InputIndex);
Assert.Equal("P005930", validated.ItemCode);
Assert.Equal(
["P999999", "P005930"],
fixture.ThemePersistence.LastReplacedItems.Select(static item => item.ItemCode));
}
[Fact]
public async Task ThemeA_nxt_editor_uses_parent_market_for_display_but_saves_raw_cross_prefix_name()
{
var fixture = new Fixture();
var nxt = ThemeItem(ThemeMarket.Nxt, "NXT theme");
fixture.ThemeSelection.SearchItems = [nxt];
fixture.ThemePersistence.StoredItems =
[new ThemeCatalogItem(2, "P005930", "Legacy KRX")];
var workflow = fixture.CreateWorkflow();
var editing = await workflow.OpenThemeForEditAsync(nxt.Identity);
Assert.Equal("Legacy KRX(NXT)", Assert.Single(editing.DraftRows).DisplayName);
await workflow.SaveAsync(editing.EditorName);
Assert.Equal("Legacy KRX", Assert.Single(fixture.ThemePersistence.LastReplacedItems).ItemName);
Assert.Equal(0, fixture.IdentityValidation.ThemeCalls);
}
[Fact]
public async Task ThemeA_edit_preserves_an_unchanged_exact_edge_space_title()
{
var fixture = new Fixture();
var item = ThemeItem(ThemeMarket.Krx, " Edge title ");
fixture.ThemeSelection.SearchItems = [item];
var workflow = fixture.CreateWorkflow();
var editing = await workflow.OpenThemeForEditAsync(item.Identity);
await workflow.SaveAsync(editing.EditorName);
Assert.Equal(" Edge title ", fixture.ThemePersistence.LastReplacedName);
}
[Theory]
[InlineData(ThemeMarket.Krx)]
[InlineData(ThemeMarket.Nxt)]
@@ -249,6 +541,31 @@ public sealed class LegacyOperatorCatalogWorkflowTests
duplicate.DraftRows.Select(row => row.RowId));
}
[Fact]
public async Task EList_edit_preserves_null_buy_amount_until_explicit_save_validation()
{
var fixture = new Fixture();
fixture.ExpertSelection.PreviewItems =
[
new ExpertRecommendationPreview(0, "000001", "First", null),
new ExpertRecommendationPreview(1, "000002", "Second", 20_000m)
];
var workflow = fixture.CreateWorkflow();
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
Assert.Equal(LegacyOperatorCatalogMode.Edit, editing.Mode);
Assert.Equal(2, editing.DraftRows.Count);
Assert.Null(editing.DraftRows[0].BuyAmount);
Assert.Equal(20_000m, editing.DraftRows[1].BuyAmount);
var blocked = await workflow.SaveAsync(string.Empty);
Assert.Equal(LegacyOperatorCatalogMode.Edit, blocked.Mode);
Assert.Equal(0, fixture.ExpertPersistence.ReplaceCalls);
}
[Fact]
public async Task Uc6_edit_save_with_zero_recommendations_is_a_completely_silent_no_op()
{
@@ -582,6 +899,8 @@ public sealed class LegacyOperatorCatalogWorkflowTests
{
internal int SearchCalls { get; private set; }
internal int LastMaximumResults { get; private set; }
internal int PreviewCalls { get; private set; }
internal IReadOnlyList<ThemeSelectorItem> SearchItems { get; set; } =
@@ -596,6 +915,7 @@ public sealed class LegacyOperatorCatalogWorkflowTests
CancellationToken cancellationToken = default)
{
SearchCalls++;
LastMaximumResults = maximumResults;
return Task.FromResult(new ThemeSearchResult(
query,
nxtSession,
@@ -650,12 +970,23 @@ public sealed class LegacyOperatorCatalogWorkflowTests
private sealed class FakeThemePersistence : IThemeCatalogPersistenceService
{
internal int ReadStoredCalls { get; private set; }
internal int CreateCalls { get; private set; }
internal int DeleteCalls { get; private set; }
internal ThemeCatalogDefinition? LastCreated { get; private set; }
internal int ReplaceCalls { get; private set; }
internal IReadOnlyList<ThemeCatalogItem> LastReplacedItems { get; private set; } = [];
internal string? LastReplacedName { get; private set; }
internal IReadOnlyList<ThemeCatalogItem> StoredItems { get; set; } =
[new ThemeCatalogItem(4, "P005930", "삼성전자")];
internal Exception? CreateException { get; set; }
internal int NameExistsCalls { get; private set; }
@@ -670,8 +1001,30 @@ public sealed class LegacyOperatorCatalogWorkflowTests
internal string? LastCheckedName { get; private set; }
internal int SuggestNextCodeCalls { get; private set; }
internal ThemeMarket? LastSuggestedMarket { get; private set; }
internal string KrxNextCode { get; set; } = "00000009";
internal string NxtNextCode { get; set; } = "00000009";
internal Exception? SuggestNextCodeException { get; set; }
public bool CanMutate => true;
public Task<ThemeCatalogStoredResult> ReadStoredAsync(
ThemeSelectionIdentity identity,
CancellationToken cancellationToken = default)
{
ReadStoredCalls++;
return Task.FromResult(new ThemeCatalogStoredResult(
identity,
DateTimeOffset.UtcNow,
StoredItems,
false));
}
public Task<bool> ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
@@ -691,8 +1044,14 @@ public sealed class LegacyOperatorCatalogWorkflowTests
public Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default) =>
Task.FromResult("00000009");
CancellationToken cancellationToken = default)
{
SuggestNextCodeCalls++;
LastSuggestedMarket = market;
return SuggestNextCodeException is null
? Task.FromResult(market == ThemeMarket.Krx ? KrxNextCode : NxtNextCode)
: Task.FromException<string>(SuggestNextCodeException);
}
public Task<OperatorCatalogMutationReceipt> CreateAsync(
ThemeCatalogDefinition definition,
@@ -709,8 +1068,13 @@ public sealed class LegacyOperatorCatalogWorkflowTests
ThemeSelectionIdentity expectedIdentity,
string newTitle,
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default) =>
Receipt(OperatorCatalogMutationKind.ReplaceTheme);
CancellationToken cancellationToken = default)
{
ReplaceCalls++;
LastReplacedItems = items;
LastReplacedName = newTitle;
return Receipt(OperatorCatalogMutationKind.ReplaceTheme);
}
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
ThemeSelectionIdentity expectedIdentity,
@@ -772,26 +1136,51 @@ public sealed class LegacyOperatorCatalogWorkflowTests
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class FakeStockSearch : IStockSearchService
private sealed class FakeStockSearch : IOperatorCatalogStockSearchService
{
internal IReadOnlyList<StockSearchItem> Items { get; set; } =
[new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "삼성전자", "005930")];
internal int LastMaximumResults { get; private set; }
internal int SearchCalls { get; private set; }
internal int SearchAllCalls { get; private set; }
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new StockSearchResult(
CancellationToken cancellationToken = default)
{
SearchCalls++;
LastMaximumResults = maximumResults;
return Task.FromResult(new StockSearchResult(
query,
DateTimeOffset.UtcNow,
Items,
false));
}
public Task<StockSearchResult> SearchAllAsync(
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
SearchAllCalls++;
LastMaximumResults = maximumResults;
return Task.FromResult(new StockSearchResult(
string.Empty,
DateTimeOffset.UtcNow,
Items,
false));
}
}
private sealed class FakeIdentityValidation : IStockMasterIdentityValidationService
{
internal int ThemeCalls { get; private set; }
internal IReadOnlyList<ThemeCatalogItem> LastThemeItems { get; private set; } = [];
internal int ExpertCalls { get; private set; }
public Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
@@ -799,6 +1188,7 @@ public sealed class LegacyOperatorCatalogWorkflowTests
CancellationToken cancellationToken = default)
{
ThemeCalls++;
LastThemeItems = items;
return Task.FromResult<IReadOnlyList<StockMasterIdentity>>([]);
}

View File

@@ -13,6 +13,27 @@ public sealed class LegacyTradingHaltWorkflowTests
private static readonly TradingHaltSelection KosdaqAlpha =
new(TradingHaltMarket.Kosdaq, "035720", "다온테크");
[Fact]
public async Task Search_AutomaticallyActivatesFirstFarPointRowAndActions()
{
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, KospiAlpha, KospiBeta, KosdaqAlpha))
};
var controller = new LegacyTradingHaltWorkflowController(service);
var searched = await controller.SearchAsync(string.Empty);
var current = controller.MaterializeSelectedAction(
LegacyTradingHaltWorkflowController.CurrentActionId);
Assert.Equal(searched.Results[0].SelectionId, searched.Selected?.SelectionId);
Assert.True(searched.Results[0].IsSelected);
Assert.All(searched.Actions, action => Assert.True(action.IsAvailable));
Assert.Equal("005930", current.StockCode);
Assert.Equal("가온전자", current.DisplayName);
}
[Fact]
public async Task SearchSelect_MaterializesClosedCurrentAndCandleDraftsFromActionIdOnly()
{