1054 lines
41 KiB
C#
1054 lines
41 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 SearchAsync_AcceptsConservativeTruncationAfterInitialFiltering()
|
|
{
|
|
var service = new FakeThemeSelectionService
|
|
{
|
|
SearchHandler = (query, session) => new ThemeSearchResult(
|
|
query,
|
|
session,
|
|
DateTimeOffset.UtcNow,
|
|
[
|
|
Selector(
|
|
ThemeMarket.Krx,
|
|
ThemeSession.NotApplicable,
|
|
"삼성 테마",
|
|
"00000001")
|
|
],
|
|
IsTruncated: true)
|
|
};
|
|
var workflow = new LegacyThemeWorkflow(service);
|
|
|
|
var snapshot = await workflow.SearchAsync(
|
|
workflow.CreateInitial(),
|
|
"ㅅㅅ",
|
|
ThemeSession.PreMarket);
|
|
|
|
Assert.Equal("삼성 테마", Assert.Single(snapshot.SearchResults).DisplayTitle);
|
|
Assert.True(snapshot.SearchIsTruncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_StillRejectsImpossiblePartialTruncationForOrdinaryText()
|
|
{
|
|
var service = new FakeThemeSelectionService
|
|
{
|
|
SearchHandler = (query, session) => new ThemeSearchResult(
|
|
query,
|
|
session,
|
|
DateTimeOffset.UtcNow,
|
|
[
|
|
Selector(
|
|
ThemeMarket.Krx,
|
|
ThemeSession.NotApplicable,
|
|
"AI",
|
|
"00000001")
|
|
],
|
|
IsTruncated: true)
|
|
};
|
|
var workflow = new LegacyThemeWorkflow(service);
|
|
|
|
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
|
|
workflow.SearchAsync(
|
|
workflow.CreateInitial(),
|
|
"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 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));
|
|
}
|
|
}
|
|
}
|