feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

View 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.");
}