338 lines
13 KiB
C#
338 lines
13 KiB
C#
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);
|
|
}
|
|
}
|