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