feat: consolidate legacy parity migration baseline
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user