feat: complete legacy UI and playout parity migration

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

View File

@@ -0,0 +1,510 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyComparisonWorkflowTests
{
private static readonly LegacyComparisonTarget Samsung =
LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kospi,
"삼성전자",
"005930");
private static readonly LegacyComparisonTarget SkHynix =
LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kospi,
"SK하이닉스",
"000660");
private static readonly LegacyComparisonTarget NxtSamsung =
LegacyComparisonTarget.CreateNxtStock(
LegacyComparisonDomesticMarket.Kospi,
"삼성전자",
"005930");
private static readonly LegacyComparisonTarget Apple =
LegacyComparisonTarget.CreateWorldStock("APPLE", "AAPL", "US");
[Fact]
public void InitialSnapshot_ClosesFixedTargetsAndRuntimeActions()
{
var controller = CreateController();
var snapshot = controller.Current;
Assert.Equal(24, snapshot.FixedTargets.Count);
Assert.Equal(
new[]
{
"kospi", "kosdaq", "kospi200", "futures", "krx100",
"won-dollar", "won-yen", "won-yuan", "won-euro", "dow",
"nasdaq", "sp500", "germany-dax", "united-kingdom-ftse",
"france-cac", "nikkei", "hang-seng", "taiwan-weighted",
"singapore-straits-times", "thailand-set",
"philippines-composite", "malaysia-klse",
"indonesia-composite", "shanghai-composite"
},
snapshot.FixedTargets.Select(target => target.SelectionId));
Assert.Equal(
new[]
{
"two-column-expected",
"two-column-current",
"comparison-candle",
"comparison-line",
"return-5d",
"return-1m",
"return-3m",
"return-6m",
"return-12m"
},
snapshot.Actions.Select(action => action.ActionId));
Assert.All(snapshot.Actions, action => Assert.False(action.IsAvailable));
}
[Fact]
public async Task SearchesAndSelection_ReproduceUc3RotateThenSwapRule()
{
var data = new FakeDataService
{
DomesticResults = [Samsung, NxtSamsung],
WorldResults = [Apple]
};
var controller = CreateController(data);
var domestic = await controller.SearchDomesticAsync(" 삼성 ");
Assert.Equal("삼성", data.DomesticQuery);
Assert.Equal(200, data.DomesticMaximumResults);
Assert.Equal(
new[] { "삼성전자", "삼성전자(NXT)" },
domestic.DomesticSearch.Results.Select(result => result.DisplayName));
var selectedDomestic = controller.SelectDomesticResult(
domestic.DomesticSearch.Results[0].SelectionId);
Assert.Equal(
domestic.DomesticSearch.Results[0].SelectionId,
selectedDomestic.DomesticSearch.SelectedResultId);
Assert.Null(selectedDomestic.FirstTarget);
Assert.Null(selectedDomestic.SecondTarget);
var first = controller.ChooseDomesticResult(
domestic.DomesticSearch.Results[0].SelectionId);
Assert.Equal("삼성전자", first.FirstTarget?.DisplayName);
Assert.Null(first.SecondTarget);
var second = controller.ChooseDomesticResult(
domestic.DomesticSearch.Results[1].SelectionId);
Assert.Equal("삼성전자(NXT)", second.FirstTarget?.DisplayName);
Assert.Equal("삼성전자", second.SecondTarget?.DisplayName);
var world = await controller.SearchWorldAsync(string.Empty);
Assert.Equal(string.Empty, data.WorldQuery);
Assert.Equal(100, data.WorldMaximumResults);
var selectedWorld = controller.SelectWorldResult(
world.WorldSearch.Results[0].SelectionId);
Assert.Equal(
world.WorldSearch.Results[0].SelectionId,
selectedWorld.WorldSearch.SelectedResultId);
Assert.Equal(second.FirstTarget?.DisplayName, selectedWorld.FirstTarget?.DisplayName);
var third = controller.ChooseWorldResult(world.WorldSearch.Results[0].SelectionId);
Assert.Equal("APPLE", third.FirstTarget?.DisplayName);
Assert.Equal("삼성전자(NXT)", third.SecondTarget?.DisplayName);
var selectedFixed = controller.SelectFixedTarget("kospi");
Assert.Equal("kospi", selectedFixed.SelectedFixedTargetId);
Assert.Equal(third.FirstTarget?.DisplayName, selectedFixed.FirstTarget?.DisplayName);
var fixedTarget = controller.ChooseFixedTarget("kospi");
Assert.Equal("코스피 지수", fixedTarget.FirstTarget?.DisplayName);
Assert.Equal("APPLE", fixedTarget.SecondTarget?.DisplayName);
var swapped = controller.SwapDraftTargets();
Assert.Equal("APPLE", swapped.FirstTarget?.DisplayName);
Assert.Equal("코스피 지수", swapped.SecondTarget?.DisplayName);
}
[Fact]
public async Task Search_RejectsDuplicateOrWrongUniverseResults()
{
var duplicateData = new FakeDataService
{
DomesticResults = [Samsung, Samsung]
};
var duplicateController = CreateController(duplicateData);
await Assert.ThrowsAsync<InvalidOperationException>(
() => duplicateController.SearchDomesticAsync("삼성"));
Assert.Equal(
LegacyComparisonSearchStatus.Error,
duplicateController.Current.DomesticSearch.Status);
Assert.Empty(duplicateController.Current.DomesticSearch.Results);
var wrongUniverse = new FakeDataService
{
WorldResults = [Samsung]
};
var wrongController = CreateController(wrongUniverse);
await Assert.ThrowsAsync<InvalidOperationException>(
() => wrongController.SearchWorldAsync("삼성"));
Assert.Equal(
LegacyComparisonSearchStatus.Error,
wrongController.Current.WorldSearch.Status);
}
[Fact]
public async Task LoadSavedPairs_ParsesStrictCp949AndDisplaysResolvedRows()
{
var data = new FakeDataService();
data.Resolve(Samsung, "삼성전자", "코스피");
data.Resolve(SkHynix, "SK하이닉스", "코스피");
data.Resolve(Apple, "APPLE", string.Empty);
var store = new FakePairStore
{
Bytes = EncodeCp949(
("삼성전자", "코스피", "SK하이닉스", "코스피"),
("코스피 지수", string.Empty, "APPLE", string.Empty))
};
var controller = CreateController(data, store);
var snapshot = await controller.LoadSavedPairsAsync();
Assert.False(snapshot.IsBusy);
Assert.Equal(2, snapshot.SavedPairs.Count);
Assert.Equal("삼성전자 ↔ SK하이닉스", snapshot.SavedPairs[0].DisplayLabel);
Assert.Equal("코스피 지수 ↔ APPLE", snapshot.SavedPairs[1].DisplayLabel);
Assert.True(snapshot.SavedPairs[0].IsSelected);
Assert.Equal("comparison-pair-00000001", snapshot.SelectedPairId);
Assert.Empty(store.Writes);
Assert.Equal(3, data.ResolveRequests.Count);
}
[Fact]
public async Task LoadSavedPairs_RejectsNonCp949NineFieldShapeWithoutChangingRows()
{
var store = new FakePairStore
{
Bytes = Encoding.UTF8.GetBytes("1^삼성전자,SK하이닉스^코스피^코스피^\n")
};
var controller = CreateController(pairStore: store);
await Assert.ThrowsAnyAsync<Exception>(() => controller.LoadSavedPairsAsync());
Assert.Empty(controller.Current.SavedPairs);
Assert.False(controller.Current.IsBusy);
Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
}
[Fact]
public async Task AddDraftPair_WritesOnlyThroughTypedStoreBoundary()
{
var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
var store = new FakePairStore();
var controller = CreateController(data, store);
var search = await controller.SearchDomesticAsync(string.Empty);
controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);
var snapshot = await controller.AddDraftPairAsync();
var write = Assert.Single(store.Writes);
var row = Assert.Single(write);
Assert.Equal(
new LegacyComparisonPairWriteRow(
1,
"SK하이닉스",
"코스피",
"삼성전자",
"코스피"),
row);
Assert.Equal("SK하이닉스 ↔ 삼성전자", Assert.Single(snapshot.SavedPairs).DisplayLabel);
Assert.False(snapshot.IsBusy);
}
[Fact]
public async Task SavedPairMoveAndDelete_PreserveActiveRowAndPersistPhysicalOrder()
{
var data = new FakeDataService();
data.Resolve(Samsung, "삼성전자", "코스피");
data.Resolve(SkHynix, "SK하이닉스", "코스피");
data.Resolve(NxtSamsung, "삼성전자(NXT)", "코스피_NXT");
var store = new FakePairStore
{
Bytes = EncodeCp949(
("삼성전자", "코스피", "SK하이닉스", "코스피"),
("삼성전자(NXT)", "코스피_NXT", "삼성전자", "코스피"))
};
var controller = CreateController(data, store);
var loaded = await controller.LoadSavedPairsAsync();
controller.SelectSavedPair(loaded.SavedPairs[1].RowId);
var moved = await controller.MoveSelectedPairAsync(
LegacyComparisonPairMoveDirection.Up);
Assert.Equal(
new[] { "삼성전자(NXT) ↔ 삼성전자", "삼성전자 ↔ SK하이닉스" },
moved.SavedPairs.Select(pair => pair.DisplayLabel));
Assert.True(moved.SavedPairs[0].IsSelected);
Assert.Equal("삼성전자(NXT)", store.Writes[^1][0].FirstName);
var writesAtBoundary = store.Writes.Count;
await controller.MoveSelectedPairAsync(LegacyComparisonPairMoveDirection.Up);
Assert.Equal(writesAtBoundary, store.Writes.Count);
var deleted = await controller.DeleteSelectedPairAsync();
Assert.Equal("삼성전자 ↔ SK하이닉스", Assert.Single(deleted.SavedPairs).DisplayLabel);
Assert.True(deleted.SavedPairs[0].IsSelected);
var empty = await controller.DeleteAllPairsAsync();
Assert.Empty(empty.SavedPairs);
Assert.Null(empty.SelectedPairId);
Assert.Empty(store.Writes[^1]);
}
[Fact]
public async Task FailedWrite_RollsBackAuthoritativePairState()
{
var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
var store = new FakePairStore { ThrowOnWrite = true };
var controller = CreateController(data, store);
var search = await controller.SearchDomesticAsync("삼성");
controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);
await Assert.ThrowsAsync<IOException>(() => controller.AddDraftPairAsync());
Assert.Empty(controller.Current.SavedPairs);
Assert.False(controller.Current.IsBusy);
Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
}
[Fact]
public async Task MaterializeSelectedAction_MapsAllNineClosedActionsFromActionIdOnly()
{
var controller = await CreateLoadedKrxPairControllerAsync();
Assert.Equal(9, controller.Current.Actions.Count);
Assert.All(controller.Current.Actions, action => Assert.True(action.IsAvailable));
var method = typeof(LegacyComparisonWorkflowController).GetMethod(
nameof(LegacyComparisonWorkflowController.MaterializeSelectedAction));
Assert.NotNull(method);
Assert.Collection(
method.GetParameters(),
parameter => Assert.Equal("actionId", parameter.Name));
var drafts = controller.Current.Actions.ToDictionary(
action => action.ActionId,
action => controller.MaterializeSelectedAction(action.ActionId),
StringComparer.Ordinal);
Assert.Equal(9, drafts.Count);
Assert.Equal(("s8018", "8032", "2열판", "EXPECTED", ""), Fields(drafts["two-column-expected"]));
Assert.Equal(("s8018", "8032", "2열판", "CURRENT", ""), Fields(drafts["two-column-current"]));
Assert.Equal(("s5026", "5026", "COMPARISON_CANDLE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-candle"]));
Assert.Equal(("s5087", "5087", "COMPARISON_LINE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-line"]));
Assert.Equal("FiveDays", drafts["return-5d"].Selection.Subtype);
Assert.Equal("OneMonth", drafts["return-1m"].Selection.Subtype);
Assert.Equal("ThreeMonths", drafts["return-3m"].Selection.Subtype);
Assert.Equal("SixMonths", drafts["return-6m"].Selection.Subtype);
Assert.Equal("TwelveMonths", drafts["return-12m"].Selection.Subtype);
Assert.All(drafts.Where(pair => pair.Key.StartsWith("return-", StringComparison.Ordinal)), pair =>
{
Assert.Equal("s5029", pair.Value.BuilderKey);
Assert.Equal("5029", pair.Value.CutCode);
Assert.Equal("RETURN_COMPARISON", pair.Value.Selection.GraphicType);
});
Assert.All(drafts.Values, draft =>
{
Assert.Equal("삼성전자,SK하이닉스", draft.Selection.Subject);
Assert.Equal("legacy-comparison-workflow", draft.Source);
Assert.Equal(6, draft.FadeDuration);
Assert.Equal(new[] { draft.CutCode }, draft.Aliases);
Assert.Equal(draft.DraftId, draft.ToLegacyPlaylistEntry().EntryId);
});
Assert.Throws<ArgumentOutOfRangeException>(
() => controller.MaterializeSelectedAction("caller-builder-override"));
}
[Fact]
public async Task FuturesExpectedAction_UsesClosedCurrentS5032CompatibilityBranch()
{
var store = new FakePairStore
{
Bytes = EncodeCp949(
("선물 지수", string.Empty, "코스피 지수", string.Empty))
};
var controller = CreateController(pairStore: store);
await controller.LoadSavedPairsAsync();
var expected = controller.MaterializeSelectedAction("two-column-expected");
Assert.Equal("s5032", expected.BuilderKey);
Assert.Equal("5032", expected.CutCode);
Assert.Equal("EXPECTED", expected.Selection.Subtype);
Assert.Equal("CURRENT", expected.EffectiveMode);
Assert.NotNull(expected.Warning);
Assert.False(controller.Current.Actions.Single(
action => action.ActionId == "comparison-candle").IsAvailable);
Assert.Throws<InvalidOperationException>(
() => controller.MaterializeSelectedAction("comparison-candle"));
}
[Fact]
public async Task Snapshots_RemainImmutableAfterLaterStateChanges()
{
var data = new FakeDataService { DomesticResults = [Samsung] };
var controller = CreateController(data);
var before = controller.Current;
var after = await controller.SearchDomesticAsync("삼성");
Assert.Empty(before.DomesticSearch.Results);
Assert.Single(after.DomesticSearch.Results);
var fixedCollection = Assert.IsAssignableFrom<ICollection<LegacyComparisonTargetView>>(
before.FixedTargets);
Assert.Throws<NotSupportedException>(() => fixedCollection.Add(before.FixedTargets[0]));
var actionCollection = Assert.IsAssignableFrom<ICollection<LegacyComparisonActionView>>(
before.Actions);
Assert.Throws<NotSupportedException>(() => actionCollection.Clear());
}
private static LegacyComparisonWorkflowController CreateController(
FakeDataService? dataService = null,
FakePairStore? pairStore = null) =>
new(dataService ?? new FakeDataService(), pairStore ?? new FakePairStore());
private static async Task<LegacyComparisonWorkflowController>
CreateLoadedKrxPairControllerAsync()
{
var data = new FakeDataService();
data.Resolve(Samsung, "삼성전자", "코스피");
data.Resolve(SkHynix, "SK하이닉스", "코스피");
var store = new FakePairStore
{
Bytes = EncodeCp949(
("삼성전자", "코스피", "SK하이닉스", "코스피"))
};
var controller = CreateController(data, store);
await controller.LoadSavedPairsAsync();
return controller;
}
private static (
string BuilderKey,
string CutCode,
string GraphicType,
string Subtype,
string DataCode) Fields(LegacyGenericPlaylistDraft draft) =>
(
draft.BuilderKey,
draft.CutCode,
draft.Selection.GraphicType,
draft.Selection.Subtype,
draft.Selection.DataCode);
private static byte[] EncodeCp949(
params (string First, string FirstMarket, string Second, string SecondMarket)[] rows)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var encoding = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
var lines = rows.Select((row, index) => string.Join(
'^',
(index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture),
$"{row.First},{row.Second}",
row.FirstMarket,
row.SecondMarket,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty));
return encoding.GetBytes(string.Join("\r\n", lines) + "\r\n");
}
private sealed class FakeDataService : ILegacyComparisonDataService
{
private readonly Dictionary<LegacyComparisonStoredTargetReference, LegacyComparisonTarget>
_resolved = [];
public IReadOnlyList<LegacyComparisonTarget> DomesticResults { get; init; } = [];
public IReadOnlyList<LegacyComparisonTarget> WorldResults { get; init; } = [];
public string? DomesticQuery { get; private set; }
public string? WorldQuery { get; private set; }
public int DomesticMaximumResults { get; private set; }
public int WorldMaximumResults { get; private set; }
public List<LegacyComparisonStoredTargetReference> ResolveRequests { get; } = [];
public void Resolve(LegacyComparisonTarget target, string name, string market) =>
_resolved[new LegacyComparisonStoredTargetReference(name, market)] = target;
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
DomesticQuery = query;
DomesticMaximumResults = maximumResults;
return Task.FromResult(DomesticResults);
}
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
WorldQuery = query;
WorldMaximumResults = maximumResults;
return Task.FromResult(WorldResults);
}
public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference target,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ResolveRequests.Add(target);
_resolved.TryGetValue(target, out var resolved);
return Task.FromResult(resolved);
}
}
private sealed class FakePairStore : ILegacyComparisonPairStore
{
public byte[]? Bytes { get; init; }
public bool ThrowOnWrite { get; init; }
public List<IReadOnlyList<LegacyComparisonPairWriteRow>> Writes { get; } = [];
public Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(Bytes?.ToArray());
}
public Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (ThrowOnWrite)
{
throw new IOException("Synthetic comparison store failure.");
}
Writes.Add(Array.AsReadOnly(rows.ToArray()));
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,205 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorComparisonIntegrationTests
{
[Fact]
public async Task ComparisonTabBuildsPairAndAddsAuthoritativeSceneRow()
{
var data = new StaticComparisonDataService();
var store = new MemoryComparisonPairStore();
var controller = new LegacyOperatorController(
new EmptyStockLookup(),
comparisonWorkflow: new LegacyComparisonWorkflowController(data, store));
var selected = await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
Assert.NotNull(selected.Comparison);
Assert.Empty(selected.Comparison.SavedPairs);
var searched = await controller.SearchComparisonDomesticAsync("반도체");
var results = searched.Comparison!.DomesticSearch.Results;
controller.ChooseComparisonDomesticResult(results[0].SelectionId);
controller.ChooseComparisonDomesticResult(results[1].SelectionId);
var saved = await controller.AddComparisonPairAsync();
Assert.Single(saved.Comparison!.SavedPairs);
Assert.Single(store.Writes);
var added = controller.ActivateComparisonAction("comparison-candle");
var row = Assert.Single(added.Playlist);
Assert.Equal("5026", row.ToPlayoutEntry().CutCode);
Assert.Equal(
"KOSPI:000660|KOSPI:005930",
Assert.IsType<LegacySceneSelection>(row.ToPlayoutEntry().Selection).DataCode);
Assert.Equal("비교", row.MarketText);
}
[Fact]
public async Task ComparisonParentDoubleClickAddsOnlyDirectChildrenInOriginalOrder()
{
var controller = new LegacyOperatorController(
new EmptyStockLookup(),
comparisonWorkflow: new LegacyComparisonWorkflowController(
new StaticComparisonDataService(),
new MemoryComparisonPairStore()));
await controller.SelectTabAsync(LegacyOperatorTabId.Comparison);
var searched = await controller.SearchComparisonDomesticAsync("반도체");
controller.ChooseComparisonDomesticResult(
searched.Comparison!.DomesticSearch.Results[0].SelectionId);
controller.ChooseComparisonDomesticResult(
searched.Comparison.DomesticSearch.Results[1].SelectionId);
await controller.AddComparisonPairAsync();
var added = controller.ActivateComparisonSection(1);
Assert.Collection(
added.Playlist,
row => Assert.Equal("5026", row.SceneAlias),
row => Assert.Equal("5087", row.SceneAlias));
}
private sealed class EmptyStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) => Task.FromResult(
new LegacyStockSearchData(query, [], []));
}
private sealed class StaticComparisonDataService : ILegacyComparisonDataService
{
private static readonly IReadOnlyList<LegacyComparisonTarget> Domestic =
[
LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kospi,
"삼성전자",
"005930"),
LegacyComparisonTarget.CreateKrxStock(
LegacyComparisonDomesticMarket.Kospi,
"SK하이닉스",
"000660")
];
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default) => Task.FromResult(Domestic);
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default) => Task.FromResult<
IReadOnlyList<LegacyComparisonTarget>>([]);
public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference target,
CancellationToken cancellationToken = default) => Task.FromResult<
LegacyComparisonTarget?>(null);
}
private sealed class MemoryComparisonPairStore : ILegacyComparisonPairStore
{
public List<IReadOnlyList<LegacyComparisonPairWriteRow>> Writes { get; } = [];
public Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default) =>
Task.FromResult<byte[]?>(null);
public Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
CancellationToken cancellationToken = default)
{
Writes.Add(rows.ToArray());
return Task.CompletedTask;
}
}
}
public sealed class LegacyComparisonProductionAdapterTests
{
[Fact]
public async Task DataAdapterMapsDomesticNxtAndWorldIdentities()
{
var lookup = new StaticStockLookup();
var world = new StaticWorldLookup();
var service = new LegacyComparisonDataService(lookup, world);
var domestic = await service.SearchDomesticAsync("삼성", 10);
Assert.Equal(LegacyComparisonTargetKind.KrxStock, domestic[0].Kind);
Assert.Equal(LegacyComparisonTargetKind.NxtStock, domestic[1].Kind);
Assert.Equal("삼성전자(NXT)", domestic[1].DisplayName);
var overseas = Assert.Single(await service.SearchWorldAsync("APPLE", 10));
Assert.Equal("world:US:AAPL", overseas.TargetId);
var resolved = await service.ResolveStoredTargetAsync(
new LegacyComparisonStoredTargetReference("APPLE", string.Empty));
Assert.Equal(overseas, resolved);
}
[Fact]
public async Task FileStoreRoundTripsStrictCp949WithAtomicTarget()
{
var directory = Path.Combine(
Path.GetTempPath(),
$"mbn-comparison-{Guid.NewGuid():N}");
var path = Path.Combine(directory, "종목비교.dat");
try
{
var store = new LegacyComparisonPairFileStore(path);
await store.WriteCp949Async(
[
new LegacyComparisonPairWriteRow(
1,
"삼성전자",
"코스피",
"SK하이닉스",
"코스피")
]);
var bytes = Assert.IsType<byte[]>(await store.ReadCp949Async());
var parsed = Assert.Single(LegacyComparisonFileParser.Parse(bytes));
Assert.Equal("삼성전자", parsed.First.Name);
Assert.Empty(Directory.EnumerateFiles(directory, "*.tmp"));
}
finally
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}
private sealed class StaticStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) => Task.FromResult(
new LegacyStockSearchData(
query,
["삼성전자", "삼성전자(NXT)"],
[
new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"삼성전자",
"005930"),
new LegacyStockIdentity(
LegacyDomesticStockMarket.NxtKospi,
"삼성전자(NXT)",
"005930")
]));
}
private sealed class StaticWorldLookup : IWorldStockSearchService
{
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));
}
}

View File

@@ -0,0 +1,288 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyExpertWorkflowTests
{
private static readonly ExpertSelectionIdentity Alpha = new("0001", "강전문");
private static readonly ExpertSelectionIdentity Beta = new("0002", "김전문");
[Fact]
public async Task SearchSelectPreview_MaterializesClosedS5074DraftFromActionIdOnly()
{
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha, Beta)),
Preview = (identity, _, _) => Task.FromResult(
PreviewResult(identity, Recommendations(6)))
};
var controller = new LegacyExpertWorkflowController(service);
var search = await controller.SearchAsync(" 전문 ");
var preview = await controller.SelectExpertAsync(search.Search.Results[1].SelectionId);
var draft = controller.MaterializeSelectedAction("five-row-current");
Assert.Equal("전문", service.SearchQuery);
Assert.Equal(500, service.SearchMaximum);
Assert.Equal(100, service.PreviewMaximum);
Assert.Equal("김전문", preview.SelectedExpert?.ExpertName);
Assert.Equal(6, preview.Preview.Items.Count);
Assert.Equal(2, preview.Preview.PageCount);
Assert.True(Assert.Single(preview.Actions).IsAvailable);
Assert.Equal("s5074", draft.BuilderKey);
Assert.Equal("5074", draft.CutCode);
Assert.Equal(new[] { "5074" }, draft.Aliases);
Assert.Equal("PAGED_EXPERT", draft.Selection.GroupCode);
Assert.Equal("김전문", draft.Selection.Subject);
Assert.Equal("INPUT_ORDER", draft.Selection.GraphicType);
Assert.Equal("CURRENT", draft.Selection.Subtype);
Assert.Equal("0002", draft.Selection.DataCode);
Assert.Equal(5, draft.PageSize);
Assert.Equal(2, draft.PageCount);
Assert.Equal(20, draft.MaximumPageCount);
Assert.Equal("1/2", draft.PageText);
Assert.Equal(draft.DraftId, draft.ToLegacyPlaylistEntry().EntryId);
Assert.Equal("legacy-expert-workflow", draft.Source);
var method = typeof(LegacyExpertWorkflowController).GetMethod(
nameof(LegacyExpertWorkflowController.MaterializeSelectedAction));
Assert.NotNull(method);
Assert.Collection(
method.GetParameters(),
parameter => Assert.Equal("actionId", parameter.Name));
Assert.Throws<ArgumentOutOfRangeException>(
() => controller.MaterializeSelectedAction("5074-caller-override"));
}
[Theory]
[InlineData(1, false, 1)]
[InlineData(5, false, 1)]
[InlineData(6, false, 2)]
[InlineData(100, true, 20)]
public async Task PageN_UsesFiveRowsAndNeverExceedsTwentyPages(
int itemCount,
bool truncated,
int expectedPageCount)
{
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha)),
Preview = (identity, _, _) => Task.FromResult(
PreviewResult(identity, Recommendations(itemCount), truncated))
};
var controller = new LegacyExpertWorkflowController(service);
var search = await controller.SearchAsync(string.Empty);
var selected = await controller.SelectExpertAsync(
search.Search.Results[0].SelectionId);
var draft = controller.MaterializeSelectedAction(
LegacyExpertWorkflowController.ActionId);
Assert.Equal(expectedPageCount, selected.Preview.PageCount);
Assert.Equal(expectedPageCount, draft.PageCount);
Assert.InRange(draft.PageCount, 1, 20);
Assert.Equal(truncated, draft.PreviewTruncated);
}
[Fact]
public async Task EmptyRecommendationPreview_RemainsVisibleButCannotCreateCut()
{
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha)),
Preview = (identity, _, _) => Task.FromResult(
PreviewResult(identity, []))
};
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.Empty(selected.Preview.Items);
Assert.Equal(0, selected.Preview.PageCount);
Assert.False(Assert.Single(selected.Actions).IsAvailable);
Assert.Throws<InvalidOperationException>(
() => controller.MaterializeSelectedAction(
LegacyExpertWorkflowController.ActionId));
}
[Fact]
public async Task LateSearchResponse_CannotReplaceNewerExpertResult()
{
var first = new TaskCompletionSource<ExpertSearchResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
var second = new TaskCompletionSource<ExpertSearchResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
var service = new DelegateExpertService
{
Search = (query, _, _) => query == "첫째" ? first.Task : second.Task
};
var controller = new LegacyExpertWorkflowController(service);
var firstRequest = controller.SearchAsync("첫째");
var secondRequest = controller.SearchAsync("둘째");
second.SetResult(SearchResult("둘째", Beta));
var newest = await secondRequest;
first.SetResult(SearchResult("첫째", Alpha));
var staleCompletion = await firstRequest;
Assert.Equal("둘째", newest.Search.Query);
Assert.Equal("김전문", Assert.Single(newest.Search.Results).ExpertName);
Assert.Equal("둘째", staleCompletion.Search.Query);
Assert.Equal("김전문", Assert.Single(staleCompletion.Search.Results).ExpertName);
Assert.Equal("둘째", controller.Current.Search.Query);
}
[Fact]
public async Task LatePreviewResponse_CannotReplaceNewerExpertSelection()
{
var alphaPreview = new TaskCompletionSource<ExpertPreviewResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
var betaPreview = new TaskCompletionSource<ExpertPreviewResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha, Beta)),
Preview = (identity, _, _) => identity == Alpha
? alphaPreview.Task
: betaPreview.Task
};
var controller = new LegacyExpertWorkflowController(service);
var search = await controller.SearchAsync(string.Empty);
var firstSelection = controller.SelectExpertAsync(search.Search.Results[0].SelectionId);
var secondSelection = controller.SelectExpertAsync(search.Search.Results[1].SelectionId);
betaPreview.SetResult(PreviewResult(Beta, Recommendations(2, "B")));
var newest = await secondSelection;
alphaPreview.SetResult(PreviewResult(Alpha, Recommendations(1, "A")));
var staleCompletion = await firstSelection;
Assert.Equal("김전문", newest.SelectedExpert?.ExpertName);
Assert.Equal(2, newest.Preview.Items.Count);
Assert.Equal("김전문", staleCompletion.SelectedExpert?.ExpertName);
Assert.Equal(2, staleCompletion.Preview.Items.Count);
Assert.All(staleCompletion.Preview.Items, item =>
Assert.StartsWith("B", item.StockCode, StringComparison.Ordinal));
}
[Fact]
public async Task StrictExpertIdentityOrderAndPreviewIdentity_AreRevalidated()
{
var unsorted = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(SearchResult(query, Beta, Alpha))
};
var unsortedController = new LegacyExpertWorkflowController(unsorted);
await Assert.ThrowsAsync<ExpertSelectionDataException>(
() => unsortedController.SearchAsync(string.Empty));
Assert.Equal(
LegacyExpertRequestStatus.Error,
unsortedController.Current.Search.Status);
var wrongPreview = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(SearchResult(query, Alpha)),
Preview = (_, _, _) => Task.FromResult(
PreviewResult(Beta, Recommendations(1)))
};
var wrongController = new LegacyExpertWorkflowController(wrongPreview);
var search = await wrongController.SearchAsync(string.Empty);
await Assert.ThrowsAsync<ExpertSelectionDataException>(
() => wrongController.SelectExpertAsync(search.Search.Results[0].SelectionId));
Assert.Equal(
LegacyExpertRequestStatus.Error,
wrongController.Current.Preview.Status);
}
[Fact]
public async Task ExpertSnapshots_AreImmutableAcrossLaterQueries()
{
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(
query == "A" ? SearchResult(query, Alpha) : SearchResult(query, Beta))
};
var controller = new LegacyExpertWorkflowController(service);
var before = await controller.SearchAsync("A");
var after = await controller.SearchAsync("B");
Assert.Equal("강전문", Assert.Single(before.Search.Results).ExpertName);
Assert.Equal("김전문", Assert.Single(after.Search.Results).ExpertName);
var rows = Assert.IsAssignableFrom<ICollection<LegacyExpertResultView>>(
before.Search.Results);
Assert.Throws<NotSupportedException>(() => rows.Clear());
var actions = Assert.IsAssignableFrom<ICollection<LegacyExpertActionView>>(
before.Actions);
Assert.Throws<NotSupportedException>(() => actions.Add(before.Actions[0]));
}
private static ExpertSearchResult SearchResult(
string query,
params ExpertSelectionIdentity[] identities) =>
new(
query,
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
identities.Select(identity =>
new ExpertSelectorItem(identity, DataSourceKind.Oracle)).ToArray(),
false);
private static ExpertPreviewResult PreviewResult(
ExpertSelectionIdentity identity,
IReadOnlyList<ExpertRecommendationPreview> items,
bool truncated = false) =>
new(
identity,
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
items,
truncated);
private static IReadOnlyList<ExpertRecommendationPreview> Recommendations(
int count,
string prefix = "S") =>
Enumerable.Range(0, count)
.Select(index => new ExpertRecommendationPreview(
index,
$"{prefix}{index:D4}",
$"추천종목{prefix}{index:D4}",
10_000m + index))
.ToArray();
private sealed class DelegateExpertService : IExpertSelectionService
{
public Func<string, int, CancellationToken, Task<ExpertSearchResult>> Search { get; init; } =
(query, _, _) => Task.FromResult(SearchResult(query));
public Func<ExpertSelectionIdentity, int, CancellationToken, Task<ExpertPreviewResult>>
Preview
{ get; init; } =
(identity, _, _) => Task.FromResult(PreviewResult(identity, []));
public string? SearchQuery { get; private set; }
public int SearchMaximum { get; private set; }
public int PreviewMaximum { get; private set; }
public Task<ExpertSearchResult> SearchAsync(
string query = "",
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
SearchQuery = query;
SearchMaximum = maximumResults;
return Search(query, maximumResults, cancellationToken);
}
public Task<ExpertPreviewResult> PreviewAsync(
ExpertSelectionIdentity identity,
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default)
{
PreviewMaximum = maximumItems;
return Preview(identity, maximumItems, cancellationToken);
}
}
}

View File

@@ -0,0 +1,331 @@
using System.Collections;
using System.Text;
using System.Text.Json.Nodes;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyFixedActionCatalogTests
{
[Fact]
public void EmbeddedCatalog_HasApprovedIdentityCountsAndOrder()
{
var catalog = LegacyFixedActionCatalog.Default;
Assert.Equal(LegacyFixedActionCatalog.ExpectedDocumentSha256, catalog.DocumentSha256);
Assert.Equal(
LegacyFixedActionCatalog.ExpectedGeneratorSourceSha256,
catalog.GeneratorSourceSha256);
Assert.Equal(328, catalog.Actions.Count);
Assert.Equal(Enumerable.Range(1, 328).Select(index => $"fixed-{index:000}"),
catalog.Actions.Select(action => action.Id));
Assert.Equal(78, catalog.GetActions(LegacyFixedMarket.Overseas).Count);
Assert.Equal(46, catalog.GetActions(LegacyFixedMarket.Exchange).Count);
Assert.Equal(204, catalog.GetActions(LegacyFixedMarket.Index).Count);
Assert.Equal(322, catalog.Actions.Count(action => action.Available));
Assert.Equal(6, catalog.Actions.Count(action => !action.Available));
}
[Fact]
public void EmbeddedCatalog_ClosesEverySceneIdentityAgainstCoreCatalog()
{
foreach (var action in LegacyFixedActionCatalog.Default.Actions)
{
var definition = Assert.Single(
LegacySceneCatalog.Definitions,
candidate => candidate.BuilderKey == action.BuilderKey);
Assert.Single(action.Aliases);
Assert.Equal(action.Code, action.Aliases[0]);
Assert.Contains(action.Code, definition.CutAliases);
Assert.Contains(
LegacySceneCatalog.FindByCutAlias(action.Code),
candidate => candidate.BuilderKey == action.BuilderKey);
}
}
[Fact]
public void RuntimeAssets_AreApprovedAndImmutable()
{
var catalog = LegacyFixedActionCatalog.Default;
Assert.Equal(
"bin/Debug/Res/해외.ini",
catalog.GetRuntimeAsset(LegacyFixedMarket.Overseas).RelativePath);
Assert.Equal(
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A",
catalog.GetRuntimeAsset(LegacyFixedMarket.Overseas).Sha256);
Assert.Equal(
"bin/Debug/Res/환율.ini",
catalog.GetRuntimeAsset(LegacyFixedMarket.Exchange).RelativePath);
Assert.Equal(
"bin/Debug/Res/지수.ini",
catalog.GetRuntimeAsset(LegacyFixedMarket.Index).RelativePath);
var dictionary = Assert.IsAssignableFrom<IDictionary>(catalog.RuntimeAssets);
Assert.True(dictionary.IsReadOnly);
Assert.Throws<NotSupportedException>(() =>
dictionary.Add(
LegacyFixedMarket.Overseas,
new LegacyFixedRuntimeAsset(LegacyFixedMarket.Overseas, "bad", "bad")));
}
[Fact]
public void QueryApi_ReturnsImmutableMarketAndSectionViews()
{
var catalog = LegacyFixedActionCatalog.Default;
var overseas = catalog.GetActions(LegacyFixedMarket.Overseas);
var sections = catalog.GetSections(LegacyFixedMarket.Overseas);
var firstSection = sections[0];
Assert.Equal(LegacyFixedMarket.Overseas, firstSection.Market);
Assert.Equal("1열판기본", firstSection.Name);
Assert.Equal(43, firstSection.Actions.Count);
Assert.Same(catalog.Actions[0], catalog.GetAction("fixed-001"));
Assert.Same(
firstSection.Actions[0],
catalog.GetActions(LegacyFixedMarket.Overseas, "1열판기본")[0]);
Assert.Empty(catalog.GetActions(LegacyFixedMarket.Overseas, "없는 섹션"));
var mutableActions = Assert.IsAssignableFrom<IList<LegacyFixedAction>>(overseas);
Assert.True(mutableActions.IsReadOnly);
Assert.Throws<NotSupportedException>(() => mutableActions.RemoveAt(0));
Assert.Null(catalog.FindAction(null));
Assert.Null(catalog.FindAction("fixed-999"));
Assert.Throws<KeyNotFoundException>(() => catalog.GetAction("fixed-999"));
Assert.Throws<ArgumentOutOfRangeException>(() =>
catalog.GetActions((LegacyFixedMarket)99));
}
[Fact]
public void Materialize_ComputesDateFromTrustedHostClock()
{
var now = new DateTimeOffset(2026, 7, 15, 23, 58, 0, TimeSpan.FromHours(9));
var result = LegacyFixedActionCatalog.Default.Materialize("fixed-248", now);
Assert.Equal("2026-07-15", result.Selection.DataCode);
Assert.Equal("", result.Selection.GraphicType);
Assert.Equal("$TODAY", result.Action.Selection.DataCode);
Assert.Equal(LegacyFixedMarket.Index, result.RuntimeAsset.Market);
}
[Theory]
[InlineData(0, "PRE_MARKET")]
[InlineData(12, "PRE_MARKET")]
[InlineData(13, "AFTER_MARKET")]
[InlineData(23, "AFTER_MARKET")]
public void Materialize_ComputesNxtSessionFromTrustedHostClock(int hour, string expected)
{
var now = new DateTimeOffset(2026, 7, 15, hour, 59, 0, TimeSpan.FromHours(9));
var result = LegacyFixedActionCatalog.Default.Materialize("fixed-263", now);
Assert.Equal(expected, result.Selection.GraphicType);
Assert.Equal("$NXT_SESSION", result.Action.Selection.GraphicType);
Assert.Equal("", result.Selection.DataCode);
}
[Theory]
[InlineData(false, false, "")]
[InlineData(true, false, "MA5")]
[InlineData(false, true, "MA20")]
[InlineData(true, true, "MA5,MA20")]
public void Materialize_ClosesMovingAverageFlags(bool ma5, bool ma20, string expected)
{
var result = LegacyFixedActionCatalog.Default.Materialize(
"fixed-136",
new DateTimeOffset(2026, 7, 15, 9, 0, 0, TimeSpan.FromHours(9)),
new LegacyMovingAverageSelection(ma5, ma20));
Assert.Equal(expected, result.Selection.GraphicType);
Assert.Equal(new LegacyMovingAverageSelection(ma5, ma20), result.MovingAverages);
Assert.Equal("", result.Action.Selection.GraphicType);
}
[Fact]
public void Materialize_IgnoresMovingAverageFlagsForOtherBuilders()
{
var result = LegacyFixedActionCatalog.Default.Materialize(
"fixed-001",
DateTimeOffset.Now,
new LegacyMovingAverageSelection(true, true));
Assert.Equal("1열판기본", result.Selection.GraphicType);
Assert.Equal(default, result.MovingAverages);
}
[Fact]
public void Materialize_ResolvesEveryTemplateWithoutAcceptingSelectionOverrides()
{
var catalog = LegacyFixedActionCatalog.Default;
var beforeBoundary = new DateTimeOffset(
2026, 7, 15, 12, 59, 0, TimeSpan.FromHours(9));
var afterBoundary = beforeBoundary.AddHours(1);
foreach (var action in catalog.Actions.Where(action => action.Available))
{
var before = catalog.Materialize(
action.Id,
beforeBoundary,
new LegacyMovingAverageSelection(true, true));
Assert.DoesNotContain('$', before.Selection.GroupCode);
Assert.DoesNotContain('$', before.Selection.Subject);
Assert.DoesNotContain('$', before.Selection.GraphicType);
Assert.DoesNotContain('$', before.Selection.Subtype);
Assert.DoesNotContain('$', before.Selection.DataCode);
if (action.DynamicDate)
{
Assert.Equal("2026-07-15", before.Selection.DataCode);
}
else if (action.DynamicNxtSession)
{
Assert.Equal("PRE_MARKET", before.Selection.GraphicType);
Assert.Equal(
"AFTER_MARKET",
catalog.Materialize(action.Id, afterBoundary).Selection.GraphicType);
}
else if (action.SupportsMovingAverages)
{
Assert.Equal("MA5,MA20", before.Selection.GraphicType);
}
else
{
Assert.Equal(action.Selection, before.Selection);
}
}
Assert.Equal(50, catalog.Actions.Count(action => action.DynamicDate));
Assert.Equal(28, catalog.Actions.Count(action => action.DynamicNxtSession));
Assert.Equal(74, catalog.Actions.Count(action => action.SupportsMovingAverages));
}
[Fact]
public void Materialize_RejectsUnknownAndUnavailableActions()
{
var catalog = LegacyFixedActionCatalog.Default;
Assert.Throws<KeyNotFoundException>(() =>
catalog.Materialize("fixed-999", DateTimeOffset.Now));
var exception = Assert.Throws<InvalidOperationException>(() =>
catalog.Materialize("fixed-245", DateTimeOffset.Now));
Assert.Contains("수동 순매도", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Loader_RejectsUnknownJsonMembersAndMissingRequiredMembers()
{
var unknown = ReadDocument();
unknown["unexpected"] = true;
AssertInvalid(unknown, "JSON schema");
var nestedUnknown = ReadDocument();
Action(nestedUnknown, 0)["selection"]!["webOverride"] = "5006";
AssertInvalid(nestedUnknown, "JSON schema");
var missing = ReadDocument();
missing.AsObject().Remove("schemaVersion");
AssertInvalid(missing, "JSON schema");
var nullActions = ReadDocument();
nullActions["actions"] = null;
AssertInvalid(nullActions, "actions is required");
}
[Fact]
public void Loader_RejectsDuplicateJsonMembers()
{
var original = Encoding.UTF8.GetString(ReadEmbeddedBytes());
var duplicate = original.Replace(
"\"schemaVersion\": 1,",
"\"schemaVersion\": 1,\n \"schemaVersion\": 1,",
StringComparison.Ordinal);
var exception = Assert.Throws<InvalidDataException>(() =>
LegacyFixedActionCatalog.LoadForValidation(Encoding.UTF8.GetBytes(duplicate)));
Assert.Contains("JSON schema", exception.Message, StringComparison.Ordinal);
Assert.IsType<System.Text.Json.JsonException>(exception.InnerException);
}
[Fact]
public void Loader_RejectsTamperedDocumentHash()
{
var tampered = ReadEmbeddedBytes().Concat([(byte)' ']).ToArray();
var exception = Assert.Throws<InvalidDataException>(() =>
LegacyFixedActionCatalog.LoadForValidation(
tampered,
LegacyFixedActionCatalog.ExpectedDocumentSha256));
Assert.Contains("hash is invalid", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Loader_RejectsUnknownBuilderCodeAndAlias()
{
var unknownBuilder = ReadDocument();
Action(unknownBuilder, 0)["builderKey"] = "s9999";
AssertInvalid(unknownBuilder, "unknown builder");
var wrongCode = ReadDocument();
Action(wrongCode, 0)["code"] = "5006";
AssertInvalid(wrongCode, "is not an alias");
var wrongAlias = ReadDocument();
Action(wrongAlias, 0)["aliases"] = new JsonArray("N5001");
AssertInvalid(wrongAlias, "sole alias");
}
[Fact]
public void Loader_RejectsNonSequentialIdAndSelectionPlaceholders()
{
var wrongId = ReadDocument();
Action(wrongId, 0)["id"] = "fixed-002";
AssertInvalid(wrongId, "action id at index 0");
var untrustedSelection = ReadDocument();
Action(untrustedSelection, 0)["selection"]!["graphicType"] = "$WEB_VALUE";
AssertInvalid(untrustedSelection, "unapproved selection placeholder");
var badDynamicDate = ReadDocument();
Action(badDynamicDate, 247)["dynamicDate"] = false;
AssertInvalid(badDynamicDate, "dynamic-date declaration");
}
[Fact]
public void Loader_RejectsTamperedRuntimeAssetMetadata()
{
var path = ReadDocument();
path["runtimeAssets"]!["overseas"]!["relativePath"] = "C:/untrusted/해외.ini";
AssertInvalid(path, "runtime asset metadata");
var hash = ReadDocument();
hash["runtimeAssets"]!["index"]!["sha256"] = new string('0', 64);
AssertInvalid(hash, "runtime asset metadata");
}
private static JsonNode ReadDocument() =>
JsonNode.Parse(ReadEmbeddedBytes()) ?? throw new InvalidOperationException();
private static byte[] ReadEmbeddedBytes()
{
using var stream = typeof(LegacyFixedActionCatalog).Assembly.GetManifestResourceStream(
"MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json")
?? throw new InvalidOperationException("Embedded test catalog was not found.");
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
return buffer.ToArray();
}
private static JsonObject Action(JsonNode document, int index) =>
document["actions"]!.AsArray()[index]!.AsObject();
private static void AssertInvalid(JsonNode document, string expectedMessage)
{
var bytes = Encoding.UTF8.GetBytes(document.ToJsonString());
var exception = Assert.Throws<InvalidDataException>(() =>
LegacyFixedActionCatalog.LoadForValidation(bytes));
Assert.Contains(expectedMessage, exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,144 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyIndustryActionCatalogTests
{
private static readonly string[] ExpectedActionIds =
[
"one-column",
"two-column",
"yield-5일",
"yield-20일",
"yield-60일",
"yield-120일",
"yield-240일",
"candle-일봉",
"candle-5일",
"candle-20일",
"candle-60일",
"candle-120일",
"candle-240일",
"candle-volume-일봉",
"candle-volume-5일",
"candle-volume-20일",
"candle-volume-60일",
"candle-volume-120일",
"candle-volume-240일",
"five-row",
"square",
"sector"
];
private static readonly string[] ExpectedRuntimeLabels =
[
"1열판",
"2열판",
"수익률그래프_5일",
"수익률그래프_20일",
"수익률그래프_60일",
"수익률그래프_120일",
"수익률그래프_240일",
"캔들그래프_일봉",
"캔들그래프_5일",
"캔들그래프_20일",
"캔들그래프_60일",
"캔들그래프_120일",
"캔들그래프_240일",
"캔들그래프(거래량)_일봉",
"캔들그래프(거래량)_5일",
"캔들그래프(거래량)_20일",
"캔들그래프(거래량)_60일",
"캔들그래프(거래량)_120일",
"캔들그래프(거래량)_240일",
"5단 표그래프",
"네모그래프",
"섹터지수"
];
[Theory]
[InlineData(
IndustryMarket.Kospi,
"bin/Debug/Res/업종_코스피.ini",
"F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52")]
[InlineData(
IndustryMarket.Kosdaq,
"bin/Debug/Res/업종_코스닥.ini",
"F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4")]
public void EachMarketHasTheExactImmutableRuntimeActionsAndAsset(
IndustryMarket market,
string expectedPath,
string expectedSha256)
{
var actions = LegacyIndustryActionCatalog.GetActions(market);
Assert.Equal(LegacyIndustryActionCatalog.ActionsPerMarket, actions.Count);
Assert.Equal(ExpectedActionIds, actions.Select(action => action.ActionId));
Assert.Equal(ExpectedRuntimeLabels, actions.Select(action => action.Label));
Assert.Equal(
LegacyIndustryActionCatalog.ActionsPerMarket,
actions.Select(action => action.ActionId).Distinct(StringComparer.Ordinal).Count());
Assert.All(actions, action => Assert.Equal(market, action.Market));
var mutableView = Assert.IsAssignableFrom<IList<LegacyIndustryActionDefinition>>(actions);
Assert.Throws<NotSupportedException>(() => mutableView.RemoveAt(0));
var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market);
Assert.Equal(expectedPath, asset.RelativePath);
Assert.Equal(expectedSha256, asset.Sha256);
}
[Theory]
[InlineData(IndustryMarket.Kospi, "two-column", "s8018", "8018", 0)]
[InlineData(IndustryMarket.Kosdaq, "two-column", "s8018", "8032", 0)]
[InlineData(IndustryMarket.Kospi, "candle-일봉", "s8010", "8035", 0)]
[InlineData(IndustryMarket.Kospi, "candle-volume-5일", "s8010", "8061", 0)]
[InlineData(IndustryMarket.Kosdaq, "candle-20일", "s8010", "8040", 0)]
[InlineData(IndustryMarket.Kosdaq, "candle-volume-60일", "s8010", "8046", 0)]
[InlineData(IndustryMarket.Kospi, "candle-120일", "s8010", "8051", 0)]
[InlineData(IndustryMarket.Kosdaq, "candle-volume-240일", "s8010", "8056", 0)]
[InlineData(IndustryMarket.Kospi, "five-row", "s5074", "5074", 5)]
[InlineData(IndustryMarket.Kospi, "square", "s8001", "8001", 0)]
[InlineData(IndustryMarket.Kosdaq, "square", "s8001", "8002", 0)]
[InlineData(IndustryMarket.Kosdaq, "sector", "s5078", "5078", 0)]
public void SceneAliasAndPageSizeAreClosedPerMarket(
IndustryMarket market,
string actionId,
string expectedBuilder,
string expectedAlias,
int expectedPageSize)
{
var action = LegacyIndustryActionCatalog.GetRequiredAction(market, actionId);
Assert.Equal(expectedBuilder, action.BuilderKey);
Assert.Equal(expectedAlias, action.SceneAlias);
Assert.Equal(expectedPageSize, action.PageSize);
}
[Theory]
[InlineData(IndustryMarket.Kospi)]
[InlineData(IndustryMarket.Kosdaq)]
public void OnlyFiveRowHasLegacyPaging(IndustryMarket market)
{
var paged = Assert.Single(
LegacyIndustryActionCatalog.GetActions(market),
action => action.PageSize != 0);
Assert.Equal("five-row", paged.ActionId);
Assert.Equal(5, paged.PageSize);
}
[Fact]
public void UnknownMarketAndActionFailClosed()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
LegacyIndustryActionCatalog.GetActions((IndustryMarket)999));
Assert.Throws<KeyNotFoundException>(() =>
LegacyIndustryActionCatalog.GetRequiredAction(IndustryMarket.Kospi, "../5001"));
Assert.False(LegacyIndustryActionCatalog.TryGetAction(
IndustryMarket.Kosdaq,
"ONE-COLUMN",
out _));
}
}

View File

@@ -0,0 +1,347 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyIndustrySelectionWorkflowTests
{
[Fact]
public async Task LoadMarketUsesServiceOrderAndCopiesRowsIntoImmutableSnapshot()
{
var source = KospiRows().ToList();
var service = new QueueIndustryService(source);
var workflow = new LegacyIndustrySelectionWorkflow(service);
var state = await workflow.LoadMarketAsync(IndustryMarket.Kospi);
source.Clear();
Assert.Equal([IndustryMarket.Kospi], service.Markets);
Assert.Equal(3, state.Industries.Count);
Assert.Equal("전기전자", state.Industries[0].IndustryName);
Assert.Equal(22, state.Actions.Count);
Assert.Null(state.SelectedIndustry);
Assert.Null(state.FirstIndustry);
Assert.Null(state.SecondIndustry);
var rows = Assert.IsAssignableFrom<IList<IndustrySelection>>(state.Industries);
Assert.Throws<NotSupportedException>(() => rows.RemoveAt(0));
var actions = Assert.IsAssignableFrom<IList<LegacyIndustryActionDefinition>>(state.Actions);
Assert.Throws<NotSupportedException>(() => actions.RemoveAt(0));
}
[Fact]
public async Task ListDoubleClickRotatesFirstIntoSecondBeforeAssigningNewFirst()
{
var workflow = await KospiWorkflowAsync();
var selectedOnly = workflow.SelectIndustry(1);
Assert.Equal("운수장비", selectedOnly.SelectedIndustry?.IndustryName);
Assert.Null(selectedOnly.FirstIndustry);
var first = workflow.DoubleClickIndustry(0);
Assert.Equal("전기전자", first.SelectedIndustry?.IndustryName);
Assert.Equal("전기전자", first.FirstIndustry?.IndustryName);
Assert.Null(first.SecondIndustry);
var second = workflow.DoubleClickIndustry(2);
Assert.Equal("화학", second.SelectedIndustry?.IndustryName);
Assert.Equal("화학", second.FirstIndustry?.IndustryName);
Assert.Equal("전기전자", second.SecondIndustry?.IndustryName);
var third = workflow.DoubleClickIndustry(1);
Assert.Equal("운수장비", third.FirstIndustry?.IndustryName);
Assert.Equal("화학", third.SecondIndustry?.IndustryName);
}
[Fact]
public async Task SwapMatchesUc2EvenWhenOnlyOneTextSlotHasAValue()
{
var workflow = await KospiWorkflowAsync();
workflow.DoubleClickIndustry(0);
var swapped = workflow.SwapIndustries();
Assert.Null(swapped.FirstIndustry);
Assert.Equal("전기전자", swapped.SecondIndustry?.IndustryName);
var restored = workflow.SwapIndustries();
Assert.Equal("전기전자", restored.FirstIndustry?.IndustryName);
Assert.Null(restored.SecondIndustry);
}
[Fact]
public async Task SameMarketRefreshReconcilesIdentityButMarketChangeClearsSelection()
{
var initial = KospiRows();
var refreshed = new[] { initial[1], initial[0], initial[2] };
var kosdaq = KosdaqRows();
var service = new QueueIndustryService(initial, refreshed, kosdaq);
var workflow = new LegacyIndustrySelectionWorkflow(service);
await workflow.LoadMarketAsync(IndustryMarket.Kospi);
workflow.DoubleClickIndustry(0);
workflow.DoubleClickIndustry(1);
var reconciled = await workflow.LoadMarketAsync(IndustryMarket.Kospi);
Assert.Equal(0, reconciled.SelectedIndex);
Assert.Equal("운수장비", reconciled.SelectedIndustry?.IndustryName);
Assert.Equal("운수장비", reconciled.FirstIndustry?.IndustryName);
Assert.Equal("전기전자", reconciled.SecondIndustry?.IndustryName);
var changed = await workflow.LoadMarketAsync(IndustryMarket.Kosdaq);
Assert.Equal(IndustryMarket.Kosdaq, changed.Market);
Assert.Null(changed.SelectedIndex);
Assert.Null(changed.SelectedIndustry);
Assert.Null(changed.FirstIndustry);
Assert.Null(changed.SecondIndustry);
}
[Fact]
public async Task InvalidIndexAndEmptyClearAreNoOps()
{
var workflow = await KospiWorkflowAsync();
var before = workflow.Current;
Assert.Equal(before.Revision, workflow.SelectIndustry(-1).Revision);
Assert.Equal(before.Revision, workflow.DoubleClickIndustry(3).Revision);
Assert.Equal(before.Revision, workflow.ClearSelections().Revision);
Assert.Equal(before.Revision, workflow.SwapIndustries().Revision);
}
[Fact]
public async Task RepresentativeActionsMaterializeOnlyFromCatalogAndAuthoritativeState()
{
var workflow = await KospiWorkflowAsync(new FixedTimeProvider(
new DateTimeOffset(2026, 7, 11, 10, 0, 0, TimeSpan.Zero)));
workflow.DoubleClickIndustry(0);
workflow.DoubleClickIndustry(1);
var single = workflow.MaterializePlaylistDraft("one-column");
Assert.Equal("s5001", single.BuilderKey);
Assert.Equal("5001", single.SceneAlias);
Assert.Equal("운수장비", single.Title);
Assert.Equal(
new LegacyIndustrySceneSelectionDraft(
"INDUSTRY_KOSPI", "운수장비", "1열판", "CURRENT", string.Empty),
single.Selection);
Assert.True(single.IsEnabled);
Assert.Equal(6, single.FadeDuration);
var pair = workflow.MaterializePlaylistDraft("two-column");
Assert.Equal("s8018", pair.BuilderKey);
Assert.Equal("8018", pair.SceneAlias);
Assert.Equal("운수장비-전기전자", pair.Selection.Subject);
Assert.Equal("2열판", pair.Selection.GraphicType);
var yield = workflow.MaterializePlaylistDraft("yield-120일");
Assert.Equal("s5086", yield.BuilderKey);
Assert.Equal("5086", yield.SceneAlias);
Assert.Equal("OneHundredTwentyDays", yield.Selection.Subtype);
var candle = workflow.MaterializePlaylistDraft("candle-volume-240일");
Assert.Equal("s8010", candle.BuilderKey);
Assert.Equal("8056", candle.SceneAlias);
Assert.Equal("KOSPI_INDUSTRY", candle.Selection.GroupCode);
Assert.Equal("VOLUME", candle.Selection.Subtype);
var five = workflow.MaterializePlaylistDraft("five-row");
Assert.Equal("s5074", five.BuilderKey);
Assert.Equal("5074", five.SceneAlias);
Assert.Equal(5, five.PageSize);
Assert.Equal("PAGED_DOMESTIC_KOSPI", five.Selection.GroupCode);
Assert.Equal("INDEX", five.Selection.Subject);
Assert.Equal("INDUSTRY", five.Selection.Subtype);
Assert.Equal("2026-07-11", five.Selection.DataCode);
Assert.Equal(
"F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52",
five.RuntimeAssetSha256);
var square = workflow.MaterializePlaylistDraft("square");
Assert.Equal("8001", square.SceneAlias);
Assert.Equal("KOSPI", square.Selection.Subject);
Assert.Equal("네모그래프", square.Selection.GraphicType);
var sector = workflow.MaterializePlaylistDraft("sector");
Assert.Equal("5078", sector.SceneAlias);
Assert.Equal("SECTOR_INDEX", sector.Selection.GraphicType);
}
[Fact]
public async Task EveryKosdaqActionMaterializesWithItsExactAliasAndPageSize()
{
var service = new QueueIndustryService(KosdaqRows());
var workflow = new LegacyIndustrySelectionWorkflow(
service,
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 0, 0, 0, TimeSpan.Zero)));
await workflow.LoadMarketAsync(IndustryMarket.Kosdaq);
workflow.DoubleClickIndustry(0);
workflow.DoubleClickIndustry(1);
foreach (var action in LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq))
{
var draft = workflow.MaterializePlaylistDraft(action.ActionId);
Assert.Equal(action.ActionId, draft.ActionId);
Assert.Equal(action.BuilderKey, draft.BuilderKey);
Assert.Equal(action.SceneAlias, draft.SceneAlias);
Assert.Equal(action.PageSize, draft.PageSize);
Assert.Equal(IndustryMarket.Kosdaq, draft.Market);
}
Assert.Equal("8032", workflow.MaterializePlaylistDraft("two-column").SceneAlias);
Assert.Equal("8002", workflow.MaterializePlaylistDraft("square").SceneAlias);
Assert.Equal(
"PAGED_DOMESTIC_KOSDAQ",
workflow.MaterializePlaylistDraft("five-row").Selection.GroupCode);
}
[Fact]
public async Task SelectionPrerequisitesUnsafePairAndUnknownActionFailClosed()
{
var workflow = await KospiWorkflowAsync();
Assert.Throws<LegacyIndustryWorkflowException>(() =>
workflow.MaterializePlaylistDraft("one-column"));
Assert.Throws<LegacyIndustryWorkflowException>(() =>
workflow.MaterializePlaylistDraft("two-column"));
Assert.NotNull(workflow.MaterializePlaylistDraft("five-row"));
Assert.Throws<LegacyIndustryWorkflowException>(() =>
workflow.MaterializePlaylistDraft("../../5001"));
Assert.Throws<ArgumentNullException>(() =>
workflow.MaterializePlaylistDraft(null!));
var unsafePair = new LegacyIndustrySelectionWorkflow(new QueueIndustryService(
[
new IndustrySelection(IndustryMarket.Kospi, "전기-전자", "013"),
new IndustrySelection(IndustryMarket.Kospi, "운수장비", "015")
]));
await unsafePair.LoadMarketAsync(IndustryMarket.Kospi);
unsafePair.DoubleClickIndustry(0);
unsafePair.DoubleClickIndustry(1);
Assert.Throws<LegacyIndustryWorkflowException>(() =>
unsafePair.MaterializePlaylistDraft("two-column"));
}
[Fact]
public async Task InvalidServiceRowsFailAtomicallyBeforeStateChanges()
{
var service = new QueueIndustryService(
[
new IndustrySelection(IndustryMarket.Kosdaq, "반도체", "027")
]);
var workflow = new LegacyIndustrySelectionWorkflow(service);
await Assert.ThrowsAsync<LegacyIndustryWorkflowException>(() =>
workflow.LoadMarketAsync(IndustryMarket.Kospi));
Assert.Equal(0, workflow.Current.Revision);
Assert.Null(workflow.Current.Market);
Assert.Empty(workflow.Current.Industries);
}
[Fact]
public async Task SlowerPreviousMarketResponseCannotReplaceOrFaultNewerMarketState()
{
var service = new ControlledIndustryService();
var workflow = new LegacyIndustrySelectionWorkflow(service);
var older = workflow.LoadMarketAsync(IndustryMarket.Kospi);
var newer = workflow.LoadMarketAsync(IndustryMarket.Kosdaq);
service.Kosdaq.SetResult(KosdaqRows());
var newerState = await newer;
// This row is invalid for the older KOSPI request. Because that request is
// already stale, it must be ignored before it can disturb current state.
service.Kospi.SetResult(KosdaqRows());
var olderResult = await older;
Assert.Equal(IndustryMarket.Kosdaq, newerState.Market);
Assert.Equal(IndustryMarket.Kosdaq, olderResult.Market);
Assert.Equal(IndustryMarket.Kosdaq, workflow.Current.Market);
Assert.Equal("반도체", workflow.Current.Industries[0].IndustryName);
Assert.Equal(1, workflow.Current.Revision);
}
[Fact]
public async Task ClearSelectionsClearsHighlightedAndBothPairSlots()
{
var workflow = await KospiWorkflowAsync();
workflow.DoubleClickIndustry(0);
workflow.DoubleClickIndustry(1);
var cleared = workflow.ClearSelections();
Assert.Null(cleared.SelectedIndex);
Assert.Null(cleared.SelectedIndustry);
Assert.Null(cleared.FirstIndustry);
Assert.Null(cleared.SecondIndustry);
}
private static async Task<LegacyIndustrySelectionWorkflow> KospiWorkflowAsync(
TimeProvider? timeProvider = null)
{
var workflow = new LegacyIndustrySelectionWorkflow(
new QueueIndustryService(KospiRows()),
timeProvider);
await workflow.LoadMarketAsync(IndustryMarket.Kospi);
return workflow;
}
private static IndustrySelection[] KospiRows() =>
[
new(IndustryMarket.Kospi, "전기전자", "013"),
new(IndustryMarket.Kospi, "운수장비", "015"),
new(IndustryMarket.Kospi, "화학", "008")
];
private static IndustrySelection[] KosdaqRows() =>
[
new(IndustryMarket.Kosdaq, "반도체", "027"),
new(IndustryMarket.Kosdaq, "IT부품", "029")
];
private sealed class QueueIndustryService(
params IReadOnlyList<IndustrySelection>[] results) : IIndustrySelectionService
{
private readonly Queue<IReadOnlyList<IndustrySelection>> _results = new(results);
public List<IndustryMarket> Markets { get; } = [];
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Markets.Add(market);
if (_results.Count == 0)
{
throw new InvalidOperationException("No fake industry result remains.");
}
return Task.FromResult(_results.Dequeue());
}
}
private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
public override DateTimeOffset GetUtcNow() => utcNow;
}
private sealed class ControlledIndustryService : IIndustrySelectionService
{
public TaskCompletionSource<IReadOnlyList<IndustrySelection>> Kospi { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<IReadOnlyList<IndustrySelection>> Kosdaq { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default) => market switch
{
IndustryMarket.Kospi => Kospi.Task.WaitAsync(cancellationToken),
IndustryMarket.Kosdaq => Kosdaq.Task.WaitAsync(cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
};
}
}

View File

@@ -1,9 +1,307 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorControllerTests
{
[Fact]
public async Task IndustryTabLoadsSelectorRotatesPairAndAddsTrustedActions()
{
var industries = Enumerable.Range(1, 11)
.Select(index => new IndustrySelection(
IndustryMarket.Kospi,
$"업종{index}",
index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
var workflow = new LegacyIndustrySelectionWorkflow(
new StaticIndustryService(industries));
var controller = new LegacyOperatorController(
Lookup(),
industryWorkflow: workflow);
var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry);
Assert.Equal(11, loaded.Industry?.Industries.Count);
controller.SelectIndustry(0, doubleClick: true);
var pair = controller.SelectIndustry(1, doubleClick: true);
Assert.Equal("업종2", pair.Industry?.FirstIndustry?.IndustryName);
Assert.Equal("업종1", pair.Industry?.SecondIndustry?.IndustryName);
var twoColumn = controller.ActivateIndustryAction("two-column");
var pairRow = Assert.Single(twoColumn.Playlist);
Assert.Equal("8018", pairRow.ToPlayoutEntry().CutCode);
Assert.Equal("업종2-업종1", pairRow.StockName);
var paged = controller.ActivateIndustryAction("five-row");
Assert.Equal("1/3", paged.Playlist[1].PageText);
Assert.Equal("5074", paged.Playlist[1].ToPlayoutEntry().CutCode);
}
[Fact]
public async Task IndustryParentDoubleClickMaterializesDirectChildrenAtomicallyInOrder()
{
var industries = Enumerable.Range(1, 3)
.Select(index => new IndustrySelection(
IndustryMarket.Kospi,
$"업종{index}",
index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
var controller = new LegacyOperatorController(
Lookup(),
industryWorkflow: new LegacyIndustrySelectionWorkflow(
new StaticIndustryService(industries)));
await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry);
controller.SelectIndustry(0, doubleClick: true);
var rejected = controller.ActivateIndustrySection(0);
Assert.Empty(rejected.Playlist);
controller.SelectIndustry(1, doubleClick: true);
var added = controller.ActivateIndustrySection(0);
Assert.Collection(
added.Playlist,
row => Assert.Equal("5001", row.SceneAlias),
row => Assert.Equal("8018", row.SceneAlias));
}
[Fact]
public async Task ThemeTabSearchPreviewSortAndActionRemainCSharpAuthoritative()
{
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI 테마");
var service = new StaticThemeService(
identity,
Enumerable.Range(0, 13)
.Select(index => new ThemeItemPreview(
index,
$"P{index + 1:000000}",
$"종목{index + 1}"))
.ToArray());
var controller = new LegacyOperatorController(
Lookup(),
themeWorkflow: new LegacyThemeWorkflow(service));
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
var searched = await controller.SearchThemesAsync("AI", ThemeSession.PreMarket);
var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId;
var selected = await controller.SelectThemeAsync(resultId);
Assert.Equal(13, selected.Theme?.PreviewItems.Count);
controller.SetThemeSort("GAIN_DESC");
var added = controller.ActivateThemeAction("five-row-current");
var row = Assert.Single(added.Playlist);
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
Assert.Equal("1/3", row.PageText);
Assert.Equal("AI 테마", row.StockName);
Assert.Equal(
"GAIN_DESC",
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.ToPlayoutEntry().Selection).GraphicType);
}
[Fact]
public async Task Theme_result_double_click_awaits_preview_and_adds_fixed_current_action_once()
{
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI theme");
var service = new StaticThemeService(
identity,
Enumerable.Range(0, 7)
.Select(index => new ThemeItemPreview(
index,
$"P{index + 1:000000}",
$"Stock {index + 1}"))
.ToArray());
var controller = new LegacyOperatorController(
Lookup(),
themeWorkflow: new LegacyThemeWorkflow(service));
var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
var resultId = Assert.Single(loaded.Theme!.SearchResults).ResultId;
var activated = await controller.ActivateThemeResultAsync(resultId);
Assert.Equal(1, service.PreviewCalls);
Assert.Equal(resultId, activated.Theme?.SelectedResultId);
var row = Assert.Single(activated.Playlist);
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
Assert.Equal("1/2", row.PageText);
Assert.Equal("INPUT_ORDER", Assert.IsType<
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.ToPlayoutEntry().Selection).GraphicType);
Assert.Equal("activate-theme-result", activated.CommandResult?.Command);
Assert.True(activated.CommandResult?.Succeeded);
}
[Fact]
public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRow()
{
var controller = new LegacyOperatorController(
Lookup(),
expertWorkflow: new LegacyExpertWorkflowController(new StaticExpertService()));
await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
var search = await controller.SearchExpertsAsync("전문가");
var selectionId = Assert.Single(search.Expert!.Search.Results).SelectionId;
await controller.SelectExpertAsync(selectionId);
var added = controller.ActivateExpertAction(
LegacyExpertWorkflowController.ActionId);
var row = Assert.Single(added.Playlist);
Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
Assert.Equal("1/2", row.PageText);
Assert.Equal("전문가", row.MarketText);
}
[Fact]
public async Task TradingHaltTabAddsCurrentAndCandleWithGlobalMovingAverages()
{
var controller = new LegacyOperatorController(
Lookup(),
tradingHaltWorkflow: new LegacyTradingHaltWorkflowController(
new StaticTradingHaltService()));
await controller.SelectTabAsync(LegacyOperatorTabId.TradingHalt);
var search = await controller.SearchTradingHaltsAsync("");
var selectionId = Assert.Single(search.TradingHalt!.Results).SelectionId;
controller.SelectTradingHalt(selectionId);
var current = controller.ActivateTradingHaltAction(
LegacyTradingHaltWorkflowController.CurrentActionId);
Assert.Equal("5001", Assert.Single(current.Playlist).ToPlayoutEntry().CutCode);
controller.SetMovingAverages(ma5: true, ma20: false);
var candle = controller.ActivateTradingHaltAction(
LegacyTradingHaltWorkflowController.CandleActionId);
var candleRow = candle.Playlist[1];
Assert.Equal("8051", candleRow.ToPlayoutEntry().CutCode);
Assert.Equal(
"MA5",
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
candleRow.ToPlayoutEntry().Selection).GraphicType);
}
[Fact]
public void Tabs_StartInOriginalOrderAndSwapByIdentity()
{
var controller = new LegacyOperatorController(Lookup());
Assert.Equal(
["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외종목", "전문가", "정지"],
controller.Current.Tabs!.Select(tab => tab.Label));
Assert.Equal(
LegacyOperatorTabId.Overseas,
Assert.Single(controller.Current.Tabs!, tab => tab.IsActive).Id);
var selected = controller.SelectTab(LegacyOperatorTabId.Theme);
Assert.Equal(
LegacyOperatorTabId.Theme,
Assert.Single(selected.Tabs!, tab => tab.IsActive).Id);
var swapped = controller.SwapTabs(
LegacyOperatorTabId.Theme,
LegacyOperatorTabId.Exchange);
Assert.Equal(LegacyOperatorTabId.Theme, swapped.Tabs![1].Id);
Assert.Equal(LegacyOperatorTabId.Exchange, swapped.Tabs[6].Id);
Assert.Equal(
LegacyOperatorTabId.Theme,
Assert.Single(swapped.Tabs, tab => tab.IsActive).Id);
}
[Fact]
public async Task PlaylistKeyboardCommandsUseActiveRowAndBoundaries()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
var rows = controller.Current.Playlist;
var last = controller.SelectPlaylistBoundary(last: true);
Assert.True(last.Playlist[0].IsEnabled);
Assert.True(last.Playlist[2].IsSelected);
var toggled = controller.ToggleActivePlaylistRowEnabled();
Assert.False(toggled.Playlist[2].IsEnabled);
var first = controller.SelectPlaylistBoundary(last: false);
Assert.True(first.Playlist[0].IsSelected);
Assert.False(first.Playlist[2].IsSelected);
}
[Fact]
public async Task FixedCatalogLeafAndSectionAreMaterializedOnlyByCSharp()
{
var controller = new LegacyOperatorController(Lookup());
var first = LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas)[0];
var leaf = controller.ActivateFixedAction(first.Id);
var row = Assert.Single(leaf.Playlist);
Assert.Equal("해외지수", row.MarketText);
Assert.Equal(first.Label, row.StockName);
Assert.Equal(first.Code, row.ToPlayoutEntry().CutCode);
Assert.Equal(
first.Selection.Subject,
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
row.ToPlayoutEntry().Selection).Subject);
var firstSection = LegacyFixedActionCatalog.Default
.GetSections(LegacyFixedMarket.Overseas)[0];
var section = await controller.ActivateFixedSection(0);
Assert.Equal(1 + firstSection.Actions.Count, section.Playlist.Count);
Assert.Equal(
firstSection.Actions.Select(action => action.Label),
section.Playlist.Skip(1).Select(item => item.StockName));
}
[Fact]
public void FixedCatalogRejectsCrossTabAndUnavailableActionsAtomically()
{
var controller = new LegacyOperatorController(Lookup());
var exchange = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Exchange)[0];
var crossTab = controller.ActivateFixedAction(exchange.Id);
Assert.Empty(crossTab.Playlist);
Assert.Equal(LegacyOperatorStatusKind.Error, crossTab.StatusKind);
var unavailable = LegacyFixedActionCatalog.Default.Actions
.First(action => !action.Available);
controller.SelectTab(unavailable.Market switch
{
LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas,
LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange,
LegacyFixedMarket.Index => LegacyOperatorTabId.Index,
_ => throw new InvalidOperationException()
});
var blocked = controller.ActivateFixedAction(unavailable.Id);
Assert.Empty(blocked.Playlist);
Assert.Equal(unavailable.Prerequisite, blocked.Dialog?.Message);
}
[Fact]
public void MovingAverageFlagsAreCapturedInFixedCandleSelection()
{
var controller = new LegacyOperatorController(Lookup());
var candle = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Index)
.First(action => action.SupportsMovingAverages);
controller.SelectTab(LegacyOperatorTabId.Index);
controller.SetMovingAverages(ma5: false, ma20: true);
var state = controller.ActivateFixedAction(candle.Id);
Assert.Equal(
"MA20",
Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
Assert.Single(state.Playlist).ToPlayoutEntry().Selection).GraphicType);
Assert.False(state.MovingAverages.Ma5);
Assert.True(state.MovingAverages.Ma20);
}
[Fact]
public async Task EmptyButtonIsNoOpButEmptyEnterSearches()
{
@@ -191,6 +489,167 @@ public sealed class LegacyOperatorControllerTests
Assert.Single(state.Playlist);
}
[Fact]
public async Task PlaylistPlainControlAndControlShiftSelectionMatchMainFormRules()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5);
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
controller.SelectPlaylistRow(ids[1], control: false, shift: false);
controller.SelectPlaylistRow(ids[3], control: true, shift: false);
controller.SelectPlaylistRow(ids[3], control: true, shift: false);
var state = controller.SelectPlaylistRow(ids[4], control: true, shift: true);
Assert.Equal(
[ids[1], ids[3], ids[4]],
state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
}
[Fact]
public async Task PlaylistShiftUsesPreviousActiveRowAndMovesItsRangeAnchor()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5);
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
controller.SelectPlaylistRow(ids[1], control: false, shift: false);
var firstRange = controller.SelectPlaylistRow(ids[3], control: false, shift: true);
Assert.Equal(
ids[1..4],
firstRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
var movedRange = controller.SelectPlaylistRow(ids[4], control: false, shift: true);
Assert.Equal(
ids[3..5],
movedRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
}
[Fact]
public async Task FirstPlaylistShiftStartsAtOriginalZeroCursor()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
var state = controller.SelectPlaylistRow(ids[2], control: false, shift: true);
Assert.All(state.Playlist, row => Assert.True(row.IsSelected));
}
[Fact]
public async Task PlaylistEnabledSupportsIndividualToggleAndAllRows()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
controller.SetPlaylistRowEnabled(ids[0], false);
var toggled = controller.TogglePlaylistRowEnabled(ids[1]);
Assert.False(toggled.Playlist[0].IsEnabled);
Assert.False(toggled.Playlist[1].IsEnabled);
Assert.True(toggled.Playlist[2].IsEnabled);
var disabled = controller.SetAllPlaylistRowsEnabled(false);
Assert.All(disabled.Playlist, row => Assert.False(row.IsEnabled));
var enabled = controller.SetAllPlaylistRowsEnabled(true);
Assert.All(enabled.Playlist, row => Assert.True(row.IsEnabled));
}
[Fact]
public async Task DeleteSelectedRowsPreservesRemainingPhysicalOrderAndClearsSelection()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
var initial = controller.Current.Playlist.ToArray();
controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false);
controller.SelectPlaylistRow(initial[3].RowId, control: true, shift: false);
var state = controller.DeleteSelectedPlaylistRows();
Assert.Equal(
[initial[0].RowId, initial[2].RowId],
state.Playlist.Select(row => row.RowId));
Assert.DoesNotContain(state.Playlist, row => row.IsSelected);
}
[Fact]
public async Task DeleteAllRowsClearsPlaylistAndSelectionState()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2);
controller.SelectPlaylistRow(
controller.Current.Playlist[1].RowId,
control: false,
shift: false);
var empty = controller.DeleteAllPlaylistRows();
Assert.Empty(empty.Playlist);
var unchangedRevision = empty.Revision;
var stillEmpty = controller.DeleteAllPlaylistRows();
Assert.Equal(unchangedRevision, stillEmpty.Revision);
}
[Fact]
public async Task MoveUsesOnlyActiveRowAndCollapsesMultiSelectionToMovedRow()
{
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
var initial = controller.Current.Playlist.ToArray();
controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false);
controller.SelectPlaylistRow(initial[2].RowId, control: true, shift: false);
var moved = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up);
Assert.Equal(
[initial[0].RowId, initial[2].RowId, initial[1].RowId, initial[3].RowId],
moved.Playlist.Select(row => row.RowId));
Assert.Equal(
initial[2].RowId,
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
var restored = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Down);
Assert.Equal(initial.Select(row => row.RowId), restored.Playlist.Select(row => row.RowId));
Assert.Equal(
initial[2].RowId,
Assert.Single(restored.Playlist, row => row.IsSelected).RowId);
}
[Fact]
public async Task MoveAtBoundaryIsNoOp()
{
var controller = await ControllerWithPlaylistAsync(0, 1);
var first = controller.Current.Playlist[0];
controller.SelectPlaylistRow(first.RowId, control: false, shift: false);
var before = controller.Current;
var after = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up);
Assert.Equal(before.Revision, after.Revision);
Assert.Equal(
before.Playlist.Select(row => row.RowId),
after.Playlist.Select(row => row.RowId));
}
private static async Task<LegacyOperatorController> ControllerWithPlaylistAsync(
params int[] physicalCutRows)
{
var controller = new LegacyOperatorController(Lookup());
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
controller.SelectStock(0);
var timestamp = 1_000L;
foreach (var physicalCutRow in physicalCutRows)
{
controller.CutPointerDown(
physicalCutRow,
control: false,
shift: false,
x: 1,
y: physicalCutRow * 23,
timestampMilliseconds: timestamp);
controller.DropSelectedCutsOnPlaylist();
timestamp += 1_000;
}
return controller;
}
private static FakeLookup Lookup() => new(new LegacyStockSearchData(
"query",
["삼성전자", "넥스트(NXT)"],
@@ -218,4 +677,104 @@ public sealed class LegacyOperatorControllerTests
return Task.FromResult(_result);
}
}
private sealed class StaticIndustryService : IIndustrySelectionService
{
private readonly IReadOnlyList<IndustrySelection> _rows;
public StaticIndustryService(IReadOnlyList<IndustrySelection> rows)
{
_rows = rows;
}
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default) => Task.FromResult(_rows);
}
private sealed class StaticThemeService : IThemeSelectionService
{
private readonly ThemeSelectionIdentity _identity;
private readonly IReadOnlyList<ThemeItemPreview> _items;
public StaticThemeService(
ThemeSelectionIdentity identity,
IReadOnlyList<ThemeItemPreview> items)
{
_identity = identity;
_items = items;
}
public int PreviewCalls { get; private set; }
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) => Task.FromResult(
new ThemeSearchResult(
query,
nxtSession,
DateTimeOffset.UtcNow,
[new ThemeSelectorItem(_identity, DataSourceKind.Oracle)],
false));
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default)
{
PreviewCalls++;
return Task.FromResult(
new ThemePreviewResult(
_identity,
DateTimeOffset.UtcNow,
_items,
false));
}
}
private sealed class StaticExpertService : IExpertSelectionService
{
private static readonly ExpertSelectionIdentity Identity = new("0001", "전문가A");
public Task<ExpertSearchResult> SearchAsync(
string query = "",
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) => Task.FromResult(
new ExpertSearchResult(
query.Trim(),
DateTimeOffset.UtcNow,
[new ExpertSelectorItem(Identity, DataSourceKind.Oracle)],
false));
public Task<ExpertPreviewResult> PreviewAsync(
ExpertSelectionIdentity identity,
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) => Task.FromResult(
new ExpertPreviewResult(
Identity,
DateTimeOffset.UtcNow,
Enumerable.Range(1, 6)
.Select(index => new ExpertRecommendationPreview(
index,
index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
$"추천{index}",
10_000m + index))
.ToArray(),
false));
}
private sealed class StaticTradingHaltService : ITradingHaltSelectionService
{
public Task<TradingHaltSelectionResult> SearchAsync(
string query = "",
int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) => Task.FromResult(
new TradingHaltSelectionResult(
query.Trim(),
DateTimeOffset.UtcNow,
[new TradingHaltSelection(TradingHaltMarket.Kospi, "005930", "삼성전자")],
false));
}
}

View File

@@ -0,0 +1,67 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorPlayoutRequestTests
{
[Fact]
public void EmptyPlaylistCannotCreatePlayoutRequest()
{
var controller = new LegacyOperatorController(new StaticLookup());
Assert.Throws<InvalidOperationException>(controller.CreatePlayoutRequest);
}
[Fact]
public async Task RequestPreservesNativePlaylistIdentityEnabledStateAndSelection()
{
var controller = new LegacyOperatorController(new StaticLookup());
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
controller.SelectStock(0);
AddCut(controller, physicalIndex: 0, timestamp: 1_000);
AddCut(controller, physicalIndex: 1, timestamp: 2_000);
var rows = controller.Current.Playlist;
controller.SetPlaylistRowEnabled(rows[0].RowId, enabled: false);
controller.SelectPlaylistRow(rows[1].RowId, control: false, shift: false);
var request = controller.CreatePlayoutRequest();
Assert.Equal(1, request.SelectedIndexZeroBased);
Assert.Equal(2, request.Playlist.Count);
Assert.Equal(rows.Select(row => row.RowId),
request.Playlist.Select(entry => entry.EntryId));
Assert.False(request.Playlist[0].IsEnabled);
Assert.True(request.Playlist[1].IsEnabled);
Assert.All(request.Playlist, entry =>
Assert.Equal("005930", entry.Selection?.DataCode));
}
private static void AddCut(
LegacyOperatorController controller,
int physicalIndex,
long timestamp)
{
controller.CutPointerDown(
physicalIndex,
control: false,
shift: false,
x: 1,
y: physicalIndex * 23,
timestampMilliseconds: timestamp);
controller.DropSelectedCutsOnPlaylist();
}
private sealed class StaticLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
query,
["삼성전자"],
[new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"삼성전자",
"005930")]));
}
}

View File

@@ -0,0 +1,154 @@
using System.Collections;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOverseasActionCatalogTests
{
private static readonly string[] ExpectedFixedNames =
[
"다우존스",
"나스닥",
"S&P500",
"독일 DAX 30",
"영국 FTSE 100",
"프랑스 CAC 40",
"일본 니케이",
"중국 상해종합",
"홍콩 항셍",
"대만 가권"
];
private static readonly string[] ExpectedSymbols =
[
"DJI@DJI",
"NAS@IXIC",
"SPI@SPX",
"XTR@DAX30",
"LNS@FTSE100",
"PAS@CAC40",
"NII@NI225",
"SHS@000001",
"HSI@HSI",
"TWS@TI01"
];
private static readonly string[] ExpectedActionIds =
[
"index-candle-5d",
"index-candle-20d",
"index-candle-60d",
"index-candle-120d",
"index-candle-240d",
"industry-current",
"stock-current",
"stock-candle-5d",
"stock-candle-20d",
"stock-candle-60d",
"stock-candle-120d",
"stock-candle-240d"
];
[Fact]
public void FixedIndexListMatchesUc5OrderAndClosedIdentity()
{
var targets = LegacyOverseasActionCatalog.FixedIndexTargets;
Assert.Equal(LegacyOverseasActionCatalog.FixedIndexTargetCount, targets.Count);
Assert.Equal(ExpectedFixedNames, targets.Select(target => target.DisplayName));
Assert.Equal(ExpectedFixedNames, targets.Select(target => target.InputName));
Assert.Equal(ExpectedSymbols, targets.Select(target => target.Symbol));
Assert.Equal(
["US", "US", "US", "DE", "GB", "FR", "JP", "CN", "HK", "TW"],
targets.Select(target => target.NationCode));
Assert.All(targets, target => Assert.Equal("0", target.ForeignDomesticCode));
Assert.Equal(
targets.Count,
targets.Select(target => target.TargetId).Distinct(StringComparer.Ordinal).Count());
var list = Assert.IsAssignableFrom<IList<LegacyOverseasFixedIndexTarget>>(targets);
Assert.True(list.IsReadOnly);
Assert.Throws<NotSupportedException>(() => list.RemoveAt(0));
}
[Fact]
public void ActionsMatchUc5ButtonOrderAliasesAndPaging()
{
var actions = LegacyOverseasActionCatalog.Actions;
Assert.Equal(LegacyOverseasActionCatalog.ActionCount, actions.Count);
Assert.Equal(ExpectedActionIds, actions.Select(action => action.ActionId));
Assert.Equal(
["8061", "8040", "8046", "8051", "8056"],
actions.Take(5).Select(action => action.SceneAlias));
Assert.Equal("5001", actions[5].SceneAlias);
Assert.Equal("5001", actions[6].SceneAlias);
Assert.Equal(
["8061", "8040", "8046", "8051", "8056"],
actions.Skip(7).Select(action => action.SceneAlias));
Assert.All(actions, action =>
{
Assert.Equal(0, action.PageSize);
Assert.Equal([action.SceneAlias], action.Aliases);
Assert.Contains(
LegacySceneCatalog.FindByCutAlias(action.SceneAlias),
scene => scene.BuilderKey == action.BuilderKey && (int)scene.PageSize == 0);
});
Assert.Equal(5, actions.Count(action => action.Source == LegacyOverseasActionSource.FixedIndex));
Assert.Single(actions, action => action.Source == LegacyOverseasActionSource.Industry);
Assert.Equal(6, actions.Count(action => action.Source == LegacyOverseasActionSource.Stock));
Assert.Equal(10, actions.Count(action => action.SupportsMovingAverages));
var list = Assert.IsAssignableFrom<IList<LegacyOverseasActionDefinition>>(actions);
Assert.True(list.IsReadOnly);
Assert.Throws<NotSupportedException>(() => list.RemoveAt(0));
var aliases = Assert.IsAssignableFrom<IList<string>>(actions[0].Aliases);
Assert.True(aliases.IsReadOnly);
}
[Theory]
[InlineData("index-candle-5d", LegacyOverseasActionSource.FixedIndex, 5, "s8010", "8061")]
[InlineData("index-candle-240d", LegacyOverseasActionSource.FixedIndex, 240, "s8010", "8056")]
[InlineData("industry-current", LegacyOverseasActionSource.Industry, null, "s5001", "5001")]
[InlineData("stock-current", LegacyOverseasActionSource.Stock, null, "s5001", "5001")]
[InlineData("stock-candle-120d", LegacyOverseasActionSource.Stock, 120, "s8010", "8051")]
public void LookupReturnsExactClosedDefinition(
string actionId,
LegacyOverseasActionSource source,
int? periodDays,
string builderKey,
string sceneAlias)
{
var action = LegacyOverseasActionCatalog.GetAction(actionId);
Assert.Same(action, LegacyOverseasActionCatalog.FindAction(actionId));
Assert.Equal(source, action.Source);
Assert.Equal(periodDays, action.PeriodDays);
Assert.Equal(builderKey, action.BuilderKey);
Assert.Equal(sceneAlias, action.SceneAlias);
}
[Fact]
public void SourceViewsAndUnknownValuesFailClosed()
{
Assert.Equal(
5,
LegacyOverseasActionCatalog.GetActions(LegacyOverseasActionSource.FixedIndex).Count);
Assert.Single(LegacyOverseasActionCatalog.GetActions(LegacyOverseasActionSource.Industry));
Assert.Equal(
6,
LegacyOverseasActionCatalog.GetActions(LegacyOverseasActionSource.Stock).Count);
Assert.Equal(
"독일 DAX 30",
LegacyOverseasActionCatalog.GetFixedIndexTarget("germany-dax-30").DisplayName);
Assert.Null(LegacyOverseasActionCatalog.FindAction("../8010"));
Assert.Null(LegacyOverseasActionCatalog.FindFixedIndexTarget("DOW-JONES"));
Assert.Throws<KeyNotFoundException>(() =>
LegacyOverseasActionCatalog.GetAction("../8010"));
Assert.Throws<KeyNotFoundException>(() =>
LegacyOverseasActionCatalog.GetFixedIndexTarget("DOW-JONES"));
Assert.Throws<ArgumentOutOfRangeException>(() =>
LegacyOverseasActionCatalog.GetActions((LegacyOverseasActionSource)99));
}
}

View File

@@ -0,0 +1,158 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOverseasOperatorControllerTests
{
private static readonly DateTimeOffset RetrievedAt =
new(2026, 7, 15, 10, 0, 0, TimeSpan.FromHours(9));
[Fact]
public void OverseasSnapshotAndFixedIndexActionRemainControllerAuthoritative()
{
var controller = CreateController();
var selectedTab = controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
Assert.NotNull(selectedTab.Overseas);
Assert.Equal(10, selectedTab.Overseas.FixedIndexTargets.Count);
Assert.Equal(12, selectedTab.Overseas.Actions.Count);
Assert.Equal(new LegacyMovingAverageSelection(true, true), selectedTab.MovingAverages);
Assert.Equal(
new LegacyOverseasMovingAverageSelection(true, true),
selectedTab.Overseas.MovingAverages);
controller.SelectOverseasFixedIndex("nasdaq");
controller.SetMovingAverages(ma5: false, ma20: true);
var state = controller.ActivateOverseasAction("index-candle-20d");
var row = Assert.Single(state.Playlist);
var entry = row.ToPlayoutEntry();
var selection = Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
entry.Selection);
Assert.Equal("legacy-overseas-00000001", row.RowId);
Assert.Equal("FOREIGN_INDEX", row.MarketText);
Assert.Equal("나스닥 캔들 20일", row.StockName);
Assert.Equal("MA20", row.GraphicType);
Assert.Equal("PRICE", row.Subtype);
Assert.Equal("1/1", row.PageText);
Assert.Equal("8040", entry.CutCode);
Assert.Equal("FOREIGN_INDEX", selection.GroupCode);
Assert.Equal("나스닥", selection.Subject);
Assert.Equal("NAS@IXIC|US|0", selection.DataCode);
}
[Fact]
public async Task OverseasIndustryAndStockSearchSelectionsMaterializeExactPlaylistRows()
{
var controller = CreateController(
industries: [new("반도체", "US Semiconductor", "NAS@SOX")],
stocks: [new("Apple", "NAS@AAPL", "US")]);
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
var industries = await controller.SearchOverseasIndustriesAsync("반도체");
var industryId = Assert.Single(industries.Overseas!.Industry.Results).SelectionId;
controller.SelectOverseasIndustry(industryId);
var industryState = controller.ActivateOverseasAction("industry-current");
var industryRow = Assert.Single(industryState.Playlist);
Assert.Equal("5001", industryRow.ToPlayoutEntry().CutCode);
Assert.Equal("FOREIGN_INDUSTRY", industryRow.PlayoutSelection!.GroupCode);
Assert.Equal("반도체", industryRow.PlayoutSelection.Subject);
Assert.Equal("1열판기본", industryRow.PlayoutSelection.GraphicType);
Assert.Equal("CURRENT", industryRow.PlayoutSelection.Subtype);
Assert.Equal("US Semiconductor|NAS@SOX|US|0", industryRow.PlayoutSelection.DataCode);
var stocks = await controller.SearchOverseasStocksAsync("apple");
var stockId = Assert.Single(stocks.Overseas!.Stock.Results).SelectionId;
controller.SelectOverseasStock(stockId);
var stockState = controller.ActivateOverseasAction("stock-candle-5d");
Assert.Equal(2, stockState.Playlist.Count);
var stockRow = stockState.Playlist[1];
Assert.Equal("8061", stockRow.ToPlayoutEntry().CutCode);
Assert.Equal("FOREIGN_STOCK", stockRow.PlayoutSelection!.GroupCode);
Assert.Equal("Apple", stockRow.PlayoutSelection.Subject);
Assert.Equal("MA5,MA20", stockRow.PlayoutSelection.GraphicType);
Assert.Equal("PRICE", stockRow.PlayoutSelection.Subtype);
Assert.Equal("NAS@AAPL|US|1", stockRow.PlayoutSelection.DataCode);
}
[Fact]
public async Task OverseasActionsRejectInactiveTabAndStaleSearchIds()
{
var controller = CreateController(
industries: [new("반도체", "US Semiconductor", "NAS@SOX")]);
var inactive = controller.SelectOverseasFixedIndex("nasdaq");
Assert.Equal(LegacyOperatorStatusKind.Error, inactive.StatusKind);
Assert.Null(inactive.Overseas);
controller.SelectTab(LegacyOperatorTabId.OverseasStocks);
var first = await controller.SearchOverseasIndustriesAsync("");
var staleId = Assert.Single(first.Overseas!.Industry.Results).SelectionId;
await controller.SearchOverseasIndustriesAsync("new generation");
var stale = controller.SelectOverseasIndustry(staleId);
Assert.Equal(LegacyOperatorStatusKind.Error, stale.StatusKind);
Assert.Null(stale.Overseas!.Industry.Selected);
}
private static LegacyOperatorController CreateController(
IReadOnlyList<OverseasIndustryIndexItem>? industries = null,
IReadOnlyList<WorldStockSearchItem>? stocks = null)
{
var workflow = new LegacyOverseasSelectionWorkflow(
new StaticIndustryService(industries ?? []),
new StaticStockService(stocks ?? []));
return new LegacyOperatorController(
new EmptyStockLookup(),
overseasWorkflow: workflow);
}
private sealed class EmptyStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new LegacyStockSearchData(query, [], []));
}
}
private sealed class StaticIndustryService(
IReadOnlyList<OverseasIndustryIndexItem> items) : IOverseasIndustryIndexSearchService
{
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new OverseasIndustryIndexSearchResult(
query.Trim(),
RetrievedAt,
items,
IsTruncated: false));
}
}
private sealed class StaticStockService(
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
{
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new WorldStockSearchResult(
query.Trim(),
RetrievedAt,
items,
IsTruncated: false));
}
}
}

View File

@@ -0,0 +1,559 @@
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOverseasSelectionWorkflowTests
{
private static readonly DateTimeOffset RetrievedAt =
new(2026, 7, 15, 9, 30, 0, TimeSpan.FromHours(9));
[Fact]
public void InitialSnapshotIsTypedImmutableAndContainsClosedCatalogs()
{
var workflow = CreateWorkflow();
var snapshot = workflow.Current;
Assert.Equal(0, snapshot.Revision);
Assert.Equal(10, snapshot.FixedIndexTargets.Count);
Assert.Equal(12, snapshot.Actions.Count);
Assert.Null(snapshot.SelectedFixedIndex);
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Industry.Status);
Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Stock.Status);
Assert.Empty(snapshot.Industry.Results);
Assert.Empty(snapshot.Stock.Results);
Assert.Equal(default, snapshot.MovingAverages);
var targets = Assert.IsAssignableFrom<IList<LegacyOverseasFixedIndexTarget>>(
snapshot.FixedIndexTargets);
var actions = Assert.IsAssignableFrom<IList<LegacyOverseasActionDefinition>>(
snapshot.Actions);
Assert.True(targets.IsReadOnly);
Assert.True(actions.IsReadOnly);
Assert.Throws<NotSupportedException>(() => targets.RemoveAt(0));
Assert.Throws<NotSupportedException>(() => actions.RemoveAt(0));
}
[Fact]
public async Task SearchesUseInjectedInterfacesNormalizeQueriesAndIssueGenerationBoundIds()
{
var industryItems = new List<OverseasIndustryIndexItem>
{
new("Alpha", "Alpha Industry", "NAS@ALPHA"),
new("Beta", "Beta Industry", "NAS@BETA")
};
var stockItems = new List<WorldStockSearchItem>
{
new("Apple", "NAS@AAPL", "US"),
new("Taiwan Semi", "TWS@2330", "TW")
};
var industry = new RecordingIndustryService(industryItems);
var stock = new RecordingStockService(stockItems);
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
var industryState = await workflow.SearchIndustriesAsync(" alpha ", 25);
var stockState = await workflow.SearchStocksAsync(" semi ", 30);
industryItems.Clear();
stockItems.Clear();
Assert.Equal([("alpha", 25)], industry.Calls);
Assert.Equal([("semi", 30)], stock.Calls);
Assert.Equal(LegacyOverseasSearchStatus.Ready, industryState.Industry.Status);
Assert.Equal("alpha", industryState.Industry.Query);
Assert.Equal(RetrievedAt, industryState.Industry.RetrievedAt);
Assert.Equal(
["overseas-industry-00000001-0001", "overseas-industry-00000001-0002"],
industryState.Industry.Results.Select(row => row.SelectionId));
Assert.Equal(
["overseas-stock-00000001-0001", "overseas-stock-00000001-0002"],
stockState.Stock.Results.Select(row => row.SelectionId));
Assert.Equal(2, workflow.Current.Industry.ResultCount);
Assert.Equal(2, workflow.Current.Stock.ResultCount);
var industryRows = Assert.IsAssignableFrom<IList<LegacyOverseasIndustrySearchRow>>(
workflow.Current.Industry.Results);
var stockRows = Assert.IsAssignableFrom<IList<LegacyOverseasStockSearchRow>>(
workflow.Current.Stock.Results);
Assert.True(industryRows.IsReadOnly);
Assert.True(stockRows.IsReadOnly);
Assert.Throws<NotSupportedException>(() => industryRows.RemoveAt(0));
Assert.Throws<NotSupportedException>(() => stockRows.RemoveAt(0));
}
[Fact]
public async Task SelectionUsesCurrentOpaqueIdsAndARefreshInvalidatesOldIds()
{
var industry = new QueueIndustryService(
IndustryResult(
"",
new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")),
IndustryResult(
"next",
new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA")));
var stock = new QueueStockService(
StockResult("", new WorldStockSearchItem("Apple", "NAS@AAPL", "US")),
StockResult("next", new WorldStockSearchItem("Taiwan Semi", "TWS@2330", "TW")));
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
var firstIndustry = await workflow.SearchIndustriesAsync("");
var oldIndustryId = firstIndustry.Industry.Results[0].SelectionId;
Assert.Equal(
"Alpha",
workflow.SelectIndustry(oldIndustryId).Industry.Selected?.KoreanName);
var firstStock = await workflow.SearchStocksAsync("");
var oldStockId = firstStock.Stock.Results[0].SelectionId;
Assert.Equal("Apple", workflow.SelectStock(oldStockId).Stock.Selected?.InputName);
await workflow.SearchIndustriesAsync("next");
await workflow.SearchStocksAsync("next");
Assert.Null(workflow.Current.Industry.Selected);
Assert.Null(workflow.Current.Stock.Selected);
Assert.Throws<KeyNotFoundException>(() => workflow.SelectIndustry(oldIndustryId));
Assert.Throws<KeyNotFoundException>(() => workflow.SelectStock(oldStockId));
Assert.StartsWith(
"overseas-industry-00000002-",
workflow.Current.Industry.Results[0].SelectionId,
StringComparison.Ordinal);
Assert.StartsWith(
"overseas-stock-00000002-",
workflow.Current.Stock.Results[0].SelectionId,
StringComparison.Ordinal);
}
[Fact]
public void FixedIndexActionsMaterializeExactAliasIdentityPageAndMovingAverages()
{
var workflow = CreateWorkflow();
workflow.SelectFixedIndex("germany-dax-30");
workflow.SetMovingAverages(ma5: true, ma20: false);
var expected = new Dictionary<string, (string Alias, int Days)>(StringComparer.Ordinal)
{
["index-candle-5d"] = ("8061", 5),
["index-candle-20d"] = ("8040", 20),
["index-candle-60d"] = ("8046", 60),
["index-candle-120d"] = ("8051", 120),
["index-candle-240d"] = ("8056", 240)
};
foreach (var pair in expected)
{
var draft = workflow.MaterializePlaylistDraft(pair.Key);
Assert.Equal("s8010", draft.BuilderKey);
Assert.Equal(pair.Value.Alias, draft.SceneAlias);
Assert.Equal([pair.Value.Alias], draft.Aliases);
Assert.Equal(0, draft.PageSize);
Assert.Equal("1/1", draft.InitialPageText);
Assert.Equal($"독일 DAX 30 캔들 {pair.Value.Days}일", draft.Title);
Assert.Equal("chart", draft.Category);
Assert.Equal("overseas", draft.Market);
Assert.Equal(LegacyOverseasActionSource.FixedIndex, draft.Source);
Assert.Equal(
new LegacyOverseasPlaylistSelection(
"FOREIGN_INDEX",
"독일 DAX 30",
"MA5",
"PRICE",
"XTR@DAX30|DE|0"),
draft.Selection);
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, false), draft.MovingAverages);
Assert.True(draft.IsEnabled);
Assert.Equal(6, draft.FadeDuration);
}
}
[Fact]
public async Task IndustryCurrentMaterializesRootWorkflowSelectionAndIgnoresMaOptions()
{
var workflow = CreateWorkflow(
industries: [new("반도체", "US Semiconductor", "NAS@SOX")]);
var searched = await workflow.SearchIndustriesAsync("");
workflow.SelectIndustry(searched.Industry.Results[0].SelectionId);
workflow.SetMovingAverages(ma5: true, ma20: true);
var draft = workflow.MaterializePlaylistDraft("industry-current");
Assert.Equal("s5001", draft.BuilderKey);
Assert.Equal("5001", draft.SceneAlias);
Assert.Equal(0, draft.PageSize);
Assert.Equal("1/1", draft.InitialPageText);
Assert.Equal("반도체", draft.Title);
Assert.Equal("plate", draft.Category);
Assert.Equal(
new LegacyOverseasPlaylistSelection(
"FOREIGN_INDUSTRY",
"반도체",
"1열판기본",
"CURRENT",
"US Semiconductor|NAS@SOX|US|0"),
draft.Selection);
Assert.Equal(default, draft.MovingAverages);
}
[Fact]
public async Task StockCurrentAndAllCandleActionsUseSelectedUsTwIdentity()
{
var workflow = CreateWorkflow(
stocks: [new("Taiwan Semi", "TWS@2330", "TW")]);
var searched = await workflow.SearchStocksAsync("semi");
workflow.SelectStock(searched.Stock.Results[0].SelectionId);
workflow.SetMovingAverages(ma5: true, ma20: true);
var current = workflow.MaterializePlaylistDraft("stock-current");
Assert.Equal("s5001", current.BuilderKey);
Assert.Equal("5001", current.SceneAlias);
Assert.Equal("stock", current.Category);
Assert.Equal(
new LegacyOverseasPlaylistSelection(
"FOREIGN_STOCK",
"Taiwan Semi",
"1열판기본",
"CURRENT",
"TWS@2330|TW|1"),
current.Selection);
Assert.Equal(default, current.MovingAverages);
var expectedAliases = new Dictionary<string, string>(StringComparer.Ordinal)
{
["stock-candle-5d"] = "8061",
["stock-candle-20d"] = "8040",
["stock-candle-60d"] = "8046",
["stock-candle-120d"] = "8051",
["stock-candle-240d"] = "8056"
};
foreach (var pair in expectedAliases)
{
var candle = workflow.MaterializePlaylistDraft(pair.Key);
Assert.Equal("s8010", candle.BuilderKey);
Assert.Equal(pair.Value, candle.SceneAlias);
Assert.Equal("chart", candle.Category);
Assert.Equal("MA5,MA20", candle.Selection.GraphicType);
Assert.Equal("PRICE", candle.Selection.Subtype);
Assert.Equal("TWS@2330|TW|1", candle.Selection.DataCode);
Assert.Equal(new LegacyOverseasMovingAverageSelection(true, true), candle.MovingAverages);
}
}
[Theory]
[InlineData(false, false, "")]
[InlineData(true, false, "MA5")]
[InlineData(false, true, "MA20")]
[InlineData(true, true, "MA5,MA20")]
public async Task MovingAverageOptionsHaveExactClosedText(
bool ma5,
bool ma20,
string expected)
{
var workflow = CreateWorkflow(stocks: [new("Apple", "NAS@AAPL", "US")]);
var searched = await workflow.SearchStocksAsync("");
workflow.SelectStock(searched.Stock.Results[0].SelectionId);
workflow.SetMovingAverages(ma5, ma20);
var draft = workflow.MaterializePlaylistDraft("stock-candle-20d");
Assert.Equal(expected, draft.Selection.GraphicType);
Assert.Equal(new LegacyOverseasMovingAverageSelection(ma5, ma20), draft.MovingAverages);
}
[Fact]
public void ActionPrerequisitesUnknownIdsAndCallerOverridesFailClosed()
{
var workflow = CreateWorkflow();
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("index-candle-5d"));
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("industry-current"));
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("stock-current"));
Assert.Throws<LegacyOverseasWorkflowException>(() =>
workflow.MaterializePlaylistDraft("../../8010"));
Assert.Throws<ArgumentNullException>(() =>
workflow.MaterializePlaylistDraft(null!));
Assert.Throws<KeyNotFoundException>(() => workflow.SelectFixedIndex("DOW-JONES"));
var methods = typeof(LegacyOverseasSelectionWorkflow).GetMethods()
.Where(method => method.Name == nameof(LegacyOverseasSelectionWorkflow.MaterializePlaylistDraft));
var method = Assert.Single(methods);
var parameter = Assert.Single(method.GetParameters());
Assert.Equal(typeof(string), parameter.ParameterType);
}
[Fact]
public async Task SlowerIndustryResponseCannotReplaceOrInvalidateNewerState()
{
var industry = new ControlledIndustryService();
var workflow = new LegacyOverseasSelectionWorkflow(industry, new RecordingStockService([]));
var older = workflow.SearchIndustriesAsync("old");
var newer = workflow.SearchIndustriesAsync("new");
industry.Complete(
"new",
IndustryResult(
"new",
new OverseasIndustryIndexItem("New", "New Industry", "NAS@NEW")));
var newerState = await newer;
// The stale response is intentionally malformed. Generation gating must happen
// before its envelope and identities are examined.
industry.Complete(
"old",
new OverseasIndustryIndexSearchResult(
"wrong",
default,
[new OverseasIndustryIndexItem(" bad ", "bad", "|")],
false));
var olderState = await older;
Assert.Equal("new", newerState.Industry.Query);
Assert.Equal("new", olderState.Industry.Query);
Assert.Equal("New", workflow.Current.Industry.Results[0].KoreanName);
Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Industry.Status);
Assert.Equal(3, workflow.Current.Revision);
}
[Fact]
public async Task SlowerStockFaultCannotReplaceOrFaultNewerState()
{
var stock = new ControlledStockService();
var workflow = new LegacyOverseasSelectionWorkflow(new RecordingIndustryService([]), stock);
var older = workflow.SearchStocksAsync("old");
var newer = workflow.SearchStocksAsync("new");
stock.Complete(
"new",
StockResult("new", new WorldStockSearchItem("New Stock", "NAS@NEW", "US")));
await newer;
stock.Fail("old", new InvalidOperationException("stale provider failure"));
var olderState = await older;
Assert.Equal("new", olderState.Stock.Query);
Assert.Equal("New Stock", olderState.Stock.Results[0].InputName);
Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Stock.Status);
Assert.Equal(3, workflow.Current.Revision);
}
[Fact]
public async Task InvalidCurrentIndustryResponseFailsAtomicallyIntoErrorState()
{
var invalid = IndustryResult(
"query",
new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA"),
new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA"));
var workflow = new LegacyOverseasSelectionWorkflow(
new QueueIndustryService(invalid),
new RecordingStockService([]));
await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
workflow.SearchIndustriesAsync("query"));
Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Industry.Status);
Assert.Empty(workflow.Current.Industry.Results);
Assert.Null(workflow.Current.Industry.Selected);
Assert.NotEmpty(workflow.Current.Industry.Error);
Assert.Equal(2, workflow.Current.Revision);
}
[Fact]
public async Task InvalidCurrentStockIdentityFailsAtomicallyIntoErrorState()
{
var invalid = StockResult(
"query",
new WorldStockSearchItem("London", "LNS@BAD", "GB"));
var workflow = new LegacyOverseasSelectionWorkflow(
new RecordingIndustryService([]),
new QueueStockService(invalid));
await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
workflow.SearchStocksAsync("query"));
Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Stock.Status);
Assert.Empty(workflow.Current.Stock.Results);
Assert.Null(workflow.Current.Stock.Selected);
Assert.NotEmpty(workflow.Current.Stock.Error);
}
[Fact]
public async Task SearchBoundsUnsafeQueriesAndNullServicesAreRejectedBeforeCalls()
{
var industry = new RecordingIndustryService([]);
var stock = new RecordingStockService([]);
var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
workflow.SearchIndustriesAsync("", 0));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
workflow.SearchStocksAsync("", 501));
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SearchIndustriesAsync(new string('x', 65)));
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SearchStocksAsync("bad\u0001query"));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
workflow.SearchStocksAsync(null!));
Assert.Empty(industry.Calls);
Assert.Empty(stock.Calls);
Assert.Throws<ArgumentNullException>(() =>
new LegacyOverseasSelectionWorkflow(null!, stock));
Assert.Throws<ArgumentNullException>(() =>
new LegacyOverseasSelectionWorkflow(industry, null!));
}
[Fact]
public async Task ClearOperationsAndRepeatedMaChoiceHaveStableRevisionSemantics()
{
var workflow = CreateWorkflow(
industries: [new("Alpha", "Alpha Industry", "NAS@ALPHA")],
stocks: [new("Apple", "NAS@AAPL", "US")]);
workflow.SelectFixedIndex("dow-jones");
var industry = await workflow.SearchIndustriesAsync("");
workflow.SelectIndustry(industry.Industry.Results[0].SelectionId);
var stock = await workflow.SearchStocksAsync("");
workflow.SelectStock(stock.Stock.Results[0].SelectionId);
workflow.SetMovingAverages(true, false);
var beforeClear = workflow.Current.Revision;
workflow.ClearFixedIndexSelection();
workflow.ClearIndustrySelection();
workflow.ClearStockSelection();
var cleared = workflow.Current;
Assert.Equal(beforeClear + 3, cleared.Revision);
Assert.Null(cleared.SelectedFixedIndex);
Assert.Null(cleared.Industry.Selected);
Assert.Null(cleared.Stock.Selected);
var stable = workflow.SetMovingAverages(true, false);
Assert.Equal(cleared.Revision, stable.Revision);
}
private static LegacyOverseasSelectionWorkflow CreateWorkflow(
IReadOnlyList<OverseasIndustryIndexItem>? industries = null,
IReadOnlyList<WorldStockSearchItem>? stocks = null) =>
new(
new RecordingIndustryService(industries ?? []),
new RecordingStockService(stocks ?? []));
private static OverseasIndustryIndexSearchResult IndustryResult(
string query,
params OverseasIndustryIndexItem[] items) =>
new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);
private static WorldStockSearchResult StockResult(
string query,
params WorldStockSearchItem[] items) =>
new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);
private sealed class RecordingIndustryService(
IReadOnlyList<OverseasIndustryIndexItem> items) : IOverseasIndustryIndexSearchService
{
public List<(string Query, int MaximumResults)> Calls { get; } = [];
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add((query, maximumResults));
return Task.FromResult(new OverseasIndustryIndexSearchResult(
query,
RetrievedAt,
items,
IsTruncated: false));
}
}
private sealed class RecordingStockService(
IReadOnlyList<WorldStockSearchItem> items) : IWorldStockSearchService
{
public List<(string Query, int MaximumResults)> Calls { get; } = [];
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add((query, maximumResults));
return Task.FromResult(new WorldStockSearchResult(
query,
RetrievedAt,
items,
IsTruncated: false));
}
}
private sealed class QueueIndustryService(
params OverseasIndustryIndexSearchResult[] results) : IOverseasIndustryIndexSearchService
{
private readonly Queue<OverseasIndustryIndexSearchResult> _results = new(results);
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_results.Dequeue());
}
}
private sealed class QueueStockService(
params WorldStockSearchResult[] results) : IWorldStockSearchService
{
private readonly Queue<WorldStockSearchResult> _results = new(results);
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_results.Dequeue());
}
}
private sealed class ControlledIndustryService : IOverseasIndustryIndexSearchService
{
private readonly Dictionary<string, TaskCompletionSource<OverseasIndustryIndexSearchResult>>
_pending = new(StringComparer.Ordinal);
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
string query,
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var source = new TaskCompletionSource<OverseasIndustryIndexSearchResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending.Add(query, source);
return source.Task.WaitAsync(cancellationToken);
}
public void Complete(string query, OverseasIndustryIndexSearchResult result) =>
_pending[query].SetResult(result);
}
private sealed class ControlledStockService : IWorldStockSearchService
{
private readonly Dictionary<string, TaskCompletionSource<WorldStockSearchResult>> _pending =
new(StringComparer.Ordinal);
public Task<WorldStockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var source = new TaskCompletionSource<WorldStockSearchResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending.Add(query, source);
return source.Task.WaitAsync(cancellationToken);
}
public void Complete(string query, WorldStockSearchResult result) =>
_pending[query].SetResult(result);
public void Fail(string query, Exception exception) =>
_pending[query].SetException(exception);
}
}

View File

@@ -0,0 +1,216 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyTabActivationParityTests
{
[Fact]
public async Task ThemeEachActivation_LoadsOriginalEmptyQueryOncePerActivation()
{
// Original evidence: Control/UC4.cs UC4_Load starts timer1 and its first
// timer1_Tick calls GetThemeByKeyword with the initially empty txts value.
var service = new CountingThemeService();
var controller = new LegacyOperatorController(
Lookup(),
themeWorkflow: new LegacyThemeWorkflow(service));
var first = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
Assert.Equal(string.Empty, service.Queries.Single());
Assert.Single(first.Theme!.SearchResults);
await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
Assert.Single(service.Queries);
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var second = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
Assert.Collection(
service.Queries,
query => Assert.Equal(string.Empty, query),
query => Assert.Equal(string.Empty, query));
Assert.Single(second.Theme!.SearchResults);
}
[Fact]
public async Task ExpertEachActivation_LoadsOriginalFullListOncePerActivation()
{
// Original evidence: Control/UC6.cs UC6_Load calls Data_Load, whose query
// reads the complete EXPERT_LIST without a filter.
var service = new CountingExpertService();
var controller = new LegacyOperatorController(
Lookup(),
expertWorkflow: new LegacyExpertWorkflowController(service));
var first = await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
Assert.Equal(string.Empty, service.Queries.Single());
Assert.Single(first.Expert!.Search.Results);
await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
Assert.Single(service.Queries);
await controller.SelectTabAsync(LegacyOperatorTabId.Index);
var second = await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
Assert.Collection(
service.Queries,
query => Assert.Equal(string.Empty, query),
query => Assert.Equal(string.Empty, query));
Assert.Single(second.Expert!.Search.Results);
}
[Fact]
public async Task DragSwapThatActivatesTheme_UsesTheSameNativeInitialLoad()
{
var service = new CountingThemeService();
var controller = new LegacyOperatorController(
Lookup(),
themeWorkflow: new LegacyThemeWorkflow(service));
var state = await controller.SwapTabsAsync(
LegacyOperatorTabId.Theme,
LegacyOperatorTabId.Exchange);
Assert.Equal(string.Empty, service.Queries.Single());
Assert.Single(state.Theme!.SearchResults);
Assert.Equal(
LegacyOperatorTabId.Theme,
Assert.Single(state.Tabs!, tab => tab.IsActive).Id);
}
[Fact]
public async Task SupersededThemeActivation_DoesNotCommitAndRetriesWhenNeeded()
{
var service = new DelayedThemeService();
var controller = new LegacyOperatorController(
Lookup(),
themeWorkflow: new LegacyThemeWorkflow(service));
var firstActivation = controller.SelectTabAsync(LegacyOperatorTabId.Theme);
await service.FirstRequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
controller.SelectTab(LegacyOperatorTabId.Index);
service.ReleaseFirstRequest.TrySetResult();
var superseded = await firstActivation;
Assert.Equal(
LegacyOperatorTabId.Index,
Assert.Single(superseded.Tabs!, tab => tab.IsActive).Id);
var retried = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
Assert.Equal(2, service.CallCount);
Assert.Single(retried.Theme!.SearchResults);
}
private static ILegacyStockLookup Lookup() =>
new StaticLookup(new LegacyStockSearchData(string.Empty, [], []));
private sealed class StaticLookup : ILegacyStockLookup
{
private readonly LegacyStockSearchData _data;
public StaticLookup(LegacyStockSearchData data)
{
_data = data;
}
public Task<LegacyStockSearchData> SearchAsync(
string text,
CancellationToken cancellationToken = default) => Task.FromResult(_data);
}
private sealed class CountingThemeService : IThemeSelectionService
{
private static readonly ThemeSelectionIdentity Identity = new(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"Theme A");
public List<string> Queries { get; } = [];
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
Queries.Add(query);
return Task.FromResult(CreateThemeResult(query, nxtSession));
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) => Task.FromResult(
new ThemePreviewResult(identity, DateTimeOffset.UtcNow, [], false));
public static ThemeSearchResult CreateThemeResult(
string query,
ThemeSession nxtSession) => new(
query,
nxtSession,
DateTimeOffset.UtcNow,
[new ThemeSelectorItem(Identity, DataSourceKind.Oracle)],
false);
}
private sealed class DelayedThemeService : IThemeSelectionService
{
private int _callCount;
public TaskCompletionSource FirstRequestStarted { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource ReleaseFirstRequest { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public int CallCount => Volatile.Read(ref _callCount);
public async Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref _callCount) == 1)
{
FirstRequestStarted.TrySetResult();
await ReleaseFirstRequest.Task.WaitAsync(cancellationToken);
}
return CountingThemeService.CreateThemeResult(query, nxtSession);
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) => Task.FromResult(
new ThemePreviewResult(identity, DateTimeOffset.UtcNow, [], false));
}
private sealed class CountingExpertService : IExpertSelectionService
{
private static readonly ExpertSelectionIdentity Identity = new("0001", "Expert A");
public List<string> Queries { get; } = [];
public Task<ExpertSearchResult> SearchAsync(
string query = "",
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
Queries.Add(query);
return Task.FromResult(new ExpertSearchResult(
query,
DateTimeOffset.UtcNow,
[new ExpertSelectorItem(Identity, DataSourceKind.Oracle)],
false));
}
public Task<ExpertPreviewResult> PreviewAsync(
ExpertSelectionIdentity identity,
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) => Task.FromResult(
new ExpertPreviewResult(identity, DateTimeOffset.UtcNow, [], false));
}
}

View File

@@ -0,0 +1,693 @@
using System.Collections;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyThemeWorkflowTests
{
[Fact]
public void Actions_AreClosedImmutableAndMatchCoreSceneCatalog()
{
Assert.Equal(
["five-row-current", "five-row-expected", "six-row-current", "twelve-row-current"],
LegacyThemeWorkflow.Actions.Select(action => action.Id));
Assert.Equal([5, 5, 6, 12], LegacyThemeWorkflow.Actions.Select(action => action.PageSize));
Assert.Equal(
[
LegacyThemeValueKind.Current,
LegacyThemeValueKind.Expected,
LegacyThemeValueKind.Current,
LegacyThemeValueKind.Current
],
LegacyThemeWorkflow.Actions.Select(action => action.ValueKind));
foreach (var action in LegacyThemeWorkflow.Actions)
{
var scene = Assert.Single(
LegacySceneCatalog.Definitions,
definition => definition.BuilderKey == action.BuilderKey);
Assert.Single(action.Aliases);
Assert.Equal(action.Code, action.Aliases[0]);
Assert.Contains(action.Code, scene.CutAliases);
Assert.Equal(action.PageSize, (int)scene.PageSize);
}
var mutable = Assert.IsAssignableFrom<IList<LegacyThemeActionDefinition>>(
LegacyThemeWorkflow.Actions);
Assert.True(mutable.IsReadOnly);
Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
Assert.Null(LegacyThemeWorkflow.FindAction(null));
Assert.Null(LegacyThemeWorkflow.FindAction("unknown"));
Assert.Equal(
["INPUT_ORDER", "GAIN_DESC", "GAIN_ASC"],
LegacyThemeWorkflow.Sorts.Select(sort => sort.Id));
Assert.Equal(
["입력순", "상승률순", "하락률순"],
LegacyThemeWorkflow.Sorts.Select(sort => sort.Label));
var mutableSorts = Assert.IsAssignableFrom<IList<LegacyThemeSortDefinition>>(
LegacyThemeWorkflow.Sorts);
Assert.True(mutableSorts.IsReadOnly);
Assert.Throws<NotSupportedException>(() => mutableSorts.RemoveAt(0));
}
[Fact]
public void InitialSnapshot_IsImmutableAndUsesOriginalInputOrderDefault()
{
var workflow = new LegacyThemeWorkflow(new FakeThemeSelectionService());
var snapshot = workflow.CreateInitial(ThemeSession.AfterMarket);
Assert.Equal(0, snapshot.Revision);
Assert.Equal("", snapshot.Query);
Assert.Equal(ThemeSession.AfterMarket, snapshot.NxtSession);
Assert.Equal(LegacyThemeSort.InputOrder, snapshot.Sort);
Assert.Equal("INPUT_ORDER", snapshot.SortId);
Assert.Empty(snapshot.SearchResults);
Assert.Null(snapshot.SelectedResultId);
Assert.Null(snapshot.SelectedTheme);
Assert.Empty(snapshot.PreviewItems);
Assert.All(snapshot.ActionStates, state =>
{
Assert.False(state.IsAvailable);
Assert.Equal("theme-not-selected", state.UnavailableReason);
});
Assert.All(
typeof(LegacyThemeWorkflowSnapshot).GetProperties(),
property => Assert.False(property.CanWrite));
Assert.Throws<ArgumentOutOfRangeException>(() =>
workflow.CreateInitial(ThemeSession.NotApplicable));
}
[Fact]
public async Task SearchAsync_NormalizesQueryPreservesExplicitIdentityAndOrdersRows()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Nxt, session, "Robotics", "00000003"),
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Zulu", "00000002"),
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Alpha", "00000001"))
};
var workflow = new LegacyThemeWorkflow(service);
var initial = workflow.CreateInitial();
var result = await workflow.SearchAsync(
initial,
" ai ",
ThemeSession.PreMarket);
var call = Assert.Single(service.SearchCalls);
Assert.Equal("ai", call.Query);
Assert.Equal(ThemeSession.PreMarket, call.Session);
Assert.Equal(LegacyThemeWorkflow.MaximumSearchResults, call.MaximumResults);
Assert.Equal(1, result.Revision);
Assert.Equal("ai", result.Query);
Assert.Collection(
result.SearchResults,
row =>
{
Assert.Equal("theme-result-001", row.ResultId);
Assert.Equal("Alpha", row.DisplayTitle);
Assert.Equal(ThemeMarket.Krx, row.Market);
Assert.Equal(ThemeSession.NotApplicable, row.Session);
Assert.Equal(DataSourceKind.Oracle, row.Source);
},
row => Assert.Equal("Zulu", row.DisplayTitle),
row =>
{
Assert.Equal("theme-result-003", row.ResultId);
Assert.Equal("Robotics(NXT)", row.DisplayTitle);
Assert.Equal(ThemeMarket.Nxt, row.Market);
Assert.Equal(ThemeSession.PreMarket, row.Session);
Assert.Equal(DataSourceKind.MariaDb, row.Source);
});
Assert.Empty(initial.SearchResults);
var mutable = Assert.IsAssignableFrom<IList<LegacyThemeSearchRow>>(result.SearchResults);
Assert.True(mutable.IsReadOnly);
Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
}
[Fact]
public async Task SearchAsync_RejectsUnsafeInputBeforeCallingService()
{
var service = new FakeThemeSelectionService();
var workflow = new LegacyThemeWorkflow(service);
var snapshot = workflow.CreateInitial();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
workflow.SearchAsync(snapshot, null!, ThemeSession.PreMarket));
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SearchAsync(snapshot, "bad\u0000query", ThemeSession.PreMarket));
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SearchAsync(
snapshot,
new string('A', LegacyThemeSelectionService.MaximumQueryLength + 1),
ThemeSession.PreMarket));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
workflow.SearchAsync(snapshot, "AI", ThemeSession.NotApplicable));
Assert.Empty(service.SearchCalls);
}
[Fact]
public async Task SearchAsync_RejectsUntrustedServiceEnvelopeProviderAndIdentity()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (_, session) => SearchResult(
"wrong-query",
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"))
};
var workflow = new LegacyThemeWorkflow(service);
var snapshot = workflow.CreateInitial();
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
service.SearchHandler = (query, session) => SearchResult(
query,
session,
new ThemeSelectorItem(
Identity(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
DataSourceKind.MariaDb));
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Nxt, ThemeSession.AfterMarket, "AI", "00000001"));
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Nxt, session, "AI(NXT)", "00000001"));
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000002"));
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));
}
[Fact]
public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var previewItems = new List<ThemeItemPreview>
{
new(0, "P005930", "삼성전자"),
new(2, "D035720", "카카오")
};
var service = ServiceWithTheme(identity, previewItems);
var workflow = new LegacyThemeWorkflow(service);
var search = await workflow.SearchAsync(
workflow.CreateInitial(),
"AI",
ThemeSession.PreMarket);
var selected = await workflow.SelectAsync(search, "theme-result-001");
var call = Assert.Single(service.PreviewCalls);
Assert.Equal(identity, call.Identity);
Assert.Equal(LegacyThemeWorkflow.MaximumPreviewItems, call.MaximumItems);
Assert.Equal(2, selected.Revision);
Assert.Equal("theme-result-001", selected.SelectedResultId);
Assert.Equal(
new LegacySelectedThemeSummary(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
2,
false),
selected.SelectedTheme);
Assert.Equal(["삼성전자", "카카오"], selected.PreviewItems.Select(row => row.ItemName));
Assert.Null(search.SelectedTheme);
Assert.All(selected.ActionStates, state =>
{
Assert.True(state.IsAvailable);
Assert.Null(state.UnavailableReason);
});
previewItems.Add(new ThemeItemPreview(3, "P000001", "Later mutation"));
Assert.Equal(2, selected.PreviewItems.Count);
var mutable = Assert.IsAssignableFrom<IList<LegacyThemePreviewRow>>(selected.PreviewItems);
Assert.True(mutable.IsReadOnly);
Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
}
[Fact]
public async Task SearchAsync_ReplacesResultsAndClearsOnlyTheNewSnapshotSelection()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
var workflow = new LegacyThemeWorkflow(service);
var selected = workflow.SelectSort(
await SearchAndSelectAsync(workflow),
"GAIN_ASC");
service.SearchHandler = (query, session) => SearchResult(query, session);
var replaced = await workflow.SearchAsync(
selected,
"new",
ThemeSession.AfterMarket);
Assert.Equal("new", replaced.Query);
Assert.Equal(ThemeSession.AfterMarket, replaced.NxtSession);
Assert.Equal(LegacyThemeSort.GainRateAscending, replaced.Sort);
Assert.Empty(replaced.SearchResults);
Assert.Null(replaced.SelectedTheme);
Assert.Empty(replaced.PreviewItems);
Assert.NotNull(selected.SelectedTheme);
Assert.Single(selected.PreviewItems);
}
[Fact]
public async Task SelectAsync_RejectsUnknownResultWithoutCallingPreview()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"))
};
var workflow = new LegacyThemeWorkflow(service);
var search = await workflow.SearchAsync(
workflow.CreateInitial(),
"",
ThemeSession.PreMarket);
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
workflow.SelectAsync(search, "theme-result-999"));
Assert.Empty(service.PreviewCalls);
}
[Fact]
public async Task SelectAsync_RejectsUntrustedPreviewEnvelopeAndItems()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
var workflow = new LegacyThemeWorkflow(service);
var search = await workflow.SearchAsync(
workflow.CreateInitial(),
"",
ThemeSession.PreMarket);
service.PreviewHandler = _ => new ThemePreviewResult(
identity with { ThemeCode = "00000002" },
DateTimeOffset.UtcNow,
[new(0, "P005930", "삼성전자")],
false);
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SelectAsync(search, "theme-result-001"));
service.PreviewHandler = _ => PreviewResult(
identity,
[new(0, "X005930", "잘못된 시장")]);
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SelectAsync(search, "theme-result-001"));
service.PreviewHandler = _ => PreviewResult(
identity,
[new(1, "P005930", "A"), new(1, "D035720", "B")]);
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SelectAsync(search, "theme-result-001"));
service.PreviewHandler = _ => PreviewResult(
identity,
CreateItems(239, ThemeMarket.Krx),
isTruncated: true);
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SelectAsync(search, "theme-result-001"));
}
[Fact]
public async Task SelectSort_UsesClosedIdsAndPreservesImmutableSelection()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
var workflow = new LegacyThemeWorkflow(service);
var selected = await SearchAndSelectAsync(workflow);
var changed = workflow.SelectSort(selected, "GAIN_DESC");
Assert.Equal(LegacyThemeSort.GainRateDescending, changed.Sort);
Assert.Equal("GAIN_DESC", changed.SortId);
Assert.Equal(selected.Revision + 1, changed.Revision);
Assert.Equal(LegacyThemeSort.InputOrder, selected.Sort);
Assert.Equal(selected.SelectedTheme, changed.SelectedTheme);
Assert.Throws<ArgumentOutOfRangeException>(() =>
workflow.SelectSort(selected, "gain_desc"));
Assert.Throws<ArgumentOutOfRangeException>(() =>
workflow.SelectSort(selected, " GAIN_DESC "));
Assert.Equal("입력순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.InputOrder));
Assert.Equal("상승률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateDescending));
Assert.Equal("하락률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateAscending));
}
[Theory]
[InlineData("INPUT_ORDER", "입력순")]
[InlineData("GAIN_DESC", "상승률순")]
[InlineData("GAIN_ASC", "하락률순")]
public async Task MaterializePlaylistDraft_KrxBuildsAllFourClosedActions(
string sortId,
string sortLabel)
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(identity, CreateItems(13, ThemeMarket.Krx));
var workflow = new LegacyThemeWorkflow(service);
var selected = workflow.SelectSort(await SearchAndSelectAsync(workflow), sortId);
foreach (var action in LegacyThemeWorkflow.Actions)
{
var draft = workflow.MaterializePlaylistDraft(selected, action.Id);
Assert.Equal(action.Id, draft.ActionId);
Assert.Equal(action.BuilderKey, draft.BuilderKey);
Assert.Equal(action.Code, draft.Code);
Assert.Equal(action.Aliases, draft.Aliases);
Assert.Equal("AI", draft.Title);
Assert.Equal($"{action.Label} · {sortLabel}", draft.Detail);
Assert.Equal("plate", draft.Category);
Assert.Equal("theme", draft.Market);
Assert.Equal("PAGED_THEME", draft.Selection.GroupCode);
Assert.Equal("AI", draft.Selection.Subject);
Assert.Equal(sortId, draft.Selection.GraphicType);
Assert.Equal(
action.ValueKind == LegacyThemeValueKind.Expected ? "EXPECTED" : "CURRENT",
draft.Selection.Subtype);
Assert.Equal("00000001", draft.Selection.DataCode);
Assert.Equal(action.PageSize, draft.PageSize);
Assert.Equal($"1/{draft.PagePlan.PageCount}", draft.PagePlan.InitialPageText);
Assert.InRange(draft.PagePlan.PageCount, 1, 20);
}
}
[Theory]
[InlineData("INPUT_ORDER")]
[InlineData("GAIN_DESC")]
[InlineData("GAIN_ASC")]
public async Task MaterializePlaylistDraft_NxtKeepsSessionAndBlocksExpected(
string sortId)
{
var identity = Identity(
ThemeMarket.Nxt,
ThemeSession.AfterMarket,
"Robotics",
"00000002");
var service = ServiceWithTheme(identity, CreateItems(7, ThemeMarket.Nxt));
var workflow = new LegacyThemeWorkflow(service);
var selected = workflow.SelectSort(
await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket),
sortId);
foreach (var actionId in new[]
{
"five-row-current", "six-row-current", "twelve-row-current"
})
{
var draft = workflow.MaterializePlaylistDraft(selected, actionId);
Assert.Equal("Robotics(NXT)", draft.Title);
Assert.Equal(
new LegacyThemePlaylistSelection(
"PAGED_NXT_THEME",
"Robotics(NXT)",
"AFTER_MARKET",
sortId,
"00000002"),
draft.Selection);
Assert.True(workflow.GetActionState(selected, actionId).IsAvailable);
}
var expectedState = workflow.GetActionState(selected, "five-row-expected");
Assert.False(expectedState.IsAvailable);
Assert.Equal("expected-theme-is-krx-only", expectedState.UnavailableReason);
var exception = Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(selected, "five-row-expected"));
Assert.Contains("expected-price", exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("five-row-current", 1, 1, 1, false)]
[InlineData("five-row-current", 5, 5, 1, false)]
[InlineData("five-row-current", 6, 6, 2, false)]
[InlineData("five-row-current", 100, 100, 20, false)]
[InlineData("five-row-current", 101, 100, 20, true)]
[InlineData("five-row-expected", 101, 100, 20, true)]
[InlineData("six-row-current", 120, 120, 20, false)]
[InlineData("six-row-current", 121, 120, 20, true)]
[InlineData("twelve-row-current", 239, 239, 20, false)]
[InlineData("twelve-row-current", 240, 240, 20, false)]
public async Task MaterializePlaylistDraft_CalculatesPageNWithTwentyPageMaximum(
string actionId,
int itemCount,
int accessibleCount,
int pageCount,
bool truncated)
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(identity, CreateItems(itemCount, ThemeMarket.Krx));
var workflow = new LegacyThemeWorkflow(service);
var selected = await SearchAndSelectAsync(workflow);
var page = workflow.MaterializePlaylistDraft(selected, actionId).PagePlan;
Assert.Equal(itemCount, page.PreviewItemCount);
Assert.Equal(accessibleCount, page.AccessibleItemCount);
Assert.Equal(pageCount, page.PageCount);
Assert.Equal(20, page.MaximumPageCount);
Assert.Equal(truncated, page.IsTruncated);
Assert.Equal($"1/{pageCount}", page.InitialPageText);
}
[Fact]
public async Task MaterializePlaylistDraft_PropagatesServiceTruncationAtPageTwenty()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(
identity,
CreateItems(240, ThemeMarket.Krx),
previewIsTruncated: true);
var workflow = new LegacyThemeWorkflow(service);
var selected = await SearchAndSelectAsync(workflow);
var page = workflow.MaterializePlaylistDraft(
selected,
"twelve-row-current").PagePlan;
Assert.Equal(20, page.PageCount);
Assert.True(page.IsTruncated);
Assert.True(selected.PreviewIsTruncated);
}
[Fact]
public async Task MaterializePlaylistDraft_RejectsMissingEmptyUnknownAndForeignState()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(identity, []);
var workflow = new LegacyThemeWorkflow(service);
var initial = workflow.CreateInitial();
Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(initial, "five-row-current"));
var selected = await SearchAndSelectAsync(workflow);
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"));
Assert.Throws<KeyNotFoundException>(() =>
workflow.MaterializePlaylistDraft(selected, "s5074"));
var other = new LegacyThemeWorkflow(new FakeThemeSelectionService());
Assert.Throws<ArgumentException>(() =>
other.MaterializePlaylistDraft(selected, "five-row-current"));
}
[Fact]
public async Task SearchAndSelect_RespectPreCanceledTokensWithoutServiceCalls()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
var workflow = new LegacyThemeWorkflow(service);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
workflow.SearchAsync(
workflow.CreateInitial(),
"",
ThemeSession.PreMarket,
cancellation.Token));
Assert.Empty(service.SearchCalls);
var search = await workflow.SearchAsync(
workflow.CreateInitial(),
"",
ThemeSession.PreMarket);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
workflow.SelectAsync(search, "theme-result-001", cancellation.Token));
Assert.Empty(service.PreviewCalls);
}
private static async Task<LegacyThemeWorkflowSnapshot> SearchAndSelectAsync(
LegacyThemeWorkflow workflow,
ThemeSession session = ThemeSession.PreMarket)
{
var search = await workflow.SearchAsync(
workflow.CreateInitial(session),
"",
session);
return await workflow.SelectAsync(search, "theme-result-001");
}
private static FakeThemeSelectionService ServiceWithTheme(
ThemeSelectionIdentity identity,
IReadOnlyList<ThemeItemPreview> items,
bool previewIsTruncated = false) =>
new()
{
SearchHandler = (query, session) => SearchResult(
query,
session,
new ThemeSelectorItem(
identity,
identity.Market == ThemeMarket.Krx
? DataSourceKind.Oracle
: DataSourceKind.MariaDb)),
PreviewHandler = _ => PreviewResult(identity, items, previewIsTruncated)
};
private static ThemeSelectionIdentity Identity(
ThemeMarket market,
ThemeSession session,
string title,
string code) =>
new(market, session, code, title);
private static ThemeSelectorItem Selector(
ThemeMarket market,
ThemeSession session,
string title,
string code) =>
new(
Identity(market, session, title, code),
market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb);
private static ThemeSearchResult SearchResult(
string query,
ThemeSession session,
params ThemeSelectorItem[] items) =>
new(query, session, DateTimeOffset.UtcNow, items, false);
private static ThemePreviewResult PreviewResult(
ThemeSelectionIdentity identity,
IReadOnlyList<ThemeItemPreview> items,
bool isTruncated = false) =>
new(identity, DateTimeOffset.UtcNow, items, isTruncated);
private static IReadOnlyList<ThemeItemPreview> CreateItems(
int count,
ThemeMarket market)
{
var prefix = market == ThemeMarket.Krx ? 'P' : 'X';
return Enumerable.Range(0, count)
.Select(index => new ThemeItemPreview(
index,
$"{prefix}{index + 1:000000}",
$"Item {index + 1}"))
.ToArray();
}
private sealed record SearchCall(
string Query,
ThemeSession Session,
int MaximumResults,
CancellationToken CancellationToken);
private sealed record PreviewCall(
ThemeSelectionIdentity Identity,
int MaximumItems,
CancellationToken CancellationToken);
private sealed class FakeThemeSelectionService : IThemeSelectionService
{
public Func<string, ThemeSession, ThemeSearchResult> SearchHandler { get; set; } =
(query, session) => SearchResult(query, session);
public Func<ThemeSelectionIdentity, ThemePreviewResult> PreviewHandler { get; set; } =
identity => PreviewResult(identity, []);
public List<SearchCall> SearchCalls { get; } = [];
public List<PreviewCall> PreviewCalls { get; } = [];
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
SearchCalls.Add(new SearchCall(query, nxtSession, maximumResults, cancellationToken));
return Task.FromResult(SearchHandler(query, nxtSession));
}
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default)
{
PreviewCalls.Add(new PreviewCall(identity, maximumItems, cancellationToken));
return Task.FromResult(PreviewHandler(identity));
}
}
}

View File

@@ -0,0 +1,330 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyManualFinancialOperatorControllerTests
{
[Fact]
public async Task GraphE_result_double_click_fresh_loads_appends_once_and_closes_on_success()
{
var service = new FakeManualService(Sales("Alpha", 1));
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
var activated = await controller.ActivateManualFinancialResultAsync(resultId);
Assert.Null(activated.ManualFinancial);
Assert.Equal(1, service.GetCalls);
Assert.Equal(0, service.MutationCalls);
Assert.Equal("5080", Assert.Single(activated.Playlist).ToPlayoutEntry().CutCode);
Assert.Equal("activate-manual-financial-result", activated.CommandResult?.Command);
Assert.Equal(resultId, activated.CommandResult?.TargetId);
Assert.True(activated.CommandResult?.Succeeded);
}
[Fact]
public async Task GraphE_result_double_click_rejects_identity_change_without_append_or_close()
{
var service = new FakeManualService(Sales("Alpha", 1))
{
GetOverride = _ => Sales("Beta", 2)
};
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
var resultId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
var rejected = await controller.ActivateManualFinancialResultAsync(resultId);
Assert.NotNull(rejected.ManualFinancial);
Assert.Empty(rejected.Playlist);
Assert.Equal(1, service.GetCalls);
Assert.Equal(0, service.MutationCalls);
Assert.NotNull(rejected.Dialog);
Assert.False(rejected.CommandResult?.Succeeded);
}
[Fact]
public async Task Existing_GraphE_row_loads_verifies_and_adds_CSharp_owned_playlist_row()
{
var service = new FakeManualService(Sales("Alpha", 1));
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
var rowId = Assert.Single(opened.ManualFinancial!.Rows).ResultId;
var loaded = await controller.LoadManualFinancialAsync(rowId);
Assert.True(loaded.ManualFinancial!.CanMaterializePlaylist);
Assert.Equal(0, service.MutationCalls);
var added = controller.ActivateManualFinancialAction("sales");
var playlist = Assert.Single(added.Playlist);
var entry = playlist.ToPlayoutEntry();
Assert.Equal("5080", entry.CutCode);
var selection = Assert.IsType<LegacySceneSelection>(entry.Selection);
Assert.Equal("Alpha", selection.Subject);
Assert.Equal("SALES", selection.GraphicType);
Assert.Equal("1/1", playlist.PageText);
}
[Fact]
public async Task GraphE_find_loads_fresh_detail_and_stale_sort_generation_opens_modal()
{
var service = new FakeManualService(Sales("Alpha", 1));
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(
ManualFinancialScreenKind.Sales);
var generation = opened.ManualFinancial!.Revision;
var found = await controller.FindManualFinancialAsync(
"Alpha",
LegacyManualFinancialFindDirection.Down,
generation);
Assert.Equal("Alpha", found.ManualFinancial!.SelectedRecord!.Identity.StockName);
Assert.Equal(1, service.GetCalls);
var sorted = controller.ToggleManualFinancialNameSort(
found.ManualFinancial.Revision);
var stale = controller.ToggleManualFinancialNameSort(
found.ManualFinancial.Revision);
Assert.NotNull(stale.Dialog);
Assert.Equal(
sorted.ManualFinancial!.NameSortDirection,
stale.ManualFinancial!.NameSortDirection);
}
[Fact]
public async Task Create_does_not_write_until_explicit_save_and_fresh_readback_completes()
{
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);
Assert.Equal(0, service.MutationCalls);
var saved = await controller.SaveManualFinancialAsync(Profit("Alpha", 10));
Assert.Equal(1, service.MutationCalls);
Assert.Equal(1, service.GetCalls);
Assert.Equal(
LegacyManualFinancialWorkflowStatus.WriteCommitted,
saved.ManualFinancial!.Status);
Assert.True(saved.ManualFinancial.CanMaterializePlaylist);
Assert.DoesNotContain("password", saved.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Delete_and_delete_all_are_only_reached_by_explicit_controller_methods()
{
var service = new FakeManualService(Sales("Alpha", 1));
var controller = Controller(service);
var opened = await controller.OpenManualFinancialAsync(ManualFinancialScreenKind.Sales);
await controller.LoadManualFinancialAsync(
Assert.Single(opened.ManualFinancial!.Rows).ResultId);
Assert.Equal(0, service.MutationCalls);
var deleted = await controller.DeleteManualFinancialAsync(all: false);
Assert.Equal(1, service.MutationCalls);
Assert.Empty(deleted.ManualFinancial!.Rows);
var all = await controller.DeleteManualFinancialAsync(all: true);
Assert.Equal(2, service.MutationCalls);
Assert.Empty(all.ManualFinancial!.Rows);
}
private static LegacyOperatorController Controller(FakeManualService service)
{
var workflow = new LegacyManualFinancialWorkflow(
service,
new FakeStockService(),
new LegacyManualFinancialWriteSafetyState());
return new LegacyOperatorController(
new EmptyStockLookup(),
manualFinancialWorkflow: workflow);
}
private static ManualFinancialSnapshot Sales(string stockName, int version) =>
new(
new ManualSalesRecord(
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
Enumerable.Range(1, 6)
.Select(index => new ManualQuarterValue($"{index}Q", index))
.ToArray()),
Version(version));
private static ManualOperatingProfitRecord Profit(string stockName, int offset) =>
new(
new ManualFinancialIdentity(
ManualFinancialScreenKind.OperatingProfit,
stockName),
Enumerable.Range(1, 6)
.Select(index => new ManualQuarterValue($"{index}Q", offset + index))
.ToArray());
private static string Version(int value) => value.ToString("X64");
private sealed class EmptyStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class FakeStockService : IStockSearchService
{
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new StockSearchResult(
query,
DateTimeOffset.UtcNow,
[new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
"Alpha",
"000001")],
false));
}
private sealed class FakeManualService : IManualFinancialScreenService
{
private readonly Dictionary<(ManualFinancialScreenKind, string), ManualFinancialSnapshot>
_rows = new();
private int _version = 10;
internal FakeManualService(params ManualFinancialSnapshot[] rows)
{
foreach (var row in rows)
{
_rows[(row.Record.Identity.Screen, row.Record.Identity.StockName)] = row;
}
}
public bool CanMutate => true;
internal int MutationCalls { get; private set; }
internal int GetCalls { get; private set; }
internal Func<ManualFinancialIdentity, ManualFinancialSnapshot>? GetOverride
{
get;
init;
}
public Task<ManualFinancialSearchResult> SearchAsync(
ManualFinancialScreenKind screen,
string query = "",
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
var rows = _rows.Values
.Where(row => row.Record.Identity.Screen == screen)
.Where(row => query.Length == 0 ||
row.Record.Identity.StockName.Contains(
query,
StringComparison.OrdinalIgnoreCase))
.Take(maximumResults)
.ToArray();
return Task.FromResult(new ManualFinancialSearchResult(
screen,
query,
DateTimeOffset.UtcNow,
rows,
false));
}
public Task<ManualFinancialSnapshot> GetAsync(
ManualFinancialIdentity identity,
CancellationToken cancellationToken = default)
{
GetCalls++;
return Task.FromResult(
GetOverride?.Invoke(identity) ??
_rows[(identity.Screen, identity.StockName)]);
}
public Task<ManualFinancialMutationReceipt> CreateAsync(
ManualFinancialRecord record,
CancellationToken cancellationToken = default)
{
MutationCalls++;
_rows[(record.Identity.Screen, record.Identity.StockName)] =
new ManualFinancialSnapshot(record, Version(++_version));
return Task.FromResult(Receipt(
ManualFinancialMutationKind.Create,
record.Identity.Screen,
record.Identity.StockName,
1));
}
public Task<ManualFinancialMutationReceipt> UpdateAsync(
ManualFinancialSnapshot expected,
ManualFinancialRecord replacement,
CancellationToken cancellationToken = default)
{
MutationCalls++;
_rows[(replacement.Identity.Screen, replacement.Identity.StockName)] =
new ManualFinancialSnapshot(replacement, Version(++_version));
return Task.FromResult(Receipt(
ManualFinancialMutationKind.Update,
replacement.Identity.Screen,
replacement.Identity.StockName,
1));
}
public Task<ManualFinancialMutationReceipt> DeleteAsync(
ManualFinancialSnapshot expected,
CancellationToken cancellationToken = default)
{
MutationCalls++;
_rows.Remove((
expected.Record.Identity.Screen,
expected.Record.Identity.StockName));
return Task.FromResult(Receipt(
ManualFinancialMutationKind.DeleteOne,
expected.Record.Identity.Screen,
expected.Record.Identity.StockName,
1));
}
public Task<ManualFinancialMutationReceipt> DeleteAllAsync(
ManualFinancialScreenKind screen,
CancellationToken cancellationToken = default)
{
MutationCalls++;
var keys = _rows.Keys.Where(key => key.Item1 == screen).ToArray();
foreach (var key in keys)
{
_rows.Remove(key);
}
return Task.FromResult(Receipt(
ManualFinancialMutationKind.DeleteAll,
screen,
string.Empty,
keys.Length));
}
private static ManualFinancialMutationReceipt Receipt(
ManualFinancialMutationKind kind,
ManualFinancialScreenKind screen,
string stockName,
int affectedRows) =>
new(
Guid.NewGuid(),
kind,
screen,
stockName,
DateTimeOffset.UtcNow,
affectedRows);
}
}

View File

@@ -0,0 +1,883 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyManualFinancialWorkflowTests
{
public static TheoryData<
ManualFinancialScreenKind,
string,
string,
string,
string,
string,
string> Screens => new()
{
{
ManualFinancialScreenKind.RevenueComposition,
"revenue-composition",
"주요매출 구성",
"INPUT_PIE",
"s5076",
"5076",
"REVENUE_COMPOSITION"
},
{
ManualFinancialScreenKind.GrowthMetrics,
"growth-metrics",
"성장성 지표",
"INPUT_GROW",
"s5079",
"5079",
"GROWTH_METRICS"
},
{
ManualFinancialScreenKind.Sales,
"sales",
"매출액",
"INPUT_SELL",
"s5080",
"5080",
"SALES"
},
{
ManualFinancialScreenKind.OperatingProfit,
"operating-profit",
"영업이익",
"INPUT_PROFIT",
"s5081",
"5081",
"OPERATING_PROFIT"
}
};
[Theory]
[MemberData(nameof(Screens))]
public void Registry_matches_every_GraphE_table_and_cut_contract(
ManualFinancialScreenKind screen,
string actionId,
string label,
string table,
string builder,
string code,
string graphic)
{
var definition = LegacyManualFinancialWorkflow.GetScreen(screen);
Assert.Equal(4, LegacyManualFinancialWorkflow.Screens.Count);
Assert.Equal(actionId, definition.ActionId);
Assert.Equal(label, definition.Label);
Assert.Equal(table, definition.TableName);
Assert.Equal(builder, definition.BuilderKey);
Assert.Equal(code, definition.Code);
Assert.Equal([code], definition.Aliases);
Assert.Equal(graphic, definition.CanonicalGraphicType);
Assert.Equal("DELETE_ALL_" + table, definition.DeleteAllConfirmationToken);
}
[Fact]
public async Task Search_then_load_uses_opaque_identity_and_fresh_exact_read()
{
var original = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1);
var refreshed = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha", 100), 2);
var service = new FakeManualFinancialService(original)
{
GetOverride = _ => refreshed
};
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await workflow.SearchAsync(state, " Alpha ");
Assert.Equal("Alpha", state.Query);
Assert.Equal(LegacyManualFinancialWorkflow.MaximumListResults, service.LastMaximumResults);
var row = Assert.Single(state.Rows);
Assert.StartsWith("mf-row-", row.ResultId, StringComparison.Ordinal);
Assert.Null(state.SelectedRecord);
Assert.False(state.IsDetailFresh);
state = await workflow.LoadAsync(state, row.ResultId);
Assert.True(state.IsDetailFresh);
Assert.Equal(Version(2), state.SelectedRowVersion);
Assert.Same(state.SelectedRecord, state.SelectedRecord);
Assert.Equal(1, service.GetCalls);
Assert.Equal("Alpha", service.LastGetIdentity?.StockName);
Assert.Equal(Version(2), Assert.Single(state.Rows).RowVersion);
}
[Fact]
public async Task GraphE_name_header_and_directional_find_are_CSharp_authoritative()
{
var service = new FakeManualFinancialService(
Snapshot(Record(ManualFinancialScreenKind.Sales, "Gamma"), 1),
Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 2),
Snapshot(Record(ManualFinancialScreenKind.Sales, "Beta"), 3));
var workflow = Workflow(service);
var state = await workflow.SearchAsync(
workflow.CreateInitial(ManualFinancialScreenKind.Sales));
Assert.Equal(["Alpha", "Beta", "Gamma"], state.Rows.Select(row => row.StockName));
Assert.Equal(LegacyManualFinancialNameSortDirection.Ascending,
state.NameSortDirection);
state = workflow.FindByName(
state,
"a",
LegacyManualFinancialFindDirection.Down,
state.Revision);
Assert.Equal("Alpha", state.Rows.Single(row =>
row.ResultId == state.SelectedRowId).StockName);
state = workflow.FindByName(
state,
"a",
LegacyManualFinancialFindDirection.Down,
state.Revision);
Assert.Equal("Beta", state.Rows.Single(row =>
row.ResultId == state.SelectedRowId).StockName);
state = workflow.FindByName(
state,
"a",
LegacyManualFinancialFindDirection.Up,
state.Revision);
Assert.Equal("Alpha", state.Rows.Single(row =>
row.ResultId == state.SelectedRowId).StockName);
var staleGeneration = state.Revision;
state = workflow.ToggleNameSort(state, staleGeneration);
Assert.Equal(["Gamma", "Beta", "Alpha"], state.Rows.Select(row => row.StockName));
Assert.Equal(LegacyManualFinancialNameSortDirection.Descending,
state.NameSortDirection);
Assert.Throws<InvalidOperationException>(() =>
workflow.ToggleNameSort(state, staleGeneration));
}
[Theory]
[MemberData(nameof(Screens))]
public async Task Create_save_readback_and_action_id_only_draft_work_for_all_modes(
ManualFinancialScreenKind screen,
string actionId,
string label,
string table,
string builder,
string code,
string graphic)
{
_ = table;
var service = new FakeManualFinancialService();
var workflow = Workflow(service);
var state = workflow.CreateInitial(screen);
state = await VerifyStockAsync(workflow, state, "Alpha");
var request = workflow.CreateCreateRequest(state, Record(screen, "Alpha"));
Assert.Equal(ManualFinancialMutationKind.Create, request.Kind);
Assert.Equal(screen, request.Screen);
Assert.Equal("Alpha", request.StockName);
Assert.Matches("^[a-f0-9]{32}$", request.RequestId);
state = await workflow.ExecuteAsync(state, request);
var draft = workflow.MaterializePlaylistDraft(state, actionId);
Assert.Equal(LegacyManualFinancialWorkflowStatus.WriteCommitted, state.Status);
Assert.True(state.IsDetailFresh);
Assert.Equal(1, service.MutationCalls);
Assert.Equal(1, service.GetCalls);
Assert.Equal(request.RequestId, state.LastRequestId);
Assert.Equal(builder, draft.BuilderKey);
Assert.Equal(code, draft.Code);
Assert.Equal([code], draft.Aliases);
Assert.Equal(label, draft.Detail);
Assert.Equal("manual-financial", draft.Category);
Assert.Equal(graphic, draft.CutSelection.Selection.GraphicType);
Assert.Equal(builder, draft.CutSelection.BuilderKey);
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("KOSPI", draft.Market);
Assert.Equal("000001", draft.StockCode);
Assert.False(draft.IsNxt);
}
[Fact]
public async Task Update_request_carries_selected_rowVersion_and_rebinds_fresh_version()
{
var service = new FakeManualFinancialService(
Snapshot(Record(ManualFinancialScreenKind.GrowthMetrics, "Alpha"), 1));
var workflow = Workflow(service);
var state = await LoadAndVerifyAsync(
workflow,
ManualFinancialScreenKind.GrowthMetrics,
"Alpha");
var replacement = Record(ManualFinancialScreenKind.GrowthMetrics, "Alpha", 100);
var request = workflow.CreateUpdateRequest(state, replacement);
Assert.Equal(Version(1), request.ExpectedRowVersion);
state = await workflow.ExecuteAsync(state, request);
Assert.Equal(Version(11), state.SelectedRowVersion);
Assert.Equal(Version(1), service.LastExpected?.RowVersion);
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(1, service.MutationCalls);
Assert.Equal(2, service.GetCalls);
}
[Fact]
public async Task Delete_one_uses_optimistic_snapshot_and_clears_selection()
{
var service = new FakeManualFinancialService(
Snapshot(Record(ManualFinancialScreenKind.OperatingProfit, "Alpha"), 1));
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.OperatingProfit);
state = await workflow.SearchAsync(state);
state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId);
var request = workflow.CreateDeleteRequest(state);
Assert.Equal(Version(1), request.ExpectedRowVersion);
state = await workflow.ExecuteAsync(state, request);
Assert.Empty(state.Rows);
Assert.Null(state.SelectedRecord);
Assert.False(state.IsDetailFresh);
Assert.Equal(Version(1), service.LastExpected?.RowVersion);
Assert.Equal(ManualFinancialMutationKind.DeleteOne, state.LastReceipt?.Kind);
}
[Fact]
public async Task Delete_all_requires_the_fixed_screen_token_and_accepts_zero_rows()
{
var service = new FakeManualFinancialService(
Snapshot(Record(ManualFinancialScreenKind.RevenueComposition, "Alpha"), 1));
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.RevenueComposition);
state = await workflow.SearchAsync(state);
Assert.Throws<ArgumentException>(() =>
workflow.CreateDeleteAllRequest(state, "DELETE_ALL_INPUT_SELL"));
var request = workflow.CreateDeleteAllRequest(
state,
state.Definition.DeleteAllConfirmationToken);
service.DeleteAllAffectedRows = 0;
state = await workflow.ExecuteAsync(state, request);
Assert.Equal("DELETE_ALL_INPUT_PIE", request.ConfirmationToken);
Assert.Empty(state.Rows);
Assert.Equal(0, state.LastReceipt?.AffectedRows);
Assert.Equal(1, service.MutationCalls);
}
[Fact]
public async Task Exact_stock_resolution_rejects_duplicate_master_names()
{
var service = new FakeManualFinancialService();
var stockSearch = new FakeStockSearchService(
new(StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001"),
new(StockMarket.Kosdaq, DataSourceKind.Oracle, "Alpha", "000002"));
var workflow = new LegacyManualFinancialWorkflow(
service,
stockSearch,
new LegacyManualFinancialWriteSafetyState());
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await workflow.SearchStocksAsync(state, "Alpha");
Assert.Throws<ArgumentException>(() =>
workflow.SelectStock(state, state.StockCandidates[0].ResultId));
}
[Fact]
public async Task Update_and_playlist_require_detail_bound_stock_verification()
{
var service = new FakeManualFinancialService(
Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1));
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await workflow.SearchAsync(state);
state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId);
Assert.Throws<InvalidOperationException>(() =>
workflow.CreateUpdateRequest(state, Record(ManualFinancialScreenKind.Sales, "Alpha")));
Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(state, "sales"));
state = await VerifyStockAsync(workflow, state, "Alpha");
Assert.NotNull(workflow.CreateUpdateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha", 10)));
Assert.NotNull(workflow.MaterializePlaylistDraft(state, "sales"));
}
[Fact]
public async Task OutcomeUnknown_latches_process_write_quarantine_and_never_retries()
{
var service = new FakeManualFinancialService
{
MutationException = new ManualFinancialMutationException("timeout", outcomeUnknown: true)
};
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
var request = workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha"));
var original = state;
state = await workflow.ExecuteAsync(state, request);
Assert.Equal(LegacyManualFinancialWorkflowStatus.OutcomeUnknown, state.Status);
Assert.True(state.WritesQuarantined);
Assert.Equal(1, service.MutationCalls);
Assert.Throws<InvalidOperationException>(() =>
workflow.CreateCreateRequest(
original,
Record(ManualFinancialScreenKind.Sales, "Alpha")));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
workflow.ExecuteAsync(original, request));
Assert.Equal(1, service.MutationCalls);
Assert.True(workflow.CreateInitial(ManualFinancialScreenKind.Sales).WritesQuarantined);
Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(state, "sales"));
}
[Fact]
public async Task Malformed_write_receipt_is_quarantined_as_unknown()
{
var service = new FakeManualFinancialService
{
ReceiptOverride = (kind, screen, name, affectedRows) => new(
Guid.NewGuid(),
ManualFinancialMutationKind.DeleteOne,
screen,
name,
DateTimeOffset.UtcNow,
affectedRows)
};
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
var request = workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha"));
state = await workflow.ExecuteAsync(state, request);
Assert.Equal(LegacyManualFinancialWorkflowStatus.OutcomeUnknown, state.Status);
Assert.True(state.WritesQuarantined);
Assert.Equal(1, service.MutationCalls);
Assert.Equal(0, service.GetCalls);
}
[Fact]
public async Task Known_rollback_is_not_quarantined_but_request_stays_one_shot()
{
var service = new FakeManualFinancialService
{
MutationException = new ManualFinancialMutationRejectedException(
ManualFinancialMutationRejectionReason.DuplicateIdentity,
"rolled back")
};
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
var request = workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha"));
var source = state;
state = await workflow.ExecuteAsync(state, request);
Assert.Equal(LegacyManualFinancialWorkflowStatus.WriteRejected, state.Status);
Assert.False(state.WritesQuarantined);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
workflow.ExecuteAsync(source, request));
Assert.Equal(1, service.MutationCalls);
}
[Fact]
public async Task Sibling_requests_from_one_snapshot_cannot_execute_twice()
{
var service = new FakeManualFinancialService();
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
var first = workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha"));
var sibling = workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha"));
_ = await workflow.ExecuteAsync(state, first);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
workflow.ExecuteAsync(state, sibling));
Assert.Equal(1, service.MutationCalls);
}
[Fact]
public async Task Pre_write_fresh_snapshot_cannot_materialize_after_update()
{
var service = new FakeManualFinancialService(
Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1));
var workflow = Workflow(service);
var stale = await LoadAndVerifyAsync(
workflow,
ManualFinancialScreenKind.Sales,
"Alpha");
var request = workflow.CreateUpdateRequest(
stale,
Record(ManualFinancialScreenKind.Sales, "Alpha", 100));
var current = await workflow.ExecuteAsync(stale, request);
Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(stale, "sales"));
Assert.NotNull(workflow.MaterializePlaylistDraft(current, "sales"));
}
[Fact]
public async Task Shared_process_safety_quarantines_new_workflow_instances()
{
var safety = new LegacyManualFinancialWriteSafetyState();
var firstService = new FakeManualFinancialService
{
MutationException = new ManualFinancialMutationException("timeout", outcomeUnknown: true)
};
var first = Workflow(firstService, safety);
var state = first.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(first, state, "Alpha");
state = await first.ExecuteAsync(
state,
first.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha")));
Assert.True(state.WritesQuarantined);
var second = Workflow(new FakeManualFinancialService(), safety);
var secondState = second.CreateInitial(ManualFinancialScreenKind.OperatingProfit);
Assert.True(secondState.WritesQuarantined);
Assert.Throws<InvalidOperationException>(() =>
second.CreateDeleteAllRequest(
secondState,
secondState.Definition.DeleteAllConfirmationToken));
}
[Fact]
public async Task Committed_write_without_readback_never_enables_action()
{
var service = new FakeManualFinancialService();
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
var request = workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha"));
service.GetException = new InvalidOperationException("read unavailable");
state = await workflow.ExecuteAsync(state, request);
Assert.Equal(LegacyManualFinancialWorkflowStatus.ReadBackRequired, state.Status);
Assert.NotNull(state.LastReceipt);
Assert.Null(state.SelectedRecord);
Assert.False(state.WritesQuarantined);
Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(state, "sales"));
}
[Fact]
public async Task Request_is_revision_bound_and_foreign_snapshots_fail_closed()
{
var firstService = new FakeManualFinancialService();
var first = Workflow(firstService);
var state = first.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(first, state, "Alpha");
var request = first.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha"));
var changed = first.BeginCreate(state);
await Assert.ThrowsAsync<ArgumentException>(() =>
first.ExecuteAsync(changed, request));
Assert.Equal(0, firstService.MutationCalls);
var second = Workflow(new FakeManualFinancialService());
Assert.Throws<ArgumentException>(() => second.BeginCreate(state));
await Assert.ThrowsAsync<ArgumentException>(() =>
second.SearchAsync(state));
}
[Fact]
public async Task Read_only_configuration_blocks_request_creation_before_executor()
{
var service = new FakeManualFinancialService { CanMutate = false };
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
Assert.Throws<InvalidOperationException>(() =>
workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha")));
Assert.Equal(0, service.MutationCalls);
}
[Fact]
public async Task Invalid_or_ambiguous_list_envelopes_are_rejected()
{
var duplicate = Snapshot(Record(ManualFinancialScreenKind.Sales, "Alpha"), 1);
var service = new FakeManualFinancialService
{
SearchOverride = (_, query) => new ManualFinancialSearchResult(
ManualFinancialScreenKind.Sales,
query,
DateTimeOffset.UtcNow,
[duplicate, duplicate],
false)
};
var workflow = Workflow(service);
await Assert.ThrowsAsync<LegacyManualFinancialWorkflowDataException>(() =>
workflow.SearchAsync(workflow.CreateInitial(ManualFinancialScreenKind.Sales)));
}
[Fact]
public async Task DTOs_and_snapshots_clone_mutable_input_collections()
{
var quarters = Enumerable.Range(1, 6)
.Select(index => new ManualQuarterValue($"{index}Q", index))
.ToArray();
var record = new ManualSalesRecord(
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha"),
quarters);
var service = new FakeManualFinancialService();
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
var request = workflow.CreateCreateRequest(state, record);
quarters[0] = new ManualQuarterValue("BAD", 999);
var cloned = Assert.IsType<ManualSalesRecord>(request.Record);
Assert.Equal("1Q", cloned.Quarters[0].Quarter);
Assert.Throws<NotSupportedException>(() =>
((IList<ManualQuarterValue>)cloned.Quarters).Add(new("7Q", 7)));
state = await workflow.ExecuteAsync(state, request);
Assert.Throws<NotSupportedException>(() =>
((IList<LegacyManualFinancialListRow>)state.Rows).Clear());
var selected = Assert.IsType<ManualSalesRecord>(state.SelectedRecord);
Assert.Throws<NotSupportedException>(() =>
((IList<ManualQuarterValue>)selected.Quarters).Clear());
}
[Fact]
public async Task Wrong_action_id_and_cross_screen_action_never_materialize()
{
var service = new FakeManualFinancialService();
var workflow = Workflow(service);
var state = workflow.CreateInitial(ManualFinancialScreenKind.Sales);
state = await VerifyStockAsync(workflow, state, "Alpha");
state = await workflow.ExecuteAsync(
state,
workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, "Alpha")));
Assert.Throws<ArgumentException>(() =>
workflow.MaterializePlaylistDraft(state, "s5080"));
Assert.Throws<ArgumentException>(() =>
workflow.MaterializePlaylistDraft(state, "operating-profit"));
}
private static LegacyManualFinancialWorkflow Workflow(
FakeManualFinancialService service,
LegacyManualFinancialWriteSafetyState? writeSafety = null) =>
new(
service,
new FakeStockSearchService(
new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
"Alpha",
"000001")),
writeSafety ?? new LegacyManualFinancialWriteSafetyState());
private static async Task<LegacyManualFinancialWorkflowSnapshot> VerifyStockAsync(
LegacyManualFinancialWorkflow workflow,
LegacyManualFinancialWorkflowSnapshot state,
string stockName)
{
state = await workflow.SearchStocksAsync(state, stockName);
return workflow.SelectStock(state, Assert.Single(state.StockCandidates).ResultId);
}
private static async Task<LegacyManualFinancialWorkflowSnapshot> LoadAndVerifyAsync(
LegacyManualFinancialWorkflow workflow,
ManualFinancialScreenKind screen,
string stockName)
{
var state = workflow.CreateInitial(screen);
state = await workflow.SearchAsync(state, stockName);
state = await workflow.LoadAsync(state, Assert.Single(state.Rows).ResultId);
return await VerifyStockAsync(workflow, state, stockName);
}
private static ManualFinancialSnapshot Snapshot(ManualFinancialRecord record, int version) =>
new(record, Version(version));
private static string Version(int value) => value.ToString("X64");
private static ManualFinancialRecord Record(
ManualFinancialScreenKind screen,
string stockName,
int offset = 0)
{
var identity = new ManualFinancialIdentity(screen, stockName);
return screen switch
{
ManualFinancialScreenKind.RevenueComposition =>
new ManualRevenueCompositionRecord(
identity,
"2026.07",
[
new ManualRevenueSlice("반도체", "25", 25),
new ManualRevenueSlice("모바일", "20", 20),
null,
null,
null
]),
ManualFinancialScreenKind.GrowthMetrics =>
new ManualGrowthMetricsRecord(
identity,
new ManualGrowthSeriesValues(1 + offset, 2, 3, 4),
new ManualGrowthSeriesValues(5, 6, 7, 8),
new ManualGrowthSeriesValues(9, 10, 11, 12),
new ManualGrowthSeriesValues(13, 14, 15, 16),
new ManualGrowthPeriodLabels("1Q", "2Q", "3Q", "4Q")),
ManualFinancialScreenKind.Sales =>
new ManualSalesRecord(
identity,
Enumerable.Range(1, 6)
.Select(index => new ManualQuarterValue(
$"{index}Q",
offset + index))
.ToArray()),
ManualFinancialScreenKind.OperatingProfit =>
new ManualOperatingProfitRecord(
identity,
Enumerable.Range(1, 6)
.Select(index => new ManualQuarterValue(
$"{index}Q",
offset + index))
.ToArray()),
_ => throw new ArgumentOutOfRangeException(nameof(screen))
};
}
private sealed class FakeStockSearchService : IStockSearchService
{
private readonly IReadOnlyList<StockSearchItem> _items;
internal FakeStockSearchService(params StockSearchItem[] items)
{
_items = items;
}
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new StockSearchResult(
query,
DateTimeOffset.UtcNow,
_items.Take(maximumResults).ToArray(),
_items.Count > maximumResults));
}
}
private sealed class FakeManualFinancialService : IManualFinancialScreenService
{
private readonly Dictionary<(ManualFinancialScreenKind Screen, string Name),
ManualFinancialSnapshot> _rows = new();
private int _nextVersion = 10;
internal FakeManualFinancialService(params ManualFinancialSnapshot[] rows)
{
foreach (var row in rows)
{
_rows[(row.Record.Identity.Screen, row.Record.Identity.StockName)] = row;
}
}
public bool CanMutate { get; set; } = true;
internal int MutationCalls { get; private set; }
internal int GetCalls { get; private set; }
internal int LastMaximumResults { get; private set; }
internal ManualFinancialIdentity? LastGetIdentity { get; private set; }
internal ManualFinancialSnapshot? LastExpected { get; private set; }
internal Exception? MutationException { get; set; }
internal Exception? GetException { get; set; }
internal Func<ManualFinancialIdentity, ManualFinancialSnapshot>? GetOverride { get; set; }
internal Func<ManualFinancialScreenKind, string, ManualFinancialSearchResult>?
SearchOverride
{ get; set; }
internal Func<
ManualFinancialMutationKind,
ManualFinancialScreenKind,
string,
int,
ManualFinancialMutationReceipt>? ReceiptOverride
{ get; set; }
internal int DeleteAllAffectedRows { get; set; }
public Task<ManualFinancialSearchResult> SearchAsync(
ManualFinancialScreenKind screen,
string query = "",
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
LastMaximumResults = maximumResults;
if (SearchOverride is not null)
{
return Task.FromResult(SearchOverride(screen, query));
}
var all = _rows.Values
.Where(row => row.Record.Identity.Screen == screen)
.Where(row => query.Length == 0 || row.Record.Identity.StockName.Contains(
query,
StringComparison.OrdinalIgnoreCase))
.OrderBy(row => row.Record.Identity.StockName, StringComparer.Ordinal)
.ToArray();
return Task.FromResult(new ManualFinancialSearchResult(
screen,
query,
DateTimeOffset.UtcNow,
all.Take(maximumResults).ToArray(),
all.Length > maximumResults));
}
public Task<ManualFinancialSnapshot> GetAsync(
ManualFinancialIdentity identity,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
GetCalls++;
LastGetIdentity = identity;
if (GetException is not null)
{
throw GetException;
}
if (GetOverride is not null)
{
return Task.FromResult(GetOverride(identity));
}
return Task.FromResult(_rows[(identity.Screen, identity.StockName)]);
}
public Task<ManualFinancialMutationReceipt> CreateAsync(
ManualFinancialRecord record,
CancellationToken cancellationToken = default)
{
BeforeMutation(cancellationToken);
var snapshot = new ManualFinancialSnapshot(record, Version(++_nextVersion));
_rows[(record.Identity.Screen, record.Identity.StockName)] = snapshot;
return Task.FromResult(Receipt(
ManualFinancialMutationKind.Create,
record.Identity.Screen,
record.Identity.StockName,
1));
}
public Task<ManualFinancialMutationReceipt> UpdateAsync(
ManualFinancialSnapshot expected,
ManualFinancialRecord replacement,
CancellationToken cancellationToken = default)
{
BeforeMutation(cancellationToken);
LastExpected = expected;
_rows[(replacement.Identity.Screen, replacement.Identity.StockName)] =
new ManualFinancialSnapshot(replacement, Version(++_nextVersion));
return Task.FromResult(Receipt(
ManualFinancialMutationKind.Update,
replacement.Identity.Screen,
replacement.Identity.StockName,
1));
}
public Task<ManualFinancialMutationReceipt> DeleteAsync(
ManualFinancialSnapshot expected,
CancellationToken cancellationToken = default)
{
BeforeMutation(cancellationToken);
LastExpected = expected;
_rows.Remove((
expected.Record.Identity.Screen,
expected.Record.Identity.StockName));
return Task.FromResult(Receipt(
ManualFinancialMutationKind.DeleteOne,
expected.Record.Identity.Screen,
expected.Record.Identity.StockName,
1));
}
public Task<ManualFinancialMutationReceipt> DeleteAllAsync(
ManualFinancialScreenKind screen,
CancellationToken cancellationToken = default)
{
BeforeMutation(cancellationToken);
foreach (var key in _rows.Keys.Where(key => key.Screen == screen).ToArray())
{
_rows.Remove(key);
}
return Task.FromResult(Receipt(
ManualFinancialMutationKind.DeleteAll,
screen,
string.Empty,
DeleteAllAffectedRows));
}
private void BeforeMutation(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
MutationCalls++;
if (MutationException is not null)
{
throw MutationException;
}
}
private ManualFinancialMutationReceipt Receipt(
ManualFinancialMutationKind kind,
ManualFinancialScreenKind screen,
string stockName,
int affectedRows) =>
ReceiptOverride?.Invoke(kind, screen, stockName, affectedRows) ?? new(
Guid.NewGuid(),
kind,
screen,
stockName,
DateTimeOffset.UtcNow,
affectedRows);
}
}

View File

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

View File

@@ -0,0 +1,298 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyNamedPlaylistOperatorIntegrationTests
{
[Fact]
public void Construction_DoesNotReadOrWriteNamedPlaylistDatabase()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
_ = CreateController(persistence);
Assert.Equal(0, persistence.ListCalls);
Assert.Equal(0, persistence.LoadCalls);
Assert.Equal(0, persistence.ReplaceCalls);
Assert.Equal(0, persistence.CreateCalls);
Assert.Equal(0, persistence.DeleteCalls);
}
[Fact]
public async Task Load_by_opaque_id_selects_and_loads_in_one_native_command()
{
var source = LegacyStockCutCatalog.CreatePlaylistRow(
"source-row",
"Samsung Electronics",
new LegacyStockIdentity(
LegacyDomesticStockMarket.Kospi,
"Samsung Electronics",
"005930"),
LegacyStockCutCatalog.Rows[1]);
var selection = Assert.IsType<LegacySceneSelection>(
source.ToPlayoutEntry().Selection);
var persistence = new OperatorNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
selection,
new NamedPlaylistPageState(1, 1))
]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId);
Assert.Equal(1, persistence.LoadCalls);
Assert.Single(loaded.Playlist);
Assert.Equal("load-named-playlist-by-id", loaded.CommandResult?.Command);
Assert.Equal(definitionId, loaded.CommandResult?.TargetId);
Assert.True(loaded.CommandResult?.Succeeded);
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh,
loaded.NamedPlaylist?.DocumentFreshness);
}
[Fact]
public async Task LoadSelected_ReplacesPlaylistWithTypedOriginalSevenFields()
{
var persistence = new OperatorNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
false,
new LegacySceneSelection(
"코스피",
"삼성전자",
"1열판기본",
"현재가",
"005930"),
new NamedPlaylistPageState(1, 1))
]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
var loaded = await controller.LoadSelectedNamedPlaylistAsync();
var row = Assert.Single(loaded.Playlist);
Assert.False(row.IsEnabled);
Assert.Equal("코스피", row.MarketText);
Assert.Equal("삼성전자", row.StockName);
Assert.Equal("1열판기본", row.GraphicType);
Assert.Equal("현재가", row.Subtype);
Assert.Equal("1/1", row.PageText);
Assert.Equal("005930", row.StockCode);
Assert.Equal("5001", row.SceneAlias);
Assert.Equal("코스피", row.PlayoutSelection?.GroupCode);
}
[Fact]
public async Task LoadSelected_UnknownSceneKeepsCurrentPlaylistAtomically()
{
var persistence = new OperatorNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
"UNKNOWN-GROUP",
"알 수 없음",
"UNKNOWN-GRAPHIC",
"UNKNOWN-SUBTYPE",
"UNKNOWN-DATA"),
null)
]);
var controller = CreateController(persistence);
var fixedAction = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Overseas)
.First(action => action.Available);
controller.ActivateFixedAction(fixedAction.Id);
var before = controller.Current.Playlist.Single();
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = listed.NamedPlaylist!.Definitions.Single().DefinitionId;
var after = await controller.LoadNamedPlaylistByIdAsync(definitionId);
Assert.Equal(before.RowId, Assert.Single(after.Playlist).RowId);
Assert.NotNull(after.Dialog);
Assert.Equal(LegacyOperatorStatusKind.Error, after.StatusKind);
Assert.Equal("load-named-playlist-by-id", after.CommandResult?.Command);
Assert.False(after.CommandResult?.Succeeded);
}
[Fact]
public async Task SaveCurrent_WritesVisibleSevenFieldsOnceAndReadsBackFresh()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var action = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Overseas)
.First(candidate => candidate.Available);
var added = controller.ActivateFixedAction(action.Id);
var visible = Assert.Single(added.Playlist);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
var saved = await controller.SaveCurrentNamedPlaylistAsync();
Assert.Equal(1, persistence.ReplaceCalls);
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.Equal(0, stored.ItemIndex);
Assert.Equal(visible.IsEnabled, stored.IsEnabled);
Assert.Equal(visible.MarketText, stored.Selection.GroupCode);
Assert.Equal(visible.StockName, stored.Selection.Subject);
Assert.Equal(visible.GraphicType, stored.Selection.GraphicType);
Assert.Equal(visible.Subtype, stored.Selection.Subtype);
Assert.Equal(visible.StockCode, stored.Selection.DataCode);
Assert.Equal(visible.PageText, stored.Page?.ToString());
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.CommittedFresh,
saved.NamedPlaylist?.LastMutationOutcome);
}
[Fact]
public async Task Save_to_opaque_id_selects_and_writes_once_in_one_native_command()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var action = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Overseas)
.First(candidate => candidate.Available);
controller.ActivateFixedAction(action.Id);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
var saved = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command);
Assert.Equal(definitionId, saved.CommandResult?.TargetId);
Assert.True(saved.CommandResult?.Succeeded);
}
private static LegacyOperatorController CreateController(
INamedPlaylistPersistenceService persistence) =>
new(
new UnusedLookup(),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
private sealed class UnusedLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class OperatorNamedPlaylistPersistence : INamedPlaylistPersistenceService
{
private const string ProgramCode = "00000001";
private const string Title = "운영 재생목록";
private List<NamedPlaylistStoredItem> _items;
private bool _exists = true;
public OperatorNamedPlaylistPersistence(IEnumerable<NamedPlaylistStoredItem> items)
{
_items = items.Select(Clone).ToList();
}
public bool CanMutate => true;
public int ListCalls { get; private set; }
public int LoadCalls { get; private set; }
public int ReplaceCalls { get; private set; }
public int CreateCalls { get; private set; }
public int DeleteCalls { get; private set; }
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
public Task<NamedPlaylistListResult> ListAsync(
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
CancellationToken cancellationToken = default)
{
++ListCalls;
IReadOnlyList<NamedPlaylistSummary> summaries = _exists
? [new NamedPlaylistSummary(ProgramCode, Title)]
: [];
return Task.FromResult(new NamedPlaylistListResult(
DateTimeOffset.UtcNow,
summaries,
false));
}
public Task<string> SuggestNextProgramCodeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(_exists ? "00000002" : ProgramCode);
public Task<NamedPlaylistDocument> LoadAsync(
string programCode,
CancellationToken cancellationToken = default)
{
++LoadCalls;
if (!_exists || !string.Equals(programCode, ProgramCode, StringComparison.Ordinal))
{
return Task.FromException<NamedPlaylistDocument>(
new NamedPlaylistNotFoundException(programCode));
}
return Task.FromResult(new NamedPlaylistDocument(
new NamedPlaylistSummary(ProgramCode, Title),
DateTimeOffset.UtcNow,
_items.Select(Clone).ToArray()));
}
public Task CreateDefinitionAsync(
string programCode,
string title,
CancellationToken cancellationToken = default)
{
++CreateCalls;
_exists = true;
_items = [];
return Task.CompletedTask;
}
public Task ReplaceItemsAsync(
string programCode,
IReadOnlyList<NamedPlaylistStoredItem> items,
CancellationToken cancellationToken = default)
{
++ReplaceCalls;
LastReplaceItems = items.Select(Clone).ToArray();
_items = items.Select(Clone).ToList();
return Task.CompletedTask;
}
public Task DeleteAsync(
string programCode,
CancellationToken cancellationToken = default)
{
++DeleteCalls;
_exists = false;
_items = [];
return Task.CompletedTask;
}
private static NamedPlaylistStoredItem Clone(NamedPlaylistStoredItem item) =>
new(
item.ItemIndex,
item.IsEnabled,
item.Selection with { },
item.Page is null
? null
: new NamedPlaylistPageState(
item.Page.CurrentPage,
item.Page.TotalPages));
}
}

View File

@@ -0,0 +1,555 @@
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyNamedPlaylistWorkflowTests
{
[Fact]
public async Task RefreshSelectAndLoad_ExposeOpaqueIdentityAndTypedRowsOnly()
{
var persistence = CreateSeededPersistence();
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
var listed = await controller.RefreshAsync();
var selected = controller.SelectDefinition(listed.Definitions.Single(
definition => definition.Title == "마감").DefinitionId);
var loaded = await controller.LoadSelectedAsync();
Assert.Equal(LegacyNamedPlaylistReadStatus.Ready, listed.ReadStatus);
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, listed.ListFreshness);
Assert.All(listed.Definitions, definition =>
Assert.DoesNotContain("000000", definition.DefinitionId, StringComparison.Ordinal));
Assert.Equal("마감", selected.SelectedTitle);
Assert.Equal("00000002", persistence.LastLoadedProgramCode);
Assert.Collection(
loaded.Rows,
row =>
{
Assert.Equal(0, row.ItemIndex);
Assert.True(row.IsEnabled);
Assert.Equal("KOSDAQ", row.Selection.GroupCode);
Assert.Equal("다온테크", row.Selection.Subject);
Assert.Equal("1열판기본", row.Selection.GraphicType);
Assert.Equal("현재가", row.Selection.Subtype);
Assert.Equal("035720", row.Selection.DataCode);
Assert.Equal("1/1", row.Page?.PageText);
});
Assert.DoesNotContain(
typeof(LegacyNamedPlaylistDefinitionView).GetProperties(),
property => property.Name.Contains("ProgramCode", StringComparison.Ordinal));
Assert.DoesNotContain(
typeof(LegacyNamedPlaylistWorkflowSnapshot).GetProperties(),
property => property.Name.Contains("ProgramCode", StringComparison.Ordinal));
Assert.DoesNotContain(
typeof(LegacyNamedPlaylistRowView).GetProperties(),
property => property.Name.Contains("ListText", StringComparison.Ordinal));
}
[Fact]
public async Task SaveCurrent_SendsOneAtomicOrderedSevenFieldBatch_ThenReadsFresh()
{
var persistence = CreateSeededPersistence();
var controller = await LoadOpeningAsync(persistence);
var drafts = new[]
{
controller.CreateRowDraft(
Entry("entry-a", true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930"),
new NamedPlaylistPageState(1, 1)),
controller.CreateRowDraft(
Entry("entry-b", false, "PAGED_THEME", "반도체", "INPUT_ORDER", "CURRENT", "00000012"),
new NamedPlaylistPageState(2, 3))
};
var result = await controller.SaveCurrentAsync(drafts);
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, result.Outcome);
Assert.True(result.IsCommitted);
Assert.False(result.AutomaticRetryAllowed);
Assert.False(result.Snapshot.IsWriteInProgress);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.Collection(
persistence.LastReplaceItems!,
item =>
{
Assert.Equal(0, item.ItemIndex);
Assert.Equal(
"1^KOSPI^삼성전자^1열판기본^현재가^1/1^005930",
SevenFields(item));
},
item =>
{
Assert.Equal(1, item.ItemIndex);
Assert.Equal(
"0^PAGED_THEME^반도체^INPUT_ORDER^CURRENT^2/3^00000012",
SevenFields(item));
});
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, result.Snapshot.DocumentFreshness);
Assert.Equal(["삼성전자", "반도체"], result.Snapshot.Rows.Select(row => row.Selection.Subject));
}
[Fact]
public async Task CreateAndDelete_UseInternalSuggestedIdentityAndFreshListReadBack()
{
var persistence = CreateSeededPersistence();
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
await controller.RefreshAsync();
var draft = controller.CreateProgramDraft("새 프로그램");
var created = await controller.CreateProgramAsync(draft);
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, created.Outcome);
Assert.Equal("00000003", persistence.LastCreatedProgramCode);
Assert.Equal("새 프로그램", created.Snapshot.SelectedTitle);
Assert.Equal(3, created.Snapshot.Definitions.Count);
Assert.Empty(created.Snapshot.Rows);
Assert.DoesNotContain(
typeof(LegacyNamedPlaylistProgramDraft).GetProperties(),
property => property.Name.Contains("Code", StringComparison.Ordinal));
Assert.DoesNotContain("00000003", draft.DraftId, StringComparison.Ordinal);
var deleted = await controller.DeleteSelectedAsync();
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, deleted.Outcome);
Assert.Equal(1, persistence.DeleteCalls);
Assert.Equal("00000003", persistence.LastDeletedProgramCode);
Assert.Null(deleted.Snapshot.SelectedDefinitionId);
Assert.DoesNotContain(
deleted.Snapshot.Definitions,
definition => string.Equals(definition.Title, "새 프로그램", StringComparison.Ordinal));
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, deleted.Snapshot.ListFreshness);
}
[Fact]
public async Task KnownRollback_PreservesLoadedRowsAndNeverRetries()
{
var persistence = CreateSeededPersistence();
var controller = await LoadOpeningAsync(persistence);
var before = controller.Current;
var draft = controller.CreateRowDraft(
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
new NamedPlaylistPageState(1, 1));
persistence.ReplaceFailure = new NamedPlaylistMutationException(
"The transaction rolled back.",
outcomeUnknown: false);
var result = await controller.SaveCurrentAsync([draft]);
Assert.Equal(LegacyNamedPlaylistMutationOutcome.RolledBack, result.Outcome);
Assert.False(result.AutomaticRetryAllowed);
Assert.False(result.Snapshot.IsWriteQuarantined);
Assert.True(result.Snapshot.CanMutate);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.Equal(
before.Rows.Select(row => row.Selection.Subject),
result.Snapshot.Rows.Select(row => row.Selection.Subject));
Assert.Equal("삼성전자", persistence.StoredSubjects("00000001").Single());
}
[Fact]
public async Task OutcomeUnknown_LatchesQuarantineAndBlocksEveryLaterWriteWithoutRetry()
{
var persistence = CreateSeededPersistence();
var controller = await LoadOpeningAsync(persistence);
var draft = controller.CreateRowDraft(
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
new NamedPlaylistPageState(1, 1));
persistence.ReplaceFailure = new NamedPlaylistMutationException(
"Commit response was lost.",
outcomeUnknown: true);
var unknown = await controller.SaveCurrentAsync([draft]);
persistence.ReplaceFailure = null;
var blocked = await controller.SaveCurrentAsync([draft]);
Assert.Equal(LegacyNamedPlaylistMutationOutcome.OutcomeUnknown, unknown.Outcome);
Assert.True(unknown.Snapshot.IsWriteQuarantined);
Assert.False(unknown.Snapshot.CanMutate);
Assert.False(unknown.AutomaticRetryAllowed);
Assert.Equal(LegacyNamedPlaylistMutationOutcome.Quarantined, blocked.Outcome);
Assert.False(blocked.AutomaticRetryAllowed);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.Equal("삼성전자", persistence.StoredSubjects("00000001").Single());
}
[Fact]
public async Task WriteGate_ReturnsBusyWhileFirstAtomicSaveIsPending()
{
var persistence = CreateSeededPersistence();
var entered = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
persistence.ReplaceEntered = entered;
persistence.ReleaseReplace = release;
var controller = await LoadOpeningAsync(persistence);
var draft = controller.CreateRowDraft(
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
new NamedPlaylistPageState(1, 1));
var first = controller.SaveCurrentAsync([draft]);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var concurrent = await controller.SaveCurrentAsync([draft]);
release.SetResult();
var completed = await first;
Assert.Equal(LegacyNamedPlaylistMutationOutcome.Busy, concurrent.Outcome);
Assert.False(concurrent.AutomaticRetryAllowed);
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, completed.Outcome);
Assert.Equal(1, persistence.ReplaceCalls);
}
[Fact]
public async Task FailedFreshReadBack_KeepsOptimisticRowsUntilExplicitFreshLoad()
{
var persistence = CreateSeededPersistence();
var controller = await LoadOpeningAsync(persistence);
var draft = controller.CreateRowDraft(
Entry("replacement", true, "KOSPI", "낙관행", "1열판기본", "현재가", "654321"),
new NamedPlaylistPageState(1, 1));
persistence.LoadFailures.Enqueue(
new NamedPlaylistDataException("Simulated read-back failure."));
var optimistic = await controller.SaveCurrentAsync([draft]);
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic,
optimistic.Outcome);
Assert.Equal(
LegacyNamedPlaylistFreshness.Optimistic,
optimistic.Snapshot.DocumentFreshness);
Assert.Equal("낙관행", Assert.Single(optimistic.Snapshot.Rows).Selection.Subject);
Assert.False(optimistic.Snapshot.IsWriteQuarantined);
Assert.Equal(1, persistence.ReplaceCalls);
var fresh = await controller.LoadSelectedAsync();
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, fresh.DocumentFreshness);
Assert.Equal("낙관행", Assert.Single(fresh.Rows).Selection.Subject);
Assert.Equal(1, persistence.ReplaceCalls);
}
[Fact]
public async Task ReadOnlyPersistence_RejectsMutationBeforeServiceCall()
{
var persistence = CreateSeededPersistence();
persistence.CanMutate = false;
var controller = await LoadOpeningAsync(persistence);
var draft = controller.CreateRowDraft(
Entry("replacement", true, "KOSPI", "교체", "1열판기본", "현재가", "123456"),
new NamedPlaylistPageState(1, 1));
var result = await controller.SaveCurrentAsync([draft]);
Assert.Equal(LegacyNamedPlaylistMutationOutcome.ReadOnly, result.Outcome);
Assert.False(result.Snapshot.CanMutate);
Assert.Equal(0, persistence.ReplaceCalls);
}
[Fact]
public async Task SnapshotsAreImmutable_AndStaleOpaqueIdsAreRejectedAfterRefresh()
{
var persistence = CreateSeededPersistence();
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
var before = await controller.RefreshAsync();
var staleId = before.Definitions[0].DefinitionId;
controller.SelectDefinition(staleId);
var loaded = await controller.LoadSelectedAsync();
var after = await controller.RefreshAsync();
var definitions = Assert.IsAssignableFrom<
ICollection<LegacyNamedPlaylistDefinitionView>>(before.Definitions);
Assert.Throws<NotSupportedException>(() => definitions.Clear());
var rows = Assert.IsAssignableFrom<ICollection<LegacyNamedPlaylistRowView>>(loaded.Rows);
Assert.Throws<NotSupportedException>(() => rows.Clear());
Assert.NotEqual(staleId, after.SelectedDefinitionId);
Assert.Throws<KeyNotFoundException>(() => controller.SelectDefinition(staleId));
Assert.Equal("마감", before.Definitions[0].Title);
}
[Fact]
public async Task DraftsAreBoundToTheirNativeAuthorityInstance()
{
var firstPersistence = CreateSeededPersistence();
var secondPersistence = CreateSeededPersistence();
var first = await LoadOpeningAsync(firstPersistence);
var second = await LoadOpeningAsync(secondPersistence);
var foreignDraft = first.CreateRowDraft(
Entry("foreign", true, "KOSPI", "외부", "1열판기본", "현재가", "111111"),
new NamedPlaylistPageState(1, 1));
await Assert.ThrowsAsync<ArgumentException>(
() => second.SaveCurrentAsync([foreignDraft]));
Assert.Equal(0, secondPersistence.ReplaceCalls);
}
private static FakeNamedPlaylistPersistence CreateSeededPersistence()
{
var persistence = new FakeNamedPlaylistPersistence();
persistence.Seed(
"00000001",
"오전",
StoredItem(0, true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930", 1, 1));
persistence.Seed(
"00000002",
"마감",
StoredItem(0, true, "KOSDAQ", "다온테크", "1열판기본", "현재가", "035720", 1, 1));
return persistence;
}
private static async Task<LegacyNamedPlaylistWorkflowController> LoadOpeningAsync(
FakeNamedPlaylistPersistence persistence)
{
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
var listed = await controller.RefreshAsync();
controller.SelectDefinition(listed.Definitions.Single(
definition => definition.Title == "오전").DefinitionId);
await controller.LoadSelectedAsync();
return controller;
}
private static LegacyPlaylistEntry Entry(
string id,
bool enabled,
string groupCode,
string subject,
string graphicType,
string subtype,
string dataCode) =>
new(
id,
"5001",
enabled,
6,
new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
dataCode));
private static NamedPlaylistStoredItem StoredItem(
int index,
bool enabled,
string groupCode,
string subject,
string graphicType,
string subtype,
string dataCode,
int currentPage,
int totalPages) =>
new(
index,
enabled,
new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
dataCode),
new NamedPlaylistPageState(currentPage, totalPages));
private static string SevenFields(NamedPlaylistStoredItem item) =>
string.Join(
'^',
item.IsEnabled ? "1" : "0",
item.Selection.GroupCode,
item.Selection.Subject,
item.Selection.GraphicType,
item.Selection.Subtype,
item.Page?.ToString() ?? string.Empty,
item.Selection.DataCode);
private sealed class FakeNamedPlaylistPersistence : INamedPlaylistPersistenceService
{
private readonly Dictionary<string, StoredProgram> _programs =
new(StringComparer.Ordinal);
public bool CanMutate { get; set; } = true;
public int CreateCalls { get; private set; }
public int ReplaceCalls { get; private set; }
public int DeleteCalls { get; private set; }
public int LoadCalls { get; private set; }
public string? LastCreatedProgramCode { get; private set; }
public string? LastLoadedProgramCode { get; private set; }
public string? LastDeletedProgramCode { get; private set; }
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
public Exception? CreateFailure { get; set; }
public Exception? ReplaceFailure { get; set; }
public Exception? DeleteFailure { get; set; }
public Queue<Exception> LoadFailures { get; } = new();
public TaskCompletionSource? ReplaceEntered { get; set; }
public TaskCompletionSource? ReleaseReplace { get; set; }
public void Seed(
string programCode,
string title,
params NamedPlaylistStoredItem[] items) =>
_programs.Add(
programCode,
new StoredProgram(title, items.Select(Clone).ToList()));
public IEnumerable<string> StoredSubjects(string programCode) =>
_programs[programCode].Items.Select(item => item.Selection.Subject);
public Task<NamedPlaylistListResult> ListAsync(
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var ordered = _programs
.Select(pair => new NamedPlaylistSummary(pair.Key, pair.Value.Title))
.OrderBy(summary => summary.Title, StringComparer.Ordinal)
.ThenBy(summary => summary.ProgramCode, StringComparer.Ordinal)
.ToArray();
return Task.FromResult(new NamedPlaylistListResult(
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
ordered.Take(maximumResults).ToArray(),
ordered.Length > maximumResults));
}
public Task<string> SuggestNextProgramCodeAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var next = _programs.Count == 0
? 1
: _programs.Keys.Max(code => int.Parse(
code,
NumberStyles.None,
CultureInfo.InvariantCulture)) + 1;
return Task.FromResult(next.ToString("D8", CultureInfo.InvariantCulture));
}
public Task<NamedPlaylistDocument> LoadAsync(
string programCode,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
++LoadCalls;
LastLoadedProgramCode = programCode;
if (LoadFailures.TryDequeue(out var failure))
{
return Task.FromException<NamedPlaylistDocument>(failure);
}
if (!_programs.TryGetValue(programCode, out var stored))
{
return Task.FromException<NamedPlaylistDocument>(
new NamedPlaylistNotFoundException(programCode));
}
return Task.FromResult(new NamedPlaylistDocument(
new NamedPlaylistSummary(programCode, stored.Title),
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
stored.Items.Select(Clone).ToArray()));
}
public Task CreateDefinitionAsync(
string programCode,
string title,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
++CreateCalls;
LastCreatedProgramCode = programCode;
if (CreateFailure is not null)
{
return Task.FromException(CreateFailure);
}
if (!_programs.TryAdd(programCode, new StoredProgram(title, [])))
{
return Task.FromException(new NamedPlaylistMutationException(
"Duplicate program; rolled back.",
outcomeUnknown: false));
}
return Task.CompletedTask;
}
public async Task ReplaceItemsAsync(
string programCode,
IReadOnlyList<NamedPlaylistStoredItem> items,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
++ReplaceCalls;
LastReplaceItems = Array.AsReadOnly(items.Select(Clone).ToArray());
ReplaceEntered?.TrySetResult();
if (ReleaseReplace is not null)
{
await ReleaseReplace.Task.WaitAsync(cancellationToken);
}
if (ReplaceFailure is not null)
{
throw ReplaceFailure;
}
if (!_programs.TryGetValue(programCode, out var stored))
{
throw new NamedPlaylistNotFoundException(programCode);
}
// One assignment models the executor's transaction boundary: no partial row
// becomes visible when an exception is raised before this point.
_programs[programCode] = stored with
{
Items = items.Select(Clone).ToList()
};
}
public Task DeleteAsync(
string programCode,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
++DeleteCalls;
LastDeletedProgramCode = programCode;
if (DeleteFailure is not null)
{
return Task.FromException(DeleteFailure);
}
if (!_programs.Remove(programCode))
{
return Task.FromException(new NamedPlaylistNotFoundException(programCode));
}
return Task.CompletedTask;
}
private static NamedPlaylistStoredItem Clone(NamedPlaylistStoredItem item) =>
new(
item.ItemIndex,
item.IsEnabled,
item.Selection with { },
item.Page is null
? null
: new NamedPlaylistPageState(
item.Page.CurrentPage,
item.Page.TotalPages));
private sealed record StoredProgram(
string Title,
List<NamedPlaylistStoredItem> Items);
}
}

View File

@@ -0,0 +1,394 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorCatalogWorkflowTests
{
[Fact]
public async Task ThemeA_create_uses_opaque_stock_selection_and_writes_only_on_explicit_save()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
Assert.True(opened.SchemaValidated);
Assert.Single(opened.Results);
var creating = await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
Assert.Equal(LegacyOperatorCatalogMode.Create, creating.Mode);
Assert.Equal("00000009", creating.CodeLabel);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
var stocks = await workflow.SearchStocksAsync("삼성");
var stockId = Assert.Single(stocks.StockResults).ResultId;
workflow.SelectStock(stockId);
var draft = workflow.AddSelectedStock(null);
Assert.Single(draft.DraftRows);
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
var saved = await workflow.SaveAsync("반도체 대표주");
Assert.Equal(1, fixture.ThemePersistence.CreateCalls);
Assert.Equal(1, fixture.IdentityValidation.ThemeCalls);
var definition = Assert.IsType<ThemeCatalogDefinition>(
fixture.ThemePersistence.LastCreated);
Assert.Equal("00000009", definition.ThemeCode);
Assert.Equal("P005930", Assert.Single(definition.Items).ItemCode);
Assert.Equal(LegacyOperatorCatalogMode.List, saved.Mode);
Assert.False(saved.WritesQuarantined);
}
[Fact]
public async Task ThemeA_existing_row_requires_fresh_complete_preview_for_edit_and_delete()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
var selected = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
Assert.Equal(LegacyOperatorCatalogMode.Edit, selected.Mode);
Assert.Equal("AI 반도체", selected.EditorName);
Assert.Single(selected.DraftRows);
Assert.Equal(1, fixture.ThemeSelection.PreviewCalls);
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
var deleted = await workflow.DeleteAsync();
Assert.Equal(1, fixture.ThemePersistence.DeleteCalls);
Assert.Equal(LegacyOperatorCatalogMode.List, deleted.Mode);
}
[Fact]
public async Task EList_preserves_buy_amount_and_CSharp_owned_reorder_in_one_create()
{
var fixture = new Fixture();
fixture.StockSearch.Items =
[
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "삼성전자", "005930"),
new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "카카오", "035720")
];
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
await workflow.BeginCreateExpertAsync();
var stocks = await workflow.SearchStocksAsync("주식");
workflow.SelectStock(stocks.StockResults[0].ResultId);
workflow.AddSelectedStock(70_000);
workflow.SelectStock(stocks.StockResults[1].ResultId);
var twoRows = workflow.AddSelectedStock(45_000);
twoRows = workflow.SetDraftBuyAmount(twoRows.DraftRows[1].RowId, 46_000);
workflow.MoveDraftRow(twoRows.DraftRows[1].RowId, LegacyOperatorCatalogMoveDirection.Up);
Assert.Equal(0, fixture.ExpertPersistence.CreateCalls);
await workflow.SaveAsync("김전문");
Assert.Equal(1, fixture.ExpertPersistence.CreateCalls);
Assert.Equal(1, fixture.IdentityValidation.ExpertCalls);
var definition = Assert.IsType<ExpertCatalogDefinition>(
fixture.ExpertPersistence.LastCreated);
Assert.Collection(
definition.Recommendations,
row =>
{
Assert.Equal("035720", row.StockCode);
Assert.Equal(46_000, row.BuyAmount);
Assert.Equal(0, row.PlayIndex);
},
row =>
{
Assert.Equal("005930", row.StockCode);
Assert.Equal(70_000, row.BuyAmount);
Assert.Equal(1, row.PlayIndex);
});
}
[Fact]
public async Task Uc4_and_Uc6_edit_entries_resolve_only_the_native_selected_identity()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
var theme = await workflow.OpenThemeForEditAsync(new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI 반도체"));
Assert.Equal(LegacyOperatorCatalogEntity.Theme, theme.Entity);
Assert.Equal(LegacyOperatorCatalogMode.Edit, theme.Mode);
Assert.Equal("AI 반도체", theme.EditorName);
Assert.True(theme.CanDelete);
var expert = await workflow.OpenExpertForEditAsync(
new ExpertSelectionIdentity("0001", "이전문"));
Assert.Equal(LegacyOperatorCatalogEntity.Expert, expert.Entity);
Assert.Equal(LegacyOperatorCatalogMode.Edit, expert.Mode);
Assert.Equal("이전문", expert.EditorName);
Assert.Equal(70_000, Assert.Single(expert.DraftRows).BuyAmount);
}
[Fact]
public async Task OutcomeUnknown_latches_process_safety_instance_and_never_retries()
{
var fixture = new Fixture();
fixture.ThemePersistence.CreateException = new OperatorCatalogMutationException(
"lost commit acknowledgement",
outcomeUnknown: true);
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
var stocks = await workflow.SearchStocksAsync("삼성");
workflow.SelectStock(Assert.Single(stocks.StockResults).ResultId);
workflow.AddSelectedStock(null);
var unknown = await workflow.SaveAsync("확인 불명 테마");
var blocked = await workflow.SaveAsync("확인 불명 테마");
Assert.True(unknown.WritesQuarantined);
Assert.True(blocked.WritesQuarantined);
Assert.False(blocked.CanSave);
Assert.Equal(1, fixture.ThemePersistence.CreateCalls);
}
[Fact]
public async Task Fabricated_catalog_stock_and_draft_ids_fail_before_authority_is_used()
{
var fixture = new Fixture();
var workflow = fixture.CreateWorkflow();
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
await Assert.ThrowsAsync<ArgumentException>(() =>
workflow.SelectAsync("catalog-result-fabricated"));
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
await workflow.SearchStocksAsync("삼성");
Assert.Throws<ArgumentException>(() => workflow.SelectStock("catalog-stock-fabricated"));
Assert.Throws<ArgumentException>(() =>
workflow.RemoveDraftRow("catalog-draft-fabricated"));
}
private sealed class Fixture
{
internal FakeThemeSelection ThemeSelection { get; } = new();
internal FakeExpertSelection ExpertSelection { get; } = new();
internal FakeThemePersistence ThemePersistence { get; } = new();
internal FakeExpertPersistence ExpertPersistence { get; } = new();
internal FakeStockSearch StockSearch { get; } = new();
internal FakeIdentityValidation IdentityValidation { get; } = new();
internal LegacyOperatorCatalogWorkflowController CreateWorkflow() =>
new(
ThemeSelection,
ExpertSelection,
ThemePersistence,
ExpertPersistence,
StockSearch,
IdentityValidation,
new FakeSchemaValidation(),
new LegacyOperatorCatalogWriteSafetyState());
}
private sealed class FakeThemeSelection : IThemeSelectionService
{
internal int PreviewCalls { get; private set; }
public Task<ThemeSearchResult> SearchAsync(
string query,
ThemeSession nxtSession,
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ThemeSearchResult(
query,
nxtSession,
DateTimeOffset.UtcNow,
[
new ThemeSelectorItem(
new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"00000001",
"AI 반도체"),
DataSourceKind.Oracle)
],
false));
public Task<ThemePreviewResult> PreviewAsync(
ThemeSelectionIdentity identity,
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default)
{
PreviewCalls++;
return Task.FromResult(new ThemePreviewResult(
identity,
DateTimeOffset.UtcNow,
[new ThemeItemPreview(4, "P005930", "삼성전자")],
false));
}
}
private sealed class FakeExpertSelection : IExpertSelectionService
{
public Task<ExpertSearchResult> SearchAsync(
string query = "",
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ExpertSearchResult(
query,
DateTimeOffset.UtcNow,
[
new ExpertSelectorItem(
new ExpertSelectionIdentity("0001", "이전문"),
DataSourceKind.Oracle)
],
false));
public Task<ExpertPreviewResult> PreviewAsync(
ExpertSelectionIdentity identity,
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
CancellationToken cancellationToken = default) =>
Task.FromResult(new ExpertPreviewResult(
identity,
DateTimeOffset.UtcNow,
[new ExpertRecommendationPreview(0, "005930", "삼성전자", 70_000)],
false));
}
private sealed class FakeThemePersistence : IThemeCatalogPersistenceService
{
internal int CreateCalls { get; private set; }
internal int DeleteCalls { get; private set; }
internal ThemeCatalogDefinition? LastCreated { get; private set; }
internal Exception? CreateException { get; set; }
public bool CanMutate => true;
public Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default) =>
Task.FromResult("00000009");
public Task<OperatorCatalogMutationReceipt> CreateAsync(
ThemeCatalogDefinition definition,
CancellationToken cancellationToken = default)
{
CreateCalls++;
LastCreated = definition;
return CreateException is null
? Receipt(OperatorCatalogMutationKind.CreateTheme)
: Task.FromException<OperatorCatalogMutationReceipt>(CreateException);
}
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ThemeSelectionIdentity expectedIdentity,
string newTitle,
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default) =>
Receipt(OperatorCatalogMutationKind.ReplaceTheme);
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
ThemeSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default)
{
DeleteCalls++;
return Receipt(OperatorCatalogMutationKind.DeleteTheme);
}
}
private sealed class FakeExpertPersistence : IExpertCatalogPersistenceService
{
internal int CreateCalls { get; private set; }
internal ExpertCatalogDefinition? LastCreated { get; private set; }
public bool CanMutate => true;
public Task<string> SuggestNextCodeAsync(CancellationToken cancellationToken = default) =>
Task.FromResult("0009");
public Task<OperatorCatalogMutationReceipt> CreateAsync(
ExpertCatalogDefinition definition,
CancellationToken cancellationToken = default)
{
CreateCalls++;
LastCreated = definition;
return Receipt(OperatorCatalogMutationKind.CreateExpert);
}
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
ExpertSelectionIdentity expectedIdentity,
string newName,
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default) =>
Receipt(OperatorCatalogMutationKind.ReplaceExpert);
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
ExpertSelectionIdentity expectedIdentity,
CancellationToken cancellationToken = default) =>
Receipt(OperatorCatalogMutationKind.DeleteExpert);
}
private sealed class FakeStockSearch : IStockSearchService
{
internal IReadOnlyList<StockSearchItem> Items { get; set; } =
[new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "삼성전자", "005930")];
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult(new StockSearchResult(
query,
DateTimeOffset.UtcNow,
Items,
false));
}
private sealed class FakeIdentityValidation : IStockMasterIdentityValidationService
{
internal int ThemeCalls { get; private set; }
internal int ExpertCalls { get; private set; }
public Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default)
{
ThemeCalls++;
return Task.FromResult<IReadOnlyList<StockMasterIdentity>>([]);
}
public Task<IReadOnlyList<StockMasterIdentity>> ValidateExpertRecommendationsAsync(
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default)
{
ExpertCalls++;
return Task.FromResult<IReadOnlyList<StockMasterIdentity>>([]);
}
}
private sealed class FakeSchemaValidation : IOperatorCatalogSchemaValidationService
{
public Task<OperatorCatalogSchemaValidationResult> ValidateAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new OperatorCatalogSchemaValidationResult(
DateTimeOffset.UtcNow,
[DataSourceKind.Oracle, DataSourceKind.MariaDb]));
}
private static Task<OperatorCatalogMutationReceipt> Receipt(
OperatorCatalogMutationKind kind) =>
Task.FromResult(new OperatorCatalogMutationReceipt(
Guid.NewGuid(),
kind,
DateTimeOffset.UtcNow));
}

View File

@@ -0,0 +1,230 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyTradingHaltWorkflowTests
{
private static readonly TradingHaltSelection KospiAlpha =
new(TradingHaltMarket.Kospi, "005930", "가온전자");
private static readonly TradingHaltSelection KospiBeta =
new(TradingHaltMarket.Kospi, "000660", "한빛반도체");
private static readonly TradingHaltSelection KosdaqAlpha =
new(TradingHaltMarket.Kosdaq, "035720", "다온테크");
[Fact]
public async Task SearchSelect_MaterializesClosedCurrentAndCandleDraftsFromActionIdOnly()
{
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, KospiAlpha, KospiBeta, KosdaqAlpha))
};
var controller = new LegacyTradingHaltWorkflowController(service);
var search = await controller.SearchAsync(" 거래 ");
var selected = controller.SelectResult(search.Results[2].SelectionId);
controller.SetMovingAverages(ma5: true, ma20: true);
var current = controller.MaterializeSelectedAction(
LegacyTradingHaltWorkflowController.CurrentActionId);
var candle = controller.MaterializeSelectedAction(
LegacyTradingHaltWorkflowController.CandleActionId);
Assert.Equal("거래", service.SearchQuery);
Assert.Equal(500, service.SearchMaximum);
Assert.Equal("다온테크", selected.Selected?.DisplayName);
Assert.Collection(
selected.Actions,
action =>
{
Assert.Equal("current", action.ActionId);
Assert.True(action.IsAvailable);
},
action =>
{
Assert.Equal("candle-120d", action.ActionId);
Assert.True(action.IsAvailable);
});
Assert.Equal("s5001", current.BuilderKey);
Assert.Equal("5001", current.CutCode);
Assert.Equal(new[] { "5001" }, current.Aliases);
Assert.Equal("거래정지-현재가", current.Detail);
Assert.Equal("stock", current.Category);
Assert.Equal("kosdaq", current.Market);
Assert.Equal("KOSDAQ", current.Selection.GroupCode);
Assert.Equal("다온테크", current.Selection.Subject);
Assert.Equal("1열판기본", current.Selection.GraphicType);
Assert.Equal("HALTED", current.Selection.Subtype);
Assert.Equal("035720", current.Selection.DataCode);
Assert.False(current.Ma5);
Assert.False(current.Ma20);
Assert.Null(current.CandlePeriodDays);
Assert.Equal("s8010", candle.BuilderKey);
Assert.Equal("8051", candle.CutCode);
Assert.Equal(new[] { "8051" }, candle.Aliases);
Assert.Equal("캔들그래프-거래정지 · 120일", candle.Detail);
Assert.Equal("chart", candle.Category);
Assert.Equal("kosdaq", candle.Market);
Assert.Equal("KOSDAQ_STOCK", candle.Selection.GroupCode);
Assert.Equal("다온테크", candle.Selection.Subject);
Assert.Equal("MA5,MA20", candle.Selection.GraphicType);
Assert.Equal("TRADING_HALT", candle.Selection.Subtype);
Assert.Equal("035720", candle.Selection.DataCode);
Assert.True(candle.Ma5);
Assert.True(candle.Ma20);
Assert.Equal(120, candle.CandlePeriodDays);
Assert.Equal("1/1", candle.PageText);
Assert.True(candle.IsEnabled);
Assert.Equal(6, candle.FadeDuration);
Assert.Equal("legacy-trading-halt-workflow", candle.Source);
Assert.Equal(candle.DraftId, candle.ToLegacyPlaylistEntry().EntryId);
var method = typeof(LegacyTradingHaltWorkflowController).GetMethod(
nameof(LegacyTradingHaltWorkflowController.MaterializeSelectedAction));
Assert.NotNull(method);
Assert.Collection(
method.GetParameters(),
parameter => Assert.Equal("actionId", parameter.Name));
Assert.Throws<ArgumentOutOfRangeException>(
() => controller.MaterializeSelectedAction("8051-caller-override"));
}
[Fact]
public async Task NameSort_FirstClickIsDescendingThenAscending_AndKeepsSelection()
{
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, KospiAlpha, KospiBeta, KosdaqAlpha))
};
var controller = new LegacyTradingHaltWorkflowController(service);
var source = await controller.SearchAsync(string.Empty);
controller.SelectResult(source.Results[0].SelectionId);
var descending = controller.ToggleNameSort();
var ascending = controller.ToggleNameSort();
Assert.Equal(
new[] { "가온전자", "한빛반도체", "다온테크" },
source.Results.Select(row => row.DisplayName));
Assert.Equal(LegacyTradingHaltNameSort.Descending, descending.NameSort);
Assert.Equal(
new[] { "한빛반도체", "다온테크", "가온전자" },
descending.Results.Select(row => row.DisplayName));
Assert.Equal(LegacyTradingHaltNameSort.Ascending, ascending.NameSort);
Assert.Equal(
new[] { "가온전자", "다온테크", "한빛반도체" },
ascending.Results.Select(row => row.DisplayName));
Assert.Equal("005930", ascending.Selected?.StockCode);
Assert.True(Assert.Single(ascending.Results, row => row.IsSelected).IsSelected);
}
[Fact]
public async Task LateSearchResponse_CannotReplaceNewerTradingHaltResult()
{
var first = new TaskCompletionSource<TradingHaltSelectionResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
var second = new TaskCompletionSource<TradingHaltSelectionResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => query == "첫째" ? first.Task : second.Task
};
var controller = new LegacyTradingHaltWorkflowController(service);
var firstRequest = controller.SearchAsync("첫째");
var secondRequest = controller.SearchAsync("둘째");
second.SetResult(SearchResult("둘째", KosdaqAlpha));
var newest = await secondRequest;
first.SetResult(SearchResult("첫째", KospiAlpha));
var staleCompletion = await firstRequest;
Assert.Equal("둘째", newest.Query);
Assert.Equal("다온테크", Assert.Single(newest.Results).DisplayName);
Assert.Equal("둘째", staleCompletion.Query);
Assert.Equal("다온테크", Assert.Single(staleCompletion.Results).DisplayName);
Assert.Equal("둘째", controller.Current.Query);
}
[Fact]
public async Task InvalidIdentityOrSourceOrder_IsRejectedAndLeavesErrorSnapshot()
{
var duplicateName = new TradingHaltSelection(
TradingHaltMarket.Kospi,
"005931",
KospiAlpha.DisplayName);
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, KospiAlpha, duplicateName))
};
var controller = new LegacyTradingHaltWorkflowController(service);
await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => controller.SearchAsync(string.Empty));
Assert.Equal(LegacyTradingHaltRequestStatus.Error, controller.Current.Status);
Assert.Empty(controller.Current.Results);
Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
}
[Fact]
public async Task TradingHaltSnapshots_AreImmutableAcrossLaterQueries()
{
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => Task.FromResult(
query == "A"
? SearchResult(query, KospiAlpha)
: SearchResult(query, KosdaqAlpha))
};
var controller = new LegacyTradingHaltWorkflowController(service);
var before = await controller.SearchAsync("A");
var after = await controller.SearchAsync("B");
Assert.Equal("가온전자", Assert.Single(before.Results).DisplayName);
Assert.Equal("다온테크", Assert.Single(after.Results).DisplayName);
var rows = Assert.IsAssignableFrom<ICollection<LegacyTradingHaltResultView>>(
before.Results);
Assert.Throws<NotSupportedException>(() => rows.Clear());
var actions = Assert.IsAssignableFrom<ICollection<LegacyTradingHaltActionView>>(
before.Actions);
Assert.Throws<NotSupportedException>(() => actions.Add(before.Actions[0]));
}
private static TradingHaltSelectionResult SearchResult(
string query,
params TradingHaltSelection[] selections) =>
new(
query,
DateTimeOffset.Parse("2026-07-15T00:00:00+09:00"),
selections,
false);
private sealed class DelegateTradingHaltService : ITradingHaltSelectionService
{
public Func<string, int, CancellationToken, Task<TradingHaltSelectionResult>> Search
{
get;
init;
} = (query, _, _) => Task.FromResult(SearchResult(query));
public string? SearchQuery { get; private set; }
public int SearchMaximum { get; private set; }
public Task<TradingHaltSelectionResult> SearchAsync(
string query = "",
int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
CancellationToken cancellationToken = default)
{
SearchQuery = query;
SearchMaximum = maximumResults;
return Search(query, maximumResults, cancellationToken);
}
}
}