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