Files
MBN_STOCK_WEBVIEW/tools/MBN_STOCK_WEBVIEW.DbSmoke/ThemeSelectorCompatibilityAudit.cs

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);
}
}