feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
internal static class ManualFinancialListCompatibilityAudit
|
||||
{
|
||||
internal static async Task RunAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executor);
|
||||
var service = new LegacyManualFinancialScreenService(executor);
|
||||
foreach (var contract in ManualFinancialCutContracts.All.OrderBy(
|
||||
contract => contract.Screen))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var table = await executor.ExecuteAsync(
|
||||
DataSourceKind.Oracle,
|
||||
"GRAPH_E_LIST_COUNT_" + contract.Screen,
|
||||
$"SELECT COUNT(*) AS ROW_COUNT FROM {contract.TableName}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (table.Columns.Count != 1 || table.Rows.Count != 1 ||
|
||||
!string.Equals(
|
||||
table.Columns[0].ColumnName,
|
||||
"ROW_COUNT",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The GraphE list count query returned an invalid schema.");
|
||||
}
|
||||
|
||||
var count = Convert.ToInt32(
|
||||
table.Rows[0][0],
|
||||
CultureInfo.InvariantCulture);
|
||||
if (count < 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The GraphE list count query returned an invalid count.");
|
||||
}
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
contract.Screen,
|
||||
query: string.Empty,
|
||||
maximumResults: LegacyManualFinancialScreenService.MaximumResults,
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var accountedRows = checked(result.Items.Count + result.RejectedItemCount);
|
||||
var strictReadyRawRows = result.RawItems.Count(item => item.IsPlayoutReady);
|
||||
var rawDeferredRows = result.RawItems.Count(item => !item.IsPlayoutReady);
|
||||
if (result.Screen != contract.Screen ||
|
||||
result.Query.Length != 0 ||
|
||||
result.IsTruncated ||
|
||||
!result.RawItemsComplete ||
|
||||
accountedRows != count ||
|
||||
result.RawItems.Count != count ||
|
||||
strictReadyRawRows != result.Items.Count ||
|
||||
rawDeferredRows != result.RejectedItemCount ||
|
||||
result.UnreadableStockNames.Count != result.RejectedItemCount ||
|
||||
result.Items.Any(item =>
|
||||
item.Record.Identity.Screen != contract.Screen) ||
|
||||
result.RawItems.Any(item =>
|
||||
item.Record.Identity.Screen != contract.Screen))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The GraphE full-list compatibility read did not account for every DB row.");
|
||||
}
|
||||
|
||||
var rawProbe = result.RawItems.FirstOrDefault(item => !item.IsPlayoutReady) ??
|
||||
result.RawItems.FirstOrDefault();
|
||||
if (rawProbe is not null)
|
||||
{
|
||||
var fresh = await service.GetRawAsync(rawProbe, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (fresh.Record.Identity != rawProbe.Record.Identity ||
|
||||
fresh.Record.StorageValues.Count != rawProbe.Record.StorageValues.Count)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The GraphE exact raw-row read was not correlated to its list handle.");
|
||||
}
|
||||
}
|
||||
|
||||
var legacyShapeCompatibleDeferredRows = result.RawItems.Count(item =>
|
||||
!item.IsPlayoutReady && IsLegacySceneShapeCompatible(item.Record));
|
||||
|
||||
Console.WriteLine(
|
||||
$"GRAPH_E_LIST_COMPAT_AUDIT: screen={contract.Screen} " +
|
||||
$"dbRows={count} rawEditable={result.RawItems.Count} " +
|
||||
$"strictReady={result.Items.Count} " +
|
||||
$"strictDeferred={result.RejectedItemCount} " +
|
||||
$"legacyShapeCompatibleDeferred={legacyShapeCompatibleDeferredRows} " +
|
||||
$"exactRawReads={(rawProbe is null ? 0 : 1)} truncated=false");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLegacySceneShapeCompatible(ManualFinancialRawRecord record) =>
|
||||
record.Identity.Screen switch
|
||||
{
|
||||
ManualFinancialScreenKind.RevenueComposition =>
|
||||
record.StorageValues.Skip(1).All(IsLegacyRevenueSlice),
|
||||
ManualFinancialScreenKind.GrowthMetrics =>
|
||||
record.StorageValues.Take(4).All(value => IsLegacyNumberSeries(value, 4)) &&
|
||||
HasAtLeastTokens(record.StorageValues[4], 4),
|
||||
ManualFinancialScreenKind.Sales or ManualFinancialScreenKind.OperatingProfit =>
|
||||
record.StorageValues.All(IsLegacyQuarter),
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static bool IsLegacyRevenueSlice(string value)
|
||||
{
|
||||
var tokens = value.Split('_', StringSplitOptions.None);
|
||||
if (tokens.Length < 2)
|
||||
{
|
||||
return value.Length == 0;
|
||||
}
|
||||
|
||||
if (tokens[0].Length == 0 || tokens[1] is "" or "-")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return double.TryParse(
|
||||
tokens[1],
|
||||
NumberStyles.Float | NumberStyles.AllowThousands,
|
||||
CultureInfo.CurrentCulture,
|
||||
out var parsed) &&
|
||||
double.IsFinite(parsed);
|
||||
}
|
||||
|
||||
private static bool IsLegacyNumberSeries(string value, int minimumTokens)
|
||||
{
|
||||
var tokens = value.Split('_', StringSplitOptions.None);
|
||||
return tokens.Length >= minimumTokens && tokens.All(token =>
|
||||
token.Length == 0 ||
|
||||
double.TryParse(
|
||||
token,
|
||||
NumberStyles.Float | NumberStyles.AllowThousands,
|
||||
CultureInfo.CurrentCulture,
|
||||
out var parsed) &&
|
||||
double.IsFinite(parsed));
|
||||
}
|
||||
|
||||
private static bool HasAtLeastTokens(string value, int minimumTokens) =>
|
||||
value.Split('_', StringSplitOptions.None).Length >= minimumTokens;
|
||||
|
||||
private static bool IsLegacyQuarter(string value)
|
||||
{
|
||||
var tokens = value.Split('_', StringSplitOptions.None);
|
||||
return tokens.Length >= 2 &&
|
||||
(tokens[1].Length == 0 || int.TryParse(
|
||||
tokens[1],
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.CurrentCulture,
|
||||
out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
internal static class NamedPlaylistOperatorCompatibilityAudit
|
||||
{
|
||||
internal static async Task RunAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executor);
|
||||
var persistence = new LegacyNamedPlaylistPersistenceService(executor);
|
||||
var workflow = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
var controller = new LegacyOperatorController(
|
||||
new UnusedStockLookup(),
|
||||
namedPlaylistWorkflow: workflow);
|
||||
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var definitions = listed.NamedPlaylist?.Definitions ?? [];
|
||||
if (definitions.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The operator compatibility audit did not receive a named-playlist definition.");
|
||||
}
|
||||
|
||||
var rowCount = 0;
|
||||
var resolvedCount = 0;
|
||||
var unresolvedCount = 0;
|
||||
for (var definitionIndex = 0; definitionIndex < definitions.Count; definitionIndex++)
|
||||
{
|
||||
var definition = definitions[definitionIndex];
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var loaded = await controller.LoadNamedPlaylistByIdAsync(
|
||||
definition.DefinitionId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var boundary = loaded.CommandResult?.Succeeded != true
|
||||
? "command"
|
||||
: loaded.Dialog is not null
|
||||
? "dialog"
|
||||
: loaded.NamedPlaylist?.ReadStatus != LegacyNamedPlaylistReadStatus.Ready
|
||||
? "read-status"
|
||||
: loaded.NamedPlaylist.DocumentFreshness !=
|
||||
LegacyNamedPlaylistFreshness.Fresh
|
||||
? "freshness"
|
||||
: loaded.Playlist.Count != loaded.NamedPlaylist.Rows.Count
|
||||
? "row-count"
|
||||
: null;
|
||||
if (boundary is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"PLIST_OPERATOR_COMPAT_BOUNDARY: " + boundary +
|
||||
" definitionOrdinal=" +
|
||||
definitionIndex.ToString(CultureInfo.InvariantCulture) +
|
||||
" commandSucceeded=" + (loaded.CommandResult?.Succeeded == true) +
|
||||
" dialog=" + (loaded.Dialog is not null) +
|
||||
" status=" + loaded.StatusKind +
|
||||
" read=" + loaded.NamedPlaylist?.ReadStatus +
|
||||
" freshness=" + loaded.NamedPlaylist?.DocumentFreshness +
|
||||
" loadedRows=" + loaded.Playlist.Count.ToString(CultureInfo.InvariantCulture) +
|
||||
" documentRows=" +
|
||||
(loaded.NamedPlaylist?.Rows.Count ?? -1).ToString(CultureInfo.InvariantCulture));
|
||||
throw new InvalidDataException(
|
||||
"The operator compatibility audit failed its " + boundary +
|
||||
" boundary at definition ordinal " +
|
||||
definitionIndex.ToString(CultureInfo.InvariantCulture) + ".");
|
||||
}
|
||||
|
||||
for (var rowIndex = 0; rowIndex < loaded.Playlist.Count; rowIndex++)
|
||||
{
|
||||
var row = loaded.Playlist[rowIndex];
|
||||
if (!row.RowId.StartsWith("legacy-named-", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The operator compatibility audit retained a previous row at definition " +
|
||||
definitionIndex.ToString(CultureInfo.InvariantCulture) + ", row " +
|
||||
rowIndex.ToString(CultureInfo.InvariantCulture) + ".");
|
||||
}
|
||||
|
||||
var entry = row.ToPlayoutEntry();
|
||||
if (row.IsSceneResolved !=
|
||||
!string.Equals(
|
||||
entry.CutCode,
|
||||
LegacyPlaylistEntry.UnresolvedCutCode,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The operator compatibility audit found inconsistent scene state at definition " +
|
||||
definitionIndex.ToString(CultureInfo.InvariantCulture) + ", row " +
|
||||
rowIndex.ToString(CultureInfo.InvariantCulture) + ".");
|
||||
}
|
||||
|
||||
if (row.IsSceneResolved)
|
||||
{
|
||||
resolvedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
unresolvedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
rowCount += loaded.Playlist.Count;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"PLIST_OPERATOR_COMPAT_AUDIT: " +
|
||||
$"definitions={definitions.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"rowsLoaded={rowCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"sceneAliasResolved={resolvedCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"rowLevelDeferred={unresolvedCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
"wholeListRejected=0 playoutPreflightAsserted=false rawValuesPrinted=false");
|
||||
}
|
||||
|
||||
private sealed class UnusedStockLookup : ILegacyStockLookup
|
||||
{
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new LegacyStockSearchData(query, [], []));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ try
|
||||
|
||||
await VerifyOracleServerVersionAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyStockSearchAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyDynamicResultCompatibilityAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyThemeSelectorAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyOverseasSelectorsAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyComparisonWorldSelectorAsync(runtime.Executor, cancellation.Token);
|
||||
@@ -57,7 +58,13 @@ try
|
||||
runtime.ConnectionFactory,
|
||||
cancellation.Token);
|
||||
await VerifyManualFinancialSearchAsync(runtime.Executor, cancellation.Token);
|
||||
await ManualFinancialListCompatibilityAudit.RunAsync(
|
||||
runtime.Executor,
|
||||
cancellation.Token);
|
||||
await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token);
|
||||
await NamedPlaylistOperatorCompatibilityAudit.RunAsync(
|
||||
runtime.Executor,
|
||||
cancellation.Token);
|
||||
await NamedManualRestoreReadOnlyAudit.RunAsync(
|
||||
runtime.Executor,
|
||||
NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(),
|
||||
@@ -736,6 +743,9 @@ static async Task VerifyOperatorCatalogSchemaAsync(
|
||||
_ = await themePersistence.SuggestNextCodeAsync(
|
||||
ThemeMarket.Nxt,
|
||||
cancellationToken);
|
||||
await ThemeCatalogStoredEditorCompatibilityAudit.RunAsync(
|
||||
executor,
|
||||
cancellationToken);
|
||||
_ = await new LegacyExpertCatalogPersistenceService(executor)
|
||||
.SuggestNextCodeAsync(cancellationToken);
|
||||
var widthSummary = await ReadMariaDbThemeColumnTypesAsync(
|
||||
@@ -813,6 +823,96 @@ static async Task VerifyStockSearchAsync(
|
||||
$"({string.Join(", ", counts)})");
|
||||
}
|
||||
|
||||
static async Task VerifyDynamicResultCompatibilityAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (LegacyOperatorCatalogWorkflowController.MaximumCatalogResults !=
|
||||
LegacyThemeSelectionService.MaximumResults ||
|
||||
LegacyOperatorCatalogWorkflowController.MaximumStockResults !=
|
||||
LegacyStockSearchService.MaximumResults ||
|
||||
LegacyManualFinancialWorkflow.MaximumStockResults !=
|
||||
LegacyStockSearchService.MaximumResults ||
|
||||
LegacyManualListsWorkflow.MaximumSearchResults <
|
||||
LegacyStockSearchService.MaximumResults)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"A dynamic operator list is still bounded below its Core compatibility ceiling.");
|
||||
}
|
||||
|
||||
var worldService = new LegacyWorldStockSearchService(executor);
|
||||
var comparison = new LegacyComparisonDataService(
|
||||
new LegacyParityStockSearchService(executor),
|
||||
worldService);
|
||||
Console.WriteLine("DynamicResultCompatibility domestic: BEGIN");
|
||||
var domestic = await comparison.SearchDomesticWithDiagnosticsAsync(
|
||||
string.Empty,
|
||||
LegacyStockSearchService.MaximumResults,
|
||||
cancellationToken);
|
||||
if (domestic.Items.Count != 4_599 ||
|
||||
domestic.IsolatedInvalidRowCount != 0 ||
|
||||
domestic.IsTruncated)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The UC3 domestic compatibility cardinality changed: expected 4,599 safe rows.");
|
||||
}
|
||||
|
||||
Console.WriteLine("DynamicResultCompatibility world: BEGIN");
|
||||
var coreWorld = await worldService.SearchAsync(
|
||||
string.Empty,
|
||||
LegacyWorldStockSearchService.MaximumResults,
|
||||
cancellationToken);
|
||||
var commaNameCount = coreWorld.Items.Count(item => item.InputName.Contains(','));
|
||||
if (coreWorld.Items.Count != 1_474 ||
|
||||
coreWorld.IsolatedInvalidRowCount != 16 ||
|
||||
commaNameCount != 1)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The raw UC3 world compatibility split changed before application mapping.");
|
||||
}
|
||||
var world = await comparison.SearchWorldWithDiagnosticsAsync(
|
||||
string.Empty,
|
||||
LegacyWorldStockSearchService.MaximumResults,
|
||||
cancellationToken);
|
||||
var worldSourceRows = checked(world.Items.Count + world.IsolatedInvalidRowCount);
|
||||
var worldActionableRows = world.Items.Count(item => item.CanSelect);
|
||||
Console.WriteLine(
|
||||
"DynamicResultCompatibility world diagnostics: " +
|
||||
$"source={worldSourceRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"visible={world.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"actionable={worldActionableRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"isolated={world.IsolatedInvalidRowCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"deferred={world.DeferredUnsafeRowCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"truncated={world.IsTruncated}");
|
||||
if (worldSourceRows != 1_490 ||
|
||||
world.Items.Count != 1_474 ||
|
||||
worldActionableRows != 1_473 ||
|
||||
world.IsolatedInvalidRowCount != 16 ||
|
||||
world.DeferredUnsafeRowCount != 1 ||
|
||||
!world.IsTruncated)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The UC3 world compatibility cardinality changed: expected 1,490 source rows, " +
|
||||
"1,474 visible rows, 1,473 selectable rows, 16 isolated blank-display rows, " +
|
||||
"and one deferred comma-subject row.");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"DynamicResultCompatibility: " +
|
||||
$"domesticSource={domestic.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"domesticVisible={domestic.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"domesticIsolated={domestic.IsolatedInvalidRowCount.ToString(CultureInfo.InvariantCulture)}; " +
|
||||
$"worldSource={worldSourceRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"worldVisible={world.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"worldActionable={worldActionableRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"worldIsolated={world.IsolatedInvalidRowCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"worldDeferred={world.DeferredUnsafeRowCount.ToString(CultureInfo.InvariantCulture)}; " +
|
||||
$"stockCeiling={LegacyStockSearchService.MaximumResults.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"worldCeiling={LegacyWorldStockSearchService.MaximumResults.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"themeCeiling={LegacyThemeSelectionService.MaximumResults.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"viCeiling={LegacyManualListsWorkflow.MaximumSearchResults.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
static async Task VerifyThemeSelectorAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -822,22 +922,81 @@ static async Task VerifyThemeSelectorAsync(
|
||||
var themes = await service.SearchAsync(
|
||||
string.Empty,
|
||||
ThemeSession.PreMarket,
|
||||
maximumResults: 100,
|
||||
maximumResults: LegacyThemeSelectionService.MaximumResults,
|
||||
cancellationToken);
|
||||
if (themes.Items.Count == 0)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The production theme selector returned no unambiguous canonical rows.");
|
||||
}
|
||||
if (themes.IsTruncated)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The production theme selector exceeded its finite " +
|
||||
$"{LegacyThemeSelectionService.MaximumResults.ToString(CultureInfo.InvariantCulture)}-row safety ceiling.");
|
||||
}
|
||||
var storedNxtSuffixCount = themes.Items.Count(item =>
|
||||
item.Identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal));
|
||||
var storedNxtSuffixKrxCount = themes.Items.Count(item =>
|
||||
item.Identity.Market == ThemeMarket.Krx &&
|
||||
item.Identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal));
|
||||
var storedNxtSuffixNxtCount = themes.Items.Count(item =>
|
||||
item.Identity.Market == ThemeMarket.Nxt &&
|
||||
item.Identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal));
|
||||
Console.WriteLine(
|
||||
"ThemeSelector identity diagnostics: " +
|
||||
$"storedNxtSuffix={storedNxtSuffixCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"krx={storedNxtSuffixKrxCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"nxt={storedNxtSuffixNxtCount.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
var workflow = new LegacyThemeWorkflow(service);
|
||||
LegacyThemeWorkflowSnapshot workflowSnapshot;
|
||||
try
|
||||
{
|
||||
workflowSnapshot = await workflow.SearchAsync(
|
||||
workflow.CreateInitial(ThemeSession.PreMarket),
|
||||
string.Empty,
|
||||
ThemeSession.PreMarket,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (LegacyThemeWorkflowDataException exception)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"THEME_WORKFLOW_BOUNDARY: " + exception.Message);
|
||||
throw new DatabaseConfigurationException(
|
||||
"The production theme rows passed the data service but failed the operator workflow: " +
|
||||
exception.Message);
|
||||
}
|
||||
if (workflowSnapshot.SearchIsTruncated ||
|
||||
workflowSnapshot.SearchResults.Count != themes.Items.Count)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The production theme operator workflow did not preserve the complete selector result.");
|
||||
}
|
||||
|
||||
await ThemeSelectorCompatibilityAudit.RunAsync(
|
||||
executor,
|
||||
service,
|
||||
themes,
|
||||
workflow,
|
||||
workflowSnapshot,
|
||||
cancellationToken);
|
||||
|
||||
var selected = themes.Items[0];
|
||||
var preview = await service.PreviewAsync(
|
||||
selected.Identity,
|
||||
maximumItems: 100,
|
||||
cancellationToken);
|
||||
var marketCounts = themes.Items
|
||||
.GroupBy(item => item.Identity.Market)
|
||||
.OrderBy(group => group.Key)
|
||||
.Select(group =>
|
||||
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine(
|
||||
$"ThemeSelector: themes={themes.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"preview={preview.Items.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||
$"ThemeSelector: themes={themes.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"({string.Join(", ", marketCounts)}), " +
|
||||
$"preview={preview.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"truncated={themes.IsTruncated}");
|
||||
}
|
||||
|
||||
static async Task VerifyOverseasSelectorsAsync(
|
||||
@@ -873,7 +1032,9 @@ static async Task VerifyOverseasSelectorsAsync(
|
||||
LegacyOverseasStockSearchService.DefaultMaximumResults,
|
||||
cancellationToken);
|
||||
if (uc5Snapshot.Stock.Status != LegacyOverseasSearchStatus.Ready ||
|
||||
uc5Snapshot.Stock.ResultCount != overseasStocks.Items.Count)
|
||||
uc5Snapshot.Stock.ResultCount != overseasStocks.Items.Count ||
|
||||
uc5Snapshot.Stock.IsolatedInvalidRowCount !=
|
||||
overseasStocks.IsolatedInvalidRowCount)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The full UC5 overseas-stock workflow rejected current master rows.");
|
||||
@@ -884,11 +1045,21 @@ static async Task VerifyOverseasSelectorsAsync(
|
||||
throw new DatabaseConfigurationException(
|
||||
"The production overseas selectors returned no rows.");
|
||||
}
|
||||
if (industries.Items.Count != 53 ||
|
||||
overseasStocks.Items.Count != 1_474 ||
|
||||
overseasStocks.IsolatedInvalidRowCount != 16 ||
|
||||
!overseasStocks.IsTruncated)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The UC5 compatibility cardinality changed: expected 53 industries, " +
|
||||
"1,474 selectable US/TW stocks, and 16 isolated blank-name rows.");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"OverseasSelectors: industries={industries.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"comparisonStocks={stocks.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"uc5Stocks={overseasStocks.Items.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||
$"uc5Stocks={overseasStocks.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"isolatedBlankNames={overseasStocks.IsolatedInvalidRowCount.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
static async Task PrintUc5OverseasStockDiagnosticsAsync(
|
||||
@@ -908,7 +1079,11 @@ static async Task PrintUc5OverseasStockDiagnosticsAsync(
|
||||
""";
|
||||
var spec = new DataQuerySpec(
|
||||
sql,
|
||||
[new DataQueryParameter("row_limit", 501, DbType.Int32)]);
|
||||
[new DataQueryParameter(
|
||||
"row_limit",
|
||||
LegacyOverseasStockSearchService.MaximumResults +
|
||||
LegacyOverseasStockSearchService.MaximumIsolatedRows + 1,
|
||||
DbType.Int32)]);
|
||||
spec.ValidateFor(DataSourceKind.Oracle);
|
||||
var table = await executor.ExecuteAsync(
|
||||
DataSourceKind.Oracle,
|
||||
@@ -1009,13 +1184,40 @@ static async Task VerifyExpertSelectorAsync(
|
||||
"The production expert selector returned no rows.");
|
||||
}
|
||||
|
||||
var preview = await service.PreviewAsync(
|
||||
experts.Items[0].Identity,
|
||||
maximumItems: 100,
|
||||
cancellationToken);
|
||||
if (experts.Items.Count != 6 || experts.IsTruncated)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The expert compatibility cardinality changed: expected all 6 experts.");
|
||||
}
|
||||
|
||||
var recommendationCount = 0;
|
||||
var missingBuyAmountCount = 0;
|
||||
foreach (var expert in experts.Items)
|
||||
{
|
||||
var preview = await service.PreviewAsync(
|
||||
expert.Identity,
|
||||
maximumItems: 100,
|
||||
cancellationToken);
|
||||
if (preview.IsTruncated)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"An expert preview exceeded the complete compatibility boundary.");
|
||||
}
|
||||
|
||||
recommendationCount += preview.Items.Count;
|
||||
missingBuyAmountCount += preview.Items.Count(item => item.BuyAmount is null);
|
||||
}
|
||||
if (recommendationCount != 32 || missingBuyAmountCount != 5)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The expert recommendation compatibility cardinality changed: " +
|
||||
"expected 32 rows including 5 blank buy amounts.");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"ExpertSelector: experts={experts.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"preview={preview.Items.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||
$"recommendations={recommendationCount.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"blankBuyAmounts={missingBuyAmountCount.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
static async Task VerifyTradingHaltSelectorAsync(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
internal static class ThemeCatalogStoredEditorCompatibilityAudit
|
||||
{
|
||||
private const string KrxCrossPrefixCode = "00005724";
|
||||
private const string KrxDuplicateIndexCode = "00001164";
|
||||
private const string NxtCrossPrefixCode = "00000168";
|
||||
|
||||
public static async Task RunAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executor);
|
||||
var selector = await new LegacyThemeSelectionService(executor).SearchAsync(
|
||||
string.Empty,
|
||||
ThemeSession.PreMarket,
|
||||
LegacyThemeSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (selector.IsTruncated)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The ThemeA stored-editor audit requires the complete selector.");
|
||||
}
|
||||
|
||||
var krxCrossIdentity = FindIdentity(
|
||||
selector,
|
||||
ThemeMarket.Krx,
|
||||
KrxCrossPrefixCode);
|
||||
var krxDuplicateIdentity = FindIdentity(
|
||||
selector,
|
||||
ThemeMarket.Krx,
|
||||
KrxDuplicateIndexCode);
|
||||
var nxtCrossIdentity = FindIdentity(
|
||||
selector,
|
||||
ThemeMarket.Nxt,
|
||||
NxtCrossPrefixCode);
|
||||
|
||||
var persistence = new LegacyThemeCatalogPersistenceService(executor);
|
||||
var krxCross = await persistence.ReadStoredAsync(
|
||||
krxCrossIdentity,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var krxDuplicate = await persistence.ReadStoredAsync(
|
||||
krxDuplicateIdentity,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var nxtCross = await persistence.ReadStoredAsync(
|
||||
nxtCrossIdentity,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RequireComplete(krxCross);
|
||||
RequireComplete(krxDuplicate);
|
||||
RequireComplete(nxtCross);
|
||||
var krxCrossRows = krxCross.Items.Count(static item =>
|
||||
item.ItemCode.Length > 0 && item.ItemCode[0] is 'X' or 'T');
|
||||
var nxtCrossRows = nxtCross.Items.Count(static item =>
|
||||
item.ItemCode.Length > 0 && item.ItemCode[0] is 'P' or 'D');
|
||||
var duplicateGroups = krxDuplicate.Items
|
||||
.GroupBy(static item => item.InputIndex)
|
||||
.Count(static group => group.Count() > 1);
|
||||
var duplicateRows = krxDuplicate.Items
|
||||
.GroupBy(static item => item.InputIndex)
|
||||
.Where(static group => group.Count() > 1)
|
||||
.Sum(static group => group.Count());
|
||||
if (krxCrossRows == 0 || nxtCrossRows == 0 ||
|
||||
duplicateGroups == 0 || duplicateRows == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The deployed ThemeA stored-editor compatibility rows were not preserved.");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"THEMEA_STORED_EDITOR_COMPATIBILITY: " +
|
||||
$"krxCrossPrefixRows={krxCrossRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"nxtCrossPrefixRows={nxtCrossRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"duplicateIndexGroups={duplicateGroups.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"duplicateIndexRows={duplicateRows.ToString(CultureInfo.InvariantCulture)} " +
|
||||
"rawValuesPrinted=false writesAttempted=false");
|
||||
}
|
||||
|
||||
private static ThemeSelectionIdentity FindIdentity(
|
||||
ThemeSearchResult selector,
|
||||
ThemeMarket market,
|
||||
string code) =>
|
||||
selector.Items.SingleOrDefault(item =>
|
||||
item.Identity.Market == market &&
|
||||
string.Equals(item.Identity.ThemeCode, code, StringComparison.Ordinal))?.Identity ??
|
||||
throw new InvalidOperationException(
|
||||
"A deployed ThemeA stored-editor compatibility identity is missing.");
|
||||
|
||||
private static void RequireComplete(ThemeCatalogStoredResult result)
|
||||
{
|
||||
if (result.IsTruncated)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A deployed ThemeA stored definition exceeds the safe editor boundary.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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