1281 lines
49 KiB
C#
1281 lines
49 KiB
C#
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;
|
|
if (args.Length == 1 &&
|
|
args[0].Equals("--manual-import-audit", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return RunManualImportAudit();
|
|
}
|
|
|
|
if (args.Length == 1 &&
|
|
args[0].Equals("--manual-import-fixture", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return await RunManualImportFixtureAsync();
|
|
}
|
|
|
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
|
|
Console.CancelKeyPress += (_, eventArgs) =>
|
|
{
|
|
eventArgs.Cancel = true;
|
|
cancellation.Cancel();
|
|
};
|
|
|
|
try
|
|
{
|
|
var selection = CreateRuntimeSelection(args);
|
|
var runtime = selection.Runtime;
|
|
var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token);
|
|
|
|
foreach (var status in statuses)
|
|
{
|
|
Console.WriteLine(FormatHealthStatus(status));
|
|
}
|
|
|
|
if (statuses.Any(status => status.State != DatabaseHealthState.Healthy))
|
|
{
|
|
Console.Error.WriteLine(
|
|
"Smoke test stopped because both database health probes must be healthy.");
|
|
return 2;
|
|
}
|
|
|
|
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);
|
|
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
|
|
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
|
|
await VerifyOperatorCatalogSchemaAsync(
|
|
runtime.Executor,
|
|
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(),
|
|
cancellation.Token);
|
|
await NxtThemeRestoreDbAudit.RunAsync(
|
|
runtime.Executor,
|
|
runtime.Options.MariaDb,
|
|
selection.ReferenceMariaDb,
|
|
cancellation.Token);
|
|
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
|
|
|
|
var service = new LegacyMarketDataService(runtime.Executor);
|
|
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
|
|
await VerifySnapshotAsync(service, MarketDataView.Kosdaq, cancellation.Token);
|
|
await VerifySnapshotAsync(service, MarketDataView.Index, cancellation.Token);
|
|
await VerifySnapshotAsync(service, MarketDataView.Overseas, cancellation.Token);
|
|
await RealSceneDbSmoke.RunAsync(runtime.Executor, cancellation.Token);
|
|
|
|
Console.WriteLine("REAL_DB_SMOKE: PASS");
|
|
return 0;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Console.Error.WriteLine("REAL_DB_SMOKE: CANCELED_OR_TIMED_OUT");
|
|
return 3;
|
|
}
|
|
catch (DatabaseOperationException exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"REAL_DB_SMOKE: FAIL - " +
|
|
$"Source={exception.DataSource} " +
|
|
$"IsTransient={FormatNullableBoolean(exception.IsTransient)} " +
|
|
$"ProviderErrorCode={FormatProviderErrorCode(exception.ProviderErrorCode)}");
|
|
return 1;
|
|
}
|
|
catch (DatabaseInfrastructureException)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"REAL_DB_SMOKE: FAIL - A database infrastructure error occurred. " +
|
|
"No provider details were printed.");
|
|
return 1;
|
|
}
|
|
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(
|
|
$"REAL_DB_SMOKE: FAIL - {exception.GetType().Name}. No provider details were printed.");
|
|
return 1;
|
|
}
|
|
|
|
static string FormatHealthStatus(DatabaseHealthStatus status)
|
|
{
|
|
var summary =
|
|
$"{status.Source}: {status.State} " +
|
|
$"({status.Elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture)} ms)";
|
|
|
|
if (status.State == DatabaseHealthState.Healthy)
|
|
{
|
|
return summary;
|
|
}
|
|
|
|
return summary +
|
|
$" IsTransient={FormatNullableBoolean(status.IsTransient)}" +
|
|
$" ProviderErrorCode={FormatProviderErrorCode(status.ProviderErrorCode)}";
|
|
}
|
|
|
|
static string FormatNullableBoolean(bool? value) => value switch
|
|
{
|
|
true => "true",
|
|
false => "false",
|
|
null => "none"
|
|
};
|
|
|
|
static string FormatProviderErrorCode(int? value) =>
|
|
value?.ToString(CultureInfo.InvariantCulture) ?? "none";
|
|
|
|
static DatabaseSmokeRuntimeSelection CreateRuntimeSelection(string[] arguments)
|
|
{
|
|
if (arguments.Length == 0)
|
|
{
|
|
return CreateJsonRuntimeSelection(configurationPath: null);
|
|
}
|
|
|
|
if (arguments.Length == 2 &&
|
|
arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return CreateJsonRuntimeSelection(arguments[1]);
|
|
}
|
|
|
|
if (arguments.Length == 2 &&
|
|
arguments[0].Equals("--legacy-runtime-root", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var root = Path.GetFullPath(arguments[1]);
|
|
var iniPath = Path.Combine(root, "Res", "MmoneyCoder.ini");
|
|
if (!File.Exists(iniPath))
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
"The legacy runtime root does not contain Res\\MmoneyCoder.ini.");
|
|
}
|
|
|
|
// Keep the selected file's endpoint as the comparison reference before
|
|
// environment overrides are applied. Credentials never leave the
|
|
// loader-owned DatabaseOptions instance.
|
|
var selectedOptions = new LegacyIniDatabaseOptionsLoader(_ => null).Load(iniPath);
|
|
var runtime = DatabaseRuntime.CreateLegacyExecutableDefault(
|
|
root,
|
|
fallbackConfigurationPath: Path.Combine(root, "missing-database.json"),
|
|
legacyOverridePath: Path.Combine(root, "missing-legacy.ini"));
|
|
return new DatabaseSmokeRuntimeSelection(
|
|
runtime,
|
|
MariaDbEndpointReference.From(selectedOptions.MariaDb));
|
|
}
|
|
|
|
throw new DatabaseConfigurationException(
|
|
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--legacy-runtime-root <bin-debug-path>|--manual-import-audit|--manual-import-fixture]");
|
|
}
|
|
|
|
static DatabaseSmokeRuntimeSelection CreateJsonRuntimeSelection(string? configurationPath)
|
|
{
|
|
var configuredOptions = new DatabaseOptionsLoader(_ => null).Load(configurationPath);
|
|
var runtime = DatabaseRuntime.CreateDefault(configurationPath);
|
|
var configuredMariaDb = configuredOptions.MariaDb;
|
|
var reference = HasCompleteEndpointIdentity(configuredMariaDb)
|
|
? MariaDbEndpointReference.From(configuredMariaDb)
|
|
: MariaDbEndpointReference.From(runtime.Options.MariaDb);
|
|
return new DatabaseSmokeRuntimeSelection(runtime, reference);
|
|
}
|
|
|
|
static bool HasCompleteEndpointIdentity(MariaDbDatabaseOptions options) =>
|
|
!string.IsNullOrWhiteSpace(options.Host) &&
|
|
options.Port is >= 1 and <= 65_535 &&
|
|
!string.IsNullOrWhiteSpace(options.Database);
|
|
|
|
static async Task VerifyLegacyComparisonImportAsync(
|
|
IDataQueryExecutor executor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const string expectedSourceSha256 =
|
|
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
|
|
var result = await new LegacyComparisonPairImportService(
|
|
executor,
|
|
TrustedLegacyComparisonFileSource.CreateDefault())
|
|
.ImportAsync(cancellationToken);
|
|
if (!string.Equals(
|
|
result.SourceSha256,
|
|
expectedSourceSha256,
|
|
StringComparison.Ordinal) ||
|
|
result.SourceRowCount != 8 ||
|
|
result.Pairs.Count != 8 ||
|
|
result.Pairs.Where((pair, index) => pair.RowNumber != index + 1).Any())
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
"The fixed legacy comparison import did not reproduce its audited 8-row identity set.");
|
|
}
|
|
|
|
Console.WriteLine(
|
|
$"LegacyComparisonImport: rows={result.SourceRowCount.ToString(CultureInfo.InvariantCulture)}, " +
|
|
$"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 =
|
|
"fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114";
|
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
|
|
var profile = Environment.GetFolderPath(
|
|
Environment.SpecialFolder.UserProfile,
|
|
Environment.SpecialFolderOption.DoNotVerify);
|
|
var localAppData = Environment.GetFolderPath(
|
|
Environment.SpecialFolder.LocalApplicationData,
|
|
Environment.SpecialFolderOption.DoNotVerify);
|
|
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
|
{
|
|
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: FAIL - required profile roots are unavailable.");
|
|
return 1;
|
|
}
|
|
|
|
var sourceDirectory = Path.Combine(
|
|
profile,
|
|
"source",
|
|
"repos",
|
|
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
|
var operatingDirectory = Path.Combine(
|
|
localAppData,
|
|
"MBN_STOCK_WEBVIEW",
|
|
"OperatorData");
|
|
var temporaryDirectory = Path.GetFullPath(Path.Combine(
|
|
Path.GetTempPath(),
|
|
"MBN_STOCK_WEBVIEW-manual-import-fixture-" + Guid.NewGuid().ToString("N")));
|
|
var expectedDataFiles = new[]
|
|
{
|
|
"개인.dat",
|
|
"외국인.dat",
|
|
"기관.dat",
|
|
"VI발동.dat"
|
|
};
|
|
var trackedOperatingFiles = expectedDataFiles.Concat(new[]
|
|
{
|
|
LegacyManualOperatorDataImporter.MarkerFileName,
|
|
LegacyManualOperatorDataImporter.IntentFileName
|
|
}).ToArray();
|
|
|
|
var sourceBefore = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
|
var operatingBefore = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
|
try
|
|
{
|
|
var destination = TrustedManualDirectory.PreparePrivateWritableDirectory(
|
|
temporaryDirectory);
|
|
LegacyManualOperatorImportResult result;
|
|
LegacyManualOperatorImportException? secondFailure = null;
|
|
using (var importer = new LegacyManualOperatorDataImporter(
|
|
destination,
|
|
new FixedLegacyManualOperatorDataSource()))
|
|
{
|
|
result = await importer.ImportAsync(cancellation.Token);
|
|
try
|
|
{
|
|
_ = await importer.ImportAsync(cancellation.Token);
|
|
}
|
|
catch (LegacyManualOperatorImportException exception)
|
|
{
|
|
secondFailure = exception;
|
|
}
|
|
}
|
|
|
|
var sourceAfter = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
|
var operatingAfter = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
|
var fixtureFiles = SnapshotFiles(destination, trackedOperatingFiles);
|
|
var dataHashesMatch = expectedDataFiles.All(name =>
|
|
sourceBefore.TryGetValue(name, out var sourceValue) &&
|
|
fixtureFiles.TryGetValue(name, out var fixtureValue) &&
|
|
string.Equals(sourceValue, fixtureValue, StringComparison.Ordinal));
|
|
var markerPresent = fixtureFiles.TryGetValue(
|
|
LegacyManualOperatorDataImporter.MarkerFileName,
|
|
out var markerValue) && markerValue != "missing";
|
|
var intentAbsent = fixtureFiles.TryGetValue(
|
|
LegacyManualOperatorDataImporter.IntentFileName,
|
|
out var intentValue) && intentValue == "missing";
|
|
var runtimeState = LegacyManualOperatorDataRuntimeGuard.Inspect(destination).State;
|
|
|
|
if (!SnapshotsEqual(sourceBefore, sourceAfter) ||
|
|
!SnapshotsEqual(operatingBefore, operatingAfter) ||
|
|
!dataHashesMatch || !markerPresent || !intentAbsent ||
|
|
result.MarkerVersion != LegacyManualOperatorDataImporter.MarkerVersion ||
|
|
result.NetSellRowCount != 15 ||
|
|
result.ViItemCount != 9 ||
|
|
!string.Equals(
|
|
result.SourceSha256,
|
|
expectedSourceSha256,
|
|
StringComparison.Ordinal) ||
|
|
runtimeState != LegacyManualOperatorDataRuntimeState.ReadyImportedData ||
|
|
secondFailure is null ||
|
|
secondFailure.Failure != LegacyManualOperatorImportFailure.AlreadyImported ||
|
|
secondFailure.OutcomeUnknown)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"MANUAL_IMPORT_FIXTURE: FAIL - an isolated import invariant was not reproduced.");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine(
|
|
$"MANUAL_IMPORT_FIXTURE: PASS rows={result.NetSellRowCount.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"vi={result.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"sourceSha256={result.SourceSha256} state={runtimeState} " +
|
|
"secondAttempt=AlreadyImported outcomeUnknown=false " +
|
|
"sourceUnchanged=true operatingImportSetUnchanged=true");
|
|
return 0;
|
|
}
|
|
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
|
{
|
|
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: CANCELED_OR_TIMED_OUT");
|
|
return 3;
|
|
}
|
|
catch (LegacyManualOperatorImportException exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"MANUAL_IMPORT_FIXTURE: FAIL - failure={exception.Failure} " +
|
|
$"outcomeUnknown={exception.OutcomeUnknown.ToString().ToLowerInvariant()}");
|
|
return exception.OutcomeUnknown ? 5 : 1;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"MANUAL_IMPORT_FIXTURE: FAIL - {exception.GetType().Name}");
|
|
return 1;
|
|
}
|
|
finally
|
|
{
|
|
RemoveOwnedFixtureDirectory(temporaryDirectory);
|
|
}
|
|
}
|
|
|
|
static int RunManualImportAudit()
|
|
{
|
|
try
|
|
{
|
|
var profile = Environment.GetFolderPath(
|
|
Environment.SpecialFolder.UserProfile,
|
|
Environment.SpecialFolderOption.DoNotVerify);
|
|
var localAppData = Environment.GetFolderPath(
|
|
Environment.SpecialFolder.LocalApplicationData,
|
|
Environment.SpecialFolderOption.DoNotVerify);
|
|
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
|
{
|
|
Console.Error.WriteLine("MANUAL_IMPORT_AUDIT: FAIL - required profile roots are unavailable.");
|
|
return 1;
|
|
}
|
|
|
|
var sourceDirectory = Path.Combine(
|
|
profile,
|
|
"source",
|
|
"repos",
|
|
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
|
var operatingDirectory = Path.Combine(
|
|
localAppData,
|
|
"MBN_STOCK_WEBVIEW",
|
|
"OperatorData");
|
|
var expectedDataFiles = new[]
|
|
{
|
|
"개인.dat",
|
|
"외국인.dat",
|
|
"기관.dat",
|
|
"VI발동.dat"
|
|
};
|
|
var source = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
|
var operating = SnapshotFiles(
|
|
operatingDirectory,
|
|
expectedDataFiles.Concat(new[]
|
|
{
|
|
LegacyManualOperatorDataImporter.MarkerFileName,
|
|
LegacyManualOperatorDataImporter.IntentFileName
|
|
}));
|
|
var markerPresent = operating[LegacyManualOperatorDataImporter.MarkerFileName] != "missing";
|
|
var intentPresent = operating[LegacyManualOperatorDataImporter.IntentFileName] != "missing";
|
|
var dataPresent = expectedDataFiles.Count(name => operating[name] != "missing");
|
|
var sourcePresent = expectedDataFiles.Count(name => source[name] != "missing");
|
|
var byteIdentical = expectedDataFiles.All(name =>
|
|
source[name] != "missing" &&
|
|
string.Equals(source[name], operating[name], StringComparison.Ordinal));
|
|
var stagedPresent = Directory.Exists(operatingDirectory) &&
|
|
Directory.EnumerateFileSystemEntries(
|
|
operatingDirectory,
|
|
".legacy-manual-import-v1-*",
|
|
SearchOption.TopDirectoryOnly).Any();
|
|
var runtimeState = LegacyManualOperatorDataRuntimeGuard
|
|
.Inspect(operatingDirectory)
|
|
.State;
|
|
|
|
var (decision, outcomeUnknown) = runtimeState switch
|
|
{
|
|
LegacyManualOperatorDataRuntimeState.InterruptedImport =>
|
|
("InterruptedImport", true),
|
|
LegacyManualOperatorDataRuntimeState.InvalidImportedState =>
|
|
("InvalidImportedState", false),
|
|
LegacyManualOperatorDataRuntimeState.ReadyImportedData =>
|
|
("AlreadyImported", false),
|
|
_ when dataPresent > 0 || stagedPresent =>
|
|
("DestinationNotEmpty", false),
|
|
_ when sourcePresent == expectedDataFiles.Length =>
|
|
("Eligible", false),
|
|
_ => ("SourceUnavailable", false)
|
|
};
|
|
Console.WriteLine(
|
|
$"MANUAL_IMPORT_AUDIT: PASS sourceFiles={sourcePresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
|
$"operatingFiles={dataPresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
|
$"sourceByteIdentical={byteIdentical.ToString().ToLowerInvariant()} " +
|
|
$"marker={markerPresent.ToString().ToLowerInvariant()} " +
|
|
$"intent={intentPresent.ToString().ToLowerInvariant()} " +
|
|
$"staged={stagedPresent.ToString().ToLowerInvariant()} " +
|
|
$"runtime={runtimeState} " +
|
|
$"decision={decision} outcomeUnknown={outcomeUnknown.ToString().ToLowerInvariant()} " +
|
|
"writesAttempted=false");
|
|
return 0;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"MANUAL_IMPORT_AUDIT: FAIL - {exception.GetType().Name}");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
static IReadOnlyDictionary<string, string> SnapshotFiles(
|
|
string directory,
|
|
IEnumerable<string> fileNames)
|
|
{
|
|
const long maximumAuditFileBytes = 32 * 1024;
|
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var fileName in fileNames)
|
|
{
|
|
var path = Path.Combine(directory, fileName);
|
|
if (!File.Exists(path))
|
|
{
|
|
result.Add(fileName, "missing");
|
|
continue;
|
|
}
|
|
|
|
using var stream = new FileStream(
|
|
path,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.ReadWrite | FileShare.Delete);
|
|
if (stream.Length is < 0 or > maximumAuditFileBytes)
|
|
{
|
|
throw new InvalidDataException(
|
|
"A manual-import audit file exceeded its closed size boundary.");
|
|
}
|
|
|
|
var bytes = new byte[checked((int)stream.Length)];
|
|
stream.ReadExactly(bytes);
|
|
if (stream.ReadByte() != -1)
|
|
{
|
|
throw new InvalidDataException(
|
|
"A manual-import audit file changed beyond its closed size boundary.");
|
|
}
|
|
|
|
var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
|
result.Add(
|
|
fileName,
|
|
bytes.Length.ToString(CultureInfo.InvariantCulture) + ":" + hash);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static bool SnapshotsEqual(
|
|
IReadOnlyDictionary<string, string> first,
|
|
IReadOnlyDictionary<string, string> second) =>
|
|
first.Count == second.Count && first.All(pair =>
|
|
second.TryGetValue(pair.Key, out var value) &&
|
|
string.Equals(pair.Value, value, StringComparison.Ordinal));
|
|
|
|
static void RemoveOwnedFixtureDirectory(string directory)
|
|
{
|
|
if (!Directory.Exists(directory))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var temporaryRoot = Path.TrimEndingDirectorySeparator(
|
|
Path.GetFullPath(Path.GetTempPath()));
|
|
var resolved = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
|
var prefix = temporaryRoot + Path.DirectorySeparatorChar;
|
|
const string ownedNamePrefix = "MBN_STOCK_WEBVIEW-manual-import-fixture-";
|
|
var ownedName = Path.GetFileName(resolved);
|
|
var ownedSuffix = ownedName.StartsWith(ownedNamePrefix, StringComparison.Ordinal)
|
|
? ownedName[ownedNamePrefix.Length..]
|
|
: string.Empty;
|
|
if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
|
|
ownedSuffix.Length != 32 ||
|
|
ownedSuffix.Any(static value => !char.IsAsciiHexDigit(value)) ||
|
|
(File.GetAttributes(resolved) & FileAttributes.ReparsePoint) != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Refusing to remove a directory outside the owned fixture boundary.");
|
|
}
|
|
|
|
var pendingDirectories = new Stack<string>();
|
|
pendingDirectories.Push(resolved);
|
|
while (pendingDirectories.TryPop(out var current))
|
|
{
|
|
foreach (var entry in Directory.EnumerateFileSystemEntries(
|
|
current,
|
|
"*",
|
|
new EnumerationOptions
|
|
{
|
|
RecurseSubdirectories = false,
|
|
ReturnSpecialDirectories = false,
|
|
AttributesToSkip = 0
|
|
}))
|
|
{
|
|
var attributes = File.GetAttributes(entry);
|
|
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Refusing to recursively remove a fixture containing a reparse point.");
|
|
}
|
|
|
|
if ((attributes & FileAttributes.Directory) != 0)
|
|
{
|
|
pendingDirectories.Push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
Directory.Delete(resolved, recursive: true);
|
|
}
|
|
|
|
static async Task VerifyOracleServerVersionAsync(
|
|
IDataQueryExecutor executor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const string query =
|
|
"SELECT VERSION FROM PRODUCT_COMPONENT_VERSION " +
|
|
"WHERE PRODUCT LIKE 'Oracle Database%' FETCH FIRST 1 ROWS ONLY";
|
|
|
|
var table = await executor.ExecuteAsync(
|
|
DataSourceKind.Oracle,
|
|
"OracleVersion",
|
|
query,
|
|
cancellationToken);
|
|
|
|
if (table.Rows.Count == 0 || table.Columns.Count == 0)
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
"The Oracle server version query returned no version.");
|
|
}
|
|
|
|
var version = Convert.ToString(
|
|
table.Rows[0][0],
|
|
CultureInfo.InvariantCulture) ?? string.Empty;
|
|
var match = Regex.Match(
|
|
version,
|
|
@"(?<!\d)(?<major>\d{2})(?:\.\d+)",
|
|
RegexOptions.CultureInvariant);
|
|
if (!match.Success ||
|
|
!int.TryParse(
|
|
match.Groups["major"].Value,
|
|
NumberStyles.None,
|
|
CultureInfo.InvariantCulture,
|
|
out var major))
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
"The Oracle server major version could not be determined.");
|
|
}
|
|
|
|
Console.WriteLine($"Oracle server major version: {major}");
|
|
if (major < 19)
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
"Oracle.ManagedDataAccess.Core requires Oracle Database 19c or newer.");
|
|
}
|
|
}
|
|
|
|
static async Task VerifySnapshotAsync(
|
|
IMarketDataService service,
|
|
MarketDataView view,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var snapshot = await service.GetAsync(
|
|
view,
|
|
maximumRowsPerTable: 5,
|
|
cancellationToken);
|
|
if (snapshot.Tables.Count == 0)
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
$"{view} returned no database result set.");
|
|
}
|
|
|
|
foreach (var table in snapshot.Tables)
|
|
{
|
|
if (table.Columns.Count == 0)
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
$"{view}/{table.Name} returned no columns.");
|
|
}
|
|
|
|
Console.WriteLine(
|
|
$"{view}/{table.Source}/{table.Name}: " +
|
|
$"{table.TotalRowCount.ToString(CultureInfo.InvariantCulture)} rows");
|
|
}
|
|
}
|
|
|
|
static async Task VerifyOperatorCatalogSchemaAsync(
|
|
IDataQueryExecutor executor,
|
|
IDatabaseConnectionFactory connectionFactory,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await new LegacyOperatorCatalogSchemaValidationService(executor)
|
|
.ValidateAsync(cancellationToken);
|
|
var themePersistence = new LegacyThemeCatalogPersistenceService(executor);
|
|
_ = await themePersistence.SuggestNextCodeAsync(
|
|
ThemeMarket.Krx,
|
|
cancellationToken);
|
|
_ = await themePersistence.SuggestNextCodeAsync(
|
|
ThemeMarket.Nxt,
|
|
cancellationToken);
|
|
await ThemeCatalogStoredEditorCompatibilityAudit.RunAsync(
|
|
executor,
|
|
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); 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(
|
|
IDataQueryExecutor executor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await new LegacyStockSearchService(executor).SearchAsync(
|
|
"삼성",
|
|
maximumResults: 100,
|
|
cancellationToken);
|
|
if (result.Items.Count == 0 ||
|
|
!result.Items.Any(item =>
|
|
item.Name.Contains("삼성", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
"The production stock search returned no matching rows for the smoke selector.");
|
|
}
|
|
|
|
var counts = result.Items
|
|
.GroupBy(item => item.Market)
|
|
.OrderBy(group => group.Key)
|
|
.Select(group => $"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
|
Console.WriteLine(
|
|
$"StockSearch: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows " +
|
|
$"({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)
|
|
{
|
|
Console.WriteLine("ThemeSelector: BEGIN");
|
|
var service = new LegacyThemeSelectionService(executor);
|
|
var themes = await service.SearchAsync(
|
|
string.Empty,
|
|
ThemeSession.PreMarket,
|
|
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)} " +
|
|
$"({string.Join(", ", marketCounts)}), " +
|
|
$"preview={preview.Items.Count.ToString(CultureInfo.InvariantCulture)}, " +
|
|
$"truncated={themes.IsTruncated}");
|
|
}
|
|
|
|
static async Task VerifyOverseasSelectorsAsync(
|
|
IDataQueryExecutor executor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Console.WriteLine("OverseasIndustrySelector: BEGIN");
|
|
var industries = await new LegacyOverseasIndustryIndexSearchService(executor)
|
|
.SearchAsync(string.Empty, maximumResults: 100, cancellationToken);
|
|
Console.WriteLine("WorldStockSelector: BEGIN");
|
|
var stocks = await new LegacyWorldStockSearchService(executor)
|
|
.SearchAsync(string.Empty, maximumResults: 100, cancellationToken);
|
|
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 ||
|
|
uc5Snapshot.Stock.IsolatedInvalidRowCount !=
|
|
overseasStocks.IsolatedInvalidRowCount)
|
|
{
|
|
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.");
|
|
}
|
|
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)}, " +
|
|
$"isolatedBlankNames={overseasStocks.IsolatedInvalidRowCount.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",
|
|
LegacyOverseasStockSearchService.MaximumResults +
|
|
LegacyOverseasStockSearchService.MaximumIsolatedRows + 1,
|
|
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(
|
|
IDataQueryExecutor executor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Console.WriteLine("ExpertSelector: BEGIN");
|
|
var service = new LegacyExpertSelectionService(executor);
|
|
var experts = await service.SearchAsync(
|
|
string.Empty,
|
|
maximumResults: 100,
|
|
cancellationToken);
|
|
if (experts.Items.Count == 0)
|
|
{
|
|
throw new DatabaseConfigurationException(
|
|
"The production expert selector returned no rows.");
|
|
}
|
|
|
|
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)}, " +
|
|
$"recommendations={recommendationCount.ToString(CultureInfo.InvariantCulture)}, " +
|
|
$"blankBuyAmounts={missingBuyAmountCount.ToString(CultureInfo.InvariantCulture)}");
|
|
}
|
|
|
|
static async Task VerifyTradingHaltSelectorAsync(
|
|
IDataQueryExecutor executor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Console.WriteLine("TradingHaltSelector: BEGIN");
|
|
var result = await new LegacyTradingHaltSelectionService(executor).SearchAsync(
|
|
string.Empty,
|
|
maximumResults: 500,
|
|
cancellationToken);
|
|
Console.WriteLine(
|
|
$"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);
|