feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

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

View File

@@ -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, [], []));
}
}

View File

@@ -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(

View File

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

View File

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