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 |--legacy-runtime-root |--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()) { 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 FindAbsentManualFinancialFixtureAsync( LegacyManualFinancialScreenService service, IEnumerable stockNames, CancellationToken cancellationToken) { foreach (var stockName in stockNames) { var absent = true; foreach (var screen in Enum.GetValues()) { 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 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 SnapshotFiles( string directory, IEnumerable fileNames) { const long maximumAuditFileBytes = 32 * 1024; var result = new Dictionary(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 first, IReadOnlyDictionary 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(); 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{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> ReadMariaDbThemeColumnTypesAsync( IDatabaseConnectionFactory connectionFactory, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(connectionFactory); await using var connection = connectionFactory.Create(DataSourceKind.MariaDb); await connection.OpenAsync(cancellationToken); var values = new List(3); foreach (var (table, columns) in new[] { ("SB_LIST", new HashSet(["SB_CODE"], StringComparer.OrdinalIgnoreCase)), ("SB_ITEM", new HashSet(["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(); 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);