feat: complete legacy UI and playout parity migration
This commit is contained in:
822
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
Normal file
822
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
Normal file
@@ -0,0 +1,822 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyThemeSort
|
||||
{
|
||||
InputOrder,
|
||||
GainRateDescending,
|
||||
GainRateAscending
|
||||
}
|
||||
|
||||
public enum LegacyThemeValueKind
|
||||
{
|
||||
Current,
|
||||
Expected
|
||||
}
|
||||
|
||||
public sealed record LegacyThemeSortDefinition(
|
||||
string Id,
|
||||
string Label,
|
||||
LegacyThemeSort Value);
|
||||
|
||||
public sealed record LegacyThemeActionState(
|
||||
string ActionId,
|
||||
bool IsAvailable,
|
||||
string? UnavailableReason);
|
||||
|
||||
public sealed class LegacyThemeActionDefinition
|
||||
{
|
||||
internal LegacyThemeActionDefinition(
|
||||
string id,
|
||||
string label,
|
||||
string builderKey,
|
||||
string code,
|
||||
int pageSize,
|
||||
LegacyThemeValueKind valueKind)
|
||||
{
|
||||
Id = id;
|
||||
Label = label;
|
||||
BuilderKey = builderKey;
|
||||
Code = code;
|
||||
Aliases = Array.AsReadOnly([code]);
|
||||
PageSize = pageSize;
|
||||
ValueKind = valueKind;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string BuilderKey { get; }
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public IReadOnlyList<string> Aliases { get; }
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
public LegacyThemeValueKind ValueKind { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyThemeSearchRow
|
||||
{
|
||||
internal LegacyThemeSearchRow(
|
||||
string resultId,
|
||||
ThemeSelectionIdentity identity,
|
||||
DataSourceKind source)
|
||||
{
|
||||
ResultId = resultId;
|
||||
Identity = identity;
|
||||
Market = identity.Market;
|
||||
Session = identity.Session;
|
||||
DisplayTitle = identity.Market == ThemeMarket.Nxt
|
||||
? $"{identity.ThemeTitle}(NXT)"
|
||||
: identity.ThemeTitle;
|
||||
Source = source;
|
||||
}
|
||||
|
||||
public string ResultId { get; }
|
||||
|
||||
public ThemeMarket Market { get; }
|
||||
|
||||
public ThemeSession Session { get; }
|
||||
|
||||
public string DisplayTitle { get; }
|
||||
|
||||
public DataSourceKind Source { get; }
|
||||
|
||||
internal ThemeSelectionIdentity Identity { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyThemePreviewRow
|
||||
{
|
||||
internal LegacyThemePreviewRow(ThemeItemPreview item)
|
||||
{
|
||||
InputIndex = item.InputIndex;
|
||||
ItemName = item.ItemName;
|
||||
ItemCode = item.ItemCode;
|
||||
}
|
||||
|
||||
public int InputIndex { get; }
|
||||
|
||||
public string ItemName { get; }
|
||||
|
||||
internal string ItemCode { get; }
|
||||
}
|
||||
|
||||
public sealed record LegacySelectedThemeSummary(
|
||||
ThemeMarket Market,
|
||||
ThemeSession Session,
|
||||
string DisplayTitle,
|
||||
int PreviewItemCount,
|
||||
bool PreviewIsTruncated);
|
||||
|
||||
public sealed class LegacyThemeWorkflowSnapshot
|
||||
{
|
||||
internal LegacyThemeWorkflowSnapshot(
|
||||
object authority,
|
||||
long revision,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
LegacyThemeSort sort,
|
||||
IEnumerable<LegacyThemeSearchRow> searchResults,
|
||||
bool searchIsTruncated,
|
||||
string? selectedResultId,
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
IEnumerable<LegacyThemePreviewRow> previewItems,
|
||||
bool previewIsTruncated)
|
||||
{
|
||||
Authority = authority;
|
||||
Revision = revision;
|
||||
Query = query;
|
||||
NxtSession = nxtSession;
|
||||
Sort = sort;
|
||||
SearchResults = ReadOnly(searchResults);
|
||||
SearchIsTruncated = searchIsTruncated;
|
||||
SelectedResultId = selectedResultId;
|
||||
SelectedIdentity = selectedIdentity;
|
||||
PreviewItems = ReadOnly(previewItems);
|
||||
PreviewIsTruncated = previewIsTruncated;
|
||||
SelectedTheme = selectedIdentity is null
|
||||
? null
|
||||
: new LegacySelectedThemeSummary(
|
||||
selectedIdentity.Market,
|
||||
selectedIdentity.Session,
|
||||
selectedIdentity.Market == ThemeMarket.Nxt
|
||||
? $"{selectedIdentity.ThemeTitle}(NXT)"
|
||||
: selectedIdentity.ThemeTitle,
|
||||
PreviewItems.Count,
|
||||
previewIsTruncated);
|
||||
ActionStates = LegacyThemeWorkflow.CreateActionStates(
|
||||
selectedIdentity,
|
||||
PreviewItems.Count);
|
||||
}
|
||||
|
||||
public long Revision { get; }
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public ThemeSession NxtSession { get; }
|
||||
|
||||
public LegacyThemeSort Sort { get; }
|
||||
|
||||
public string SortId => LegacyThemeWorkflow.SortId(Sort);
|
||||
|
||||
public IReadOnlyList<LegacyThemeSearchRow> SearchResults { get; }
|
||||
|
||||
public bool SearchIsTruncated { get; }
|
||||
|
||||
public string? SelectedResultId { get; }
|
||||
|
||||
public LegacySelectedThemeSummary? SelectedTheme { get; }
|
||||
|
||||
public IReadOnlyList<LegacyThemePreviewRow> PreviewItems { get; }
|
||||
|
||||
public bool PreviewIsTruncated { get; }
|
||||
|
||||
public IReadOnlyList<LegacyThemeActionState> ActionStates { get; }
|
||||
|
||||
internal object Authority { get; }
|
||||
|
||||
internal ThemeSelectionIdentity? SelectedIdentity { get; }
|
||||
|
||||
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> source) =>
|
||||
Array.AsReadOnly(source.ToArray());
|
||||
}
|
||||
|
||||
public sealed record LegacyThemePlaylistSelection(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
public sealed record LegacyThemePagePlan(
|
||||
int PreviewItemCount,
|
||||
int AccessibleItemCount,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
bool IsTruncated,
|
||||
string InitialPageText);
|
||||
|
||||
public sealed record LegacyThemePlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string Code,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
LegacyThemePlaylistSelection Selection,
|
||||
LegacyThemeValueKind ValueKind,
|
||||
LegacyThemeSort Sort,
|
||||
int PageSize,
|
||||
LegacyThemePagePlan PagePlan);
|
||||
|
||||
public sealed class LegacyThemeWorkflowDataException : Exception
|
||||
{
|
||||
public LegacyThemeWorkflowDataException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C#-authoritative migration of the read-only UC4 theme selector. Search and preview data
|
||||
/// come only from <see cref="IThemeSelectionService"/>; Web callers retain opaque result and
|
||||
/// action ids and never provide scene codes, resolver groups, theme identities, or PageN data.
|
||||
/// </summary>
|
||||
public sealed class LegacyThemeWorkflow
|
||||
{
|
||||
public const int MaximumSearchResults = 100;
|
||||
public const int MaximumPreviewItems = 240;
|
||||
public const int MaximumPageCount = 20;
|
||||
|
||||
private static readonly IReadOnlyList<LegacyThemeActionDefinition> RegisteredActions =
|
||||
CreateActions();
|
||||
private static readonly IReadOnlyList<LegacyThemeSortDefinition> RegisteredSorts =
|
||||
Array.AsReadOnly<LegacyThemeSortDefinition>(
|
||||
[
|
||||
new("INPUT_ORDER", "입력순", LegacyThemeSort.InputOrder),
|
||||
new("GAIN_DESC", "상승률순", LegacyThemeSort.GainRateDescending),
|
||||
new("GAIN_ASC", "하락률순", LegacyThemeSort.GainRateAscending)
|
||||
]);
|
||||
private static readonly IReadOnlyDictionary<string, LegacyThemeActionDefinition> ActionsById =
|
||||
new ReadOnlyDictionary<string, LegacyThemeActionDefinition>(
|
||||
RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal));
|
||||
|
||||
private readonly IThemeSelectionService _selectionService;
|
||||
private readonly object _authority = new();
|
||||
|
||||
static LegacyThemeWorkflow()
|
||||
{
|
||||
ValidateActions(RegisteredActions);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflow(IThemeSelectionService selectionService)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyThemeActionDefinition> Actions => RegisteredActions;
|
||||
|
||||
public static IReadOnlyList<LegacyThemeSortDefinition> Sorts => RegisteredSorts;
|
||||
|
||||
public LegacyThemeWorkflowSnapshot CreateInitial(
|
||||
ThemeSession nxtSession = ThemeSession.PreMarket)
|
||||
{
|
||||
RequireNxtSession(nxtSession);
|
||||
return NewSnapshot(
|
||||
revision: 0,
|
||||
query: string.Empty,
|
||||
nxtSession,
|
||||
LegacyThemeSort.InputOrder,
|
||||
[],
|
||||
searchIsTruncated: false,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SearchAsync(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
RequireNxtSession(nxtSession);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await _selectionService.SearchAsync(
|
||||
normalizedQuery,
|
||||
nxtSession,
|
||||
MaximumSearchResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var rows = ValidateSearchResult(result, normalizedQuery, nxtSession);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
normalizedQuery,
|
||||
nxtSession,
|
||||
snapshot.Sort,
|
||||
rows,
|
||||
result.IsTruncated,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SelectAsync(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string resultId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resultId);
|
||||
var selected = snapshot.SearchResults.FirstOrDefault(row =>
|
||||
string.Equals(row.ResultId, resultId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException($"Unknown theme result id '{resultId}'.");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var preview = await _selectionService.PreviewAsync(
|
||||
selected.Identity,
|
||||
MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var previewRows = ValidatePreviewResult(preview, selected.Identity);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
snapshot.Query,
|
||||
snapshot.NxtSession,
|
||||
snapshot.Sort,
|
||||
snapshot.SearchResults,
|
||||
snapshot.SearchIsTruncated,
|
||||
selected.ResultId,
|
||||
selected.Identity,
|
||||
previewRows,
|
||||
preview.IsTruncated);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflowSnapshot SelectSort(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string sortId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
var sort = ParseSort(sortId);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
snapshot.Query,
|
||||
snapshot.NxtSession,
|
||||
sort,
|
||||
snapshot.SearchResults,
|
||||
snapshot.SearchIsTruncated,
|
||||
snapshot.SelectedResultId,
|
||||
snapshot.SelectedIdentity,
|
||||
snapshot.PreviewItems,
|
||||
snapshot.PreviewIsTruncated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes from a trusted snapshot and a closed action id only. All K3D-facing values,
|
||||
/// theme identity fields, sort values, and PageN calculations are rebuilt in this method.
|
||||
/// </summary>
|
||||
public LegacyThemePlaylistDraft MaterializePlaylistDraft(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string actionId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
var action = ActionsById.TryGetValue(actionId, out var found)
|
||||
? found
|
||||
: throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
|
||||
var identity = snapshot.SelectedIdentity
|
||||
?? throw new InvalidOperationException("A theme must be selected and previewed first.");
|
||||
if (snapshot.PreviewItems.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("An empty theme preview cannot be added to the playlist.");
|
||||
}
|
||||
|
||||
if (action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
identity.Market == ThemeMarket.Nxt)
|
||||
{
|
||||
throw new InvalidOperationException("NXT themes do not provide expected-price data.");
|
||||
}
|
||||
|
||||
var sortId = SortId(snapshot.Sort);
|
||||
var valueKind = ValueKindId(action.ValueKind);
|
||||
var displayTitle = identity.Market == ThemeMarket.Nxt
|
||||
? $"{identity.ThemeTitle}(NXT)"
|
||||
: identity.ThemeTitle;
|
||||
var selection = identity.Market == ThemeMarket.Krx
|
||||
? new LegacyThemePlaylistSelection(
|
||||
"PAGED_THEME",
|
||||
identity.ThemeTitle,
|
||||
sortId,
|
||||
valueKind,
|
||||
identity.ThemeCode)
|
||||
: new LegacyThemePlaylistSelection(
|
||||
"PAGED_NXT_THEME",
|
||||
displayTitle,
|
||||
SessionId(identity.Session),
|
||||
sortId,
|
||||
identity.ThemeCode);
|
||||
|
||||
var maximumItems = checked(action.PageSize * MaximumPageCount);
|
||||
var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems);
|
||||
var pageCount = (accessibleItems + action.PageSize - 1) / action.PageSize;
|
||||
var pagePlan = new LegacyThemePagePlan(
|
||||
snapshot.PreviewItems.Count,
|
||||
accessibleItems,
|
||||
action.PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
snapshot.PreviewIsTruncated || snapshot.PreviewItems.Count > maximumItems,
|
||||
$"1/{pageCount.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
return new LegacyThemePlaylistDraft(
|
||||
action.Id,
|
||||
action.BuilderKey,
|
||||
action.Code,
|
||||
action.Aliases,
|
||||
displayTitle,
|
||||
$"{action.Label} · {SortLabel(snapshot.Sort)}",
|
||||
"plate",
|
||||
"theme",
|
||||
selection,
|
||||
action.ValueKind,
|
||||
snapshot.Sort,
|
||||
action.PageSize,
|
||||
pagePlan);
|
||||
}
|
||||
|
||||
public static LegacyThemeActionDefinition? FindAction(string? actionId) =>
|
||||
actionId is not null && ActionsById.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: null;
|
||||
|
||||
public LegacyThemeActionState GetActionState(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string actionId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
return snapshot.ActionStates.FirstOrDefault(state =>
|
||||
string.Equals(state.ActionId, actionId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
|
||||
}
|
||||
|
||||
public static string SortId(LegacyThemeSort sort) => sort switch
|
||||
{
|
||||
LegacyThemeSort.InputOrder => "INPUT_ORDER",
|
||||
LegacyThemeSort.GainRateDescending => "GAIN_DESC",
|
||||
LegacyThemeSort.GainRateAscending => "GAIN_ASC",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
|
||||
};
|
||||
|
||||
public static string SortLabel(LegacyThemeSort sort) => sort switch
|
||||
{
|
||||
LegacyThemeSort.InputOrder => "입력순",
|
||||
LegacyThemeSort.GainRateDescending => "상승률순",
|
||||
LegacyThemeSort.GainRateAscending => "하락률순",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
|
||||
};
|
||||
|
||||
private LegacyThemeWorkflowSnapshot NewSnapshot(
|
||||
long revision,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
LegacyThemeSort sort,
|
||||
IEnumerable<LegacyThemeSearchRow> searchResults,
|
||||
bool searchIsTruncated,
|
||||
string? selectedResultId,
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
IEnumerable<LegacyThemePreviewRow> previewItems,
|
||||
bool previewIsTruncated) =>
|
||||
new(
|
||||
_authority,
|
||||
revision,
|
||||
query,
|
||||
nxtSession,
|
||||
sort,
|
||||
searchResults,
|
||||
searchIsTruncated,
|
||||
selectedResultId,
|
||||
selectedIdentity,
|
||||
previewItems,
|
||||
previewIsTruncated);
|
||||
|
||||
private void RequireSnapshot(LegacyThemeWorkflowSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
if (!ReferenceEquals(snapshot.Authority, _authority))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The theme snapshot belongs to another workflow instance.",
|
||||
nameof(snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemeSearchRow> ValidateSearchResult(
|
||||
ThemeSearchResult result,
|
||||
string query,
|
||||
ThemeSession nxtSession)
|
||||
{
|
||||
if (result is null || result.Items is null ||
|
||||
!string.Equals(result.Query, query, StringComparison.Ordinal) ||
|
||||
result.NxtSession != nxtSession)
|
||||
{
|
||||
throw InvalidData("search envelope");
|
||||
}
|
||||
|
||||
var serviceItems = result.Items.ToArray();
|
||||
if (serviceItems.Length > MaximumSearchResults ||
|
||||
result.IsTruncated && serviceItems.Length != MaximumSearchResults)
|
||||
{
|
||||
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)
|
||||
};
|
||||
foreach (var item in serviceItems)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
throw InvalidData("search item");
|
||||
}
|
||||
|
||||
ValidateIdentity(item.Identity, nxtSession);
|
||||
var expectedSource = item.Identity.Market == ThemeMarket.Krx
|
||||
? 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");
|
||||
}
|
||||
}
|
||||
|
||||
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++)
|
||||
{
|
||||
rows[index] = new LegacyThemeSearchRow(
|
||||
$"theme-result-{index + 1:000}",
|
||||
ordered[index].Identity,
|
||||
ordered[index].Source);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemePreviewRow> ValidatePreviewResult(
|
||||
ThemePreviewResult result,
|
||||
ThemeSelectionIdentity identity)
|
||||
{
|
||||
if (result is null || result.Items is null || result.Identity != identity)
|
||||
{
|
||||
throw InvalidData("preview envelope");
|
||||
}
|
||||
|
||||
var serviceItems = result.Items.ToArray();
|
||||
if (serviceItems.Length > MaximumPreviewItems ||
|
||||
result.IsTruncated && serviceItems.Length != MaximumPreviewItems)
|
||||
{
|
||||
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];
|
||||
for (var index = 0; index < serviceItems.Length; index++)
|
||||
{
|
||||
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) ||
|
||||
!IsItemCodeForMarket(item.ItemCode, identity.Market) ||
|
||||
!IsCanonicalText(item.ItemName, 200))
|
||||
{
|
||||
throw InvalidData("preview item identity");
|
||||
}
|
||||
|
||||
previousIndex = item.InputIndex;
|
||||
rows[index] = new LegacyThemePreviewRow(item);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
}
|
||||
|
||||
private static void ValidateIdentity(
|
||||
ThemeSelectionIdentity identity,
|
||||
ThemeSession requestedNxtSession)
|
||||
{
|
||||
if (identity is null || !Enum.IsDefined(identity.Market) ||
|
||||
!Enum.IsDefined(identity.Session) ||
|
||||
identity.Market == ThemeMarket.Krx &&
|
||||
identity.Session != ThemeSession.NotApplicable ||
|
||||
identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession ||
|
||||
!IsThemeCode(identity.ThemeCode) ||
|
||||
!IsCanonicalText(identity.ThemeTitle, 128) ||
|
||||
identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData("theme identity");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The theme search query is invalid.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > LegacyThemeSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"A theme search query must contain at most {LegacyThemeSelectionService.MaximumQueryLength} visible characters.",
|
||||
nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static LegacyThemeSort ParseSort(string sortId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sortId);
|
||||
return sortId switch
|
||||
{
|
||||
"INPUT_ORDER" => LegacyThemeSort.InputOrder,
|
||||
"GAIN_DESC" => LegacyThemeSort.GainRateDescending,
|
||||
"GAIN_ASC" => LegacyThemeSort.GainRateAscending,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(sortId),
|
||||
sortId,
|
||||
"Unknown theme sort id.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string SessionId(ThemeSession session) => session switch
|
||||
{
|
||||
ThemeSession.PreMarket => "PRE_MARKET",
|
||||
ThemeSession.AfterMarket => "AFTER_MARKET",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(session),
|
||||
session,
|
||||
"NXT requires an explicit session.")
|
||||
};
|
||||
|
||||
private static string ValueKindId(LegacyThemeValueKind valueKind) => valueKind switch
|
||||
{
|
||||
LegacyThemeValueKind.Current => "CURRENT",
|
||||
LegacyThemeValueKind.Expected => "EXPECTED",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(valueKind),
|
||||
valueKind,
|
||||
"Unknown theme value kind.")
|
||||
};
|
||||
|
||||
private static void RequireNxtSession(ThemeSession nxtSession)
|
||||
{
|
||||
if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(nxtSession),
|
||||
nxtSession,
|
||||
"An explicit NXT session is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsThemeCode(string value) =>
|
||||
value is not null && value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit);
|
||||
|
||||
private static bool IsItemCodeForMarket(string value, ThemeMarket market)
|
||||
{
|
||||
if (value is null || value.Length is < 2 or > 13 || !value.All(char.IsAsciiLetterOrDigit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return market switch
|
||||
{
|
||||
ThemeMarket.Krx => value[0] is 'P' or 'D',
|
||||
ThemeMarket.Nxt => value[0] is 'X' or 'T',
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsCanonicalText(string value, int maximumLength) =>
|
||||
value is not null && value.Length is >= 1 &&
|
||||
value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemeActionDefinition> CreateActions() =>
|
||||
Array.AsReadOnly(
|
||||
[
|
||||
new LegacyThemeActionDefinition(
|
||||
"five-row-current",
|
||||
"5단 표그래프 · 현재가",
|
||||
"s5074",
|
||||
"5074",
|
||||
5,
|
||||
LegacyThemeValueKind.Current),
|
||||
new LegacyThemeActionDefinition(
|
||||
"five-row-expected",
|
||||
"5단 표그래프 · 예상체결가",
|
||||
"s5074",
|
||||
"5074",
|
||||
5,
|
||||
LegacyThemeValueKind.Expected),
|
||||
new LegacyThemeActionDefinition(
|
||||
"six-row-current",
|
||||
"6종목 현재가",
|
||||
"s5077",
|
||||
"5077",
|
||||
6,
|
||||
LegacyThemeValueKind.Current),
|
||||
new LegacyThemeActionDefinition(
|
||||
"twelve-row-current",
|
||||
"12종목 현재가",
|
||||
"s5088",
|
||||
"5088",
|
||||
12,
|
||||
LegacyThemeValueKind.Current)
|
||||
]);
|
||||
|
||||
internal static IReadOnlyList<LegacyThemeActionState> CreateActionStates(
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
int previewItemCount)
|
||||
{
|
||||
var states = new LegacyThemeActionState[RegisteredActions.Count];
|
||||
for (var index = 0; index < RegisteredActions.Count; index++)
|
||||
{
|
||||
var action = RegisteredActions[index];
|
||||
var reason = selectedIdentity is null
|
||||
? "theme-not-selected"
|
||||
: previewItemCount == 0
|
||||
? "theme-preview-is-empty"
|
||||
: action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
selectedIdentity.Market == ThemeMarket.Nxt
|
||||
? "expected-theme-is-krx-only"
|
||||
: null;
|
||||
states[index] = new LegacyThemeActionState(
|
||||
action.Id,
|
||||
reason is null,
|
||||
reason);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(states);
|
||||
}
|
||||
|
||||
private static void ValidateActions(
|
||||
IReadOnlyCollection<LegacyThemeActionDefinition> actions)
|
||||
{
|
||||
if (actions.Count != 4 ||
|
||||
actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != 4)
|
||||
{
|
||||
throw new InvalidOperationException("The UC4 action catalog must contain four unique actions.");
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var scene = LegacySceneCatalog.Definitions.Single(definition =>
|
||||
string.Equals(definition.BuilderKey, action.BuilderKey, StringComparison.Ordinal));
|
||||
if (!scene.CutAliases.Contains(action.Code, StringComparer.Ordinal) ||
|
||||
action.Aliases.Count != 1 ||
|
||||
!string.Equals(action.Aliases[0], action.Code, StringComparison.Ordinal) ||
|
||||
(int)scene.PageSize != action.PageSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Theme action '{action.Id}' does not match the Core scene catalog.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyThemeWorkflowDataException InvalidData(string detail) =>
|
||||
new($"The injected theme selection service returned invalid {detail} data.");
|
||||
}
|
||||
Reference in New Issue
Block a user