Files
MBN_STOCK_WEBVIEW/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyThemeWorkflowTests.cs

998 lines
39 KiB
C#

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));
Assert.Equal(5_000, LegacyThemeWorkflow.MaximumSearchResults);
Assert.Equal(
LegacyThemeSelectionService.MaximumResults,
LegacyThemeWorkflow.MaximumSearchResults);
}
[Theory]
[InlineData(0, ThemeSession.PreMarket)]
[InlineData(12, ThemeSession.PreMarket)]
[InlineData(13, ThemeSession.AfterMarket)]
[InlineData(23, ThemeSession.AfterMarket)]
public void CurrentNxtSession_UsesTheOriginalNativeLocalHourBoundary(
int hour,
ThemeSession expected)
{
var workflow = new LegacyThemeWorkflow(
new FakeThemeSelectionService(),
() => new DateTimeOffset(2026, 7, 18, hour, 0, 0, TimeSpan.FromHours(9)));
Assert.Equal(expected, workflow.CurrentNxtSession);
}
[Fact]
public async Task ResetForTabActivation_RestoresTheNewUc4Defaults()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001");
var workflow = new LegacyThemeWorkflow(
ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]));
var selected = workflow.SelectSort(
await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket),
"GAIN_DESC");
var reset = workflow.ResetForTabActivation(selected);
Assert.Equal(selected.Revision + 1, reset.Revision);
Assert.Equal(string.Empty, reset.Query);
Assert.Equal(ThemeSession.PreMarket, reset.NxtSession);
Assert.Equal(LegacyThemeSort.InputOrder, reset.Sort);
Assert.Empty(reset.SearchResults);
Assert.Null(reset.SelectedResultId);
Assert.Null(reset.SelectedTheme);
Assert.Empty(reset.PreviewItems);
}
[Fact]
public async Task SearchAsync_NormalizesQueryAndPreservesProviderOrderWithinKrxThenNxt()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Zulu", "00000002"),
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Alpha", "00000001"),
Selector(ThemeMarket.Nxt, session, "Robotics", "00000003"))
};
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("Zulu", row.DisplayTitle);
Assert.Equal(ThemeMarket.Krx, row.Market);
Assert.Equal(ThemeSession.NotApplicable, row.Session);
Assert.Equal(DataSourceKind.Oracle, row.Source);
},
row => Assert.Equal("Alpha", 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"));
var storedSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal("AI(NXT)", Assert.Single(storedSuffix.SearchResults).DisplayTitle);
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Nxt, session, "AI(NXT)(NXT)", "00000001"));
var repeatedNxtSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal(
"AI(NXT)(NXT)",
Assert.Single(repeatedNxtSuffix.SearchResults).Identity.ThemeTitle);
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI(NXT)", "00000001"));
var krxStoredSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal("AI(NXT)", Assert.Single(krxStoredSuffix.SearchResults).DisplayTitle);
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI(NXT)(NXT)", "00000001"));
var repeatedKrxSuffix = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal(ThemeMarket.Krx, Assert.Single(repeatedKrxSuffix.SearchResults).Market);
service.SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000002"));
var duplicateTitles = await workflow.SearchAsync(
snapshot,
"AI",
ThemeSession.PreMarket);
Assert.Equal(2, duplicateTitles.SearchResults.Count);
}
[Fact]
public async Task SearchAsync_RejectsProviderRowsThatWouldRequireReordering()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Nxt, session, "NXT first", "00000002"),
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "KRX later", "00000001"))
};
var workflow = new LegacyThemeWorkflow(service);
var exception = await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(workflow.CreateInitial(), "", ThemeSession.PreMarket));
Assert.Contains("search provider order", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_PreservesExactLegacyStoredTitlesWithEdgeSpaces()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => SearchResult(
query,
session,
Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, " KRX Theme ", "00000001"),
Selector(ThemeMarket.Nxt, session, " NXT Theme ", "00000002"))
};
var workflow = new LegacyThemeWorkflow(service);
var result = await workflow.SearchAsync(
workflow.CreateInitial(),
string.Empty,
ThemeSession.PreMarket);
Assert.Collection(
result.SearchResults,
row =>
{
Assert.Equal(" KRX Theme ", row.Identity.ThemeTitle);
Assert.Equal(" KRX Theme ", row.DisplayTitle);
Assert.Equal(ThemeMarket.Krx, row.Market);
},
row =>
{
Assert.Equal(" NXT Theme ", row.Identity.ThemeTitle);
Assert.Equal(" NXT Theme (NXT)", row.DisplayTitle);
Assert.Equal(ThemeMarket.Nxt, row.Market);
});
}
[Fact]
public async Task SearchAsync_AcceptsFiniteFiveThousandRowTruncationBoundary()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => new ThemeSearchResult(
query,
session,
DateTimeOffset.UtcNow,
Enumerable.Range(0, LegacyThemeWorkflow.MaximumSearchResults)
.Select(index => Selector(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
$"Theme {index}",
index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture)))
.ToArray(),
IsTruncated: true)
};
var workflow = new LegacyThemeWorkflow(service);
var snapshot = await workflow.SearchAsync(
workflow.CreateInitial(),
string.Empty,
ThemeSession.PreMarket);
Assert.Equal(5_000, snapshot.SearchResults.Count);
Assert.True(snapshot.SearchIsTruncated);
Assert.Equal("theme-result-5000", snapshot.SearchResults[^1].ResultId);
}
[Fact]
public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview()
{
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 SearchAsync_RetainsOnlyACorrelatedSelectionAndPreview()
{
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_DESC");
var refreshed = await workflow.SearchAsync(
selected,
"AI",
ThemeSession.AfterMarket);
Assert.Equal(
new LegacySelectedThemeSummary(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
1,
false),
refreshed.SelectedTheme);
Assert.NotNull(refreshed.SelectedResultId);
Assert.Single(refreshed.PreviewItems);
Assert.Equal("삼성전자", refreshed.PreviewItems[0].ItemName);
Assert.Equal(LegacyThemeSort.GainRateDescending, refreshed.Sort);
Assert.Single(service.PreviewCalls);
}
[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);
var expectedGraphicType = action.Id switch
{
"five-row-current" or "five-row-expected" => "5단 표그래프",
"six-row-current" => "6종목 현재가",
"twelve-row-current" => "12종목 현재가",
_ => throw new InvalidOperationException()
};
var expectedSubtype =
$"테마-{(action.ValueKind == LegacyThemeValueKind.Expected ? "" : "")}({sortLabel})";
Assert.Equal(expectedGraphicType, draft.PlaylistGraphicType);
Assert.Equal(expectedSubtype, draft.PlaylistSubtype);
Assert.Equal(expectedSubtype, 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("AI", draft.Selection.ExactSubject);
Assert.Equal(action.PageSize, draft.PageSize);
Assert.Equal($"1/{draft.PagePlan.PageCount}", draft.PagePlan.InitialPageText);
Assert.InRange(draft.PagePlan.PageCount, 1, 20);
}
}
[Theory]
[InlineData("INPUT_ORDER")]
[InlineData("GAIN_DESC")]
[InlineData("GAIN_ASC")]
public async Task MaterializePlaylistDraft_NxtKeepsSessionAndExpectedClickShowsOriginalWarning(
string sortId)
{
var identity = Identity(
ThemeMarket.Nxt,
ThemeSession.AfterMarket,
"Robotics",
"00000002");
var service = ServiceWithTheme(identity, CreateItems(7, ThemeMarket.Nxt));
var workflow = new LegacyThemeWorkflow(
service,
() => new DateTimeOffset(2026, 7, 18, 14, 0, 0, TimeSpan.FromHours(9)));
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",
"Robotics"),
draft.Selection);
Assert.True(workflow.GetActionState(selected, actionId).IsAvailable);
}
var expectedState = workflow.GetActionState(selected, "five-row-expected");
Assert.True(expectedState.IsAvailable);
Assert.Null(expectedState.UnavailableReason);
var exception = Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(selected, "five-row-expected"));
Assert.Equal("NXT는 데이터가 없습니다.", exception.Message);
}
[Fact]
public async Task MaterializePlaylistDraft_NxtStoredSuffixIsNotDuplicated()
{
var identity = Identity(
ThemeMarket.Nxt,
ThemeSession.PreMarket,
"Robotics(NXT)",
"00000002");
var workflow = new LegacyThemeWorkflow(
ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Nxt)));
var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);
var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");
Assert.Equal("Robotics(NXT)", draft.Title);
Assert.Equal("Robotics(NXT)", draft.Selection.Subject);
Assert.Equal("Robotics(NXT)", draft.Selection.ExactSubject);
}
[Fact]
public async Task MaterializePlaylistDraft_KrxStoredNxtSuffixKeepsExplicitKrxRoute()
{
var identity = Identity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"Robotics(NXT)",
"00000002");
var workflow = new LegacyThemeWorkflow(
ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Krx)));
var selected = await SearchAndSelectAsync(workflow);
var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");
Assert.Equal("Robotics(NXT)", draft.Title);
Assert.Equal("PAGED_THEME", draft.Selection.GroupCode);
Assert.Equal("Robotics(NXT)", draft.Selection.Subject);
Assert.Equal("Robotics(NXT)", draft.Selection.ExactSubject);
}
[Fact]
public async Task SelectAsync_NxtEmptyActivePreviewRemainsSelectedButCannotAddOrPlayout()
{
var identity = Identity(
ThemeMarket.Nxt,
ThemeSession.PreMarket,
" NXT Theme",
"00000004");
var workflow = new LegacyThemeWorkflow(ServiceWithTheme(identity, []));
var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);
Assert.Equal(ThemeMarket.Nxt, selected.SelectedTheme!.Market);
Assert.Equal(" NXT Theme(NXT)", selected.SelectedTheme.DisplayTitle);
Assert.Empty(selected.PreviewItems);
Assert.All(selected.ActionStates, state =>
{
Assert.False(state.IsAvailable);
Assert.Equal("theme-preview-is-empty", state.UnavailableReason);
});
Assert.Throws<InvalidOperationException>(() =>
workflow.MaterializePlaylistDraft(selected, "five-row-current"));
}
[Fact]
public async Task MaterializePlaylistDraft_NxtReevaluatesTheNativeHourAtActionTime()
{
var localNow = new DateTimeOffset(
2026,
7,
18,
10,
0,
0,
TimeSpan.FromHours(9));
var identity = Identity(
ThemeMarket.Nxt,
ThemeSession.PreMarket,
"Robotics",
"00000002");
var workflow = new LegacyThemeWorkflow(
ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Nxt)),
() => localNow);
var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);
localNow = localNow.AddHours(4);
var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");
Assert.Equal("AFTER_MARKET", draft.Selection.GraphicType);
}
[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));
}
}
}