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