feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -211,6 +211,8 @@ public sealed record LegacyThemePlaylistDraft(
string Code,
IReadOnlyList<string> Aliases,
string Title,
string PlaylistGraphicType,
string PlaylistSubtype,
string Detail,
string Category,
string Market,
@@ -253,6 +255,7 @@ public sealed class LegacyThemeWorkflow
RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal));
private readonly IThemeSelectionService _selectionService;
private readonly Func<DateTimeOffset> _localNow;
private readonly object _authority = new();
static LegacyThemeWorkflow()
@@ -260,16 +263,22 @@ public sealed class LegacyThemeWorkflow
ValidateActions(RegisteredActions);
}
public LegacyThemeWorkflow(IThemeSelectionService selectionService)
public LegacyThemeWorkflow(
IThemeSelectionService selectionService,
Func<DateTimeOffset>? localNow = null)
{
_selectionService = selectionService ??
throw new ArgumentNullException(nameof(selectionService));
_localNow = localNow ?? (() => DateTimeOffset.Now);
}
public static IReadOnlyList<LegacyThemeActionDefinition> Actions => RegisteredActions;
public static IReadOnlyList<LegacyThemeSortDefinition> Sorts => RegisteredSorts;
public ThemeSession CurrentNxtSession =>
_localNow().Hour <= 12 ? ThemeSession.PreMarket : ThemeSession.AfterMarket;
public LegacyThemeWorkflowSnapshot CreateInitial(
ThemeSession nxtSession = ThemeSession.PreMarket)
{
@@ -287,6 +296,25 @@ public sealed class LegacyThemeWorkflow
previewIsTruncated: false);
}
public LegacyThemeWorkflowSnapshot ResetForTabActivation(
LegacyThemeWorkflowSnapshot snapshot,
ThemeSession nxtSession = ThemeSession.PreMarket)
{
RequireSnapshot(snapshot);
RequireNxtSession(nxtSession);
return NewSnapshot(
checked(snapshot.Revision + 1),
query: string.Empty,
nxtSession,
LegacyThemeSort.InputOrder,
[],
searchIsTruncated: false,
selectedResultId: null,
selectedIdentity: null,
previewItems: [],
previewIsTruncated: false);
}
public async Task<LegacyThemeWorkflowSnapshot> SearchAsync(
LegacyThemeWorkflowSnapshot snapshot,
string query,
@@ -305,6 +333,9 @@ public sealed class LegacyThemeWorkflow
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var rows = ValidateSearchResult(result, normalizedQuery, nxtSession);
var retainedSelection = snapshot.SelectedIdentity is null
? null
: rows.SingleOrDefault(row => row.Identity == snapshot.SelectedIdentity);
return NewSnapshot(
checked(snapshot.Revision + 1),
normalizedQuery,
@@ -312,10 +343,10 @@ public sealed class LegacyThemeWorkflow
snapshot.Sort,
rows,
result.IsTruncated,
selectedResultId: null,
selectedIdentity: null,
previewItems: [],
previewIsTruncated: false);
retainedSelection?.ResultId,
retainedSelection?.Identity,
retainedSelection is null ? [] : snapshot.PreviewItems,
retainedSelection is not null && snapshot.PreviewIsTruncated);
}
public async Task<LegacyThemeWorkflowSnapshot> SelectAsync(
@@ -349,6 +380,31 @@ public sealed class LegacyThemeWorkflow
preview.IsTruncated);
}
public LegacyThemeWorkflowSnapshot RemoveSelectedResult(
LegacyThemeWorkflowSnapshot snapshot)
{
RequireSnapshot(snapshot);
if (snapshot.SelectedResultId is null)
{
return snapshot;
}
return NewSnapshot(
checked(snapshot.Revision + 1),
snapshot.Query,
snapshot.NxtSession,
snapshot.Sort,
snapshot.SearchResults.Where(row => !string.Equals(
row.ResultId,
snapshot.SelectedResultId,
StringComparison.Ordinal)),
snapshot.SearchIsTruncated,
selectedResultId: null,
selectedIdentity: null,
previewItems: [],
previewIsTruncated: false);
}
public LegacyThemeWorkflowSnapshot SelectSort(
LegacyThemeWorkflowSnapshot snapshot,
string sortId)
@@ -391,7 +447,7 @@ public sealed class LegacyThemeWorkflow
if (action.ValueKind == LegacyThemeValueKind.Expected &&
identity.Market == ThemeMarket.Nxt)
{
throw new InvalidOperationException("NXT themes do not provide expected-price data.");
throw new InvalidOperationException("NXT는 데이터가 없습니다.");
}
var sortId = SortId(snapshot.Sort);
@@ -399,6 +455,9 @@ public sealed class LegacyThemeWorkflow
var displayTitle = identity.Market == ThemeMarket.Nxt
? $"{identity.ThemeTitle}(NXT)"
: identity.ThemeTitle;
var playlistGraphicType = PlaylistGraphicType(action);
var playlistSubtype =
$"테마-{ValueKindLabel(action.ValueKind)}({SortLabel(snapshot.Sort)})";
var selection = identity.Market == ThemeMarket.Krx
? new LegacyThemePlaylistSelection(
"PAGED_THEME",
@@ -409,7 +468,7 @@ public sealed class LegacyThemeWorkflow
: new LegacyThemePlaylistSelection(
"PAGED_NXT_THEME",
displayTitle,
SessionId(identity.Session),
SessionId(CurrentNxtSession),
sortId,
identity.ThemeCode);
@@ -431,7 +490,9 @@ public sealed class LegacyThemeWorkflow
action.Code,
action.Aliases,
displayTitle,
$"{action.Label} · {SortLabel(snapshot.Sort)}",
playlistGraphicType,
playlistSubtype,
playlistSubtype,
"plate",
"theme",
selection,
@@ -527,16 +588,12 @@ public sealed class LegacyThemeWorkflow
throw InvalidData("search envelope");
}
var titles = new Dictionary<ThemeMarket, HashSet<string>>
{
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
};
var codes = new Dictionary<ThemeMarket, HashSet<string>>
{
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
};
var sawNxt = false;
foreach (var item in serviceItems)
{
if (item is null)
@@ -549,25 +606,30 @@ public sealed class LegacyThemeWorkflow
? DataSourceKind.Oracle
: DataSourceKind.MariaDb;
if (item.Source != expectedSource ||
!titles[item.Identity.Market].Add(item.Identity.ThemeTitle) ||
!codes[item.Identity.Market].Add(item.Identity.ThemeCode))
{
throw InvalidData("search identity or provider");
}
// UC4 appended the Oracle list and then the MariaDB list. Preserve
// each provider's returned order; do not sort rows by title or code.
if (item.Identity.Market == ThemeMarket.Nxt)
{
sawNxt = true;
}
else if (sawNxt)
{
throw InvalidData("search provider order");
}
}
var ordered = serviceItems
.OrderBy(item => item.Identity.Market)
.ThenBy(item => item.Identity.ThemeTitle, StringComparer.Ordinal)
.ThenBy(item => item.Identity.ThemeCode, StringComparer.Ordinal)
.ToArray();
var rows = new LegacyThemeSearchRow[ordered.Length];
for (var index = 0; index < ordered.Length; index++)
var rows = new LegacyThemeSearchRow[serviceItems.Length];
for (var index = 0; index < serviceItems.Length; index++)
{
rows[index] = new LegacyThemeSearchRow(
$"theme-result-{index + 1:000}",
ordered[index].Identity,
ordered[index].Source);
serviceItems[index].Identity,
serviceItems[index].Source);
}
return Array.AsReadOnly(rows);
@@ -589,7 +651,6 @@ public sealed class LegacyThemeWorkflow
throw InvalidData("preview envelope");
}
var itemCodes = new HashSet<string>(StringComparer.Ordinal);
var indexes = new HashSet<int>();
var previousIndex = -1;
var rows = new LegacyThemePreviewRow[serviceItems.Length];
@@ -598,7 +659,7 @@ public sealed class LegacyThemeWorkflow
var item = serviceItems[index]
?? throw InvalidData("preview item");
if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex ||
!indexes.Add(item.InputIndex) || !itemCodes.Add(item.ItemCode) ||
!indexes.Add(item.InputIndex) ||
!IsItemCodeForMarket(item.ItemCode, identity.Market) ||
!IsCanonicalText(item.ItemName, 200))
{
@@ -684,6 +745,28 @@ public sealed class LegacyThemeWorkflow
"Unknown theme value kind.")
};
private static string ValueKindLabel(LegacyThemeValueKind valueKind) => valueKind switch
{
LegacyThemeValueKind.Current => "현재가",
LegacyThemeValueKind.Expected => "예상체결가",
_ => throw new ArgumentOutOfRangeException(
nameof(valueKind),
valueKind,
"Unknown theme value kind.")
};
private static string PlaylistGraphicType(LegacyThemeActionDefinition action) =>
action.Id switch
{
"five-row-current" or "five-row-expected" => "5단 표그래프",
"six-row-current" => "6종목 현재가",
"twelve-row-current" => "12종목 현재가",
_ => throw new ArgumentOutOfRangeException(
nameof(action),
action.Id,
"Unknown theme action.")
};
private static void RequireNxtSession(ThemeSession nxtSession)
{
if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
@@ -780,10 +863,7 @@ public sealed class LegacyThemeWorkflow
? "theme-not-selected"
: previewItemCount == 0
? "theme-preview-is-empty"
: action.ValueKind == LegacyThemeValueKind.Expected &&
selectedIdentity.Market == ThemeMarket.Nxt
? "expected-theme-is-krx-only"
: null;
: null;
states[index] = new LegacyThemeActionState(
action.Id,
reason is null,