From ffb8f43c191e08ca9c8c843fdfacc581a97a7e2f Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 12 Jul 2026 02:05:49 +0900 Subject: [PATCH] Complete legacy operator UI and playout migration --- LegacySceneRuntimeFactory.cs | 98 +- MBN_STOCK_WEBVIEW.csproj | 5 +- MainWindow.Background.cs | 280 + MainWindow.ManualFinancial.cs | 2046 +++++++ MainWindow.ManualLists.cs | 786 +++ MainWindow.NamedPlaylists.cs | 921 +++ MainWindow.OperatorCatalogs.cs | 1712 ++++++ MainWindow.PlaylistPagePlans.cs | 251 + MainWindow.Playout.cs | 67 +- MainWindow.xaml.cs | 1945 +++++- Package.appxmanifest | 2 +- README.md | 17 +- Web/app.js | 5218 ++++++++++++++++- Web/candle-options-workflow.js | 146 + Web/comparison-workflow.js | 496 ++ Web/expert-workflow.js | 502 ++ Web/index.html | 359 +- Web/industry-workflow.js | 326 + Web/legacy-fixed-workflow.js | 527 ++ Web/manual-financial-ui.css | 326 + Web/manual-financial-ui.js | 1128 ++++ Web/manual-financial-workflow.js | 1033 ++++ Web/manual-lists-ui.css | 261 + Web/manual-lists-ui.js | 919 +++ Web/manual-lists-workflow.js | 390 ++ Web/named-manual-restore-workflow.js | 204 + Web/named-playlist-workflow.js | 691 +++ Web/operator-catalog-ui.css | 56 + Web/operator-catalog-ui.js | 1018 ++++ Web/operator-catalog-workflow.js | 761 +++ Web/operator-workflow.js | 440 ++ Web/overseas-workflow.js | 521 ++ Web/playout-safety.js | 9 + Web/styles.css | 367 +- Web/theme-workflow.js | 325 + Web/trading-halt-workflow.js | 361 ++ docs/MIGRATION.md | 17 +- docs/OPERATOR_UI_PARITY.md | 280 + docs/OPERATOR_WORKFLOW.md | 72 + docs/PLAYOUT_OPERATIONS.md | 8 +- docs/SCENE_EQUIVALENCE.md | 8 +- scripts/Test-WebPlayout.ps1 | 210 +- .../Data/ExpertCatalogPersistence.cs | 480 ++ .../Data/ExpertSelection.cs | 540 ++ .../Data/IndustrySelection.cs | 141 + .../Data/ManualFinancialScreens.cs | 1816 ++++++ .../Data/NamedPlaylistPersistence.cs | 1004 ++++ .../Data/OperatorCatalogMutationContracts.cs | 195 + .../Data/OperatorCatalogSchemaValidation.cs | 170 + .../Data/OverseasIndustryIndexSearch.cs | 242 + .../Data/StockSearch.cs | 281 + .../Data/ThemeCatalogPersistence.cs | 587 ++ .../Data/ThemeSelection.cs | 620 ++ .../Data/TradingHaltSelection.cs | 324 + .../Data/WorldStockSearch.cs | 228 + .../Playout/OperatorMutationGate.cs | 50 + .../Playout/PlayoutBridgeProtocol.cs | 46 + .../Playout/Scenes/CandleSceneDataLoader.cs | 77 +- ...ComparisonAndYieldLegacyRequestResolver.cs | 58 +- ...LegacyParameterizedSceneRequestResolver.cs | 43 +- .../Playout/Scenes/LegacyPlayoutWorkflow.cs | 116 +- .../Scenes/LegacySceneDataSourceRouter.cs | 41 +- .../Scenes/PagedQuoteSceneDataLoaders.cs | 59 +- .../ParameterizedMarketSceneBuilders.cs | 2 +- .../ParameterizedMarketSceneDataLoaders.cs | 4 +- .../Scenes/ParameterizedMarketSceneQueries.cs | 8 + .../RegisteredLegacySceneCueProvider.cs | 143 +- .../Scenes/TrustedViManualReference.cs | 119 + .../OperatorCatalogMutationExecutor.cs | 369 ++ .../OracleManualFinancialMutationExecutor.cs | 345 ++ .../OracleNamedPlaylistMutationExecutor.cs | 266 + .../S5025TrustedManualFileDataSource.cs | 240 +- .../Playout/TrustedManualDirectory.cs | 367 ++ .../Playout/ViTrustedManualFileStore.cs | 377 ++ .../Properties/AssemblyInfo.cs | 3 + .../CandleSceneDataLoaderTests.cs | 148 +- ...risonAndYieldLegacyRequestResolverTests.cs | 39 + ...acyExpertCatalogPersistenceServiceTests.cs | 235 + .../LegacyExpertSelectionServiceTests.cs | 476 ++ .../LegacyIndustrySelectionServiceTests.cs | 139 + ...LegacyManualFinancialScreenServiceTests.cs | 616 ++ ...amedPlaylistPersistenceIntegrationTests.cs | 271 + ...acyNamedPlaylistPersistenceServiceTests.cs | 578 ++ ...atorCatalogSchemaValidationServiceTests.cs | 101 + ...OverseasIndustryIndexSearchServiceTests.cs | 312 + ...yParameterizedSceneRequestResolverTests.cs | 125 + .../LegacyPlayoutWorkflowTests.cs | 75 +- .../LegacyStockSearchServiceTests.cs | 259 + ...gacyThemeCatalogPersistenceServiceTests.cs | 293 + .../LegacyThemeSelectionServiceTests.cs | 566 ++ .../LegacyTradingHaltSelectionServiceTests.cs | 352 ++ .../LegacyWorldStockSearchServiceTests.cs | 313 + .../OperatorCatalogTestDoubles.cs | 87 + .../OperatorMutationGateTests.cs | 61 + .../PagedQuoteSceneDataLoadersTests.cs | 31 +- ...arameterizedMarketSceneDataLoadersTests.cs | 91 + .../PlayoutBridgeProtocolTests.cs | 51 + .../RegisteredLegacySceneCueProviderTests.cs | 167 + .../TrustedViManualReferenceTests.cs | 117 + .../OperatorCatalogMutationExecutorTests.cs | 494 ++ ...cleManualFinancialMutationExecutorTests.cs | 501 ++ ...racleNamedPlaylistMutationExecutorTests.cs | 814 +++ .../S5025TrustedManualFileDataSourceTests.cs | 17 +- .../S5025TrustedManualFileStoreTests.cs | 184 + .../TrustedManualDirectoryTests.cs | 127 + .../ViTrustedManualFileStoreTests.cs | 193 + tests/Web/candle-options-workflow.test.cjs | 119 + tests/Web/comparison-workflow.test.cjs | 266 + tests/Web/expert-workflow.test.cjs | 310 + tests/Web/industry-workflow.test.cjs | 142 + tests/Web/legacy-fixed-workflow.test.cjs | 210 + ...nual-financial-bridge-integration.test.cjs | 123 + tests/Web/manual-financial-ui.test.cjs | 372 ++ tests/Web/manual-financial-workflow.test.cjs | 740 +++ .../manual-lists-bridge-integration.test.cjs | 56 + tests/Web/manual-lists-ui.test.cjs | 353 ++ tests/Web/manual-lists-workflow.test.cjs | 297 + .../named-manual-restore-workflow.test.cjs | 149 + ...named-playlist-bridge-integration.test.cjs | 93 + tests/Web/named-playlist-workflow.test.cjs | 430 ++ ...erator-catalog-bridge-integration.test.cjs | 53 + tests/Web/operator-catalog-ui.test.cjs | 348 ++ tests/Web/operator-catalog-workflow.test.cjs | 446 ++ tests/Web/operator-command-matrix.test.cjs | 156 + .../Web/operator-ui-app-integration.test.cjs | 371 ++ tests/Web/operator-workflow.test.cjs | 326 + tests/Web/overseas-workflow.test.cjs | 377 ++ tests/Web/playout-safety.test.cjs | 26 + tests/Web/theme-workflow.test.cjs | 242 + tests/Web/trading-halt-workflow.test.cjs | 272 + tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs | 121 +- 131 files changed, 48473 insertions(+), 228 deletions(-) create mode 100644 MainWindow.Background.cs create mode 100644 MainWindow.ManualFinancial.cs create mode 100644 MainWindow.ManualLists.cs create mode 100644 MainWindow.NamedPlaylists.cs create mode 100644 MainWindow.OperatorCatalogs.cs create mode 100644 MainWindow.PlaylistPagePlans.cs create mode 100644 Web/candle-options-workflow.js create mode 100644 Web/comparison-workflow.js create mode 100644 Web/expert-workflow.js create mode 100644 Web/industry-workflow.js create mode 100644 Web/legacy-fixed-workflow.js create mode 100644 Web/manual-financial-ui.css create mode 100644 Web/manual-financial-ui.js create mode 100644 Web/manual-financial-workflow.js create mode 100644 Web/manual-lists-ui.css create mode 100644 Web/manual-lists-ui.js create mode 100644 Web/manual-lists-workflow.js create mode 100644 Web/named-manual-restore-workflow.js create mode 100644 Web/named-playlist-workflow.js create mode 100644 Web/operator-catalog-ui.css create mode 100644 Web/operator-catalog-ui.js create mode 100644 Web/operator-catalog-workflow.js create mode 100644 Web/operator-workflow.js create mode 100644 Web/overseas-workflow.js create mode 100644 Web/theme-workflow.js create mode 100644 Web/trading-halt-workflow.js create mode 100644 docs/OPERATOR_UI_PARITY.md create mode 100644 docs/OPERATOR_WORKFLOW.md create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogMutationContracts.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedViManualReference.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/TrustedManualDirectory.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Infrastructure/Properties/AssemblyInfo.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertCatalogPersistenceServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyIndustrySelectionServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceIntegrationTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOperatorCatalogSchemaValidationServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeCatalogPersistenceServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorCatalogTestDoubles.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorMutationGateTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedViManualReferenceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileStoreTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/TrustedManualDirectoryTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ViTrustedManualFileStoreTests.cs create mode 100644 tests/Web/candle-options-workflow.test.cjs create mode 100644 tests/Web/comparison-workflow.test.cjs create mode 100644 tests/Web/expert-workflow.test.cjs create mode 100644 tests/Web/industry-workflow.test.cjs create mode 100644 tests/Web/legacy-fixed-workflow.test.cjs create mode 100644 tests/Web/manual-financial-bridge-integration.test.cjs create mode 100644 tests/Web/manual-financial-ui.test.cjs create mode 100644 tests/Web/manual-financial-workflow.test.cjs create mode 100644 tests/Web/manual-lists-bridge-integration.test.cjs create mode 100644 tests/Web/manual-lists-ui.test.cjs create mode 100644 tests/Web/manual-lists-workflow.test.cjs create mode 100644 tests/Web/named-manual-restore-workflow.test.cjs create mode 100644 tests/Web/named-playlist-bridge-integration.test.cjs create mode 100644 tests/Web/named-playlist-workflow.test.cjs create mode 100644 tests/Web/operator-catalog-bridge-integration.test.cjs create mode 100644 tests/Web/operator-catalog-ui.test.cjs create mode 100644 tests/Web/operator-catalog-workflow.test.cjs create mode 100644 tests/Web/operator-command-matrix.test.cjs create mode 100644 tests/Web/operator-ui-app-integration.test.cjs create mode 100644 tests/Web/operator-workflow.test.cjs create mode 100644 tests/Web/overseas-workflow.test.cjs create mode 100644 tests/Web/theme-workflow.test.cjs create mode 100644 tests/Web/trading-halt-workflow.test.cjs diff --git a/LegacySceneRuntimeFactory.cs b/LegacySceneRuntimeFactory.cs index c109129..c8d4bc1 100644 --- a/LegacySceneRuntimeFactory.cs +++ b/LegacySceneRuntimeFactory.cs @@ -14,7 +14,10 @@ internal static class LegacySceneRuntimeFactory public static LegacyPlayoutWorkflow Create( IPlayoutEngine engine, IDataQueryExecutor executor, - LegacySceneCueCompositionOptions? compositionOptions = null) + LegacySceneCueCompositionOptions? compositionOptions = null, + ILegacySceneCueCompositionOptionsSource? compositionOptionsSource = null, + IS5025TrustedManualDataSource? trustedManualSource = null, + ITrustedViStockNameSnapshotSource? trustedViSource = null) { ArgumentNullException.ThrowIfNull(engine); ArgumentNullException.ThrowIfNull(executor); @@ -50,12 +53,14 @@ internal static class LegacySceneRuntimeFactory var salesLoader = new S5080SceneDataLoader(executor); var participantTrendLoader = new S5083SceneDataLoader(executor); var indexTrendLoader = new S5084SceneDataLoader(executor); - var manualDataDirectory = Environment.GetEnvironmentVariable( - S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable); - IS5025TrustedManualDataSource? trustedManualSource = - string.IsNullOrWhiteSpace(manualDataDirectory) + if (trustedManualSource is null) + { + var manualDataDirectory = Environment.GetEnvironmentVariable( + S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable); + trustedManualSource = string.IsNullOrWhiteSpace(manualDataDirectory) ? null : new S5025TrustedManualFileDataSource(manualDataDirectory); + } var gridMarketRequestResolver = new LegacyGridMarketSceneRequestResolver( executor, trustedManualSource); @@ -334,6 +339,10 @@ internal static class LegacySceneRuntimeFactory RequireFirstPage(page); var selection = RequireSelection(entry); var target = ParseCandleMarket(selection.GroupCode); + var foreignIdentity = target is + CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock + ? ParseForeignCandleIdentity(selection.DataCode, target) + : default; var request = new S8010SceneDataRequest( ParseCandleAlias(entry.CutCode), target, @@ -342,7 +351,9 @@ internal static class LegacySceneRuntimeFactory ? RequireLookupValue(selection.Subject, "instrument") : null, HasClosedFlag(selection.GraphicType, "MA5"), - HasClosedFlag(selection.GraphicType, "MA20")); + HasClosedFlag(selection.GraphicType, "MA20"), + foreignIdentity.Symbol, + foreignIdentity.NationCode); var data = await candleLoader.LoadAsync( request, cancellationToken).ConfigureAwait(false); @@ -350,10 +361,30 @@ internal static class LegacySceneRuntimeFactory }), new LegacySceneDataSourceRoute(["5074"], async (entry, page, cancellationToken) => { - var request = CreatePagedQuoteRequest(RequireSelection(entry), page); + var selection = RequireSelection(entry); + var request = selection.GroupCode == "PAGED_VI" + ? await TrustedViManualReference.ResolveAsync( + selection, + page, + trustedViSource, + cancellationToken).ConfigureAwait(false) + : CreatePagedQuoteRequest(selection, page); var data = await fiveRowLoader.LoadAsync(request, cancellationToken) .ConfigureAwait(false); return new LegacySceneDataPage("s5074", data, data.Rows.Count); + }, async (entry, cancellationToken) => + { + var selection = RequireSelection(entry); + var request = selection.GroupCode == "PAGED_VI" + ? await TrustedViManualReference.ResolveAsync( + selection, + pageIndexZeroBased: 0, + trustedSource: trustedViSource, + cancellationToken: cancellationToken).ConfigureAwait(false) + : CreatePagedQuoteRequest(selection, 0); + var data = await fiveRowLoader.LoadPagePlanAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s5074", data, data.Rows.Count); }), new LegacySceneDataSourceRoute(["5077"], async (entry, page, cancellationToken) => { @@ -361,6 +392,12 @@ internal static class LegacySceneRuntimeFactory var data = await sixRowLoader.LoadAsync(request, cancellationToken) .ConfigureAwait(false); return new LegacySceneDataPage("s5077", data, data.Rows.Count); + }, async (entry, cancellationToken) => + { + var request = CreatePagedQuoteRequest(RequireSelection(entry), 0); + var data = await sixRowLoader.LoadPagePlanAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s5077", data, data.Rows.Count); }), new LegacySceneDataSourceRoute(["5088"], async (entry, page, cancellationToken) => { @@ -368,13 +405,25 @@ internal static class LegacySceneRuntimeFactory var data = await twelveRowLoader.LoadAsync(request, cancellationToken) .ConfigureAwait(false); return new LegacySceneDataPage("s5088", data, data.Rows.Count); + }, async (entry, cancellationToken) => + { + var request = CreatePagedQuoteRequest(RequireSelection(entry), 0); + var data = await twelveRowLoader.LoadPagePlanAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s5088", data, data.Rows.Count); }) ]); LegacySceneRuntimeCoverage.Validate(dataSource); - var cueProvider = new RegisteredLegacySceneCueProvider( - dataSource, - options: compositionOptions); + if (compositionOptions is not null && compositionOptionsSource is not null) + { + throw new ArgumentException( + "Specify either fixed or mutable scene composition options, not both."); + } + + var cueProvider = compositionOptionsSource is null + ? new RegisteredLegacySceneCueProvider(dataSource, options: compositionOptions) + : new RegisteredLegacySceneCueProvider(dataSource, compositionOptionsSource); return new LegacyPlayoutWorkflow(engine, cueProvider); } @@ -448,6 +497,34 @@ internal static class LegacySceneRuntimeFactory CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock; + private static (string? Symbol, string? NationCode) ParseForeignCandleIdentity( + string value, + CandleMarketTarget target) + { + var parts = value.Split('|', StringSplitOptions.None); + var expectedType = target switch + { + CandleMarketTarget.OverseasIndex => "0", + CandleMarketTarget.OverseasStock => "1", + _ => throw new LegacySceneDataException( + "The foreign candle target is invalid.") + }; + if (parts.Length != 3 || + parts[0].Length is < 1 or > 64 || + parts[0].Any(character => char.IsControl(character) || char.IsSurrogate(character)) || + parts[1].Length != 2 || + parts[1].Any(character => character is < 'A' or > 'Z') || + !string.Equals(parts[2], expectedType, StringComparison.Ordinal) || + target == CandleMarketTarget.OverseasStock && + parts[1] is not ("US" or "TW")) + { + throw new LegacySceneDataException( + "The foreign candle identity is invalid."); + } + + return (parts[0], parts[1]); + } + private static string RequireLookupValue(string value, string label) { if (string.IsNullOrWhiteSpace(value)) @@ -500,6 +577,7 @@ internal static class LegacySceneRuntimeFactory ParseNxtSession(selection.GraphicType), page), "PAGED_EXPERT" => new ExpertPagedQuoteLoadRequest( + RequireLookupValue(selection.DataCode, "expert code"), RequireLookupValue(selection.Subject, "expert"), page), "PAGED_VI" => new ViPagedQuoteLoadRequest( diff --git a/MBN_STOCK_WEBVIEW.csproj b/MBN_STOCK_WEBVIEW.csproj index 8fad7fd..7bbae0d 100644 --- a/MBN_STOCK_WEBVIEW.csproj +++ b/MBN_STOCK_WEBVIEW.csproj @@ -25,8 +25,9 @@ Properties\PublishProfiles\win-x64.pubxml false false - 1.0.0 - 1 + 1.0.2 + 3 + 1.0.2 diff --git a/MainWindow.Background.cs b/MainWindow.Background.cs new file mode 100644 index 0000000..7c3a992 --- /dev/null +++ b/MainWindow.Background.cs @@ -0,0 +1,280 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Playout.Configuration; +using Microsoft.UI.Xaml; +using Windows.Storage.Pickers; +using WinRT.Interop; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private static readonly IReadOnlySet OperatorBackgroundExtensions = + new HashSet([".vrv", ".jpg", ".jpeg", ".png"], + StringComparer.OrdinalIgnoreCase); + private int _backgroundSelectionInFlight; + + private void HandleBackgroundStatusRequest(JsonElement payload) + { + if (!TryParseBackgroundRequest(payload, out var requestId)) + { + PostBackgroundError(string.Empty, "올바르지 않은 배경 상태 요청입니다."); + return; + } + + PostBackgroundStatus(requestId); + } + + private async Task HandleChooseBackgroundAsync(JsonElement payload) + { + if (!TryParseBackgroundRequest(payload, out var requestId)) + { + PostBackgroundError(string.Empty, "올바르지 않은 배경 파일 선택 요청입니다."); + return; + } + + if (!await _playoutCommandGate.WaitAsync(0)) + { + PostBackgroundError(requestId, "송출 명령 처리 중에는 배경을 변경할 수 없습니다."); + return; + } + + string? error = null; + var message = "배경 파일 선택을 취소했습니다."; + Interlocked.Exchange(ref _backgroundSelectionInFlight, 1); + try + { + if (!CanChangeLegacyBackground(allowBackgroundOperation: true)) + { + error = "배경은 IDLE 상태에서 다음 PREPARE 전에만 변경할 수 있습니다."; + return; + } + + var picker = new FileOpenPicker + { + SuggestedStartLocation = PickerLocationId.DocumentsLibrary, + ViewMode = PickerViewMode.List + }; + picker.FileTypeFilter.Add(".vrv"); + picker.FileTypeFilter.Add(".jpg"); + picker.FileTypeFilter.Add(".jpeg"); + picker.FileTypeFilter.Add(".png"); + InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this)); + + var selected = await picker.PickSingleFileAsync(); + if (selected is null) + { + return; + } + + if (!CanChangeLegacyBackground(allowBackgroundOperation: true)) + { + error = "배경 파일 선택 중 송출 상태가 바뀌어 변경을 취소했습니다."; + return; + } + + var composition = CreateOperatorBackgroundComposition(selected.Path); + _legacyCompositionOptionsSource!.Update(composition); + _lastEnabledLegacyComposition = composition; + message = "선택한 배경을 다음 PREPARE부터 사용합니다."; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException) + { + error = "배경 파일은 설정된 컷 루트 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다."; + } + catch + { + error = "배경 파일을 선택하지 못했습니다."; + } + finally + { + Interlocked.Exchange(ref _backgroundSelectionInFlight, 0); + _playoutCommandGate.Release(); + + if (error is null) + { + PostBackgroundStatus(requestId, message); + } + else + { + PostBackgroundError(requestId, error); + } + } + } + + private async Task HandleToggleBackgroundAsync(JsonElement payload) + { + if (!TryParseBackgroundRequest(payload, out var requestId)) + { + PostBackgroundError(string.Empty, "올바르지 않은 배경 토글 요청입니다."); + return; + } + + if (!await _playoutCommandGate.WaitAsync(0)) + { + PostBackgroundError(requestId, "송출 명령 처리 중에는 배경을 변경할 수 없습니다."); + return; + } + + string? error = null; + var message = string.Empty; + Interlocked.Exchange(ref _backgroundSelectionInFlight, 1); + try + { + if (!CanChangeLegacyBackground(allowBackgroundOperation: true)) + { + error = "배경은 IDLE 상태에서 다음 PREPARE 전에만 변경할 수 있습니다."; + return; + } + + var source = _legacyCompositionOptionsSource ?? + throw new InvalidOperationException("Background composition is unavailable."); + var current = source.Current; + if (current.BackgroundKind == LegacySceneBackgroundKind.None) + { + if (_lastEnabledLegacyComposition is null) + { + error = "켜 둘 배경 파일이 없습니다. 먼저 F2로 배경 파일을 선택하세요."; + return; + } + + source.Update(_lastEnabledLegacyComposition); + message = "배경 사용을 켰습니다. 다음 PREPARE부터 반영됩니다."; + } + else + { + _lastEnabledLegacyComposition = current; + source.Update(new LegacySceneCueCompositionOptions( + current.FadeDuration, + LegacySceneBackgroundKind.None)); + message = "배경 사용을 껐습니다. 다음 PREPARE부터 반영됩니다."; + } + } + catch + { + error = "배경 사용 상태를 변경하지 못했습니다."; + } + finally + { + Interlocked.Exchange(ref _backgroundSelectionInFlight, 0); + _playoutCommandGate.Release(); + + if (error is null) + { + PostBackgroundStatus(requestId, message); + } + else + { + PostBackgroundError(requestId, error); + } + } + } + + private LegacySceneCueCompositionOptions CreateOperatorBackgroundComposition( + string selectedPath) + { + var options = _playoutOptions ?? + throw new InvalidOperationException("Playout options are unavailable."); + var source = _legacyCompositionOptionsSource ?? + throw new InvalidOperationException("Background composition is unavailable."); + var rootText = options.SceneDirectory?.Trim(); + if (string.IsNullOrWhiteSpace(rootText) || !Path.IsPathFullyQualified(rootText) || + string.IsNullOrWhiteSpace(selectedPath) || !Path.IsPathFullyQualified(selectedPath)) + { + throw new ArgumentException("A trusted scene root and background are required."); + } + + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText)); + var candidate = Path.GetFullPath(selectedPath); + var rootPrefix = root + Path.DirectorySeparatorChar; + var extension = Path.GetExtension(candidate); + if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) || + !OperatorBackgroundExtensions.Contains(extension)) + { + throw new ArgumentException("The selected background is outside the trusted scene root."); + } + + var kind = string.Equals(extension, ".vrv", StringComparison.OrdinalIgnoreCase) + ? LegacySceneBackgroundKind.Video + : LegacySceneBackgroundKind.Texture; + var validationOptions = new PlayoutOptions + { + SceneDirectory = root, + LegacySceneFadeDuration = source.Current.FadeDuration, + LegacySceneBackgroundKind = kind, + LegacySceneBackgroundAssetPath = Path.GetRelativePath(root, candidate), + LegacySceneBackgroundVideoLoopCount = options.LegacySceneBackgroundVideoLoopCount, + LegacySceneBackgroundVideoLoopInfinite = options.LegacySceneBackgroundVideoLoopInfinite + }; + return PlayoutSceneCompositionFactory.Create(validationOptions); + } + + private bool CanChangeLegacyBackground(bool allowBackgroundOperation = false) + { + if (_playoutOptions is null || _legacyCompositionOptionsSource is null || + string.IsNullOrWhiteSpace(_playoutOptions.SceneDirectory) || + IsBrowserCorrelationQuarantined() || + (!allowBackgroundOperation && + Volatile.Read(ref _backgroundSelectionInFlight) != 0) || + Volatile.Read(ref _playoutCommandInFlight) != 0 || + Volatile.Read(ref _playoutShutdownStarted) != 0) + { + return false; + } + + var status = _playoutEngine?.Status; + var workflow = _playoutWorkflow?.State; + return status is not null && workflow is not null && + !status.IsPlayCompletionPending && + string.IsNullOrWhiteSpace(status.PreparedSceneName) && + string.IsNullOrWhiteSpace(status.OnAirSceneName) && + string.IsNullOrWhiteSpace(workflow.PreparedCutCode) && + string.IsNullOrWhiteSpace(workflow.OnAirCutCode) && + (_legacyRefreshTask is null || _legacyRefreshTask.IsCompleted); + } + + private static bool TryParseBackgroundRequest(JsonElement payload, out string requestId) + { + requestId = string.Empty; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId") && + TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId); + } + + private void PostBackgroundStatus(string requestId, string? message = null) + { + var current = _legacyCompositionOptionsSource?.Current ?? + LegacySceneCueCompositionOptions.Default; + var enabled = current.BackgroundKind != LegacySceneBackgroundKind.None; + PostMessage("background-status", new + { + requestId, + enabled, + kind = current.BackgroundKind switch + { + LegacySceneBackgroundKind.Texture => "texture", + LegacySceneBackgroundKind.Video => "video", + _ => "none" + }, + fileName = enabled ? Path.GetFileName(current.BackgroundAssetPath) : string.Empty, + canChange = CanChangeLegacyBackground(), + message = message ?? (enabled + ? "선택한 배경은 다음 PREPARE부터 적용됩니다." + : "배경 사용 안 함") + }); + } + + private void PostBackgroundError(string requestId, string message) + { + PostMessage("background-error", new + { + requestId, + message + }); + } +} diff --git a/MainWindow.ManualFinancial.cs b/MainWindow.ManualFinancial.cs new file mode 100644 index 0000000..fae2119 --- /dev/null +++ b/MainWindow.ManualFinancial.cs @@ -0,0 +1,2046 @@ +using System.Globalization; +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private const string ManualFinancialCorrelationQuarantineMessage = + "A manual-financial database write result could not be confirmed. Restart the app and reconcile INPUT_* before another write."; + + private readonly SemaphoreSlim _manualFinancialWriteGate = new(1, 1); + private readonly object _manualFinancialReadLanesGate = new(); + private readonly Dictionary _manualFinancialReadLanes = + new(StringComparer.Ordinal); + private IManualFinancialScreenService? _manualFinancialScreenService; + private CancellationTokenSource? _manualFinancialWriteCancellation; + private string? _manualFinancialInitializationError; + private string? _manualFinancialWriteQuarantineMessage; + private int _manualFinancialRuntimeState; + private int _manualFinancialWriteInFlight; + private int _manualFinancialWriteQuarantined; + private int _manualFinancialBrowserGeneration; + + /// + /// Root integration hook. Call after InitializeDatabaseRuntime. Every request + /// handler also calls it lazily, so an omitted eager call fails closed. + /// + private void InitializeManualFinancialRuntime() + { + if (Volatile.Read(ref _manualFinancialRuntimeState) != 0) + { + return; + } + + var runtime = _databaseRuntime; + if (runtime is null || + Interlocked.CompareExchange(ref _manualFinancialRuntimeState, 1, 0) != 0) + { + return; + } + + try + { + _manualFinancialScreenService = new LegacyManualFinancialScreenService( + runtime.Executor, + new OracleManualFinancialMutationExecutor( + runtime.ConnectionFactory, + runtime.Options.Resilience)); + } + catch + { + _manualFinancialScreenService = null; + _manualFinancialInitializationError = + "The manual-financial Oracle boundary could not be initialized."; + Volatile.Write(ref _manualFinancialRuntimeState, -1); + } + } + + /// + /// Root WebMessage hook. True means the request belongs to this boundary, + /// including malformed payloads which receive an INVALID_REQUEST response. + /// + private bool TryHandleManualFinancialRequest(string? requestType, JsonElement payload) + { + switch (requestType) + { + case "request-manual-financial-list": + _ = HandleManualFinancialListRequestAsync(payload); + return true; + case "request-manual-financial-load": + _ = HandleManualFinancialLoadRequestAsync(payload); + return true; + case "create-manual-financial-record": + _ = HandleManualFinancialCreateRequestAsync(payload); + return true; + case "update-manual-financial-record": + _ = HandleManualFinancialUpdateRequestAsync(payload); + return true; + case "delete-manual-financial-record": + _ = HandleManualFinancialDeleteRequestAsync(payload); + return true; + case "delete-all-manual-financial-records": + _ = HandleManualFinancialDeleteAllRequestAsync(payload); + return true; + case "request-manual-financial-selection": + _ = HandleManualFinancialSelectionRequestAsync(payload); + return true; + default: + return false; + } + } + + private async Task HandleManualFinancialListRequestAsync(JsonElement payload) + { + if (!TryParseManualFinancialListRequest( + payload, + out var requestId, + out var screen, + out var query, + out var maximumResults)) + { + PostManualFinancialInvalidRequest( + SafeManualFinancialRequestId(payload), + "list", + SafeManualFinancialScreen(payload), + query: SafeManualFinancialQuery(payload)); + return; + } + + InitializeManualFinancialRuntime(); + var service = _manualFinancialScreenService; + if (service is null) + { + PostManualFinancialUnavailable(requestId, "list", ToWireValue(screen), query: query); + return; + } + + await ExecuteManualFinancialReadAsync( + requestId, + "list", + screen, + query, + string.Empty, + async cancellationToken => + { + var result = await service.SearchAsync( + screen, + query, + maximumResults, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new ManualFinancialBridgeReply( + "manual-financial-list-results", + new + { + requestId, + screen = ToWireValue(screen), + query = result.Query, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + items = result.Items.Select(CreateManualFinancialSnapshotPayload) + }); + }); + } + + private async Task HandleManualFinancialLoadRequestAsync(JsonElement payload) + { + if (!TryParseManualFinancialIdentityRequest( + payload, + ["requestId", "screen", "stockName"], + out var requestId, + out var identity)) + { + PostManualFinancialInvalidRequest( + SafeManualFinancialRequestId(payload), + "load", + SafeManualFinancialScreen(payload), + stockName: SafeManualFinancialStockName(payload)); + return; + } + + InitializeManualFinancialRuntime(); + var service = _manualFinancialScreenService; + if (service is null) + { + PostManualFinancialUnavailable( + requestId, + "load", + ToWireValue(identity.Screen), + stockName: identity.StockName); + return; + } + + await ExecuteManualFinancialReadAsync( + requestId, + "load", + identity.Screen, + string.Empty, + identity.StockName, + async cancellationToken => + { + var snapshot = await service.GetAsync(identity, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new ManualFinancialBridgeReply( + "manual-financial-load-results", + new + { + requestId, + screen = ToWireValue(identity.Screen), + retrievedAt = DateTimeOffset.UtcNow, + snapshot = CreateManualFinancialSnapshotPayload(snapshot) + }); + }); + } + + private async Task HandleManualFinancialSelectionRequestAsync(JsonElement payload) + { + if (!TryParseManualFinancialSelectionRequest( + payload, + out var requestId, + out var identity, + out var rowVersion, + out var stock)) + { + PostManualFinancialInvalidRequest( + SafeManualFinancialRequestId(payload), + "selection", + SafeManualFinancialScreen(payload), + stockName: SafeManualFinancialStockName(payload)); + return; + } + + InitializeManualFinancialRuntime(); + var service = _manualFinancialScreenService; + if (service is null || _stockSearchService is null) + { + PostManualFinancialUnavailable( + requestId, + "selection", + ToWireValue(identity.Screen), + stockName: identity.StockName); + return; + } + + await ExecuteManualFinancialReadAsync( + requestId, + "selection", + identity.Screen, + string.Empty, + identity.StockName, + async cancellationToken => + { + var snapshot = await service.GetAsync(identity, cancellationToken); + EnsureManualFinancialRowVersion(snapshot, rowVersion); + var verified = await ResolveManualFinancialStockAsync( + identity, + stock, + cancellationToken); + var result = ManualFinancialCutContracts.CreateSelection(snapshot, verified); + cancellationToken.ThrowIfCancellationRequested(); + return new ManualFinancialBridgeReply( + "manual-financial-selection-results", + new + { + requestId, + screen = ToWireValue(identity.Screen), + stockName = identity.StockName, + rowVersion = snapshot.RowVersion, + result.BuilderKey, + code = result.CutCode, + aliases = new[] { result.CutCode }, + selection = new + { + result.Selection.GroupCode, + result.Selection.Subject, + result.Selection.GraphicType, + result.Selection.Subtype, + result.Selection.DataCode + }, + result.CurrentPage, + result.TotalPages + }); + }); + } + + private async Task HandleManualFinancialCreateRequestAsync(JsonElement payload) + { + if (!TryParseManualFinancialCreateRequest( + payload, + out var requestId, + out var record, + out var stock)) + { + PostManualFinancialInvalidRequest( + SafeManualFinancialRequestId(payload), + "create", + SafeManualFinancialScreen(payload), + stockName: SafeManualFinancialRecordStockName(payload)); + return; + } + + InitializeManualFinancialRuntime(); + await ExecuteManualFinancialWriteAsync( + requestId, + "create", + record.Identity.Screen, + record.Identity.StockName, + async cancellationToken => + { + _ = await ResolveManualFinancialStockAsync( + record.Identity, + stock, + cancellationToken); + }, + (service, cancellationToken) => service.CreateAsync(record, cancellationToken)); + } + + private async Task HandleManualFinancialUpdateRequestAsync(JsonElement payload) + { + if (!TryParseManualFinancialUpdateRequest( + payload, + out var requestId, + out var replacement, + out var expectedRowVersion)) + { + PostManualFinancialInvalidRequest( + SafeManualFinancialRequestId(payload), + "update", + SafeManualFinancialScreen(payload), + stockName: SafeManualFinancialStockName(payload)); + return; + } + + InitializeManualFinancialRuntime(); + ManualFinancialSnapshot? expected = null; + await ExecuteManualFinancialWriteAsync( + requestId, + "update", + replacement.Identity.Screen, + replacement.Identity.StockName, + async cancellationToken => + { + var service = RequireManualFinancialService(); + expected = await service.GetAsync(replacement.Identity, cancellationToken); + EnsureManualFinancialRowVersion(expected, expectedRowVersion); + }, + (service, cancellationToken) => service.UpdateAsync( + expected ?? throw new InvalidOperationException("Update preflight did not complete."), + replacement, + cancellationToken)); + } + + private async Task HandleManualFinancialDeleteRequestAsync(JsonElement payload) + { + if (!TryParseManualFinancialDeleteRequest( + payload, + out var requestId, + out var identity, + out var expectedRowVersion)) + { + PostManualFinancialInvalidRequest( + SafeManualFinancialRequestId(payload), + "delete-one", + SafeManualFinancialScreen(payload), + stockName: SafeManualFinancialStockName(payload)); + return; + } + + InitializeManualFinancialRuntime(); + ManualFinancialSnapshot? expected = null; + await ExecuteManualFinancialWriteAsync( + requestId, + "delete-one", + identity.Screen, + identity.StockName, + async cancellationToken => + { + var service = RequireManualFinancialService(); + expected = await service.GetAsync(identity, cancellationToken); + EnsureManualFinancialRowVersion(expected, expectedRowVersion); + }, + (service, cancellationToken) => service.DeleteAsync( + expected ?? throw new InvalidOperationException("Delete preflight did not complete."), + cancellationToken)); + } + + private async Task HandleManualFinancialDeleteAllRequestAsync(JsonElement payload) + { + if (!TryParseManualFinancialDeleteAllRequest( + payload, + out var requestId, + out var screen)) + { + PostManualFinancialInvalidRequest( + SafeManualFinancialRequestId(payload), + "delete-all", + SafeManualFinancialScreen(payload)); + return; + } + + InitializeManualFinancialRuntime(); + await ExecuteManualFinancialWriteAsync( + requestId, + "delete-all", + screen, + string.Empty, + preflight: null, + (service, cancellationToken) => service.DeleteAllAsync(screen, cancellationToken)); + } + + private async Task ExecuteManualFinancialReadAsync( + string requestId, + string operation, + ManualFinancialScreenKind screen, + string query, + string stockName, + Func> operationBody) + { + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var browserGeneration = Volatile.Read(ref _manualFinancialBrowserGeneration); + var request = new ManualFinancialReadRequest( + operation, + requestId, + screen, + query, + stockName, + browserGeneration, + requestCancellation); + RegisterManualFinancialRead(request); + var databaseActivityEntered = false; + + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostManualFinancialReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The read was canceled because playout has database priority."); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostManualFinancialReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The read was canceled because playout has database priority."); + return; + } + + if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration)) + { + requestCancellation.Cancel(); + return; + } + + var reply = await operationBody(requestToken); + requestToken.ThrowIfCancellationRequested(); + if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration) || + !TryCompleteManualFinancialReadIfCurrent(request)) + { + return; + } + + PostMessage(reply.MessageType, reply.Payload); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostManualFinancialReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The read was canceled because playout has database priority."); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostManualFinancialReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The read was canceled because playout has database priority."); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostManualFinancialReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The read was canceled because playout has database priority."); + } + catch (ManualFinancialBridgeStaleException) + { + PostManualFinancialReadErrorIfCurrent( + request, + "STALE_ROW", + "The manual-financial record changed. Reload before selecting it.", + retryable: false); + } + catch (ManualFinancialNotFoundException) + { + PostManualFinancialReadErrorIfCurrent( + request, + "NOT_FOUND", + "The manual-financial record does not exist.", + retryable: false); + } + catch (ManualFinancialAmbiguousIdentityException) + { + PostManualFinancialReadErrorIfCurrent( + request, + "AMBIGUOUS_IDENTITY", + "The manual-financial STOCK_NAME is duplicated.", + retryable: false); + } + catch (ManualFinancialDataException) + { + PostManualFinancialReadErrorIfCurrent( + request, + "INVALID_DATA", + "Stored manual-financial data does not match the migrated contract.", + retryable: false); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (StockSearchDataException) + { + PostManualFinancialReadErrorIfCurrent( + request, + "INVALID_STOCK_DATA", + "The stock master result is ambiguous or invalid.", + retryable: false); + } + catch (ArgumentException) + { + PostManualFinancialReadErrorIfCurrent( + request, + "INVALID_REQUEST", + "The manual-financial request identity is invalid or ambiguous.", + retryable: false); + } + catch (DatabaseOperationException exception) + { + PostManualFinancialReadErrorIfCurrent( + request, + "DATABASE_ERROR", + exception.Message, + retryable: true); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostManualFinancialReadErrorIfCurrent( + request, + "DATABASE_ERROR", + "The manual-financial database read failed.", + retryable: true); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + ReleaseManualFinancialRead(request); + requestCancellation.Dispose(); + } + } + + private async Task ExecuteManualFinancialWriteAsync( + string requestId, + string operation, + ManualFinancialScreenKind screen, + string stockName, + Func? preflight, + Func> + operationBody) + { + var service = _manualFinancialScreenService; + if (service is null || !service.CanMutate) + { + PostManualFinancialUnavailable( + requestId, + operation, + ToWireValue(screen), + stockName: stockName); + return; + } + + if (IsManualFinancialWriteQuarantined()) + { + PostManualFinancialQuarantined(requestId, operation, screen, stockName); + return; + } + + if (!await _manualFinancialWriteGate.WaitAsync(0)) + { + PostManualFinancialMutationError( + requestId, + operation, + screen, + stockName, + "WRITE_BUSY", + "Another manual-financial write is already running.", + outcomeUnknown: false); + return; + } + + var playoutGateEntered = false; + var databaseActivityEntered = false; + var mutationDispatched = false; + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var browserGeneration = Volatile.Read(ref _manualFinancialBrowserGeneration); + Volatile.Write(ref _manualFinancialWriteCancellation, requestCancellation); + + try + { + playoutGateEntered = await _playoutCommandGate.WaitAsync(0, requestToken); + if (!playoutGateEntered || !CanStartManualFinancialWrite()) + { + PostManualFinancialPlayoutActive(requestId, operation, screen, stockName); + return; + } + + databaseActivityEntered = await _databaseActivityGate.WaitAsync(0, requestToken); + if (!databaseActivityEntered) + { + PostManualFinancialMutationError( + requestId, + operation, + screen, + stockName, + "DATABASE_BUSY", + "The database is busy. Reload before making a new explicit write request.", + outcomeUnknown: false); + return; + } + + requestToken.ThrowIfCancellationRequested(); + if (!CanStartManualFinancialWrite()) + { + PostManualFinancialPlayoutActive(requestId, operation, screen, stockName); + return; + } + + if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration)) + { + requestCancellation.Cancel(); + return; + } + + if (preflight is not null) + { + await preflight(requestToken); + requestToken.ThrowIfCancellationRequested(); + } + + if (!CanStartManualFinancialWrite() || + browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration)) + { + requestCancellation.Cancel(); + return; + } + + mutationDispatched = true; + Interlocked.Exchange(ref _manualFinancialWriteInFlight, 1); + var receipt = await operationBody(service, requestToken); + if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration) || + !ReferenceEquals( + Volatile.Read(ref _manualFinancialWriteCancellation), + requestCancellation)) + { + LatchManualFinancialWriteQuarantine( + ManualFinancialCorrelationQuarantineMessage); + return; + } + + PostMessage("manual-financial-mutation-result", new + { + requestId, + operationId = receipt.OperationId.ToString("N", CultureInfo.InvariantCulture), + operation, + screen = ToWireValue(receipt.Screen), + receipt.StockName, + receipt.CommittedAt, + receipt.AffectedRows + }); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + if (mutationDispatched) + { + LatchManualFinancialWriteQuarantine( + ManualFinancialCorrelationQuarantineMessage); + } + } + catch (ManualFinancialBridgeStaleException) + { + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "STALE_ROW", + "The record changed. Reload before submitting a new explicit write.", + outcomeUnknown: false, + requestCancellation); + } + catch (ManualFinancialNotFoundException) + { + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "NOT_FOUND", + "The manual-financial record no longer exists.", + outcomeUnknown: false, + requestCancellation); + } + catch (ManualFinancialAmbiguousIdentityException) + { + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "AMBIGUOUS_IDENTITY", + "The manual-financial STOCK_NAME is duplicated. No write was attempted.", + outcomeUnknown: false, + requestCancellation); + } + catch (ManualFinancialMutationRejectedException exception) + { + var (code, message) = exception.Reason switch + { + ManualFinancialMutationRejectionReason.DuplicateIdentity => + ("DUPLICATE_IDENTITY", "The stock already exists. Reload before editing it."), + ManualFinancialMutationRejectionReason.NotFoundOrChanged => + ("STALE_ROW", "The record changed or disappeared. Reload before editing it."), + _ => + ("AMBIGUOUS_IDENTITY", "The manual-financial identity is ambiguous.") + }; + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + code, + message, + outcomeUnknown: false, + requestCancellation); + } + catch (ManualFinancialMutationException exception) + { + if (exception.OutcomeUnknown) + { + LatchManualFinancialWriteQuarantine(exception.Message); + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "OUTCOME_UNKNOWN", + ManualFinancialCorrelationQuarantineMessage, + outcomeUnknown: true, + requestCancellation); + } + else + { + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "WRITE_FAILED", + "The Oracle transaction rolled back and was not retried.", + outcomeUnknown: false, + requestCancellation); + } + } + catch (ArgumentException) + { + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "INVALID_REQUEST", + "The manual-financial write identity or record is invalid.", + outcomeUnknown: false, + requestCancellation); + } + catch (StockSearchDataException) + { + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "INVALID_STOCK_DATA", + "The stock master result is ambiguous or invalid.", + outcomeUnknown: false, + requestCancellation); + } + catch (OperationCanceledException) + { + if (mutationDispatched) + { + LatchManualFinancialWriteQuarantine( + ManualFinancialCorrelationQuarantineMessage); + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "OUTCOME_UNKNOWN", + ManualFinancialCorrelationQuarantineMessage, + outcomeUnknown: true, + requestCancellation); + } + else + { + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + "CANCELLED", + "The write stopped before an Oracle transaction began.", + outcomeUnknown: false, + requestCancellation); + } + } + catch + { + if (mutationDispatched) + { + LatchManualFinancialWriteQuarantine( + ManualFinancialCorrelationQuarantineMessage); + } + + PostManualFinancialWriteErrorIfCurrent( + requestId, + operation, + screen, + stockName, + mutationDispatched ? "OUTCOME_UNKNOWN" : "WRITE_FAILED", + mutationDispatched + ? ManualFinancialCorrelationQuarantineMessage + : "The write failed before an Oracle transaction began.", + outcomeUnknown: mutationDispatched, + requestCancellation); + } + finally + { + Interlocked.Exchange(ref _manualFinancialWriteInFlight, 0); + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + if (playoutGateEntered) + { + _playoutCommandGate.Release(); + } + + Interlocked.CompareExchange( + ref _manualFinancialWriteCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + _manualFinancialWriteGate.Release(); + } + } + + private bool CanStartManualFinancialWrite() + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 || + Volatile.Read(ref _playoutShutdownStarted) != 0 || + IsBrowserCorrelationQuarantined() || + IsManualFinancialWriteQuarantined()) + { + return false; + } + + return CanStartOperatorMutation(IsManualFinancialWriteQuarantined()); + } + + private async Task ResolveManualFinancialStockAsync( + ManualFinancialIdentity identity, + ManualFinancialStockWire stock, + CancellationToken cancellationToken) + { + var stockService = _stockSearchService ?? throw new InvalidOperationException( + "The domestic stock-search service is unavailable."); + if (!string.Equals(identity.StockName, stock.Name, StringComparison.Ordinal)) + { + throw new ArgumentException("The supplied stock name does not match the record."); + } + + var search = await stockService.SearchAsync( + identity.StockName, + LegacyStockSearchService.MaximumResults, + cancellationToken); + var verified = ManualFinancialCutContracts.VerifyStock( + identity, + search.Items, + stock.Market, + stock.Code); + if (verified.Source != stock.Source || + !string.Equals(verified.Name, stock.Name, StringComparison.Ordinal)) + { + throw new ArgumentException("The supplied stock provider identity is invalid."); + } + + return verified; + } + + private IManualFinancialScreenService RequireManualFinancialService() => + _manualFinancialScreenService ?? throw new InvalidOperationException( + "The manual-financial service is unavailable."); + + private static void EnsureManualFinancialRowVersion( + ManualFinancialSnapshot snapshot, + string expectedRowVersion) + { + if (!string.Equals(snapshot.RowVersion, expectedRowVersion, StringComparison.Ordinal)) + { + throw new ManualFinancialBridgeStaleException(); + } + } + + private void RegisterManualFinancialRead(ManualFinancialReadRequest request) + { + ManualFinancialReadRequest? superseded; + var shouldPostSuperseded = false; + lock (_manualFinancialReadLanesGate) + { + _manualFinancialReadLanes.TryGetValue(request.Lane, out superseded); + _manualFinancialReadLanes[request.Lane] = request; + shouldPostSuperseded = superseded?.TryComplete() == true; + } + + if (superseded is null) + { + return; + } + + CancelRequest(superseded.Cancellation); + if (shouldPostSuperseded) + { + PostManualFinancialReadError( + superseded.RequestId, + superseded.Operation, + superseded.Screen, + superseded.Query, + superseded.StockName, + "CANCELLED", + "The read was canceled because a newer request replaced it.", + retryable: true); + } + } + + private bool TryCompleteManualFinancialReadIfCurrent(ManualFinancialReadRequest request) + { + lock (_manualFinancialReadLanesGate) + { + return request.BrowserGeneration == + Volatile.Read(ref _manualFinancialBrowserGeneration) && + _manualFinancialReadLanes.TryGetValue(request.Lane, out var current) && + ReferenceEquals(current, request) && + request.TryComplete(); + } + } + + private void ReleaseManualFinancialRead(ManualFinancialReadRequest request) + { + lock (_manualFinancialReadLanesGate) + { + if (_manualFinancialReadLanes.TryGetValue(request.Lane, out var current) && + ReferenceEquals(current, request)) + { + _manualFinancialReadLanes.Remove(request.Lane); + } + } + } + + private ManualFinancialReadRequest[] SnapshotManualFinancialReads(bool clear) + { + lock (_manualFinancialReadLanesGate) + { + var requests = _manualFinancialReadLanes.Values.ToArray(); + if (clear) + { + _manualFinancialReadLanes.Clear(); + foreach (var request in requests) + { + _ = request.TryComplete(); + } + } + + return requests; + } + } + + private void PostManualFinancialReadCancellationIfCurrent( + ManualFinancialReadRequest request, + string code, + string message) => + PostManualFinancialReadErrorIfCurrent( + request, + code, + message, + retryable: true); + + private void PostManualFinancialReadErrorIfCurrent( + ManualFinancialReadRequest request, + string code, + string message, + bool retryable) + { + if (_lifetimeCancellation.IsCancellationRequested || + !TryCompleteManualFinancialReadIfCurrent(request)) + { + return; + } + + PostManualFinancialReadError( + request.RequestId, + request.Operation, + request.Screen, + request.Query, + request.StockName, + code, + message, + retryable); + } + + private void PostManualFinancialWriteErrorIfCurrent( + string requestId, + string operation, + ManualFinancialScreenKind screen, + string stockName, + string code, + string message, + bool outcomeUnknown, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _manualFinancialWriteCancellation), + requestCancellation)) + { + return; + } + + PostManualFinancialMutationError( + requestId, + operation, + screen, + stockName, + code, + message, + outcomeUnknown); + } + + private void PostManualFinancialReadError( + string requestId, + string operation, + ManualFinancialScreenKind screen, + string query, + string stockName, + string code, + string message, + bool retryable) + { + if (operation == "list") + { + PostMessage("manual-financial-list-error", new + { + requestId, + screen = ToWireValue(screen), + query, + message + }); + return; + } + + PostMessage( + operation == "selection" + ? "manual-financial-selection-error" + : "manual-financial-load-error", + new + { + requestId, + screen = ToWireValue(screen), + stockName, + code, + message, + retryable + }); + } + + private void PostManualFinancialInvalidRequest( + string requestId, + string operation, + string screen, + string query = "", + string stockName = "") + { + if (TryParseManualFinancialScreen(screen, out var parsedScreen)) + { + if (operation is "list" or "load" or "selection") + { + PostManualFinancialReadError( + requestId, + operation, + parsedScreen, + query, + stockName, + "INVALID_REQUEST", + "The manual-financial request payload is invalid.", + retryable: false); + return; + } + + PostManualFinancialMutationError( + requestId, + operation, + parsedScreen, + stockName, + "INVALID_REQUEST", + "The manual-financial write payload is invalid.", + outcomeUnknown: false); + return; + } + + PostMessage("manual-financial-request-error", new + { + requestId, + operation, + code = "INVALID_REQUEST", + message = "The manual-financial request payload is invalid." + }); + } + + private void PostManualFinancialUnavailable( + string requestId, + string operation, + string screen, + string query = "", + string stockName = "") + { + if (TryParseManualFinancialScreen(screen, out var parsedScreen) && + operation is "list" or "load" or "selection") + { + PostManualFinancialReadError( + requestId, + operation, + parsedScreen, + query, + stockName, + "DATABASE_UNAVAILABLE", + _manualFinancialInitializationError ?? + _databaseInitializationError ?? + "The manual-financial database boundary is unavailable.", + retryable: false); + return; + } + + if (TryParseManualFinancialScreen(screen, out parsedScreen)) + { + PostManualFinancialMutationError( + requestId, + operation, + parsedScreen, + stockName, + "DATABASE_UNAVAILABLE", + _manualFinancialInitializationError ?? + _databaseInitializationError ?? + "The manual-financial database boundary is unavailable.", + outcomeUnknown: false); + } + } + + private void PostManualFinancialPlayoutActive( + string requestId, + string operation, + ManualFinancialScreenKind screen, + string stockName) => + PostManualFinancialMutationError( + requestId, + operation, + screen, + stockName, + "PLAYOUT_ACTIVE", + "Manual-financial writes are allowed only while playout is fully IDLE.", + outcomeUnknown: false); + + private void PostManualFinancialQuarantined( + string requestId, + string operation, + ManualFinancialScreenKind screen, + string stockName) => + PostManualFinancialMutationError( + requestId, + operation, + screen, + stockName, + "OUTCOME_UNKNOWN", + GetOperatorMutationQuarantineMessage( + Volatile.Read(ref _manualFinancialWriteQuarantineMessage) ?? + ManualFinancialCorrelationQuarantineMessage), + outcomeUnknown: true); + + private void PostManualFinancialMutationError( + string requestId, + string operation, + ManualFinancialScreenKind screen, + string stockName, + string code, + string message, + bool outcomeUnknown) + { + PostMessage("manual-financial-mutation-error", new + { + requestId, + operation, + screen = ToWireValue(screen), + stockName, + code, + message, + retryable = false, + outcomeUnknown + }); + } + + private bool IsManualFinancialWriteQuarantined() => + Volatile.Read(ref _manualFinancialWriteQuarantined) != 0 || + IsOperatorMutationQuarantined(); + + private void LatchManualFinancialWriteQuarantine(string message) + { + LatchOperatorMutationQuarantine(message); + if (Interlocked.CompareExchange(ref _manualFinancialWriteQuarantined, 1, 0) == 0) + { + Volatile.Write( + ref _manualFinancialWriteQuarantineMessage, + string.IsNullOrWhiteSpace(message) + ? ManualFinancialCorrelationQuarantineMessage + : message); + } + } + + /// + /// Root browser-navigation/process-failure hook. Correlation loss during a + /// dispatched write permanently quarantines writes for this process. + /// + private void InvalidateManualFinancialBrowserRequests() + { + Interlocked.Increment(ref _manualFinancialBrowserGeneration); + if (Volatile.Read(ref _manualFinancialWriteInFlight) != 0) + { + LatchManualFinancialWriteQuarantine( + ManualFinancialCorrelationQuarantineMessage); + } + + foreach (var request in SnapshotManualFinancialReads(clear: true)) + { + CancelRequest(request.Cancellation); + } + + CancelRequest(Interlocked.Exchange(ref _manualFinancialWriteCancellation, null)); + } + + /// Root playout-priority hook; writes own the playout gate. + private void CancelManualFinancialReadsForPlayout() + { + foreach (var request in SnapshotManualFinancialReads(clear: false)) + { + CancelRequest(request.Cancellation); + } + } + + /// Root window-close hook. + private void ShutdownManualFinancialRuntime() + { + if (Volatile.Read(ref _manualFinancialWriteInFlight) != 0) + { + LatchManualFinancialWriteQuarantine( + ManualFinancialCorrelationQuarantineMessage); + } + + foreach (var request in SnapshotManualFinancialReads(clear: true)) + { + CancelRequest(request.Cancellation); + } + + CancelRequest(Interlocked.Exchange(ref _manualFinancialWriteCancellation, null)); + _manualFinancialScreenService = null; + } + + private static bool TryParseManualFinancialListRequest( + JsonElement payload, + out string requestId, + out ManualFinancialScreenKind screen, + out string query, + out int maximumResults) + { + requestId = string.Empty; + screen = default; + query = string.Empty; + maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "screen", "query", "maximumResults") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + TryParseManualFinancialScreen(GetString(payload, "screen"), out screen) && + TryGetManualFinancialQuery(payload, "query", out query) && + TryGetOptionalManualFinancialLimit(payload, ref maximumResults); + } + + private static bool TryParseManualFinancialIdentityRequest( + JsonElement payload, + string[] exactProperties, + out string requestId, + out ManualFinancialIdentity identity) + { + requestId = string.Empty; + identity = new ManualFinancialIdentity(default, string.Empty); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, exactProperties) || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !TryParseManualFinancialScreen(GetString(payload, "screen"), out var screen) || + !TryGetManualFinancialText( + payload, + "stockName", + 200, + allowEmpty: false, + allowUnderscore: true, + out var stockName)) + { + return false; + } + + identity = new ManualFinancialIdentity(screen, stockName); + return true; + } + + private static bool TryParseManualFinancialCreateRequest( + JsonElement payload, + out string requestId, + out ManualFinancialRecord record, + out ManualFinancialStockWire stock) + { + requestId = string.Empty; + record = null!; + stock = default; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "screen", "stock", "record") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + TryParseManualFinancialScreen(GetString(payload, "screen"), out var screen) && + payload.TryGetProperty("record", out var recordElement) && + TryParseManualFinancialRecord(recordElement, screen, out record) && + payload.TryGetProperty("stock", out var stockElement) && + TryParseManualFinancialStock(stockElement, record.Identity.StockName, out stock); + } + + private static bool TryParseManualFinancialUpdateRequest( + JsonElement payload, + out string requestId, + out ManualFinancialRecord replacement, + out string expectedRowVersion) + { + requestId = string.Empty; + replacement = null!; + expectedRowVersion = string.Empty; + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + payload, + "requestId", + "screen", + "stockName", + "expectedRowVersion", + "record") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !TryParseManualFinancialScreen(GetString(payload, "screen"), out var screen) || + !TryGetManualFinancialText( + payload, + "stockName", + 200, + allowEmpty: false, + allowUnderscore: true, + out var stockName) || + !TryGetManualFinancialRowVersion(payload, "expectedRowVersion", out expectedRowVersion) || + !payload.TryGetProperty("record", out var recordElement) || + !TryParseManualFinancialRecord(recordElement, screen, out replacement)) + { + return false; + } + + return string.Equals(replacement.Identity.StockName, stockName, StringComparison.Ordinal); + } + + private static bool TryParseManualFinancialDeleteRequest( + JsonElement payload, + out string requestId, + out ManualFinancialIdentity identity, + out string expectedRowVersion) + { + expectedRowVersion = string.Empty; + return TryParseManualFinancialIdentityRequest( + payload, + ["requestId", "screen", "stockName", "expectedRowVersion"], + out requestId, + out identity) && + TryGetManualFinancialRowVersion( + payload, + "expectedRowVersion", + out expectedRowVersion); + } + + private static bool TryParseManualFinancialDeleteAllRequest( + JsonElement payload, + out string requestId, + out ManualFinancialScreenKind screen) + { + requestId = string.Empty; + screen = default; + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "screen", "confirmationToken") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !TryParseManualFinancialScreen(GetString(payload, "screen"), out screen)) + { + return false; + } + + var expected = "DELETE_ALL_" + ManualFinancialCutContracts.Get(screen).TableName; + return string.Equals( + GetString(payload, "confirmationToken"), + expected, + StringComparison.Ordinal); + } + + private static bool TryParseManualFinancialSelectionRequest( + JsonElement payload, + out string requestId, + out ManualFinancialIdentity identity, + out string rowVersion, + out ManualFinancialStockWire stock) + { + rowVersion = string.Empty; + stock = default; + if (!TryParseManualFinancialIdentityRequest( + payload, + ["requestId", "screen", "stockName", "rowVersion", "stock"], + out requestId, + out identity) || + !TryGetManualFinancialRowVersion(payload, "rowVersion", out rowVersion) || + !payload.TryGetProperty("stock", out var stockElement) || + !TryParseManualFinancialStock(stockElement, identity.StockName, out stock)) + { + return false; + } + + return true; + } + + private static bool TryParseManualFinancialRecord( + JsonElement element, + ManualFinancialScreenKind screen, + out ManualFinancialRecord record) + { + record = null!; + if (element.ValueKind != JsonValueKind.Object || + !TryGetManualFinancialText( + element, + "kind", + 32, + allowEmpty: false, + allowUnderscore: false, + out var kind) || + !string.Equals(kind, ToWireValue(screen), StringComparison.Ordinal) || + !TryGetManualFinancialText( + element, + "stockName", + 200, + allowEmpty: false, + allowUnderscore: true, + out var stockName)) + { + return false; + } + + var identity = new ManualFinancialIdentity(screen, stockName); + switch (screen) + { + case ManualFinancialScreenKind.RevenueComposition: + if (!HasOnlyProperties(element, "kind", "stockName", "baseDate", "slices") || + !TryGetManualFinancialText( + element, + "baseDate", + 200, + allowEmpty: true, + allowUnderscore: true, + out var baseDate) || + !TryParseManualRevenueSlices(element, out var slices)) + { + return false; + } + + record = new ManualRevenueCompositionRecord(identity, baseDate, slices); + return true; + case ManualFinancialScreenKind.GrowthMetrics: + if (!HasOnlyProperties( + element, + "kind", + "stockName", + "salesGrowth", + "operatingProfitGrowth", + "netAssetGrowth", + "netIncomeGrowth", + "periods") || + !TryParseManualGrowthSeries(element, "salesGrowth", out var salesGrowth) || + !TryParseManualGrowthSeries( + element, + "operatingProfitGrowth", + out var operatingProfitGrowth) || + !TryParseManualGrowthSeries(element, "netAssetGrowth", out var netAssetGrowth) || + !TryParseManualGrowthSeries(element, "netIncomeGrowth", out var netIncomeGrowth) || + !TryParseManualPeriods(element, out var periods)) + { + return false; + } + + record = new ManualGrowthMetricsRecord( + identity, + salesGrowth, + operatingProfitGrowth, + netAssetGrowth, + netIncomeGrowth, + periods); + return true; + case ManualFinancialScreenKind.Sales: + case ManualFinancialScreenKind.OperatingProfit: + if (!HasOnlyProperties(element, "kind", "stockName", "quarters") || + !TryParseManualQuarters(element, out var quarters)) + { + return false; + } + + record = screen == ManualFinancialScreenKind.Sales + ? new ManualSalesRecord(identity, quarters) + : new ManualOperatingProfitRecord(identity, quarters); + return true; + default: + return false; + } + } + + private static bool TryParseManualRevenueSlices( + JsonElement record, + out IReadOnlyList slices) + { + slices = Array.Empty(); + if (!record.TryGetProperty("slices", out var element) || + element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 5) + { + return false; + } + + var result = new List(5); + foreach (var slice in element.EnumerateArray()) + { + if (slice.ValueKind == JsonValueKind.Null) + { + result.Add(null); + continue; + } + + if (slice.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(slice, "label", "percentageText", "percentage") || + !TryGetManualFinancialText( + slice, + "label", + 200, + allowEmpty: false, + allowUnderscore: false, + out var label) || + !TryGetManualFinancialText( + slice, + "percentageText", + 64, + allowEmpty: false, + allowUnderscore: false, + out var percentageText) || + !slice.TryGetProperty("percentage", out var percentageElement) || + percentageElement.ValueKind != JsonValueKind.Number || + !percentageElement.TryGetDouble(out var percentage) || + !double.IsFinite(percentage)) + { + return false; + } + + result.Add(new ManualRevenueSlice(label, percentageText, percentage)); + } + + slices = result; + return true; + } + + private static bool TryParseManualGrowthSeries( + JsonElement record, + string propertyName, + out ManualGrowthSeriesValues values) + { + values = new ManualGrowthSeriesValues(null, null, null, null); + if (!record.TryGetProperty(propertyName, out var element) || + element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 4) + { + return false; + } + + var parsed = new double?[4]; + var index = 0; + foreach (var value in element.EnumerateArray()) + { + if (value.ValueKind == JsonValueKind.Null) + { + parsed[index++] = null; + continue; + } + + if (value.ValueKind != JsonValueKind.Number || + !value.TryGetDouble(out var number) || !double.IsFinite(number)) + { + return false; + } + + parsed[index++] = number; + } + + values = new ManualGrowthSeriesValues(parsed[0], parsed[1], parsed[2], parsed[3]); + return true; + } + + private static bool TryParseManualPeriods( + JsonElement record, + out ManualGrowthPeriodLabels periods) + { + periods = new ManualGrowthPeriodLabels(string.Empty, string.Empty, string.Empty, string.Empty); + if (!record.TryGetProperty("periods", out var element) || + element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 4) + { + return false; + } + + var parsed = new string[4]; + var index = 0; + foreach (var value in element.EnumerateArray()) + { + if (value.ValueKind != JsonValueKind.String || + !IsSafeManualFinancialText( + value.GetString(), + 200, + allowEmpty: true, + allowUnderscore: false, + out parsed[index++])) + { + return false; + } + } + + periods = new ManualGrowthPeriodLabels(parsed[0], parsed[1], parsed[2], parsed[3]); + return true; + } + + private static bool TryParseManualQuarters( + JsonElement record, + out IReadOnlyList quarters) + { + quarters = Array.Empty(); + if (!record.TryGetProperty("quarters", out var element) || + element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 6) + { + return false; + } + + var result = new List(6); + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(item, "quarter", "value") || + !TryGetManualFinancialText( + item, + "quarter", + 200, + allowEmpty: false, + allowUnderscore: false, + out var quarter) || + !item.TryGetProperty("value", out var valueElement) || + valueElement.ValueKind != JsonValueKind.Number || + !valueElement.TryGetInt32(out var value)) + { + return false; + } + + result.Add(new ManualQuarterValue(quarter, value)); + } + + quarters = result; + return true; + } + + private static bool TryParseManualFinancialStock( + JsonElement element, + string expectedName, + out ManualFinancialStockWire stock) + { + stock = default; + if (element.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(element, "market", "source", "name", "code") || + !TryParseStockMarket(GetString(element, "market"), out var market) || + !TryParseDataSource(GetString(element, "source"), out var source) || + !TryGetManualFinancialText( + element, + "name", + 200, + allowEmpty: false, + allowUnderscore: true, + out var name) || + !TryGetManualFinancialStockCode(element, "code", out var code) || + !string.Equals(name, expectedName, StringComparison.Ordinal) || + (market is StockMarket.Kospi or StockMarket.Kosdaq + ? source != DataSourceKind.Oracle + : source != DataSourceKind.MariaDb)) + { + return false; + } + + stock = new ManualFinancialStockWire(market, source, name, code); + return true; + } + + private static bool TryGetOptionalManualFinancialLimit( + JsonElement payload, + ref int maximumResults) + { + if (!payload.TryGetProperty("maximumResults", out var element)) + { + return true; + } + + return element.ValueKind == JsonValueKind.Number && + element.TryGetInt32(out maximumResults) && + maximumResults is >= 1 and <= LegacyManualFinancialScreenService.MaximumResults; + } + + private static bool TryGetManualFinancialQuery( + JsonElement payload, + string propertyName, + out string query) + { + query = string.Empty; + if (!payload.TryGetProperty(propertyName, out var element) || + element.ValueKind != JsonValueKind.String) + { + return false; + } + + var value = element.GetString(); + return value is not null && + value.Length <= LegacyManualFinancialScreenService.MaximumQueryLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + IsSafeManualFinancialText( + value, + LegacyManualFinancialScreenService.MaximumQueryLength, + allowEmpty: true, + allowUnderscore: true, + out query); + } + + private static bool TryGetManualFinancialText( + JsonElement payload, + string propertyName, + int maximumLength, + bool allowEmpty, + bool allowUnderscore, + out string value) + { + value = string.Empty; + return payload.TryGetProperty(propertyName, out var element) && + element.ValueKind == JsonValueKind.String && + IsSafeManualFinancialText( + element.GetString(), + maximumLength, + allowEmpty, + allowUnderscore, + out value); + } + + private static bool IsSafeManualFinancialText( + string? input, + int maximumLength, + bool allowEmpty, + bool allowUnderscore, + out string value) + { + value = input ?? string.Empty; + if (input is null || (!allowEmpty && input.Length == 0) || + input.Length > maximumLength || input != input.Trim() || + input.Contains('^') || (!allowUnderscore && input.Contains('_'))) + { + return false; + } + + foreach (var character in input) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || category is + UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static bool TryGetManualFinancialRowVersion( + JsonElement payload, + string propertyName, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return value.Length == 64 && value.All(static character => + char.IsAsciiDigit(character) || character is >= 'A' and <= 'F'); + } + + private static bool TryGetManualFinancialStockCode( + JsonElement payload, + string propertyName, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return value.Length is >= 1 and <= 32 && value.All(char.IsAsciiLetterOrDigit); + } + + private static bool TryParseManualFinancialScreen( + string? value, + out ManualFinancialScreenKind screen) + { + screen = value switch + { + "revenue-composition" => ManualFinancialScreenKind.RevenueComposition, + "growth-metrics" => ManualFinancialScreenKind.GrowthMetrics, + "sales" => ManualFinancialScreenKind.Sales, + "operating-profit" => ManualFinancialScreenKind.OperatingProfit, + _ => default + }; + return value is "revenue-composition" or "growth-metrics" or + "sales" or "operating-profit"; + } + + private static string ToWireValue(ManualFinancialScreenKind screen) => screen switch + { + ManualFinancialScreenKind.RevenueComposition => "revenue-composition", + ManualFinancialScreenKind.GrowthMetrics => "growth-metrics", + ManualFinancialScreenKind.Sales => "sales", + ManualFinancialScreenKind.OperatingProfit => "operating-profit", + _ => throw new ArgumentOutOfRangeException(nameof(screen)) + }; + + private static bool TryParseStockMarket(string? value, out StockMarket market) + { + market = value switch + { + "kospi" => StockMarket.Kospi, + "kosdaq" => StockMarket.Kosdaq, + "nxt-kospi" => StockMarket.NxtKospi, + "nxt-kosdaq" => StockMarket.NxtKosdaq, + _ => default + }; + return value is "kospi" or "kosdaq" or "nxt-kospi" or "nxt-kosdaq"; + } + + private static bool TryParseDataSource(string? value, out DataSourceKind source) + { + source = value switch + { + "oracle" => DataSourceKind.Oracle, + "mariaDb" => DataSourceKind.MariaDb, + _ => default + }; + return value is "oracle" or "mariaDb"; + } + + private static object CreateManualFinancialSnapshotPayload(ManualFinancialSnapshot snapshot) => + new + { + stockName = snapshot.Record.Identity.StockName, + snapshot.RowVersion, + record = CreateManualFinancialRecordPayload(snapshot.Record) + }; + + private static object CreateManualFinancialRecordPayload(ManualFinancialRecord record) => + record switch + { + ManualRevenueCompositionRecord revenue => new + { + kind = ToWireValue(revenue.Identity.Screen), + stockName = revenue.Identity.StockName, + revenue.BaseDate, + slices = revenue.Slices.Select(slice => slice is null + ? null + : (object)new + { + slice.Label, + slice.PercentageText, + slice.Percentage + }) + }, + ManualGrowthMetricsRecord growth => new + { + kind = ToWireValue(growth.Identity.Screen), + stockName = growth.Identity.StockName, + salesGrowth = growth.SalesGrowth.ToArray(), + operatingProfitGrowth = growth.OperatingProfitGrowth.ToArray(), + netAssetGrowth = growth.NetAssetGrowth.ToArray(), + netIncomeGrowth = growth.NetIncomeGrowth.ToArray(), + periods = growth.Periods.ToArray() + }, + ManualSalesRecord sales => new + { + kind = ToWireValue(sales.Identity.Screen), + stockName = sales.Identity.StockName, + quarters = sales.Quarters.Select(quarter => new + { + quarter.Quarter, + quarter.Value + }) + }, + ManualOperatingProfitRecord profit => new + { + kind = ToWireValue(profit.Identity.Screen), + stockName = profit.Identity.StockName, + quarters = profit.Quarters.Select(quarter => new + { + quarter.Quarter, + quarter.Value + }) + }, + _ => throw new ArgumentOutOfRangeException(nameof(record)) + }; + + private static string SafeManualFinancialRequestId(JsonElement payload) => + payload.ValueKind == JsonValueKind.Object && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId) + ? requestId + : string.Empty; + + private static string SafeManualFinancialScreen(JsonElement payload) + { + var value = payload.ValueKind == JsonValueKind.Object + ? GetString(payload, "screen") + : null; + return TryParseManualFinancialScreen(value, out var screen) + ? ToWireValue(screen) + : string.Empty; + } + + private static string SafeManualFinancialQuery(JsonElement payload) => + payload.ValueKind == JsonValueKind.Object && + TryGetManualFinancialQuery(payload, "query", out var query) + ? query + : string.Empty; + + private static string SafeManualFinancialStockName(JsonElement payload) => + payload.ValueKind == JsonValueKind.Object && + TryGetManualFinancialText( + payload, + "stockName", + 200, + allowEmpty: false, + allowUnderscore: true, + out var stockName) + ? stockName + : string.Empty; + + private static string SafeManualFinancialRecordStockName(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object || + !payload.TryGetProperty("record", out var record) || + record.ValueKind != JsonValueKind.Object) + { + return string.Empty; + } + + return SafeManualFinancialStockName(record); + } + + private readonly record struct ManualFinancialBridgeReply(string MessageType, object Payload); + + private sealed class ManualFinancialReadRequest + { + private int _terminalState; + + public ManualFinancialReadRequest( + string lane, + string requestId, + ManualFinancialScreenKind screen, + string query, + string stockName, + int browserGeneration, + CancellationTokenSource cancellation) + { + Lane = lane; + RequestId = requestId; + Operation = lane; + Screen = screen; + Query = query; + StockName = stockName; + BrowserGeneration = browserGeneration; + Cancellation = cancellation; + } + + public string Lane { get; } + + public string RequestId { get; } + + public string Operation { get; } + + public ManualFinancialScreenKind Screen { get; } + + public string Query { get; } + + public string StockName { get; } + + public int BrowserGeneration { get; } + + public CancellationTokenSource Cancellation { get; } + + public bool TryComplete() => Interlocked.CompareExchange(ref _terminalState, 1, 0) == 0; + } + + private readonly record struct ManualFinancialStockWire( + StockMarket Market, + DataSourceKind Source, + string Name, + string Code); + + private sealed class ManualFinancialBridgeStaleException : Exception; +} diff --git a/MainWindow.ManualLists.cs b/MainWindow.ManualLists.cs new file mode 100644 index 0000000..e1be2c3 --- /dev/null +++ b/MainWindow.ManualLists.cs @@ -0,0 +1,786 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private const string OperatorDataDirectoryEnvironmentVariable = + "MBN_STOCK_OPERATOR_DATA_DIRECTORY"; + private const int MaximumManualValueLength = 256; + private const string ManualOperatorCorrelationQuarantineMessage = + "An operator-data file write result could not be confirmed. Restart the app and reconcile the trusted files before another write."; + + private readonly SemaphoreSlim _manualOperatorDataGate = new(1, 1); + private S5025TrustedManualFileStore? _manualNetSellStore; + private ViTrustedManualFileStore? _viManualListStore; + private string? _manualOperatorDataInitializationError; + private int _manualOperatorWriteInFlight; + private int _manualOperatorWriteQuarantined; + private int _manualOperatorBrowserGeneration; + private string? _manualOperatorWriteQuarantineMessage = + ManualOperatorCorrelationQuarantineMessage; + + private void InitializeManualOperatorDataRuntime() + { + try + { + var configuredDirectory = Environment.GetEnvironmentVariable( + OperatorDataDirectoryEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(configuredDirectory)) + { + _manualOperatorDataInitializationError = + "외부 수동 데이터 경로는 TOCTOU 안전성을 보장할 수 없어 비활성화되었습니다. 앱 전용 로컬 운영 데이터 폴더를 사용하세요."; + return; + } + + var directory = TrustedManualDirectory.PreparePrivateWritableDirectory(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MBN_STOCK_WEBVIEW", + "OperatorData")); + _manualNetSellStore = new S5025TrustedManualFileStore(directory); + _viManualListStore = new ViTrustedManualFileStore(directory); + } + catch + { + _viManualListStore?.Dispose(); + _manualNetSellStore?.Dispose(); + _viManualListStore = null; + _manualNetSellStore = null; + _manualOperatorDataInitializationError = + "수동 송출 데이터 저장소를 초기화하지 못했습니다. 로컬 운영 데이터 설정을 확인하세요."; + } + } + + private void HandleManualOperatorStatusRequest(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)) + { + PostMessage("manual-operator-data-error", new + { + requestId = SafeManualOperatorRequestId(payload), + operation = "status", + code = "INVALID_REQUEST", + message = "올바르지 않은 수동 데이터 상태 요청입니다.", + retryable = false, + outcomeUnknown = false + }); + return; + } + + PostMessage("manual-operator-data-status", new + { + requestId, + available = _manualNetSellStore is not null && _viManualListStore is not null, + writeQuarantined = IsManualOperatorWriteQuarantined(), + message = IsManualOperatorWriteQuarantined() + ? GetOperatorMutationQuarantineMessage( + _manualOperatorWriteQuarantineMessage ?? + ManualOperatorCorrelationQuarantineMessage) + : _manualOperatorDataInitializationError + }); + } + + private async Task HandleManualNetSellReadAsync(JsonElement payload) + { + if (!TryParseAudienceRequest(payload, out var requestId, out var audience)) + { + PostManualOperatorError( + requestId, + "read-net-sell", + "INVALID_REQUEST", + "올바르지 않은 수동 순매도 조회 요청입니다.", + retryable: false, + outcomeUnknown: false); + return; + } + + var store = _manualNetSellStore; + if (store is null) + { + PostManualOperatorUnavailable(requestId, "read-net-sell"); + return; + } + + var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); + await _manualOperatorDataGate.WaitAsync(_lifetimeCancellation.Token); + try + { + var rows = await store.ReadAsync(audience, _lifetimeCancellation.Token); + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + return; + } + PostMessage("manual-net-sell-data", new + { + requestId, + audience = ToWireValue(audience), + rows = rows.Select(row => new + { + row.LeftName, + row.LeftAmount, + row.RightName, + row.RightAmount + }) + }); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + // Window shutdown owns cancellation and no stale browser response is posted. + } + catch (S5025TrustedManualDataException) + { + PostManualOperatorError( + requestId, + "read-net-sell", + "DATA_INVALID", + "수동 순매도 데이터 파일이 없거나 형식이 올바르지 않습니다.", + retryable: false, + outcomeUnknown: false); + } + finally + { + _manualOperatorDataGate.Release(); + } + } + + private async Task HandleManualNetSellWriteAsync(JsonElement payload) + { + if (!TryParseManualNetSellWrite( + payload, + out var requestId, + out var audience, + out var rows)) + { + PostManualOperatorError( + requestId, + "save-net-sell", + "INVALID_REQUEST", + "수동 순매도 입력은 5행의 검증된 값이어야 합니다.", + retryable: false, + outcomeUnknown: false); + return; + } + + var store = _manualNetSellStore; + if (store is null) + { + PostManualOperatorUnavailable(requestId, "save-net-sell"); + return; + } + + if (IsManualOperatorWriteQuarantined()) + { + PostManualOperatorQuarantine(requestId, "save-net-sell"); + return; + } + + if (!await TryEnterManualMutationGatesAsync(requestId, "save-net-sell")) + { + return; + } + + var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); + if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined())) + { + ExitManualMutationGates(); + PostManualOperatorError( + requestId, + "save-net-sell", + "PLAYOUT_ACTIVE", + "Operator data cannot be changed unless playout is verifiably idle.", + retryable: true, + outcomeUnknown: false); + return; + } + Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1); + try + { + await store.WriteAsync(audience, rows, _lifetimeCancellation.Token); + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + QuarantineManualOperatorWrites( + "수동 순매도 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + return; + } + PostManualNetSellWriteResult(requestId, audience, recovered: false); + } + catch (Exception exception) when (exception is + S5025TrustedManualDataException or OperationCanceledException) + { + if (_lifetimeCancellation.IsCancellationRequested) + { + return; + } + + try + { + var persisted = await store.ReadAsync(audience, CancellationToken.None); + if (persisted.SequenceEqual(rows)) + { + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + QuarantineManualOperatorWrites( + "수동 순매도 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + return; + } + PostManualNetSellWriteResult(requestId, audience, recovered: true); + return; + } + + QuarantineManualOperatorWrites( + "수동 순매도 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + PostManualOperatorQuarantine(requestId, "save-net-sell"); + } + catch + { + QuarantineManualOperatorWrites( + "수동 순매도 저장 결과를 확인할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + PostManualOperatorQuarantine(requestId, "save-net-sell"); + } + } + finally + { + Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0); + ExitManualMutationGates(); + } + } + + private async Task HandleViManualListReadAsync(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)) + { + PostManualOperatorError( + SafeManualOperatorRequestId(payload), + "read-vi", + "INVALID_REQUEST", + "올바르지 않은 VI 수동 목록 조회 요청입니다.", + retryable: false, + outcomeUnknown: false); + return; + } + + var store = _viManualListStore; + if (store is null) + { + PostManualOperatorUnavailable(requestId, "read-vi"); + return; + } + + var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); + await _manualOperatorDataGate.WaitAsync(_lifetimeCancellation.Token); + try + { + var snapshot = await store.ReadSnapshotAsync(_lifetimeCancellation.Token); + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + return; + } + PostMessage("vi-manual-list-data", new + { + requestId, + itemCount = snapshot.Items.Count, + pageCount = snapshot.Items.Count == 0 ? 0 : (snapshot.Items.Count + 4) / 5, + items = snapshot.Items, + version = snapshot.RowVersion + }); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + // Window shutdown owns cancellation. + } + catch (ViTrustedManualDataException) + { + PostManualOperatorError( + requestId, + "read-vi", + "DATA_INVALID", + "VI 수동 목록 파일 형식이 올바르지 않습니다.", + retryable: false, + outcomeUnknown: false); + } + finally + { + _manualOperatorDataGate.Release(); + } + } + + private async Task HandleViManualListWriteAsync(JsonElement payload) + { + if (!TryParseViWrite( + payload, + out var requestId, + out var expectedVersion, + out var items)) + { + PostManualOperatorError( + requestId, + "save-vi", + "INVALID_REQUEST", + $"VI 수동 목록은 최대 {ViTrustedManualFileStore.MaximumItemCount}개의 검증된 종목이어야 합니다.", + retryable: false, + outcomeUnknown: false); + return; + } + + var store = _viManualListStore; + if (store is null) + { + PostManualOperatorUnavailable(requestId, "save-vi"); + return; + } + + if (IsManualOperatorWriteQuarantined()) + { + PostManualOperatorQuarantine(requestId, "save-vi"); + return; + } + + if (!await TryEnterManualMutationGatesAsync(requestId, "save-vi")) + { + return; + } + + var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); + if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined())) + { + ExitManualMutationGates(); + PostManualOperatorError( + requestId, + "save-vi", + "PLAYOUT_ACTIVE", + "Operator data cannot be changed unless playout is verifiably idle.", + retryable: true, + outcomeUnknown: false); + return; + } + var mutationStarted = false; + try + { + var currentSnapshot = await store.ReadSnapshotAsync(_lifetimeCancellation.Token); + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + return; + } + + if (!string.Equals( + currentSnapshot.RowVersion, + expectedVersion, + StringComparison.Ordinal)) + { + PostManualOperatorError( + requestId, + "save-vi", + "STALE_DATA", + "The VI manual list changed after it was read. Reload it before saving.", + retryable: false, + outcomeUnknown: false); + return; + } + + Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1); + mutationStarted = true; + var result = await store.WriteAsync(items, _lifetimeCancellation.Token); + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + QuarantineManualOperatorWrites( + "VI 수동 목록 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + return; + } + PostViWriteResult(requestId, result, recovered: false); + } + catch (Exception exception) when (exception is + ViTrustedManualDataException or OperationCanceledException) + { + if (_lifetimeCancellation.IsCancellationRequested) + { + return; + } + + if (!mutationStarted) + { + PostManualOperatorError( + requestId, + "save-vi", + "DATA_INVALID", + "The current VI manual list could not be verified before saving.", + retryable: false, + outcomeUnknown: false); + return; + } + + try + { + var persisted = await store.ReadSnapshotAsync(CancellationToken.None); + if (persisted.Items.SequenceEqual(items)) + { + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + QuarantineManualOperatorWrites( + "VI 수동 목록 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + return; + } + PostViWriteResult( + requestId, + new ViTrustedManualWriteResult(persisted, Persisted: items.Count != 0), + recovered: true); + return; + } + + QuarantineManualOperatorWrites( + "VI 수동 목록 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + PostManualOperatorQuarantine(requestId, "save-vi"); + } + catch + { + QuarantineManualOperatorWrites( + "VI 수동 목록 저장 결과를 확인할 수 없습니다. 파일을 직접 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + PostManualOperatorQuarantine(requestId, "save-vi"); + } + } + finally + { + Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0); + ExitManualMutationGates(); + } + } + + private async Task TryEnterManualMutationGatesAsync( + string requestId, + string operation) + { + if (!await _playoutCommandGate.WaitAsync(0)) + { + PostManualOperatorError( + requestId, + operation, + "PLAYOUT_BUSY", + "송출 명령을 처리하는 동안 수동 데이터를 저장할 수 없습니다.", + retryable: true, + outcomeUnknown: false); + return false; + } + + if (!await _manualOperatorDataGate.WaitAsync(0)) + { + _playoutCommandGate.Release(); + PostManualOperatorError( + requestId, + operation, + "DATA_BUSY", + "다른 수동 데이터 작업이 진행 중입니다.", + retryable: true, + outcomeUnknown: false); + return false; + } + + if (!CanStartOperatorMutation(IsManualOperatorWriteQuarantined())) + { + ExitManualMutationGates(); + PostManualOperatorError( + requestId, + operation, + "PLAYOUT_ACTIVE", + "PREPARE 또는 송출 중에는 수동 데이터를 변경할 수 없습니다. TAKE OUT 후 저장하세요.", + retryable: true, + outcomeUnknown: false); + return false; + } + + return true; + } + + private void ExitManualMutationGates() + { + _manualOperatorDataGate.Release(); + _playoutCommandGate.Release(); + } + + private static bool TryParseAudienceRequest( + JsonElement payload, + out string requestId, + out S5025ManualAudience audience) + { + requestId = SafeManualOperatorRequestId(payload); + audience = default; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "audience") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + TryParseAudience(GetString(payload, "audience"), out audience); + } + + private static string SafeManualOperatorRequestId(JsonElement payload) => + payload.ValueKind == JsonValueKind.Object && + TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out var requestId) + ? requestId + : string.Empty; + + private static bool TryParseManualNetSellWrite( + JsonElement payload, + out string requestId, + out S5025ManualAudience audience, + out IReadOnlyList rows) + { + requestId = SafeManualOperatorRequestId(payload); + audience = default; + rows = Array.Empty(); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "audience", "rows") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !TryParseAudience(GetString(payload, "audience"), out audience) || + !payload.TryGetProperty("rows", out var rowsValue) || + rowsValue.ValueKind != JsonValueKind.Array || + rowsValue.GetArrayLength() != 5) + { + return false; + } + + var parsed = new List(5); + foreach (var row in rowsValue.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + row, + "leftName", + "leftAmount", + "rightName", + "rightAmount") || + !TryGetManualValue(row, "leftName", out var leftName) || + !TryGetManualValue(row, "leftAmount", out var leftAmount) || + !TryGetManualValue(row, "rightName", out var rightName) || + !TryGetManualValue(row, "rightAmount", out var rightAmount)) + { + return false; + } + + parsed.Add(new S5025TrustedManualRow( + leftName, + leftAmount, + rightName, + rightAmount)); + } + + rows = parsed; + return true; + } + + private static bool TryParseViWrite( + JsonElement payload, + out string requestId, + out string expectedVersion, + out IReadOnlyList items) + { + requestId = SafeManualOperatorRequestId(payload); + expectedVersion = string.Empty; + items = Array.Empty(); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "expectedVersion", "items") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !TryGetViExpectedVersion(payload, out expectedVersion) || + !payload.TryGetProperty("items", out var itemsValue) || + itemsValue.ValueKind != JsonValueKind.Array || + itemsValue.GetArrayLength() > ViTrustedManualFileStore.MaximumItemCount) + { + return false; + } + + var parsed = new List(itemsValue.GetArrayLength()); + foreach (var item in itemsValue.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(item, "code", "name") || + !TryGetManualValue(item, "code", out var code, allowEmpty: false, maximumLength: 64) || + !TryGetManualValue(item, "name", out var name, allowEmpty: false, maximumLength: 200) || + !string.Equals(code, code.Trim(), StringComparison.Ordinal) || + !string.Equals(name, name.Trim(), StringComparison.Ordinal) || + name.Contains(',')) + { + return false; + } + + parsed.Add(new ViTrustedManualItem(code, name)); + } + + items = parsed; + return true; + } + + private static bool TryGetViExpectedVersion( + JsonElement payload, + out string expectedVersion) + { + expectedVersion = string.Empty; + if (payload.ValueKind != JsonValueKind.Object || + !payload.TryGetProperty("expectedVersion", out var value) || + value.ValueKind != JsonValueKind.String) + { + return false; + } + + expectedVersion = value.GetString() ?? string.Empty; + return TrustedViManualReference.IsRowVersion(expectedVersion); + } + + private static bool TryGetManualValue( + JsonElement payload, + string propertyName, + out string value, + bool allowEmpty = true, + int maximumLength = MaximumManualValueLength) + { + value = GetString(payload, propertyName) ?? string.Empty; + return payload.TryGetProperty(propertyName, out var element) && + element.ValueKind == JsonValueKind.String && + (allowEmpty || value.Length > 0) && + value.Length <= maximumLength && + !value.Contains('^') && + !value.Any(char.IsControl); + } + + private static bool TryParseAudience( + string? value, + out S5025ManualAudience audience) + { + audience = value switch + { + "INDIVIDUAL" => S5025ManualAudience.Individual, + "FOREIGN" => S5025ManualAudience.Foreign, + "INSTITUTION" => S5025ManualAudience.Institution, + _ => default + }; + return value is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION"; + } + + private static string ToWireValue(S5025ManualAudience audience) => audience switch + { + S5025ManualAudience.Individual => "INDIVIDUAL", + S5025ManualAudience.Foreign => "FOREIGN", + S5025ManualAudience.Institution => "INSTITUTION", + _ => throw new ArgumentOutOfRangeException(nameof(audience)) + }; + + private void PostManualNetSellWriteResult( + string requestId, + S5025ManualAudience audience, + bool recovered) => + PostMessage("manual-net-sell-save-result", new + { + requestId, + audience = ToWireValue(audience), + saved = true, + recovered + }); + + private void PostViWriteResult( + string requestId, + ViTrustedManualWriteResult result, + bool recovered) => + PostMessage("vi-manual-list-save-result", new + { + requestId, + saved = true, + recovered, + persisted = result.Persisted, + itemCount = result.Snapshot.Items.Count, + pageCount = result.Snapshot.Items.Count == 0 + ? 0 + : (result.Snapshot.Items.Count + 4) / 5, + version = result.Snapshot.RowVersion + }); + + private void PostManualOperatorUnavailable(string requestId, string operation) => + PostManualOperatorError( + requestId, + operation, + "DATA_UNAVAILABLE", + _manualOperatorDataInitializationError ?? + "수동 송출 데이터 저장소를 사용할 수 없습니다.", + retryable: false, + outcomeUnknown: false); + + private void PostManualOperatorQuarantine(string requestId, string operation) => + PostManualOperatorError( + requestId, + operation, + "OUTCOME_UNKNOWN", + _manualOperatorWriteQuarantineMessage ?? + "이전 저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다.", + retryable: false, + outcomeUnknown: true); + + private void PostManualOperatorError( + string requestId, + string operation, + string code, + string message, + bool retryable, + bool outcomeUnknown) => + PostMessage("manual-operator-data-error", new + { + requestId, + operation, + code, + message, + retryable, + outcomeUnknown + }); + + private bool IsManualOperatorWriteQuarantined() => + Volatile.Read(ref _manualOperatorWriteQuarantined) != 0 || + IsOperatorMutationQuarantined(); + + private void QuarantineManualOperatorWrites(string message) + { + LatchOperatorMutationQuarantine(message); + _manualOperatorWriteQuarantineMessage = message; + Interlocked.Exchange(ref _manualOperatorWriteQuarantined, 1); + } + + private void ShutdownManualOperatorDataRuntime() + { + InvalidateManualOperatorBrowserRequests(); + var netSellStore = Interlocked.Exchange(ref _manualNetSellStore, null); + var viStore = Interlocked.Exchange(ref _viManualListStore, null); + if (netSellStore is null && viStore is null) + { + return; + } + + _ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore); + } + + private async Task DisposeManualOperatorStoresWhenIdleAsync( + S5025TrustedManualFileStore? netSellStore, + ViTrustedManualFileStore? viStore) + { + await _manualOperatorDataGate.WaitAsync().ConfigureAwait(false); + try + { + viStore?.Dispose(); + netSellStore?.Dispose(); + } + finally + { + _manualOperatorDataGate.Release(); + } + } + + private void InvalidateManualOperatorBrowserRequests() + { + Interlocked.Increment(ref _manualOperatorBrowserGeneration); + if (Volatile.Read(ref _manualOperatorWriteInFlight) != 0) + { + QuarantineManualOperatorWrites( + "수동 데이터 저장 중 화면 상관관계가 사라졌습니다. 저장 파일을 확인하고 앱을 다시 시작하기 전까지 저장을 반복하지 마세요."); + } + } +} diff --git a/MainWindow.NamedPlaylists.cs b/MainWindow.NamedPlaylists.cs new file mode 100644 index 0000000..02794fd --- /dev/null +++ b/MainWindow.NamedPlaylists.cs @@ -0,0 +1,921 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private const string NamedPlaylistCorrelationQuarantineMessage = + "A named-playlist write result could not be confirmed. Restart the app before another named-playlist write."; + + private async Task HandleNamedPlaylistListRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "maximumResults") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId)) + { + PostNamedPlaylistInvalidRequest(requestId, "list"); + return; + } + + var maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions; + if (payload.TryGetProperty("maximumResults", out var maximumElement) && + (maximumElement.ValueKind != JsonValueKind.Number || + !maximumElement.TryGetInt32(out maximumResults) || + maximumResults is < 1 or > LegacyNamedPlaylistPersistenceService.MaximumDefinitions)) + { + PostNamedPlaylistInvalidRequest(requestId, "list"); + return; + } + + var service = _namedPlaylistPersistenceService; + if (service is null) + { + PostNamedPlaylistUnavailable(requestId, "list"); + return; + } + + await ExecuteNamedPlaylistReadAsync( + requestId, + "list", + async cancellationToken => + { + var result = await service.ListAsync(maximumResults, cancellationToken); + var suggestedProgramCode = await service.SuggestNextProgramCodeAsync( + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PostMessage("named-playlist-list-results", new + { + requestId, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + suggestedProgramCode, + canMutate = service.CanMutate && !IsNamedPlaylistWriteQuarantined(), + writeQuarantined = IsNamedPlaylistWriteQuarantined(), + definitions = result.Items.Select(item => new + { + item.ProgramCode, + item.Title + }) + }); + }); + } + + private async Task HandleNamedPlaylistLoadRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var programCode = GetString(payload, "programCode") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "programCode") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId) || + !TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode)) + { + PostNamedPlaylistInvalidRequest(requestId, "load", programCode); + return; + } + + var service = _namedPlaylistPersistenceService; + if (service is null) + { + PostNamedPlaylistUnavailable(requestId, "load", programCode); + return; + } + + await ExecuteNamedPlaylistReadAsync( + requestId, + "load", + async cancellationToken => + { + var result = await service.LoadAsync(programCode, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PostMessage("named-playlist-load-results", new + { + requestId, + definition = new + { + result.Definition.ProgramCode, + result.Definition.Title + }, + result.RetrievedAt, + totalRowCount = result.Items.Count, + canMutate = service.CanMutate && !IsNamedPlaylistWriteQuarantined(), + writeQuarantined = IsNamedPlaylistWriteQuarantined(), + rows = result.Items.Select(item => new + { + item.ItemIndex, + enabled = item.IsEnabled, + item.Selection.GroupCode, + item.Selection.Subject, + item.Selection.GraphicType, + item.Selection.Subtype, + pageText = item.Page?.ToString() ?? string.Empty, + item.Selection.DataCode + }) + }); + }); + } + + private async Task HandleNamedPlaylistCreateRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var programCode = GetString(payload, "programCode") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "programCode", "title") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId) || + !TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode) || + !TryGetNamedPlaylistText( + payload, + "title", + maximumLength: 128, + allowEmpty: false, + allowCaret: true, + out var title)) + { + PostNamedPlaylistInvalidRequest(requestId, "create", programCode); + return; + } + + await ExecuteNamedPlaylistWriteAsync( + requestId, + "create", + programCode, + (service, cancellationToken) => service.CreateDefinitionAsync( + programCode, + title, + cancellationToken)); + } + + private async Task HandleNamedPlaylistReplaceRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var programCode = GetString(payload, "programCode") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "programCode", "items") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId) || + !TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode) || + !TryParseNamedPlaylistItems(payload, out var items)) + { + PostNamedPlaylistInvalidRequest(requestId, "replace", programCode); + return; + } + + await ExecuteNamedPlaylistWriteAsync( + requestId, + "replace", + programCode, + (service, cancellationToken) => service.ReplaceItemsAsync( + programCode, + items, + cancellationToken)); + } + + private async Task HandleNamedPlaylistDeleteRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var programCode = GetString(payload, "programCode") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "programCode") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId) || + !TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode)) + { + PostNamedPlaylistInvalidRequest(requestId, "delete", programCode); + return; + } + + await ExecuteNamedPlaylistWriteAsync( + requestId, + "delete", + programCode, + (service, cancellationToken) => service.DeleteAsync( + programCode, + cancellationToken)); + } + + private async Task ExecuteNamedPlaylistReadAsync( + string requestId, + string operation, + Func operationBody) + { + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var browserGeneration = Volatile.Read(ref _namedPlaylistBrowserGeneration); + var previousCancellation = Interlocked.Exchange( + ref _namedPlaylistReadCancellation, + requestCancellation); + CancelRequest(previousCancellation); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostNamedPlaylistReadCancellationIfCurrent( + requestId, + operation, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostNamedPlaylistReadCancellationIfCurrent( + requestId, + operation, + requestCancellation); + return; + } + + if (browserGeneration != Volatile.Read(ref _namedPlaylistBrowserGeneration)) + { + requestCancellation.Cancel(); + return; + } + + await operationBody(requestToken); + requestToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostNamedPlaylistReadCancellationIfCurrent( + requestId, + operation, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostNamedPlaylistReadCancellationIfCurrent( + requestId, + operation, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostNamedPlaylistReadCancellationIfCurrent( + requestId, + operation, + requestCancellation); + } + catch (NamedPlaylistNotFoundException) + { + PostNamedPlaylistError( + requestId, + operation, + "NOT_FOUND", + "The named playlist does not exist.", + retryable: false, + outcomeUnknown: false); + } + catch (NamedPlaylistDataException) + { + PostNamedPlaylistError( + requestId, + operation, + "INVALID_DATA", + "The stored named-playlist data is invalid.", + retryable: false, + outcomeUnknown: false); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (ArgumentException) + { + PostNamedPlaylistInvalidRequest(requestId, operation); + } + catch (DatabaseOperationException exception) + { + PostNamedPlaylistError( + requestId, + operation, + "DATABASE_ERROR", + exception.Message, + retryable: true, + outcomeUnknown: false); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostNamedPlaylistError( + requestId, + operation, + "DATABASE_ERROR", + "The named-playlist database request failed.", + retryable: true, + outcomeUnknown: false); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _namedPlaylistReadCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private async Task ExecuteNamedPlaylistWriteAsync( + string requestId, + string operation, + string programCode, + Func operationBody) + { + var service = _namedPlaylistPersistenceService; + if (service is null || !service.CanMutate) + { + PostNamedPlaylistUnavailable(requestId, operation, programCode); + return; + } + + if (IsNamedPlaylistWriteQuarantined()) + { + PostNamedPlaylistError( + requestId, + operation, + "OUTCOME_UNKNOWN", + Volatile.Read(ref _namedPlaylistWriteQuarantineMessage) ?? + NamedPlaylistCorrelationQuarantineMessage, + retryable: false, + outcomeUnknown: true, + programCode); + return; + } + + if (!await _namedPlaylistWriteGate.WaitAsync(0)) + { + PostNamedPlaylistError( + requestId, + operation, + "WRITE_BUSY", + "Another named-playlist write is already running.", + retryable: false, + outcomeUnknown: false, + programCode); + return; + } + + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var browserGeneration = Volatile.Read(ref _namedPlaylistBrowserGeneration); + Volatile.Write(ref _namedPlaylistWriteCancellation, requestCancellation); + var databaseActivityEntered = false; + var mutationStarted = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostNamedPlaylistWriteCancellationIfCurrent( + requestId, + operation, + programCode, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostNamedPlaylistWriteCancellationIfCurrent( + requestId, + operation, + programCode, + requestCancellation); + return; + } + + if (IsNamedPlaylistWriteQuarantined()) + { + PostNamedPlaylistError( + requestId, + operation, + "OUTCOME_UNKNOWN", + Volatile.Read(ref _namedPlaylistWriteQuarantineMessage) ?? + NamedPlaylistCorrelationQuarantineMessage, + retryable: false, + outcomeUnknown: true, + programCode); + return; + } + + mutationStarted = true; + Interlocked.Exchange(ref _namedPlaylistWriteInFlight, 1); + if (browserGeneration != Volatile.Read(ref _namedPlaylistBrowserGeneration)) + { + mutationStarted = false; + requestCancellation.Cancel(); + return; + } + + await operationBody(service, requestToken); + if (browserGeneration != Volatile.Read(ref _namedPlaylistBrowserGeneration) || + !ReferenceEquals( + Volatile.Read(ref _namedPlaylistWriteCancellation), + requestCancellation)) + { + LatchNamedPlaylistWriteQuarantine( + NamedPlaylistCorrelationQuarantineMessage); + return; + } + + PostMessage("named-playlist-mutation-results", new + { + requestId, + operation, + programCode, + completedAt = DateTimeOffset.UtcNow, + writeQuarantined = IsNamedPlaylistWriteQuarantined() + }); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + // App shutdown owns the cancellation and no Web document can receive it. + } + catch (NamedPlaylistMutationException exception) + { + if (exception.OutcomeUnknown) + { + LatchNamedPlaylistWriteQuarantine(exception.Message); + PostNamedPlaylistWriteErrorIfCurrent( + requestId, + operation, + programCode, + "OUTCOME_UNKNOWN", + NamedPlaylistCorrelationQuarantineMessage, + outcomeUnknown: true, + requestCancellation); + } + else if (requestToken.IsCancellationRequested) + { + PostNamedPlaylistWriteCancellationIfCurrent( + requestId, + operation, + programCode, + requestCancellation); + } + else + { + PostNamedPlaylistWriteErrorIfCurrent( + requestId, + operation, + programCode, + "WRITE_FAILED", + "The named-playlist write rolled back and was not retried.", + outcomeUnknown: false, + requestCancellation); + } + } + catch (NamedPlaylistNotFoundException) + { + PostNamedPlaylistWriteErrorIfCurrent( + requestId, + operation, + programCode, + "NOT_FOUND", + "The named playlist does not exist.", + outcomeUnknown: false, + requestCancellation); + } + catch (ArgumentException) + { + PostNamedPlaylistWriteErrorIfCurrent( + requestId, + operation, + programCode, + "INVALID_REQUEST", + "The named-playlist write request is invalid.", + outcomeUnknown: false, + requestCancellation); + } + catch (OperationCanceledException) + { + if (mutationStarted) + { + LatchNamedPlaylistWriteQuarantine( + NamedPlaylistCorrelationQuarantineMessage); + PostNamedPlaylistWriteErrorIfCurrent( + requestId, + operation, + programCode, + "OUTCOME_UNKNOWN", + NamedPlaylistCorrelationQuarantineMessage, + outcomeUnknown: true, + requestCancellation); + } + else + { + PostNamedPlaylistWriteCancellationIfCurrent( + requestId, + operation, + programCode, + requestCancellation); + } + } + catch + { + if (mutationStarted) + { + LatchNamedPlaylistWriteQuarantine( + NamedPlaylistCorrelationQuarantineMessage); + } + + PostNamedPlaylistWriteErrorIfCurrent( + requestId, + operation, + programCode, + mutationStarted ? "OUTCOME_UNKNOWN" : "WRITE_FAILED", + mutationStarted + ? NamedPlaylistCorrelationQuarantineMessage + : "The named-playlist write failed before a transaction began.", + outcomeUnknown: mutationStarted, + requestCancellation); + } + finally + { + Interlocked.Exchange(ref _namedPlaylistWriteInFlight, 0); + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _namedPlaylistWriteCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + _namedPlaylistWriteGate.Release(); + } + } + + private void PostNamedPlaylistReadCancellationIfCurrent( + string requestId, + string operation, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _namedPlaylistReadCancellation), + requestCancellation)) + { + return; + } + + PostNamedPlaylistError( + requestId, + operation, + "PLAYOUT_PRIORITY", + "The named-playlist read was canceled because playout has database priority.", + retryable: true, + outcomeUnknown: false); + } + + private void PostNamedPlaylistWriteCancellationIfCurrent( + string requestId, + string operation, + string programCode, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _namedPlaylistWriteCancellation), + requestCancellation)) + { + return; + } + + PostNamedPlaylistError( + requestId, + operation, + "PLAYOUT_PRIORITY", + "The named-playlist write stopped before execution because playout has database priority.", + retryable: false, + outcomeUnknown: false, + programCode); + } + + private void PostNamedPlaylistWriteErrorIfCurrent( + string requestId, + string operation, + string programCode, + string code, + string message, + bool outcomeUnknown, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _namedPlaylistWriteCancellation), + requestCancellation)) + { + return; + } + + PostNamedPlaylistError( + requestId, + operation, + code, + message, + retryable: false, + outcomeUnknown, + programCode); + } + + private void PostNamedPlaylistInvalidRequest( + string requestId, + string operation, + string programCode = "") => + PostNamedPlaylistError( + requestId, + operation, + "INVALID_REQUEST", + "The named-playlist request is invalid.", + retryable: false, + outcomeUnknown: false, + programCode); + + private void PostNamedPlaylistUnavailable( + string requestId, + string operation, + string programCode = "") => + PostNamedPlaylistError( + requestId, + operation, + "DATABASE_UNAVAILABLE", + _databaseInitializationError ?? "The named-playlist database is unavailable.", + retryable: false, + outcomeUnknown: false, + programCode); + + private void PostNamedPlaylistError( + string requestId, + string operation, + string code, + string message, + bool retryable, + bool outcomeUnknown, + string programCode = "") + { + PostMessage("named-playlist-error", new + { + requestId, + operation, + programCode, + code, + message, + retryable = retryable && !outcomeUnknown, + outcomeUnknown, + writeQuarantined = IsNamedPlaylistWriteQuarantined() + }); + } + + private bool IsNamedPlaylistWriteQuarantined() => + Volatile.Read(ref _namedPlaylistWriteQuarantined) != 0 || + IsOperatorMutationQuarantined(); + + private void LatchNamedPlaylistWriteQuarantine(string message) + { + LatchOperatorMutationQuarantine(message); + if (Interlocked.CompareExchange(ref _namedPlaylistWriteQuarantined, 1, 0) == 0) + { + Volatile.Write( + ref _namedPlaylistWriteQuarantineMessage, + string.IsNullOrWhiteSpace(message) + ? NamedPlaylistCorrelationQuarantineMessage + : message); + } + } + + private void InvalidateNamedPlaylistBrowserRequests() + { + Interlocked.Increment(ref _namedPlaylistBrowserGeneration); + if (Volatile.Read(ref _namedPlaylistWriteInFlight) != 0) + { + LatchNamedPlaylistWriteQuarantine( + NamedPlaylistCorrelationQuarantineMessage); + } + + var readCancellation = Interlocked.Exchange( + ref _namedPlaylistReadCancellation, + null); + CancelRequest(readCancellation); + var writeCancellation = Interlocked.Exchange( + ref _namedPlaylistWriteCancellation, + null); + CancelRequest(writeCancellation); + } + + private static bool TryParseNamedPlaylistItems( + JsonElement payload, + out IReadOnlyList items) + { + items = Array.Empty(); + if (!payload.TryGetProperty("items", out var itemsElement) || + itemsElement.ValueKind != JsonValueKind.Array || + itemsElement.GetArrayLength() > LegacyNamedPlaylistPersistenceService.MaximumItems) + { + return false; + } + + var parsed = new List(itemsElement.GetArrayLength()); + var expectedIndex = 0; + foreach (var itemElement in itemsElement.EnumerateArray()) + { + if (itemElement.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + itemElement, + "itemIndex", + "enabled", + "groupCode", + "subject", + "graphicType", + "subtype", + "pageText", + "dataCode") || + !itemElement.TryGetProperty("itemIndex", out var indexElement) || + indexElement.ValueKind != JsonValueKind.Number || + !indexElement.TryGetInt32(out var itemIndex) || + itemIndex != expectedIndex || + !itemElement.TryGetProperty("enabled", out var enabledElement) || + enabledElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False) || + !TryGetNamedPlaylistText( + itemElement, + "groupCode", + 256, + allowEmpty: true, + allowCaret: false, + out var groupCode) || + !TryGetNamedPlaylistText( + itemElement, + "subject", + 256, + allowEmpty: true, + allowCaret: false, + out var subject) || + !TryGetNamedPlaylistText( + itemElement, + "graphicType", + 256, + allowEmpty: true, + allowCaret: false, + out var graphicType) || + !TryGetNamedPlaylistText( + itemElement, + "subtype", + 256, + allowEmpty: true, + allowCaret: false, + out var subtype) || + !TryGetNamedPlaylistText( + itemElement, + "pageText", + 9, + allowEmpty: true, + allowCaret: false, + out var pageText) || + !TryGetNamedPlaylistText( + itemElement, + "dataCode", + 256, + allowEmpty: true, + allowCaret: false, + out var dataCode) || + !TryParseNamedPlaylistPage(pageText, out var page)) + { + return false; + } + + parsed.Add(new NamedPlaylistStoredItem( + itemIndex, + enabledElement.GetBoolean(), + new LegacySceneSelection( + groupCode, + subject, + graphicType, + subtype, + dataCode), + page)); + expectedIndex++; + } + + items = parsed.AsReadOnly(); + return true; + } + + private static bool TryGetNamedPlaylistProgramCode( + JsonElement payload, + string propertyName, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return value.Length == 8 && value.All(char.IsAsciiDigit); + } + + private static bool TryGetNamedPlaylistText( + JsonElement payload, + string propertyName, + int maximumLength, + bool allowEmpty, + bool allowCaret, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + if (!payload.TryGetProperty(propertyName, out var element) || + element.ValueKind != JsonValueKind.String || + (!allowEmpty && value.Length == 0) || + value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + (!allowCaret && value.Contains('^'))) + { + return false; + } + + return !value.Any(static character => + char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD'); + } + + private static bool TryParseNamedPlaylistPage( + string value, + out NamedPlaylistPageState? page) + { + page = null; + if (value.Length == 0) + { + return true; + } + + var separator = value.IndexOf('/'); + if (separator < 1 || separator != value.LastIndexOf('/') || + !TryParseUnsignedPageNumber(value.AsSpan(0, separator), out var currentPage) || + !TryParseUnsignedPageNumber(value.AsSpan(separator + 1), out var totalPages)) + { + return false; + } + + try + { + page = new NamedPlaylistPageState(currentPage, totalPages); + return string.Equals(page.ToString(), value, StringComparison.Ordinal); + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static bool TryParseUnsignedPageNumber( + ReadOnlySpan value, + out int number) + { + number = 0; + if (value.Length is < 1 or > 4) + { + return false; + } + + foreach (var character in value) + { + if (!char.IsAsciiDigit(character)) + { + return false; + } + + number = checked((number * 10) + (character - '0')); + } + + return true; + } +} diff --git a/MainWindow.OperatorCatalogs.cs b/MainWindow.OperatorCatalogs.cs new file mode 100644 index 0000000..5cadd4d --- /dev/null +++ b/MainWindow.OperatorCatalogs.cs @@ -0,0 +1,1712 @@ +using System.Globalization; +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private const string OperatorCatalogQuarantineMessage = + "A theme/expert database write result could not be confirmed. Restart the app and reconcile the database before another catalog write."; + private const decimal OperatorCatalogMaximumWebSafeInteger = 9_007_199_254_740_991m; + + private readonly SemaphoreSlim _operatorCatalogWriteGate = new(1, 1); + private readonly object _operatorCatalogReadLanesGate = new(); + private readonly Dictionary _operatorCatalogReadLanes = + new(StringComparer.Ordinal); + private IThemeCatalogPersistenceService? _themeCatalogPersistenceService; + private IExpertCatalogPersistenceService? _expertCatalogPersistenceService; + private IOperatorCatalogSchemaValidationService? _operatorCatalogSchemaValidationService; + private CancellationTokenSource? _operatorCatalogWriteCancellation; + private string? _operatorCatalogInitializationError; + private string? _operatorCatalogWriteQuarantineMessage; + private int _operatorCatalogRuntimeState; + private int _operatorCatalogWriteInFlight; + private int _operatorCatalogWriteQuarantined; + private int _operatorCatalogBrowserGeneration; + + /// + /// Root integration hook. It is safe to call after InitializeDatabaseRuntime; + /// request handlers also call it lazily so a missing optional eager call fails + /// closed rather than dereferencing an uninitialized service. + /// + private void InitializeOperatorCatalogRuntime() + { + if (Volatile.Read(ref _operatorCatalogRuntimeState) != 0) + { + return; + } + + var runtime = _databaseRuntime; + if (runtime is null || + Interlocked.CompareExchange(ref _operatorCatalogRuntimeState, 1, 0) != 0) + { + return; + } + + try + { + var mutationExecutor = new OperatorCatalogMutationExecutor( + runtime.ConnectionFactory, + runtime.Options.Resilience); + _themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService( + runtime.Executor, + mutationExecutor); + _expertCatalogPersistenceService = new LegacyExpertCatalogPersistenceService( + runtime.Executor, + mutationExecutor); + _operatorCatalogSchemaValidationService = + new LegacyOperatorCatalogSchemaValidationService(runtime.Executor); + } + catch + { + _themeCatalogPersistenceService = null; + _expertCatalogPersistenceService = null; + _operatorCatalogSchemaValidationService = null; + _operatorCatalogInitializationError = + "The theme/expert database boundary could not be initialized."; + Volatile.Write(ref _operatorCatalogRuntimeState, -1); + } + } + + /// + /// Root WebMessage switch hook. Return true means the request type belongs to + /// this boundary, including malformed payloads which receive a correlated + /// INVALID_REQUEST response here. + /// + private bool TryHandleOperatorCatalogRequest(string? requestType, JsonElement payload) + { + switch (requestType) + { + case "request-theme-catalog-list": + _ = HandleThemeCatalogListRequestAsync(payload); + return true; + case "request-expert-catalog-list": + _ = HandleExpertCatalogListRequestAsync(payload); + return true; + case "request-operator-catalog-schema": + _ = HandleOperatorCatalogSchemaRequestAsync(payload); + return true; + case "request-theme-catalog-next-code": + _ = HandleThemeCatalogNextCodeRequestAsync(payload); + return true; + case "request-expert-catalog-next-code": + _ = HandleExpertCatalogNextCodeRequestAsync(payload); + return true; + case "create-theme-catalog": + _ = HandleThemeCatalogCreateRequestAsync(payload); + return true; + case "replace-theme-catalog": + _ = HandleThemeCatalogReplaceRequestAsync(payload); + return true; + case "delete-theme-catalog": + _ = HandleThemeCatalogDeleteRequestAsync(payload); + return true; + case "create-expert-catalog": + _ = HandleExpertCatalogCreateRequestAsync(payload); + return true; + case "replace-expert-catalog": + _ = HandleExpertCatalogReplaceRequestAsync(payload); + return true; + case "delete-expert-catalog": + _ = HandleExpertCatalogDeleteRequestAsync(payload); + return true; + default: + return false; + } + } + + private async Task HandleThemeCatalogListRequestAsync(JsonElement payload) + { + if (!TryParseThemeCatalogListRequest( + payload, + out var requestId, + out var query, + out var nxtSession, + out var maximumResults)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "theme", + "list"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _themeSelectionService; + if (service is null) + { + PostOperatorCatalogUnavailable(requestId, "theme", "list"); + return; + } + + await ExecuteOperatorCatalogReadAsync( + requestId, + "theme", + "list", + async cancellationToken => + { + var result = await service.SearchAsync( + query, + nxtSession, + maximumResults, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new OperatorCatalogBridgeReply( + "theme-catalog-list-results", + new + { + requestId, + query = result.Query, + nxtSession = ToWireValue(result.NxtSession), + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + canMutate = CanMutateThemeCatalog(), + writeQuarantined = IsOperatorCatalogWriteQuarantined(), + results = result.Items.Select(item => new + { + market = ToWireValue(item.Identity.Market), + session = ToWireValue(item.Identity.Session), + themeCode = item.Identity.ThemeCode, + themeTitle = item.Identity.ThemeTitle, + source = ToWireValue(item.Source) + }).ToArray() + }); + }); + } + + private async Task HandleExpertCatalogListRequestAsync(JsonElement payload) + { + if (!TryParseExpertCatalogListRequest( + payload, + out var requestId, + out var query, + out var maximumResults)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "expert", + "list"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _expertSelectionService; + if (service is null) + { + PostOperatorCatalogUnavailable(requestId, "expert", "list"); + return; + } + + await ExecuteOperatorCatalogReadAsync( + requestId, + "expert", + "list", + async cancellationToken => + { + var result = await service.SearchAsync( + query, + maximumResults, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new OperatorCatalogBridgeReply( + "expert-catalog-list-results", + new + { + requestId, + query = result.Query, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + canMutate = CanMutateExpertCatalog(), + writeQuarantined = IsOperatorCatalogWriteQuarantined(), + results = result.Items.Select(item => new + { + expertCode = item.Identity.ExpertCode, + expertName = item.Identity.ExpertName, + source = ToWireValue(item.Source) + }).ToArray() + }); + }); + } + + private async Task HandleOperatorCatalogSchemaRequestAsync(JsonElement payload) + { + if (!TryParseOperatorCatalogRequestIdOnly(payload, out var requestId)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "catalog", + "schemaPreflight"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _operatorCatalogSchemaValidationService; + if (service is null) + { + PostOperatorCatalogUnavailable( + requestId, + "catalog", + "schemaPreflight"); + return; + } + + await ExecuteOperatorCatalogReadAsync( + requestId, + "catalog", + "schemaPreflight", + async cancellationToken => + { + var result = await service.ValidateAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new OperatorCatalogBridgeReply( + "operator-catalog-schema-results", + new + { + requestId, + result.ValidatedAt, + validatedSources = result.ValidatedSources + .Select(ToWireValue) + .ToArray(), + themeCanMutate = CanMutateThemeCatalog(), + expertCanMutate = CanMutateExpertCatalog(), + writeQuarantined = IsOperatorCatalogWriteQuarantined() + }); + }); + } + + private async Task HandleThemeCatalogNextCodeRequestAsync(JsonElement payload) + { + if (!TryParseThemeCatalogNextCodeRequest( + payload, + out var requestId, + out var market)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "theme", + "nextCode"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _themeCatalogPersistenceService; + if (service is null) + { + PostOperatorCatalogUnavailable(requestId, "theme", "nextCode"); + return; + } + + await ExecuteOperatorCatalogReadAsync( + requestId, + "theme", + "nextCode", + async cancellationToken => + { + var code = await service.SuggestNextCodeAsync(market, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new OperatorCatalogBridgeReply( + "theme-catalog-next-code-results", + new + { + requestId, + market = ToWireValue(market), + themeCode = code, + canMutate = CanMutateThemeCatalog(), + writeQuarantined = IsOperatorCatalogWriteQuarantined() + }); + }); + } + + private async Task HandleExpertCatalogNextCodeRequestAsync(JsonElement payload) + { + if (!TryParseOperatorCatalogRequestIdOnly(payload, out var requestId)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "expert", + "nextCode"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _expertCatalogPersistenceService; + if (service is null) + { + PostOperatorCatalogUnavailable(requestId, "expert", "nextCode"); + return; + } + + await ExecuteOperatorCatalogReadAsync( + requestId, + "expert", + "nextCode", + async cancellationToken => + { + var code = await service.SuggestNextCodeAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new OperatorCatalogBridgeReply( + "expert-catalog-next-code-results", + new + { + requestId, + expertCode = code, + canMutate = CanMutateExpertCatalog(), + writeQuarantined = IsOperatorCatalogWriteQuarantined() + }); + }); + } + + private async Task HandleThemeCatalogCreateRequestAsync(JsonElement payload) + { + if (!TryParseThemeCatalogCreateRequest( + payload, + out var requestId, + out var definition)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "theme", + "create"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _themeCatalogPersistenceService; + await ExecuteOperatorCatalogWriteAsync( + requestId, + "theme", + "create", + definition.ThemeCode, + service?.CanMutate == true, + cancellationToken => service!.CreateAsync(definition, cancellationToken)); + } + + private async Task HandleThemeCatalogReplaceRequestAsync(JsonElement payload) + { + if (!TryParseThemeCatalogReplaceRequest( + payload, + out var requestId, + out var expectedIdentity, + out var newTitle, + out var items)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "theme", + "replace"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _themeCatalogPersistenceService; + await ExecuteOperatorCatalogWriteAsync( + requestId, + "theme", + "replace", + expectedIdentity.ThemeCode, + service?.CanMutate == true, + cancellationToken => service!.ReplaceAsync( + expectedIdentity, + newTitle, + items, + cancellationToken)); + } + + private async Task HandleThemeCatalogDeleteRequestAsync(JsonElement payload) + { + if (!TryParseThemeCatalogDeleteRequest( + payload, + out var requestId, + out var identity)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "theme", + "delete"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _themeCatalogPersistenceService; + await ExecuteOperatorCatalogWriteAsync( + requestId, + "theme", + "delete", + identity.ThemeCode, + service?.CanMutate == true, + cancellationToken => service!.DeleteAsync(identity, cancellationToken)); + } + + private async Task HandleExpertCatalogCreateRequestAsync(JsonElement payload) + { + if (!TryParseExpertCatalogCreateRequest( + payload, + out var requestId, + out var definition)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "expert", + "create"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _expertCatalogPersistenceService; + await ExecuteOperatorCatalogWriteAsync( + requestId, + "expert", + "create", + definition.Identity.ExpertCode, + service?.CanMutate == true, + cancellationToken => service!.CreateAsync(definition, cancellationToken)); + } + + private async Task HandleExpertCatalogReplaceRequestAsync(JsonElement payload) + { + if (!TryParseExpertCatalogReplaceRequest( + payload, + out var requestId, + out var expectedIdentity, + out var newName, + out var recommendations)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "expert", + "replace"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _expertCatalogPersistenceService; + await ExecuteOperatorCatalogWriteAsync( + requestId, + "expert", + "replace", + expectedIdentity.ExpertCode, + service?.CanMutate == true, + cancellationToken => service!.ReplaceAsync( + expectedIdentity, + newName, + recommendations, + cancellationToken)); + } + + private async Task HandleExpertCatalogDeleteRequestAsync(JsonElement payload) + { + if (!TryParseExpertCatalogDeleteRequest( + payload, + out var requestId, + out var identity)) + { + PostOperatorCatalogInvalidRequest( + SafeOperatorCatalogRequestId(payload), + "expert", + "delete"); + return; + } + + InitializeOperatorCatalogRuntime(); + var service = _expertCatalogPersistenceService; + await ExecuteOperatorCatalogWriteAsync( + requestId, + "expert", + "delete", + identity.ExpertCode, + service?.CanMutate == true, + cancellationToken => service!.DeleteAsync(identity, cancellationToken)); + } + + private async Task ExecuteOperatorCatalogReadAsync( + string requestId, + string entity, + string operation, + Func> operationBody) + { + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var browserGeneration = Volatile.Read(ref _operatorCatalogBrowserGeneration); + var request = new OperatorCatalogReadRequest( + $"{entity}:{operation}", + requestId, + entity, + operation, + browserGeneration, + requestCancellation); + RegisterOperatorCatalogRead(request); + var databaseActivityEntered = false; + + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostOperatorCatalogReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The catalog read was canceled because playout has database priority."); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostOperatorCatalogReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The catalog read was canceled because playout has database priority."); + return; + } + + if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration)) + { + requestCancellation.Cancel(); + return; + } + + var reply = await operationBody(requestToken); + requestToken.ThrowIfCancellationRequested(); + if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration) || + !TryCompleteOperatorCatalogReadIfCurrent(request)) + { + return; + } + + PostMessage(reply.MessageType, reply.Payload); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostOperatorCatalogReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The catalog read was canceled because playout has database priority."); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostOperatorCatalogReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The catalog read was canceled because playout has database priority."); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostOperatorCatalogReadCancellationIfCurrent( + request, + "PLAYOUT_PRIORITY", + "The catalog read was canceled because playout has database priority."); + } + catch (ArgumentException) + { + PostOperatorCatalogReadErrorIfCurrent( + request, + "INVALID_REQUEST", + "The theme/expert catalog request is invalid.", + retryable: false); + } + catch (ThemeSelectionDataException) + { + PostOperatorCatalogReadErrorIfCurrent( + request, + "INVALID_DATA", + "Theme catalog data is ambiguous or invalid.", + retryable: false); + } + catch (ExpertSelectionDataException) + { + PostOperatorCatalogReadErrorIfCurrent( + request, + "INVALID_DATA", + "Expert catalog data is ambiguous or invalid.", + retryable: false); + } + catch (OperatorCatalogSchemaException) + { + PostOperatorCatalogReadErrorIfCurrent( + request, + "INVALID_SCHEMA", + "The theme/expert database schema does not match the migrated contract.", + retryable: false); + } + catch (DatabaseOperationException exception) + { + PostOperatorCatalogReadErrorIfCurrent( + request, + "DATABASE_ERROR", + exception.Message, + retryable: true); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostOperatorCatalogReadErrorIfCurrent( + request, + "DATABASE_ERROR", + "The theme/expert database read failed.", + retryable: true); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + ReleaseOperatorCatalogRead(request); + requestCancellation.Dispose(); + } + } + + private async Task ExecuteOperatorCatalogWriteAsync( + string requestId, + string entity, + string operation, + string identityCode, + bool canMutate, + Func> operationBody) + { + if (!canMutate) + { + PostOperatorCatalogUnavailable(requestId, entity, operation, identityCode); + return; + } + + if (IsOperatorCatalogWriteQuarantined()) + { + PostOperatorCatalogQuarantined(requestId, entity, operation, identityCode); + return; + } + + if (!await _operatorCatalogWriteGate.WaitAsync(0)) + { + PostOperatorCatalogError( + requestId, + entity, + operation, + identityCode, + "WRITE_BUSY", + "Another theme/expert write is already running.", + retryable: false, + outcomeUnknown: false); + return; + } + + var playoutGateEntered = false; + var databaseActivityEntered = false; + var mutationDispatched = false; + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var browserGeneration = Volatile.Read(ref _operatorCatalogBrowserGeneration); + Volatile.Write(ref _operatorCatalogWriteCancellation, requestCancellation); + + try + { + playoutGateEntered = await _playoutCommandGate.WaitAsync(0, requestToken); + if (!playoutGateEntered) + { + PostOperatorCatalogPlayoutActive( + requestId, + entity, + operation, + identityCode); + return; + } + + if (!CanStartOperatorCatalogWrite()) + { + PostOperatorCatalogPlayoutActive( + requestId, + entity, + operation, + identityCode); + return; + } + + databaseActivityEntered = await _databaseActivityGate.WaitAsync(0, requestToken); + if (!databaseActivityEntered) + { + PostOperatorCatalogError( + requestId, + entity, + operation, + identityCode, + "DATABASE_BUSY", + "The database is busy. Refresh before making a new explicit write request.", + retryable: false, + outcomeUnknown: false); + return; + } + + requestToken.ThrowIfCancellationRequested(); + if (!CanStartOperatorCatalogWrite()) + { + PostOperatorCatalogPlayoutActive( + requestId, + entity, + operation, + identityCode); + return; + } + + if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration)) + { + requestCancellation.Cancel(); + return; + } + + mutationDispatched = true; + Interlocked.Exchange(ref _operatorCatalogWriteInFlight, 1); + var receipt = await operationBody(requestToken); + if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration) || + !ReferenceEquals( + Volatile.Read(ref _operatorCatalogWriteCancellation), + requestCancellation)) + { + LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage); + return; + } + + PostMessage("operator-catalog-mutation-results", new + { + requestId, + entity, + operation, + identityCode, + operationId = receipt.OperationId, + receipt.CommittedAt, + writeQuarantined = IsOperatorCatalogWriteQuarantined() + }); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + if (mutationDispatched) + { + LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage); + } + } + catch (OperatorCatalogConflictException) + { + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "CONFLICT", + "The catalog changed or a code/name is already in use. Refresh before a new edit.", + outcomeUnknown: false, + requestCancellation); + } + catch (OperatorCatalogMutationException exception) + { + if (exception.OutcomeUnknown) + { + LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage); + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "OUTCOME_UNKNOWN", + OperatorCatalogQuarantineMessage, + outcomeUnknown: true, + requestCancellation); + } + else + { + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "WRITE_FAILED", + "The database transaction rolled back and was not retried.", + outcomeUnknown: false, + requestCancellation); + } + } + catch (ArgumentException) + { + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "INVALID_REQUEST", + "The theme/expert write request is invalid.", + outcomeUnknown: false, + requestCancellation); + } + catch (OperationCanceledException) + { + if (mutationDispatched) + { + LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage); + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "OUTCOME_UNKNOWN", + OperatorCatalogQuarantineMessage, + outcomeUnknown: true, + requestCancellation); + } + else + { + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "CANCELLED", + "The write stopped before a database transaction began.", + outcomeUnknown: false, + requestCancellation); + } + } + catch + { + if (mutationDispatched) + { + LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage); + } + + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + mutationDispatched ? "OUTCOME_UNKNOWN" : "WRITE_FAILED", + mutationDispatched + ? OperatorCatalogQuarantineMessage + : "The write failed before a database transaction began.", + outcomeUnknown: mutationDispatched, + requestCancellation); + } + finally + { + Interlocked.Exchange(ref _operatorCatalogWriteInFlight, 0); + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + if (playoutGateEntered) + { + _playoutCommandGate.Release(); + } + + Interlocked.CompareExchange( + ref _operatorCatalogWriteCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + _operatorCatalogWriteGate.Release(); + } + } + + private bool CanStartOperatorCatalogWrite() + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 || + Volatile.Read(ref _playoutShutdownStarted) != 0 || + IsBrowserCorrelationQuarantined() || + IsOperatorCatalogWriteQuarantined()) + { + return false; + } + + return CanStartOperatorMutation(IsOperatorCatalogWriteQuarantined()); + } + + private void RegisterOperatorCatalogRead(OperatorCatalogReadRequest request) + { + OperatorCatalogReadRequest? superseded; + var shouldPostSuperseded = false; + lock (_operatorCatalogReadLanesGate) + { + _operatorCatalogReadLanes.TryGetValue(request.Lane, out superseded); + _operatorCatalogReadLanes[request.Lane] = request; + shouldPostSuperseded = superseded?.TryComplete() == true; + } + + if (superseded is null) + { + return; + } + + CancelRequest(superseded.Cancellation); + if (shouldPostSuperseded) + { + PostOperatorCatalogError( + superseded.RequestId, + superseded.Entity, + superseded.Operation, + string.Empty, + "CANCELLED", + "The catalog read was canceled because a newer request replaced it.", + retryable: true, + outcomeUnknown: false); + } + } + + private bool TryCompleteOperatorCatalogReadIfCurrent(OperatorCatalogReadRequest request) + { + lock (_operatorCatalogReadLanesGate) + { + return request.BrowserGeneration == + Volatile.Read(ref _operatorCatalogBrowserGeneration) && + _operatorCatalogReadLanes.TryGetValue(request.Lane, out var current) && + ReferenceEquals(current, request) && + request.TryComplete(); + } + } + + private void ReleaseOperatorCatalogRead(OperatorCatalogReadRequest request) + { + lock (_operatorCatalogReadLanesGate) + { + if (_operatorCatalogReadLanes.TryGetValue(request.Lane, out var current) && + ReferenceEquals(current, request)) + { + _operatorCatalogReadLanes.Remove(request.Lane); + } + } + } + + private OperatorCatalogReadRequest[] SnapshotOperatorCatalogReads(bool clear) + { + lock (_operatorCatalogReadLanesGate) + { + var requests = _operatorCatalogReadLanes.Values.ToArray(); + if (clear) + { + _operatorCatalogReadLanes.Clear(); + foreach (var request in requests) + { + _ = request.TryComplete(); + } + } + + return requests; + } + } + + private void PostOperatorCatalogReadCancellationIfCurrent( + OperatorCatalogReadRequest request, + string code, + string message) => + PostOperatorCatalogReadErrorIfCurrent( + request, + code, + message, + retryable: true); + + private void PostOperatorCatalogReadErrorIfCurrent( + OperatorCatalogReadRequest request, + string code, + string message, + bool retryable) + { + if (_lifetimeCancellation.IsCancellationRequested || + !TryCompleteOperatorCatalogReadIfCurrent(request)) + { + return; + } + + PostOperatorCatalogError( + request.RequestId, + request.Entity, + request.Operation, + string.Empty, + code, + message, + retryable, + outcomeUnknown: false); + } + + private void PostOperatorCatalogWriteErrorIfCurrent( + string requestId, + string entity, + string operation, + string identityCode, + string code, + string message, + bool outcomeUnknown, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _operatorCatalogWriteCancellation), + requestCancellation)) + { + return; + } + + PostOperatorCatalogError( + requestId, + entity, + operation, + identityCode, + code, + message, + retryable: false, + outcomeUnknown); + } + + private void PostOperatorCatalogInvalidRequest( + string requestId, + string entity, + string operation) => + PostOperatorCatalogError( + requestId, + entity, + operation, + string.Empty, + "INVALID_REQUEST", + "The theme/expert catalog request is invalid.", + retryable: false, + outcomeUnknown: false); + + private void PostOperatorCatalogUnavailable( + string requestId, + string entity, + string operation, + string identityCode = "") => + PostOperatorCatalogError( + requestId, + entity, + operation, + identityCode, + "DATABASE_UNAVAILABLE", + _operatorCatalogInitializationError ?? + _databaseInitializationError ?? + "The theme/expert database boundary is unavailable.", + retryable: false, + outcomeUnknown: false); + + private void PostOperatorCatalogPlayoutActive( + string requestId, + string entity, + string operation, + string identityCode) => + PostOperatorCatalogError( + requestId, + entity, + operation, + identityCode, + "PLAYOUT_ACTIVE", + "Theme/expert database writes are allowed only while playout is fully IDLE.", + retryable: false, + outcomeUnknown: false); + + private void PostOperatorCatalogQuarantined( + string requestId, + string entity, + string operation, + string identityCode) => + PostOperatorCatalogError( + requestId, + entity, + operation, + identityCode, + "OUTCOME_UNKNOWN", + GetOperatorMutationQuarantineMessage( + Volatile.Read(ref _operatorCatalogWriteQuarantineMessage) ?? + OperatorCatalogQuarantineMessage), + retryable: false, + outcomeUnknown: true); + + private void PostOperatorCatalogError( + string requestId, + string entity, + string operation, + string identityCode, + string code, + string message, + bool retryable, + bool outcomeUnknown) + { + PostMessage("operator-catalog-error", new + { + requestId, + entity, + operation, + identityCode, + code, + message, + retryable = retryable && !outcomeUnknown, + outcomeUnknown, + writeQuarantined = IsOperatorCatalogWriteQuarantined() + }); + } + + private bool CanMutateThemeCatalog() => + _themeCatalogPersistenceService?.CanMutate == true && + !IsOperatorCatalogWriteQuarantined(); + + private bool CanMutateExpertCatalog() => + _expertCatalogPersistenceService?.CanMutate == true && + !IsOperatorCatalogWriteQuarantined(); + + private bool IsOperatorCatalogWriteQuarantined() => + Volatile.Read(ref _operatorCatalogWriteQuarantined) != 0 || + IsOperatorMutationQuarantined(); + + private void LatchOperatorCatalogWriteQuarantine(string message) + { + LatchOperatorMutationQuarantine(message); + if (Interlocked.CompareExchange(ref _operatorCatalogWriteQuarantined, 1, 0) == 0) + { + Volatile.Write( + ref _operatorCatalogWriteQuarantineMessage, + string.IsNullOrWhiteSpace(message) + ? OperatorCatalogQuarantineMessage + : message); + } + } + + /// + /// Root browser-navigation/process-failure hook. Losing JavaScript request + /// correlation while a write is active permanently quarantines writes for + /// this process and never retries the command. + /// + private void InvalidateOperatorCatalogBrowserRequests() + { + Interlocked.Increment(ref _operatorCatalogBrowserGeneration); + if (Volatile.Read(ref _operatorCatalogWriteInFlight) != 0) + { + LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage); + } + + foreach (var request in SnapshotOperatorCatalogReads(clear: true)) + { + CancelRequest(request.Cancellation); + } + + CancelRequest(Interlocked.Exchange(ref _operatorCatalogWriteCancellation, null)); + } + + /// + /// Root playout-priority hook. A catalog write owns the playout gate and + /// therefore cannot reach this point concurrently; only read work is canceled. + /// + private void CancelOperatorCatalogReadsForPlayout() + { + foreach (var request in SnapshotOperatorCatalogReads(clear: false)) + { + CancelRequest(request.Cancellation); + } + } + + /// Root window-close hook. + private void ShutdownOperatorCatalogRuntime() + { + if (Volatile.Read(ref _operatorCatalogWriteInFlight) != 0) + { + LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage); + } + + foreach (var request in SnapshotOperatorCatalogReads(clear: true)) + { + CancelRequest(request.Cancellation); + } + + CancelRequest(Interlocked.Exchange(ref _operatorCatalogWriteCancellation, null)); + _themeCatalogPersistenceService = null; + _expertCatalogPersistenceService = null; + _operatorCatalogSchemaValidationService = null; + } + + private static bool TryParseThemeCatalogListRequest( + JsonElement payload, + out string requestId, + out string query, + out ThemeSession nxtSession, + out int maximumResults) + { + requestId = string.Empty; + query = string.Empty; + nxtSession = default; + maximumResults = LegacyThemeSelectionService.DefaultMaximumResults; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "query", "nxtSession", "maximumResults") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + TryGetOperatorCatalogQuery( + payload, + "query", + LegacyThemeSelectionService.MaximumQueryLength, + out query) && + TryParseThemeNxtSession(GetString(payload, "nxtSession"), out nxtSession) && + TryGetOptionalOperatorCatalogLimit( + payload, + LegacyThemeSelectionService.MaximumResults, + ref maximumResults); + } + + private static bool TryParseExpertCatalogListRequest( + JsonElement payload, + out string requestId, + out string query, + out int maximumResults) + { + requestId = string.Empty; + query = string.Empty; + maximumResults = LegacyExpertSelectionService.DefaultMaximumResults; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "query", "maximumResults") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + TryGetOperatorCatalogQuery( + payload, + "query", + LegacyExpertSelectionService.MaximumQueryLength, + out query) && + TryGetOptionalOperatorCatalogLimit( + payload, + LegacyExpertSelectionService.MaximumResults, + ref maximumResults); + } + + private static bool TryParseOperatorCatalogRequestIdOnly( + JsonElement payload, + out string requestId) + { + requestId = string.Empty; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId); + } + + private static bool TryParseThemeCatalogNextCodeRequest( + JsonElement payload, + out string requestId, + out ThemeMarket market) + { + requestId = string.Empty; + market = default; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "market") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + TryParseThemeMarket(GetString(payload, "market"), out market); + } + + private static bool TryParseThemeCatalogCreateRequest( + JsonElement payload, + out string requestId, + out ThemeCatalogDefinition definition) + { + requestId = string.Empty; + definition = null!; + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "draft") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !payload.TryGetProperty("draft", out var draft) || + draft.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(draft, "market", "themeCode", "themeTitle", "items") || + !TryParseThemeMarket(GetString(draft, "market"), out var market) || + !TryGetOperatorCatalogCode(draft, "themeCode", 8, out var code) || + !TryGetOperatorCatalogText(draft, "themeTitle", 128, out var title) || + market == ThemeMarket.Nxt && title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase) || + !TryParseThemeCatalogItems(draft, market, out var items)) + { + return false; + } + + definition = new ThemeCatalogDefinition(market, code, title, items); + return true; + } + + private static bool TryParseThemeCatalogReplaceRequest( + JsonElement payload, + out string requestId, + out ThemeSelectionIdentity expectedIdentity, + out string newTitle, + out IReadOnlyList items) + { + requestId = string.Empty; + expectedIdentity = null!; + newTitle = string.Empty; + items = Array.Empty(); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "expectedIdentity", "newTitle", "items") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !payload.TryGetProperty("expectedIdentity", out var identityElement) || + !TryParseThemeCatalogIdentity(identityElement, out expectedIdentity) || + !TryGetOperatorCatalogText(payload, "newTitle", 128, out newTitle) || + expectedIdentity.Market == ThemeMarket.Nxt && + newTitle.Contains("(NXT)", StringComparison.OrdinalIgnoreCase) || + !TryParseThemeCatalogItems(payload, expectedIdentity.Market, out items)) + { + return false; + } + + return true; + } + + private static bool TryParseThemeCatalogDeleteRequest( + JsonElement payload, + out string requestId, + out ThemeSelectionIdentity identity) + { + requestId = string.Empty; + identity = null!; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "identity") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + payload.TryGetProperty("identity", out var identityElement) && + TryParseThemeCatalogIdentity(identityElement, out identity); + } + + private static bool TryParseThemeCatalogIdentity( + JsonElement element, + out ThemeSelectionIdentity identity) + { + identity = null!; + if (element.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(element, "market", "session", "themeCode", "themeTitle") || + !TryParseThemeMarket(GetString(element, "market"), out var market) || + !TryParseThemeSession(GetString(element, "session"), out var session) || + market == ThemeMarket.Krx && session != ThemeSession.NotApplicable || + market == ThemeMarket.Nxt && + session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket) || + !TryGetOperatorCatalogCode(element, "themeCode", 8, out var code) || + !TryGetOperatorCatalogText(element, "themeTitle", 128, out var title) || + market == ThemeMarket.Nxt && title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + identity = new ThemeSelectionIdentity(market, session, code, title); + return true; + } + + private static bool TryParseThemeCatalogItems( + JsonElement parent, + ThemeMarket market, + out IReadOnlyList items) + { + items = Array.Empty(); + if (!parent.TryGetProperty("items", out var array) || + array.ValueKind != JsonValueKind.Array || + array.GetArrayLength() > LegacyThemeCatalogPersistenceService.MaximumItems) + { + return false; + } + + var parsed = new List(array.GetArrayLength()); + var codes = new HashSet(StringComparer.Ordinal); + var names = new HashSet(StringComparer.Ordinal); + var expectedIndex = 0; + foreach (var element in array.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(element, "inputIndex", "itemCode", "itemName") || + !element.TryGetProperty("inputIndex", out var indexElement) || + indexElement.ValueKind != JsonValueKind.Number || + !indexElement.TryGetInt32(out var inputIndex) || + inputIndex != expectedIndex || + !TryGetOperatorCatalogText(element, "itemCode", 13, out var itemCode) || + !IsThemeItemCodeForMarket(itemCode, market) || + !TryGetOperatorCatalogText(element, "itemName", 200, out var itemName) || + !codes.Add(itemCode) || + !names.Add(itemName)) + { + return false; + } + + parsed.Add(new ThemeCatalogItem(inputIndex, itemCode, itemName)); + expectedIndex++; + } + + items = parsed.AsReadOnly(); + return true; + } + + private static bool TryParseExpertCatalogCreateRequest( + JsonElement payload, + out string requestId, + out ExpertCatalogDefinition definition) + { + requestId = string.Empty; + definition = null!; + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "draft") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !payload.TryGetProperty("draft", out var draft) || + draft.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(draft, "expertCode", "expertName", "recommendations") || + !TryGetOperatorCatalogCode(draft, "expertCode", 4, out var code) || + !TryGetOperatorCatalogText(draft, "expertName", 128, out var name) || + !TryParseExpertCatalogRecommendations(draft, out var recommendations)) + { + return false; + } + + definition = new ExpertCatalogDefinition( + new ExpertSelectionIdentity(code, name), + recommendations); + return true; + } + + private static bool TryParseExpertCatalogReplaceRequest( + JsonElement payload, + out string requestId, + out ExpertSelectionIdentity expectedIdentity, + out string newName, + out IReadOnlyList recommendations) + { + requestId = string.Empty; + expectedIdentity = null!; + newName = string.Empty; + recommendations = Array.Empty(); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + payload, + "requestId", + "expectedIdentity", + "newName", + "recommendations") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !payload.TryGetProperty("expectedIdentity", out var identityElement) || + !TryParseExpertCatalogIdentity(identityElement, out expectedIdentity) || + !TryGetOperatorCatalogText(payload, "newName", 128, out newName) || + !TryParseExpertCatalogRecommendations(payload, out recommendations)) + { + return false; + } + + return true; + } + + private static bool TryParseExpertCatalogDeleteRequest( + JsonElement payload, + out string requestId, + out ExpertSelectionIdentity identity) + { + requestId = string.Empty; + identity = null!; + return payload.ValueKind == JsonValueKind.Object && + HasOnlyProperties(payload, "requestId", "identity") && + TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) && + payload.TryGetProperty("identity", out var identityElement) && + TryParseExpertCatalogIdentity(identityElement, out identity); + } + + private static bool TryParseExpertCatalogIdentity( + JsonElement element, + out ExpertSelectionIdentity identity) + { + identity = null!; + if (element.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(element, "expertCode", "expertName") || + !TryGetOperatorCatalogCode(element, "expertCode", 4, out var code) || + !TryGetOperatorCatalogText(element, "expertName", 128, out var name)) + { + return false; + } + + identity = new ExpertSelectionIdentity(code, name); + return true; + } + + private static bool TryParseExpertCatalogRecommendations( + JsonElement parent, + out IReadOnlyList recommendations) + { + recommendations = Array.Empty(); + if (!parent.TryGetProperty("recommendations", out var array) || + array.ValueKind != JsonValueKind.Array || + array.GetArrayLength() > LegacyExpertCatalogPersistenceService.MaximumRecommendations) + { + return false; + } + + var parsed = new List(array.GetArrayLength()); + var codes = new HashSet(StringComparer.Ordinal); + var names = new HashSet(StringComparer.Ordinal); + var expectedIndex = 0; + foreach (var element in array.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + element, + "playIndex", + "stockCode", + "stockName", + "buyAmount") || + !element.TryGetProperty("playIndex", out var indexElement) || + indexElement.ValueKind != JsonValueKind.Number || + !indexElement.TryGetInt32(out var playIndex) || + playIndex != expectedIndex || + !TryGetOperatorCatalogText(element, "stockCode", 32, out var stockCode) || + !stockCode.All(char.IsAsciiLetterOrDigit) || + !TryGetOperatorCatalogText(element, "stockName", 200, out var stockName) || + !element.TryGetProperty("buyAmount", out var amountElement) || + amountElement.ValueKind != JsonValueKind.Number || + !amountElement.TryGetInt64(out var amount) || + amount <= 0 || + amount > OperatorCatalogMaximumWebSafeInteger || + !codes.Add(stockCode) || + !names.Add(stockName)) + { + return false; + } + + parsed.Add(new ExpertCatalogRecommendation( + playIndex, + stockCode, + stockName, + amount)); + expectedIndex++; + } + + recommendations = parsed.AsReadOnly(); + return true; + } + + private static bool TryGetOptionalOperatorCatalogLimit( + JsonElement payload, + int maximum, + ref int value) + { + if (!payload.TryGetProperty("maximumResults", out var element)) + { + return true; + } + + return element.ValueKind == JsonValueKind.Number && + element.TryGetInt32(out value) && + value is >= 1 && value <= maximum; + } + + private static bool TryGetOperatorCatalogQuery( + JsonElement payload, + string propertyName, + int maximumLength, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return payload.TryGetProperty(propertyName, out var element) && + element.ValueKind == JsonValueKind.String && + value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + IsSafeOperatorCatalogText(value); + } + + private static bool TryGetOperatorCatalogCode( + JsonElement payload, + string propertyName, + int length, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return value.Length == length && value.All(char.IsAsciiDigit); + } + + private static bool TryGetOperatorCatalogText( + JsonElement payload, + string propertyName, + int maximumLength, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return payload.TryGetProperty(propertyName, out var element) && + element.ValueKind == JsonValueKind.String && + value.Length is > 0 && value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + IsSafeOperatorCatalogText(value); + } + + private static bool IsSafeOperatorCatalogText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static bool IsThemeItemCodeForMarket(string itemCode, ThemeMarket market) + { + if (itemCode.Length is < 2 or > 13 || + !itemCode.AsSpan(1).ToArray().All(char.IsAsciiLetterOrDigit)) + { + return false; + } + + return market switch + { + ThemeMarket.Krx => itemCode[0] is 'P' or 'D', + ThemeMarket.Nxt => itemCode[0] is 'X' or 'T', + _ => false + }; + } + + private static string SafeOperatorCatalogRequestId(JsonElement payload) + { + return payload.ValueKind == JsonValueKind.Object && + TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out var requestId) + ? requestId + : string.Empty; + } + + private readonly record struct OperatorCatalogBridgeReply(string MessageType, object Payload); + + private sealed class OperatorCatalogReadRequest + { + private int _terminalState; + + public OperatorCatalogReadRequest( + string lane, + string requestId, + string entity, + string operation, + int browserGeneration, + CancellationTokenSource cancellation) + { + Lane = lane; + RequestId = requestId; + Entity = entity; + Operation = operation; + BrowserGeneration = browserGeneration; + Cancellation = cancellation; + } + + public string Lane { get; } + + public string RequestId { get; } + + public string Entity { get; } + + public string Operation { get; } + + public int BrowserGeneration { get; } + + public CancellationTokenSource Cancellation { get; } + + public bool TryComplete() => Interlocked.CompareExchange(ref _terminalState, 1, 0) == 0; + } +} diff --git a/MainWindow.PlaylistPagePlans.cs b/MainWindow.PlaylistPagePlans.cs new file mode 100644 index 0000000..dbb458d --- /dev/null +++ b/MainWindow.PlaylistPagePlans.cs @@ -0,0 +1,251 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private async Task HandlePlaylistPagePlansRequestAsync(JsonElement payload) + { + var parsed = PlayoutBridgeProtocol.ParsePagePlanRequest(payload); + var request = parsed.Request; + if (!parsed.IsValid) + { + PostPlaylistPagePlansError( + request.RequestId, + "INVALID_REQUEST", + parsed.Error, + retryable: false); + return; + } + + var workflow = _playoutWorkflow; + var engine = _playoutEngine; + if (workflow is null || engine is null) + { + PostPlaylistPagePlansError( + request.RequestId, + "PLAYOUT_UNAVAILABLE", + _playoutInitializationError ?? + "The playout workflow is not available for page preflight.", + retryable: false); + return; + } + + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var browserGeneration = Volatile.Read(ref _playlistPagePlanBrowserGeneration); + var previousCancellation = Interlocked.Exchange( + ref _playlistPagePlanCancellation, + requestCancellation); + CancelRequest(previousCancellation); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "PLAYOUT_PRIORITY", + "A playout command has priority over page preflight.", + retryable: true, + requestCancellation, + browserGeneration); + return; + } + + if (!IsPlaylistPagePlanIdle(workflow, engine)) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "PLAYOUT_NOT_IDLE", + "Page preflight is available only while no scene is prepared or on air.", + retryable: true, + requestCancellation, + browserGeneration); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "PLAYOUT_PRIORITY", + "A playout command cancelled page preflight before the database read.", + retryable: true, + requestCancellation, + browserGeneration); + return; + } + + if (!IsPlaylistPagePlanIdle(workflow, engine)) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "PLAYOUT_NOT_IDLE", + "Playout became active before page preflight could start.", + retryable: true, + requestCancellation, + browserGeneration); + return; + } + + var plans = await workflow.PreflightPagePlansAsync( + request.Entries!, + requestToken); + requestToken.ThrowIfCancellationRequested(); + if (!IsCurrentPlaylistPagePlanRequest(requestCancellation, browserGeneration)) + { + return; + } + + PostMessage("playlist-page-plans-results", new + { + requestId = request.RequestId, + calculatedAt = DateTimeOffset.UtcNow, + plans = plans.Select(plan => new + { + entryId = plan.EntryId, + itemCount = plan.ItemCount, + pageSize = plan.PageSize, + pageCount = plan.PageCount, + accessibleItemCount = plan.AccessibleItemCount, + isTruncated = plan.IsTruncated, + builderKey = plan.BuilderKey + }) + }); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "PLAYOUT_PRIORITY", + "A playout command cancelled the read-only page preflight.", + retryable: true, + requestCancellation, + browserGeneration); + } + } + catch (DatabaseOperationException exception) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "DATABASE_UNAVAILABLE", + exception.Message, + retryable: true, + requestCancellation, + browserGeneration); + } + catch (LegacySceneDataException exception) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "SCENE_DATA_INVALID", + exception.Message, + retryable: false, + requestCancellation, + browserGeneration); + } + catch (InvalidOperationException exception) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "PLAYOUT_NOT_IDLE", + exception.Message, + retryable: true, + requestCancellation, + browserGeneration); + } + catch (Exception exception) + { + PostPlaylistPagePlansErrorIfCurrent( + request.RequestId, + "PAGE_PREFLIGHT_FAILED", + exception.Message, + retryable: false, + requestCancellation, + browserGeneration); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _playlistPagePlanCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private static bool IsPlaylistPagePlanIdle( + LegacyPlayoutWorkflow workflow, + IPlayoutEngine engine) + { + var workflowState = workflow.State; + var engineStatus = engine.Status; + return string.IsNullOrWhiteSpace(workflowState.PreparedCutCode) && + string.IsNullOrWhiteSpace(workflowState.OnAirCutCode) && + string.IsNullOrWhiteSpace(engineStatus.PreparedSceneName) && + string.IsNullOrWhiteSpace(engineStatus.OnAirSceneName); + } + + private bool IsCurrentPlaylistPagePlanRequest( + CancellationTokenSource requestCancellation, + int browserGeneration) => + ReferenceEquals( + Volatile.Read(ref _playlistPagePlanCancellation), + requestCancellation) && + browserGeneration == Volatile.Read(ref _playlistPagePlanBrowserGeneration) && + !_lifetimeCancellation.IsCancellationRequested; + + private void PostPlaylistPagePlansErrorIfCurrent( + string requestId, + string code, + string message, + bool retryable, + CancellationTokenSource requestCancellation, + int browserGeneration) + { + if (!IsCurrentPlaylistPagePlanRequest(requestCancellation, browserGeneration)) + { + return; + } + + PostPlaylistPagePlansError(requestId, code, message, retryable); + } + + private void PostPlaylistPagePlansError( + string requestId, + string code, + string message, + bool retryable) + { + PostMessage("playlist-page-plans-error", new + { + requestId, + code, + message, + retryable + }); + } + + private void InvalidatePlaylistPagePlanBrowserRequest() + { + Interlocked.Increment(ref _playlistPagePlanBrowserGeneration); + CancelRequest(Interlocked.Exchange(ref _playlistPagePlanCancellation, null)); + } +} diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index da1505f..ee73d60 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -16,10 +16,15 @@ public sealed partial class MainWindow private readonly SemaphoreSlim _playoutCommandGate = new(1, 1); private IPlayoutEngine? _playoutEngine; private LegacyPlayoutWorkflow? _playoutWorkflow; + private PlayoutOptions? _playoutOptions; + private MutableLegacySceneCueCompositionOptionsSource? _legacyCompositionOptionsSource; + private LegacySceneCueCompositionOptions? _lastEnabledLegacyComposition; private string? _playoutInitializationError; private int _playoutCommandInFlight; private int _playoutShutdownStarted; private int _browserCorrelationQuarantined; + private int _operatorMutationQuarantined; + private string? _operatorMutationQuarantineMessage; private Task? _legacyRefreshTask; private readonly LegacyRefreshEpoch _legacyRefreshEpoch = new(LegacyRefreshRuntimeStatus.Empty); @@ -30,6 +35,13 @@ public sealed partial class MainWindow { var options = PlayoutOptionsLoader.Load(); var compositionOptions = PlayoutSceneCompositionFactory.Create(options); + _playoutOptions = options; + _legacyCompositionOptionsSource = + new MutableLegacySceneCueCompositionOptionsSource(compositionOptions); + _lastEnabledLegacyComposition = compositionOptions.BackgroundKind == + LegacySceneBackgroundKind.None + ? null + : compositionOptions; _playoutEngine = PlayoutEngineFactory.Create(options); _playoutEngine.StatusChanged += OnPlayoutStatusChanged; if (_databaseRuntime is not null) @@ -37,7 +49,9 @@ public sealed partial class MainWindow _playoutWorkflow = LegacySceneRuntimeFactory.Create( _playoutEngine, _databaseRuntime.Executor, - compositionOptions); + compositionOptionsSource: _legacyCompositionOptionsSource, + trustedManualSource: _manualNetSellStore, + trustedViSource: _viManualListStore); } } catch (Exception exception) @@ -1027,6 +1041,57 @@ public sealed partial class MainWindow private void ResetLegacyRefreshStatus() => _legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty); + /// + /// Provides the single fail-closed playout boundary used by every operator-data + /// mutation. A missing engine/workflow is not an idle state: it means native + /// state cannot be proven, so a write must not start. + /// + private bool CanStartOperatorMutation(bool familyWriteQuarantined) + { + var engine = _playoutEngine; + var workflow = _playoutWorkflow; + var status = engine?.Status; + var workflowState = workflow?.State; + var refreshStatus = GetLegacyRefreshStatus(); + var snapshot = new OperatorMutationGateSnapshot( + IsFamilyWriteQuarantined: familyWriteQuarantined, + IsGlobalWriteQuarantined: IsOperatorMutationQuarantined(), + IsLifetimeCancellationRequested: _lifetimeCancellation.IsCancellationRequested, + IsPlayoutCommandInFlight: Volatile.Read(ref _playoutCommandInFlight) != 0, + IsPlayoutShutdownStarted: Volatile.Read(ref _playoutShutdownStarted) != 0, + IsBrowserCorrelationQuarantined: IsBrowserCorrelationQuarantined(), + IsEngineAvailable: engine is not null, + IsWorkflowAvailable: workflow is not null, + IsCommandAvailable: status?.IsCommandAvailable ?? false, + IsPlayCompletionPending: status?.IsPlayCompletionPending ?? true, + PreparedSceneName: status?.PreparedSceneName, + OnAirSceneName: status?.OnAirSceneName, + PreparedCutCode: workflowState?.PreparedCutCode, + OnAirCutCode: workflowState?.OnAirCutCode, + IsRefreshActive: refreshStatus.IsActive, + IsRefreshTaskRunning: _legacyRefreshTask is { IsCompleted: false }); + + return OperatorMutationGate.CanStart(snapshot); + } + + private bool IsOperatorMutationQuarantined() => + Volatile.Read(ref _operatorMutationQuarantined) != 0; + + private string GetOperatorMutationQuarantineMessage(string fallback) => + Volatile.Read(ref _operatorMutationQuarantineMessage) ?? fallback; + + private void LatchOperatorMutationQuarantine(string message) + { + if (Interlocked.CompareExchange(ref _operatorMutationQuarantined, 1, 0) == 0) + { + Volatile.Write( + ref _operatorMutationQuarantineMessage, + string.IsNullOrWhiteSpace(message) + ? "An operator-data mutation has an unknown outcome. Restart and reconcile every operator-data store before writing again." + : message); + } + } + private void ShutdownPlayoutRuntime() { if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 1b6781c..d514395 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -21,8 +21,32 @@ public sealed partial class MainWindow : Window private readonly SemaphoreSlim _databaseActivityGate = new(1, 1); private DatabaseRuntime? _databaseRuntime; private IMarketDataService? _marketDataService; + private IStockSearchService? _stockSearchService; + private IWorldStockSearchService? _worldStockSearchService; + private IOverseasIndustryIndexSearchService? _overseasIndustryIndexSearchService; + private IThemeSelectionService? _themeSelectionService; + private IIndustrySelectionService? _industrySelectionService; + private ITradingHaltSelectionService? _tradingHaltSelectionService; + private IExpertSelectionService? _expertSelectionService; + private INamedPlaylistPersistenceService? _namedPlaylistPersistenceService; private CancellationTokenSource? _marketDataCancellation; + private CancellationTokenSource? _stockSearchCancellation; + private CancellationTokenSource? _worldStockSearchCancellation; + private CancellationTokenSource? _overseasIndustrySearchCancellation; + private CancellationTokenSource? _themeSelectionCancellation; + private CancellationTokenSource? _industrySelectionCancellation; + private CancellationTokenSource? _tradingHaltSelectionCancellation; + private CancellationTokenSource? _expertSelectionCancellation; + private CancellationTokenSource? _namedPlaylistReadCancellation; + private CancellationTokenSource? _namedPlaylistWriteCancellation; + private CancellationTokenSource? _playlistPagePlanCancellation; private CancellationTokenSource? _databaseHealthCancellation; + private readonly SemaphoreSlim _namedPlaylistWriteGate = new(1, 1); + private int _namedPlaylistWriteInFlight; + private int _namedPlaylistWriteQuarantined; + private int _namedPlaylistBrowserGeneration; + private int _playlistPagePlanBrowserGeneration; + private string? _namedPlaylistWriteQuarantineMessage; private long _databaseStatusSequence; private string? _databaseInitializationError; private bool _webViewReady; @@ -30,6 +54,7 @@ public sealed partial class MainWindow : Window public MainWindow() { InitializeComponent(); + InitializeManualOperatorDataRuntime(); InitializeDatabaseRuntime(); InitializePlayoutRuntime(); Root.Loaded += OnRootLoaded; @@ -43,6 +68,22 @@ public sealed partial class MainWindow : Window _databaseRuntime = DatabaseRuntime.CreateDefault(); DataQueryExecutor.Configure(_databaseRuntime.Executor); _marketDataService = new LegacyMarketDataService(_databaseRuntime.Executor); + _stockSearchService = new LegacyStockSearchService(_databaseRuntime.Executor); + _worldStockSearchService = new LegacyWorldStockSearchService(_databaseRuntime.Executor); + _overseasIndustryIndexSearchService = + new LegacyOverseasIndustryIndexSearchService(_databaseRuntime.Executor); + _themeSelectionService = new LegacyThemeSelectionService(_databaseRuntime.Executor); + _industrySelectionService = new LegacyIndustrySelectionService(_databaseRuntime.Executor); + _tradingHaltSelectionService = new LegacyTradingHaltSelectionService( + _databaseRuntime.Executor); + _expertSelectionService = new LegacyExpertSelectionService(_databaseRuntime.Executor); + _namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService( + _databaseRuntime.Executor, + new OracleNamedPlaylistMutationExecutor( + _databaseRuntime.ConnectionFactory, + _databaseRuntime.Options.Resilience)); + InitializeOperatorCatalogRuntime(); + InitializeManualFinancialRuntime(); } catch (DatabaseOperationException exception) { @@ -179,6 +220,17 @@ public sealed partial class MainWindow : Window { var request = JsonSerializer.Deserialize(args.WebMessageAsJson, JsonOptions); + if (request is not null && + TryHandleOperatorCatalogRequest(request.Type, request.Payload)) + { + return; + } + if (request is not null && + TryHandleManualFinancialRequest(request.Type, request.Payload)) + { + return; + } + switch (request?.Type) { case "ready": @@ -199,9 +251,171 @@ public sealed partial class MainWindow : Window case "playout-timeout-quarantine": _ = HandlePlayoutTimeoutQuarantineAsync(request.Payload); break; + case "request-playlist-page-plans" when + request.Payload.ValueKind == JsonValueKind.Object: + _ = HandlePlaylistPagePlansRequestAsync(request.Payload); + break; + case "request-playlist-page-plans": + PostPlaylistPagePlansError( + string.Empty, + "INVALID_REQUEST", + "The playlist page-plan request payload is invalid.", + retryable: false); + break; + case "request-background-status": + HandleBackgroundStatusRequest(request.Payload); + break; + case "choose-background": + _ = HandleChooseBackgroundAsync(request.Payload); + break; + case "toggle-background": + _ = HandleToggleBackgroundAsync(request.Payload); + break; case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleMarketDataRequestAsync(request.Payload); break; + case "request-market-data": + PostMessage("market-data-error", new + { + requestId = string.Empty, + view = string.Empty, + message = "The market-data request payload is invalid." + }); + break; + case "search-stocks" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleStockSearchRequestAsync(request.Payload); + break; + case "search-stocks": + PostStockSearchError( + string.Empty, + string.Empty, + "The stock-search request payload is invalid."); + break; + case "search-world-stocks" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleWorldStockSearchRequestAsync(request.Payload); + break; + case "search-world-stocks": + PostWorldStockSearchError( + string.Empty, + string.Empty, + "올바르지 않은 해외종목 검색 요청입니다."); + break; + case "search-overseas-industries" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleOverseasIndustrySearchRequestAsync(request.Payload); + break; + case "search-overseas-industries": + PostOverseasIndustrySearchError( + string.Empty, + string.Empty, + "올바르지 않은 미국 해외업종 검색 요청입니다."); + break; + case "search-themes" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleThemeSearchRequestAsync(request.Payload); + break; + case "search-themes": + PostThemeSearchError( + string.Empty, + string.Empty, + string.Empty, + "The theme-search request payload is invalid."); + break; + case "request-theme-preview" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleThemePreviewRequestAsync(request.Payload); + break; + case "request-theme-preview": + PostThemePreviewError( + string.Empty, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + "The theme-preview request payload is invalid."); + break; + case "search-experts" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleExpertSearchRequestAsync(request.Payload); + break; + case "search-experts": + PostExpertSearchError( + string.Empty, + string.Empty, + "올바르지 않은 전문가 검색 요청입니다."); + break; + case "request-expert-preview" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleExpertPreviewRequestAsync(request.Payload); + break; + case "request-expert-preview": + PostExpertPreviewError( + string.Empty, + string.Empty, + string.Empty, + "올바르지 않은 전문가 미리보기 요청입니다."); + break; + case "request-industries" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleIndustrySelectionRequestAsync(request.Payload); + break; + case "request-industries": + PostIndustrySelectionError( + string.Empty, + string.Empty, + "The industry request payload is invalid."); + break; + case "search-trading-halts" when request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleTradingHaltSelectionRequestAsync(request.Payload); + break; + case "search-trading-halts": + PostTradingHaltSelectionError( + string.Empty, + string.Empty, + "올바르지 않은 거래정지 종목 검색 요청입니다."); + break; + case "request-manual-operator-data-status": + HandleManualOperatorStatusRequest(request.Payload); + break; + case "request-manual-net-sell-data": + _ = HandleManualNetSellReadAsync(request.Payload); + break; + case "save-manual-net-sell-data": + _ = HandleManualNetSellWriteAsync(request.Payload); + break; + case "request-vi-manual-list": + _ = HandleViManualListReadAsync(request.Payload); + break; + case "save-vi-manual-list": + _ = HandleViManualListWriteAsync(request.Payload); + break; + case "request-named-playlist-list" when + request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleNamedPlaylistListRequestAsync(request.Payload); + break; + case "request-named-playlist-load" when + request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleNamedPlaylistLoadRequestAsync(request.Payload); + break; + case "create-named-playlist" when + request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleNamedPlaylistCreateRequestAsync(request.Payload); + break; + case "replace-named-playlist" when + request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleNamedPlaylistReplaceRequestAsync(request.Payload); + break; + case "delete-named-playlist" when + request.Payload.ValueKind == JsonValueKind.Object: + _ = HandleNamedPlaylistDeleteRequestAsync(request.Payload); + break; + case "request-named-playlist-list": + case "request-named-playlist-load": + case "create-named-playlist": + case "replace-named-playlist": + case "delete-named-playlist": + PostNamedPlaylistError( + string.Empty, + "unknown", + "INVALID_REQUEST", + "The named-playlist request payload is invalid.", + retryable: false, + outcomeUnknown: false); + break; case "open-external" when request.Payload.ValueKind == JsonValueKind.Object: if (request.Payload.TryGetProperty("url", out var value) && Uri.TryCreate(value.GetString(), UriKind.Absolute, out var uri)) @@ -272,9 +486,9 @@ public sealed partial class MainWindow : Window } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; var previousCancellation = Interlocked.Exchange(ref _marketDataCancellation, requestCancellation); previousCancellation?.Cancel(); - previousCancellation?.Dispose(); var databaseActivityEntered = false; try @@ -285,9 +499,9 @@ public sealed partial class MainWindow : Window return; } - await _databaseActivityGate.WaitAsync(requestCancellation.Token); + await _databaseActivityGate.WaitAsync(requestToken); databaseActivityEntered = true; - requestCancellation.Token.ThrowIfCancellationRequested(); + requestToken.ThrowIfCancellationRequested(); // Close the race where playout starts after this request is published // but before its market query begins. @@ -298,8 +512,8 @@ public sealed partial class MainWindow : Window } var snapshot = await _marketDataService - .GetAsync(view, maximumRows, requestCancellation.Token); - requestCancellation.Token.ThrowIfCancellationRequested(); + .GetAsync(view, maximumRows, requestToken); + requestToken.ThrowIfCancellationRequested(); PostMessage("market-data", new { @@ -316,14 +530,14 @@ public sealed partial class MainWindow : Window }) }); } - catch (OperationCanceledException) when (requestCancellation.IsCancellationRequested) + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityMarketDataCancellationIfCurrent( requestId, view, requestCancellation); } - catch (DatabaseOperationException) when (requestCancellation.IsCancellationRequested) + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) { // Some providers surface a provider exception instead of cancellation after // a command has already taken priority. Never publish that stale failure. @@ -332,7 +546,7 @@ public sealed partial class MainWindow : Window view, requestCancellation); } - catch (Exception) when (requestCancellation.IsCancellationRequested) + catch (Exception) when (requestToken.IsCancellationRequested) { PostPlayoutPriorityMarketDataCancellationIfCurrent( requestId, @@ -356,13 +570,11 @@ public sealed partial class MainWindow : Window _databaseActivityGate.Release(); } - if (ReferenceEquals(Interlocked.CompareExchange( - ref _marketDataCancellation, - null, - requestCancellation), requestCancellation)) - { - requestCancellation.Dispose(); - } + Interlocked.CompareExchange( + ref _marketDataCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); } } @@ -396,6 +608,1561 @@ public sealed partial class MainWindow : Window }); } + private async Task HandleStockSearchRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var query = GetString(payload, "query") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId)) + { + PostStockSearchError(requestId, query, "올바르지 않은 종목 검색 요청입니다."); + return; + } + + var maximumResults = LegacyStockSearchService.DefaultMaximumResults; + if (payload.TryGetProperty("maximumResults", out var requestedLimit)) + { + if (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumResults)) + { + PostStockSearchError(requestId, query, "검색 결과 제한값이 올바르지 않습니다."); + return; + } + } + + if (_stockSearchService is null) + { + PostStockSearchError( + requestId, + query, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var previousCancellation = Interlocked.Exchange( + ref _stockSearchCancellation, + requestCancellation); + previousCancellation?.Cancel(); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + + // A playout command always wins the race for the shared DB executor. + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + var result = await _stockSearchService.SearchAsync( + query, + maximumResults, + requestToken); + requestToken.ThrowIfCancellationRequested(); + + PostMessage("stock-search-results", new + { + requestId, + query = result.Query, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + results = result.Items.Select(item => new + { + market = ToWireValue(item.Market), + source = ToWireValue(item.Source), + name = item.Name, + code = item.Code + }) + }); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + // A canceled provider may surface its native exception. Do not publish + // stale provider details after playout or a newer search took priority. + PostPlayoutPriorityStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (ArgumentException) + { + PostStockSearchError( + requestId, + query, + $"검색어는 공백이 아닌 {LegacyStockSearchService.MaximumQueryLength}자 이하여야 하며 결과 제한은 1~{LegacyStockSearchService.MaximumResults}개입니다."); + } + catch (StockSearchDataException) + { + PostStockSearchError(requestId, query, "종목 검색 결과 형식이 올바르지 않습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (DatabaseOperationException exception) + { + PostStockSearchError(requestId, query, exception.Message); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostStockSearchError( + requestId, + query, + "종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _stockSearchCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private void PostPlayoutPriorityStockSearchCancellationIfCurrent( + string requestId, + string query, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _stockSearchCancellation), + requestCancellation)) + { + // Shutdown or a newer stock search superseded this response. + return; + } + + PostStockSearchError( + requestId, + query, + "송출 명령과 자동 갱신을 우선 처리하기 위해 검색을 취소했습니다. 다시 검색하세요."); + } + + private void PostStockSearchError(string requestId, string query, string message) + { + PostMessage("stock-search-error", new + { + requestId, + query, + message + }); + } + + private async Task HandleWorldStockSearchRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var query = GetString(payload, "query") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !payload.TryGetProperty("query", out var queryElement) || + queryElement.ValueKind != JsonValueKind.String) + { + PostWorldStockSearchError( + requestId, + query, + "올바르지 않은 해외종목 검색 요청입니다."); + return; + } + + var maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults; + if (payload.TryGetProperty("maximumResults", out var requestedLimit)) + { + if (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumResults)) + { + PostWorldStockSearchError( + requestId, + query, + "검색 결과 제한값이 올바르지 않습니다."); + return; + } + } + + if (_worldStockSearchService is null) + { + PostWorldStockSearchError( + requestId, + query, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var previousCancellation = Interlocked.Exchange( + ref _worldStockSearchCancellation, + requestCancellation); + previousCancellation?.Cancel(); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + + // Playout commands always take priority over operator lookup reads. + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + var result = await _worldStockSearchService.SearchAsync( + query, + maximumResults, + requestToken); + requestToken.ThrowIfCancellationRequested(); + + PostMessage("world-stock-search-results", new + { + requestId, + query = result.Query, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + results = result.Items.Select(item => new + { + inputName = item.InputName, + symbol = item.Symbol, + nationCode = item.NationCode, + fdtc = "1" + }) + }); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + // Providers can surface a native error after cancellation. Suppress + // stale details when playout or a newer lookup owns the DB slot. + PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (ArgumentException) + { + PostWorldStockSearchError( + requestId, + query, + $"검색어는 공백이 아닌 {LegacyWorldStockSearchService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyWorldStockSearchService.MaximumResults}개여야 합니다."); + } + catch (WorldStockSearchDataException) + { + PostWorldStockSearchError( + requestId, + query, + "해외종목 검색 결과의 형식 또는 식별자가 올바르지 않습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (DatabaseOperationException exception) + { + PostWorldStockSearchError(requestId, query, exception.Message); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostWorldStockSearchError( + requestId, + query, + "해외종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _worldStockSearchCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private void PostPlayoutPriorityWorldStockSearchCancellationIfCurrent( + string requestId, + string query, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _worldStockSearchCancellation), + requestCancellation)) + { + // Shutdown or a newer world-stock search superseded this response. + return; + } + + PostWorldStockSearchError( + requestId, + query, + "송출 명령과 자동 갱신을 우선 처리하기 위해 해외종목 검색을 취소했습니다. 다시 검색하세요."); + } + + private void PostWorldStockSearchError(string requestId, string query, string message) + { + PostMessage("world-stock-search-error", new + { + requestId, + query, + message + }); + } + + private async Task HandleOverseasIndustrySearchRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var query = GetString(payload, "query") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !payload.TryGetProperty("query", out var queryElement) || + queryElement.ValueKind != JsonValueKind.String) + { + PostOverseasIndustrySearchError( + requestId, + query, + "올바르지 않은 미국 해외업종 검색 요청입니다."); + return; + } + + var maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults; + if (payload.TryGetProperty("maximumResults", out var requestedLimit) && + (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumResults))) + { + PostOverseasIndustrySearchError( + requestId, + query, + "해외업종 검색 결과 제한값이 올바르지 않습니다."); + return; + } + + var service = _overseasIndustryIndexSearchService; + if (service is null) + { + PostOverseasIndustrySearchError( + requestId, + query, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var previousCancellation = Interlocked.Exchange( + ref _overseasIndustrySearchCancellation, + requestCancellation); + previousCancellation?.Cancel(); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + var result = await service.SearchAsync(query, maximumResults, requestToken); + requestToken.ThrowIfCancellationRequested(); + PostMessage("overseas-industry-results", new + { + requestId, + query = result.Query, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + results = result.Items.Select(item => new + { + koreanName = item.KoreanName, + inputName = item.InputName, + symbol = item.Symbol, + nationCode = "US", + fdtc = "0" + }).ToArray() + }); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (ArgumentException) + { + PostOverseasIndustrySearchError( + requestId, + query, + $"검색어는 {LegacyOverseasIndustryIndexSearchService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyOverseasIndustryIndexSearchService.MaximumResults}개여야 합니다."); + } + catch (OverseasIndustryIndexSearchDataException) + { + PostOverseasIndustrySearchError( + requestId, + query, + "미국 해외업종 검색 결과의 스키마 또는 식별자가 올바르지 않습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (DatabaseOperationException exception) + { + PostOverseasIndustrySearchError(requestId, query, exception.Message); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostOverseasIndustrySearchError( + requestId, + query, + "미국 해외업종을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _overseasIndustrySearchCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private void PostPlayoutPriorityOverseasIndustryCancellationIfCurrent( + string requestId, + string query, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _overseasIndustrySearchCancellation), + requestCancellation)) + { + // Shutdown or a newer overseas-industry lookup owns the response. + return; + } + + PostOverseasIndustrySearchError( + requestId, + query, + "송출 명령과 자동 갱신을 우선 처리하기 위해 미국 해외업종 검색을 취소했습니다. 다시 검색하세요."); + } + + private void PostOverseasIndustrySearchError( + string requestId, + string query, + string message) + { + PostMessage("overseas-industry-error", new + { + requestId, + query, + message + }); + } + + private async Task HandleThemeSearchRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var query = GetString(payload, "query") ?? string.Empty; + var nxtSessionValue = GetString(payload, "nxtSession") ?? string.Empty; + if (!HasOnlyProperties( + payload, + "requestId", + "query", + "nxtSession", + "maximumResults") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !payload.TryGetProperty("query", out var queryElement) || + queryElement.ValueKind != JsonValueKind.String || + !TryParseThemeNxtSession(nxtSessionValue, out var nxtSession)) + { + PostThemeSearchError( + requestId, + query, + nxtSessionValue, + "올바르지 않은 테마 검색 요청입니다."); + return; + } + + var maximumResults = LegacyThemeSelectionService.DefaultMaximumResults; + if (payload.TryGetProperty("maximumResults", out var requestedLimit) && + (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumResults))) + { + PostThemeSearchError( + requestId, + query, + nxtSessionValue, + "테마 검색 결과 제한값이 올바르지 않습니다."); + return; + } + + var service = _themeSelectionService; + if (service is null) + { + PostThemeSearchError( + requestId, + query, + nxtSessionValue, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + await ExecuteThemeSelectionRequestAsync( + async requestToken => + { + var result = await service.SearchAsync( + query, + nxtSession, + maximumResults, + requestToken); + requestToken.ThrowIfCancellationRequested(); + PostMessage("theme-search-results", new + { + requestId, + query = result.Query, + nxtSession = ToWireValue(result.NxtSession), + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + results = result.Items.Select(item => new + { + market = ToWireValue(item.Identity.Market), + session = ToWireValue(item.Identity.Session), + themeCode = item.Identity.ThemeCode, + title = item.Identity.ThemeTitle, + displayTitle = item.Identity.Market == ThemeMarket.Nxt + ? $"{item.Identity.ThemeTitle}(NXT)" + : item.Identity.ThemeTitle, + source = ToWireValue(item.Source) + }).ToArray() + }); + }, + message => PostThemeSearchError( + requestId, + query, + nxtSessionValue, + message), + $"검색어는 {LegacyThemeSelectionService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyThemeSelectionService.MaximumResults}개여야 합니다.", + "테마 검색 결과의 스키마 또는 식별자가 올바르지 않습니다.", + "테마를 검색하지 못했습니다. DB 연결 상태를 확인하세요."); + } + + private void PostThemeSearchError( + string requestId, + string query, + string nxtSession, + string message) + { + PostMessage("theme-search-error", new + { + requestId, + query, + nxtSession, + message + }); + } + + private async Task HandleThemePreviewRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var marketValue = GetString(payload, "market") ?? string.Empty; + var sessionValue = GetString(payload, "session") ?? string.Empty; + var themeCode = GetString(payload, "themeCode") ?? string.Empty; + var themeTitle = GetString(payload, "themeTitle") ?? string.Empty; + if (!HasOnlyProperties( + payload, + "requestId", + "market", + "session", + "themeCode", + "themeTitle", + "maximumItems") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !TryParseThemeMarket(marketValue, out var market) || + !TryParseThemeSession(sessionValue, out var session) || + !payload.TryGetProperty("themeCode", out var codeElement) || + codeElement.ValueKind != JsonValueKind.String || + !payload.TryGetProperty("themeTitle", out var titleElement) || + titleElement.ValueKind != JsonValueKind.String) + { + PostThemePreviewError( + requestId, + marketValue, + sessionValue, + themeCode, + themeTitle, + "올바르지 않은 테마 미리보기 요청입니다."); + return; + } + + var maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems; + if (payload.TryGetProperty("maximumItems", out var requestedLimit) && + (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumItems))) + { + PostThemePreviewError( + requestId, + marketValue, + sessionValue, + themeCode, + themeTitle, + "테마 미리보기 종목 제한값이 올바르지 않습니다."); + return; + } + + var service = _themeSelectionService; + if (service is null) + { + PostThemePreviewError( + requestId, + marketValue, + sessionValue, + themeCode, + themeTitle, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + var identity = new ThemeSelectionIdentity(market, session, themeCode, themeTitle); + await ExecuteThemeSelectionRequestAsync( + async requestToken => + { + var result = await service.PreviewAsync( + identity, + maximumItems, + requestToken); + requestToken.ThrowIfCancellationRequested(); + PostMessage("theme-preview-results", new + { + requestId, + selection = new + { + market = ToWireValue(result.Identity.Market), + session = ToWireValue(result.Identity.Session), + themeCode = result.Identity.ThemeCode, + title = result.Identity.ThemeTitle + }, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + items = result.Items.Select(item => new + { + inputIndex = item.InputIndex, + itemCode = item.ItemCode, + itemName = item.ItemName + }).ToArray() + }); + }, + message => PostThemePreviewError( + requestId, + marketValue, + sessionValue, + themeCode, + themeTitle, + message), + $"테마 시장·세션·코드·제목을 확인하고 종목 제한은 1~{LegacyThemeSelectionService.MaximumPreviewItems}개로 지정하세요.", + "테마 미리보기 결과의 스키마 또는 식별자가 올바르지 않습니다.", + "테마 종목을 불러오지 못했습니다. DB 연결 상태를 확인하세요."); + } + + private void PostThemePreviewError( + string requestId, + string market, + string session, + string themeCode, + string themeTitle, + string message) + { + PostMessage("theme-preview-error", new + { + requestId, + selection = new + { + market, + session, + themeCode, + title = themeTitle + }, + message + }); + } + + private async Task ExecuteThemeSelectionRequestAsync( + Func operation, + Action postError, + string validationMessage, + string invalidDataMessage, + string unexpectedMessage) + { + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var previousCancellation = Interlocked.Exchange( + ref _themeSelectionCancellation, + requestCancellation); + previousCancellation?.Cancel(); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityThemeCancellationIfCurrent( + postError, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityThemeCancellationIfCurrent( + postError, + requestCancellation); + return; + } + + await operation(requestToken); + requestToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityThemeCancellationIfCurrent( + postError, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityThemeCancellationIfCurrent( + postError, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityThemeCancellationIfCurrent( + postError, + requestCancellation); + } + catch (ArgumentException) + { + postError(validationMessage); + } + catch (ThemeSelectionDataException) + { + postError(invalidDataMessage); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (DatabaseOperationException exception) + { + postError(exception.Message); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + postError(unexpectedMessage); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _themeSelectionCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private void PostPlayoutPriorityThemeCancellationIfCurrent( + Action postError, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _themeSelectionCancellation), + requestCancellation)) + { + // Shutdown or a newer theme selector request owns the response. + return; + } + + postError( + "송출 명령과 자동 갱신을 우선 처리하기 위해 테마 조회를 취소했습니다. 다시 조회하세요."); + } + + private async Task HandleExpertSearchRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var query = GetString(payload, "query") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId) || + !payload.TryGetProperty("query", out var queryElement) || + queryElement.ValueKind != JsonValueKind.String) + { + PostExpertSearchError( + requestId, + query, + "올바르지 않은 전문가 검색 요청입니다."); + return; + } + + var maximumResults = LegacyExpertSelectionService.DefaultMaximumResults; + if (payload.TryGetProperty("maximumResults", out var requestedLimit) && + (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumResults))) + { + PostExpertSearchError( + requestId, + query, + "전문가 검색 결과 제한값이 올바르지 않습니다."); + return; + } + + var service = _expertSelectionService; + if (service is null) + { + PostExpertSearchError( + requestId, + query, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + await ExecuteExpertSelectionRequestAsync( + async requestToken => + { + var result = await service.SearchAsync( + query, + maximumResults, + requestToken); + requestToken.ThrowIfCancellationRequested(); + PostMessage("expert-search-results", new + { + requestId, + query = result.Query, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + results = result.Items.Select(item => new + { + expertCode = item.Identity.ExpertCode, + expertName = item.Identity.ExpertName, + source = ToWireValue(item.Source) + }).ToArray() + }); + }, + message => PostExpertSearchError(requestId, query, message), + $"검색어는 {LegacyExpertSelectionService.MaximumQueryLength}자 이하여야 하며 " + + $"결과 제한은 1~{LegacyExpertSelectionService.MaximumResults}개입니다.", + "전문가 검색 결과의 형식 또는 식별자가 올바르지 않습니다.", + "전문가를 검색하지 못했습니다. DB 연결 상태를 확인하세요."); + } + + private void PostExpertSearchError(string requestId, string query, string message) + { + PostMessage("expert-search-error", new + { + requestId, + query, + message + }); + } + + private async Task HandleExpertPreviewRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var expertCode = GetString(payload, "expertCode") ?? string.Empty; + var expertName = GetString(payload, "expertName") ?? string.Empty; + if (!HasOnlyProperties( + payload, + "requestId", + "expertCode", + "expertName", + "maximumItems") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId) || + !payload.TryGetProperty("expertCode", out var codeElement) || + codeElement.ValueKind != JsonValueKind.String || + !payload.TryGetProperty("expertName", out var nameElement) || + nameElement.ValueKind != JsonValueKind.String) + { + PostExpertPreviewError( + requestId, + expertCode, + expertName, + "올바르지 않은 전문가 미리보기 요청입니다."); + return; + } + + var maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems; + if (payload.TryGetProperty("maximumItems", out var requestedLimit) && + (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumItems))) + { + PostExpertPreviewError( + requestId, + expertCode, + expertName, + "전문가 추천종목 제한값이 올바르지 않습니다."); + return; + } + + var service = _expertSelectionService; + if (service is null) + { + PostExpertPreviewError( + requestId, + expertCode, + expertName, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + var identity = new ExpertSelectionIdentity(expertCode, expertName); + await ExecuteExpertSelectionRequestAsync( + async requestToken => + { + var result = await service.PreviewAsync( + identity, + maximumItems, + requestToken); + requestToken.ThrowIfCancellationRequested(); + PostMessage("expert-preview-results", new + { + requestId, + selection = new + { + expertCode = result.Identity.ExpertCode, + expertName = result.Identity.ExpertName + }, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + items = result.Items.Select(item => new + { + playIndex = item.PlayIndex, + stockCode = item.StockCode, + stockName = item.StockName, + buyAmount = item.BuyAmount + }).ToArray() + }); + }, + message => PostExpertPreviewError( + requestId, + expertCode, + expertName, + message), + $"전문가 코드·이름을 확인하고 종목 제한은 " + + $"1~{LegacyExpertSelectionService.MaximumPreviewItems}개로 지정하세요.", + "전문가 추천종목의 형식, 순서 또는 식별자가 올바르지 않습니다.", + "전문가 추천종목을 불러오지 못했습니다. DB 연결 상태를 확인하세요."); + } + + private void PostExpertPreviewError( + string requestId, + string expertCode, + string expertName, + string message) + { + PostMessage("expert-preview-error", new + { + requestId, + selection = new + { + expertCode, + expertName + }, + message + }); + } + + private async Task ExecuteExpertSelectionRequestAsync( + Func operation, + Action postError, + string validationMessage, + string invalidDataMessage, + string unexpectedMessage) + { + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var previousCancellation = Interlocked.Exchange( + ref _expertSelectionCancellation, + requestCancellation); + CancelRequest(previousCancellation); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityExpertCancellationIfCurrent( + postError, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityExpertCancellationIfCurrent( + postError, + requestCancellation); + return; + } + + await operation(requestToken); + requestToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityExpertCancellationIfCurrent( + postError, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityExpertCancellationIfCurrent( + postError, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityExpertCancellationIfCurrent( + postError, + requestCancellation); + } + catch (ArgumentException) + { + postError(validationMessage); + } + catch (ExpertSelectionDataException) + { + postError(invalidDataMessage); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (DatabaseOperationException exception) + { + postError(exception.Message); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + postError(unexpectedMessage); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _expertSelectionCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private void PostPlayoutPriorityExpertCancellationIfCurrent( + Action postError, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _expertSelectionCancellation), + requestCancellation)) + { + // Shutdown or a newer expert selector request owns the response. + return; + } + + postError( + "송출 명령을 우선 처리하기 위해 전문가 조회를 취소했습니다. 다시 조회하세요."); + } + + private async Task HandleIndustrySelectionRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var marketValue = GetString(payload, "market") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "market") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) || + !TryParseIndustryMarket(marketValue, out var market)) + { + PostIndustrySelectionError(requestId, marketValue, "올바르지 않은 업종 목록 요청입니다."); + return; + } + + if (_industrySelectionService is null) + { + PostIndustrySelectionError( + requestId, + marketValue, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var previousCancellation = Interlocked.Exchange( + ref _industrySelectionCancellation, + requestCancellation); + previousCancellation?.Cancel(); + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityIndustryCancellationIfCurrent( + requestId, + marketValue, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityIndustryCancellationIfCurrent( + requestId, + marketValue, + requestCancellation); + return; + } + + var result = await _industrySelectionService.GetAsync(market, requestToken); + requestToken.ThrowIfCancellationRequested(); + PostMessage("industry-results", new + { + requestId, + market = ToWireValue(market), + totalRowCount = result.Count, + results = result.Select(item => new + { + market = ToWireValue(item.Market), + name = item.IndustryName, + code = item.IndustryCode + }) + }); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityIndustryCancellationIfCurrent( + requestId, + marketValue, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityIndustryCancellationIfCurrent( + requestId, + marketValue, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityIndustryCancellationIfCurrent( + requestId, + marketValue, + requestCancellation); + } + catch (IndustrySelectionDataException) + { + PostIndustrySelectionError(requestId, marketValue, "업종 목록 결과 형식이 올바르지 않습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (DatabaseOperationException exception) + { + PostIndustrySelectionError(requestId, marketValue, exception.Message); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostIndustrySelectionError( + requestId, + marketValue, + "업종 목록을 불러오지 못했습니다. DB 연결 상태를 확인하세요."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _industrySelectionCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private void PostPlayoutPriorityIndustryCancellationIfCurrent( + string requestId, + string market, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _industrySelectionCancellation), + requestCancellation)) + { + return; + } + + PostIndustrySelectionError( + requestId, + market, + "송출 명령과 자동 갱신을 우선 처리하기 위해 업종 조회를 취소했습니다. 다시 조회하세요."); + } + + private void PostIndustrySelectionError(string requestId, string market, string message) + { + PostMessage("industry-error", new + { + requestId, + market, + message + }); + } + + private async Task HandleTradingHaltSelectionRequestAsync(JsonElement payload) + { + var requestId = GetString(payload, "requestId") ?? string.Empty; + var query = GetString(payload, "query") ?? string.Empty; + if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") || + !TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId) || + !payload.TryGetProperty("query", out var queryElement) || + queryElement.ValueKind != JsonValueKind.String) + { + PostTradingHaltSelectionError( + requestId, + query, + "올바르지 않은 거래정지 종목 검색 요청입니다."); + return; + } + + var maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults; + if (payload.TryGetProperty("maximumResults", out var requestedLimit) && + (requestedLimit.ValueKind != JsonValueKind.Number || + !requestedLimit.TryGetInt32(out maximumResults))) + { + PostTradingHaltSelectionError( + requestId, + query, + "거래정지 검색 결과 제한값이 올바르지 않습니다."); + return; + } + + var service = _tradingHaltSelectionService; + if (service is null) + { + PostTradingHaltSelectionError( + requestId, + query, + _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."); + return; + } + + var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var requestToken = requestCancellation.Token; + var previousCancellation = Interlocked.Exchange( + ref _tradingHaltSelectionCancellation, + requestCancellation); + CancelRequest(previousCancellation); + + var databaseActivityEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityTradingHaltCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + await _databaseActivityGate.WaitAsync(requestToken); + databaseActivityEntered = true; + requestToken.ThrowIfCancellationRequested(); + + // Playout always owns the shared executor before an operator lookup. + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + requestCancellation.Cancel(); + PostPlayoutPriorityTradingHaltCancellationIfCurrent( + requestId, + query, + requestCancellation); + return; + } + + var result = await service.SearchAsync(query, maximumResults, requestToken); + requestToken.ThrowIfCancellationRequested(); + + PostMessage("trading-halt-results", new + { + requestId, + query = result.Query, + result.RetrievedAt, + totalRowCount = result.Items.Count, + truncated = result.IsTruncated, + results = result.Items.Select(item => new + { + market = ToWireValue(item.Market), + stockCode = item.StockCode, + displayName = item.DisplayName + }).ToArray() + }); + } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityTradingHaltCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (DatabaseOperationException) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityTradingHaltCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (Exception) when (requestToken.IsCancellationRequested) + { + PostPlayoutPriorityTradingHaltCancellationIfCurrent( + requestId, + query, + requestCancellation); + } + catch (ArgumentException) + { + PostTradingHaltSelectionError( + requestId, + query, + $"검색어는 {LegacyTradingHaltSelectionService.MaximumQueryLength}자 이하여야 하며 " + + $"결과 제한은 1~{LegacyTradingHaltSelectionService.MaximumResults}개입니다."); + } + catch (TradingHaltSelectionDataException) + { + PostTradingHaltSelectionError( + requestId, + query, + "거래정지 종목 검색 결과의 형식 또는 식별자가 올바르지 않습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch (DatabaseOperationException exception) + { + PostTradingHaltSelectionError(requestId, query, exception.Message); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostTradingHaltSelectionError( + requestId, + query, + "거래정지 종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + finally + { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _tradingHaltSelectionCancellation, + null, + requestCancellation); + requestCancellation.Dispose(); + } + } + + private void PostPlayoutPriorityTradingHaltCancellationIfCurrent( + string requestId, + string query, + CancellationTokenSource requestCancellation) + { + if (_lifetimeCancellation.IsCancellationRequested || + !ReferenceEquals( + Volatile.Read(ref _tradingHaltSelectionCancellation), + requestCancellation)) + { + // Shutdown or a newer trading-halt request owns the response. + return; + } + + PostTradingHaltSelectionError( + requestId, + query, + "송출 명령을 우선 처리하기 위해 거래정지 검색을 취소했습니다. 다시 검색하세요."); + } + + private void PostTradingHaltSelectionError( + string requestId, + string query, + string message) + { + PostMessage("trading-halt-error", new + { + requestId, + query, + message + }); + } + private async Task PostDatabaseStatusAsync( CancellationToken cancellationToken, bool periodic = false) @@ -557,7 +2324,23 @@ public sealed partial class MainWindow : Window private void CancelActiveMarketDataRequest() { - var cancellation = Volatile.Read(ref _marketDataCancellation); + CancelRequest(Volatile.Read(ref _marketDataCancellation)); + CancelRequest(Volatile.Read(ref _stockSearchCancellation)); + CancelRequest(Volatile.Read(ref _worldStockSearchCancellation)); + CancelRequest(Volatile.Read(ref _overseasIndustrySearchCancellation)); + CancelRequest(Volatile.Read(ref _themeSelectionCancellation)); + CancelRequest(Volatile.Read(ref _industrySelectionCancellation)); + CancelRequest(Volatile.Read(ref _tradingHaltSelectionCancellation)); + CancelRequest(Volatile.Read(ref _expertSelectionCancellation)); + CancelRequest(Volatile.Read(ref _namedPlaylistReadCancellation)); + CancelRequest(Volatile.Read(ref _namedPlaylistWriteCancellation)); + CancelRequest(Volatile.Read(ref _playlistPagePlanCancellation)); + CancelOperatorCatalogReadsForPlayout(); + CancelManualFinancialReadsForPlayout(); + } + + private static void CancelRequest(CancellationTokenSource? cancellation) + { try { cancellation?.Cancel(); @@ -615,8 +2398,91 @@ public sealed partial class MainWindow : Window return value?.ToLowerInvariant() is "kospi" or "kosdaq" or "index" or "overseas"; } + private static bool TryParseIndustryMarket(string? value, out IndustryMarket market) + { + market = value?.ToLowerInvariant() switch + { + "kospi" => IndustryMarket.Kospi, + "kosdaq" => IndustryMarket.Kosdaq, + _ => default + }; + return value?.ToLowerInvariant() is "kospi" or "kosdaq"; + } + + private static bool TryParseThemeMarket(string? value, out ThemeMarket market) + { + market = value?.ToLowerInvariant() switch + { + "krx" => ThemeMarket.Krx, + "nxt" => ThemeMarket.Nxt, + _ => default + }; + return value?.ToLowerInvariant() is "krx" or "nxt"; + } + + private static bool TryParseThemeNxtSession(string? value, out ThemeSession session) + { + session = value?.ToLowerInvariant() switch + { + "premarket" => ThemeSession.PreMarket, + "aftermarket" => ThemeSession.AfterMarket, + _ => default + }; + return value?.ToLowerInvariant() is "premarket" or "aftermarket"; + } + + private static bool TryParseThemeSession(string? value, out ThemeSession session) + { + session = value?.ToLowerInvariant() switch + { + "notapplicable" => ThemeSession.NotApplicable, + "premarket" => ThemeSession.PreMarket, + "aftermarket" => ThemeSession.AfterMarket, + _ => default + }; + return value?.ToLowerInvariant() is "notapplicable" or "premarket" or "aftermarket"; + } + private static string ToWireValue(MarketDataView view) => view.ToString().ToLowerInvariant(); + private static string ToWireValue(StockMarket market) => market switch + { + StockMarket.Kospi => "kospi", + StockMarket.Kosdaq => "kosdaq", + StockMarket.NxtKospi => "nxt-kospi", + StockMarket.NxtKosdaq => "nxt-kosdaq", + _ => "unknown" + }; + + private static string ToWireValue(IndustryMarket market) => market switch + { + IndustryMarket.Kospi => "kospi", + IndustryMarket.Kosdaq => "kosdaq", + _ => "unknown" + }; + + private static string ToWireValue(TradingHaltMarket market) => market switch + { + TradingHaltMarket.Kospi => "kospi", + TradingHaltMarket.Kosdaq => "kosdaq", + _ => "unknown" + }; + + private static string ToWireValue(ThemeMarket market) => market switch + { + ThemeMarket.Krx => "krx", + ThemeMarket.Nxt => "nxt", + _ => "unknown" + }; + + private static string ToWireValue(ThemeSession session) => session switch + { + ThemeSession.NotApplicable => "notApplicable", + ThemeSession.PreMarket => "preMarket", + ThemeSession.AfterMarket => "afterMarket", + _ => "unknown" + }; + private static string ToWireValue(DataSourceKind source) => source switch { DataSourceKind.Oracle => "oracle", @@ -708,6 +2574,11 @@ public sealed partial class MainWindow : Window private void QuarantineIfBrowserCorrelationCanBeLost() { + InvalidateNamedPlaylistBrowserRequests(); + InvalidatePlaylistPagePlanBrowserRequest(); + InvalidateOperatorCatalogBrowserRequests(); + InvalidateManualOperatorBrowserRequests(); + InvalidateManualFinancialBrowserRequests(); if (Volatile.Read(ref _playoutCommandInFlight) != 0) { // Navigation or a renderer failure destroys the JavaScript request @@ -725,7 +2596,47 @@ public sealed partial class MainWindow : Window ShutdownPlayoutRuntime(); var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null); requestCancellation?.Cancel(); - requestCancellation?.Dispose(); + var stockSearchCancellation = Interlocked.Exchange(ref _stockSearchCancellation, null); + stockSearchCancellation?.Cancel(); + var worldStockSearchCancellation = Interlocked.Exchange( + ref _worldStockSearchCancellation, + null); + worldStockSearchCancellation?.Cancel(); + var overseasIndustrySearchCancellation = Interlocked.Exchange( + ref _overseasIndustrySearchCancellation, + null); + overseasIndustrySearchCancellation?.Cancel(); + var themeSelectionCancellation = Interlocked.Exchange( + ref _themeSelectionCancellation, + null); + themeSelectionCancellation?.Cancel(); + var industrySelectionCancellation = Interlocked.Exchange( + ref _industrySelectionCancellation, + null); + industrySelectionCancellation?.Cancel(); + var tradingHaltSelectionCancellation = Interlocked.Exchange( + ref _tradingHaltSelectionCancellation, + null); + CancelRequest(tradingHaltSelectionCancellation); + var expertSelectionCancellation = Interlocked.Exchange( + ref _expertSelectionCancellation, + null); + CancelRequest(expertSelectionCancellation); + var namedPlaylistReadCancellation = Interlocked.Exchange( + ref _namedPlaylistReadCancellation, + null); + CancelRequest(namedPlaylistReadCancellation); + var namedPlaylistWriteCancellation = Interlocked.Exchange( + ref _namedPlaylistWriteCancellation, + null); + CancelRequest(namedPlaylistWriteCancellation); + var playlistPagePlanCancellation = Interlocked.Exchange( + ref _playlistPagePlanCancellation, + null); + CancelRequest(playlistPagePlanCancellation); + ShutdownOperatorCatalogRuntime(); + ShutdownManualFinancialRuntime(); + ShutdownManualOperatorDataRuntime(); DataQueryExecutor.Reset(); if (!wasWebViewReady) diff --git a/Package.appxmanifest b/Package.appxmanifest index 882ed03..b1d5f9e 100644 --- a/Package.appxmanifest +++ b/Package.appxmanifest @@ -10,7 +10,7 @@ + Version="1.0.2.0" /> diff --git a/README.md b/README.md index 3f5349e..4146add 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ - WinUI 3 네이티브 창과 WebView2 간 JSON 메시지 브리지 - FarPoint 플레이리스트를 대체하는 Web UI - - 항목 선택 + - 첫 열 송출 포함, 행/Ctrl+행 작업 선택 - 드래그 정렬 및 위/아래 이동 - 선택 삭제 - 로컬 저장/불러오기 +- Oracle/MariaDB 4개 국내 시장 종목 검색과 실행본 `종목.ini` 31개 컷 선택 - 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI -- `F2`, `F8`, `Esc` 단축키 +- `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약 - 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관 - Oracle/MariaDB 실제 비동기 `IDataQueryExecutor`와 소스별 health/retry/timeout - WebView 코스피·코스닥·NXT·5개 지수·해외 실데이터 조회와 장애 UI @@ -29,12 +30,16 @@ - 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트 - MSIX 패키지 매니페스트와 x64 게시 프로필 -Oracle/MariaDB 연결 계층과 Tornado/K3D 어댑터는 구현·검증을 완료했습니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md)를 참고하세요. +Oracle/MariaDB 연결 계층과 Tornado/K3D 어댑터는 구현·검증을 완료했습니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. -## 검증 기준선 (2026-07-11) +35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 운영 화면은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 운영 DB write와 각 화면의 실제 PGM 확인은 별도 승인 회차 전까지 완료 증거로 간주하지 않습니다. -- 검증 소스는 clean `main`의 `fa0eae7b61f57f134dfd30ebf1e59e9cc2cccfc5`다. Core 790개, Infrastructure 65개, Playout 374개가 Debug/Release x64에서 각각 모두 통과했고, 실제 Oracle/MariaDB read-only DB smoke도 55/55를 통과했다. -- Visual Studio 2026의 MSBuild `18.7.8`로 Debug/Release x64와 앱 패키지 빌드를 완료했다. 서명·설치·내용 감사를 통과한 MSIX의 SHA-256은 `D51E8CB637860D9F0377AEE6FF691D7D954BA0FF7E30B1AB65443DA153BC5429`이며, 패키지 컨텍스트의 `DryRun` 전체 workflow도 통과했다. +## 최종 검증 기준선 (2026-07-12) + +- 현재 마이그레이션 소스에서 Core 1,116개, Infrastructure 125개, Playout 374개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,615개, 전체 3,230개이며 실패·skip·경고는 0건이다. Web 구문 19개와 테스트 235/235, 원본 scene 기준선 35/35도 통과했다. +- 원본 `Cuts` 기준으로 도달 가능한 builder 34개와 active alias 45개의 컷 커버리지를 다시 검사했고 missing/unsafe는 0건이다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색 63건, 필수 scene 33/33과 전체 query 55/55를 통과했다. `s5025`는 승인된 외부 수동 파일이 필요한 전제조건으로 분리된다. +- Visual Studio 2026 MSBuild `18.7.8.30822`로 Debug x64와 signed Release x64 MSIX 빌드를 완료했다. `1.0.2.0` 패키지 SHA-256은 `4A4ED867D16B75C1C82CB55DA5111E0502573F486B8B897889069843D9DF5072`이며 서명, 281개 패키지 항목, Web 24개 byte-for-byte 일치, 금지 자산·비밀값 0건을 확인했다. +- 설치본 `Wickedness.MBNStockWebView_1.0.2.0_x64__qbv3jkvsn3aj0`은 실제 package context에서 WebView2 프로세스 6개와 함께 실행된다. 전체 운영 UI, Oracle/MariaDB 연결, `삼성` 63건 검색, 기본 `DryRun`, 응답 정상과 패키지 생성 이후 오류 이벤트 0건을 확인했다. - 실제 Tornado2 검증은 고정 계획 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19`의 `MBNWEB-20260711-H` 한 회차로 제한했다. PGM에서 `5001`과 `5074` page 1/page 2를 확인한 뒤 TAKE OUT의 검은 화면을 확인했다. Network Monitoring의 정확한 합계는 `SCENE_PREPARE 5001 = 3`, `SCENE_PREPARE 5074 = 2`, `PLAY = 4`, `SCENE_PLAYED = 4`, `FAILURE = 0`, `ERROR = 0`, retry `0`이며 최종 `OutcomeUnknown = false`다. - 실제 Tornado 증거는 승인된 `5001`/`5074`에만 적용한다. 나머지를 포함한 35개 builder, 45개 active alias, 5·6·12행 PageN 경계와 마지막 페이지는 자동 매트릭스 테스트로 검증했다. `s5025`는 Git/MSIX 밖의 승인된 trusted CP949 입력 파일이 있어야 하는 운영 전제조건을 유지한다. diff --git a/Web/app.js b/Web/app.js index bb19dff..ffabbf3 100644 --- a/Web/app.js +++ b/Web/app.js @@ -3,7 +3,44 @@ const playoutSafety = globalThis.MbnPlayoutSafety; if (!playoutSafety) throw new Error("Playout safety module is unavailable."); + const operatorWorkflow = globalThis.MbnOperatorWorkflow; + if (!operatorWorkflow) throw new Error("Operator workflow module is unavailable."); + const fixedWorkflow = globalThis.MbnLegacyFixedWorkflow; + if (!fixedWorkflow) throw new Error("Legacy fixed workflow module is unavailable."); + const industryWorkflow = globalThis.MbnIndustryWorkflow; + if (!industryWorkflow) throw new Error("Legacy industry workflow module is unavailable."); + const comparisonWorkflow = globalThis.MbnComparisonWorkflow; + if (!comparisonWorkflow) throw new Error("Legacy comparison workflow module is unavailable."); + const themeWorkflow = globalThis.MbnThemeWorkflow; + if (!themeWorkflow) throw new Error("Legacy theme workflow module is unavailable."); + const overseasWorkflow = globalThis.MbnOverseasWorkflow; + if (!overseasWorkflow) throw new Error("Legacy overseas workflow module is unavailable."); + const expertWorkflow = globalThis.MbnExpertWorkflow; + if (!expertWorkflow) throw new Error("Legacy expert workflow module is unavailable."); + const tradingHaltWorkflow = globalThis.MbnTradingHaltWorkflow; + if (!tradingHaltWorkflow) throw new Error("Legacy trading-halt workflow module is unavailable."); + const candleOptionsWorkflow = globalThis.MbnCandleOptionsWorkflow; + if (!candleOptionsWorkflow) throw new Error("Global candle-options workflow module is unavailable."); + const manualListsWorkflow = globalThis.MbnManualListsWorkflow; + if (!manualListsWorkflow) throw new Error("Manual lists workflow module is unavailable."); + const manualFinancialWorkflow = globalThis.MbnManualFinancialWorkflow; + if (!manualFinancialWorkflow) throw new Error("Manual financial workflow module is unavailable."); + const namedManualRestoreWorkflow = globalThis.MbnNamedManualRestoreWorkflow; + if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable."); + const operatorCatalogWorkflow = globalThis.MbnOperatorCatalogWorkflow; + if (!operatorCatalogWorkflow) throw new Error("Operator catalog workflow module is unavailable."); + const manualListsUiModule = globalThis.MbnManualListsUi; + if (!manualListsUiModule) throw new Error("Manual lists UI module is unavailable."); + const manualFinancialUiModule = globalThis.MbnManualFinancialUi; + if (!manualFinancialUiModule) throw new Error("Manual financial UI module is unavailable."); + const operatorCatalogUiModule = globalThis.MbnOperatorCatalogUi; + if (!operatorCatalogUiModule) throw new Error("Operator catalog UI module is unavailable."); + const namedPlaylistWorkflow = globalThis.MbnNamedPlaylistWorkflow; + if (!namedPlaylistWorkflow) throw new Error("Named playlist workflow module is unavailable."); const DEFAULT_FADE_DURATION = 6; + const namedPagedBuilderKeys = new Set(["s5074", "s5077", "s5088"]); + const namedWriteOperations = new Set(["create", "replace", "delete"]); + const namedInvalidBuilderKey = "s0"; const catalog = [ { builderKey: "s5001", code: "5001", aliases: ["5001", "N5001"], title: "1열판 · 기본", detail: "국내·NXT·해외 현재가", category: "plate", market: "kospi" }, @@ -48,6 +85,11 @@ .filter(item => item.reachable !== false) .flatMap(sceneAliases)); if (reachableAliases.size !== 45) throw new Error("Legacy scene catalog must expose all 45 active aliases."); + const operatorInputBuilderKeys = new Set([ + "s5001", "s5006", "s5011", "s5026", "s5029", "s5037", "s5074", + "s5076", "s5077", "s5079", "s5080", "s5081", "s5086", "s5087", + "s5088", "s8003", "s8010" + ]); const sceneSelectionDefaults = Object.freeze({ s5001: ["KOSPI", "삼성전자", "", "CURRENT", ""], @@ -112,20 +154,57 @@ const marketTitles = { dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽", index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽", - compare: "종목 비교", theme: "테마 관리", expert: "전문가 추천", halted: "거래 정지" + compare: "종목 비교", theme: "테마 관리", "foreign-stock": "해외종목 그래픽", + expert: "전문가 추천", halted: "거래 정지" }; - const liveDataViews = new Set(["kospi", "kosdaq", "index", "overseas"]); + const liveDataViews = new Set(["index", "overseas"]); + const databaseBackedViews = new Set([ + "kospi", "kosdaq", "index", "overseas", "theme", "foreign-stock", "expert", "halted" + ]); const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" }; const storageKey = "mbn-stock-webview.playlist.v1"; + const namedPlaylistBackupStorageKey = "mbn-stock-webview.playlist.before-db-load.v1"; + const comparisonStorageKey = "mbn-stock-webview.comparison-pairs.v1"; const state = { activeMarket: "dashboard", activeCategory: "all", + stockWorkflowCollapsed: false, + fixedWorkflowCollapsed: false, + industryWorkflowCollapsed: false, + fixedSearch: "", search: "", playlist: [], selectedRows: new Set(), + selectionAnchorIndex: -1, currentIndex: -1, dragIndex: -1, + candleOptions: { + ma5: true, + ma20: true, + invalidIds: [] + }, + manualFinancialRestore: { + entries: new Map() + }, + namedPlaylist: { + open: false, + status: "idle", + pending: null, + pagePlanPending: null, + pagePlanError: null, + restoration: null, + definitions: [], + selectedProgramCode: "", + suggestedProgramCode: "", + canMutate: false, + writeQuarantined: false, + error: "", + loadedDefinition: null, + guardByEntryId: new Map(), + manualRestoreFinalized: true, + selectAfterRefresh: "" + }, playout: { mode: "unknown", engineState: "IDLE", @@ -170,9 +249,20 @@ changedAtMs: 0, latestStatusRequestId: null, pending: null, + takeInAfterPrepare: false, error: null, lastSignature: "" }, + background: { + requestId: null, + enabled: false, + kind: "none", + fileName: "", + canChange: false, + pending: false, + message: "원본 F2/F3 배경 상태를 확인하고 있습니다.", + error: "" + }, database: { sources: [], loading: true, lastSignature: "", latestSequence: 0 }, liveData: { requestId: null, @@ -182,6 +272,114 @@ retrievedAt: null, error: "", timeoutId: null + }, + stockSearch: { + requestId: null, + query: "", + status: "idle", + results: [], + selected: null, + totalRowCount: 0, + truncated: false, + error: "", + timeoutId: null + }, + industry: { + requestId: null, + market: null, + status: "idle", + results: [], + totalRowCount: 0, + highlighted: null, + pairFirst: null, + pairSecond: null, + search: "", + error: "", + timeoutId: null + }, + comparison: { + source: "domestic", + draftFirst: null, + draftSecond: null, + pairList: comparisonWorkflow.deleteAllPairs(), + selectedPairId: null, + domestic: { + requestId: null, + query: "", + status: "idle", + results: [], + totalRowCount: 0, + error: "", + timeoutId: null + }, + world: { + requestId: null, + query: "", + status: "idle", + results: [], + totalRowCount: 0, + error: "", + timeoutId: null + } + }, + theme: { + nxtSession: "preMarket", + sort: "INPUT_ORDER", + search: { + requestId: null, + query: "", + status: "idle", + results: [], + totalRowCount: 0, + truncated: false, + error: "", + timeoutId: null + }, + selected: null, + preview: { + requestId: null, + status: "idle", + items: [], + totalRowCount: 0, + truncated: false, + error: "", + timeoutId: null, + catalogContext: null + } + }, + overseas: { + source: "industry", + industry: { + request: null, status: "idle", results: [], selected: null, + totalRowCount: 0, truncated: false, error: "", timeoutId: null + }, + stock: { + request: null, status: "idle", results: [], selected: null, + totalRowCount: 0, truncated: false, error: "", timeoutId: null + } + }, + expert: { + search: { + request: null, status: "idle", results: [], totalRowCount: 0, + truncated: false, error: "", timeoutId: null + }, + selected: null, + selectable: null, + preview: { + request: null, response: null, status: "idle", items: [], + totalRowCount: 0, truncated: false, error: "", timeoutId: null, + catalogContext: null + } + }, + tradingHalt: { + request: null, + status: "idle", + results: [], + selected: null, + totalRowCount: 0, + truncated: false, + error: "", + timeoutId: null } }; @@ -196,16 +394,160 @@ workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"), catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"), catalogList: document.querySelector("#catalogList"), catalogModeBadge: document.querySelector("#catalogModeBadge"), + stockWorkflow: document.querySelector("#stockWorkflow"), + stockSearchForm: document.querySelector("#stockSearchForm"), + stockSearchInput: document.querySelector("#stockSearchInput"), + stockSearchButton: document.querySelector("#stockSearchButton"), + stockSearchBadge: document.querySelector("#stockSearchBadge"), + stockWorkflowToggle: document.querySelector("#stockWorkflowToggle"), + stockSearchState: document.querySelector("#stockSearchState"), + stockSearchResults: document.querySelector("#stockSearchResults"), + selectedStock: document.querySelector("#selectedStock"), + selectedStockName: document.querySelector("#selectedStockName"), + selectedStockMeta: document.querySelector("#selectedStockMeta"), + stockMa5: document.querySelector("#stockMa5"), stockMa20: document.querySelector("#stockMa20"), + stockCutList: document.querySelector("#stockCutList"), + manualStockActions: document.querySelector("#manualStockActions"), + legacyFixedWorkflow: document.querySelector("#legacyFixedWorkflow"), + legacyFixedWorkflowTitle: document.querySelector("#legacyFixedWorkflowTitle"), + legacyFixedCount: document.querySelector("#legacyFixedCount"), + legacyFixedToggle: document.querySelector("#legacyFixedToggle"), + legacyFixedSearch: document.querySelector("#legacyFixedSearch"), + legacyFixedState: document.querySelector("#legacyFixedState"), + legacyFixedSections: document.querySelector("#legacyFixedSections"), + industryWorkflow: document.querySelector("#industryWorkflow"), + industryWorkflowTitle: document.querySelector("#industryWorkflowTitle"), + industryCount: document.querySelector("#industryCount"), + industryRetry: document.querySelector("#industryRetry"), + industryWorkflowToggle: document.querySelector("#industryWorkflowToggle"), + industrySearch: document.querySelector("#industrySearch"), + industryState: document.querySelector("#industryState"), + industryResults: document.querySelector("#industryResults"), + industryPrimary: document.querySelector("#industryPrimary"), + industrySecondary: document.querySelector("#industrySecondary"), + industrySwap: document.querySelector("#industrySwap"), + industryClear: document.querySelector("#industryClear"), + industryCutList: document.querySelector("#industryCutList"), + comparisonWorkflow: document.querySelector("#comparisonWorkflow"), + comparisonPairCount: document.querySelector("#comparisonPairCount"), + comparisonSourceTabs: document.querySelector("#comparisonSourceTabs"), + comparisonDomesticPanel: document.querySelector("#comparisonDomesticPanel"), + comparisonDomesticSearchForm: document.querySelector("#comparisonDomesticSearchForm"), + comparisonDomesticSearch: document.querySelector("#comparisonDomesticSearch"), + comparisonDomesticSearchButton: document.querySelector("#comparisonDomesticSearchButton"), + comparisonDomesticState: document.querySelector("#comparisonDomesticState"), + comparisonDomesticResults: document.querySelector("#comparisonDomesticResults"), + comparisonFixedPanel: document.querySelector("#comparisonFixedPanel"), + comparisonFixedTargets: document.querySelector("#comparisonFixedTargets"), + comparisonWorldPanel: document.querySelector("#comparisonWorldPanel"), + comparisonWorldSearchForm: document.querySelector("#comparisonWorldSearchForm"), + comparisonWorldSearch: document.querySelector("#comparisonWorldSearch"), + comparisonWorldSearchButton: document.querySelector("#comparisonWorldSearchButton"), + comparisonWorldState: document.querySelector("#comparisonWorldState"), + comparisonWorldResults: document.querySelector("#comparisonWorldResults"), + comparisonFirst: document.querySelector("#comparisonFirst"), + comparisonSecond: document.querySelector("#comparisonSecond"), + comparisonSwap: document.querySelector("#comparisonSwap"), + comparisonClear: document.querySelector("#comparisonClear"), + comparisonSavePair: document.querySelector("#comparisonSavePair"), + comparisonMoveUp: document.querySelector("#comparisonMoveUp"), + comparisonMoveDown: document.querySelector("#comparisonMoveDown"), + comparisonDeletePair: document.querySelector("#comparisonDeletePair"), + comparisonDeleteAll: document.querySelector("#comparisonDeleteAll"), + comparisonPairList: document.querySelector("#comparisonPairList"), + comparisonYieldBatch: document.querySelector("#comparisonYieldBatch"), + comparisonActionList: document.querySelector("#comparisonActionList"), + themeWorkflow: document.querySelector("#themeWorkflow"), + themeCount: document.querySelector("#themeCount"), + themeSearchForm: document.querySelector("#themeSearchForm"), + themeSearch: document.querySelector("#themeSearch"), + themeNxtSession: document.querySelector("#themeNxtSession"), + themeSearchButton: document.querySelector("#themeSearchButton"), + themeSearchState: document.querySelector("#themeSearchState"), + themeResults: document.querySelector("#themeResults"), + selectedTheme: document.querySelector("#selectedTheme"), + selectedThemeTitle: document.querySelector("#selectedThemeTitle"), + selectedThemeMeta: document.querySelector("#selectedThemeMeta"), + themeSort: document.querySelector("#themeSort"), + themePreviewCount: document.querySelector("#themePreviewCount"), + themePreviewState: document.querySelector("#themePreviewState"), + themePreviewItems: document.querySelector("#themePreviewItems"), + themeActionList: document.querySelector("#themeActionList"), + themeCatalogManageButton: document.querySelector("#themeCatalogManageButton"), + overseasWorkflow: document.querySelector("#overseasWorkflow"), + overseasCount: document.querySelector("#overseasCount"), + overseasSourceTabs: document.querySelector("#overseasSourceTabs"), + overseasSearchForm: document.querySelector("#overseasSearchForm"), + overseasSearch: document.querySelector("#overseasSearch"), + overseasSearchButton: document.querySelector("#overseasSearchButton"), + overseasSearchState: document.querySelector("#overseasSearchState"), + overseasResults: document.querySelector("#overseasResults"), + selectedOverseas: document.querySelector("#selectedOverseas"), + selectedOverseasName: document.querySelector("#selectedOverseasName"), + selectedOverseasMeta: document.querySelector("#selectedOverseasMeta"), + overseasMa5: document.querySelector("#overseasMa5"), + overseasMa20: document.querySelector("#overseasMa20"), + overseasActionList: document.querySelector("#overseasActionList"), + expertWorkflow: document.querySelector("#expertWorkflow"), + expertCount: document.querySelector("#expertCount"), + expertSearchForm: document.querySelector("#expertSearchForm"), + expertSearch: document.querySelector("#expertSearch"), + expertSearchButton: document.querySelector("#expertSearchButton"), + expertSearchState: document.querySelector("#expertSearchState"), + expertResults: document.querySelector("#expertResults"), + selectedExpert: document.querySelector("#selectedExpert"), + selectedExpertName: document.querySelector("#selectedExpertName"), + selectedExpertMeta: document.querySelector("#selectedExpertMeta"), + expertPreviewCount: document.querySelector("#expertPreviewCount"), + expertPreviewState: document.querySelector("#expertPreviewState"), + expertPreviewItems: document.querySelector("#expertPreviewItems"), + expertActionList: document.querySelector("#expertActionList"), + expertCatalogManageButton: document.querySelector("#expertCatalogManageButton"), + tradingHaltWorkflow: document.querySelector("#tradingHaltWorkflow"), + tradingHaltCount: document.querySelector("#tradingHaltCount"), + tradingHaltSearchForm: document.querySelector("#tradingHaltSearchForm"), + tradingHaltSearch: document.querySelector("#tradingHaltSearch"), + tradingHaltSearchButton: document.querySelector("#tradingHaltSearchButton"), + tradingHaltSearchState: document.querySelector("#tradingHaltSearchState"), + tradingHaltResults: document.querySelector("#tradingHaltResults"), + selectedTradingHalt: document.querySelector("#selectedTradingHalt"), + selectedTradingHaltName: document.querySelector("#selectedTradingHaltName"), + selectedTradingHaltMeta: document.querySelector("#selectedTradingHaltMeta"), + tradingHaltMa5: document.querySelector("#tradingHaltMa5"), + tradingHaltMa20: document.querySelector("#tradingHaltMa20"), + tradingHaltActionList: document.querySelector("#tradingHaltActionList"), liveDataPanel: document.querySelector("#liveDataPanel"), liveDataTitle: document.querySelector("#liveDataTitle"), liveDataState: document.querySelector("#liveDataState"), liveDataTables: document.querySelector("#liveDataTables"), databaseSources: document.querySelector("#databaseSources"), playlistBody: document.querySelector("#playlistBody"), playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"), + toggleAllEnabled: document.querySelector("#toggleAllEnabledButton"), selectAll: document.querySelector("#selectAllButton"), moveUpButton: document.querySelector("#moveUpButton"), moveDownButton: document.querySelector("#moveDownButton"), deleteButton: document.querySelector("#deleteButton"), + deleteAllButton: document.querySelector("#deleteAllButton"), + globalMa5: document.querySelector("#globalMa5"), + globalMa20: document.querySelector("#globalMa20"), loadButton: document.querySelector("#loadButton"), + saveButton: document.querySelector("#saveButton"), + namedPlaylistOpenButton: document.querySelector("#namedPlaylistOpenButton"), + namedPlaylistPanel: document.querySelector("#namedPlaylistPanel"), + namedPlaylistStateBadge: document.querySelector("#namedPlaylistStateBadge"), + namedPlaylistRefreshButton: document.querySelector("#namedPlaylistRefreshButton"), + namedPlaylistCloseButton: document.querySelector("#namedPlaylistCloseButton"), + namedPlaylistStatus: document.querySelector("#namedPlaylistStatus"), + namedPlaylistDefinitions: document.querySelector("#namedPlaylistDefinitions"), + namedPlaylistSelectionSummary: document.querySelector("#namedPlaylistSelectionSummary"), + namedPlaylistLoadButton: document.querySelector("#namedPlaylistLoadButton"), + namedPlaylistPageRetryButton: document.querySelector("#namedPlaylistPageRetryButton"), + namedPlaylistRestoreBackupButton: document.querySelector("#namedPlaylistRestoreBackupButton"), + namedPlaylistProgramCode: document.querySelector("#namedPlaylistProgramCode"), + namedPlaylistProgramTitle: document.querySelector("#namedPlaylistProgramTitle"), + namedPlaylistCreateButton: document.querySelector("#namedPlaylistCreateButton"), + namedPlaylistReplaceButton: document.querySelector("#namedPlaylistReplaceButton"), + namedPlaylistDeleteButton: document.querySelector("#namedPlaylistDeleteButton"), + namedPlaylistGuardSummary: document.querySelector("#namedPlaylistGuardSummary"), previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"), playoutState: document.querySelector("#playoutState"), playoutSummary: document.querySelector("#playoutSummary"), @@ -217,6 +559,10 @@ playoutConnectionDot: document.querySelector("#playoutConnectionDot"), playoutConnectionState: document.querySelector("#playoutConnectionState"), playoutSafetyMode: document.querySelector("#playoutSafetyMode"), + backgroundFileName: document.querySelector("#backgroundFileName"), + backgroundStateText: document.querySelector("#backgroundStateText"), + chooseBackgroundButton: document.querySelector("#chooseBackgroundButton"), + toggleBackgroundButton: document.querySelector("#toggleBackgroundButton"), playoutStatusMessage: document.querySelector("#playoutStatusMessage"), playoutSceneCode: document.querySelector("#playoutSceneCode"), playoutEntryId: document.querySelector("#playoutEntryId"), @@ -247,11 +593,61 @@ }; const nativeBridge = window.chrome?.webview; + let manualListsUi = null; + let manualFinancialUi = null; + let operatorCatalogUi = null; function createId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } + function operatorUiLocked() { + return isPlaylistSnapshotLocked() || namedPlaylistBusy() || + state.manualFinancialRestore.entries.size > 0; + } + + function selectedStockForManualFinancial() { + const selected = state.stockSearch.selected; + if (!selected) return null; + return Object.freeze({ + market: selected.isNxt ? `nxt-${selected.market}` : selected.market, + source: selected.isNxt ? "mariaDb" : "oracle", + name: selected.stockName, + code: selected.stockCode + }); + } + + function appendTrustedOperatorEntry(entry) { + if (operatorUiLocked()) throw new Error("The operator playlist is locked."); + if (!entry || typeof entry !== "object" || Array.isArray(entry) || + typeof entry.id !== "string" || state.playlist.some(item => item.id === entry.id)) { + throw new TypeError("A unique trusted operator playlist entry is required."); + } + const source = entry.operator?.source; + if (source === "legacy-manual-lists-workflow") { + if (!manualListsWorkflow.restorePlaylistEntry(entry)) { + throw new TypeError("The manual-list entry failed strict restoration."); + } + } else if (source === "manual-financial-workflow") { + if (!manualFinancialWorkflow.isPlaylistEntryPlayable(entry)) { + throw new TypeError("The manual-financial entry is not native-confirmed."); + } + } else { + throw new TypeError("The operator UI attempted to append an unsupported entry source."); + } + const definition = catalog.find(value => value.builderKey === entry.builderKey && + sceneAliases(value).includes(String(entry.code || ""))); + if (!definition) throw new TypeError("The operator entry does not resolve to a registered scene."); + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + return true; + } + function isPlaylistSnapshotLocked() { return playoutSafety.playlistSnapshotLocked({ pending: Boolean(state.playout.pending), @@ -326,8 +722,2673 @@ } } + function fixedMarketTitle(market) { + return { overseas: "해외", exchange: "환율", index: "지수" }[market] || "원본"; + } + + function openFixedManualAction(action) { + if (!action || action.available !== false || operatorUiLocked()) return false; + if (action.builderKey === "s5025") { + manualListsUi?.openNetSell(action.selection?.groupCode); + return true; + } + if (action.builderKey === "s5074" && action.selection?.groupCode === "PAGED_VI") { + manualListsUi?.openVi(); + return true; + } + showToast(action.prerequisite || "지원하는 수동 입력 화면을 찾지 못했습니다."); + return false; + } + + function addFixedAction(action) { + if (action?.available === false) { + openFixedManualAction(action); + return; + } + if (!action?.available || !requirePlaylistEditable()) { + return; + } + try { + const created = fixedWorkflow.createFixedPlaylistEntry(action.id, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION, + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }); + const definition = catalog.find(item => + item.builderKey === created.builderKey && sceneAliases(item).includes(created.code)); + if (!definition) throw new Error("The fixed action does not resolve to a registered scene alias."); + const entry = { ...definition, ...created }; + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${entry.code} ${action.section} · ${action.label} 추가`); + showToast(`${action.label} 항목을 플레이리스트에 추가했습니다.`); + } catch { + showToast("원본 운영 항목 매핑을 확인할 수 없어 추가하지 않았습니다."); + } + } + + function renderFixedWorkflow() { + const visible = fixedWorkflow.fixedMarkets.includes(state.activeMarket); + elements.legacyFixedWorkflow.hidden = !visible; + if (!visible) return; + + const allActions = fixedWorkflow.actionsForMarket(state.activeMarket); + const search = state.fixedSearch.trim().toLocaleLowerCase("ko-KR"); + const actions = allActions.filter(action => !search || + `${action.section} ${action.label} ${action.code} ${action.builderKey}` + .toLocaleLowerCase("ko-KR").includes(search)); + const available = allActions.filter(action => action.available).length; + const manual = allActions.length - available; + elements.legacyFixedWorkflow.classList.toggle("collapsed", state.fixedWorkflowCollapsed); + elements.legacyFixedToggle.textContent = state.fixedWorkflowCollapsed ? "펼치기" : "접기"; + elements.legacyFixedToggle.setAttribute("aria-expanded", String(!state.fixedWorkflowCollapsed)); + elements.legacyFixedWorkflowTitle.textContent = `${fixedMarketTitle(state.activeMarket)} 원본 운영 항목`; + elements.legacyFixedCount.textContent = `${allActions.length} ACTIONS`; + elements.legacyFixedState.textContent = search + ? `${actions.length}개 검색 결과 · 원본 실행본 SHA 고정` + : `${available}개 즉시 사용${manual ? ` · 수동 전제 ${manual}개` : ""}`; + elements.legacyFixedSections.replaceChildren(); + + if (!actions.length) { + const empty = document.createElement("div"); + empty.className = "catalog-empty"; + empty.textContent = "조건에 맞는 원본 운영 항목이 없습니다."; + elements.legacyFixedSections.append(empty); + return; + } + + const sections = new Map(); + for (const action of actions) { + if (!sections.has(action.section)) sections.set(action.section, []); + sections.get(action.section).push(action); + } + for (const [sectionName, sectionActions] of sections) { + const section = document.createElement("section"); + section.className = "legacy-fixed-section"; + const header = document.createElement("header"); + const title = document.createElement("strong"); + const count = document.createElement("span"); + title.textContent = sectionName; + count.textContent = `${sectionActions.length}`; + header.append(title, count); + section.append(header); + + for (const action of sectionActions) { + const row = document.createElement("article"); + row.className = `legacy-fixed-row${action.available ? "" : " unavailable"}`; + const number = document.createElement("span"); + number.className = "legacy-fixed-number"; + number.textContent = action.id.slice(-3); + const copy = document.createElement("div"); + copy.className = "legacy-fixed-copy"; + const label = document.createElement("strong"); + const meta = document.createElement("small"); + label.textContent = action.label; + meta.textContent = action.available + ? `${action.code} · ${action.builderKey}` + : `수동 입력 필요 · ${action.builderKey}`; + copy.append(label, meta); + const add = document.createElement("button"); + add.className = "legacy-fixed-add"; + add.type = "button"; + add.textContent = action.available ? "+" : "입력"; + add.disabled = operatorUiLocked(); + add.title = action.available ? "플레이리스트에 추가" : "원본 수동 입력 화면 열기"; + add.addEventListener("click", () => addFixedAction(action)); + row.addEventListener("dblclick", event => { + if (event.target.closest("button")) return; + addFixedAction(action); + }); + row.append(number, copy, add); + section.append(row); + } + elements.legacyFixedSections.append(section); + } + } + + function synchronizeFixedEntries(showFeedback) { + let changed = false; + let invalid = false; + state.playlist = state.playlist.map(item => { + if (item?.operator?.source !== "legacy-fixed-workflow") return item; + const refreshed = fixedWorkflow.refreshFixedPlaylistEntry(item); + if (!refreshed) { + invalid = true; + return item; + } + const definition = catalog.find(value => + value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code)); + if (!definition) { + invalid = true; + return item; + } + if (refreshed.selection.dataCode !== item.selection?.dataCode || + refreshed.selection.graphicType !== item.selection?.graphicType) changed = true; + return { ...definition, ...refreshed }; + }); + if (invalid) { + showToast("저장된 원본 운영 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); + return false; + } + if (changed) { + renderPlaylist(); + updatePreview(); + if (showFeedback) showToast("날짜와 NXT 세션을 현재 운영 시점으로 갱신했습니다."); + } + return true; + } + + function clearIndustryTimeout() { + if (state.industry.timeoutId !== null) clearTimeout(state.industry.timeoutId); + state.industry.timeoutId = null; + } + + function industryIdentity(industry) { + if (!industry) return ""; + return [industry.market, industry.code, industry.name].join("|"); + } + + function reconcileIndustrySelection(value, results) { + const identity = industryIdentity(value); + return identity ? results.find(item => industryIdentity(item) === identity) || null : null; + } + + function selectIndustry(industry) { + state.industry.highlighted = industry; + renderIndustryWorkflow(); + addLog(`업종 선택 · ${industry.name} · ${industry.code}`); + } + + function shiftIndustryPair(industry) { + state.industry.pairSecond = state.industry.pairFirst; + state.industry.pairFirst = industry; + renderIndustryWorkflow(); + addLog(`업종 비교 선택 · ${industry.name}`); + } + + function addIndustryCut(cutId) { + if (!requirePlaylistEditable()) return; + const industry = state.industry; + if (!industryWorkflow.isCutAllowed( + state.activeMarket, + cutId, + industry.highlighted, + industry.pairFirst, + industry.pairSecond)) { + showToast(cutId === "two-column" + ? "업종 목록에서 두 업종을 차례로 더블클릭하세요." + : "업종 목록에서 한 업종을 선택하세요."); + return; + } + + try { + const created = industryWorkflow.createIndustryPlaylistEntry(state.activeMarket, cutId, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION, + selected: industry.highlighted, + pair: [industry.pairFirst, industry.pairSecond], + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }); + const definition = catalog.find(item => + item.builderKey === created.builderKey && sceneAliases(item).includes(created.code)); + if (!definition) throw new Error("The industry action does not resolve to a registered scene alias."); + const entry = { ...definition, ...created }; + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`); + showToast(`${entry.detail} 항목을 플레이리스트에 추가했습니다.`); + } catch { + showToast("선택한 업종과 컷을 안전한 장면으로 구성하지 못했습니다."); + } + } + + function renderIndustryWorkflow() { + const visible = industryWorkflow.markets.includes(state.activeMarket); + elements.industryWorkflow.hidden = !visible; + if (!visible) return; + + const industry = state.industry; + const marketLabel = state.activeMarket === "kospi" ? "코스피" : "코스닥"; + const playlistLocked = isPlaylistSnapshotLocked(); + elements.industryWorkflow.classList.toggle("collapsed", state.industryWorkflowCollapsed); + elements.industryWorkflowToggle.textContent = state.industryWorkflowCollapsed ? "펼치기" : "접기"; + elements.industryWorkflowToggle.setAttribute("aria-expanded", String(!state.industryWorkflowCollapsed)); + elements.industryWorkflowTitle.textContent = `${marketLabel} 업종 선택`; + elements.industryCount.textContent = industry.status === "loading" + ? "LOADING" + : `${industry.totalRowCount.toLocaleString("ko-KR")} DB`; + elements.industryRetry.disabled = industry.status === "loading"; + elements.industryState.className = `industry-state ${industry.status}`; + if (industry.status === "loading") { + elements.industryState.textContent = `${marketLabel} 업종 목록을 Oracle에서 조회하고 있습니다.`; + } else if (industry.status === "error") { + elements.industryState.textContent = industry.error || "업종 목록을 불러오지 못했습니다."; + } else if (industry.status === "ready") { + elements.industryState.textContent = `${industry.totalRowCount.toLocaleString("ko-KR")}개 업종 · 클릭은 단일 선택, 더블클릭은 비교 선택`; + } else { + elements.industryState.textContent = "업종 목록을 조회할 준비가 되었습니다."; + } + + const term = industry.search.trim().toLocaleLowerCase("ko-KR"); + const results = industry.results.filter(item => !term || + `${item.name} ${item.code}`.toLocaleLowerCase("ko-KR").includes(term)); + elements.industryResults.replaceChildren(); + if (!results.length) { + const empty = document.createElement("div"); + empty.className = "industry-results-empty"; + empty.textContent = industry.status === "ready" ? "일치하는 업종이 없습니다." : "업종 목록이 여기에 표시됩니다."; + elements.industryResults.append(empty); + } else { + const selectedIdentity = industryIdentity(industry.highlighted); + for (const item of results) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "industry-result"; + row.setAttribute("role", "option"); + const selected = industryIdentity(item) === selectedIdentity; + row.classList.toggle("selected", selected); + row.setAttribute("aria-selected", selected ? "true" : "false"); + const name = document.createElement("strong"); + const code = document.createElement("small"); + name.textContent = item.name; + code.textContent = item.code; + row.append(name, code); + row.addEventListener("click", () => selectIndustry(item)); + row.addEventListener("dblclick", () => shiftIndustryPair(item)); + elements.industryResults.append(row); + } + } + + elements.industryPrimary.textContent = industry.pairFirst?.name || "선택 없음"; + elements.industrySecondary.textContent = industry.pairSecond?.name || "선택 없음"; + elements.industrySwap.disabled = playlistLocked || !industry.pairFirst || !industry.pairSecond; + elements.industryClear.disabled = playlistLocked || + (!industry.highlighted && !industry.pairFirst && !industry.pairSecond); + + elements.industryCutList.replaceChildren(); + let previousSection = null; + industryWorkflow.cuts.forEach((cut, index) => { + if (cut.section !== previousSection) { + const section = document.createElement("div"); + section.className = "industry-cut-section"; + section.textContent = cut.section; + elements.industryCutList.append(section); + previousSection = cut.section; + } + const allowed = industryWorkflow.isCutAllowed( + state.activeMarket, cut.id, industry.highlighted, industry.pairFirst, industry.pairSecond); + const row = document.createElement("div"); + row.className = "industry-cut-row"; + row.classList.toggle("disabled", !allowed); + const number = document.createElement("span"); + number.className = "industry-cut-number"; + number.textContent = String(index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + copy.className = "industry-cut-copy"; + const label = document.createElement("strong"); + const detail = document.createElement("small"); + label.textContent = cut.label; + detail.textContent = `${cut.builderKey} · ${cut.kind === "fixed" ? "시장 전체" : (cut.kind === "pair" ? "두 업종" : "선택 업종")}`; + copy.append(label, detail); + const add = document.createElement("button"); + add.type = "button"; + add.className = "industry-cut-add"; + add.textContent = "+"; + add.disabled = !allowed || playlistLocked; + add.title = allowed ? "플레이리스트에 추가" : (cut.kind === "pair" ? "두 업종을 더블클릭해 선택하세요." : "업종을 선택하세요."); + add.addEventListener("click", () => addIndustryCut(cut.id)); + row.addEventListener("dblclick", event => { + if (!event.target.closest("button") && allowed) addIndustryCut(cut.id); + }); + row.append(number, copy, add); + elements.industryCutList.append(row); + }); + } + + function requestIndustries(market = state.activeMarket) { + clearIndustryTimeout(); + if (!industryWorkflow.markets.includes(market)) return; + const changingMarket = state.industry.market !== market; + if (changingMarket) { + state.industry.highlighted = null; + state.industry.pairFirst = null; + state.industry.pairSecond = null; + state.industry.search = ""; + elements.industrySearch.value = ""; + } + const requestId = createId(); + state.industry.requestId = requestId; + state.industry.market = market; + state.industry.status = "loading"; + state.industry.results = []; + state.industry.totalRowCount = 0; + state.industry.error = ""; + renderIndustryWorkflow(); + + if (!nativeBridge) { + state.industry.status = "error"; + state.industry.error = "브라우저 미리보기에서는 실제 업종 DB 조회를 사용할 수 없습니다."; + renderIndustryWorkflow(); + return; + } + + postNative("request-industries", { requestId, market }); + state.industry.timeoutId = setTimeout(() => { + if (state.industry.requestId !== requestId || state.industry.status !== "loading") return; + state.industry.status = "error"; + state.industry.error = "업종 조회 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 조회하세요."; + renderIndustryWorkflow(); + addLog(`${marketLabelForIndustry(market)} 업종 조회 시간 초과`); + }, 30000); + } + + function marketLabelForIndustry(market) { + return market === "kospi" ? "코스피" : "코스닥"; + } + + function failIndustryResponse(message) { + clearIndustryTimeout(); + state.industry.status = "error"; + state.industry.results = []; + state.industry.totalRowCount = 0; + state.industry.error = message; + renderIndustryWorkflow(); + } + + function handleIndustryResults(payload) { + if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return; + const rawResults = Array.isArray(payload.results) ? payload.results : null; + if (!rawResults || rawResults.length > 500 || !Number.isInteger(payload.totalRowCount) || + payload.totalRowCount !== rawResults.length) { + failIndustryResponse("업종 목록 응답 형식이 올바르지 않습니다."); + return; + } + const results = []; + const names = new Set(); + const codes = new Set(); + for (const value of rawResults) { + const normalized = industryWorkflow.normalizeIndustry(value); + if (!normalized || normalized.market !== state.industry.market || + names.has(normalized.name) || codes.has(normalized.code)) { + failIndustryResponse("업종 목록에 올바르지 않거나 중복된 항목이 있습니다."); + return; + } + names.add(normalized.name); + codes.add(normalized.code); + results.push(normalized); + } + clearIndustryTimeout(); + state.industry.results = results; + state.industry.totalRowCount = results.length; + state.industry.highlighted = reconcileIndustrySelection(state.industry.highlighted, results); + state.industry.pairFirst = reconcileIndustrySelection(state.industry.pairFirst, results); + state.industry.pairSecond = reconcileIndustrySelection(state.industry.pairSecond, results); + state.industry.status = "ready"; + state.industry.error = ""; + renderIndustryWorkflow(); + addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 완료 · ${results.length}건`); + } + + function handleIndustryError(payload) { + if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return; + failIndustryResponse(typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : "업종 목록을 불러오지 못했습니다."); + addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 실패`); + } + + function synchronizeIndustryEntries(showFeedback) { + let changed = false; + let invalid = false; + state.playlist = state.playlist.map(item => { + if (item?.operator?.source !== "legacy-industry-workflow") return item; + const refreshed = industryWorkflow.refreshIndustryPlaylistEntry(item); + if (!refreshed) { + invalid = true; + return item; + } + const definition = catalog.find(value => + value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code)); + if (!definition) { + invalid = true; + return item; + } + if (JSON.stringify(refreshed.selection) !== JSON.stringify(item.selection)) changed = true; + return { ...definition, ...refreshed }; + }); + if (invalid) { + showToast("저장된 업종 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); + return false; + } + if (changed) { + renderPlaylist(); + updatePreview(); + if (showFeedback) showToast("업종 페이지 날짜를 현재 운영 시점으로 갱신했습니다."); + } + return true; + } + + function clearComparisonSearchTimeout(kind) { + const search = state.comparison[kind]; + if (search?.timeoutId !== null) clearTimeout(search.timeoutId); + if (search) search.timeoutId = null; + } + + function comparisonTargetDisplay(target) { + return target?.displayName || target?.stockName || target?.inputName || target?.target || "선택 없음"; + } + + function comparisonTargetMeta(target) { + if (!target) return ""; + if (target.kind === "market-target") return target.target; + if (target.kind === "world-stock") return `${target.nation} · ${target.symbol}`; + return `${target.market.toLocaleUpperCase("en-US")}${target.kind === "nxt-stock" ? " NXT" : " KRX"} · ${target.stockCode}`; + } + + function comparisonTargetFromStockResult(value) { + const stock = operatorWorkflow.normalizeStockResult(value); + if (!stock) return null; + return comparisonWorkflow.normalizeTarget({ + kind: stock.isNxt ? "nxt-stock" : "krx-stock", + market: stock.market, + stockName: stock.stockName, + stockCode: stock.stockCode, + displayName: stock.displayName + }); + } + + function comparisonTargetFromWorldResult(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return comparisonWorkflow.normalizeTarget({ + kind: "world-stock", + inputName: value.inputName, + displayName: value.inputName, + symbol: value.symbol, + nation: value.nationCode + }); + } + + function rotateComparisonDraft(target) { + if (isPlaylistSnapshotLocked()) return; + const rotated = comparisonWorkflow.rotatePair( + [state.comparison.draftFirst, state.comparison.draftSecond], + target); + if (!rotated) return; + [state.comparison.draftFirst, state.comparison.draftSecond] = rotated; + renderComparisonWorkflow(); + addLog(`비교 대상 선택 · ${comparisonTargetDisplay(target)}`); + } + + function selectedComparisonRecord() { + return state.comparison.pairList.pairs.find(record => + record.id === state.comparison.selectedPairId) || null; + } + + function selectedComparisonPair() { + return selectedComparisonRecord()?.pair || null; + } + + function persistComparisonPairList(nextList, successMessage) { + try { + localStorage.setItem(comparisonStorageKey, JSON.stringify(nextList)); + state.comparison.pairList = nextList; + if (successMessage) addLog(successMessage); + return true; + } catch { + showToast("비교쌍 로컬 저장소를 갱신하지 못했습니다."); + return false; + } + } + + function loadComparisonPairList() { + try { + const raw = JSON.parse(localStorage.getItem(comparisonStorageKey) || "null"); + state.comparison.pairList = comparisonWorkflow.normalizePairList(raw); + state.comparison.selectedPairId = state.comparison.pairList.pairs[0]?.id || null; + } catch { + state.comparison.pairList = comparisonWorkflow.deleteAllPairs(); + state.comparison.selectedPairId = null; + } + } + + function saveComparisonDraftPair() { + if (isPlaylistSnapshotLocked()) return; + const pair = comparisonWorkflow.normalizePair([ + state.comparison.draftFirst, + state.comparison.draftSecond + ]); + if (!pair) { + showToast("비교 대상 두 개를 더블클릭해 선택하세요."); + return; + } + const id = createId(); + const record = comparisonWorkflow.createPairRecord(id, pair); + if (!record) { + showToast("비교쌍 식별자를 안전하게 구성하지 못했습니다."); + return; + } + try { + const next = comparisonWorkflow.appendPair(state.comparison.pairList, record); + if (!persistComparisonPairList(next, `비교쌍 저장 · ${comparisonWorkflow.pairLabel(pair)}`)) return; + state.comparison.selectedPairId = id; + renderComparisonWorkflow(); + showToast("비교쌍을 저장하고 선택했습니다."); + } catch { + showToast("비교쌍 저장 한도 또는 형식을 확인하세요."); + } + } + + function moveComparisonPair(direction) { + if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return; + const next = comparisonWorkflow.reorderPairList( + state.comparison.pairList, + state.comparison.selectedPairId, + direction); + if (next === state.comparison.pairList || + next.pairs.map(value => value.id).join("|") === state.comparison.pairList.pairs.map(value => value.id).join("|")) return; + if (persistComparisonPairList(next, "비교쌍 순서 변경")) renderComparisonWorkflow(); + } + + function deleteSelectedComparisonPair() { + if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return; + const previousId = state.comparison.selectedPairId; + const index = state.comparison.pairList.pairs.findIndex(record => record.id === previousId); + const next = comparisonWorkflow.deletePair(state.comparison.pairList, previousId); + if (!persistComparisonPairList(next, "비교쌍 삭제")) return; + state.comparison.selectedPairId = next.pairs[Math.min(Math.max(index, 0), next.pairs.length - 1)]?.id || null; + renderComparisonWorkflow(); + } + + function deleteAllComparisonPairs() { + if (isPlaylistSnapshotLocked() || !state.comparison.pairList.pairs.length) return; + if (!window.confirm("저장된 비교쌍을 모두 삭제할까요?")) return; + const next = comparisonWorkflow.deleteAllPairs(); + if (!persistComparisonPairList(next, "비교쌍 전체 삭제")) return; + state.comparison.selectedPairId = null; + renderComparisonWorkflow(); + } + + function appendComparisonPlaylistEntries(createdEntries) { + if (!requirePlaylistEditable()) return false; + const entries = []; + for (const created of createdEntries) { + const definition = catalog.find(item => + item.builderKey === created.builderKey && sceneAliases(item).includes(created.code)); + if (!definition) { + showToast("비교 컷이 등록된 장면 alias와 일치하지 않습니다."); + return false; + } + entries.push({ ...definition, ...created }); + } + const startIndex = state.playlist.length; + state.playlist.push(...entries); + state.selectedRows.clear(); + entries.forEach(entry => state.selectedRows.add(entry.id)); + state.currentIndex = startIndex; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`비교 컷 ${entries.length}개 추가 · ${entries[0]?.title || "비교쌍"}`); + showToast(entries.length === 1 ? "비교 컷을 추가했습니다." : `비교 수익률 컷 ${entries.length}개를 추가했습니다.`); + return true; + } + + function addComparisonAction(actionId) { + const pair = selectedComparisonPair(); + if (!pair) { + showToast("저장 비교쌍에서 사용할 행을 선택하세요."); + return; + } + const compatibility = comparisonWorkflow.getCompatibility(actionId, pair); + if (!compatibility.supported) { + showToast(comparisonCompatibilityText(compatibility.reason)); + return; + } + try { + const entry = comparisonWorkflow.createComparisonPlaylistEntry(actionId, pair, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION + }); + if (appendComparisonPlaylistEntries([entry]) && entry.warning) showToast(entry.warning); + } catch { + showToast("선택한 비교쌍과 컷을 안전한 장면으로 구성하지 못했습니다."); + } + } + + function addComparisonYieldBatch() { + const pair = selectedComparisonPair(); + if (!pair || !comparisonWorkflow.yieldActionIds.every(actionId => + comparisonWorkflow.isActionAllowed(actionId, pair))) { + showToast("수익률 비교 5종은 국내 KRX 종목 두 개가 필요합니다."); + return; + } + try { + const entries = comparisonWorkflow.createYieldBatchEntries(pair, { + idPrefix: createId(), + fadeDuration: DEFAULT_FADE_DURATION + }); + appendComparisonPlaylistEntries(entries); + } catch { + showToast("수익률 비교 5종을 안전하게 구성하지 못했습니다."); + } + } + + function comparisonCompatibilityText(reason) { + return { + "pair-incomplete-or-invalid": "저장 비교쌍을 선택하세요.", + "comparison-graphs-require-two-krx-stocks": "비교 차트와 수익률은 국내 KRX 종목 두 개만 지원합니다.", + "two-futures-targets-are-unsupported": "선물 지수 두 개의 조합은 지원하지 않습니다.", + "futures-requires-a-market-target-counterpart": "선물은 다른 시장 지수와만 2열판으로 비교할 수 있습니다.", + "mixed-market-and-world-is-unsupported": "시장 지수와 해외 종목의 혼합 2열판은 안전한 DTO가 없어 지원하지 않습니다.", + "expected-mode-supports-only-krx-stocks-and-four-domestic-indices": "예상체결은 국내 KRX 종목과 국내 4개 지수만 지원합니다." + }[reason] || "현재 Core에서 안전하게 지원하지 않는 비교 조합입니다."; + } + + function renderComparisonTargetList(container, values, emptyText) { + container.replaceChildren(); + if (!values.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = emptyText; + container.append(empty); + return; + } + const locked = isPlaylistSnapshotLocked(); + for (const target of values) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "comparison-target"; + button.disabled = locked; + button.title = "더블클릭하면 첫 번째 슬롯에 넣고 기존 첫 번째를 두 번째로 이동합니다."; + const name = document.createElement("strong"); + const meta = document.createElement("small"); + name.textContent = comparisonTargetDisplay(target); + meta.textContent = comparisonTargetMeta(target); + button.append(name, meta); + button.addEventListener("dblclick", () => rotateComparisonDraft(target)); + button.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + rotateComparisonDraft(target); + } + }); + container.append(button); + } + } + + function renderComparisonSearchState(kind, element) { + const search = state.comparison[kind]; + element.className = `comparison-search-state ${search.status}`; + if (search.status === "loading") element.textContent = `“${search.query}” 검색 중입니다.`; + else if (search.status === "error") element.textContent = search.error || "검색에 실패했습니다."; + else if (search.status === "ready") { + element.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}건 · 대상을 더블클릭하세요.`; + } else { + element.textContent = kind === "domestic" ? "국내·NXT 종목명을 입력하세요." : "미국·대만 종목명을 입력하세요."; + } + } + + function renderComparisonWorkflow() { + const visible = state.activeMarket === "compare"; + elements.comparisonWorkflow.hidden = !visible; + if (!visible) return; + const comparison = state.comparison; + const locked = isPlaylistSnapshotLocked(); + const pairs = comparison.pairList.pairs; + const selectedRecord = selectedComparisonRecord(); + const selectedPair = selectedRecord?.pair || null; + elements.comparisonPairCount.textContent = `${pairs.length} PAIRS`; + + elements.comparisonSourceTabs.querySelectorAll("button[data-comparison-source]").forEach(button => { + button.classList.toggle("active", button.dataset.comparisonSource === comparison.source); + button.disabled = locked; + }); + elements.comparisonDomesticPanel.hidden = comparison.source !== "domestic"; + elements.comparisonFixedPanel.hidden = comparison.source !== "fixed"; + elements.comparisonWorldPanel.hidden = comparison.source !== "world"; + + renderComparisonSearchState("domestic", elements.comparisonDomesticState); + renderComparisonSearchState("world", elements.comparisonWorldState); + elements.comparisonDomesticSearch.disabled = locked; + elements.comparisonDomesticSearchButton.disabled = locked || comparison.domestic.status === "loading"; + elements.comparisonWorldSearch.disabled = locked; + elements.comparisonWorldSearchButton.disabled = locked || comparison.world.status === "loading"; + renderComparisonTargetList( + elements.comparisonDomesticResults, + comparison.domestic.results, + comparison.domestic.status === "ready" ? "일치하는 국내·NXT 종목이 없습니다." : "검색 결과가 여기에 표시됩니다."); + renderComparisonTargetList(elements.comparisonFixedTargets, comparisonWorkflow.marketTargets, "고정 비교 대상이 없습니다."); + renderComparisonTargetList( + elements.comparisonWorldResults, + comparison.world.results, + comparison.world.status === "ready" ? "일치하는 해외 종목이 없습니다." : "검색 결과가 여기에 표시됩니다."); + + elements.comparisonFirst.textContent = comparisonTargetDisplay(comparison.draftFirst); + elements.comparisonSecond.textContent = comparisonTargetDisplay(comparison.draftSecond); + elements.comparisonSwap.disabled = locked || !comparison.draftFirst || !comparison.draftSecond; + elements.comparisonClear.disabled = locked || (!comparison.draftFirst && !comparison.draftSecond); + elements.comparisonSavePair.disabled = locked || + !comparisonWorkflow.normalizePair([comparison.draftFirst, comparison.draftSecond]); + + elements.comparisonPairList.replaceChildren(); + if (!pairs.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = "저장된 비교쌍이 없습니다."; + elements.comparisonPairList.append(empty); + } else { + pairs.forEach((record, index) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "comparison-pair-row"; + row.disabled = locked; + row.classList.toggle("selected", record.id === comparison.selectedPairId); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", record.id === comparison.selectedPairId ? "true" : "false"); + const number = document.createElement("span"); + number.textContent = String(index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + const title = document.createElement("strong"); + const meta = document.createElement("small"); + title.textContent = comparisonWorkflow.pairLabel(record.pair); + meta.textContent = record.pair.map(comparisonTargetMeta).join(" · "); + copy.append(title, meta); + const kind = document.createElement("span"); + kind.textContent = record.pair.map(target => target.kind.replace("-stock", "")).join("/"); + row.append(number, copy, kind); + row.addEventListener("click", () => { + if (isPlaylistSnapshotLocked()) return; + comparison.selectedPairId = record.id; + renderComparisonWorkflow(); + }); + elements.comparisonPairList.append(row); + }); + } + const selectedIndex = pairs.findIndex(record => record.id === comparison.selectedPairId); + elements.comparisonMoveUp.disabled = locked || selectedIndex <= 0; + elements.comparisonMoveDown.disabled = locked || selectedIndex < 0 || selectedIndex >= pairs.length - 1; + elements.comparisonDeletePair.disabled = locked || selectedIndex < 0; + elements.comparisonDeleteAll.disabled = locked || !pairs.length; + + elements.comparisonActionList.replaceChildren(); + let previousSection = null; + comparisonWorkflow.actions.forEach((action, index) => { + const sectionName = action.kind === "two-column" + ? "2열판" + : (action.kind === "comparison" ? "종목별 비교분석" : "종목별 수익률 비교"); + if (sectionName !== previousSection) { + const section = document.createElement("div"); + section.className = "comparison-action-section"; + section.textContent = sectionName; + elements.comparisonActionList.append(section); + previousSection = sectionName; + } + const compatibility = comparisonWorkflow.getCompatibility(action.id, selectedPair); + const row = document.createElement("div"); + row.className = "comparison-action-row"; + row.classList.toggle("disabled", !compatibility.supported); + const number = document.createElement("span"); + number.textContent = String(index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + copy.className = "comparison-action-copy"; + const label = document.createElement("strong"); + const detail = document.createElement("small"); + label.textContent = action.label; + detail.textContent = compatibility.supported + ? `${action.kind === "two-column" ? "s8018/s5032" : (action.kind === "return" ? "s5029" : action.builderKey)} · ${compatibility.effectiveMode || "GRAPH"}` + : comparisonCompatibilityText(compatibility.reason); + copy.append(label, detail); + const add = document.createElement("button"); + add.type = "button"; + add.className = "comparison-action-add"; + add.textContent = "+"; + add.disabled = locked || !compatibility.supported; + add.title = compatibility.supported ? "플레이리스트에 추가" : comparisonCompatibilityText(compatibility.reason); + add.addEventListener("click", () => addComparisonAction(action.id)); + row.addEventListener("dblclick", event => { + if (!event.target.closest("button") && compatibility.supported) addComparisonAction(action.id); + }); + row.append(number, copy, add); + elements.comparisonActionList.append(row); + }); + elements.comparisonYieldBatch.disabled = locked || !selectedPair || + !comparisonWorkflow.yieldActionIds.every(actionId => comparisonWorkflow.isActionAllowed(actionId, selectedPair)); + } + + function requestComparisonDomesticSearch(event) { + event?.preventDefault(); + const search = state.comparison.domestic; + clearComparisonSearchTimeout("domestic"); + const query = String(elements.comparisonDomesticSearch.value || "").trim(); + if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) { + search.status = "error"; + search.error = "1~64자의 국내·NXT 종목명을 입력하세요."; + search.results = []; + renderComparisonWorkflow(); + return; + } + if (!nativeBridge) { + search.status = "error"; + search.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다."; + renderComparisonWorkflow(); + return; + } + const requestId = createId(); + Object.assign(search, { + requestId, query, status: "loading", results: [], totalRowCount: 0, error: "" + }); + renderComparisonWorkflow(); + postNative("search-stocks", { requestId, query, maximumResults: 200 }); + search.timeoutId = setTimeout(() => { + if (search.requestId !== requestId || search.status !== "loading") return; + search.status = "error"; + search.error = "국내·NXT 종목 검색 응답 시간이 초과되었습니다."; + renderComparisonWorkflow(); + }, 30000); + } + + function handleComparisonDomesticResults(payload) { + const search = state.comparison.domestic; + if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false; + clearComparisonSearchTimeout("domestic"); + const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : []; + const results = raw.flatMap(value => { + const target = comparisonTargetFromStockResult(value); + return target ? [target] : []; + }); + if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) { + search.status = "error"; + search.results = []; + search.error = "국내·NXT 종목 검색 결과 식별자가 올바르지 않습니다."; + } else { + search.status = "ready"; + search.results = results; + search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length; + search.error = ""; + } + renderComparisonWorkflow(); + return true; + } + + function handleComparisonDomesticError(payload) { + const search = state.comparison.domestic; + if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false; + clearComparisonSearchTimeout("domestic"); + search.status = "error"; + search.results = []; + search.error = typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : "국내·NXT 종목 검색에 실패했습니다."; + renderComparisonWorkflow(); + return true; + } + + function requestComparisonWorldSearch(event) { + event?.preventDefault(); + const search = state.comparison.world; + clearComparisonSearchTimeout("world"); + const query = String(elements.comparisonWorldSearch.value || "").trim(); + if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) { + search.status = "error"; + search.error = "1~64자의 미국·대만 종목명을 입력하세요."; + search.results = []; + renderComparisonWorkflow(); + return; + } + if (!nativeBridge) { + search.status = "error"; + search.error = "브라우저 미리보기에서는 실제 해외종목 DB 검색을 사용할 수 없습니다."; + renderComparisonWorkflow(); + return; + } + const requestId = createId(); + Object.assign(search, { + requestId, query, status: "loading", results: [], totalRowCount: 0, error: "" + }); + renderComparisonWorkflow(); + postNative("search-world-stocks", { requestId, query, maximumResults: 100 }); + search.timeoutId = setTimeout(() => { + if (search.requestId !== requestId || search.status !== "loading") return; + search.status = "error"; + search.error = "해외종목 검색 응답 시간이 초과되었습니다."; + renderComparisonWorkflow(); + }, 30000); + } + + function handleComparisonWorldResults(payload) { + const search = state.comparison.world; + if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return; + clearComparisonSearchTimeout("world"); + const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : []; + const results = raw.flatMap(value => { + const target = comparisonTargetFromWorldResult(value); + return target ? [target] : []; + }); + if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) { + search.status = "error"; + search.results = []; + search.error = "해외종목 검색 결과 식별자가 올바르지 않습니다."; + } else { + search.status = "ready"; + search.results = results; + search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length; + search.error = ""; + } + renderComparisonWorkflow(); + } + + function handleComparisonWorldError(payload) { + const search = state.comparison.world; + if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return; + clearComparisonSearchTimeout("world"); + search.status = "error"; + search.results = []; + search.error = typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : "해외종목 검색에 실패했습니다."; + renderComparisonWorkflow(); + } + + function synchronizeComparisonEntries() { + let invalid = false; + state.playlist = state.playlist.map(item => { + if (item?.operator?.source !== "legacy-comparison-workflow") return item; + const refreshed = comparisonWorkflow.refreshComparisonPlaylistEntry(item); + if (!refreshed) { + invalid = true; + return item; + } + const definition = catalog.find(value => + value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code)); + if (!definition) { + invalid = true; + return item; + } + return { ...definition, ...refreshed }; + }); + if (invalid) { + showToast("저장된 비교 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); + return false; + } + return true; + } + + function clearThemeTimeout(kind) { + const value = state.theme[kind]; + if (value?.timeoutId !== null) clearTimeout(value.timeoutId); + if (value) value.timeoutId = null; + } + + function themeSessionWire(theme) { + if (theme?.market === "krx") return "notApplicable"; + return theme?.session === "AFTER_MARKET" ? "afterMarket" : "preMarket"; + } + + function themeIdentity(theme) { + if (!theme) return ""; + return [theme.market, themeSessionWire(theme), theme.themeCode, theme.title].join("|"); + } + + function selectedThemeWithItemCount(itemCount) { + const selected = state.theme.selected; + return selected ? themeWorkflow.normalizeTheme({ ...selected, itemCount }) : null; + } + + function themeCompatibilityText(reason) { + return { + "invalid-theme": "테마를 선택하세요.", + "invalid-theme-sort": "테마 정렬 방식을 확인하세요.", + "theme-preview-is-empty": "종목이 없는 테마는 송출할 수 없습니다.", + "expected-theme-is-krx-only": "예상체결가는 KRX 테마 5단 표에서만 지원합니다.", + "nxt-session-required": "NXT 장전 또는 시간외 세션을 선택하세요." + }[reason] || "현재 테마와 컷 조합은 지원하지 않습니다."; + } + + function addThemeAction(actionId) { + if (!requirePlaylistEditable()) return; + const theme = state.theme.selected; + const sort = state.theme.sort; + if (state.theme.preview.status !== "ready" || !theme) { + showToast("테마를 선택하고 종목 미리보기가 완료될 때까지 기다리세요."); + return; + } + const compatibility = themeWorkflow.getCompatibility(actionId, theme, sort); + if (!compatibility.supported) { + showToast(themeCompatibilityText(compatibility.reason)); + return; + } + try { + const created = themeWorkflow.createThemePlaylistEntry(actionId, theme, sort, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION + }); + const definition = catalog.find(item => + item.builderKey === created.builderKey && sceneAliases(item).includes(created.code)); + if (!definition) throw new Error("The theme action does not resolve to a registered scene alias."); + const entry = { ...definition, ...created }; + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`); + showToast("테마 컷을 플레이리스트에 추가했습니다."); + } catch { + showToast("테마와 컷을 안전한 페이지 장면으로 구성하지 못했습니다."); + } + } + + function renderThemeWorkflow() { + const visible = state.activeMarket === "theme"; + elements.themeWorkflow.hidden = !visible; + if (!visible) return; + const theme = state.theme; + const search = theme.search; + const preview = theme.preview; + const locked = isPlaylistSnapshotLocked(); + elements.themeCatalogManageButton.disabled = operatorUiLocked(); + elements.themeCatalogManageButton.textContent = preview.catalogContext ? "선택 테마 편집" : "테마 관리"; + elements.themeCount.textContent = search.status === "loading" + ? "LOADING" + : `${search.totalRowCount.toLocaleString("ko-KR")} THEMES`; + elements.themeSearch.disabled = locked; + elements.themeNxtSession.disabled = locked; + elements.themeSearchButton.disabled = locked || search.status === "loading"; + elements.themeSearchState.className = `theme-search-state ${search.status}`; + if (search.status === "loading") { + elements.themeSearchState.textContent = "KRX(Oracle)와 NXT(MariaDB) 테마를 조회하고 있습니다."; + } else if (search.status === "error") { + elements.themeSearchState.textContent = search.error || "테마 검색에 실패했습니다."; + } else if (search.status === "ready") { + elements.themeSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 테마${search.truncated ? " · 결과 제한 적용" : ""}`; + } else { + elements.themeSearchState.textContent = "조회 버튼을 누르면 전체 테마를 불러옵니다."; + } + + elements.themeResults.replaceChildren(); + if (!search.results.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = search.status === "ready" ? "일치하는 테마가 없습니다." : "테마 검색 결과가 여기에 표시됩니다."; + elements.themeResults.append(empty); + } else { + const selectedIdentity = themeIdentity(theme.selected); + for (const item of search.results) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "theme-result"; + row.disabled = locked; + const selected = themeIdentity(item) === selectedIdentity; + row.classList.toggle("selected", selected); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", selected ? "true" : "false"); + const copy = document.createElement("span"); + const title = document.createElement("strong"); + const meta = document.createElement("small"); + title.textContent = item.displayTitle; + meta.textContent = `${item.themeCode} · ${themeSessionWire(item)}`; + copy.append(title, meta); + const market = document.createElement("span"); + market.className = "theme-market-badge"; + market.textContent = item.market.toLocaleUpperCase("en-US"); + row.append(copy, market); + row.addEventListener("click", () => selectTheme(item)); + elements.themeResults.append(row); + } + } + + elements.selectedTheme.hidden = !theme.selected; + elements.themeSort.disabled = locked || !theme.selected; + if (theme.selected) { + elements.selectedThemeTitle.textContent = theme.selected.displayTitle; + elements.selectedThemeMeta.textContent = `${theme.selected.themeCode} · ${theme.selected.market.toLocaleUpperCase("en-US")} · ${themeSessionWire(theme.selected)}`; + } + + elements.themePreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`; + elements.themePreviewState.className = `theme-preview-state ${preview.status}`; + if (preview.status === "loading") elements.themePreviewState.textContent = "선택 테마의 종목을 조회하고 있습니다."; + else if (preview.status === "error") elements.themePreviewState.textContent = preview.error || "테마 종목을 불러오지 못했습니다."; + else if (preview.status === "ready") { + elements.themePreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 종목${preview.truncated ? " · 240개 상한 적용" : ""}`; + } else elements.themePreviewState.textContent = "테마를 선택하면 종목 구성을 확인합니다."; + elements.themePreviewItems.replaceChildren(); + if (!preview.items.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = preview.status === "ready" ? "테마에 송출 가능한 종목이 없습니다." : "미리보기 대기 중"; + elements.themePreviewItems.append(empty); + } else { + for (const item of preview.items) { + const row = document.createElement("div"); + row.className = "theme-preview-item"; + const index = document.createElement("span"); + const name = document.createElement("strong"); + const code = document.createElement("small"); + index.textContent = String(item.inputIndex).padStart(2, "0"); + name.textContent = item.itemName; + code.textContent = item.itemCode; + row.append(index, name, code); + elements.themePreviewItems.append(row); + } + } + + elements.themeActionList.replaceChildren(); + themeWorkflow.actions.forEach((action, index) => { + const compatibility = preview.status === "ready" + ? themeWorkflow.getCompatibility(action.id, theme.selected, theme.sort) + : { supported: false, reason: "invalid-theme" }; + const row = document.createElement("div"); + row.className = "theme-action-row"; + row.classList.toggle("disabled", !compatibility.supported); + const number = document.createElement("span"); + number.textContent = String(index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + copy.className = "theme-action-copy"; + const label = document.createElement("strong"); + const detail = document.createElement("small"); + label.textContent = action.label; + const page = theme.selected ? themeWorkflow.calculatePagePreview(action.id, theme.selected) : null; + detail.textContent = compatibility.supported + ? `${action.code} · ${action.pageSize}개 × ${page?.pageCount ?? "?"}페이지${page?.isTruncated ? " · 20페이지 상한" : ""}` + : themeCompatibilityText(compatibility.reason); + copy.append(label, detail); + const add = document.createElement("button"); + add.type = "button"; + add.className = "theme-action-add"; + add.textContent = "+"; + add.disabled = locked || !compatibility.supported; + add.title = compatibility.supported ? "플레이리스트에 추가" : themeCompatibilityText(compatibility.reason); + add.addEventListener("click", () => addThemeAction(action.id)); + row.addEventListener("dblclick", event => { + if (!event.target.closest("button") && compatibility.supported) addThemeAction(action.id); + }); + row.append(number, copy, add); + elements.themeActionList.append(row); + }); + } + + function requestThemeSearch(event) { + event?.preventDefault(); + const search = state.theme.search; + clearThemeTimeout("search"); + clearThemeTimeout("preview"); + const query = String(elements.themeSearch.value || "").trim(); + if (query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) { + search.status = "error"; + search.error = "테마 검색어는 64자 이하여야 합니다."; + search.results = []; + renderThemeWorkflow(); + return; + } + state.theme.selected = null; + Object.assign(state.theme.preview, { + requestId: null, status: "idle", items: [], totalRowCount: 0, truncated: false, error: "" + }); + if (!nativeBridge) { + search.status = "error"; + search.error = "브라우저 미리보기에서는 실제 테마 DB 조회를 사용할 수 없습니다."; + renderThemeWorkflow(); + return; + } + const requestId = createId(); + Object.assign(search, { + requestId, query, status: "loading", results: [], totalRowCount: 0, truncated: false, error: "" + }); + renderThemeWorkflow(); + postNative("search-themes", { + requestId, + query, + nxtSession: state.theme.nxtSession, + maximumResults: 200 + }); + search.timeoutId = setTimeout(() => { + if (search.requestId !== requestId || search.status !== "loading") return; + search.status = "error"; + search.error = "테마 검색 응답 시간이 초과되었습니다."; + renderThemeWorkflow(); + }, 30000); + } + + function handleThemeSearchResults(payload) { + const search = state.theme.search; + if (!payload || payload.requestId !== search.requestId || payload.query !== search.query || + payload.nxtSession !== state.theme.nxtSession) return; + clearThemeTimeout("search"); + const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : []; + const results = raw.flatMap(value => { + const normalized = themeWorkflow.normalizeTheme({ ...value, itemCount: null }); + return normalized ? [normalized] : []; + }); + const identities = results.map(themeIdentity); + if (results.length !== raw.length || new Set(identities).size !== identities.length) { + search.status = "error"; + search.results = []; + search.error = "테마 검색 결과 식별자가 올바르지 않습니다."; + } else { + search.status = "ready"; + search.results = results; + search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length; + search.truncated = payload.truncated === true; + search.error = ""; + } + renderThemeWorkflow(); + } + + function handleThemeSearchError(payload) { + const search = state.theme.search; + if (!payload || payload.requestId !== search.requestId || payload.query !== search.query || + payload.nxtSession !== state.theme.nxtSession) return; + clearThemeTimeout("search"); + search.status = "error"; + search.results = []; + search.error = typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : "테마 검색에 실패했습니다."; + renderThemeWorkflow(); + } + + function selectTheme(theme) { + if (isPlaylistSnapshotLocked()) return; + const normalized = themeWorkflow.normalizeTheme({ ...theme, itemCount: null }); + if (!normalized) return; + state.theme.selected = normalized; + requestThemePreview(normalized); + addLog(`테마 선택 · ${normalized.displayTitle} · ${normalized.themeCode}`); + } + + function requestThemePreview(theme) { + clearThemeTimeout("preview"); + const preview = state.theme.preview; + const requestId = createId(); + Object.assign(preview, { + requestId, status: "loading", items: [], totalRowCount: 0, truncated: false, error: "", + catalogContext: null + }); + renderThemeWorkflow(); + if (!nativeBridge) { + preview.status = "error"; + preview.error = "브라우저 미리보기에서는 실제 테마 종목 조회를 사용할 수 없습니다."; + renderThemeWorkflow(); + return; + } + postNative("request-theme-preview", { + requestId, + market: theme.market, + session: themeSessionWire(theme), + themeCode: theme.themeCode, + themeTitle: theme.title, + maximumItems: 240 + }); + preview.timeoutId = setTimeout(() => { + if (preview.requestId !== requestId || preview.status !== "loading") return; + preview.status = "error"; + preview.error = "테마 종목 미리보기 응답 시간이 초과되었습니다."; + renderThemeWorkflow(); + }, 30000); + } + + function handleThemePreviewResults(payload) { + const preview = state.theme.preview; + if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return; + const rawSelection = payload.selection; + const responseTheme = themeWorkflow.normalizeTheme({ + market: rawSelection?.market, + session: rawSelection?.session, + themeCode: rawSelection?.themeCode, + title: rawSelection?.title, + itemCount: Number.isInteger(payload.totalRowCount) ? payload.totalRowCount : null + }); + if (!responseTheme || themeIdentity(responseTheme) !== themeIdentity(state.theme.selected)) return; + clearThemeTimeout("preview"); + const rawItems = Array.isArray(payload.items) ? payload.items.slice(0, 240) : []; + const seenCodes = new Set(); + const seenIndexes = new Set(); + const items = []; + for (const value of rawItems) { + const inputIndex = value?.inputIndex; + const itemCode = typeof value?.itemCode === "string" ? value.itemCode.trim() : ""; + const itemName = typeof value?.itemName === "string" ? value.itemName.trim() : ""; + if (!Number.isInteger(inputIndex) || inputIndex < 0 || inputIndex > 9999 || + !itemCode || itemCode.length > 32 || !itemName || itemName.length > 200 || + /[\u0000-\u001f\u007f]/.test(`${itemCode}${itemName}`) || + seenCodes.has(itemCode) || seenIndexes.has(inputIndex)) { + preview.status = "error"; + preview.items = []; + preview.error = "테마 종목 미리보기 식별자가 올바르지 않습니다."; + renderThemeWorkflow(); + return; + } + seenCodes.add(itemCode); + seenIndexes.add(inputIndex); + items.push({ inputIndex, itemCode, itemName }); + } + if (!Number.isInteger(payload.totalRowCount) || payload.totalRowCount !== items.length) { + preview.status = "error"; + preview.items = []; + preview.error = "테마 종목 미리보기 행 수가 올바르지 않습니다."; + renderThemeWorkflow(); + return; + } + state.theme.selected = responseTheme; + preview.status = "ready"; + preview.items = items; + preview.totalRowCount = items.length; + preview.truncated = payload.truncated === true; + preview.error = ""; + const catalogSelection = { + market: responseTheme.market, + session: themeSessionWire(responseTheme), + themeCode: responseTheme.themeCode, + themeTitle: responseTheme.title, + source: responseTheme.market === "krx" ? "oracle" : "mariaDb" + }; + const catalogPreview = { + requestId: payload.requestId, + selection: { + market: responseTheme.market, + session: themeSessionWire(responseTheme), + themeCode: responseTheme.themeCode, + title: responseTheme.title + }, + retrievedAt: payload.retrievedAt, + totalRowCount: items.length, + truncated: payload.truncated === true, + items: items.map(value => ({ ...value })) + }; + try { + operatorCatalogWorkflow.createThemeEditDraft(catalogSelection, catalogPreview); + preview.catalogContext = Object.freeze({ + selection: Object.freeze({ ...catalogSelection }), + preview: Object.freeze({ ...catalogPreview, items: Object.freeze(catalogPreview.items) }) + }); + } catch { + preview.catalogContext = null; + } + renderThemeWorkflow(); + addLog(`테마 종목 조회 완료 · ${responseTheme.displayTitle} · ${items.length}건`); + } + + function handleThemePreviewError(payload) { + const preview = state.theme.preview; + if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return; + const selection = payload.selection; + const identity = [selection?.market, selection?.session, selection?.themeCode, selection?.title].join("|"); + if (identity !== themeIdentity(state.theme.selected)) return; + clearThemeTimeout("preview"); + preview.status = "error"; + preview.items = []; + preview.catalogContext = null; + preview.error = typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : "테마 종목을 불러오지 못했습니다."; + renderThemeWorkflow(); + } + + function synchronizeThemeEntries() { + let invalid = false; + state.playlist = state.playlist.map(item => { + if (item?.operator?.source !== "legacy-theme-workflow") return item; + const refreshed = themeWorkflow.refreshThemePlaylistEntry(item); + if (!refreshed) { + invalid = true; + return item; + } + const definition = catalog.find(value => + value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code)); + if (!definition) { + invalid = true; + return item; + } + return { ...definition, ...refreshed }; + }); + if (invalid) { + showToast("저장된 테마 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); + return false; + } + return true; + } + + function clearOverseasTimeout(kind) { + const value = state.overseas[kind]; + if (value?.timeoutId !== null) clearTimeout(value.timeoutId); + if (value) value.timeoutId = null; + } + + function currentOverseasStore() { + return state.overseas[state.overseas.source]; + } + + function overseasIdentity(kind, value) { + if (!value) return ""; + return kind === "industry" + ? [value.koreanName, value.inputName, value.symbol, value.nationCode, value.fdtc].join("|") + : [value.inputName, value.symbol, value.nationCode, value.fdtc].join("|"); + } + + function selectOverseas(value) { + if (isPlaylistSnapshotLocked()) return; + const kind = state.overseas.source; + const normalized = kind === "industry" + ? overseasWorkflow.normalizeIndustry(value) + : overseasWorkflow.normalizeStock(value); + if (!normalized) return; + state.overseas[kind].selected = normalized; + renderOverseasWorkflow(); + addLog(`해외 ${kind === "industry" ? "업종" : "종목"} 선택 · ${kind === "industry" ? normalized.koreanName : normalized.inputName} · ${normalized.symbol}`); + } + + function addOverseasAction(actionId) { + if (!requirePlaylistEditable()) return; + const kind = state.overseas.source; + const selected = state.overseas[kind].selected; + if (!selected) { + showToast("먼저 해외업종 또는 해외종목을 선택하세요."); + elements.overseasSearch.focus(); + return; + } + try { + const options = { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION, + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }; + const created = kind === "industry" + ? overseasWorkflow.createOverseasIndustryPlaylistEntry(selected, options) + : overseasWorkflow.createOverseasStockPlaylistEntry(selected, actionId, options); + const definition = catalog.find(item => + item.builderKey === created.builderKey && sceneAliases(item).includes(created.code)); + if (!definition) throw new Error("The overseas action does not resolve to a scene alias."); + const entry = { ...definition, ...created }; + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`); + showToast("해외 대상 컷을 플레이리스트에 추가했습니다."); + } catch { + showToast("선택한 해외 대상과 컷을 안전하게 구성하지 못했습니다."); + } + } + + function renderOverseasWorkflow() { + renderGlobalCandleOptions(); + const visible = state.activeMarket === "foreign-stock"; + elements.overseasWorkflow.hidden = !visible; + if (!visible) return; + const kind = state.overseas.source; + const store = currentOverseasStore(); + const locked = isPlaylistSnapshotLocked(); + elements.overseasSourceTabs.querySelectorAll("button[data-overseas-source]").forEach(button => { + button.classList.toggle("active", button.dataset.overseasSource === kind); + button.disabled = locked; + }); + elements.overseasCount.textContent = store.status === "loading" + ? "LOADING" + : `${store.totalRowCount.toLocaleString("ko-KR")} ${kind === "industry" ? "INDUSTRIES" : "STOCKS"}`; + elements.overseasSearch.disabled = locked; + elements.overseasSearchButton.disabled = locked || store.status === "loading"; + elements.overseasSearchState.className = `overseas-search-state ${store.status}`; + if (store.status === "loading") { + elements.overseasSearchState.textContent = kind === "industry" + ? "Oracle에서 미국 해외업종을 조회하고 있습니다." + : "Oracle에서 미국·대만 해외종목을 조회하고 있습니다."; + } else if (store.status === "error") { + elements.overseasSearchState.textContent = store.error || "해외 대상 조회에 실패했습니다."; + } else if (store.status === "ready") { + elements.overseasSearchState.textContent = `${store.totalRowCount.toLocaleString("ko-KR")}개 결과${store.truncated ? " · 결과 제한 적용" : ""}`; + } else { + elements.overseasSearchState.textContent = "조회 버튼을 누르면 전체 목록을 불러옵니다."; + } + + elements.overseasResults.replaceChildren(); + if (!store.results.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = store.status === "ready" ? "일치하는 결과가 없습니다." : "검색 결과가 여기에 표시됩니다."; + elements.overseasResults.append(empty); + } else { + const selectedIdentity = overseasIdentity(kind, store.selected); + for (const item of store.results) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "overseas-result"; + row.disabled = locked; + const selected = overseasIdentity(kind, item) === selectedIdentity; + row.classList.toggle("selected", selected); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", selected ? "true" : "false"); + const copy = document.createElement("span"); + const name = document.createElement("strong"); + const meta = document.createElement("small"); + name.textContent = kind === "industry" ? item.koreanName : item.inputName; + meta.textContent = kind === "industry" + ? `${item.inputName} · ${item.symbol}` + : `${item.symbol} · FDTC ${item.fdtc}`; + copy.append(name, meta); + const nation = document.createElement("span"); + nation.textContent = item.nationCode; + row.append(copy, nation); + row.addEventListener("click", () => selectOverseas(item)); + elements.overseasResults.append(row); + } + } + + elements.selectedOverseas.hidden = !store.selected; + elements.overseasMa5.disabled = locked || kind !== "stock" || !store.selected; + elements.overseasMa20.disabled = locked || kind !== "stock" || !store.selected; + if (store.selected) { + elements.selectedOverseasName.textContent = kind === "industry" + ? store.selected.koreanName + : store.selected.inputName; + elements.selectedOverseasMeta.textContent = `${store.selected.symbol} · ${store.selected.nationCode} · FDTC ${store.selected.fdtc}`; + } + + const actions = kind === "industry" + ? overseasWorkflow.industryActions + : overseasWorkflow.stockActions; + elements.overseasActionList.replaceChildren(); + actions.forEach((action, index) => { + const allowed = overseasWorkflow.isActionAllowed(kind, action.id, store.selected); + const row = document.createElement("div"); + row.className = "overseas-action-row"; + row.classList.toggle("disabled", !allowed); + const number = document.createElement("span"); + number.textContent = String(index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + copy.className = "overseas-action-copy"; + const label = document.createElement("strong"); + const detail = document.createElement("small"); + label.textContent = action.label; + detail.textContent = `${action.code}${action.periodDays ? ` · ${action.periodDays}일` : " · 현재가"}`; + copy.append(label, detail); + const add = document.createElement("button"); + add.type = "button"; + add.className = "overseas-action-add"; + add.textContent = "+"; + add.disabled = locked || !allowed; + add.title = allowed ? "플레이리스트에 추가" : "해외 대상을 먼저 선택하세요."; + add.addEventListener("click", () => addOverseasAction(action.id)); + row.addEventListener("dblclick", event => { + if (!event.target.closest("button") && allowed) addOverseasAction(action.id); + }); + row.append(number, copy, add); + elements.overseasActionList.append(row); + }); + } + + function requestOverseasSearch(event) { + event?.preventDefault(); + if (isPlaylistSnapshotLocked()) return; + const kind = state.overseas.source; + const store = state.overseas[kind]; + clearOverseasTimeout(kind); + let request; + try { + const createRequest = kind === "industry" + ? overseasWorkflow.createIndustrySearchRequest + : overseasWorkflow.createStockSearchRequest; + request = createRequest( + createId(), + String(elements.overseasSearch.value || ""), + overseasWorkflow.bridgeContracts[kind].maximumResults); + } catch { + store.status = "error"; + store.error = "검색어는 64자 이하의 안전한 문자여야 합니다."; + store.results = []; + store.totalRowCount = 0; + renderOverseasWorkflow(); + return; + } + store.request = request; + store.status = "loading"; + store.results = []; + store.selected = null; + store.totalRowCount = 0; + store.truncated = false; + store.error = ""; + renderOverseasWorkflow(); + if (!nativeBridge) { + store.status = "error"; + store.error = "브라우저 미리보기에서는 실제 해외 DB 조회를 사용할 수 없습니다."; + renderOverseasWorkflow(); + return; + } + postNative(overseasWorkflow.bridgeContracts[kind].requestType, request); + store.timeoutId = setTimeout(() => { + if (store.request !== request || store.status !== "loading") return; + store.status = "error"; + store.error = "해외 대상 조회 응답 시간이 초과되었습니다."; + renderOverseasWorkflow(); + }, 30000); + } + + function handleOverseasSearchResults(kind, payload) { + const store = state.overseas[kind]; + const request = store.request; + if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false; + clearOverseasTimeout(kind); + const response = kind === "industry" + ? overseasWorkflow.normalizeIndustrySearchResponse(payload, request) + : overseasWorkflow.normalizeStockSearchResponse(payload, request); + if (!response) { + store.status = "error"; + store.results = []; + store.totalRowCount = 0; + store.error = "해외 대상 응답 형식 또는 식별자가 올바르지 않습니다."; + } else { + store.status = "ready"; + store.results = [...response.results]; + store.totalRowCount = response.totalRowCount; + store.truncated = response.truncated; + store.error = ""; + } + renderOverseasWorkflow(); + return true; + } + + function handleOverseasSearchError(kind, payload) { + const store = state.overseas[kind]; + const request = store.request; + if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false; + clearOverseasTimeout(kind); + const error = kind === "industry" + ? overseasWorkflow.normalizeIndustrySearchError(payload, request) + : overseasWorkflow.normalizeStockSearchError(payload, request); + store.status = "error"; + store.results = []; + store.totalRowCount = 0; + store.error = error?.message || "해외 대상 조회에 실패했습니다."; + renderOverseasWorkflow(); + return true; + } + + function synchronizeOverseasEntries() { + let invalid = false; + state.playlist = state.playlist.map(item => { + if (item?.operator?.source !== "legacy-overseas-workflow") return item; + const refreshed = overseasWorkflow.refreshOverseasPlaylistEntry(item); + if (!refreshed) { + invalid = true; + return item; + } + const definition = catalog.find(value => + value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code)); + if (!definition) { + invalid = true; + return item; + } + return { ...definition, ...refreshed }; + }); + if (invalid) { + showToast("저장된 해외 대상 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); + return false; + } + return true; + } + + function clearExpertTimeout(kind) { + const value = state.expert[kind]; + if (value?.timeoutId !== null) clearTimeout(value.timeoutId); + if (value) value.timeoutId = null; + } + + function expertIdentity(value) { + return value ? `${value.expertCode}|${value.expertName}` : ""; + } + + function selectExpert(value) { + if (isPlaylistSnapshotLocked()) return; + const selected = expertWorkflow.normalizeExpert(value); + if (!selected) return; + state.expert.selected = selected; + state.expert.selectable = null; + requestExpertPreview(selected); + addLog(`전문가 선택 · ${selected.expertName} · ${selected.expertCode}`); + } + + function addExpertAction() { + if (!requirePlaylistEditable()) return; + const selected = state.expert.selectable; + if (!selected || state.expert.preview.status !== "ready") { + showToast("전문가를 선택하고 추천 종목 미리보기가 완료될 때까지 기다리세요."); + return; + } + try { + const created = expertWorkflow.createExpertPlaylistEntry(selected, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION + }); + const definition = catalog.find(item => + item.builderKey === created.builderKey && sceneAliases(item).includes(created.code)); + if (!definition) throw new Error("The expert action does not resolve to a scene alias."); + const entry = { ...definition, ...created }; + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${entry.code} ${entry.title} · ${entry.pageCount}페이지 추가`); + showToast("전문가 추천 컷을 플레이리스트에 추가했습니다."); + } catch { + showToast("전문가 추천을 안전한 페이지 장면으로 구성하지 못했습니다."); + } + } + + function renderExpertWorkflow() { + const visible = state.activeMarket === "expert"; + elements.expertWorkflow.hidden = !visible; + if (!visible) return; + const expert = state.expert; + const search = expert.search; + const preview = expert.preview; + const locked = isPlaylistSnapshotLocked(); + elements.expertCatalogManageButton.disabled = operatorUiLocked(); + elements.expertCatalogManageButton.textContent = preview.catalogContext ? "선택 전문가 편집" : "전문가 관리"; + elements.expertCount.textContent = search.status === "loading" + ? "LOADING" + : `${search.totalRowCount.toLocaleString("ko-KR")} EXPERTS`; + elements.expertSearch.disabled = locked; + elements.expertSearchButton.disabled = locked || search.status === "loading"; + elements.expertSearchState.className = `expert-search-state ${search.status}`; + if (search.status === "loading") elements.expertSearchState.textContent = "Oracle 전문가 목록을 조회하고 있습니다."; + else if (search.status === "error") elements.expertSearchState.textContent = search.error || "전문가 검색에 실패했습니다."; + else if (search.status === "ready") elements.expertSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}명${search.truncated ? " · 결과 제한 적용" : ""}`; + else elements.expertSearchState.textContent = "조회 버튼을 누르면 전체 전문가를 불러옵니다."; + + elements.expertResults.replaceChildren(); + if (!search.results.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = search.status === "ready" ? "일치하는 전문가가 없습니다." : "전문가 검색 결과가 여기에 표시됩니다."; + elements.expertResults.append(empty); + } else { + const selectedIdentity = expertIdentity(expert.selected); + for (const item of search.results) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "expert-result"; + row.disabled = locked; + const selected = expertIdentity(item) === selectedIdentity; + row.classList.toggle("selected", selected); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", selected ? "true" : "false"); + const copy = document.createElement("span"); + const name = document.createElement("strong"); + const code = document.createElement("small"); + name.textContent = item.expertName; + code.textContent = item.expertCode; + copy.append(name, code); + const source = document.createElement("span"); + source.textContent = "ORACLE"; + row.append(copy, source); + row.addEventListener("click", () => selectExpert(item)); + elements.expertResults.append(row); + } + } + + elements.selectedExpert.hidden = !expert.selected; + if (expert.selected) { + elements.selectedExpertName.textContent = expert.selected.expertName; + elements.selectedExpertMeta.textContent = `${expert.selected.expertCode} · ORACLE`; + } + elements.expertPreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`; + elements.expertPreviewState.className = `expert-preview-state ${preview.status}`; + if (preview.status === "loading") elements.expertPreviewState.textContent = "추천 종목을 PLAYINDEX 순서로 조회하고 있습니다."; + else if (preview.status === "error") elements.expertPreviewState.textContent = preview.error || "추천 종목을 불러오지 못했습니다."; + else if (preview.status === "ready") elements.expertPreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 추천${preview.truncated ? " · 100개 상한 적용" : ""}`; + else elements.expertPreviewState.textContent = "전문가를 선택하면 추천 종목을 확인합니다."; + elements.expertPreviewItems.replaceChildren(); + if (!preview.items.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = preview.status === "ready" ? "추천 종목이 없습니다." : "미리보기 대기 중"; + elements.expertPreviewItems.append(empty); + } else { + for (const item of preview.items) { + const row = document.createElement("div"); + row.className = "expert-preview-item"; + const index = document.createElement("span"); + const name = document.createElement("strong"); + const amount = document.createElement("small"); + index.textContent = String(item.playIndex).padStart(2, "0"); + name.textContent = `${item.stockName} · ${item.stockCode}`; + amount.textContent = `${item.buyAmount.toLocaleString("ko-KR")}원`; + row.append(index, name, amount); + elements.expertPreviewItems.append(row); + } + } + + elements.expertActionList.replaceChildren(); + const action = expertWorkflow.actions[0]; + const page = expert.selectable ? expertWorkflow.calculatePagePreview(expert.selectable) : null; + const allowed = Boolean(expert.selectable && preview.status === "ready" && preview.totalRowCount > 0); + const row = document.createElement("div"); + row.className = "expert-action-row"; + row.classList.toggle("disabled", !allowed); + const number = document.createElement("span"); + number.textContent = "01"; + const copy = document.createElement("span"); + copy.className = "expert-action-copy"; + const label = document.createElement("strong"); + const detail = document.createElement("small"); + label.textContent = action.label; + detail.textContent = allowed + ? `${action.code} · 5개 × ${page?.pageCount ?? 0}페이지${page?.isTruncated ? " · 상한" : ""}` + : "전문가와 추천 종목을 먼저 확인하세요."; + copy.append(label, detail); + const add = document.createElement("button"); + add.type = "button"; + add.className = "expert-action-add"; + add.textContent = "+"; + add.disabled = locked || !allowed; + add.addEventListener("click", addExpertAction); + row.addEventListener("dblclick", event => { + if (!event.target.closest("button") && allowed) addExpertAction(); + }); + row.append(number, copy, add); + elements.expertActionList.append(row); + } + + function requestExpertSearch(event) { + event?.preventDefault(); + if (isPlaylistSnapshotLocked()) return; + const search = state.expert.search; + clearExpertTimeout("search"); + clearExpertTimeout("preview"); + let request; + try { + request = expertWorkflow.createExpertSearchRequest( + createId(), + String(elements.expertSearch.value || ""), + expertWorkflow.bridgeContract.maximumResults); + } catch { + search.status = "error"; + search.error = "검색어는 64자 이하의 안전한 문자여야 합니다."; + search.results = []; + search.totalRowCount = 0; + renderExpertWorkflow(); + return; + } + state.expert.selected = null; + state.expert.selectable = null; + Object.assign(state.expert.preview, { + request: null, response: null, status: "idle", items: [], totalRowCount: 0, + truncated: false, error: "" + }); + Object.assign(search, { + request, status: "loading", results: [], totalRowCount: 0, truncated: false, error: "" + }); + renderExpertWorkflow(); + if (!nativeBridge) { + search.status = "error"; + search.error = "브라우저 미리보기에서는 실제 전문가 DB 조회를 사용할 수 없습니다."; + renderExpertWorkflow(); + return; + } + postNative(expertWorkflow.bridgeContract.searchRequestType, request); + search.timeoutId = setTimeout(() => { + if (search.request !== request || search.status !== "loading") return; + search.status = "error"; + search.error = "전문가 검색 응답 시간이 초과되었습니다."; + renderExpertWorkflow(); + }, 30000); + } + + function handleExpertSearchResults(payload) { + const search = state.expert.search; + const request = search.request; + if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return; + clearExpertTimeout("search"); + const response = expertWorkflow.normalizeExpertSearchResponse(payload, request); + if (!response) { + search.status = "error"; + search.results = []; + search.totalRowCount = 0; + search.error = "전문가 응답 형식 또는 식별자가 올바르지 않습니다."; + } else { + search.status = "ready"; + search.results = [...response.results]; + search.totalRowCount = response.totalRowCount; + search.truncated = response.truncated; + search.error = ""; + } + renderExpertWorkflow(); + } + + function handleExpertSearchError(payload) { + const search = state.expert.search; + const request = search.request; + if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return; + clearExpertTimeout("search"); + const error = expertWorkflow.normalizeExpertSearchError(payload, request); + search.status = "error"; + search.results = []; + search.totalRowCount = 0; + search.error = error?.message || "전문가 검색에 실패했습니다."; + renderExpertWorkflow(); + } + + function requestExpertPreview(selected) { + clearExpertTimeout("preview"); + const preview = state.expert.preview; + let request; + try { + request = expertWorkflow.createExpertPreviewRequest( + createId(), selected, expertWorkflow.bridgeContract.maximumPreviewItems); + } catch { + preview.status = "error"; + preview.error = "전문가 식별자가 올바르지 않습니다."; + renderExpertWorkflow(); + return; + } + Object.assign(preview, { + request, response: null, status: "loading", items: [], totalRowCount: 0, + truncated: false, error: "", catalogContext: null + }); + state.expert.selectable = null; + renderExpertWorkflow(); + if (!nativeBridge) { + preview.status = "error"; + preview.error = "브라우저 미리보기에서는 실제 추천 종목 조회를 사용할 수 없습니다."; + renderExpertWorkflow(); + return; + } + postNative(expertWorkflow.bridgeContract.previewRequestType, request); + preview.timeoutId = setTimeout(() => { + if (preview.request !== request || preview.status !== "loading") return; + preview.status = "error"; + preview.error = "추천 종목 미리보기 응답 시간이 초과되었습니다."; + renderExpertWorkflow(); + }, 30000); + } + + function handleExpertPreviewResults(payload) { + const preview = state.expert.preview; + const request = preview.request; + if (!request || payload?.requestId !== request.requestId || !state.expert.selected) return; + clearExpertTimeout("preview"); + const response = expertWorkflow.normalizeExpertPreviewResponse(payload, request); + const selectable = response + ? expertWorkflow.createSelectableExpert(state.expert.selected, response) + : null; + if (!response || !selectable) { + preview.status = "error"; + preview.items = []; + preview.totalRowCount = 0; + preview.error = "추천 종목 응답 형식, 순서 또는 식별자가 올바르지 않습니다."; + state.expert.selectable = null; + } else { + preview.status = "ready"; + preview.response = response; + preview.items = [...response.items]; + preview.totalRowCount = response.totalRowCount; + preview.truncated = response.truncated; + preview.error = ""; + state.expert.selectable = selectable; + try { + operatorCatalogWorkflow.createExpertEditDraft(state.expert.selected, response); + preview.catalogContext = Object.freeze({ + selection: state.expert.selected, + preview: response + }); + } catch { + preview.catalogContext = null; + } + } + renderExpertWorkflow(); + } + + function handleExpertPreviewError(payload) { + const preview = state.expert.preview; + const request = preview.request; + if (!request || payload?.requestId !== request.requestId) return; + clearExpertTimeout("preview"); + const error = expertWorkflow.normalizeExpertPreviewError(payload, request); + preview.status = "error"; + preview.items = []; + preview.totalRowCount = 0; + preview.catalogContext = null; + preview.error = error?.message || "추천 종목을 불러오지 못했습니다."; + state.expert.selectable = null; + renderExpertWorkflow(); + } + + function synchronizeExpertEntries() { + let invalid = false; + state.playlist = state.playlist.map(item => { + if (item?.operator?.source !== "legacy-expert-workflow") return item; + const refreshed = expertWorkflow.refreshExpertPlaylistEntry(item); + if (!refreshed) { + invalid = true; + return item; + } + const definition = catalog.find(value => + value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code)); + if (!definition) { + invalid = true; + return item; + } + return { ...definition, ...refreshed }; + }); + if (invalid) { + showToast("저장된 전문가 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); + return false; + } + return true; + } + + function clearTradingHaltTimeout() { + if (state.tradingHalt.timeoutId !== null) clearTimeout(state.tradingHalt.timeoutId); + state.tradingHalt.timeoutId = null; + } + + function tradingHaltIdentity(value) { + if (!value) return ""; + return [value.market, value.stockCode, value.displayName].join("|"); + } + + function selectTradingHalt(value) { + if (isPlaylistSnapshotLocked()) return; + const selected = tradingHaltWorkflow.normalizeTradingHalt(value); + if (!selected) return; + state.tradingHalt.selected = selected; + renderTradingHaltWorkflow(); + addLog(`거래 정지 종목 선택 · ${selected.displayName} · ${selected.stockCode}`); + } + + function addTradingHaltAction(actionId) { + if (!requirePlaylistEditable()) return; + const selected = state.tradingHalt.selected; + if (!selected) { + showToast("먼저 거래 정지 종목을 선택하세요."); + elements.tradingHaltSearch.focus(); + return; + } + + try { + const created = tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, actionId, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION, + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }); + const definition = catalog.find(item => + item.builderKey === created.builderKey && sceneAliases(item).includes(created.code)); + if (!definition) throw new Error("The trading-halt action does not resolve to a scene alias."); + const entry = { ...definition, ...created }; + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`); + showToast("거래 정지 컷을 플레이리스트에 추가했습니다."); + } catch { + showToast("선택한 거래 정지 종목과 컷을 안전하게 구성하지 못했습니다."); + } + } + + function renderTradingHaltWorkflow() { + renderGlobalCandleOptions(); + const visible = state.activeMarket === "halted"; + elements.tradingHaltWorkflow.hidden = !visible; + if (!visible) return; + + const workflow = state.tradingHalt; + const locked = isPlaylistSnapshotLocked(); + elements.tradingHaltCount.textContent = workflow.status === "loading" + ? "LOADING" + : `${workflow.totalRowCount.toLocaleString("ko-KR")} HALTED`; + elements.tradingHaltSearch.disabled = locked; + elements.tradingHaltSearchButton.disabled = locked || workflow.status === "loading"; + elements.tradingHaltSearchState.className = `trading-halt-search-state ${workflow.status}`; + if (workflow.status === "loading") { + elements.tradingHaltSearchState.textContent = "Oracle의 KOSPI·KOSDAQ 거래 정지 종목을 조회하고 있습니다."; + } else if (workflow.status === "error") { + elements.tradingHaltSearchState.textContent = workflow.error || "거래 정지 종목 조회에 실패했습니다."; + } else if (workflow.status === "ready") { + elements.tradingHaltSearchState.textContent = `${workflow.totalRowCount.toLocaleString("ko-KR")}개 종목${workflow.truncated ? " · 결과 제한 적용" : ""}`; + } else { + elements.tradingHaltSearchState.textContent = "조회 버튼을 누르면 현재 거래 정지 종목을 불러옵니다."; + } + + elements.tradingHaltResults.replaceChildren(); + if (!workflow.results.length) { + const empty = document.createElement("div"); + empty.className = "comparison-target-empty"; + empty.textContent = workflow.status === "ready" + ? "일치하는 거래 정지 종목이 없습니다." + : "거래 정지 종목 검색 결과가 여기에 표시됩니다."; + elements.tradingHaltResults.append(empty); + } else { + const selectedIdentity = tradingHaltIdentity(workflow.selected); + for (const item of workflow.results) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "trading-halt-result"; + row.disabled = locked; + const selected = tradingHaltIdentity(item) === selectedIdentity; + row.classList.toggle("selected", selected); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", selected ? "true" : "false"); + const copy = document.createElement("span"); + const name = document.createElement("strong"); + const code = document.createElement("small"); + name.textContent = item.displayName; + code.textContent = item.stockCode; + copy.append(name, code); + const market = document.createElement("span"); + market.className = "trading-halt-market"; + market.textContent = item.market.toLocaleUpperCase("en-US"); + row.append(copy, market); + row.addEventListener("click", () => selectTradingHalt(item)); + elements.tradingHaltResults.append(row); + } + } + + elements.selectedTradingHalt.hidden = !workflow.selected; + elements.tradingHaltMa5.disabled = locked || !workflow.selected; + elements.tradingHaltMa20.disabled = locked || !workflow.selected; + if (workflow.selected) { + elements.selectedTradingHaltName.textContent = workflow.selected.displayName; + elements.selectedTradingHaltMeta.textContent = `${workflow.selected.stockCode} · ${workflow.selected.market.toLocaleUpperCase("en-US")}`; + } + + elements.tradingHaltActionList.replaceChildren(); + tradingHaltWorkflow.actions.forEach((action, index) => { + const row = document.createElement("div"); + row.className = "trading-halt-action-row"; + row.classList.toggle("disabled", !workflow.selected); + const number = document.createElement("span"); + number.textContent = String(index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + copy.className = "trading-halt-action-copy"; + const label = document.createElement("strong"); + const detail = document.createElement("small"); + label.textContent = action.label; + detail.textContent = action.kind === "candle" + ? `${action.code} · 120일 · MA5/MA20 선택` + : `${action.code} · 1열판 현재가`; + copy.append(label, detail); + const add = document.createElement("button"); + add.type = "button"; + add.className = "trading-halt-action-add"; + add.textContent = "+"; + add.disabled = locked || !workflow.selected; + add.title = workflow.selected ? "플레이리스트에 추가" : "거래 정지 종목을 먼저 선택하세요."; + add.addEventListener("click", () => addTradingHaltAction(action.id)); + row.addEventListener("dblclick", event => { + if (!event.target.closest("button") && workflow.selected) addTradingHaltAction(action.id); + }); + row.append(number, copy, add); + elements.tradingHaltActionList.append(row); + }); + } + + function requestTradingHaltSearch(event) { + event?.preventDefault(); + if (isPlaylistSnapshotLocked()) return; + clearTradingHaltTimeout(); + const workflow = state.tradingHalt; + let request; + try { + request = tradingHaltWorkflow.createTradingHaltSearchRequest( + createId(), + String(elements.tradingHaltSearch.value || ""), + tradingHaltWorkflow.bridgeContract.maximumResults); + } catch { + workflow.status = "error"; + workflow.error = "검색어는 64자 이하의 안전한 문자여야 합니다."; + workflow.results = []; + workflow.totalRowCount = 0; + renderTradingHaltWorkflow(); + return; + } + + workflow.request = request; + workflow.status = "loading"; + workflow.results = []; + workflow.selected = null; + workflow.totalRowCount = 0; + workflow.truncated = false; + workflow.error = ""; + renderTradingHaltWorkflow(); + if (!nativeBridge) { + workflow.status = "error"; + workflow.error = "브라우저 미리보기에서는 실제 거래 정지 DB 조회를 사용할 수 없습니다."; + renderTradingHaltWorkflow(); + return; + } + postNative(tradingHaltWorkflow.bridgeContract.requestType, request); + workflow.timeoutId = setTimeout(() => { + if (workflow.request !== request || workflow.status !== "loading") return; + workflow.status = "error"; + workflow.error = "거래 정지 종목 조회 응답 시간이 초과되었습니다."; + renderTradingHaltWorkflow(); + }, 30000); + } + + function handleTradingHaltResults(payload) { + const workflow = state.tradingHalt; + const request = workflow.request; + if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return; + clearTradingHaltTimeout(); + const response = tradingHaltWorkflow.normalizeTradingHaltSearchResponse(payload, request); + if (!response) { + workflow.status = "error"; + workflow.results = []; + workflow.totalRowCount = 0; + workflow.error = "거래 정지 종목 응답 형식 또는 식별자가 올바르지 않습니다."; + } else { + workflow.status = "ready"; + workflow.results = [...response.results]; + workflow.totalRowCount = response.totalRowCount; + workflow.truncated = response.truncated; + workflow.error = ""; + } + renderTradingHaltWorkflow(); + } + + function handleTradingHaltError(payload) { + const workflow = state.tradingHalt; + const request = workflow.request; + if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return; + clearTradingHaltTimeout(); + const error = tradingHaltWorkflow.normalizeTradingHaltError(payload, request); + workflow.status = "error"; + workflow.results = []; + workflow.totalRowCount = 0; + workflow.error = error?.message || "거래 정지 종목 조회에 실패했습니다."; + renderTradingHaltWorkflow(); + } + + function synchronizeTradingHaltEntries() { + let invalid = false; + state.playlist = state.playlist.map(item => { + if (item?.operator?.source !== "legacy-trading-halt-workflow") return item; + const refreshed = tradingHaltWorkflow.refreshTradingHaltPlaylistEntry(item); + if (!refreshed) { + invalid = true; + return item; + } + const definition = catalog.find(value => + value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code)); + if (!definition) { + invalid = true; + return item; + } + return { ...definition, ...refreshed }; + }); + if (invalid) { + showToast("저장된 거래 정지 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); + return false; + } + return true; + } + + function clearStockSearchTimeout() { + if (state.stockSearch.timeoutId !== null) clearTimeout(state.stockSearch.timeoutId); + state.stockSearch.timeoutId = null; + } + + function stockIdentity(stock) { + if (!stock) return ""; + return [stock.market, stock.isNxt === true ? "nxt" : "krx", stock.stockCode, stock.stockName].join("|"); + } + + function selectStock(stock) { + state.stockSearch.selected = stock; + renderStockWorkflow(); + addLog(`종목 선택 · ${stock.displayName} · ${stock.stockCode}`); + } + + function addStockCut(cutId) { + if (!requirePlaylistEditable()) return; + const stock = state.stockSearch.selected; + if (!stock) { + showToast("먼저 종목을 검색하고 결과에서 한 종목을 선택하세요."); + elements.stockSearchInput.focus(); + return; + } + + if (!operatorWorkflow.isStockCutAllowed(stock, cutId)) { + showToast(stock.isNxt ? "NXT 종목은 1열판기본_현재가만 지원합니다." : "이 종목과 컷 조합은 지원하지 않습니다."); + return; + } + + try { + const created = operatorWorkflow.createStockPlaylistEntry(stock, cutId, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION, + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }); + const definition = catalog.find(item => item.builderKey === created.builderKey); + if (!definition || !sceneAliases(definition).includes(String(created.code || ""))) { + throw new Error("The operator cut does not resolve to a registered scene alias."); + } + const entry = { + ...definition, + ...created, + enabled: true + }; + state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${entry.code} ${stock.displayName} · ${entry.operator?.legacyLabel || entry.title} 추가`); + showToast(`${stock.displayName} 컷을 플레이리스트에 추가했습니다.`); + } catch { + showToast("선택한 종목과 컷을 안전한 장면으로 구성하지 못했습니다."); + } + } + + function renderGlobalCandleOptions() { + const { ma5, ma20 } = state.candleOptions; + for (const element of [elements.globalMa5, elements.stockMa5, elements.overseasMa5, elements.tradingHaltMa5]) { + if (element) element.checked = ma5; + } + for (const element of [elements.globalMa20, elements.stockMa20, elements.overseasMa20, elements.tradingHaltMa20]) { + if (element) element.checked = ma20; + } + const locked = isPlaylistSnapshotLocked(); + elements.globalMa5.disabled = locked; + elements.globalMa20.disabled = locked; + } + + function synchronizeGlobalCandleOptions(render = false) { + if (isPlaylistSnapshotLocked()) { + renderGlobalCandleOptions(); + return false; + } + const { ma5, ma20 } = state.candleOptions; + const result = candleOptionsWorkflow.synchronizePlaylist( + state.playlist, + ma5, + ma20, + { + stock: operatorWorkflow, + fixed: fixedWorkflow, + industry: industryWorkflow, + overseas: overseasWorkflow, + tradingHalt: tradingHaltWorkflow + }, + new Date()); + state.candleOptions.invalidIds = [...result.invalidIds]; + state.playlist = [...result.playlist].map(item => { + if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item; + const definition = catalog.find(value => + value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || ""))); + return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item; + }); + renderGlobalCandleOptions(); + + if (render && (result.changed || !result.valid)) { + renderPlaylist(); + updatePreview(); + } + if (!result.valid) { + if (render) { + addLog(`캔들 이동평균선 동기화 차단 · 신뢰 복원 실패 ${result.invalidIds.length}건`); + showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다. 해당 항목을 삭제하고 다시 추가하세요."); + } + return false; + } + if (result.changed && render) { + addLog(`전체 캔들 이동평균선 적용 · 5일선 ${ma5 ? "표시" : "숨김"} · 20일선 ${ma20 ? "표시" : "숨김"}`); + } + return true; + } + + function applyGlobalCandleOptions(ma5, ma20, render = true) { + if (isPlaylistSnapshotLocked()) { + renderGlobalCandleOptions(); + return false; + } + try { + const normalized = candleOptionsWorkflow.normalizeOptions(Boolean(ma5), Boolean(ma20)); + state.candleOptions.ma5 = normalized.ma5; + state.candleOptions.ma20 = normalized.ma20; + return synchronizeGlobalCandleOptions(render); + } catch { + renderGlobalCandleOptions(); + return false; + } + } + + function synchronizeStockCandleOptions(render = false) { + return applyGlobalCandleOptions(elements.stockMa5.checked, elements.stockMa20.checked, render); + } + + function renderStockWorkflow() { + renderGlobalCandleOptions(); + const visible = ["dashboard", "kospi", "kosdaq"].includes(state.activeMarket); + elements.stockWorkflow.hidden = !visible; + if (!visible) return; + elements.stockWorkflow.classList.toggle("collapsed", state.stockWorkflowCollapsed); + elements.stockWorkflowToggle.textContent = state.stockWorkflowCollapsed ? "펼치기" : "접기"; + elements.stockWorkflowToggle.setAttribute("aria-expanded", String(!state.stockWorkflowCollapsed)); + + const search = state.stockSearch; + const playlistLocked = isPlaylistSnapshotLocked(); + elements.stockMa5.disabled = playlistLocked; + elements.stockMa20.disabled = playlistLocked; + elements.stockSearchButton.disabled = search.status === "loading"; + elements.stockSearchBadge.textContent = search.status === "loading" ? "SEARCHING" : "DB SEARCH"; + elements.stockSearchState.className = `stock-search-state ${search.status}`; + if (search.status === "loading") { + elements.stockSearchState.textContent = `“${search.query}” 종목을 Oracle/MariaDB에서 조회하고 있습니다.`; + } else if (search.status === "error") { + elements.stockSearchState.textContent = search.error || "종목 검색에 실패했습니다."; + } else if (search.status === "ready") { + const suffix = search.truncated ? " · 결과 제한 적용" : ""; + elements.stockSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 종목 검색 완료${suffix}`; + } else { + elements.stockSearchState.textContent = "종목명을 입력하고 Enter를 누르세요."; + } + + elements.stockSearchResults.replaceChildren(); + if (!search.results.length) { + const empty = document.createElement("div"); + empty.className = "stock-results-empty"; + empty.textContent = search.status === "ready" ? "일치하는 종목이 없습니다." : "검색 결과가 여기에 표시됩니다."; + elements.stockSearchResults.append(empty); + } else { + const selectedIdentity = stockIdentity(search.selected); + for (const stock of search.results) { + const row = document.createElement("button"); + row.className = "stock-result"; + row.type = "button"; + row.setAttribute("role", "option"); + const selected = stockIdentity(stock) === selectedIdentity; + row.classList.toggle("selected", selected); + row.setAttribute("aria-selected", selected ? "true" : "false"); + + const copy = document.createElement("span"); + const name = document.createElement("strong"); + const meta = document.createElement("small"); + name.textContent = stock.displayName; + meta.textContent = `${stock.stockCode} · ${stock.sourceLabel || stock.source || "DB"}`; + copy.append(name, meta); + const market = document.createElement("span"); + market.className = "stock-market-badge"; + market.textContent = stock.marketLabel || `${stock.market}${stock.isNxt ? " NXT" : ""}`; + row.append(copy, market); + row.addEventListener("click", () => selectStock(stock)); + elements.stockSearchResults.append(row); + } + } + + elements.selectedStock.hidden = !search.selected; + if (search.selected) { + elements.selectedStockName.textContent = search.selected.displayName; + elements.selectedStockMeta.textContent = `${search.selected.marketLabel || search.selected.market} · ${search.selected.stockCode}`; + } + + elements.stockCutList.replaceChildren(); + let previousSection = null; + operatorWorkflow.stockCuts.forEach((cut, index) => { + if (cut.section !== previousSection) { + const section = document.createElement("div"); + section.className = "stock-cut-section"; + section.textContent = cut.section; + elements.stockCutList.append(section); + previousSection = cut.section; + } + const allowed = Boolean(search.selected) && operatorWorkflow.isStockCutAllowed(search.selected, cut.id); + const row = document.createElement("div"); + row.className = "stock-cut-row"; + row.classList.toggle("disabled", !allowed); + const number = document.createElement("span"); + number.className = "stock-cut-number"; + number.textContent = String(index + 1).padStart(2, "0"); + const copy = document.createElement("span"); + copy.className = "stock-cut-copy"; + const label = document.createElement("strong"); + const detail = document.createElement("small"); + label.textContent = cut.label; + detail.textContent = `${cut.builderKey} · ${cut.detail || "원본 종목 컷"}`; + copy.append(label, detail); + const add = document.createElement("button"); + add.className = "stock-cut-add"; + add.type = "button"; + add.textContent = "+"; + add.disabled = !allowed || isPlaylistSnapshotLocked(); + add.title = !search.selected + ? "먼저 종목을 선택하세요." + : (!allowed ? "이 종목 시장에서는 지원하지 않는 컷입니다." : "플레이리스트에 추가"); + add.addEventListener("click", () => addStockCut(cut.id)); + row.addEventListener("dblclick", () => { if (allowed) addStockCut(cut.id); }); + row.append(number, copy, add); + elements.stockCutList.append(row); + }); + + elements.manualStockActions.replaceChildren(); + for (const action of operatorWorkflow.manualStockActions) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = action.label; + button.disabled = !search.selected || operatorUiLocked(); + button.title = !search.selected + ? "먼저 종목을 검색하고 선택하세요." + : `${action.prerequisiteTable} 원본 수동 입력 화면 열기`; + button.addEventListener("click", () => manualFinancialUi?.open(action)); + elements.manualStockActions.append(button); + } + } + + function requestStockSearch(event) { + event?.preventDefault(); + clearStockSearchTimeout(); + const query = String(elements.stockSearchInput.value || "").trim(); + if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) { + state.stockSearch.status = "error"; + state.stockSearch.error = "1~64자의 종목명을 입력하세요."; + state.stockSearch.results = []; + state.stockSearch.selected = null; + renderStockWorkflow(); + return; + } + if (!nativeBridge) { + state.stockSearch.status = "error"; + state.stockSearch.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다."; + renderStockWorkflow(); + return; + } + + const requestId = createId(); + state.stockSearch.requestId = requestId; + state.stockSearch.query = query; + state.stockSearch.status = "loading"; + state.stockSearch.results = []; + state.stockSearch.selected = null; + state.stockSearch.totalRowCount = 0; + state.stockSearch.truncated = false; + state.stockSearch.error = ""; + renderStockWorkflow(); + postNative("search-stocks", { requestId, query, maximumResults: 200 }); + state.stockSearch.timeoutId = setTimeout(() => { + if (state.stockSearch.requestId !== requestId || state.stockSearch.status !== "loading") return; + state.stockSearch.status = "error"; + state.stockSearch.error = "종목 검색 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 검색하세요."; + renderStockWorkflow(); + addLog(`종목 검색 시간 초과 · ${query}`); + }, 30000); + } + + function handleStockSearchResults(payload) { + if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return; + clearStockSearchTimeout(); + const results = Array.isArray(payload.results) ? payload.results : []; + state.stockSearch.results = results.slice(0, 500).flatMap(value => { + try { + const normalized = operatorWorkflow.normalizeStockResult(value); + return normalized ? [normalized] : []; + } catch { return []; } + }); + state.stockSearch.status = "ready"; + state.stockSearch.totalRowCount = Number.isInteger(payload.totalRowCount) + ? Math.max(0, payload.totalRowCount) + : state.stockSearch.results.length; + state.stockSearch.truncated = payload.truncated === true; + state.stockSearch.error = ""; + renderStockWorkflow(); + addLog(`종목 검색 완료 · ${state.stockSearch.query} · ${state.stockSearch.results.length}건 표시`); + } + + function handleStockSearchError(payload) { + if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return; + clearStockSearchTimeout(); + state.stockSearch.status = "error"; + state.stockSearch.results = []; + state.stockSearch.selected = null; + state.stockSearch.error = typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : "종목 검색에 실패했습니다."; + renderStockWorkflow(); + addLog(`종목 검색 실패 · ${state.stockSearch.query}`); + } + function addToPlaylist(item) { if (item.reachable === false || !requirePlaylistEditable()) return; + if (operatorInputBuilderKeys.has(item.builderKey)) { + if (["s5026", "s5029", "s5087"].includes(item.builderKey)) { + showToast("비교 장면은 두 종목을 확정하는 전용 입력 화면에서 추가해야 합니다."); + } else if (["s5074", "s5077", "s5088"].includes(item.builderKey)) { + showToast("5·6·12행 페이지 장면은 지수·업종·테마 등 원본 전용 입력 화면에서 추가해야 합니다."); + } else if (["s5076", "s5079", "s5080", "s5081"].includes(item.builderKey)) { + showToast("이 장면은 원본 수동 재무 데이터 화면의 저장 항목에서 추가해야 합니다."); + } else { + showToast("종목 검색 결과와 종목용 컷을 선택해 추가하세요."); + elements.stockSearchInput.focus(); + } + return; + } const selection = defaultSceneSelection(item.builderKey); const entry = { ...item, @@ -339,7 +3400,10 @@ subtype: selection?.subtype || "" }; state.playlist.push(entry); + state.selectedRows.clear(); + state.selectedRows.add(entry.id); state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; renderPlaylist(); updatePreview(); addLog(`${item.code} ${item.title} 항목 추가`); @@ -347,26 +3411,49 @@ } function renderPlaylist() { + renderGlobalCandleOptions(); elements.playlistBody.replaceChildren(); elements.playlistCount.textContent = `${state.playlist.length} CUTS`; elements.playlistEmpty.classList.toggle("show", state.playlist.length === 0); + const allSelected = state.playlist.length > 0 && + state.playlist.every(item => state.selectedRows.has(item.id)); + const allEnabled = state.playlist.length > 0 && + state.playlist.every(item => item.enabled !== false); + elements.selectAll.textContent = allSelected ? "작업 선택해제" : "작업 전체선택"; + elements.toggleAllEnabled.textContent = allEnabled ? "전체 송출 해제" : "전체 송출"; state.playlist.forEach((item, index) => { const row = document.createElement("tr"); + const namedGuard = state.namedPlaylist.guardByEntryId.get(item.id); + const manualFinancialRestore = state.manualFinancialRestore.entries.get(item.id); row.draggable = !isPlaylistSnapshotLocked(); row.dataset.index = String(index); row.classList.toggle("active", index === state.currentIndex); + row.classList.toggle("selected", state.selectedRows.has(item.id)); + row.classList.toggle("named-restore-invalid", namedGuard?.trusted === false); + row.classList.toggle("named-page-pending", namedGuard?.trusted === true && + namedGuard.pageRecalculationRequired && !namedGuard.pageRecalculated); + row.classList.toggle("named-restore-invalid", manualFinancialRestore?.status === "error"); + row.classList.toggle("named-page-pending", Boolean(manualFinancialRestore) && + manualFinancialRestore.status !== "error"); + row.setAttribute("aria-selected", state.selectedRows.has(item.id) ? "true" : "false"); const checkCell = document.createElement("td"); checkCell.className = "check-cell"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; - checkbox.checked = state.selectedRows.has(item.id); + checkbox.checked = item.enabled !== false; checkbox.disabled = isPlaylistSnapshotLocked(); - checkbox.setAttribute("aria-label", `${item.title} 선택`); + checkbox.setAttribute("aria-label", `${item.title} 송출 포함`); checkbox.addEventListener("click", event => event.stopPropagation()); - checkbox.addEventListener("change", () => { - checkbox.checked ? state.selectedRows.add(item.id) : state.selectedRows.delete(item.id); + checkbox.addEventListener("change", event => { + event.stopPropagation(); + if (!requirePlaylistEditable()) { + checkbox.checked = item.enabled !== false; + return; + } + item.enabled = checkbox.checked; + addLog(`${item.code} ${checkbox.checked ? "송출 포함" : "송출 제외"}`); }); checkCell.append(checkbox); @@ -374,43 +3461,68 @@ number.className = "number-cell"; number.textContent = String(index + 1).padStart(2, "0"); - const titleCell = document.createElement("td"); - const title = document.createElement("strong"); - const detail = document.createElement("small"); - title.textContent = item.title; - detail.textContent = `${item.code} · ${item.detail}`; - titleCell.append(title, detail); + const targetCell = document.createElement("td"); + targetCell.className = "playlist-target-cell"; + const target = document.createElement("strong"); + const targetMeta = document.createElement("small"); + target.textContent = item.selection?.subject || item.title; + targetMeta.textContent = [item.selection?.groupCode, item.selection?.dataCode] + .filter(Boolean).join(" · ") || item.detail; + targetCell.append(target, targetMeta); - const typeCell = document.createElement("td"); + const cutCell = document.createElement("td"); + cutCell.className = "playlist-cut-cell"; + const cutTitle = document.createElement("strong"); + const cutMeta = document.createElement("small"); + cutTitle.textContent = item.operator?.legacyLabel || item.title; + cutMeta.textContent = `${item.code} · ${item.builderKey}`; + cutCell.append(cutTitle, cutMeta); + + const detailCell = document.createElement("td"); + detailCell.className = "playlist-detail-cell"; + const detailTitle = document.createElement("strong"); + detailTitle.textContent = [item.selection?.graphicType, item.selection?.subtype] + .filter(Boolean).join(" · ") || item.detail; const type = document.createElement("span"); type.className = "row-type"; - type.textContent = item.category.toUpperCase(); - const includeLabel = document.createElement("label"); - includeLabel.className = "row-enabled"; - const include = document.createElement("input"); - include.type = "checkbox"; - include.checked = item.enabled !== false; - include.disabled = isPlaylistSnapshotLocked(); - include.setAttribute("aria-label", `${item.title} 송출 포함`); - include.addEventListener("click", event => event.stopPropagation()); - include.addEventListener("change", event => { - event.stopPropagation(); - if (!requirePlaylistEditable()) { - include.checked = item.enabled !== false; - return; - } - item.enabled = include.checked; - addLog(`${item.code} ${include.checked ? "송출 포함" : "송출 제외"}`); - }); - includeLabel.append(include, document.createTextNode("송출")); - typeCell.append(type, includeLabel); + let rowType = item.category.toUpperCase(); + if (namedGuard?.pageRecalculationRequired && !namedGuard.pageRecalculated) rowType = "PAGE 재계산"; + if (namedGuard?.recalculatedPagePlan?.isTruncated === true) rowType = "20 PAGE 제한"; + if (namedGuard?.pageUnavailableReason === "empty-result") rowType = "DB 결과 없음"; + if (namedGuard?.trusted === false) rowType = "복원 필요"; + if (manualFinancialRestore) rowType = manualFinancialRestore.status === "error" + ? "재무 복원 실패" + : "재무 DB 확인"; + type.textContent = rowType; + const detailMeta = document.createElement("small"); + detailMeta.append(type); + detailCell.append(detailTitle, detailMeta); + + const page = document.createElement("td"); + page.className = "page-cell"; + const freshNamedPagePlan = namedGuard?.recalculatedPagePlan; + page.textContent = item.id === state.playout.currentEntryId && state.playout.pageCount > 0 + ? `${state.playout.pageIndex + 1}/${state.playout.pageCount}` + : (freshNamedPagePlan?.itemCount === 0 + ? "0건" + : (Number.isInteger(freshNamedPagePlan?.pageCount) + ? `1/${freshNamedPagePlan.pageCount}` + : (item.pageText || "1/1"))); + if (namedGuard?.rawRow) { + const historical = namedPlaylistWorkflow.pageStatusLabel(namedGuard.rawRow); + const current = freshNamedPagePlan + ? `현재 DB ${freshNamedPagePlan.itemCount}건 · ${freshNamedPagePlan.pageCount}페이지` + + (freshNamedPagePlan.isTruncated ? ` · ${freshNamedPagePlan.accessibleItemCount}건까지만 송출` : "") + : "현재 DB 페이지 조회 전"; + page.title = `${historical} / ${current}`; + } const drag = document.createElement("td"); drag.className = "drag-cell"; drag.textContent = "⠿"; - row.append(checkCell, number, titleCell, typeCell, drag); - row.addEventListener("click", () => selectPlaylistRow(index)); + row.append(checkCell, number, targetCell, cutCell, detailCell, page, drag); + row.addEventListener("click", event => selectPlaylistRow(index, event)); row.addEventListener("dragstart", onDragStart); row.addEventListener("dragover", onDragOver); row.addEventListener("dragleave", () => row.classList.remove("drag-over")); @@ -420,8 +3532,26 @@ }); } - function selectPlaylistRow(index) { + function selectPlaylistRow(index, event) { if (isPlaylistSnapshotLocked()) return; + const item = state.playlist[index]; + if (!item) return; + if (event?.shiftKey) { + const anchor = state.selectionAnchorIndex >= 0 && state.selectionAnchorIndex < state.playlist.length + ? state.selectionAnchorIndex + : (state.currentIndex >= 0 ? state.currentIndex : index); + if (!(event.ctrlKey || event.metaKey)) state.selectedRows.clear(); + const from = Math.min(anchor, index); + const to = Math.max(anchor, index); + for (let value = from; value <= to; value++) state.selectedRows.add(state.playlist[value].id); + } else if (event?.ctrlKey || event?.metaKey) { + if (state.selectedRows.has(item.id)) state.selectedRows.delete(item.id); + else state.selectedRows.add(item.id); + } else { + state.selectedRows.clear(); + state.selectedRows.add(item.id); + } + if (!event?.shiftKey) state.selectionAnchorIndex = index; state.currentIndex = index; renderPlaylist(); updatePreview(); @@ -447,7 +3577,7 @@ function renderSceneSelectionForm(item) { const selection = item?.selection || {}; - const disabled = !item || isPlaylistSnapshotLocked(); + const disabled = !item || isPlaylistSnapshotLocked() || Boolean(item.operator?.source); elements.sceneGroupCode.value = String(selection.groupCode || ""); elements.sceneSubject.value = String(selection.subject || ""); elements.sceneGraphicType.value = String(selection.graphicType || ""); @@ -554,6 +3684,7 @@ const [moved] = state.playlist.splice(state.dragIndex, 1); state.playlist.splice(targetIndex, 0, moved); state.currentIndex = targetIndex; + state.selectionAnchorIndex = targetIndex; state.dragIndex = -1; renderPlaylist(); updatePreview(); @@ -570,33 +3701,98 @@ const allSelected = state.playlist.length > 0 && state.playlist.every(item => state.selectedRows.has(item.id)); state.selectedRows.clear(); if (!allSelected) state.playlist.forEach(item => state.selectedRows.add(item.id)); + state.selectionAnchorIndex = !allSelected && state.playlist.length ? 0 : -1; renderPlaylist(); } + function toggleAllEntriesEnabled() { + if (!requirePlaylistEditable()) return; + if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다."); + const enable = state.playlist.some(item => item.enabled === false); + state.playlist.forEach(item => { item.enabled = enable; }); + renderPlaylist(); + addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`); + } + function moveSelected(direction) { if (!requirePlaylistEditable()) return; - const index = state.playlist.findIndex(item => state.selectedRows.has(item.id)); - if (index < 0) return showToast("이동할 항목을 선택하세요."); - const target = Math.max(0, Math.min(state.playlist.length - 1, index + direction)); - if (index === target) return; - [state.playlist[index], state.playlist[target]] = [state.playlist[target], state.playlist[index]]; - state.currentIndex = target; + if (!state.selectedRows.size) return showToast("이동할 항목을 선택하세요."); + const currentId = state.playlist[state.currentIndex]?.id; + let changed = false; + if (direction < 0) { + for (let index = 1; index < state.playlist.length; index++) { + if (state.selectedRows.has(state.playlist[index].id) && + !state.selectedRows.has(state.playlist[index - 1].id)) { + [state.playlist[index - 1], state.playlist[index]] = [state.playlist[index], state.playlist[index - 1]]; + changed = true; + } + } + } else { + for (let index = state.playlist.length - 2; index >= 0; index--) { + if (state.selectedRows.has(state.playlist[index].id) && + !state.selectedRows.has(state.playlist[index + 1].id)) { + [state.playlist[index], state.playlist[index + 1]] = [state.playlist[index + 1], state.playlist[index]]; + changed = true; + } + } + } + if (!changed) return; + state.currentIndex = state.playlist.findIndex(item => item.id === currentId); + state.selectionAnchorIndex = state.currentIndex; renderPlaylist(); updatePreview(); + addLog(`선택 항목 ${direction < 0 ? "위" : "아래"}로 이동`); } function deleteSelected() { if (!requirePlaylistEditable()) return; if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요."); const deleted = state.selectedRows.size; + for (const id of state.selectedRows) { + const restore = state.manualFinancialRestore.entries.get(id); + clearManualFinancialRestoreTimeout(restore); + state.manualFinancialRestore.entries.delete(id); + } state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id)); state.selectedRows.clear(); state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1); + state.selectionAnchorIndex = state.currentIndex; renderPlaylist(); updatePreview(); addLog(`${deleted}개 항목 삭제`); } + function deleteAllPlaylistEntries() { + if (!requirePlaylistEditable()) return; + if (!state.playlist.length) return; + if (!window.confirm("플레이리스트의 모든 항목을 삭제할까요?")) return; + const deleted = state.playlist.length; + installManualFinancialRestores([]); + state.playlist = []; + state.selectedRows.clear(); + state.currentIndex = -1; + state.selectionAnchorIndex = -1; + renderPlaylist(); + updatePreview(); + addLog(`플레이리스트 전체 ${deleted}개 항목 삭제`); + } + + function selectPlaylistBoundary(index, extend) { + if (!requirePlaylistEditable() || !state.playlist.length) return; + const target = Math.max(0, Math.min(state.playlist.length - 1, index)); + selectPlaylistRow(target, { shiftKey: extend, ctrlKey: false, metaKey: false }); + elements.playlistBody.querySelector(`tr[data-index="${target}"]`)?.scrollIntoView({ block: "nearest" }); + } + + function toggleCurrentEntryEnabled() { + if (!requirePlaylistEditable()) return; + const item = state.playlist[state.currentIndex]; + if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요."); + item.enabled = item.enabled === false; + renderPlaylist(); + addLog(`${item.code} ${item.enabled ? "송출 포함" : "송출 제외"}`); + } + function savePlaylist() { localStorage.setItem(storageKey, JSON.stringify(state.playlist)); addLog(`플레이리스트 ${state.playlist.length}개 로컬 저장`); @@ -614,10 +3810,105 @@ return result; } - function normalizeStoredPlaylist(value) { + function normalizeStoredPlaylist(value, options = {}) { if (!Array.isArray(value)) return []; return value.slice(0, 1000).flatMap(item => { if (!item || typeof item !== "object") return []; + const restoredFixedEntry = fixedWorkflow.restoreFixedPlaylistEntry(item, { now: options.now }); + if (restoredFixedEntry) { + const fixedDefinition = catalog.find(definition => + definition.builderKey === restoredFixedEntry.builderKey && + sceneAliases(definition).includes(restoredFixedEntry.code)); + return fixedDefinition + ? [{ ...fixedDefinition, ...restoredFixedEntry }] + : []; + } + const restoredIndustryEntry = industryWorkflow.restoreIndustryPlaylistEntry(item, { now: options.now }); + if (restoredIndustryEntry) { + const industryDefinition = catalog.find(definition => + definition.builderKey === restoredIndustryEntry.builderKey && + sceneAliases(definition).includes(restoredIndustryEntry.code)); + return industryDefinition + ? [{ ...industryDefinition, ...restoredIndustryEntry }] + : []; + } + const restoredComparisonEntry = comparisonWorkflow.restoreComparisonPlaylistEntry(item); + if (restoredComparisonEntry) { + const comparisonDefinition = catalog.find(definition => + definition.builderKey === restoredComparisonEntry.builderKey && + sceneAliases(definition).includes(restoredComparisonEntry.code)); + return comparisonDefinition + ? [{ ...comparisonDefinition, ...restoredComparisonEntry }] + : []; + } + const restoredThemeEntry = themeWorkflow.restoreThemePlaylistEntry(item); + if (restoredThemeEntry) { + const themeDefinition = catalog.find(definition => + definition.builderKey === restoredThemeEntry.builderKey && + sceneAliases(definition).includes(restoredThemeEntry.code)); + return themeDefinition + ? [{ ...themeDefinition, ...restoredThemeEntry }] + : []; + } + const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item); + if (restoredOverseasEntry) { + const overseasDefinition = catalog.find(definition => + definition.builderKey === restoredOverseasEntry.builderKey && + sceneAliases(definition).includes(restoredOverseasEntry.code)); + return overseasDefinition + ? [{ ...overseasDefinition, ...restoredOverseasEntry }] + : []; + } + const restoredExpertEntry = expertWorkflow.restoreExpertPlaylistEntry(item); + if (restoredExpertEntry) { + const expertDefinition = catalog.find(definition => + definition.builderKey === restoredExpertEntry.builderKey && + sceneAliases(definition).includes(restoredExpertEntry.code)); + return expertDefinition + ? [{ ...expertDefinition, ...restoredExpertEntry }] + : []; + } + const restoredTradingHaltEntry = tradingHaltWorkflow.restoreTradingHaltPlaylistEntry(item); + if (restoredTradingHaltEntry) { + const tradingHaltDefinition = catalog.find(definition => + definition.builderKey === restoredTradingHaltEntry.builderKey && + sceneAliases(definition).includes(restoredTradingHaltEntry.code)); + return tradingHaltDefinition + ? [{ ...tradingHaltDefinition, ...restoredTradingHaltEntry }] + : []; + } + const restoredManualListEntry = manualListsWorkflow.restorePlaylistEntry(item); + if (restoredManualListEntry) { + const manualListDefinition = catalog.find(definition => + definition.builderKey === restoredManualListEntry.builderKey && + sceneAliases(definition).includes(restoredManualListEntry.code)); + return manualListDefinition + ? [{ ...manualListDefinition, ...restoredManualListEntry }] + : []; + } + const pendingManualFinancialEntry = manualFinancialWorkflow.restorePlaylistEntry(item); + if (pendingManualFinancialEntry) { + const manualFinancialDefinition = catalog.find(definition => + definition.builderKey === pendingManualFinancialEntry.entry.builderKey && + sceneAliases(definition).includes(pendingManualFinancialEntry.entry.code)); + if (!manualFinancialDefinition) return []; + if (Array.isArray(options.manualFinancialRestores)) { + options.manualFinancialRestores.push(pendingManualFinancialEntry); + } + return [{ ...manualFinancialDefinition, ...pendingManualFinancialEntry.entry }]; + } + const restoredOperatorEntry = operatorWorkflow.restoreStockPlaylistEntry(item); + if (restoredOperatorEntry) { + const operatorDefinition = catalog.find(definition => + definition.builderKey === restoredOperatorEntry.builderKey && + sceneAliases(definition).includes(restoredOperatorEntry.code)); + return operatorDefinition + ? [{ ...operatorDefinition, ...restoredOperatorEntry }] + : []; + } + if (operatorInputBuilderKeys.has(String(item.builderKey || "")) || item.operator !== undefined) { + return []; + } const storedCode = String(item.code || ""); const definitionIndex = playoutSafety.resolveStoredCatalogIndex( catalog, @@ -625,11 +3916,12 @@ storedCode); const definition = definitionIndex >= 0 ? catalog[definitionIndex] : null; if (!definition) return []; + if (operatorInputBuilderKeys.has(definition.builderKey)) return []; const id = typeof item.id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(item.id) ? item.id : createId(); - const selection = normalizeStoredSelection(item.selection) || - defaultSceneSelection(definition.builderKey); + const selection = normalizeStoredSelection(item.selection); + if (!selection) return []; const fadeDuration = Number(item.fadeDuration); return [{ ...definition, @@ -648,13 +3940,365 @@ }); } + function manualFinancialRestoreRecordForRequest(requestId, status) { + if (typeof requestId !== "string") return null; + for (const record of state.manualFinancialRestore.entries.values()) { + const expectedId = record.request?.requestId ?? record.request?.payload?.requestId; + if (record.status === status && expectedId === requestId) return record; + } + return null; + } + + function clearManualFinancialRestoreTimeout(record) { + if (record?.timeoutId !== null && record?.timeoutId !== undefined) { + clearTimeout(record.timeoutId); + record.timeoutId = null; + } + } + + function failManualFinancialRestore(record, message) { + if (!record || !state.manualFinancialRestore.entries.has(record.pending.entry.id)) return; + clearManualFinancialRestoreTimeout(record); + record.status = "error"; + record.request = null; + record.error = typeof message === "string" && message.trim() + ? message.trim() + : "수동 재무 항목을 현재 DB 값으로 다시 확인하지 못했습니다."; + renderPlaylist(); + renderPlayout(); + const restoreLabel = record.pending.identity?.stockName || record.pending.audience || + (record.pending.kind === "vi" ? "VI" : "manual"); + addLog(`수동 입력 복원 차단 · ${restoreLabel} · ${record.error}`); + requestNextManualFinancialRestore(); + } + + function armManualFinancialRestoreTimeout(record) { + clearManualFinancialRestoreTimeout(record); + const expectedRequestId = record.request?.requestId ?? record.request?.payload?.requestId; + record.timeoutId = setTimeout(() => { + if ((record.request?.requestId ?? record.request?.payload?.requestId) !== expectedRequestId) return; + failManualFinancialRestore(record, "수동 재무 복원 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다."); + }, 30000); + } + + function requestManualFinancialRestoreLoad(record) { + try { + if (record.restoreKind === "named-list") { + const envelope = namedManualRestoreWorkflow.createManualListReadRequest( + `named-manual-read-${createId()}`, + record.pending); + record.status = "manual-list"; + record.request = envelope; + record.error = ""; + postNative(envelope.type, envelope.payload); + armManualFinancialRestoreTimeout(record); + return; + } + const request = record.restoreKind === "named-financial" + ? manualFinancialWorkflow.createLoadRequest( + `named-financial-load-${createId()}`, + record.pending.screen, + record.pending.stockName) + : manualFinancialWorkflow.createRestoreLoadRequest( + `manual-restore-load-${createId()}`, + record.pending); + record.status = "load"; + record.request = request; + record.error = ""; + postNative(manualFinancialWorkflow.bridgeContract.loadRequestType, request); + armManualFinancialRestoreTimeout(record); + } catch { + failManualFinancialRestore(record, "저장된 수동 재무 식별자가 현재 계약과 일치하지 않습니다."); + } + } + + function requestNextManualFinancialRestore() { + const records = [...state.manualFinancialRestore.entries.values()]; + if (records.some(record => ["load", "stock", "selection", "manual-list"].includes(record.status))) return; + const next = records.find(record => record.status === "queued"); + if (next) { + requestManualFinancialRestoreLoad(next); + return; + } + finalizeNamedManualRestores(); + } + + function installManualFinancialRestores(pendingValues) { + for (const current of state.manualFinancialRestore.entries.values()) { + clearManualFinancialRestoreTimeout(current); + } + state.manualFinancialRestore.entries.clear(); + for (const pending of pendingValues) { + if (!pending?.entry?.id || state.manualFinancialRestore.entries.has(pending.entry.id)) continue; + state.manualFinancialRestore.entries.set(pending.entry.id, { + pending, + restoreKind: namedManualRestoreWorkflow.isPending(pending) + ? (pending.kind === "financial" ? "named-financial" : "named-list") + : "stored-financial", + status: nativeBridge ? "queued" : "error", + request: null, + loadResponse: null, + verifiedStock: null, + timeoutId: null, + error: nativeBridge ? "" : "Native Bridge가 없어 현재 DB 재검증을 실행할 수 없습니다." + }); + } + if (nativeBridge) requestNextManualFinancialRestore(); + } + + function handleManualFinancialRestoreLoadResults(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load"); + if (!record) return false; + clearManualFinancialRestoreTimeout(record); + const response = manualFinancialWorkflow.normalizeLoadResponse(payload, record.request); + if (!response) { + failManualFinancialRestore(record, "Native Bridge가 올바른 수동 재무 load 응답을 보내지 않았습니다."); + return true; + } + record.loadResponse = response; + const request = { + requestId: `manual-restore-stock-${createId()}`, + query: response.snapshot.stockName, + maximumResults: 200 + }; + record.status = "stock"; + record.request = request; + postNative("search-stocks", request); + armManualFinancialRestoreTimeout(record); + return true; + } + + function handleManualFinancialRestoreLoadError(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load"); + if (!record) return false; + const error = manualFinancialWorkflow.normalizeLoadError(payload, record.request); + failManualFinancialRestore(record, error?.message || "수동 재무 DB load가 실패했습니다."); + return true; + } + + function handleManualFinancialRestoreStockResults(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock"); + if (!record) return false; + clearManualFinancialRestoreTimeout(record); + const response = manualFinancialWorkflow.normalizeStockSearchResponse(payload); + const correlated = response && response.query === record.request.query && + response.truncated === false && response.totalRowCount <= record.request.maximumResults; + const namedMatch = record.restoreKind === "named-financial" && correlated + ? response.results.filter(item => + item.name === record.pending.stockName && item.market === record.pending.market) + : []; + const expectedMarket = record.restoreKind === "named-financial" + ? record.pending.market + : record.pending.verifiedStock.market; + const expectedCode = record.restoreKind === "named-financial" + ? (namedMatch.length === 1 ? namedMatch[0].code : "") + : record.pending.verifiedStock.code; + const verified = correlated && manualFinancialWorkflow.verifyStockForRecord( + record.loadResponse.snapshot.record, + response, + expectedMarket, + expectedCode); + if (!verified) { + failManualFinancialRestore(record, "저장된 종목의 시장·코드를 현재 종목 마스터에서 유일하게 확인하지 못했습니다."); + return true; + } + record.verifiedStock = verified; + try { + const request = record.restoreKind === "named-financial" + ? manualFinancialWorkflow.createSelectionRequest( + `named-financial-selection-${createId()}`, + record.loadResponse.snapshot, + verified) + : manualFinancialWorkflow.createRestoreSelectionRequest( + `manual-restore-selection-${createId()}`, + record.pending, + record.loadResponse, + verified); + record.status = "selection"; + record.request = request; + postNative(manualFinancialWorkflow.bridgeContract.selectionRequestType, request); + armManualFinancialRestoreTimeout(record); + } catch { + failManualFinancialRestore(record, "수동 재무 행 또는 종목 마스터 값이 저장 시점과 달라졌습니다."); + } + return true; + } + + function handleManualFinancialRestoreStockError(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock"); + if (!record) return false; + const message = payload?.query === record.request.query && typeof payload?.message === "string" + ? payload.message + : "종목 마스터 재검증에 실패했습니다."; + failManualFinancialRestore(record, message); + return true; + } + + function handleManualFinancialRestoreSelectionResults(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection"); + if (!record) return false; + clearManualFinancialRestoreTimeout(record); + const selection = manualFinancialWorkflow.normalizeSelectionResponse(payload, record.request); + let entry = null; + if (selection) { + try { + entry = record.restoreKind === "named-financial" + ? manualFinancialWorkflow.createPlaylistEntry( + record.loadResponse.snapshot, + record.verifiedStock, + selection, + { + id: record.pending.entry.id, + fadeDuration: record.pending.fadeDuration, + enabled: record.pending.enabled + }) + : manualFinancialWorkflow.materializeRestoredPlaylistEntry( + record.pending, + record.loadResponse, + record.verifiedStock, + selection); + } catch { + entry = null; + } + } + if (!entry || !manualFinancialWorkflow.isPlaylistEntryPlayable(entry) || + (record.restoreKind === "named-financial" && + !namedManualRestoreWorkflow.matchesMaterializedEntry(record.pending, entry))) { + failManualFinancialRestore(record, "수동 재무 선택 결과가 저장된 행과 정확히 일치하지 않습니다."); + return true; + } + const index = state.playlist.findIndex(item => item.id === record.pending.entry.id); + if (index < 0 || isPlaylistSnapshotLocked()) { + failManualFinancialRestore(record, "복원 중 플레이리스트 상태가 바뀌어 항목을 적용하지 않았습니다."); + return true; + } + if (record.restoreKind === "named-financial" && + !trustNamedManualRestoration(record.pending, entry)) { + failManualFinancialRestore(record, "DB raw 행과 현재 GraphE 선택을 안전하게 연결하지 못했습니다."); + return true; + } + state.playlist[index] = entry; + state.manualFinancialRestore.entries.delete(entry.id); + renderPlaylist(); + updatePreview(); + renderPlayout(); + addLog(`수동 재무 복원 완료 · ${entry.title} · ${entry.code}`); + requestNextManualFinancialRestore(); + return true; + } + + function handleManualFinancialRestoreSelectionError(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection"); + if (!record) return false; + const error = manualFinancialWorkflow.normalizeSelectionError(payload, record.request); + failManualFinancialRestore(record, error?.message || "수동 재무 장면 선택 재검증이 실패했습니다."); + return true; + } + + function handleManualFinancialRestoreRequestError(payload) { + const record = ["load", "selection"] + .map(status => manualFinancialRestoreRecordForRequest(payload?.requestId, status)) + .find(Boolean); + if (!record) return false; + const expectedOperation = record.status === "selection" ? "selection" : "load"; + const error = manualFinancialWorkflow.normalizeRequestError(payload, expectedOperation); + failManualFinancialRestore(record, error?.message || "수동 재무 복원 요청 형식이 거부되었습니다."); + return true; + } + + function trustNamedManualRestoration(pending, entry) { + const restoration = state.namedPlaylist.restoration; + if (!restoration || !namedManualRestoreWorkflow.matchesMaterializedEntry(pending, entry)) return false; + const row = restoration.rows[pending.itemIndex]; + if (!row || row.trusted || row.rawRow.enabled !== pending.enabled || + !sameNamedSelection(row.rawRow, pending.rawSelection)) return false; + const rows = restoration.rows.map((value, index) => index === pending.itemIndex + ? Object.freeze({ ...value, entry, trusted: true }) + : value); + state.namedPlaylist.restoration = Object.freeze({ + ...restoration, + rows: Object.freeze(rows) + }); + return true; + } + + function finalizeNamedManualRestores() { + if (state.namedPlaylist.manualRestoreFinalized || !state.namedPlaylist.restoration) return; + const records = [...state.manualFinancialRestore.entries.values()]; + if (records.some(record => ["queued", "load", "stock", "selection", "manual-list"].includes(record.status))) { + return; + } + const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration); + const materialized = materializeNamedRestoration(restoration); + state.namedPlaylist.restoration = restoration; + state.namedPlaylist.guardByEntryId = materialized.guards; + state.namedPlaylist.manualRestoreFinalized = true; + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + requestNamedPlaylistPagePlans(); + } + + function handleNamedManualListResults(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list"); + if (!record || record.restoreKind !== "named-list") return false; + clearManualFinancialRestoreTimeout(record); + let entry = null; + try { + entry = namedManualRestoreWorkflow.materializeManualList( + record.pending, + payload, + record.request); + } catch { + entry = null; + } + const index = state.playlist.findIndex(item => item.id === record.pending.entry.id); + if (!entry || index < 0 || isPlaylistSnapshotLocked() || + !trustNamedManualRestoration(record.pending, entry)) { + failManualFinancialRestore(record, "현재 신뢰 저장소 값이 DB raw 행과 정확히 일치하지 않습니다."); + return true; + } + state.playlist[index] = entry; + state.manualFinancialRestore.entries.delete(entry.id); + renderPlaylist(); + updatePreview(); + renderPlayout(); + addLog(`수동 입력 복원 완료 · ${entry.title} · ${entry.code}`); + requestNextManualFinancialRestore(); + return true; + } + + function handleNamedManualListError(payload) { + const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list"); + if (!record || record.restoreKind !== "named-list") return false; + const expectedId = record.request?.payload?.requestId; + const error = manualListsWorkflow.normalizeError(payload, expectedId); + failManualFinancialRestore(record, error?.message || "신뢰 수동 저장소 읽기에 실패했습니다."); + return true; + } + + function manualFinancialPrepareBlocked() { + if (state.manualFinancialRestore.entries.size > 0) return true; + return state.playlist.some(item => item?.operator?.source === "manual-financial-workflow" && + !manualFinancialWorkflow.isPlaylistEntryPlayable(item)); + } + function loadPlaylist() { if (!requirePlaylistEditable()) return; try { const stored = JSON.parse(localStorage.getItem(storageKey) || "[]"); - state.playlist = normalizeStoredPlaylist(stored); + const pendingManualFinancial = []; + state.playlist = normalizeStoredPlaylist(stored, { + manualFinancialRestores: pendingManualFinancial + }); + installManualFinancialRestores(pendingManualFinancial); + state.namedPlaylist.guardByEntryId.clear(); + state.namedPlaylist.loadedDefinition = null; + synchronizeGlobalCandleOptions(false); state.selectedRows.clear(); + if (state.playlist.length) state.selectedRows.add(state.playlist[0].id); state.currentIndex = state.playlist.length ? 0 : -1; + state.selectionAnchorIndex = state.currentIndex; renderPlaylist(); updatePreview(); addLog(`플레이리스트 ${state.playlist.length}개 불러오기`); @@ -664,6 +4308,894 @@ } } + function sameNamedSelection(left, right) { + return Boolean(left && right) && + ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => typeof left[key] === "string" && left[key] === right[key]); + } + + function namedNowForRawRow(rawRow) { + const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(rawRow?.dataCode || "")); + const now = dateMatch + ? new Date(Number(dateMatch[1]), Number(dateMatch[2]) - 1, Number(dateMatch[3]), 9, 0, 0, 0) + : new Date(); + if (rawRow?.graphicType === "PRE_MARKET") now.setHours(9, 0, 0, 0); + if (rawRow?.graphicType === "AFTER_MARKET") now.setHours(14, 0, 0, 0); + return now; + } + + function addNamedCandidate(candidates, factory) { + try { + const candidate = factory(); + if (candidate) candidates.push(candidate); + } catch { + // A raw seven-field row is untrusted until a closed workflow can recreate it. + } + } + + function namedMovingAverageOptions(rawRow) { + const flags = new Set(String(rawRow.graphicType || "").split(",").filter(Boolean)); + return { + ma5: flags.has("MA5"), + ma20: flags.has("MA20") + }; + } + + function addFixedNamedCandidates(candidates, rawRow, itemIndex, now) { + for (const action of fixedWorkflow.actions) { + if (!action.available) continue; + const matches = ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => { + if (key === "dataCode" && action.dynamicDate) return /^\d{4}-\d{2}-\d{2}$/.test(rawRow.dataCode); + if (key === "graphicType" && action.dynamicNxtSession) { + return ["PRE_MARKET", "AFTER_MARKET"].includes(rawRow.graphicType); + } + return action.selection[key] === rawRow[key]; + }); + if (!matches) continue; + addNamedCandidate(candidates, () => fixedWorkflow.createFixedPlaylistEntry(action.id, { + id: `db-${itemIndex}-${action.id}`, + fadeDuration: DEFAULT_FADE_DURATION, + now + })); + } + } + + function addStockNamedCandidates(candidates, rawRow, itemIndex) { + const market = operatorWorkflow.normalizeMarket(rawRow.groupCode); + if (!market || !rawRow.subject || !rawRow.dataCode) return; + const displayName = rawRow.subject; + const stockName = displayName.replace(/\s*\(NXT\)\s*/g, "").trim(); + const stock = { + market: market.market, + stockName, + displayName, + stockCode: rawRow.dataCode, + isNxt: market.isNxt || displayName.includes("(NXT)") + }; + const movingAverages = namedMovingAverageOptions(rawRow); + for (const cut of operatorWorkflow.stockCuts) { + addNamedCandidate(candidates, () => operatorWorkflow.createStockPlaylistEntry(stock, cut.id, { + id: `db-${itemIndex}-stock-${cut.id}`, + fadeDuration: DEFAULT_FADE_DURATION, + ...movingAverages + })); + } + } + + function addIndustryNamedCandidates(candidates, rawRow, itemIndex, now) { + const upperGroup = String(rawRow.groupCode || "").toLocaleUpperCase("en-US"); + const market = upperGroup.includes("KOSDAQ") ? "kosdaq" : (upperGroup.includes("KOSPI") ? "kospi" : null); + if (!market) return; + industryWorkflow.cuts.forEach((cut, cutIndex) => { + const options = { + id: `db-${itemIndex}-industry-${cutIndex}`, + fadeDuration: DEFAULT_FADE_DURATION, + now + }; + if (cut.kind === "pair") { + const names = String(rawRow.subject || "").split("-"); + if (names.length !== 2 || names.some(value => !value)) return; + options.pair = names.map((name, index) => ({ market, name, code: `R${itemIndex}${index}` })); + } else if (cut.kind !== "fixed") { + options.selected = { market, name: rawRow.subject, code: `R${itemIndex}` }; + } + addNamedCandidate(candidates, () => + industryWorkflow.createIndustryPlaylistEntry(market, cut.id, options)); + }); + } + + function namedComparisonTarget(token, itemIndex, tokenIndex) { + const marketTarget = comparisonWorkflow.getMarketTarget(token); + if (marketTarget) return marketTarget; + const isNxt = token.endsWith("(NXT)"); + const stockName = token.replace(/\s*\(NXT\)\s*$/, "").trim(); + return stockName ? { + kind: isNxt ? "nxt-stock" : "krx-stock", + market: "kospi", + stockName, + stockCode: `R${itemIndex}${tokenIndex}` + } : null; + } + + function addComparisonNamedCandidates(candidates, rawRow, itemIndex) { + const tokens = String(rawRow.subject || "").split(","); + if (tokens.length !== 2 || tokens.some(value => !value)) return; + const pair = tokens.map((token, tokenIndex) => namedComparisonTarget(token, itemIndex, tokenIndex)); + if (pair.some(value => !value)) return; + for (const action of comparisonWorkflow.actions) { + addNamedCandidate(candidates, () => comparisonWorkflow.createComparisonPlaylistEntry(action.id, pair, { + id: `db-${itemIndex}-comparison-${action.id}`, + fadeDuration: DEFAULT_FADE_DURATION + })); + } + } + + function addThemeNamedCandidates(candidates, rawRow, itemIndex) { + let theme; + let sort; + if (rawRow.groupCode === "PAGED_THEME") { + theme = { + market: "krx", + themeCode: rawRow.dataCode, + title: rawRow.subject, + session: null, + itemCount: 1 + }; + sort = rawRow.graphicType; + } else if (rawRow.groupCode === "PAGED_NXT_THEME" && rawRow.subject.endsWith("(NXT)")) { + theme = { + market: "nxt", + themeCode: rawRow.dataCode, + title: rawRow.subject.slice(0, -5), + session: rawRow.graphicType, + itemCount: 1 + }; + sort = rawRow.subtype; + } else { + return; + } + for (const action of themeWorkflow.actions) { + addNamedCandidate(candidates, () => themeWorkflow.createThemePlaylistEntry(action.id, theme, sort, { + id: `db-${itemIndex}-theme-${action.id}`, + fadeDuration: DEFAULT_FADE_DURATION + })); + } + } + + function addOverseasNamedCandidates(candidates, rawRow, itemIndex) { + const movingAverages = namedMovingAverageOptions(rawRow); + if (rawRow.groupCode === "FOREIGN_INDUSTRY") { + const parts = String(rawRow.dataCode || "").split("|"); + if (parts.length !== 4) return; + addNamedCandidate(candidates, () => overseasWorkflow.createOverseasIndustryPlaylistEntry({ + koreanName: rawRow.subject, + inputName: parts[0], + symbol: parts[1], + nationCode: parts[2], + fdtc: parts[3] + }, { + id: `db-${itemIndex}-overseas-industry`, + fadeDuration: DEFAULT_FADE_DURATION + })); + return; + } + if (rawRow.groupCode !== "FOREIGN_STOCK") return; + const parts = String(rawRow.dataCode || "").split("|"); + if (parts.length !== 3) return; + const stock = { + inputName: rawRow.subject, + symbol: parts[0], + nationCode: parts[1], + fdtc: parts[2] + }; + for (const action of overseasWorkflow.stockActions) { + addNamedCandidate(candidates, () => overseasWorkflow.createOverseasStockPlaylistEntry(stock, action.id, { + id: `db-${itemIndex}-overseas-${action.id}`, + fadeDuration: DEFAULT_FADE_DURATION, + ...movingAverages + })); + } + } + + function addExpertNamedCandidate(candidates, rawRow, itemIndex) { + if (rawRow.groupCode !== "PAGED_EXPERT") return; + addNamedCandidate(candidates, () => expertWorkflow.createExpertPlaylistEntry({ + expertCode: rawRow.dataCode, + expertName: rawRow.subject, + source: "oracle", + itemCount: 1, + previewTruncated: false + }, { + id: `db-${itemIndex}-expert`, + fadeDuration: DEFAULT_FADE_DURATION + })); + } + + function addTradingHaltNamedCandidates(candidates, rawRow, itemIndex) { + const upperGroup = String(rawRow.groupCode || "").toLocaleUpperCase("en-US"); + const market = upperGroup.includes("KOSDAQ") ? "kosdaq" : (upperGroup.includes("KOSPI") ? "kospi" : null); + if (!market || !rawRow.subject || !rawRow.dataCode) return; + const selected = { market, stockCode: rawRow.dataCode, displayName: rawRow.subject }; + const movingAverages = namedMovingAverageOptions(rawRow); + for (const action of tradingHaltWorkflow.actions) { + addNamedCandidate(candidates, () => tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, action.id, { + id: `db-${itemIndex}-halt-${action.id}`, + fadeDuration: DEFAULT_FADE_DURATION, + ...movingAverages + })); + } + } + + function addDefaultNamedCandidates(candidates, rawRow, itemIndex) { + for (const definition of catalog) { + if (definition.reachable === false || operatorInputBuilderKeys.has(definition.builderKey)) continue; + const selection = defaultSceneSelection(definition.builderKey); + if (!sameNamedSelection(selection, rawRow)) continue; + candidates.push({ + ...definition, + id: `db-${itemIndex}-default-${definition.builderKey}`, + selection: { ...selection }, + enabled: rawRow.enabled, + fadeDuration: DEFAULT_FADE_DURATION, + graphicType: selection.graphicType, + subtype: selection.subtype + }); + } + } + + function resolveNamedPlaylistRawRow(rawRow, itemIndex) { + const candidates = []; + const now = namedNowForRawRow(rawRow); + addFixedNamedCandidates(candidates, rawRow, itemIndex, now); + addStockNamedCandidates(candidates, rawRow, itemIndex); + addIndustryNamedCandidates(candidates, rawRow, itemIndex, now); + addComparisonNamedCandidates(candidates, rawRow, itemIndex); + addThemeNamedCandidates(candidates, rawRow, itemIndex); + addOverseasNamedCandidates(candidates, rawRow, itemIndex); + addExpertNamedCandidate(candidates, rawRow, itemIndex); + addTradingHaltNamedCandidates(candidates, rawRow, itemIndex); + addDefaultNamedCandidates(candidates, rawRow, itemIndex); + + const trusted = new Map(); + for (const candidate of candidates) { + const restored = normalizeStoredPlaylist([{ ...candidate, enabled: rawRow.enabled }], { now }); + const entry = restored.length === 1 ? restored[0] : null; + if (!entry || entry.enabled !== rawRow.enabled || !sameNamedSelection(entry.selection, rawRow)) continue; + const key = `${entry.builderKey}|${entry.code}|${JSON.stringify(entry.selection)}`; + if (!trusted.has(key)) trusted.set(key, entry); + } + return trusted.size === 1 ? [...trusted.values()][0] : null; + } + + function prepareNamedPagePreflight(restoration) { + return namedPlaylistWorkflow.preparePagePreflight( + restoration, + entry => namedPagedBuilderKeys.has(entry.builderKey)); + } + + function namedInvalidEntry(definition, rawRow) { + return { + id: `db-invalid-${definition.programCode}-${rawRow.itemIndex}`, + builderKey: namedInvalidBuilderKey, + code: "0", + aliases: ["0"], + title: rawRow.subject || `DB 행 ${rawRow.itemIndex + 1}`, + detail: "DB 7필드에서 컷을 유일하게 복원할 수 없음", + category: "invalid", + market: rawRow.groupCode || "unknown", + selection: { + groupCode: rawRow.groupCode, + subject: rawRow.subject, + graphicType: rawRow.graphicType, + subtype: rawRow.subtype, + dataCode: rawRow.dataCode + }, + enabled: rawRow.enabled, + fadeDuration: DEFAULT_FADE_DURATION, + graphicType: rawRow.graphicType, + subtype: rawRow.subtype, + pageText: rawRow.pageText + }; + } + + function materializeNamedRestoration(restoration) { + const entries = []; + const guards = new Map(); + for (const row of restoration.rows) { + const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" || + row.entry?.operator?.source === "legacy-manual-lists-workflow"; + const entry = row.trusted + ? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText }) + : namedInvalidEntry(restoration.definition, row.rawRow); + entries.push(entry); + guards.set(entry.id, Object.freeze({ + trusted: row.trusted, + pageRecalculationRequired: row.pageRecalculationRequired, + pageRecalculated: row.pageRecalculated, + pagePreflightCompleted: row.pagePreflightCompleted, + pageUnavailableReason: row.pageUnavailableReason, + recalculatedPagePlan: row.recalculatedPagePlan, + historicalPageOutOfBounds: row.rawRow.historicalPageOutOfBounds, + rawRow: row.rawRow + })); + } + return { entries, guards }; + } + + function namedPlaylistPrepareBlockers() { + const blockers = []; + state.playlist.forEach((entry, index) => { + const guard = state.namedPlaylist.guardByEntryId.get(entry.id); + if (!guard) return; + if (!guard.trusted) blockers.push({ itemIndex: index, reason: "trusted-restore-required" }); + if (guard.pageRecalculationRequired && !guard.pageRecalculated) { + blockers.push({ + itemIndex: index, + reason: guard.pagePreflightCompleted && guard.pageUnavailableReason === "empty-result" + ? "page-result-empty" + : "page-recalculation-required" + }); + } + }); + return blockers; + } + + function clearNamedPlaylistLoadState() { + state.namedPlaylist.guardByEntryId.clear(); + state.namedPlaylist.loadedDefinition = null; + state.namedPlaylist.restoration = null; + state.namedPlaylist.pagePlanPending = null; + state.namedPlaylist.pagePlanError = null; + state.namedPlaylist.manualRestoreFinalized = true; + } + + function saveNamedPlaylistBackup() { + if (!state.playlist.length) return true; + try { + localStorage.setItem(namedPlaylistBackupStorageKey, JSON.stringify({ + savedAt: new Date().toISOString(), + playlist: state.playlist + })); + return true; + } catch { + return false; + } + } + + function hasNamedPlaylistBackup() { + try { + return Boolean(localStorage.getItem(namedPlaylistBackupStorageKey)); + } catch { + return false; + } + } + + function restoreNamedPlaylistBackup() { + if (!requirePlaylistEditable() || namedPlaylistBusy()) return; + try { + const stored = JSON.parse(localStorage.getItem(namedPlaylistBackupStorageKey) || "null"); + if (!stored || typeof stored !== "object" || !Array.isArray(stored.playlist)) throw new Error(); + const pendingManualFinancial = []; + const restored = normalizeStoredPlaylist(stored.playlist, { + manualFinancialRestores: pendingManualFinancial + }); + if (restored.length !== stored.playlist.length) throw new Error(); + state.playlist = restored; + installManualFinancialRestores(pendingManualFinancial); + synchronizeGlobalCandleOptions(false); + clearNamedPlaylistLoadState(); + state.selectedRows.clear(); + if (state.playlist.length) state.selectedRows.add(state.playlist[0].id); + state.currentIndex = state.playlist.length ? 0 : -1; + state.selectionAnchorIndex = state.currentIndex; + localStorage.removeItem(namedPlaylistBackupStorageKey); + renderPlaylist(); + updatePreview(); + addLog(`DB 불러오기 전 플레이리스트 ${state.playlist.length}개 복원`); + showToast("DB 불러오기 전 상태를 복원했습니다."); + } catch { + showToast("DB 불러오기 전 상태 백업을 복원할 수 없습니다."); + } + } + + function namedPageTextForEntry(entry) { + if (!namedPagedBuilderKeys.has(entry.builderKey)) return "1/1"; + const guard = state.namedPlaylist.guardByEntryId.get(entry.id); + const guardedCount = guard?.recalculatedPagePlan?.pageCount; + const previewCount = entry.pagePreview?.pageCount ?? entry.pageCount; + const pageCount = Number.isInteger(guardedCount) ? guardedCount : previewCount; + if (!Number.isInteger(pageCount) || pageCount < 1 || + pageCount > namedPlaylistWorkflow.bridgeContract.maximumPageCount) { + throw new Error("5·6·12행 장면은 현재 DB 결과로 페이지를 다시 계산한 뒤 저장해야 합니다."); + } + return `1/${pageCount}`; + } + + function isTrustedNamedPlaylistEntry(entry) { + const guard = state.namedPlaylist.guardByEntryId.get(entry?.id); + if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false; + const selection = entry?.selection; + if (!selection) return false; + const now = namedNowForRawRow(selection); + const restored = normalizeStoredPlaylist([entry], { now }); + if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey || + restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false; + let fields; + try { + fields = namedPlaylistWorkflow.toLegacySevenFields(entry, namedPageTextForEntry(entry)); + } catch { + return false; + } + const roundTrip = resolveNamedPlaylistRawRow({ + itemIndex: 0, + enabled: fields[0] === "1", + groupCode: fields[1], + subject: fields[2], + graphicType: fields[3], + subtype: fields[4], + pageText: fields[5], + dataCode: fields[6] + }, 0); + return Boolean(roundTrip && roundTrip.builderKey === entry.builderKey && roundTrip.code === entry.code); + } + + function selectedNamedPlaylistDefinition() { + return state.namedPlaylist.definitions.find( + value => value.programCode === state.namedPlaylist.selectedProgramCode) || null; + } + + function namedPlaylistBusy() { + return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending); + } + + function renderNamedPlaylist() { + const workflow = state.namedPlaylist; + const locked = isPlaylistSnapshotLocked(); + const pending = workflow.pending || workflow.pagePlanPending; + const selected = selectedNamedPlaylistDefinition(); + const blockers = namedPlaylistPrepareBlockers(); + const pageRetryAvailable = workflow.pagePlanError?.retryable === true || + blockers.some(value => value.reason === "page-result-empty"); + const writeAllowed = Boolean(nativeBridge) && !locked && !pending && + workflow.canMutate && !workflow.writeQuarantined; + + elements.namedPlaylistPanel.hidden = !workflow.open; + elements.namedPlaylistOpenButton.setAttribute("aria-expanded", String(workflow.open)); + elements.namedPlaylistOpenButton.textContent = workflow.open ? "DB 패널 닫기" : "DB 저장·불러오기"; + + elements.namedPlaylistDefinitions.replaceChildren(); + for (const definition of workflow.definitions) { + const option = document.createElement("option"); + option.value = definition.programCode; + option.textContent = `${definition.title} · ${definition.programCode}`; + option.selected = definition.programCode === workflow.selectedProgramCode; + elements.namedPlaylistDefinitions.append(option); + } + elements.namedPlaylistSelectionSummary.textContent = selected + ? `${selected.title} · ${selected.programCode}${workflow.loadedDefinition?.programCode === selected.programCode ? " · 현재 불러온 정의" : ""}` + : "선택된 DB 정의 없음"; + + elements.namedPlaylistStatus.classList.remove("ready", "error", "quarantined"); + elements.namedPlaylistStateBadge.classList.remove("neutral", "live", "dry-run"); + let badge = "대기"; + let status = "Oracle에 저장된 이름 목록을 조회하세요. 로컬 임시저장과는 별도입니다."; + if (!nativeBridge) { + badge = "미리보기"; + status = "Native Bridge가 없어 Oracle DB 플레이리스트를 사용할 수 없습니다."; + } else if (workflow.writeQuarantined) { + badge = "WRITE 잠금"; + status = workflow.error || "DB 쓰기 결과가 불명확합니다. 앱을 다시 시작하기 전까지 생성·덮어쓰기·삭제를 반복하지 않습니다."; + elements.namedPlaylistStatus.classList.add("quarantined"); + elements.namedPlaylistStateBadge.classList.add("dry-run"); + } else if (workflow.error) { + badge = "오류"; + status = workflow.error; + elements.namedPlaylistStatus.classList.add("error"); + elements.namedPlaylistStateBadge.classList.add("dry-run"); + } else if (pending) { + badge = "처리 중"; + status = pending.operation === "page-preflight" + ? "현재 Oracle 조회 결과로 5·6·12행 장면의 페이지 수를 계산하고 있습니다. 이 조회는 송출 엔진을 호출하지 않습니다." + : `${pending.operation.toLocaleUpperCase("en-US")} 요청을 처리하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다.`; + } else if (workflow.loadedDefinition) { + badge = blockers.length ? "복원 확인" : "불러옴"; + status = blockers.length + ? `${workflow.loadedDefinition.title}을(를) 불러왔지만 ${blockers.length}개 안전 조건이 남았습니다. 표시된 행을 현재 전용 선택 화면에서 다시 구성하세요.` + : `${workflow.loadedDefinition.title} · ${state.playlist.length}개 행이 신뢰 복원과 페이지 검증을 통과했습니다.`; + elements.namedPlaylistStatus.classList.add(blockers.length ? "quarantined" : "ready"); + elements.namedPlaylistStateBadge.classList.add(blockers.length ? "dry-run" : "live"); + } else if (workflow.status === "ready") { + badge = "목록 준비"; + status = `${workflow.definitions.length}개 DB 정의를 조회했습니다.${workflow.canMutate ? "" : " 현재 환경은 DB 쓰기를 허용하지 않습니다."}`; + elements.namedPlaylistStatus.classList.add("ready"); + elements.namedPlaylistStateBadge.classList.add("live"); + } else { + elements.namedPlaylistStateBadge.classList.add("neutral"); + } + if (!elements.namedPlaylistStateBadge.classList.contains("live") && + !elements.namedPlaylistStateBadge.classList.contains("dry-run")) { + elements.namedPlaylistStateBadge.classList.add("neutral"); + } + elements.namedPlaylistStateBadge.textContent = badge; + elements.namedPlaylistStatus.textContent = locked + ? "PREPARED/PROGRAM 상태에서는 DB 플레이리스트 UI가 TAKE OUT까지 잠깁니다." + : status; + + elements.namedPlaylistRefreshButton.disabled = !nativeBridge || locked || Boolean(pending); + elements.namedPlaylistDefinitions.disabled = locked || Boolean(pending) || !workflow.definitions.length; + elements.namedPlaylistLoadButton.disabled = !nativeBridge || locked || Boolean(pending) || !selected; + elements.namedPlaylistPageRetryButton.hidden = !pageRetryAvailable; + elements.namedPlaylistPageRetryButton.disabled = !nativeBridge || locked || Boolean(pending) || + !workflow.restoration; + elements.namedPlaylistRestoreBackupButton.disabled = locked || Boolean(pending) || !hasNamedPlaylistBackup(); + elements.namedPlaylistProgramCode.disabled = !writeAllowed; + elements.namedPlaylistProgramTitle.disabled = !writeAllowed; + elements.namedPlaylistCreateButton.disabled = !writeAllowed; + elements.namedPlaylistReplaceButton.disabled = !writeAllowed || !selected; + elements.namedPlaylistDeleteButton.disabled = !writeAllowed || !selected; + for (const button of [ + elements.namedPlaylistRefreshButton, + elements.namedPlaylistLoadButton, + elements.namedPlaylistPageRetryButton, + elements.namedPlaylistCreateButton, + elements.namedPlaylistReplaceButton, + elements.namedPlaylistDeleteButton + ]) button.setAttribute("aria-busy", String(Boolean(pending))); + + const unresolved = blockers.filter(value => value.reason === "trusted-restore-required").length; + const pagePending = blockers.filter(value => value.reason === "page-recalculation-required").length; + const pageEmpty = blockers.filter(value => value.reason === "page-result-empty").length; + elements.namedPlaylistGuardSummary.classList.toggle("blocked", blockers.length > 0); + elements.namedPlaylistGuardSummary.textContent = blockers.length + ? `PREPARE 차단 · 컷 복원 ${unresolved}건 · 페이지 조회 ${pagePending}건 · DB 결과 없음 ${pageEmpty}건. 원본 7필드가 여러 컷과 겹치면 임의로 선택하지 않습니다.` + : "DB에서 읽은 각 행은 기존 trusted restore 체인과 현재 20페이지 상한 검증을 통과했습니다."; + } + + function beginNamedPlaylistOperation(operation, request, programCode = "") { + if (namedPlaylistBusy()) return false; + state.namedPlaylist.pending = { + requestId: request.requestId, + operation, + programCode + }; + state.namedPlaylist.status = "pending"; + state.namedPlaylist.error = ""; + postNative(namedPlaylistWorkflow.bridgeContract.requests[operation], request); + renderNamedPlaylist(); + return true; + } + + function requestNamedPlaylistList() { + if (!nativeBridge) return showToast("브라우저 미리보기에서는 Oracle DB 이름 목록을 조회하지 않습니다."); + if (isPlaylistSnapshotLocked()) return showToast("TAKE OUT 후 DB 플레이리스트를 조회하세요."); + try { + const request = namedPlaylistWorkflow.createListRequest(`named-list-${createId()}`, 1000); + beginNamedPlaylistOperation("list", request); + } catch { + showToast("DB 플레이리스트 목록 요청을 만들 수 없습니다."); + } + } + + function requestNamedPlaylistLoad() { + if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy()) return; + const definition = selectedNamedPlaylistDefinition(); + if (!definition) return showToast("불러올 DB 정의를 선택하세요."); + if (state.playlist.length && !window.confirm( + `${definition.title}을(를) 불러오면 현재 화면의 플레이리스트가 바뀝니다. 불러오기 직전 상태는 별도 로컬 백업으로 보존합니다.`)) return; + try { + const request = namedPlaylistWorkflow.createLoadRequest( + `named-load-${createId()}`, + definition.programCode); + beginNamedPlaylistOperation("load", request, definition.programCode); + } catch { + showToast("선택한 DB 플레이리스트 요청 형식이 올바르지 않습니다."); + } + } + + function requestNamedPlaylistCreate() { + if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() || + state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return; + try { + const request = namedPlaylistWorkflow.createDefinitionRequest( + `named-create-${createId()}`, + elements.namedPlaylistProgramCode.value, + elements.namedPlaylistProgramTitle.value); + if (!window.confirm(`${request.programCode} · ${request.title} 이름을 새로 만들까요? 현재 순서는 만든 뒤 '선택 정의에 현재 순서 저장'으로 저장합니다.`)) return; + state.namedPlaylist.selectAfterRefresh = request.programCode; + beginNamedPlaylistOperation("create", request, request.programCode); + } catch { + showToast("새 코드는 8자리 숫자, 이름은 앞뒤 공백 없는 128자 이내로 입력하세요."); + } + } + + function requestNamedPlaylistReplace() { + if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() || + state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return; + const definition = selectedNamedPlaylistDefinition(); + if (!definition) return showToast("덮어쓸 DB 정의를 선택하세요."); + const blockers = namedPlaylistPrepareBlockers(); + if (blockers.length) { + renderNamedPlaylist(); + return showToast("복원 또는 페이지 재계산이 끝나지 않은 행은 DB에 덮어쓸 수 없습니다."); + } + try { + const request = namedPlaylistWorkflow.createReplaceRequest( + `named-replace-${createId()}`, + definition.programCode, + state.playlist, + { + isTrustedEntry: isTrustedNamedPlaylistEntry, + pageTextForEntry: namedPageTextForEntry + }); + if (!window.confirm(`${definition.title}의 DB 플레이리스트를 현재 ${request.items.length}개 순서로 교체할까요? 이 작업은 한 트랜잭션으로 실행되며 자동 재시도하지 않습니다.`)) return; + beginNamedPlaylistOperation("replace", request, definition.programCode); + } catch (error) { + addLog(`DB 플레이리스트 저장 차단 · ${error?.message || "trusted restore 실패"}`); + showToast("DB 7필드로 유일하게 왕복 복원되지 않거나 현재 페이지 수가 없는 행이 있어 저장을 중단했습니다."); + } + } + + function requestNamedPlaylistDelete() { + if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() || + state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return; + const definition = selectedNamedPlaylistDefinition(); + if (!definition) return showToast("삭제할 DB 정의를 선택하세요."); + if (!window.confirm(`${definition.title} · ${definition.programCode} 정의와 그 하위 PLAY_LIST 행을 삭제할까요?`)) return; + try { + const request = namedPlaylistWorkflow.createDeleteRequest( + `named-delete-${createId()}`, + definition.programCode); + beginNamedPlaylistOperation("delete", request, definition.programCode); + } catch { + showToast("선택한 DB 정의를 삭제할 수 없습니다."); + } + } + + function namedPendingMatches(operation, payload) { + const pending = state.namedPlaylist.pending; + return Boolean(pending && pending.operation === operation && payload?.requestId === pending.requestId); + } + + function failNamedPlaylistProtocol(operation) { + const pending = state.namedPlaylist.pending; + if (!pending || pending.operation !== operation) return; + state.namedPlaylist.pending = null; + state.namedPlaylist.status = "error"; + state.namedPlaylist.error = "Native Bridge가 유효한 DB 플레이리스트 응답을 보내지 않았습니다."; + if (namedWriteOperations.has(operation)) { + state.namedPlaylist.writeQuarantined = true; + state.namedPlaylist.canMutate = false; + state.namedPlaylist.error += " 쓰기 결과를 확인할 수 없어 앱 재시작 전까지 추가 쓰기를 차단합니다."; + } + renderNamedPlaylist(); + } + + function handleNamedPlaylistListResults(payload) { + const result = namedPlaylistWorkflow.normalizeListResponse(payload); + if (!result) return failNamedPlaylistProtocol("list"); + if (!namedPendingMatches("list", result)) return; + const previousSuggestion = state.namedPlaylist.suggestedProgramCode; + state.namedPlaylist.pending = null; + state.namedPlaylist.status = "ready"; + state.namedPlaylist.definitions = [...result.definitions]; + state.namedPlaylist.suggestedProgramCode = result.suggestedProgramCode; + state.namedPlaylist.canMutate = result.canMutate; + state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined; + const preferred = state.namedPlaylist.selectAfterRefresh || state.namedPlaylist.selectedProgramCode; + state.namedPlaylist.selectedProgramCode = result.definitions.some(value => value.programCode === preferred) + ? preferred + : (result.definitions[0]?.programCode || ""); + state.namedPlaylist.selectAfterRefresh = ""; + if (!elements.namedPlaylistProgramCode.value || + elements.namedPlaylistProgramCode.value === previousSuggestion) { + elements.namedPlaylistProgramCode.value = result.suggestedProgramCode; + } + renderNamedPlaylist(); + addLog(`DB 플레이리스트 이름 목록 ${result.definitions.length}개 조회`); + } + + function handleNamedPlaylistLoadResults(payload) { + const load = namedPlaylistWorkflow.normalizeLoadResponse(payload); + if (!load) return failNamedPlaylistProtocol("load"); + if (!namedPendingMatches("load", load)) return; + state.namedPlaylist.pending = null; + state.namedPlaylist.canMutate = load.canMutate; + state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || load.writeQuarantined; + if (isPlaylistSnapshotLocked()) { + state.namedPlaylist.status = "error"; + state.namedPlaylist.error = "DB 응답 중 송출 상태가 잠겨 현재 플레이리스트를 교체하지 않았습니다."; + renderNamedPlaylist(); + return; + } + if (!saveNamedPlaylistBackup()) { + state.namedPlaylist.status = "error"; + state.namedPlaylist.error = "현재 플레이리스트를 로컬 백업하지 못해 DB 불러오기를 적용하지 않았습니다."; + renderNamedPlaylist(); + return; + } + try { + let restoration = namedPlaylistWorkflow.restoreRawRows(load, resolveNamedPlaylistRawRow); + const pendingNamedManual = restoration.rows.flatMap(row => { + if (row.trusted) return []; + const pending = namedManualRestoreWorkflow.classify(row.rawRow, { + id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`, + fadeDuration: DEFAULT_FADE_DURATION + }); + return pending ? [pending] : []; + }); + state.namedPlaylist.manualRestoreFinalized = pendingNamedManual.length === 0; + if (state.namedPlaylist.manualRestoreFinalized) { + restoration = prepareNamedPagePreflight(restoration); + } + const materialized = materializeNamedRestoration(restoration); + state.playlist = materialized.entries; + synchronizeGlobalCandleOptions(false); + state.namedPlaylist.guardByEntryId = materialized.guards; + state.namedPlaylist.restoration = restoration; + state.namedPlaylist.pagePlanError = null; + state.namedPlaylist.loadedDefinition = load.definition; + state.namedPlaylist.selectedProgramCode = load.definition.programCode; + state.namedPlaylist.status = "loaded"; + state.namedPlaylist.error = ""; + state.selectedRows.clear(); + if (state.playlist.length) state.selectedRows.add(state.playlist[0].id); + state.currentIndex = state.playlist.length ? 0 : -1; + state.selectionAnchorIndex = state.currentIndex; + installManualFinancialRestores(pendingNamedManual); + renderPlaylist(); + updatePreview(); + const blockers = namedPlaylistPrepareBlockers(); + addLog(`DB 플레이리스트 ${load.definition.title} · ${state.playlist.length}개 불러오기 · 차단 ${blockers.length}건`); + showToast(blockers.length + ? "DB 목록을 불러왔습니다. 표시된 복원·페이지 항목을 확인하세요." + : "DB 플레이리스트를 안전하게 불러왔습니다."); + if (state.namedPlaylist.manualRestoreFinalized) requestNamedPlaylistPagePlans(); + } catch { + state.namedPlaylist.status = "error"; + state.namedPlaylist.error = "DB 원시 행의 엄격한 복원 중 오류가 발생해 현재 플레이리스트를 교체하지 않았습니다."; + renderNamedPlaylist(); + } + } + + function requestNamedPlaylistPagePlans() { + const workflow = state.namedPlaylist; + if (!nativeBridge || isPlaylistSnapshotLocked() || workflow.pending || + workflow.pagePlanPending || !workflow.restoration) return; + try { + const request = namedPlaylistWorkflow.createPagePlanRequest( + `named-page-plan-${createId()}`, + workflow.restoration); + if (!request) { + workflow.pagePlanError = null; + workflow.status = "loaded"; + renderNamedPlaylist(); + return; + } + workflow.pagePlanPending = { + operation: "page-preflight", + requestId: request.requestId, + request, + restoration: workflow.restoration + }; + workflow.pagePlanError = null; + workflow.error = ""; + workflow.status = "page-preflight"; + postNative(namedPlaylistWorkflow.pagePlanBridgeContract.request, request); + renderNamedPlaylist(); + renderPlayout(); + } catch (error) { + workflow.status = "error"; + workflow.pagePlanError = Object.freeze({ + code: "INVALID_PAGE_PREFLIGHT", + message: error?.message || "페이지 조회 요청을 만들 수 없습니다.", + retryable: false + }); + workflow.error = workflow.pagePlanError.message; + renderNamedPlaylist(); + } + } + + function failNamedPagePlanProtocol(pending) { + if (state.namedPlaylist.pagePlanPending !== pending) return; + state.namedPlaylist.pagePlanPending = null; + state.namedPlaylist.status = "error"; + state.namedPlaylist.pagePlanError = Object.freeze({ + code: "INVALID_RESPONSE", + message: "Native Bridge가 현재 요청과 정확히 일치하는 페이지 계산 결과를 보내지 않았습니다.", + retryable: true + }); + state.namedPlaylist.error = state.namedPlaylist.pagePlanError.message; + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedPlaylistPagePlanResults(payload) { + const pending = state.namedPlaylist.pagePlanPending; + if (!pending || payload?.requestId !== pending.requestId) return; + const result = namedPlaylistWorkflow.normalizePagePlanResponse(payload, pending.request); + if (!result) return failNamedPagePlanProtocol(pending); + if (state.namedPlaylist.restoration !== pending.restoration) return; + try { + const restoration = namedPlaylistWorkflow.applyPagePlanResponse( + pending.restoration, + result); + const materialized = materializeNamedRestoration(restoration); + state.namedPlaylist.restoration = restoration; + state.namedPlaylist.guardByEntryId = materialized.guards; + state.namedPlaylist.pagePlanPending = null; + state.namedPlaylist.pagePlanError = null; + state.namedPlaylist.status = "loaded"; + state.namedPlaylist.error = ""; + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + const empty = result.plans.filter(plan => plan.itemCount === 0).length; + const truncated = result.plans.filter(plan => plan.isTruncated).length; + addLog(`DB 페이지 사전조회 ${result.plans.length}개 완료 · 결과 없음 ${empty}개 · 20페이지 제한 ${truncated}개`); + showToast(empty + ? `페이지 조회를 완료했지만 ${empty}개 장면은 DB 결과가 없어 PREPARE할 수 없습니다.` + : "현재 DB 결과로 페이지 계산을 완료했습니다."); + } catch { + failNamedPagePlanProtocol(pending); + } + } + + function handleNamedPlaylistPagePlanError(payload) { + const pending = state.namedPlaylist.pagePlanPending; + if (!pending || payload?.requestId !== pending.requestId) return; + const error = namedPlaylistWorkflow.normalizePagePlanError(payload, pending.requestId); + if (!error) return failNamedPagePlanProtocol(pending); + state.namedPlaylist.pagePlanPending = null; + state.namedPlaylist.status = "error"; + state.namedPlaylist.pagePlanError = error; + state.namedPlaylist.error = `${error.message} [${error.code}] 자동 재시도하지 않습니다.`; + renderNamedPlaylist(); + renderPlayout(); + addLog(`DB 페이지 사전조회 오류 · ${error.code}`); + showToast(state.namedPlaylist.error); + } + + function handleNamedPlaylistMutationResults(payload) { + const result = namedPlaylistWorkflow.normalizeMutationResponse(payload); + if (!result) { + const operation = state.namedPlaylist.pending?.operation; + if (namedWriteOperations.has(operation)) failNamedPlaylistProtocol(operation); + return; + } + if (!namedPendingMatches(result.operation, result)) return; + state.namedPlaylist.pending = null; + state.namedPlaylist.status = "ready"; + state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined; + if (result.writeQuarantined) state.namedPlaylist.canMutate = false; + if (result.operation === "delete") { + state.namedPlaylist.definitions = state.namedPlaylist.definitions.filter( + value => value.programCode !== result.programCode); + if (state.namedPlaylist.loadedDefinition?.programCode === result.programCode) { + state.namedPlaylist.loadedDefinition = null; + } + state.namedPlaylist.selectedProgramCode = ""; + } else { + state.namedPlaylist.selectAfterRefresh = result.programCode; + } + addLog(`DB 플레이리스트 ${result.operation.toLocaleUpperCase("en-US")} 완료 · ${result.programCode}`); + showToast("DB 플레이리스트 작업이 완료되었습니다."); + requestNamedPlaylistList(); + } + + function handleNamedPlaylistError(payload) { + const error = namedPlaylistWorkflow.normalizeError(payload); + const pending = state.namedPlaylist.pending; + if (!error || !pending || error.requestId !== pending.requestId || error.operation !== pending.operation) return; + state.namedPlaylist.pending = null; + state.namedPlaylist.status = "error"; + state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined; + if (state.namedPlaylist.writeQuarantined) state.namedPlaylist.canMutate = false; + state.namedPlaylist.error = `${error.message} [${error.code}]${error.outcomeUnknown ? " 결과를 확인할 수 없어 자동 재시도하지 않습니다." : ""}`; + renderNamedPlaylist(); + addLog(`DB 플레이리스트 ${error.operation.toLocaleUpperCase("en-US")} 오류 · ${error.code}`); + showToast(state.namedPlaylist.error); + } + function normalizePlayoutState(value) { const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, ""); if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED"; @@ -817,6 +5349,10 @@ state.playout.error?.outcomeUnknown === true; const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable; const mutationCommandReady = commandReady && !state.playout.refreshFaulted; + const namedPrepareBlocked = namedPlaylistBusy() || + namedPlaylistPrepareBlockers().length > 0; + const candlePrepareBlocked = state.candleOptions.invalidIds.length > 0; + const manualFinancialBlocked = manualFinancialPrepareBlocked(); const currentCue = cueFromItem(state.playlist[state.currentIndex]); const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode); const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode); @@ -945,17 +5481,35 @@ elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음"; } - elements.prepareButton.disabled = !mutationCommandReady || !currentCue; - elements.takeInButton.disabled = !mutationCommandReady || !prepared || !takeInSafe; + elements.prepareButton.disabled = !mutationCommandReady || !currentCue || namedPrepareBlocked || + candlePrepareBlocked || manualFinancialBlocked; + elements.takeInButton.disabled = !mutationCommandReady || !currentCue || !takeInSafe || onAir; elements.nextButton.disabled = !mutationCommandReady || !onAir || state.playlist.length === 0 || state.playout.nextKind === "end-of-playlist"; elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode); const playlistLocked = isPlaylistSnapshotLocked(); + const operatorLocked = operatorUiLocked(); + manualListsUi?.setLocked(operatorLocked); + manualFinancialUi?.setLocked(operatorLocked); + operatorCatalogUi?.setLocked(operatorLocked); + elements.toggleAllEnabled.disabled = playlistLocked; elements.selectAll.disabled = playlistLocked; elements.moveUpButton.disabled = playlistLocked; elements.moveDownButton.disabled = playlistLocked; elements.deleteButton.disabled = playlistLocked; - elements.loadButton.disabled = playlistLocked; + elements.deleteAllButton.disabled = playlistLocked; + elements.loadButton.disabled = playlistLocked || namedPlaylistBusy(); + renderGlobalCandleOptions(); + renderNamedPlaylist(); + renderStockWorkflow(); + renderFixedWorkflow(); + renderIndustryWorkflow(); + renderComparisonWorkflow(); + renderThemeWorkflow(); + renderOverseasWorkflow(); + renderExpertWorkflow(); + renderTradingHaltWorkflow(); + renderBackgroundControls(); renderCatalog(); for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) { button.setAttribute("aria-busy", String(Boolean(pending))); @@ -1010,9 +5564,9 @@ function requestPlayoutCommand(command) { if (!nativeBridge) { showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다."); - return; + return false; } - if (state.playout.pending) return; + if (state.playout.pending) return false; const blockReason = playoutSafety.commandBlockReason({ pending: state.playout.pending, outcomeUnknown: state.playout.nativeOutcomeUnknown || @@ -1034,14 +5588,43 @@ "nothing-to-take-out": "종료할 송출 장면이 없습니다." }[blockReason] || "다른 송출 명령을 처리하고 있습니다."; showToast(message); - return; + return false; + } + + if (command === "prepare") { + if (namedPlaylistBusy()) { + showToast("DB 플레이리스트 및 현재 페이지 조회가 끝난 뒤 PREPARE를 실행하세요."); + return false; + } + const namedBlockers = namedPlaylistPrepareBlockers(); + if (namedBlockers.length) { + state.namedPlaylist.open = true; + renderNamedPlaylist(); + showToast("DB에서 불러온 행의 컷 복원 또는 현재 페이지 재계산이 끝나지 않아 PREPARE를 차단했습니다."); + return false; + } + if (manualFinancialPrepareBlocked()) { + showToast("수동 재무 항목의 현재 DB 행·종목·장면 선택 재검증이 끝나지 않아 PREPARE를 차단했습니다."); + return false; + } + if (!synchronizeGlobalCandleOptions(false)) { + showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다."); + return false; + } + if (!synchronizeFixedEntries(false)) return false; + if (!synchronizeIndustryEntries(false)) return false; + if (!synchronizeComparisonEntries()) return false; + if (!synchronizeThemeEntries()) return false; + if (!synchronizeOverseasEntries()) return false; + if (!synchronizeExpertEntries()) return false; + if (!synchronizeTradingHaltEntries()) return false; } const resolved = resolveCommand(command); if (!resolved) { const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요."; showToast(message); - return; + return false; } const requestId = createId(); @@ -1067,6 +5650,7 @@ pending.timeoutId = setTimeout(() => { if (state.playout.pending?.requestId !== requestId) return; state.playout.pending = null; + if (command === "prepare") state.playout.takeInAfterPrepare = false; postNative("playout-timeout-quarantine", { requestId, command }); showPlayoutError({ code: "WEB_TIMEOUT", @@ -1077,6 +5661,143 @@ addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`); requestPlayoutStatus(); }, state.playout.responseTimeoutMs); + return true; + } + + function requestLegacyTakeInShortcut() { + const action = playoutSafety.legacyTakeInShortcutAction({ + pending: Boolean(state.playout.pending), + takeInAllowed: isDryRunMode() || + ((isTestMode() || isLiveMode()) && state.playout.liveTakeInAllowed), + engineState: state.playout.engineState, + preparedCode: state.playout.preparedCode, + onAirCode: state.playout.onAirCode + }); + if (action === "blocked") return false; + if (action === "take-in-locked") { + showToast("현재 검증 회차는 TAKE IN 승인이 잠겨 있어 PREPARE를 자동 실행하지 않습니다."); + return false; + } + if (action === "next-required") { + showToast("현재 장면이 송출 중입니다. 다음 장면은 NEXT를 사용하세요."); + return false; + } + if (action === "take-in") { + state.playout.takeInAfterPrepare = false; + return requestPlayoutCommand("take-in"); + } + + state.playout.takeInAfterPrepare = true; + const started = requestPlayoutCommand("prepare"); + if (!started) state.playout.takeInAfterPrepare = false; + else addLog("F8/TAKE IN · PREPARE 성공 후 1회 TAKE IN 예약"); + return started; + } + + function renderBackgroundControls() { + const background = state.background; + const locked = isPlaylistSnapshotLocked(); + elements.backgroundFileName.textContent = background.enabled + ? (background.fileName || "선택된 배경") + : "배경 없음"; + elements.backgroundStateText.textContent = background.error || background.message || + (background.enabled ? `${background.kind.toLocaleUpperCase("en-US")} 배경 사용` : "배경 사용 안 함"); + elements.chooseBackgroundButton.disabled = !nativeBridge || locked || + background.pending || !background.canChange; + elements.toggleBackgroundButton.disabled = !nativeBridge || locked || + background.pending || !background.canChange; + elements.chooseBackgroundButton.setAttribute("aria-busy", String(background.pending)); + elements.toggleBackgroundButton.setAttribute("aria-busy", String(background.pending)); + } + + function beginBackgroundRequest(type) { + if (!nativeBridge) { + showToast("브라우저 미리보기에서는 네이티브 배경 파일을 선택할 수 없습니다."); + return false; + } + if (isPlaylistSnapshotLocked() || state.background.pending || !state.background.canChange) { + showToast("배경은 IDLE 상태에서 다음 PREPARE 전에만 변경할 수 있습니다."); + return false; + } + const requestId = createId(); + state.background.requestId = requestId; + state.background.pending = true; + state.background.error = ""; + state.background.message = type === "choose-background" + ? "배경 파일 선택 창을 기다리고 있습니다." + : "배경 사용 상태를 변경하고 있습니다."; + renderBackgroundControls(); + postNative(type, { requestId }); + return true; + } + + function requestBackgroundStatus() { + if (!nativeBridge) { + state.background.canChange = false; + state.background.pending = false; + state.background.message = "브라우저 미리보기 · 배경 변경 없음"; + renderBackgroundControls(); + return; + } + const requestId = createId(); + state.background.requestId = requestId; + postNative("request-background-status", { requestId }); + } + + function requestBackgroundSelection() { + return beginBackgroundRequest("choose-background"); + } + + function requestBackgroundToggle() { + return beginBackgroundRequest("toggle-background"); + } + + function notifyLegacyBackgroundShortcut() { + return requestBackgroundSelection(); + } + + function handleBackgroundStatus(payload) { + if (!payload || typeof payload !== "object" || + (payload.requestId && payload.requestId !== state.background.requestId)) return; + const kind = payload.kind; + const fileName = payload.fileName; + const message = payload.message; + const validText = value => typeof value === "string" && value.length <= 260 && + !/[\u0000-\u001f\u007f]/.test(value); + if (typeof payload.enabled !== "boolean" || + !["none", "texture", "video"].includes(kind) || + !validText(fileName) || !validText(message) || + typeof payload.canChange !== "boolean" || + (payload.enabled && (kind === "none" || !fileName)) || + (!payload.enabled && kind !== "none")) { + state.background.pending = false; + state.background.error = "배경 상태 응답 형식이 올바르지 않습니다."; + renderBackgroundControls(); + return; + } + Object.assign(state.background, { + enabled: payload.enabled, + kind, + fileName, + canChange: payload.canChange, + pending: false, + message, + error: "" + }); + renderBackgroundControls(); + } + + function handleBackgroundError(payload) { + if (!payload || typeof payload !== "object" || + payload.requestId !== state.background.requestId) return; + const message = typeof payload.message === "string" && payload.message.length <= 512 && + !/[\u0000-\u001f\u007f]/.test(payload.message) + ? payload.message + : "배경 설정을 변경하지 못했습니다."; + state.background.pending = false; + state.background.error = message; + renderBackgroundControls(); + showToast(message); } function requestPlayoutStatus() { @@ -1197,6 +5918,8 @@ function handlePlayoutCommandResult(payload) { const pending = finishPendingCommand(payload); if (!pending) return; + const takeInAfterPrepare = pending.command === "prepare" && state.playout.takeInAfterPrepare; + if (pending.command === "prepare") state.playout.takeInAfterPrepare = false; if (payload.succeeded !== true) { showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true }); addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`); @@ -1252,12 +5975,18 @@ const safetyLabel = payload.dryRun === true ? "DRY RUN · " : ""; addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`); showToast(state.playout.message); - requestPlayoutStatus(); + if (takeInAfterPrepare) { + addLog("PREPARE 확인 · 예약된 TAKE IN 1회 실행"); + setTimeout(() => requestPlayoutCommand("take-in"), 0); + } else { + requestPlayoutStatus(); + } } function handlePlayoutCommandError(payload) { const pending = finishPendingCommand(payload); if (!pending) return; + if (pending.command === "prepare") state.playout.takeInAfterPrepare = false; if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true; showPlayoutError({ code: payload.code, @@ -1588,9 +6317,7 @@ const body = document.createElement("tbody"); for (const row of rows) { const tr = document.createElement("tr"); - tr.className = "live-data-selectable"; - tr.title = "두 번 클릭하면 현재 플레이리스트 장면의 DB 선택값으로 적용합니다."; - tr.addEventListener("dblclick", () => applyLiveRowToCurrentCut(table, row, columns)); + tr.title = "시장 현황 조회 전용입니다. 종목 컷은 위 종목 검색에서 선택하세요."; columns.forEach((column, index) => { const td = document.createElement("td"); td.textContent = formatCellValue(cellValue(row, column, index)); @@ -1723,6 +6450,10 @@ } if (!message || typeof message.type !== "string") return; + if (manualFinancialUi?.handleMessage(message.type, message.payload)) return; + if (manualListsUi?.handleMessage(message.type, message.payload)) return; + if (operatorCatalogUi?.handleMessage(message.type, message.payload)) return; + switch (message.type) { case "app-info": { const info = message.payload || {}; @@ -1741,6 +6472,112 @@ case "market-data-error": handleMarketDataError(message.payload); break; + case "stock-search-results": + if (!handleManualFinancialRestoreStockResults(message.payload) && + !handleComparisonDomesticResults(message.payload)) handleStockSearchResults(message.payload); + break; + case "stock-search-error": + if (!handleManualFinancialRestoreStockError(message.payload) && + !handleComparisonDomesticError(message.payload)) handleStockSearchError(message.payload); + break; + case "manual-financial-load-results": + handleManualFinancialRestoreLoadResults(message.payload); + break; + case "manual-financial-load-error": + handleManualFinancialRestoreLoadError(message.payload); + break; + case "manual-financial-selection-results": + handleManualFinancialRestoreSelectionResults(message.payload); + break; + case "manual-financial-selection-error": + handleManualFinancialRestoreSelectionError(message.payload); + break; + case "manual-financial-request-error": + handleManualFinancialRestoreRequestError(message.payload); + break; + case "manual-net-sell-data": + case "vi-manual-list-data": + handleNamedManualListResults(message.payload); + break; + case "manual-operator-data-error": + handleNamedManualListError(message.payload); + break; + case "world-stock-search-results": + if (!handleOverseasSearchResults("stock", message.payload)) { + handleComparisonWorldResults(message.payload); + } + break; + case "world-stock-search-error": + if (!handleOverseasSearchError("stock", message.payload)) { + handleComparisonWorldError(message.payload); + } + break; + case "overseas-industry-results": + handleOverseasSearchResults("industry", message.payload); + break; + case "overseas-industry-error": + handleOverseasSearchError("industry", message.payload); + break; + case "expert-search-results": + handleExpertSearchResults(message.payload); + break; + case "expert-search-error": + handleExpertSearchError(message.payload); + break; + case "expert-preview-results": + handleExpertPreviewResults(message.payload); + break; + case "expert-preview-error": + handleExpertPreviewError(message.payload); + break; + case "theme-search-results": + handleThemeSearchResults(message.payload); + break; + case "theme-search-error": + handleThemeSearchError(message.payload); + break; + case "theme-preview-results": + handleThemePreviewResults(message.payload); + break; + case "theme-preview-error": + handleThemePreviewError(message.payload); + break; + case "trading-halt-results": + handleTradingHaltResults(message.payload); + break; + case "trading-halt-error": + handleTradingHaltError(message.payload); + break; + case "background-status": + handleBackgroundStatus(message.payload); + break; + case "background-error": + handleBackgroundError(message.payload); + break; + case "industry-results": + handleIndustryResults(message.payload); + break; + case "industry-error": + handleIndustryError(message.payload); + break; + case "named-playlist-list-results": + handleNamedPlaylistListResults(message.payload); + break; + case "named-playlist-load-results": + handleNamedPlaylistLoadResults(message.payload); + break; + case "named-playlist-mutation-results": + handleNamedPlaylistMutationResults(message.payload); + break; + case "named-playlist-error": + handleNamedPlaylistError(message.payload); + break; + case "playlist-page-plans-results": + handleNamedPlaylistPagePlanResults(message.payload); + break; + case "playlist-page-plans-error": + handleNamedPlaylistPagePlanError(message.payload); + break; case "playout-status": handlePlayoutStatus(message.payload); break; @@ -1757,12 +6594,32 @@ document.querySelector("#marketNav").addEventListener("click", event => { const button = event.target.closest("button[data-market]"); if (!button) return; + const previousMarket = state.activeMarket; document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button)); state.activeMarket = button.dataset.market; + if (previousMarket !== state.activeMarket && industryWorkflow.markets.includes(state.activeMarket)) { + state.stockWorkflowCollapsed = true; + state.industryWorkflowCollapsed = false; + } else if (previousMarket !== state.activeMarket && state.activeMarket === "dashboard") { + state.stockWorkflowCollapsed = false; + } elements.workspaceTitle.textContent = marketTitles[state.activeMarket]; + renderStockWorkflow(); + renderFixedWorkflow(); + renderIndustryWorkflow(); + renderComparisonWorkflow(); + renderThemeWorkflow(); + renderOverseasWorkflow(); + renderExpertWorkflow(); + renderTradingHaltWorkflow(); renderCatalog(); requestMarketData(state.activeMarket); - if (liveDataViews.has(state.activeMarket)) requestDatabaseStatus(); + if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket); + if (state.activeMarket === "theme" && state.theme.search.status === "idle") requestThemeSearch(); + if (state.activeMarket === "foreign-stock" && currentOverseasStore().status === "idle") requestOverseasSearch(); + if (state.activeMarket === "expert" && state.expert.search.status === "idle") requestExpertSearch(); + if (state.activeMarket === "halted" && state.tradingHalt.status === "idle") requestTradingHaltSearch(); + if (databaseBackedViews.has(state.activeMarket)) requestDatabaseStatus(); }); elements.catalogTabs.addEventListener("click", event => { @@ -1773,17 +6630,161 @@ renderCatalog(); }); + elements.stockSearchForm.addEventListener("submit", requestStockSearch); + elements.stockWorkflowToggle.addEventListener("click", () => { + state.stockWorkflowCollapsed = !state.stockWorkflowCollapsed; + if (!state.stockWorkflowCollapsed && industryWorkflow.markets.includes(state.activeMarket)) { + state.industryWorkflowCollapsed = true; + renderIndustryWorkflow(); + } + renderStockWorkflow(); + }); + elements.legacyFixedToggle.addEventListener("click", () => { + state.fixedWorkflowCollapsed = !state.fixedWorkflowCollapsed; + renderFixedWorkflow(); + }); + elements.legacyFixedSearch.addEventListener("input", event => { + state.fixedSearch = event.target.value; + renderFixedWorkflow(); + }); + elements.industryRetry.addEventListener("click", () => requestIndustries(state.activeMarket)); + elements.industryWorkflowToggle.addEventListener("click", () => { + state.industryWorkflowCollapsed = !state.industryWorkflowCollapsed; + if (!state.industryWorkflowCollapsed) { + state.stockWorkflowCollapsed = true; + renderStockWorkflow(); + } + renderIndustryWorkflow(); + }); + elements.industrySearch.addEventListener("input", event => { + state.industry.search = event.target.value; + renderIndustryWorkflow(); + }); + elements.industrySwap.addEventListener("click", () => { + if (isPlaylistSnapshotLocked() || !state.industry.pairFirst || !state.industry.pairSecond) return; + [state.industry.pairFirst, state.industry.pairSecond] = + [state.industry.pairSecond, state.industry.pairFirst]; + renderIndustryWorkflow(); + addLog("업종 비교 순서 교환"); + }); + elements.industryClear.addEventListener("click", () => { + if (isPlaylistSnapshotLocked()) return; + state.industry.highlighted = null; + state.industry.pairFirst = null; + state.industry.pairSecond = null; + renderIndustryWorkflow(); + }); + elements.comparisonSourceTabs.addEventListener("click", event => { + const button = event.target.closest("button[data-comparison-source]"); + if (!button || isPlaylistSnapshotLocked()) return; + state.comparison.source = button.dataset.comparisonSource; + renderComparisonWorkflow(); + }); + elements.comparisonDomesticSearchForm.addEventListener("submit", requestComparisonDomesticSearch); + elements.comparisonWorldSearchForm.addEventListener("submit", requestComparisonWorldSearch); + elements.comparisonSwap.addEventListener("click", () => { + if (isPlaylistSnapshotLocked()) return; + const swapped = comparisonWorkflow.swapPair([ + state.comparison.draftFirst, + state.comparison.draftSecond + ]); + if (!swapped) return; + [state.comparison.draftFirst, state.comparison.draftSecond] = swapped; + renderComparisonWorkflow(); + }); + elements.comparisonClear.addEventListener("click", () => { + if (isPlaylistSnapshotLocked()) return; + [state.comparison.draftFirst, state.comparison.draftSecond] = comparisonWorkflow.clearPair(); + renderComparisonWorkflow(); + }); + elements.comparisonSavePair.addEventListener("click", saveComparisonDraftPair); + elements.comparisonMoveUp.addEventListener("click", () => moveComparisonPair("up")); + elements.comparisonMoveDown.addEventListener("click", () => moveComparisonPair("down")); + elements.comparisonDeletePair.addEventListener("click", deleteSelectedComparisonPair); + elements.comparisonDeleteAll.addEventListener("click", deleteAllComparisonPairs); + elements.comparisonYieldBatch.addEventListener("click", addComparisonYieldBatch); + elements.themeSearchForm.addEventListener("submit", requestThemeSearch); + elements.themeNxtSession.addEventListener("change", () => { + if (isPlaylistSnapshotLocked()) return; + state.theme.nxtSession = elements.themeNxtSession.value; + requestThemeSearch(); + }); + elements.themeSort.addEventListener("change", () => { + const sort = themeWorkflow.normalizeThemeSort(elements.themeSort.value); + if (!sort || isPlaylistSnapshotLocked()) return; + state.theme.sort = sort.id; + renderThemeWorkflow(); + }); + elements.themeCatalogManageButton.addEventListener("click", () => { + const context = state.theme.preview.catalogContext; + operatorCatalogUi?.openTheme(context || undefined); + }); + elements.overseasSourceTabs.addEventListener("click", event => { + const button = event.target.closest("button[data-overseas-source]"); + if (!button || isPlaylistSnapshotLocked()) return; + state.overseas.source = button.dataset.overseasSource; + elements.overseasSearch.value = currentOverseasStore().request?.query || ""; + renderOverseasWorkflow(); + if (currentOverseasStore().status === "idle") requestOverseasSearch(); + }); + elements.overseasSearchForm.addEventListener("submit", requestOverseasSearch); + elements.expertSearchForm.addEventListener("submit", requestExpertSearch); + elements.expertCatalogManageButton.addEventListener("click", () => { + const context = state.expert.preview.catalogContext; + operatorCatalogUi?.openExpert(context || undefined); + }); + elements.tradingHaltSearchForm.addEventListener("submit", requestTradingHaltSearch); + elements.globalMa5.addEventListener("change", () => + applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true)); + elements.globalMa20.addEventListener("change", () => + applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true)); + elements.stockMa5.addEventListener("change", () => synchronizeStockCandleOptions(true)); + elements.stockMa20.addEventListener("change", () => synchronizeStockCandleOptions(true)); + elements.overseasMa5.addEventListener("change", () => + applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true)); + elements.overseasMa20.addEventListener("change", () => + applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true)); + elements.tradingHaltMa5.addEventListener("change", () => + applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true)); + elements.tradingHaltMa20.addEventListener("change", () => + applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true)); elements.catalogSearch.addEventListener("input", event => { state.search = event.target.value; renderCatalog(); }); + elements.toggleAllEnabled.addEventListener("click", toggleAllEntriesEnabled); document.querySelector("#selectAllButton").addEventListener("click", toggleSelectAll); document.querySelector("#moveUpButton").addEventListener("click", () => moveSelected(-1)); document.querySelector("#moveDownButton").addEventListener("click", () => moveSelected(1)); document.querySelector("#deleteButton").addEventListener("click", deleteSelected); - document.querySelector("#saveButton").addEventListener("click", savePlaylist); - document.querySelector("#loadButton").addEventListener("click", loadPlaylist); + elements.deleteAllButton.addEventListener("click", deleteAllPlaylistEntries); + elements.saveButton.addEventListener("click", savePlaylist); + elements.loadButton.addEventListener("click", loadPlaylist); + elements.namedPlaylistOpenButton.addEventListener("click", () => { + state.namedPlaylist.open = !state.namedPlaylist.open; + renderNamedPlaylist(); + if (state.namedPlaylist.open && !namedPlaylistBusy() && !state.namedPlaylist.definitions.length) { + requestNamedPlaylistList(); + } + }); + elements.namedPlaylistCloseButton.addEventListener("click", () => { + state.namedPlaylist.open = false; + renderNamedPlaylist(); + }); + elements.namedPlaylistRefreshButton.addEventListener("click", requestNamedPlaylistList); + elements.namedPlaylistDefinitions.addEventListener("change", () => { + state.namedPlaylist.selectedProgramCode = elements.namedPlaylistDefinitions.value; + renderNamedPlaylist(); + }); + elements.namedPlaylistLoadButton.addEventListener("click", requestNamedPlaylistLoad); + elements.namedPlaylistPageRetryButton.addEventListener("click", requestNamedPlaylistPagePlans); + elements.namedPlaylistRestoreBackupButton.addEventListener("click", restoreNamedPlaylistBackup); + elements.namedPlaylistCreateButton.addEventListener("click", requestNamedPlaylistCreate); + elements.namedPlaylistReplaceButton.addEventListener("click", requestNamedPlaylistReplace); + elements.namedPlaylistDeleteButton.addEventListener("click", requestNamedPlaylistDelete); elements.sceneSelectionForm.addEventListener("submit", applySceneSelection); elements.sceneCutAlias.addEventListener("change", applyCutAliasPreset); + elements.chooseBackgroundButton.addEventListener("click", requestBackgroundSelection); + elements.toggleBackgroundButton.addEventListener("click", requestBackgroundToggle); elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare")); - elements.takeInButton.addEventListener("click", () => requestPlayoutCommand("take-in")); + elements.takeInButton.addEventListener("click", requestLegacyTakeInShortcut); elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next")); elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out")); document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError()); @@ -1792,7 +6793,12 @@ postNative("request-app-info"); requestDatabaseStatus(); requestPlayoutStatus(); + requestBackgroundStatus(); if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket); + if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket); + if (state.activeMarket === "foreign-stock") requestOverseasSearch(); + if (state.activeMarket === "expert") requestExpertSearch(); + if (state.activeMarket === "halted") requestTradingHaltSearch(); }); document.querySelector("#retryLiveDataButton").addEventListener("click", () => { requestDatabaseStatus(); @@ -1800,22 +6806,101 @@ }); document.addEventListener("keydown", event => { + if (["F2", "F3", "F8", "Escape"].includes(event.key)) { + event.preventDefault(); + if (event.repeat) return; + if (event.key === "F2") notifyLegacyBackgroundShortcut(); + else if (event.key === "F3") requestBackgroundToggle(); + else if (event.key === "F8") requestLegacyTakeInShortcut(); + else requestPlayoutCommand("take-out"); + return; + } if (event.ctrlKey && event.key.toLowerCase() === "k") { - event.preventDefault(); elements.catalogSearch.focus(); return; + event.preventDefault(); + const comparisonTarget = state.comparison.source === "world" + ? elements.comparisonWorldSearch + : (state.comparison.source === "domestic" + ? elements.comparisonDomesticSearch + : elements.comparisonFixedTargets.querySelector("button")); + const target = !elements.tradingHaltWorkflow.hidden + ? elements.tradingHaltSearch + : (!elements.expertWorkflow.hidden + ? elements.expertSearch + : (!elements.overseasWorkflow.hidden + ? elements.overseasSearch + : (!elements.themeWorkflow.hidden + ? elements.themeSearch + : (!elements.comparisonWorkflow.hidden + ? (comparisonTarget || elements.catalogSearch) + : (!elements.industryWorkflow.hidden && !state.industryWorkflowCollapsed + ? elements.industrySearch + : (!elements.stockWorkflow.hidden && !state.stockWorkflowCollapsed + ? elements.stockSearchInput + : (!elements.legacyFixedWorkflow.hidden ? elements.legacyFixedSearch : elements.catalogSearch))))))); + target.focus(); + return; } if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return; - if (event.key === "F2") { event.preventDefault(); requestPlayoutCommand("prepare"); } - if (event.key === "F8") { event.preventDefault(); requestPlayoutCommand("take-in"); } - if (event.key === "Escape") { event.preventDefault(); requestPlayoutCommand("take-out"); } + if (event.key === "Home") { + event.preventDefault(); + selectPlaylistBoundary(0, event.shiftKey); + return; + } + if (event.key === "End") { + event.preventDefault(); + selectPlaylistBoundary(state.playlist.length - 1, event.shiftKey); + return; + } + if (event.key === "Delete") { event.preventDefault(); deleteSelected(); } + if (event.key === " ") { event.preventDefault(); toggleCurrentEntryEnabled(); } }); + window.addEventListener("pagehide", () => operatorCatalogUi?.loseCorrelation()); + } + + function initializeOperatorUiControllers() { + const shared = { + postNative: (type, payload) => postNative(type, payload), + appendPlaylistEntry: appendTrustedOperatorEntry, + isLocked: operatorUiLocked, + showToast, + addLog, + createId + }; + manualListsUi = manualListsUiModule.createController(shared); + manualFinancialUi = manualFinancialUiModule.createController({ + ...shared, + getSelectedStock: selectedStockForManualFinancial + }); + operatorCatalogUi = operatorCatalogUiModule.createController({ + postNative: shared.postNative, + isLocked: operatorUiLocked, + showToast, + addLog, + createId + }); + manualListsUi.mount(document.body); + manualFinancialUi.mount(document.body); + operatorCatalogUi.mount(document.body); } function initialize() { + initializeOperatorUiControllers(); bindEvents(); + loadComparisonPairList(); renderCatalog(); + renderStockWorkflow(); + renderFixedWorkflow(); + renderIndustryWorkflow(); + renderComparisonWorkflow(); + renderThemeWorkflow(); + renderOverseasWorkflow(); + renderExpertWorkflow(); + renderTradingHaltWorkflow(); + renderBackgroundControls(); renderPlaylist(); + renderNamedPlaylist(); updatePreview(); renderDatabaseStatus(); renderLiveData(); @@ -1829,6 +6914,7 @@ postNative("request-app-info"); requestDatabaseStatus(); requestPlayoutStatus(); + requestBackgroundStatus(); } else { elements.bridgeState.textContent = "브라우저 미리보기"; state.database.loading = false; diff --git a/Web/candle-options-workflow.js b/Web/candle-options-workflow.js new file mode 100644 index 0000000..6b25562 --- /dev/null +++ b/Web/candle-options-workflow.js @@ -0,0 +1,146 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnCandleOptionsWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const CANDLE_BUILDER_KEY = "s8010"; + const supportedSources = Object.freeze([ + "legacy-stock-workflow", + "legacy-fixed-workflow", + "legacy-industry-workflow", + "legacy-overseas-workflow", + "legacy-trading-halt-workflow" + ]); + + function normalizeOptions(ma5, ma20) { + if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") { + throw new TypeError("Global moving-average flags must be boolean values."); + } + return Object.freeze({ ma5, ma20 }); + } + + function expectedGraphicType(ma5, ma20) { + const options = normalizeOptions(ma5, ma20); + const flags = []; + if (options.ma5) flags.push("MA5"); + if (options.ma20) flags.push("MA20"); + return flags.join(","); + } + + function hasAdapters(value) { + return value && typeof value === "object" && !Array.isArray(value) && + value.stock && value.fixed && value.industry && value.overseas && value.tradingHalt; + } + + function recreateCandleEntry(value, ma5, ma20, adapters, now = new Date()) { + const movingAverages = normalizeOptions(ma5, ma20); + if (!value || typeof value !== "object" || Array.isArray(value) || + value.builderKey !== CANDLE_BUILDER_KEY || typeof value.enabled !== "boolean" || + !hasAdapters(adapters)) return null; + const operator = value.operator; + if (!operator || typeof operator !== "object" || !supportedSources.includes(operator.source)) return null; + const options = { + id: value.id, + fadeDuration: value.fadeDuration, + ma5: movingAverages.ma5, + ma20: movingAverages.ma20 + }; + let recreated = null; + try { + switch (operator.source) { + case "legacy-stock-workflow": { + const cut = adapters.stock.getStockCut(operator.cutId); + if (cut?.kind !== "candle") return null; + recreated = adapters.stock.createStockPlaylistEntry(operator.stock, cut.id, options); + break; + } + case "legacy-fixed-workflow": { + const action = adapters.fixed.actions.find(item => item.id === operator.actionId); + if (action?.builderKey !== CANDLE_BUILDER_KEY || action.available !== true) return null; + recreated = adapters.fixed.createFixedPlaylistEntry(action.id, { ...options, now }); + break; + } + case "legacy-industry-workflow": { + const cut = adapters.industry.cuts.find(item => item.id === operator.cutId); + if (cut?.kind !== "candle") return null; + recreated = adapters.industry.createIndustryPlaylistEntry(operator.market, cut.id, { + ...options, + selected: operator.selected, + pair: operator.pair, + now + }); + break; + } + case "legacy-overseas-workflow": { + const action = adapters.overseas.stockActions.find(item => item.id === operator.actionId); + if (operator.kind !== "stock" || action?.kind !== "candle") return null; + recreated = adapters.overseas.createOverseasStockPlaylistEntry( + operator.identity, + action.id, + options); + break; + } + case "legacy-trading-halt-workflow": { + const action = adapters.tradingHalt.getAction(operator.actionId); + if (action?.kind !== "candle") return null; + recreated = adapters.tradingHalt.createTradingHaltPlaylistEntry( + operator.tradingHalt, + action.id, + options); + break; + } + default: + return null; + } + } catch { + return null; + } + + return recreated ? { ...recreated, enabled: value.enabled } : null; + } + + function entryAlreadyMatches(value, ma5, ma20) { + const graphicType = expectedGraphicType(ma5, ma20); + return value?.selection?.graphicType === graphicType && + value?.graphicType === graphicType && + value?.operator?.movingAverages?.ma5 === ma5 && + value?.operator?.movingAverages?.ma20 === ma20; + } + + function synchronizePlaylist(values, ma5, ma20, adapters, now = new Date()) { + normalizeOptions(ma5, ma20); + if (!Array.isArray(values)) throw new TypeError("A playlist array is required."); + const invalidIds = []; + let changed = false; + const playlist = values.map(value => { + if (value?.builderKey !== CANDLE_BUILDER_KEY) return value; + if (entryAlreadyMatches(value, ma5, ma20)) return value; + const recreated = recreateCandleEntry(value, ma5, ma20, adapters, now); + if (!recreated) { + invalidIds.push(typeof value?.id === "string" ? value.id : ""); + return value; + } + changed = true; + return recreated; + }); + return Object.freeze({ + playlist: Object.freeze(playlist), + changed, + valid: invalidIds.length === 0, + invalidIds: Object.freeze(invalidIds) + }); + } + + return { + candleBuilderKey: CANDLE_BUILDER_KEY, + supportedSources, + normalizeOptions, + expectedGraphicType, + recreateCandleEntry, + synchronizePlaylist + }; +}); diff --git a/Web/comparison-workflow.js b/Web/comparison-workflow.js new file mode 100644 index 0000000..aa0eef8 --- /dev/null +++ b/Web/comparison-workflow.js @@ -0,0 +1,496 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnComparisonWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const SCHEMA_VERSION = 1; + const PAIR_LIST_SCHEMA_VERSION = 1; + const MAX_SAVED_PAIRS = 500; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const stockCodePattern = /^[A-Za-z0-9._-]{1,32}$/; + const worldSymbolPattern = /^[A-Za-z0-9@._:-]{1,64}$/; + const controlCharacterPattern = /[\u0000-\u001f\u007f]/; + + const runtimeAsset = Object.freeze({ + relativePath: "bin/Debug/Res/종목비교.ini", + sha256: "980F74C000EF6A34B85FDAAF6D2756269483637186C91A93548F35C5CFC1B7E4" + }); + + const marketTargetRows = [ + ["kospi", "코스피 지수", "Kospi"], + ["kosdaq", "코스닥 지수", "Kosdaq"], + ["kospi200", "코스피200 지수", "Kospi200"], + ["futures", "선물 지수", "Futures"], + ["krx100", "KRX100지수", "Krx100"], + ["won-dollar", "환율-원달러", "WonDollar"], + ["won-yen", "환율-원엔", "WonYen"], + ["won-yuan", "환율-원위엔", "WonYuan"], + ["won-euro", "환율-원유로", "WonEuro"], + ["dow", "해외지수-다우", "Dow"], + ["nasdaq", "해외지수-나스닥", "Nasdaq"], + ["sp500", "해외지수-S&P", "Sp500"], + ["germany-dax", "해외지수-독일", "GermanyDax"], + ["united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"], + ["france-cac", "해외지수-프랑스", "FranceCac"], + ["nikkei", "해외지수-일본", "Nikkei"], + ["hang-seng", "해외지수-홍콩", "HangSeng"], + ["taiwan-weighted", "해외지수-대만", "TaiwanWeighted"], + ["singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"], + ["thailand-set", "해외지수-태국", "ThailandSet"], + ["philippines-composite", "해외지수-필리핀", "PhilippinesComposite"], + ["malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"], + ["indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"], + ["shanghai-composite", "해외지수-중국", "ShanghaiComposite"] + ]; + + const marketTargets = Object.freeze(marketTargetRows.map(([id, displayName, target]) => + Object.freeze({ kind: "market-target", id, displayName, target }))); + if (marketTargets.length !== 24 || new Set(marketTargets.map(value => value.target)).size !== 24) { + throw new Error("The legacy comparison selector must expose exactly 24 market targets."); + } + const marketTargetsByTarget = new Map(marketTargets.map(value => [value.target, value])); + const marketTargetsByLookup = new Map(); + for (const target of marketTargets) { + marketTargetsByLookup.set(target.id.toLocaleLowerCase("en-US"), target); + marketTargetsByLookup.set(target.target.toLocaleLowerCase("en-US"), target); + marketTargetsByLookup.set(target.displayName.toLocaleLowerCase("ko-KR"), target); + } + + function action(id, label, kind, details = {}) { + return Object.freeze({ id, label, kind, ...details }); + } + + const actions = Object.freeze([ + action("two-column-expected", "2열판 예상체결", "two-column", { mode: "EXPECTED" }), + action("two-column-current", "2열판", "two-column", { mode: "CURRENT" }), + action("comparison-candle", "종목별 비교분석_캔들 그래프", "comparison", { + builderKey: "s5026", code: "5026", graphicType: "COMPARISON_CANDLE", period: "FiveDays" + }), + action("comparison-line", "종목별 비교분석_라인 그래프", "comparison", { + builderKey: "s5087", code: "5087", graphicType: "COMPARISON_LINE", period: "FiveDays" + }), + action("return-5d", "5일", "return", { period: "FiveDays" }), + action("return-1m", "1개월", "return", { period: "OneMonth" }), + action("return-3m", "3개월", "return", { period: "ThreeMonths" }), + action("return-6m", "6개월", "return", { period: "SixMonths" }), + action("return-12m", "12개월", "return", { period: "TwelveMonths" }) + ]); + if (actions.length !== 9 || new Set(actions.map(value => value.id)).size !== 9) { + throw new Error("The legacy comparison tree must expose exactly nine actions."); + } + const actionsById = new Map(actions.map(value => [value.id, value])); + const yieldActionIds = Object.freeze(actions + .filter(value => value.kind === "return") + .map(value => value.id)); + const expectedMarketTargets = new Set(["Kospi", "Kosdaq", "Kospi200", "Krx100"]); + + function cleanText(value, maximumLength = 128) { + if (typeof value !== "string") return null; + const text = value.trim(); + return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null; + } + + function getMarketTarget(value) { + if (value && typeof value === "object" && !Array.isArray(value)) value = value.target ?? value.id ?? value.displayName; + const text = cleanText(value); + return text ? marketTargetsByLookup.get(text.toLocaleLowerCase("ko-KR")) ?? null : null; + } + + function normalizeMarket(value) { + const text = String(value || "").trim().toLocaleLowerCase("en-US"); + return text === "kospi" || text === "kosdaq" ? text : null; + } + + function normalizeTarget(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + switch (value.kind) { + case "market-target": { + const target = marketTargetsByTarget.get(value.target); + if (!target) return null; + if ((value.id !== undefined && value.id !== target.id) || + (value.displayName !== undefined && value.displayName !== target.displayName)) return null; + return target; + } + case "krx-stock": + case "nxt-stock": { + const market = normalizeMarket(value.market); + const stockName = cleanText(value.stockName ?? value.name); + const stockCode = cleanText(value.stockCode ?? value.code, 32); + if (!market || !stockName || stockName.includes(",") || stockName.includes("(NXT)") || + !stockCode || !stockCodePattern.test(stockCode)) return null; + const displayName = value.kind === "nxt-stock" ? `${stockName}(NXT)` : stockName; + if (value.displayName !== undefined && value.displayName !== displayName) return null; + return Object.freeze({ kind: value.kind, market, stockName, stockCode, displayName }); + } + case "world-stock": { + const inputName = cleanText(value.inputName); + const displayName = cleanText(value.displayName ?? value.name); + const symbol = cleanText(value.symbol, 64); + const nation = cleanText(value.nation, 2)?.toLocaleUpperCase("en-US"); + if (!inputName || inputName.includes(",") || !displayName || !symbol || + !worldSymbolPattern.test(symbol) || (nation !== "US" && nation !== "TW")) return null; + return Object.freeze({ kind: "world-stock", inputName, displayName, symbol, nation }); + } + default: + return null; + } + } + + function targetKey(value) { + const target = normalizeTarget(value); + if (!target) return null; + switch (target.kind) { + case "market-target": return `market:${target.target}`; + case "krx-stock": return `krx:${target.market}:${target.stockCode}`; + case "nxt-stock": return `nxt:${target.market}:${target.stockCode}`; + case "world-stock": return `world:${target.nation}:${target.symbol}`; + default: return null; + } + } + + function subjectTokenForTarget(value) { + const target = normalizeTarget(value); + if (!target) return null; + switch (target.kind) { + case "market-target": return target.target; + case "krx-stock": return target.stockName; + case "nxt-stock": return `${target.stockName}(NXT)`; + case "world-stock": return target.inputName; + default: return null; + } + } + + function pairParts(value) { + if (Array.isArray(value)) return [value[0], value[1]]; + if (value && typeof value === "object") return [value.first ?? value.pair?.[0], value.second ?? value.pair?.[1]]; + return [null, null]; + } + + function normalizePair(value) { + const [firstValue, secondValue] = pairParts(value); + const first = normalizeTarget(firstValue); + const second = normalizeTarget(secondValue); + return first && second ? Object.freeze([first, second]) : null; + } + + function normalizePairState(value) { + const [firstValue, secondValue] = pairParts(value); + const first = firstValue == null ? null : normalizeTarget(firstValue); + const second = secondValue == null ? null : normalizeTarget(secondValue); + return (firstValue == null || first) && (secondValue == null || second) + ? Object.freeze([first, second]) + : null; + } + + function rotatePair(value, selectedValue) { + const pair = normalizePairState(value); + const selected = normalizeTarget(selectedValue); + return pair && selected ? Object.freeze([selected, pair[0]]) : null; + } + + function swapPair(value) { + const pair = normalizePairState(value); + return pair ? Object.freeze([pair[1], pair[0]]) : null; + } + + function clearPair() { + return Object.freeze([null, null]); + } + + function pairLabel(value) { + const pair = normalizePair(value); + return pair ? `${pair[0].displayName} ↔ ${pair[1].displayName}` : ""; + } + + function unsupported(actionId, reason) { + return Object.freeze({ actionId, supported: false, reason, effectiveMode: null, warning: null }); + } + + function supported(actionId, effectiveMode = null, warning = null) { + return Object.freeze({ actionId, supported: true, reason: null, effectiveMode, warning }); + } + + function getCompatibility(actionId, pairValue) { + const selectedAction = actionsById.get(actionId); + if (!selectedAction) return unsupported(actionId, "unknown-action"); + const pair = normalizePair(pairValue); + if (!pair) return unsupported(actionId, "pair-incomplete-or-invalid"); + const futuresCount = pair.filter(value => value.kind === "market-target" && value.target === "Futures").length; + const hasWorld = pair.some(value => value.kind === "world-stock"); + + if (selectedAction.kind === "comparison" || selectedAction.kind === "return") { + return pair.every(value => value.kind === "krx-stock") + ? supported(actionId, "GRAPH") + : unsupported(actionId, "comparison-graphs-require-two-krx-stocks"); + } + + if (futuresCount > 1) return unsupported(actionId, "two-futures-targets-are-unsupported"); + if (futuresCount === 1) { + const counterpart = pair.find(value => !(value.kind === "market-target" && value.target === "Futures")); + if (!counterpart || counterpart.kind !== "market-target") { + return unsupported(actionId, "futures-requires-a-market-target-counterpart"); + } + return selectedAction.mode === "EXPECTED" + ? supported(actionId, "CURRENT", "The current Core routes a futures expected action through the current s5032 branch.") + : supported(actionId, "CURRENT"); + } + + if (selectedAction.mode === "EXPECTED") { + const allowed = pair.every(value => value.kind === "krx-stock" || + (value.kind === "market-target" && expectedMarketTargets.has(value.target))); + return allowed + ? supported(actionId, "EXPECTED") + : unsupported(actionId, "expected-mode-supports-only-krx-stocks-and-four-domestic-indices"); + } + + const hasMarketTarget = pair.some(value => value.kind === "market-target"); + if (hasMarketTarget && hasWorld) { + return unsupported(actionId, "mixed-market-and-world-is-unsupported"); + } + return supported(actionId, "CURRENT"); + } + + function isActionAllowed(actionId, pairValue) { + return getCompatibility(actionId, pairValue).supported; + } + + function buildComparisonSelection(actionId, pairValue) { + const selectedAction = actionsById.get(actionId); + const pair = normalizePair(pairValue); + const compatibility = getCompatibility(actionId, pair); + if (!selectedAction || !pair || !compatibility.supported) { + throw new RangeError(`The comparison action is unavailable: ${compatibility.reason || "invalid-selection"}.`); + } + const subject = pair.map(subjectTokenForTarget).join(","); + if (selectedAction.kind === "two-column") { + const futures = pair.some(value => value.kind === "market-target" && value.target === "Futures"); + const code = futures ? "5032" : "8032"; + return Object.freeze({ + builderKey: futures ? "s5032" : "s8018", + code, + aliases: Object.freeze([code]), + selection: Object.freeze({ + groupCode: pair.some(value => value.kind === "market-target") ? "INDEX" : "STOCK", + subject, + graphicType: "2열판", + subtype: selectedAction.mode, + dataCode: "" + }), + effectiveMode: compatibility.effectiveMode, + warning: compatibility.warning + }); + } + const builderKey = selectedAction.kind === "return" ? "s5029" : selectedAction.builderKey; + const code = selectedAction.kind === "return" ? "5029" : selectedAction.code; + const graphicType = selectedAction.kind === "return" ? "RETURN_COMPARISON" : selectedAction.graphicType; + return Object.freeze({ + builderKey, + code, + aliases: Object.freeze([code]), + selection: Object.freeze({ + groupCode: "STOCK", + subject, + graphicType, + subtype: selectedAction.period, + dataCode: "" + }), + effectiveMode: compatibility.effectiveMode, + warning: compatibility.warning + }); + } + + function requireOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Comparison playlist options are required."); + } + const id = cleanText(options.id); + if (!id || !identifierPattern.test(id)) throw new TypeError("A safe comparison playlist id is required."); + const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration); + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + return { id, fadeDuration }; + } + + function createComparisonPlaylistEntry(actionId, pairValue, options) { + const selectedAction = actionsById.get(actionId); + if (!selectedAction) throw new RangeError("The comparison action is unknown."); + const pair = normalizePair(pairValue); + if (!pair) throw new RangeError("Select two comparison targets first."); + const normalized = requireOptions(options); + const mapping = buildComparisonSelection(actionId, pair); + const entry = { + id: normalized.id, + builderKey: mapping.builderKey, + code: mapping.code, + aliases: mapping.aliases, + title: pairLabel(pair), + detail: selectedAction.label, + category: selectedAction.kind === "two-column" ? "plate" : "chart", + market: "compare", + selection: mapping.selection, + enabled: true, + fadeDuration: normalized.fadeDuration, + graphicType: mapping.selection.graphicType, + subtype: mapping.selection.subtype, + effectiveMode: mapping.effectiveMode, + warning: mapping.warning, + operator: Object.freeze({ + source: "legacy-comparison-workflow", + schemaVersion: SCHEMA_VERSION, + actionId, + pair, + effectiveMode: mapping.effectiveMode, + assetPath: runtimeAsset.relativePath, + assetSha256: runtimeAsset.sha256 + }) + }; + return entry; + } + + function createYieldBatchEntries(pairValue, options = {}) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Yield batch options must be an object."); + } + const idPrefix = cleanText(options.idPrefix ?? "comparison-return", 96); + if (!idPrefix || !identifierPattern.test(idPrefix)) throw new TypeError("A safe yield batch id prefix is required."); + return Object.freeze(yieldActionIds.map(actionId => createComparisonPlaylistEntry(actionId, pairValue, { + id: `${idPrefix}-${actionId.replace("return-", "")}`, + fadeDuration: options.fadeDuration + }))); + } + + function sameSelection(left, right) { + if (!left || typeof left !== "object" || !right || typeof right !== "object") return false; + return ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => typeof left[key] === "string" && left[key] === right[key]); + } + + function restoreComparisonPlaylistEntry(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null; + const operator = value.operator; + if (!operator || typeof operator !== "object" || operator.source !== "legacy-comparison-workflow" || + operator.schemaVersion !== SCHEMA_VERSION || operator.assetPath !== runtimeAsset.relativePath || + operator.assetSha256 !== runtimeAsset.sha256 || !actionsById.has(operator.actionId)) return null; + let recreated; + try { + recreated = createComparisonPlaylistEntry(operator.actionId, operator.pair, { + id: value.id, + fadeDuration: value.fadeDuration + }); + } catch { + return null; + } + const scalars = [ + "id", "builderKey", "code", "title", "detail", "category", "market", + "fadeDuration", "graphicType", "subtype", "effectiveMode", "warning" + ]; + if (!scalars.every(key => value[key] === recreated[key]) || + !Array.isArray(value.aliases) || value.aliases.length !== recreated.aliases.length || + !value.aliases.every((alias, index) => alias === recreated.aliases[index]) || + !sameSelection(value.selection, recreated.selection) || + operator.effectiveMode !== recreated.operator.effectiveMode) return null; + return { ...recreated, enabled: value.enabled }; + } + + function createPairRecord(idValue, pairValue) { + const id = cleanText(idValue); + const pair = normalizePair(pairValue); + if (!id || !identifierPattern.test(id) || !pair) return null; + return Object.freeze({ id, pair }); + } + + function emptyPairList() { + return Object.freeze({ schemaVersion: PAIR_LIST_SCHEMA_VERSION, pairs: Object.freeze([]) }); + } + + function freezePairList(records) { + return Object.freeze({ schemaVersion: PAIR_LIST_SCHEMA_VERSION, pairs: Object.freeze(records) }); + } + + function normalizePairList(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || + value.schemaVersion !== PAIR_LIST_SCHEMA_VERSION || !Array.isArray(value.pairs)) return emptyPairList(); + const records = []; + const ids = new Set(); + for (const raw of value.pairs) { + if (records.length >= MAX_SAVED_PAIRS) break; + const record = createPairRecord(raw?.id, raw?.pair ?? raw); + if (!record || ids.has(record.id)) continue; + ids.add(record.id); + records.push(record); + } + return freezePairList(records); + } + + function appendPair(value, recordOrId, pairValue) { + const list = normalizePairList(value); + const record = typeof recordOrId === "string" + ? createPairRecord(recordOrId, pairValue) + : createPairRecord(recordOrId?.id, recordOrId?.pair ?? recordOrId); + if (!record) throw new TypeError("A valid saved comparison pair is required."); + if (list.pairs.length >= MAX_SAVED_PAIRS) throw new RangeError("The saved comparison pair limit was reached."); + if (list.pairs.some(value => value.id === record.id)) throw new RangeError("The saved comparison pair id already exists."); + return freezePairList([...list.pairs, record]); + } + + function reorderPairList(value, idValue, directionValue) { + const list = normalizePairList(value); + const id = cleanText(idValue); + const direction = directionValue === "up" ? -1 : directionValue === "down" ? 1 : Number(directionValue); + if (!id || (direction !== -1 && direction !== 1)) return list; + const from = list.pairs.findIndex(value => value.id === id); + const to = from + direction; + if (from < 0 || to < 0 || to >= list.pairs.length) return list; + const records = [...list.pairs]; + [records[from], records[to]] = [records[to], records[from]]; + return freezePairList(records); + } + + function deletePair(value, idValue) { + const list = normalizePairList(value); + const id = cleanText(idValue); + return id ? freezePairList(list.pairs.filter(value => value.id !== id)) : list; + } + + function deleteAllPairs() { + return emptyPairList(); + } + + return { + runtimeAsset, + marketTargets, + marketQuoteTargets: marketTargets, + actions, + comparisonActions: actions, + yieldActionIds, + pairListSchemaVersion: PAIR_LIST_SCHEMA_VERSION, + normalizeTarget, + getMarketTarget, + targetKey, + subjectTokenForTarget, + normalizePair, + rotatePair, + swapPair, + clearPair, + pairLabel, + getCompatibility, + isActionAllowed, + buildComparisonSelection, + createComparisonPlaylistEntry, + createYieldBatchEntries, + restoreComparisonPlaylistEntry, + refreshComparisonPlaylistEntry: restoreComparisonPlaylistEntry, + createPairRecord, + normalizePairList, + appendPair, + reorderPairList, + deletePair, + deleteAllPairs + }; +}); diff --git a/Web/expert-workflow.js b/Web/expert-workflow.js new file mode 100644 index 0000000..987afc6 --- /dev/null +++ b/Web/expert-workflow.js @@ -0,0 +1,502 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnExpertWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const DEFAULT_MAXIMUM_RESULTS = 100; + const MAXIMUM_RESULTS = 500; + const MAXIMUM_QUERY_LENGTH = 64; + const DEFAULT_MAXIMUM_PREVIEW_ITEMS = 100; + const MAXIMUM_PREVIEW_ITEMS = 100; + const MAXIMUM_PAGE_COUNT = 20; + const PAGE_SIZE = 5; + const SCHEMA_VERSION = 1; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const expertCodePattern = /^[0-9]{4}$/; + const stockCodePattern = /^[A-Za-z0-9]{1,32}$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + + const bridgeContract = Object.freeze({ + searchRequestType: "search-experts", + searchResultType: "expert-search-results", + searchErrorType: "expert-search-error", + previewRequestType: "request-expert-preview", + previewResultType: "expert-preview-results", + previewErrorType: "expert-preview-error", + defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS, + maximumResults: MAXIMUM_RESULTS, + maximumQueryLength: MAXIMUM_QUERY_LENGTH, + defaultMaximumPreviewItems: DEFAULT_MAXIMUM_PREVIEW_ITEMS, + maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS + }); + + const sourceContract = Object.freeze({ + originalControl: "Control/UC6.cs", + provider: "oracle", + expertTable: "EXPERT_LIST", + recommendationTable: "RECOMMEND_LIST", + originalGroupLabel: "전문가 추천", + originalCutLabel: "5단 표그래프", + originalValueLabel: "현재가", + builderKey: "s5074", + cutCode: "5074", + resolverGroup: "PAGED_EXPERT", + pageSize: PAGE_SIZE, + maximumPageCount: MAXIMUM_PAGE_COUNT + }); + + const actions = Object.freeze([ + Object.freeze({ + id: "five-row-current", + label: "전문가 추천 · 5단 표그래프 · 현재가", + builderKey: "s5074", + code: "5074", + aliases: Object.freeze(["5074"]), + pageSize: PAGE_SIZE + }) + ]); + const action = actions[0]; + const trustedPreviewResponses = new WeakSet(); + + function hasExactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && + actual.every((key, index) => key === expected[index]); + } + + function hasOnlyKeys(value, keys) { + return value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).every(key => keys.includes(key)); + } + + function isSafeCanonicalText(value, maximumLength) { + return typeof value === "string" && + value.length > 0 && value.length <= maximumLength && + value === value.trim() && !unsafeTextPattern.test(value); + } + + function isSafeRequestId(value) { + return isSafeCanonicalText(value, 128) && identifierPattern.test(value); + } + + function normalizeQuery(value) { + if (typeof value !== "string" || unsafeTextPattern.test(value)) return null; + const query = value.trim(); + return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) + ? query + : null; + } + + function normalizeExpertIdentity(value) { + if (!hasExactKeys(value, ["expertCode", "expertName"]) || + !expertCodePattern.test(value.expertCode) || + !isSafeCanonicalText(value.expertName, 128)) return null; + return Object.freeze({ + expertCode: value.expertCode, + expertName: value.expertName + }); + } + + function normalizeExpert(value) { + if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) || + value.source !== "oracle") return null; + const identity = normalizeExpertIdentity({ + expertCode: value.expertCode, + expertName: value.expertName + }); + return identity && Object.freeze({ ...identity, source: "oracle" }); + } + + function createExpertSearchRequest(requestId, query = "", maximumResults) { + if (!isSafeRequestId(requestId)) { + throw new TypeError("A safe expert-search request id is required."); + } + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === null) { + throw new TypeError(`An expert query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`); + } + if (maximumResults !== undefined && + (!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) { + throw new RangeError(`Expert results must be limited from 1 through ${MAXIMUM_RESULTS}.`); + } + const request = { requestId, query: normalizedQuery }; + if (maximumResults !== undefined) request.maximumResults = maximumResults; + return Object.freeze(request); + } + + function compareExperts(left, right) { + if (left.expertName < right.expertName) return -1; + if (left.expertName > right.expertName) return 1; + if (left.expertCode < right.expertCode) return -1; + if (left.expertCode > right.expertCode) return 1; + return 0; + } + + function normalizeExpertSearchResponse(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results" + ]) || !isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query || + !isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" || + !Array.isArray(value.results) || value.results.length !== value.totalRowCount) return null; + + let expected = null; + if (expectedRequest !== undefined) { + try { + expected = createExpertSearchRequest( + expectedRequest?.requestId, + expectedRequest?.query, + expectedRequest?.maximumResults); + } catch { + return null; + } + const bound = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS; + if (value.requestId !== expected.requestId || value.query !== expected.query || + value.results.length > bound || (value.truncated && value.results.length !== bound)) return null; + } + + const results = []; + const codes = new Set(); + const names = new Set(); + for (const raw of value.results) { + const expert = normalizeExpert(raw); + if (!expert || codes.has(expert.expertCode) || names.has(expert.expertName) || + (results.length && compareExperts(results[results.length - 1], expert) > 0)) return null; + codes.add(expert.expertCode); + names.add(expert.expertName); + results.push(expert); + } + return Object.freeze({ + requestId: value.requestId, + query: value.query, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + results: Object.freeze(results) + }); + } + + function normalizeExpertSearchError(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "query", "message"]) || + !isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query || + !isSafeCanonicalText(value.message, 512)) return null; + if (expectedRequest) { + let expected; + try { + expected = createExpertSearchRequest( + expectedRequest.requestId, + expectedRequest.query, + expectedRequest.maximumResults); + } catch { + return null; + } + if (value.requestId !== expected.requestId || value.query !== expected.query) return null; + } + return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message }); + } + + function createExpertPreviewRequest(requestId, expertValue, maximumItems) { + if (!isSafeRequestId(requestId)) { + throw new TypeError("A safe expert-preview request id is required."); + } + const expert = normalizeExpert(expertValue); + if (!expert) throw new TypeError("A typed Oracle expert is required."); + if (maximumItems !== undefined && + (!Number.isInteger(maximumItems) || maximumItems < 1 || maximumItems > MAXIMUM_PREVIEW_ITEMS)) { + throw new RangeError(`Expert preview items must be limited from 1 through ${MAXIMUM_PREVIEW_ITEMS}.`); + } + const request = { + requestId, + expertCode: expert.expertCode, + expertName: expert.expertName + }; + if (maximumItems !== undefined) request.maximumItems = maximumItems; + return Object.freeze(request); + } + + function normalizeRecommendation(value) { + if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) || + !Number.isInteger(value.playIndex) || value.playIndex < 0 || value.playIndex > 9999 || + typeof value.stockCode !== "string" || !stockCodePattern.test(value.stockCode) || + !isSafeCanonicalText(value.stockName, 200) || + !Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0) return null; + return Object.freeze({ + playIndex: value.playIndex, + stockCode: value.stockCode, + stockName: value.stockName, + buyAmount: value.buyAmount + }); + } + + function normalizeExpertPreviewResponse(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items" + ]) || !isSafeRequestId(value.requestId) || + !isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + value.totalRowCount > MAXIMUM_PREVIEW_ITEMS || typeof value.truncated !== "boolean" || + !Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null; + const selection = normalizeExpertIdentity(value.selection); + if (!selection) return null; + + if (expectedRequest !== undefined) { + let expected; + try { + expected = createExpertPreviewRequest( + expectedRequest?.requestId, + { + expertCode: expectedRequest?.expertCode, + expertName: expectedRequest?.expertName, + source: "oracle" + }, + expectedRequest?.maximumItems); + } catch { + return null; + } + const bound = expected.maximumItems ?? DEFAULT_MAXIMUM_PREVIEW_ITEMS; + if (value.requestId !== expected.requestId || + selection.expertCode !== expected.expertCode || + selection.expertName !== expected.expertName || + value.items.length > bound || (value.truncated && value.items.length !== bound)) return null; + } + + const items = []; + const indexes = new Set(); + const codes = new Set(); + const names = new Set(); + for (const raw of value.items) { + const item = normalizeRecommendation(raw); + if (!item || indexes.has(item.playIndex) || codes.has(item.stockCode) || names.has(item.stockName) || + (items.length && items[items.length - 1].playIndex >= item.playIndex)) return null; + indexes.add(item.playIndex); + codes.add(item.stockCode); + names.add(item.stockName); + items.push(item); + } + const result = Object.freeze({ + requestId: value.requestId, + selection, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + items: Object.freeze(items) + }); + trustedPreviewResponses.add(result); + return result; + } + + function normalizeExpertPreviewError(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "selection", "message"]) || + !isSafeRequestId(value.requestId) || !isSafeCanonicalText(value.message, 512)) return null; + const selection = normalizeExpertIdentity(value.selection); + if (!selection) return null; + if (expectedRequest) { + let expected; + try { + expected = createExpertPreviewRequest( + expectedRequest.requestId, + { + expertCode: expectedRequest.expertCode, + expertName: expectedRequest.expertName, + source: "oracle" + }, + expectedRequest.maximumItems); + } catch { + return null; + } + if (value.requestId !== expected.requestId || + selection.expertCode !== expected.expertCode || + selection.expertName !== expected.expertName) return null; + } + return Object.freeze({ requestId: value.requestId, selection, message: value.message }); + } + + function createSelectableExpert(expertValue, previewValue) { + const expert = normalizeExpert(expertValue); + if (!expert || !previewValue || typeof previewValue !== "object" || + !trustedPreviewResponses.has(previewValue) || + !normalizeExpertIdentity(previewValue.selection) || + previewValue.selection.expertCode !== expert.expertCode || + previewValue.selection.expertName !== expert.expertName || + !Number.isInteger(previewValue.totalRowCount) || previewValue.totalRowCount < 0 || + previewValue.totalRowCount > MAXIMUM_PREVIEW_ITEMS || + typeof previewValue.truncated !== "boolean") return null; + return Object.freeze({ + ...expert, + itemCount: previewValue.totalRowCount, + previewTruncated: previewValue.truncated + }); + } + + function normalizeSelectableExpert(value) { + if (!hasExactKeys(value, [ + "expertCode", "expertName", "source", "itemCount", "previewTruncated" + ]) || !Number.isInteger(value.itemCount) || value.itemCount < 0 || + value.itemCount > MAXIMUM_PREVIEW_ITEMS || typeof value.previewTruncated !== "boolean") return null; + const expert = normalizeExpert({ + expertCode: value.expertCode, + expertName: value.expertName, + source: value.source + }); + return expert && Object.freeze({ + ...expert, + itemCount: value.itemCount, + previewTruncated: value.previewTruncated + }); + } + + function calculatePagePreview(expertValue) { + const expert = normalizeSelectableExpert(expertValue); + if (!expert) return null; + return Object.freeze({ + itemCount: expert.itemCount, + accessibleItemCount: expert.itemCount, + pageSize: PAGE_SIZE, + pageCount: Math.ceil(expert.itemCount / PAGE_SIZE), + maximumPageCount: MAXIMUM_PAGE_COUNT, + isTruncated: expert.previewTruncated + }); + } + + function buildExpertSelection(expertValue) { + const expert = normalizeSelectableExpert(expertValue); + if (!expert) throw new TypeError("A previewed expert selection is required."); + return Object.freeze({ + builderKey: action.builderKey, + code: action.code, + aliases: action.aliases, + selection: Object.freeze({ + groupCode: "PAGED_EXPERT", + subject: expert.expertName, + graphicType: "INPUT_ORDER", + subtype: "CURRENT", + dataCode: expert.expertCode + }) + }); + } + + function requireOptions(options) { + if (!hasOnlyKeys(options, ["id", "fadeDuration"]) || !isSafeRequestId(options.id)) { + throw new TypeError("Safe expert playlist options are required."); + } + const fadeDuration = options.fadeDuration === undefined + ? DEFAULT_FADE_DURATION + : options.fadeDuration; + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + return Object.freeze({ id: options.id, fadeDuration }); + } + + function createExpertPlaylistEntry(expertValue, options) { + const expert = normalizeSelectableExpert(expertValue); + if (!expert) throw new TypeError("A previewed expert selection is required."); + if (expert.itemCount === 0) throw new RangeError("An expert with no recommendation rows cannot create a cut."); + const normalizedOptions = requireOptions(options); + const mapping = buildExpertSelection(expert); + const pagePreview = calculatePagePreview(expert); + return { + id: normalizedOptions.id, + builderKey: mapping.builderKey, + code: mapping.code, + aliases: [...mapping.aliases], + title: expert.expertName, + detail: action.label, + category: "expert", + source: expert.source, + expertCode: expert.expertCode, + expertName: expert.expertName, + itemCount: expert.itemCount, + previewTruncated: expert.previewTruncated, + pageSize: pagePreview.pageSize, + pageCount: pagePreview.pageCount, + selection: mapping.selection, + enabled: true, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: mapping.selection.graphicType, + subtype: mapping.selection.subtype, + operator: Object.freeze({ + source: "legacy-expert-workflow", + schemaVersion: SCHEMA_VERSION, + actionId: action.id, + expert, + pagePreview + }) + }; + } + + function sameStringArray(left, right) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && + left.every((value, index) => value === right[index]); + } + + function sameSelection(left, right) { + return left && right && ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => typeof left[key] === "string" && left[key] === right[key]); + } + + function samePagePreview(left, right) { + return left && right && [ + "itemCount", "accessibleItemCount", "pageSize", "pageCount", + "maximumPageCount", "isTruncated" + ].every(key => left[key] === right[key]); + } + + function restoreExpertPlaylistEntry(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || + typeof value.enabled !== "boolean") return null; + const operator = value.operator; + if (!hasExactKeys(operator, ["source", "schemaVersion", "actionId", "expert", "pagePreview"]) || + operator.source !== "legacy-expert-workflow" || operator.schemaVersion !== SCHEMA_VERSION || + operator.actionId !== action.id || !normalizeSelectableExpert(operator.expert)) return null; + + let recreated; + try { + recreated = createExpertPlaylistEntry(operator.expert, { + id: value.id, + fadeDuration: value.fadeDuration + }); + } catch { + return null; + } + const scalars = [ + "id", "builderKey", "code", "title", "detail", "category", "source", + "expertCode", "expertName", "itemCount", "previewTruncated", "pageSize", + "pageCount", "fadeDuration", "graphicType", "subtype" + ]; + if (!scalars.every(key => value[key] === recreated[key]) || + !sameStringArray(value.aliases, recreated.aliases) || + !sameSelection(value.selection, recreated.selection) || + !samePagePreview(operator.pagePreview, recreated.operator.pagePreview)) return null; + return { ...recreated, enabled: value.enabled }; + } + + return { + bridgeContract, + sourceContract, + actions, + normalizeExpertIdentity, + normalizeExpert, + createExpertSearchRequest, + normalizeExpertSearchResponse, + normalizeExpertSearchError, + createExpertPreviewRequest, + normalizeRecommendation, + normalizeExpertPreviewResponse, + normalizeExpertPreviewError, + createSelectableExpert, + calculatePagePreview, + buildExpertSelection, + createExpertPlaylistEntry, + restoreExpertPlaylistEntry, + refreshExpertPlaylistEntry: restoreExpertPlaylistEntry + }; +}); diff --git a/Web/index.html b/Web/index.html index 30e0993..e28edb8 100644 --- a/Web/index.html +++ b/Web/index.html @@ -6,6 +6,9 @@ MBN Stock WebView + + +
@@ -27,6 +30,7 @@ + @@ -61,12 +65,278 @@
-

CUT CATALOG

그래픽 항목

+

OPERATOR INPUT

종목·그래픽 선택

Web UI
+
+
+
+

LEGACY STOCK WORKFLOW

+

종목 검색

+
+
+ DB SEARCH + +
+
+
+ + + +
+
종목명을 입력하고 Enter를 누르세요.
+
+ +
+
종목용 컷더블클릭 또는 + 버튼으로 추가
+ + +
+
+
+
+ + + + + + +
@@ -96,17 +366,64 @@ 0 CUTS
- + + - - - + +
+ 캔들 + + +
+ + +
+
- +
#그래픽 컷구분정렬
송출#종목·대상세부사항Page정렬
@@ -137,6 +454,15 @@ 확인 중
+
+
+ BACKGROUND + 배경 없음 + 원본 F2/F3 배경 상태 확인 중 +
+ + +
송출 어댑터 상태를 확인하고 있습니다.
SCENE @@ -191,7 +517,7 @@
- + @@ -207,6 +533,23 @@
+ + + + + + + + + + + + + + + + + diff --git a/Web/industry-workflow.js b/Web/industry-workflow.js new file mode 100644 index 0000000..1ce8d28 --- /dev/null +++ b/Web/industry-workflow.js @@ -0,0 +1,326 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnIndustryWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const codePattern = /^[A-Za-z0-9]{1,32}$/; + const controlCharacterPattern = /[\u0000-\u001f\u007f]/; + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + const markets = Object.freeze(["kospi", "kosdaq"]); + const runtimeAssets = Object.freeze({ + kospi: Object.freeze({ + relativePath: "bin/Debug/Res/업종_코스피.ini", + sha256: "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52" + }), + kosdaq: Object.freeze({ + relativePath: "bin/Debug/Res/업종_코스닥.ini", + sha256: "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4" + }) + }); + + const periodCuts = [ + ["5일", "8061", "FiveDays"], ["20일", "8040", "TwentyDays"], + ["60일", "8046", "SixtyDays"], ["120일", "8051", "OneHundredTwentyDays"], + ["240일", "8056", "TwoHundredFortyDays"] + ]; + const candleCuts = [["일봉", "8035"], ...periodCuts.map(([label, code]) => [label, code])]; + + function cut(id, label, section, kind, details = {}) { + return Object.freeze({ id, label, section, kind, ...details }); + } + + const cuts = Object.freeze([ + cut("one-column", "1열판", "업종 판", "single", { builderKey: "s5001" }), + cut("two-column", "2열판", "업종 판", "pair", { builderKey: "s8018" }), + ...periodCuts.map(([label, , period]) => + cut(`yield-${label}`, `수익률그래프_${label}`, "수익률 그래프", "yield", { + builderKey: "s5086", period + })), + ...candleCuts.map(([label, code]) => + cut(`candle-${label}`, `캔들그래프_${label}`, "캔들 그래프", "candle", { + builderKey: "s8010", code, mode: "PRICE" + })), + ...candleCuts.map(([label, code]) => + cut(`candle-volume-${label}`, `캔들그래프(거래량)_${label}`, "캔들 그래프 · 거래량", "candle", { + builderKey: "s8010", code, mode: "VOLUME" + })), + cut("five-row", "5단 표그래프", "시장 전체", "fixed", { builderKey: "s5074" }), + cut("square", "네모그래프", "시장 전체", "fixed", { builderKey: "s8001" }), + cut("sector", "섹터지수", "시장 전체", "fixed", { builderKey: "s5078" }) + ]); + if (cuts.length !== 22 || new Set(cuts.map(value => value.id)).size !== 22) { + throw new Error("Each legacy industry tab must contain exactly 22 cut actions."); + } + const cutsById = new Map(cuts.map(value => [value.id, value])); + + function cleanText(value, maximumLength = 128) { + if (typeof value !== "string") return null; + const text = value.trim(); + return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null; + } + + function normalizeMarket(value) { + const text = String(value || "").trim().toLocaleLowerCase("en-US"); + return markets.includes(text) ? text : null; + } + + function normalizeIndustry(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const market = normalizeMarket(value.market); + const name = cleanText(value.name ?? value.industryName); + const code = cleanText(value.code ?? value.industryCode, 32); + if (!market || !name || !code || !codePattern.test(code)) return null; + return Object.freeze({ market, name, code }); + } + + function localDateText(now = new Date()) { + const year = String(now.getFullYear()).padStart(4, "0"); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function requireOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Industry playlist options are required."); + } + const id = cleanText(options.id); + if (!id || !identifierPattern.test(id)) throw new TypeError("A safe industry playlist id is required."); + const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration); + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date(); + const ma5 = options.ma5 === undefined ? false : options.ma5; + const ma20 = options.ma20 === undefined ? false : options.ma20; + if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") { + throw new TypeError("Moving-average flags must be boolean values."); + } + return { id, fadeDuration, now, ma5, ma20 }; + } + + function mappingFor(market, selectedCut, selected, primary, secondary, now, movingAverages) { + const industryGroup = market === "kospi" ? "INDUSTRY_KOSPI" : "INDUSTRY_KOSDAQ"; + const candleGroup = market === "kospi" ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY"; + const pagedGroup = market === "kospi" ? "PAGED_DOMESTIC_KOSPI" : "PAGED_DOMESTIC_KOSDAQ"; + const marketName = market === "kospi" ? "KOSPI" : "KOSDAQ"; + switch (selectedCut.kind) { + case "single": + if (!selected) throw new RangeError("Select one industry first."); + return { + code: "5001", + selection: { + groupCode: industryGroup, subject: selected.name, + graphicType: "1열판", subtype: "CURRENT", dataCode: "" + } + }; + case "pair": + if (!primary || !secondary || primary.name.includes("-") || secondary.name.includes("-")) { + throw new RangeError("Select two delimiter-safe industries first."); + } + return { + code: market === "kospi" ? "8018" : "8032", + selection: { + groupCode: industryGroup, subject: `${primary.name}-${secondary.name}`, + graphicType: "2열판", subtype: "", dataCode: "" + } + }; + case "yield": + if (!selected) throw new RangeError("Select one industry first."); + return { + code: "5086", + selection: { + groupCode: industryGroup, subject: selected.name, + graphicType: "YIELD_GRAPH", subtype: selectedCut.period, dataCode: "" + } + }; + case "candle": + if (!selected) throw new RangeError("Select one industry first."); + const flags = []; + if (movingAverages.ma5) flags.push("MA5"); + if (movingAverages.ma20) flags.push("MA20"); + return { + code: selectedCut.code, + selection: { + groupCode: candleGroup, subject: selected.name, + graphicType: flags.join(","), subtype: selectedCut.mode, dataCode: "" + } + }; + case "fixed": + if (selectedCut.id === "five-row") { + return { + code: "5074", + selection: { + groupCode: pagedGroup, subject: "INDEX", + graphicType: "", subtype: "INDUSTRY", dataCode: localDateText(now) + } + }; + } + if (selectedCut.id === "square") { + return { + code: market === "kospi" ? "8001" : "8002", + selection: { + groupCode: industryGroup, subject: marketName, + graphicType: "네모그래프", subtype: "", dataCode: "" + } + }; + } + return { + code: "5078", + selection: { + groupCode: industryGroup, subject: marketName, + graphicType: "SECTOR_INDEX", subtype: "", dataCode: "" + } + }; + default: + throw new Error("The selected industry cut is unsupported."); + } + } + + function isCutAllowed(marketValue, cutId, selectedValue, primaryValue, secondaryValue) { + const market = normalizeMarket(marketValue); + const selectedCut = cutsById.get(cutId); + const selected = normalizeIndustry(selectedValue); + const primary = normalizeIndustry(primaryValue); + const secondary = normalizeIndustry(secondaryValue); + if (!market || !selectedCut) return false; + if (selected && selected.market !== market) return false; + if (primary && primary.market !== market) return false; + if (secondary && secondary.market !== market) return false; + if (selectedCut.kind === "fixed") return true; + if (selectedCut.kind === "pair") { + return Boolean(primary && secondary && !primary.name.includes("-") && !secondary.name.includes("-")); + } + return Boolean(selected); + } + + function createIndustryPlaylistEntry(marketValue, cutId, options) { + const market = normalizeMarket(marketValue); + const selectedCut = cutsById.get(cutId); + if (!market || !selectedCut) throw new RangeError("The industry action is unknown."); + const selected = normalizeIndustry(options?.selected); + const pair = Array.isArray(options?.pair) ? options.pair : []; + const primary = normalizeIndustry(pair[0]); + const secondary = normalizeIndustry(pair[1]); + if ((selected && selected.market !== market) || (primary && primary.market !== market) || + (secondary && secondary.market !== market)) { + throw new RangeError("The selected industry market is inconsistent."); + } + if (!isCutAllowed(market, cutId, selected, primary, secondary)) { + throw new RangeError(selectedCut.kind === "pair" ? "Select two industries first." : "Select one industry first."); + } + const normalized = requireOptions(options); + const movingAverages = Object.freeze({ + ma5: selectedCut.kind === "candle" && normalized.ma5, + ma20: selectedCut.kind === "candle" && normalized.ma20 + }); + const mapping = mappingFor( + market, + selectedCut, + selected, + primary, + secondary, + normalized.now, + movingAverages); + const selection = Object.freeze(mapping.selection); + return { + id: normalized.id, + builderKey: selectedCut.builderKey, + code: mapping.code, + aliases: [mapping.code], + title: selectedCut.kind === "fixed" + ? `${market === "kospi" ? "코스피" : "코스닥"} ${selectedCut.label}` + : (selectedCut.kind === "pair" ? `${primary.name}-${secondary.name}` : selected.name), + detail: selectedCut.label, + category: selectedCut.kind === "candle" || selectedCut.kind === "yield" ? "chart" : "plate", + market, + selection, + enabled: true, + fadeDuration: normalized.fadeDuration, + graphicType: selection.graphicType, + subtype: selection.subtype, + operator: Object.freeze({ + source: "legacy-industry-workflow", + cutId: selectedCut.id, + market, + ...(selectedCut.kind === "pair" ? { pair: Object.freeze([primary, secondary]) } : {}), + ...(selectedCut.kind !== "pair" && selectedCut.kind !== "fixed" ? { selected } : {}), + assetPath: runtimeAssets[market].relativePath, + assetSha256: runtimeAssets[market].sha256, + movingAverages + }) + }; + } + + function sameSelection(left, right, allowDynamicDate) { + if (!left || typeof left !== "object" || !right || typeof right !== "object") return false; + return ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => { + if (allowDynamicDate && key === "dataCode") return typeof left[key] === "string" && datePattern.test(left[key]); + return typeof left[key] === "string" && left[key] === right[key]; + }); + } + + function restoreIndustryPlaylistEntry(value, options = {}) { + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null; + const operator = value.operator; + if (!operator || typeof operator !== "object" || operator.source !== "legacy-industry-workflow") return null; + const market = normalizeMarket(operator.market); + const selectedCut = cutsById.get(operator.cutId); + if (!market || !selectedCut || operator.assetPath !== runtimeAssets[market].relativePath || + operator.assetSha256 !== runtimeAssets[market].sha256) return null; + let movingAverages = operator.movingAverages; + if (movingAverages === undefined) { + if (selectedCut.kind === "candle") { + const graphicType = value?.selection?.graphicType; + if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null; + movingAverages = { + ma5: graphicType === "MA5" || graphicType === "MA5,MA20", + ma20: graphicType === "MA20" || graphicType === "MA5,MA20" + }; + } else { + movingAverages = { ma5: false, ma20: false }; + } + } + if (!movingAverages || typeof movingAverages !== "object" || Array.isArray(movingAverages) || + typeof movingAverages.ma5 !== "boolean" || typeof movingAverages.ma20 !== "boolean" || + Object.keys(movingAverages).length !== 2 || + (selectedCut.kind !== "candle" && (movingAverages.ma5 || movingAverages.ma20))) return null; + let recreated; + try { + recreated = createIndustryPlaylistEntry(market, selectedCut.id, { + id: value.id, + fadeDuration: value.fadeDuration, + selected: operator.selected, + pair: operator.pair, + now: options.now, + ma5: movingAverages.ma5, + ma20: movingAverages.ma20 + }); + } catch { + return null; + } + const scalars = ["id", "builderKey", "code", "title", "detail", "category", "market", "fadeDuration"]; + if (!scalars.every(key => value[key] === recreated[key]) || + !Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.code || + !sameSelection(value.selection, recreated.selection, selectedCut.id === "five-row")) return null; + return { ...recreated, enabled: value.enabled }; + } + + return { + markets, + runtimeAssets, + cuts, + normalizeIndustry, + isCutAllowed, + localDateText, + createIndustryPlaylistEntry, + restoreIndustryPlaylistEntry, + refreshIndustryPlaylistEntry: restoreIndustryPlaylistEntry + }; +}); diff --git a/Web/legacy-fixed-workflow.js b/Web/legacy-fixed-workflow.js new file mode 100644 index 0000000..33782a0 --- /dev/null +++ b/Web/legacy-fixed-workflow.js @@ -0,0 +1,527 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnLegacyFixedWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const controlCharacterPattern = /[\u0000-\u001f\u007f]/; + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + const fixedMarkets = Object.freeze(["overseas", "exchange", "index"]); + const runtimeAssets = Object.freeze({ + overseas: Object.freeze({ + relativePath: "bin/Debug/Res/해외.ini", + sha256: "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A" + }), + exchange: Object.freeze({ + relativePath: "bin/Debug/Res/환율.ini", + sha256: "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C" + }), + index: Object.freeze({ + relativePath: "bin/Debug/Res/지수.ini", + sha256: "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6" + }) + }); + + const rawActions = []; + + function cloneSelection(value) { + return { + groupCode: String(value.groupCode || ""), + subject: String(value.subject || ""), + graphicType: String(value.graphicType || ""), + subtype: String(value.subtype || ""), + dataCode: String(value.dataCode || "") + }; + } + + function addAction(market, section, label, definition) { + const ordinal = rawActions.length + 1; + rawActions.push({ + id: `fixed-${String(ordinal).padStart(3, "0")}`, + market, + section, + label, + detail: definition.detail || section, + category: definition.category || "market", + builderKey: definition.builderKey, + code: definition.code, + aliases: definition.aliases || [definition.code], + available: definition.available !== false, + prerequisite: definition.prerequisite || "", + selection: cloneSelection(definition.selection || {}), + dynamicDate: definition.dynamicDate === true, + dynamicNxtSession: definition.dynamicNxtSession === true + }); + } + + const foreignIndices = [ + ["다우", "Dow"], ["나스닥", "Nasdaq"], ["S&P", "Sp500"], + ["독일", "GermanyDax"], ["영국", "UnitedKingdomFtse"], ["프랑스", "FranceCac"], + ["니케이", "Nikkei"], ["중국 상해", "ShanghaiComposite"], ["홍콩 항셍", "HangSeng"], + ["대만 가권", "TaiwanWeighted"], ["싱가포르 지수", "SingaporeStraitsTimes"], + ["태국 지수", "ThailandSet"], ["필리핀 지수", "PhilippinesComposite"], + ["말레이시아 지수", "MalaysiaKlse"], ["인도네시아 지수", "IndonesiaComposite"] + ]; + const commodities = [ + ["소맥", "FeedWheat"], ["옥수수", "Corn"], ["밀", "Wheat"], ["대두", "Soybean"], + ["콩", "Bean"], ["현미", "BrownRice"], ["커피", "Coffee"], ["코코아", "Cocoa"], + ["설탕", "Sugar"], ["생우(소)", "LiveCattle"], ["비육우", "FeederCattle"], + ["돈육(돼지)", "LeanHogs"], ["구리", "Copper"], ["철광석", "IronOre"], + ["니켈", "Nickel"], ["천연가스", "NaturalGas"], ["백금", "Platinum"], + ["팔라듐", "Palladium"], ["납", "Lead"], ["아연", "Zinc"], ["주석", "Tin"], + ["원면", "RawCotton"], ["목화(면화)", "Cotton"], + ["국제 금", "InternationalGold"], ["국제 은", "InternationalSilver"], + ["국내 금", "DomesticGold"], ["국내 은", "DomesticSilver"] + ]; + + for (const [label, subject] of foreignIndices) { + addAction("overseas", "1열판기본", label, { + builderKey: "s5001", code: "5001", category: "plate", + selection: { groupCode: "FOREIGN_INDEX", subject, graphicType: "1열판기본", subtype: "CURRENT" } + }); + } + addAction("overseas", "1열판기본", "BDI 지수", { + builderKey: "s5001", code: "5001", category: "plate", + selection: { groupCode: "FOREIGN_INDEX", subject: "BALTIC_DRY_INDEX", graphicType: "1열판기본", subtype: "CURRENT" } + }); + for (const [label, subject] of commodities) { + addAction("overseas", "1열판기본", label, { + builderKey: "s5001", code: "5001", category: "plate", + selection: { groupCode: "COMMODITY", subject, graphicType: "1열판기본", subtype: "CURRENT" } + }); + } + + [ + ["농산물[원면, 목화(면화)]", "Cotton"], + ["원자재[국제 금, 국제 은]", "InternationalPreciousMetals"], + ["원자재[국내 금, 국내 은]", "DomesticPreciousMetals"] + ].forEach(([label, subtype]) => addAction("overseas", "2열판-", label, { + builderKey: "s50160", code: "50160", category: "plate", + selection: { groupCode: "ALL", subject: "INDEX", graphicType: "2열판-", subtype } + })); + + [ + ["미국 지수[다우, 나스닥, S&P]", "UnitedStatesIndices"], + ["중화권 지수[홍콩항셍, 상해종합, 대만]", "GreaterChinaIndices"], + ["유럽권 지수[영국, 프랑스, 독일]", "EuropeanIndices"], + ["아시아 지수[상해, 대만, 일본]", "AsianIndices"], + ["미국국채[2년, 3년, 5년]", "UnitedStatesTreasuries"], + ["채권금리(CD,CP,콜금리)", "BondYields"], + ["유가[두바이, 브렌트유, WTI]", "Oil"], + ["광물[금, 은, 구리]", "Minerals"], + ["식자재[대두, 옥수수, 밀]", "FoodMaterials"], + ["농산물[소맥, 옥수수, 밀]", "AgricultureWheatCorn"], + ["농산물[대두, 콩, 현미]", "AgricultureSoyRice"], + ["농산물[커피, 코코아, 설탕]", "AgricultureCoffeeCocoaSugar"], + ["농산물[생우(소), 비육우, 돈육(돼지)]", "AgricultureLivestock"], + ["원자재[구리, 철광석, 니켈]", "RawMaterialsCopperIronNickel"], + ["원자재[천연가스, 백금, 팔라듐]", "RawMaterialsGasPlatinumPalladium"], + ["원자재[납, 아연, 주석]", "RawMaterialsLeadZincTin"] + ].forEach(([label, subtype]) => addAction("overseas", "3열판", label, { + builderKey: "s5016", code: "5016", category: "plate", + selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype } + })); + + [ + ["글로벌 증시 지도", "8067"], ["아시아 증시 지도", "5072"], + ["미국 증시 지도", "5068"], ["유럽 증시 지도", "5070"] + ].forEach(([label, code]) => addAction("overseas", "글로벌 증시 지도", label, { + builderKey: "s8067", code, aliases: [code], + selection: { groupCode: "ALL", subject: "INDEX", graphicType: "WORLD_MAP", subtype: code } + })); + + [ + ["미국 다우 지수", "DowJones"], ["미국 나스닥 지수", "Nasdaq"], + ["미국 S&P 지수", "StandardAndPoor500"], ["중국 상해 지수", "China"], + ["일본 닛케이 지수", "Japan"], ["유가 두바이 지수", "DubaiCrude"], + ["유가 WTI 지수", "WtiCrude"], ["유가 브렌트유 지수", "BrentCrude"], + ["금 지수", "Gold"] + ].forEach(([label, subtype]) => addAction("overseas", "이미지 그래프", label, { + builderKey: "s6001", code: "6001", + selection: { groupCode: "FOREIGN_INDEX", subject: label, graphicType: "IMAGE_GRAPH", subtype } + })); + + [["다우", "DOW"], ["나스닥", "NASDAQ"], ["S&P", "S&P500"]] + .forEach(([label, subject]) => addAction("overseas", "미국업종", label, { + builderKey: "s5078", code: "5078", category: "chart", + selection: { groupCode: "FOREIGN_INDEX", subject, graphicType: "US_SECTOR_INDEX", subtype: "" } + })); + + const exchangeTargets = [ + ["원달러", "WonDollar"], ["원엔", "WonYen"], + ["원위엔", "WonYuan"], ["원유로", "WonEuro"] + ]; + for (const [label, subject] of exchangeTargets) { + addAction("exchange", "1열판기본", label, { + builderKey: "s5001", code: "5001", category: "plate", + selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "1열판기본", subtype: "CURRENT" } + }); + } + [ + ["해외환율[엔달러, 위안화달러, 유로달러]", "OverseasExchangeRates"], + ["환율[원달러, 원엔, 원유로]", "DomesticExchangeRates"] + ].forEach(([label, subtype]) => addAction("exchange", "3열판", label, { + builderKey: "s5016", code: "5016", category: "plate", + selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype } + })); + + const yieldPeriods = [ + ["5일", "FiveDays"], ["20일", "TwentyDays"], ["60일", "SixtyDays"], + ["120일", "OneHundredTwentyDays"], ["240일", "TwoHundredFortyDays"] + ]; + for (const [label, subject] of exchangeTargets) { + for (const [periodLabel, subtype] of yieldPeriods) { + addAction("exchange", "수익률 그래프", `${label}_${periodLabel}`, { + builderKey: "s5086", code: "5086", category: "chart", + selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "YIELD_GRAPH", subtype } + }); + } + } + for (const [label, subject] of exchangeTargets) { + for (const [periodLabel, subtype] of yieldPeriods) { + addAction("exchange", "라인 그래프", `${label}_${periodLabel}`, { + builderKey: "s50860", code: "50860", category: "chart", + selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "LINE_GRAPH", subtype } + }); + } + } + + [ + ["3열판_주요지수[코스피, 코스닥, 코스피200]", "MajorKospiKosdaqKospi200"], + ["3열판_주요지수[코스피, 코스닥, 선물]", "MajorKospiKosdaqFutures"], + ["3열판_예상체결지수[코스피, 코스닥, 코스피200]", "ExpectedKospiKosdaqKospi200"], + ["3열판_예상체결지수[코스피, 코스닥, 선물]", "ExpectedKospiKosdaqFutures"] + ].forEach(([label, subtype]) => addAction("index", "주요지수", label, { + builderKey: "s5016", code: "5016", category: "plate", + selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype } + })); + + const candlePeriods = [ + ["일봉", "8035"], ["5일", "8061"], ["20일", "8040"], + ["60일", "8046"], ["120일", "8051"], ["240일", "8056"] + ]; + function addIndexMarket(section, groupCode, candleGroup, options) { + addAction("index", section, "1열판기본_예상지수", { + builderKey: "s5001", code: "5001", category: "plate", + selection: { groupCode, subject: "INDEX", graphicType: "1열판기본", subtype: "EXPECTED_INDEX" } + }); + addAction("index", section, "1열판기본_현재지수", { + builderKey: "s5001", code: "5001", category: "plate", + selection: { groupCode, subject: "INDEX", graphicType: "1열판기본", subtype: "CURRENT" } + }); + if (options.yield) { + for (const [periodLabel, subtype] of yieldPeriods) { + addAction("index", section, `수익률그래프_${periodLabel}`, { + builderKey: "s5086", code: "5086", category: "chart", + selection: { groupCode, subject: "INDEX", graphicType: "YIELD_GRAPH", subtype } + }); + } + } + for (const [periodLabel, code] of candlePeriods) { + addAction("index", section, `캔들그래프_${periodLabel}`, { + builderKey: "s8010", code, category: "chart", + selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "PRICE" } + }); + } + if (options.volume) { + for (const [periodLabel, code] of candlePeriods) { + addAction("index", section, `캔들그래프(거래량)_${periodLabel}`, { + builderKey: "s8010", code, category: "chart", + selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "VOLUME" } + }); + } + } + if (options.expected) { + for (const [periodLabel, code] of candlePeriods.slice(1)) { + addAction("index", section, `캔들그래프(예상지수)_${periodLabel}`, { + builderKey: "s8010", code, category: "chart", + selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "EXPECTED" } + }); + } + } + } + addIndexMarket("코스피", "KOSPI", "KOSPI_INDEX", { yield: true, volume: true, expected: true }); + addIndexMarket("코스닥", "KOSDAQ", "KOSDAQ_INDEX", { yield: true, volume: true, expected: true }); + addIndexMarket("코스피200", "KOSPI200", "KOSPI200_INDEX", { yield: true, volume: true, expected: true }); + addIndexMarket("선물", "FUTURES", "FUTURES_INDEX", { yield: false, volume: false, expected: true }); + addIndexMarket("KRX100", "KRX100", "KRX100_INDEX", { yield: true, volume: true, expected: false }); + + function trend(label, builderKey, code, groupCode, graphicType, subtype, available = true) { + addAction("index", "매매동향", label, { + builderKey, code, available, category: "market", + prerequisite: available ? "" : "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.", + selection: { groupCode, subject: "INDEX", graphicType, subtype } + }); + } + trend("주체별 매매동향_표그래프", "s5082", "5082", "ALL", "TRADING_BY_PARTICIPANT", "TABLE_GRAPH"); + trend("코스피 매매동향_표그래프", "s5023", "5023", "KOSPI", "KOSPI_TRADING_TREND", "TABLE_GRAPH"); + trend("코스피 매매동향_라인그래프", "s5084", "5084", "KOSPI", "KOSPI_TRADING_TREND", "LINE_GRAPH"); + trend("코스피 매매동향_막대그래프", "s5024", "5024", "KOSPI", "KOSPI_TRADING_TREND", "BAR_GRAPH"); + trend("코스닥 매매동향_표그래프", "s5023", "5023", "KOSDAQ", "KOSDAQ_TRADING_TREND", "TABLE_GRAPH"); + trend("코스닥 매매동향_라인그래프", "s5084", "5084", "KOSDAQ", "KOSDAQ_TRADING_TREND", "LINE_GRAPH"); + trend("코스닥 매매동향_막대그래프", "s5024", "5024", "KOSDAQ", "KOSDAQ_TRADING_TREND", "BAR_GRAPH"); + trend("개인 매매동향_라인그래프", "s5083", "5083", "ALL", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH"); + trend("외국인 매매동향_라인그래프", "s5083", "5083", "ALL", "FOREIGN_TRADING_TREND", "LINE_GRAPH"); + trend("기관 매매동향_라인그래프", "s5083", "5083", "ALL", "INSTITUTION_TRADING_TREND", "LINE_GRAPH"); + trend("기관 순매수 현황_표그래프", "s6067", "6067", "ALL", "INSTITUTION_NET_BUY", "TABLE_GRAPH"); + trend("프로그램 매매_표그래프", "s5085", "5085", "ALL", "PROGRAM_TRADING", "TABLE_GRAPH"); + trend("개인 순매도 상위(수동)", "s5025", "5025", "INDIVIDUAL", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false); + trend("외국인 순매도 상위(수동)", "s5025", "5025", "FOREIGN", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false); + trend("기관 순매도 상위(수동)", "s5025", "5025", "INSTITUTION", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false); + + const fiveRows = [ + "코스피 시가총액", "코스피 예상가 시가총액 상위", "코스피 상승률 상위", "코스피 하락률 상위", + "코스피 시간외 상승률 상위", "코스피 시간외 하락률 상위", + "코스닥 시가총액", "코스닥 예상가 시가총액 상위", "코스닥 상승률 상위", "코스닥 하락률 상위", + "코스닥 시간외 상승률 상위", "코스닥 시간외 하락률 상위", "코스피 200 종목", "ETF 종목", "VI 발동(수동)", + "코스피_NXT 시가총액", "코스피_NXT 상승률 상위", "코스피_NXT 하락률 상위", + "코스피_NXT 시간외 상승률 상위", "코스피_NXT 시간외 하락률 상위", "코스피_NXT 거래량 상위", + "코스닥_NXT 시가총액", "코스닥_NXT 상승률 상위", "코스닥_NXT 하락률 상위", + "코스닥_NXT 시간외 상승률 상위", "코스닥_NXT 시간외 하락률 상위", "코스닥_NXT 거래량 상위" + ]; + const sixAndTwelveRows = [ + "코스피 시가총액", "코스피 예상가 시가총액 상위", "코스피 상승률 상위", "코스피 하락률 상위", + "코스피 52주 신고가", "코스피 52주 신저가", "코스피 시간외 상승률 상위", "코스피 시간외 하락률 상위", + "코스닥 시가총액", "코스닥 예상가 시가총액 상위", "코스닥 상승률 상위", "코스닥 하락률 상위", + "코스닥 52주 신고가", "코스닥 52주 신저가", "코스닥 시간외 상승률 상위", "코스닥 시간외 하락률 상위", + "코스피 200 종목", "ETF 종목", "VI 발동(수동)", + "코스피_NXT 시가총액", "코스피_NXT 상승률 상위", "코스피_NXT 하락률 상위", "코스피_NXT 거래량 상위", + "코스닥_NXT 시가총액", "코스닥_NXT 상승률 상위", "코스닥_NXT 하락률 상위", "코스닥_NXT 거래량 상위" + ]; + + function pagedDefinition(label, builderKey, code) { + if (label === "VI 발동(수동)") { + return { + // VILIST removes the placeholder row and always creates the legacy + // five-row outcome, even when the placeholder came from the 6/12 section. + builderKey: "s5074", code: "5074", available: false, category: "plate", + prerequisite: "검증된 VI 종목 목록 입력 화면이 필요합니다.", + selection: { groupCode: "PAGED_VI", subject: "", graphicType: "INPUT_ORDER", subtype: "CURRENT" } + }; + } + let groupCode; + if (label.startsWith("코스피_NXT")) groupCode = "PAGED_NXT_KOSPI"; + else if (label.startsWith("코스닥_NXT")) groupCode = "PAGED_NXT_KOSDAQ"; + else if (label.startsWith("코스닥")) groupCode = "PAGED_DOMESTIC_KOSDAQ"; + else groupCode = "PAGED_DOMESTIC_KOSPI"; + + let subtype; + if (label.includes("예상가 시가총액")) subtype = "EXPECTED_MARKET_CAP"; + else if (label.includes("시가총액")) subtype = "MARKET_CAP"; + else if (label.includes("시간외 상승률")) subtype = "AFTER_HOURS_GAIN_RATE"; + else if (label.includes("시간외 하락률")) subtype = "AFTER_HOURS_LOSS_RATE"; + else if (label.includes("상승률")) subtype = "GAIN_RATE"; + else if (label.includes("하락률")) subtype = "LOSS_RATE"; + else if (label.includes("52주 신고가")) subtype = "52_WEEK_HIGH"; + else if (label.includes("52주 신저가")) subtype = "52_WEEK_LOW"; + else if (label.includes("200 종목")) subtype = "KOSPI200"; + else if (label.startsWith("ETF")) subtype = "ETF"; + else if (label.includes("거래량")) subtype = "VOLUME"; + else throw new Error(`Unknown paged legacy action: ${label}`); + + const dynamicNxtSession = groupCode.startsWith("PAGED_NXT_"); + return { + builderKey, code, category: "plate", + dynamicDate: !dynamicNxtSession, + dynamicNxtSession, + selection: { + groupCode, + subject: "INDEX", + graphicType: dynamicNxtSession ? "$NXT_SESSION" : "", + subtype, + dataCode: dynamicNxtSession ? "" : "$TODAY" + } + }; + } + + function addPagedSection(section, labels, builderKey, code) { + for (const label of labels) addAction("index", section, label, pagedDefinition(label, builderKey, code)); + } + addPagedSection("5단 표그래프", fiveRows, "s5074", "5074"); + addPagedSection("6종목 현재가", sixAndTwelveRows, "s5077", "5077"); + addPagedSection("12종목 현재가", sixAndTwelveRows, "s5088", "5088"); + + const actions = Object.freeze(rawActions.map(value => Object.freeze({ + ...value, + aliases: Object.freeze([...value.aliases]), + selection: Object.freeze({ ...value.selection }) + }))); + if (actions.length !== 328) throw new Error("The three fixed legacy tabs must contain exactly 328 actions."); + if (actions.filter(action => action.available).length !== 322) { + throw new Error("Exactly six fixed-tab actions must remain trusted manual prerequisites."); + } + const byId = new Map(actions.map(action => [action.id, action])); + + function localDateText(now = new Date()) { + const year = String(now.getFullYear()).padStart(4, "0"); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function currentNxtSession(now = new Date()) { + return now.getHours() <= 12 ? "PRE_MARKET" : "AFTER_MARKET"; + } + + function materializeSelection(action, now, movingAverages) { + const selection = cloneSelection(action.selection); + if (action.dynamicDate) selection.dataCode = localDateText(now); + if (action.dynamicNxtSession) selection.graphicType = currentNxtSession(now); + if (action.builderKey === "s8010") { + const flags = []; + if (movingAverages.ma5) flags.push("MA5"); + if (movingAverages.ma20) flags.push("MA20"); + selection.graphicType = flags.join(","); + } + return selection; + } + + function requireOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Fixed playlist options are required."); + } + const id = typeof options.id === "string" ? options.id.trim() : ""; + if (!identifierPattern.test(id)) throw new TypeError("A safe fixed playlist entry id is required."); + const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration); + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date(); + const ma5 = options.ma5 === undefined ? false : options.ma5; + const ma20 = options.ma20 === undefined ? false : options.ma20; + if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") { + throw new TypeError("Moving-average flags must be boolean values."); + } + return { id, fadeDuration, now, ma5, ma20 }; + } + + function createFixedPlaylistEntry(actionId, options) { + const action = typeof actionId === "string" ? byId.get(actionId) : null; + if (!action) throw new RangeError("The selected fixed legacy action is unknown."); + if (!action.available) throw new RangeError(action.prerequisite || "The selected action requires trusted manual data."); + const normalized = requireOptions(options); + const movingAverages = Object.freeze({ + ma5: action.builderKey === "s8010" && normalized.ma5, + ma20: action.builderKey === "s8010" && normalized.ma20 + }); + const selection = Object.freeze(materializeSelection(action, normalized.now, movingAverages)); + return { + id: normalized.id, + builderKey: action.builderKey, + code: action.code, + aliases: [...action.aliases], + title: action.label, + detail: `${action.section} · ${action.label}`, + category: action.category, + market: action.market, + selection, + enabled: true, + fadeDuration: normalized.fadeDuration, + graphicType: selection.graphicType, + subtype: selection.subtype, + operator: Object.freeze({ + source: "legacy-fixed-workflow", + actionId: action.id, + legacyTab: action.market, + legacySection: action.section, + legacyLabel: action.label, + assetPath: runtimeAssets[action.market].relativePath, + assetSha256: runtimeAssets[action.market].sha256, + movingAverages + }) + }; + } + + function sameStringArray(left, right) { + return Array.isArray(left) && Array.isArray(right) && + left.length === right.length && left.every((value, index) => value === right[index]); + } + + function normalizeStoredMovingAverages(value, action) { + const stored = value?.operator?.movingAverages; + if (stored !== undefined) { + if (!stored || typeof stored !== "object" || Array.isArray(stored) || + typeof stored.ma5 !== "boolean" || typeof stored.ma20 !== "boolean" || + Object.keys(stored).length !== 2) return null; + if (action.builderKey !== "s8010" && (stored.ma5 || stored.ma20)) return null; + return Object.freeze({ ma5: stored.ma5, ma20: stored.ma20 }); + } + if (action.builderKey !== "s8010") return Object.freeze({ ma5: false, ma20: false }); + const graphicType = value?.selection?.graphicType; + if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null; + return Object.freeze({ + ma5: graphicType === "MA5" || graphicType === "MA5,MA20", + ma20: graphicType === "MA20" || graphicType === "MA5,MA20" + }); + } + + function storedSelectionMatches(value, action, movingAverages) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) { + const text = value[key]; + if (typeof text !== "string" || text.length > 256 || controlCharacterPattern.test(text)) return false; + if (action.dynamicDate && key === "dataCode") { + if (!datePattern.test(text)) return false; + } else if (action.dynamicNxtSession && key === "graphicType") { + if (!(["PRE_MARKET", "AFTER_MARKET"].includes(text))) return false; + } else if (action.builderKey === "s8010" && key === "graphicType") { + const flags = []; + if (movingAverages.ma5) flags.push("MA5"); + if (movingAverages.ma20) flags.push("MA20"); + if (text !== flags.join(",")) return false; + } else if (text !== action.selection[key]) return false; + } + return true; + } + + function restoreFixedPlaylistEntry(value, options = {}) { + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null; + const operator = value.operator; + if (!operator || typeof operator !== "object" || operator.source !== "legacy-fixed-workflow") return null; + const action = byId.get(operator.actionId); + const movingAverages = action ? normalizeStoredMovingAverages(value, action) : null; + if (!action || !action.available || !movingAverages || + !storedSelectionMatches(value.selection, action, movingAverages)) return null; + if (operator.legacyTab !== action.market || operator.legacySection !== action.section || + operator.legacyLabel !== action.label || operator.assetPath !== runtimeAssets[action.market].relativePath || + operator.assetSha256 !== runtimeAssets[action.market].sha256) return null; + if (value.builderKey !== action.builderKey || value.code !== action.code || + value.title !== action.label || value.detail !== `${action.section} · ${action.label}` || + value.category !== action.category || value.market !== action.market || + !sameStringArray(value.aliases, action.aliases)) return null; + let recreated; + try { + recreated = createFixedPlaylistEntry(action.id, { + id: value.id, + fadeDuration: value.fadeDuration, + now: options.now, + ma5: movingAverages.ma5, + ma20: movingAverages.ma20 + }); + } catch { + return null; + } + return { ...recreated, enabled: value.enabled }; + } + + function actionsForMarket(market) { + return fixedMarkets.includes(market) ? actions.filter(action => action.market === market) : []; + } + + return { + runtimeAssets, + fixedMarkets, + actions, + actionsForMarket, + localDateText, + currentNxtSession, + createFixedPlaylistEntry, + restoreFixedPlaylistEntry, + refreshFixedPlaylistEntry: restoreFixedPlaylistEntry + }; +}); diff --git a/Web/manual-financial-ui.css b/Web/manual-financial-ui.css new file mode 100644 index 0000000..522f687 --- /dev/null +++ b/Web/manual-financial-ui.css @@ -0,0 +1,326 @@ +.mfui-overlay[hidden] { + display: none !important; +} + +.mfui-overlay { + position: fixed; + inset: 0; + z-index: 1400; + display: grid; + place-items: center; + padding: 24px; + background: rgba(4, 10, 18, 0.74); + backdrop-filter: blur(7px); + color: #e9f0f8; +} + +.mfui-dialog { + --mfui-border: rgba(139, 162, 190, 0.28); + --mfui-panel: rgba(15, 27, 42, 0.96); + --mfui-muted: #91a3b8; + width: min(1180px, 96vw); + max-height: min(880px, 94vh); + overflow: hidden; + border: 1px solid var(--mfui-border); + border-radius: 16px; + background: #0b1624; + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.52); + font-family: "Segoe UI", "Malgun Gothic", sans-serif; +} + +.mfui-header, +.mfui-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + border-color: var(--mfui-border); + background: linear-gradient(135deg, rgba(20, 39, 59, 0.98), rgba(9, 21, 35, 0.98)); +} + +.mfui-header { + border-bottom: 1px solid var(--mfui-border); +} + +.mfui-footer { + align-items: flex-start; + border-top: 1px solid var(--mfui-border); +} + +.mfui-heading { + min-width: 0; +} + +.mfui-title, +.mfui-editor-title, +.mfui-subtitle, +.mfui-status, +.mfui-quarantine, +.mfui-list-state, +.mfui-stock-state, +.mfui-empty { + margin: 0; +} + +.mfui-title { + color: #f5f9ff; + font-size: 22px; + line-height: 1.25; +} + +.mfui-subtitle, +.mfui-list-state, +.mfui-stock-state, +.mfui-empty { + color: var(--mfui-muted); + font-size: 12px; +} + +.mfui-subtitle { + margin-top: 4px; +} + +.mfui-body { + display: grid; + grid-template-columns: minmax(250px, 0.72fr) minmax(520px, 1.8fr); + min-height: 560px; + max-height: calc(94vh - 150px); +} + +.mfui-list-panel, +.mfui-editor { + min-height: 0; + padding: 18px; +} + +.mfui-list-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; + border-right: 1px solid var(--mfui-border); + background: rgba(8, 19, 31, 0.92); +} + +.mfui-editor { + overflow: auto; + background: var(--mfui-panel); +} + +.mfui-editor-title { + color: #f1f6fc; + font-size: 17px; +} + +.mfui-stock-state { + margin: 5px 0 15px; +} + +.mfui-search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.mfui-search-input, +.mfui-input { + min-width: 0; + border: 1px solid rgba(135, 158, 186, 0.34); + border-radius: 8px; + outline: none; + background: rgba(4, 14, 25, 0.88); + color: #f1f6fc; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.mfui-search-input { + height: 38px; + padding: 0 11px; +} + +.mfui-input { + width: 100%; + height: 34px; + padding: 0 9px; + box-sizing: border-box; +} + +.mfui-search-input:focus, +.mfui-input:focus { + border-color: #55a9ed; + box-shadow: 0 0 0 3px rgba(65, 151, 221, 0.16); +} + +.mfui-input[readonly] { + color: #aebdcd; + background: rgba(30, 43, 58, 0.82); +} + +.mfui-list { + min-height: 0; + overflow: auto; + border: 1px solid rgba(135, 158, 186, 0.18); + border-radius: 10px; + background: rgba(2, 10, 19, 0.48); +} + +.mfui-list-row { + width: 100%; + padding: 10px 12px; + border: 0; + border-bottom: 1px solid rgba(135, 158, 186, 0.14); + background: transparent; + color: #dce7f3; + text-align: left; + cursor: pointer; +} + +.mfui-list-row:hover, +.mfui-list-row[aria-selected="true"] { + background: rgba(42, 125, 191, 0.24); + color: #fff; +} + +.mfui-empty { + padding: 18px 12px; + text-align: center; +} + +.mfui-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.mfui-field { + display: grid; + gap: 5px; + min-width: 0; +} + +.mfui-field-label { + color: #aebdcd; + font-size: 11px; + font-weight: 650; +} + +.mfui-group { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; + min-width: 0; + margin: 0; + padding: 10px; + border: 1px solid rgba(135, 158, 186, 0.2); + border-radius: 10px; + background: rgba(6, 17, 29, 0.52); +} + +.mfui-group-title { + padding: 0 5px; + color: #73b8ee; + font-size: 11px; + font-weight: 700; +} + +.mfui-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; + padding-top: 15px; + border-top: 1px solid rgba(135, 158, 186, 0.18); +} + +.mfui-button, +.mfui-search-button, +.mfui-close { + min-height: 36px; + padding: 0 13px; + border: 1px solid rgba(135, 158, 186, 0.36); + border-radius: 8px; + background: rgba(30, 49, 69, 0.92); + color: #e8f0f8; + font-weight: 650; + cursor: pointer; +} + +.mfui-button:hover:not(:disabled), +.mfui-search-button:hover:not(:disabled), +.mfui-close:hover:not(:disabled) { + border-color: #78b9eb; + background: rgba(43, 75, 105, 0.96); +} + +.mfui-primary { + border-color: rgba(65, 164, 226, 0.64); + background: #1671b5; +} + +.mfui-accent { + border-color: rgba(72, 194, 147, 0.64); + background: #147b59; +} + +.mfui-danger { + border-color: rgba(224, 100, 108, 0.54); + background: rgba(119, 41, 50, 0.9); +} + +.mfui-button:disabled, +.mfui-search-button:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.mfui-status { + color: #c6d4e2; + font-size: 12px; +} + +.mfui-status[data-status="error"], +.mfui-status[data-status="quarantined"], +.mfui-quarantine { + color: #ff9da5; +} + +.mfui-quarantine { + max-width: 58%; + font-size: 12px; + font-weight: 700; + text-align: right; +} + +@media (max-width: 820px) { + .mfui-overlay { + padding: 8px; + } + + .mfui-dialog { + width: 100%; + max-height: 98vh; + } + + .mfui-body { + grid-template-columns: 1fr; + overflow: auto; + } + + .mfui-list-panel { + min-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--mfui-border); + } + + .mfui-editor { + overflow: visible; + } + + .mfui-form { + grid-template-columns: 1fr; + } + + .mfui-quarantine { + max-width: none; + } +} diff --git a/Web/manual-financial-ui.js b/Web/manual-financial-ui.js new file mode 100644 index 0000000..5d26591 --- /dev/null +++ b/Web/manual-financial-ui.js @@ -0,0 +1,1128 @@ +(function (root, factory) { + "use strict"; + + const workflow = typeof module === "object" && module.exports + ? require("./manual-financial-workflow.js") + : root && root.MbnManualFinancialWorkflow; + const api = Object.freeze(factory(workflow, root)); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnManualFinancialUi = api; +})(typeof globalThis === "object" ? globalThis : this, function (workflow, root) { + "use strict"; + + if (!workflow || !workflow.bridgeContract) { + throw new Error("MbnManualFinancialWorkflow must be loaded before manual-financial-ui.js."); + } + + const contract = workflow.bridgeContract; + const STOCK_SEARCH_REQUEST = "search-stocks"; + const STOCK_SEARCH_RESULT = "stock-search-results"; + const STOCK_SEARCH_ERROR = "stock-search-error"; + const STOCK_SEARCH_MAXIMUM_QUERY_LENGTH = 64; + const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + const LEGACY_ACTION_SCREENS = Object.freeze({ + "manual-revenue-mix": "revenue-composition", + "manual-growth-metrics": "growth-metrics", + "manual-sales": "sales", + "manual-operating-profit": "operating-profit" + }); + const SCREEN_LABELS = Object.freeze({ + "revenue-composition": "주요매출 구성", + "growth-metrics": "성장성 지표", + sales: "매출액", + "operating-profit": "영업이익" + }); + const GROWTH_SERIES = Object.freeze([ + ["salesGrowth", "매출액 증가율"], + ["operatingProfitGrowth", "영업이익 증가율"], + ["netAssetGrowth", "순자산 증가율"], + ["netIncomeGrowth", "순이익 증가율"] + ]); + + let processWriteQuarantine = null; + let controllerOrdinal = 0; + + function hasExactKeys(value, expectedKeys) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + return actual.length === expected.length && + actual.every((key, index) => key === expected[index]); + } + + function safeText(value, maximumLength, allowEmpty = false) { + return typeof value === "string" && value === value.trim() && + value.length <= maximumLength && (allowEmpty || value.length > 0) && + !UNSAFE_TEXT_PATTERN.test(value); + } + + function textValue(value) { + return value === undefined || value === null ? "" : String(value).trim(); + } + + function freezeObject(value) { + return Object.freeze({ ...value }); + } + + function resolveScreen(value) { + if (typeof value === "string") { + if (workflow.getScreenDefinition(value)) return value; + return LEGACY_ACTION_SCREENS[value] || null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (typeof value.screen === "string" && workflow.getScreenDefinition(value.screen)) { + return value.screen; + } + return typeof value.id === "string" ? LEGACY_ACTION_SCREENS[value.id] || null : null; + } + + function emptyForm(screen, stockName = "") { + if (!workflow.getScreenDefinition(screen)) return null; + const result = { stockName: textValue(stockName) }; + if (screen === "revenue-composition") { + result.baseDate = ""; + for (let index = 1; index <= 5; index += 1) { + result[`slice${index}Label`] = ""; + result[`slice${index}Percentage`] = ""; + } + } else if (screen === "growth-metrics") { + for (let index = 1; index <= 4; index += 1) { + result[`period${index}`] = ""; + for (const [series] of GROWTH_SERIES) result[`${series}${index}`] = ""; + } + } else { + for (let index = 1; index <= 6; index += 1) { + result[`quarter${index}`] = ""; + result[`value${index}`] = ""; + } + } + return freezeObject(result); + } + + function recordToForm(screen, value) { + const record = workflow.normalizeRecord(screen, value); + if (!record) return null; + const result = { stockName: record.stockName }; + if (screen === "revenue-composition") { + result.baseDate = record.baseDate; + record.slices.forEach((slice, index) => { + result[`slice${index + 1}Label`] = slice ? slice.label : ""; + result[`slice${index + 1}Percentage`] = slice ? slice.percentageText : ""; + }); + } else if (screen === "growth-metrics") { + record.periods.forEach((period, index) => { + result[`period${index + 1}`] = period; + }); + for (const [series] of GROWTH_SERIES) { + record[series].forEach((item, index) => { + result[`${series}${index + 1}`] = item === null ? "" : String(item); + }); + } + } else { + record.quarters.forEach((item, index) => { + result[`quarter${index + 1}`] = item.quarter; + result[`value${index + 1}`] = String(item.value); + }); + } + return freezeObject(result); + } + + function optionalNumber(value) { + const raw = textValue(value); + if (raw === "") return null; + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw)) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : undefined; + } + + function requiredInteger(value) { + const raw = textValue(value); + if (!/^[+-]?\d+$/.test(raw)) return undefined; + const parsed = Number(raw); + return Number.isSafeInteger(parsed) ? parsed : undefined; + } + + function formToRecord(screen, form) { + if (!workflow.getScreenDefinition(screen) || + !form || typeof form !== "object" || Array.isArray(form)) return null; + const stockName = textValue(form.stockName); + let candidate; + if (screen === "revenue-composition") { + const slices = []; + for (let index = 1; index <= 5; index += 1) { + const label = textValue(form[`slice${index}Label`]); + const percentageText = textValue(form[`slice${index}Percentage`]); + if (!label && !percentageText) { + slices.push(null); + continue; + } + const percentage = percentageText === "-" ? 0 : optionalNumber(percentageText); + if (!label || !percentageText || percentage === undefined || percentage === null) return null; + slices.push({ label, percentageText, percentage }); + } + candidate = { + kind: screen, + stockName, + baseDate: textValue(form.baseDate), + slices + }; + } else if (screen === "growth-metrics") { + candidate = { kind: screen, stockName }; + for (const [series] of GROWTH_SERIES) { + const values = []; + for (let index = 1; index <= 4; index += 1) { + const parsed = optionalNumber(form[`${series}${index}`]); + if (parsed === undefined) return null; + values.push(parsed); + } + candidate[series] = values; + } + candidate.periods = []; + for (let index = 1; index <= 4; index += 1) { + candidate.periods.push(textValue(form[`period${index}`])); + } + } else { + const quarters = []; + for (let index = 1; index <= 6; index += 1) { + const quarter = textValue(form[`quarter${index}`]); + const value = requiredInteger(form[`value${index}`]); + if (!quarter || value === undefined) return null; + quarters.push({ quarter, value }); + } + candidate = { kind: screen, stockName, quarters }; + } + return workflow.normalizeRecord(screen, candidate); + } + + function latchWriteQuarantine(message) { + if (!processWriteQuarantine) { + processWriteQuarantine = freezeObject({ + active: true, + message: safeText(message, 512) + ? message + : "수동 재무 DB 쓰기 결과를 확인할 수 없습니다. 앱을 다시 시작하고 DB를 대조하세요." + }); + } + return processWriteQuarantine; + } + + function getProcessWriteQuarantine() { + return processWriteQuarantine || Object.freeze({ active: false, message: "" }); + } + + function createPendingCoordinator() { + const slots = Object.create(null); + + function set(slot, request, context = {}) { + if (typeof slot !== "string" || !request || !REQUEST_ID_PATTERN.test(request.requestId)) { + throw new TypeError("A correlated request is required."); + } + slots[slot] = Object.freeze({ request, context: Object.freeze({ ...context }) }); + return slots[slot]; + } + + function get(slot) { + return slots[slot] || null; + } + + function matches(slot, requestId) { + const pending = get(slot); + return !!pending && pending.request.requestId === requestId; + } + + function clear(slot, requestId) { + if (!Object.prototype.hasOwnProperty.call(slots, slot)) return false; + if (requestId !== undefined && slots[slot].request.requestId !== requestId) return false; + delete slots[slot]; + return true; + } + + function clearReads() { + for (const slot of ["list", "load", "stock", "selection"]) delete slots[slot]; + } + + function snapshot() { + const result = {}; + for (const [slot, pending] of Object.entries(slots)) { + result[slot] = pending.request.requestId; + } + return Object.freeze(result); + } + + function acceptMutation(kind, payload) { + const pending = get("write"); + if (!pending) return Object.freeze({ status: "stale", value: null, pending: null }); + const requestId = payload && typeof payload === "object" ? payload.requestId : null; + if (typeof requestId === "string" && REQUEST_ID_PATTERN.test(requestId) && + requestId !== pending.request.requestId) { + return Object.freeze({ status: "stale", value: null, pending: null }); + } + const value = kind === "result" + ? workflow.normalizeMutationResult(payload, pending.request) + : workflow.normalizeMutationError(payload, pending.request); + clear("write"); + if (!value) { + const quarantine = latchWriteQuarantine( + "수동 재무 DB 쓰기 응답의 상관관계 또는 결과 형식을 확인할 수 없습니다."); + return Object.freeze({ status: "invalid", value: null, pending, quarantine }); + } + if (kind === "error" && value.outcomeUnknown) { + latchWriteQuarantine(value.message); + } + return Object.freeze({ status: "accepted", value, pending }); + } + + return Object.freeze({ + set, + get, + matches, + clear, + clearReads, + snapshot, + acceptMutationResult: payload => acceptMutation("result", payload), + acceptMutationError: payload => acceptMutation("error", payload) + }); + } + + function normalizeStockSearchError(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "query", "message"]) || + !REQUEST_ID_PATTERN.test(value.requestId) || + !safeText(value.query, contract.maximumQueryLength, true) || + !safeText(value.message, 512) || !expectedRequest || + value.requestId !== expectedRequest.requestId || + value.query !== expectedRequest.query) return null; + return freezeObject(value); + } + + function preferredStockIdentity(raw, response, stockName) { + const exact = response.results.filter(item => item.name === stockName); + if (exact.length !== 1) return null; + const only = exact[0]; + if (raw && typeof raw === "object" && !Array.isArray(raw) && + raw.name === only.name && raw.market === only.market && raw.code === only.code && + (raw.source === undefined || raw.source === only.source)) return only; + return only; + } + + function createController(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Manual-financial UI dependencies are required."); + } + const required = [ + "postNative", "appendPlaylistEntry", "isLocked", "getSelectedStock", + "showToast", "addLog", "createId" + ]; + for (const name of required) { + if (typeof options[name] !== "function") { + throw new TypeError(`Manual-financial UI dependency ${name} must be a function.`); + } + } + + const pending = createPendingCoordinator(); + const state = { + mounted: false, + visible: false, + screen: null, + profile: null, + query: "", + status: "idle", + message: "", + items: [], + snapshot: null, + form: null, + stockStatus: "idle", + verifiedStock: null, + selectedStockIdentity: null, + locked: false + }; + const refs = Object.create(null); + const instanceId = `manual-financial-${++controllerOrdinal}`; + + function externallyLocked() { + let applicationLocked = true; + try { + applicationLocked = options.isLocked() !== false; + } catch { + applicationLocked = true; + } + return state.locked || applicationLocked; + } + + function writeBlocked() { + return externallyLocked() || !!pending.get("write") || !!pending.get("load") || + !!pending.get("stock") || !!pending.get("selection") || + getProcessWriteQuarantine().active; + } + + function notify(message) { + if (safeText(message, 512)) options.showToast(message); + } + + function log(message) { + if (safeText(message, 512)) options.addLog(message); + } + + function nextRequestId() { + const value = String(options.createId()); + if (!REQUEST_ID_PATTERN.test(value)) { + throw new TypeError("createId must return a safe unique request id."); + } + return value; + } + + function post(type, payload) { + options.postNative(type, payload); + } + + function seedStockName() { + try { + const stock = options.getSelectedStock(); + return stock && typeof stock.name === "string" ? stock.name : ""; + } catch { + return ""; + } + } + + function stateView() { + const quarantine = getProcessWriteQuarantine(); + return Object.freeze({ + mounted: state.mounted, + visible: state.visible, + screen: state.screen, + status: state.status, + message: state.message, + query: state.query, + itemCount: state.items.length, + selectedStockName: state.snapshot?.stockName || state.form?.stockName || "", + hasSnapshot: !!state.snapshot, + stockStatus: state.stockStatus, + verifiedStock: state.verifiedStock + ? freezeObject(state.verifiedStock) + : null, + pending: pending.snapshot(), + locked: externallyLocked(), + writeQuarantined: quarantine.active, + quarantineMessage: quarantine.message, + canWrite: !writeBlocked() + }); + } + + function setStatus(status, message = "") { + state.status = status; + state.message = safeText(message, 512, true) ? message : ""; + } + + function requestList(query = state.query) { + if (!state.screen) return false; + if (pending.get("write")) { + notify("DB 쓰기 결과를 기다리는 동안 목록을 다시 조회할 수 없습니다."); + return false; + } + try { + const request = workflow.createListRequest( + nextRequestId(), state.screen, textValue(query), contract.defaultMaximumResults); + state.query = request.query; + pending.set("list", request); + setStatus("loading", "수동 재무 목록을 조회하고 있습니다."); + post(contract.listRequestType, request); + render(); + return true; + } catch { + setStatus("error", "목록 검색 조건이 올바르지 않습니다."); + render(); + return false; + } + } + + function requestLoad(stockName) { + if (!state.screen) return false; + if (pending.get("write")) { + notify("DB 쓰기 결과를 기다리는 동안 행을 다시 조회할 수 없습니다."); + return false; + } + try { + const request = workflow.createLoadRequest(nextRequestId(), state.screen, stockName); + pending.set("load", request); + setStatus("loading", `${stockName} 데이터를 다시 확인하고 있습니다.`); + post(contract.loadRequestType, request); + render(); + return true; + } catch { + setStatus("error", "선택한 수동 재무 행을 조회할 수 없습니다."); + render(); + return false; + } + } + + function syncFormFromDom() { + if (!state.form || !refs.formInputs) return; + const next = { ...state.form }; + for (const [key, input] of refs.formInputs.entries()) next[key] = input.value; + state.form = freezeObject(next); + } + + function currentRecord() { + syncFormFromDom(); + return formToRecord(state.screen, state.form); + } + + function beginStockVerification(intent) { + if (intent === "verify-only" && + (pending.get("write") || pending.get("load") || pending.get("selection"))) { + notify("진행 중인 GraphE 요청이 끝난 뒤 종목을 다시 확인하세요."); + return false; + } + if (!state.screen || ["create", "update", "delete", "playlist"].includes(intent) && + writeBlocked()) { + notify(getProcessWriteQuarantine().active + ? getProcessWriteQuarantine().message + : "송출 또는 다른 쓰기 작업 중에는 수동 재무 데이터를 변경할 수 없습니다."); + return false; + } + if ((intent === "update" || intent === "delete" || intent === "playlist") && !state.snapshot) { + notify("DB 목록에서 먼저 행을 선택하세요."); + return false; + } + const record = currentRecord(); + if (!record) { + notify("화면 입력값과 필수 개수를 확인하세요."); + return false; + } + if (state.snapshot && record.stockName !== state.snapshot.stockName) { + notify("기존 행의 종목명은 변경할 수 없습니다."); + return false; + } + if (record.stockName.length > STOCK_SEARCH_MAXIMUM_QUERY_LENGTH) { + notify(`종목명은 재검증을 위해 ${STOCK_SEARCH_MAXIMUM_QUERY_LENGTH}자 이하여야 합니다.`); + return false; + } + let preferred = null; + try { + preferred = options.getSelectedStock(); + } catch { + preferred = null; + } + let request; + try { + request = Object.freeze({ + requestId: nextRequestId(), + query: record.stockName, + maximumResults: contract.defaultMaximumResults + }); + } catch { + notify("종목 검색 요청 ID를 만들 수 없습니다."); + return false; + } + pending.set("stock", request, { + intent, + record, + snapshot: state.snapshot, + preferred + }); + state.verifiedStock = null; + state.stockStatus = "loading"; + setStatus("loading", `${record.stockName} 종목 identity를 DB에서 다시 확인하고 있습니다.`); + post(STOCK_SEARCH_REQUEST, request); + render(); + return true; + } + + function postMutation(operation, record, snapshot) { + if (writeBlocked()) return false; + let request; + let type; + try { + if (operation === "create") { + request = workflow.createCreateRequest(nextRequestId(), record, state.verifiedStock); + type = contract.createRequestType; + } else if (operation === "update") { + request = workflow.createUpdateRequest(nextRequestId(), snapshot, record); + type = contract.updateRequestType; + } else if (operation === "delete-one") { + request = workflow.createDeleteRequest(nextRequestId(), snapshot); + type = contract.deleteRequestType; + } else { + return false; + } + } catch { + notify("DB 쓰기 요청을 안전하게 만들 수 없습니다."); + return false; + } + pending.set("write", request, { operation }); + setStatus("writing", "DB 쓰기 결과를 기다리고 있습니다. 자동 재시도하지 않습니다."); + post(type, request); + render(); + return true; + } + + function postSelection(snapshot, verifiedStock) { + if (externallyLocked() || !snapshot || snapshot !== state.snapshot) return false; + try { + const request = workflow.createSelectionRequest(nextRequestId(), snapshot, verifiedStock); + pending.set("selection", request, { snapshot, verifiedStock }); + setStatus("loading", "플레이리스트 장면 identity를 네이티브에서 확인하고 있습니다."); + post(contract.selectionRequestType, request); + render(); + return true; + } catch { + notify("플레이리스트 선택 요청을 만들 수 없습니다."); + return false; + } + } + + function continueAfterStockVerification(context, verifiedStock) { + state.verifiedStock = verifiedStock; + state.selectedStockIdentity = freezeObject(verifiedStock); + state.stockStatus = "verified"; + if (context.intent === "verify-only") { + setStatus("ready", `${verifiedStock.name} 종목 identity를 확인했습니다.`); + notify("종목 identity를 DB에서 확인했습니다."); + render(); + return true; + } + if (context.snapshot !== state.snapshot) { + setStatus("error", "행 선택이 변경되어 작업을 중단했습니다."); + render(); + return false; + } + if (context.intent === "create") return postMutation("create", context.record, null); + if (context.intent === "update") return postMutation("update", context.record, context.snapshot); + if (context.intent === "delete") return postMutation("delete-one", context.record, context.snapshot); + if (context.intent === "playlist") return postSelection(context.snapshot, verifiedStock); + return false; + } + + function deleteAll() { + if (!state.screen || writeBlocked()) { + notify(getProcessWriteQuarantine().active + ? getProcessWriteQuarantine().message + : "현재는 전체 삭제를 실행할 수 없습니다."); + return false; + } + const profile = workflow.getScreenDefinition(state.screen); + const confirmFunction = root && typeof root.confirm === "function" ? root.confirm.bind(root) : null; + if (!confirmFunction || !confirmFunction(`${SCREEN_LABELS[state.screen]} 전체 행을 삭제할까요?`)) { + return false; + } + try { + const request = workflow.createDeleteAllRequest( + nextRequestId(), state.screen, profile.deleteAllConfirmation); + pending.set("write", request, { operation: "delete-all" }); + setStatus("writing", "전체 삭제 결과를 기다리고 있습니다. 자동 재시도하지 않습니다."); + post(contract.deleteAllRequestType, request); + render(); + return true; + } catch { + notify("전체 삭제 요청을 안전하게 만들 수 없습니다."); + return false; + } + } + + function consumeRead(slot, payload, normalizer, onAccepted) { + const requestId = payload && typeof payload === "object" ? payload.requestId : null; + const active = pending.get(slot); + if (!active || active.request.requestId !== requestId) return false; + const normalized = normalizer(payload, active.request); + pending.clear(slot); + if (!normalized) { + setStatus("error", "네이티브 응답 형식 또는 상관관계가 올바르지 않습니다."); + render(); + return true; + } + onAccepted(normalized, active); + render(); + return true; + } + + function handleListResult(payload) { + return consumeRead("list", payload, workflow.normalizeListResponse, normalized => { + state.items = [...normalized.items]; + setStatus("ready", `${normalized.totalRowCount}개 행을 조회했습니다.`); + }); + } + + function handleLoadResult(payload) { + return consumeRead("load", payload, workflow.normalizeLoadResponse, normalized => { + state.snapshot = normalized.snapshot; + state.form = recordToForm(state.screen, normalized.snapshot.record); + state.verifiedStock = null; + state.stockStatus = "idle"; + setStatus("ready", `${normalized.snapshot.stockName} 행을 불러왔습니다.`); + }); + } + + function handleStockResult(payload) { + const active = pending.get("stock"); + const requestId = payload && typeof payload === "object" ? payload.requestId : null; + if (!active || requestId !== active.request.requestId) return false; + const normalized = workflow.normalizeStockSearchResponse(payload); + pending.clear("stock"); + if (!normalized || normalized.query !== active.request.query || normalized.truncated || + normalized.totalRowCount > active.request.maximumResults) { + state.stockStatus = "error"; + setStatus("error", "종목 검색 응답 형식 또는 상관관계가 올바르지 않습니다."); + render(); + return true; + } + const identity = preferredStockIdentity( + active.context.preferred, + normalized, + active.context.record.stockName); + const verified = identity && workflow.verifyStockForRecord( + active.context.record, + normalized, + identity.market, + identity.code); + if (!verified) { + state.stockStatus = "error"; + setStatus("error", "같은 이름의 종목이 없거나 시장 identity가 모호합니다."); + render(); + return true; + } + continueAfterStockVerification(active.context, verified); + return true; + } + + function handleSelectionResult(payload) { + return consumeRead("selection", payload, workflow.normalizeSelectionResponse, + (normalized, active) => { + try { + const entry = workflow.createPlaylistEntry( + active.context.snapshot, + active.context.verifiedStock, + normalized, + { id: nextRequestId(), fadeDuration: 6 }); + if (!workflow.isPlaylistEntryPlayable(entry)) throw new TypeError("Untrusted entry."); + if (options.appendPlaylistEntry(entry) === false) { + throw new Error("The host rejected the playlist entry."); + } + setStatus("ready", `${entry.title} ${SCREEN_LABELS[state.screen]} 항목을 추가했습니다.`); + notify("플레이리스트에 추가했습니다."); + log(`GraphE playlist add · ${entry.code} · ${entry.title}`); + } catch { + setStatus("error", "네이티브 확인 결과로 플레이리스트 항목을 만들 수 없습니다."); + } + }); + } + + function handleReadError(slot, payload, normalizer) { + const active = pending.get(slot); + const requestId = payload && typeof payload === "object" ? payload.requestId : null; + if (!active || requestId !== active.request.requestId) return false; + const normalized = normalizer(payload, active.request); + pending.clear(slot); + setStatus("error", normalized?.message || "네이티브 오류 응답 형식이 올바르지 않습니다."); + render(); + return true; + } + + function handleStockError(payload) { + const active = pending.get("stock"); + const requestId = payload && typeof payload === "object" ? payload.requestId : null; + if (!active || requestId !== active.request.requestId) return false; + const normalized = normalizeStockSearchError(payload, active.request); + pending.clear("stock"); + state.stockStatus = "error"; + setStatus("error", normalized?.message || "종목 검색 오류 응답 형식이 올바르지 않습니다."); + render(); + return true; + } + + function handleMutation(kind, payload) { + const outcome = kind === "result" + ? pending.acceptMutationResult(payload) + : pending.acceptMutationError(payload); + if (outcome.status === "stale") return false; + if (outcome.status === "invalid") { + setStatus("quarantined", getProcessWriteQuarantine().message); + notify(getProcessWriteQuarantine().message); + render(); + return true; + } + if (kind === "error") { + setStatus(outcome.value.outcomeUnknown ? "quarantined" : "error", outcome.value.message); + notify(outcome.value.message); + render(); + return true; + } + const result = outcome.value; + log(`GraphE DB ${result.operation} committed · ${result.screen} · ${result.affectedRows}`); + notify("수동 재무 DB 변경이 커밋되었습니다."); + if (result.operation === "delete-one" || result.operation === "delete-all") { + state.snapshot = null; + state.form = emptyForm(state.screen, seedStockName()); + state.verifiedStock = null; + } + setStatus("ready", "DB 변경을 확인했습니다."); + requestList(state.query); + if (result.operation === "create" || result.operation === "update") { + requestLoad(result.stockName); + } + render(); + return true; + } + + function handleGenericRequestError(payload) { + const operation = payload && typeof payload === "object" ? payload.operation : null; + const slot = operation === "list" ? "list" + : operation === "load" ? "load" + : operation === "selection" ? "selection" + : ["create", "update", "delete-one", "delete-all"].includes(operation) + ? "write" + : null; + const active = slot && pending.get(slot); + if (!active || payload.requestId !== active.request.requestId) return false; + const expectedOperation = slot === "write" ? active.context.operation : operation; + const normalized = workflow.normalizeRequestError(payload, expectedOperation); + pending.clear(slot); + if (slot === "write" && !normalized) { + latchWriteQuarantine( + "수동 재무 DB 쓰기 요청 오류의 상관관계를 확인할 수 없습니다."); + } + const quarantined = slot === "write" && !normalized; + setStatus( + quarantined ? "quarantined" : "error", + quarantined + ? getProcessWriteQuarantine().message + : normalized?.message || "네이티브 요청 오류 형식이 올바르지 않습니다."); + render(); + return true; + } + + function handleMessage(type, payload) { + switch (type) { + case contract.listResultType: + return handleListResult(payload); + case contract.listErrorType: + return handleReadError("list", payload, workflow.normalizeListError); + case contract.loadResultType: + return handleLoadResult(payload); + case contract.loadErrorType: + return handleReadError("load", payload, workflow.normalizeLoadError); + case STOCK_SEARCH_RESULT: + return handleStockResult(payload); + case STOCK_SEARCH_ERROR: + return handleStockError(payload); + case contract.selectionResultType: + return handleSelectionResult(payload); + case contract.selectionErrorType: + return handleReadError("selection", payload, workflow.normalizeSelectionError); + case contract.mutationResultType: + return handleMutation("result", payload); + case contract.mutationErrorType: + return handleMutation("error", payload); + case contract.requestErrorType: + return handleGenericRequestError(payload); + default: + return false; + } + } + + function makeElement(documentObject, tag, className, text) { + const element = documentObject.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + + function makeButton(documentObject, text, className, handler) { + const button = makeElement(documentObject, "button", className, text); + button.type = "button"; + button.addEventListener("click", handler); + return button; + } + + function mount(host = typeof document === "object" ? document.body : null) { + if (state.mounted) return refs.overlay; + if (!host || typeof host.appendChild !== "function") { + throw new TypeError("A DOM host is required to mount the manual-financial UI."); + } + const documentObject = host.ownerDocument || (root && root.document); + if (!documentObject || typeof documentObject.createElement !== "function") { + throw new TypeError("A DOM document is required to mount the manual-financial UI."); + } + + const overlay = makeElement(documentObject, "div", "mfui-overlay"); + overlay.hidden = true; + const dialog = makeElement(documentObject, "section", "mfui-dialog"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-labelledby", `${instanceId}-title`); + + const header = makeElement(documentObject, "header", "mfui-header"); + const heading = makeElement(documentObject, "div", "mfui-heading"); + const title = makeElement(documentObject, "h2", "mfui-title", "수동 재무 입력"); + title.id = `${instanceId}-title`; + const subtitle = makeElement(documentObject, "p", "mfui-subtitle", "GraphE DB 행을 안전하게 조회합니다."); + heading.append(title, subtitle); + const close = makeButton(documentObject, "닫기", "mfui-close", () => { + state.visible = false; + render(); + }); + header.append(heading, close); + + const body = makeElement(documentObject, "div", "mfui-body"); + const listPanel = makeElement(documentObject, "aside", "mfui-list-panel"); + const searchForm = makeElement(documentObject, "form", "mfui-search"); + const query = makeElement(documentObject, "input", "mfui-search-input"); + query.type = "search"; + query.maxLength = contract.maximumQueryLength; + query.placeholder = "저장 종목명 검색"; + query.setAttribute("aria-label", "수동 재무 저장 종목 검색"); + const searchButton = makeElement(documentObject, "button", "mfui-search-button", "조회"); + searchButton.type = "submit"; + searchForm.append(query, searchButton); + searchForm.addEventListener("submit", event => { + event.preventDefault(); + requestList(query.value); + }); + const listState = makeElement(documentObject, "p", "mfui-list-state", "목록을 열어 주세요."); + const list = makeElement(documentObject, "div", "mfui-list"); + list.setAttribute("role", "listbox"); + listPanel.append(searchForm, listState, list); + + const editor = makeElement(documentObject, "article", "mfui-editor"); + const editorTitle = makeElement(documentObject, "h3", "mfui-editor-title", "입력값"); + const stockStatus = makeElement(documentObject, "p", "mfui-stock-state", "종목 identity 미확인"); + const form = makeElement(documentObject, "div", "mfui-form"); + const actions = makeElement(documentObject, "div", "mfui-actions"); + const reset = makeButton(documentObject, "새 입력", "mfui-button", () => { + if (writeBlocked()) { + notify("진행 중인 GraphE 작업이 끝난 뒤 새 입력을 시작하세요."); + return; + } + state.snapshot = null; + state.form = emptyForm(state.screen, seedStockName()); + state.verifiedStock = null; + state.stockStatus = "idle"; + setStatus("ready", "새 수동 재무 행을 입력할 수 있습니다."); + render(); + }); + const verify = makeButton(documentObject, "종목 재검증", "mfui-button", () => beginStockVerification("verify-only")); + const create = makeButton(documentObject, "신규 저장", "mfui-button mfui-primary", () => beginStockVerification("create")); + const update = makeButton(documentObject, "수정 저장", "mfui-button", () => beginStockVerification("update")); + const remove = makeButton(documentObject, "선택 삭제", "mfui-button mfui-danger", () => beginStockVerification("delete")); + const removeAll = makeButton(documentObject, "전체 삭제", "mfui-button mfui-danger", deleteAll); + const playlist = makeButton(documentObject, "플레이리스트 추가", "mfui-button mfui-accent", () => beginStockVerification("playlist")); + actions.append(reset, verify, create, update, remove, removeAll, playlist); + editor.append(editorTitle, stockStatus, form, actions); + body.append(listPanel, editor); + + const footer = makeElement(documentObject, "footer", "mfui-footer"); + const status = makeElement(documentObject, "p", "mfui-status", "준비됨"); + const quarantine = makeElement(documentObject, "p", "mfui-quarantine"); + footer.append(status, quarantine); + dialog.append(header, body, footer); + overlay.append(dialog); + host.appendChild(overlay); + + Object.assign(refs, { + overlay, + dialog, + title, + subtitle, + close, + query, + searchButton, + listState, + list, + editorTitle, + stockStatus, + form, + actions, + reset, + verify, + create, + update, + remove, + removeAll, + playlist, + status, + quarantine, + formInputs: new Map() + }); + state.mounted = true; + render(); + return overlay; + } + + function appendField(container, labelText, key, options = {}) { + const documentObject = container.ownerDocument; + const field = makeElement(documentObject, "label", "mfui-field"); + const label = makeElement(documentObject, "span", "mfui-field-label", labelText); + const input = makeElement(documentObject, "input", "mfui-input"); + input.type = options.type || "text"; + input.inputMode = options.inputMode || "text"; + input.maxLength = options.maxLength || 200; + input.value = state.form?.[key] || ""; + input.readOnly = options.readOnly === true; + input.addEventListener("input", () => { + state.form = freezeObject({ ...state.form, [key]: input.value }); + if (key === "stockName") { + state.verifiedStock = null; + state.stockStatus = "idle"; + } + }); + field.append(label, input); + container.append(field); + refs.formInputs.set(key, input); + return input; + } + + function appendGroup(titleText) { + const documentObject = refs.form.ownerDocument; + const group = makeElement(documentObject, "fieldset", "mfui-group"); + const legend = makeElement(documentObject, "legend", "mfui-group-title", titleText); + group.append(legend); + refs.form.append(group); + return group; + } + + function renderForm() { + if (!state.mounted) return; + refs.form.replaceChildren(); + refs.formInputs = new Map(); + if (!state.screen || !state.form) return; + appendField(refs.form, "종목명", "stockName", { readOnly: !!state.snapshot }); + if (state.screen === "revenue-composition") { + appendField(refs.form, "기준일", "baseDate"); + for (let index = 1; index <= 5; index += 1) { + const group = appendGroup(`구성 ${index}`); + appendField(group, "구성명", `slice${index}Label`); + appendField(group, "비율", `slice${index}Percentage`, { inputMode: "decimal", maxLength: 64 }); + } + } else if (state.screen === "growth-metrics") { + const periods = appendGroup("분기"); + for (let index = 1; index <= 4; index += 1) { + appendField(periods, `분기 ${index}`, `period${index}`); + } + for (const [series, label] of GROWTH_SERIES) { + const group = appendGroup(label); + for (let index = 1; index <= 4; index += 1) { + appendField(group, `값 ${index}`, `${series}${index}`, { inputMode: "decimal", maxLength: 64 }); + } + } + } else { + for (let index = 1; index <= 6; index += 1) { + const group = appendGroup(`분기·금액 ${index}`); + appendField(group, "분기", `quarter${index}`); + appendField(group, "금액", `value${index}`, { inputMode: "numeric", maxLength: 16 }); + } + } + } + + function renderList() { + if (!state.mounted) return; + refs.list.replaceChildren(); + const documentObject = refs.list.ownerDocument; + for (const snapshot of state.items) { + const row = makeButton(documentObject, snapshot.stockName, "mfui-list-row", () => { + requestLoad(snapshot.stockName); + }); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", state.snapshot?.stockName === snapshot.stockName ? "true" : "false"); + refs.list.append(row); + } + if (!state.items.length) { + refs.list.append(makeElement(documentObject, "p", "mfui-empty", "조회된 행이 없습니다.")); + } + } + + function render() { + const view = stateView(); + if (!state.mounted) return view; + refs.overlay.hidden = !state.visible; + refs.title.textContent = state.screen ? SCREEN_LABELS[state.screen] : "수동 재무 입력"; + refs.subtitle.textContent = state.profile + ? `${state.profile.table} · ${state.profile.builderKey}/${state.profile.code}` + : "GraphE DB 행을 안전하게 조회합니다."; + refs.query.value = state.query; + refs.listState.textContent = state.status === "loading" && pending.get("list") + ? "목록 조회 중" + : `${state.items.length}개 행`; + refs.editorTitle.textContent = state.snapshot + ? `${state.snapshot.stockName} 수정` + : "신규 입력"; + refs.stockStatus.textContent = state.stockStatus === "verified" && state.verifiedStock + ? `${state.verifiedStock.market} · ${state.verifiedStock.code} 확인됨` + : state.stockStatus === "loading" + ? "종목 identity 확인 중" + : state.stockStatus === "error" + ? "종목 identity 확인 실패" + : "종목 identity 미확인"; + refs.status.textContent = state.message || state.status; + refs.status.dataset.status = state.status; + refs.quarantine.hidden = !view.writeQuarantined; + refs.quarantine.textContent = view.quarantineMessage; + const blocked = writeBlocked(); + refs.searchButton.disabled = !!pending.get("list"); + refs.verify.disabled = externallyLocked() || !!pending.get("stock") || + !!pending.get("write") || !!pending.get("load") || !!pending.get("selection"); + refs.reset.disabled = writeBlocked(); + refs.create.disabled = blocked || !!state.snapshot; + refs.update.disabled = blocked || !state.snapshot; + refs.remove.disabled = blocked || !state.snapshot; + refs.removeAll.disabled = blocked; + refs.playlist.disabled = writeBlocked() || !state.snapshot; + renderList(); + renderForm(); + return view; + } + + function open(value) { + if (pending.get("write")) { + notify("DB 쓰기 결과를 기다리는 동안 다른 GraphE 화면으로 전환할 수 없습니다."); + return false; + } + const screen = resolveScreen(value); + if (!screen) { + notify("지원하지 않는 수동 재무 화면입니다."); + return false; + } + pending.clearReads(); + state.screen = screen; + state.profile = workflow.getScreenDefinition(screen); + state.visible = true; + state.query = ""; + state.items = []; + state.snapshot = null; + state.form = emptyForm(screen, seedStockName()); + state.stockStatus = "idle"; + state.verifiedStock = null; + setStatus("idle", "수동 재무 목록을 조회합니다."); + render(); + return requestList(""); + } + + function setLocked(value) { + if (typeof value !== "boolean") throw new TypeError("setLocked requires a boolean value."); + state.locked = value; + return render(); + } + + return Object.freeze({ mount, open, handleMessage, render, setLocked }); + } + + /** + * App integration hook (intentionally not wired here): + * 1. Load manual-financial-workflow.js, this file and manual-financial-ui.css. + * 2. Construct one controller with createController(...), then call mount(document.body). + * 3. Route the four legacy manual action clicks to controller.open(action). + * 4. Offer every WebView message to controller.handleMessage(type, payload); it returns false + * for unrelated/shared stock messages that do not match its active request. + * 5. Mirror playlist PREPARED/PROGRAM/command locks through controller.setLocked(boolean). + * The host must keep this controller alive while a native request is pending so correlation and + * the process-lifetime OutcomeUnknown quarantine cannot be lost. + */ + + return { + createController, + resolveScreen, + emptyForm, + recordToForm, + formToRecord, + createPendingCoordinator, + getProcessWriteQuarantine + }; +}); diff --git a/Web/manual-financial-workflow.js b/Web/manual-financial-workflow.js new file mode 100644 index 0000000..e867dbc --- /dev/null +++ b/Web/manual-financial-workflow.js @@ -0,0 +1,1033 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnManualFinancialWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_MAXIMUM_RESULTS = 200; + const MAXIMUM_RESULTS = 1000; + const MAXIMUM_QUERY_LENGTH = 64; + const DEFAULT_FADE_DURATION = 6; + const OPERATOR_SCHEMA_VERSION = 1; + const MAXIMUM_FLOAT = 1_000_000_000_000; + const int32Minimum = -2147483648; + const int32Maximum = 2147483647; + const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; + const stockCodePattern = /^[A-Za-z0-9]{1,32}$/; + const rowVersionPattern = /^[0-9A-F]{64}$/; + const numberTextPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + + const bridgeContract = Object.freeze({ + requestErrorType: "manual-financial-request-error", + listRequestType: "request-manual-financial-list", + listResultType: "manual-financial-list-results", + listErrorType: "manual-financial-list-error", + loadRequestType: "request-manual-financial-load", + loadResultType: "manual-financial-load-results", + loadErrorType: "manual-financial-load-error", + selectionRequestType: "request-manual-financial-selection", + selectionResultType: "manual-financial-selection-results", + selectionErrorType: "manual-financial-selection-error", + createRequestType: "create-manual-financial-record", + updateRequestType: "update-manual-financial-record", + deleteRequestType: "delete-manual-financial-record", + deleteAllRequestType: "delete-all-manual-financial-records", + mutationResultType: "manual-financial-mutation-result", + mutationErrorType: "manual-financial-mutation-error", + defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS, + maximumResults: MAXIMUM_RESULTS, + maximumQueryLength: MAXIMUM_QUERY_LENGTH + }); + + const screenDefinitions = Object.freeze({ + "revenue-composition": Object.freeze({ + screen: "revenue-composition", + table: "INPUT_PIE", + label: "주요매출 구성", + builderKey: "s5076", + code: "5076", + aliases: Object.freeze(["5076"]), + graphicType: "REVENUE_COMPOSITION", + storageColumns: Object.freeze([ + "STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", + "GUSUNG_3", "GUSUNG_4", "GUSUNG_5" + ]), + storageShape: "BASE_DATE + five label_percentage columns", + deleteAllConfirmation: "DELETE_ALL_INPUT_PIE" + }), + "growth-metrics": Object.freeze({ + screen: "growth-metrics", + table: "INPUT_GROW", + label: "성장성 지표", + builderKey: "s5079", + code: "5079", + aliases: Object.freeze(["5079"]), + graphicType: "GROWTH_METRICS", + storageColumns: Object.freeze([ + "STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", + "GUSUNG_4", "BASE_DATE" + ]), + storageShape: "four value_value_value_value series + BASE_DATE periods", + deleteAllConfirmation: "DELETE_ALL_INPUT_GROW" + }), + sales: Object.freeze({ + screen: "sales", + table: "INPUT_SELL", + label: "매출액", + builderKey: "s5080", + code: "5080", + aliases: Object.freeze(["5080"]), + graphicType: "SALES", + storageColumns: Object.freeze([ + "STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", + "GUSUNG_4", "GUSUNG_5", "GUSUNG_6" + ]), + storageShape: "six quarter_integer columns", + deleteAllConfirmation: "DELETE_ALL_INPUT_SELL" + }), + "operating-profit": Object.freeze({ + screen: "operating-profit", + table: "INPUT_PROFIT", + label: "영업이익", + builderKey: "s5081", + code: "5081", + aliases: Object.freeze(["5081"]), + graphicType: "OPERATING_PROFIT", + storageColumns: Object.freeze([ + "STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", + "GUSUNG_4", "GUSUNG_5", "GUSUNG_6" + ]), + storageShape: "six quarter_integer columns", + deleteAllConfirmation: "DELETE_ALL_INPUT_PROFIT" + }) + }); + + const sourceContract = Object.freeze({ + originalForm: "Form/GraphE.cs", + provider: "oracle", + operatorSchemaVersion: OPERATOR_SCHEMA_VERSION, + restoreRequiresNativeLoad: true, + rawNamedPlaylistRowPlayable: false, + screens: Object.freeze(Object.values(screenDefinitions)), + writeSafety: Object.freeze({ + valuesBound: true, + oneTransactionPerWrite: true, + exclusiveTableIdentityCheck: true, + optimisticRowVersion: "SHA-256", + outcomeUnknownAutomaticRetry: false + }) + }); + + const actions = Object.freeze(Object.values(screenDefinitions).map(definition => Object.freeze({ + id: definition.screen, + label: definition.label, + screen: definition.screen, + builderKey: definition.builderKey, + code: definition.code, + aliases: definition.aliases, + pageSize: 1 + }))); + + const trustedSnapshots = new WeakSet(); + const trustedStockSearchResponses = new WeakSet(); + const verifiedStocks = new WeakSet(); + const trustedSelectionResponses = new WeakSet(); + const trustedPlaylistEntries = new WeakSet(); + const pendingRestoreEntries = new WeakSet(); + + function hasExactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && + actual.every((key, index) => key === expected[index]); + } + + function isSafeText(value, maximumLength, options = {}) { + const { allowEmpty = false, allowUnderscore = true } = options; + return typeof value === "string" && value.length <= maximumLength && + (allowEmpty || value.length > 0) && value === value.trim() && + !unsafeTextPattern.test(value) && !value.includes("^") && + (allowUnderscore || !value.includes("_")); + } + + function isRequestId(value) { + return typeof value === "string" && requestIdPattern.test(value); + } + + function definition(screen) { + return typeof screen === "string" && + Object.prototype.hasOwnProperty.call(screenDefinitions, screen) + ? screenDefinitions[screen] + : null; + } + + function normalizeQuery(value) { + if (typeof value !== "string" || unsafeTextPattern.test(value)) return null; + const query = value.trim(); + return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) + ? query + : null; + } + + function normalizeFinite(value) { + return typeof value === "number" && Number.isFinite(value) && + Math.abs(value) <= MAXIMUM_FLOAT ? value : null; + } + + function normalizeRevenueSlice(value) { + if (value === null) return null; + if (!hasExactKeys(value, ["label", "percentageText", "percentage"]) || + !isSafeText(value.label, 200, { allowUnderscore: false }) || + !isSafeText(value.percentageText, 64, { allowUnderscore: false }) || + normalizeFinite(value.percentage) === null) return undefined; + const parsed = value.percentageText === "-" + ? 0 + : numberTextPattern.test(value.percentageText) + ? Number(value.percentageText) + : Number.NaN; + if (!Number.isFinite(parsed) || Math.abs(parsed) > MAXIMUM_FLOAT || + !Object.is(parsed, value.percentage) && parsed !== value.percentage) return undefined; + return Object.freeze({ + label: value.label, + percentageText: value.percentageText, + percentage: parsed + }); + } + + function normalizeGrowthSeries(value) { + if (!Array.isArray(value) || value.length !== 4) return null; + const result = []; + for (const item of value) { + if (item === null) { + result.push(null); + } else { + const normalized = normalizeFinite(item); + if (normalized === null) return null; + result.push(normalized); + } + } + return Object.freeze(result); + } + + function normalizePeriods(value) { + if (!Array.isArray(value) || value.length !== 4) return null; + const periods = []; + for (const period of value) { + if (!isSafeText(period, 200, { allowEmpty: true, allowUnderscore: false })) return null; + periods.push(period); + } + return Object.freeze(periods); + } + + function normalizeQuarters(value) { + if (!Array.isArray(value) || value.length !== 6) return null; + const quarters = []; + for (const item of value) { + if (!hasExactKeys(item, ["quarter", "value"]) || + !isSafeText(item.quarter, 200, { allowUnderscore: false }) || + !Number.isInteger(item.value) || item.value < int32Minimum || item.value > int32Maximum) { + return null; + } + quarters.push(Object.freeze({ quarter: item.quarter, value: item.value })); + } + return Object.freeze(quarters); + } + + function normalizeRecord(screen, value) { + const profile = definition(screen); + if (!profile || !value || typeof value !== "object" || Array.isArray(value) || + value.kind !== screen || !isSafeText(value.stockName, 200)) return null; + + if (screen === "revenue-composition") { + if (!hasExactKeys(value, ["kind", "stockName", "baseDate", "slices"]) || + !isSafeText(value.baseDate, 200, { allowEmpty: true }) || + !Array.isArray(value.slices) || value.slices.length !== 5) return null; + const slices = []; + for (const raw of value.slices) { + const slice = normalizeRevenueSlice(raw); + if (slice === undefined) return null; + slices.push(slice); + } + return Object.freeze({ + kind: screen, + stockName: value.stockName, + baseDate: value.baseDate, + slices: Object.freeze(slices) + }); + } + + if (screen === "growth-metrics") { + if (!hasExactKeys(value, [ + "kind", "stockName", "salesGrowth", "operatingProfitGrowth", + "netAssetGrowth", "netIncomeGrowth", "periods" + ])) return null; + const salesGrowth = normalizeGrowthSeries(value.salesGrowth); + const operatingProfitGrowth = normalizeGrowthSeries(value.operatingProfitGrowth); + const netAssetGrowth = normalizeGrowthSeries(value.netAssetGrowth); + const netIncomeGrowth = normalizeGrowthSeries(value.netIncomeGrowth); + const periods = normalizePeriods(value.periods); + if (!salesGrowth || !operatingProfitGrowth || !netAssetGrowth || + !netIncomeGrowth || !periods) return null; + return Object.freeze({ + kind: screen, + stockName: value.stockName, + salesGrowth, + operatingProfitGrowth, + netAssetGrowth, + netIncomeGrowth, + periods + }); + } + + if (screen === "sales" || screen === "operating-profit") { + if (!hasExactKeys(value, ["kind", "stockName", "quarters"])) return null; + const quarters = normalizeQuarters(value.quarters); + return quarters && Object.freeze({ + kind: screen, + stockName: value.stockName, + quarters + }); + } + + return null; + } + + function serializeRecordForStorage(screen, value) { + const record = normalizeRecord(screen, value); + const profile = definition(screen); + if (!record || !profile) return null; + let values; + if (screen === "revenue-composition") { + values = [record.stockName, record.baseDate, + ...record.slices.map(slice => slice ? `${slice.label}_${slice.percentageText}` : "")]; + } else if (screen === "growth-metrics") { + const series = [record.salesGrowth, record.operatingProfitGrowth, + record.netAssetGrowth, record.netIncomeGrowth]; + values = [record.stockName, + ...series.map(items => items.map(item => item === null ? "" : String(item)).join("_")), + record.periods.join("_")]; + } else { + values = [record.stockName, + ...record.quarters.map(item => `${item.quarter}_${item.value}`)]; + } + return Object.freeze({ + table: profile.table, + columns: profile.storageColumns, + values: Object.freeze(values) + }); + } + + function createListRequest(requestId, screen, query = "", maximumResults) { + if (!isRequestId(requestId)) throw new TypeError("A safe list request id is required."); + if (!definition(screen)) throw new TypeError("A closed manual-financial screen is required."); + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === null) throw new TypeError("The manual-financial query is invalid."); + if (maximumResults !== undefined && + (!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) { + throw new RangeError(`Manual-financial results must be limited from 1 through ${MAXIMUM_RESULTS}.`); + } + const request = { requestId, screen, query: normalizedQuery }; + if (maximumResults !== undefined) request.maximumResults = maximumResults; + return Object.freeze(request); + } + + function normalizeRequestError(value, expectedOperation) { + if (!hasExactKeys(value, ["requestId", "operation", "code", "message"]) || + value.requestId !== "" && !isRequestId(value.requestId) || + ![ + "list", "load", "selection", "create", "update", "delete-one", "delete-all" + ].includes(value.operation) || value.code !== "INVALID_REQUEST" || + !isSafeText(value.message, 512) || + expectedOperation !== undefined && value.operation !== expectedOperation) return null; + return Object.freeze({ ...value }); + } + + function compareNames(left, right) { + const foldedLeft = left.toUpperCase(); + const foldedRight = right.toUpperCase(); + if (foldedLeft < foldedRight) return -1; + if (foldedLeft > foldedRight) return 1; + if (left < right) return -1; + if (left > right) return 1; + return 0; + } + + function normalizeSnapshot(screen, value) { + if (!hasExactKeys(value, ["stockName", "rowVersion", "record"]) || + !rowVersionPattern.test(value.rowVersion)) return null; + const record = normalizeRecord(screen, value.record); + if (!record || value.stockName !== record.stockName) return null; + const snapshot = Object.freeze({ + screen, + stockName: record.stockName, + rowVersion: value.rowVersion, + record + }); + trustedSnapshots.add(snapshot); + return snapshot; + } + + function normalizeListResponse(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "screen", "query", "retrievedAt", "totalRowCount", "truncated", "items" + ]) || !isRequestId(value.requestId) || !definition(value.screen) || + normalizeQuery(value.query) !== value.query || + !isSafeText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" || + !Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null; + + if (expectedRequest !== undefined) { + let expected; + try { + expected = createListRequest( + expectedRequest?.requestId, + expectedRequest?.screen, + expectedRequest?.query, + expectedRequest?.maximumResults); + } catch { + return null; + } + const bound = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS; + if (value.requestId !== expected.requestId || value.screen !== expected.screen || + value.query !== expected.query || value.items.length > bound || + value.truncated && value.items.length !== bound) return null; + } + + const items = []; + const names = new Set(); + for (const raw of value.items) { + const snapshot = normalizeSnapshot(value.screen, raw); + const foldedName = snapshot?.stockName.toUpperCase(); + if (!snapshot || names.has(foldedName) || + items.length && compareNames(items[items.length - 1].stockName, snapshot.stockName) > 0) { + return null; + } + names.add(foldedName); + items.push(snapshot); + } + return Object.freeze({ + requestId: value.requestId, + screen: value.screen, + query: value.query, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + items: Object.freeze(items) + }); + } + + function normalizeListError(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "screen", "query", "message"]) || + !isRequestId(value.requestId) || !definition(value.screen) || + normalizeQuery(value.query) !== value.query || + !isSafeText(value.message, 512)) return null; + if (expectedRequest) { + let expected; + try { + expected = createListRequest( + expectedRequest.requestId, + expectedRequest.screen, + expectedRequest.query, + expectedRequest.maximumResults); + } catch { + return null; + } + if (value.requestId !== expected.requestId || value.screen !== expected.screen || + value.query !== expected.query) return null; + } + return Object.freeze({ + requestId: value.requestId, + screen: value.screen, + query: value.query, + message: value.message + }); + } + + function createLoadRequest(requestId, screen, stockName) { + if (!isRequestId(requestId)) throw new TypeError("A safe load request id is required."); + if (!definition(screen) || !isSafeText(stockName, 200)) { + throw new TypeError("A closed screen and canonical stock name are required."); + } + return Object.freeze({ requestId, screen, stockName }); + } + + function normalizeLoadResponse(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "screen", "retrievedAt", "snapshot"]) || + !isRequestId(value.requestId) || !definition(value.screen) || + !isSafeText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt))) return null; + let expected; + if (expectedRequest !== undefined) { + try { + expected = createLoadRequest( + expectedRequest?.requestId, + expectedRequest?.screen, + expectedRequest?.stockName); + } catch { + return null; + } + if (value.requestId !== expected.requestId || value.screen !== expected.screen) return null; + } + const snapshot = normalizeSnapshot(value.screen, value.snapshot); + if (!snapshot || expected && snapshot.stockName !== expected.stockName) return null; + return Object.freeze({ + requestId: value.requestId, + screen: value.screen, + retrievedAt: value.retrievedAt, + snapshot + }); + } + + function normalizeLoadError(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "screen", "stockName", "code", "message", "retryable" + ]) || !isRequestId(value.requestId) || !definition(value.screen) || + !isSafeText(value.stockName, 200) || !isSafeText(value.code, 64) || + !isSafeText(value.message, 512) || typeof value.retryable !== "boolean") return null; + if (expectedRequest) { + let expected; + try { + expected = createLoadRequest( + expectedRequest.requestId, + expectedRequest.screen, + expectedRequest.stockName); + } catch { + return null; + } + if (value.requestId !== expected.requestId || value.screen !== expected.screen || + value.stockName !== expected.stockName) return null; + } + return Object.freeze({ ...value }); + } + + function normalizeStockSearchResponse(value) { + if (!hasExactKeys(value, [ + "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results" + ]) || !isRequestId(value.requestId) || normalizeQuery(value.query) !== value.query || + !isSafeText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + typeof value.truncated !== "boolean" || !Array.isArray(value.results) || + value.results.length !== value.totalRowCount) return null; + const results = []; + const identities = new Set(); + for (const item of value.results) { + if (!hasExactKeys(item, ["market", "source", "name", "code"]) || + !["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"].includes(item.market) || + !["oracle", "mariaDb"].includes(item.source) || + (item.market.startsWith("nxt-") ? item.source !== "mariaDb" : item.source !== "oracle") || + !isSafeText(item.name, 200) || !stockCodePattern.test(item.code)) return null; + const key = `${item.market}\u001f${item.source}\u001f${item.code}`; + if (identities.has(key)) return null; + identities.add(key); + results.push(Object.freeze({ + market: item.market, + source: item.source, + name: item.name, + code: item.code + })); + } + const response = Object.freeze({ + requestId: value.requestId, + query: value.query.trim(), + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + results: Object.freeze(results) + }); + trustedStockSearchResponses.add(response); + return response; + } + + function verifyStockForRecord(recordValue, stockResponse, selectedMarket, selectedCode) { + const screen = recordValue?.kind; + const record = normalizeRecord(screen, recordValue); + if (!record || !stockResponse || !trustedStockSearchResponses.has(stockResponse) || + typeof selectedMarket !== "string" || !stockCodePattern.test(selectedCode)) return null; + const matches = stockResponse.results.filter(item => item.name === record.stockName); + if (matches.length !== 1 || matches[0].market !== selectedMarket || + matches[0].code !== selectedCode) return null; + const verified = Object.freeze({ + market: matches[0].market, + source: matches[0].source, + name: matches[0].name, + code: matches[0].code + }); + verifiedStocks.add(verified); + return verified; + } + + function normalizeVerifiedStockValue(value) { + if (!hasExactKeys(value, ["market", "source", "name", "code"]) || + !["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"].includes(value.market) || + !["oracle", "mariaDb"].includes(value.source) || + (value.market.startsWith("nxt-") ? value.source !== "mariaDb" : value.source !== "oracle") || + !isSafeText(value.name, 200) || !stockCodePattern.test(value.code)) return null; + return Object.freeze({ + market: value.market, + source: value.source, + name: value.name, + code: value.code + }); + } + + function createSelectionRequest(requestId, snapshot, verifiedStock) { + if (!isRequestId(requestId)) throw new TypeError("A safe selection request id is required."); + if (!snapshot || !trustedSnapshots.has(snapshot) || + !verifiedStock || !verifiedStocks.has(verifiedStock) || + snapshot.stockName !== verifiedStock.name) { + throw new TypeError("Selection requires a native snapshot and freshly verified stock."); + } + return Object.freeze({ + requestId, + screen: snapshot.screen, + stockName: snapshot.stockName, + rowVersion: snapshot.rowVersion, + stock: Object.freeze({ + market: verifiedStock.market, + source: verifiedStock.source, + name: verifiedStock.name, + code: verifiedStock.code + }) + }); + } + + function normalizeSelectionResponse(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "screen", "stockName", "rowVersion", "builderKey", "code", + "aliases", "selection", "currentPage", "totalPages" + ]) || !isRequestId(value.requestId) || !definition(value.screen) || + !isSafeText(value.stockName, 200) || !rowVersionPattern.test(value.rowVersion) || + !isSafeText(value.builderKey, 32) || !/^[0-9A-Z]{4,6}$/.test(value.code) || + !Array.isArray(value.aliases) || value.aliases.length !== 1 || + value.aliases[0] !== value.code || + !hasExactKeys(value.selection, [ + "groupCode", "subject", "graphicType", "subtype", "dataCode" + ]) || value.currentPage !== 1 || value.totalPages !== 1) return null; + const expected = expectedRequest; + if (!expected || !hasExactKeys(expected, [ + "requestId", "screen", "stockName", "rowVersion", "stock" + ]) || value.requestId !== expected.requestId || value.screen !== expected.screen || + value.stockName !== expected.stockName || value.rowVersion !== expected.rowVersion || + !normalizeVerifiedStockValue(expected.stock) || expected.stock.name !== expected.stockName) return null; + const profile = definition(value.screen); + const groupCode = { + kospi: "KOSPI", + kosdaq: "KOSDAQ", + "nxt-kospi": "NXT_KOSPI", + "nxt-kosdaq": "NXT_KOSDAQ" + }[expected.stock?.market]; + if (!profile || !groupCode || value.builderKey !== profile.builderKey || + value.code !== profile.code || + value.selection.groupCode !== groupCode || + value.selection.subject !== value.stockName || + value.selection.graphicType !== profile.graphicType || + value.selection.subtype !== "" || value.selection.dataCode !== "") return null; + const normalized = Object.freeze({ + requestId: value.requestId, + screen: value.screen, + stockName: value.stockName, + rowVersion: value.rowVersion, + builderKey: value.builderKey, + code: value.code, + aliases: Object.freeze([...value.aliases]), + selection: Object.freeze({ ...value.selection }), + currentPage: 1, + totalPages: 1 + }); + trustedSelectionResponses.add(normalized); + return normalized; + } + + function normalizeSelectionError(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "screen", "stockName", "code", "message", "retryable" + ]) || !isRequestId(value.requestId) || !definition(value.screen) || + !isSafeText(value.stockName, 200) || !isSafeText(value.code, 64) || + !isSafeText(value.message, 512) || typeof value.retryable !== "boolean") return null; + if (expectedRequest && + (value.requestId !== expectedRequest.requestId || value.screen !== expectedRequest.screen || + value.stockName !== expectedRequest.stockName)) return null; + return Object.freeze({ ...value }); + } + + function createCreateRequest(requestId, recordValue, verifiedStock) { + const screen = recordValue?.kind; + const record = normalizeRecord(screen, recordValue); + if (!isRequestId(requestId)) throw new TypeError("A safe create request id is required."); + if (!record || !verifiedStock || !verifiedStocks.has(verifiedStock) || + verifiedStock.name !== record.stockName) { + throw new TypeError("Create requires one verified stock and a valid closed record."); + } + return Object.freeze({ + requestId, + screen, + stock: Object.freeze({ + market: verifiedStock.market, + source: verifiedStock.source, + name: verifiedStock.name, + code: verifiedStock.code + }), + record + }); + } + + function createUpdateRequest(requestId, snapshot, replacementValue) { + if (!isRequestId(requestId)) throw new TypeError("A safe update request id is required."); + if (!snapshot || !trustedSnapshots.has(snapshot)) { + throw new TypeError("Update requires a native-normalized snapshot."); + } + const replacement = normalizeRecord(snapshot.screen, replacementValue); + if (!replacement || replacement.stockName !== snapshot.stockName) { + throw new TypeError("A GraphE update cannot rename a stock or change its screen."); + } + return Object.freeze({ + requestId, + screen: snapshot.screen, + stockName: snapshot.stockName, + expectedRowVersion: snapshot.rowVersion, + record: replacement + }); + } + + function createDeleteRequest(requestId, snapshot) { + if (!isRequestId(requestId)) throw new TypeError("A safe delete request id is required."); + if (!snapshot || !trustedSnapshots.has(snapshot)) { + throw new TypeError("Delete requires a native-normalized snapshot."); + } + return Object.freeze({ + requestId, + screen: snapshot.screen, + stockName: snapshot.stockName, + expectedRowVersion: snapshot.rowVersion + }); + } + + function createDeleteAllRequest(requestId, screen, confirmationToken) { + const profile = definition(screen); + if (!isRequestId(requestId)) throw new TypeError("A safe delete-all request id is required."); + if (!profile || confirmationToken !== profile.deleteAllConfirmation) { + throw new TypeError("Delete-all requires the exact screen confirmation token."); + } + return Object.freeze({ requestId, screen, confirmationToken }); + } + + function normalizeMutationResult(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "operationId", "operation", "screen", "stockName", "committedAt", "affectedRows" + ]) || !isRequestId(value.requestId) || !isRequestId(value.operationId) || + !["create", "update", "delete-one", "delete-all"].includes(value.operation) || + !definition(value.screen) || + typeof value.stockName !== "string" || + (value.operation === "delete-all" + ? value.stockName !== "" + : !isSafeText(value.stockName, 200)) || + !isSafeText(value.committedAt, 64) || !Number.isFinite(Date.parse(value.committedAt)) || + !Number.isInteger(value.affectedRows) || value.affectedRows < 0 || + value.operation !== "delete-all" && value.affectedRows !== 1) return null; + if (expectedRequest && + (value.requestId !== expectedRequest.requestId || value.screen !== expectedRequest.screen || + value.operation !== operationForRequest(expectedRequest) || + value.stockName !== (expectedRequest.stockName ?? expectedRequest.record?.stockName ?? ""))) return null; + return Object.freeze({ ...value }); + } + + function normalizeMutationError(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "operation", "screen", "stockName", "code", "message", + "retryable", "outcomeUnknown" + ]) || !isRequestId(value.requestId) || + !["create", "update", "delete-one", "delete-all"].includes(value.operation) || + !definition(value.screen) || typeof value.stockName !== "string" || + (value.operation === "delete-all" + ? value.stockName !== "" + : !isSafeText(value.stockName, 200)) || + !isSafeText(value.code, 64) || !isSafeText(value.message, 512) || + value.retryable !== false || typeof value.outcomeUnknown !== "boolean") return null; + if (expectedRequest && + (value.requestId !== expectedRequest.requestId || value.screen !== expectedRequest.screen || + value.operation !== operationForRequest(expectedRequest) || + value.stockName !== (expectedRequest.stockName ?? expectedRequest.record?.stockName ?? ""))) return null; + return Object.freeze({ ...value }); + } + + function operationForRequest(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (Object.prototype.hasOwnProperty.call(value, "confirmationToken")) return "delete-all"; + if (Object.prototype.hasOwnProperty.call(value, "expectedRowVersion")) { + return Object.prototype.hasOwnProperty.call(value, "record") ? "update" : "delete-one"; + } + return Object.prototype.hasOwnProperty.call(value, "record") ? "create" : null; + } + + function selectionForValues(screen, stockName, stock) { + const profile = definition(screen); + const groupCode = { + kospi: "KOSPI", + kosdaq: "KOSDAQ", + "nxt-kospi": "NXT_KOSPI", + "nxt-kosdaq": "NXT_KOSDAQ" + }[stock?.market]; + if (!profile || !groupCode) return null; + return Object.freeze({ + builderKey: profile.builderKey, + code: profile.code, + aliases: profile.aliases, + selection: Object.freeze({ + groupCode, + subject: stockName, + graphicType: profile.graphicType, + subtype: "", + dataCode: "" + }), + currentPage: 1, + pageCount: 1 + }); + } + + function buildSelection(snapshot, verifiedStock) { + if (!snapshot || !trustedSnapshots.has(snapshot) || + !verifiedStock || !verifiedStocks.has(verifiedStock) || + snapshot.stockName !== verifiedStock.name) return null; + return selectionForValues(snapshot.screen, snapshot.stockName, verifiedStock); + } + + function normalizePlaylistOptions(options, profile, stock) { + if (!options || typeof options !== "object" || Array.isArray(options) || + !Object.keys(options).every(key => ["id", "fadeDuration", "enabled"].includes(key))) { + throw new TypeError("Manual-financial playlist options are invalid."); + } + const id = options.id ?? `manual-financial-${profile.code}-${stock.code}`; + const fadeDuration = options.fadeDuration ?? DEFAULT_FADE_DURATION; + const enabled = options.enabled ?? true; + if (!isRequestId(id) || !Number.isInteger(fadeDuration) || + fadeDuration < 0 || fadeDuration > 60 || typeof enabled !== "boolean") { + throw new TypeError("Manual-financial playlist options are invalid."); + } + return Object.freeze({ id, fadeDuration, enabled }); + } + + function createEntryObject(snapshot, stock, selection, options) { + const profile = definition(snapshot.screen); + const normalizedOptions = normalizePlaylistOptions(options, profile, stock); + const snapshotMetadata = Object.freeze({ + screen: snapshot.screen, + stockName: snapshot.stockName, + rowVersion: snapshot.rowVersion, + record: snapshot.record + }); + const stockMetadata = Object.freeze({ + market: stock.market, + source: stock.source, + name: stock.name, + code: stock.code + }); + return Object.freeze({ + id: normalizedOptions.id, + builderKey: selection.builderKey, + code: selection.code, + aliases: Object.freeze([...selection.aliases]), + title: snapshot.stockName, + detail: profile.label, + category: "manual-financial", + market: stock.market, + stockName: snapshot.stockName, + stockCode: stock.code, + isNxt: stock.market.startsWith("nxt-"), + selection: Object.freeze({ ...selection.selection }), + enabled: normalizedOptions.enabled, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: selection.selection.graphicType, + subtype: selection.selection.subtype, + currentPage: 1, + pageCount: 1, + operator: Object.freeze({ + source: "manual-financial-workflow", + schemaVersion: OPERATOR_SCHEMA_VERSION, + screen: snapshot.screen, + snapshot: snapshotMetadata, + verifiedStock: stockMetadata + }) + }); + } + + function createPlaylistEntry(snapshot, verifiedStock, selectionResponse, options = {}) { + const selection = buildSelection(snapshot, verifiedStock); + if (!selection || !selectionResponse || + !trustedSelectionResponses.has(selectionResponse) || + selectionResponse.screen !== snapshot.screen || + selectionResponse.stockName !== snapshot.stockName || + selectionResponse.rowVersion !== snapshot.rowVersion || + selectionResponse.builderKey !== selection.builderKey || + selectionResponse.code !== selection.code || + !deepEqualValue(selectionResponse.aliases, selection.aliases) || + !deepEqualValue(selectionResponse.selection, selection.selection) || + selectionResponse.currentPage !== 1 || selectionResponse.totalPages !== 1) { + throw new TypeError( + "A native-confirmed selection, trusted snapshot and verified stock are required."); + } + const entry = createEntryObject(snapshot, verifiedStock, selection, options); + trustedPlaylistEntries.add(entry); + return entry; + } + + function deepEqualValue(left, right) { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && + left.length === right.length && + left.every((value, index) => deepEqualValue(value, right[index])); + } + if (!left || typeof left !== "object" || !right || typeof right !== "object") return false; + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return leftKeys.length === rightKeys.length && + leftKeys.every((key, index) => key === rightKeys[index] && + deepEqualValue(left[key], right[key])); + } + + function restorePlaylistEntry(value) { + if (!hasExactKeys(value, [ + "id", "builderKey", "code", "aliases", "title", "detail", "category", "market", + "stockName", "stockCode", "isNxt", "selection", "enabled", "fadeDuration", + "graphicType", "subtype", "currentPage", "pageCount", "operator" + ]) || !value.operator || !hasExactKeys(value.operator, [ + "source", "schemaVersion", "screen", "snapshot", "verifiedStock" + ]) || value.operator.source !== "manual-financial-workflow" || + value.operator.schemaVersion !== OPERATOR_SCHEMA_VERSION || + !definition(value.operator.screen) || + !hasExactKeys(value.operator.snapshot, ["screen", "stockName", "rowVersion", "record"]) || + value.operator.snapshot.screen !== value.operator.screen || + !rowVersionPattern.test(value.operator.snapshot.rowVersion)) return null; + const record = normalizeRecord(value.operator.screen, value.operator.snapshot.record); + const stock = normalizeVerifiedStockValue(value.operator.verifiedStock); + if (!record || !stock || record.stockName !== value.operator.snapshot.stockName || + stock.name !== record.stockName) return null; + const snapshot = Object.freeze({ + screen: value.operator.screen, + stockName: record.stockName, + rowVersion: value.operator.snapshot.rowVersion, + record + }); + const selection = selectionForValues(snapshot.screen, snapshot.stockName, stock); + if (!selection) return null; + let canonical; + try { + canonical = createEntryObject(snapshot, stock, selection, { + id: value.id, + fadeDuration: value.fadeDuration, + enabled: value.enabled + }); + } catch { + return null; + } + if (!deepEqualValue(value, canonical)) return null; + const pending = Object.freeze({ + status: "refresh-required", + playable: false, + entry: canonical, + identity: Object.freeze({ screen: snapshot.screen, stockName: snapshot.stockName }), + rowVersion: snapshot.rowVersion, + verifiedStock: stock + }); + pendingRestoreEntries.add(pending); + return pending; + } + + function createRestoreLoadRequest(requestId, pending) { + if (!pending || !pendingRestoreEntries.has(pending)) { + throw new TypeError("A validated pending restore is required."); + } + return createLoadRequest(requestId, pending.identity.screen, pending.identity.stockName); + } + + function sameStoredSnapshot(pending, snapshot) { + const stored = pending?.entry?.operator?.snapshot; + return !!stored && snapshot.screen === stored.screen && + snapshot.stockName === stored.stockName && snapshot.rowVersion === stored.rowVersion && + deepEqualValue(snapshot.record, stored.record); + } + + function createRestoreSelectionRequest(requestId, pending, loadResponse, verifiedStock) { + if (!pending || !pendingRestoreEntries.has(pending) || + !loadResponse || !loadResponse.snapshot || + !trustedSnapshots.has(loadResponse.snapshot) || + !verifiedStock || !verifiedStocks.has(verifiedStock) || + !sameStoredSnapshot(pending, loadResponse.snapshot) || + !deepEqualValue(pending.verifiedStock, verifiedStock)) { + throw new TypeError("Restore refresh did not reproduce the stored identities."); + } + return createSelectionRequest(requestId, loadResponse.snapshot, verifiedStock); + } + + function materializeRestoredPlaylistEntry( + pending, + loadResponse, + verifiedStock, + selectionResponse) { + if (!pending || !pendingRestoreEntries.has(pending) || + !loadResponse || !loadResponse.snapshot || + !trustedSnapshots.has(loadResponse.snapshot) || + !verifiedStock || !verifiedStocks.has(verifiedStock) || + !selectionResponse || !trustedSelectionResponses.has(selectionResponse) || + !sameStoredSnapshot(pending, loadResponse.snapshot) || + !deepEqualValue(pending.verifiedStock, verifiedStock)) return null; + let recreated; + try { + recreated = createPlaylistEntry( + loadResponse.snapshot, + verifiedStock, + selectionResponse, + { + id: pending.entry.id, + fadeDuration: pending.entry.fadeDuration, + enabled: pending.entry.enabled + }); + } catch { + return null; + } + return deepEqualValue(recreated, pending.entry) ? recreated : null; + } + + function isPlaylistEntryPlayable(value) { + return !!value && trustedPlaylistEntries.has(value); + } + + return { + bridgeContract, + sourceContract, + actions, + getScreenDefinition: definition, + normalizeRecord, + serializeRecordForStorage, + normalizeRequestError, + createListRequest, + normalizeListResponse, + normalizeListError, + createLoadRequest, + normalizeLoadResponse, + normalizeLoadError, + normalizeStockSearchResponse, + verifyStockForRecord, + createSelectionRequest, + normalizeSelectionResponse, + normalizeSelectionError, + createCreateRequest, + createUpdateRequest, + createDeleteRequest, + createDeleteAllRequest, + normalizeMutationResult, + normalizeMutationError, + buildSelection, + createPlaylistEntry, + restorePlaylistEntry, + createRestoreLoadRequest, + createRestoreSelectionRequest, + materializeRestoredPlaylistEntry, + isPlaylistEntryPlayable + }; +}); diff --git a/Web/manual-lists-ui.css b/Web/manual-lists-ui.css new file mode 100644 index 0000000..23c050f --- /dev/null +++ b/Web/manual-lists-ui.css @@ -0,0 +1,261 @@ +.manual-lists-root { + position: fixed; + inset: 0; + z-index: 1200; + color: #e8edf6; + font: 13px/1.45 "Segoe UI", "Malgun Gothic", sans-serif; +} + +.manual-lists-root[hidden] { + display: none; +} + +.manual-lists-backdrop { + display: grid; + width: 100%; + height: 100%; + box-sizing: border-box; + place-items: center; + padding: 24px; + background: rgb(5 10 18 / 76%); + backdrop-filter: blur(3px); +} + +.manual-lists-dialog { + display: flex; + width: min(1080px, 96vw); + max-height: 92vh; + box-sizing: border-box; + flex-direction: column; + gap: 14px; + overflow: hidden; + padding: 18px; + border: 1px solid #344258; + border-radius: 12px; + background: #111927; + box-shadow: 0 24px 80px rgb(0 0 0 / 48%); +} + +.manual-lists-header, +.manual-lists-actions, +.manual-lists-search-controls, +.manual-lists-tabs { + display: flex; + align-items: center; + gap: 8px; +} + +.manual-lists-header { + justify-content: space-between; +} + +.manual-lists-title { + margin: 0; + color: #fff; + font-size: 20px; + font-weight: 700; +} + +.manual-lists-close, +.manual-lists-button, +.manual-lists-tab, +.manual-lists-search-result { + min-height: 34px; + box-sizing: border-box; + border: 1px solid #42516a; + border-radius: 6px; + color: #dfe8f7; + background: #1d293a; + font: inherit; + cursor: pointer; +} + +.manual-lists-close, +.manual-lists-button, +.manual-lists-tab { + padding: 6px 12px; +} + +.manual-lists-close:hover:not(:disabled), +.manual-lists-button:hover:not(:disabled), +.manual-lists-tab:hover:not(:disabled), +.manual-lists-search-result:hover:not(:disabled) { + border-color: #6b83a7; + background: #28374d; +} + +.manual-lists-button:disabled, +.manual-lists-tab:disabled, +.manual-lists-search-result:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.manual-lists-button.is-primary { + border-color: #2f7ac4; + background: #175a97; +} + +.manual-lists-button.is-accent { + border-color: #3e9a6b; + background: #17623f; +} + +.manual-lists-tab.is-active { + border-color: #f29b38; + color: #fff; + background: #8a4d12; +} + +.manual-lists-banner { + padding: 9px 12px; + border: 1px solid #6d5b31; + border-radius: 6px; + color: #ffe5a8; + background: #342b18; +} + +.manual-lists-banner-danger { + border-color: #8b3d47; + color: #ffd0d5; + background: #3d1c23; +} + +.manual-lists-table-wrap, +.manual-lists-search-results { + overflow: auto; +} + +.manual-lists-table-wrap { + min-height: 160px; + max-height: 45vh; + border: 1px solid #334158; + border-radius: 7px; +} + +.manual-lists-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.manual-lists-table th, +.manual-lists-table td { + padding: 7px; + border: 1px solid #334158; + text-align: left; + vertical-align: middle; +} + +.manual-lists-table thead th { + position: sticky; + top: 0; + z-index: 1; + color: #f4f7fc; + background: #202c3e; +} + +.manual-lists-row-number { + width: 42px; + color: #8fa4c3; + text-align: center !important; +} + +.manual-lists-net-table th:first-child { + width: 42px; +} + +.manual-lists-input { + width: 100%; + min-height: 34px; + box-sizing: border-box; + padding: 6px 8px; + border: 1px solid #42516a; + border-radius: 5px; + outline: none; + color: #f4f7fc; + background: #0b1320; + font: inherit; +} + +.manual-lists-input:focus { + border-color: #5da6e8; + box-shadow: 0 0 0 2px rgb(66 145 217 / 20%); +} + +.manual-lists-input:disabled { + color: #7f8ba0; + background: #141b27; +} + +.manual-lists-search { + display: grid; + gap: 8px; +} + +.manual-lists-search-input { + flex: 1; +} + +.manual-lists-search-state, +.manual-lists-summary { + margin: 0; + color: #9eafc8; +} + +.manual-lists-search-results { + display: grid; + max-height: 150px; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 6px; +} + +.manual-lists-search-result { + padding: 7px 9px; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.manual-lists-code { + width: 160px; + color: #9cc9ff; + font-family: Consolas, monospace; +} + +.manual-lists-name { + overflow-wrap: anywhere; +} + +.manual-lists-row-actions { + display: flex; + width: 190px; + gap: 5px; +} + +.manual-lists-row-actions .manual-lists-button { + min-width: 42px; + padding-inline: 8px; +} + +.manual-lists-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + +@media (max-width: 760px) { + .manual-lists-backdrop { + padding: 8px; + } + + .manual-lists-dialog { + width: 100%; + max-height: 98vh; + padding: 12px; + } + + .manual-lists-net-table { + min-width: 760px; + } +} diff --git a/Web/manual-lists-ui.js b/Web/manual-lists-ui.js new file mode 100644 index 0000000..51211f8 --- /dev/null +++ b/Web/manual-lists-ui.js @@ -0,0 +1,919 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory(root)); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnManualListsUi = api; +})(typeof globalThis === "object" ? globalThis : this, function (root) { + "use strict"; + + const NET_KEYS = Object.freeze(["leftName", "leftAmount", "rightName", "rightAmount"]); + const STOCK_MARKETS = Object.freeze(["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"]); + const STOCK_SOURCES = Object.freeze(["oracle", "mariaDb"]); + const MAXIMUM_STOCK_RESULTS = 200; + const DEFAULT_FADE_DURATION = 6; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const stockCodePattern = /^[A-Za-z0-9._-]{1,63}$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + + function exactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); + } + + function safeText(value, maximumLength, allowEmpty = true) { + return typeof value === "string" && value.length <= maximumLength && + (allowEmpty || value.length > 0) && !value.includes("^") && !unsafeTextPattern.test(value); + } + + function copyRows(rows) { + return rows.map(row => Object.freeze({ + leftName: row.leftName, + leftAmount: row.leftAmount, + rightName: row.rightName, + rightAmount: row.rightAmount + })); + } + + function copyItems(items) { + return items.map(item => Object.freeze({ code: item.code, name: item.name })); + } + + function rowsEqual(left, right) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && + left.every((row, index) => NET_KEYS.every(key => row[key] === right[index][key])); + } + + function itemsEqual(left, right) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && + left.every((item, index) => item.code === right[index].code && item.name === right[index].name); + } + + function emptyRows() { + return Array.from({ length: 5 }, () => ({ + leftName: "", + leftAmount: "", + rightName: "", + rightAmount: "" + })); + } + + function resolveWorkflow() { + if (root && root.MbnManualListsWorkflow) return root.MbnManualListsWorkflow; + if (typeof require === "function") return require("./manual-lists-workflow.js"); + throw new Error("MbnManualListsWorkflow must be loaded before the manual-list UI."); + } + + function normalizeStockResult(value) { + if (!exactKeys(value, ["market", "source", "name", "code"]) || + !STOCK_MARKETS.includes(value.market) || !STOCK_SOURCES.includes(value.source) || + !safeText(value.name, 200, false) || value.name !== value.name.trim() || value.name.includes(",") || + typeof value.code !== "string" || !stockCodePattern.test(value.code)) return null; + return Object.freeze({ + market: value.market, + source: value.source, + name: value.name, + code: value.code + }); + } + + function normalizeStockSearchResponse(value, expected) { + if (!exactKeys(value, [ + "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results" + ]) || !expected || value.requestId !== expected.requestId || value.query !== expected.query || + !identifierPattern.test(value.requestId) || !safeText(value.query, 100) || + typeof value.retrievedAt !== "string" || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + typeof value.truncated !== "boolean" || !Array.isArray(value.results) || + value.results.length > expected.maximumResults || value.totalRowCount < value.results.length) return null; + const results = value.results.map(normalizeStockResult); + if (!results.every(Boolean)) return null; + const identities = new Set(results.map(item => `${item.market}\u0000${item.code}\u0000${item.name}`)); + if (identities.size !== results.length) return null; + return Object.freeze({ + requestId: value.requestId, + query: value.query, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + results: Object.freeze(results) + }); + } + + function normalizeStockSearchError(value, expected) { + if (!exactKeys(value, ["requestId", "query", "message"]) || !expected || + value.requestId !== expected.requestId || value.query !== expected.query || + !identifierPattern.test(value.requestId) || !safeText(value.query, 100) || + !safeText(value.message, 512, false)) return null; + return Object.freeze({ ...value }); + } + + function createController(dependencies) { + const workflow = resolveWorkflow(); + if (!exactKeys(dependencies, [ + "postNative", "appendPlaylistEntry", "isLocked", "showToast", "addLog", "createId" + ]) || !Object.values(dependencies).every(value => typeof value === "function")) { + throw new TypeError("Closed manual-list UI dependencies are required."); + } + + const { + postNative, + appendPlaylistEntry, + isLocked, + showToast, + addLog, + createId + } = dependencies; + + const state = { + host: null, + root: null, + open: false, + mode: null, + manualLocked: false, + available: null, + quarantined: false, + quarantineMessage: "", + audience: "INDIVIDUAL", + netRows: emptyRows(), + netRevision: 0, + netVerified: null, + viItems: [], + viBaseVersion: null, + viVersion: null, + viRevision: 0, + viVerified: null, + searchQuery: "", + searchResults: [], + searchMessage: "", + pending: { + status: null, + netRead: null, + netSave: null, + viRead: null, + viSave: null, + stockSearch: null + } + }; + + function locked() { + try { + return state.manualLocked || Boolean(isLocked()); + } catch { + return true; + } + } + + function canMutate() { + return state.available === true && !state.pending.status && !locked() && !state.quarantined; + } + + function notify(message, kind = "info") { + try { showToast(message, kind); } catch { /* host notification is best effort */ } + } + + function log(message) { + try { addLog(message); } catch { /* host logging is best effort */ } + } + + function nextId() { + const value = createId(); + if (typeof value !== "string" || !identifierPattern.test(value)) { + throw new TypeError("createId returned an unsafe identifier."); + } + return value; + } + + function send(request) { + postNative(request.type, request.payload); + return request.payload.requestId; + } + + function requestStatus() { + const requestId = nextId(); + state.pending.status = { requestId }; + send(workflow.createStatusRequest(requestId)); + } + + function requestNetRead(audience, verification = null) { + state.netVerified = null; + const requestId = nextId(); + state.pending.netRead = { + requestId, + audience, + revision: state.netRevision, + verification + }; + send(workflow.createNetSellReadRequest(requestId, audience)); + } + + function requestViRead(verification = null) { + state.viVersion = null; + state.viVerified = null; + const requestId = nextId(); + state.pending.viRead = { + requestId, + revision: state.viRevision, + verification + }; + send(workflow.createViReadRequest(requestId)); + } + + function invalidateNetVerification() { + state.netRevision += 1; + state.netVerified = null; + } + + function invalidateViVerification() { + state.viRevision += 1; + state.viVersion = null; + state.viVerified = null; + } + + function setAudience(audience) { + const normalized = workflow.normalizeAudience(audience); + if (!normalized) throw new RangeError("Unknown FSell audience."); + state.audience = normalized; + state.netRows = emptyRows(); + invalidateNetVerification(); + requestNetRead(normalized); + render(); + } + + function setNetCell(rowIndex, key, value) { + if (locked() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 || + !NET_KEYS.includes(key) || typeof value !== "string" || value.length > 256) return false; + state.netRows[rowIndex][key] = value; + invalidateNetVerification(); + updateGateButtons(); + return true; + } + + function saveNetSell() { + if (!canMutate() || state.pending.netRead || state.pending.netSave) return false; + let request; + try { + request = workflow.createNetSellSaveRequest(nextId(), state.audience, state.netRows); + } catch { + notify("순매도 입력 5행의 값을 확인하세요.", "error"); + return false; + } + const snapshot = copyRows(request.payload.rows); + state.pending.netSave = { + requestId: request.payload.requestId, + audience: request.payload.audience, + rows: snapshot, + revision: state.netRevision + }; + state.netVerified = null; + send(request); + render(); + return true; + } + + function addNetSellCut() { + const verified = state.netVerified; + if (!canMutate() || !verified || verified.audience !== state.audience || + !rowsEqual(verified.rows, state.netRows)) return false; + try { + const entry = workflow.createNetSellPlaylistEntry(state.audience, { + id: nextId(), + fadeDuration: DEFAULT_FADE_DURATION + }); + appendPlaylistEntry(entry); + notify("수동 순매도 컷을 playlist에 추가했습니다.", "success"); + log(`수동 순매도 컷 추가 · ${state.audience}`); + return true; + } catch { + notify("수동 순매도 컷을 추가하지 못했습니다.", "error"); + return false; + } + } + + function searchStocks(query = state.searchQuery) { + const normalizedQuery = typeof query === "string" ? query.trim() : ""; + if (!safeText(normalizedQuery, 100)) return false; + const requestId = nextId(); + const request = { + requestId, + query: normalizedQuery, + maximumResults: MAXIMUM_STOCK_RESULTS + }; + state.searchQuery = normalizedQuery; + state.searchResults = []; + state.searchMessage = "검색 중…"; + state.pending.stockSearch = request; + postNative("search-stocks", request); + render(); + return true; + } + + function toViItem(stock) { + const prefix = stock.market.endsWith("kospi") ? "P" : "D"; + return workflow.normalizeViItem({ code: `${prefix}${stock.code}`, name: stock.name }); + } + + function addSearchResult(index) { + if (locked() || !Number.isInteger(index) || index < 0 || + index >= state.searchResults.length || state.viItems.length >= workflow.maximumViItems) return false; + const item = toViItem(state.searchResults[index]); + if (!item) return false; + state.viItems.push({ code: item.code, name: item.name }); + invalidateViVerification(); + render(); + return true; + } + + function removeViItem(index) { + if (locked() || !Number.isInteger(index) || index < 0 || index >= state.viItems.length) return false; + state.viItems.splice(index, 1); + invalidateViVerification(); + render(); + return true; + } + + function moveViItem(index, delta) { + if (locked() || !Number.isInteger(index) || ![-1, 1].includes(delta)) return false; + const target = index + delta; + if (index < 0 || index >= state.viItems.length || target < 0 || target >= state.viItems.length) return false; + [state.viItems[index], state.viItems[target]] = [state.viItems[target], state.viItems[index]]; + invalidateViVerification(); + render(); + return true; + } + + function clearViItems() { + if (locked() || state.viItems.length === 0) return false; + state.viItems = []; + invalidateViVerification(); + render(); + return true; + } + + function saveVi() { + if (!canMutate() || !state.viBaseVersion || state.pending.viRead || state.pending.viSave) return false; + let request; + try { + request = workflow.createViSaveRequest(nextId(), state.viItems, state.viBaseVersion); + } catch { + notify("VI 목록 값을 확인하세요.", "error"); + return false; + } + state.pending.viSave = { + requestId: request.payload.requestId, + expectedVersion: request.payload.expectedVersion, + items: copyItems(request.payload.items), + revision: state.viRevision + }; + state.viVerified = null; + send(request); + render(); + return true; + } + + function addViCut() { + const verified = state.viVerified; + if (!canMutate() || state.viItems.length === 0 || !verified || + verified.version !== state.viVersion || !itemsEqual(verified.items, state.viItems)) return false; + try { + const entry = workflow.createViPlaylistEntry(state.viItems, state.viVersion, { + id: nextId(), + fadeDuration: DEFAULT_FADE_DURATION + }); + appendPlaylistEntry(entry); + notify("VI 발동 컷을 playlist에 추가했습니다.", "success"); + log(`VI 발동 컷 추가 · ${state.viItems.length}종목 · ${entry.pageCount}페이지`); + return true; + } catch { + notify("최신 VI 목록을 다시 조회한 뒤 추가하세요.", "error"); + return false; + } + } + + function handleStatus(payload) { + const pending = state.pending.status; + if (!pending) return false; + const normalized = workflow.normalizeStatusResponse(payload, pending.requestId); + if (!normalized) return false; + state.pending.status = null; + state.available = normalized.available; + if (normalized.writeQuarantined) { + state.quarantined = true; + state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다."; + } + render(); + return true; + } + + function handleNetData(payload) { + const pending = state.pending.netRead; + if (!pending) return false; + const normalized = workflow.normalizeNetSellDataResponse(payload, pending); + if (!normalized) return false; + state.pending.netRead = null; + if (pending.audience !== state.audience || pending.revision !== state.netRevision) return true; + state.netRows = copyRows(normalized.rows).map(row => ({ ...row })); + if (pending.verification && + pending.verification.audience === state.audience && + rowsEqual(pending.verification.rows, normalized.rows)) { + state.netVerified = { + audience: state.audience, + rows: copyRows(normalized.rows) + }; + notify("저장 파일 재조회가 입력과 일치합니다.", "success"); + } else { + state.netVerified = null; + } + render(); + return true; + } + + function handleNetSave(payload) { + const pending = state.pending.netSave; + if (!pending) return false; + const normalized = workflow.normalizeNetSellSaveResponse(payload, pending); + if (!normalized) return false; + state.pending.netSave = null; + requestNetRead(pending.audience, { + audience: pending.audience, + rows: pending.rows + }); + notify("저장했습니다. 파일을 다시 확인하고 있습니다.", "success"); + render(); + return true; + } + + function handleViData(payload) { + const pending = state.pending.viRead; + if (!pending) return false; + const normalized = workflow.normalizeViDataResponse(payload, pending.requestId); + if (!normalized) return false; + state.pending.viRead = null; + if (pending.revision !== state.viRevision) return true; + + state.viItems = copyItems(normalized.items).map(item => ({ ...item })); + state.viBaseVersion = normalized.version; + state.viVersion = normalized.version; + const verification = pending.verification; + if (verification && verification.persisted && + (verification.version !== normalized.version || + !itemsEqual(verification.items, normalized.items))) { + state.viVerified = null; + notify("저장 후 VI 파일 내용이 달라 컷 추가를 차단했습니다.", "error"); + } else { + state.viVerified = { + version: normalized.version, + items: copyItems(normalized.items) + }; + if (verification) { + notify( + verification.persisted + ? "저장 파일 재조회가 VI 입력과 일치합니다." + : "빈 목록은 원본 동작에 따라 파일을 변경하지 않았습니다.", + "success"); + } + } + render(); + return true; + } + + function handleViSave(payload) { + const pending = state.pending.viSave; + if (!pending) return false; + const normalized = workflow.normalizeViSaveResponse(payload, pending.requestId); + if (!normalized) return false; + state.pending.viSave = null; + requestViRead({ + items: pending.items, + version: normalized.version, + persisted: normalized.persisted + }); + notify( + normalized.persisted + ? "VI 목록을 저장했습니다. 파일을 다시 확인하고 있습니다." + : "빈 VI 목록은 원본 동작에 따라 저장 파일을 변경하지 않았습니다.", + "success"); + render(); + return true; + } + + const pendingOperations = Object.freeze({ + status: "status", + netRead: "read-net-sell", + netSave: "save-net-sell", + viRead: "read-vi", + viSave: "save-vi" + }); + + function handleManualError(payload) { + for (const [key, operation] of Object.entries(pendingOperations)) { + const pending = state.pending[key]; + if (!pending || payload?.requestId !== pending.requestId) continue; + const normalized = workflow.normalizeError(payload, pending.requestId); + if (!normalized || normalized.operation !== operation) return false; + state.pending[key] = null; + if (key === "status") state.available = false; + if (key === "viSave" && normalized.code === "STALE_DATA") { + state.viBaseVersion = null; + state.viVersion = null; + state.viVerified = null; + } + if (normalized.outcomeUnknown) { + state.quarantined = true; + state.quarantineMessage = normalized.message; + state.netVerified = null; + state.viVerified = null; + notify("저장 결과가 불명확하여 앱 재시작 전까지 재저장을 차단합니다.", "error"); + } else { + notify(normalized.message, "error"); + } + log(`수동 목록 오류 · ${normalized.operation} · ${normalized.code}`); + render(); + return true; + } + return false; + } + + function handleStockResults(payload) { + const pending = state.pending.stockSearch; + if (!pending) return false; + const normalized = normalizeStockSearchResponse(payload, pending); + if (!normalized) return false; + state.pending.stockSearch = null; + state.searchResults = [...normalized.results]; + state.searchMessage = normalized.results.length === 0 + ? "검색 결과가 없습니다." + : `${normalized.results.length}건${normalized.truncated ? " · 일부 결과" : ""}`; + render(); + return true; + } + + function handleStockError(payload) { + const pending = state.pending.stockSearch; + if (!pending) return false; + const normalized = normalizeStockSearchError(payload, pending); + if (!normalized) return false; + state.pending.stockSearch = null; + state.searchResults = []; + state.searchMessage = normalized.message; + notify(normalized.message, "error"); + render(); + return true; + } + + function handleMessage(type, payload) { + switch (type) { + case workflow.bridgeContract.statusResultType: return handleStatus(payload); + case workflow.bridgeContract.netSellDataType: return handleNetData(payload); + case workflow.bridgeContract.netSellSaveResultType: return handleNetSave(payload); + case workflow.bridgeContract.viDataType: return handleViData(payload); + case workflow.bridgeContract.viSaveResultType: return handleViSave(payload); + case workflow.bridgeContract.errorType: return handleManualError(payload); + case "stock-search-results": return handleStockResults(payload); + case "stock-search-error": return handleStockError(payload); + default: return false; + } + } + + function element(tag, className, text) { + const node = state.root.ownerDocument.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function button(text, action, options = {}) { + const node = element("button", options.className || "manual-lists-button", text); + node.type = "button"; + node.disabled = Boolean(options.disabled); + if (options.title) node.title = options.title; + node.addEventListener("click", action); + return node; + } + + function renderHeader(panel) { + const header = element("header", "manual-lists-header"); + const title = element( + "h2", + "manual-lists-title", + state.mode === "net" ? "수동 순매도 상위" : "VI 발동 목록"); + const close = button("닫기", () => { + state.open = false; + render(); + }, { className: "manual-lists-close" }); + header.append(title, close); + panel.append(header); + } + + function renderBanner(panel) { + if (state.quarantined) { + const banner = element( + "div", + "manual-lists-banner manual-lists-banner-danger", + state.quarantineMessage || "저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다."); + banner.setAttribute("role", "alert"); + panel.append(banner); + } else if (locked()) { + panel.append(element( + "div", + "manual-lists-banner", + "PREPARE 또는 송출 중에는 목록을 변경할 수 없습니다.")); + } else if (state.available === false) { + panel.append(element( + "div", + "manual-lists-banner manual-lists-banner-danger", + "수동 데이터 저장소를 사용할 수 없습니다.")); + } + } + + function renderNet(panel) { + const tabs = element("div", "manual-lists-tabs"); + for (const audience of workflow.audiences) { + const tab = button(workflow.audienceLabels[audience], () => setAudience(audience), { + className: audience === state.audience + ? "manual-lists-tab is-active" + : "manual-lists-tab", + disabled: Boolean(state.pending.netSave) + }); + tab.setAttribute("aria-pressed", String(audience === state.audience)); + tabs.append(tab); + } + panel.append(tabs); + + const table = element("table", "manual-lists-table manual-lists-net-table"); + const head = element("thead"); + const headRow = element("tr"); + for (const text of ["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"]) { + headRow.append(element("th", "", text)); + } + head.append(headRow); + table.append(head); + const body = element("tbody"); + state.netRows.forEach((row, rowIndex) => { + const tr = element("tr"); + tr.append(element("th", "manual-lists-row-number", String(rowIndex + 1))); + for (const key of NET_KEYS) { + const td = element("td"); + const input = element("input", "manual-lists-input"); + input.type = "text"; + input.maxLength = 256; + input.value = row[key]; + input.disabled = locked() || Boolean(state.pending.netSave); + input.setAttribute("aria-label", `${rowIndex + 1}행 ${key}`); + input.addEventListener("input", event => setNetCell(rowIndex, key, event.currentTarget.value)); + td.append(input); + tr.append(td); + } + body.append(tr); + }); + table.append(body); + const tableWrap = element("div", "manual-lists-table-wrap"); + tableWrap.append(table); + panel.append(tableWrap); + + const actions = element("div", "manual-lists-actions"); + actions.append( + button("재조회", () => requestNetRead(state.audience), { + disabled: Boolean(state.pending.netRead || state.pending.netSave) + }), + button("저장", saveNetSell, { + className: "manual-lists-button is-primary", + disabled: !canMutate() || Boolean(state.pending.netRead || state.pending.netSave) + }), + button("s5025 추가", addNetSellCut, { + className: "manual-lists-button is-accent", + disabled: !canMutate() || !state.netVerified || + !rowsEqual(state.netVerified.rows, state.netRows) + }) + ); + panel.append(actions); + } + + function renderSearch(panel) { + const section = element("section", "manual-lists-search"); + const controls = element("div", "manual-lists-search-controls"); + const input = element("input", "manual-lists-input manual-lists-search-input"); + input.type = "search"; + input.maxLength = 100; + input.placeholder = "종목명 검색"; + input.value = state.searchQuery; + input.addEventListener("input", event => { state.searchQuery = event.currentTarget.value; }); + input.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + searchStocks(event.currentTarget.value); + } + }); + controls.append(input, button("검색", () => searchStocks(input.value), { + disabled: Boolean(state.pending.stockSearch) + })); + section.append(controls); + section.append(element("p", "manual-lists-search-state", state.searchMessage)); + const results = element("div", "manual-lists-search-results"); + state.searchResults.forEach((stock, index) => { + results.append(button( + `${stock.name} · ${stock.code} · ${stock.market}`, + () => addSearchResult(index), + { + className: "manual-lists-search-result", + disabled: locked() || state.viItems.length >= workflow.maximumViItems + })); + }); + section.append(results); + panel.append(section); + } + + function renderVi(panel) { + renderSearch(panel); + const summary = element( + "div", + "manual-lists-summary", + `${state.viItems.length}/${workflow.maximumViItems}종목 · ${state.viItems.length === 0 ? 0 : Math.ceil(state.viItems.length / workflow.viPageSize)}페이지`); + panel.append(summary); + + const tableWrap = element("div", "manual-lists-table-wrap"); + const table = element("table", "manual-lists-table manual-lists-vi-table"); + const head = element("thead"); + const headRow = element("tr"); + for (const text of ["#", "코드", "종목명", "순서/삭제"]) headRow.append(element("th", "", text)); + head.append(headRow); + table.append(head); + const body = element("tbody"); + state.viItems.forEach((item, index) => { + const row = element("tr"); + row.append( + element("td", "manual-lists-row-number", String(index + 1)), + element("td", "manual-lists-code", item.code), + element("td", "manual-lists-name", item.name) + ); + const controls = element("td", "manual-lists-row-actions"); + controls.append( + button("↑", () => moveViItem(index, -1), { + disabled: locked() || index === 0, + title: "위로" + }), + button("↓", () => moveViItem(index, 1), { + disabled: locked() || index === state.viItems.length - 1, + title: "아래로" + }), + button("삭제", () => removeViItem(index), { disabled: locked() }) + ); + row.append(controls); + body.append(row); + }); + table.append(body); + tableWrap.append(table); + panel.append(tableWrap); + + const actions = element("div", "manual-lists-actions"); + actions.append( + button("전체 삭제", clearViItems, { + disabled: locked() || state.viItems.length === 0 + }), + button("재조회", () => requestViRead(), { + disabled: Boolean(state.pending.viRead || state.pending.viSave) + }), + button("저장", saveVi, { + className: "manual-lists-button is-primary", + disabled: !canMutate() || !state.viBaseVersion || + Boolean(state.pending.viRead || state.pending.viSave) + }), + button("s5074 추가", addViCut, { + className: "manual-lists-button is-accent", + disabled: !canMutate() || state.viItems.length === 0 || !state.viVerified || + state.viVerified.version !== state.viVersion || + !itemsEqual(state.viVerified.items, state.viItems) + }) + ); + panel.append(actions); + } + + function updateGateButtons() { + if (!state.root) return; + const add = state.root.querySelector(".manual-lists-button.is-accent"); + if (!add) return; + add.disabled = state.mode === "net" + ? !canMutate() || !state.netVerified || !rowsEqual(state.netVerified.rows, state.netRows) + : !canMutate() || state.viItems.length === 0 || !state.viVerified || + state.viVerified.version !== state.viVersion || !itemsEqual(state.viVerified.items, state.viItems); + } + + function snapshot() { + return Object.freeze({ + open: state.open, + mode: state.mode, + locked: locked(), + available: state.available, + quarantined: state.quarantined, + audience: state.audience, + netRows: Object.freeze(copyRows(state.netRows)), + netVerified: Boolean(state.netVerified && rowsEqual(state.netVerified.rows, state.netRows)), + viItems: Object.freeze(copyItems(state.viItems)), + viBaseVersion: state.viBaseVersion, + viVersion: state.viVersion, + viVerified: Boolean(state.viVerified && state.viVerified.version === state.viVersion && + itemsEqual(state.viVerified.items, state.viItems)), + searchResults: Object.freeze(state.searchResults.map(item => ({ ...item }))), + pending: Object.freeze(Object.fromEntries( + Object.entries(state.pending).map(([key, value]) => [key, value?.requestId || null]))) + }); + } + + function render() { + if (!state.root) return snapshot(); + state.root.hidden = !state.open; + state.root.replaceChildren(); + if (!state.open) return snapshot(); + const backdrop = element("div", "manual-lists-backdrop"); + const panel = element("section", "manual-lists-dialog"); + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-modal", "true"); + renderHeader(panel); + renderBanner(panel); + if (state.mode === "net") renderNet(panel); + if (state.mode === "vi") renderVi(panel); + backdrop.append(panel); + state.root.append(backdrop); + return snapshot(); + } + + function mount(host) { + const resolvedHost = host || root?.document?.body; + if (!resolvedHost || typeof resolvedHost.append !== "function" || !resolvedHost.ownerDocument) { + throw new TypeError("A DOM host is required to mount the manual-list UI."); + } + if (state.root?.parentNode) state.root.remove(); + state.host = resolvedHost; + state.root = resolvedHost.ownerDocument.createElement("div"); + state.root.className = "manual-lists-root"; + state.root.hidden = true; + resolvedHost.append(state.root); + render(); + return state.root; + } + + function openNetSell(audience = state.audience) { + const normalized = workflow.normalizeAudience(audience); + if (!normalized) throw new RangeError("Unknown FSell audience."); + state.open = true; + state.mode = "net"; + state.audience = normalized; + state.netRows = emptyRows(); + invalidateNetVerification(); + requestStatus(); + requestNetRead(normalized); + render(); + return snapshot(); + } + + function openVi() { + state.open = true; + state.mode = "vi"; + requestStatus(); + requestViRead(); + render(); + return snapshot(); + } + + function setLocked(value) { + state.manualLocked = Boolean(value); + render(); + return state.manualLocked; + } + + const actions = Object.freeze({ + setAudience, + setNetCell, + saveNetSell, + addNetSellCut, + requestNetRead: () => requestNetRead(state.audience), + searchStocks, + addSearchResult, + removeViItem, + moveViItem, + clearViItems, + saveVi, + addViCut, + requestViRead: () => requestViRead(), + close: () => { + state.open = false; + render(); + } + }); + + return Object.freeze({ + mount, + openNetSell, + openVi, + handleMessage, + render, + setLocked, + actions, + getState: snapshot + }); + } + + return { createController }; +}); diff --git a/Web/manual-lists-workflow.js b/Web/manual-lists-workflow.js new file mode 100644 index 0000000..cab2c19 --- /dev/null +++ b/Web/manual-lists-workflow.js @@ -0,0 +1,390 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnManualListsWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const SCHEMA_VERSION = 1; + const MAXIMUM_VI_ITEMS = 100; + const VI_PAGE_SIZE = 5; + const MAXIMUM_PAGE_COUNT = 20; + const VI_SNAPSHOT_SUBJECT = "@MBN_VI_SNAPSHOT_V1"; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const rowVersionPattern = /^[a-f0-9]{64}$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + const audiences = Object.freeze(["INDIVIDUAL", "FOREIGN", "INSTITUTION"]); + const audienceLabels = Object.freeze({ + INDIVIDUAL: "개인", + FOREIGN: "외국인", + INSTITUTION: "기관" + }); + + const bridgeContract = Object.freeze({ + statusRequestType: "request-manual-operator-data-status", + statusResultType: "manual-operator-data-status", + netSellReadType: "request-manual-net-sell-data", + netSellDataType: "manual-net-sell-data", + netSellSaveType: "save-manual-net-sell-data", + netSellSaveResultType: "manual-net-sell-save-result", + viReadType: "request-vi-manual-list", + viDataType: "vi-manual-list-data", + viSaveType: "save-vi-manual-list", + viSaveResultType: "vi-manual-list-save-result", + errorType: "manual-operator-data-error" + }); + + function hasExactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); + } + + function isRequestId(value) { + return typeof value === "string" && identifierPattern.test(value); + } + + function isRowVersion(value) { + return typeof value === "string" && rowVersionPattern.test(value); + } + + function isSafeText(value, maximumLength, allowEmpty = true) { + return typeof value === "string" && + (allowEmpty || value.length > 0) && + value.length <= maximumLength && + !value.includes("^") && + !unsafeTextPattern.test(value); + } + + function normalizeAudience(value) { + return typeof value === "string" && audiences.includes(value) ? value : null; + } + + function normalizeNetSellRow(value) { + if (!hasExactKeys(value, ["leftName", "leftAmount", "rightName", "rightAmount"])) return null; + const keys = ["leftName", "leftAmount", "rightName", "rightAmount"]; + if (!keys.every(key => isSafeText(value[key], 256))) return null; + return Object.freeze({ + leftName: value.leftName, + leftAmount: value.leftAmount, + rightName: value.rightName, + rightAmount: value.rightAmount + }); + } + + function normalizeNetSellRows(value) { + if (!Array.isArray(value) || value.length !== 5) return null; + const rows = value.map(normalizeNetSellRow); + return rows.every(Boolean) ? Object.freeze(rows) : null; + } + + function normalizeViItem(value) { + if (!hasExactKeys(value, ["code", "name"]) || + !isSafeText(value.code, 64, false) || + !isSafeText(value.name, 200, false) || + value.code !== value.code.trim() || value.name !== value.name.trim() || + value.name.includes(",")) return null; + return Object.freeze({ code: value.code, name: value.name }); + } + + function normalizeViItems(value, allowEmpty = true) { + if (!Array.isArray(value) || value.length > MAXIMUM_VI_ITEMS || (!allowEmpty && value.length === 0)) { + return null; + } + const items = value.map(normalizeViItem); + return items.every(Boolean) ? Object.freeze(items) : null; + } + + function request(type, requestId, extra = {}) { + if (!isRequestId(requestId)) throw new TypeError("A safe manual-data request id is required."); + return Object.freeze({ type, payload: Object.freeze({ requestId, ...extra }) }); + } + + function createStatusRequest(requestId) { + return request(bridgeContract.statusRequestType, requestId); + } + + function createNetSellReadRequest(requestId, audience) { + const normalized = normalizeAudience(audience); + if (!normalized) throw new RangeError("The manual net-sell audience is invalid."); + return request(bridgeContract.netSellReadType, requestId, { audience: normalized }); + } + + function createNetSellSaveRequest(requestId, audience, rows) { + const normalizedAudience = normalizeAudience(audience); + const normalizedRows = normalizeNetSellRows(rows); + if (!normalizedAudience || !normalizedRows) throw new TypeError("Five valid manual net-sell rows are required."); + return request(bridgeContract.netSellSaveType, requestId, { + audience: normalizedAudience, + rows: normalizedRows + }); + } + + function createViReadRequest(requestId) { + return request(bridgeContract.viReadType, requestId); + } + + function createViSaveRequest(requestId, items, expectedVersion) { + const normalized = normalizeViItems(items); + if (!normalized || !isRowVersion(expectedVersion)) { + throw new TypeError("A valid VI manual list and its last-read version are required."); + } + return request(bridgeContract.viSaveType, requestId, { + expectedVersion, + items: normalized + }); + } + + function normalizeStatusResponse(value, expectedRequestId) { + if (!hasExactKeys(value, ["requestId", "available", "writeQuarantined", "message"]) || + !isRequestId(value.requestId) || + typeof value.available !== "boolean" || + typeof value.writeQuarantined !== "boolean" || + !(value.message === null || isSafeText(value.message, 512, false)) || + (expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null; + return Object.freeze({ ...value }); + } + + function normalizeNetSellDataResponse(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "audience", "rows"]) || + !isRequestId(value.requestId) || + !normalizeAudience(value.audience) || + (expectedRequest && + (value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null; + const rows = normalizeNetSellRows(value.rows); + return rows && Object.freeze({ requestId: value.requestId, audience: value.audience, rows }); + } + + function normalizeNetSellSaveResponse(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "audience", "saved", "recovered"]) || + !isRequestId(value.requestId) || + !normalizeAudience(value.audience) || + value.saved !== true || typeof value.recovered !== "boolean" || + (expectedRequest && + (value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null; + return Object.freeze({ ...value }); + } + + function normalizeViDataResponse(value, expectedRequestId) { + if (!hasExactKeys(value, ["requestId", "itemCount", "pageCount", "items", "version"]) || + !isRequestId(value.requestId) || + !isRowVersion(value.version) || + !Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS || + !Number.isInteger(value.pageCount) || value.pageCount < 0 || value.pageCount > MAXIMUM_PAGE_COUNT || + value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) || + (expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null; + const items = normalizeViItems(value.items); + if (!items || items.length !== value.itemCount) return null; + return Object.freeze({ + requestId: value.requestId, + itemCount: value.itemCount, + pageCount: value.pageCount, + items, + version: value.version + }); + } + + function normalizeViSaveResponse(value, expectedRequestId) { + if (!hasExactKeys(value, [ + "requestId", "saved", "recovered", "persisted", "itemCount", "pageCount", "version" + ]) || + !isRequestId(value.requestId) || value.saved !== true || typeof value.recovered !== "boolean" || + typeof value.persisted !== "boolean" || !isRowVersion(value.version) || + !Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS || + !Number.isInteger(value.pageCount) || + value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) || + (expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null; + return Object.freeze({ ...value }); + } + + function normalizeError(value, expectedRequestId) { + if (!hasExactKeys(value, ["requestId", "operation", "code", "message", "retryable", "outcomeUnknown"]) || + !(value.requestId === "" || isRequestId(value.requestId)) || + !isSafeText(value.operation, 64, false) || + !isSafeText(value.code, 64, false) || + !isSafeText(value.message, 512, false) || + typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" || + (value.outcomeUnknown && value.retryable) || + (expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null; + return Object.freeze({ ...value }); + } + + function requireOptions(options) { + if (!hasExactKeys(options, ["id", "fadeDuration"]) || !isRequestId(options.id) || + !Number.isInteger(options.fadeDuration) || options.fadeDuration < 0 || options.fadeDuration > 60) { + throw new TypeError("Safe manual-list playlist options are required."); + } + return options; + } + + function createNetSellPlaylistEntry(audience, options) { + const normalizedAudience = normalizeAudience(audience); + const normalizedOptions = requireOptions(options); + if (!normalizedAudience) throw new RangeError("The manual net-sell audience is invalid."); + const label = audienceLabels[normalizedAudience]; + const selection = Object.freeze({ + groupCode: normalizedAudience, + subject: "INDEX", + graphicType: "MANUAL_NET_SELL", + subtype: "MANUAL_NET_SELL", + dataCode: "" + }); + return { + id: normalizedOptions.id, + builderKey: "s5025", + code: "5025", + aliases: ["5025"], + title: `${label} 순매도 상위(수동)`, + detail: "매매동향 · 검증된 수동 입력 5행", + category: "market", + market: "index", + selection, + enabled: true, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: selection.graphicType, + subtype: selection.subtype, + operator: Object.freeze({ + source: "legacy-manual-lists-workflow", + schemaVersion: SCHEMA_VERSION, + kind: "net-sell", + audience: normalizedAudience + }) + }; + } + + function createViPlaylistEntry(items, rowVersion, options) { + const normalizedItems = normalizeViItems(items, false); + const normalizedOptions = requireOptions(options); + if (!normalizedItems || !isRowVersion(rowVersion)) { + throw new TypeError("A versioned non-empty VI snapshot is required."); + } + const pageCount = Math.ceil(normalizedItems.length / VI_PAGE_SIZE); + const selection = Object.freeze({ + groupCode: "PAGED_VI", + subject: VI_SNAPSHOT_SUBJECT, + graphicType: "INPUT_ORDER", + subtype: "CURRENT", + dataCode: rowVersion + }); + return { + id: normalizedOptions.id, + builderKey: "s5074", + code: "5074", + aliases: ["5074"], + title: normalizedItems.map(item => item.name).join(", "), + detail: "5단 표그래프 · VI 발동(수동)", + category: "plate", + market: "index", + itemCount: normalizedItems.length, + pageSize: VI_PAGE_SIZE, + pageCount, + selection, + enabled: true, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: selection.graphicType, + subtype: selection.subtype, + operator: Object.freeze({ + source: "legacy-manual-lists-workflow", + schemaVersion: SCHEMA_VERSION, + kind: "vi", + items: normalizedItems, + rowVersion, + pagePreview: Object.freeze({ + itemCount: normalizedItems.length, + pageSize: VI_PAGE_SIZE, + pageCount, + maximumPageCount: MAXIMUM_PAGE_COUNT + }) + }) + }; + } + + function sameSelection(left, right) { + const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"]; + return hasExactKeys(left, keys) && hasExactKeys(right, keys) && keys + .every(key => typeof left[key] === "string" && left[key] === right[key]); + } + + function restorePlaylistEntry(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null; + const operator = value.operator; + if (!operator || operator.source !== "legacy-manual-lists-workflow" || + operator.schemaVersion !== SCHEMA_VERSION || !["net-sell", "vi"].includes(operator.kind)) return null; + const topKeys = operator.kind === "net-sell" + ? [ + "id", "builderKey", "code", "aliases", "title", "detail", "category", "market", + "selection", "enabled", "fadeDuration", "graphicType", "subtype", "operator" + ] + : [ + "id", "builderKey", "code", "aliases", "title", "detail", "category", "market", + "itemCount", "pageSize", "pageCount", "selection", "enabled", "fadeDuration", + "graphicType", "subtype", "operator" + ]; + const operatorKeys = operator.kind === "net-sell" + ? ["source", "schemaVersion", "kind", "audience"] + : ["source", "schemaVersion", "kind", "items", "rowVersion", "pagePreview"]; + if (!hasExactKeys(value, topKeys) || !hasExactKeys(operator, operatorKeys)) return null; + if (operator.kind === "vi" && + (!isRowVersion(operator.rowVersion) || + !hasExactKeys(operator.pagePreview, ["itemCount", "pageSize", "pageCount", "maximumPageCount"]))) { + return null; + } + let recreated; + try { + const options = { id: value.id, fadeDuration: value.fadeDuration }; + recreated = operator.kind === "net-sell" + ? createNetSellPlaylistEntry(operator.audience, options) + : createViPlaylistEntry(operator.items, operator.rowVersion, options); + } catch { + return null; + } + const scalars = [ + "id", "builderKey", "code", "title", "detail", "category", "market", + "fadeDuration", "graphicType", "subtype" + ]; + if (!scalars.every(key => value[key] === recreated[key]) || + !Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.aliases[0] || + !sameSelection(value.selection, recreated.selection)) return null; + if (operator.kind === "vi" && + (value.itemCount !== recreated.itemCount || value.pageSize !== recreated.pageSize || + value.pageCount !== recreated.pageCount || + operator.pagePreview.itemCount !== recreated.operator.pagePreview.itemCount || + operator.pagePreview.pageSize !== recreated.operator.pagePreview.pageSize || + operator.pagePreview.pageCount !== recreated.operator.pagePreview.pageCount || + operator.pagePreview.maximumPageCount !== recreated.operator.pagePreview.maximumPageCount)) return null; + return { ...recreated, enabled: value.enabled }; + } + + return { + bridgeContract, + audiences, + audienceLabels, + maximumViItems: MAXIMUM_VI_ITEMS, + viPageSize: VI_PAGE_SIZE, + maximumPageCount: MAXIMUM_PAGE_COUNT, + viSnapshotSubject: VI_SNAPSHOT_SUBJECT, + normalizeAudience, + normalizeNetSellRow, + normalizeNetSellRows, + normalizeViItem, + normalizeViItems, + createStatusRequest, + createNetSellReadRequest, + createNetSellSaveRequest, + createViReadRequest, + createViSaveRequest, + normalizeStatusResponse, + normalizeNetSellDataResponse, + normalizeNetSellSaveResponse, + normalizeViDataResponse, + normalizeViSaveResponse, + normalizeError, + createNetSellPlaylistEntry, + createViPlaylistEntry, + restorePlaylistEntry, + refreshPlaylistEntry: restorePlaylistEntry + }; +}); diff --git a/Web/named-manual-restore-workflow.js b/Web/named-manual-restore-workflow.js new file mode 100644 index 0000000..4e6d7ee --- /dev/null +++ b/Web/named-manual-restore-workflow.js @@ -0,0 +1,204 @@ +(function (root, factory) { + "use strict"; + + const manualLists = typeof module === "object" && module.exports + ? require("./manual-lists-workflow.js") + : root.MbnManualListsWorkflow; + const manualFinancial = typeof module === "object" && module.exports + ? require("./manual-financial-workflow.js") + : root.MbnManualFinancialWorkflow; + const api = Object.freeze(factory(manualLists, manualFinancial)); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnNamedManualRestoreWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function (manualLists, manualFinancial) { + "use strict"; + + if (!manualLists || !manualFinancial) throw new Error("Manual restore dependencies are unavailable."); + + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const versionPattern = /^[a-f0-9]{64}$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + const pendingValues = new WeakSet(); + const legacyAudienceByGroup = Object.freeze({ + "개인 순매도 상위(수동)": "INDIVIDUAL", + "외국인 순매도 상위(수동)": "FOREIGN", + "기관 순매도 상위(수동)": "INSTITUTION" + }); + const marketByGroup = Object.freeze({ + KOSPI: "kospi", KOSDAQ: "kosdaq", NXT_KOSPI: "nxt-kospi", NXT_KOSDAQ: "nxt-kosdaq", + "코스피": "kospi", "코스닥": "kosdaq", "코스피_NXT": "nxt-kospi", "코스닥_NXT": "nxt-kosdaq" + }); + const financialByGraphic = Object.freeze(Object.fromEntries( + manualFinancial.sourceContract.screens.flatMap(screen => [ + [screen.graphicType, screen], + [screen.label, screen] + ]))); + + function safeText(value, maximum, allowEmpty = false) { + return typeof value === "string" && value.length <= maximum && + (allowEmpty || value.length > 0) && value === value.trim() && + !value.includes("^") && !unsafeTextPattern.test(value); + } + + function selection(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw) || + typeof raw.enabled !== "boolean" || !Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 || + !safeText(raw.groupCode, 256, true) || !safeText(raw.subject, 4096, true) || + !safeText(raw.graphicType, 256, true) || !safeText(raw.subtype, 256, true) || + !safeText(raw.dataCode, 1024, true)) return null; + return Object.freeze({ + groupCode: raw.groupCode, + subject: raw.subject, + graphicType: raw.graphicType, + subtype: raw.subtype, + dataCode: raw.dataCode + }); + } + + function options(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || + !identifierPattern.test(value.id) || !Number.isInteger(value.fadeDuration) || + value.fadeDuration < 0 || value.fadeDuration > 60) return null; + return Object.freeze({ id: value.id, fadeDuration: value.fadeDuration }); + } + + function exactHistoricalViNames(subject) { + if (!safeText(subject, 4096) || subject.startsWith("@MBN_VI_SNAPSHOT_")) return null; + const names = subject.split(","); + if (names.length < 1 || names.length > manualLists.maximumViItems || + names.some(name => !safeText(name, 200) || name !== name.trim()) || + new Set(names).size !== names.length) return null; + return Object.freeze(names); + } + + function classify(raw, rawOptions) { + const selected = selection(raw); + const normalizedOptions = options(rawOptions); + if (!selected || !normalizedOptions) return null; + let descriptor = null; + + const canonicalAudience = manualLists.normalizeAudience(selected.groupCode); + const legacyAudience = legacyAudienceByGroup[selected.groupCode] || null; + if ((canonicalAudience && selected.subject === "INDEX" && + selected.graphicType === "MANUAL_NET_SELL" && selected.subtype === "MANUAL_NET_SELL") || + (legacyAudience && selected.subject === "" && + selected.graphicType === "순매도 상위" && selected.subtype === "순매도 상위")) { + if (selected.dataCode !== "") return null; + descriptor = { + kind: "net-sell", + audience: canonicalAudience || legacyAudience, + historical: Boolean(legacyAudience) + }; + } else if (selected.groupCode === "PAGED_VI" && selected.graphicType === "INPUT_ORDER" && + selected.subtype === "CURRENT") { + if (selected.subject === manualLists.viSnapshotSubject && versionPattern.test(selected.dataCode)) { + descriptor = { kind: "vi", expectedVersion: selected.dataCode, historicalNames: null }; + } else if (selected.dataCode === "") { + const names = exactHistoricalViNames(selected.subject); + if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names }; + } + } else if (selected.groupCode === "" && selected.graphicType === "5단 표그래프" && + selected.subtype === "VI 발동(수동)" && selected.dataCode === "") { + const names = exactHistoricalViNames(selected.subject); + if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names }; + } else { + const profile = financialByGraphic[selected.graphicType]; + const market = marketByGroup[selected.groupCode]; + if (profile && market && safeText(selected.subject, 200) && + selected.subtype === "" && selected.dataCode === "") { + descriptor = { + kind: "financial", + screen: profile.screen, + builderKey: profile.builderKey, + code: profile.code, + market, + stockName: selected.subject, + identity: Object.freeze({ screen: profile.screen, stockName: selected.subject }) + }; + } + } + + if (!descriptor) return null; + const pending = Object.freeze({ + ...descriptor, + itemIndex: raw.itemIndex, + enabled: raw.enabled, + entry: Object.freeze({ id: normalizedOptions.id }), + fadeDuration: normalizedOptions.fadeDuration, + rawSelection: selected + }); + pendingValues.add(pending); + return pending; + } + + function createManualListReadRequest(requestId, pending) { + if (!pendingValues.has(pending) || !["net-sell", "vi"].includes(pending.kind)) { + throw new TypeError("A validated named manual-list restore is required."); + } + return pending.kind === "net-sell" + ? manualLists.createNetSellReadRequest(requestId, pending.audience) + : manualLists.createViReadRequest(requestId); + } + + function materializeManualList(pending, payload, requestEnvelope) { + if (!pendingValues.has(pending) || !requestEnvelope || + requestEnvelope.type !== (pending.kind === "net-sell" + ? manualLists.bridgeContract.netSellReadType + : manualLists.bridgeContract.viReadType)) return null; + let entry; + if (pending.kind === "net-sell") { + const response = manualLists.normalizeNetSellDataResponse(payload, requestEnvelope.payload); + if (!response) return null; + entry = manualLists.createNetSellPlaylistEntry(pending.audience, { + id: pending.entry.id, + fadeDuration: pending.fadeDuration + }); + } else if (pending.kind === "vi") { + const response = manualLists.normalizeViDataResponse(payload, requestEnvelope.payload.requestId); + if (!response || response.itemCount < 1 || + (pending.expectedVersion !== null && response.version !== pending.expectedVersion)) return null; + if (pending.historicalNames !== null) { + const names = response.items.map(item => item.name); + if (names.length !== pending.historicalNames.length || + names.some((name, index) => name !== pending.historicalNames[index])) return null; + } + entry = manualLists.createViPlaylistEntry(response.items, response.version, { + id: pending.entry.id, + fadeDuration: pending.fadeDuration + }); + } + return manualLists.restorePlaylistEntry({ ...entry, enabled: pending.enabled }); + } + + function isPending(value, kind) { + return pendingValues.has(value) && (kind === undefined || value.kind === kind); + } + + function matchesMaterializedEntry(pending, entry) { + if (!pendingValues.has(pending) || !entry || entry.id !== pending.entry.id || + entry.enabled !== pending.enabled || entry.fadeDuration !== pending.fadeDuration) return false; + if (pending.kind === "net-sell") { + const restored = manualLists.restorePlaylistEntry(entry); + return !!restored && restored.builderKey === "s5025" && + restored.operator.kind === "net-sell" && restored.operator.audience === pending.audience; + } + if (pending.kind === "vi") { + const restored = manualLists.restorePlaylistEntry(entry); + return !!restored && restored.builderKey === "s5074" && restored.operator.kind === "vi"; + } + if (pending.kind === "financial") { + return manualFinancial.isPlaylistEntryPlayable(entry) && + entry.builderKey === pending.builderKey && entry.code === pending.code && + entry.stockName === pending.stockName && entry.market === pending.market; + } + return false; + } + + return { + classify, + createManualListReadRequest, + materializeManualList, + isPending, + matchesMaterializedEntry + }; +}); diff --git a/Web/named-playlist-workflow.js b/Web/named-playlist-workflow.js new file mode 100644 index 0000000..f370130 --- /dev/null +++ b/Web/named-playlist-workflow.js @@ -0,0 +1,691 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnNamedPlaylistWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const SCHEMA_VERSION = 1; + const MAXIMUM_DEFINITIONS = 1000; + const MAXIMUM_ITEMS = 1000; + const MAXIMUM_PAGE_COUNT = 20; + const MAXIMUM_STORED_PAGE_COUNT = 9999; + const DEFAULT_MAXIMUM_DEFINITIONS = 200; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const programCodePattern = /^[0-9]{8}$/; + const cutCodePattern = /^[0-9]{1,8}$/; + const builderKeyPattern = /^s[0-9]+$/; + const controlCharacterPattern = /[\u0000-\u001f\u007f]/; + const pagedBuilderByCode = Object.freeze({ + "5074": Object.freeze({ builderKey: "s5074", pageSize: 5 }), + "5077": Object.freeze({ builderKey: "s5077", pageSize: 6 }), + "5088": Object.freeze({ builderKey: "s5088", pageSize: 12 }) + }); + + const bridgeContract = Object.freeze({ + requests: Object.freeze({ + list: "request-named-playlist-list", + load: "request-named-playlist-load", + create: "create-named-playlist", + replace: "replace-named-playlist", + delete: "delete-named-playlist" + }), + responses: Object.freeze({ + list: "named-playlist-list-results", + load: "named-playlist-load-results", + mutation: "named-playlist-mutation-results", + error: "named-playlist-error" + }), + schemaVersion: SCHEMA_VERSION, + maximumDefinitions: MAXIMUM_DEFINITIONS, + maximumItems: MAXIMUM_ITEMS, + maximumPageCount: MAXIMUM_PAGE_COUNT, + maximumStoredPageCount: MAXIMUM_STORED_PAGE_COUNT + }); + + const pagePlanBridgeContract = Object.freeze({ + request: "request-playlist-page-plans", + results: "playlist-page-plans-results", + error: "playlist-page-plans-error", + pagedBuilderByCode + }); + + const operations = Object.freeze(["list", "load", "create", "replace", "delete"]); + const operationSet = new Set(operations); + + function isPlainObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } + + function hasExactKeys(value, expected) { + if (!isPlainObject(value)) return false; + const actual = Object.keys(value).sort(); + const sortedExpected = [...expected].sort(); + return actual.length === sortedExpected.length && + actual.every((key, index) => key === sortedExpected[index]); + } + + function exactText(value, maximumLength, allowEmpty = false, allowCaret = false) { + if (typeof value !== "string" || value.length > maximumLength || + (!allowEmpty && value.length === 0) || value !== value.trim() || + controlCharacterPattern.test(value) || (!allowCaret && value.includes("^"))) return null; + return value; + } + + function requestId(value) { + const text = exactText(value, 128); + return text && identifierPattern.test(text) ? text : null; + } + + function programCode(value) { + const text = exactText(value, 8); + return text && programCodePattern.test(text) ? text : null; + } + + function isIsoTimestamp(value) { + return typeof value === "string" && value.length >= 20 && value.length <= 64 && + !Number.isNaN(Date.parse(value)); + } + + function createListRequest(requestIdValue, maximumResults = DEFAULT_MAXIMUM_DEFINITIONS) { + const id = requestId(requestIdValue); + if (!id) throw new TypeError("A safe named-playlist request id is required."); + if (!Number.isInteger(maximumResults) || maximumResults < 1 || + maximumResults > MAXIMUM_DEFINITIONS) { + throw new RangeError(`The named-playlist definition limit must be 1 through ${MAXIMUM_DEFINITIONS}.`); + } + return Object.freeze({ requestId: id, maximumResults }); + } + + function createLoadRequest(requestIdValue, programCodeValue) { + const id = requestId(requestIdValue); + const code = programCode(programCodeValue); + if (!id || !code) throw new TypeError("A safe request id and eight-digit program code are required."); + return Object.freeze({ requestId: id, programCode: code }); + } + + function createDefinitionRequest(requestIdValue, programCodeValue, titleValue) { + const request = createLoadRequest(requestIdValue, programCodeValue); + const title = exactText(titleValue, 128, false, true); + if (!title) throw new TypeError("A canonical named-playlist title is required."); + return Object.freeze({ ...request, title }); + } + + function createDeleteRequest(requestIdValue, programCodeValue) { + return createLoadRequest(requestIdValue, programCodeValue); + } + + function parseStoredPageText(value) { + if (value === "") return null; + if (typeof value !== "string" || !/^[1-9][0-9]{0,3}\/(?:0|[1-9][0-9]{0,3})$/.test(value)) { + return null; + } + const [currentText, totalText] = value.split("/"); + const currentPage = Number(currentText); + const totalPages = Number(totalText); + if (totalPages > MAXIMUM_STORED_PAGE_COUNT || + (totalPages === 0 ? currentPage !== 1 : currentPage > totalPages)) return null; + const isWithinPlayoutBounds = totalPages >= 1 && + totalPages <= MAXIMUM_PAGE_COUNT && currentPage <= totalPages; + return Object.freeze({ + pageText: value, + currentPage, + totalPages, + isWithinPlayoutBounds, + historicalPageOutOfBounds: !isWithinPlayoutBounds, + pageRecalculationRequired: true + }); + } + + function normalizeSelection(value) { + if (!hasExactKeys(value, ["groupCode", "subject", "graphicType", "subtype", "dataCode"])) { + return null; + } + const groupCode = exactText(value.groupCode, 256, true); + const subject = exactText(value.subject, 256, true); + const graphicType = exactText(value.graphicType, 256, true); + const subtype = exactText(value.subtype, 256, true); + const dataCode = exactText(value.dataCode, 256, true); + if ([groupCode, subject, graphicType, subtype, dataCode].some(field => field === null)) return null; + return Object.freeze({ groupCode, subject, graphicType, subtype, dataCode }); + } + + function normalizePageTextForSave(value) { + if (value === "") return ""; + const page = parseStoredPageText(value); + if (!page) throw new RangeError("The stored named-playlist page text is invalid."); + return page.pageText; + } + + function toLegacySevenFields(entry, pageTextValue = "1/1") { + if (!isPlainObject(entry) || typeof entry.enabled !== "boolean") { + throw new TypeError("A typed playlist entry with an enabled flag is required."); + } + const selection = normalizeSelection(entry.selection); + if (!selection) throw new TypeError("The playlist selection cannot be stored safely."); + const pageText = normalizePageTextForSave(pageTextValue); + return Object.freeze([ + entry.enabled ? "1" : "0", + selection.groupCode, + selection.subject, + selection.graphicType, + selection.subtype, + pageText, + selection.dataCode + ]); + } + + function legacyListText(fieldsValue) { + if (!Array.isArray(fieldsValue) || fieldsValue.length !== 7 || + fieldsValue.some(field => typeof field !== "string" || field.includes("^") || + controlCharacterPattern.test(field)) || + !["0", "1"].includes(fieldsValue[0]) || + fieldsValue.slice(1, 5).some(field => exactText(field, 256, true) === null) || + exactText(fieldsValue[6], 256, true) === null || + (fieldsValue[5] !== "" && !parseStoredPageText(fieldsValue[5]))) { + throw new TypeError("Exactly seven safe legacy playlist fields are required."); + } + const value = fieldsValue.join("^"); + if (value.length > 4000) throw new RangeError("The legacy LIST_TEXT value is too long."); + return value; + } + + function createReplaceRequest( + requestIdValue, + programCodeValue, + playlistEntries, + options = {}) { + const request = createLoadRequest(requestIdValue, programCodeValue); + if (!Array.isArray(playlistEntries) || playlistEntries.length > MAXIMUM_ITEMS) { + throw new RangeError(`A named playlist can contain at most ${MAXIMUM_ITEMS} entries.`); + } + if (!isPlainObject(options) || typeof options.isTrustedEntry !== "function") { + throw new TypeError("A trusted-entry verifier is required before database storage."); + } + if (options.pageTextForEntry !== undefined && typeof options.pageTextForEntry !== "function") { + throw new TypeError("pageTextForEntry must be a function when supplied."); + } + + const items = playlistEntries.map((entry, itemIndex) => { + if (options.isTrustedEntry(entry, itemIndex) !== true) { + throw new TypeError(`Playlist entry ${itemIndex} did not pass trusted restoration.`); + } + const pageText = options.pageTextForEntry + ? options.pageTextForEntry(entry, itemIndex) + : "1/1"; + const fields = toLegacySevenFields(entry, pageText); + legacyListText(fields); + return Object.freeze({ + itemIndex, + enabled: fields[0] === "1", + groupCode: fields[1], + subject: fields[2], + graphicType: fields[3], + subtype: fields[4], + pageText: fields[5], + dataCode: fields[6] + }); + }); + return Object.freeze({ ...request, items: Object.freeze(items) }); + } + + function normalizeDefinition(value) { + if (!hasExactKeys(value, ["programCode", "title"])) return null; + const code = programCode(value.programCode); + const title = exactText(value.title, 128, false, true); + return code && title ? Object.freeze({ programCode: code, title }) : null; + } + + function normalizeListResponse(value) { + const keys = [ + "requestId", "retrievedAt", "totalRowCount", "truncated", + "suggestedProgramCode", "canMutate", "writeQuarantined", "definitions" + ]; + if (!hasExactKeys(value, keys) || !requestId(value.requestId) || + !isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) || + value.totalRowCount < 0 || typeof value.truncated !== "boolean" || + !programCode(value.suggestedProgramCode) || typeof value.canMutate !== "boolean" || + typeof value.writeQuarantined !== "boolean" || !Array.isArray(value.definitions) || + value.definitions.length !== value.totalRowCount || + value.definitions.length > MAXIMUM_DEFINITIONS || + (value.writeQuarantined && value.canMutate)) return null; + const definitions = []; + const codes = new Set(); + for (const raw of value.definitions) { + const definition = normalizeDefinition(raw); + if (!definition || codes.has(definition.programCode)) return null; + codes.add(definition.programCode); + definitions.push(definition); + } + return Object.freeze({ + requestId: value.requestId, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + suggestedProgramCode: value.suggestedProgramCode, + canMutate: value.canMutate, + writeQuarantined: value.writeQuarantined, + definitions: Object.freeze(definitions) + }); + } + + function normalizeRawRow(value, expectedIndex) { + const keys = [ + "itemIndex", "enabled", "groupCode", "subject", "graphicType", + "subtype", "pageText", "dataCode" + ]; + if (!hasExactKeys(value, keys) || value.itemIndex !== expectedIndex || + typeof value.enabled !== "boolean") return null; + const selection = normalizeSelection({ + groupCode: value.groupCode, + subject: value.subject, + graphicType: value.graphicType, + subtype: value.subtype, + dataCode: value.dataCode + }); + if (!selection || typeof value.pageText !== "string" || value.pageText.length > 9) return null; + const pageState = value.pageText === "" ? null : parseStoredPageText(value.pageText); + if (value.pageText !== "" && !pageState) return null; + const legacyFields = Object.freeze([ + value.enabled ? "1" : "0", + selection.groupCode, + selection.subject, + selection.graphicType, + selection.subtype, + value.pageText, + selection.dataCode + ]); + const listText = legacyListText(legacyFields); + return Object.freeze({ + itemIndex: value.itemIndex, + enabled: value.enabled, + groupCode: selection.groupCode, + subject: selection.subject, + graphicType: selection.graphicType, + subtype: selection.subtype, + pageText: value.pageText, + dataCode: selection.dataCode, + legacyFields, + listText, + pageState, + requiresTrustedRestore: true, + pageRecalculationRequired: pageState !== null, + historicalPageOutOfBounds: pageState?.historicalPageOutOfBounds === true + }); + } + + function normalizeLoadResponse(value) { + const keys = [ + "requestId", "definition", "retrievedAt", "totalRowCount", + "canMutate", "writeQuarantined", "rows" + ]; + if (!hasExactKeys(value, keys) || !requestId(value.requestId) || + !isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) || + value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_ITEMS || + typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" || + (value.writeQuarantined && value.canMutate) || !Array.isArray(value.rows) || + value.rows.length !== value.totalRowCount) return null; + const definition = normalizeDefinition(value.definition); + if (!definition) return null; + const rawRows = []; + for (let index = 0; index < value.rows.length; index += 1) { + const row = normalizeRawRow(value.rows[index], index); + if (!row) return null; + rawRows.push(row); + } + return Object.freeze({ + kind: "named-playlist-load", + schemaVersion: SCHEMA_VERSION, + requestId: value.requestId, + definition, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + canMutate: value.canMutate, + writeQuarantined: value.writeQuarantined, + rawRows: Object.freeze(rawRows) + }); + } + + function sameSelection(entry, rawRow) { + const selection = normalizeSelection(entry?.selection); + return selection !== null && selection.groupCode === rawRow.groupCode && + selection.subject === rawRow.subject && selection.graphicType === rawRow.graphicType && + selection.subtype === rawRow.subtype && selection.dataCode === rawRow.dataCode; + } + + function freezeTrustedEntry(entry) { + const copy = { + ...entry, + selection: Object.freeze({ ...entry.selection }) + }; + if (Array.isArray(entry.aliases)) copy.aliases = Object.freeze([...entry.aliases]); + if (isPlainObject(entry.operator)) copy.operator = Object.freeze({ ...entry.operator }); + return Object.freeze(copy); + } + + function trustedEntryMatchesRaw(entry, rawRow) { + return isPlainObject(entry) && requestId(entry.id) && + typeof entry.builderKey === "string" && builderKeyPattern.test(entry.builderKey) && + typeof entry.code === "string" && cutCodePattern.test(entry.code) && + entry.enabled === rawRow.enabled && sameSelection(entry, rawRow); + } + + function restoreRawRows(loadValue, trustedResolver) { + const load = loadValue?.kind === "named-playlist-load" + ? loadValue + : normalizeLoadResponse(loadValue); + if (!load || typeof trustedResolver !== "function") { + throw new TypeError("A normalized load response and trusted resolver are required."); + } + const rows = load.rawRows.map(rawRow => { + let candidate = null; + try { + candidate = trustedResolver(rawRow, rawRow.itemIndex); + } catch { + candidate = null; + } + const trusted = trustedEntryMatchesRaw(candidate, rawRow); + return Object.freeze({ + rawRow, + entry: trusted ? freezeTrustedEntry(candidate) : null, + trusted, + pageRecalculationRequired: rawRow.pageRecalculationRequired, + pageRecalculated: !rawRow.pageRecalculationRequired, + pagePreflightCompleted: false, + pageUnavailableReason: null, + recalculatedPagePlan: null + }); + }); + return freezeRestoration(load.definition, rows); + } + + function preparePagePreflight(restoration, isPagedEntry) { + if (!restoration || restoration.kind !== "named-playlist-restoration" || + typeof isPagedEntry !== "function") { + throw new TypeError("A valid restoration and paged-entry classifier are required."); + } + const rows = restoration.rows.map(row => { + const paged = row.trusted && isPagedEntry(row.entry, row.rawRow.itemIndex) === true; + return Object.freeze({ + ...row, + pageRecalculationRequired: paged, + pageRecalculated: !paged, + pagePreflightCompleted: false, + pageUnavailableReason: null, + recalculatedPagePlan: null + }); + }); + return freezeRestoration(restoration.definition, rows); + } + + function pagePlanEntry(entry) { + const definition = pagedBuilderByCode[entry?.code]; + const selection = normalizeSelection(entry?.selection); + const fadeDuration = Number(entry?.fadeDuration); + if (!definition || definition.builderKey !== entry?.builderKey || + !requestId(entry?.id) || typeof entry?.enabled !== "boolean" || + !selection || !Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + return null; + } + return Object.freeze({ + id: entry.id, + code: entry.code, + enabled: entry.enabled, + fadeDuration, + selection + }); + } + + function createPagePlanRequest(requestIdValue, restoration) { + const id = requestId(requestIdValue); + if (!id || !restoration || restoration.kind !== "named-playlist-restoration") { + throw new TypeError("A safe request id and trusted restoration are required."); + } + const entries = []; + const identifiers = new Set(); + for (const row of restoration.rows) { + if (!row.trusted || !row.pageRecalculationRequired) continue; + const entry = pagePlanEntry(row.entry); + if (!entry || identifiers.has(entry.id)) { + throw new TypeError("A paged restoration entry is not safe for page preflight."); + } + identifiers.add(entry.id); + entries.push(entry); + } + if (!entries.length) return null; + return Object.freeze({ requestId: id, entries: Object.freeze(entries) }); + } + + function normalizePagePlanResult(value, expectedEntry) { + const keys = [ + "entryId", "itemCount", "pageSize", "pageCount", + "accessibleItemCount", "isTruncated", "builderKey" + ]; + const definition = pagedBuilderByCode[expectedEntry?.code]; + if (!hasExactKeys(value, keys) || !definition || + value.entryId !== expectedEntry.id || value.builderKey !== definition.builderKey || + value.pageSize !== definition.pageSize || + !Number.isSafeInteger(value.itemCount) || value.itemCount < 0 || + !Number.isInteger(value.pageCount) || value.pageCount < 0 || + value.pageCount > MAXIMUM_PAGE_COUNT || + !Number.isSafeInteger(value.accessibleItemCount) || value.accessibleItemCount < 0 || + typeof value.isTruncated !== "boolean") return null; + + const expectedPageCount = value.itemCount === 0 + ? 0 + : Math.min(MAXIMUM_PAGE_COUNT, Math.ceil(value.itemCount / value.pageSize)); + const expectedAccessible = Math.min( + value.itemCount, + expectedPageCount * value.pageSize); + if (value.pageCount !== expectedPageCount || + value.accessibleItemCount !== expectedAccessible || + value.isTruncated !== (value.itemCount > expectedAccessible)) return null; + return Object.freeze({ ...value }); + } + + function normalizePagePlanResponse(value, expectedRequest) { + if (!expectedRequest || !hasExactKeys(expectedRequest, ["requestId", "entries"]) || + !hasExactKeys(value, ["requestId", "calculatedAt", "plans"]) || + value.requestId !== expectedRequest.requestId || !isIsoTimestamp(value.calculatedAt) || + !Array.isArray(value.plans) || value.plans.length !== expectedRequest.entries.length) { + return null; + } + const plans = []; + for (let index = 0; index < expectedRequest.entries.length; index += 1) { + const plan = normalizePagePlanResult(value.plans[index], expectedRequest.entries[index]); + if (!plan) return null; + plans.push(plan); + } + return Object.freeze({ + requestId: value.requestId, + calculatedAt: value.calculatedAt, + plans: Object.freeze(plans) + }); + } + + function normalizePagePlanError(value, expectedRequestId) { + if (!hasExactKeys(value, ["requestId", "code", "message", "retryable"]) || + value.requestId !== expectedRequestId || !requestId(value.requestId) || + !exactText(value.code, 64) || !exactText(value.message, 1024) || + typeof value.retryable !== "boolean") return null; + return Object.freeze({ ...value }); + } + + function normalizePagePlan(value) { + if (!hasExactKeys(value, ["itemCount", "pageSize", "pageCount"]) || + !Number.isInteger(value.itemCount) || value.itemCount < 0 || + !Number.isInteger(value.pageSize) || value.pageSize < 0 || value.pageSize > 12 || + !Number.isInteger(value.pageCount) || value.pageCount < 1 || + value.pageCount > MAXIMUM_PAGE_COUNT) return null; + return Object.freeze({ + itemCount: value.itemCount, + pageSize: value.pageSize, + pageCount: value.pageCount + }); + } + + function markPageRecalculated(restoration, itemIndex, pagePlanValue) { + if (!restoration || restoration.kind !== "named-playlist-restoration" || + !Number.isInteger(itemIndex) || itemIndex < 0 || itemIndex >= restoration.rows.length) { + throw new TypeError("A valid restoration row is required."); + } + const pagePlan = normalizePagePlan(pagePlanValue); + if (!pagePlan) throw new RangeError("The recalculated page plan is invalid or exceeds 20 pages."); + const rows = restoration.rows.map((row, index) => index === itemIndex + ? Object.freeze({ + ...row, + pageRecalculated: true, + pagePreflightCompleted: true, + pageUnavailableReason: null, + recalculatedPagePlan: pagePlan + }) + : row); + return freezeRestoration(restoration.definition, rows); + } + + function applyPagePlanResponse(restoration, response) { + if (!restoration || restoration.kind !== "named-playlist-restoration" || + !response || !Array.isArray(response.plans)) { + throw new TypeError("A valid restoration and normalized page-plan response are required."); + } + let current = restoration; + for (const plan of response.plans) { + const itemIndex = current.rows.findIndex(row => + row.trusted && row.entry?.id === plan.entryId && + row.entry?.builderKey === plan.builderKey && row.pageRecalculationRequired); + if (itemIndex < 0) throw new TypeError("The page-plan result does not match the restoration."); + if (plan.itemCount === 0) { + const rows = current.rows.map((row, index) => index === itemIndex + ? Object.freeze({ + ...row, + pageRecalculated: false, + pagePreflightCompleted: true, + pageUnavailableReason: "empty-result", + recalculatedPagePlan: Object.freeze({ ...plan }) + }) + : row); + current = freezeRestoration(current.definition, rows); + continue; + } + current = markPageRecalculated(current, itemIndex, { + itemCount: plan.itemCount, + pageSize: plan.pageSize, + pageCount: plan.pageCount + }); + const rows = current.rows.map((row, index) => index === itemIndex + ? Object.freeze({ ...row, recalculatedPagePlan: Object.freeze({ ...plan }) }) + : row); + current = freezeRestoration(current.definition, rows); + } + return current; + } + + function prepareBlockers(restoration) { + if (!restoration || restoration.kind !== "named-playlist-restoration") { + return Object.freeze([Object.freeze({ itemIndex: -1, reason: "invalid-restoration" })]); + } + const blockers = []; + for (const row of restoration.rows) { + if (!row.trusted) blockers.push(Object.freeze({ + itemIndex: row.rawRow.itemIndex, + reason: "trusted-restore-required" + })); + if (row.pageRecalculationRequired && !row.pageRecalculated) blockers.push(Object.freeze({ + itemIndex: row.rawRow.itemIndex, + reason: row.pagePreflightCompleted && row.pageUnavailableReason === "empty-result" + ? "page-result-empty" + : "page-recalculation-required" + })); + } + return Object.freeze(blockers); + } + + function freezeRestoration(definition, rowsValue) { + const rows = Object.freeze([...rowsValue]); + const provisional = { kind: "named-playlist-restoration", schemaVersion: SCHEMA_VERSION, definition, rows }; + const blockers = prepareBlockers(provisional); + return Object.freeze({ ...provisional, blockers, readyForPrepare: blockers.length === 0 }); + } + + function canPrepareRestoredRows(restoration) { + return prepareBlockers(restoration).length === 0; + } + + function materializeTrustedPlaylist(restoration) { + if (!canPrepareRestoredRows(restoration)) { + throw new Error("Trusted restoration and fresh page calculation are required before PREPARE."); + } + return Object.freeze(restoration.rows.map(row => row.entry)); + } + + function pageStatusLabel(rawRow) { + if (!rawRow || rawRow.requiresTrustedRestore !== true) return "Invalid stored row"; + if (!rawRow.pageState) return "No stored page; trusted restoration is required"; + if (rawRow.historicalPageOutOfBounds) { + return `Stored page ${rawRow.pageText}; recalculate before PREPARE (20-page maximum)`; + } + return `Stored page ${rawRow.pageText}; recalculate before PREPARE`; + } + + function normalizeMutationResponse(value) { + const keys = ["requestId", "operation", "programCode", "completedAt", "writeQuarantined"]; + if (!hasExactKeys(value, keys) || !requestId(value.requestId) || + !["create", "replace", "delete"].includes(value.operation) || + !programCode(value.programCode) || !isIsoTimestamp(value.completedAt) || + typeof value.writeQuarantined !== "boolean") return null; + return Object.freeze({ ...value }); + } + + function normalizeError(value) { + const keys = [ + "requestId", "operation", "programCode", "code", "message", + "retryable", "outcomeUnknown", "writeQuarantined" + ]; + if (!hasExactKeys(value, keys) || !requestId(value.requestId) || + !operationSet.has(value.operation) || + (value.programCode !== "" && !programCode(value.programCode)) || + !exactText(value.code, 64) || !exactText(value.message, 1024) || + typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" || + typeof value.writeQuarantined !== "boolean" || + (value.outcomeUnknown && (value.retryable || !value.writeQuarantined))) return null; + return Object.freeze({ ...value }); + } + + function isWriteBlocked(value) { + return value?.writeQuarantined === true; + } + + return { + bridgeContract, + pagePlanBridgeContract, + operations, + createListRequest, + createLoadRequest, + createDefinitionRequest, + createReplaceRequest, + createDeleteRequest, + parseStoredPageText, + toLegacySevenFields, + legacyListText, + normalizeListResponse, + normalizeLoadResponse, + restoreRawRows, + preparePagePreflight, + createPagePlanRequest, + normalizePagePlanResponse, + normalizePagePlanError, + applyPagePlanResponse, + markPageRecalculated, + prepareBlockers, + canPrepareRestoredRows, + materializeTrustedPlaylist, + pageStatusLabel, + normalizeMutationResponse, + normalizeError, + isWriteBlocked + }; +}); diff --git a/Web/operator-catalog-ui.css b/Web/operator-catalog-ui.css new file mode 100644 index 0000000..b68a9ca --- /dev/null +++ b/Web/operator-catalog-ui.css @@ -0,0 +1,56 @@ +.mbn-operator-catalog-dialog { + width: min(980px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + padding: 0; + border: 1px solid #29415b; + border-radius: 12px; + background: #081522; + color: #c6d4e3; + box-shadow: 0 24px 80px rgba(0, 0, 0, .55); +} + +.mbn-operator-catalog-dialog::backdrop { background: rgba(2, 8, 14, .72); } +.mbn-operator-catalog-shell { display: grid; gap: 10px; padding: 14px; } +.mbn-operator-catalog-header { display: flex; align-items: center; gap: 10px; } +.mbn-operator-catalog-header h2 { flex: 1; margin: 0; font-size: 16px; } +.mbn-operator-catalog-status { color: #8bdcc4; font: 11px Consolas, monospace; } +.mbn-operator-catalog-dialog button, +.mbn-operator-catalog-dialog input, +.mbn-operator-catalog-dialog select { + min-height: 32px; + border: 1px solid #29415b; + border-radius: 6px; + background: #0c1f31; + color: #c6d4e3; +} +.mbn-operator-catalog-dialog button { padding: 0 10px; cursor: pointer; } +.mbn-operator-catalog-dialog button:disabled { cursor: not-allowed; opacity: .38; } +.mbn-operator-catalog-dialog input, +.mbn-operator-catalog-dialog select { min-width: 0; padding: 0 8px; } +.mbn-operator-catalog-tabs, +.mbn-operator-catalog-toolbar, +.mbn-operator-catalog-actions, +.mbn-operator-catalog-item-form { display: flex; flex-wrap: wrap; gap: 6px; } +.mbn-operator-catalog-tabs button[aria-pressed="true"] { border-color: #32d5a4; color: #68e2bd; } +.mbn-operator-catalog-toolbar [data-role="query"] { flex: 1 1 240px; } +.mbn-operator-catalog-body { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(360px, 1.2fr); gap: 10px; min-height: 360px; } +.mbn-operator-catalog-list, +.mbn-operator-catalog-draft { min-height: 0; max-height: 52vh; margin: 0; padding: 6px; overflow: auto; border: 1px solid #1c334a; border-radius: 8px; list-style: none; } +.mbn-operator-catalog-row button { width: 100%; margin-bottom: 4px; text-align: left; } +.mbn-operator-catalog-row button.selected { border-color: #32d5a4; background: rgba(50, 213, 164, .12); } +.mbn-operator-catalog-editor { display: grid; align-content: start; gap: 10px; } +.mbn-operator-catalog-identity { display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: center; } +.mbn-operator-catalog-identity output { color: #8bdcc4; font: 12px Consolas, monospace; } +.mbn-operator-catalog-item-form [data-role="stock-code"] { width: 120px; } +.mbn-operator-catalog-item-form [data-role="stock-name"] { flex: 1 1 180px; } +.mbn-operator-catalog-item-form [data-role="buy-amount"] { width: 130px; } +.mbn-operator-catalog-draft-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: 5px; align-items: center; padding: 5px; border-bottom: 1px solid #172b3e; } +.mbn-operator-catalog-draft-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mbn-operator-catalog-dialog .mbn-danger { border-color: rgba(255, 102, 128, .45); color: #ff9aad; } +.mbn-operator-catalog-error { margin: 0; padding: 8px; border: 1px solid rgba(255, 102, 128, .35); border-radius: 6px; color: #ff9aad; } + +@media (max-width: 720px) { + .mbn-operator-catalog-body { grid-template-columns: 1fr; } + .mbn-operator-catalog-list, + .mbn-operator-catalog-draft { max-height: 30vh; } +} diff --git a/Web/operator-catalog-ui.js b/Web/operator-catalog-ui.js new file mode 100644 index 0000000..a7f4478 --- /dev/null +++ b/Web/operator-catalog-ui.js @@ -0,0 +1,1018 @@ +(function (root, factory) { + "use strict"; + + let workflow = root && root.MbnOperatorCatalogWorkflow; + if (!workflow && typeof module === "object" && module.exports) { + workflow = require("./operator-catalog-workflow.js"); + } + const api = Object.freeze(factory(workflow)); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnOperatorCatalogUi = api; +})(typeof globalThis === "object" ? globalThis : this, function (workflow) { + "use strict"; + + if (!workflow) throw new Error("MbnOperatorCatalogWorkflow is required."); + + const globalScope = typeof globalThis === "object" ? globalThis : null; + const fixedRequestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; + const themeStockCodePattern = /^[A-Za-z0-9]{1,12}$/; + const expertStockCodePattern = /^[A-Za-z0-9]{1,32}$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + const themePrefixes = Object.freeze({ + krx: Object.freeze(["P", "D"]), + nxt: Object.freeze(["X", "T"]) + }); + + function exactOptions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const required = ["postNative", "isLocked", "showToast", "addLog", "createId"]; + return required.every(key => typeof value[key] === "function") && + (value.requestStockSearch === undefined || typeof value.requestStockSearch === "function"); + } + + function safeText(value, maximumLength) { + return typeof value === "string" && value.length > 0 && value.length <= maximumLength && + value === value.trim() && !unsafeTextPattern.test(value); + } + + function clone(value) { + if (value === null || value === undefined) return value; + return JSON.parse(JSON.stringify(value)); + } + + function createController(options) { + if (!exactOptions(options)) { + throw new TypeError("Operator catalog UI requires closed native and operator callbacks."); + } + + const contract = workflow.bridgeContract; + const coordinator = workflow.createMutationCoordinator(); + const state = { + mounted: false, + open: false, + entity: "theme", + mode: "list", + forcedLocked: false, + processQuarantined: false, + schema: null, + canMutate: { theme: null, expert: null }, + activeRead: null, + readQueue: [], + themeQuery: "", + expertQuery: "", + nxtSession: "preMarket", + themeMarketForNew: "krx", + themeResults: [], + expertResults: [], + selectedCatalogIndex: -1, + edit: null, + status: "대기", + error: "" + }; + let dom = null; + + function nextRequestId(kind) { + const value = `catalog-${kind}-${String(options.createId())}`; + if (!fixedRequestIdPattern.test(value)) { + throw new TypeError("createId returned an unsafe operator-catalog identifier."); + } + return value; + } + + function locked() { + let external = true; + try { + external = options.isLocked() === true; + } catch { + external = true; + } + return state.forcedLocked || external; + } + + function quarantined() { + return state.processQuarantined || coordinator.getState().quarantined; + } + + function notifyError(message) { + state.error = message; + state.status = "차단됨"; + options.showToast(message); + render(); + return false; + } + + function latchQuarantine(message) { + state.processQuarantined = true; + state.error = message || "DB 쓰기 결과를 확인할 수 없습니다. 앱 재시작 전까지 쓰기를 차단합니다."; + state.status = "WRITE 잠금"; + options.addLog(`ThemeA/EList WRITE quarantine · ${state.error}`); + } + + function expectedReadError(entity, operation, requestId) { + return Object.freeze({ requestId, entity, operation, identityCode: "" }); + } + + function enqueueRead(key, type, request, expectedError) { + state.readQueue = state.readQueue.filter(item => item.key !== key); + if (state.activeRead?.key === key) state.activeRead.superseded = true; + state.readQueue.push({ key, type, request, expectedError, superseded: false }); + pumpReadQueue(); + render(); + } + + function pumpReadQueue() { + if (state.activeRead || state.readQueue.length === 0) return; + const pending = state.readQueue.shift(); + state.activeRead = pending; + state.status = "DB 조회 중"; + state.error = ""; + try { + options.postNative(pending.type, pending.request); + } catch { + state.activeRead = null; + state.error = "Native Bridge로 DB 조회 요청을 전달하지 못했습니다."; + state.status = "조회 실패"; + pumpReadQueue(); + } + } + + function completeRead(pending, apply) { + if (state.activeRead !== pending) return false; + if (!pending.superseded) apply(); + state.activeRead = null; + if (!state.error) state.status = "준비됨"; + pumpReadQueue(); + render(); + return true; + } + + function requestSchemaPreflight() { + if (state.schema || state.activeRead?.key === "schema" || + state.readQueue.some(item => item.key === "schema")) return; + const request = workflow.createSchemaPreflightRequest(nextRequestId("schema")); + enqueueRead( + "schema", + contract.requests.schemaPreflight, + request, + expectedReadError("catalog", "schemaPreflight", request.requestId)); + } + + function requestCatalogList(query) { + if (state.entity === "theme") { + const request = workflow.createThemeCatalogListRequest( + nextRequestId("theme-list"), + query ?? state.themeQuery, + state.nxtSession, + 500); + state.themeQuery = request.query; + enqueueRead( + "catalog-list", + contract.requests.themeList, + request, + expectedReadError("theme", "list", request.requestId)); + return request; + } + const request = workflow.createExpertCatalogListRequest( + nextRequestId("expert-list"), + query ?? state.expertQuery, + 500); + state.expertQuery = request.query; + enqueueRead( + "catalog-list", + contract.requests.expertList, + request, + expectedReadError("expert", "list", request.requestId)); + return request; + } + + function normalizeEditContext(context, entity) { + if (!context || typeof context !== "object" || Array.isArray(context) || + Object.keys(context).sort().join("|") !== "preview|selection") { + throw new TypeError("Editing requires exactly one read-only selection and complete preview."); + } + return entity === "theme" + ? workflow.createThemeEditDraft(context.selection, context.preview) + : workflow.createExpertEditDraft(context.selection, context.preview); + } + + function openEntity(entity, context) { + state.open = true; + state.entity = entity; + state.selectedCatalogIndex = -1; + state.error = ""; + if (context !== undefined && context !== null) { + state.edit = normalizeEditContext(context, entity); + state.mode = "edit"; + state.status = "편집 준비"; + } else { + state.edit = null; + state.mode = "list"; + state.status = "목록 준비"; + } + requestSchemaPreflight(); + if (state.mode === "list") requestCatalogList(); + render(); + return snapshot(); + } + + function openTheme(context) { + return openEntity("theme", context); + } + + function openExpert(context) { + return openEntity("expert", context); + } + + function beginNewTheme(market) { + if (market !== "krx" && market !== "nxt") return notifyError("KRX 또는 NXT 시장을 선택하세요."); + if (locked() || quarantined()) return notifyError("현재 ThemeA 쓰기를 시작할 수 없습니다."); + state.themeMarketForNew = market; + const request = workflow.createThemeNextCodeRequest(nextRequestId("theme-next"), market); + enqueueRead( + "next-code", + contract.requests.themeNextCode, + request, + expectedReadError("theme", "nextCode", request.requestId)); + return true; + } + + function beginNewExpert() { + if (locked() || quarantined()) return notifyError("현재 EList 쓰기를 시작할 수 없습니다."); + const request = workflow.createExpertNextCodeRequest(nextRequestId("expert-next")); + enqueueRead( + "next-code", + contract.requests.expertNextCode, + request, + expectedReadError("expert", "nextCode", request.requestId)); + return true; + } + + function currentDraft() { + return state.edit?.draft || null; + } + + function updateDraft(values) { + const draft = currentDraft(); + if (!draft || !values || typeof values !== "object" || Array.isArray(values)) return false; + if (state.entity === "theme" && Object.keys(values).every(key => key === "themeTitle") && + typeof values.themeTitle === "string") { + draft.themeTitle = values.themeTitle; + } else if (state.entity === "expert" && + Object.keys(values).every(key => key === "expertName") && + typeof values.expertName === "string") { + draft.expertName = values.expertName; + } else { + return false; + } + state.error = ""; + render(); + return true; + } + + function addThemeItem(prefix, stockCode, stockName) { + const draft = currentDraft(); + const allowed = draft && themePrefixes[draft.market]; + if (!draft || state.entity !== "theme" || !allowed?.includes(prefix) || + typeof stockCode !== "string" || !themeStockCodePattern.test(stockCode) || + !safeText(stockName, 200) || draft.items.length >= workflow.limits.maximumThemeItems) { + return notifyError("시장별 prefix와 종목 코드·이름을 정확히 입력하세요."); + } + const itemCode = `${prefix}${stockCode}`; + const candidate = { + market: draft.market, + themeCode: draft.themeCode, + themeTitle: safeText(draft.themeTitle, 128) ? draft.themeTitle : "검증", + items: [...draft.items, { inputIndex: draft.items.length, itemCode, itemName: stockName }] + }; + if (!workflow.normalizeThemeDraft(candidate)) { + return notifyError("중복되거나 허용되지 않은 ThemeA 종목입니다."); + } + draft.items.push({ inputIndex: draft.items.length, itemCode, itemName: stockName }); + state.error = ""; + render(); + return true; + } + + function addExpertRecommendation(stockCode, stockName, buyAmount) { + const draft = currentDraft(); + const amount = typeof buyAmount === "number" ? buyAmount : Number(buyAmount); + if (!draft || state.entity !== "expert" || + typeof stockCode !== "string" || !expertStockCodePattern.test(stockCode) || + !safeText(stockName, 200) || !Number.isSafeInteger(amount) || amount <= 0 || + draft.recommendations.length >= workflow.limits.maximumRecommendations) { + return notifyError("종목 코드·이름과 1 이상의 정수 매수가를 정확히 입력하세요."); + } + const candidate = { + expertCode: draft.expertCode, + expertName: safeText(draft.expertName, 128) ? draft.expertName : "검증", + recommendations: [ + ...draft.recommendations, + { + playIndex: draft.recommendations.length, + stockCode, + stockName, + buyAmount: amount + } + ] + }; + if (!workflow.normalizeExpertDraft(candidate)) { + return notifyError("중복되거나 허용되지 않은 EList 추천 종목입니다."); + } + draft.recommendations.push({ + playIndex: draft.recommendations.length, + stockCode, + stockName, + buyAmount: amount + }); + state.error = ""; + render(); + return true; + } + + function reindexDraftRows(rows, key) { + rows.forEach((item, index) => { item[key] = index; }); + } + + function moveDraftItem(index, direction) { + const draft = currentDraft(); + const rows = state.entity === "theme" ? draft?.items : draft?.recommendations; + const target = index + direction; + if (!rows || !Number.isInteger(index) || ![-1, 1].includes(direction) || + index < 0 || target < 0 || index >= rows.length || target >= rows.length) return false; + [rows[index], rows[target]] = [rows[target], rows[index]]; + reindexDraftRows(rows, state.entity === "theme" ? "inputIndex" : "playIndex"); + render(); + return true; + } + + function removeDraftItem(index) { + const draft = currentDraft(); + const rows = state.entity === "theme" ? draft?.items : draft?.recommendations; + if (!rows || !Number.isInteger(index) || index < 0 || index >= rows.length) return false; + rows.splice(index, 1); + reindexDraftRows(rows, state.entity === "theme" ? "inputIndex" : "playIndex"); + render(); + return true; + } + + function canWrite() { + if (locked() || quarantined() || coordinator.getState().active || state.activeRead || + state.readQueue.length > 0 || !state.schema) return false; + return state.entity === "theme" + ? state.schema.themeCanMutate === true && state.canMutate.theme !== false + : state.schema.expertCanMutate === true && state.canMutate.expert !== false; + } + + function sendMutation(type, payload) { + if (!canWrite()) return notifyError("송출 IDLE 및 schema preflight 완료 후 DB 쓰기를 실행하세요."); + try { + coordinator.begin(type, payload); + options.postNative(type, payload); + state.status = "DB 반영 확인 중"; + state.error = ""; + options.addLog(`${type} 요청 · 자동 재시도 없음`); + render(); + return true; + } catch (error) { + if (coordinator.getState().active) { + coordinator.loseCorrelation(); + latchQuarantine("DB 쓰기 요청 전달 여부를 확인할 수 없습니다. 앱을 재시작하세요."); + } + return notifyError(error?.message || "DB 쓰기 요청을 만들 수 없습니다."); + } + } + + function submit() { + if (!state.edit || (state.mode !== "new" && state.mode !== "edit")) return false; + try { + const id = nextRequestId(`${state.entity}-${state.mode}`); + if (state.entity === "theme") { + const request = state.mode === "new" + ? workflow.createThemeCreateRequest(id, state.edit.draft) + : workflow.createThemeReplaceRequest(id, state.edit); + const type = state.mode === "new" + ? contract.requests.themeCreate + : contract.requests.themeReplace; + return sendMutation(type, request); + } + const request = state.mode === "new" + ? workflow.createExpertCreateRequest(id, state.edit.draft) + : workflow.createExpertReplaceRequest(id, state.edit); + const type = state.mode === "new" + ? contract.requests.expertCreate + : contract.requests.expertReplace; + return sendMutation(type, request); + } catch (error) { + return notifyError(error?.message || "편집 내용을 검증할 수 없습니다."); + } + } + + function selectedCatalog() { + const rows = state.entity === "theme" ? state.themeResults : state.expertResults; + return rows[state.selectedCatalogIndex] || null; + } + + function selectCatalog(index) { + const rows = state.entity === "theme" ? state.themeResults : state.expertResults; + if (!Number.isInteger(index) || index < 0 || index >= rows.length) return false; + state.selectedCatalogIndex = index; + render(); + return true; + } + + function deleteCurrent() { + try { + const id = nextRequestId(`${state.entity}-delete`); + if (state.entity === "theme") { + const source = state.mode === "edit" + ? state.edit?.expectedIdentity + : selectedCatalog(); + if (!source) return notifyError("삭제할 ThemeA 정의를 선택하세요."); + const identity = { + market: source.market, + session: source.session, + themeCode: source.themeCode, + themeTitle: source.themeTitle + }; + return sendMutation( + contract.requests.themeDelete, + workflow.createThemeDeleteRequest(id, identity)); + } + const source = state.mode === "edit" + ? state.edit?.expectedIdentity + : selectedCatalog(); + if (!source) return notifyError("삭제할 EList 정의를 선택하세요."); + return sendMutation( + contract.requests.expertDelete, + workflow.createExpertDeleteRequest(id, { + expertCode: source.expertCode, + expertName: source.expertName + })); + } catch (error) { + return notifyError(error?.message || "삭제 요청을 검증할 수 없습니다."); + } + } + + function handleSchemaResult(payload) { + const pending = state.activeRead; + if (!pending || pending.type !== contract.requests.schemaPreflight || + payload?.requestId !== pending.request.requestId) return; + const result = workflow.normalizeSchemaResult(payload, pending.request); + if (!result) return failCurrentRead(pending, "schema preflight 응답이 현재 요청과 일치하지 않습니다."); + completeRead(pending, () => { + state.schema = result; + state.canMutate.theme = result.themeCanMutate; + state.canMutate.expert = result.expertCanMutate; + if (result.writeQuarantined) latchQuarantine("Native catalog 쓰기가 이미 격리되었습니다."); + }); + } + + function handleThemeListResult(payload) { + const pending = state.activeRead; + if (!pending || pending.type !== contract.requests.themeList || + payload?.requestId !== pending.request.requestId) return; + const result = workflow.normalizeThemeListResult(payload, pending.request); + if (!result) return failCurrentRead(pending, "ThemeA 목록 응답이 현재 검색과 일치하지 않습니다."); + completeRead(pending, () => { + state.themeResults = [...result.results]; + state.canMutate.theme = state.canMutate.theme !== false && result.canMutate; + state.selectedCatalogIndex = -1; + if (result.writeQuarantined) latchQuarantine("ThemeA 쓰기가 Native에서 격리되었습니다."); + }); + } + + function handleExpertListResult(payload) { + const pending = state.activeRead; + if (!pending || pending.type !== contract.requests.expertList || + payload?.requestId !== pending.request.requestId) return; + const result = workflow.normalizeExpertListResult(payload, pending.request); + if (!result) return failCurrentRead(pending, "EList 목록 응답이 현재 검색과 일치하지 않습니다."); + completeRead(pending, () => { + state.expertResults = [...result.results]; + state.canMutate.expert = state.canMutate.expert !== false && result.canMutate; + state.selectedCatalogIndex = -1; + if (result.writeQuarantined) latchQuarantine("EList 쓰기가 Native에서 격리되었습니다."); + }); + } + + function handleThemeNextCodeResult(payload) { + const pending = state.activeRead; + if (!pending || pending.type !== contract.requests.themeNextCode || + payload?.requestId !== pending.request.requestId) return; + const result = workflow.normalizeThemeNextCodeResult(payload, pending.request); + if (!result) return failCurrentRead(pending, "ThemeA 다음 코드 응답이 현재 시장과 일치하지 않습니다."); + completeRead(pending, () => { + if (result.writeQuarantined) latchQuarantine("ThemeA 쓰기가 Native에서 격리되었습니다."); + state.canMutate.theme = state.canMutate.theme !== false && result.canMutate; + state.entity = "theme"; + state.mode = "new"; + state.edit = { + expectedIdentity: null, + draft: workflow.createBlankThemeDraft(result.market, result.themeCode) + }; + }); + } + + function handleExpertNextCodeResult(payload) { + const pending = state.activeRead; + if (!pending || pending.type !== contract.requests.expertNextCode || + payload?.requestId !== pending.request.requestId) return; + const result = workflow.normalizeExpertNextCodeResult(payload, pending.request); + if (!result) return failCurrentRead(pending, "EList 다음 코드 응답이 현재 요청과 일치하지 않습니다."); + completeRead(pending, () => { + if (result.writeQuarantined) latchQuarantine("EList 쓰기가 Native에서 격리되었습니다."); + state.canMutate.expert = state.canMutate.expert !== false && result.canMutate; + state.entity = "expert"; + state.mode = "new"; + state.edit = { + expectedIdentity: null, + draft: workflow.createBlankExpertDraft(result.expertCode) + }; + }); + } + + function failCurrentRead(pending, message) { + completeRead(pending, () => { + state.error = message; + state.status = "응답 차단"; + }); + } + + function handleMutationResult(payload) { + const active = coordinator.getState().active; + if (!active || payload?.requestId !== active.requestId) return; + const result = coordinator.acceptResult(payload); + if (!result) { + coordinator.loseCorrelation(); + latchQuarantine("DB 쓰기 결과 형식이 현재 요청과 일치하지 않습니다. 앱을 재시작하세요."); + render(); + return; + } + state.status = "DB 반영 완료"; + state.error = ""; + state.mode = "list"; + state.edit = null; + options.addLog(`${result.entity} ${result.operation} 완료 · ${result.identityCode} · ${result.operationId}`); + options.showToast("ThemeA/EList DB 작업이 완료되었습니다."); + render(); + } + + function handleCatalogError(payload) { + const activeMutation = coordinator.getState().active; + if (activeMutation) { + if (payload?.requestId !== activeMutation.requestId) return; + const error = coordinator.acceptError(payload); + if (!error) { + coordinator.loseCorrelation(); + latchQuarantine("DB 쓰기 오류 응답을 현재 요청과 연결할 수 없습니다. 앱을 재시작하세요."); + } else { + if (error.outcomeUnknown || error.writeQuarantined) latchQuarantine(error.message); + else { + state.error = `${error.message} [${error.code}]`; + state.status = "DB 반영 실패"; + } + } + render(); + return; + } + + const pending = state.activeRead; + if (!pending || payload?.requestId !== pending.request.requestId) return; + const error = workflow.normalizeCatalogError(payload, pending.expectedError); + if (!error) return failCurrentRead(pending, "DB 조회 오류 응답을 현재 요청과 연결할 수 없습니다."); + completeRead(pending, () => { + if (error.writeQuarantined || error.outcomeUnknown) latchQuarantine(error.message); + state.error = `${error.message} [${error.code}]`; + state.status = "조회 실패"; + }); + } + + function handleMessage(type, payload) { + switch (type) { + case contract.events.schemaPreflight: + handleSchemaResult(payload); + return true; + case contract.events.themeList: + handleThemeListResult(payload); + return true; + case contract.events.expertList: + handleExpertListResult(payload); + return true; + case contract.events.themeNextCode: + handleThemeNextCodeResult(payload); + return true; + case contract.events.expertNextCode: + handleExpertNextCodeResult(payload); + return true; + case contract.events.mutation: + handleMutationResult(payload); + return true; + case contract.events.error: + handleCatalogError(payload); + return true; + default: + return false; + } + } + + function search(query, nxtSession) { + if (state.entity === "theme" && nxtSession !== undefined) { + if (nxtSession !== "preMarket" && nxtSession !== "afterMarket") { + return notifyError("NXT 세션을 명시적으로 선택하세요."); + } + state.nxtSession = nxtSession; + } + try { + requestCatalogList(query); + return true; + } catch (error) { + return notifyError(error?.message || "카탈로그 검색 요청이 올바르지 않습니다."); + } + } + + function loseCorrelation() { + if (coordinator.getState().active) { + coordinator.loseCorrelation(); + latchQuarantine("Browser 상관관계를 잃어 DB 쓰기 결과를 확인할 수 없습니다."); + } + state.activeRead = null; + state.readQueue = []; + render(); + } + + function setLocked(value = true) { + state.forcedLocked = value === true; + render(); + return locked(); + } + + function close() { + state.open = false; + render(); + } + + function snapshot() { + return Object.freeze({ + mounted: state.mounted, + open: state.open, + entity: state.entity, + mode: state.mode, + locked: locked(), + quarantined: quarantined(), + status: state.status, + error: state.error, + schema: clone(state.schema), + canMutate: clone(state.canMutate), + activeRead: clone(state.activeRead && { + key: state.activeRead.key, + type: state.activeRead.type, + request: state.activeRead.request, + superseded: state.activeRead.superseded + }), + queuedReadCount: state.readQueue.length, + mutation: clone(coordinator.getState().active), + themeResults: clone(state.themeResults), + expertResults: clone(state.expertResults), + selectedCatalogIndex: state.selectedCatalogIndex, + edit: clone(state.edit), + draft: clone(currentDraft()) + }); + } + + function makeElement(documentValue, tagName, className, text) { + const element = documentValue.createElement(tagName); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + + function assignRole(element, role) { + element.setAttribute("data-role", role); + return element; + } + + function addStylesheet(documentValue) { + if (!documentValue.head || documentValue.getElementById?.("mbn-operator-catalog-ui-css")) return; + const link = documentValue.createElement("link"); + link.id = "mbn-operator-catalog-ui-css"; + link.rel = "stylesheet"; + link.href = "operator-catalog-ui.css"; + documentValue.head.appendChild(link); + } + + function mount(host) { + if (state.mounted) return snapshot(); + const target = host ?? (typeof document === "object" ? document.body : null); + const documentValue = target?.ownerDocument ?? (typeof document === "object" ? document : null); + if (!target || !documentValue || typeof documentValue.createElement !== "function" || + typeof target.appendChild !== "function") { + throw new TypeError("mount requires a DOM host."); + } + addStylesheet(documentValue); + + const dialog = makeElement(documentValue, "dialog", "mbn-operator-catalog-dialog"); + dialog.setAttribute("aria-labelledby", "mbnOperatorCatalogTitle"); + const shell = makeElement(documentValue, "section", "mbn-operator-catalog-shell"); + const header = makeElement(documentValue, "header", "mbn-operator-catalog-header"); + const title = makeElement(documentValue, "h2", "", "ThemeA / EList 운영 관리"); + title.id = "mbnOperatorCatalogTitle"; + const status = assignRole(makeElement(documentValue, "span", "mbn-operator-catalog-status"), "status"); + status.setAttribute("aria-live", "polite"); + const closeButton = assignRole(makeElement(documentValue, "button", "", "닫기"), "close"); + closeButton.type = "button"; + header.append(title, status, closeButton); + + const tabs = makeElement(documentValue, "nav", "mbn-operator-catalog-tabs"); + const themeTab = assignRole(makeElement(documentValue, "button", "", "ThemeA"), "theme-tab"); + const expertTab = assignRole(makeElement(documentValue, "button", "", "EList"), "expert-tab"); + themeTab.type = expertTab.type = "button"; + tabs.append(themeTab, expertTab); + + const toolbar = makeElement(documentValue, "form", "mbn-operator-catalog-toolbar"); + const query = assignRole(makeElement(documentValue, "input"), "query"); + query.type = "search"; + query.maxLength = workflow.limits.maximumQueryLength; + query.autocomplete = "off"; + const session = assignRole(makeElement(documentValue, "select"), "nxt-session"); + for (const [value, label] of [["preMarket", "NXT 프리마켓"], ["afterMarket", "NXT 애프터마켓"]]) { + const option = makeElement(documentValue, "option", "", label); + option.value = value; + session.appendChild(option); + } + const searchButton = assignRole(makeElement(documentValue, "button", "", "검색"), "search"); + searchButton.type = "submit"; + const market = assignRole(makeElement(documentValue, "select"), "new-market"); + for (const [value, label] of [["krx", "KRX"], ["nxt", "NXT"]]) { + const option = makeElement(documentValue, "option", "", label); + option.value = value; + market.appendChild(option); + } + const newButton = assignRole(makeElement(documentValue, "button", "", "새 정의"), "new"); + newButton.type = "button"; + const listDeleteButton = assignRole( + makeElement(documentValue, "button", "mbn-danger", "선택 정의 삭제"), + "delete-selected"); + listDeleteButton.type = "button"; + toolbar.append(query, session, searchButton, market, newButton, listDeleteButton); + + const body = makeElement(documentValue, "div", "mbn-operator-catalog-body"); + const catalog = assignRole(makeElement(documentValue, "ol", "mbn-operator-catalog-list"), "catalog-list"); + const editor = assignRole(makeElement(documentValue, "section", "mbn-operator-catalog-editor"), "editor"); + const identity = makeElement(documentValue, "div", "mbn-operator-catalog-identity"); + const code = assignRole(makeElement(documentValue, "output"), "identity-code"); + const name = assignRole(makeElement(documentValue, "input"), "identity-name"); + name.type = "text"; + name.maxLength = 128; + name.autocomplete = "off"; + identity.append(code, name); + + const itemForm = makeElement(documentValue, "form", "mbn-operator-catalog-item-form"); + const prefix = assignRole(makeElement(documentValue, "select"), "item-prefix"); + const stockCode = assignRole(makeElement(documentValue, "input"), "stock-code"); + stockCode.type = "text"; + stockCode.maxLength = 32; + stockCode.autocomplete = "off"; + const stockName = assignRole(makeElement(documentValue, "input"), "stock-name"); + stockName.type = "text"; + stockName.maxLength = 200; + stockName.autocomplete = "off"; + const buyAmount = assignRole(makeElement(documentValue, "input"), "buy-amount"); + buyAmount.type = "number"; + buyAmount.min = "1"; + buyAmount.step = "1"; + const addButton = assignRole(makeElement(documentValue, "button", "", "추가"), "add-item"); + addButton.type = "submit"; + itemForm.append(prefix, stockCode, stockName, buyAmount, addButton); + const draftList = assignRole(makeElement(documentValue, "ol", "mbn-operator-catalog-draft"), "draft-list"); + const actions = makeElement(documentValue, "footer", "mbn-operator-catalog-actions"); + const saveButton = assignRole(makeElement(documentValue, "button", "", "DB 반영"), "save"); + const deleteButton = assignRole(makeElement(documentValue, "button", "mbn-danger", "정의 삭제"), "delete"); + const cancelButton = assignRole(makeElement(documentValue, "button", "", "목록으로"), "cancel-edit"); + saveButton.type = deleteButton.type = cancelButton.type = "button"; + actions.append(saveButton, deleteButton, cancelButton); + editor.append(identity, itemForm, draftList, actions); + body.append(catalog, editor); + + const error = assignRole(makeElement(documentValue, "p", "mbn-operator-catalog-error"), "error"); + shell.append(header, tabs, toolbar, body, error); + dialog.appendChild(shell); + target.appendChild(dialog); + + dom = { + document: documentValue, + dialog, + status, + closeButton, + themeTab, + expertTab, + query, + session, + market, + newButton, + listDeleteButton, + toolbar, + catalog, + editor, + code, + name, + itemForm, + prefix, + stockCode, + stockName, + buyAmount, + draftList, + saveButton, + deleteButton, + cancelButton, + error + }; + + closeButton.addEventListener("click", close); + themeTab.addEventListener("click", () => openTheme()); + expertTab.addEventListener("click", () => openExpert()); + toolbar.addEventListener("submit", event => { + event.preventDefault(); + search(query.value, session.value); + }); + newButton.addEventListener("click", () => state.entity === "theme" + ? beginNewTheme(market.value) + : beginNewExpert()); + listDeleteButton.addEventListener("click", () => { + const confirmDelete = typeof globalScope?.confirm !== "function" || + globalScope.confirm("선택한 ThemeA/EList 정의를 DB에서 삭제할까요?"); + if (confirmDelete) deleteCurrent(); + }); + name.addEventListener("input", () => updateDraft(state.entity === "theme" + ? { themeTitle: name.value } + : { expertName: name.value })); + itemForm.addEventListener("submit", event => { + event.preventDefault(); + const added = state.entity === "theme" + ? addThemeItem(prefix.value, stockCode.value, stockName.value) + : addExpertRecommendation(stockCode.value, stockName.value, buyAmount.value); + if (added) { + stockCode.value = ""; + stockName.value = ""; + buyAmount.value = ""; + } + }); + saveButton.addEventListener("click", submit); + deleteButton.addEventListener("click", () => { + const confirmDelete = typeof globalScope?.confirm !== "function" || + globalScope.confirm("선택한 ThemeA/EList 정의를 DB에서 삭제할까요?"); + if (confirmDelete) deleteCurrent(); + }); + cancelButton.addEventListener("click", () => { + state.mode = "list"; + state.edit = null; + render(); + }); + + state.mounted = true; + render(); + return snapshot(); + } + + function renderCatalogRows() { + if (!dom) return; + dom.catalog.replaceChildren(); + const rows = state.entity === "theme" ? state.themeResults : state.expertResults; + rows.forEach((item, index) => { + const row = makeElement(dom.document, "li", "mbn-operator-catalog-row"); + const button = makeElement( + dom.document, + "button", + index === state.selectedCatalogIndex ? "selected" : "", + state.entity === "theme" + ? `${item.themeCode} · ${item.themeTitle} · ${item.market.toUpperCase()}` + : `${item.expertCode} · ${item.expertName}`); + button.type = "button"; + button.addEventListener("click", () => selectCatalog(index)); + row.appendChild(button); + dom.catalog.appendChild(row); + }); + } + + function renderDraftRows() { + if (!dom) return; + dom.draftList.replaceChildren(); + const draft = currentDraft(); + const rows = state.entity === "theme" ? draft?.items : draft?.recommendations; + (rows || []).forEach((item, index) => { + const row = makeElement(dom.document, "li", "mbn-operator-catalog-draft-row"); + const label = makeElement( + dom.document, + "span", + "", + state.entity === "theme" + ? `${item.itemCode} · ${item.itemName}` + : `${item.stockCode} · ${item.stockName} · ${item.buyAmount}`); + const up = makeElement(dom.document, "button", "", "↑"); + const down = makeElement(dom.document, "button", "", "↓"); + const remove = makeElement(dom.document, "button", "mbn-danger", "삭제"); + up.type = down.type = remove.type = "button"; + up.disabled = index === 0 || locked(); + down.disabled = index + 1 === rows.length || locked(); + remove.disabled = locked(); + up.addEventListener("click", () => moveDraftItem(index, -1)); + down.addEventListener("click", () => moveDraftItem(index, 1)); + remove.addEventListener("click", () => removeDraftItem(index)); + row.append(label, up, down, remove); + dom.draftList.appendChild(row); + }); + } + + function render() { + if (!dom) return snapshot(); + const isLocked = locked(); + const isQuarantined = quarantined(); + const mutationActive = Boolean(coordinator.getState().active); + dom.status.textContent = isQuarantined ? "WRITE 잠금" : state.status; + dom.error.textContent = state.error; + dom.error.hidden = !state.error; + dom.themeTab.setAttribute("aria-pressed", String(state.entity === "theme")); + dom.expertTab.setAttribute("aria-pressed", String(state.entity === "expert")); + dom.query.value = state.entity === "theme" ? state.themeQuery : state.expertQuery; + dom.session.value = state.nxtSession; + dom.session.hidden = state.entity !== "theme"; + dom.market.hidden = state.entity !== "theme"; + dom.market.value = state.themeMarketForNew; + dom.newButton.textContent = state.entity === "theme" ? "새 ThemeA" : "새 EList"; + dom.newButton.disabled = isLocked || isQuarantined || mutationActive; + dom.listDeleteButton.disabled = !canWrite() || !selectedCatalog(); + dom.catalog.hidden = state.mode !== "list"; + dom.editor.hidden = state.mode === "list"; + const draft = currentDraft(); + dom.code.textContent = draft + ? (state.entity === "theme" ? draft.themeCode : draft.expertCode) + : ""; + dom.name.value = draft + ? (state.entity === "theme" ? draft.themeTitle : draft.expertName) + : ""; + dom.name.placeholder = state.entity === "theme" ? "ThemeA 이름" : "전문가 이름"; + dom.name.disabled = isLocked || mutationActive; + dom.prefix.hidden = state.entity !== "theme"; + dom.buyAmount.hidden = state.entity !== "expert"; + if (draft && state.entity === "theme") { + const previous = dom.prefix.value; + dom.prefix.replaceChildren(); + for (const value of themePrefixes[draft.market]) { + const option = makeElement(dom.document, "option", "", `${value} prefix`); + option.value = value; + dom.prefix.appendChild(option); + } + dom.prefix.value = themePrefixes[draft.market].includes(previous) + ? previous + : themePrefixes[draft.market][0]; + } + for (const control of [dom.stockCode, dom.stockName, dom.buyAmount, dom.prefix]) { + control.disabled = isLocked || mutationActive; + } + dom.saveButton.disabled = !canWrite() || !draft; + dom.deleteButton.disabled = !canWrite() || + (state.mode !== "edit" && !selectedCatalog()); + dom.cancelButton.disabled = mutationActive; + dom.closeButton.disabled = mutationActive; + renderCatalogRows(); + renderDraftRows(); + + if (state.open) { + if (typeof dom.dialog.showModal === "function" && !dom.dialog.open) dom.dialog.showModal(); + else { + dom.dialog.hidden = false; + dom.dialog.setAttribute("open", ""); + } + } else if (typeof dom.dialog.close === "function" && dom.dialog.open) { + dom.dialog.close(); + } else { + dom.dialog.hidden = true; + dom.dialog.removeAttribute("open"); + } + return snapshot(); + } + + return Object.freeze({ + mount, + openTheme, + openExpert, + handleMessage, + render, + setLocked, + close, + search, + beginNewTheme, + beginNewExpert, + updateDraft, + addThemeItem, + addExpertRecommendation, + moveDraftItem, + removeDraftItem, + selectCatalog, + submit, + deleteCurrent, + loseCorrelation + }); + } + + return Object.freeze({ createController }); +}); diff --git a/Web/operator-catalog-workflow.js b/Web/operator-catalog-workflow.js new file mode 100644 index 0000000..111e41c --- /dev/null +++ b/Web/operator-catalog-workflow.js @@ -0,0 +1,761 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnOperatorCatalogWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const MAXIMUM_THEME_ITEMS = 240; + const MAXIMUM_RECOMMENDATIONS = 100; + const MAXIMUM_RESULTS = 500; + const MAXIMUM_QUERY_LENGTH = 64; + const MAXIMUM_WEB_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; + const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; + const themeCodePattern = /^[0-9]{8}$/; + const expertCodePattern = /^[0-9]{4}$/; + const stockCodePattern = /^[A-Za-z0-9]{1,32}$/; + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + + const bridgeContract = Object.freeze({ + requests: Object.freeze({ + themeList: "request-theme-catalog-list", + expertList: "request-expert-catalog-list", + schemaPreflight: "request-operator-catalog-schema", + themeNextCode: "request-theme-catalog-next-code", + expertNextCode: "request-expert-catalog-next-code", + themeCreate: "create-theme-catalog", + themeReplace: "replace-theme-catalog", + themeDelete: "delete-theme-catalog", + expertCreate: "create-expert-catalog", + expertReplace: "replace-expert-catalog", + expertDelete: "delete-expert-catalog" + }), + events: Object.freeze({ + themeList: "theme-catalog-list-results", + expertList: "expert-catalog-list-results", + schemaPreflight: "operator-catalog-schema-results", + themeNextCode: "theme-catalog-next-code-results", + expertNextCode: "expert-catalog-next-code-results", + mutation: "operator-catalog-mutation-results", + error: "operator-catalog-error" + }) + }); + + function hasExactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && + actual.every((key, index) => key === expected[index]); + } + + function hasOnlyKeys(value, keys) { + return value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).every(key => keys.includes(key)); + } + + function isSafeCanonicalText(value, maximumLength, allowEmpty = false) { + return typeof value === "string" && + (allowEmpty ? value.length >= 0 : value.length > 0) && + value.length <= maximumLength && value === value.trim() && + !unsafeTextPattern.test(value); + } + + function requireRequestId(value) { + if (typeof value !== "string" || !requestIdPattern.test(value)) { + throw new TypeError("A safe operator-catalog request id is required."); + } + return value; + } + + function normalizeQuery(value) { + if (typeof value !== "string" || unsafeTextPattern.test(value)) return null; + const query = value.trim(); + return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) + ? query + : null; + } + + function normalizeMarket(value) { + return value === "krx" || value === "nxt" ? value : null; + } + + function normalizeSession(value, market) { + if (market === "krx") return value === "notApplicable" ? value : null; + if (market === "nxt") { + return value === "preMarket" || value === "afterMarket" ? value : null; + } + return null; + } + + function normalizeThemeIdentity(value) { + if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle"])) return null; + const market = normalizeMarket(value.market); + if (!market || !normalizeSession(value.session, market) || + !themeCodePattern.test(value.themeCode) || + !isSafeCanonicalText(value.themeTitle, 128) || + (market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null; + return Object.freeze({ + market, + session: value.session, + themeCode: value.themeCode, + themeTitle: value.themeTitle + }); + } + + function normalizeThemeItem(value, expectedIndex, market) { + if (!hasExactKeys(value, ["inputIndex", "itemCode", "itemName"]) || + value.inputIndex !== expectedIndex || !Number.isInteger(value.inputIndex) || + !isSafeCanonicalText(value.itemCode, 13) || + !isSafeCanonicalText(value.itemName, 200)) return null; + const prefix = value.itemCode[0]; + const suffix = value.itemCode.slice(1); + if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) || + (market === "krx" && prefix !== "P" && prefix !== "D") || + (market === "nxt" && prefix !== "X" && prefix !== "T")) return null; + return Object.freeze({ + inputIndex: value.inputIndex, + itemCode: value.itemCode, + itemName: value.itemName + }); + } + + function normalizeThemeItems(value, market) { + if (!Array.isArray(value) || value.length > MAXIMUM_THEME_ITEMS) return null; + const items = []; + const codes = new Set(); + const names = new Set(); + for (let index = 0; index < value.length; index += 1) { + const item = normalizeThemeItem(value[index], index, market); + if (!item || codes.has(item.itemCode) || names.has(item.itemName)) return null; + codes.add(item.itemCode); + names.add(item.itemName); + items.push(item); + } + return Object.freeze(items); + } + + function normalizeThemeDraft(value) { + if (!hasExactKeys(value, ["market", "themeCode", "themeTitle", "items"])) return null; + const market = normalizeMarket(value.market); + if (!market || !themeCodePattern.test(value.themeCode) || + !isSafeCanonicalText(value.themeTitle, 128) || + (market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null; + const items = normalizeThemeItems(value.items, market); + return items && Object.freeze({ + market, + themeCode: value.themeCode, + themeTitle: value.themeTitle, + items + }); + } + + function normalizeExpertIdentity(value) { + if (!hasExactKeys(value, ["expertCode", "expertName"]) || + !expertCodePattern.test(value.expertCode) || + !isSafeCanonicalText(value.expertName, 128)) return null; + return Object.freeze({ + expertCode: value.expertCode, + expertName: value.expertName + }); + } + + function normalizeRecommendation(value, expectedIndex) { + if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) || + value.playIndex !== expectedIndex || !Number.isInteger(value.playIndex) || + !stockCodePattern.test(value.stockCode) || + !isSafeCanonicalText(value.stockName, 200) || + !Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0 || + value.buyAmount > MAXIMUM_WEB_SAFE_INTEGER) return null; + return Object.freeze({ + playIndex: value.playIndex, + stockCode: value.stockCode, + stockName: value.stockName, + buyAmount: value.buyAmount + }); + } + + function normalizeRecommendations(value) { + if (!Array.isArray(value) || value.length > MAXIMUM_RECOMMENDATIONS) return null; + const items = []; + const codes = new Set(); + const names = new Set(); + for (let index = 0; index < value.length; index += 1) { + const item = normalizeRecommendation(value[index], index); + if (!item || codes.has(item.stockCode) || names.has(item.stockName)) return null; + codes.add(item.stockCode); + names.add(item.stockName); + items.push(item); + } + return Object.freeze(items); + } + + function normalizeExpertDraft(value) { + if (!hasExactKeys(value, ["expertCode", "expertName", "recommendations"])) return null; + const identity = normalizeExpertIdentity({ + expertCode: value.expertCode, + expertName: value.expertName + }); + const recommendations = normalizeRecommendations(value.recommendations); + return identity && recommendations && Object.freeze({ + expertCode: identity.expertCode, + expertName: identity.expertName, + recommendations + }); + } + + function createThemeCatalogListRequest(requestId, query = "", nxtSession = "preMarket", maximumResults) { + requireRequestId(requestId); + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === null) throw new TypeError("A safe theme catalog query is required."); + if (nxtSession !== "preMarket" && nxtSession !== "afterMarket") { + throw new TypeError("An explicit NXT catalog session is required."); + } + if (maximumResults !== undefined && + (!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) { + throw new RangeError("Theme catalog results must be limited from 1 through 500."); + } + const request = { requestId, query: normalizedQuery, nxtSession }; + if (maximumResults !== undefined) request.maximumResults = maximumResults; + return Object.freeze(request); + } + + function createExpertCatalogListRequest(requestId, query = "", maximumResults) { + requireRequestId(requestId); + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === null) throw new TypeError("A safe expert catalog query is required."); + if (maximumResults !== undefined && + (!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) { + throw new RangeError("Expert catalog results must be limited from 1 through 500."); + } + const request = { requestId, query: normalizedQuery }; + if (maximumResults !== undefined) request.maximumResults = maximumResults; + return Object.freeze(request); + } + + function createSchemaPreflightRequest(requestId) { + return Object.freeze({ requestId: requireRequestId(requestId) }); + } + + function createThemeNextCodeRequest(requestId, market) { + requireRequestId(requestId); + if (!normalizeMarket(market)) throw new TypeError("A KRX or NXT market is required."); + return Object.freeze({ requestId, market }); + } + + function createExpertNextCodeRequest(requestId) { + return Object.freeze({ requestId: requireRequestId(requestId) }); + } + + function normalizeThemeCatalogSelection(value) { + if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle", "source"])) return null; + const identity = normalizeThemeIdentity({ + market: value.market, + session: value.session, + themeCode: value.themeCode, + themeTitle: value.themeTitle + }); + if (!identity || + (identity.market === "krx" && value.source !== "oracle") || + (identity.market === "nxt" && value.source !== "mariaDb")) return null; + return Object.freeze({ ...identity, source: value.source }); + } + + function normalizeExpertCatalogSelection(value) { + if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) || value.source !== "oracle") { + return null; + } + const identity = normalizeExpertIdentity({ + expertCode: value.expertCode, + expertName: value.expertName + }); + return identity && Object.freeze({ ...identity, source: "oracle" }); + } + + function normalizeThemePreviewForEdit(value, selection) { + if (!hasExactKeys(value, [ + "requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items" + ]) || !requestIdPattern.test(value.requestId) || + !isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + value.totalRowCount > MAXIMUM_THEME_ITEMS || value.truncated !== false || + !hasExactKeys(value.selection, ["market", "session", "themeCode", "title"])) return null; + const previewIdentity = normalizeThemeIdentity({ + market: value.selection.market, + session: value.selection.session, + themeCode: value.selection.themeCode, + themeTitle: value.selection.title + }); + if (!previewIdentity || ["market", "session", "themeCode", "themeTitle"] + .some(key => previewIdentity[key] !== selection[key])) return null; + if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null; + const items = []; + const codes = new Set(); + const names = new Set(); + let previousIndex = -1; + for (const raw of value.items) { + if (!hasExactKeys(raw, ["inputIndex", "itemCode", "itemName"]) || + !Number.isInteger(raw.inputIndex) || raw.inputIndex <= previousIndex || raw.inputIndex > 9999 || + !isSafeCanonicalText(raw.itemCode, 13) || !isSafeCanonicalText(raw.itemName, 200)) return null; + const prefix = raw.itemCode[0]; + const suffix = raw.itemCode.slice(1); + if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) || + (selection.market === "krx" && prefix !== "P" && prefix !== "D") || + (selection.market === "nxt" && prefix !== "X" && prefix !== "T") || + codes.has(raw.itemCode) || names.has(raw.itemName)) return null; + previousIndex = raw.inputIndex; + codes.add(raw.itemCode); + names.add(raw.itemName); + items.push(Object.freeze({ + inputIndex: items.length, + itemCode: raw.itemCode, + itemName: raw.itemName + })); + } + return Object.freeze(items); + } + + function normalizeExpertPreviewForEdit(value, selection) { + if (!hasExactKeys(value, [ + "requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items" + ]) || !requestIdPattern.test(value.requestId) || + !isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + value.totalRowCount > MAXIMUM_RECOMMENDATIONS || value.truncated !== false) return null; + const previewIdentity = normalizeExpertIdentity(value.selection); + if (!previewIdentity || previewIdentity.expertCode !== selection.expertCode || + previewIdentity.expertName !== selection.expertName) return null; + if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null; + const items = []; + const codes = new Set(); + const names = new Set(); + let previousIndex = -1; + for (const raw of value.items) { + if (!hasExactKeys(raw, ["playIndex", "stockCode", "stockName", "buyAmount"]) || + !Number.isInteger(raw.playIndex) || raw.playIndex <= previousIndex || raw.playIndex > 9999 || + !stockCodePattern.test(raw.stockCode) || !isSafeCanonicalText(raw.stockName, 200) || + !Number.isSafeInteger(raw.buyAmount) || raw.buyAmount <= 0 || + codes.has(raw.stockCode) || names.has(raw.stockName)) return null; + previousIndex = raw.playIndex; + codes.add(raw.stockCode); + names.add(raw.stockName); + items.push(Object.freeze({ + playIndex: items.length, + stockCode: raw.stockCode, + stockName: raw.stockName, + buyAmount: raw.buyAmount + })); + } + return Object.freeze(items); + } + + function createThemeEditDraft(selectionValue, previewValue) { + const selection = normalizeThemeCatalogSelection(selectionValue); + if (!selection) throw new TypeError("A typed theme catalog selection is required."); + const items = normalizeThemePreviewForEdit(previewValue, selection); + if (!items) throw new TypeError("A complete, correlated theme preview is required for editing."); + return { + expectedIdentity: { + market: selection.market, + session: selection.session, + themeCode: selection.themeCode, + themeTitle: selection.themeTitle + }, + draft: { + market: selection.market, + themeCode: selection.themeCode, + themeTitle: selection.themeTitle, + items: items.map(item => ({ ...item })) + } + }; + } + + function createExpertEditDraft(selectionValue, previewValue) { + const selection = normalizeExpertCatalogSelection(selectionValue); + if (!selection) throw new TypeError("A typed expert catalog selection is required."); + const recommendations = normalizeExpertPreviewForEdit(previewValue, selection); + if (!recommendations) throw new TypeError("A complete, correlated expert preview is required for editing."); + return { + expectedIdentity: { + expertCode: selection.expertCode, + expertName: selection.expertName + }, + draft: { + expertCode: selection.expertCode, + expertName: selection.expertName, + recommendations: recommendations.map(item => ({ ...item })) + } + }; + } + + function createBlankThemeDraft(market, themeCode) { + if (!normalizeMarket(market) || !themeCodePattern.test(themeCode)) { + throw new TypeError("A typed market and suggested eight-digit theme code are required."); + } + return { market, themeCode, themeTitle: "", items: [] }; + } + + function createBlankExpertDraft(expertCode) { + if (!expertCodePattern.test(expertCode)) { + throw new TypeError("A suggested four-digit expert code is required."); + } + return { expertCode, expertName: "", recommendations: [] }; + } + + function cloneThemeDraft(draft) { + const normalized = normalizeThemeDraft(draft); + return normalized && { + market: normalized.market, + themeCode: normalized.themeCode, + themeTitle: normalized.themeTitle, + items: normalized.items.map(item => ({ ...item })) + }; + } + + function cloneExpertDraft(draft) { + const normalized = normalizeExpertDraft(draft); + return normalized && { + expertCode: normalized.expertCode, + expertName: normalized.expertName, + recommendations: normalized.recommendations.map(item => ({ ...item })) + }; + } + + function createThemeCreateRequest(requestId, draftValue) { + requireRequestId(requestId); + const draft = cloneThemeDraft(draftValue); + if (!draft) throw new TypeError("A complete theme draft is required."); + return Object.freeze({ requestId, draft }); + } + + function createThemeReplaceRequest(requestId, editValue) { + requireRequestId(requestId); + if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) { + throw new TypeError("An explicit theme edit identity and draft are required."); + } + const identity = normalizeThemeIdentity(editValue.expectedIdentity); + const draft = cloneThemeDraft(editValue.draft); + if (!identity || !draft || identity.market !== draft.market || + identity.themeCode !== draft.themeCode) { + throw new TypeError("The theme edit identity does not match its draft."); + } + return Object.freeze({ + requestId, + expectedIdentity: { ...identity }, + newTitle: draft.themeTitle, + items: draft.items + }); + } + + function createThemeDeleteRequest(requestId, identityValue) { + requireRequestId(requestId); + const identity = normalizeThemeIdentity(identityValue); + if (!identity) throw new TypeError("An explicit theme identity is required."); + return Object.freeze({ requestId, identity: { ...identity } }); + } + + function createExpertCreateRequest(requestId, draftValue) { + requireRequestId(requestId); + const draft = cloneExpertDraft(draftValue); + if (!draft) throw new TypeError("A complete expert draft is required."); + return Object.freeze({ requestId, draft }); + } + + function createExpertReplaceRequest(requestId, editValue) { + requireRequestId(requestId); + if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) { + throw new TypeError("An explicit expert edit identity and draft are required."); + } + const identity = normalizeExpertIdentity(editValue.expectedIdentity); + const draft = cloneExpertDraft(editValue.draft); + if (!identity || !draft || identity.expertCode !== draft.expertCode) { + throw new TypeError("The expert edit identity does not match its draft."); + } + return Object.freeze({ + requestId, + expectedIdentity: { ...identity }, + newName: draft.expertName, + recommendations: draft.recommendations + }); + } + + function createExpertDeleteRequest(requestId, identityValue) { + requireRequestId(requestId); + const identity = normalizeExpertIdentity(identityValue); + if (!identity) throw new TypeError("An explicit expert identity is required."); + return Object.freeze({ requestId, identity: { ...identity } }); + } + + function normalizeThemeListResult(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "query", "nxtSession", "retrievedAt", "totalRowCount", "truncated", + "canMutate", "writeQuarantined", "results" + ]) || !requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query || + (value.nxtSession !== "preMarket" && value.nxtSession !== "afterMarket") || + !isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" || + typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" || + (value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) || + value.results.length !== value.totalRowCount) return null; + if (expectedRequest && (value.requestId !== expectedRequest.requestId || + value.query !== expectedRequest.query || value.nxtSession !== expectedRequest.nxtSession || + value.results.length > (expectedRequest.maximumResults ?? 100))) return null; + const results = []; + const identities = new Set(); + const titles = new Set(); + for (const raw of value.results) { + const item = normalizeThemeCatalogSelection(raw); + const key = item && `${item.market}:${item.themeCode}`; + const titleKey = item && `${item.market}:${item.themeTitle}`; + if (!item || identities.has(key) || titles.has(titleKey)) return null; + identities.add(key); + titles.add(titleKey); + results.push(item); + } + return Object.freeze({ ...value, results: Object.freeze(results) }); + } + + function normalizeExpertListResult(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "canMutate", + "writeQuarantined", "results" + ]) || !requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query || + !isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || + value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" || + typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" || + (value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) || + value.results.length !== value.totalRowCount) return null; + if (expectedRequest && (value.requestId !== expectedRequest.requestId || + value.query !== expectedRequest.query || + value.results.length > (expectedRequest.maximumResults ?? 100))) return null; + const results = []; + const codes = new Set(); + const names = new Set(); + for (const raw of value.results) { + const item = normalizeExpertCatalogSelection(raw); + if (!item || codes.has(item.expertCode) || names.has(item.expertName)) return null; + codes.add(item.expertCode); + names.add(item.expertName); + results.push(item); + } + return Object.freeze({ ...value, results: Object.freeze(results) }); + } + + function normalizeThemeNextCodeResult(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "market", "themeCode", "canMutate", "writeQuarantined" + ]) || !requestIdPattern.test(value.requestId) || !normalizeMarket(value.market) || + !themeCodePattern.test(value.themeCode) || typeof value.canMutate !== "boolean" || + typeof value.writeQuarantined !== "boolean" || + (value.writeQuarantined && value.canMutate)) return null; + if (expectedRequest && (value.requestId !== expectedRequest.requestId || + value.market !== expectedRequest.market)) return null; + return Object.freeze({ ...value }); + } + + function normalizeExpertNextCodeResult(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "expertCode", "canMutate", "writeQuarantined" + ]) || !requestIdPattern.test(value.requestId) || !expertCodePattern.test(value.expertCode) || + typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" || + (value.writeQuarantined && value.canMutate)) return null; + if (expectedRequest && value.requestId !== expectedRequest.requestId) return null; + return Object.freeze({ ...value }); + } + + function normalizeSchemaResult(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "validatedAt", "validatedSources", "themeCanMutate", "expertCanMutate", + "writeQuarantined" + ]) || !requestIdPattern.test(value.requestId) || + !isSafeCanonicalText(value.validatedAt, 64) || !Number.isFinite(Date.parse(value.validatedAt)) || + !Array.isArray(value.validatedSources) || value.validatedSources.length !== 2 || + value.validatedSources[0] !== "oracle" || value.validatedSources[1] !== "mariaDb" || + typeof value.themeCanMutate !== "boolean" || typeof value.expertCanMutate !== "boolean" || + typeof value.writeQuarantined !== "boolean" || + (value.writeQuarantined && (value.themeCanMutate || value.expertCanMutate))) return null; + if (expectedRequest && value.requestId !== expectedRequest.requestId) return null; + return Object.freeze({ ...value, validatedSources: Object.freeze([...value.validatedSources]) }); + } + + const mutationOperations = new Set(["create", "replace", "delete"]); + function normalizeMutationResult(value, expected) { + if (!hasExactKeys(value, [ + "requestId", "entity", "operation", "identityCode", "operationId", "committedAt", + "writeQuarantined" + ]) || !requestIdPattern.test(value.requestId) || + (value.entity !== "theme" && value.entity !== "expert") || + !mutationOperations.has(value.operation) || + !(value.entity === "theme" ? themeCodePattern : expertCodePattern).test(value.identityCode) || + !uuidPattern.test(value.operationId) || !isSafeCanonicalText(value.committedAt, 64) || + !Number.isFinite(Date.parse(value.committedAt)) || value.writeQuarantined !== false) return null; + if (expected && ["requestId", "entity", "operation", "identityCode"] + .some(key => value[key] !== expected[key])) return null; + return Object.freeze({ ...value }); + } + + const errorCodes = new Set([ + "INVALID_REQUEST", "DATABASE_UNAVAILABLE", "INVALID_DATA", "INVALID_SCHEMA", "DATABASE_ERROR", + "PLAYOUT_PRIORITY", "PLAYOUT_ACTIVE", "DATABASE_BUSY", "WRITE_BUSY", "CONFLICT", "WRITE_FAILED", + "CANCELLED", "OUTCOME_UNKNOWN" + ]); + function normalizeCatalogError(value, expected) { + if (!hasExactKeys(value, [ + "requestId", "entity", "operation", "identityCode", "code", "message", "retryable", + "outcomeUnknown", "writeQuarantined" + ]) || (value.requestId !== "" && !requestIdPattern.test(value.requestId)) || + !["theme", "expert", "catalog"].includes(value.entity) || + !["list", "nextCode", "create", "replace", "delete", "schemaPreflight"].includes(value.operation) || + typeof value.identityCode !== "string" || value.identityCode.length > 8 || + !errorCodes.has(value.code) || !isSafeCanonicalText(value.message, 512) || + typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" || + typeof value.writeQuarantined !== "boolean" || + (value.outcomeUnknown && (value.retryable || !value.writeQuarantined)) || + (mutationOperations.has(value.operation) && value.retryable)) return null; + if (expected && ["requestId", "entity", "operation", "identityCode"] + .some(key => value[key] !== expected[key])) return null; + return Object.freeze({ ...value }); + } + + function describeMutationRequest(type, payload) { + try { + switch (type) { + case bridgeContract.requests.themeCreate: { + const request = createThemeCreateRequest(payload?.requestId, payload?.draft); + return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "create", + identityCode: request.draft.themeCode }); + } + case bridgeContract.requests.themeReplace: { + if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newTitle", "items"])) return null; + const request = createThemeReplaceRequest(payload.requestId, { + expectedIdentity: payload.expectedIdentity, + draft: { + market: payload.expectedIdentity?.market, + themeCode: payload.expectedIdentity?.themeCode, + themeTitle: payload.newTitle, + items: payload.items + } + }); + return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "replace", + identityCode: request.expectedIdentity.themeCode }); + } + case bridgeContract.requests.themeDelete: { + if (!hasExactKeys(payload, ["requestId", "identity"])) return null; + const request = createThemeDeleteRequest(payload.requestId, payload.identity); + return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "delete", + identityCode: request.identity.themeCode }); + } + case bridgeContract.requests.expertCreate: { + const request = createExpertCreateRequest(payload?.requestId, payload?.draft); + return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "create", + identityCode: request.draft.expertCode }); + } + case bridgeContract.requests.expertReplace: { + if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newName", "recommendations"])) return null; + const request = createExpertReplaceRequest(payload.requestId, { + expectedIdentity: payload.expectedIdentity, + draft: { + expertCode: payload.expectedIdentity?.expertCode, + expertName: payload.newName, + recommendations: payload.recommendations + } + }); + return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "replace", + identityCode: request.expectedIdentity.expertCode }); + } + case bridgeContract.requests.expertDelete: { + if (!hasExactKeys(payload, ["requestId", "identity"])) return null; + const request = createExpertDeleteRequest(payload.requestId, payload.identity); + return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "delete", + identityCode: request.identity.expertCode }); + } + default: + return null; + } + } catch { + return null; + } + } + + function createMutationCoordinator() { + let active = null; + let quarantined = false; + return Object.freeze({ + begin(type, payload) { + if (quarantined) throw new Error("Operator catalog writes are quarantined for this process."); + if (active) throw new Error("Another operator catalog write is already active."); + const description = describeMutationRequest(type, payload); + if (!description) throw new TypeError("A valid operator catalog mutation request is required."); + active = description; + return description; + }, + acceptResult(payload) { + if (!active) return null; + const result = normalizeMutationResult(payload, active); + if (!result) return null; + active = null; + return result; + }, + acceptError(payload) { + if (!active) return null; + const error = normalizeCatalogError(payload, active); + if (!error) return null; + if (error.outcomeUnknown || error.writeQuarantined) quarantined = true; + active = null; + return error; + }, + loseCorrelation() { + if (active) quarantined = true; + active = null; + }, + getState() { + return Object.freeze({ active, quarantined }); + } + }); + } + + return { + bridgeContract, + limits: Object.freeze({ + maximumThemeItems: MAXIMUM_THEME_ITEMS, + maximumRecommendations: MAXIMUM_RECOMMENDATIONS, + maximumResults: MAXIMUM_RESULTS, + maximumQueryLength: MAXIMUM_QUERY_LENGTH + }), + normalizeThemeIdentity, + normalizeThemeDraft, + normalizeExpertIdentity, + normalizeExpertDraft, + createThemeCatalogListRequest, + createExpertCatalogListRequest, + createSchemaPreflightRequest, + createThemeNextCodeRequest, + createExpertNextCodeRequest, + normalizeThemeCatalogSelection, + normalizeExpertCatalogSelection, + createThemeEditDraft, + createExpertEditDraft, + createBlankThemeDraft, + createBlankExpertDraft, + createThemeCreateRequest, + createThemeReplaceRequest, + createThemeDeleteRequest, + createExpertCreateRequest, + createExpertReplaceRequest, + createExpertDeleteRequest, + normalizeThemeListResult, + normalizeExpertListResult, + normalizeThemeNextCodeResult, + normalizeExpertNextCodeResult, + normalizeSchemaResult, + normalizeMutationResult, + normalizeCatalogError, + describeMutationRequest, + createMutationCoordinator + }; +}); diff --git a/Web/operator-workflow.js b/Web/operator-workflow.js new file mode 100644 index 0000000..be0d232 --- /dev/null +++ b/Web/operator-workflow.js @@ -0,0 +1,440 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnOperatorWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const MAX_TEXT_LENGTH = 128; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const stockCodePattern = /^[A-Za-z0-9]{1,32}$/; + const controlCharacterPattern = /[\u0000-\u001f\u007f]/; + const runtimeStockAsset = Object.freeze({ + relativePath: "bin/Debug/Res/종목.ini", + sha256: "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20" + }); + + function freezeCut(cut) { + return Object.freeze({ + ...cut, + section: cut.group, + alias: cut.aliases[0], + cutCode: cut.aliases[0], + aliases: Object.freeze([...cut.aliases]) + }); + } + + // bin/Debug/Res/종목.ini (SHA-256 45DFB180...940409E20), in runtime order. + // The two '-' separator rows are deliberately absent. The source RES copy differs + // at the after-hours label, so the operator-visible runtime asset is authoritative. + const stockCuts = Object.freeze([ + freezeCut({ id: "basic-expected", label: "1열판기본_예상체결가", group: "1열판 기본", detail: "예상체결가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "EXPECTED_OPENING" }), + freezeCut({ id: "basic-current", label: "1열판기본_현재가", group: "1열판 기본", detail: "현재가", builderKey: "s5001", aliases: ["5001", "N5001"], kind: "basic", subtype: "CURRENT" }), + freezeCut({ id: "basic-after-hours", label: "1열판기본_시간외단일가", group: "1열판 기본", detail: "시간외단일가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "AFTER_HOURS_SINGLE_PRICE" }), + + freezeCut({ id: "detail-open", label: "1열판상세_시가", group: "1열판 상세", detail: "시가", builderKey: "s5006", aliases: ["5006"], kind: "open-detail", subtype: "시가" }), + freezeCut({ id: "detail-face-value", label: "1열판상세_액면가", group: "1열판 상세", detail: "액면가", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "FaceValue" }), + freezeCut({ id: "detail-pbr", label: "1열판상세_PBR", group: "1열판 상세", detail: "PBR", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Valuation" }), + freezeCut({ id: "detail-volume", label: "1열판상세_거래량", group: "1열판 상세", detail: "거래량", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Volume" }), + + freezeCut({ id: "yield-5d", label: "수익률그래프_5일", group: "수익률 그래프", detail: "5일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "5일" }), + freezeCut({ id: "yield-20d", label: "수익률그래프_20일", group: "수익률 그래프", detail: "20일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "20일" }), + freezeCut({ id: "yield-60d", label: "수익률그래프_60일", group: "수익률 그래프", detail: "60일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "60일" }), + freezeCut({ id: "yield-120d", label: "수익률그래프_120일", group: "수익률 그래프", detail: "120일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "120일" }), + freezeCut({ id: "yield-240d", label: "수익률그래프_240일", group: "수익률 그래프", detail: "240일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "240일" }), + + freezeCut({ id: "candle-daily", label: "캔들그래프_일봉", group: "캔들 그래프", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "PRICE" }), + freezeCut({ id: "candle-5d", label: "캔들그래프_5일", group: "캔들 그래프", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "PRICE" }), + freezeCut({ id: "candle-20d", label: "캔들그래프_20일", group: "캔들 그래프", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "PRICE" }), + freezeCut({ id: "candle-60d", label: "캔들그래프_60일", group: "캔들 그래프", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "PRICE" }), + freezeCut({ id: "candle-120d", label: "캔들그래프_120일", group: "캔들 그래프", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "PRICE" }), + freezeCut({ id: "candle-240d", label: "캔들그래프_240일", group: "캔들 그래프", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "PRICE" }), + + freezeCut({ id: "candle-volume-daily", label: "캔들그래프(거래량)_일봉", group: "캔들 그래프 · 거래량", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "VOLUME" }), + freezeCut({ id: "candle-volume-5d", label: "캔들그래프(거래량)_5일", group: "캔들 그래프 · 거래량", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "VOLUME" }), + freezeCut({ id: "candle-volume-20d", label: "캔들그래프(거래량)_20일", group: "캔들 그래프 · 거래량", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "VOLUME" }), + freezeCut({ id: "candle-volume-60d", label: "캔들그래프(거래량)_60일", group: "캔들 그래프 · 거래량", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "VOLUME" }), + freezeCut({ id: "candle-volume-120d", label: "캔들그래프(거래량)_120일", group: "캔들 그래프 · 거래량", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "VOLUME" }), + freezeCut({ id: "candle-volume-240d", label: "캔들그래프(거래량)_240일", group: "캔들 그래프 · 거래량", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "VOLUME" }), + + freezeCut({ id: "candle-expected-5d", label: "캔들그래프(예상체결가)_5일", group: "캔들 그래프 · 예상체결가", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "EXPECTED" }), + freezeCut({ id: "candle-expected-20d", label: "캔들그래프(예상체결가)_20일", group: "캔들 그래프 · 예상체결가", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "EXPECTED" }), + freezeCut({ id: "candle-expected-60d", label: "캔들그래프(예상체결가)_60일", group: "캔들 그래프 · 예상체결가", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "EXPECTED" }), + freezeCut({ id: "candle-expected-120d", label: "캔들그래프(예상체결가)_120일", group: "캔들 그래프 · 예상체결가", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "EXPECTED" }), + freezeCut({ id: "candle-expected-240d", label: "캔들그래프(예상체결가)_240일", group: "캔들 그래프 · 예상체결가", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "EXPECTED" }), + + freezeCut({ id: "order-book", label: "호가창_표그래프", group: "표 그래프", detail: "호가창", builderKey: "s8003", aliases: ["8003"], kind: "order-book", subtype: "TABLE_GRAPH" }), + freezeCut({ id: "traders", label: "거래원_표그래프", group: "표 그래프", detail: "거래원", builderKey: "s5037", aliases: ["5037"], kind: "traders", subtype: "TABLE_GRAPH" }) + ]); + + if (stockCuts.length !== 31 || new Set(stockCuts.map(cut => cut.id)).size !== 31) { + throw new Error("The legacy stock workflow must contain exactly 31 unique cut variants."); + } + + function manualAction(id, label, builderKey, alias, table, prerequisite) { + return Object.freeze({ + id, + label, + builderKey, + alias, + cutCode: alias, + aliases: Object.freeze([alias]), + available: false, + autoAdd: false, + status: "unavailable", + prerequisiteTable: table, + prerequisites: Object.freeze([ + `선택 종목에 대한 ${table} 수동 입력 데이터`, + prerequisite, + "수동 데이터 검증과 명시적 플레이리스트 추가 흐름" + ]) + }); + } + + // These buttons opened GraphE/INPUT_* manual-data workflows in the source program. + // A stock search result alone is not sufficient input, so they must never auto-add a cut. + const manualStockActions = Object.freeze([ + manualAction("manual-revenue-mix", "주요매출 구성", "s5076", "5076", "INPUT_PIE", "구성 항목과 비중 및 기준일 입력"), + manualAction("manual-growth-metrics", "성장성 지표", "s5079", "5079", "INPUT_GROW", "성장성 지표 값과 분기 입력"), + manualAction("manual-sales", "매출액", "s5080", "5080", "INPUT_SELL", "기간별 매출액 수동 값 입력"), + manualAction("manual-operating-profit", "영업이익", "s5081", "5081", "INPUT_PROFIT", "기간별 영업이익 수동 값 입력") + ]); + + const cutsById = new Map(stockCuts.map(cut => [cut.id, cut])); + + function valueFrom(raw, names) { + for (const name of names) { + if (Object.prototype.hasOwnProperty.call(raw, name) && raw[name] !== undefined && raw[name] !== null) { + return raw[name]; + } + } + + const keys = Object.keys(raw); + for (const name of names) { + const key = keys.find(candidate => candidate.toLocaleUpperCase("en-US") === name.toLocaleUpperCase("en-US")); + if (key !== undefined && raw[key] !== undefined && raw[key] !== null) return raw[key]; + } + return undefined; + } + + function cleanText(value) { + if (typeof value !== "string") return null; + const text = value.trim(); + return text.length > 0 && text.length <= MAX_TEXT_LENGTH && !controlCharacterPattern.test(text) + ? text + : null; + } + + function normalizeMarket(value) { + const text = String(value ?? "") + .trim() + .toLocaleUpperCase("en-US") + .replace(/[\s-]+/g, "_"); + const isNxt = text.includes("NXT"); + if (["KOSPI", "KOSPI_STOCK", "코스피", "코스피종목", "NXT_KOSPI", "KOSPI_NXT", "코스피_NXT", "NXT코스피"].includes(text)) { + return { market: "kospi", isNxt }; + } + if (["KOSDAQ", "KOSDAQ_STOCK", "코스닥", "코스닥종목", "NXT_KOSDAQ", "KOSDAQ_NXT", "코스닥_NXT", "NXT코스닥"].includes(text)) { + return { market: "kosdaq", isNxt }; + } + return null; + } + + function normalizeBoolean(value) { + if (value === true || value === false) return value; + if (value === 1 || value === "1") return true; + if (value === 0 || value === "0") return false; + return null; + } + + function validateStockResult(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { valid: false, error: "stock-object-required", value: null }; + } + + const marketValue = valueFrom(raw, ["market", "MARKET", "marketName", "MARKET_NAME", "groupCode", "GROUP_CODE"]); + const normalizedMarket = normalizeMarket(marketValue); + if (!normalizedMarket) return { valid: false, error: "unsupported-market", value: null }; + + const rawStockName = cleanText(valueFrom(raw, [ + "stockName", "STOCK_NAME", "F_STOCK_NAME", "F_STOCK_WANNAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME" + ])); + const rawDisplayName = cleanText(valueFrom(raw, [ + "displayName", "DISPLAY_NAME", "F_STOCK_WANNAME", "F_STOCK_NAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME" + ])); + if (!rawStockName && !rawDisplayName) { + return { valid: false, error: "stock-name-required", value: null }; + } + + const rawCode = valueFrom(raw, [ + "stockCode", "STOCK_CODE", "F_STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE" + ]); + const stockCode = typeof rawCode === "number" && Number.isSafeInteger(rawCode) && rawCode >= 0 + ? String(rawCode) + : cleanText(rawCode); + if (!stockCode || !stockCodePattern.test(stockCode)) { + return { valid: false, error: "invalid-stock-code", value: null }; + } + + const explicitNxtValue = valueFrom(raw, ["isNxt", "IS_NXT", "nxt", "NXT"]); + const explicitNxt = explicitNxtValue === undefined ? null : normalizeBoolean(explicitNxtValue); + if (explicitNxtValue !== undefined && explicitNxt === null) { + return { valid: false, error: "invalid-nxt-flag", value: null }; + } + + const nameImpliesNxt = `${rawStockName || ""} ${rawDisplayName || ""}`.includes("(NXT)"); + const inferredNxt = normalizedMarket.isNxt || nameImpliesNxt; + if (explicitNxt === false && inferredNxt) { + return { valid: false, error: "inconsistent-nxt-selection", value: null }; + } + const isNxt = explicitNxt === true || inferredNxt; + + const withoutNxtSuffix = value => value.replace(/\s*\(NXT\)\s*/g, "").trim(); + const stockName = cleanText(withoutNxtSuffix(rawStockName || rawDisplayName)); + if (!stockName) return { valid: false, error: "stock-name-required", value: null }; + const baseDisplayName = cleanText(rawDisplayName || rawStockName); + if (!baseDisplayName) return { valid: false, error: "display-name-required", value: null }; + const displayName = isNxt + ? `${withoutNxtSuffix(baseDisplayName)}(NXT)` + : baseDisplayName; + + return { + valid: true, + error: null, + value: Object.freeze({ + market: normalizedMarket.market, + stockName, + displayName, + stockCode, + isNxt + }) + }; + } + + function normalizeStockResult(raw) { + const validation = validateStockResult(raw); + return validation.valid ? validation.value : null; + } + + function getStockCut(cutId) { + return typeof cutId === "string" ? cutsById.get(cutId) || null : null; + } + + function isStockCutAllowed(stock, cutId) { + const normalized = normalizeStockResult(stock); + const cut = getStockCut(cutId); + return Boolean(normalized && cut && (!normalized.isNxt || cut.id === "basic-current")); + } + + function requireOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Stock playlist options are required."); + } + const id = cleanText(options.id); + if (!id || !identifierPattern.test(id)) { + throw new TypeError("A safe stock playlist entry id is required."); + } + + const ma5 = options.ma5 === undefined ? false : normalizeBoolean(options.ma5); + const ma20 = options.ma20 === undefined ? false : normalizeBoolean(options.ma20); + if (ma5 === null || ma20 === null) { + throw new TypeError("Moving-average flags must be boolean values."); + } + + const fadeDuration = options.fadeDuration === undefined + ? DEFAULT_FADE_DURATION + : Number(options.fadeDuration); + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + return { id, ma5, ma20, fadeDuration }; + } + + function marketGroup(stock, stockSuffix) { + const prefix = stock.isNxt ? "NXT_" : ""; + return `${prefix}${stock.market.toLocaleUpperCase("en-US")}${stockSuffix}`; + } + + function selectionFor(stock, cut, movingAverages) { + const common = { + subject: stock.displayName, + dataCode: stock.stockCode + }; + switch (cut.kind) { + case "basic": + return { + groupCode: marketGroup(stock, ""), + ...common, + graphicType: "1열판기본", + subtype: cut.subtype + }; + case "open-detail": + return { + groupCode: marketGroup(stock, "_STOCK"), + ...common, + graphicType: "1열판상세", + subtype: cut.subtype + }; + case "detail": + return { + groupCode: marketGroup(stock, "_STOCK"), + ...common, + graphicType: "1열판상세", + subtype: cut.subtype + }; + case "yield": + return { + groupCode: marketGroup(stock, ""), + ...common, + graphicType: "수익률그래프", + subtype: cut.subtype + }; + case "candle": { + const flags = []; + if (movingAverages.ma5) flags.push("MA5"); + if (movingAverages.ma20) flags.push("MA20"); + return { + groupCode: marketGroup(stock, "_STOCK"), + ...common, + graphicType: flags.join(","), + subtype: cut.subtype + }; + } + case "order-book": + return { + groupCode: marketGroup(stock, ""), + ...common, + graphicType: "ORDER_BOOK", + subtype: "TABLE_GRAPH" + }; + case "traders": + return { + groupCode: marketGroup(stock, ""), + ...common, + graphicType: "TRADER", + subtype: "TABLE_GRAPH" + }; + default: + throw new Error("The stock cut mapping is unavailable."); + } + } + + function createStockPlaylistEntry(stock, cutId, options) { + const normalized = normalizeStockResult(stock); + if (!normalized) throw new TypeError("A valid selected stock is required."); + const cut = getStockCut(cutId); + if (!cut) throw new RangeError("The selected stock cut is unknown."); + if (!isStockCutAllowed(normalized, cut.id)) { + throw new RangeError("NXT stocks support only 1열판기본_현재가."); + } + + const normalizedOptions = requireOptions(options); + const movingAverages = Object.freeze({ + ma5: cut.kind === "candle" && normalizedOptions.ma5, + ma20: cut.kind === "candle" && normalizedOptions.ma20 + }); + const selection = Object.freeze(selectionFor(normalized, cut, movingAverages)); + const code = normalized.isNxt ? "N5001" : cut.aliases[0]; + const operator = Object.freeze({ + source: "legacy-stock-workflow", + cutId: cut.id, + legacyLabel: cut.label, + legacyGroup: cut.group, + legacyDetail: cut.detail, + stock: normalized, + movingAverages + }); + + return { + id: normalizedOptions.id, + builderKey: cut.builderKey, + code, + aliases: [...cut.aliases], + title: normalized.displayName, + detail: cut.label, + category: "stock", + market: normalized.market, + stockName: normalized.stockName, + displayName: normalized.displayName, + stockCode: normalized.stockCode, + isNxt: normalized.isNxt, + selection, + enabled: true, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: selection.graphicType, + subtype: selection.subtype, + operator + }; + } + + function sameStringArray(left, right) { + return Array.isArray(left) && Array.isArray(right) && + left.length === right.length && left.every((value, index) => value === right[index]); + } + + function sameSelection(left, right) { + if (!left || typeof left !== "object" || !right || typeof right !== "object") return false; + return ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => typeof left[key] === "string" && left[key] === right[key]); + } + + function restoreStockPlaylistEntry(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || + typeof value.enabled !== "boolean") { + return null; + } + const operator = value.operator; + if (!operator || typeof operator !== "object" || + operator.source !== "legacy-stock-workflow" || + typeof operator.cutId !== "string" || + !operator.movingAverages || typeof operator.movingAverages !== "object" || + typeof operator.movingAverages.ma5 !== "boolean" || + typeof operator.movingAverages.ma20 !== "boolean") { + return null; + } + + let recreated; + try { + recreated = createStockPlaylistEntry(operator.stock, operator.cutId, { + id: value.id, + ma5: operator.movingAverages.ma5, + ma20: operator.movingAverages.ma20, + fadeDuration: value.fadeDuration + }); + } catch { + return null; + } + + const trustedScalarKeys = [ + "id", "builderKey", "code", "title", "detail", "category", "market", + "stockName", "displayName", "stockCode", "isNxt", "fadeDuration", + "graphicType", "subtype" + ]; + if (!trustedScalarKeys.every(key => value[key] === recreated[key]) || + !sameStringArray(value.aliases, recreated.aliases) || + !sameSelection(value.selection, recreated.selection) || + operator.legacyLabel !== recreated.operator.legacyLabel || + operator.legacyGroup !== recreated.operator.legacyGroup || + operator.legacyDetail !== recreated.operator.legacyDetail) { + return null; + } + + return { ...recreated, enabled: value.enabled }; + } + + return { + runtimeStockAsset, + stockCuts, + manualStockActions, + normalizeMarket, + validateStockResult, + normalizeStockResult, + getStockCut, + isStockCutAllowed, + createStockPlaylistEntry, + restoreStockPlaylistEntry + }; +}); diff --git a/Web/overseas-workflow.js b/Web/overseas-workflow.js new file mode 100644 index 0000000..420d8ec --- /dev/null +++ b/Web/overseas-workflow.js @@ -0,0 +1,521 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnOverseasWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const DEFAULT_MAXIMUM_RESULTS = 100; + const MAXIMUM_RESULTS = 500; + const MAXIMUM_QUERY_LENGTH = 64; + const SCHEMA_VERSION = 1; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const symbolPattern = /^[A-Za-z0-9@._$:-]{1,64}$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + + const bridgeContracts = Object.freeze({ + industry: Object.freeze({ + requestType: "search-overseas-industries", + resultType: "overseas-industry-results", + errorType: "overseas-industry-error", + defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS, + maximumResults: MAXIMUM_RESULTS, + maximumQueryLength: MAXIMUM_QUERY_LENGTH + }), + stock: Object.freeze({ + requestType: "search-world-stocks", + resultType: "world-stock-search-results", + errorType: "world-stock-search-error", + defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS, + maximumResults: MAXIMUM_RESULTS, + maximumQueryLength: MAXIMUM_QUERY_LENGTH + }) + }); + + const sourceContract = Object.freeze({ + originalControl: "Control/UC5.cs", + industry: Object.freeze({ + fdtc: "0", + nationCodes: Object.freeze(["US"]), + lookupField: "F_KNAM", + currentCutCode: "5001" + }), + stock: Object.freeze({ + fdtc: "1", + nationCodes: Object.freeze(["US", "TW"]), + lookupField: "F_INPUT_NAME", + currentCutCode: "5001", + candleCutCodes: Object.freeze(["8061", "8040", "8046", "8051", "8056"]) + }) + }); + + function action(id, label, builderKey, code, kind, periodDays = null) { + return Object.freeze({ + id, + label, + builderKey, + code, + aliases: Object.freeze([code]), + kind, + periodDays + }); + } + + const industryActions = Object.freeze([ + action("industry-current", "\uBBF8\uAD6D \uD574\uC678\uC5C5\uC885 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current") + ]); + const stockActions = Object.freeze([ + action("stock-current", "\uD574\uC678\uC885\uBAA9 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current"), + action("stock-candle-5d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 5\uC77C", "s8010", "8061", "candle", 5), + action("stock-candle-20d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 20\uC77C", "s8010", "8040", "candle", 20), + action("stock-candle-60d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 60\uC77C", "s8010", "8046", "candle", 60), + action("stock-candle-120d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 120\uC77C", "s8010", "8051", "candle", 120), + action("stock-candle-240d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 240\uC77C", "s8010", "8056", "candle", 240) + ]); + const actionsById = new Map( + [...industryActions, ...stockActions].map(value => [value.id, value])); + + function hasExactKeys(value, expected) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const required = [...expected].sort(); + return actual.length === required.length && actual.every((key, index) => key === required[index]); + } + + function isSafeCanonicalText(value, maximumLength, allowEmpty = false) { + return typeof value === "string" && + value.length <= maximumLength && + (allowEmpty || value.length > 0) && + value === value.trim() && + !unsafeTextPattern.test(value); + } + + function normalizeQuery(value) { + if (typeof value !== "string" || unsafeTextPattern.test(value)) return null; + const query = value.trim(); + return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null; + } + + function normalizeSymbol(value) { + return isSafeCanonicalText(value, 64) && symbolPattern.test(value) ? value : null; + } + + function normalizeIndustry(value) { + if (!hasExactKeys(value, [ + "koreanName", "inputName", "symbol", "nationCode", "fdtc" + ])) return null; + if (!isSafeCanonicalText(value.koreanName, 200) || value.koreanName.includes("|") || + !isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") || + !normalizeSymbol(value.symbol) || value.nationCode !== "US" || value.fdtc !== "0") { + return null; + } + return Object.freeze({ + koreanName: value.koreanName, + inputName: value.inputName, + symbol: value.symbol, + nationCode: "US", + fdtc: "0" + }); + } + + function normalizeStock(value) { + if (!hasExactKeys(value, ["inputName", "symbol", "nationCode", "fdtc"])) return null; + if (!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") || + !normalizeSymbol(value.symbol) || !["US", "TW"].includes(value.nationCode) || + value.fdtc !== "1") { + return null; + } + return Object.freeze({ + inputName: value.inputName, + symbol: value.symbol, + nationCode: value.nationCode, + fdtc: "1" + }); + } + + function createSearchRequest(requestId, query, maximumResults) { + if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) { + throw new TypeError("A safe overseas search request id is required."); + } + const normalizedQuery = normalizeQuery(query ?? ""); + if (normalizedQuery === null) { + throw new TypeError(`An overseas search query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`); + } + if (maximumResults !== undefined && + (!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) { + throw new RangeError(`Overseas results must be limited from 1 through ${MAXIMUM_RESULTS}.`); + } + const result = { requestId, query: normalizedQuery }; + if (maximumResults !== undefined) result.maximumResults = maximumResults; + return Object.freeze(result); + } + + function createIndustrySearchRequest(requestId, query = "", maximumResults) { + return createSearchRequest(requestId, query, maximumResults); + } + + function createStockSearchRequest(requestId, query = "", maximumResults) { + return createSearchRequest(requestId, query, maximumResults); + } + + function compareUpper(left, right) { + const upperLeft = left.toLocaleUpperCase("en-US"); + const upperRight = right.toLocaleUpperCase("en-US"); + if (upperLeft < upperRight) return -1; + if (upperLeft > upperRight) return 1; + return 0; + } + + function compareOrdinal(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; + } + + function compareIndustries(left, right) { + return compareUpper(left.koreanName, right.koreanName) || + compareOrdinal(left.inputName, right.inputName) || + compareOrdinal(left.symbol, right.symbol); + } + + function compareStocks(left, right) { + return compareUpper(left.inputName, right.inputName) || + compareOrdinal(left.symbol, right.symbol) || + compareOrdinal(left.nationCode, right.nationCode); + } + + function normalizeSearchResponse(value, expectedRequest, normalizeItem, compare, duplicateKeys) { + if (!hasExactKeys(value, [ + "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results" + ]) || !isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) || + normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.retrievedAt, 64) || + !Number.isFinite(Date.parse(value.retrievedAt)) || !Number.isInteger(value.totalRowCount) || + value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_RESULTS || + typeof value.truncated !== "boolean" || !Array.isArray(value.results) || + value.results.length !== value.totalRowCount) { + return null; + } + if (expectedRequest) { + let expected; + try { + expected = createSearchRequest( + expectedRequest.requestId, + expectedRequest.query, + expectedRequest.maximumResults); + } catch { + return null; + } + const expectedLimit = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS; + if (value.requestId !== expected.requestId || value.query !== expected.query || + value.results.length > expectedLimit) { + return null; + } + } + + const results = []; + const keySets = duplicateKeys.map(() => new Set()); + for (const raw of value.results) { + const item = normalizeItem(raw); + if (!item) return null; + for (let index = 0; index < duplicateKeys.length; index += 1) { + const key = duplicateKeys[index](item); + if (keySets[index].has(key)) return null; + keySets[index].add(key); + } + if (results.length && compare(results[results.length - 1], item) > 0) return null; + results.push(item); + } + return Object.freeze({ + requestId: value.requestId, + query: value.query, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + results: Object.freeze(results) + }); + } + + function normalizeIndustrySearchResponse(value, expectedRequest) { + return normalizeSearchResponse( + value, + expectedRequest, + normalizeIndustry, + compareIndustries, + [item => item.koreanName, item => item.inputName, item => item.symbol]); + } + + function normalizeStockSearchResponse(value, expectedRequest) { + return normalizeSearchResponse( + value, + expectedRequest, + normalizeStock, + compareStocks, + [item => item.inputName, item => `${item.inputName}\u0000${item.symbol}\u0000${item.nationCode}`]); + } + + function normalizeSearchError(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "query", "message"]) || + !isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) || + normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.message, 512)) { + return null; + } + if (expectedRequest && + (value.requestId !== expectedRequest.requestId || + value.query !== normalizeQuery(expectedRequest.query))) return null; + return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message }); + } + + function normalizeIndustrySearchError(value, expectedRequest) { + return normalizeSearchError(value, expectedRequest); + } + + function normalizeStockSearchError(value, expectedRequest) { + return normalizeSearchError(value, expectedRequest); + } + + function requireOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options) || + !isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) { + throw new TypeError("Safe overseas playlist options are required."); + } + const fadeDuration = options.fadeDuration === undefined + ? DEFAULT_FADE_DURATION + : options.fadeDuration; + const ma5 = options.ma5 === undefined ? false : options.ma5; + const ma20 = options.ma20 === undefined ? false : options.ma20; + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") { + throw new TypeError("Moving-average flags must be boolean values."); + } + return Object.freeze({ id: options.id, fadeDuration, ma5, ma20 }); + } + + function encodeIndustryIdentity(value) { + return `${value.inputName}|${value.symbol}|${value.nationCode}|${value.fdtc}`; + } + + function encodeStockIdentity(value) { + return `${value.symbol}|${value.nationCode}|${value.fdtc}`; + } + + function movingAverageFlags(actionValue, options) { + const enabled = actionValue.kind === "candle"; + const movingAverages = Object.freeze({ + ma5: enabled && options.ma5, + ma20: enabled && options.ma20 + }); + const flags = []; + if (movingAverages.ma5) flags.push("MA5"); + if (movingAverages.ma20) flags.push("MA20"); + return Object.freeze({ movingAverages, graphicType: flags.join(",") }); + } + + function buildOverseasIndustrySelection(industryValue) { + const industry = normalizeIndustry(industryValue); + if (!industry) throw new TypeError("A valid US overseas-industry identity is required."); + return Object.freeze({ + builderKey: "s5001", + code: "5001", + aliases: Object.freeze(["5001"]), + selection: Object.freeze({ + groupCode: "FOREIGN_INDUSTRY", + subject: industry.koreanName, + graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8", + subtype: "CURRENT", + dataCode: encodeIndustryIdentity(industry) + }), + movingAverages: Object.freeze({ ma5: false, ma20: false }) + }); + } + + function buildOverseasStockSelection(stockValue, actionId, options = {}) { + const stock = normalizeStock(stockValue); + const selectedAction = actionsById.get(actionId); + if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required."); + if (!selectedAction || !stockActions.includes(selectedAction)) { + throw new RangeError("The overseas-stock action is unknown."); + } + const ma5 = options.ma5 === undefined ? false : options.ma5; + const ma20 = options.ma20 === undefined ? false : options.ma20; + if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") { + throw new TypeError("Moving-average flags must be boolean values."); + } + const flags = movingAverageFlags(selectedAction, { ma5, ma20 }); + return Object.freeze({ + builderKey: selectedAction.builderKey, + code: selectedAction.code, + aliases: selectedAction.aliases, + selection: Object.freeze({ + groupCode: "FOREIGN_STOCK", + subject: stock.inputName, + graphicType: selectedAction.kind === "current" + ? "\u0031\uC5F4\uD310\uAE30\uBCF8" + : flags.graphicType, + subtype: selectedAction.kind === "current" ? "CURRENT" : "PRICE", + dataCode: encodeStockIdentity(stock) + }), + movingAverages: flags.movingAverages + }); + } + + function createOverseasIndustryPlaylistEntry(industryValue, options) { + const industry = normalizeIndustry(industryValue); + if (!industry) throw new TypeError("A valid US overseas-industry identity is required."); + const normalizedOptions = requireOptions(options); + const mapping = buildOverseasIndustrySelection(industry); + const actionValue = industryActions[0]; + return { + id: normalizedOptions.id, + builderKey: mapping.builderKey, + code: mapping.code, + aliases: [...mapping.aliases], + title: industry.koreanName, + detail: actionValue.label, + category: "plate", + market: "overseas", + symbol: industry.symbol, + nationCode: industry.nationCode, + fdtc: industry.fdtc, + selection: mapping.selection, + enabled: true, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: mapping.selection.graphicType, + subtype: mapping.selection.subtype, + operator: Object.freeze({ + source: "legacy-overseas-workflow", + schemaVersion: SCHEMA_VERSION, + kind: "industry", + actionId: actionValue.id, + identity: industry, + movingAverages: mapping.movingAverages + }) + }; + } + + function createOverseasStockPlaylistEntry(stockValue, actionId, options) { + const stock = normalizeStock(stockValue); + if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required."); + const selectedAction = actionsById.get(actionId); + if (!selectedAction || !stockActions.includes(selectedAction)) { + throw new RangeError("The overseas-stock action is unknown."); + } + const normalizedOptions = requireOptions(options); + const mapping = buildOverseasStockSelection(stock, actionId, normalizedOptions); + return { + id: normalizedOptions.id, + builderKey: mapping.builderKey, + code: mapping.code, + aliases: [...mapping.aliases], + title: stock.inputName, + detail: selectedAction.label, + category: selectedAction.kind === "candle" ? "chart" : "stock", + market: "overseas", + symbol: stock.symbol, + nationCode: stock.nationCode, + fdtc: stock.fdtc, + selection: mapping.selection, + enabled: true, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: mapping.selection.graphicType, + subtype: mapping.selection.subtype, + operator: Object.freeze({ + source: "legacy-overseas-workflow", + schemaVersion: SCHEMA_VERSION, + kind: "stock", + actionId, + identity: stock, + movingAverages: mapping.movingAverages + }) + }; + } + + function sameSelection(left, right) { + return hasExactKeys(left, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) && + hasExactKeys(right, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) && + ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => left[key] === right[key]); + } + + function sameAliases(value, recreated) { + return Array.isArray(value.aliases) && value.aliases.length === 1 && + value.aliases[0] === recreated.code; + } + + function restoreOverseasPlaylistEntry(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || + typeof value.enabled !== "boolean") return null; + const operator = value.operator; + if (!hasExactKeys(operator, [ + "source", "schemaVersion", "kind", "actionId", "identity", "movingAverages" + ]) || operator.source !== "legacy-overseas-workflow" || + operator.schemaVersion !== SCHEMA_VERSION || + !hasExactKeys(operator.movingAverages, ["ma5", "ma20"]) || + typeof operator.movingAverages.ma5 !== "boolean" || + typeof operator.movingAverages.ma20 !== "boolean") return null; + + let recreated; + try { + recreated = operator.kind === "industry" + ? createOverseasIndustryPlaylistEntry(operator.identity, { + id: value.id, + fadeDuration: value.fadeDuration, + ma5: operator.movingAverages.ma5, + ma20: operator.movingAverages.ma20 + }) + : operator.kind === "stock" + ? createOverseasStockPlaylistEntry(operator.identity, operator.actionId, { + id: value.id, + fadeDuration: value.fadeDuration, + ma5: operator.movingAverages.ma5, + ma20: operator.movingAverages.ma20 + }) + : null; + } catch { + return null; + } + if (!recreated || operator.actionId !== recreated.operator.actionId) return null; + const scalars = [ + "id", "builderKey", "code", "title", "detail", "category", "market", + "symbol", "nationCode", "fdtc", "fadeDuration", "graphicType", "subtype" + ]; + if (!scalars.every(key => value[key] === recreated[key]) || + !sameAliases(value, recreated) || !sameSelection(value.selection, recreated.selection) || + JSON.stringify(operator) !== JSON.stringify(recreated.operator)) return null; + return { ...recreated, enabled: value.enabled }; + } + + function isActionAllowed(kind, actionId, identity) { + if (kind === "industry") { + return actionId === "industry-current" && Boolean(normalizeIndustry(identity)); + } + return kind === "stock" && Boolean(normalizeStock(identity)) && + stockActions.some(value => value.id === actionId); + } + + return { + bridgeContracts, + sourceContract, + industryActions, + stockActions, + normalizeIndustry, + normalizeStock, + createIndustrySearchRequest, + createStockSearchRequest, + normalizeIndustrySearchResponse, + normalizeStockSearchResponse, + normalizeIndustrySearchError, + normalizeStockSearchError, + isActionAllowed, + buildOverseasIndustrySelection, + buildOverseasStockSelection, + createOverseasIndustryPlaylistEntry, + createOverseasStockPlaylistEntry, + restoreOverseasPlaylistEntry, + refreshOverseasPlaylistEntry: restoreOverseasPlaylistEntry + }; +}); diff --git a/Web/playout-safety.js b/Web/playout-safety.js index 9e91ac0..e886ca2 100644 --- a/Web/playout-safety.js +++ b/Web/playout-safety.js @@ -42,6 +42,14 @@ return null; } + function legacyTakeInShortcutAction(snapshot) { + if (!snapshot || snapshot.pending) return "blocked"; + if (!snapshot.takeInAllowed) return "take-in-locked"; + if (snapshot.engineState === "PROGRAM" || snapshot.onAirCode) return "next-required"; + if (snapshot.engineState === "PREPARED" || snapshot.preparedCode) return "take-in"; + return "prepare-then-take-in"; + } + function matchesPendingCommand(pending, payload) { return Boolean( pending && payload && @@ -94,6 +102,7 @@ normalizeConnectionState, responseTimeoutFromNative, commandBlockReason, + legacyTakeInShortcutAction, matchesPendingCommand, canClearCommandError, playlistSnapshotLocked, diff --git a/Web/styles.css b/Web/styles.css index 416bcf5..22d2f2c 100644 --- a/Web/styles.css +++ b/Web/styles.css @@ -28,6 +28,7 @@ body { background: var(--bg); color: var(--text); font-size: 13px; } button, input { font: inherit; } button { color: inherit; } +.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } .app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); width: 100%; height: 100%; } @@ -160,6 +161,324 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor .catalog-tabs { display: flex; gap: 5px; padding: 0 13px 11px; border-bottom: 1px solid var(--border-soft); } .catalog-tabs button { padding: 5px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; } .catalog-tabs button.active { border-color: var(--border); background: var(--surface-3); color: white; } +.stock-workflow { display: flex; min-height: 405px; max-height: 56%; flex: 0 0 430px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; } +.stock-workflow[hidden] { display: none; } +.stock-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; } +.stock-workflow.collapsed > :not(.stock-workflow-header) { display: none; } +.stock-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 6px; border-bottom: 1px solid var(--border-soft); } +.stock-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } +.stock-workflow-header .panel-kicker { margin-bottom: 3px; } +.stock-workflow-tools { display: flex; align-items: center; gap: 5px; } +.stock-workflow-tools button { min-height: 23px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; } +.stock-search-form { display: grid; grid-template-columns: minmax(0, 1fr) 48px; gap: 6px; padding: 8px 10px 6px; } +.stock-search-form input { min-width: 0; height: 31px; padding: 0 9px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #d4e0ec; font-size: 10px; } +.stock-search-form input:focus { border-color: rgba(86,168,255,.68); box-shadow: 0 0 0 2px rgba(86,168,255,.08); } +.stock-search-form button { height: 31px; min-height: 31px; border-color: rgba(86,168,255,.35); background: rgba(86,168,255,.1); color: #91c8ff; font-size: 9px; cursor: pointer; } +.stock-search-form button:disabled { cursor: wait; opacity: .55; } +.stock-search-state { min-height: 27px; margin: 0 10px 6px; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 5px; color: #70849c; font-size: 8px; line-height: 1.45; } +.stock-search-state.loading { border-color: rgba(86,168,255,.22); color: #8ec4ff; } +.stock-search-state.error { border-color: rgba(255,102,128,.22); color: #ff9bad; } +.stock-search-state.ready { border-color: rgba(50,213,164,.18); color: #8bdcc4; } +.stock-search-results { min-height: 55px; max-height: 112px; margin: 0 10px 7px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; } +.stock-result { display: grid; width: 100%; min-height: 37px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 5px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.32); border-radius: 0; background: transparent; text-align: left; cursor: pointer; } +.stock-result:last-child { border-bottom: 0; } +.stock-result:hover, .stock-result.selected { background: rgba(86,168,255,.08); } +.stock-result.selected { box-shadow: inset 2px 0 var(--mint); } +.stock-result strong, .stock-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stock-result strong { margin-bottom: 2px; color: #d6e2ef; font-size: 9px; } +.stock-result small { color: #68809a; font: 7px Consolas, monospace; } +.stock-market-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 7px Consolas, monospace; white-space: nowrap; } +.stock-results-empty { padding: 18px 8px; color: #5f7288; font-size: 8px; text-align: center; } +.selected-stock { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 7px; margin: 0 10px 7px; padding: 6px 7px; border: 1px solid rgba(50,213,164,.2); border-radius: 6px; background: rgba(50,213,164,.055); } +.selected-stock[hidden] { display: none; } +.selected-stock span { color: #6f8da4; font-size: 7px; } +.selected-stock strong { overflow: hidden; color: #9ce4cf; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.selected-stock small { color: #6f8da4; font: 7px Consolas, monospace; white-space: nowrap; } +.stock-cut-header { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 7px; min-height: 34px; padding: 0 10px; border-top: 1px solid rgba(53,75,99,.25); border-bottom: 1px solid rgba(53,75,99,.25); } +.stock-cut-header strong, .stock-cut-header small { display: block; } +.stock-cut-header strong { color: #aebed0; font-size: 9px; } +.stock-cut-header small { margin-top: 1px; color: #5f7288; font-size: 7px; } +.stock-cut-header label { display: flex; align-items: center; gap: 3px; color: #8296ad; font-size: 7px; white-space: nowrap; } +.stock-cut-header input { width: 11px; height: 11px; } +.stock-cut-list { min-height: 90px; flex: 1; overflow-y: auto; } +.stock-cut-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas, monospace; letter-spacing: .05em; } +.stock-cut-row { display: grid; min-height: 31px; grid-template-columns: 28px minmax(0, 1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); cursor: default; } +.stock-cut-row:hover { background: rgba(255,255,255,.025); } +.stock-cut-row.disabled { opacity: .38; } +.stock-cut-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; } +.stock-cut-copy strong, .stock-cut-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stock-cut-copy strong { color: #b6c6d8; font-size: 8px; } +.stock-cut-copy small { margin-top: 2px; color: #5f7288; font: 7px Consolas, monospace; } +.stock-cut-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; } +.stock-cut-add:disabled { cursor: not-allowed; opacity: .35; } +.stock-cut-separator { height: 5px; border-bottom: 1px solid rgba(53,75,99,.35); background: rgba(255,255,255,.012); } +.manual-stock-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 6px 8px; border-top: 1px solid var(--border-soft); } +.manual-stock-actions button { min-width: 0; height: 25px; min-height: 25px; padding: 0 4px; overflow: hidden; color: #7489a0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } +.manual-stock-actions button:disabled { cursor: not-allowed; opacity: .55; } +.legacy-fixed-workflow { display: flex; min-height: 405px; max-height: 64%; flex: 0 0 455px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; } +.legacy-fixed-workflow[hidden] { display: none; } +.legacy-fixed-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; } +.legacy-fixed-workflow.collapsed > :not(.legacy-fixed-header) { display: none; } +.legacy-fixed-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 6px; border-bottom: 1px solid var(--border-soft); } +.legacy-fixed-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } +.legacy-fixed-header .panel-kicker { margin-bottom: 3px; } +.legacy-fixed-tools { display: flex; align-items: center; gap: 5px; } +.legacy-fixed-tools button { min-height: 23px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; } +.legacy-fixed-search { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; height: 36px; margin: 8px 10px 5px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: #07111d; } +.legacy-fixed-search span { color: #60748c; font-size: 14px; } +.legacy-fixed-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #d4e0ec; font-size: 9px; } +.legacy-fixed-state { min-height: 24px; padding: 3px 11px 5px; color: #6d8299; font-size: 7px; } +.legacy-fixed-sections { min-height: 0; flex: 1; overflow-y: auto; } +.legacy-fixed-section { border-top: 1px solid rgba(53,75,99,.28); } +.legacy-fixed-section > header { position: sticky; z-index: 2; top: 0; display: flex; min-height: 28px; align-items: center; justify-content: space-between; padding: 0 9px; background: #0b1827; color: #7890aa; font: 8px Consolas, "Malgun Gothic", sans-serif; } +.legacy-fixed-section > header span { color: #536b84; font-size: 7px; } +.legacy-fixed-row { display: grid; min-height: 34px; grid-template-columns: 29px minmax(0, 1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-top: 1px solid rgba(53,75,99,.2); } +.legacy-fixed-row:hover { background: rgba(255,255,255,.025); } +.legacy-fixed-row.unavailable { opacity: .48; } +.legacy-fixed-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; } +.legacy-fixed-copy strong, .legacy-fixed-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.legacy-fixed-copy strong { color: #bac9d9; font-size: 8px; } +.legacy-fixed-copy small { margin-top: 2px; color: #60748c; font: 7px Consolas, monospace; } +.legacy-fixed-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; } +.legacy-fixed-add:disabled { cursor: not-allowed; color: #697b90; opacity: .45; } +.industry-workflow { display: flex; min-height: 405px; max-height: 64%; flex: 0 0 455px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; } +.industry-workflow[hidden] { display: none; } +.industry-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; } +.industry-workflow.collapsed > :not(.industry-workflow-header) { display: none; } +.industry-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); } +.industry-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } +.industry-workflow-header .panel-kicker { margin-bottom: 3px; } +.industry-workflow-tools { display: flex; align-items: center; gap: 4px; } +.industry-workflow-tools button { min-height: 23px; padding: 0 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; } +.industry-workflow-tools button:disabled { cursor: wait; opacity: .5; } +.industry-search { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; height: 34px; margin: 7px 9px 4px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: #07111d; } +.industry-search span { color: #60748c; font-size: 14px; } +.industry-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #d4e0ec; font-size: 9px; } +.industry-state { min-height: 23px; padding: 3px 10px 5px; color: #6d8299; font-size: 7px; } +.industry-state.error { color: #ff9bad; } +.industry-results { min-height: 70px; max-height: 105px; margin: 0 9px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; } +.industry-result { display: grid; width: 100%; min-height: 30px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; } +.industry-result:last-child { border-bottom: 0; } +.industry-result:hover, .industry-result.selected { background: rgba(86,168,255,.075); } +.industry-result strong { overflow: hidden; color: #b9c8d8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.industry-result small { color: #60748c; font: 7px Consolas, monospace; } +.industry-pair { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) 27px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); } +.industry-pair div { min-width: 0; } +.industry-pair span, .industry-pair strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.industry-pair span { margin-bottom: 2px; color: #5e748c; font-size: 6px; } +.industry-pair strong { color: #aebed0; font-size: 8px; } +.industry-pair button { min-height: 25px; padding: 0 6px; color: #8298af; font-size: 8px; } +.industry-cut-list { min-height: 90px; flex: 1; overflow-y: auto; } +.industry-cut-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas, monospace; letter-spacing: .04em; } +.industry-cut-row { display: grid; min-height: 31px; grid-template-columns: 28px minmax(0,1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); } +.industry-cut-row:hover { background: rgba(255,255,255,.025); } +.industry-cut-row.disabled { opacity: .4; } +.industry-cut-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; } +.industry-cut-copy strong, .industry-cut-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.industry-cut-copy strong { color: #b6c6d8; font-size: 8px; } +.industry-cut-copy small { margin-top: 2px; color: #5f7288; font: 7px Consolas, monospace; } +.industry-cut-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; } +.industry-cut-add:disabled { cursor: not-allowed; opacity: .35; } +.comparison-workflow { display: flex; min-height: 560px; max-height: 76%; flex: 0 0 620px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; } +.comparison-workflow[hidden] { display: none; } +.comparison-workflow-header { display: flex; min-height: 49px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); } +.comparison-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } +.comparison-workflow-header .panel-kicker { margin-bottom: 3px; } +.comparison-source-tabs { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); } +.comparison-source-tabs button { min-height: 26px; padding: 0 5px; color: #6f849c; font-size: 7px; } +.comparison-source-tabs button.active { border-color: rgba(50,213,164,.35); background: rgba(50,213,164,.08); color: #8be2c8; } +.comparison-source-panel { flex: 0 0 auto; } +.comparison-source-panel[hidden] { display: none; } +.comparison-search-form { display: grid; grid-template-columns: minmax(0,1fr) 45px; gap: 5px; padding: 6px 8px 4px; } +.comparison-search-form input { min-width: 0; height: 29px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #d4e0ec; font-size: 8px; } +.comparison-search-form button { min-height: 29px; padding: 0 6px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; } +.comparison-search-state { min-height: 21px; padding: 2px 9px 4px; color: #667c94; font-size: 7px; } +.comparison-search-state.loading { color: #8ec4ff; } +.comparison-search-state.error { color: #ff9bad; } +.comparison-search-state.ready { color: #8bdcc4; } +.comparison-target-results { min-height: 70px; max-height: 105px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; } +.comparison-target-results.fixed { display: grid; max-height: 125px; grid-template-columns: repeat(2,minmax(0,1fr)); margin-top: 6px; } +.comparison-target { display: grid; width: 100%; min-height: 29px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; color: #b9c8d8; text-align: left; cursor: pointer; } +.comparison-target:hover { background: rgba(86,168,255,.075); } +.comparison-target strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.comparison-target small { color: #60748c; font: 6px Consolas,monospace; white-space: nowrap; } +.comparison-target-empty { padding: 18px 8px; color: #5f7288; font-size: 8px; text-align: center; } +.comparison-draft { display: grid; min-height: 51px; grid-template-columns: minmax(0,1fr) 25px minmax(0,1fr) auto auto; align-items: center; gap: 4px; padding: 5px 7px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); } +.comparison-draft div { min-width: 0; } +.comparison-draft span,.comparison-draft strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.comparison-draft span { margin-bottom: 2px; color: #5e748c; font-size: 6px; } +.comparison-draft strong { color: #aebed0; font-size: 8px; } +.comparison-draft button { min-height: 25px; padding: 0 5px; color: #8298af; font-size: 7px; } +.comparison-draft button.primary { border-color: rgba(50,213,164,.3); color: #83dec3; } +.comparison-pair-header,.comparison-action-header { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 6px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); } +.comparison-pair-header strong,.comparison-pair-header small,.comparison-action-header strong,.comparison-action-header small { display: block; } +.comparison-pair-header strong,.comparison-action-header strong { color: #aebed0; font-size: 8px; } +.comparison-pair-header small,.comparison-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; } +.comparison-pair-header > div:last-child { display: flex; gap: 3px; } +.comparison-pair-header button,.comparison-action-header button { min-height: 23px; padding: 0 5px; color: #8298af; font-size: 6px; } +.comparison-pair-list { min-height: 55px; max-height: 95px; overflow-y: auto; } +.comparison-pair-row { display: grid; width: 100%; min-height: 31px; grid-template-columns: 25px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.24); border-radius: 0; background: transparent; text-align: left; cursor: pointer; } +.comparison-pair-row:hover,.comparison-pair-row.selected { background: rgba(50,213,164,.055); } +.comparison-pair-row.selected { box-shadow: inset 2px 0 var(--mint); } +.comparison-pair-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; } +.comparison-pair-row strong,.comparison-pair-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.comparison-pair-row strong { color: #b6c6d8; font-size: 8px; } +.comparison-pair-row small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; } +.comparison-pair-row > span:last-child { color: #68809a; font: 6px Consolas,monospace; } +.comparison-action-list { min-height: 120px; flex: 1 0 auto; } +.comparison-action-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas,monospace; } +.comparison-action-row { display: grid; min-height: 31px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); } +.comparison-action-row.disabled { opacity: .38; } +.comparison-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; } +.comparison-action-copy strong,.comparison-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.comparison-action-copy strong { color: #b6c6d8; font-size: 8px; } +.comparison-action-copy small { margin-top: 1px; color: #5f7288; font: 6px Consolas,monospace; } +.comparison-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); } +.theme-workflow { display: flex; min-height: 545px; max-height: 76%; flex: 0 0 610px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; } +.theme-workflow[hidden] { display: none; } +.theme-workflow-header { display: flex; min-height: 49px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); } +.theme-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } +.theme-workflow-header .panel-kicker { margin-bottom: 3px; } +.theme-search-form { display: grid; grid-template-columns: minmax(0,1fr) 76px 42px; gap: 5px; padding: 7px 8px 5px; } +.theme-search-form input,.theme-search-form select { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 7px; } +.theme-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 7px; } +.theme-search-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; } +.theme-search-state.loading,.theme-preview-state.loading { color: #8ec4ff; } +.theme-search-state.error,.theme-preview-state.error { color: #ff9bad; } +.theme-search-state.ready,.theme-preview-state.ready { color: #8bdcc4; } +.theme-results { min-height: 78px; max-height: 118px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; } +.theme-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; } +.theme-result:hover,.theme-result.selected { background: rgba(86,168,255,.075); } +.theme-result.selected { box-shadow: inset 2px 0 var(--mint); } +.theme-result strong,.theme-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.theme-result strong { color: #b9c8d8; font-size: 8px; } +.theme-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; } +.theme-market-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 6px Consolas,monospace; } +.selected-theme { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 7px; min-height: 47px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); } +.selected-theme[hidden] { display: none; } +.selected-theme span,.selected-theme strong,.selected-theme small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.selected-theme span { color: #5e748c; font-size: 6px; } +.selected-theme strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; } +.selected-theme small { color: #60748c; font: 6px Consolas,monospace; } +.selected-theme label { color: #667c94; font-size: 6px; } +.selected-theme select { display: block; min-width: 80px; height: 26px; margin-top: 2px; padding: 0 5px; border: 1px solid #29415b; border-radius: 5px; background: #07111d; color: #aebed0; font-size: 7px; } +.theme-preview-header { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 7px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); } +.theme-preview-header strong,.theme-preview-header small { display: block; } +.theme-preview-header strong { color: #aebed0; font-size: 8px; } +.theme-preview-header small { color: #5f7288; font-size: 6px; } +.theme-preview-header > span { color: #67809a; font: 6px Consolas,monospace; } +.theme-preview-state { min-height: 20px; padding: 3px 9px; color: #667c94; font-size: 7px; } +.theme-preview-items { min-height: 55px; max-height: 92px; overflow-y: auto; } +.theme-preview-item { display: grid; min-height: 25px; grid-template-columns: 28px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 8px; border-bottom: 1px solid rgba(53,75,99,.2); } +.theme-preview-item span { color: #536b84; font: 6px Consolas,monospace; text-align: center; } +.theme-preview-item strong { overflow: hidden; color: #aebed0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } +.theme-preview-item small { color: #60748c; font: 6px Consolas,monospace; } +.theme-action-header { min-height: 33px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.28); border-bottom: 1px solid rgba(53,75,99,.28); } +.theme-action-header strong,.theme-action-header small { display: block; } +.theme-action-header strong { color: #aebed0; font-size: 8px; } +.theme-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; } +.theme-action-list { min-height: 120px; } +.theme-action-row { display: grid; min-height: 32px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); } +.theme-action-row.disabled { opacity: .38; } +.theme-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; } +.theme-action-copy strong,.theme-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.theme-action-copy strong { color: #b6c6d8; font-size: 8px; } +.theme-action-copy small { color: #5f7288; font: 6px Consolas,monospace; } +.theme-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); } +.theme-crud-notice { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-top: 1px solid rgba(255,190,80,.14); color: #8a7554; font-size: 6px; } +.theme-crud-notice button,.expert-crud-notice button { min-height: 25px; padding: 0 8px; border: 1px solid rgba(255,190,80,.24); border-radius: 6px; background: rgba(255,190,80,.08); color: #d3a85f; cursor: pointer; font-size: 7px; } +.theme-crud-notice button:disabled,.expert-crud-notice button:disabled { cursor: not-allowed; opacity: .4; } +.overseas-workflow,.expert-workflow { display: flex; min-height: 470px; max-height: 76%; flex: 0 0 540px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; } +.overseas-workflow[hidden],.expert-workflow[hidden] { display: none; } +.overseas-workflow-header,.expert-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); } +.overseas-workflow-header h3,.expert-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } +.overseas-workflow-header .panel-kicker,.expert-workflow-header .panel-kicker { margin-bottom: 3px; } +.overseas-source-tabs { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; padding: 6px 8px 2px; } +.overseas-source-tabs button { min-height: 27px; color: #6f849c; font-size: 7px; } +.overseas-source-tabs button.active { border-color: rgba(50,213,164,.35); background: rgba(50,213,164,.08); color: #8be2c8; } +.overseas-search-form,.expert-search-form { display: grid; grid-template-columns: minmax(0,1fr) 44px; gap: 5px; padding: 6px 8px 5px; } +.overseas-search-form input,.expert-search-form input { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 8px; } +.overseas-search-form button,.expert-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; } +.overseas-search-state,.expert-search-state,.expert-preview-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; } +.overseas-search-state.loading,.expert-search-state.loading,.expert-preview-state.loading { color: #8ec4ff; } +.overseas-search-state.error,.expert-search-state.error,.expert-preview-state.error { color: #ff9bad; } +.overseas-search-state.ready,.expert-search-state.ready,.expert-preview-state.ready { color: #8bdcc4; } +.overseas-results,.expert-results { min-height: 82px; max-height: 135px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; } +.overseas-result,.expert-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; } +.overseas-result:hover,.overseas-result.selected,.expert-result:hover,.expert-result.selected { background: rgba(86,168,255,.075); } +.overseas-result.selected,.expert-result.selected { box-shadow: inset 2px 0 var(--mint); } +.overseas-result strong,.overseas-result small,.expert-result strong,.expert-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.overseas-result strong,.expert-result strong { color: #b9c8d8; font-size: 8px; } +.overseas-result small,.expert-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; } +.overseas-result > span:last-child,.expert-result > span:last-child { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 6px Consolas,monospace; } +.selected-overseas { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); } +.selected-overseas[hidden],.selected-expert[hidden] { display: none; } +.selected-overseas span,.selected-overseas strong,.selected-overseas small,.selected-expert span,.selected-expert strong,.selected-expert small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.selected-overseas span,.selected-expert span { color: #5e748c; font-size: 6px; } +.selected-overseas strong,.selected-expert strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; } +.selected-overseas small,.selected-expert small { color: #60748c; font: 6px Consolas,monospace; } +.overseas-ma-options { display: flex; gap: 6px; color: #72879e; font-size: 7px; } +.overseas-ma-options label { display: flex; align-items: center; gap: 3px; white-space: nowrap; } +.overseas-action-header,.expert-action-header,.expert-preview-header { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 8px; border-bottom: 1px solid rgba(53,75,99,.28); } +.overseas-action-header strong,.overseas-action-header small,.expert-action-header strong,.expert-action-header small,.expert-preview-header strong,.expert-preview-header small { display: block; } +.overseas-action-header strong,.expert-action-header strong,.expert-preview-header strong { color: #aebed0; font-size: 8px; } +.overseas-action-header small,.expert-action-header small,.expert-preview-header small { color: #5f7288; font-size: 6px; } +.expert-preview-header > span { color: #67809a; font: 6px Consolas,monospace; } +.overseas-action-list,.expert-action-list { min-height: 75px; } +.overseas-action-row,.expert-action-row { display: grid; min-height: 32px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); } +.overseas-action-row.disabled,.expert-action-row.disabled { opacity: .38; } +.overseas-action-row > span:first-child,.expert-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; } +.overseas-action-copy strong,.overseas-action-copy small,.expert-action-copy strong,.expert-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.overseas-action-copy strong,.expert-action-copy strong { color: #b6c6d8; font-size: 8px; } +.overseas-action-copy small,.expert-action-copy small { color: #5f7288; font: 6px Consolas,monospace; } +.overseas-action-add,.expert-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); } +.selected-expert { min-height: 45px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); } +.expert-preview-items { min-height: 66px; max-height: 116px; overflow-y: auto; } +.expert-preview-item { display: grid; min-height: 27px; grid-template-columns: 28px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 8px; border-bottom: 1px solid rgba(53,75,99,.2); } +.expert-preview-item span { color: #536b84; font: 6px Consolas,monospace; text-align: center; } +.expert-preview-item strong { overflow: hidden; color: #aebed0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } +.expert-preview-item small { color: #60748c; font: 6px Consolas,monospace; } +.expert-crud-notice { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-top: 1px solid rgba(255,190,80,.14); color: #8a7554; font-size: 6px; } +.trading-halt-workflow { display: flex; min-height: 420px; max-height: 76%; flex: 0 0 475px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; } +.trading-halt-workflow[hidden] { display: none; } +.trading-halt-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); } +.trading-halt-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } +.trading-halt-workflow-header .panel-kicker { margin-bottom: 3px; } +.trading-halt-search-form { display: grid; grid-template-columns: minmax(0,1fr) 44px; gap: 5px; padding: 7px 8px 5px; } +.trading-halt-search-form input { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 8px; } +.trading-halt-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; } +.trading-halt-search-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; } +.trading-halt-search-state.loading { color: #8ec4ff; } +.trading-halt-search-state.error { color: #ff9bad; } +.trading-halt-search-state.ready { color: #8bdcc4; } +.trading-halt-results { min-height: 100px; max-height: 175px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; } +.trading-halt-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; } +.trading-halt-result:hover,.trading-halt-result.selected { background: rgba(86,168,255,.075); } +.trading-halt-result.selected { box-shadow: inset 2px 0 var(--mint); } +.trading-halt-result strong,.trading-halt-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trading-halt-result strong { color: #b9c8d8; font-size: 8px; } +.trading-halt-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; } +.trading-halt-market { padding: 3px 5px; border: 1px solid rgba(255,102,128,.2); border-radius: 5px; color: #ff9bad; font: 6px Consolas,monospace; } +.selected-trading-halt { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); } +.selected-trading-halt[hidden] { display: none; } +.selected-trading-halt span,.selected-trading-halt strong,.selected-trading-halt small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.selected-trading-halt span { color: #5e748c; font-size: 6px; } +.selected-trading-halt strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; } +.selected-trading-halt small { color: #60748c; font: 6px Consolas,monospace; } +.trading-halt-ma-options { display: flex; gap: 6px; color: #72879e; font-size: 7px; } +.trading-halt-ma-options label { display: flex; align-items: center; gap: 3px; white-space: nowrap; } +.trading-halt-action-header { padding: 6px 8px; border-bottom: 1px solid rgba(53,75,99,.28); } +.trading-halt-action-header strong,.trading-halt-action-header small { display: block; } +.trading-halt-action-header strong { color: #aebed0; font-size: 8px; } +.trading-halt-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; } +.trading-halt-action-list { min-height: 74px; } +.trading-halt-action-row { display: grid; min-height: 34px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); } +.trading-halt-action-row.disabled { opacity: .38; } +.trading-halt-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; } +.trading-halt-action-copy strong,.trading-halt-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trading-halt-action-copy strong { color: #b6c6d8; font-size: 8px; } +.trading-halt-action-copy small { color: #5f7288; font: 6px Consolas,monospace; } +.trading-halt-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); } .live-data-panel { display: flex; min-height: 235px; max-height: 54%; flex: 0 1 365px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; } .live-data-header { display: flex; min-height: 51px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 7px; border-bottom: 1px solid var(--border-soft); } .live-data-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; } @@ -217,10 +536,43 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor .add-cut:hover { border-color: var(--mint); background: var(--mint-soft); } .catalog-empty { padding: 35px 10px; color: var(--muted); text-align: center; } -.playlist-actions { display: grid; grid-template-columns: auto 30px 30px auto 1fr auto auto; gap: 5px; padding: 10px 12px; border-bottom: 1px solid var(--border-soft); } +.playlist-actions { display: grid; grid-template-columns: auto auto 30px 30px auto auto 1fr auto auto auto; gap: 5px; padding: 10px 12px; border-bottom: 1px solid var(--border-soft); } .playlist-actions button { padding: 0 9px; font-size: 9px; } .playlist-actions button.danger { color: #ff9aad; } .playlist-actions button.primary-soft { border-color: rgba(50,213,164,.23); background: var(--mint-soft); color: var(--mint); } +.playlist-actions button.database-soft { border-color: rgba(86,168,255,.25); background: rgba(86,168,255,.1); color: #8fc3fa; } +.global-candle-options { display: flex; align-items: center; justify-content: flex-end; min-width: 0; gap: 7px; padding: 0 4px; color: #8196ad; font-size: 8px; white-space: nowrap; } +.global-candle-options > span { color: #5f7690; font: 7px Consolas, monospace; letter-spacing: .08em; } +.global-candle-options label { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; } +.global-candle-options input { width: 13px; height: 13px; margin: 0; accent-color: var(--mint); } +.global-candle-options input:disabled + * { opacity: .45; } +.named-playlist-panel { flex: 0 0 auto; max-height: 352px; padding: 10px 12px 9px; border-bottom: 1px solid var(--border); background: linear-gradient(145deg, rgba(12,31,49,.98), rgba(8,21,35,.98)); overflow-y: auto; } +.named-playlist-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.named-playlist-header h3 { margin: 0; color: #d9e6f4; font-size: 11px; } +.named-playlist-header-actions { display: flex; align-items: center; gap: 5px; } +.named-playlist-panel button { min-height: 27px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: var(--surface-2); color: #9db0c5; font-size: 8px; cursor: pointer; } +.named-playlist-panel button:hover:not(:disabled) { border-color: #426487; color: white; } +.named-playlist-panel button.primary-soft { border-color: rgba(50,213,164,.25); background: var(--mint-soft); color: var(--mint); } +.named-playlist-panel button.danger { border-color: rgba(255,102,128,.2); color: #ff9aad; } +.named-playlist-panel button:disabled { cursor: not-allowed; opacity: .38; } +.named-playlist-status { min-height: 29px; margin: 8px 0; padding: 6px 8px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,18,30,.72); color: #7f94ab; font-size: 8px; line-height: 1.45; } +.named-playlist-status.error { border-color: rgba(255,102,128,.25); color: #ff9aad; } +.named-playlist-status.ready { border-color: rgba(50,213,164,.2); color: #8bdcc4; } +.named-playlist-status.quarantined { border-color: rgba(255,186,85,.28); color: var(--amber); } +.named-playlist-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 5px; } +.named-playlist-definitions-label, .named-playlist-create label { color: #637991; font: 7px Consolas, monospace; text-transform: uppercase; } +.named-playlist-definitions { width: 100%; min-height: 88px; padding: 3px; border: 1px solid var(--border); border-radius: 6px; outline: none; background: #081522; color: #b8c8da; font: 9px/1.5 Consolas, "Malgun Gothic", sans-serif; } +.named-playlist-definitions:focus { border-color: #426487; } +.named-playlist-definitions option { padding: 3px 5px; } +.named-playlist-selection-summary { min-height: 17px; color: #70869e; font-size: 8px; } +.named-playlist-load-actions, .named-playlist-mutation-actions { display: flex; flex-wrap: wrap; gap: 5px; } +.named-playlist-create { display: grid; grid-template-columns: auto 78px auto minmax(100px,1fr) auto; align-items: center; gap: 5px; padding-top: 5px; border-top: 1px solid var(--border-soft); } +.named-playlist-create input { min-width: 0; height: 27px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; outline: none; background: #081522; color: #c6d4e3; font-size: 8px; } +.named-playlist-create input:focus { border-color: #426487; } +.named-playlist-guard-summary { margin: 7px 0 0; color: #61778f; font-size: 7px; line-height: 1.45; } +.named-playlist-guard-summary.blocked { color: var(--amber); } +tbody tr.named-restore-invalid { box-shadow: inset 3px 0 var(--red); } +tbody tr.named-page-pending { box-shadow: inset 3px 0 var(--amber); } .table-wrap { position: relative; min-height: 0; flex: 1; overflow: auto; } table { width: 100%; border-collapse: collapse; table-layout: fixed; } thead { position: sticky; z-index: 2; top: 0; background: #0d1929; } @@ -229,10 +581,15 @@ td { height: 47px; border-bottom: 1px solid var(--border-soft); color: #b9c7d8; th, td { padding: 0 8px; } .check-cell { width: 35px; text-align: center; } .number-cell { width: 34px; color: var(--muted-2); text-align: center; } +.page-cell { width: 48px; color: var(--muted-2); text-align: center; } .drag-cell { width: 42px; color: var(--muted-2); text-align: center; } +.playlist-target-cell { width: 24%; } +.playlist-cut-cell { width: 25%; } +.playlist-detail-cell { width: 28%; } tbody tr { cursor: pointer; transition: .12s ease; } tbody tr:hover { background: rgba(255,255,255,.022); } tbody tr.active { background: var(--mint-soft); } +tbody tr.selected { box-shadow: inset 2px 0 rgba(86,168,255,.8); } tbody tr.dragging { opacity: .42; } tbody tr.drag-over { box-shadow: inset 0 2px var(--mint); } td strong, td small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } @@ -257,6 +614,14 @@ input[type="checkbox"] { accent-color: var(--mint); } .playout-health-item.safety strong { grid-column: 1; color: var(--amber); } .playout-health-item.safety.live-allowed strong { color: var(--mint); } .playout-health-item.safety.live-locked strong { color: var(--red); } +.background-control-bar { display: grid; min-height: 42px; grid-template-columns: minmax(0,1fr) auto auto; align-items: center; gap: 6px; margin: 7px 13px 0; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.6); } +.background-control-bar > div { min-width: 0; } +.background-control-bar small,.background-control-bar strong,.background-control-bar span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.background-control-bar small { color: #5e748c; font: 6px Consolas,monospace; } +.background-control-bar strong { margin: 1px 0; color: #aebed0; font-size: 8px; } +.background-control-bar span { color: #60748c; font-size: 6px; } +.background-control-bar button { min-height: 27px; padding: 0 7px; border-color: #29415b; color: #8fa7c0; font-size: 7px; } +.background-control-bar button:disabled { cursor: not-allowed; opacity: .4; } .playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; } .playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; } .playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; } diff --git a/Web/theme-workflow.js b/Web/theme-workflow.js new file mode 100644 index 0000000..db722f3 --- /dev/null +++ b/Web/theme-workflow.js @@ -0,0 +1,325 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnThemeWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const SCHEMA_VERSION = 1; + const MAXIMUM_PAGE_COUNT = 20; + const MAXIMUM_PREVIEW_ITEMS = 240; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const themeCodePattern = /^[0-9]{8}$/; + const controlCharacterPattern = /[\u0000-\u001f\u007f]/; + + const sourceContract = Object.freeze({ + originalControl: "Control/UC4.cs", + krxResolverGroup: "PAGED_THEME", + nxtResolverGroup: "PAGED_NXT_THEME", + maximumPageCount: MAXIMUM_PAGE_COUNT, + maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS + }); + + const themeSorts = Object.freeze([ + Object.freeze({ id: "INPUT_ORDER", label: "입력순" }), + Object.freeze({ id: "GAIN_DESC", label: "상승률순" }), + Object.freeze({ id: "GAIN_ASC", label: "하락률순" }) + ]); + const themeSortsById = new Map(themeSorts.map(value => [value.id, value])); + + const nxtSessions = Object.freeze([ + Object.freeze({ id: "PRE_MARKET", label: "프리마켓" }), + Object.freeze({ id: "AFTER_MARKET", label: "애프터마켓" }) + ]); + + function action(id, label, builderKey, code, pageSize, valueKind) { + return Object.freeze({ + id, + label, + builderKey, + code, + aliases: Object.freeze([code]), + pageSize, + valueKind + }); + } + + const actions = Object.freeze([ + action("five-row-current", "5단 표그래프 · 현재가", "s5074", "5074", 5, "CURRENT"), + action("five-row-expected", "5단 표그래프 · 예상체결가", "s5074", "5074", 5, "EXPECTED"), + action("six-row-current", "6종목 현재가", "s5077", "5077", 6, "CURRENT"), + action("twelve-row-current", "12종목 현재가", "s5088", "5088", 12, "CURRENT") + ]); + const actionsById = new Map(actions.map(value => [value.id, value])); + + function cleanText(value, maximumLength = 128) { + if (typeof value !== "string") return null; + const text = value.trim(); + return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null; + } + + function normalizeMarket(value) { + const text = String(value || "").trim().toLocaleLowerCase("en-US"); + return text === "krx" || text === "nxt" ? text : null; + } + + function normalizeNxtSession(value) { + const text = String(value || "") + .trim() + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/-/g, "_") + .toLocaleUpperCase("en-US"); + return text === "PRE_MARKET" || text === "AFTER_MARKET" ? text : null; + } + + function isKrxEmptySession(value) { + if (value === undefined || value === null || value === "") return true; + const text = String(value) + .trim() + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/-/g, "_") + .toLocaleUpperCase("en-US"); + return text === "NOT_APPLICABLE"; + } + + function themeValidationReason(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid-theme"; + const market = normalizeMarket(value.market ?? value.Market); + if (!market) return "invalid-theme-market"; + const rawThemeCode = value.themeCode ?? value.ThemeCode ?? value.code; + const themeCode = cleanText(rawThemeCode, 8); + if (!themeCode || themeCode !== rawThemeCode || !themeCodePattern.test(themeCode)) { + return "invalid-theme-code"; + } + const title = cleanText(value.title ?? value.themeTitle ?? value.ThemeTitle); + if (!title || title !== String(value.title ?? value.themeTitle ?? value.ThemeTitle) || + title.includes("(NXT)")) return "invalid-theme-title"; + const sessionValue = value.session ?? value.Session; + if (market === "nxt" && !normalizeNxtSession(sessionValue)) return "nxt-session-required"; + if (market === "krx" && !isKrxEmptySession(sessionValue)) return "krx-session-must-be-empty"; + const itemCountValue = value.itemCount ?? value.previewItemCount ?? + (Array.isArray(value.items) ? value.items.length : null); + if (itemCountValue !== null && itemCountValue !== undefined && + (!Number.isInteger(itemCountValue) || itemCountValue < 0 || itemCountValue > MAXIMUM_PREVIEW_ITEMS)) { + return "invalid-theme-item-count"; + } + const canonicalDisplay = market === "nxt" ? `${title}(NXT)` : title; + if (value.displayTitle !== undefined && value.displayTitle !== canonicalDisplay) { + return "invalid-theme-display-title"; + } + return null; + } + + function normalizeTheme(value) { + if (themeValidationReason(value)) return null; + const market = normalizeMarket(value.market ?? value.Market); + const themeCode = value.themeCode ?? value.ThemeCode ?? value.code; + const title = value.title ?? value.themeTitle ?? value.ThemeTitle; + const itemCountValue = value.itemCount ?? value.previewItemCount ?? + (Array.isArray(value.items) ? value.items.length : null); + const session = market === "nxt" ? normalizeNxtSession(value.session ?? value.Session) : null; + return Object.freeze({ + market, + themeCode, + title, + displayTitle: market === "nxt" ? `${title}(NXT)` : title, + session, + itemCount: itemCountValue === undefined ? null : itemCountValue + }); + } + + function normalizeThemeSort(value) { + if (value && typeof value === "object" && !Array.isArray(value)) value = value.id; + const id = String(value || "").trim().toLocaleUpperCase("en-US"); + return themeSortsById.get(id) ?? null; + } + + function unsupported(actionId, reason) { + return Object.freeze({ actionId, supported: false, reason }); + } + + function getCompatibility(actionId, themeValue, sortValue) { + const selectedAction = actionsById.get(actionId); + if (!selectedAction) return unsupported(actionId, "unknown-action"); + const reason = themeValidationReason(themeValue); + if (reason) return unsupported(actionId, reason); + const theme = normalizeTheme(themeValue); + if (!normalizeThemeSort(sortValue)) return unsupported(actionId, "invalid-theme-sort"); + if (theme.itemCount === 0) return unsupported(actionId, "theme-preview-is-empty"); + if (selectedAction.valueKind === "EXPECTED" && theme.market !== "krx") { + return unsupported(actionId, "expected-theme-is-krx-only"); + } + return Object.freeze({ actionId, supported: true, reason: null }); + } + + function isActionAllowed(actionId, themeValue, sortValue) { + return getCompatibility(actionId, themeValue, sortValue).supported; + } + + function calculatePagePreview(actionId, themeValue) { + const selectedAction = actionsById.get(actionId); + const theme = normalizeTheme(themeValue); + if (!selectedAction || !theme) return null; + const maximumItems = selectedAction.pageSize * MAXIMUM_PAGE_COUNT; + if (theme.itemCount === null) { + return Object.freeze({ + itemCount: null, + accessibleItemCount: null, + pageSize: selectedAction.pageSize, + pageCount: null, + maximumPageCount: MAXIMUM_PAGE_COUNT, + isTruncated: false + }); + } + const accessibleItemCount = Math.min(theme.itemCount, maximumItems); + return Object.freeze({ + itemCount: theme.itemCount, + accessibleItemCount, + pageSize: selectedAction.pageSize, + pageCount: Math.ceil(accessibleItemCount / selectedAction.pageSize), + maximumPageCount: MAXIMUM_PAGE_COUNT, + isTruncated: theme.itemCount > maximumItems + }); + } + + function buildThemeSelection(actionId, themeValue, sortValue) { + const selectedAction = actionsById.get(actionId); + const theme = normalizeTheme(themeValue); + const sort = normalizeThemeSort(sortValue); + const compatibility = getCompatibility(actionId, themeValue, sortValue); + if (!selectedAction || !theme || !sort || !compatibility.supported) { + throw new RangeError(`The theme action is unavailable: ${compatibility.reason || "invalid-selection"}.`); + } + const selection = theme.market === "krx" + ? Object.freeze({ + groupCode: "PAGED_THEME", + subject: theme.title, + graphicType: sort.id, + subtype: selectedAction.valueKind, + dataCode: theme.themeCode + }) + : Object.freeze({ + groupCode: "PAGED_NXT_THEME", + subject: theme.displayTitle, + graphicType: theme.session, + subtype: sort.id, + dataCode: theme.themeCode + }); + return Object.freeze({ + builderKey: selectedAction.builderKey, + code: selectedAction.code, + aliases: selectedAction.aliases, + selection, + pageSize: selectedAction.pageSize, + pagePreview: calculatePagePreview(actionId, theme) + }); + } + + function requireOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Theme playlist options are required."); + } + const id = cleanText(options.id); + if (!id || !identifierPattern.test(id)) throw new TypeError("A safe theme playlist id is required."); + const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration); + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + return { id, fadeDuration }; + } + + function createThemePlaylistEntry(actionId, themeValue, sortValue, options) { + const selectedAction = actionsById.get(actionId); + if (!selectedAction) throw new RangeError("The theme action is unknown."); + const theme = normalizeTheme(themeValue); + if (!theme) throw new RangeError(`The theme selection is unavailable: ${themeValidationReason(themeValue)}.`); + const sort = normalizeThemeSort(sortValue); + if (!sort) throw new RangeError("The theme selection is unavailable: invalid-theme-sort."); + const normalized = requireOptions(options); + const mapping = buildThemeSelection(actionId, theme, sort); + return { + id: normalized.id, + builderKey: mapping.builderKey, + code: mapping.code, + aliases: mapping.aliases, + title: theme.displayTitle, + detail: `${selectedAction.label} · ${sort.label}`, + category: "plate", + market: "theme", + selection: mapping.selection, + enabled: true, + fadeDuration: normalized.fadeDuration, + graphicType: mapping.selection.graphicType, + subtype: mapping.selection.subtype, + pageSize: mapping.pageSize, + pagePreview: mapping.pagePreview, + operator: Object.freeze({ + source: "legacy-theme-workflow", + schemaVersion: SCHEMA_VERSION, + actionId, + theme, + sort: sort.id, + resolverGroup: mapping.selection.groupCode + }) + }; + } + + function sameSelection(left, right) { + if (!left || typeof left !== "object" || !right || typeof right !== "object") return false; + return ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => typeof left[key] === "string" && left[key] === right[key]); + } + + function samePagePreview(left, right) { + if (!left || typeof left !== "object" || !right || typeof right !== "object") return false; + return ["itemCount", "accessibleItemCount", "pageSize", "pageCount", "maximumPageCount", "isTruncated"] + .every(key => left[key] === right[key]); + } + + function restoreThemePlaylistEntry(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null; + const operator = value.operator; + if (!operator || typeof operator !== "object" || operator.source !== "legacy-theme-workflow" || + operator.schemaVersion !== SCHEMA_VERSION || !actionsById.has(operator.actionId)) return null; + let recreated; + try { + recreated = createThemePlaylistEntry(operator.actionId, operator.theme, operator.sort, { + id: value.id, + fadeDuration: value.fadeDuration + }); + } catch { + return null; + } + const scalars = [ + "id", "builderKey", "code", "title", "detail", "category", "market", + "fadeDuration", "graphicType", "subtype", "pageSize" + ]; + if (!scalars.every(key => value[key] === recreated[key]) || + !Array.isArray(value.aliases) || value.aliases.length !== recreated.aliases.length || + !value.aliases.every((alias, index) => alias === recreated.aliases[index]) || + !sameSelection(value.selection, recreated.selection) || + !samePagePreview(value.pagePreview, recreated.pagePreview) || + operator.resolverGroup !== recreated.operator.resolverGroup) return null; + return { ...recreated, enabled: value.enabled }; + } + + return { + sourceContract, + themeSorts, + nxtSessions, + actions, + themeActions: actions, + normalizeTheme, + normalizeThemeSort, + getCompatibility, + isActionAllowed, + calculatePagePreview, + buildThemeSelection, + createThemePlaylistEntry, + restoreThemePlaylistEntry, + refreshThemePlaylistEntry: restoreThemePlaylistEntry + }; +}); diff --git a/Web/trading-halt-workflow.js b/Web/trading-halt-workflow.js new file mode 100644 index 0000000..e797bf5 --- /dev/null +++ b/Web/trading-halt-workflow.js @@ -0,0 +1,361 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnTradingHaltWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const DEFAULT_FADE_DURATION = 6; + const DEFAULT_MAXIMUM_RESULTS = 100; + const MAXIMUM_RESULTS = 500; + const MAXIMUM_QUERY_LENGTH = 64; + const SCHEMA_VERSION = 1; + const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; + const stockCodePattern = /^[A-Za-z0-9]{1,32}$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + const markets = Object.freeze(["kospi", "kosdaq"]); + + const bridgeContract = Object.freeze({ + requestType: "search-trading-halts", + resultType: "trading-halt-results", + errorType: "trading-halt-error", + defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS, + maximumResults: MAXIMUM_RESULTS, + maximumQueryLength: MAXIMUM_QUERY_LENGTH + }); + + const sourceContract = Object.freeze({ + originalControl: "Control/UC7.cs", + currentCutCode: "5001", + candleCutCode: "8051", + candlePeriodDays: 120, + currentResolverGroups: Object.freeze({ kospi: "KOSPI", kosdaq: "KOSDAQ" }), + candleResolverGroups: Object.freeze({ kospi: "KOSPI_STOCK", kosdaq: "KOSDAQ_STOCK" }) + }); + + function action(id, label, builderKey, code, kind) { + return Object.freeze({ + id, + label, + builderKey, + code, + aliases: Object.freeze([code]), + kind + }); + } + + const actions = Object.freeze([ + action("current", "\uAC70\uB798\uC815\uC9C0-\uD604\uC7AC\uAC00", "s5001", "5001", "current"), + action("candle-120d", "\uCE94\uB4E4\uADF8\uB798\uD504-\uAC70\uB798\uC815\uC9C0 · 120\uC77C", "s8010", "8051", "candle") + ]); + const actionsById = new Map(actions.map(value => [value.id, value])); + + function hasExactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); + } + + function isSafeCanonicalText(value, maximumLength) { + return typeof value === "string" && + value.length > 0 && + value.length <= maximumLength && + value === value.trim() && + !unsafeTextPattern.test(value); + } + + function normalizeQuery(value) { + if (typeof value !== "string" || value.length > MAXIMUM_QUERY_LENGTH || unsafeTextPattern.test(value)) { + return null; + } + const query = value.trim(); + return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null; + } + + function normalizeTradingHalt(value) { + if (!hasExactKeys(value, ["market", "stockCode", "displayName"]) || + !markets.includes(value.market) || + !isSafeCanonicalText(value.stockCode, 32) || + !stockCodePattern.test(value.stockCode) || + !isSafeCanonicalText(value.displayName, 200)) { + return null; + } + return Object.freeze({ + market: value.market, + stockCode: value.stockCode, + displayName: value.displayName + }); + } + + function createTradingHaltSearchRequest(requestId, query = "", maximumResults) { + if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) { + throw new TypeError("A safe trading-halt request id is required."); + } + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === null) { + throw new TypeError(`A trading-halt query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`); + } + if (maximumResults !== undefined && + (!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) { + throw new RangeError(`Trading-halt results must be limited from 1 through ${MAXIMUM_RESULTS}.`); + } + const request = { requestId, query: normalizedQuery }; + if (maximumResults !== undefined) request.maximumResults = maximumResults; + return Object.freeze(request); + } + + function compareTradingHalts(left, right) { + const marketOrder = markets.indexOf(left.market) - markets.indexOf(right.market); + if (marketOrder !== 0) return marketOrder; + if (left.displayName < right.displayName) return -1; + if (left.displayName > right.displayName) return 1; + if (left.stockCode < right.stockCode) return -1; + if (left.stockCode > right.stockCode) return 1; + return 0; + } + + function normalizeTradingHaltSearchResponse(value, expectedRequest) { + if (!hasExactKeys(value, [ + "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results" + ]) || + !isSafeCanonicalText(value.requestId, 128) || + !identifierPattern.test(value.requestId) || + normalizeQuery(value.query) !== value.query || + !isSafeCanonicalText(value.retrievedAt, 64) || + !Number.isFinite(Date.parse(value.retrievedAt)) || + !Number.isInteger(value.totalRowCount) || + value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_RESULTS || + typeof value.truncated !== "boolean" || + !Array.isArray(value.results) || + value.results.length !== value.totalRowCount) { + return null; + } + + if (expectedRequest !== undefined) { + let expected; + try { + expected = createTradingHaltSearchRequest( + expectedRequest?.requestId, + expectedRequest?.query, + expectedRequest?.maximumResults); + } catch { + return null; + } + if (value.requestId !== expected.requestId || value.query !== expected.query || + (expected.maximumResults !== undefined && value.results.length > expected.maximumResults)) { + return null; + } + } + + const results = []; + const identities = new Set(); + const lookupKeys = new Set(); + for (const raw of value.results) { + const item = normalizeTradingHalt(raw); + if (!item) return null; + const identity = `${item.market}\u0000${item.stockCode}`; + const lookupKey = `${item.market}\u0000${item.displayName}`; + if (identities.has(identity) || lookupKeys.has(lookupKey)) return null; + if (results.length && compareTradingHalts(results[results.length - 1], item) > 0) return null; + identities.add(identity); + lookupKeys.add(lookupKey); + results.push(item); + } + + return Object.freeze({ + requestId: value.requestId, + query: value.query, + retrievedAt: value.retrievedAt, + totalRowCount: value.totalRowCount, + truncated: value.truncated, + results: Object.freeze(results) + }); + } + + function normalizeTradingHaltError(value, expectedRequest) { + if (!hasExactKeys(value, ["requestId", "query", "message"]) || + !isSafeCanonicalText(value.requestId, 128) || + !identifierPattern.test(value.requestId) || + normalizeQuery(value.query) !== value.query || + !isSafeCanonicalText(value.message, 512)) { + return null; + } + if (expectedRequest && + (value.requestId !== expectedRequest.requestId || value.query !== normalizeQuery(expectedRequest.query))) { + return null; + } + return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message }); + } + + function getAction(actionId) { + return typeof actionId === "string" ? actionsById.get(actionId) || null : null; + } + + function requireOptions(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Trading-halt playlist options are required."); + } + if (!isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) { + throw new TypeError("A safe trading-halt playlist id is required."); + } + const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : options.fadeDuration; + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + throw new RangeError("Fade duration must be an integer from 0 through 60."); + } + const ma5 = options.ma5 === undefined ? false : options.ma5; + const ma20 = options.ma20 === undefined ? false : options.ma20; + if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") { + throw new TypeError("Moving-average flags must be boolean values."); + } + return Object.freeze({ id: options.id, fadeDuration, ma5, ma20 }); + } + + function buildTradingHaltSelection(actionId, tradingHalt, options = {}) { + const selectedAction = getAction(actionId); + const selected = normalizeTradingHalt(tradingHalt); + if (!selectedAction) throw new RangeError("The trading-halt action is unknown."); + if (!selected) throw new TypeError("A valid trading-halt selection is required."); + const ma5 = options.ma5 === undefined ? false : options.ma5; + const ma20 = options.ma20 === undefined ? false : options.ma20; + if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") { + throw new TypeError("Moving-average flags must be boolean values."); + } + const movingAverages = Object.freeze({ + ma5: selectedAction.kind === "candle" && ma5, + ma20: selectedAction.kind === "candle" && ma20 + }); + const flags = []; + if (movingAverages.ma5) flags.push("MA5"); + if (movingAverages.ma20) flags.push("MA20"); + const selection = selectedAction.kind === "current" + ? Object.freeze({ + groupCode: sourceContract.currentResolverGroups[selected.market], + subject: selected.displayName, + graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8", + subtype: "HALTED", + dataCode: selected.stockCode + }) + : Object.freeze({ + groupCode: sourceContract.candleResolverGroups[selected.market], + subject: selected.displayName, + graphicType: flags.join(","), + subtype: "TRADING_HALT", + dataCode: selected.stockCode + }); + return Object.freeze({ + builderKey: selectedAction.builderKey, + code: selectedAction.code, + aliases: selectedAction.aliases, + selection, + movingAverages + }); + } + + function createTradingHaltPlaylistEntry(tradingHalt, actionId, options) { + const selected = normalizeTradingHalt(tradingHalt); + if (!selected) throw new TypeError("A valid trading-halt selection is required."); + const selectedAction = getAction(actionId); + if (!selectedAction) throw new RangeError("The trading-halt action is unknown."); + const normalizedOptions = requireOptions(options); + const mapping = buildTradingHaltSelection(actionId, selected, normalizedOptions); + return { + id: normalizedOptions.id, + builderKey: mapping.builderKey, + code: mapping.code, + aliases: [...mapping.aliases], + title: selected.displayName, + detail: selectedAction.label, + category: selectedAction.kind === "candle" ? "chart" : "stock", + market: selected.market, + stockCode: selected.stockCode, + displayName: selected.displayName, + selection: mapping.selection, + enabled: true, + fadeDuration: normalizedOptions.fadeDuration, + graphicType: mapping.selection.graphicType, + subtype: mapping.selection.subtype, + operator: Object.freeze({ + source: "legacy-trading-halt-workflow", + schemaVersion: SCHEMA_VERSION, + actionId: selectedAction.id, + tradingHalt: selected, + movingAverages: mapping.movingAverages, + resolverGroup: mapping.selection.groupCode + }) + }; + } + + function sameStringArray(left, right) { + return Array.isArray(left) && Array.isArray(right) && + left.length === right.length && left.every((value, index) => value === right[index]); + } + + function sameSelection(left, right) { + if (!left || typeof left !== "object" || !right || typeof right !== "object") return false; + return ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => typeof left[key] === "string" && left[key] === right[key]); + } + + function restoreTradingHaltPlaylistEntry(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") { + return null; + } + const operator = value.operator; + if (!operator || typeof operator !== "object" || + operator.source !== "legacy-trading-halt-workflow" || + operator.schemaVersion !== SCHEMA_VERSION || + !actionsById.has(operator.actionId) || + !operator.movingAverages || typeof operator.movingAverages !== "object" || + typeof operator.movingAverages.ma5 !== "boolean" || + typeof operator.movingAverages.ma20 !== "boolean") { + return null; + } + + let recreated; + try { + recreated = createTradingHaltPlaylistEntry(operator.tradingHalt, operator.actionId, { + id: value.id, + fadeDuration: value.fadeDuration, + ma5: operator.movingAverages.ma5, + ma20: operator.movingAverages.ma20 + }); + } catch { + return null; + } + + const scalars = [ + "id", "builderKey", "code", "title", "detail", "category", "market", + "stockCode", "displayName", "fadeDuration", "graphicType", "subtype" + ]; + if (!scalars.every(key => value[key] === recreated[key]) || + !sameStringArray(value.aliases, recreated.aliases) || + !sameSelection(value.selection, recreated.selection) || + operator.movingAverages.ma5 !== recreated.operator.movingAverages.ma5 || + operator.movingAverages.ma20 !== recreated.operator.movingAverages.ma20 || + operator.resolverGroup !== recreated.operator.resolverGroup) { + return null; + } + return { ...recreated, enabled: value.enabled }; + } + + return { + bridgeContract, + sourceContract, + markets, + actions, + tradingHaltActions: actions, + normalizeTradingHalt, + normalizeTradingHaltResult: normalizeTradingHalt, + createTradingHaltSearchRequest, + normalizeTradingHaltSearchResponse, + normalizeTradingHaltError, + getAction, + buildTradingHaltSelection, + createTradingHaltPlaylistEntry, + restoreTradingHaltPlaylistEntry, + refreshTradingHaltPlaylistEntry: restoreTradingHaltPlaylistEntry + }; +}); diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index f1b6670..c4b4d36 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -19,6 +19,7 @@ | WinForms MainForm | WinUI 3 `MainWindow` + 로컬 WebView2 UI | | FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 | | 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` | +| Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight | | WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 | | 직접 DBManager 호출 | Core의 `IDataQueryExecutor` 경계 | | `Data/Request` SQL 정의 | .NET 8 Core 프로젝트로 71개 이관 | @@ -33,7 +34,7 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일만 제공합니다. 외부 탐색은 WebView 안에서 차단하고 기본 브라우저로 넘기며, 개발자 도구와 기본 컨텍스트 메뉴는 디버거가 연결된 경우에만 활성화합니다. -## 다음 구현 경계 +## 구현 및 후속 운영 경계 ### 데이터베이스 @@ -58,17 +59,19 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일 - 완료: 네이티브/Interop 이중 SHA-256 핀, 프로세스 수명 파일 잠금, 실제 PGM listener 소유권 및 KTAP 지연 dispatch 차단 - 과거 기본 경로 증거: 승인된 PGM 고정 runner에서 `5001 → 5006 → TAKE OUT`과 Network Monitoring `HELLO/LOAD_SCENE/SCENE_PREPARE/PLAY/STOPAL/BYE`, 39개 연속 캡처를 검증했다. 이는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh 또는 35개 builder 동등성 완료 증거가 아니다. - 완료: 첫 과거 회차의 출력 전 Prepare 거부 원인이 cue 이중 resolve임을 확인하고, 상대 cue와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지 -- 현재 목표 미완료: 승인 회차 `MBNWEB-20260711-A`에서 설치된 MSIX WebView의 CONNECT와 `PREPARE 5001/page 1` 및 Network Monitoring transaction/mutation/SCENE_PREPARE는 성공했다. 그러나 fresh TAKE IN의 Oracle 조회가 K3D PLAY 전에 `DATABASE_UNAVAILABLE`로 명확히 실패해 재시도하지 않았고, STOPAL/UNLOAD/BYE로 안전하게 회수했다. 회차 종료 뒤 Oracle/MariaDB 전체 스모크는 다시 통과했지만 새 회차에서 `fresh TAKE IN → playlist/Page NEXT → timer refresh → TAKE OUT`과 PGM 동시 검증을 통과해야 한다. +- 과거 실패·복구 증거: 승인 회차 `MBNWEB-20260711-A`의 CONNECT/PREPARE 뒤 fresh TAKE IN Oracle 조회가 K3D PLAY 전에 명확히 실패했으며 재시도 없이 STOPAL/UNLOAD/BYE로 회수했다. +- 승인 범위 완료: 이후 `MBNWEB-20260711-H`에서 설치된 MSIX WebView로 `5001 PREPARE → fresh TAKE IN → 자동 refresh 1회 → 5074 playlist NEXT → Page NEXT → TAKE OUT`을 실제 PGM과 Network Monitoring에서 함께 확인했다. `PLAY=4`, `SCENE_PLAYED=4`, FAILURE/ERROR/retry=0, 최종 `OutcomeUnknown=false`였다. 이 증거는 승인된 `5001`/`5074`에만 적용한다. - 완료: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload와 pending callback fail-closed accounting - 완료: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB 검증 ### 화면 기능 -- Oracle 플레이리스트 영구 저장/불러오기 -- 10개 업무 탭의 실제 데이터 바인딩 -- 테마, 전문가, VI, 비교, 수동 그래프 편집기 -- 35개 장면 빌더의 실제 Tornado2/PGM 화면 동등성 회차 검증 -- 실제 Preview 이미지 및 씬/영상 자산 연결 +- 완료: MainForm과 UC1~UC7의 종목·고정·업종·비교·테마·해외·전문가·거래정지 workflow +- 완료: GraphE, FSell, VIList, PList/AList, ThemeA, EList 전용 화면과 native bridge +- 완료: 328 fixed + 44 industry + 31 stock + 9 comparison의 412 action 자동 매트릭스 +- 완료: F2/F3 배경 선택, 전역 5일선/20일선, 이름 있는 Oracle 플레이리스트 +- 후속 승인 범위: `5001`/`5074` 이외 장면의 실제 Tornado2/PGM 확인 +- 후속 운영 자산: 필요할 때 trusted scene-root의 실제 Preview 이미지 및 영상 연결 ## 의도적으로 제외한 항목 diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md new file mode 100644 index 0000000..ff3296b --- /dev/null +++ b/docs/OPERATOR_UI_PARITY.md @@ -0,0 +1,280 @@ +# 운영자 UI 동등성 인벤토리 + +## 문서 목적과 판정 기준 + +이 문서는 원본 WinForms 운영 화면의 사용자 동작을 새 WinUI 3/WebView 구현에 1:1로 연결한다. 장면 builder가 존재하는 것, 네이티브 bridge와 workflow 모듈이 존재하는 것, 실제 Web 화면에서 운영자가 사용할 수 있는 것은 서로 다른 완료 조건으로 취급한다. + +상태 표기는 다음과 같다. + +| 상태 | 의미 | +|---|---| +| 화면 통합 완료 | 현재 `Web/index.html`과 `Web/app.js`에서 검색·선택·플레이리스트 추가 또는 저장 동작을 실행할 수 있다. | +| 화면 통합 완료 / 실환경 검증 대기 | Web 화면, native bridge, workflow와 자동 테스트까지 연결됐지만 운영 DB write 또는 해당 경로의 실제 PGM 확인은 수행하지 않았다. | +| 일부 검증 | 화면 동작은 구현됐지만 해당 기능 전체를 실제 운영 DB write 또는 PGM으로 확인하지 않았다. | + +`DB-W`는 운영 데이터베이스에 실제 쓰기를 수행해 확인했는지를 뜻한다. `PGM`은 실제 Tornado2 PGM 출력으로 해당 UI 경로를 확인했는지를 뜻한다. fake executor, DryRun, SQL 계약 테스트는 자동 테스트 근거이지 실제 DB-W 또는 PGM 검증으로 계산하지 않는다. + +## 원본 보존 상태 + +- 원본 저장소는 읽기 전용으로만 분석했고 파일을 수정하지 않았다. +- 분석 시작 시 원본에 이미 존재하던 dirty 파일은 `MBN_STOCK_N/MainForm.cs`, `MBN_STOCK_N/RES/MmoneyCoder.ini` 두 개였다. 두 파일의 기존 변경을 그대로 보존했다. +- 이 문서에는 비밀번호, 실제 방송 데이터, 자산·경로 해시를 기록하지 않는다. + +## 412개 실행 action의 고정 분해 + +412는 다음 네 집합의 합이다. 동적 테마·전문가 목록과 GraphE 편집 행 수는 이 숫자에 중복해 더하지 않는다. + +| 집합 | 원본 UI | 수량 | 새 구현 | 자동 근거 | 상태 | +|---|---|---:|---|---|---| +| 고정 action | `MainForm`의 해외·환율·지수 탭과 `Control/UC1.cs` | 328 | `Web/legacy-fixed-workflow.js` | `tests/Web/legacy-fixed-workflow.test.cjs`가 328개, 직접 생성 가능한 322개, 수동 전제 6개를 고정한다. | 화면 통합 완료. 수동 6개는 아래 대체 경계를 통과해야 한다. | +| 업종 action | `Control/UC1.cs` + `Control/UC2.cs`의 코스피·코스닥 | 44 | `Web/industry-workflow.js`의 시장별 22개 | `tests/Web/industry-workflow.test.cjs` | 화면 통합 완료 | +| 종목 action | `MainForm` 좌측 종목 검색·컷 목록 | 31 | `Web/operator-workflow.js` | `tests/Web/operator-workflow.test.cjs`가 실행 자산 순서의 31개를 고정한다. | 화면 통합 완료 | +| 비교 action | `Control/UC1.cs` + `Control/UC3.cs` | 9 | `Web/comparison-workflow.js` | `tests/Web/comparison-workflow.test.cjs`가 24개 비교 대상과 9개 action을 별도로 고정한다. | 화면 통합 완료 | +| 합계 | | **412** | | | | + +고정 action 328개 안의 수동 전제 6개는 일반 고정 DTO로 임의 생성하지 않는다. + +| 원본 고정 항목 | 수량 | 원본 전용 폼 | 새 대체 경계 | 관계 | +|---|---:|---|---|---| +| 개인·외국인·기관 순매도 상위(수동) | 3 | `Form/FSell.cs` | `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, 신뢰된 s5025 파일 저장소 | 각 audience의 검증된 5행 입력이 있어야 `s5025/5025` 플레이리스트 항목을 만든다. | +| 5단·6종목·12종목 섹션의 VI 발동(수동) placeholder | 3 | `Form/VIList.cs` | `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, 신뢰된 VI 저장소 | 세 placeholder 모두 원본 VIList가 만든 하나의 `s5074/5074` 5행 페이지 결과로 대체된다. 6·12행 장면으로 바꾸지 않는다. | + +현재 이 수동 6개는 FSell/VIList 대체 화면까지 통합됐다. 고정 카탈로그에서 임의 DTO로 직접 활성화하지 않고, 전용 입력 화면이 저장 후 재조회한 신뢰 데이터와 버전을 공급한 뒤에만 플레이리스트에 넣는다. + +## 화면 단위 완료 요약 + +| 원본 화면 | 핵심 역할 | 새 화면/모듈 상태 | DB-W | 해당 UI의 PGM | +|---|---|---|---|---| +| MainForm | 종목 검색, 31개 컷, playlist, 송출·배경·저장 | 화면 통합 완료 | 없음/해당 없음 | 일부: 승인된 `5001`, `5074`, TAKE OUT | +| UC1 | 328 fixed, 업종·비교 action 트리 | 전체 action과 수동 6개 전용 편집 화면 통합 완료 | 해당 없음 | 일부: generic `5074`; 전체 action 미검증 | +| UC2 | 업종 목록과 두 업종 선택 | 화면 통합 완료 | 해당 없음 | 미검증 | +| UC3 | 비교 대상·저장 비교쌍·9 action | 화면 통합 완료 | 원본 파일 저장을 browser schema 저장으로 대체 | 미검증 | +| UC4 | 테마 검색·preview·5/6/12행 항목 | 조회/선택 및 ThemeA CRUD 화면 통합 완료 | CRUD 미실행 | 미검증 | +| UC5 | 해외업종·해외종목·해외지수 | 화면 통합 완료 | 해당 없음 | 미검증 | +| UC6 | 전문가 조회·추천 preview·5행 항목 | 조회/선택 및 EList/추천 CRUD 화면 통합 완료 | CRUD 미실행 | 미검증 | +| UC7 | 거래정지 검색·현재/120일 캔들 | 화면 통합 완료 | 해당 없음 | 미검증 | +| GraphE | 4개 수동 재무 CRUD와 playlist 추가 | 화면 통합 완료 / 실환경 검증 대기 | 미실행 | 미검증 | +| FSell | 개인·외국인·기관 수동 순매도 5행 | 화면 통합 완료 / 실환경 검증 대기 | 운영 데이터 write 미실행 | 미검증 | +| VIList | 수동 VI 목록·순서·5개 단위 page | 화면 통합 완료 / 실환경 검증 대기 | 운영 데이터 write 미실행 | 수동 VI dataset 미검증 | +| PList | 이름 있는 playlist 목록·저장·불러오기·삭제 | 화면 통합 완료 | 운영 DB write 미실행 | 복원 경로 미검증 | +| AList | 프로그램 코드·이름 생성 | PList의 named playlist 화면에 통합 | 운영 DB write 미실행 | 해당 없음 | +| ThemeA | KRX/NXT 테마 구성 CRUD | 화면 통합 완료 / 실환경 검증 대기 | 미실행 | 미검증 | +| EList | 전문가 생성과 추천 구성 CRUD | 화면 통합 완료 / 실환경 검증 대기 | 미실행 | 미검증 | + +## MainForm 매핑 + +원본 파일은 `MainForm.cs`와 `MainForm.Designer.cs`다. + +| 원본 이벤트·컨트롤 | 원본 동작과 입력/저장 경계 | 새 구현 파일·bridge/workflow | 현재 상태 | 자동 테스트 근거 | DB-W / PGM | +|---|---|---|---|---|---| +| `Form1_Load`, `tabControl1_SelectedIndexChanged` | 실행 자산을 읽고 UC1~UC7을 탭에 배치하며 Tornado에 연결한다. | `MainWindow.xaml.cs`, `MainWindow.Playout.cs`, `Web/index.html`, `Web/app.js` | 화면 통합 완료 | `LegacyWebWorkflowIntegrationTests.cs`, `PlayoutBridgeProtocolTests.cs`, Web 전체 회귀 | DB-W 해당 없음 / PGM 일부 완료 | +| `button9_Click`, `txtSearch_KeyPress`, `searchStock`, `jongmokCode` | 코스피·코스닥·NXT 두 시장씩 검색하고 이름, 코드, 시장을 선택한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs`; native `search-stocks`; `Web/operator-workflow.js` | 화면 통합 완료 | `LegacyStockSearchServiceTests.cs`, `operator-workflow.test.cjs` | DB-W 없음 / `5001` 경로 일부 PGM 완료 | +| `listView1_*`, `GridView1_*`, `SetPlayList`, `PlayList_DragDrop` | 실행 자산의 종목 컷을 선택·드래그해 enabled, 시장, 종목, 종류, 세부사항, page, 종목코드 행을 만든다. | `Web/operator-workflow.js`, `Web/app.js`의 종목 workflow와 표준 playlist entry | 화면 통합 완료 | `operator-workflow.test.cjs`, `candle-options-workflow.test.cjs` | DB-W 없음 / 전체 31개 PGM 미검증 | +| `b1_Click` (`주요매출 구성`, `성장성 지표`, `매출액`, `영업이익`) | `GraphE` 네 모드를 연다. | `MainWindow.ManualFinancial.cs`, `Web/manual-financial-workflow.js`, `Web/manual-financial-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | GraphE 표 참조 | DB-W 없음 / PGM 없음 | +| `PlayList_CellClick`, `PlayList_KeyPress`, `checkBox1_CheckedChanged`, `btnup_Click`, `btndown_Click`, `btndelete_Click`, `btntdelete_Click` | 단일·Ctrl·Shift 선택, Space/전체 송출 체크, 순서 이동, 선택·전체 삭제. 송출 중 이동·전체 삭제를 막는다. | `Web/app.js`의 playlist 상태, snapshot lock, `Web/playout-safety.js` | 화면 통합 완료 | `playout-safety.test.cjs`, named/각 workflow의 trusted restore 테스트 | DB-W 없음 / PGM 제어 일부 완료 | +| `button5_Click`, `btnload_Click` | `PList`를 열어 이름 있는 DB 플레이리스트를 저장·불러온다. | `MainWindow.NamedPlaylists.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `Web/named-playlist-workflow.js` | 화면 통합 완료 | named playlist Core/Infrastructure/Web/bridge 테스트 | DB-W 없음 / 복원 항목별 PGM 없음 | +| `btnPrepare_Click`, `btnTake_Click`/F8, `btnNext_Click`, `btnTakeout_Click`/Esc | PREPARE, TAKE IN, 같은 장면 page NEXT 또는 다음 행 NEXT, StopAll 기반 TAKE OUT. | `MainWindow.Playout.cs`; `IPlayoutEngine`; native `playout-command`, `request-playlist-page-plans`; `Web/playout-safety.js` | 화면 통합 완료 | playout/Core/PageN/PGM sequence 테스트와 `playout-safety.test.cjs` | DB-W 해당 없음 / 승인된 `5001`, `5074` page 1·2 및 TAKE OUT만 PGM 확인 | +| `btnback_Click`/F2, `ckback_CheckedChanged`/F3 | 허용 배경 파일 선택, 배경 없음 토글, 송출 중 변경 금지. | `MainWindow.Background.cs`; native `request-background-status`, `choose-background`, `toggle-background`; Web 배경 컨트롤 | 화면 통합 완료 | bridge·경로 검증과 앱 빌드 | DB-W 해당 없음 / 기능별 PGM 미검증 | +| `timer1_Tick`, `timer2_Tick`, `timer3_Tick`, `MainForm_FormClosing`, `MainForm_Resize`, keyboard handlers | 자동 갱신, 상태 표시, 종료 정리, 단축키를 담당한다. | playout refresh scheduler, cancellation/lifetime hooks, Web 상태·단축키 | 화면 통합 완료 | refresh epoch/scheduler, playout safety, bridge 테스트 | DB-W 해당 없음 / 일부 PGM 완료 | + +실제 PGM 완료는 이미 승인된 제한 회차의 `5001`과 `5074` 흐름만 뜻한다. 412개 전체나 아래 편집 화면의 실제 PGM 동등성을 뜻하지 않는다. + +## UC1~UC7 매핑 + +### UC1 — 해외·환율·지수·업종·비교 action 트리 + +원본: `Control/UC1.cs`, `Control/UC1.Designer.cs` + +| 원본 이벤트·버튼 | 원본 경계 | 새 구현 | 상태·검증 | +|---|---|---|---| +| `UC1_Load`, `INI_parser` | 탭별 실행 자산을 읽어 category/leaf TreeView를 구성한다. | `legacy-fixed-workflow.js`, `industry-workflow.js`, `comparison-workflow.js`의 폐쇄형 inventory | 화면 통합 완료. 각 Web 테스트가 수량·중복·매핑을 고정한다. | +| `chE_CheckedChanged`, `treeView_BeforeSelect`, `treeView_BeforeExpand/BeforeCollapse`, `treeView_DrawNode`, `treeView_MouseDown` | 전체 expand/collapse와 트리 표시를 제어한다. | Web category/section 렌더링과 펼침 상태 | 화면 통합 완료. 시각 상호작용이며 DB-W/PGM 대상 아님. | +| `treeView_DoubleClick` | category 전체 또는 leaf를 playlist에 넣고 5·6·12개 PageN, NXT 제한, 업종 쌍, 비교 쌍을 계산한다. FSell·VI 수동 항목이면 전용 폼으로 분기한다. | 고정 328, 업종 44, 비교 9 workflow의 `create*PlaylistEntry`; page plan bridge; 수동 6개는 `manual-lists-workflow.js`와 `manual-lists-ui.js`로 분리 | 전체 화면 통합 완료. 실제 DB-W 없음, 전체 PGM 미검증. | +| `treeView_AfterSelect`, `btnswap_Click` | 원본에서 실질 동작이 없거나 주석 처리된 이벤트다. | 별도 기능 없음 | 의도적으로 추가 동작을 만들지 않는다. | + +### UC2 — 코스피·코스닥 업종 선택과 쌍 + +원본: `Control/UC2.cs`, `Control/UC2.Designer.cs` + +| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `UC2_Load` | 코스피/코스닥 업종 이름·코드를 DB에서 읽는다. | `src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs`; native `request-industries` | 화면 통합 완료. `LegacyIndustrySelectionServiceTests.cs`, `industry-workflow.test.cjs`. | +| `listbox_DoubleClick`, `btnswap_Click` | 선택 업종을 두 슬롯에 회전 입력하고 순서를 바꾼다. | `Web/industry-workflow.js`의 selected/primary/secondary 입력 | 화면 통합 완료. 시장 혼합과 빈 슬롯을 fail closed 한다. | +| UC1 업종 leaf 실행 | 단일, 2열, 수익률 5종, 캔들 12종, 시장 전체 3종으로 시장별 22개를 만든다. | `industry-workflow.js` | 44개 화면 통합 완료. 실제 DB-W 없음 / 업종별 PGM 없음. | + +### UC3 — 비교 대상·저장 비교쌍 + +원본: `Control/UC3.cs`, `Control/UC3.Designer.cs` + +| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `UC3_Load`, `Data_Load` | 24개 고정 비교 대상을 읽고 로컬 `종목비교.dat`의 저장쌍을 불러온다. | `Web/comparison-workflow.js`; 24개 typed target; schema-versioned pair list | 화면 통합 완료. Web은 안전한 browser storage를 사용하며 raw 원본 파일을 신뢰하지 않는다. | +| `btnsearch_Click`, `listBox_DoubleClick`, `listBox1_DoubleClick`, `btnsearch2_Click`, `FpSpread2_CellDoubleClick` | 국내 종목, 24개 시장 대상, 해외 종목을 두 슬롯에 회전 입력한다. | native `search-stocks`, `search-world-stocks`; comparison target normalizer | 화면 통합 완료. `comparison-workflow.test.cjs`가 회전·시장·해외 identity를 검증한다. | +| `btnswap_Click`, `btnAdd_Click`, `btn1/2/3/4_Click`, `File_Save` | 쌍 교환, 추가, 선택/전체 삭제, 위/아래 이동 후 저장한다. | comparison pair append/delete/delete-all/reorder와 Web storage | 화면 통합 완료. 최대치·중복·손상 복원을 테스트한다. | +| UC1 비교 leaf 실행 | 현재/예상 2열, 비교 캔들/라인, 5일·1/3/6/12개월 수익률의 9개 action | `comparison-workflow.js` | 9개 화면 통합 완료. 실제 DB-W 없음 / 비교 PGM 없음. | + +### UC4 — 테마 검색·미리보기·송출 선택 + +원본: `Control/UC4.cs`, `Control/UC4.Designer.cs` + +| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `btn1_Click`, `GetThemeByKeyword` | KRX Oracle과 NXT 저장소에서 테마명·코드를 검색한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs`; native `search-themes`; `Web/theme-workflow.js` | 검색 화면 통합 완료. `LegacyThemeSelectionServiceTests.cs`, `theme-workflow.test.cjs`. | +| `FpSpread1_CellClick` | 선택 테마의 구성 종목과 입력 순서를 DB에서 읽고 중지 종목을 제외한다. | native `request-theme-preview`와 typed preview | 화면 통합 완료. 중복 key·시장 불일치·truncated preview를 거부한다. | +| `btnnow_Click`, `btnpre_Click`, `btnnow_6_Click`, `btnnow_12_Click`, double-click | 5단 현재/예상, 6종목 현재, 12종목 현재를 입력순·상승률·하락률로 만들고 5/6/12 page 수를 계산한다. NXT 예상은 차단한다. | `theme-workflow.js`의 compatibility, page preview, playlist entry | 화면 통합 완료. 실제 DB-W 없음 / 테마 PGM 없음. | +| `btn2_Click`, `btn3_Click`, `btn4_Click` | ThemeA 추가·편집 폼을 열거나 선택 테마와 구성 종목을 삭제한다. | `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`, `Web/operator-catalog-ui.js` | ThemeA CRUD 화면 통합 완료. 실제 DB-W/PGM 없음. | + +### UC5 — 해외업종·해외종목·해외지수 + +원본: `Control/UC5.cs`, `Control/UC5.Designer.cs` + +| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `UC5_Load` | 고정 해외지수 목록을 표시한다. | `Web/overseas-workflow.js`의 고정 지수 asset | 화면 통합 완료. | +| `btnsearch1_Click`, `upjong_search` | 미국 해외업종의 입력명·표시명·symbol을 조회한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs`; native `search-overseas-industries` | 화면 통합 완료. `LegacyOverseasIndustryIndexSearchServiceTests.cs`. | +| `btnsearch2_Click`, `jongmok_search` | US/TW 해외종목 이름·symbol을 조회한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs`; native `search-world-stocks` | 화면 통합 완료. `LegacyWorldStockSearchServiceTests.cs`. | +| `btn1_Click`, `Account_Graph` | 업종/종목 현재 1열과 지수·종목 5/20/60/120/240일 캔들을 playlist에 넣는다. | `Web/overseas-workflow.js` | 화면 통합 완료. `overseas-workflow.test.cjs`; 실제 DB-W 없음 / 해외 PGM 없음. | + +### UC6 — 전문가 목록·추천 종목·송출 선택 + +원본: `Control/UC6.cs`, `Control/UC6.Designer.cs` + +| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `UC6_Load`, `Data_Load`, `FpSpread1_CellClick` | `EXPERT_LIST`를 읽고 선택 전문가의 `RECOMMEND_LIST`를 PLAYINDEX 순서로 미리 본다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs`; native `search-experts`, `request-expert-preview`; `Web/expert-workflow.js` | 검색·선택 화면 통합 완료. `LegacyExpertSelectionServiceTests.cs`, `expert-workflow.test.cjs`. | +| `btlistadd_Click` | 선택 전문가를 5행 page, 최대 20 page의 전문가 추천 항목으로 만든다. | `expert-workflow.js`의 native-normalized preview와 playlist entry | 화면 통합 완료. 실제 DB-W 없음 / 전문가 PGM 없음. | +| `btnAdd_Click` | EList 신규 전문가 폼을 연다. | operator catalog expert create와 `operator-catalog-ui.js` | EList 신규 UI 통합 완료. | +| `btnDelete_Click`, `btndel_Click`, `btnsave_Click`, 종목 검색·double-click | 전문가 삭제, 추천 종목 편집·삭제·재정렬·저장. 원본은 전문가/추천 행을 삭제 후 재삽입한다. | `MainWindow.OperatorCatalogs.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `Web/operator-catalog-workflow.js`, `Web/operator-catalog-ui.js` | EList/전문가 CRUD 화면 통합 완료. 실제 DB-W/PGM 없음. | + +### UC7 — 거래정지 종목 + +원본: `Control/UC7.cs`, `Control/UC7.Designer.cs` + +| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `btnsearch_Click`, `txts_KeyPress`, `search` | 거래정지 코스피와 코스닥 종목을 빈 검색 또는 부분 검색으로 합친다. | `src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs`; native `search-trading-halts`; `Web/trading-halt-workflow.js` | 화면 통합 완료. `LegacyTradingHaltSelectionServiceTests.cs`. | +| `FpSpread1_CellClick` | 종목명 열 정렬 | Web 정규화 결과의 결정적 정렬 | 화면 통합 완료. | +| `btn4_Click` (`현재가`, `120일 캔들`) | 시장·종목 identity로 현재 1열 또는 거래정지 120일 캔들 항목을 만든다. | `trading-halt-workflow.js` | 화면 통합 완료. `trading-halt-workflow.test.cjs`; 실제 DB-W 없음 / PGM 없음. | + +## 전용 Form 매핑 + +### GraphE — 네 가지 수동 재무 편집기 + +원본: `Form/GraphE.cs`, `Form/GraphE.Designer.cs` + +| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `GraphE_Load`, `Data_Init`, `FpSpread1_CellClick`, `Data_Load` | 네 테이블 전체/검색 목록을 읽고 선택 행을 편집 DTO로 분해한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs`; native `request-manual-financial-list`, `request-manual-financial-load`; `Web/manual-financial-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | +| 종목 검색, `listBox_DoubleClick`, `upSearch_Click`, `downSearch_Click` | 정확한 종목을 선택하고 테이블 내 이름을 위/아래로 찾는다. | native stock master 재검증과 manual financial list/search | 화면 통합 완료. 이름만으로 시장·코드를 추정하지 않는다. | +| `btadd_Click` | 기존 이름이면 update, 아니면 insert. `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT`의 폐쇄형 필드를 저장한다. | native `create-manual-financial-record`, `update-manual-financial-record`; `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs` | 화면 통합 완료. 단일 트랜잭션, optimistic `rowVersion`, no retry, OutcomeUnknown quarantine 구현. 실제 DB-W 없음. | +| `btndel_Click`, `btnall_Click` | 선택 이름 삭제 또는 테이블 전체 삭제 확인 | native `delete-manual-financial-record`, `delete-all-manual-financial-records` | 화면 통합 완료. 실제 DB-W 없음. | +| `btnAdd_Click`, row double-click, `Select_Item` | DB에 존재하는 행을 정확한 시장/종목과 결합해 1 page playlist 항목으로 추가한다. | native `request-manual-financial-selection`; `Web/manual-financial-workflow.js` | 화면 통합 완료. 저장 복원본은 fresh load + rowVersion/record 일치 + stock 재검증 + native selection 전까지 PREPARE 불가. PGM 없음. | + +화면별 폐쇄형 매핑은 다음과 같다. + +| 원본 모드 | 테이블 | 입력 | builder/code | +|---|---|---|---| +| 주요매출 구성 | `INPUT_PIE` | 종목, 기준일, 최대 5개 구성명·비율 | `s5076/5076` | +| 성장성 지표 | `INPUT_GROW` | 매출·영업이익·순자산·순이익 증가율 각 4개와 분기 4개 | `s5079/5079` | +| 매출액 | `INPUT_SELL` | 분기·금액 6쌍 | `s5080/5080` | +| 영업이익 | `INPUT_PROFIT` | 분기·금액 6쌍 | `s5081/5081` | + +자동 근거는 `LegacyManualFinancialScreenServiceTests.cs`, `OracleManualFinancialMutationExecutorTests.cs`, `manual-financial-workflow.test.cjs`, `manual-financial-bridge-integration.test.cjs`다. + +### FSell — 수동 순매도 3종 + +원본: `Form/FSell.cs`, `Form/FSell.Designer.cs` + +| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `FSell_Load`, `data_load`, `ReadListFile` | 개인·외국인·기관별 로컬 파일에서 5행, 행마다 좌/우 종목·금액을 읽는다. | `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs`의 store/data source; native `request-manual-net-sell-data`; `Web/manual-lists-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | +| `btnok_Click`, `WriteListFile` | 정확히 5행을 저장하고 수동 순매도 항목 생성을 허용한다. | native `save-manual-net-sell-data`; `Web/manual-lists-workflow.js` | 화면 통합 완료. 검증된 versioned 데이터만 `s5025/5025`로 변환한다. 실제 파일 write는 자동 테스트 임시 디렉터리에서만 확인, 운영 데이터 write 없음. | +| `btncn_Click` | 취소 시 placeholder 행을 유지하지 않는다. | workflow가 저장 성공·신뢰 데이터 없이는 항목을 만들지 않는다. | 화면 통합 완료. PGM 없음. | + +자동 근거는 `TrustedManualSceneDataLoader5025Tests.cs`, `S5025TrustedManualFileDataSourceTests.cs`, `S5025TrustedManualFileStoreTests.cs`, `manual-lists-workflow.test.cjs`다. + +### VIList — 수동 VI 목록 + +원본: `Form/VIList.cs`, `Form/VIList.Designer.cs` + +| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `VILIST_Load` | 로컬 VI 목록을 읽는다. | `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs`; native `request-vi-manual-list`; `Web/manual-lists-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | +| 종목 검색, `listBox_DoubleClick` | 종목 master에서 코스피/코스닥 prefix와 종목명을 추가한다. | native `search-stocks`, `manual-lists-workflow.js`의 strict VI item | 화면 통합 완료. | +| `btn1/2/3/4_Click` | 선택/전체 삭제와 위/아래 이동 | manual lists draft operations | 화면 통합 완료. | +| `btnok_Click` | 목록을 저장하고 종목명을 comma로 결합해 항상 `5단 표그래프 / VI 발동(수동)`으로 추가하며 page를 5개 단위로 계산한다. | native `save-vi-manual-list`; trusted `s5074/5074`, 5개 단위 page entry | 화면 통합 완료. 자동 테스트는 `ViTrustedManualFileStoreTests.cs`, `manual-lists-workflow.test.cjs`, fixed workflow의 VI placeholder 테스트. 운영 write 없음. | +| `btncancel_Click` | 저장·추가 없이 닫는다. | workflow 취소 시 mutation 없음 | 화면 통합 완료. 수동 VI dataset의 PGM 없음. 기존 generic `5074` PGM 검증을 VI 입력 검증으로 간주하지 않는다. | + +### PList + AList — 이름 있는 DB 플레이리스트 + +원본: `Form/PList.cs`, `Form/AList.cs`와 각 Designer + +| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `PList_Load`, `Get_FileName_Folder` | `DC_LIST`의 프로그램 제목·코드를 조회한다. | native `request-named-playlist-list`; `named-playlist-workflow.js` | 화면 통합 완료 | +| `AList_Load`, `GetNextCode`, `btnok_Click` | 다음 8자리 코드와 프로그램명을 생성해 `DC_LIST`에 추가한다. | native `create-named-playlist` | 화면 통합 완료. parameterized transaction 사용. | +| `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면 통합 완료. raw·untrusted entry는 저장 또는 PREPARE 불가. | +| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면 통합 완료. raw DB row는 builder/selection/page를 재검증할 때까지 PREPARE 불가. | +| `PList.button1_Click` | 프로그램 정의 삭제 | native `delete-named-playlist` | 화면 통합 완료. 실제 운영 DB-W 없음. | +| 취소 | 아무 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. | + +구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트다. 실제 운영 DB write와 named playlist 전용 PGM 검증은 아직 없다. + +### ThemeA — 테마 추가·편집 + +원본: `Form/ThemeA.cs`, `Form/ThemeA.Designer.cs` + +| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `Theme_Load` | KRX/NXT 선택, 기존 테마 구성 종목과 순서를 로드한다. | native `request-theme-catalog-list`, `request-operator-catalog-schema`; `operator-catalog-workflow.js`; `operator-catalog-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | +| `btnC_Click` | 시장별 테마명 중복을 확인한다. | native list/identity validation | 화면 통합 완료 | +| 종목 검색·double-click | 같은 시장의 종목만 prefix가 포함된 구성 행으로 추가한다. | stock search + typed theme draft/preview | 화면 통합 완료 | +| `btn1/2/3/4_Click`, Delete key | 선택/전체 삭제, 위/아래 이동 | operator catalog theme draft operations | 화면 통합 완료 | +| `btnok_Click`, `GetNextCode` | 신규 코드를 만들거나 기존 `SB_LIST`/`SB_ITEM`을 교체하고 입력 순서를 저장한다. KRX와 NXT 저장소를 구분한다. | native `request-theme-catalog-next-code`, `create-theme-catalog`, `replace-theme-catalog`; atomic mutation executor | 화면 통합 완료. 실제 운영 DB-W/PGM 없음. | +| UC4 테마 삭제 | `SB_ITEM` 후 `SB_LIST` 삭제 | native `delete-theme-catalog` | 화면 통합 완료. | + +구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`다. 자동 근거는 theme catalog, schema, executor, Web workflow 테스트다. + +### EList — 전문가 신규 생성 + +원본: `Form/EList.cs`, `Form/EList.Designer.cs` + +| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | +|---|---|---|---| +| `EList_Load`, `GetNextCode` | `EXPERT_LIST`의 다음 4자리 코드를 제안한다. | native `request-expert-catalog-next-code`; `operator-catalog-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | +| `btnok_Click` | 이름을 검증하고 `EXPERT_LIST`에 신규 전문가를 저장한다. | native `create-expert-catalog` | 화면 통합 완료. 실제 운영 DB-W 없음. | +| UC6 저장·삭제 | 전문가와 `RECOMMEND_LIST` 구성 종목·금액·PLAYINDEX를 교체 또는 삭제한다. | native `replace-expert-catalog`, `delete-expert-catalog` | 화면 통합 완료. 실제 PGM 없음. | +| `btncn_Click` | 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. | + +구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, 공통 schema/executor/native partial, `Web/operator-catalog-workflow.js`다. 자동 근거는 expert catalog, schema, executor, Web workflow 테스트다. + +## bridge와 화면 로딩 상태 요약 + +| 기능군 | native request | workflow | 현재 `index.html`/`app.js` | +|---|---|---|---| +| 종목/고정/업종/비교/테마 조회/해외/전문가 조회/거래정지 | `search-stocks`, `request-industries`, `search-themes`, `request-theme-preview`, `search-world-stocks`, `search-overseas-industries`, `search-experts`, `request-expert-preview`, `search-trading-halts` | 각 전용 workflow | 로드·연결됨 | +| 이름 있는 플레이리스트 | `request-named-playlist-list`, `request-named-playlist-load`, `create-named-playlist`, `replace-named-playlist`, `delete-named-playlist` | `named-playlist-workflow.js` | 로드·연결됨 | +| GraphE | `request-manual-financial-list`, `request-manual-financial-load`, `request-manual-financial-selection`, `create-manual-financial-record`, `update-manual-financial-record`, `delete-manual-financial-record`, `delete-all-manual-financial-records` | `manual-financial-workflow.js`, `manual-financial-ui.js` | 로드·연결됨 | +| FSell/VI | `request-manual-operator-data-status`, `request-manual-net-sell-data`, `save-manual-net-sell-data`, `request-vi-manual-list`, `save-vi-manual-list` | `manual-lists-workflow.js`, `manual-lists-ui.js` | 로드·연결됨 | +| ThemeA/EList CRUD | `request-theme-catalog-list`, `request-expert-catalog-list`, `request-operator-catalog-schema`, theme/expert next-code, create/replace/delete requests | `operator-catalog-workflow.js`, `operator-catalog-ui.js` | 로드·연결됨 | + +## 근거 파일 빠른 링크 + +| 영역 | 구현 | 자동 테스트 | +|---|---|---| +| 공통 화면·송출·배경 | [MainWindow](../MainWindow.xaml.cs), [Playout partial](../MainWindow.Playout.cs), [Background partial](../MainWindow.Background.cs), [Web app](../Web/app.js) | [playout safety](../tests/Web/playout-safety.test.cjs) | +| 328 fixed / 44 industry / 31 stock / 9 comparison | [fixed workflow](../Web/legacy-fixed-workflow.js), [industry workflow](../Web/industry-workflow.js), [stock workflow](../Web/operator-workflow.js), [comparison workflow](../Web/comparison-workflow.js) | [fixed](../tests/Web/legacy-fixed-workflow.test.cjs), [industry](../tests/Web/industry-workflow.test.cjs), [stock](../tests/Web/operator-workflow.test.cjs), [comparison](../tests/Web/comparison-workflow.test.cjs) | +| 테마 조회·해외·전문가 조회·거래정지 | [theme](../Web/theme-workflow.js), [overseas](../Web/overseas-workflow.js), [expert](../Web/expert-workflow.js), [trading halt](../Web/trading-halt-workflow.js) | [theme](../tests/Web/theme-workflow.test.cjs), [overseas](../tests/Web/overseas-workflow.test.cjs), [expert](../tests/Web/expert-workflow.test.cjs), [trading halt](../tests/Web/trading-halt-workflow.test.cjs) | +| GraphE | [native partial](../MainWindow.ManualFinancial.cs), [Core service](../src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs), [Oracle executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs), [workflow](../Web/manual-financial-workflow.js) | [Core](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs), [Web](../tests/Web/manual-financial-workflow.test.cjs), [bridge](../tests/Web/manual-financial-bridge-integration.test.cjs) | +| FSell / VI | [native partial](../MainWindow.ManualLists.cs), [FSell store/data source](../src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs), [VI store](../src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs), [workflow](../Web/manual-lists-workflow.js) | [FSell store](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileStoreTests.cs), [VI store](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ViTrustedManualFileStoreTests.cs), [Web](../tests/Web/manual-lists-workflow.test.cjs) | +| PList / AList | [native partial](../MainWindow.NamedPlaylists.cs), [Core service](../src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs), [Oracle executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs), [workflow](../Web/named-playlist-workflow.js) | [Core integration](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceIntegrationTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs), [Web](../tests/Web/named-playlist-workflow.test.cjs), [bridge](../tests/Web/named-playlist-bridge-integration.test.cjs) | +| ThemeA / EList | [native partial](../MainWindow.OperatorCatalogs.cs), [theme persistence](../src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs), [expert persistence](../src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs), [executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs), [workflow](../Web/operator-catalog-workflow.js) | [theme](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeCatalogPersistenceServiceTests.cs), [expert](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertCatalogPersistenceServiceTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs), [Web](../tests/Web/operator-catalog-workflow.test.cjs) | + +## 실제 검증 판정 + +| 항목 | 결과 | +|---|---| +| 자동 테스트 | Core 1,116개, Infrastructure 125개, Playout 374개가 Debug/Release x64에서 각각 통과했고 Web workflow·bridge 235/235도 통과했다. 412 action의 폐쇄형 매트릭스, 계약·경계·복원 실패와 native fail-closed gate를 검증한다. | +| 실제 운영 DB write | **미실행.** GraphE, named playlist, ThemeA, EList의 mutation executor는 fake/계약 테스트로 검증했으며 운영 DB에는 쓰지 않았다. FSell/VI는 테스트용 격리 저장소만 사용했다. | +| 실제 Tornado2 PGM | 제한된 승인 회차에서 `5001`, `5074` page 1·2, TAKE OUT을 확인했다. 이는 전체 412 action, GraphE, FSell, VI, ThemeA, EList, 이름 있는 playlist 각각의 UI 동등성 검증이 아니다. | +| 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 새 signed `1.0.2.0` Release MSIX package-context 실행은 완료됐다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·롤백 계획이 있는 회차에서만 수행한다. 운영 DB write 또는 새 Live TAKE IN을 승인 없이 실행하지 않는다. | diff --git a/docs/OPERATOR_WORKFLOW.md b/docs/OPERATOR_WORKFLOW.md new file mode 100644 index 0000000..5be86ae --- /dev/null +++ b/docs/OPERATOR_WORKFLOW.md @@ -0,0 +1,72 @@ +# 운영자 워크플로우 마이그레이션 + +## 결론 + +장면 엔진 35개와 운영 화면은 같은 범위가 아니다. 35개 builder가 DB DTO와 K3D mutation을 만들 수 있어도, 원본 운영자가 선택하던 종목·업종·비교 대상과 수동 입력을 Web UI가 만들지 못하면 실제 방송 업무는 시작할 수 없다. + +이번 단계에서 캡처의 핵심 종목 흐름은 다음과 같이 복구했다. + +`종목명 검색 → Oracle/MariaDB 결과 선택 → 종목용 컷 선택 → 플레이리스트 → F8 PREPARE/TAKE IN → NEXT/TAKE OUT` + +## 복구된 종목 흐름 + +- KOSPI `T_STOCK`, KOSDAQ `T_KOSDAQ_STOCK`, NXT KOSPI `N_STOCK/N_ONLINE`, NXT KOSDAQ `N_KOSDAQ_STOCK/N_KOSDAQ_ONLINE`을 매개변수 쿼리로 검색한다. +- LIKE의 `%`, `_`, `!`는 escape하고 검색어·결과 수·열 스키마·종목 코드 형식을 제한한다. +- 검색 결과는 시장, 종목명, 종목 코드를 함께 보존하므로 원본처럼 이름을 다시 훑어 코드를 추정하지 않는다. +- 실제 실행 자산 `bin/Debug/Res/종목.ini`의 31개 항목을 원본 순서로 표시한다. +- 실행 자산 SHA-256은 `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20`으로 고정했다. 소스 `RES/종목.ini`와 다른 `시간외단일가` 표기는 실행 자산을 기준으로 한다. +- NXT 종목은 원본과 같이 `1열판기본_현재가`와 `N5001`만 허용한다. +- 5일선·20일선은 컷 추가 시점의 사본이 아니라 현재 전역 체크 상태를 모든 캔들 항목에 동기화하고 PREPARE 직전에 다시 고정한다. +- 저장된 typed 종목 항목은 builder, alias, selection, 종목, 이동평균선이 모두 재생성 결과와 같을 때만 복원한다. 실패한 항목은 일반 catalog 경로로 우회하지 못한다. +- 플레이리스트 첫 열은 원본 의미와 같은 `송출` 여부다. 행 클릭은 삭제·이동 선택이며 Ctrl+클릭으로 복수 선택한다. +- 현재 장면 행은 네이티브가 보낸 실제 page index/page count를 표시한다. +- F8은 IDLE에서 PREPARE 성공을 받은 뒤 TAKE IN을 정확히 한 번 요청한다. Live/Test TAKE IN 승인이 잠겨 있으면 PREPARE도 자동 실행하지 않는다. +- 원본 F2는 trusted scene-root 안의 배경 파일 선택을 열고, F3은 배경 없음 상태를 토글한다. PREPARE 단축키로 재사용하지 않는다. + +## 현재 사용법 + +1. 대시보드, 코스피 또는 코스닥 메뉴에서 왼쪽 `종목 검색`에 종목명을 입력한다. +2. 결과에서 정확한 종목과 시장을 선택한다. +3. `종목용 컷`의 `+`를 누르거나 행을 더블클릭한다. +4. 플레이리스트 첫 열의 `송출` 체크와 순서를 확인한다. +5. DryRun에서 F8 또는 TAKE IN 버튼으로 PREPARE→TAKE IN을 검증한다. +6. 실제 Test/Live에서는 회차별 안전 게이트가 TAKE IN을 허용한 경우에만 같은 버튼이 활성화된다. +7. 종목 입력 영역의 `접기`를 누르면 전체 장면 catalog와 시장 데이터 영역을 사용할 수 있다. + +## 실제 검증 결과 + +2026-07-11 로컬 운영 DB에서 다음을 확인했다. + +- Oracle 21c 및 MariaDB health 정상 +- `삼성` 검색 63건: KOSPI 42, KOSDAQ 9, NXT KOSPI 12 +- `삼성전자`/`KR7005930003` 선택 +- `1열판기본_현재가` → builder `s5001`, alias `5001`, KOSPI/CURRENT selection 생성 +- 패키지 컨텍스트 DryRun의 PREPARE → TAKE IN, mutation preview 32개, TAKE OUT, 최종 편집 잠금 해제 +- COM 및 `KTAPConnect` 호출 없음 + +## 전체 운영 화면 구현 범위 + +35개 scene builder와 별개였던 원본 운영자 선택 표면을 모두 닫힌 workflow와 전용 화면으로 연결했다. 정확한 실행 action 분해는 다음과 같다. + +| 범위 | 수량 | 상태 | +|---|---:|---| +| 해외·환율·지수 등 고정 action | 328 | 화면 통합 완료. 이 중 수동 placeholder 6개는 FSell/VIList 화면으로만 연다. | +| 코스피·코스닥 업종 action | 44 | 화면 통합 완료 | +| 종목 action | 31 | 화면 통합 완료 | +| 비교 action | 9 | 화면 통합 완료 | +| 합계 | **412** | 명령 생성·strict restore 자동 매트릭스 완료 | +| UC3 고정 비교 선택지 | 24 | 비교 화면의 typed target으로 통합 완료 | + +추가 전용 화면도 연결됐다. + +- GraphE: 주요매출 구성, 성장성 지표, 매출액, 영업이익의 조회·편집·삭제·playlist 추가 +- FSell/VIList: trusted local store의 저장·재조회·버전 검증 뒤 `s5025`/`s5074` 추가 +- ThemeA/EList: 테마·전문가와 구성 종목의 생성·교체·삭제 +- PList/AList: Oracle `DC_LIST`/`PLAY_LIST` 이름 있는 저장·불러오기·삭제 +- F2/F3: trusted 배경 선택과 배경 없음 토글 + +수동 입력 DB 쓰기와 이름 있는 플레이리스트 저장은 read-only query executor와 분리된 매개변수 repository/transaction 경계를 사용한다. 송출이 IDLE임을 네이티브 engine과 workflow 양쪽에서 확인할 수 없거나, browser 상관관계·commit 결과가 불명확하면 모든 operator write를 프로세스 수명 동안 차단하고 자동 재시도하지 않는다. + +## 완료 판정 + +412개 실행 명령, UC3 비교 선택지, 수동 입력, 배경, 이름 있는 저장·불러오기까지 Web 화면 연결과 자동 계약 검증은 완료됐다. 다만 실제 운영 DB write와 각 경로의 Tornado2 PGM 확인은 UI 구현 완료와 별도다. 승인된 테스트 데이터·롤백 계획·회차별 PGM 승인이 없는 실환경 mutation이나 TAKE IN은 완료 증거를 만들기 위해 임의로 실행하지 않는다. diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md index 4997cc7..34e11ac 100644 --- a/docs/PLAYOUT_OPERATIONS.md +++ b/docs/PLAYOUT_OPERATIONS.md @@ -7,9 +7,9 @@ | 항목 | 상태 | |---|---| | 기본 모드 | `DryRun`; COM 객체와 `KTAPConnect`를 만들지 않음 | -| 자동 테스트 | 최종 전체 재검증에서 Core Debug/Release x64 각각 790/790, Playout 각각 374/374, Infrastructure 각각 65/65(합계 각 1,229/1,229), Web safety 12/12 통과; 이후 테스트 추가 시 개수보다 실패 0건을 기준으로 재확인 | -| Visual Studio 2026 | Debug/Release x64 빌드 성공 | -| trusted Release x64 MSIX | 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 5001 timer refresh, 5074 playlist NEXT와 page 1→2 Page NEXT, TAKE OUT 정리를 확인; 나머지 PageN/마지막 경계는 자동 테스트로 검증 | +| 자동 테스트 | 최종 전체 재검증에서 Core Debug/Release x64 각각 1,116/1,116, Playout 각각 374/374, Infrastructure 각각 125/125(합계 각 1,615/1,615), Web 235/235 통과; 실패·skip·경고 0건 | +| Visual Studio 2026 | MSBuild `18.7.8.30822` Debug x64와 signed Release x64 패키지 빌드 성공 | +| trusted Release x64 MSIX | `1.0.2.0` 생성·서명 검증·x64 설치·package context 실행 성공. SHA-256 `4A4ED867D16B75C1C82CB55DA5111E0502573F486B8B897889069843D9DF5072`; source Web 24개 일치, 금지 자산 0건, 기본 DryRun과 전체 UI·실제 DB 검색·오류 이벤트 0건 확인. Round H의 실제 DB DryRun/Live workflow 증거는 아래에 별도 보존 | | 실제 데이터 전체 scene smoke | 34개 도달 가능 builder 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic 포함 Oracle/MariaDB query 55건 통과 | | `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight | | `s5006` `Video\큐브배경.vrv` | 현재 승인 Cuts root에서 누락 | @@ -263,6 +263,8 @@ vendor monitor의 실제 명령 표기는 버전에 따라 다를 수 있으므 `OutcomeUnknown`은 단순 실패 code가 아니라 실제 출력 결과를 모른다는 latch다. Cancelled, Rejected, `WEB_TIMEOUT` 또는 UI 오류로 낮추지 않는다. timeout quarantine은 native process-lifetime latch이므로 WebView reload나 오류 창으로 해제되지 않는다. refresh fault marker는 성공한 TAKE OUT 뒤 native reset에만 맞춰 제거할 수 있지만 unknown/quarantine latch에는 영향을 주지 않는다. process를 재시작해 표시를 지우는 행위도 실제 출력 복구가 아니다. +GraphE, ThemeA/EList, FSell/VIList 또는 이름 있는 플레이리스트 쓰기에서 commit·파일 교체 결과나 browser 응답 상관관계를 확인하지 못한 경우에도 동일한 원칙을 적용한다. 어느 한 subsystem에서 `OutcomeUnknown`이 발생하면 네이티브 global operator-mutation quarantine이 모든 operator write를 프로세스 수명 동안 차단한다. 다른 편집 화면으로 옮겨 쓰거나 같은 값을 다시 저장하지 않는다. 앱을 종료한 뒤 Oracle `INPUT_*`/`SB_*`/`EXPERT_*`/`DC_LIST`/`PLAY_LIST` 또는 trusted local file을 읽기 전용으로 대조하고, 실제 결과를 확정·복구한 다음 새 앱 프로세스와 새 명시적 작업으로 시작한다. 송출 engine/workflow가 없거나 IDLE을 양쪽에서 증명하지 못하는 상태, PREPARED/PROGRAM, callback pending, refresh, shutdown 중에도 모든 operator mutation은 fail closed다. + ## Rollback과 unload ### 명확히 출력 전 실패한 경우 diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md index 99e2117..d9b56f6 100644 --- a/docs/SCENE_EQUIVALENCE.md +++ b/docs/SCENE_EQUIVALENCE.md @@ -1,6 +1,6 @@ # 35개 Scene 동등성 완료 매트릭스 -기준 시각은 2026-07-11이다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 [`legacy-scene-source-hashes.json`](legacy-scene-source-hashes.json), 원본 구조 기준선 검사는 [`Test-LegacySceneBaseline.ps1`](../scripts/Test-LegacySceneBaseline.ps1)에 있다. +기준 시각은 2026-07-12이다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 [`legacy-scene-source-hashes.json`](legacy-scene-source-hashes.json), 원본 구조 기준선 검사는 [`Test-LegacySceneBaseline.ps1`](../scripts/Test-LegacySceneBaseline.ps1)에 있다. 이 문서에서 **구현 완료**는 DTO → mutation builder, 실제 데이터 loader, playlist selection resolver 또는 명시적 runtime route, 자동 테스트가 모두 존재한다는 뜻이다. **자동 동등성 완료**는 원본 35개 builder의 source hash/inventory 기준선을 고정하고, 원본 구현에서 도출한 오브젝트·mutation·호출 순서·45개 alias·PageN 기대값을 새 구현의 매트릭스와 자동 suite로 검증했다는 뜻이다. 실행 중인 구 구현과 새 구현을 같은 입력으로 직접 호출해 의미를 대조하는 comparator가 있다는 뜻은 아니다. **실제 Tornado 검증**은 승인된 scene과 회차에만 적용한다. 현재 마이그레이션 기준선은 완료됐지만 실제 PGM 증거를 승인되지 않은 나머지 scene으로 확대 해석하지 않는다. @@ -11,9 +11,9 @@ | 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 | | DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 | | loader와 runtime route | 완료 | MainForm 도달 가능 builder 34개, active alias 45개를 fail-closed route로 등록; `s8086`은 원본과 같이 무alias 진단 전용 | -| 자동 테스트 | 완료 | 소스 `fa0eae7b61f57f134dfd30ebf1e59e9cc2cccfc5`: Core 790/790, Playout 374/374, Infrastructure 65/65가 Debug/Release x64에서 각각 통과했고 Web safety 12/12, 원본 baseline 35/35도 통과 | -| Visual Studio 2026 빌드 | 완료 | MSBuild `18.7.8`로 Debug/Release x64 및 앱 패키지 빌드 성공 | -| Release x64 MSIX | 완료 | 서명·설치·내용 감사와 package-context 실행 통과. SHA-256 `D51E8CB637860D9F0377AEE6FF691D7D954BA0FF7E30B1AB65443DA153BC5429`; packaged `DryRun`에서 `5001 PREPARE → fresh TAKE IN → 자동 refresh 1회 → 5074 playlist NEXT → Page NEXT → TAKE OUT`을 확인하고 전체 PageN 경계는 자동 테스트로 확인 | +| 자동 테스트 | 완료 | Core 1,116/1,116, Playout 374/374, Infrastructure 125/125가 Debug/Release x64에서 각각 통과했다. 구성별 1,615개, 전체 3,230개이며 실패·skip·경고는 0건이다. Web 235/235와 원본 baseline 35/35도 통과 | +| Visual Studio 2026 빌드 | 완료 | MSBuild `18.7.8.30822`로 Debug x64와 signed Release x64 앱 패키지 빌드 성공 | +| Release x64 MSIX | 완료 | `1.0.2.0` 서명·설치·내용 감사와 package-context 실행 통과. SHA-256 `4A4ED867D16B75C1C82CB55DA5111E0502573F486B8B897889069843D9DF5072`; Web 24개가 source와 일치하고 금지 자산은 0건이며 기본 `DryRun`에서 전체 UI와 실제 DB 종목 검색을 확인. Round H의 승인된 실제 송출 증거는 아래 별도 행과 운영 문서에 보존 | | 실제 데이터 전체 장면 smoke | 완료 | 34개 도달 가능 builder 전체 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic도 통과했고 실제 Oracle/MariaDB query 55/55가 성공했다. | | 이번 마이그레이션의 실제 Tornado2/PGM | **승인 범위 완료** | 계획 SHA-256 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19`의 `MBNWEB-20260711-H`에서 `5001 → 자동 refresh 1회 → 5074 page 1 → page 2 → TAKE OUT`을 PGM과 Network Monitoring으로 함께 확인. 최종 검은 화면, retry 0, `OutcomeUnknown = false` | diff --git a/scripts/Test-WebPlayout.ps1 b/scripts/Test-WebPlayout.ps1 index 2564454..a41fae2 100644 --- a/scripts/Test-WebPlayout.ps1 +++ b/scripts/Test-WebPlayout.ps1 @@ -45,9 +45,33 @@ $node = Resolve-NodePath -ExplicitPath $NodePath $repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $scripts = @( (Join-Path $repositoryRoot 'Web\playout-safety.js'), + (Join-Path $repositoryRoot 'Web\operator-workflow.js'), + (Join-Path $repositoryRoot 'Web\legacy-fixed-workflow.js'), + (Join-Path $repositoryRoot 'Web\industry-workflow.js'), + (Join-Path $repositoryRoot 'Web\comparison-workflow.js'), + (Join-Path $repositoryRoot 'Web\theme-workflow.js'), + (Join-Path $repositoryRoot 'Web\overseas-workflow.js'), + (Join-Path $repositoryRoot 'Web\expert-workflow.js'), + (Join-Path $repositoryRoot 'Web\trading-halt-workflow.js'), + (Join-Path $repositoryRoot 'Web\candle-options-workflow.js'), + (Join-Path $repositoryRoot 'Web\manual-lists-workflow.js'), + (Join-Path $repositoryRoot 'Web\manual-financial-workflow.js'), + (Join-Path $repositoryRoot 'Web\named-manual-restore-workflow.js'), + (Join-Path $repositoryRoot 'Web\operator-catalog-workflow.js'), + (Join-Path $repositoryRoot 'Web\manual-lists-ui.js'), + (Join-Path $repositoryRoot 'Web\manual-financial-ui.js'), + (Join-Path $repositoryRoot 'Web\operator-catalog-ui.js'), + (Join-Path $repositoryRoot 'Web\named-playlist-workflow.js'), (Join-Path $repositoryRoot 'Web\app.js') ) -$test = Join-Path $repositoryRoot 'tests\Web\playout-safety.test.cjs' +$testFiles = @( + Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'tests\Web') -Filter '*.test.cjs' -File | + Sort-Object -Property Name | + Select-Object -ExpandProperty FullName +) +if ($testFiles.Count -eq 0) { + throw 'No Web playout test files were found.' +} foreach ($script in $scripts) { & $node --check $script @@ -57,7 +81,14 @@ foreach ($script in $scripts) { } $appScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\app.js') -Raw -Encoding UTF8 +$indexMarkup = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\index.html') -Raw -Encoding UTF8 +$namedPlaylistScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\named-playlist-workflow.js') -Raw -Encoding UTF8 $mainWindowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.xaml.cs') -Raw -Encoding UTF8 +$namedPlaylistBridgeScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.NamedPlaylists.cs') -Raw -Encoding UTF8 +$pagePlanBridgeScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.PlaylistPagePlans.cs') -Raw -Encoding UTF8 +$legacyWorkflowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.Core\Playout\Scenes\LegacyPlayoutWorkflow.cs') -Raw -Encoding UTF8 +$backgroundScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.Background.cs') -Raw -Encoding UTF8 +$playoutMainScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.Playout.cs') -Raw -Encoding UTF8 $sceneCatalogMatches = [regex]::Matches( $appScript, 'builderKey:\s*"s[0-9]+"') @@ -91,17 +122,186 @@ if ($appScript -notmatch 'normalizeScenePreviewFields' -or $appScript -notmatch 'renderSceneDataPreview') { throw 'Web playout must render bounded authoritative scene data and complete page state.' } -if ($appScript -notmatch 'row-enabled' -or - $appScript -notmatch 'item\.enabled = include\.checked' -or +if ($appScript -notmatch 'checkbox\.checked = item\.enabled !== false' -or + $appScript -notmatch 'item\.enabled = checkbox\.checked' -or $appScript -notmatch 'isPlaylistSnapshotLocked' -or $appScript -notmatch 'pending: Boolean\(state\.playout\.pending\)' -or $appScript -notmatch 'outcomeUnknown: state\.playout\.sessionQuarantined') { throw 'Web playlist must expose the legacy active flag and freeze its native snapshot.' } +if ($appScript -notmatch 'toggleAllEntriesEnabled' -or + $appScript -notmatch 'deleteAllPlaylistEntries' -or + $appScript -notmatch 'event\?\.shiftKey' -or + $appScript -notmatch 'event\.key === "Home"' -or + $appScript -notmatch 'event\.key === "End"') { + throw 'Legacy playlist enabled-all, delete-all, range, Home and End controls are incomplete.' +} if ($appScript -notmatch 'postNative\("playout-timeout-quarantine", \{ requestId, command \}\)' -or $appScript -notmatch 'browserCorrelationQuarantined') { throw 'Web response timeouts must latch the process-lifetime native quarantine.' } +if ($appScript -match 'event\.key === "F2"\) requestPlayoutCommand' -or + $appScript -notmatch 'notifyLegacyBackgroundShortcut' -or + $appScript -notmatch 'legacyTakeInShortcutAction' -or + $appScript -notmatch 'synchronizeStockCandleOptions' -or + $appScript -notmatch 'operatorInputBuilderKeys\.has\(definition\.builderKey\)') { + throw 'Legacy F2, F8, moving-average and typed-playlist safety integration is incomplete.' +} +if ($indexMarkup -notmatch 'id="globalMa5"' -or + $indexMarkup -notmatch 'id="globalMa20"' -or + $indexMarkup -notmatch '' -or + $appScript -notmatch 'MbnCandleOptionsWorkflow' -or + $appScript -notmatch 'synchronizeGlobalCandleOptions' -or + $appScript -notmatch 'legacy-overseas-workflow' -or + $appScript -notmatch 'legacy-trading-halt-workflow') { + throw 'The original global 5-day/20-day candle options are not synchronized across every candle workflow.' +} +foreach ($asset in @( + 'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'operator-catalog-workflow.js', + 'manual-lists-ui.js', 'manual-financial-ui.js', 'operator-catalog-ui.js')) { + if ($indexMarkup -notmatch [regex]::Escape("")) { + throw "Operator UI script is not loaded: $asset" + } +} +foreach ($asset in @('manual-lists-ui.css', 'manual-financial-ui.css', 'operator-catalog-ui.css')) { + if ($indexMarkup -notmatch [regex]::Escape("") ) { + throw "Operator UI stylesheet is not loaded: $asset" + } +} +if ($indexMarkup -notmatch 'id="themeCatalogManageButton"' -or + $indexMarkup -notmatch 'id="expertCatalogManageButton"' -or + $appScript -notmatch 'MbnManualListsUi' -or + $appScript -notmatch 'MbnManualFinancialUi' -or + $appScript -notmatch 'MbnOperatorCatalogUi' -or + $appScript -notmatch 'initializeOperatorUiControllers' -or + $appScript -notmatch 'appendTrustedOperatorEntry' -or + $appScript -notmatch 'manualFinancialPrepareBlocked' -or + $appScript -notmatch 'createRestoreSelectionRequest' -or + $appScript -notmatch 'operatorCatalogUi\?\.loseCorrelation') { + throw 'GraphE, FSell, VIList, ThemeA or EList is not fully connected to the Web operator surface.' +} +if ($appScript -notmatch 'requestBackgroundSelection' -or + $appScript -notmatch 'event\.key === "F3"' -or + $appScript -notmatch 'postNative\("request-background-status"' -or + $mainWindowScript -notmatch 'case "choose-background"' -or + $mainWindowScript -notmatch 'case "toggle-background"' -or + $playoutMainScript -notmatch 'MutableLegacySceneCueCompositionOptionsSource' -or + $backgroundScript -notmatch 'OperatorBackgroundExtensions') { + throw 'Legacy F2/F3 native background selection integration is incomplete.' +} +if ($appScript -notmatch 'restoreFixedPlaylistEntry' -or + $appScript -notmatch 'synchronizeFixedEntries' -or + $appScript -notmatch 'renderFixedWorkflow' -or + $appScript -notmatch 'legacy-fixed-workflow') { + throw 'Legacy fixed operator catalog integration is incomplete.' +} +if ($appScript -notmatch 'restoreIndustryPlaylistEntry' -or + $appScript -notmatch 'synchronizeIndustryEntries' -or + $appScript -notmatch 'renderIndustryWorkflow' -or + $appScript -notmatch 'postNative\("request-industries", \{ requestId, market \}\)' -or + $mainWindowScript -notmatch 'case "request-industries"' -or + $mainWindowScript -notmatch 'PostMessage\("industry-results"') { + throw 'Legacy KOSPI/KOSDAQ industry selector integration is incomplete.' +} +if ($appScript -notmatch 'restoreComparisonPlaylistEntry' -or + $appScript -notmatch 'synchronizeComparisonEntries' -or + $appScript -notmatch 'renderComparisonWorkflow' -or + $appScript -notmatch 'postNative\("search-world-stocks"' -or + $mainWindowScript -notmatch 'case "search-world-stocks"' -or + $mainWindowScript -notmatch 'PostMessage\("world-stock-search-results"') { + throw 'Legacy comparison selector and world-stock search integration is incomplete.' +} +if ($appScript -notmatch 'restoreThemePlaylistEntry' -or + $appScript -notmatch 'synchronizeThemeEntries' -or + $appScript -notmatch 'renderThemeWorkflow' -or + $appScript -notmatch 'postNative\("search-themes"' -or + $appScript -notmatch 'postNative\("request-theme-preview"' -or + $mainWindowScript -notmatch 'case "search-themes"' -or + $mainWindowScript -notmatch 'case "request-theme-preview"') { + throw 'Legacy read-only theme selector integration is incomplete.' +} +if ($appScript -notmatch 'restoreOverseasPlaylistEntry' -or + $appScript -notmatch 'synchronizeOverseasEntries' -or + $appScript -notmatch 'renderOverseasWorkflow' -or + $appScript -notmatch 'postNative\(overseasWorkflow\.bridgeContracts\[kind\]\.requestType, request\)' -or + $mainWindowScript -notmatch 'case "search-overseas-industries"' -or + $mainWindowScript -notmatch 'PostMessage\("overseas-industry-results"') { + throw 'Legacy overseas-industry and overseas-stock selector integration is incomplete.' +} +if ($appScript -notmatch 'restoreExpertPlaylistEntry' -or + $appScript -notmatch 'synchronizeExpertEntries' -or + $appScript -notmatch 'renderExpertWorkflow' -or + $appScript -notmatch 'postNative\(expertWorkflow\.bridgeContract\.previewRequestType, request\)' -or + $mainWindowScript -notmatch 'case "search-experts"' -or + $mainWindowScript -notmatch 'case "request-expert-preview"') { + throw 'Legacy read-only expert selector integration is incomplete.' +} +if ($appScript -notmatch 'restoreTradingHaltPlaylistEntry' -or + $appScript -notmatch 'synchronizeTradingHaltEntries' -or + $appScript -notmatch 'renderTradingHaltWorkflow' -or + $appScript -notmatch 'postNative\(tradingHaltWorkflow\.bridgeContract\.requestType, request\)' -or + $mainWindowScript -notmatch 'case "search-trading-halts"' -or + $mainWindowScript -notmatch 'PostMessage\("trading-halt-results"') { + throw 'Legacy trading-halt selector integration is incomplete.' +} +if ($indexMarkup -notmatch 'id="namedPlaylistPanel"' -or + $indexMarkup -notmatch 'id="namedPlaylistDefinitions"' -or + $indexMarkup -notmatch 'id="namedPlaylistCreateButton"' -or + $indexMarkup -notmatch 'id="namedPlaylistReplaceButton"' -or + $indexMarkup -notmatch 'id="namedPlaylistDeleteButton"' -or + $indexMarkup -notmatch 'id="namedPlaylistPageRetryButton"' -or + $indexMarkup -notmatch '' -or + $indexMarkup -notmatch 'id="loadButton"' -or + $indexMarkup -notmatch 'id="saveButton"' -or + $indexMarkup -notmatch 'id="namedPlaylistOpenButton"') { + throw 'Named Oracle playlist controls must remain explicit and separate from local temporary storage.' +} +if ($appScript -notmatch 'MbnNamedPlaylistWorkflow' -or + $appScript -notmatch 'requestNamedPlaylistList' -or + $appScript -notmatch 'requestNamedPlaylistLoad' -or + $appScript -notmatch 'requestNamedPlaylistCreate' -or + $appScript -notmatch 'requestNamedPlaylistReplace' -or + $appScript -notmatch 'requestNamedPlaylistDelete' -or + $appScript -notmatch 'namedPlaylistPrepareBlockers' -or + $appScript -notmatch 'restoreRawRows\(load, resolveNamedPlaylistRawRow\)' -or + $appScript -notmatch 'requestNamedPlaylistPagePlans' -or + $appScript -notmatch 'normalizePagePlanResponse\(payload, pending\.request\)' -or + $appScript -notmatch 'page-result-empty' -or + $appScript -notmatch 'namedPlaylistBackupStorageKey' -or + $appScript -notmatch 'writeQuarantined') { + throw 'Named Oracle playlist UI, trusted restoration, backup, PREPARE guard, or write quarantine is incomplete.' +} +if ($namedPlaylistScript -notmatch 'request-playlist-page-plans' -or + $namedPlaylistScript -notmatch 'playlist-page-plans-results' -or + $namedPlaylistScript -notmatch 'playlist-page-plans-error' -or + $mainWindowScript -notmatch 'case "request-playlist-page-plans"' -or + $pagePlanBridgeScript -notmatch 'PreflightPagePlansAsync' -or + $pagePlanBridgeScript -notmatch 'await _databaseActivityGate\.WaitAsync\(requestToken\)' -or + $pagePlanBridgeScript -notmatch 'Volatile\.Read\(ref _playoutCommandInFlight\) != 0' -or + $legacyWorkflowScript -notmatch 'PreflightPagePlansAsync') { + throw 'Read-only named-playlist page preflight, exact Web correlation, or playout-priority gating is incomplete.' +} +foreach ($requestType in @( + 'request-named-playlist-list', + 'request-named-playlist-load', + 'create-named-playlist', + 'replace-named-playlist', + 'delete-named-playlist')) { + if ($namedPlaylistScript -notmatch [regex]::Escape($requestType) -or + $mainWindowScript -notmatch [regex]::Escape($requestType)) { + throw "Named playlist request contract is incomplete: $requestType" + } +} +foreach ($responseType in @( + 'named-playlist-list-results', + 'named-playlist-load-results', + 'named-playlist-mutation-results', + 'named-playlist-error')) { + if ($appScript -notmatch [regex]::Escape($responseType) -or + $namedPlaylistBridgeScript -notmatch [regex]::Escape($responseType)) { + throw "Named playlist response contract is incomplete: $responseType" + } +} if ($mainWindowScript -notmatch 'OnNavigationStarting' -or $mainWindowScript -notmatch 'OnProcessFailed' -or $mainWindowScript -notmatch 'ReloadBrowserSafely' -or @@ -110,7 +310,7 @@ if ($mainWindowScript -notmatch 'OnNavigationStarting' -or throw 'Reload, navigation, and WebView process failure must quarantine in-flight playout correlation.' } -& $node --test $test +& $node --test @testFiles if ($LASTEXITCODE -ne 0) { throw "Web playout tests failed with exit code $LASTEXITCODE." } @@ -120,5 +320,5 @@ if ($LASTEXITCODE -ne 0) { SyntaxFiles = $scripts.Count SceneCatalogEntries = $sceneCatalogMatches.Count ReachableSelectionPresets = $selectionPresetMatches.Count - TestFile = [IO.Path]::GetFileName($test) + TestFiles = $testFiles.Count } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs new file mode 100644 index 0000000..eec9a7d --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs @@ -0,0 +1,480 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace MMoneyCoderSharp.Data; + +public sealed record ExpertCatalogRecommendation( + int PlayIndex, + string StockCode, + string StockName, + decimal BuyAmount); + +public sealed record ExpertCatalogDefinition( + ExpertSelectionIdentity Identity, + IReadOnlyList Recommendations); + +public interface IExpertCatalogPersistenceService +{ + bool CanMutate { get; } + + Task SuggestNextCodeAsync(CancellationToken cancellationToken = default); + + Task CreateAsync( + ExpertCatalogDefinition definition, + CancellationToken cancellationToken = default); + + Task ReplaceAsync( + ExpertSelectionIdentity expectedIdentity, + string newName, + IReadOnlyList recommendations, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + ExpertSelectionIdentity expectedIdentity, + CancellationToken cancellationToken = default); +} + +/// +/// Atomic migration of EList/UC6 expert CRUD on Oracle. Code and name are a +/// closed optimistic identity, while duplicate names and stale edits are checked +/// inside the same serializable transaction that replaces recommendation rows. +/// +public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatalogPersistenceService +{ + public const int MaximumRecommendations = 100; + + internal const string NextCodeQueryName = "EXPERT_NEXT_CODE"; + + private const int MaximumExpertNameLength = 128; + private const int MaximumStockNameLength = 200; + private const decimal MaximumWebSafeInteger = 9_007_199_254_740_991m; + + private const string NextCodeSql = """ + SELECT CASE + WHEN NVL(MAX(TO_NUMBER(TRIM(EP_CODE))), 0) < 9999 + THEN LPAD( + TO_CHAR(NVL(MAX(TO_NUMBER(TRIM(EP_CODE))), 0) + 1), + 4, + '0') + ELSE 'EXHAUSTED' + END EXPERT_CODE + FROM EXPERT_LIST + """; + + private const string InsertExpertSql = """ + INSERT INTO EXPERT_LIST(EP_CODE, EP_NAME) + SELECT :expert_code, :expert_name + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM EXPERT_LIST WHERE EP_CODE = :conflict_code) + AND NOT EXISTS ( + SELECT 1 FROM EXPERT_LIST WHERE EP_NAME = :conflict_name) + """; + + private const string UpdateExpertSql = """ + UPDATE EXPERT_LIST target + SET EP_NAME = :new_name + WHERE target.EP_CODE = :expert_code + AND target.EP_NAME = :expected_name + AND NOT EXISTS ( + SELECT 1 + FROM EXPERT_LIST other_row + WHERE other_row.EP_NAME = :duplicate_name + AND other_row.EP_CODE <> :other_code) + """; + + private const string DeleteRecommendationsSql = + "DELETE FROM RECOMMEND_LIST WHERE RC_CODE = :expert_code"; + + private const string InsertRecommendationSql = """ + INSERT INTO RECOMMEND_LIST( + RC_CODE, + RC_STOCK_CODE, + RC_STOCK_NAME, + RC_BUY_AMOUNT, + PLAYINDEX) + VALUES ( + :expert_code, + :stock_code, + :stock_name, + :buy_amount, + :play_index) + """; + + private const string DeleteExpertSql = """ + DELETE FROM EXPERT_LIST + WHERE EP_CODE = :expert_code + AND EP_NAME = :expected_name + """; + + private readonly IDataQueryExecutor _queryExecutor; + private readonly IOperatorCatalogMutationExecutor? _mutationExecutor; + + public LegacyExpertCatalogPersistenceService(IDataQueryExecutor queryExecutor) + : this(queryExecutor, null) + { + } + + public LegacyExpertCatalogPersistenceService( + IDataQueryExecutor queryExecutor, + IOperatorCatalogMutationExecutor? mutationExecutor) + { + _queryExecutor = queryExecutor ?? throw new ArgumentNullException(nameof(queryExecutor)); + _mutationExecutor = mutationExecutor; + } + + public bool CanMutate => _mutationExecutor is not null; + + public async Task SuggestNextCodeAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var query = new DataQuerySpec(NextCodeSql); + query.ValidateFor(DataSourceKind.Oracle); + var table = await _queryExecutor.ExecuteAsync( + DataSourceKind.Oracle, + NextCodeQueryName, + query, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + if (table is null || + table.Columns.Count != 1 || + !string.Equals(table.Columns[0].ColumnName, "EXPERT_CODE", StringComparison.Ordinal) || + table.Columns[0].DataType != typeof(string) || + table.Rows.Count != 1 || + table.Rows[0][0] is not string code || + !ExpertCodePattern().IsMatch(code)) + { + throw new ExpertSelectionDataException( + "Expert next-code data has an invalid schema, value, or exhausted code range."); + } + + return code; + } + + public Task CreateAsync( + ExpertCatalogDefinition definition, + CancellationToken cancellationToken = default) + { + var canonical = ValidateDefinition(definition, nameof(definition)); + var commands = new List( + canonical.Recommendations.Count + 1) + { + Command( + OperatorCatalogDbCommandKind.InsertExpert, + InsertExpertSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("expert_code", canonical.Identity.ExpertCode), + String("expert_name", canonical.Identity.ExpertName), + String("conflict_code", canonical.Identity.ExpertCode), + String("conflict_name", canonical.Identity.ExpertName)) + }; + commands.AddRange(canonical.Recommendations.Select(recommendation => + CreateRecommendationCommand(canonical.Identity.ExpertCode, recommendation))); + + return ExecuteAsync( + new OperatorCatalogDbTransaction( + Guid.NewGuid(), + OperatorCatalogMutationKind.CreateExpert, + DataSourceKind.Oracle, + canonical.Identity.ExpertCode, + commands), + cancellationToken); + } + + public Task ReplaceAsync( + ExpertSelectionIdentity expectedIdentity, + string newName, + IReadOnlyList recommendations, + CancellationToken cancellationToken = default) + { + var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity)); + var canonicalName = ValidateCanonicalText( + newName, + MaximumExpertNameLength, + nameof(newName)); + var canonicalRecommendations = ValidateRecommendations( + recommendations, + nameof(recommendations)); + var commands = new List( + canonicalRecommendations.Count + 2) + { + Command( + OperatorCatalogDbCommandKind.UpdateExpert, + UpdateExpertSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("new_name", canonicalName), + String("expert_code", expected.ExpertCode), + String("expected_name", expected.ExpertName), + String("duplicate_name", canonicalName), + String("other_code", expected.ExpertCode)), + Command( + OperatorCatalogDbCommandKind.DeleteRecommendations, + DeleteRecommendationsSql, + OperatorCatalogAffectedRowsRule.AnyNonNegative, + String("expert_code", expected.ExpertCode)) + }; + commands.AddRange(canonicalRecommendations.Select(recommendation => + CreateRecommendationCommand(expected.ExpertCode, recommendation))); + + return ExecuteAsync( + new OperatorCatalogDbTransaction( + Guid.NewGuid(), + OperatorCatalogMutationKind.ReplaceExpert, + DataSourceKind.Oracle, + expected.ExpertCode, + commands), + cancellationToken); + } + + public Task DeleteAsync( + ExpertSelectionIdentity expectedIdentity, + CancellationToken cancellationToken = default) + { + var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity)); + return ExecuteAsync( + new OperatorCatalogDbTransaction( + Guid.NewGuid(), + OperatorCatalogMutationKind.DeleteExpert, + DataSourceKind.Oracle, + expected.ExpertCode, + [ + Command( + OperatorCatalogDbCommandKind.DeleteRecommendations, + DeleteRecommendationsSql, + OperatorCatalogAffectedRowsRule.AnyNonNegative, + String("expert_code", expected.ExpertCode)), + Command( + OperatorCatalogDbCommandKind.DeleteExpert, + DeleteExpertSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("expert_code", expected.ExpertCode), + String("expected_name", expected.ExpertName)) + ]), + cancellationToken); + } + + private async Task ExecuteAsync( + OperatorCatalogDbTransaction transaction, + CancellationToken cancellationToken) + { + var executor = _mutationExecutor ?? throw new InvalidOperationException( + "Expert catalog persistence is configured for read-only access."); + cancellationToken.ThrowIfCancellationRequested(); + + OperatorCatalogDbTransactionResult result; + try + { + result = await executor.ExecuteTransactionAsync(transaction, cancellationToken) + .ConfigureAwait(false); + } + catch (OperatorCatalogMutationException) + { + throw; + } + catch (Exception exception) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction outcome is unknown; do not retry automatically.", + outcomeUnknown: true, + exception); + } + + if (result is null || + result.OperationId != transaction.OperationId || + result.AffectedRows is null || + result.AffectedRows.Count != transaction.Commands.Count || + result.AffectedRows.Any(static count => count < 0)) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction returned an ambiguous commit receipt; do not retry automatically.", + outcomeUnknown: true); + } + + for (var index = 0; index < transaction.Commands.Count; index++) + { + if (transaction.Commands[index].AffectedRowsRule == + OperatorCatalogAffectedRowsRule.ExactlyOne && + result.AffectedRows[index] != 1) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction returned an ambiguous affected-row receipt; do not retry automatically.", + outcomeUnknown: true); + } + } + + return new OperatorCatalogMutationReceipt( + result.OperationId, + transaction.Kind, + result.CommittedAt); + } + + private static OperatorCatalogDbCommand CreateRecommendationCommand( + string expertCode, + ExpertCatalogRecommendation recommendation) => + Command( + OperatorCatalogDbCommandKind.InsertRecommendation, + InsertRecommendationSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("expert_code", expertCode), + String("stock_code", recommendation.StockCode), + String("stock_name", recommendation.StockName), + new OperatorCatalogDbParameter( + "buy_amount", + recommendation.BuyAmount, + DbType.Decimal), + new OperatorCatalogDbParameter( + "play_index", + recommendation.PlayIndex, + DbType.Int32)); + + private static ExpertCatalogDefinition ValidateDefinition( + ExpertCatalogDefinition definition, + string parameterName) + { + ArgumentNullException.ThrowIfNull(definition); + return new ExpertCatalogDefinition( + ValidateIdentity(definition.Identity, parameterName), + ValidateRecommendations(definition.Recommendations, parameterName)); + } + + private static ExpertSelectionIdentity ValidateIdentity( + ExpertSelectionIdentity identity, + string parameterName) + { + ArgumentNullException.ThrowIfNull(identity); + var code = ValidateCanonicalText(identity.ExpertCode, 4, parameterName); + if (!ExpertCodePattern().IsMatch(code)) + { + throw new ArgumentException( + "An expert code must contain four digits.", + parameterName); + } + + var name = ValidateCanonicalText( + identity.ExpertName, + MaximumExpertNameLength, + parameterName); + return new ExpertSelectionIdentity(code, name); + } + + private static IReadOnlyList ValidateRecommendations( + IReadOnlyList recommendations, + string parameterName) + { + ArgumentNullException.ThrowIfNull(recommendations); + if (recommendations.Count > MaximumRecommendations) + { + throw new ArgumentOutOfRangeException( + parameterName, + $"An expert can contain at most {MaximumRecommendations} recommendations."); + } + + var stockCodes = new HashSet(StringComparer.Ordinal); + var stockNames = new HashSet(StringComparer.Ordinal); + var canonical = new ExpertCatalogRecommendation[recommendations.Count]; + for (var index = 0; index < recommendations.Count; index++) + { + var recommendation = recommendations[index] ?? throw new ArgumentException( + "An expert recommendation is missing.", + parameterName); + if (recommendation.PlayIndex != index) + { + throw new ArgumentException( + "Recommendation indexes must be contiguous and zero based.", + parameterName); + } + + var stockCode = ValidateCanonicalText( + recommendation.StockCode, + 32, + parameterName); + if (!StockCodePattern().IsMatch(stockCode)) + { + throw new ArgumentException("A recommendation stock code is invalid.", parameterName); + } + + var stockName = ValidateCanonicalText( + recommendation.StockName, + MaximumStockNameLength, + parameterName); + if (!stockCodes.Add(stockCode) || !stockNames.Add(stockName)) + { + throw new ArgumentException( + "Recommendation stock codes and names must each be unique.", + parameterName); + } + + var buyAmount = recommendation.BuyAmount; + if (buyAmount <= 0 || buyAmount > MaximumWebSafeInteger || + decimal.Truncate(buyAmount) != buyAmount) + { + throw new ArgumentOutOfRangeException( + parameterName, + "A recommendation buy amount must be a positive whole web-safe integer."); + } + + canonical[index] = new ExpertCatalogRecommendation( + index, + stockCode, + stockName, + buyAmount); + } + + return Array.AsReadOnly(canonical); + } + + private static string ValidateCanonicalText( + string value, + int maximumLength, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value)) + { + throw new ArgumentException("An expert catalog value is invalid.", parameterName); + } + + return value; + } + + private static bool IsSafeText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static OperatorCatalogDbParameter String(string name, string value) => + new(name, value, DbType.String); + + private static OperatorCatalogDbCommand Command( + OperatorCatalogDbCommandKind kind, + string sql, + OperatorCatalogAffectedRowsRule rule, + params OperatorCatalogDbParameter[] parameters) => + new(kind, sql, rule, parameters); + + [GeneratedRegex("^[0-9]{4}$", RegexOptions.CultureInvariant)] + private static partial Regex ExpertCodePattern(); + + [GeneratedRegex("^[A-Za-z0-9]{1,32}$", RegexOptions.CultureInvariant)] + private static partial Regex StockCodePattern(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs new file mode 100644 index 0000000..17c6b1e --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs @@ -0,0 +1,540 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace MMoneyCoderSharp.Data; + +public sealed record ExpertSelectionIdentity( + string ExpertCode, + string ExpertName); + +public sealed record ExpertSelectorItem( + ExpertSelectionIdentity Identity, + DataSourceKind Source); + +public sealed record ExpertSearchResult( + string Query, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public sealed record ExpertRecommendationPreview( + int PlayIndex, + string StockCode, + string StockName, + decimal BuyAmount); + +public sealed record ExpertPreviewResult( + ExpertSelectionIdentity Identity, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public interface IExpertSelectionService +{ + Task SearchAsync( + string query = "", + int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults, + CancellationToken cancellationToken = default); + + Task PreviewAsync( + ExpertSelectionIdentity identity, + int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems, + CancellationToken cancellationToken = default); +} + +public sealed class ExpertSelectionDataException : Exception +{ + public ExpertSelectionDataException(string message) + : base(message) + { + } +} + +/// +/// Read-only migration of UC6's Oracle expert list and recommendation preview. +/// Both expert code and name remain bound through preview and playout selection; +/// no CRUD statement or free-form table selection is exposed. +/// +public sealed partial class LegacyExpertSelectionService : IExpertSelectionService +{ + public const int DefaultMaximumResults = 100; + public const int MaximumResults = 500; + public const int MaximumQueryLength = 64; + public const int DefaultMaximumPreviewItems = 100; + public const int MaximumPreviewItems = 100; + + private const int MaximumExpertNameLength = 128; + private const int MaximumStockNameLength = 200; + private const int MaximumPlayIndex = 9_999; + private const decimal MaximumWebSafeInteger = 9_007_199_254_740_991m; + private const string SearchQueryName = "EXPERT_SEARCH"; + private const string PreviewQueryName = "EXPERT_PREVIEW"; + + private static readonly string[] SearchColumns = + ["EXPERT_NAME", "EXPERT_CODE"]; + + private static readonly string[] PreviewColumns = + [ + "EXPERT_NAME", + "EXPERT_CODE", + "STOCK_CODE", + "STOCK_NAME", + "BUY_AMOUNT", + "PLAY_INDEX" + ]; + + private const string SearchSql = """ + SELECT EXPERT_NAME, EXPERT_CODE + FROM ( + SELECT EP_NAME EXPERT_NAME, + EP_CODE EXPERT_CODE + FROM EXPERT_LIST + WHERE UPPER(EP_NAME) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(EP_NAME), EP_CODE + ) + WHERE ROWNUM <= :row_limit + """; + + private const string PreviewSql = """ + SELECT EXPERT_NAME, EXPERT_CODE, + STOCK_CODE, STOCK_NAME, BUY_AMOUNT, PLAY_INDEX + FROM ( + SELECT e.EP_NAME EXPERT_NAME, + e.EP_CODE EXPERT_CODE, + r.RC_STOCK_CODE STOCK_CODE, + r.RC_STOCK_NAME STOCK_NAME, + TO_CHAR( + r.RC_BUY_AMOUNT, + 'TM9', + 'NLS_NUMERIC_CHARACTERS=''.,''') BUY_AMOUNT, + TO_CHAR(r.PLAYINDEX, 'FM999999990') PLAY_INDEX + FROM EXPERT_LIST e + LEFT JOIN RECOMMEND_LIST r ON e.EP_CODE = r.RC_CODE + WHERE e.EP_CODE = :expert_code + AND e.EP_NAME = :expert_name + ORDER BY r.PLAYINDEX + ) + WHERE ROWNUM <= :row_limit + """; + + private readonly IDataQueryExecutor _executor; + + public LegacyExpertSelectionService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task SearchAsync( + string query = "", + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = ValidateAndNormalizeQuery(query); + ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults)); + cancellationToken.ThrowIfCancellationRequested(); + + var rowLimit = checked(maximumResults + 1); + var spec = CreateSearchSpec( + EscapeLikePattern(normalizedQuery.ToUpperInvariant()), + rowLimit); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + SearchQueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var matches = MapSearchRows(table, rowLimit); + var ordered = matches + .OrderBy(static item => item.Identity.ExpertName, StringComparer.Ordinal) + .ThenBy(static item => item.Identity.ExpertCode, StringComparer.Ordinal) + .Take(maximumResults) + .ToArray(); + return new ExpertSearchResult( + normalizedQuery, + DateTimeOffset.Now, + ordered, + matches.Count > maximumResults); + } + + public async Task PreviewAsync( + ExpertSelectionIdentity identity, + int maximumItems = DefaultMaximumPreviewItems, + CancellationToken cancellationToken = default) + { + var canonicalIdentity = ValidateIdentity(identity); + ValidateLimit(maximumItems, MaximumPreviewItems, nameof(maximumItems)); + cancellationToken.ThrowIfCancellationRequested(); + + var rowLimit = checked(maximumItems + 1); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + PreviewQueryName, + CreatePreviewSpec(canonicalIdentity, rowLimit), + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var items = MapPreviewRows(table, canonicalIdentity, rowLimit); + return new ExpertPreviewResult( + canonicalIdentity, + DateTimeOffset.Now, + items.Take(maximumItems).ToArray(), + items.Count > maximumItems); + } + + private static IReadOnlyList MapSearchRows( + DataTable? table, + int rowLimit) + { + ValidateExactStringSchema(table, SearchColumns, rowLimit, SearchQueryName); + + var names = new HashSet(StringComparer.Ordinal); + var codes = new HashSet(StringComparer.Ordinal); + var results = new List(table!.Rows.Count); + foreach (DataRow row in table.Rows) + { + var name = ReadCanonicalString( + row, + 0, + MaximumExpertNameLength, + SearchQueryName); + var code = ReadExpertCode(row, 1, SearchQueryName); + + // The paged scene originally resolves EP_CODE from EP_NAME. Both + // columns must therefore be unique before a selectable row is exposed. + if (!names.Add(name) || !codes.Add(code)) + { + throw InvalidData(SearchQueryName, "duplicate expert name or code"); + } + + results.Add(new ExpertSelectorItem( + new ExpertSelectionIdentity(code, name), + DataSourceKind.Oracle)); + } + + return results; + } + + private static IReadOnlyList MapPreviewRows( + DataTable? table, + ExpertSelectionIdentity identity, + int rowLimit) + { + ValidateExactStringSchema(table, PreviewColumns, rowLimit, PreviewQueryName); + if (table!.Rows.Count < 1) + { + throw InvalidData(PreviewQueryName, "missing selected expert"); + } + + var stockCodes = new HashSet(StringComparer.Ordinal); + var stockNames = new HashSet(StringComparer.Ordinal); + var playIndexes = new HashSet(); + var items = new List(table.Rows.Count); + var sawEmptyExpert = false; + + foreach (DataRow row in table.Rows) + { + var expertName = ReadCanonicalString( + row, + 0, + MaximumExpertNameLength, + PreviewQueryName); + var expertCode = ReadExpertCode(row, 1, PreviewQueryName); + if (!string.Equals(expertName, identity.ExpertName, StringComparison.Ordinal) || + !string.Equals(expertCode, identity.ExpertCode, StringComparison.Ordinal)) + { + throw InvalidData(PreviewQueryName, "selected expert identity"); + } + + var stockCodeValue = row[2]; + var stockNameValue = row[3]; + var buyAmountValue = row[4]; + var playIndexValue = row[5]; + var isEmptyExpert = stockCodeValue is DBNull && + stockNameValue is DBNull && + buyAmountValue is DBNull && + playIndexValue is DBNull; + if (isEmptyExpert) + { + if (table.Rows.Count != 1) + { + throw InvalidData(PreviewQueryName, "empty recommendation sentinel"); + } + + sawEmptyExpert = true; + continue; + } + + if (stockCodeValue is DBNull || + stockNameValue is DBNull || + buyAmountValue is DBNull || + playIndexValue is DBNull) + { + throw InvalidData(PreviewQueryName, "partial recommendation identity"); + } + + var stockCode = ReadCanonicalString(row, 2, 32, PreviewQueryName); + if (!StockCodePattern().IsMatch(stockCode)) + { + throw InvalidData(PreviewQueryName, "stock code"); + } + + var stockName = ReadCanonicalString( + row, + 3, + MaximumStockNameLength, + PreviewQueryName); + var buyAmount = ReadPositiveDecimal(row, 4, PreviewQueryName); + var playIndex = ReadPlayIndex(row, 5, PreviewQueryName); + if (!stockCodes.Add(stockCode) || + !stockNames.Add(stockName) || + !playIndexes.Add(playIndex)) + { + throw InvalidData( + PreviewQueryName, + "duplicate stock code, stock name, or play index"); + } + + items.Add(new ExpertRecommendationPreview( + playIndex, + stockCode, + stockName, + buyAmount)); + } + + if (sawEmptyExpert) + { + return Array.Empty(); + } + + for (var index = 1; index < items.Count; index++) + { + if (items[index - 1].PlayIndex >= items[index].PlayIndex) + { + throw InvalidData(PreviewQueryName, "recommendation order"); + } + } + + return items; + } + + private static DataQuerySpec CreateSearchSpec(string escapedQuery, int rowLimit) + { + var spec = new DataQuerySpec( + SearchSql, + [ + new DataQueryParameter("search_term", escapedQuery, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(DataSourceKind.Oracle); + return spec; + } + + private static DataQuerySpec CreatePreviewSpec( + ExpertSelectionIdentity identity, + int rowLimit) + { + var spec = new DataQuerySpec( + PreviewSql, + [ + new DataQueryParameter("expert_code", identity.ExpertCode, DbType.String), + new DataQueryParameter("expert_name", identity.ExpertName, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(DataSourceKind.Oracle); + return spec; + } + + private static ExpertSelectionIdentity ValidateIdentity(ExpertSelectionIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + var code = ValidateCanonicalInput(identity.ExpertCode, 4, nameof(identity)); + var name = ValidateCanonicalInput( + identity.ExpertName, + MaximumExpertNameLength, + nameof(identity)); + if (!ExpertCodePattern().IsMatch(code)) + { + throw new ArgumentException("The expert selection code is invalid.", nameof(identity)); + } + + return identity with { ExpertCode = code, ExpertName = name }; + } + + private static string ValidateAndNormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsSafeText(query)) + { + throw new ArgumentException("The expert search query is invalid.", nameof(query)); + } + + var normalized = query.Trim(); + if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized)) + { + throw new ArgumentException( + $"An expert search query must contain at most {MaximumQueryLength} visible characters.", + nameof(query)); + } + + return normalized; + } + + private static string ValidateCanonicalInput( + string value, + int maximumLength, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value)) + { + throw new ArgumentException("The expert selection value is invalid.", parameterName); + } + + return value; + } + + private static void ValidateLimit(int value, int maximum, string parameterName) + { + if (value is < 1 || value > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"The requested limit must be between 1 and {maximum}."); + } + } + + private static void ValidateExactStringSchema( + DataTable? table, + IReadOnlyList columns, + int rowLimit, + string queryName) + { + if (table is null || table.Columns.Count != columns.Count || table.Rows.Count > rowLimit) + { + throw InvalidData(queryName, "schema or row bound"); + } + + for (var index = 0; index < columns.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + columns[index], + StringComparison.Ordinal) || + table.Columns[index].DataType != typeof(string)) + { + throw InvalidData(queryName, "schema"); + } + } + } + + private static string ReadCanonicalString( + DataRow row, + int ordinal, + int maximumLength, + string queryName) + { + if (row[ordinal] is not string value || + value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value)) + { + throw InvalidData(queryName, "value"); + } + + return value; + } + + private static string ReadExpertCode(DataRow row, int ordinal, string queryName) + { + var code = ReadCanonicalString(row, ordinal, 4, queryName); + if (!ExpertCodePattern().IsMatch(code)) + { + throw InvalidData(queryName, "expert code"); + } + + return code; + } + + private static decimal ReadPositiveDecimal(DataRow row, int ordinal, string queryName) + { + var raw = ReadCanonicalString(row, ordinal, 64, queryName); + if (!decimal.TryParse( + raw, + NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out var value) || + value <= 0 || + value > MaximumWebSafeInteger || + decimal.Truncate(value) != value || + !string.Equals( + raw, + value.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal)) + { + throw InvalidData(queryName, "buy amount"); + } + + return value; + } + + private static int ReadPlayIndex(DataRow row, int ordinal, string queryName) + { + var raw = ReadCanonicalString(row, ordinal, 4, queryName); + if (!int.TryParse( + raw, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var value) || + value is < 0 or > MaximumPlayIndex || + !string.Equals( + raw, + value.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal)) + { + throw InvalidData(queryName, "play index"); + } + + return value; + } + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static bool IsSafeText(string value) + { + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static ExpertSelectionDataException InvalidData(string queryName, string detail) => + new($"Expert selection data from {queryName} contains an invalid {detail}."); + + [GeneratedRegex("^[0-9]{4}$", RegexOptions.CultureInvariant)] + private static partial Regex ExpertCodePattern(); + + [GeneratedRegex("^[A-Za-z0-9]{1,32}$", RegexOptions.CultureInvariant)] + private static partial Regex StockCodePattern(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs new file mode 100644 index 0000000..a80e353 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs @@ -0,0 +1,141 @@ +#nullable enable + +using System.Data; +using System.Text.RegularExpressions; + +namespace MMoneyCoderSharp.Data; + +public enum IndustryMarket +{ + Kospi, + Kosdaq +} + +public sealed record IndustrySelection( + IndustryMarket Market, + string IndustryName, + string IndustryCode); + +public interface IIndustrySelectionService +{ + Task> GetAsync( + IndustryMarket market, + CancellationToken cancellationToken = default); +} + +public sealed class IndustrySelectionDataException : Exception +{ + public IndustrySelectionDataException(string message) : base(message) + { + } +} + +/// +/// Reads the two industry selectors used by legacy UC2. The Web layer receives only +/// normalized names and codes; it never chooses a table or supplies SQL text. +/// +public sealed partial class LegacyIndustrySelectionService : IIndustrySelectionService +{ + public const int MaximumResults = 500; + public const int MaximumNameLength = 128; + + private readonly IDataQueryExecutor _executor; + + public LegacyIndustrySelectionService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task> GetAsync( + IndustryMarket market, + CancellationToken cancellationToken = default) + { + var query = QueryFor(market); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + query.TableName, + query.Spec, + cancellationToken).ConfigureAwait(false); + return Map(table, market); + } + + private static IndustryQuery QueryFor(IndustryMarket market) => market switch + { + IndustryMarket.Kospi => new IndustryQuery( + "OPERATOR_KOSPI_INDUSTRIES", + new DataQuerySpec( + "SELECT F_PART_NAME INDUSTRY_NAME, F_PART_CODE INDUSTRY_CODE " + + "FROM T_PART WHERE F_PART_CODE <> '001' ORDER BY F_PART_NAME")), + IndustryMarket.Kosdaq => new IndustryQuery( + "OPERATOR_KOSDAQ_INDUSTRIES", + new DataQuerySpec( + "SELECT DISTINCT A.F_PART_NAME INDUSTRY_NAME, A.F_PART_CODE INDUSTRY_CODE " + + "FROM T_KOSDAQ_PART A INNER JOIN T_KOSDAQ_INDEX B " + + "ON A.F_PART_CODE = B.F_PART_CODE " + + "WHERE A.F_PART_CODE <> '001' ORDER BY A.F_PART_CODE")), + _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.") + }; + + private static IReadOnlyList Map(DataTable? table, IndustryMarket market) + { + if (table is null || table.Columns.Count != 2 || + !HasColumn(table, "INDUSTRY_NAME") || !HasColumn(table, "INDUSTRY_CODE") || + table.Rows.Count > MaximumResults) + { + throw new IndustrySelectionDataException("The industry selector returned an invalid schema."); + } + + var results = new List(table.Rows.Count); + var names = new HashSet(StringComparer.Ordinal); + var codes = new HashSet(StringComparer.Ordinal); + foreach (DataRow row in table.Rows) + { + var name = ReadRequiredText(row, "INDUSTRY_NAME", MaximumNameLength); + var code = ReadRequiredText(row, "INDUSTRY_CODE", 32); + if (!IndustryCodePattern().IsMatch(code)) + { + throw new IndustrySelectionDataException("The industry selector returned an invalid code."); + } + + // Every downstream scene query resolves an industry by name. Treat both + // columns as unique identities so a selector can never represent an + // ambiguous name/code pair, even if the returned tuples are distinct. + if (!names.Add(name) || !codes.Add(code)) + { + throw new IndustrySelectionDataException( + "The industry selector returned an ambiguous duplicate name or code."); + } + + results.Add(new IndustrySelection(market, name, code)); + } + + return Array.AsReadOnly(results.ToArray()); + } + + private static bool HasColumn(DataTable table, string name) => + table.Columns.Cast().Any(column => + string.Equals(column.ColumnName, name, StringComparison.OrdinalIgnoreCase)); + + private static string ReadRequiredText(DataRow row, string column, int maximumLength) + { + var value = row[column]; + if (value is null or DBNull) + { + throw new IndustrySelectionDataException("The industry selector returned a missing value."); + } + + var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim(); + if (string.IsNullOrWhiteSpace(text) || text.Length > maximumLength || + text.Any(character => char.IsControl(character))) + { + throw new IndustrySelectionDataException("The industry selector returned an invalid value."); + } + + return text; + } + + [GeneratedRegex("^[A-Za-z0-9]{1,32}$", RegexOptions.CultureInvariant)] + private static partial Regex IndustryCodePattern(); + + private sealed record IndustryQuery(string TableName, DataQuerySpec Spec); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs new file mode 100644 index 0000000..7b4f4b8 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs @@ -0,0 +1,1816 @@ +#nullable enable + +using System.Collections.ObjectModel; +using System.Data; +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MMoneyCoderSharp.Data; + +/// +/// The four manual financial editors opened by the original Form/GraphE.cs. +/// This closed enum is also the only route by which an INPUT_* table can be +/// selected; callers can never supply a table name. +/// +public enum ManualFinancialScreenKind +{ + RevenueComposition, + GrowthMetrics, + Sales, + OperatingProfit +} + +public sealed record ManualFinancialIdentity( + ManualFinancialScreenKind Screen, + string StockName); + +public abstract record ManualFinancialRecord(ManualFinancialIdentity Identity); + +/// +/// INPUT_PIE stores one populated slice as label_percentage. PercentageText is +/// retained because s5076 displays the legacy text while Percentage drives its +/// circle angle. An absent slice is stored as an empty column, not "_". +/// +public sealed record ManualRevenueSlice( + string Label, + string PercentageText, + double Percentage); + +public sealed record ManualRevenueCompositionRecord( + ManualFinancialIdentity Identity, + string BaseDate, + IReadOnlyList Slices) + : ManualFinancialRecord(Identity); + +public sealed record ManualGrowthSeriesValues( + double? First, + double? Second, + double? Third, + double? Fourth) +{ + public IReadOnlyList ToArray() => [First, Second, Third, Fourth]; +} + +public sealed record ManualGrowthPeriodLabels( + string First, + string Second, + string Third, + string Fourth) +{ + public IReadOnlyList ToArray() => [First, Second, Third, Fourth]; +} + +/// +/// Series order is the original GraphE order: sales growth, operating-profit +/// growth, net-asset growth and net-income growth. Each GUSUNG_N and BASE_DATE +/// contains exactly four underscore-delimited tokens. +/// +public sealed record ManualGrowthMetricsRecord( + ManualFinancialIdentity Identity, + ManualGrowthSeriesValues SalesGrowth, + ManualGrowthSeriesValues OperatingProfitGrowth, + ManualGrowthSeriesValues NetAssetGrowth, + ManualGrowthSeriesValues NetIncomeGrowth, + ManualGrowthPeriodLabels Periods) + : ManualFinancialRecord(Identity); + +public sealed record ManualQuarterValue(string Quarter, int Value); + +public sealed record ManualSalesRecord( + ManualFinancialIdentity Identity, + IReadOnlyList Quarters) + : ManualFinancialRecord(Identity); + +public sealed record ManualOperatingProfitRecord( + ManualFinancialIdentity Identity, + IReadOnlyList Quarters) + : ManualFinancialRecord(Identity); + +/// +/// ROW_VERSION is calculated by Oracle with STANDARD_HASH over every stored +/// column. It is used for optimistic update/delete checks without exposing ROWID +/// or rebuilding a WHERE clause from operator text. +/// +public sealed record ManualFinancialSnapshot( + ManualFinancialRecord Record, + string RowVersion); + +public sealed record ManualFinancialSearchResult( + ManualFinancialScreenKind Screen, + string Query, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public sealed record ManualFinancialScreenContract( + ManualFinancialScreenKind Screen, + string TableName, + string BuilderKey, + string CutCode, + string OriginalGraphicType, + string CanonicalGraphicType, + IReadOnlyList StorageColumns, + string CompoundStorageShape); + +public sealed record ManualFinancialCutSelection( + string BuilderKey, + string CutCode, + LegacySceneSelection Selection, + int CurrentPage, + int TotalPages); + +/// +/// Opaque result of resolving one stock name to one exact master row. GraphE's +/// INPUT_* schema has no stock code, so a duplicated master name cannot safely +/// create a cut and is rejected before this binding can be created. +/// +public sealed class VerifiedManualFinancialStock +{ + internal VerifiedManualFinancialStock(StockSearchItem item) + { + Market = item.Market; + Source = item.Source; + Name = item.Name; + Code = item.Code; + } + + public StockMarket Market { get; } + + public DataSourceKind Source { get; } + + public string Name { get; } + + public string Code { get; } + + public override string ToString() => "Verified manual-financial stock"; +} + +/// +/// Documents and creates the exact one-page GraphE playlist contract. The stock +/// code is used to prove an unambiguous master selection, but DataCode remains +/// empty because the original GraphE playlist row did not populate hidden column +/// six and the four scene loaders query INPUT_* by STOCK_NAME. +/// +public static class ManualFinancialCutContracts +{ + private static readonly IReadOnlyDictionary + Contracts = new ReadOnlyDictionary( + new Dictionary + { + [ManualFinancialScreenKind.RevenueComposition] = Contract( + ManualFinancialScreenKind.RevenueComposition, + "INPUT_PIE", + "s5076", + "5076", + "주요매출 구성", + "REVENUE_COMPOSITION", + ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"], + "BASE_DATE + five label_percentage columns"), + [ManualFinancialScreenKind.GrowthMetrics] = Contract( + ManualFinancialScreenKind.GrowthMetrics, + "INPUT_GROW", + "s5079", + "5079", + "성장성 지표", + "GROWTH_METRICS", + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"], + "four value_value_value_value series + four period labels in BASE_DATE"), + [ManualFinancialScreenKind.Sales] = Contract( + ManualFinancialScreenKind.Sales, + "INPUT_SELL", + "s5080", + "5080", + "매출액", + "SALES", + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"], + "six quarter_integer columns"), + [ManualFinancialScreenKind.OperatingProfit] = Contract( + ManualFinancialScreenKind.OperatingProfit, + "INPUT_PROFIT", + "s5081", + "5081", + "영업이익", + "OPERATING_PROFIT", + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"], + "six quarter_integer columns") + }); + + public static IReadOnlyCollection All => + Contracts.Values.ToArray(); + + public static ManualFinancialScreenContract Get(ManualFinancialScreenKind screen) => + Contracts.TryGetValue(screen, out var contract) + ? contract + : throw new ArgumentOutOfRangeException(nameof(screen)); + + public static VerifiedManualFinancialStock VerifyStock( + ManualFinancialIdentity identity, + IReadOnlyList candidates, + StockMarket selectedMarket, + string selectedCode) + { + var validatedIdentity = ManualFinancialValidation.Identity(identity, nameof(identity)); + ArgumentNullException.ThrowIfNull(candidates); + selectedCode = ManualFinancialValidation.StockCode(selectedCode, nameof(selectedCode)); + + var sameName = new List(); + var exactIdentities = new HashSet<(StockMarket Market, DataSourceKind Source, string Code)>(); + foreach (var candidate in candidates) + { + if (candidate is null) + { + throw new ArgumentException("A stock candidate collection is invalid.", nameof(candidates)); + } + + var name = ManualFinancialValidation.StockName(candidate.Name, nameof(candidates)); + var code = ManualFinancialValidation.StockCode(candidate.Code, nameof(candidates)); + ValidateProvider(candidate.Market, candidate.Source, nameof(candidates)); + if (!exactIdentities.Add((candidate.Market, candidate.Source, code))) + { + throw new ArgumentException("A stock candidate collection contains a duplicate identity.", nameof(candidates)); + } + + if (string.Equals(name, validatedIdentity.StockName, StringComparison.Ordinal)) + { + sameName.Add(candidate with { Name = name, Code = code }); + } + } + + // INPUT_* has STOCK_NAME only. Even two legitimate market masters with the + // same name are ambiguous for later scene playback and must fail closed. + if (sameName.Count != 1 || + sameName[0].Market != selectedMarket || + !string.Equals(sameName[0].Code, selectedCode, StringComparison.Ordinal)) + { + throw new ArgumentException( + "The manual-financial stock name does not resolve to one exact master identity.", + nameof(candidates)); + } + + return new VerifiedManualFinancialStock(sameName[0]); + } + + public static ManualFinancialCutSelection CreateSelection( + ManualFinancialSnapshot snapshot, + VerifiedManualFinancialStock stock) + { + var validated = ManualFinancialValidation.Snapshot(snapshot, nameof(snapshot)); + ArgumentNullException.ThrowIfNull(stock); + ValidateProvider(stock.Market, stock.Source, nameof(stock)); + _ = ManualFinancialValidation.StockCode(stock.Code, nameof(stock)); + if (!string.Equals( + validated.Record.Identity.StockName, + stock.Name, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "The verified stock does not match the manual-financial record.", + nameof(stock)); + } + + var contract = Get(validated.Record.Identity.Screen); + var groupCode = stock.Market switch + { + StockMarket.Kospi => "KOSPI", + StockMarket.Kosdaq => "KOSDAQ", + StockMarket.NxtKospi => "NXT_KOSPI", + StockMarket.NxtKosdaq => "NXT_KOSDAQ", + _ => throw new ArgumentOutOfRangeException(nameof(stock)) + }; + return new ManualFinancialCutSelection( + contract.BuilderKey, + contract.CutCode, + new LegacySceneSelection( + groupCode, + validated.Record.Identity.StockName, + contract.CanonicalGraphicType, + string.Empty, + string.Empty), + 1, + 1); + } + + private static ManualFinancialScreenContract Contract( + ManualFinancialScreenKind screen, + string tableName, + string builderKey, + string cutCode, + string originalGraphicType, + string canonicalGraphicType, + string[] storageColumns, + string compoundStorageShape) => + new( + screen, + tableName, + builderKey, + cutCode, + originalGraphicType, + canonicalGraphicType, + Array.AsReadOnly(storageColumns), + compoundStorageShape); + + private static void ValidateProvider( + StockMarket market, + DataSourceKind source, + string parameterName) + { + var expected = market switch + { + StockMarket.Kospi or StockMarket.Kosdaq => DataSourceKind.Oracle, + StockMarket.NxtKospi or StockMarket.NxtKosdaq => DataSourceKind.MariaDb, + _ => throw new ArgumentOutOfRangeException(parameterName) + }; + if (source != expected) + { + throw new ArgumentException("The stock market/provider identity is invalid.", parameterName); + } + } +} + +public enum ManualFinancialMutationKind +{ + Create, + Update, + DeleteOne, + DeleteAll +} + +public enum ManualFinancialDbCommandKind +{ + Insert, + Update, + DeleteOne, + DeleteAll +} + +public enum ManualFinancialAffectedRowsRule +{ + ExactlyOne, + AnyNonNegative +} + +public sealed class ManualFinancialDbParameter +{ + internal ManualFinancialDbParameter(string name, object? value, DbType dbType) + { + if (!DataQueryParameter.IsValidName(name) || !Enum.IsDefined(dbType)) + { + throw new ArgumentException("A manual-financial database parameter is invalid."); + } + + Name = name; + Value = value; + DbType = dbType; + } + + public string Name { get; } + + public object? Value { get; } + + public DbType DbType { get; } + + public override string ToString() => "Manual-financial database parameter"; +} + +/// +/// A command from the fixed INPUT_PIE/GROW/SELL/PROFIT mutation set. SQL and +/// table identifiers are never caller supplied; every operator value is bound. +/// +public sealed class ManualFinancialDbCommand +{ + internal ManualFinancialDbCommand( + ManualFinancialDbCommandKind kind, + string sql, + IEnumerable parameters, + ManualFinancialAffectedRowsRule affectedRowsRule) + { + Kind = kind; + Sql = sql; + Parameters = Array.AsReadOnly(parameters.ToArray()); + AffectedRowsRule = affectedRowsRule; + } + + public ManualFinancialDbCommandKind Kind { get; } + + public string Sql { get; } + + public IReadOnlyList Parameters { get; } + + public ManualFinancialAffectedRowsRule AffectedRowsRule { get; } + + public override string ToString() => $"Manual-financial {Kind} command"; +} + +/// +/// One write operation. An executor must open one Oracle transaction, execute +/// ExclusiveTableLockSql once, execute Commands once in order, validate every +/// affected-row rule before commit, and never retry after timeout/ambiguity. +/// +public sealed class ManualFinancialDbTransaction +{ + internal ManualFinancialDbTransaction( + Guid operationId, + ManualFinancialMutationKind kind, + ManualFinancialScreenKind screen, + string tableName, + string exclusiveTableLockSql, + IEnumerable commands) + { + OperationId = operationId; + Kind = kind; + Screen = screen; + TableName = tableName; + ExclusiveTableLockSql = exclusiveTableLockSql; + Commands = Array.AsReadOnly(commands.ToArray()); + } + + public Guid OperationId { get; } + + public ManualFinancialMutationKind Kind { get; } + + public ManualFinancialScreenKind Screen { get; } + + public string TableName { get; } + + public string ExclusiveTableLockSql { get; } + + public bool RequiresExclusiveTableLock => true; + + public IReadOnlyList Commands { get; } + + public override string ToString() => $"Manual-financial {Kind} transaction"; +} + +public sealed record ManualFinancialDbTransactionResult( + Guid OperationId, + IReadOnlyList AffectedRows, + DateTimeOffset CommittedAt); + +public sealed record ManualFinancialMutationReceipt( + Guid OperationId, + ManualFinancialMutationKind Kind, + ManualFinancialScreenKind Screen, + string StockName, + DateTimeOffset CommittedAt, + int AffectedRows); + +/// +/// Infrastructure seam for state-changing Oracle work. The executor must report +/// known rollback with OutcomeUnknown=false and an indeterminate commit with +/// OutcomeUnknown=true. It must never retry this transaction automatically. +/// +public interface IManualFinancialMutationExecutor +{ + Task ExecuteTransactionAsync( + DataSourceKind source, + ManualFinancialDbTransaction transaction, + CancellationToken cancellationToken = default); +} + +public interface IManualFinancialScreenService +{ + bool CanMutate { get; } + + Task SearchAsync( + ManualFinancialScreenKind screen, + string query = "", + int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults, + CancellationToken cancellationToken = default); + + Task GetAsync( + ManualFinancialIdentity identity, + CancellationToken cancellationToken = default); + + Task CreateAsync( + ManualFinancialRecord record, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + ManualFinancialSnapshot expected, + ManualFinancialRecord replacement, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + ManualFinancialSnapshot expected, + CancellationToken cancellationToken = default); + + Task DeleteAllAsync( + ManualFinancialScreenKind screen, + CancellationToken cancellationToken = default); +} + +public class ManualFinancialDataException : Exception +{ + public ManualFinancialDataException(string message) + : base(message) + { + } +} + +public sealed class ManualFinancialNotFoundException : ManualFinancialDataException +{ + public ManualFinancialNotFoundException(ManualFinancialIdentity identity) + : base($"A {identity.Screen} record for {identity.StockName} does not exist.") + { + } +} + +public sealed class ManualFinancialAmbiguousIdentityException : ManualFinancialDataException +{ + public ManualFinancialAmbiguousIdentityException( + ManualFinancialScreenKind screen, + string stockName) + : base($"{screen} contains an ambiguous STOCK_NAME identity for {stockName}.") + { + } +} + +public class ManualFinancialMutationException : Exception +{ + public ManualFinancialMutationException( + string message, + bool outcomeUnknown, + Exception? innerException = null) + : base(message, innerException) + { + OutcomeUnknown = outcomeUnknown; + } + + public bool OutcomeUnknown { get; } +} + +public enum ManualFinancialMutationRejectionReason +{ + DuplicateIdentity, + NotFoundOrChanged, + AmbiguousIdentity +} + +/// +/// A safe-to-correct rejection after the executor has rolled the transaction +/// back. The caller may ask the operator to reload/edit, but must not retry the +/// same command automatically. +/// +public sealed class ManualFinancialMutationRejectedException : ManualFinancialMutationException +{ + public ManualFinancialMutationRejectedException( + ManualFinancialMutationRejectionReason reason, + string message, + Exception? innerException = null) + : base(message, outcomeUnknown: false, innerException) + { + Reason = reason; + } + + public ManualFinancialMutationRejectionReason Reason { get; } +} + +/// +/// Typed migration of Form/GraphE.cs. Reads reproduce its list, substring search +/// and exact-row selection over Oracle. Writes preserve the INPUT_* underscore +/// representation, but replace concatenated SQL with fixed bound commands, +/// exclusive-table identity checks and optimistic ROW_VERSION checks. +/// +public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenService +{ + public const int DefaultMaximumResults = 200; + public const int MaximumResults = 1_000; + public const int MaximumQueryLength = 64; + + private readonly IDataQueryExecutor _queryExecutor; + private readonly IManualFinancialMutationExecutor? _mutationExecutor; + + public LegacyManualFinancialScreenService(IDataQueryExecutor queryExecutor) + : this(queryExecutor, null) + { + } + + public LegacyManualFinancialScreenService( + IDataQueryExecutor queryExecutor, + IManualFinancialMutationExecutor? mutationExecutor) + { + _queryExecutor = queryExecutor ?? throw new ArgumentNullException(nameof(queryExecutor)); + _mutationExecutor = mutationExecutor; + } + + public bool CanMutate => _mutationExecutor is not null; + + public async Task SearchAsync( + ManualFinancialScreenKind screen, + string query = "", + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var profile = ManualFinancialSqlProfiles.Get(screen); + var normalizedQuery = ManualFinancialValidation.Query(query, nameof(query)); + if (maximumResults is < 1 or > MaximumResults) + { + throw new ArgumentOutOfRangeException( + nameof(maximumResults), + $"The requested result limit must be between 1 and {MaximumResults}."); + } + + cancellationToken.ThrowIfCancellationRequested(); + var rowLimit = checked(maximumResults + 1); + var spec = new DataQuerySpec( + profile.SearchSql, + [ + new DataQueryParameter( + "search_empty", + normalizedQuery.Length == 0 ? 1 : 0, + DbType.Int32), + new DataQueryParameter( + "search_term", + EscapeLikePattern(normalizedQuery.ToUpperInvariant()), + DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await _queryExecutor.ExecuteAsync( + DataSourceKind.Oracle, + profile.SearchQueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var snapshots = ParseRows(table, profile, rowLimit); + RejectDuplicateNames(snapshots, screen); + return new ManualFinancialSearchResult( + screen, + normalizedQuery, + DateTimeOffset.Now, + snapshots.Take(maximumResults).ToArray(), + snapshots.Count > maximumResults); + } + + public async Task GetAsync( + ManualFinancialIdentity identity, + CancellationToken cancellationToken = default) + { + var validatedIdentity = ManualFinancialValidation.Identity(identity, nameof(identity)); + var profile = ManualFinancialSqlProfiles.Get(validatedIdentity.Screen); + cancellationToken.ThrowIfCancellationRequested(); + var spec = new DataQuerySpec( + profile.GetSql, + [new DataQueryParameter("stock_name", validatedIdentity.StockName, DbType.String)]); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await _queryExecutor.ExecuteAsync( + DataSourceKind.Oracle, + profile.GetQueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var rows = ParseRows(table, profile, maximumRows: 2); + return rows.Count switch + { + 0 => throw new ManualFinancialNotFoundException(validatedIdentity), + 1 when rows[0].Record.Identity == validatedIdentity => rows[0], + 1 => throw InvalidData(profile, "record identity"), + _ => throw new ManualFinancialAmbiguousIdentityException( + validatedIdentity.Screen, + validatedIdentity.StockName) + }; + } + + public Task CreateAsync( + ManualFinancialRecord record, + CancellationToken cancellationToken = default) + { + var validated = ManualFinancialValidation.Record(record, nameof(record)); + var profile = ManualFinancialSqlProfiles.Get(validated.Identity.Screen); + var fields = ManualFinancialValidation.StorageValues(validated); + var parameters = CreateValueParameters(validated.Identity.StockName, fields); + parameters.Add(StringParameter("unique_stock_name", validated.Identity.StockName)); + return ExecuteMutationAsync( + profile, + ManualFinancialMutationKind.Create, + new ManualFinancialDbCommand( + ManualFinancialDbCommandKind.Insert, + profile.InsertSql, + parameters, + ManualFinancialAffectedRowsRule.ExactlyOne), + validated.Identity.StockName, + cancellationToken); + } + + public Task UpdateAsync( + ManualFinancialSnapshot expected, + ManualFinancialRecord replacement, + CancellationToken cancellationToken = default) + { + var validatedExpected = ManualFinancialValidation.Snapshot(expected, nameof(expected)); + var validatedReplacement = ManualFinancialValidation.Record(replacement, nameof(replacement)); + if (validatedExpected.Record.Identity != validatedReplacement.Identity) + { + throw new ArgumentException( + "A GraphE update cannot rename a stock or change its screen.", + nameof(replacement)); + } + + var profile = ManualFinancialSqlProfiles.Get(validatedReplacement.Identity.Screen); + var fields = ManualFinancialValidation.StorageValues(validatedReplacement); + var parameters = CreateFieldParameters(fields); + parameters.Add(StringParameter("identity_stock_name", validatedReplacement.Identity.StockName)); + parameters.Add(StringParameter("expected_row_version", validatedExpected.RowVersion)); + parameters.Add(StringParameter("unique_stock_name", validatedReplacement.Identity.StockName)); + return ExecuteMutationAsync( + profile, + ManualFinancialMutationKind.Update, + new ManualFinancialDbCommand( + ManualFinancialDbCommandKind.Update, + profile.UpdateSql, + parameters, + ManualFinancialAffectedRowsRule.ExactlyOne), + validatedReplacement.Identity.StockName, + cancellationToken); + } + + public Task DeleteAsync( + ManualFinancialSnapshot expected, + CancellationToken cancellationToken = default) + { + var validated = ManualFinancialValidation.Snapshot(expected, nameof(expected)); + var profile = ManualFinancialSqlProfiles.Get(validated.Record.Identity.Screen); + return ExecuteMutationAsync( + profile, + ManualFinancialMutationKind.DeleteOne, + new ManualFinancialDbCommand( + ManualFinancialDbCommandKind.DeleteOne, + profile.DeleteOneSql, + [ + StringParameter("identity_stock_name", validated.Record.Identity.StockName), + StringParameter("expected_row_version", validated.RowVersion), + StringParameter("unique_stock_name", validated.Record.Identity.StockName) + ], + ManualFinancialAffectedRowsRule.ExactlyOne), + validated.Record.Identity.StockName, + cancellationToken); + } + + public Task DeleteAllAsync( + ManualFinancialScreenKind screen, + CancellationToken cancellationToken = default) + { + var profile = ManualFinancialSqlProfiles.Get(screen); + return ExecuteMutationAsync( + profile, + ManualFinancialMutationKind.DeleteAll, + new ManualFinancialDbCommand( + ManualFinancialDbCommandKind.DeleteAll, + profile.DeleteAllSql, + [], + ManualFinancialAffectedRowsRule.AnyNonNegative), + string.Empty, + cancellationToken); + } + + private async Task ExecuteMutationAsync( + ManualFinancialSqlProfile profile, + ManualFinancialMutationKind kind, + ManualFinancialDbCommand command, + string stockName, + CancellationToken cancellationToken) + { + var executor = _mutationExecutor ?? throw new InvalidOperationException( + "Manual-financial screens are configured for read-only access."); + cancellationToken.ThrowIfCancellationRequested(); + var transaction = new ManualFinancialDbTransaction( + Guid.NewGuid(), + kind, + profile.Screen, + profile.TableName, + profile.ExclusiveTableLockSql, + [command]); + + ManualFinancialDbTransactionResult result; + try + { + result = await executor.ExecuteTransactionAsync( + DataSourceKind.Oracle, + transaction, + cancellationToken).ConfigureAwait(false); + } + catch (ManualFinancialMutationException) + { + throw; + } + catch (Exception exception) + { + // A cancellation/timeout after Oracle received a mutation is not proof + // of rollback. Never turn it into an automatic retry opportunity. + throw new ManualFinancialMutationException( + $"The {kind} transaction outcome is unknown; do not retry automatically.", + outcomeUnknown: true, + exception); + } + + if (result is null || + result.OperationId != transaction.OperationId || + result.AffectedRows is null || + result.AffectedRows.Count != 1 || + !Matches(command.AffectedRowsRule, result.AffectedRows[0])) + { + throw new ManualFinancialMutationException( + $"The {kind} transaction returned an ambiguous commit result; do not retry automatically.", + outcomeUnknown: true); + } + + return new ManualFinancialMutationReceipt( + result.OperationId, + kind, + profile.Screen, + stockName, + result.CommittedAt, + result.AffectedRows[0]); + } + + private static bool Matches(ManualFinancialAffectedRowsRule rule, int affectedRows) => + rule switch + { + ManualFinancialAffectedRowsRule.ExactlyOne => affectedRows == 1, + ManualFinancialAffectedRowsRule.AnyNonNegative => affectedRows >= 0, + _ => false + }; + + private static IReadOnlyList ParseRows( + DataTable? table, + ManualFinancialSqlProfile profile, + int maximumRows) + { + if (table is null || table.Rows.Count > maximumRows || + table.Columns.Count != profile.ResultColumns.Count) + { + throw InvalidData(profile, "schema or row bound"); + } + + for (var index = 0; index < profile.ResultColumns.Count; index++) + { + var column = table.Columns[index]; + if (!string.Equals( + column.ColumnName, + profile.ResultColumns[index], + StringComparison.Ordinal) || + column.DataType != typeof(string)) + { + throw InvalidData(profile, "schema"); + } + } + + var result = new List(table.Rows.Count); + foreach (DataRow row in table.Rows) + { + try + { + result.Add(ManualFinancialValidation.Parse(profile.Screen, row)); + } + catch (ArgumentException exception) + { + throw new ManualFinancialDataException( + $"Manual-financial data from {profile.SearchQueryName} is invalid: {exception.Message}"); + } + } + + return result; + } + + private static void RejectDuplicateNames( + IReadOnlyList snapshots, + ManualFinancialScreenKind screen) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var snapshot in snapshots) + { + if (!names.Add(snapshot.Record.Identity.StockName)) + { + throw new ManualFinancialAmbiguousIdentityException( + screen, + snapshot.Record.Identity.StockName); + } + } + } + + private static List CreateValueParameters( + string stockName, + IReadOnlyList fields) + { + var parameters = new List(fields.Count + 2) + { + StringParameter("stock_name", stockName) + }; + parameters.AddRange(CreateFieldParameters(fields)); + return parameters; + } + + private static List CreateFieldParameters( + IReadOnlyList fields) + { + var parameters = new List(fields.Count); + for (var index = 0; index < fields.Count; index++) + { + parameters.Add(StringParameter( + "field_" + (index + 1).ToString(CultureInfo.InvariantCulture), + fields[index])); + } + + return parameters; + } + + private static ManualFinancialDbParameter StringParameter(string name, string value) => + new(name, value, DbType.String); + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static ManualFinancialDataException InvalidData( + ManualFinancialSqlProfile profile, + string detail) => + new($"Manual-financial data from {profile.SearchQueryName} contains an invalid {detail}."); +} + +internal static class ManualFinancialValidation +{ + private const int MaximumStockNameLength = 200; + private const int MaximumStockCodeLength = 32; + private const int MaximumTextLength = 200; + private const int MaximumNumberTextLength = 64; + private const double MaximumAbsoluteFloatingValue = 1_000_000_000_000d; + + internal static ManualFinancialIdentity Identity( + ManualFinancialIdentity? identity, + string parameterName) + { + ArgumentNullException.ThrowIfNull(identity, parameterName); + _ = ManualFinancialCutContracts.Get(identity.Screen); + return new ManualFinancialIdentity( + identity.Screen, + StockName(identity.StockName, parameterName)); + } + + internal static string StockName(string value, string parameterName) => + Text(value, MaximumStockNameLength, allowEmpty: false, allowUnderscore: true, parameterName); + + internal static string StockCode(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (value.Length is < 1 or > MaximumStockCodeLength || + value.Any(static character => !char.IsAsciiLetterOrDigit(character))) + { + throw new ArgumentException("A stock code is invalid.", parameterName); + } + + return value; + } + + internal static string Query(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (!IsSafe(value)) + { + throw new ArgumentException("A manual-financial query is invalid.", parameterName); + } + + var normalized = value.Trim(); + if (normalized.Length > LegacyManualFinancialScreenService.MaximumQueryLength || + !IsSafe(normalized)) + { + throw new ArgumentException("A manual-financial query is invalid.", parameterName); + } + + return normalized; + } + + internal static ManualFinancialSnapshot Snapshot( + ManualFinancialSnapshot? snapshot, + string parameterName) + { + ArgumentNullException.ThrowIfNull(snapshot, parameterName); + var record = Record(snapshot.Record, parameterName); + var rowVersion = RowVersion(snapshot.RowVersion, parameterName); + return new ManualFinancialSnapshot(record, rowVersion); + } + + internal static ManualFinancialRecord Record( + ManualFinancialRecord? record, + string parameterName) + { + ArgumentNullException.ThrowIfNull(record, parameterName); + var identity = Identity(record.Identity, parameterName); + return record switch + { + ManualRevenueCompositionRecord revenue + when identity.Screen == ManualFinancialScreenKind.RevenueComposition => + Revenue(identity, revenue, parameterName), + ManualGrowthMetricsRecord growth + when identity.Screen == ManualFinancialScreenKind.GrowthMetrics => + Growth(identity, growth, parameterName), + ManualSalesRecord sales + when identity.Screen == ManualFinancialScreenKind.Sales => + new ManualSalesRecord( + identity, + Quarters(sales.Quarters, parameterName)), + ManualOperatingProfitRecord profit + when identity.Screen == ManualFinancialScreenKind.OperatingProfit => + new ManualOperatingProfitRecord( + identity, + Quarters(profit.Quarters, parameterName)), + _ => throw new ArgumentException( + "A manual-financial DTO does not match its screen.", + parameterName) + }; + } + + internal static IReadOnlyList StorageValues(ManualFinancialRecord record) + { + var validated = Record(record, nameof(record)); + return validated switch + { + ManualRevenueCompositionRecord revenue => + [revenue.BaseDate, .. revenue.Slices.Select(SerializeSlice)], + ManualGrowthMetricsRecord growth => + [ + SerializeGrowth(growth.SalesGrowth), + SerializeGrowth(growth.OperatingProfitGrowth), + SerializeGrowth(growth.NetAssetGrowth), + SerializeGrowth(growth.NetIncomeGrowth), + string.Join('_', growth.Periods.ToArray()) + ], + ManualSalesRecord sales => sales.Quarters.Select(SerializeQuarter).ToArray(), + ManualOperatingProfitRecord profit => profit.Quarters.Select(SerializeQuarter).ToArray(), + _ => throw new ArgumentOutOfRangeException(nameof(record)) + }; + } + + internal static ManualFinancialSnapshot Parse( + ManualFinancialScreenKind screen, + DataRow row) + { + var stockName = StockName(RequiredString(row, "STOCK_NAME"), nameof(row)); + var identity = new ManualFinancialIdentity(screen, stockName); + ManualFinancialRecord record = screen switch + { + ManualFinancialScreenKind.RevenueComposition => + new ManualRevenueCompositionRecord( + identity, + Text( + RequiredString(row, "BASE_DATE"), + MaximumTextLength, + allowEmpty: true, + allowUnderscore: true, + nameof(row)), + Enumerable.Range(1, 5) + .Select(index => ParseSlice(RequiredString(row, "GUSUNG_" + index))) + .ToArray()), + ManualFinancialScreenKind.GrowthMetrics => ParseGrowth(identity, row), + ManualFinancialScreenKind.Sales => + new ManualSalesRecord(identity, ParseQuarters(row, allowThousands: false)), + ManualFinancialScreenKind.OperatingProfit => + new ManualOperatingProfitRecord(identity, ParseQuarters(row, allowThousands: true)), + _ => throw new ArgumentOutOfRangeException(nameof(screen)) + }; + return Snapshot( + new ManualFinancialSnapshot(record, RequiredString(row, "ROW_VERSION")), + nameof(row)); + } + + private static ManualRevenueCompositionRecord Revenue( + ManualFinancialIdentity identity, + ManualRevenueCompositionRecord record, + string parameterName) + { + if (record.Slices is null || record.Slices.Count != 5) + { + throw new ArgumentException("Revenue composition requires exactly five slices.", parameterName); + } + + var slices = new ManualRevenueSlice?[5]; + for (var index = 0; index < slices.Length; index++) + { + var slice = record.Slices[index]; + if (slice is null) + { + slices[index] = null; + continue; + } + + var label = Text( + slice.Label, + MaximumTextLength, + allowEmpty: false, + allowUnderscore: false, + parameterName); + var percentageText = Text( + slice.PercentageText, + MaximumNumberTextLength, + allowEmpty: false, + allowUnderscore: false, + parameterName); + var parsed = percentageText == "-" + ? 0d + : ParseFinite(percentageText, allowThousands: false, parameterName); + Finite(slice.Percentage, parameterName); + if (!parsed.Equals(slice.Percentage)) + { + throw new ArgumentException( + "A revenue percentage text/value pair is inconsistent.", + parameterName); + } + + slices[index] = new ManualRevenueSlice(label, percentageText, parsed); + } + + return new ManualRevenueCompositionRecord( + identity, + Text( + record.BaseDate, + MaximumTextLength, + allowEmpty: true, + allowUnderscore: true, + parameterName), + slices); + } + + private static ManualGrowthMetricsRecord Growth( + ManualFinancialIdentity identity, + ManualGrowthMetricsRecord record, + string parameterName) => + new( + identity, + GrowthValues(record.SalesGrowth, parameterName), + GrowthValues(record.OperatingProfitGrowth, parameterName), + GrowthValues(record.NetAssetGrowth, parameterName), + GrowthValues(record.NetIncomeGrowth, parameterName), + Periods(record.Periods, parameterName)); + + private static ManualGrowthSeriesValues GrowthValues( + ManualGrowthSeriesValues? values, + string parameterName) + { + ArgumentNullException.ThrowIfNull(values, parameterName); + foreach (var value in values.ToArray()) + { + if (value.HasValue) + { + Finite(value.Value, parameterName); + } + } + + return values; + } + + private static ManualGrowthPeriodLabels Periods( + ManualGrowthPeriodLabels? periods, + string parameterName) + { + ArgumentNullException.ThrowIfNull(periods, parameterName); + var values = periods.ToArray() + .Select(value => Text( + value, + MaximumTextLength, + allowEmpty: true, + allowUnderscore: false, + parameterName)) + .ToArray(); + return new ManualGrowthPeriodLabels(values[0], values[1], values[2], values[3]); + } + + private static IReadOnlyList Quarters( + IReadOnlyList? quarters, + string parameterName) + { + if (quarters is null || quarters.Count != 6 || quarters.Any(static value => value is null)) + { + throw new ArgumentException("Quarter data requires exactly six values.", parameterName); + } + + return quarters.Select(value => new ManualQuarterValue( + Text( + value.Quarter, + MaximumTextLength, + allowEmpty: false, + allowUnderscore: false, + parameterName), + value.Value)) + .ToArray(); + } + + private static ManualGrowthMetricsRecord ParseGrowth( + ManualFinancialIdentity identity, + DataRow row) + { + var series = Enumerable.Range(1, 4) + .Select(index => ParseGrowthValues(RequiredString(row, "GUSUNG_" + index))) + .ToArray(); + var periods = RequiredString(row, "BASE_DATE").Split('_', StringSplitOptions.None); + if (periods.Length != 4) + { + throw new ArgumentException("Growth period storage is invalid.", nameof(row)); + } + + return new ManualGrowthMetricsRecord( + identity, + series[0], + series[1], + series[2], + series[3], + Periods( + new ManualGrowthPeriodLabels(periods[0], periods[1], periods[2], periods[3]), + nameof(row))); + } + + private static ManualGrowthSeriesValues ParseGrowthValues(string value) + { + var tokens = value.Split('_', StringSplitOptions.None); + if (tokens.Length != 4) + { + throw new ArgumentException("Growth-series storage is invalid.", nameof(value)); + } + + var values = tokens.Select(token => token.Length == 0 + ? (double?)null + : ParseFinite(token, allowThousands: true, nameof(value))) + .ToArray(); + return new ManualGrowthSeriesValues(values[0], values[1], values[2], values[3]); + } + + private static IReadOnlyList ParseQuarters( + DataRow row, + bool allowThousands) + { + var values = new ManualQuarterValue[6]; + for (var index = 0; index < values.Length; index++) + { + var stored = RequiredString(row, "GUSUNG_" + (index + 1)); + var tokens = stored.Split('_', StringSplitOptions.None); + if (tokens.Length != 2 || tokens[0].Length == 0) + { + throw new ArgumentException("Quarter storage is invalid.", nameof(row)); + } + + var number = 0; + var styles = NumberStyles.Integer | + (allowThousands ? NumberStyles.AllowThousands : NumberStyles.None); + if (tokens[1].Length != 0 && + !int.TryParse(tokens[1], styles, CultureInfo.InvariantCulture, out number)) + { + throw new ArgumentException("Quarter value storage is invalid.", nameof(row)); + } + + values[index] = new ManualQuarterValue( + Text( + tokens[0], + MaximumTextLength, + allowEmpty: false, + allowUnderscore: false, + nameof(row)), + number); + } + + return values; + } + + private static ManualRevenueSlice? ParseSlice(string value) + { + if (value.Length == 0) + { + return null; + } + + var tokens = value.Split('_', StringSplitOptions.None); + if (tokens.Length != 2 || tokens[0].Length == 0) + { + throw new ArgumentException("Revenue-slice storage is invalid.", nameof(value)); + } + + var percentageText = tokens[1] is "" or "-" ? tokens[1] : Text( + tokens[1], + MaximumNumberTextLength, + allowEmpty: false, + allowUnderscore: false, + nameof(value)); + var number = percentageText is "" or "-" + ? 0d + : ParseFinite(percentageText, allowThousands: false, nameof(value)); + return new ManualRevenueSlice( + Text( + tokens[0], + MaximumTextLength, + allowEmpty: false, + allowUnderscore: false, + nameof(value)), + percentageText.Length == 0 ? "0" : percentageText, + number); + } + + private static string SerializeSlice(ManualRevenueSlice? slice) => + slice is null ? string.Empty : slice.Label + "_" + slice.PercentageText; + + private static string SerializeGrowth(ManualGrowthSeriesValues values) => + string.Join('_', values.ToArray().Select(value => value.HasValue + ? value.Value.ToString("G17", CultureInfo.InvariantCulture) + : string.Empty)); + + private static string SerializeQuarter(ManualQuarterValue value) => + value.Quarter + "_" + value.Value.ToString(CultureInfo.InvariantCulture); + + private static string RequiredString(DataRow row, string columnName) + { + if (!row.Table.Columns.Contains(columnName) || row[columnName] is not string value) + { + throw new ArgumentException("A manual-financial database value is invalid.", nameof(row)); + } + + return value; + } + + private static string RowVersion(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (value.Length != 64 || + value.Any(static character => + !char.IsAsciiDigit(character) && character is not (>= 'A' and <= 'F'))) + { + throw new ArgumentException("A manual-financial row version is invalid.", parameterName); + } + + return value; + } + + private static string Text( + string value, + int maximumLength, + bool allowEmpty, + bool allowUnderscore, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if ((!allowEmpty && value.Length == 0) || + value.Length > maximumLength || + value != value.Trim() || + !IsSafe(value) || + value.Contains('^') || + (!allowUnderscore && value.Contains('_'))) + { + throw new ArgumentException("A manual-financial text value is invalid.", parameterName); + } + + return value; + } + + private static double ParseFinite( + string value, + bool allowThousands, + string parameterName) + { + var styles = NumberStyles.Float | + (allowThousands ? NumberStyles.AllowThousands : NumberStyles.None); + if (!double.TryParse(value, styles, CultureInfo.InvariantCulture, out var parsed)) + { + throw new ArgumentException("A manual-financial number is invalid.", parameterName); + } + + Finite(parsed, parameterName); + return parsed; + } + + private static void Finite(double value, string parameterName) + { + if (!double.IsFinite(value) || Math.Abs(value) > MaximumAbsoluteFloatingValue) + { + throw new ArgumentException("A manual-financial number is outside the safe range.", parameterName); + } + } + + private static bool IsSafe(string value) => + !value.Any(static character => + char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD' || + CharUnicodeInfo.GetUnicodeCategory(character) is + UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator); +} + +internal sealed record ManualFinancialSqlProfile( + ManualFinancialScreenKind Screen, + string TableName, + string SearchQueryName, + string GetQueryName, + string SearchSql, + string GetSql, + string ExclusiveTableLockSql, + string InsertSql, + string UpdateSql, + string DeleteOneSql, + string DeleteAllSql, + IReadOnlyList ResultColumns); + +internal static class ManualFinancialSqlProfiles +{ + private static readonly string[] PieColumns = + ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "ROW_VERSION"]; + private static readonly string[] GrowColumns = + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE", "ROW_VERSION"]; + private static readonly string[] SixValueColumns = + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6", "ROW_VERSION"]; + + private static readonly IReadOnlyDictionary + Profiles = new ReadOnlyDictionary( + new Dictionary + { + [ManualFinancialScreenKind.RevenueComposition] = new( + ManualFinancialScreenKind.RevenueComposition, + "INPUT_PIE", + "MANUAL_FINANCIAL_PIE_SEARCH", + "MANUAL_FINANCIAL_PIE_GET", + PieSearchSql, + PieGetSql, + "LOCK TABLE INPUT_PIE IN EXCLUSIVE MODE", + PieInsertSql, + PieUpdateSql, + PieDeleteOneSql, + "DELETE FROM INPUT_PIE", + Array.AsReadOnly(PieColumns)), + [ManualFinancialScreenKind.GrowthMetrics] = new( + ManualFinancialScreenKind.GrowthMetrics, + "INPUT_GROW", + "MANUAL_FINANCIAL_GROW_SEARCH", + "MANUAL_FINANCIAL_GROW_GET", + GrowSearchSql, + GrowGetSql, + "LOCK TABLE INPUT_GROW IN EXCLUSIVE MODE", + GrowInsertSql, + GrowUpdateSql, + GrowDeleteOneSql, + "DELETE FROM INPUT_GROW", + Array.AsReadOnly(GrowColumns)), + [ManualFinancialScreenKind.Sales] = new( + ManualFinancialScreenKind.Sales, + "INPUT_SELL", + "MANUAL_FINANCIAL_SELL_SEARCH", + "MANUAL_FINANCIAL_SELL_GET", + SellSearchSql, + SellGetSql, + "LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE", + SellInsertSql, + SellUpdateSql, + SellDeleteOneSql, + "DELETE FROM INPUT_SELL", + Array.AsReadOnly(SixValueColumns)), + [ManualFinancialScreenKind.OperatingProfit] = new( + ManualFinancialScreenKind.OperatingProfit, + "INPUT_PROFIT", + "MANUAL_FINANCIAL_PROFIT_SEARCH", + "MANUAL_FINANCIAL_PROFIT_GET", + ProfitSearchSql, + ProfitGetSql, + "LOCK TABLE INPUT_PROFIT IN EXCLUSIVE MODE", + ProfitInsertSql, + ProfitUpdateSql, + ProfitDeleteOneSql, + "DELETE FROM INPUT_PROFIT", + Array.AsReadOnly(SixValueColumns)) + }); + + internal static ManualFinancialSqlProfile Get(ManualFinancialScreenKind screen) => + Profiles.TryGetValue(screen, out var profile) + ? profile + : throw new ArgumentOutOfRangeException(nameof(screen)); + + private const string PieSearchSql = + """ + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION + FROM ( + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_PIE + WHERE (:search_empty = 1 OR + UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') + ORDER BY UPPER(STOCK_NAME), STOCK_NAME + ) + WHERE ROWNUM <= :row_limit + """; + + private const string PieGetSql = + """ + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION + FROM ( + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_PIE + WHERE STOCK_NAME = :stock_name + ) + WHERE ROWNUM <= 2 + """; + + private const string PieInsertSql = + """ + INSERT INTO INPUT_PIE( + STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5) + SELECT :stock_name, :field_1, :field_2, :field_3, :field_4, :field_5, :field_6 + FROM DUAL + WHERE (SELECT COUNT(*) FROM INPUT_PIE WHERE STOCK_NAME = :unique_stock_name) = 0 + """; + + private const string PieUpdateSql = + """ + UPDATE INPUT_PIE + SET BASE_DATE = :field_1, + GUSUNG_1 = :field_2, + GUSUNG_2 = :field_3, + GUSUNG_3 = :field_4, + GUSUNG_4 = :field_5, + GUSUNG_5 = :field_6 + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_PIE WHERE STOCK_NAME = :unique_stock_name) = 1 + """; + + private const string PieDeleteOneSql = + """ + DELETE FROM INPUT_PIE + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_PIE WHERE STOCK_NAME = :unique_stock_name) = 1 + """; + + private const string GrowSearchSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION + FROM ( + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_GROW + WHERE (:search_empty = 1 OR + UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') + ORDER BY UPPER(STOCK_NAME), STOCK_NAME + ) + WHERE ROWNUM <= :row_limit + """; + + private const string GrowGetSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION + FROM ( + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_GROW + WHERE STOCK_NAME = :stock_name + ) + WHERE ROWNUM <= 2 + """; + + private const string GrowInsertSql = + """ + INSERT INTO INPUT_GROW( + STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE) + SELECT :stock_name, :field_1, :field_2, :field_3, :field_4, :field_5 + FROM DUAL + WHERE (SELECT COUNT(*) FROM INPUT_GROW WHERE STOCK_NAME = :unique_stock_name) = 0 + """; + + private const string GrowUpdateSql = + """ + UPDATE INPUT_GROW + SET GUSUNG_1 = :field_1, + GUSUNG_2 = :field_2, + GUSUNG_3 = :field_3, + GUSUNG_4 = :field_4, + BASE_DATE = :field_5 + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_GROW WHERE STOCK_NAME = :unique_stock_name) = 1 + """; + + private const string GrowDeleteOneSql = + """ + DELETE FROM INPUT_GROW + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(BASE_DATE, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_GROW WHERE STOCK_NAME = :unique_stock_name) = 1 + """; + + private const string SellSearchSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + FROM ( + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_SELL + WHERE (:search_empty = 1 OR + UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') + ORDER BY UPPER(STOCK_NAME), STOCK_NAME + ) + WHERE ROWNUM <= :row_limit + """; + + private const string SellGetSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + FROM ( + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_SELL + WHERE STOCK_NAME = :stock_name + ) + WHERE ROWNUM <= 2 + """; + + private const string SellInsertSql = + """ + INSERT INTO INPUT_SELL( + STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6) + SELECT :stock_name, :field_1, :field_2, :field_3, :field_4, :field_5, :field_6 + FROM DUAL + WHERE (SELECT COUNT(*) FROM INPUT_SELL WHERE STOCK_NAME = :unique_stock_name) = 0 + """; + + private const string SellUpdateSql = + """ + UPDATE INPUT_SELL + SET GUSUNG_1 = :field_1, + GUSUNG_2 = :field_2, + GUSUNG_3 = :field_3, + GUSUNG_4 = :field_4, + GUSUNG_5 = :field_5, + GUSUNG_6 = :field_6 + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_SELL WHERE STOCK_NAME = :unique_stock_name) = 1 + """; + + private const string SellDeleteOneSql = + """ + DELETE FROM INPUT_SELL + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_SELL WHERE STOCK_NAME = :unique_stock_name) = 1 + """; + + private const string ProfitSearchSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + FROM ( + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_PROFIT + WHERE (:search_empty = 1 OR + UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') + ORDER BY UPPER(STOCK_NAME), STOCK_NAME + ) + WHERE ROWNUM <= :row_limit + """; + + private const string ProfitGetSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + FROM ( + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, + RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + FROM INPUT_PROFIT + WHERE STOCK_NAME = :stock_name + ) + WHERE ROWNUM <= 2 + """; + + private const string ProfitInsertSql = + """ + INSERT INTO INPUT_PROFIT( + STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6) + SELECT :stock_name, :field_1, :field_2, :field_3, :field_4, :field_5, :field_6 + FROM DUAL + WHERE (SELECT COUNT(*) FROM INPUT_PROFIT WHERE STOCK_NAME = :unique_stock_name) = 0 + """; + + private const string ProfitUpdateSql = + """ + UPDATE INPUT_PROFIT + SET GUSUNG_1 = :field_1, + GUSUNG_2 = :field_2, + GUSUNG_3 = :field_3, + GUSUNG_4 = :field_4, + GUSUNG_5 = :field_5, + GUSUNG_6 = :field_6 + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_PROFIT WHERE STOCK_NAME = :unique_stock_name) = 1 + """; + + private const string ProfitDeleteOneSql = + """ + DELETE FROM INPUT_PROFIT + WHERE STOCK_NAME = :identity_stock_name + AND RAWTOHEX(STANDARD_HASH( + NVL(STOCK_NAME, CHR(0)) || CHR(31) || + NVL(GUSUNG_1, CHR(0)) || CHR(31) || + NVL(GUSUNG_2, CHR(0)) || CHR(31) || + NVL(GUSUNG_3, CHR(0)) || CHR(31) || + NVL(GUSUNG_4, CHR(0)) || CHR(31) || + NVL(GUSUNG_5, CHR(0)) || CHR(31) || + NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND (SELECT COUNT(*) FROM INPUT_PROFIT WHERE STOCK_NAME = :unique_stock_name) = 1 + """; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs new file mode 100644 index 0000000..9e0db0d --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs @@ -0,0 +1,1004 @@ +#nullable enable + +using System.Collections.ObjectModel; +using System.Data; +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MMoneyCoderSharp.Data; + +/// +/// The page value stored in the sixth caret-delimited field of PLAY_LIST.LIST_TEXT. +/// The original application displays pages as one-based values such as 1/4. +/// Historical rows can contain 1/0 or totals above the current 20-page playout +/// bound. They are preserved for compatibility but are never authoritative for +/// scene paging; scene data must calculate the current page plan again. +/// +public sealed record NamedPlaylistPageState +{ + public NamedPlaylistPageState(int currentPage, int totalPages) + { + if (totalPages is < 0 or > LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount || + currentPage < 1 || + totalPages == 0 && currentPage != 1 || + totalPages > 0 && currentPage > totalPages) + { + throw new ArgumentOutOfRangeException( + nameof(currentPage), + "A stored named-playlist page value is invalid."); + } + + CurrentPage = currentPage; + TotalPages = totalPages; + } + + public int CurrentPage { get; } + + public int TotalPages { get; } + + /// + /// True only when the stored display value is compatible with the current + /// maximum-20-page scene contract. False requires a fresh DB page plan before + /// PREPARE and must not be treated as a playable page count. + /// + public bool IsWithinPlayoutBounds => + TotalPages is >= 1 and <= LegacyNamedPlaylistPersistenceService.MaximumPageCount && + CurrentPage <= TotalPages; + + public override string ToString() => + string.Create( + CultureInfo.InvariantCulture, + $"{CurrentPage}/{TotalPages}"); +} + +/// +/// A typed representation of one original FarPoint playlist row. ItemIndex is +/// always zero-based and contiguous. Selection maps exactly to original columns +/// 1, 2, 3, 4 and hidden column 6; Page maps to column 5. +/// +public sealed record NamedPlaylistStoredItem( + int ItemIndex, + bool IsEnabled, + LegacySceneSelection Selection, + NamedPlaylistPageState? Page); + +public sealed record NamedPlaylistSummary(string ProgramCode, string Title); + +public sealed record NamedPlaylistListResult( + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public sealed record NamedPlaylistDocument( + NamedPlaylistSummary Definition, + DateTimeOffset RetrievedAt, + IReadOnlyList Items); + +public enum NamedPlaylistMutationKind +{ + CreateDefinition, + ReplaceItems, + DeletePlaylist +} + +public enum NamedPlaylistDbCommandKind +{ + InsertDefinition, + DeleteItems, + InsertItem, + DeleteDefinition +} + +/// +/// One provider-neutral bind value for a fixed named-playlist mutation command. +/// Values are deliberately not included in ToString so diagnostics cannot +/// accidentally disclose operator-entered content. +/// +public sealed class NamedPlaylistDbParameter +{ + internal NamedPlaylistDbParameter(string name, object? value, DbType dbType) + { + if (!DataQueryParameter.IsValidName(name) || !Enum.IsDefined(dbType)) + { + throw new ArgumentException("A named-playlist parameter is invalid."); + } + + Name = name; + Value = value; + DbType = dbType; + } + + public string Name { get; } + + public object? Value { get; } + + public DbType DbType { get; } + + public override string ToString() => "Named-playlist database parameter"; +} + +/// +/// A command from the closed Oracle DC_LIST/PLAY_LIST command set. Callers +/// cannot construct arbitrary SQL; the persistence service creates commands +/// from fixed statements and bound values only. +/// +public sealed class NamedPlaylistDbCommand +{ + internal NamedPlaylistDbCommand( + NamedPlaylistDbCommandKind kind, + string sql, + IEnumerable parameters) + { + Kind = kind; + Sql = sql; + Parameters = Array.AsReadOnly(parameters.ToArray()); + } + + public NamedPlaylistDbCommandKind Kind { get; } + + public string Sql { get; } + + public IReadOnlyList Parameters { get; } + + public override string ToString() => $"Named-playlist {Kind} command"; +} + +/// +/// An ordered command batch that must be executed once on one Oracle connection +/// and committed as one transaction. An executor must never retry a transaction +/// after a timeout or an otherwise ambiguous result. +/// +public sealed class NamedPlaylistDbTransaction +{ + internal NamedPlaylistDbTransaction( + Guid operationId, + NamedPlaylistMutationKind kind, + string programCode, + IEnumerable commands) + { + OperationId = operationId; + Kind = kind; + ProgramCode = programCode; + Commands = Array.AsReadOnly(commands.ToArray()); + } + + public Guid OperationId { get; } + + public NamedPlaylistMutationKind Kind { get; } + + public string ProgramCode { get; } + + public IReadOnlyList Commands { get; } + + public override string ToString() => $"Named-playlist {Kind} transaction"; +} + +public sealed record NamedPlaylistDbTransactionResult( + Guid OperationId, + IReadOnlyList AffectedRows, + DateTimeOffset CommittedAt); + +/// +/// Infrastructure seam for state-changing Oracle work. Returning means the +/// transaction committed. A known rollback or an ambiguous outcome must be +/// reported with NamedPlaylistMutationException and the appropriate flag. +/// +public interface INamedPlaylistMutationExecutor +{ + Task ExecuteTransactionAsync( + DataSourceKind source, + NamedPlaylistDbTransaction transaction, + CancellationToken cancellationToken = default); +} + +public interface INamedPlaylistPersistenceService +{ + bool CanMutate { get; } + + Task ListAsync( + int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions, + CancellationToken cancellationToken = default); + + Task SuggestNextProgramCodeAsync( + CancellationToken cancellationToken = default); + + Task LoadAsync( + string programCode, + CancellationToken cancellationToken = default); + + Task CreateDefinitionAsync( + string programCode, + string title, + CancellationToken cancellationToken = default); + + Task ReplaceItemsAsync( + string programCode, + IReadOnlyList items, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string programCode, + CancellationToken cancellationToken = default); +} + +public class NamedPlaylistDataException : Exception +{ + public NamedPlaylistDataException(string message) + : base(message) + { + } +} + +public sealed class NamedPlaylistNotFoundException : NamedPlaylistDataException +{ + public NamedPlaylistNotFoundException(string programCode) + : base($"Named playlist {programCode} does not exist.") + { + } +} + +public sealed class NamedPlaylistMutationException : Exception +{ + public NamedPlaylistMutationException( + string message, + bool outcomeUnknown, + Exception? innerException = null) + : base(message, innerException) + { + OutcomeUnknown = outcomeUnknown; + } + + public bool OutcomeUnknown { get; } +} + +/// +/// Typed migration of AList/PList. Reads retain the original DC_LIST title sort, +/// PLAY_LIST item-index sort and seven-field LIST_TEXT representation. Writes +/// preserve that representation but make replace/delete atomic and bind every +/// operator value instead of concatenating SQL. +/// +public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersistenceService +{ + public const int DefaultMaximumDefinitions = 200; + public const int MaximumDefinitions = 1_000; + public const int MaximumItems = 1_000; + public const int MaximumPageCount = 20; + public const int MaximumStoredPageCount = 9_999; + + private const int MaximumTitleLength = 128; + private const int MaximumSelectionFieldLength = 256; + private const int MaximumListTextLength = 4_000; + + internal const string ListQueryName = "NAMED_PLAYLIST_LIST"; + internal const string NextCodeQueryName = "NAMED_PLAYLIST_NEXT_CODE"; + internal const string LoadQueryName = "NAMED_PLAYLIST_LOAD"; + + internal const string ListSql = + """ + SELECT DC_CODE, DC_TITLE + FROM ( + SELECT TRIM(DC_CODE) DC_CODE, + DC_TITLE + FROM DC_LIST + ORDER BY DC_TITLE, DC_CODE + ) + WHERE ROWNUM <= :row_limit + """; + + internal const string NextCodeSql = + """ + SELECT LPAD( + TO_CHAR(NVL(MAX(TO_NUMBER(TRIM(DC_CODE))), 0) + 1), + 8, + '0') DC_CODE + FROM DC_LIST + """; + + internal const string LoadSql = + """ + SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX + FROM ( + SELECT TRIM(d.DC_CODE) DC_CODE, + d.DC_TITLE DC_TITLE, + p.LIST_TEXT LIST_TEXT, + TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX + FROM DC_LIST d + LEFT JOIN PLAY_LIST p + ON p.PG_CODE = d.DC_CODE + WHERE d.DC_CODE = :program_code + ORDER BY TO_NUMBER(p.ITEM_INDEX) + ) + WHERE ROWNUM <= :row_limit + """; + + internal const string InsertDefinitionSql = + "INSERT INTO DC_LIST(DC_CODE, DC_TITLE) VALUES (:program_code, :program_title)"; + + internal const string DeleteItemsSql = + "DELETE FROM PLAY_LIST WHERE PG_CODE = :program_code"; + + internal const string InsertItemSql = + """ + INSERT INTO PLAY_LIST( + PG_CODE, + LIST_TEXT, + ITEM_NAME, + ITEM_CODE, + ITEM_DETAIL, + ITEM_TYPE, + ITEM_ECUT, + ITEM_ESTOCK, + ITEM_COUNT, + ITEM_INPUT, + ITEM_INDEX) + VALUES ( + :program_code, + :list_text, + :item_name, + :item_code, + :item_detail, + :item_type, + :item_ecut, + :item_estock, + :item_count, + :item_input, + :item_index) + """; + + internal const string DeleteDefinitionSql = + "DELETE FROM DC_LIST WHERE DC_CODE = :program_code"; + + private static readonly string[] ListColumns = ["DC_CODE", "DC_TITLE"]; + private static readonly string[] NextCodeColumns = ["DC_CODE"]; + private static readonly string[] LoadColumns = + ["DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX"]; + + private readonly IDataQueryExecutor _queryExecutor; + private readonly INamedPlaylistMutationExecutor? _mutationExecutor; + + public LegacyNamedPlaylistPersistenceService(IDataQueryExecutor queryExecutor) + : this(queryExecutor, null) + { + } + + public LegacyNamedPlaylistPersistenceService( + IDataQueryExecutor queryExecutor, + INamedPlaylistMutationExecutor? mutationExecutor) + { + _queryExecutor = queryExecutor ?? throw new ArgumentNullException(nameof(queryExecutor)); + _mutationExecutor = mutationExecutor; + } + + public bool CanMutate => _mutationExecutor is not null; + + public async Task ListAsync( + int maximumResults = DefaultMaximumDefinitions, + CancellationToken cancellationToken = default) + { + ValidateLimit(maximumResults, MaximumDefinitions, nameof(maximumResults)); + cancellationToken.ThrowIfCancellationRequested(); + + var rowLimit = checked(maximumResults + 1); + var spec = new DataQuerySpec( + ListSql, + [new DataQueryParameter("row_limit", rowLimit, DbType.Int32)]); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await _queryExecutor.ExecuteAsync( + DataSourceKind.Oracle, + ListQueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + ValidateExactStringSchema(table, ListColumns, rowLimit, ListQueryName); + var definitions = new List(table.Rows.Count); + var codes = new HashSet(StringComparer.Ordinal); + foreach (DataRow row in table.Rows) + { + var code = ReadProgramCode(row, 0, ListQueryName); + var title = ReadCanonicalString( + row, + 1, + MaximumTitleLength, + allowEmpty: false, + allowCaret: true, + ListQueryName); + if (!codes.Add(code)) + { + throw InvalidData(ListQueryName, "duplicate program code"); + } + + definitions.Add(new NamedPlaylistSummary(code, title)); + } + + return new NamedPlaylistListResult( + DateTimeOffset.Now, + definitions.Take(maximumResults).ToArray(), + definitions.Count > maximumResults); + } + + public async Task SuggestNextProgramCodeAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var spec = new DataQuerySpec(NextCodeSql); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await _queryExecutor.ExecuteAsync( + DataSourceKind.Oracle, + NextCodeQueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + ValidateExactStringSchema(table, NextCodeColumns, 1, NextCodeQueryName); + if (table.Rows.Count != 1) + { + throw InvalidData(NextCodeQueryName, "result cardinality"); + } + + return ReadProgramCode(table.Rows[0], 0, NextCodeQueryName); + } + + public async Task LoadAsync( + string programCode, + CancellationToken cancellationToken = default) + { + var code = ValidateProgramCode(programCode, nameof(programCode)); + cancellationToken.ThrowIfCancellationRequested(); + + var rowLimit = MaximumItems + 1; + var spec = new DataQuerySpec( + LoadSql, + [ + new DataQueryParameter("program_code", code, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await _queryExecutor.ExecuteAsync( + DataSourceKind.Oracle, + LoadQueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + ValidateExactStringSchema(table, LoadColumns, rowLimit, LoadQueryName); + if (table.Rows.Count == 0) + { + throw new NamedPlaylistNotFoundException(code); + } + + if (table.Rows.Count > MaximumItems) + { + throw InvalidData(LoadQueryName, "item count"); + } + + NamedPlaylistSummary? definition = null; + var items = new List(table.Rows.Count); + var sawEmptySentinel = false; + foreach (DataRow row in table.Rows) + { + var rowCode = ReadProgramCode(row, 0, LoadQueryName); + var title = ReadCanonicalString( + row, + 1, + MaximumTitleLength, + allowEmpty: false, + allowCaret: true, + LoadQueryName); + if (!string.Equals(code, rowCode, StringComparison.Ordinal)) + { + throw InvalidData(LoadQueryName, "program identity"); + } + + var currentDefinition = new NamedPlaylistSummary(rowCode, title); + if (definition is null) + { + definition = currentDefinition; + } + else if (definition != currentDefinition) + { + throw InvalidData(LoadQueryName, "program definition"); + } + + var listTextValue = row[2]; + var itemIndexValue = row[3]; + if (listTextValue is DBNull && itemIndexValue is DBNull) + { + if (table.Rows.Count != 1) + { + throw InvalidData(LoadQueryName, "empty-playlist sentinel"); + } + + sawEmptySentinel = true; + continue; + } + + if (listTextValue is not string listText || itemIndexValue is not string itemIndexText) + { + throw InvalidData(LoadQueryName, "partial item row"); + } + + if (listText.Length is < 1 or > MaximumListTextLength || + !int.TryParse( + itemIndexText, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var itemIndex) || + itemIndex < 0 || + itemIndex >= MaximumItems || + !string.Equals( + itemIndexText, + itemIndex.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal)) + { + throw InvalidData(LoadQueryName, "item index or text"); + } + + items.Add(ParseItem(itemIndex, listText, LoadQueryName)); + } + + if (sawEmptySentinel) + { + items.Clear(); + } + + for (var index = 0; index < items.Count; index++) + { + if (items[index].ItemIndex != index) + { + throw InvalidData(LoadQueryName, "non-contiguous item order"); + } + } + + return new NamedPlaylistDocument( + definition!, + DateTimeOffset.Now, + items.ToArray()); + } + + public Task CreateDefinitionAsync( + string programCode, + string title, + CancellationToken cancellationToken = default) + { + var code = ValidateProgramCode(programCode, nameof(programCode)); + var canonicalTitle = ValidateText( + title, + MaximumTitleLength, + allowEmpty: false, + allowCaret: true, + nameof(title)); + var transaction = new NamedPlaylistDbTransaction( + Guid.NewGuid(), + NamedPlaylistMutationKind.CreateDefinition, + code, + [ + Command( + NamedPlaylistDbCommandKind.InsertDefinition, + InsertDefinitionSql, + StringParameter("program_code", code), + StringParameter("program_title", canonicalTitle)) + ]); + return ExecuteMutationAsync(transaction, cancellationToken); + } + + public Task ReplaceItemsAsync( + string programCode, + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + var code = ValidateProgramCode(programCode, nameof(programCode)); + ArgumentNullException.ThrowIfNull(items); + if (items.Count > MaximumItems) + { + throw new ArgumentOutOfRangeException( + nameof(items), + $"A named playlist can contain at most {MaximumItems} items."); + } + + var commands = new List(items.Count + 1) + { + Command( + NamedPlaylistDbCommandKind.DeleteItems, + DeleteItemsSql, + StringParameter("program_code", code)) + }; + + for (var index = 0; index < items.Count; index++) + { + var item = ValidateItem(items[index], index, nameof(items)); + var listText = SerializeItem(item); + commands.Add(Command( + NamedPlaylistDbCommandKind.InsertItem, + InsertItemSql, + StringParameter("program_code", code), + StringParameter("list_text", listText), + NullStringParameter("item_name"), + NullStringParameter("item_code"), + NullStringParameter("item_detail"), + NullStringParameter("item_type"), + NullStringParameter("item_ecut"), + NullStringParameter("item_estock"), + NullStringParameter("item_count"), + NullStringParameter("item_input"), + new NamedPlaylistDbParameter("item_index", index, DbType.Int32))); + } + + var transaction = new NamedPlaylistDbTransaction( + Guid.NewGuid(), + NamedPlaylistMutationKind.ReplaceItems, + code, + commands); + return ExecuteMutationAsync(transaction, cancellationToken); + } + + public Task DeleteAsync( + string programCode, + CancellationToken cancellationToken = default) + { + var code = ValidateProgramCode(programCode, nameof(programCode)); + // The original PList deletes DC_LIST only and leaves orphaned PLAY_LIST + // rows. The migrated contract removes children first in the same transaction. + var transaction = new NamedPlaylistDbTransaction( + Guid.NewGuid(), + NamedPlaylistMutationKind.DeletePlaylist, + code, + [ + Command( + NamedPlaylistDbCommandKind.DeleteItems, + DeleteItemsSql, + StringParameter("program_code", code)), + Command( + NamedPlaylistDbCommandKind.DeleteDefinition, + DeleteDefinitionSql, + StringParameter("program_code", code)) + ]); + return ExecuteMutationAsync(transaction, cancellationToken); + } + + internal static string SerializeItem(NamedPlaylistStoredItem item) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(item.Selection); + return string.Join( + '^', + item.IsEnabled ? "1" : "0", + item.Selection.GroupCode, + item.Selection.Subject, + item.Selection.GraphicType, + item.Selection.Subtype, + item.Page?.ToString() ?? string.Empty, + item.Selection.DataCode); + } + + private async Task ExecuteMutationAsync( + NamedPlaylistDbTransaction transaction, + CancellationToken cancellationToken) + { + var executor = _mutationExecutor ?? throw new InvalidOperationException( + "Named-playlist persistence is configured for read-only access."); + cancellationToken.ThrowIfCancellationRequested(); + + NamedPlaylistDbTransactionResult result; + try + { + result = await executor.ExecuteTransactionAsync( + DataSourceKind.Oracle, + transaction, + cancellationToken).ConfigureAwait(false); + } + catch (NamedPlaylistMutationException) + { + throw; + } + catch (Exception exception) + { + // The executor did not provide a known-rollback indication. Treat the + // outcome conservatively and never invite an automatic retry. + throw new NamedPlaylistMutationException( + $"The {transaction.Kind} transaction outcome is unknown; do not retry automatically.", + outcomeUnknown: true, + exception); + } + + if (result is null || + result.OperationId != transaction.OperationId || + result.AffectedRows is null || + result.AffectedRows.Count != transaction.Commands.Count || + result.AffectedRows.Any(static count => count < 0)) + { + throw new NamedPlaylistMutationException( + $"The {transaction.Kind} transaction returned an ambiguous commit result; do not retry automatically.", + outcomeUnknown: true); + } + + for (var index = 0; index < transaction.Commands.Count; index++) + { + var command = transaction.Commands[index]; + var affectedRows = result.AffectedRows[index]; + if (command.Kind is (NamedPlaylistDbCommandKind.InsertDefinition or + NamedPlaylistDbCommandKind.InsertItem) && + affectedRows != 1) + { + throw new NamedPlaylistMutationException( + $"The {transaction.Kind} transaction returned an ambiguous inserted-row count; do not retry automatically.", + outcomeUnknown: true); + } + + if (command.Kind == NamedPlaylistDbCommandKind.DeleteDefinition && affectedRows != 1) + { + if (affectedRows == 0) + { + throw new NamedPlaylistNotFoundException(transaction.ProgramCode); + } + + throw new NamedPlaylistMutationException( + $"The {transaction.Kind} transaction returned an ambiguous deleted-row count; do not retry automatically.", + outcomeUnknown: true); + } + } + } + + private static NamedPlaylistStoredItem ParseItem( + int itemIndex, + string listText, + string queryName) + { + if (!IsSafeText(listText)) + { + throw InvalidData(queryName, "LIST_TEXT value"); + } + + var fields = listText.Split('^'); + if (fields.Length != 7 || fields[0] is not ("0" or "1")) + { + throw InvalidData(queryName, "LIST_TEXT shape"); + } + + var selection = new LegacySceneSelection( + ValidateLoadedField(fields[1], queryName), + ValidateLoadedField(fields[2], queryName), + ValidateLoadedField(fields[3], queryName), + ValidateLoadedField(fields[4], queryName), + ValidateLoadedField(fields[6], queryName)); + var page = ParsePage(fields[5], queryName); + return new NamedPlaylistStoredItem(itemIndex, fields[0] == "1", selection, page); + } + + private static NamedPlaylistPageState? ParsePage(string value, string queryName) + { + // PList.data_load removes ASCII spaces from the page column only. + var canonical = value.Replace(" ", string.Empty, StringComparison.Ordinal); + if (canonical.Length == 0) + { + return null; + } + + var separator = canonical.IndexOf('/'); + if (separator < 1 || separator != canonical.LastIndexOf('/') || + !int.TryParse( + canonical.AsSpan(0, separator), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var currentPage) || + !int.TryParse( + canonical.AsSpan(separator + 1), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var totalPages)) + { + throw InvalidData(queryName, "page value"); + } + + try + { + return new NamedPlaylistPageState(currentPage, totalPages); + } + catch (ArgumentOutOfRangeException exception) + { + throw new NamedPlaylistDataException( + $"Named-playlist data from {queryName} contains an invalid page value: {exception.Message}"); + } + } + + private static NamedPlaylistStoredItem ValidateItem( + NamedPlaylistStoredItem? item, + int expectedIndex, + string parameterName) + { + if (item is null || item.ItemIndex != expectedIndex || item.Selection is null) + { + throw new ArgumentException( + "Named-playlist items must have contiguous zero-based indexes.", + parameterName); + } + + var selection = item.Selection; + var canonical = new LegacySceneSelection( + ValidateText( + selection.GroupCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateText( + selection.Subject, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateText( + selection.GraphicType, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateText( + selection.Subtype, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateText( + selection.DataCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName)); + var validated = item with { Selection = canonical }; + if (SerializeItem(validated).Length > MaximumListTextLength) + { + throw new ArgumentException( + $"A serialized named-playlist item must contain at most {MaximumListTextLength} characters.", + parameterName); + } + + return validated; + } + + private static string ValidateLoadedField(string value, string queryName) + { + if (value.Length > MaximumSelectionFieldLength || !IsSafeText(value)) + { + throw InvalidData(queryName, "selection field"); + } + + return value; + } + + private static string ValidateProgramCode(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length != 8 || value.Any(static character => !char.IsAsciiDigit(character))) + { + throw new ArgumentException( + "A named-playlist program code must contain exactly eight digits.", + parameterName); + } + + return value; + } + + private static string ValidateText( + string value, + int maximumLength, + bool allowEmpty, + bool allowCaret, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if ((!allowEmpty && value.Length == 0) || + value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value) || + (!allowCaret && value.Contains('^'))) + { + throw new ArgumentException( + "A named-playlist text value is invalid.", + parameterName); + } + + return value; + } + + private static bool IsSafeText(string value) => + !value.Any(static character => + char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD'); + + private static string ReadProgramCode(DataRow row, int ordinal, string queryName) + { + if (row[ordinal] is not string value) + { + throw InvalidData(queryName, "program code"); + } + + try + { + return ValidateProgramCode(value, nameof(value)); + } + catch (ArgumentException) + { + throw InvalidData(queryName, "program code"); + } + } + + private static string ReadCanonicalString( + DataRow row, + int ordinal, + int maximumLength, + bool allowEmpty, + bool allowCaret, + string queryName) + { + if (row[ordinal] is not string value) + { + throw InvalidData(queryName, "text value"); + } + + try + { + return ValidateText( + value, + maximumLength, + allowEmpty, + allowCaret, + nameof(value)); + } + catch (ArgumentException) + { + throw InvalidData(queryName, "text value"); + } + } + + private static void ValidateExactStringSchema( + DataTable? table, + IReadOnlyList columns, + int rowLimit, + string queryName) + { + if (table is null || table.Columns.Count != columns.Count || table.Rows.Count > rowLimit) + { + throw InvalidData(queryName, "schema or row bound"); + } + + for (var index = 0; index < columns.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + columns[index], + StringComparison.Ordinal) || + table.Columns[index].DataType != typeof(string)) + { + throw InvalidData(queryName, "schema"); + } + } + } + + private static void ValidateLimit(int value, int maximum, string parameterName) + { + if (value is < 1 || value > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"The requested limit must be between 1 and {maximum}."); + } + } + + private static NamedPlaylistDbCommand Command( + NamedPlaylistDbCommandKind kind, + string sql, + params NamedPlaylistDbParameter[] parameters) => + new(kind, sql, parameters); + + private static NamedPlaylistDbParameter StringParameter(string name, string value) => + new(name, value, DbType.String); + + private static NamedPlaylistDbParameter NullStringParameter(string name) => + new(name, null, DbType.String); + + private static NamedPlaylistDataException InvalidData(string queryName, string detail) => + new($"Named-playlist data from {queryName} contains an invalid {detail}."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogMutationContracts.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogMutationContracts.cs new file mode 100644 index 0000000..ca683c2 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogMutationContracts.cs @@ -0,0 +1,195 @@ +#nullable enable + +using System.Collections.ObjectModel; +using System.Data; + +namespace MMoneyCoderSharp.Data; + +public enum OperatorCatalogMutationKind +{ + CreateTheme, + ReplaceTheme, + DeleteTheme, + CreateExpert, + ReplaceExpert, + DeleteExpert +} + +public enum OperatorCatalogDbCommandKind +{ + InsertTheme, + UpdateTheme, + DeleteThemeItems, + InsertThemeItem, + DeleteTheme, + InsertExpert, + UpdateExpert, + DeleteRecommendations, + InsertRecommendation, + DeleteExpert +} + +/// +/// The mutation executor must evaluate this expectation immediately after the +/// command, before it executes the next command or commits the transaction. +/// +public enum OperatorCatalogAffectedRowsRule +{ + AnyNonNegative, + ExactlyOne +} + +/// +/// One bound input value in the closed theme/expert mutation command set. +/// Values are deliberately omitted from diagnostics. +/// +public sealed class OperatorCatalogDbParameter +{ + internal OperatorCatalogDbParameter(string name, object? value, DbType dbType) + { + if (!DataQueryParameter.IsValidName(name) || !Enum.IsDefined(dbType)) + { + throw new ArgumentException("An operator-catalog parameter is invalid."); + } + + Name = name; + Value = value; + DbType = dbType; + } + + public string Name { get; } + + public object? Value { get; } + + public DbType DbType { get; } + + public override string ToString() => "Operator-catalog database parameter"; +} + +/// +/// A command from a closed, provider-specific SQL set. Arbitrary SQL cannot be +/// supplied by WebView or application callers because construction is internal +/// to the Core assembly. +/// +public sealed class OperatorCatalogDbCommand +{ + internal OperatorCatalogDbCommand( + OperatorCatalogDbCommandKind kind, + string sql, + OperatorCatalogAffectedRowsRule affectedRowsRule, + IEnumerable parameters) + { + Kind = kind; + Sql = sql; + AffectedRowsRule = affectedRowsRule; + Parameters = new ReadOnlyCollection(parameters.ToArray()); + } + + public OperatorCatalogDbCommandKind Kind { get; } + + public string Sql { get; } + + public OperatorCatalogAffectedRowsRule AffectedRowsRule { get; } + + public IReadOnlyList Parameters { get; } + + public override string ToString() => $"Operator-catalog {Kind} command"; +} + +/// +/// An ordered mutation that must execute exactly once on one connection under +/// one serializable transaction. Executors must not retry any command or batch. +/// +public sealed class OperatorCatalogDbTransaction +{ + internal OperatorCatalogDbTransaction( + Guid operationId, + OperatorCatalogMutationKind kind, + DataSourceKind source, + string identityCode, + IEnumerable commands) + { + OperationId = operationId; + Kind = kind; + Source = source; + IdentityCode = identityCode; + Commands = new ReadOnlyCollection(commands.ToArray()); + } + + public Guid OperationId { get; } + + public OperatorCatalogMutationKind Kind { get; } + + public DataSourceKind Source { get; } + + public string IdentityCode { get; } + + public IReadOnlyList Commands { get; } + + public override string ToString() => $"Operator-catalog {Kind} transaction"; +} + +public sealed record OperatorCatalogDbTransactionResult( + Guid OperationId, + IReadOnlyList AffectedRows, + DateTimeOffset CommittedAt); + +public sealed record OperatorCatalogMutationReceipt( + Guid OperationId, + OperatorCatalogMutationKind Kind, + DateTimeOffset CommittedAt); + +/// +/// Infrastructure seam for state-changing theme/expert work. Returning means +/// commit completed. Implementations must execute once, check every affected-row +/// rule before continuing, and never apply a transient retry policy. +/// +public interface IOperatorCatalogMutationExecutor +{ + Task ExecuteTransactionAsync( + OperatorCatalogDbTransaction transaction, + CancellationToken cancellationToken = default); +} + +public class OperatorCatalogMutationException : Exception +{ + public OperatorCatalogMutationException( + string message, + bool outcomeUnknown, + Exception? innerException = null) + : base(message, innerException) + { + OutcomeUnknown = outcomeUnknown; + } + + /// + /// True means the operator must reconcile the database manually. The + /// application must never retry that operation automatically. + /// + public bool OutcomeUnknown { get; } +} + +/// +/// A serializable transaction was rolled back because its bound identity, +/// uniqueness precondition, or affected-row expectation no longer matched. +/// The caller must refresh and make a new explicit edit decision. +/// +public sealed class OperatorCatalogConflictException : OperatorCatalogMutationException +{ + public OperatorCatalogConflictException( + OperatorCatalogMutationKind kind, + OperatorCatalogDbCommandKind commandKind, + Exception? innerException = null) + : base( + $"The {kind} operation conflicted with current catalog data and was rolled back; refresh before trying a new operation.", + outcomeUnknown: false, + innerException) + { + Kind = kind; + CommandKind = commandKind; + } + + public OperatorCatalogMutationKind Kind { get; } + + public OperatorCatalogDbCommandKind CommandKind { get; } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs new file mode 100644 index 0000000..b2378c8 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs @@ -0,0 +1,170 @@ +#nullable enable + +using System.Data; + +namespace MMoneyCoderSharp.Data; + +public sealed record OperatorCatalogSchemaValidationResult( + DateTimeOffset ValidatedAt, + IReadOnlyList ValidatedSources); + +public interface IOperatorCatalogSchemaValidationService +{ + Task ValidateAsync( + CancellationToken cancellationToken = default); +} + +/// +/// Read-only schema preflight for the four original CRUD tables. Each query is +/// deliberately constrained by WHERE 1 = 0: providers parse and describe all +/// referenced tables/columns, but no catalog row is read or changed. +/// +public sealed class LegacyOperatorCatalogSchemaValidationService : + IOperatorCatalogSchemaValidationService +{ + internal const string OracleQueryName = "OPERATOR_CATALOG_SCHEMA_ORACLE"; + internal const string MariaDbQueryName = "OPERATOR_CATALOG_SCHEMA_MARIADB"; + + internal const string OracleSql = """ + SELECT l.SB_CODE THEME_CODE, + l.SB_TITLE THEME_TITLE, + l.SB_MARKET THEME_MARKET, + i.SB_M_CODE ITEM_THEME_CODE, + i.SB_I_CODE ITEM_CODE, + i.SB_I_NAME ITEM_NAME, + i.SB_I_INDEX ITEM_INDEX, + e.EP_CODE EXPERT_CODE, + e.EP_NAME EXPERT_NAME, + r.RC_CODE RECOMMEND_EXPERT_CODE, + r.RC_STOCK_CODE STOCK_CODE, + r.RC_STOCK_NAME STOCK_NAME, + r.RC_BUY_AMOUNT BUY_AMOUNT, + r.PLAYINDEX PLAY_INDEX + FROM SB_LIST l + LEFT JOIN SB_ITEM i ON 1 = 0 + LEFT JOIN EXPERT_LIST e ON 1 = 0 + LEFT JOIN RECOMMEND_LIST r ON 1 = 0 + WHERE 1 = 0 + """; + + internal const string MariaDbSql = """ + SELECT l.SB_CODE THEME_CODE, + l.SB_TITLE THEME_TITLE, + l.SB_MARKET THEME_MARKET, + i.SB_M_CODE ITEM_THEME_CODE, + i.SB_I_CODE ITEM_CODE, + i.SB_I_NAME ITEM_NAME, + i.SB_I_INDEX ITEM_INDEX + FROM SB_LIST l + LEFT JOIN SB_ITEM i ON 1 = 0 + WHERE 1 = 0 + """; + + private static readonly string[] OracleColumns = + [ + "THEME_CODE", + "THEME_TITLE", + "THEME_MARKET", + "ITEM_THEME_CODE", + "ITEM_CODE", + "ITEM_NAME", + "ITEM_INDEX", + "EXPERT_CODE", + "EXPERT_NAME", + "RECOMMEND_EXPERT_CODE", + "STOCK_CODE", + "STOCK_NAME", + "BUY_AMOUNT", + "PLAY_INDEX" + ]; + + private static readonly string[] MariaDbColumns = + [ + "THEME_CODE", + "THEME_TITLE", + "THEME_MARKET", + "ITEM_THEME_CODE", + "ITEM_CODE", + "ITEM_NAME", + "ITEM_INDEX" + ]; + + private readonly IDataQueryExecutor _executor; + + public LegacyOperatorCatalogSchemaValidationService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task ValidateAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await ValidateSourceAsync( + DataSourceKind.Oracle, + OracleQueryName, + OracleSql, + OracleColumns, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await ValidateSourceAsync( + DataSourceKind.MariaDb, + MariaDbQueryName, + MariaDbSql, + MariaDbColumns, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return new OperatorCatalogSchemaValidationResult( + DateTimeOffset.Now, + [DataSourceKind.Oracle, DataSourceKind.MariaDb]); + } + + private async Task ValidateSourceAsync( + DataSourceKind source, + string queryName, + string sql, + IReadOnlyList expectedColumns, + CancellationToken cancellationToken) + { + var spec = new DataQuerySpec(sql); + spec.ValidateFor(source); + var table = await _executor.ExecuteAsync( + source, + queryName, + spec, + cancellationToken).ConfigureAwait(false); + + if (table is null || table.Rows.Count != 0 || + table.Columns.Count != expectedColumns.Count) + { + throw new OperatorCatalogSchemaException( + source, + "The read-only schema preflight returned an unexpected shape."); + } + + for (var index = 0; index < expectedColumns.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + expectedColumns[index], + StringComparison.Ordinal)) + { + throw new OperatorCatalogSchemaException( + source, + "The read-only schema preflight returned an unexpected column."); + } + } + } +} + +public sealed class OperatorCatalogSchemaException : Exception +{ + public OperatorCatalogSchemaException(DataSourceKind source, string message) + : base($"{source} operator-catalog schema validation failed. {message}") + { + DataSource = source; + } + + public DataSourceKind DataSource { get; } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs new file mode 100644 index 0000000..0ebf3d3 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs @@ -0,0 +1,242 @@ +#nullable enable + +using System.Data; +using System.Globalization; + +namespace MMoneyCoderSharp.Data; + +/// +/// Exact US foreign-industry/index identity from T_WORLD_IX_EQ_MASTER. +/// KoreanName is F_KNAM (the one-column lookup key), InputName is +/// F_INPUT_NAME (the candle lookup key), and Symbol is F_SYMB. +/// +public sealed record OverseasIndustryIndexItem( + string KoreanName, + string InputName, + string Symbol); + +public sealed record OverseasIndustryIndexSearchResult( + string Query, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public interface IOverseasIndustryIndexSearchService +{ + Task SearchAsync( + string query, + int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default); +} + +public sealed class OverseasIndustryIndexSearchDataException : Exception +{ + public OverseasIndustryIndexSearchDataException(string message) + : base(message) + { + } +} + +/// +/// Parameterized, read-only migration of UC5.upjong_search. Blank input keeps +/// the original initial-list behavior while all non-blank LIKE metacharacters +/// remain data through an escaped Oracle bind value. +/// +public sealed class LegacyOverseasIndustryIndexSearchService + : IOverseasIndustryIndexSearchService +{ + public const int DefaultMaximumResults = 100; + public const int MaximumResults = 500; + public const int MaximumQueryLength = 64; + + private const int MaximumKoreanNameLength = 200; + private const int MaximumInputNameLength = 200; + private const int MaximumSymbolLength = 64; + private const string QueryName = "OVERSEAS_INDUSTRY_INDEX_SEARCH"; + + private static readonly string[] ResultColumns = + ["F_KNAM", "F_INPUT_NAME", "F_SYMB"]; + + private const string SearchSql = """ + SELECT F_KNAM, F_INPUT_NAME, F_SYMB + FROM ( + SELECT F_KNAM, F_INPUT_NAME, F_SYMB + FROM ( + SELECT F_KNAM, F_INPUT_NAME, F_SYMB, + COUNT(*) OVER (PARTITION BY F_KNAM) AS KNAM_COUNT, + COUNT(*) OVER (PARTITION BY F_INPUT_NAME) AS INPUT_NAME_COUNT, + COUNT(*) OVER (PARTITION BY F_SYMB) AS SYMBOL_COUNT + FROM T_WORLD_IX_EQ_MASTER + WHERE F_FDTC = '0' + AND F_NATC = 'US' + ) + WHERE KNAM_COUNT = 1 + AND INPUT_NAME_COUNT = 1 + AND SYMBOL_COUNT = 1 + AND UPPER(F_KNAM) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(F_KNAM), F_INPUT_NAME, F_SYMB + ) + WHERE ROWNUM <= :row_limit + """; + + private readonly IDataQueryExecutor _executor; + + public LegacyOverseasIndustryIndexSearchService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task SearchAsync( + string query, + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = ValidateAndNormalizeQuery(query); + if (maximumResults is < 1 or > MaximumResults) + { + throw new ArgumentOutOfRangeException( + nameof(maximumResults), + $"The requested result limit must be between 1 and {MaximumResults}."); + } + + cancellationToken.ThrowIfCancellationRequested(); + var rowLimit = checked(maximumResults + 1); + var spec = CreateQuerySpec( + EscapeLikePattern(normalizedQuery.ToUpperInvariant()), + rowLimit); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + QueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var matches = ValidateRows(table, rowLimit); + return new OverseasIndustryIndexSearchResult( + normalizedQuery, + DateTimeOffset.Now, + matches.Take(maximumResults).ToArray(), + matches.Count > maximumResults); + } + + private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit) + { + var spec = new DataQuerySpec( + SearchSql, + [ + new DataQueryParameter("search_term", escapedQuery, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(DataSourceKind.Oracle); + return spec; + } + + private static string ValidateAndNormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsSafeText(query)) + { + throw new ArgumentException("The overseas selector query is invalid.", nameof(query)); + } + + var normalized = query.Trim(); + if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized)) + { + throw new ArgumentException( + $"An overseas selector query must contain at most {MaximumQueryLength} visible characters.", + nameof(query)); + } + + return normalized; + } + + private static IReadOnlyList ValidateRows( + DataTable? table, + int rowLimit) + { + if (table is null || + table.Columns.Count != ResultColumns.Length || + table.Rows.Count > rowLimit) + { + throw InvalidData("schema or row bound"); + } + + for (var index = 0; index < ResultColumns.Length; index++) + { + var column = table.Columns[index]; + if (!string.Equals( + column.ColumnName, + ResultColumns[index], + StringComparison.Ordinal) || + column.DataType != typeof(string)) + { + throw InvalidData("schema"); + } + } + + var koreanNames = new HashSet(StringComparer.Ordinal); + var inputNames = new HashSet(StringComparer.Ordinal); + var symbols = new HashSet(StringComparer.Ordinal); + var results = new List(table.Rows.Count); + foreach (DataRow row in table.Rows) + { + var koreanName = ReadCanonicalValue(row, 0, MaximumKoreanNameLength); + var inputName = ReadCanonicalValue(row, 1, MaximumInputNameLength); + var symbol = ReadCanonicalValue(row, 2, MaximumSymbolLength); + + // Current one-column, candle, and quote loaders use F_KNAM, + // F_INPUT_NAME, and F_SYMB respectively. A duplicate in any key can + // resolve a different master row from the operator's chosen entry. + if (!koreanNames.Add(koreanName) || + !inputNames.Add(inputName) || + !symbols.Add(symbol)) + { + throw InvalidData("ambiguous duplicate downstream lookup key"); + } + + results.Add(new OverseasIndustryIndexItem(koreanName, inputName, symbol)); + } + + return results; + } + + private static string ReadCanonicalValue(DataRow row, int ordinal, int maximumLength) + { + if (row[ordinal] is not string value || + value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value)) + { + throw InvalidData("value"); + } + + return value; + } + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static bool IsSafeText(string value) + { + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static OverseasIndustryIndexSearchDataException InvalidData(string detail) => + new($"Overseas industry/index data from {QueryName} contains an invalid {detail}."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs new file mode 100644 index 0000000..57c7e6a --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs @@ -0,0 +1,281 @@ +#nullable enable + +using System.Data; +using System.Globalization; + +namespace MMoneyCoderSharp.Data; + +public enum StockMarket +{ + Kospi, + Kosdaq, + NxtKospi, + NxtKosdaq +} + +public sealed record StockSearchItem( + StockMarket Market, + DataSourceKind Source, + string Name, + string Code); + +public sealed record StockSearchResult( + string Query, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public interface IStockSearchService +{ + Task SearchAsync( + string query, + int maximumResults = LegacyStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default); +} + +public sealed class StockSearchDataException : Exception +{ + public StockSearchDataException(string message) + : base(message) + { + } +} + +/// +/// Searches the four domestic stock masters used by the legacy operator lookup. +/// Input is always bound, LIKE metacharacters are escaped, and every provider +/// result must match the two-column contract before any value reaches the UI. +/// +public sealed class LegacyStockSearchService : IStockSearchService +{ + public const int DefaultMaximumResults = 100; + public const int MaximumResults = 500; + public const int MaximumQueryLength = 64; + + private const int MaximumStockNameLength = 200; + private const int MaximumStockCodeLength = 32; + private const string StockNameColumn = "STOCK_NAME"; + private const string StockCodeColumn = "STOCK_CODE"; + + private readonly IDataQueryExecutor _executor; + + public LegacyStockSearchService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task SearchAsync( + string query, + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = ValidateAndNormalizeQuery(query); + if (maximumResults is < 1 or > MaximumResults) + { + throw new ArgumentOutOfRangeException( + nameof(maximumResults), + $"The requested result limit must be between 1 and {MaximumResults}."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); + var perMarketLimit = checked(maximumResults + 1); + var matches = new List(perMarketLimit * StockSearchQueries.All.Count); + + // Keep provider load predictable and cancellation responsive. The shared + // application database gate grants this whole operation one read slot. + foreach (var profile in StockSearchQueries.All) + { + cancellationToken.ThrowIfCancellationRequested(); + var spec = profile.CreateSpec(escapedQuery, perMarketLimit); + var table = await _executor + .ExecuteAsync(profile.Source, profile.TableName, spec, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + AppendValidatedRows(table, profile, matches); + } + + var ordered = matches + .OrderBy(static item => item.Market) + .ThenBy(static item => item.Name, StringComparer.Ordinal) + .ThenBy(static item => item.Code, StringComparer.Ordinal) + .Take(maximumResults) + .ToArray(); + + return new StockSearchResult( + normalizedQuery, + DateTimeOffset.Now, + ordered, + matches.Count > maximumResults); + } + + private static string ValidateAndNormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var normalized = query.Trim(); + if (!IsSafeText(query) || + normalized.Length is < 1 or > MaximumQueryLength || + !IsSafeText(normalized)) + { + throw new ArgumentException( + $"A stock search query must contain 1 to {MaximumQueryLength} visible characters.", + nameof(query)); + } + + return normalized; + } + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static void AppendValidatedRows( + DataTable? table, + StockSearchQueryProfile profile, + ICollection destination) + { + if (table is null || + table.Columns.Count != 2 || + !HasExactStringColumn(table.Columns[0], StockNameColumn) || + !HasExactStringColumn(table.Columns[1], StockCodeColumn)) + { + throw InvalidSchema(profile.TableName); + } + + foreach (DataRow row in table.Rows) + { + if (row[0] is not string rawName || row[1] is not string rawCode) + { + throw InvalidSchema(profile.TableName); + } + + var name = rawName.Trim(); + var code = rawCode.Trim(); + if (!IsSafeDatabaseValue(name, MaximumStockNameLength) || + !IsSafeDatabaseValue(code, MaximumStockCodeLength)) + { + throw new StockSearchDataException( + $"Stock search data from {profile.TableName} contains an invalid value."); + } + + destination.Add(new StockSearchItem(profile.Market, profile.Source, name, code)); + } + } + + private static bool HasExactStringColumn(DataColumn column, string name) => + string.Equals(column.ColumnName, name, StringComparison.Ordinal) && + column.DataType == typeof(string); + + private static bool IsSafeDatabaseValue(string value, int maximumLength) => + value.Length is > 0 && value.Length <= maximumLength && IsSafeText(value); + + private static bool IsSafeText(string value) + { + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static StockSearchDataException InvalidSchema(string tableName) => + new($"Stock search data from {tableName} does not match the required schema."); +} + +internal sealed record StockSearchQueryProfile( + StockMarket Market, + DataSourceKind Source, + string TableName, + string Sql) +{ + internal DataQuerySpec CreateSpec(string escapedQuery, int rowLimit) + { + var queryParameter = new DataQueryParameter( + "search_term", + escapedQuery, + DbType.String); + var limitParameter = new DataQueryParameter( + "row_limit", + rowLimit, + DbType.Int32); + var spec = new DataQuerySpec(Sql, [queryParameter, limitParameter]); + spec.ValidateFor(Source); + return spec; + } +} + +internal static class StockSearchQueries +{ + internal static IReadOnlyList All { get; } = + [ + new( + StockMarket.Kospi, + DataSourceKind.Oracle, + "STOCK_SEARCH_KOSPI", + """ + SELECT STOCK_NAME, STOCK_CODE + FROM ( + SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE + FROM T_STOCK + WHERE F_MKT_HALT = 'N' + AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(F_STOCK_WANNAME), F_STOCK_CODE + ) + WHERE ROWNUM <= :row_limit + """), + new( + StockMarket.Kosdaq, + DataSourceKind.Oracle, + "STOCK_SEARCH_KOSDAQ", + """ + SELECT STOCK_NAME, STOCK_CODE + FROM ( + SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE + FROM T_KOSDAQ_STOCK + WHERE F_MKT_HALT = 'N' + AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(F_STOCK_WANNAME), F_STOCK_CODE + ) + WHERE ROWNUM <= :row_limit + """), + new( + StockMarket.NxtKospi, + DataSourceKind.MariaDb, + "STOCK_SEARCH_NXT_KOSPI", + """ + SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE + FROM N_STOCK a + INNER JOIN N_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE + WHERE a.F_STOP_GUBUN = 'N' + AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%') ESCAPE '!' + ORDER BY UPPER(a.F_STOCK_NAME), a.F_STOCK_CODE + LIMIT @row_limit + """), + new( + StockMarket.NxtKosdaq, + DataSourceKind.MariaDb, + "STOCK_SEARCH_NXT_KOSDAQ", + """ + SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE + FROM N_KOSDAQ_STOCK a + INNER JOIN N_KOSDAQ_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE + WHERE a.F_STOP_GUBUN = 'N' + AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%') ESCAPE '!' + ORDER BY UPPER(a.F_STOCK_NAME), a.F_STOCK_CODE + LIMIT @row_limit + """) + ]; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs new file mode 100644 index 0000000..3fec9b5 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs @@ -0,0 +1,587 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace MMoneyCoderSharp.Data; + +public sealed record ThemeCatalogItem( + int InputIndex, + string ItemCode, + string ItemName); + +public sealed record ThemeCatalogDefinition( + ThemeMarket Market, + string ThemeCode, + string ThemeTitle, + IReadOnlyList Items); + +public interface IThemeCatalogPersistenceService +{ + bool CanMutate { get; } + + Task SuggestNextCodeAsync( + ThemeMarket market, + CancellationToken cancellationToken = default); + + Task CreateAsync( + ThemeCatalogDefinition definition, + CancellationToken cancellationToken = default); + + Task ReplaceAsync( + ThemeSelectionIdentity expectedIdentity, + string newTitle, + IReadOnlyList items, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + ThemeSelectionIdentity expectedIdentity, + CancellationToken cancellationToken = default); +} + +/// +/// Atomic migration of ThemeA/UC4 theme CRUD. KRX stays on Oracle and NXT stays +/// on MariaDB. Suggested codes are advisory; create uses serializable, +/// conditional insertion so a stale suggestion cannot overwrite another theme. +/// +public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalogPersistenceService +{ + public const int MaximumItems = 240; + + internal const string NextKrxCodeQueryName = "THEME_NEXT_CODE_KRX"; + internal const string NextNxtCodeQueryName = "THEME_NEXT_CODE_NXT"; + + private const int MaximumThemeTitleLength = 128; + private const int MaximumItemNameLength = 200; + + private const string NextKrxCodeSql = """ + SELECT CASE + WHEN NVL(MAX(TO_NUMBER(TRIM(SB_CODE))), 0) < 99999999 + THEN LPAD( + TO_CHAR(NVL(MAX(TO_NUMBER(TRIM(SB_CODE))), 0) + 1), + 8, + '0') + ELSE 'EXHAUSTED' + END THEME_CODE + FROM SB_LIST + """; + + private const string NextNxtCodeSql = """ + SELECT CASE + WHEN SUM( + CASE + WHEN TRIM(SB_CODE) REGEXP '^[0-9]{8}$' THEN 0 + ELSE 1 + END) > 0 + THEN 'INVALID' + WHEN COALESCE(MAX(CAST(TRIM(SB_CODE) AS UNSIGNED)), 0) < 99999999 + THEN LPAD( + CAST(COALESCE(MAX(CAST(TRIM(SB_CODE) AS UNSIGNED)), 0) + 1 AS CHAR), + 8, + '0') + ELSE 'EXHAUSTED' + END THEME_CODE + FROM SB_LIST + """; + + private readonly IDataQueryExecutor _queryExecutor; + private readonly IOperatorCatalogMutationExecutor? _mutationExecutor; + + public LegacyThemeCatalogPersistenceService(IDataQueryExecutor queryExecutor) + : this(queryExecutor, null) + { + } + + public LegacyThemeCatalogPersistenceService( + IDataQueryExecutor queryExecutor, + IOperatorCatalogMutationExecutor? mutationExecutor) + { + _queryExecutor = queryExecutor ?? throw new ArgumentNullException(nameof(queryExecutor)); + _mutationExecutor = mutationExecutor; + } + + public bool CanMutate => _mutationExecutor is not null; + + public async Task SuggestNextCodeAsync( + ThemeMarket market, + CancellationToken cancellationToken = default) + { + var profile = ThemeMutationProfiles.For(market); + cancellationToken.ThrowIfCancellationRequested(); + var query = new DataQuerySpec( + market == ThemeMarket.Krx ? NextKrxCodeSql : NextNxtCodeSql); + query.ValidateFor(profile.Source); + var table = await _queryExecutor.ExecuteAsync( + profile.Source, + market == ThemeMarket.Krx ? NextKrxCodeQueryName : NextNxtCodeQueryName, + query, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + if (table is null || + table.Columns.Count != 1 || + !string.Equals(table.Columns[0].ColumnName, "THEME_CODE", StringComparison.Ordinal) || + table.Columns[0].DataType != typeof(string) || + table.Rows.Count != 1 || + table.Rows[0][0] is not string code || + !ThemeCodePattern().IsMatch(code)) + { + throw new ThemeSelectionDataException( + "Theme next-code data has an invalid schema, value, or exhausted code range."); + } + + return code; + } + + public Task CreateAsync( + ThemeCatalogDefinition definition, + CancellationToken cancellationToken = default) + { + var canonical = ValidateDefinition(definition, nameof(definition)); + var profile = ThemeMutationProfiles.For(canonical.Market); + var commands = new List(canonical.Items.Count + 1) + { + profile.CreateThemeCommand(canonical.ThemeCode, canonical.ThemeTitle) + }; + commands.AddRange(canonical.Items.Select(item => + profile.CreateItemCommand(canonical.ThemeCode, item))); + + return ExecuteAsync( + new OperatorCatalogDbTransaction( + Guid.NewGuid(), + OperatorCatalogMutationKind.CreateTheme, + profile.Source, + canonical.ThemeCode, + commands), + cancellationToken); + } + + public Task ReplaceAsync( + ThemeSelectionIdentity expectedIdentity, + string newTitle, + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity)); + var title = ValidateThemeTitle(newTitle, expected.Market, nameof(newTitle)); + var canonicalItems = ValidateItems(items, expected.Market, nameof(items)); + var profile = ThemeMutationProfiles.For(expected.Market); + var commands = new List(canonicalItems.Count + 2) + { + profile.CreateUpdateCommand(expected, title), + profile.CreateDeleteItemsCommand(expected.ThemeCode) + }; + commands.AddRange(canonicalItems.Select(item => + profile.CreateItemCommand(expected.ThemeCode, item))); + + return ExecuteAsync( + new OperatorCatalogDbTransaction( + Guid.NewGuid(), + OperatorCatalogMutationKind.ReplaceTheme, + profile.Source, + expected.ThemeCode, + commands), + cancellationToken); + } + + public Task DeleteAsync( + ThemeSelectionIdentity expectedIdentity, + CancellationToken cancellationToken = default) + { + var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity)); + var profile = ThemeMutationProfiles.For(expected.Market); + var transaction = new OperatorCatalogDbTransaction( + Guid.NewGuid(), + OperatorCatalogMutationKind.DeleteTheme, + profile.Source, + expected.ThemeCode, + [ + profile.CreateDeleteItemsCommand(expected.ThemeCode), + profile.CreateDeleteThemeCommand(expected) + ]); + return ExecuteAsync(transaction, cancellationToken); + } + + private async Task ExecuteAsync( + OperatorCatalogDbTransaction transaction, + CancellationToken cancellationToken) + { + var executor = _mutationExecutor ?? throw new InvalidOperationException( + "Theme catalog persistence is configured for read-only access."); + cancellationToken.ThrowIfCancellationRequested(); + + OperatorCatalogDbTransactionResult result; + try + { + result = await executor.ExecuteTransactionAsync(transaction, cancellationToken) + .ConfigureAwait(false); + } + catch (OperatorCatalogMutationException) + { + throw; + } + catch (Exception exception) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction outcome is unknown; do not retry automatically.", + outcomeUnknown: true, + exception); + } + + if (result is null || + result.OperationId != transaction.OperationId || + result.AffectedRows is null || + result.AffectedRows.Count != transaction.Commands.Count || + result.AffectedRows.Any(static count => count < 0)) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction returned an ambiguous commit receipt; do not retry automatically.", + outcomeUnknown: true); + } + + for (var index = 0; index < transaction.Commands.Count; index++) + { + if (transaction.Commands[index].AffectedRowsRule == + OperatorCatalogAffectedRowsRule.ExactlyOne && + result.AffectedRows[index] != 1) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction returned an ambiguous affected-row receipt; do not retry automatically.", + outcomeUnknown: true); + } + } + + return new OperatorCatalogMutationReceipt( + result.OperationId, + transaction.Kind, + result.CommittedAt); + } + + private static ThemeCatalogDefinition ValidateDefinition( + ThemeCatalogDefinition definition, + string parameterName) + { + ArgumentNullException.ThrowIfNull(definition); + if (!Enum.IsDefined(definition.Market)) + { + throw new ArgumentOutOfRangeException(parameterName, "The theme market is invalid."); + } + + var code = ValidateThemeCode(definition.ThemeCode, parameterName); + var title = ValidateThemeTitle(definition.ThemeTitle, definition.Market, parameterName); + var items = ValidateItems(definition.Items, definition.Market, parameterName); + return new ThemeCatalogDefinition(definition.Market, code, title, items); + } + + private static ThemeSelectionIdentity ValidateIdentity( + ThemeSelectionIdentity identity, + string parameterName) + { + ArgumentNullException.ThrowIfNull(identity); + if (!Enum.IsDefined(identity.Market) || !Enum.IsDefined(identity.Session) || + identity.Market == ThemeMarket.Krx && identity.Session != ThemeSession.NotApplicable || + identity.Market == ThemeMarket.Nxt && + identity.Session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) + { + throw new ArgumentException("The theme identity is invalid.", parameterName); + } + + return identity with + { + ThemeCode = ValidateThemeCode(identity.ThemeCode, parameterName), + ThemeTitle = ValidateThemeTitle(identity.ThemeTitle, identity.Market, parameterName) + }; + } + + private static IReadOnlyList ValidateItems( + IReadOnlyList items, + ThemeMarket market, + string parameterName) + { + ArgumentNullException.ThrowIfNull(items); + if (items.Count > MaximumItems) + { + throw new ArgumentOutOfRangeException( + parameterName, + $"A theme can contain at most {MaximumItems} items."); + } + + var codes = new HashSet(StringComparer.Ordinal); + var names = new HashSet(StringComparer.Ordinal); + var canonical = new ThemeCatalogItem[items.Count]; + for (var index = 0; index < items.Count; index++) + { + var item = items[index] ?? throw new ArgumentException( + "A theme item is missing.", + parameterName); + if (item.InputIndex != index) + { + throw new ArgumentException( + "Theme item indexes must be contiguous and zero based.", + parameterName); + } + + var code = ValidateCanonicalText(item.ItemCode, 13, parameterName); + var codePattern = market == ThemeMarket.Krx + ? KrxItemCodePattern() + : NxtItemCodePattern(); + if (!codePattern.IsMatch(code)) + { + throw new ArgumentException( + "A theme item code does not match its market.", + parameterName); + } + + var name = ValidateCanonicalText( + item.ItemName, + MaximumItemNameLength, + parameterName); + if (!codes.Add(code) || !names.Add(name)) + { + throw new ArgumentException( + "Theme item codes and names must each be unique.", + parameterName); + } + + canonical[index] = new ThemeCatalogItem(index, code, name); + } + + return Array.AsReadOnly(canonical); + } + + private static string ValidateThemeCode(string value, string parameterName) + { + var code = ValidateCanonicalText(value, 8, parameterName); + if (!ThemeCodePattern().IsMatch(code)) + { + throw new ArgumentException("A theme code must contain eight digits.", parameterName); + } + + return code; + } + + private static string ValidateThemeTitle( + string value, + ThemeMarket market, + string parameterName) + { + var title = ValidateCanonicalText(value, MaximumThemeTitleLength, parameterName); + if (market == ThemeMarket.Nxt && + title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "The NXT display suffix is not part of the stored theme title.", + parameterName); + } + + return title; + } + + private static string ValidateCanonicalText( + string value, + int maximumLength, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value)) + { + throw new ArgumentException("A theme catalog value is invalid.", parameterName); + } + + return value; + } + + private static bool IsSafeText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + [GeneratedRegex("^[0-9]{8}$", RegexOptions.CultureInvariant)] + private static partial Regex ThemeCodePattern(); + + [GeneratedRegex("^[PD][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)] + private static partial Regex KrxItemCodePattern(); + + [GeneratedRegex("^[XT][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)] + private static partial Regex NxtItemCodePattern(); +} + +internal sealed record ThemeMutationProfile( + ThemeMarket Market, + DataSourceKind Source, + string DatabaseMarket, + char Marker, + string CreateSql, + string UpdateSql, + string DeleteItemsSql, + string InsertItemSql, + string DeleteSql) +{ + internal OperatorCatalogDbCommand CreateThemeCommand(string code, string title) => + Command( + OperatorCatalogDbCommandKind.InsertTheme, + CreateSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("theme_code", code), + String("theme_title", title), + String("conflict_code", code), + String("conflict_title", title)); + + internal OperatorCatalogDbCommand CreateUpdateCommand( + ThemeSelectionIdentity expected, + string newTitle) => + Command( + OperatorCatalogDbCommandKind.UpdateTheme, + UpdateSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("new_title", newTitle), + String("theme_code", expected.ThemeCode), + String("expected_title", expected.ThemeTitle), + String("duplicate_title", newTitle), + String("other_code", expected.ThemeCode)); + + internal OperatorCatalogDbCommand CreateDeleteItemsCommand(string code) => + Command( + OperatorCatalogDbCommandKind.DeleteThemeItems, + DeleteItemsSql, + OperatorCatalogAffectedRowsRule.AnyNonNegative, + String("theme_code", code)); + + internal OperatorCatalogDbCommand CreateItemCommand( + string code, + ThemeCatalogItem item) => + Command( + OperatorCatalogDbCommandKind.InsertThemeItem, + InsertItemSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("theme_code", code), + String("item_code", item.ItemCode), + String("item_name", item.ItemName), + new OperatorCatalogDbParameter("item_index", item.InputIndex, DbType.Int32)); + + internal OperatorCatalogDbCommand CreateDeleteThemeCommand(ThemeSelectionIdentity expected) => + Command( + OperatorCatalogDbCommandKind.DeleteTheme, + DeleteSql, + OperatorCatalogAffectedRowsRule.ExactlyOne, + String("theme_code", expected.ThemeCode), + String("expected_title", expected.ThemeTitle)); + + private static OperatorCatalogDbParameter String(string name, string value) => + new(name, value, DbType.String); + + private static OperatorCatalogDbCommand Command( + OperatorCatalogDbCommandKind kind, + string sql, + OperatorCatalogAffectedRowsRule rule, + params OperatorCatalogDbParameter[] parameters) => + new(kind, sql, rule, parameters); +} + +internal static class ThemeMutationProfiles +{ + private static readonly ThemeMutationProfile Krx = new( + ThemeMarket.Krx, + DataSourceKind.Oracle, + "KRX", + ':', + """ + INSERT INTO SB_LIST(SB_CODE, SB_TITLE, SB_MARKET) + SELECT :theme_code, :theme_title, 'KRX' + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM SB_LIST WHERE SB_CODE = :conflict_code) + AND NOT EXISTS ( + SELECT 1 + FROM SB_LIST + WHERE SB_MARKET = 'KRX' + AND SB_TITLE = :conflict_title) + """, + """ + UPDATE SB_LIST target + SET SB_TITLE = :new_title + WHERE target.SB_CODE = :theme_code + AND target.SB_TITLE = :expected_title + AND target.SB_MARKET = 'KRX' + AND NOT EXISTS ( + SELECT 1 + FROM SB_LIST other_row + WHERE other_row.SB_MARKET = 'KRX' + AND other_row.SB_TITLE = :duplicate_title + AND other_row.SB_CODE <> :other_code) + """, + "DELETE FROM SB_ITEM WHERE SB_M_CODE = :theme_code", + """ + INSERT INTO SB_ITEM(SB_M_CODE, SB_I_CODE, SB_I_NAME, SB_I_INDEX) + VALUES (:theme_code, :item_code, :item_name, :item_index) + """, + """ + DELETE FROM SB_LIST + WHERE SB_CODE = :theme_code + AND SB_TITLE = :expected_title + AND SB_MARKET = 'KRX' + """); + + private static readonly ThemeMutationProfile Nxt = new( + ThemeMarket.Nxt, + DataSourceKind.MariaDb, + "NXT", + '@', + """ + INSERT INTO SB_LIST(SB_CODE, SB_TITLE, SB_MARKET) + SELECT @theme_code, @theme_title, 'NXT' + WHERE NOT EXISTS ( + SELECT 1 FROM SB_LIST WHERE SB_CODE = @conflict_code) + AND NOT EXISTS ( + SELECT 1 + FROM SB_LIST + WHERE SB_MARKET = 'NXT' + AND SB_TITLE = @conflict_title) + """, + """ + UPDATE SB_LIST target + LEFT JOIN SB_LIST other_row + ON other_row.SB_MARKET = 'NXT' + AND other_row.SB_TITLE = @duplicate_title + AND other_row.SB_CODE <> @other_code + SET target.SB_TITLE = @new_title + WHERE target.SB_CODE = @theme_code + AND target.SB_TITLE = @expected_title + AND target.SB_MARKET = 'NXT' + AND other_row.SB_CODE IS NULL + """, + "DELETE FROM SB_ITEM WHERE SB_M_CODE = @theme_code", + """ + INSERT INTO SB_ITEM(SB_M_CODE, SB_I_CODE, SB_I_NAME, SB_I_INDEX) + VALUES (@theme_code, @item_code, @item_name, @item_index) + """, + """ + DELETE FROM SB_LIST + WHERE SB_CODE = @theme_code + AND SB_TITLE = @expected_title + AND SB_MARKET = 'NXT' + """); + + internal static ThemeMutationProfile For(ThemeMarket market) => market switch + { + ThemeMarket.Krx => Krx, + ThemeMarket.Nxt => Nxt, + _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported theme market.") + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs new file mode 100644 index 0000000..b96003a --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs @@ -0,0 +1,620 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace MMoneyCoderSharp.Data; + +public enum ThemeMarket +{ + Krx, + Nxt +} + +public enum ThemeSession +{ + NotApplicable, + PreMarket, + AfterMarket +} + +/// +/// Closed theme identity carried from selector to preview and eventually to the +/// paged scene request. Market and NXT session are explicit; the UI must never +/// infer either value from a display-title suffix. +/// +public sealed record ThemeSelectionIdentity( + ThemeMarket Market, + ThemeSession Session, + string ThemeCode, + string ThemeTitle); + +public sealed record ThemeSelectorItem( + ThemeSelectionIdentity Identity, + DataSourceKind Source); + +public sealed record ThemeSearchResult( + string Query, + ThemeSession NxtSession, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public sealed record ThemeItemPreview( + int InputIndex, + string ItemCode, + string ItemName); + +public sealed record ThemePreviewResult( + ThemeSelectionIdentity Identity, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public interface IThemeSelectionService +{ + Task SearchAsync( + string query, + ThemeSession nxtSession, + int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults, + CancellationToken cancellationToken = default); + + Task PreviewAsync( + ThemeSelectionIdentity identity, + int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems, + CancellationToken cancellationToken = default); +} + +public sealed class ThemeSelectionDataException : Exception +{ + public ThemeSelectionDataException(string message) + : base(message) + { + } +} + +/// +/// Read-only migration of UC4's theme list and selected-item preview. KRX data +/// remains on Oracle and NXT data remains on MariaDB, matching both the original +/// Nxt_ViewQuery path and the migrated PAGED_THEME/PAGED_NXT_THEME loaders. +/// +public sealed partial class LegacyThemeSelectionService : IThemeSelectionService +{ + public const int DefaultMaximumResults = 100; + public const int MaximumResults = 500; + public const int MaximumQueryLength = 64; + public const int DefaultMaximumPreviewItems = 240; + public const int MaximumPreviewItems = 240; + + private const int MaximumThemeTitleLength = 128; + private const int MaximumItemNameLength = 200; + private const int MaximumItemIndex = 9_999; + + private static readonly string[] SearchColumns = + ["THEME_TITLE", "THEME_CODE", "THEME_MARKET"]; + private static readonly string[] PreviewColumns = + [ + "THEME_TITLE", + "THEME_CODE", + "THEME_MARKET", + "ITEM_NAME", + "ITEM_CODE", + "ITEM_INDEX" + ]; + + private readonly IDataQueryExecutor _executor; + + public LegacyThemeSelectionService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task SearchAsync( + string query, + ThemeSession nxtSession, + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = ValidateAndNormalizeSearchQuery(query); + ValidateNxtSession(nxtSession, nameof(nxtSession)); + ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults)); + cancellationToken.ThrowIfCancellationRequested(); + + var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); + var perMarketLimit = checked(maximumResults + 1); + var matches = new List(perMarketLimit * ThemeQueries.All.Count); + + foreach (var profile in ThemeQueries.All) + { + cancellationToken.ThrowIfCancellationRequested(); + var table = await _executor.ExecuteAsync( + profile.Source, + profile.SearchName, + profile.CreateSearchSpec(escapedQuery, perMarketLimit), + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + AppendSearchRows(table, profile, nxtSession, perMarketLimit, matches); + } + + var ordered = matches + .OrderBy(static item => item.Identity.Market) + .ThenBy(static item => item.Identity.ThemeTitle, StringComparer.Ordinal) + .ThenBy(static item => item.Identity.ThemeCode, StringComparer.Ordinal) + .Take(maximumResults) + .ToArray(); + + return new ThemeSearchResult( + normalizedQuery, + nxtSession, + DateTimeOffset.Now, + ordered, + matches.Count > maximumResults); + } + + public async Task PreviewAsync( + ThemeSelectionIdentity identity, + int maximumItems = DefaultMaximumPreviewItems, + CancellationToken cancellationToken = default) + { + var canonicalIdentity = ValidateIdentity(identity); + ValidateLimit(maximumItems, MaximumPreviewItems, nameof(maximumItems)); + cancellationToken.ThrowIfCancellationRequested(); + + var profile = ThemeQueries.For(canonicalIdentity.Market); + var rowLimit = checked(maximumItems + 1); + var table = await _executor.ExecuteAsync( + profile.Source, + profile.PreviewName, + profile.CreatePreviewSpec(canonicalIdentity, rowLimit), + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var items = MapPreviewRows(table, profile, canonicalIdentity, rowLimit); + return new ThemePreviewResult( + canonicalIdentity, + DateTimeOffset.Now, + items.Take(maximumItems).ToArray(), + items.Count > maximumItems); + } + + private static void AppendSearchRows( + DataTable? table, + ThemeQueryProfile profile, + ThemeSession nxtSession, + int rowLimit, + ICollection destination) + { + ValidateExactStringSchema(table, SearchColumns, rowLimit, profile.SearchName); + + var titles = new HashSet(StringComparer.Ordinal); + var codes = new HashSet(StringComparer.Ordinal); + foreach (DataRow row in table!.Rows) + { + var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.SearchName); + var code = ReadThemeCode(row, 1, profile.SearchName); + var market = ReadCanonicalString(row, 2, 3, profile.SearchName); + if (!string.Equals(market, profile.DatabaseMarket, StringComparison.Ordinal)) + { + throw InvalidData(profile.SearchName, "market identity"); + } + + // PAGED_THEME resolves by title and UC4 previews by code. Both keys + // must be unique inside one market or the selection is ambiguous. + if (!titles.Add(title) || !codes.Add(code)) + { + throw InvalidData(profile.SearchName, "duplicate title or code"); + } + + var session = profile.Market == ThemeMarket.Krx + ? ThemeSession.NotApplicable + : nxtSession; + destination.Add(new ThemeSelectorItem( + new ThemeSelectionIdentity(profile.Market, session, code, title), + profile.Source)); + } + } + + private static IReadOnlyList MapPreviewRows( + DataTable? table, + ThemeQueryProfile profile, + ThemeSelectionIdentity identity, + int rowLimit) + { + ValidateExactStringSchema(table, PreviewColumns, rowLimit, profile.PreviewName); + if (table!.Rows.Count < 1) + { + throw InvalidData(profile.PreviewName, "missing selected theme"); + } + + var items = new List(table.Rows.Count); + var itemCodes = new HashSet(StringComparer.Ordinal); + var indexes = new HashSet(); + var sawEmptyThemeRow = false; + foreach (DataRow row in table.Rows) + { + var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.PreviewName); + var code = ReadThemeCode(row, 1, profile.PreviewName); + var market = ReadCanonicalString(row, 2, 3, profile.PreviewName); + if (!string.Equals(title, identity.ThemeTitle, StringComparison.Ordinal) || + !string.Equals(code, identity.ThemeCode, StringComparison.Ordinal) || + !string.Equals(market, profile.DatabaseMarket, StringComparison.Ordinal)) + { + throw InvalidData(profile.PreviewName, "selected theme identity"); + } + + var nameValue = row[3]; + var codeValue = row[4]; + var indexValue = row[5]; + var isEmptyThemeRow = nameValue is DBNull && codeValue is DBNull && indexValue is DBNull; + if (isEmptyThemeRow) + { + if (table.Rows.Count != 1) + { + throw InvalidData(profile.PreviewName, "empty item sentinel"); + } + + sawEmptyThemeRow = true; + continue; + } + + if (nameValue is DBNull || codeValue is DBNull || indexValue is DBNull) + { + throw InvalidData(profile.PreviewName, "partial item identity"); + } + + var itemName = ReadCanonicalString(row, 3, MaximumItemNameLength, profile.PreviewName); + var itemCode = ReadCanonicalString(row, 4, 13, profile.PreviewName); + if (!IsItemCodeForMarket(itemCode, identity.Market)) + { + throw InvalidData(profile.PreviewName, "item code"); + } + + var rawIndex = ReadCanonicalString(row, 5, 4, profile.PreviewName); + if (!int.TryParse(rawIndex, NumberStyles.None, CultureInfo.InvariantCulture, out var inputIndex) || + inputIndex is < 0 or > MaximumItemIndex || + !string.Equals( + rawIndex, + inputIndex.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal)) + { + throw InvalidData(profile.PreviewName, "item index"); + } + + if (!itemCodes.Add(itemCode) || !indexes.Add(inputIndex)) + { + throw InvalidData(profile.PreviewName, "duplicate item code or index"); + } + + items.Add(new ThemeItemPreview(inputIndex, itemCode, itemName)); + } + + if (sawEmptyThemeRow) + { + return Array.Empty(); + } + + for (var index = 1; index < items.Count; index++) + { + if (items[index - 1].InputIndex >= items[index].InputIndex) + { + throw InvalidData(profile.PreviewName, "item order"); + } + } + + return items; + } + + private static ThemeSelectionIdentity ValidateIdentity(ThemeSelectionIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + if (!Enum.IsDefined(identity.Market) || !Enum.IsDefined(identity.Session)) + { + throw new ArgumentException("The theme selection identity is invalid.", nameof(identity)); + } + + if (identity.Market == ThemeMarket.Krx && identity.Session != ThemeSession.NotApplicable || + identity.Market == ThemeMarket.Nxt && + identity.Session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) + { + throw new ArgumentException( + "The theme selection market and session do not form a safe identity.", + nameof(identity)); + } + + var title = ValidateCanonicalInput( + identity.ThemeTitle, + MaximumThemeTitleLength, + nameof(identity)); + var code = ValidateCanonicalInput(identity.ThemeCode, 8, nameof(identity)); + if (!ThemeCodePattern().IsMatch(code)) + { + throw new ArgumentException("The theme selection code is invalid.", nameof(identity)); + } + + return identity with { ThemeTitle = title, ThemeCode = code }; + } + + private static string ValidateAndNormalizeSearchQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsSafeText(query)) + { + throw new ArgumentException("The theme search query is invalid.", nameof(query)); + } + + var normalized = query.Trim(); + if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized)) + { + throw new ArgumentException( + $"A theme search query must contain at most {MaximumQueryLength} visible characters.", + nameof(query)); + } + + return normalized; + } + + private static string ValidateCanonicalInput( + string value, + int maximumLength, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value)) + { + throw new ArgumentException("The theme selection value is invalid.", parameterName); + } + + return value; + } + + private static void ValidateNxtSession(ThemeSession session, string parameterName) + { + if (session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) + { + throw new ArgumentOutOfRangeException( + parameterName, + session, + "An explicit NXT pre-market or after-market session is required."); + } + } + + private static void ValidateLimit(int value, int maximum, string parameterName) + { + if (value is < 1 || value > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"The requested limit must be between 1 and {maximum}."); + } + } + + private static void ValidateExactStringSchema( + DataTable? table, + IReadOnlyList columns, + int rowLimit, + string queryName) + { + if (table is null || table.Columns.Count != columns.Count || table.Rows.Count > rowLimit) + { + throw InvalidData(queryName, "schema or row bound"); + } + + for (var index = 0; index < columns.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + columns[index], + StringComparison.Ordinal) || + table.Columns[index].DataType != typeof(string)) + { + throw InvalidData(queryName, "schema"); + } + } + } + + private static string ReadCanonicalString( + DataRow row, + int ordinal, + int maximumLength, + string queryName) + { + if (row[ordinal] is not string value || + value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value)) + { + throw InvalidData(queryName, "value"); + } + + return value; + } + + private static string ReadThemeCode(DataRow row, int ordinal, string queryName) + { + var code = ReadCanonicalString(row, ordinal, 8, queryName); + if (!ThemeCodePattern().IsMatch(code)) + { + throw InvalidData(queryName, "theme code"); + } + + return code; + } + + private static bool IsItemCodeForMarket(string value, ThemeMarket market) + { + var pattern = market == ThemeMarket.Krx + ? KrxItemCodePattern() + : NxtItemCodePattern(); + return pattern.IsMatch(value); + } + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static bool IsSafeText(string value) + { + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static ThemeSelectionDataException InvalidData(string queryName, string detail) => + new($"Theme selection data from {queryName} contains an invalid {detail}."); + + [GeneratedRegex("^[0-9]{8}$", RegexOptions.CultureInvariant)] + private static partial Regex ThemeCodePattern(); + + [GeneratedRegex("^[PD][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)] + private static partial Regex KrxItemCodePattern(); + + [GeneratedRegex("^[XT][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)] + private static partial Regex NxtItemCodePattern(); +} + +internal sealed record ThemeQueryProfile( + ThemeMarket Market, + DataSourceKind Source, + string DatabaseMarket, + string SearchName, + string PreviewName, + string SearchSql, + string PreviewSql) +{ + internal DataQuerySpec CreateSearchSpec(string escapedQuery, int rowLimit) + { + var spec = new DataQuerySpec( + SearchSql, + [ + new DataQueryParameter("search_term", escapedQuery, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(Source); + return spec; + } + + internal DataQuerySpec CreatePreviewSpec(ThemeSelectionIdentity identity, int rowLimit) + { + var spec = new DataQuerySpec( + PreviewSql, + [ + new DataQueryParameter("theme_code", identity.ThemeCode, DbType.String), + new DataQueryParameter("theme_title", identity.ThemeTitle, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(Source); + return spec; + } +} + +internal static class ThemeQueries +{ + private static readonly ThemeQueryProfile Krx = new( + ThemeMarket.Krx, + DataSourceKind.Oracle, + "KRX", + "THEME_SEARCH_KRX", + "THEME_PREVIEW_KRX", + """ + SELECT THEME_TITLE, THEME_CODE, THEME_MARKET + FROM ( + SELECT SB_TITLE THEME_TITLE, + SB_CODE THEME_CODE, + SB_MARKET THEME_MARKET + FROM SB_LIST + WHERE SB_MARKET = 'KRX' + AND UPPER(SB_TITLE) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(SB_TITLE), SB_CODE + ) + WHERE ROWNUM <= :row_limit + """, + """ + SELECT THEME_TITLE, THEME_CODE, THEME_MARKET, + ITEM_NAME, ITEM_CODE, ITEM_INDEX + FROM ( + SELECT l.SB_TITLE THEME_TITLE, + l.SB_CODE THEME_CODE, + l.SB_MARKET THEME_MARKET, + a.SB_I_NAME ITEM_NAME, + a.SB_I_CODE ITEM_CODE, + TO_CHAR(a.SB_I_INDEX, 'FM999999990') ITEM_INDEX + FROM SB_LIST l + LEFT JOIN ( + SELECT i.SB_M_CODE, i.SB_I_NAME, i.SB_I_CODE, i.SB_I_INDEX + FROM SB_ITEM i + JOIN v_all_stock s + ON SUBSTR(i.SB_I_CODE, 2, 12) = s.F_STOCK_CODE + WHERE s.F_MKT_HALT = 'N' + ) a ON l.SB_CODE = a.SB_M_CODE + WHERE l.SB_CODE = :theme_code + AND l.SB_TITLE = :theme_title + AND l.SB_MARKET = 'KRX' + ORDER BY a.SB_I_INDEX + ) + WHERE ROWNUM <= :row_limit + """); + + private static readonly ThemeQueryProfile Nxt = new( + ThemeMarket.Nxt, + DataSourceKind.MariaDb, + "NXT", + "THEME_SEARCH_NXT", + "THEME_PREVIEW_NXT", + """ + SELECT SB_TITLE THEME_TITLE, + SB_CODE THEME_CODE, + SB_MARKET THEME_MARKET + FROM SB_LIST + WHERE SB_MARKET = 'NXT' + AND UPPER(SB_TITLE) LIKE CONCAT('%', @search_term, '%') ESCAPE '!' + ORDER BY UPPER(SB_TITLE), SB_CODE + LIMIT @row_limit + """, + """ + SELECT l.SB_TITLE THEME_TITLE, + l.SB_CODE THEME_CODE, + l.SB_MARKET THEME_MARKET, + a.SB_I_NAME ITEM_NAME, + a.SB_I_CODE ITEM_CODE, + CAST(a.SB_I_INDEX AS CHAR) ITEM_INDEX + FROM SB_LIST l + LEFT JOIN ( + SELECT i.SB_M_CODE, i.SB_I_NAME, i.SB_I_CODE, i.SB_I_INDEX + FROM SB_ITEM i + JOIN v_all_stock s + ON SUBSTRING(i.SB_I_CODE, 2, 12) = s.F_STOCK_CODE + WHERE s.F_STOP_GUBUN = 'N' + ) a ON l.SB_CODE = a.SB_M_CODE + WHERE l.SB_CODE = @theme_code + AND l.SB_TITLE = @theme_title + AND l.SB_MARKET = 'NXT' + ORDER BY a.SB_I_INDEX + LIMIT @row_limit + """); + + internal static IReadOnlyList All { get; } = [Krx, Nxt]; + + internal static ThemeQueryProfile For(ThemeMarket market) => market switch + { + ThemeMarket.Krx => Krx, + ThemeMarket.Nxt => Nxt, + _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported theme market.") + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs new file mode 100644 index 0000000..5b6360e --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs @@ -0,0 +1,324 @@ +#nullable enable + +using System.Data; +using System.Globalization; + +namespace MMoneyCoderSharp.Data; + +public enum TradingHaltMarket +{ + Kospi, + Kosdaq +} + +public sealed record TradingHaltSelection( + TradingHaltMarket Market, + string StockCode, + string DisplayName); + +public sealed record TradingHaltSelectionResult( + string Query, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public interface ITradingHaltSelectionService +{ + Task SearchAsync( + string query = "", + int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults, + CancellationToken cancellationToken = default); +} + +public sealed class TradingHaltSelectionDataException : Exception +{ + public TradingHaltSelectionDataException(string message) + : base(message) + { + } +} + +/// +/// Reads the KOSPI and KOSDAQ trading-halt selectors used by legacy UC7. +/// The empty query returns the bounded selector contents; a non-empty query +/// applies the legacy name filter through a bound parameter. Database rows +/// must satisfy the exact market/code/name identity contract before they are +/// exposed to an operator surface. +/// +public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionService +{ + public const int DefaultMaximumResults = 100; + public const int MaximumResults = 500; + public const int MaximumQueryLength = 64; + + private const int MaximumStockCodeLength = 32; + private const int MaximumDisplayNameLength = 200; + private const string StockCodeColumn = "STOCK_CODE"; + private const string StockNameColumn = "STOCK_NAME"; + + private readonly IDataQueryExecutor _executor; + + public LegacyTradingHaltSelectionService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task SearchAsync( + string query = "", + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = ValidateAndNormalizeQuery(query); + if (maximumResults is < 1 or > MaximumResults) + { + throw new ArgumentOutOfRangeException( + nameof(maximumResults), + $"The requested result limit must be between 1 and {MaximumResults}."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var rowLimit = checked(maximumResults + 1); + var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); + var matches = new List(rowLimit * TradingHaltQueries.All.Count); + var identities = new HashSet<(TradingHaltMarket Market, string StockCode)>(); + var lookupKeys = new HashSet<(TradingHaltMarket Market, string DisplayName)>(); + + // UC7 appends KOSPI followed by KOSDAQ. Keep that provider sequence, + // then apply a deterministic final order independent of provider row order. + foreach (var profile in TradingHaltQueries.All) + { + cancellationToken.ThrowIfCancellationRequested(); + var spec = profile.CreateSpec(escapedQuery, rowLimit); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + profile.QueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + AppendValidatedRows( + table, + profile, + rowLimit, + matches, + identities, + lookupKeys); + } + + var ordered = matches + .OrderBy(static item => item.Market) + .ThenBy(static item => item.DisplayName, StringComparer.Ordinal) + .ThenBy(static item => item.StockCode, StringComparer.Ordinal) + .Take(maximumResults) + .ToArray(); + + return new TradingHaltSelectionResult( + normalizedQuery, + DateTimeOffset.Now, + ordered, + matches.Count > maximumResults); + } + + private static string ValidateAndNormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var normalized = query.Trim(); + if (query.Length > MaximumQueryLength || + normalized.Length > MaximumQueryLength || + !IsSafeText(query) || + !IsSafeText(normalized)) + { + throw new ArgumentException( + $"A trading-halt search query must contain at most {MaximumQueryLength} safe characters.", + nameof(query)); + } + + return normalized; + } + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static void AppendValidatedRows( + DataTable? table, + TradingHaltQueryProfile profile, + int rowLimit, + ICollection destination, + ISet<(TradingHaltMarket Market, string StockCode)> identities, + ISet<(TradingHaltMarket Market, string DisplayName)> lookupKeys) + { + if (table is null || + table.Columns.Count != 2 || + !HasExactStringColumn(table.Columns[0], StockCodeColumn) || + !HasExactStringColumn(table.Columns[1], StockNameColumn) || + table.Rows.Count > rowLimit) + { + throw InvalidSchema(profile.QueryName); + } + + foreach (DataRow row in table.Rows) + { + if (row[0] is not string stockCode || row[1] is not string displayName) + { + throw InvalidSchema(profile.QueryName); + } + + if (!IsCanonicalDatabaseValue(stockCode, MaximumStockCodeLength) || + !IsCanonicalDatabaseValue(displayName, MaximumDisplayNameLength)) + { + throw new TradingHaltSelectionDataException( + $"Trading-halt selector data from {profile.QueryName} contains an invalid value."); + } + + if (!identities.Add((profile.Market, stockCode))) + { + throw new TradingHaltSelectionDataException( + $"Trading-halt selector data from {profile.QueryName} contains a duplicate market/code identity."); + } + + // Legacy downstream requests resolve a domestic stock by market and + // display name. Distinct codes with the same key would be ambiguous. + if (!lookupKeys.Add((profile.Market, displayName))) + { + throw new TradingHaltSelectionDataException( + $"Trading-halt selector data from {profile.QueryName} contains an ambiguous market/name lookup key."); + } + + destination.Add(new TradingHaltSelection(profile.Market, stockCode, displayName)); + } + } + + private static bool HasExactStringColumn(DataColumn column, string name) => + string.Equals(column.ColumnName, name, StringComparison.Ordinal) && + column.DataType == typeof(string); + + private static bool IsCanonicalDatabaseValue(string value, int maximumLength) => + value.Length is > 0 && + value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + IsSafeText(value); + + private static bool IsSafeText(string value) + { + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static TradingHaltSelectionDataException InvalidSchema(string queryName) => + new($"Trading-halt selector data from {queryName} does not match the required schema or row bound."); +} + +internal sealed record TradingHaltQueryProfile( + TradingHaltMarket Market, + string QueryName, + string AllSql, + string FilteredSql) +{ + internal DataQuerySpec CreateSpec(string escapedQuery, int rowLimit) + { + DataQuerySpec spec; + if (escapedQuery.Length == 0) + { + spec = new DataQuerySpec( + AllSql, + [new DataQueryParameter("row_limit", rowLimit, DbType.Int32)]); + } + else + { + spec = new DataQuerySpec( + FilteredSql, + [ + new DataQueryParameter("search_term", escapedQuery, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + } + + spec.ValidateFor(DataSourceKind.Oracle); + return spec; + } +} + +internal static class TradingHaltQueries +{ + private const string KospiAllSql = """ + SELECT STOCK_CODE, STOCK_NAME + FROM ( + SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME + FROM T_STOP_ONLINE1 a + INNER JOIN T_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE + WHERE b.F_MKT_HALT = 'Y' + ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE + ) + WHERE ROWNUM <= :row_limit + """; + + private const string KospiFilteredSql = """ + SELECT STOCK_CODE, STOCK_NAME + FROM ( + SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME + FROM T_STOP_ONLINE1 a + INNER JOIN T_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE + WHERE b.F_MKT_HALT = 'Y' + AND UPPER(b.F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE + ) + WHERE ROWNUM <= :row_limit + """; + + private const string KosdaqAllSql = """ + SELECT STOCK_CODE, STOCK_NAME + FROM ( + SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME + FROM T_STOP_KOSDAQ_ONLINE1 a + INNER JOIN T_KOSDAQ_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE + WHERE b.F_MKT_HALT = 'Y' + ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE + ) + WHERE ROWNUM <= :row_limit + """; + + private const string KosdaqFilteredSql = """ + SELECT STOCK_CODE, STOCK_NAME + FROM ( + SELECT b.F_STOCK_CODE AS STOCK_CODE, b.F_STOCK_WANNAME AS STOCK_NAME + FROM T_STOP_KOSDAQ_ONLINE1 a + INNER JOIN T_KOSDAQ_STOCK b ON a.F_STOCK_CODE = b.F_STOCK_CODE + WHERE b.F_MKT_HALT = 'Y' + AND UPPER(b.F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(b.F_STOCK_WANNAME), b.F_STOCK_CODE + ) + WHERE ROWNUM <= :row_limit + """; + + internal static IReadOnlyList All { get; } = + [ + new( + TradingHaltMarket.Kospi, + "TRADING_HALT_KOSPI", + KospiAllSql, + KospiFilteredSql), + new( + TradingHaltMarket.Kosdaq, + "TRADING_HALT_KOSDAQ", + KosdaqAllSql, + KosdaqFilteredSql) + ]; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs new file mode 100644 index 0000000..2754547 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs @@ -0,0 +1,228 @@ +#nullable enable + +using System.Data; +using System.Globalization; + +namespace MMoneyCoderSharp.Data; + +public sealed record WorldStockSearchItem( + string InputName, + string Symbol, + string NationCode); + +public sealed record WorldStockSearchResult( + string Query, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + +public interface IWorldStockSearchService +{ + Task SearchAsync( + string query, + int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default); +} + +public sealed class WorldStockSearchDataException : Exception +{ + public WorldStockSearchDataException(string message) + : base(message) + { + } +} + +/// +/// Searches the Oracle world-equity master used by the legacy overseas-stock +/// comparison workflow. Input is bound and LIKE metacharacters are escaped; +/// database output is accepted only when it exactly matches the required +/// three-column identity contract. +/// +public sealed class LegacyWorldStockSearchService : IWorldStockSearchService +{ + public const int DefaultMaximumResults = 100; + public const int MaximumResults = 500; + public const int MaximumQueryLength = 64; + + private const int MaximumInputNameLength = 200; + private const int MaximumSymbolLength = 64; + private const string InputNameColumn = "F_INPUT_NAME"; + private const string SymbolColumn = "F_SYMB"; + private const string NationCodeColumn = "F_NATC"; + private const string QueryName = "WORLD_STOCK_SEARCH"; + + private const string SearchSql = """ + 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') + AND UPPER(F_INPUT_NAME) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY UPPER(F_INPUT_NAME), F_SYMB, F_NATC + ) + WHERE ROWNUM <= :row_limit + """; + + private readonly IDataQueryExecutor _executor; + + public LegacyWorldStockSearchService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task SearchAsync( + string query, + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = ValidateAndNormalizeQuery(query); + if (maximumResults is < 1 or > MaximumResults) + { + throw new ArgumentOutOfRangeException( + nameof(maximumResults), + $"The requested result limit must be between 1 and {MaximumResults}."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var rowLimit = checked(maximumResults + 1); + var spec = CreateQuerySpec( + EscapeLikePattern(normalizedQuery.ToUpperInvariant()), + rowLimit); + var table = await _executor + .ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var matches = ValidateRows(table, rowLimit); + return new WorldStockSearchResult( + normalizedQuery, + DateTimeOffset.Now, + matches.Take(maximumResults).ToArray(), + matches.Count > maximumResults); + } + + private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit) + { + var spec = new DataQuerySpec( + SearchSql, + [ + new DataQueryParameter("search_term", escapedQuery, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + spec.ValidateFor(DataSourceKind.Oracle); + return spec; + } + + private static string ValidateAndNormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var normalized = query.Trim(); + if (!IsSafeText(query) || + normalized.Length > MaximumQueryLength || + !IsSafeText(normalized)) + { + throw new ArgumentException( + $"A world-stock search query must contain at most {MaximumQueryLength} visible characters.", + nameof(query)); + } + + return normalized; + } + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static IReadOnlyList ValidateRows( + DataTable? table, + int rowLimit) + { + if (table is null || + table.Columns.Count != 3 || + !HasExactStringColumn(table.Columns[0], InputNameColumn) || + !HasExactStringColumn(table.Columns[1], SymbolColumn) || + !HasExactStringColumn(table.Columns[2], NationCodeColumn) || + table.Rows.Count > rowLimit) + { + throw InvalidSchema(); + } + + var identities = new HashSet(); + var inputNames = new HashSet(StringComparer.Ordinal); + var matches = new List(table.Rows.Count); + foreach (DataRow row in table.Rows) + { + if (row[0] is not string inputName || + row[1] is not string symbol || + row[2] is not string nationCode) + { + throw InvalidSchema(); + } + + if (!IsCanonicalDatabaseValue(inputName, MaximumInputNameLength) || + !IsCanonicalDatabaseValue(symbol, MaximumSymbolLength) || + nationCode is not ("US" or "TW")) + { + throw new WorldStockSearchDataException( + $"World-stock search data from {QueryName} contains an invalid value."); + } + + var item = new WorldStockSearchItem(inputName, symbol, nationCode); + if (!identities.Add(item)) + { + throw new WorldStockSearchDataException( + $"World-stock search data from {QueryName} contains a duplicate identity."); + } + + // The legacy comparison resolver selects the master row by + // F_INPUT_NAME alone. Even distinct symbols or nations are unsafe to + // expose when that lookup key would resolve ambiguously downstream. + if (!inputNames.Add(inputName)) + { + throw new WorldStockSearchDataException( + $"World-stock search data from {QueryName} contains a duplicate F_INPUT_NAME lookup key."); + } + + matches.Add(item); + } + + return matches; + } + + private static bool HasExactStringColumn(DataColumn column, string name) => + string.Equals(column.ColumnName, name, StringComparison.Ordinal) && + column.DataType == typeof(string); + + private static bool IsCanonicalDatabaseValue(string value, int maximumLength) => + value.Length is > 0 && + value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + IsSafeText(value); + + private static bool IsSafeText(string value) + { + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static WorldStockSearchDataException InvalidSchema() => + new($"World-stock search data from {QueryName} does not match the required schema."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs new file mode 100644 index 0000000..5e3ce32 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs @@ -0,0 +1,50 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout; + +/// +/// Immutable observation of every native-runtime condition that must be idle +/// before an operator-data mutation can start. +/// +public sealed record OperatorMutationGateSnapshot( + bool IsFamilyWriteQuarantined, + bool IsGlobalWriteQuarantined, + bool IsLifetimeCancellationRequested, + bool IsPlayoutCommandInFlight, + bool IsPlayoutShutdownStarted, + bool IsBrowserCorrelationQuarantined, + bool IsEngineAvailable, + bool IsWorkflowAvailable, + bool IsCommandAvailable, + bool IsPlayCompletionPending, + string? PreparedSceneName, + string? OnAirSceneName, + string? PreparedCutCode, + string? OnAirCutCode, + bool IsRefreshActive, + bool IsRefreshTaskRunning); + +/// +/// Pure fail-closed evaluator for the operator-data mutation boundary. +/// +public static class OperatorMutationGate +{ + public static bool CanStart(OperatorMutationGateSnapshot? snapshot) => + snapshot is not null && + !snapshot.IsFamilyWriteQuarantined && + !snapshot.IsGlobalWriteQuarantined && + !snapshot.IsLifetimeCancellationRequested && + !snapshot.IsPlayoutCommandInFlight && + !snapshot.IsPlayoutShutdownStarted && + !snapshot.IsBrowserCorrelationQuarantined && + snapshot.IsEngineAvailable && + snapshot.IsWorkflowAvailable && + snapshot.IsCommandAvailable && + !snapshot.IsPlayCompletionPending && + string.IsNullOrWhiteSpace(snapshot.PreparedSceneName) && + string.IsNullOrWhiteSpace(snapshot.OnAirSceneName) && + string.IsNullOrWhiteSpace(snapshot.PreparedCutCode) && + string.IsNullOrWhiteSpace(snapshot.OnAirCutCode) && + !snapshot.IsRefreshActive && + !snapshot.IsRefreshTaskRunning; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs index 4c5d634..d530564 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs @@ -35,6 +35,15 @@ public sealed record PlayoutBridgeTimeoutQuarantineParseResult( PlayoutBridgeTimeoutQuarantine Request, string Error); +public sealed record PlayoutBridgePagePlanRequest( + string RequestId, + IReadOnlyList? Entries); + +public sealed record PlayoutBridgePagePlanRequestParseResult( + bool IsValid, + PlayoutBridgePagePlanRequest Request, + string Error); + /// /// Strict, COM-neutral parser for the local WebView playout command boundary. /// Presentation text is intentionally not accepted as scene mutation data. @@ -169,6 +178,36 @@ public static class PlayoutBridgeProtocol -1)); } + /// + /// Parses the read-only PageN preflight boundary. It accepts only the three paged + /// cut aliases and never accepts a stored page number, cue, mutation, or asset path. + /// + public static PlayoutBridgePagePlanRequestParseResult ParsePagePlanRequest( + JsonElement payload) + { + var empty = new PlayoutBridgePagePlanRequest(string.Empty, null); + if (payload.ValueKind != JsonValueKind.Object) + { + return InvalidPagePlan(empty, "The playlist page-plan request is invalid."); + } + + if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId)) + { + return InvalidPagePlan(empty, "The playlist page-plan request identifier is invalid."); + } + + var correlated = new PlayoutBridgePagePlanRequest(requestId, null); + if (!HasOnlyProperties(payload, "requestId", "entries") || + !payload.TryGetProperty("entries", out var entriesElement) || + !TryParsePlaylist(entriesElement, out var entries) || + entries!.Any(entry => entry.CutCode is not ("5074" or "5077" or "5088"))) + { + return InvalidPagePlan(correlated, "The playlist page-plan entries are invalid."); + } + + return ValidPagePlan(new PlayoutBridgePagePlanRequest(requestId, entries)); + } + /// /// Parses the browser watchdog notification. A valid notification permanently /// quarantines the native playout runtime for the remainder of the app process; @@ -371,4 +410,11 @@ public static class PlayoutBridgeProtocol private static PlayoutBridgeTimeoutQuarantineParseResult InvalidTimeoutQuarantine( PlayoutBridgeTimeoutQuarantine request, string error) => new(false, request, error); + + private static PlayoutBridgePagePlanRequestParseResult ValidPagePlan( + PlayoutBridgePagePlanRequest request) => new(true, request, string.Empty); + + private static PlayoutBridgePagePlanRequestParseResult InvalidPagePlan( + PlayoutBridgePagePlanRequest request, + string error) => new(false, request, error); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs index 23b7f21..f1a1ad7 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs @@ -16,7 +16,9 @@ public sealed record S8010SceneDataRequest( CandleChartMode ChartMode, string? InstrumentName, bool ShowMovingAverage5, - bool ShowMovingAverage20); + bool ShowMovingAverage20, + string? ForeignSymbol = null, + string? ForeignNationCode = null); /// /// Loads the two legacy s8010 result sets (current quote, then ordered candles) through @@ -91,7 +93,9 @@ internal static class S8010CandleQueryFactory var requiresSelection = RequiresSelection(request.MarketTarget); if (!requiresSelection) { - if (!string.IsNullOrWhiteSpace(request.InstrumentName)) + if (!string.IsNullOrWhiteSpace(request.InstrumentName) || + request.ForeignSymbol is not null || + request.ForeignNationCode is not null) { throw InvalidRequest(); } @@ -107,6 +111,33 @@ internal static class S8010CandleQueryFactory throw InvalidRequest(); } + var isForeign = request.MarketTarget is + CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock; + if (!isForeign) + { + if (request.ForeignSymbol is not null || request.ForeignNationCode is not null) + { + throw InvalidRequest(); + } + + return selection; + } + + var symbol = LegacySceneBuilderGuard.Text( + request.ForeignSymbol, + nameof(request.ForeignSymbol)); + var nationCode = LegacySceneBuilderGuard.Text( + request.ForeignNationCode, + nameof(request.ForeignNationCode)); + if (symbol.Length > 64 || + nationCode.Length != 2 || + nationCode.Any(character => character is < 'A' or > 'Z') || + request.MarketTarget == CandleMarketTarget.OverseasStock && + nationCode is not ("US" or "TW")) + { + throw InvalidRequest(); + } + return selection; } @@ -187,11 +218,15 @@ internal static class S8010CandleQueryFactory CandleMarketTarget.OverseasIndex => Foreign( maximumRows, selection!, - "0"), + request.ForeignSymbol!, + request.ForeignNationCode!, + ForeignInstrumentScope.GlobalIndex), CandleMarketTarget.OverseasStock => Foreign( maximumRows, selection!, - "1"), + request.ForeignSymbol!, + request.ForeignNationCode!, + ForeignInstrumentScope.UsOrTaiwanStock), CandleMarketTarget.KospiStock => Stock( request.ChartMode, isIntraday, @@ -518,9 +553,21 @@ internal static class S8010CandleQueryFactory private static QueryPair Foreign( int maximumRows, string selection, - string instrumentType) + string symbol, + string nationCode, + ForeignInstrumentScope scope) { - const string currentSql = """ + var (instrumentType, masterScope) = scope switch + { + ForeignInstrumentScope.GlobalIndex => ( + "0", + "AND a.f_fdtc = '0'"), + ForeignInstrumentScope.UsOrTaiwanStock => ( + "1", + "AND a.f_fdtc = '1' AND a.f_natc IN ('US', 'TW')"), + _ => throw InvalidRequest() + }; + var currentSql = $""" SELECT DISTINCT a.f_input_name DISPLAY_NAME, ROUND(b.f_last, 2) CURRENT_PRICE, @@ -530,13 +577,18 @@ internal static class S8010CandleQueryFactory FROM t_world_ix_eq_master a, t_world_ix_eq_sise b WHERE a.f_symb = b.f_symb AND a.f_input_name = :selection + AND a.f_symb = :symbol + AND a.f_natc = :nationCode + {masterScope} AND b.f_fdtc = :instrumentType """; var current = Oracle( currentSql, new DataQueryParameter("selection", selection, DbType.String), + new DataQueryParameter("symbol", symbol, DbType.String), + new DataQueryParameter("nationCode", nationCode, DbType.String), new DataQueryParameter("instrumentType", instrumentType, DbType.String)); - const string rowsSql = """ + var rowsSql = $""" SELECT TRADING_DATE, TRADING_TIME, OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, @@ -555,6 +607,9 @@ internal static class S8010CandleQueryFactory FROM t_world_ix_eq_master a, t_world_ix_eq_his b WHERE a.f_symb = b.f_symb AND a.f_input_name = :selection + AND a.f_symb = :symbol + AND a.f_natc = :nationCode + {masterScope} AND b.f_fdtc = :instrumentType ORDER BY b.F_xYMD DESC ) @@ -564,6 +619,8 @@ internal static class S8010CandleQueryFactory var rows = Oracle( rowsSql, new DataQueryParameter("selection", selection, DbType.String), + new DataQueryParameter("symbol", symbol, DbType.String), + new DataQueryParameter("nationCode", nationCode, DbType.String), new DataQueryParameter("instrumentType", instrumentType, DbType.String), new DataQueryParameter("rowLimit", maximumRows, DbType.Int32)); return new QueryPair(current, rows); @@ -779,6 +836,12 @@ internal static class S8010CandleQueryFactory string MasterTable, string BatchTable); + private enum ForeignInstrumentScope + { + GlobalIndex, + UsOrTaiwanStock + } + private sealed record QueryPair(DataQuerySpec Current, DataQuerySpec Rows); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs index b3a6880..48b64b9 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs @@ -123,22 +123,27 @@ public sealed class ComparisonAndYieldLegacyRequestResolver } var groupCode = selection.GroupCode?.Trim() ?? string.Empty; - if (subject == "지수") + if (subject is "지수" or "INDEX") { var index = groupCode switch { - "코스피" => YieldGraphInstrumentKind.KospiIndex, - "코스닥" => YieldGraphInstrumentKind.KosdaqIndex, - "코스피200" => YieldGraphInstrumentKind.Kospi200Index, - "KRX100" => YieldGraphInstrumentKind.Krx100Index, + "코스피" or "KOSPI" or "KOSPI_INDEX" => + YieldGraphInstrumentKind.KospiIndex, + "코스닥" or "KOSDAQ" or "KOSDAQ_INDEX" => + YieldGraphInstrumentKind.KosdaqIndex, + "코스피200" or "KOSPI200" or "KOSPI200_INDEX" => + YieldGraphInstrumentKind.Kospi200Index, + "KRX100" or "KRX100_INDEX" => YieldGraphInstrumentKind.Krx100Index, _ => throw InvalidSelection() }; return new YieldSceneLoadRequest(period, new YieldGraphInstrument(index)); } - if (groupCode.Contains("업종", StringComparison.Ordinal)) + if (groupCode.Contains("업종", StringComparison.Ordinal) || + groupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal)) { - var industry = groupCode.Contains("코스피", StringComparison.Ordinal) + var industry = groupCode.Contains("코스피", StringComparison.Ordinal) || + groupCode.Contains("KOSPI", StringComparison.Ordinal) ? YieldGraphInstrumentKind.KospiIndustry : YieldGraphInstrumentKind.KosdaqIndustry; return new YieldSceneLoadRequest( @@ -256,8 +261,16 @@ public sealed class ComparisonAndYieldLegacyRequestResolver }; } - private static ComparisonGraphPeriod ParseComparisonPeriod(string value) => - Required(value) switch + private static ComparisonGraphPeriod ParseComparisonPeriod(string value) + { + var required = Required(value); + if (Enum.TryParse(required, ignoreCase: false, out var canonical) && + Enum.IsDefined(canonical)) + { + return canonical; + } + + return required switch { "일봉" => ComparisonGraphPeriod.Daily, "5일" => ComparisonGraphPeriod.FiveDays, @@ -267,9 +280,18 @@ public sealed class ComparisonAndYieldLegacyRequestResolver "12개월" => ComparisonGraphPeriod.TwelveMonths, _ => throw InvalidSelection() }; + } - private static YieldGraphPeriod ParseYieldPeriod(string value) => - Required(value) switch + private static YieldGraphPeriod ParseYieldPeriod(string value) + { + var required = Required(value); + if (Enum.TryParse(required, ignoreCase: false, out var canonical) && + Enum.IsDefined(canonical)) + { + return canonical; + } + + return required switch { "5일" => YieldGraphPeriod.FiveDays, "20일" => YieldGraphPeriod.TwentyDays, @@ -278,9 +300,23 @@ public sealed class ComparisonAndYieldLegacyRequestResolver "240일" => YieldGraphPeriod.TwoHundredFortyDays, _ => throw InvalidSelection() }; + } private static YieldGraphInstrumentKind? TryParseFx(string subject) { + var canonical = subject switch + { + nameof(YieldGraphInstrumentKind.WonDollar) => YieldGraphInstrumentKind.WonDollar, + nameof(YieldGraphInstrumentKind.WonYen) => YieldGraphInstrumentKind.WonYen, + nameof(YieldGraphInstrumentKind.WonYuan) => YieldGraphInstrumentKind.WonYuan, + nameof(YieldGraphInstrumentKind.WonEuro) => YieldGraphInstrumentKind.WonEuro, + _ => (YieldGraphInstrumentKind?)null + }; + if (canonical.HasValue) + { + return canonical; + } + if (subject.Contains("원달러", StringComparison.Ordinal)) { return YieldGraphInstrumentKind.WonDollar; diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs index bb7067d..3fb0a9c 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs @@ -133,6 +133,9 @@ public sealed class LegacyParameterizedSceneRequestResolver subject.Contains("FUTURES", StringComparison.Ordinal)) { var values = SplitPair(subject, ','); + // Keep this branch closed to the market targets that s5032 renders with + // its index/futures column contract. The original accepted a stock name + // here but then read the stock name as a numeric price and failed. var first = ParseMarketTarget(values[0]); var second = ParseMarketTarget(values[1]); var firstIsFutures = first == LegacyMarketQuoteTarget.Futures; @@ -164,11 +167,20 @@ public sealed class LegacyParameterizedSceneRequestResolver if (firstIsTarget != secondIsTarget) { var stockIndex = firstIsTarget ? 1 : 0; + if (expected && pair[stockIndex].Contains("(NXT)", StringComparison.Ordinal)) + { + throw new LegacySceneDataException( + "Expected stock data is unavailable for NXT selections."); + } + var stock = await ResolveCurrentStockAsync( pair[stockIndex], + // The original mixed branch has a domestic/NXT stock column shape. + // Its world fallback returns a different five-column shape which + // SetDataText cannot render, so world remains a stock-pair-only case. allowWorld: false, cancellationToken).ConfigureAwait(false); - var domesticMarket = ToKrxMarket(stock.Market); + var domesticMarket = ToDomesticMarket(stock.Market, allowNxt: !expected); return new LegacyS8018LoadRequest( new S8018MixedMarketAndStockLoadRequest( stockIndex == 0 ? S8018PairSide.First : S8018PairSide.Second, @@ -329,7 +341,9 @@ public sealed class LegacyParameterizedSceneRequestResolver rawName, allowWorld: false, cancellationToken).ConfigureAwait(false); - return new DomesticStockSelection(ToKrxMarket(current.Market), current.StockName); + return new DomesticStockSelection( + ToDomesticMarket(current.Market, allowNxt: false), + current.StockName); } private async Task StockExistsAsync( @@ -413,7 +427,9 @@ public sealed class LegacyParameterizedSceneRequestResolver "SCENE_RESOLVE_WORLD_STOCK", new DataQuerySpec( "SELECT COUNT(*) MATCH_COUNT FROM T_WORLD_IX_EQ_MASTER " + - "WHERE F_KNAM = :stockName", + "WHERE F_INPUT_NAME = :stockName " + + "AND F_FDTC = '1' " + + "AND F_NATC IN ('US', 'TW')", [parameter])), _ => throw new LegacySceneDataException("The stock-master lookup is invalid.") }; @@ -427,13 +443,22 @@ public sealed class LegacyParameterizedSceneRequestResolver : S8018SessionPhase.BeforeOrAtKrxClose; } - private static LegacyDomesticEquityMarket ToKrxMarket(S8018StockMarket market) => market switch + private static LegacyDomesticEquityMarket ToDomesticMarket( + S8018StockMarket market, + bool allowNxt) { - S8018StockMarket.Kospi => LegacyDomesticEquityMarket.Kospi, - S8018StockMarket.Kosdaq => LegacyDomesticEquityMarket.Kosdaq, - _ => throw new LegacySceneDataException( - "The selected scene branch supports only KRX stocks.") - }; + var result = market switch + { + S8018StockMarket.Kospi => LegacyDomesticEquityMarket.Kospi, + S8018StockMarket.Kosdaq => LegacyDomesticEquityMarket.Kosdaq, + S8018StockMarket.NxtKospi when allowNxt => LegacyDomesticEquityMarket.NxtKospi, + S8018StockMarket.NxtKosdaq when allowNxt => LegacyDomesticEquityMarket.NxtKosdaq, + _ => throw new LegacySceneDataException( + "The selected scene branch supports only the permitted domestic stock markets.") + }; + + return result; + } private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) => entry.Selection ?? throw new LegacySceneDataException( diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs index 410106c..54dbf5c 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -40,6 +40,34 @@ public interface ILegacySceneCueProvider CancellationToken cancellationToken = default); } +/// +/// Read-only page-zero DTO boundary used to calculate PageN before PREPARE. Implementations +/// must not build a cue, dispatch an engine command, or touch COM/vendor objects. +/// +public interface ILegacyScenePagePlanProvider +{ + Task CreatePagePlanAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default); +} + +public sealed record LegacyScenePagePlan( + string BuilderKey, + int ItemCount, + ScenePageSize PageSize, + int PageCount, + int AccessibleItemCount, + bool IsTruncated); + +public sealed record LegacyPlaylistPagePlan( + string EntryId, + int ItemCount, + int PageSize, + int PageCount, + int AccessibleItemCount, + bool IsTruncated, + string BuilderKey); + public enum LegacyWorkflowNextKind { None, @@ -73,6 +101,7 @@ public sealed class LegacyPlayoutWorkflow private readonly IPlayoutEngine _engine; private readonly ILegacySceneCueProvider _cueProvider; + private readonly ILegacyScenePagePlanProvider? _pagePlanProvider; private readonly SemaphoreSlim _gate = new(1, 1); private readonly object _engineStatusSync = new(); private IReadOnlyList _playlist = []; @@ -87,11 +116,80 @@ public sealed class LegacyPlayoutWorkflow { _engine = engine ?? throw new ArgumentNullException(nameof(engine)); _cueProvider = cueProvider ?? throw new ArgumentNullException(nameof(cueProvider)); + _pagePlanProvider = cueProvider as ILegacyScenePagePlanProvider; State = EmptyState; } public LegacyPlayoutWorkflowState State { get; private set; } + /// + /// Loads only trusted page-zero database DTOs and returns bounded PageN plans. The + /// workflow gate makes this mutually exclusive with PREPARE/TAKE IN/NEXT/TAKE OUT, + /// but no IPlayoutEngine method is called and workflow state is not changed. + /// + public async Task> PreflightPagePlansAsync( + IReadOnlyList entries, + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_prepared is not null || _onAir is not null) + { + throw new InvalidOperationException( + "Playlist page plans can be queried only while the playout workflow is idle."); + } + + var provider = _pagePlanProvider ?? throw new InvalidOperationException( + "The legacy scene provider does not expose read-only page preflight."); + var snapshot = ValidatePlaylistEntries(entries, nameof(entries)); + var results = new LegacyPlaylistPagePlan[snapshot.Count]; + for (var index = 0; index < snapshot.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = snapshot[index]; + var plan = await provider.CreatePagePlanAsync(entry, cancellationToken) + .ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(plan); + if (plan.ItemCount < 0 || + plan.PageSize is not (ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve) || + plan.PageCount is < 0 or > ScenePaging.MaximumPageCount || + plan.AccessibleItemCount < 0 || + plan.AccessibleItemCount > plan.ItemCount || + plan.IsTruncated != (plan.AccessibleItemCount < plan.ItemCount) || + string.IsNullOrWhiteSpace(plan.BuilderKey)) + { + throw new LegacySceneDataException( + "The scene provider returned an invalid page preflight plan."); + } + + var expected = ScenePaging.CreatePlan(plan.ItemCount, plan.PageSize); + if (!expected.IsValid || expected.Plan is null || + expected.Plan.TotalPages != plan.PageCount || + expected.Plan.AccessibleItemCount != plan.AccessibleItemCount) + { + throw new LegacySceneDataException( + "The scene provider page preflight does not match PageN rules."); + } + + results[index] = new LegacyPlaylistPagePlan( + entry.EntryId, + plan.ItemCount, + (int)plan.PageSize, + plan.PageCount, + plan.AccessibleItemCount, + plan.IsTruncated, + plan.BuilderKey); + } + + return Array.AsReadOnly(results); + } + finally + { + _gate.Release(); + } + } + /// /// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native /// playlist state. It never dispatches an engine command. If a command currently @@ -433,12 +531,24 @@ public sealed class LegacyPlayoutWorkflow IReadOnlyList? playlist, int cueIndexZeroBased) { - if (playlist is null || playlist.Count is < 1 or > MaximumPlaylistItems || - cueIndexZeroBased < 0 || cueIndexZeroBased >= playlist.Count) + var entries = ValidatePlaylistEntries(playlist, nameof(playlist)); + if (cueIndexZeroBased < 0 || cueIndexZeroBased >= entries.Count) { throw new ArgumentException("The playlist selection is invalid.", nameof(playlist)); } + return entries; + } + + private static IReadOnlyList ValidatePlaylistEntries( + IReadOnlyList? playlist, + string parameterName) + { + if (playlist is null || playlist.Count is < 1 or > MaximumPlaylistItems) + { + throw new ArgumentException("The playlist selection is invalid.", parameterName); + } + var entries = playlist.ToArray(); var ids = new HashSet(StringComparer.Ordinal); foreach (var entry in entries) @@ -448,7 +558,7 @@ public sealed class LegacyPlayoutWorkflow entry.FadeDuration is < 0 or > 60 || !IsSafeSelection(entry.Selection)) { - throw new ArgumentException("The playlist contains an invalid entry.", nameof(playlist)); + throw new ArgumentException("The playlist contains an invalid entry.", parameterName); } } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs index 935ff1c..c066685 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs @@ -9,6 +9,10 @@ public delegate Task LegacySceneDataLoaderDelegate( int pageIndexZeroBased, CancellationToken cancellationToken); +public delegate Task LegacyScenePagePlanDataLoaderDelegate( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken); + /// /// Native-only route from one or more closed cut aliases to a parameterized DTO loader. /// The delegate is composed in managed code; it is never selected by a Web-supplied type, @@ -18,10 +22,12 @@ public sealed class LegacySceneDataSourceRoute { public LegacySceneDataSourceRoute( IEnumerable cutCodes, - LegacySceneDataLoaderDelegate loader) + LegacySceneDataLoaderDelegate loader, + LegacyScenePagePlanDataLoaderDelegate? pagePlanLoader = null) { ArgumentNullException.ThrowIfNull(cutCodes); Loader = loader ?? throw new ArgumentNullException(nameof(loader)); + PagePlanLoader = pagePlanLoader; var codes = cutCodes.Select(ValidateCutCode).ToArray(); if (codes.Length == 0 || codes.Distinct(StringComparer.Ordinal).Count() != codes.Length) @@ -36,6 +42,8 @@ public sealed class LegacySceneDataSourceRoute internal LegacySceneDataLoaderDelegate Loader { get; } + internal LegacyScenePagePlanDataLoaderDelegate? PagePlanLoader { get; } + private static string ValidateCutCode(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length > 64 || @@ -52,14 +60,19 @@ public sealed class LegacySceneDataSourceRoute /// Immutable dispatcher used by the app composition root to join scene-specific DB loaders. /// Unknown cuts fail closed before any query is issued. /// -public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource +public sealed class LegacySceneDataSourceRouter : + ILegacySceneDataSource, + ILegacyScenePagePlanDataSource { private readonly IReadOnlyDictionary _loaders; + private readonly IReadOnlyDictionary _pagePlanLoaders; public LegacySceneDataSourceRouter(IEnumerable routes) { ArgumentNullException.ThrowIfNull(routes); var loaders = new Dictionary(StringComparer.Ordinal); + var pagePlanLoaders = new Dictionary( + StringComparer.Ordinal); foreach (var route in routes) { ArgumentNullException.ThrowIfNull(route); @@ -71,6 +84,14 @@ public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource "A cut code can have only one scene-data route.", nameof(routes)); } + + if (route.PagePlanLoader is not null && + !pagePlanLoaders.TryAdd(code, route.PagePlanLoader)) + { + throw new ArgumentException( + "A cut code can have only one scene page-plan route.", + nameof(routes)); + } } } @@ -80,6 +101,8 @@ public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource } _loaders = new ReadOnlyDictionary(loaders); + _pagePlanLoaders = new ReadOnlyDictionary( + pagePlanLoaders); CutCodes = Array.AsReadOnly(loaders.Keys.OrderBy(code => code, StringComparer.Ordinal).ToArray()); } @@ -104,4 +127,18 @@ public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource return loader(entry, pageIndexZeroBased, cancellationToken); } + + public Task LoadPagePlanDataAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + if (!_pagePlanLoaders.TryGetValue(entry.CutCode, out var loader)) + { + throw new LegacySceneDataException( + "The selected cut does not have a registered page preflight route."); + } + + return loader(entry, cancellationToken); + } } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs index 9c0e09a..b0f30b1 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs @@ -89,6 +89,7 @@ public sealed record NxtThemePagedQuoteLoadRequest( int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); public sealed record ExpertPagedQuoteLoadRequest( + string ExpertCode, string ExpertName, int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); @@ -112,6 +113,11 @@ public sealed class S5074SceneDataLoader PagedQuoteLoadRequest request, CancellationToken cancellationToken = default) => _core.LoadAsync(request, cancellationToken); + + public Task LoadPagePlanAsync( + PagedQuoteLoadRequest request, + CancellationToken cancellationToken = default) => + _core.LoadAsync(request, cancellationToken, includeTruncationProbe: true); } public sealed class S5077SceneDataLoader @@ -130,6 +136,11 @@ public sealed class S5077SceneDataLoader PagedQuoteLoadRequest request, CancellationToken cancellationToken = default) => _core.LoadAsync(request, cancellationToken); + + public Task LoadPagePlanAsync( + PagedQuoteLoadRequest request, + CancellationToken cancellationToken = default) => + _core.LoadAsync(request, cancellationToken, includeTruncationProbe: true); } public sealed class S5088SceneDataLoader @@ -148,6 +159,11 @@ public sealed class S5088SceneDataLoader PagedQuoteLoadRequest request, CancellationToken cancellationToken = default) => _core.LoadAsync(request, cancellationToken); + + public Task LoadPagePlanAsync( + PagedQuoteLoadRequest request, + CancellationToken cancellationToken = default) => + _core.LoadAsync(request, cancellationToken, includeTruncationProbe: true); } internal sealed class PagedQuoteSceneDataLoaderCore @@ -171,7 +187,8 @@ internal sealed class PagedQuoteSceneDataLoaderCore public async Task LoadAsync( PagedQuoteLoadRequest request, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool includeTruncationProbe = false) { ArgumentNullException.ThrowIfNull(request); if (request.PageNumberOneBased is < 1 or > ScenePaging.MaximumPageCount) @@ -180,14 +197,32 @@ internal sealed class PagedQuoteSceneDataLoaderCore "The paged quote request must select a page from 1 through 20."); } - var maximumRows = checked((int)_pageSize * ScenePaging.MaximumPageCount); - var plan = PagedQuoteQueryFactory.Create(request, maximumRows); + var accessibleRows = checked((int)_pageSize * ScenePaging.MaximumPageCount); + var queryRows = includeTruncationProbe + ? checked(accessibleRows + 1) + : accessibleRows; + var plan = PagedQuoteQueryFactory.Create(request, queryRows); var table = await _executor.ExecuteAsync( plan.Source, _tableName, plan.Query, cancellationToken).ConfigureAwait(false); - var rows = MapRows(table, maximumRows, plan.HeaderStyle); + var rows = MapRows( + table, + queryRows, + plan.HeaderStyle, + allowEmpty: includeTruncationProbe); + if (includeTruncationProbe && rows.Count == 0) + { + return new PagedQuoteSceneData( + plan.Branch, + plan.HeaderStyle, + plan.TitleStyle, + plan.Session, + plan.Title, + rows, + request.PageNumberOneBased); + } var window = ScenePaging.GetWindow( rows.Count, _pageSize, @@ -211,9 +246,11 @@ internal sealed class PagedQuoteSceneDataLoaderCore private static IReadOnlyList MapRows( DataTable? table, int maximumRows, - PagedQuoteHeaderStyle headerStyle) + PagedQuoteHeaderStyle headerStyle, + bool allowEmpty) { - if (table is null || table.Rows.Count is < 1 || table.Rows.Count > maximumRows) + if (table is null || (!allowEmpty && table.Rows.Count < 1) || + table.Rows.Count > maximumRows) { throw new LegacySceneDataException( "The paged quote query returned an invalid row count."); @@ -471,12 +508,19 @@ internal static class PagedQuoteQueryFactory ExpertPagedQuoteLoadRequest request, int maximumRows) { + var expertCode = RequiredSelection(request.ExpertCode); var expertName = RequiredSelection(request.ExpertName); + if (expertCode.Length != 4 || expertCode.Any(character => !char.IsAsciiDigit(character))) + { + throw InvalidRequest(); + } + return new PagedQuoteQueryPlan( DataSourceKind.Oracle, new DataQuerySpec( ExpertQuery, [ + new DataQueryParameter("expertCode", expertCode, DbType.String), new DataQueryParameter("expertName", expertName, DbType.String), new DataQueryParameter("maxRows", maximumRows, DbType.Int32) ]), @@ -1030,7 +1074,8 @@ internal static class PagedQuoteQueryFactory FROM EXPERT_LIST e JOIN RECOMMEND_LIST r ON e.EP_CODE = r.RC_CODE JOIN QUOTES q ON r.RC_STOCK_CODE = q.STOCK_CODE - WHERE e.EP_NAME = :expertName + WHERE e.EP_CODE = :expertCode + AND e.EP_NAME = :expertName AND r.RC_BUY_AMOUNT <> 0 ORDER BY r.PLAYINDEX ) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs index b8aacab..7a7df51 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs @@ -1085,7 +1085,7 @@ public sealed class S8018SceneMutationBuilder : ILegacySceneMutationBuilder +/// Supplies an immutable composition snapshot for one cue build. Native operator controls +/// may replace the snapshot only while the playout workflow is idle; Web content never +/// supplies an asset path. +/// +public interface ILegacySceneCueCompositionOptionsSource +{ + LegacySceneCueCompositionOptions Current { get; } +} + +public sealed class MutableLegacySceneCueCompositionOptionsSource : + ILegacySceneCueCompositionOptionsSource +{ + private LegacySceneCueCompositionOptions _current; + + public MutableLegacySceneCueCompositionOptionsSource( + LegacySceneCueCompositionOptions initial) + { + _current = RegisteredLegacySceneCueProvider.ValidateOptions(initial); + } + + public LegacySceneCueCompositionOptions Current => Volatile.Read(ref _current); + + public void Update(LegacySceneCueCompositionOptions value) + { + var validated = RegisteredLegacySceneCueProvider.ValidateOptions(value); + Interlocked.Exchange(ref _current, validated); + } +} + /// /// One trusted DTO produced by a scene-specific database loader. BuilderKey resolves /// conditional aliases such as 5032/8018 without accepting K3D object or method names. @@ -44,26 +74,54 @@ public interface ILegacySceneDataSource CancellationToken cancellationToken = default); } +/// +/// Optional read-only route that loads a bounded page-zero DTO with one extra row used +/// only to detect the 20-page truncation boundary. It never builds scene mutations. +/// +public interface ILegacyScenePagePlanDataSource +{ + Task LoadPagePlanDataAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default); +} + /// /// Converts a native DB DTO into a complete cue while preserving the legacy order: /// load/effect/background, BeginTransaction, ordered builder mutations, /// QueryVariables, EndTransaction, Prepare. The latter operations are performed by /// IPlayoutEngine; this provider exposes no COM or reflection surface. /// -public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider +public sealed class RegisteredLegacySceneCueProvider : + ILegacySceneCueProvider, + ILegacyScenePagePlanProvider { private readonly ILegacySceneDataSource _dataSource; + private readonly ILegacyScenePagePlanDataSource? _pagePlanDataSource; private readonly LegacySceneMutationBuilderRegistry _builders; - private readonly LegacySceneCueCompositionOptions _options; + private readonly ILegacySceneCueCompositionOptionsSource _optionsSource; public RegisteredLegacySceneCueProvider( ILegacySceneDataSource dataSource, LegacySceneMutationBuilderRegistry? builders = null, LegacySceneCueCompositionOptions? options = null) + : this( + dataSource, + new FixedCompositionOptionsSource( + ValidateOptions(options ?? LegacySceneCueCompositionOptions.Default)), + builders) + { + } + + public RegisteredLegacySceneCueProvider( + ILegacySceneDataSource dataSource, + ILegacySceneCueCompositionOptionsSource optionsSource, + LegacySceneMutationBuilderRegistry? builders = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _pagePlanDataSource = dataSource as ILegacyScenePagePlanDataSource; _builders = builders ?? LegacySceneMutationBuilderRegistry.Default; - _options = ValidateOptions(options ?? LegacySceneCueCompositionOptions.Default); + _optionsSource = optionsSource ?? throw new ArgumentNullException(nameof(optionsSource)); + _ = ValidateOptions(_optionsSource.Current); } public async Task CreatePageAsync( @@ -77,6 +135,10 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased)); } + // Capture once before the asynchronous DB load. F2/F3 changes can affect only a + // later PREPARE and cannot alter a cue halfway through construction. + var options = ValidateOptions(_optionsSource.Current); + var loaded = await _dataSource.LoadPageAsync( entry, pageIndexZeroBased, @@ -88,15 +150,7 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider throw new LegacySceneDataException("A scene loader returned an invalid item count."); } - var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate => - string.Equals(candidate.BuilderKey, loaded.BuilderKey, StringComparison.Ordinal)); - if (definition is null || - definition.Reachability == LegacySceneReachability.NoMainFormDispatch || - !definition.CutAliases.Contains(entry.CutCode, StringComparer.Ordinal)) - { - throw new LegacySceneDataException( - "The scene loader returned a builder that is not valid for the selected cut."); - } + var definition = RequireDefinition(entry, loaded); var pageSize = ConvertPageSize(definition.PageSize); if (pageSize is null && pageIndexZeroBased != 0) @@ -119,14 +173,14 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider var builderMutations = _builders.Build(definition.BuilderKey, loaded.Data); var mutations = new List(builderMutations.Count + 2); - AddBackgroundMutations(mutations, _options); + AddBackgroundMutations(mutations, options); mutations.AddRange(builderMutations); return new LegacySceneCuePage( new PlayoutCue( entry.CutCode + ".t2s", entry.CutCode, - FadeDuration: entry.FadeDuration ?? _options.FadeDuration, + FadeDuration: entry.FadeDuration ?? options.FadeDuration, Mutations: Array.AsReadOnly(mutations.ToArray())), loaded.ItemCount, pageSize, @@ -134,6 +188,58 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider LegacyScenePreviewFactory.Create(Array.AsReadOnly(mutations.ToArray()))); } + public async Task CreatePagePlanAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + var source = _pagePlanDataSource ?? throw new InvalidOperationException( + "The registered scene data source does not expose page preflight data."); + var loaded = await source.LoadPagePlanDataAsync(entry, cancellationToken) + .ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(loaded); + ArgumentNullException.ThrowIfNull(loaded.Data); + if (loaded.ItemCount < 0) + { + throw new LegacySceneDataException("A scene page preflight returned an invalid item count."); + } + + var definition = RequireDefinition(entry, loaded); + var pageSize = ConvertPageSize(definition.PageSize) ?? throw new LegacySceneDataException( + "Page preflight is available only for a 5-, 6-, or 12-row scene."); + var result = ScenePaging.CreatePlan(loaded.ItemCount, pageSize); + if (!result.IsValid || result.Plan is null) + { + throw new LegacySceneDataException("The scene page preflight returned invalid paging data."); + } + + var plan = result.Plan; + return new LegacyScenePagePlan( + definition.BuilderKey, + plan.ItemCount, + plan.PageSize, + plan.TotalPages, + plan.AccessibleItemCount, + plan.ExcludedItemCount > 0); + } + + private static LegacySceneDefinition RequireDefinition( + LegacyPlaylistEntry entry, + LegacySceneDataPage loaded) + { + var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate => + string.Equals(candidate.BuilderKey, loaded.BuilderKey, StringComparison.Ordinal)); + if (definition is null || + definition.Reachability == LegacySceneReachability.NoMainFormDispatch || + !definition.CutAliases.Contains(entry.CutCode, StringComparer.Ordinal)) + { + throw new LegacySceneDataException( + "The scene loader returned a builder that is not valid for the selected cut."); + } + + return definition; + } + private static ScenePageSize? ConvertPageSize(LegacyScenePageSize value) => value switch { LegacyScenePageSize.None => null, @@ -143,9 +249,10 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider _ => throw new LegacySceneDataException("The scene catalog contains an invalid page size.") }; - private static LegacySceneCueCompositionOptions ValidateOptions( + internal static LegacySceneCueCompositionOptions ValidateOptions( LegacySceneCueCompositionOptions options) { + ArgumentNullException.ThrowIfNull(options); if (options.FadeDuration is < 0 or > 60 || !Enum.IsDefined(options.BackgroundKind) || (options.BackgroundKind == LegacySceneBackgroundKind.Video && @@ -203,4 +310,10 @@ public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider throw new InvalidOperationException("Unknown background kind."); } } + + private sealed class FixedCompositionOptionsSource( + LegacySceneCueCompositionOptions current) : ILegacySceneCueCompositionOptionsSource + { + public LegacySceneCueCompositionOptions Current { get; } = current; + } } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedViManualReference.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedViManualReference.cs new file mode 100644 index 0000000..d895e8f --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedViManualReference.cs @@ -0,0 +1,119 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// A versioned, trusted projection of the operator-owned VI file. Browser playlist +/// payloads carry only and +/// ; stock names are resolved again inside the native boundary. +/// +public sealed record TrustedViStockNameSnapshot( + IReadOnlyList StockNames, + string RowVersion); + +public interface ITrustedViStockNameSnapshotSource +{ + Task ReadStockNameSnapshotAsync( + CancellationToken cancellationToken = default); +} + +public static class TrustedViManualReference +{ + public const string SubjectSentinel = "@MBN_VI_SNAPSHOT_V1"; + public const int RowVersionLength = 64; + public const int MaximumItemCount = 100; + + public static bool IsRowVersion(string? value) => + value is { Length: RowVersionLength } && + value.All(character => + character is >= '0' and <= '9' or >= 'a' and <= 'f'); + + /// + /// Resolves a PAGED_VI selection. Versioned selections fail closed unless the + /// current trusted snapshot is byte-for-byte the version selected by the operator. + /// Historical comma-name selections remain available only with an empty data code. + /// + public static async Task ResolveAsync( + LegacySceneSelection selection, + int pageIndexZeroBased, + ITrustedViStockNameSnapshotSource? trustedSource, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(selection); + if (!string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) || + pageIndexZeroBased < 0) + { + throw new LegacySceneDataException("The VI paged selection is invalid."); + } + + var page = checked(pageIndexZeroBased + 1); + if (string.Equals(selection.Subject, SubjectSentinel, StringComparison.Ordinal)) + { + if (trustedSource is null || !IsRowVersion(selection.DataCode)) + { + throw new LegacySceneDataException( + "The trusted VI snapshot reference is unavailable or invalid."); + } + + var snapshot = await trustedSource.ReadStockNameSnapshotAsync(cancellationToken) + .ConfigureAwait(false); + if (!IsRowVersion(snapshot.RowVersion) || + !string.Equals( + selection.DataCode, + snapshot.RowVersion, + StringComparison.Ordinal)) + { + throw new LegacySceneDataException( + "The trusted VI snapshot changed after playlist creation."); + } + + var names = ValidateTrustedNames(snapshot.StockNames); + return new ViPagedQuoteLoadRequest(names, page); + } + + // A version is meaningful only with the exact reserved sentinel. This closes + // the downgrade path where a modified sentinel could otherwise be treated as + // one historical stock name. + if (!string.IsNullOrEmpty(selection.DataCode) || + selection.Subject.StartsWith("@MBN_VI_SNAPSHOT_", StringComparison.Ordinal)) + { + throw new LegacySceneDataException("The trusted VI snapshot reference is invalid."); + } + + return new ViPagedQuoteLoadRequest(ParseHistoricalNames(selection.Subject), page); + } + + private static IReadOnlyList ValidateTrustedNames(IReadOnlyList? values) + { + if (values is null || values.Count is < 1 or > MaximumItemCount) + { + throw new LegacySceneDataException("The trusted VI snapshot is empty or invalid."); + } + + var names = values.ToArray(); + if (names.Any(value => + string.IsNullOrWhiteSpace(value) || + value.Length > 200 || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + value.Contains(',') || + value.Any(char.IsControl))) + { + throw new LegacySceneDataException("The trusted VI snapshot contains invalid data."); + } + + return Array.AsReadOnly(names); + } + + private static IReadOnlyList ParseHistoricalNames(string value) + { + var names = value.Split( + ',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (names.Length is < 1 or > 240 || names.Any(name => name.Any(char.IsControl))) + { + throw new LegacySceneDataException("The VI stock-name selection is invalid."); + } + + return Array.AsReadOnly(names); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs new file mode 100644 index 0000000..f876ac2 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs @@ -0,0 +1,369 @@ +#nullable enable + +using System.Data; +using System.Data.Common; +using MMoneyCoderSharp.Data; +using MySqlConnector; +using Oracle.ManagedDataAccess.Client; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +/// +/// Executes the closed SB_LIST/SB_ITEM and EXPERT_LIST/RECOMMEND_LIST command +/// sets exactly once. Every operation uses one serializable transaction. There +/// is intentionally no transient retry path for state-changing commands. +/// +public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationExecutor +{ + private readonly IDatabaseConnectionFactory _connectionFactory; + private readonly ITransientDatabaseErrorDetector _errorDetector; + private readonly TimeSpan _operationTimeout; + private readonly Action _configureCommand; + + public OperatorCatalogMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector? errorDetector = null) + : this( + connectionFactory, + resilienceOptions, + errorDetector ?? new TransientDatabaseErrorDetector(), + ConfigureProviderCommand) + { + } + + internal OperatorCatalogMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector errorDetector, + Action configureCommand) + { + _connectionFactory = connectionFactory ?? + throw new ArgumentNullException(nameof(connectionFactory)); + ArgumentNullException.ThrowIfNull(resilienceOptions); + _errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector)); + _configureCommand = configureCommand ?? + throw new ArgumentNullException(nameof(configureCommand)); + + var validationOptions = new DatabaseOptions { Resilience = resilienceOptions }; + validationOptions.ValidateRuntimeSettings(); + _operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds); + } + + public async Task ExecuteTransactionAsync( + OperatorCatalogDbTransaction transaction, + CancellationToken cancellationToken = default) + { + ValidateTransaction(transaction); + cancellationToken.ThrowIfCancellationRequested(); + var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source); + if (commandTimeoutSeconds is < 1 or > 600) + { + throw new DatabaseConfigurationException( + "The operator-catalog command timeout must be between 1 and 600 seconds."); + } + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(_operationTimeout); + var operationToken = timeoutSource.Token; + + DbConnection? connection = null; + DbTransaction? databaseTransaction = null; + var commitStarted = false; + try + { + connection = _connectionFactory.Create(transaction.Source); + await connection.OpenAsync(operationToken).ConfigureAwait(false); + databaseTransaction = await connection.BeginTransactionAsync( + IsolationLevel.Serializable, + operationToken) + .ConfigureAwait(false); + + var affectedRows = new List(transaction.Commands.Count); + foreach (var commandSpec in transaction.Commands) + { + operationToken.ThrowIfCancellationRequested(); + await using var command = connection.CreateCommand(); + command.CommandText = commandSpec.Sql; + command.CommandType = CommandType.Text; + command.CommandTimeout = commandTimeoutSeconds; + command.Transaction = databaseTransaction; + _configureCommand(transaction.Source, command); + + foreach (var parameterSpec in commandSpec.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = parameterSpec.Name; + parameter.Direction = ParameterDirection.Input; + parameter.DbType = parameterSpec.DbType; + parameter.Value = parameterSpec.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + // Execute exactly once and enforce the optimistic/uniqueness + // precondition before any later command can run. + var count = await command.ExecuteNonQueryAsync(operationToken) + .ConfigureAwait(false); + if (count < 0 || + commandSpec.AffectedRowsRule == + OperatorCatalogAffectedRowsRule.ExactlyOne && count != 1) + { + throw new AffectedRowsConflictException(commandSpec.Kind, count); + } + + affectedRows.Add(count); + } + + commitStarted = true; + await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false); + return new OperatorCatalogDbTransactionResult( + transaction.OperationId, + Array.AsReadOnly(affectedRows.ToArray()), + DateTimeOffset.UtcNow); + } + catch (Exception exception) + { + if (commitStarted) + { + // The provider may have committed even if the client observed a + // timeout or disconnect. Do not issue rollback or retry commands. + throw UnknownOutcome(transaction.Kind, exception); + } + + if (databaseTransaction is null) + { + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction did not start and did not commit.", + outcomeUnknown: false, + exception); + } + + var interrupted = exception is OperationCanceledException || + _errorDetector.IsTimeout(transaction.Source, exception); + var rollbackSucceeded = await TryRollbackAsync( + databaseTransaction, + commandTimeoutSeconds) + .ConfigureAwait(false); + + // Once a command is in flight, cancellation/timeout is treated as + // OutcomeUnknown even when rollback returned. This is deliberately + // conservative and prohibits an automatic retry. + if (interrupted || !rollbackSucceeded) + { + throw UnknownOutcome(transaction.Kind, exception); + } + + if (exception is AffectedRowsConflictException conflict) + { + throw new OperatorCatalogConflictException( + transaction.Kind, + conflict.CommandKind, + exception); + } + + throw new OperatorCatalogMutationException( + $"The {transaction.Kind} transaction was rolled back and did not commit.", + outcomeUnknown: false, + exception); + } + finally + { + await DisposeQuietlyAsync(databaseTransaction).ConfigureAwait(false); + await DisposeQuietlyAsync(connection).ConfigureAwait(false); + } + } + + internal static void ConfigureProviderCommand( + DataSourceKind source, + DbCommand command) + { + ArgumentNullException.ThrowIfNull(command); + switch (source) + { + case DataSourceKind.Oracle when command is OracleCommand oracleCommand: + oracleCommand.BindByName = true; + break; + case DataSourceKind.MariaDb when command is MySqlCommand: + break; + case DataSourceKind.Oracle: + case DataSourceKind.MariaDb: + throw new DatabaseConfigurationException( + "The operator-catalog connection created the wrong provider command."); + default: + throw new ArgumentOutOfRangeException(nameof(source), source, null); + } + } + + private static async Task TryRollbackAsync( + DbTransaction transaction, + int timeoutSeconds) + { + try + { + using var rollbackTimeout = new CancellationTokenSource( + TimeSpan.FromSeconds(timeoutSeconds)); + await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + + private static async Task DisposeQuietlyAsync(IAsyncDisposable? disposable) + { + if (disposable is null) + { + return; + } + + try + { + await disposable.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Commit/rollback outcome has already been classified. Disposal must + // not replace that evidence or trigger another database operation. + } + } + + private static void ValidateTransaction(OperatorCatalogDbTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + if (transaction.OperationId == Guid.Empty || + !Enum.IsDefined(transaction.Kind) || + !Enum.IsDefined(transaction.Source) || + transaction.Commands.Count == 0 || + transaction.Commands.Any(static command => command is null)) + { + throw new ArgumentException( + "The operator-catalog transaction is invalid.", + nameof(transaction)); + } + + var themeMutation = transaction.Kind is + OperatorCatalogMutationKind.CreateTheme or + OperatorCatalogMutationKind.ReplaceTheme or + OperatorCatalogMutationKind.DeleteTheme; + var identityLength = themeMutation ? 8 : 4; + if (transaction.IdentityCode.Length != identityLength || + transaction.IdentityCode.Any(static character => !char.IsAsciiDigit(character)) || + !themeMutation && transaction.Source != DataSourceKind.Oracle) + { + throw new ArgumentException( + "The operator-catalog transaction identity or source is invalid.", + nameof(transaction)); + } + + var commandKinds = transaction.Commands.Select(static command => command.Kind).ToArray(); + var validShape = transaction.Kind switch + { + OperatorCatalogMutationKind.CreateTheme => + commandKinds[0] == OperatorCatalogDbCommandKind.InsertTheme && + commandKinds.Skip(1).All(static kind => + kind == OperatorCatalogDbCommandKind.InsertThemeItem), + OperatorCatalogMutationKind.ReplaceTheme => + commandKinds.Length >= 2 && + commandKinds[0] == OperatorCatalogDbCommandKind.UpdateTheme && + commandKinds[1] == OperatorCatalogDbCommandKind.DeleteThemeItems && + commandKinds.Skip(2).All(static kind => + kind == OperatorCatalogDbCommandKind.InsertThemeItem), + OperatorCatalogMutationKind.DeleteTheme => + commandKinds.SequenceEqual( + [ + OperatorCatalogDbCommandKind.DeleteThemeItems, + OperatorCatalogDbCommandKind.DeleteTheme + ]), + OperatorCatalogMutationKind.CreateExpert => + commandKinds[0] == OperatorCatalogDbCommandKind.InsertExpert && + commandKinds.Skip(1).All(static kind => + kind == OperatorCatalogDbCommandKind.InsertRecommendation), + OperatorCatalogMutationKind.ReplaceExpert => + commandKinds.Length >= 2 && + commandKinds[0] == OperatorCatalogDbCommandKind.UpdateExpert && + commandKinds[1] == OperatorCatalogDbCommandKind.DeleteRecommendations && + commandKinds.Skip(2).All(static kind => + kind == OperatorCatalogDbCommandKind.InsertRecommendation), + OperatorCatalogMutationKind.DeleteExpert => + commandKinds.SequenceEqual( + [ + OperatorCatalogDbCommandKind.DeleteRecommendations, + OperatorCatalogDbCommandKind.DeleteExpert + ]), + _ => false + }; + if (!validShape) + { + throw new ArgumentException( + "The operator-catalog transaction command order is invalid.", + nameof(transaction)); + } + + var marker = transaction.Source == DataSourceKind.Oracle ? ':' : '@'; + var identityParameterName = themeMutation ? "theme_code" : "expert_code"; + foreach (var command in transaction.Commands) + { + if (string.IsNullOrWhiteSpace(command.Sql) || + command.Sql.Contains(';', StringComparison.Ordinal) || + !Enum.IsDefined(command.Kind) || + !Enum.IsDefined(command.AffectedRowsRule) || + command.Parameters.Count == 0 || + command.Parameters.Any(static parameter => parameter is null)) + { + throw new ArgumentException( + "An operator-catalog command is invalid.", + nameof(transaction)); + } + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in command.Parameters) + { + if (!names.Add(parameter.Name) || + command.Sql.IndexOf( + string.Concat(marker, parameter.Name), + StringComparison.OrdinalIgnoreCase) < 0) + { + throw new ArgumentException( + "An operator-catalog command parameter is invalid.", + nameof(transaction)); + } + } + + var identityParameter = command.Parameters.SingleOrDefault(parameter => + string.Equals( + parameter.Name, + identityParameterName, + StringComparison.OrdinalIgnoreCase)); + if (identityParameter?.Value is not string commandIdentity || + !string.Equals( + commandIdentity, + transaction.IdentityCode, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "An operator-catalog command identity is invalid.", + nameof(transaction)); + } + } + } + + private static OperatorCatalogMutationException UnknownOutcome( + OperatorCatalogMutationKind kind, + Exception exception) => + new( + $"The {kind} transaction outcome is unknown; do not retry automatically.", + outcomeUnknown: true, + exception); + + private sealed class AffectedRowsConflictException( + OperatorCatalogDbCommandKind commandKind, + int affectedRows) + : Exception("An operator-catalog affected-row precondition failed.") + { + internal OperatorCatalogDbCommandKind CommandKind { get; } = commandKind; + + internal int AffectedRows { get; } = affectedRows; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs new file mode 100644 index 0000000..8625afe --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs @@ -0,0 +1,345 @@ +#nullable enable + +using System.Data; +using System.Data.Common; +using System.Text.RegularExpressions; +using MMoneyCoderSharp.Data; +using Oracle.ManagedDataAccess.Client; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +/// +/// Executes the closed GraphE INPUT_* write contract on one Oracle connection +/// and one transaction. Each operation takes the profile's exclusive table lock, +/// executes its bound command once, validates affected rows before commit, and +/// deliberately contains no retry loop. +/// +public sealed partial class OracleManualFinancialMutationExecutor + : IManualFinancialMutationExecutor +{ + private readonly IDatabaseConnectionFactory _connectionFactory; + private readonly ITransientDatabaseErrorDetector _errorDetector; + private readonly TimeSpan _operationTimeout; + private readonly int _commandTimeoutSeconds; + private readonly Action _configureCommand; + + public OracleManualFinancialMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector? errorDetector = null) + : this( + connectionFactory, + resilienceOptions, + errorDetector ?? new TransientDatabaseErrorDetector(), + ConfigureOracleCommand) + { + } + + internal OracleManualFinancialMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector errorDetector, + Action configureCommand) + { + _connectionFactory = connectionFactory ?? + throw new ArgumentNullException(nameof(connectionFactory)); + ArgumentNullException.ThrowIfNull(resilienceOptions); + _errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector)); + _configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand)); + + var validationOptions = new DatabaseOptions { Resilience = resilienceOptions }; + validationOptions.ValidateRuntimeSettings(); + _operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds); + _commandTimeoutSeconds = connectionFactory.GetCommandTimeoutSeconds(DataSourceKind.Oracle); + if (_commandTimeoutSeconds is < 1 or > 600) + { + throw new DatabaseConfigurationException( + "The Oracle manual-financial command timeout must be between 1 and 600 seconds."); + } + } + + public async Task ExecuteTransactionAsync( + DataSourceKind source, + ManualFinancialDbTransaction transaction, + CancellationToken cancellationToken = default) + { + if (source != DataSourceKind.Oracle) + { + throw new ArgumentOutOfRangeException( + nameof(source), + source, + "Manual-financial GraphE records are stored in Oracle."); + } + + ValidateTransaction(transaction); + cancellationToken.ThrowIfCancellationRequested(); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(_operationTimeout); + var operationToken = timeoutSource.Token; + + DbConnection? connection = null; + DbTransaction? databaseTransaction = null; + var commitStarted = false; + try + { + connection = _connectionFactory.Create(DataSourceKind.Oracle); + await connection.OpenAsync(operationToken).ConfigureAwait(false); + databaseTransaction = await connection.BeginTransactionAsync( + IsolationLevel.ReadCommitted, + operationToken) + .ConfigureAwait(false); + + await ExecuteLockAsync( + connection, + databaseTransaction, + transaction.ExclusiveTableLockSql, + operationToken).ConfigureAwait(false); + + var affectedRows = new List(transaction.Commands.Count); + foreach (var commandSpec in transaction.Commands) + { + operationToken.ThrowIfCancellationRequested(); + var count = await ExecuteCommandAsync( + connection, + databaseTransaction, + commandSpec, + operationToken).ConfigureAwait(false); + if (!Matches(commandSpec.AffectedRowsRule, count)) + { + throw Rejected(transaction.Kind, count); + } + + affectedRows.Add(count); + } + + commitStarted = true; + await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false); + return new ManualFinancialDbTransactionResult( + transaction.OperationId, + Array.AsReadOnly(affectedRows.ToArray()), + DateTimeOffset.UtcNow); + } + catch (Exception exception) + { + if (commitStarted) + { + // The client cannot prove whether Oracle committed. Issuing a + // rollback or repeating the command would make the situation worse. + throw UnknownOutcome(transaction.Kind, exception); + } + + var isCancellationOrTimeout = exception is OperationCanceledException || + _errorDetector.IsTimeout(DataSourceKind.Oracle, exception); + var rollbackFailed = false; + if (databaseTransaction is not null) + { + rollbackFailed = !await TryRollbackAsync(databaseTransaction) + .ConfigureAwait(false); + } + + if (databaseTransaction is not null && (isCancellationOrTimeout || rollbackFailed)) + { + throw UnknownOutcome(transaction.Kind, exception); + } + + if (exception is ManualFinancialMutationRejectedException rejected) + { + throw rejected; + } + + if (exception is ManualFinancialMutationException mutationException) + { + throw mutationException; + } + + throw new ManualFinancialMutationException( + $"The {transaction.Kind} Oracle transaction did not commit.", + outcomeUnknown: false, + exception); + } + finally + { + if (databaseTransaction is not null) + { + await databaseTransaction.DisposeAsync().ConfigureAwait(false); + } + + if (connection is not null) + { + await connection.DisposeAsync().ConfigureAwait(false); + } + } + } + + internal static void ConfigureOracleCommand(DbCommand command) + { + ArgumentNullException.ThrowIfNull(command); + if (command is not OracleCommand oracleCommand) + { + throw new DatabaseConfigurationException( + "The manual-financial mutation connection did not create an Oracle command."); + } + + oracleCommand.BindByName = true; + } + + private async Task ExecuteLockAsync( + DbConnection connection, + DbTransaction transaction, + string sql, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + Configure(command, transaction, sql); + // Execute exactly once. LOCK TABLE has no operator value and no retry. + _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteCommandAsync( + DbConnection connection, + DbTransaction transaction, + ManualFinancialDbCommand commandSpec, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + Configure(command, transaction, commandSpec.Sql); + foreach (var parameterSpec in commandSpec.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = parameterSpec.Name; + parameter.Direction = ParameterDirection.Input; + parameter.DbType = parameterSpec.DbType; + parameter.Value = parameterSpec.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + // Execute exactly once. There is deliberately no transient retry. + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private void Configure(DbCommand command, DbTransaction transaction, string sql) + { + command.CommandText = sql; + command.CommandType = CommandType.Text; + command.CommandTimeout = _commandTimeoutSeconds; + command.Transaction = transaction; + _configureCommand(command); + } + + private async Task TryRollbackAsync(DbTransaction transaction) + { + try + { + using var rollbackTimeout = new CancellationTokenSource( + TimeSpan.FromSeconds(_commandTimeoutSeconds)); + await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + + private static void ValidateTransaction(ManualFinancialDbTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + var expected = transaction.Screen switch + { + ManualFinancialScreenKind.RevenueComposition => + (Table: "INPUT_PIE", Lock: "LOCK TABLE INPUT_PIE IN EXCLUSIVE MODE"), + ManualFinancialScreenKind.GrowthMetrics => + (Table: "INPUT_GROW", Lock: "LOCK TABLE INPUT_GROW IN EXCLUSIVE MODE"), + ManualFinancialScreenKind.Sales => + (Table: "INPUT_SELL", Lock: "LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE"), + ManualFinancialScreenKind.OperatingProfit => + (Table: "INPUT_PROFIT", Lock: "LOCK TABLE INPUT_PROFIT IN EXCLUSIVE MODE"), + _ => throw InvalidTransaction() + }; + if (transaction.OperationId == Guid.Empty || + !transaction.RequiresExclusiveTableLock || + !string.Equals(transaction.TableName, expected.Table, StringComparison.Ordinal) || + !string.Equals(transaction.ExclusiveTableLockSql, expected.Lock, StringComparison.Ordinal) || + transaction.Commands.Count != 1 || transaction.Commands[0] is null) + { + throw InvalidTransaction(); + } + + var command = transaction.Commands[0]; + var validShape = transaction.Kind switch + { + ManualFinancialMutationKind.Create => + command.Kind == ManualFinancialDbCommandKind.Insert && + command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne, + ManualFinancialMutationKind.Update => + command.Kind == ManualFinancialDbCommandKind.Update && + command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne, + ManualFinancialMutationKind.DeleteOne => + command.Kind == ManualFinancialDbCommandKind.DeleteOne && + command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne, + ManualFinancialMutationKind.DeleteAll => + command.Kind == ManualFinancialDbCommandKind.DeleteAll && + command.AffectedRowsRule == ManualFinancialAffectedRowsRule.AnyNonNegative, + _ => false + }; + if (!validShape || string.IsNullOrWhiteSpace(command.Sql) || command.Sql.Contains(';')) + { + throw InvalidTransaction(); + } + + var placeholders = PlaceholderPattern() + .Matches(command.Sql) + .Select(static match => match.Groups[1].Value) + .ToArray(); + var parameters = command.Parameters.ToArray(); + if (parameters.Any(static parameter => + parameter is null || + parameter.DbType != DbType.String || + parameter.Value is not string) || + parameters.Select(static parameter => parameter.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() != parameters.Length || + !placeholders.SequenceEqual( + parameters.Select(static parameter => parameter.Name), + StringComparer.OrdinalIgnoreCase)) + { + throw InvalidTransaction(); + } + } + + private static bool Matches(ManualFinancialAffectedRowsRule rule, int count) => + rule switch + { + ManualFinancialAffectedRowsRule.ExactlyOne => count == 1, + ManualFinancialAffectedRowsRule.AnyNonNegative => count >= 0, + _ => false + }; + + private static ManualFinancialMutationRejectedException Rejected( + ManualFinancialMutationKind kind, + int affectedRows) + { + var reason = affectedRows is < 0 or > 1 + ? ManualFinancialMutationRejectionReason.AmbiguousIdentity + : kind == ManualFinancialMutationKind.Create + ? ManualFinancialMutationRejectionReason.DuplicateIdentity + : ManualFinancialMutationRejectionReason.NotFoundOrChanged; + return new ManualFinancialMutationRejectedException( + reason, + $"The {kind} precondition failed and the Oracle transaction was rolled back."); + } + + private static ManualFinancialMutationException UnknownOutcome( + ManualFinancialMutationKind kind, + Exception exception) => + new( + $"The {kind} Oracle transaction outcome is unknown; do not retry automatically.", + outcomeUnknown: true, + exception); + + private static ArgumentException InvalidTransaction() => + new("The manual-financial transaction is invalid.", "transaction"); + + [GeneratedRegex(@":([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)] + private static partial Regex PlaceholderPattern(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs new file mode 100644 index 0000000..059320c --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs @@ -0,0 +1,266 @@ +#nullable enable + +using System.Data; +using System.Data.Common; +using MMoneyCoderSharp.Data; +using Oracle.ManagedDataAccess.Client; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +/// +/// Executes the closed DC_LIST/PLAY_LIST mutation contract on one Oracle +/// connection and one transaction. Unlike the read executor, this class has no +/// retry loop: repeating a write after a timeout or ambiguous commit could +/// duplicate or destroy an operator playlist. +/// +public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutationExecutor +{ + private readonly IDatabaseConnectionFactory _connectionFactory; + private readonly ITransientDatabaseErrorDetector _errorDetector; + private readonly TimeSpan _operationTimeout; + private readonly int _commandTimeoutSeconds; + private readonly Action _configureCommand; + + public OracleNamedPlaylistMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector? errorDetector = null) + : this( + connectionFactory, + resilienceOptions, + errorDetector ?? new TransientDatabaseErrorDetector(), + ConfigureOracleCommand) + { + } + + internal OracleNamedPlaylistMutationExecutor( + IDatabaseConnectionFactory connectionFactory, + DatabaseResilienceOptions resilienceOptions, + ITransientDatabaseErrorDetector errorDetector, + Action configureCommand) + { + _connectionFactory = connectionFactory ?? + throw new ArgumentNullException(nameof(connectionFactory)); + ArgumentNullException.ThrowIfNull(resilienceOptions); + _errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector)); + _configureCommand = configureCommand ?? + throw new ArgumentNullException(nameof(configureCommand)); + + var validationOptions = new DatabaseOptions { Resilience = resilienceOptions }; + validationOptions.ValidateRuntimeSettings(); + _operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds); + _commandTimeoutSeconds = connectionFactory.GetCommandTimeoutSeconds(DataSourceKind.Oracle); + if (_commandTimeoutSeconds is < 1 or > 600) + { + throw new DatabaseConfigurationException( + "The Oracle named-playlist command timeout must be between 1 and 600 seconds."); + } + } + + public async Task ExecuteTransactionAsync( + DataSourceKind source, + NamedPlaylistDbTransaction transaction, + CancellationToken cancellationToken = default) + { + if (source != DataSourceKind.Oracle) + { + throw new ArgumentOutOfRangeException( + nameof(source), + source, + "Named playlists are stored in Oracle."); + } + + ValidateTransaction(transaction); + cancellationToken.ThrowIfCancellationRequested(); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(_operationTimeout); + var operationToken = timeoutSource.Token; + + DbConnection? connection = null; + DbTransaction? databaseTransaction = null; + var commitStarted = false; + try + { + connection = _connectionFactory.Create(DataSourceKind.Oracle); + await connection.OpenAsync(operationToken).ConfigureAwait(false); + databaseTransaction = await connection.BeginTransactionAsync( + IsolationLevel.ReadCommitted, + operationToken) + .ConfigureAwait(false); + + var affectedRows = new List(transaction.Commands.Count); + foreach (var commandSpec in transaction.Commands) + { + operationToken.ThrowIfCancellationRequested(); + await using var command = connection.CreateCommand(); + command.CommandText = commandSpec.Sql; + command.CommandType = CommandType.Text; + command.CommandTimeout = _commandTimeoutSeconds; + command.Transaction = databaseTransaction; + _configureCommand(command); + + foreach (var parameterSpec in commandSpec.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = parameterSpec.Name; + parameter.Direction = ParameterDirection.Input; + parameter.DbType = parameterSpec.DbType; + parameter.Value = parameterSpec.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + // Execute exactly once. There is deliberately no transient retry. + var count = await command.ExecuteNonQueryAsync(operationToken) + .ConfigureAwait(false); + affectedRows.Add(count); + } + + commitStarted = true; + await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false); + return new NamedPlaylistDbTransactionResult( + transaction.OperationId, + Array.AsReadOnly(affectedRows.ToArray()), + DateTimeOffset.UtcNow); + } + catch (Exception exception) + { + if (commitStarted) + { + // Commit may have reached Oracle even when the client observed a + // timeout/cancellation/disconnect. A rollback here cannot prove + // whether commit succeeded, so do not issue another DB command. + throw UnknownOutcome(transaction.Kind, exception); + } + + var isCancellationOrTimeout = exception is OperationCanceledException || + _errorDetector.IsTimeout(DataSourceKind.Oracle, exception); + var rollbackFailed = false; + if (databaseTransaction is not null) + { + rollbackFailed = !await TryRollbackAsync(databaseTransaction) + .ConfigureAwait(false); + } + + var outcomeUnknown = databaseTransaction is not null && + (isCancellationOrTimeout || rollbackFailed); + if (outcomeUnknown) + { + throw UnknownOutcome(transaction.Kind, exception); + } + + throw new NamedPlaylistMutationException( + $"The {transaction.Kind} Oracle transaction did not commit.", + outcomeUnknown: false, + exception); + } + finally + { + if (databaseTransaction is not null) + { + await databaseTransaction.DisposeAsync().ConfigureAwait(false); + } + + if (connection is not null) + { + await connection.DisposeAsync().ConfigureAwait(false); + } + } + } + + internal static void ConfigureOracleCommand(DbCommand command) + { + ArgumentNullException.ThrowIfNull(command); + if (command is not OracleCommand oracleCommand) + { + throw new DatabaseConfigurationException( + "The named-playlist mutation connection did not create an Oracle command."); + } + + oracleCommand.BindByName = true; + } + + private async Task TryRollbackAsync(DbTransaction transaction) + { + try + { + using var rollbackTimeout = new CancellationTokenSource( + TimeSpan.FromSeconds(_commandTimeoutSeconds)); + await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + + private static void ValidateTransaction(NamedPlaylistDbTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + if (transaction.OperationId == Guid.Empty || + transaction.ProgramCode.Length != 8 || + transaction.ProgramCode.Any(static character => !char.IsAsciiDigit(character)) || + transaction.Commands.Count == 0 || + transaction.Commands.Any(static command => command is null)) + { + throw new ArgumentException( + "The named-playlist transaction is invalid.", + nameof(transaction)); + } + + var validShape = transaction.Kind switch + { + NamedPlaylistMutationKind.CreateDefinition => + transaction.Commands.Count == 1 && + transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.InsertDefinition, + NamedPlaylistMutationKind.ReplaceItems => + transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.DeleteItems && + transaction.Commands.Skip(1).All(static command => + command.Kind == NamedPlaylistDbCommandKind.InsertItem), + NamedPlaylistMutationKind.DeletePlaylist => + transaction.Commands.Count == 2 && + transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.DeleteItems && + transaction.Commands[1].Kind == NamedPlaylistDbCommandKind.DeleteDefinition, + _ => false + }; + if (!validShape) + { + throw new ArgumentException( + "The named-playlist transaction command order is invalid.", + nameof(transaction)); + } + + foreach (var command in transaction.Commands) + { + if (string.IsNullOrWhiteSpace(command.Sql) || command.Sql.Contains(';')) + { + throw new ArgumentException( + "The named-playlist transaction SQL is invalid.", + nameof(transaction)); + } + + var programCodeParameters = command.Parameters.Where(static parameter => + string.Equals(parameter.Name, "program_code", StringComparison.OrdinalIgnoreCase)); + var programCodeParameter = programCodeParameters.SingleOrDefault(); + if (programCodeParameter?.Value is not string commandProgramCode || + !string.Equals( + commandProgramCode, + transaction.ProgramCode, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "The named-playlist transaction program identity is invalid.", + nameof(transaction)); + } + } + } + + private static NamedPlaylistMutationException UnknownOutcome( + NamedPlaylistMutationKind kind, + Exception exception) => + new( + $"The {kind} Oracle transaction outcome is unknown; do not retry automatically.", + outcomeUnknown: true, + exception); +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs index 9100324..79bc5fb 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs @@ -30,11 +30,11 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo public const int MaximumFileSizeBytes = 32 * 1024; - private const int ExpectedRowCount = 5; - private const int ExpectedColumnCount = 4; - private const int MaximumCellLength = 256; + internal const int ExpectedRowCount = 5; + internal const int ExpectedColumnCount = 4; + internal const int MaximumCellLength = 256; - private static readonly Encoding Cp949Encoding = CreateCp949Encoding(); + internal static readonly Encoding Cp949Encoding = CreateCp949Encoding(); private readonly string _trustedDirectory; private readonly StringComparison _pathComparison = OperatingSystem.IsWindows() @@ -68,13 +68,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var fileName = audience switch - { - S5025ManualAudience.Individual => "개인.dat", - S5025ManualAudience.Foreign => "외국인.dat", - S5025ManualAudience.Institution => "기관.dat", - _ => throw InvalidData() - }; + var fileName = GetFileName(audience); try { @@ -91,6 +85,11 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo bufferSize: 4_096, FileOptions.Asynchronous | FileOptions.SequentialScan)) { + TrustedDirectoryLease.EnsureRegularFileHandle( + stream.SafeFileHandle, + path, + MaximumFileSizeBytes, + allowEmpty: false); if (stream.Length <= 0 || stream.Length > MaximumFileSizeBytes) { throw InvalidData(); @@ -147,7 +146,17 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo } } - private string GetContainedFilePath(string fileName) + internal string TrustedDirectory => _trustedDirectory; + + internal static string GetFileName(S5025ManualAudience audience) => audience switch + { + S5025ManualAudience.Individual => "개인.dat", + S5025ManualAudience.Foreign => "외국인.dat", + S5025ManualAudience.Institution => "기관.dat", + _ => throw InvalidData() + }; + + internal string GetContainedFilePath(string fileName) { var candidate = Path.GetFullPath(Path.Combine(_trustedDirectory, fileName)); var prefix = Path.EndsInDirectorySeparator(_trustedDirectory) @@ -195,19 +204,14 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo } } - // The approved legacy files contain five data rows followed by one empty - // terminal record. s5025 intentionally rendered `row - 1`, thereby - // ignoring exactly that record. Preserve that contract without accepting - // internal blank rows or an arbitrary number of trailing records. - if (lines.Count == ExpectedRowCount + 1 && lines[^1].Length == 0) - { - lines.RemoveAt(lines.Count - 1); - } - - if (lines.Count != ExpectedRowCount) + // FSell always wrote five rows plus exactly one empty terminal record. + // Original s5025 rendered `row - 1`; accepting a five-row file without + // that record would therefore render one more row than the original. + if (lines.Count != ExpectedRowCount + 1 || lines[^1].Length != 0) { throw InvalidData(); } + lines.RemoveAt(lines.Count - 1); var rows = new S5025TrustedManualRow[ExpectedRowCount]; for (var rowIndex = 0; rowIndex < lines.Count; rowIndex++) @@ -261,7 +265,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo } } - private static void EnsureDirectoryIsTrusted(string directory) + internal static void EnsureDirectoryIsTrusted(string directory) { var current = new DirectoryInfo(directory); while (current is not null) @@ -277,7 +281,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo } } - private static void EnsureRegularNonReparseFile(string path) + internal static void EnsureRegularNonReparseFile(string path) { var attributes = File.GetAttributes(path); if ((attributes & FileAttributes.Directory) != 0 @@ -313,5 +317,191 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo } } - private static S5025TrustedManualDataException InvalidData() => new(); + internal static S5025TrustedManualDataException InvalidData() => new(); +} + +/// +/// Closed read/write boundary used by the migrated FSell operator screen. The +/// browser selects only one of the three legacy audiences; it can never supply +/// a directory or file name. Writes are validated, CP949 encoded, and replaced +/// atomically inside the composition-root-owned trusted directory. +/// +public sealed class S5025TrustedManualFileStore : IS5025TrustedManualDataSource, IDisposable +{ + private readonly TrustedDirectoryLease _directoryLease; + private readonly S5025TrustedManualFileDataSource _source; + + public S5025TrustedManualFileStore(string trustedDirectory) + { + _directoryLease = TrustedManualDirectory.AcquirePrivateWritableLease(trustedDirectory); + _source = new S5025TrustedManualFileDataSource(trustedDirectory); + } + + public static S5025TrustedManualFileStore CreateFromEnvironment( + Func? readEnvironmentVariable = null) + { + var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable; + return new S5025TrustedManualFileStore( + read(S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable) ?? string.Empty); + } + + public Task> ReadAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) => + _source.ReadAsync(audience, cancellationToken); + + public async Task WriteAsync( + S5025ManualAudience audience, + IReadOnlyList rows, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var fileName = S5025TrustedManualFileDataSource.GetFileName(audience); + ValidateRows(rows); + + string text; + byte[] bytes; + try + { + // FSell wrote five records and one empty terminal record. Preserve + // that exact shape because the scene reader deliberately validates it. + text = string.Join("\r\n", rows.Select(row => string.Join( + '^', + row.LeftName, + row.LeftAmount, + row.RightName, + row.RightAmount))) + "\r\n\r\n"; + bytes = S5025TrustedManualFileDataSource.Cp949Encoding.GetBytes(text); + } + catch (EncoderFallbackException) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + if (bytes.Length <= 0 || + bytes.Length > S5025TrustedManualFileDataSource.MaximumFileSizeBytes) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + var directory = _source.TrustedDirectory; + var destination = _source.GetContainedFilePath(fileName); + var temporaryName = $".{fileName}.{Guid.NewGuid():N}.tmp"; + var temporary = _source.GetContainedFilePath(temporaryName); + + try + { + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + if (File.Exists(destination)) + { + S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination); + } + + await using (var stream = new FileStream( + temporary, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4_096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + TrustedDirectoryLease.EnsureRegularFileHandle( + stream.SafeFileHandle, + temporary, + S5025TrustedManualFileDataSource.MaximumFileSizeBytes, + allowEmpty: true); + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(temporary); + if (File.Exists(destination)) + { + S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination); + } + + File.Move(temporary, destination, overwrite: true); + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination); + + // Read through the production parser before reporting success. This + // makes a malformed or partially persisted file fail closed. + _ = await _source.ReadAsync(audience, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (S5025TrustedManualDataException) + { + throw; + } + catch (Exception exception) when (exception is + IOException or + UnauthorizedAccessException or + SecurityException or + ArgumentException or + NotSupportedException) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + finally + { + try + { + if (File.Exists(temporary)) + { + var attributes = File.GetAttributes(temporary); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0) + { + File.Delete(temporary); + } + } + } + catch (Exception exception) when (exception is + IOException or + UnauthorizedAccessException or + SecurityException) + { + // A failed cleanup does not alter the success/failure result. A + // hidden temporary file is never considered by the scene source. + } + } + } + + public void Dispose() => _directoryLease.Dispose(); + + private static void ValidateRows(IReadOnlyList? rows) + { + if (rows is null || rows.Count != S5025TrustedManualFileDataSource.ExpectedRowCount) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + foreach (var row in rows) + { + if (row is null) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + var values = new[] + { + row.LeftName, + row.LeftAmount, + row.RightName, + row.RightAmount + }; + if (values.Any(value => + value is null || + value.Length > S5025TrustedManualFileDataSource.MaximumCellLength || + value.Contains('^') || + value.Any(char.IsControl))) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + } + } } diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/TrustedManualDirectory.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/TrustedManualDirectory.cs new file mode 100644 index 0000000..1b098ea --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/TrustedManualDirectory.cs @@ -0,0 +1,367 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +/// +/// Creates the app-owned operator-data directory without following an existing +/// reparse ancestor, replaces inherited ACLs with a closed per-user ACL, and pins +/// every directory component against rename/delete while a writable store is alive. +/// +public static class TrustedManualDirectory +{ + public static string PreparePrivateWritableDirectory(string directory) + { + var fullPath = NormalizeAbsolutePath(directory); + ValidateExistingAncestorsBeforeCreate(fullPath); + Directory.CreateDirectory(fullPath); + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath); + + if (OperatingSystem.IsWindows()) + { + ApplyPrivateWindowsAcl(fullPath); + ValidatePrivateWindowsAcl(fullPath); + } + + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath); + return fullPath; + } + + internal static TrustedDirectoryLease AcquirePrivateWritableLease(string directory) + { + var fullPath = NormalizeAbsolutePath(directory); + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath); + if (OperatingSystem.IsWindows()) + { + ValidatePrivateWindowsAcl(fullPath); + } + + return TrustedDirectoryLease.Acquire(fullPath); + } + + internal static string NormalizeAbsolutePath(string directory) + { + if (string.IsNullOrWhiteSpace(directory) || + directory.IndexOf('\0') >= 0 || + !Path.IsPathFullyQualified(directory)) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + } + + private static void ValidateExistingAncestorsBeforeCreate(string fullPath) + { + var current = new DirectoryInfo(fullPath); + while (!current.Exists) + { + if (File.Exists(current.FullName)) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + current = current.Parent ?? + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(current.FullName); + } + + [SupportedOSPlatform("windows")] + private static void ApplyPrivateWindowsAcl(string fullPath) + { + var currentUser = GetCurrentUserSid(); + var security = new DirectorySecurity(); + security.SetOwner(currentUser); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + + const InheritanceFlags inheritance = + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + AddFullControl(security, currentUser, inheritance); + AddFullControl(security, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), inheritance); + AddFullControl( + security, + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + inheritance); + + new DirectoryInfo(fullPath).SetAccessControl(security); + } + + [SupportedOSPlatform("windows")] + private static void ValidatePrivateWindowsAcl(string fullPath) + { + var currentUser = GetCurrentUserSid(); + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var administrators = new SecurityIdentifier( + WellKnownSidType.BuiltinAdministratorsSid, + null); + var allowed = new HashSet(StringComparer.Ordinal) + { + currentUser.Value, + system.Value, + administrators.Value + }; + + var security = new DirectoryInfo(fullPath).GetAccessControl( + AccessControlSections.Access | AccessControlSections.Owner); + var owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + if (!security.AreAccessRulesProtected || + owner is null || + !allowed.Contains(owner.Value)) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + var currentUserHasFullControl = false; + foreach (FileSystemAccessRule rule in security.GetAccessRules( + includeExplicit: true, + includeInherited: true, + targetType: typeof(SecurityIdentifier))) + { + var sid = (SecurityIdentifier)rule.IdentityReference; + if (rule.AccessControlType != AccessControlType.Allow || + !allowed.Contains(sid.Value)) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + if (sid.Equals(currentUser) && + (rule.FileSystemRights & FileSystemRights.FullControl) == + FileSystemRights.FullControl) + { + currentUserHasFullControl = true; + } + } + + if (!currentUserHasFullControl) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + } + + [SupportedOSPlatform("windows")] + private static SecurityIdentifier GetCurrentUserSid() => + WindowsIdentity.GetCurrent().User ?? + throw S5025TrustedManualFileDataSource.InvalidData(); + + [SupportedOSPlatform("windows")] + private static void AddFullControl( + DirectorySecurity security, + SecurityIdentifier identity, + InheritanceFlags inheritance) => + security.AddAccessRule(new FileSystemAccessRule( + identity, + FileSystemRights.FullControl, + inheritance, + PropagationFlags.None, + AccessControlType.Allow)); +} + +internal sealed class TrustedDirectoryLease : IDisposable +{ + private const uint GenericRead = 0x80000000; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagOpenReparsePoint = 0x00200000; + private const uint FileNameNormalized = 0; + + private readonly IReadOnlyList _directoryHandles; + + private TrustedDirectoryLease(IReadOnlyList directoryHandles) + { + _directoryHandles = directoryHandles; + } + + public static TrustedDirectoryLease Acquire(string directory) + { + if (!OperatingSystem.IsWindows()) + { + return new TrustedDirectoryLease(Array.Empty()); + } + + var ancestors = new List(); + for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent) + { + ancestors.Add(current.FullName); + } + ancestors.Reverse(); + + var handles = new List(ancestors.Count); + try + { + foreach (var ancestor in ancestors) + { + var handle = CreateFileW( + ancestor, + GenericRead, + FileShare.Read | FileShare.Write, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + EnsureDirectoryHandleIsDirect(handle, ancestor); + handles.Add(handle); + } + + return new TrustedDirectoryLease(handles.AsReadOnly()); + } + catch + { + foreach (var handle in handles) + { + handle.Dispose(); + } + + throw S5025TrustedManualFileDataSource.InvalidData(); + } + } + + public static void EnsureRegularFileHandle( + SafeFileHandle handle, + string expectedPath, + long maximumLength, + bool allowEmpty) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + if (!GetFileInformationByHandle(handle, out var information) || + (information.FileAttributes & + (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 || + information.NumberOfLinks != 1) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + var length = ((long)information.FileSizeHigh << 32) | information.FileSizeLow; + if (length > maximumLength || (!allowEmpty && length <= 0)) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + var resolved = GetFinalPath(handle); + var expected = Path.GetFullPath(expectedPath); + if (!string.Equals(resolved, expected, StringComparison.OrdinalIgnoreCase)) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + } + + public void Dispose() + { + foreach (var handle in _directoryHandles.Reverse()) + { + handle.Dispose(); + } + } + + private static void EnsureDirectoryHandleIsDirect(SafeFileHandle handle, string expectedPath) + { + if (!GetFileInformationByHandle(handle, out var information) || + (information.FileAttributes & FileAttributes.Directory) == 0 || + (information.FileAttributes & FileAttributes.ReparsePoint) != 0 || + !string.Equals( + GetFinalPath(handle), + Path.GetFullPath(expectedPath), + StringComparison.OrdinalIgnoreCase)) + { + throw S5025TrustedManualFileDataSource.InvalidData(); + } + } + + private static string GetFinalPath(SafeFileHandle handle) + { + var capacity = 512; + while (capacity <= 32_768) + { + var buffer = new StringBuilder(capacity); + var length = GetFinalPathNameByHandleW( + handle, + buffer, + (uint)buffer.Capacity, + FileNameNormalized); + if (length == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (length < buffer.Capacity) + { + var path = buffer.ToString(); + if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase)) + { + path = "\\\\" + path[8..]; + } + else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal)) + { + path = path[4..]; + } + + return Path.GetFullPath(path); + } + + capacity = checked((int)length + 1); + } + + throw S5025TrustedManualFileDataSource.InvalidData(); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string fileName, + uint desiredAccess, + FileShare shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation information); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle file, + StringBuilder path, + uint pathLength, + uint flags); + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public FileAttributes FileAttributes; + public FileTime CreationTime; + public FileTime LastAccessTime; + public FileTime LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime + { + public uint Low; + public uint High; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs new file mode 100644 index 0000000..e3ca3f7 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs @@ -0,0 +1,377 @@ +using System.Security; +using System.Security.Cryptography; +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +public sealed record ViTrustedManualItem(string Code, string Name); + +public sealed record ViTrustedManualSnapshot( + IReadOnlyList Items, + string RowVersion); + +public sealed record ViTrustedManualWriteResult( + ViTrustedManualSnapshot Snapshot, + bool Persisted); + +public sealed class ViTrustedManualDataException : Exception +{ + internal ViTrustedManualDataException() + : base("The trusted VI manual-data store is unavailable or invalid.") + { + } +} + +/// +/// Closed store for the original VI발동.dat operator list. The path remains a +/// composition-root decision; WebView callers can read or replace only the +/// validated code/name rows and cannot choose a file. +/// +public sealed class ViTrustedManualFileStore : ITrustedViStockNameSnapshotSource, IDisposable +{ + public const int MaximumItemCount = 100; + public const int MaximumFileSizeBytes = 64 * 1024; + + private const string FileName = "VI발동.dat"; + private const int MaximumCodeLength = 64; + private const int MaximumNameLength = 200; + private static readonly Encoding Cp949 = CreateCp949Encoding(); + + private readonly TrustedDirectoryLease _directoryLease; + private readonly S5025TrustedManualFileDataSource _pathBoundary; + + public ViTrustedManualFileStore(string trustedDirectory) + { + _directoryLease = TrustedManualDirectory.AcquirePrivateWritableLease(trustedDirectory); + _pathBoundary = new S5025TrustedManualFileDataSource(trustedDirectory); + } + + public async Task> ReadAsync( + CancellationToken cancellationToken = default) => + (await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false)).Items; + + public async Task ReadSnapshotAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var directory = _pathBoundary.TrustedDirectory; + var path = _pathBoundary.GetContainedFilePath(FileName); + try + { + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + if (!File.Exists(path)) + { + return CreateSnapshot(Array.Empty()); + } + + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw InvalidData(); + } + + byte[] bytes; + await using (var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4_096, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + TrustedDirectoryLease.EnsureRegularFileHandle( + stream.SafeFileHandle, + path, + MaximumFileSizeBytes, + allowEmpty: true); + if (stream.Length > MaximumFileSizeBytes) + { + throw InvalidData(); + } + + bytes = new byte[checked((int)stream.Length)]; + var offset = 0; + while (offset < bytes.Length) + { + var read = await stream.ReadAsync( + bytes.AsMemory(offset), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + throw InvalidData(); + } + + offset += read; + } + } + + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + EnsureViFile(path); + if (bytes.Length == 0) + { + return CreateSnapshot(Array.Empty()); + } + + RejectUnicodeBom(bytes); + var text = Cp949.GetString(bytes); + var rows = new List(); + using var reader = new StringReader(text); + while (reader.ReadLine() is { } rawLine) + { + var line = rawLine.Trim(); + if (line.Length == 0) + { + continue; + } + + var columns = line.Split('^'); + if (columns.Length != 9 || columns.Skip(2).Any(value => value.Length != 0)) + { + throw InvalidData(); + } + + var item = new ViTrustedManualItem(columns[0], columns[1]); + ValidateItem(item); + rows.Add(item); + if (rows.Count > MaximumItemCount) + { + throw InvalidData(); + } + } + + return CreateSnapshot(rows); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (ViTrustedManualDataException) + { + throw; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or SecurityException or + ArgumentException or NotSupportedException or DecoderFallbackException) + { + throw InvalidData(); + } + } + + public async Task WriteAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (items is null || items.Count > MaximumItemCount) + { + throw InvalidData(); + } + + foreach (var item in items) + { + ValidateItem(item); + } + + // Original VILIST did not call WriteListFile when the grid was empty. + // Preserve the existing file and return its current version explicitly. + if (items.Count == 0) + { + return new ViTrustedManualWriteResult( + await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false), + Persisted: false); + } + + byte[] bytes; + try + { + bytes = SerializeCanonical(items); + } + catch (EncoderFallbackException) + { + throw InvalidData(); + } + + if (bytes.Length > MaximumFileSizeBytes) + { + throw InvalidData(); + } + + var directory = _pathBoundary.TrustedDirectory; + var destination = _pathBoundary.GetContainedFilePath(FileName); + var temporary = _pathBoundary.GetContainedFilePath( + $".{FileName}.{Guid.NewGuid():N}.tmp"); + try + { + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + if (File.Exists(destination)) + { + EnsureViFile(destination); + } + + await using (var stream = new FileStream( + temporary, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4_096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + TrustedDirectoryLease.EnsureRegularFileHandle( + stream.SafeFileHandle, + temporary, + MaximumFileSizeBytes, + allowEmpty: true); + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + var temporaryAttributes = File.GetAttributes(temporary); + if ((temporaryAttributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 || + new FileInfo(temporary).Length > MaximumFileSizeBytes) + { + throw InvalidData(); + } + + if (File.Exists(destination)) + { + EnsureViFile(destination); + } + + File.Move(temporary, destination, overwrite: true); + S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory); + var persisted = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false); + var expectedVersion = ComputeRowVersion(bytes); + if (!persisted.Items.SequenceEqual(items) || + !string.Equals(persisted.RowVersion, expectedVersion, StringComparison.Ordinal)) + { + throw InvalidData(); + } + + return new ViTrustedManualWriteResult(persisted, Persisted: true); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (ViTrustedManualDataException) + { + throw; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or SecurityException or + ArgumentException or NotSupportedException) + { + throw InvalidData(); + } + finally + { + try + { + if (File.Exists(temporary)) + { + var attributes = File.GetAttributes(temporary); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0) + { + File.Delete(temporary); + } + } + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or SecurityException) + { + // A leftover hidden temporary file is never consumed by the store. + } + } + } + + public async Task ReadStockNameSnapshotAsync( + CancellationToken cancellationToken = default) + { + var snapshot = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false); + return new TrustedViStockNameSnapshot( + Array.AsReadOnly(snapshot.Items.Select(item => item.Name).ToArray()), + snapshot.RowVersion); + } + + public void Dispose() => _directoryLease.Dispose(); + + private static void ValidateItem(ViTrustedManualItem? item) + { + if (item is null || + !IsSafe(item.Code, MaximumCodeLength) || + !IsSafe(item.Name, MaximumNameLength) || + item.Name.Contains(',')) + { + throw InvalidData(); + } + } + + private static bool IsSafe(string? value, int maximumLength) => + value is not null && + value.Length is > 0 && + value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + !value.Contains('^') && + !value.Any(char.IsControl); + + private static void EnsureViFile(string path) + { + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 || + new FileInfo(path).Length > MaximumFileSizeBytes) + { + throw InvalidData(); + } + } + + private static ViTrustedManualSnapshot CreateSnapshot( + IReadOnlyList items) + { + var immutable = Array.AsReadOnly(items.ToArray()); + byte[] canonical; + try + { + canonical = SerializeCanonical(immutable); + } + catch (EncoderFallbackException) + { + throw InvalidData(); + } + + return new ViTrustedManualSnapshot(immutable, ComputeRowVersion(canonical)); + } + + private static byte[] SerializeCanonical(IEnumerable items) + { + var text = string.Concat(items.Select(item => + $"{item.Code}^{item.Name}^^^^^^^\r\n")); + return Cp949.GetBytes(text); + } + + private static string ComputeRowVersion(ReadOnlySpan canonicalBytes) => + Convert.ToHexString(SHA256.HashData(canonicalBytes)).ToLowerInvariant(); + + private static Encoding CreateCp949Encoding() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + private static void RejectUnicodeBom(ReadOnlySpan bytes) + { + if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) || + bytes.StartsWith(new byte[] { 0xFF, 0xFE }) || + bytes.StartsWith(new byte[] { 0xFE, 0xFF }) || + bytes.StartsWith(new byte[] { 0x00, 0x00, 0xFE, 0xFF })) + { + throw InvalidData(); + } + } + + private static ViTrustedManualDataException InvalidData() => new(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Properties/AssemblyInfo.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..83931c2 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Infrastructure.Tests")] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs index bfb9e8a..0e48fb2 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs @@ -111,7 +111,35 @@ public sealed class CandleSceneDataLoaderTests Request(CandleCutAlias.Cut8035, CandleMarketTarget.KosdaqStock, CandleChartMode.TradingHalt, "Stock"), Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, CandleChartMode.TradingHalt, null), Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, new string('x', 257)), - Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "bad\nname") + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "bad\nname"), + new( + CandleCutAlias.Cut8061, + CandleMarketTarget.OverseasIndex, + CandleChartMode.Price, + "World", + true, + true), + Request( + CandleCutAlias.Cut8061, + CandleMarketTarget.OverseasStock, + CandleChartMode.Price, + "World", + "NAS@WORLD", + "DE"), + Request( + CandleCutAlias.Cut8061, + CandleMarketTarget.OverseasIndex, + CandleChartMode.Price, + "World", + "WORLD@INDEX", + "us"), + Request( + CandleCutAlias.Cut8061, + CandleMarketTarget.KospiStock, + CandleChartMode.Price, + "Stock", + "INVALID@DOMESTIC", + "KR") }; [Theory] @@ -173,6 +201,100 @@ public sealed class CandleSceneDataLoaderTests Assert.DoesNotContain("{0}", call.Spec.Sql, StringComparison.Ordinal)); } + [Fact] + public async Task Overseas_candles_scope_master_type_and_preserve_global_index_support() + { + var indexExecutor = new RecordingExecutor(QuoteTable(), RowsTable(false)); + await new S8010SceneDataLoader(indexExecutor).LoadAsync( + Request( + CandleCutAlias.Cut8061, + CandleMarketTarget.OverseasIndex, + CandleChartMode.Price, + "Germany DAX", + "XTR@DAX30", + "DE")); + + Assert.All(indexExecutor.Calls, call => + { + Assert.Contains("a.f_fdtc = '0'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + var type = Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "instrumentType"); + Assert.Equal("0", type.Value); + Assert.Equal( + "XTR@DAX30", + Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "symbol").Value); + Assert.Equal( + "DE", + Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "nationCode").Value); + }); + + var stockExecutor = new RecordingExecutor(QuoteTable(), RowsTable(false)); + await new S8010SceneDataLoader(stockExecutor).LoadAsync( + Request( + CandleCutAlias.Cut8061, + CandleMarketTarget.OverseasStock, + CandleChartMode.Price, + "NVIDIA", + "NAS@NVDA", + "US")); + + Assert.All(stockExecutor.Calls, call => + { + Assert.Contains("a.f_fdtc = '1'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "a.f_natc IN ('US', 'TW')", + call.Spec.Sql, + StringComparison.OrdinalIgnoreCase); + var type = Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "instrumentType"); + Assert.Equal("1", type.Value); + Assert.Equal( + "NAS@NVDA", + Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "symbol").Value); + Assert.Equal( + "US", + Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "nationCode").Value); + }); + } + + [Fact] + public async Task Overseas_stock_candle_preserves_the_original_taiwan_scope() + { + var executor = new RecordingExecutor(QuoteTable(), RowsTable(false)); + + await new S8010SceneDataLoader(executor).LoadAsync( + Request( + CandleCutAlias.Cut8061, + CandleMarketTarget.OverseasStock, + CandleChartMode.Price, + "Taiwan Stock", + "TWS@2330", + "TW")); + + Assert.All(executor.Calls, call => + { + Assert.Contains( + "a.f_natc IN ('US', 'TW')", + call.Spec.Sql, + StringComparison.OrdinalIgnoreCase); + Assert.Equal( + "TW", + Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "nationCode").Value); + }); + } + [Theory] [MemberData(nameof(AliasLimits))] public async Task Every_alias_binds_its_legacy_row_bound_and_maps_time_shape( @@ -336,8 +458,28 @@ public sealed class CandleSceneDataLoaderTests CandleCutAlias alias, CandleMarketTarget target, CandleChartMode mode, - string? selection) => - new(alias, target, mode, selection, true, true); + string? selection, + string? foreignSymbol = null, + string? foreignNationCode = null) + { + if (target is CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock) + { + foreignSymbol ??= target == CandleMarketTarget.OverseasIndex + ? "TEST@INDEX" + : "TEST@STOCK"; + foreignNationCode ??= "US"; + } + + return new( + alias, + target, + mode, + selection, + true, + true, + foreignSymbol, + foreignNationCode); + } private static int AliasLimit(CandleCutAlias alias) => alias switch { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs index bde556f..ad8b591 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs @@ -8,6 +8,45 @@ namespace MBN_STOCK_WEBVIEW.Core.Tests; public sealed class ComparisonAndYieldLegacyRequestResolverTests { + [Theory] + [InlineData("WonDollar", "FiveDays", YieldGraphInstrumentKind.WonDollar, YieldGraphPeriod.FiveDays)] + [InlineData("WonYen", "TwentyDays", YieldGraphInstrumentKind.WonYen, YieldGraphPeriod.TwentyDays)] + [InlineData("WonYuan", "SixtyDays", YieldGraphInstrumentKind.WonYuan, YieldGraphPeriod.SixtyDays)] + [InlineData("WonEuro", "OneHundredTwentyDays", YieldGraphInstrumentKind.WonEuro, YieldGraphPeriod.OneHundredTwentyDays)] + [InlineData("WonDollar", "TwoHundredFortyDays", YieldGraphInstrumentKind.WonDollar, YieldGraphPeriod.TwoHundredFortyDays)] + public async Task Fixed_operator_catalog_accepts_closed_FX_and_period_enum_names( + string subject, + string subtype, + YieldGraphInstrumentKind expectedKind, + YieldGraphPeriod expectedPeriod) + { + var resolver = new ComparisonAndYieldLegacyRequestResolver(new RecordingExecutor()); + + var result = Assert.IsType(await resolver.ResolveAsync( + Entry("5086", "EXCHANGE_RATE", subject, subtype))); + + Assert.Equal(expectedKind, result.Request.Instrument.Kind); + Assert.Equal(expectedPeriod, result.Request.Period); + } + + [Theory] + [InlineData("KOSPI", YieldGraphInstrumentKind.KospiIndex)] + [InlineData("KOSDAQ", YieldGraphInstrumentKind.KosdaqIndex)] + [InlineData("KOSPI200", YieldGraphInstrumentKind.Kospi200Index)] + [InlineData("KRX100", YieldGraphInstrumentKind.Krx100Index)] + public async Task Fixed_operator_catalog_accepts_closed_index_selectors( + string groupCode, + YieldGraphInstrumentKind expectedKind) + { + var resolver = new ComparisonAndYieldLegacyRequestResolver(new RecordingExecutor()); + + var result = Assert.IsType(await resolver.ResolveAsync( + Entry("5086", groupCode, "INDEX", "TwentyDays"))); + + Assert.Equal(expectedKind, result.Request.Instrument.Kind); + Assert.Equal(YieldGraphPeriod.TwentyDays, result.Request.Period); + } + [Theory] [InlineData("5026", "s5026")] [InlineData("5087", "s5087")] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertCatalogPersistenceServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertCatalogPersistenceServiceTests.cs new file mode 100644 index 0000000..16e91f1 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertCatalogPersistenceServiceTests.cs @@ -0,0 +1,235 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyExpertCatalogPersistenceServiceTests +{ + [Fact] + public async Task SuggestNextCodeAsync_UsesOriginalFourDigitOracleSequenceReadOnly() + { + var query = new CatalogRecordingQueryExecutor(call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal(LegacyExpertCatalogPersistenceService.NextCodeQueryName, call.TableName); + Assert.Contains("TO_NUMBER(TRIM(EP_CODE))", call.Spec.Sql, StringComparison.Ordinal); + Assert.Empty(call.Spec.Parameters); + return OperatorCatalogTestTables.SingleString("EXPERT_CODE", "0042"); + }); + var service = new LegacyExpertCatalogPersistenceService(query); + + Assert.Equal("0042", await service.SuggestNextCodeAsync()); + Assert.False(service.CanMutate); + Assert.Single(query.Calls); + } + + [Fact] + public async Task SuggestNextCodeAsync_RejectsExhaustedCodeRange() + { + var service = new LegacyExpertCatalogPersistenceService( + new CatalogRecordingQueryExecutor(_ => + OperatorCatalogTestTables.SingleString("EXPERT_CODE", "EXHAUSTED"))); + + await Assert.ThrowsAsync(() => + service.SuggestNextCodeAsync()); + } + + [Fact] + public async Task CreateAsync_BindsExpertThenRecommendationsInOneOracleTransaction() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation); + using var cancellation = new CancellationTokenSource(); + var definition = new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0042", "홍'길동"), + [ + new ExpertCatalogRecommendation(0, "005930", "삼성전자", 72000m), + new ExpertCatalogRecommendation(1, "035720", "카카오", 45000m) + ]); + + var receipt = await service.CreateAsync(definition, cancellation.Token); + + Assert.True(service.CanMutate); + Assert.Equal(OperatorCatalogMutationKind.CreateExpert, receipt.Kind); + var call = Assert.Single(mutation.Calls); + Assert.Equal(cancellation.Token, call.Token); + var transaction = call.Transaction; + Assert.Equal(DataSourceKind.Oracle, transaction.Source); + Assert.Equal("0042", transaction.IdentityCode); + Assert.Equal( + [ + OperatorCatalogDbCommandKind.InsertExpert, + OperatorCatalogDbCommandKind.InsertRecommendation, + OperatorCatalogDbCommandKind.InsertRecommendation + ], + transaction.Commands.Select(static command => command.Kind)); + Assert.All(transaction.Commands, command => + Assert.DoesNotContain("홍'길동", command.Sql, StringComparison.Ordinal)); + + var parent = transaction.Commands[0]; + Assert.Contains("NOT EXISTS", parent.Sql, StringComparison.Ordinal); + Assert.Equal("홍'길동", Parameter(parent, "expert_name").Value); + Assert.Equal("홍'길동", Parameter(parent, "conflict_name").Value); + var first = transaction.Commands[1]; + Assert.Equal("005930", Parameter(first, "stock_code").Value); + Assert.Equal(72000m, Parameter(first, "buy_amount").Value); + Assert.Equal(DbType.Decimal, Parameter(first, "buy_amount").DbType); + Assert.Equal(0, Parameter(first, "play_index").Value); + } + + [Fact] + public async Task ReplaceAsync_UsesExpectedCodeAndNameBeforeChildReplacement() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation); + + await service.ReplaceAsync( + new ExpertSelectionIdentity("0010", "기존 전문가"), + "새 전문가", + [new ExpertCatalogRecommendation(0, "005930", "삼성전자", 70000m)]); + + var transaction = Assert.Single(mutation.Calls).Transaction; + Assert.Equal(OperatorCatalogMutationKind.ReplaceExpert, transaction.Kind); + Assert.Equal( + [ + OperatorCatalogDbCommandKind.UpdateExpert, + OperatorCatalogDbCommandKind.DeleteRecommendations, + OperatorCatalogDbCommandKind.InsertRecommendation + ], + transaction.Commands.Select(static command => command.Kind)); + var update = transaction.Commands[0]; + Assert.Equal("기존 전문가", Parameter(update, "expected_name").Value); + Assert.Equal("새 전문가", Parameter(update, "new_name").Value); + Assert.Equal("새 전문가", Parameter(update, "duplicate_name").Value); + Assert.Contains("NOT EXISTS", update.Sql, StringComparison.Ordinal); + Assert.Equal( + OperatorCatalogAffectedRowsRule.ExactlyOne, + update.AffectedRowsRule); + Assert.Equal( + OperatorCatalogAffectedRowsRule.AnyNonNegative, + transaction.Commands[1].AffectedRowsRule); + } + + [Fact] + public async Task DeleteAsync_DeletesRecommendationsBeforeIdentityBoundExpert() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation); + + await service.DeleteAsync(new ExpertSelectionIdentity("0010", "전문가")); + + var transaction = Assert.Single(mutation.Calls).Transaction; + Assert.Equal( + [ + OperatorCatalogDbCommandKind.DeleteRecommendations, + OperatorCatalogDbCommandKind.DeleteExpert + ], + transaction.Commands.Select(static command => command.Kind)); + Assert.Equal("전문가", Parameter(transaction.Commands[1], "expected_name").Value); + Assert.Equal( + OperatorCatalogAffectedRowsRule.ExactlyOne, + transaction.Commands[1].AffectedRowsRule); + } + + [Fact] + public async Task Mutations_RejectDuplicateNamesCodesOrderAndUnsafeAmountsBeforeExecutor() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation); + var identity = new ExpertSelectionIdentity("0001", "전문가"); + + await Assert.ThrowsAsync(() => service.CreateAsync( + new ExpertCatalogDefinition( + identity, + [ + new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1m), + new ExpertCatalogRecommendation(1, "005930", "다른이름", 2m) + ]))); + await Assert.ThrowsAsync(() => service.CreateAsync( + new ExpertCatalogDefinition( + identity, + [ + new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1m), + new ExpertCatalogRecommendation(1, "035720", "삼성전자", 2m) + ]))); + await Assert.ThrowsAsync(() => service.CreateAsync( + new ExpertCatalogDefinition( + identity, + [new ExpertCatalogRecommendation(1, "005930", "삼성전자", 1m)]))); + await Assert.ThrowsAsync(() => service.CreateAsync( + new ExpertCatalogDefinition( + identity, + [new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1.5m)]))); + await Assert.ThrowsAsync(() => service.CreateAsync( + new ExpertCatalogDefinition( + identity, + [new ExpertCatalogRecommendation(0, "005930", "삼성전자", 0m)]))); + + Assert.Empty(mutation.Calls); + } + + [Fact] + public async Task Mutations_FailClosedForReadOnlyCancellationAndKnownConflict() + { + var definition = new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "전문가"), + []); + var readOnly = new LegacyExpertCatalogPersistenceService(UnusedQuery()); + await Assert.ThrowsAsync(() => readOnly.CreateAsync(definition)); + + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + service.CreateAsync(definition, cancellation.Token)); + Assert.Empty(mutation.Calls); + + mutation.Exception = new OperatorCatalogConflictException( + OperatorCatalogMutationKind.CreateExpert, + OperatorCatalogDbCommandKind.InsertExpert); + var conflict = await Assert.ThrowsAsync(() => + service.CreateAsync(definition)); + Assert.False(conflict.OutcomeUnknown); + Assert.Equal(OperatorCatalogDbCommandKind.InsertExpert, conflict.CommandKind); + } + + [Fact] + public async Task CreateAsync_TreatsUnclassifiedOrAmbiguousResultAsOutcomeUnknown() + { + var definition = new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "전문가"), + []); + var mutation = new CatalogRecordingMutationExecutor + { + Exception = new TimeoutException("provider detail") + }; + var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation); + + var unclassified = await Assert.ThrowsAsync(() => + service.CreateAsync(definition)); + Assert.True(unclassified.OutcomeUnknown); + Assert.Contains("do not retry automatically", unclassified.Message, StringComparison.Ordinal); + + var ambiguousMutation = new CatalogRecordingMutationExecutor(transaction => + new OperatorCatalogDbTransactionResult( + transaction.OperationId, + [0], + DateTimeOffset.UtcNow)); + var ambiguousService = new LegacyExpertCatalogPersistenceService( + UnusedQuery(), + ambiguousMutation); + var ambiguous = await Assert.ThrowsAsync(() => + ambiguousService.CreateAsync(definition)); + Assert.True(ambiguous.OutcomeUnknown); + } + + private static CatalogRecordingQueryExecutor UnusedQuery() => + new(_ => throw new InvalidOperationException("No read query was expected.")); + + private static OperatorCatalogDbParameter Parameter( + OperatorCatalogDbCommand command, + string name) => + Assert.Single(command.Parameters, parameter => + string.Equals(parameter.Name, name, StringComparison.Ordinal)); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs new file mode 100644 index 0000000..810a308 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs @@ -0,0 +1,476 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyExpertSelectionServiceTests +{ + [Fact] + public async Task SearchAsync_UsesBoundOracleExpertListAndReturnsTypedDeterministicRows() + { + var executor = new RecordingExecutor(_ => SearchTable( + ("Zulu", "0002"), + ("Alpha", "0001"))); + var service = new LegacyExpertSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.SearchAsync(" a!_% ", 25, cancellation.Token); + + Assert.Equal("a!_%", result.Query); + Assert.False(result.IsTruncated); + Assert.Collection( + result.Items, + item => + { + Assert.Equal(new ExpertSelectionIdentity("0001", "Alpha"), item.Identity); + Assert.Equal(DataSourceKind.Oracle, item.Source); + }, + item => Assert.Equal(new ExpertSelectionIdentity("0002", "Zulu"), item.Identity)); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("EXPERT_SEARCH", call.QueryName); + Assert.Equal(cancellation.Token, call.CancellationToken); + call.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains("FROM EXPERT_LIST", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("EP_NAME EXPERT_NAME", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("EP_CODE EXPERT_CODE", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("a!_%", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Collection( + call.Spec.Parameters, + parameter => + { + Assert.Equal("search_term", parameter.Name); + Assert.Equal("A!!!_!%", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }, + parameter => + { + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(26, parameter.Value); + Assert.Equal(DbType.Int32, parameter.DbType); + }); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task SearchAsync_AllowsBlankInitialLoadAndReportsGlobalBound() + { + var executor = new RecordingExecutor(_ => SearchTable( + ("Alpha", "0001"), + ("Beta", "0002"))); + var service = new LegacyExpertSelectionService(executor); + + var result = await service.SearchAsync(" ", maximumResults: 1); + + Assert.Equal(string.Empty, result.Query); + Assert.True(result.IsTruncated); + Assert.Single(result.Items); + Assert.Equal(string.Empty, executor.Calls[0].Spec.Parameters[0].Value); + Assert.Equal(2, executor.Calls[0].Spec.Parameters[1].Value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(501)] + public async Task SearchAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit) + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyExpertSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("", limit)); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("bad\u0000value")] + [InlineData("bad\u200Bvalue")] + [InlineData("bad\uD800value")] + public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query) + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyExpertSelectionService(executor); + + await Assert.ThrowsAsync(() => service.SearchAsync(query)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess() + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyExpertSelectionService(executor); + + await Assert.ThrowsAsync(() => service.SearchAsync(null!)); + await Assert.ThrowsAsync(() => service.SearchAsync( + new string('A', LegacyExpertSelectionService.MaximumQueryLength + 1))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsExactSchemaAndRowBoundViolations() + { + var wrongSchema = SearchTable(); + wrongSchema.Columns[0].ColumnName = "expert_name"; + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(new RecordingExecutor(_ => wrongSchema)) + .SearchAsync()); + + var overflow = SearchTable( + ("A", "0001"), + ("B", "0002"), + ("C", "0003")); + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(new RecordingExecutor(_ => overflow)) + .SearchAsync(maximumResults: 1)); + } + + [Theory] + [InlineData("Analyst", "0001", "Analyst", "0002")] + [InlineData("Analyst", "0001", "Other", "0001")] + public async Task SearchAsync_RejectsDuplicateExpertNameOrCode( + string name1, + string code1, + string name2, + string code2) + { + var executor = new RecordingExecutor(_ => SearchTable( + (name1, code1), + (name2, code2))); + var service = new LegacyExpertSelectionService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync()); + + Assert.Contains("duplicate expert name or code", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(" Analyst", "0001")] + [InlineData("Analyst", "001")] + [InlineData("Analyst", "00A1")] + [InlineData("Bad\nName", "0001")] + public async Task SearchAsync_RejectsNonCanonicalExpertIdentity(string name, string code) + { + var executor = new RecordingExecutor(_ => SearchTable((name, code))); + + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(executor).SearchAsync()); + } + + [Fact] + public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyExpertSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.SearchAsync(cancellationToken: cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task PreviewAsync_BindsCodeAndNameAndMapsOriginalRecommendationFields() + { + var identity = Identity(); + var executor = new RecordingExecutor(_ => PreviewTable( + ("Analyst A", "0001", "005930", "Samsung", "70000", "0"), + ("Analyst A", "0001", "035720", "Kakao", "42000", "2"))); + var service = new LegacyExpertSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.PreviewAsync(identity, 12, cancellation.Token); + + Assert.Equal(identity, result.Identity); + Assert.False(result.IsTruncated); + Assert.Equal( + [ + new ExpertRecommendationPreview(0, "005930", "Samsung", 70000m), + new ExpertRecommendationPreview(2, "035720", "Kakao", 42000m) + ], + result.Items); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("EXPERT_PREVIEW", call.QueryName); + Assert.Equal(cancellation.Token, call.CancellationToken); + Assert.Contains("FROM EXPERT_LIST e", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN RECOMMEND_LIST r", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY r.PLAYINDEX", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.Collection( + call.Spec.Parameters, + parameter => + { + Assert.Equal("expert_code", parameter.Name); + Assert.Equal("0001", parameter.Value); + }, + parameter => + { + Assert.Equal("expert_name", parameter.Name); + Assert.Equal("Analyst A", parameter.Value); + }, + parameter => + { + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(13, parameter.Value); + }); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task PreviewAsync_MapsSingleNullRecommendationSentinelToEmptyExpert() + { + var executor = new RecordingExecutor(_ => PreviewTable( + ("Analyst A", "0001", null, null, null, null))); + + var result = await new LegacyExpertSelectionService(executor).PreviewAsync(Identity()); + + Assert.Empty(result.Items); + Assert.False(result.IsTruncated); + } + + [Fact] + public async Task PreviewAsync_AppliesFiveByTwentyMaximumAndReportsTruncation() + { + var executor = new RecordingExecutor(_ => PreviewTable( + ("Analyst A", "0001", "000001", "Alpha", "100", "0"), + ("Analyst A", "0001", "000002", "Beta", "200", "1"))); + var service = new LegacyExpertSelectionService(executor); + + var result = await service.PreviewAsync(Identity(), maximumItems: 1); + + Assert.True(result.IsTruncated); + Assert.Single(result.Items); + Assert.Equal(2, executor.Calls[0].Spec.Parameters[2].Value); + Assert.Equal(100, LegacyExpertSelectionService.MaximumPreviewItems); + } + + public static TheoryData UnsafeIdentities => new() + { + new("001", "Analyst A"), + new("00A1", "Analyst A"), + new("0001", " Analyst A"), + new("0001", "Bad\u0000Name") + }; + + [Theory] + [MemberData(nameof(UnsafeIdentities))] + public async Task PreviewAsync_RejectsUnsafeIdentityBeforeDatabaseAccess( + ExpertSelectionIdentity identity) + { + var executor = new RecordingExecutor(_ => PreviewTable()); + + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(executor).PreviewAsync(identity)); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(101)] + public async Task PreviewAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit) + { + var executor = new RecordingExecutor(_ => PreviewTable()); + + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(executor).PreviewAsync(Identity(), limit)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task PreviewAsync_RejectsMissingOrMismatchedSelectedIdentity() + { + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(new RecordingExecutor(_ => PreviewTable())) + .PreviewAsync(Identity())); + + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(new RecordingExecutor(_ => PreviewTable( + ("Other", "0001", "005930", "Samsung", "70000", "0")))) + .PreviewAsync(Identity())); + } + + [Fact] + public async Task PreviewAsync_RejectsWrongSchemaPartialSentinelAndRowOverflow() + { + var wrongSchema = PreviewTable(); + wrongSchema.Columns[5].ColumnName = "play_index"; + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(new RecordingExecutor(_ => wrongSchema)) + .PreviewAsync(Identity())); + + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(new RecordingExecutor(_ => PreviewTable( + ("Analyst A", "0001", "005930", null, null, null)))) + .PreviewAsync(Identity())); + + var overflow = PreviewTable( + ("Analyst A", "0001", "000001", "One", "100", "0"), + ("Analyst A", "0001", "000002", "Two", "200", "1"), + ("Analyst A", "0001", "000003", "Three", "300", "2")); + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(new RecordingExecutor(_ => overflow)) + .PreviewAsync(Identity(), maximumItems: 1)); + } + + [Theory] + [InlineData("005930", "Samsung", "0", "0")] + [InlineData("005930", "Samsung", "-1", "0")] + [InlineData("005930", "Samsung", "70000.5", "0")] + [InlineData("005930", "Samsung", "9007199254740992", "0")] + [InlineData("005930", "Samsung", "070000", "0")] + [InlineData("00-5930", "Samsung", "70000", "0")] + [InlineData("005930", " Samsung", "70000", "0")] + [InlineData("005930", "Samsung", "70000", "01")] + [InlineData("005930", "Samsung", "70000", "-1")] + public async Task PreviewAsync_RejectsInvalidRecommendationValues( + string stockCode, + string stockName, + string buyAmount, + string playIndex) + { + var executor = new RecordingExecutor(_ => PreviewTable( + ("Analyst A", "0001", stockCode, stockName, buyAmount, playIndex))); + + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(executor).PreviewAsync(Identity())); + } + + [Theory] + [InlineData("005930", "Samsung", "0", "005930", "Other", "1")] + [InlineData("005930", "Samsung", "0", "035720", "Samsung", "1")] + [InlineData("005930", "Samsung", "0", "035720", "Kakao", "0")] + [InlineData("005930", "Samsung", "2", "035720", "Kakao", "1")] + public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingRecommendationIdentity( + string code1, + string name1, + string index1, + string code2, + string name2, + string index2) + { + var executor = new RecordingExecutor(_ => PreviewTable( + ("Analyst A", "0001", code1, name1, "100", index1), + ("Analyst A", "0001", code2, name2, "200", index2))); + + await Assert.ThrowsAsync(() => + new LegacyExpertSelectionService(executor).PreviewAsync(Identity())); + } + + [Fact] + public async Task PreviewAsync_StopsAfterProviderCancellation() + { + using var cancellation = new CancellationTokenSource(); + var executor = new RecordingExecutor(_ => + { + cancellation.Cancel(); + return PreviewTable( + ("Analyst A", "0001", "005930", "Samsung", "70000", "0")); + }); + + await Assert.ThrowsAnyAsync(() => + new LegacyExpertSelectionService(executor).PreviewAsync( + Identity(), + cancellationToken: cancellation.Token)); + + Assert.Single(executor.Calls); + } + + private static ExpertSelectionIdentity Identity() => new("0001", "Analyst A"); + + private static DataTable SearchTable(params (string Name, string Code)[] rows) + { + var table = new DataTable(); + table.Columns.Add("EXPERT_NAME", typeof(string)); + table.Columns.Add("EXPERT_CODE", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.Name, row.Code); + } + + return table; + } + + private static DataTable PreviewTable( + params (string ExpertName, string ExpertCode, string? StockCode, + string? StockName, string? BuyAmount, string? PlayIndex)[] rows) + { + var table = new DataTable(); + table.Columns.Add("EXPERT_NAME", typeof(string)); + table.Columns.Add("EXPERT_CODE", typeof(string)); + table.Columns.Add("STOCK_CODE", typeof(string)); + table.Columns.Add("STOCK_NAME", typeof(string)); + table.Columns.Add("BUY_AMOUNT", typeof(string)); + table.Columns.Add("PLAY_INDEX", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add( + row.ExpertName, + row.ExpertCode, + row.StockCode is null ? DBNull.Value : row.StockCode, + row.StockName is null ? DBNull.Value : row.StockName, + row.BuyAmount is null ? DBNull.Value : row.BuyAmount, + row.PlayIndex is null ? DBNull.Value : row.PlayIndex); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string QueryName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyIndustrySelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyIndustrySelectionServiceTests.cs new file mode 100644 index 0000000..07dff0d --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyIndustrySelectionServiceTests.cs @@ -0,0 +1,139 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyIndustrySelectionServiceTests +{ + [Theory] + [InlineData(IndustryMarket.Kospi, "OPERATOR_KOSPI_INDUSTRIES", "FROM T_PART")] + [InlineData(IndustryMarket.Kosdaq, "OPERATOR_KOSDAQ_INDUSTRIES", "FROM T_KOSDAQ_PART")] + public async Task GetAsync_uses_one_closed_parameterized_query_and_normalizes_rows( + IndustryMarket market, + string expectedTableName, + string expectedSqlFragment) + { + var executor = new RecordingExecutor(Table((" 반도체 ", "013"), ("운송", "021"))); + var service = new LegacyIndustrySelectionService(executor); + + var result = await service.GetAsync(market); + + Assert.Equal(2, result.Count); + Assert.All(result, value => Assert.Equal(market, value.Market)); + Assert.Equal(new IndustrySelection(market, "반도체", "013"), result[0]); + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal(expectedTableName, call.TableName); + Assert.Contains(expectedSqlFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Empty(call.Spec.Parameters); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task GetAsync_rejects_wrong_schema_duplicate_unsafe_code_and_control_text() + { + var wrongSchema = Table(("반도체", "013")); + wrongSchema.Columns[0].ColumnName = "NAME"; + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(new RecordingExecutor(wrongSchema)) + .GetAsync(IndustryMarket.Kospi)); + + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(new RecordingExecutor( + Table(("반도체", "013"), ("반도체", "013")))) + .GetAsync(IndustryMarket.Kospi)); + + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(new RecordingExecutor(Table(("반도체", "01-3")))) + .GetAsync(IndustryMarket.Kospi)); + + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(new RecordingExecutor(Table(("반\n도체", "013")))) + .GetAsync(IndustryMarket.Kospi)); + } + + [Fact] + public async Task GetAsync_rejects_ambiguous_duplicate_name_or_code() + { + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(new RecordingExecutor( + Table(("반도체", "013"), ("반도체", "014")))) + .GetAsync(IndustryMarket.Kospi)); + + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(new RecordingExecutor( + Table(("반도체", "013"), ("운송", "013")))) + .GetAsync(IndustryMarket.Kosdaq)); + } + + [Fact] + public async Task GetAsync_rejects_unbounded_results_and_unknown_market_before_string_SQL() + { + var oversized = Table(Enumerable.Range(0, LegacyIndustrySelectionService.MaximumResults + 1) + .Select(index => ($"업종{index}", index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture))) + .ToArray()); + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(new RecordingExecutor(oversized)) + .GetAsync(IndustryMarket.Kosdaq)); + + var executor = new RecordingExecutor(Table()); + await Assert.ThrowsAsync(() => + new LegacyIndustrySelectionService(executor).GetAsync((IndustryMarket)999)); + Assert.Empty(executor.Calls); + Assert.Equal(0, executor.StringCalls); + } + + private static DataTable Table(params (string Name, string Code)[] rows) + { + var table = new DataTable(); + table.Columns.Add("INDUSTRY_NAME", typeof(string)); + table.Columns.Add("INDUSTRY_CODE", typeof(string)); + foreach (var row in rows) table.Rows.Add(row.Name, row.Code); + return table; + } + + private sealed record QueryCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec); + + private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor + { + private readonly Queue _results = new(results); + + public List Calls { get; } = []; + + public int StringCalls { get; private set; } + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + StringCalls++; + throw new InvalidOperationException("String SQL is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + throw new InvalidOperationException("String SQL is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) throw new InvalidOperationException("No result remains."); + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs new file mode 100644 index 0000000..0f19626 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs @@ -0,0 +1,616 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyManualFinancialScreenServiceTests +{ + private const string Version = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + public static TheoryData Screens => new() + { + { ManualFinancialScreenKind.RevenueComposition, "INPUT_PIE", "s5076", "5076", "REVENUE_COMPOSITION" }, + { ManualFinancialScreenKind.GrowthMetrics, "INPUT_GROW", "s5079", "5079", "GROWTH_METRICS" }, + { ManualFinancialScreenKind.Sales, "INPUT_SELL", "s5080", "5080", "SALES" }, + { ManualFinancialScreenKind.OperatingProfit, "INPUT_PROFIT", "s5081", "5081", "OPERATING_PROFIT" } + }; + + [Theory] + [MemberData(nameof(Screens))] + public void Contracts_pin_the_original_table_builder_cut_and_one_page_selection( + ManualFinancialScreenKind screen, + string table, + string builder, + string cut, + string graphic) + { + var contract = ManualFinancialCutContracts.Get(screen); + + Assert.Equal(table, contract.TableName); + Assert.Equal(builder, contract.BuilderKey); + Assert.Equal(cut, contract.CutCode); + Assert.Equal(graphic, contract.CanonicalGraphicType); + Assert.StartsWith("INPUT_", contract.TableName, StringComparison.Ordinal); + Assert.Contains("_", contract.CompoundStorageShape, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(Screens))] + public async Task Search_uses_one_parameterized_Oracle_profile_and_maps_the_closed_DTO( + ManualFinancialScreenKind screen, + string tableName, + string builder, + string cut, + string graphic) + { + var executor = new RecordingQueryExecutor(Table(screen, "삼성전자")); + var service = new LegacyManualFinancialScreenService(executor); + + var result = await service.SearchAsync(screen, " 삼성 ", 25); + + Assert.Equal(screen, result.Screen); + Assert.Equal("삼성", result.Query); + Assert.Single(result.Items); + Assert.Equal(screen, result.Items[0].Record.Identity.Screen); + Assert.Equal(builder, ManualFinancialCutContracts.Get(screen).BuilderKey); + Assert.Equal(cut, ManualFinancialCutContracts.Get(screen).CutCode); + Assert.Equal(graphic, ManualFinancialCutContracts.Get(screen).CanonicalGraphicType); + Assert.Equal("삼성전자", result.Items[0].Record.Identity.StockName); + Assert.Equal(Version, result.Items[0].RowVersion); + Assert.False(result.IsTruncated); + Assert.Equal(DataSourceKind.Oracle, executor.Source); + Assert.Equal("MANUAL_FINANCIAL_" + QueryToken(screen) + "_SEARCH", executor.TableName); + Assert.NotNull(executor.Spec); + Assert.Contains("FROM " + tableName, executor.Spec!.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("삼성", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(["search_empty", "search_term", "row_limit"], + executor.Spec.Parameters.Select(parameter => parameter.Name)); + Assert.Equal(0, executor.Spec.Parameters[0].Value); + Assert.Equal("삼성", executor.Spec.Parameters[1].Value); + Assert.Equal(26, executor.Spec.Parameters[2].Value); + } + + [Fact] + public async Task Blank_list_and_literal_substring_search_are_bound_and_escaped() + { + var blankExecutor = new RecordingQueryExecutor(Table( + ManualFinancialScreenKind.Sales, + "A%_!")); + await new LegacyManualFinancialScreenService(blankExecutor) + .SearchAsync(ManualFinancialScreenKind.Sales, " "); + Assert.Equal(1, blankExecutor.Spec!.Parameters[0].Value); + Assert.Equal(string.Empty, blankExecutor.Spec.Parameters[1].Value); + + var escapedExecutor = new RecordingQueryExecutor(Table( + ManualFinancialScreenKind.Sales, + "A%_!")); + await new LegacyManualFinancialScreenService(escapedExecutor) + .SearchAsync(ManualFinancialScreenKind.Sales, "a%_!"); + Assert.Equal("A!%!_!!", escapedExecutor.Spec!.Parameters[1].Value); + Assert.Contains("ESCAPE '!'", escapedExecutor.Spec.Sql, StringComparison.Ordinal); + } + + [Fact] + public async Task Revenue_storage_maps_five_label_percentage_fields_and_empty_slice() + { + var table = EmptyTable(ManualFinancialScreenKind.RevenueComposition); + table.Rows.Add("A", "2026-06", "반도체_50.0", "서비스_-", "", "기타_25", "해외_25", Version); + + var result = await new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table)) + .SearchAsync(ManualFinancialScreenKind.RevenueComposition); + + var record = Assert.IsType(result.Items[0].Record); + Assert.Equal("2026-06", record.BaseDate); + Assert.Equal(5, record.Slices.Count); + Assert.Equal(new ManualRevenueSlice("반도체", "50.0", 50d), record.Slices[0]); + Assert.Equal(new ManualRevenueSlice("서비스", "-", 0d), record.Slices[1]); + Assert.Null(record.Slices[2]); + } + + [Fact] + public async Task Growth_storage_maps_four_named_series_and_four_periods() + { + var result = await new LegacyManualFinancialScreenService( + new RecordingQueryExecutor(Table(ManualFinancialScreenKind.GrowthMetrics, "A"))) + .SearchAsync(ManualFinancialScreenKind.GrowthMetrics); + + var record = Assert.IsType(result.Items[0].Record); + Assert.Equal([1d, 2d, 3d, 4d], record.SalesGrowth.ToArray()); + Assert.Equal([5d, 6d, null, 8d], record.OperatingProfitGrowth.ToArray()); + Assert.Equal(["1Q", "2Q", "3Q", "4Q"], record.Periods.ToArray()); + } + + [Theory] + [InlineData(ManualFinancialScreenKind.Sales)] + [InlineData(ManualFinancialScreenKind.OperatingProfit)] + public async Task Six_quarter_storage_maps_signed_integer_values(ManualFinancialScreenKind screen) + { + var result = await new LegacyManualFinancialScreenService( + new RecordingQueryExecutor(Table(screen, "A"))) + .SearchAsync(screen); + + var quarters = result.Items[0].Record switch + { + ManualSalesRecord sales => sales.Quarters, + ManualOperatingProfitRecord profit => profit.Quarters, + _ => throw new InvalidOperationException() + }; + Assert.Equal(6, quarters.Count); + Assert.Equal("1Q", quarters[0].Quarter); + Assert.Equal(10, quarters[0].Value); + Assert.Equal(-20, quarters[1].Value); + } + + [Fact] + public async Task Search_rejects_duplicate_or_case_ambiguous_stock_names() + { + var table = EmptyTable(ManualFinancialScreenKind.Sales); + AddQuarterRow(table, "Alpha"); + AddQuarterRow(table, "ALPHA", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + await Assert.ThrowsAsync(() => + new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table)) + .SearchAsync(ManualFinancialScreenKind.Sales)); + } + + [Fact] + public async Task Exact_get_binds_name_and_rejects_missing_or_duplicate_rows() + { + var identity = new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha"); + + var missing = new RecordingQueryExecutor(EmptyTable(ManualFinancialScreenKind.Sales)); + await Assert.ThrowsAsync(() => + new LegacyManualFinancialScreenService(missing).GetAsync(identity)); + Assert.Equal("stock_name", missing.Spec!.Parameters.Single().Name); + Assert.Equal("Alpha", missing.Spec.Parameters.Single().Value); + Assert.DoesNotContain("Alpha", missing.Spec.Sql, StringComparison.Ordinal); + + var duplicate = EmptyTable(ManualFinancialScreenKind.Sales); + AddQuarterRow(duplicate, "Alpha"); + AddQuarterRow(duplicate, "Alpha", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + await Assert.ThrowsAsync(() => + new LegacyManualFinancialScreenService(new RecordingQueryExecutor(duplicate)) + .GetAsync(identity)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Read_schema_and_row_version_fail_closed(bool badSchema) + { + var table = Table(ManualFinancialScreenKind.Sales, "A"); + if (badSchema) + { + table.Columns[0].ColumnName = "stock_name"; + } + else + { + table.Rows[0]["ROW_VERSION"] = "not-a-version"; + } + + await Assert.ThrowsAsync(() => + new LegacyManualFinancialScreenService(new RecordingQueryExecutor(table)) + .SearchAsync(ManualFinancialScreenKind.Sales)); + } + + [Theory] + [MemberData(nameof(Screens))] + public async Task Create_uses_bound_values_one_locked_transaction_and_exactly_one_row( + ManualFinancialScreenKind screen, + string table, + string builder, + string cut, + string graphic) + { + var mutation = new RecordingMutationExecutor(); + var service = new LegacyManualFinancialScreenService( + new RecordingQueryExecutor(EmptyTable(screen)), + mutation); + var record = Record(screen, "Operator ' quoted"); + + var receipt = await service.CreateAsync(record); + + Assert.Equal(1, mutation.CallCount); + Assert.Equal(ManualFinancialMutationKind.Create, receipt.Kind); + Assert.Equal(screen, receipt.Screen); + Assert.Equal("Operator ' quoted", receipt.StockName); + Assert.Equal(1, receipt.AffectedRows); + Assert.NotEqual(Guid.Empty, receipt.OperationId); + Assert.Equal(DataSourceKind.Oracle, mutation.Source); + var transaction = Assert.IsType(mutation.Transaction); + Assert.Equal(screen, transaction.Screen); + Assert.Equal(builder, ManualFinancialCutContracts.Get(screen).BuilderKey); + Assert.Equal(cut, ManualFinancialCutContracts.Get(screen).CutCode); + Assert.Equal(graphic, ManualFinancialCutContracts.Get(screen).CanonicalGraphicType); + Assert.Equal(table, transaction.TableName); + Assert.Equal(ManualFinancialMutationKind.Create, transaction.Kind); + Assert.True(transaction.RequiresExclusiveTableLock); + Assert.Equal("LOCK TABLE " + table + " IN EXCLUSIVE MODE", transaction.ExclusiveTableLockSql); + var command = Assert.Single(transaction.Commands); + Assert.Equal(ManualFinancialDbCommandKind.Insert, command.Kind); + Assert.Equal(ManualFinancialAffectedRowsRule.ExactlyOne, command.AffectedRowsRule); + Assert.Contains("INSERT INTO " + table, command.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("Operator ' quoted", command.Sql, StringComparison.Ordinal); + Assert.Equal("Operator ' quoted", command.Parameters.Single(p => p.Name == "stock_name").Value); + Assert.Equal("Operator ' quoted", command.Parameters.Single(p => p.Name == "unique_stock_name").Value); + Assert.All(command.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType)); + } + + [Fact] + public async Task Update_preserves_identity_binds_row_version_and_serializes_loader_shapes() + { + var mutation = new RecordingMutationExecutor(); + var service = MutableService(ManualFinancialScreenKind.GrowthMetrics, mutation); + var original = Snapshot(Record(ManualFinancialScreenKind.GrowthMetrics, "A")); + var replacement = new ManualGrowthMetricsRecord( + original.Record.Identity, + new ManualGrowthSeriesValues(1, null, 3.5, 4), + new ManualGrowthSeriesValues(5, 6, 7, 8), + new ManualGrowthSeriesValues(9, 10, 11, 12), + new ManualGrowthSeriesValues(13, 14, 15, 16), + new ManualGrowthPeriodLabels("1Q", "2Q", "3Q", "4Q")); + + await service.UpdateAsync(original, replacement); + + var transaction = Assert.IsType(mutation.Transaction); + var command = Assert.Single(transaction.Commands); + Assert.Equal(ManualFinancialDbCommandKind.Update, command.Kind); + Assert.Equal(Version, command.Parameters.Single(p => p.Name == "expected_row_version").Value); + Assert.Equal("1__3.5_4", command.Parameters.Single(p => p.Name == "field_1").Value); + Assert.Equal("1Q_2Q_3Q_4Q", command.Parameters.Single(p => p.Name == "field_5").Value); + Assert.Contains("STANDARD_HASH", command.Sql, StringComparison.Ordinal); + Assert.Contains("COUNT(*)", command.Sql, StringComparison.Ordinal); + } + + [Fact] + public async Task Update_rejects_rename_or_screen_change_before_executor() + { + var mutation = new RecordingMutationExecutor(); + var service = MutableService(ManualFinancialScreenKind.Sales, mutation); + var original = Snapshot(Record(ManualFinancialScreenKind.Sales, "A")); + + await Assert.ThrowsAsync(() => + service.UpdateAsync(original, Record(ManualFinancialScreenKind.Sales, "B"))); + await Assert.ThrowsAsync(() => + service.UpdateAsync(original, Record(ManualFinancialScreenKind.OperatingProfit, "A"))); + Assert.Equal(0, mutation.CallCount); + } + + [Fact] + public async Task Delete_one_binds_identity_and_version_while_delete_all_has_no_values() + { + var mutation = new RecordingMutationExecutor(); + var service = MutableService(ManualFinancialScreenKind.OperatingProfit, mutation); + var snapshot = Snapshot(Record(ManualFinancialScreenKind.OperatingProfit, "A")); + + await service.DeleteAsync(snapshot); + var deleteOne = Assert.Single(mutation.Transaction!.Commands); + Assert.Equal(ManualFinancialDbCommandKind.DeleteOne, deleteOne.Kind); + Assert.Equal(["identity_stock_name", "expected_row_version", "unique_stock_name"], + deleteOne.Parameters.Select(parameter => parameter.Name)); + Assert.Contains("STANDARD_HASH", deleteOne.Sql, StringComparison.Ordinal); + + await service.DeleteAllAsync(ManualFinancialScreenKind.OperatingProfit); + var deleteAll = Assert.Single(mutation.Transaction!.Commands); + Assert.Equal(ManualFinancialDbCommandKind.DeleteAll, deleteAll.Kind); + Assert.Equal(ManualFinancialAffectedRowsRule.AnyNonNegative, deleteAll.AffectedRowsRule); + Assert.Empty(deleteAll.Parameters); + Assert.Equal("DELETE FROM INPUT_PROFIT", deleteAll.Sql); + } + + [Fact] + public async Task Read_only_service_blocks_every_mutation_before_database_work() + { + var service = new LegacyManualFinancialScreenService( + new RecordingQueryExecutor(EmptyTable(ManualFinancialScreenKind.Sales))); + Assert.False(service.CanMutate); + var record = Record(ManualFinancialScreenKind.Sales, "A"); + var snapshot = Snapshot(record); + + await Assert.ThrowsAsync(() => service.CreateAsync(record)); + await Assert.ThrowsAsync(() => service.UpdateAsync(snapshot, record)); + await Assert.ThrowsAsync(() => service.DeleteAsync(snapshot)); + await Assert.ThrowsAsync(() => + service.DeleteAllAsync(ManualFinancialScreenKind.Sales)); + } + + [Fact] + public async Task Unknown_outcome_is_not_retried_and_known_rollback_is_preserved() + { + var unknown = new RecordingMutationExecutor { Exception = new TimeoutException("late") }; + var service = MutableService(ManualFinancialScreenKind.Sales, unknown); + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(Record(ManualFinancialScreenKind.Sales, "A"))); + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, unknown.CallCount); + + var rejectedException = new ManualFinancialMutationRejectedException( + ManualFinancialMutationRejectionReason.DuplicateIdentity, + "rolled back"); + var rejected = new RecordingMutationExecutor { Exception = rejectedException }; + var rejectedService = MutableService(ManualFinancialScreenKind.Sales, rejected); + var actual = await Assert.ThrowsAsync(() => + rejectedService.CreateAsync(Record(ManualFinancialScreenKind.Sales, "A"))); + Assert.Same(rejectedException, actual); + Assert.False(actual.OutcomeUnknown); + Assert.Equal(1, rejected.CallCount); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(2)] + public async Task Ambiguous_committed_row_count_is_OutcomeUnknown_and_never_retried(int affectedRows) + { + var mutation = new RecordingMutationExecutor { AffectedRows = affectedRows }; + var service = MutableService(ManualFinancialScreenKind.Sales, mutation); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(Record(ManualFinancialScreenKind.Sales, "A"))); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, mutation.CallCount); + } + + [Fact] + public void Verified_stock_rejects_duplicate_names_duplicate_rows_or_wrong_provider() + { + var identity = new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, "Alpha"); + var selected = new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "Alpha", "000001"); + + Assert.Throws(() => ManualFinancialCutContracts.VerifyStock( + identity, + [selected, new(StockMarket.Kosdaq, DataSourceKind.Oracle, "Alpha", "000002")], + StockMarket.Kospi, + "000001")); + Assert.Throws(() => ManualFinancialCutContracts.VerifyStock( + identity, + [selected, selected], + StockMarket.Kospi, + "000001")); + Assert.Throws(() => ManualFinancialCutContracts.VerifyStock( + identity, + [new(StockMarket.Kospi, DataSourceKind.MariaDb, "Alpha", "000001")], + StockMarket.Kospi, + "000001")); + } + + [Theory] + [MemberData(nameof(Screens))] + public void Verified_stock_creates_the_exact_s5076_s5079_s5080_s5081_contract( + ManualFinancialScreenKind screen, + string _, + string builder, + string cut, + string graphic) + { + var record = Record(screen, "Alpha"); + var snapshot = Snapshot(record); + var candidate = new StockSearchItem( + StockMarket.Kosdaq, + DataSourceKind.Oracle, + "Alpha", + "000001"); + var stock = ManualFinancialCutContracts.VerifyStock( + record.Identity, + [candidate], + StockMarket.Kosdaq, + "000001"); + + var selection = ManualFinancialCutContracts.CreateSelection(snapshot, stock); + + Assert.Equal(builder, selection.BuilderKey); + Assert.Equal(cut, selection.CutCode); + Assert.Equal(1, selection.CurrentPage); + Assert.Equal(1, selection.TotalPages); + Assert.Equal("KOSDAQ", selection.Selection.GroupCode); + Assert.Equal("Alpha", selection.Selection.Subject); + Assert.Equal(graphic, selection.Selection.GraphicType); + Assert.Equal(string.Empty, selection.Selection.Subtype); + Assert.Equal(string.Empty, selection.Selection.DataCode); + } + + [Fact] + public async Task Invalid_closed_DTO_shapes_fail_before_a_mutation() + { + var mutation = new RecordingMutationExecutor(); + var service = MutableService(ManualFinancialScreenKind.RevenueComposition, mutation); + var identity = new ManualFinancialIdentity( + ManualFinancialScreenKind.RevenueComposition, + "A"); + var wrongCount = new ManualRevenueCompositionRecord( + identity, + "2026", + [new("A", "50", 50)]); + var inconsistent = new ManualRevenueCompositionRecord( + identity, + "2026", + [new("A", "50", 49), null, null, null, null]); + var wrongType = new ManualSalesRecord(identity, SixQuarters()); + + await Assert.ThrowsAsync(() => service.CreateAsync(wrongCount)); + await Assert.ThrowsAsync(() => service.CreateAsync(inconsistent)); + await Assert.ThrowsAsync(() => service.CreateAsync(wrongType)); + Assert.Equal(0, mutation.CallCount); + } + + private static LegacyManualFinancialScreenService MutableService( + ManualFinancialScreenKind screen, + RecordingMutationExecutor mutation) => + new(new RecordingQueryExecutor(EmptyTable(screen)), mutation); + + private static ManualFinancialSnapshot Snapshot(ManualFinancialRecord record) => + new(record, Version); + + private static ManualFinancialRecord Record( + ManualFinancialScreenKind screen, + string stockName) + { + var identity = new ManualFinancialIdentity(screen, stockName); + return screen switch + { + ManualFinancialScreenKind.RevenueComposition => + new ManualRevenueCompositionRecord( + identity, + "2026-06", + [ + new("반도체", "50", 50), + new("서비스", "25", 25), + new("해외", "25", 25), + null, + null + ]), + ManualFinancialScreenKind.GrowthMetrics => + new ManualGrowthMetricsRecord( + identity, + new(1, 2, 3, 4), + new(5, 6, null, 8), + new(9, 10, 11, 12), + new(13, 14, 15, 16), + new("1Q", "2Q", "3Q", "4Q")), + ManualFinancialScreenKind.Sales => new ManualSalesRecord(identity, SixQuarters()), + ManualFinancialScreenKind.OperatingProfit => + new ManualOperatingProfitRecord(identity, SixQuarters()), + _ => throw new ArgumentOutOfRangeException(nameof(screen)) + }; + } + + private static IReadOnlyList SixQuarters() => + [ + new("1Q", 10), + new("2Q", -20), + new("3Q", 30), + new("4Q", 40), + new("5Q", 50), + new("6Q", 60) + ]; + + private static DataTable Table(ManualFinancialScreenKind screen, string stockName) + { + var table = EmptyTable(screen); + switch (screen) + { + case ManualFinancialScreenKind.RevenueComposition: + table.Rows.Add(stockName, "2026-06", "반도체_50", "서비스_25", "해외_25", "", "", Version); + break; + case ManualFinancialScreenKind.GrowthMetrics: + table.Rows.Add(stockName, "1_2_3_4", "5_6__8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q", Version); + break; + case ManualFinancialScreenKind.Sales: + case ManualFinancialScreenKind.OperatingProfit: + AddQuarterRow(table, stockName); + break; + default: + throw new ArgumentOutOfRangeException(nameof(screen)); + } + + return table; + } + + private static DataTable EmptyTable(ManualFinancialScreenKind screen) + { + var table = new DataTable(); + foreach (var column in ManualFinancialCutContracts.Get(screen).StorageColumns) + { + table.Columns.Add(column, typeof(string)); + } + table.Columns.Add("ROW_VERSION", typeof(string)); + return table; + } + + private static void AddQuarterRow( + DataTable table, + string stockName, + string version = Version) => + table.Rows.Add( + stockName, + "1Q_10", + "2Q_-20", + "3Q_30", + "4Q_40", + "5Q_50", + "6Q_60", + version); + + private static string QueryToken(ManualFinancialScreenKind screen) => screen switch + { + ManualFinancialScreenKind.RevenueComposition => "PIE", + ManualFinancialScreenKind.GrowthMetrics => "GROW", + ManualFinancialScreenKind.Sales => "SELL", + ManualFinancialScreenKind.OperatingProfit => "PROFIT", + _ => throw new ArgumentOutOfRangeException(nameof(screen)) + }; + + private sealed class RecordingQueryExecutor : IDataQueryExecutor + { + private readonly DataTable _table; + + public RecordingQueryExecutor(DataTable table) + { + _table = table; + } + + public DataSourceKind? Source { get; private set; } + + public string? TableName { get; private set; } + + public DataQuerySpec? Spec { get; private set; } + + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new NotSupportedException(); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Source = source; + TableName = tableName; + Spec = query; + return Task.FromResult(_table); + } + } + + private sealed class RecordingMutationExecutor : IManualFinancialMutationExecutor + { + public int CallCount { get; private set; } + + public int AffectedRows { get; init; } = 1; + + public Exception? Exception { get; init; } + + public DataSourceKind? Source { get; private set; } + + public ManualFinancialDbTransaction? Transaction { get; private set; } + + public Task ExecuteTransactionAsync( + DataSourceKind source, + ManualFinancialDbTransaction transaction, + CancellationToken cancellationToken = default) + { + CallCount++; + Source = source; + Transaction = transaction; + if (Exception is not null) + { + return Task.FromException(Exception); + } + + return Task.FromResult(new ManualFinancialDbTransactionResult( + transaction.OperationId, + [AffectedRows], + DateTimeOffset.Now)); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceIntegrationTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceIntegrationTests.cs new file mode 100644 index 0000000..a568fd9 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceIntegrationTests.cs @@ -0,0 +1,271 @@ +using System.Data; +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +/// +/// Component integration of the typed service and its read/transaction seams. +/// The store is in-memory: no production Oracle row is changed by this test. +/// +public sealed class LegacyNamedPlaylistPersistenceIntegrationTests +{ + [Fact] + public async Task NamedPlaylistLifecycle_PreservesTitleAndVisibleItemOrder() + { + var store = new InMemoryOracleContractStore(); + var service = new LegacyNamedPlaylistPersistenceService(store, store); + + var firstCode = await service.SuggestNextProgramCodeAsync(); + await service.CreateDefinitionAsync(firstCode, "Morning"); + var secondCode = await service.SuggestNextProgramCodeAsync(); + await service.CreateDefinitionAsync(secondCode, "Afternoon"); + + var definitions = await service.ListAsync(); + Assert.Equal( + ["Afternoon", "Morning"], + definitions.Items.Select(static item => item.Title)); + + var alpha = StoredItem(0, "Alpha", "000001"); + var beta = StoredItem(1, "Beta", "000002"); + await service.ReplaceItemsAsync(firstCode, [alpha, beta]); + + var initial = await service.LoadAsync(firstCode); + Assert.Equal( + ["Alpha", "Beta"], + initial.Items.Select(static item => item.Selection.Subject)); + + await service.ReplaceItemsAsync( + firstCode, + [beta with { ItemIndex = 0 }, alpha with { ItemIndex = 1 }]); + + var reordered = await service.LoadAsync(firstCode); + Assert.Equal( + ["Beta", "Alpha"], + reordered.Items.Select(static item => item.Selection.Subject)); + Assert.Equal([0, 1], reordered.Items.Select(static item => item.ItemIndex)); + + await service.DeleteAsync(firstCode); + await Assert.ThrowsAsync( + () => service.LoadAsync(firstCode)); + var remaining = await service.ListAsync(); + Assert.Equal(secondCode, Assert.Single(remaining.Items).ProgramCode); + } + + private static NamedPlaylistStoredItem StoredItem( + int index, + string subject, + string dataCode) => + new( + index, + true, + new LegacySceneSelection("KOSPI", subject, "1열판기본", "현재가", dataCode), + new NamedPlaylistPageState(1, 1)); + + private sealed class InMemoryOracleContractStore + : IDataQueryExecutor, INamedPlaylistMutationExecutor + { + private Dictionary _definitions = new(StringComparer.Ordinal); + private Dictionary> _items = new(StringComparer.Ordinal); + + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new InvalidOperationException("String SQL fallback is forbidden."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("String SQL fallback is forbidden."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Equal(DataSourceKind.Oracle, source); + query.ValidateFor(source); + var table = tableName switch + { + LegacyNamedPlaylistPersistenceService.ListQueryName => List(query), + LegacyNamedPlaylistPersistenceService.NextCodeQueryName => NextCode(), + LegacyNamedPlaylistPersistenceService.LoadQueryName => Load(query), + _ => throw new InvalidOperationException("Unexpected query contract.") + }; + return Task.FromResult(table); + } + + public Task ExecuteTransactionAsync( + DataSourceKind source, + NamedPlaylistDbTransaction transaction, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Equal(DataSourceKind.Oracle, source); + + var definitions = new Dictionary(_definitions, StringComparer.Ordinal); + var items = _items.ToDictionary( + static pair => pair.Key, + static pair => new SortedDictionary(pair.Value), + StringComparer.Ordinal); + var affected = new List(transaction.Commands.Count); + foreach (var command in transaction.Commands) + { + switch (command.Kind) + { + case NamedPlaylistDbCommandKind.InsertDefinition: + { + var code = StringValue(command, "program_code"); + var title = StringValue(command, "program_title"); + if (!definitions.TryAdd(code, title)) + { + throw new NamedPlaylistMutationException( + "Duplicate definition; transaction rolled back.", + outcomeUnknown: false); + } + + affected.Add(1); + break; + } + + case NamedPlaylistDbCommandKind.DeleteItems: + { + var code = StringValue(command, "program_code"); + var count = items.TryGetValue(code, out var existing) ? existing.Count : 0; + items.Remove(code); + affected.Add(count); + break; + } + + case NamedPlaylistDbCommandKind.InsertItem: + { + var code = StringValue(command, "program_code"); + if (!definitions.ContainsKey(code)) + { + throw new NamedPlaylistMutationException( + "Missing definition; transaction rolled back.", + outcomeUnknown: false); + } + + var listText = StringValue(command, "list_text"); + var index = IntValue(command, "item_index"); + if (!items.TryGetValue(code, out var playlistItems)) + { + playlistItems = new SortedDictionary(); + items.Add(code, playlistItems); + } + + playlistItems.Add(index, listText); + affected.Add(1); + break; + } + + case NamedPlaylistDbCommandKind.DeleteDefinition: + { + var code = StringValue(command, "program_code"); + affected.Add(definitions.Remove(code) ? 1 : 0); + break; + } + + default: + throw new InvalidOperationException("Unexpected mutation contract."); + } + } + + _definitions = definitions; + _items = items; + return Task.FromResult(new NamedPlaylistDbTransactionResult( + transaction.OperationId, + affected, + DateTimeOffset.UtcNow)); + } + + private DataTable List(DataQuerySpec query) + { + var rowLimit = Convert.ToInt32( + Parameter(query.Parameters, "row_limit").Value, + CultureInfo.InvariantCulture); + var table = new DataTable(); + table.Columns.Add("DC_CODE", typeof(string)); + table.Columns.Add("DC_TITLE", typeof(string)); + foreach (var pair in _definitions + .OrderBy(static pair => pair.Value, StringComparer.Ordinal) + .ThenBy(static pair => pair.Key, StringComparer.Ordinal) + .Take(rowLimit)) + { + table.Rows.Add(pair.Key, pair.Value); + } + + return table; + } + + private DataTable NextCode() + { + var next = _definitions.Count == 0 + ? 1 + : _definitions.Keys.Max(static code => + int.Parse(code, NumberStyles.None, CultureInfo.InvariantCulture)) + 1; + var table = new DataTable(); + table.Columns.Add("DC_CODE", typeof(string)); + table.Rows.Add(next.ToString("D8", CultureInfo.InvariantCulture)); + return table; + } + + private DataTable Load(DataQuerySpec query) + { + var code = Convert.ToString( + Parameter(query.Parameters, "program_code").Value, + CultureInfo.InvariantCulture)!; + var rowLimit = Convert.ToInt32( + Parameter(query.Parameters, "row_limit").Value, + CultureInfo.InvariantCulture); + var table = new DataTable(); + table.Columns.Add("DC_CODE", typeof(string)); + table.Columns.Add("DC_TITLE", typeof(string)); + table.Columns.Add("LIST_TEXT", typeof(string)); + table.Columns.Add("ITEM_INDEX", typeof(string)); + if (!_definitions.TryGetValue(code, out var title)) + { + return table; + } + + if (!_items.TryGetValue(code, out var playlistItems) || playlistItems.Count == 0) + { + table.Rows.Add(code, title, DBNull.Value, DBNull.Value); + return table; + } + + foreach (var item in playlistItems.Take(rowLimit)) + { + table.Rows.Add( + code, + title, + item.Value, + item.Key.ToString(CultureInfo.InvariantCulture)); + } + + return table; + } + + private static string StringValue(NamedPlaylistDbCommand command, string name) => + Assert.IsType(CommandParameter(command, name).Value); + + private static int IntValue(NamedPlaylistDbCommand command, string name) => + Assert.IsType(CommandParameter(command, name).Value); + + private static NamedPlaylistDbParameter CommandParameter( + NamedPlaylistDbCommand command, + string name) => + Assert.Single(command.Parameters, parameter => + string.Equals(parameter.Name, name, StringComparison.Ordinal)); + + private static DataQueryParameter Parameter( + IReadOnlyList parameters, + string name) => + Assert.Single(parameters, parameter => + string.Equals(parameter.Name, name, StringComparison.Ordinal)); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceServiceTests.cs new file mode 100644 index 0000000..a226841 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceServiceTests.cs @@ -0,0 +1,578 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyNamedPlaylistPersistenceServiceTests +{ + [Fact] + public async Task ListAsync_UsesBoundOracleLimitAndReportsTruncation() + { + var query = new RecordingQueryExecutor(call => + { + Assert.Equal(LegacyNamedPlaylistPersistenceService.ListQueryName, call.QueryName); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal(LegacyNamedPlaylistPersistenceService.ListSql, call.Spec.Sql); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(3, parameter.Value); + Assert.Equal(DbType.Int32, parameter.DbType); + return ListTable( + ("00000001", "Alpha"), + ("00000002", "Beta"), + ("00000003", "Gamma")); + }); + var service = new LegacyNamedPlaylistPersistenceService(query); + + var result = await service.ListAsync(2); + + Assert.True(result.IsTruncated); + Assert.Equal( + ["00000001", "00000002"], + result.Items.Select(static item => item.ProgramCode)); + Assert.Equal(["Alpha", "Beta"], result.Items.Select(static item => item.Title)); + Assert.Single(query.Calls); + Assert.Equal(0, query.StringSqlCallCount); + } + + [Fact] + public async Task ListAsync_AllowsDuplicateTitlesButRejectsDuplicateCodes() + { + var duplicateTitles = new RecordingQueryExecutor(_ => + ListTable(("00000001", "Morning"), ("00000002", "Morning"))); + var validService = new LegacyNamedPlaylistPersistenceService(duplicateTitles); + + var valid = await validService.ListAsync(); + + Assert.Equal(2, valid.Items.Count); + + var duplicateCodes = new RecordingQueryExecutor(_ => + ListTable(("00000001", "Morning"), ("00000001", "Evening"))); + var invalidService = new LegacyNamedPlaylistPersistenceService(duplicateCodes); + + await Assert.ThrowsAsync(() => invalidService.ListAsync()); + } + + [Fact] + public async Task ListAsync_RejectsSchemaDriftAndInvalidLimit() + { + var wrongSchema = new DataTable(); + wrongSchema.Columns.Add("DC_TITLE", typeof(string)); + wrongSchema.Columns.Add("DC_CODE", typeof(string)); + var query = new RecordingQueryExecutor(_ => wrongSchema); + var service = new LegacyNamedPlaylistPersistenceService(query); + + await Assert.ThrowsAsync(() => service.ListAsync()); + await Assert.ThrowsAsync(() => service.ListAsync(0)); + } + + [Fact] + public async Task SuggestNextProgramCodeAsync_PreservesOriginalEightDigitContract() + { + var query = new RecordingQueryExecutor(call => + { + Assert.Equal(LegacyNamedPlaylistPersistenceService.NextCodeQueryName, call.QueryName); + Assert.Equal(LegacyNamedPlaylistPersistenceService.NextCodeSql, call.Spec.Sql); + Assert.Empty(call.Spec.Parameters); + return NextCodeTable("00000124"); + }); + var service = new LegacyNamedPlaylistPersistenceService(query); + + var code = await service.SuggestNextProgramCodeAsync(); + + Assert.Equal("00000124", code); + } + + [Theory] + [InlineData("124")] + [InlineData("0000000A")] + [InlineData("100000000")] + public async Task SuggestNextProgramCodeAsync_RejectsInvalidDatabaseCode(string code) + { + var query = new RecordingQueryExecutor(_ => NextCodeTable(code)); + var service = new LegacyNamedPlaylistPersistenceService(query); + + await Assert.ThrowsAsync( + () => service.SuggestNextProgramCodeAsync()); + } + + [Fact] + public async Task LoadAsync_ParsesOriginalSevenFieldRowsAndPageWhitespace() + { + var query = new RecordingQueryExecutor(call => + { + Assert.Equal(LegacyNamedPlaylistPersistenceService.LoadQueryName, call.QueryName); + Assert.Equal(LegacyNamedPlaylistPersistenceService.LoadSql, call.Spec.Sql); + Assert.Collection( + call.Spec.Parameters, + parameter => + { + Assert.Equal("program_code", parameter.Name); + Assert.Equal("00000042", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }, + parameter => + { + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(1_001, parameter.Value); + Assert.Equal(DbType.Int32, parameter.DbType); + }); + return LoadTable( + ("00000042", "Opening", "1^KOSPI^삼성전자^1열판기본^현재가^ 1 / 2 ^005930", "0"), + ("00000042", "Opening", "0^INDEX^코스피 지수^2열판^현재가^1/1^", "1")); + }); + var service = new LegacyNamedPlaylistPersistenceService(query); + + var document = await service.LoadAsync("00000042"); + + Assert.Equal(new NamedPlaylistSummary("00000042", "Opening"), document.Definition); + Assert.Collection( + document.Items, + item => + { + Assert.Equal(0, item.ItemIndex); + Assert.True(item.IsEnabled); + Assert.Equal("KOSPI", item.Selection.GroupCode); + Assert.Equal("삼성전자", item.Selection.Subject); + Assert.Equal("1열판기본", item.Selection.GraphicType); + Assert.Equal("현재가", item.Selection.Subtype); + Assert.Equal("005930", item.Selection.DataCode); + Assert.Equal(new NamedPlaylistPageState(1, 2), item.Page); + }, + item => + { + Assert.Equal(1, item.ItemIndex); + Assert.False(item.IsEnabled); + Assert.Equal("INDEX", item.Selection.GroupCode); + Assert.Equal(string.Empty, item.Selection.DataCode); + Assert.Equal(new NamedPlaylistPageState(1, 1), item.Page); + }); + } + + [Fact] + public async Task LoadAsync_ReturnsEmptyDocumentFromLeftJoinSentinel() + { + var table = LoadTable(); + table.Rows.Add("00000007", "Empty", DBNull.Value, DBNull.Value); + var query = new RecordingQueryExecutor(_ => table); + var service = new LegacyNamedPlaylistPersistenceService(query); + + var document = await service.LoadAsync("00000007"); + + Assert.Empty(document.Items); + Assert.Equal("Empty", document.Definition.Title); + } + + [Fact] + public async Task LoadAsync_RejectsMissingDefinitionAndNonContiguousIndexes() + { + var notFound = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => LoadTable())); + await Assert.ThrowsAsync( + () => notFound.LoadAsync("00000001")); + + var gap = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => LoadTable( + ("00000001", "Gap", "1^KOSPI^Alpha^Plate^Current^1/1^000001", "1")))); + await Assert.ThrowsAsync( + () => gap.LoadAsync("00000001")); + } + + [Theory] + [InlineData("1^KOSPI^Alpha^Plate^Current^1/1")] + [InlineData("yes^KOSPI^Alpha^Plate^Current^1/1^000001")] + [InlineData("1^KOSPI^Alpha^Plate^Current^0/1^000001")] + [InlineData("1^KOSPI^Alpha^Plate^Current^1/10000^000001")] + [InlineData("1^KOSPI^Al\npha^Plate^Current^1/1^000001")] + public async Task LoadAsync_RejectsMalformedLegacyListText(string listText) + { + var query = new RecordingQueryExecutor(_ => + LoadTable(("00000001", "Malformed", listText, "0"))); + var service = new LegacyNamedPlaylistPersistenceService(query); + + await Assert.ThrowsAsync( + () => service.LoadAsync("00000001")); + } + + [Fact] + public async Task LoadAsync_PreservesHistoricalPageDisplayOutsideCurrentPlayoutBound() + { + var query = new RecordingQueryExecutor(_ => LoadTable( + ("00000001", "Historical", "1^KOSPI^Alpha^Plate^Current^1/0^000001", "0"), + ("00000001", "Historical", "1^KOSPI^Beta^Plate^Current^1/24^000002", "1"))); + var service = new LegacyNamedPlaylistPersistenceService(query); + + var document = await service.LoadAsync("00000001"); + + Assert.Collection( + document.Items, + item => + { + Assert.Equal(new NamedPlaylistPageState(1, 0), item.Page); + Assert.False(item.Page!.IsWithinPlayoutBounds); + }, + item => + { + Assert.Equal(new NamedPlaylistPageState(1, 24), item.Page); + Assert.False(item.Page!.IsWithinPlayoutBounds); + }); + Assert.True(new NamedPlaylistPageState(4, 20).IsWithinPlayoutBounds); + } + + [Fact] + public async Task CreateDefinitionAsync_BindsTitleAndNeverAddsItToSql() + { + var mutations = new RecordingMutationExecutor(SuccessResult); + var query = new RecordingQueryExecutor(_ => throw new InvalidOperationException()); + var service = new LegacyNamedPlaylistPersistenceService(query, mutations); + + await service.CreateDefinitionAsync("00000009", "O'Brien 오전"); + + var call = Assert.Single(mutations.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal(NamedPlaylistMutationKind.CreateDefinition, call.Transaction.Kind); + Assert.Equal("00000009", call.Transaction.ProgramCode); + var command = Assert.Single(call.Transaction.Commands); + Assert.Equal(NamedPlaylistDbCommandKind.InsertDefinition, command.Kind); + Assert.Equal(LegacyNamedPlaylistPersistenceService.InsertDefinitionSql, command.Sql); + Assert.DoesNotContain("O'Brien", command.Sql, StringComparison.Ordinal); + Assert.Collection( + command.Parameters, + parameter => AssertParameter(parameter, "program_code", "00000009", DbType.String), + parameter => AssertParameter(parameter, "program_title", "O'Brien 오전", DbType.String)); + Assert.Empty(query.Calls); + } + + [Fact] + public async Task ReplaceItemsAsync_AtomicallyDeletesThenInsertsInVisibleOrder() + { + var mutations = new RecordingMutationExecutor(call => + Result(call, [7, 1, 1])); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => throw new InvalidOperationException()), + mutations); + var items = new[] + { + Item(0, true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930", 1, 1), + Item(1, false, "THEME", "반도체", "5단 표그래프", "상승률순", "00000012", 2, 3) + }; + + await service.ReplaceItemsAsync("00000011", items); + + var transaction = Assert.Single(mutations.Calls).Transaction; + Assert.Equal(NamedPlaylistMutationKind.ReplaceItems, transaction.Kind); + Assert.Collection( + transaction.Commands, + command => + { + Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind); + Assert.Equal(LegacyNamedPlaylistPersistenceService.DeleteItemsSql, command.Sql); + AssertParameter(Assert.Single(command.Parameters), "program_code", "00000011", DbType.String); + }, + command => AssertInsertItem( + command, + "1^KOSPI^삼성전자^1열판기본^현재가^1/1^005930", + 0), + command => AssertInsertItem( + command, + "0^THEME^반도체^5단 표그래프^상승률순^2/3^00000012", + 1)); + } + + [Fact] + public async Task ReplaceItemsAsync_AllowsAnEmptyAtomicReplacement() + { + var mutations = new RecordingMutationExecutor(call => Result(call, [3])); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => throw new InvalidOperationException()), + mutations); + + await service.ReplaceItemsAsync("00000001", []); + + var command = Assert.Single(Assert.Single(mutations.Calls).Transaction.Commands); + Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind); + } + + [Fact] + public async Task ReplaceItemsAsync_RejectsGapsAndCaretInjectionBeforeMutation() + { + var mutations = new RecordingMutationExecutor(SuccessResult); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => throw new InvalidOperationException()), + mutations); + + await Assert.ThrowsAsync(() => service.ReplaceItemsAsync( + "00000001", + [Item(1, true, "KOSPI", "Alpha", "Plate", "Current", "", 1, 1)])); + await Assert.ThrowsAsync(() => service.ReplaceItemsAsync( + "00000001", + [Item(0, true, "KOSPI", "Alpha^Beta", "Plate", "Current", "", 1, 1)])); + + Assert.Empty(mutations.Calls); + } + + [Fact] + public async Task DeleteAsync_DeletesChildrenThenDefinitionInOneTransaction() + { + var mutations = new RecordingMutationExecutor(call => Result(call, [4, 1])); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => throw new InvalidOperationException()), + mutations); + + await service.DeleteAsync("00000012"); + + var transaction = Assert.Single(mutations.Calls).Transaction; + Assert.Equal(NamedPlaylistMutationKind.DeletePlaylist, transaction.Kind); + Assert.Collection( + transaction.Commands, + command => Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind), + command => Assert.Equal(NamedPlaylistDbCommandKind.DeleteDefinition, command.Kind)); + } + + [Fact] + public async Task Mutations_RequireConfiguredWriterAndHonorPreCancellation() + { + var readOnly = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => ListTable())); + Assert.False(readOnly.CanMutate); + await Assert.ThrowsAsync( + () => readOnly.DeleteAsync("00000001")); + + var mutations = new RecordingMutationExecutor(SuccessResult); + var writable = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => ListTable()), + mutations); + Assert.True(writable.CanMutate); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => writable.DeleteAsync("00000001", cancellation.Token)); + Assert.Empty(mutations.Calls); + } + + [Fact] + public async Task MutationExecutorUnknownFailure_IsMarkedOutcomeUnknownWithoutRetry() + { + var mutations = new RecordingMutationExecutor(_ => + throw new TimeoutException("simulated timeout")); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => ListTable()), + mutations); + + var exception = await Assert.ThrowsAsync( + () => service.DeleteAsync("00000001")); + + Assert.True(exception.OutcomeUnknown); + Assert.Single(mutations.Calls); + Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task MutationExecutorKnownRollback_IsPreserved() + { + var expected = new NamedPlaylistMutationException( + "Unique constraint; rolled back.", + outcomeUnknown: false); + var mutations = new RecordingMutationExecutor(_ => throw expected); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => ListTable()), + mutations); + + var actual = await Assert.ThrowsAsync( + () => service.CreateDefinitionAsync("00000001", "Duplicate")); + + Assert.Same(expected, actual); + Assert.False(actual.OutcomeUnknown); + Assert.Single(mutations.Calls); + } + + [Fact] + public async Task AmbiguousMutationResult_IsMarkedOutcomeUnknown() + { + var mutations = new RecordingMutationExecutor(call => Result(call, [0])); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => ListTable()), + mutations); + + var exception = await Assert.ThrowsAsync( + () => service.CreateDefinitionAsync("00000001", "No row")); + + Assert.True(exception.OutcomeUnknown); + } + + [Fact] + public async Task DeleteMissingDefinition_ReportsNotFoundAfterKnownCommit() + { + var mutations = new RecordingMutationExecutor(call => Result(call, [0, 0])); + var service = new LegacyNamedPlaylistPersistenceService( + new RecordingQueryExecutor(_ => ListTable()), + mutations); + + await Assert.ThrowsAsync( + () => service.DeleteAsync("00000001")); + } + + private static NamedPlaylistStoredItem Item( + int index, + bool enabled, + string groupCode, + string subject, + string graphicType, + string subtype, + string dataCode, + int currentPage, + int totalPages) => + new( + index, + enabled, + new LegacySceneSelection(groupCode, subject, graphicType, subtype, dataCode), + new NamedPlaylistPageState(currentPage, totalPages)); + + private static void AssertInsertItem( + NamedPlaylistDbCommand command, + string expectedListText, + int expectedIndex) + { + Assert.Equal(NamedPlaylistDbCommandKind.InsertItem, command.Kind); + Assert.Equal(LegacyNamedPlaylistPersistenceService.InsertItemSql, command.Sql); + Assert.Equal(11, command.Parameters.Count); + AssertParameter(command.Parameters[0], "program_code", "00000011", DbType.String); + AssertParameter(command.Parameters[1], "list_text", expectedListText, DbType.String); + foreach (var parameter in command.Parameters.Skip(2).Take(8)) + { + Assert.Null(parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + } + + AssertParameter(command.Parameters[10], "item_index", expectedIndex, DbType.Int32); + } + + private static void AssertParameter( + NamedPlaylistDbParameter parameter, + string name, + object? value, + DbType dbType) + { + Assert.Equal(name, parameter.Name); + Assert.Equal(value, parameter.Value); + Assert.Equal(dbType, parameter.DbType); + } + + private static DataTable ListTable(params (string Code, string Title)[] rows) + { + var table = new DataTable(); + table.Columns.Add("DC_CODE", typeof(string)); + table.Columns.Add("DC_TITLE", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.Code, row.Title); + } + + return table; + } + + private static DataTable NextCodeTable(string code) + { + var table = new DataTable(); + table.Columns.Add("DC_CODE", typeof(string)); + table.Rows.Add(code); + return table; + } + + private static DataTable LoadTable( + params (string Code, string Title, string ListText, string ItemIndex)[] rows) + { + var table = new DataTable(); + table.Columns.Add("DC_CODE", typeof(string)); + table.Columns.Add("DC_TITLE", typeof(string)); + table.Columns.Add("LIST_TEXT", typeof(string)); + table.Columns.Add("ITEM_INDEX", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.Code, row.Title, row.ListText, row.ItemIndex); + } + + return table; + } + + private static NamedPlaylistDbTransactionResult SuccessResult(MutationCall call) => + Result( + call, + call.Transaction.Commands.Select(static command => + command.Kind is NamedPlaylistDbCommandKind.DeleteItems ? 0 : 1).ToArray()); + + private static NamedPlaylistDbTransactionResult Result( + MutationCall call, + IReadOnlyList affectedRows) => + new(call.Transaction.OperationId, affectedRows, DateTimeOffset.UtcNow); + + private sealed record QueryCall( + DataSourceKind Source, + string QueryName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingQueryExecutor(Func handler) + : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + var call = new QueryCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } + + private sealed record MutationCall( + DataSourceKind Source, + NamedPlaylistDbTransaction Transaction, + CancellationToken CancellationToken); + + private sealed class RecordingMutationExecutor( + Func handler) + : INamedPlaylistMutationExecutor + { + public List Calls { get; } = []; + + public Task ExecuteTransactionAsync( + DataSourceKind source, + NamedPlaylistDbTransaction transaction, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var call = new MutationCall(source, transaction, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call)); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOperatorCatalogSchemaValidationServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOperatorCatalogSchemaValidationServiceTests.cs new file mode 100644 index 0000000..4d6cede --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOperatorCatalogSchemaValidationServiceTests.cs @@ -0,0 +1,101 @@ +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyOperatorCatalogSchemaValidationServiceTests +{ + [Fact] + public async Task ValidateAsync_ExecutesOnlyZeroRowOracleAndMariaSchemaQueries() + { + var query = new CatalogRecordingQueryExecutor(call => call.Source switch + { + DataSourceKind.Oracle => OperatorCatalogTestTables.Empty( + "THEME_CODE", + "THEME_TITLE", + "THEME_MARKET", + "ITEM_THEME_CODE", + "ITEM_CODE", + "ITEM_NAME", + "ITEM_INDEX", + "EXPERT_CODE", + "EXPERT_NAME", + "RECOMMEND_EXPERT_CODE", + "STOCK_CODE", + "STOCK_NAME", + "BUY_AMOUNT", + "PLAY_INDEX"), + DataSourceKind.MariaDb => OperatorCatalogTestTables.Empty( + "THEME_CODE", + "THEME_TITLE", + "THEME_MARKET", + "ITEM_THEME_CODE", + "ITEM_CODE", + "ITEM_NAME", + "ITEM_INDEX"), + _ => throw new InvalidOperationException() + }); + var service = new LegacyOperatorCatalogSchemaValidationService(query); + using var cancellation = new CancellationTokenSource(); + + var result = await service.ValidateAsync(cancellation.Token); + + Assert.Equal( + [DataSourceKind.Oracle, DataSourceKind.MariaDb], + result.ValidatedSources); + Assert.Collection( + query.Calls, + call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal( + LegacyOperatorCatalogSchemaValidationService.OracleQueryName, + call.TableName); + Assert.Contains("EXPERT_LIST", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("RECOMMEND_LIST", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("WHERE 1 = 0", call.Spec.Sql, StringComparison.Ordinal); + Assert.Empty(call.Spec.Parameters); + Assert.Equal(cancellation.Token, call.CancellationToken); + }, + call => + { + Assert.Equal(DataSourceKind.MariaDb, call.Source); + Assert.Equal( + LegacyOperatorCatalogSchemaValidationService.MariaDbQueryName, + call.TableName); + Assert.Contains("SB_LIST", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("SB_ITEM", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("WHERE 1 = 0", call.Spec.Sql, StringComparison.Ordinal); + Assert.Empty(call.Spec.Parameters); + Assert.Equal(cancellation.Token, call.CancellationToken); + }); + } + + [Fact] + public async Task ValidateAsync_FailsClosedOnUnexpectedSchemaAndStopsBeforeSecondSource() + { + var query = new CatalogRecordingQueryExecutor(_ => + OperatorCatalogTestTables.Empty("WRONG_COLUMN")); + var service = new LegacyOperatorCatalogSchemaValidationService(query); + + var exception = await Assert.ThrowsAsync(() => + service.ValidateAsync()); + + Assert.Equal(DataSourceKind.Oracle, exception.DataSource); + Assert.Single(query.Calls); + } + + [Fact] + public async Task ValidateAsync_PropagatesCancellationWithoutAdditionalQuery() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var query = new CatalogRecordingQueryExecutor(_ => + throw new InvalidOperationException("No query expected.")); + var service = new LegacyOperatorCatalogSchemaValidationService(query); + + await Assert.ThrowsAnyAsync(() => + service.ValidateAsync(cancellation.Token)); + + Assert.Empty(query.Calls); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs new file mode 100644 index 0000000..19785c3 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs @@ -0,0 +1,312 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyOverseasIndustryIndexSearchServiceTests +{ + [Fact] + public async Task SearchAsync_UsesBoundUsIndexMasterQueryAndMapsAllLoaderKeys() + { + var executor = new RecordingExecutor(_ => ResultTable( + ("Philadelphia Semiconductor", "US_SEMICONDUCTOR", "SOX"), + ("Dow Transportation", "US_DOW_TRANSPORT", "DJT"))); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.SearchAsync( + " semiconductor ", + 25, + cancellation.Token); + + Assert.Equal("semiconductor", result.Query); + Assert.False(result.IsTruncated); + Assert.Collection( + result.Items, + item => Assert.Equal( + new OverseasIndustryIndexItem( + "Philadelphia Semiconductor", + "US_SEMICONDUCTOR", + "SOX"), + item), + item => Assert.Equal( + new OverseasIndustryIndexItem( + "Dow Transportation", + "US_DOW_TRANSPORT", + "DJT"), + item)); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("OVERSEAS_INDUSTRY_INDEX_SEARCH", call.TableName); + Assert.Equal(cancellation.Token, call.CancellationToken); + call.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains("FROM T_WORLD_IX_EQ_MASTER", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("F_FDTC = '0'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("F_NATC = 'US'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains( + "COUNT(*) OVER (PARTITION BY F_KNAM)", + call.Spec.Sql, + StringComparison.Ordinal); + Assert.Contains("KNAM_COUNT = 1", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("INPUT_NAME_COUNT = 1", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("SYMBOL_COUNT = 1", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("UPPER(F_KNAM)", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("semiconductor", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Collection( + call.Spec.Parameters, + parameter => + { + Assert.Equal("search_term", parameter.Name); + Assert.Equal("SEMICONDUCTOR", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }, + parameter => + { + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(26, parameter.Value); + Assert.Equal(DbType.Int32, parameter.DbType); + }); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task SearchAsync_AllowsOriginalUc5BlankInitialList(string query) + { + var executor = new RecordingExecutor(_ => ResultTable()); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + var result = await service.SearchAsync(query); + + Assert.Equal(string.Empty, result.Query); + var call = Assert.Single(executor.Calls); + Assert.Equal(string.Empty, call.Spec.Parameters[0].Value); + } + + [Fact] + public async Task SearchAsync_EscapesLikeMetacharactersOnlyInsideBoundValue() + { + var executor = new RecordingExecutor(_ => ResultTable()); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + await service.SearchAsync(" a!_%b "); + + var call = Assert.Single(executor.Calls); + Assert.Equal("A!!!_!%B", call.Spec.Parameters[0].Value); + Assert.DoesNotContain("A!!!_!%B", call.Spec.Sql, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_ReturnsBoundedRowsAndReportsTruncation() + { + var executor = new RecordingExecutor(_ => ResultTable( + ("Alpha", "ALPHA", "A"), + ("Beta", "BETA", "B"), + ("Gamma", "GAMMA", "C"))); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + var result = await service.SearchAsync("", maximumResults: 2); + + Assert.True(result.IsTruncated); + Assert.Equal(2, result.Items.Count); + Assert.Equal(3, executor.Calls[0].Spec.Parameters[1].Value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(501)] + [InlineData(int.MaxValue)] + public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults) + { + var executor = new RecordingExecutor(_ => ResultTable()); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("industry", maximumResults)); + + Assert.Equal("maximumResults", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("bad\u0000value")] + [InlineData("\nindustry")] + [InlineData("bad\u200Bvalue")] + [InlineData("bad\uD800value")] + public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query) + { + var executor = new RecordingExecutor(_ => ResultTable()); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync(query)); + + Assert.Equal("query", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess() + { + var executor = new RecordingExecutor(_ => ResultTable()); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + await Assert.ThrowsAsync(() => service.SearchAsync(null!)); + await Assert.ThrowsAsync( + () => service.SearchAsync( + new string( + 'A', + LegacyOverseasIndustryIndexSearchService.MaximumQueryLength + 1))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsAnythingOtherThanExactOrderedStringSchema() + { + var wrongCase = ResultTable(); + wrongCase.Columns[0].ColumnName = "f_knam"; + var executor = new RecordingExecutor(_ => wrongCase); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("")); + + Assert.Contains("schema", exception.Message, StringComparison.Ordinal); + Assert.Single(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsProviderRowsBeyondBoundQueryContract() + { + var executor = new RecordingExecutor(_ => ResultTable( + ("One", "ONE", "1"), + ("Two", "TWO", "2"), + ("Three", "THREE", "3"))); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("", maximumResults: 1)); + } + + [Theory] + [InlineData(" Leading", "INPUT", "SYM")] + [InlineData("Name", "INPUT ", "SYM")] + [InlineData("Name", "INPUT", " SYM")] + [InlineData("Name\nBad", "INPUT", "SYM")] + [InlineData("Name", "IN\u0000PUT", "SYM")] + [InlineData("Name", "INPUT", "SY\u200BM")] + public async Task SearchAsync_RejectsNonCanonicalDatabaseValues( + string koreanName, + string inputName, + string symbol) + { + var executor = new RecordingExecutor(_ => ResultTable( + (koreanName, inputName, symbol))); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("")); + } + + [Theory] + [InlineData("Shared", "ONE", "1", "Shared", "TWO", "2")] + [InlineData("One", "SHARED", "1", "Two", "SHARED", "2")] + [InlineData("One", "ONE", "SHARED", "Two", "TWO", "SHARED")] + public async Task SearchAsync_RejectsDuplicateAnyDownstreamLookupKey( + string koreanName1, + string inputName1, + string symbol1, + string koreanName2, + string inputName2, + string symbol2) + { + var executor = new RecordingExecutor(_ => ResultTable( + (koreanName1, inputName1, symbol1), + (koreanName2, inputName2, symbol2))); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("")); + + Assert.Contains("duplicate downstream lookup key", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => ResultTable()); + var service = new LegacyOverseasIndustryIndexSearchService(executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.SearchAsync("", cancellationToken: cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + private static DataTable ResultTable( + params (string KoreanName, string InputName, string Symbol)[] rows) + { + var table = new DataTable(); + table.Columns.Add("F_KNAM", typeof(string)); + table.Columns.Add("F_INPUT_NAME", typeof(string)); + table.Columns.Add("F_SYMB", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.KoreanName, row.InputName, row.Symbol); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs index 5ba19b6..959a18e 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs @@ -46,6 +46,56 @@ public sealed class LegacyParameterizedSceneRequestResolverTests Assert.Equal(LegacyMarketQuoteTarget.Kosdaq, request.PrimaryTarget); } + [Theory] + [InlineData( + "코스피 지수,삼성전자(NXT)", + 1, + 0, + LegacyDomesticEquityMarket.NxtKospi, + S8018PairSide.Second, + LegacyMarketQuoteTarget.Kospi)] + [InlineData( + "에이비엘바이오(NXT),원달러 환율", + 0, + 1, + LegacyDomesticEquityMarket.NxtKosdaq, + S8018PairSide.First, + LegacyMarketQuoteTarget.WonDollar)] + public async Task Current_mixed_market_and_nxt_stock_preserves_the_original_nxt_branch( + string subject, + int nxtKospiMatches, + int nxtKosdaqMatches, + LegacyDomesticEquityMarket expectedStockMarket, + S8018PairSide expectedStockSide, + LegacyMarketQuoteTarget expectedMarketTarget) + { + var executor = new RecordingExecutor( + Count(nxtKospiMatches), + Count(nxtKosdaqMatches)); + var resolver = new LegacyParameterizedSceneRequestResolver(executor); + + var result = await resolver.ResolveTwoColumnRequestAsync(Entry( + "8018", "종목", subject, "현재가")); + + var routed = Assert.IsType(result); + var request = Assert.IsType(routed.Request); + Assert.Equal(expectedStockSide, request.StockSide); + Assert.Equal(expectedMarketTarget, request.MarketTarget); + Assert.Equal(expectedStockMarket, request.StockMarket); + Assert.Equal(S8018MarketDataMode.Current, request.Mode); + Assert.Equal( + ["SCENE_RESOLVE_NXT_KOSPI_STOCK", "SCENE_RESOLVE_NXT_KOSDAQ_STOCK"], + executor.Calls.Select(call => call.TableName)); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.MariaDb, call.Source); + var parameter = Assert.Single(call.Spec.Parameters); + var value = Assert.IsType(parameter.Value); + Assert.DoesNotContain("(NXT)", value, StringComparison.Ordinal); + Assert.DoesNotContain(value, call.Spec.Sql, StringComparison.Ordinal); + }); + } + [Fact] public async Task Current_stock_pair_uses_original_master_table_precedence_and_session_boundary() { @@ -96,6 +146,81 @@ public sealed class LegacyParameterizedSceneRequestResolverTests Assert.Empty(executor.Calls); } + [Fact] + public async Task Expected_mixed_nxt_pair_fails_before_any_database_lookup() + { + var executor = new RecordingExecutor(); + var resolver = new LegacyParameterizedSceneRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveTwoColumnRequestAsync(Entry( + "8018", "지수", "코스피 지수,A(NXT)", "예상체결"))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task Current_world_stock_identity_uses_the_operator_input_name_consistently() + { + var executor = new RecordingExecutor( + Count(0), Count(0), Count(1), + Count(1), Count(0)); + var resolver = new LegacyParameterizedSceneRequestResolver(executor); + + var result = await resolver.ResolveTwoColumnRequestAsync(Entry( + "8018", "종목", "NVIDIA,삼성전자", "현재가")); + + var routed = Assert.IsType(result); + var request = Assert.IsType(routed.Request); + Assert.Equal(S8018StockMarket.World, request.First.Market); + Assert.Equal("NVIDIA", request.First.StockName); + Assert.Equal(S8018StockMarket.Kospi, request.Second.Market); + + var worldCall = Assert.Single( + executor.Calls, + call => call.TableName == "SCENE_RESOLVE_WORLD_STOCK"); + Assert.Equal(DataSourceKind.Oracle, worldCall.Source); + Assert.Contains("F_INPUT_NAME = :stockName", worldCall.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("F_FDTC = '1'", worldCall.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "F_NATC IN ('US', 'TW')", + worldCall.Spec.Sql, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("F_KNAM", worldCall.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Equal("NVIDIA", Assert.Single(worldCall.Spec.Parameters).Value); + } + + [Fact] + public async Task Mixed_market_and_world_stock_remains_closed_without_a_generalized_mixed_dto() + { + var executor = new RecordingExecutor(Count(0), Count(0)); + var resolver = new LegacyParameterizedSceneRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveTwoColumnRequestAsync(Entry( + "8018", "지수", "코스피 지수,NVIDIA", "현재가"))); + + Assert.Equal( + ["SCENE_RESOLVE_KOSPI_STOCK", "SCENE_RESOLVE_KOSDAQ_STOCK"], + executor.Calls.Select(call => call.TableName)); + Assert.DoesNotContain( + executor.Calls, + call => call.TableName == "SCENE_RESOLVE_WORLD_STOCK"); + } + + [Fact] + public async Task Futures_and_domestic_stock_remains_closed_before_database_lookup() + { + var executor = new RecordingExecutor(); + var resolver = new LegacyParameterizedSceneRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveTwoColumnRequestAsync(Entry( + "5032", "종목", "선물,삼성전자", "현재가"))); + + Assert.Empty(executor.Calls); + } + [Fact] public void S8001_accepts_only_the_two_original_markets() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs index 1f7412c..99cfa11 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs @@ -425,12 +425,65 @@ public sealed class LegacyPlayoutWorkflowTests Assert.Equal("one", workflow.State.CurrentEntryId); } + [Fact] + public async Task PreflightPagePlans_UsesNoEngineCommandAndReturnsEmptyAndCappedPlans() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5074"] = (101, ScenePageSize.Five), + ["5077"] = (0, ScenePageSize.Six), + ["5088"] = (241, ScenePageSize.Twelve) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + + var plans = await workflow.PreflightPagePlansAsync( + [ + new("five", "5074"), + new("six", "5077", IsEnabled: false), + new("twelve", "5088") + ]); + + Assert.Empty(engine.Calls); + Assert.Empty(provider.Calls); + Assert.Equal(["5074", "5077", "5088"], provider.PlanCalls); + Assert.Equal( + new LegacyPlaylistPagePlan("five", 101, 5, 20, 100, true, "s5074"), + plans[0]); + Assert.Equal( + new LegacyPlaylistPagePlan("six", 0, 6, 0, 0, false, "s5077"), + plans[1]); + Assert.Equal( + new LegacyPlaylistPagePlan("twelve", 241, 12, 20, 240, true, "s5088"), + plans[2]); + Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased); + } + + [Fact] + public async Task PreflightPagePlans_IsRejectedAfterPrepareWithoutQueryingAnotherDto() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5074"] = (5, ScenePageSize.Five) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess); + + await Assert.ThrowsAsync(() => + workflow.PreflightPagePlansAsync([new("other", "5074")])); + + Assert.Empty(provider.PlanCalls); + } + private sealed class RecordingProvider( IReadOnlyDictionary definitions) - : ILegacySceneCueProvider + : ILegacySceneCueProvider, ILegacyScenePagePlanProvider { public List Calls { get; } = []; + public List PlanCalls { get; } = []; + public Func? SceneNameOverride { get; init; } public string? BuilderKey { get; init; } @@ -456,6 +509,26 @@ public sealed class LegacyPlayoutWorkflowTests BuilderKey, PreviewFields)); } + + public Task CreatePagePlanAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + PlanCalls.Add(entry.CutCode); + var definition = definitions[entry.CutCode]; + var pageSize = definition.Size ?? throw new LegacySceneDataException( + "The test entry is not paged."); + var result = ScenePaging.CreatePlan(definition.Count, pageSize); + var plan = Assert.IsType(result.Plan); + return Task.FromResult(new LegacyScenePagePlan( + "s" + entry.CutCode, + plan.ItemCount, + plan.PageSize, + plan.TotalPages, + plan.AccessibleItemCount, + plan.ExcludedItemCount > 0)); + } } private sealed class RecordingEngine : IPlayoutEngine diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs new file mode 100644 index 0000000..0035a08 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs @@ -0,0 +1,259 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyStockSearchServiceTests +{ + [Fact] + public async Task SearchAsync_UsesFourParameterizedProviderQueriesInLegacyMarketOrder() + { + var executor = new RecordingExecutor(_ => StockTable()); + var service = new LegacyStockSearchService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.SearchAsync(" 삼성 ", 25, cancellation.Token); + + Assert.Equal("삼성", result.Query); + Assert.False(result.IsTruncated); + Assert.Empty(result.Items); + Assert.Equal( + [ + "STOCK_SEARCH_KOSPI", + "STOCK_SEARCH_KOSDAQ", + "STOCK_SEARCH_NXT_KOSPI", + "STOCK_SEARCH_NXT_KOSDAQ" + ], + executor.Calls.Select(static call => call.TableName)); + Assert.Equal( + [ + DataSourceKind.Oracle, + DataSourceKind.Oracle, + DataSourceKind.MariaDb, + DataSourceKind.MariaDb + ], + executor.Calls.Select(static call => call.Source)); + + Assert.All(executor.Calls, call => + { + call.Spec.ValidateFor(call.Source); + Assert.Equal(cancellation.Token, call.CancellationToken); + Assert.Equal(2, call.Spec.Parameters.Count); + Assert.Equal("search_term", call.Spec.Parameters[0].Name); + Assert.Equal("삼성", call.Spec.Parameters[0].Value); + Assert.Equal(DbType.String, call.Spec.Parameters[0].DbType); + Assert.Equal("row_limit", call.Spec.Parameters[1].Name); + Assert.Equal(26, call.Spec.Parameters[1].Value); + Assert.Equal(DbType.Int32, call.Spec.Parameters[1].DbType); + Assert.Contains("ORDER BY", call.Spec.Sql, StringComparison.Ordinal); + }); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task SearchAsync_EscapesLikeMetacharactersInsideBoundValue() + { + var executor = new RecordingExecutor(_ => StockTable()); + var service = new LegacyStockSearchService(executor); + + await service.SearchAsync(" 50%!_ "); + + Assert.All( + executor.Calls, + call => Assert.Equal("50!%!!!_", call.Spec.Parameters[0].Value)); + Assert.All( + executor.Calls, + call => Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal)); + } + + [Fact] + public async Task SearchAsync_ValidatesSchemaAndReturnsDeterministicCappedOrder() + { + var tables = new Queue( + [ + StockTable(("가나다", "000002"), ("가나다", "000001")), + StockTable(("코스닥", "100001")), + StockTable(("NXT가(NXT)", "000002")), + StockTable(("NXT나(NXT)", "100001")) + ]); + var executor = new RecordingExecutor(_ => tables.Dequeue()); + var service = new LegacyStockSearchService(executor); + + var result = await service.SearchAsync("가", maximumResults: 3); + + Assert.True(result.IsTruncated); + Assert.Collection( + result.Items, + item => Assert.Equal( + new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "가나다", "000001"), + item), + item => Assert.Equal( + new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "가나다", "000002"), + item), + item => Assert.Equal( + new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "코스닥", "100001"), + item)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(501)] + [InlineData(int.MaxValue)] + public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults) + { + var executor = new RecordingExecutor(_ => StockTable()); + var service = new LegacyStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("삼성", maximumResults)); + + Assert.Equal("maximumResults", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("bad\u0000value")] + [InlineData("\n삼성")] + [InlineData("bad\u200Bvalue")] + [InlineData("bad\uD800value")] + public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query) + { + var executor = new RecordingExecutor(_ => StockTable()); + var service = new LegacyStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync(query)); + + Assert.Equal("query", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsOverlongQueryBeforeDatabaseAccess() + { + var executor = new RecordingExecutor(_ => StockTable()); + var service = new LegacyStockSearchService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync(new string('A', LegacyStockSearchService.MaximumQueryLength + 1))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsNullQueryBeforeDatabaseAccess() + { + var executor = new RecordingExecutor(_ => StockTable()); + var service = new LegacyStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync(null!)); + + Assert.Equal("query", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsAnythingOtherThanExactStringSchema() + { + var wrongCase = new DataTable(); + wrongCase.Columns.Add("stock_name", typeof(string)); + wrongCase.Columns.Add("STOCK_CODE", typeof(string)); + var executor = new RecordingExecutor(_ => wrongCase); + var service = new LegacyStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("삼성")); + + Assert.Contains("STOCK_SEARCH_KOSPI", exception.Message, StringComparison.Ordinal); + Assert.Single(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsNullOrControlCharacterDatabaseValues() + { + var malformed = StockTable(); + malformed.Rows.Add("삼성\n전자", "005930"); + var executor = new RecordingExecutor(_ => malformed); + var service = new LegacyStockSearchService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("삼성")); + + Assert.Single(executor.Calls); + } + + [Fact] + public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => StockTable()); + var service = new LegacyStockSearchService(executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.SearchAsync("삼성", cancellationToken: cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + private static DataTable StockTable(params (string Name, string Code)[] rows) + { + var table = new DataTable(); + table.Columns.Add("STOCK_NAME", typeof(string)); + table.Columns.Add("STOCK_CODE", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.Name, row.Code); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeCatalogPersistenceServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeCatalogPersistenceServiceTests.cs new file mode 100644 index 0000000..ed8598f --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeCatalogPersistenceServiceTests.cs @@ -0,0 +1,293 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyThemeCatalogPersistenceServiceTests +{ + [Fact] + public async Task SuggestNextCodeAsync_UsesReadOnlyProviderSpecificQueries() + { + var query = new CatalogRecordingQueryExecutor(call => call.TableName switch + { + LegacyThemeCatalogPersistenceService.NextKrxCodeQueryName => + OperatorCatalogTestTables.SingleString("THEME_CODE", "00000042"), + LegacyThemeCatalogPersistenceService.NextNxtCodeQueryName => + OperatorCatalogTestTables.SingleString("THEME_CODE", "00000009"), + _ => throw new InvalidOperationException(call.TableName) + }); + var service = new LegacyThemeCatalogPersistenceService(query); + + Assert.Equal("00000042", await service.SuggestNextCodeAsync(ThemeMarket.Krx)); + Assert.Equal("00000009", await service.SuggestNextCodeAsync(ThemeMarket.Nxt)); + + Assert.False(service.CanMutate); + Assert.Collection( + query.Calls, + call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Contains("TO_NUMBER(TRIM(SB_CODE))", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM SB_LIST", call.Spec.Sql, StringComparison.Ordinal); + Assert.Empty(call.Spec.Parameters); + }, + call => + { + Assert.Equal(DataSourceKind.MariaDb, call.Source); + Assert.Contains("CAST(TRIM(SB_CODE) AS UNSIGNED)", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("REGEXP '^[0-9]{8}$'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Empty(call.Spec.Parameters); + }); + } + + [Fact] + public async Task SuggestNextCodeAsync_RejectsExhaustedCodeRange() + { + var service = new LegacyThemeCatalogPersistenceService( + new CatalogRecordingQueryExecutor(_ => + OperatorCatalogTestTables.SingleString("THEME_CODE", "EXHAUSTED"))); + + await Assert.ThrowsAsync(() => + service.SuggestNextCodeAsync(ThemeMarket.Krx)); + } + + [Fact] + public async Task CreateAsync_KrxBindsParentThenOrderedChildrenInOneTransaction() + { + var query = UnusedQuery(); + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyThemeCatalogPersistenceService(query, mutation); + using var cancellation = new CancellationTokenSource(); + var definition = new ThemeCatalogDefinition( + ThemeMarket.Krx, + "00000042", + "AI'테마", + [ + new ThemeCatalogItem(0, "P005930", "삼성전자"), + new ThemeCatalogItem(1, "D035720", "카카오") + ]); + + var receipt = await service.CreateAsync(definition, cancellation.Token); + + Assert.True(service.CanMutate); + Assert.Equal(OperatorCatalogMutationKind.CreateTheme, receipt.Kind); + var call = Assert.Single(mutation.Calls); + Assert.Equal(cancellation.Token, call.Token); + var transaction = call.Transaction; + Assert.Equal(DataSourceKind.Oracle, transaction.Source); + Assert.Equal("00000042", transaction.IdentityCode); + Assert.NotEqual(Guid.Empty, transaction.OperationId); + Assert.Equal( + [ + OperatorCatalogDbCommandKind.InsertTheme, + OperatorCatalogDbCommandKind.InsertThemeItem, + OperatorCatalogDbCommandKind.InsertThemeItem + ], + transaction.Commands.Select(static command => command.Kind)); + Assert.All(transaction.Commands, command => + Assert.DoesNotContain("AI'테마", command.Sql, StringComparison.Ordinal)); + + var parent = transaction.Commands[0]; + Assert.Contains("NOT EXISTS", parent.Sql, StringComparison.Ordinal); + Assert.Equal(OperatorCatalogAffectedRowsRule.ExactlyOne, parent.AffectedRowsRule); + Assert.Equal("AI'테마", Parameter(parent, "theme_title").Value); + Assert.Equal("AI'테마", Parameter(parent, "conflict_title").Value); + Assert.Equal(DbType.String, Parameter(parent, "theme_title").DbType); + + Assert.Equal("P005930", Parameter(transaction.Commands[1], "item_code").Value); + Assert.Equal(0, Parameter(transaction.Commands[1], "item_index").Value); + Assert.Equal("D035720", Parameter(transaction.Commands[2], "item_code").Value); + Assert.Equal(1, Parameter(transaction.Commands[2], "item_index").Value); + } + + [Fact] + public async Task CreateAsync_NxtUsesMariaMarkerAndRawStoredTitle() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation); + var definition = new ThemeCatalogDefinition( + ThemeMarket.Nxt, + "00000007", + "로봇", + [new ThemeCatalogItem(0, "X005930", "삼성전자")]); + + await service.CreateAsync(definition); + + var transaction = Assert.Single(mutation.Calls).Transaction; + Assert.Equal(DataSourceKind.MariaDb, transaction.Source); + Assert.All(transaction.Commands, command => + { + Assert.DoesNotContain(':', command.Sql); + Assert.All(command.Parameters, parameter => + Assert.Contains($"@{parameter.Name}", command.Sql, StringComparison.OrdinalIgnoreCase)); + }); + Assert.Contains("'NXT'", transaction.Commands[0].Sql, StringComparison.Ordinal); + + await Assert.ThrowsAsync(() => service.CreateAsync( + definition with { ThemeTitle = "로봇(NXT)" })); + Assert.Single(mutation.Calls); + } + + [Fact] + public async Task ReplaceAsync_ChecksCodeAndOriginalTitleBeforeReplacingChildren() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation); + var identity = new ThemeSelectionIdentity( + ThemeMarket.Krx, + ThemeSession.NotApplicable, + "00000042", + "기존 테마"); + + await service.ReplaceAsync( + identity, + "새 테마", + [new ThemeCatalogItem(0, "P005930", "삼성전자")]); + + var transaction = Assert.Single(mutation.Calls).Transaction; + Assert.Equal(OperatorCatalogMutationKind.ReplaceTheme, transaction.Kind); + Assert.Equal( + [ + OperatorCatalogDbCommandKind.UpdateTheme, + OperatorCatalogDbCommandKind.DeleteThemeItems, + OperatorCatalogDbCommandKind.InsertThemeItem + ], + transaction.Commands.Select(static command => command.Kind)); + var update = transaction.Commands[0]; + Assert.Equal("기존 테마", Parameter(update, "expected_title").Value); + Assert.Equal("새 테마", Parameter(update, "new_title").Value); + Assert.Equal("새 테마", Parameter(update, "duplicate_title").Value); + Assert.Contains("NOT EXISTS", update.Sql, StringComparison.Ordinal); + Assert.Equal( + OperatorCatalogAffectedRowsRule.ExactlyOne, + update.AffectedRowsRule); + Assert.Equal( + OperatorCatalogAffectedRowsRule.AnyNonNegative, + transaction.Commands[1].AffectedRowsRule); + } + + [Fact] + public async Task DeleteAsync_DeletesChildrenBeforeIdentityBoundParent() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation); + var identity = new ThemeSelectionIdentity( + ThemeMarket.Nxt, + ThemeSession.AfterMarket, + "00000008", + "반도체"); + + await service.DeleteAsync(identity); + + var transaction = Assert.Single(mutation.Calls).Transaction; + Assert.Equal(DataSourceKind.MariaDb, transaction.Source); + Assert.Equal( + [ + OperatorCatalogDbCommandKind.DeleteThemeItems, + OperatorCatalogDbCommandKind.DeleteTheme + ], + transaction.Commands.Select(static command => command.Kind)); + Assert.Equal("반도체", Parameter(transaction.Commands[1], "expected_title").Value); + Assert.Equal( + OperatorCatalogAffectedRowsRule.ExactlyOne, + transaction.Commands[1].AffectedRowsRule); + } + + [Fact] + public async Task Mutations_RejectDuplicateOrAmbiguousItemsBeforeExecutor() + { + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation); + var baseDefinition = new ThemeCatalogDefinition( + ThemeMarket.Krx, + "00000001", + "테마", + []); + + await Assert.ThrowsAsync(() => service.CreateAsync( + baseDefinition with + { + Items = + [ + new ThemeCatalogItem(0, "P005930", "삼성전자"), + new ThemeCatalogItem(1, "P005930", "다른이름") + ] + })); + await Assert.ThrowsAsync(() => service.CreateAsync( + baseDefinition with + { + Items = + [ + new ThemeCatalogItem(0, "P005930", "삼성전자"), + new ThemeCatalogItem(1, "D035720", "삼성전자") + ] + })); + await Assert.ThrowsAsync(() => service.CreateAsync( + baseDefinition with + { + Items = [new ThemeCatalogItem(1, "P005930", "삼성전자")] + })); + await Assert.ThrowsAsync(() => service.CreateAsync( + baseDefinition with + { + Items = [new ThemeCatalogItem(0, "X005930", "삼성전자")] + })); + + Assert.Empty(mutation.Calls); + } + + [Fact] + public async Task Mutations_FailClosedForReadOnlyCancellationAndUnknownExecutorOutcome() + { + var definition = new ThemeCatalogDefinition( + ThemeMarket.Krx, + "00000001", + "테마", + []); + var readOnly = new LegacyThemeCatalogPersistenceService(UnusedQuery()); + await Assert.ThrowsAsync(() => readOnly.CreateAsync(definition)); + + var mutation = new CatalogRecordingMutationExecutor(); + var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + service.CreateAsync(definition, cancellation.Token)); + Assert.Empty(mutation.Calls); + + mutation.Exception = new TimeoutException("provider detail"); + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(definition)); + Assert.True(exception.OutcomeUnknown); + Assert.Contains("do not retry automatically", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateAsync_TreatsMismatchedCommittedReceiptAsOutcomeUnknown() + { + var mutation = new CatalogRecordingMutationExecutor(transaction => + new OperatorCatalogDbTransactionResult( + Guid.NewGuid(), + [1], + DateTimeOffset.UtcNow)); + var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(new ThemeCatalogDefinition( + ThemeMarket.Krx, + "00000001", + "테마", + []))); + + Assert.True(exception.OutcomeUnknown); + } + + private static CatalogRecordingQueryExecutor UnusedQuery() => + new(_ => throw new InvalidOperationException("No read query was expected.")); + + private static OperatorCatalogDbParameter Parameter( + OperatorCatalogDbCommand command, + string name) => + Assert.Single(command.Parameters, parameter => + string.Equals(parameter.Name, name, StringComparison.Ordinal)); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs new file mode 100644 index 0000000..e569ee0 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs @@ -0,0 +1,566 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyThemeSelectionServiceTests +{ + [Fact] + public async Task SearchAsync_UsesOriginalOracleAndMariaProvidersWithClosedIdentity() + { + var executor = new RecordingExecutor(call => call.TableName switch + { + "THEME_SEARCH_KRX" => SearchTable(("AI", "00000001", "KRX")), + "THEME_SEARCH_NXT" => SearchTable(("Robotics", "00000002", "NXT")), + _ => throw new InvalidOperationException(call.TableName) + }); + var service = new LegacyThemeSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.SearchAsync( + " ai ", + ThemeSession.PreMarket, + 25, + cancellation.Token); + + Assert.Equal("ai", result.Query); + Assert.Equal(ThemeSession.PreMarket, result.NxtSession); + Assert.False(result.IsTruncated); + Assert.Collection( + result.Items, + item => + { + Assert.Equal(DataSourceKind.Oracle, item.Source); + Assert.Equal( + new ThemeSelectionIdentity( + ThemeMarket.Krx, + ThemeSession.NotApplicable, + "00000001", + "AI"), + item.Identity); + }, + item => + { + Assert.Equal(DataSourceKind.MariaDb, item.Source); + Assert.Equal( + new ThemeSelectionIdentity( + ThemeMarket.Nxt, + ThemeSession.PreMarket, + "00000002", + "Robotics"), + item.Identity); + }); + + Assert.Collection( + executor.Calls, + call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("THEME_SEARCH_KRX", call.TableName); + Assert.Contains("SB_MARKET = 'KRX'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal("AI", call.Spec.Parameters[0].Value); + Assert.Equal(26, call.Spec.Parameters[1].Value); + Assert.Equal(cancellation.Token, call.CancellationToken); + }, + call => + { + Assert.Equal(DataSourceKind.MariaDb, call.Source); + Assert.Equal("THEME_SEARCH_NXT", call.TableName); + Assert.Contains("SB_MARKET = 'NXT'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal("AI", call.Spec.Parameters[0].Value); + Assert.Equal(26, call.Spec.Parameters[1].Value); + Assert.Equal(cancellation.Token, call.CancellationToken); + }); + Assert.All(executor.Calls, call => + { + call.Spec.ValidateFor(call.Source); + Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(DbType.String, call.Spec.Parameters[0].DbType); + Assert.Equal(DbType.Int32, call.Spec.Parameters[1].DbType); + }); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task SearchAsync_AllowsOriginalBlankInitialLoadAndEscapesLikeValue() + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyThemeSelectionService(executor); + + await service.SearchAsync(" ", ThemeSession.AfterMarket); + Assert.All(executor.Calls, call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value)); + + executor.Calls.Clear(); + await service.SearchAsync(" a!_% ", ThemeSession.AfterMarket); + Assert.All(executor.Calls, call => Assert.Equal("A!!!_!%", call.Spec.Parameters[0].Value)); + } + + [Fact] + public async Task SearchAsync_AppliesCombinedBoundAndDeterministicMarketOrder() + { + var executor = new RecordingExecutor(call => call.Source switch + { + DataSourceKind.Oracle => SearchTable( + ("Zulu", "00000002", "KRX"), + ("Alpha", "00000001", "KRX")), + DataSourceKind.MariaDb => SearchTable(("Beta", "00000003", "NXT")), + _ => throw new InvalidOperationException() + }); + var service = new LegacyThemeSelectionService(executor); + + var result = await service.SearchAsync("", ThemeSession.PreMarket, 2); + + Assert.True(result.IsTruncated); + Assert.Equal( + ["Alpha", "Zulu"], + result.Items.Select(static item => item.Identity.ThemeTitle)); + Assert.All(executor.Calls, call => Assert.Equal(3, call.Spec.Parameters[1].Value)); + } + + [Theory] + [InlineData(ThemeSession.NotApplicable)] + [InlineData((ThemeSession)99)] + public async Task SearchAsync_RejectsUnsafeNxtSessionBeforeDatabaseAccess(ThemeSession session) + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("AI", session)); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(501)] + public async Task SearchAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit) + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("AI", ThemeSession.PreMarket, limit)); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("bad\u0000value")] + [InlineData("bad\u200Bvalue")] + [InlineData("bad\uD800value")] + public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query) + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync(query, ThemeSession.PreMarket)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess() + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync(null!, ThemeSession.PreMarket)); + await Assert.ThrowsAsync( + () => service.SearchAsync( + new string('A', LegacyThemeSelectionService.MaximumQueryLength + 1), + ThemeSession.PreMarket)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsSchemaMarketAndBoundViolations() + { + var wrongSchema = SearchTable(); + wrongSchema.Columns[0].ColumnName = "theme_title"; + var executor = new RecordingExecutor(_ => wrongSchema); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("", ThemeSession.PreMarket)); + + executor = new RecordingExecutor(_ => SearchTable(("AI", "00000001", "NXT"))); + service = new LegacyThemeSelectionService(executor); + await Assert.ThrowsAsync( + () => service.SearchAsync("", ThemeSession.PreMarket)); + + executor = new RecordingExecutor(_ => SearchTable( + ("A", "00000001", "KRX"), + ("B", "00000002", "KRX"), + ("C", "00000003", "KRX"))); + service = new LegacyThemeSelectionService(executor); + await Assert.ThrowsAsync( + () => service.SearchAsync("", ThemeSession.PreMarket, 1)); + } + + [Theory] + [InlineData("AI", "00000001", "AI", "00000002")] + [InlineData("AI", "00000001", "Robotics", "00000001")] + public async Task SearchAsync_RejectsDuplicateTitleOrCodeWithinMarket( + string title1, + string code1, + string title2, + string code2) + { + var executor = new RecordingExecutor(_ => SearchTable( + (title1, code1, "KRX"), + (title2, code2, "KRX"))); + var service = new LegacyThemeSelectionService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("", ThemeSession.PreMarket)); + + Assert.Contains("duplicate title or code", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => SearchTable()); + var service = new LegacyThemeSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.SearchAsync( + "AI", + ThemeSession.PreMarket, + cancellationToken: cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task PreviewAsync_KrxUsesBoundIdentityAndMapsInputOrder() + { + var identity = new ThemeSelectionIdentity( + ThemeMarket.Krx, + ThemeSession.NotApplicable, + "00000001", + "AI"); + var executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", "Alpha", "P005930", "0"), + ("AI", "00000001", "KRX", "Beta", "D035720", "2"))); + var service = new LegacyThemeSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.PreviewAsync(identity, 12, cancellation.Token); + + Assert.Equal(identity, result.Identity); + Assert.False(result.IsTruncated); + Assert.Equal( + [ + new ThemeItemPreview(0, "P005930", "Alpha"), + new ThemeItemPreview(2, "D035720", "Beta") + ], + result.Items); + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("THEME_PREVIEW_KRX", call.TableName); + Assert.Contains("LEFT JOIN", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("F_MKT_HALT = 'N'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal( + ["theme_code", "theme_title", "row_limit"], + call.Spec.Parameters.Select(static parameter => parameter.Name)); + Assert.Equal("00000001", call.Spec.Parameters[0].Value); + Assert.Equal("AI", call.Spec.Parameters[1].Value); + Assert.Equal(13, call.Spec.Parameters[2].Value); + Assert.Equal(cancellation.Token, call.CancellationToken); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task PreviewAsync_NxtUsesMariaAndPreservesExplicitSession() + { + var identity = new ThemeSelectionIdentity( + ThemeMarket.Nxt, + ThemeSession.AfterMarket, + "00000002", + "Robotics"); + var executor = new RecordingExecutor(_ => PreviewTable( + ("Robotics", "00000002", "NXT", "Gamma", "X005930", "0"))); + var service = new LegacyThemeSelectionService(executor); + + var result = await service.PreviewAsync(identity); + + Assert.Equal(ThemeSession.AfterMarket, result.Identity.Session); + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.MariaDb, call.Source); + Assert.Equal("THEME_PREVIEW_NXT", call.TableName); + Assert.Contains("F_STOP_GUBUN = 'N'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal); + call.Spec.ValidateFor(DataSourceKind.MariaDb); + } + + [Fact] + public async Task PreviewAsync_MapsSingleNullItemSentinelToEmptyTheme() + { + var identity = KrxIdentity(); + var executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", null, null, null))); + var service = new LegacyThemeSelectionService(executor); + + var result = await service.PreviewAsync(identity); + + Assert.Empty(result.Items); + Assert.False(result.IsTruncated); + } + + [Fact] + public async Task PreviewAsync_AppliesMaximumAndReportsTruncation() + { + var identity = KrxIdentity(); + var executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", "Alpha", "P000001", "0"), + ("AI", "00000001", "KRX", "Beta", "P000002", "1"))); + var service = new LegacyThemeSelectionService(executor); + + var result = await service.PreviewAsync(identity, maximumItems: 1); + + Assert.True(result.IsTruncated); + Assert.Single(result.Items); + Assert.Equal(2, executor.Calls[0].Spec.Parameters[2].Value); + } + + public static TheoryData UnsafeIdentities => new() + { + new(ThemeMarket.Krx, ThemeSession.PreMarket, "00000001", "AI"), + new(ThemeMarket.Nxt, ThemeSession.NotApplicable, "00000001", "AI"), + new(ThemeMarket.Nxt, (ThemeSession)99, "00000001", "AI"), + new((ThemeMarket)99, ThemeSession.NotApplicable, "00000001", "AI"), + new(ThemeMarket.Krx, ThemeSession.NotApplicable, "1", "AI"), + new(ThemeMarket.Krx, ThemeSession.NotApplicable, "0000000A", "AI"), + new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", " AI"), + new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "bad\u0000title") + }; + + [Theory] + [MemberData(nameof(UnsafeIdentities))] + public async Task PreviewAsync_RejectsUnsafeIdentityBeforeDatabaseAccess( + ThemeSelectionIdentity identity) + { + var executor = new RecordingExecutor(_ => PreviewTable()); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync(() => service.PreviewAsync(identity)); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(241)] + public async Task PreviewAsync_RejectsInvalidLimitBeforeDatabaseAccess(int limit) + { + var executor = new RecordingExecutor(_ => PreviewTable()); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity(), limit)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task PreviewAsync_RejectsMissingOrMismatchedSelectedIdentity() + { + var executor = new RecordingExecutor(_ => PreviewTable()); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity())); + + executor = new RecordingExecutor(_ => PreviewTable( + ("Other", "00000001", "KRX", "Alpha", "P005930", "0"))); + service = new LegacyThemeSelectionService(executor); + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity())); + } + + [Fact] + public async Task PreviewAsync_RejectsPartialSentinelAndWrongMarketItemCode() + { + var executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", "Alpha", null, null))); + var service = new LegacyThemeSelectionService(executor); + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity())); + + executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", "Alpha", "X005930", "0"))); + service = new LegacyThemeSelectionService(executor); + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity())); + } + + [Theory] + [InlineData("P005930", "0", "P005930", "1")] + [InlineData("P005930", "0", "D035720", "0")] + [InlineData("P005930", "2", "D035720", "1")] + public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingItemIdentity( + string code1, + string index1, + string code2, + string index2) + { + var executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", "Alpha", code1, index1), + ("AI", "00000001", "KRX", "Beta", code2, index2))); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity())); + } + + [Theory] + [InlineData("01")] + [InlineData("-1")] + [InlineData("10000")] + [InlineData("x")] + public async Task PreviewAsync_RejectsNonCanonicalOrOutOfRangeIndex(string index) + { + var executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", "Alpha", "P005930", index))); + var service = new LegacyThemeSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity())); + } + + [Fact] + public async Task PreviewAsync_RejectsWrongSchemaAndProviderRowOverflow() + { + var wrongSchema = PreviewTable(); + wrongSchema.Columns[5].ColumnName = "item_index"; + var executor = new RecordingExecutor(_ => wrongSchema); + var service = new LegacyThemeSelectionService(executor); + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity())); + + executor = new RecordingExecutor(_ => PreviewTable( + ("AI", "00000001", "KRX", "A", "P000001", "0"), + ("AI", "00000001", "KRX", "B", "P000002", "1"), + ("AI", "00000001", "KRX", "C", "P000003", "2"))); + service = new LegacyThemeSelectionService(executor); + await Assert.ThrowsAsync( + () => service.PreviewAsync(KrxIdentity(), maximumItems: 1)); + } + + [Fact] + public async Task PreviewAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => PreviewTable()); + var service = new LegacyThemeSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.PreviewAsync( + KrxIdentity(), + cancellationToken: cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + private static ThemeSelectionIdentity KrxIdentity() => new( + ThemeMarket.Krx, + ThemeSession.NotApplicable, + "00000001", + "AI"); + + private static DataTable SearchTable( + params (string Title, string Code, string Market)[] rows) + { + var table = new DataTable(); + table.Columns.Add("THEME_TITLE", typeof(string)); + table.Columns.Add("THEME_CODE", typeof(string)); + table.Columns.Add("THEME_MARKET", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.Title, row.Code, row.Market); + } + + return table; + } + + private static DataTable PreviewTable( + params (string Title, string Code, string Market, string? ItemName, string? ItemCode, string? Index)[] rows) + { + var table = new DataTable(); + table.Columns.Add("THEME_TITLE", typeof(string)); + table.Columns.Add("THEME_CODE", typeof(string)); + table.Columns.Add("THEME_MARKET", typeof(string)); + table.Columns.Add("ITEM_NAME", typeof(string)); + table.Columns.Add("ITEM_CODE", typeof(string)); + table.Columns.Add("ITEM_INDEX", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add( + row.Title, + row.Code, + row.Market, + row.ItemName is null ? DBNull.Value : row.ItemName, + row.ItemCode is null ? DBNull.Value : row.ItemCode, + row.Index is null ? DBNull.Value : row.Index); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs new file mode 100644 index 0000000..4b0c677 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs @@ -0,0 +1,352 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyTradingHaltSelectionServiceTests +{ + [Fact] + public async Task SearchAsync_EmptyQueryUsesBothBoundedUc7OracleQueries() + { + var results = new Queue( + [ + TradingHaltTable(("000002", "Beta"), ("000001", "Alpha")), + TradingHaltTable(("100002", "Delta"), ("100001", "Gamma")) + ]); + var executor = new RecordingExecutor(_ => results.Dequeue()); + var service = new LegacyTradingHaltSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.SearchAsync("", 25, cancellation.Token); + + Assert.Equal("", result.Query); + Assert.False(result.IsTruncated); + Assert.Collection( + result.Items, + item => Assert.Equal( + new TradingHaltSelection(TradingHaltMarket.Kospi, "000001", "Alpha"), + item), + item => Assert.Equal( + new TradingHaltSelection(TradingHaltMarket.Kospi, "000002", "Beta"), + item), + item => Assert.Equal( + new TradingHaltSelection(TradingHaltMarket.Kosdaq, "100002", "Delta"), + item), + item => Assert.Equal( + new TradingHaltSelection(TradingHaltMarket.Kosdaq, "100001", "Gamma"), + item)); + + Assert.Equal( + ["TRADING_HALT_KOSPI", "TRADING_HALT_KOSDAQ"], + executor.Calls.Select(static call => call.QueryName)); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal(cancellation.Token, call.CancellationToken); + call.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains("F_MKT_HALT = 'Y'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ON a.F_STOCK_CODE = b.F_STOCK_CODE", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("F_CURR_PRICE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(26, parameter.Value); + Assert.Equal(DbType.Int32, parameter.DbType); + }); + Assert.Contains("FROM T_STOP_ONLINE1 a", executor.Calls[0].Spec.Sql, StringComparison.Ordinal); + Assert.Contains("INNER JOIN T_STOCK b", executor.Calls[0].Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM T_STOP_KOSDAQ_ONLINE1 a", executor.Calls[1].Spec.Sql, StringComparison.Ordinal); + Assert.Contains("INNER JOIN T_KOSDAQ_STOCK b", executor.Calls[1].Spec.Sql, StringComparison.Ordinal); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task SearchAsync_FilteredQueryBindsAndEscapesTheUc7NameFilter() + { + var executor = new RecordingExecutor(_ => TradingHaltTable()); + var service = new LegacyTradingHaltSelectionService(executor); + + var result = await service.SearchAsync(" 50%!_ ", maximumResults: 12); + + Assert.Equal("50%!_", result.Query); + Assert.Equal(2, executor.Calls.Count); + Assert.All(executor.Calls, call => + { + Assert.Contains( + "UPPER(b.F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!'", + call.Spec.Sql, + StringComparison.Ordinal); + Assert.DoesNotContain("50", call.Spec.Sql, StringComparison.Ordinal); + Assert.Collection( + call.Spec.Parameters, + parameter => + { + Assert.Equal("search_term", parameter.Name); + Assert.Equal("50!%!!!_", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }, + parameter => + { + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(13, parameter.Value); + Assert.Equal(DbType.Int32, parameter.DbType); + }); + }); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task SearchAsync_ReturnsDeterministicGlobalLimitAndTruncation() + { + var results = new Queue( + [ + TradingHaltTable(("000003", "Gamma"), ("000001", "Alpha")), + TradingHaltTable(("100001", "Beta")) + ]); + var executor = new RecordingExecutor(_ => results.Dequeue()); + var service = new LegacyTradingHaltSelectionService(executor); + + var result = await service.SearchAsync(maximumResults: 2); + + Assert.True(result.IsTruncated); + Assert.Equal( + [ + new TradingHaltSelection(TradingHaltMarket.Kospi, "000001", "Alpha"), + new TradingHaltSelection(TradingHaltMarket.Kospi, "000003", "Gamma") + ], + result.Items); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(501)] + [InlineData(int.MaxValue)] + public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults) + { + var executor = new RecordingExecutor(_ => TradingHaltTable()); + var service = new LegacyTradingHaltSelectionService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("", maximumResults)); + + Assert.Equal("maximumResults", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("bad\u0000value")] + [InlineData("bad\nvalue")] + [InlineData("bad\u200Bvalue")] + [InlineData("bad\uD800value")] + public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query) + { + var executor = new RecordingExecutor(_ => TradingHaltTable()); + var service = new LegacyTradingHaltSelectionService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync(query)); + + Assert.Equal("query", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess() + { + var executor = new RecordingExecutor(_ => TradingHaltTable()); + var service = new LegacyTradingHaltSelectionService(executor); + + await Assert.ThrowsAsync(() => service.SearchAsync(null!)); + await Assert.ThrowsAsync(() => service.SearchAsync( + new string('A', LegacyTradingHaltSelectionService.MaximumQueryLength + 1))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsAnythingOtherThanExactTwoStringColumnSchema() + { + var wrongCase = new DataTable(); + wrongCase.Columns.Add("stock_code", typeof(string)); + wrongCase.Columns.Add("STOCK_NAME", typeof(string)); + var executor = new RecordingExecutor(_ => wrongCase); + var service = new LegacyTradingHaltSelectionService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync()); + + Assert.Contains("TRADING_HALT_KOSPI", exception.Message, StringComparison.Ordinal); + Assert.Single(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsRowsBeyondTheBoundQueryContract() + { + var executor = new RecordingExecutor(_ => TradingHaltTable( + ("000001", "One"), + ("000002", "Two"), + ("000003", "Three"))); + var service = new LegacyTradingHaltSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync(maximumResults: 1)); + + Assert.Single(executor.Calls); + } + + [Theory] + [InlineData(" 000001", "Name")] + [InlineData("000001", "Name ")] + [InlineData("000001", "Bad\nName")] + [InlineData("00\u000001", "Name")] + [InlineData("", "Name")] + [InlineData("000001", "")] + public async Task SearchAsync_RejectsNonCanonicalIdentityValues( + string stockCode, + string displayName) + { + var executor = new RecordingExecutor(_ => TradingHaltTable((stockCode, displayName))); + var service = new LegacyTradingHaltSelectionService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync()); + } + + [Fact] + public async Task SearchAsync_RejectsDuplicateMarketCodeIdentity() + { + var executor = new RecordingExecutor(_ => TradingHaltTable( + ("000001", "First Name"), + ("000001", "Second Name"))); + var service = new LegacyTradingHaltSelectionService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync()); + + Assert.Contains("duplicate market/code", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_RejectsAmbiguousMarketNameLookupKey() + { + var executor = new RecordingExecutor(_ => TradingHaltTable( + ("000001", "Shared Name"), + ("000002", "Shared Name"))); + var service = new LegacyTradingHaltSelectionService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync()); + + Assert.Contains("ambiguous market/name", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_AllowsSameCodeAndNameInDifferentTypedMarkets() + { + var results = new Queue( + [ + TradingHaltTable(("000001", "Shared Name")), + TradingHaltTable(("000001", "Shared Name")) + ]); + var executor = new RecordingExecutor(_ => results.Dequeue()); + var service = new LegacyTradingHaltSelectionService(executor); + + var result = await service.SearchAsync(); + + Assert.Collection( + result.Items, + item => Assert.Equal(TradingHaltMarket.Kospi, item.Market), + item => Assert.Equal(TradingHaltMarket.Kosdaq, item.Market)); + } + + [Fact] + public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => TradingHaltTable()); + var service = new LegacyTradingHaltSelectionService(executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.SearchAsync(cancellationToken: cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_StopsBeforeKosdaqWhenCanceledAfterKospiQuery() + { + using var cancellation = new CancellationTokenSource(); + var executor = new RecordingExecutor(_ => + { + cancellation.Cancel(); + return TradingHaltTable(("000001", "Alpha")); + }); + var service = new LegacyTradingHaltSelectionService(executor); + + await Assert.ThrowsAnyAsync( + () => service.SearchAsync(cancellationToken: cancellation.Token)); + + var call = Assert.Single(executor.Calls); + Assert.Equal("TRADING_HALT_KOSPI", call.QueryName); + } + + private static DataTable TradingHaltTable(params (string StockCode, string DisplayName)[] rows) + { + var table = new DataTable(); + table.Columns.Add("STOCK_CODE", typeof(string)); + table.Columns.Add("STOCK_NAME", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.StockCode, row.DisplayName); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string QueryName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs new file mode 100644 index 0000000..7c894fb --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs @@ -0,0 +1,313 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyWorldStockSearchServiceTests +{ + [Fact] + public async Task SearchAsync_UsesBoundOracleQueryAndReturnsExactIdentityFields() + { + var executor = new RecordingExecutor(_ => WorldStockTable( + ("Apple", "AAPL", "US"), + ("Taiwan Semiconductor", "2330", "TW"))); + var service = new LegacyWorldStockSearchService(executor); + using var cancellation = new CancellationTokenSource(); + + var result = await service.SearchAsync(" apple ", 25, cancellation.Token); + + Assert.Equal("apple", result.Query); + Assert.False(result.IsTruncated); + Assert.Collection( + result.Items, + item => Assert.Equal(new WorldStockSearchItem("Apple", "AAPL", "US"), item), + item => Assert.Equal( + new WorldStockSearchItem("Taiwan Semiconductor", "2330", "TW"), + item)); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("WORLD_STOCK_SEARCH", call.TableName); + Assert.Equal(cancellation.Token, call.CancellationToken); + call.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains("FROM T_WORLD_IX_EQ_MASTER", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("F_FDTC = '1'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("apple", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Collection( + call.Spec.Parameters, + parameter => + { + Assert.Equal("search_term", parameter.Name); + Assert.Equal("APPLE", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }, + parameter => + { + Assert.Equal("row_limit", parameter.Name); + Assert.Equal(26, parameter.Value); + Assert.Equal(DbType.Int32, parameter.DbType); + }); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task SearchAsync_EscapesLikeMetacharactersInsideBoundValue() + { + var executor = new RecordingExecutor(_ => WorldStockTable()); + var service = new LegacyWorldStockSearchService(executor); + + await service.SearchAsync(" a!_%b "); + + var call = Assert.Single(executor.Calls); + Assert.Equal("A!!!_!%B", call.Spec.Parameters[0].Value); + Assert.Contains("LIKE '%' || :search_term || '%' ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task SearchAsync_AllowsOriginalUc5BlankInitialList(string query) + { + var executor = new RecordingExecutor(_ => WorldStockTable()); + var service = new LegacyWorldStockSearchService(executor); + + var result = await service.SearchAsync(query); + + Assert.Equal(string.Empty, result.Query); + var call = Assert.Single(executor.Calls); + Assert.Equal(string.Empty, call.Spec.Parameters[0].Value); + } + + [Fact] + public async Task SearchAsync_ReturnsBoundedRowsAndReportsTruncation() + { + var executor = new RecordingExecutor(_ => WorldStockTable( + ("Alpha", "AAA", "US"), + ("Beta", "BBB", "US"), + ("Gamma", "CCC", "TW"))); + var service = new LegacyWorldStockSearchService(executor); + + var result = await service.SearchAsync("a", maximumResults: 2); + + Assert.True(result.IsTruncated); + Assert.Equal(2, result.Items.Count); + Assert.Equal(3, executor.Calls[0].Spec.Parameters[1].Value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(501)] + [InlineData(int.MaxValue)] + public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults) + { + var executor = new RecordingExecutor(_ => WorldStockTable()); + var service = new LegacyWorldStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("apple", maximumResults)); + + Assert.Equal("maximumResults", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("bad\u0000value")] + [InlineData("\napple")] + [InlineData("bad\u200Bvalue")] + [InlineData("bad\uD800value")] + public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query) + { + var executor = new RecordingExecutor(_ => WorldStockTable()); + var service = new LegacyWorldStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync(query)); + + Assert.Equal("query", exception.ParamName); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsNullAndOverlongQueriesBeforeDatabaseAccess() + { + var executor = new RecordingExecutor(_ => WorldStockTable()); + var service = new LegacyWorldStockSearchService(executor); + + await Assert.ThrowsAsync(() => service.SearchAsync(null!)); + await Assert.ThrowsAsync( + () => service.SearchAsync( + new string('A', LegacyWorldStockSearchService.MaximumQueryLength + 1))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsAnythingOtherThanExactThreeStringColumnSchema() + { + var wrongCase = new DataTable(); + wrongCase.Columns.Add("f_input_name", typeof(string)); + wrongCase.Columns.Add("F_SYMB", typeof(string)); + wrongCase.Columns.Add("F_NATC", typeof(string)); + var executor = new RecordingExecutor(_ => wrongCase); + var service = new LegacyWorldStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("apple")); + + Assert.Contains("WORLD_STOCK_SEARCH", exception.Message, StringComparison.Ordinal); + Assert.Single(executor.Calls); + } + + [Fact] + public async Task SearchAsync_RejectsExtraRowsBeyondTheBoundQueryContract() + { + var executor = new RecordingExecutor(_ => WorldStockTable( + ("One", "1", "US"), + ("Two", "2", "US"), + ("Three", "3", "US"))); + var service = new LegacyWorldStockSearchService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("o", maximumResults: 1)); + + Assert.Single(executor.Calls); + } + + [Theory] + [InlineData(" Apple", "AAPL", "US")] + [InlineData("Apple", "AAPL ", "US")] + [InlineData("Apple\nInc", "AAPL", "US")] + [InlineData("Apple", "AA\u0000PL", "US")] + [InlineData("Apple", "AAPL", "KR")] + [InlineData("Apple", "AAPL", "us")] + public async Task SearchAsync_RejectsNonCanonicalIdentityValues( + string inputName, + string symbol, + string nationCode) + { + var executor = new RecordingExecutor(_ => WorldStockTable( + (inputName, symbol, nationCode))); + var service = new LegacyWorldStockSearchService(executor); + + await Assert.ThrowsAsync( + () => service.SearchAsync("apple")); + } + + [Fact] + public async Task SearchAsync_RejectsDuplicateCompositeIdentity() + { + var executor = new RecordingExecutor(_ => WorldStockTable( + ("Apple", "AAPL", "US"), + ("Apple", "AAPL", "US"))); + var service = new LegacyWorldStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("apple")); + + Assert.Contains("duplicate identity", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_RejectsDuplicateInputNameAcrossSymbolsAndNations() + { + var executor = new RecordingExecutor(_ => WorldStockTable( + ("Shared Name", "SHARED", "US"), + ("Shared Name", "2330", "TW"))); + var service = new LegacyWorldStockSearchService(executor); + + var exception = await Assert.ThrowsAsync( + () => service.SearchAsync("shared")); + + Assert.Contains("duplicate F_INPUT_NAME", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_AllowsSameSymbolForDistinctInputNameKeys() + { + var executor = new RecordingExecutor(_ => WorldStockTable( + ("First Name", "SHARED", "US"), + ("Second Name", "SHARED", "TW"))); + var service = new LegacyWorldStockSearchService(executor); + + var result = await service.SearchAsync("name"); + + Assert.Equal(2, result.Items.Count); + } + + [Fact] + public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => WorldStockTable()); + var service = new LegacyWorldStockSearchService(executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => service.SearchAsync("apple", cancellationToken: cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + private static DataTable WorldStockTable( + params (string InputName, string Symbol, string NationCode)[] rows) + { + var table = new DataTable(); + table.Columns.Add("F_INPUT_NAME", typeof(string)); + table.Columns.Add("F_SYMB", typeof(string)); + table.Columns.Add("F_NATC", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.InputName, row.Symbol, row.NationCode); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + query.ValidateFor(source); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorCatalogTestDoubles.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorCatalogTestDoubles.cs new file mode 100644 index 0000000..d17f334 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorCatalogTestDoubles.cs @@ -0,0 +1,87 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +internal sealed record CatalogQueryCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + +internal sealed class CatalogRecordingQueryExecutor( + Func handler) : IDataQueryExecutor +{ + internal List Calls { get; } = []; + + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new InvalidOperationException("Synchronous catalog queries are forbidden."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("String SQL catalog queries are forbidden."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + var call = new CatalogQueryCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call)); + } +} +internal sealed class CatalogRecordingMutationExecutor( + Func? handler = null) + : IOperatorCatalogMutationExecutor +{ + internal List<(OperatorCatalogDbTransaction Transaction, CancellationToken Token)> Calls + { get; } = []; + + internal Exception? Exception { get; set; } + + public Task ExecuteTransactionAsync( + OperatorCatalogDbTransaction transaction, + CancellationToken cancellationToken = default) + { + Calls.Add((transaction, cancellationToken)); + if (Exception is not null) + { + return Task.FromException(Exception); + } + + var result = handler?.Invoke(transaction) ?? new OperatorCatalogDbTransactionResult( + transaction.OperationId, + transaction.Commands.Select(command => + command.AffectedRowsRule == OperatorCatalogAffectedRowsRule.ExactlyOne ? 1 : 0) + .ToArray(), + DateTimeOffset.Parse("2026-07-11T00:00:00+09:00")); + return Task.FromResult(result); + } +} + +internal static class OperatorCatalogTestTables +{ + internal static DataTable SingleString(string columnName, string value) + { + var table = new DataTable(); + table.Columns.Add(columnName, typeof(string)); + table.Rows.Add(value); + return table; + } + + internal static DataTable Empty(params string[] columns) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(string)); + } + + return table; + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorMutationGateTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorMutationGateTests.cs new file mode 100644 index 0000000..3f862ed --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/OperatorMutationGateTests.cs @@ -0,0 +1,61 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class OperatorMutationGateTests +{ + private static readonly OperatorMutationGateSnapshot Ready = new( + IsFamilyWriteQuarantined: false, + IsGlobalWriteQuarantined: false, + IsLifetimeCancellationRequested: false, + IsPlayoutCommandInFlight: false, + IsPlayoutShutdownStarted: false, + IsBrowserCorrelationQuarantined: false, + IsEngineAvailable: true, + IsWorkflowAvailable: true, + IsCommandAvailable: true, + IsPlayCompletionPending: false, + PreparedSceneName: null, + OnAirSceneName: null, + PreparedCutCode: null, + OnAirCutCode: null, + IsRefreshActive: false, + IsRefreshTaskRunning: false); + + public static TheoryData BlockingSnapshots => + new() + { + { "family write quarantine", Ready with { IsFamilyWriteQuarantined = true } }, + { "global write quarantine", Ready with { IsGlobalWriteQuarantined = true } }, + { "lifetime cancellation", Ready with { IsLifetimeCancellationRequested = true } }, + { "playout command in flight", Ready with { IsPlayoutCommandInFlight = true } }, + { "playout shutdown", Ready with { IsPlayoutShutdownStarted = true } }, + { "browser correlation quarantine", Ready with { IsBrowserCorrelationQuarantined = true } }, + { "missing engine", Ready with { IsEngineAvailable = false } }, + { "missing workflow", Ready with { IsWorkflowAvailable = false } }, + { "command unavailable", Ready with { IsCommandAvailable = false } }, + { "play completion pending", Ready with { IsPlayCompletionPending = true } }, + { "engine prepared scene", Ready with { PreparedSceneName = "prepared-scene" } }, + { "engine on-air scene", Ready with { OnAirSceneName = "on-air-scene" } }, + { "workflow prepared cut", Ready with { PreparedCutCode = "5001" } }, + { "workflow on-air cut", Ready with { OnAirCutCode = "5001" } }, + { "refresh active", Ready with { IsRefreshActive = true } }, + { "refresh task running", Ready with { IsRefreshTaskRunning = true } } + }; + + [Fact] + public void CanStart_WhenEveryConditionIsSafe_ReturnsTrue() + { + Assert.True(OperatorMutationGate.CanStart(Ready)); + } + + [Theory] + [MemberData(nameof(BlockingSnapshots))] + public void CanStart_WhenOneConditionIsUnsafe_ReturnsFalse( + string condition, + OperatorMutationGateSnapshot snapshot) + { + Assert.NotEmpty(condition); + Assert.False(OperatorMutationGate.CanStart(snapshot)); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs index 6163522..201ae4d 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs @@ -143,13 +143,15 @@ public sealed class PagedQuoteSceneDataLoadersTests ["Gamma", 900m, 1000m, 11.11d, "2"])); var data = await new S5077SceneDataLoader(executor).LoadAsync( - new ExpertPagedQuoteLoadRequest("Analyst A")); + new ExpertPagedQuoteLoadRequest("0001", "Analyst A")); var mutations = new S5077SceneMutationBuilder().Build(data); Assert.Equal(DataSourceKind.Oracle, executor.Source); Assert.NotNull(executor.Spec); + Assert.Contains("e.EP_CODE = :expertCode", executor.Spec.Sql, StringComparison.Ordinal); Assert.Contains("e.EP_NAME = :expertName", executor.Spec.Sql, StringComparison.Ordinal); - Assert.Equal("Analyst A", executor.Spec.Parameters[0].Value); + Assert.Equal("0001", executor.Spec.Parameters[0].Value); + Assert.Equal("Analyst A", executor.Spec.Parameters[1].Value); Assert.Equal(PagedQuoteBranch.Expert, data.Branch); Assert.Equal(PagedQuoteHeaderStyle.ExpertReturns, data.HeaderStyle); Assert.Contains(new PlayoutSetValue("column1", "\uB9E4\uC218\uAC00"), mutations); @@ -465,6 +467,7 @@ public sealed class PagedQuoteSceneDataLoadersTests "Theme", PagedQuoteThemeSort.InputOrder, NxtMarketSession.NotApplicable), + new ExpertPagedQuoteLoadRequest("001", "Analyst A"), new ViPagedQuoteLoadRequest([]) ]; @@ -477,6 +480,30 @@ public sealed class PagedQuoteSceneDataLoadersTests } } + [Fact] + public async Task PagePlanProbe_AllowsZeroRowsAndRequestsOneRowBeyondTwentyPages() + { + var emptyExecutor = new RecordingExecutor(NumberedQuoteTable(0)); + var request = new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.MarketCapitalization, + new DateOnly(2026, 7, 12)); + + var empty = await new S5074SceneDataLoader(emptyExecutor) + .LoadPagePlanAsync(request); + + Assert.Empty(empty.Rows); + Assert.Equal(101, Assert.Single(emptyExecutor.Spec!.Parameters).Value); + + var cappedExecutor = new RecordingExecutor(NumberedQuoteTable(101)); + var capped = await new S5074SceneDataLoader(cappedExecutor) + .LoadPagePlanAsync(request); + + Assert.Equal(101, capped.Rows.Count); + Assert.Equal(101, Assert.Single(cappedExecutor.Spec!.Parameters).Value); + Assert.Equal(0, cappedExecutor.StringCalls); + } + private static Task LoadAsync( string scene, IDataQueryExecutor executor, diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs index 12c5784..c921be7 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs @@ -95,6 +95,55 @@ public sealed class ParameterizedMarketSceneDataLoadersTests } } + [Fact] + public async Task Foreign_selector_queries_cannot_widen_beyond_their_master_scope() + { + const string industryName = "Sensitive US industry"; + var industryExecutor = new RecordingExecutor( + QuoteTable(industryName, 100m, "+", 1m, 1m)); + await new S5001SceneDataLoader(industryExecutor).LoadAsync( + new S5001ForeignIndustryLoadRequest(industryName)); + var industry = Assert.Single(industryExecutor.Calls); + + Assert.Equal("SCENE_FOREIGN_INDUSTRY", industry.TableName); + Assert.Contains("b.f_fdtc = '0'", industry.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("b.f_natc = 'US'", industry.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("a.f_fdtc = '0'", industry.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(industryName, industry.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(industryName, Assert.Single(industry.Spec.Parameters).Value); + + const string stockName = "Sensitive world stock"; + var stockExecutor = new RecordingExecutor( + QuoteTable(stockName, 100m, "+", 1m, 1m)); + await new S5001SceneDataLoader(stockExecutor).LoadAsync( + new S5001ForeignStockLoadRequest(stockName)); + var stock = Assert.Single(stockExecutor.Calls); + + Assert.Equal("SCENE_FOREIGN_STOCK", stock.TableName); + Assert.Contains("b.f_fdtc = '1'", stock.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "b.f_natc IN ('US', 'TW')", + stock.Spec.Sql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("a.f_fdtc = '1'", stock.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(stockName, stock.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(stockName, Assert.Single(stock.Spec.Parameters).Value); + } + + [Fact] + public async Task Closed_foreign_index_symbols_keep_global_markets_but_reject_stock_master_rows() + { + var executor = new RecordingExecutor(QuoteTable("DAX", 100m, "+", 1m, 1m)); + + await new S5001SceneDataLoader(executor).LoadAsync( + new S5001ForeignIndexLoadRequest(S5001ForeignIndexTarget.GermanyDax)); + + var call = Assert.Single(executor.Calls); + Assert.Contains("a.f_fdtc = '0'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("b.f_fdtc = '0'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("f_natc", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task S5001_closed_target_sets_all_construct_valid_specs_and_generate_typed_data() { @@ -301,6 +350,48 @@ public sealed class ParameterizedMarketSceneDataLoadersTests AssertReadOnlyBounded); } + [Fact] + public async Task S8018_current_mixed_market_and_nxt_stock_uses_the_existing_domestic_dto_safely() + { + var executor = new RecordingExecutor( + QuoteTable("에이비엘바이오", 120_000m, "+", 1_000m, 0.84m), + QuoteTable("코스피", 2_800m, "-", -10m, -0.36m)); + + var data = await new S8018SceneDataLoader(executor).LoadAsync( + new S8018MixedMarketAndStockLoadRequest( + S8018PairSide.First, + LegacyMarketQuoteTarget.Kospi, + LegacyDomesticEquityMarket.NxtKosdaq, + "에이비엘바이오", + S8018MarketDataMode.Current)); + + Assert.Equal( + [DataSourceKind.MariaDb, DataSourceKind.Oracle], + executor.Calls.Select(call => call.Source)); + Assert.Equal( + ["SCENE_NXT_KOSDAQ_STOCK", "SCENE_KOSPI_INDEX"], + executor.Calls.Select(call => call.TableName)); + Assert.All(executor.Calls, AssertReadOnlyBounded); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task S8018_expected_mixed_nxt_stock_is_rejected_before_queries() + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S8018SceneDataLoader(executor).LoadAsync( + new S8018MixedMarketAndStockLoadRequest( + S8018PairSide.Second, + LegacyMarketQuoteTarget.Kospi, + LegacyDomesticEquityMarket.NxtKospi, + "삼성전자", + S8018MarketDataMode.Expected))); + + Assert.Empty(executor.Calls); + } + [Fact] public async Task S8018_expected_market_mode_is_closed_to_domestic_targets() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs index 2ed2926..ce57c07 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs @@ -160,6 +160,57 @@ public sealed class PlayoutBridgeProtocolTests Assert.Null(result.Request.Playlist); } + [Fact] + public void ParsePagePlanRequest_AcceptsOnlyPagedEntriesWithoutStoredPageInput() + { + using var document = JsonDocument.Parse(""" + { + "requestId":"PAGE_PLAN_1", + "entries":[ + { + "id":"entry-5074", + "code":"5074", + "enabled":true, + "fadeDuration":6, + "selection":{ + "groupCode":"PAGED_DOMESTIC_KOSPI", + "subject":"INDEX", + "graphicType":"", + "subtype":"MARKET_CAP", + "dataCode":"2026-07-12" + } + }, + {"id":"entry-5088","code":"5088","enabled":false} + ] + } + """); + + var result = PlayoutBridgeProtocol.ParsePagePlanRequest(document.RootElement); + + Assert.True(result.IsValid); + Assert.Equal("PAGE_PLAN_1", result.Request.RequestId); + Assert.Equal(["5074", "5088"], result.Request.Entries!.Select(entry => entry.CutCode)); + Assert.Equal("2026-07-12", result.Request.Entries![0].Selection?.DataCode); + } + + [Theory] + [InlineData("{\"requestId\":\"REQ\",\"entries\":[]}")] + [InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true}]}")] + [InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"one\",\"code\":\"5074\",\"enabled\":true,\"pageText\":\"1/24\"}]}")] + [InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"same\",\"code\":\"5074\",\"enabled\":true},{\"id\":\"same\",\"code\":\"5077\",\"enabled\":true}]}")] + [InlineData("{\"requestId\":\"REQ\",\"entries\":[{\"id\":\"one\",\"code\":\"5088\",\"enabled\":true}],\"cue\":{\"code\":\"5088\"}}")] + public void ParsePagePlanRequest_RejectsNonPagedStoredPageOrExtraControlInput(string json) + { + using var document = JsonDocument.Parse(json); + + var result = PlayoutBridgeProtocol.ParsePagePlanRequest(document.RootElement); + + Assert.False(result.IsValid); + Assert.Equal("REQ", result.Request.RequestId); + Assert.Null(result.Request.Entries); + Assert.NotEmpty(result.Error); + } + [Theory] [InlineData("prepare")] [InlineData("take-in")] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs index 648ffb2..f1d0f35 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs @@ -54,6 +54,121 @@ public sealed class RegisteredLegacySceneCueProviderTests page.Cue.Mutations!.Skip(2)); } + [Fact] + public async Task CreatePagePlan_UsesDedicatedDtoRouteWithoutBuildingACue() + { + var rows = Enumerable.Range(1, 101) + .Select(index => new PagedQuoteRowData( + $"item-{index}", + index, + index, + index, + ScenePriceDirection.Up)) + .ToArray(); + var page = new LegacySceneDataPage( + "s5074", + new PagedQuoteSceneData( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "preflight", + rows, + PageNumberOneBased: 1), + rows.Length); + var dataSource = new PagePlanDataSource(page); + var provider = new RegisteredLegacySceneCueProvider(dataSource); + + var plan = await provider.CreatePagePlanAsync(new("entry", "5074")); + + Assert.Equal("s5074", plan.BuilderKey); + Assert.Equal(101, plan.ItemCount); + Assert.Equal(ScenePageSize.Five, plan.PageSize); + Assert.Equal(20, plan.PageCount); + Assert.Equal(100, plan.AccessibleItemCount); + Assert.True(plan.IsTruncated); + Assert.Equal(0, dataSource.PageCalls); + Assert.Equal(1, dataSource.PagePlanCalls); + } + + [Fact] + public async Task CreatePagePlan_PreservesAnExplicitZeroItemResult() + { + var page = new LegacySceneDataPage( + "s5077", + new PagedQuoteSceneData( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "empty", + [], + PageNumberOneBased: 1), + 0); + var dataSource = new PagePlanDataSource(page); + var provider = new RegisteredLegacySceneCueProvider(dataSource); + + var plan = await provider.CreatePagePlanAsync(new("empty-entry", "5077")); + + Assert.Equal("s5077", plan.BuilderKey); + Assert.Equal(0, plan.ItemCount); + Assert.Equal(ScenePageSize.Six, plan.PageSize); + Assert.Equal(0, plan.PageCount); + Assert.Equal(0, plan.AccessibleItemCount); + Assert.False(plan.IsTruncated); + Assert.Equal(0, dataSource.PageCalls); + Assert.Equal(1, dataSource.PagePlanCalls); + } + + [Fact] + public async Task MutableCompositionSource_AppliesOnlyToCuesStartedAfterTheUpdate() + { + var pageData = new LegacySceneDataPage( + "s5085", + new S5085SceneData( + [ + new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 12.5) + ]), + 1); + var dataSource = new BlockingDataSource(pageData); + var optionsSource = new MutableLegacySceneCueCompositionOptionsSource( + LegacySceneCueCompositionOptions.Default); + var provider = new RegisteredLegacySceneCueProvider(dataSource, optionsSource); + + var inFlight = provider.CreatePageAsync(new("entry-one", "5085"), 0); + await dataSource.Started; + optionsSource.Update(new LegacySceneCueCompositionOptions( + 6, + LegacySceneBackgroundKind.Texture, + "Video\\studio.png")); + dataSource.Release(); + + var first = await inFlight; + var second = await provider.CreatePageAsync(new("entry-two", "5085"), 0); + + Assert.Equal(new PlayoutUseBackground(false), first.Cue.Mutations![0]); + Assert.Collection( + second.Cue.Mutations!.Take(2), + mutation => Assert.Equal( + new PlayoutSetBackgroundTexture("Video\\studio.png"), + mutation), + mutation => Assert.Equal(new PlayoutUseBackground(true), mutation)); + } + + [Fact] + public void MutableCompositionSource_RejectsUnsafeReplacementAndKeepsTheCurrentSnapshot() + { + var source = new MutableLegacySceneCueCompositionOptionsSource( + LegacySceneCueCompositionOptions.Default); + + Assert.Throws(() => source.Update( + new LegacySceneCueCompositionOptions( + 6, + LegacySceneBackgroundKind.Video, + "..\\outside.vrv"))); + Assert.Equal(LegacySceneCueCompositionOptions.Default, source.Current); + } + [Fact] public async Task CreatePage_UsesCatalogPageSizeAndKeepsTheFullItemCount() { @@ -150,4 +265,56 @@ public sealed class RegisteredLegacySceneCueProviderTests return Task.FromResult(page); } } + + private sealed class PagePlanDataSource(LegacySceneDataPage planPage) : + ILegacySceneDataSource, + ILegacyScenePagePlanDataSource + { + public int PageCalls { get; private set; } + + public int PagePlanCalls { get; private set; } + + public Task LoadPageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default) + { + PageCalls++; + throw new InvalidOperationException("Normal cue creation must not run during preflight."); + } + + public Task LoadPagePlanDataAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + PagePlanCalls++; + return Task.FromResult(planPage); + } + } + + private sealed class BlockingDataSource(LegacySceneDataPage page) : ILegacySceneDataSource + { + private readonly TaskCompletionSource _started = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _release = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Started => _started.Task; + + public void Release() => _release.TrySetResult(); + + public async Task LoadPageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default) + { + _started.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken); + _release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _release.TrySetResult(); + return page; + } + } } diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedViManualReferenceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedViManualReferenceTests.cs new file mode 100644 index 0000000..72e7b26 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedViManualReferenceTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class TrustedViManualReferenceTests +{ + private const string Version = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + [Fact] + public async Task Realistic_twenty_page_web_reference_crosses_bridge_and_resolves_native_names() + { + var names = Enumerable.Range(1, 100) + .Select(index => $"현실적인긴종목명테스트{index}") + .ToArray(); + using var document = JsonDocument.Parse($$""" + { + "requestId": "vi_prepare_20_pages", + "command": "prepare", + "playlist": [ + { + "id": "vi_entry_20_pages", + "code": "5074", + "enabled": true, + "fadeDuration": 4, + "selection": { + "groupCode": "PAGED_VI", + "subject": "{{TrustedViManualReference.SubjectSentinel}}", + "graphicType": "INPUT_ORDER", + "subtype": "CURRENT", + "dataCode": "{{Version}}" + } + } + ], + "selectedIndex": 0 + } + """); + + var parsed = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement); + + Assert.True(parsed.IsValid, parsed.Error); + var selection = Assert.Single(parsed.Request.Playlist!).Selection!; + var request = await TrustedViManualReference.ResolveAsync( + selection, + pageIndexZeroBased: 19, + new StubSnapshotSource(names, Version)); + Assert.Equal(20, request.PageNumberOneBased); + Assert.Equal(names, request.StockNames); + } + + [Fact] + public async Task Version_mismatch_fails_closed_before_query_request_creation() + { + var selection = TrustedSelection(Version); + + await Assert.ThrowsAsync(() => + TrustedViManualReference.ResolveAsync( + selection, + 0, + new StubSnapshotSource(["삼성전자"], new string('b', 64)))); + } + + [Fact] + public async Task Modified_sentinel_cannot_downgrade_a_versioned_reference() + { + var selection = TrustedSelection(Version) with + { + Subject = "@MBN_VI_SNAPSHOT_V0" + }; + + await Assert.ThrowsAsync(() => + TrustedViManualReference.ResolveAsync( + selection, + 0, + new StubSnapshotSource(["삼성전자"], Version))); + } + + [Fact] + public async Task Historical_bounded_name_list_remains_compatible() + { + var selection = new LegacySceneSelection( + "PAGED_VI", + "삼성전자,SK하이닉스,삼성전자", + "INPUT_ORDER", + "CURRENT", + string.Empty); + + var request = await TrustedViManualReference.ResolveAsync( + selection, + 1, + trustedSource: null); + + Assert.Equal(2, request.PageNumberOneBased); + Assert.Equal(["삼성전자", "SK하이닉스", "삼성전자"], request.StockNames); + } + + private static LegacySceneSelection TrustedSelection(string version) => new( + "PAGED_VI", + TrustedViManualReference.SubjectSentinel, + "INPUT_ORDER", + "CURRENT", + version); + + private sealed class StubSnapshotSource( + IReadOnlyList names, + string version) : ITrustedViStockNameSnapshotSource + { + public Task ReadStockNameSnapshotAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new TrustedViStockNameSnapshot(names, version)); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs new file mode 100644 index 0000000..c0c7640 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs @@ -0,0 +1,494 @@ +using System.Collections; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class OperatorCatalogMutationExecutorTests +{ + [Fact] + public async Task ExecuteTransactionAsync_UsesOneSerializableTransactionAndCommitsOnce() + { + var plan = new CatalogMutationConnectionPlan(); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + var factory = new CatalogMutationConnectionFactory(plan); + var executor = CreateExecutor(factory); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + var receipt = await service.CreateAsync(new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0042", "전문가"), + [ + new ExpertCatalogRecommendation(0, "005930", "삼성전자", 70000m), + new ExpertCatalogRecommendation(1, "035720", "카카오", 45000m) + ])); + + Assert.Equal(OperatorCatalogMutationKind.CreateExpert, receipt.Kind); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(DataSourceKind.Oracle, factory.LastSource); + Assert.Equal(IsolationLevel.Serializable, plan.IsolationLevel); + Assert.Equal(1, plan.BeginCount); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(3, plan.Commands.Count); + Assert.Empty(plan.NonQuerySteps); + Assert.All(plan.Commands, command => + { + Assert.Equal(7, command.CommandTimeoutSeconds); + Assert.True(command.HadTransaction); + Assert.All(command.Parameters, parameter => + Assert.Equal(ParameterDirection.Input, parameter.Direction)); + }); + Assert.Contains("INSERT INTO EXPERT_LIST", plan.Commands[0].Sql, StringComparison.Ordinal); + Assert.Equal("0042", Parameter(plan.Commands[0], "expert_code").Value); + Assert.Equal("전문가", Parameter(plan.Commands[0], "expert_name").Value); + Assert.Contains("INSERT INTO RECOMMEND_LIST", plan.Commands[1].Sql, StringComparison.Ordinal); + Assert.Equal(70000m, Parameter(plan.Commands[1], "buy_amount").Value); + Assert.Equal(1, plan.ConnectionDisposeCount); + Assert.Equal(1, plan.TransactionDisposeCount); + } + + [Fact] + public async Task ExecuteTransactionAsync_SupportsMariaThemeWithSameAtomicContract() + { + var plan = new CatalogMutationConnectionPlan(); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + var factory = new CatalogMutationConnectionFactory(plan); + var executor = CreateExecutor(factory); + var service = new LegacyThemeCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + await service.CreateAsync(new ThemeCatalogDefinition( + ThemeMarket.Nxt, + "00000007", + "반도체", + [new ThemeCatalogItem(0, "X005930", "삼성전자")])); + + Assert.Equal(DataSourceKind.MariaDb, factory.LastSource); + Assert.Equal(IsolationLevel.Serializable, plan.IsolationLevel); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(2, plan.Commands.Count); + Assert.All(plan.Commands, command => Assert.Contains('@', command.Sql)); + } + + [Fact] + public async Task ExecuteTransactionAsync_AffectedRowConflictRollsBackBeforeNextCommand() + { + var plan = new CatalogMutationConnectionPlan(); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(0)); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + var factory = new CatalogMutationConnectionFactory(plan); + var executor = CreateExecutor(factory); + var service = new LegacyThemeCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(new ThemeCatalogDefinition( + ThemeMarket.Krx, + "00000001", + "중복 테마", + [new ThemeCatalogItem(0, "P005930", "삼성전자")]))); + + Assert.False(exception.OutcomeUnknown); + Assert.Equal(OperatorCatalogDbCommandKind.InsertTheme, exception.CommandKind); + Assert.Single(plan.Commands); + Assert.Single(plan.NonQuerySteps); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + } + + [Fact] + public async Task ExecuteTransactionAsync_EmptyChildDeleteThenIdentityParentDeleteCommits() + { + var plan = new CatalogMutationConnectionPlan(); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(0)); + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan)); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + await service.DeleteAsync(new ExpertSelectionIdentity("0001", "전문가")); + + Assert.Equal(2, plan.Commands.Count); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ExecuteTransactionAsync_TimeoutOrCancellationAfterBeginIsOutcomeUnknown( + bool cancellation) + { + var plan = new CatalogMutationConnectionPlan(); + plan.NonQuerySteps.Enqueue(_ => cancellation + ? Task.FromException(new OperationCanceledException("cancelled in provider")) + : Task.FromException(new TimeoutException("provider timeout"))); + var factory = new CatalogMutationConnectionFactory(plan); + var executor = CreateExecutor(factory); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "전문가"), + []))); + + Assert.True(exception.OutcomeUnknown); + Assert.Contains("do not retry automatically", exception.Message, StringComparison.Ordinal); + Assert.Single(plan.Commands); + Assert.Empty(plan.NonQuerySteps); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + } + + [Fact] + public async Task ExecuteTransactionAsync_OrdinaryFailureWithKnownRollbackIsNotUnknown() + { + var plan = new CatalogMutationConnectionPlan(); + plan.NonQuerySteps.Enqueue(_ => + Task.FromException(new TestDatabaseException("constraint"))); + var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan)); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "전문가"), + []))); + + Assert.False(exception.OutcomeUnknown); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + } + + [Fact] + public async Task ExecuteTransactionAsync_RollbackFailureMakesOutcomeUnknown() + { + var plan = new CatalogMutationConnectionPlan + { + RollbackAsync = _ => Task.FromException(new IOException("rollback disconnected")) + }; + plan.NonQuerySteps.Enqueue(_ => + Task.FromException(new TestDatabaseException("constraint"))); + var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan)); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "전문가"), + []))); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + } + + [Fact] + public async Task ExecuteTransactionAsync_CommitFailureIsUnknownAndDoesNotRollback() + { + var plan = new CatalogMutationConnectionPlan + { + CommitAsync = _ => Task.FromException(new TimeoutException("commit response lost")) + }; + plan.NonQuerySteps.Enqueue(_ => Task.FromResult(1)); + var executor = CreateExecutor(new CatalogMutationConnectionFactory(plan)); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "전문가"), + []))); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + } + + [Fact] + public async Task ExecuteTransactionAsync_PreCancelledTokenDoesNotOpenConnection() + { + var plan = new CatalogMutationConnectionPlan(); + var factory = new CatalogMutationConnectionFactory(plan); + var executor = CreateExecutor(factory); + var service = new LegacyExpertCatalogPersistenceService( + new CatalogNoQueryExecutor(), + executor); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + service.CreateAsync( + new ExpertCatalogDefinition( + new ExpertSelectionIdentity("0001", "전문가"), + []), + cancellation.Token)); + + Assert.Equal(0, factory.CreateCount); + Assert.Equal(0, plan.BeginCount); + } + + private static OperatorCatalogMutationExecutor CreateExecutor( + IDatabaseConnectionFactory factory) => + new( + factory, + new DatabaseResilienceOptions + { + OperationTimeoutSeconds = 30, + MaximumRetryCount = 5, + InitialRetryDelayMilliseconds = 1 + }, + new StubErrorDetector(transient: true), + static (_, _) => { }); + + private static ObservedCatalogMutationParameter Parameter( + ObservedCatalogMutationCommand command, + string name) => + Assert.Single(command.Parameters, parameter => + string.Equals(parameter.Name, name, StringComparison.Ordinal)); +} + +internal sealed class CatalogNoQueryExecutor : IDataQueryExecutor +{ + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new InvalidOperationException("No query expected."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("No query expected."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("No query expected."); +} + +internal sealed class CatalogMutationConnectionPlan +{ + internal Queue>> NonQuerySteps { get; } = new(); + + internal Func OpenAsync { get; init; } = + _ => Task.CompletedTask; + + internal Func CommitAsync { get; init; } = + _ => Task.CompletedTask; + + internal Func RollbackAsync { get; init; } = + _ => Task.CompletedTask; + + internal List Commands { get; } = []; + + internal IsolationLevel? IsolationLevel { get; set; } + + internal int BeginCount { get; set; } + + internal int CommitCount { get; set; } + + internal int RollbackCount { get; set; } + + internal int ConnectionDisposeCount { get; set; } + + internal int TransactionDisposeCount { get; set; } +} + +internal sealed record ObservedCatalogMutationCommand( + string Sql, + int CommandTimeoutSeconds, + bool HadTransaction, + IReadOnlyList Parameters); + +internal sealed record ObservedCatalogMutationParameter( + string Name, + object? Value, + DbType DbType, + ParameterDirection Direction); + +internal sealed class CatalogMutationConnectionFactory(CatalogMutationConnectionPlan plan) + : IDatabaseConnectionFactory +{ + internal int CreateCount { get; private set; } + + internal DataSourceKind? LastSource { get; private set; } + + public DbConnection Create(DataSourceKind source) + { + CreateCount++; + LastSource = source; + return new CatalogMutationDbConnection(plan); + } + + public int GetCommandTimeoutSeconds(DataSourceKind source) => 7; +} + +internal sealed class CatalogMutationDbConnection(CatalogMutationConnectionPlan plan) + : DbConnection +{ + private ConnectionState _state = ConnectionState.Closed; + + [AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => "CatalogFake"; + + public override string DataSource => "CatalogFake"; + + public override string ServerVersion => "1"; + + public override ConnectionState State => _state; + + public override void ChangeDatabase(string databaseName) + { + } + + public override void Close() => _state = ConnectionState.Closed; + + public override void Open() => throw new InvalidOperationException("Synchronous open forbidden."); + + public override async Task OpenAsync(CancellationToken cancellationToken) + { + await plan.OpenAsync(cancellationToken); + _state = ConnectionState.Open; + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + { + plan.BeginCount++; + plan.IsolationLevel = isolationLevel; + return new CatalogMutationDbTransaction(this, plan, isolationLevel); + } + + protected override DbCommand CreateDbCommand() => + new CatalogMutationDbCommand(this, plan); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + plan.ConnectionDisposeCount++; + _state = ConnectionState.Closed; + } + + base.Dispose(disposing); + } +} + +internal sealed class CatalogMutationDbTransaction( + DbConnection connection, + CatalogMutationConnectionPlan plan, + IsolationLevel isolationLevel) : DbTransaction +{ + public override IsolationLevel IsolationLevel => isolationLevel; + + protected override DbConnection DbConnection => connection; + + public override void Commit() => throw new InvalidOperationException("Sync commit forbidden."); + + public override void Rollback() => throw new InvalidOperationException("Sync rollback forbidden."); + + public override async Task CommitAsync(CancellationToken cancellationToken = default) + { + plan.CommitCount++; + await plan.CommitAsync(cancellationToken); + } + + public override async Task RollbackAsync(CancellationToken cancellationToken = default) + { + plan.RollbackCount++; + await plan.RollbackAsync(cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + plan.TransactionDisposeCount++; + } + + base.Dispose(disposing); + } +} + +internal sealed class CatalogMutationDbCommand( + DbConnection connection, + CatalogMutationConnectionPlan plan) : DbCommand +{ + private readonly FakeDbParameterCollection _parameters = new(); + + [AllowNull] + public override string CommandText { get; set; } = string.Empty; + + public override int CommandTimeout { get; set; } + + public override CommandType CommandType { get; set; } + + public override bool DesignTimeVisible { get; set; } + + public override UpdateRowSource UpdatedRowSource { get; set; } + + protected override DbConnection? DbConnection { get; set; } = connection; + + protected override DbParameterCollection DbParameterCollection => _parameters; + + protected override DbTransaction? DbTransaction { get; set; } + + public override void Cancel() + { + } + + public override int ExecuteNonQuery() => + throw new InvalidOperationException("Synchronous execution forbidden."); + + public override object? ExecuteScalar() => throw new NotSupportedException(); + + public override void Prepare() + { + } + + protected override DbParameter CreateDbParameter() => new FakeDbParameter(); + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => + throw new NotSupportedException(); + + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken) + { + plan.Commands.Add(new ObservedCatalogMutationCommand( + CommandText, + CommandTimeout, + DbTransaction is not null, + _parameters.Cast() + .Select(parameter => new ObservedCatalogMutationParameter( + parameter.ParameterName, + parameter.Value, + parameter.DbType, + parameter.Direction)) + .ToArray())); + + if (!plan.NonQuerySteps.TryDequeue(out var step)) + { + throw new InvalidOperationException("No non-query plan remains."); + } + + return step(cancellationToken); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs new file mode 100644 index 0000000..28969ac --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs @@ -0,0 +1,501 @@ +#nullable enable + +using System.Collections; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using MMoneyCoderSharp.Data; +using Oracle.ManagedDataAccess.Client; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class OracleManualFinancialMutationExecutorTests +{ + private const string Version = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + [Fact] + public void ConfigureOracleCommand_sets_bind_by_name_and_rejects_another_provider() + { + using var oracleCommand = new OracleCommand(); + OracleManualFinancialMutationExecutor.ConfigureOracleCommand(oracleCommand); + Assert.True(oracleCommand.BindByName); + + var plan = new Plan(); + using var fake = new FakeCommand(new FakeConnection(plan), plan, 0); + Assert.Throws(() => + OracleManualFinancialMutationExecutor.ConfigureOracleCommand(fake)); + } + + [Fact] + public async Task Create_locks_then_executes_bound_command_once_and_commits_one_transaction() + { + var plan = new Plan + { + ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 1) + }; + var factory = new Factory(plan, 17); + var service = Service(Executor(factory)); + + var receipt = await service.CreateAsync(Sales("Operator ' quoted")); + + Assert.Equal(1, factory.CreateCount); + Assert.Equal(ManualFinancialMutationKind.Create, receipt.Kind); + Assert.Equal(ManualFinancialScreenKind.Sales, receipt.Screen); + Assert.Equal("Operator ' quoted", receipt.StockName); + Assert.Equal(1, receipt.AffectedRows); + Assert.NotEqual(Guid.Empty, receipt.OperationId); + Assert.Equal(1, plan.OpenCount); + Assert.Equal(1, plan.BeginCount); + Assert.Equal(IsolationLevel.ReadCommitted, plan.IsolationLevel); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(2, plan.Commands.Count); + Assert.Equal("LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE", plan.Commands[0].CommandText); + Assert.Empty(plan.Commands[0].ParametersSnapshot); + Assert.StartsWith("INSERT INTO INPUT_SELL", plan.Commands[1].CommandText.TrimStart(), StringComparison.Ordinal); + Assert.DoesNotContain("Operator ' quoted", plan.Commands[1].CommandText, StringComparison.Ordinal); + Assert.Equal("Operator ' quoted", plan.Commands[1].ParametersSnapshot + .Single(parameter => parameter.ParameterName == "stock_name").Value); + Assert.All(plan.Commands, command => + { + Assert.True(command.BindByName); + Assert.Equal(17, command.CommandTimeout); + Assert.Equal(1, command.ExecuteCount); + Assert.Equal(0, command.SynchronousExecuteCount); + Assert.True(command.WasDisposed); + }); + Assert.True(plan.ConnectionDisposed); + Assert.True(plan.TransactionDisposed); + } + + [Fact] + public async Task Zero_affected_create_rolls_back_as_known_duplicate_before_commit() + { + var plan = new Plan + { + ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 0) + }; + var factory = new Factory(plan); + var service = Service(Executor(factory)); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(Sales("Alpha"))); + + Assert.False(exception.OutcomeUnknown); + Assert.Equal(ManualFinancialMutationRejectionReason.DuplicateIdentity, exception.Reason); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + Assert.Equal(2, plan.Commands.Count); + Assert.Equal([1, 1], plan.Commands.Select(command => command.ExecuteCount)); + Assert.Equal(1, factory.CreateCount); + } + + [Fact] + public async Task Command_timeout_is_OutcomeUnknown_and_is_never_retried() + { + var plan = new Plan + { + ExecuteAsync = (index, _) => index == 0 + ? Task.FromResult(-1) + : Task.FromException(new TimeoutException("late")) + }; + var factory = new Factory(plan); + var service = Service(Executor(factory)); + + var exception = await Assert.ThrowsAsync(() => + service.CreateAsync(Sales("Alpha"))); + + Assert.True(exception.OutcomeUnknown); + Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + Assert.Equal(2, plan.Commands.Count); + Assert.Equal([1, 1], plan.Commands.Select(command => command.ExecuteCount)); + } + + [Fact] + public async Task Commit_response_loss_is_OutcomeUnknown_without_rollback_or_retry() + { + var plan = new Plan + { + ExecuteAsync = (index, _) => Task.FromResult(index == 0 ? -1 : 1), + CommitAsync = _ => Task.FromException(new TestDatabaseException("lost")) + }; + var factory = new Factory(plan); + var service = Service(Executor(factory)); + + var exception = await Assert.ThrowsAsync(() => + service.DeleteAsync(new ManualFinancialSnapshot(Sales("Alpha"), Version))); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(2, plan.Commands.Count); + } + + private static LegacyManualFinancialScreenService Service( + IManualFinancialMutationExecutor executor) => + new(new ForbiddenQueryExecutor(), executor); + + private static OracleManualFinancialMutationExecutor Executor( + Factory factory, + int operationTimeoutSeconds = 30) => + new( + factory, + new DatabaseResilienceOptions + { + OperationTimeoutSeconds = operationTimeoutSeconds, + MaximumRetryCount = 5, + InitialRetryDelayMilliseconds = 30_000 + }, + new StubErrorDetector(false), + command => Assert.IsType(command).BindByName = true); + + private static ManualSalesRecord Sales(string stockName) => + new( + new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName), + [ + new("1Q", 10), + new("2Q", 20), + new("3Q", 30), + new("4Q", 40), + new("5Q", 50), + new("6Q", 60) + ]); + + private sealed class ForbiddenQueryExecutor : IDataQueryExecutor + { + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new InvalidOperationException("A mutation test must not read."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("A mutation test must not read."); + } + + private sealed class Factory(Plan plan, int commandTimeoutSeconds = 15) + : IDatabaseConnectionFactory + { + public int CreateCount { get; private set; } + + public DbConnection Create(DataSourceKind source) + { + Assert.Equal(DataSourceKind.Oracle, source); + CreateCount++; + return new FakeConnection(plan); + } + + public int GetCommandTimeoutSeconds(DataSourceKind source) + { + Assert.Equal(DataSourceKind.Oracle, source); + return commandTimeoutSeconds; + } + } + + private sealed class Plan + { + public Func> ExecuteAsync { get; init; } = + (_, _) => Task.FromResult(1); + + public Func CommitAsync { get; init; } = + _ => Task.CompletedTask; + + public Func RollbackAsync { get; init; } = + _ => Task.CompletedTask; + + public int OpenCount { get; set; } + + public int BeginCount { get; set; } + + public int CommitCount { get; set; } + + public int RollbackCount { get; set; } + + public IsolationLevel? IsolationLevel { get; set; } + + public bool ConnectionDisposed { get; set; } + + public bool TransactionDisposed { get; set; } + + public List Commands { get; } = []; + } + + private sealed class FakeConnection(Plan plan) : DbConnection + { + private ConnectionState _state = ConnectionState.Closed; + + [AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => "Fake"; + + public override string DataSource => "Fake"; + + public override string ServerVersion => "1"; + + public override ConnectionState State => _state; + + public override void ChangeDatabase(string databaseName) + { + } + + public override void Close() => _state = ConnectionState.Closed; + + public override void Open() => throw new InvalidOperationException("Synchronous open is forbidden."); + + public override Task OpenAsync(CancellationToken cancellationToken) + { + plan.OpenCount++; + _state = ConnectionState.Open; + return Task.CompletedTask; + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => + throw new InvalidOperationException("Synchronous begin is forbidden."); + + protected override ValueTask BeginDbTransactionAsync( + IsolationLevel isolationLevel, + CancellationToken cancellationToken) + { + plan.BeginCount++; + plan.IsolationLevel = isolationLevel; + return ValueTask.FromResult(new FakeTransaction(this, plan, isolationLevel)); + } + + protected override DbCommand CreateDbCommand() + { + var command = new FakeCommand(this, plan, plan.Commands.Count); + plan.Commands.Add(command); + return command; + } + + public override ValueTask DisposeAsync() + { + plan.ConnectionDisposed = true; + _state = ConnectionState.Closed; + return ValueTask.CompletedTask; + } + } + + private sealed class FakeTransaction( + DbConnection connection, + Plan plan, + IsolationLevel isolationLevel) : DbTransaction + { + public override IsolationLevel IsolationLevel => isolationLevel; + + protected override DbConnection DbConnection => connection; + + public override void Commit() => throw new InvalidOperationException("Synchronous commit is forbidden."); + + public override async Task CommitAsync(CancellationToken cancellationToken = default) + { + plan.CommitCount++; + await plan.CommitAsync(cancellationToken); + } + + public override void Rollback() => throw new InvalidOperationException("Synchronous rollback is forbidden."); + + public override async Task RollbackAsync(CancellationToken cancellationToken = default) + { + plan.RollbackCount++; + await plan.RollbackAsync(cancellationToken); + } + + public override ValueTask DisposeAsync() + { + plan.TransactionDisposed = true; + return ValueTask.CompletedTask; + } + } + + private sealed class FakeCommand( + DbConnection connection, + Plan plan, + int commandIndex) : DbCommand + { + private readonly FakeParameterCollection _parameters = new(); + + public bool BindByName { get; set; } + + public int ExecuteCount { get; private set; } + + public int SynchronousExecuteCount { get; private set; } + + public bool WasDisposed { get; private set; } + + public IReadOnlyList ParametersSnapshot => + _parameters.Cast().ToArray(); + + [AllowNull] + public override string CommandText { get; set; } = string.Empty; + + public override int CommandTimeout { get; set; } + + public override CommandType CommandType { get; set; } + + public override bool DesignTimeVisible { get; set; } + + public override UpdateRowSource UpdatedRowSource { get; set; } + + protected override DbConnection? DbConnection { get; set; } = connection; + + protected override DbParameterCollection DbParameterCollection => _parameters; + + protected override DbTransaction? DbTransaction { get; set; } + + public override void Cancel() + { + } + + public override int ExecuteNonQuery() + { + SynchronousExecuteCount++; + throw new InvalidOperationException("Synchronous execution is forbidden."); + } + + public override async Task ExecuteNonQueryAsync(CancellationToken cancellationToken) + { + ExecuteCount++; + return await plan.ExecuteAsync(commandIndex, cancellationToken); + } + + public override object? ExecuteScalar() => throw new NotSupportedException(); + + public override void Prepare() + { + } + + protected override DbParameter CreateDbParameter() => new FakeParameter(); + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => + throw new NotSupportedException(); + + public override ValueTask DisposeAsync() + { + WasDisposed = true; + return ValueTask.CompletedTask; + } + } + + private sealed class FakeParameter : DbParameter + { + public override DbType DbType { get; set; } + + public override ParameterDirection Direction { get; set; } + + public override bool IsNullable { get; set; } + + [AllowNull] + public override string ParameterName { get; set; } = string.Empty; + + public override int Size { get; set; } + + [AllowNull] + public override string SourceColumn { get; set; } = string.Empty; + + public override bool SourceColumnNullMapping { get; set; } + + public override object? Value { get; set; } + + public override void ResetDbType() => DbType = default; + } + + private sealed class FakeParameterCollection : DbParameterCollection + { + private readonly List _parameters = []; + + public override int Count => _parameters.Count; + + public override object SyncRoot => ((ICollection)_parameters).SyncRoot; + + public override int Add(object value) + { + _parameters.Add(Require(value)); + return _parameters.Count - 1; + } + + public override void AddRange(Array values) + { + foreach (var value in values) + { + Add(value ?? throw new ArgumentNullException(nameof(values))); + } + } + + public override void Clear() => _parameters.Clear(); + + public override bool Contains(object value) => + value is DbParameter parameter && _parameters.Contains(parameter); + + public override bool Contains(string value) => IndexOf(value) >= 0; + + public override void CopyTo(Array array, int index) => + ((ICollection)_parameters).CopyTo(array, index); + + public override IEnumerator GetEnumerator() => _parameters.GetEnumerator(); + + public override int IndexOf(object value) => + value is DbParameter parameter ? _parameters.IndexOf(parameter) : -1; + + public override int IndexOf(string parameterName) => + _parameters.FindIndex(parameter => string.Equals( + parameter.ParameterName, + parameterName, + StringComparison.OrdinalIgnoreCase)); + + public override void Insert(int index, object value) => + _parameters.Insert(index, Require(value)); + + public override void Remove(object value) + { + if (value is DbParameter parameter) + { + _parameters.Remove(parameter); + } + } + + public override void RemoveAt(int index) => _parameters.RemoveAt(index); + + public override void RemoveAt(string parameterName) + { + var index = IndexOf(parameterName); + if (index >= 0) + { + RemoveAt(index); + } + } + + protected override DbParameter GetParameter(int index) => _parameters[index]; + + protected override DbParameter GetParameter(string parameterName) + { + var index = IndexOf(parameterName); + return index >= 0 ? _parameters[index] : throw new IndexOutOfRangeException(); + } + + protected override void SetParameter(int index, DbParameter value) => + _parameters[index] = value; + + protected override void SetParameter(string parameterName, DbParameter value) + { + var index = IndexOf(parameterName); + if (index >= 0) + { + _parameters[index] = value; + } + else + { + _parameters.Add(value); + } + } + + private static DbParameter Require(object value) => + value as DbParameter ?? throw new ArgumentException("A parameter is required."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs new file mode 100644 index 0000000..9b056c0 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs @@ -0,0 +1,814 @@ +using System.Collections; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; +using Oracle.ManagedDataAccess.Client; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class OracleNamedPlaylistMutationExecutorTests +{ + [Fact] + public void ConfigureOracleCommand_SetsBindByNameAndRejectsAnotherProviderCommand() + { + using var oracleCommand = new OracleCommand(); + + OracleNamedPlaylistMutationExecutor.ConfigureOracleCommand(oracleCommand); + + Assert.True(oracleCommand.BindByName); + using var fakeCommand = new MutationFakeCommand( + new MutationFakeConnection(new MutationPlan()), + new MutationPlan(), + 0); + Assert.Throws( + () => OracleNamedPlaylistMutationExecutor.ConfigureOracleCommand(fakeCommand)); + } + + [Fact] + public async Task ReplaceItemsAsync_UsesOneConnectionTransactionAndCommitWithBoundCommands() + { + var plan = new MutationPlan(); + var factory = new MutationConnectionFactory(plan, commandTimeoutSeconds: 17); + var executor = CreateExecutor(factory); + var service = CreateService(executor); + + await service.ReplaceItemsAsync( + "00000011", + [ + Item(0, "Alpha", "000001"), + Item(1, "Beta", "000002") + ]); + + Assert.Equal(1, factory.CreateCount); + Assert.Equal([DataSourceKind.Oracle], factory.Sources); + Assert.Equal(1, plan.OpenCount); + Assert.Equal(1, plan.BeginCount); + Assert.Equal(IsolationLevel.ReadCommitted, plan.ObservedIsolationLevel); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(0, plan.SynchronousBeginCount); + Assert.Equal(0, plan.SynchronousCommitCount); + Assert.Equal(0, plan.SynchronousRollbackCount); + Assert.True(plan.ConnectionDisposed); + Assert.True(plan.TransactionDisposed); + + Assert.Collection( + plan.Commands, + command => + { + Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, CommandKind(command)); + Assert.StartsWith( + "DELETE FROM PLAY_LIST", + command.CommandText.TrimStart(), + StringComparison.Ordinal); + AssertParameter( + Assert.Single(command.ParametersSnapshot), + "program_code", + "00000011", + DbType.String); + }, + command => AssertInsert(command, 0, "Alpha", "000001"), + command => AssertInsert(command, 1, "Beta", "000002")); + + var transaction = Assert.IsType(plan.Transaction); + foreach (var command in plan.Commands) + { + Assert.True(command.BindByName); + Assert.Equal(CommandType.Text, command.CommandType); + Assert.Equal(17, command.CommandTimeout); + Assert.Same(transaction, command.ObservedTransaction); + Assert.Equal(1, command.ExecuteCount); + Assert.Equal(0, command.SynchronousExecuteCount); + Assert.True(command.WasDisposed); + } + } + + [Fact] + public async Task CommandFailure_RollsBackAndReportsKnownNonCommitWithoutRetry() + { + var plan = new MutationPlan + { + ExecuteAsync = (index, _) => index == 1 + ? Task.FromException(new TestDatabaseException("command failure")) + : Task.FromResult(1) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync(() => + service.ReplaceItemsAsync( + "00000011", + [Item(0, "Alpha", "000001"), Item(1, "Beta", "000002")])); + + Assert.False(exception.OutcomeUnknown); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(2, plan.Commands.Count); + Assert.Equal([1, 1], plan.Commands.Select(static command => command.ExecuteCount)); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + } + + [Fact] + public async Task CommandTimeout_AttemptsRollbackButStillQuarantinesOutcome() + { + var plan = new MutationPlan + { + ExecuteAsync = (_, _) => Task.FromException(new TimeoutException("timeout")) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync( + () => service.DeleteAsync("00000011")); + + Assert.True(exception.OutcomeUnknown); + Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + Assert.Equal(1, factory.CreateCount); + Assert.Single(plan.Commands); + Assert.Equal(1, plan.Commands[0].ExecuteCount); + } + + [Fact] + public async Task CommandCancellation_AttemptsRollbackButStillQuarantinesOutcome() + { + var plan = new MutationPlan + { + ExecuteAsync = (_, token) => + Task.FromException(new OperationCanceledException(token)) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync( + () => service.DeleteAsync("00000011")); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + Assert.Equal(1, factory.CreateCount); + } + + [Fact] + public async Task OverallOperationTimeout_CancelsCommandOnceAndDoesNotRetry() + { + var plan = new MutationPlan + { + ExecuteAsync = (_, token) => WaitForCancellationAsync(token) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory, operationTimeoutSeconds: 1)); + + var exception = await Assert.ThrowsAsync( + () => service.DeleteAsync("00000011")); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, factory.CreateCount); + Assert.Single(plan.Commands); + Assert.Equal(1, plan.Commands[0].ExecuteCount); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + } + + [Fact] + public async Task RollbackFailure_ChangesOrdinaryCommandFailureToOutcomeUnknown() + { + var plan = new MutationPlan + { + ExecuteAsync = (_, _) => + Task.FromException(new TestDatabaseException("command failure")), + RollbackAsync = _ => + Task.FromException(new TestDatabaseException("rollback failure")) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync( + () => service.DeleteAsync("00000011")); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + Assert.Equal(1, factory.CreateCount); + } + + [Fact] + public async Task CommitFailure_IsOutcomeUnknownAndNeverIssuesRollbackOrRetry() + { + var plan = new MutationPlan + { + CommitAsync = _ => + Task.FromException(new TestDatabaseException("commit response lost")) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync( + () => service.CreateDefinitionAsync("00000011", "Morning")); + + Assert.True(exception.OutcomeUnknown); + Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(1, factory.CreateCount); + Assert.Single(plan.Commands); + Assert.Equal(1, plan.Commands[0].ExecuteCount); + } + + [Fact] + public async Task CommitCancellation_IsOutcomeUnknownAndNeverIssuesRollback() + { + var plan = new MutationPlan + { + CommitAsync = token => + Task.FromException(new OperationCanceledException(token)) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync( + () => service.CreateDefinitionAsync("00000011", "Morning")); + + Assert.True(exception.OutcomeUnknown); + Assert.Equal(1, plan.CommitCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(1, factory.CreateCount); + } + + [Fact] + public async Task OpenFailure_HasKnownNonCommitAndCreatesNoTransaction() + { + var plan = new MutationPlan + { + OpenAsync = _ => Task.FromException(new TestDatabaseException("open failure")) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync( + () => service.CreateDefinitionAsync("00000011", "Morning")); + + Assert.False(exception.OutcomeUnknown); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(1, plan.OpenCount); + Assert.Equal(0, plan.BeginCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + Assert.Empty(plan.Commands); + Assert.True(plan.ConnectionDisposed); + } + + [Fact] + public async Task BeginFailure_HasKnownNonCommitAndExecutesNoCommand() + { + var plan = new MutationPlan + { + BeginAsync = (_, _) => + Task.FromException(new TestDatabaseException("begin failure")) + }; + var factory = new MutationConnectionFactory(plan); + var service = CreateService(CreateExecutor(factory)); + + var exception = await Assert.ThrowsAsync( + () => service.CreateDefinitionAsync("00000011", "Morning")); + + Assert.False(exception.OutcomeUnknown); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(1, plan.BeginCount); + Assert.Equal(0, plan.RollbackCount); + Assert.Equal(0, plan.CommitCount); + Assert.Empty(plan.Commands); + } + + [Fact] + public void Constructor_RejectsInvalidCommandTimeout() + { + var factory = new MutationConnectionFactory( + new MutationPlan(), + commandTimeoutSeconds: 0); + + Assert.Throws(() => CreateExecutor(factory)); + Assert.Equal(0, factory.CreateCount); + } + + private static OracleNamedPlaylistMutationExecutor CreateExecutor( + MutationConnectionFactory factory, + int operationTimeoutSeconds = 30) => + new( + factory, + new DatabaseResilienceOptions + { + OperationTimeoutSeconds = operationTimeoutSeconds, + MaximumRetryCount = 5, + InitialRetryDelayMilliseconds = 30_000 + }, + new TransientDatabaseErrorDetector(), + command => Assert.IsType(command).BindByName = true); + + private static LegacyNamedPlaylistPersistenceService CreateService( + INamedPlaylistMutationExecutor mutations) => + new(new ForbiddenQueryExecutor(), mutations); + + private static NamedPlaylistStoredItem Item( + int index, + string subject, + string dataCode) => + new( + index, + true, + new LegacySceneSelection("KOSPI", subject, "1열판기본", "현재가", dataCode), + new NamedPlaylistPageState(1, 1)); + + private static void AssertInsert( + MutationFakeCommand command, + int expectedIndex, + string expectedSubject, + string expectedDataCode) + { + Assert.Equal(NamedPlaylistDbCommandKind.InsertItem, CommandKind(command)); + Assert.StartsWith( + "INSERT INTO PLAY_LIST(", + command.CommandText.TrimStart(), + StringComparison.Ordinal); + Assert.Equal(11, command.ParametersSnapshot.Count); + AssertParameter( + command.ParametersSnapshot[0], + "program_code", + "00000011", + DbType.String); + AssertParameter( + command.ParametersSnapshot[1], + "list_text", + $"1^KOSPI^{expectedSubject}^1열판기본^현재가^1/1^{expectedDataCode}", + DbType.String); + foreach (var parameter in command.ParametersSnapshot.Skip(2).Take(8)) + { + Assert.Same(DBNull.Value, parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + Assert.Equal(ParameterDirection.Input, parameter.Direction); + } + + AssertParameter( + command.ParametersSnapshot[10], + "item_index", + expectedIndex, + DbType.Int32); + } + + private static NamedPlaylistDbCommandKind CommandKind(MutationFakeCommand command) + { + var sql = command.CommandText.TrimStart(); + if (sql.StartsWith("INSERT INTO DC_LIST(", StringComparison.Ordinal)) + { + return NamedPlaylistDbCommandKind.InsertDefinition; + } + + if (sql.StartsWith("DELETE FROM PLAY_LIST", StringComparison.Ordinal)) + { + return NamedPlaylistDbCommandKind.DeleteItems; + } + + if (sql.StartsWith("INSERT INTO PLAY_LIST(", StringComparison.Ordinal)) + { + return NamedPlaylistDbCommandKind.InsertItem; + } + + if (sql.StartsWith("DELETE FROM DC_LIST", StringComparison.Ordinal)) + { + return NamedPlaylistDbCommandKind.DeleteDefinition; + } + + throw new InvalidOperationException("Unexpected command SQL."); + } + + private static void AssertParameter( + DbParameter parameter, + string expectedName, + object expectedValue, + DbType expectedType) + { + Assert.Equal(expectedName, parameter.ParameterName); + Assert.Equal(expectedValue, parameter.Value); + Assert.Equal(expectedType, parameter.DbType); + Assert.Equal(ParameterDirection.Input, parameter.Direction); + } + + private static Task WaitForCancellationAsync(CancellationToken token) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + token.Register(() => completion.TrySetCanceled(token)); + return completion.Task; + } + + private sealed class ForbiddenQueryExecutor : IDataQueryExecutor + { + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new InvalidOperationException("A mutation test must not run a read query."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("A mutation test must not run a read query."); + } + + private sealed class MutationConnectionFactory( + MutationPlan plan, + int commandTimeoutSeconds = 15) : IDatabaseConnectionFactory + { + public int CreateCount { get; private set; } + + public List Sources { get; } = []; + + public DbConnection Create(DataSourceKind source) + { + CreateCount++; + Sources.Add(source); + return new MutationFakeConnection(plan); + } + + public int GetCommandTimeoutSeconds(DataSourceKind source) + { + Assert.Equal(DataSourceKind.Oracle, source); + return commandTimeoutSeconds; + } + } + + private sealed class MutationPlan + { + public Func OpenAsync { get; init; } = + _ => Task.CompletedTask; + + public Func>? BeginAsync + { + get; + init; + } + + public Func> ExecuteAsync { get; init; } = + (_, _) => Task.FromResult(1); + + public Func CommitAsync { get; init; } = + _ => Task.CompletedTask; + + public Func RollbackAsync { get; init; } = + _ => Task.CompletedTask; + + public int OpenCount { get; set; } + + public int BeginCount { get; set; } + + public int CommitCount { get; set; } + + public int RollbackCount { get; set; } + + public int SynchronousBeginCount { get; set; } + + public int SynchronousCommitCount { get; set; } + + public int SynchronousRollbackCount { get; set; } + + public IsolationLevel? ObservedIsolationLevel { get; set; } + + public bool ConnectionDisposed { get; set; } + + public bool TransactionDisposed { get; set; } + + public MutationFakeTransaction? Transaction { get; set; } + + public List Commands { get; } = []; + } + + private sealed class MutationFakeConnection(MutationPlan plan) : DbConnection + { + private ConnectionState _state = ConnectionState.Closed; + + [AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => "MutationFake"; + + public override string DataSource => "MutationFake"; + + public override string ServerVersion => "1.0"; + + public override ConnectionState State => _state; + + public override void ChangeDatabase(string databaseName) + { + } + + public override void Close() => _state = ConnectionState.Closed; + + public override void Open() => + throw new InvalidOperationException("Synchronous open is forbidden."); + + public override async Task OpenAsync(CancellationToken cancellationToken) + { + plan.OpenCount++; + await plan.OpenAsync(cancellationToken); + _state = ConnectionState.Open; + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + { + plan.SynchronousBeginCount++; + throw new InvalidOperationException("Synchronous begin is forbidden."); + } + + protected override async ValueTask BeginDbTransactionAsync( + IsolationLevel isolationLevel, + CancellationToken cancellationToken) + { + plan.BeginCount++; + plan.ObservedIsolationLevel = isolationLevel; + if (plan.BeginAsync is not null) + { + return await plan.BeginAsync(isolationLevel, cancellationToken); + } + + var transaction = new MutationFakeTransaction(this, plan, isolationLevel); + plan.Transaction = transaction; + return transaction; + } + + protected override DbCommand CreateDbCommand() + { + var command = new MutationFakeCommand(this, plan, plan.Commands.Count); + plan.Commands.Add(command); + return command; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + plan.ConnectionDisposed = true; + _state = ConnectionState.Closed; + } + + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() + { + plan.ConnectionDisposed = true; + _state = ConnectionState.Closed; + return ValueTask.CompletedTask; + } + } + + private sealed class MutationFakeTransaction( + DbConnection connection, + MutationPlan plan, + IsolationLevel isolationLevel) : DbTransaction + { + public override IsolationLevel IsolationLevel => isolationLevel; + + protected override DbConnection DbConnection => connection; + + public override void Commit() + { + plan.SynchronousCommitCount++; + throw new InvalidOperationException("Synchronous commit is forbidden."); + } + + public override async Task CommitAsync(CancellationToken cancellationToken = default) + { + plan.CommitCount++; + await plan.CommitAsync(cancellationToken); + } + + public override void Rollback() + { + plan.SynchronousRollbackCount++; + throw new InvalidOperationException("Synchronous rollback is forbidden."); + } + + public override async Task RollbackAsync(CancellationToken cancellationToken = default) + { + plan.RollbackCount++; + await plan.RollbackAsync(cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + plan.TransactionDisposed = true; + } + + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() + { + plan.TransactionDisposed = true; + return ValueTask.CompletedTask; + } + } + + private sealed class MutationFakeCommand( + DbConnection connection, + MutationPlan plan, + int commandIndex) : DbCommand + { + private readonly MutationFakeParameterCollection _parameters = new(); + + public bool BindByName { get; set; } + + public int ExecuteCount { get; private set; } + + public int SynchronousExecuteCount { get; private set; } + + public bool WasDisposed { get; private set; } + + public DbTransaction? ObservedTransaction => DbTransaction; + + public IReadOnlyList ParametersSnapshot => + _parameters.Cast().ToArray(); + + [AllowNull] + public override string CommandText { get; set; } = string.Empty; + + public override int CommandTimeout { get; set; } + + public override CommandType CommandType { get; set; } + + public override bool DesignTimeVisible { get; set; } + + public override UpdateRowSource UpdatedRowSource { get; set; } + + protected override DbConnection? DbConnection { get; set; } = connection; + + protected override DbParameterCollection DbParameterCollection => _parameters; + + protected override DbTransaction? DbTransaction { get; set; } + + public override void Cancel() + { + } + + public override int ExecuteNonQuery() + { + SynchronousExecuteCount++; + throw new InvalidOperationException("Synchronous execution is forbidden."); + } + + public override async Task ExecuteNonQueryAsync( + CancellationToken cancellationToken) + { + ExecuteCount++; + return await plan.ExecuteAsync(commandIndex, cancellationToken); + } + + public override object? ExecuteScalar() => throw new NotSupportedException(); + + public override void Prepare() + { + } + + protected override DbParameter CreateDbParameter() => new MutationFakeParameter(); + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + WasDisposed = true; + } + + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() + { + WasDisposed = true; + return ValueTask.CompletedTask; + } + } + + private sealed class MutationFakeParameter : DbParameter + { + public override DbType DbType { get; set; } + + public override ParameterDirection Direction { get; set; } + + public override bool IsNullable { get; set; } + + [AllowNull] + public override string ParameterName { get; set; } = string.Empty; + + public override int Size { get; set; } + + [AllowNull] + public override string SourceColumn { get; set; } = string.Empty; + + public override bool SourceColumnNullMapping { get; set; } + + public override object? Value { get; set; } + + public override void ResetDbType() => DbType = default; + } + + private sealed class MutationFakeParameterCollection : DbParameterCollection + { + private readonly List _parameters = []; + + public override int Count => _parameters.Count; + + public override object SyncRoot => ((ICollection)_parameters).SyncRoot; + + public override int Add(object value) + { + _parameters.Add(RequireParameter(value)); + return _parameters.Count - 1; + } + + public override void AddRange(Array values) + { + foreach (var value in values) + { + Add(value ?? throw new ArgumentNullException(nameof(values))); + } + } + + public override void Clear() => _parameters.Clear(); + + public override bool Contains(object value) => + value is DbParameter parameter && _parameters.Contains(parameter); + + public override bool Contains(string value) => IndexOf(value) >= 0; + + public override void CopyTo(Array array, int index) => + ((ICollection)_parameters).CopyTo(array, index); + + public override IEnumerator GetEnumerator() => _parameters.GetEnumerator(); + + public override int IndexOf(object value) => + value is DbParameter parameter ? _parameters.IndexOf(parameter) : -1; + + public override int IndexOf(string parameterName) => + _parameters.FindIndex(parameter => string.Equals( + parameter.ParameterName, + parameterName, + StringComparison.OrdinalIgnoreCase)); + + public override void Insert(int index, object value) => + _parameters.Insert(index, RequireParameter(value)); + + public override void Remove(object value) + { + if (value is DbParameter parameter) + { + _parameters.Remove(parameter); + } + } + + public override void RemoveAt(int index) => _parameters.RemoveAt(index); + + public override void RemoveAt(string parameterName) + { + var index = IndexOf(parameterName); + if (index >= 0) + { + RemoveAt(index); + } + } + + protected override DbParameter GetParameter(int index) => _parameters[index]; + + protected override DbParameter GetParameter(string parameterName) + { + var index = IndexOf(parameterName); + return index >= 0 ? _parameters[index] : throw new IndexOutOfRangeException(); + } + + protected override void SetParameter(int index, DbParameter value) => + _parameters[index] = value; + + protected override void SetParameter(string parameterName, DbParameter value) + { + var index = IndexOf(parameterName); + if (index >= 0) + { + _parameters[index] = value; + } + else + { + _parameters.Add(value); + } + } + + private static DbParameter RequireParameter(object value) => + value as DbParameter ?? + throw new ArgumentException("A DbParameter is required.", nameof(value)); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs index c0c6eee..f9d8fa2 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs @@ -115,7 +115,7 @@ public sealed class S5025TrustedManualFileDataSourceTests using var directory = new TemporaryDirectory(); await File.WriteAllBytesAsync( Path.Combine(directory.Path, "개인.dat"), - Cp949.GetBytes(content)); + Cp949.GetBytes(content + "\r\n\r\n")); var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => @@ -123,15 +123,16 @@ public sealed class S5025TrustedManualFileDataSourceTests } [Fact] - public async Task ReadAsync_accepts_one_trailing_line_terminator() + public async Task ReadAsync_rejects_missing_empty_terminal_record() { using var directory = new TemporaryDirectory(); - WriteValidFile(Path.Combine(directory.Path, "개인.dat"), trailingNewLine: true); + await File.WriteAllBytesAsync( + Path.Combine(directory.Path, "개인.dat"), + Cp949.GetBytes(ValidContent() + "\r\n")); var source = new S5025TrustedManualFileDataSource(directory.Path); - var rows = await source.ReadAsync(S5025ManualAudience.Individual); - - Assert.Equal(5, rows.Count); + await Assert.ThrowsAsync(() => + source.ReadAsync(S5025ManualAudience.Individual)); } [Fact] @@ -244,9 +245,9 @@ public sealed class S5025TrustedManualFileDataSourceTests source.ReadAsync(S5025ManualAudience.Individual, cancellation.Token)); } - private static void WriteValidFile(string path, bool trailingNewLine = false) + private static void WriteValidFile(string path) { - var content = ValidContent() + (trailingNewLine ? "\r\n" : string.Empty); + var content = ValidContent() + "\r\n\r\n"; File.WriteAllBytes(path, Cp949.GetBytes(content)); } diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileStoreTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileStoreTests.cs new file mode 100644 index 0000000..a988a52 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileStoreTests.cs @@ -0,0 +1,184 @@ +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class S5025TrustedManualFileStoreTests +{ + [Theory] + [InlineData(S5025ManualAudience.Individual, "개인.dat")] + [InlineData(S5025ManualAudience.Foreign, "외국인.dat")] + [InlineData(S5025ManualAudience.Institution, "기관.dat")] + public async Task WriteAsync_round_trips_the_closed_audience_file( + S5025ManualAudience audience, + string expectedFileName) + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + + await store.WriteAsync(audience, Rows()); + + Assert.True(File.Exists(Path.Combine(directory.Path, expectedFileName))); + var actual = await store.ReadAsync(audience); + Assert.Equal(Rows(), actual); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp")); + } + + [Fact] + public async Task WriteAsync_preserves_the_legacy_cp949_terminal_record() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + + await store.WriteAsync(S5025ManualAudience.Individual, Rows()); + + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var cp949 = Encoding.GetEncoding(949); + var bytes = await File.ReadAllBytesAsync(Path.Combine(directory.Path, "개인.dat")); + var text = cp949.GetString(bytes); + Assert.EndsWith("\r\n\r\n", text, StringComparison.Ordinal); + Assert.Equal(5, text.Split("\r\n", StringSplitOptions.None).Length - 2); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + [InlineData(6)] + public async Task WriteAsync_requires_exactly_five_rows(int rowCount) + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + + await Assert.ThrowsAsync(() => + store.WriteAsync( + S5025ManualAudience.Individual, + Rows().Take(rowCount).Concat(Enumerable.Repeat(Rows()[0], Math.Max(0, rowCount - 5))) + .Take(rowCount) + .ToArray())); + } + + [Theory] + [InlineData("bad^name")] + [InlineData("bad\nname")] + public async Task WriteAsync_rejects_delimiter_and_control_characters(string value) + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + var rows = Rows().ToArray(); + rows[0] = rows[0] with { LeftName = value }; + + await Assert.ThrowsAsync(() => + store.WriteAsync(S5025ManualAudience.Individual, rows)); + } + + [Fact] + public async Task WriteAsync_rejects_text_that_cp949_cannot_encode() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + var rows = Rows().ToArray(); + rows[0] = rows[0] with { LeftName = "😀" }; + + await Assert.ThrowsAsync(() => + store.WriteAsync(S5025ManualAudience.Individual, rows)); + } + + [Fact] + public async Task WriteAsync_honors_pre_cancelled_token_without_creating_a_file() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + store.WriteAsync(S5025ManualAudience.Individual, Rows(), cancellation.Token)); + Assert.Empty(Directory.EnumerateFiles(directory.Path)); + } + + [Fact] + public async Task WriteAsync_rejects_unknown_audience_before_creating_a_file() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + + await Assert.ThrowsAsync(() => + store.WriteAsync((S5025ManualAudience)99, Rows())); + Assert.Empty(Directory.EnumerateFiles(directory.Path)); + } + + [Fact] + public async Task WriteAsync_rejects_an_existing_reparse_destination() + { + using var directory = new TemporaryDirectory(); + using var outside = new TemporaryDirectory(); + var target = Path.Combine(outside.Path, "outside.dat"); + await File.WriteAllTextAsync(target, "outside"); + var link = Path.Combine(directory.Path, "개인.dat"); + if (!TryCreateFileSymbolicLink(link, target)) + { + return; + } + + using var store = CreateStore(directory.Path); + await Assert.ThrowsAsync(() => + store.WriteAsync(S5025ManualAudience.Individual, Rows())); + Assert.Equal("outside", await File.ReadAllTextAsync(target)); + } + + private static IReadOnlyList Rows() => + [ + new("삼성전자", "1,234", "SK하이닉스", "-987"), + new("현대차", "2", "기아", "-2"), + new("NAVER", "3", "카카오", "-3"), + new("LG화학", "4", "포스코", "-4"), + new("한화", "5", "셀트리온", "-5") + ]; + + private static S5025TrustedManualFileStore CreateStore(string directory) + { + TrustedManualDirectory.PreparePrivateWritableDirectory(directory); + return new S5025TrustedManualFileStore(directory); + } + + private static bool TryCreateFileSymbolicLink(string link, string target) + { + try + { + File.CreateSymbolicLink(link, target); + return true; + } + catch (Exception exception) when (exception is + UnauthorizedAccessException or IOException or PlatformNotSupportedException) + { + return false; + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException) + { + // Cleanup must not hide the test result. + } + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/TrustedManualDirectoryTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/TrustedManualDirectoryTests.cs new file mode 100644 index 0000000..513775c --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/TrustedManualDirectoryTests.cs @@ -0,0 +1,127 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using System.Runtime.Versioning; +using MBN_STOCK_WEBVIEW.Infrastructure; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class TrustedManualDirectoryTests +{ + [Fact] + public void Prepare_creates_nested_private_directory_with_protected_acl() + { + using var parent = new TemporaryDirectory(); + var target = Path.Combine(parent.Path, "operator", "private"); + + var prepared = TrustedManualDirectory.PreparePrivateWritableDirectory(target); + + Assert.Equal(Path.GetFullPath(target), prepared); + Assert.True(Directory.Exists(prepared)); + if (OperatingSystem.IsWindows()) + { + AssertPrivateAcl(prepared); + } + } + + [SupportedOSPlatform("windows")] + private static void AssertPrivateAcl(string directory) + { + var security = new DirectoryInfo(directory).GetAccessControl( + AccessControlSections.Access); + Assert.True(security.AreAccessRulesProtected); + Assert.DoesNotContain( + security.GetAccessRules(true, true, typeof(SecurityIdentifier)) + .Cast(), + rule => rule.IsInherited); + } + + [Fact] + public void Writable_store_rejects_acl_expanded_to_world_readable() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var directory = new TemporaryDirectory(); + TrustedManualDirectory.PreparePrivateWritableDirectory(directory.Path); + var info = new DirectoryInfo(directory.Path); + var security = info.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.Read, + AccessControlType.Allow)); + info.SetAccessControl(security); + + Assert.Throws(() => + new S5025TrustedManualFileStore(directory.Path)); + } + + [Fact] + public void Writable_lease_pins_directory_against_rename_race() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var parent = new TemporaryDirectory(); + var directory = Path.Combine(parent.Path, "operator"); + TrustedManualDirectory.PreparePrivateWritableDirectory(directory); + using (var store = new S5025TrustedManualFileStore(directory)) + { + Assert.ThrowsAny(() => + Directory.Move(directory, Path.Combine(parent.Path, "redirected"))); + } + + Directory.Move(directory, Path.Combine(parent.Path, "released")); + } + + [Fact] + public void Prepare_rejects_reparse_ancestor_before_creating_target() + { + using var parent = new TemporaryDirectory(); + using var outside = new TemporaryDirectory(); + var link = Path.Combine(parent.Path, "linked"); + try + { + Directory.CreateSymbolicLink(link, outside.Path); + } + catch (Exception exception) when (exception is + UnauthorizedAccessException or IOException or PlatformNotSupportedException) + { + return; + } + + Assert.Throws(() => + TrustedManualDirectory.PreparePrivateWritableDirectory( + Path.Combine(link, "must-not-be-created"))); + Assert.False(Directory.Exists(Path.Combine(outside.Path, "must-not-be-created"))); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException) + { + // Cleanup must not hide the test result. + } + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ViTrustedManualFileStoreTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ViTrustedManualFileStoreTests.cs new file mode 100644 index 0000000..da6ee3e --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ViTrustedManualFileStoreTests.cs @@ -0,0 +1,193 @@ +using System.Text; +using MBN_STOCK_WEBVIEW.Infrastructure; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class ViTrustedManualFileStoreTests +{ + [Fact] + public async Task ReadAsync_imports_the_original_nine_column_cp949_shape() + { + using var directory = new TemporaryDirectory(); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var cp949 = Encoding.GetEncoding(949); + await File.WriteAllBytesAsync( + Path.Combine(directory.Path, "VI발동.dat"), + cp949.GetBytes( + "PKR7005930003^삼성전자^^^^^^^\r\n" + + "PKR7016360000^삼성증권^^^^^^^\r\n")); + using var store = CreateStore(directory.Path); + + var actual = await store.ReadAsync(); + + Assert.Equal( + [ + new ViTrustedManualItem("PKR7005930003", "삼성전자"), + new ViTrustedManualItem("PKR7016360000", "삼성증권") + ], + actual); + } + + [Fact] + public async Task WriteAsync_round_trips_order_duplicates_and_version() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + ViTrustedManualItem[] expected = + [ + new("005930", "삼성전자"), + new("016360", "삼성증권"), + new("005930", "삼성전자") + ]; + + var write = await store.WriteAsync(expected); + + Assert.True(write.Persisted); + Assert.Matches("^[a-f0-9]{64}$", write.Snapshot.RowVersion); + Assert.Equal(expected, await store.ReadAsync()); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp")); + } + + [Fact] + public async Task Empty_list_is_an_original_compatible_no_op() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + + var firstNoOp = await store.WriteAsync(Array.Empty()); + Assert.False(firstNoOp.Persisted); + Assert.False(File.Exists(Path.Combine(directory.Path, "VI발동.dat"))); + Assert.Empty(await store.ReadAsync()); + + var expected = new[] { new ViTrustedManualItem("005930", "삼성전자") }; + var written = await store.WriteAsync(expected); + var secondNoOp = await store.WriteAsync(Array.Empty()); + + Assert.False(secondNoOp.Persisted); + Assert.Equal(written.Snapshot.RowVersion, secondNoOp.Snapshot.RowVersion); + Assert.Equal(expected, await store.ReadAsync()); + } + + [Fact] + public async Task Missing_file_is_an_empty_versioned_first_run_list() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + + var snapshot = await store.ReadSnapshotAsync(); + + Assert.Empty(snapshot.Items); + Assert.Equal( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + snapshot.RowVersion); + } + + [Fact] + public async Task ReadAsync_rejects_nonempty_legacy_extra_columns() + { + using var directory = new TemporaryDirectory(); + await File.WriteAllTextAsync( + Path.Combine(directory.Path, "VI발동.dat"), + "005930^삼성전자^unexpected^^^^^^\r\n"); + using var store = CreateStore(directory.Path); + + await Assert.ThrowsAsync(() => store.ReadAsync()); + } + + [Theory] + [InlineData("", "삼성전자")] + [InlineData("005930", "")] + [InlineData("005^930", "삼성전자")] + [InlineData("005930", "삼성\n전자")] + [InlineData(" 005930", "삼성전자")] + [InlineData("005930", "삼성전자 ")] + [InlineData("005930", "삼성,전자")] + [InlineData("005930", "😀")] + public async Task WriteAsync_rejects_invalid_or_non_cp949_values(string code, string name) + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + + await Assert.ThrowsAsync(() => + store.WriteAsync([new ViTrustedManualItem(code, name)])); + } + + [Fact] + public async Task WriteAsync_caps_the_original_five_row_scene_at_twenty_pages() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + var items = Enumerable.Range(1, ViTrustedManualFileStore.MaximumItemCount + 1) + .Select(index => new ViTrustedManualItem(index.ToString("D6"), $"종목{index}")) + .ToArray(); + + await Assert.ThrowsAsync(() => store.WriteAsync(items)); + } + + [Fact] + public async Task WriteAsync_honors_pre_cancelled_token() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + store.WriteAsync( + [new ViTrustedManualItem("005930", "삼성전자")], + cancellation.Token)); + Assert.Empty(Directory.EnumerateFiles(directory.Path)); + } + + [Fact] + public async Task Canonical_snapshot_version_covers_all_twenty_pages() + { + using var directory = new TemporaryDirectory(); + using var store = CreateStore(directory.Path); + var items = Enumerable.Range(1, ViTrustedManualFileStore.MaximumItemCount) + .Select(index => new ViTrustedManualItem( + $"P{index:D6}", + $"현실적인긴종목명테스트{index}")) + .ToArray(); + + var written = await store.WriteAsync(items); + var snapshot = await store.ReadSnapshotAsync(); + + Assert.True(written.Persisted); + Assert.Equal(100, snapshot.Items.Count); + Assert.Equal(written.Snapshot.RowVersion, snapshot.RowVersion); + Assert.Matches("^[a-f0-9]{64}$", snapshot.RowVersion); + } + + private static ViTrustedManualFileStore CreateStore(string directory) + { + TrustedManualDirectory.PreparePrivateWritableDirectory(directory); + return new ViTrustedManualFileStore(directory); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException) + { + // Cleanup must not hide the test result. + } + } + } +} diff --git a/tests/Web/candle-options-workflow.test.cjs b/tests/Web/candle-options-workflow.test.cjs new file mode 100644 index 0000000..5b48846 --- /dev/null +++ b/tests/Web/candle-options-workflow.test.cjs @@ -0,0 +1,119 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/candle-options-workflow.js"); +const stock = require("../../Web/operator-workflow.js"); +const fixed = require("../../Web/legacy-fixed-workflow.js"); +const industry = require("../../Web/industry-workflow.js"); +const overseas = require("../../Web/overseas-workflow.js"); +const tradingHalt = require("../../Web/trading-halt-workflow.js"); + +const adapters = { stock, fixed, industry, overseas, tradingHalt }; +const options = id => ({ id, fadeDuration: 6, ma5: false, ma20: false }); + +const domesticStock = Object.freeze({ + market: "kospi", + stockName: "삼성전자", + displayName: "삼성전자", + stockCode: "005930", + isNxt: false +}); +const industryItem = Object.freeze({ market: "kospi", name: "전기전자", code: "013" }); +const overseasStock = Object.freeze({ + inputName: "NVIDIA", + symbol: "NAS@NVDA", + nationCode: "US", + fdtc: "1" +}); +const haltedStock = Object.freeze({ + market: "kospi", + stockCode: "005930", + displayName: "삼성전자" +}); + +function entries() { + const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available); + const industryCut = industry.cuts.find(value => value.kind === "candle"); + return [ + stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")), + fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")), + industry.createIndustryPlaylistEntry("kospi", industryCut.id, { + ...options("industry-candle"), + selected: industryItem + }), + overseas.createOverseasStockPlaylistEntry( + overseasStock, + "stock-candle-120d", + options("overseas-candle")), + tradingHalt.createTradingHaltPlaylistEntry( + haltedStock, + "candle-120d", + options("halt-candle")) + ]; +} + +test("global flags map to the exact closed candle graphic type", () => { + assert.equal(workflow.expectedGraphicType(false, false), ""); + assert.equal(workflow.expectedGraphicType(true, false), "MA5"); + assert.equal(workflow.expectedGraphicType(false, true), "MA20"); + assert.equal(workflow.expectedGraphicType(true, true), "MA5,MA20"); + assert.throws(() => workflow.expectedGraphicType("yes", true)); +}); + +test("all five original candle entry sources can be recreated with global flags", () => { + for (const entry of entries()) { + const recreated = workflow.recreateCandleEntry(entry, true, true, adapters); + assert.ok(recreated, entry.operator.source); + assert.equal(recreated.selection.graphicType, "MA5,MA20"); + assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true }); + assert.equal(recreated.enabled, entry.enabled); + } +}); + +test("playlist synchronization changes only s8010 entries and preserves order", () => { + const candles = entries(); + const nonCandle = stock.createStockPlaylistEntry(domesticStock, "basic-current", { + id: "non-candle", + fadeDuration: 6, + ma5: false, + ma20: false + }); + const input = [candles[0], nonCandle, ...candles.slice(1)]; + + const result = workflow.synchronizePlaylist(input, true, false, adapters); + + assert.equal(result.valid, true); + assert.equal(result.changed, true); + assert.equal(result.playlist[1], nonCandle); + assert.deepEqual(result.playlist.map(value => value.id), input.map(value => value.id)); + for (const entry of result.playlist.filter(value => value.builderKey === "s8010")) { + assert.equal(entry.selection.graphicType, "MA5"); + } +}); + +test("an untrusted s8010 entry blocks synchronization instead of being rewritten", () => { + const untrusted = { + id: "unknown-candle", + builderKey: "s8010", + code: "8051", + enabled: true, + fadeDuration: 6, + selection: { graphicType: "" }, + operator: { source: "unknown" } + }; + const result = workflow.synchronizePlaylist([untrusted], true, true, adapters); + assert.equal(result.valid, false); + assert.equal(result.changed, false); + assert.deepEqual(result.invalidIds, ["unknown-candle"]); + assert.equal(result.playlist[0], untrusted); +}); + +test("a second synchronization with identical flags is stable", () => { + const first = workflow.synchronizePlaylist(entries(), true, true, adapters); + const second = workflow.synchronizePlaylist([...first.playlist], true, true, adapters); + assert.equal(first.valid, true); + assert.equal(second.valid, true); + assert.equal(second.changed, false); + second.playlist.forEach((entry, index) => assert.equal(entry, first.playlist[index])); +}); diff --git a/tests/Web/comparison-workflow.test.cjs b/tests/Web/comparison-workflow.test.cjs new file mode 100644 index 0000000..01a6b67 --- /dev/null +++ b/tests/Web/comparison-workflow.test.cjs @@ -0,0 +1,266 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/comparison-workflow.js"); + +const kospi = workflow.getMarketTarget("Kospi"); +const kospi200 = workflow.getMarketTarget("코스피200 지수"); +const futures = workflow.getMarketTarget("futures"); +const wonDollar = workflow.getMarketTarget("WonDollar"); +const samsung = Object.freeze({ + kind: "krx-stock", market: "kospi", stockName: "삼성전자", + stockCode: "005930", displayName: "삼성전자" +}); +const skHynix = Object.freeze({ + kind: "krx-stock", market: "kospi", stockName: "SK하이닉스", + stockCode: "000660", displayName: "SK하이닉스" +}); +const nxtSamsung = Object.freeze({ + kind: "nxt-stock", market: "kospi", stockName: "삼성전자", + stockCode: "005930", displayName: "삼성전자(NXT)" +}); +const apple = Object.freeze({ + kind: "world-stock", inputName: "Apple Inc", displayName: "애플", + symbol: "NAS@AAPL", nation: "US" +}); + +test("comparison inventory pins the exact runtime asset, 24 targets and nine actions", () => { + assert.deepEqual(workflow.runtimeAsset, { + relativePath: "bin/Debug/Res/종목비교.ini", + sha256: "980F74C000EF6A34B85FDAAF6D2756269483637186C91A93548F35C5CFC1B7E4" + }); + assert.equal(workflow.marketTargets.length, 24); + assert.equal(new Set(workflow.marketTargets.map(value => value.target)).size, 24); + assert.deepEqual(workflow.marketTargets.map(value => [value.displayName, value.target]), [ + ["코스피 지수", "Kospi"], ["코스닥 지수", "Kosdaq"], + ["코스피200 지수", "Kospi200"], ["선물 지수", "Futures"], + ["KRX100지수", "Krx100"], ["환율-원달러", "WonDollar"], + ["환율-원엔", "WonYen"], ["환율-원위엔", "WonYuan"], + ["환율-원유로", "WonEuro"], ["해외지수-다우", "Dow"], + ["해외지수-나스닥", "Nasdaq"], ["해외지수-S&P", "Sp500"], + ["해외지수-독일", "GermanyDax"], ["해외지수-영국", "UnitedKingdomFtse"], + ["해외지수-프랑스", "FranceCac"], ["해외지수-일본", "Nikkei"], + ["해외지수-홍콩", "HangSeng"], ["해외지수-대만", "TaiwanWeighted"], + ["해외지수-싱가포르", "SingaporeStraitsTimes"], ["해외지수-태국", "ThailandSet"], + ["해외지수-필리핀", "PhilippinesComposite"], ["해외지수-말레이시아", "MalaysiaKlse"], + ["해외지수-인도네시아", "IndonesiaComposite"], ["해외지수-중국", "ShanghaiComposite"] + ]); + assert.deepEqual(workflow.actions.map(value => value.label), [ + "2열판 예상체결", "2열판", "종목별 비교분석_캔들 그래프", "종목별 비교분석_라인 그래프", + "5일", "1개월", "3개월", "6개월", "12개월" + ]); + assert.deepEqual(workflow.yieldActionIds, [ + "return-5d", "return-1m", "return-3m", "return-6m", "return-12m" + ]); +}); + +test("typed market, KRX, NXT and world targets normalize without heuristic aliases", () => { + assert.equal(workflow.getMarketTarget("코스피200 지수"), kospi200); + assert.equal(workflow.getMarketTarget("sp500").target, "Sp500"); + assert.deepEqual(workflow.normalizeTarget({ + kind: "krx-stock", market: "KOSPI", name: " 삼성전자 ", code: "005930" + }), samsung); + assert.deepEqual(workflow.normalizeTarget({ + kind: "nxt-stock", market: "KOSPI", name: "삼성전자", code: "005930" + }), nxtSamsung); + assert.deepEqual(workflow.normalizeTarget(apple), apple); + assert.equal(workflow.subjectTokenForTarget(kospi200), "Kospi200"); + assert.equal(workflow.subjectTokenForTarget(nxtSamsung), "삼성전자(NXT)"); + assert.equal(workflow.subjectTokenForTarget(apple), "Apple Inc"); + assert.equal(workflow.targetKey(nxtSamsung), "nxt:kospi:005930"); + + assert.equal(workflow.normalizeTarget({ ...kospi, displayName: "코스피" }), null); + assert.equal(workflow.normalizeTarget({ ...samsung, stockName: "삼성,전자" }), null); + assert.equal(workflow.normalizeTarget({ ...nxtSamsung, displayName: "삼성전자" }), null); + assert.equal(workflow.normalizeTarget({ ...apple, nation: "JP" }), null); + assert.equal(workflow.normalizeTarget({ ...apple, symbol: "AAPL;DROP" }), null); +}); + +test("target double-click rotation, swap and clear preserve the original slot rules", () => { + const empty = workflow.clearPair(); + assert.deepEqual(empty, [null, null]); + const first = workflow.rotatePair(empty, samsung); + assert.deepEqual(first, [samsung, null]); + const second = workflow.rotatePair(first, skHynix); + assert.deepEqual(second, [skHynix, samsung]); + assert.deepEqual(workflow.swapPair(second), [samsung, skHynix]); + assert.deepEqual(workflow.rotatePair(second, skHynix), [skHynix, skHynix]); + assert.equal(workflow.normalizePair(first), null); +}); + +test("CURRENT compatibility mirrors supported s8018 and s5032 branches and fails closed", () => { + assert.equal(workflow.isActionAllowed("two-column-current", [kospi, kospi200]), true); + assert.equal(workflow.isActionAllowed("two-column-current", [kospi, samsung]), true); + assert.equal(workflow.isActionAllowed("two-column-current", [kospi, nxtSamsung]), true); + assert.equal(workflow.isActionAllowed("two-column-current", [samsung, nxtSamsung]), true); + assert.equal(workflow.isActionAllowed("two-column-current", [apple, samsung]), true); + assert.equal(workflow.isActionAllowed("two-column-current", [apple, nxtSamsung]), true); + + assert.equal(workflow.getCompatibility("two-column-current", [kospi, apple]).reason, + "mixed-market-and-world-is-unsupported"); + assert.equal(workflow.getCompatibility("two-column-current", [futures, samsung]).reason, + "futures-requires-a-market-target-counterpart"); + assert.equal(workflow.getCompatibility("two-column-current", [futures, futures]).reason, + "two-futures-targets-are-unsupported"); + assert.equal(workflow.isActionAllowed("two-column-current", [futures, kospi]), true); +}); + +test("EXPECTED and graph compatibility expose only combinations the Core can resolve", () => { + assert.equal(workflow.isActionAllowed("two-column-expected", [kospi, samsung]), true); + assert.equal(workflow.isActionAllowed("two-column-expected", [samsung, skHynix]), true); + assert.equal(workflow.isActionAllowed("two-column-expected", [wonDollar, samsung]), false); + assert.equal(workflow.isActionAllowed("two-column-expected", [nxtSamsung, samsung]), false); + assert.equal(workflow.isActionAllowed("two-column-expected", [apple, samsung]), false); + + const futuresExpected = workflow.getCompatibility("two-column-expected", [futures, wonDollar]); + assert.equal(futuresExpected.supported, true); + assert.equal(futuresExpected.effectiveMode, "CURRENT"); + assert.match(futuresExpected.warning, /current s5032/i); + + for (const actionId of ["comparison-candle", "comparison-line", ...workflow.yieldActionIds]) { + assert.equal(workflow.isActionAllowed(actionId, [samsung, skHynix]), true); + assert.equal(workflow.isActionAllowed(actionId, [samsung, nxtSamsung]), false); + assert.equal(workflow.isActionAllowed(actionId, [samsung, apple]), false); + assert.equal(workflow.isActionAllowed(actionId, [kospi, samsung]), false); + } +}); + +test("two-column builders use canonical market subjects and the correct dynamic alias", () => { + const current = workflow.createComparisonPlaylistEntry("two-column-current", [kospi200, samsung], { + id: "current-pair" + }); + assert.equal(current.builderKey, "s8018"); + assert.equal(current.code, "8032"); + assert.deepEqual(current.aliases, ["8032"]); + assert.deepEqual(current.selection, { + groupCode: "INDEX", subject: "Kospi200,삼성전자", + graphicType: "2열판", subtype: "CURRENT", dataCode: "" + }); + + const stockPair = workflow.buildComparisonSelection("two-column-current", [nxtSamsung, apple]); + assert.equal(stockPair.builderKey, "s8018"); + assert.equal(stockPair.selection.groupCode, "STOCK"); + assert.equal(stockPair.selection.subject, "삼성전자(NXT),Apple Inc"); + + const futuresPair = workflow.createComparisonPlaylistEntry("two-column-expected", [wonDollar, futures], { + id: "futures-pair", fadeDuration: 0 + }); + assert.equal(futuresPair.builderKey, "s5032"); + assert.equal(futuresPair.code, "5032"); + assert.equal(futuresPair.selection.subtype, "EXPECTED"); + assert.equal(futuresPair.effectiveMode, "CURRENT"); + assert.equal(futuresPair.fadeDuration, 0); +}); + +test("candle, line and all five return actions create explicit selections in legacy order", () => { + const candle = workflow.createComparisonPlaylistEntry("comparison-candle", [samsung, skHynix], { + id: "candle" + }); + assert.equal(candle.builderKey, "s5026"); + assert.equal(candle.code, "5026"); + assert.deepEqual(candle.selection, { + groupCode: "STOCK", subject: "삼성전자,SK하이닉스", + graphicType: "COMPARISON_CANDLE", subtype: "FiveDays", dataCode: "" + }); + + const line = workflow.createComparisonPlaylistEntry("comparison-line", [samsung, skHynix], { + id: "line" + }); + assert.equal(line.builderKey, "s5087"); + assert.equal(line.selection.graphicType, "COMPARISON_LINE"); + + const batch = workflow.createYieldBatchEntries([samsung, skHynix], { + idPrefix: "pair-yield", fadeDuration: 4 + }); + assert.equal(batch.length, 5); + assert.deepEqual(batch.map(value => value.id), [ + "pair-yield-5d", "pair-yield-1m", "pair-yield-3m", "pair-yield-6m", "pair-yield-12m" + ]); + assert.deepEqual(batch.map(value => value.selection.subtype), [ + "FiveDays", "OneMonth", "ThreeMonths", "SixMonths", "TwelveMonths" + ]); + assert.ok(batch.every(value => value.builderKey === "s5029" && value.code === "5029")); +}); + +test("trusted restore reconstructs selections and rejects stored-entry tampering", () => { + const original = workflow.createComparisonPlaylistEntry("two-column-current", [kospi, nxtSamsung], { + id: "stored", fadeDuration: 7 + }); + assert.ok(workflow.restoreComparisonPlaylistEntry(original)); + assert.equal(workflow.restoreComparisonPlaylistEntry({ ...original, enabled: false }).enabled, false); + assert.equal(workflow.restoreComparisonPlaylistEntry({ ...original, code: "5032" }), null); + assert.equal(workflow.restoreComparisonPlaylistEntry({ + ...original, selection: { ...original.selection, subject: "Kospi,삼성전자" } + }), null); + assert.equal(workflow.restoreComparisonPlaylistEntry({ + ...original, operator: { ...original.operator, assetSha256: "0".repeat(64) } + }), null); + assert.equal(workflow.restoreComparisonPlaylistEntry({ + ...original, operator: { ...original.operator, schemaVersion: 2 } + }), null); + assert.equal(workflow.restoreComparisonPlaylistEntry({ + ...original, + operator: { + ...original.operator, + pair: [{ ...kospi, displayName: "변조" }, nxtSamsung] + } + }), null); +}); + +test("saved pair list normalization is schema-versioned, bounded and duplicate-safe", () => { + const first = workflow.createPairRecord("first", [samsung, skHynix]); + const second = workflow.createPairRecord("second", [kospi, nxtSamsung]); + assert.ok(first); + assert.equal(workflow.createPairRecord("bad id", [samsung, skHynix]), null); + assert.deepEqual(workflow.normalizePairList({ schemaVersion: 2, pairs: [first] }), { + schemaVersion: 1, pairs: [] + }); + + const normalized = workflow.normalizePairList({ + schemaVersion: 1, + pairs: [first, { id: "invalid", pair: [samsung, null] }, first, second] + }); + assert.deepEqual(normalized.pairs.map(value => value.id), ["first", "second"]); + assert.ok(Object.isFrozen(normalized)); + assert.ok(Object.isFrozen(normalized.pairs)); + + const appended = workflow.appendPair(normalized, "third", [apple, nxtSamsung]); + assert.deepEqual(appended.pairs.map(value => value.id), ["first", "second", "third"]); + assert.deepEqual(normalized.pairs.map(value => value.id), ["first", "second"]); + assert.throws(() => workflow.appendPair(appended, first), /already exists/i); +}); + +test("saved pair rows reorder and delete persistently even at the empty-list boundary", () => { + let list = workflow.deleteAllPairs(); + list = workflow.appendPair(list, "first", [samsung, skHynix]); + list = workflow.appendPair(list, "second", [kospi, nxtSamsung]); + list = workflow.appendPair(list, "third", [apple, samsung]); + + assert.deepEqual(workflow.reorderPairList(list, "first", "up").pairs.map(value => value.id), + ["first", "second", "third"]); + list = workflow.reorderPairList(list, "third", -1); + assert.deepEqual(list.pairs.map(value => value.id), ["first", "third", "second"]); + list = workflow.reorderPairList(list, "first", "down"); + assert.deepEqual(list.pairs.map(value => value.id), ["third", "first", "second"]); + list = workflow.deletePair(list, "first"); + assert.deepEqual(list.pairs.map(value => value.id), ["third", "second"]); + list = workflow.deleteAllPairs(); + assert.deepEqual(list, { schemaVersion: 1, pairs: [] }); +}); + +test("unsafe ids, fades, incomplete pairs and unsupported combinations cannot create entries", () => { + assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, null], { + id: "missing" + }), /two comparison targets/i); + assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [kospi, apple], { + id: "unsupported" + }), /mixed-market-and-world/i); + assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, skHynix], { + id: "bad id" + }), /safe comparison playlist id/i); + assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, skHynix], { + id: "bad-fade", fadeDuration: 61 + }), /0 through 60/i); + assert.equal(workflow.getCompatibility("unknown", [samsung, skHynix]).reason, "unknown-action"); +}); diff --git a/tests/Web/expert-workflow.test.cjs b/tests/Web/expert-workflow.test.cjs new file mode 100644 index 0000000..ab39a55 --- /dev/null +++ b/tests/Web/expert-workflow.test.cjs @@ -0,0 +1,310 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/expert-workflow.js"); + +const expert = Object.freeze({ + expertCode: "0001", + expertName: "Analyst A", + source: "oracle" +}); + +function previewRequest(id = "preview-1", maximumItems) { + return workflow.createExpertPreviewRequest(id, expert, maximumItems); +} + +function previewPayload(items, overrides = {}) { + return { + requestId: "preview-1", + selection: { expertCode: "0001", expertName: "Analyst A" }, + retrievedAt: "2026-07-11T23:10:00+09:00", + totalRowCount: items.length, + truncated: false, + items, + ...overrides + }; +} + +const recommendations = Object.freeze([ + Object.freeze({ playIndex: 0, stockCode: "005930", stockName: "Samsung", buyAmount: 70000 }), + Object.freeze({ playIndex: 2, stockCode: "035720", stockName: "Kakao", buyAmount: 42000 }) +]); + +test("workflow pins the Oracle UC6 reader and one exact 5074 action", () => { + assert.deepEqual(workflow.bridgeContract, { + searchRequestType: "search-experts", + searchResultType: "expert-search-results", + searchErrorType: "expert-search-error", + previewRequestType: "request-expert-preview", + previewResultType: "expert-preview-results", + previewErrorType: "expert-preview-error", + defaultMaximumResults: 100, + maximumResults: 500, + maximumQueryLength: 64, + defaultMaximumPreviewItems: 100, + maximumPreviewItems: 100 + }); + assert.equal(workflow.sourceContract.provider, "oracle"); + assert.equal(workflow.sourceContract.expertTable, "EXPERT_LIST"); + assert.equal(workflow.sourceContract.recommendationTable, "RECOMMEND_LIST"); + assert.equal(workflow.sourceContract.pageSize, 5); + assert.equal(workflow.sourceContract.maximumPageCount, 20); + assert.deepEqual(workflow.actions, [{ + id: "five-row-current", + label: "전문가 추천 · 5단 표그래프 · 현재가", + builderKey: "s5074", + code: "5074", + aliases: ["5074"], + pageSize: 5 + }]); +}); + +test("expert search requests are exact, bounded and normalize the original blank load", () => { + assert.deepEqual(workflow.createExpertSearchRequest("search-1", " "), { + requestId: "search-1", + query: "" + }); + assert.deepEqual(workflow.createExpertSearchRequest("search-2", " Kim ", 25), { + requestId: "search-2", + query: "Kim", + maximumResults: 25 + }); + assert.throws(() => workflow.createExpertSearchRequest("bad id", ""), /request id/); + assert.throws(() => workflow.createExpertSearchRequest("safe", "bad\u0000query"), /query/); + assert.throws(() => workflow.createExpertSearchRequest("safe", "", 0), /limited/); + assert.throws(() => workflow.createExpertSearchRequest("safe", "", 501), /limited/); +}); + +test("search responses keep typed code and name and reject ambiguity or reordering", () => { + const request = workflow.createExpertSearchRequest("search-1", "", 5); + const payload = { + requestId: "search-1", + query: "", + retrievedAt: "2026-07-11T23:00:00+09:00", + totalRowCount: 2, + truncated: false, + results: [ + { expertCode: "0001", expertName: "Alpha", source: "oracle" }, + { expertCode: "0002", expertName: "Beta", source: "oracle" } + ] + }; + const normalized = workflow.normalizeExpertSearchResponse(payload, request); + assert.ok(normalized); + assert.deepEqual(normalized.results[0], { + expertCode: "0001", + expertName: "Alpha", + source: "oracle" + }); + assert.ok(Object.isFrozen(normalized.results)); + assert.equal(workflow.normalizeExpertSearchResponse({ + ...payload, + results: [...payload.results].reverse() + }, request), null); + assert.equal(workflow.normalizeExpertSearchResponse({ + ...payload, + results: [payload.results[0], { ...payload.results[1], expertName: "Alpha" }] + }, request), null); + assert.equal(workflow.normalizeExpertSearchResponse({ + ...payload, + results: [payload.results[0], { ...payload.results[1], expertCode: "0001" }] + }, request), null); + assert.equal(workflow.normalizeExpertSearchResponse({ + ...payload, + results: [{ ...payload.results[0], source: "mariaDb" }, payload.results[1]] + }, request), null); + assert.equal(workflow.normalizeExpertSearchResponse({ ...payload, requestId: "stale" }, request), null); +}); + +test("search errors are exact and correlated to the active request", () => { + const request = workflow.createExpertSearchRequest("search-1", "Kim"); + assert.deepEqual(workflow.normalizeExpertSearchError({ + requestId: "search-1", + query: "Kim", + message: "Database unavailable" + }, request), { + requestId: "search-1", + query: "Kim", + message: "Database unavailable" + }); + assert.equal(workflow.normalizeExpertSearchError({ + requestId: "other", + query: "Kim", + message: "Database unavailable" + }, request), null); + assert.equal(workflow.normalizeExpertSearchError({ + requestId: "search-1", + query: "Kim", + message: "bad\nmessage" + }, request), null); +}); + +test("preview requests bind the exact expert code and name", () => { + assert.deepEqual(previewRequest("preview-1", 25), { + requestId: "preview-1", + expertCode: "0001", + expertName: "Analyst A", + maximumItems: 25 + }); + assert.throws(() => workflow.createExpertPreviewRequest("preview", { + expertCode: "0001", + expertName: "Analyst A", + source: "mariaDb" + }), /Oracle expert/); + assert.throws(() => workflow.createExpertPreviewRequest("preview", { + expertCode: "001", + expertName: "Analyst A", + source: "oracle" + }), /Oracle expert/); + assert.throws(() => previewRequest("preview", 101), /limited/); +}); + +test("preview responses preserve PLAYINDEX and reject duplicate or unsafe rows", () => { + const request = previewRequest(); + const normalized = workflow.normalizeExpertPreviewResponse( + previewPayload(recommendations), + request); + assert.ok(normalized); + assert.deepEqual(normalized.items, recommendations); + assert.ok(Object.isFrozen(normalized.items)); + + for (const items of [ + [recommendations[0], { ...recommendations[1], playIndex: 0 }], + [recommendations[0], { ...recommendations[1], stockCode: "005930" }], + [recommendations[0], { ...recommendations[1], stockName: "Samsung" }], + [recommendations[1], recommendations[0]], + [{ ...recommendations[0], buyAmount: 0 }], + [{ ...recommendations[0], buyAmount: 100.5 }], + [{ ...recommendations[0], stockCode: "../005930" }] + ]) { + assert.equal(workflow.normalizeExpertPreviewResponse(previewPayload(items), request), null); + } + assert.equal(workflow.normalizeExpertPreviewResponse( + previewPayload(recommendations, { + selection: { expertCode: "0002", expertName: "Analyst A" } + }), request), null); +}); + +test("only a native-normalized preview can create the closed PAGED_EXPERT selection", () => { + const raw = previewPayload(recommendations); + assert.equal(workflow.createSelectableExpert(expert, raw), null); + const preview = workflow.normalizeExpertPreviewResponse(raw, previewRequest()); + const selected = workflow.createSelectableExpert(expert, preview); + assert.deepEqual(selected, { + expertCode: "0001", + expertName: "Analyst A", + source: "oracle", + itemCount: 2, + previewTruncated: false + }); + assert.deepEqual(workflow.buildExpertSelection(selected), { + builderKey: "s5074", + code: "5074", + aliases: ["5074"], + selection: { + groupCode: "PAGED_EXPERT", + subject: "Analyst A", + graphicType: "INPUT_ORDER", + subtype: "CURRENT", + dataCode: "0001" + } + }); + assert.deepEqual(workflow.calculatePagePreview(selected), { + itemCount: 2, + accessibleItemCount: 2, + pageSize: 5, + pageCount: 1, + maximumPageCount: 20, + isTruncated: false + }); +}); + +test("the 5 by 20 boundary is explicit and empty experts cannot create a cut", () => { + const hundred = Array.from({ length: 100 }, (_, index) => ({ + playIndex: index, + stockCode: String(index + 1).padStart(6, "0"), + stockName: `Stock ${String(index + 1).padStart(3, "0")}`, + buyAmount: 1000 + index + })); + const request = previewRequest("preview-1", 100); + const preview = workflow.normalizeExpertPreviewResponse( + previewPayload(hundred, { truncated: true }), + request); + const selected = workflow.createSelectableExpert(expert, preview); + assert.deepEqual(workflow.calculatePagePreview(selected), { + itemCount: 100, + accessibleItemCount: 100, + pageSize: 5, + pageCount: 20, + maximumPageCount: 20, + isTruncated: true + }); + + const emptyPreview = workflow.normalizeExpertPreviewResponse( + previewPayload([]), + previewRequest()); + const empty = workflow.createSelectableExpert(expert, emptyPreview); + assert.throws(() => workflow.createExpertPlaylistEntry(empty, { id: "empty" }), /no recommendation/); +}); + +test("playlist creation and trusted restore bind every identity and page field", () => { + const preview = workflow.normalizeExpertPreviewResponse( + previewPayload(recommendations), + previewRequest()); + const selected = workflow.createSelectableExpert(expert, preview); + const entry = workflow.createExpertPlaylistEntry(selected, { + id: "expert-1", + fadeDuration: 8 + }); + assert.equal(entry.builderKey, "s5074"); + assert.equal(entry.code, "5074"); + assert.equal(entry.pageSize, 5); + assert.equal(entry.pageCount, 1); + assert.equal(entry.fadeDuration, 8); + assert.equal(entry.operator.schemaVersion, 1); + + const stored = JSON.parse(JSON.stringify(entry)); + stored.enabled = false; + const restored = workflow.restoreExpertPlaylistEntry(stored); + assert.ok(restored); + assert.equal(restored.enabled, false); + assert.equal(restored.selection.dataCode, "0001"); + + for (const tamper of [ + value => { value.builderKey = "s5077"; }, + value => { value.code = "5077"; }, + value => { value.selection.subject = "Other"; }, + value => { value.selection.dataCode = "0002"; }, + value => { value.operator.expert.expertName = "Other"; }, + value => { value.operator.pagePreview.pageCount = 2; }, + value => { value.operator.actionId = "unknown"; }, + value => { value.enabled = "false"; } + ]) { + const candidate = JSON.parse(JSON.stringify(entry)); + tamper(candidate); + assert.equal(workflow.restoreExpertPlaylistEntry(candidate), null); + } + assert.throws(() => workflow.createExpertPlaylistEntry(selected, { id: "bad id" }), /options/); + assert.throws(() => workflow.createExpertPlaylistEntry(selected, { id: "safe", fadeDuration: 61 }), /Fade/); +}); + +test("preview errors are exact and bound to expert identity", () => { + const request = previewRequest(); + assert.deepEqual(workflow.normalizeExpertPreviewError({ + requestId: "preview-1", + selection: { expertCode: "0001", expertName: "Analyst A" }, + message: "Preview unavailable" + }, request), { + requestId: "preview-1", + selection: { expertCode: "0001", expertName: "Analyst A" }, + message: "Preview unavailable" + }); + assert.equal(workflow.normalizeExpertPreviewError({ + requestId: "preview-1", + selection: { expertCode: "0002", expertName: "Analyst A" }, + message: "Preview unavailable" + }, request), null); + assert.equal(workflow.normalizeExpertPreviewError({ + requestId: "preview-1", + selection: { expertCode: "0001", expertName: "Analyst A" }, + message: "bad\u200Bmessage" + }, request), null); +}); diff --git a/tests/Web/industry-workflow.test.cjs b/tests/Web/industry-workflow.test.cjs new file mode 100644 index 0000000..e5e91f3 --- /dev/null +++ b/tests/Web/industry-workflow.test.cjs @@ -0,0 +1,142 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/industry-workflow.js"); + +const kospi = Object.freeze({ market: "kospi", name: "전기전자", code: "013" }); +const kospiSecond = Object.freeze({ market: "kospi", name: "운수장비", code: "015" }); +const kosdaq = Object.freeze({ market: "kosdaq", name: "반도체", code: "027" }); +const kosdaqSecond = Object.freeze({ market: "kosdaq", name: "IT부품", code: "029" }); + +test("industry runtime assets expose 22 exact cut templates per market", () => { + assert.equal(workflow.cuts.length, 22); + assert.equal(new Set(workflow.cuts.map(value => value.id)).size, 22); + assert.equal(workflow.runtimeAssets.kospi.sha256, "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52"); + assert.equal(workflow.runtimeAssets.kosdaq.sha256, "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4"); +}); + +test("native industry rows normalize only bounded KOSPI and KOSDAQ identities", () => { + assert.deepEqual(workflow.normalizeIndustry({ market: "KOSPI", name: " 전기전자 ", code: "013" }), kospi); + assert.equal(workflow.normalizeIndustry({ market: "nxt-kospi", name: "전기전자", code: "013" }), null); + assert.equal(workflow.normalizeIndustry({ market: "kospi", name: "전기\n전자", code: "013" }), null); + assert.equal(workflow.normalizeIndustry({ market: "kospi", name: "전기전자", code: "01-3" }), null); +}); + +test("single, pair, yield and candle actions project closed legacy selections", () => { + const single = workflow.createIndustryPlaylistEntry("kospi", "one-column", { + id: "single", selected: kospi + }); + assert.equal(single.builderKey, "s5001"); + assert.equal(single.code, "5001"); + assert.deepEqual(single.selection, { + groupCode: "INDUSTRY_KOSPI", subject: "전기전자", + graphicType: "1열판", subtype: "CURRENT", dataCode: "" + }); + + const pair = workflow.createIndustryPlaylistEntry("kosdaq", "two-column", { + id: "pair", pair: [kosdaq, kosdaqSecond] + }); + assert.equal(pair.builderKey, "s8018"); + assert.equal(pair.code, "8032"); + assert.equal(pair.selection.subject, "반도체-IT부품"); + + const yieldEntry = workflow.createIndustryPlaylistEntry("kospi", "yield-120일", { + id: "yield", selected: kospi + }); + assert.equal(yieldEntry.builderKey, "s5086"); + assert.equal(yieldEntry.selection.subtype, "OneHundredTwentyDays"); + + const candle = workflow.createIndustryPlaylistEntry("kosdaq", "candle-volume-240일", { + id: "candle", selected: kosdaq + }); + assert.equal(candle.builderKey, "s8010"); + assert.equal(candle.code, "8056"); + assert.equal(candle.selection.groupCode, "KOSDAQ_INDUSTRY"); + assert.equal(candle.selection.subtype, "VOLUME"); +}); + +test("market-wide industry actions need no selected industry and use explicit aliases", () => { + const now = new Date(2026, 6, 11, 10, 0, 0); + const five = workflow.createIndustryPlaylistEntry("kosdaq", "five-row", { id: "five", now }); + assert.equal(five.builderKey, "s5074"); + assert.equal(five.selection.groupCode, "PAGED_DOMESTIC_KOSDAQ"); + assert.equal(five.selection.subtype, "INDUSTRY"); + assert.equal(five.selection.dataCode, "2026-07-11"); + + const square = workflow.createIndustryPlaylistEntry("kosdaq", "square", { id: "square" }); + assert.equal(square.builderKey, "s8001"); + assert.equal(square.code, "8002"); + assert.equal(square.selection.subject, "KOSDAQ"); + + const sector = workflow.createIndustryPlaylistEntry("kospi", "sector", { id: "sector" }); + assert.equal(sector.builderKey, "s5078"); + assert.equal(sector.selection.graphicType, "SECTOR_INDEX"); +}); + +test("industry candles carry and restore the global moving-average flags", () => { + const value = workflow.createIndustryPlaylistEntry("kospi", "candle-120일", { + id: "industry-candle-flags", + selected: kospi, + ma5: true, + ma20: true + }); + assert.equal(value.selection.graphicType, "MA5,MA20"); + assert.deepEqual(value.operator.movingAverages, { ma5: true, ma20: true }); + assert.deepEqual(workflow.restoreIndustryPlaylistEntry(value), value); + assert.equal(workflow.restoreIndustryPlaylistEntry({ + ...value, + selection: { ...value.selection, graphicType: "MA20" } + }), null); +}); + +test("legacy industry candle entries gain closed moving-average metadata on restore", () => { + const value = workflow.createIndustryPlaylistEntry("kosdaq", "candle-volume-20일", { + id: "industry-candle-legacy", + selected: kosdaq + }); + const legacy = { + ...value, + operator: Object.freeze(Object.fromEntries( + Object.entries(value.operator).filter(([key]) => key !== "movingAverages"))) + }; + assert.deepEqual( + workflow.restoreIndustryPlaylistEntry(legacy).operator.movingAverages, + { ma5: false, ma20: false }); +}); + +test("selection prerequisites and cross-market values fail closed", () => { + assert.equal(workflow.isCutAllowed("kospi", "one-column", null, null, null), false); + assert.equal(workflow.isCutAllowed("kospi", "two-column", kospi, kospi, null), false); + assert.equal(workflow.isCutAllowed("kospi", "two-column", null, kospi, kospiSecond), true); + assert.equal(workflow.isCutAllowed("kospi", "five-row", null, null, null), true); + assert.throws(() => workflow.createIndustryPlaylistEntry("kospi", "one-column", { + id: "cross", selected: kosdaq + })); +}); + +test("stored industry entries restore from trusted bindings and reject tampering", () => { + const value = workflow.createIndustryPlaylistEntry("kospi", "two-column", { + id: "stored-pair", pair: [kospi, kospiSecond] + }); + assert.ok(workflow.restoreIndustryPlaylistEntry(value)); + assert.equal(workflow.restoreIndustryPlaylistEntry({ ...value, code: "5032" }), null); + assert.equal(workflow.restoreIndustryPlaylistEntry({ + ...value, + selection: { ...value.selection, subject: "전기전자-화학" } + }), null); + assert.equal(workflow.restoreIndustryPlaylistEntry({ + ...value, + operator: { ...value.operator, assetSha256: "0".repeat(64) } + }), null); +}); + +test("paged industry dates refresh at restore time", () => { + const original = workflow.createIndustryPlaylistEntry("kospi", "five-row", { + id: "paged", now: new Date(2026, 6, 10, 23, 59, 0) + }); + const refreshed = workflow.restoreIndustryPlaylistEntry(original, { + now: new Date(2026, 6, 11, 0, 1, 0) + }); + assert.equal(refreshed.selection.dataCode, "2026-07-11"); +}); diff --git a/tests/Web/legacy-fixed-workflow.test.cjs b/tests/Web/legacy-fixed-workflow.test.cjs new file mode 100644 index 0000000..af653d7 --- /dev/null +++ b/tests/Web/legacy-fixed-workflow.test.cjs @@ -0,0 +1,210 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/legacy-fixed-workflow.js"); + +const knownAliases = new Map([ + ["s5001", new Set(["5001"])], + ["s5016", new Set(["5016"])], + ["s50160", new Set(["50160"])], + ["s5023", new Set(["5023"])], + ["s5024", new Set(["5024"])], + ["s5025", new Set(["5025"])], + ["s5074", new Set(["5074"])], + ["s5077", new Set(["5077"])], + ["s5078", new Set(["5078"])], + ["s5082", new Set(["5082"])], + ["s5083", new Set(["5083"])], + ["s5084", new Set(["5084"])], + ["s5085", new Set(["5085"])], + ["s5086", new Set(["5086"])], + ["s50860", new Set(["50860"])], + ["s5088", new Set(["5088"])], + ["s6001", new Set(["6001"])], + ["s6067", new Set(["6067"])], + ["s8010", new Set(["8035", "8061", "8040", "8046", "8051", "8056"])], + ["s8067", new Set(["8067", "5068", "5070", "5072"])] +]); + +function action(market, section, label) { + const result = workflow.actions.find(value => + value.market === market && value.section === section && value.label === label); + assert.ok(result, `${market}/${section}/${label} action is missing`); + return result; +} + +test("runtime overseas, exchange and index assets expose all 328 leaf actions", () => { + assert.equal(workflow.actions.length, 328); + assert.equal(workflow.actionsForMarket("overseas").length, 78); + assert.equal(workflow.actionsForMarket("exchange").length, 46); + assert.equal(workflow.actionsForMarket("index").length, 204); + assert.equal(workflow.actions.filter(value => value.available).length, 322); + assert.equal(workflow.actions.filter(value => !value.available).length, 6); + assert.equal(new Set(workflow.actions.map(value => value.id)).size, 328); + assert.equal(workflow.runtimeAssets.overseas.sha256, "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"); + assert.equal(workflow.runtimeAssets.exchange.sha256, "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"); + assert.equal(workflow.runtimeAssets.index.sha256, "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6"); +}); + +test("every available fixed action creates a closed registered builder and alias", () => { + for (const value of workflow.actions) { + if (!value.available) { + assert.throws(() => workflow.createFixedPlaylistEntry(value.id, { id: `entry-${value.id}` })); + continue; + } + const entry = workflow.createFixedPlaylistEntry(value.id, { + id: `entry-${value.id}`, + now: new Date(2026, 6, 11, 9, 30, 0) + }); + assert.equal(entry.operator.source, "legacy-fixed-workflow"); + assert.equal(entry.operator.actionId, value.id); + assert.ok(knownAliases.get(entry.builderKey)?.has(entry.code), `${entry.builderKey}/${entry.code}`); + assert.deepEqual(entry.aliases, [entry.code]); + assert.equal(typeof entry.selection.groupCode, "string"); + assert.equal(typeof entry.selection.subject, "string"); + assert.equal(typeof entry.selection.graphicType, "string"); + assert.equal(typeof entry.selection.subtype, "string"); + assert.equal(typeof entry.selection.dataCode, "string"); + } +}); + +test("representative overseas and exchange actions preserve closed native selectors", () => { + const gold = workflow.createFixedPlaylistEntry( + action("overseas", "1열판기본", "국제 금").id, + { id: "gold" }); + assert.equal(gold.builderKey, "s5001"); + assert.deepEqual(gold.selection, { + groupCode: "COMMODITY", subject: "InternationalGold", + graphicType: "1열판기본", subtype: "CURRENT", dataCode: "" + }); + + const fx = workflow.createFixedPlaylistEntry( + action("exchange", "라인 그래프", "원달러_20일").id, + { id: "fx" }); + assert.equal(fx.builderKey, "s50860"); + assert.equal(fx.selection.subject, "WonDollar"); + assert.equal(fx.selection.subtype, "TwentyDays"); + + const map = workflow.createFixedPlaylistEntry( + action("overseas", "글로벌 증시 지도", "미국 증시 지도").id, + { id: "map" }); + assert.equal(map.builderKey, "s8067"); + assert.equal(map.code, "5068"); +}); + +test("index variants map current, candle, trend and paged rows without free text", () => { + const current = workflow.createFixedPlaylistEntry( + action("index", "코스피200", "1열판기본_현재지수").id, + { id: "current" }); + assert.deepEqual(current.selection, { + groupCode: "KOSPI200", subject: "INDEX", + graphicType: "1열판기본", subtype: "CURRENT", dataCode: "" + }); + + const candle = workflow.createFixedPlaylistEntry( + action("index", "선물", "캔들그래프(예상지수)_240일").id, + { id: "candle" }); + assert.equal(candle.builderKey, "s8010"); + assert.equal(candle.code, "8056"); + assert.equal(candle.selection.groupCode, "FUTURES_INDEX"); + assert.equal(candle.selection.subtype, "EXPECTED"); + + const trend = workflow.createFixedPlaylistEntry( + action("index", "매매동향", "외국인 매매동향_라인그래프").id, + { id: "trend" }); + assert.equal(trend.selection.graphicType, "FOREIGN_TRADING_TREND"); + + const page = workflow.createFixedPlaylistEntry( + action("index", "12종목 현재가", "코스닥 52주 신저가").id, + { id: "page", now: new Date(2026, 6, 11, 13, 0, 0) }); + assert.equal(page.builderKey, "s5088"); + assert.equal(page.selection.groupCode, "PAGED_DOMESTIC_KOSDAQ"); + assert.equal(page.selection.subtype, "52_WEEK_LOW"); + assert.equal(page.selection.dataCode, "2026-07-11"); +}); + +test("fixed candle actions carry the original global 5-day and 20-day flags", () => { + const candleAction = workflow.actions.find(value => value.builderKey === "s8010"); + assert.ok(candleAction); + const both = workflow.createFixedPlaylistEntry(candleAction.id, { + id: "candle-both", + ma5: true, + ma20: true + }); + assert.equal(both.selection.graphicType, "MA5,MA20"); + assert.deepEqual(both.operator.movingAverages, { ma5: true, ma20: true }); + assert.deepEqual(workflow.restoreFixedPlaylistEntry(both), both); + + const ma20 = workflow.createFixedPlaylistEntry(candleAction.id, { + id: "candle-ma20", + ma5: false, + ma20: true + }); + assert.equal(ma20.selection.graphicType, "MA20"); + assert.deepEqual(workflow.restoreFixedPlaylistEntry(ma20), ma20); + assert.equal(workflow.restoreFixedPlaylistEntry({ + ...both, + selection: { ...both.selection, graphicType: "" } + }), null); +}); + +test("pre-global-option fixed candle entries restore once through a closed flag migration", () => { + const candleAction = workflow.actions.find(value => value.builderKey === "s8010"); + const legacy = workflow.createFixedPlaylistEntry(candleAction.id, { id: "legacy-candle" }); + const withoutMetadata = { + ...legacy, + operator: Object.freeze(Object.fromEntries( + Object.entries(legacy.operator).filter(([key]) => key !== "movingAverages"))) + }; + const restored = workflow.restoreFixedPlaylistEntry(withoutMetadata); + assert.deepEqual(restored.operator.movingAverages, { ma5: false, ma20: false }); +}); + +test("NXT session and domestic date refresh at restore instead of remaining stale", () => { + const nxtAction = action("index", "5단 표그래프", "코스피_NXT 거래량 상위"); + const original = workflow.createFixedPlaylistEntry(nxtAction.id, { + id: "nxt-page", now: new Date(2026, 6, 11, 9, 0, 0) + }); + assert.equal(original.selection.graphicType, "PRE_MARKET"); + const refreshed = workflow.restoreFixedPlaylistEntry(original, { + now: new Date(2026, 6, 11, 15, 0, 0) + }); + assert.equal(refreshed.selection.graphicType, "AFTER_MARKET"); + + const domesticAction = action("index", "6종목 현재가", "코스피 시가총액"); + const domestic = workflow.createFixedPlaylistEntry(domesticAction.id, { + id: "domestic-page", now: new Date(2026, 6, 10, 23, 50, 0) + }); + const nextDay = workflow.restoreFixedPlaylistEntry(domestic, { + now: new Date(2026, 6, 11, 0, 10, 0) + }); + assert.equal(nextDay.selection.dataCode, "2026-07-11"); +}); + +test("stored fixed entries fail closed after action, mapping, asset or selection tampering", () => { + const value = workflow.createFixedPlaylistEntry( + action("exchange", "1열판기본", "원엔").id, + { id: "stored" }); + assert.ok(workflow.restoreFixedPlaylistEntry(value)); + assert.equal(workflow.restoreFixedPlaylistEntry({ ...value, code: "5006" }), null); + assert.equal(workflow.restoreFixedPlaylistEntry({ + ...value, + selection: { ...value.selection, subject: "WonDollar" } + }), null); + assert.equal(workflow.restoreFixedPlaylistEntry({ + ...value, + operator: { ...value.operator, assetSha256: "0".repeat(64) } + }), null); + assert.equal(workflow.restoreFixedPlaylistEntry({ + ...value, + operator: { ...value.operator, actionId: "fixed-999" } + }), null); +}); + +test("all three VI placeholders preserve the original VILIST five-row outcome", () => { + const vi = workflow.actions.filter(value => value.label === "VI 발동(수동)"); + assert.equal(vi.length, 3); + assert.ok(vi.every(value => !value.available)); + assert.ok(vi.every(value => value.builderKey === "s5074" && value.code === "5074")); +}); diff --git a/tests/Web/manual-financial-bridge-integration.test.cjs b/tests/Web/manual-financial-bridge-integration.test.cjs new file mode 100644 index 0000000..a21073f --- /dev/null +++ b/tests/Web/manual-financial-bridge-integration.test.cjs @@ -0,0 +1,123 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const workflow = require("../../Web/manual-financial-workflow.js"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const bridge = fs.readFileSync( + path.join(repositoryRoot, "MainWindow.ManualFinancial.cs"), + "utf8"); +const playoutBridge = fs.readFileSync( + path.join(repositoryRoot, "MainWindow.Playout.cs"), + "utf8"); +const operatorGate = fs.readFileSync( + path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"), + "utf8"); + +test("native partial exposes every strict workflow request and response", () => { + for (const [name, value] of Object.entries(workflow.bridgeContract)) { + if (name.endsWith("RequestType") || name.endsWith("ResultType") || name.endsWith("ErrorType")) { + assert.match(bridge, new RegExp(`"${value}"`)); + } + } + assert.match(bridge, /private bool TryHandleManualFinancialRequest\(/); + assert.match(bridge, /HasOnlyProperties\(payload, "requestId", "screen", "stock", "record"\)/); + assert.match(bridge, /element\.GetArrayLength\(\) != 5/); + assert.match(bridge, /element\.GetArrayLength\(\) != 4/); + assert.match(bridge, /element\.GetArrayLength\(\) != 6/); +}); + +test("runtime helper composes the typed service with the non-retrying Oracle executor", () => { + assert.match(bridge, + /new LegacyManualFinancialScreenService\(\s*runtime\.Executor,\s*new OracleManualFinancialMutationExecutor\(/s); + assert.match(bridge, /runtime\.ConnectionFactory/); + assert.match(bridge, /runtime\.Options\.Resilience/); +}); + +test("list, load and selection reads own independent correlation lanes", () => { + assert.match(bridge, + /Dictionary _manualFinancialReadLanes/); + assert.match(bridge, + /new ManualFinancialReadRequest\(\s*operation,\s*requestId,/s); + assert.match(bridge, /RegisterManualFinancialRead\(request\)/); + assert.match(bridge, /_manualFinancialReadLanes\[request\.Lane\] = request/); + assert.doesNotMatch(bridge, /_manualFinancialReadCancellation/); + assert.match(bridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/); + assert.ok((bridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 2); + assert.match(bridge, /browserGeneration != Volatile\.Read\(ref _manualFinancialBrowserGeneration\)/); + assert.match(bridge, /!TryCompleteManualFinancialReadIfCurrent\(request\)/); +}); + +test("same-lane supersede emits one correlated terminal cancellation", () => { + assert.match(bridge, /CancelRequest\(superseded\.Cancellation\)/); + assert.match(bridge, /superseded\?\.TryComplete\(\)/); + assert.match(bridge, /"CANCELLED"/); + assert.match(bridge, /newer request replaced it/); + assert.match(bridge, + /Interlocked\.CompareExchange\(ref _terminalState, 1, 0\) == 0/); + assert.match(bridge, + /TryCompleteManualFinancialReadIfCurrent\(request\)[\s\S]*PostMessage\(reply\.MessageType/s); + assert.match(bridge, + /ReferenceEquals\(current, request\) &&[\s\S]*request\.TryComplete\(\)/s); +}); + +test("writes are single-flight and impossible during command, PREPARED, PROGRAM or refresh", () => { + assert.match(bridge, /_manualFinancialWriteGate\.WaitAsync\(0\)/); + assert.match(bridge, /_playoutCommandGate\.WaitAsync\(0, requestToken\)/); + assert.match(bridge, /_databaseActivityGate\.WaitAsync\(0, requestToken\)/); + assert.match(bridge, /CanStartManualFinancialWrite\(\)/); + assert.match(bridge, + /CanStartOperatorMutation\(IsManualFinancialWriteQuarantined\(\)\)/); + assert.match(playoutBridge, /private bool CanStartOperatorMutation\(/); + assert.match(playoutBridge, /new OperatorMutationGateSnapshot\(/); + assert.match(playoutBridge, /IsEngineAvailable: engine is not null/); + assert.match(playoutBridge, /IsWorkflowAvailable: workflow is not null/); + assert.match(playoutBridge, /IsRefreshTaskRunning: _legacyRefreshTask is \{ IsCompleted: false \}/); + for (const condition of [ + "IsCommandAvailable", "IsPlayCompletionPending", "PreparedSceneName", "OnAirSceneName", + "PreparedCutCode", "OnAirCutCode", "IsRefreshActive", "IsRefreshTaskRunning" + ]) { + assert.match(operatorGate, new RegExp(`snapshot\\.${condition}`)); + } + assert.match(bridge, /"PLAYOUT_ACTIVE"/); +}); + +test("OutcomeUnknown and browser correlation loss latch a process-lifetime no-retry quarantine", () => { + assert.match(bridge, /catch \(ManualFinancialMutationException exception\)/); + assert.match(bridge, /if \(exception\.OutcomeUnknown\)/); + assert.match(bridge, + /Interlocked\.CompareExchange\(ref _manualFinancialWriteQuarantined, 1, 0\)/); + assert.match(bridge, /retryable = false/); + assert.match(bridge, /rolled back and was not retried/i); + assert.doesNotMatch(bridge, /for\s*\([^)]*retry|while\s*\([^)]*retry/i); +}); + +test("browser, playout and shutdown hooks cancel correlation without clearing quarantine", () => { + assert.match(bridge, /private void InvalidateManualFinancialBrowserRequests\(\)/); + assert.match(bridge, /Interlocked\.Increment\(ref _manualFinancialBrowserGeneration\)/); + assert.match(bridge, /Volatile\.Read\(ref _manualFinancialWriteInFlight\) != 0/); + assert.match(bridge, + /Interlocked\.Exchange\(ref _manualFinancialWriteCancellation, null\)/); + assert.match(bridge, /private void CancelManualFinancialReadsForPlayout\(\)/); + assert.match(bridge, /private void ShutdownManualFinancialRuntime\(\)/); + assert.ok((bridge.match(/SnapshotManualFinancialReads\(clear: true\)/g) || []).length >= 2); + assert.match(bridge, + /CancelManualFinancialReadsForPlayout\(\)[\s\S]*SnapshotManualFinancialReads\(clear: false\)/s); +}); + +test("selection and restore boundaries re-read row version before exposing a cut", () => { + assert.match(bridge, /var snapshot = await service\.GetAsync\(identity, cancellationToken\)/); + assert.match(bridge, /EnsureManualFinancialRowVersion\(snapshot, rowVersion\)/); + assert.match(bridge, /ResolveManualFinancialStockAsync\(/); + assert.match(bridge, /ManualFinancialCutContracts\.CreateSelection\(snapshot, verified\)/); + assert.match(bridge, /"manual-financial-selection-results"/); +}); + +test("successful writes return the real transaction receipt fields", () => { + assert.match(bridge, /operationId = receipt\.OperationId\.ToString\("N"/); + assert.match(bridge, /receipt\.CommittedAt/); + assert.match(bridge, /receipt\.AffectedRows/); +}); diff --git a/tests/Web/manual-financial-ui.test.cjs b/tests/Web/manual-financial-ui.test.cjs new file mode 100644 index 0000000..5a32107 --- /dev/null +++ b/tests/Web/manual-financial-ui.test.cjs @@ -0,0 +1,372 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/manual-financial-workflow.js"); +const ui = require("../../Web/manual-financial-ui.js"); + +const rowVersion = "A".repeat(64); +const STOCK_RESULT = "stock-search-results"; + +function quarters() { + return [ + { quarter: "1Q", value: 10 }, + { quarter: "2Q", value: -20 }, + { quarter: "3Q", value: 30 }, + { quarter: "4Q", value: 40 }, + { quarter: "5Q", value: 50 }, + { quarter: "6Q", value: 60 } + ]; +} + +function records() { + return { + "revenue-composition": { + kind: "revenue-composition", + stockName: "Alpha", + baseDate: "2026-06", + slices: [ + { label: "Semiconductor", percentageText: "50.0", percentage: 50 }, + { label: "Service", percentageText: "-", percentage: 0 }, + null, + { label: "Overseas", percentageText: "25", percentage: 25 }, + null + ] + }, + "growth-metrics": { + kind: "growth-metrics", + stockName: "Alpha", + salesGrowth: [1, 2.5, null, -4], + operatingProfitGrowth: [5, 6, 7, 8], + netAssetGrowth: [9, null, 11, 12], + netIncomeGrowth: [13, 14, 15, 16], + periods: ["1Q", "2Q", "3Q", "4Q"] + }, + sales: { + kind: "sales", + stockName: "Alpha", + quarters: quarters() + }, + "operating-profit": { + kind: "operating-profit", + stockName: "Alpha", + quarters: quarters().map(item => ({ ...item, value: item.value * 2 })) + } + }; +} + +function createHarness() { + const posted = []; + const entries = []; + const toasts = []; + const logs = []; + let ordinal = 0; + const controller = ui.createController({ + postNative(type, payload) { + posted.push({ type, payload }); + }, + appendPlaylistEntry(entry) { + entries.push(entry); + return true; + }, + isLocked() { + return false; + }, + getSelectedStock() { + return { market: "kospi", source: "oracle", name: "Alpha", code: "000001" }; + }, + showToast(message) { + toasts.push(message); + }, + addLog(message) { + logs.push(message); + }, + createId() { + ordinal += 1; + return `manual-ui-${ordinal}`; + } + }); + return { controller, posted, entries, toasts, logs }; +} + +test("all four GraphE records round-trip through pure form conversion", () => { + for (const [screen, record] of Object.entries(records())) { + const form = ui.recordToForm(screen, record); + assert.ok(form, screen); + assert.deepEqual(ui.formToRecord(screen, form), workflow.normalizeRecord(screen, record)); + } + + const revenue = ui.recordToForm("revenue-composition", records()["revenue-composition"]); + assert.equal(ui.formToRecord("revenue-composition", { + ...revenue, + slice3Label: "Only label", + slice3Percentage: "" + }), null); + + const growth = ui.recordToForm("growth-metrics", records()["growth-metrics"]); + assert.equal(ui.formToRecord("growth-metrics", { + ...growth, + salesGrowth2: "not-a-number" + }), null); + + const sales = ui.recordToForm("sales", records().sales); + assert.equal(ui.formToRecord("sales", { ...sales, value4: "10.5" }), null); + assert.equal(ui.resolveScreen("manual-operating-profit"), "operating-profit"); + assert.equal(ui.resolveScreen({ id: "manual-revenue-mix" }), "revenue-composition"); + assert.equal(ui.resolveScreen({ screen: "growth-metrics" }), "growth-metrics"); +}); + +test("controller accepts only the pending list correlation and ignores shared stock traffic", () => { + const harness = createHarness(); + assert.equal(harness.controller.open({ id: "manual-sales" }), true); + assert.equal(harness.posted.length, 1); + assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType); + const request = harness.posted[0].payload; + assert.deepEqual(request, { + requestId: "manual-ui-1", + screen: "sales", + query: "", + maximumResults: workflow.bridgeContract.defaultMaximumResults + }); + assert.equal(harness.controller.render().pending.list, request.requestId); + + const payload = { + requestId: request.requestId, + screen: "sales", + query: "", + retrievedAt: "2026-07-12T10:00:00+09:00", + totalRowCount: 1, + truncated: false, + items: [{ + stockName: "Alpha", + rowVersion, + record: records().sales + }] + }; + assert.equal(harness.controller.handleMessage( + workflow.bridgeContract.listResultType, + { ...payload, requestId: "stale-list" }), false); + assert.equal(harness.controller.render().pending.list, request.requestId); + assert.equal(harness.controller.render().itemCount, 0); + + assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, payload), true); + assert.equal(harness.controller.render().pending.list, undefined); + assert.equal(harness.controller.render().itemCount, 1); + assert.equal(harness.controller.render().status, "ready"); + + assert.equal(harness.controller.handleMessage(STOCK_RESULT, { + requestId: "another-component", + query: "Alpha", + retrievedAt: "2026-07-12T10:01:00+09:00", + totalRowCount: 0, + truncated: false, + results: [] + }), false); + assert.equal(harness.controller.setLocked(true).locked, true); + assert.equal(harness.controller.setLocked(false).locked, false); +}); + +class FakeElement { + constructor(tagName, ownerDocument) { + this.tagName = tagName.toUpperCase(); + this.ownerDocument = ownerDocument; + this.children = []; + this.attributes = new Map(); + this.listeners = new Map(); + this.dataset = {}; + this.textContent = ""; + this.value = ""; + this.hidden = false; + this.disabled = false; + this.readOnly = false; + this.className = ""; + } + + append(...values) { + for (const value of values) this.appendChild(value); + } + + appendChild(value) { + this.children.push(value); + return value; + } + + replaceChildren(...values) { + this.children = []; + this.append(...values); + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + addEventListener(name, callback) { + this.listeners.set(name, callback); + } +} + +class FakeDocument { + constructor() { + this.body = new FakeElement("body", this); + } + + createElement(tagName) { + return new FakeElement(tagName, this); + } +} + +function findByClass(node, className) { + if (String(node.className || "").split(/\s+/).includes(className)) return node; + for (const child of node.children || []) { + const found = findByClass(child, className); + if (found) return found; + } + return null; +} + +test("mount creates the isolated text-only dialog and exposes exactly the integration methods", () => { + const harness = createHarness(); + const document = new FakeDocument(); + const overlay = harness.controller.mount(document.body); + assert.equal(overlay.tagName, "DIV"); + assert.equal(overlay.className, "mfui-overlay"); + assert.equal(document.body.children.length, 1); + assert.deepEqual(Object.keys(harness.controller), [ + "mount", "open", "handleMessage", "render", "setLocked" + ]); + assert.equal(harness.controller.open("revenue-composition"), true); + assert.equal(overlay.hidden, false); + assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType); +}); + +test("fresh load, exact stock revalidation and native selection create one playable entry", () => { + const harness = createHarness(); + const document = new FakeDocument(); + const overlay = harness.controller.mount(document.body); + harness.controller.open("sales"); + const listRequest = harness.posted.at(-1).payload; + assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, { + requestId: listRequest.requestId, + screen: "sales", + query: "", + retrievedAt: "2026-07-12T10:00:00+09:00", + totalRowCount: 1, + truncated: false, + items: [{ stockName: "Alpha", rowVersion, record: records().sales }] + }), true); + + const listRow = findByClass(overlay, "mfui-list-row"); + assert.ok(listRow); + listRow.listeners.get("click")(); + const loadCall = harness.posted.at(-1); + assert.equal(loadCall.type, workflow.bridgeContract.loadRequestType); + assert.equal(harness.controller.handleMessage(workflow.bridgeContract.loadResultType, { + requestId: loadCall.payload.requestId, + screen: "sales", + retrievedAt: "2026-07-12T10:01:00+09:00", + snapshot: { stockName: "Alpha", rowVersion, record: records().sales } + }), true); + assert.equal(harness.controller.render().hasSnapshot, true); + + const playlistButton = findByClass(overlay, "mfui-accent"); + assert.ok(playlistButton); + playlistButton.listeners.get("click")(); + const truncatedSearch = harness.posted.at(-1); + assert.equal(truncatedSearch.type, "search-stocks"); + harness.controller.handleMessage(STOCK_RESULT, { + requestId: truncatedSearch.payload.requestId, + query: "Alpha", + retrievedAt: "2026-07-12T10:02:00+09:00", + totalRowCount: 1, + truncated: true, + results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }] + }); + assert.equal(harness.entries.length, 0); + assert.equal(harness.controller.render().stockStatus, "error"); + + playlistButton.listeners.get("click")(); + const stockCall = harness.posted.at(-1); + harness.controller.handleMessage(STOCK_RESULT, { + requestId: stockCall.payload.requestId, + query: "Alpha", + retrievedAt: "2026-07-12T10:03:00+09:00", + totalRowCount: 1, + truncated: false, + results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }] + }); + const selectionCall = harness.posted.at(-1); + assert.equal(selectionCall.type, workflow.bridgeContract.selectionRequestType); + const profile = workflow.getScreenDefinition("sales"); + harness.controller.handleMessage(workflow.bridgeContract.selectionResultType, { + requestId: selectionCall.payload.requestId, + screen: "sales", + stockName: "Alpha", + rowVersion, + builderKey: profile.builderKey, + code: profile.code, + aliases: [profile.code], + selection: { + groupCode: "KOSPI", + subject: "Alpha", + graphicType: profile.graphicType, + subtype: "", + dataCode: "" + }, + currentPage: 1, + totalPages: 1 + }); + assert.equal(harness.entries.length, 1); + assert.equal(workflow.isPlaylistEntryPlayable(harness.entries[0]), true); + assert.equal(harness.entries[0].code, "5080"); +}); + +test("UI source uses DOM construction and documents the isolated app integration hook", () => { + const source = fs.readFileSync( + path.join(__dirname, "..", "..", "Web", "manual-financial-ui.js"), + "utf8"); + assert.match(source, /App integration hook/); + assert.match(source, /createElement/); + assert.doesNotMatch(source, /\.innerHTML\s*=/); + assert.doesNotMatch(source, /insertAdjacentHTML/); +}); + +test("pending writes correlate exactly and OutcomeUnknown latches process quarantine", () => { + const coordinator = ui.createPendingCoordinator(); + const expected = { + requestId: "write-1", + screen: "sales", + stockName: "Alpha", + expectedRowVersion: rowVersion + }; + coordinator.set("write", expected, { operation: "delete-one" }); + + const unknown = { + requestId: "write-1", + operation: "delete-one", + screen: "sales", + stockName: "Alpha", + code: "OUTCOME_UNKNOWN", + message: "Commit result is unknown", + retryable: false, + outcomeUnknown: true + }; + assert.equal(coordinator.acceptMutationError({ + ...unknown, + requestId: "stale-write" + }).status, "stale"); + assert.equal(coordinator.snapshot().write, "write-1"); + assert.equal(ui.getProcessWriteQuarantine().active, false); + + const accepted = coordinator.acceptMutationError(unknown); + assert.equal(accepted.status, "accepted"); + assert.equal(accepted.value.outcomeUnknown, true); + assert.equal(coordinator.snapshot().write, undefined); + assert.equal(ui.getProcessWriteQuarantine().active, true); + assert.match(ui.getProcessWriteQuarantine().message, /unknown/i); + + const laterController = createHarness().controller; + assert.equal(laterController.render().writeQuarantined, true); + assert.equal(laterController.render().canWrite, false); +}); diff --git a/tests/Web/manual-financial-workflow.test.cjs b/tests/Web/manual-financial-workflow.test.cjs new file mode 100644 index 0000000..bb3256f --- /dev/null +++ b/tests/Web/manual-financial-workflow.test.cjs @@ -0,0 +1,740 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/manual-financial-workflow.js"); + +const version = "A".repeat(64); + +function quarters() { + return [ + { quarter: "1Q", value: 10 }, + { quarter: "2Q", value: -20 }, + { quarter: "3Q", value: 30 }, + { quarter: "4Q", value: 40 }, + { quarter: "5Q", value: 50 }, + { quarter: "6Q", value: 60 } + ]; +} + +function record(screen, stockName = "Alpha") { + switch (screen) { + case "revenue-composition": + return { + kind: screen, + stockName, + baseDate: "2026-06", + slices: [ + { label: "Semiconductor", percentageText: "50.0", percentage: 50 }, + { label: "Service", percentageText: "-", percentage: 0 }, + { label: "Overseas", percentageText: "25", percentage: 25 }, + null, + null + ] + }; + case "growth-metrics": + return { + kind: screen, + stockName, + salesGrowth: [1, 2, 3, 4], + operatingProfitGrowth: [5, 6, null, 8], + netAssetGrowth: [9, 10, 11, 12], + netIncomeGrowth: [13, 14, 15, 16], + periods: ["1Q", "2Q", "3Q", "4Q"] + }; + case "sales": + case "operating-profit": + return { kind: screen, stockName, quarters: quarters() }; + default: + throw new Error("bad screen"); + } +} + +function listRequest(screen = "sales") { + return workflow.createListRequest("list-1", screen, "Alpha", 25); +} + +function listPayload(screen = "sales", records = [record(screen)]) { + return { + requestId: "list-1", + screen, + query: "Alpha", + retrievedAt: "2026-07-12T00:10:00+09:00", + totalRowCount: records.length, + truncated: false, + items: records.map((item, index) => ({ + stockName: item.stockName, + rowVersion: String.fromCharCode(65 + index).repeat(64), + record: item + })) + }; +} + +function trustedSnapshot(screen = "sales", stockName = "Alpha") { + const request = workflow.createListRequest("list-1", screen, "Alpha", 25); + const response = workflow.normalizeListResponse( + listPayload(screen, [record(screen, stockName)]), + request); + assert.ok(response); + return response.items[0]; +} + +function stockPayload(results = [{ + market: "kospi", + source: "oracle", + name: "Alpha", + code: "000001" +}]) { + return { + requestId: "stock-1", + query: "Alpha", + retrievedAt: "2026-07-12T00:11:00+09:00", + totalRowCount: results.length, + truncated: false, + results + }; +} + +function verifiedStock(recordValue = record("sales"), results) { + const response = workflow.normalizeStockSearchResponse(stockPayload(results)); + assert.ok(response); + const verified = workflow.verifyStockForRecord( + recordValue, + response, + "kospi", + "000001"); + assert.ok(verified); + return verified; +} + +function trustedSelection(snapshot, stock, requestId = "selection-1") { + const request = workflow.createSelectionRequest(requestId, snapshot, stock); + const profile = workflow.getScreenDefinition(snapshot.screen); + const groupCode = { + kospi: "KOSPI", + kosdaq: "KOSDAQ", + "nxt-kospi": "NXT_KOSPI", + "nxt-kosdaq": "NXT_KOSDAQ" + }[stock.market]; + const response = workflow.normalizeSelectionResponse({ + requestId, + screen: snapshot.screen, + stockName: snapshot.stockName, + rowVersion: snapshot.rowVersion, + builderKey: profile.builderKey, + code: profile.code, + aliases: [profile.code], + selection: { + groupCode, + subject: snapshot.stockName, + graphicType: profile.graphicType, + subtype: "", + dataCode: "" + }, + currentPage: 1, + totalPages: 1 + }, request); + assert.ok(response); + return response; +} + +function trustedLoad(snapshot, requestId = "load-1") { + const request = workflow.createLoadRequest(requestId, snapshot.screen, snapshot.stockName); + const response = workflow.normalizeLoadResponse({ + requestId, + screen: snapshot.screen, + retrievedAt: "2026-07-12T00:20:00+09:00", + snapshot: { + stockName: snapshot.stockName, + rowVersion: snapshot.rowVersion, + record: snapshot.record + } + }, request); + assert.ok(response); + return response; +} + +test("workflow pins GraphE Oracle tables, CRUD bridge and four scene actions", () => { + assert.deepEqual(workflow.bridgeContract, { + requestErrorType: "manual-financial-request-error", + listRequestType: "request-manual-financial-list", + listResultType: "manual-financial-list-results", + listErrorType: "manual-financial-list-error", + loadRequestType: "request-manual-financial-load", + loadResultType: "manual-financial-load-results", + loadErrorType: "manual-financial-load-error", + selectionRequestType: "request-manual-financial-selection", + selectionResultType: "manual-financial-selection-results", + selectionErrorType: "manual-financial-selection-error", + createRequestType: "create-manual-financial-record", + updateRequestType: "update-manual-financial-record", + deleteRequestType: "delete-manual-financial-record", + deleteAllRequestType: "delete-all-manual-financial-records", + mutationResultType: "manual-financial-mutation-result", + mutationErrorType: "manual-financial-mutation-error", + defaultMaximumResults: 200, + maximumResults: 1000, + maximumQueryLength: 64 + }); + assert.equal(workflow.sourceContract.originalForm, "Form/GraphE.cs"); + assert.equal(workflow.sourceContract.provider, "oracle"); + assert.equal(workflow.sourceContract.operatorSchemaVersion, 1); + assert.equal(workflow.sourceContract.restoreRequiresNativeLoad, true); + assert.equal(workflow.sourceContract.rawNamedPlaylistRowPlayable, false); + assert.equal(workflow.sourceContract.writeSafety.outcomeUnknownAutomaticRetry, false); + assert.deepEqual(workflow.actions.map(action => [action.screen, action.builderKey, action.code]), [ + ["revenue-composition", "s5076", "5076"], + ["growth-metrics", "s5079", "5079"], + ["sales", "s5080", "5080"], + ["operating-profit", "s5081", "5081"] + ]); +}); + +test("generic native request errors are exact and operation-correlated", () => { + const error = { + requestId: "", + operation: "load", + code: "INVALID_REQUEST", + message: "Invalid screen." + }; + assert.deepEqual(workflow.normalizeRequestError(error, "load"), error); + assert.equal(workflow.normalizeRequestError(error, "list"), null); + assert.equal(workflow.normalizeRequestError({ ...error, extra: true }, "load"), null); + assert.equal(workflow.normalizeRequestError({ ...error, requestId: "bad id" }, "load"), null); + assert.equal(workflow.normalizeRequestError({ ...error, operation: "unknown" }), null); + assert.equal(workflow.normalizeRequestError({ ...error, code: "OTHER" }), null); + assert.equal(workflow.normalizeRequestError({ ...error, message: "bad\nmessage" }), null); +}); + +test("all closed records serialize exactly to the existing underscore loader shapes", () => { + assert.deepEqual(workflow.serializeRecordForStorage( + "revenue-composition", + record("revenue-composition")), { + table: "INPUT_PIE", + columns: ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"], + values: ["Alpha", "2026-06", "Semiconductor_50.0", "Service_-", "Overseas_25", "", ""] + }); + assert.deepEqual(workflow.serializeRecordForStorage( + "growth-metrics", + record("growth-metrics")), { + table: "INPUT_GROW", + columns: ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"], + values: ["Alpha", "1_2_3_4", "5_6__8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q"] + }); + assert.deepEqual(workflow.serializeRecordForStorage("sales", record("sales")).values, [ + "Alpha", "1Q_10", "2Q_-20", "3Q_30", "4Q_40", "5Q_50", "6Q_60" + ]); + assert.equal(workflow.serializeRecordForStorage( + "operating-profit", + record("operating-profit")).table, "INPUT_PROFIT"); +}); + +test("closed DTO validation rejects malformed counts, separators and unsafe numbers", () => { + const revenue = record("revenue-composition"); + assert.equal(workflow.normalizeRecord("revenue-composition", { + ...revenue, + slices: revenue.slices.slice(0, 4) + }), null); + assert.equal(workflow.normalizeRecord("revenue-composition", { + ...revenue, + slices: [{ label: "bad_label", percentageText: "50", percentage: 50 }, ...revenue.slices.slice(1)] + }), null); + assert.equal(workflow.normalizeRecord("revenue-composition", { + ...revenue, + slices: [{ label: "A", percentageText: "50", percentage: 49 }, ...revenue.slices.slice(1)] + }), null); + assert.equal(workflow.normalizeRecord("growth-metrics", { + ...record("growth-metrics"), + salesGrowth: [1, 2, Number.POSITIVE_INFINITY, 4] + }), null); + assert.equal(workflow.normalizeRecord("sales", { + ...record("sales"), + quarters: quarters().slice(1) + }), null); + assert.equal(workflow.normalizeRecord("sales", { + ...record("sales"), + stockName: "bad\nname" + }), null); +}); + +test("list requests normalize blank/search text and enforce exact screen bounds", () => { + assert.deepEqual(workflow.createListRequest("list-1", "sales", " "), { + requestId: "list-1", + screen: "sales", + query: "" + }); + assert.deepEqual(listRequest(), { + requestId: "list-1", + screen: "sales", + query: "Alpha", + maximumResults: 25 + }); + assert.throws(() => workflow.createListRequest("bad id", "sales"), /request id/); + assert.throws(() => workflow.createListRequest("list", "unknown"), /screen/); + assert.throws(() => workflow.createListRequest("list", "sales", "", 1001), /limited/); +}); + +test("native list responses create trusted snapshots and reject duplicate or reordered names", () => { + const request = workflow.createListRequest("list-1", "sales", "Alpha", 25); + const payload = listPayload("sales", [record("sales", "Alpha"), record("sales", "Beta")]); + const normalized = workflow.normalizeListResponse(payload, request); + assert.ok(normalized); + assert.equal(normalized.items[0].rowVersion, version); + assert.ok(Object.isFrozen(normalized.items)); + + assert.equal(workflow.normalizeListResponse({ + ...payload, + items: [...payload.items].reverse() + }, request), null); + assert.equal(workflow.normalizeListResponse(listPayload("sales", [ + record("sales", "Alpha"), record("sales", "ALPHA") + ]), request), null); + assert.equal(workflow.normalizeListResponse({ ...payload, requestId: "stale" }, request), null); + assert.equal(workflow.normalizeListResponse({ + ...payload, + items: [{ ...payload.items[0], rowVersion: "bad" }, payload.items[1]] + }, request), null); +}); + +test("list errors are exact and correlated", () => { + const request = listRequest(); + assert.deepEqual(workflow.normalizeListError({ + requestId: "list-1", + screen: "sales", + query: "Alpha", + message: "Database unavailable" + }, request), { + requestId: "list-1", + screen: "sales", + query: "Alpha", + message: "Database unavailable" + }); + assert.equal(workflow.normalizeListError({ + requestId: "other", + screen: "sales", + query: "Alpha", + message: "Database unavailable" + }, request), null); +}); + +test("load responses are exact native refreshes and never trust a stale request", () => { + const snapshot = trustedSnapshot("sales"); + const request = workflow.createLoadRequest("load-1", "sales", "Alpha"); + assert.deepEqual(request, { + requestId: "load-1", + screen: "sales", + stockName: "Alpha" + }); + const loaded = trustedLoad(snapshot); + assert.deepEqual(loaded.snapshot.record, snapshot.record); + assert.equal(workflow.normalizeLoadResponse({ + requestId: "load-1", + screen: "sales", + retrievedAt: "2026-07-12T00:20:00+09:00", + snapshot: { + stockName: "Beta", + rowVersion: snapshot.rowVersion, + record: record("sales", "Beta") + } + }, request), null); + assert.deepEqual(workflow.normalizeLoadError({ + requestId: "load-1", + screen: "sales", + stockName: "Alpha", + code: "NOT_FOUND", + message: "Missing", + retryable: false + }, request).code, "NOT_FOUND"); +}); + +test("stock master normalization enforces provider identity and rejects duplicate rows", () => { + const normalized = workflow.normalizeStockSearchResponse(stockPayload()); + assert.ok(normalized); + assert.equal(normalized.results[0].source, "oracle"); + assert.equal(workflow.normalizeStockSearchResponse(stockPayload([{ + market: "kospi", source: "mariaDb", name: "Alpha", code: "000001" + }])), null); + assert.equal(workflow.normalizeStockSearchResponse(stockPayload([ + { market: "kospi", source: "oracle", name: "Alpha", code: "000001" }, + { market: "kospi", source: "oracle", name: "Alpha", code: "000001" } + ])), null); +}); + +test("verification rejects same-name market ambiguity and fabricated responses", () => { + const value = record("sales"); + assert.equal(workflow.verifyStockForRecord(value, stockPayload(), "kospi", "000001"), null); + const response = workflow.normalizeStockSearchResponse(stockPayload([ + { market: "kospi", source: "oracle", name: "Alpha", code: "000001" }, + { market: "kosdaq", source: "oracle", name: "Alpha", code: "000002" } + ])); + assert.ok(response); + assert.equal(workflow.verifyStockForRecord(value, response, "kospi", "000001"), null); + const one = workflow.normalizeStockSearchResponse(stockPayload()); + assert.equal(workflow.verifyStockForRecord(value, one, "kosdaq", "000001"), null); +}); + +test("selection requests bind snapshot version and freshly verified stock to native result", () => { + const snapshot = trustedSnapshot("sales"); + const stock = verifiedStock(snapshot.record); + const request = workflow.createSelectionRequest("selection-1", snapshot, stock); + assert.deepEqual(request, { + requestId: "selection-1", + screen: "sales", + stockName: "Alpha", + rowVersion: version, + stock: { + market: "kospi", + source: "oracle", + name: "Alpha", + code: "000001" + } + }); + const response = trustedSelection(snapshot, stock); + assert.equal(response.builderKey, "s5080"); + assert.equal(workflow.normalizeSelectionResponse({ + ...response, + rowVersion: "B".repeat(64) + }, request), null); + assert.deepEqual(workflow.normalizeSelectionError({ + requestId: "selection-1", + screen: "sales", + stockName: "Alpha", + code: "STALE_ROW", + message: "Reload", + retryable: false + }, request).code, "STALE_ROW"); +}); + +test("create requires a verified exact stock and sends no row version", () => { + const value = record("sales"); + const stock = verifiedStock(value); + assert.deepEqual(workflow.createCreateRequest("create-1", value, stock), { + requestId: "create-1", + screen: "sales", + stock: { + market: "kospi", + source: "oracle", + name: "Alpha", + code: "000001" + }, + record: value + }); + assert.throws(() => workflow.createCreateRequest("create-1", value, { + market: "kospi", source: "oracle", name: "Alpha", code: "000001" + }), /verified stock/); + assert.throws(() => workflow.createCreateRequest( + "create-1", + record("sales", "Beta"), + stock), /verified stock/); +}); + +test("update and single delete require trusted snapshots and optimistic row version", () => { + const snapshot = trustedSnapshot("sales"); + const replacement = record("sales"); + assert.deepEqual(workflow.createUpdateRequest("update-1", snapshot, replacement), { + requestId: "update-1", + screen: "sales", + stockName: "Alpha", + expectedRowVersion: version, + record: replacement + }); + assert.deepEqual(workflow.createDeleteRequest("delete-1", snapshot), { + requestId: "delete-1", + screen: "sales", + stockName: "Alpha", + expectedRowVersion: version + }); + assert.throws(() => workflow.createUpdateRequest( + "update-1", + snapshot, + record("sales", "Beta")), /cannot rename/); + assert.throws(() => workflow.createDeleteRequest("delete-1", { + ...snapshot + }), /native-normalized/); +}); + +test("delete-all requires the exact table-specific confirmation token", () => { + assert.deepEqual(workflow.createDeleteAllRequest( + "delete-all-1", + "sales", + "DELETE_ALL_INPUT_SELL"), { + requestId: "delete-all-1", + screen: "sales", + confirmationToken: "DELETE_ALL_INPUT_SELL" + }); + assert.throws(() => workflow.createDeleteAllRequest( + "delete-all-1", + "sales", + "DELETE_ALL_INPUT_PROFIT"), /confirmation token/); +}); + +test("mutation results correlate and errors can never request automatic retry", () => { + const snapshot = trustedSnapshot("sales"); + const request = workflow.createDeleteRequest("delete-1", snapshot); + assert.deepEqual(workflow.normalizeMutationResult({ + requestId: "delete-1", + operationId: "operation-1", + operation: "delete-one", + screen: "sales", + stockName: "Alpha", + committedAt: "2026-07-12T00:12:00+09:00", + affectedRows: 1 + }, request), { + requestId: "delete-1", + operationId: "operation-1", + operation: "delete-one", + screen: "sales", + stockName: "Alpha", + committedAt: "2026-07-12T00:12:00+09:00", + affectedRows: 1 + }); + assert.deepEqual(workflow.normalizeMutationError({ + requestId: "delete-1", + operation: "delete-one", + screen: "sales", + stockName: "Alpha", + code: "OUTCOME_UNKNOWN", + message: "Do not retry automatically", + retryable: false, + outcomeUnknown: true + }, request).outcomeUnknown, true); + assert.equal(workflow.normalizeMutationResult({ + requestId: "delete-1", + operationId: "operation-1", + operation: "update", + screen: "sales", + stockName: "Alpha", + committedAt: "2026-07-12T00:12:00+09:00", + affectedRows: 1 + }, request), null); + assert.equal(workflow.normalizeMutationError({ + requestId: "delete-1", + operation: "delete-one", + screen: "sales", + stockName: "Alpha", + code: "OUTCOME_UNKNOWN", + message: "retry", + retryable: true, + outcomeUnknown: true + }, request), null); +}); + +test("only trusted DB snapshots and verified master rows create the four exact one-page cuts", () => { + const expectations = [ + ["revenue-composition", "s5076", "5076", "REVENUE_COMPOSITION"], + ["growth-metrics", "s5079", "5079", "GROWTH_METRICS"], + ["sales", "s5080", "5080", "SALES"], + ["operating-profit", "s5081", "5081", "OPERATING_PROFIT"] + ]; + for (const [screen, builderKey, code, graphicType] of expectations) { + const snapshot = trustedSnapshot(screen); + const stock = verifiedStock(snapshot.record); + assert.deepEqual(workflow.buildSelection(snapshot, stock), { + builderKey, + code, + aliases: [code], + selection: { + groupCode: "KOSPI", + subject: "Alpha", + graphicType, + subtype: "", + dataCode: "" + }, + currentPage: 1, + pageCount: 1 + }); + } + const rawSnapshot = listPayload("sales").items[0]; + const stock = verifiedStock(record("sales")); + assert.equal(workflow.buildSelection(rawSnapshot, stock), null); +}); + +test("playlist entry has the complete standard operator metadata and native trust", () => { + const snapshot = trustedSnapshot("operating-profit"); + const stock = verifiedStock(snapshot.record); + const selection = trustedSelection(snapshot, stock); + const entry = workflow.createPlaylistEntry(snapshot, stock, selection, { + id: "profit-1", + fadeDuration: 8 + }); + assert.deepEqual(entry, { + id: "profit-1", + builderKey: "s5081", + code: "5081", + aliases: ["5081"], + title: "Alpha", + detail: "영업이익", + category: "manual-financial", + market: "kospi", + stockName: "Alpha", + stockCode: "000001", + isNxt: false, + selection: { + groupCode: "KOSPI", + subject: "Alpha", + graphicType: "OPERATING_PROFIT", + subtype: "", + dataCode: "" + }, + enabled: true, + fadeDuration: 8, + graphicType: "OPERATING_PROFIT", + subtype: "", + currentPage: 1, + pageCount: 1, + operator: { + source: "manual-financial-workflow", + schemaVersion: 1, + screen: "operating-profit", + snapshot: { + screen: "operating-profit", + stockName: "Alpha", + rowVersion: version, + record: snapshot.record + }, + verifiedStock: { + market: "kospi", + source: "oracle", + name: "Alpha", + code: "000001" + } + } + }); + assert.equal(workflow.isPlaylistEntryPlayable(entry), true); + assert.equal(workflow.isPlaylistEntryPlayable(JSON.parse(JSON.stringify(entry))), false); + assert.throws(() => workflow.createPlaylistEntry(snapshot, stock, { + ...selection + }, { id: "forged" }), /native-confirmed/); +}); + +test("stored entries restore only as non-playable refresh-required descriptors", () => { + const snapshot = trustedSnapshot("sales"); + const stock = verifiedStock(snapshot.record); + const selection = trustedSelection(snapshot, stock); + const live = workflow.createPlaylistEntry(snapshot, stock, selection, { + id: "sales-restore", + fadeDuration: 7, + enabled: false + }); + const stored = JSON.parse(JSON.stringify(live)); + const pending = workflow.restorePlaylistEntry(stored); + assert.ok(pending); + assert.equal(pending.status, "refresh-required"); + assert.equal(pending.playable, false); + assert.equal(workflow.isPlaylistEntryPlayable(pending.entry), false); + assert.deepEqual(workflow.createRestoreLoadRequest("restore-load", pending), { + requestId: "restore-load", + screen: "sales", + stockName: "Alpha" + }); + + assert.equal(workflow.restorePlaylistEntry({ + ...stored, + selection: { ...stored.selection, dataCode: "tampered" } + }), null); + assert.equal(workflow.restorePlaylistEntry({ + ...stored, + operator: { ...stored.operator, schemaVersion: 2 } + }), null); +}); + +test("native load, fresh stock verification and native selection materialize a stored entry", () => { + const originalSnapshot = trustedSnapshot("sales"); + const originalStock = verifiedStock(originalSnapshot.record); + const originalSelection = trustedSelection(originalSnapshot, originalStock); + const stored = JSON.parse(JSON.stringify(workflow.createPlaylistEntry( + originalSnapshot, + originalStock, + originalSelection, + { id: "sales-restore", fadeDuration: 7, enabled: false }))); + const pending = workflow.restorePlaylistEntry(stored); + + const loadRequest = workflow.createRestoreLoadRequest("restore-load", pending); + const loadResponse = workflow.normalizeLoadResponse({ + requestId: "restore-load", + screen: "sales", + retrievedAt: "2026-07-12T00:20:00+09:00", + snapshot: { + stockName: stored.operator.snapshot.stockName, + rowVersion: stored.operator.snapshot.rowVersion, + record: stored.operator.snapshot.record + } + }, loadRequest); + assert.ok(loadResponse); + const freshStockResponse = workflow.normalizeStockSearchResponse(stockPayload()); + const freshStock = workflow.verifyStockForRecord( + loadResponse.snapshot.record, + freshStockResponse, + pending.verifiedStock.market, + pending.verifiedStock.code); + assert.ok(freshStock); + const selectionRequest = workflow.createRestoreSelectionRequest( + "restore-selection", + pending, + loadResponse, + freshStock); + const profile = workflow.getScreenDefinition("sales"); + const selectionResponse = workflow.normalizeSelectionResponse({ + requestId: "restore-selection", + screen: "sales", + stockName: "Alpha", + rowVersion: version, + builderKey: profile.builderKey, + code: profile.code, + aliases: [profile.code], + selection: { + groupCode: "KOSPI", + subject: "Alpha", + graphicType: profile.graphicType, + subtype: "", + dataCode: "" + }, + currentPage: 1, + totalPages: 1 + }, selectionRequest); + const restored = workflow.materializeRestoredPlaylistEntry( + pending, + loadResponse, + freshStock, + selectionResponse); + assert.deepEqual(restored, stored); + assert.equal(workflow.isPlaylistEntryPlayable(restored), true); +}); + +test("row-version or record drift keeps a stored entry permanently unplayable", () => { + const snapshot = trustedSnapshot("sales"); + const stock = verifiedStock(snapshot.record); + const selection = trustedSelection(snapshot, stock); + const stored = JSON.parse(JSON.stringify(workflow.createPlaylistEntry( + snapshot, + stock, + selection, + { id: "stale-entry" }))); + const pending = workflow.restorePlaylistEntry(stored); + const request = workflow.createRestoreLoadRequest("restore-load", pending); + const changed = workflow.normalizeLoadResponse({ + requestId: "restore-load", + screen: "sales", + retrievedAt: "2026-07-12T00:20:00+09:00", + snapshot: { + stockName: "Alpha", + rowVersion: "B".repeat(64), + record: record("sales") + } + }, request); + const freshStockResponse = workflow.normalizeStockSearchResponse(stockPayload()); + const freshStock = workflow.verifyStockForRecord( + changed.snapshot.record, + freshStockResponse, + "kospi", + "000001"); + assert.throws(() => workflow.createRestoreSelectionRequest( + "restore-selection", + pending, + changed, + freshStock), /did not reproduce/); + assert.equal(workflow.materializeRestoredPlaylistEntry( + pending, + changed, + freshStock, + selection), null); + assert.equal(workflow.isPlaylistEntryPlayable(stored), false); +}); diff --git a/tests/Web/manual-lists-bridge-integration.test.cjs b/tests/Web/manual-lists-bridge-integration.test.cjs new file mode 100644 index 0000000..d753821 --- /dev/null +++ b/tests/Web/manual-lists-bridge-integration.test.cjs @@ -0,0 +1,56 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const root = path.resolve(__dirname, "..", ".."); +const native = fs.readFileSync(path.join(root, "MainWindow.ManualLists.cs"), "utf8"); +const playout = fs.readFileSync(path.join(root, "MainWindow.Playout.cs"), "utf8"); +const factory = fs.readFileSync(path.join(root, "LegacySceneRuntimeFactory.cs"), "utf8"); + +test("native VI responses expose the canonical version and no-op persistence receipt", () => { + assert.match(native, /ReadSnapshotAsync\(_lifetimeCancellation\.Token\)/u); + assert.match(native, /version = snapshot\.RowVersion/u); + assert.match(native, /persisted = result\.Persisted/u); + assert.match(native, /version = result\.Snapshot\.RowVersion/u); +}); + +test("VI writes require a closed expected version and compare it under the data gate before mutation", () => { + assert.match(native, /HasOnlyProperties\(payload, "requestId", "expectedVersion", "items"\)/u); + assert.match(native, /TrustedViManualReference\.IsRowVersion\(expectedVersion\)/u); + + const methodStart = native.indexOf("private async Task HandleViManualListWriteAsync"); + const methodEnd = native.indexOf("private async Task TryEnterManualMutationGatesAsync", methodStart); + const method = native.slice(methodStart, methodEnd); + const gate = method.indexOf("TryEnterManualMutationGatesAsync"); + const reread = method.indexOf("var currentSnapshot = await store.ReadSnapshotAsync"); + const compare = method.indexOf("currentSnapshot.RowVersion"); + const stale = method.indexOf('"STALE_DATA"'); + const mutation = method.indexOf("var result = await store.WriteAsync"); + assert.ok(methodStart >= 0 && methodEnd > methodStart); + assert.ok(gate >= 0 && gate < reread && reread < compare && compare < stale && stale < mutation); + assert.match(method.slice(stale, mutation), /retryable: false,[\s\S]*outcomeUnknown: false/u); +}); + +test("different post-error readback always enters OutcomeUnknown quarantine", () => { + assert.equal(native.includes('"SAVE_FAILED"'), false); + assert.match(native, /수동 순매도 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다/u); + assert.match(native, /VI 수동 목록 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다/u); + assert.ok((native.match(/PostManualOperatorQuarantine\(requestId, "save-/gu) || []).length >= 4); +}); + +test("configured external operator paths are disabled before any directory creation", () => { + const configuredGuard = native.indexOf("if (!string.IsNullOrWhiteSpace(configuredDirectory))"); + const privatePrepare = native.indexOf("TrustedManualDirectory.PreparePrivateWritableDirectory"); + assert.ok(configuredGuard >= 0); + assert.ok(privatePrepare > configuredGuard); + assert.equal(native.includes("Directory.CreateDirectory(directory)"), false); +}); + +test("trusted VI source is injected and checked for both page plan and page load", () => { + assert.match(playout, /trustedViSource: _viManualListStore/u); + assert.match(factory, /ITrustedViStockNameSnapshotSource\? trustedViSource/u); + assert.ok((factory.match(/TrustedViManualReference\.ResolveAsync/gu) || []).length >= 2); +}); diff --git a/tests/Web/manual-lists-ui.test.cjs b/tests/Web/manual-lists-ui.test.cjs new file mode 100644 index 0000000..06443f0 --- /dev/null +++ b/tests/Web/manual-lists-ui.test.cjs @@ -0,0 +1,353 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); +const ui = require("../../Web/manual-lists-ui.js"); + +const versionA = "a".repeat(64); +const versionB = "b".repeat(64); + +const netRows = () => Array.from({ length: 5 }, (_, index) => ({ + leftName: `좌측${index + 1}`, + leftAmount: `${index + 1},000`, + rightName: `우측${index + 1}`, + rightAmount: `-${index + 1},000` +})); + +const viItems = () => [ + { code: "P005930", name: "삼성전자" }, + { code: "P000660", name: "SK하이닉스" }, + { code: "D035720", name: "카카오" } +]; + +function fixture() { + let sequence = 0; + let externallyLocked = false; + const posts = []; + const entries = []; + const toasts = []; + const logs = []; + const controller = ui.createController({ + postNative: (type, payload) => posts.push({ type, payload }), + appendPlaylistEntry: entry => entries.push(entry), + isLocked: () => externallyLocked, + showToast: (message, kind) => toasts.push({ message, kind }), + addLog: message => logs.push(message), + createId: () => `manual_ui_${++sequence}` + }); + return { + controller, + posts, + entries, + toasts, + logs, + setExternalLock: value => { externallyLocked = value; }, + latest: type => [...posts].reverse().find(item => item.type === type) + }; +} + +function enableManualStore(context) { + const requestId = context.controller.getState().pending.status; + assert.equal(context.controller.handleMessage("manual-operator-data-status", { + requestId, + available: true, + writeQuarantined: false, + message: null + }), true); +} + +test("FSell cut opens only after five saved rows are re-read unchanged", () => { + const context = fixture(); + const { controller } = context; + controller.openNetSell("FOREIGN"); + enableManualStore(context); + const initialRead = controller.getState().pending.netRead; + assert.equal(controller.handleMessage("manual-net-sell-data", { + requestId: initialRead, + audience: "FOREIGN", + rows: netRows() + }), true); + assert.equal(controller.actions.addNetSellCut(), false); + + assert.equal(controller.actions.saveNetSell(), true); + const save = context.latest("save-manual-net-sell-data"); + assert.equal(save.payload.rows.length, 5); + assert.equal(controller.handleMessage("manual-net-sell-save-result", { + requestId: save.payload.requestId, + audience: "FOREIGN", + saved: true, + recovered: false + }), true); + assert.equal(controller.actions.addNetSellCut(), false); + + const verificationRead = controller.getState().pending.netRead; + assert.equal(controller.handleMessage("manual-net-sell-data", { + requestId: verificationRead, + audience: "FOREIGN", + rows: netRows() + }), true); + assert.equal(controller.getState().netVerified, true); + assert.equal(controller.actions.addNetSellCut(), true); + assert.equal(context.entries[0].builderKey, "s5025"); + + controller.actions.setNetCell(0, "leftAmount", "변경"); + assert.equal(controller.actions.addNetSellCut(), false); +}); + +test("VI cut binds the latest version and reorder requires save plus matching reread", () => { + const context = fixture(); + const { controller } = context; + controller.openVi(); + enableManualStore(context); + const read = controller.getState().pending.viRead; + assert.equal(controller.handleMessage("vi-manual-list-data", { + requestId: read, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: versionA + }), true); + assert.equal(controller.actions.addViCut(), true); + assert.equal(context.entries[0].selection.subject, "@MBN_VI_SNAPSHOT_V1"); + assert.equal(context.entries[0].selection.dataCode, versionA); + + assert.equal(controller.actions.moveViItem(2, -1), true); + assert.deepEqual(controller.getState().viItems.map(item => item.name), [ + "삼성전자", "카카오", "SK하이닉스" + ]); + assert.equal(controller.getState().viBaseVersion, versionA); + assert.equal(controller.getState().viVersion, null); + assert.equal(controller.actions.addViCut(), false); + + assert.equal(controller.actions.saveVi(), true); + const save = context.latest("save-vi-manual-list"); + assert.equal(save.payload.expectedVersion, versionA); + assert.equal(controller.handleMessage("vi-manual-list-save-result", { + requestId: save.payload.requestId, + saved: true, + recovered: false, + persisted: true, + itemCount: 3, + pageCount: 1, + version: versionB + }), true); + const verify = controller.getState().pending.viRead; + assert.equal(controller.handleMessage("vi-manual-list-data", { + requestId: verify, + itemCount: 3, + pageCount: 1, + items: save.payload.items, + version: versionB + }), true); + assert.equal(controller.getState().viVerified, true); + assert.equal(controller.actions.addViCut(), true); + assert.equal(context.entries[1].selection.dataCode, versionB); + assert.deepEqual(context.entries[1].operator.items, save.payload.items); +}); + +test("VI mismatched post-save version never opens the playlist gate", () => { + const context = fixture(); + const { controller } = context; + controller.openVi(); + enableManualStore(context); + controller.handleMessage("vi-manual-list-data", { + requestId: controller.getState().pending.viRead, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: versionA + }); + controller.actions.moveViItem(2, -1); + controller.actions.saveVi(); + const save = context.latest("save-vi-manual-list"); + controller.handleMessage("vi-manual-list-save-result", { + requestId: save.payload.requestId, + saved: true, + recovered: false, + persisted: true, + itemCount: 3, + pageCount: 1, + version: versionB + }); + controller.handleMessage("vi-manual-list-data", { + requestId: controller.getState().pending.viRead, + itemCount: 3, + pageCount: 1, + items: save.payload.items, + version: versionA + }); + assert.equal(controller.getState().viVerified, false); + assert.equal(controller.actions.addViCut(), false); +}); + +test("VI stale-data conflict is terminal, preserves the draft, and requires reread", () => { + const context = fixture(); + const { controller } = context; + controller.openVi(); + enableManualStore(context); + controller.handleMessage("vi-manual-list-data", { + requestId: controller.getState().pending.viRead, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: versionA + }); + controller.actions.moveViItem(2, -1); + const draft = controller.getState().viItems; + assert.equal(controller.actions.saveVi(), true); + const save = context.latest("save-vi-manual-list"); + const postCount = context.posts.length; + + assert.equal(controller.handleMessage("manual-operator-data-error", { + requestId: save.payload.requestId, + operation: "save-vi", + code: "STALE_DATA", + message: "The VI list changed. Reload before saving.", + retryable: false, + outcomeUnknown: false + }), true); + assert.deepEqual(controller.getState().viItems, draft); + assert.equal(controller.getState().viBaseVersion, null); + assert.equal(controller.getState().viVersion, null); + assert.equal(controller.getState().viVerified, false); + assert.equal(controller.getState().quarantined, false); + assert.equal(controller.actions.saveVi(), false); + assert.equal(context.posts.length, postCount); +}); + +test("empty VI save is a successful no-op and can never create an empty cut", () => { + const context = fixture(); + const { controller } = context; + controller.openVi(); + enableManualStore(context); + controller.handleMessage("vi-manual-list-data", { + requestId: controller.getState().pending.viRead, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: versionA + }); + controller.actions.clearViItems(); + assert.equal(controller.actions.addViCut(), false); + assert.equal(controller.actions.saveVi(), true); + const save = context.latest("save-vi-manual-list"); + assert.equal(save.payload.expectedVersion, versionA); + assert.deepEqual(save.payload.items, []); + assert.equal(controller.handleMessage("vi-manual-list-save-result", { + requestId: save.payload.requestId, + saved: true, + recovered: false, + persisted: false, + itemCount: 3, + pageCount: 1, + version: versionA + }), true); + assert.ok(context.toasts.some(item => item.message.includes("변경하지 않았습니다"))); + + controller.handleMessage("vi-manual-list-data", { + requestId: controller.getState().pending.viRead, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: versionA + }); + assert.equal(controller.getState().viItems.length, 3); + controller.actions.clearViItems(); + assert.equal(controller.actions.addViCut(), false); +}); + +test("stale correlations are ignored and OutcomeUnknown quarantines every later save", () => { + const context = fixture(); + const { controller } = context; + controller.openVi(); + enableManualStore(context); + const staleRead = controller.getState().pending.viRead; + controller.actions.requestViRead(); + const currentRead = controller.getState().pending.viRead; + assert.notEqual(staleRead, currentRead); + assert.equal(controller.handleMessage("vi-manual-list-data", { + requestId: staleRead, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: versionA + }), false); + assert.equal(controller.getState().viItems.length, 0); + controller.handleMessage("vi-manual-list-data", { + requestId: currentRead, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: versionA + }); + assert.equal(controller.actions.saveVi(), true); + const save = context.latest("save-vi-manual-list"); + assert.equal(controller.handleMessage("manual-operator-data-error", { + requestId: save.payload.requestId, + operation: "save-vi", + code: "OUTCOME_UNKNOWN", + message: "저장 결과를 확인할 수 없습니다.", + retryable: false, + outcomeUnknown: true + }), true); + assert.equal(controller.getState().quarantined, true); + const postCount = context.posts.length; + assert.equal(controller.actions.saveVi(), false); + assert.equal(context.posts.length, postCount); +}); + +test("stock search is exact and correlated before it can append a VI row", () => { + const context = fixture(); + const { controller } = context; + controller.openVi(); + assert.equal(controller.actions.searchStocks("삼성"), true); + const search = context.latest("search-stocks"); + assert.equal(controller.handleMessage("stock-search-results", { + requestId: "stale_request", + query: "삼성", + retrievedAt: "2026-07-12T00:00:00+09:00", + totalRowCount: 1, + truncated: false, + results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }] + }), false); + assert.equal(controller.handleMessage("stock-search-results", { + requestId: search.payload.requestId, + query: "삼성", + retrievedAt: "2026-07-12T00:00:00+09:00", + totalRowCount: 1, + truncated: false, + results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }] + }), true); + assert.equal(controller.actions.addSearchResult(0), true); + assert.deepEqual(controller.getState().viItems, [{ code: "P005930", name: "삼성전자" }]); +}); + +test("explicit and host locks block edits, saves, and cut creation", () => { + const context = fixture(); + const { controller } = context; + controller.openNetSell("INDIVIDUAL"); + enableManualStore(context); + controller.handleMessage("manual-net-sell-data", { + requestId: controller.getState().pending.netRead, + audience: "INDIVIDUAL", + rows: netRows() + }); + controller.setLocked(true); + assert.equal(controller.actions.setNetCell(0, "leftName", "변경"), false); + assert.equal(controller.actions.saveNetSell(), false); + controller.setLocked(false); + context.setExternalLock(true); + assert.equal(controller.actions.saveNetSell(), false); + assert.equal(controller.getState().locked, true); +}); + +test("controller source constructs DOM without HTML injection or filesystem paths", () => { + const source = fs.readFileSync( + path.resolve(__dirname, "..", "..", "Web", "manual-lists-ui.js"), + "utf8"); + assert.equal(source.includes("innerHTML"), false); + assert.equal(source.includes("insertAdjacentHTML"), false); + assert.equal(/(?:[A-Z]:\\|file:\/\/)/u.test(source), false); +}); diff --git a/tests/Web/manual-lists-workflow.test.cjs b/tests/Web/manual-lists-workflow.test.cjs new file mode 100644 index 0000000..795374f --- /dev/null +++ b/tests/Web/manual-lists-workflow.test.cjs @@ -0,0 +1,297 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/manual-lists-workflow.js"); + +const requestId = "manual_req_1"; +const rowVersion = "a".repeat(64); +const rows = () => Array.from({ length: 5 }, (_, index) => ({ + leftName: `왼쪽${index + 1}`, + leftAmount: `${index + 1},000`, + rightName: `오른쪽${index + 1}`, + rightAmount: `-${index + 1},000` +})); +const viItems = () => [ + { code: "005930", name: "삼성전자" }, + { code: "000660", name: "SK하이닉스" }, + { code: "005930", name: "삼성전자" } +]; + +test("bridge requests expose only closed operations and keys", () => { + assert.deepEqual(workflow.createStatusRequest(requestId), { + type: "request-manual-operator-data-status", + payload: { requestId } + }); + assert.deepEqual(workflow.createNetSellReadRequest(requestId, "FOREIGN"), { + type: "request-manual-net-sell-data", + payload: { requestId, audience: "FOREIGN" } + }); + assert.deepEqual(workflow.createViReadRequest(requestId), { + type: "request-vi-manual-list", + payload: { requestId } + }); +}); + +test("net-sell save request requires exactly five strict rows", () => { + const request = workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows()); + assert.equal(request.type, "save-manual-net-sell-data"); + assert.equal(request.payload.rows.length, 5); + assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows().slice(0, 4))); + assert.throws(() => workflow.createNetSellSaveRequest(requestId, "OTHER", rows())); + assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", [ + ...rows().slice(0, 4), + { ...rows()[4], extra: "not allowed" } + ])); +}); + +test("manual value delimiter and control characters fail closed", () => { + for (const leftName of ["bad^name", "bad\nname", "bad\u200Ename", "bad\uFFFDname"]) { + const invalid = rows(); + invalid[0].leftName = leftName; + assert.throws(() => workflow.createNetSellSaveRequest(requestId, "FOREIGN", invalid)); + } +}); + +test("VI save preserves order and duplicates but never exposes a path", () => { + const request = workflow.createViSaveRequest(requestId, viItems(), rowVersion); + assert.deepEqual(request, { + type: "save-vi-manual-list", + payload: { requestId, expectedVersion: rowVersion, items: viItems() } + }); + assert.equal(JSON.stringify(request).includes("path"), false); + assert.throws(() => workflow.createViSaveRequest(requestId, viItems())); + assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "A".repeat(64))); + assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "a".repeat(63))); +}); + +test("VI list rejects comma ambiguity, unsafe text, and more than twenty pages", () => { + assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A,B" }], rowVersion)); + assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1^2", name: "A" }], rowVersion)); + assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: " 1", name: "A" }], rowVersion)); + assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A " }], rowVersion)); + assert.throws(() => workflow.createViSaveRequest( + requestId, + Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `Item${index + 1}` })), + rowVersion)); + assert.throws(() => workflow.createViSaveRequest( + requestId, + Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `종목${index + 1}` })))); +}); + +test("status response preserves process-lifetime write quarantine", () => { + assert.deepEqual(workflow.normalizeStatusResponse({ + requestId, + available: true, + writeQuarantined: true, + message: "저장 결과를 확인할 수 없습니다." + }, requestId), { + requestId, + available: true, + writeQuarantined: true, + message: "저장 결과를 확인할 수 없습니다." + }); + assert.equal(workflow.normalizeStatusResponse({ + requestId, + available: true, + writeQuarantined: true, + message: "저장 결과를 확인할 수 없습니다.", + extra: true + }), null); +}); + +test("net-sell data response is correlated and exact", () => { + const response = { + requestId, + audience: "INSTITUTION", + rows: rows() + }; + assert.ok(workflow.normalizeNetSellDataResponse(response, { + requestId, + audience: "INSTITUTION" + })); + assert.equal(workflow.normalizeNetSellDataResponse(response, { + requestId: "other", + audience: "INSTITUTION" + }), null); +}); + +test("net-sell write result distinguishes verified recovery", () => { + const response = { + requestId, + audience: "FOREIGN", + saved: true, + recovered: true + }; + assert.ok(workflow.normalizeNetSellSaveResponse(response, { requestId, audience: "FOREIGN" })); + assert.equal(workflow.normalizeNetSellSaveResponse({ ...response, saved: false }), null); +}); + +test("VI data response verifies item and page counts", () => { + const response = { + requestId, + itemCount: 3, + pageCount: 1, + items: viItems(), + version: rowVersion + }; + assert.ok(workflow.normalizeViDataResponse(response, requestId)); + assert.equal(workflow.normalizeViDataResponse({ ...response, pageCount: 2 }), null); + assert.equal(workflow.normalizeViDataResponse({ ...response, itemCount: 2 }), null); +}); + +test("VI save response rejects impossible page counts", () => { + assert.ok(workflow.normalizeViSaveResponse({ + requestId, + saved: true, + recovered: false, + persisted: true, + itemCount: 100, + pageCount: 20, + version: rowVersion + }, requestId)); + assert.equal(workflow.normalizeViSaveResponse({ + requestId, + saved: true, + recovered: false, + persisted: true, + itemCount: 100, + pageCount: 19, + version: rowVersion + }), null); + assert.ok(workflow.normalizeViSaveResponse({ + requestId, + saved: true, + recovered: false, + persisted: false, + itemCount: 3, + pageCount: 1, + version: rowVersion + }, requestId)); +}); + +test("OutcomeUnknown errors are never retryable", () => { + assert.ok(workflow.normalizeError({ + requestId, + operation: "save-vi", + code: "OUTCOME_UNKNOWN", + message: "저장 결과를 확인할 수 없습니다.", + retryable: false, + outcomeUnknown: true + }, requestId)); + assert.equal(workflow.normalizeError({ + requestId, + operation: "save-vi", + code: "OUTCOME_UNKNOWN", + message: "저장 결과를 확인할 수 없습니다.", + retryable: true, + outcomeUnknown: true + }), null); +}); + +test("manual net-sell playlist mapping is original s5025 contract", () => { + const entry = workflow.createNetSellPlaylistEntry("INDIVIDUAL", { + id: "entry_1", + fadeDuration: 6 + }); + assert.equal(entry.builderKey, "s5025"); + assert.equal(entry.code, "5025"); + assert.deepEqual(entry.selection, { + groupCode: "INDIVIDUAL", + subject: "INDEX", + graphicType: "MANUAL_NET_SELL", + subtype: "MANUAL_NET_SELL", + dataCode: "" + }); +}); + +test("VI playlist mapping uses a bounded trusted snapshot reference", () => { + const entry = workflow.createViPlaylistEntry(viItems(), rowVersion, { + id: "entry_2", + fadeDuration: 4 + }); + assert.equal(entry.builderKey, "s5074"); + assert.equal(entry.code, "5074"); + assert.equal(entry.itemCount, 3); + assert.equal(entry.pageSize, 5); + assert.equal(entry.pageCount, 1); + assert.equal(entry.selection.groupCode, "PAGED_VI"); + assert.equal(entry.selection.subject, workflow.viSnapshotSubject); + assert.equal(entry.selection.dataCode, rowVersion); + assert.ok(entry.selection.subject.length <= 256); +}); + +test("one hundred realistic long names still create a bounded twenty-page reference", () => { + const items = Array.from({ length: 100 }, (_, index) => ({ + code: `P${String(index + 1).padStart(6, "0")}`, + name: `현실적인긴종목명테스트${index + 1}` + })); + const entry = workflow.createViPlaylistEntry(items, rowVersion, { + id: "entry_20_pages", + fadeDuration: 4 + }); + assert.equal(entry.pageCount, 20); + assert.equal(entry.selection.subject, workflow.viSnapshotSubject); + assert.equal(entry.selection.dataCode.length, 64); + assert.equal(JSON.stringify(entry.selection).includes(items[0].name), false); +}); + +test("empty VI list can be saved but cannot create a playout entry", () => { + assert.deepEqual(workflow.createViSaveRequest(requestId, [], rowVersion), { + type: "save-vi-manual-list", + payload: { requestId, expectedVersion: rowVersion, items: [] } + }); + assert.throws(() => workflow.createViPlaylistEntry([], rowVersion, { + id: "entry_empty", + fadeDuration: 6 + })); +}); + +test("trusted restore recreates manual entries and rejects tampering", () => { + const netSell = workflow.createNetSellPlaylistEntry("FOREIGN", { + id: "entry_net", + fadeDuration: 6 + }); + const vi = workflow.createViPlaylistEntry(viItems(), rowVersion, { + id: "entry_vi", + fadeDuration: 6 + }); + assert.deepEqual(workflow.restorePlaylistEntry(netSell), netSell); + assert.deepEqual(workflow.restorePlaylistEntry(vi), vi); + assert.equal(workflow.restorePlaylistEntry({ + ...vi, + selection: { ...vi.selection, subject: "다른종목" } + }), null); + assert.equal(workflow.restorePlaylistEntry({ ...vi, extra: true }), null); + assert.equal(workflow.restorePlaylistEntry({ + ...vi, + operator: { + ...vi.operator, + pagePreview: { ...vi.operator.pagePreview, pageCount: 20 } + } + }), null); + assert.equal(workflow.restorePlaylistEntry({ + ...vi, + selection: { ...vi.selection, extra: true } + }), null); + assert.equal(workflow.restorePlaylistEntry({ + ...netSell, + operator: { ...netSell.operator, audience: "OTHER" } + }), null); +}); + +test("request identifiers and option bounds are strict", () => { + assert.throws(() => workflow.createStatusRequest("bad id")); + assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", { + id: "entry", + fadeDuration: 61 + })); + assert.throws(() => workflow.createViPlaylistEntry(viItems(), rowVersion, { + id: "entry", + fadeDuration: -1 + })); + assert.throws(() => workflow.createViPlaylistEntry(viItems(), "A".repeat(64), { + id: "entry", + fadeDuration: 4 + })); +}); diff --git a/tests/Web/named-manual-restore-workflow.test.cjs b/tests/Web/named-manual-restore-workflow.test.cjs new file mode 100644 index 0000000..c673949 --- /dev/null +++ b/tests/Web/named-manual-restore-workflow.test.cjs @@ -0,0 +1,149 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const workflow = require("../../Web/named-manual-restore-workflow.js"); +const manualLists = require("../../Web/manual-lists-workflow.js"); +const manualFinancial = require("../../Web/manual-financial-workflow.js"); + +function raw(overrides = {}) { + return { + itemIndex: 0, + enabled: true, + groupCode: "INDIVIDUAL", + subject: "INDEX", + graphicType: "MANUAL_NET_SELL", + subtype: "MANUAL_NET_SELL", + dataCode: "", + ...overrides + }; +} + +const options = { id: "db-invalid-00000001-0", fadeDuration: 6 }; +const rows = Array.from({ length: 5 }, (_, index) => ({ + leftName: `L${index}`, leftAmount: `${index}`, + rightName: `R${index}`, rightAmount: `${index + 10}` +})); + +test("s5025 raw restoration accepts only canonical or exact legacy audience signatures", () => { + const canonical = workflow.classify(raw(), options); + assert.equal(canonical.kind, "net-sell"); + assert.equal(canonical.audience, "INDIVIDUAL"); + + const legacy = workflow.classify(raw({ + groupCode: "외국인 순매도 상위(수동)", + subject: "", + graphicType: "순매도 상위", + subtype: "순매도 상위" + }), options); + assert.equal(legacy.audience, "FOREIGN"); + assert.equal(legacy.historical, true); + assert.equal(workflow.classify(raw({ + groupCode: "외국인 순매도 상위", + subject: "", + graphicType: "순매도 상위", + subtype: "순매도 상위" + }), options), null); + assert.equal(workflow.classify(raw({ dataCode: "path.dat" }), options), null); +}); + +test("s5025 becomes playable only after one correlated trusted five-row read", () => { + const pending = workflow.classify(raw({ enabled: false }), options); + const request = workflow.createManualListReadRequest("named-net-read", pending); + const entry = workflow.materializeManualList(pending, { + requestId: "named-net-read", + audience: "INDIVIDUAL", + rows + }, request); + assert.ok(entry); + assert.equal(entry.enabled, false); + assert.equal(entry.builderKey, "s5025"); + assert.equal(manualLists.restorePlaylistEntry(entry).operator.audience, "INDIVIDUAL"); + assert.equal(workflow.materializeManualList(pending, { + requestId: "stale-read", + audience: "INDIVIDUAL", + rows + }, request), null); +}); + +test("versioned and historical VI raw rows require the current exact native snapshot", () => { + const version = "a".repeat(64); + const current = workflow.classify(raw({ + groupCode: "PAGED_VI", + subject: manualLists.viSnapshotSubject, + graphicType: "INPUT_ORDER", + subtype: "CURRENT", + dataCode: version + }), options); + const request = workflow.createManualListReadRequest("named-vi-read", current); + const payload = { + requestId: "named-vi-read", + itemCount: 2, + pageCount: 1, + items: [{ code: "005930", name: "삼성전자" }, { code: "000660", name: "SK하이닉스" }], + version + }; + assert.equal(workflow.materializeManualList(current, payload, request).builderKey, "s5074"); + assert.equal(workflow.materializeManualList(current, { ...payload, version: "b".repeat(64) }, request), null); + + const historical = workflow.classify(raw({ + groupCode: "", + subject: "삼성전자,SK하이닉스", + graphicType: "5단 표그래프", + subtype: "VI 발동(수동)", + dataCode: "" + }), options); + const historicalRequest = workflow.createManualListReadRequest("named-vi-history", historical); + assert.ok(workflow.materializeManualList(historical, { + ...payload, + requestId: "named-vi-history" + }, historicalRequest)); + assert.equal(workflow.materializeManualList(historical, { + ...payload, + requestId: "named-vi-history", + items: [...payload.items].reverse() + }, historicalRequest), null); + assert.equal(workflow.classify(raw({ + groupCode: "", + subject: "삼성전자,삼성전자", + graphicType: "5단 표그래프", + subtype: "VI 발동(수동)" + }), options), null); + assert.equal(workflow.classify(raw({ + groupCode: "", + subject: "삼성전자, SK하이닉스", + graphicType: "5단 표그래프", + subtype: "VI 발동(수동)" + }), options), null); +}); + +test("GraphE raw rows yield only a fresh-load descriptor with exact screen, market and name", () => { + for (const profile of manualFinancial.sourceContract.screens) { + const pending = workflow.classify(raw({ + groupCode: "코스피", + subject: "삼성전자", + graphicType: profile.label, + subtype: "", + dataCode: "" + }), options); + assert.equal(pending.kind, "financial"); + assert.equal(pending.screen, profile.screen); + assert.equal(pending.builderKey, profile.builderKey); + assert.equal(pending.market, "kospi"); + assert.deepEqual(pending.identity, { screen: profile.screen, stockName: "삼성전자" }); + } + const profile = manualFinancial.sourceContract.screens[0]; + assert.equal(workflow.classify(raw({ + groupCode: "UNKNOWN", + subject: "삼성전자", + graphicType: profile.graphicType, + subtype: "", + dataCode: "" + }), options), null); + assert.equal(workflow.classify(raw({ + groupCode: "KOSPI", + subject: "삼성전자", + graphicType: profile.graphicType, + subtype: "", + dataCode: "005930" + }), options), null); +}); diff --git a/tests/Web/named-playlist-bridge-integration.test.cjs b/tests/Web/named-playlist-bridge-integration.test.cjs new file mode 100644 index 0000000..ff1ec28 --- /dev/null +++ b/tests/Web/named-playlist-bridge-integration.test.cjs @@ -0,0 +1,93 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const workflow = require("../../Web/named-playlist-workflow.js"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const mainWindow = fs.readFileSync(path.join(repositoryRoot, "MainWindow.xaml.cs"), "utf8"); +const namedBridge = fs.readFileSync(path.join(repositoryRoot, "MainWindow.NamedPlaylists.cs"), "utf8"); +const pagePlanBridge = fs.readFileSync(path.join(repositoryRoot, "MainWindow.PlaylistPagePlans.cs"), "utf8"); +const appScript = fs.readFileSync(path.join(repositoryRoot, "Web", "app.js"), "utf8"); + +test("native switch and JavaScript contract use the same five requests and four responses", () => { + for (const requestType of Object.values(workflow.bridgeContract.requests)) { + assert.match(mainWindow, new RegExp(`case "${requestType}"`)); + } + for (const responseType of Object.values(workflow.bridgeContract.responses)) { + assert.match(namedBridge, new RegExp(`"${responseType}"`)); + } + assert.match(mainWindow, /new LegacyNamedPlaylistPersistenceService\(/); + assert.match(mainWindow, /new OracleNamedPlaylistMutationExecutor\(/); +}); + +test("named reads use latest-request cancellation and the shared playout-priority database gate", () => { + assert.match(namedBridge, + /Interlocked\.Exchange\(\s*ref _namedPlaylistReadCancellation,\s*requestCancellation\)/s); + assert.match(namedBridge, /CancelRequest\(previousCancellation\)/); + assert.ok((namedBridge.match(/await _databaseActivityGate\.WaitAsync\(requestToken\)/g) || []).length >= 2); + assert.ok((namedBridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 4); + assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _namedPlaylistReadCancellation\)\)/); + assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _namedPlaylistWriteCancellation\)\)/); +}); + +test("writes are single-flight, never superseded, and permanently quarantine OutcomeUnknown", () => { + assert.match(namedBridge, /_namedPlaylistWriteGate\.WaitAsync\(0\)/); + assert.match(namedBridge, /catch \(NamedPlaylistMutationException exception\)/); + assert.match(namedBridge, /if \(exception\.OutcomeUnknown\)/); + assert.match(namedBridge, + /Interlocked\.CompareExchange\(ref _namedPlaylistWriteQuarantined, 1, 0\)/); + assert.match(namedBridge, /IsNamedPlaylistWriteQuarantined\(\)/); + assert.match(namedBridge, /do not retry|was not retried/i); + assert.doesNotMatch(namedBridge, /for\s*\([^)]*retry|while\s*\([^)]*retry/i); +}); + +test("browser correlation loss cancels active requests and latches an in-flight write", () => { + assert.match(mainWindow, + /private void QuarantineIfBrowserCorrelationCanBeLost\(\)\s*\{\s*InvalidateNamedPlaylistBrowserRequests\(\)/s); + assert.match(namedBridge, /Interlocked\.Increment\(ref _namedPlaylistBrowserGeneration\)/); + assert.match(namedBridge, /Volatile\.Read\(ref _namedPlaylistWriteInFlight\) != 0/); + assert.match(namedBridge, + /Interlocked\.Exchange\(\s*ref _namedPlaylistWriteCancellation,\s*null\)/s); +}); + +test("native replacement accepts only the exact eight raw-row properties and 1000 rows", () => { + for (const property of [ + "itemIndex", "enabled", "groupCode", "subject", + "graphicType", "subtype", "pageText", "dataCode" + ]) { + assert.match(namedBridge, new RegExp(`"${property}"`)); + } + assert.match(namedBridge, + /itemsElement\.GetArrayLength\(\) > LegacyNamedPlaylistPersistenceService\.MaximumItems/); + assert.match(namedBridge, /itemIndex != expectedIndex/); + assert.match(namedBridge, /value\.Contains\('\^'\)/); + assert.match(namedBridge, + /return string\.Equals\(page\.ToString\(\), value, StringComparison\.Ordinal\)/); +}); + +test("page preflight uses one correlated read-only bridge with playout priority", () => { + assert.match(mainWindow, /case "request-playlist-page-plans"/); + assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _playlistPagePlanCancellation\)\)/); + assert.match(pagePlanBridge, + /Interlocked\.Exchange\(\s*ref _playlistPagePlanCancellation,\s*requestCancellation\)/s); + assert.match(pagePlanBridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/); + assert.ok((pagePlanBridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 2); + assert.match(pagePlanBridge, /PreflightPagePlansAsync\(/); + assert.match(pagePlanBridge, /PostMessage\("playlist-page-plans-results"/); + assert.match(pagePlanBridge, /PostMessage\("playlist-page-plans-error"/); + assert.doesNotMatch(pagePlanBridge, /PrepareAsync\(|PlayAsync\(|ClearAsync\(|UnloadAsync\(/); +}); + +test("browser page-plan handling requires exact correlation and exposes explicit retry", () => { + assert.match(appScript, /normalizePagePlanResponse\(payload, pending\.request\)/); + assert.match(appScript, /payload\?\.requestId !== pending\.requestId/); + assert.match(appScript, /applyPagePlanResponse\(/); + assert.match(appScript, /page-result-empty/); + assert.match(appScript, /namedPlaylistPageRetryButton/); + assert.match(appScript, /case "playlist-page-plans-results"/); + assert.match(appScript, /case "playlist-page-plans-error"/); + assert.doesNotMatch(appScript, /markPageRecalculated\(/); +}); diff --git a/tests/Web/named-playlist-workflow.test.cjs b/tests/Web/named-playlist-workflow.test.cjs new file mode 100644 index 0000000..283eee7 --- /dev/null +++ b/tests/Web/named-playlist-workflow.test.cjs @@ -0,0 +1,430 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/named-playlist-workflow.js"); + +const retrievedAt = "2026-07-11T09:30:00+09:00"; +const selection = Object.freeze({ + groupCode: "KOSPI", + subject: "Samsung Electronics", + graphicType: "ONE_COLUMN", + subtype: "CURRENT", + dataCode: "005930" +}); + +function trustedEntry(overrides = {}) { + return { + id: "entry-1", + builderKey: "s5001", + code: "5001", + enabled: true, + selection, + operator: { source: "trusted-test", schemaVersion: 1 }, + ...overrides + }; +} + +function nativeRow(overrides = {}) { + return { + itemIndex: 0, + enabled: true, + groupCode: "KOSPI", + subject: "Samsung Electronics", + graphicType: "ONE_COLUMN", + subtype: "CURRENT", + pageText: "1/1", + dataCode: "005930", + ...overrides + }; +} + +function loadResponse(rows = [nativeRow()], overrides = {}) { + return { + requestId: "load-1", + definition: { programCode: "00000001", title: "Morning" }, + retrievedAt, + totalRowCount: rows.length, + canMutate: true, + writeQuarantined: false, + rows, + ...overrides + }; +} + +test("named-playlist bridge contract is closed and bounded", () => { + assert.deepEqual(workflow.bridgeContract, { + requests: { + list: "request-named-playlist-list", + load: "request-named-playlist-load", + create: "create-named-playlist", + replace: "replace-named-playlist", + delete: "delete-named-playlist" + }, + responses: { + list: "named-playlist-list-results", + load: "named-playlist-load-results", + mutation: "named-playlist-mutation-results", + error: "named-playlist-error" + }, + schemaVersion: 1, + maximumDefinitions: 1000, + maximumItems: 1000, + maximumPageCount: 20, + maximumStoredPageCount: 9999 + }); + assert.deepEqual(workflow.operations, ["list", "load", "create", "replace", "delete"]); +}); + +test("list, load, create and delete request payloads require canonical identities", () => { + assert.deepEqual(workflow.createListRequest("list-1"), { + requestId: "list-1", maximumResults: 200 + }); + assert.deepEqual(workflow.createListRequest("list-2", 1000), { + requestId: "list-2", maximumResults: 1000 + }); + assert.deepEqual(workflow.createLoadRequest("load-1", "00000001"), { + requestId: "load-1", programCode: "00000001" + }); + assert.deepEqual(workflow.createDefinitionRequest("create-1", "00000001", "Morning"), { + requestId: "create-1", programCode: "00000001", title: "Morning" + }); + assert.deepEqual(workflow.createDeleteRequest("delete-1", "00000001"), { + requestId: "delete-1", programCode: "00000001" + }); + assert.throws(() => workflow.createListRequest("bad id"), /safe/i); + assert.throws(() => workflow.createListRequest("list", 1001), /1 through 1000/i); + assert.throws(() => workflow.createLoadRequest("load", "1"), /eight-digit/i); + assert.throws(() => workflow.createDefinitionRequest("create", "00000001", " Morning"), /canonical/i); +}); + +test("trusted entries serialize to the exact original seven LIST_TEXT fields", () => { + const entry = trustedEntry(); + assert.deepEqual(workflow.toLegacySevenFields(entry, "2/4"), [ + "1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "2/4", "005930" + ]); + assert.equal( + workflow.legacyListText(workflow.toLegacySevenFields(entry, "2/4")), + "1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^2/4^005930"); + + const request = workflow.createReplaceRequest( + "replace-1", + "00000001", + [entry, trustedEntry({ id: "entry-2", enabled: false })], + { + isTrustedEntry: () => true, + pageTextForEntry: (_, index) => index === 0 ? "2/4" : "1/0" + }); + assert.deepEqual(request.items, [ + { + itemIndex: 0, enabled: true, groupCode: "KOSPI", subject: "Samsung Electronics", + graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "2/4", dataCode: "005930" + }, + { + itemIndex: 1, enabled: false, groupCode: "KOSPI", subject: "Samsung Electronics", + graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "1/0", dataCode: "005930" + } + ]); +}); + +test("database replacement requires trusted verification and enforces the 1000-row bound", () => { + assert.throws(() => workflow.createReplaceRequest( + "replace", "00000001", [trustedEntry()]), /trusted-entry verifier/i); + assert.throws(() => workflow.createReplaceRequest( + "replace", "00000001", [trustedEntry()], { isTrustedEntry: () => false }), /trusted restoration/i); + + const entries = Array.from({ length: 1000 }, (_, index) => trustedEntry({ id: `entry-${index}` })); + const bounded = workflow.createReplaceRequest( + "replace", "00000001", entries, { isTrustedEntry: () => true }); + assert.equal(bounded.items.length, 1000); + assert.throws(() => workflow.createReplaceRequest( + "replace", "00000001", [...entries, trustedEntry({ id: "overflow" })], + { isTrustedEntry: () => true }), /at most 1000/i); +}); + +test("unsafe selection delimiters, controls and noncanonical pages never reach the bridge", () => { + assert.throws(() => workflow.toLegacySevenFields(trustedEntry({ + selection: { ...selection, subject: "Alpha^Beta" } + })), /cannot be stored safely/i); + assert.throws(() => workflow.toLegacySevenFields(trustedEntry({ + selection: { ...selection, subject: "Alpha\nBeta" } + })), /cannot be stored safely/i); + assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "01/02"), /page text/i); + assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "0/0"), /page text/i); + assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "1/10000"), /page text/i); + assert.throws(() => workflow.legacyListText([ + "yes", "KOSPI", "Alpha", "ONE_COLUMN", "CURRENT", "1/1", "000001" + ]), /seven safe/i); +}); + +test("stored page parsing preserves historical values but marks the current 20-page boundary", () => { + assert.deepEqual(workflow.parseStoredPageText("4/20"), { + pageText: "4/20", currentPage: 4, totalPages: 20, + isWithinPlayoutBounds: true, + historicalPageOutOfBounds: false, + pageRecalculationRequired: true + }); + assert.deepEqual(workflow.parseStoredPageText("1/0"), { + pageText: "1/0", currentPage: 1, totalPages: 0, + isWithinPlayoutBounds: false, + historicalPageOutOfBounds: true, + pageRecalculationRequired: true + }); + assert.deepEqual(workflow.parseStoredPageText("1/24"), { + pageText: "1/24", currentPage: 1, totalPages: 24, + isWithinPlayoutBounds: false, + historicalPageOutOfBounds: true, + pageRecalculationRequired: true + }); + for (const value of ["", "01/1", "1/01", "2/1", "2/0", "1/10000", "text"]) { + assert.equal(workflow.parseStoredPageText(value), null); + } +}); + +test("list responses are exact, duplicate-safe and expose permanent write quarantine", () => { + const response = { + requestId: "list-1", + retrievedAt, + totalRowCount: 2, + truncated: false, + suggestedProgramCode: "00000003", + canMutate: true, + writeQuarantined: false, + definitions: [ + { programCode: "00000001", title: "Morning" }, + { programCode: "00000002", title: "Noon" } + ] + }; + assert.deepEqual(workflow.normalizeListResponse(response), response); + assert.equal(workflow.normalizeListResponse({ ...response, extra: true }), null); + assert.equal(workflow.normalizeListResponse({ + ...response, + definitions: [response.definitions[0], response.definitions[0]] + }), null); + assert.equal(workflow.normalizeListResponse({ + ...response, canMutate: true, writeQuarantined: true + }), null); + const quarantined = workflow.normalizeListResponse({ + ...response, canMutate: false, writeQuarantined: true + }); + assert.equal(workflow.isWriteBlocked(quarantined), true); +}); + +test("load normalization preserves every raw field before trusted restoration", () => { + const normalized = workflow.normalizeLoadResponse(loadResponse([ + nativeRow({ pageText: "1/24" }), + nativeRow({ itemIndex: 1, enabled: false, subject: "SK hynix", dataCode: "000660", pageText: "" }) + ])); + assert.ok(normalized); + assert.equal(normalized.rawRows.length, 2); + assert.deepEqual(normalized.rawRows[0].legacyFields, [ + "1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "1/24", "005930" + ]); + assert.equal(normalized.rawRows[0].listText, + "1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^1/24^005930"); + assert.equal(normalized.rawRows[0].requiresTrustedRestore, true); + assert.equal(normalized.rawRows[0].pageRecalculationRequired, true); + assert.equal(normalized.rawRows[0].historicalPageOutOfBounds, true); + assert.equal(normalized.rawRows[1].requiresTrustedRestore, true); + assert.equal(normalized.rawRows[1].pageRecalculationRequired, false); + assert.match(workflow.pageStatusLabel(normalized.rawRows[0]), /20-page maximum/i); +}); + +test("load responses reject extra fields, reordered indexes and malformed historical pages", () => { + assert.equal(workflow.normalizeLoadResponse({ ...loadResponse(), extra: true }), null); + assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ itemIndex: 1 })])), null); + assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "01/1" })])), null); + assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ subject: " Alpha" })])), null); + assert.equal(workflow.normalizeLoadResponse(loadResponse([], { totalRowCount: 1 })), null); +}); + +test("raw database rows cannot become playable until a trusted resolver reconstructs them", () => { + const load = workflow.normalizeLoadResponse(loadResponse()); + const unresolved = workflow.restoreRawRows(load, () => null); + assert.equal(unresolved.readyForPrepare, false); + assert.deepEqual(unresolved.blockers.map(value => value.reason), [ + "trusted-restore-required", "page-recalculation-required" + ]); + assert.throws(() => workflow.materializeTrustedPlaylist(unresolved), /trusted restoration/i); + assert.equal(unresolved.rows[0].rawRow, load.rawRows[0]); +}); + +test("trusted resolver output must reproduce enabled state and all five selection fields", () => { + const load = workflow.normalizeLoadResponse(loadResponse()); + assert.equal(workflow.restoreRawRows(load, () => trustedEntry()).rows[0].trusted, true); + assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ enabled: false })).rows[0].trusted, false); + assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ + selection: { ...selection, subject: "Tampered" } + })).rows[0].trusted, false); + assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ code: "bad" })).rows[0].trusted, false); + assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ builderKey: "5001" })).rows[0].trusted, false); +}); + +test("fresh page plans are mandatory before PREPARE and cannot exceed 20 pages", () => { + const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })])); + const restored = workflow.restoreRawRows(load, () => trustedEntry()); + assert.equal(restored.readyForPrepare, false); + assert.throws(() => workflow.markPageRecalculated(restored, 0, { + itemCount: 101, pageSize: 5, pageCount: 21 + }), /exceeds 20 pages/i); + + const recalculated = workflow.markPageRecalculated(restored, 0, { + itemCount: 100, pageSize: 5, pageCount: 20 + }); + assert.equal(recalculated.readyForPrepare, true); + assert.equal(workflow.canPrepareRestoredRows(recalculated), true); + assert.deepEqual(recalculated.rows[0].recalculatedPagePlan, { + itemCount: 100, pageSize: 5, pageCount: 20 + }); + assert.equal(recalculated.rows[0].rawRow.pageText, "1/24"); + assert.deepEqual(workflow.materializeTrustedPlaylist(recalculated), [trustedEntry()]); +}); + +test("paged trusted rows create a strict preflight request without using stored page text", () => { + function prepare(pageText) { + const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText })])); + const restored = workflow.restoreRawRows(load, () => trustedEntry({ + builderKey: "s5074", + code: "5074", + fadeDuration: 6 + })); + return workflow.preparePagePreflight(restored, entry => entry.builderKey === "s5074"); + } + + const request = workflow.createPagePlanRequest("page-plan-1", prepare("1/24")); + const requestFromDifferentHistoricalPage = workflow.createPagePlanRequest( + "page-plan-1", + prepare("4/20")); + + assert.deepEqual(request, { + requestId: "page-plan-1", + entries: [{ + id: "entry-1", + code: "5074", + enabled: true, + fadeDuration: 6, + selection + }] + }); + assert.deepEqual(requestFromDifferentHistoricalPage, request); + assert.equal(Object.hasOwn(request.entries[0], "pageText"), false); +}); + +test("correlated capped page plans enable PREPARE and preserve explicit truncation", () => { + const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })])); + let restored = workflow.restoreRawRows(load, () => trustedEntry({ + builderKey: "s5074", + code: "5074", + fadeDuration: 6 + })); + restored = workflow.preparePagePreflight(restored, () => true); + const request = workflow.createPagePlanRequest("page-plan-2", restored); + const rawResponse = { + requestId: "page-plan-2", + calculatedAt: retrievedAt, + plans: [{ + entryId: "entry-1", + itemCount: 101, + pageSize: 5, + pageCount: 20, + accessibleItemCount: 100, + isTruncated: true, + builderKey: "s5074" + }] + }; + const response = workflow.normalizePagePlanResponse(rawResponse, request); + assert.ok(response); + restored = workflow.applyPagePlanResponse(restored, response); + + assert.equal(restored.readyForPrepare, true); + assert.equal(restored.rows[0].pageRecalculated, true); + assert.deepEqual(restored.rows[0].recalculatedPagePlan, rawResponse.plans[0]); + assert.equal(workflow.normalizePagePlanResponse({ + ...rawResponse, + requestId: "stale-request" + }, request), null); + assert.equal(workflow.normalizePagePlanResponse({ + ...rawResponse, + plans: [{ ...rawResponse.plans[0], accessibleItemCount: 101 }] + }, request), null); +}); + +test("zero-row page preflight is complete but explicitly unavailable for PREPARE", () => { + const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })])); + let restored = workflow.restoreRawRows(load, () => trustedEntry({ + builderKey: "s5077", + code: "5077", + fadeDuration: 6 + })); + restored = workflow.preparePagePreflight(restored, () => true); + const request = workflow.createPagePlanRequest("page-plan-zero", restored); + const response = workflow.normalizePagePlanResponse({ + requestId: "page-plan-zero", + calculatedAt: retrievedAt, + plans: [{ + entryId: "entry-1", + itemCount: 0, + pageSize: 6, + pageCount: 0, + accessibleItemCount: 0, + isTruncated: false, + builderKey: "s5077" + }] + }, request); + restored = workflow.applyPagePlanResponse(restored, response); + + assert.equal(restored.readyForPrepare, false); + assert.equal(restored.rows[0].pagePreflightCompleted, true); + assert.equal(restored.rows[0].pageRecalculated, false); + assert.deepEqual(restored.blockers.map(value => value.reason), ["page-result-empty"]); +}); + +test("nonpaged rows become ready without a synthetic page-plan result", () => { + const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })])); + const restored = workflow.preparePagePreflight( + workflow.restoreRawRows(load, () => trustedEntry()), + () => false); + assert.equal(restored.readyForPrepare, true); + assert.equal(workflow.createPagePlanRequest("no-page-plan", restored), null); + assert.equal(restored.rows[0].recalculatedPagePlan, null); +}); + +test("rows without stored page text still require trust but no artificial page plan", () => { + const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })])); + const restored = workflow.restoreRawRows(load, () => trustedEntry()); + assert.equal(restored.readyForPrepare, true); + assert.deepEqual(restored.blockers, []); + assert.equal(restored.rows[0].pageRecalculated, true); +}); + +test("mutation responses are strict and retain quarantine state", () => { + const response = { + requestId: "replace-1", + operation: "replace", + programCode: "00000001", + completedAt: retrievedAt, + writeQuarantined: false + }; + assert.deepEqual(workflow.normalizeMutationResponse(response), response); + assert.equal(workflow.normalizeMutationResponse({ ...response, operation: "load" }), null); + assert.equal(workflow.normalizeMutationResponse({ ...response, extra: true }), null); +}); + +test("OutcomeUnknown errors permanently block writes and can never be retryable", () => { + const error = { + requestId: "replace-1", + operation: "replace", + programCode: "00000001", + code: "OUTCOME_UNKNOWN", + message: "Restart before another write.", + retryable: false, + outcomeUnknown: true, + writeQuarantined: true + }; + const normalized = workflow.normalizeError(error); + assert.deepEqual(normalized, error); + assert.equal(workflow.isWriteBlocked(normalized), true); + assert.equal(workflow.normalizeError({ ...error, retryable: true }), null); + assert.equal(workflow.normalizeError({ ...error, writeQuarantined: false }), null); + assert.equal(workflow.normalizeError({ ...error, extra: true }), null); +}); diff --git a/tests/Web/operator-catalog-bridge-integration.test.cjs b/tests/Web/operator-catalog-bridge-integration.test.cjs new file mode 100644 index 0000000..7d36307 --- /dev/null +++ b/tests/Web/operator-catalog-bridge-integration.test.cjs @@ -0,0 +1,53 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const bridge = fs.readFileSync( + path.join(repositoryRoot, "MainWindow.OperatorCatalogs.cs"), + "utf8"); + +test("theme, expert, schema and next-code reads use entity-operation lanes", () => { + assert.match(bridge, + /Dictionary _operatorCatalogReadLanes/); + assert.match(bridge, + /new OperatorCatalogReadRequest\(\s*\$"\{entity\}:\{operation\}"/s); + assert.match(bridge, /_operatorCatalogReadLanes\[request\.Lane\] = request/); + assert.match(bridge, /_operatorCatalogReadLanes\.TryGetValue\(request\.Lane/); + assert.doesNotMatch(bridge, /_operatorCatalogReadCancellation/); + assert.match(bridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/); + assert.match(bridge, /OperatorCatalogBridgeReply/); +}); + +test("each catalog request publishes at most one terminal result or error", () => { + assert.match(bridge, /CancelRequest\(superseded\.Cancellation\)/); + assert.match(bridge, /superseded\?\.TryComplete\(\)/); + assert.match(bridge, /"CANCELLED"/); + assert.match(bridge, /newer request replaced it/); + assert.match(bridge, + /Interlocked\.CompareExchange\(ref _terminalState, 1, 0\) == 0/); + assert.match(bridge, + /TryCompleteOperatorCatalogReadIfCurrent\(request\)[\s\S]*PostMessage\(reply\.MessageType/s); + assert.match(bridge, + /ReferenceEquals\(current, request\) &&[\s\S]*request\.TryComplete\(\)/s); + assert.match(bridge, + /PostOperatorCatalogReadErrorIfCurrent\([\s\S]*!TryCompleteOperatorCatalogReadIfCurrent\(request\)/s); +}); + +test("browser invalidation, playout priority and shutdown cover every active lane", () => { + assert.match(bridge, /private OperatorCatalogReadRequest\[\] SnapshotOperatorCatalogReads/); + assert.ok((bridge.match(/SnapshotOperatorCatalogReads\(clear: true\)/g) || []).length >= 2); + assert.match(bridge, + /CancelOperatorCatalogReadsForPlayout\(\)[\s\S]*SnapshotOperatorCatalogReads\(clear: false\)/s); +}); + +test("write safety gate and process quarantine remain intact", () => { + assert.match(bridge, + /CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/); + assert.match(bridge, /LatchOperatorCatalogWriteQuarantine/); + assert.match(bridge, /Interlocked\.Exchange\(ref _operatorCatalogWriteCancellation, null\)/); + assert.doesNotMatch(bridge, /operationBody\(requestToken\)\.ConfigureAwait\(false\)/); +}); diff --git a/tests/Web/operator-catalog-ui.test.cjs b/tests/Web/operator-catalog-ui.test.cjs new file mode 100644 index 0000000..4014cf3 --- /dev/null +++ b/tests/Web/operator-catalog-ui.test.cjs @@ -0,0 +1,348 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const workflow = require("../../Web/operator-catalog-workflow.js"); +const ui = require("../../Web/operator-catalog-ui.js"); + +const retrievedAt = "2026-07-12T10:00:00+09:00"; + +function themeContext(title = "AI 반도체") { + return { + selection: { + market: "krx", + session: "notApplicable", + themeCode: "00000001", + themeTitle: title, + source: "oracle" + }, + preview: { + requestId: "theme-preview-1", + selection: { + market: "krx", + session: "notApplicable", + themeCode: "00000001", + title + }, + retrievedAt, + totalRowCount: 2, + truncated: false, + items: [ + { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }, + { inputIndex: 4, itemCode: "D035720", itemName: "카카오" } + ] + } + }; +} + +function expertContext() { + return { + selection: { expertCode: "0001", expertName: "홍길동", source: "oracle" }, + preview: { + requestId: "expert-preview-1", + selection: { expertCode: "0001", expertName: "홍길동" }, + retrievedAt, + totalRowCount: 2, + truncated: false, + items: [ + { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 }, + { playIndex: 8, stockCode: "035720", stockName: "카카오", buyAmount: 45000 } + ] + } + }; +} + +function harness() { + const posted = []; + const toasts = []; + const logs = []; + let sequence = 0; + let isLocked = false; + const controller = ui.createController({ + postNative(type, payload) { posted.push({ type, payload }); }, + isLocked() { return isLocked; }, + showToast(message) { toasts.push(message); }, + addLog(message) { logs.push(message); }, + createId() { sequence += 1; return String(sequence); } + }); + return { + controller, + posted, + toasts, + logs, + setExternalLocked(value) { isLocked = value; } + }; +} + +function completeSchema(h, overrides = {}) { + const pending = h.controller.render().activeRead; + assert.equal(pending.type, workflow.bridgeContract.requests.schemaPreflight); + h.controller.handleMessage(workflow.bridgeContract.events.schemaPreflight, { + requestId: pending.request.requestId, + validatedAt: retrievedAt, + validatedSources: ["oracle", "mariaDb"], + themeCanMutate: true, + expertCanMutate: true, + writeQuarantined: false, + ...overrides + }); +} + +function mutationResult(active, overrides = {}) { + return { + requestId: active.requestId, + entity: active.entity, + operation: active.operation, + identityCode: active.identityCode, + operationId: "123e4567-e89b-12d3-a456-426614174000", + committedAt: retrievedAt, + writeQuarantined: false, + ...overrides + }; +} + +test("controller exposes the independent integration API", () => { + const h = harness(); + for (const method of [ + "mount", "openTheme", "openExpert", "handleMessage", "render", "setLocked" + ]) assert.equal(typeof h.controller[method], "function"); +}); + +test("theme edit starts only from a closed selection and complete preview", () => { + const h = harness(); + const opened = h.controller.openTheme(themeContext()); + + assert.equal(opened.entity, "theme"); + assert.equal(opened.mode, "edit"); + assert.deepEqual(opened.draft.items, [ + { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }, + { inputIndex: 1, itemCode: "D035720", itemName: "카카오" } + ]); + assert.equal(h.posted[0].type, workflow.bridgeContract.requests.schemaPreflight); + assert.throws(() => h.controller.openTheme({ selection: themeContext().selection }), /exactly/i); +}); + +test("theme prefixes, duplicate checks and reordering remain explicit in replace requests", () => { + const h = harness(); + h.controller.openTheme(themeContext()); + completeSchema(h); + assert.equal(h.controller.addThemeItem("X", "000660", "SK하이닉스"), false); + assert.equal(h.controller.addThemeItem("P", "000660", "SK하이닉스"), true); + assert.equal(h.controller.moveDraftItem(2, -1), true); + assert.equal(h.controller.updateDraft({ themeTitle: "AI·로봇" }), true); + + assert.deepEqual(h.controller.render().draft.items, [ + { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }, + { inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" }, + { inputIndex: 2, itemCode: "D035720", itemName: "카카오" } + ]); + assert.equal(h.controller.submit(), true); + const sent = h.posted.at(-1); + assert.equal(sent.type, workflow.bridgeContract.requests.themeReplace); + assert.equal(sent.payload.newTitle, "AI·로봇"); + assert.deepEqual(sent.payload.items, h.controller.render().draft.items); + + const active = h.controller.render().mutation; + h.controller.handleMessage(workflow.bridgeContract.events.mutation, + mutationResult(active, { requestId: "stale" })); + assert.deepEqual(h.controller.render().mutation, active); + h.controller.handleMessage(workflow.bridgeContract.events.mutation, mutationResult(active)); + assert.equal(h.controller.render().mutation, null); + assert.equal(h.controller.render().mode, "list"); +}); + +test("schema, catalog search and next-code reads are serialized and exactly correlated", () => { + const h = harness(); + h.controller.openTheme(); + assert.equal(h.posted.length, 1); + completeSchema(h); + assert.equal(h.posted.at(-1).type, workflow.bridgeContract.requests.themeList); + + let pending = h.controller.render().activeRead; + h.controller.handleMessage(workflow.bridgeContract.events.themeList, { + requestId: pending.request.requestId, + query: "", + nxtSession: "preMarket", + retrievedAt, + totalRowCount: 1, + truncated: false, + canMutate: true, + writeQuarantined: false, + results: [{ + market: "krx", session: "notApplicable", themeCode: "00000001", + themeTitle: "AI", source: "oracle" + }] + }); + assert.equal(h.controller.render().themeResults.length, 1); + + h.controller.search("old", "afterMarket"); + const old = h.controller.render().activeRead; + h.controller.search("latest", "afterMarket"); + assert.equal(h.controller.render().activeRead.superseded, true); + h.controller.handleMessage(workflow.bridgeContract.events.themeList, { + requestId: old.request.requestId, + query: "old", + nxtSession: "afterMarket", + retrievedAt, + totalRowCount: 0, + truncated: false, + canMutate: true, + writeQuarantined: false, + results: [] + }); + pending = h.controller.render().activeRead; + assert.equal(pending.request.query, "latest"); + assert.equal(h.controller.render().themeResults.length, 1); + h.controller.handleMessage(workflow.bridgeContract.events.themeList, { + requestId: pending.request.requestId, + query: "latest", + nxtSession: "afterMarket", + retrievedAt, + totalRowCount: 0, + truncated: false, + canMutate: true, + writeQuarantined: false, + results: [] + }); + assert.equal(h.controller.render().themeResults.length, 0); + + assert.equal(h.controller.beginNewTheme("nxt"), true); + pending = h.controller.render().activeRead; + h.controller.handleMessage(workflow.bridgeContract.events.themeNextCode, { + requestId: pending.request.requestId, + market: "nxt", + themeCode: "00000007", + canMutate: true, + writeQuarantined: false + }); + assert.equal(h.controller.render().mode, "new"); + assert.equal(h.controller.addThemeItem("P", "005930", "삼성전자"), false); + assert.equal(h.controller.addThemeItem("X", "005930", "삼성전자"), true); + assert.equal(h.controller.addThemeItem("T", "000660", "SK하이닉스"), true); +}); + +test("expert recommendations preserve exact code, name, buy amount and order", () => { + const h = harness(); + h.controller.openExpert(expertContext()); + completeSchema(h); + assert.equal(h.controller.addExpertRecommendation("005930", "중복", 1), false); + assert.equal(h.controller.addExpertRecommendation("000660", "SK하이닉스", "123456"), true); + assert.equal(h.controller.moveDraftItem(2, -1), true); + assert.equal(h.controller.updateDraft({ expertName: "홍길동 위원" }), true); + assert.equal(h.controller.submit(), true); + + const sent = h.posted.at(-1); + assert.equal(sent.type, workflow.bridgeContract.requests.expertReplace); + assert.equal(sent.payload.newName, "홍길동 위원"); + assert.deepEqual(sent.payload.recommendations, [ + { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 }, + { playIndex: 1, stockCode: "000660", stockName: "SK하이닉스", buyAmount: 123456 }, + { playIndex: 2, stockCode: "035720", stockName: "카카오", buyAmount: 45000 } + ]); +}); + +test("OutcomeUnknown latches a process quarantine and never retries", () => { + const h = harness(); + h.controller.openExpert(expertContext()); + completeSchema(h); + h.controller.updateDraft({ expertName: "홍길동 위원" }); + assert.equal(h.controller.submit(), true); + const active = h.controller.render().mutation; + const writeCount = h.posted.length; + h.controller.handleMessage(workflow.bridgeContract.events.error, { + requestId: active.requestId, + entity: active.entity, + operation: active.operation, + identityCode: active.identityCode, + code: "OUTCOME_UNKNOWN", + message: "Restart and reconcile before another write.", + retryable: false, + outcomeUnknown: true, + writeQuarantined: true + }); + + assert.equal(h.controller.render().quarantined, true); + assert.equal(h.controller.render().mutation, null); + assert.equal(h.controller.submit(), false); + assert.equal(h.posted.length, writeCount); +}); + +test("a malformed same-request mutation result loses correlation and quarantines", () => { + const h = harness(); + h.controller.openTheme(themeContext()); + completeSchema(h); + assert.equal(h.controller.submit(), true); + const active = h.controller.render().mutation; + h.controller.handleMessage(workflow.bridgeContract.events.mutation, { + ...mutationResult(active), + operationId: "not-a-uuid" + }); + assert.equal(h.controller.render().quarantined, true); + assert.equal(h.controller.render().mutation, null); +}); + +class FakeElement { + constructor(tagName, ownerDocument) { + this.tagName = tagName.toUpperCase(); + this.ownerDocument = ownerDocument; + this.children = []; + this.attributes = new Map(); + this.listeners = new Map(); + this.textContent = ""; + this.value = ""; + this.hidden = false; + this.disabled = false; + this.open = false; + } + append(...values) { values.forEach(value => this.appendChild(value)); } + appendChild(value) { this.children.push(value); return value; } + replaceChildren(...values) { this.children = []; this.append(...values); } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + removeAttribute(name) { this.attributes.delete(name); } + addEventListener(name, callback) { this.listeners.set(name, callback); } +} + +class FakeDocument { + constructor() { + this.head = new FakeElement("head", this); + this.body = new FakeElement("body", this); + } + createElement(tagName) { return new FakeElement(tagName, this); } + getElementById(id) { + function find(node) { + if (node.id === id) return node; + for (const child of node.children) { + const match = find(child); + if (match) return match; + } + return null; + } + return find(this.head) || find(this.body); + } +} + +function findRole(node, role) { + if (node.attributes?.get("data-role") === role) return node; + for (const child of node.children || []) { + const match = findRole(child, role); + if (match) return match; + } + return null; +} + +test("mount creates a dialog with text-only rendering and its own stylesheet", () => { + const h = harness(); + const document = new FakeDocument(); + h.controller.mount(document.body); + h.controller.openTheme(themeContext("AI")); + assert.equal(document.body.children[0].tagName, "DIALOG"); + assert.equal(document.head.children[0].href, "operator-catalog-ui.css"); + assert.equal(findRole(document.body, "identity-name").value, "AI"); + + const source = fs.readFileSync( + path.resolve(__dirname, "../../Web/operator-catalog-ui.js"), + "utf8"); + assert.doesNotMatch(source, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(/); +}); diff --git a/tests/Web/operator-catalog-workflow.test.cjs b/tests/Web/operator-catalog-workflow.test.cjs new file mode 100644 index 0000000..ea47d21 --- /dev/null +++ b/tests/Web/operator-catalog-workflow.test.cjs @@ -0,0 +1,446 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const workflow = require("../../Web/operator-catalog-workflow.js"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const nativeBridge = fs.readFileSync( + path.join(repositoryRoot, "MainWindow.OperatorCatalogs.cs"), + "utf8"); +const playoutBridge = fs.readFileSync( + path.join(repositoryRoot, "MainWindow.Playout.cs"), + "utf8"); +const operatorGate = fs.readFileSync( + path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"), + "utf8"); + +const krxSelection = Object.freeze({ + market: "krx", + session: "notApplicable", + themeCode: "00000001", + themeTitle: "AI 반도체", + source: "oracle" +}); + +const expertSelection = Object.freeze({ + expertCode: "0001", + expertName: "홍길동", + source: "oracle" +}); + +function themePreview(overrides = {}) { + return { + requestId: "theme-preview-1", + selection: { + market: "krx", + session: "notApplicable", + themeCode: "00000001", + title: "AI 반도체" + }, + retrievedAt: "2026-07-11T23:00:00+09:00", + totalRowCount: 2, + truncated: false, + items: [ + { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }, + { inputIndex: 3, itemCode: "D035720", itemName: "카카오" } + ], + ...overrides + }; +} + +function expertPreview(overrides = {}) { + return { + requestId: "expert-preview-1", + selection: { expertCode: "0001", expertName: "홍길동" }, + retrievedAt: "2026-07-11T23:00:00+09:00", + totalRowCount: 2, + truncated: false, + items: [ + { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 }, + { playIndex: 4, stockCode: "035720", stockName: "카카오", buyAmount: 45000 } + ], + ...overrides + }; +} + +test("bridge contract pins every independent request and event name", () => { + assert.deepEqual(workflow.bridgeContract.requests, { + themeList: "request-theme-catalog-list", + expertList: "request-expert-catalog-list", + schemaPreflight: "request-operator-catalog-schema", + themeNextCode: "request-theme-catalog-next-code", + expertNextCode: "request-expert-catalog-next-code", + themeCreate: "create-theme-catalog", + themeReplace: "replace-theme-catalog", + themeDelete: "delete-theme-catalog", + expertCreate: "create-expert-catalog", + expertReplace: "replace-expert-catalog", + expertDelete: "delete-expert-catalog" + }); + assert.deepEqual(workflow.bridgeContract.events, { + themeList: "theme-catalog-list-results", + expertList: "expert-catalog-list-results", + schemaPreflight: "operator-catalog-schema-results", + themeNextCode: "theme-catalog-next-code-results", + expertNextCode: "expert-catalog-next-code-results", + mutation: "operator-catalog-mutation-results", + error: "operator-catalog-error" + }); + assert.deepEqual(workflow.limits, { + maximumThemeItems: 240, + maximumRecommendations: 100, + maximumResults: 500, + maximumQueryLength: 64 + }); +}); + +test("native partial pins the same contract and fail-closed write gates without a retry loop", () => { + for (const requestType of Object.values(workflow.bridgeContract.requests)) { + assert.match(nativeBridge, new RegExp(`"${requestType}"`)); + } + for (const eventType of Object.values(workflow.bridgeContract.events)) { + assert.match(nativeBridge, new RegExp(`"${eventType}"`)); + } + assert.match(nativeBridge, /_operatorCatalogWriteGate\.WaitAsync\(0\)/); + assert.match(nativeBridge, /_playoutCommandGate\.WaitAsync\(0, requestToken\)/); + assert.match(nativeBridge, /_databaseActivityGate\.WaitAsync\(0, requestToken\)/); + assert.match(nativeBridge, /CanStartOperatorCatalogWrite\(\)/); + assert.match(nativeBridge, + /CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/); + assert.match(playoutBridge, /private bool CanStartOperatorMutation\(/); + assert.match(playoutBridge, /new OperatorMutationGateSnapshot\(/); + assert.match(operatorGate, /!snapshot\.IsPlayCompletionPending/); + assert.match(operatorGate, /snapshot\.PreparedSceneName/); + assert.match(operatorGate, /snapshot\.OnAirSceneName/); + assert.match(operatorGate, /snapshot\.PreparedCutCode/); + assert.match(operatorGate, /snapshot\.OnAirCutCode/); + assert.match(nativeBridge, /LatchOperatorCatalogWriteQuarantine/); + assert.match(nativeBridge, /OUTCOME_UNKNOWN/); + assert.match(nativeBridge, /OperatorCatalogMutationExecutor/); + assert.doesNotMatch(nativeBridge, /MaximumRetryCount|Task\.Delay|while\s*\(/); +}); + +test("list, schema and next-code requests are exact, normalized and bounded", () => { + assert.deepEqual(workflow.createThemeCatalogListRequest( + "theme-list-1", " AI ", "preMarket", 25), { + requestId: "theme-list-1", + query: "AI", + nxtSession: "preMarket", + maximumResults: 25 + }); + assert.deepEqual(workflow.createExpertCatalogListRequest("expert-list-1", " 홍 "), { + requestId: "expert-list-1", + query: "홍" + }); + assert.deepEqual(workflow.createSchemaPreflightRequest("schema-1"), { + requestId: "schema-1" + }); + assert.deepEqual(workflow.createThemeNextCodeRequest("next-1", "nxt"), { + requestId: "next-1", + market: "nxt" + }); + assert.deepEqual(workflow.createExpertNextCodeRequest("next-2"), { + requestId: "next-2" + }); + assert.throws(() => workflow.createThemeCatalogListRequest("bad id", "", "preMarket"), /request id/); + assert.throws(() => workflow.createThemeCatalogListRequest("safe", "", "none"), /session/); + assert.throws(() => workflow.createExpertCatalogListRequest("safe", "bad\u0000query"), /query/); + assert.throws(() => workflow.createExpertCatalogListRequest("safe", "", 501), /limited/); +}); + +test("theme selection plus complete preview becomes an editable explicit identity and reindexed draft", () => { + const edit = workflow.createThemeEditDraft(krxSelection, themePreview()); + assert.deepEqual(edit, { + expectedIdentity: { + market: "krx", + session: "notApplicable", + themeCode: "00000001", + themeTitle: "AI 반도체" + }, + draft: { + market: "krx", + themeCode: "00000001", + themeTitle: "AI 반도체", + items: [ + { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }, + { inputIndex: 1, itemCode: "D035720", itemName: "카카오" } + ] + } + }); + edit.draft.themeTitle = "AI·로봇"; + edit.draft.items.reverse(); + edit.draft.items.forEach((item, index) => { item.inputIndex = index; }); + const request = workflow.createThemeReplaceRequest("theme-replace-1", edit); + assert.deepEqual(request, { + requestId: "theme-replace-1", + expectedIdentity: { + market: "krx", + session: "notApplicable", + themeCode: "00000001", + themeTitle: "AI 반도체" + }, + newTitle: "AI·로봇", + items: [ + { inputIndex: 0, itemCode: "D035720", itemName: "카카오" }, + { inputIndex: 1, itemCode: "P005930", itemName: "삼성전자" } + ] + }); +}); + +test("theme edit conversion rejects truncated, stale, duplicate and market-mismatched previews", () => { + assert.throws(() => workflow.createThemeEditDraft( + krxSelection, + themePreview({ truncated: true })), /complete/); + assert.throws(() => workflow.createThemeEditDraft( + krxSelection, + themePreview({ selection: { ...themePreview().selection, themeCode: "00000002" } })), /correlated/); + assert.throws(() => workflow.createThemeEditDraft( + krxSelection, + themePreview({ + items: [ + { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }, + { inputIndex: 1, itemCode: "D035720", itemName: "삼성전자" } + ] + })), /complete/); + assert.equal(workflow.normalizeThemeDraft({ + market: "nxt", + themeCode: "00000001", + themeTitle: "로봇(NXT)", + items: [] + }), null); + assert.equal(workflow.normalizeThemeDraft({ + market: "krx", + themeCode: "00000001", + themeTitle: "AI", + items: [{ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" }] + }), null); +}); + +test("new theme drafts require a valid title before create and delete keeps full identity", () => { + const draft = workflow.createBlankThemeDraft("nxt", "00000003"); + assert.deepEqual(draft, { + market: "nxt", + themeCode: "00000003", + themeTitle: "", + items: [] + }); + assert.throws(() => workflow.createThemeCreateRequest("create-1", draft), /complete/); + draft.themeTitle = "로봇"; + draft.items.push({ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" }); + assert.deepEqual(workflow.createThemeCreateRequest("create-1", draft), { + requestId: "create-1", + draft + }); + assert.deepEqual(workflow.createThemeDeleteRequest( + "delete-1", + { market: "nxt", session: "afterMarket", themeCode: "00000003", themeTitle: "로봇" }), { + requestId: "delete-1", + identity: { + market: "nxt", + session: "afterMarket", + themeCode: "00000003", + themeTitle: "로봇" + } + }); +}); + +test("expert selection plus preview becomes editable and preserves buy amounts while reindexing", () => { + const edit = workflow.createExpertEditDraft(expertSelection, expertPreview()); + assert.deepEqual(edit, { + expectedIdentity: { expertCode: "0001", expertName: "홍길동" }, + draft: { + expertCode: "0001", + expertName: "홍길동", + recommendations: [ + { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 }, + { playIndex: 1, stockCode: "035720", stockName: "카카오", buyAmount: 45000 } + ] + } + }); + edit.draft.expertName = "홍길동 위원"; + assert.deepEqual(workflow.createExpertReplaceRequest("expert-replace-1", edit), { + requestId: "expert-replace-1", + expectedIdentity: { expertCode: "0001", expertName: "홍길동" }, + newName: "홍길동 위원", + recommendations: edit.draft.recommendations + }); + assert.throws(() => workflow.createExpertEditDraft( + expertSelection, + expertPreview({ truncated: true })), /complete/); + assert.equal(workflow.normalizeExpertDraft({ + expertCode: "0001", + expertName: "홍길동", + recommendations: [ + { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 }, + { playIndex: 1, stockCode: "005930", stockName: "다른이름", buyAmount: 1 } + ] + }), null); +}); + +test("new expert create and delete requests carry exact draft and identity only", () => { + const draft = workflow.createBlankExpertDraft("0002"); + assert.throws(() => workflow.createExpertCreateRequest("expert-create-1", draft), /complete/); + draft.expertName = "김전문"; + draft.recommendations.push({ + playIndex: 0, + stockCode: "005930", + stockName: "삼성전자", + buyAmount: 71000 + }); + assert.deepEqual(workflow.createExpertCreateRequest("expert-create-1", draft), { + requestId: "expert-create-1", + draft + }); + assert.deepEqual(workflow.createExpertDeleteRequest( + "expert-delete-1", + { expertCode: "0002", expertName: "김전문" }), { + requestId: "expert-delete-1", + identity: { expertCode: "0002", expertName: "김전문" } + }); + assert.throws(() => workflow.createExpertCreateRequest("safe", { + ...draft, + recommendations: [{ ...draft.recommendations[0], buyAmount: 1.5 }] + }), /complete/); +}); + +test("read responses reject stale correlation, ambiguous identities and quarantined mutation flags", () => { + const themeRequest = workflow.createThemeCatalogListRequest("theme-list-1", "", "preMarket", 10); + const themeResult = { + requestId: "theme-list-1", + query: "", + nxtSession: "preMarket", + retrievedAt: "2026-07-11T23:00:00+09:00", + totalRowCount: 1, + truncated: false, + canMutate: true, + writeQuarantined: false, + results: [krxSelection] + }; + assert.ok(workflow.normalizeThemeListResult(themeResult, themeRequest)); + assert.equal(workflow.normalizeThemeListResult({ ...themeResult, requestId: "stale" }, themeRequest), null); + assert.equal(workflow.normalizeThemeListResult({ + ...themeResult, + canMutate: true, + writeQuarantined: true + }, themeRequest), null); + + const expertRequest = workflow.createExpertCatalogListRequest("expert-list-1", "", 10); + const expertResult = { + requestId: "expert-list-1", + query: "", + retrievedAt: "2026-07-11T23:00:00+09:00", + totalRowCount: 2, + truncated: false, + canMutate: true, + writeQuarantined: false, + results: [ + expertSelection, + { expertCode: "0002", expertName: "김전문", source: "oracle" } + ] + }; + assert.ok(workflow.normalizeExpertListResult(expertResult, expertRequest)); + assert.equal(workflow.normalizeExpertListResult({ + ...expertResult, + results: [expertSelection, { expertCode: "0002", expertName: "홍길동", source: "oracle" }] + }, expertRequest), null); +}); + +test("schema and next-code results are exact and correlated", () => { + const schemaRequest = workflow.createSchemaPreflightRequest("schema-1"); + assert.deepEqual(workflow.normalizeSchemaResult({ + requestId: "schema-1", + validatedAt: "2026-07-11T23:00:00+09:00", + validatedSources: ["oracle", "mariaDb"], + themeCanMutate: true, + expertCanMutate: true, + writeQuarantined: false + }, schemaRequest).validatedSources, ["oracle", "mariaDb"]); + const themeNextRequest = workflow.createThemeNextCodeRequest("next-1", "krx"); + assert.ok(workflow.normalizeThemeNextCodeResult({ + requestId: "next-1", + market: "krx", + themeCode: "00000003", + canMutate: true, + writeQuarantined: false + }, themeNextRequest)); + assert.equal(workflow.normalizeThemeNextCodeResult({ + requestId: "next-1", + market: "nxt", + themeCode: "00000003", + canMutate: true, + writeQuarantined: false + }, themeNextRequest), null); + assert.ok(workflow.normalizeExpertNextCodeResult({ + requestId: "next-2", + expertCode: "0007", + canMutate: false, + writeQuarantined: true + }, workflow.createExpertNextCodeRequest("next-2"))); +}); + +test("mutation coordinator permits one correlated write and latches process quarantine on unknown outcome", () => { + const request = workflow.createThemeCreateRequest("create-1", { + market: "krx", + themeCode: "00000003", + themeTitle: "로봇", + items: [] + }); + const coordinator = workflow.createMutationCoordinator(); + assert.deepEqual(coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), { + requestId: "create-1", + entity: "theme", + operation: "create", + identityCode: "00000003" + }); + assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /already active/); + assert.equal(coordinator.acceptResult({ + requestId: "stale", + entity: "theme", + operation: "create", + identityCode: "00000003", + operationId: "123e4567-e89b-12d3-a456-426614174000", + committedAt: "2026-07-11T23:00:00+09:00", + writeQuarantined: false + }), null); + assert.ok(coordinator.getState().active); + const error = coordinator.acceptError({ + requestId: "create-1", + entity: "theme", + operation: "create", + identityCode: "00000003", + code: "OUTCOME_UNKNOWN", + message: "Restart and reconcile before another write.", + retryable: false, + outcomeUnknown: true, + writeQuarantined: true + }); + assert.ok(error); + assert.deepEqual(coordinator.getState(), { active: null, quarantined: true }); + assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /quarantined/); +}); + +test("known rollback clears the active coordinator without inventing an automatic retry", () => { + const request = workflow.createExpertDeleteRequest( + "delete-1", + { expertCode: "0001", expertName: "홍길동" }); + const coordinator = workflow.createMutationCoordinator(); + coordinator.begin(workflow.bridgeContract.requests.expertDelete, request); + assert.ok(coordinator.acceptError({ + requestId: "delete-1", + entity: "expert", + operation: "delete", + identityCode: "0001", + code: "CONFLICT", + message: "Refresh before a new edit.", + retryable: false, + outcomeUnknown: false, + writeQuarantined: false + })); + assert.deepEqual(coordinator.getState(), { active: null, quarantined: false }); + coordinator.begin(workflow.bridgeContract.requests.expertDelete, request); + coordinator.loseCorrelation(); + assert.deepEqual(coordinator.getState(), { active: null, quarantined: true }); +}); diff --git a/tests/Web/operator-command-matrix.test.cjs b/tests/Web/operator-command-matrix.test.cjs new file mode 100644 index 0000000..b35599a --- /dev/null +++ b/tests/Web/operator-command-matrix.test.cjs @@ -0,0 +1,156 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const fixed = require("../../Web/legacy-fixed-workflow.js"); +const industry = require("../../Web/industry-workflow.js"); +const stock = require("../../Web/operator-workflow.js"); +const comparison = require("../../Web/comparison-workflow.js"); + +const now = new Date("2026-07-11T12:00:00+09:00"); +const kospiStock = Object.freeze({ + market: "kospi", + stockName: "삼성전자", + displayName: "삼성전자", + stockCode: "005930", + isNxt: false +}); +const primaryIndustry = Object.freeze({ + market: "kospi", + code: "001", + name: "전기전자" +}); +const secondaryIndustry = Object.freeze({ + market: "kospi", + code: "002", + name: "금융업" +}); +const kosdaqPrimaryIndustry = Object.freeze({ + market: "kosdaq", + code: "101", + name: "IT" +}); +const kosdaqSecondaryIndustry = Object.freeze({ + market: "kosdaq", + code: "102", + name: "제약" +}); +const comparisonPair = Object.freeze([ + Object.freeze({ kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930" }), + Object.freeze({ kind: "krx-stock", market: "kospi", stockName: "SK하이닉스", stockCode: "000660" }) +]); + +test("the original operator command matrix has exactly 412 visible slots", () => { + assert.equal(fixed.actions.length, 328); + assert.equal(industry.cuts.length * industry.markets.length, 44); + assert.equal(stock.stockCuts.length, 31); + assert.equal(comparison.actions.length, 9); + assert.equal( + fixed.actions.length + industry.cuts.length * industry.markets.length + + stock.stockCuts.length + comparison.actions.length, + 412); + + assert.equal(new Set(fixed.actions.map(value => value.id)).size, 328); + assert.equal(new Set(industry.cuts.map(value => value.id)).size, 22); + assert.equal(new Set(stock.stockCuts.map(value => value.id)).size, 31); + assert.equal(new Set(comparison.actions.map(value => value.id)).size, 9); +}); + +test("all 322 non-manual fixed slots create and strictly round-trip", () => { + const available = fixed.actions.filter(value => value.available); + assert.equal(available.length, 322); + + for (const [index, action] of available.entries()) { + const created = fixed.createFixedPlaylistEntry(action.id, { + id: `fixed-matrix-${index + 1}`, + fadeDuration: 6, + now, + ma5: true, + ma20: true + }); + const restored = fixed.restoreFixedPlaylistEntry(created, { now }); + assert.ok(restored, `${action.id} ${action.label}`); + assert.equal(restored.builderKey, action.builderKey); + assert.equal(restored.code, action.code); + } +}); + +test("the six fixed manual placeholders resolve only through their dedicated editors", () => { + const manual = fixed.actions.filter(value => !value.available); + assert.deepEqual( + manual.map(value => [value.id, value.label, value.builderKey, value.code]), + [ + ["fixed-245", "개인 순매도 상위(수동)", "s5025", "5025"], + ["fixed-246", "외국인 순매도 상위(수동)", "s5025", "5025"], + ["fixed-247", "기관 순매도 상위(수동)", "s5025", "5025"], + ["fixed-262", "VI 발동(수동)", "s5074", "5074"], + ["fixed-293", "VI 발동(수동)", "s5074", "5074"], + ["fixed-320", "VI 발동(수동)", "s5074", "5074"] + ]); + for (const action of manual) { + assert.throws(() => fixed.createFixedPlaylistEntry(action.id, { + id: `blocked-${action.id}`, + fadeDuration: 6, + now + }), /manual|수동|입력/i); + } +}); + +test("all 44 industry slots create and strictly round-trip with explicit selections", () => { + let ordinal = 0; + for (const market of industry.markets) { + const selected = market === "kospi" ? primaryIndustry : kosdaqPrimaryIndustry; + const pair = market === "kospi" + ? [primaryIndustry, secondaryIndustry] + : [kosdaqPrimaryIndustry, kosdaqSecondaryIndustry]; + for (const cut of industry.cuts) { + ordinal += 1; + const created = industry.createIndustryPlaylistEntry(market, cut.id, { + id: `industry-matrix-${ordinal}`, + fadeDuration: 6, + selected, + pair, + now, + ma5: true, + ma20: true + }); + const restored = industry.restoreIndustryPlaylistEntry(created, { now }); + assert.ok(restored, `${market} ${cut.id}`); + assert.equal(restored.builderKey, created.builderKey); + assert.equal(restored.code, created.code); + } + } + assert.equal(ordinal, 44); +}); + +test("all 31 stock and nine comparison slots create and strictly round-trip", () => { + for (const [index, cut] of stock.stockCuts.entries()) { + const created = stock.createStockPlaylistEntry(kospiStock, cut.id, { + id: `stock-matrix-${index + 1}`, + fadeDuration: 6, + ma5: true, + ma20: true + }); + assert.ok(stock.restoreStockPlaylistEntry(created), cut.id); + } + + for (const [index, action] of comparison.actions.entries()) { + const created = comparison.createComparisonPlaylistEntry( + action.id, + comparisonPair, + { id: `comparison-matrix-${index + 1}`, fadeDuration: 6 }); + assert.ok(comparison.restoreComparisonPlaylistEntry(created), action.id); + } +}); + +test("the four GraphE buttons remain outside the 412 slots and map to exact INPUT tables", () => { + assert.deepEqual( + stock.manualStockActions.map(value => [value.id, value.builderKey, value.prerequisiteTable]), + [ + ["manual-revenue-mix", "s5076", "INPUT_PIE"], + ["manual-growth-metrics", "s5079", "INPUT_GROW"], + ["manual-sales", "s5080", "INPUT_SELL"], + ["manual-operating-profit", "s5081", "INPUT_PROFIT"] + ]); +}); diff --git a/tests/Web/operator-ui-app-integration.test.cjs b/tests/Web/operator-ui-app-integration.test.cjs new file mode 100644 index 0000000..79e88d3 --- /dev/null +++ b/tests/Web/operator-ui-app-integration.test.cjs @@ -0,0 +1,371 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const manualFinancialUiModule = require("../../Web/manual-financial-ui.js"); +const manualFinancialWorkflow = require("../../Web/manual-financial-workflow.js"); +const manualListsUiModule = require("../../Web/manual-lists-ui.js"); +const manualListsWorkflow = require("../../Web/manual-lists-workflow.js"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const read = relativePath => fs.readFileSync(path.join(repositoryRoot, relativePath), "utf8"); +const app = read("Web/app.js"); +const index = read("Web/index.html"); +const manualFinancialUi = read("Web/manual-financial-ui.js"); +const mainWindow = read("MainWindow.xaml.cs"); +const manualFinancialBridge = read("MainWindow.ManualFinancial.cs"); +const manualListsBridge = read("MainWindow.ManualLists.cs"); +const namedPlaylistBridge = read("MainWindow.NamedPlaylists.cs"); +const operatorCatalogBridge = read("MainWindow.OperatorCatalogs.cs"); +const playoutBridge = read("MainWindow.Playout.cs"); +const operatorMutationGate = read("src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs"); + +function functionBody(source, name, nextName) { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `${name} must exist`); + const end = nextName ? source.indexOf(`function ${nextName}(`, start + 1) : source.length; + assert.ok(end > start, `${name} must precede ${nextName || "the end of the file"}`); + return source.slice(start, end); +} + +function sliceBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start + startMarker.length); + assert.ok(start >= 0, `${startMarker} must exist`); + assert.ok(end > start, `${endMarker} must follow ${startMarker}`); + return source.slice(start, end); +} + +function assertOrdered(source, values) { + let previous = -1; + for (const value of values) { + const offset = source.indexOf(value); + assert.ok(offset >= 0, `${value} must be loaded`); + assert.ok(offset > previous, `${value} must preserve dependency order`); + assert.equal(source.indexOf(value, offset + value.length), -1, `${value} must be loaded once`); + previous = offset; + } +} + +test("operator styles and scripts load once in dependency order before app.js", () => { + assertOrdered(index, [ + 'href="manual-lists-ui.css"', + 'href="manual-financial-ui.css"', + 'href="operator-catalog-ui.css"' + ]); + assertOrdered(index, [ + 'src="manual-lists-workflow.js"', + 'src="manual-financial-workflow.js"', + 'src="operator-catalog-workflow.js"', + 'src="manual-lists-ui.js"', + 'src="manual-financial-ui.js"', + 'src="operator-catalog-ui.js"', + 'src="app.js"' + ]); +}); + +test("legacy GraphE, FSell, VIList, ThemeA and EList controls open their real controllers", () => { + assert.match(index, /id="themeCatalogManageButton"/u); + assert.match(index, /id="expertCatalogManageButton"/u); + + const fixedManual = functionBody(app, "openFixedManualAction", "addFixedAction"); + assert.match(fixedManual, + /action\.builderKey === "s5025"[\s\S]*manualListsUi\?\.openNetSell\(action\.selection\?\.groupCode\)/u); + assert.match(fixedManual, + /action\.builderKey === "s5074"[\s\S]*action\.selection\?\.groupCode === "PAGED_VI"[\s\S]*manualListsUi\?\.openVi\(\)/u); + + const stockRender = functionBody(app, "renderStockWorkflow", "requestStockSearch"); + assert.match(stockRender, /for \(const action of operatorWorkflow\.manualStockActions\)/u); + assert.match(stockRender, /button\.disabled = !search\.selected \|\| operatorUiLocked\(\)/u); + assert.match(stockRender, /manualFinancialUi\?\.open\(action\)/u); + + assert.match(app, + /themeCatalogManageButton\.addEventListener\("click", \(\) => \{[\s\S]*operatorCatalogUi\?\.openTheme\(context \|\| undefined\)/u); + assert.match(app, + /expertCatalogManageButton\.addEventListener\("click", \(\) => \{[\s\S]*operatorCatalogUi\?\.openExpert\(context \|\| undefined\)/u); + assert.match(app, /themeCatalogManageButton\.disabled = operatorUiLocked\(\)/u); + assert.match(app, /expertCatalogManageButton\.disabled = operatorUiLocked\(\)/u); +}); + +test("first-routing consumes only messages owned by an operator controller", () => { + const nativeHandler = functionBody(app, "handleNativeMessage", "bindEvents"); + const financialOffset = nativeHandler.indexOf("manualFinancialUi?.handleMessage"); + const listsOffset = nativeHandler.indexOf("manualListsUi?.handleMessage"); + const catalogOffset = nativeHandler.indexOf("operatorCatalogUi?.handleMessage"); + const switchOffset = nativeHandler.indexOf("switch (message.type)"); + assert.ok(financialOffset >= 0 && financialOffset < listsOffset); + assert.ok(listsOffset < catalogOffset && catalogOffset < switchOffset); + + let id = 0; + const financial = manualFinancialUiModule.createController({ + postNative() {}, + appendPlaylistEntry() { return true; }, + isLocked() { return false; }, + getSelectedStock() { return null; }, + showToast() {}, + addLog() {}, + createId() { id += 1; return `test-${id}`; } + }); + const unownedFinancialTypes = [ + manualFinancialWorkflow.bridgeContract.listResultType, + manualFinancialWorkflow.bridgeContract.listErrorType, + manualFinancialWorkflow.bridgeContract.loadResultType, + manualFinancialWorkflow.bridgeContract.loadErrorType, + "stock-search-results", + "stock-search-error", + manualFinancialWorkflow.bridgeContract.selectionResultType, + manualFinancialWorkflow.bridgeContract.selectionErrorType, + manualFinancialWorkflow.bridgeContract.mutationResultType, + manualFinancialWorkflow.bridgeContract.mutationErrorType, + manualFinancialWorkflow.bridgeContract.requestErrorType + ]; + for (const type of unownedFinancialTypes) { + assert.equal(financial.handleMessage(type, { requestId: "not-owned" }), false, type); + } + + const lists = manualListsUiModule.createController({ + postNative() {}, + appendPlaylistEntry() { return true; }, + isLocked() { return false; }, + showToast() {}, + addLog() {}, + createId() { id += 1; return `test-${id}`; } + }); + for (const type of [ + manualListsWorkflow.bridgeContract.statusResultType, + manualListsWorkflow.bridgeContract.netSellDataType, + manualListsWorkflow.bridgeContract.netSellSaveResultType, + manualListsWorkflow.bridgeContract.viDataType, + manualListsWorkflow.bridgeContract.viSaveResultType, + manualListsWorkflow.bridgeContract.errorType, + "stock-search-results", + "stock-search-error" + ]) { + assert.equal(lists.handleMessage(type, { requestId: "not-owned" }), false, type); + } +}); + +test("operator locks and trusted append checks cover every migrated manual surface", () => { + const lock = functionBody(app, "operatorUiLocked", "selectedStockForManualFinancial"); + assert.match(lock, /isPlaylistSnapshotLocked\(\)/u); + assert.match(lock, /namedPlaylistBusy\(\)/u); + assert.match(lock, /state\.manualFinancialRestore\.entries\.size > 0/u); + + const renderPlayout = functionBody(app, "renderPlayout", "clearPlayoutError"); + for (const controller of ["manualListsUi", "manualFinancialUi", "operatorCatalogUi"]) { + assert.match(renderPlayout, new RegExp(`${controller}\\?\\.setLocked\\(operatorLocked\\)`)); + } + + const append = functionBody(app, "appendTrustedOperatorEntry", "isPlaylistSnapshotLocked"); + assert.match(append, /if \(operatorUiLocked\(\)\) throw/u); + assert.match(append, /manualListsWorkflow\.restorePlaylistEntry\(entry\)/u); + assert.match(append, /manualFinancialWorkflow\.isPlaylistEntryPlayable\(entry\)/u); + assert.match(append, + /catalog\.find\(value => value\.builderKey === entry\.builderKey &&[\s\S]*sceneAliases\(value\)\.includes/u); + assert.match(append, /state\.playlist\.push\(entry\)/u); + assert.match(manualFinancialUi, + /if \(!workflow\.isPlaylistEntryPlayable\(entry\)\) throw new TypeError\("Untrusted entry\."\)/u); +}); + +test("stored GraphE entries remain unplayable until exact non-truncated DB revalidation", () => { + const normalizeStored = functionBody(app, "normalizeStoredPlaylist", "manualFinancialRestoreRecordForRequest"); + assert.match(normalizeStored, /manualFinancialWorkflow\.restorePlaylistEntry\(item\)/u); + assert.match(normalizeStored, /options\.manualFinancialRestores\.push\(pendingManualFinancialEntry\)/u); + assert.match(normalizeStored, + /operatorInputBuilderKeys\.has\(String\(item\.builderKey \|\| ""\)\) \|\| item\.operator !== undefined[\s\S]*return \[\]/u); + + const stockRestore = functionBody( + app, + "handleManualFinancialRestoreStockResults", + "handleManualFinancialRestoreStockError"); + assert.match(stockRestore, /response\.query\s*[!=]==?\s*record\.request\.query|record\.request\.query\s*[!=]==?\s*response\.query/u); + assert.match(stockRestore, /response\.truncated/u); + assert.match(stockRestore, /response\.totalRowCount[\s\S]*record\.request\.maximumResults/u); + assert.match(stockRestore, + /const expectedMarket = [\s\S]*record\.pending\.verifiedStock\.market/u); + assert.match(stockRestore, + /const expectedCode = [\s\S]*record\.pending\.verifiedStock\.code/u); + assert.match(stockRestore, + /verifyStockForRecord\([\s\S]*expectedMarket,[\s\S]*expectedCode/u); + assert.match(stockRestore, + /record\.restoreKind === "named-financial"[\s\S]*namedMatch\.length === 1/u); + + const prepareBlock = functionBody(app, "manualFinancialPrepareBlocked", "loadPlaylist"); + assert.match(prepareBlock, /state\.manualFinancialRestore\.entries\.size > 0/u); + assert.match(prepareBlock, /manualFinancialWorkflow\.isPlaylistEntryPlayable\(item\)/u); + assert.match(app, + /prepareButton\.disabled = [^;]*manualFinancialBlocked/u); + const requestCommand = functionBody(app, "requestPlayoutCommand", "requestPlayoutStatus"); + assert.match(requestCommand, + /if \(command === "prepare"\)[\s\S]*if \(manualFinancialPrepareBlocked\(\)\)[\s\S]*return false/u); +}); + +test("native root routes all operator families and invalidates every browser correlation", () => { + assert.match(mainWindow, /TryHandleOperatorCatalogRequest\(request\.Type, request\.Payload\)/u); + assert.match(mainWindow, /TryHandleManualFinancialRequest\(request\.Type, request\.Payload\)/u); + assert.match(mainWindow, /case "request-manual-operator-data-status"/u); + assert.match(mainWindow, /case "save-manual-net-sell-data"/u); + assert.match(mainWindow, /case "save-vi-manual-list"/u); + + assert.match(mainWindow, /InvalidateOperatorCatalogBrowserRequests\(\)/u); + assert.match(mainWindow, /InvalidateManualOperatorBrowserRequests\(\)/u); + assert.match(mainWindow, /InvalidateManualFinancialBrowserRequests\(\)/u); + + assert.match(manualFinancialBridge, /_playoutCommandGate\.WaitAsync\(0,/u); + assert.match(manualFinancialBridge, /_databaseActivityGate\.WaitAsync\(0,/u); + assert.match(operatorCatalogBridge, /_playoutCommandGate\.WaitAsync\(0,/u); + assert.match(operatorCatalogBridge, /_databaseActivityGate\.WaitAsync\(0,/u); + assert.match(manualListsBridge, /TryEnterManualMutationGatesAsync/u); + assert.match(manualListsBridge, /_playoutCommandGate\.WaitAsync\(0\)/u); +}); + +test("native operator writes share one fail-closed idle gate at their dispatch boundary", () => { + const gateStart = playoutBridge.indexOf("private bool CanStartOperatorMutation("); + const gateEnd = playoutBridge.indexOf("private void ShutdownPlayoutRuntime(", gateStart); + assert.ok(gateStart >= 0 && gateEnd > gateStart); + const gate = playoutBridge.slice(gateStart, gateEnd); + assert.match(gate, /familyWriteQuarantined/u); + assert.match(gate, /_lifetimeCancellation\.IsCancellationRequested/u); + assert.match(gate, /_playoutCommandInFlight/u); + assert.match(gate, /_playoutShutdownStarted/u); + assert.match(gate, /IsBrowserCorrelationQuarantined\(\)/u); + assert.match(gate, /IsEngineAvailable: engine is not null/u); + assert.match(gate, /IsWorkflowAvailable: workflow is not null/u); + assert.match(gate, /IsCommandAvailable: status\?\.IsCommandAvailable \?\? false/u); + assert.match(gate, /IsPlayCompletionPending: status\?\.IsPlayCompletionPending \?\? true/u); + assert.match(gate, /PreparedSceneName: status\?\.PreparedSceneName/u); + assert.match(gate, /OnAirSceneName: status\?\.OnAirSceneName/u); + assert.match(gate, /PreparedCutCode: workflowState\?\.PreparedCutCode/u); + assert.match(gate, /OnAirCutCode: workflowState\?\.OnAirCutCode/u); + assert.match(gate, /IsRefreshActive: refreshStatus\.IsActive/u); + assert.match(gate, /IsRefreshTaskRunning: _legacyRefreshTask is \{ IsCompleted: false \}/u); + assert.match(gate, /OperatorMutationGate\.CanStart\(snapshot\)/u); + assert.match(operatorMutationGate, /snapshot is not null/u); + assert.match(operatorMutationGate, /snapshot\.IsEngineAvailable/u); + assert.match(operatorMutationGate, /snapshot\.IsWorkflowAvailable/u); + + assert.match(manualFinancialBridge, + /CanStartOperatorMutation\(IsManualFinancialWriteQuarantined\(\)\)/u); + assert.ok((manualFinancialBridge.match(/CanStartManualFinancialWrite\(\)/gu) || []).length >= 3); + assert.match(operatorCatalogBridge, + /CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/u); + assert.ok((operatorCatalogBridge.match(/CanStartOperatorCatalogWrite\(\)/gu) || []).length >= 2); + assert.match(manualListsBridge, + /CanStartOperatorMutation\(IsManualOperatorWriteQuarantined\(\)\)/u); + assert.ok((manualListsBridge.match( + /CanStartOperatorMutation\(IsManualOperatorWriteQuarantined\(\)\)/gu) || []).length >= 3); + + for (const write of [ + sliceBetween( + manualListsBridge, + "private async Task HandleManualNetSellWriteAsync(", + "private async Task HandleViManualListReadAsync("), + sliceBetween( + manualListsBridge, + "private async Task HandleViManualListWriteAsync(", + "private async Task TryEnterManualMutationGatesAsync(") + ]) { + assert.match(write, + /TryEnterManualMutationGatesAsync\([\s\S]*Interlocked\.Exchange\(ref _manualOperatorWriteInFlight, 1\)[\s\S]*await (?:store\.)?Write/su); + } +}); + +test("an unknown write outcome globally quarantines GraphE, ThemeA/EList, FSell/VI and named playlists", () => { + assert.match(playoutBridge, + /private int _operatorMutationQuarantined;[\s\S]*private string\? _operatorMutationQuarantineMessage;/u); + assert.match(playoutBridge, + /private bool IsOperatorMutationQuarantined\(\) =>[\s\S]*_operatorMutationQuarantined/u); + assert.match(playoutBridge, + /private void LatchOperatorMutationQuarantine\(string message\)[\s\S]*Interlocked\.CompareExchange\(ref _operatorMutationQuarantined, 1, 0\)/u); + + for (const [surface, source, familyCheck, familyLatch] of [ + ["GraphE", manualFinancialBridge, + "IsManualFinancialWriteQuarantined", "LatchManualFinancialWriteQuarantine"], + ["ThemeA/EList", operatorCatalogBridge, + "IsOperatorCatalogWriteQuarantined", "LatchOperatorCatalogWriteQuarantine"], + ["FSell/VI", manualListsBridge, + "IsManualOperatorWriteQuarantined", "QuarantineManualOperatorWrites"], + ["named playlist", namedPlaylistBridge, + "IsNamedPlaylistWriteQuarantined", "LatchNamedPlaylistWriteQuarantine"] + ]) { + assert.match(source, new RegExp( + `private bool ${familyCheck}\\(\\) =>[\\s\\S]{0,180}IsOperatorMutationQuarantined\\(\\)`, "u"), + `${surface} must observe the process-wide quarantine`); + assert.match(source, new RegExp( + `private void ${familyLatch}\\(string message\\)[\\s\\S]{0,260}LatchOperatorMutationQuarantine\\(message\\)`, "u"), + `${surface} must latch the process-wide quarantine`); + } +}); + +test("malformed operator payloads retain a family-specific fail-closed fallback", () => { + assert.match(manualFinancialBridge, + /TryHandleManualFinancialRequest\(string\? requestType, JsonElement payload\)[\s\S]*case "create-manual-financial-record"[\s\S]*return true/u); + assert.match(manualFinancialBridge, + /private static string SafeManualFinancialRequestId\(JsonElement payload\) =>[\s\S]*payload\.ValueKind == JsonValueKind\.Object/u); + assert.match(operatorCatalogBridge, + /TryHandleOperatorCatalogRequest\(string\? requestType, JsonElement payload\)[\s\S]*case "create-theme-catalog"[\s\S]*case "create-expert-catalog"[\s\S]*return true/u); + assert.match(operatorCatalogBridge, + /private static string SafeOperatorCatalogRequestId\(JsonElement payload\)[\s\S]*payload\.ValueKind == JsonValueKind\.Object/u); + + for (const [parser, nextParser, readToken] of [ + ["TryParseAudienceRequest", "SafeManualOperatorRequestId", 'GetString(payload, "audience")'], + ["TryParseManualNetSellWrite", "TryParseViWrite", 'GetString(payload, "audience")'], + ["TryParseViWrite", "TryGetViExpectedVersion", "TryGetViExpectedVersion(payload"] + ]) { + const body = sliceBetween( + manualListsBridge, + `private static bool ${parser}(`, + `private static ${nextParser === "SafeManualOperatorRequestId" ? "string" : "bool"} ${nextParser}(`); + assert.match(body, /requestId = SafeManualOperatorRequestId\(payload\)/u, parser); + const objectGuard = body.indexOf("payload.ValueKind"); + const firstPropertyRead = body.indexOf(readToken); + assert.ok(objectGuard >= 0 && firstPropertyRead > objectGuard, + `${parser} must check object kind before reading a property`); + } + assert.ok((manualListsBridge.match(/"INVALID_REQUEST"/gu) || []).length >= 5); + + const viVersion = sliceBetween( + manualListsBridge, + "private static bool TryGetViExpectedVersion(", + "private static bool TryGetManualValue("); + assert.ok(viVersion.indexOf("payload.ValueKind") < + viVersion.indexOf('payload.TryGetProperty("expectedVersion"')); + + assert.match(mainWindow, + /case "request-named-playlist-list":[\s\S]*case "delete-named-playlist":[\s\S]*PostNamedPlaylistError\([\s\S]*"INVALID_REQUEST"/u); +}); + +test("window shutdown detaches and disposes both trusted manual stores after their gate is idle", () => { + const closed = sliceBetween(mainWindow, "private void OnClosed(", "private sealed record WebRequest("); + assert.match(closed, /ShutdownManualOperatorDataRuntime\(\)/u); + + const shutdown = sliceBetween( + manualListsBridge, + "private void ShutdownManualOperatorDataRuntime(", + "private async Task DisposeManualOperatorStoresWhenIdleAsync("); + assert.match(shutdown, /InvalidateManualOperatorBrowserRequests\(\)/u); + assert.match(shutdown, /Interlocked\.Exchange\(ref _manualNetSellStore, null\)/u); + assert.match(shutdown, /Interlocked\.Exchange\(ref _viManualListStore, null\)/u); + assert.match(shutdown, /DisposeManualOperatorStoresWhenIdleAsync\(netSellStore, viStore\)/u); + + const dispose = sliceBetween( + manualListsBridge, + "private async Task DisposeManualOperatorStoresWhenIdleAsync(", + "private void InvalidateManualOperatorBrowserRequests("); + assert.match(dispose, /await _manualOperatorDataGate\.WaitAsync\(\)/u); + assert.match(dispose, /viStore\?\.Dispose\(\)/u); + assert.match(dispose, /netSellStore\?\.Dispose\(\)/u); + assert.match(dispose, /_manualOperatorDataGate\.Release\(\)/u); +}); + +test("GraphE async UI boundaries retain the dispatcher context before posting WebView replies", () => { + assert.doesNotMatch(manualFinancialBridge, + /operationBody\(requestToken\)\.ConfigureAwait\(false\)/u); + assert.doesNotMatch(manualFinancialBridge, + /preflight\(requestToken\)\.ConfigureAwait\(false\)/u); + assert.doesNotMatch(manualFinancialBridge, + /operationBody\(service,\s*requestToken\)\.ConfigureAwait\(false\)/u); +}); diff --git a/tests/Web/operator-workflow.test.cjs b/tests/Web/operator-workflow.test.cjs new file mode 100644 index 0000000..ba7efc1 --- /dev/null +++ b/tests/Web/operator-workflow.test.cjs @@ -0,0 +1,326 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/operator-workflow.js"); + +const legacyLabels = [ + "1열판기본_예상체결가", + "1열판기본_현재가", + "1열판기본_시간외단일가", + "1열판상세_시가", + "1열판상세_액면가", + "1열판상세_PBR", + "1열판상세_거래량", + "수익률그래프_5일", + "수익률그래프_20일", + "수익률그래프_60일", + "수익률그래프_120일", + "수익률그래프_240일", + "캔들그래프_일봉", + "캔들그래프_5일", + "캔들그래프_20일", + "캔들그래프_60일", + "캔들그래프_120일", + "캔들그래프_240일", + "캔들그래프(거래량)_일봉", + "캔들그래프(거래량)_5일", + "캔들그래프(거래량)_20일", + "캔들그래프(거래량)_60일", + "캔들그래프(거래량)_120일", + "캔들그래프(거래량)_240일", + "캔들그래프(예상체결가)_5일", + "캔들그래프(예상체결가)_20일", + "캔들그래프(예상체결가)_60일", + "캔들그래프(예상체결가)_120일", + "캔들그래프(예상체결가)_240일", + "호가창_표그래프", + "거래원_표그래프" +]; + +const kospiStock = Object.freeze({ + market: "kospi", + stockName: "테스트전자", + displayName: "테스트전자", + stockCode: "A123456", + isNxt: false +}); + +test("stock catalog is pinned to the 31-row runtime 종목.ini workflow", () => { + assert.deepEqual(workflow.runtimeStockAsset, { + relativePath: "bin/Debug/Res/종목.ini", + sha256: "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20" + }); + assert.equal(workflow.stockCuts.length, 31); + assert.deepEqual(workflow.stockCuts.map(cut => cut.label), legacyLabels); + assert.equal(new Set(workflow.stockCuts.map(cut => cut.id)).size, 31); + assert.equal(workflow.stockCuts.some(cut => cut.label === "-"), false); + + for (const cut of workflow.stockCuts) { + assert.equal(typeof cut.id, "string"); + assert.equal(typeof cut.section, "string"); + assert.equal(typeof cut.group, "string"); + assert.equal(typeof cut.detail, "string"); + assert.match(cut.builderKey, /^s\d+$/); + assert.match(cut.alias, /^[A-Z]?\d+$/); + assert.equal(cut.cutCode, cut.alias); + assert.equal(cut.aliases.includes(cut.alias), true); + } +}); + +test("native stock search rows normalize to a typed stock without sample fallbacks", () => { + assert.deepEqual( + workflow.normalizeStockResult({ + market: "kospi", + source: "oracle", + name: "실제조회종목", + code: "005930" + }), + { + market: "kospi", + stockName: "실제조회종목", + displayName: "실제조회종목", + stockCode: "005930", + isNxt: false + }); + + assert.deepEqual( + workflow.normalizeStockResult({ + market: "nxt-kosdaq", + source: "mariaDb", + name: "NXT조회종목", + code: "123456" + }), + { + market: "kosdaq", + stockName: "NXT조회종목", + displayName: "NXT조회종목(NXT)", + stockCode: "123456", + isNxt: true + }); + + assert.deepEqual( + workflow.normalizeStockResult({ + market: "코스피_NXT", + stockName: "표시종목(NXT)", + displayName: "표시종목(NXT)", + stockCode: "A000001", + isNxt: true + }), + { + market: "kospi", + stockName: "표시종목", + displayName: "표시종목(NXT)", + stockCode: "A000001", + isNxt: true + }); + + assert.equal(workflow.normalizeStockResult(null), null); + assert.equal(workflow.normalizeStockResult({}), null); + assert.equal(workflow.normalizeStockResult({ market: "kospi", name: "", code: "005930" }), null); + assert.equal(workflow.normalizeStockResult({ market: "overseas", name: "종목", code: "005930" }), null); + assert.equal(workflow.normalizeStockResult({ market: "kospi", name: "종목", code: "../cut" }), null); + assert.equal(workflow.normalizeStockResult({ market: "nxt-kospi", name: "종목", code: "005930", isNxt: false }), null); +}); + +test("NXT selection permits only 1열판기본_현재가", () => { + const nxt = { + market: "nxt-kospi", + stockName: "NXT테스트", + displayName: "NXT테스트(NXT)", + stockCode: "123456", + isNxt: true + }; + for (const cut of workflow.stockCuts) { + assert.equal( + workflow.isStockCutAllowed(nxt, cut.id), + cut.id === "basic-current", + cut.label); + } + assert.equal(workflow.isStockCutAllowed(kospiStock, "not-a-cut"), false); +}); + +test("every regular stock cut maps to its closed builder, alias and LegacySceneSelection", () => { + const expected = [ + ["basic-expected", "s5001", "5001", "KOSPI", "1열판기본", "EXPECTED_OPENING"], + ["basic-current", "s5001", "5001", "KOSPI", "1열판기본", "CURRENT"], + ["basic-after-hours", "s5001", "5001", "KOSPI", "1열판기본", "AFTER_HOURS_SINGLE_PRICE"], + ["detail-open", "s5006", "5006", "KOSPI_STOCK", "1열판상세", "시가"], + ["detail-face-value", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "FaceValue"], + ["detail-pbr", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "Valuation"], + ["detail-volume", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "Volume"], + ["yield-5d", "s5086", "5086", "KOSPI", "수익률그래프", "5일"], + ["yield-20d", "s5086", "5086", "KOSPI", "수익률그래프", "20일"], + ["yield-60d", "s5086", "5086", "KOSPI", "수익률그래프", "60일"], + ["yield-120d", "s5086", "5086", "KOSPI", "수익률그래프", "120일"], + ["yield-240d", "s5086", "5086", "KOSPI", "수익률그래프", "240일"], + ["candle-daily", "s8010", "8035", "KOSPI_STOCK", "MA5,MA20", "PRICE"], + ["candle-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "PRICE"], + ["candle-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "PRICE"], + ["candle-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "PRICE"], + ["candle-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "PRICE"], + ["candle-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "PRICE"], + ["candle-volume-daily", "s8010", "8035", "KOSPI_STOCK", "MA5,MA20", "VOLUME"], + ["candle-volume-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "VOLUME"], + ["candle-volume-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "VOLUME"], + ["candle-volume-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "VOLUME"], + ["candle-volume-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "VOLUME"], + ["candle-volume-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "VOLUME"], + ["candle-expected-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"], + ["candle-expected-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"], + ["candle-expected-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"], + ["candle-expected-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"], + ["candle-expected-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"], + ["order-book", "s8003", "8003", "KOSPI", "ORDER_BOOK", "TABLE_GRAPH"], + ["traders", "s5037", "5037", "KOSPI", "TRADER", "TABLE_GRAPH"] + ]; + + assert.equal(expected.length, 31); + for (const [id, builderKey, code, groupCode, graphicType, subtype] of expected) { + const entry = workflow.createStockPlaylistEntry(kospiStock, id, { + id: `entry-${id}`, + ma5: true, + ma20: true, + fadeDuration: 8 + }); + assert.equal(entry.builderKey, builderKey, id); + assert.equal(entry.code, code, id); + assert.equal(entry.selection.groupCode, groupCode, id); + assert.equal(entry.selection.subject, "테스트전자", id); + assert.equal(entry.selection.graphicType, graphicType, id); + assert.equal(entry.selection.subtype, subtype, id); + assert.equal(entry.selection.dataCode, "A123456", id); + assert.equal(entry.category, "stock", id); + assert.equal(entry.market, "kospi", id); + assert.equal(entry.enabled, true, id); + assert.equal(entry.fadeDuration, 8, id); + assert.equal(entry.operator.cutId, id, id); + assert.equal(entry.operator.source, "legacy-stock-workflow", id); + } +}); + +test("moving-average flags are ordered and apply only to candle cuts", () => { + const ma20 = workflow.createStockPlaylistEntry(kospiStock, "candle-20d", { + id: "ma20", + ma5: false, + ma20: true + }); + assert.equal(ma20.selection.graphicType, "MA20"); + assert.deepEqual(ma20.operator.movingAverages, { ma5: false, ma20: true }); + + const none = workflow.createStockPlaylistEntry(kospiStock, "candle-5d", { id: "none" }); + assert.equal(none.selection.graphicType, ""); + + const nonCandle = workflow.createStockPlaylistEntry(kospiStock, "basic-current", { + id: "quote", + ma5: true, + ma20: true + }); + assert.equal(nonCandle.selection.graphicType, "1열판기본"); + assert.deepEqual(nonCandle.operator.movingAverages, { ma5: false, ma20: false }); +}); + +test("NXT current quote uses N5001 and the native NXT market selection", () => { + const entry = workflow.createStockPlaylistEntry({ + market: "nxt-kosdaq", + stockName: "NXT테스트", + displayName: "NXT테스트", + stockCode: "654321", + isNxt: true + }, "basic-current", { id: "nxt-current" }); + + assert.equal(entry.builderKey, "s5001"); + assert.equal(entry.code, "N5001"); + assert.equal(entry.market, "kosdaq"); + assert.equal(entry.title, "NXT테스트(NXT)"); + assert.deepEqual(entry.selection, { + groupCode: "NXT_KOSDAQ", + subject: "NXT테스트(NXT)", + dataCode: "654321", + graphicType: "1열판기본", + subtype: "CURRENT" + }); + assert.throws( + () => workflow.createStockPlaylistEntry(entry.operator.stock, "order-book", { id: "bad" }), + /NXT stocks support only/); +}); + +test("entry construction fails closed and never supplies a sample stock", () => { + assert.throws( + () => workflow.createStockPlaylistEntry({}, "basic-current", { id: "missing-stock" }), + /valid selected stock/); + assert.throws( + () => workflow.createStockPlaylistEntry(kospiStock, "missing-cut", { id: "missing-cut" }), + /unknown/); + assert.throws( + () => workflow.createStockPlaylistEntry(kospiStock, "basic-current", {}), + /entry id/); + assert.throws( + () => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "unsafe id" }), + /entry id/); + assert.throws( + () => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "fade", fadeDuration: 61 }), + /Fade duration/); + assert.throws( + () => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "flags", ma5: "yes" }), + /Moving-average flags/); + + const entry = workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "default-fade" }); + assert.equal(entry.fadeDuration, 6); + assert.equal(entry.stockName, "테스트전자"); +}); + +test("stored operator stock entries restore only when every trusted mapping still matches", () => { + const stored = workflow.createStockPlaylistEntry(kospiStock, "candle-volume-60d", { + id: "stored-candle", + ma5: true, + ma20: false, + fadeDuration: 9 + }); + stored.enabled = false; + + const restored = workflow.restoreStockPlaylistEntry(JSON.parse(JSON.stringify(stored))); + assert.ok(restored); + assert.equal(restored.enabled, false); + assert.equal(restored.code, "8046"); + assert.equal(restored.detail, "캔들그래프(거래량)_60일"); + assert.equal(restored.selection.graphicType, "MA5"); + + for (const tamper of [ + value => { value.builderKey = "s5001"; }, + value => { value.code = "5001"; }, + value => { value.selection.subject = "다른종목"; }, + value => { value.operator.cutId = "basic-current"; }, + value => { value.operator.source = "unknown"; }, + value => { value.operator.legacyLabel = "변조"; }, + value => { value.enabled = "false"; } + ]) { + const candidate = JSON.parse(JSON.stringify(stored)); + tamper(candidate); + assert.equal(workflow.restoreStockPlaylistEntry(candidate), null); + } + assert.equal(workflow.restoreStockPlaylistEntry(null), null); +}); + +test("four legacy manual-data shortcuts are explicit unavailable prerequisites", () => { + assert.deepEqual( + workflow.manualStockActions.map(action => [ + action.label, + action.builderKey, + action.alias, + action.prerequisiteTable + ]), + [ + ["주요매출 구성", "s5076", "5076", "INPUT_PIE"], + ["성장성 지표", "s5079", "5079", "INPUT_GROW"], + ["매출액", "s5080", "5080", "INPUT_SELL"], + ["영업이익", "s5081", "5081", "INPUT_PROFIT"] + ]); + for (const action of workflow.manualStockActions) { + assert.equal(action.available, false); + assert.equal(action.autoAdd, false); + assert.equal(action.status, "unavailable"); + assert.equal(action.prerequisites.length, 3); + assert.match(action.prerequisites[0], new RegExp(action.prerequisiteTable)); + } +}); diff --git a/tests/Web/overseas-workflow.test.cjs b/tests/Web/overseas-workflow.test.cjs new file mode 100644 index 0000000..c02dac0 --- /dev/null +++ b/tests/Web/overseas-workflow.test.cjs @@ -0,0 +1,377 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/overseas-workflow.js"); + +const industry = Object.freeze({ + koreanName: "Philadelphia Semiconductor", + inputName: "US Semiconductor Index", + symbol: "NAS@SOX", + nationCode: "US", + fdtc: "0" +}); +const usStock = Object.freeze({ + inputName: "NVIDIA", + symbol: "NAS@NVDA", + nationCode: "US", + fdtc: "1" +}); +const twStock = Object.freeze({ + inputName: "Taiwan Semiconductor", + symbol: "TWS@2330", + nationCode: "TW", + fdtc: "1" +}); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function industryResponse(overrides = {}) { + return { + requestId: "overseas-industry-1", + query: "", + retrievedAt: "2026-07-11T12:34:56+09:00", + totalRowCount: 2, + truncated: false, + results: [ + { + koreanName: "Alpha Industry", + inputName: "ALPHA_INDEX", + symbol: "NAS@ALPHA", + nationCode: "US", + fdtc: "0" + }, + { + koreanName: "Beta Industry", + inputName: "BETA_INDEX", + symbol: "NAS@BETA", + nationCode: "US", + fdtc: "0" + } + ], + ...overrides + }; +} + +function stockResponse(overrides = {}) { + return { + requestId: "overseas-stock-1", + query: "", + retrievedAt: "2026-07-11T12:34:56+09:00", + totalRowCount: 2, + truncated: false, + results: [ + { inputName: "Apple", symbol: "NAS@AAPL", nationCode: "US", fdtc: "1" }, + { inputName: "Taiwan Semiconductor", symbol: "TWS@2330", nationCode: "TW", fdtc: "1" } + ], + ...overrides + }; +} + +test("UC5 bridge contracts expose one industry action and six stock actions", () => { + assert.deepEqual(workflow.bridgeContracts.industry, { + requestType: "search-overseas-industries", + resultType: "overseas-industry-results", + errorType: "overseas-industry-error", + defaultMaximumResults: 100, + maximumResults: 500, + maximumQueryLength: 64 + }); + assert.deepEqual(workflow.bridgeContracts.stock, { + requestType: "search-world-stocks", + resultType: "world-stock-search-results", + errorType: "world-stock-search-error", + defaultMaximumResults: 100, + maximumResults: 500, + maximumQueryLength: 64 + }); + assert.deepEqual(workflow.industryActions.map(value => [value.id, value.code]), [ + ["industry-current", "5001"] + ]); + assert.deepEqual(workflow.stockActions.map(value => [value.id, value.code, value.periodDays]), [ + ["stock-current", "5001", null], + ["stock-candle-5d", "8061", 5], + ["stock-candle-20d", "8040", 20], + ["stock-candle-60d", "8046", 60], + ["stock-candle-120d", "8051", 120], + ["stock-candle-240d", "8056", 240] + ]); + assert.deepEqual(workflow.sourceContract.industry, { + fdtc: "0", nationCodes: ["US"], lookupField: "F_KNAM", currentCutCode: "5001" + }); + assert.deepEqual(workflow.sourceContract.stock.candleCutCodes, [ + "8061", "8040", "8046", "8051", "8056" + ]); +}); + +test("industry and stock search requests normalize blank initial lists and exact limits", () => { + assert.deepEqual( + workflow.createIndustrySearchRequest("industry-request", " "), + { requestId: "industry-request", query: "" }); + assert.deepEqual( + workflow.createStockSearchRequest("stock-request", " NVDA ", 200), + { requestId: "stock-request", query: "NVDA", maximumResults: 200 }); + + for (const create of [workflow.createIndustrySearchRequest, workflow.createStockSearchRequest]) { + assert.throws(() => create("bad id", ""), /safe/i); + assert.throws(() => create("request", "bad\nquery"), /safe/i); + assert.throws(() => create("request", "x".repeat(65)), /64/); + assert.throws(() => create("request", "", 0), /1 through 500/); + assert.throws(() => create("request", "", 501), /1 through 500/); + assert.throws(() => create("request", "", "100"), /1 through 500/); + } +}); + +test("native identities require exact name, symbol, nation and FDTC fields", () => { + assert.deepEqual(workflow.normalizeIndustry(industry), industry); + assert.deepEqual(workflow.normalizeStock(usStock), usStock); + assert.deepEqual(workflow.normalizeStock(twStock), twStock); + + assert.equal(workflow.normalizeIndustry({ ...industry, nationCode: "TW" }), null); + assert.equal(workflow.normalizeIndustry({ ...industry, fdtc: "1" }), null); + assert.equal(workflow.normalizeIndustry({ ...industry, koreanName: " Industry" }), null); + assert.equal(workflow.normalizeIndustry({ ...industry, inputName: "A|B" }), null); + assert.equal(workflow.normalizeStock({ ...usStock, nationCode: "JP" }), null); + assert.equal(workflow.normalizeStock({ ...usStock, fdtc: 1 }), null); + assert.equal(workflow.normalizeStock({ ...usStock, symbol: "../../NVDA" }), null); + assert.equal(workflow.normalizeStock({ ...usStock, inputName: "NV\u200BIDIA" }), null); + assert.equal(workflow.normalizeStock({ ...usStock, koreanName: "extra" }), null); +}); + +test("industry responses are request-bound, ordered and deeply normalized", () => { + const request = workflow.createIndustrySearchRequest("overseas-industry-1", "", 2); + const normalized = workflow.normalizeIndustrySearchResponse(industryResponse(), request); + assert.ok(normalized); + assert.deepEqual(normalized, industryResponse()); + assert.equal(Object.isFrozen(normalized), true); + assert.equal(Object.isFrozen(normalized.results), true); + assert.equal(Object.isFrozen(normalized.results[0]), true); + + assert.equal(workflow.normalizeIndustrySearchResponse( + industryResponse({ requestId: "stale" }), request), null); + assert.equal(workflow.normalizeIndustrySearchResponse( + industryResponse({ totalRowCount: 1 }), request), null); + assert.equal(workflow.normalizeIndustrySearchResponse( + industryResponse({ retrievedAt: "invalid" }), request), null); + assert.equal(workflow.normalizeIndustrySearchResponse( + { ...industryResponse(), extra: true }, request), null); + assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({ + results: [...industryResponse().results].reverse() + }), request), null); + + const overDefault = Array.from({ length: 101 }, (_, index) => { + const key = String(index).padStart(3, "0"); + return { + koreanName: `Industry ${key}`, + inputName: `INDEX_${key}`, + symbol: `NAS@I${key}`, + nationCode: "US", + fdtc: "0" + }; + }); + assert.equal(workflow.normalizeIndustrySearchResponse({ + ...industryResponse(), + totalRowCount: overDefault.length, + results: overDefault + }, workflow.createIndustrySearchRequest("overseas-industry-1", "")), null); +}); + +test("industry responses reject every ambiguous downstream lookup key", () => { + for (const key of ["koreanName", "inputName", "symbol"]) { + const rows = clone(industryResponse().results); + rows[1][key] = rows[0][key]; + assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({ results: rows })), null); + } + const wrongScope = clone(industryResponse()); + wrongScope.results[1].fdtc = "1"; + assert.equal(workflow.normalizeIndustrySearchResponse(wrongScope), null); +}); + +test("stock responses preserve US/TW identity and reject duplicate input-name lookups", () => { + const request = workflow.createStockSearchRequest("overseas-stock-1", "", 2); + assert.ok(workflow.normalizeStockSearchResponse(stockResponse(), request)); + + const duplicateName = clone(stockResponse()); + duplicateName.results[1].inputName = duplicateName.results[0].inputName; + assert.equal(workflow.normalizeStockSearchResponse(duplicateName), null); + + const sameSymbol = clone(stockResponse()); + sameSymbol.results[1].symbol = sameSymbol.results[0].symbol; + assert.ok(workflow.normalizeStockSearchResponse(sameSymbol)); + + const wrongNation = clone(stockResponse()); + wrongNation.results[1].nationCode = "JP"; + assert.equal(workflow.normalizeStockSearchResponse(wrongNation), null); + assert.equal(workflow.normalizeStockSearchResponse(stockResponse({ + results: [...stockResponse().results].reverse() + })), null); +}); + +test("bridge errors use exact payloads and reject stale request correlation", () => { + const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI"); + const industryError = { + requestId: "industry-error-1", + query: "AI", + message: "Industry lookup failed." + }; + assert.deepEqual( + workflow.normalizeIndustrySearchError(industryError, industryRequest), + industryError); + assert.equal(workflow.normalizeIndustrySearchError({ + ...industryError, requestId: "stale" + }, industryRequest), null); + assert.equal(workflow.normalizeIndustrySearchError({ + ...industryError, extra: true + }, industryRequest), null); + + const stockRequest = workflow.createStockSearchRequest("stock-error-1", "NVDA"); + const stockError = { requestId: "stock-error-1", query: "NVDA", message: "Failed." }; + assert.deepEqual(workflow.normalizeStockSearchError(stockError, stockRequest), stockError); + assert.equal(workflow.normalizeStockSearchError({ + ...stockError, message: "bad\nmessage" + }, stockRequest), null); +}); + +test("US overseas industries expose only the 5001 current one-column mapping", () => { + assert.equal(workflow.isActionAllowed("industry", "industry-current", industry), true); + assert.equal(workflow.isActionAllowed("industry", "stock-candle-5d", industry), false); + const mapping = workflow.buildOverseasIndustrySelection(industry); + assert.equal(mapping.builderKey, "s5001"); + assert.equal(mapping.code, "5001"); + assert.deepEqual(mapping.aliases, ["5001"]); + assert.deepEqual(mapping.selection, { + groupCode: "FOREIGN_INDUSTRY", + subject: "Philadelphia Semiconductor", + graphicType: "1\uC5F4\uD310\uAE30\uBCF8", + subtype: "CURRENT", + dataCode: "US Semiconductor Index|NAS@SOX|US|0" + }); + assert.throws(() => workflow.buildOverseasIndustrySelection({ + ...industry, nationCode: "TW" + }), /valid/i); +}); + +test("US/TW stocks expose 5001 current and the five exact UC5 candle aliases", () => { + const current = workflow.buildOverseasStockSelection(usStock, "stock-current", { + ma5: true, + ma20: true + }); + assert.equal(current.builderKey, "s5001"); + assert.deepEqual(current.selection, { + groupCode: "FOREIGN_STOCK", + subject: "NVIDIA", + graphicType: "1\uC5F4\uD310\uAE30\uBCF8", + subtype: "CURRENT", + dataCode: "NAS@NVDA|US|1" + }); + assert.deepEqual(current.movingAverages, { ma5: false, ma20: false }); + + for (const action of workflow.stockActions.filter(value => value.kind === "candle")) { + const mapping = workflow.buildOverseasStockSelection(twStock, action.id, { + ma5: true, + ma20: false + }); + assert.equal(mapping.builderKey, "s8010"); + assert.equal(mapping.code, action.code); + assert.deepEqual(mapping.aliases, [action.code]); + assert.deepEqual(mapping.selection, { + groupCode: "FOREIGN_STOCK", + subject: "Taiwan Semiconductor", + graphicType: "MA5", + subtype: "PRICE", + dataCode: "TWS@2330|TW|1" + }); + } + assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-candle-intraday"), /unknown/i); + assert.throws(() => workflow.buildOverseasStockSelection(usStock, "industry-current"), /unknown/i); + assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-current", { + ma5: "true" + }), /boolean/i); +}); + +test("playlist entries preserve typed FDTC identity and runtime candle bindings", () => { + const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, { + id: "industry-entry", + fadeDuration: 7 + }); + assert.equal(industryEntry.category, "plate"); + assert.equal(industryEntry.symbol, "NAS@SOX"); + assert.equal(industryEntry.nationCode, "US"); + assert.equal(industryEntry.fdtc, "0"); + assert.equal(industryEntry.operator.kind, "industry"); + assert.equal(industryEntry.operator.schemaVersion, 1); + + const candleEntry = workflow.createOverseasStockPlaylistEntry( + twStock, + "stock-candle-240d", + { id: "tw-candle", fadeDuration: 8, ma5: true, ma20: true }); + assert.equal(candleEntry.builderKey, "s8010"); + assert.equal(candleEntry.code, "8056"); + assert.equal(candleEntry.category, "chart"); + assert.equal(candleEntry.selection.dataCode, "TWS@2330|TW|1"); + assert.equal(candleEntry.selection.graphicType, "MA5,MA20"); + assert.equal(candleEntry.operator.kind, "stock"); + assert.deepEqual(candleEntry.operator.identity, twStock); +}); + +test("trusted restore reconstructs entries and rejects identity or mapping tampering", () => { + const original = workflow.createOverseasStockPlaylistEntry( + usStock, + "stock-candle-120d", + { id: "stored-overseas", fadeDuration: 9, ma5: true, ma20: false }); + original.enabled = false; + const restored = workflow.restoreOverseasPlaylistEntry(clone(original)); + assert.ok(restored); + assert.equal(restored.enabled, false); + + const tampers = [ + value => { value.builderKey = "s5001"; }, + value => { value.code = "8040"; }, + value => { value.selection.groupCode = "FOREIGN_INDEX"; }, + value => { value.selection.subject = "Other"; }, + value => { value.selection.dataCode = "NAS@NVDA|TW|1"; }, + value => { value.selection.subtype = "VOLUME"; }, + value => { value.symbol = "NAS@OTHER"; }, + value => { value.nationCode = "TW"; }, + value => { value.fdtc = "0"; }, + value => { value.operator.actionId = "stock-current"; }, + value => { value.operator.schemaVersion = 2; }, + value => { value.operator.identity.symbol = "NAS@OTHER"; }, + value => { value.operator.identity.nationCode = "TW"; }, + value => { value.operator.identity.fdtc = "0"; }, + value => { value.operator.movingAverages.ma5 = false; }, + value => { value.enabled = "false"; } + ]; + for (const tamper of tampers) { + const candidate = clone(original); + tamper(candidate); + assert.equal(workflow.restoreOverseasPlaylistEntry(candidate), null); + } + + const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, { + id: "stored-industry" + }); + assert.ok(workflow.restoreOverseasPlaylistEntry(clone(industryEntry))); + const tamperedIndustry = clone(industryEntry); + tamperedIndustry.operator.identity.koreanName = "Other Industry"; + assert.equal(workflow.restoreOverseasPlaylistEntry(tamperedIndustry), null); + assert.equal(workflow.restoreOverseasPlaylistEntry(null), null); +}); + +test("playlist option validation rejects unsafe ids, fades and identities", () => { + assert.throws(() => workflow.createOverseasIndustryPlaylistEntry(industry, { + id: "bad id" + }), /safe/i); + assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", { + id: "fade", fadeDuration: 61 + }), /0 through 60/i); + assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", { + id: "flags", ma20: 1 + }), /boolean/i); + assert.throws(() => workflow.createOverseasStockPlaylistEntry( + { ...usStock, nationCode: "JP" }, + "stock-current", + { id: "invalid" }), /valid/i); +}); diff --git a/tests/Web/playout-safety.test.cjs b/tests/Web/playout-safety.test.cjs index 3ee5bd3..e078cac 100644 --- a/tests/Web/playout-safety.test.cjs +++ b/tests/Web/playout-safety.test.cjs @@ -48,6 +48,32 @@ test("TAKE IN and TAKE OUT preserve the native state prerequisites", () => { null); }); +test("legacy F8 chooses exactly one safe PREPARE or TAKE IN action", () => { + assert.equal( + safety.legacyTakeInShortcutAction(ready({ takeInAllowed: true })), + "prepare-then-take-in"); + assert.equal( + safety.legacyTakeInShortcutAction(ready({ + takeInAllowed: true, + engineState: "PREPARED", + preparedCode: "5001" + })), + "take-in"); + assert.equal( + safety.legacyTakeInShortcutAction(ready({ + takeInAllowed: true, + engineState: "PROGRAM", + onAirCode: "5001" + })), + "next-required"); + assert.equal( + safety.legacyTakeInShortcutAction(ready({ takeInAllowed: false })), + "take-in-locked"); + assert.equal( + safety.legacyTakeInShortcutAction(ready({ takeInAllowed: true, pending: true })), + "blocked"); +}); + test("Refresh faults block mutations but preserve emergency TAKE OUT", () => { const faulted = ready({ refreshFaulted: true, diff --git a/tests/Web/theme-workflow.test.cjs b/tests/Web/theme-workflow.test.cjs new file mode 100644 index 0000000..7688b59 --- /dev/null +++ b/tests/Web/theme-workflow.test.cjs @@ -0,0 +1,242 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/theme-workflow.js"); + +const krxTheme = Object.freeze({ + market: "krx", + themeCode: "00000001", + title: "AI 반도체", + displayTitle: "AI 반도체", + session: null, + itemCount: 17 +}); +const nxtPreTheme = Object.freeze({ + market: "nxt", + themeCode: "00000002", + title: "로봇", + displayTitle: "로봇(NXT)", + session: "PRE_MARKET", + itemCount: 121 +}); +const nxtAfterTheme = Object.freeze({ + ...nxtPreTheme, + session: "AFTER_MARKET" +}); + +test("theme workflow exposes the UC4 and closed resolver contract", () => { + assert.deepEqual(workflow.sourceContract, { + originalControl: "Control/UC4.cs", + krxResolverGroup: "PAGED_THEME", + nxtResolverGroup: "PAGED_NXT_THEME", + maximumPageCount: 20, + maximumPreviewItems: 240 + }); + assert.deepEqual(workflow.themeSorts, [ + { id: "INPUT_ORDER", label: "입력순" }, + { id: "GAIN_DESC", label: "상승률순" }, + { id: "GAIN_ASC", label: "하락률순" } + ]); + assert.deepEqual(workflow.nxtSessions.map(value => value.id), ["PRE_MARKET", "AFTER_MARKET"]); + assert.deepEqual(workflow.actions.map(value => [value.id, value.builderKey, value.code, value.pageSize]), [ + ["five-row-current", "s5074", "5074", 5], + ["five-row-expected", "s5074", "5074", 5], + ["six-row-current", "s5077", "5077", 6], + ["twelve-row-current", "s5088", "5088", 12] + ]); +}); + +test("KRX and NXT database identities normalize without inferring market from a suffix", () => { + assert.deepEqual(workflow.normalizeTheme({ + Market: "Krx", ThemeCode: "00000001", ThemeTitle: "AI 반도체", Session: "NotApplicable", + previewItemCount: 17 + }), krxTheme); + assert.deepEqual(workflow.normalizeTheme({ + market: "NXT", themeCode: "00000002", title: "로봇", session: "PreMarket", items: new Array(121) + }), nxtPreTheme); + assert.deepEqual(workflow.normalizeTheme({ + market: "nxt", code: "00000002", themeTitle: "로봇", session: "after-market", itemCount: 121 + }), nxtAfterTheme); + assert.equal(workflow.normalizeTheme({ + market: "nxt", themeCode: "00000002", title: "로봇(NXT)", session: "PRE_MARKET" + }), null); + assert.equal(workflow.normalizeTheme({ + market: "krx", themeCode: "00000001", title: " AI 반도체 " + }), null); +}); + +test("typed theme validation reports clear market, code, session and preview failures", () => { + assert.equal(workflow.getCompatibility("five-row-current", { + market: "other", themeCode: "00000001", title: "AI" + }, "INPUT_ORDER").reason, "invalid-theme-market"); + assert.equal(workflow.getCompatibility("five-row-current", { + market: "krx", themeCode: "1", title: "AI" + }, "INPUT_ORDER").reason, "invalid-theme-code"); + assert.equal(workflow.getCompatibility("five-row-current", { + market: "krx", themeCode: " 00000001 ", title: "AI" + }, "INPUT_ORDER").reason, "invalid-theme-code"); + assert.equal(workflow.getCompatibility("five-row-current", { + market: "nxt", themeCode: "00000001", title: "AI" + }, "INPUT_ORDER").reason, "nxt-session-required"); + assert.equal(workflow.getCompatibility("five-row-current", { + market: "krx", themeCode: "00000001", title: "AI", session: "PRE_MARKET" + }, "INPUT_ORDER").reason, "krx-session-must-be-empty"); + assert.equal(workflow.getCompatibility("five-row-current", { + ...krxTheme, itemCount: 241 + }, "INPUT_ORDER").reason, "invalid-theme-item-count"); + assert.equal(workflow.getCompatibility("five-row-current", krxTheme, "RANDOM").reason, + "invalid-theme-sort"); +}); + +test("only three current row sizes and KRX five-row expected are compatible", () => { + for (const actionId of ["five-row-current", "six-row-current", "twelve-row-current"]) { + for (const sort of workflow.themeSorts) { + assert.equal(workflow.isActionAllowed(actionId, krxTheme, sort.id), true); + assert.equal(workflow.isActionAllowed(actionId, nxtPreTheme, sort.id), true); + assert.equal(workflow.isActionAllowed(actionId, nxtAfterTheme, sort.id), true); + } + } + assert.equal(workflow.isActionAllowed("five-row-expected", krxTheme, "INPUT_ORDER"), true); + assert.equal(workflow.getCompatibility("five-row-expected", nxtPreTheme, "INPUT_ORDER").reason, + "expected-theme-is-krx-only"); + assert.equal(workflow.getCompatibility("six-row-expected", krxTheme, "INPUT_ORDER").reason, + "unknown-action"); + assert.equal(workflow.getCompatibility("five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER").reason, + "theme-preview-is-empty"); +}); + +test("KRX current and expected selections map sort and value kind to separate fields", () => { + const current = workflow.buildThemeSelection("five-row-current", krxTheme, "GAIN_DESC"); + assert.equal(current.builderKey, "s5074"); + assert.equal(current.code, "5074"); + assert.deepEqual(current.aliases, ["5074"]); + assert.deepEqual(current.selection, { + groupCode: "PAGED_THEME", + subject: "AI 반도체", + graphicType: "GAIN_DESC", + subtype: "CURRENT", + dataCode: "00000001" + }); + + const expected = workflow.buildThemeSelection("five-row-expected", krxTheme, "GAIN_ASC"); + assert.equal(expected.builderKey, "s5074"); + assert.deepEqual(expected.selection, { + groupCode: "PAGED_THEME", + subject: "AI 반도체", + graphicType: "GAIN_ASC", + subtype: "EXPECTED", + dataCode: "00000001" + }); +}); + +test("NXT selections map explicit session to GraphicType and sort to Subtype", () => { + const five = workflow.buildThemeSelection("five-row-current", nxtPreTheme, "INPUT_ORDER"); + assert.deepEqual(five.selection, { + groupCode: "PAGED_NXT_THEME", + subject: "로봇(NXT)", + graphicType: "PRE_MARKET", + subtype: "INPUT_ORDER", + dataCode: "00000002" + }); + + const six = workflow.buildThemeSelection("six-row-current", nxtAfterTheme, "GAIN_DESC"); + assert.equal(six.builderKey, "s5077"); + assert.equal(six.code, "5077"); + assert.equal(six.selection.graphicType, "AFTER_MARKET"); + assert.equal(six.selection.subtype, "GAIN_DESC"); + + const twelve = workflow.buildThemeSelection("twelve-row-current", nxtAfterTheme, "GAIN_ASC"); + assert.equal(twelve.builderKey, "s5088"); + assert.equal(twelve.code, "5088"); + assert.deepEqual(twelve.aliases, ["5088"]); + assert.equal(twelve.selection.subtype, "GAIN_ASC"); +}); + +test("item-count previews calculate 5, 6 and 12 row pages within the 20-page boundary", () => { + assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 17 }), { + itemCount: 17, accessibleItemCount: 17, pageSize: 5, + pageCount: 4, maximumPageCount: 20, isTruncated: false + }); + assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 101 }), { + itemCount: 101, accessibleItemCount: 100, pageSize: 5, + pageCount: 20, maximumPageCount: 20, isTruncated: true + }); + assert.deepEqual(workflow.calculatePagePreview("six-row-current", { ...krxTheme, itemCount: 120 }), { + itemCount: 120, accessibleItemCount: 120, pageSize: 6, + pageCount: 20, maximumPageCount: 20, isTruncated: false + }); + assert.deepEqual(workflow.calculatePagePreview("twelve-row-current", { ...krxTheme, itemCount: 240 }), { + itemCount: 240, accessibleItemCount: 240, pageSize: 12, + pageCount: 20, maximumPageCount: 20, isTruncated: false + }); + assert.deepEqual(workflow.calculatePagePreview("five-row-current", { + market: "krx", themeCode: "00000001", title: "AI 반도체" + }), { + itemCount: null, accessibleItemCount: null, pageSize: 5, + pageCount: null, maximumPageCount: 20, isTruncated: false + }); +}); + +test("playlist entries preserve the alias, preview and explicit theme identity", () => { + const entry = workflow.createThemePlaylistEntry("six-row-current", nxtAfterTheme, "GAIN_DESC", { + id: "nxt-theme", fadeDuration: 4 + }); + assert.equal(entry.builderKey, "s5077"); + assert.equal(entry.code, "5077"); + assert.equal(entry.title, "로봇(NXT)"); + assert.equal(entry.detail, "6종목 현재가 · 상승률순"); + assert.equal(entry.pageSize, 6); + assert.equal(entry.pagePreview.pageCount, 20); + assert.equal(entry.pagePreview.isTruncated, true); + assert.equal(entry.fadeDuration, 4); + assert.deepEqual(entry.operator.theme, nxtAfterTheme); + assert.equal(entry.operator.resolverGroup, "PAGED_NXT_THEME"); +}); + +test("trusted theme restore reconstructs fields and rejects action, identity and preview tampering", () => { + const original = workflow.createThemePlaylistEntry("five-row-expected", krxTheme, "GAIN_ASC", { + id: "stored-theme", fadeDuration: 8 + }); + assert.ok(workflow.restoreThemePlaylistEntry(original)); + assert.equal(workflow.restoreThemePlaylistEntry({ ...original, enabled: false }).enabled, false); + assert.equal(workflow.restoreThemePlaylistEntry({ ...original, code: "5088" }), null); + assert.equal(workflow.restoreThemePlaylistEntry({ + ...original, + selection: { ...original.selection, subtype: "CURRENT" } + }), null); + assert.equal(workflow.restoreThemePlaylistEntry({ + ...original, + pagePreview: { ...original.pagePreview, pageCount: 20 } + }), null); + assert.equal(workflow.restoreThemePlaylistEntry({ + ...original, + operator: { ...original.operator, schemaVersion: 2 } + }), null); + assert.equal(workflow.restoreThemePlaylistEntry({ + ...original, + operator: { ...original.operator, theme: { ...original.operator.theme, themeCode: "00000003" } } + }), null); + assert.equal(workflow.restoreThemePlaylistEntry({ + ...original, + operator: { ...original.operator, resolverGroup: "PAGED_NXT_THEME" } + }), null); +}); + +test("unknown actions, unsafe ids, invalid fades and unsupported known-empty themes cannot be created", () => { + assert.throws(() => workflow.createThemePlaylistEntry("unknown", krxTheme, "INPUT_ORDER", { + id: "unknown" + }), /unknown/i); + assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", { + id: "bad id" + }), /safe theme playlist id/i); + assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", { + id: "bad-fade", fadeDuration: 61 + }), /0 through 60/i); + assert.throws(() => workflow.createThemePlaylistEntry( + "five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER", { id: "empty" }), + /theme-preview-is-empty/i); + assert.throws(() => workflow.createThemePlaylistEntry( + "five-row-expected", nxtPreTheme, "INPUT_ORDER", { id: "nxt-expected" }), + /expected-theme-is-krx-only/i); +}); diff --git a/tests/Web/trading-halt-workflow.test.cjs b/tests/Web/trading-halt-workflow.test.cjs new file mode 100644 index 0000000..19ec6bc --- /dev/null +++ b/tests/Web/trading-halt-workflow.test.cjs @@ -0,0 +1,272 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const workflow = require("../../Web/trading-halt-workflow.js"); + +const kospi = Object.freeze({ + market: "kospi", + stockCode: "005930", + displayName: "삼성전자" +}); +const kosdaq = Object.freeze({ + market: "kosdaq", + stockCode: "A123456", + displayName: "코스닥정지종목" +}); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function validResponse(overrides = {}) { + return { + requestId: "halt-request-1", + query: "", + retrievedAt: "2026-07-11T12:34:56+09:00", + totalRowCount: 2, + truncated: false, + results: [ + { market: "kospi", stockCode: "000001", displayName: "가나다" }, + { market: "kosdaq", stockCode: "000002", displayName: "라마바" } + ], + ...overrides + }; +} + +test("workflow pins the UC7 bridge and two closed scene actions", () => { + assert.deepEqual(workflow.bridgeContract, { + requestType: "search-trading-halts", + resultType: "trading-halt-results", + errorType: "trading-halt-error", + defaultMaximumResults: 100, + maximumResults: 500, + maximumQueryLength: 64 + }); + assert.deepEqual(workflow.sourceContract, { + originalControl: "Control/UC7.cs", + currentCutCode: "5001", + candleCutCode: "8051", + candlePeriodDays: 120, + currentResolverGroups: { kospi: "KOSPI", kosdaq: "KOSDAQ" }, + candleResolverGroups: { kospi: "KOSPI_STOCK", kosdaq: "KOSDAQ_STOCK" } + }); + assert.deepEqual(workflow.actions.map(value => [ + value.id, value.builderKey, value.code, value.kind + ]), [ + ["current", "s5001", "5001", "current"], + ["candle-120d", "s8010", "8051", "candle"] + ]); +}); + +test("search requests match the exact native bridge payload and normalize the query", () => { + assert.deepEqual( + workflow.createTradingHaltSearchRequest("request-1", " 삼성 "), + { requestId: "request-1", query: "삼성" }); + assert.deepEqual( + workflow.createTradingHaltSearchRequest("request-2", "", 200), + { requestId: "request-2", query: "", maximumResults: 200 }); + + assert.throws(() => workflow.createTradingHaltSearchRequest("bad id", ""), /safe/i); + assert.throws(() => workflow.createTradingHaltSearchRequest("request", "bad\nquery"), /safe/i); + assert.throws(() => workflow.createTradingHaltSearchRequest("request", "x".repeat(65)), /64/); + assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", 0), /1 through 500/); + assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", 501), /1 through 500/); + assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", "100"), /1 through 500/); +}); + +test("native rows normalize only exact typed market, stock-code and display-name values", () => { + assert.deepEqual(workflow.normalizeTradingHalt(kospi), kospi); + assert.deepEqual(workflow.normalizeTradingHalt(kosdaq), kosdaq); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, market: "KOSPI" }), null); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, market: "nxt-kospi" }), null); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, stockCode: "../005930" }), null); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, stockCode: " 005930" }), null); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: " 삼성전자" }), null); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: "삼성\n전자" }), null); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: "삼성\u200B전자" }), null); + assert.equal(workflow.normalizeTradingHalt({ ...kospi, code: "005930" }), null); + assert.equal(workflow.normalizeTradingHalt({ market: "kospi", code: "005930", name: "삼성전자" }), null); +}); + +test("search responses are request-bound, deterministically ordered and deeply normalized", () => { + const request = workflow.createTradingHaltSearchRequest("halt-request-1", "", 2); + const response = workflow.normalizeTradingHaltSearchResponse(validResponse(), request); + assert.ok(response); + assert.deepEqual(response, validResponse()); + assert.equal(Object.isFrozen(response), true); + assert.equal(Object.isFrozen(response.results), true); + assert.equal(Object.isFrozen(response.results[0]), true); + + assert.equal(workflow.normalizeTradingHaltSearchResponse( + validResponse({ requestId: "other-request" }), request), null); + assert.equal(workflow.normalizeTradingHaltSearchResponse( + validResponse({ query: "삼성" }), request), null); + assert.equal(workflow.normalizeTradingHaltSearchResponse( + validResponse({ totalRowCount: 1 }), request), null); + assert.equal(workflow.normalizeTradingHaltSearchResponse( + validResponse({ retrievedAt: "not-a-date" }), request), null); + assert.equal(workflow.normalizeTradingHaltSearchResponse( + { ...validResponse(), unexpected: true }, request), null); +}); + +test("search responses reject duplicates, ambiguous names, invalid rows and reordered markets", () => { + const duplicateIdentity = validResponse({ + results: [ + { market: "kospi", stockCode: "000001", displayName: "가나다" }, + { market: "kospi", stockCode: "000001", displayName: "라마바" } + ] + }); + assert.equal(workflow.normalizeTradingHaltSearchResponse(duplicateIdentity), null); + + const ambiguousName = validResponse({ + results: [ + { market: "kospi", stockCode: "000001", displayName: "가나다" }, + { market: "kospi", stockCode: "000002", displayName: "가나다" } + ] + }); + assert.equal(workflow.normalizeTradingHaltSearchResponse(ambiguousName), null); + + const crossMarketSameName = validResponse({ + results: [ + { market: "kospi", stockCode: "000001", displayName: "가나다" }, + { market: "kosdaq", stockCode: "000001", displayName: "가나다" } + ] + }); + assert.ok(workflow.normalizeTradingHaltSearchResponse(crossMarketSameName)); + + const reordered = validResponse({ results: [...validResponse().results].reverse() }); + assert.equal(workflow.normalizeTradingHaltSearchResponse(reordered), null); + assert.equal(workflow.normalizeTradingHaltSearchResponse(validResponse({ + results: [validResponse().results[0], { ...validResponse().results[1], market: "nxt-kosdaq" }] + })), null); +}); + +test("5001 current selections bind the exact halted-stock Core contract", () => { + for (const [stock, groupCode] of [[kospi, "KOSPI"], [kosdaq, "KOSDAQ"]]) { + const mapping = workflow.buildTradingHaltSelection("current", stock, { + ma5: true, + ma20: true + }); + assert.equal(mapping.builderKey, "s5001"); + assert.equal(mapping.code, "5001"); + assert.deepEqual(mapping.aliases, ["5001"]); + assert.deepEqual(mapping.selection, { + groupCode, + subject: stock.displayName, + graphicType: "1열판기본", + subtype: "HALTED", + dataCode: stock.stockCode + }); + assert.deepEqual(mapping.movingAverages, { ma5: false, ma20: false }); + } +}); + +test("8051 selections expose only the four closed moving-average flag combinations", () => { + const cases = [ + [false, false, ""], + [true, false, "MA5"], + [false, true, "MA20"], + [true, true, "MA5,MA20"] + ]; + for (const [ma5, ma20, graphicType] of cases) { + const mapping = workflow.buildTradingHaltSelection("candle-120d", kosdaq, { ma5, ma20 }); + assert.equal(mapping.builderKey, "s8010"); + assert.equal(mapping.code, "8051"); + assert.deepEqual(mapping.aliases, ["8051"]); + assert.deepEqual(mapping.selection, { + groupCode: "KOSDAQ_STOCK", + subject: "코스닥정지종목", + graphicType, + subtype: "TRADING_HALT", + dataCode: "A123456" + }); + } + assert.throws(() => workflow.buildTradingHaltSelection("candle-120d", kospi, { + ma5: "true", ma20: false + }), /boolean/i); +}); + +test("playlist entries preserve the typed identity and reject unsafe options", () => { + const entry = workflow.createTradingHaltPlaylistEntry(kospi, "candle-120d", { + id: "halt-candle", + fadeDuration: 8, + ma5: true, + ma20: false + }); + assert.equal(entry.builderKey, "s8010"); + assert.equal(entry.code, "8051"); + assert.equal(entry.category, "chart"); + assert.equal(entry.market, "kospi"); + assert.equal(entry.stockCode, "005930"); + assert.equal(entry.displayName, "삼성전자"); + assert.equal(entry.selection.graphicType, "MA5"); + assert.equal(entry.operator.source, "legacy-trading-halt-workflow"); + assert.equal(entry.operator.schemaVersion, 1); + assert.deepEqual(entry.operator.tradingHalt, kospi); + + assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "unknown", { + id: "unknown" + }), /unknown/i); + assert.throws(() => workflow.createTradingHaltPlaylistEntry({}, "current", { + id: "invalid" + }), /valid/i); + assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", { + id: "bad id" + }), /safe/i); + assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", { + id: "fade", fadeDuration: 61 + }), /0 through 60/i); + assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", { + id: "flags", ma20: 1 + }), /boolean/i); +}); + +test("trusted restore reconstructs entries and rejects mapping, identity and flag tampering", () => { + const original = workflow.createTradingHaltPlaylistEntry(kosdaq, "candle-120d", { + id: "stored-halt", + fadeDuration: 9, + ma5: true, + ma20: true + }); + original.enabled = false; + const restored = workflow.restoreTradingHaltPlaylistEntry(clone(original)); + assert.ok(restored); + assert.equal(restored.enabled, false); + assert.equal(restored.selection.graphicType, "MA5,MA20"); + + const tampers = [ + value => { value.builderKey = "s5001"; }, + value => { value.code = "5001"; }, + value => { value.selection.groupCode = "KOSPI_STOCK"; }, + value => { value.selection.subject = "다른종목"; }, + value => { value.selection.dataCode = "000001"; }, + value => { value.selection.graphicType = "MA20,MA5"; }, + value => { value.selection.subtype = "PRICE"; }, + value => { value.operator.actionId = "current"; }, + value => { value.operator.schemaVersion = 2; }, + value => { value.operator.resolverGroup = "KOSPI_STOCK"; }, + value => { value.operator.movingAverages.ma5 = false; }, + value => { value.operator.tradingHalt.stockCode = "000001"; }, + value => { value.enabled = "false"; } + ]; + for (const tamper of tampers) { + const candidate = clone(original); + tamper(candidate); + assert.equal(workflow.restoreTradingHaltPlaylistEntry(candidate), null); + } + assert.equal(workflow.restoreTradingHaltPlaylistEntry(null), null); +}); + +test("native error payloads are exact and bound to the active request", () => { + const request = workflow.createTradingHaltSearchRequest("halt-request-1", "삼성", 100); + const error = { + requestId: "halt-request-1", + query: "삼성", + message: "거래정지 종목을 검색하지 못했습니다." + }; + assert.deepEqual(workflow.normalizeTradingHaltError(error, request), error); + assert.equal(workflow.normalizeTradingHaltError({ ...error, requestId: "old" }, request), null); + assert.equal(workflow.normalizeTradingHaltError({ ...error, message: "bad\nmessage" }, request), null); + assert.equal(workflow.normalizeTradingHaltError({ ...error, detail: "extra" }, request), null); +}); diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs index 9e00f74..4e695ca 100644 --- a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs @@ -32,6 +32,11 @@ try } await VerifyOracleServerVersionAsync(runtime.Executor, cancellation.Token); + await VerifyStockSearchAsync(runtime.Executor, cancellation.Token); + await VerifyOverseasSelectorsAsync(runtime.Executor, cancellation.Token); + await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token); + await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token); + await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token); var service = new LegacyMarketDataService(runtime.Executor); await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token); @@ -64,10 +69,15 @@ catch (DatabaseInfrastructureException) "No provider details were printed."); return 1; } -catch +catch (OverseasIndustryIndexSearchDataException exception) +{ + Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}"); + return 1; +} +catch (Exception exception) { Console.Error.WriteLine( - "REAL_DB_SMOKE: FAIL - An unexpected error occurred. No provider details were printed."); + $"REAL_DB_SMOKE: FAIL - {exception.GetType().Name}. No provider details were printed."); return 1; } @@ -188,3 +198,110 @@ static async Task VerifySnapshotAsync( $"{table.TotalRowCount.ToString(CultureInfo.InvariantCulture)} rows"); } } + +static async Task VerifyOperatorCatalogSchemaAsync( + IDataQueryExecutor executor, + 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 new LegacyExpertCatalogPersistenceService(executor) + .SuggestNextCodeAsync(cancellationToken); + Console.WriteLine( + "Operator catalog schema: " + + string.Join( + ",", + result.ValidatedSources.Select(static source => source.ToString())) + + " (read-only zero-row preflight and code-range validation)"); +} + +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 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); + if (industries.Items.Count == 0 || stocks.Items.Count == 0) + { + throw new DatabaseConfigurationException( + "The production overseas selectors returned no rows."); + } + + Console.WriteLine( + $"OverseasSelectors: industries={industries.Items.Count.ToString(CultureInfo.InvariantCulture)}, " + + $"stocks={stocks.Items.Count.ToString(CultureInfo.InvariantCulture)}"); +} + +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."); + } + + var preview = await service.PreviewAsync( + experts.Items[0].Identity, + maximumItems: 100, + cancellationToken); + Console.WriteLine( + $"ExpertSelector: experts={experts.Items.Count.ToString(CultureInfo.InvariantCulture)}, " + + $"preview={preview.Items.Count.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"); +}