903 lines
30 KiB
C#
903 lines
30 KiB
C#
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 PlaylistGraphicType,
|
|
string PlaylistSubtype,
|
|
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 Func<DateTimeOffset> _localNow;
|
|
private readonly object _authority = new();
|
|
|
|
static LegacyThemeWorkflow()
|
|
{
|
|
ValidateActions(RegisteredActions);
|
|
}
|
|
|
|
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)
|
|
{
|
|
RequireNxtSession(nxtSession);
|
|
return NewSnapshot(
|
|
revision: 0,
|
|
query: string.Empty,
|
|
nxtSession,
|
|
LegacyThemeSort.InputOrder,
|
|
[],
|
|
searchIsTruncated: false,
|
|
selectedResultId: null,
|
|
selectedIdentity: null,
|
|
previewItems: [],
|
|
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,
|
|
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);
|
|
var retainedSelection = snapshot.SelectedIdentity is null
|
|
? null
|
|
: rows.SingleOrDefault(row => row.Identity == snapshot.SelectedIdentity);
|
|
return NewSnapshot(
|
|
checked(snapshot.Revision + 1),
|
|
normalizedQuery,
|
|
nxtSession,
|
|
snapshot.Sort,
|
|
rows,
|
|
result.IsTruncated,
|
|
retainedSelection?.ResultId,
|
|
retainedSelection?.Identity,
|
|
retainedSelection is null ? [] : snapshot.PreviewItems,
|
|
retainedSelection is not null && snapshot.PreviewIsTruncated);
|
|
}
|
|
|
|
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 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)
|
|
{
|
|
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는 데이터가 없습니다.");
|
|
}
|
|
|
|
var sortId = SortId(snapshot.Sort);
|
|
var valueKind = ValueKindId(action.ValueKind);
|
|
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",
|
|
identity.ThemeTitle,
|
|
sortId,
|
|
valueKind,
|
|
identity.ThemeCode)
|
|
: new LegacyThemePlaylistSelection(
|
|
"PAGED_NXT_THEME",
|
|
displayTitle,
|
|
SessionId(CurrentNxtSession),
|
|
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,
|
|
playlistGraphicType,
|
|
playlistSubtype,
|
|
playlistSubtype,
|
|
"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 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)
|
|
{
|
|
throw InvalidData("search item");
|
|
}
|
|
|
|
ValidateIdentity(item.Identity, nxtSession);
|
|
var expectedSource = item.Identity.Market == ThemeMarket.Krx
|
|
? DataSourceKind.Oracle
|
|
: DataSourceKind.MariaDb;
|
|
if (item.Source != expectedSource ||
|
|
!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 rows = new LegacyThemeSearchRow[serviceItems.Length];
|
|
for (var index = 0; index < serviceItems.Length; index++)
|
|
{
|
|
rows[index] = new LegacyThemeSearchRow(
|
|
$"theme-result-{index + 1:000}",
|
|
serviceItems[index].Identity,
|
|
serviceItems[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 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) ||
|
|
!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 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))
|
|
{
|
|
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"
|
|
: 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.");
|
|
}
|