feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
internal static class ThemeSelectorCompatibilityAudit
|
||||
{
|
||||
private const string KrxQueryName = "AUDIT_THEME_SELECTOR_RAW_KRX";
|
||||
private const string NxtQueryName = "AUDIT_THEME_SELECTOR_RAW_NXT";
|
||||
private const string NxtActiveCodesQueryName = "AUDIT_THEME_SELECTOR_ACTIVE_NXT_CODES";
|
||||
|
||||
private const string KrxSql = """
|
||||
SELECT s.SB_TITLE THEME_TITLE,
|
||||
s.SB_CODE THEME_CODE,
|
||||
s.SB_MARKET THEME_MARKET
|
||||
FROM SB_LIST s
|
||||
WHERE s.SB_MARKET = 'KRX'
|
||||
""";
|
||||
|
||||
private const string NxtSql = """
|
||||
SELECT s.SB_TITLE THEME_TITLE,
|
||||
s.SB_CODE THEME_CODE,
|
||||
s.SB_MARKET THEME_MARKET
|
||||
FROM SB_LIST s
|
||||
WHERE s.SB_MARKET = 'NXT'
|
||||
""";
|
||||
|
||||
private const string NxtActiveCodesSql = """
|
||||
SELECT DISTINCT i.SB_M_CODE THEME_CODE
|
||||
FROM SB_ITEM i
|
||||
JOIN (
|
||||
SELECT CONCAT('X', s.F_STOCK_CODE) ITEM_CODE
|
||||
FROM N_STOCK s
|
||||
WHERE s.F_STOP_GUBUN = 'N'
|
||||
UNION ALL
|
||||
SELECT CONCAT('T', s.F_STOCK_CODE) ITEM_CODE
|
||||
FROM N_KOSDAQ_STOCK s
|
||||
WHERE s.F_STOP_GUBUN = 'N'
|
||||
) current_nxt
|
||||
ON BINARY i.SB_I_CODE = BINARY current_nxt.ITEM_CODE
|
||||
""";
|
||||
|
||||
public static async Task RunAsync(
|
||||
IDataQueryExecutor executor,
|
||||
LegacyThemeSelectionService service,
|
||||
ThemeSearchResult selector,
|
||||
LegacyThemeWorkflow workflow,
|
||||
LegacyThemeWorkflowSnapshot workflowSnapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executor);
|
||||
ArgumentNullException.ThrowIfNull(service);
|
||||
ArgumentNullException.ThrowIfNull(selector);
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(workflowSnapshot);
|
||||
|
||||
var krx = await LoadRowsAsync(
|
||||
executor,
|
||||
DataSourceKind.Oracle,
|
||||
KrxQueryName,
|
||||
KrxSql,
|
||||
ThemeMarket.Krx,
|
||||
"KRX",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var nxt = await LoadRowsAsync(
|
||||
executor,
|
||||
DataSourceKind.MariaDb,
|
||||
NxtQueryName,
|
||||
NxtSql,
|
||||
ThemeMarket.Nxt,
|
||||
"NXT",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var raw = krx.Concat(nxt).ToArray();
|
||||
|
||||
RequireUniqueCodes(raw);
|
||||
var rawKeys = raw.Select(static row => row.Key).ToHashSet();
|
||||
var selectorKeys = selector.Items
|
||||
.Select(static item => new ThemeKey(
|
||||
item.Identity.Market,
|
||||
item.Identity.ThemeCode,
|
||||
item.Identity.ThemeTitle))
|
||||
.ToHashSet();
|
||||
if (selector.IsTruncated || rawKeys.Count != raw.Length ||
|
||||
selectorKeys.Count != selector.Items.Count ||
|
||||
!rawKeys.SetEquals(selectorKeys))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The Theme selector did not preserve every safe raw SB_LIST identity.");
|
||||
}
|
||||
|
||||
if (workflowSnapshot.SearchIsTruncated ||
|
||||
workflowSnapshot.SearchResults.Count != selector.Items.Count)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The Theme operator workflow did not preserve every raw selector identity.");
|
||||
}
|
||||
|
||||
var krxEdgeSpace = krx.Count(static row => HasEdgeWhitespace(row.Title));
|
||||
var nxtEdgeSpace = nxt.Count(static row => HasEdgeWhitespace(row.Title));
|
||||
var duplicateTitleRows = raw
|
||||
.GroupBy(static row => (row.Market, row.Title))
|
||||
.Where(static group => group.Count() > 1)
|
||||
.Sum(static group => group.Count());
|
||||
var duplicateTitleGroups = raw
|
||||
.GroupBy(static row => (row.Market, row.Title))
|
||||
.Count(static group => group.Count() > 1);
|
||||
var krxSuffixRows = krx.Count(static row =>
|
||||
row.Title.EndsWith("(NXT)", StringComparison.Ordinal));
|
||||
if (krxEdgeSpace == 0 || nxtEdgeSpace == 0 ||
|
||||
duplicateTitleRows == 0 || krxSuffixRows == 0)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The deployed Theme compatibility fixtures are missing from SB_LIST.");
|
||||
}
|
||||
|
||||
var suffixRow = workflowSnapshot.SearchResults.FirstOrDefault(static row =>
|
||||
row.Market == ThemeMarket.Krx &&
|
||||
row.DisplayTitle.EndsWith("(NXT)", StringComparison.Ordinal));
|
||||
if (suffixRow is null || suffixRow.Market != ThemeMarket.Krx ||
|
||||
!suffixRow.DisplayTitle.EndsWith("(NXT)", StringComparison.Ordinal))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"A stored KRX '(NXT)' title lost its explicit KRX identity.");
|
||||
}
|
||||
|
||||
var activeNxtCodes = await LoadActiveNxtCodesAsync(
|
||||
executor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var emptyNxt = selector.Items.FirstOrDefault(item =>
|
||||
item.Identity.Market == ThemeMarket.Nxt &&
|
||||
!activeNxtCodes.Contains(item.Identity.ThemeCode));
|
||||
if (emptyNxt is null)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The deployed empty-active NXT Theme compatibility fixture is missing.");
|
||||
}
|
||||
|
||||
var emptyPreview = await service.PreviewAsync(
|
||||
emptyNxt.Identity,
|
||||
LegacyThemeSelectionService.MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (emptyPreview.IsTruncated || emptyPreview.Items.Count != 0 ||
|
||||
emptyPreview.Identity != emptyNxt.Identity)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The empty-active NXT Theme did not remain a correlated empty preview.");
|
||||
}
|
||||
|
||||
var emptyIndex = Array.FindIndex(
|
||||
selector.Items.ToArray(),
|
||||
item => item == emptyNxt);
|
||||
if (emptyIndex < 0 || emptyIndex >= workflowSnapshot.SearchResults.Count)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The empty-active NXT Theme lost its opaque workflow correlation.");
|
||||
}
|
||||
var emptyWorkflowRow = workflowSnapshot.SearchResults[emptyIndex];
|
||||
var selectedEmpty = await workflow.SelectAsync(
|
||||
workflowSnapshot,
|
||||
emptyWorkflowRow.ResultId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(
|
||||
selectedEmpty.SelectedResultId,
|
||||
emptyWorkflowRow.ResultId,
|
||||
StringComparison.Ordinal) ||
|
||||
selectedEmpty.SelectedTheme?.Market != ThemeMarket.Nxt ||
|
||||
selectedEmpty.ActionStates.Any(static state =>
|
||||
state.IsAvailable ||
|
||||
!string.Equals(
|
||||
state.UnavailableReason,
|
||||
"theme-preview-is-empty",
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"An empty-active NXT Theme became eligible for playlist or playout action.");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"THEME_SELECTOR_COMPATIBILITY: " +
|
||||
$"raw={raw.Length.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"visible={selector.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"krx={krx.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"nxt={nxt.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"krxEdgeSpace={krxEdgeSpace.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"nxtEdgeSpace={nxtEdgeSpace.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"duplicateTitleGroups={duplicateTitleGroups.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"duplicateTitleRows={duplicateTitleRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"krxStoredNxtSuffix={krxSuffixRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
"emptyNxtVisible=true emptyNxtActionsBlocked=true");
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<RawThemeRow>> LoadRowsAsync(
|
||||
IDataQueryExecutor executor,
|
||||
DataSourceKind source,
|
||||
string queryName,
|
||||
string sql,
|
||||
ThemeMarket market,
|
||||
string databaseMarket,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spec = new DataQuerySpec(sql);
|
||||
spec.ValidateFor(source);
|
||||
var table = await executor.ExecuteAsync(
|
||||
source,
|
||||
queryName,
|
||||
spec,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (table is null || table.Columns.Count != 3 ||
|
||||
!string.Equals(table.Columns[0].ColumnName, "THEME_TITLE", StringComparison.Ordinal) ||
|
||||
!string.Equals(table.Columns[1].ColumnName, "THEME_CODE", StringComparison.Ordinal) ||
|
||||
!string.Equals(table.Columns[2].ColumnName, "THEME_MARKET", StringComparison.Ordinal) ||
|
||||
table.Columns.Cast<DataColumn>().Any(static column => column.DataType != typeof(string)))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
$"The {queryName} result has an invalid schema.");
|
||||
}
|
||||
|
||||
var result = new RawThemeRow[table.Rows.Count];
|
||||
for (var index = 0; index < table.Rows.Count; index++)
|
||||
{
|
||||
var row = table.Rows[index];
|
||||
var title = RequireStoredTitle(row[0], queryName);
|
||||
var code = RequireCode(row[1], queryName);
|
||||
if (row[2] is not string actualMarket ||
|
||||
!string.Equals(actualMarket, databaseMarket, StringComparison.Ordinal))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
$"The {queryName} result contains an invalid market identity.");
|
||||
}
|
||||
|
||||
result[index] = new RawThemeRow(market, code, title);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<HashSet<string>> LoadActiveNxtCodesAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spec = new DataQuerySpec(NxtActiveCodesSql);
|
||||
spec.ValidateFor(DataSourceKind.MariaDb);
|
||||
var table = await executor.ExecuteAsync(
|
||||
DataSourceKind.MariaDb,
|
||||
NxtActiveCodesQueryName,
|
||||
spec,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (table is null || table.Columns.Count != 1 ||
|
||||
!string.Equals(table.Columns[0].ColumnName, "THEME_CODE", StringComparison.Ordinal) ||
|
||||
table.Columns[0].DataType != typeof(string))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
$"The {NxtActiveCodesQueryName} result has an invalid schema.");
|
||||
}
|
||||
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (DataRow row in table.Rows)
|
||||
{
|
||||
if (row[0] is not string code || !result.Add(code))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
$"The {NxtActiveCodesQueryName} result contains an invalid code.");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string RequireStoredTitle(object value, string queryName)
|
||||
{
|
||||
if (value is not string title || title.Length is < 1 or > 128 ||
|
||||
string.IsNullOrWhiteSpace(title) || !IsSafeText(title))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
$"The {queryName} result contains an unsafe stored title.");
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
private static string RequireCode(object value, string queryName)
|
||||
{
|
||||
if (value is not string code || code.Length is < 3 or > 8 ||
|
||||
code.Any(static character => !char.IsAsciiDigit(character)))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
$"The {queryName} result contains an invalid theme code.");
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
private static void RequireUniqueCodes(IEnumerable<RawThemeRow> rows)
|
||||
{
|
||||
if (rows.GroupBy(static row => (row.Market, row.Code))
|
||||
.Any(static group => group.Count() != 1))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The deployed Theme catalog contains an ambiguous market/code identity.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasEdgeWhitespace(string value) =>
|
||||
value.Length > 0 &&
|
||||
(char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1]));
|
||||
|
||||
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 readonly record struct ThemeKey(
|
||||
ThemeMarket Market,
|
||||
string Code,
|
||||
string Title);
|
||||
|
||||
private sealed record RawThemeRow(
|
||||
ThemeMarket Market,
|
||||
string Code,
|
||||
string Title)
|
||||
{
|
||||
public ThemeKey Key => new(Market, Code, Title);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user