feat: consolidate legacy parity migration baseline

This commit is contained in:
2026-07-20 22:06:12 +09:00
parent 5eb4054120
commit 4a54977341
66 changed files with 5391 additions and 460 deletions

View File

@@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,8 +1,10 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;
Console.OutputEncoding = Encoding.UTF8;
@@ -47,9 +49,14 @@ try
await VerifyStockSearchAsync(runtime.Executor, cancellation.Token);
await VerifyThemeSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyOverseasSelectorsAsync(runtime.Executor, cancellation.Token);
await VerifyComparisonWorldSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token);
await VerifyOperatorCatalogSchemaAsync(
runtime.Executor,
runtime.ConnectionFactory,
cancellation.Token);
await VerifyManualFinancialSearchAsync(runtime.Executor, cancellation.Token);
await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token);
await NamedManualRestoreReadOnlyAudit.RunAsync(
runtime.Executor,
@@ -98,6 +105,16 @@ catch (OverseasIndustryIndexSearchDataException exception)
Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}");
return 1;
}
catch (WorldStockSearchDataException exception)
{
Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}");
return 1;
}
catch (ManualFinancialDataException exception)
{
Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}");
return 1;
}
catch (Exception exception)
{
Console.Error.WriteLine(
@@ -215,6 +232,93 @@ static async Task VerifyLegacyComparisonImportAsync(
$"sha256={result.SourceSha256}, identities=fresh-read-only");
}
static async Task VerifyManualFinancialSearchAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
var service = new LegacyManualFinancialScreenService(executor);
var stockSearch = await new LegacyStockSearchService(executor)
.SearchAsync("삼성", 100, cancellationToken)
.ConfigureAwait(false);
var absentFixture = await FindAbsentManualFinancialFixtureAsync(
service,
stockSearch.Items
.Select(static item => item.Name)
.Distinct(StringComparer.Ordinal),
cancellationToken)
.ConfigureAwait(false);
foreach (var screen in Enum.GetValues<ManualFinancialScreenKind>())
{
ManualFinancialSearchResult result;
try
{
result = await service.SearchAsync(
screen,
absentFixture,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
catch (ManualFinancialDataException exception)
{
throw new ManualFinancialDataException(
$"GraphE search {screen} failed validation: {exception.Message}");
}
if (result.Items.Any(row =>
string.Equals(
row.Record.Identity.StockName,
absentFixture,
StringComparison.Ordinal)))
{
throw new DatabaseConfigurationException(
"The read-only GraphE search fixture must be absent before UI CRUD validation.");
}
Console.WriteLine(
$"GraphE search {screen}: rows={result.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
$"fixture={absentFixture}, fixtureAbsent=true, fresh-read-only");
}
}
static async Task<string> FindAbsentManualFinancialFixtureAsync(
LegacyManualFinancialScreenService service,
IEnumerable<string> stockNames,
CancellationToken cancellationToken)
{
foreach (var stockName in stockNames)
{
var absent = true;
foreach (var screen in Enum.GetValues<ManualFinancialScreenKind>())
{
try
{
_ = await service.GetAsync(
new ManualFinancialIdentity(screen, stockName),
cancellationToken)
.ConfigureAwait(false);
absent = false;
break;
}
catch (ManualFinancialNotFoundException)
{
}
catch (ManualFinancialDataException)
{
absent = false;
break;
}
}
if (absent)
{
return stockName;
}
}
throw new DatabaseConfigurationException(
"No read-only GraphE UI fixture was absent from all four manual-financial tables.");
}
static async Task<int> RunManualImportFixtureAsync()
{
const string expectedSourceSha256 =
@@ -620,6 +724,7 @@ static async Task VerifySnapshotAsync(
static async Task VerifyOperatorCatalogSchemaAsync(
IDataQueryExecutor executor,
IDatabaseConnectionFactory connectionFactory,
CancellationToken cancellationToken)
{
var result = await new LegacyOperatorCatalogSchemaValidationService(executor)
@@ -633,12 +738,54 @@ static async Task VerifyOperatorCatalogSchemaAsync(
cancellationToken);
_ = await new LegacyExpertCatalogPersistenceService(executor)
.SuggestNextCodeAsync(cancellationToken);
var widthSummary = await ReadMariaDbThemeColumnTypesAsync(
connectionFactory,
cancellationToken);
Console.WriteLine(
"Operator catalog schema: " +
string.Join(
",",
result.ValidatedSources.Select(static source => source.ToString())) +
" (read-only zero-row preflight and code-range validation)");
" (read-only zero-row preflight and code-range validation); MariaDB " +
string.Join(", ", widthSummary));
}
static async Task<IReadOnlyList<string>> ReadMariaDbThemeColumnTypesAsync(
IDatabaseConnectionFactory connectionFactory,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
await using var connection = connectionFactory.Create(DataSourceKind.MariaDb);
await connection.OpenAsync(cancellationToken);
var values = new List<string>(3);
foreach (var (table, columns) in new[]
{
("SB_LIST", new HashSet<string>(["SB_CODE"], StringComparer.OrdinalIgnoreCase)),
("SB_ITEM", new HashSet<string>(["SB_M_CODE", "SB_I_CODE"], StringComparer.OrdinalIgnoreCase))
})
{
await using var command = connection.CreateCommand();
command.CommandText = $"SHOW COLUMNS FROM {table}";
command.CommandTimeout = connectionFactory.GetCommandTimeoutSeconds(
DataSourceKind.MariaDb);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var column = reader.GetString(0);
if (columns.Contains(column))
{
values.Add($"{table}.{column}={reader.GetString(1)}");
}
}
}
if (values.Count != 3)
{
throw new DatabaseConfigurationException(
"The MariaDB ThemeA column-type audit returned an unexpected shape.");
}
return values.Order(StringComparer.Ordinal).ToArray();
}
static async Task VerifyStockSearchAsync(
@@ -703,7 +850,36 @@ static async Task VerifyOverseasSelectorsAsync(
Console.WriteLine("WorldStockSelector: BEGIN");
var stocks = await new LegacyWorldStockSearchService(executor)
.SearchAsync(string.Empty, maximumResults: 100, cancellationToken);
if (industries.Items.Count == 0 || stocks.Items.Count == 0)
Console.WriteLine("OverseasStockSelector: BEGIN");
WorldStockSearchResult overseasStocks;
try
{
overseasStocks = await new LegacyOverseasStockSearchService(executor)
.SearchAsync(
string.Empty,
LegacyOverseasStockSearchService.DefaultMaximumResults,
cancellationToken);
}
catch (WorldStockSearchDataException)
{
await PrintUc5OverseasStockDiagnosticsAsync(executor, cancellationToken);
throw;
}
var uc5Workflow = new LegacyOverseasSelectionWorkflow(
new LegacyOverseasIndustryIndexSearchService(executor),
new LegacyOverseasStockSearchService(executor));
var uc5Snapshot = await uc5Workflow.SearchStocksAsync(
string.Empty,
LegacyOverseasStockSearchService.DefaultMaximumResults,
cancellationToken);
if (uc5Snapshot.Stock.Status != LegacyOverseasSearchStatus.Ready ||
uc5Snapshot.Stock.ResultCount != overseasStocks.Items.Count)
{
throw new DatabaseConfigurationException(
"The full UC5 overseas-stock workflow rejected current master rows.");
}
if (industries.Items.Count == 0 || stocks.Items.Count == 0 ||
overseasStocks.Items.Count == 0)
{
throw new DatabaseConfigurationException(
"The production overseas selectors returned no rows.");
@@ -711,7 +887,110 @@ static async Task VerifyOverseasSelectorsAsync(
Console.WriteLine(
$"OverseasSelectors: industries={industries.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
$"stocks={stocks.Items.Count.ToString(CultureInfo.InvariantCulture)}");
$"comparisonStocks={stocks.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
$"uc5Stocks={overseasStocks.Items.Count.ToString(CultureInfo.InvariantCulture)}");
}
static async Task PrintUc5OverseasStockDiagnosticsAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
const string sql = """
SELECT F_INPUT_NAME, F_SYMB, F_NATC
FROM (
SELECT F_INPUT_NAME, F_SYMB, F_NATC
FROM T_WORLD_IX_EQ_MASTER
WHERE F_FDTC = '1'
AND F_NATC IN ('US', 'TW')
ORDER BY F_INPUT_NAME
)
WHERE ROWNUM <= :row_limit
""";
var spec = new DataQuerySpec(
sql,
[new DataQueryParameter("row_limit", 501, DbType.Int32)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await executor.ExecuteAsync(
DataSourceKind.Oracle,
"UC5_OVERSEAS_STOCK_DIAGNOSTIC",
spec,
cancellationToken);
var violations = new List<string>();
var identities = new HashSet<(string InputName, string Symbol, string NationCode)>();
var normalizedIdentities =
new HashSet<(string InputName, string Symbol, string NationCode)>();
for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++)
{
var values = new[]
{
(Name: "F_INPUT_NAME", Value: table.Rows[rowIndex][0], Maximum: 200),
(Name: "F_SYMB", Value: table.Rows[rowIndex][1], Maximum: 64),
(Name: "F_NATC", Value: table.Rows[rowIndex][2], Maximum: 8)
};
var valid = true;
var text = new string[3];
for (var columnIndex = 0; columnIndex < values.Length; columnIndex++)
{
var field = values[columnIndex];
if (field.Value is not string value)
{
violations.Add($"row={rowIndex + 1} field={field.Name} reason=non-string-or-null");
valid = false;
continue;
}
text[columnIndex] = value;
var unsafeText = value.Any(character =>
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
return char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' || category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator;
});
if (value.Length < 1 || value.Length > field.Maximum ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) || unsafeText)
{
violations.Add(
$"row={rowIndex + 1} field={field.Name} reason=non-canonical " +
$"length={value.Length} trimEqual={string.Equals(value, value.Trim(), StringComparison.Ordinal)} " +
$"unsafeText={unsafeText}");
valid = false;
}
}
if (valid && (text[2].Length is not (2 or 3) ||
text[2].Any(character => character is < 'A' or > 'Z')))
{
violations.Add($"row={rowIndex + 1} field=F_NATC reason=nation-code");
valid = false;
}
if (valid && !identities.Add((text[0], text[1], text[2])))
{
violations.Add($"row={rowIndex + 1} field=identity reason=duplicate-exact-identity");
}
if (text.All(value => value is not null))
{
var normalized = (
text[0].Trim(' '),
text[1].Trim(' '),
text[2].Trim(' '));
if (!normalizedIdentities.Add(normalized))
{
violations.Add(
$"row={rowIndex + 1} field=identity reason=duplicate-after-ascii-trim");
}
}
}
Console.WriteLine(
$"UC5OverseasStockDiagnostic: rows={table.Rows.Count.ToString(CultureInfo.InvariantCulture)}, " +
$"violations={violations.Count.ToString(CultureInfo.InvariantCulture)}");
foreach (var violation in violations.Take(20))
{
Console.WriteLine("UC5OverseasStockDiagnostic: " + violation);
}
}
static async Task VerifyExpertSelectorAsync(
@@ -752,6 +1031,48 @@ static async Task VerifyTradingHaltSelectorAsync(
$"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows");
}
static async Task VerifyComparisonWorldSelectorAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
var candidates = await new LegacyOverseasStockSearchService(executor).SearchAsync(
string.Empty,
maximumResults: 100,
cancellationToken);
var comparison = new LegacyComparisonDataService(
new LegacyParityStockSearchService(executor),
new LegacyWorldStockSearchService(executor));
foreach (var query in candidates.Items
.Select(item => item.InputName)
.Where(value => value.Length <= LegacyWorldStockSearchService.MaximumQueryLength)
.Distinct(StringComparer.Ordinal))
{
try
{
var result = await comparison.SearchWorldAsync(
query,
maximumResults: 100,
cancellationToken);
if (result.Count > 0)
{
Console.WriteLine(
$"ComparisonWorldSelector: query={query}, " +
$"rows={result.Count.ToString(CultureInfo.InvariantCulture)}");
return;
}
}
catch (Exception exception) when (
exception is WorldStockSearchDataException or ArgumentException)
{
// Try the next current master identity; ambiguous lookup keys are
// intentionally unavailable to the comparison workflow.
}
}
throw new WorldStockSearchDataException(
"No unambiguous current comparison-world query was found.");
}
internal sealed record DatabaseSmokeRuntimeSelection(
DatabaseRuntime Runtime,
MariaDbEndpointReference ReferenceMariaDb);