From 8dae7b8e0de2b634200e9c18f58edf37ea64f14c Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 23:58:45 +0900 Subject: [PATCH] feat: migrate legacy playout workflow and scenes --- Config/playout.example.json | 5 + LegacySceneRuntimeFactory.cs | 595 ++++++++ MainWindow.Playout.cs | 541 ++++++- MainWindow.xaml.cs | 25 +- Package.appxmanifest | 2 +- README.md | 2 +- Web/app.js | 694 ++++++++- Web/index.html | 39 + Web/playout-safety.js | 40 +- Web/styles.css | 24 + docs/LEGACY_PLAYOUT_ANALYSIS.md | 92 +- docs/MIGRATION.md | 16 +- docs/PLAYOUT.md | 27 +- docs/PLAYOUT_OPERATIONS.md | 261 ++++ docs/SCENE_EQUIVALENCE.md | 168 ++ docs/legacy-scene-source-hashes.json | 42 + scripts/Test-LegacyCutCoverage.ps1 | 60 + scripts/Test-LegacySceneBaseline.ps1 | 52 + scripts/Test-WebPlayout.ps1 | 56 + .../Data/DataQuerySpec.cs | 418 +++++ .../Data/IDataQueryExecutor.cs | 23 + .../Playout/IPlayoutEngine.cs | 139 +- .../Playout/PlayoutBridgeProtocol.cs | 243 +++ .../Playout/ScenePaging.cs | 331 ++++ .../Playout/Scenes/CandleSceneBuilder.cs | 703 +++++++++ .../Playout/Scenes/CandleSceneDataLoader.cs | 1057 +++++++++++++ .../Scenes/ChartLegacySceneRequestResolver.cs | 274 ++++ .../Scenes/ChartSceneBuilders5078To5084.cs | 735 +++++++++ .../Playout/Scenes/ChartSceneDataLoaders.cs | 909 +++++++++++ ...ComparisonAndYieldLegacyRequestResolver.cs | 335 ++++ .../Scenes/ComparisonAndYieldSceneBuilders.cs | 1159 ++++++++++++++ .../ComparisonAndYieldSceneDataLoaders.cs | 1102 ++++++++++++++ .../Scenes/EquityPanelSceneDataLoaders.cs | 515 +++++++ .../Scenes/FoundationalSceneBuilders.cs | 457 ++++++ .../Scenes/GridMarketSceneDataLoaders.cs | 662 ++++++++ .../Playout/Scenes/GridSceneBuilder5082.cs | 169 +++ .../LegacyGridMarketSceneRequestResolver.cs | 406 +++++ ...LegacyParameterizedSceneRequestResolver.cs | 639 ++++++++ .../Playout/Scenes/LegacyPlayoutWorkflow.cs | 572 +++++++ .../Scenes/LegacyPriceDirectionMutations.cs | 130 ++ .../Playout/Scenes/LegacySceneCatalog.cs | 298 ++++ .../Scenes/LegacySceneDataSourceRouter.cs | 107 ++ .../Playout/Scenes/LegacySceneDefinition.cs | 237 +++ .../Playout/Scenes/LegacySceneDtoMarkers.cs | 42 + .../Scenes/LegacySceneMutationBuilder.cs | 78 + .../LegacySceneMutationBuilderRegistry.cs | 273 ++++ .../Playout/Scenes/LegacyScenePreview.cs | 99 ++ .../Scenes/LegacySceneRefreshPolicy.cs | 80 + .../Scenes/LegacySceneRuntimeCoverage.cs | 177 +++ .../Playout/Scenes/ManualSceneDataLoaders.cs | 200 +++ .../Scenes/MarketPanelSceneDataLoaders.cs | 733 +++++++++ .../Playout/Scenes/PagedQuoteSceneBuilders.cs | 388 +++++ .../Scenes/PagedQuoteSceneDataLoaders.cs | 1138 ++++++++++++++ .../Playout/Scenes/PanelSceneDataLoaders.cs | 166 ++ .../ParameterizedMarketSceneBuilders.cs | 1352 +++++++++++++++++ .../ParameterizedMarketSceneDataLoaders.cs | 1033 +++++++++++++ .../Scenes/ParameterizedMarketSceneQueries.cs | 729 +++++++++ .../Scenes/ReadOnlyPanelSceneBuilders.cs | 697 +++++++++ .../RegisteredLegacySceneCueProvider.cs | 206 +++ .../Scenes/TabularMarketSceneBuilders.cs | 602 ++++++++ .../Scenes/TraderQuoteSceneDataLoaders.cs | 300 ++++ .../TrustedManualSceneDataLoader5025.cs | 91 ++ .../Execution/ResilientDataQueryExecutor.cs | 81 +- .../S5025TrustedManualFileDataSource.cs | 317 ++++ .../Configuration/PlayoutOptions.cs | 21 + .../Configuration/PlayoutOptionsLoader.cs | 25 +- .../PlayoutSceneCompositionFactory.cs | 109 ++ .../Configuration/ValidatedPlayoutOptions.cs | 237 ++- .../InstalledK3dInteropConnectBinding.cs | 50 +- .../Diagnostics/PgmCutsSequence.cs | 1 + .../Interop/DynamicK3dEventHandler.cs | 349 +++++ .../Interop/DynamicK3dSession.cs | 558 ++++++- .../TornadoPlayoutEngine.cs | 270 +++- .../CandleSceneBuilderTests.cs | 427 ++++++ .../CandleSceneDataLoaderTests.cs | 458 ++++++ .../ChartLegacySceneRequestResolverTests.cs | 238 +++ .../ChartSceneBuilders5078To5084Tests.cs | 490 ++++++ .../ChartSceneDataLoadersTests.cs | 372 +++++ ...risonAndYieldLegacyRequestResolverTests.cs | 402 +++++ .../ComparisonAndYieldSceneBuildersTests.cs | 597 ++++++++ ...ComparisonAndYieldSceneDataLoadersTests.cs | 728 +++++++++ .../DataQuerySpecTests.cs | 236 +++ .../EquityPanelSceneDataLoadersTests.cs | 286 ++++ .../FoundationalSceneBuildersTests.cs | 176 +++ .../GridMarketSceneDataLoadersTests.cs | 351 +++++ .../GridSceneBuilder5082Tests.cs | 146 ++ ...gacyGridMarketSceneRequestResolverTests.cs | 427 ++++++ ...yParameterizedSceneRequestResolverTests.cs | 178 +++ .../LegacyPlayoutWorkflowTests.cs | 481 ++++++ .../LegacyPriceDirectionMutationsTests.cs | 108 ++ .../LegacySceneCatalogTests.cs | 295 ++++ .../LegacySceneDataSourceRouterTests.cs | 72 + ...LegacySceneMutationBuilderRegistryTests.cs | 146 ++ .../LegacyScenePreviewTests.cs | 51 + .../LegacySceneRefreshPolicyTests.cs | 47 + .../LegacySceneRuntimeCoverageTests.cs | 145 ++ .../LegacyWebWorkflowIntegrationTests.cs | 177 +++ .../ManualSceneDataLoadersTests.cs | 142 ++ .../MarketPanelSceneDataLoadersTests.cs | 388 +++++ .../PagedQuoteSceneBuildersTests.cs | 353 +++++ .../PagedQuoteSceneDataLoadersTests.cs | 574 +++++++ .../PanelSceneDataLoadersTests.cs | 90 ++ .../ParameterizedMarketSceneBuildersTests.cs | 487 ++++++ ...arameterizedMarketSceneDataLoadersTests.cs | 618 ++++++++ .../PlayoutBridgeProtocolTests.cs | 109 ++ .../ReadOnlyPanelSceneBuildersTests.cs | 552 +++++++ .../RegisteredLegacySceneCueProviderTests.cs | 153 ++ .../ScenePagingTests.cs | 335 ++++ .../TabularMarketSceneBuildersTests.cs | 282 ++++ .../TraderQuoteSceneDataLoadersTests.cs | 306 ++++ .../TrustedManualSceneDataLoader5025Tests.cs | 130 ++ .../DatabaseFakes.cs | 58 +- .../ProviderDatabaseConnectionFactoryTests.cs | 27 + .../ResilientDataQueryExecutorTests.cs | 246 +++ .../S5025TrustedManualFileDataSourceTests.cs | 330 ++++ .../DryRunPlayoutEngineTests.cs | 13 +- .../DynamicK3dEventHandlerTests.cs | 103 ++ .../DynamicK3dSessionTests.cs | 583 ++++++- .../IsolatedTestCommandTests.cs | 5 + .../PgmCutsSequenceRunnerTests.cs | 14 + .../PlayoutFakes.cs | 36 +- .../PlayoutOptionsLoaderTests.cs | 23 + .../PlayoutSafetyValidationTests.cs | 79 +- .../PlayoutSceneCompositionFactoryTests.cs | 53 + .../TornadoPlayoutEngineTests.cs | 567 ++++++- tests/Web/playout-safety.test.cjs | 66 + tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs | 52 +- .../RealSceneDbSmoke.cs | 811 ++++++++++ 128 files changed, 39177 insertions(+), 205 deletions(-) create mode 100644 LegacySceneRuntimeFactory.cs create mode 100644 docs/PLAYOUT_OPERATIONS.md create mode 100644 docs/SCENE_EQUIVALENCE.md create mode 100644 docs/legacy-scene-source-hashes.json create mode 100644 scripts/Test-LegacyCutCoverage.ps1 create mode 100644 scripts/Test-LegacySceneBaseline.ps1 create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartLegacySceneRequestResolver.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/EquityPanelSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridMarketSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridSceneBuilder5082.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyGridMarketSceneRequestResolver.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPriceDirectionMutations.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneCatalog.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDefinition.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDtoMarkers.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilder.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilderRegistry.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRuntimeCoverage.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/MarketPanelSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PanelSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TraderQuoteSceneDataLoaders.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedManualSceneDataLoader5025.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dEventHandler.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartLegacySceneRequestResolverTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneBuilders5078To5084Tests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQuerySpecTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/EquityPanelSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/FoundationalSceneBuildersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/GridMarketSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/GridSceneBuilder5082Tests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyGridMarketSceneRequestResolverTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPriceDirectionMutationsTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneCatalogTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneDataSourceRouterTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneMutationBuilderRegistryTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRuntimeCoverageTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWebWorkflowIntegrationTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ManualSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/MarketPanelSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/PanelSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/ScenePagingTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/TraderQuoteSceneDataLoadersTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedManualSceneDataLoader5025Tests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dEventHandlerTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs create mode 100644 tools/MBN_STOCK_WEBVIEW.DbSmoke/RealSceneDbSmoke.cs diff --git a/Config/playout.example.json b/Config/playout.example.json index 18b54ed..82cfa31 100644 --- a/Config/playout.example.json +++ b/Config/playout.example.json @@ -7,6 +7,11 @@ "sceneDirectory": null, "outputChannel": null, "layoutIndex": 10, + "legacySceneFadeDuration": 6, + "legacySceneBackgroundKind": "None", + "legacySceneBackgroundAssetPath": null, + "legacySceneBackgroundVideoLoopCount": 2004, + "legacySceneBackgroundVideoLoopInfinite": true, "testProcessWindowTitlePattern": null, "testSceneAllowlist": [], "trustedLiveOutputEnabled": false, diff --git a/LegacySceneRuntimeFactory.cs b/LegacySceneRuntimeFactory.cs new file mode 100644 index 0000000..c109129 --- /dev/null +++ b/LegacySceneRuntimeFactory.cs @@ -0,0 +1,595 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +/// +/// Application composition root for native DB-to-scene routes. Routes are added only +/// when their explicit loader and mutation builder have been ported and tested. +/// +internal static class LegacySceneRuntimeFactory +{ + public static LegacyPlayoutWorkflow Create( + IPlayoutEngine engine, + IDataQueryExecutor executor, + LegacySceneCueCompositionOptions? compositionOptions = null) + { + ArgumentNullException.ThrowIfNull(engine); + ArgumentNullException.ThrowIfNull(executor); + + var revenueLoader = new S5076SceneDataLoader(executor); + var profitLoader = new S5081SceneDataLoader(executor); + var tradingLoader = new S5082SceneDataLoader(executor); + var nxtDetailLoader = new S5006NxtSceneDataLoader(executor); + var domesticDetailLoader = new S5006DomesticSceneDataLoader(executor); + var equityDetailLoader = new S5011SceneDataLoader(executor); + var threePanelLoader = new S5016SceneDataLoader(executor); + var twoPanelLoader = new S50160SceneDataLoader(executor); + var imageQuoteLoader = new S6001SceneDataLoader(executor); + var worldMapLoader = new S8067SceneDataLoader(executor); + var candleLoader = new S8010SceneDataLoader(executor); + var fiveRowLoader = new S5074SceneDataLoader(executor); + var sixRowLoader = new S5077SceneDataLoader(executor); + var twelveRowLoader = new S5088SceneDataLoader(executor); + var parameterizedRequestResolver = new LegacyParameterizedSceneRequestResolver(executor); + var oneColumnLoader = new S5001SceneDataLoader(executor); + var futuresPairLoader = new S5032SceneDataLoader(executor); + var twoColumnLoader = new S8018SceneDataLoader(executor); + var sectorSquareLoader = new S8001SceneDataLoader(executor); + var comparisonRequestResolver = new ComparisonAndYieldLegacyRequestResolver(executor); + var candlePairLoader = new S5026SceneDataLoader(executor); + var comparisonYieldLoader = new S5029SceneDataLoader(executor); + var singleYieldLoader = new S5086SceneDataLoader(executor); + var quotedYieldLoader = new S50860SceneDataLoader(executor); + var linePairLoader = new S5087SceneDataLoader(executor); + var chartRequestResolver = new ChartLegacySceneRequestResolver(); + var sectorIndexLoader = new S5078SceneDataLoader(executor); + var growthMetricsLoader = new S5079SceneDataLoader(executor); + 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) + ? null + : new S5025TrustedManualFileDataSource(manualDataDirectory); + var gridMarketRequestResolver = new LegacyGridMarketSceneRequestResolver( + executor, + trustedManualSource); + var dailyInvestorLoader = new S5023SceneDataLoader(executor); + var investorBarLoader = new S5024SceneDataLoader(executor); + var traderLoader = new S5037SceneDataLoader(executor); + var programTradingLoader = new S5085SceneDataLoader(executor); + var institutionNetBuyLoader = new S6067SceneDataLoader(executor); + var orderBookLoader = new S8003SceneDataLoader(executor); + + var dataSource = new LegacySceneDataSourceRouter( + [ + new LegacySceneDataSourceRoute(["5076"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var data = await revenueLoader.LoadAsync( + selection.Subject, + cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s5076", data, 1); + }), + new LegacySceneDataSourceRoute(["5081"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var data = await profitLoader.LoadAsync( + selection.Subject, + cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s5081", data, 1); + }), + new LegacySceneDataSourceRoute(["5082"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var data = await tradingLoader.LoadAsync(cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s5082", data, 1); + }), + new LegacySceneDataSourceRoute(["5001", "N5001"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var request = parameterizedRequestResolver.CreateS5001Request(entry); + var data = await oneColumnLoader.LoadAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s5001", data, 1); + }), + new LegacySceneDataSourceRoute(["8018", "8032", "5032"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var resolved = await parameterizedRequestResolver.ResolveTwoColumnRequestAsync( + entry, + cancellationToken).ConfigureAwait(false); + return resolved switch + { + LegacyS5032LoadRequest request => new LegacySceneDataPage( + "s5032", + await futuresPairLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS8018LoadRequest request => new LegacySceneDataPage( + "s8018", + await twoColumnLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + _ => throw new LegacySceneDataException( + "The two-column scene selection is invalid.") + }; + }), + new LegacySceneDataSourceRoute(["8001", "8002"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var request = parameterizedRequestResolver.CreateS8001Request(entry); + var data = await sectorSquareLoader.LoadAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s8001", data, 1); + }), + new LegacySceneDataSourceRoute( + ["5026", "5029", "5086", "50860", "5087"], + async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var resolved = await comparisonRequestResolver.ResolveAsync( + entry, + cancellationToken).ConfigureAwait(false); + return resolved switch + { + LegacyS5026SceneLoadRequest request => new LegacySceneDataPage( + "s5026", + await candlePairLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5029SceneLoadRequest request => new LegacySceneDataPage( + "s5029", + await comparisonYieldLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5086SceneLoadRequest request => new LegacySceneDataPage( + "s5086", + await singleYieldLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS50860SceneLoadRequest request => new LegacySceneDataPage( + "s50860", + await quotedYieldLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5087SceneLoadRequest request => new LegacySceneDataPage( + "s5087", + await linePairLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + _ => throw new LegacySceneDataException( + "The comparison scene selection is invalid.") + }; + }), + new LegacySceneDataSourceRoute( + ["5078", "5079", "5080", "5083", "5084"], + async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var resolved = chartRequestResolver.Resolve(entry); + return resolved switch + { + LegacyS5078ChartLoadRequest request => new LegacySceneDataPage( + "s5078", + await sectorIndexLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5079ChartLoadRequest request => new LegacySceneDataPage( + "s5079", + await growthMetricsLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5080ChartLoadRequest request => new LegacySceneDataPage( + "s5080", + await salesLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5083ChartLoadRequest request => new LegacySceneDataPage( + "s5083", + await participantTrendLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5084ChartLoadRequest request => new LegacySceneDataPage( + "s5084", + await indexTrendLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + _ => throw new LegacySceneDataException( + "The chart scene selection is invalid.") + }; + }), + new LegacySceneDataSourceRoute( + ["5023", "5024", "5025", "5037", "5085", "6067", "8003"], + async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var resolved = await gridMarketRequestResolver.ResolveAsync( + entry, + cancellationToken).ConfigureAwait(false); + return resolved switch + { + LegacyS5023SceneLoadRequest request => new LegacySceneDataPage( + "s5023", + await dailyInvestorLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5024SceneLoadRequest request => new LegacySceneDataPage( + "s5024", + await investorBarLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5025SceneLoadRequest => new LegacySceneDataPage( + "s5025", + await gridMarketRequestResolver.LoadTrustedS5025Async( + entry, + cancellationToken).ConfigureAwait(false), + 1), + LegacyS5037SceneLoadRequest request => new LegacySceneDataPage( + "s5037", + await traderLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS5085SceneLoadRequest => new LegacySceneDataPage( + "s5085", + await programTradingLoader.LoadAsync(cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS6067SceneLoadRequest => new LegacySceneDataPage( + "s6067", + await institutionNetBuyLoader.LoadAsync(cancellationToken) + .ConfigureAwait(false), + 1), + LegacyS8003SceneLoadRequest request => new LegacySceneDataPage( + "s8003", + await orderBookLoader.LoadAsync(request.Request, cancellationToken) + .ConfigureAwait(false), + 1), + _ => throw new LegacySceneDataException( + "The grid-market scene selection is invalid.") + }; + }), + new LegacySceneDataSourceRoute(["5006"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var market = ParseDomesticMarket(selection.GroupCode); + S5006SceneData data; + if (market is LegacyDomesticEquityMarket.NxtKospi or + LegacyDomesticEquityMarket.NxtKosdaq) + { + data = await nxtDetailLoader.LoadAsync( + market, + selection.DataCode, + cancellationToken).ConfigureAwait(false); + } + else + { + data = await domesticDetailLoader.LoadAsync( + new S5006DomesticSceneDataRequest(market, selection.DataCode), + cancellationToken).ConfigureAwait(false); + } + + return new LegacySceneDataPage("s5006", data, 1); + }), + new LegacySceneDataSourceRoute(["5011"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var data = await equityDetailLoader.LoadAsync( + new S5011SceneDataRequest( + ParseDomesticMarket(selection.GroupCode), + ParseClosedEnum(selection.Subtype, "5011 detail branch"), + selection.DataCode), + cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s5011", data, 1); + }), + new LegacySceneDataSourceRoute(["5016"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var data = await threePanelLoader.LoadAsync( + ParseClosedEnum(selection.Subtype, "5016 panel target"), + cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s5016", data, 1); + }), + new LegacySceneDataSourceRoute(["50160"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var data = await twoPanelLoader.LoadAsync( + ParseClosedEnum(selection.Subtype, "50160 panel target"), + cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s50160", data, 1); + }), + new LegacySceneDataSourceRoute(["6001"], async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var data = await imageQuoteLoader.LoadAsync( + ParseClosedEnum(selection.Subtype, "6001 quote target"), + cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s6001", data, 1); + }), + new LegacySceneDataSourceRoute( + ["8067", "5068", "5070", "5072"], + async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var data = await worldMapLoader.LoadAsync(cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s8067", data, 1); + }), + new LegacySceneDataSourceRoute( + ["8035", "8061", "8040", "8046", "8051", "8056"], + async (entry, page, cancellationToken) => + { + RequireFirstPage(page); + var selection = RequireSelection(entry); + var target = ParseCandleMarket(selection.GroupCode); + var request = new S8010SceneDataRequest( + ParseCandleAlias(entry.CutCode), + target, + ParseCandleMode(selection.Subtype), + CandleRequiresInstrument(target) + ? RequireLookupValue(selection.Subject, "instrument") + : null, + HasClosedFlag(selection.GraphicType, "MA5"), + HasClosedFlag(selection.GraphicType, "MA20")); + var data = await candleLoader.LoadAsync( + request, + cancellationToken).ConfigureAwait(false); + return new LegacySceneDataPage("s8010", data, 1); + }), + new LegacySceneDataSourceRoute(["5074"], async (entry, page, cancellationToken) => + { + var request = CreatePagedQuoteRequest(RequireSelection(entry), page); + var data = await fiveRowLoader.LoadAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s5074", data, data.Rows.Count); + }), + new LegacySceneDataSourceRoute(["5077"], async (entry, page, cancellationToken) => + { + var request = CreatePagedQuoteRequest(RequireSelection(entry), page); + var data = await sixRowLoader.LoadAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s5077", data, data.Rows.Count); + }), + new LegacySceneDataSourceRoute(["5088"], async (entry, page, cancellationToken) => + { + var request = CreatePagedQuoteRequest(RequireSelection(entry), page); + var data = await twelveRowLoader.LoadAsync(request, cancellationToken) + .ConfigureAwait(false); + return new LegacySceneDataPage("s5088", data, data.Rows.Count); + }) + ]); + + LegacySceneRuntimeCoverage.Validate(dataSource); + var cueProvider = new RegisteredLegacySceneCueProvider( + dataSource, + options: compositionOptions); + return new LegacyPlayoutWorkflow(engine, cueProvider); + } + + private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) => + entry.Selection ?? throw new LegacySceneDataException( + "The selected cut requires native database lookup information."); + + private static void RequireFirstPage(int pageIndexZeroBased) + { + if (pageIndexZeroBased != 0) + { + throw new LegacySceneDataException("The selected cut is not paged."); + } + } + + private static LegacyDomesticEquityMarket ParseDomesticMarket(string value) => value switch + { + "KOSPI" or "KOSPI_STOCK" or "코스피" or "코스피종목" => + LegacyDomesticEquityMarket.Kospi, + "KOSDAQ" or "KOSDAQ_STOCK" or "코스닥" or "코스닥종목" => + LegacyDomesticEquityMarket.Kosdaq, + "NXT_KOSPI" or "NXT코스피" or "코스피(NXT)" => + LegacyDomesticEquityMarket.NxtKospi, + "NXT_KOSDAQ" or "NXT코스닥" or "코스닥(NXT)" => + LegacyDomesticEquityMarket.NxtKosdaq, + _ => throw new LegacySceneDataException( + "The domestic equity market selection is invalid.") + }; + + private static CandleCutAlias ParseCandleAlias(string value) => value switch + { + "8035" => CandleCutAlias.Cut8035, + "8061" => CandleCutAlias.Cut8061, + "8040" => CandleCutAlias.Cut8040, + "8046" => CandleCutAlias.Cut8046, + "8051" => CandleCutAlias.Cut8051, + "8056" => CandleCutAlias.Cut8056, + _ => throw new LegacySceneDataException("The candle cut alias is invalid.") + }; + + private static CandleMarketTarget ParseCandleMarket(string value) => value switch + { + "KOSPI_INDEX" or "코스피지수" => CandleMarketTarget.KospiIndex, + "KOSDAQ_INDEX" or "코스닥지수" => CandleMarketTarget.KosdaqIndex, + "KOSPI200_INDEX" or "코스피200" => CandleMarketTarget.Kospi200Index, + "KRX100_INDEX" or "KRX100" => CandleMarketTarget.Krx100Index, + "FUTURES_INDEX" or "선물" => CandleMarketTarget.FuturesIndex, + "KOSPI_INDUSTRY" or "코스피업종" => CandleMarketTarget.KospiIndustry, + "KOSDAQ_INDUSTRY" or "코스닥업종" => CandleMarketTarget.KosdaqIndustry, + "FOREIGN_INDEX" or "해외지수" => CandleMarketTarget.OverseasIndex, + "FOREIGN_STOCK" or "해외종목" => CandleMarketTarget.OverseasStock, + "KOSPI_STOCK" or "코스피종목" => CandleMarketTarget.KospiStock, + "KOSDAQ_STOCK" or "코스닥종목" => CandleMarketTarget.KosdaqStock, + _ => throw new LegacySceneDataException("The candle market selection is invalid.") + }; + + private static CandleChartMode ParseCandleMode(string value) => value switch + { + "" or "PRICE" or "현재가" => CandleChartMode.Price, + "EXPECTED" or "예상체결" => CandleChartMode.ExpectedExecution, + "VOLUME" or "거래량" => CandleChartMode.PriceWithVolume, + "TRADING_HALT" or "거래정지" => CandleChartMode.TradingHalt, + _ => throw new LegacySceneDataException("The candle chart mode is invalid.") + }; + + private static bool CandleRequiresInstrument(CandleMarketTarget value) => value is + CandleMarketTarget.KospiIndustry or + CandleMarketTarget.KosdaqIndustry or + CandleMarketTarget.OverseasIndex or + CandleMarketTarget.OverseasStock or + CandleMarketTarget.KospiStock or + CandleMarketTarget.KosdaqStock; + + private static string RequireLookupValue(string value, string label) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new LegacySceneDataException($"The scene {label} selection is required."); + } + + return value; + } + + private static bool HasClosedFlag(string value, string flag) => + value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Contains(flag, StringComparer.Ordinal); + + private static PagedQuoteLoadRequest CreatePagedQuoteRequest( + LegacySceneSelection selection, + int pageIndexZeroBased) + { + var page = checked(pageIndexZeroBased + 1); + return selection.GroupCode switch + { + "PAGED_DOMESTIC_KOSPI" => new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + ParseDomesticPagedList(selection.Subtype), + ParseAsOfDate(selection.DataCode), + page), + "PAGED_DOMESTIC_KOSDAQ" => new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kosdaq, + ParseDomesticPagedList(selection.Subtype), + ParseAsOfDate(selection.DataCode), + page), + "PAGED_NXT_KOSPI" => new NxtPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + ParseNxtPagedList(selection.Subtype), + ParseNxtSession(selection.GraphicType), + page), + "PAGED_NXT_KOSDAQ" => new NxtPagedQuoteLoadRequest( + PagedQuoteMarket.Kosdaq, + ParseNxtPagedList(selection.Subtype), + ParseNxtSession(selection.GraphicType), + page), + "PAGED_THEME" => new ThemePagedQuoteLoadRequest( + RequireLookupValue(selection.Subject, "theme"), + ParseThemeSort(selection.GraphicType), + ParseThemeValueKind(selection.Subtype), + page), + "PAGED_NXT_THEME" => new NxtThemePagedQuoteLoadRequest( + RequireLookupValue(selection.Subject, "NXT theme"), + ParseThemeSort(selection.Subtype), + ParseNxtSession(selection.GraphicType), + page), + "PAGED_EXPERT" => new ExpertPagedQuoteLoadRequest( + RequireLookupValue(selection.Subject, "expert"), + page), + "PAGED_VI" => new ViPagedQuoteLoadRequest( + ParseStockNames(selection.Subject), + page), + _ => throw new LegacySceneDataException("The paged quote branch is invalid.") + }; + } + + private static DomesticPagedQuoteList ParseDomesticPagedList(string value) => value switch + { + "EXPECTED_MARKET_CAP" => DomesticPagedQuoteList.ExpectedMarketCapitalization, + "MARKET_CAP" => DomesticPagedQuoteList.MarketCapitalization, + "INDUSTRY" => DomesticPagedQuoteList.Industry, + "AFTER_HOURS_GAIN_RATE" => DomesticPagedQuoteList.AfterHoursGainRate, + "AFTER_HOURS_LOSS_RATE" => DomesticPagedQuoteList.AfterHoursLossRate, + "GAIN_RATE" => DomesticPagedQuoteList.GainRate, + "LOSS_RATE" => DomesticPagedQuoteList.LossRate, + "52_WEEK_HIGH" => DomesticPagedQuoteList.FiftyTwoWeekHigh, + "52_WEEK_LOW" => DomesticPagedQuoteList.FiftyTwoWeekLow, + "KOSPI200" => DomesticPagedQuoteList.Kospi200, + "ETF" => DomesticPagedQuoteList.Etf, + _ => throw new LegacySceneDataException("The domestic paged quote list is invalid.") + }; + + private static NxtPagedQuoteList ParseNxtPagedList(string value) => value switch + { + "MARKET_CAP" => NxtPagedQuoteList.MarketCapitalization, + "AFTER_HOURS_GAIN_RATE" => NxtPagedQuoteList.AfterHoursGainRate, + "AFTER_HOURS_LOSS_RATE" => NxtPagedQuoteList.AfterHoursLossRate, + "GAIN_RATE" => NxtPagedQuoteList.GainRate, + "LOSS_RATE" => NxtPagedQuoteList.LossRate, + "VOLUME" => NxtPagedQuoteList.Volume, + _ => throw new LegacySceneDataException("The NXT paged quote list is invalid.") + }; + + private static NxtMarketSession ParseNxtSession(string value) => value switch + { + "PRE_MARKET" => NxtMarketSession.PreMarket, + "AFTER_MARKET" => NxtMarketSession.AfterMarket, + _ => throw new LegacySceneDataException("The NXT market session is invalid.") + }; + + private static PagedQuoteThemeSort ParseThemeSort(string value) => value switch + { + "" or "INPUT_ORDER" => PagedQuoteThemeSort.InputOrder, + "GAIN_DESC" => PagedQuoteThemeSort.GainRateDescending, + "GAIN_ASC" => PagedQuoteThemeSort.GainRateAscending, + _ => throw new LegacySceneDataException("The theme sort selection is invalid.") + }; + + private static PagedQuoteThemeValueKind ParseThemeValueKind(string value) => value switch + { + "" or "CURRENT" => PagedQuoteThemeValueKind.Current, + "EXPECTED" => PagedQuoteThemeValueKind.ExpectedExecution, + _ => throw new LegacySceneDataException("The theme value selection is invalid.") + }; + + private static DateOnly ParseAsOfDate(string value) => + DateOnly.TryParseExact( + value, + "yyyy-MM-dd", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var date) + ? date + : throw new LegacySceneDataException("The paged quote as-of date is invalid."); + + private static IReadOnlyList ParseStockNames(string value) + { + var names = value.Split( + ',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (names.Length is < 1 or > 240) + { + throw new LegacySceneDataException("The VI stock-name selection is invalid."); + } + + return Array.AsReadOnly(names); + } + + private static TEnum ParseClosedEnum(string value, string label) + where TEnum : struct, Enum + { + if (!Enum.TryParse(value, ignoreCase: false, out var parsed) || + !Enum.IsDefined(parsed)) + { + throw new LegacySceneDataException($"The {label} selection is invalid."); + } + + return parsed; + } +} diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index 6af397c..92a3361 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -1,5 +1,7 @@ using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; using MBN_STOCK_WEBVIEW.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; @@ -8,19 +10,36 @@ namespace MBN_STOCK_WEBVIEW; public sealed partial class MainWindow { private const int MaximumPlayoutRequestIdLength = 128; + private const string BrowserCorrelationQuarantineMessage = + "Browser correlation was lost before the playout result could be confirmed. Verify PGM output and restart the app before issuing another command."; private readonly SemaphoreSlim _playoutCommandGate = new(1, 1); private IPlayoutEngine? _playoutEngine; + private LegacyPlayoutWorkflow? _playoutWorkflow; private string? _playoutInitializationError; private int _playoutCommandInFlight; private int _playoutShutdownStarted; + private int _browserCorrelationQuarantined; + private CancellationTokenSource? _legacyRefreshCancellation; + private Task? _legacyRefreshTask; + private readonly object _legacyRefreshStatusGate = new(); + private LegacyRefreshRuntimeStatus _legacyRefreshStatus = LegacyRefreshRuntimeStatus.Empty; private void InitializePlayoutRuntime() { try { - _playoutEngine = PlayoutEngineFactory.CreateDefault(); + var options = PlayoutOptionsLoader.Load(); + var compositionOptions = PlayoutSceneCompositionFactory.Create(options); + _playoutEngine = PlayoutEngineFactory.Create(options); _playoutEngine.StatusChanged += OnPlayoutStatusChanged; + if (_databaseRuntime is not null) + { + _playoutWorkflow = LegacySceneRuntimeFactory.Create( + _playoutEngine, + _databaseRuntime.Executor, + compositionOptions); + } } catch (Exception exception) { @@ -57,6 +76,13 @@ public sealed partial class MainWindow private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args) { + _playoutWorkflow?.ObserveEngineStatus(args.Current); + if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) && + string.IsNullOrWhiteSpace(args.Current.OnAirSceneName)) + { + StopLegacyRefreshLoop(); + ResetLegacyRefreshStatus(); + } if (Volatile.Read(ref _playoutCommandInFlight) != 0 || Volatile.Read(ref _playoutShutdownStarted) != 0) { @@ -92,7 +118,7 @@ public sealed partial class MainWindow private async Task HandlePlayoutCommandAsync(JsonElement payload) { - var parsed = PlayoutBridgeProtocol.ParseCommand(payload); + var parsed = PlayoutBridgeProtocol.ParseWorkflowCommand(payload); var request = parsed.Request; if (!parsed.IsValid) { @@ -106,8 +132,21 @@ public sealed partial class MainWindow return; } + if (IsBrowserCorrelationQuarantined()) + { + PostPlayoutCommandError( + request.RequestId, + request.Command, + "OUTCOME_UNKNOWN", + BrowserCorrelationQuarantineMessage, + retryable: false, + outcomeUnknown: true); + return; + } + var engine = _playoutEngine; - if (engine is null) + var workflow = _playoutWorkflow; + if (engine is null || workflow is null) { PostPlayoutCommandError( request.RequestId, @@ -134,8 +173,42 @@ public sealed partial class MainWindow try { Interlocked.Exchange(ref _playoutCommandInFlight, 1); - var result = await ExecutePlayoutCommandAsync(engine, request, _lifetimeCancellation.Token); + if (IsBrowserCorrelationQuarantined()) + { + PostPlayoutCommandError( + request.RequestId, + request.Command, + "OUTCOME_UNKNOWN", + BrowserCorrelationQuarantineMessage, + retryable: false, + outcomeUnknown: true); + return; + } + + var refreshBeforeCommand = GetLegacyRefreshStatus(); + if (refreshBeforeCommand.IsFaulted && request.Command != "take-out") + { + PostPlayoutCommandError( + request.RequestId, + request.Command, + "REFRESH_FAULT", + refreshBeforeCommand.FaultMessage ?? + "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 상태를 확인하세요.", + retryable: false, + outcomeUnknown: false); + return; + } + + var nextKindBeforeCommand = workflow.State.NextKind; + // MainForm stops timer1 before every operator command. TAKE IN and a + // successful playlist NEXT start it again; Page NEXT intentionally does not. + StopLegacyRefreshLoop(); + var result = await ExecutePlayoutCommandAsync( + workflow, + request, + _lifetimeCancellation.Token); var status = engine.Status; + var workflowState = workflow.State; if (!result.IsSuccess) { @@ -149,6 +222,19 @@ public sealed partial class MainWindow return; } + if (request.Command == "take-in" || + (request.Command == "next" && + nextKindBeforeCommand == LegacyWorkflowNextKind.PlaylistNext)) + { + StartLegacyRefreshLoop(workflow); + } + else if (request.Command == "take-out") + { + ResetLegacyRefreshStatus(); + } + + var refreshStatus = GetLegacyRefreshStatus(); + PostMessage("playout-command-result", new { requestId = request.RequestId, @@ -158,7 +244,24 @@ public sealed partial class MainWindow state = ToPlayoutPhase(status), message = result.Message, preparedCode = status.PreparedSceneName, - onAirCode = status.OnAirSceneName + onAirCode = status.OnAirSceneName, + currentCueIndex = workflowState.CurrentCueIndexZeroBased, + currentEntryId = workflowState.CurrentEntryId, + builderKey = workflowState.BuilderKey, + pageIndex = workflowState.PageIndexZeroBased, + pageCount = workflowState.PageCount, + pageSize = workflowState.PageSize, + itemCount = workflowState.ItemCount, + currentPageItemCount = workflowState.CurrentPageItemCount, + isLastPage = workflowState.IsLastPage, + nextKind = ToWireValue(workflowState.NextKind), + previewFields = workflowState.PreviewFields, + refreshActive = refreshStatus.IsActive, + refreshNextAt = refreshStatus.NextAtUtc, + refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc, + refreshFaulted = refreshStatus.IsFaulted, + refreshFaultCode = refreshStatus.FaultCode, + refreshFaultMessage = refreshStatus.FaultMessage }); } catch (OperationCanceledException) @@ -171,6 +274,27 @@ public sealed partial class MainWindow retryable: true, outcomeUnknown: false); } + catch (DatabaseOperationException exception) + { + PostPlayoutCommandError( + request.RequestId, + request.Command, + "DATABASE_UNAVAILABLE", + exception.Message, + retryable: true, + outcomeUnknown: false); + } + catch (Exception exception) when ( + exception is LegacySceneDataException or ArgumentException) + { + PostPlayoutCommandError( + request.RequestId, + request.Command, + "SCENE_DATA_INVALID", + exception.Message, + retryable: false, + outcomeUnknown: false); + } catch { PostPlayoutCommandError( @@ -192,23 +316,73 @@ public sealed partial class MainWindow } } + private async Task HandlePlayoutTimeoutQuarantineAsync(JsonElement payload) + { + var parsed = PlayoutBridgeProtocol.ParseTimeoutQuarantine(payload); + if (!parsed.IsValid) + { + PostMessage("bridge-error", new { message = parsed.Error }); + return; + } + + await QuarantineForBrowserCorrelationLossAsync(); + } + + private async Task QuarantineForBrowserCorrelationLossAsync() + { + + if (Interlocked.Exchange(ref _browserCorrelationQuarantined, 1) != 0) + { + QueuePlayoutStatus(); + return; + } + + // Latch before the first await so a reload or a second WebView message can + // never race ahead of native quarantine. The latch is process-lifetime only. + StopLegacyRefreshLoop(); + QueuePlayoutStatus(); + + var engine = _playoutEngine; + if (engine is not null) + { + try + { + await engine.QuarantineAsync(); + } + catch + { + // The native latch remains authoritative even if abandoning the vendor + // session cannot be confirmed. Retrying any command would be unsafe. + } + } + + QueuePlayoutStatus(); + } + + private bool IsBrowserCorrelationQuarantined() => + Volatile.Read(ref _browserCorrelationQuarantined) != 0; + private static Task ExecutePlayoutCommandAsync( - IPlayoutEngine engine, - PlayoutBridgeCommand request, + LegacyPlayoutWorkflow workflow, + PlayoutBridgeWorkflowCommand request, CancellationToken cancellationToken) => request.Command switch { - "prepare" => engine.PrepareAsync(request.Cue!, cancellationToken), - "take-in" => engine.TakeInAsync(cancellationToken), - "next" => engine.NextAsync(request.Cue!, cancellationToken), + "prepare" => workflow.PrepareAsync( + request.Playlist!, + request.SelectedIndexZeroBased, + cancellationToken), + "take-in" => workflow.TakeInAsync(cancellationToken), + "next" => workflow.NextAsync(cancellationToken), // Preserve the legacy MainForm TAKE OUT behavior (StopAll on the selected // scene player). Test/Live safety gates are enforced inside the engine. - "take-out" => engine.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken), + "take-out" => workflow.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken), _ => throw new InvalidOperationException("검증되지 않은 송출 명령입니다.") }; private void PostPlayoutStatus(string? requestId = null) { var status = _playoutEngine?.Status; + var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined(); if (status is null) { var changedAt = DateTimeOffset.UtcNow; @@ -218,7 +392,9 @@ public sealed partial class MainWindow { mode = "disabled", state = "IDLE", - connectionState = "unavailable", + connectionState = browserCorrelationQuarantined + ? "outcome-unknown" + : "unavailable", processDetected = false, connected = false, lastKtapConnectState = "not-attempted", @@ -229,11 +405,29 @@ public sealed partial class MainWindow networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, - outcomeUnknown = false, + outcomeUnknown = browserCorrelationQuarantined, + browserCorrelationQuarantined, operationTimeoutMilliseconds = 5000, message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.", preparedCode = (string?)null, onAirCode = (string?)null, + currentCueIndex = -1, + currentEntryId = (string?)null, + builderKey = (string?)null, + pageIndex = 0, + pageCount = 0, + pageSize = 0, + itemCount = 0, + currentPageItemCount = 0, + isLastPage = true, + nextKind = "none", + previewFields = Array.Empty(), + refreshActive = false, + refreshNextAt = (DateTimeOffset?)null, + refreshLastSuccessAt = (DateTimeOffset?)null, + refreshFaulted = false, + refreshFaultCode = (string?)null, + refreshFaultMessage = (string?)null, changedAt }); } @@ -244,7 +438,9 @@ public sealed partial class MainWindow requestId, mode = "disabled", state = "IDLE", - connectionState = "unavailable", + connectionState = browserCorrelationQuarantined + ? "outcome-unknown" + : "unavailable", processDetected = false, connected = false, lastKtapConnectState = "not-attempted", @@ -255,11 +451,29 @@ public sealed partial class MainWindow networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, - outcomeUnknown = false, + outcomeUnknown = browserCorrelationQuarantined, + browserCorrelationQuarantined, operationTimeoutMilliseconds = 5000, message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.", preparedCode = (string?)null, onAirCode = (string?)null, + currentCueIndex = -1, + currentEntryId = (string?)null, + builderKey = (string?)null, + pageIndex = 0, + pageCount = 0, + pageSize = 0, + itemCount = 0, + currentPageItemCount = 0, + isLastPage = true, + nextKind = "none", + previewFields = Array.Empty(), + refreshActive = false, + refreshNextAt = (DateTimeOffset?)null, + refreshLastSuccessAt = (DateTimeOffset?)null, + refreshFaulted = false, + refreshFaultCode = (string?)null, + refreshFaultMessage = (string?)null, changedAt }); } @@ -272,12 +486,16 @@ public sealed partial class MainWindow } else { + var workflowState = _playoutWorkflow?.State; + var refreshStatus = GetLegacyRefreshStatus(); PostMessage("playout-status", new { requestId, mode = ToWireValue(status.Mode), state = ToPlayoutPhase(status), - connectionState = ToWireValue(status.State), + connectionState = browserCorrelationQuarantined + ? "outcome-unknown" + : ToWireValue(status.State), processDetected = status.IsProcessRunning, connected = status.IsConnected, lastKtapConnectState = ToWireValue(status.LastKtapConnectState), @@ -286,39 +504,100 @@ public sealed partial class MainWindow ktapHelloObserved = status.KtapHelloObserved, networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected, networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, - commandAvailable = status.IsCommandAvailable, - liveTakeInAllowed = status.LiveTakeInAllowed, - outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown, + commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined, + liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined, + outcomeUnknown = browserCorrelationQuarantined || + status.State == PlayoutConnectionState.OutcomeUnknown, + browserCorrelationQuarantined, operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds, - message = status.Message, + message = browserCorrelationQuarantined + ? BrowserCorrelationQuarantineMessage + : status.Message, preparedCode = status.PreparedSceneName, onAirCode = status.OnAirSceneName, + currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1, + currentEntryId = workflowState?.CurrentEntryId, + builderKey = workflowState?.BuilderKey, + pageIndex = workflowState?.PageIndexZeroBased ?? 0, + pageCount = workflowState?.PageCount ?? 0, + pageSize = workflowState?.PageSize ?? 0, + itemCount = workflowState?.ItemCount ?? 0, + currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0, + isLastPage = workflowState?.IsLastPage ?? true, + nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None), + previewFields = workflowState?.PreviewFields ?? + Array.Empty(), + refreshActive = refreshStatus.IsActive, + refreshNextAt = refreshStatus.NextAtUtc, + refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc, + refreshFaulted = refreshStatus.IsFaulted, + refreshFaultCode = refreshStatus.FaultCode, + refreshFaultMessage = refreshStatus.FaultMessage, changedAt = status.ChangedAtUtc }); } } - private static object CreatePlayoutStatusPayload(PlayoutStatus status) => new + private object CreatePlayoutStatusPayload(PlayoutStatus status) { - mode = ToWireValue(status.Mode), - state = ToPlayoutPhase(status), - connectionState = ToWireValue(status.State), - processDetected = status.IsProcessRunning, - connected = status.IsConnected, - lastKtapConnectState = ToWireValue(status.LastKtapConnectState), - ktapConnectAttempted = status.KtapConnectAttempted, - ktapConnectAccepted = status.KtapConnectAccepted, - ktapHelloObserved = status.KtapHelloObserved, - networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected, - networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, - commandAvailable = status.IsCommandAvailable, - liveTakeInAllowed = status.LiveTakeInAllowed, - outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown, - operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds, - message = status.Message, - preparedCode = status.PreparedSceneName, - onAirCode = status.OnAirSceneName, - changedAt = status.ChangedAtUtc + var workflowState = _playoutWorkflow?.State; + var refreshStatus = GetLegacyRefreshStatus(); + var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined(); + return new + { + mode = ToWireValue(status.Mode), + state = ToPlayoutPhase(status), + connectionState = browserCorrelationQuarantined + ? "outcome-unknown" + : ToWireValue(status.State), + processDetected = status.IsProcessRunning, + connected = status.IsConnected, + lastKtapConnectState = ToWireValue(status.LastKtapConnectState), + ktapConnectAttempted = status.KtapConnectAttempted, + ktapConnectAccepted = status.KtapConnectAccepted, + ktapHelloObserved = status.KtapHelloObserved, + networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected, + networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, + commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined, + liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined, + outcomeUnknown = browserCorrelationQuarantined || + status.State == PlayoutConnectionState.OutcomeUnknown, + browserCorrelationQuarantined, + operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds, + message = browserCorrelationQuarantined + ? BrowserCorrelationQuarantineMessage + : status.Message, + preparedCode = status.PreparedSceneName, + onAirCode = status.OnAirSceneName, + currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1, + currentEntryId = workflowState?.CurrentEntryId, + builderKey = workflowState?.BuilderKey, + pageIndex = workflowState?.PageIndexZeroBased ?? 0, + pageCount = workflowState?.PageCount ?? 0, + pageSize = workflowState?.PageSize ?? 0, + itemCount = workflowState?.ItemCount ?? 0, + currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0, + isLastPage = workflowState?.IsLastPage ?? true, + nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None), + previewFields = workflowState?.PreviewFields ?? + Array.Empty(), + refreshActive = refreshStatus.IsActive, + refreshNextAt = refreshStatus.NextAtUtc, + refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc, + refreshFaulted = refreshStatus.IsFaulted, + refreshFaultCode = refreshStatus.FaultCode, + refreshFaultMessage = refreshStatus.FaultMessage, + changedAt = status.ChangedAtUtc + }; + } + + private static string ToWireValue(LegacyWorkflowNextKind value) => value switch + { + LegacyWorkflowNextKind.None => "none", + LegacyWorkflowNextKind.PageNext => "page-next", + LegacyWorkflowNextKind.PlaylistNext => "playlist-next", + LegacyWorkflowNextKind.EndOfPlaylist => "end-of-playlist", + _ => "none" }; private void PostPlayoutCommandError( @@ -424,6 +703,169 @@ public sealed partial class MainWindow PlayoutResultCode.Unavailable or PlayoutResultCode.Failed; + private void StartLegacyRefreshLoop(LegacyPlayoutWorkflow workflow) + { + StopLegacyRefreshLoop(); + var cutCode = workflow.State.OnAirCutCode; + if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var interval) || + interval <= TimeSpan.Zero || + Volatile.Read(ref _playoutShutdownStarted) != 0) + { + return; + } + + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + _legacyRefreshCancellation = cancellation; + SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus( + true, + DateTimeOffset.UtcNow.Add(interval), + GetLegacyRefreshStatus().LastSuccessAtUtc, + false, + null, + null)); + _legacyRefreshTask = RunLegacyRefreshLoopAsync( + workflow, + interval, + cancellation); + } + + private void StopLegacyRefreshLoop() + { + var cancellation = Interlocked.Exchange( + ref _legacyRefreshCancellation, + null); + if (cancellation is not null) + { + cancellation.Cancel(); + } + + var status = GetLegacyRefreshStatus(); + if (status.IsActive) + { + SetLegacyRefreshStatus(status with { IsActive = false, NextAtUtc = null }); + } + } + + private async Task RunLegacyRefreshLoopAsync( + LegacyPlayoutWorkflow workflow, + TimeSpan interval, + CancellationTokenSource cancellation) + { + var token = cancellation.Token; + var nextDelay = interval; + try + { + while (true) + { + await Task.Delay(nextDelay, token); + await _playoutCommandGate.WaitAsync(token); + try + { + if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) || + Volatile.Read(ref _playoutShutdownStarted) != 0) + { + return; + } + + Interlocked.Exchange(ref _playoutCommandInFlight, 1); + var result = await workflow.RefreshOnAirAsync(token); + // A failed, timed-out, or unknown update never repeats. The operator + // must reconcile native/PGM state before starting another command. + if (!result.IsSuccess) + { + SetLegacyRefreshFault( + ToWireValue(result.Code), + "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요."); + return; + } + + // MainForm changes timer1.Interval to 3000 after the first + // successful same-scene refresh. + nextDelay = LegacySceneRefreshPolicy.RecurringInterval; + SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus( + true, + DateTimeOffset.UtcNow.Add(nextDelay), + DateTimeOffset.UtcNow, + false, + null, + null)); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return; + } + catch (DatabaseOperationException) + { + SetLegacyRefreshFault( + "DATABASE_UNAVAILABLE", + "자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요."); + return; + } + catch (LegacySceneDataException) + { + SetLegacyRefreshFault( + "SCENE_DATA_INVALID", + "자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요."); + return; + } + catch + { + SetLegacyRefreshFault( + "REFRESH_FAILED", + "자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요."); + return; + } + finally + { + Interlocked.Exchange(ref _playoutCommandInFlight, 0); + _playoutCommandGate.Release(); + QueuePlayoutStatus(); + } + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Operator commands and shutdown cancel the legacy timer deterministically. + } + finally + { + Interlocked.CompareExchange( + ref _legacyRefreshCancellation, + null, + cancellation); + cancellation.Dispose(); + } + } + + private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus() + { + lock (_legacyRefreshStatusGate) + { + return _legacyRefreshStatus; + } + } + + private void SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus status) + { + lock (_legacyRefreshStatusGate) + { + _legacyRefreshStatus = status; + } + } + + private void SetLegacyRefreshFault(string code, string message) => + SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus( + false, + null, + GetLegacyRefreshStatus().LastSuccessAtUtc, + true, + code, + message)); + + private void ResetLegacyRefreshStatus() => + SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus.Empty); + private void ShutdownPlayoutRuntime() { if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0) @@ -431,7 +873,9 @@ public sealed partial class MainWindow return; } + StopLegacyRefreshLoop(); var engine = Interlocked.Exchange(ref _playoutEngine, null); + _playoutWorkflow = null; if (engine is null) { return; @@ -461,4 +905,21 @@ public sealed partial class MainWindow // Disposal is best-effort and bounded during window shutdown. } } + + private sealed record LegacyRefreshRuntimeStatus( + bool IsActive, + DateTimeOffset? NextAtUtc, + DateTimeOffset? LastSuccessAtUtc, + bool IsFaulted, + string? FaultCode, + string? FaultMessage) + { + public static LegacyRefreshRuntimeStatus Empty { get; } = new( + false, + null, + null, + false, + null, + null); + } } diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index af3744a..c330865 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -130,6 +130,7 @@ public sealed partial class MainWindow : Window if (PlayoutBridgeProtocol.IsTrustedSource(uri.AbsoluteUri, AppHost) || uri.Scheme == "about") { + QuarantineIfBrowserCorrelationCanBeLost(); LoadingOverlay.Visibility = Visibility.Visible; return; } @@ -192,6 +193,9 @@ public sealed partial class MainWindow : Window case "playout-command": _ = HandlePlayoutCommandAsync(request.Payload); break; + case "playout-timeout-quarantine": + _ = HandlePlayoutTimeoutQuarantineAsync(request.Payload); + break; case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleMarketDataRequestAsync(request.Payload); break; @@ -203,7 +207,7 @@ public sealed partial class MainWindow : Window } break; case "reload": - Browser.Reload(); + ReloadBrowserSafely(); break; } } @@ -477,6 +481,7 @@ public sealed partial class MainWindow : Window private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args) { + QuarantineIfBrowserCorrelationCanBeLost(); ShowError("WebView2 프로세스가 종료되었습니다.", args.ProcessFailedKind.ToString()); } @@ -508,7 +513,23 @@ public sealed partial class MainWindow : Window { if (_webViewReady) { - Browser.Reload(); + ReloadBrowserSafely(); + } + } + + private void ReloadBrowserSafely() + { + QuarantineIfBrowserCorrelationCanBeLost(); + Browser.Reload(); + } + + private void QuarantineIfBrowserCorrelationCanBeLost() + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + // Navigation or a renderer failure destroys the JavaScript request + // correlation. Latch native OutcomeUnknown before the document can go away. + _ = QuarantineForBrowserCorrelationLossAsync(); } } diff --git a/Package.appxmanifest b/Package.appxmanifest index a8c5247..882ed03 100644 --- a/Package.appxmanifest +++ b/Package.appxmanifest @@ -9,7 +9,7 @@ diff --git a/README.md b/README.md index 3d5f23f..aea62bb 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ dotnet build MBN_STOCK_WEBVIEW.csproj ` -p:AppxPackageSigningEnabled=false ``` -위 명령은 서명되지 않은 개발 패키지를 `AppPackages`에 만듭니다. 배포용 MSIX는 `Package.appxmanifest`의 `CN=Wickedness`와 정확히 일치하는 인증서로 서명해야 합니다. 인증서와 개인 키는 저장소에 커밋하지 않습니다. +위 명령은 서명되지 않은 개발 패키지를 `AppPackages`에 만듭니다. 배포용 MSIX는 `Package.appxmanifest`의 `Identity/Publisher` 값과 정확히 일치하는 인증서로 서명해야 합니다. 인증서와 개인 키는 저장소에 커밋하지 않습니다. ## 구성과 보안 diff --git a/Web/app.js b/Web/app.js index 1282fdb..da5e067 100644 --- a/Web/app.js +++ b/Web/app.js @@ -3,24 +3,111 @@ const playoutSafety = globalThis.MbnPlayoutSafety; if (!playoutSafety) throw new Error("Playout safety module is unavailable."); + const DEFAULT_FADE_DURATION = 6; const catalog = [ - { code: "5001", title: "1열판 · 기본", detail: "종목 현재가 / 등락", category: "plate", market: "kospi" }, - { code: "5006", title: "1열판 · 시가", detail: "시가 정보", category: "plate", market: "kosdaq" }, - { code: "5016", title: "3열판 · 주요 지수", detail: "국내외 지수 3열 구성", category: "plate", market: "index" }, - { code: "5023", title: "매매동향 · 지수별", detail: "코스피 / 코스닥", category: "market", market: "index" }, - { code: "5024", title: "매매동향 · 막대그래프", detail: "주체별 매매 동향", category: "chart", market: "kospi" }, - { code: "5026", title: "비교분석 · 캔들형", detail: "두 종목 비교", category: "chart", market: "compare" }, - { code: "5074", title: "5단표", detail: "상승률 / 하락률 / 거래량", category: "plate", market: "kosdaq" }, - { code: "5083", title: "선그래프 · 주체별", detail: "개인 / 외국인 / 기관", category: "chart", market: "kospi" }, - { code: "5086", title: "수익률 그래프", detail: "기간별 수익률", category: "chart", market: "compare" }, - { code: "6001", title: "해외증시", detail: "미국 / 유럽 / 아시아", category: "market", market: "overseas" }, - { code: "8035", title: "캔들 그래프 · 5분", detail: "지수 / 종목 5분봉", category: "chart", market: "index" }, - { code: "8086", title: "유가 · 금값", detail: "WTI / 브렌트 / 국제금", category: "market", market: "exchange" }, - { code: "THEME", title: "테마 종목", detail: "테마별 5·6·12 종목", category: "plate", market: "theme" }, - { code: "EXPERT", title: "전문가 추천", detail: "추천 종목 그래픽", category: "plate", market: "expert" }, - { code: "HALT", title: "거래 정지", detail: "정지 종목 검색", category: "market", market: "halted" } + { builderKey: "s5001", code: "5001", aliases: ["5001", "N5001"], title: "1열판 · 기본", detail: "국내·NXT·해외 현재가", category: "plate", market: "kospi" }, + { builderKey: "s5006", code: "5006", title: "1열판 · 시가 상세", detail: "현재·시가·고가·저가", category: "plate", market: "kosdaq" }, + { builderKey: "s5011", code: "5011", title: "1열판 · 상세", detail: "52주·시총·순위", category: "plate", market: "kospi" }, + { builderKey: "s5016", code: "5016", title: "3열판", detail: "주요 지수 3열 구성", category: "plate", market: "index" }, + { builderKey: "s50160", code: "50160", title: "2열판 · 보조", detail: "원면·금·국채 2열", category: "plate", market: "overseas" }, + { builderKey: "s5023", code: "5023", title: "매매동향 · 표", detail: "지수별 투자자 동향", category: "market", market: "index" }, + { builderKey: "s5024", code: "5024", title: "매매동향 · 막대", detail: "주체별 매매 동향", category: "chart", market: "kospi" }, + { builderKey: "s5025", code: "5025", title: "순매도 상위", detail: "수동 상위 종목 표", category: "market", market: "kospi" }, + { builderKey: "s5026", code: "5026", title: "비교분석 · 캔들", detail: "두 종목 주간 비교", category: "chart", market: "compare" }, + { builderKey: "s5029", code: "5029", title: "수익률 비교", detail: "두 종목 수익률", category: "chart", market: "compare" }, + { builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"], title: "2열판 · 선물", detail: "지수·선물 2열", category: "plate", market: "index" }, + { builderKey: "s5037", code: "5037", title: "거래원", detail: "매수·매도 거래원", category: "market", market: "kospi" }, + { builderKey: "s5074", code: "5074", title: "5단 표", detail: "5개 단위 · 최대 20페이지", category: "plate", market: "kosdaq" }, + { builderKey: "s5076", code: "5076", title: "주요매출 구성", detail: "매출 비중 원그래프", category: "chart", market: "kospi" }, + { builderKey: "s5077", code: "5077", title: "6종목 현재가", detail: "6개 단위 · 최대 20페이지", category: "plate", market: "kospi" }, + { builderKey: "s5078", code: "5078", title: "섹터 지수", detail: "미국·국내 섹터 막대", category: "chart", market: "overseas" }, + { builderKey: "s5079", code: "5079", title: "성장성 지표", detail: "복수 성장 지표 선그래프", category: "chart", market: "kospi" }, + { builderKey: "s5080", code: "5080", title: "매출액 지표", detail: "분기별 매출 막대", category: "chart", market: "kospi" }, + { builderKey: "s5081", code: "5081", title: "영업이익 지표", detail: "분기별 영업이익", category: "chart", market: "kospi" }, + { builderKey: "s5082", code: "5082", title: "주체별 매매동향", detail: "코스피·코스닥·200", category: "market", market: "index" }, + { builderKey: "s5083", code: "5083", title: "주체별 선그래프", detail: "개인·외국인·기관", category: "chart", market: "kospi" }, + { builderKey: "s5084", code: "5084", title: "매매동향 라인", detail: "코스피·코스닥", category: "chart", market: "index" }, + { builderKey: "s5085", code: "5085", title: "프로그램 매매", detail: "구분별 프로그램 금액", category: "market", market: "index" }, + { builderKey: "s5086", code: "5086", title: "수익률 그래프", detail: "지수·종목 기간 수익률", category: "chart", market: "compare" }, + { builderKey: "s50860", code: "50860", title: "라인 그래프", detail: "지수·종목 기간 추이", category: "chart", market: "compare" }, + { builderKey: "s5087", code: "5087", title: "비교분석 · 라인", detail: "두 종목 라인 비교", category: "chart", market: "compare" }, + { builderKey: "s5088", code: "5088", title: "12종목 현재가", detail: "12개 단위 · 최대 20페이지", category: "plate", market: "theme" }, + { builderKey: "s6001", code: "6001", title: "해외증시 이미지", detail: "해외 지수·원자재", category: "market", market: "overseas" }, + { builderKey: "s6067", code: "6067", title: "기관 순매수", detail: "기관 순매수 현황", category: "market", market: "kospi" }, + { builderKey: "s8001", code: "8001", aliases: ["8001", "8002"], title: "네모 그래프", detail: "업종별 등락 큐브", category: "chart", market: "index" }, + { builderKey: "s8003", code: "8003", title: "호가창", detail: "매수·매도 10단 호가", category: "market", market: "kospi" }, + { builderKey: "s8010", code: "8035", aliases: ["8035", "8061", "8040", "8046", "8051", "8056"], title: "캔들 그래프", detail: "8035·8061·8040·8046·8051·8056", category: "chart", market: "index" }, + { builderKey: "s8018", code: "8018", aliases: ["8018", "8032", "5032"], title: "2열판 · 시장", detail: "국내·NXT·해외 2열", category: "plate", market: "index" }, + { builderKey: "s8067", code: "8067", aliases: ["8067", "5068", "5070", "5072"], title: "글로벌 증시 지도", detail: "8067·5068·5070·5072", category: "market", market: "overseas" }, + { builderKey: "s8086", code: "8086", title: "유가·금값", detail: "원본 MainForm 미도달 장면", category: "market", market: "exchange", reachable: false } ]; + if (catalog.length !== 35) throw new Error("Legacy scene catalog must contain exactly 35 builders."); + const sceneAliases = item => Array.isArray(item?.aliases) ? item.aliases : [String(item?.code || "")]; + const reachableAliases = new Set(catalog + .filter(item => item.reachable !== false) + .flatMap(sceneAliases)); + if (reachableAliases.size !== 45) throw new Error("Legacy scene catalog must expose all 45 active aliases."); + + const sceneSelectionDefaults = Object.freeze({ + s5001: ["KOSPI", "삼성전자", "", "CURRENT", ""], + s5006: ["KOSPI_STOCK", "삼성전자", "", "CURRENT", "005930"], + s5011: ["KOSPI_STOCK", "삼성전자", "", "Valuation", "005930"], + s5016: ["ALL", "INDEX", "", "UnitedStatesIndices", ""], + s50160: ["ALL", "INDEX", "", "Cotton", ""], + s5023: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", ""], + s5024: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", ""], + s5025: ["INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", ""], + s5026: ["STOCK", "삼성전자,SK하이닉스", "", "", ""], + s5029: ["STOCK", "삼성전자,SK하이닉스", "", "5일", ""], + s5032: ["INDEX", "FUTURES,Kospi", "", "CURRENT", ""], + s5037: ["KOSPI", "삼성전자", "TRADER", "TABLE_GRAPH", ""], + s5074: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"], + s5076: ["KOSPI", "삼성전자", "", "", ""], + s5077: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"], + s5078: ["FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", "", ""], + s5079: ["KOSPI", "삼성전자", "GROWTH_METRICS", "", ""], + s5080: ["KOSPI", "삼성전자", "SALES", "", ""], + s5081: ["KOSPI", "삼성전자", "", "", ""], + s5082: ["ALL", "INDEX", "", "", ""], + s5083: ["ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", ""], + s5084: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", ""], + s5085: ["ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", ""], + s5086: ["EXCHANGE_RATE", "원달러", "", "20일", ""], + s50860: ["EXCHANGE_RATE", "원달러", "", "20일", ""], + s5087: ["STOCK", "삼성전자,SK하이닉스", "", "", ""], + s5088: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"], + s6001: ["FOREIGN_INDEX", "DOW", "", "DowJones", ""], + s6067: ["ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", ""], + s8001: ["ALL", "KOSPI", "", "", ""], + s8003: ["KOSPI", "삼성전자", "ORDER_BOOK", "TABLE_GRAPH", "005930"], + s8010: ["KOSPI_INDEX", "INDEX", "", "PRICE", ""], + s8018: ["INDEX", "Kospi,Kosdaq", "", "CURRENT", ""], + s8067: ["ALL", "INDEX", "", "", ""] + }); + if (Object.keys(sceneSelectionDefaults).length !== 34) { + throw new Error("Every reachable legacy builder must have a Web selection preset."); + } + + function localDateText() { + const 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 defaultSceneSelection(builderKey) { + const values = sceneSelectionDefaults[builderKey]; + if (!values) return undefined; + return { + groupCode: values[0], + subject: values[1], + graphicType: values[2], + subtype: values[3], + dataCode: values[4] === "$TODAY" ? localDateText() : values[4] + }; + } const marketTitles = { dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽", @@ -54,12 +141,31 @@ commandAvailable: false, liveTakeInAllowed: false, nativeOutcomeUnknown: false, + browserCorrelationQuarantined: false, + sessionQuarantined: false, operationTimeoutMs: 5000, responseTimeoutMs: 10000, message: "송출 어댑터 상태를 확인하고 있습니다.", preparedCode: null, onAirCode: null, preparedCue: null, + currentCueIndex: -1, + currentEntryId: null, + builderKey: null, + pageIndex: 0, + pageCount: 0, + pageSize: 0, + itemCount: 0, + currentPageItemCount: 0, + isLastPage: true, + nextKind: "none", + previewFields: [], + refreshActive: false, + refreshNextAt: null, + refreshLastSuccessAt: null, + refreshFaulted: false, + refreshFaultCode: null, + refreshFaultMessage: null, changedAt: null, changedAtMs: 0, latestStatusRequestId: null, @@ -95,6 +201,11 @@ databaseSources: document.querySelector("#databaseSources"), playlistBody: document.querySelector("#playlistBody"), playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"), + selectAll: document.querySelector("#selectAllButton"), + moveUpButton: document.querySelector("#moveUpButton"), + moveDownButton: document.querySelector("#moveDownButton"), + deleteButton: document.querySelector("#deleteButton"), + loadButton: document.querySelector("#loadButton"), previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"), playoutState: document.querySelector("#playoutState"), playoutSummary: document.querySelector("#playoutSummary"), @@ -107,8 +218,28 @@ playoutConnectionState: document.querySelector("#playoutConnectionState"), playoutSafetyMode: document.querySelector("#playoutSafetyMode"), playoutStatusMessage: document.querySelector("#playoutStatusMessage"), + playoutSceneCode: document.querySelector("#playoutSceneCode"), + playoutEntryId: document.querySelector("#playoutEntryId"), + playoutBuilderKey: document.querySelector("#playoutBuilderKey"), + playoutPageNumber: document.querySelector("#playoutPageNumber"), + playoutPageSize: document.querySelector("#playoutPageSize"), + playoutItemCount: document.querySelector("#playoutItemCount"), + playoutCurrentRows: document.querySelector("#playoutCurrentRows"), + playoutLastPage: document.querySelector("#playoutLastPage"), + playoutNextKind: document.querySelector("#playoutNextKind"), + playoutRefreshState: document.querySelector("#playoutRefreshState"), + sceneDataPreview: document.querySelector("#sceneDataPreview"), playoutError: document.querySelector("#playoutError"), playoutErrorMessage: document.querySelector("#playoutErrorMessage"), + sceneSelectionForm: document.querySelector("#sceneSelectionForm"), + sceneGroupCode: document.querySelector("#sceneGroupCode"), + sceneSubject: document.querySelector("#sceneSubject"), + sceneGraphicType: document.querySelector("#sceneGraphicType"), + sceneSubtype: document.querySelector("#sceneSubtype"), + sceneDataCode: document.querySelector("#sceneDataCode"), + sceneCutAlias: document.querySelector("#sceneCutAlias"), + sceneFadeDuration: document.querySelector("#sceneFadeDuration"), + applySceneSelectionButton: document.querySelector("#applySceneSelectionButton"), prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"), nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"), eventLog: document.querySelector("#eventLog"), @@ -121,6 +252,24 @@ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } + function isPlaylistSnapshotLocked() { + return playoutSafety.playlistSnapshotLocked({ + pending: Boolean(state.playout.pending), + outcomeUnknown: state.playout.sessionQuarantined || + state.playout.nativeOutcomeUnknown || + state.playout.error?.outcomeUnknown === true, + engineState: state.playout.engineState, + preparedCode: state.playout.preparedCode, + onAirCode: state.playout.onAirCode + }); + } + + function requirePlaylistEditable() { + if (!isPlaylistSnapshotLocked()) return true; + showToast("PREPARE 이후에는 TAKE OUT까지 플레이리스트가 잠깁니다."); + return false; + } + function updateClock() { elements.clock.textContent = new Intl.DateTimeFormat("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false @@ -167,7 +316,8 @@ const add = document.createElement("button"); add.className = "add-cut"; add.type = "button"; - add.title = "플레이리스트에 추가"; + add.disabled = item.reachable === false || isPlaylistSnapshotLocked(); + add.title = item.reachable === false ? "원본 MainForm에서 도달하지 않는 장면" : "플레이리스트에 추가"; add.textContent = "+"; add.addEventListener("click", () => addToPlaylist(item)); @@ -177,7 +327,17 @@ } function addToPlaylist(item) { - const entry = { ...item, id: createId() }; + if (item.reachable === false || !requirePlaylistEditable()) return; + const selection = defaultSceneSelection(item.builderKey); + const entry = { + ...item, + id: createId(), + selection, + enabled: true, + fadeDuration: DEFAULT_FADE_DURATION, + graphicType: selection?.graphicType || "", + subtype: selection?.subtype || "" + }; state.playlist.push(entry); state.currentIndex = state.playlist.length - 1; renderPlaylist(); @@ -193,7 +353,7 @@ state.playlist.forEach((item, index) => { const row = document.createElement("tr"); - row.draggable = true; + row.draggable = !isPlaylistSnapshotLocked(); row.dataset.index = String(index); row.classList.toggle("active", index === state.currentIndex); @@ -202,6 +362,7 @@ const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = state.selectedRows.has(item.id); + checkbox.disabled = isPlaylistSnapshotLocked(); checkbox.setAttribute("aria-label", `${item.title} 선택`); checkbox.addEventListener("click", event => event.stopPropagation()); checkbox.addEventListener("change", () => { @@ -224,7 +385,25 @@ const type = document.createElement("span"); type.className = "row-type"; type.textContent = item.category.toUpperCase(); - typeCell.append(type); + 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); const drag = document.createElement("td"); drag.className = "drag-cell"; @@ -242,6 +421,7 @@ } function selectPlaylistRow(index) { + if (isPlaylistSnapshotLocked()) return; state.currentIndex = index; renderPlaylist(); updatePreview(); @@ -252,15 +432,107 @@ if (!item) { elements.previewTitle.textContent = "선택된 그래픽 없음"; elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요."; + renderSceneSelectionForm(null); renderPlayout(); return; } elements.previewTitle.textContent = item.title; - elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`; + const selection = item.selection?.subject + ? ` · ${item.selection.subject}${item.selection.dataCode ? ` (${item.selection.dataCode})` : ""}` + : ""; + elements.previewSubtitle.textContent = `${item.code} · ${item.detail}${selection}`; + renderSceneSelectionForm(item); renderPlayout(); } + function renderSceneSelectionForm(item) { + const selection = item?.selection || {}; + const disabled = !item || isPlaylistSnapshotLocked(); + elements.sceneGroupCode.value = String(selection.groupCode || ""); + elements.sceneSubject.value = String(selection.subject || ""); + elements.sceneGraphicType.value = String(selection.graphicType || ""); + elements.sceneSubtype.value = String(selection.subtype || ""); + elements.sceneDataCode.value = String(selection.dataCode || ""); + elements.sceneCutAlias.replaceChildren(); + for (const alias of item ? sceneAliases(item) : []) { + const option = document.createElement("option"); + option.value = alias; + option.textContent = alias; + elements.sceneCutAlias.append(option); + } + elements.sceneCutAlias.value = String(item?.code || ""); + elements.sceneFadeDuration.value = String(Number.isInteger(Number(item?.fadeDuration)) + ? Number(item.fadeDuration) + : DEFAULT_FADE_DURATION); + for (const input of [ + elements.sceneGroupCode, + elements.sceneSubject, + elements.sceneGraphicType, + elements.sceneSubtype, + elements.sceneDataCode, + elements.sceneCutAlias, + elements.sceneFadeDuration + ]) input.disabled = disabled; + elements.applySceneSelectionButton.disabled = disabled; + } + + function applySceneSelection(event) { + event.preventDefault(); + if (!requirePlaylistEditable()) return; + const item = state.playlist[state.currentIndex]; + if (!item) return; + const values = [ + elements.sceneGroupCode.value, + elements.sceneSubject.value, + elements.sceneGraphicType.value, + elements.sceneSubtype.value, + elements.sceneDataCode.value + ]; + if (values.some(value => value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))) { + showToast("장면 조회값 형식이 올바르지 않습니다."); + return; + } + const fadeDuration = Number(elements.sceneFadeDuration.value); + if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) { + showToast("FADE 값은 0~60 사이의 정수여야 합니다."); + return; + } + const cutAlias = String(elements.sceneCutAlias.value || ""); + if (!sceneAliases(item).includes(cutAlias)) { + showToast("허용되지 않은 CUT alias입니다."); + return; + } + item.selection = { + groupCode: values[0].trim(), + subject: values[1].trim(), + graphicType: values[2].trim(), + subtype: values[3].trim(), + dataCode: values[4].trim() + }; + item.code = cutAlias; + item.fadeDuration = fadeDuration; + renderPlaylist(); + updatePreview(); + addLog(`${item.code} 장면 조회값 적용`); + showToast("장면 조회값을 적용했습니다."); + } + + function applyCutAliasPreset() { + const item = state.playlist[state.currentIndex]; + if (!item) return; + const alias = String(elements.sceneCutAlias.value || ""); + if (item.builderKey === "s5001") { + elements.sceneGroupCode.value = alias === "N5001" ? "NXT_KOSPI" : "KOSPI"; + } else if (item.builderKey === "s8001") { + elements.sceneSubject.value = alias === "8002" ? "KOSDAQ" : "KOSPI"; + } + } + function onDragStart(event) { + if (!requirePlaylistEditable()) { + event.preventDefault(); + return; + } state.dragIndex = Number(event.currentTarget.dataset.index); event.currentTarget.classList.add("dragging"); event.dataTransfer.effectAllowed = "move"; @@ -268,6 +540,7 @@ } function onDragOver(event) { + if (isPlaylistSnapshotLocked()) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; event.currentTarget.classList.add("drag-over"); @@ -275,6 +548,7 @@ function onDrop(event) { event.preventDefault(); + if (!requirePlaylistEditable()) return; const targetIndex = Number(event.currentTarget.dataset.index); if (state.dragIndex < 0 || state.dragIndex === targetIndex) return; const [moved] = state.playlist.splice(state.dragIndex, 1); @@ -292,6 +566,7 @@ } function toggleSelectAll() { + if (!requirePlaylistEditable()) return; 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)); @@ -299,6 +574,7 @@ } 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)); @@ -310,6 +586,7 @@ } function deleteSelected() { + if (!requirePlaylistEditable()) return; if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요."); const deleted = state.selectedRows.size; state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id)); @@ -326,10 +603,56 @@ showToast("플레이리스트를 저장했습니다."); } + function normalizeStoredSelection(value) { + if (!value || typeof value !== "object") return undefined; + const result = {}; + for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) { + const text = typeof value[key] === "string" ? value[key] : ""; + if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return undefined; + result[key] = text; + } + return result; + } + + function normalizeStoredPlaylist(value) { + if (!Array.isArray(value)) return []; + return value.slice(0, 1000).flatMap(item => { + if (!item || typeof item !== "object") return []; + const storedCode = String(item.code || ""); + const definitionIndex = playoutSafety.resolveStoredCatalogIndex( + catalog, + item.builderKey, + storedCode); + const definition = definitionIndex >= 0 ? catalog[definitionIndex] : null; + if (!definition) 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 fadeDuration = Number(item.fadeDuration); + return [{ + ...definition, + id, + code: sceneAliases(definition).includes(storedCode) ? storedCode : definition.code, + enabled: item.enabled !== false, + fadeDuration: Number.isInteger(fadeDuration) && fadeDuration >= 0 && fadeDuration <= 60 + ? fadeDuration + : DEFAULT_FADE_DURATION, + ...(selection ? { + selection, + graphicType: selection.graphicType, + subtype: selection.subtype + } : {}) + }]; + }); + } + function loadPlaylist() { + if (!requirePlaylistEditable()) return; try { const stored = JSON.parse(localStorage.getItem(storageKey) || "[]"); - state.playlist = Array.isArray(stored) ? stored : []; + state.playlist = normalizeStoredPlaylist(stored); state.selectedRows.clear(); state.currentIndex = state.playlist.length ? 0 : -1; renderPlaylist(); @@ -353,6 +676,73 @@ return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-"); } + function normalizeScenePreviewFields(value) { + if (!Array.isArray(value) || value.length > 4096) return []; + return value.flatMap(field => { + if (!field || typeof field !== "object") return []; + const kind = typeof field.kind === "string" ? field.kind : ""; + const name = typeof field.name === "string" ? field.name : ""; + const text = typeof field.value === "string" ? field.value : ""; + if (!/^[a-z-]{1,32}$/.test(kind) || !name || name.length > 128 || + text.length > 16384 || /[\u0000\r\n]/.test(name + text)) return []; + return [{ kind, name, value: text }]; + }); + } + + function applyRefreshState(payload) { + state.playout.refreshActive = payload.refreshActive === true; + state.playout.refreshNextAt = payload.refreshNextAt || null; + state.playout.refreshLastSuccessAt = payload.refreshLastSuccessAt || null; + state.playout.refreshFaulted = payload.refreshFaulted === true; + state.playout.refreshFaultCode = typeof payload.refreshFaultCode === "string" + ? payload.refreshFaultCode + : null; + state.playout.refreshFaultMessage = typeof payload.refreshFaultMessage === "string" + ? payload.refreshFaultMessage + : null; + if (state.playout.refreshFaulted && state.playout.error?.outcomeUnknown !== true) { + state.playout.error = { + code: state.playout.refreshFaultCode || "REFRESH_FAULT", + message: state.playout.refreshFaultMessage || + "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.", + retryable: false, + outcomeUnknown: false, + nonClearable: true, + isRefreshFault: true + }; + } else if (playoutSafety.shouldClearRefreshError( + state.playout.error, + state.playout.refreshFaulted)) { + state.playout.error = null; + } + } + + function renderSceneDataPreview() { + elements.sceneDataPreview.replaceChildren(); + if (state.playout.previewFields.length === 0) { + const empty = document.createElement("div"); + empty.className = "scene-data-empty"; + empty.textContent = "PREPARE 후 실제 DTO → mutation 값을 표시합니다."; + elements.sceneDataPreview.append(empty); + return; + } + + const fragment = document.createDocumentFragment(); + for (const field of state.playout.previewFields) { + const row = document.createElement("div"); + row.className = "scene-data-row"; + const kind = document.createElement("span"); + kind.textContent = field.kind; + const name = document.createElement("strong"); + name.textContent = field.name; + const value = document.createElement("code"); + value.textContent = field.value; + row.append(kind, name, value); + fragment.append(row); + } + elements.sceneDataPreview.append(fragment); + } + function isDryRunMode(mode = state.playout.mode) { return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode)); } @@ -381,6 +771,33 @@ return { code: String(item.code), title: String(item.title), detail: String(item.detail) }; } + function workflowEntryFromItem(item) { + const selection = item?.selection && typeof item.selection === "object" + ? item.selection + : { + groupCode: String(item?.groupCode || item?.market || ""), + subject: String(item?.subject || item?.title || ""), + graphicType: String(item?.graphicType || item?.detail || ""), + subtype: String(item?.subtype || ""), + dataCode: String(item?.dataCode || "") + }; + return { + id: String(item.id), + code: String(item.code), + enabled: item.enabled !== false, + fadeDuration: Number.isInteger(Number(item.fadeDuration)) + ? Number(item.fadeDuration) + : DEFAULT_FADE_DURATION, + selection: { + groupCode: String(selection.groupCode || ""), + subject: String(selection.subject || ""), + graphicType: String(selection.graphicType || ""), + subtype: String(selection.subtype || ""), + dataCode: String(selection.dataCode || "") + } + }; + } + function findCueByCode(code) { if (!code) return null; const current = state.playlist[state.currentIndex]; @@ -395,8 +812,11 @@ const liveMode = isLiveMode(); const testMode = isTestMode(); const bridgeReady = Boolean(nativeBridge); - const outcomeUnknown = state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true; + const outcomeUnknown = state.playout.nativeOutcomeUnknown || + state.playout.sessionQuarantined || + state.playout.error?.outcomeUnknown === true; const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable; + const mutationCommandReady = commandReady && !state.playout.refreshFaulted; 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); @@ -465,17 +885,46 @@ const changedLabel = state.playout.changedAtMs ? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}` : ""; + const pageLabel = state.playout.pageCount > 0 + ? ` · 페이지 ${state.playout.pageIndex + 1}/${state.playout.pageCount}` + : ""; elements.playoutStatusMessage.textContent = pending ? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}` - : `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`; + : `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${pageLabel}${changedLabel}`; elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending)); - elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown); + elements.playoutStatusMessage.classList.toggle( + "error", + Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown || state.playout.refreshFaulted); + elements.playoutSceneCode.textContent = state.playout.onAirCode || state.playout.preparedCode || "—"; + elements.playoutEntryId.textContent = state.playout.currentEntryId || "—"; + elements.playoutBuilderKey.textContent = state.playout.builderKey || "—"; + elements.playoutPageNumber.textContent = state.playout.pageCount > 0 + ? `${state.playout.pageIndex + 1}/${state.playout.pageCount}` + : "—"; + elements.playoutPageSize.textContent = state.playout.pageSize > 0 + ? String(state.playout.pageSize) + : "—"; + elements.playoutItemCount.textContent = String(state.playout.itemCount); + elements.playoutCurrentRows.textContent = String(state.playout.currentPageItemCount); + elements.playoutLastPage.textContent = state.playout.isLastPage ? "YES" : "NO"; + elements.playoutNextKind.textContent = state.playout.nextKind.replaceAll("-", " ").toUpperCase(); + const refreshNextMs = parseChangedAt(state.playout.refreshNextAt); + elements.playoutRefreshState.textContent = state.playout.refreshFaulted + ? "FAULT" + : (state.playout.refreshActive + ? `ACTIVE · ${refreshNextMs ? new Date(refreshNextMs).toLocaleTimeString("ko-KR", { hour12: false }) : "WAIT"}` + : "STOPPED"); + renderSceneDataPreview(); elements.playoutSummary.classList.remove("ready", "dry-run", "error"); - if (state.playout.nativeOutcomeUnknown) { + if (outcomeUnknown) { elements.playoutSummary.classList.add("error"); elements.playoutSummaryBadge.textContent = "UNKNOWN"; elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요"; + } else if (state.playout.refreshFaulted) { + elements.playoutSummary.classList.add("error"); + elements.playoutSummaryBadge.textContent = "REFRESH FAULT"; + elements.playoutSummaryDetail.textContent = "자동 갱신 중단 · TAKE OUT 후 확인 필요"; } else if (dryRun) { elements.playoutSummary.classList.add("dry-run"); elements.playoutSummaryBadge.textContent = "DRY RUN"; @@ -496,22 +945,36 @@ elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음"; } - elements.prepareButton.disabled = !commandReady || !currentCue; - elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe; - elements.nextButton.disabled = !commandReady || !onAir || state.playlist.length === 0; + elements.prepareButton.disabled = !mutationCommandReady || !currentCue; + elements.takeInButton.disabled = !mutationCommandReady || !prepared || !takeInSafe; + 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(); + elements.selectAll.disabled = playlistLocked; + elements.moveUpButton.disabled = playlistLocked; + elements.moveDownButton.disabled = playlistLocked; + elements.deleteButton.disabled = playlistLocked; + elements.loadButton.disabled = playlistLocked; + renderCatalog(); for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) { button.setAttribute("aria-busy", String(Boolean(pending))); } elements.playoutError.hidden = !state.playout.error; elements.playoutErrorMessage.textContent = state.playout.error?.message || ""; - document.querySelector("#clearPlayoutErrorButton").textContent = state.playout.nativeOutcomeUnknown + document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "닫기 · 잠금 유지" - : (outcomeUnknown ? "확인 후 잠금 해제" : "닫기"); + : "닫기"; } function clearPlayoutError(render = true) { + if (!playoutSafety.canClearCommandError( + state.playout.error, + state.playout.sessionQuarantined || state.playout.nativeOutcomeUnknown)) { + if (render) showToast("결과 미확인 잠금은 앱을 다시 시작하기 전까지 해제할 수 없습니다."); + return; + } state.playout.error = null; if (render) renderPlayout(); } @@ -522,6 +985,7 @@ if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요."); else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다."); state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown }; + if (outcomeUnknown || code === "WEB_TIMEOUT") state.playout.sessionQuarantined = true; renderPlayout(); showToast(state.playout.error.message); } @@ -536,9 +1000,8 @@ return cue ? { cue, targetIndex: state.currentIndex } : null; } if (command === "next") { - if (!state.playlist.length) return null; - const targetIndex = state.currentIndex < 0 ? 0 : (state.currentIndex + 1) % state.playlist.length; - return { cue: cueFromItem(state.playlist[targetIndex]), targetIndex }; + const cue = findCueByCode(state.playout.onAirCode) || current; + return cue ? { cue: cueFromItem(cue), targetIndex: state.currentIndex } : null; } if (command === "take-out") return { cue: null, targetIndex: state.currentIndex }; return null; @@ -552,8 +1015,11 @@ if (state.playout.pending) return; const blockReason = playoutSafety.commandBlockReason({ pending: state.playout.pending, - outcomeUnknown: state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true, + outcomeUnknown: state.playout.nativeOutcomeUnknown || + state.playout.sessionQuarantined || + state.playout.error?.outcomeUnknown === true, commandAvailable: state.playout.commandAvailable, + refreshFaulted: state.playout.refreshFaulted, engineState: state.playout.engineState, preparedCode: state.playout.preparedCode, onAirCode: state.playout.onAirCode @@ -562,6 +1028,7 @@ const message = { "outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.", "unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.", + "refresh-fault": "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.", "next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.", "take-in-requires-prepare": "먼저 PREPARE를 완료하세요.", "nothing-to-take-out": "종료할 송출 장면이 없습니다." @@ -591,14 +1058,16 @@ addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`); const payload = { requestId, command }; - if (resolved.cue && (command === "prepare" || command === "next")) { - payload.cue = { code: resolved.cue.code }; + if (command === "prepare") { + payload.playlist = state.playlist.map(workflowEntryFromItem); + payload.selectedIndex = state.currentIndex; } postNative("playout-command", payload); pending.timeoutId = setTimeout(() => { if (state.playout.pending?.requestId !== requestId) return; state.playout.pending = null; + postNative("playout-timeout-quarantine", { requestId, command }); showPlayoutError({ code: "WEB_TIMEOUT", message: "송출 엔진 응답 시간이 초과되었습니다.", @@ -625,6 +1094,8 @@ function handlePlayoutStatus(payload) { if (!payload || typeof payload !== "object") return; if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return; + const previousCueIndex = state.currentIndex; + const previousSnapshotLocked = isPlaylistSnapshotLocked(); const changedAtMs = parseChangedAt(payload.changedAt); if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return; @@ -649,7 +1120,10 @@ state.playout.networkMonitoringCheckRequired = payload.networkMonitoringCheckRequired === true; state.playout.commandAvailable = payload.commandAvailable === true; state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true; + state.playout.browserCorrelationQuarantined = + payload.browserCorrelationQuarantined === true; state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true || + state.playout.browserCorrelationQuarantined || state.playout.connectionState === "outcome-unknown"; state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds)) ? Number(payload.operationTimeoutMilliseconds) @@ -661,6 +1135,30 @@ state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null; state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null; state.playout.preparedCue = findCueByCode(state.playout.preparedCode); + state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex) ? payload.currentCueIndex : -1; + state.playout.currentEntryId = typeof payload.currentEntryId === "string" + ? payload.currentEntryId + : null; + state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || "")) + ? String(payload.builderKey) + : null; + state.playout.pageIndex = Number.isInteger(payload.pageIndex) && payload.pageIndex >= 0 ? payload.pageIndex : 0; + state.playout.pageCount = Number.isInteger(payload.pageCount) && payload.pageCount >= 0 ? payload.pageCount : 0; + state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : 0; + state.playout.itemCount = Number.isInteger(payload.itemCount) && payload.itemCount >= 0 ? payload.itemCount : 0; + state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount) && + payload.currentPageItemCount >= 0 ? payload.currentPageItemCount : 0; + state.playout.isLastPage = payload.isLastPage !== false; + state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"] + .includes(payload.nextKind) ? payload.nextKind : "none"; + state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields); + applyRefreshState(payload); + const authoritativeIndex = state.playout.currentEntryId + ? state.playlist.findIndex(item => item.id === state.playout.currentEntryId) + : state.playout.currentCueIndex; + if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) { + state.currentIndex = authoritativeIndex; + } state.playout.changedAt = payload.changedAt || null; state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs; if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null; @@ -674,7 +1172,13 @@ }; } - renderPlayout(); + if (state.currentIndex !== previousCueIndex || + isPlaylistSnapshotLocked() !== previousSnapshotLocked) { + renderPlaylist(); + updatePreview(); + } else { + renderPlayout(); + } const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.lastKtapConnectState, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].join("|"); if (signature !== state.playout.lastSignature) { addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`); @@ -708,17 +1212,41 @@ : `${pending.command.toUpperCase()} 완료`; state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null; state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null; + state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex) + ? payload.currentCueIndex + : state.playout.currentCueIndex; + state.playout.currentEntryId = typeof payload.currentEntryId === "string" + ? payload.currentEntryId + : state.playout.currentEntryId; + state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || "")) + ? String(payload.builderKey) + : state.playout.builderKey; + state.playout.pageIndex = Number.isInteger(payload.pageIndex) ? payload.pageIndex : state.playout.pageIndex; + state.playout.pageCount = Number.isInteger(payload.pageCount) ? payload.pageCount : state.playout.pageCount; + state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : state.playout.pageSize; + state.playout.itemCount = Number.isInteger(payload.itemCount) ? payload.itemCount : state.playout.itemCount; + state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount) + ? payload.currentPageItemCount + : state.playout.currentPageItemCount; + state.playout.isLastPage = payload.isLastPage !== false; + state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"] + .includes(payload.nextKind) ? payload.nextKind : state.playout.nextKind; + state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields); + applyRefreshState(payload); - if (pending.command === "prepare" || pending.command === "next") { + if (pending.command === "prepare") { state.playout.preparedCue = pending.cue; } else if (!state.playout.preparedCode) { state.playout.preparedCue = null; } - if (pending.command === "next") { - state.currentIndex = pending.targetIndex; - renderPlaylist(); - updatePreview(); + const authoritativeIndex = state.playout.currentEntryId + ? state.playlist.findIndex(item => item.id === state.playout.currentEntryId) + : state.playout.currentCueIndex; + if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) { + state.currentIndex = authoritativeIndex; } + renderPlaylist(); + updatePreview(); renderPlayout(); const safetyLabel = payload.dryRun === true ? "DRY RUN · " : ""; @@ -933,6 +1461,78 @@ return String(value); } + function liveRowValues(row, columns) { + const values = new Map(); + columns.forEach((column, index) => { + values.set(String(column.key).toLocaleUpperCase("en-US"), cellValue(row, column, index)); + }); + return values; + } + + function firstLiveValue(values, keys) { + for (const key of keys) { + const value = values.get(key); + if (value !== null && value !== undefined && String(value).trim()) return String(value).trim(); + } + return ""; + } + + function liveSelectionGroup(table, item) { + const name = String(table?.name || "").toLocaleUpperCase("ko-KR"); + let group; + if (name.includes("NXT") && state.activeMarket === "kospi") group = "NXT_KOSPI"; + else if (name.includes("NXT") && state.activeMarket === "kosdaq") group = "NXT_KOSDAQ"; + else if (state.activeMarket === "kospi") group = "KOSPI_STOCK"; + else if (state.activeMarket === "kosdaq") group = "KOSDAQ_STOCK"; + else if (state.activeMarket === "overseas") group = name.includes("종목") ? "FOREIGN_STOCK" : "FOREIGN_INDEX"; + else if (name.includes("코스닥")) group = "KOSDAQ_INDEX"; + else if (name.includes("200")) group = "KOSPI200_INDEX"; + else if (name.includes("KRX100")) group = "KRX100_INDEX"; + else group = "KOSPI_INDEX"; + + if (["s5037", "s5079", "s5080", "s8003"].includes(item?.builderKey)) { + if (group === "KOSPI_STOCK") return "KOSPI"; + if (group === "KOSDAQ_STOCK") return "KOSDAQ"; + } + return group; + } + + function applyLiveRowToCurrentCut(table, row, columns) { + const item = state.playlist[state.currentIndex]; + if (!item) { + showToast("먼저 적용할 플레이리스트 장면을 선택하세요."); + return; + } + + const values = liveRowValues(row, columns); + const subject = firstLiveValue(values, [ + "F_STOCK_WANNAME", "F_STOCK_NAME", "STOCK_NAME", "F_KNAM", + "F_INPUT_NAME", "NAME", "WANNAME" + ]); + const dataCode = firstLiveValue(values, [ + "F_STOCK_CODE", "STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE" + ]); + if (!subject && !dataCode) { + showToast("이 행에는 장면 조회에 사용할 종목명이나 코드가 없습니다."); + return; + } + + const previousSelection = item.selection || {}; + item.selection = { + groupCode: liveSelectionGroup(table, item), + subject: subject || dataCode, + graphicType: String(previousSelection.graphicType || item.graphicType || ""), + subtype: String(previousSelection.subtype || item.subtype || ""), + dataCode + }; + item.subject = item.selection.subject; + item.dataCode = dataCode; + renderPlaylist(); + updatePreview(); + addLog(`${item.code} DB 선택 적용 · ${item.selection.subject}`); + showToast("선택한 DB 행을 현재 장면에 적용했습니다."); + } + function renderLiveTables() { elements.liveDataTables.replaceChildren(); for (const table of state.liveData.tables) { @@ -982,6 +1582,9 @@ 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)); columns.forEach((column, index) => { const td = document.createElement("td"); td.textContent = formatCellValue(cellValue(row, column, index)); @@ -1171,6 +1774,8 @@ document.querySelector("#deleteButton").addEventListener("click", deleteSelected); document.querySelector("#saveButton").addEventListener("click", savePlaylist); document.querySelector("#loadButton").addEventListener("click", loadPlaylist); + elements.sceneSelectionForm.addEventListener("submit", applySceneSelection); + elements.sceneCutAlias.addEventListener("change", applyCutAliasPreset); elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare")); elements.takeInButton.addEventListener("click", () => requestPlayoutCommand("take-in")); elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next")); @@ -1192,6 +1797,9 @@ if (event.ctrlKey && event.key.toLowerCase() === "k") { event.preventDefault(); elements.catalogSearch.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"); } @@ -1202,9 +1810,9 @@ bindEvents(); renderCatalog(); renderPlaylist(); + updatePreview(); renderDatabaseStatus(); renderLiveData(); - renderPlayout(); updateClock(); setInterval(updateClock, 1000); addLog("WebView 운영 화면 시작"); diff --git a/Web/index.html b/Web/index.html index 6fdb8f9..30e0993 100644 --- a/Web/index.html +++ b/Web/index.html @@ -138,6 +138,18 @@
송출 어댑터 상태를 확인하고 있습니다.
+
+ SCENE + ENTRY + BUILDER + PAGE + PAGE SIZE + TOTAL ROWS 0 + PAGE ROWS 0 + LAST PAGE YES + NEXT NONE + REFRESH STOPPED +
+
+
SCENE DATAasset 경로는 숨김 처리
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
diff --git a/Web/playout-safety.js b/Web/playout-safety.js index fd24b12..21541ff 100644 --- a/Web/playout-safety.js +++ b/Web/playout-safety.js @@ -32,6 +32,7 @@ if (!snapshot || snapshot.pending) return "busy"; if (snapshot.outcomeUnknown) return "outcome-unknown"; if (!snapshot.commandAvailable) return "unavailable"; + if (snapshot.refreshFaulted && command !== "take-out") return "refresh-fault"; const onAir = snapshot.engineState === "PROGRAM" || Boolean(snapshot.onAirCode); const prepared = snapshot.engineState === "PREPARED" || Boolean(snapshot.preparedCode); @@ -48,10 +49,47 @@ payload.command === pending.command); } + function canClearCommandError(error, sessionQuarantined) { + if (sessionQuarantined || !error) return !sessionQuarantined; + return error.outcomeUnknown !== true && error.code !== "WEB_TIMEOUT" && + error.nonClearable !== true; + } + + function playlistSnapshotLocked(snapshot) { + return Boolean(snapshot && ( + snapshot.pending || + snapshot.outcomeUnknown || + snapshot.engineState === "PREPARED" || + snapshot.engineState === "PROGRAM" || + snapshot.preparedCode || + snapshot.onAirCode)); + } + + function shouldClearRefreshError(error, refreshFaulted) { + return !refreshFaulted && error?.isRefreshFault === true; + } + + function resolveStoredCatalogIndex(catalog, builderKey, cutCode) { + if (!Array.isArray(catalog)) return -1; + const exact = catalog.findIndex(item => + item && item.reachable !== false && item.builderKey === builderKey); + if (exact >= 0) return exact; + const code = String(cutCode || ""); + return catalog.findIndex(item => { + if (!item || item.reachable === false) return false; + const aliases = Array.isArray(item.aliases) ? item.aliases : [String(item.code || "")]; + return aliases.includes(code); + }); + } + return { normalizeConnectionState, responseTimeoutFromNative, commandBlockReason, - matchesPendingCommand + matchesPendingCommand, + canClearCommandError, + playlistSnapshotLocked, + shouldClearRefreshError, + resolveStoredCatalogIndex }; }); diff --git a/Web/styles.css b/Web/styles.css index 9f6aad5..416bcf5 100644 --- a/Web/styles.css +++ b/Web/styles.css @@ -239,6 +239,8 @@ td strong, td small { display: block; overflow: hidden; white-space: nowrap; tex td strong { margin-bottom: 3px; color: #d7e2ee; font-size: 10px; } td small { color: var(--muted-2); font-size: 8px; } .row-type { display: inline-block; padding: 3px 5px; border-radius: 5px; background: var(--surface-3); color: #7f96b0; font: 8px Consolas, monospace; } +.row-enabled { display: flex; align-items: center; gap: 3px; margin-top: 4px; color: var(--muted-2); font: 7px Consolas, monospace; } +.row-enabled input { width: 11px; height: 11px; accent-color: var(--mint); } input[type="checkbox"] { accent-color: var(--mint); } .empty-state { position: absolute; inset: 36px 0 0; display: none; place-content: center; padding: 20px; color: var(--muted); text-align: center; } .empty-state.show { display: grid; } @@ -258,6 +260,9 @@ input[type="checkbox"] { accent-color: var(--mint); } .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; } +.playout-page-state { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; margin: 6px 13px 0; } +.playout-page-state span { min-width: 0; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 5px; color: var(--muted-2); font: 7px Consolas, monospace; } +.playout-page-state strong { display: block; margin-top: 2px; overflow: hidden; color: #a8bad0; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } .playout-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 13px 0; padding: 8px 9px; border: 1px solid rgba(255,102,128,.28); border-radius: 7px; background: rgba(255,102,128,.07); } .playout-error[hidden] { display: none; } .playout-error strong, .playout-error span { display: block; } @@ -266,6 +271,15 @@ input[type="checkbox"] { accent-color: var(--mint); } .playout-error button { min-height: 24px; flex: 0 0 auto; padding: 0 7px; border: 1px solid rgba(255,102,128,.25); border-radius: 5px; background: transparent; color: #e38c9c; font-size: 8px; cursor: pointer; } .preview-stage { position: relative; display: grid; min-height: 255px; margin: 13px; place-items: center; overflow: hidden; border: 1px solid #29405a; border-radius: 9px; background: radial-gradient(circle at 50% 45%, #193552, #08121f 60%); } .preview-stage::after { position: absolute; inset: 0; background-image: linear-gradient(rgba(65,101,133,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(65,101,133,.08) 1px, transparent 1px); background-size: 24px 24px; content: ""; } +.scene-data-preview { margin: -6px 13px 10px; overflow: hidden; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.72); } +.scene-data-preview-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); color: var(--muted-2); font: 7px Consolas, monospace; } +.scene-data-preview-header strong { color: var(--mint); letter-spacing: .08em; } +.scene-data-preview-body { max-height: 145px; overflow: auto; } +.scene-data-empty { padding: 12px 8px; color: var(--muted-2); font-size: 8px; text-align: center; } +.scene-data-row { display: grid; grid-template-columns: 72px minmax(110px, .8fr) minmax(160px, 1.4fr); gap: 7px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); align-items: center; } +.scene-data-row span { color: #7290ad; font: 7px Consolas, monospace; text-transform: uppercase; } +.scene-data-row strong { overflow: hidden; color: #a9bdd2; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.scene-data-row code { overflow: hidden; color: #d4e0ec; font: 8px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } .safe-area { position: relative; z-index: 1; width: 78%; padding: 31px 20px; border: 1px dashed rgba(137,167,195,.24); color: var(--muted); text-align: center; } .safe-area span, .safe-area strong, .safe-area small { display: block; } .safe-area span { margin-bottom: 13px; color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .16em; } @@ -274,6 +288,13 @@ input[type="checkbox"] { accent-color: var(--mint); } .stage-corner { position: absolute; z-index: 2; width: 20px; height: 20px; border-color: var(--mint); border-style: solid; opacity: .7; } .top-left { top: 10px; left: 10px; border-width: 1px 0 0 1px; }.top-right { top: 10px; right: 10px; border-width: 1px 1px 0 0; }.bottom-left { bottom: 10px; left: 10px; border-width: 0 0 1px 1px; }.bottom-right { right: 10px; bottom: 10px; border-width: 0 1px 1px 0; } +.scene-selection-form { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)) auto; gap: 5px; margin: -6px 13px 10px; } +.scene-selection-form label { min-width: 0; color: var(--muted-2); font: 7px Consolas, monospace; } +.scene-selection-form input, .scene-selection-form select { width: 100%; min-width: 0; height: 27px; margin-top: 3px; padding: 0 7px; border: 1px solid var(--border-soft); border-radius: 5px; outline: 0; background: #08131f; color: #b8c8db; font-size: 8px; } +.scene-selection-form input:focus, .scene-selection-form select:focus { border-color: rgba(86,168,255,.55); } +.scene-selection-form button { align-self: end; height: 27px; padding: 0 9px; border: 1px solid rgba(86,168,255,.3); border-radius: 5px; background: rgba(86,168,255,.08); color: #8ec4ff; font-size: 8px; cursor: pointer; } +.scene-selection-form button:disabled, .scene-selection-form input:disabled, .scene-selection-form select:disabled { cursor: not-allowed; opacity: .4; } + .playout-controls { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; padding: 0 13px 12px; } .playout-controls button { display: flex; min-width: 0; height: 42px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: #b5c4d5; font-size: 9px; font-weight: 700; cursor: pointer; } .playout-controls button span { color: var(--muted-2); font: 8px Consolas, monospace; } @@ -283,6 +304,9 @@ input[type="checkbox"] { accent-color: var(--mint); } .playout-controls .prepare { border-color: rgba(86,168,255,.34); background: rgba(86,168,255,.1); color: #8ec4ff; } .playout-controls .take-in { border-color: rgba(50,213,164,.38); background: var(--mint-soft); color: var(--mint); } .playout-controls .take-out { border-color: rgba(255,102,128,.28); background: rgba(255,102,128,.08); color: #ff8fa2; } +.add-cut:disabled { cursor: not-allowed; opacity: .28; } +.live-data-selectable { cursor: pointer; } +.live-data-selectable:hover td { background: rgba(86,168,255,.08); color: #d8e8fb; } .event-log { display: flex; min-height: 0; flex: 1; flex-direction: column; margin: 0 13px; border: 1px solid var(--border-soft); border-radius: 8px; background: #08131f; overflow: hidden; } .event-log-header { display: flex; align-items: center; justify-content: space-between; min-height: 33px; padding: 0 10px; border-bottom: 1px solid var(--border-soft); } diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md index a70f72b..6ac67a4 100644 --- a/docs/LEGACY_PLAYOUT_ANALYSIS.md +++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md @@ -1,57 +1,89 @@ # 원본 Tornado 송출 흐름 분석 -이 문서는 `MBN_STOCK_N`의 `MainForm`, `Scene` 35개 및 `PageN`/`Nxt_PageN`을 새 송출 어댑터와 대조한 기준선입니다. 원본 파일은 읽기만 했으며 새 저장소로 복사하지 않았습니다. +이 문서는 `C:\Users\MD\source\repos\MBN_STOCK_N`의 `MainForm`, `Scene` 35개와 `PageN`/`Nxt_PageN`을 새 송출 runtime과 대조한 기준선이다. 원본은 읽기 전용으로만 조사했으며 소스, DB 비밀번호, 운영 설정과 실제 자산을 새 저장소로 복사하지 않았다. ## MainForm 호출 순서 -원본 연결은 UI STA에서 `KTAPConnect(1, "127.0.0.1", 30001, 0, event)`를 호출한 뒤 `GetScenePlayer()`를 얻습니다. 연결 성공 판정은 재연결 코드와 동일하게 반환값 `1`입니다. +원본 연결은 UI STA에서 `KTAPConnect(1, "127.0.0.1", 30001, 0, event)`를 호출한 뒤 `GetScenePlayer()`를 얻는다. 원본의 `30001`은 당시 운영값일 뿐 새 Test endpoint의 기본값이 아니다. 새 회차에서는 Tornado2 `Tools > Option > Control > Network Server > TCP Port`의 실값을 사용하고, 반환값 `1`뿐 아니라 `OnHello`와 Network Monitoring `[R]`/`[S]`를 함께 확인해야 한다. -여기의 `30001`은 원본 시스템의 당시 값일 뿐 새 Test endpoint의 기본값이나 검증값이 아닙니다. 새 설정은 격리 Test Tornado의 현재 `Tools > Option > Control > Network Server > TCP Port`를 직접 확인해 사용합니다. 반환값 `1`도 매뉴얼의 `OnHello` 또는 Network Monitoring `[R]`/`[S]` 확인을 대신하지 않습니다. - -원본 장면 PREPARE의 호출 순서는 다음과 같습니다. +원본 PREPARE의 순서는 다음과 같다. 1. `LoadScene(Cuts\.t2s, )` -2. IN effect 플래그에 fade effect `7`과 `FadeInSec` 적용 +2. IN effect flag `1`에 fade effect `7`과 `FadeInSec` 적용 3. `BeginTransaction()` -4. 장면별 데이터와 오브젝트 변경 +4. 장면별 object 값과 시각 속성 변경 5. `scene.QueryVariables()` 6. `EndTransaction()` 7. `player.Prepare(10, scene)` -TAKE IN은 준비된 장면에 `Play(10)`을 호출하고 `m_TakeIn=true`로 전환합니다. TAKE OUT은 과거 `CutOut(10)` 대신 현재 운영 코드와 동일하게 `StopAll()`을 사용하고 on-air 상태를 해제합니다. NEXT는 `m_TakeIn`이 참일 때만 실행되므로 IDLE 또는 PREPARED 상태에서 바로 출력을 시작해서는 안 됩니다. +새 `DynamicK3dSession`도 같은 순서를 사용한다. output channel이 명시된 경우에만 `EndTransactionOnChannel`을 사용하며 layout `10`은 `Prepare`/`Play`/`CutOut`에만 전달한다. transaction 중 실패하면 `RollbackTransaction`을 시도하고 원래 실패를 보존한다. -새 `DynamicK3dSession`은 매뉴얼의 transaction 제한에 맞춰 오브젝트 변경을 `BeginTransaction`/`EndTransaction[OnChannel]` 안에서 끝낸 뒤 `QueryVariables`를 호출하고 `Prepare`합니다. `SetSceneEffectType`의 첫 인수도 layout이 아니라 IN effect 플래그 `1`로 전달하며, layout `10`은 `Prepare`/`Play`/`CutOut`에만 사용합니다. `TornadoPlayoutEngine`은 on-air 상태가 없으면 NEXT를 COM 호출 전에 거부합니다. +공통 scene fade/background는 Web 입력이 아니라 로컬 trusted 설정이다. 원본 `ComboDi.SelectedIndex`에 맞춘 fade 기본값은 6이다. background kind와 scene root 아래 상대 asset, video loop를 `PlayoutSceneCompositionFactory`가 검사하며 `DryRun`도 실제 COM 전에 파일 존재, 확장자, root 탈출과 reparse point를 fail-closed 검증한다. 실제 asset 경로는 Web preview나 wire status에 노출하지 않는다. -K3D의 Play/Stop 계열은 완료 이벤트가 별도인 비동기 명령입니다. 현재 callback handler를 아직 포팅하지 않았으므로 이전/on-air Scene을 명령 반환 직후 `Unload`하지 않고 연결 종료까지 보존합니다. 실제 운영의 장기 세션 정리는 `OnScenePlayed`/`OnCutOut`/`OnStopAll` 성공 콜백 기반으로 구현해야 합니다. +## PREPARE, TAKE IN, NEXT, TAKE OUT 상태 전이 -## Scene 빌더의 범위 +- PREPARE는 선택 위치부터 다음 활성 row를 찾아 page 0의 실제 데이터를 조회하고 scene을 load/transaction/prepare한다. 성공한 전체 playlist와 선택 index는 immutable native snapshot으로 고정된다. +- PREPARE/PROGRAM이 이미 활성인 상태에서 PREPARE를 다시 누르면 원본 toggle과 같이 `TakeOut(All)`/`StopAll`로 정리한다. +- TAKE IN은 PREPARE 때의 DTO를 그대로 play하지 않는다. 원본이 `m_Super`를 초기화하고 `ONAirMode`를 다시 호출하는 것처럼 같은 frozen entry/page를 DB에서 새로 조회하고 `LoadScene` → transaction → `Prepare(10)`한 뒤 성공한 경우에만 `Play(10)`한다. +- NEXT는 on-air 상태에서만 허용한다. 다음 page가 있으면 Page NEXT, 마지막 page면 다음 활성 playlist entry의 page 0으로 구분한다. 끝에서 wrap하지 않는다. +- TAKE OUT은 현재 원본 운영 경로와 같이 `StopAll()`을 사용하고 on-air 상태를 해제한다. -원본 `Scene` 폴더에는 35개 빌더가 있습니다. 단순 텍스트와 가시성 외에도 색상, 위치, 크기, crop key, path point, 그래프 데이터, 배경 texture/video 등 장면별 K3D 변형을 수행합니다. 이 로직은 데이터 조회와 WinForms 컨트롤에 강하게 결합되어 있어 단순한 Web 제목/설명 문자열로 대체할 수 없습니다. +timeout, dispatch 뒤 cancellation 또는 결과가 불명확한 COM 실패는 `OutcomeUnknown` latch로 남긴다. 취소나 retry 가능한 실패로 낮추거나 같은 명령을 다시 보내지 않는다. -현재 어댑터의 `PlayoutField`는 COM 경계를 검증하는 공통 `SetValue`/`SetVisible`만 표현합니다. Web bridge는 presentation용 `title`/`detail`을 장면 데이터인 것처럼 버리거나 추측하지 않고, PREPARE/NEXT에 검증된 scene code만 보냅니다. 따라서 승인된 `5001.t2s`/`5006.t2s` 연결 시험은 파일 load·prepare·play·stop 경로를 검증하지만 원본 시장 데이터가 채워진 방송 화면의 동등성을 증명하지 않습니다. +## PageN, Page NEXT와 timer refresh -장면 데이터를 포팅할 때는 scene code별 builder가 Core의 조회 결과를 명시적인 mutation DTO로 변환하고, 허용된 K3D 메서드만 어댑터가 실행하도록 확장해야 합니다. 오브젝트 이름이나 메서드를 Web 입력에서 임의로 전달하는 범용 reflection API는 만들지 않습니다. +`PageN`과 `Nxt_PageN`은 조회 행 수를 5·6·12개 단위로 나눠 최대 20페이지의 `m_pcnt`를 계산한다. 새 구현의 대상은 `s5074`(5), `s5077`(6), `s5088`(12)이며 `pageCount = min(20, ceil(itemCount/pageSize))`를 사용한다. -## PageN과 NEXT +원본에는 같은 scene을 바꾸는 두 경로가 있다. 서로 혼동하면 안 된다. -`PageN`과 `Nxt_PageN`은 조회 행 수를 5·6·12개 단위로 나눠 최대 20페이지의 `m_pcnt`를 계산합니다. `MainForm.Next_Scene`은 5단/6종목/12종목 장면에서 다음 플레이리스트 항목으로 즉시 이동하지 않고 다음 페이지 데이터를 같은 scene에 다시 채웁니다. 경로에 따라 새 scene을 load하거나 `GetPlayingScene(10)`을 얻어 transaction 후 다시 prepare/play합니다. +1. Operator Page NEXT는 `btnNext_Click` → `Next_Scene(0)` 경로다. 다음 page의 fresh 데이터를 조회하고 새 scene을 `LoadScene` → transaction → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. playlist index는 유지하지만 `GetPlayingScene` in-place 갱신은 아니다. +2. Timer refresh는 `timer1_Tick` → `Show_PlayList(idx: 1)` 경로다. current entry/page의 fresh DB DTO를 사용하고 K3D 호출은 `Play(10)` → `GetPlayingScene(10)` → transaction → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)` 순서로 현재 scene을 갱신한다. scene-level background와 transition effect는 다시 적용하지 않는다. -현재 Web NEXT는 on-air 상태에서 다음 플레이리스트 cue를 prepare/play하는 어댑터 수준의 동작입니다. `m_pcnt`, 현재 페이지, 같은 scene의 in-place update 및 `GetPlayingScene` 기반 갱신은 아직 장면 builder 계층이 없으므로 구현 범위에 포함되지 않습니다. 운영 동등성 검증에서는 이 항목을 별도 완료 조건으로 추적해야 하며, 현재 Test 시퀀스 성공을 PageN 포팅 완료로 해석하지 않습니다. +operator command를 시작할 때 timer를 먼저 멈춘다. TAKE IN과 playlist NEXT 성공 뒤 해당 cut의 원본 `m_time`으로 첫 refresh를 예약하고, 첫 성공 이후에는 3초 간격으로 반복한다. Page NEXT 뒤에는 원본처럼 timer를 다시 시작하지 않는다. refresh 실패, timeout 또는 `OutcomeUnknown`이면 fault latch를 세우고 자동 반복을 중단하며 TAKE OUT 외 mutation 명령을 막는다. -## 현재 Test 판정 범위 +마지막 부분 page는 남은 row만 채우고 나머지 object를 clear/hide한다. `s5088` NXT 비교와 조회 index는 모두 `i + pageIndex * 12`를 사용한다. page 경계값과 partial-page clearing은 자동 테스트로 검증했다. -격리 Test에서 확인할 수 있는 범위는 다음과 같습니다. +## 35개 Scene builder와 실제 데이터 -- x64 COM 활성화와 KTAP 연결/해제 -- 승인된 `.t2s`의 load와 scene alias -- 오브젝트 transaction 종료 뒤 `QueryVariables`, layout 10 prepare -- TAKE IN, 다음 cue의 NEXT, TAKE OUT `StopAll` -- STA 직렬화, timeout, 프로세스 교체 및 오류 상태 +원본 `Scene` 폴더의 35개 builder는 모두 typed DTO와 COM-neutral mutation builder로 포팅했다. 값, visibility, face color, position, position key, scale, crop key, circle angle, path/path-shape, image/texture/video와 scene background를 개별 mutation으로 표현하고 `IPlayoutEngine` 뒤에서만 K3D 호출로 변환한다. -다음 항목은 별도 장면 마이그레이션 작업이 필요합니다. +- registry/catalog: 35개 builder 1:1 +- MainForm 도달 runtime: 34개 builder, active cut alias 45개 +- `s5032`/`s8018`: `5032`, `8018`, `8032` shared alias를 closed selection으로 분기 +- `s8086`: 원본 MainForm dispatch가 없어 active alias와 앱 runtime route 없이 diagnostic으로 유지 +- 실제 데이터 smoke: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일 통과 +- `s8086` diagnostic 조회 통과, 전체 Oracle/MariaDB query 55건 통과 -- 35개 scene builder의 데이터/시각 속성 동등성 -- `PageN`/`Nxt_PageN` 페이지 계산과 같은 scene 갱신 -- 배경 영상·texture 및 그래프/path mutation -- 실제 시장 데이터와 원본 화면의 픽셀/내용 비교 -- `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 Scene unload +builder별 object/mutation과 검증 상태는 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md)에 있다. 실제 `.t2s`, DB 계정과 외부 CP949 파일은 Git에 넣지 않는다. + +## WebView 상태와 안전 경계 + +Web catalog는 35개 builder와 도달 가능한 45개 alias를 제공하고 playlist row별 `enabled` flag를 보존한다. PREPARE가 성공하면 native snapshot을 freeze하며 pending command, `OutcomeUnknown`과 timeout quarantine 중에도 편집 잠금을 유지하므로 이후 Web 편집으로 NEXT 대상을 바꿀 수 없다. + +native status는 현재 entry, builder, page size/index/count, current row 수, last-page, next kind와 bounded typed preview를 authoritative 값으로 보낸다. preview는 object 값과 상태를 보여주되 asset path는 숨긴다. refresh active/next/last-success/fault도 표시한다. 성공한 TAKE OUT으로 native refresh state가 reset되면 전용 refresh error marker만 제거하고 다른 unknown/quarantine latch는 유지한다. + +Web 응답 제한 시간이 지나면 strict `ParseTimeoutQuarantine` 요청을 native에 보내고 MainWindow가 process-lifetime correlation latch를 먼저 세운 뒤 vendor session을 quarantine한다. 명령 진행 중 trusted navigation, reload 또는 WebView2 process failure도 JavaScript 상관관계를 잃기 전에 같은 latch를 세우므로 WebView reload로 해제되지 않는다. pending request와 맞지 않는 늦은 응답은 state 전이 근거로 쓰지 않으며 같은 명령을 다시 보내지 않는다. `OutcomeUnknown`, native fault와 Web timeout은 UI를 닫는 것으로 해제되지 않는다. + +## Callback과 scene 수명 + +vendor event handler의 `OnScenePlayed`, `OnCutOut`, `OnStopAll`은 managed callback queue로 연결돼 있다. + +- `OnScenePlayed` 성공 뒤에만 이전 retired scene을 unload/release한다. +- pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다. +- `CutOut`/`StopAll` dispatch 전에 completion counter를 올리고 동기 호출 실패 시 원복한다. 성공 callback은 대응 counter를 하나 줄이고, stop/cut으로 중단된 Play는 별도 `OnScenePlayed`가 없을 수 있으므로 pending Play accounting을 취소한다. +- pending lifecycle callback, queue overflow, callback failure 또는 connection generation 불일치가 있으면 Disconnect와 조기 unload를 하지 않고 session을 abandon/quarantine한다. +- 이전 generation의 늦은 callback은 현재 scene state를 변경하지 않는다. + +따라서 장기 실행 시에도 callback으로 안전성이 확인된 retired scene만 unload된다. + +## 검증 판정 범위 + +다음 자동·통합 검증은 완료됐다. + +- 35개 builder, loader, resolver, runtime coverage와 PageN 경계 +- 실제 Oracle/MariaDB 및 trusted CP949 source → DTO → mutation preflight +- Debug/Release x64 Core, Playout, Infrastructure suite와 Web safety suite +- Visual Studio 2026 Debug/Release x64 빌드 +- trusted Release x64 MSIX 생성, 설치와 package context 실행 + +하지만 이번 마이그레이션 WebView workflow로 실제 Tornado2 PGM에 PREPARE/TAKE IN/Page NEXT/playlist NEXT/timer refresh/TAKE OUT을 보내고 Network Monitoring과 화면을 함께 확인하는 회차는 아직 승인되지 않았고 실행하지 않았다. 과거 고정 `5001 → 5006` runner 증거는 이 동등성 검증을 대신하지 않는다. 실제 운영 검증은 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)의 회차 승인과 반복 금지 절차를 따른다. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index bab06af..83265fb 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -53,23 +53,21 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일 - 완료: `IPlayoutEngine` 경계, bounded STA FIFO/message pump, timeout 후 `OutcomeUnknown` 격리 - 완료: 연결/해제, 명시적 재연결(no replay), `Tornado2` 접두사 프로세스 감시 - 완료: `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` WebView 메시지 및 상태/오류 UI 연결 -- 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널·씬 allowlist, Live 이중 승인 +- 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널, Test/Live 공통 폐쇄형 씬 allowlist, Live 이중 승인 - 완료: 현재 Tornado2 PGM 렌더 창에 대한 x64 K3D `KTAPConnect → Disconnect` 실제 왕복 및 Network Monitoring `[R] HELLO`/`[S] SUCCESS HELLO` 확인. 렌더 명령은 호출하지 않음 - 완료: 네이티브/Interop 이중 SHA-256 핀, 프로세스 수명 파일 잠금, 실제 PGM listener 소유권 및 KTAP 지연 dispatch 차단 -- 완료: 승인된 실제 PGM에서 고정 해시의 `5001 → 5006 → TAKE OUT` 호출. `Connect → Prepare/Play(5001) → Prepare/Play(5006) → StopAll → Disconnect` 전 단계 성공, 세 관찰 구간 `5051/5052/5093ms`, PGM의 5001·5006 화면과 최종 검은 화면 확인 -- 완료: 같은 회차 Network Monitoring의 `HELLO/LOAD_SCENE/SCENE_PREPARE/PLAY/STOPAL/BYE` 기록과 39개 연속 캡처를 manifest SHA-256으로 검증. 로컬 증거와 재현 절차는 [Tornado/K3D 운영 가이드](PLAYOUT.md)에 기록 -- 완료: 첫 회차의 출력 전 Prepare 거부 원인이 cue 이중 resolve임을 확인하고, 상대 cue와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지 -- 목표 완료 판정: 최초 완료 조건의 격리 Test 인스턴스 검증은 이후 운영자의 현재 PGM 대상 회차별 명시 승인으로 대체되었습니다. 따라서 위 고정 테스트 컷 `5001`/`5006`의 실제 SDK 호출과 화면·Network Monitoring 증거를 이번 목표의 실제 호출 검증으로 인정합니다. 이 예외는 현재 PGM을 일반 Test 인스턴스로 분류하거나 앱의 Test/Live 안전 게이트를 완화하지 않습니다. -- 배포 전 후속: 별도 승인된 Test 환경에서 MSIX WebView 컨텍스트의 실제 COM 활성화와 네 버튼 출력 관찰 -- 후속: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload -- 후속: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 같은-scene 페이지 갱신 포팅 +- 과거 기본 경로 증거: 승인된 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와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지 +- 현재 목표 미완료: 설치된 MSIX WebView workflow의 실제 Tornado2 `PREPARE → fresh TAKE IN → playlist/Page NEXT → timer refresh → TAKE OUT`, Network Monitoring과 PGM 동시 검증은 새 회차 승인 전이며 아직 실행하지 않았다. +- 완료: `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개 장면 빌더의 단계별 포팅 +- 35개 장면 빌더의 실제 Tornado2/PGM 화면 동등성 회차 검증 - 실제 Preview 이미지 및 씬/영상 자산 연결 ## 의도적으로 제외한 항목 diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index 3401c5b..0fa94ae 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -93,8 +93,12 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb | `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 | | `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 | | `layoutIndex` | 씬 플레이어 layout 위치 | +| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본값 6, 허용 범위 0~60 | +| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web에서 변경할 수 없음 | +| `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 | +| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경의 bounded loop 설정 | | `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 | -| `testSceneAllowlist` | `Test`에서 허용한 테스트 scene name(code) 목록. 경로나 확장자는 넣지 않음 | +| `testSceneAllowlist` | 실제 COM을 사용하는 `Test`와 `Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) | | `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 | | `queueCapacity` | 직렬 STA 명령 큐의 최대 대기 항목 수 | | `*TimeoutMilliseconds` | 연결, 작업 및 해제 제한 시간 | @@ -112,6 +116,11 @@ MBN_STOCK_PLAYOUT_CLIENT_PORT MBN_STOCK_PLAYOUT_SCENE_DIRECTORY MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL MBN_STOCK_PLAYOUT_LAYOUT_INDEX +MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION +MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND +MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET +MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT +MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN MBN_STOCK_PLAYOUT_QUEUE_CAPACITY MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS @@ -123,7 +132,7 @@ MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS MBN_STOCK_PLAYOUT_RECONNECT_ENABLED ``` -`testSceneAllowlist`와 `trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 Test allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다. +`testSceneAllowlist`와 `trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 공통 background/fade도 Web payload가 아니라 이 trusted 프로세스 설정 경계에서만 결정합니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다. ### KTAP 포트와 Network Monitoring 판정 @@ -131,7 +140,7 @@ K3DAsyncEngine 매뉴얼의 `KTAPConnect(bTCP, HostAddress, nHostPort, nClientPo Tornado2의 `View > Network Monitoring Window`에서 `[R]`은 서버가 클라이언트 요청을 받은 기록, `[S]`는 서버가 응답을 보낸 기록이며 `TCPSession`은 TCP 세션 수입니다(매뉴얼 26~27쪽). 프로세스 감지, COM 등록 probe 또는 COM 객체 생성만으로는 이 기록이 생기지 않습니다. 기본 앱과 `--dry-run`, `--probe`, `--test-plan`은 KTAP를 호출하지 않으므로 빈 모니터가 정상입니다. -상태의 `accepted-unconfirmed`는 `KTAPConnect`가 SDK 성공값 `1`을 반환했다는 뜻일 뿐입니다. 매뉴얼 41쪽의 `OnHello` 콜백이나 실제 `[R]`/`[S]`를 자동 확인했다는 뜻이 아닙니다. 현재 late-bound 어댑터는 282개 메서드 `IKAEventHandler` ABI를 안전하게 패키징하는 검증된 전략이 없어 `ktapHelloObserved`를 `null`로 보고합니다. `lastKtapConnectState`는 현재 연결 상태가 아니라 가장 최근 KTAP dispatch 시도의 증거이며, 화면은 `Connected`/`Faulted` 같은 현재 상태와 분리해 표시합니다. 따라서 격리 `--test-connect`가 성공했는데도 같은 시각의 `[R]`/`[S]`가 전혀 없다면 `--test-sequence`로 진행하지 말고 mode/config 파일, 실제 Network Server TCP Port와 안전 게이트 거부 여부를 먼저 확인합니다. +상태의 `accepted-unconfirmed`는 `KTAPConnect`가 SDK 성공값 `1`을 반환했다는 뜻일 뿐입니다. 매뉴얼 41쪽의 `OnHello` 콜백이나 실제 `[R]`/`[S]`를 자동 확인했다는 뜻이 아닙니다. connect-only `--pgm-connect-diagnostic`은 렌더 API 표면을 제거한 별도 binding이라 callback을 관찰하지 않고 `ktapHelloObserved`를 `null`로 보고합니다. 일반 `IPlayoutEngine` 경로는 검증된 282-method `DynamicK3dEventHandler`로 `OnHello`와 lifecycle callback을 수신하지만, callback 미수신 상태를 성공으로 추정하지 않으며 Network Monitoring은 계속 사람이 대조합니다. `lastKtapConnectState`는 현재 연결 상태가 아니라 가장 최근 KTAP dispatch 시도의 증거이며, 화면은 `Connected`/`Faulted` 같은 현재 상태와 분리해 표시합니다. 따라서 격리 `--test-connect`가 성공했는데도 같은 시각의 `[R]`/`[S]`가 전혀 없다면 `--test-sequence`로 진행하지 말고 mode/config 파일, 실제 Network Server TCP Port와 안전 게이트 거부 여부를 먼저 확인합니다. ### PGM 네트워크 연결 전용 진단 @@ -206,7 +215,7 @@ powershell -NoProfile -ExecutionPolicy Bypass ` 첫 회차는 `Connect: Success` 뒤 출력 전 `prepare-first: Rejected`로 중단되고 안전한 `Disconnect: Success`만 수행했습니다. 원인은 사전 검사에서 상대 cue를 절대 경로로 한 번 resolve한 뒤 그 이미 resolve된 cue를 `TornadoPlayoutEngine`에 전달해 엔진이 두 번째 resolve에서 거부한 것이었습니다. 수정 후 절대 경로는 승인 자산 검사와 파일 lease에만 사용하고, 엔진에는 고정 상대 cue인 `5001.t2s`와 `5006.t2s`를 전달합니다. 상대 cue와 검증용 절대 자산 경로가 분리되는 회귀 테스트도 추가했습니다. 이 회차는 `Play` 전에 결과가 명확히 거부되고 같은 대상의 Disconnect 성공까지 확인됐기 때문에 원인 수정 후 새 회차를 진행할 수 있었습니다. timeout, `OutcomeUnknown`, 대상 교체 또는 출력 결과가 불명확한 경우에는 자동·수동으로 반복하지 않고 quarantine 뒤 PGM 상태를 사람이 먼저 확인합니다. -PGM 전용 시퀀스의 `outputChannel`은 의도적으로 비워 원본 `MainForm` 연결과 같은 `GetScenePlayer()`를 사용합니다. 검증되지 않은 임의 채널을 추정해 `GetScenePlayerOnChannel()`을 호출하지 않기 위함입니다. TAKE OUT은 원본의 현재 운영 경로와 같이 `TakeOut(All)`을 `StopAll()`로 매핑합니다. 과거 `CutOut(10)`보다 현재 재생기의 모든 레이어를 정리해 최종 PGM이 검은 화면으로 돌아오는 동작과 일치합니다. 이 검증은 두 승인 컷의 load/prepare/play/stop 경로를 증명하지만, 아직 포팅되지 않은 35개 scene builder와 `PageN` 데이터 표현의 동등성을 증명하지는 않습니다. +PGM 전용 시퀀스의 `outputChannel`은 의도적으로 비워 원본 `MainForm` 연결과 같은 `GetScenePlayer()`를 사용합니다. 검증되지 않은 임의 채널을 추정해 `GetScenePlayerOnChannel()`을 호출하지 않기 위함입니다. TAKE OUT은 원본의 현재 운영 경로와 같이 `TakeOut(All)`을 `StopAll()`로 매핑합니다. 과거 `CutOut(10)`보다 현재 재생기의 모든 레이어를 정리해 최종 PGM이 검은 화면으로 돌아오는 동작과 일치합니다. 현재 35개 scene builder, 34개 도달 runtime, PageN과 실제 데이터 mutation은 포팅·자동/DB 검증을 마쳤지만, 이 과거 고정 runner는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh와 실제 화면 동등성을 검증한 회차가 아니므로 완료 증거로 사용하지 않습니다. ## 모드와 안전 게이트 @@ -313,7 +322,7 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` 시퀀스는 `Connect → Prepare(5001) → TakeIn → 관찰 → Next(5006) → 관찰 → TakeOut(All) → 관찰 → Disconnect` 순서입니다. K3D의 Play/Stop 완료는 비동기 이벤트이므로 마지막 관찰 창이 끝나기 전에는 Disconnect하지 않습니다. 자동 재연결은 CLI가 강제로 비활성화합니다. 성공한 `TakeIn` 뒤 관찰 취소처럼 결과가 확정된 중단이면 `TakeOut(All)`을 한 번만 정리 단계로 요청한 뒤, 정리가 성공한 경우에만 `Disconnect`합니다. 이미 실행 결과가 불명확하거나 `TakeOut`이 어떤 비성공 결과라도 반환하면 출력이 남아 있을 수 있으므로 추가 출력 명령과 SDK `Disconnect`를 보내지 않습니다. 이때 `QuarantineAsync`가 같은 STA에서 제어 메서드 호출 없이 로컬 COM 참조만 해제한 다음 bounded 어댑터 폐기를 수행합니다. quarantine 자체를 완료하지 못하면 의도하지 않은 Disconnect보다 로컬 누수를 택해 일반 Dispose도 생략합니다. 어느 경우든 격리 모니터에서 최종 Test 출력 상태를 사람이 확인해야 합니다. -현재 런타임은 vendor의 282-method event handler를 managed callback으로 아직 소비하지 않습니다. 따라서 `Play`, `CutOut`, `StopAll`의 COM 반환 직후 Scene을 `Unload`하지 않고 prepared/current/retired 참조를 연결 종료까지 보존합니다. 이는 두 장면만 쓰는 bounded 검증에서 조기 Unload로 화면 전환을 끊는 위험을 피하기 위한 동작입니다. 장시간 운영에서 Scene을 누적하지 않으려면 `OnScenePlayed`/`OnCutOut`/`OnStopAll` 성공 콜백 뒤에만 unload queue를 비우는 handler 포팅이 선행되어야 합니다. +현재 런타임은 vendor의 282-method event handler에서 `OnScenePlayed`, `OnCutOut`, `OnStopAll`을 managed callback queue로 수신합니다. `OnScenePlayed` 성공 뒤에만 retired scene을 unload하고, pending Play callback이 있으면 TAKE OUT 이외의 mutation 명령을 fail-closed 차단합니다. `CutOut`/`StopAll` completion counter는 dispatch 직전에 증가하고 동기 실패 시 원복되며, 성공한 stop/cut callback은 중단된 pending Play accounting도 취소합니다. lifecycle callback이 pending이거나 실패·overflow가 발생하면 Disconnect나 조기 unload를 하지 않고 session을 abandon/quarantine하여 결과 불명확 상태를 보존합니다. JSON 결과는 단계별 operation/result code와 `connectRequestIssued`, nullable `comActivationAttempted`, `lastKtapConnectState`, `ktapConnectAttempted`, nullable `ktapConnectAccepted`, nullable `ktapHelloObserved`, nullable `networkMonitoringRecordExpected`, `networkMonitoringCheckRequired`, nullable `networkMonitoringVerified`, `outputMayBeActive`, quarantine 시도·완료 여부를 제공합니다. `connectRequestIssued`는 엔진 API 요청일 뿐 KTAP 통신 증거가 아니며, `comActivationAttempted`도 COM 활성화 추정값일 뿐입니다. `lastKtapConnectState`는 `not-attempted`, `attempted`, `accepted-unconfirmed`, `failed` 중 하나입니다. `networkMonitoringRecordExpected`는 성공값을 받은 경우 `true`, dispatch가 없으면 `false`, local reflection/COM 실패 또는 timeout으로 서버 도달을 예측할 수 없으면 `null`입니다. `networkMonitoringCheckRequired`는 KTAP dispatch 경로에 들어간 모든 경우 `true`이며, `networkMonitoringVerified`는 앱이 Tornado2 UI를 판독하지 않으므로 항상 `null`입니다. 운영자가 직접 `[R]`/`[S]`를 확인해야 합니다. `outputMayBeActive: true`이면 자동 정리를 성공으로 확인하지 못했으므로 사람이 격리 출력을 확인해야 합니다. 특히 `Unavailable`, 취소, timeout은 연결 전 거부와 연결 도중 안전 게이트 변화가 같은 결과 code가 될 수 있으므로 추측하지 않습니다. 로컬 경로, 씬 code, PID, 창 제목, HRESULT 및 엔진 원문 오류는 출력하지 않습니다. `--test-plan`의 `runtimeProcessGateChecked: false`는 자산 계획만 검증했다는 뜻이며 실제 연결 가능성을 증명하지 않습니다. @@ -330,13 +339,15 @@ CLI용 Test JSON은 위처럼 앱 기본 경로인 `playout.local.json`과 다 3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다. 4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다. -WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 앱 재시작 전까지 유지됩니다. 브라우저 응답 제한 시간은 고정 15초가 아니라 검증된 native operation timeout에 5초 전달 여유를 더해 사용하고, native 명령이 끝나면 상관 응답을 놓친 경우에도 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다. +2026-07-10 최종 서명 Release x64 MSIX를 설치한 package context에서 실제 Oracle/MariaDB를 읽는 `DryRun` 검증을 완료했습니다. Web catalog 35개(34개 송출 가능), DB 상태 2/2 정상, alias `5001`/`N5001`, fade 6, mutation preview와 asset 경로 비노출을 확인했습니다. 이어 `5001 PREPARE → fresh TAKE IN → 최초 2초·이후 3초 timer refresh → 5074 playlist NEXT → 같은 entry/scene의 Page NEXT`를 수행했고 Page NEXT 뒤 refresh가 정지했습니다. 5074는 5개 단위로 `1/20`부터 `20/20`까지 순서대로 진행되어 마지막에 `isLastPage=YES`, `END OF PLAYLIST`, NEXT 비활성이 되었으며, TAKE OUT 뒤 scene/refresh가 정리되고 playlist 편집 잠금이 해제되었습니다. 전 과정의 안전 배지는 `DRY RUN · PROGRAM 차단`이었고 COM/KTAP/Tornado2 연결은 발생하지 않았습니다. + +WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. Web catalog는 35개 builder와 도달 가능한 45개 alias, row별 `enabled`를 보존하고 PREPARE 성공 뒤 playlist snapshot을 freeze합니다. pending command, `OutcomeUnknown`과 timeout quarantine 중에도 snapshot 편집 잠금을 유지합니다. native status의 current entry/builder/page size/current rows/last-page와 bounded preview가 authoritative 값이며 asset path는 preview에서 숨깁니다. refresh active/next/last-success/fault도 전달하고 refresh fault는 TAKE OUT 외 mutation 명령을 막습니다. 성공한 TAKE OUT 뒤에는 전용 refresh error marker만 reset하며 다른 unknown latch를 지우지 않습니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 유지됩니다. 브라우저 응답 제한 시간은 검증된 native operation timeout에 5초 전달 여유를 더해 사용하며, 상관 응답이 오지 않으면 strict `ParseTimeoutQuarantine` 요청을 native에 보냅니다. MainWindow는 await 전에 process-lifetime latch를 세우고 vendor session을 quarantine하므로 WebView reload로도 해제되지 않습니다. 명령 진행 중 trusted navigation, reload 또는 WebView2 process failure로 JavaScript 상관관계가 사라지는 경우도 같은 native latch를 먼저 세웁니다. 늦은 응답이나 UI 재시도로 같은 명령을 다시 보내지 않으며 native 명령이 끝나면 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다. On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 종료 및 세션 재활용 경로도 SDK `Disconnect`를 호출하지 않습니다. 중앙 `ReleaseSessionAsync` 방어가 세션을 quarantine/abandon하고 결과를 불명확 상태로 승격하므로, 운영자는 먼저 성공한 `TAKE OUT`을 확인한 다음 정상 종료해야 합니다. -승인 컷의 load/play 경로와 원본 Scene/PageN 데이터 표현력은 서로 다른 검증 범위입니다. 현재 지원 범위와 아직 포팅되지 않은 복합 mutation·페이지 갱신은 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)을 기준으로 판단합니다. +승인 컷의 과거 load/play 경로와 현재 WebView 기반 Scene/PageN 데이터 동등성은 서로 다른 검증 범위입니다. 복합 mutation·페이지 계산·fresh TAKE IN·Page NEXT·timer refresh는 구현 및 자동/실데이터 검증을 마쳤으며 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)과 [35개 Scene 매트릭스](SCENE_EQUIVALENCE.md)에 기록합니다. 남은 단계는 회차 승인을 받은 실제 Tornado2 Network Monitoring/PGM 검증입니다. -일반 앱과 정규 Test 경로의 향후 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → TAKE IN → NEXT → TAKE OUT` 결과를 관찰합니다. PGM/운영 출력에 변화가 보이면 즉시 앱을 종료하고 롤백합니다. 명령 timeout 뒤 결과가 불명확하면 명령을 자동 또는 수동으로 반복하지 말고 테스트 출력 상태를 먼저 확인합니다. +일반 앱과 정규 Test 경로의 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → fresh TAKE IN → Page/playlist NEXT → timer refresh → TAKE OUT` 결과를 관찰합니다. 의도하지 않은 PGM/운영 출력 변화가 보이면 추가 명령을 중단합니다. native 결과가 명확하고 Gate A에 포함된 경우에만 TAKE OUT을 한 번 요청하며, timeout·`OutcomeUnknown`·`WEB_TIMEOUT`·refresh fault라면 앱 종료나 반대 명령으로 자동 롤백하지 않고 session을 quarantine한 채 운영자가 실제 출력 상태를 먼저 확인합니다. 패키지 스모크에서는 벤더 x64 COM이 장비에 정식 등록되어 있어야 합니다. MSIX에 벤더 DLL을 복사해 활성화 오류를 우회하지 않습니다. 패키지 컨텍스트에서 COM 활성화가 막히면 `DryRun` 또는 `Disabled`를 유지하고 HRESULT와 등록 검사 결과만 보고합니다. diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md new file mode 100644 index 0000000..1766d62 --- /dev/null +++ b/docs/PLAYOUT_OPERATIONS.md @@ -0,0 +1,261 @@ +# MBN_STOCK_WEBVIEW 송출 운영·검증 절차 + +이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. + +## 현재 상태 + +| 항목 | 상태 | +|---|---| +| 기본 모드 | `DryRun`; COM 객체와 `KTAPConnect`를 만들지 않음 | +| 자동 테스트 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 64/64, Web safety 11/11 통과; 이후 테스트 추가 시 개수보다 실패 0건을 기준으로 재확인 | +| Visual Studio 2026 | Debug/Release x64 빌드 성공 | +| trusted Release x64 MSIX | 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 5001 timer refresh, 5074 playlist/Page NEXT와 1~20페이지 마지막 경계, TAKE OUT 정리를 확인 | +| 실제 데이터 전체 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에서 누락 | +| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **미승인·미실행** | + +과거 고정 `5001 → 5006` runner의 실제 PGM 기록은 연결과 기본 K3D 명령 표면의 증거다. 현재 WebView playlist, fresh TAKE IN, Page NEXT, timer refresh, callback/unload의 동등성 완료 증거는 아니다. 이번 WebView runtime의 실제 Tornado2 Network Monitoring/PGM 회차는 여전히 승인되지 않았고 실행하지 않았다. + +## 절대 안전 규칙 + +1. 앱의 기본값은 항상 `DryRun`으로 유지한다. +2. `DryRun`, `--probe`, `--dry-run`, `--test-plan`은 `KTAPConnect`를 호출하지 않는다. 이때 Tornado2 Network Monitoring에 기록이 없는 것이 정상이다. +3. 실제 출력은 승인된 테스트 scene, 지정된 PGM, 지정된 회차에서만 허용한다. +4. Connect/PREPARE를 시작하기 전에 회차 범위 승인이 있어야 한다. **현재 PGM에 TAKE IN을 보내기 직전에는 같은 회차의 별도 명시적 승인을 다시 받아야 한다.** +5. 승인에 없는 NEXT, Page NEXT, TAKE OUT 이외 명령 또는 다른 scene으로 범위를 넓히지 않는다. +6. timeout, `OutcomeUnknown`, `WEB_TIMEOUT`, refresh fault, 응답/화면 불일치, 대상 프로세스 변경, callback 누락 상태에서는 같은 명령을 자동 또는 수동으로 반복하지 않는다. +7. 실제 검증 회차에는 `reconnectEnabled=false`, `maximumReconnectAttempts=0`을 사용한다. 재접속은 새 회차와 새 승인으로만 수행한다. +8. 안전 게이트, x64 vendor hash pin, scene allowlist, process/window/port ownership 검사를 완화하지 않는다. +9. Web 입력으로 object 이름, 파일 경로, K3D method, 임의 SQL을 받지 않는다. +10. 실제 on-air일 수 있는 scene을 수동으로 unload하거나 COM RCW를 강제 release하지 않는다. +11. pending Play callback이 있으면 TAKE OUT 이외의 PREPARE/TAKE IN/NEXT/timer refresh를 실행하지 않는다. lifecycle callback이 남아 있으면 Disconnect하지 않는다. + +## Git 밖에서 준비할 항목 + +다음 항목은 로컬 운영 설정 또는 승인된 외부 자산이다. Git, MSIX, 로그 첨부, 테스트 fixture에 복사하지 않는다. + +- 원본 test Cuts root: `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts` +- 실제 `.t2s`, image, texture, video와 기타 scene 자산 +- `s5006`의 상대 자산 `Video\큐브배경.vrv`; 현재 위 Cuts root에는 없으므로 제공 전 실제 PREPARE 금지 +- `s5025` trusted CP949 수동 파일 디렉터리와 환경 변수 `MBN_STOCK_S5025_MANUAL_DATA_DIRECTORY` +- Oracle/MariaDB host, SID/service, database, user, password와 운영 query selector +- K3D/Tornado vendor DLL, Interop, license와 설치 경로 +- `MBN_STOCK_K3D_NATIVE_SHA256`, `MBN_STOCK_K3D_INTEROP_SHA256` 승인 값과 승인 근거 +- MSIX 서명 인증서, 개인 키, 암호와 배포용 secrets +- 실제 Tornado host/port, output channel, PGM 창 정보와 운영 설정 +- Network Monitoring/PGM screenshot·영상·manifest 등 실제 방송 증거 + +Cuts root는 이번 작업에서 읽기 전용으로 취급한다. [`Test-LegacyCutCoverage.ps1`](../scripts/Test-LegacyCutCoverage.ps1)은 45개 active alias의 cut 존재 여부만 검사하며 자산을 복사하거나 수정하지 않는다. + +## 승인 게이트 + +### Gate A: 회차 범위 승인 + +Connect 또는 실제 PREPARE 전에 다음 내용을 운영 기록에 남긴다. + +```text +[MBN_STOCK_WEBVIEW Tornado 검증 회차] +회차 ID: +예정 시각/최대 종료 시각: +대상 Tornado2/PGM 식별값: +승인된 test cut alias: +승인된 selector와 시작 page: +허용 동작과 횟수: CONNECT, PREPARE, TAKE IN, NEXT/Page NEXT, timer refresh 관찰, TAKE OUT, DISCONNECT +Network Monitoring/PGM 관찰 담당자: +비상 TAKE OUT 담당자: +``` + +대상, cut, selector, page, 횟수 중 하나라도 바뀌면 같은 회차 승인을 재사용하지 않는다. + +### Gate B: TAKE IN 직전 승인 + +PREPARE 결과와 Network Monitoring 상태를 운영자가 확인한 후, TAKE IN 직전에 다음과 같이 명시적인 승인을 받아야 한다. + +```text +회차 의 준비된 cut , page 을 현재 PGM에 TAKE IN 1회 실행하는 것을 승인한다. +``` + +이 문구가 없거나 회차 ID/cut/page가 다르면 TAKE IN을 실행하지 않는다. 이전 대화의 일반적 동의, 과거 회차 승인, DryRun 성공은 Gate B를 충족하지 않는다. + +## 실제 연결 전 offline preflight + +아래 단계는 Tornado에 명령을 보내지 않는다. + +1. 원본 기준선과 cut alias를 확인한다. + + ```powershell + powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacySceneBaseline.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacyCutCoverage.ps1 ` + -CutRoot "C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts" + ``` + +2. Debug/Release x64 테스트와 Web bridge 검사를 실행한다. + + ```powershell + dotnet test .\tests\MBN_STOCK_WEBVIEW.Core.Tests\MBN_STOCK_WEBVIEW.Core.Tests.csproj -c Debug -p:Platform=x64 + dotnet test .\tests\MBN_STOCK_WEBVIEW.Core.Tests\MBN_STOCK_WEBVIEW.Core.Tests.csproj -c Release -p:Platform=x64 + dotnet test .\tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj -c Debug -p:Platform=x64 + dotnet test .\tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj -c Release -p:Platform=x64 + dotnet test .\tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj -c Debug -p:Platform=x64 + dotnet test .\tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj -c Release -p:Platform=x64 + powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-WebPlayout.ps1 + ``` + +3. 실제 DB smoke는 read-only query만 사용하는 별도 단계로 실행한다. DB 오류가 있으면 Tornado 단계로 넘어가지 않는다. + + ```powershell + dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release --no-restore + ``` + +4. 설치된 trusted Release x64 MSIX가 package context에서 실행되는지 확인한다. `bin` 또는 loose EXE를 실제 검증에 사용하지 않는다. +5. K3D Registry64, AMD64 PE, TypeLib/Interop metadata, 두 SHA-256 pin, license를 확인한다. DLL을 repo 또는 앱 폴더로 복사해 우회하지 않는다. +6. 정확히 하나의 승인 대상 Tornado2 프로세스, PGM 창, Network Server TCP port와 LISTEN 소유권을 확인한다. 매뉴얼 예시 port를 추정해 사용하지 않는다. +7. `s5025`를 선택했다면 trusted 외부 디렉터리와 파일 preflight가 성공해야 한다. `s5006`를 선택했다면 `Video\큐브배경.vrv`가 승인 asset root 안에 있어야 한다. +8. 실제 COM을 사용하는 Test/Live playlist의 모든 cut이 로컬 폐쇄형 allowlist 안에 있고 selector가 closed enum/lookup 규칙을 통과하는지 DryRun에서 확인한다. allowlist가 비어 있으면 실제 모드 초기화 자체를 거부해야 한다. +9. 로컬 playout 설정의 `legacySceneFadeDuration` 기본값 6과 `legacySceneBackgroundKind`를 확인한다. 공통 background를 쓸 때 `legacySceneBackgroundAssetPath`는 `sceneDirectory` 아래의 승인된 상대 경로여야 한다. `PlayoutSceneCompositionFactory`의 DryRun preflight가 파일 존재, 허용 확장자, root 탈출, 절대 경로와 reparse point를 모두 거부하는지 확인한다. Web에는 이 asset 경로를 보내지 않는다. +10. Web DryRun에서 45개 active alias, row별 `enabled`, PREPARE 뒤 snapshot freeze, current entry/builder/page size/current rows/last-page/preview와 refresh 상태를 확인한다. pending command와 `OutcomeUnknown`/timeout quarantine에서도 playlist 편집이 잠겨야 한다. preview에 image/texture/video 경로가 나타나면 실제 회차를 중단한다. + +하나라도 실패하면 회차를 시작하지 않는다. 설정을 수정한 뒤 처음부터 새 preflight 결과를 만든다. + +## 정상 실행 순서와 관찰점 + +### 1. Connect + +- Gate A와 모든 preflight를 다시 확인한다. +- Connect는 한 번만 보낸다. +- `OnHello`와 Network Monitoring의 request/response를 함께 확인한다. +- Connect 결과가 accepted이지만 `OnHello` 또는 monitor 왕복이 확인되지 않으면 준비 완료로 간주하지 않는다. + +### 2. PREPARE + +PREPARE는 다음 native 순서를 수행해야 한다. + +1. 현재 활성 playlist 항목과 page 0을 native loader가 조회한다. +2. scene load, trusted common background와 기본 fade 6 또는 승인된 로컬 fade/scene effect를 적용한다. +3. `BeginTransaction` → allowlisted mutation → `QueryVariables` → `EndTransaction`을 실행한다. +4. layout 10을 `Prepare`한다. + +관찰자는 Network Monitoring의 load/prepare 관련 왕복이 한 번씩인지, 앱이 `Prepared`와 정확한 cue/page를 표시하는지 확인한다. PGM에 의도하지 않은 on-air 변화가 있으면 즉시 회차를 중단하고 장애 절차를 따른다. + +### 3. TAKE IN + +- Gate B를 받은 뒤 한 번만 실행한다. +- PREPARE 때의 DTO를 재사용하지 않고 frozen entry/page를 실제 DB에서 새로 조회하는지 확인한다. +- fresh scene의 `LoadScene` → transaction → `Prepare(10)`이 명확히 성공한 뒤 `Play(10)`이 한 번 실행되는지와 `OnScenePlayed`를 확인한다. +- PGM에서 원본과 비교할 object 이름별 값, 표시 상태, 색, 위치/크기, crop, path, image/texture/video, fade를 기록한다. +- Network Monitoring의 두 번째 load/prepare와 PLAY request/response, PGM 화면 시각을 같은 증거 묶음에 보존한다. + +### 4. NEXT + +앱이 반환한 `nextKind`가 기준이다. Web에서 임의로 page/playlist 유형을 정하지 않는다. + +- `PageNext`: playlist index를 유지하고 원본 `Next_Scene(0)`처럼 다음 page의 fresh 데이터를 조회한 뒤 새 scene을 `LoadScene` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. 이 경로에서 `GetPlayingScene` in-place 갱신을 기대하지 않는다. +- `PlaylistNext`: 현재 page가 마지막일 때 다음 활성 playlist 항목을 load/prepare/play한다. 비활성 항목은 앞으로 건너뛰고 끝에서 wrap하지 않는다. +- 각 NEXT는 Gate A에 승인된 유형과 횟수 안에서만 한 번씩 실행한다. +- `pageIndex`, `pageCount`, `itemCount`, 마지막 부분 page의 빈 object clear/hide와 PGM 화면을 함께 확인한다. +- 모든 operator command는 timer를 먼저 멈춘다. TAKE IN/playlist NEXT 뒤에는 원본 `m_time`으로 첫 timer가 시작되지만 Page NEXT 성공 뒤에는 timer가 정지 상태로 남아야 한다. + +### 5. Timer refresh + +- 승인 범위에 포함된 경우에만 자동 refresh를 관찰한다. 첫 실행은 해당 cut의 원본 `m_time` 뒤, 이후 성공한 실행은 3초 간격이어야 한다. +- 각 tick은 current entry/page의 fresh DB DTO를 사용하며 K3D 호출은 원본처럼 `Play(10)` → `GetPlayingScene(10)` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)` 순서다. +- timer refresh는 scene-level background와 fade effect를 다시 적용하지 않는다. +- 앱의 `refreshActive`, `refreshNextAt`, `refreshLastSuccessAt`, fault code/message와 PGM 데이터를 함께 기록한다. +- refresh가 실패하거나 timeout/unknown이면 fault latch가 켜지고 반복이 즉시 끝나야 한다. TAKE OUT 이외의 명령을 시도하지 않는다. +- 명확히 성공한 TAKE OUT 뒤 native refresh state가 reset되면 전용 refresh fault marker만 사라져야 한다. `OutcomeUnknown`, `WEB_TIMEOUT` 또는 native quarantine 표시는 함께 reset되면 안 된다. + +### 6. TAKE OUT과 Disconnect + +- 승인된 TAKE OUT 한 번으로 `TakeOut(All)`/`StopAll`을 실행한다. +- `OnCutOut` 또는 `OnStopAll`, 앱 state clear와 PGM의 검은 화면 또는 승인된 종료 상태를 확인한다. +- pending callback과 retired scene 정리가 끝난 뒤에만 Disconnect한다. +- Network Monitoring에서 stop 계열 왕복과 BYE/disconnect를 확인한다. + +## Network Monitoring과 PGM 증거 체크리스트 + +vendor monitor의 실제 명령 표기는 버전에 따라 다를 수 있으므로 추정 문자열로 성공을 만들지 않는다. raw 화면과 시각을 보존하고 다음 의미 단위로 대조한다. + +| 단계 | Network Monitoring | PGM/앱 | +|---|---|---| +| Connect | HELLO 또는 대응 connect request/response 한 쌍 | 앱 Connected, 대상 process generation 일치 | +| PREPARE | scene load, mutation transaction, prepare의 중복 없는 왕복 | Prepared cue/page/row 수 일치, 의도하지 않은 on-air 없음 | +| TAKE IN | fresh DB 결과의 두 번째 load/prepare 뒤 PLAY request/response | `OnScenePlayed`, 실제 object 값과 시각 상태 일치 | +| Page NEXT | 같은 alias의 새 scene load/transaction/prepare/play 왕복 | playlist index 유지, page만 +1, 빈 slot clear/hide, refresh timer 정지 | +| Playlist NEXT | 다음 cut load/prepare/play 왕복 | 다음 활성 항목과 page 0 표시 | +| Timer refresh | PLAY, `GetPlayingScene` update transaction, prepare, PLAY의 중복 없는 왕복 | 첫 `m_time` 뒤 실행, 이후 3초, 같은 entry/page의 fresh 값, refresh 상태 정상 | +| TAKE OUT | STOP/STOPALL 대응 왕복 | `OnCutOut`/`OnStopAll`, 승인된 종료 화면 | +| Disconnect | BYE 또는 대응 disconnect 왕복 | callback pending 0, 연결 해제 | + +회차 증거에는 다음을 포함한다. + +- 회차 ID, package version, Git commit, 실행 시각과 timezone +- cut alias, closed selector, page index/count, DB 조회 식별값; password와 connection string 제외 +- 각 앱 명령의 결과 code와 `OutcomeUnknown` 여부 +- 같은 시각의 Network Monitoring과 PGM 화면 +- PREPARE/TAKE IN/NEXT/timer refresh/TAKE OUT 전후 screenshot 또는 연속 capture +- callback 순서, retired/unloaded scene 수, disconnect 결과 +- Web의 frozen entry/builder/page size/current rows/last-page/preview와 refresh status; asset path는 제외 +- 운영자 최종 판정과 불일치 목록 + +증거는 Git 제외 로컬 경로 또는 승인된 증거 저장소에 보관한다. 캡처에 host, port, 계정, license 정보가 보이면 외부 전달 전에 별도 보안 절차로 처리하며 원본 증거를 repo에 넣지 않는다. + +## 장애 판정과 반복 금지 + +| 상황 | 즉시 조치 | 재시도 조건 | +|---|---|---| +| DB/selector/asset preflight 실패, COM 호출 전 명시적 `Rejected` | 회차 중단, DryRun으로 복귀, 원인 기록 | offline 수정과 전체 preflight 후 새 회차 승인 | +| Connect timeout 또는 `OnHello`/monitor 불일치 | 결과를 불명확으로 격리, Connect 반복 금지 | 운영자가 세션과 process/port 상태를 확인한 뒤 새 회차 승인 | +| PREPARE/TAKE IN/NEXT/refresh/TAKE OUT timeout | `OutcomeUnknown`으로 취급, 같은 명령과 반대 명령 자동 실행 금지 | PGM/monitor/콜백을 사람이 확인하고 출력 안전을 복구한 뒤 새 회차 승인 | +| `WEB_TIMEOUT` | strict timeout-quarantine message를 native에 전달. MainWindow가 process-lifetime latch를 먼저 세우고 vendor session을 quarantine; 늦은 응답, UI 재시도와 WebView reload로 해제 금지 | native status와 PGM을 사람이 대조하고 process를 새로 시작한 뒤 새 승인 | +| refresh DB/scene/COM fault | refresh loop 중단, fault latch 유지, TAKE OUT 외 명령 금지 | 정상 TAKE OUT과 실제 화면 확인 후 offline 원인 수정, 새 회차 승인 | +| 명령 성공 응답과 PGM 화면 불일치 | 앱 state를 신뢰하지 말고 회차 중단 | PGM 운영자가 실제 상태를 판정하고 새 회차 승인 | +| callback 누락/지연 | scene을 unload하거나 disconnect 강제하지 않음 | callback 도착 또는 운영자의 수동 안전 판정 후 새 회차 | +| Tornado process generation, 창, LISTEN 소유권 변경 | 모든 자동 동작 중지, 대상 격리 | 새 process를 처음부터 검증하고 새 승인 | +| 연결 장애/Disconnect timeout | 자동 reconnect 금지, 연결·출력 상태를 불명확으로 기록 | 운영자가 Tornado 세션을 확인한 뒤 새 회차 | + +`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를 재시작해 표시를 지우는 행위도 실제 출력 복구가 아니다. + +## Rollback과 unload + +### 명확히 출력 전 실패한 경우 + +COM 명령 전 validation에서 명시적으로 거부됐고 Network Monitoring/PGM에 변화가 없음을 확인한 경우에만 offline 수정으로 돌아간다. 수정 후에는 기존 회차를 이어가지 않고 새 회차로 시작한다. + +### 출력이 명확히 active이고 엔진이 정상인 경우 + +Gate A에 포함된 TAKE OUT을 한 번 실행한다. 성공 callback과 PGM 종료 화면을 확인한 뒤 Disconnect한다. 같은 TAKE OUT을 확인용으로 반복하지 않는다. + +### 결과가 불명확한 경우 + +1. 앱에서 추가 PREPARE, TAKE IN, NEXT, timer refresh, TAKE OUT, Disconnect를 자동으로 보내지 않는다. +2. 현재 앱 status, 마지막 명령, Network Monitoring, PGM을 캡처한다. +3. 방송 운영자가 Tornado 본 프로그램/PGM에서 실제 출력과 세션을 판정한다. +4. 필요한 수동 정리는 방송 운영 권한과 현장 절차로 수행한다. Codex나 앱이 임의 명령을 추정하지 않는다. +5. 안전한 black/approved fallback과 세션 종료가 사람에게 확인된 뒤 앱을 종료한다. +6. 장애 원인과 조치가 확정될 때까지 새 검증을 승인하지 않는다. + +### Scene 수명 규칙 + +- `OnScenePlayed`가 새 scene의 재생을 확정하면 이전 scene만 retired queue로 이동한다. +- `OnCutOut`은 해당 layout, `OnStopAll`은 전체 player의 on-air 참조를 정리할 근거다. +- pending Play callback이 있으면 TAKE OUT 이외의 명령을 fail-closed 차단한다. pending `OnScenePlayed`/`OnCutOut`/`OnStopAll`이 있으면 Disconnect와 unload를 시도하지 않는다. +- `CutOut`/`StopAll` pending counter는 SDK dispatch 직전에 증가하고 동기 호출 실패 시 감소한다. 성공 callback은 대응 counter를 하나 감소시키고 중단된 Play의 pending accounting을 취소한다. dispatch 뒤 cancellation/timeout이면 counter 상태를 성공으로 추정하지 않는다. +- 이전 connection generation에서 늦게 온 callback은 현재 generation의 state나 scene을 정리하는 근거로 쓰지 않는다. +- 장기 실행 중 retired scene은 callback으로 안전성이 확인된 뒤 STA queue 안에서 `Unload`와 release를 수행한다. +- 강제 GC, 임의 RCW release, 현재 on-air scene unload를 rollback으로 사용하지 않는다. + +## 완료 판정 + +실제 검증 회차는 다음을 모두 만족할 때만 성공이다. + +1. Gate A와 Gate B가 같은 회차에 기록돼 있다. +2. 허용된 test cut과 selector만 사용했다. +3. PREPARE → fresh DB TAKE IN → 승인된 Page NEXT/playlist NEXT → 원본 간격 timer refresh → TAKE OUT 순서가 중복 없이 완료됐다. +4. 모든 결과가 명확한 성공이며 `OutcomeUnknown=false`다. +5. Network Monitoring request/response와 PGM 화면이 같은 시각 증거로 남아 있다. +6. 원본과 object 값, 표시, 색, 위치/크기, crop, path, asset, fade, page와 refresh 상태가 일치한다. Web preview에는 실제 asset path가 없다. +7. callback과 unload 순서가 정상이고 Disconnect가 성공했다. +8. DB password, vendor DLL/license, 인증서/private key, 실제 cut/asset, 운영 설정이 Git 변경에 포함되지 않았다. + +검증 후 결과를 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md)의 해당 행에 기록한다. 일부 scene 성공이나 과거 runner 성공으로 나머지 행을 일괄 완료 처리하지 않는다. diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md new file mode 100644 index 0000000..b5516ba --- /dev/null +++ b/docs/SCENE_EQUIVALENCE.md @@ -0,0 +1,168 @@ +# 35개 Scene 동등성 완료 매트릭스 + +기준 시각은 2026-07-10이다. 원본 `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, 자동 테스트가 모두 존재한다는 뜻이다. **동등성 완료**는 여기에 실제 데이터와 승인된 Tornado2/PGM 검증까지 통과해야 한다. 따라서 현재 전체 목표는 아직 완료가 아니다. + +## 현재 판정 + +| 항목 | 상태 | 근거와 제한 | +|---|---|---| +| 원본 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 진단 전용 | +| 자동 테스트 | 완료 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 64/64, Web safety 11/11 통과. 테스트 추가에 따라 개수는 달라질 수 있으며 핵심 판정은 각 suite의 실패 0건이다. | +| Visual Studio 2026 빌드 | 완료 | Debug/Release x64 빌드 성공 | +| Release x64 MSIX | 완료 | trusted 개발 MSIX 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 `5001 PREPARE → fresh TAKE IN → timer refresh → 5074 playlist NEXT → Page NEXT → TAKE OUT`과 5074의 `1/20`~`20/20`, 마지막 `END OF PLAYLIST`/NEXT 비활성, 편집 잠금 해제를 확인 | +| 실제 데이터 전체 장면 smoke | 완료 | 34개 도달 가능 builder 전체 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic도 통과했고 실제 Oracle/MariaDB query 55건이 모두 성공했다. | +| 이번 마이그레이션의 실제 Tornado2/PGM | **미승인·미실행** | 현재 WebView → native workflow로 실제 PREPARE/fresh TAKE IN/Page NEXT/playlist NEXT/timer refresh/TAKE OUT, Network Monitoring, PGM 화면을 함께 검증하지 않음 | + +과거 고정 runner로 수행한 `5001 → 5006` PGM 왕복은 K3D 연결과 기본 load/play/stop 경로의 증거일 뿐이다. 35개 builder, 실제 DB mutation, Page NEXT와 현재 WebView workflow의 동등성 증거로 재사용하지 않는다. + +## 표기 + +Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM-neutral 모델을 뜻한다. + +| 약어 | mutation | +|---|---| +| `V` | `PlayoutSetValue` | +| `A` | `PlayoutSetAssetValue` | +| `Vis` | `PlayoutSetVisible` | +| `C` | `PlayoutSetFaceColor` | +| `Pos` | `PlayoutSetPosition` | +| `PosK` | `PlayoutSetPositionKey` | +| `Scale` | `PlayoutSetScale` | +| `Crop` | `PlayoutSetCropKey` | +| `Angle` | `PlayoutSetCircleAngleKey` | +| `Path` | `PlayoutSetPathPoints` | +| `Shape` | `PlayoutSetPathShapePoints` | +| `BgV` | `PlayoutSetBackgroundVideo` 및 background 사용 상태 | + +실제 데이터 상태는 다음과 같이 기록한다. + +- `DB-P`: 실제 Oracle/MariaDB 조회 → typed DTO → mutation preflight가 통과했다. +- `FILE-P`: `s5025`의 trusted 외부 CP949 수동 파일 → DTO → mutation preflight가 통과했다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다. +- `DB-DP`: 원본 MainForm에서 도달하지 않는 `s8086` diagnostic 조회와 mutation preflight가 통과했다. 이 결과로 runtime alias를 만들지는 않는다. +- `TOR-W`: 이번 마이그레이션 runtime으로 해당 scene을 실제 Tornado2/PGM에서 검증하지 않았다. 회차 승인 전에는 실행하지 않는다. +- `TOR-NA`: active alias가 없어 운영 송출 대상이 아니다. + +## 자동 테스트 묶음 + +아래 묶음은 최신 Core Debug/Release x64 전체 실행에 포함돼 실패 0건으로 통과했다. 모든 행에는 공통으로 `LegacySceneCatalogTests`, `LegacySceneMutationBuilderRegistryTests`, `LegacySceneRuntimeCoverageTests`, `LegacySceneDataSourceRouterTests`가 적용된다. + +| 코드 | 테스트 파일 | +|---|---| +| `T-PARAM` | `ParameterizedMarketSceneBuildersTests`, `ParameterizedMarketSceneDataLoadersTests`, `LegacyParameterizedSceneRequestResolverTests` | +| `T-PANEL` | `ReadOnlyPanelSceneBuildersTests`, `EquityPanelSceneDataLoadersTests`, `PanelSceneDataLoadersTests`, `MarketPanelSceneDataLoadersTests` | +| `T-GRID` | `TabularMarketSceneBuildersTests`, `FoundationalSceneBuildersTests`, `GridMarketSceneDataLoadersTests`, `TraderQuoteSceneDataLoadersTests`, `LegacyGridMarketSceneRequestResolverTests` | +| `T-MANUAL` | `TrustedManualSceneDataLoader5025Tests`, Infrastructure의 `S5025TrustedManualFileDataSourceTests` | +| `T-COMP` | `ComparisonAndYieldSceneBuildersTests`, `ComparisonAndYieldSceneDataLoadersTests`, `ComparisonAndYieldLegacyRequestResolverTests` | +| `T-CHART` | `ChartSceneBuilders5078To5084Tests`, `ChartSceneDataLoadersTests`, `ChartLegacySceneRequestResolverTests` | +| `T-PAGED` | `PagedQuoteSceneBuildersTests`, `PagedQuoteSceneDataLoadersTests`, `ScenePagingTests`, `LegacyPlayoutWorkflowTests` | +| `T-FOUND` | `FoundationalSceneBuildersTests`, `ManualSceneDataLoadersTests`, 해당 panel/grid loader 테스트 | +| `T-5082` | `GridSceneBuilder5082Tests` | +| `T-CANDLE` | `CandleSceneBuilderTests`, `CandleSceneDataLoaderTests` | + +## 35개 builder별 완료 판정 + +| Builder / alias / page | 원본 기능 | 새 builder → loader → resolver/route | mutation | 자동 테스트 | 실제 데이터 | 실제 Tornado | +|---|---|---|---|---|---|---| +| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` | +| `s5006` / `5006` / — | 국내·NXT 종목 현재·시가·고가·저가와 비율, 등락 상태, 큐브 배경 영상 | `S5006SceneMutationBuilder` → `S5006DomesticSceneDataLoader`/`S5006NxtSceneDataLoader` → market 직접 route | `V, Vis, C, BgV` | `T-PANEL` 통과 | `DB-P`; `Video\큐브배경.vrv` 외부 자산 누락 | `TOR-W` | +| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-W` | +| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-W` | +| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-W` | +| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-W` | +| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-W` | +| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-W` | +| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-W` | +| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | +| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` | +| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-W` | +| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` | +| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-W` | +| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` | +| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-W` | +| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` | +| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-W` | +| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-W` | +| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-W` | +| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` | +| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` | +| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-W` | +| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | +| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | +| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | +| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` | +| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-W` | +| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-W` | +| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-W` | +| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-W` | +| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-W` | +| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` | +| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-W` | +| `s8086` / active alias 없음 / — | 유가·금 3열 원천. 원본 파일은 있으나 MainForm dispatch 없음 | `S8086SceneMutationBuilder` → `S8086DiagnosticSceneDataLoader`; 앱 runtime route 없음 | `V, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-DP` | `TOR-NA` | + +## 공통 호출 순서 동등성 + +모든 runtime scene은 Web에서 object 이름이나 K3D 메서드를 받지 않는다. Web은 closed playlist selection만 보내고, loader가 조회 결과를 명시적 DTO로 만들며, registry의 typed builder가 allowlisted mutation을 생성한다. + +PREPARE의 공통 순서는 다음과 같다. + +1. 활성 playlist 항목과 cut alias를 결정하고 native loader로 page DTO를 조회한다. +2. scene을 load하고 cue-level background와 fade/scene effect를 적용한다. +3. `BeginTransaction`을 호출한다. +4. builder가 생성한 mutation을 원본 순서대로 적용한다. +5. `QueryVariables`를 호출한다. +6. `EndTransaction`을 호출한다. +7. layout 10을 `Prepare`한다. + +TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원본 `ONAirMode`와 같이 현재 playlist snapshot의 같은 entry/page를 실제 DB에서 다시 조회하고 새 scene을 `LoadScene` → transaction → `Prepare(10)`한 뒤, 그 prepare가 명확히 성공한 경우에만 `Play(10)`을 한 번 호출한다. PREPARE를 다시 누르는 원본 toggle은 `TakeOut(All)`/`StopAll`로 state를 정리한다. timeout 또는 `OutcomeUnknown`은 retry 가능한 실패로 낮추지 않는다. + +공통 fade와 배경은 Web 입력이 아니라 로컬 trusted playout 설정에서만 온다. `legacySceneFadeDuration`의 원본 기본값은 6이며, `legacySceneBackgroundKind`, scene root 아래의 상대 `legacySceneBackgroundAssetPath`, video loop 설정을 `PlayoutSceneCompositionFactory`가 검증한다. `DryRun`에서도 경로 탈출, 절대 경로, 허용되지 않은 확장자, reparse point, 누락 파일을 실제 COM 전에 fail-closed preflight한다. asset 경로는 Web 상태와 preview에 노출하지 않는다. + +이 순서는 `RegisteredLegacySceneCueProviderTests`, `LegacyPlayoutWorkflowTests`, Playout의 `DynamicK3dSessionTests`, `TornadoPlayoutEngineTests`에서 검증한다. 실제 COM 형식과 vendor 구현은 계속 `IPlayoutEngine` 뒤에 있다. + +## PageN과 NEXT 동등성 + +페이지 대상은 `s5074`(5), `s5077`(6), `s5088`(12)이다. 일반 시장과 NXT 시장이 같은 계산기를 사용하며 page index는 0부터 시작한다. + +- `pageCount = min(20, ceil(itemCount / pageSize))`다. +- 경계값 `0`, `1`, `size-1`, `size`, `size+1`, `20*size`, `20*size+1`을 테스트한다. +- 마지막 부분 페이지는 남은 행만 채우고 나머지 object를 clear/hide한다. +- `s5088`의 NXT 인덱스는 비교와 조회 모두 `i + pageIndex * 12`를 사용한다. +- 현재 항목에 다음 page가 있으면 operator Page NEXT는 playlist index를 유지하되 원본 `Next_Scene(0)`처럼 다음 page 데이터를 조회하고 새 scene을 `LoadScene` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. 이 경로는 `GetPlayingScene` in-place refresh가 아니다. +- 마지막 page면 다음 활성 playlist 항목으로 이동한다. 비활성 항목은 원본처럼 앞으로 건너뛰며 끝에서 wrap하지 않는다. +- 모든 operator command는 기존 refresh timer를 먼저 멈춘다. TAKE IN과 playlist NEXT 성공 뒤에는 해당 cut의 원본 `m_time`으로 첫 timer를 시작하고, 첫 성공 뒤부터 3초 간격으로 갱신한다. Page NEXT 성공 뒤에는 timer를 다시 시작하지 않는다. +- timer refresh는 current entry/page를 fresh DB DTO로 다시 만든 뒤, 원본 `timer1_Tick`의 K3D 호출 순서인 `Play(10)` → `GetPlayingScene(10)` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`으로 on-air scene을 갱신한다. + +## WebView 동등성 및 안전 상태 + +- Web catalog는 35개 builder와 도달 가능한 45개 active cut alias를 모두 보존하며 `s8086`은 선택 불가 진단 항목으로 유지한다. +- 각 playlist row는 원본 active flag에 대응하는 `enabled`를 제공한다. PREPARE 성공 시 전체 playlist와 선택 index를 immutable native snapshot으로 고정한다. pending command 또는 `OutcomeUnknown`/timeout quarantine 중에도 snapshot 편집을 잠그며, 그 상태가 해제될 때까지 Web 편집값으로 NEXT 대상을 바꾸지 않는다. +- native 결과가 authoritative source이며 `currentEntryId`, `builderKey`, `pageSize`, `currentPageItemCount`, `isLastPage`, `nextKind`와 bounded mutation preview를 표시한다. +- preview는 object별 typed 값/상태를 보여주되 image, texture, video와 공통 background의 실제 asset path는 항상 `configured` 같은 비밀 없는 상태로 치환한다. +- refresh의 active/next/last-success/fault 상태를 표시한다. refresh DB·scene·COM 실패는 fault latch를 세워 TAKE OUT 외의 mutation 명령을 막고 자동 반복하지 않는다. 성공한 TAKE OUT 뒤 native refresh state가 reset되면 전용 refresh fault marker만 지우며 `OutcomeUnknown`이나 timeout quarantine은 함께 지우지 않는다. +- native 응답 상관관계가 Web 제한 시간 안에 끝나지 않으면 `WEB_TIMEOUT`을 native에 보고한다. `ParseTimeoutQuarantine`이 검증한 요청은 MainWindow의 process-lifetime latch를 먼저 세운 뒤 vendor session을 quarantine하므로 WebView reload로 해제되지 않는다. 늦은 응답이나 UI 재시도로 같은 명령을 다시 보내지 않으며 authoritative native/PGM 상태를 사람이 확인한다. + +## Callback과 scene 수명 + +- `OnScenePlayed`가 성공한 뒤 이전 scene을 retired queue로 옮겨 안전하게 unload한다. +- `OnCutOut`은 해당 layout의 on-air 참조를, `OnStopAll`은 player의 모든 on-air 참조를 정리할 근거다. +- 이전 connection generation의 늦은 callback은 현재 state를 바꾸지 않는다. +- pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다. 어떤 lifecycle callback이든 pending이면 Disconnect와 session recycle을 하지 않고 abandon/quarantine 및 `OutcomeUnknown`으로 보수적으로 전환한다. +- `StopAll`/`CutOut`은 SDK dispatch 전에 각 pending completion counter를 올리고 동기 호출 실패 시 되돌린다. 성공 callback은 대응 counter를 정확히 하나 줄이며, 성공한 stop/cut은 중단된 Play가 뒤늦게 `OnScenePlayed`를 보내지 않을 수 있으므로 해당 pending Play accounting을 취소한다. dispatch 뒤 cancellation/timeout은 결과 불명확으로 취급한다. +- 장기 실행 시 retired scene은 callback으로 안전성이 확인된 뒤 unload/release한다. + +운영·장애 복구·승인 절차는 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)를 따른다. + +## 남은 완료 조건 + +다음 항목이 끝나기 전에는 이 문서의 전체 상태를 동등성 완료로 바꾸지 않는다. + +1. 승인된 외부 asset root에 `s5006`의 `Video\큐브배경.vrv`가 제공돼야 한다. +2. 설치된 Release x64 MSIX의 WebView workflow로 승인된 테스트 scene에 대해 PREPARE → fresh TAKE IN → Page NEXT/playlist NEXT → timer refresh → TAKE OUT을 실행해야 한다. +3. 같은 회차의 Tornado2 Network Monitoring 명령/응답과 PGM 데이터·페이지·종료 화면을 함께 보존하고 비교해야 한다. +4. timeout, `OutcomeUnknown`, refresh fault, callback/연결 장애 복구 절차를 실제 검증 결과에 적용하고 운영자가 판정해야 한다. + +실제 DB/CP949 통합 검증과 자동 suite는 완료됐지만, 위 실제 Tornado2 검증은 여전히 회차 승인 전이며 실행하지 않았다. diff --git a/docs/legacy-scene-source-hashes.json b/docs/legacy-scene-source-hashes.json new file mode 100644 index 0000000..46751ba --- /dev/null +++ b/docs/legacy-scene-source-hashes.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "sourceRootHint": "MBN_STOCK_N/MBN_STOCK_N/Scene (external read-only source)", + "algorithm": "SHA-256", + "builders": [ + { "builder": "s5001", "sourceFile": "Scene/s5001.cs", "sha256": "70A8DEBCF3E68E3EEDE93DF2B0D5F49F48800F01E75273D7B06BF9032B7136EC" }, + { "builder": "s5006", "sourceFile": "Scene/s5006.cs", "sha256": "FCF9001E837B5D48C724867B8D3717E87E893732E3685CD82057E12C9B2E4E10" }, + { "builder": "s5011", "sourceFile": "Scene/s5011.cs", "sha256": "A2DA771481016E6F009BE2E6B0C66A67EA836A5AEA343CD3046BC2E61D869AA6" }, + { "builder": "s5016", "sourceFile": "Scene/s5016.cs", "sha256": "43E7113E495A379C2D1A0F0B2F07AA244104BA1286DE3FCCE18225D74934C7A5" }, + { "builder": "s50160", "sourceFile": "Scene/s50160.cs", "sha256": "3A5F3591F3678AF2578224A9E01C233BE6A99185E3D96C1CEA1D716D06CEF42C" }, + { "builder": "s5023", "sourceFile": "Scene/s5023.cs", "sha256": "7FF3855BB9B1D63314B28B3614CB887E82855D0EF65FB40DE07F8FDB2973F678" }, + { "builder": "s5024", "sourceFile": "Scene/s5024.cs", "sha256": "306062555EAAC5AEC1EFAC4D6C75A930D207C5CF62652BB3E1531CFF60DD7A42" }, + { "builder": "s5025", "sourceFile": "Scene/s5025.cs", "sha256": "123EEAAEB40EA3D43AB85F098BE26838D711927F3F4D50A353F9DB44E873D011" }, + { "builder": "s5026", "sourceFile": "Scene/s5026.cs", "sha256": "F56550975963B0787D15901175747F4A5725C364E0AA83BA41D4C5588D8FE409" }, + { "builder": "s5029", "sourceFile": "Scene/s5029.cs", "sha256": "84E566A2AA7D960CA5136710B8E50466C4A8757E7E63B494B4AA0771C6BE985E" }, + { "builder": "s5032", "sourceFile": "Scene/s5032.cs", "sha256": "1199B47E833C9B2ECF70772D338DD22D82D25E4D2700C4B47B49FD78D7198576" }, + { "builder": "s5037", "sourceFile": "Scene/s5037.cs", "sha256": "62E442802D4CE329782B2B3CA95890EECCB469A94FBF6E3085F2385837EAFBD5" }, + { "builder": "s5074", "sourceFile": "Scene/s5074.cs", "sha256": "A9DF084C412F53DCB12AFF59B6D60B093EC793636D984F684C51EF383EAD7F5D" }, + { "builder": "s5076", "sourceFile": "Scene/s5076.cs", "sha256": "4C1212F3062C8B02004882688BA07488E0A6C79467AC79D2341A869D47A7E8FC" }, + { "builder": "s5077", "sourceFile": "Scene/s5077.cs", "sha256": "F806AF7A406E56C9523E236543D98B87148687D3BDB4F7914C1B303A54F3DDBC" }, + { "builder": "s5078", "sourceFile": "Scene/s5078.cs", "sha256": "F233605E97C74CB502192EB540100E20F09E734EDC13A4579296705217B866C4" }, + { "builder": "s5079", "sourceFile": "Scene/s5079.cs", "sha256": "848359AA045B2F98D02D36A425FFD3FEEA24E98151680654141F7FF831E81537" }, + { "builder": "s5080", "sourceFile": "Scene/s5080.cs", "sha256": "5BEBC6C222A43D00BD14D02416407D934CBB7555E488ADCCC8CDD8C0DE88581F" }, + { "builder": "s5081", "sourceFile": "Scene/s5081.cs", "sha256": "58413871174A117F619D0D4FF8040600FCF8F3C97349D0DBA4FFB3D7554A1090" }, + { "builder": "s5082", "sourceFile": "Scene/s5082.cs", "sha256": "0A9D6D473053D1A737527432913A1D9153113E51A096410D9C964DF4DE4027D4" }, + { "builder": "s5083", "sourceFile": "Scene/s5083.cs", "sha256": "7EACE01BA34CECC30E6B8F9895ED400A8E25376BC7616789A8130AD46AF2349E" }, + { "builder": "s5084", "sourceFile": "Scene/s5084.cs", "sha256": "DE8AFF7CCA22400E6F9F4CECCBC136D48646AFEA7FD5DE7E5B8282B65B62947F" }, + { "builder": "s5085", "sourceFile": "Scene/s5085.cs", "sha256": "45EB9637B4C5D7DF6C7EAA8F45C3D51E58968F50F75DDBCEC3015B09E6C5085C" }, + { "builder": "s5086", "sourceFile": "Scene/s5086.cs", "sha256": "A6E390000D810E34E04267E309181C7B58F092EC682960DA8C4D7A7D9A0CEE91" }, + { "builder": "s50860", "sourceFile": "Scene/s50860.cs", "sha256": "B9498FD3BCFCF3C7716CC9C070FF43D7DD22047DDAD2E02B04B6DFBDFAA5D91E" }, + { "builder": "s5087", "sourceFile": "Scene/s5087.cs", "sha256": "75BEB7843D373A26355B848280DED477EBF79E2569A06623E8E4715351A8B18B" }, + { "builder": "s5088", "sourceFile": "Scene/s5088.cs", "sha256": "8F8BF4204056D04D6ECA6681735B52A86C0B6EB14187E54F43685B13B57FA3D1" }, + { "builder": "s6001", "sourceFile": "Scene/s6001.cs", "sha256": "1FEC555C61D78C5C0E605687CC30B81B0E46D9D45506F663A80D054E32CB9EDF" }, + { "builder": "s6067", "sourceFile": "Scene/s6067.cs", "sha256": "D37CA7D6D719ABEEC52F8A38865CAD84913E04A5281D4AA852881EA0EED5E852" }, + { "builder": "s8001", "sourceFile": "Scene/s8001.cs", "sha256": "EE70C6D8AF855948184E1291FAF03C7D8D084E2246B3FDC03D3029F406CE0C16" }, + { "builder": "s8003", "sourceFile": "Scene/s8003.cs", "sha256": "03A01A164CB25F4B22630227B718EB7297C4224EDA0A1397CC99C79159A13A60" }, + { "builder": "s8010", "sourceFile": "Scene/s8010.cs", "sha256": "CC28074583D84F630FBD7CBC6A3364EB66AB4E82B9F6F387A81E414EEB0AC448" }, + { "builder": "s8018", "sourceFile": "Scene/s8018.cs", "sha256": "01B3EE85D728CCA922393FE80303475E959D578BEAED83ABA937BB20BC290170" }, + { "builder": "s8067", "sourceFile": "Scene/s8067.cs", "sha256": "1E8A7C4367AA7E56FEE1DF0C13CACD28FC243E393850B865D6585FCFF570D465" }, + { "builder": "s8086", "sourceFile": "Scene/s8086.cs", "sha256": "5373B8CBFF07163A2719317EBB69A57011A0006726C9899F9F63357C6AA52D3B" } + ] +} diff --git a/scripts/Test-LegacyCutCoverage.ps1 b/scripts/Test-LegacyCutCoverage.ps1 new file mode 100644 index 0000000..617cfc0 --- /dev/null +++ b/scripts/Test-LegacyCutCoverage.ps1 @@ -0,0 +1,60 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $CutRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not [IO.Path]::IsPathRooted($CutRoot)) { + throw 'CutRoot must be an absolute path.' +} + +$root = [IO.Path]::GetFullPath($CutRoot) +if (-not (Test-Path -LiteralPath $root -PathType Container)) { + throw 'CutRoot does not identify an existing directory.' +} + +# Active MainForm aliases for the 34 reachable builders. s8086 intentionally has no alias. +$aliases = @( + '5001', 'N5001', '5006', '5011', '5016', '50160', + '5023', '5024', '5025', '5026', '5029', + '8018', '8032', '5032', '5037', + '5074', '5076', '5077', '5078', '5079', + '5080', '5081', '5082', '5083', '5084', '5085', + '5086', '50860', '5087', '5088', + '6001', '6067', '8001', '8002', '8003', + '8035', '8061', '8040', '8046', '8051', '8056', + '8067', '5068', '5070', '5072' +) + +$missing = [Collections.Generic.List[string]]::new() +$unsafe = [Collections.Generic.List[string]]::new() +foreach ($alias in $aliases) { + $path = Join-Path $root ($alias + '.t2s') + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + $missing.Add($alias) + continue + } + + $item = Get-Item -LiteralPath $path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0) { + $unsafe.Add($alias) + } +} + +if ($missing.Count -ne 0 -or $unsafe.Count -ne 0) { + throw "Legacy cut coverage failed. Missing=$($missing.Count), Unsafe=$($unsafe.Count)." +} + +[pscustomobject][ordered]@{ + Status = 'Passed' + ReachableBuilders = 34 + ActiveAliases = $aliases.Count + Missing = $missing.Count + Unsafe = $unsafe.Count +} diff --git a/scripts/Test-LegacySceneBaseline.ps1 b/scripts/Test-LegacySceneBaseline.ps1 new file mode 100644 index 0000000..38b9caa --- /dev/null +++ b/scripts/Test-LegacySceneBaseline.ps1 @@ -0,0 +1,52 @@ +[CmdletBinding()] +param( + [string] $LegacyProjectRoot, + [string] $ManifestPath +) + +$ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($LegacyProjectRoot)) { + $LegacyProjectRoot = Join-Path $PSScriptRoot '..\..\MBN_STOCK_N\MBN_STOCK_N' +} +if ([string]::IsNullOrWhiteSpace($ManifestPath)) { + $ManifestPath = Join-Path $PSScriptRoot '..\docs\legacy-scene-source-hashes.json' +} +$legacyRoot = [IO.Path]::GetFullPath($LegacyProjectRoot) +$manifestFile = [IO.Path]::GetFullPath($ManifestPath) + +if (-not (Test-Path -LiteralPath $legacyRoot -PathType Container)) { + throw 'The read-only legacy project root was not found.' +} + +if (-not (Test-Path -LiteralPath $manifestFile -PathType Leaf)) { + throw 'The legacy scene hash manifest was not found.' +} + +$manifest = Get-Content -LiteralPath $manifestFile -Raw -Encoding UTF8 | ConvertFrom-Json +if ($manifest.schemaVersion -ne 1 -or + $manifest.algorithm -ne 'SHA-256' -or + $manifest.builders.Count -ne 35) { + throw 'The legacy scene hash manifest is invalid.' +} + +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($entry in $manifest.builders) { + if (-not $seen.Add([string] $entry.builder)) { + throw 'The legacy scene hash manifest contains a duplicate builder.' + } + + $relativePath = ([string] $entry.sourceFile).Replace('/', [IO.Path]::DirectorySeparatorChar) + $sourceFile = [IO.Path]::GetFullPath((Join-Path $legacyRoot $relativePath)) + $rootPrefix = $legacyRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (-not $sourceFile.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) { + throw "Legacy source is missing for $($entry.builder)." + } + + $actual = (Get-FileHash -LiteralPath $sourceFile -Algorithm SHA256).Hash + if (-not $actual.Equals([string] $entry.sha256, [StringComparison]::OrdinalIgnoreCase)) { + throw "Legacy source baseline changed for $($entry.builder)." + } +} + +Write-Output "LEGACY_SCENE_BASELINE: PASS ($($seen.Count)/35)" diff --git a/scripts/Test-WebPlayout.ps1 b/scripts/Test-WebPlayout.ps1 index 80e68c8..2564454 100644 --- a/scripts/Test-WebPlayout.ps1 +++ b/scripts/Test-WebPlayout.ps1 @@ -56,6 +56,60 @@ foreach ($script in $scripts) { } } +$appScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\app.js') -Raw -Encoding UTF8 +$mainWindowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.xaml.cs') -Raw -Encoding UTF8 +$sceneCatalogMatches = [regex]::Matches( + $appScript, + 'builderKey:\s*"s[0-9]+"') +if ($sceneCatalogMatches.Count -ne 35) { + throw "Web scene catalog must contain exactly 35 builder entries; found $($sceneCatalogMatches.Count)." +} +$selectionPresetMatches = [regex]::Matches( + $appScript, + '(?m)^\s+s[0-9]+:\s+\[') +if ($selectionPresetMatches.Count -ne 34 -or + $appScript -notmatch 'Every reachable legacy builder must have a Web selection preset') { + throw "Web scene selection presets must cover exactly 34 reachable builders; found $($selectionPresetMatches.Count)." +} +if ($appScript -notmatch 'reachableAliases\.size !== 45' -or + $appScript -notmatch 'sceneCutAlias' -or + $appScript -notmatch 'sceneAliases\(definition\)\.includes\(storedCode\)' -or + $appScript -notmatch 'resolveStoredCatalogIndex') { + throw 'Web scene selection must expose and preserve all 45 active cut aliases.' +} +if ($appScript -match 'payload\.cue' -or + $appScript -notmatch 'payload\.playlist' -or + $appScript -notmatch 'payload\.selectedIndex') { + throw 'Web playout must send a PREPARE playlist snapshot and must not choose the NEXT cue.' +} +if ($appScript -notmatch 'const DEFAULT_FADE_DURATION = 6;' -or + $appScript -notmatch 'fadeDuration: DEFAULT_FADE_DURATION') { + throw 'Web playout default fade must match MainForm ComboDi.SelectedIndex 6.' +} +if ($appScript -notmatch 'normalizeScenePreviewFields' -or + $appScript -notmatch 'currentPageItemCount' -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 + $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 'postNative\("playout-timeout-quarantine", \{ requestId, command \}\)' -or + $appScript -notmatch 'browserCorrelationQuarantined') { + throw 'Web response timeouts must latch the process-lifetime native quarantine.' +} +if ($mainWindowScript -notmatch 'OnNavigationStarting' -or + $mainWindowScript -notmatch 'OnProcessFailed' -or + $mainWindowScript -notmatch 'ReloadBrowserSafely' -or + $mainWindowScript -notmatch 'QuarantineIfBrowserCorrelationCanBeLost' -or + $mainWindowScript -notmatch 'Volatile\.Read\(ref _playoutCommandInFlight\)') { + throw 'Reload, navigation, and WebView process failure must quarantine in-flight playout correlation.' +} + & $node --test $test if ($LASTEXITCODE -ne 0) { throw "Web playout tests failed with exit code $LASTEXITCODE." @@ -64,5 +118,7 @@ if ($LASTEXITCODE -ne 0) { [pscustomobject][ordered]@{ Status = 'Passed' SyntaxFiles = $scripts.Count + SceneCatalogEntries = $sceneCatalogMatches.Count + ReachableSelectionPresets = $selectionPresetMatches.Count TestFile = [IO.Path]::GetFileName($test) } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs new file mode 100644 index 0000000..ecb6d26 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs @@ -0,0 +1,418 @@ +#nullable enable + +using System.Collections.ObjectModel; +using System.Data; + +namespace MMoneyCoderSharp.Data; + +/// +/// Describes one provider-neutral input parameter for a read query. +/// Names are logical identifiers; the SQL supplies the provider marker +/// (:name for Oracle or @name for MariaDB). +/// +public sealed class DataQueryParameter +{ + private const int MaximumNameLength = 128; + + public DataQueryParameter(string name, object? value, DbType? dbType = null) + { + if (!IsValidName(name)) + { + throw new ArgumentException( + "A query parameter name is invalid.", + nameof(name)); + } + + if (dbType.HasValue && !Enum.IsDefined(dbType.Value)) + { + throw new ArgumentOutOfRangeException( + nameof(dbType), + "A query parameter database type is invalid."); + } + + Name = name; + Value = value; + DbType = dbType; + } + + public string Name { get; } + + public object? Value { get; } + + public DbType? DbType { get; } + + public override string ToString() => "Data query parameter"; + + internal static bool IsValidName(string? name) + { + if (string.IsNullOrEmpty(name) || name.Length > MaximumNameLength) + { + return false; + } + + if (!IsAsciiLetter(name[0]) && name[0] != '_') + { + return false; + } + + for (var index = 1; index < name.Length; index++) + { + var character = name[index]; + if (!IsAsciiLetter(character) && !char.IsAsciiDigit(character) && character != '_') + { + return false; + } + } + + return true; + } + + private static bool IsAsciiLetter(char character) => + character is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; +} + +/// +/// An immutable, validated SELECT/WITH query and its input parameters. +/// The deliberately conservative validation fails closed for ambiguous SQL. +/// Each logical parameter must occur exactly once; use distinct logical names +/// when one value is intentionally bound at multiple SQL positions. +/// +public sealed class DataQuerySpec +{ + private const int MaximumSqlLength = 65_536; + + private static readonly HashSet StateChangingTokens = new( + [ + "INSERT", + "UPDATE", + "DELETE", + "REPLACE", + "MERGE", + "CALL", + "EXEC", + "EXECUTE", + "CREATE", + "ALTER", + "DROP", + "TRUNCATE", + "GRANT", + "REVOKE", + "INTO", + "LOCK", + "FUNCTION", + "PROCEDURE", + "BEGIN", + "DECLARE", + "PRAGMA" + ], + StringComparer.OrdinalIgnoreCase); + + private readonly ReadOnlyCollection _references; + + public DataQuerySpec(string sql) + : this(sql, Array.Empty()) + { + } + + public DataQuerySpec(string sql, IEnumerable parameters) + { + if (string.IsNullOrWhiteSpace(sql) || sql.Length > MaximumSqlLength || sql.IndexOf('\0') >= 0) + { + throw InvalidSql(nameof(sql)); + } + + ArgumentNullException.ThrowIfNull(parameters); + + var suppliedParameters = parameters.ToArray(); + if (suppliedParameters.Any(static parameter => parameter is null)) + { + throw new ArgumentException( + "A query parameter collection is invalid.", + nameof(parameters)); + } + + var parametersByName = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var parameter in suppliedParameters) + { + if (!parametersByName.TryAdd(parameter.Name, parameter)) + { + throw new ArgumentException( + "A query parameter collection is invalid.", + nameof(parameters)); + } + } + + var parsed = Parse(sql); + if (parsed.Tokens.Count == 0 || + (!string.Equals(parsed.Tokens[0], "SELECT", StringComparison.OrdinalIgnoreCase) && + !string.Equals(parsed.Tokens[0], "WITH", StringComparison.OrdinalIgnoreCase)) || + parsed.Tokens.Any(StateChangingTokens.Contains)) + { + throw InvalidSql(nameof(sql)); + } + + var referencedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in parsed.References) + { + if (!referencedNames.Add(reference.Name) || !parametersByName.ContainsKey(reference.Name)) + { + throw InvalidSql(nameof(sql)); + } + } + + if (referencedNames.Count != parametersByName.Count) + { + throw new ArgumentException( + "A query parameter collection does not match the query.", + nameof(parameters)); + } + + // Preserve SQL placeholder order. This also keeps positional Oracle binding + // deterministic without constructing provider-specific parameter objects. + var orderedParameters = parsed.References + .Select(reference => parametersByName[reference.Name]) + .ToArray(); + + Sql = sql; + Parameters = Array.AsReadOnly(orderedParameters); + _references = Array.AsReadOnly(parsed.References.ToArray()); + } + + public string Sql { get; } + + public IReadOnlyList Parameters { get; } + + /// + /// Verifies that all placeholders use the selected provider's marker. + /// + public void ValidateFor(DataSourceKind source) + { + var expectedMarker = source switch + { + DataSourceKind.Oracle => ':', + DataSourceKind.MariaDb => '@', + _ => throw new ArgumentOutOfRangeException( + nameof(source), + "The query data source is invalid.") + }; + + if (_references.Any(reference => reference.Marker != expectedMarker)) + { + throw InvalidSql(nameof(source)); + } + } + + public override string ToString() => "Parameterized read query"; + + private static ParsedSql Parse(string sql) + { + var tokens = new List(); + var references = new List(); + + for (var index = 0; index < sql.Length;) + { + var current = sql[index]; + + if (IsPortableSqlWhitespace(current)) + { + index++; + continue; + } + + if (current > 0x7f) + { + // Unquoted identifiers and whitespace differ between providers. + // Unicode remains supported inside quoted text and comments only. + throw InvalidSql(nameof(sql)); + } + + if (current == '-' && index + 1 < sql.Length && sql[index + 1] == '-') + { + if (index + 2 < sql.Length && !IsPortableLineCommentWhitespace(sql[index + 2])) + { + // MariaDB only recognizes -- as a comment when whitespace follows. + // Reject the cross-provider ambiguity instead of skipping SQL text. + throw InvalidSql(nameof(sql)); + } + + index += 2; + while (index < sql.Length && sql[index] is not '\r' and not '\n') + { + index++; + } + + continue; + } + + if (current == '/' && index + 1 < sql.Length && sql[index + 1] == '*') + { + if ((index + 2 < sql.Length && sql[index + 2] == '!') || + (index + 3 < sql.Length && + (sql[index + 2] is 'M' or 'm') && + sql[index + 3] == '!')) + { + // MySQL/MariaDB execute version and /*M! comments as SQL. + throw InvalidSql(nameof(sql)); + } + + var commentEnd = sql.IndexOf("*/", index + 2, StringComparison.Ordinal); + if (commentEnd < 0) + { + throw InvalidSql(nameof(sql)); + } + + index = commentEnd + 2; + continue; + } + + if (current is '\'' or '"' or '`') + { + if (current == '\'' && + index > 0 && + sql[index - 1] is 'Q' or 'q') + { + // Oracle alternative quoting has delimiter-dependent rules. + // Reject it instead of interpreting its body with standard quoting. + throw InvalidSql(nameof(sql)); + } + + index = SkipQuotedText(sql, index, current); + continue; + } + + if (current == ';') + { + throw InvalidSql(nameof(sql)); + } + + if (current is ':' or '@') + { + if (index > 0 && IsAmbiguousPlaceholderBoundary(sql[index - 1])) + { + throw InvalidSql(nameof(sql)); + } + + var nameStart = index + 1; + if (nameStart >= sql.Length || + (!IsAsciiLetter(sql[nameStart]) && sql[nameStart] != '_')) + { + throw InvalidSql(nameof(sql)); + } + + var nameEnd = nameStart + 1; + while (nameEnd < sql.Length && IsParameterNameCharacter(sql[nameEnd])) + { + nameEnd++; + } + + if (nameEnd < sql.Length && !IsValidPlaceholderTerminator(sql[nameEnd])) + { + throw InvalidSql(nameof(sql)); + } + + var name = sql[nameStart..nameEnd]; + if (!DataQueryParameter.IsValidName(name)) + { + throw InvalidSql(nameof(sql)); + } + + references.Add(new ParameterReference(current, name)); + index = nameEnd; + continue; + } + + if (IsAsciiLetter(current) || current == '_') + { + var tokenEnd = index + 1; + while (tokenEnd < sql.Length && IsSqlTokenCharacter(sql[tokenEnd])) + { + tokenEnd++; + } + + tokens.Add(sql[index..tokenEnd]); + index = tokenEnd; + continue; + } + + if (!char.IsAsciiDigit(current) && !IsAllowedSqlPunctuation(current)) + { + throw InvalidSql(nameof(sql)); + } + + index++; + } + + return new ParsedSql(tokens, references); + } + + private static int SkipQuotedText(string sql, int quoteStart, char quote) + { + for (var index = quoteStart + 1; index < sql.Length; index++) + { + if (sql[index] == '\\') + { + // MariaDB backslash escaping depends on SQL_MODE. Reject the + // ambiguity so executable text can never be mistaken for a literal. + throw InvalidSql(nameof(sql)); + } + + if (sql[index] != quote) + { + continue; + } + + if (index + 1 < sql.Length && sql[index + 1] == quote) + { + index++; + continue; + } + + return index + 1; + } + + throw InvalidSql(nameof(sql)); + } + + private static bool IsParameterNameCharacter(char character) => + IsAsciiLetter(character) || char.IsAsciiDigit(character) || character == '_'; + + private static bool IsSqlTokenCharacter(char character) => + IsParameterNameCharacter(character) || character is '$' or '#'; + + private static bool IsPortableLineCommentWhitespace(char character) => + IsPortableSqlWhitespace(character); + + private static bool IsPortableSqlWhitespace(char character) => + character is ' ' or '\t' or '\r' or '\n' or '\f'; + + private static bool IsAmbiguousPlaceholderBoundary(char character) => + char.IsLetterOrDigit(character) || + char.GetUnicodeCategory(character) is + System.Globalization.UnicodeCategory.ConnectorPunctuation or + System.Globalization.UnicodeCategory.NonSpacingMark or + System.Globalization.UnicodeCategory.SpacingCombiningMark or + System.Globalization.UnicodeCategory.Format || + character is '$' or '#' or ':' or '@'; + + private static bool IsValidPlaceholderTerminator(char character) => + IsPortableSqlWhitespace(character) || + character is ',' or ')' or '(' or '+' or '-' or '*' or '/' or '%' or + '=' or '<' or '>' or '!' or '|' or '&' or '^' or '.' or ';'; + + private static bool IsAllowedSqlPunctuation(char character) => + character is ',' or ')' or '(' or '+' or '-' or '*' or '/' or '%' or + '=' or '<' or '>' or '!' or '|' or '&' or '^' or '.' or '~'; + + private static bool IsAsciiLetter(char character) => + character is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static ArgumentException InvalidSql(string parameterName) => + new("The read query is invalid or ambiguous.", parameterName); + + private sealed record ParameterReference(char Marker, string Name); + + private sealed record ParsedSql( + IReadOnlyList Tokens, + IReadOnlyList References); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs index 04074ab..4956794 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs @@ -25,6 +25,29 @@ public interface IDataQueryExecutor string tableName, string query, CancellationToken cancellationToken = default); + + /// + /// Executes an immutable parameterized read query asynchronously. + /// Existing adapters remain source-compatible, but must opt in before + /// accepting a spec that contains parameters. + /// + Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + query.ValidateFor(source); + + if (query.Parameters.Count > 0) + { + throw new NotSupportedException( + "This query executor does not support parameterized queries."); + } + + return ExecuteAsync(source, tableName, query.Sql, cancellationToken); + } } /// diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs index 9de150a..fc8bad3 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs @@ -31,7 +31,8 @@ public enum PlayoutOperation Prepare, TakeIn, Next, - TakeOut + TakeOut, + UpdateOnAir } public enum PlayoutResultCode @@ -67,6 +68,125 @@ public sealed record PlayoutField( string? Value = null, bool? IsVisible = null); +[Flags] +public enum PlayoutVectorComponents +{ + X = 1, + Y = 2, + XY = X | Y, + Z = 4, + ZX = Z | X, + YZ = Y | Z, + XYZ = X | Y | Z +} + +[Flags] +public enum PlayoutCropEdges +{ + Left = 1, + Top = 2, + Right = 4, + Horizontal = Left | Right, + Bottom = 8, + Vertical = Top | Bottom, + All = Left | Top | Right | Bottom +} + +[Flags] +public enum PlayoutAngleComponents +{ + Start = 1, + End = 2, + All = Start | End +} + +public readonly record struct PlayoutPoint(float X, float Y, float Z = 0); + +/// +/// COM-neutral, ordered K3D mutation applied inside the scene transaction. +/// Concrete mutation types form the allowlist; arbitrary method names are never accepted. +/// +public abstract record PlayoutMutation; + +public enum PlayoutMutationTiming +{ + BeforeTransaction, + InTransaction +} + +public sealed record PlayoutSetValue(string ObjectName, string Value) : PlayoutMutation; + +public sealed record PlayoutSetAssetValue(string ObjectName, string AssetPath) : PlayoutMutation; + +public sealed record PlayoutSetVisible(string ObjectName, bool IsVisible) : PlayoutMutation; + +public sealed record PlayoutSetFaceColor( + string ObjectName, + int Red, + int Green, + int Blue, + int Alpha = 255) : PlayoutMutation; + +public sealed record PlayoutSetPosition( + string ObjectName, + float X, + float Y, + float Z, + PlayoutVectorComponents Components) : PlayoutMutation; + +public sealed record PlayoutSetPositionKey( + string ObjectName, + int KeyIndex, + float X, + float Y, + float Z, + PlayoutVectorComponents Components) : PlayoutMutation; + +public sealed record PlayoutSetScale( + string ObjectName, + float X, + float Y, + float Z, + PlayoutVectorComponents Components) : PlayoutMutation; + +public sealed record PlayoutSetCropKey( + string ObjectName, + int KeyIndex, + float Left, + float Top, + float Right, + float Bottom, + PlayoutCropEdges Edges) : PlayoutMutation; + +public sealed record PlayoutSetCircleAngleKey( + string ObjectName, + int KeyIndex, + float Start, + float End, + PlayoutAngleComponents Components) : PlayoutMutation; + +public sealed record PlayoutSetPathPoints( + string ObjectName, + IReadOnlyList Points) : PlayoutMutation; + +public sealed record PlayoutSetPathShapePoints( + string ObjectName, + IReadOnlyList Points) : PlayoutMutation; + +public sealed record PlayoutUseBackground( + bool IsEnabled, + PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation; + +public sealed record PlayoutSetBackgroundTexture( + string AssetPath, + PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation; + +public sealed record PlayoutSetBackgroundVideo( + string AssetPath, + int LoopCount, + bool LoopInfinite, + PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation; + /// /// Identifies a scene and the neutral object changes needed to prepare it. /// SceneFile is an input only and is never copied into user-facing status/error text. @@ -75,7 +195,8 @@ public sealed record PlayoutCue( string SceneFile, string SceneName, IReadOnlyList? Fields = null, - int FadeDuration = 0); + int FadeDuration = 0, + IReadOnlyList? Mutations = null); public sealed record PlayoutStatus( PlayoutMode Mode, @@ -115,9 +236,9 @@ public sealed record PlayoutStatus( }; /// - /// The late-bound adapter currently cannot observe the 282-method IKAEventHandler callback - /// interface without a separately validated interop strategy. Null therefore means that the - /// OnHello callback was not programmatically verified, not that Tornado rejected the request. + /// True after the dynamically generated, vendor-compatible IKAEventHandler receives OnHello; + /// false when a completed connection lifecycle explicitly proves it was not observed. Null + /// means that callback delivery is still pending or could not be established reliably. /// public bool? KtapHelloObserved { get; init; } @@ -185,6 +306,14 @@ public interface IPlayoutEngine : IAsyncDisposable PlayoutCue cue, CancellationToken cancellationToken = default); + /// + /// Updates and replays the scene already on air without loading a replacement scene. + /// Used by MainForm-compatible timer refresh through GetPlayingScene. + /// + Task UpdateOnAirAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default); + Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs index 8bfdd8b..4c5d634 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs @@ -1,6 +1,7 @@ #nullable enable using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.Core.Playout; @@ -14,6 +15,26 @@ public sealed record PlayoutBridgeCommandParseResult( PlayoutBridgeCommand Request, string Error); +public sealed record PlayoutBridgeWorkflowCommand( + string RequestId, + string Command, + IReadOnlyList? Playlist, + int SelectedIndexZeroBased); + +public sealed record PlayoutBridgeWorkflowCommandParseResult( + bool IsValid, + PlayoutBridgeWorkflowCommand Request, + string Error); + +public sealed record PlayoutBridgeTimeoutQuarantine( + string RequestId, + string Command); + +public sealed record PlayoutBridgeTimeoutQuarantineParseResult( + bool IsValid, + PlayoutBridgeTimeoutQuarantine Request, + string Error); + /// /// Strict, COM-neutral parser for the local WebView playout command boundary. /// Presentation text is intentionally not accepted as scene mutation data. @@ -22,6 +43,8 @@ public static class PlayoutBridgeProtocol { private const int MaximumRequestIdLength = 128; private const int MaximumSceneCodeLength = 64; + private const int MaximumPlaylistLength = 1_000; + private const int MaximumLookupTextLength = 256; public static bool IsTrustedSource(string? source, string expectedHost) => !string.IsNullOrWhiteSpace(source) && @@ -77,6 +100,111 @@ public static class PlayoutBridgeProtocol return Valid(new PlayoutBridgeCommand(requestId, command, null)); } + /// + /// Parses the native workflow boundary used by the migrated MainForm state machine. + /// PREPARE supplies an immutable playlist snapshot; NEXT has no caller-selected cue so + /// native PageN/playlist rules remain authoritative. Lookup text can only reach + /// parameterized scene-data loaders and is never interpreted as a K3D control surface. + /// + public static PlayoutBridgeWorkflowCommandParseResult ParseWorkflowCommand( + JsonElement payload) + { + var empty = new PlayoutBridgeWorkflowCommand( + string.Empty, + string.Empty, + null, + -1); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "command", "playlist", "selectedIndex")) + { + return InvalidWorkflow(empty, "The playout workflow request shape is invalid."); + } + + if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId)) + { + return InvalidWorkflow(empty, "The playout workflow request identifier is invalid."); + } + + var command = GetString(payload, "command") ?? string.Empty; + if (command is not ("prepare" or "take-in" or "next" or "take-out")) + { + return InvalidWorkflow( + new PlayoutBridgeWorkflowCommand(requestId, string.Empty, null, -1), + "The playout workflow command is not supported."); + } + + var hasPlaylist = payload.TryGetProperty("playlist", out var playlistElement); + var hasSelectedIndex = payload.TryGetProperty("selectedIndex", out var indexElement); + if (command == "prepare") + { + if (!hasPlaylist || !hasSelectedIndex || + !TryParsePlaylist(playlistElement, out var playlist) || + indexElement.ValueKind != JsonValueKind.Number || + !indexElement.TryGetInt32(out var selectedIndex) || + selectedIndex < 0 || selectedIndex >= playlist!.Count) + { + return InvalidWorkflow( + new PlayoutBridgeWorkflowCommand(requestId, command, null, -1), + "The PREPARE playlist selection is invalid."); + } + + return ValidWorkflow(new PlayoutBridgeWorkflowCommand( + requestId, + command, + playlist, + selectedIndex)); + } + + if (hasPlaylist || hasSelectedIndex) + { + return InvalidWorkflow( + new PlayoutBridgeWorkflowCommand(requestId, command, null, -1), + "Only PREPARE can supply a playlist selection."); + } + + return ValidWorkflow(new PlayoutBridgeWorkflowCommand( + requestId, + command, + null, + -1)); + } + + /// + /// Parses the browser watchdog notification. A valid notification permanently + /// quarantines the native playout runtime for the remainder of the app process; + /// it is deliberately not a playout command and carries no scene data. + /// + public static PlayoutBridgeTimeoutQuarantineParseResult ParseTimeoutQuarantine( + JsonElement payload) + { + var empty = new PlayoutBridgeTimeoutQuarantine(string.Empty, string.Empty); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "command")) + { + return InvalidTimeoutQuarantine( + empty, + "The playout timeout quarantine request shape is invalid."); + } + + if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId)) + { + return InvalidTimeoutQuarantine( + empty, + "The playout timeout quarantine request identifier is invalid."); + } + + var command = GetString(payload, "command") ?? string.Empty; + if (command is not ("prepare" or "take-in" or "next" or "take-out")) + { + return InvalidTimeoutQuarantine( + new PlayoutBridgeTimeoutQuarantine(requestId, string.Empty), + "The timed-out playout command is not supported."); + } + + return ValidTimeoutQuarantine( + new PlayoutBridgeTimeoutQuarantine(requestId, command)); + } + private static bool TryParseCue(JsonElement payload, out PlayoutCue? cue) { cue = null; @@ -91,6 +219,107 @@ public static class PlayoutBridgeProtocol return true; } + private static bool TryParsePlaylist( + JsonElement payload, + out IReadOnlyList? playlist) + { + playlist = null; + if (payload.ValueKind != JsonValueKind.Array || + payload.GetArrayLength() is < 1 or > MaximumPlaylistLength) + { + return false; + } + + var entries = new List(payload.GetArrayLength()); + var identifiers = new HashSet(StringComparer.Ordinal); + foreach (var element in payload.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(element, "id", "code", "enabled", "fadeDuration", "selection") || + !TryGetSafeToken(element, "id", MaximumRequestIdLength, out var id) || + !TryGetSafeToken(element, "code", MaximumSceneCodeLength, out var code) || + !identifiers.Add(id) || + !element.TryGetProperty("enabled", out var enabledElement) || + enabledElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + return false; + } + + int? fadeDuration = null; + if (element.TryGetProperty("fadeDuration", out var fadeElement)) + { + if (fadeElement.ValueKind != JsonValueKind.Number || + !fadeElement.TryGetInt32(out var parsedFade) || + parsedFade is < 0 or > 60) + { + return false; + } + + fadeDuration = parsedFade; + } + + LegacySceneSelection? selection = null; + if (element.TryGetProperty("selection", out var selectionElement) && + !TryParseSelection(selectionElement, out selection)) + { + return false; + } + + entries.Add(new LegacyPlaylistEntry( + id, + code, + enabledElement.GetBoolean(), + fadeDuration, + selection)); + } + + playlist = Array.AsReadOnly(entries.ToArray()); + return true; + } + + private static bool TryParseSelection( + JsonElement payload, + out LegacySceneSelection? selection) + { + selection = null; + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + payload, + "groupCode", + "subject", + "graphicType", + "subtype", + "dataCode") || + !TryGetLookupText(payload, "groupCode", out var groupCode) || + !TryGetLookupText(payload, "subject", out var subject) || + !TryGetLookupText(payload, "graphicType", out var graphicType) || + !TryGetLookupText(payload, "subtype", out var subtype) || + !TryGetLookupText(payload, "dataCode", out var dataCode)) + { + return false; + } + + selection = new LegacySceneSelection( + groupCode, + subject, + graphicType, + subtype, + dataCode); + return true; + } + + private static bool TryGetLookupText( + JsonElement payload, + string propertyName, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return payload.TryGetProperty(propertyName, out var element) && + element.ValueKind == JsonValueKind.String && + value.Length <= MaximumLookupTextLength && + !value.Any(char.IsControl); + } + private static bool TryGetSafeToken( JsonElement payload, string propertyName, @@ -128,4 +357,18 @@ public static class PlayoutBridgeProtocol private static PlayoutBridgeCommandParseResult Invalid( PlayoutBridgeCommand request, string error) => new(false, request, error); + + private static PlayoutBridgeWorkflowCommandParseResult ValidWorkflow( + PlayoutBridgeWorkflowCommand request) => new(true, request, string.Empty); + + private static PlayoutBridgeWorkflowCommandParseResult InvalidWorkflow( + PlayoutBridgeWorkflowCommand request, + string error) => new(false, request, error); + + private static PlayoutBridgeTimeoutQuarantineParseResult ValidTimeoutQuarantine( + PlayoutBridgeTimeoutQuarantine request) => new(true, request, string.Empty); + + private static PlayoutBridgeTimeoutQuarantineParseResult InvalidTimeoutQuarantine( + PlayoutBridgeTimeoutQuarantine request, + string error) => new(false, request, error); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs new file mode 100644 index 0000000..166ff6f --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs @@ -0,0 +1,331 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout; + +/// +/// The only page sizes used by the legacy PageN/Nxt_PageN workflow. +/// +public enum ScenePageSize +{ + Five = 5, + Six = 6, + Twelve = 12 +} + +public enum ScenePagingResultKind +{ + Valid, + Invalid +} + +/// +/// A validated paging plan. Page numbers are one-based; item offsets are zero-based. +/// Items beyond pages are deliberately excluded. +/// +public sealed record ScenePagingPlan +{ + internal ScenePagingPlan( + int itemCount, + ScenePageSize pageSize, + int totalPages, + int accessibleItemCount) + { + ItemCount = itemCount; + PageSize = pageSize; + TotalPages = totalPages; + AccessibleItemCount = accessibleItemCount; + } + + public int ItemCount { get; } + + public ScenePageSize PageSize { get; } + + public int TotalPages { get; } + + public int AccessibleItemCount { get; } + + public int ExcludedItemCount => ItemCount - AccessibleItemCount; +} + +public sealed record ScenePagingPlanResult( + ScenePagingResultKind Kind, + ScenePagingPlan? Plan, + string Error) +{ + public bool IsValid => Kind == ScenePagingResultKind.Valid; +} + +/// +/// Describes one valid page. PageNumberOneBased is one-based; OffsetZeroBased is zero-based. +/// RemainingItemCount is limited to the accessible 20-page range. +/// +public sealed record ScenePageWindow( + int PageNumberOneBased, + int TotalPages, + int OffsetZeroBased, + int ItemCount, + int RemainingItemCount); + +public sealed record ScenePageWindowResult( + ScenePagingResultKind Kind, + ScenePagingPlan? Plan, + ScenePageWindow? Window, + string Error) +{ + public bool IsValid => Kind == ScenePagingResultKind.Valid; +} + +/// +/// A playlist definition. ItemCount must be zero for a non-paged cue. +/// +public sealed record ScenePlaylistCue( + string CueCode, + bool IsEnabled, + ScenePageSize? PageSize = null, + int ItemCount = 0); + +/// +/// Runtime state used to decide NEXT. CurrentCueIndexZeroBased is zero-based and +/// CurrentPageNumberOneBased is one-based. A non-paged cue, or a paged cue with no +/// available items, uses a null current page. +/// +public sealed record SceneNextState( + IReadOnlyList? Playlist, + int CurrentCueIndexZeroBased, + int? CurrentPageNumberOneBased); + +public enum SceneNextDecisionKind +{ + SameCueNextPage, + NextEnabledCue, + EndOfPlaylist, + Invalid +} + +public sealed record SceneNextDecision( + SceneNextDecisionKind Kind, + int? TargetCueIndexZeroBased, + int? TargetPageNumberOneBased, + ScenePageWindow? TargetPage, + string Error) +{ + public bool IsValid => Kind != SceneNextDecisionKind.Invalid; +} + +/// +/// Pure PageN/Nxt_PageN calculations with no database, COM, or UI dependency. +/// +public static class ScenePaging +{ + public const int MaximumPageCount = 20; + + public static ScenePagingPlanResult CreatePlan( + int itemCount, + ScenePageSize pageSize) + { + if (itemCount < 0) + { + return InvalidPlan("ItemCount cannot be negative."); + } + + if (!IsSupported(pageSize)) + { + return InvalidPlan("PageSize must be 5, 6, or 12."); + } + + var size = (int)pageSize; + var uncappedTotalPages = itemCount == 0 + ? 0 + : ((itemCount - 1) / size) + 1; + var totalPages = Math.Min(MaximumPageCount, uncappedTotalPages); + var accessibleItemCount = Math.Min(itemCount, MaximumPageCount * size); + + return new ScenePagingPlanResult( + ScenePagingResultKind.Valid, + new ScenePagingPlan(itemCount, pageSize, totalPages, accessibleItemCount), + string.Empty); + } + + public static ScenePageWindowResult GetWindow( + int itemCount, + ScenePageSize pageSize, + int pageNumberOneBased) + { + var planResult = CreatePlan(itemCount, pageSize); + if (!planResult.IsValid || planResult.Plan is null) + { + return new ScenePageWindowResult( + ScenePagingResultKind.Invalid, + null, + null, + planResult.Error); + } + + var plan = planResult.Plan; + if (pageNumberOneBased < 1 || pageNumberOneBased > plan.TotalPages) + { + return new ScenePageWindowResult( + ScenePagingResultKind.Invalid, + plan, + null, + "PageNumberOneBased is outside the available page range."); + } + + var size = (int)plan.PageSize; + var offset = (pageNumberOneBased - 1) * size; + var count = Math.Min(size, plan.AccessibleItemCount - offset); + var remaining = plan.AccessibleItemCount - offset - count; + var window = new ScenePageWindow( + pageNumberOneBased, + plan.TotalPages, + offset, + count, + remaining); + + return new ScenePageWindowResult( + ScenePagingResultKind.Valid, + plan, + window, + string.Empty); + } + + public static SceneNextDecision DecideNext(SceneNextState? state) + { + if (state?.Playlist is null) + { + return InvalidDecision("Playlist state is required."); + } + + var playlist = state.Playlist; + if (state.CurrentCueIndexZeroBased < 0 || + state.CurrentCueIndexZeroBased >= playlist.Count) + { + return InvalidDecision("CurrentCueIndexZeroBased is outside the playlist."); + } + + var plans = new ScenePagingPlan?[playlist.Count]; + for (var index = 0; index < playlist.Count; index++) + { + var cue = playlist[index]; + if (cue is null || string.IsNullOrWhiteSpace(cue.CueCode)) + { + return InvalidDecision("Every playlist cue requires a cue code."); + } + + if (cue.PageSize is null) + { + if (cue.ItemCount != 0) + { + return InvalidDecision("A non-paged cue must have ItemCount zero."); + } + + continue; + } + + var planResult = CreatePlan(cue.ItemCount, cue.PageSize.Value); + if (!planResult.IsValid || planResult.Plan is null) + { + return InvalidDecision(planResult.Error); + } + + plans[index] = planResult.Plan; + } + + var currentIndex = state.CurrentCueIndexZeroBased; + var currentCue = playlist[currentIndex]; + var currentPlan = plans[currentIndex]; + + if (currentPlan is null) + { + if (state.CurrentPageNumberOneBased is not null) + { + return InvalidDecision("A non-paged cue cannot have a current page."); + } + } + else if (currentPlan.TotalPages == 0) + { + if (state.CurrentPageNumberOneBased is not null) + { + return InvalidDecision("A paged cue with no items cannot have a current page."); + } + } + else + { + var currentPage = state.CurrentPageNumberOneBased; + if (currentPage is null || + currentPage.Value < 1 || + currentPage.Value > currentPlan.TotalPages) + { + return InvalidDecision("CurrentPageNumberOneBased is outside the current cue."); + } + + if (currentPage.Value < currentPlan.TotalPages) + { + var nextPage = currentPage.Value + 1; + var pageResult = GetWindow( + currentCue.ItemCount, + currentCue.PageSize!.Value, + nextPage); + if (!pageResult.IsValid || pageResult.Window is null) + { + return InvalidDecision(pageResult.Error); + } + + return new SceneNextDecision( + SceneNextDecisionKind.SameCueNextPage, + currentIndex, + nextPage, + pageResult.Window, + string.Empty); + } + } + + for (var index = currentIndex + 1; index < playlist.Count; index++) + { + if (!playlist[index].IsEnabled) + { + continue; + } + + var targetPlan = plans[index]; + int? targetPageNumber = targetPlan?.TotalPages > 0 ? 1 : null; + ScenePageWindow? targetPage = null; + if (targetPageNumber is not null) + { + var pageResult = GetWindow( + playlist[index].ItemCount, + playlist[index].PageSize!.Value, + targetPageNumber.Value); + if (!pageResult.IsValid || pageResult.Window is null) + { + return InvalidDecision(pageResult.Error); + } + + targetPage = pageResult.Window; + } + + return new SceneNextDecision( + SceneNextDecisionKind.NextEnabledCue, + index, + targetPageNumber, + targetPage, + string.Empty); + } + + return new SceneNextDecision( + SceneNextDecisionKind.EndOfPlaylist, + null, + null, + null, + string.Empty); + } + + private static bool IsSupported(ScenePageSize pageSize) => + pageSize is ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve; + + private static ScenePagingPlanResult InvalidPlan(string error) => + new(ScenePagingResultKind.Invalid, null, error); + + private static SceneNextDecision InvalidDecision(string error) => + new(SceneNextDecisionKind.Invalid, null, null, null, error); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs new file mode 100644 index 0000000..621f774 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs @@ -0,0 +1,703 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum CandleCutAlias +{ + Cut8035 = 8035, + Cut8061 = 8061, + Cut8040 = 8040, + Cut8046 = 8046, + Cut8051 = 8051, + Cut8056 = 8056 +} + +public enum CandlePeriod +{ + Intraday, + FiveDays, + TwentyDays, + SixtyDays, + OneHundredTwentyDays, + TwoHundredFortyDays +} + +public enum CandleChartMode +{ + Price, + PriceWithVolume, + ExpectedExecution, + TradingHalt +} + +public enum CandleMarketTarget +{ + KospiIndex, + KosdaqIndex, + Kospi200Index, + Krx100Index, + FuturesIndex, + KospiIndustry, + KosdaqIndustry, + OverseasIndex, + OverseasStock, + KospiStock, + KosdaqStock +} + +public static class CandleCutAliasMetadata +{ + public static CandlePeriod GetPeriod(CandleCutAlias alias) => alias switch + { + CandleCutAlias.Cut8035 => CandlePeriod.Intraday, + CandleCutAlias.Cut8061 => CandlePeriod.FiveDays, + CandleCutAlias.Cut8040 => CandlePeriod.TwentyDays, + CandleCutAlias.Cut8046 => CandlePeriod.SixtyDays, + CandleCutAlias.Cut8051 => CandlePeriod.OneHundredTwentyDays, + CandleCutAlias.Cut8056 => CandlePeriod.TwoHundredFortyDays, + _ => throw new LegacySceneDataException("Unknown candle cut alias.") + }; + + internal static double GetMovingAverageWidth(CandleCutAlias alias) => alias switch + { + CandleCutAlias.Cut8035 => 0d, + CandleCutAlias.Cut8061 => 1188d, + CandleCutAlias.Cut8040 => 1398d, + CandleCutAlias.Cut8046 => 1442d, + CandleCutAlias.Cut8051 => 1452d, + CandleCutAlias.Cut8056 => 1464d, + _ => throw new LegacySceneDataException("Unknown candle cut alias.") + }; +} + +public sealed record CandleQuoteData( + double CurrentPrice, + double ChangePrice, + double Rate, + ScenePriceDirection Direction); + +public sealed record CandleRowData( + DateOnly TradingDate, + TimeOnly? TradingTime, + double Open, + double High, + double Low, + double Close, + double? Volume = null, + double? MovingAverage5 = null, + double? MovingAverage20 = null); + +public sealed record S8010SceneData( + CandleCutAlias CutAlias, + CandleMarketTarget MarketTarget, + CandleChartMode ChartMode, + string DisplayName, + CandleQuoteData Quote, + IReadOnlyList Rows, + bool ShowMovingAverage5, + bool ShowMovingAverage20) : ILegacySceneData; + +public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder +{ + private const int MaximumCandleCount = 240; + private const double GraphStartX = -730.57d; + private const double GraphEndX = 738.65d; + private const double TagMinimumX = -657.57d; + private const double TagMaximumX = 670.65d; + + public string BuilderKey => "s8010"; + + public IReadOnlyList Build(S8010SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + ValidateEnums(data); + var displayName = LegacySceneBuilderGuard.Text( + data.DisplayName, + nameof(data.DisplayName)); + var quote = LegacySceneBuilderGuard.NotNull(data.Quote, nameof(data.Quote)); + ValidateQuote(quote); + var rows = NormalizeRows(data.Rows, data.CutAlias, data.ChartMode); + var period = CandleCutAliasMetadata.GetPeriod(data.CutAlias); + var lineWidth = CandleCutAliasMetadata.GetMovingAverageWidth(data.CutAlias); + var format = UsesIntegerPrices(data.MarketTarget) ? "#,##0" : "#,##0.00"; + var mutations = new List(540 + (rows.Count * 12)) + { + new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode)), + // A path mutation with no points reproduces Begin/Clear/End from the legacy setup. + new PlayoutSetPathPoints("ma5", Array.Empty()), + new PlayoutSetPathPoints("ma20", Array.Empty()) + }; + + AddResolvedTitleAndUnit( + mutations, + displayName, + data.MarketTarget, + data.ChartMode); + AddQuote(mutations, quote, format); + + var range = CreateRange(rows, data); + AddInitialVisibility(mutations, data.ChartMode); + var paths = AddCandles( + mutations, + rows, + data, + period, + lineWidth, + format, + range); + AddMovingAveragePaths(mutations, paths, data); + return mutations; + } + + private static void ValidateEnums(S8010SceneData data) + { + if (!Enum.IsDefined(data.CutAlias) || !Enum.IsDefined(data.MarketTarget) || + !Enum.IsDefined(data.ChartMode)) + { + throw new LegacySceneDataException("Unknown candle scene selection."); + } + + if (data.ChartMode == CandleChartMode.TradingHalt && + data.MarketTarget is not (CandleMarketTarget.KospiStock or + CandleMarketTarget.KosdaqStock)) + { + throw new LegacySceneDataException( + "Trading-halt candles require a domestic stock target."); + } + } + + private static void ValidateQuote(CandleQuoteData quote) + { + if (!double.IsFinite(quote.CurrentPrice) || + !double.IsFinite(quote.ChangePrice) || + !double.IsFinite(quote.Rate) || + !Enum.IsDefined(quote.Direction)) + { + throw new LegacySceneDataException("Candle quote data is invalid."); + } + } + + private static IReadOnlyList NormalizeRows( + IReadOnlyList? rows, + CandleCutAlias alias, + CandleChartMode chartMode) + { + var values = LegacySceneBuilderGuard.Range( + rows, + 1, + MaximumCandleCount, + nameof(rows)); + var requiresTime = CandleCutAliasMetadata.GetPeriod(alias) == CandlePeriod.Intraday; + return values.Select((row, index) => + { + var value = LegacySceneBuilderGuard.NotNull(row, $"Rows[{index}]"); + if (requiresTime && value.TradingTime is null) + { + throw new LegacySceneDataException( + $"Scene data field 'Rows[{index}].TradingTime' is required for 8035."); + } + + if (!double.IsFinite(value.Open) || !double.IsFinite(value.High) || + !double.IsFinite(value.Low) || !double.IsFinite(value.Close) || + value.High < value.Low || value.High < value.Open || + value.High < value.Close || value.Low > value.Open || + value.Low > value.Close) + { + throw new LegacySceneDataException( + $"Scene data field 'Rows[{index}]' has an invalid OHLC range."); + } + + ValidateOptionalFinite(value.Volume, $"Rows[{index}].Volume"); + ValidateOptionalFinite(value.MovingAverage5, $"Rows[{index}].MovingAverage5"); + ValidateOptionalFinite(value.MovingAverage20, $"Rows[{index}].MovingAverage20"); + if (value.Volume < 0) + { + throw new LegacySceneDataException( + $"Scene data field 'Rows[{index}].Volume' cannot be negative."); + } + + if (chartMode == CandleChartMode.PriceWithVolume && value.Volume is null) + { + // The vendor source treats DB null as zero. Normalize it before calculating max. + return value with { Volume = 0d }; + } + + return value; + }).ToArray(); + } + + private static void ValidateOptionalFinite(double? value, string name) + { + if (value.HasValue && !double.IsFinite(value.Value)) + { + throw new LegacySceneDataException($"Scene data field '{name}' must be finite."); + } + } + + private static string InitialTitle(string displayName, CandleChartMode mode) => + mode == CandleChartMode.ExpectedExecution + ? displayName + " \uC608\uC0C1\uCCB4\uACB0" + : displayName; + + private static void AddResolvedTitleAndUnit( + ICollection mutations, + string displayName, + CandleMarketTarget target, + CandleChartMode mode) + { + if (target == CandleMarketTarget.OverseasStock) + { + mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : \uB2EC\uB7EC")); + return; + } + + if (target == CandleMarketTarget.OverseasIndex) + { + mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : p")); + return; + } + + if (IsIndex(target)) + { + var title = MarketLabel(target) + (mode == CandleChartMode.ExpectedExecution + ? " \uC608\uC0C1\uCCB4\uACB0\uC9C0\uC218" + : " \uC9C0\uC218"); + mutations.Add(new PlayoutSetValue("title", title)); + mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : p")); + return; + } + + if (mode == CandleChartMode.ExpectedExecution) + { + // The legacy non-index expected-price branch only rewrites the title. + mutations.Add(new PlayoutSetValue( + "title", + displayName + " \uC608\uC0C1\uCCB4\uACB0")); + return; + } + + var resolvedTitle = target switch + { + CandleMarketTarget.KospiIndustry => "\uCF54\uC2A4\uD53C - " + displayName, + CandleMarketTarget.KosdaqIndustry => "\uCF54\uC2A4\uB2E5 - " + displayName, + _ => displayName + }; + var unit = IsIndustry(target) ? "\uB2E8\uC704 : p" : "\uB2E8\uC704 : \uC6D0"; + // The legacy stock/industry branch writes unit before its resolved title. + mutations.Add(new PlayoutSetValue("unit", unit)); + mutations.Add(new PlayoutSetValue("title", resolvedTitle)); + } + + private static string MarketLabel(CandleMarketTarget target) => target switch + { + CandleMarketTarget.KospiIndex => "\uCF54\uC2A4\uD53C", + CandleMarketTarget.KosdaqIndex => "\uCF54\uC2A4\uB2E5", + CandleMarketTarget.Kospi200Index => "\uCF54\uC2A4\uD53C200", + CandleMarketTarget.Krx100Index => "KRX100", + CandleMarketTarget.FuturesIndex => "\uC120\uBB3C", + _ => throw new LegacySceneDataException("The target is not a domestic index.") + }; + + private static void AddQuote( + ICollection mutations, + CandleQuoteData quote, + string format) + { + mutations.Add(new PlayoutSetValue( + "price", + quote.CurrentPrice.ToString(format, CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "changePrice", + quote.ChangePrice.ToString(format, CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "rate", + quote.Rate.ToString("##0.00", CultureInfo.InvariantCulture))); + CandleQuoteDirection.Add(mutations, quote.Direction); + } + + private static CandleRange CreateRange( + IReadOnlyList rows, + S8010SceneData data) + { + var candleMinimum = rows.Min(row => row.Low); + var candleMaximum = rows.Max(row => row.High); + var minimum = candleMinimum; + var maximum = candleMaximum; + if (SupportsMovingAverages(data.MarketTarget)) + { + if (data.ShowMovingAverage5) + { + var values = rows.Where(row => row.MovingAverage5.HasValue) + .Select(row => row.MovingAverage5!.Value) + .ToArray(); + if (values.Length != 0) + { + minimum = Math.Min(minimum, values.Min()); + maximum = Math.Max(maximum, values.Max()); + } + } + + if (data.ShowMovingAverage20) + { + var values = rows.Where(row => row.MovingAverage20.HasValue) + .Select(row => row.MovingAverage20!.Value) + .ToArray(); + if (values.Length != 0) + { + minimum = Math.Min(minimum, values.Min()); + maximum = Math.Max(maximum, values.Max()); + } + } + } + + var gap = maximum - minimum; + if (!double.IsFinite(gap)) + { + throw new LegacySceneDataException( + "Candle price range cannot be represented as a finite value."); + } + + if (gap == 0d) + { + // Equal OHLC input formerly divided by zero. A unit range keeps every crop finite. + gap = 1d; + } + + var maximumVolume = data.ChartMode == CandleChartMode.PriceWithVolume + ? rows.Max(row => row.Volume ?? 0d) + : 0d; + return new CandleRange( + minimum, + maximum, + gap, + candleMinimum, + candleMaximum, + maximumVolume); + } + + private static void AddInitialVisibility( + ICollection mutations, + CandleChartMode mode) + { + mutations.Add(new PlayoutSetVisible( + "volrumeG", + mode == CandleChartMode.PriceWithVolume)); + for (var index = 1; index <= MaximumCandleCount; index++) + { + mutations.Add(new PlayoutSetVisible("volume" + index, false)); + } + + for (var index = 1; index <= MaximumCandleCount; index++) + { + mutations.Add(new PlayoutSetVisible("candle" + index, false)); + } + } + + private static CandlePaths AddCandles( + ICollection mutations, + IReadOnlyList rows, + S8010SceneData data, + CandlePeriod period, + double lineWidth, + string format, + CandleRange range) + { + var ma5Points = new List(rows.Count); + var ma20Points = new List(rows.Count); + var needMovingAverageChecker = false; + var movingAverageCounter = 0; + var originalGraphLength = GraphEndX - GraphStartX; + var endX = GraphEndX; + var tagMaximum = TagMaximumX; + var length = originalGraphLength; + var lengthPerCandle = length / rows.Count; + if (period == CandlePeriod.Intraday) + { + endX = (originalGraphLength * rows.Count / 78d) + GraphStartX; + tagMaximum = endX - 68d; + length = endX - GraphStartX; + lengthPerCandle = length / rows.Count; + } + + for (var zeroBased = 0; zeroBased < rows.Count; zeroBased++) + { + var row = rows[zeroBased]; + var index = zeroBased + 1; + AddDateLabel(mutations, rows, period, zeroBased); + + if (row.High.Equals(range.CandleMaximum)) + { + var x = TagPosition(index, lengthPerCandle, tagMaximum); + mutations.Add(new PlayoutSetPosition( + "highTag", (float)x, 0, 0, PlayoutVectorComponents.X)); + mutations.Add(new PlayoutSetValue( + "highPrice", row.High.ToString(format, CultureInfo.InvariantCulture))); + } + + if (row.Low.Equals(range.CandleMinimum)) + { + var x = TagPosition(index, lengthPerCandle, tagMaximum); + mutations.Add(new PlayoutSetPosition( + "lowTag", (float)x, 0, 0, PlayoutVectorComponents.X)); + mutations.Add(new PlayoutSetValue( + "lowPrice", row.Low.ToString(format, CultureInfo.InvariantCulture))); + } + + mutations.Add(new PlayoutSetVisible("candle" + index, true)); + mutations.Add(Crop( + "candlebar" + index, + Normalize(row.High, range), + Normalize(row.Low, range))); + + if (row.MovingAverage5.HasValue) + { + var include = true; + if (needMovingAverageChecker) + { + movingAverageCounter++; + include = movingAverageCounter >= 4; + } + + if (include) + { + ma5Points.Add(MovingAveragePoint( + row.MovingAverage5.Value, + zeroBased, + rows.Count, + lineWidth, + range)); + } + } + else + { + needMovingAverageChecker = true; + } + + if (row.MovingAverage20.HasValue && + (!needMovingAverageChecker || movingAverageCounter >= 19)) + { + ma20Points.Add(MovingAveragePoint( + row.MovingAverage20.Value, + zeroBased, + rows.Count, + lineWidth, + range)); + } + + AddCandleBody(mutations, row, index, range); + if (data.ChartMode == CandleChartMode.PriceWithVolume) + { + var volume = row.Volume ?? 0d; + var percentage = range.MaximumVolume == 0d + ? 0d + : Math.Abs(volume / range.MaximumVolume * 100d); + mutations.Add(new PlayoutSetVisible("volume" + index, true)); + mutations.Add(Crop("volume" + index, 100d - percentage, 100d)); + } + } + + return new CandlePaths(ma5Points, ma20Points); + } + + private static void AddDateLabel( + ICollection mutations, + IReadOnlyList rows, + CandlePeriod period, + int zeroBasedIndex) + { + if (period == CandlePeriod.Intraday) + { + if (zeroBasedIndex != 0) + { + return; + } + + var startsAtNine = rows[0].TradingTime!.Value.Hour == 9; + mutations.Add(new PlayoutSetValue("date_begin", startsAtNine ? "09:00" : "10:00")); + mutations.Add(new PlayoutSetValue("date_middle", startsAtNine ? "12:00" : "13:00")); + mutations.Add(new PlayoutSetValue("date_end", startsAtNine ? "15:30" : "16:30")); + return; + } + + var index = zeroBasedIndex + 1; + var value = rows[zeroBasedIndex].TradingDate.ToString("MM/dd", CultureInfo.InvariantCulture); + if (index == 1) + { + mutations.Add(new PlayoutSetValue("date_begin", value)); + } + else if (index == rows.Count) + { + mutations.Add(new PlayoutSetValue("date_end", value)); + } + else if (index == (int)Math.Ceiling(rows.Count / 2d)) + { + mutations.Add(new PlayoutSetValue("date_middle", value)); + } + } + + private static double TagPosition( + int oneBasedIndex, + double lengthPerCandle, + double tagMaximum) + { + var x = GraphStartX + (oneBasedIndex * lengthPerCandle) - (lengthPerCandle / 2d); + if (x > tagMaximum) + { + x = tagMaximum; + } + else if (x < TagMinimumX) + { + x = TagMinimumX; + } + + return x; + } + + private static double Normalize(double value, CandleRange range) => + Math.Abs((value - range.Maximum) / range.Gap * 100d); + + private static PlayoutSetCropKey Crop(string objectName, double top, double bottom) => + new( + objectName, + 1, + 0, + (float)top, + 0, + (float)bottom, + PlayoutCropEdges.Vertical); + + private static PlayoutPoint MovingAveragePoint( + double value, + int zeroBasedIndex, + int count, + double lineWidth, + CandleRange range) + { + var x = count <= 1 + ? 0f + : (float)(lineWidth / (count - 1d)) * zeroBasedIndex; + var y = (float)((value - range.Maximum) / range.Gap * 196d) + 98f; + return new PlayoutPoint(x, y, 0); + } + + private static void AddCandleBody( + ICollection mutations, + CandleRowData row, + int index, + CandleRange range) + { + var open = Normalize(row.Open, range); + var close = Normalize(row.Close, range); + var bodyName = "candlebody" + index; + var barName = "candlebar" + index; + if (row.Open > row.Close) + { + mutations.Add(Crop(bodyName, open, close)); + mutations.Add(new PlayoutSetFaceColor(bodyName, 15, 99, 189)); + mutations.Add(new PlayoutSetFaceColor(barName, 15, 99, 189)); + } + else if (row.Open < row.Close) + { + mutations.Add(Crop(bodyName, close, open)); + mutations.Add(new PlayoutSetFaceColor(bodyName, 170, 0, 0)); + mutations.Add(new PlayoutSetFaceColor(barName, 170, 0, 0)); + } + else + { + mutations.Add(Crop(bodyName, close, open + 1d)); + mutations.Add(new PlayoutSetFaceColor(bodyName, 95, 95, 95)); + mutations.Add(new PlayoutSetFaceColor(barName, 95, 95, 95)); + } + } + + private static void AddMovingAveragePaths( + ICollection mutations, + CandlePaths paths, + S8010SceneData data) + { + mutations.Add(new PlayoutSetPathPoints( + "ma5", + data.ShowMovingAverage5 ? paths.MovingAverage5 : Array.Empty())); + mutations.Add(new PlayoutSetPathPoints( + "ma20", + data.ShowMovingAverage20 ? paths.MovingAverage20 : Array.Empty())); + mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20)); + + if (!SupportsMovingAverages(data.MarketTarget)) + { + mutations.Add(new PlayoutSetVisible("guide", false)); + mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty())); + mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty())); + } + } + + private static bool UsesIntegerPrices(CandleMarketTarget target) => target is + CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock; + + private static bool IsIndex(CandleMarketTarget target) => target is + CandleMarketTarget.KospiIndex or CandleMarketTarget.KosdaqIndex or + CandleMarketTarget.Kospi200Index or CandleMarketTarget.Krx100Index or + CandleMarketTarget.FuturesIndex; + + private static bool IsIndustry(CandleMarketTarget target) => target is + CandleMarketTarget.KospiIndustry or CandleMarketTarget.KosdaqIndustry; + + private static bool SupportsMovingAverages(CandleMarketTarget target) => + !IsIndex(target) && !IsIndustry(target) && target != CandleMarketTarget.OverseasIndex; + + private sealed record CandleRange( + double Minimum, + double Maximum, + double Gap, + double CandleMinimum, + double CandleMaximum, + double MaximumVolume); + + private sealed record CandlePaths( + IReadOnlyList MovingAverage5, + IReadOnlyList MovingAverage20); +} + +internal static class CandleQuoteDirection +{ + private static readonly string[] DirectionObjects = + ["upup", "up", "flat", "down", "downdown"]; + + public static void Add( + ICollection mutations, + ScenePriceDirection direction) + { + foreach (var name in DirectionObjects) + { + mutations.Add(new PlayoutSetVisible(name, false)); + } + + string? active = direction switch + { + ScenePriceDirection.LimitUp => "upup", + ScenePriceDirection.Up => "up", + ScenePriceDirection.Flat or ScenePriceDirection.Unknown => null, + ScenePriceDirection.Down => "down", + ScenePriceDirection.LimitDown => "downdown", + _ => throw new LegacySceneDataException("Unknown candle quote direction.") + }; + if (active is not null) + { + mutations.Add(new PlayoutSetVisible(active, true)); + } + + var selected = direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1, + ScenePriceDirection.Flat or ScenePriceDirection.Unknown => 2, + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3, + _ => throw new LegacySceneDataException("Unknown candle quote direction.") + }; + for (var index = 1; index <= 3; index++) + { + mutations.Add(new PlayoutSetVisible("bg" + index, index == selected)); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs new file mode 100644 index 0000000..23b7f21 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs @@ -0,0 +1,1057 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Closed request contract for the production s8010 candle queries. InstrumentName is a +/// bound value only; it is never interpolated into SQL or used as an identifier. +/// +public sealed record S8010SceneDataRequest( + CandleCutAlias CutAlias, + CandleMarketTarget MarketTarget, + CandleChartMode ChartMode, + string? InstrumentName, + bool ShowMovingAverage5, + bool ShowMovingAverage20); + +/// +/// Loads the two legacy s8010 result sets (current quote, then ordered candles) through +/// parameterized, read-only Oracle queries and maps them to the trusted scene DTO. +/// +public sealed class S8010SceneDataLoader +{ + private const string CurrentResultName = "S8010_CURRENT"; + private const string RowsResultName = "S8010_ROWS"; + private readonly IDataQueryExecutor _executor; + + public S8010SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S8010SceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var selection = S8010CandleQueryFactory.ValidateRequest(request); + var plan = S8010CandleQueryFactory.Create(request, selection); + + var current = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + CurrentResultName, + plan.Current, + cancellationToken).ConfigureAwait(false); + var quote = S8010CandleTableMapper.MapQuote(current); + + var rows = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + RowsResultName, + plan.Rows, + cancellationToken).ConfigureAwait(false); + var candles = S8010CandleTableMapper.MapRows( + rows, + plan.IsIntraday, + plan.MaximumRows); + var data = new S8010SceneData( + request.CutAlias, + request.MarketTarget, + request.ChartMode, + quote.DisplayName, + quote.Quote, + candles, + request.ShowMovingAverage5, + request.ShowMovingAverage20); + + // Use the scene builder as the final DTO invariant check. No COM operation occurs. + _ = new S8010SceneMutationBuilder().Build(data); + return data; + } +} + +internal static class S8010CandleQueryFactory +{ + private const int MaximumSelectionLength = 256; + private const string NullNumber = "CAST(NULL AS NUMBER)"; + private const string NullTime = "CAST(NULL AS VARCHAR2(6))"; + + public static string? ValidateRequest(S8010SceneDataRequest request) + { + if (!Enum.IsDefined(request.CutAlias) || + !Enum.IsDefined(request.MarketTarget) || + !Enum.IsDefined(request.ChartMode)) + { + throw InvalidRequest(); + } + + var requiresSelection = RequiresSelection(request.MarketTarget); + if (!requiresSelection) + { + if (!string.IsNullOrWhiteSpace(request.InstrumentName)) + { + throw InvalidRequest(); + } + + return null; + } + + var selection = LegacySceneBuilderGuard.Text( + request.InstrumentName, + nameof(request.InstrumentName)); + if (selection.Length > MaximumSelectionLength) + { + throw InvalidRequest(); + } + + return selection; + } + + public static S8010CandleQueryPlan Create( + S8010SceneDataRequest request, + string? selection) + { + var isIntraday = request.CutAlias == CandleCutAlias.Cut8035; + var maximumRows = MaximumRows(request.CutAlias); + EnsureSupported(request, isIntraday); + + var queries = request.MarketTarget switch + { + CandleMarketTarget.KospiIndex => DomesticIndex( + request.ChartMode, + isIntraday, + maximumRows, + new DomesticIndexSource( + "t_index", + "t_fo_index", + "t_index_his_5M", + "t_index_his_day", + "001")), + CandleMarketTarget.KosdaqIndex => DomesticIndex( + request.ChartMode, + isIntraday, + maximumRows, + new DomesticIndexSource( + "t_kosdaq_index", + "t_kosdaq_fo_index", + "t_kosdaq_index_his_5M", + "t_kosdaq_index_his_day", + "001")), + CandleMarketTarget.Kospi200Index => DomesticIndex( + request.ChartMode, + isIntraday, + maximumRows, + new DomesticIndexSource( + "T_200_INDEX", + "T_200_FO_INDEX", + "t_200_index_his_5M", + "t_200_index_his_day", + "029")), + CandleMarketTarget.Krx100Index => DomesticIndex( + request.ChartMode, + isIntraday, + maximumRows, + new DomesticIndexSource( + "t_krx100_index", + null, + "t_krx100_index_his_5M", + "t_krx100_index_his_day", + "043")), + CandleMarketTarget.FuturesIndex => Futures( + request.ChartMode, + isIntraday, + maximumRows), + CandleMarketTarget.KospiIndustry => Industry( + request.ChartMode, + isIntraday, + maximumRows, + selection!, + new IndustrySource( + "t_index", + "t_part", + "t_index_his_5M", + "t_index_his_day")), + CandleMarketTarget.KosdaqIndustry => Industry( + request.ChartMode, + isIntraday, + maximumRows, + selection!, + new IndustrySource( + "t_kosdaq_index", + "t_kosdaq_part", + "t_kosdaq_index_his_5M", + "t_kosdaq_index_his_day")), + CandleMarketTarget.OverseasIndex => Foreign( + maximumRows, + selection!, + "0"), + CandleMarketTarget.OverseasStock => Foreign( + maximumRows, + selection!, + "1"), + CandleMarketTarget.KospiStock => Stock( + request.ChartMode, + isIntraday, + maximumRows, + selection!, + new StockSource( + "t_online1", + "t_online1_call", + "t_stop_online1", + "t_online_his_5M", + "t_candle_history", + "t_stock", + "t_batch_day")), + CandleMarketTarget.KosdaqStock => Stock( + request.ChartMode, + isIntraday, + maximumRows, + selection!, + new StockSource( + "t_kosdaq_online1", + "t_kosdaq_online1_call", + "t_stop_kosdaq_online1", + "t_kosdaq_online_his_5M", + "t_kosdaq_candle_history", + "t_kosdaq_stock", + "t_kosdaq_batch_day")), + _ => throw InvalidRequest() + }; + + return new S8010CandleQueryPlan( + queries.Current, + queries.Rows, + maximumRows, + isIntraday); + } + + private static QueryPair DomesticIndex( + CandleChartMode mode, + bool isIntraday, + int maximumRows, + DomesticIndexSource source) + { + var expected = mode == CandleChartMode.ExpectedExecution; + var currentTable = expected ? source.ForecastTable! : source.CurrentTable; + var changeExpression = expected + ? "ABS(ROUND(f_part_chg / 100, 2))" + : "ROUND(f_part_chg / 100, 2)"; + var currentSql = $""" + SELECT + :displayName DISPLAY_NAME, + ROUND(f_part_idx / 100, 2) CURRENT_PRICE, + {changeExpression} CHANGE_PRICE, + ROUND((f_part_chg / DECODE( + f_chg_type, + '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, + 1)) * 100, 2) RATE, + f_chg_type DIRECTION_CODE + FROM {currentTable} + WHERE f_part_code = :partCode + """; + var current = Oracle( + currentSql, + new DataQueryParameter("displayName", "\uC9C0\uC218", DbType.String), + new DataQueryParameter("partCode", source.PartCode, DbType.String)); + var volume = mode == CandleChartMode.PriceWithVolume + ? "a.F_PART_VOL" + : NullNumber; + var rowsSql = isIntraday + ? $""" + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + a.F_DATA_DAY TRADING_DATE, + a.F_DATA_TIME TRADING_TIME, + a.F_PART_INIT_IDX / 100 OPEN_PRICE, + a.F_PART_HIGH_IDX / 100 HIGH_PRICE, + a.F_PART_LOW_IDX / 100 LOW_PRICE, + a.F_PART_IDX / 100 CLOSE_PRICE, + {volume} VOLUME, + {NullNumber} MA5, + {NullNumber} MA20 + FROM {source.FiveMinuteTable} a + WHERE a.f_part_code = :partCode + AND a.f_data_day = (SELECT MAX(open_day) FROM v_open_day) + ORDER BY a.F_DATA_DAY, a.F_DATA_TIME + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE, TRADING_TIME + """ + : $""" + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + a.F_DATA_DAY TRADING_DATE, + {NullTime} TRADING_TIME, + a.F_PART_INIT_IDX / 100 OPEN_PRICE, + a.F_PART_HIGH_IDX / 100 HIGH_PRICE, + a.F_PART_LOW_IDX / 100 LOW_PRICE, + a.F_PART_IDX / 100 CLOSE_PRICE, + {volume} VOLUME, + a.F_PART_MA5_IDX / 100 MA5, + a.F_PART_MA20_IDX / 100 MA20 + FROM {source.DailyTable} a + WHERE a.f_part_code = :partCode + ORDER BY a.F_DATA_DAY DESC + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE + """; + var rows = Oracle( + rowsSql, + new DataQueryParameter("partCode", source.PartCode, DbType.String), + new DataQueryParameter("rowLimit", maximumRows, DbType.Int32)); + return new QueryPair(current, rows); + } + + private static QueryPair Futures( + CandleChartMode mode, + bool isIntraday, + int maximumRows) + { + var currentSql = mode == CandleChartMode.ExpectedExecution + ? """ + SELECT + :displayName DISPLAY_NAME, + part_idx CURRENT_PRICE, + ABS(part_chg) CHANGE_PRICE, + ROUND(part_chg / DECODE( + chg_type, + '+', (part_idx / 100) - (part_chg / 100), + '-', (part_idx / 100) + (part_chg / 100), + 1), 2) RATE, + chg_type DIRECTION_CODE + FROM ( + SELECT + DECODE(a.f_fo_price, 0, b.F_JUN_LAST_PRICE, a.f_fo_price) part_idx, + DECODE(a.f_fo_price, 0, 0, + (a.f_fo_price - b.F_JUN_LAST_PRICE) / 100) part_chg, + DECODE(a.f_fo_price, 0, ' ', + DECODE(SIGN(a.f_fo_price - b.F_JUN_LAST_PRICE), + 1, '+', -1, '-', 0, ' ')) chg_type + FROM t_sunmul_online a, t_sunmul_batch b + WHERE a.f_stock_code = b.f_stock_code + AND a.f_stock_seq = 1 + AND b.f_market_date = (SELECT MAX(OPEN_DAY) FROM v_open_day) + AND b.F_MONTH_GUBUN = '1' + ) + """ + : """ + SELECT + :displayName DISPLAY_NAME, + part_idx CURRENT_PRICE, + ABS(part_chg) CHANGE_PRICE, + ABS(ROUND(part_chg / DECODE( + chg_type, + '+', (part_idx / 100) - (part_chg / 100), + '-', (part_idx / 100) + (part_chg / 100), + 1), 2)) RATE, + chg_type DIRECTION_CODE + FROM ( + SELECT + DECODE(a.f_curr_price, 0, + b.F_JUN_LAST_PRICE / 100, + a.f_curr_price / 100) part_idx, + DECODE(a.f_curr_price, 0, 0, + (a.f_curr_price - b.F_JUN_LAST_PRICE * 100) / 100) part_chg, + DECODE(a.f_curr_price, 0, ' ', + DECODE(SIGN(a.f_curr_price - b.F_JUN_LAST_PRICE * 100), + 1, '+', -1, '-', 0, ' ')) chg_type + FROM t_sunmul_online a, t_sunmul_batch b + WHERE a.f_stock_code = b.f_stock_code + AND a.f_stock_seq = 1 + AND b.f_market_date = (SELECT MAX(OPEN_DAY) FROM v_open_day) + AND b.F_MONTH_GUBUN = '1' + ) + """; + var current = Oracle( + currentSql, + new DataQueryParameter("displayName", "\uC9C0\uC218", DbType.String)); + var rowsSql = isIntraday + ? """ + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + a.F_DATA_DAY TRADING_DATE, + a.F_DATA_TIME TRADING_TIME, + a.F_PART_INIT_IDX / 100 OPEN_PRICE, + a.F_PART_HIGH_IDX / 100 HIGH_PRICE, + a.F_PART_LOW_IDX / 100 LOW_PRICE, + a.F_PART_IDX / 100 CLOSE_PRICE, + CAST(NULL AS NUMBER) VOLUME, + CAST(NULL AS NUMBER) MA5, + CAST(NULL AS NUMBER) MA20 + FROM t_sunmul_his_5M a + WHERE a.f_data_day = (SELECT MAX(open_day) FROM v_open_day) + ORDER BY a.F_DATA_DAY, a.F_DATA_TIME + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE, TRADING_TIME + """ + : """ + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + F_DATA_DAY TRADING_DATE, + CAST(NULL AS VARCHAR2(6)) TRADING_TIME, + F_PART_INIT_IDX / 100 OPEN_PRICE, + F_PART_HIGH_IDX / 100 HIGH_PRICE, + F_PART_LOW_IDX / 100 LOW_PRICE, + F_PART_IDX / 100 CLOSE_PRICE, + CAST(NULL AS NUMBER) VOLUME, + F_PART_MA5_IDX / 100 MA5, + F_PART_MA20_IDX / 100 MA20 + FROM T_SUNMUL_HIS_DAY + ORDER BY F_DATA_DAY DESC + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE + """; + var rows = Oracle( + rowsSql, + new DataQueryParameter("rowLimit", maximumRows, DbType.Int32)); + return new QueryPair(current, rows); + } + + private static QueryPair Industry( + CandleChartMode mode, + bool isIntraday, + int maximumRows, + string selection, + IndustrySource source) + { + var currentSql = $""" + SELECT + b.f_part_name DISPLAY_NAME, + ROUND(a.f_part_idx / 100, 2) CURRENT_PRICE, + ROUND(a.f_part_chg / 100, 2) CHANGE_PRICE, + ROUND((a.f_part_chg / DECODE( + a.f_chg_type, + '+', a.f_part_idx - a.f_part_chg, + '-', a.f_part_idx + a.f_part_chg, + 1)) * 100, 2) RATE, + a.f_chg_type DIRECTION_CODE + FROM {source.CurrentTable} a, {source.MasterTable} b + WHERE a.f_part_code = b.f_part_code + AND b.f_part_name = :selection + """; + var current = Oracle( + currentSql, + new DataQueryParameter("selection", selection, DbType.String)); + var volume = mode == CandleChartMode.PriceWithVolume + ? "a.F_PART_VOL" + : NullNumber; + var rowsSql = isIntraday + ? $""" + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + a.F_DATA_DAY TRADING_DATE, + a.F_DATA_TIME TRADING_TIME, + a.F_PART_INIT_IDX / 100 OPEN_PRICE, + a.F_PART_HIGH_IDX / 100 HIGH_PRICE, + a.F_PART_LOW_IDX / 100 LOW_PRICE, + a.F_PART_IDX / 100 CLOSE_PRICE, + {volume} VOLUME, + {NullNumber} MA5, + {NullNumber} MA20 + FROM {source.FiveMinuteTable} a, {source.MasterTable} b + WHERE a.f_part_code = b.f_part_code + AND b.f_part_name = :selection + AND a.f_data_day = (SELECT MAX(open_day) FROM v_open_day) + ORDER BY a.F_DATA_DAY, a.F_DATA_TIME + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE, TRADING_TIME + """ + : $""" + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + a.F_DATA_DAY TRADING_DATE, + {NullTime} TRADING_TIME, + a.F_PART_INIT_IDX / 100 OPEN_PRICE, + a.F_PART_HIGH_IDX / 100 HIGH_PRICE, + a.F_PART_LOW_IDX / 100 LOW_PRICE, + a.F_PART_IDX / 100 CLOSE_PRICE, + {volume} VOLUME, + a.F_PART_MA5_IDX / 100 MA5, + a.F_PART_MA20_IDX / 100 MA20 + FROM {source.DailyTable} a, {source.MasterTable} b + WHERE a.f_part_code = b.f_part_code + AND b.f_part_name = :selection + ORDER BY a.F_DATA_DAY DESC + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE + """; + var rows = Oracle( + rowsSql, + new DataQueryParameter("selection", selection, DbType.String), + new DataQueryParameter("rowLimit", maximumRows, DbType.Int32)); + return new QueryPair(current, rows); + } + + private static QueryPair Foreign( + int maximumRows, + string selection, + string instrumentType) + { + const string currentSql = """ + SELECT DISTINCT + a.f_input_name DISPLAY_NAME, + ROUND(b.f_last, 2) CURRENT_PRICE, + ABS(ROUND(b.f_diff, 2)) CHANGE_PRICE, + ROUND(b.f_rate, 2) RATE, + b.f_sign DIRECTION_CODE + 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 b.f_fdtc = :instrumentType + """; + var current = Oracle( + currentSql, + new DataQueryParameter("selection", selection, DbType.String), + new DataQueryParameter("instrumentType", instrumentType, DbType.String)); + const string rowsSql = """ + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT DISTINCT + b.F_xYMD TRADING_DATE, + CAST(NULL AS VARCHAR2(6)) TRADING_TIME, + b.f_open OPEN_PRICE, + b.f_high HIGH_PRICE, + b.f_low LOW_PRICE, + b.f_last CLOSE_PRICE, + CAST(NULL AS NUMBER) VOLUME, + CAST(NULL AS NUMBER) MA5, + CAST(NULL AS NUMBER) MA20 + 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 b.f_fdtc = :instrumentType + ORDER BY b.F_xYMD DESC + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE + """; + var rows = Oracle( + rowsSql, + new DataQueryParameter("selection", selection, DbType.String), + new DataQueryParameter("instrumentType", instrumentType, DbType.String), + new DataQueryParameter("rowLimit", maximumRows, DbType.Int32)); + return new QueryPair(current, rows); + } + + private static QueryPair Stock( + CandleChartMode mode, + bool isIntraday, + int maximumRows, + string selection, + StockSource source) + { + var haltCode = mode == CandleChartMode.TradingHalt ? "Y" : "N"; + string currentSql; + if (mode == CandleChartMode.ExpectedExecution) + { + currentSql = $""" + SELECT DISTINCT + b.F_STOCK_WANNAME DISPLAY_NAME, + a.f_fore_price CURRENT_PRICE, + ABS(a.f_fore_price - c.f_final_price) CHANGE_PRICE, + ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE, + CASE + WHEN a.f_fore_price - c.f_final_price > 0 THEN '+' + WHEN a.f_fore_price - c.f_final_price = 0 THEN ' ' + WHEN a.f_fore_price - c.f_final_price < 0 THEN '-' + END DIRECTION_CODE + FROM {source.ForecastTable} a, {source.MasterTable} b, {source.BatchTable} c + WHERE b.f_stock_wanname = :selection + AND b.f_mkt_halt = :haltCode + AND a.f_stock_code = b.f_stock_code + AND c.f_stock_code = b.f_stock_code + """; + } + else + { + var currentTable = mode == CandleChartMode.TradingHalt + ? source.HaltedCurrentTable + : source.CurrentTable; + currentSql = $""" + SELECT DISTINCT + b.F_STOCK_WANNAME DISPLAY_NAME, + a.f_curr_price CURRENT_PRICE, + ABS(a.f_net_chg) CHANGE_PRICE, + ROUND((a.f_net_chg / DECODE( + a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE, + a.f_chg_type DIRECTION_CODE + FROM {currentTable} a, {source.MasterTable} b + WHERE b.f_stock_wanname = :selection + AND b.f_mkt_halt = :haltCode + AND a.f_curr_price <> 0 + AND a.f_stock_code = b.f_stock_code + """; + } + + var current = Oracle( + currentSql, + new DataQueryParameter("selection", selection, DbType.String), + new DataQueryParameter("haltCode", haltCode, DbType.String)); + var volume = mode == CandleChartMode.PriceWithVolume + ? "a.F_NET_VOL" + : NullNumber; + var rowsSql = isIntraday + ? $""" + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + a.F_DATA_DAY TRADING_DATE, + a.F_DATA_TIME TRADING_TIME, + a.F_INIT_PRICE OPEN_PRICE, + a.F_HIGH_PRICE HIGH_PRICE, + a.F_LOW_PRICE LOW_PRICE, + a.F_CURR_PRICE CLOSE_PRICE, + {volume} VOLUME, + {NullNumber} MA5, + {NullNumber} MA20 + FROM {source.FiveMinuteTable} a, {source.MasterTable} b + WHERE b.f_stock_wanname = :selection + AND b.f_mkt_halt = :haltCode + AND a.f_stock_code = b.f_stock_code + AND a.f_curr_price <> 0 + AND a.f_data_day = (SELECT MAX(open_day) FROM v_open_day) + ORDER BY a.F_DATA_DAY, a.F_DATA_TIME + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE, TRADING_TIME + """ + : $""" + SELECT + TRADING_DATE, TRADING_TIME, + OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE, + VOLUME, MA5, MA20 + FROM ( + SELECT + a.F_DATA_DATE TRADING_DATE, + {NullTime} TRADING_TIME, + a.F_INIT_PRICE OPEN_PRICE, + a.F_HIGH_PRICE HIGH_PRICE, + a.F_LOW_PRICE LOW_PRICE, + a.F_CURR_PRICE CLOSE_PRICE, + {volume} VOLUME, + a.F_MA5_PRICE MA5, + a.F_MA20_PRICE MA20 + FROM {source.DailyTable} a, {source.MasterTable} b + WHERE b.f_stock_wanname = :selection + AND b.f_mkt_halt = :haltCode + AND a.f_stock_code = b.f_stock_code + ORDER BY a.F_DATA_DATE DESC + ) + WHERE ROWNUM <= :rowLimit + ORDER BY TRADING_DATE + """; + var rows = Oracle( + rowsSql, + new DataQueryParameter("selection", selection, DbType.String), + new DataQueryParameter("haltCode", haltCode, DbType.String), + new DataQueryParameter("rowLimit", maximumRows, DbType.Int32)); + return new QueryPair(current, rows); + } + + private static void EnsureSupported(S8010SceneDataRequest request, bool isIntraday) + { + var supported = request.MarketTarget switch + { + CandleMarketTarget.KospiIndex or + CandleMarketTarget.KosdaqIndex or + CandleMarketTarget.Kospi200Index => request.ChartMode switch + { + CandleChartMode.Price or CandleChartMode.PriceWithVolume => true, + CandleChartMode.ExpectedExecution => !isIntraday, + _ => false + }, + CandleMarketTarget.Krx100Index => + request.ChartMode is CandleChartMode.Price or CandleChartMode.PriceWithVolume, + CandleMarketTarget.FuturesIndex => + request.ChartMode == CandleChartMode.Price || + (request.ChartMode == CandleChartMode.ExpectedExecution && !isIntraday), + CandleMarketTarget.KospiIndustry or CandleMarketTarget.KosdaqIndustry => + request.ChartMode is CandleChartMode.Price or CandleChartMode.PriceWithVolume, + CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock => + request.ChartMode == CandleChartMode.Price && !isIntraday, + CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock => + request.ChartMode switch + { + CandleChartMode.Price or CandleChartMode.PriceWithVolume => true, + CandleChartMode.ExpectedExecution or CandleChartMode.TradingHalt => !isIntraday, + _ => false + }, + _ => false + }; + + if (!supported) + { + throw InvalidRequest(); + } + } + + private static bool RequiresSelection(CandleMarketTarget target) => target is + CandleMarketTarget.KospiIndustry or CandleMarketTarget.KosdaqIndustry or + CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock or + CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock; + + private static int MaximumRows(CandleCutAlias alias) => alias switch + { + CandleCutAlias.Cut8035 => 78, + CandleCutAlias.Cut8061 => 5, + CandleCutAlias.Cut8040 => 20, + CandleCutAlias.Cut8046 => 60, + CandleCutAlias.Cut8051 => 120, + CandleCutAlias.Cut8056 => 240, + _ => throw InvalidRequest() + }; + + private static DataQuerySpec Oracle( + string sql, + params DataQueryParameter[] parameters) + { + var spec = new DataQuerySpec(sql, parameters); + spec.ValidateFor(DataSourceKind.Oracle); + return spec; + } + + private static LegacySceneDataException InvalidRequest() => + new("The candle scene data request is unsupported or invalid."); + + private sealed record DomesticIndexSource( + string CurrentTable, + string? ForecastTable, + string FiveMinuteTable, + string DailyTable, + string PartCode); + + private sealed record IndustrySource( + string CurrentTable, + string MasterTable, + string FiveMinuteTable, + string DailyTable); + + private sealed record StockSource( + string CurrentTable, + string ForecastTable, + string HaltedCurrentTable, + string FiveMinuteTable, + string DailyTable, + string MasterTable, + string BatchTable); + + private sealed record QueryPair(DataQuerySpec Current, DataQuerySpec Rows); +} + +internal sealed record S8010CandleQueryPlan( + DataQuerySpec Current, + DataQuerySpec Rows, + int MaximumRows, + bool IsIntraday); + +internal static class S8010CandleTableMapper +{ + private static readonly string[] QuoteColumns = + [ + "DISPLAY_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "RATE", "DIRECTION_CODE" + ]; + + private static readonly string[] RowColumns = + [ + "TRADING_DATE", "TRADING_TIME", + "OPEN_PRICE", "HIGH_PRICE", "LOW_PRICE", "CLOSE_PRICE", + "VOLUME", "MA5", "MA20" + ]; + + public static MappedQuote MapQuote(DataTable? table) + { + RequireSchema(table, QuoteColumns); + if (table!.Rows.Count != 1) + { + throw InvalidResult(); + } + + var row = table.Rows[0]; + var displayName = RequiredText(row, "DISPLAY_NAME"); + var quote = new CandleQuoteData( + RequiredDouble(row, "CURRENT_PRICE"), + RequiredDouble(row, "CHANGE_PRICE"), + RequiredDouble(row, "RATE"), + Direction(row["DIRECTION_CODE"])); + return new MappedQuote(displayName, quote); + } + + public static IReadOnlyList MapRows( + DataTable? table, + bool isIntraday, + int maximumRows) + { + RequireSchema(table, RowColumns); + if (table!.Rows.Count is < 1 || table.Rows.Count > maximumRows) + { + throw InvalidResult(); + } + + var result = new CandleRowData[table.Rows.Count]; + DateOnly? previousDate = null; + TimeOnly? previousTime = null; + for (var index = 0; index < table.Rows.Count; index++) + { + var row = table.Rows[index]; + var date = RequiredDate(row, "TRADING_DATE"); + var time = OptionalTime(row, "TRADING_TIME"); + if (isIntraday != time.HasValue) + { + throw InvalidResult(); + } + + if (previousDate.HasValue) + { + var compare = date.CompareTo(previousDate.Value); + if (compare < 0 || + (compare == 0 && (!isIntraday || + time!.Value.CompareTo(previousTime!.Value) <= 0))) + { + throw InvalidResult(); + } + } + + result[index] = new CandleRowData( + date, + time, + RequiredDouble(row, "OPEN_PRICE"), + RequiredDouble(row, "HIGH_PRICE"), + RequiredDouble(row, "LOW_PRICE"), + RequiredDouble(row, "CLOSE_PRICE"), + OptionalDouble(row, "VOLUME"), + OptionalDouble(row, "MA5"), + OptionalDouble(row, "MA20")); + previousDate = date; + previousTime = time; + } + + return result; + } + + private static void RequireSchema(DataTable? table, IReadOnlyList expected) + { + if (table is null || table.Columns.Count != expected.Count) + { + throw InvalidResult(); + } + + for (var index = 0; index < expected.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + expected[index], + StringComparison.OrdinalIgnoreCase)) + { + throw InvalidResult(); + } + } + } + + private static string RequiredText(DataRow row, string column) + { + var value = row[column]; + if (value is null or DBNull) + { + throw InvalidResult(); + } + + var text = Convert.ToString(value, CultureInfo.InvariantCulture); + try + { + return LegacySceneBuilderGuard.Text(text, column); + } + catch (LegacySceneDataException) + { + throw InvalidResult(); + } + } + + private static double RequiredDouble(DataRow row, string column) + { + var value = OptionalDouble(row, column); + return value ?? throw InvalidResult(); + } + + private static double? OptionalDouble(DataRow row, string column) + { + var value = row[column]; + if (value is null or DBNull) + { + return null; + } + + try + { + var number = value is string text + ? double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) + : Convert.ToDouble(value, CultureInfo.InvariantCulture); + if (!double.IsFinite(number)) + { + throw InvalidResult(); + } + + return number; + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + private static DateOnly RequiredDate(DataRow row, string column) + { + var value = row[column]; + if (value is DateOnly dateOnly) + { + return dateOnly; + } + + if (value is DateTime dateTime) + { + return DateOnly.FromDateTime(dateTime); + } + + if (value is DateTimeOffset offset) + { + return DateOnly.FromDateTime(offset.DateTime); + } + + if (value is null or DBNull) + { + throw InvalidResult(); + } + + var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim(); + string[] formats = ["yyyyMMdd", "yyyy-MM-dd", "yyyy/MM/dd", "yyyy.MM.dd"]; + if (text is null || !DateOnly.TryParseExact( + text, + formats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var result)) + { + throw InvalidResult(); + } + + return result; + } + + private static TimeOnly? OptionalTime(DataRow row, string column) + { + var value = row[column]; + if (value is null or DBNull) + { + return null; + } + + if (value is TimeOnly timeOnly) + { + return timeOnly; + } + + if (value is DateTime dateTime) + { + return TimeOnly.FromDateTime(dateTime); + } + + if (value is TimeSpan span && span >= TimeSpan.Zero && span < TimeSpan.FromDays(1)) + { + return TimeOnly.FromTimeSpan(span); + } + + var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim(); + if (string.IsNullOrEmpty(text)) + { + return null; + } + + if (text.All(char.IsAsciiDigit)) + { + text = text.Length switch + { + <= 4 => text.PadLeft(4, '0'), + <= 6 => text.PadLeft(6, '0'), + _ => text + }; + } + + string[] formats = ["HHmm", "HHmmss", "HH:mm", "HH:mm:ss"]; + if (!TimeOnly.TryParseExact( + text, + formats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var result)) + { + throw InvalidResult(); + } + + return result; + } + + private static ScenePriceDirection Direction(object? value) + { + var text = value is null or DBNull + ? string.Empty + : Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim(); + return text switch + { + "\uC0C1\uD55C" or "1" => ScenePriceDirection.LimitUp, + "\uC0C1\uC2B9" or "+" or "2" => ScenePriceDirection.Up, + null or "" or "\uBCF4\uD569" or "3" => ScenePriceDirection.Flat, + "\uD558\uB77D" or "-" or "5" => ScenePriceDirection.Down, + "\uD558\uD55C" or "4" => ScenePriceDirection.LimitDown, + _ => throw InvalidResult() + }; + } + + private static LegacySceneDataException InvalidResult() => + new("The candle scene query returned invalid data or an unexpected schema."); + + internal sealed record MappedQuote(string DisplayName, CandleQuoteData Quote); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartLegacySceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartLegacySceneRequestResolver.cs new file mode 100644 index 0000000..35ff4a9 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartLegacySceneRequestResolver.cs @@ -0,0 +1,274 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Closed result for the five chart cuts whose legacy constructors selected a DB +/// branch from MainForm's code/jongmok/forCutInfo/sub playlist columns. +/// +public abstract record LegacyChartSceneLoadRequest(string BuilderKey); + +public sealed record LegacyS5078ChartLoadRequest(S5078SceneDataRequest Request) + : LegacyChartSceneLoadRequest("s5078"); + +public sealed record LegacyS5079ChartLoadRequest(S5079SceneDataRequest Request) + : LegacyChartSceneLoadRequest("s5079"); + +public sealed record LegacyS5080ChartLoadRequest(S5080SceneDataRequest Request) + : LegacyChartSceneLoadRequest("s5080"); + +public sealed record LegacyS5083ChartLoadRequest(S5083SceneDataRequest Request) + : LegacyChartSceneLoadRequest("s5083"); + +public sealed record LegacyS5084ChartLoadRequest(S5084SceneDataRequest Request) + : LegacyChartSceneLoadRequest("s5084"); + +/// +/// Reproduces MainForm's five chart-constructor inputs without carrying forward its +/// substring-based cut dispatch. LegacySceneSelection maps GroupCode=code, +/// Subject=jongmok, GraphicType=forCutInfo, Subtype=sub and DataCode=the hidden +/// stock-code column. Every accepted selector belongs to an explicit closed set. +/// +public sealed class ChartLegacySceneRequestResolver +{ + private const int MaximumFieldLength = 256; + private const int MaximumDataCodeLength = 32; + + public LegacyChartSceneLoadRequest Resolve(LegacyPlaylistEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + return entry.CutCode switch + { + "5078" => new LegacyS5078ChartLoadRequest(CreateS5078Request(entry)), + "5079" => new LegacyS5079ChartLoadRequest(CreateS5079Request(entry)), + "5080" => new LegacyS5080ChartLoadRequest(CreateS5080Request(entry)), + "5083" => new LegacyS5083ChartLoadRequest(CreateS5083Request(entry)), + "5084" => new LegacyS5084ChartLoadRequest(CreateS5084Request(entry)), + _ => throw InvalidSelection() + }; + } + + public S5078SceneDataRequest CreateS5078Request(LegacyPlaylistEntry entry) + { + var selection = Require(entry, "5078"); + RequireEmpty(selection.Subtype); + RequireEmpty(selection.DataCode); + + var subject = RequiredExact(selection.Subject); + var group = OptionalTrimmed(selection.GroupCode); + var graphic = RequiredTrimmed(selection.GraphicType); + var market = subject switch + { + "다우" or "DOW" => SectorIndexMarket.Dow, + "나스닥" or "NASDAQ" => SectorIndexMarket.Nasdaq, + "S&P" or "S&P500" => SectorIndexMarket.StandardAndPoor, + "코스피" or "KOSPI" => SectorIndexMarket.Kospi, + "코스닥" or "KOSDAQ" => SectorIndexMarket.Kosdaq, + _ => throw InvalidSelection() + }; + + if (market is SectorIndexMarket.Dow or + SectorIndexMarket.Nasdaq or + SectorIndexMarket.StandardAndPoor) + { + if (!IsOneOf(group, "해외지수", "FOREIGN_INDEX") || + !IsOneOf(graphic, "미국업종", "US_SECTOR_INDEX")) + { + throw InvalidSelection(); + } + } + else if (market == SectorIndexMarket.Kospi) + { + if (!IsOneOf(group, "업종_코스피", "INDUSTRY_KOSPI") || + !IsOneOf(graphic, "섹터지수", "SECTOR_INDEX")) + { + throw InvalidSelection(); + } + } + else if (!IsOneOf(group, "업종_코스닥", "INDUSTRY_KOSDAQ") || + !IsOneOf(graphic, "섹터지수", "SECTOR_INDEX")) + { + throw InvalidSelection(); + } + + return new S5078SceneDataRequest(market); + } + + public S5079SceneDataRequest CreateS5079Request(LegacyPlaylistEntry entry) + { + var selection = Require(entry, "5079"); + ValidateManualStockSelection(selection, "성장성 지표", "GROWTH_METRICS"); + return new S5079SceneDataRequest(RequiredExact(selection.Subject)); + } + + public S5080SceneDataRequest CreateS5080Request(LegacyPlaylistEntry entry) + { + var selection = Require(entry, "5080"); + ValidateManualStockSelection(selection, "매출액", "SALES"); + return new S5080SceneDataRequest(RequiredExact(selection.Subject)); + } + + public S5083SceneDataRequest CreateS5083Request(LegacyPlaylistEntry entry) + { + var selection = Require(entry, "5083"); + RequireIndexLineGraph(selection, expectedGroup: "전체", canonicalGroup: "ALL"); + var participant = RequiredTrimmed(selection.GraphicType) switch + { + "개인 매매동향" or "INDIVIDUAL_TRADING_TREND" => + InvestorFlowParticipant.Individual, + "기관 매매동향" or "INSTITUTION_TRADING_TREND" => + InvestorFlowParticipant.Institution, + "외국인 매매동향" or "FOREIGN_TRADING_TREND" => + InvestorFlowParticipant.Foreign, + _ => throw InvalidSelection() + }; + return new S5083SceneDataRequest(participant); + } + + public S5084SceneDataRequest CreateS5084Request(LegacyPlaylistEntry entry) + { + var selection = Require(entry, "5084"); + RequireCommonIndexLineFields(selection); + var group = RequiredTrimmed(selection.GroupCode); + var index = RequiredTrimmed(selection.GraphicType) switch + { + "코스피 매매동향" or "KOSPI_TRADING_TREND" + when IsOneOf(group, "코스피", "KOSPI") => InvestorFlowIndex.Kospi, + "코스닥 매매동향" or "KOSDAQ_TRADING_TREND" + when IsOneOf(group, "코스닥", "KOSDAQ") => InvestorFlowIndex.Kosdaq, + _ => throw InvalidSelection() + }; + return new S5084SceneDataRequest(index); + } + + private static void ValidateManualStockSelection( + LegacySceneSelection selection, + string originalGraphic, + string canonicalGraphic) + { + _ = RequiredExact(selection.Subject); + var group = RequiredTrimmed(selection.GroupCode); + if (!IsOneOf( + group, + "코스피", + "코스닥", + "코스피_NXT", + "코스닥_NXT", + "KOSPI", + "KOSDAQ", + "NXT_KOSPI", + "NXT_KOSDAQ")) + { + throw InvalidSelection(); + } + + if (!IsOneOf(RequiredTrimmed(selection.GraphicType), originalGraphic, canonicalGraphic)) + { + throw InvalidSelection(); + } + + RequireEmpty(selection.Subtype); + ValidateOptionalDataCode(selection.DataCode); + } + + private static void RequireIndexLineGraph( + LegacySceneSelection selection, + string expectedGroup, + string canonicalGroup) + { + RequireCommonIndexLineFields(selection); + if (!IsOneOf(RequiredTrimmed(selection.GroupCode), expectedGroup, canonicalGroup)) + { + throw InvalidSelection(); + } + } + + private static void RequireCommonIndexLineFields(LegacySceneSelection selection) + { + if (!IsOneOf(RequiredTrimmed(selection.Subject), "지수", "INDEX") || + !IsOneOf(RequiredTrimmed(selection.Subtype), "라인그래프", "LINE_GRAPH")) + { + throw InvalidSelection(); + } + + RequireEmpty(selection.DataCode); + } + + private static LegacySceneSelection Require(LegacyPlaylistEntry entry, string cutCode) + { + ArgumentNullException.ThrowIfNull(entry); + if (entry.CutCode != cutCode || entry.Selection is null) + { + throw InvalidSelection(); + } + + ValidateField(entry.Selection.GroupCode); + ValidateField(entry.Selection.Subject); + ValidateField(entry.Selection.GraphicType); + ValidateField(entry.Selection.Subtype); + ValidateField(entry.Selection.DataCode); + return entry.Selection; + } + + private static string RequiredExact(string? value) + { + ValidateField(value); + if (string.IsNullOrWhiteSpace(value) || value != value.Trim()) + { + throw InvalidSelection(); + } + + return value; + } + + private static string RequiredTrimmed(string? value) + { + ValidateField(value); + var trimmed = value!.Trim(); + return trimmed.Length != 0 ? trimmed : throw InvalidSelection(); + } + + private static string OptionalTrimmed(string? value) + { + ValidateField(value); + return value!.Trim(); + } + + private static void RequireEmpty(string? value) + { + ValidateField(value); + if (!string.IsNullOrWhiteSpace(value)) + { + throw InvalidSelection(); + } + } + + private static void ValidateOptionalDataCode(string? value) + { + ValidateField(value); + if (string.IsNullOrEmpty(value)) + { + return; + } + + if (value.Length > MaximumDataCodeLength || + value.Any(character => !char.IsAsciiLetterOrDigit(character))) + { + throw InvalidSelection(); + } + } + + private static void ValidateField(string? value) + { + if (value is null || value.Length > MaximumFieldLength || value.Any(char.IsControl)) + { + throw InvalidSelection(); + } + } + + private static bool IsOneOf(string value, params string[] accepted) => + accepted.Contains(value, StringComparer.Ordinal); + + private static LegacySceneDataException InvalidSelection() => + new("The chart legacy selection is unsupported or invalid."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs new file mode 100644 index 0000000..04085a1 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs @@ -0,0 +1,735 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum SectorIndexMarket +{ + Dow, + Nasdaq, + StandardAndPoor, + Kospi, + Kosdaq +} + +public enum SectorBarVariant +{ + Primary, + Secondary +} + +public sealed record SectorIndexBarData( + string Category, + double Value, + SectorBarVariant Variant); + +public sealed record S5078SceneData( + SectorIndexMarket Market, + IReadOnlyList Rows) : ILegacySceneData; + +public sealed class S5078SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5078"; + + public IReadOnlyList Build(S5078SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var rows = LegacySceneBuilderGuard.Count(data.Rows, 10, nameof(data.Rows)); + var (title, primaryScaleFactor) = data.Market switch + { + SectorIndexMarket.Dow => ("다우 섹터지수", -1d), + SectorIndexMarket.Nasdaq => ("나스닥 섹터지수", -1d), + SectorIndexMarket.StandardAndPoor => ("S&P 섹터지수", -1d), + SectorIndexMarket.Kospi => ("코스피 섹터지수", 1d), + SectorIndexMarket.Kosdaq => ("코스닥 섹터지수", 1d), + _ => throw new LegacySceneDataException("Unknown sector index market.") + }; + + var validatedRows = new SectorIndexBarData[rows.Count]; + var maximumMagnitude = 0d; + for (var index = 0; index < rows.Count; index++) + { + var row = LegacySceneBuilderGuard.NotNull(rows[index], $"Rows[{index}]"); + LegacySceneBuilderGuard.Text(row.Category, $"Rows[{index}].Category"); + if (!double.IsFinite(row.Value)) + { + throw new LegacySceneDataException("Sector index values must be finite."); + } + + _ = VariantSuffix(row.Variant); + validatedRows[index] = row; + maximumMagnitude = Math.Max(maximumMagnitude, Math.Abs(row.Value)); + } + + var mutations = new List(91) + { + new PlayoutSetValue("title", title) + }; + + for (var index = 1; index <= 10; index++) + { + mutations.Add(new PlayoutSetVisible($"bar{index}_1", false)); + mutations.Add(new PlayoutSetVisible($"value{index}_1", false)); + mutations.Add(new PlayoutSetVisible($"bar{index}_2", false)); + mutations.Add(new PlayoutSetVisible($"value{index}_2", false)); + } + + for (var index = 1; index <= validatedRows.Length; index++) + { + var row = validatedRows[index - 1]; + var suffix = VariantSuffix(row.Variant); + var scaleFactor = row.Variant == SectorBarVariant.Primary + ? primaryScaleFactor + : 1d; + var scale = maximumMagnitude == 0d + ? 0f + : LegacyChartSafety.FiniteFloat( + row.Value / maximumMagnitude * scaleFactor, + "Sector index scale"); + + mutations.Add(new PlayoutSetValue( + "category" + index, + row.Category)); + mutations.Add(new PlayoutSetVisible($"bar{index}_{suffix}", true)); + mutations.Add(new PlayoutSetVisible($"value{index}_{suffix}", true)); + mutations.Add(new PlayoutSetScale( + $"bar{index}_{suffix}", + scale, + 0, + 0, + PlayoutVectorComponents.X)); + mutations.Add(new PlayoutSetValue( + $"value{index}_{suffix}", + row.Value.ToString("##0.00", CultureInfo.InvariantCulture))); + } + + return mutations; + } + + private static int VariantSuffix(SectorBarVariant variant) => variant switch + { + SectorBarVariant.Primary => 1, + SectorBarVariant.Secondary => 2, + _ => throw new LegacySceneDataException("Unknown sector bar variant.") + }; +} + +public enum GrowthMetricPath +{ + First, + Second, + Third, + Fourth +} + +/// +/// Null values represent the legacy empty token and are normalized to zero. The first +/// value controls visibility exactly as the original rs[0] empty check did. +/// +public sealed record GrowthMetricSeriesData( + GrowthMetricPath Path, + IReadOnlyList Values); + +public sealed record S5079SceneData( + string StockName, + IReadOnlyList Series, + IReadOnlyList Dates) : ILegacySceneData; + +public sealed class S5079SceneMutationBuilder : ILegacySceneMutationBuilder +{ + private static readonly float[] XPositions = [0, 368, 740, 1110]; + + public string BuilderKey => "s5079"; + + public IReadOnlyList Build(S5079SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + var sourceSeries = LegacySceneBuilderGuard.Count(data.Series, 4, nameof(data.Series)); + var dates = LegacySceneBuilderGuard.Count(data.Dates, 4, nameof(data.Dates)); + var normalized = new Dictionary(); + + for (var seriesIndex = 0; seriesIndex < sourceSeries.Count; seriesIndex++) + { + var series = LegacySceneBuilderGuard.NotNull( + sourceSeries[seriesIndex], + $"Series[{seriesIndex}]"); + _ = PathNumber(series.Path); + var values = LegacySceneBuilderGuard.Count( + series.Values, + 4, + $"Series[{seriesIndex}].Values"); + var normalizedValues = new double[4]; + for (var valueIndex = 0; valueIndex < values.Count; valueIndex++) + { + var value = values[valueIndex] ?? 0d; + if (!double.IsFinite(value)) + { + throw new LegacySceneDataException("Growth metric values must be finite."); + } + + normalizedValues[valueIndex] = value; + } + + if (!normalized.TryAdd( + series.Path, + new NormalizedGrowthSeries(values[0].HasValue, normalizedValues))) + { + throw new LegacySceneDataException("Growth metric paths must be unique."); + } + } + + foreach (var path in Enum.GetValues()) + { + if (!normalized.ContainsKey(path)) + { + throw new LegacySceneDataException("All four growth metric paths are required."); + } + } + + var validatedDates = new string[dates.Count]; + for (var index = 0; index < dates.Count; index++) + { + validatedDates[index] = LegacySceneBuilderGuard.Text( + dates[index], + $"Dates[{index}]", + allowEmpty: true); + } + + var allValues = normalized.Values.SelectMany(item => item.Values).ToArray(); + var maximum = allValues.Max(); + var minimum = allValues.Min(); + var range = maximum - minimum; + if (!double.IsFinite(range)) + { + throw new LegacySceneDataException("Growth metric range must be finite."); + } + + var gap = range / 5d; + var mutations = new List(36) + { + new PlayoutSetValue("title", stockName + " 성장성 지표") + }; + for (var index = 1; index <= 5; index++) + { + mutations.Add(new PlayoutSetValue( + "value" + index, + Math.Ceiling(minimum + (gap * index)) + .ToString(CultureInfo.InvariantCulture))); + } + + foreach (var path in Enum.GetValues()) + { + var pathNumber = PathNumber(path); + var series = normalized[path]; + var points = new PlayoutPoint[4]; + for (var index = 0; index < points.Length; index++) + { + var y = range == 0d + ? 0f + : LegacyChartSafety.FiniteFloat( + (((360d * (series.Values[index] - minimum) / range) - 180d) * 0.8d), + "Growth metric Y position"); + points[index] = new PlayoutPoint(XPositions[index], y, 0); + } + + AppendLegacyGrowthPath( + mutations, + pathNumber, + series.IsVisible, + points); + } + + for (var index = 1; index <= validatedDates.Length; index++) + { + mutations.Add(new PlayoutSetValue("date" + index, validatedDates[index - 1])); + } + + return mutations; + } + + private static void AppendLegacyGrowthPath( + ICollection mutations, + int pathNumber, + bool isVisible, + IReadOnlyList points) + { + var objectName = "path" + pathNumber; + if (pathNumber != 3) + { + mutations.Add(new PlayoutSetVisible(objectName, false)); + if (isVisible) + { + // The original sets visibility once per point inside its AddPathPoint loop. + for (var index = 0; index < points.Count; index++) + { + mutations.Add(new PlayoutSetVisible(objectName, true)); + } + } + } + + // PlayoutSetPathPoints expands to Begin/Clear/Add*/End in the adapter. + mutations.Add(new PlayoutSetPathPoints(objectName, points)); + if (pathNumber == 3) + { + mutations.Add(new PlayoutSetVisible(objectName, isVisible)); + } + } + + private static int PathNumber(GrowthMetricPath path) => path switch + { + GrowthMetricPath.First => 1, + GrowthMetricPath.Second => 2, + GrowthMetricPath.Third => 3, + GrowthMetricPath.Fourth => 4, + _ => throw new LegacySceneDataException("Unknown growth metric path.") + }; + + private sealed record NormalizedGrowthSeries(bool IsVisible, double[] Values); +} + +public sealed record QuarterlySalesData(string Quarter, int Value); + +public sealed record S5080SceneData( + string StockName, + IReadOnlyList Quarters) : ILegacySceneData; + +public sealed class S5080SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5080"; + + public IReadOnlyList Build(S5080SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + var quarters = LegacySceneBuilderGuard.Count(data.Quarters, 6, nameof(data.Quarters)); + var validated = new QuarterlySalesData[quarters.Count]; + var mutations = new List(38) + { + new PlayoutSetValue("title", stockName + " 매출액") + }; + + for (var index = 0; index < quarters.Count; index++) + { + var quarter = LegacySceneBuilderGuard.NotNull( + quarters[index], + $"Quarters[{index}]"); + validated[index] = quarter; + mutations.Add(new PlayoutSetValue( + "quarter" + (index + 1), + LegacySceneBuilderGuard.Text( + quarter.Quarter, + $"Quarters[{index}].Quarter"))); + } + + for (var index = 1; index <= validated.Length; index++) + { + var value = validated[index - 1].Value; + var variant = value < 0 ? 2 : 1; + mutations.Add(new PlayoutSetVisible($"barG{index}_1", false)); + mutations.Add(new PlayoutSetVisible($"barG{index}_2", false)); + mutations.Add(new PlayoutSetVisible($"barG{index}_{variant}", true)); + mutations.Add(new PlayoutSetValue( + $"value{index}_{variant}", + value.ToString("#,##0", CultureInfo.InvariantCulture))); + } + + var maximum = validated.Max(item => item.Value); + var minimum = validated.Min(item => item.Value); + var allPositive = maximum > 0 && minimum > 0; + var allNegative = maximum < 0 && minimum < 0; + var mixedDenominator = (double)maximum + Math.Abs((double)minimum); + var center = allPositive + ? -240f + : allNegative + ? 115f + : mixedDenominator == 0d + ? -250f + : LegacyChartSafety.FiniteFloat( + (355d * Math.Abs((double)minimum) / mixedDenominator) - 250d, + "Quarterly sales center"); + mutations.Add(new PlayoutSetPositionKey( + "centerbar", + 0, + 0, + center, + 0, + PlayoutVectorComponents.Y)); + + var denominator = allPositive + ? (double)maximum + : allNegative + ? (double)minimum + : (double)maximum - minimum; + for (var index = 1; index <= validated.Length; index++) + { + var value = validated[index - 1].Value; + var scale = denominator == 0d + ? 0f + : LegacyChartSafety.FiniteFloat( + 1.8d * Math.Abs(value / denominator), + "Quarterly sales scale"); + var variant = value < 0 ? 2 : 1; + mutations.Add(new PlayoutSetScale( + $"bar{index}_{variant}", + 0, + scale, + 0, + PlayoutVectorComponents.Y)); + } + + return mutations; + } +} + +public enum InvestorFlowParticipant +{ + Individual, + Institution, + Foreign +} + +public enum InvestorFlowMarket +{ + Kospi, + Kosdaq, + Kospi200 +} + +public sealed record InvestorFlowSummaryData( + InvestorFlowMarket Market, + int Value); + +public sealed record InvestorFlowTrendPoint( + InvestorFlowMarket Market, + int Value, + TimeOnly DataTime); + +public sealed record S5083SceneData( + InvestorFlowParticipant Participant, + IReadOnlyList Summary, + IReadOnlyList TrendPoints) : ILegacySceneData; + +public sealed class S5083SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5083"; + + public IReadOnlyList Build(S5083SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var title = data.Participant switch + { + InvestorFlowParticipant.Individual => "개인 매매동향", + InvestorFlowParticipant.Institution => "기관 매매동향", + InvestorFlowParticipant.Foreign => "외국인 매매동향", + _ => throw new LegacySceneDataException("Unknown investor flow participant.") + }; + var summary = LegacySceneBuilderGuard.Count(data.Summary, 3, nameof(data.Summary)); + var trendPoints = LegacySceneBuilderGuard.Range( + data.TrendPoints, + 3, + 10_000, + nameof(data.TrendPoints)); + + var seenSummaryMarkets = new HashSet(); + foreach (var row in summary) + { + var validated = LegacySceneBuilderGuard.NotNull(row, nameof(data.Summary)); + _ = MarketSlot(validated.Market); + if (!seenSummaryMarkets.Add(validated.Market)) + { + throw new LegacySceneDataException("Investor flow summary markets must be unique."); + } + } + + var grouped = new Dictionary> + { + [InvestorFlowMarket.Kospi] = [], + [InvestorFlowMarket.Kosdaq] = [], + [InvestorFlowMarket.Kospi200] = [] + }; + var maximum = 0d; + var minimum = 0d; + foreach (var point in trendPoints) + { + var validated = LegacySceneBuilderGuard.NotNull(point, nameof(data.TrendPoints)); + _ = MarketSlot(validated.Market); + grouped[validated.Market].Add(validated.Value); + maximum = Math.Max(maximum, validated.Value); + minimum = Math.Min(minimum, validated.Value); + } + + if (grouped.Values.Any(values => values.Count == 0)) + { + throw new LegacySceneDataException("Every investor flow market requires trend data."); + } + + var scale = LegacyIntradayChartScale.Create(maximum, minimum); + var lineWidth = LegacyIntradayChartScale.LineWidth(trendPoints[^1].DataTime); + var mutations = new List(8) + { + new PlayoutSetValue("title", title) + }; + foreach (var row in summary) + { + mutations.Add(new PlayoutSetValue( + "price" + MarketSlot(row.Market), + row.Value.ToString("#,##0", CultureInfo.InvariantCulture))); + } + + mutations.Add(new PlayoutSetPosition( + "baseline", + 0, + scale.BaselinePosition, + 0, + PlayoutVectorComponents.Y)); + + var kospi = grouped[InvestorFlowMarket.Kospi]; + mutations.Add(new PlayoutSetPathPoints( + "path1", + BuildS5083Path(kospi, lineWidth, scale, S5083XMode.FirstPath, kospi.Count))); + mutations.Add(new PlayoutSetPathPoints( + "path2", + BuildS5083Path( + grouped[InvestorFlowMarket.Kosdaq], + lineWidth, + scale, + S5083XMode.FollowingPath, + kospi.Count))); + mutations.Add(new PlayoutSetPathPoints( + "path3", + BuildS5083Path( + grouped[InvestorFlowMarket.Kospi200], + lineWidth, + scale, + S5083XMode.FollowingPath, + grouped[InvestorFlowMarket.Kospi200].Count))); + return mutations; + } + + private static IReadOnlyList BuildS5083Path( + IReadOnlyList values, + double lineWidth, + LegacyIntradayChartScale scale, + S5083XMode xMode, + int denominatorCount) + { + var points = new List(values.Count + 1) + { + new(0, scale.Baseline, 0) + }; + for (var index = 0; index < values.Count; index++) + { + double x; + if (xMode == S5083XMode.FirstPath) + { + x = values.Count == 1 + ? 0d + : lineWidth / (values.Count - 1d) * index; + } + else + { + x = lineWidth / denominatorCount * (index + 1d); + } + + points.Add(new PlayoutPoint( + LegacyChartSafety.FiniteFloat(x, "Investor flow X position"), + scale.Y(values[index]), + 0)); + } + + return points; + } + + private static int MarketSlot(InvestorFlowMarket market) => market switch + { + InvestorFlowMarket.Kospi => 1, + InvestorFlowMarket.Kosdaq => 2, + InvestorFlowMarket.Kospi200 => 3, + _ => throw new LegacySceneDataException("Unknown investor flow market.") + }; + + private enum S5083XMode + { + FirstPath, + FollowingPath + } +} + +public enum InvestorFlowIndex +{ + Kospi, + Kosdaq +} + +public sealed record InvestorFlowTotalsData( + int Individual, + int Foreign, + int Institution); + +public sealed record InvestorFlowIndexTrendPoint( + int Individual, + int Foreign, + int Institution, + TimeOnly DataTime); + +public sealed record S5084SceneData( + InvestorFlowIndex Index, + InvestorFlowTotalsData Totals, + IReadOnlyList TrendPoints) : ILegacySceneData; + +public sealed class S5084SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5084"; + + public IReadOnlyList Build(S5084SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var title = data.Index switch + { + InvestorFlowIndex.Kospi => "코스피 매매동향", + InvestorFlowIndex.Kosdaq => "코스닥 매매동향", + _ => throw new LegacySceneDataException("Unknown investor flow index.") + }; + var totals = LegacySceneBuilderGuard.NotNull(data.Totals, nameof(data.Totals)); + var trendPoints = LegacySceneBuilderGuard.Range( + data.TrendPoints, + 1, + 10_000, + nameof(data.TrendPoints)); + + var maximum = 0d; + var minimum = 0d; + foreach (var point in trendPoints) + { + var validated = LegacySceneBuilderGuard.NotNull(point, nameof(data.TrendPoints)); + maximum = Math.Max( + maximum, + Math.Max(validated.Individual, Math.Max(validated.Foreign, validated.Institution))); + minimum = Math.Min( + minimum, + Math.Min(validated.Individual, Math.Min(validated.Foreign, validated.Institution))); + } + + var scale = LegacyIntradayChartScale.Create(maximum, minimum); + var lineWidth = LegacyIntradayChartScale.LineWidth(trendPoints[^1].DataTime); + var mutations = new List(8) + { + new PlayoutSetValue("title", title), + new PlayoutSetValue( + "price1", + totals.Individual.ToString("#,##0", CultureInfo.InvariantCulture)), + new PlayoutSetValue( + "price2", + totals.Foreign.ToString("#,##0", CultureInfo.InvariantCulture)), + new PlayoutSetValue( + "price3", + totals.Institution.ToString("#,##0", CultureInfo.InvariantCulture)), + new PlayoutSetPosition( + "baseline", + 0, + scale.BaselinePosition, + 0, + PlayoutVectorComponents.Y), + new PlayoutSetPathPoints( + "path1", + BuildS5084Path(trendPoints.Select(item => item.Individual).ToArray(), lineWidth, scale)), + new PlayoutSetPathPoints( + "path2", + BuildS5084Path(trendPoints.Select(item => item.Foreign).ToArray(), lineWidth, scale)), + new PlayoutSetPathPoints( + "path3", + BuildS5084Path(trendPoints.Select(item => item.Institution).ToArray(), lineWidth, scale)) + }; + return mutations; + } + + private static IReadOnlyList BuildS5084Path( + IReadOnlyList values, + double lineWidth, + LegacyIntradayChartScale scale) + { + var points = new List(values.Count + 1) + { + new(0, scale.Baseline, 0) + }; + for (var index = 0; index < values.Count; index++) + { + points.Add(new PlayoutPoint( + LegacyChartSafety.FiniteFloat( + lineWidth / values.Count * (index + 1d), + "Index flow X position"), + scale.Y(values[index]), + 0)); + } + + return points; + } +} + +internal sealed record LegacyIntradayChartScale( + double Minimum, + double Range, + float Baseline, + float BaselinePosition) +{ + private const double GraphMinimum = -138d; + private const double GraphHeight = 266d; + private const double GraphOffset = 48d; + private const double DegenerateMidpoint = -5d; + private const double FullLineWidth = 1212.26d; + + public static LegacyIntradayChartScale Create(double maximum, double minimum) + { + var range = maximum - minimum; + if (!double.IsFinite(maximum) || !double.IsFinite(minimum) || !double.IsFinite(range)) + { + throw new LegacySceneDataException("Intraday chart range must be finite."); + } + + var baseline = range == 0d + ? DegenerateMidpoint + : GraphMinimum + ((0d - minimum) / range * GraphHeight); + var baselinePosition = Convert.ToInt32(GraphOffset + baseline); + return new LegacyIntradayChartScale( + minimum, + range, + LegacyChartSafety.FiniteFloat(baseline, "Intraday baseline"), + baselinePosition); + } + + public static double LineWidth(TimeOnly time) + { + var minutes = (time.Hour * 60) + time.Minute; + if (minutes >= 930) + { + return FullLineWidth; + } + + // The legacy formula becomes negative before 09:00. Clamp to the physical graph. + var progress = Math.Clamp((minutes - 540d) / 390d, 0d, 1d); + return FullLineWidth * progress; + } + + public float Y(double value) + { + var y = Range == 0d + ? DegenerateMidpoint + : GraphMinimum + ((value - Minimum) / Range * GraphHeight); + return LegacyChartSafety.FiniteFloat(y, "Intraday Y position"); + } +} + +internal static class LegacyChartSafety +{ + public static float FiniteFloat(double value, string name) + { + if (!double.IsFinite(value) || value < -float.MaxValue || value > float.MaxValue) + { + throw new LegacySceneDataException(name + " must be a finite float."); + } + + return (float)value; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneDataLoaders.cs new file mode 100644 index 0000000..46e0fa2 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneDataLoaders.cs @@ -0,0 +1,909 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed record S5078SceneDataRequest(SectorIndexMarket Market); + +public sealed class S5078SceneDataLoader +{ + private static readonly string[] Columns = + ["CATEGORY", "PART_INDEX", "CHG_TYPE", "PART_CHANGE", "RATE"]; + + private readonly IDataQueryExecutor _executor; + + public S5078SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5078SceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var spec = S5078QueryFactory.Create(request.Market); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S5078_SECTOR_INDEX", + spec, + cancellationToken).ConfigureAwait(false); + var rows = ChartSceneDataLoaderReader.Rows(table, Columns, 10, 10); + var mapped = new SectorIndexBarData[rows.Count]; + for (var index = 0; index < rows.Count; index++) + { + var row = rows[index]; + // These values were returned and displayed by the original query even though + // only RATE participates in the K3D mutation. Validate them fail-closed. + _ = ChartSceneDataLoaderReader.Double(row, "PART_INDEX"); + _ = ChartSceneDataLoaderReader.Double(row, "PART_CHANGE"); + var changeType = ChartSceneDataLoaderReader.Text( + row, + "CHG_TYPE", + allowEmpty: true).Trim(); + mapped[index] = new SectorIndexBarData( + ChartSceneDataLoaderReader.Text(row, "CATEGORY"), + ChartSceneDataLoaderReader.Double(row, "RATE"), + changeType == "-" + ? SectorBarVariant.Primary + : SectorBarVariant.Secondary); + } + + var data = new S5078SceneData(request.Market, mapped); + _ = new S5078SceneMutationBuilder().Build(data); + return data; + } +} + +internal static class S5078QueryFactory +{ + private const string KospiQuery = """ + SELECT + b.f_part_name CATEGORY, + a.F_PART_IDX / 100 PART_INDEX, + a.F_CHG_TYPE CHG_TYPE, + a.F_PART_CHG / 100 PART_CHANGE, + ROUND( + a.f_part_chg / + DECODE( + a.f_chg_type, + '+', (a.f_part_idx / 100) - (a.f_part_chg / 100), + '-', (a.f_part_idx / 100) + (a.f_part_chg / 100)), + 2) RATE + FROM t_index a, t_part b + WHERE a.f_part_code = b.f_part_code + AND a.f_part_code IN + ('013', '009', '011', '016', '018', '021', '008', '007', '015', '005') + ORDER BY a.f_part_code + """; + + private const string KosdaqQuery = """ + SELECT + b.f_part_name CATEGORY, + a.F_PART_IDX / 100 PART_INDEX, + a.F_CHG_TYPE CHG_TYPE, + a.F_PART_CHG / 100 PART_CHANGE, + ROUND( + a.f_part_chg / + DECODE( + a.f_chg_type, + '+', (a.f_part_idx / 100) - (a.f_part_chg / 100), + '-', (a.f_part_idx / 100) + (a.f_part_chg / 100)), + 2) RATE + FROM t_kosdaq_index a, t_kosdaq_part b + WHERE a.f_part_code = b.f_part_code + AND a.f_part_code IN + ('159', '160', '066', '154', '065', '031', '029', '070', '024', '153') + ORDER BY a.f_part_code + """; + + private const string DowQuery = """ + SELECT + a.f_input_name CATEGORY, + ROUND(b.f_last, 2) PART_INDEX, + b.f_sign CHG_TYPE, + ABS(ROUND(b.f_diff, 2)) PART_CHANGE, + ROUND(b.f_rate, 2) RATE + FROM t_world_ix_eq_master a, t_world_ix_eq_sise b + WHERE a.f_symb = b.f_symb + AND a.f_symb IN + ('DJI@DJINET', 'DJI@DJUSAU', 'DJI@DJUSCH', 'DJI@DJUSNF', 'DJI@DJUSCN', + 'DJI@DJT', 'DJI@DJU', 'DJI@DJUSFB', 'DJI@DJUSCFT', 'DJI@DJUSEE') + """; + + private const string NasdaqQuery = """ + SELECT + a.f_input_name CATEGORY, + ROUND(b.f_last, 2) PART_INDEX, + b.f_sign CHG_TYPE, + ABS(ROUND(b.f_diff, 2)) PART_CHANGE, + ROUND(b.f_rate, 2) RATE + FROM t_world_ix_eq_master a, t_world_ix_eq_sise b + WHERE a.f_symb = b.f_symb + AND a.f_symb IN + ('NAS@CXBT', 'NAS@NDX', 'NAS@NQSSSE', 'NAS@IXBK', 'NAS@IXIS', + 'NAS@IXUT', 'NAS@IXK', 'NAS@NBI', 'NAS@IXF', 'NAS@NDXT') + """; + + private const string StandardAndPoorQuery = """ + SELECT + a.f_input_name CATEGORY, + ROUND(b.f_last, 2) PART_INDEX, + b.f_sign CHG_TYPE, + ABS(ROUND(b.f_diff, 2)) PART_CHANGE, + ROUND(b.f_rate, 2) RATE + FROM t_world_ix_eq_master a, t_world_ix_eq_sise b + WHERE a.f_symb = b.f_symb + AND a.f_symb IN + ('SPI@S5ENRS', 'SPI@S5RLST', 'SPI@S5AUCO', 'SPI@S5INFT', 'SPI@S5COND', + 'SPI@S5INSU', 'SPI@S5ELUTX', 'SPI@S5HLTH', 'SPI@S5FINL', 'SPI@S5TELS') + """; + + public static DataQuerySpec Create(SectorIndexMarket market) + { + var sql = market switch + { + SectorIndexMarket.Dow => DowQuery, + SectorIndexMarket.Nasdaq => NasdaqQuery, + SectorIndexMarket.StandardAndPoor => StandardAndPoorQuery, + SectorIndexMarket.Kospi => KospiQuery, + SectorIndexMarket.Kosdaq => KosdaqQuery, + _ => throw ChartSceneDataLoaderReader.InvalidRequest() + }; + return ChartSceneDataLoaderReader.Spec(sql); + } +} + +public sealed record S5079SceneDataRequest(string StockName); + +public sealed class S5079SceneDataLoader +{ + private static readonly string[] Columns = + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"]; + + private const string Query = """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE + FROM INPUT_GROW + WHERE STOCK_NAME = :stockName + """; + + private readonly IDataQueryExecutor _executor; + + public S5079SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5079SceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var stockName = ChartSceneDataLoaderReader.Selection(request.StockName); + var spec = ChartSceneDataLoaderReader.Spec( + Query, + new DataQueryParameter("stockName", stockName, DbType.String)); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "INPUT_GROW", + spec, + cancellationToken).ConfigureAwait(false); + var row = ChartSceneDataLoaderReader.SingleRow(table, Columns); + var series = new GrowthMetricSeriesData[4]; + foreach (var path in Enum.GetValues()) + { + var column = "GUSUNG_" + (((int)path) + 1).ToString(CultureInfo.InvariantCulture); + series[(int)path] = new GrowthMetricSeriesData( + path, + ParseGrowthSeries(ChartSceneDataLoaderReader.Text(row, column, allowEmpty: true))); + } + + var dates = ChartSceneDataLoaderReader.Text(row, "BASE_DATE", allowEmpty: true) + .Split('_', StringSplitOptions.None); + if (dates.Length != 4) + { + throw ChartSceneDataLoaderReader.InvalidResult(); + } + + for (var index = 0; index < dates.Length; index++) + { + _ = LegacySceneBuilderGuard.Text( + dates[index], + $"Dates[{index}]", + allowEmpty: true); + } + + var data = new S5079SceneData( + ChartSceneDataLoaderReader.Text(row, "STOCK_NAME"), + series, + dates); + _ = new S5079SceneMutationBuilder().Build(data); + return data; + } + + private static IReadOnlyList ParseGrowthSeries(string value) + { + var tokens = value.Split('_', StringSplitOptions.None); + if (tokens.Length != 4) + { + throw ChartSceneDataLoaderReader.InvalidResult(); + } + + var values = new double?[4]; + for (var index = 0; index < tokens.Length; index++) + { + if (tokens[index].Length == 0) + { + values[index] = null; + continue; + } + + if (!double.TryParse( + tokens[index], + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out var parsed) || + !double.IsFinite(parsed)) + { + throw ChartSceneDataLoaderReader.InvalidResult(); + } + + values[index] = parsed; + } + + return values; + } +} + +public sealed record S5080SceneDataRequest(string StockName); + +public sealed class S5080SceneDataLoader +{ + private static readonly string[] Columns = + [ + "STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", + "GUSUNG_4", "GUSUNG_5", "GUSUNG_6" + ]; + + private const string Query = """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6 + FROM INPUT_SELL + WHERE STOCK_NAME = :stockName + """; + + private readonly IDataQueryExecutor _executor; + + public S5080SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5080SceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var stockName = ChartSceneDataLoaderReader.Selection(request.StockName); + var spec = ChartSceneDataLoaderReader.Spec( + Query, + new DataQueryParameter("stockName", stockName, DbType.String)); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "INPUT_SELL", + spec, + cancellationToken).ConfigureAwait(false); + var row = ChartSceneDataLoaderReader.SingleRow(table, Columns); + var quarters = new QuarterlySalesData[6]; + for (var index = 0; index < quarters.Length; index++) + { + var raw = ChartSceneDataLoaderReader.Text( + row, + "GUSUNG_" + (index + 1).ToString(CultureInfo.InvariantCulture), + allowEmpty: true); + quarters[index] = ParseQuarter(raw); + } + + var data = new S5080SceneData( + ChartSceneDataLoaderReader.Text(row, "STOCK_NAME"), + quarters); + _ = new S5080SceneMutationBuilder().Build(data); + return data; + } + + private static QuarterlySalesData ParseQuarter(string value) + { + var tokens = value.Split('_', StringSplitOptions.None); + if (tokens.Length != 2 || string.IsNullOrWhiteSpace(tokens[0])) + { + throw ChartSceneDataLoaderReader.InvalidResult(); + } + + var number = 0; + if (tokens[1].Length != 0 && + !int.TryParse( + tokens[1], + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out number)) + { + throw ChartSceneDataLoaderReader.InvalidResult(); + } + + return new QuarterlySalesData(tokens[0], number); + } +} + +public sealed record S5083SceneDataRequest(InvestorFlowParticipant Participant); + +public sealed class S5083SceneDataLoader +{ + private static readonly string[] SummaryColumns = ["MARKET", "AMOUNT"]; + private static readonly string[] TrendColumns = ["MARKET", "DATA_TIME", "PRICE"]; + + private readonly IDataQueryExecutor _executor; + + public S5083SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5083SceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var plan = S5083QueryFactory.Create(request.Participant); + var summaryTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S5083_SUMMARY", + plan.Summary, + cancellationToken).ConfigureAwait(false); + var trendTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S5083_TREND", + plan.Trend, + cancellationToken).ConfigureAwait(false); + + var summaryRows = ChartSceneDataLoaderReader.Rows(summaryTable, SummaryColumns, 3, 3); + var summary = summaryRows + .Select(row => new InvestorFlowSummaryData( + ChartSceneDataLoaderReader.Market(row, "MARKET"), + ChartSceneDataLoaderReader.Int32(row, "AMOUNT"))) + .ToArray(); + + var trendRows = ChartSceneDataLoaderReader.Rows(trendTable, TrendColumns, 3, 10_000); + var trend = trendRows + .Select(row => new InvestorFlowTrendPoint( + ChartSceneDataLoaderReader.Market(row, "MARKET"), + ChartSceneDataLoaderReader.Int32(row, "PRICE"), + ChartSceneDataLoaderReader.Time(row, "DATA_TIME"))) + .ToArray(); + + var data = new S5083SceneData(request.Participant, summary, trend); + _ = new S5083SceneMutationBuilder().Build(data); + return data; + } +} + +internal sealed record S5083QueryPlan(DataQuerySpec Summary, DataQuerySpec Trend); + +internal static class S5083QueryFactory +{ + private const string IndividualSummary = """ + SELECT '코스피' MARKET, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT + FROM t_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE = '8000' + UNION + SELECT '코스닥' MARKET, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT + FROM t_kosdaq_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE = '8000' + UNION + SELECT '코스피200' MARKET, + ROUND((SUM(F_SELL_turnover) / 100000000) - + (SUM(F_BUY_turnover) / 100000000)) AMOUNT + FROM t_invest + WHERE F_PART_CODE = '029' + AND F_INVEST_CODE = '8000' + """; + + private const string IndividualTrend = """ + SELECT '코스피' MARKET, + f_data_time DATA_TIME, + ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE + FROM t_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE = '8000' + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + UNION + SELECT '코스닥' MARKET, + f_data_time DATA_TIME, + ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE + FROM t_kosdaq_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE = '8000' + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + UNION + SELECT '코스피200' MARKET, + f_data_time DATA_TIME, + ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE + FROM t_invest_his + WHERE F_PART_CODE = '029' + AND F_INVEST_CODE = '8000' + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + ORDER BY MARKET, DATA_TIME + """; + + private const string InstitutionSummary = """ + SELECT '코스피' MARKET, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT + FROM t_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000') + GROUP BY f_part_code + UNION + SELECT '코스닥' MARKET, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT + FROM t_kosdaq_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000') + GROUP BY f_part_code + UNION + SELECT '코스피200' MARKET, + ROUND((SUM(F_SELL_turnover) / 100000000) - + (SUM(F_BUY_turnover) / 100000000)) AMOUNT + FROM t_invest + WHERE F_PART_CODE = '029' + AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000') + """; + + private const string InstitutionTrend = """ + SELECT '코스피' MARKET, + f_data_time DATA_TIME, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE + FROM t_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time + UNION + SELECT '코스닥' MARKET, + f_data_time DATA_TIME, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE + FROM t_kosdaq_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time + UNION + SELECT '코스피200' MARKET, + f_data_time DATA_TIME, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE + FROM t_invest_his + WHERE F_PART_CODE = '029' + AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time + ORDER BY MARKET, DATA_TIME + """; + + private const string ForeignSummary = """ + SELECT '코스피' MARKET, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT + FROM t_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('9000', '9001') + UNION + SELECT '코스닥' MARKET, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT + FROM t_kosdaq_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('9000', '9001') + UNION + SELECT '코스피200' MARKET, + ROUND((SUM(F_SELL_turnover) / 100000000) - + (SUM(F_BUY_turnover) / 100000000)) AMOUNT + FROM t_invest + WHERE F_PART_CODE = '029' + AND F_INVEST_CODE IN ('9000', '9001') + """; + + private const string ForeignTrend = """ + SELECT '코스피' MARKET, + f_data_time DATA_TIME, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE + FROM t_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('9000', '9001') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time + UNION + SELECT '코스닥' MARKET, + f_data_time DATA_TIME, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE + FROM t_kosdaq_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN ('9000', '9001') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time + UNION + SELECT '코스피200' MARKET, + f_data_time DATA_TIME, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE + FROM t_invest_his + WHERE F_PART_CODE = '029' + AND F_INVEST_CODE IN ('9000', '9001') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time + ORDER BY MARKET, DATA_TIME + """; + + public static S5083QueryPlan Create(InvestorFlowParticipant participant) + { + var queries = participant switch + { + InvestorFlowParticipant.Individual => (IndividualSummary, IndividualTrend), + InvestorFlowParticipant.Institution => (InstitutionSummary, InstitutionTrend), + InvestorFlowParticipant.Foreign => (ForeignSummary, ForeignTrend), + _ => throw ChartSceneDataLoaderReader.InvalidRequest() + }; + return new S5083QueryPlan( + ChartSceneDataLoaderReader.Spec(queries.Item1), + ChartSceneDataLoaderReader.Spec(queries.Item2)); + } +} + +public sealed record S5084SceneDataRequest(InvestorFlowIndex Index); + +public sealed class S5084SceneDataLoader +{ + private static readonly string[] SummaryColumns = + ["INDIVIDUAL", "FOREIGN", "INSTITUTION"]; + private static readonly string[] TrendColumns = + ["DATA_TIME", "INDIVIDUAL", "FOREIGN", "INSTITUTION"]; + + private readonly IDataQueryExecutor _executor; + + public S5084SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5084SceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var plan = S5084QueryFactory.Create(request.Index); + var summaryTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S5084_SUMMARY", + plan.Summary, + cancellationToken).ConfigureAwait(false); + var trendTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S5084_TREND", + plan.Trend, + cancellationToken).ConfigureAwait(false); + var summaryRow = ChartSceneDataLoaderReader.SingleRow(summaryTable, SummaryColumns); + var totals = new InvestorFlowTotalsData( + ChartSceneDataLoaderReader.Int32(summaryRow, "INDIVIDUAL"), + ChartSceneDataLoaderReader.Int32(summaryRow, "FOREIGN"), + ChartSceneDataLoaderReader.Int32(summaryRow, "INSTITUTION")); + var trendRows = ChartSceneDataLoaderReader.Rows(trendTable, TrendColumns, 1, 10_000); + var trend = trendRows + .Select(row => new InvestorFlowIndexTrendPoint( + ChartSceneDataLoaderReader.Int32(row, "INDIVIDUAL"), + ChartSceneDataLoaderReader.Int32(row, "FOREIGN"), + ChartSceneDataLoaderReader.Int32(row, "INSTITUTION"), + ChartSceneDataLoaderReader.Time(row, "DATA_TIME"))) + .ToArray(); + var data = new S5084SceneData(request.Index, totals, trend); + _ = new S5084SceneMutationBuilder().Build(data); + return data; + } +} + +internal sealed record S5084QueryPlan(DataQuerySpec Summary, DataQuerySpec Trend); + +internal static class S5084QueryFactory +{ + private const string KospiSummary = """ + SELECT + SUM(DECODE( + f_invest_code, + '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0)) INDIVIDUAL, + SUM(DECODE( + f_invest_code, + '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0)) FOREIGN, + SUM(DECODE( + f_invest_code, + '8000', 0, + '9000', 0, + '9001', 0, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000))) INSTITUTION + FROM t_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN + ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000', + '8000', '9000', '9001') + GROUP BY f_invest_code + """; + + private const string KosdaqSummary = """ + SELECT + SUM(DECODE( + f_invest_code, + '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0)) INDIVIDUAL, + SUM(DECODE( + f_invest_code, + '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0)) FOREIGN, + SUM(DECODE( + f_invest_code, + '8000', 0, + '9000', 0, + '9001', 0, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000))) INSTITUTION + FROM t_kosdaq_invest + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN + ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000', + '8000', '9000', '9001') + GROUP BY f_invest_code + """; + + private const string KospiTrend = """ + SELECT + f_data_time DATA_TIME, + SUM(p_1) INDIVIDUAL, + SUM(p_2) FOREIGN, + SUM(p_3) INSTITUTION + FROM ( + SELECT + f_data_time, + DECODE( + f_invest_code, + '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0) p_1, + DECODE( + f_invest_code, + '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0) p_2, + DECODE( + f_invest_code, + '8000', 0, + '9000', 0, + '9001', 0, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000)) p_3 + FROM t_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN + ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000', + '8000', '9000', '9001') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time, f_invest_code + ) + GROUP BY f_data_time + ORDER BY f_data_time + """; + + private const string KosdaqTrend = """ + SELECT + f_data_time DATA_TIME, + SUM(p_1) INDIVIDUAL, + SUM(p_2) FOREIGN, + SUM(p_3) INSTITUTION + FROM ( + SELECT + f_data_time, + DECODE( + f_invest_code, + '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0) p_1, + DECODE( + f_invest_code, + '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000), + 0) p_2, + DECODE( + f_invest_code, + '8000', 0, + '9000', 0, + '9001', 0, + ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000)) p_3 + FROM t_kosdaq_invest_his + WHERE F_PART_CODE = '001' + AND F_INVEST_CODE IN + ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000', + '8000', '9000', '9001') + AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day) + GROUP BY f_data_time, f_invest_code + ) + GROUP BY f_data_time + ORDER BY f_data_time + """; + + public static S5084QueryPlan Create(InvestorFlowIndex index) + { + var queries = index switch + { + InvestorFlowIndex.Kospi => (KospiSummary, KospiTrend), + InvestorFlowIndex.Kosdaq => (KosdaqSummary, KosdaqTrend), + _ => throw ChartSceneDataLoaderReader.InvalidRequest() + }; + return new S5084QueryPlan( + ChartSceneDataLoaderReader.Spec(queries.Item1), + ChartSceneDataLoaderReader.Spec(queries.Item2)); + } +} + +internal static class ChartSceneDataLoaderReader +{ + private const int MaximumSelectionLength = 256; + private const int MaximumTextLength = 4_096; + + public static DataQuerySpec Spec(string sql, params DataQueryParameter[] parameters) + { + var spec = new DataQuerySpec(sql, parameters); + spec.ValidateFor(DataSourceKind.Oracle); + return spec; + } + + public static string Selection(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > MaximumSelectionLength || + value.Any(char.IsControl)) + { + throw InvalidRequest(); + } + + return value; + } + + public static DataRow SingleRow(DataTable? table, IReadOnlyList columns) => + Rows(table, columns, 1, 1)[0]; + + public static IReadOnlyList Rows( + DataTable? table, + IReadOnlyList columns, + int minimum, + int maximum) + { + if (table is null || table.Columns.Count != columns.Count || + table.Rows.Count < minimum || table.Rows.Count > maximum) + { + throw InvalidResult(); + } + + for (var index = 0; index < columns.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + columns[index], + StringComparison.OrdinalIgnoreCase)) + { + throw InvalidResult(); + } + } + + return table.Rows.Cast().ToArray(); + } + + public static string Text(DataRow row, string column, bool allowEmpty = false) + { + if (row[column] is not { } value || value is DBNull) + { + throw InvalidResult(); + } + + string text; + try + { + text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + catch (Exception exception) when (exception is FormatException or InvalidCastException) + { + throw InvalidResult(); + } + + if (text.Length > MaximumTextLength || text.Any(char.IsControl) || + (!allowEmpty && string.IsNullOrWhiteSpace(text))) + { + throw InvalidResult(); + } + + return text; + } + + public static double Double(DataRow row, string column) + { + try + { + var value = Convert.ToDouble(Value(row, column), CultureInfo.InvariantCulture); + return double.IsFinite(value) ? value : throw InvalidResult(); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + public static int Int32(DataRow row, string column) + { + try + { + var value = Convert.ToDecimal(Value(row, column), CultureInfo.InvariantCulture); + if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue) + { + throw InvalidResult(); + } + + return decimal.ToInt32(value); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + public static InvestorFlowMarket Market(DataRow row, string column) => + Text(row, column).Trim() switch + { + "코스피" => InvestorFlowMarket.Kospi, + "코스닥" => InvestorFlowMarket.Kosdaq, + "코스피200" => InvestorFlowMarket.Kospi200, + _ => throw InvalidResult() + }; + + public static TimeOnly Time(DataRow row, string column) + { + var value = Value(row, column); + if (value is DateTime dateTime) + { + return TimeOnly.FromDateTime(dateTime); + } + + var text = Text(row, column).Trim(); + if (text.Length < 4) + { + throw InvalidResult(); + } + + var timeText = text[^4..]; + if (!TimeOnly.TryParseExact( + timeText, + "HHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var time)) + { + throw InvalidResult(); + } + + return time; + } + + private static object Value(DataRow row, string column) => + row[column] is { } value && value is not DBNull ? value : throw InvalidResult(); + + public static LegacySceneDataException InvalidRequest() => + new("The chart scene data request is unsupported or invalid."); + + public static LegacySceneDataException InvalidResult() => + new("The chart scene query returned invalid data or an unexpected schema."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs new file mode 100644 index 0000000..b3a6880 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs @@ -0,0 +1,335 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Closed result for MainForm's s5026/s5029/s5086/s50860/s5087 dispatch. +/// +public abstract record LegacyComparisonAndYieldSceneLoadRequest(string BuilderKey); + +public sealed record LegacyS5026SceneLoadRequest(ComparisonPairSceneLoadRequest Request) + : LegacyComparisonAndYieldSceneLoadRequest("s5026"); + +public sealed record LegacyS5029SceneLoadRequest(ComparisonPairSceneLoadRequest Request) + : LegacyComparisonAndYieldSceneLoadRequest("s5029"); + +public sealed record LegacyS5086SceneLoadRequest(YieldSceneLoadRequest Request) + : LegacyComparisonAndYieldSceneLoadRequest("s5086"); + +public sealed record LegacyS50860SceneLoadRequest(YieldSceneLoadRequest Request) + : LegacyComparisonAndYieldSceneLoadRequest("s50860"); + +public sealed record LegacyS5087SceneLoadRequest(ComparisonPairSceneLoadRequest Request) + : LegacyComparisonAndYieldSceneLoadRequest("s5087"); + +/// +/// Converts the legacy playlist fields to the closed comparison/yield loader requests. +/// LegacySceneSelection maps to MainForm as GroupCode=code, Subject=jongmok, +/// GraphicType=forCutInfo, Subtype=sub and DataCode=dataCode. The five source +/// constructors use only code, jongmok and sub. +/// +public sealed class ComparisonAndYieldLegacyRequestResolver +{ + private readonly IDataQueryExecutor _executor; + + public ComparisonAndYieldLegacyRequestResolver(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task ResolveAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + if (entry.CutCode is not ("5026" or "5029" or "5086" or "50860" or "5087")) + { + throw InvalidSelection(); + } + + var selection = entry.Selection ?? throw InvalidSelection(); + var subject = Required(selection.Subject); + + return entry.CutCode switch + { + "5026" => new LegacyS5026SceneLoadRequest( + await ResolvePairAsync( + subject, + ComparisonGraphPeriod.FiveDays, + cancellationToken).ConfigureAwait(false)), + "5029" => new LegacyS5029SceneLoadRequest( + await ResolvePairAsync( + subject, + ParseComparisonPeriod(selection.Subtype), + cancellationToken).ConfigureAwait(false)), + "5087" => new LegacyS5087SceneLoadRequest( + await ResolvePairAsync( + subject, + ComparisonGraphPeriod.FiveDays, + cancellationToken).ConfigureAwait(false)), + "5086" => new LegacyS5086SceneLoadRequest( + await ResolveYieldAsync( + selection, + subject, + requireQuotedFx: false, + cancellationToken).ConfigureAwait(false)), + "50860" => new LegacyS50860SceneLoadRequest( + await ResolveYieldAsync( + selection, + subject, + requireQuotedFx: true, + cancellationToken).ConfigureAwait(false)), + _ => throw InvalidSelection() + }; + } + + private async Task ResolvePairAsync( + string subject, + ComparisonGraphPeriod period, + CancellationToken cancellationToken) + { + var stockNames = SplitPair(subject); + var first = await ResolveStockAsync(stockNames[0], cancellationToken) + .ConfigureAwait(false); + var second = await ResolveStockAsync(stockNames[1], cancellationToken) + .ConfigureAwait(false); + return new ComparisonPairSceneLoadRequest(period, first, second); + } + + private async Task ResolveYieldAsync( + LegacySceneSelection selection, + string subject, + bool requireQuotedFx, + CancellationToken cancellationToken) + { + var period = ParseYieldPeriod(selection.Subtype); + var fxKind = TryParseFx(subject); + if (fxKind.HasValue) + { + return new YieldSceneLoadRequest( + period, + new YieldGraphInstrument(fxKind.Value)); + } + + // The source s50860 reads quote cells which exist only in the FX result shape. + // Reject every other legacy branch before performing a stock-master lookup. + if (requireQuotedFx) + { + throw InvalidSelection(); + } + + var groupCode = selection.GroupCode?.Trim() ?? string.Empty; + if (subject == "지수") + { + var index = groupCode switch + { + "코스피" => YieldGraphInstrumentKind.KospiIndex, + "코스닥" => YieldGraphInstrumentKind.KosdaqIndex, + "코스피200" => YieldGraphInstrumentKind.Kospi200Index, + "KRX100" => YieldGraphInstrumentKind.Krx100Index, + _ => throw InvalidSelection() + }; + return new YieldSceneLoadRequest(period, new YieldGraphInstrument(index)); + } + + if (groupCode.Contains("업종", StringComparison.Ordinal)) + { + var industry = groupCode.Contains("코스피", StringComparison.Ordinal) + ? YieldGraphInstrumentKind.KospiIndustry + : YieldGraphInstrumentKind.KosdaqIndustry; + return new YieldSceneLoadRequest( + period, + new YieldGraphInstrument(industry, subject)); + } + + var stock = await ResolveStockAsync(subject, cancellationToken).ConfigureAwait(false); + var stockKind = stock.Market switch + { + ComparisonEquityMarket.Kospi => YieldGraphInstrumentKind.KospiStock, + ComparisonEquityMarket.Kosdaq => YieldGraphInstrumentKind.KosdaqStock, + _ => throw InvalidSelection() + }; + return new YieldSceneLoadRequest( + period, + new YieldGraphInstrument(stockKind, stock.StockName)); + } + + private async Task ResolveStockAsync( + string rawName, + CancellationToken cancellationToken) + { + var stockName = Required(rawName); + ComparisonEquityMarket? market = null; + if (await StockExistsAsync( + ComparisonEquityMarket.Kospi, + stockName, + cancellationToken).ConfigureAwait(false)) + { + market = ComparisonEquityMarket.Kospi; + } + + // MainForm scans the KOSDAQ cache after the KOSPI cache, so a later + // KOSDAQ match intentionally takes precedence for an overlapping name. + if (await StockExistsAsync( + ComparisonEquityMarket.Kosdaq, + stockName, + cancellationToken).ConfigureAwait(false)) + { + market = ComparisonEquityMarket.Kosdaq; + } + + return market.HasValue + ? new ComparisonStockSelection(market.Value, stockName) + : throw InvalidSelection(); + } + + private async Task StockExistsAsync( + ComparisonEquityMarket market, + string stockName, + CancellationToken cancellationToken) + { + var query = StockMasterQuery(market, stockName); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + query.TableName, + query.Spec, + cancellationToken).ConfigureAwait(false); + if (table is null || table.Columns.Count != 1 || table.Rows.Count != 1 || + !string.Equals( + table.Columns[0].ColumnName, + "MATCH_COUNT", + StringComparison.OrdinalIgnoreCase) || + table.Rows[0][0] is null or DBNull) + { + throw InvalidResult(); + } + + decimal count; + try + { + count = Convert.ToDecimal(table.Rows[0][0], CultureInfo.InvariantCulture); + } + catch (Exception exception) when (exception is + FormatException or InvalidCastException or OverflowException) + { + _ = exception; + throw InvalidResult(); + } + + if (count != decimal.Truncate(count)) + { + throw InvalidResult(); + } + + return count switch + { + 0m => false, + 1m => true, + _ => throw InvalidResult() + }; + } + + private static StockMasterLookup StockMasterQuery( + ComparisonEquityMarket market, + string stockName) + { + var parameter = new DataQueryParameter("stockName", stockName, DbType.String); + return market switch + { + ComparisonEquityMarket.Kospi => new StockMasterLookup( + "SCENE_COMPARISON_RESOLVE_KOSPI_STOCK", + new DataQuerySpec( + "SELECT COUNT(*) MATCH_COUNT FROM T_STOCK " + + "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName", + [parameter])), + ComparisonEquityMarket.Kosdaq => new StockMasterLookup( + "SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK", + new DataQuerySpec( + "SELECT COUNT(*) MATCH_COUNT FROM T_KOSDAQ_STOCK " + + "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName", + [parameter])), + _ => throw InvalidSelection() + }; + } + + private static ComparisonGraphPeriod ParseComparisonPeriod(string value) => + Required(value) switch + { + "일봉" => ComparisonGraphPeriod.Daily, + "5일" => ComparisonGraphPeriod.FiveDays, + "1개월" => ComparisonGraphPeriod.OneMonth, + "3개월" => ComparisonGraphPeriod.ThreeMonths, + "6개월" => ComparisonGraphPeriod.SixMonths, + "12개월" => ComparisonGraphPeriod.TwelveMonths, + _ => throw InvalidSelection() + }; + + private static YieldGraphPeriod ParseYieldPeriod(string value) => + Required(value) switch + { + "5일" => YieldGraphPeriod.FiveDays, + "20일" => YieldGraphPeriod.TwentyDays, + "60일" => YieldGraphPeriod.SixtyDays, + "120일" => YieldGraphPeriod.OneHundredTwentyDays, + "240일" => YieldGraphPeriod.TwoHundredFortyDays, + _ => throw InvalidSelection() + }; + + private static YieldGraphInstrumentKind? TryParseFx(string subject) + { + if (subject.Contains("원달러", StringComparison.Ordinal)) + { + return YieldGraphInstrumentKind.WonDollar; + } + + if (subject.Contains("원엔", StringComparison.Ordinal)) + { + return YieldGraphInstrumentKind.WonYen; + } + + if (subject.Contains("원위엔", StringComparison.Ordinal)) + { + return YieldGraphInstrumentKind.WonYuan; + } + + if (subject.Contains("원유로", StringComparison.Ordinal)) + { + return YieldGraphInstrumentKind.WonEuro; + } + + return null; + } + + private static string[] SplitPair(string subject) + { + var values = subject.Split(',', StringSplitOptions.TrimEntries); + if (values.Length != 2 || values.Any(string.IsNullOrWhiteSpace)) + { + throw InvalidSelection(); + } + + return values; + } + + private static string Required(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw InvalidSelection(); + } + + return value.Trim(); + } + + private static LegacySceneDataException InvalidSelection() => + new("The comparison or yield legacy selection is unsupported."); + + private static LegacySceneDataException InvalidResult() => + new("A comparison stock-master lookup returned an invalid result."); + + private sealed record StockMasterLookup(string TableName, DataQuerySpec Spec); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs new file mode 100644 index 0000000..1e141f0 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs @@ -0,0 +1,1159 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum ComparisonSeriesTarget +{ + First, + Second +} + +public enum ComparisonEquityMarket +{ + Kospi, + Kosdaq +} + +public enum ComparisonChangeDirection +{ + LimitUp, + Up, + Flat, + Down, + LimitDown +} + +public enum ComparisonGraphPeriod +{ + Daily, + FiveDays, + OneMonth, + ThreeMonths, + SixMonths, + TwelveMonths +} + +public sealed record ComparisonQuoteData( + ComparisonSeriesTarget Series, + ComparisonEquityMarket Market, + string StockName, + long CurrentPrice, + long NetChange, + double Rate, + ComparisonChangeDirection Direction); + +public sealed record ComparisonCandlePoint( + DateOnly OpenDay, + double High, + double Low, + double Open, + double Close); + +public sealed record ComparisonCandleSeriesData( + ComparisonQuoteData Quote, + IReadOnlyList Candles); + +public sealed record S5026SceneData( + ComparisonGraphPeriod Period, + IReadOnlyList Series) : ILegacySceneData; + +public sealed class S5026SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5026"; + + public IReadOnlyList Build(S5026SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + if (data.Period != ComparisonGraphPeriod.FiveDays) + { + throw new LegacySceneDataException("s5026 requires the legacy five-day period."); + } + + var series = ComparisonSceneGuard.CandleSeries(data.Series); + var mutations = new List(80); + ComparisonSceneMutations.AppendQuoteHeader( + mutations, + series[0].Quote, + series[1].Quote); + ComparisonSceneMutations.AppendInitialDirectionHides( + mutations, + ComparisonInitialHideStyle.S5026); + ComparisonSceneMutations.AppendDirection( + mutations, + series[0].Quote.Series, + series[0].Quote.Direction); + ComparisonSceneMutations.AppendDirection( + mutations, + series[1].Quote.Series, + series[1].Quote.Direction); + + foreach (var item in series) + { + AppendCandles(mutations, item); + } + + return mutations; + } + + private static void AppendCandles( + ICollection mutations, + ComparisonCandleSeriesData series) + { + var seriesNumber = ComparisonSceneGuard.SeriesNumber(series.Quote.Series); + var candles = series.Candles; + var maximum = candles.Max(item => item.High); + var minimum = candles.Min(item => item.Low); + var range = maximum - minimum; + if (!double.IsFinite(range)) + { + throw new LegacySceneDataException("Candle range must be finite."); + } + + // The source divides every crop by range and emits NaN when all candles are equal. + // RangePercent pins that degenerate chart to zero-percent crop coordinates. + for (var index = 0; index < candles.Count; index++) + { + var candleNumber = index + 1; + ComparisonSceneMutations.AppendCandleDate( + mutations, + seriesNumber, + candleNumber, + candles.Count, + candles[index].OpenDay); + + var candle = candles[index]; + var high = ComparisonGraphSafety.RangePercent(candle.High, maximum, range); + var low = ComparisonGraphSafety.RangePercent(candle.Low, maximum, range); + var open = ComparisonGraphSafety.RangePercent(candle.Open, maximum, range); + var close = ComparisonGraphSafety.RangePercent(candle.Close, maximum, range); + var bar = $"candlebar{seriesNumber}_{candleNumber}"; + var body = $"candlebody{seriesNumber}_{candleNumber}"; + mutations.Add(new PlayoutSetCropKey( + bar, + 1, + 0, + high, + 0, + low, + PlayoutCropEdges.Vertical)); + + (float Top, float Bottom, int R, int G, int B) bodyStyle; + if (candle.Open > candle.Close) + { + bodyStyle = (open, close, 15, 99, 189); + } + else if (candle.Open < candle.Close) + { + bodyStyle = (close, open, 170, 0, 0); + } + else + { + bodyStyle = (close - 0.1f, open + 3f, 95, 95, 95); + } + + mutations.Add(new PlayoutSetCropKey( + body, + 1, + 0, + bodyStyle.Top, + 0, + bodyStyle.Bottom, + PlayoutCropEdges.Vertical)); + mutations.Add(new PlayoutSetFaceColor( + body, + bodyStyle.R, + bodyStyle.G, + bodyStyle.B)); + mutations.Add(new PlayoutSetFaceColor( + bar, + bodyStyle.R, + bodyStyle.G, + bodyStyle.B)); + } + } +} + +public sealed record ComparisonYieldPoint(DateOnly DataDay, int Value); + +public sealed record ComparisonYieldSeriesData( + ComparisonQuoteData Quote, + IReadOnlyList Points); + +public sealed record S5029SceneData( + ComparisonGraphPeriod Period, + IReadOnlyList Series) : ILegacySceneData; + +public sealed class S5029SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5029"; + + public IReadOnlyList Build(S5029SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + ComparisonSceneGuard.ValidatePeriod(data.Period); + return BuildComparisonYieldScene( + data.Series, + ComparisonInitialHideStyle.S5029, + maximumX: 812f, + baselineOffset: -50f); + } + + internal static IReadOnlyList BuildComparisonYieldScene( + IReadOnlyList source, + ComparisonInitialHideStyle initialHideStyle, + float maximumX, + float baselineOffset) + { + var series = ComparisonSceneGuard.YieldSeries(source); + var mutations = new List(); + ComparisonSceneMutations.AppendQuoteHeader( + mutations, + series[0].Quote, + series[1].Quote); + ComparisonSceneMutations.AppendInitialDirectionHides(mutations, initialHideStyle); + ComparisonSceneMutations.AppendDirection( + mutations, + series[0].Quote.Series, + series[0].Quote.Direction); + ComparisonSceneMutations.AppendDirection( + mutations, + series[1].Quote.Series, + series[1].Quote.Direction); + + foreach (var item in series) + { + AppendYieldSeries(mutations, item, maximumX, baselineOffset); + } + + return mutations; + } + + private static void AppendYieldSeries( + ICollection mutations, + ComparisonYieldSeriesData series, + float maximumX, + float baselineOffset) + { + var seriesNumber = ComparisonSceneGuard.SeriesNumber(series.Quote.Series); + var points = series.Points; + for (var index = 0; index < points.Count; index++) + { + ComparisonSceneMutations.AppendYieldDate( + mutations, + seriesNumber.ToString(CultureInfo.InvariantCulture), + index, + points.Count, + points[index].DataDay); + } + + var maximum = points.Max(item => item.Value); + var minimum = points.Min(item => item.Value); + var range = (double)maximum - minimum; + var reference = points[^1].Value; + // The source divides by max-min. Equal values retain its configured baseline offset. + var baseline = range == 0d + ? baselineOffset + : ComparisonGraphSafety.Float( + ((reference - (double)minimum) / range * 150d) + baselineOffset, + "Comparison baseline"); + mutations.Add(new PlayoutSetPosition( + "baseline" + seriesNumber, + 0, + baseline, + 0, + PlayoutVectorComponents.Y)); + + var reversed = points.Select(item => item.Value).Reverse().ToArray(); + // The original divides by count-1. A single point is pinned at X=0 instead of NaN. + var graphStep = reversed.Length == 1 ? 0d : maximumX / (reversed.Length - 1d); + const double tagStart = -383.48d; + const double tagEnd = 359.28d; + var tagStep = reversed.Length == 1 + ? 0d + : (tagEnd - tagStart) / (reversed.Length - 1d); + var plus = new List(); + var minus = new List(); + var previous = 0d; + + for (var index = 0; index < reversed.Length; index++) + { + var x = graphStep * index; + var value = range == 0d + ? 0d + : (reference - (double)reversed[index]) / range * 150d; + ComparisonGraphSafety.AppendSignedPoint(plus, minus, x, value, graphStep, previous); + previous = value; + + var tagX = ComparisonGraphSafety.Float( + tagStart + (index * tagStep), + "Comparison tag X"); + if (reversed[index] == maximum) + { + mutations.Add(new PlayoutSetPosition( + "highTag" + seriesNumber, + tagX, + 0, + 0, + PlayoutVectorComponents.X)); + mutations.Add(new PlayoutSetValue( + "highPrice" + seriesNumber, + reversed[index].ToString("#,##0", CultureInfo.InvariantCulture))); + } + + if (reversed[index] == minimum) + { + mutations.Add(new PlayoutSetPosition( + "lowTag" + seriesNumber, + tagX, + 0, + 0, + PlayoutVectorComponents.X)); + mutations.Add(new PlayoutSetValue( + "lowPrice" + seriesNumber, + reversed[index].ToString("#,##0", CultureInfo.InvariantCulture))); + } + } + + ComparisonSceneMutations.AppendSignedGraphs( + mutations, + seriesNumber.ToString(CultureInfo.InvariantCulture), + 0, + baseline, + PlayoutVectorComponents.Y, + maximumX, + plus, + minus); + } +} + +public sealed record S5087SceneData( + ComparisonGraphPeriod Period, + IReadOnlyList Series) : ILegacySceneData; + +public sealed class S5087SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5087"; + + public IReadOnlyList Build(S5087SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + if (data.Period != ComparisonGraphPeriod.FiveDays) + { + throw new LegacySceneDataException("s5087 requires the legacy five-day period."); + } + + return S5029SceneMutationBuilder.BuildComparisonYieldScene( + data.Series, + ComparisonInitialHideStyle.S5087, + maximumX: 700f, + baselineOffset: -170f); + } +} + +public enum YieldGraphPeriod +{ + FiveDays, + TwentyDays, + SixtyDays, + OneHundredTwentyDays, + TwoHundredFortyDays +} + +public enum YieldGraphInstrumentKind +{ + WonDollar, + WonYen, + WonYuan, + WonEuro, + KospiIndex, + KosdaqIndex, + Kospi200Index, + Krx100Index, + KospiIndustry, + KosdaqIndustry, + KospiStock, + KosdaqStock +} + +public sealed record YieldGraphInstrument( + YieldGraphInstrumentKind Kind, + string? Name = null); + +/// +/// A null value represents a missing DB cell. s5086 explicitly normalizes a later missing +/// value while s50860 rejects it, matching their distinct legacy data paths. +/// +public sealed record YieldGraphPoint(DateOnly DataDay, double? Value); + +public sealed record S5086SceneData( + YieldGraphPeriod Period, + YieldGraphInstrument Instrument, + IReadOnlyList Points) : ILegacySceneData; + +public sealed class S5086SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5086"; + + public IReadOnlyList Build(S5086SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var points = SingleYieldSceneGuard.NormalizeS5086Points(data.Points); + var presentation = SingleYieldSceneGuard.Presentation(data.Instrument, slashFxTitle: false); + var mutations = new List(); + SingleYieldSceneMutations.AppendHeader( + mutations, + SingleYieldSceneGuard.Subtitle(data.Period), + presentation); + SingleYieldSceneMutations.AppendDates(mutations, points); + + var price = points[0].Value; + var reference = points[^1].Value; + var rate = reference == 0d + ? 0d + : (price - reference) / reference * 100d; + if (!double.IsFinite(rate)) + { + throw new LegacySceneDataException("s5086 rate must be finite."); + } + mutations.Add(new PlayoutSetValue( + "price", + SingleYieldSceneGuard.Format(price, presentation.Format))); + mutations.Add(new PlayoutSetValue( + "referencePrice", + SingleYieldSceneGuard.Format(reference, presentation.Format))); + mutations.Add(new PlayoutSetValue( + "rate", + rate.ToString("0.00", CultureInfo.InvariantCulture))); + if (rate > 0d) + { + mutations.Add(new PlayoutSetVisible("bg1", true)); + mutations.Add(new PlayoutSetVisible("bg2", false)); + mutations.Add(new PlayoutSetVisible("bg3", false)); + } + else if (rate < 0d) + { + mutations.Add(new PlayoutSetVisible("bg1", false)); + mutations.Add(new PlayoutSetVisible("bg2", false)); + mutations.Add(new PlayoutSetVisible("bg3", true)); + } + else + { + mutations.Add(new PlayoutSetVisible("bg1", false)); + mutations.Add(new PlayoutSetVisible("bg2", true)); + mutations.Add(new PlayoutSetVisible("bg3", false)); + } + + SingleYieldSceneMutations.AppendGraph( + mutations, + points, + presentation.Format); + return mutations; + } +} + +public enum YieldQuoteDirection +{ + Up, + Flat, + Down +} + +public sealed record S50860SceneData( + YieldGraphPeriod Period, + YieldGraphInstrument Instrument, + double ChangePrice, + double Rate, + YieldQuoteDirection Direction, + IReadOnlyList Points) : ILegacySceneData; + +public sealed class S50860SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s50860"; + + public IReadOnlyList Build(S50860SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + if (!double.IsFinite(data.ChangePrice) || !double.IsFinite(data.Rate)) + { + throw new LegacySceneDataException("s50860 quote values must be finite."); + } + + var points = SingleYieldSceneGuard.RequireCompletePoints(data.Points, "s50860"); + var presentation = SingleYieldSceneGuard.Presentation(data.Instrument, slashFxTitle: true); + var mutations = new List(); + SingleYieldSceneMutations.AppendHeader( + mutations, + SingleYieldSceneGuard.Subtitle(data.Period), + presentation); + SingleYieldSceneMutations.AppendDates(mutations, points); + mutations.Add(new PlayoutSetValue( + "price", + SingleYieldSceneGuard.Format(points[0].Value, presentation.Format))); + mutations.Add(new PlayoutSetValue( + "changePrice", + Math.Abs(data.ChangePrice).ToString("0.00", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "rate", + data.Rate.ToString("0.00", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetVisible("up", false)); + mutations.Add(new PlayoutSetVisible("flat", false)); + mutations.Add(new PlayoutSetVisible("down", false)); + + var (active, red, green, blue) = data.Direction switch + { + YieldQuoteDirection.Up => ("up", 170, 0, 0), + YieldQuoteDirection.Flat => ("flat", 95, 95, 95), + YieldQuoteDirection.Down => ("down", 15, 99, 189), + _ => throw new LegacySceneDataException("Unknown yield quote direction.") + }; + mutations.Add(new PlayoutSetVisible(active, true)); + mutations.Add(new PlayoutSetFaceColor("bg", red, green, blue)); + + SingleYieldSceneMutations.AppendGraph( + mutations, + points, + presentation.Format); + return mutations; + } +} + +internal enum ComparisonInitialHideStyle +{ + S5026, + S5029, + S5087 +} + +internal static class ComparisonSceneGuard +{ + public static IReadOnlyList CandleSeries( + IReadOnlyList? source) + { + var input = LegacySceneBuilderGuard.Count(source, 2, "Series"); + var ordered = new ComparisonCandleSeriesData?[2]; + foreach (var item in input) + { + var series = LegacySceneBuilderGuard.NotNull(item, "Series"); + ValidateQuote(series.Quote); + var index = SeriesNumber(series.Quote.Series) - 1; + if (ordered[index] is not null) + { + throw new LegacySceneDataException("Comparison series targets must be unique."); + } + + var candles = LegacySceneBuilderGuard.Count(series.Candles, 5, "Candles"); + foreach (var candle in candles) + { + var point = LegacySceneBuilderGuard.NotNull(candle, "Candles"); + if (!double.IsFinite(point.High) || !double.IsFinite(point.Low) || + !double.IsFinite(point.Open) || !double.IsFinite(point.Close) || + point.High < point.Low) + { + throw new LegacySceneDataException("Candle values are invalid."); + } + } + + ordered[index] = series; + } + + return ordered.Select(item => item!).ToArray(); + } + + public static IReadOnlyList YieldSeries( + IReadOnlyList? source) + { + var input = LegacySceneBuilderGuard.Count(source, 2, "Series"); + var ordered = new ComparisonYieldSeriesData?[2]; + foreach (var item in input) + { + var series = LegacySceneBuilderGuard.NotNull(item, "Series"); + ValidateQuote(series.Quote); + var index = SeriesNumber(series.Quote.Series) - 1; + if (ordered[index] is not null) + { + throw new LegacySceneDataException("Comparison series targets must be unique."); + } + + _ = LegacySceneBuilderGuard.Range(series.Points, 1, 10_000, "Points"); + foreach (var point in series.Points) + { + _ = LegacySceneBuilderGuard.NotNull(point, "Points"); + } + + ordered[index] = series; + } + + return ordered.Select(item => item!).ToArray(); + } + + public static void ValidatePeriod(ComparisonGraphPeriod period) + { + _ = period switch + { + ComparisonGraphPeriod.Daily or + ComparisonGraphPeriod.FiveDays or + ComparisonGraphPeriod.OneMonth or + ComparisonGraphPeriod.ThreeMonths or + ComparisonGraphPeriod.SixMonths or + ComparisonGraphPeriod.TwelveMonths => true, + _ => throw new LegacySceneDataException("Unknown comparison graph period.") + }; + } + + public static int SeriesNumber(ComparisonSeriesTarget series) => series switch + { + ComparisonSeriesTarget.First => 1, + ComparisonSeriesTarget.Second => 2, + _ => throw new LegacySceneDataException("Unknown comparison series target.") + }; + + private static void ValidateQuote(ComparisonQuoteData? value) + { + var quote = LegacySceneBuilderGuard.NotNull(value, "Quote"); + _ = SeriesNumber(quote.Series); + _ = quote.Market switch + { + ComparisonEquityMarket.Kospi or ComparisonEquityMarket.Kosdaq => true, + _ => throw new LegacySceneDataException("Unknown comparison equity market.") + }; + _ = quote.Direction switch + { + ComparisonChangeDirection.LimitUp or + ComparisonChangeDirection.Up or + ComparisonChangeDirection.Flat or + ComparisonChangeDirection.Down or + ComparisonChangeDirection.LimitDown => true, + _ => throw new LegacySceneDataException("Unknown comparison change direction.") + }; + LegacySceneBuilderGuard.Text(quote.StockName, "StockName"); + if (!double.IsFinite(quote.Rate)) + { + throw new LegacySceneDataException("Comparison quote rate must be finite."); + } + } +} + +internal static class ComparisonSceneMutations +{ + public static void AppendQuoteHeader( + ICollection mutations, + ComparisonQuoteData first, + ComparisonQuoteData second) + { + mutations.Add(new PlayoutSetValue("title1", first.StockName)); + mutations.Add(new PlayoutSetValue("title2", second.StockName)); + mutations.Add(new PlayoutSetValue( + "price1", + first.CurrentPrice.ToString("#,##0", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "price2", + second.CurrentPrice.ToString("#,##0", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "changePrice1", + first.NetChange.ToString("#,##0", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "changePrice2", + second.NetChange.ToString("#,##0", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "rate1", + first.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "rate2", + second.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture))); + } + + public static void AppendInitialDirectionHides( + ICollection mutations, + ComparisonInitialHideStyle style) + { + string[][] orders = style switch + { + ComparisonInitialHideStyle.S5026 => + [ + ["up", "flat", "down"], + ["up", "flat", "down"] + ], + ComparisonInitialHideStyle.S5029 => + [ + ["upup", "up", "flat", "downdown", "down"], + ["upup", "up", "flat", "downdown", "down"] + ], + ComparisonInitialHideStyle.S5087 => + [ + ["up", "upup", "flat", "down", "downdown"], + ["upup", "up", "flat", "down", "downdown"] + ], + _ => throw new LegacySceneDataException("Unknown comparison visibility style.") + }; + + for (var series = 1; series <= 2; series++) + { + foreach (var objectName in orders[series - 1]) + { + mutations.Add(new PlayoutSetVisible(objectName + series, false)); + } + } + } + + public static void AppendDirection( + ICollection mutations, + ComparisonSeriesTarget target, + ComparisonChangeDirection direction) + { + var suffix = ComparisonSceneGuard.SeriesNumber(target) + .ToString(CultureInfo.InvariantCulture); + foreach (var name in new[] { "upup", "up", "flat", "down", "downdown" }) + { + mutations.Add(new PlayoutSetVisible(name + suffix, false)); + } + + var (active, selectedBackground) = direction switch + { + ComparisonChangeDirection.LimitUp => ("upup", 1), + ComparisonChangeDirection.Up => ("up", 1), + ComparisonChangeDirection.Flat => ("flat", 2), + ComparisonChangeDirection.Down => ("down", 3), + ComparisonChangeDirection.LimitDown => ("downdown", 3), + _ => throw new LegacySceneDataException("Unknown comparison change direction.") + }; + mutations.Add(new PlayoutSetVisible(active + suffix, true)); + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + $"bg{suffix}_{variant}", + variant == selectedBackground)); + } + } + + public static void AppendCandleDate( + ICollection mutations, + int seriesNumber, + int candleNumber, + int candleCount, + DateOnly date) + { + int? slot = candleNumber switch + { + 1 => 1, + _ when candleNumber == candleCount => 5, + _ when candleNumber == (int)Math.Ceiling(candleCount / 2d) => 3, + _ => null + }; + if (slot.HasValue) + { + mutations.Add(new PlayoutSetValue( + $"date{seriesNumber}_{slot.Value}", + FormatDate(date))); + } + } + + public static void AppendYieldDate( + ICollection mutations, + string seriesSuffix, + int index, + int count, + DateOnly date) + { + var last = count - 1; + int? slot = index switch + { + 0 => 5, + _ when index == last => 1, + _ when index == (int)Math.Ceiling(last / 2d) => 3, + _ when index == (int)Math.Ceiling(last / 4d) => 4, + _ when index == (int)Math.Ceiling(last * 3d / 4d) => 2, + _ => null + }; + if (slot.HasValue) + { + var objectName = string.IsNullOrEmpty(seriesSuffix) + ? "date" + slot.Value + : "date" + seriesSuffix + "_" + slot.Value; + mutations.Add(new PlayoutSetValue( + objectName, + FormatDate(date))); + } + } + + public static void AppendSignedGraphs( + ICollection mutations, + string suffix, + float positionX, + float baseline, + PlayoutVectorComponents positionComponents, + float maximumX, + IReadOnlyList plus, + IReadOnlyList minus) + { + mutations.Add(new PlayoutSetPosition( + "minusGraph" + suffix, + positionX, + baseline, + 0, + positionComponents)); + mutations.Add(new PlayoutSetPathShapePoints( + "minusGraph" + suffix, + SignedShapePoints(minus, maximumX, invertY: true))); + mutations.Add(new PlayoutSetPosition( + "plusGraph" + suffix, + positionX, + baseline, + 0, + positionComponents)); + mutations.Add(new PlayoutSetPathShapePoints( + "plusGraph" + suffix, + SignedShapePoints(plus, maximumX, invertY: false))); + } + + private static IReadOnlyList SignedShapePoints( + IReadOnlyList source, + float maximumX, + bool invertY) + { + var points = new List(source.Count + 2) { new(0, 0, 0) }; + points.AddRange(source.Select(point => new PlayoutPoint( + point.X, + invertY ? -point.Y : point.Y, + 0))); + points.Add(new PlayoutPoint(maximumX, 0, 0)); + return points; + } + + private static string FormatDate(DateOnly date) => + date.ToString("MM/dd", CultureInfo.InvariantCulture); +} + +internal enum YieldValueFormat +{ + Integer, + DecimalTwo +} + +internal enum YieldHeaderOrder +{ + UnitThenTitle, + UnitTitleUnit, + TitleThenUnit +} + +internal sealed record YieldInstrumentPresentation( + string Title, + string Unit, + YieldValueFormat Format, + YieldHeaderOrder HeaderOrder); + +internal sealed record NormalizedYieldPoint(DateOnly DataDay, double Value); + +internal static class SingleYieldSceneGuard +{ + public static string Subtitle(YieldGraphPeriod period) => period switch + { + YieldGraphPeriod.FiveDays => "1주일", + YieldGraphPeriod.TwentyDays => "1개월", + YieldGraphPeriod.SixtyDays => "3개월", + YieldGraphPeriod.OneHundredTwentyDays => "6개월", + YieldGraphPeriod.TwoHundredFortyDays => "12개월", + _ => throw new LegacySceneDataException("Unknown yield graph period.") + }; + + public static YieldInstrumentPresentation Presentation( + YieldGraphInstrument? source, + bool slashFxTitle) + { + var instrument = LegacySceneBuilderGuard.NotNull(source, "Instrument"); + return instrument.Kind switch + { + YieldGraphInstrumentKind.WonDollar => Fx("원달러", "원/달러"), + YieldGraphInstrumentKind.WonYen => Fx("원엔", "원/엔"), + YieldGraphInstrumentKind.WonYuan => Fx("원위엔", "원/위엔"), + YieldGraphInstrumentKind.WonEuro => Fx("원유로", "원/유로"), + YieldGraphInstrumentKind.KospiIndex => Index("코스피 수익률"), + YieldGraphInstrumentKind.KosdaqIndex => Index("코스닥 수익률"), + YieldGraphInstrumentKind.Kospi200Index => Index("코스피200 수익률"), + YieldGraphInstrumentKind.Krx100Index => Index("KRX100 수익률"), + YieldGraphInstrumentKind.KospiIndustry => Industry("코스피 - "), + YieldGraphInstrumentKind.KosdaqIndustry => Industry("코스닥 - "), + YieldGraphInstrumentKind.KospiStock or YieldGraphInstrumentKind.KosdaqStock => Stock(), + _ => throw new LegacySceneDataException("Unknown yield graph instrument.") + }; + + YieldInstrumentPresentation Fx(string yieldTitle, string slashTitle) => new( + slashFxTitle ? slashTitle : yieldTitle + " 수익률", + "단위 : 원", + YieldValueFormat.DecimalTwo, + YieldHeaderOrder.UnitThenTitle); + + static YieldInstrumentPresentation Index(string title) => new( + title, + "단위 : p", + YieldValueFormat.DecimalTwo, + YieldHeaderOrder.UnitTitleUnit); + + YieldInstrumentPresentation Industry(string prefix) => new( + prefix + LegacySceneBuilderGuard.Text(instrument.Name, "Instrument.Name"), + "단위 : p", + YieldValueFormat.DecimalTwo, + YieldHeaderOrder.TitleThenUnit); + + YieldInstrumentPresentation Stock() => new( + LegacySceneBuilderGuard.Text(instrument.Name, "Instrument.Name"), + "단위 : 원", + YieldValueFormat.Integer, + YieldHeaderOrder.UnitThenTitle); + } + + public static IReadOnlyList NormalizeS5086Points( + IReadOnlyList? source) + { + var points = LegacySceneBuilderGuard.Range(source, 1, 10_000, "Points"); + var normalized = new NormalizedYieldPoint[points.Count]; + double? previous = null; + for (var index = 0; index < points.Count; index++) + { + var point = LegacySceneBuilderGuard.NotNull(points[index], $"Points[{index}]"); + if (point.Value is null) + { + // The source attempted rows[i-1] and crashes when i==0. Fail closed there; + // later missing rows are explicitly forward-filled, which is the source comment's intent. + if (previous is null) + { + throw new LegacySceneDataException("The first s5086 graph value is required."); + } + + normalized[index] = new NormalizedYieldPoint(point.DataDay, previous.Value); + continue; + } + + if (!double.IsFinite(point.Value.Value)) + { + throw new LegacySceneDataException("Yield graph values must be finite."); + } + + previous = point.Value.Value; + normalized[index] = new NormalizedYieldPoint(point.DataDay, previous.Value); + } + + return normalized; + } + + public static IReadOnlyList RequireCompletePoints( + IReadOnlyList? source, + string scene) + { + var points = LegacySceneBuilderGuard.Range(source, 1, 10_000, "Points"); + var normalized = new NormalizedYieldPoint[points.Count]; + for (var index = 0; index < points.Count; index++) + { + var point = LegacySceneBuilderGuard.NotNull(points[index], $"Points[{index}]"); + if (point.Value is null || !double.IsFinite(point.Value.Value)) + { + throw new LegacySceneDataException(scene + " requires every graph value."); + } + + normalized[index] = new NormalizedYieldPoint(point.DataDay, point.Value.Value); + } + + return normalized; + } + + public static string Format(double value, YieldValueFormat format) => format switch + { + YieldValueFormat.Integer => value.ToString("#,##0", CultureInfo.InvariantCulture), + YieldValueFormat.DecimalTwo => value.ToString("#,##0.00", CultureInfo.InvariantCulture), + _ => throw new LegacySceneDataException("Unknown yield value format.") + }; +} + +internal static class SingleYieldSceneMutations +{ + public static void AppendHeader( + ICollection mutations, + string subtitle, + YieldInstrumentPresentation presentation) + { + mutations.Add(new PlayoutSetValue("subtitle", subtitle)); + switch (presentation.HeaderOrder) + { + case YieldHeaderOrder.UnitThenTitle: + mutations.Add(new PlayoutSetValue("unit", presentation.Unit)); + mutations.Add(new PlayoutSetValue("title", presentation.Title)); + break; + case YieldHeaderOrder.UnitTitleUnit: + mutations.Add(new PlayoutSetValue("unit", presentation.Unit)); + mutations.Add(new PlayoutSetValue("title", presentation.Title)); + mutations.Add(new PlayoutSetValue("unit", presentation.Unit)); + break; + case YieldHeaderOrder.TitleThenUnit: + mutations.Add(new PlayoutSetValue("title", presentation.Title)); + mutations.Add(new PlayoutSetValue("unit", presentation.Unit)); + break; + default: + throw new LegacySceneDataException("Unknown yield header order."); + } + } + + public static void AppendDates( + ICollection mutations, + IReadOnlyList points) + { + for (var index = 0; index < points.Count; index++) + { + ComparisonSceneMutations.AppendYieldDate( + mutations, + string.Empty, + index, + points.Count, + points[index].DataDay); + } + } + + public static void AppendGraph( + ICollection mutations, + IReadOnlyList points, + YieldValueFormat format) + { + var maximum = points.Max(item => item.Value); + var minimum = points.Min(item => item.Value); + var range = maximum - minimum; + if (!double.IsFinite(range)) + { + throw new LegacySceneDataException("Yield graph range must be finite."); + } + + var reference = points[^1].Value; + // The source divides by max-min. Equal values retain the fixed -75 baseline. + var baseline = range == 0d + ? -75f + : ComparisonGraphSafety.Float( + ((reference - minimum) / range * 225d) - 75d, + "Yield baseline"); + mutations.Add(new PlayoutSetPosition( + "baseline", + 0, + baseline, + 0, + PlayoutVectorComponents.Y)); + + var reversed = points.Select(item => item.Value).Reverse().ToArray(); + const float maximumX = 1333f; + // The source divides by count-1. A single point is pinned at X=0 instead of NaN. + var graphStep = reversed.Length == 1 ? 0d : maximumX / (reversed.Length - 1d); + const double tagStart = -647.50d; + const double tagEnd = 653.79d; + var tagStep = reversed.Length == 1 + ? 0d + : (tagEnd - tagStart) / (reversed.Length - 1d); + var plus = new List(); + var minus = new List(); + var previous = 0d; + for (var index = 0; index < reversed.Length; index++) + { + var x = graphStep * index; + var value = range == 0d + ? 0d + : (reference - reversed[index]) / range * 225d; + ComparisonGraphSafety.AppendSignedPoint(plus, minus, x, value, graphStep, previous); + previous = value; + var tagX = ComparisonGraphSafety.Float( + tagStart + (index * tagStep), + "Yield tag X"); + + if (reversed[index] == maximum) + { + mutations.Add(new PlayoutSetPosition( + "highTag", + tagX, + 0, + 0, + PlayoutVectorComponents.X)); + mutations.Add(new PlayoutSetValue( + "highPrice", + SingleYieldSceneGuard.Format(reversed[index], format))); + } + + if (reversed[index] == minimum) + { + mutations.Add(new PlayoutSetPosition( + "lowTag", + tagX, + 0, + 0, + PlayoutVectorComponents.X)); + mutations.Add(new PlayoutSetValue( + "lowPrice", + SingleYieldSceneGuard.Format(reversed[index], format))); + } + } + + ComparisonSceneMutations.AppendSignedGraphs( + mutations, + string.Empty, + -659.82f, + baseline, + PlayoutVectorComponents.XYZ, + maximumX, + plus, + minus); + } +} + +internal static class ComparisonGraphSafety +{ + public static float RangePercent(double value, double maximum, double range) + { + var result = range == 0d + ? 0d + : Math.Abs(100d * (value - maximum) / range); + return Float(result, "Candle crop"); + } + + public static void AppendSignedPoint( + ICollection plus, + ICollection minus, + double x, + double value, + double step, + double previous) + { + if ((previous > 0d && value < 0d) || (previous < 0d && value > 0d)) + { + var denominator = Math.Abs(previous - value); + var crossingX = denominator == 0d + ? x + : x - (step * (Math.Abs(value) / denominator)); + var crossing = new PlayoutPoint(Float(crossingX, "Graph crossing X"), 0, 0); + plus.Add(crossing); + minus.Add(crossing); + } + + var point = new PlayoutPoint( + Float(x, "Graph X"), + Float(value, "Graph Y"), + 0); + if (value < 0d) + { + plus.Add(point); + } + else if (value > 0d) + { + minus.Add(point); + } + else + { + plus.Add(point); + minus.Add(point); + } + } + + public static float Float(double value, string name) + { + if (!double.IsFinite(value) || value < -float.MaxValue || value > float.MaxValue) + { + throw new LegacySceneDataException(name + " must be a finite float."); + } + + return (float)value; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneDataLoaders.cs new file mode 100644 index 0000000..4f72333 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneDataLoaders.cs @@ -0,0 +1,1102 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed record ComparisonStockSelection( + ComparisonEquityMarket Market, + string StockName); + +public sealed record ComparisonPairSceneLoadRequest( + ComparisonGraphPeriod Period, + ComparisonStockSelection First, + ComparisonStockSelection Second); + +public sealed record YieldSceneLoadRequest( + YieldGraphPeriod Period, + YieldGraphInstrument Instrument); + +public sealed class S5026SceneDataLoader +{ + private readonly ComparisonAndYieldSceneLoaderCore _core; + + public S5026SceneDataLoader(IDataQueryExecutor executor) + { + _core = new ComparisonAndYieldSceneLoaderCore(executor); + } + + public async Task LoadAsync( + ComparisonPairSceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.Period != ComparisonGraphPeriod.FiveDays) + { + throw ComparisonAndYieldLoadGuard.InvalidRequest(); + } + + var series = await _core.LoadCandlePairAsync( + request, + "SCENE_5026", + cancellationToken).ConfigureAwait(false); + return new S5026SceneData(request.Period, series); + } +} + +public sealed class S5029SceneDataLoader +{ + private readonly ComparisonAndYieldSceneLoaderCore _core; + + public S5029SceneDataLoader(IDataQueryExecutor executor) + { + _core = new ComparisonAndYieldSceneLoaderCore(executor); + } + + public async Task LoadAsync( + ComparisonPairSceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + _ = ComparisonAndYieldLoadGuard.ComparisonRowLimit(request.Period); + var series = await _core.LoadComparisonYieldPairAsync( + request, + "SCENE_5029", + cancellationToken).ConfigureAwait(false); + return new S5029SceneData(request.Period, series); + } +} + +public sealed class S5087SceneDataLoader +{ + private readonly ComparisonAndYieldSceneLoaderCore _core; + + public S5087SceneDataLoader(IDataQueryExecutor executor) + { + _core = new ComparisonAndYieldSceneLoaderCore(executor); + } + + public async Task LoadAsync( + ComparisonPairSceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.Period != ComparisonGraphPeriod.FiveDays) + { + throw ComparisonAndYieldLoadGuard.InvalidRequest(); + } + + var series = await _core.LoadComparisonYieldPairAsync( + request, + "SCENE_5087", + cancellationToken).ConfigureAwait(false); + return new S5087SceneData(request.Period, series); + } +} + +public sealed class S5086SceneDataLoader +{ + private readonly ComparisonAndYieldSceneLoaderCore _core; + + public S5086SceneDataLoader(IDataQueryExecutor executor) + { + _core = new ComparisonAndYieldSceneLoaderCore(executor); + } + + public async Task LoadAsync( + YieldSceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var points = await _core.LoadSingleYieldAsync( + request, + "SCENE_5086_YIELD", + allowMissingValues: true, + cancellationToken).ConfigureAwait(false); + return new S5086SceneData(request.Period, request.Instrument, points); + } +} + +public sealed class S50860SceneDataLoader +{ + private readonly ComparisonAndYieldSceneLoaderCore _core; + + public S50860SceneDataLoader(IDataQueryExecutor executor) + { + _core = new ComparisonAndYieldSceneLoaderCore(executor); + } + + public async Task LoadAsync( + YieldSceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ComparisonAndYieldLoadGuard.ValidateInstrument(request.Instrument); + if (!ComparisonAndYieldLoadGuard.IsFx(request.Instrument.Kind)) + { + // The source reads cells 4/5/6 for every branch. Only Exchange supplies + // net_chg/chg_type/rate at those positions; index, industry, and stock + // result shapes cannot produce a valid s50860 quote and are rejected. + throw ComparisonAndYieldLoadGuard.InvalidRequest(); + } + + var loaded = await _core.LoadQuotedFxYieldAsync( + request, + "SCENE_50860_YIELD", + cancellationToken).ConfigureAwait(false); + return new S50860SceneData( + request.Period, + request.Instrument, + loaded.ChangePrice, + loaded.Rate, + loaded.Direction, + loaded.Points); + } +} + +internal sealed class ComparisonAndYieldSceneLoaderCore +{ + private static readonly string[] CandleColumns = + [ + "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION", + "OPEN_DAY", "HIGH_VALUE", "LOW_VALUE", "OPEN_VALUE", "CLOSE_VALUE" + ]; + + private static readonly string[] ComparisonYieldColumns = + [ + "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION", + "DATA_DAY", "VALUE" + ]; + + private static readonly string[] SingleYieldColumns = ["DATA_DAY", "VALUE"]; + + private static readonly string[] QuotedFxColumns = + ["DATA_DAY", "VALUE", "CHANGE_PRICE", "RATE", "DIRECTION"]; + + private readonly IDataQueryExecutor _executor; + + public ComparisonAndYieldSceneLoaderCore(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task> LoadCandlePairAsync( + ComparisonPairSceneLoadRequest request, + string tablePrefix, + CancellationToken cancellationToken) + { + var selections = ComparisonAndYieldLoadGuard.Pair(request); + var result = new ComparisonCandleSeriesData[2]; + for (var index = 0; index < selections.Length; index++) + { + var target = index == 0 + ? ComparisonSeriesTarget.First + : ComparisonSeriesTarget.Second; + var selection = selections[index]; + var spec = ComparisonAndYieldQueryFactory.ComparisonCandle(selection); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + tablePrefix + "_SERIES_" + (index + 1).ToString(CultureInfo.InvariantCulture), + spec, + cancellationToken).ConfigureAwait(false); + result[index] = MapCandleSeries(table, target, selection.Market); + } + + return result; + } + + public async Task> LoadComparisonYieldPairAsync( + ComparisonPairSceneLoadRequest request, + string tablePrefix, + CancellationToken cancellationToken) + { + var selections = ComparisonAndYieldLoadGuard.Pair(request); + var rowLimit = ComparisonAndYieldLoadGuard.ComparisonRowLimit(request.Period); + var result = new ComparisonYieldSeriesData[2]; + for (var index = 0; index < selections.Length; index++) + { + var target = index == 0 + ? ComparisonSeriesTarget.First + : ComparisonSeriesTarget.Second; + var selection = selections[index]; + var spec = ComparisonAndYieldQueryFactory.ComparisonYield(selection, rowLimit); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + tablePrefix + "_SERIES_" + (index + 1).ToString(CultureInfo.InvariantCulture), + spec, + cancellationToken).ConfigureAwait(false); + result[index] = MapComparisonYieldSeries( + table, + target, + selection.Market, + rowLimit); + } + + return result; + } + + public async Task> LoadSingleYieldAsync( + YieldSceneLoadRequest request, + string tableName, + bool allowMissingValues, + CancellationToken cancellationToken) + { + var rowLimit = ComparisonAndYieldLoadGuard.YieldRowLimit(request.Period); + ComparisonAndYieldLoadGuard.ValidateInstrument(request.Instrument); + var spec = ComparisonAndYieldQueryFactory.SingleYield( + request.Instrument, + rowLimit); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + tableName, + spec, + cancellationToken).ConfigureAwait(false); + return MapSingleYield(table, rowLimit, allowMissingValues); + } + + public async Task LoadQuotedFxYieldAsync( + YieldSceneLoadRequest request, + string tableName, + CancellationToken cancellationToken) + { + var rowLimit = ComparisonAndYieldLoadGuard.YieldRowLimit(request.Period); + var spec = ComparisonAndYieldQueryFactory.QuotedFxYield( + request.Instrument, + rowLimit); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + tableName, + spec, + cancellationToken).ConfigureAwait(false); + return MapQuotedFxYield(table, rowLimit); + } + + private static ComparisonCandleSeriesData MapCandleSeries( + DataTable? table, + ComparisonSeriesTarget target, + ComparisonEquityMarket market) + { + ComparisonAndYieldTableReader.RequireShape(table, 5, CandleColumns); + var rows = table!.Rows.Cast().ToArray(); + var quote = ComparisonAndYieldTableReader.ComparisonQuote(rows[0], target, market); + var candles = new ComparisonCandlePoint[rows.Length]; + DateOnly? previousDate = null; + for (var index = 0; index < rows.Length; index++) + { + var row = rows[index]; + ComparisonAndYieldTableReader.RequireSameQuote(row, quote); + var date = ComparisonAndYieldTableReader.Date(row, "OPEN_DAY"); + if (previousDate.HasValue && date <= previousDate.Value) + { + throw ComparisonAndYieldTableReader.InvalidResult(); + } + + var high = ComparisonAndYieldTableReader.Double(row, "HIGH_VALUE"); + var low = ComparisonAndYieldTableReader.Double(row, "LOW_VALUE"); + var open = ComparisonAndYieldTableReader.Double(row, "OPEN_VALUE"); + var close = ComparisonAndYieldTableReader.Double(row, "CLOSE_VALUE"); + if (high < low) + { + throw ComparisonAndYieldTableReader.InvalidResult(); + } + + candles[index] = new ComparisonCandlePoint(date, high, low, open, close); + previousDate = date; + } + + return new ComparisonCandleSeriesData(quote, candles); + } + + private static ComparisonYieldSeriesData MapComparisonYieldSeries( + DataTable? table, + ComparisonSeriesTarget target, + ComparisonEquityMarket market, + int rowLimit) + { + ComparisonAndYieldTableReader.RequireShape( + table, + rowLimit, + ComparisonYieldColumns); + var rows = table!.Rows.Cast().ToArray(); + var quote = ComparisonAndYieldTableReader.ComparisonQuote(rows[0], target, market); + var points = new ComparisonYieldPoint[rows.Length]; + DateOnly? previousDate = null; + for (var index = 0; index < rows.Length; index++) + { + var row = rows[index]; + ComparisonAndYieldTableReader.RequireSameQuote(row, quote); + var date = ComparisonAndYieldTableReader.Date(row, "DATA_DAY"); + if (previousDate.HasValue && date >= previousDate.Value) + { + throw ComparisonAndYieldTableReader.InvalidResult(); + } + + points[index] = new ComparisonYieldPoint( + date, + ComparisonAndYieldTableReader.Int32(row, "VALUE")); + previousDate = date; + } + + return new ComparisonYieldSeriesData(quote, points); + } + + private static IReadOnlyList MapSingleYield( + DataTable? table, + int rowLimit, + bool allowMissingValues) + { + ComparisonAndYieldTableReader.RequireShape(table, rowLimit, SingleYieldColumns); + var points = new YieldGraphPoint[rowLimit]; + DateOnly? previousDate = null; + for (var index = 0; index < rowLimit; index++) + { + var row = table!.Rows[index]; + var date = ComparisonAndYieldTableReader.Date(row, "DATA_DAY"); + if (previousDate.HasValue && date >= previousDate.Value) + { + throw ComparisonAndYieldTableReader.InvalidResult(); + } + + var value = ComparisonAndYieldTableReader.NullableDouble(row, "VALUE"); + if ((!allowMissingValues && value is null) || (index == 0 && value is null)) + { + throw ComparisonAndYieldTableReader.InvalidResult(); + } + + points[index] = new YieldGraphPoint(date, value); + previousDate = date; + } + + return points; + } + + private static QuotedFxYieldResult MapQuotedFxYield( + DataTable? table, + int rowLimit) + { + ComparisonAndYieldTableReader.RequireShape(table, rowLimit, QuotedFxColumns); + var points = new YieldGraphPoint[rowLimit]; + DateOnly? previousDate = null; + for (var index = 0; index < rowLimit; index++) + { + var row = table!.Rows[index]; + var date = ComparisonAndYieldTableReader.Date(row, "DATA_DAY"); + if (previousDate.HasValue && date >= previousDate.Value) + { + throw ComparisonAndYieldTableReader.InvalidResult(); + } + + points[index] = new YieldGraphPoint( + date, + ComparisonAndYieldTableReader.Double(row, "VALUE")); + previousDate = date; + } + + var first = table!.Rows[0]; + return new QuotedFxYieldResult( + ComparisonAndYieldTableReader.Double(first, "CHANGE_PRICE"), + ComparisonAndYieldTableReader.Double(first, "RATE"), + ComparisonAndYieldTableReader.YieldDirection(first, "DIRECTION"), + points); + } +} + +internal sealed record QuotedFxYieldResult( + double ChangePrice, + double Rate, + YieldQuoteDirection Direction, + IReadOnlyList Points); + +internal static class ComparisonAndYieldLoadGuard +{ + public static ComparisonStockSelection[] Pair(ComparisonPairSceneLoadRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var first = ValidateSelection(request.First); + var second = ValidateSelection(request.Second); + return [first, second]; + } + + public static int ComparisonRowLimit(ComparisonGraphPeriod period) => period switch + { + ComparisonGraphPeriod.Daily => 1, + ComparisonGraphPeriod.FiveDays => 5, + ComparisonGraphPeriod.OneMonth => 20, + ComparisonGraphPeriod.ThreeMonths => 60, + ComparisonGraphPeriod.SixMonths => 120, + ComparisonGraphPeriod.TwelveMonths => 240, + _ => throw InvalidRequest() + }; + + public static int YieldRowLimit(YieldGraphPeriod period) => period switch + { + YieldGraphPeriod.FiveDays => 5, + YieldGraphPeriod.TwentyDays => 20, + YieldGraphPeriod.SixtyDays => 60, + YieldGraphPeriod.OneHundredTwentyDays => 120, + YieldGraphPeriod.TwoHundredFortyDays => 240, + _ => throw InvalidRequest() + }; + + public static void ValidateInstrument(YieldGraphInstrument? instrument) + { + var value = LegacySceneBuilderGuard.NotNull(instrument, "Instrument"); + if (!Enum.IsDefined(value.Kind)) + { + throw InvalidRequest(); + } + + var requiresName = value.Kind is + YieldGraphInstrumentKind.KospiIndustry or + YieldGraphInstrumentKind.KosdaqIndustry or + YieldGraphInstrumentKind.KospiStock or + YieldGraphInstrumentKind.KosdaqStock; + if (requiresName) + { + _ = LegacySceneBuilderGuard.Text(value.Name, "Instrument.Name"); + } + else if (!string.IsNullOrEmpty(value.Name)) + { + throw InvalidRequest(); + } + } + + public static bool IsFx(YieldGraphInstrumentKind kind) => kind is + YieldGraphInstrumentKind.WonDollar or + YieldGraphInstrumentKind.WonYen or + YieldGraphInstrumentKind.WonYuan or + YieldGraphInstrumentKind.WonEuro; + + public static LegacySceneDataException InvalidRequest() => + new("The comparison or yield request contains an unsupported closed selection."); + + private static ComparisonStockSelection ValidateSelection( + ComparisonStockSelection? selection) + { + var value = LegacySceneBuilderGuard.NotNull(selection, "StockSelection"); + if (!Enum.IsDefined(value.Market)) + { + throw InvalidRequest(); + } + + return value with + { + StockName = LegacySceneBuilderGuard.Text(value.StockName, "StockName") + }; + } +} + +internal static class ComparisonAndYieldTableReader +{ + public static void RequireShape( + DataTable? table, + int expectedRows, + IReadOnlyList expectedColumns) + { + if (table is null || table.Rows.Count != expectedRows || + table.Columns.Count != expectedColumns.Count) + { + throw InvalidResult(); + } + + var columns = table.Columns + .Cast() + .Select(column => column.ColumnName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (expectedColumns.Any(column => !columns.Contains(column))) + { + throw InvalidResult(); + } + } + + public static ComparisonQuoteData ComparisonQuote( + DataRow row, + ComparisonSeriesTarget target, + ComparisonEquityMarket market) + { + var currentPrice = Int64(row, "CURRENT_PRICE"); + var netChange = Int64(row, "NET_CHANGE"); + if (currentPrice <= 0 || netChange < 0) + { + throw InvalidResult(); + } + + return new ComparisonQuoteData( + target, + market, + Text(row, "STOCK_NAME"), + currentPrice, + netChange, + Double(row, "RATE"), + ComparisonDirection(row, "DIRECTION")); + } + + public static void RequireSameQuote(DataRow row, ComparisonQuoteData quote) + { + var candidate = ComparisonQuote(row, quote.Series, quote.Market); + if (candidate != quote) + { + throw InvalidResult(); + } + } + + public static string Text(DataRow row, string column) + { + var value = Required(row, column); + try + { + return LegacySceneBuilderGuard.Text( + Convert.ToString(value, CultureInfo.InvariantCulture), + column); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + public static DateOnly Date(DataRow row, string column) + { + var value = Required(row, column); + if (value is DateOnly dateOnly) + { + return dateOnly; + } + + if (value is DateTime dateTime) + { + return DateOnly.FromDateTime(dateTime); + } + + string? text; + try + { + text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim(); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + + return DateOnly.TryParseExact( + text, + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsed) + ? parsed + : throw InvalidResult(); + } + + public static long Int64(DataRow row, string column) + { + var value = Decimal(row, column); + if (decimal.Truncate(value) != value || value is < long.MinValue or > long.MaxValue) + { + throw InvalidResult(); + } + + return decimal.ToInt64(value); + } + + public static int Int32(DataRow row, string column) + { + var value = Decimal(row, column); + if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue) + { + throw InvalidResult(); + } + + return decimal.ToInt32(value); + } + + public static double Double(DataRow row, string column) + { + double value; + try + { + value = Convert.ToDouble(Required(row, column), CultureInfo.InvariantCulture); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + + return double.IsFinite(value) ? value : throw InvalidResult(); + } + + public static double? NullableDouble(DataRow row, string column) + { + var value = row[column]; + return value is null or DBNull ? null : Double(row, column); + } + + public static ComparisonChangeDirection ComparisonDirection( + DataRow row, + string column) + { + var value = DirectionText(row, column); + return value switch + { + "1" or "\uC0C1\uD55C" => ComparisonChangeDirection.LimitUp, + "2" or "+" or "\uC0C1\uC2B9" => ComparisonChangeDirection.Up, + "3" or "" or "\uBCF4\uD569" => ComparisonChangeDirection.Flat, + "5" or "-" or "\uD558\uB77D" => ComparisonChangeDirection.Down, + "4" or "\uD558\uD55C" => ComparisonChangeDirection.LimitDown, + _ => throw InvalidResult() + }; + } + + public static YieldQuoteDirection YieldDirection(DataRow row, string column) => + DirectionText(row, column) switch + { + "+" or "1" or "2" or "\uC0C1\uD55C" or "\uC0C1\uC2B9" => + YieldQuoteDirection.Up, + "" or "3" or "\uBCF4\uD569" => YieldQuoteDirection.Flat, + "-" or "4" or "5" or "\uD558\uD55C" or "\uD558\uB77D" => + YieldQuoteDirection.Down, + _ => throw InvalidResult() + }; + + public static LegacySceneDataException InvalidResult() => + new("The comparison or yield query returned an invalid result."); + + private static decimal Decimal(DataRow row, string column) + { + try + { + return Convert.ToDecimal(Required(row, column), CultureInfo.InvariantCulture); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + private static string DirectionText(DataRow row, string column) + { + if (row[column] is null or DBNull) + { + // Oracle materializes its empty-string flat marker as DBNull. + return string.Empty; + } + + try + { + return Convert.ToString( + row[column], + CultureInfo.InvariantCulture)?.Trim() ?? string.Empty; + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + private static object Required(DataRow row, string column) => + row[column] is { } value && value is not DBNull + ? value + : throw InvalidResult(); +} + +internal static class ComparisonAndYieldQueryFactory +{ + public static DataQuerySpec ComparisonCandle(ComparisonStockSelection selection) + { + var (online, stock, history) = EquityTables(selection.Market); + var sql = $""" + WITH SELECTED_STOCK AS ( + SELECT :stockName STOCK_NAME FROM DUAL + ), QUOTE_ROW AS ( + SELECT DISTINCT b.f_stock_wanname STOCK_NAME, + a.f_curr_price CURRENT_PRICE, + ABS(a.f_net_chg) NET_CHANGE, + {OracleEquityRate("a")} RATE, + a.f_chg_type DIRECTION + FROM {online} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME + WHERE b.f_mkt_halt = 'N' + AND a.f_curr_price <> 0 + ), OPEN_DAYS AS ( + SELECT OPEN_DAY + FROM ( + SELECT OPEN_DAY FROM v_open_day ORDER BY OPEN_DAY DESC + ) + WHERE ROWNUM <= :rowLimit + ), CANDLE_ROWS AS ( + SELECT h.f_data_date OPEN_DAY, + h.f_high_price HIGH_VALUE, + h.f_low_price LOW_VALUE, + h.f_init_price OPEN_VALUE, + h.f_curr_price CLOSE_VALUE + FROM {history} h + JOIN {stock} b ON h.f_stock_code = b.f_stock_code + JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME + WHERE b.f_mkt_halt = 'N' + ) + SELECT q.STOCK_NAME, q.CURRENT_PRICE, q.NET_CHANGE, q.RATE, q.DIRECTION, + d.OPEN_DAY, c.HIGH_VALUE, c.LOW_VALUE, c.OPEN_VALUE, c.CLOSE_VALUE + FROM QUOTE_ROW q + CROSS JOIN OPEN_DAYS d + LEFT JOIN CANDLE_ROWS c ON d.OPEN_DAY = c.OPEN_DAY + ORDER BY d.OPEN_DAY + """; + return new DataQuerySpec( + sql, + [ + new DataQueryParameter("stockName", selection.StockName, DbType.String), + new DataQueryParameter("rowLimit", 5, DbType.Int32) + ]); + } + + public static DataQuerySpec ComparisonYield( + ComparisonStockSelection selection, + int rowLimit) + { + var (online, stock, history) = EquityTables(selection.Market); + var sql = $""" + WITH SELECTED_STOCK AS ( + SELECT :stockName STOCK_NAME FROM DUAL + ), CURRENT_DAY AS ( + SELECT MAX(SUBSTR(f_data_time, 1, 8)) DATA_DAY + FROM t_index + WHERE f_part_code = '001' + ), QUOTE_ROW AS ( + SELECT DISTINCT b.f_stock_wanname STOCK_NAME, + a.f_curr_price CURRENT_PRICE, + ABS(a.f_net_chg) NET_CHANGE, + {OracleEquityRate("a")} RATE, + a.f_chg_type DIRECTION + FROM {online} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME + WHERE b.f_mkt_halt = 'N' + AND a.f_curr_price <> 0 + ), STOCK_HISTORY AS ( + SELECT h.f_data_date DATA_DAY, h.f_curr_price VALUE + FROM {history} h + JOIN {stock} b ON h.f_stock_code = b.f_stock_code + JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME + WHERE b.f_mkt_halt = 'N' + ), RAW_POINTS AS ( + SELECT d.open_day DATA_DAY, h.VALUE + FROM v_open_day d + LEFT JOIN STOCK_HISTORY h ON d.open_day = h.DATA_DAY + WHERE d.open_day <> (SELECT DATA_DAY FROM CURRENT_DAY) + UNION ALL + SELECT (SELECT DATA_DAY FROM CURRENT_DAY) DATA_DAY, + a.f_curr_price VALUE + FROM {online} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME + WHERE b.f_mkt_halt = 'N' + AND a.f_curr_price <> 0 + ), BOUNDED_POINTS AS ( + SELECT DATA_DAY, VALUE + FROM ( + SELECT DATA_DAY, VALUE FROM RAW_POINTS ORDER BY DATA_DAY DESC + ) + WHERE ROWNUM <= :rowLimit + ) + SELECT q.STOCK_NAME, q.CURRENT_PRICE, q.NET_CHANGE, q.RATE, q.DIRECTION, + p.DATA_DAY, p.VALUE + FROM QUOTE_ROW q + CROSS JOIN BOUNDED_POINTS p + ORDER BY p.DATA_DAY DESC + """; + return new DataQuerySpec( + sql, + [ + new DataQueryParameter("stockName", selection.StockName, DbType.String), + new DataQueryParameter("rowLimit", rowLimit, DbType.Int32) + ]); + } + + public static DataQuerySpec SingleYield( + YieldGraphInstrument instrument, + int rowLimit) + { + var raw = RawSingleYield(instrument); + var sql = $""" + {raw.Prefix} + SELECT DATA_DAY, VALUE + FROM ( + SELECT DATA_DAY, VALUE FROM RAW_POINTS ORDER BY DATA_DAY DESC + ) + WHERE ROWNUM <= :rowLimit + """; + var parameters = raw.Parameters.ToList(); + parameters.Add(new DataQueryParameter("rowLimit", rowLimit, DbType.Int32)); + return new DataQuerySpec(sql, parameters); + } + + public static DataQuerySpec QuotedFxYield( + YieldGraphInstrument instrument, + int rowLimit) + { + var inputCode = FxCode(instrument.Kind); + var sql = $""" + WITH SELECTED_INPUT AS ( + SELECT :inputCode INPUT_CODE FROM DUAL + ), RAW_POINTS AS ( + SELECT a.f_data_day DATA_DAY, + a.f_curr_price VALUE, + a.f_net_chg CHANGE_PRICE, + {OracleFxRate("a")} RATE, + a.f_chg_type DIRECTION + FROM t_week_index a + JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE + WHERE a.f_data_day <> ( + SELECT MAX(t.f_data_day) + FROM t_input_index t + JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE + ) + AND a.f_data_day IN (SELECT open_day FROM v_open_day) + UNION ALL + SELECT a.f_data_day DATA_DAY, + a.f_curr_price VALUE, + a.f_net_chg CHANGE_PRICE, + {OracleFxRate("a")} RATE, + a.f_chg_type DIRECTION + FROM t_input_index a + JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE + WHERE a.f_data_day = ( + SELECT MAX(t.f_data_day) + FROM t_input_index t + JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE + ) + ) + SELECT DATA_DAY, VALUE, CHANGE_PRICE, RATE, DIRECTION + FROM ( + SELECT DATA_DAY, VALUE, CHANGE_PRICE, RATE, DIRECTION + FROM RAW_POINTS + ORDER BY DATA_DAY DESC + ) + WHERE ROWNUM <= :rowLimit + """; + return new DataQuerySpec( + sql, + [ + new DataQueryParameter("inputCode", inputCode, DbType.String), + new DataQueryParameter("rowLimit", rowLimit, DbType.Int32) + ]); + } + + private static RawYieldQuery RawSingleYield(YieldGraphInstrument instrument) => + instrument.Kind switch + { + YieldGraphInstrumentKind.WonDollar or + YieldGraphInstrumentKind.WonYen or + YieldGraphInstrumentKind.WonYuan or + YieldGraphInstrumentKind.WonEuro => RawFxYield(instrument.Kind), + YieldGraphInstrumentKind.KospiIndex => RawIndexYield( + "t_index_his_day", "t_index", "001"), + YieldGraphInstrumentKind.KosdaqIndex => RawIndexYield( + "t_kosdaq_index_his_day", "t_kosdaq_index", "001"), + YieldGraphInstrumentKind.Kospi200Index => RawIndexYield( + "t_200_index_his_day", "t_200_index", "029"), + YieldGraphInstrumentKind.Krx100Index => RawIndexYield( + "t_krx100_index_his_day", "t_krx100_index", "043"), + YieldGraphInstrumentKind.KospiIndustry => RawIndustryYield( + instrument.Name!, + "t_part", + "t_index_his", + "t_index"), + YieldGraphInstrumentKind.KosdaqIndustry => RawIndustryYield( + instrument.Name!, + "t_kosdaq_part", + "t_kosdaq_index_his", + "t_kosdaq_index"), + YieldGraphInstrumentKind.KospiStock => RawStockYield( + instrument.Name!, + "t_stock", + "t_candle_history", + "t_online1"), + YieldGraphInstrumentKind.KosdaqStock => RawStockYield( + instrument.Name!, + "t_kosdaq_stock", + "t_kosdaq_candle_history", + "t_kosdaq_online1"), + _ => throw ComparisonAndYieldLoadGuard.InvalidRequest() + }; + + private static RawYieldQuery RawFxYield(YieldGraphInstrumentKind kind) + { + var inputCode = FxCode(kind); + return new RawYieldQuery( + $""" + WITH SELECTED_INPUT AS ( + SELECT :inputCode INPUT_CODE FROM DUAL + ), RAW_POINTS AS ( + SELECT a.f_data_day DATA_DAY, a.f_curr_price VALUE + FROM t_week_index a + JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE + WHERE a.f_data_day <> ( + SELECT MAX(t.f_data_day) + FROM t_input_index t + JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE + ) + AND a.f_data_day IN (SELECT open_day FROM v_open_day) + UNION ALL + SELECT a.f_data_day DATA_DAY, a.f_curr_price VALUE + FROM t_input_index a + JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE + WHERE a.f_data_day = ( + SELECT MAX(t.f_data_day) + FROM t_input_index t + JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE + ) + ) + """, + [new DataQueryParameter("inputCode", inputCode, DbType.String)]); + } + + private static RawYieldQuery RawIndexYield( + string historyTable, + string currentTable, + string partCode) => new( + $""" + WITH CURRENT_DAY AS ( + SELECT MAX(SUBSTR(f_data_time, 1, 8)) DATA_DAY + FROM {currentTable} + WHERE f_part_code = '{partCode}' + ), RAW_POINTS AS ( + SELECT h.f_data_day DATA_DAY, h.f_part_idx / 100 VALUE + FROM {historyTable} h + WHERE h.f_part_code = '{partCode}' + AND h.f_data_day <> (SELECT DATA_DAY FROM CURRENT_DAY) + AND RTRIM(h.f_data_day) IN (SELECT open_day FROM v_open_day) + UNION ALL + SELECT SUBSTR(c.f_data_time, 1, 8) DATA_DAY, + c.f_part_idx / 100 VALUE + FROM {currentTable} c + WHERE c.f_part_code = '{partCode}' + AND SUBSTR(c.f_data_time, 1, 8) = ( + SELECT DATA_DAY FROM CURRENT_DAY + ) + ) + """, + []); + + private static RawYieldQuery RawIndustryYield( + string name, + string partTable, + string historyTable, + string currentTable) => new( + $""" + WITH SELECTED_NAME AS ( + SELECT :instrumentName INSTRUMENT_NAME FROM DUAL + ), SELECTED_PART AS ( + SELECT p.f_part_code PART_CODE + FROM {partTable} p + JOIN SELECTED_NAME n ON p.f_part_name = n.INSTRUMENT_NAME + ), CURRENT_DAY AS ( + SELECT MAX(SUBSTR(c.f_data_time, 1, 8)) DATA_DAY + FROM {currentTable} c + JOIN SELECTED_PART p ON c.f_part_code = p.PART_CODE + ), RAW_POINTS AS ( + SELECT SUBSTR(h.f_data_time, 1, 8) DATA_DAY, + h.f_part_idx / 100 VALUE + FROM {historyTable} h + JOIN SELECTED_PART p ON h.f_part_code = p.PART_CODE + WHERE SUBSTR(h.f_data_time, 1, 8) <> + (SELECT DATA_DAY FROM CURRENT_DAY) + AND RTRIM(h.f_data_time) IN ( + SELECT open_day || '1535' FROM v_open_day + ) + UNION ALL + SELECT SUBSTR(c.f_data_time, 1, 8) DATA_DAY, + c.f_part_idx / 100 VALUE + FROM {currentTable} c + JOIN SELECTED_PART p ON c.f_part_code = p.PART_CODE + WHERE SUBSTR(c.f_data_time, 1, 8) = + (SELECT DATA_DAY FROM CURRENT_DAY) + ) + """, + [new DataQueryParameter("instrumentName", name, DbType.String)]); + + private static RawYieldQuery RawStockYield( + string name, + string stockTable, + string historyTable, + string onlineTable) => new( + $""" + WITH SELECTED_NAME AS ( + SELECT :instrumentName INSTRUMENT_NAME FROM DUAL + ), CURRENT_DAY AS ( + SELECT MAX(SUBSTR(f_data_time, 1, 8)) DATA_DAY + FROM t_index + WHERE f_part_code = '001' + ), STOCK_HISTORY AS ( + SELECT h.f_data_date DATA_DAY, h.f_curr_price VALUE + FROM {historyTable} h + JOIN {stockTable} b ON h.f_stock_code = b.f_stock_code + JOIN SELECTED_NAME n ON b.f_stock_wanname = n.INSTRUMENT_NAME + WHERE b.f_mkt_halt = 'N' + ), RAW_POINTS AS ( + SELECT d.open_day DATA_DAY, h.VALUE + FROM v_open_day d + LEFT JOIN STOCK_HISTORY h ON d.open_day = h.DATA_DAY + WHERE d.open_day <> (SELECT DATA_DAY FROM CURRENT_DAY) + UNION ALL + SELECT (SELECT DATA_DAY FROM CURRENT_DAY) DATA_DAY, + o.f_curr_price VALUE + FROM {onlineTable} o + JOIN {stockTable} b ON o.f_stock_code = b.f_stock_code + JOIN SELECTED_NAME n ON b.f_stock_wanname = n.INSTRUMENT_NAME + WHERE b.f_mkt_halt = 'N' + AND o.f_curr_price <> 0 + ) + """, + [new DataQueryParameter("instrumentName", name, DbType.String)]); + + private static (string Online, string Stock, string History) EquityTables( + ComparisonEquityMarket market) => market switch + { + ComparisonEquityMarket.Kospi => + ("t_online1", "t_stock", "t_candle_history"), + ComparisonEquityMarket.Kosdaq => + ("t_kosdaq_online1", "t_kosdaq_stock", "t_kosdaq_candle_history"), + _ => throw ComparisonAndYieldLoadGuard.InvalidRequest() + }; + + private static string FxCode(YieldGraphInstrumentKind kind) => kind switch + { + YieldGraphInstrumentKind.WonDollar => "A09", + YieldGraphInstrumentKind.WonYen => "A10", + YieldGraphInstrumentKind.WonYuan => "A90", + YieldGraphInstrumentKind.WonEuro => "A91", + _ => throw ComparisonAndYieldLoadGuard.InvalidRequest() + }; + + private static string OracleEquityRate(string alias) => $""" + ROUND(({alias}.f_net_chg / + DECODE({alias}.f_chg_type, + '1', {alias}.f_curr_price - {alias}.f_net_chg, + '2', {alias}.f_curr_price - {alias}.f_net_chg, + '3', {alias}.f_curr_price, + '4', -1 * ({alias}.f_curr_price + {alias}.f_net_chg), + '5', -1 * ({alias}.f_curr_price + {alias}.f_net_chg))) * 100, 2) + """; + + private static string OracleFxRate(string alias) => $""" + ROUND(({alias}.f_net_chg / + DECODE({alias}.f_chg_type, + '+', {alias}.f_curr_price - {alias}.f_net_chg, + '', {alias}.f_curr_price, + '-', {alias}.f_curr_price + {alias}.f_net_chg, + 1)) * 100, 2) + """; + + private sealed record RawYieldQuery( + string Prefix, + IReadOnlyList Parameters); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/EquityPanelSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/EquityPanelSceneDataLoaders.cs new file mode 100644 index 0000000..b7cc3d9 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/EquityPanelSceneDataLoaders.cs @@ -0,0 +1,515 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed record S5006DomesticSceneDataRequest( + LegacyDomesticEquityMarket Market, + string StockCode); + +public sealed class S5006DomesticSceneDataLoader +{ + private static readonly string[] Columns = + [ + "STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", + "OPEN_PRICE", "OPEN_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE" + ]; + + private readonly IDataQueryExecutor _executor; + + public S5006DomesticSceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5006DomesticSceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var view = request.Market switch + { + LegacyDomesticEquityMarket.Kospi => "V_2", + LegacyDomesticEquityMarket.Kosdaq => "V_2_KOSDAQ", + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + var stockCode = EquityPanelLoaderReader.StockCode(request.StockCode); + var sql = $""" + SELECT + a.stock_name STOCK_NAME, + a.chg_type DIRECTION_CODE, + a.curr_price CURRENT_PRICE, + a.net_chg NET_CHANGE, + a.rate RATE, + a.init_price OPEN_PRICE, + DECODE( + a.final_price, + 0, ROUND(((a.init_price - a.base_price) / a.base_price) * 100, 2), + ROUND(((a.init_price - a.final_price) / a.final_price) * 100, 2)) OPEN_RATE, + a.high_price HIGH_PRICE, + DECODE( + a.final_price, + 0, ROUND(((a.high_price - a.base_price) / a.base_price) * 100, 2), + ROUND(((a.high_price - a.final_price) / a.final_price) * 100, 2)) HIGH_RATE, + a.low_price LOW_PRICE, + DECODE( + a.final_price, + 0, ROUND(((a.low_price - a.base_price) / a.base_price) * 100, 2), + ROUND(((a.low_price - a.final_price) / a.final_price) * 100, 2)) LOW_RATE + FROM {view} a + WHERE a.stock_code = :stockCode + """; + var spec = EquityPanelLoaderReader.Spec( + DataSourceKind.Oracle, + sql, + new DataQueryParameter("stockCode", stockCode, DbType.String)); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S5006_DOMESTIC", + spec, + cancellationToken).ConfigureAwait(false); + var row = EquityPanelLoaderReader.SingleRow(table, Columns); + var data = new S5006SceneData( + request.Market, + EquityPanelLoaderReader.Text(row, "STOCK_NAME"), + EquityPanelLoaderReader.Direction(row, "DIRECTION_CODE"), + EquityPanelLoaderReader.Int64(row, "CURRENT_PRICE"), + EquityPanelLoaderReader.Int64(row, "NET_CHANGE"), + EquityPanelLoaderReader.Decimal(row, "RATE"), + EquityPanelLoaderReader.Int64(row, "OPEN_PRICE"), + EquityPanelLoaderReader.Decimal(row, "OPEN_RATE"), + EquityPanelLoaderReader.Int64(row, "HIGH_PRICE"), + EquityPanelLoaderReader.Decimal(row, "HIGH_RATE"), + EquityPanelLoaderReader.Int64(row, "LOW_PRICE"), + EquityPanelLoaderReader.Decimal(row, "LOW_RATE")); + _ = new S5006SceneMutationBuilder().Build(data); + return data; + } +} + +public sealed record S5011SceneDataRequest( + LegacyDomesticEquityMarket Market, + S5011DetailBranch Branch, + string StockCode); + +public sealed class S5011SceneDataLoader +{ + private static readonly string[] Columns = + [ + "STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", + "DETAIL_1", "DETAIL_2", "DETAIL_3", "DETAIL_4" + ]; + + private readonly IDataQueryExecutor _executor; + + public S5011SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5011SceneDataRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (!Enum.IsDefined(request.Market) || !Enum.IsDefined(request.Branch)) + { + throw EquityPanelLoaderReader.InvalidRequest(); + } + + var stockCode = EquityPanelLoaderReader.StockCode(request.StockCode); + var plan = S5011QueryFactory.Create(request.Market, request.Branch, stockCode); + var table = await _executor.ExecuteAsync( + plan.Source, + "S5011_DETAIL", + plan.Spec, + cancellationToken).ConfigureAwait(false); + var row = EquityPanelLoaderReader.SingleRow(table, Columns); + S5011DetailData detail = request.Branch switch + { + S5011DetailBranch.FaceValue => new S5011FaceValueDetail( + EquityPanelLoaderReader.Int64(row, "DETAIL_1"), + EquityPanelLoaderReader.Int64(row, "DETAIL_2"), + EquityPanelLoaderReader.Int64(row, "DETAIL_3"), + EquityPanelLoaderReader.Int64(row, "DETAIL_4")), + S5011DetailBranch.Valuation => new S5011ValuationDetail( + EquityPanelLoaderReader.Decimal(row, "DETAIL_1"), + EquityPanelLoaderReader.Decimal(row, "DETAIL_2"), + EquityPanelLoaderReader.Int64(row, "DETAIL_3"), + EquityPanelLoaderReader.Int64(row, "DETAIL_4")), + S5011DetailBranch.Volume => new S5011VolumeDetail( + EquityPanelLoaderReader.Int64(row, "DETAIL_1"), + EquityPanelLoaderReader.Int64(row, "DETAIL_2"), + EquityPanelLoaderReader.Int64(row, "DETAIL_3"), + EquityPanelLoaderReader.Int64(row, "DETAIL_4")), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + var data = new S5011SceneData( + request.Market, + EquityPanelLoaderReader.Text(row, "STOCK_NAME"), + EquityPanelLoaderReader.Direction(row, "DIRECTION_CODE"), + EquityPanelLoaderReader.Int64(row, "CURRENT_PRICE"), + EquityPanelLoaderReader.Int64(row, "NET_CHANGE"), + EquityPanelLoaderReader.Decimal(row, "RATE"), + detail); + _ = new S5011SceneMutationBuilder().Build(data); + return data; + } +} + +internal static class S5011QueryFactory +{ + public static QueryPlan Create( + LegacyDomesticEquityMarket market, + S5011DetailBranch branch, + string stockCode) + { + if (market is LegacyDomesticEquityMarket.NxtKospi or + LegacyDomesticEquityMarket.NxtKosdaq) + { + if (branch == S5011DetailBranch.Valuation) + { + // The legacy NXT query fabricated four zero valuation values. + throw EquityPanelLoaderReader.InvalidRequest(); + } + + return Nxt(market, branch, stockCode); + } + + return Oracle(market, branch, stockCode); + } + + private static QueryPlan Oracle( + LegacyDomesticEquityMarket market, + S5011DetailBranch branch, + string stockCode) + { + var source = market switch + { + LegacyDomesticEquityMarket.Kospi => new OracleSource( + "V_2", "t_online1", "t_stock", "T_CANDLE_HISTORY"), + LegacyDomesticEquityMarket.Kosdaq => new OracleSource( + "V_2_KOSDAQ", "t_kosdaq_online1", "t_kosdaq_stock", + "T_KOSDAQ_CANDLE_HISTORY"), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + var sql = branch switch + { + S5011DetailBranch.FaceValue => $""" + SELECT + a.stock_name STOCK_NAME, + a.chg_type DIRECTION_CODE, + a.curr_price CURRENT_PRICE, + a.net_chg NET_CHANGE, + a.rate RATE, + a.list_price DETAIL_1, + a.capital_price DETAIL_2, + a.siga_price DETAIL_3, + b.rank_num DETAIL_4 + FROM {source.View} a, + (SELECT + z.f_stock_code stock_code, + RANK() OVER (ORDER BY z.f_list_num * y.f_curr_price DESC) rank_num + FROM {source.OnlineTable} y, {source.StockTable} z + WHERE y.f_stock_code = z.f_stock_code + AND z.f_mkt_halt = 'N') b + WHERE a.stock_code = b.stock_code + AND a.stock_code = :stockCode + """, + S5011DetailBranch.Valuation => $""" + SELECT + a.stock_name STOCK_NAME, + a.chg_type DIRECTION_CODE, + a.curr_price CURRENT_PRICE, + a.net_chg NET_CHANGE, + a.rate RATE, + a.pbr DETAIL_1, + a.per DETAIL_2, + a.bps DETAIL_3, + a.eps DETAIL_4 + FROM {source.View} a + WHERE a.stock_code = :stockCode + """, + S5011DetailBranch.Volume => $""" + SELECT + a.stock_name STOCK_NAME, + a.chg_type DIRECTION_CODE, + a.curr_price CURRENT_PRICE, + a.net_chg NET_CHANGE, + a.rate RATE, + a.net_vol DETAIL_1, + a.net_turnover DETAIL_2, + NVL(( + SELECT ROUND((SUM(recent.F_CURR_PRICE) + a.curr_price) / 5) + FROM ( + SELECT F_CURR_PRICE + FROM ( + SELECT F_CURR_PRICE + FROM {source.CandleTable} + WHERE F_DATA_DATE < (SELECT MAX(open_day) FROM v_open_day) + AND f_stock_code = :stockCode5 + ORDER BY F_DATA_DATE DESC + ) + WHERE ROWNUM < 5 + ) recent + ), 0) DETAIL_3, + NVL(( + SELECT ROUND((SUM(recent.F_CURR_PRICE) + a.curr_price) / 20) + FROM ( + SELECT F_CURR_PRICE + FROM ( + SELECT F_CURR_PRICE + FROM {source.CandleTable} + WHERE F_DATA_DATE < (SELECT MAX(open_day) FROM v_open_day) + AND f_stock_code = :stockCode20 + ORDER BY F_DATA_DATE DESC + ) + WHERE ROWNUM < 20 + ) recent + ), 0) DETAIL_4 + FROM {source.View} a + WHERE a.stock_code = :stockCode + """, + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + var parameters = branch == S5011DetailBranch.Volume + ? new[] + { + new DataQueryParameter("stockCode5", stockCode, DbType.String), + new DataQueryParameter("stockCode20", stockCode, DbType.String), + new DataQueryParameter("stockCode", stockCode, DbType.String) + } + : [new DataQueryParameter("stockCode", stockCode, DbType.String)]; + return new QueryPlan( + DataSourceKind.Oracle, + EquityPanelLoaderReader.Spec(DataSourceKind.Oracle, sql, parameters)); + } + + private static QueryPlan Nxt( + LegacyDomesticEquityMarket market, + S5011DetailBranch branch, + string stockCode) + { + var source = market switch + { + LegacyDomesticEquityMarket.NxtKospi => new NxtSource( + "n_online", "n_stock"), + LegacyDomesticEquityMarket.NxtKosdaq => new NxtSource( + "n_kosdaq_online", "n_kosdaq_stock"), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + var sql = branch switch + { + S5011DetailBranch.FaceValue => $""" + SELECT + b.f_stock_name STOCK_NAME, + a.f_chg_type DIRECTION_CODE, + a.f_curr_price CURRENT_PRICE, + a.f_net_chg NET_CHANGE, + ROUND((a.f_net_chg / CASE + WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg + WHEN a.f_chg_type = '3' THEN a.f_curr_price + WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg) + END) * 100, 2) RATE, + b.f_list_price DETAIL_1, + b.f_capital_price DETAIL_2, + b.f_list_num * a.f_curr_price DETAIL_3, + b.rank_num DETAIL_4 + FROM {source.OnlineTable} a + JOIN ( + SELECT + z.f_stock_code, + z.f_stock_name, + z.f_list_price, + z.f_capital_price, + z.f_list_num, + RANK() OVER (ORDER BY z.f_list_num * y.f_curr_price DESC) rank_num + FROM {source.OnlineTable} y + JOIN {source.StockTable} z ON y.f_stock_code = z.f_stock_code + WHERE z.f_stop_gubun = 'N' + ) b ON a.f_stock_code = b.f_stock_code + WHERE a.f_stock_code = @stockCode + """, + S5011DetailBranch.Volume => $""" + SELECT + z.f_stock_name STOCK_NAME, + a.f_chg_type DIRECTION_CODE, + a.f_curr_price CURRENT_PRICE, + a.f_net_chg NET_CHANGE, + ROUND((a.f_net_chg / CASE + WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg + WHEN a.f_chg_type = '3' THEN a.f_curr_price + WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg) + END) * 100, 2) RATE, + a.f_net_vol DETAIL_1, + a.f_net_turnover DETAIL_2, + 0 DETAIL_3, + 0 DETAIL_4 + FROM {source.OnlineTable} a + JOIN {source.StockTable} z ON a.f_stock_code = z.f_stock_code + WHERE a.f_stock_code = @stockCode + """, + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + return new QueryPlan( + DataSourceKind.MariaDb, + EquityPanelLoaderReader.Spec( + DataSourceKind.MariaDb, + sql, + new DataQueryParameter("stockCode", stockCode, DbType.String))); + } + + internal sealed record QueryPlan(DataSourceKind Source, DataQuerySpec Spec); + + private sealed record OracleSource( + string View, + string OnlineTable, + string StockTable, + string CandleTable); + + private sealed record NxtSource(string OnlineTable, string StockTable); +} + +internal static class EquityPanelLoaderReader +{ + private const int MaximumTextLength = 256; + + public static DataQuerySpec Spec( + DataSourceKind source, + string sql, + params DataQueryParameter[] parameters) + { + var spec = new DataQuerySpec(sql, parameters); + spec.ValidateFor(source); + return spec; + } + + public static string StockCode(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 32 || + value.Any(character => !char.IsAsciiLetterOrDigit(character))) + { + throw InvalidRequest(); + } + + return value; + } + + public static DataRow SingleRow(DataTable? table, IReadOnlyList columns) + { + ExactSchema(table, columns); + if (table!.Rows.Count != 1) + { + throw InvalidResult(); + } + + return table.Rows[0]; + } + + public static void ExactSchema(DataTable? table, IReadOnlyList columns) + { + if (table is null || table.Columns.Count != columns.Count) + { + throw InvalidResult(); + } + + for (var index = 0; index < columns.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + columns[index], + StringComparison.OrdinalIgnoreCase)) + { + throw InvalidResult(); + } + } + } + + public static string Text(DataRow row, string column, bool allowNull = false) + { + var value = row[column]; + if (value is null or DBNull) + { + if (allowNull) + { + return string.Empty; + } + + throw InvalidResult(); + } + + var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim(); + if (text is null || text.Length > MaximumTextLength || text.Any(char.IsControl) || + (!allowNull && text.Length == 0)) + { + throw InvalidResult(); + } + + return text; + } + + public static object Value(DataRow row, string column) => + row[column] is { } value && value is not DBNull ? value : throw InvalidResult(); + + public static long Int64(DataRow row, string column) + { + try + { + return checked(Convert.ToInt64(Value(row, column), CultureInfo.InvariantCulture)); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + public static decimal Decimal(DataRow row, string column) + { + try + { + return Convert.ToDecimal(Value(row, column), CultureInfo.InvariantCulture); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + public static double Double(DataRow row, string column) + { + try + { + var result = Convert.ToDouble(Value(row, column), CultureInfo.InvariantCulture); + return double.IsFinite(result) ? result : throw InvalidResult(); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + public static ScenePriceDirection Direction(DataRow row, string column) => + Direction(Text(row, column, allowNull: true)); + + public static ScenePriceDirection Direction(string? value) => value?.Trim() switch + { + "\uC0C1\uD55C" or "1" => ScenePriceDirection.LimitUp, + "\uC0C1\uC2B9" or "+" or "2" => ScenePriceDirection.Up, + null or "" or "\uBCF4\uD569" or "3" => ScenePriceDirection.Flat, + "\uD558\uB77D" or "-" or "5" => ScenePriceDirection.Down, + "\uD558\uD55C" or "4" => ScenePriceDirection.LimitDown, + _ => throw InvalidResult() + }; + + public static LegacySceneDataException InvalidRequest() => + new("The panel scene data request is unsupported or invalid."); + + public static LegacySceneDataException InvalidResult() => + new("The panel scene query returned invalid data or an unexpected schema."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs new file mode 100644 index 0000000..9fb0a4b --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs @@ -0,0 +1,457 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed record RevenueSliceData( + string Label, + string PercentageText, + double Percentage); + +public sealed record S5076SceneData( + string StockName, + string Basis, + IReadOnlyList Slices) : ILegacySceneData; + +public sealed class S5076SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5076"; + + public IReadOnlyList Build(S5076SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + var basis = LegacySceneBuilderGuard.Text(data.Basis, nameof(data.Basis)); + var slices = LegacySceneBuilderGuard.Count(data.Slices, 5, nameof(data.Slices)); + var mutations = new List(34) + { + new PlayoutSetValue("title", stockName + " 주요매출 구성"), + new PlayoutSetValue("date", basis + " 기준") + }; + + for (var index = 1; index <= 5; index++) + { + mutations.Add(new PlayoutSetVisible("circle" + index, false)); + mutations.Add(new PlayoutSetVisible("group" + index, false)); + } + + double startAngle = 0; + for (var index = 1; index <= 5; index++) + { + var slice = slices[index - 1]; + if (slice is null || string.IsNullOrEmpty(slice.Label)) + { + continue; + } + + var label = LegacySceneBuilderGuard.Text(slice.Label, $"Slices[{index - 1}].Label"); + var percentageText = LegacySceneBuilderGuard.Text( + slice.PercentageText, + $"Slices[{index - 1}].PercentageText"); + if (!double.IsFinite(slice.Percentage)) + { + throw new LegacySceneDataException("Revenue percentage must be finite."); + } + + mutations.Add(new PlayoutSetVisible("circle" + index, true)); + mutations.Add(new PlayoutSetVisible("group" + index, true)); + mutations.Add(new PlayoutSetValue("word" + index, label)); + mutations.Add(new PlayoutSetValue("percent" + index, percentageText)); + + var angle = slice.Percentage / 100d * 360d; + mutations.Add(new PlayoutSetCircleAngleKey( + "circle" + index, + 0, + (float)startAngle, + (float)startAngle, + PlayoutAngleComponents.All)); + mutations.Add(new PlayoutSetCircleAngleKey( + "circle" + index, + 1, + (float)startAngle, + (float)(startAngle + angle), + PlayoutAngleComponents.All)); + startAngle += angle; + } + + return mutations; + } +} + +public sealed record QuarterlyProfitData(string Quarter, int Value); + +public sealed record S5081SceneData( + string StockName, + IReadOnlyList Quarters) : ILegacySceneData; + +public sealed class S5081SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5081"; + + public IReadOnlyList Build(S5081SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + var quarters = LegacySceneBuilderGuard.Count(data.Quarters, 6, nameof(data.Quarters)); + var mutations = new List(38) + { + new PlayoutSetValue("title", stockName + " 영업이익") + }; + + for (var index = 1; index <= 6; index++) + { + var quarter = LegacySceneBuilderGuard.NotNull( + quarters[index - 1], + $"Quarters[{index - 1}]"); + mutations.Add(new PlayoutSetValue( + "quarter" + index, + LegacySceneBuilderGuard.Text(quarter.Quarter, $"Quarters[{index - 1}].Quarter"))); + } + + var values = quarters.Select(item => item.Value).ToArray(); + for (var index = 1; index <= 6; index++) + { + var value = values[index - 1]; + var variant = value < 0 ? 2 : 1; + mutations.Add(new PlayoutSetVisible($"barG{index}_1", false)); + mutations.Add(new PlayoutSetVisible($"barG{index}_2", false)); + mutations.Add(new PlayoutSetVisible($"barG{index}_{variant}", true)); + mutations.Add(new PlayoutSetValue( + $"value{index}_{variant}", + value.ToString("#,##0", CultureInfo.InvariantCulture))); + } + + var maximum = values.Max(); + var minimum = values.Min(); + var allPositive = maximum > 0 && minimum > 0; + var allNegative = maximum < 0 && minimum < 0; + var center = allPositive + ? -240f + : allNegative + ? 115f + : SafeFloat((355d * Math.Abs(minimum) / (maximum + Math.Abs((double)minimum))) - 250d, -250f); + mutations.Add(new PlayoutSetPositionKey( + "centerbar", + 0, + 0, + center, + 0, + PlayoutVectorComponents.Y)); + + for (var index = 1; index <= 6; index++) + { + var value = values[index - 1]; + var denominator = allPositive + ? maximum + : allNegative + ? minimum + : maximum - minimum; + var scale = denominator == 0 + ? 0f + : (float)(1.8d * Math.Abs(value / (double)denominator)); + var variant = value < 0 ? 2 : 1; + mutations.Add(new PlayoutSetScale( + $"bar{index}_{variant}", + 0, + scale, + 0, + PlayoutVectorComponents.Y)); + } + + return mutations; + } + + private static float SafeFloat(double value, float fallback) => + double.IsFinite(value) ? (float)value : fallback; +} + +public enum ProgramTradingMetric +{ + NetBuy, + Arbitrage, + NonArbitrage, + Basis, + Kospi200, + Futures +} + +public sealed record ProgramTradingMetricData(ProgramTradingMetric Metric, double Value); + +public sealed record S5085SceneData( + IReadOnlyList Metrics) : ILegacySceneData; + +public sealed class S5085SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5085"; + + public IReadOnlyList Build(S5085SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var metrics = LegacySceneBuilderGuard.Range(data.Metrics, 1, 6, nameof(data.Metrics)); + if (metrics.Select(item => item.Metric).Distinct().Count() != metrics.Count) + { + throw new LegacySceneDataException("Program trading metrics must be unique."); + } + + var mutations = new List(metrics.Count * 3); + foreach (var metric in metrics) + { + if (!double.IsFinite(metric.Value)) + { + throw new LegacySceneDataException("Program trading value must be finite."); + } + + var (index, title) = metric.Metric switch + { + ProgramTradingMetric.NetBuy => (1, "순매수"), + ProgramTradingMetric.Arbitrage => (2, "차익"), + ProgramTradingMetric.NonArbitrage => (3, "비차익"), + ProgramTradingMetric.Basis => (4, "베이시스"), + ProgramTradingMetric.Kospi200 => (5, "코스피200"), + ProgramTradingMetric.Futures => (6, "선물"), + _ => throw new LegacySceneDataException("Unknown program trading metric.") + }; + var color = SignedColor(metric.Value, (171, 0, 0), (15, 99, 188), (47, 47, 47)); + mutations.Add(new PlayoutSetValue("title" + index, title)); + mutations.Add(new PlayoutSetFaceColor("price" + index, color.R, color.G, color.B)); + mutations.Add(new PlayoutSetValue( + "price" + index, + metric.Value.ToString("#,##0.##", CultureInfo.InvariantCulture))); + } + + return mutations; + } + + private static (int R, int G, int B) SignedColor( + double value, + (int R, int G, int B) positive, + (int R, int G, int B) negative, + (int R, int G, int B) zero) => value > 0 ? positive : value < 0 ? negative : zero; +} + +public enum InstitutionMarket +{ + Kospi, + Kosdaq, + Other +} + +public enum InstitutionKind +{ + Securities, + Insurance, + InvestmentTrust, + Bank, + Fund +} + +public sealed record InstitutionNetBuyData( + InstitutionMarket Market, + InstitutionKind Institution, + int Value); + +public sealed record S6067SceneData( + IReadOnlyList Rows) : ILegacySceneData; + +public sealed class S6067SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s6067"; + + public IReadOnlyList Build(S6067SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var rows = LegacySceneBuilderGuard.Range(data.Rows, 1, 15, nameof(data.Rows)); + var mutations = new List(rows.Count * 2); + foreach (var row in rows) + { + var market = row.Market switch + { + InstitutionMarket.Kospi => 1, + InstitutionMarket.Kosdaq => 2, + InstitutionMarket.Other => 3, + _ => throw new LegacySceneDataException("Unknown institution market.") + }; + var institution = row.Institution switch + { + InstitutionKind.Securities => 1, + InstitutionKind.Insurance => 2, + InstitutionKind.InvestmentTrust => 3, + InstitutionKind.Bank => 4, + InstitutionKind.Fund => 5, + _ => throw new LegacySceneDataException("Unknown institution type.") + }; + var name = $"row{market}_value{institution}"; + var color = row.Value > 0 + ? (R: 171, G: 0, B: 0) + : row.Value < 0 + ? (R: 15, G: 99, B: 188) + : (R: 47, G: 47, B: 47); + mutations.Add(new PlayoutSetFaceColor(name, color.R, color.G, color.B)); + mutations.Add(new PlayoutSetValue( + name, + row.Value.ToString("#,##0", CultureInfo.InvariantCulture))); + } + + return mutations; + } +} + +public enum WorldMarket +{ + Korea, + China, + HongKong, + Japan, + Taiwan, + Dow, + Nasdaq, + StandardAndPoor, + UnitedKingdom, + Germany, + France +} + +public enum SceneMarketTrend +{ + Up, + Flat, + Down +} + +public sealed record WorldMarketQuoteData( + WorldMarket Market, + double CurrentPrice, + double NetChange, + double Rate, + SceneMarketTrend Trend); + +public sealed record S8067SceneData( + IReadOnlyList Quotes) : ILegacySceneData; + +public sealed class S8067SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s8067"; + + public IReadOnlyList Build(S8067SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var quotes = LegacySceneBuilderGuard.Range(data.Quotes, 1, 11, nameof(data.Quotes)); + if (quotes.Select(item => item.Market).Distinct().Count() != quotes.Count) + { + throw new LegacySceneDataException("World market quotes must be unique."); + } + + var mutations = new List(quotes.Count * 7); + foreach (var quote in quotes) + { + if (!double.IsFinite(quote.CurrentPrice) || !double.IsFinite(quote.NetChange) || + !double.IsFinite(quote.Rate)) + { + throw new LegacySceneDataException("World market quote values must be finite."); + } + + var target = NameOf(quote.Market); + mutations.Add(new PlayoutSetValue( + "price_" + target, + quote.CurrentPrice.ToString("#,##0.###", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetValue( + "rate_" + target, + quote.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture))); + mutations.Add(new PlayoutSetVisible("up_" + target, false)); + mutations.Add(new PlayoutSetVisible("down_" + target, false)); + mutations.Add(new PlayoutSetVisible("flat_" + target, false)); + + if (quote.Trend == SceneMarketTrend.Up) + { + mutations.Add(new PlayoutSetFaceColor("bg_" + target, 170, 0, 0)); + mutations.Add(new PlayoutSetVisible("up_" + target, true)); + } + else if (quote.Trend == SceneMarketTrend.Down && quote.NetChange != 0) + { + mutations.Add(new PlayoutSetFaceColor("bg_" + target, 1, 101, 255)); + mutations.Add(new PlayoutSetVisible("down_" + target, true)); + } + else + { + // Preserve the original global "bg" target used for flat rows. + mutations.Add(new PlayoutSetFaceColor("bg", 191, 191, 191)); + mutations.Add(new PlayoutSetVisible("flat_" + target, true)); + } + } + + return mutations; + } + + private static string NameOf(WorldMarket market) => market switch + { + WorldMarket.Korea => "한국", + WorldMarket.China => "중국", + WorldMarket.HongKong => "홍콩", + WorldMarket.Japan => "일본", + WorldMarket.Taiwan => "대만", + WorldMarket.Dow => "다우", + WorldMarket.Nasdaq => "나스닥", + WorldMarket.StandardAndPoor => "S&P", + WorldMarket.UnitedKingdom => "영국", + WorldMarket.Germany => "독일", + WorldMarket.France => "프랑스", + _ => throw new LegacySceneDataException("Unknown world market.") + }; +} + +public enum CommodityUnit +{ + DollarsPerBarrel, + DollarsPerOunce +} + +public sealed record S8086SceneData( + string Title, + CommodityUnit Unit, + double CurrentPrice, + double ChangePrice, + double Rate, + SceneMarketTrend Trend) : ILegacySceneData; + +public sealed class S8086SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s8086"; + + public IReadOnlyList Build(S8086SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); + if (!double.IsFinite(data.CurrentPrice) || !double.IsFinite(data.ChangePrice) || + !double.IsFinite(data.Rate)) + { + throw new LegacySceneDataException("Commodity quote values must be finite."); + } + + var unit = data.Unit switch + { + CommodityUnit.DollarsPerBarrel => "단위: 달러/배럴", + CommodityUnit.DollarsPerOunce => "단위: 달러/온스", + _ => throw new LegacySceneDataException("Unknown commodity unit.") + }; + var color = data.Trend switch + { + SceneMarketTrend.Up => (R: 172, G: 0, B: 0), + SceneMarketTrend.Down => (R: 14, G: 100, B: 188), + SceneMarketTrend.Flat => (R: 95, G: 95, B: 95), + _ => throw new LegacySceneDataException("Unknown commodity trend.") + }; + + return + [ + new PlayoutSetValue("unit", unit), + new PlayoutSetValue("title", title), + new PlayoutSetValue("price", data.CurrentPrice.ToString("#,##0.00", CultureInfo.InvariantCulture)), + new PlayoutSetValue("changePrice", data.ChangePrice.ToString("#,##0.00", CultureInfo.InvariantCulture)), + new PlayoutSetValue("rate", data.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)), + new PlayoutSetFaceColor("bg", color.R, color.G, color.B), + new PlayoutSetFaceColor("changePrice", color.R, color.G, color.B), + new PlayoutSetFaceColor("rate", color.R, color.G, color.B) + ]; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridMarketSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridMarketSceneDataLoaders.cs new file mode 100644 index 0000000..2b545ca --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridMarketSceneDataLoaders.cs @@ -0,0 +1,662 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed record S5023SceneLoadRequest(LegacyDomesticMarket Market); + +public sealed class S5023SceneDataLoader +{ + private static readonly string[] DailyColumns = + ["TRADING_DATE", "INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"]; + private static readonly string[] TotalColumns = + ["INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"]; + + private readonly IDataQueryExecutor _executor; + + public S5023SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5023SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var plan = GridMarketSceneQueries.S5023(request.Market); + var dailyTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5023_DAILY", + plan.Daily, + cancellationToken).ConfigureAwait(false); + var totalTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5023_MONTHLY", + plan.Monthly, + cancellationToken).ConfigureAwait(false); + + var dailyRows = LegacyTabularSceneLoaderReader.ExactRows(dailyTable, 5, DailyColumns); + var mappedRows = new DailyInvestorFlowData?[dailyRows.Count]; + DateOnly? previous = null; + for (var index = 0; index < dailyRows.Count; index++) + { + var row = dailyRows[index]; + var date = LegacyTabularSceneLoaderReader.Date(row, "TRADING_DATE"); + if (previous.HasValue && date >= previous.Value) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + previous = date; + mappedRows[index] = new DailyInvestorFlowData( + date, + new InvestorFlowAmounts( + LegacyTabularSceneLoaderReader.Int32(row, "INDIVIDUAL_AMOUNT"), + LegacyTabularSceneLoaderReader.Int32(row, "FOREIGN_AMOUNT"), + LegacyTabularSceneLoaderReader.Int32(row, "INSTITUTION_AMOUNT"))); + } + + var total = LegacyTabularSceneLoaderReader.ExactRows(totalTable, 1, TotalColumns)[0]; + var data = new S5023SceneData( + request.Market, + mappedRows, + new InvestorFlowAmounts( + LegacyTabularSceneLoaderReader.Int32(total, "INDIVIDUAL_AMOUNT"), + LegacyTabularSceneLoaderReader.Int32(total, "FOREIGN_AMOUNT"), + LegacyTabularSceneLoaderReader.Int32(total, "INSTITUTION_AMOUNT"))); + return LegacyTabularSceneLoaderReader.Preflight(new S5023SceneMutationBuilder(), data); + } +} + +public sealed record S5024SceneLoadRequest(LegacyDomesticMarket Market); + +public sealed class S5024SceneDataLoader +{ + private static readonly string[] Columns = ["PARTICIPANT_CODE", "AMOUNT"]; + private static readonly string[] ExpectedCodes = ["INDIVIDUAL", "FOREIGN", "INSTITUTION"]; + + private readonly IDataQueryExecutor _executor; + + public S5024SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5024SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5024", + GridMarketSceneQueries.S5024(request.Market), + cancellationToken).ConfigureAwait(false); + var rows = LegacyTabularSceneLoaderReader.ExactRows(table, 3, Columns); + var amounts = new InvestorTradingAmountData[rows.Count]; + + for (var index = 0; index < rows.Count; index++) + { + var code = LegacyTabularSceneLoaderReader.Text(rows[index], "PARTICIPANT_CODE"); + if (!string.Equals(code, ExpectedCodes[index], StringComparison.Ordinal)) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + var participant = code switch + { + "INDIVIDUAL" => InvestorParticipant.Individual, + "FOREIGN" => InvestorParticipant.Foreign, + "INSTITUTION" => InvestorParticipant.Institution, + _ => throw LegacyTabularSceneLoaderReader.InvalidResult() + }; + amounts[index] = new InvestorTradingAmountData( + participant, + LegacyTabularSceneLoaderReader.Double(rows[index], "AMOUNT")); + } + + var data = new S5024SceneData(request.Market, amounts); + return LegacyTabularSceneLoaderReader.Preflight(new S5024SceneMutationBuilder(), data); + } +} + +public sealed class S5085SceneDataLoader +{ + private static readonly string[] Columns = ["METRIC_CODE", "METRIC_VALUE"]; + private static readonly (string Code, ProgramTradingMetric Metric)[] Expected = + [ + ("NET_BUY", ProgramTradingMetric.NetBuy), + ("ARBITRAGE", ProgramTradingMetric.Arbitrage), + ("NON_ARBITRAGE", ProgramTradingMetric.NonArbitrage), + ("BASIS", ProgramTradingMetric.Basis), + ("KOSPI200", ProgramTradingMetric.Kospi200), + ("FUTURES", ProgramTradingMetric.Futures) + ]; + + private readonly IDataQueryExecutor _executor; + + public S5085SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5085", + GridMarketSceneQueries.S5085, + cancellationToken).ConfigureAwait(false); + var rows = LegacyTabularSceneLoaderReader.ExactRows(table, Expected.Length, Columns); + var metrics = new ProgramTradingMetricData[rows.Count]; + + for (var index = 0; index < rows.Count; index++) + { + var code = LegacyTabularSceneLoaderReader.Text(rows[index], "METRIC_CODE"); + if (!string.Equals(code, Expected[index].Code, StringComparison.Ordinal)) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + metrics[index] = new ProgramTradingMetricData( + Expected[index].Metric, + LegacyTabularSceneLoaderReader.Double(rows[index], "METRIC_VALUE")); + } + + var data = new S5085SceneData(metrics); + return LegacyTabularSceneLoaderReader.Preflight(new S5085SceneMutationBuilder(), data); + } +} + +public sealed class S6067SceneDataLoader +{ + private static readonly string[] Columns = ["MARKET_CODE", "INSTITUTION_CODE", "AMOUNT"]; + private static readonly (string MarketCode, InstitutionMarket Market)[] Markets = + [ + ("KOSPI", InstitutionMarket.Kospi), + ("KOSDAQ", InstitutionMarket.Kosdaq), + ("KOSPI200", InstitutionMarket.Other) + ]; + private static readonly (string InstitutionCode, InstitutionKind Institution)[] Institutions = + [ + ("SECURITIES", InstitutionKind.Securities), + ("INSURANCE", InstitutionKind.Insurance), + ("INVESTMENT_TRUST", InstitutionKind.InvestmentTrust), + ("BANK", InstitutionKind.Bank), + ("FUND", InstitutionKind.Fund) + ]; + + private readonly IDataQueryExecutor _executor; + + public S6067SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_6067", + GridMarketSceneQueries.S6067, + cancellationToken).ConfigureAwait(false); + var rows = LegacyTabularSceneLoaderReader.ExactRows( + table, + Markets.Length * Institutions.Length, + Columns); + var mapped = new InstitutionNetBuyData[rows.Count]; + + for (var index = 0; index < rows.Count; index++) + { + var marketIndex = index / Institutions.Length; + var institutionIndex = index % Institutions.Length; + var row = rows[index]; + var marketCode = LegacyTabularSceneLoaderReader.Text(row, "MARKET_CODE"); + var institutionCode = LegacyTabularSceneLoaderReader.Text(row, "INSTITUTION_CODE"); + if (!string.Equals(marketCode, Markets[marketIndex].MarketCode, StringComparison.Ordinal) || + !string.Equals( + institutionCode, + Institutions[institutionIndex].InstitutionCode, + StringComparison.Ordinal)) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + mapped[index] = new InstitutionNetBuyData( + Markets[marketIndex].Market, + Institutions[institutionIndex].Institution, + LegacyTabularSceneLoaderReader.Int32(row, "AMOUNT")); + } + + var data = new S6067SceneData(mapped); + return LegacyTabularSceneLoaderReader.Preflight(new S6067SceneMutationBuilder(), data); + } +} + +internal static class GridMarketSceneQueries +{ + public static (DataQuerySpec Daily, DataQuerySpec Monthly) S5023(LegacyDomesticMarket market) + { + var (currentTable, historyTable) = market switch + { + LegacyDomesticMarket.Kospi => ("t_invest", "t_invest_his"), + LegacyDomesticMarket.Kosdaq => ("t_kosdaq_invest", "t_kosdaq_invest_his"), + _ => throw LegacyTabularSceneLoaderReader.InvalidRequest() + }; + + var current = CurrentInvestorFlow(currentTable); + var history = HistoryInvestorFlow(historyTable, 5); + var monthlyHistory = HistoryInvestorFlow(historyTable, 20); + var daily = $""" + SELECT + trading_date TRADING_DATE, + individual_amount INDIVIDUAL_AMOUNT, + foreign_amount FOREIGN_AMOUNT, + institution_amount INSTITUTION_AMOUNT + FROM ( + {current} + UNION + {history} + ) + ORDER BY trading_date DESC + """; + var monthly = $""" + SELECT + SUM(individual_amount) INDIVIDUAL_AMOUNT, + SUM(foreign_amount) FOREIGN_AMOUNT, + SUM(institution_amount) INSTITUTION_AMOUNT + FROM ( + {current} + UNION + {monthlyHistory} + ) + """; + return (Oracle(daily), Oracle(monthly)); + } + + public static DataQuerySpec S5024(LegacyDomesticMarket market) + { + var table = market switch + { + LegacyDomesticMarket.Kospi => "t_invest", + LegacyDomesticMarket.Kosdaq => "t_kosdaq_invest", + _ => throw LegacyTabularSceneLoaderReader.InvalidRequest() + }; + return Oracle($""" + SELECT participant_code PARTICIPANT_CODE, amount AMOUNT + FROM ( + SELECT + DECODE( + f_invest_code, + '8000', 'INDIVIDUAL', + '9000', 'FOREIGN', + '9001', 'FOREIGN', + 'INSTITUTION') participant_code, + ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount + FROM {table} + WHERE f_part_code = '001' + AND f_invest_code IN ( + '1000', '2000', '3000', '3100', '4000', '5000', + '6000', '7000', '8000', '9000', '9001') + GROUP BY DECODE( + f_invest_code, + '8000', 'INDIVIDUAL', + '9000', 'FOREIGN', + '9001', 'FOREIGN', + 'INSTITUTION') + ) + ORDER BY DECODE(participant_code, 'INDIVIDUAL', 1, 'FOREIGN', 2, 3) + """); + } + + public static DataQuerySpec S5085 { get; } = Oracle(""" + WITH future_row AS ( + SELECT DECODE( + SIGN(a.f_curr_price - a.f_base_price), + -1, (a.f_curr_price / 100) * -1, + a.f_curr_price / 100) futures_value + FROM t_sunmul_online a + JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code + WHERE a.f_stock_seq = 1 + AND b.f_market_date = (SELECT MAX(open_day) FROM v_open_day) + AND b.f_month_gubun = '1' + FETCH FIRST 1 ROW ONLY + ) + SELECT metric_code METRIC_CODE, metric_value METRIC_VALUE + FROM ( + SELECT 1 sort_order, 'NET_BUY' metric_code, + ROUND(( + (f_bcon_buy_w_amt + f_bcon_buy_j_amt) - + (f_bcon_sell_w_amt + f_bcon_sell_j_amt) + + (f_ccon_buy_w_amt + f_ccon_buy_j_amt) - + (f_ccon_sell_w_amt + f_ccon_sell_j_amt)) / 100000000) metric_value + FROM t_pgm_tot + UNION ALL + SELECT 2, 'ARBITRAGE', + ROUND(((f_ccon_buy_w_amt + f_ccon_buy_j_amt) - + (f_ccon_sell_w_amt + f_ccon_sell_j_amt)) / 100000000) + FROM t_pgm_tot + UNION ALL + SELECT 3, 'NON_ARBITRAGE', + ROUND(((f_bcon_buy_w_amt + f_bcon_buy_j_amt) - + (f_bcon_sell_w_amt + f_bcon_sell_j_amt)) / 100000000) + FROM t_pgm_tot + UNION ALL + SELECT 4, 'BASIS', futures_value - kospi200_value + FROM ( + SELECT DECODE(f_chg_type, '-', (f_part_idx / 100) * -1, f_part_idx / 100) kospi200_value + FROM t_200_index + WHERE f_part_code = '029' + ), future_row + UNION ALL + SELECT 5, 'KOSPI200', + DECODE(f_chg_type, '-', (f_part_idx / 100) * -1, f_part_idx / 100) + FROM t_200_index + WHERE f_part_code = '029' + UNION ALL + SELECT 6, 'FUTURES', futures_value + FROM future_row + ) + ORDER BY sort_order + """); + + public static DataQuerySpec S6067 { get; } = Oracle(""" + SELECT market_code MARKET_CODE, institution_code INSTITUTION_CODE, amount AMOUNT + FROM ( + SELECT + 1 market_order, + DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5) institution_order, + 'KOSPI' market_code, + DECODE( + f_invest_code, + '1000', 'SECURITIES', + '2000', 'INSURANCE', + '3000', 'INVESTMENT_TRUST', + '4000', 'BANK', + '6000', 'FUND') institution_code, + ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount + FROM t_invest + WHERE f_part_code = '001' + AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000') + GROUP BY f_invest_code + UNION ALL + SELECT + 2, + DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5), + 'KOSDAQ', + DECODE( + f_invest_code, + '1000', 'SECURITIES', + '2000', 'INSURANCE', + '3000', 'INVESTMENT_TRUST', + '4000', 'BANK', + '6000', 'FUND'), + ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) + FROM t_kosdaq_invest + WHERE f_part_code = '001' + AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000') + GROUP BY f_invest_code + UNION ALL + SELECT + 3, + DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5), + 'KOSPI200', + DECODE( + f_invest_code, + '1000', 'SECURITIES', + '2000', 'INSURANCE', + '3000', 'INVESTMENT_TRUST', + '4000', 'BANK', + '6000', 'FUND'), + ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) + FROM t_invest + WHERE f_part_code = '029' + AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000') + GROUP BY f_invest_code + ) + ORDER BY market_order, institution_order + """); + + private static string CurrentInvestorFlow(string table) => $""" + SELECT + TO_CHAR((SELECT MAX(open_day) FROM v_open_day)) trading_date, + individual_amount, + foreign_amount, + institution_amount + FROM ( + SELECT + SUM(CASE WHEN f_invest_code = '8000' THEN amount ELSE 0 END) individual_amount, + SUM(CASE WHEN f_invest_code IN ('9000', '9001') THEN amount ELSE 0 END) foreign_amount, + SUM(CASE WHEN f_invest_code NOT IN ('8000', '9000', '9001') THEN amount ELSE 0 END) institution_amount + FROM ( + SELECT + f_invest_code, + ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount + FROM {table} + WHERE f_part_code = '001' + AND f_invest_code IN ( + '1000', '2000', '3000', '3100', '4000', '5000', + '6000', '7000', '8000', '9000', '9001') + GROUP BY f_invest_code + ) + ) + """; + + private static string HistoryInvestorFlow(string table, int exclusiveRowNumber) => $""" + SELECT + SUBSTR(f_data_time, 1, 8) trading_date, + SUM(CASE WHEN f_invest_code = '8000' THEN amount ELSE 0 END) individual_amount, + SUM(CASE WHEN f_invest_code IN ('9000', '9001') THEN amount ELSE 0 END) foreign_amount, + SUM(CASE WHEN f_invest_code NOT IN ('8000', '9000', '9001') THEN amount ELSE 0 END) institution_amount + FROM ( + SELECT + f_data_time, + f_invest_code, + ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount + FROM {table} + WHERE f_part_code = '001' + AND f_invest_code IN ( + '1000', '2000', '3000', '3100', '4000', '5000', + '6000', '7000', '8000', '9000', '9001') + AND f_data_time IN ( + SELECT data_time || '1535' + FROM ( + SELECT open_day data_time FROM v_open_day + MINUS + SELECT SUBSTR(f_data_time, 1, 8) data_time + FROM t_index + WHERE f_part_code = '001' + ORDER BY data_time DESC + ) + WHERE ROWNUM < {exclusiveRowNumber}) + GROUP BY f_data_time, f_invest_code + ) + GROUP BY SUBSTR(f_data_time, 1, 8) + """; + + private static DataQuerySpec Oracle(string sql) => + LegacyTabularSceneLoaderReader.Spec(DataSourceKind.Oracle, sql); +} + +internal static class LegacyTabularSceneLoaderReader +{ + private const int MaximumTextLength = 256; + private const int MaximumSelectorLength = 128; + + public static DataQuerySpec Spec( + DataSourceKind source, + string sql, + params DataQueryParameter[] parameters) + { + var spec = new DataQuerySpec(sql, parameters); + spec.ValidateFor(source); + return spec; + } + + public static IReadOnlyList ExactRows( + DataTable? table, + int rowCount, + IReadOnlyList columns) + { + if (table is null || table.Rows.Count != rowCount || table.Columns.Count != columns.Count) + { + throw InvalidResult(); + } + + for (var index = 0; index < columns.Count; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + columns[index], + StringComparison.OrdinalIgnoreCase)) + { + throw InvalidResult(); + } + } + + return table.Rows.Cast().ToArray(); + } + + public static string Text(DataRow row, string column, bool allowEmpty = false) + { + var text = Convert.ToString(Required(row, column), CultureInfo.InvariantCulture) ?? string.Empty; + if (text.Length > MaximumTextLength) + { + throw InvalidResult(); + } + + try + { + return LegacySceneBuilderGuard.Text(text, column, allowEmpty); + } + catch (LegacySceneDataException) + { + throw InvalidResult(); + } + } + + public static string Selector(string? value) + { + try + { + var selector = LegacySceneBuilderGuard.Text(value, "selector"); + return selector.Length <= MaximumSelectorLength + ? selector + : throw InvalidRequest(); + } + catch (LegacySceneDataException) + { + throw InvalidRequest(); + } + } + + public static string StockCode(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 32 || + value.Any(character => !char.IsAsciiLetterOrDigit(character))) + { + throw InvalidRequest(); + } + + return value; + } + + public static DateOnly Date(DataRow row, string column) + { + var text = Text(row, column); + if (text.Length != 8 || text.Any(character => !char.IsAsciiDigit(character)) || + !DateOnly.TryParseExact( + text, + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value)) + { + throw InvalidResult(); + } + + return value; + } + + public static int Int32(DataRow row, string column) + { + var value = Decimal(row, column); + if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue) + { + throw InvalidResult(); + } + + return decimal.ToInt32(value); + } + + public static long Int64(DataRow row, string column) + { + var value = Decimal(row, column); + if (decimal.Truncate(value) != value || value is < long.MinValue or > long.MaxValue) + { + throw InvalidResult(); + } + + return decimal.ToInt64(value); + } + + public static double Double(DataRow row, string column) + { + try + { + var value = Convert.ToDouble(Required(row, column), CultureInfo.InvariantCulture); + return double.IsFinite(value) ? value : throw InvalidResult(); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + public static ScenePriceDirection Direction(DataRow row, string column) => + Text(row, column) switch + { + "1" => ScenePriceDirection.LimitUp, + "2" => ScenePriceDirection.Up, + "3" => ScenePriceDirection.Flat, + "4" => ScenePriceDirection.LimitDown, + "5" => ScenePriceDirection.Down, + _ => throw InvalidResult() + }; + + public static TData Preflight( + ILegacySceneMutationBuilder builder, + TData data) + where TData : ILegacySceneData + { + _ = builder.Build(data); + return data; + } + + public static LegacySceneDataException InvalidRequest() => + new("The scene data request is invalid."); + + public static LegacySceneDataException InvalidResult() => + new("The scene query returned an invalid result."); + + private static decimal Decimal(DataRow row, string column) + { + try + { + return Convert.ToDecimal(Required(row, column), CultureInfo.InvariantCulture); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidResult(); + } + } + + private static object Required(DataRow row, string column) => + row[column] is { } value && value is not DBNull + ? value + : throw InvalidResult(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridSceneBuilder5082.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridSceneBuilder5082.cs new file mode 100644 index 0000000..bd6b317 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridSceneBuilder5082.cs @@ -0,0 +1,169 @@ +#nullable enable + +using System.Globalization; +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed record InvestorTradingValues( + int Individual, + int Foreign, + int Institution); + +public sealed record S5082SceneData( + InvestorTradingValues Kospi, + InvestorTradingValues Kosdaq, + InvestorTradingValues Kospi200) : ILegacySceneData; + +public sealed class S5082SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5082"; + + public IReadOnlyList Build(S5082SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + InvestorTradingValues[] rows = + [ + LegacySceneBuilderGuard.NotNull(data.Kospi, nameof(data.Kospi)), + LegacySceneBuilderGuard.NotNull(data.Kosdaq, nameof(data.Kosdaq)), + LegacySceneBuilderGuard.NotNull(data.Kospi200, nameof(data.Kospi200)) + ]; + var mutations = new List(54); + for (var rowIndex = 0; rowIndex < rows.Length; rowIndex++) + { + int[] values = + [ + rows[rowIndex].Individual, + rows[rowIndex].Foreign, + rows[rowIndex].Institution + ]; + for (var columnIndex = 0; columnIndex < values.Length; columnIndex++) + { + var text = values[columnIndex].ToString("#,##0", CultureInfo.InvariantCulture); + var prefix = $"row{rowIndex + 1}_value{columnIndex + 1}_"; + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue(prefix + variant, text)); + } + + var visibleVariant = values[columnIndex] > 0 + ? 1 + : values[columnIndex] < 0 + ? 3 + : 2; + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + prefix + variant, + variant == visibleVariant)); + } + } + } + + return mutations; + } +} + +public sealed class S5082SceneDataLoader +{ + private const string Query = """ + SELECT name, + SUM(DECODE(invest, '개인', org_amt, 0)) individual_value, + SUM(DECODE(invest, '외국인', org_amt, 0)) foreign_value, + SUM(DECODE(invest, '기관', org_amt, 0)) institution_value + FROM ( + SELECT 1 s, '코스피' name, + DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') invest, + ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt + FROM t_invest + WHERE f_part_code = '001' + AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001') + GROUP BY DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') + UNION + SELECT 2 s, '코스닥' name, + DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') invest, + ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt + FROM t_kosdaq_invest + WHERE f_part_code = '001' + AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001') + GROUP BY DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') + UNION + SELECT 3 s, '코스피200' name, + DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') invest, + ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt + FROM t_invest + WHERE f_part_code = '029' + AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001') + GROUP BY DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') + ) + GROUP BY name, s + ORDER BY s + """; + + private readonly IDataQueryExecutor _executor; + + public S5082SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + CancellationToken cancellationToken = default) + { + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5082", + new DataQuerySpec(Query), + cancellationToken).ConfigureAwait(false); + var rows = MapRows(table); + return new S5082SceneData(rows["코스피"], rows["코스닥"], rows["코스피200"]); + } + + private static IReadOnlyDictionary MapRows(DataTable? table) + { + string[] required = ["NAME", "INDIVIDUAL_VALUE", "FOREIGN_VALUE", "INSTITUTION_VALUE"]; + if (table is null || table.Rows.Count != 3 || required.Any(name => !table.Columns.Contains(name))) + { + throw new LegacySceneDataException("Scene 5082 returned an unexpected result shape."); + } + + var mapped = new Dictionary(StringComparer.Ordinal); + foreach (DataRow row in table.Rows) + { + var name = Convert.ToString(row["NAME"], CultureInfo.InvariantCulture) ?? string.Empty; + if (name is not ("코스피" or "코스닥" or "코스피200") || + !mapped.TryAdd(name, new InvestorTradingValues( + RequiredInt(row, "INDIVIDUAL_VALUE"), + RequiredInt(row, "FOREIGN_VALUE"), + RequiredInt(row, "INSTITUTION_VALUE")))) + { + throw new LegacySceneDataException("Scene 5082 returned an unknown or duplicate market row."); + } + } + + if (mapped.Count != 3) + { + throw new LegacySceneDataException("Scene 5082 did not return every required market row."); + } + + return mapped; + } + + private static int RequiredInt(DataRow row, string column) + { + if (row[column] is null or DBNull) + { + throw new LegacySceneDataException("Scene 5082 returned a required null value."); + } + + try + { + return checked(Convert.ToInt32(row[column], CultureInfo.InvariantCulture)); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw new LegacySceneDataException("Scene 5082 returned an invalid numeric value."); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyGridMarketSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyGridMarketSceneRequestResolver.cs new file mode 100644 index 0000000..ba6f6e9 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyGridMarketSceneRequestResolver.cs @@ -0,0 +1,406 @@ +#nullable enable + +using System.Data; +using System.IO; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Closed runtime plans for the legacy grid, trader, quote and trusted-manual cuts. +/// They contain typed loader requests only; Web input can never supply SQL, mutations, +/// COM operations or a manual-data path. +/// +public abstract record LegacyGridMarketSceneLoadRequest(string BuilderKey); + +public sealed record LegacyS5023SceneLoadRequest(S5023SceneLoadRequest Request) + : LegacyGridMarketSceneLoadRequest("s5023"); + +public sealed record LegacyS5024SceneLoadRequest(S5024SceneLoadRequest Request) + : LegacyGridMarketSceneLoadRequest("s5024"); + +public sealed record LegacyS5025SceneLoadRequest(S5025ManualSceneLoadRequest Request) + : LegacyGridMarketSceneLoadRequest("s5025"); + +public sealed record LegacyS5037SceneLoadRequest(S5037SceneLoadRequest Request) + : LegacyGridMarketSceneLoadRequest("s5037"); + +public sealed record LegacyS5085SceneLoadRequest() + : LegacyGridMarketSceneLoadRequest("s5085"); + +public sealed record LegacyS6067SceneLoadRequest() + : LegacyGridMarketSceneLoadRequest("s6067"); + +public sealed record LegacyS8003SceneLoadRequest(S8003SceneLoadRequest Request) + : LegacyGridMarketSceneLoadRequest("s8003"); + +/// +/// Mirrors the original MainForm mapping of playlist code/jongmok/forCutInfo/sub to +/// , , +/// and +/// . Stock-master resolution remains read-only +/// and parameterized. The optional hidden is +/// only an integrity check and is never used as SQL or as a path. +/// +public sealed class LegacyGridMarketSceneRequestResolver +{ + private static readonly string[] StockCodeColumns = ["STOCK_CODE"]; + + private readonly IDataQueryExecutor _executor; + private readonly IS5025TrustedManualDataSource? _trustedManualSource; + + public LegacyGridMarketSceneRequestResolver( + IDataQueryExecutor executor, + IS5025TrustedManualDataSource? trustedManualSource = null) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _trustedManualSource = trustedManualSource; + } + + public async Task ResolveAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + return entry.CutCode switch + { + "5023" => new LegacyS5023SceneLoadRequest(CreateS5023(entry)), + "5024" => new LegacyS5024SceneLoadRequest(CreateS5024(entry)), + "5025" => new LegacyS5025SceneLoadRequest(CreateS5025(entry)), + "5037" => new LegacyS5037SceneLoadRequest(CreateS5037(entry)), + "5085" => CreateS5085(entry), + "6067" => CreateS6067(entry), + "8003" => new LegacyS8003SceneLoadRequest( + await ResolveS8003Async(entry, cancellationToken).ConfigureAwait(false)), + _ => throw new LegacySceneDataException( + "The selected cut is not supported by the grid-market resolver.") + }; + } + + /// + /// Required runtime path for s5025. The trusted source is supplied by native + /// composition/configuration, never by LegacySceneSelection. Reading and validating + /// all five rows completes before any cue reaches DB or COM execution. + /// + public async Task LoadTrustedS5025Async( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + var request = CreateS5025(entry); + var source = _trustedManualSource ?? throw new LegacySceneDataException( + "Trusted manual scene data is not configured."); + try + { + return await new S5025SceneDataLoader(source) + .LoadAsync(request, cancellationToken) + .ConfigureAwait(false); + } + catch (LegacySceneDataException) + { + throw; + } + catch (Exception exception) when (exception is IOException or + UnauthorizedAccessException or + InvalidOperationException) + { + throw new LegacySceneDataException("Trusted manual scene data is unavailable."); + } + } + + private static S5023SceneLoadRequest CreateS5023(LegacyPlaylistEntry entry) + { + var selection = RequireSelection(entry); + RequireIndexSubject(selection.Subject); + RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table); + RequireNoDataCode(selection.DataCode); + var market = ParseTradingTrendMarket(selection.GraphicType); + RequireSameMarket(selection.GroupCode, market); + return new S5023SceneLoadRequest(market); + } + + private static S5024SceneLoadRequest CreateS5024(LegacyPlaylistEntry entry) + { + var selection = RequireSelection(entry); + RequireIndexSubject(selection.Subject); + RequireSubtype(selection.Subtype, SceneGraphicSubtype.Bar); + RequireNoDataCode(selection.DataCode); + var market = ParseTradingTrendMarket(selection.GraphicType); + RequireSameMarket(selection.GroupCode, market); + return new S5024SceneLoadRequest(market); + } + + private S5025ManualSceneLoadRequest CreateS5025(LegacyPlaylistEntry entry) + { + var selection = RequireSelection(entry); + RequireIndexSubject(selection.Subject); + RequireManualNetSellGraphic(selection.GraphicType); + RequireManualNetSellGraphic(selection.Subtype); + RequireNoDataCode(selection.DataCode); + if (_trustedManualSource is null) + { + throw new LegacySceneDataException("Trusted manual scene data is not configured."); + } + + var audience = RequireText(selection.GroupCode) switch + { + "개인 순매도 상위(수동)" or "INDIVIDUAL" => S5025ManualAudience.Individual, + "외국인 순매도 상위(수동)" or "FOREIGN" => S5025ManualAudience.Foreign, + "기관 순매도 상위(수동)" or "INSTITUTION" => S5025ManualAudience.Institution, + _ => throw InvalidSelection() + }; + return new S5025ManualSceneLoadRequest(audience); + } + + private static S5037SceneLoadRequest CreateS5037(LegacyPlaylistEntry entry) + { + var selection = RequireSelection(entry); + RequireGraphic(selection.GraphicType, "거래원", "TRADER"); + RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table); + RequireOptionalStockCode(selection.DataCode); + return new S5037SceneLoadRequest( + ParseMarket(selection.GroupCode), + RequireSelector(selection.Subject)); + } + + private static LegacyS5085SceneLoadRequest CreateS5085(LegacyPlaylistEntry entry) + { + var selection = RequireSelection(entry); + RequireAllGroup(selection.GroupCode); + RequireIndexSubject(selection.Subject); + RequireGraphic(selection.GraphicType, "프로그램 매매", "PROGRAM_TRADING"); + RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table); + RequireNoDataCode(selection.DataCode); + return new LegacyS5085SceneLoadRequest(); + } + + private static LegacyS6067SceneLoadRequest CreateS6067(LegacyPlaylistEntry entry) + { + var selection = RequireSelection(entry); + RequireAllGroup(selection.GroupCode); + RequireIndexSubject(selection.Subject); + RequireGraphic(selection.GraphicType, "기관 순매수 현황", "INSTITUTION_NET_BUY"); + RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table); + RequireNoDataCode(selection.DataCode); + return new LegacyS6067SceneLoadRequest(); + } + + private async Task ResolveS8003Async( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken) + { + var selection = RequireSelection(entry); + var expectedMarket = ParseMarket(selection.GroupCode); + var stockName = RequireSelector(selection.Subject); + RequireGraphic(selection.GraphicType, "호가창", "ORDER_BOOK"); + RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table); + var expectedCode = RequireOptionalStockCode(selection.DataCode); + + var kospi = await ResolveStockAsync( + LegacyDomesticMarket.Kospi, + stockName, + cancellationToken).ConfigureAwait(false); + var kosdaq = await ResolveStockAsync( + LegacyDomesticMarket.Kosdaq, + stockName, + cancellationToken).ConfigureAwait(false); + + // MainForm iterated KOSPI first and KOSDAQ second, so the latter won when + // an identical display name existed in both cached stock-master tables. + var resolved = kosdaq ?? kospi ?? throw new LegacySceneDataException( + "The order-book stock selection was not found."); + if (resolved.Market != expectedMarket || + (expectedCode.Length > 0 && + !string.Equals(expectedCode, resolved.StockCode, StringComparison.Ordinal))) + { + throw InvalidSelection(); + } + + return new S8003SceneLoadRequest(resolved.Market, resolved.StockCode); + } + + private async Task ResolveStockAsync( + LegacyDomesticMarket market, + string stockName, + CancellationToken cancellationToken) + { + var (tableName, table) = market switch + { + LegacyDomesticMarket.Kospi => ("SCENE_RESOLVE_8003_KOSPI", "T_STOCK"), + LegacyDomesticMarket.Kosdaq => ("SCENE_RESOLVE_8003_KOSDAQ", "T_KOSDAQ_STOCK"), + _ => throw InvalidSelection() + }; + var spec = LegacyTabularSceneLoaderReader.Spec( + DataSourceKind.Oracle, + $""" + SELECT f_stock_code STOCK_CODE + FROM {table} + WHERE f_mkt_halt = 'N' + AND f_stock_wanname = :stockName + """, + new DataQueryParameter("stockName", stockName, DbType.String)); + var result = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + tableName, + spec, + cancellationToken).ConfigureAwait(false); + if (result is null || result.Columns.Count != StockCodeColumns.Length || + !string.Equals( + result.Columns[0].ColumnName, + StockCodeColumns[0], + StringComparison.OrdinalIgnoreCase) || + result.Rows.Count > 1) + { + throw new LegacySceneDataException( + "An order-book stock-master lookup returned an invalid result."); + } + + if (result.Rows.Count == 0) + { + return null; + } + + if (result.Rows[0][0] is null or DBNull) + { + throw new LegacySceneDataException( + "An order-book stock-master lookup returned an invalid result."); + } + + var code = RequireStockCode( + Convert.ToString(result.Rows[0][0], System.Globalization.CultureInfo.InvariantCulture)); + return new ResolvedStock(market, code); + } + + private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) => + entry.Selection ?? throw new LegacySceneDataException( + "The selected cut requires native lookup information."); + + private static LegacyDomesticMarket ParseTradingTrendMarket(string value) => + RequireText(value) switch + { + "코스피 매매동향" or "KOSPI_TRADING_TREND" => LegacyDomesticMarket.Kospi, + "코스닥 매매동향" or "KOSDAQ_TRADING_TREND" => LegacyDomesticMarket.Kosdaq, + _ => throw InvalidSelection() + }; + + private static LegacyDomesticMarket ParseMarket(string value) => + RequireText(value) switch + { + "코스피" or "KOSPI" => LegacyDomesticMarket.Kospi, + "코스닥" or "KOSDAQ" => LegacyDomesticMarket.Kosdaq, + _ => throw InvalidSelection() + }; + + private static void RequireSameMarket(string value, LegacyDomesticMarket expected) + { + if (ParseMarket(value) != expected) + { + throw InvalidSelection(); + } + } + + private static void RequireIndexSubject(string value) + { + if (RequireText(value) is not ("지수" or "INDEX")) + { + throw InvalidSelection(); + } + } + + private static void RequireAllGroup(string value) + { + if (RequireText(value) is not ("전체" or "ALL")) + { + throw InvalidSelection(); + } + } + + private static void RequireSubtype(string value, SceneGraphicSubtype subtype) + { + var text = RequireText(value); + var valid = subtype switch + { + SceneGraphicSubtype.Table => text is "표그래프" or "TABLE_GRAPH", + SceneGraphicSubtype.Bar => text is "막대그래프" or "BAR_GRAPH", + _ => false + }; + if (!valid) + { + throw InvalidSelection(); + } + } + + private static void RequireManualNetSellGraphic(string value) + { + if (RequireText(value) is not ("순매도 상위" or "MANUAL_NET_SELL")) + { + throw InvalidSelection(); + } + } + + private static void RequireGraphic(string value, string korean, string stableCode) + { + var text = RequireText(value); + if (!string.Equals(text, korean, StringComparison.Ordinal) && + !string.Equals(text, stableCode, StringComparison.Ordinal)) + { + throw InvalidSelection(); + } + } + + private static string RequireSelector(string value) + { + var text = RequireText(value); + if (text.Length > 128) + { + throw InvalidSelection(); + } + + return text; + } + + private static string RequireText(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 256 || value.Any(char.IsControl)) + { + throw InvalidSelection(); + } + + return value.Trim(); + } + + private static void RequireNoDataCode(string? value) + { + if (value is null || value.Length != 0) + { + throw InvalidSelection(); + } + } + + private static string RequireOptionalStockCode(string? value) => + value is { Length: 0 } ? string.Empty : RequireStockCode(value); + + private static string RequireStockCode(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 32 || + value.Any(character => !char.IsAsciiLetterOrDigit(character))) + { + throw InvalidSelection(); + } + + return value; + } + + private static LegacySceneDataException InvalidSelection() => + new("The selected scene lookup fields are invalid."); + + private sealed record ResolvedStock( + LegacyDomesticMarket Market, + string StockCode); + + private enum SceneGraphicSubtype + { + Table, + Bar + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs new file mode 100644 index 0000000..bb7067d --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs @@ -0,0 +1,639 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Closed result for the legacy aliases (8018/8032/5032) that MainForm routed +/// to one of two different scene builders after inspecting the playlist fields. +/// +public abstract record LegacyTwoColumnSceneLoadRequest(string BuilderKey); + +public sealed record LegacyS5032LoadRequest(S5032SceneLoadRequest Request) + : LegacyTwoColumnSceneLoadRequest("s5032"); + +public sealed record LegacyS8018LoadRequest(S8018SceneLoadRequest Request) + : LegacyTwoColumnSceneLoadRequest("s8018"); + +/// +/// Converts the five legacy playlist columns into closed, scene-specific load requests. +/// The resolver mirrors MainForm's conditional s5001/s5032/s8018/s8001 dispatch and uses +/// parameterized, read-only stock-master lookups where MainForm used its cached stock tables. +/// +public sealed class LegacyParameterizedSceneRequestResolver +{ + private readonly IDataQueryExecutor _executor; + private readonly TimeProvider _timeProvider; + + public LegacyParameterizedSceneRequestResolver( + IDataQueryExecutor executor, + TimeProvider? timeProvider = null) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public S5001SceneLoadRequest CreateS5001Request(LegacyPlaylistEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + if (entry.CutCode is not ("5001" or "N5001")) + { + throw new LegacySceneDataException("The selected cut is not an s5001 alias."); + } + + var selection = RequireSelection(entry); + var subject = RequireSelector(selection.Subject, "subject"); + + if (subject == "BDI 지수" || subject == "BALTIC_DRY_INDEX") + { + return new S5001BalticDryIndexLoadRequest(); + } + + if (TryParseCommodity(subject, out var commodity)) + { + return new S5001CommodityLoadRequest(commodity); + } + + if (entry.CutCode == "N5001") + { + return CreateS5001DomesticStock(selection, subject); + } + + if (subject is "지수" or "INDEX") + { + return new S5001DomesticIndexLoadRequest( + ParseDomesticIndex(selection.GroupCode), + IsExpectedIndex(selection.Subtype) + ? S5001IndexMode.Expected + : S5001IndexMode.Current); + } + + if (selection.GroupCode is "해외지수" or "FOREIGN_INDEX") + { + return new S5001ForeignIndexLoadRequest(ParseForeignIndex(subject)); + } + + if (selection.GroupCode is "환율" or "EXCHANGE_RATE") + { + return new S5001ExchangeRateLoadRequest(ParseExchangeRate(subject)); + } + + if (selection.GroupCode is "해외업종" or "FOREIGN_INDUSTRY") + { + return new S5001ForeignIndustryLoadRequest(subject); + } + + if (selection.GroupCode is "해외종목" or "FOREIGN_STOCK") + { + return new S5001ForeignStockLoadRequest(subject); + } + + if (selection.GroupCode.Contains("업종", StringComparison.Ordinal) || + selection.GroupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal)) + { + return new S5001DomesticIndustryLoadRequest( + ParseDomesticMarket(selection.GroupCode, allowNxt: true), + subject); + } + + return CreateS5001DomesticStock(selection, subject); + } + + public async Task ResolveTwoColumnRequestAsync( + LegacyPlaylistEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + if (entry.CutCode is not ("8018" or "8032" or "5032")) + { + throw new LegacySceneDataException("The selected cut is not a two-column alias."); + } + + var selection = RequireSelection(entry); + var subject = RequireSelector(selection.Subject, "subject"); + + // MainForm selects s8018 idx=3 before considering the futures condition. + if (selection.GroupCode.Contains("업종", StringComparison.Ordinal) || + selection.GroupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal)) + { + var industries = SplitPair(subject, '-'); + var market = ParseDomesticMarket(selection.GroupCode, allowNxt: false); + return new LegacyS8018LoadRequest( + new S8018DomesticIndustryPairLoadRequest( + market, + industries[0], + industries[1])); + } + + // MainForm selects the special s5032 builder whenever the combined subject + // contains the Korean futures label. s5032 then normalizes futures to side two. + if (subject.Contains("선물", StringComparison.Ordinal) || + subject.Contains("FUTURES", StringComparison.Ordinal)) + { + var values = SplitPair(subject, ','); + var first = ParseMarketTarget(values[0]); + var second = ParseMarketTarget(values[1]); + var firstIsFutures = first == LegacyMarketQuoteTarget.Futures; + var secondIsFutures = second == LegacyMarketQuoteTarget.Futures; + if (firstIsFutures == secondIsFutures) + { + throw new LegacySceneDataException( + "The s5032 selection must contain exactly one futures item."); + } + + return new LegacyS5032LoadRequest( + new S5032IndexAndFuturesLoadRequest(firstIsFutures ? second : first)); + } + + var pair = SplitPair(subject, ','); + var expected = IsExpectedPair(selection.Subtype); + var firstIsTarget = TryParseMarketTarget(pair[0], out var firstTarget); + var secondIsTarget = TryParseMarketTarget(pair[1], out var secondTarget); + + if (firstIsTarget && secondIsTarget) + { + return new LegacyS8018LoadRequest( + new S8018MarketPairLoadRequest( + expected ? S8018MarketDataMode.Expected : S8018MarketDataMode.Current, + firstTarget, + secondTarget)); + } + + if (firstIsTarget != secondIsTarget) + { + var stockIndex = firstIsTarget ? 1 : 0; + var stock = await ResolveCurrentStockAsync( + pair[stockIndex], + allowWorld: false, + cancellationToken).ConfigureAwait(false); + var domesticMarket = ToKrxMarket(stock.Market); + return new LegacyS8018LoadRequest( + new S8018MixedMarketAndStockLoadRequest( + stockIndex == 0 ? S8018PairSide.First : S8018PairSide.Second, + firstIsTarget ? firstTarget : secondTarget, + domesticMarket, + stock.StockName, + expected ? S8018MarketDataMode.Expected : S8018MarketDataMode.Current)); + } + + if (expected) + { + var first = await ResolveDomesticStockAsync(pair[0], cancellationToken) + .ConfigureAwait(false); + var second = await ResolveDomesticStockAsync(pair[1], cancellationToken) + .ConfigureAwait(false); + return new LegacyS8018LoadRequest( + new S8018ExpectedStockPairLoadRequest( + first.Market, + first.StockName, + second.Market, + second.StockName)); + } + + var currentFirst = await ResolveCurrentStockAsync( + pair[0], + allowWorld: true, + cancellationToken).ConfigureAwait(false); + var currentSecond = await ResolveCurrentStockAsync( + pair[1], + allowWorld: true, + cancellationToken).ConfigureAwait(false); + return new LegacyS8018LoadRequest( + new S8018CurrentStockPairLoadRequest( + CurrentSession(), + currentFirst, + currentSecond)); + } + + public S8001SceneLoadRequest CreateS8001Request(LegacyPlaylistEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + if (entry.CutCode is not ("8001" or "8002")) + { + throw new LegacySceneDataException("The selected cut is not an s8001 alias."); + } + + var subject = RequireSelector(RequireSelection(entry).Subject, "subject"); + return subject switch + { + "코스피" or "KOSPI" => new S8001SceneLoadRequest(S8001Market.Kospi), + "코스닥" or "KOSDAQ" => new S8001SceneLoadRequest(S8001Market.Kosdaq), + _ => throw new LegacySceneDataException("The s8001 market selection is invalid.") + }; + } + + private S5001DomesticStockLoadRequest CreateS5001DomesticStock( + LegacySceneSelection selection, + string subject) + { + var market = ParseDomesticMarket(selection.GroupCode, allowNxt: true); + var mode = selection.Subtype switch + { + "예상체결가" or "EXPECTED" or "EXPECTED_OPENING" => + S5001DomesticStockMode.ExpectedOpening, + "시간외단일가" or "AFTER_HOURS" or "AFTER_HOURS_SINGLE_PRICE" => + S5001DomesticStockMode.AfterHoursSinglePrice, + _ => S5001DomesticStockMode.Current + }; + var halted = mode == S5001DomesticStockMode.Current && + (selection.Subtype.Contains("거래정지", StringComparison.Ordinal) || + selection.Subtype == "HALTED"); + var nxt = market is LegacyDomesticEquityMarket.NxtKospi or + LegacyDomesticEquityMarket.NxtKosdaq; + var badge = !nxt + ? S5001NxtBadge.None + : _timeProvider.GetLocalNow().Hour <= 12 + ? S5001NxtBadge.PreMarket + : S5001NxtBadge.AfterMarket; + return new S5001DomesticStockLoadRequest( + market, + mode, + badge, + halted ? S5001StockTradingStatus.Halted : S5001StockTradingStatus.Regular, + subject); + } + + private async Task ResolveCurrentStockAsync( + string rawName, + bool allowWorld, + CancellationToken cancellationToken) + { + var name = RequireSelector(rawName, "stock"); + var isNxt = name.Contains("(NXT)", StringComparison.Ordinal); + var selector = isNxt + ? RequireSelector(name.Replace("(NXT)", string.Empty, StringComparison.Ordinal), "stock") + : name; + + if (isNxt) + { + S8018StockMarket? market = null; + if (await StockExistsAsync(StockMasterKind.NxtKospi, selector, cancellationToken) + .ConfigureAwait(false)) + { + market = S8018StockMarket.NxtKospi; + } + + // Preserve MainForm's later-table precedence when names overlap. + if (await StockExistsAsync(StockMasterKind.NxtKosdaq, selector, cancellationToken) + .ConfigureAwait(false)) + { + market = S8018StockMarket.NxtKosdaq; + } + + return market.HasValue + ? new S8018CurrentStockSelection(market.Value, selector) + : throw new LegacySceneDataException("The NXT stock selection was not found."); + } + + S8018StockMarket? domestic = null; + if (await StockExistsAsync(StockMasterKind.Kospi, selector, cancellationToken) + .ConfigureAwait(false)) + { + domestic = S8018StockMarket.Kospi; + } + + if (await StockExistsAsync(StockMasterKind.Kosdaq, selector, cancellationToken) + .ConfigureAwait(false)) + { + domestic = S8018StockMarket.Kosdaq; + } + + if (domestic.HasValue) + { + return new S8018CurrentStockSelection(domestic.Value, selector); + } + + if (allowWorld && + await StockExistsAsync(StockMasterKind.World, selector, cancellationToken) + .ConfigureAwait(false)) + { + return new S8018CurrentStockSelection(S8018StockMarket.World, selector); + } + + throw new LegacySceneDataException("The stock selection was not found."); + } + + private async Task ResolveDomesticStockAsync( + string rawName, + CancellationToken cancellationToken) + { + if (rawName.Contains("(NXT)", StringComparison.Ordinal)) + { + throw new LegacySceneDataException( + "Expected stock data is unavailable for NXT selections."); + } + + var current = await ResolveCurrentStockAsync( + rawName, + allowWorld: false, + cancellationToken).ConfigureAwait(false); + return new DomesticStockSelection(ToKrxMarket(current.Market), current.StockName); + } + + private async Task StockExistsAsync( + StockMasterKind kind, + string stockName, + CancellationToken cancellationToken) + { + var query = StockMasterQuery(kind, stockName); + var table = await _executor.ExecuteAsync( + query.Source, + query.TableName, + query.Spec, + cancellationToken).ConfigureAwait(false); + if (table is null || table.Columns.Count != 1 || table.Rows.Count != 1 || + !string.Equals(table.Columns[0].ColumnName, "MATCH_COUNT", StringComparison.OrdinalIgnoreCase) || + table.Rows[0][0] is null or DBNull) + { + throw new LegacySceneDataException("A stock-master lookup returned an invalid schema."); + } + + try + { + var count = Convert.ToInt32(table.Rows[0][0], System.Globalization.CultureInfo.InvariantCulture); + return count switch + { + 0 => false, + 1 => true, + _ => throw new LegacySceneDataException( + "A stock-master lookup returned a non-unique selection.") + }; + } + catch (LegacySceneDataException) + { + throw; + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + _ = exception; + throw new LegacySceneDataException( + "A stock-master lookup returned an invalid count."); + } + } + + private static ParameterizedSceneQuery StockMasterQuery( + StockMasterKind kind, + string stockName) + { + var parameter = new DataQueryParameter("stockName", stockName, DbType.String); + return kind switch + { + StockMasterKind.Kospi => new ParameterizedSceneQuery( + DataSourceKind.Oracle, + "SCENE_RESOLVE_KOSPI_STOCK", + new DataQuerySpec( + "SELECT COUNT(*) MATCH_COUNT FROM T_STOCK " + + "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName", + [parameter])), + StockMasterKind.Kosdaq => new ParameterizedSceneQuery( + DataSourceKind.Oracle, + "SCENE_RESOLVE_KOSDAQ_STOCK", + new DataQuerySpec( + "SELECT COUNT(*) MATCH_COUNT FROM T_KOSDAQ_STOCK " + + "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName", + [parameter])), + StockMasterKind.NxtKospi => new ParameterizedSceneQuery( + DataSourceKind.MariaDb, + "SCENE_RESOLVE_NXT_KOSPI_STOCK", + new DataQuerySpec( + "SELECT COUNT(*) MATCH_COUNT FROM N_STOCK " + + "WHERE F_STOP_GUBUN = 'N' AND F_STOCK_NAME = @stockName", + [parameter])), + StockMasterKind.NxtKosdaq => new ParameterizedSceneQuery( + DataSourceKind.MariaDb, + "SCENE_RESOLVE_NXT_KOSDAQ_STOCK", + new DataQuerySpec( + "SELECT COUNT(*) MATCH_COUNT FROM N_KOSDAQ_STOCK " + + "WHERE F_STOP_GUBUN = 'N' AND F_STOCK_NAME = @stockName", + [parameter])), + StockMasterKind.World => new ParameterizedSceneQuery( + DataSourceKind.Oracle, + "SCENE_RESOLVE_WORLD_STOCK", + new DataQuerySpec( + "SELECT COUNT(*) MATCH_COUNT FROM T_WORLD_IX_EQ_MASTER " + + "WHERE F_KNAM = :stockName", + [parameter])), + _ => throw new LegacySceneDataException("The stock-master lookup is invalid.") + }; + } + + private S8018SessionPhase CurrentSession() + { + var now = _timeProvider.GetLocalNow(); + return now.TimeOfDay > new TimeSpan(15, 30, 0) + ? S8018SessionPhase.AfterKrxClose + : S8018SessionPhase.BeforeOrAtKrxClose; + } + + private static LegacyDomesticEquityMarket ToKrxMarket(S8018StockMarket market) => market switch + { + S8018StockMarket.Kospi => LegacyDomesticEquityMarket.Kospi, + S8018StockMarket.Kosdaq => LegacyDomesticEquityMarket.Kosdaq, + _ => throw new LegacySceneDataException( + "The selected scene branch supports only KRX stocks.") + }; + + private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) => + entry.Selection ?? throw new LegacySceneDataException( + "The selected cut requires native database lookup information."); + + private static string RequireSelector(string value, string label) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new LegacySceneDataException($"The scene {label} selection is required."); + } + + return value.Trim(); + } + + private static string[] SplitPair(string value, char separator) + { + var values = value.Split(separator, StringSplitOptions.TrimEntries); + if (values.Length != 2 || values.Any(string.IsNullOrWhiteSpace)) + { + throw new LegacySceneDataException("The scene pair selection is invalid."); + } + + return values; + } + + private static LegacyDomesticEquityMarket ParseDomesticMarket(string value, bool allowNxt) + { + var market = value switch + { + "코스피" or "KOSPI" or "KOSPI_STOCK" => LegacyDomesticEquityMarket.Kospi, + "코스닥" or "KOSDAQ" or "KOSDAQ_STOCK" => LegacyDomesticEquityMarket.Kosdaq, + "코스피_NXT" or "NXT코스피" or "NXT_KOSPI" => LegacyDomesticEquityMarket.NxtKospi, + "코스닥_NXT" or "NXT코스닥" or "NXT_KOSDAQ" => LegacyDomesticEquityMarket.NxtKosdaq, + _ when value.Contains("코스피_NXT", StringComparison.Ordinal) || + value.Contains("NXT_KOSPI", StringComparison.Ordinal) => + LegacyDomesticEquityMarket.NxtKospi, + _ when value.Contains("코스닥_NXT", StringComparison.Ordinal) || + value.Contains("NXT_KOSDAQ", StringComparison.Ordinal) => + LegacyDomesticEquityMarket.NxtKosdaq, + _ when value.Contains("코스피", StringComparison.Ordinal) || + value.Contains("KOSPI", StringComparison.Ordinal) => + LegacyDomesticEquityMarket.Kospi, + _ when value.Contains("코스닥", StringComparison.Ordinal) || + value.Contains("KOSDAQ", StringComparison.Ordinal) => + LegacyDomesticEquityMarket.Kosdaq, + _ => throw new LegacySceneDataException("The domestic equity market selection is invalid.") + }; + + if (!allowNxt && market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq) + { + throw new LegacySceneDataException("The selected scene branch does not support NXT."); + } + + return market; + } + + private static S5001DomesticIndexTarget ParseDomesticIndex(string value) => value switch + { + "코스피" or "KOSPI" => S5001DomesticIndexTarget.Kospi, + "코스닥" or "KOSDAQ" => S5001DomesticIndexTarget.Kosdaq, + "코스피200" or "코스피 200" or "KOSPI200" => S5001DomesticIndexTarget.Kospi200, + "선물" or "FUTURES" => S5001DomesticIndexTarget.Futures, + "KRX100" or "KRX 100" => S5001DomesticIndexTarget.Krx100, + _ => throw new LegacySceneDataException("The domestic index selection is invalid.") + }; + + private static bool IsExpectedIndex(string value) => value is + "예상지수" or "EXPECTED" or "EXPECTED_INDEX"; + + private static bool IsExpectedPair(string value) => value is + "예상체결" or "예상체결가" or "EXPECTED" or "EXPECTED_OPENING"; + + private static S5001ForeignIndexTarget ParseForeignIndex(string value) => value switch + { + "다우" or "Dow" => S5001ForeignIndexTarget.Dow, + "나스닥" or "Nasdaq" => S5001ForeignIndexTarget.Nasdaq, + "S&P" or "S&P500" or "Sp500" => S5001ForeignIndexTarget.Sp500, + "독일" or "GermanyDax" => S5001ForeignIndexTarget.GermanyDax, + "영국" or "UnitedKingdomFtse" => S5001ForeignIndexTarget.UnitedKingdomFtse, + "프랑스" or "FranceCac" => S5001ForeignIndexTarget.FranceCac, + "니케이" or "Nikkei" => S5001ForeignIndexTarget.Nikkei, + "중국 상해" or "ShanghaiComposite" => S5001ForeignIndexTarget.ShanghaiComposite, + "홍콩 항셍" or "HangSeng" => S5001ForeignIndexTarget.HangSeng, + "대만 가권" or "TaiwanWeighted" => S5001ForeignIndexTarget.TaiwanWeighted, + "싱가포르 지수" or "SingaporeStraitsTimes" => S5001ForeignIndexTarget.SingaporeStraitsTimes, + "태국 지수" or "ThailandSet" => S5001ForeignIndexTarget.ThailandSet, + "필리핀 지수" or "PhilippinesComposite" => S5001ForeignIndexTarget.PhilippinesComposite, + "말레이시아 지수" or "MalaysiaKlse" => S5001ForeignIndexTarget.MalaysiaKlse, + "인도네시아 지수" or "IndonesiaComposite" => S5001ForeignIndexTarget.IndonesiaComposite, + _ => throw new LegacySceneDataException("The foreign index selection is invalid.") + }; + + private static S5001ExchangeRateTarget ParseExchangeRate(string value) => value switch + { + "원달러" or "WonDollar" => S5001ExchangeRateTarget.WonDollar, + "원엔" or "WonYen" => S5001ExchangeRateTarget.WonYen, + "원위엔" or "원위안" or "WonYuan" => S5001ExchangeRateTarget.WonYuan, + "원유로" or "WonEuro" => S5001ExchangeRateTarget.WonEuro, + _ => throw new LegacySceneDataException("The exchange-rate selection is invalid.") + }; + + private static LegacyMarketQuoteTarget ParseMarketTarget(string value) => + TryParseMarketTarget(value, out var target) + ? target + : throw new LegacySceneDataException("The market item selection is invalid."); + + private static bool TryParseMarketTarget(string value, out LegacyMarketQuoteTarget target) + { + var text = value.Trim(); + if (Enum.TryParse(text, ignoreCase: false, out target) && + Enum.IsDefined(target)) + { + return true; + } + + if (text.Contains("코스피", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Kospi; + else if (text.Contains("코스닥", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Kosdaq; + else if (text.Contains("선물", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Futures; + else if (text.Contains("KRX100", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Krx100; + else if (text.Contains("원달러", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonDollar; + else if (text.Contains("원엔", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonYen; + else if (text.Contains("원위엔", StringComparison.Ordinal) || text.Contains("원위안", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonYuan; + else if (text.Contains("원유로", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonEuro; + else if (text.Contains("다우", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Dow; + else if (text.Contains("나스닥", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Nasdaq; + else if (text.Contains("S&P", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Sp500; + else if (text.Contains("독일", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.GermanyDax; + else if (text.Contains("영국", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.UnitedKingdomFtse; + else if (text.Contains("프랑스", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.FranceCac; + else if (text.Contains("니케이", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Nikkei; + else if (text.Contains("중국 상해", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.ShanghaiComposite; + else if (text.Contains("홍콩 항셍", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.HangSeng; + else if (text.Contains("대만 가권", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.TaiwanWeighted; + else if (text.Contains("싱가포르 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.SingaporeStraitsTimes; + else if (text.Contains("태국 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.ThailandSet; + else if (text.Contains("필리핀 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.PhilippinesComposite; + else if (text.Contains("말레이시아 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.MalaysiaKlse; + else if (text.Contains("인도네시아 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.IndonesiaComposite; + else + { + target = default; + return false; + } + + return true; + } + + private static bool TryParseCommodity(string value, out S5001CommodityTarget target) + { + if (Enum.TryParse(value, ignoreCase: false, out target) && Enum.IsDefined(target)) + { + return true; + } + + return CommodityNames.TryGetValue(value, out target); + } + + private static readonly IReadOnlyDictionary CommodityNames = + new Dictionary(StringComparer.Ordinal) + { + ["소맥"] = S5001CommodityTarget.FeedWheat, + ["옥수수"] = S5001CommodityTarget.Corn, + ["밀"] = S5001CommodityTarget.Wheat, + ["대두"] = S5001CommodityTarget.Soybean, + ["콩"] = S5001CommodityTarget.Bean, + ["현미"] = S5001CommodityTarget.BrownRice, + ["커피"] = S5001CommodityTarget.Coffee, + ["코코아"] = S5001CommodityTarget.Cocoa, + ["설탕"] = S5001CommodityTarget.Sugar, + ["원면"] = S5001CommodityTarget.RawCotton, + ["목화(면화)"] = S5001CommodityTarget.Cotton, + ["생우(소)"] = S5001CommodityTarget.LiveCattle, + ["비육우"] = S5001CommodityTarget.FeederCattle, + ["돈육(돼지)"] = S5001CommodityTarget.LeanHogs, + ["구리"] = S5001CommodityTarget.Copper, + ["철광석"] = S5001CommodityTarget.IronOre, + ["니켈"] = S5001CommodityTarget.Nickel, + ["천연가스"] = S5001CommodityTarget.NaturalGas, + ["백금"] = S5001CommodityTarget.Platinum, + ["팔라듐"] = S5001CommodityTarget.Palladium, + ["납"] = S5001CommodityTarget.Lead, + ["아연"] = S5001CommodityTarget.Zinc, + ["주석"] = S5001CommodityTarget.Tin, + ["국제 금"] = S5001CommodityTarget.InternationalGold, + ["국제 은"] = S5001CommodityTarget.InternationalSilver, + ["국내 금"] = S5001CommodityTarget.DomesticGold, + ["국내 은"] = S5001CommodityTarget.DomesticSilver + }; + + private sealed record DomesticStockSelection( + LegacyDomesticEquityMarket Market, + string StockName); + + private enum StockMasterKind + { + Kospi, + Kosdaq, + NxtKospi, + NxtKosdaq, + World + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs new file mode 100644 index 0000000..7585f81 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -0,0 +1,572 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Native lookup fields corresponding to the legacy playlist's code, subject, +/// graphic type, subtype and hidden data-code columns. They may select parameterized +/// DB data but never carry K3D method names, object names, mutations or asset paths. +/// +public sealed record LegacySceneSelection( + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string DataCode); + +public sealed record LegacyPlaylistEntry( + string EntryId, + string CutCode, + bool IsEnabled = true, + int? FadeDuration = null, + LegacySceneSelection? Selection = null); + +public sealed record LegacySceneCuePage( + PlayoutCue Cue, + int ItemCount, + ScenePageSize? PageSize = null, + string? BuilderKey = null, + IReadOnlyList? PreviewFields = null); + +/// +/// Native-only data boundary. Implementations query DB data and invoke a registered typed +/// scene builder; Web input can select a catalog code but cannot supply mutations or paths. +/// +public interface ILegacySceneCueProvider +{ + Task CreatePageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default); +} + +public enum LegacyWorkflowNextKind +{ + None, + PageNext, + PlaylistNext, + EndOfPlaylist +} + +public sealed record LegacyPlayoutWorkflowState( + int CurrentCueIndexZeroBased, + string? PreparedCutCode, + string? OnAirCutCode, + int PageIndexZeroBased, + int PageCount, + int PageSize, + int ItemCount, + bool IsLastPage, + LegacyWorkflowNextKind NextKind, + string? CurrentEntryId, + string? BuilderKey, + int CurrentPageItemCount, + IReadOnlyList PreviewFields); + +/// +/// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine. +/// It is deliberately unaware of COM and serializes DB-to-cue work with engine calls. +/// +public sealed class LegacyPlayoutWorkflow +{ + private const int MaximumPlaylistItems = 1_000; + + private readonly IPlayoutEngine _engine; + private readonly ILegacySceneCueProvider _cueProvider; + private readonly SemaphoreSlim _gate = new(1, 1); + private IReadOnlyList _playlist = []; + private ActivePage? _prepared; + private ActivePage? _onAir; + private int _engineClearRequested; + + public LegacyPlayoutWorkflow( + IPlayoutEngine engine, + ILegacySceneCueProvider cueProvider) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _cueProvider = cueProvider ?? throw new ArgumentNullException(nameof(cueProvider)); + State = EmptyState; + } + + public LegacyPlayoutWorkflowState State { get; private set; } + + /// + /// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native + /// playlist state. It never dispatches an engine command. If a command currently + /// owns the workflow gate, reconciliation is applied before that command releases it. + /// + public void ObserveEngineStatus(PlayoutStatus status) + { + ArgumentNullException.ThrowIfNull(status); + if (!string.IsNullOrWhiteSpace(status.PreparedSceneName) || + !string.IsNullOrWhiteSpace(status.OnAirSceneName)) + { + return; + } + + Interlocked.Exchange(ref _engineClearRequested, 1); + if (!_gate.Wait(0)) + { + return; + } + + try + { + ApplyRequestedEngineClear(); + } + finally + { + _gate.Release(); + } + } + + public async Task PrepareAsync( + IReadOnlyList playlist, + int cueIndexZeroBased, + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // MainForm's PREPARE button is a mode toggle. When PREPARE/PROGRAM is + // already active, a second press follows btnTakeout_Click and StopAll + // instead of replacing the current scene with another prepare. + if (_prepared is not null || _onAir is not null) + { + var takeOut = await _engine.TakeOutAsync( + PlayoutTakeOutScope.All, + cancellationToken).ConfigureAwait(false); + if (takeOut.IsSuccess) + { + _prepared = null; + _onAir = null; + _playlist = []; + State = EmptyState; + } + + return takeOut; + } + + var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased); + var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased); + if (selectedIndex is null) + { + return Rejected( + PlayoutOperation.Prepare, + "선택한 위치 이후에 활성화된 플레이리스트 항목이 없습니다."); + } + + var page = await CreatePageAsync( + snapshot, + selectedIndex.Value, + pageIndexZeroBased: 0, + cancellationToken).ConfigureAwait(false); + var result = await _engine.PrepareAsync(page.Page.Cue, cancellationToken) + .ConfigureAwait(false); + if (result.IsSuccess) + { + _playlist = snapshot; + _prepared = page; + PublishState(); + } + + return result; + } + finally + { + ApplyRequestedEngineClear(); + _gate.Release(); + } + } + + public async Task TakeInAsync( + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_prepared is null) + { + return Rejected(PlayoutOperation.TakeIn, "PREPARE가 완료된 장면이 없습니다."); + } + + // MainForm's TAKE IN path resets m_Super and runs ONAirMode again before + // Play(10). Recreate the selected page and prepare the fresh database + // snapshot; never play when that prepare did not succeed. + var refreshed = await CreatePageAsync( + _playlist, + _prepared.CueIndexZeroBased, + _prepared.PageIndexZeroBased, + cancellationToken).ConfigureAwait(false); + RequireSameScene(_prepared, refreshed, "TAKE IN refresh"); + + var prepare = await _engine.PrepareAsync( + refreshed.Page.Cue, + cancellationToken).ConfigureAwait(false); + if (!prepare.IsSuccess) + { + return prepare; + } + + // The engine now owns the freshly prepared scene even if Play fails. + _prepared = refreshed; + PublishState(); + + var result = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false); + if (result.IsSuccess) + { + _onAir = refreshed; + _prepared = null; + PublishState(); + } + + return result; + } + finally + { + ApplyRequestedEngineClear(); + _gate.Release(); + } + } + + public async Task NextAsync( + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_onAir is null) + { + return Rejected(PlayoutOperation.Next, "TAKE IN 이후에만 NEXT를 실행할 수 있습니다."); + } + + if (_onAir.Plan is { } currentPlan && + _onAir.PageIndexZeroBased + 1 < currentPlan.TotalPages) + { + var nextPage = await CreatePageAsync( + _playlist, + _onAir.CueIndexZeroBased, + _onAir.PageIndexZeroBased + 1, + cancellationToken).ConfigureAwait(false); + if (!string.Equals( + nextPage.Page.Cue.SceneName, + _onAir.Page.Cue.SceneName, + StringComparison.OrdinalIgnoreCase)) + { + throw new LegacySceneDataException( + "A page update must target the scene already on air."); + } + + // btnNext_Click calls Next_Scene(0). For all 5/6/12-row branches + // the idx == 0 path loads and prepares a fresh scene before Play. + var result = await _engine.NextAsync( + nextPage.Page.Cue, + cancellationToken).ConfigureAwait(false); + if (result.IsSuccess) + { + _onAir = nextPage; + PublishState(); + } + + return result; + } + + var targetIndex = FindNextEnabledCue(_onAir.CueIndexZeroBased + 1); + if (targetIndex is null) + { + PublishState(LegacyWorkflowNextKind.EndOfPlaylist); + return Rejected(PlayoutOperation.Next, "플레이리스트의 마지막 장면입니다."); + } + + var nextCue = await CreatePageAsync( + _playlist, + targetIndex.Value, + pageIndexZeroBased: 0, + cancellationToken).ConfigureAwait(false); + var nextResult = await _engine.NextAsync(nextCue.Page.Cue, cancellationToken) + .ConfigureAwait(false); + if (nextResult.IsSuccess) + { + _onAir = nextCue; + _prepared = null; + PublishState(); + } + + return nextResult; + } + finally + { + ApplyRequestedEngineClear(); + _gate.Release(); + } + } + + /// + /// Reproduces MainForm's timer-driven same-scene refresh. The active non-paged + /// entry and current page are queried again and updated through + /// IPlayoutEngine.UpdateOnAirAsync, whose vendor implementation uses + /// GetPlayingScene. Timer start/stop policy remains an application concern. + /// + public async Task RefreshOnAirAsync( + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_onAir is null) + { + return Rejected( + PlayoutOperation.UpdateOnAir, + "A scene must be on air before it can be refreshed."); + } + + var refreshed = await CreatePageAsync( + _playlist, + _onAir.CueIndexZeroBased, + _onAir.PageIndexZeroBased, + cancellationToken).ConfigureAwait(false); + RequireSameScene(_onAir, refreshed, "On-air refresh"); + + var result = await _engine.UpdateOnAirAsync( + refreshed.Page.Cue, + cancellationToken).ConfigureAwait(false); + if (result.IsSuccess) + { + _onAir = refreshed; + PublishState(); + } + + return result; + } + finally + { + ApplyRequestedEngineClear(); + _gate.Release(); + } + } + + public async Task TakeOutAsync( + PlayoutTakeOutScope scope, + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var result = await _engine.TakeOutAsync(scope, cancellationToken) + .ConfigureAwait(false); + if (result.IsSuccess) + { + _prepared = null; + _onAir = null; + _playlist = []; + State = EmptyState; + } + + return result; + } + finally + { + ApplyRequestedEngineClear(); + _gate.Release(); + } + } + + private async Task CreatePageAsync( + IReadOnlyList playlist, + int cueIndexZeroBased, + int pageIndexZeroBased, + CancellationToken cancellationToken) + { + var entry = playlist[cueIndexZeroBased]; + var page = await _cueProvider.CreatePageAsync( + entry, + pageIndexZeroBased, + cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(page); + ArgumentNullException.ThrowIfNull(page.Cue); + + ScenePagingPlan? plan = null; + if (page.PageSize is { } pageSize) + { + var planResult = ScenePaging.CreatePlan(page.ItemCount, pageSize); + if (!planResult.IsValid || planResult.Plan is null) + { + throw new LegacySceneDataException("The scene provider returned invalid paging data."); + } + + var window = ScenePaging.GetWindow( + page.ItemCount, + pageSize, + pageIndexZeroBased + 1); + if (!window.IsValid || window.Window is null) + { + throw new LegacySceneDataException("The requested scene page is outside the available range."); + } + + plan = planResult.Plan; + } + else if (pageIndexZeroBased != 0 || page.ItemCount < 0) + { + throw new LegacySceneDataException("A non-paged scene returned invalid page data."); + } + + return new ActivePage(cueIndexZeroBased, pageIndexZeroBased, page, plan); + } + + private static IReadOnlyList ValidatePlaylist( + IReadOnlyList? playlist, + int cueIndexZeroBased) + { + if (playlist is null || playlist.Count is < 1 or > MaximumPlaylistItems || + cueIndexZeroBased < 0 || cueIndexZeroBased >= playlist.Count) + { + throw new ArgumentException("The playlist selection is invalid.", nameof(playlist)); + } + + var entries = playlist.ToArray(); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var entry in entries) + { + if (entry is null || !IsSafeToken(entry.EntryId) || !IsSafeToken(entry.CutCode) || + !ids.Add(entry.EntryId) || + entry.FadeDuration is < 0 or > 60 || + !IsSafeSelection(entry.Selection)) + { + throw new ArgumentException("The playlist contains an invalid entry.", nameof(playlist)); + } + } + + return Array.AsReadOnly(entries); + } + + private int? FindNextEnabledCue(int startIndex) + => FindNextEnabledCue(_playlist, startIndex); + + private static int? FindNextEnabledCue( + IReadOnlyList playlist, + int startIndex) + { + for (var index = startIndex; index < playlist.Count; index++) + { + if (playlist[index].IsEnabled) + { + return index; + } + } + + return null; + } + + private void PublishState(LegacyWorkflowNextKind? forcedNextKind = null) + { + var active = _onAir ?? _prepared; + if (active is null) + { + State = EmptyState; + return; + } + + var pageCount = active.Plan?.TotalPages ?? 1; + var pageSize = active.Page.PageSize is { } size ? (int)size : 0; + var isLastPage = active.PageIndexZeroBased + 1 >= pageCount; + var nextKind = forcedNextKind ?? (!isLastPage + ? LegacyWorkflowNextKind.PageNext + : FindNextEnabledCue(active.CueIndexZeroBased + 1).HasValue + ? LegacyWorkflowNextKind.PlaylistNext + : LegacyWorkflowNextKind.EndOfPlaylist); + State = new LegacyPlayoutWorkflowState( + active.CueIndexZeroBased, + _prepared?.Page.Cue.SceneName, + _onAir?.Page.Cue.SceneName, + active.PageIndexZeroBased, + pageCount, + pageSize, + active.Page.ItemCount, + isLastPage, + nextKind, + _playlist[active.CueIndexZeroBased].EntryId, + active.Page.BuilderKey, + active.Plan is null + ? active.Page.ItemCount + : Math.Min( + pageSize, + Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))), + active.Page.PreviewFields ?? Array.Empty()); + } + + private static bool IsSafeToken(string? value) => + !string.IsNullOrWhiteSpace(value) && + value.Length <= 128 && + value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-'); + + private static bool IsSafeSelection(LegacySceneSelection? selection) + { + if (selection is null) + { + return true; + } + + return IsSafeLookupText(selection.GroupCode) && + IsSafeLookupText(selection.Subject) && + IsSafeLookupText(selection.GraphicType) && + IsSafeLookupText(selection.Subtype) && + IsSafeLookupText(selection.DataCode); + } + + private static bool IsSafeLookupText(string? value) => + value is not null && + value.Length <= 256 && + !value.Any(char.IsControl); + + private static void RequireSameScene( + ActivePage current, + ActivePage refreshed, + string operation) + { + if (!string.Equals( + refreshed.Page.Cue.SceneName, + current.Page.Cue.SceneName, + StringComparison.OrdinalIgnoreCase)) + { + throw new LegacySceneDataException( + $"{operation} must target the scene that is already selected."); + } + } + + private static PlayoutResult Rejected(PlayoutOperation operation, string message) => + new(operation, PlayoutResultCode.Rejected, false, message, DateTimeOffset.UtcNow); + + private void ApplyRequestedEngineClear() + { + if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0) + { + return; + } + + _prepared = null; + _onAir = null; + _playlist = []; + State = EmptyState; + } + + private static readonly LegacyPlayoutWorkflowState EmptyState = new( + -1, + null, + null, + 0, + 0, + 0, + 0, + true, + LegacyWorkflowNextKind.None, + null, + null, + 0, + Array.Empty()); + + private sealed record ActivePage( + int CueIndexZeroBased, + int PageIndexZeroBased, + LegacySceneCuePage Page, + ScenePagingPlan? Plan); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPriceDirectionMutations.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPriceDirectionMutations.cs new file mode 100644 index 0000000..75f4276 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPriceDirectionMutations.cs @@ -0,0 +1,130 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Typed form of the direction strings used by the legacy scene builders. +/// +public enum ScenePriceDirection +{ + Unknown, + LimitUp, + Up, + Flat, + Down, + LimitDown +} + +/// +/// Reproduces MainForm.setObjectStockChange without exposing a K3D object. +/// The returned sequence intentionally follows the original call order. +/// +public static class LegacyPriceDirectionMutations +{ + private static readonly string[] DirectionObjects = + ["upup", "up", "flat", "down", "downdown"]; + + private static readonly string[] VariantObjects = + ["bg", "changePrice", "wonText", "rate", "percent"]; + + public static ScenePriceDirection Parse(string? value) => value?.Trim() switch + { + "상한" => ScenePriceDirection.LimitUp, + "상승" or "+" => ScenePriceDirection.Up, + null or "" or "보합" => ScenePriceDirection.Flat, + "하락" or "-" => ScenePriceDirection.Down, + "하한" => ScenePriceDirection.LimitDown, + _ => ScenePriceDirection.Unknown + }; + + public static IReadOnlyList Build( + ScenePriceDirection direction, + int legacyVariantIndex) + { + if (legacyVariantIndex is < 0 or > 3) + { + throw new ArgumentOutOfRangeException( + nameof(legacyVariantIndex), + "Legacy direction variant must be between zero and three."); + } + + var suffix = legacyVariantIndex is 1 or 2 + ? legacyVariantIndex.ToString(System.Globalization.CultureInfo.InvariantCulture) + : string.Empty; + var mutations = new List(32); + + foreach (var objectName in DirectionObjects) + { + mutations.Add(new PlayoutSetVisible(objectName + suffix, false)); + } + + var activeDirectionObject = direction switch + { + ScenePriceDirection.LimitUp => "upup", + ScenePriceDirection.Up => "up", + ScenePriceDirection.Flat => "flat", + ScenePriceDirection.Down => "down", + ScenePriceDirection.LimitDown => "downdown", + _ => null + }; + + if (activeDirectionObject is null) + { + return mutations; + } + + mutations.Add(new PlayoutSetVisible(activeDirectionObject + suffix, true)); + + var selectedVariant = direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1, + ScenePriceDirection.Flat => 2, + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3, + _ => throw new InvalidOperationException("A known direction was expected.") + }; + + for (var variant = 1; variant <= 3; variant++) + { + foreach (var objectName in VariantObjects) + { + mutations.Add(new PlayoutSetVisible( + $"{objectName}{variant}_{suffix}", + variant == selectedVariant)); + } + } + + if (legacyVariantIndex != 0) + { + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + $"unit{variant}_{suffix}", + variant == selectedVariant)); + } + } + + // The original helper only changes price variants for the ordinary + // up/flat/down branches when cnt == 3. + if (legacyVariantIndex == 3 && + direction is ScenePriceDirection.Up or ScenePriceDirection.Flat or ScenePriceDirection.Down) + { + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + $"price{variant}_{suffix}", + variant == selectedVariant)); + } + } + + var pattern = selectedVariant switch + { + 1 => @"Images\그림_빨강.png", + 2 => @"Images\그림_검정.png", + 3 => @"Images\그림_파랑.png", + _ => throw new InvalidOperationException("A known visual variant was expected.") + }; + mutations.Add(new PlayoutSetAssetValue("pattern" + suffix, pattern)); + + return mutations; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneCatalog.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneCatalog.cs new file mode 100644 index 0000000..fc5f5a0 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneCatalog.cs @@ -0,0 +1,298 @@ +#nullable enable + +using System.Collections.ObjectModel; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Fixed metadata for the 35 builders found in the read-only MBN_STOCK_N Scene folder. +/// Runtime implementations and their concrete DTO contracts are validated one-to-one by +/// . +/// +public static class LegacySceneCatalog +{ + public const int ExpectedDefinitionCount = 35; + + private const LegacySceneMutationCapability Value = + LegacySceneMutationCapability.Value; + private const LegacySceneMutationCapability Asset = + LegacySceneMutationCapability.AssetValue; + private const LegacySceneMutationCapability Visible = + LegacySceneMutationCapability.Visibility; + private const LegacySceneMutationCapability Color = + LegacySceneMutationCapability.FaceColor; + private const LegacySceneMutationCapability Position = + LegacySceneMutationCapability.Position; + private const LegacySceneMutationCapability PositionKey = + LegacySceneMutationCapability.PositionKey; + private const LegacySceneMutationCapability Scale = + LegacySceneMutationCapability.Scale; + private const LegacySceneMutationCapability Crop = + LegacySceneMutationCapability.Crop; + private const LegacySceneMutationCapability CircleAngle = + LegacySceneMutationCapability.CircleAngleKey; + private const LegacySceneMutationCapability Path = + LegacySceneMutationCapability.PathPoints; + private const LegacySceneMutationCapability PathShape = + LegacySceneMutationCapability.PathShapePoints; + private const LegacySceneMutationCapability BackgroundVideo = + LegacySceneMutationCapability.BackgroundVideo; + + private static readonly ReadOnlyCollection RegisteredDefinitions = + Array.AsReadOnly(CreateDefinitions()); + + private static readonly IReadOnlyDictionary BySceneCode = + CreateSceneCodeIndex(RegisteredDefinitions); + + private static readonly IReadOnlyDictionary> + ByCutAlias = CreateCutAliasIndex(RegisteredDefinitions); + + static LegacySceneCatalog() + { + ValidateInvariants(RegisteredDefinitions); + } + + public static IReadOnlyList Definitions => RegisteredDefinitions; + + /// + /// Finds a definition by its builder-derived numeric scene code. + /// + public static LegacySceneDefinition? FindBySceneCode(string? sceneCode) + { + if (string.IsNullOrWhiteSpace(sceneCode)) + { + return null; + } + + return BySceneCode.TryGetValue(sceneCode, out var definition) + ? definition + : null; + } + + /// + /// Returns every builder eligible for an alias. The 8018/8032/5032 aliases intentionally + /// return both conditional builders; the caller must use typed playlist context to choose. + /// + public static IReadOnlyList FindByCutAlias(string? cutAlias) + { + if (string.IsNullOrWhiteSpace(cutAlias)) + { + return Array.Empty(); + } + + return ByCutAlias.TryGetValue(cutAlias, out var definitions) + ? definitions + : Array.Empty(); + } + + private static LegacySceneDefinition[] CreateDefinitions() => + [ + Define("s5001", "5001", ["5001", "N5001"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + Define("s5006", "5006", ["5006"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | BackgroundVideo), + Define("s5011", "5011", ["5011"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color), + Define("s5016", "5016", ["5016"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + Define("s50160", "50160", ["50160"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + Define("s5023", "5023", ["5023"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + Define("s5024", "5024", ["5024"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | Scale), + Define("s5025", "5025", ["5025"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value), + Define("s5026", "5026", ["5026"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Crop), + Define("s5029", "5029", ["5029"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | PathShape), + Define("s5032", "5032", ["8018", "8032", "5032"], + LegacyScenePageSize.None, + LegacySceneReachability.ConditionalMainFormDispatch, + Value | Visible | Asset), + Define("s5037", "5037", ["5037"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + Define("s5074", "5074", ["5074"], + LegacyScenePageSize.Five, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + Define("s5076", "5076", ["5076"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | CircleAngle), + Define("s5077", "5077", ["5077"], + LegacyScenePageSize.Six, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + Define("s5078", "5078", ["5078"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Scale), + Define("s5079", "5079", ["5079"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Path), + Define("s5080", "5080", ["5080"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | PositionKey | Scale), + Define("s5081", "5081", ["5081"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | PositionKey | Scale), + Define("s5082", "5082", ["5082"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + Define("s5083", "5083", ["5083"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Position | Path), + Define("s5084", "5084", ["5084"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Position | Path), + Define("s5085", "5085", ["5085"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + Define("s5086", "5086", ["5086"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | PathShape), + Define("s50860", "50860", ["50860"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Position | PathShape), + Define("s5087", "5087", ["5087"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | PathShape), + Define("s5088", "5088", ["5088"], + LegacyScenePageSize.Twelve, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + Define("s6001", "6001", ["6001"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Asset | Visible | Color), + Define("s6067", "6067", ["6067"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + Define("s8001", "8001", ["8001", "8002"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + Define("s8003", "8003", ["8003"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Scale), + Define( + "s8010", + "8010", + ["8035", "8061", "8040", "8046", "8051", "8056"], + LegacyScenePageSize.None, + LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Position | Crop | Path), + Define("s8018", "8018", ["8018", "8032", "5032"], + LegacyScenePageSize.None, + LegacySceneReachability.ConditionalMainFormDispatch, + Value | Visible | Asset), + Define("s8067", "8067", ["8067", "5068", "5070", "5072"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color), + Define("s8086", "8086", [], + LegacyScenePageSize.None, LegacySceneReachability.NoMainFormDispatch, + Value | Color) + ]; + + private static LegacySceneDefinition Define( + string builderKey, + string sceneCode, + IEnumerable cutAliases, + LegacyScenePageSize pageSize, + LegacySceneReachability reachability, + LegacySceneMutationCapability mutationCapabilities) + where TDtoMarker : ILegacySceneDtoMarker => + new( + builderKey, + sceneCode, + cutAliases, + pageSize, + reachability, + typeof(TDtoMarker), + builderKey, + mutationCapabilities); + + private static IReadOnlyDictionary CreateSceneCodeIndex( + IEnumerable definitions) => + new ReadOnlyDictionary( + definitions.ToDictionary( + definition => definition.SceneCode, + StringComparer.Ordinal)); + + private static IReadOnlyDictionary> + CreateCutAliasIndex(IEnumerable definitions) + { + var mutable = new Dictionary>( + StringComparer.Ordinal); + foreach (var definition in definitions) + { + foreach (var alias in definition.CutAliases) + { + if (!mutable.TryGetValue(alias, out var matches)) + { + matches = []; + mutable.Add(alias, matches); + } + + matches.Add(definition); + } + } + + var readOnly = mutable.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)Array.AsReadOnly(pair.Value.ToArray()), + StringComparer.Ordinal); + return new ReadOnlyDictionary>(readOnly); + } + + private static void ValidateInvariants( + IReadOnlyCollection definitions) + { + if (definitions.Count != ExpectedDefinitionCount) + { + throw new InvalidOperationException( + $"The legacy scene catalog must contain exactly {ExpectedDefinitionCount} definitions."); + } + + RequireUnique(definitions, definition => definition.BuilderKey, "builder key"); + RequireUnique(definitions, definition => definition.SceneCode, "scene code"); + RequireUnique(definitions, definition => definition.SourceHashKey, "source hash key"); + RequireUnique(definitions, definition => definition.DtoTypeMarker.FullName!, "DTO marker"); + + foreach (var aliasGroup in definitions + .SelectMany(definition => definition.CutAliases.Select(alias => + (Alias: alias, Definition: definition))) + .GroupBy(pair => pair.Alias, StringComparer.Ordinal) + .Where(group => group.Count() > 1)) + { + if (aliasGroup.Any(pair => + pair.Definition.Reachability != + LegacySceneReachability.ConditionalMainFormDispatch)) + { + throw new InvalidOperationException( + $"Shared cut alias '{aliasGroup.Key}' must map only to conditional builders."); + } + } + } + + private static void RequireUnique( + IEnumerable definitions, + Func selector, + string label) + { + var duplicate = definitions + .GroupBy(selector, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException( + $"Duplicate {label} '{duplicate.Key}' in the legacy scene catalog."); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs new file mode 100644 index 0000000..935ff1c --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs @@ -0,0 +1,107 @@ +#nullable enable + +using System.Collections.ObjectModel; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public delegate Task LegacySceneDataLoaderDelegate( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + 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, +/// method name, object name or file path. +/// +public sealed class LegacySceneDataSourceRoute +{ + public LegacySceneDataSourceRoute( + IEnumerable cutCodes, + LegacySceneDataLoaderDelegate loader) + { + ArgumentNullException.ThrowIfNull(cutCodes); + Loader = loader ?? throw new ArgumentNullException(nameof(loader)); + var codes = cutCodes.Select(ValidateCutCode).ToArray(); + if (codes.Length == 0 || + codes.Distinct(StringComparer.Ordinal).Count() != codes.Length) + { + throw new ArgumentException("A scene-data route requires unique cut codes.", nameof(cutCodes)); + } + + CutCodes = Array.AsReadOnly(codes); + } + + public IReadOnlyList CutCodes { get; } + + internal LegacySceneDataLoaderDelegate Loader { get; } + + private static string ValidateCutCode(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 64 || + value.Any(character => !char.IsAsciiLetterOrDigit(character))) + { + throw new ArgumentException("A scene-data route contains an invalid cut code."); + } + + return value; + } +} + +/// +/// 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 +{ + private readonly IReadOnlyDictionary _loaders; + + public LegacySceneDataSourceRouter(IEnumerable routes) + { + ArgumentNullException.ThrowIfNull(routes); + var loaders = new Dictionary(StringComparer.Ordinal); + foreach (var route in routes) + { + ArgumentNullException.ThrowIfNull(route); + foreach (var code in route.CutCodes) + { + if (!loaders.TryAdd(code, route.Loader)) + { + throw new ArgumentException( + "A cut code can have only one scene-data route.", + nameof(routes)); + } + } + } + + if (loaders.Count == 0) + { + throw new ArgumentException("At least one scene-data route is required.", nameof(routes)); + } + + _loaders = new ReadOnlyDictionary(loaders); + CutCodes = Array.AsReadOnly(loaders.Keys.OrderBy(code => code, StringComparer.Ordinal).ToArray()); + } + + public IReadOnlyList CutCodes { get; } + + public Task LoadPageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + if (pageIndexZeroBased < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased)); + } + + if (!_loaders.TryGetValue(entry.CutCode, out var loader)) + { + throw new LegacySceneDataException( + "The selected cut does not have a registered scene-data loader."); + } + + return loader(entry, pageIndexZeroBased, cancellationToken); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDefinition.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDefinition.cs new file mode 100644 index 0000000..6085090 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDefinition.cs @@ -0,0 +1,237 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Page sizes represented by the legacy scene catalog. None is used by non-paged scenes. +/// +public enum LegacyScenePageSize +{ + None = 0, + Five = 5, + Six = 6, + Twelve = 12 +} + +/// +/// Describes whether the legacy MainForm can construct a builder. +/// +public enum LegacySceneReachability +{ + MainFormDispatch, + ConditionalMainFormDispatch, + NoMainFormDispatch +} + +/// +/// Closed metadata describing the K3D mutation families observed in a legacy builder. +/// It deliberately contains no object names, method names, values, or paths. +/// +[Flags] +public enum LegacySceneMutationCapability +{ + None = 0, + Value = 1 << 0, + AssetValue = 1 << 1, + Visibility = 1 << 2, + FaceColor = 1 << 3, + Position = 1 << 4, + PositionKey = 1 << 5, + Scale = 1 << 6, + Crop = 1 << 7, + CircleAngleKey = 1 << 8, + PathPoints = 1 << 9, + PathShapePoints = 1 << 10, + BackgroundVideo = 1 << 11 +} + +/// +/// Catalog-only marker that keeps every legacy source slot unique. Concrete runtime DTOs +/// implement ILegacySceneData and are exposed by LegacySceneMutationBuilderRegistry. +/// +public interface ILegacySceneDtoMarker +{ +} + +/// +/// Immutable metadata for one of the 35 legacy scene builders. +/// +public sealed class LegacySceneDefinition +{ + private const LegacySceneMutationCapability AllKnownCapabilities = + LegacySceneMutationCapability.Value | + LegacySceneMutationCapability.AssetValue | + LegacySceneMutationCapability.Visibility | + LegacySceneMutationCapability.FaceColor | + LegacySceneMutationCapability.Position | + LegacySceneMutationCapability.PositionKey | + LegacySceneMutationCapability.Scale | + LegacySceneMutationCapability.Crop | + LegacySceneMutationCapability.CircleAngleKey | + LegacySceneMutationCapability.PathPoints | + LegacySceneMutationCapability.PathShapePoints | + LegacySceneMutationCapability.BackgroundVideo; + + internal LegacySceneDefinition( + string builderKey, + string sceneCode, + IEnumerable cutAliases, + LegacyScenePageSize pageSize, + LegacySceneReachability reachability, + Type dtoTypeMarker, + string sourceHashKey, + LegacySceneMutationCapability mutationCapabilities) + { + BuilderKey = RequireBuilderKey(builderKey); + SceneCode = RequireDigits(sceneCode, nameof(sceneCode)); + PageSize = RequireDefined(pageSize, nameof(pageSize)); + Reachability = RequireDefined(reachability, nameof(reachability)); + DtoTypeMarker = RequireDtoType(dtoTypeMarker); + SourceHashKey = RequireSourceHashKey(sourceHashKey); + MutationCapabilities = RequireCapabilities(mutationCapabilities); + + ArgumentNullException.ThrowIfNull(cutAliases); + var aliases = cutAliases + .Select(alias => RequireAlias(alias)) + .ToArray(); + if (aliases.Distinct(StringComparer.Ordinal).Count() != aliases.Length) + { + throw new ArgumentException( + "Cut aliases must be unique within a scene definition.", + nameof(cutAliases)); + } + + if (reachability == LegacySceneReachability.NoMainFormDispatch) + { + if (aliases.Length != 0) + { + throw new ArgumentException( + "A scene with no MainForm dispatch cannot declare an active cut alias.", + nameof(cutAliases)); + } + } + else if (aliases.Length == 0) + { + throw new ArgumentException( + "A reachable scene requires at least one cut alias.", + nameof(cutAliases)); + } + + CutAliases = Array.AsReadOnly(aliases); + } + + public string BuilderKey { get; } + + public string SceneCode { get; } + + public IReadOnlyList CutAliases { get; } + + public LegacyScenePageSize PageSize { get; } + + public LegacySceneReachability Reachability { get; } + + public Type DtoTypeMarker { get; } + + /// + /// Key used to locate the source hash entry; it is not the hash value or a file path. + /// + public string SourceHashKey { get; } + + public LegacySceneMutationCapability MutationCapabilities { get; } + + public bool HasCapability(LegacySceneMutationCapability capability) => + capability != LegacySceneMutationCapability.None && + (MutationCapabilities & capability) == capability; + + private static string RequireBuilderKey(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + if (value.Length < 2 || value[0] != 's' || + value.AsSpan(1).IndexOfAnyExceptInRange('0', '9') >= 0) + { + throw new ArgumentException( + "BuilderKey must be a lowercase 's' followed by digits.", + nameof(value)); + } + + return value; + } + + private static string RequireDigits(string value, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName); + if (value.AsSpan().IndexOfAnyExceptInRange('0', '9') >= 0) + { + throw new ArgumentException("The value must contain digits only.", parameterName); + } + + return value; + } + + private static string RequireAlias(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + if (!value.All(char.IsLetterOrDigit)) + { + throw new ArgumentException( + "A cut alias may contain letters and digits only.", + nameof(value)); + } + + return value; + } + + private static string RequireSourceHashKey(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + if (value.Any(character => + !(char.IsAsciiLetterOrDigit(character) || character is '-' or '_'))) + { + throw new ArgumentException( + "SourceHashKey must be an opaque key, not a path.", + nameof(value)); + } + + return value; + } + + private static Type RequireDtoType(Type value) + { + ArgumentNullException.ThrowIfNull(value); + if (!typeof(ILegacySceneDtoMarker).IsAssignableFrom(value) || + value == typeof(ILegacySceneDtoMarker)) + { + throw new ArgumentException( + "DtoTypeMarker must be a concrete scene DTO marker type.", + nameof(value)); + } + + return value; + } + + private static TEnum RequireDefined(TEnum value, string parameterName) + where TEnum : struct, Enum + { + if (!Enum.IsDefined(value)) + { + throw new ArgumentOutOfRangeException(parameterName, value, "Unknown enum value."); + } + + return value; + } + + private static LegacySceneMutationCapability RequireCapabilities( + LegacySceneMutationCapability value) + { + if (value == LegacySceneMutationCapability.None || + (value & ~AllKnownCapabilities) != 0) + { + throw new ArgumentOutOfRangeException( + nameof(value), + value, + "At least one known mutation capability is required."); + } + + return value; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDtoMarkers.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDtoMarkers.cs new file mode 100644 index 0000000..cf96559 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDtoMarkers.cs @@ -0,0 +1,42 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +// These types intentionally carry no fields. Each is a unique catalog/source-hash slot; +// concrete runtime DTOs are registered separately by LegacySceneMutationBuilderRegistry. +// They are never populated from Web input and expose no K3D control surface. +public sealed class S5001SceneDtoMarker : ILegacySceneDtoMarker { private S5001SceneDtoMarker() { } } +public sealed class S5006SceneDtoMarker : ILegacySceneDtoMarker { private S5006SceneDtoMarker() { } } +public sealed class S5011SceneDtoMarker : ILegacySceneDtoMarker { private S5011SceneDtoMarker() { } } +public sealed class S5016SceneDtoMarker : ILegacySceneDtoMarker { private S5016SceneDtoMarker() { } } +public sealed class S50160SceneDtoMarker : ILegacySceneDtoMarker { private S50160SceneDtoMarker() { } } +public sealed class S5023SceneDtoMarker : ILegacySceneDtoMarker { private S5023SceneDtoMarker() { } } +public sealed class S5024SceneDtoMarker : ILegacySceneDtoMarker { private S5024SceneDtoMarker() { } } +public sealed class S5025SceneDtoMarker : ILegacySceneDtoMarker { private S5025SceneDtoMarker() { } } +public sealed class S5026SceneDtoMarker : ILegacySceneDtoMarker { private S5026SceneDtoMarker() { } } +public sealed class S5029SceneDtoMarker : ILegacySceneDtoMarker { private S5029SceneDtoMarker() { } } +public sealed class S5032SceneDtoMarker : ILegacySceneDtoMarker { private S5032SceneDtoMarker() { } } +public sealed class S5037SceneDtoMarker : ILegacySceneDtoMarker { private S5037SceneDtoMarker() { } } +public sealed class S5074SceneDtoMarker : ILegacySceneDtoMarker { private S5074SceneDtoMarker() { } } +public sealed class S5076SceneDtoMarker : ILegacySceneDtoMarker { private S5076SceneDtoMarker() { } } +public sealed class S5077SceneDtoMarker : ILegacySceneDtoMarker { private S5077SceneDtoMarker() { } } +public sealed class S5078SceneDtoMarker : ILegacySceneDtoMarker { private S5078SceneDtoMarker() { } } +public sealed class S5079SceneDtoMarker : ILegacySceneDtoMarker { private S5079SceneDtoMarker() { } } +public sealed class S5080SceneDtoMarker : ILegacySceneDtoMarker { private S5080SceneDtoMarker() { } } +public sealed class S5081SceneDtoMarker : ILegacySceneDtoMarker { private S5081SceneDtoMarker() { } } +public sealed class S5082SceneDtoMarker : ILegacySceneDtoMarker { private S5082SceneDtoMarker() { } } +public sealed class S5083SceneDtoMarker : ILegacySceneDtoMarker { private S5083SceneDtoMarker() { } } +public sealed class S5084SceneDtoMarker : ILegacySceneDtoMarker { private S5084SceneDtoMarker() { } } +public sealed class S5085SceneDtoMarker : ILegacySceneDtoMarker { private S5085SceneDtoMarker() { } } +public sealed class S5086SceneDtoMarker : ILegacySceneDtoMarker { private S5086SceneDtoMarker() { } } +public sealed class S50860SceneDtoMarker : ILegacySceneDtoMarker { private S50860SceneDtoMarker() { } } +public sealed class S5087SceneDtoMarker : ILegacySceneDtoMarker { private S5087SceneDtoMarker() { } } +public sealed class S5088SceneDtoMarker : ILegacySceneDtoMarker { private S5088SceneDtoMarker() { } } +public sealed class S6001SceneDtoMarker : ILegacySceneDtoMarker { private S6001SceneDtoMarker() { } } +public sealed class S6067SceneDtoMarker : ILegacySceneDtoMarker { private S6067SceneDtoMarker() { } } +public sealed class S8001SceneDtoMarker : ILegacySceneDtoMarker { private S8001SceneDtoMarker() { } } +public sealed class S8003SceneDtoMarker : ILegacySceneDtoMarker { private S8003SceneDtoMarker() { } } +public sealed class S8010SceneDtoMarker : ILegacySceneDtoMarker { private S8010SceneDtoMarker() { } } +public sealed class S8018SceneDtoMarker : ILegacySceneDtoMarker { private S8018SceneDtoMarker() { } } +public sealed class S8067SceneDtoMarker : ILegacySceneDtoMarker { private S8067SceneDtoMarker() { } } +public sealed class S8086SceneDtoMarker : ILegacySceneDtoMarker { private S8086SceneDtoMarker() { } } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilder.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilder.cs new file mode 100644 index 0000000..9fc9659 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilder.cs @@ -0,0 +1,78 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Marker for trusted, scene-specific data created by the native DB layer. +/// Web messages never deserialize into these contracts. +/// +public interface ILegacySceneData +{ +} + +/// +/// Converts one explicit scene DTO into a deterministic COM-neutral mutation sequence. +/// +public interface ILegacySceneMutationBuilder + where TData : ILegacySceneData +{ + string BuilderKey { get; } + + IReadOnlyList Build(TData data); +} + +public sealed class LegacySceneDataException : Exception +{ + public LegacySceneDataException(string message) + : base(message) + { + } +} + +internal static class LegacySceneBuilderGuard +{ + public static T NotNull(T? value, string name) + where T : class => value ?? throw new LegacySceneDataException( + $"Scene data field '{name}' is required."); + + public static string Text(string? value, string name, bool allowEmpty = false) + { + if (value is null || value.Length > 4_096 || value.Any(char.IsControl) || + (!allowEmpty && string.IsNullOrWhiteSpace(value))) + { + throw new LegacySceneDataException( + $"Scene data field '{name}' is invalid."); + } + + return value; + } + + public static IReadOnlyList Count( + IReadOnlyList? values, + int expected, + string name) + { + if (values is null || values.Count != expected) + { + throw new LegacySceneDataException( + $"Scene data field '{name}' must contain exactly {expected} items."); + } + + return values; + } + + public static IReadOnlyList Range( + IReadOnlyList? values, + int minimum, + int maximum, + string name) + { + if (values is null || values.Count < minimum || values.Count > maximum) + { + throw new LegacySceneDataException( + $"Scene data field '{name}' must contain between {minimum} and {maximum} items."); + } + + return values; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilderRegistry.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilderRegistry.cs new file mode 100644 index 0000000..ab13e1b --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneMutationBuilderRegistry.cs @@ -0,0 +1,273 @@ +#nullable enable + +using System.Collections.ObjectModel; +using System.Reflection; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Read-only metadata for one trusted scene mutation builder. Instances can only be +/// created by . +/// +public sealed class LegacySceneMutationBuilderRegistration +{ + internal LegacySceneMutationBuilderRegistration(string builderKey, Type dataType) + { + BuilderKey = builderKey; + DataType = dataType; + } + + /// + /// Closed builder identity from . + /// + public string BuilderKey { get; } + + /// + /// Trusted native-data contract accepted by the registered builder. + /// + public Type DataType { get; } +} + +/// +/// Closed, COM-neutral dispatch table for every ported legacy scene builder. +/// The registry accepts only a catalog builder key and an +/// instance; it has no raw K3D method, object-name, mutation, or asset-path input. +/// +public sealed class LegacySceneMutationBuilderRegistry +{ + private static readonly Lazy DefaultRegistry = + new(CreateDefault, LazyThreadSafetyMode.ExecutionAndPublication); + + private readonly IReadOnlyDictionary _byBuilderKey; + + private LegacySceneMutationBuilderRegistry( + IReadOnlyDictionary byBuilderKey, + IReadOnlyList registrations) + { + _byBuilderKey = byBuilderKey; + Registrations = registrations; + } + + /// + /// Registry discovered once from the Core assembly and validated against all 35 + /// entries in . + /// + public static LegacySceneMutationBuilderRegistry Default => DefaultRegistry.Value; + + /// + /// Registrations in catalog order. The collection and its entries are immutable. + /// + public IReadOnlyList Registrations { get; } + + /// + /// Looks up a closed builder identity. Invalid or unregistered strings are rejected + /// without being interpreted as a scene file, type name, or K3D object name. + /// + public bool TryGetRegistration( + string? builderKey, + out LegacySceneMutationBuilderRegistration? registration) + { + registration = null; + if (!IsBuilderKey(builderKey) || + !_byBuilderKey.TryGetValue(builderKey!, out var adapter)) + { + return false; + } + + registration = adapter.Registration; + return true; + } + + /// + /// Dispatches trusted scene data to the builder registered for . + /// A DTO registered for any other key is rejected before its builder is called. + /// + public IReadOnlyList Build( + string builderKey, + ILegacySceneData data) + { + ArgumentNullException.ThrowIfNull(data); + if (!IsBuilderKey(builderKey) || + !_byBuilderKey.TryGetValue(builderKey, out var adapter)) + { + throw new ArgumentException( + "The scene builder key is not registered.", + nameof(builderKey)); + } + + return adapter.Build(data); + } + + private static LegacySceneMutationBuilderRegistry CreateDefault() + { + var builderContract = typeof(ILegacySceneMutationBuilder<>); + var candidates = typeof(LegacySceneMutationBuilderRegistry) + .Assembly + .DefinedTypes + .Where(type => type.IsClass && !type.IsAbstract) + .Select(type => new + { + Type = type, + Contracts = type.ImplementedInterfaces + .Where(contract => + contract.IsGenericType && + contract.GetGenericTypeDefinition() == builderContract) + .ToArray() + }) + .Where(candidate => candidate.Contracts.Length != 0) + .OrderBy(candidate => candidate.Type.FullName, StringComparer.Ordinal) + .ToArray(); + + var adapters = new Dictionary(StringComparer.Ordinal); + foreach (var candidate in candidates) + { + if (candidate.Contracts.Length != 1) + { + throw new InvalidOperationException( + "A legacy scene builder must implement exactly one typed builder contract."); + } + + var constructor = candidate.Type.GetConstructor( + BindingFlags.Instance | BindingFlags.Public, + binder: null, + Type.EmptyTypes, + modifiers: null); + if (constructor is null) + { + throw new InvalidOperationException( + "Every legacy scene builder must expose a public parameterless constructor."); + } + + var dataType = candidate.Contracts[0].GenericTypeArguments[0]; + ValidateDataType(dataType); + + var builder = constructor.Invoke(null); + var adapter = CreateAdapter(dataType, builder); + if (!IsBuilderKey(adapter.Registration.BuilderKey)) + { + throw new InvalidOperationException( + "A legacy scene builder returned an invalid builder key."); + } + + if (!adapters.TryAdd(adapter.Registration.BuilderKey, adapter)) + { + throw new InvalidOperationException( + "Every legacy scene builder key must have exactly one implementation."); + } + } + + var catalogKeys = LegacySceneCatalog.Definitions + .Select(definition => definition.BuilderKey) + .ToArray(); + var discoveredKeys = adapters.Keys + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray(); + if (adapters.Count != LegacySceneCatalog.ExpectedDefinitionCount || + !catalogKeys.OrderBy(key => key, StringComparer.Ordinal) + .SequenceEqual(discoveredKeys, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + "Legacy scene builders must have exact one-to-one parity with the 35-scene catalog."); + } + + var orderedAdapters = catalogKeys + .Select(key => adapters[key]) + .ToArray(); + var registrations = Array.AsReadOnly( + orderedAdapters.Select(adapter => adapter.Registration).ToArray()); + var readOnlyAdapters = new ReadOnlyDictionary( + orderedAdapters.ToDictionary( + adapter => adapter.Registration.BuilderKey, + StringComparer.Ordinal)); + + return new LegacySceneMutationBuilderRegistry(readOnlyAdapters, registrations); + } + + private static BuilderAdapter CreateAdapter(Type dataType, object builder) + { + var factory = typeof(LegacySceneMutationBuilderRegistry) + .GetMethod( + nameof(CreateTypedAdapter), + BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(dataType); + try + { + return (BuilderAdapter)factory.Invoke(null, [builder])!; + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + throw exception.InnerException; + } + } + + private static BuilderAdapter CreateTypedAdapter(object builder) + where TData : ILegacySceneData => + new TypedBuilderAdapter((ILegacySceneMutationBuilder)builder); + + private static void ValidateDataType(Type dataType) + { + if (dataType == typeof(ILegacySceneData) || + !typeof(ILegacySceneData).IsAssignableFrom(dataType) || + dataType.IsInterface || + dataType.ContainsGenericParameters) + { + throw new InvalidOperationException( + "A legacy scene builder must use a closed, concrete scene-data contract."); + } + } + + private static bool IsBuilderKey(string? value) + { + if (value is null || value.Length is < 2 or > 16 || value[0] != 's') + { + return false; + } + + return value.AsSpan(1).IndexOfAnyExceptInRange('0', '9') < 0; + } + + private abstract class BuilderAdapter + { + protected BuilderAdapter(string builderKey, Type dataType) + { + Registration = new LegacySceneMutationBuilderRegistration(builderKey, dataType); + } + + public LegacySceneMutationBuilderRegistration Registration { get; } + + public abstract IReadOnlyList Build(ILegacySceneData data); + } + + private sealed class TypedBuilderAdapter : BuilderAdapter + where TData : ILegacySceneData + { + private readonly ILegacySceneMutationBuilder _builder; + + public TypedBuilderAdapter(ILegacySceneMutationBuilder builder) + : base(builder.BuilderKey, typeof(TData)) + { + _builder = builder; + } + + public override IReadOnlyList Build(ILegacySceneData data) + { + if (data is not TData typedData) + { + throw new ArgumentException( + "The scene data does not match the registered builder key.", + nameof(data)); + } + + var mutations = _builder.Build(typedData) ?? + throw new InvalidOperationException( + "A legacy scene mutation builder returned no mutation collection."); + if (mutations.Any(mutation => mutation is null)) + { + throw new InvalidOperationException( + "A legacy scene mutation builder returned a null mutation."); + } + + return Array.AsReadOnly(mutations.ToArray()); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs new file mode 100644 index 0000000..57b4138 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs @@ -0,0 +1,99 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Safe, read-only representation of a typed builder mutation for the operator preview. +/// Asset paths are deliberately replaced with a configured marker. +/// +public sealed record LegacyScenePreviewField(string Kind, string Name, string Value); + +public static class LegacyScenePreviewFactory +{ + private const int MaximumFields = 4_096; + private const int MaximumValueLength = 16_384; + + public static IReadOnlyList Create( + IReadOnlyList mutations) + { + ArgumentNullException.ThrowIfNull(mutations); + if (mutations.Count > MaximumFields) + { + throw new LegacySceneDataException("The scene preview exceeds its bounded field count."); + } + + var fields = new List(mutations.Count); + foreach (var mutation in mutations) + { + ArgumentNullException.ThrowIfNull(mutation); + LegacyScenePreviewField field = mutation switch + { + PlayoutSetValue value => new("value", value.ObjectName, value.Value), + PlayoutSetAssetValue asset => new("asset", asset.ObjectName, "configured"), + PlayoutSetVisible visible => new( + "visible", visible.ObjectName, visible.IsVisible ? "true" : "false"), + PlayoutSetFaceColor color => new( + "color", color.ObjectName, + $"{color.Red},{color.Green},{color.Blue},{color.Alpha}"), + PlayoutSetPosition position => new( + "position", position.ObjectName, + $"{F(position.X)},{F(position.Y)},{F(position.Z)} [{position.Components}]"), + PlayoutSetPositionKey position => new( + "position-key", position.ObjectName, + $"key={position.KeyIndex};{F(position.X)},{F(position.Y)},{F(position.Z)} [{position.Components}]"), + PlayoutSetScale scale => new( + "scale", scale.ObjectName, + $"{F(scale.X)},{F(scale.Y)},{F(scale.Z)} [{scale.Components}]"), + PlayoutSetCropKey crop => new( + "crop", crop.ObjectName, + $"key={crop.KeyIndex};{F(crop.Left)},{F(crop.Top)},{F(crop.Right)},{F(crop.Bottom)} [{crop.Edges}]"), + PlayoutSetCircleAngleKey angle => new( + "angle", angle.ObjectName, + $"key={angle.KeyIndex};{F(angle.Start)},{F(angle.End)} [{angle.Components}]"), + PlayoutSetPathPoints path => new( + "path", path.ObjectName, Points(path.Points)), + PlayoutSetPathShapePoints path => new( + "path-shape", path.ObjectName, Points(path.Points)), + PlayoutUseBackground background => new( + "background", "scene", background.IsEnabled ? "enabled" : "disabled"), + PlayoutSetBackgroundTexture => new( + "background-texture", "scene", "configured"), + PlayoutSetBackgroundVideo video => new( + "background-video", "scene", + $"configured;loop={video.LoopCount};infinite={video.LoopInfinite.ToString().ToLowerInvariant()}"), + _ => throw new LegacySceneDataException( + "The scene preview encountered an unsupported typed mutation.") + }; + + if (string.IsNullOrWhiteSpace(field.Name) || field.Name.Length > 128 || + field.Name.Any(char.IsControl) || field.Value.Length > MaximumValueLength || + field.Value.Any(character => character is '\0' or '\r' or '\n')) + { + throw new LegacySceneDataException("The scene preview contains an invalid field."); + } + + fields.Add(field); + } + + return Array.AsReadOnly(fields.ToArray()); + } + + private static string Points(IReadOnlyList points) + { + ArgumentNullException.ThrowIfNull(points); + var value = string.Join( + ";", + points.Select(point => $"{F(point.X)},{F(point.Y)},{F(point.Z)}")); + if (value.Length > MaximumValueLength) + { + throw new LegacySceneDataException("The scene preview path exceeds its bounded size."); + } + + return $"count={points.Count};{value}"; + } + + private static string F(float value) => + value.ToString("0.###", CultureInfo.InvariantCulture); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs new file mode 100644 index 0000000..d125084 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs @@ -0,0 +1,80 @@ +#nullable enable + +using System.Collections.ObjectModel; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Closed refresh intervals copied from MainForm.Show_PlayList's m_time assignments. +/// Browser input cannot select or alter these values. +/// +public static class LegacySceneRefreshPolicy +{ + public static TimeSpan RecurringInterval { get; } = TimeSpan.FromSeconds(3); + + private static readonly IReadOnlyDictionary Intervals = + new ReadOnlyDictionary(new Dictionary( + StringComparer.Ordinal) + { + ["5001"] = Seconds(2), + ["N5001"] = Seconds(2), + ["5006"] = Seconds(5), + ["5011"] = Seconds(5), + ["5016"] = Seconds(5), + ["50160"] = Seconds(30), + ["5023"] = Seconds(30), + ["5024"] = Seconds(60), + ["5025"] = Seconds(30), + ["5026"] = Seconds(8), + ["5029"] = Seconds(7), + ["5032"] = Seconds(5), + ["5037"] = Seconds(5), + ["5074"] = Seconds(7), + ["5076"] = Seconds(3_000_000), + ["5077"] = Seconds(7), + ["5078"] = Seconds(30), + ["5079"] = Seconds(3_000_000), + ["5080"] = Seconds(3_000_000), + ["5081"] = Seconds(3_000_000), + ["5082"] = Seconds(6), + ["5083"] = Seconds(60), + ["5084"] = Seconds(5), + ["5085"] = Seconds(30), + ["5086"] = Seconds(7), + ["50860"] = Seconds(7), + ["5087"] = Seconds(7), + ["5088"] = Seconds(7), + ["6001"] = Seconds(30), + ["6067"] = Seconds(30), + ["8001"] = Seconds(120), + ["8002"] = Seconds(120), + ["8003"] = Seconds(8), + ["8018"] = Seconds(5), + ["8032"] = Seconds(5), + ["8035"] = Seconds(8), + ["8040"] = Seconds(8), + ["8046"] = Seconds(8), + ["8051"] = Seconds(8), + ["8056"] = Seconds(8), + ["8061"] = Seconds(8), + ["8067"] = Seconds(30), + ["5068"] = Seconds(30), + ["5070"] = Seconds(30), + ["5072"] = Seconds(30) + }); + + public static int Count => Intervals.Count; + + public static bool TryGetInterval(string? cutCode, out TimeSpan interval) + { + if (string.IsNullOrWhiteSpace(cutCode)) + { + interval = default; + return false; + } + + return Intervals.TryGetValue(cutCode, out interval); + } + + private static TimeSpan Seconds(int seconds) => TimeSpan.FromSeconds(seconds); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRuntimeCoverage.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRuntimeCoverage.cs new file mode 100644 index 0000000..4beea27 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRuntimeCoverage.cs @@ -0,0 +1,177 @@ +#nullable enable + +using System.Collections.ObjectModel; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Immutable runtime-to-catalog coverage result. Missing aliases identify catalog aliases +/// with no router route; unexpected aliases identify routes with no reachable MainForm +/// builder. Conditional aliases may intentionally cover more than one builder. +/// +public sealed record LegacySceneRuntimeCoverageReport( + IReadOnlyList ReachableBuilderKeys, + IReadOnlyList UnreachableBuilderKeys, + IReadOnlyList ExpectedCutCodes, + IReadOnlyList RegisteredCutCodes, + IReadOnlyList MissingCutCodes, + IReadOnlyList UnexpectedCutCodes, + IReadOnlyList UncoveredBuilderKeys, + IReadOnlyDictionary> ConditionalAliasOwners) +{ + public bool IsComplete => + ReachableBuilderKeys.Count == LegacySceneRuntimeCoverage.ExpectedReachableBuilderCount && + ExpectedCutCodes.Count == LegacySceneRuntimeCoverage.ExpectedActiveAliasCount && + RegisteredCutCodes.Count == LegacySceneRuntimeCoverage.ExpectedActiveAliasCount && + MissingCutCodes.Count == 0 && + UnexpectedCutCodes.Count == 0 && + UncoveredBuilderKeys.Count == 0; +} + +/// +/// Validates that the application composition root has one closed router entry for every +/// active MainForm cut alias and no others. The catalog owns alias-to-builder semantics: +/// 8018/8032/5032 intentionally cover both s5032 and s8018, while s8086 has no MainForm +/// alias and must never be registered as an active runtime cut. +/// +public static class LegacySceneRuntimeCoverage +{ + public const int ExpectedReachableBuilderCount = 34; + public const int ExpectedActiveAliasCount = 45; + + private static readonly RuntimeCatalogContract Contract = CreateContract(); + + public static IReadOnlyList ExpectedCutCodes => Contract.ExpectedCutCodes; + + public static LegacySceneRuntimeCoverageReport Analyze( + LegacySceneDataSourceRouter router) + { + ArgumentNullException.ThrowIfNull(router); + var registered = Array.AsReadOnly( + router.CutCodes.OrderBy(code => code, StringComparer.Ordinal).ToArray()); + var registeredSet = registered.ToHashSet(StringComparer.Ordinal); + var expectedSet = Contract.ExpectedCutCodes.ToHashSet(StringComparer.Ordinal); + var missing = Array.AsReadOnly( + Contract.ExpectedCutCodes + .Where(code => !registeredSet.Contains(code)) + .ToArray()); + var unexpected = Array.AsReadOnly( + registered + .Where(code => !expectedSet.Contains(code)) + .ToArray()); + var uncovered = Array.AsReadOnly( + Contract.ReachableDefinitions + .Where(definition => definition.CutAliases.Any(alias => !registeredSet.Contains(alias))) + .Select(definition => definition.BuilderKey) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray()); + + return new LegacySceneRuntimeCoverageReport( + Contract.ReachableBuilderKeys, + Contract.UnreachableBuilderKeys, + Contract.ExpectedCutCodes, + registered, + missing, + unexpected, + uncovered, + Contract.ConditionalAliasOwners); + } + + public static LegacySceneRuntimeCoverageReport Validate( + LegacySceneDataSourceRouter router) + { + var report = Analyze(router); + if (!report.IsComplete) + { + throw new InvalidOperationException( + "The legacy scene-data router does not exactly cover the active MainForm aliases."); + } + + return report; + } + + private static RuntimeCatalogContract CreateContract() + { + var definitions = LegacySceneCatalog.Definitions; + var reachable = definitions + .Where(definition => definition.Reachability is + LegacySceneReachability.MainFormDispatch or + LegacySceneReachability.ConditionalMainFormDispatch) + .OrderBy(definition => definition.BuilderKey, StringComparer.Ordinal) + .ToArray(); + var unreachable = definitions + .Where(definition => definition.Reachability == + LegacySceneReachability.NoMainFormDispatch) + .OrderBy(definition => definition.BuilderKey, StringComparer.Ordinal) + .ToArray(); + var aliases = reachable + .SelectMany(definition => definition.CutAliases) + .Distinct(StringComparer.Ordinal) + .OrderBy(alias => alias, StringComparer.Ordinal) + .ToArray(); + + if (reachable.Length != ExpectedReachableBuilderCount || + unreachable.Length != 1 || + unreachable[0].BuilderKey != "s8086" || + unreachable[0].CutAliases.Count != 0 || + reachable.Any(definition => definition.CutAliases.Count == 0) || + aliases.Length != ExpectedActiveAliasCount || + aliases.Contains("8086", StringComparer.Ordinal)) + { + throw new InvalidOperationException( + "The legacy scene catalog does not match the runtime coverage baseline."); + } + + var conditionalDefinitions = reachable + .Where(definition => definition.Reachability == + LegacySceneReachability.ConditionalMainFormDispatch) + .ToArray(); + if (conditionalDefinitions.Length != 2 || + !conditionalDefinitions.Select(definition => definition.BuilderKey) + .ToHashSet(StringComparer.Ordinal) + .SetEquals(["s5032", "s8018"])) + { + throw new InvalidOperationException( + "The conditional legacy scene catalog contract is invalid."); + } + + var aliasOwners = reachable + .SelectMany(definition => definition.CutAliases.Select(alias => + (Alias: alias, definition.BuilderKey))) + .GroupBy(value => value.Alias, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)Array.AsReadOnly( + group.Select(value => value.BuilderKey) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray()), + StringComparer.Ordinal); + var sharedAliases = aliasOwners + .Where(pair => pair.Value.Count > 1) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var expectedSharedAliases = new HashSet( + ["5032", "8018", "8032"], + StringComparer.Ordinal); + if (!sharedAliases.Keys.ToHashSet(StringComparer.Ordinal).SetEquals(expectedSharedAliases) || + sharedAliases.Values.Any(owners => + !owners.ToHashSet(StringComparer.Ordinal).SetEquals(["s5032", "s8018"]))) + { + throw new InvalidOperationException( + "The shared conditional cut-alias contract is invalid."); + } + + return new RuntimeCatalogContract( + Array.AsReadOnly(reachable), + Array.AsReadOnly(reachable.Select(definition => definition.BuilderKey).ToArray()), + Array.AsReadOnly(unreachable.Select(definition => definition.BuilderKey).ToArray()), + Array.AsReadOnly(aliases), + new ReadOnlyDictionary>(sharedAliases)); + } + + private sealed record RuntimeCatalogContract( + IReadOnlyList ReachableDefinitions, + IReadOnlyList ReachableBuilderKeys, + IReadOnlyList UnreachableBuilderKeys, + IReadOnlyList ExpectedCutCodes, + IReadOnlyDictionary> ConditionalAliasOwners); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs new file mode 100644 index 0000000..d90ec13 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs @@ -0,0 +1,200 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed class S5076SceneDataLoader +{ + private const string Query = """ + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5 + FROM INPUT_PIE + WHERE STOCK_NAME = :stockName + """; + + private readonly IDataQueryExecutor _executor; + + public S5076SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + string stockName, + CancellationToken cancellationToken = default) + { + stockName = LegacySceneBuilderGuard.Text(stockName, nameof(stockName)); + var spec = new DataQuerySpec( + Query, + [new DataQueryParameter("stockName", stockName, DbType.String)]); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "INPUT_PIE", + spec, + cancellationToken).ConfigureAwait(false); + var row = SceneDataTableReader.SingleRow( + table, + "STOCK_NAME", "BASE_DATE", + "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"); + var slices = new RevenueSliceData?[5]; + for (var index = 0; index < slices.Length; index++) + { + var value = SceneDataTableReader.RequiredString(row, $"GUSUNG_{index + 1}", allowEmpty: true); + slices[index] = ParseSlice(value); + } + + return new S5076SceneData( + SceneDataTableReader.RequiredString(row, "STOCK_NAME"), + SceneDataTableReader.RequiredString(row, "BASE_DATE"), + slices); + } + + private static RevenueSliceData? ParseSlice(string value) + { + if (value.Length == 0) + { + return null; + } + + var parts = value.Split('_'); + if (parts.Length != 2 || parts[0].Length == 0) + { + throw new LegacySceneDataException("INPUT_PIE contains an invalid revenue slice."); + } + + var label = parts[0]; + var percentageText = parts[1]; + if (percentageText is "" or "-") + { + percentageText = "0"; + } + + if (!double.TryParse( + percentageText, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var percentage) || + !double.IsFinite(percentage)) + { + throw new LegacySceneDataException("INPUT_PIE contains an invalid revenue percentage."); + } + + return new RevenueSliceData(label, percentageText, percentage); + } +} + +public sealed class S5081SceneDataLoader +{ + private const string Query = """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6 + FROM INPUT_PROFIT + WHERE STOCK_NAME = :stockName + """; + + private readonly IDataQueryExecutor _executor; + + public S5081SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + string stockName, + CancellationToken cancellationToken = default) + { + stockName = LegacySceneBuilderGuard.Text(stockName, nameof(stockName)); + var spec = new DataQuerySpec( + Query, + [new DataQueryParameter("stockName", stockName, DbType.String)]); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "INPUT_PROFIT", + spec, + cancellationToken).ConfigureAwait(false); + var row = SceneDataTableReader.SingleRow( + table, + "STOCK_NAME", + "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"); + var quarters = new QuarterlyProfitData[6]; + for (var index = 0; index < quarters.Length; index++) + { + var value = SceneDataTableReader.RequiredString(row, $"GUSUNG_{index + 1}"); + quarters[index] = ParseQuarter(value); + } + + return new S5081SceneData( + SceneDataTableReader.RequiredString(row, "STOCK_NAME"), + quarters); + } + + private static QuarterlyProfitData ParseQuarter(string value) + { + var parts = value.Split('_'); + if (parts.Length != 2 || parts[0].Length == 0) + { + throw new LegacySceneDataException("INPUT_PROFIT contains an invalid quarter value."); + } + + var label = parts[0]; + var numberText = parts[1]; + if (numberText.Length == 0) + { + numberText = "0"; + } + + if (!int.TryParse( + numberText, + NumberStyles.Integer | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out var number)) + { + throw new LegacySceneDataException("INPUT_PROFIT contains an invalid profit value."); + } + + return new QuarterlyProfitData(label, number); + } +} + +internal static class SceneDataTableReader +{ + public static DataRow SingleRow(DataTable? table, params string[] requiredColumns) + { + if (table is null || table.Rows.Count != 1) + { + throw new LegacySceneDataException("The scene query must return exactly one row."); + } + + var available = table.Columns + .Cast() + .Select(column => column.ColumnName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (requiredColumns.Any(column => !available.Contains(column))) + { + throw new LegacySceneDataException("The scene query returned an unexpected schema."); + } + + return table.Rows[0]; + } + + public static string RequiredString( + DataRow row, + string columnName, + bool allowEmpty = false) + { + var value = row[columnName]; + if (value is null or DBNull) + { + if (allowEmpty) + { + return string.Empty; + } + + throw new LegacySceneDataException("The scene query returned a required null value."); + } + + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + return LegacySceneBuilderGuard.Text(text, columnName, allowEmpty); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/MarketPanelSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/MarketPanelSceneDataLoaders.cs new file mode 100644 index 0000000..a51c95e --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/MarketPanelSceneDataLoaders.cs @@ -0,0 +1,733 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed class S5016SceneDataLoader +{ + private readonly IDataQueryExecutor _executor; + + public S5016SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5016PanelTarget target, + CancellationToken cancellationToken = default) + { + if (!Enum.IsDefined(target)) + { + throw EquityPanelLoaderReader.InvalidRequest(); + } + + var spec = PanelQuoteQueryFactory.Create(target); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S5016_PANEL", + spec, + cancellationToken).ConfigureAwait(false); + var data = new S5016SceneData(target, PanelQuoteTableMapper.Map(table, 3)); + _ = new S5016SceneMutationBuilder().Build(data); + return data; + } +} + +public sealed class S50160SceneDataLoader +{ + private readonly IDataQueryExecutor _executor; + + public S50160SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S50160PanelTarget target, + CancellationToken cancellationToken = default) + { + if (!Enum.IsDefined(target)) + { + throw EquityPanelLoaderReader.InvalidRequest(); + } + + var spec = PanelQuoteQueryFactory.Create(target); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S50160_PANEL", + spec, + cancellationToken).ConfigureAwait(false); + var data = new S50160SceneData(target, PanelQuoteTableMapper.Map(table, 2)); + _ = new S50160SceneMutationBuilder().Build(data); + return data; + } +} + +internal static class PanelQuoteQueryFactory +{ + public static DataQuerySpec Create(S5016PanelTarget target) => + Create(Profile(target)); + + public static DataQuerySpec Create(S50160PanelTarget target) => + Create(Profile(target)); + + private static DataQuerySpec Create(PanelProfile profile) => profile.Source switch + { + PanelSource.WorldIndex => World(profile, "t_world_ix_eq_master", "t_world_ix_eq_sise", "b.f_sign"), + PanelSource.WorldFuture => World( + profile, + "t_world_future_master", + "t_world_future_sise", + "CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+' " + + "WHEN TO_NUMBER(b.f_diff) < 0 THEN '-' ELSE ' ' END"), + PanelSource.WorldForex => World( + profile, + "t_world_forex_master", + "t_world_forex_sise", + "CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+' " + + "WHEN TO_NUMBER(b.f_diff) < 0 THEN '-' ELSE ' ' END"), + PanelSource.DomesticExchange => DomesticExchange(profile), + PanelSource.MajorIndex200 => Major(forecast: false, futures: false), + PanelSource.MajorIndexFutures => Major(forecast: false, futures: true), + PanelSource.ExpectedIndex200 => Major(forecast: true, futures: false), + PanelSource.ExpectedIndexFutures => Major(forecast: true, futures: true), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + + private static DataQuerySpec World( + PanelProfile profile, + string masterTable, + string quoteTable, + string signalExpression) + { + var selects = profile.Symbols.Select((symbol, zeroBased) => + { + var index = zeroBased + 1; + return $""" + SELECT + {index} ORDINAL, + a.f_input_name NAME, + a.f_input_name PART_NAME, + ROUND(b.f_last, 4) PRICE, + ROUND(b.f_diff, 4) CHANGE, + ROUND(b.f_rate, 4) RATE, + {signalExpression} SIGNAL + FROM {masterTable} a, {quoteTable} b + WHERE a.f_symb = b.f_symb + AND a.f_symb = :symbol{index} + """; + }); + var sql = string.Join("\nUNION ALL\n", selects) + "\nORDER BY ORDINAL"; + var parameters = profile.Symbols + .Select((symbol, index) => new DataQueryParameter( + "symbol" + (index + 1), + symbol, + DbType.String)) + .ToArray(); + return EquityPanelLoaderReader.Spec(DataSourceKind.Oracle, sql, parameters); + } + + private static DataQuerySpec DomesticExchange(PanelProfile profile) + { + var selects = profile.Symbols.Select((symbol, zeroBased) => + { + var index = zeroBased + 1; + return $""" + SELECT + {index} ORDINAL, + :display{index} NAME, + CAST(NULL AS VARCHAR2(256)) PART_NAME, + a.f_curr_price PRICE, + a.f_net_chg CHANGE, + ROUND(a.f_net_chg / DECODE( + a.f_chg_type, + '+', (a.f_curr_price / 100) - (ABS(a.f_net_chg) / 100), + '-', (a.f_curr_price / 100) + (a.f_net_chg / 100), + 1), 2) RATE, + a.f_chg_type SIGNAL + FROM t_input_index a, t_input_code b + WHERE a.f_input_code = b.f_input_code + AND a.f_input_code = :symbol{index} + """; + }); + var sql = string.Join("\nUNION ALL\n", selects) + "\nORDER BY ORDINAL"; + var parameters = new List(profile.Symbols.Count * 2); + for (var index = 0; index < profile.Symbols.Count; index++) + { + parameters.Add(new DataQueryParameter( + "display" + (index + 1), + profile.DisplayNames![index], + DbType.String)); + parameters.Add(new DataQueryParameter( + "symbol" + (index + 1), + profile.Symbols[index], + DbType.String)); + } + + return EquityPanelLoaderReader.Spec( + DataSourceKind.Oracle, + sql, + parameters.ToArray()); + } + + private static DataQuerySpec Major(bool forecast, bool futures) + { + var firstTable = forecast ? "t_fo_index" : "t_index"; + var secondTable = forecast ? "T_KOSDAQ_FO_INDEX" : "T_KOSDAQ_INDEX"; + var thirdTable = forecast ? "T_200_FO_INDEX" : "T_200_INDEX"; + var third = futures + ? $""" + SELECT + 3 ORDINAL, + :display3 NAME, + CAST(NULL AS VARCHAR2(256)) PART_NAME, + part_idx PRICE, + part_chg CHANGE, + ROUND(part_chg / DECODE( + chg_type, + '+', (part_idx / 100) - (part_chg / 100), + '-', (part_idx / 100) + (part_chg / 100), + 1), 2) RATE, + chg_type SIGNAL + FROM ( + SELECT + DECODE( + a.{(forecast ? "f_fo_price" : "f_curr_price")}, + 0, + {(forecast ? "b.F_JUN_LAST_PRICE" : "b.F_JUN_LAST_PRICE / 100")}, + {(forecast ? "a.f_fo_price" : "a.f_curr_price / 100")}) part_idx, + DECODE( + a.{(forecast ? "f_fo_price" : "f_curr_price")}, + 0, + 0, + (a.{(forecast ? "f_fo_price" : "f_curr_price")} - + {(forecast ? "b.F_JUN_LAST_PRICE" : "b.F_JUN_LAST_PRICE * 100")}) / 100) part_chg, + DECODE( + a.{(forecast ? "f_fo_price" : "f_curr_price")}, + 0, + ' ', + DECODE(SIGN( + a.{(forecast ? "f_fo_price" : "f_curr_price")} - + {(forecast ? "b.F_JUN_LAST_PRICE" : "b.F_JUN_LAST_PRICE * 100")}), + 1, '+', -1, '-', 0, ' ')) chg_type + FROM t_sunmul_online a, t_sunmul_batch b + WHERE a.f_stock_code = b.f_stock_code + AND a.f_stock_seq = 1 + AND b.f_market_date = (SELECT MAX(OPEN_DAY) FROM v_open_day) + AND b.F_MONTH_GUBUN = '1' + ) + """ + : $""" + SELECT + 3 ORDINAL, + :display3 NAME, + CAST(NULL AS VARCHAR2(256)) PART_NAME, + f_part_idx / 100 PRICE, + f_part_chg / 100 CHANGE, + ROUND((f_part_chg / DECODE( + f_chg_type, + '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, + 1)) * 100, 2) RATE, + f_chg_type SIGNAL + FROM {thirdTable} + WHERE f_part_code = :partCode3 + """; + var sql = $""" + SELECT + 1 ORDINAL, + :display1 NAME, + CAST(NULL AS VARCHAR2(256)) PART_NAME, + f_part_idx / 100 PRICE, + f_part_chg / 100 CHANGE, + ROUND((f_part_chg / DECODE( + f_chg_type, + '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, + 1)) * 100, 2) RATE, + f_chg_type SIGNAL + FROM {firstTable} + WHERE f_part_code = :partCode1 + UNION ALL + SELECT + 2 ORDINAL, + :display2 NAME, + CAST(NULL AS VARCHAR2(256)) PART_NAME, + f_part_idx / 100 PRICE, + f_part_chg / 100 CHANGE, + ROUND((f_part_chg / DECODE( + f_chg_type, + '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, + 1)) * 100, 2) RATE, + f_chg_type SIGNAL + FROM {secondTable} + WHERE f_part_code = :partCode2 + UNION ALL + {third} + ORDER BY ORDINAL + """; + var parameters = new List + { + new("display1", "KOSPI", DbType.String), + new("partCode1", "001", DbType.String), + new("display2", "KOSQ", DbType.String), + new("partCode2", "001", DbType.String), + new("display3", futures ? "\uC120\uBB3C" : "KOSPI200", DbType.String) + }; + if (!futures) + { + parameters.Add(new DataQueryParameter("partCode3", "029", DbType.String)); + } + + return EquityPanelLoaderReader.Spec( + DataSourceKind.Oracle, + sql, + parameters.ToArray()); + } + + private static PanelProfile Profile(S5016PanelTarget target) => target switch + { + S5016PanelTarget.UnitedStatesIndices => WorldIndex("DJI@DJI", "NAS@IXIC", "SPI@SPX"), + S5016PanelTarget.GreaterChinaIndices => WorldIndex("HSI@HSI", "SHS@000001", "TWS@TI01"), + S5016PanelTarget.EuropeanIndices => WorldIndex("LNS@FTSE100", "PAS@CAC40", "XTR@DAX30"), + S5016PanelTarget.AsianIndices => WorldIndex("SHS@000001", "TWS@TI01", "NII@NI225"), + S5016PanelTarget.UnitedStatesTreasuries => WorldIndex("GVO@TR02Y", "GVO@TR03Y", "GVO@TR05Y"), + S5016PanelTarget.BondYields => WorldIndex("KIR@CMAA", "KIR@NB03Y", "KIR@CD91"), + S5016PanelTarget.Oil => WorldFuture("SPT@DU", "SPT@EB", "SPT@CL"), + S5016PanelTarget.Minerals => WorldFuture("COM@GC", "COM@SI", "LME@CDY"), + S5016PanelTarget.FoodMaterials => WorldFuture("CBT$CORN", "CBT@SOYBEAN", "CBT@WHEAT"), + S5016PanelTarget.OverseasExchangeRates => new( + PanelSource.WorldForex, + ["USDJPYCOMP", "EURUSDCOMP", "USDCNYCOMP"]), + S5016PanelTarget.DomesticExchangeRates => new( + PanelSource.DomesticExchange, + ["A09", "A10", "A91"], + ["\uC6D0/\uB2EC\uB7EC", "\uC6D0/\uC5D4", "\uC6D0/\uC720\uB85C"]), + S5016PanelTarget.AgricultureWheatCorn => WorldFuture("MK@WHEAT", "CBT$CORN", "CBT@WHEAT"), + S5016PanelTarget.AgricultureSoyRice => WorldFuture("CBT@SOYBEAN", "MK@BEAN", "MK@RICE"), + S5016PanelTarget.AgricultureCoffeeCocoaSugar => WorldFuture("NYB@KC", "MK@COCOA", "NYB@SB"), + S5016PanelTarget.AgricultureLivestock => WorldFuture("MK@LC", "MK@FC", "MK@PORK"), + S5016PanelTarget.RawMaterialsCopperIronNickel => WorldFuture("LME@CDY", "MK@IRON", "LME@NDY"), + S5016PanelTarget.RawMaterialsGasPlatinumPalladium => WorldFuture("NYM@NG", "NYM@PL", "NYM@PA"), + S5016PanelTarget.RawMaterialsLeadZincTin => WorldFuture("MK@PDA", "MK@ZDA", "LME@SDY"), + S5016PanelTarget.MajorKospiKosdaqKospi200 => new(PanelSource.MajorIndex200, []), + S5016PanelTarget.MajorKospiKosdaqFutures => new(PanelSource.MajorIndexFutures, []), + S5016PanelTarget.ExpectedKospiKosdaqKospi200 => new(PanelSource.ExpectedIndex200, []), + S5016PanelTarget.ExpectedKospiKosdaqFutures => new(PanelSource.ExpectedIndexFutures, []), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + + private static PanelProfile Profile(S50160PanelTarget target) => target switch + { + S50160PanelTarget.Cotton => WorldFuture("MK@COTTON", "NYB@CT"), + S50160PanelTarget.InternationalPreciousMetals => WorldFuture("COM@GC", "COM@SI"), + S50160PanelTarget.DomesticPreciousMetals => WorldFuture("MK@GC", "MK@SI"), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + + private static PanelProfile WorldIndex(params string[] symbols) => + new(PanelSource.WorldIndex, symbols); + + private static PanelProfile WorldFuture(params string[] symbols) => + new(PanelSource.WorldFuture, symbols); + + private enum PanelSource + { + WorldIndex, + WorldFuture, + WorldForex, + DomesticExchange, + MajorIndex200, + MajorIndexFutures, + ExpectedIndex200, + ExpectedIndexFutures + } + + private sealed record PanelProfile( + PanelSource Source, + IReadOnlyList Symbols, + IReadOnlyList? DisplayNames = null); +} + +internal static class PanelQuoteTableMapper +{ + private static readonly string[] Columns = + ["ORDINAL", "NAME", "PART_NAME", "PRICE", "CHANGE", "RATE", "SIGNAL"]; + + public static IReadOnlyList Map(DataTable? table, int expectedRows) + { + EquityPanelLoaderReader.ExactSchema(table, Columns); + if (table!.Rows.Count != expectedRows) + { + throw EquityPanelLoaderReader.InvalidResult(); + } + + var result = new LegacyPanelQuoteData[expectedRows]; + for (var index = 0; index < expectedRows; index++) + { + var row = table.Rows[index]; + if (EquityPanelLoaderReader.Int64(row, "ORDINAL") != index + 1) + { + throw EquityPanelLoaderReader.InvalidResult(); + } + + var change = EquityPanelLoaderReader.Decimal(row, "CHANGE"); + result[index] = new LegacyPanelQuoteData( + EquityPanelLoaderReader.Text(row, "NAME"), + row["PART_NAME"] is DBNull + ? null + : EquityPanelLoaderReader.Text(row, "PART_NAME"), + EquityPanelLoaderReader.Decimal(row, "PRICE"), + change, + EquityPanelLoaderReader.Decimal(row, "RATE"), + Signal(EquityPanelLoaderReader.Text(row, "SIGNAL", allowNull: true))); + } + + return result; + } + + private static LegacyPanelQuoteSignal Signal(string value) => value switch + { + "+" or "1" or "2" => LegacyPanelQuoteSignal.Positive, + "-" or "4" or "5" => LegacyPanelQuoteSignal.Negative, + "" or "3" => LegacyPanelQuoteSignal.Flat, + _ => throw EquityPanelLoaderReader.InvalidResult() + }; +} + +public sealed class S6001SceneDataLoader +{ + private static readonly string[] Columns = + ["TITLE", "PRICE", "CHANGE", "RATE", "SIGNAL"]; + + private readonly IDataQueryExecutor _executor; + + public S6001SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S6001QuoteTarget target, + CancellationToken cancellationToken = default) + { + if (!Enum.IsDefined(target)) + { + throw EquityPanelLoaderReader.InvalidRequest(); + } + + var profile = CommodityQuoteQueryFactory.S6001(target); + var spec = CommodityQuoteQueryFactory.Create(profile); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S6001_QUOTE", + spec, + cancellationToken).ConfigureAwait(false); + var row = EquityPanelLoaderReader.SingleRow(table, Columns); + var data = new S6001SceneData( + target, + EquityPanelLoaderReader.Text(row, "TITLE"), + EquityPanelLoaderReader.Decimal(row, "PRICE"), + EquityPanelLoaderReader.Decimal(row, "CHANGE"), + EquityPanelLoaderReader.Decimal(row, "RATE"), + EquityPanelLoaderReader.Direction(row, "SIGNAL")); + _ = new S6001SceneMutationBuilder().Build(data); + return data; + } +} + +public enum S8086DiagnosticTarget +{ + DubaiCrude, + BrentCrude, + WtiCrude, + Gold, + Silver, + Copper +} + +public sealed class S8086DiagnosticSceneDataLoader +{ + private static readonly string[] Columns = + ["TITLE", "PRICE", "CHANGE", "RATE", "SIGNAL"]; + + private readonly IDataQueryExecutor _executor; + + public S8086DiagnosticSceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S8086DiagnosticTarget target, + CancellationToken cancellationToken = default) + { + if (!Enum.IsDefined(target)) + { + throw EquityPanelLoaderReader.InvalidRequest(); + } + + var profile = CommodityQuoteQueryFactory.S8086(target); + var spec = CommodityQuoteQueryFactory.Create(profile); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S8086_DIAGNOSTIC", + spec, + cancellationToken).ConfigureAwait(false); + var row = EquityPanelLoaderReader.SingleRow(table, Columns); + var data = new S8086SceneData( + EquityPanelLoaderReader.Text(row, "TITLE"), + profile.Unit, + EquityPanelLoaderReader.Double(row, "PRICE"), + EquityPanelLoaderReader.Double(row, "CHANGE"), + EquityPanelLoaderReader.Double(row, "RATE"), + CommodityQuoteQueryFactory.Trend( + EquityPanelLoaderReader.Text(row, "SIGNAL", allowNull: true))); + _ = new S8086SceneMutationBuilder().Build(data); + return data; + } +} + +internal static class CommodityQuoteQueryFactory +{ + public static QuoteProfile S6001(S6001QuoteTarget target) => target switch + { + S6001QuoteTarget.DowJones => Index("DJI@DJI"), + S6001QuoteTarget.Nasdaq => Index("NAS@IXIC"), + S6001QuoteTarget.StandardAndPoor500 => Index("SPI@SPX"), + S6001QuoteTarget.Germany => Index("XTR@DAX30"), + S6001QuoteTarget.UnitedKingdom => Index("LNS@FTSE100"), + S6001QuoteTarget.France => Index("PAS@CAC40"), + S6001QuoteTarget.Japan => Index("NII@NI225"), + S6001QuoteTarget.China => Index("SHS@000001"), + S6001QuoteTarget.HongKong => Index("HSI@HSI"), + S6001QuoteTarget.Taiwan => Index("TWS@TI01"), + S6001QuoteTarget.Singapore => Index("SGI@STI"), + S6001QuoteTarget.Thailand => Index("THI@SET"), + S6001QuoteTarget.Philippines => Index("PHI@COMP"), + // Preserve the legacy source selections, including its Malaysia/Indonesia swap. + S6001QuoteTarget.Malaysia => Index("IDI@JKSE"), + S6001QuoteTarget.Indonesia => Index("MYI@KLSE"), + S6001QuoteTarget.DubaiCrude => Future("SPT@DU", CommodityUnit.DollarsPerBarrel), + S6001QuoteTarget.BrentCrude => Future("SPT@EB", CommodityUnit.DollarsPerBarrel), + S6001QuoteTarget.WtiCrude => Future("SPT@CL", CommodityUnit.DollarsPerBarrel), + S6001QuoteTarget.Gold => Future( + "COM@GC", + CommodityUnit.DollarsPerOunce, + absoluteValues: true), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + + public static QuoteProfile S8086(S8086DiagnosticTarget target) => target switch + { + S8086DiagnosticTarget.DubaiCrude => Future("SPT@DU", CommodityUnit.DollarsPerBarrel), + S8086DiagnosticTarget.BrentCrude => Future("SPT@EB", CommodityUnit.DollarsPerBarrel), + S8086DiagnosticTarget.WtiCrude => Future("SPT@CL", CommodityUnit.DollarsPerBarrel), + S8086DiagnosticTarget.Gold => Future( + "COM@GC", CommodityUnit.DollarsPerOunce, absoluteValues: true), + S8086DiagnosticTarget.Silver => Future( + "COM@SI", CommodityUnit.DollarsPerOunce, absoluteValues: true), + S8086DiagnosticTarget.Copper => Future( + "LME@CDY", CommodityUnit.DollarsPerOunce, absoluteValues: true), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + + public static DataQuerySpec Create(QuoteProfile profile) + { + var (master, quote, signal) = profile.Source switch + { + QuoteSource.WorldIndex => ( + "t_world_ix_eq_master", + "t_world_ix_eq_sise", + "b.f_sign"), + QuoteSource.WorldFuture => ( + "t_world_future_master", + "t_world_future_sise", + "CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+' " + + "WHEN TO_NUMBER(b.f_diff) < 0 THEN '-' ELSE ' ' END"), + _ => throw EquityPanelLoaderReader.InvalidRequest() + }; + var priceExpression = profile.AbsoluteValues + ? "ABS(ROUND(b.f_last, 4))" + : "ROUND(b.f_last, 4)"; + var changeExpression = profile.AbsoluteValues + ? "ABS(ROUND(b.f_diff, 4))" + : "ROUND(b.f_diff, 4)"; + var sql = $""" + SELECT + a.f_input_name TITLE, + {priceExpression} PRICE, + {changeExpression} CHANGE, + ROUND(b.f_rate, 4) RATE, + {signal} SIGNAL + FROM {master} a, {quote} b + WHERE a.f_symb = b.f_symb + AND a.f_symb = :symbol + """; + return EquityPanelLoaderReader.Spec( + DataSourceKind.Oracle, + sql, + new DataQueryParameter("symbol", profile.Symbol, DbType.String)); + } + + public static SceneMarketTrend Trend(string value) => value switch + { + "+" or "1" or "2" => SceneMarketTrend.Up, + "-" or "4" or "5" => SceneMarketTrend.Down, + "" or "3" => SceneMarketTrend.Flat, + _ => throw EquityPanelLoaderReader.InvalidResult() + }; + + private static QuoteProfile Index(string symbol) => + new(QuoteSource.WorldIndex, symbol, CommodityUnit.DollarsPerOunce, false); + + private static QuoteProfile Future( + string symbol, + CommodityUnit unit, + bool absoluteValues = false) => + new(QuoteSource.WorldFuture, symbol, unit, absoluteValues); + + internal enum QuoteSource + { + WorldIndex, + WorldFuture + } + + internal sealed record QuoteProfile( + QuoteSource Source, + string Symbol, + CommodityUnit Unit, + bool AbsoluteValues); +} + +public sealed class S8067SceneDataLoader +{ + private static readonly string[] Columns = + ["ORDINAL", "MARKET_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", "SIGNAL"]; + + private static readonly (string Code, string Symbol)[] Markets = + [ + ("CHINA", "SHS@000001"), + ("HONG_KONG", "HSI@HSI"), + ("JAPAN", "NII@NI225"), + ("TAIWAN", "TWS@TI01"), + ("DOW", "DJI@DJI"), + ("NASDAQ", "NAS@IXIC"), + ("SP", "SPI@SPX"), + ("UNITED_KINGDOM", "LNS@FTSE100"), + ("GERMANY", "XTR@DAX30"), + ("FRANCE", "PAS@CAC40") + ]; + + private readonly IDataQueryExecutor _executor; + + public S8067SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + var spec = CreateSpec(); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "S8067_WORLD_MAP", + spec, + cancellationToken).ConfigureAwait(false); + EquityPanelLoaderReader.ExactSchema(table, Columns); + if (table.Rows.Count != 11) + { + throw EquityPanelLoaderReader.InvalidResult(); + } + + var quotes = new WorldMarketQuoteData[11]; + for (var index = 0; index < quotes.Length; index++) + { + var row = table.Rows[index]; + if (EquityPanelLoaderReader.Int64(row, "ORDINAL") != index + 1) + { + throw EquityPanelLoaderReader.InvalidResult(); + } + + quotes[index] = new WorldMarketQuoteData( + Market(EquityPanelLoaderReader.Text(row, "MARKET_CODE")), + EquityPanelLoaderReader.Double(row, "CURRENT_PRICE"), + EquityPanelLoaderReader.Double(row, "NET_CHANGE"), + EquityPanelLoaderReader.Double(row, "RATE"), + CommodityQuoteQueryFactory.Trend( + EquityPanelLoaderReader.Text(row, "SIGNAL", allowNull: true))); + } + + var data = new S8067SceneData(quotes); + _ = new S8067SceneMutationBuilder().Build(data); + return data; + } + + private static DataQuerySpec CreateSpec() + { + const string korea = """ + SELECT + 1 ORDINAL, + 'KOREA' MARKET_CODE, + ROUND(f_part_idx / 100, 2) CURRENT_PRICE, + ROUND(f_part_chg / 100, 2) NET_CHANGE, + ROUND((f_part_chg / DECODE( + f_chg_type, + '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, + 1)) * 100, 2) RATE, + f_chg_type SIGNAL + FROM t_index + WHERE f_part_code = :partCode + """; + var world = Markets.Select((market, zeroBased) => + { + var ordinal = zeroBased + 2; + return $""" + SELECT + {ordinal} ORDINAL, + '{market.Code}' MARKET_CODE, + TO_NUMBER(f_last) CURRENT_PRICE, + TO_NUMBER(f_diff) NET_CHANGE, + TO_NUMBER(f_rate) RATE, + f_sign SIGNAL + FROM t_world_ix_eq_sise + WHERE f_symb = :symbol{ordinal} + """; + }); + var sql = korea + "\nUNION ALL\n" + + string.Join("\nUNION ALL\n", world) + "\nORDER BY ORDINAL"; + var parameters = new List + { + new("partCode", "001", DbType.String) + }; + parameters.AddRange(Markets.Select((market, zeroBased) => + new DataQueryParameter( + "symbol" + (zeroBased + 2), + market.Symbol, + DbType.String))); + return EquityPanelLoaderReader.Spec( + DataSourceKind.Oracle, + sql, + parameters.ToArray()); + } + + private static WorldMarket Market(string code) => code switch + { + "KOREA" => WorldMarket.Korea, + "CHINA" => WorldMarket.China, + "HONG_KONG" => WorldMarket.HongKong, + "JAPAN" => WorldMarket.Japan, + "TAIWAN" => WorldMarket.Taiwan, + "DOW" => WorldMarket.Dow, + "NASDAQ" => WorldMarket.Nasdaq, + "SP" => WorldMarket.StandardAndPoor, + "UNITED_KINGDOM" => WorldMarket.UnitedKingdom, + "GERMANY" => WorldMarket.Germany, + "FRANCE" => WorldMarket.France, + _ => throw EquityPanelLoaderReader.InvalidResult() + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs new file mode 100644 index 0000000..e42a783 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs @@ -0,0 +1,388 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum PagedQuoteBranch +{ + Domestic, + Nxt, + Theme, + NxtTheme, + Expert, + Vi +} + +public enum PagedQuoteHeaderStyle +{ + StandardWon, + IndustryPoints, + ExpertReturns +} + +public enum PagedQuoteTitleStyle +{ + Exact, + ExpectedExecution +} + +public enum NxtMarketSession +{ + NotApplicable, + PreMarket, + AfterMarket +} + +public sealed record PagedQuoteRowData( + string Name, + decimal PrimaryValue, + decimal SecondaryValue, + double Rate, + ScenePriceDirection Direction); + +/// +/// Trusted page input shared by the legacy 5-, 6-, and 12-row quote scenes. +/// PageNumberOneBased follows ScenePaging; Rows always contains the complete logical result. +/// +public sealed record PagedQuoteSceneData( + PagedQuoteBranch Branch, + PagedQuoteHeaderStyle HeaderStyle, + PagedQuoteTitleStyle TitleStyle, + NxtMarketSession NxtSession, + string Title, + IReadOnlyList Rows, + int PageNumberOneBased) : ILegacySceneData; + +public sealed class S5074SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5074"; + + public IReadOnlyList Build(PagedQuoteSceneData data) => + PagedQuoteMutations.Build(data, ScenePageSize.Five, useWideRateVariants: false); +} + +public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5077"; + + public IReadOnlyList Build(PagedQuoteSceneData data) => + PagedQuoteMutations.Build(data, ScenePageSize.Six, useWideRateVariants: true); +} + +public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5088"; + + public IReadOnlyList Build(PagedQuoteSceneData data) => + PagedQuoteMutations.Build(data, ScenePageSize.Twelve, useWideRateVariants: true); +} + +internal static class PagedQuoteMutations +{ + private static readonly string[] DirectionNames = + ["upup", "up", "flat", "down", "downdown"]; + + public static IReadOnlyList Build( + PagedQuoteSceneData data, + ScenePageSize pageSize, + bool useWideRateVariants) + { + ArgumentNullException.ThrowIfNull(data); + var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); + ValidatePresentation(data); + var rows = LegacySceneBuilderGuard.NotNull(data.Rows, nameof(data.Rows)); + var windowResult = ScenePaging.GetWindow( + rows.Count, + pageSize, + data.PageNumberOneBased); + if (!windowResult.IsValid || windowResult.Window is null) + { + throw new LegacySceneDataException( + "The requested quote page is outside the accessible 20-page range."); + } + + var window = windowResult.Window; + var selectedRows = new PagedQuoteRowData[window.ItemCount]; + for (var index = 0; index < window.ItemCount; index++) + { + var sourceIndex = window.OffsetZeroBased + index; + var row = LegacySceneBuilderGuard.NotNull( + rows[sourceIndex], + $"Rows[{sourceIndex}]"); + if (!double.IsFinite(row.Rate)) + { + throw new LegacySceneDataException( + $"Scene data field 'Rows[{sourceIndex}].Rate' must be finite."); + } + + if (data.HeaderStyle != PagedQuoteHeaderStyle.IndustryPoints && + (decimal.Truncate(row.PrimaryValue) != row.PrimaryValue || + decimal.Truncate(row.SecondaryValue) != row.SecondaryValue)) + { + throw new LegacySceneDataException( + $"Scene data field 'Rows[{sourceIndex}]' requires whole-won values."); + } + + selectedRows[index] = row with + { + Name = LegacySceneBuilderGuard.Text( + row.Name, + $"Rows[{sourceIndex}].Name") + }; + } + + var size = (int)pageSize; + var estimatedPerRow = useWideRateVariants ? 27 : 19; + var mutations = new List(14 + (size * estimatedPerRow)) + { + // Every legacy constructor hides the market mark before entering its branch. + new PlayoutSetVisible("mart", false) + }; + for (var slot = 1; slot <= size; slot++) + { + mutations.Add(new PlayoutSetVisible("row" + slot, false)); + } + + mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle))); + AddMarketMark(mutations, data.Branch, data.NxtSession); + AddHeader(mutations, data.HeaderStyle); + + for (var slot = 1; slot <= size; slot++) + { + if (slot <= selectedRows.Length) + { + AddPopulatedRow( + mutations, + slot, + selectedRows[slot - 1], + useWideRateVariants, + usePointValues: data.HeaderStyle == PagedQuoteHeaderStyle.IndustryPoints); + } + else + { + AddClearedRow(mutations, slot, useWideRateVariants); + } + } + + return mutations; + } + + private static void ValidatePresentation(PagedQuoteSceneData data) + { + if (!Enum.IsDefined(data.Branch) || !Enum.IsDefined(data.HeaderStyle) || + !Enum.IsDefined(data.TitleStyle) || !Enum.IsDefined(data.NxtSession)) + { + throw new LegacySceneDataException("Unknown paged quote presentation value."); + } + + var isNxt = data.Branch is PagedQuoteBranch.Nxt or PagedQuoteBranch.NxtTheme; + if (isNxt != (data.NxtSession != NxtMarketSession.NotApplicable)) + { + throw new LegacySceneDataException( + "Only NXT branches can select a pre-market or after-market mark."); + } + + if ((data.Branch == PagedQuoteBranch.Expert) != + (data.HeaderStyle == PagedQuoteHeaderStyle.ExpertReturns)) + { + throw new LegacySceneDataException( + "The expert branch requires the expert return columns."); + } + + if (data.HeaderStyle == PagedQuoteHeaderStyle.IndustryPoints && + data.Branch != PagedQuoteBranch.Domestic) + { + throw new LegacySceneDataException( + "Industry point columns are available only to the domestic branch."); + } + } + + private static string BuildTitle(string title, PagedQuoteTitleStyle style) => style switch + { + PagedQuoteTitleStyle.Exact => title, + PagedQuoteTitleStyle.ExpectedExecution => title + " \uC608\uC0C1 \uCCB4\uACB0\uAC00", + _ => throw new LegacySceneDataException("Unknown paged quote title style.") + }; + + private static void AddMarketMark( + ICollection mutations, + PagedQuoteBranch branch, + NxtMarketSession session) + { + if (branch is not (PagedQuoteBranch.Nxt or PagedQuoteBranch.NxtTheme)) + { + mutations.Add(new PlayoutSetVisible("mart", false)); + return; + } + + var relativeAsset = session switch + { + NxtMarketSession.PreMarket => "Images\\\uD504\uB9AC\uB9C8\uCF13.png", + NxtMarketSession.AfterMarket => "Images\\\uC560\uD504\uD130\uB9C8\uCF13.png", + _ => throw new LegacySceneDataException("An NXT market session is required.") + }; + mutations.Add(new PlayoutSetAssetValue("mart", relativeAsset)); + mutations.Add(new PlayoutSetVisible("mart", true)); + } + + private static void AddHeader( + ICollection mutations, + PagedQuoteHeaderStyle style) + { + var values = style switch + { + PagedQuoteHeaderStyle.StandardWon => + (Unit: "\uB2E8\uC704 : \uC6D0", Column1: "\uD604\uC7AC\uAC00", Column2: "\uB4F1\uB77D\uAC00", Column3: "\uB4F1\uB77D\uB960"), + PagedQuoteHeaderStyle.IndustryPoints => + (Unit: "\uB2E8\uC704 : p", Column1: "\uD604\uC7AC", Column2: "\uB4F1\uB77D", Column3: "\uB4F1\uB77D\uB960"), + PagedQuoteHeaderStyle.ExpertReturns => + (Unit: "\uB2E8\uC704 : \uC6D0", Column1: "\uB9E4\uC218\uAC00", Column2: "\uD604\uC7AC\uAC00", Column3: "\uC218\uC775\uB960"), + _ => throw new LegacySceneDataException("Unknown paged quote header style.") + }; + mutations.Add(new PlayoutSetValue("unit", values.Unit)); + mutations.Add(new PlayoutSetValue("column1", values.Column1)); + mutations.Add(new PlayoutSetValue("column2", values.Column2)); + mutations.Add(new PlayoutSetValue("column3", values.Column3)); + } + + private static void AddPopulatedRow( + ICollection mutations, + int slot, + PagedQuoteRowData row, + bool useWideRateVariants, + bool usePointValues) + { + mutations.Add(new PlayoutSetVisible("row" + slot, true)); + mutations.Add(new PlayoutSetValue("title" + slot, row.Name)); + mutations.Add(new PlayoutSetValue( + "price" + slot, + FormatNumber(row.PrimaryValue, usePointValues))); + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue( + $"changePrice{slot}_{variant}", + FormatNumber(row.SecondaryValue, usePointValues))); + } + + var rate = row.Rate.ToString("##0.00", CultureInfo.InvariantCulture); + if (useWideRateVariants) + { + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"rate{slot}_{variant}", rate)); + } + } + else + { + mutations.Add(new PlayoutSetValue("rate" + slot, rate)); + } + + AddDirectionHidden(mutations, slot); + var selectedVariant = row.Direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1, + ScenePriceDirection.Flat or ScenePriceDirection.Unknown => 2, + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3, + _ => throw new LegacySceneDataException("Unknown price direction.") + }; + AddVariantVisibility(mutations, slot, selectedVariant, useWideRateVariants); + + var activeDirection = row.Direction switch + { + ScenePriceDirection.LimitUp => "upup", + ScenePriceDirection.Up => "up", + ScenePriceDirection.Flat or ScenePriceDirection.Unknown => "flat", + ScenePriceDirection.Down => "down", + ScenePriceDirection.LimitDown => "downdown", + _ => throw new LegacySceneDataException("Unknown price direction.") + }; + mutations.Add(new PlayoutSetVisible(activeDirection + slot, true)); + } + + private static void AddClearedRow( + ICollection mutations, + int slot, + bool useWideRateVariants) + { + // The original hides all row groups first. Explicit value/variant clearing is the + // approved partial-page correction required for in-place GetPlayingScene updates. + mutations.Add(new PlayoutSetVisible("row" + slot, false)); + mutations.Add(new PlayoutSetValue("title" + slot, string.Empty)); + mutations.Add(new PlayoutSetValue("price" + slot, string.Empty)); + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"changePrice{slot}_{variant}", string.Empty)); + } + + if (useWideRateVariants) + { + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"rate{slot}_{variant}", string.Empty)); + } + } + else + { + mutations.Add(new PlayoutSetValue("rate" + slot, string.Empty)); + } + + AddDirectionHidden(mutations, slot); + AddVariantVisibility(mutations, slot, selectedVariant: 0, useWideRateVariants); + } + + private static void AddDirectionHidden( + ICollection mutations, + int slot) + { + foreach (var name in DirectionNames) + { + mutations.Add(new PlayoutSetVisible(name + slot, false)); + } + } + + private static void AddVariantVisibility( + ICollection mutations, + int slot, + int selectedVariant, + bool useWideRateVariants) + { + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + $"bg{slot}_{variant}", + variant == selectedVariant)); + } + + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + $"changePrice{slot}_{variant}", + variant == selectedVariant)); + } + + if (!useWideRateVariants) + { + return; + } + + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + $"rate{slot}_{variant}", + variant == selectedVariant)); + } + + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetVisible( + $"percent{slot}_{variant}", + variant == selectedVariant)); + } + } + + private static string FormatNumber(decimal value, bool usePointValues) => + value.ToString( + usePointValues ? "#,##0.00" : "#,##0", + CultureInfo.InvariantCulture); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs new file mode 100644 index 0000000..9c0e09a --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs @@ -0,0 +1,1138 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum PagedQuoteMarket +{ + Kospi, + Kosdaq +} + +public enum DomesticPagedQuoteList +{ + ExpectedMarketCapitalization, + MarketCapitalization, + Industry, + AfterHoursGainRate, + AfterHoursLossRate, + GainRate, + LossRate, + FiftyTwoWeekHigh, + FiftyTwoWeekLow, + Kospi200, + Etf +} + +public enum NxtPagedQuoteList +{ + MarketCapitalization, + AfterHoursGainRate, + AfterHoursLossRate, + GainRate, + LossRate, + Volume +} + +public enum PagedQuoteThemeSort +{ + InputOrder, + GainRateDescending, + GainRateAscending +} + +public enum PagedQuoteThemeValueKind +{ + Current, + ExpectedExecution +} + +/// +/// Closed request family for the six mutually exclusive legacy s5074/s5077/s5088 +/// data branches. Page numbers are one-based, matching ScenePaging and PageN. +/// +public abstract record PagedQuoteLoadRequest +{ + private protected PagedQuoteLoadRequest(int pageNumberOneBased) + { + PageNumberOneBased = pageNumberOneBased; + } + + public int PageNumberOneBased { get; } +} + +public sealed record DomesticPagedQuoteLoadRequest( + PagedQuoteMarket Market, + DomesticPagedQuoteList List, + DateOnly AsOfDate, + int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); + +public sealed record NxtPagedQuoteLoadRequest( + PagedQuoteMarket Market, + NxtPagedQuoteList List, + NxtMarketSession Session, + int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); + +public sealed record ThemePagedQuoteLoadRequest( + string ThemeName, + PagedQuoteThemeSort Sort, + PagedQuoteThemeValueKind ValueKind, + int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); + +public sealed record NxtThemePagedQuoteLoadRequest( + string ThemeName, + PagedQuoteThemeSort Sort, + NxtMarketSession Session, + int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); + +public sealed record ExpertPagedQuoteLoadRequest( + string ExpertName, + int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); + +public sealed record ViPagedQuoteLoadRequest( + IReadOnlyList StockNames, + int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); + +public sealed class S5074SceneDataLoader +{ + private readonly PagedQuoteSceneDataLoaderCore _core; + + public S5074SceneDataLoader(IDataQueryExecutor executor) + { + _core = new PagedQuoteSceneDataLoaderCore( + executor, + ScenePageSize.Five, + "SCENE_5074_PAGED_QUOTES"); + } + + public Task LoadAsync( + PagedQuoteLoadRequest request, + CancellationToken cancellationToken = default) => + _core.LoadAsync(request, cancellationToken); +} + +public sealed class S5077SceneDataLoader +{ + private readonly PagedQuoteSceneDataLoaderCore _core; + + public S5077SceneDataLoader(IDataQueryExecutor executor) + { + _core = new PagedQuoteSceneDataLoaderCore( + executor, + ScenePageSize.Six, + "SCENE_5077_PAGED_QUOTES"); + } + + public Task LoadAsync( + PagedQuoteLoadRequest request, + CancellationToken cancellationToken = default) => + _core.LoadAsync(request, cancellationToken); +} + +public sealed class S5088SceneDataLoader +{ + private readonly PagedQuoteSceneDataLoaderCore _core; + + public S5088SceneDataLoader(IDataQueryExecutor executor) + { + _core = new PagedQuoteSceneDataLoaderCore( + executor, + ScenePageSize.Twelve, + "SCENE_5088_PAGED_QUOTES"); + } + + public Task LoadAsync( + PagedQuoteLoadRequest request, + CancellationToken cancellationToken = default) => + _core.LoadAsync(request, cancellationToken); +} + +internal sealed class PagedQuoteSceneDataLoaderCore +{ + private static readonly string[] ResultColumns = + ["NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION"]; + + private readonly IDataQueryExecutor _executor; + private readonly ScenePageSize _pageSize; + private readonly string _tableName; + + public PagedQuoteSceneDataLoaderCore( + IDataQueryExecutor executor, + ScenePageSize pageSize, + string tableName) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _pageSize = pageSize; + _tableName = tableName; + } + + public async Task LoadAsync( + PagedQuoteLoadRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (request.PageNumberOneBased is < 1 or > ScenePaging.MaximumPageCount) + { + throw new LegacySceneDataException( + "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 table = await _executor.ExecuteAsync( + plan.Source, + _tableName, + plan.Query, + cancellationToken).ConfigureAwait(false); + var rows = MapRows(table, maximumRows, plan.HeaderStyle); + var window = ScenePaging.GetWindow( + rows.Count, + _pageSize, + request.PageNumberOneBased); + if (!window.IsValid) + { + throw new LegacySceneDataException( + "The requested paged quote page is outside the returned result."); + } + + return new PagedQuoteSceneData( + plan.Branch, + plan.HeaderStyle, + plan.TitleStyle, + plan.Session, + plan.Title, + rows, + request.PageNumberOneBased); + } + + private static IReadOnlyList MapRows( + DataTable? table, + int maximumRows, + PagedQuoteHeaderStyle headerStyle) + { + if (table is null || table.Rows.Count is < 1 || table.Rows.Count > maximumRows) + { + throw new LegacySceneDataException( + "The paged quote query returned an invalid row count."); + } + + var actualColumns = table.Columns + .Cast() + .Select(column => column.ColumnName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (table.Columns.Count != ResultColumns.Length || + ResultColumns.Any(column => !actualColumns.Contains(column))) + { + throw new LegacySceneDataException( + "The paged quote query returned an unexpected schema."); + } + + var result = new PagedQuoteRowData[table.Rows.Count]; + for (var index = 0; index < table.Rows.Count; index++) + { + var row = table.Rows[index]; + var mapped = new PagedQuoteRowData( + RequiredText(row, "NAME"), + RequiredDecimal(row, "PRIMARY_VALUE"), + RequiredDecimal(row, "SECONDARY_VALUE"), + RequiredRate(row, "RATE"), + RequiredDirection(row, "DIRECTION")); + if (headerStyle != PagedQuoteHeaderStyle.IndustryPoints && + (decimal.Truncate(mapped.PrimaryValue) != mapped.PrimaryValue || + decimal.Truncate(mapped.SecondaryValue) != mapped.SecondaryValue)) + { + throw InvalidValue(); + } + + result[index] = mapped; + } + + return result; + } + + private static string RequiredText(DataRow row, string column) + { + var value = RequiredValue(row, column); + try + { + return LegacySceneBuilderGuard.Text( + Convert.ToString(value, CultureInfo.InvariantCulture), + column); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException) + { + throw InvalidValue(); + } + } + + private static decimal RequiredDecimal(DataRow row, string column) + { + try + { + return Convert.ToDecimal( + RequiredValue(row, column), + CultureInfo.InvariantCulture); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidValue(); + } + } + + private static double RequiredRate(DataRow row, string column) + { + double value; + try + { + value = Convert.ToDouble( + RequiredValue(row, column), + CultureInfo.InvariantCulture); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidValue(); + } + + return double.IsFinite(value) ? value : throw InvalidValue(); + } + + private static ScenePriceDirection RequiredDirection(DataRow row, string column) + { + string? value; + try + { + value = Convert.ToString( + RequiredValue(row, column), + CultureInfo.InvariantCulture)?.Trim(); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException) + { + throw InvalidValue(); + } + + return value switch + { + "1" or "\uC0C1\uD55C" => ScenePriceDirection.LimitUp, + "2" or "+" or "\uC0C1\uC2B9" => ScenePriceDirection.Up, + "3" or "" or "\uBCF4\uD569" => ScenePriceDirection.Flat, + "5" or "-" or "\uD558\uB77D" => ScenePriceDirection.Down, + "4" or "\uD558\uD55C" => ScenePriceDirection.LimitDown, + _ => throw InvalidValue() + }; + } + + private static object RequiredValue(DataRow row, string column) => + row[column] is { } value && value is not DBNull + ? value + : throw InvalidValue(); + + private static LegacySceneDataException InvalidValue() => + new("The paged quote query returned an invalid required value."); +} + +internal sealed record PagedQuoteQueryPlan( + DataSourceKind Source, + DataQuerySpec Query, + PagedQuoteBranch Branch, + PagedQuoteHeaderStyle HeaderStyle, + PagedQuoteTitleStyle TitleStyle, + NxtMarketSession Session, + string Title); + +internal static class PagedQuoteQueryFactory +{ + public static PagedQuoteQueryPlan Create( + PagedQuoteLoadRequest request, + int maximumRows) => request switch + { + DomesticPagedQuoteLoadRequest value => Domestic(value, maximumRows), + NxtPagedQuoteLoadRequest value => Nxt(value, maximumRows), + ThemePagedQuoteLoadRequest value => Theme(value, maximumRows), + NxtThemePagedQuoteLoadRequest value => NxtTheme(value, maximumRows), + ExpertPagedQuoteLoadRequest value => Expert(value, maximumRows), + ViPagedQuoteLoadRequest value => Vi(value, maximumRows), + _ => throw new LegacySceneDataException("Unknown paged quote request type.") + }; + + private static PagedQuoteQueryPlan Domestic( + DomesticPagedQuoteLoadRequest request, + int maximumRows) + { + ValidateMarket(request.Market); + if (!Enum.IsDefined(request.List)) + { + throw InvalidRequest(); + } + + if ((request.List is DomesticPagedQuoteList.Kospi200 or DomesticPagedQuoteList.Etf) && + request.Market != PagedQuoteMarket.Kospi) + { + throw InvalidRequest(); + } + + var parameters = new List(); + var query = BuildDomesticQuery(request, parameters); + parameters.Add(new DataQueryParameter("maxRows", maximumRows, DbType.Int32)); + var header = request.List == DomesticPagedQuoteList.Industry + ? PagedQuoteHeaderStyle.IndustryPoints + : PagedQuoteHeaderStyle.StandardWon; + return new PagedQuoteQueryPlan( + DataSourceKind.Oracle, + new DataQuerySpec(query, parameters), + PagedQuoteBranch.Domestic, + header, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + BuildDomesticTitle(request.Market, request.List)); + } + + private static PagedQuoteQueryPlan Nxt( + NxtPagedQuoteLoadRequest request, + int maximumRows) + { + ValidateMarket(request.Market); + ValidateNxtSession(request.Session); + if (!Enum.IsDefined(request.List)) + { + throw InvalidRequest(); + } + + return new PagedQuoteQueryPlan( + DataSourceKind.MariaDb, + new DataQuerySpec( + BuildNxtQuery(request), + [new DataQueryParameter("maxRows", maximumRows, DbType.Int32)]), + PagedQuoteBranch.Nxt, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + request.Session, + BuildNxtTitle(request.Market, request.List)); + } + + private static PagedQuoteQueryPlan Theme( + ThemePagedQuoteLoadRequest request, + int maximumRows) + { + var title = RequiredSelection(request.ThemeName); + ValidateThemeSort(request.Sort); + if (!Enum.IsDefined(request.ValueKind)) + { + throw InvalidRequest(); + } + + return new PagedQuoteQueryPlan( + DataSourceKind.Oracle, + new DataQuerySpec( + BuildThemeQuery(request.Sort, request.ValueKind), + [ + new DataQueryParameter("themeTitle", title, DbType.String), + new DataQueryParameter("maxRows", maximumRows, DbType.Int32) + ]), + PagedQuoteBranch.Theme, + PagedQuoteHeaderStyle.StandardWon, + request.ValueKind == PagedQuoteThemeValueKind.ExpectedExecution + ? PagedQuoteTitleStyle.ExpectedExecution + : PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + title); + } + + private static PagedQuoteQueryPlan NxtTheme( + NxtThemePagedQuoteLoadRequest request, + int maximumRows) + { + var title = RequiredSelection(request.ThemeName) + .Replace("(NXT)", string.Empty, StringComparison.Ordinal) + .Trim(); + title = RequiredSelection(title); + ValidateThemeSort(request.Sort); + ValidateNxtSession(request.Session); + return new PagedQuoteQueryPlan( + DataSourceKind.MariaDb, + new DataQuerySpec( + BuildNxtThemeQuery(request.Sort), + [ + new DataQueryParameter("themeTitle", title, DbType.String), + new DataQueryParameter("maxRows", maximumRows, DbType.Int32) + ]), + PagedQuoteBranch.NxtTheme, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + request.Session, + title); + } + + private static PagedQuoteQueryPlan Expert( + ExpertPagedQuoteLoadRequest request, + int maximumRows) + { + var expertName = RequiredSelection(request.ExpertName); + return new PagedQuoteQueryPlan( + DataSourceKind.Oracle, + new DataQuerySpec( + ExpertQuery, + [ + new DataQueryParameter("expertName", expertName, DbType.String), + new DataQueryParameter("maxRows", maximumRows, DbType.Int32) + ]), + PagedQuoteBranch.Expert, + PagedQuoteHeaderStyle.ExpertReturns, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + expertName); + } + + private static PagedQuoteQueryPlan Vi( + ViPagedQuoteLoadRequest request, + int maximumRows) + { + if (request.StockNames is null || request.StockNames.Count is < 1 || + request.StockNames.Count > maximumRows) + { + throw InvalidRequest(); + } + + var names = request.StockNames + .Select(RequiredSelection) + .ToArray(); + var parameters = names + .Select((name, index) => new DataQueryParameter( + "stockName" + index.ToString(CultureInfo.InvariantCulture), + name, + DbType.String)) + .ToList(); + parameters.Add(new DataQueryParameter("maxRows", maximumRows, DbType.Int32)); + return new PagedQuoteQueryPlan( + DataSourceKind.Oracle, + new DataQuerySpec(BuildViQuery(names.Length), parameters), + PagedQuoteBranch.Vi, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "\u0056\u0049 \uBC1C\uB3D9"); + } + + private static string RequiredSelection(string? value) => + LegacySceneBuilderGuard.Text(value, "Selection"); + + private static void ValidateMarket(PagedQuoteMarket market) + { + if (!Enum.IsDefined(market)) + { + throw InvalidRequest(); + } + } + + private static void ValidateThemeSort(PagedQuoteThemeSort sort) + { + if (!Enum.IsDefined(sort)) + { + throw InvalidRequest(); + } + } + + private static void ValidateNxtSession(NxtMarketSession session) + { + if (session is not (NxtMarketSession.PreMarket or NxtMarketSession.AfterMarket)) + { + throw InvalidRequest(); + } + } + + private static LegacySceneDataException InvalidRequest() => + new("The paged quote request contains an invalid closed selection."); + + private static string BuildDomesticQuery( + DomesticPagedQuoteLoadRequest request, + ICollection parameters) + { + var online = request.Market == PagedQuoteMarket.Kospi + ? "t_online1" + : "t_kosdaq_online1"; + var stock = request.Market == PagedQuoteMarket.Kospi + ? "t_stock" + : "t_kosdaq_stock"; + var overtime = request.Market == PagedQuoteMarket.Kospi + ? "t_overtime_sise" + : "t_kosdaq_overtime_sise"; + + string ranked = request.List switch + { + DomesticPagedQuoteList.GainRate => StandardDomesticRanked( + online, + stock, + "AND a.f_chg_type IN ('1', '2')", + "RATE DESC"), + DomesticPagedQuoteList.LossRate => StandardDomesticRanked( + online, + stock, + "AND a.f_chg_type IN ('4', '5')", + "RATE"), + DomesticPagedQuoteList.MarketCapitalization => $""" + SELECT DISTINCT b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION, + b.f_list_num * a.f_curr_price SORT_VALUE + FROM {online} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF') + AND a.f_curr_price <> 0 + ORDER BY SORT_VALUE DESC + """, + DomesticPagedQuoteList.ExpectedMarketCapitalization => + ExpectedCapitalRanked(request.Market), + DomesticPagedQuoteList.Industry => IndustryRanked(request.Market), + DomesticPagedQuoteList.AfterHoursGainRate => OvertimeRanked( + overtime, + stock, + "AND a.f_chg_type IN ('1', '2')", + "RATE DESC"), + DomesticPagedQuoteList.AfterHoursLossRate => OvertimeRanked( + overtime, + stock, + "AND a.f_chg_type IN ('4', '5')", + "RATE"), + DomesticPagedQuoteList.FiftyTwoWeekHigh => FiftyTwoWeekRanked( + request, + parameters, + online, + stock, + high: true), + DomesticPagedQuoteList.FiftyTwoWeekLow => FiftyTwoWeekRanked( + request, + parameters, + online, + stock, + high: false), + DomesticPagedQuoteList.Kospi200 => $""" + SELECT DISTINCT b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION, + b.f_list_num * a.f_curr_price SORT_VALUE + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_kospi200_gubun <> '0' + AND a.f_curr_price <> 0 + ORDER BY SORT_VALUE DESC + """, + DomesticPagedQuoteList.Etf => $""" + SELECT DISTINCT b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION, + b.f_list_num * a.f_curr_price SORT_VALUE + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_sect_code = 'EF' + AND a.f_curr_price <> 0 + ORDER BY SORT_VALUE DESC + """, + _ => throw InvalidRequest() + }; + + return $""" + SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION + FROM ( + {ranked} + ) + WHERE ROWNUM <= :maxRows + """; + } + + private static string StandardDomesticRanked( + string online, + string stock, + string extraPredicate, + string orderBy) => $""" + SELECT DISTINCT b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION + FROM {online} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF') + AND a.f_curr_price <> 0 + {extraPredicate} + ORDER BY {orderBy} + """; + + private static string ExpectedCapitalRanked(PagedQuoteMarket market) + { + var call = market == PagedQuoteMarket.Kospi + ? "t_online1_call" + : "t_kosdaq_online1_call"; + var stock = market == PagedQuoteMarket.Kospi + ? "t_stock" + : "t_kosdaq_stock"; + var batch = market == PagedQuoteMarket.Kospi + ? "t_batch_day" + : "t_kosdaq_batch_day"; + return $""" + SELECT DISTINCT b.f_stock_wanname NAME, + a.f_fore_price PRIMARY_VALUE, + ABS(a.f_fore_price - c.f_final_price) SECONDARY_VALUE, + ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE, + CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '2' + WHEN a.f_fore_price - c.f_final_price < 0 THEN '5' + ELSE '3' END DIRECTION, + b.f_list_num * a.f_fore_price SORT_VALUE + FROM {call} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + JOIN {batch} c ON a.f_stock_code = c.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF') + AND a.f_fore_price <> 0 + AND c.f_final_price <> 0 + ORDER BY SORT_VALUE DESC + """; + } + + private static string IndustryRanked(PagedQuoteMarket market) + { + var index = market == PagedQuoteMarket.Kospi + ? "t_index" + : "t_kosdaq_index"; + var part = market == PagedQuoteMarket.Kospi + ? "t_part" + : "t_kosdaq_part"; + return $""" + SELECT b.f_part_name NAME, + a.f_part_idx / 100 PRIMARY_VALUE, + a.f_part_chg / 100 SECONDARY_VALUE, + ROUND(a.f_part_chg / + DECODE(a.f_chg_type, + '+', (a.f_part_idx / 100) - (a.f_part_chg / 100), + '-', (a.f_part_idx / 100) + (a.f_part_chg / 100)), 2) RATE, + a.f_chg_type DIRECTION + FROM {index} a + JOIN {part} b ON a.f_part_code = b.f_part_code + WHERE a.f_part_code <> '001' + ORDER BY a.f_part_code + """; + } + + private static string OvertimeRanked( + string overtime, + string stock, + string extraPredicate, + string orderBy) => $""" + SELECT DISTINCT b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION + FROM {overtime} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_sect_code = 'ST' + AND a.f_curr_price <> 0 + {extraPredicate} + ORDER BY {orderBy} + """; + + private static string FiftyTwoWeekRanked( + DomesticPagedQuoteLoadRequest request, + ICollection parameters, + string online, + string stock, + bool high) + { + if (request.AsOfDate.DayNumber < 364) + { + throw InvalidRequest(); + } + + var history = request.Market == PagedQuoteMarket.Kospi + ? "t_candle_history" + : "t_kosdaq_candle_history"; + var priceColumn = high ? "f_high_price" : "f_low_price"; + var aggregate = high ? "MAX" : "MAX"; + var comparison = high + ? $"h.{priceColumn} <= d.f_high_price" + : $"h.{priceColumn} <= d.f_low_price"; + var order = high + ? "RATE DESC" + : request.Market == PagedQuoteMarket.Kospi ? "RATE" : "RATE DESC"; + parameters.Add(new DataQueryParameter( + "startDate", + request.AsOfDate.AddDays(-364).ToString("yyyyMMdd", CultureInfo.InvariantCulture), + DbType.String)); + parameters.Add(new DataQueryParameter( + "endDate", + request.AsOfDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture), + DbType.String)); + return $""" + SELECT b.f_stock_wanname NAME, + d.f_curr_price PRIMARY_VALUE, + d.f_net_chg SECONDARY_VALUE, + {OracleRate("d")} RATE, + d.f_chg_type DIRECTION + FROM ( + SELECT f_stock_code, {aggregate}({priceColumn}) {priceColumn} + FROM {history} + WHERE f_data_date BETWEEN :startDate AND :endDate + GROUP BY f_stock_code + ) h + JOIN {stock} b ON h.f_stock_code = b.f_stock_code + JOIN {online} d ON h.f_stock_code = d.f_stock_code + WHERE d.f_mkt_halt = 'N' + AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF') + AND d.f_curr_price > 0 + AND {comparison} + ORDER BY {order} + """; + } + + private static string OracleRate(string alias) => $""" + ROUND(({alias}.f_net_chg / + DECODE({alias}.f_chg_type, + '1', {alias}.f_curr_price - {alias}.f_net_chg, + '2', {alias}.f_curr_price - {alias}.f_net_chg, + '3', {alias}.f_curr_price, + '4', -1 * ({alias}.f_curr_price + {alias}.f_net_chg), + '5', -1 * ({alias}.f_curr_price + {alias}.f_net_chg))) * 100, 2) + """; + + private static string BuildNxtQuery(NxtPagedQuoteLoadRequest request) + { + var online = request.Market == PagedQuoteMarket.Kospi + ? "n_online" + : "n_kosdaq_online"; + var stock = request.Market == PagedQuoteMarket.Kospi + ? "n_stock" + : "n_kosdaq_stock"; + var overtime = request.Market == PagedQuoteMarket.Kospi + ? "n_overtime" + : "n_kosdaq_overtime"; + var source = request.List is + NxtPagedQuoteList.AfterHoursGainRate or + NxtPagedQuoteList.AfterHoursLossRate + ? overtime + : online; + var predicate = request.List switch + { + NxtPagedQuoteList.GainRate or NxtPagedQuoteList.AfterHoursGainRate => + "AND a.f_chg_type IN ('1', '2')", + NxtPagedQuoteList.LossRate or NxtPagedQuoteList.AfterHoursLossRate => + "AND a.f_chg_type IN ('4', '5')", + _ => string.Empty + }; + var sortValue = request.List switch + { + NxtPagedQuoteList.MarketCapitalization => + "b.f_list_num * a.f_curr_price", + NxtPagedQuoteList.Volume => "a.f_net_vol", + _ => "0" + }; + var order = request.List switch + { + NxtPagedQuoteList.MarketCapitalization => + "SORT_VALUE DESC", + NxtPagedQuoteList.Volume => "SORT_VALUE DESC", + NxtPagedQuoteList.GainRate or NxtPagedQuoteList.AfterHoursGainRate => + "RATE DESC", + NxtPagedQuoteList.LossRate or NxtPagedQuoteList.AfterHoursLossRate => + "RATE", + _ => throw InvalidRequest() + }; + return $""" + SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION + FROM ( + SELECT DISTINCT b.f_stock_name NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {MariaRate("a")} RATE, + a.f_chg_type DIRECTION, + {sortValue} SORT_VALUE + FROM {source} a + JOIN {stock} b ON a.f_stock_code = b.f_stock_code + WHERE b.f_stop_gubun = 'N' + AND a.f_curr_price != 0 + {predicate} + ORDER BY {order} + ) ranked + LIMIT @maxRows + """; + } + + private static string MariaRate(string alias) => $""" + ROUND(({alias}.f_net_chg / + CASE {alias}.f_chg_type + WHEN '1' THEN {alias}.f_curr_price - {alias}.f_net_chg + WHEN '2' THEN {alias}.f_curr_price - {alias}.f_net_chg + WHEN '3' THEN {alias}.f_curr_price + WHEN '4' THEN -1 * ({alias}.f_curr_price + {alias}.f_net_chg) + WHEN '5' THEN -1 * ({alias}.f_curr_price + {alias}.f_net_chg) + END) * 100, 2) + """; + + private static string BuildThemeQuery( + PagedQuoteThemeSort sort, + PagedQuoteThemeValueKind valueKind) + { + var quoteRows = valueKind switch + { + PagedQuoteThemeValueKind.Current => $""" + SELECT b.f_stock_code STOCK_CODE, + b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + ABS(a.f_net_chg) SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0 + UNION ALL + SELECT b.f_stock_code STOCK_CODE, + b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION + FROM t_kosdaq_online1 a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0 + """, + PagedQuoteThemeValueKind.ExpectedExecution => """ + SELECT b.f_stock_code STOCK_CODE, + b.f_stock_wanname NAME, + a.f_fore_price PRIMARY_VALUE, + ABS(a.f_fore_price - c.f_final_price) SECONDARY_VALUE, + ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE, + CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '2' + WHEN a.f_fore_price - c.f_final_price < 0 THEN '5' + ELSE '3' END DIRECTION + FROM t_online1_call a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + JOIN t_batch_day c ON a.f_stock_code = c.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND a.f_fore_price <> 0 + AND c.f_final_price <> 0 + UNION ALL + SELECT b.f_stock_code STOCK_CODE, + b.f_stock_wanname NAME, + a.f_fore_price PRIMARY_VALUE, + ABS(a.f_fore_price - c.f_final_price) SECONDARY_VALUE, + ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE, + CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '2' + WHEN a.f_fore_price - c.f_final_price < 0 THEN '5' + ELSE '3' END DIRECTION + FROM t_kosdaq_online1_call a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + JOIN t_kosdaq_batch_day c ON a.f_stock_code = c.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND a.f_fore_price <> 0 + AND c.f_final_price <> 0 + """, + _ => throw InvalidRequest() + }; + var orderBy = sort switch + { + PagedQuoteThemeSort.InputOrder => "t.SB_I_INDEX", + PagedQuoteThemeSort.GainRateDescending => "q.RATE DESC", + PagedQuoteThemeSort.GainRateAscending => "q.RATE", + _ => throw InvalidRequest() + }; + return $""" + WITH THEME_ITEMS AS ( + SELECT i.SB_I_CODE, i.SB_I_INDEX + FROM SB_LIST l + JOIN SB_ITEM i ON l.SB_CODE = i.SB_M_CODE + WHERE l.SB_TITLE = :themeTitle + AND l.SB_MARKET = 'KRX' + ), QUOTES AS ( + {quoteRows} + ) + SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION + FROM ( + SELECT q.NAME, q.PRIMARY_VALUE, q.SECONDARY_VALUE, + q.RATE, q.DIRECTION + FROM THEME_ITEMS t + JOIN QUOTES q ON SUBSTR(t.SB_I_CODE, 2, 12) = q.STOCK_CODE + ORDER BY {orderBy} + ) + WHERE ROWNUM <= :maxRows + """; + } + + private static string BuildNxtThemeQuery(PagedQuoteThemeSort sort) + { + var orderBy = sort switch + { + PagedQuoteThemeSort.InputOrder => "i.SB_I_INDEX", + PagedQuoteThemeSort.GainRateDescending => "RATE DESC", + PagedQuoteThemeSort.GainRateAscending => "RATE", + _ => throw InvalidRequest() + }; + return $""" + SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION + FROM ( + SELECT b.F_STOCK_NAME NAME, + b.f_curr_price PRIMARY_VALUE, + b.f_net_chg SECONDARY_VALUE, + {MariaRate("b")} RATE, + b.f_chg_type DIRECTION + FROM SB_LIST l + JOIN SB_ITEM i ON l.SB_CODE = i.SB_M_CODE + JOIN v_all_stock b + ON SUBSTRING(i.SB_I_CODE, 2, 12) = b.f_stock_code + WHERE l.SB_TITLE = @themeTitle + AND l.SB_MARKET = 'NXT' + AND b.f_stop_gubun = 'N' + AND b.f_curr_price != 0 + ORDER BY {orderBy} + ) ranked + LIMIT @maxRows + """; + } + + private const string ExpertQuery = """ + WITH QUOTES AS ( + SELECT b.f_stock_code STOCK_CODE, + b.f_stock_wanname NAME, + a.f_curr_price CURRENT_PRICE + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0 + UNION ALL + SELECT b.f_stock_code STOCK_CODE, + b.f_stock_wanname NAME, + a.f_curr_price CURRENT_PRICE + FROM t_kosdaq_online1 a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0 + ) + SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION + FROM ( + SELECT r.RC_STOCK_NAME NAME, + r.RC_BUY_AMOUNT PRIMARY_VALUE, + q.CURRENT_PRICE SECONDARY_VALUE, + ROUND(((q.CURRENT_PRICE - r.RC_BUY_AMOUNT) / + r.RC_BUY_AMOUNT) * 100, 2) RATE, + CASE WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT > 0 THEN '2' + WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT < 0 THEN '5' + ELSE '3' END DIRECTION + 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 + AND r.RC_BUY_AMOUNT <> 0 + ORDER BY r.PLAYINDEX + ) + WHERE ROWNUM <= :maxRows + """; + + private static string BuildViQuery(int nameCount) + { + var requestedRows = Enumerable.Range(0, nameCount) + .Select(index => $"SELECT {index.ToString(CultureInfo.InvariantCulture)} INPUT_ORDER, :stockName{index.ToString(CultureInfo.InvariantCulture)} STOCK_NAME FROM DUAL") + .ToArray(); + return $""" + WITH REQUESTED_NAMES AS ( + {string.Join(" UNION ALL ", requestedRows)} + ), QUOTES AS ( + SELECT b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + ABS(a.f_net_chg) SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0 + UNION ALL + SELECT b.f_stock_wanname NAME, + a.f_curr_price PRIMARY_VALUE, + a.f_net_chg SECONDARY_VALUE, + {OracleRate("a")} RATE, + a.f_chg_type DIRECTION + FROM t_kosdaq_online1 a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0 + ) + SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION + FROM ( + SELECT q.NAME, q.PRIMARY_VALUE, q.SECONDARY_VALUE, + q.RATE, q.DIRECTION + FROM REQUESTED_NAMES r + JOIN QUOTES q ON r.STOCK_NAME = q.NAME + ORDER BY r.INPUT_ORDER + ) + WHERE ROWNUM <= :maxRows + """; + } + + private static string BuildDomesticTitle( + PagedQuoteMarket market, + DomesticPagedQuoteList list) + { + var marketName = MarketName(market); + return list switch + { + DomesticPagedQuoteList.ExpectedMarketCapitalization => + marketName + " \uC608\uC0C1\uAC00 \uC2DC\uAC00\uCD1D\uC561 \uC0C1\uC704", + DomesticPagedQuoteList.MarketCapitalization => + marketName + " \uC2DC\uAC00\uCD1D\uC561 \uC0C1\uC704", + DomesticPagedQuoteList.Industry => marketName + " \uC5C5\uC885", + DomesticPagedQuoteList.AfterHoursGainRate => + marketName + " \uC2DC\uAC04\uC678 \uC0C1\uC2B9\uB960 \uC0C1\uC704", + DomesticPagedQuoteList.AfterHoursLossRate => + marketName + " \uC2DC\uAC04\uC678 \uD558\uB77D\uB960 \uC0C1\uC704", + DomesticPagedQuoteList.GainRate => + marketName + " \uC0C1\uC2B9\uB960 \uC0C1\uC704", + DomesticPagedQuoteList.LossRate => + marketName + " \uD558\uB77D\uB960 \uC0C1\uC704", + DomesticPagedQuoteList.FiftyTwoWeekHigh => + marketName + " 52\uC8FC \uC2E0\uACE0\uAC00", + DomesticPagedQuoteList.FiftyTwoWeekLow => + marketName + " 52\uC8FC \uC2E0\uC800\uAC00", + DomesticPagedQuoteList.Kospi200 => marketName + " 200", + DomesticPagedQuoteList.Etf => "ETF", + _ => throw InvalidRequest() + }; + } + + private static string BuildNxtTitle( + PagedQuoteMarket market, + NxtPagedQuoteList list) + { + var marketName = MarketName(market); + return list switch + { + NxtPagedQuoteList.MarketCapitalization => + marketName + " \uC2DC\uAC00\uCD1D\uC561 \uC0C1\uC704", + NxtPagedQuoteList.AfterHoursGainRate => + marketName + " \uC2DC\uAC04\uC678 \uC0C1\uC2B9\uB960 \uC0C1\uC704", + NxtPagedQuoteList.AfterHoursLossRate => + marketName + " \uC2DC\uAC04\uC678 \uD558\uB77D\uB960 \uC0C1\uC704", + NxtPagedQuoteList.GainRate => + marketName + " \uC0C1\uC2B9\uB960 \uC0C1\uC704", + NxtPagedQuoteList.LossRate => + marketName + " \uD558\uB77D\uB960 \uC0C1\uC704", + NxtPagedQuoteList.Volume => + marketName + " \uAC70\uB798\uB7C9 \uC0C1\uC704", + _ => throw InvalidRequest() + }; + } + + private static string MarketName(PagedQuoteMarket market) => market switch + { + PagedQuoteMarket.Kospi => "\uCF54\uC2A4\uD53C", + PagedQuoteMarket.Kosdaq => "\uCF54\uC2A4\uB2E5", + _ => throw InvalidRequest() + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PanelSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PanelSceneDataLoaders.cs new file mode 100644 index 0000000..bc294bc --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PanelSceneDataLoaders.cs @@ -0,0 +1,166 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed class S5006NxtSceneDataLoader +{ + private const string KospiQuery = """ + SELECT a.f_stock_code stock_code, b.f_stock_name stock_name, + a.f_curr_price curr_price, + CASE WHEN a.f_chg_type = '1' THEN '상한' + WHEN a.f_chg_type = '2' THEN '상승' + WHEN a.f_chg_type = '3' THEN '보합' + WHEN a.f_chg_type = '4' THEN '하한' + WHEN a.f_chg_type = '5' THEN '하락' END chg_type, + a.f_net_chg net_chg, + ROUND((a.f_net_chg / CASE WHEN a.f_chg_type IN ('1','2') THEN a.f_curr_price - a.f_net_chg + WHEN a.f_chg_type = '3' THEN a.f_curr_price + WHEN a.f_chg_type IN ('4','5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) rate, + a.f_init_price init_price, + IF(c.f_final_price = 0, + ROUND(((a.f_init_price - c.f_base_price) / c.f_base_price) * 100, 2), + ROUND(((a.f_init_price - c.f_final_price) / c.f_final_price) * 100, 2)) init_rate, + a.f_high_price high_price, + IF(c.f_final_price = 0, + ROUND(((a.f_high_price - c.f_base_price) / c.f_base_price) * 100, 2), + ROUND(((a.f_high_price - c.f_final_price) / c.f_final_price) * 100, 2)) high_rate, + a.f_low_price low_price, + IF(c.f_final_price = 0, + ROUND(((a.f_low_price - c.f_base_price) / c.f_base_price) * 100, 2), + ROUND(((a.f_low_price - c.f_final_price) / c.f_final_price) * 100, 2)) low_rate + FROM n_online a + JOIN n_stock b ON a.f_stock_code = b.f_stock_code + JOIN n_batch_his c ON a.f_stock_code = c.f_stock_code + WHERE a.f_curr_price <> 0 + AND c.f_data_day = (SELECT MAX(h.f_data_day) FROM n_batch_his h WHERE h.f_stock_code = a.f_stock_code) + AND a.f_stock_code = @stockCode + """; + + private const string KosdaqQuery = """ + SELECT a.f_stock_code stock_code, b.f_stock_name stock_name, + a.f_curr_price curr_price, + CASE WHEN a.f_chg_type = '1' THEN '상한' + WHEN a.f_chg_type = '2' THEN '상승' + WHEN a.f_chg_type = '3' THEN '보합' + WHEN a.f_chg_type = '4' THEN '하한' + WHEN a.f_chg_type = '5' THEN '하락' END chg_type, + a.f_net_chg net_chg, + ROUND((a.f_net_chg / CASE WHEN a.f_chg_type IN ('1','2') THEN a.f_curr_price - a.f_net_chg + WHEN a.f_chg_type = '3' THEN a.f_curr_price + WHEN a.f_chg_type IN ('4','5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) rate, + a.f_init_price init_price, + IF(c.f_final_price = 0, + ROUND(((a.f_init_price - c.f_base_price) / c.f_base_price) * 100, 2), + ROUND(((a.f_init_price - c.f_final_price) / c.f_final_price) * 100, 2)) init_rate, + a.f_high_price high_price, + IF(c.f_final_price = 0, + ROUND(((a.f_high_price - c.f_base_price) / c.f_base_price) * 100, 2), + ROUND(((a.f_high_price - c.f_final_price) / c.f_final_price) * 100, 2)) high_rate, + a.f_low_price low_price, + IF(c.f_final_price = 0, + ROUND(((a.f_low_price - c.f_base_price) / c.f_base_price) * 100, 2), + ROUND(((a.f_low_price - c.f_final_price) / c.f_final_price) * 100, 2)) low_rate + FROM n_kosdaq_online a + JOIN n_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + JOIN n_kosdaq_batch_his c ON a.f_stock_code = c.f_stock_code + WHERE a.f_curr_price <> 0 + AND c.f_data_day = (SELECT MAX(h.f_data_day) FROM n_kosdaq_batch_his h WHERE h.f_stock_code = a.f_stock_code) + AND a.f_stock_code = @stockCode + """; + + private readonly IDataQueryExecutor _executor; + + public S5006NxtSceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + LegacyDomesticEquityMarket market, + string stockCode, + CancellationToken cancellationToken = default) + { + var query = market switch + { + LegacyDomesticEquityMarket.NxtKospi => KospiQuery, + LegacyDomesticEquityMarket.NxtKosdaq => KosdaqQuery, + _ => throw new LegacySceneDataException("The NXT scene loader requires an NXT market.") + }; + stockCode = RequireStockCode(stockCode); + var spec = new DataQuerySpec( + query, + [new DataQueryParameter("stockCode", stockCode, DbType.String)]); + var table = await _executor.ExecuteAsync( + DataSourceKind.MariaDb, + "SCENE_5006_NXT", + spec, + cancellationToken).ConfigureAwait(false); + var row = SceneDataTableReader.SingleRow( + table, + "STOCK_NAME", "CURR_PRICE", "CHG_TYPE", "NET_CHG", "RATE", + "INIT_PRICE", "INIT_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE"); + var directionText = SceneDataTableReader.RequiredString(row, "CHG_TYPE"); + var direction = LegacyPriceDirectionMutations.Parse(directionText); + if (direction == ScenePriceDirection.Unknown) + { + throw new LegacySceneDataException("The NXT quote returned an unknown direction."); + } + + return new S5006SceneData( + market, + SceneDataTableReader.RequiredString(row, "STOCK_NAME"), + direction, + Int64(row, "CURR_PRICE"), + Int64(row, "NET_CHG"), + Decimal(row, "RATE"), + Int64(row, "INIT_PRICE"), + Decimal(row, "INIT_RATE"), + Int64(row, "HIGH_PRICE"), + Decimal(row, "HIGH_RATE"), + Int64(row, "LOW_PRICE"), + Decimal(row, "LOW_RATE")); + } + + private static string RequireStockCode(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 32 || + value.Any(character => !char.IsAsciiLetterOrDigit(character))) + { + throw new LegacySceneDataException("The NXT stock code is invalid."); + } + + return value; + } + + private static long Int64(DataRow row, string column) + { + try + { + return checked(Convert.ToInt64(RequiredValue(row, column), CultureInfo.InvariantCulture)); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw new LegacySceneDataException("The NXT quote returned an invalid integer."); + } + } + + private static decimal Decimal(DataRow row, string column) + { + try + { + return Convert.ToDecimal(RequiredValue(row, column), CultureInfo.InvariantCulture); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw new LegacySceneDataException("The NXT quote returned an invalid decimal."); + } + } + + private static object RequiredValue(DataRow row, string column) => row[column] is { } value && value is not DBNull + ? value + : throw new LegacySceneDataException("The NXT quote returned a required null value."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs new file mode 100644 index 0000000..b8aacab --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs @@ -0,0 +1,1352 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Closed query targets shared by the legacy two-column market scenes. Query loaders +/// translate these values to vendor/database identifiers; no raw symbol reaches a DTO. +/// +public enum LegacyMarketQuoteTarget +{ + Kospi, + Kosdaq, + Kospi200, + Futures, + Krx100, + WonDollar, + WonYen, + WonYuan, + WonEuro, + Dow, + Nasdaq, + Sp500, + GermanyDax, + UnitedKingdomFtse, + FranceCac, + Nikkei, + ShanghaiComposite, + HangSeng, + TaiwanWeighted, + SingaporeStraitsTimes, + ThailandSet, + PhilippinesComposite, + MalaysiaKlse, + IndonesiaComposite +} + +public enum S5001Branch +{ + BalticDryIndex, + Commodity, + DomesticIndex, + ForeignIndex, + ExchangeRate, + DomesticIndustry, + ForeignIndustry, + ForeignStock, + DomesticStock +} + +public enum S5001CommodityTarget +{ + FeedWheat, + Corn, + Wheat, + Soybean, + Bean, + BrownRice, + Coffee, + Cocoa, + Sugar, + RawCotton, + Cotton, + LiveCattle, + FeederCattle, + LeanHogs, + Copper, + IronOre, + Nickel, + NaturalGas, + Platinum, + Palladium, + Lead, + Zinc, + Tin, + InternationalGold, + InternationalSilver, + DomesticGold, + DomesticSilver +} + +public enum S5001DomesticIndexTarget +{ + Kospi, + Kosdaq, + Kospi200, + Futures, + Krx100 +} + +public enum S5001IndexMode +{ + Current, + Expected +} + +public enum S5001ForeignIndexTarget +{ + Dow, + Nasdaq, + Sp500, + GermanyDax, + UnitedKingdomFtse, + FranceCac, + Nikkei, + ShanghaiComposite, + HangSeng, + TaiwanWeighted, + SingaporeStraitsTimes, + ThailandSet, + PhilippinesComposite, + MalaysiaKlse, + IndonesiaComposite +} + +public enum S5001ExchangeRateTarget +{ + WonDollar, + WonYen, + WonYuan, + WonEuro +} + +public enum S5001DomesticStockMode +{ + Current, + ExpectedOpening, + AfterHoursSinglePrice +} + +public enum S5001NxtBadge +{ + None, + PreMarket, + AfterMarket +} + +public abstract record S5001SceneData : ILegacySceneData +{ + private protected S5001SceneData() + { + } + + public abstract S5001Branch Branch { get; } +} + +public sealed record S5001BalticDryIndexSceneData( + string Title, + ScenePriceDirection Direction, + long Price, + long NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.BalticDryIndex; +} + +public sealed record S5001CommoditySceneData( + S5001CommodityTarget Target, + string Title, + ScenePriceDirection Direction, + decimal Price, + decimal NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.Commodity; +} + +public sealed record S5001DomesticIndexSceneData( + S5001DomesticIndexTarget Target, + S5001IndexMode Mode, + string Title, + ScenePriceDirection Direction, + decimal Price, + decimal NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.DomesticIndex; +} + +public sealed record S5001ForeignIndexSceneData( + S5001ForeignIndexTarget Target, + string Title, + ScenePriceDirection Direction, + decimal Price, + decimal NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.ForeignIndex; +} + +public sealed record S5001ExchangeRateSceneData( + S5001ExchangeRateTarget Target, + string Title, + ScenePriceDirection Direction, + decimal Price, + decimal NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.ExchangeRate; +} + +public sealed record S5001DomesticIndustrySceneData( + LegacyDomesticEquityMarket Market, + string Title, + ScenePriceDirection Direction, + decimal Price, + decimal NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.DomesticIndustry; +} + +public sealed record S5001ForeignIndustrySceneData( + string Title, + ScenePriceDirection Direction, + decimal Price, + decimal NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.ForeignIndustry; +} + +public sealed record S5001ForeignStockSceneData( + string Title, + ScenePriceDirection Direction, + decimal Price, + decimal NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.ForeignStock; +} + +public sealed record S5001DomesticStockSceneData( + LegacyDomesticEquityMarket Market, + S5001DomesticStockMode Mode, + S5001NxtBadge Badge, + string StockName, + ScenePriceDirection Direction, + long Price, + long NetChange, + decimal Rate) : S5001SceneData +{ + public override S5001Branch Branch => S5001Branch.DomesticStock; +} + +public sealed class S5001SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5001"; + + public IReadOnlyList Build(S5001SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var mutations = new List(48) + { + new PlayoutSetVisible("mart", false) + }; + + switch (data) + { + case S5001BalticDryIndexSceneData bdi: + AddBalticDryIndex(mutations, bdi); + break; + case S5001CommoditySceneData commodity: + AddCommodity(mutations, commodity); + break; + case S5001DomesticIndexSceneData domesticIndex: + AddDomesticIndex(mutations, domesticIndex); + break; + case S5001ForeignIndexSceneData foreignIndex: + AddForeignIndex(mutations, foreignIndex); + break; + case S5001ExchangeRateSceneData exchangeRate: + AddDecimalQuote(mutations, exchangeRate.Title, exchangeRate.Direction, + exchangeRate.Price, exchangeRate.NetChange, exchangeRate.Rate, "단위 : 원"); + RequireDefined(exchangeRate.Target, nameof(exchangeRate.Target)); + break; + case S5001DomesticIndustrySceneData industry: + AddDomesticIndustry(mutations, industry); + break; + case S5001ForeignIndustrySceneData foreignIndustry: + AddDecimalQuote(mutations, foreignIndustry.Title, foreignIndustry.Direction, + foreignIndustry.Price, foreignIndustry.NetChange, foreignIndustry.Rate, "단위 : p"); + break; + case S5001ForeignStockSceneData foreignStock: + AddDecimalQuote(mutations, foreignStock.Title, foreignStock.Direction, + foreignStock.Price, foreignStock.NetChange, foreignStock.Rate, "단위 : 달러"); + break; + case S5001DomesticStockSceneData stock: + AddDomesticStock(mutations, stock); + break; + default: + throw new LegacySceneDataException("Unsupported s5001 branch DTO."); + } + + return mutations; + } + + private static void AddBalticDryIndex( + List mutations, + S5001BalticDryIndexSceneData data) + { + var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); + var direction = RequireDirection(data.Direction); + var variant = DirectionVariant(direction); + mutations.Add(new PlayoutSetValue("title", title)); + mutations.Add(new PlayoutSetValue("price", FormatInteger(data.Price))); + mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(Math.Abs(data.NetChange)))); + mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate))); + mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty)); + mutations.Add(new PlayoutSetValue("unitText", "단위 : 달러")); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0)); + } + + private static void AddCommodity( + List mutations, + S5001CommoditySceneData data) + { + RequireDefined(data.Target, nameof(data.Target)); + var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); + var direction = RequireDirection(data.Direction); + var variant = DirectionVariant(direction); + mutations.Add(new PlayoutSetValue("title", title)); + mutations.Add(new PlayoutSetValue("price", FormatRawDecimal(data.Price))); + mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatRawDecimal(Math.Abs(data.NetChange)))); + mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate))); + mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty)); + mutations.Add(new PlayoutSetValue("unitText", "단위 : 달러")); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0)); + } + + private static void AddDomesticIndex( + List mutations, + S5001DomesticIndexSceneData data) + { + RequireDefined(data.Target, nameof(data.Target)); + RequireDefined(data.Mode, nameof(data.Mode)); + AddDecimalQuote(mutations, data.Title, data.Direction, + data.Price, data.NetChange, data.Rate, "단위 : p"); + } + + private static void AddForeignIndex( + List mutations, + S5001ForeignIndexSceneData data) + { + RequireDefined(data.Target, nameof(data.Target)); + AddDecimalQuote(mutations, data.Title, data.Direction, + data.Price, data.NetChange, data.Rate, "단위 : p"); + } + + private static void AddDomesticIndustry( + List mutations, + S5001DomesticIndustrySceneData data) + { + RequireDomesticMarket(data.Market, allowNxt: true); + var prefix = data.Market is LegacyDomesticEquityMarket.Kospi or LegacyDomesticEquityMarket.NxtKospi + ? "코스피 - " + : "코스닥 - "; + var title = prefix + LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); + AddDecimalQuote(mutations, title, data.Direction, + data.Price, data.NetChange, data.Rate, "단위 : p"); + } + + private static void AddDecimalQuote( + List mutations, + string rawTitle, + ScenePriceDirection rawDirection, + decimal price, + decimal netChange, + decimal rate, + string unit) + { + var title = LegacySceneBuilderGuard.Text(rawTitle, nameof(rawTitle)); + var direction = RequireDirection(rawDirection); + var variant = DirectionVariant(direction); + mutations.Add(new PlayoutSetValue("title", title)); + mutations.Add(new PlayoutSetValue("price", FormatDecimal2(price))); + mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatDecimal2(Math.Abs(netChange)))); + mutations.Add(new PlayoutSetValue("rate" + variant, FormatDirectedRate(rate, direction))); + mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty)); + mutations.Add(new PlayoutSetValue("unitText", unit)); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0)); + } + + private static void AddDomesticStock( + List mutations, + S5001DomesticStockSceneData data) + { + RequireDomesticMarket(data.Market, allowNxt: true); + RequireDefined(data.Mode, nameof(data.Mode)); + RequireDefined(data.Badge, nameof(data.Badge)); + var direction = RequireDirection(data.Direction); + var variant = DirectionVariant(direction); + var rawName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + var isNxt = data.Market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq; + + if (data.Mode != S5001DomesticStockMode.Current && isNxt) + { + throw new LegacySceneDataException("s5001 expected/after-hours stock branches do not accept NXT markets."); + } + + if (data.Mode == S5001DomesticStockMode.Current) + { + if (isNxt == (data.Badge == S5001NxtBadge.None)) + { + throw new LegacySceneDataException("s5001 current NXT stocks require a closed session badge; KRX stocks require None."); + } + } + else if (data.Badge != S5001NxtBadge.None) + { + throw new LegacySceneDataException("s5001 non-current stock branches cannot carry a market badge."); + } + + // The legacy constructor writes this before entering any stock sub-branch. + mutations.Add(new PlayoutSetValue("unitText", "단위 : 원")); + + switch (data.Mode) + { + case S5001DomesticStockMode.ExpectedOpening: + mutations.Add(new PlayoutSetValue("title", rawName + " 예상체결가")); + AddStockValues(mutations, data, variant, forceDownMagnitude: true); + break; + case S5001DomesticStockMode.AfterHoursSinglePrice: + mutations.Add(new PlayoutSetValue("title", rawName)); + mutations.Add(new PlayoutSetValue("title", rawName + " 시간외단일가")); + AddStockValues(mutations, data, variant, forceDownMagnitude: false); + break; + case S5001DomesticStockMode.Current: + mutations.Add(new PlayoutSetValue( + "title", + rawName.Replace("(NXT)", string.Empty, StringComparison.Ordinal))); + AddStockValues(mutations, data, variant, forceDownMagnitude: false); + if (isNxt) + { + var asset = data.Badge == S5001NxtBadge.PreMarket + ? @"Images\프리마켓.png" + : @"Images\애프터마켓.png"; + mutations.Add(new PlayoutSetAssetValue("mart", asset)); + mutations.Add(new PlayoutSetVisible("mart", true)); + } + else + { + mutations.Add(new PlayoutSetVisible("mart", false)); + } + + break; + default: + throw new LegacySceneDataException("Unsupported s5001 stock mode."); + } + + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0)); + } + + private static void AddStockValues( + List mutations, + S5001DomesticStockSceneData data, + string variant, + bool forceDownMagnitude) + { + var change = forceDownMagnitude && data.Direction is ScenePriceDirection.Down or ScenePriceDirection.LimitDown + ? Math.Abs(data.NetChange) + : data.NetChange; + mutations.Add(new PlayoutSetValue("price", FormatInteger(data.Price))); + mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(change))); + mutations.Add(new PlayoutSetValue("rate" + variant, FormatDirectedRate(data.Rate, data.Direction))); + mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty)); + mutations.Add(new PlayoutSetValue("unitText", "단위 : 원")); + } + + private static string FormatInteger(long value) => + value.ToString("#,##0", CultureInfo.InvariantCulture); + + private static string FormatDecimal2(decimal value) => + value.ToString("#,##0.00", CultureInfo.InvariantCulture); + + private static string FormatRate(decimal value) => + value.ToString("##0.00", CultureInfo.InvariantCulture); + + private static string FormatRawDecimal(decimal value) => + value.ToString(CultureInfo.InvariantCulture); + + internal static string FormatDirectedRate(decimal rate, ScenePriceDirection direction) + { + var value = direction is ScenePriceDirection.Down or ScenePriceDirection.LimitDown + ? -Math.Abs(rate) + : rate; + return value.ToString("#,##0.00", CultureInfo.InvariantCulture); + } + + internal static string DirectionVariant(ScenePriceDirection direction) => direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => "1_", + ScenePriceDirection.Flat => "2_", + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => "3_", + _ => throw new LegacySceneDataException("A known scene price direction is required.") + }; + + internal static ScenePriceDirection RequireDirection(ScenePriceDirection direction) => direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up or ScenePriceDirection.Flat or + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => direction, + _ => throw new LegacySceneDataException("A known scene price direction is required.") + }; + + internal static void RequireDomesticMarket(LegacyDomesticEquityMarket market, bool allowNxt) + { + var valid = market is LegacyDomesticEquityMarket.Kospi or LegacyDomesticEquityMarket.Kosdaq || + (allowNxt && market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq); + if (!valid) + { + throw new LegacySceneDataException("A supported closed domestic market is required."); + } + } + + internal static void RequireDefined(TEnum value, string name) + where TEnum : struct, Enum + { + if (!Enum.IsDefined(value)) + { + throw new LegacySceneDataException($"Scene enum field '{name}' is invalid."); + } + } +} + +public enum S5032Branch +{ + IndexAndFutures, + DomesticStockPair, + DomesticIndustryPair +} + +public enum S5032CompanionSignal +{ + Positive, + Flat, + Negative +} + +public sealed record S5032PrimaryQuote( + string Title, + decimal Price, + decimal NetChange, + decimal Rate, + ScenePriceDirection Direction); + +public sealed record S5032CompanionQuote( + string Title, + decimal Price, + decimal NetChange, + S5032CompanionSignal Signal, + decimal OpenInterest, + decimal TradingValue, + decimal Volume); + +public sealed record S5032StockQuote( + string Title, + long Price, + long NetChange, + decimal Rate, + ScenePriceDirection Direction); + +public abstract record S5032SceneData : ILegacySceneData +{ + private protected S5032SceneData() + { + } + + public abstract S5032Branch Branch { get; } +} + +public sealed record S5032IndexAndFuturesSceneData( + LegacyMarketQuoteTarget PrimaryTarget, + LegacyMarketQuoteTarget CompanionTarget, + S5032PrimaryQuote Primary, + S5032CompanionQuote Companion) : S5032SceneData +{ + public override S5032Branch Branch => S5032Branch.IndexAndFutures; +} + +public sealed record S5032DomesticIndustryPairSceneData( + LegacyDomesticEquityMarket Market, + S5032PrimaryQuote Primary, + S5032CompanionQuote Companion) : S5032SceneData +{ + public override S5032Branch Branch => S5032Branch.DomesticIndustryPair; +} + +public sealed record S5032DomesticStockPairSceneData( + LegacyDomesticEquityMarket FirstMarket, + LegacyDomesticEquityMarket SecondMarket, + S5032StockQuote First, + S5032StockQuote Second) : S5032SceneData +{ + public override S5032Branch Branch => S5032Branch.DomesticStockPair; +} + +public sealed class S5032SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5032"; + + public IReadOnlyList Build(S5032SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + return data switch + { + S5032IndexAndFuturesSceneData comparison => BuildComparison(comparison), + S5032DomesticIndustryPairSceneData industry => BuildIndustry(industry), + S5032DomesticStockPairSceneData stocks => BuildStocks(stocks), + _ => throw new LegacySceneDataException("Unsupported s5032 branch DTO.") + }; + } + + private static IReadOnlyList BuildComparison( + S5032IndexAndFuturesSceneData data) + { + S5001SceneMutationBuilder.RequireDefined(data.PrimaryTarget, nameof(data.PrimaryTarget)); + S5001SceneMutationBuilder.RequireDefined(data.CompanionTarget, nameof(data.CompanionTarget)); + return BuildComparisonCore(data.Primary, data.Companion, null); + } + + private static IReadOnlyList BuildIndustry( + S5032DomesticIndustryPairSceneData data) + { + S5001SceneMutationBuilder.RequireDomesticMarket(data.Market, allowNxt: false); + // Unlike s5001/s8018, s5032's Data_View writes both DB titles verbatim. + return BuildComparisonCore(data.Primary, data.Companion, null); + } + + private static IReadOnlyList BuildComparisonCore( + S5032PrimaryQuote? rawPrimary, + S5032CompanionQuote? rawCompanion, + string? titlePrefix) + { + var primary = LegacySceneBuilderGuard.NotNull(rawPrimary, nameof(rawPrimary)); + var companion = LegacySceneBuilderGuard.NotNull(rawCompanion, nameof(rawCompanion)); + var primaryTitle = LegacySceneBuilderGuard.Text(primary.Title, nameof(primary.Title)); + var companionTitle = LegacySceneBuilderGuard.Text(companion.Title, nameof(companion.Title)); + var direction = S5001SceneMutationBuilder.RequireDirection(primary.Direction); + S5001SceneMutationBuilder.RequireDefined(companion.Signal, nameof(companion.Signal)); + + var mutations = new List(52) + { + new PlayoutSetValue("title", (titlePrefix ?? string.Empty) + primaryTitle), + new PlayoutSetValue("price", FormatDecimal2(primary.Price)), + new PlayoutSetValue("changePrice", FormatDecimal2(Math.Abs(primary.NetChange))), + new PlayoutSetValue("rate", S5001SceneMutationBuilder.FormatDirectedRate(primary.Rate, direction)) + }; + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 3)); + mutations.Add(new PlayoutSetValue("title1", (titlePrefix ?? string.Empty) + companionTitle)); + mutations.Add(new PlayoutSetValue("price1", FormatRawDecimal(companion.Price))); + mutations.Add(new PlayoutSetValue("changePrice1", FormatRawDecimal(companion.NetChange))); + AddCompanionDirection(mutations, companion.Signal); + mutations.Add(new PlayoutSetValue("r_value1", FormatWhole(companion.OpenInterest))); + mutations.Add(new PlayoutSetValue("r_value2", FormatWhole(companion.TradingValue / 1_000_000m))); + mutations.Add(new PlayoutSetValue("r_value3", FormatWhole(companion.Volume))); + return mutations; + } + + private static IReadOnlyList BuildStocks(S5032DomesticStockPairSceneData data) + { + S5001SceneMutationBuilder.RequireDomesticMarket(data.FirstMarket, allowNxt: false); + S5001SceneMutationBuilder.RequireDomesticMarket(data.SecondMarket, allowNxt: false); + var first = ValidateStock(data.First, nameof(data.First)); + var second = ValidateStock(data.Second, nameof(data.Second)); + var mutations = new List(72) + { + new PlayoutSetValue("title1", first.Title), + new PlayoutSetValue("title2", second.Title), + new PlayoutSetValue("price1", FormatInteger(first.Price)), + new PlayoutSetValue("price2", FormatInteger(second.Price)), + new PlayoutSetValue("changePrice1", FormatInteger(first.NetChange)), + new PlayoutSetValue("changePrice2", FormatInteger(second.NetChange)), + new PlayoutSetValue("rate1", FormatRate(first.Rate)), + new PlayoutSetValue("rate2", FormatRate(second.Rate)), + new PlayoutSetValue("wonText1", string.Empty), + new PlayoutSetValue("wonText2", string.Empty), + new PlayoutSetValue("unitText1", "단위 : 원"), + new PlayoutSetValue("unitText2", "단위 : 원") + }; + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1)); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2)); + return mutations; + } + + private static S5032StockQuote ValidateStock(S5032StockQuote? quote, string name) + { + var value = LegacySceneBuilderGuard.NotNull(quote, name); + var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title"); + var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction); + return value with { Title = title, Direction = direction }; + } + + private static void AddCompanionDirection( + ICollection mutations, + S5032CompanionSignal signal) + { + var up = signal == S5032CompanionSignal.Positive; + var down = signal == S5032CompanionSignal.Negative; + mutations.Add(new PlayoutSetVisible("up1", up)); + mutations.Add(new PlayoutSetVisible("upup1", false)); + mutations.Add(new PlayoutSetVisible("down1", down)); + mutations.Add(new PlayoutSetVisible("downdown1", false)); + mutations.Add(new PlayoutSetVisible("flat1", !up && !down)); + } + + private static string FormatInteger(long value) => value.ToString("#,##0", CultureInfo.InvariantCulture); + private static string FormatDecimal2(decimal value) => value.ToString("#,##0.00", CultureInfo.InvariantCulture); + private static string FormatRate(decimal value) => value.ToString("##0.00", CultureInfo.InvariantCulture); + private static string FormatWhole(decimal value) => value.ToString("#,##0", CultureInfo.InvariantCulture); + private static string FormatRawDecimal(decimal value) => value.ToString(CultureInfo.InvariantCulture); +} + +public enum S8018Branch +{ + MarketPair, + MixedMarketAndStock, + ExpectedStockPair, + CurrentStockPair, + DomesticIndustryPair +} + +public enum S8018PairSide +{ + First, + Second +} + +public enum S8018StockMarket +{ + Kospi, + Kosdaq, + NxtKospi, + NxtKosdaq, + World +} + +public enum S8018SessionPhase +{ + BeforeOrAtKrxClose, + AfterKrxClose +} + +public sealed record S8018MarketQuote( + string Title, + decimal Price, + decimal NetChange, + decimal Rate, + ScenePriceDirection Direction); + +public sealed record S8018MixedStockQuote( + LegacyDomesticEquityMarket Market, + string Title, + long Price, + long NetChange, + decimal Rate, + ScenePriceDirection Direction); + +public sealed record S8018ExpectedStockQuote( + LegacyDomesticEquityMarket Market, + string Title, + long Price, + long NetChange, + decimal Rate, + ScenePriceDirection Direction); + +public sealed record S8018CurrentStockQuote( + S8018StockMarket Market, + string Title, + decimal Price, + decimal NetChange, + decimal Rate, + ScenePriceDirection Direction); + +public abstract record S8018SceneData : ILegacySceneData +{ + private protected S8018SceneData() + { + } + + public abstract S8018Branch Branch { get; } +} + +public sealed record S8018MarketPairSceneData( + LegacyMarketQuoteTarget FirstTarget, + LegacyMarketQuoteTarget SecondTarget, + S8018MarketQuote First, + S8018MarketQuote Second) : S8018SceneData +{ + public override S8018Branch Branch => S8018Branch.MarketPair; +} + +public sealed record S8018MixedMarketAndStockSceneData( + S8018PairSide StockSide, + LegacyMarketQuoteTarget MarketTarget, + S8018MarketQuote MarketQuote, + S8018MixedStockQuote StockQuote) : S8018SceneData +{ + public override S8018Branch Branch => S8018Branch.MixedMarketAndStock; +} + +public sealed record S8018ExpectedStockPairSceneData( + S8018ExpectedStockQuote First, + S8018ExpectedStockQuote Second) : S8018SceneData +{ + public override S8018Branch Branch => S8018Branch.ExpectedStockPair; +} + +public sealed record S8018CurrentStockPairSceneData( + S8018SessionPhase Session, + S8018CurrentStockQuote First, + S8018CurrentStockQuote Second) : S8018SceneData +{ + public override S8018Branch Branch => S8018Branch.CurrentStockPair; +} + +public sealed record S8018DomesticIndustryPairSceneData( + LegacyDomesticEquityMarket Market, + S8018MarketQuote First, + S8018MarketQuote Second) : S8018SceneData +{ + public override S8018Branch Branch => S8018Branch.DomesticIndustryPair; +} + +public sealed class S8018SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s8018"; + + public IReadOnlyList Build(S8018SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var mutations = InitialBadgeHides(); + switch (data) + { + case S8018MarketPairSceneData pair: + S5001SceneMutationBuilder.RequireDefined(pair.FirstTarget, nameof(pair.FirstTarget)); + S5001SceneMutationBuilder.RequireDefined(pair.SecondTarget, nameof(pair.SecondTarget)); + AddMarketPair(mutations, pair.First, pair.Second, null); + break; + case S8018DomesticIndustryPairSceneData industries: + S5001SceneMutationBuilder.RequireDomesticMarket(industries.Market, allowNxt: false); + var prefix = industries.Market == LegacyDomesticEquityMarket.Kospi ? "코스피 - " : "코스닥 - "; + AddMarketPair(mutations, industries.First, industries.Second, prefix); + break; + case S8018MixedMarketAndStockSceneData mixed: + AddMixedPair(mutations, mixed); + break; + case S8018ExpectedStockPairSceneData expected: + AddExpectedStocks(mutations, expected); + break; + case S8018CurrentStockPairSceneData current: + AddCurrentStocks(mutations, current); + break; + default: + throw new LegacySceneDataException("Unsupported s8018 branch DTO."); + } + + return mutations; + } + + private static List InitialBadgeHides() => + [ + new PlayoutSetVisible("mart1", false), + new PlayoutSetVisible("mart2", false), + new PlayoutSetVisible("end1", false), + new PlayoutSetVisible("end2", false) + ]; + + private static void AddMarketPair( + List mutations, + S8018MarketQuote? rawFirst, + S8018MarketQuote? rawSecond, + string? prefix) + { + var first = ValidateMarketQuote(rawFirst, nameof(rawFirst)); + var second = ValidateMarketQuote(rawSecond, nameof(rawSecond)); + mutations.Add(new PlayoutSetValue("title1", (prefix ?? string.Empty) + first.Title)); + mutations.Add(new PlayoutSetValue("title2", (prefix ?? string.Empty) + second.Title)); + mutations.Add(new PlayoutSetValue("price1", FormatDecimal2(first.Price))); + mutations.Add(new PlayoutSetValue("price2", FormatDecimal2(second.Price))); + AddComparisonChangeValues(mutations, first, 1); + AddComparisonChangeValues(mutations, second, 2); + AddComparisonRateValues(mutations, first, 1); + AddComparisonRateValues(mutations, second, 2); + mutations.Add(new PlayoutSetValue("wonText1", string.Empty)); + mutations.Add(new PlayoutSetValue("wonText2", string.Empty)); + mutations.Add(new PlayoutSetValue("unitText1", "단위 : p")); + mutations.Add(new PlayoutSetValue("unitText2", "단위 : p")); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1)); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2)); + } + + private static void AddMixedPair( + List mutations, + S8018MixedMarketAndStockSceneData data) + { + S5001SceneMutationBuilder.RequireDefined(data.StockSide, nameof(data.StockSide)); + S5001SceneMutationBuilder.RequireDefined(data.MarketTarget, nameof(data.MarketTarget)); + var market = ValidateMarketQuote(data.MarketQuote, nameof(data.MarketQuote)); + var stock = ValidateMixedStock(data.StockQuote); + + if (data.StockSide == S8018PairSide.First) + { + AddSingleMarketSide(mutations, market, 2, addUnit: true); + AddSimpleStockSide(mutations, stock, 1); + } + else + { + // Data_View contains a duplicated "index == 1" condition; consequently + // the original does not write unitText1 for this partial side. + AddSingleMarketSide(mutations, market, 1, addUnit: false); + AddSimpleStockSide(mutations, stock, 2); + } + } + + private static void AddSingleMarketSide( + List mutations, + S8018MarketQuote quote, + int side, + bool addUnit) + { + mutations.Add(new PlayoutSetValue("title" + side, quote.Title)); + mutations.Add(new PlayoutSetValue("price" + side, FormatDecimal2(quote.Price))); + AddComparisonChangeValues(mutations, quote, side); + AddComparisonRateValues(mutations, quote, side); + mutations.Add(new PlayoutSetValue("wonText1", string.Empty)); + mutations.Add(new PlayoutSetValue("wonText2", string.Empty)); + if (addUnit) + { + mutations.Add(new PlayoutSetValue("unitText" + side, "단위 : p")); + } + + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(quote.Direction, side)); + } + + private static void AddSimpleStockSide( + List mutations, + S8018MixedStockQuote quote, + int side) + { + mutations.Add(new PlayoutSetValue("title" + side, quote.Title)); + mutations.Add(new PlayoutSetValue("price" + side, FormatInteger(quote.Price))); + mutations.Add(new PlayoutSetValue("changePrice" + side, FormatInteger(quote.NetChange))); + mutations.Add(new PlayoutSetValue("rate" + side, FormatRate(quote.Rate))); + mutations.Add(new PlayoutSetValue("wonText" + side, string.Empty)); + mutations.Add(new PlayoutSetValue("unitText" + side, "단위 : 원")); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(quote.Direction, side)); + } + + private static void AddExpectedStocks( + List mutations, + S8018ExpectedStockPairSceneData data) + { + var first = ValidateExpectedStock(data.First, nameof(data.First)); + var second = ValidateExpectedStock(data.Second, nameof(data.Second)); + mutations.Add(new PlayoutSetValue("title1", first.Title + " 예상체결")); + mutations.Add(new PlayoutSetValue("title2", second.Title + " 예상체결")); + mutations.Add(new PlayoutSetValue("price1", FormatInteger(first.Price))); + mutations.Add(new PlayoutSetValue("price2", FormatInteger(second.Price))); + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"changePrice{variant}_1", FormatInteger(Math.Abs(first.NetChange)))); + mutations.Add(new PlayoutSetValue($"changePrice{variant}_2", FormatInteger(Math.Abs(second.NetChange)))); + } + + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"rate{variant}_1", FormatRate(first.Rate))); + mutations.Add(new PlayoutSetValue($"rate{variant}_2", FormatRate(second.Rate))); + } + + mutations.Add(new PlayoutSetValue("wonText1", string.Empty)); + mutations.Add(new PlayoutSetValue("wonText2", string.Empty)); + mutations.Add(new PlayoutSetValue("unitText1", "단위 : 원")); + mutations.Add(new PlayoutSetValue("unitText2", "단위 : 원")); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1)); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2)); + } + + private static void AddCurrentStocks( + List mutations, + S8018CurrentStockPairSceneData data) + { + S5001SceneMutationBuilder.RequireDefined(data.Session, nameof(data.Session)); + var first = ValidateCurrentStock(data.First, nameof(data.First)); + var second = ValidateCurrentStock(data.Second, nameof(data.Second)); + mutations.Add(new PlayoutSetValue("unitText1", UnitFor(first.Market))); + mutations.Add(new PlayoutSetValue("unitText2", UnitFor(second.Market))); + mutations.Add(new PlayoutSetValue("title1", first.Title)); + mutations.Add(new PlayoutSetValue("title2", second.Title)); + AddVenue(mutations, first.Market, data.Session, 1); + AddVenue(mutations, second.Market, data.Session, 2); + + if (IsKrx(first.Market) && IsKrx(second.Market)) + { + mutations.Add(new PlayoutSetVisible("mart1", false)); + mutations.Add(new PlayoutSetVisible("mart2", false)); + mutations.Add(new PlayoutSetVisible("end1", false)); + mutations.Add(new PlayoutSetVisible("end2", false)); + } + + mutations.Add(new PlayoutSetValue("price1", FormatLegacyStockDecimal(first.Price))); + mutations.Add(new PlayoutSetValue("price2", FormatLegacyStockDecimal(second.Price))); + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue( + $"changePrice{variant}_1", + FormatLegacyStockDecimal(first.NetChange))); + mutations.Add(new PlayoutSetValue( + $"changePrice{variant}_2", + FormatLegacyStockDecimal(second.NetChange))); + } + + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"rate{variant}_1", FormatRate(first.Rate))); + mutations.Add(new PlayoutSetValue($"rate{variant}_2", FormatRate(second.Rate))); + } + + mutations.Add(new PlayoutSetValue("wonText1", string.Empty)); + mutations.Add(new PlayoutSetValue("wonText2", string.Empty)); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1)); + mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2)); + } + + private static void AddVenue( + ICollection mutations, + S8018StockMarket market, + S8018SessionPhase session, + int side) + { + if (market == S8018StockMarket.World) + { + mutations.Add(new PlayoutSetVisible("mart" + side, false)); + mutations.Add(new PlayoutSetVisible("end" + side, false)); + return; + } + + var krx = IsKrx(market); + mutations.Add(new PlayoutSetAssetValue( + "mart" + side, + krx ? @"Images\KRX.png" : @"Images\NXT.png")); + mutations.Add(new PlayoutSetVisible("mart" + side, true)); + if (session == S8018SessionPhase.BeforeOrAtKrxClose) + { + mutations.Add(new PlayoutSetVisible("end" + side, false)); + } + else if (krx) + { + mutations.Add(new PlayoutSetVisible("end" + side, true)); + } + } + + private static void AddComparisonChangeValues( + ICollection mutations, + S8018MarketQuote quote, + int side) + { + var value = FormatDecimal2(Math.Abs(quote.NetChange)); + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"changePrice{variant}_{side}", value)); + } + } + + private static void AddComparisonRateValues( + ICollection mutations, + S8018MarketQuote quote, + int side) + { + var value = S5001SceneMutationBuilder.FormatDirectedRate(quote.Rate, quote.Direction); + for (var variant = 1; variant <= 3; variant++) + { + mutations.Add(new PlayoutSetValue($"rate{variant}_{side}", value)); + } + } + + private static S8018MarketQuote ValidateMarketQuote(S8018MarketQuote? quote, string name) + { + var value = LegacySceneBuilderGuard.NotNull(quote, name); + var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title"); + var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction); + return value with { Title = title, Direction = direction }; + } + + private static S8018MixedStockQuote ValidateMixedStock(S8018MixedStockQuote? quote) + { + var value = LegacySceneBuilderGuard.NotNull(quote, nameof(quote)); + S5001SceneMutationBuilder.RequireDomesticMarket(value.Market, allowNxt: false); + var title = LegacySceneBuilderGuard.Text(value.Title, nameof(value.Title)); + var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction); + return value with { Title = title, Direction = direction }; + } + + private static S8018ExpectedStockQuote ValidateExpectedStock( + S8018ExpectedStockQuote? quote, + string name) + { + var value = LegacySceneBuilderGuard.NotNull(quote, name); + S5001SceneMutationBuilder.RequireDomesticMarket(value.Market, allowNxt: false); + var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title"); + var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction); + return value with { Title = title, Direction = direction }; + } + + private static S8018CurrentStockQuote ValidateCurrentStock( + S8018CurrentStockQuote? quote, + string name) + { + var value = LegacySceneBuilderGuard.NotNull(quote, name); + S5001SceneMutationBuilder.RequireDefined(value.Market, name + ".Market"); + var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title"); + var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction); + return value with { Title = title, Direction = direction }; + } + + private static bool IsKrx(S8018StockMarket market) => + market is S8018StockMarket.Kospi or S8018StockMarket.Kosdaq; + + private static string UnitFor(S8018StockMarket market) => + market == S8018StockMarket.World ? "단위 : 달러" : "단위 : 원"; + + private static string FormatInteger(long value) => value.ToString("#,##0", CultureInfo.InvariantCulture); + private static string FormatDecimal2(decimal value) => value.ToString("#,##0.00", CultureInfo.InvariantCulture); + private static string FormatRate(decimal value) => value.ToString("##0.00", CultureInfo.InvariantCulture); + + private static string FormatLegacyStockDecimal(decimal value) + { + var scale = (decimal.GetBits(value)[3] >> 16) & 0x7f; + return scale > 0 + ? value.ToString(CultureInfo.InvariantCulture) + : value.ToString("#,##0", CultureInfo.InvariantCulture); + } +} + +public enum S8001Market +{ + Kospi, + Kosdaq +} + +public enum S8001Sector +{ + Communications, + Finance, + Construction, + Machinery, + Distribution, + PaperAndWood, + TransportationEquipment, + TransportationWarehouse, + FoodAndBeverage, + SteelAndMetal, + MedicalPrecision, + TextilesAndApparel, + ElectricalAndElectronics, + Chemicals, + NonMetallicMinerals, + MachineryAndEquipment, + ItComponents, + Transportation, + Semiconductor, + Internet +} + +public enum S8001SectorSignal +{ + Positive, + Flat, + Negative +} + +public sealed record S8001SectorQuote( + S8001Sector Sector, + S8001SectorSignal Signal, + decimal Magnitude); + +public sealed record S8001SceneData( + S8001Market Market, + IReadOnlyList Sectors) : ILegacySceneData; + +public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s8001"; + + public IReadOnlyList Build(S8001SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + S5001SceneMutationBuilder.RequireDefined(data.Market, nameof(data.Market)); + var rows = LegacySceneBuilderGuard.Range(data.Sectors, 1, 18, nameof(data.Sectors)); + var seen = new HashSet(); + var mutations = new List(rows.Count * 2); + foreach (var row in rows) + { + if (row is null) + { + throw new LegacySceneDataException("s8001 sector rows cannot contain null."); + } + + S5001SceneMutationBuilder.RequireDefined(row.Sector, nameof(row.Sector)); + S5001SceneMutationBuilder.RequireDefined(row.Signal, nameof(row.Signal)); + if (!seen.Add(row.Sector)) + { + throw new LegacySceneDataException("s8001 sector rows must be unique."); + } + + if (!BelongsToMarket(row.Sector, data.Market)) + { + throw new LegacySceneDataException("s8001 sector does not belong to the selected closed market."); + } + + if (row.Magnitude < 0) + { + throw new LegacySceneDataException("s8001 sector magnitude cannot be negative."); + } + + var objectPrefix = SectorObjectName(row.Sector); + var value = row.Magnitude.ToString(CultureInfo.InvariantCulture); + mutations.Add(new PlayoutSetValue( + objectPrefix + "_등락", + row.Signal == S8001SectorSignal.Negative ? "-" + value : value)); + + var color = ColorFor(row.Signal, row.Magnitude); + if (color is { } rgb) + { + mutations.Add(new PlayoutSetFaceColor( + objectPrefix + "_큐브", + rgb.Red, + rgb.Green, + rgb.Blue)); + } + } + + return mutations; + } + + private static bool BelongsToMarket(S8001Sector sector, S8001Market market) => market switch + { + S8001Market.Kospi => sector is + S8001Sector.Communications or S8001Sector.Finance or S8001Sector.Construction or + S8001Sector.Machinery or S8001Sector.Distribution or S8001Sector.PaperAndWood or + S8001Sector.TransportationEquipment or S8001Sector.TransportationWarehouse or + S8001Sector.FoodAndBeverage or S8001Sector.SteelAndMetal or + S8001Sector.MedicalPrecision or S8001Sector.TextilesAndApparel or + S8001Sector.ElectricalAndElectronics or S8001Sector.Chemicals or + S8001Sector.NonMetallicMinerals, + S8001Market.Kosdaq => sector is + S8001Sector.MachineryAndEquipment or S8001Sector.ItComponents or + S8001Sector.Transportation or S8001Sector.PaperAndWood or + S8001Sector.Semiconductor or S8001Sector.Internet, + _ => false + }; + + private static string SectorObjectName(S8001Sector sector) => sector switch + { + S8001Sector.Communications => "통신", + S8001Sector.Finance => "금융", + S8001Sector.Construction => "건설", + S8001Sector.Machinery => "기계", + S8001Sector.Distribution => "유통", + S8001Sector.PaperAndWood => "종이목재", + S8001Sector.TransportationEquipment => "운수장비", + S8001Sector.TransportationWarehouse => "운수창고", + S8001Sector.FoodAndBeverage => "음식료", + S8001Sector.SteelAndMetal => "철강금속", + S8001Sector.MedicalPrecision => "의료정밀", + S8001Sector.TextilesAndApparel => "섬유의복", + S8001Sector.ElectricalAndElectronics => "전기전자", + S8001Sector.Chemicals => "화학", + S8001Sector.NonMetallicMinerals => "비금속광물", + S8001Sector.MachineryAndEquipment => "기계장비", + S8001Sector.ItComponents => "IT부품", + S8001Sector.Transportation => "운송", + S8001Sector.Semiconductor => "반도체", + S8001Sector.Internet => "인터넷", + _ => throw new LegacySceneDataException("Unsupported s8001 sector target.") + }; + + private static (int Red, int Green, int Blue)? ColorFor( + S8001SectorSignal signal, + decimal magnitude) + { + if (signal == S8001SectorSignal.Flat) + { + return null; + } + + if (signal == S8001SectorSignal.Positive) + { + return magnitude switch + { + < 1m => (206, 61, 59), + < 3m => (181, 28, 39), + < 5m => (154, 28, 39), + < 10m => (131, 28, 39), + _ => (174, 0, 0) + }; + } + + return magnitude switch + { + < 1m => (66, 128, 185), + < 3m => (52, 117, 157), + < 5m => (52, 78, 126), + < 10m => (42, 61, 124), + _ => (42, 47, 124) + }; + } +} + +/// +/// Exact ordered form of MainForm.setObjectStockChange for the parameterized scenes. +/// Limit-up intentionally writes its pattern before unit visibility, unlike the newer +/// shared helper, because that is the order in the original MainForm. +/// +internal static class ExactLegacyPriceDirectionMutations +{ + public static IReadOnlyList Build( + ScenePriceDirection rawDirection, + int legacyVariantIndex) + { + if (legacyVariantIndex is < 0 or > 3) + { + throw new ArgumentOutOfRangeException(nameof(legacyVariantIndex)); + } + + var direction = S5001SceneMutationBuilder.RequireDirection(rawDirection); + var pattern = direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => @"Images\그림_빨강.png", + ScenePriceDirection.Flat => @"Images\그림_검정.png", + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => @"Images\그림_파랑.png", + _ => throw new LegacySceneDataException("A known scene visual variant is required.") + }; + var mutations = LegacyPriceDirectionMutations.Build(direction, legacyVariantIndex) + .Select(mutation => mutation is PlayoutSetAssetValue asset + ? new PlayoutSetAssetValue(asset.ObjectName, pattern) + : mutation) + .ToList(); + + // MainForm writes the limit-up pattern before unit1/2/3. The shared + // helper writes units first, so move only this one mutation for cnt 1/2/3. + if (direction == ScenePriceDirection.LimitUp && legacyVariantIndex != 0) + { + var assetIndex = mutations.FindIndex(static mutation => mutation is PlayoutSetAssetValue); + var asset = mutations[assetIndex]; + mutations.RemoveAt(assetIndex); + mutations.Insert(assetIndex - 3, asset); + } + + return mutations; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs new file mode 100644 index 0000000..03c3d01 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs @@ -0,0 +1,1033 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum S5001StockTradingStatus +{ + Regular, + Halted +} + +public abstract record S5001SceneLoadRequest +{ + private protected S5001SceneLoadRequest() + { + } + + public abstract S5001Branch Branch { get; } +} + +public sealed record S5001BalticDryIndexLoadRequest : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.BalticDryIndex; +} + +public sealed record S5001CommodityLoadRequest(S5001CommodityTarget Target) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.Commodity; +} + +public sealed record S5001DomesticIndexLoadRequest( + S5001DomesticIndexTarget Target, + S5001IndexMode Mode) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.DomesticIndex; +} + +public sealed record S5001ForeignIndexLoadRequest(S5001ForeignIndexTarget Target) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.ForeignIndex; +} + +public sealed record S5001ExchangeRateLoadRequest(S5001ExchangeRateTarget Target) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.ExchangeRate; +} + +public sealed record S5001DomesticIndustryLoadRequest( + LegacyDomesticEquityMarket Market, + string IndustryName) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.DomesticIndustry; +} + +public sealed record S5001ForeignIndustryLoadRequest(string IndustryName) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.ForeignIndustry; +} + +public sealed record S5001ForeignStockLoadRequest(string StockName) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.ForeignStock; +} + +public sealed record S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket Market, + S5001DomesticStockMode Mode, + S5001NxtBadge Badge, + S5001StockTradingStatus TradingStatus, + string StockName) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.DomesticStock; +} + +public sealed class S5001SceneDataLoader +{ + private readonly IDataQueryExecutor _executor; + + public S5001SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5001SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return request switch + { + S5001BalticDryIndexLoadRequest => await LoadBalticDryIndexAsync(cancellationToken).ConfigureAwait(false), + S5001CommodityLoadRequest commodity => await LoadCommodityAsync(commodity, cancellationToken).ConfigureAwait(false), + S5001DomesticIndexLoadRequest index => await LoadDomesticIndexAsync(index, cancellationToken).ConfigureAwait(false), + S5001ForeignIndexLoadRequest index => await LoadForeignIndexAsync(index, cancellationToken).ConfigureAwait(false), + S5001ExchangeRateLoadRequest exchange => await LoadExchangeAsync(exchange, cancellationToken).ConfigureAwait(false), + S5001DomesticIndustryLoadRequest industry => await LoadDomesticIndustryAsync(industry, cancellationToken).ConfigureAwait(false), + S5001ForeignIndustryLoadRequest industry => await LoadForeignIndustryAsync(industry, cancellationToken).ConfigureAwait(false), + S5001ForeignStockLoadRequest stock => await LoadForeignStockAsync(stock, cancellationToken).ConfigureAwait(false), + S5001DomesticStockLoadRequest stock => await LoadDomesticStockAsync(stock, cancellationToken).ConfigureAwait(false), + _ => throw new LegacySceneDataException("Unsupported s5001 load request.") + }; + } + + private async Task LoadBalticDryIndexAsync(CancellationToken cancellationToken) + { + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.WorldFuture("MK@BDI"), + cancellationToken).ConfigureAwait(false); + return new S5001BalticDryIndexSceneData( + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Int64(row, "PRICE"), + ParameterizedSceneRowReader.Int64(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + } + + private async Task LoadCommodityAsync( + S5001CommodityLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.Target, nameof(request.Target)); + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.WorldFuture(CommoditySymbol(request.Target)), + cancellationToken).ConfigureAwait(false); + return new S5001CommoditySceneData( + request.Target, + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Decimal(row, "PRICE"), + ParameterizedSceneRowReader.Decimal(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + } + + private async Task LoadDomesticIndexAsync( + S5001DomesticIndexLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.Target, nameof(request.Target)); + S5001SceneMutationBuilder.RequireDefined(request.Mode, nameof(request.Mode)); + var target = ToMarketTarget(request.Target); + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.MarketQuote(target, request.Mode == S5001IndexMode.Expected), + cancellationToken).ConfigureAwait(false); + return new S5001DomesticIndexSceneData( + request.Target, + request.Mode, + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Decimal(row, "PRICE"), + ParameterizedSceneRowReader.Decimal(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + } + + private async Task LoadForeignIndexAsync( + S5001ForeignIndexLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.Target, nameof(request.Target)); + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.MarketQuote(ToMarketTarget(request.Target), expected: false), + cancellationToken).ConfigureAwait(false); + return new S5001ForeignIndexSceneData( + request.Target, + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Decimal(row, "PRICE"), + ParameterizedSceneRowReader.Decimal(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + } + + private async Task LoadExchangeAsync( + S5001ExchangeRateLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.Target, nameof(request.Target)); + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.MarketQuote(ToMarketTarget(request.Target), expected: false), + cancellationToken).ConfigureAwait(false); + return new S5001ExchangeRateSceneData( + request.Target, + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Decimal(row, "PRICE"), + ParameterizedSceneRowReader.Decimal(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + } + + private async Task LoadDomesticIndustryAsync( + S5001DomesticIndustryLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDomesticMarket(request.Market, allowNxt: true); + var name = ParameterizedSceneRowReader.Selector(request.IndustryName, nameof(request.IndustryName)); + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.DomesticIndustry(request.Market, name), + cancellationToken).ConfigureAwait(false); + return new S5001DomesticIndustrySceneData( + request.Market, + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Decimal(row, "PRICE"), + ParameterizedSceneRowReader.Decimal(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + } + + private async Task LoadForeignIndustryAsync( + S5001ForeignIndustryLoadRequest request, + CancellationToken cancellationToken) + { + var name = ParameterizedSceneRowReader.Selector(request.IndustryName, nameof(request.IndustryName)); + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.ForeignIndustry(name), + cancellationToken).ConfigureAwait(false); + return DecimalScene(row, static (title, direction, price, change, rate) => + new S5001ForeignIndustrySceneData(title, direction, price, change, rate)); + } + + private async Task LoadForeignStockAsync( + S5001ForeignStockLoadRequest request, + CancellationToken cancellationToken) + { + var name = ParameterizedSceneRowReader.Selector(request.StockName, nameof(request.StockName)); + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.ForeignStock(name), + cancellationToken).ConfigureAwait(false); + return DecimalScene(row, static (title, direction, price, change, rate) => + new S5001ForeignStockSceneData(title, direction, price, change, rate)); + } + + private async Task LoadDomesticStockAsync( + S5001DomesticStockLoadRequest request, + CancellationToken cancellationToken) + { + ValidateS5001StockRequest(request); + var rawSelector = ParameterizedSceneRowReader.Selector(request.StockName, nameof(request.StockName)); + var selector = ParameterizedSceneRowReader.Selector( + rawSelector.Replace("(NXT)", string.Empty, StringComparison.Ordinal).Trim(), + nameof(request.StockName)); + var query = request.Mode switch + { + S5001DomesticStockMode.Current => ParameterizedMarketSceneQueries.CurrentStock( + request.Market, selector, request.TradingStatus == S5001StockTradingStatus.Halted), + S5001DomesticStockMode.ExpectedOpening => ParameterizedMarketSceneQueries.ExpectedStock(request.Market, selector), + S5001DomesticStockMode.AfterHoursSinglePrice => ParameterizedMarketSceneQueries.AfterHoursStock(request.Market, selector), + _ => throw new LegacySceneDataException("Unsupported s5001 stock mode.") + }; + var row = await ExecuteSingleAsync(query, cancellationToken).ConfigureAwait(false); + return new S5001DomesticStockSceneData( + request.Market, + request.Mode, + request.Badge, + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Int64(row, "PRICE"), + ParameterizedSceneRowReader.Int64(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + } + + private async Task ExecuteSingleAsync( + ParameterizedSceneQuery query, + CancellationToken cancellationToken) + { + var table = await _executor.ExecuteAsync( + query.Source, + query.TableName, + query.Spec, + cancellationToken).ConfigureAwait(false); + return ParameterizedSceneRowReader.SingleQuote(table); + } + + private static S5001SceneData DecimalScene( + DataRow row, + Func factory) => factory( + ParameterizedSceneRowReader.String(row, "TITLE"), + ParameterizedSceneRowReader.Direction(row, "DIRECTION"), + ParameterizedSceneRowReader.Decimal(row, "PRICE"), + ParameterizedSceneRowReader.Decimal(row, "NET_CHANGE"), + ParameterizedSceneRowReader.Decimal(row, "RATE")); + + private static void ValidateS5001StockRequest(S5001DomesticStockLoadRequest request) + { + S5001SceneMutationBuilder.RequireDomesticMarket(request.Market, allowNxt: true); + S5001SceneMutationBuilder.RequireDefined(request.Mode, nameof(request.Mode)); + S5001SceneMutationBuilder.RequireDefined(request.Badge, nameof(request.Badge)); + S5001SceneMutationBuilder.RequireDefined(request.TradingStatus, nameof(request.TradingStatus)); + var nxt = request.Market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq; + if (request.Mode != S5001DomesticStockMode.Current && nxt) + { + throw new LegacySceneDataException("Only current stock queries support NXT."); + } + + if (request.TradingStatus == S5001StockTradingStatus.Halted && + (nxt || request.Mode != S5001DomesticStockMode.Current)) + { + throw new LegacySceneDataException("The closed halted-stock query is available only for current KRX quotes."); + } + + if (request.Mode == S5001DomesticStockMode.Current) + { + if (nxt == (request.Badge == S5001NxtBadge.None)) + { + throw new LegacySceneDataException("The current stock badge does not match its market."); + } + } + else if (request.Badge != S5001NxtBadge.None) + { + throw new LegacySceneDataException("A non-current stock query cannot carry an NXT badge."); + } + } + + internal static LegacyMarketQuoteTarget ToMarketTarget(S5001DomesticIndexTarget target) => target switch + { + S5001DomesticIndexTarget.Kospi => LegacyMarketQuoteTarget.Kospi, + S5001DomesticIndexTarget.Kosdaq => LegacyMarketQuoteTarget.Kosdaq, + S5001DomesticIndexTarget.Kospi200 => LegacyMarketQuoteTarget.Kospi200, + S5001DomesticIndexTarget.Futures => LegacyMarketQuoteTarget.Futures, + S5001DomesticIndexTarget.Krx100 => LegacyMarketQuoteTarget.Krx100, + _ => throw new LegacySceneDataException("Unsupported domestic index target.") + }; + + internal static LegacyMarketQuoteTarget ToMarketTarget(S5001ForeignIndexTarget target) => target switch + { + S5001ForeignIndexTarget.Dow => LegacyMarketQuoteTarget.Dow, + S5001ForeignIndexTarget.Nasdaq => LegacyMarketQuoteTarget.Nasdaq, + S5001ForeignIndexTarget.Sp500 => LegacyMarketQuoteTarget.Sp500, + S5001ForeignIndexTarget.GermanyDax => LegacyMarketQuoteTarget.GermanyDax, + S5001ForeignIndexTarget.UnitedKingdomFtse => LegacyMarketQuoteTarget.UnitedKingdomFtse, + S5001ForeignIndexTarget.FranceCac => LegacyMarketQuoteTarget.FranceCac, + S5001ForeignIndexTarget.Nikkei => LegacyMarketQuoteTarget.Nikkei, + S5001ForeignIndexTarget.ShanghaiComposite => LegacyMarketQuoteTarget.ShanghaiComposite, + S5001ForeignIndexTarget.HangSeng => LegacyMarketQuoteTarget.HangSeng, + S5001ForeignIndexTarget.TaiwanWeighted => LegacyMarketQuoteTarget.TaiwanWeighted, + S5001ForeignIndexTarget.SingaporeStraitsTimes => LegacyMarketQuoteTarget.SingaporeStraitsTimes, + S5001ForeignIndexTarget.ThailandSet => LegacyMarketQuoteTarget.ThailandSet, + S5001ForeignIndexTarget.PhilippinesComposite => LegacyMarketQuoteTarget.PhilippinesComposite, + S5001ForeignIndexTarget.MalaysiaKlse => LegacyMarketQuoteTarget.MalaysiaKlse, + S5001ForeignIndexTarget.IndonesiaComposite => LegacyMarketQuoteTarget.IndonesiaComposite, + _ => throw new LegacySceneDataException("Unsupported foreign index target.") + }; + + internal static LegacyMarketQuoteTarget ToMarketTarget(S5001ExchangeRateTarget target) => target switch + { + S5001ExchangeRateTarget.WonDollar => LegacyMarketQuoteTarget.WonDollar, + S5001ExchangeRateTarget.WonYen => LegacyMarketQuoteTarget.WonYen, + S5001ExchangeRateTarget.WonYuan => LegacyMarketQuoteTarget.WonYuan, + S5001ExchangeRateTarget.WonEuro => LegacyMarketQuoteTarget.WonEuro, + _ => throw new LegacySceneDataException("Unsupported exchange-rate target.") + }; + + private static string CommoditySymbol(S5001CommodityTarget target) => target switch + { + S5001CommodityTarget.FeedWheat => "MK@WHEAT", + S5001CommodityTarget.Corn => "CBT$CORN", + S5001CommodityTarget.Wheat => "CBT@WHEAT", + S5001CommodityTarget.Soybean => "CBT@SOYBEAN", + S5001CommodityTarget.Bean => "MK@BEAN", + S5001CommodityTarget.BrownRice => "MK@RICE", + S5001CommodityTarget.Coffee => "NYB@KC", + S5001CommodityTarget.Cocoa => "MK@COCOA", + S5001CommodityTarget.Sugar => "NYB@SB", + S5001CommodityTarget.RawCotton => "MK@COTTON", + S5001CommodityTarget.Cotton => "NYB@CT", + S5001CommodityTarget.LiveCattle => "MK@LC", + S5001CommodityTarget.FeederCattle => "MK@FC", + S5001CommodityTarget.LeanHogs => "MK@PORK", + S5001CommodityTarget.Copper => "LME@CDY", + S5001CommodityTarget.IronOre => "MK@IRON", + S5001CommodityTarget.Nickel => "LME@NDY", + S5001CommodityTarget.NaturalGas => "NYM@NG", + S5001CommodityTarget.Platinum => "NYM@PL", + S5001CommodityTarget.Palladium => "NYM@PA", + S5001CommodityTarget.Lead => "MK@PDA", + S5001CommodityTarget.Zinc => "MK@ZDA", + S5001CommodityTarget.Tin => "LME@SDY", + S5001CommodityTarget.InternationalGold => "COM@GC", + S5001CommodityTarget.InternationalSilver => "COM@SI", + S5001CommodityTarget.DomesticGold => "MK@GC", + S5001CommodityTarget.DomesticSilver => "MK@SI", + _ => throw new LegacySceneDataException("Unsupported commodity target.") + }; +} + +public abstract record S5032SceneLoadRequest +{ + private protected S5032SceneLoadRequest() + { + } + + public abstract S5032Branch Branch { get; } +} + +public sealed record S5032IndexAndFuturesLoadRequest( + LegacyMarketQuoteTarget PrimaryTarget) : S5032SceneLoadRequest +{ + public override S5032Branch Branch => S5032Branch.IndexAndFutures; +} + +public sealed record S5032DomesticIndustryPairLoadRequest( + LegacyDomesticEquityMarket Market, + string FirstIndustry, + string SecondIndustry) : S5032SceneLoadRequest +{ + public override S5032Branch Branch => S5032Branch.DomesticIndustryPair; +} + +public sealed record S5032DomesticStockPairLoadRequest( + LegacyDomesticEquityMarket FirstMarket, + string FirstStock, + LegacyDomesticEquityMarket SecondMarket, + string SecondStock) : S5032SceneLoadRequest +{ + public override S5032Branch Branch => S5032Branch.DomesticStockPair; +} + +public sealed class S5032SceneDataLoader +{ + private readonly IDataQueryExecutor _executor; + + public S5032SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5032SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return request switch + { + S5032IndexAndFuturesLoadRequest index => await LoadIndexAsync(index, cancellationToken).ConfigureAwait(false), + S5032DomesticIndustryPairLoadRequest industry => await LoadIndustryAsync(industry, cancellationToken).ConfigureAwait(false), + S5032DomesticStockPairLoadRequest stocks => await LoadStocksAsync(stocks, cancellationToken).ConfigureAwait(false), + _ => throw new LegacySceneDataException("Unsupported s5032 load request.") + }; + } + + private async Task LoadIndexAsync( + S5032IndexAndFuturesLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.PrimaryTarget, nameof(request.PrimaryTarget)); + if (request.PrimaryTarget == LegacyMarketQuoteTarget.Futures) + { + throw new LegacySceneDataException("s5032 normalizes futures to the companion side."); + } + + var primary = await LoadMarketQuoteAsync(request.PrimaryTarget, cancellationToken).ConfigureAwait(false); + var futureQuery = ParameterizedMarketSceneQueries.FuturesDetail(); + var futureTable = await ExecuteAsync(futureQuery, cancellationToken).ConfigureAwait(false); + var future = ParameterizedSceneRowReader.SingleFuture(futureTable); + return new S5032IndexAndFuturesSceneData( + request.PrimaryTarget, + LegacyMarketQuoteTarget.Futures, + new S5032PrimaryQuote(primary.Title, primary.Price, primary.NetChange, primary.Rate, primary.Direction), + new S5032CompanionQuote( + ParameterizedSceneRowReader.String(future, "TITLE"), + ParameterizedSceneRowReader.Decimal(future, "PRICE"), + ParameterizedSceneRowReader.Decimal(future, "NET_CHANGE"), + ToCompanionSignal(ParameterizedSceneRowReader.Direction(future, "DIRECTION")), + ParameterizedSceneRowReader.Decimal(future, "NET_VOLUME"), + ParameterizedSceneRowReader.Decimal(future, "NET_TURNOVER"), + ParameterizedSceneRowReader.Decimal(future, "OUTSTANDING_VOLUME"))); + } + + private async Task LoadIndustryAsync( + S5032DomesticIndustryPairLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDomesticMarket(request.Market, allowNxt: false); + var firstName = ParameterizedSceneRowReader.Selector(request.FirstIndustry, nameof(request.FirstIndustry)); + var secondName = ParameterizedSceneRowReader.Selector(request.SecondIndustry, nameof(request.SecondIndustry)); + var first = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.DomesticIndustry(request.Market, firstName), + cancellationToken).ConfigureAwait(false); + var second = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.DomesticIndustry(request.Market, secondName), + cancellationToken).ConfigureAwait(false); + return new S5032DomesticIndustryPairSceneData( + request.Market, + new S5032PrimaryQuote(first.Title, first.Price, first.NetChange, first.Rate, first.Direction), + new S5032CompanionQuote(second.Title, second.Price, second.NetChange, + ToCompanionSignal(second.Direction), 0m, 0m, 0m)); + } + + private async Task LoadStocksAsync( + S5032DomesticStockPairLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDomesticMarket(request.FirstMarket, allowNxt: false); + S5001SceneMutationBuilder.RequireDomesticMarket(request.SecondMarket, allowNxt: false); + var firstName = ParameterizedSceneRowReader.Selector(request.FirstStock, nameof(request.FirstStock)); + var secondName = ParameterizedSceneRowReader.Selector(request.SecondStock, nameof(request.SecondStock)); + var first = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.CurrentStock(request.FirstMarket, firstName, halted: false), + cancellationToken).ConfigureAwait(false); + var second = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.CurrentStock(request.SecondMarket, secondName, halted: false), + cancellationToken).ConfigureAwait(false); + return new S5032DomesticStockPairSceneData( + request.FirstMarket, + request.SecondMarket, + ToStockQuote(first), + ToStockQuote(second)); + } + + private async Task LoadMarketQuoteAsync( + LegacyMarketQuoteTarget target, + CancellationToken cancellationToken) => await LoadQuoteAsync( + ParameterizedMarketSceneQueries.MarketQuote(target, expected: false), + cancellationToken).ConfigureAwait(false); + + private async Task LoadQuoteAsync( + ParameterizedSceneQuery query, + CancellationToken cancellationToken) + { + var table = await ExecuteAsync(query, cancellationToken).ConfigureAwait(false); + return ParameterizedSceneRowReader.Quote(ParameterizedSceneRowReader.SingleQuote(table)); + } + + private Task ExecuteAsync( + ParameterizedSceneQuery query, + CancellationToken cancellationToken) => _executor.ExecuteAsync( + query.Source, query.TableName, query.Spec, cancellationToken); + + private static S5032StockQuote ToStockQuote(ParameterizedQuoteRow value) => new( + value.Title, + ParameterizedSceneRowReader.CheckedInt64(value.Price), + ParameterizedSceneRowReader.CheckedInt64(value.NetChange), + value.Rate, + value.Direction); + + private static S5032CompanionSignal ToCompanionSignal(ScenePriceDirection direction) => direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => S5032CompanionSignal.Positive, + ScenePriceDirection.Flat => S5032CompanionSignal.Flat, + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => S5032CompanionSignal.Negative, + _ => throw new LegacySceneDataException("The companion quote returned an unknown direction.") + }; +} + +public enum S8018MarketDataMode +{ + Current, + Expected +} + +public abstract record S8018SceneLoadRequest +{ + private protected S8018SceneLoadRequest() + { + } + + public abstract S8018Branch Branch { get; } +} + +public sealed record S8018MarketPairLoadRequest( + S8018MarketDataMode Mode, + LegacyMarketQuoteTarget FirstTarget, + LegacyMarketQuoteTarget SecondTarget) : S8018SceneLoadRequest +{ + public override S8018Branch Branch => S8018Branch.MarketPair; +} + +public sealed record S8018MixedMarketAndStockLoadRequest( + S8018PairSide StockSide, + LegacyMarketQuoteTarget MarketTarget, + LegacyDomesticEquityMarket StockMarket, + string StockName, + S8018MarketDataMode Mode = S8018MarketDataMode.Current) : S8018SceneLoadRequest +{ + public override S8018Branch Branch => S8018Branch.MixedMarketAndStock; +} + +public sealed record S8018ExpectedStockPairLoadRequest( + LegacyDomesticEquityMarket FirstMarket, + string FirstStock, + LegacyDomesticEquityMarket SecondMarket, + string SecondStock) : S8018SceneLoadRequest +{ + public override S8018Branch Branch => S8018Branch.ExpectedStockPair; +} + +public sealed record S8018CurrentStockSelection(S8018StockMarket Market, string StockName); + +public sealed record S8018CurrentStockPairLoadRequest( + S8018SessionPhase Session, + S8018CurrentStockSelection First, + S8018CurrentStockSelection Second) : S8018SceneLoadRequest +{ + public override S8018Branch Branch => S8018Branch.CurrentStockPair; +} + +public sealed record S8018DomesticIndustryPairLoadRequest( + LegacyDomesticEquityMarket Market, + string FirstIndustry, + string SecondIndustry) : S8018SceneLoadRequest +{ + public override S8018Branch Branch => S8018Branch.DomesticIndustryPair; +} + +public sealed class S8018SceneDataLoader +{ + private readonly IDataQueryExecutor _executor; + + public S8018SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S8018SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return request switch + { + S8018MarketPairLoadRequest pair => await LoadMarketPairAsync(pair, cancellationToken).ConfigureAwait(false), + S8018MixedMarketAndStockLoadRequest mixed => await LoadMixedAsync(mixed, cancellationToken).ConfigureAwait(false), + S8018ExpectedStockPairLoadRequest expected => await LoadExpectedAsync(expected, cancellationToken).ConfigureAwait(false), + S8018CurrentStockPairLoadRequest current => await LoadCurrentAsync(current, cancellationToken).ConfigureAwait(false), + S8018DomesticIndustryPairLoadRequest industry => await LoadIndustryAsync(industry, cancellationToken).ConfigureAwait(false), + _ => throw new LegacySceneDataException("Unsupported s8018 load request.") + }; + } + + private async Task LoadMarketPairAsync( + S8018MarketPairLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.Mode, nameof(request.Mode)); + ValidateMarketTarget(request.FirstTarget, request.Mode); + ValidateMarketTarget(request.SecondTarget, request.Mode); + var expected = request.Mode == S8018MarketDataMode.Expected; + var first = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.MarketQuote(request.FirstTarget, expected), + cancellationToken).ConfigureAwait(false); + var second = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.MarketQuote(request.SecondTarget, expected), + cancellationToken).ConfigureAwait(false); + return new S8018MarketPairSceneData( + request.FirstTarget, + request.SecondTarget, + ToMarketQuote(first), + ToMarketQuote(second)); + } + + private async Task LoadMixedAsync( + S8018MixedMarketAndStockLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.StockSide, nameof(request.StockSide)); + S5001SceneMutationBuilder.RequireDefined(request.Mode, nameof(request.Mode)); + ValidateMarketTarget(request.MarketTarget, request.Mode); + S5001SceneMutationBuilder.RequireDomesticMarket(request.StockMarket, allowNxt: false); + var stockName = ParameterizedSceneRowReader.Selector(request.StockName, nameof(request.StockName)); + var expected = request.Mode == S8018MarketDataMode.Expected; + var stockQuery = expected + ? ParameterizedMarketSceneQueries.ExpectedStock(request.StockMarket, stockName) + : ParameterizedMarketSceneQueries.CurrentStock(request.StockMarket, stockName, halted: false); + var marketQuery = ParameterizedMarketSceneQueries.MarketQuote(request.MarketTarget, expected); + + ParameterizedQuoteRow market; + ParameterizedQuoteRow stock; + if (request.StockSide == S8018PairSide.First) + { + stock = await LoadQuoteAsync(stockQuery, cancellationToken).ConfigureAwait(false); + market = await LoadQuoteAsync(marketQuery, cancellationToken).ConfigureAwait(false); + } + else + { + market = await LoadQuoteAsync(marketQuery, cancellationToken).ConfigureAwait(false); + stock = await LoadQuoteAsync(stockQuery, cancellationToken).ConfigureAwait(false); + } + + return new S8018MixedMarketAndStockSceneData( + request.StockSide, + request.MarketTarget, + ToMarketQuote(market), + new S8018MixedStockQuote( + request.StockMarket, + stock.Title, + ParameterizedSceneRowReader.CheckedInt64(stock.Price), + ParameterizedSceneRowReader.CheckedInt64(stock.NetChange), + stock.Rate, + stock.Direction)); + } + + private async Task LoadExpectedAsync( + S8018ExpectedStockPairLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDomesticMarket(request.FirstMarket, allowNxt: false); + S5001SceneMutationBuilder.RequireDomesticMarket(request.SecondMarket, allowNxt: false); + var firstName = ParameterizedSceneRowReader.Selector(request.FirstStock, nameof(request.FirstStock)); + var secondName = ParameterizedSceneRowReader.Selector(request.SecondStock, nameof(request.SecondStock)); + var first = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.ExpectedStock(request.FirstMarket, firstName), + cancellationToken).ConfigureAwait(false); + var second = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.ExpectedStock(request.SecondMarket, secondName), + cancellationToken).ConfigureAwait(false); + return new S8018ExpectedStockPairSceneData( + ToExpectedStock(request.FirstMarket, first), + ToExpectedStock(request.SecondMarket, second)); + } + + private async Task LoadCurrentAsync( + S8018CurrentStockPairLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDefined(request.Session, nameof(request.Session)); + var firstSelection = ValidateSelection(request.First, nameof(request.First)); + var secondSelection = ValidateSelection(request.Second, nameof(request.Second)); + var first = await LoadCurrentSelectionAsync(firstSelection, cancellationToken).ConfigureAwait(false); + var second = await LoadCurrentSelectionAsync(secondSelection, cancellationToken).ConfigureAwait(false); + return new S8018CurrentStockPairSceneData( + request.Session, + ToCurrentStock(firstSelection.Market, first), + ToCurrentStock(secondSelection.Market, second)); + } + + private async Task LoadIndustryAsync( + S8018DomesticIndustryPairLoadRequest request, + CancellationToken cancellationToken) + { + S5001SceneMutationBuilder.RequireDomesticMarket(request.Market, allowNxt: false); + var firstName = ParameterizedSceneRowReader.Selector(request.FirstIndustry, nameof(request.FirstIndustry)); + var secondName = ParameterizedSceneRowReader.Selector(request.SecondIndustry, nameof(request.SecondIndustry)); + var first = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.DomesticIndustry(request.Market, firstName), + cancellationToken).ConfigureAwait(false); + var second = await LoadQuoteAsync( + ParameterizedMarketSceneQueries.DomesticIndustry(request.Market, secondName), + cancellationToken).ConfigureAwait(false); + return new S8018DomesticIndustryPairSceneData( + request.Market, + ToMarketQuote(first), + ToMarketQuote(second)); + } + + private async Task LoadCurrentSelectionAsync( + S8018CurrentStockSelection selection, + CancellationToken cancellationToken) + { + var rawName = ParameterizedSceneRowReader.Selector( + selection.StockName, + nameof(selection.StockName)); + var name = ParameterizedSceneRowReader.Selector( + rawName.Replace("(NXT)", string.Empty, StringComparison.Ordinal).Trim(), + nameof(selection.StockName)); + var query = selection.Market switch + { + S8018StockMarket.Kospi => ParameterizedMarketSceneQueries.CurrentStock( + LegacyDomesticEquityMarket.Kospi, name, halted: false), + S8018StockMarket.Kosdaq => ParameterizedMarketSceneQueries.CurrentStock( + LegacyDomesticEquityMarket.Kosdaq, name, halted: false), + S8018StockMarket.NxtKospi => ParameterizedMarketSceneQueries.CurrentStock( + LegacyDomesticEquityMarket.NxtKospi, name, halted: false), + S8018StockMarket.NxtKosdaq => ParameterizedMarketSceneQueries.CurrentStock( + LegacyDomesticEquityMarket.NxtKosdaq, name, halted: false), + S8018StockMarket.World => ParameterizedMarketSceneQueries.ForeignStock(name), + _ => throw new LegacySceneDataException("Unsupported s8018 stock market.") + }; + return await LoadQuoteAsync(query, cancellationToken).ConfigureAwait(false); + } + + private async Task LoadQuoteAsync( + ParameterizedSceneQuery query, + CancellationToken cancellationToken) + { + var table = await _executor.ExecuteAsync( + query.Source, query.TableName, query.Spec, cancellationToken).ConfigureAwait(false); + return ParameterizedSceneRowReader.Quote(ParameterizedSceneRowReader.SingleQuote(table)); + } + + private static S8018CurrentStockSelection ValidateSelection( + S8018CurrentStockSelection? selection, + string name) + { + var value = LegacySceneBuilderGuard.NotNull(selection, name); + S5001SceneMutationBuilder.RequireDefined(value.Market, name + ".Market"); + var stockName = ParameterizedSceneRowReader.Selector(value.StockName, name + ".StockName"); + return value with { StockName = stockName }; + } + + private static void ValidateMarketTarget( + LegacyMarketQuoteTarget target, + S8018MarketDataMode mode) + { + S5001SceneMutationBuilder.RequireDefined(target, nameof(target)); + if (mode == S8018MarketDataMode.Expected && target is not ( + LegacyMarketQuoteTarget.Kospi or LegacyMarketQuoteTarget.Kosdaq or + LegacyMarketQuoteTarget.Kospi200 or LegacyMarketQuoteTarget.Futures or + LegacyMarketQuoteTarget.Krx100)) + { + throw new LegacySceneDataException("Expected market data is supported only for domestic index targets."); + } + } + + private static S8018MarketQuote ToMarketQuote(ParameterizedQuoteRow value) => new( + value.Title, value.Price, value.NetChange, value.Rate, value.Direction); + + private static S8018ExpectedStockQuote ToExpectedStock( + LegacyDomesticEquityMarket market, + ParameterizedQuoteRow value) => new( + market, + value.Title, + ParameterizedSceneRowReader.CheckedInt64(value.Price), + ParameterizedSceneRowReader.CheckedInt64(value.NetChange), + value.Rate, + value.Direction); + + private static S8018CurrentStockQuote ToCurrentStock( + S8018StockMarket market, + ParameterizedQuoteRow value) => new( + market, value.Title, value.Price, value.NetChange, value.Rate, value.Direction); +} + +public sealed record S8001SceneLoadRequest(S8001Market Market); + +public sealed class S8001SceneDataLoader +{ + private readonly IDataQueryExecutor _executor; + + public S8001SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S8001SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + S5001SceneMutationBuilder.RequireDefined(request.Market, nameof(request.Market)); + var query = ParameterizedMarketSceneQueries.Sectors(request.Market); + var table = await _executor.ExecuteAsync( + query.Source, query.TableName, query.Spec, cancellationToken).ConfigureAwait(false); + var expectedCount = request.Market == S8001Market.Kospi ? 15 : 6; + var rows = ParameterizedSceneRowReader.ExactRows( + table, + expectedCount, + expectedCount, + "SECTOR_CODE", "SECTOR_NAME", "DIRECTION", "MAGNITUDE"); + var quotes = new List(expectedCount); + var seen = new HashSet(); + foreach (var row in rows) + { + var sector = request.Market == S8001Market.Kospi + ? KospiSector(ParameterizedSceneRowReader.String(row, "SECTOR_CODE")) + : KosdaqSector(ParameterizedSceneRowReader.String(row, "SECTOR_NAME")); + if (!seen.Add(sector)) + { + throw new LegacySceneDataException("The s8001 query returned a duplicate sector."); + } + + quotes.Add(new S8001SectorQuote( + sector, + ParameterizedSceneRowReader.SectorSignal(row, "DIRECTION"), + ParameterizedSceneRowReader.NonNegativeDecimal(row, "MAGNITUDE"))); + } + + return new S8001SceneData(request.Market, quotes); + } + + private static S8001Sector KospiSector(string value) => value switch + { + "005" => S8001Sector.FoodAndBeverage, + "006" => S8001Sector.TextilesAndApparel, + "007" => S8001Sector.PaperAndWood, + "008" => S8001Sector.Chemicals, + "009" => S8001Sector.NonMetallicMinerals, + "011" => S8001Sector.SteelAndMetal, + "012" => S8001Sector.Machinery, + "013" => S8001Sector.ElectricalAndElectronics, + "014" => S8001Sector.MedicalPrecision, + "015" => S8001Sector.TransportationEquipment, + "016" => S8001Sector.Distribution, + "018" => S8001Sector.Construction, + "019" => S8001Sector.TransportationWarehouse, + "020" => S8001Sector.Communications, + "021" => S8001Sector.Finance, + _ => throw new LegacySceneDataException("The s8001 query returned an unknown closed sector code.") + }; + + private static S8001Sector KosdaqSector(string value) => value switch + { + "기계장비" => S8001Sector.MachineryAndEquipment, + "IT부품" => S8001Sector.ItComponents, + "운송" => S8001Sector.Transportation, + "종이목재" => S8001Sector.PaperAndWood, + "반도체" => S8001Sector.Semiconductor, + "인터넷" => S8001Sector.Internet, + _ => throw new LegacySceneDataException("The s8001 query returned an unknown closed sector.") + }; +} + +internal sealed record ParameterizedQuoteRow( + string Title, + decimal Price, + decimal NetChange, + decimal Rate, + ScenePriceDirection Direction); + +internal static class ParameterizedSceneRowReader +{ + private static readonly string[] QuoteColumns = ["TITLE", "PRICE", "DIRECTION", "NET_CHANGE", "RATE"]; + private static readonly string[] FutureColumns = + [ + "TITLE", "PRICE", "DIRECTION", "NET_CHANGE", "RATE", + "NET_VOLUME", "OUTSTANDING_VOLUME", "NET_TURNOVER" + ]; + + public static DataRow SingleQuote(DataTable? table) => + ExactRows(table, 1, 1, QuoteColumns)[0]; + + public static DataRow SingleFuture(DataTable? table) => + ExactRows(table, 1, 1, FutureColumns)[0]; + + public static IReadOnlyList ExactRows( + DataTable? table, + int minimum, + int maximum, + params string[] columns) + { + if (table is null || table.Rows.Count < minimum || table.Rows.Count > maximum) + { + throw new LegacySceneDataException("The parameterized scene query returned an invalid row count."); + } + + if (table.Columns.Count != columns.Length) + { + throw new LegacySceneDataException("The parameterized scene query returned an unexpected schema."); + } + + var available = table.Columns.Cast() + .Select(column => column.ColumnName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (columns.Any(column => !available.Contains(column))) + { + throw new LegacySceneDataException("The parameterized scene query returned an unexpected schema."); + } + + return table.Rows.Cast().ToArray(); + } + + public static ParameterizedQuoteRow Quote(DataRow row) => new( + String(row, "TITLE"), + Decimal(row, "PRICE"), + Decimal(row, "NET_CHANGE"), + Decimal(row, "RATE"), + Direction(row, "DIRECTION")); + + public static string String(DataRow row, string column, bool allowEmpty = false) + { + var value = Required(row, column); + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + return LegacySceneBuilderGuard.Text(text, column, allowEmpty); + } + + public static decimal Decimal(DataRow row, string column) + { + try + { + return Convert.ToDecimal(Required(row, column), CultureInfo.InvariantCulture); + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw new LegacySceneDataException("The parameterized scene query returned an invalid decimal."); + } + } + + public static decimal NonNegativeDecimal(DataRow row, string column) + { + var value = Decimal(row, column); + return value >= 0 + ? value + : throw new LegacySceneDataException("The parameterized scene query returned a negative magnitude."); + } + + public static long Int64(DataRow row, string column) => CheckedInt64(Decimal(row, column)); + + public static long CheckedInt64(decimal value) + { + if (decimal.Truncate(value) != value || value is < long.MinValue or > long.MaxValue) + { + throw new LegacySceneDataException("The parameterized scene query returned an invalid integer."); + } + + return decimal.ToInt64(value); + } + + public static ScenePriceDirection Direction(DataRow row, string column) + { + var value = String(row, column, allowEmpty: true).Trim(); + return value switch + { + "1" or "상한" => ScenePriceDirection.LimitUp, + "2" or "상승" or "+" => ScenePriceDirection.Up, + "" or "3" or "보합" => ScenePriceDirection.Flat, + "5" or "하락" or "-" => ScenePriceDirection.Down, + "4" or "하한" => ScenePriceDirection.LimitDown, + _ => throw new LegacySceneDataException("The parameterized scene query returned an unknown direction.") + }; + } + + public static S8001SectorSignal SectorSignal(DataRow row, string column) => Direction(row, column) switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => S8001SectorSignal.Positive, + ScenePriceDirection.Flat => S8001SectorSignal.Flat, + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => S8001SectorSignal.Negative, + _ => throw new LegacySceneDataException("The parameterized scene query returned an unknown sector signal.") + }; + + public static string Selector(string? value, string name) + { + var text = LegacySceneBuilderGuard.Text(value, name); + if (text.Length > 128) + { + throw new LegacySceneDataException("A parameterized scene selector is too long."); + } + + return text; + } + + private static object Required(DataRow row, string column) => row[column] is { } value && value is not DBNull + ? value + : throw new LegacySceneDataException("The parameterized scene query returned a required null value."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs new file mode 100644 index 0000000..b8695ee --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs @@ -0,0 +1,729 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +internal sealed record ParameterizedSceneQuery( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec); + +internal static class ParameterizedMarketSceneQueries +{ + public static ParameterizedSceneQuery WorldFuture(string symbol) => + Oracle( + "SCENE_WORLD_FUTURE_QUOTE", + WorldFutureQuery, + new DataQueryParameter("symbol", symbol, DbType.String)); + + public static ParameterizedSceneQuery MarketQuote( + LegacyMarketQuoteTarget target, + bool expected) + { + S5001SceneMutationBuilder.RequireDefined(target, nameof(target)); + return expected ? ExpectedMarketQuote(target) : CurrentMarketQuote(target); + } + + public static ParameterizedSceneQuery FuturesDetail() => + Oracle( + "SCENE_FUTURES_DETAIL", + CurrentFuturesDetailQuery, + new DataQueryParameter("stockSequence", 1, DbType.Int32), + new DataQueryParameter("monthCategory", "1", DbType.String)); + + public static ParameterizedSceneQuery DomesticIndustry( + LegacyDomesticEquityMarket market, + string industryName) => market switch + { + LegacyDomesticEquityMarket.Kospi => Oracle( + "SCENE_KOSPI_INDUSTRY", + KospiIndustryQuery, + new DataQueryParameter("industryName", industryName, DbType.String)), + LegacyDomesticEquityMarket.Kosdaq => Oracle( + "SCENE_KOSDAQ_INDUSTRY", + KosdaqIndustryQuery, + new DataQueryParameter("industryName", industryName, DbType.String)), + LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq => + throw new LegacySceneDataException( + "The original DataManager has no NXT industry request implementation."), + _ => throw new LegacySceneDataException("Unsupported domestic industry market.") + }; + + public static ParameterizedSceneQuery ForeignIndustry(string industryName) => + Oracle( + "SCENE_FOREIGN_INDUSTRY", + ForeignIndustryQuery, + new DataQueryParameter("industryName", industryName, DbType.String)); + + public static ParameterizedSceneQuery ForeignStock(string stockName) => + Oracle( + "SCENE_FOREIGN_STOCK", + ForeignStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)); + + public static ParameterizedSceneQuery CurrentStock( + LegacyDomesticEquityMarket market, + string stockName, + bool halted) + { + if (halted) + { + return market switch + { + LegacyDomesticEquityMarket.Kospi => Oracle( + "SCENE_HALTED_KOSPI_STOCK", + HaltedKospiStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + LegacyDomesticEquityMarket.Kosdaq => Oracle( + "SCENE_HALTED_KOSDAQ_STOCK", + HaltedKosdaqStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + _ => throw new LegacySceneDataException("Unsupported halted stock market.") + }; + } + + return market switch + { + LegacyDomesticEquityMarket.Kospi => Oracle( + "SCENE_KOSPI_STOCK", + KospiStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + LegacyDomesticEquityMarket.Kosdaq => Oracle( + "SCENE_KOSDAQ_STOCK", + KosdaqStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + LegacyDomesticEquityMarket.NxtKospi => Maria( + "SCENE_NXT_KOSPI_STOCK", + NxtKospiStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + LegacyDomesticEquityMarket.NxtKosdaq => Maria( + "SCENE_NXT_KOSDAQ_STOCK", + NxtKosdaqStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + _ => throw new LegacySceneDataException("Unsupported current stock market.") + }; + } + + public static ParameterizedSceneQuery ExpectedStock( + LegacyDomesticEquityMarket market, + string stockName) => market switch + { + LegacyDomesticEquityMarket.Kospi => Oracle( + "SCENE_EXPECTED_KOSPI_STOCK", + ExpectedKospiStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + LegacyDomesticEquityMarket.Kosdaq => Oracle( + "SCENE_EXPECTED_KOSDAQ_STOCK", + ExpectedKosdaqStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + _ => throw new LegacySceneDataException("Unsupported expected stock market.") + }; + + public static ParameterizedSceneQuery AfterHoursStock( + LegacyDomesticEquityMarket market, + string stockName) => market switch + { + LegacyDomesticEquityMarket.Kospi => Oracle( + "SCENE_AFTER_HOURS_KOSPI_STOCK", + AfterHoursKospiStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + LegacyDomesticEquityMarket.Kosdaq => Oracle( + "SCENE_AFTER_HOURS_KOSDAQ_STOCK", + AfterHoursKosdaqStockQuery, + new DataQueryParameter("stockName", stockName, DbType.String)), + _ => throw new LegacySceneDataException("Unsupported after-hours stock market.") + }; + + public static ParameterizedSceneQuery Sectors(S8001Market market) => market switch + { + S8001Market.Kospi => Oracle( + "SCENE_8001_KOSPI_SECTORS", + KospiSectorsQuery), + S8001Market.Kosdaq => Oracle( + "SCENE_8001_KOSDAQ_SECTORS", + KosdaqSectorsQuery, + KosdaqSectorParameters()), + _ => throw new LegacySceneDataException("Unsupported s8001 market.") + }; + + private static ParameterizedSceneQuery CurrentMarketQuote(LegacyMarketQuoteTarget target) => target switch + { + LegacyMarketQuoteTarget.Kospi => DomesticIndex(KospiIndexQuery, "SCENE_KOSPI_INDEX", "001"), + LegacyMarketQuoteTarget.Kosdaq => DomesticIndex(KosdaqIndexQuery, "SCENE_KOSDAQ_INDEX", "001"), + LegacyMarketQuoteTarget.Kospi200 => DomesticIndex(Kospi200IndexQuery, "SCENE_KOSPI200_INDEX", "029"), + LegacyMarketQuoteTarget.Krx100 => DomesticIndex(Krx100IndexQuery, "SCENE_KRX100_INDEX", "043"), + LegacyMarketQuoteTarget.Futures => Oracle( + "SCENE_FUTURES_INDEX", + CurrentFuturesQuoteQuery, + new DataQueryParameter("stockSequence", 1, DbType.Int32), + new DataQueryParameter("monthCategory", "1", DbType.String)), + LegacyMarketQuoteTarget.WonDollar => Exchange("원/달러", "A09"), + LegacyMarketQuoteTarget.WonYen => Exchange("원/엔", "A10"), + LegacyMarketQuoteTarget.WonYuan => Exchange("원/위엔", "A90"), + LegacyMarketQuoteTarget.WonEuro => Exchange("원/유로", "A91"), + _ => ForeignIndex(ForeignSymbol(target)) + }; + + private static ParameterizedSceneQuery ExpectedMarketQuote(LegacyMarketQuoteTarget target) => target switch + { + LegacyMarketQuoteTarget.Kospi => DomesticIndex(ExpectedKospiIndexQuery, "SCENE_EXPECTED_KOSPI_INDEX", "001"), + LegacyMarketQuoteTarget.Kosdaq => DomesticIndex(ExpectedKosdaqIndexQuery, "SCENE_EXPECTED_KOSDAQ_INDEX", "001"), + LegacyMarketQuoteTarget.Kospi200 => DomesticIndex(ExpectedKospi200IndexQuery, "SCENE_EXPECTED_KOSPI200_INDEX", "029"), + LegacyMarketQuoteTarget.Krx100 => DomesticIndex(ExpectedKrx100IndexQuery, "SCENE_EXPECTED_KRX100_INDEX", "043"), + LegacyMarketQuoteTarget.Futures => Oracle( + "SCENE_EXPECTED_FUTURES_INDEX", + ExpectedFuturesQuoteQuery, + new DataQueryParameter("stockSequence", 1, DbType.Int32), + new DataQueryParameter("monthCategory", "1", DbType.String)), + _ => throw new LegacySceneDataException("Expected data is unavailable for this market target.") + }; + + private static ParameterizedSceneQuery DomesticIndex( + string sql, + string tableName, + string partCode) => Oracle( + tableName, + sql, + new DataQueryParameter("partCode", partCode, DbType.String)); + + private static ParameterizedSceneQuery Exchange(string displayName, string inputCode) => Oracle( + "SCENE_EXCHANGE_RATE", + ExchangeQuery, + new DataQueryParameter("displayName", displayName, DbType.String), + new DataQueryParameter("inputCode", inputCode, DbType.String)); + + private static ParameterizedSceneQuery ForeignIndex(string symbol) => Oracle( + "SCENE_FOREIGN_INDEX", + ForeignIndexQuery, + new DataQueryParameter("symbol", symbol, DbType.String)); + + private static string ForeignSymbol(LegacyMarketQuoteTarget target) => target switch + { + LegacyMarketQuoteTarget.Dow => "DJI@DJI", + LegacyMarketQuoteTarget.Nasdaq => "NAS@IXIC", + LegacyMarketQuoteTarget.Sp500 => "SPI@SPX", + LegacyMarketQuoteTarget.GermanyDax => "XTR@DAX30", + LegacyMarketQuoteTarget.UnitedKingdomFtse => "LNS@FTSE100", + LegacyMarketQuoteTarget.FranceCac => "PAS@CAC40", + LegacyMarketQuoteTarget.Nikkei => "NII@NI225", + LegacyMarketQuoteTarget.ShanghaiComposite => "SHS@000001", + LegacyMarketQuoteTarget.HangSeng => "HSI@HSI", + LegacyMarketQuoteTarget.TaiwanWeighted => "TWS@TI01", + LegacyMarketQuoteTarget.SingaporeStraitsTimes => "SGI@STI", + LegacyMarketQuoteTarget.ThailandSet => "THI@SET", + LegacyMarketQuoteTarget.PhilippinesComposite => "PHI@COMP", + // Preserve the original request mapping, including its historical symbol labels. + LegacyMarketQuoteTarget.MalaysiaKlse => "MYI@JKSE", + LegacyMarketQuoteTarget.IndonesiaComposite => "IDI@KLSE", + _ => throw new LegacySceneDataException("Unsupported foreign market target.") + }; + + private static ParameterizedSceneQuery Oracle( + string tableName, + string sql, + params DataQueryParameter[] parameters) => new( + DataSourceKind.Oracle, + tableName, + new DataQuerySpec(sql, parameters)); + + private static ParameterizedSceneQuery Maria( + string tableName, + string sql, + params DataQueryParameter[] parameters) => new( + DataSourceKind.MariaDb, + tableName, + new DataQuerySpec(sql, parameters)); + + private static DataQueryParameter[] KosdaqSectorParameters() => + [ + StringParameter("sector01", "기계장비"), + StringParameter("sector02", "IT부품"), + StringParameter("sector03", "운송"), + StringParameter("sector04", "종이목재"), + StringParameter("sector05", "반도체"), + StringParameter("sector06", "인터넷") + ]; + + private static DataQueryParameter StringParameter(string name, string value) => + new(name, value, DbType.String); + + private const string WorldFutureQuery = """ + SELECT a.f_input_name TITLE, + ABS(ROUND(b.f_last, 2)) PRICE, + CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+' + WHEN TO_NUMBER(b.f_diff) < 0 THEN '-' + ELSE '' END DIRECTION, + ABS(ROUND(b.f_diff, 2)) NET_CHANGE, + ROUND(b.f_rate, 2) RATE + FROM t_world_future_master a + JOIN t_world_future_sise b ON a.f_symb = b.f_symb + WHERE a.f_symb = :symbol + FETCH FIRST 2 ROWS ONLY + """; + + private const string KospiIndexQuery = """ + SELECT '코스피' TITLE, + ROUND(f_part_idx / 100, 2) PRICE, + f_chg_type DIRECTION, + ROUND(f_part_chg / 100, 2) NET_CHANGE, + ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE + FROM t_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string KosdaqIndexQuery = """ + SELECT '코스닥' TITLE, + ROUND(f_part_idx / 100, 2) PRICE, + f_chg_type DIRECTION, + ROUND(f_part_chg / 100, 2) NET_CHANGE, + ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE + FROM t_kosdaq_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string Kospi200IndexQuery = """ + SELECT '코스피200' TITLE, + f_part_idx / 100 PRICE, + f_chg_type DIRECTION, + f_part_chg / 100 NET_CHANGE, + ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE + FROM t_200_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string Krx100IndexQuery = """ + SELECT 'KRX100' TITLE, + f_part_idx / 100 PRICE, + f_chg_type DIRECTION, + f_part_chg / 100 NET_CHANGE, + ROUND(f_part_chg / DECODE(f_chg_type, '+', (f_part_idx / 100) - (f_part_chg / 100), + '-', (f_part_idx / 100) + (f_part_chg / 100), 1), 2) RATE + FROM t_krx100_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string ExpectedKospiIndexQuery = """ + SELECT '코스피 예상체결지수' TITLE, + ROUND(f_part_idx / 100, 2) PRICE, + f_chg_type DIRECTION, + ABS(ROUND(f_part_chg / 100, 2)) NET_CHANGE, + ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE + FROM t_fo_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string ExpectedKosdaqIndexQuery = """ + SELECT '코스닥 예상체결지수' TITLE, + ROUND(f_part_idx / 100, 2) PRICE, + f_chg_type DIRECTION, + ABS(ROUND(f_part_chg / 100, 2)) NET_CHANGE, + ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE + FROM t_kosdaq_fo_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string ExpectedKospi200IndexQuery = """ + SELECT '코스피200 예상체결지수' TITLE, + f_part_idx / 100 PRICE, + f_chg_type DIRECTION, + ABS(ROUND(f_part_chg / 100, 2)) NET_CHANGE, + ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg, + '-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE + FROM t_200_fo_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string ExpectedKrx100IndexQuery = """ + SELECT 'KRX100 예상체결지수' TITLE, + f_part_idx / 100 PRICE, + f_chg_type DIRECTION, + f_part_chg / 100 NET_CHANGE, + ROUND(f_part_chg / DECODE(f_chg_type, '+', (f_part_idx / 100) - (f_part_chg / 100), + '-', (f_part_idx / 100) + (f_part_chg / 100), 1), 2) RATE + FROM t_krx100_fo_index + WHERE f_part_code = :partCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string CurrentFuturesQuoteQuery = """ + SELECT '선물' TITLE, + DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) PRICE, + DECODE(a.f_curr_price, 0, '', + DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100), 1, '+', -1, '-', 0, '')) DIRECTION, + ABS(DECODE(a.f_curr_price, 0, 0, + (a.f_curr_price - b.f_jun_last_price * 100) / 100)) NET_CHANGE, + ABS(ROUND(DECODE(a.f_curr_price, 0, 0, + (a.f_curr_price - b.f_jun_last_price * 100) / 100) / + DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100), + 1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) - + DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100), + -1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) + + DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100), 1), 2)) RATE + FROM t_sunmul_online a + JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code + WHERE a.f_stock_seq = :stockSequence + AND b.f_market_date = (SELECT MAX(open_day) FROM v_open_day) + AND b.f_month_gubun = :monthCategory + FETCH FIRST 2 ROWS ONLY + """; + + private const string CurrentFuturesDetailQuery = """ + SELECT '선물' TITLE, + DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) PRICE, + DECODE(a.f_curr_price, 0, '', + DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100), 1, '+', -1, '-', 0, '')) DIRECTION, + ABS(DECODE(a.f_curr_price, 0, 0, + (a.f_curr_price - b.f_jun_last_price * 100) / 100)) NET_CHANGE, + ABS(ROUND(DECODE(a.f_curr_price, 0, 0, + (a.f_curr_price - b.f_jun_last_price * 100) / 100) / + DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100), + 1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) - + DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100), + -1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) + + DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100), 1), 2)) RATE, + a.f_net_vol NET_VOLUME, + a.f_out_vol OUTSTANDING_VOLUME, + a.f_net_turnover NET_TURNOVER + FROM t_sunmul_online a + JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code + WHERE a.f_stock_seq = :stockSequence + AND b.f_market_date = (SELECT MAX(open_day) FROM v_open_day) + AND b.f_month_gubun = :monthCategory + FETCH FIRST 2 ROWS ONLY + """; + + private const string ExpectedFuturesQuoteQuery = """ + SELECT '선물 예상체결지수' TITLE, + a.f_fo_price / 100 PRICE, + DECODE(SIGN(a.f_fo_price - a.f_base_price), 1, '+', -1, '-', 0, '') DIRECTION, + ABS(ROUND((a.f_fo_price - a.f_base_price) / 100, 2)) NET_CHANGE, + ROUND(((a.f_fo_price - a.f_base_price) / 100) / + DECODE(SIGN(a.f_fo_price - a.f_base_price), + 1, (a.f_fo_price / 100) - ((a.f_fo_price - a.f_base_price) / 100), + -1, (a.f_fo_price / 100) + ((a.f_fo_price - a.f_base_price) / 100), 1), 2) RATE + FROM t_sunmul_online a + JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code + WHERE a.f_stock_seq = :stockSequence + AND b.f_month_gubun = :monthCategory + FETCH FIRST 2 ROWS ONLY + """; + + private const string ForeignIndexQuery = """ + SELECT a.f_input_name TITLE, + ROUND(b.f_last, 2) PRICE, + b.f_sign DIRECTION, + ROUND(b.f_diff, 2) NET_CHANGE, + ROUND(b.f_rate, 2) RATE + FROM t_world_ix_eq_master a + JOIN t_world_ix_eq_sise b ON a.f_symb = b.f_symb + WHERE a.f_symb = :symbol + FETCH FIRST 2 ROWS ONLY + """; + + private const string ExchangeQuery = """ + SELECT :displayName TITLE, + a.f_curr_price PRICE, + a.f_chg_type DIRECTION, + a.f_net_chg NET_CHANGE, + ROUND((a.f_net_chg / DECODE(a.f_chg_type, + '+', a.f_curr_price - a.f_net_chg, + '-', a.f_curr_price + a.f_net_chg, 1)) * 100, 2) RATE + FROM t_input_index a + JOIN t_input_code b ON a.f_input_code = b.f_input_code + WHERE a.f_input_code = :inputCode + FETCH FIRST 2 ROWS ONLY + """; + + private const string KospiIndustryQuery = """ + SELECT b.f_part_name TITLE, + ROUND(a.f_part_idx / 100, 2) PRICE, + a.f_chg_type DIRECTION, + ROUND(a.f_part_chg / 100, 2) NET_CHANGE, + ROUND((a.f_part_chg / DECODE(a.f_chg_type, + '+', a.f_part_idx - a.f_part_chg, + '-', a.f_part_idx + a.f_part_chg, 1)) * 100, 2) RATE + FROM t_index a + JOIN t_part b ON a.f_part_code = b.f_part_code + WHERE b.f_part_name = :industryName + FETCH FIRST 2 ROWS ONLY + """; + + private const string KosdaqIndustryQuery = """ + SELECT b.f_part_name TITLE, + ROUND(a.f_part_idx / 100, 2) PRICE, + a.f_chg_type DIRECTION, + ROUND(a.f_part_chg / 100, 2) NET_CHANGE, + ROUND((a.f_part_chg / DECODE(a.f_chg_type, + '+', a.f_part_idx - a.f_part_chg, + '-', a.f_part_idx + a.f_part_chg, 1)) * 100, 2) RATE + FROM t_kosdaq_index a + JOIN t_kosdaq_part b ON a.f_part_code = b.f_part_code + WHERE b.f_part_name = :industryName + FETCH FIRST 2 ROWS ONLY + """; + + private const string ForeignIndustryQuery = """ + SELECT b.f_knam TITLE, + a.f_last PRICE, + a.f_sign DIRECTION, + ABS(ROUND(a.f_diff, 2)) NET_CHANGE, + ROUND(a.f_rate, 2) RATE + FROM t_world_ix_eq_sise a + JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb + WHERE b.f_knam = :industryName + FETCH FIRST 2 ROWS ONLY + """; + + private const string ForeignStockQuery = """ + SELECT b.f_input_name TITLE, + a.f_last PRICE, + a.f_sign DIRECTION, + ABS(ROUND(a.f_diff, 2)) NET_CHANGE, + ROUND(a.f_rate, 2) RATE + FROM t_world_ix_eq_sise a + JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb + WHERE b.f_input_name = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string KospiStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_curr_price PRICE, + DECODE(a.f_chg_type, '1', '상한', '2', '상승', '3', '보합', '4', '하한', '5', '하락') DIRECTION, + ABS(a.f_net_chg) NET_CHANGE, + ROUND((a.f_net_chg / DECODE(a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND a.f_curr_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string KosdaqStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_curr_price PRICE, + DECODE(a.f_chg_type, '1', '상한', '2', '상승', '3', '보합', '4', '하한', '5', '하락') DIRECTION, + a.f_net_chg NET_CHANGE, + ROUND((a.f_net_chg / DECODE(a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE + FROM t_kosdaq_online1 a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND a.f_curr_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string HaltedKospiStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_curr_price PRICE, + DECODE(a.f_chg_type, '1', '상한', '2', '상승', '3', '보합', '4', '하한', '5', '하락') DIRECTION, + ABS(a.f_net_chg) NET_CHANGE, + ROUND((a.f_net_chg / DECODE(a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE + FROM t_stop_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'Y' + AND a.f_curr_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string HaltedKosdaqStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_curr_price PRICE, + DECODE(a.f_chg_type, '1', '상한', '2', '상승', '3', '보합', '4', '하한', '5', '하락') DIRECTION, + ABS(a.f_net_chg) NET_CHANGE, + ROUND((a.f_net_chg / DECODE(a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE + FROM t_stop_kosdaq_online1 a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'Y' + AND a.f_curr_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string NxtKospiStockQuery = """ + SELECT DISTINCT b.f_stock_name TITLE, + a.f_curr_price PRICE, + CASE WHEN a.f_chg_type = '1' THEN '상한' + WHEN a.f_chg_type = '2' THEN '상승' + WHEN a.f_chg_type = '3' THEN '보합' + WHEN a.f_chg_type = '4' THEN '하한' + WHEN a.f_chg_type = '5' THEN '하락' END DIRECTION, + ABS(a.f_net_chg) NET_CHANGE, + ROUND((a.f_net_chg / + CASE WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg + WHEN a.f_chg_type = '3' THEN a.f_curr_price + WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) RATE + FROM n_online a + JOIN n_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_stop_gubun = 'N' + AND a.f_curr_price != 0 + AND b.f_stock_name = @stockName + LIMIT 2 + """; + + private const string NxtKosdaqStockQuery = """ + SELECT DISTINCT b.f_stock_name TITLE, + a.f_curr_price PRICE, + CASE WHEN a.f_chg_type = '1' THEN '상한' + WHEN a.f_chg_type = '2' THEN '상승' + WHEN a.f_chg_type = '3' THEN '보합' + WHEN a.f_chg_type = '4' THEN '하한' + WHEN a.f_chg_type = '5' THEN '하락' END DIRECTION, + a.f_net_chg NET_CHANGE, + ROUND((a.f_net_chg / + CASE WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg + WHEN a.f_chg_type = '3' THEN a.f_curr_price + WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) RATE + FROM n_kosdaq_online a + JOIN n_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_stop_gubun = 'N' + AND a.f_curr_price != 0 + AND b.f_stock_name = @stockName + LIMIT 2 + """; + + private const string ExpectedKospiStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_fore_price PRICE, + CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '+' + WHEN a.f_fore_price - c.f_final_price < 0 THEN '-' + ELSE '' END DIRECTION, + ABS(a.f_fore_price - c.f_final_price) NET_CHANGE, + ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE + FROM t_online1_call a + JOIN t_stock b ON b.f_stock_code = a.f_stock_code + JOIN t_batch_day c ON c.f_stock_code = a.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND a.f_fore_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string ExpectedKosdaqStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_fore_price PRICE, + CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '+' + WHEN a.f_fore_price - c.f_final_price < 0 THEN '-' + ELSE '' END DIRECTION, + ABS(a.f_fore_price - c.f_final_price) NET_CHANGE, + ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE + FROM t_kosdaq_online1_call a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + JOIN t_kosdaq_batch_day c ON c.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND a.f_fore_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string AfterHoursKospiStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_curr_price PRICE, + DECODE(a.f_chg_type, '1', '상한', '2', '상승', '3', '보합', '4', '하한', '5', '하락') DIRECTION, + a.f_net_chg NET_CHANGE, + ROUND((a.f_net_chg / DECODE(a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE + FROM t_overtime_sise a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_sect_code = 'ST' + AND a.f_curr_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string AfterHoursKosdaqStockQuery = """ + SELECT DISTINCT b.f_stock_wanname TITLE, + a.f_curr_price PRICE, + DECODE(a.f_chg_type, '1', '상한', '2', '상승', '3', '보합', '4', '하한', '5', '하락') DIRECTION, + a.f_net_chg NET_CHANGE, + ROUND((a.f_net_chg / DECODE(a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE + FROM t_kosdaq_overtime_sise a + JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND b.f_sect_code = 'ST' + AND a.f_curr_price <> 0 + AND b.f_stock_wanname = :stockName + FETCH FIRST 2 ROWS ONLY + """; + + private const string KospiSectorsQuery = """ + SELECT a.f_part_code SECTOR_CODE, + b.f_part_name SECTOR_NAME, + a.f_chg_type DIRECTION, + ABS(ROUND(a.f_part_chg / + DECODE(a.f_chg_type, + '+', (a.f_part_idx / 100) - (a.f_part_chg / 100), + '-', (a.f_part_idx / 100) + (a.f_part_chg / 100), 1), 2)) MAGNITUDE + FROM t_index a + JOIN t_part b ON a.f_part_code = b.f_part_code + WHERE a.f_part_code IN ( + '013', '009', '011', '016', '018', + '021', '008', '020', '007', '012', + '015', '005', '014', '006', '019') + ORDER BY a.f_part_code + FETCH FIRST 15 ROWS ONLY + """; + + private const string KosdaqSectorsQuery = """ + SELECT a.f_part_code SECTOR_CODE, + b.f_part_name SECTOR_NAME, + a.f_chg_type DIRECTION, + ABS(ROUND(a.f_part_chg / + DECODE(a.f_chg_type, + '+', (a.f_part_idx / 100) - (a.f_part_chg / 100), + '-', (a.f_part_idx / 100) + (a.f_part_chg / 100), 1), 2)) MAGNITUDE + FROM t_kosdaq_index a + JOIN t_kosdaq_part b ON a.f_part_code = b.f_part_code + WHERE b.f_part_name IN ( + :sector01, :sector02, :sector03, + :sector04, :sector05, :sector06) + ORDER BY a.f_part_code + FETCH FIRST 7 ROWS ONLY + """; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs new file mode 100644 index 0000000..6f384fc --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs @@ -0,0 +1,697 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum LegacyDomesticEquityMarket +{ + Kospi, + Kosdaq, + NxtKospi, + NxtKosdaq +} + +public sealed record S5006SceneData( + LegacyDomesticEquityMarket Market, + string StockName, + ScenePriceDirection Direction, + long CurrentPrice, + long NetChange, + decimal Rate, + long OpenPrice, + decimal OpenRate, + long HighPrice, + decimal HighRate, + long LowPrice, + decimal LowRate) : ILegacySceneData; + +public sealed class S5006SceneMutationBuilder : ILegacySceneMutationBuilder +{ + private const string BackgroundAsset = @"Video\큐브배경.vrv"; + + public string BuilderKey => "s5006"; + + public IReadOnlyList Build(S5006SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + ClosedSceneMappings.RequireDomesticMarket(data.Market); + var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + var direction = ClosedSceneMappings.RequireDirection(data.Direction); + var variant = ClosedSceneMappings.DirectionValueVariant(direction); + + var mutations = new List(38) + { + new PlayoutSetValue("title", stockName), + new PlayoutSetBackgroundVideo( + BackgroundAsset, + 1, + true, + PlayoutMutationTiming.InTransaction), + new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)), + new PlayoutSetValue("changePrice" + variant, FormatInteger(data.NetChange)), + new PlayoutSetValue("rate" + variant, FormatRate(data.Rate)), + new PlayoutSetValue("price1", FormatInteger(data.OpenPrice)), + new PlayoutSetValue("ratio1", FormatRate(data.OpenRate)), + new PlayoutSetValue("price2", FormatInteger(data.HighPrice)), + new PlayoutSetValue("ratio2", FormatRate(data.HighRate)), + new PlayoutSetValue("price3", FormatInteger(data.LowPrice)), + new PlayoutSetValue("ratio3", FormatRate(data.LowRate)) + }; + + ClosedSceneMappings.AddDirectionHides(mutations, string.Empty); + mutations.AddRange(LegacyPriceDirectionMutations.Build(direction, 0)); + return mutations; + } + + private static string FormatInteger(long value) => + value.ToString("#,##0", CultureInfo.InvariantCulture); + + private static string FormatRate(decimal value) => + value.ToString("##0.00", CultureInfo.InvariantCulture); +} + +public enum S5011DetailBranch +{ + FaceValue, + Valuation, + Volume +} + +public abstract record S5011DetailData +{ + private protected S5011DetailData() + { + } + + public abstract S5011DetailBranch Branch { get; } +} + +public sealed record S5011FaceValueDetail( + long ListedPrice, + long CapitalPrice, + long MarketCapitalization, + long MarketRank) : S5011DetailData +{ + public override S5011DetailBranch Branch => S5011DetailBranch.FaceValue; +} + +public sealed record S5011ValuationDetail( + decimal Pbr, + decimal Per, + long Bps, + long Eps) : S5011DetailData +{ + public override S5011DetailBranch Branch => S5011DetailBranch.Valuation; +} + +public sealed record S5011VolumeDetail( + long Volume, + long Turnover, + long FiveDayAverage, + long TwentyDayAverage) : S5011DetailData +{ + public override S5011DetailBranch Branch => S5011DetailBranch.Volume; +} + +public sealed record S5011SceneData( + LegacyDomesticEquityMarket Market, + string StockName, + ScenePriceDirection Direction, + long CurrentPrice, + long NetChange, + decimal Rate, + S5011DetailData Detail) : ILegacySceneData; + +public sealed class S5011SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5011"; + + public IReadOnlyList Build(S5011SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var market = ClosedSceneMappings.RequireDomesticMarket(data.Market); + var rawName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + var stockName = LegacySceneBuilderGuard.Text( + rawName.Replace("(NXT)", string.Empty, StringComparison.Ordinal), + nameof(data.StockName)); + var direction = ClosedSceneMappings.RequireDirection(data.Direction); + var detail = LegacySceneBuilderGuard.NotNull(data.Detail, nameof(data.Detail)); + var variant = ClosedSceneMappings.DirectionValueVariant(direction); + var mutations = new List(44) + { + new PlayoutSetValue("title", stockName) + }; + + AddDetailMutations(mutations, detail); + mutations.Add(new PlayoutSetValue("price", FormatInteger(data.CurrentPrice))); + mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(data.NetChange))); + mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate))); + ClosedSceneMappings.AddDirectionHides(mutations, string.Empty); + mutations.AddRange(LegacyPriceDirectionMutations.Build(direction, 0)); + mutations.Add(new PlayoutSetVisible("mart", ClosedSceneMappings.IsNxt(market))); + return mutations; + } + + private static void AddDetailMutations( + ICollection mutations, + S5011DetailData detail) + { + switch (detail) + { + case S5011FaceValueDetail faceValue: + AddLabels( + mutations, + ["원", "억원", "억원", "위"], + ["액면가", "자본금", "시가총액", "시총순위"]); + AddValues( + mutations, + [ + FormatInteger(faceValue.ListedPrice), + FormatInteger(faceValue.CapitalPrice / 100_000_000L), + FormatInteger(faceValue.MarketCapitalization / 100_000_000L), + FormatInteger(faceValue.MarketRank) + ]); + break; + + case S5011ValuationDetail valuation: + AddLabels( + mutations, + ["배", "배", "원", "원"], + ["PBR", "PER", "BPS", "EPS"]); + AddValues( + mutations, + [ + valuation.Pbr.ToString(CultureInfo.InvariantCulture), + valuation.Per.ToString(CultureInfo.InvariantCulture), + FormatInteger(valuation.Bps), + FormatInteger(valuation.Eps) + ]); + break; + + case S5011VolumeDetail volume: + AddLabels( + mutations, + ["주", "백만", "원", "원"], + ["거래량", "거래대금", "5MA", "20MA"]); + AddValues( + mutations, + [ + FormatInteger(volume.Volume), + FormatInteger(volume.Turnover / 1_000_000L), + volume.FiveDayAverage.ToString("#,##0.###", CultureInfo.InvariantCulture), + volume.TwentyDayAverage.ToString("#,##0.###", CultureInfo.InvariantCulture) + ]); + break; + + default: + throw new LegacySceneDataException("Unknown s5011 detail branch."); + } + } + + private static void AddLabels( + ICollection mutations, + IReadOnlyList units, + IReadOnlyList titles) + { + for (var index = 1; index <= 4; index++) + { + mutations.Add(new PlayoutSetValue("r_unit" + index, units[index - 1])); + } + + for (var index = 1; index <= 4; index++) + { + mutations.Add(new PlayoutSetValue("r_title" + index, titles[index - 1])); + } + } + + private static void AddValues( + ICollection mutations, + IReadOnlyList values) + { + for (var index = 1; index <= 4; index++) + { + mutations.Add(new PlayoutSetValue("r_value" + index, values[index - 1])); + } + } + + private static string FormatInteger(long value) => + value.ToString("#,##0", CultureInfo.InvariantCulture); + + private static string FormatRate(decimal value) => + value.ToString("##0.00", CultureInfo.InvariantCulture); +} + +public enum LegacyPanelQuoteSignal +{ + Positive, + Flat, + Negative +} + +public sealed record LegacyPanelQuoteData( + string Name, + string? PartName, + decimal Price, + decimal Change, + decimal Rate, + LegacyPanelQuoteSignal Signal); + +public enum S5016PanelTarget +{ + UnitedStatesIndices, + GreaterChinaIndices, + EuropeanIndices, + AsianIndices, + UnitedStatesTreasuries, + BondYields, + Oil, + Minerals, + FoodMaterials, + OverseasExchangeRates, + DomesticExchangeRates, + AgricultureWheatCorn, + AgricultureSoyRice, + AgricultureCoffeeCocoaSugar, + AgricultureLivestock, + RawMaterialsCopperIronNickel, + RawMaterialsGasPlatinumPalladium, + RawMaterialsLeadZincTin, + MajorKospiKosdaqKospi200, + MajorKospiKosdaqFutures, + ExpectedKospiKosdaqKospi200, + ExpectedKospiKosdaqFutures +} + +public sealed record S5016SceneData( + S5016PanelTarget Target, + IReadOnlyList Quotes) : ILegacySceneData; + +public sealed class S5016SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5016"; + + public IReadOnlyList Build(S5016SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var metadata = Metadata(data.Target); + var quotes = LegacySceneBuilderGuard.Count(data.Quotes, 3, nameof(data.Quotes)); + var mutations = new List(79) + { + new PlayoutSetValue("title", metadata.Title) + }; + + for (var index = 1; index <= 3; index++) + { + var quote = LegacySceneBuilderGuard.NotNull(quotes[index - 1], $"Quotes[{index - 1}]"); + var name = metadata.UsePartName + ? LegacySceneBuilderGuard.Text(quote.PartName, $"Quotes[{index - 1}].PartName") + : LegacySceneBuilderGuard.Text(quote.Name, $"Quotes[{index - 1}].Name") + .Replace("KOSPI", "코스피", StringComparison.Ordinal) + .Replace("KOSQ", "코스닥", StringComparison.Ordinal); + LegacyPanelQuoteMutations.AddQuote( + mutations, + index, + name, + quote, + metadata.ChangeDecimalPlaces); + mutations.Add(new PlayoutSetValue("unitText", metadata.UnitText)); + } + + return mutations; + } + + private static S5016Metadata Metadata(S5016PanelTarget target) => target switch + { + S5016PanelTarget.UnitedStatesIndices => new("미국 지수", "단위 : p"), + S5016PanelTarget.GreaterChinaIndices => new("중화권 지수", "단위 : p"), + S5016PanelTarget.EuropeanIndices => new("유럽 지수", "단위 : p"), + S5016PanelTarget.AsianIndices => new("아시아 지수", "단위 : p"), + S5016PanelTarget.UnitedStatesTreasuries => new("미국 국채", "단위 : %"), + S5016PanelTarget.BondYields => new("채권 금리", "단위 : %", UsePartName: true), + S5016PanelTarget.Oil => new("유가", "단위 : 배럴/달러"), + S5016PanelTarget.Minerals => new("광물", "단위 : $"), + S5016PanelTarget.FoodMaterials => new("식자재", "단위 : ¢"), + S5016PanelTarget.OverseasExchangeRates => new( + "해외 환율", + "단위 : $", + ChangeDecimalPlaces: 4), + S5016PanelTarget.DomesticExchangeRates => new("환율", "단위 : 원"), + S5016PanelTarget.AgricultureWheatCorn or + S5016PanelTarget.AgricultureSoyRice or + S5016PanelTarget.AgricultureCoffeeCocoaSugar or + S5016PanelTarget.AgricultureLivestock => new("농산물", "단위 : $"), + S5016PanelTarget.RawMaterialsCopperIronNickel or + S5016PanelTarget.RawMaterialsGasPlatinumPalladium or + S5016PanelTarget.RawMaterialsLeadZincTin => new("원자재", "단위 : $"), + S5016PanelTarget.MajorKospiKosdaqKospi200 or + S5016PanelTarget.MajorKospiKosdaqFutures => new("주요지수", "단위 : p"), + S5016PanelTarget.ExpectedKospiKosdaqKospi200 or + S5016PanelTarget.ExpectedKospiKosdaqFutures => new("예상체결지수", "단위 : p"), + _ => throw new LegacySceneDataException("Unknown s5016 panel target.") + }; + + private readonly record struct S5016Metadata( + string Title, + string UnitText, + bool UsePartName = false, + int ChangeDecimalPlaces = 2); +} + +public enum S50160PanelTarget +{ + Cotton, + InternationalPreciousMetals, + DomesticPreciousMetals +} + +public sealed record S50160SceneData( + S50160PanelTarget Target, + IReadOnlyList Quotes) : ILegacySceneData; + +public sealed class S50160SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s50160"; + + public IReadOnlyList Build(S50160SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var metadata = Metadata(data.Target); + var quotes = LegacySceneBuilderGuard.Count(data.Quotes, 2, nameof(data.Quotes)); + var mutations = new List(59) + { + new PlayoutSetValue("title", metadata.Title) + }; + + for (var index = 1; index <= 2; index++) + { + var quote = LegacySceneBuilderGuard.NotNull(quotes[index - 1], $"Quotes[{index - 1}]"); + var name = LegacySceneBuilderGuard.Text(quote.Name, $"Quotes[{index - 1}].Name") + .Replace("KOSPI", "코스피", StringComparison.Ordinal) + .Replace("KOSQ", "코스닥", StringComparison.Ordinal); + LegacyPanelQuoteMutations.AddQuote(mutations, index, name, quote, 2); + mutations.Add(new PlayoutSetValue("unit" + index + "_1", string.Empty)); + mutations.Add(new PlayoutSetValue("unit" + index + "_2", string.Empty)); + mutations.Add(new PlayoutSetValue("unit" + index + "_3", string.Empty)); + mutations.Add(new PlayoutSetValue("unitText", metadata.UnitText)); + } + + return mutations; + } + + private static S50160Metadata Metadata(S50160PanelTarget target) => target switch + { + S50160PanelTarget.Cotton => new("농산물", "단위 : $"), + S50160PanelTarget.InternationalPreciousMetals => new("원자재", "단위 : $"), + S50160PanelTarget.DomesticPreciousMetals => new("원자재", "단위 : 원"), + _ => throw new LegacySceneDataException("Unknown s50160 panel target.") + }; + + private readonly record struct S50160Metadata(string Title, string UnitText); +} + +public enum S6001QuoteTarget +{ + DowJones, + Nasdaq, + StandardAndPoor500, + Germany, + UnitedKingdom, + France, + Japan, + China, + HongKong, + Taiwan, + Singapore, + Thailand, + Philippines, + Malaysia, + Indonesia, + DubaiCrude, + BrentCrude, + WtiCrude, + Gold +} + +public sealed record S6001SceneData( + S6001QuoteTarget Target, + string Title, + decimal Price, + decimal Change, + decimal Rate, + ScenePriceDirection Direction) : ILegacySceneData; + +public sealed class S6001SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s6001"; + + public IReadOnlyList Build(S6001SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var metadata = Metadata(data.Target); + var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); + var direction = ClosedSceneMappings.RequireDirection(data.Direction); + if (data.Change == decimal.MinValue || data.Rate == decimal.MinValue) + { + throw new LegacySceneDataException("s6001 quote values are outside the supported range."); + } + + var mutations = new List(19); + if (metadata.IsIndex) + { + mutations.Add(new PlayoutSetValue("title", title)); + mutations.Add(new PlayoutSetAssetValue("movie", metadata.AssetPath)); + mutations.Add(new PlayoutSetValue("price", FormatDecimal(data.Price))); + mutations.Add(new PlayoutSetValue("changePrice", FormatDecimal(Math.Abs(data.Change)))); + var signedRate = direction is ScenePriceDirection.Down or ScenePriceDirection.LimitDown + ? -Math.Abs(data.Rate) + : data.Rate; + mutations.Add(new PlayoutSetValue("rate", FormatDecimal(signedRate))); + mutations.Add(new PlayoutSetValue("wonText", string.Empty)); + mutations.Add(new PlayoutSetValue("unit", metadata.UnitText)); + } + else + { + mutations.Add(new PlayoutSetAssetValue("movie", metadata.AssetPath)); + mutations.Add(new PlayoutSetValue("unit", metadata.UnitText)); + mutations.Add(new PlayoutSetValue("title", title)); + mutations.Add(new PlayoutSetValue("price", FormatDecimal(data.Price))); + mutations.Add(new PlayoutSetValue("changePrice", FormatDecimal(data.Change))); + mutations.Add(new PlayoutSetValue("rate", FormatDecimal(data.Rate))); + mutations.Add(new PlayoutSetValue("wonText", string.Empty)); + } + + S6001DirectionMutations.Add(mutations, direction); + return mutations; + } + + private static S6001Metadata Metadata(S6001QuoteTarget target) => target switch + { + S6001QuoteTarget.DowJones or + S6001QuoteTarget.Nasdaq or + S6001QuoteTarget.StandardAndPoor500 => Index("미국"), + S6001QuoteTarget.Germany => Index("독일"), + S6001QuoteTarget.UnitedKingdom => Index("영국"), + S6001QuoteTarget.France => Index("프랑스"), + S6001QuoteTarget.Japan => Index("일본"), + S6001QuoteTarget.China => Index("중국"), + S6001QuoteTarget.HongKong => Index("홍콩"), + S6001QuoteTarget.Taiwan => Index("대만"), + S6001QuoteTarget.Singapore => Index("싱가포르"), + S6001QuoteTarget.Thailand => Index("태국"), + S6001QuoteTarget.Philippines => Index("필리핀"), + S6001QuoteTarget.Malaysia => Index("말레이시아"), + S6001QuoteTarget.Indonesia => Index("인도네시아"), + S6001QuoteTarget.DubaiCrude or + S6001QuoteTarget.BrentCrude or + S6001QuoteTarget.WtiCrude => new(@"images\주유기merge.png", "단위 : 배럴/달러", false), + S6001QuoteTarget.Gold => new(@"images\35752913_l.jpg", "단위 : 달러/온스", false), + _ => throw new LegacySceneDataException("Unknown s6001 quote target.") + }; + + private static S6001Metadata Index(string nation) => + new(@"Video\20201008_" + nation + ".vrv", "단위 : p", true); + + private static string FormatDecimal(decimal value) => + value.ToString("#,##0.00", CultureInfo.InvariantCulture); + + private readonly record struct S6001Metadata( + string AssetPath, + string UnitText, + bool IsIndex); +} + +internal static class LegacyPanelQuoteMutations +{ + private static readonly string[] VariantFamilies = + ["bg", "rate", "changePrice", "percent", "unit"]; + + public static void AddQuote( + ICollection mutations, + int index, + string name, + LegacyPanelQuoteData quote, + int changeDecimalPlaces) + { + if (quote.Change == decimal.MinValue) + { + throw new LegacySceneDataException("Panel quote change is outside the supported range."); + } + + var variant = quote.Signal switch + { + LegacyPanelQuoteSignal.Positive => 1, + LegacyPanelQuoteSignal.Flat => 2, + LegacyPanelQuoteSignal.Negative when quote.Change == 0 => 2, + LegacyPanelQuoteSignal.Negative => 3, + _ => throw new LegacySceneDataException("Unknown panel quote signal.") + }; + + ClosedSceneMappings.AddPanelDirectionHides( + mutations, + index.ToString(CultureInfo.InvariantCulture)); + mutations.Add(new PlayoutSetValue("title" + index, name)); + mutations.Add(new PlayoutSetValue( + "price" + index, + quote.Price.ToString("#,##0.00", CultureInfo.InvariantCulture))); + + foreach (var family in VariantFamilies) + { + for (var visual = 1; visual <= 3; visual++) + { + mutations.Add(new PlayoutSetVisible( + family + index + "_" + visual, + visual == variant)); + } + } + + var directionObject = variant switch + { + 1 => "up", + 2 => "flat", + 3 => "down", + _ => throw new InvalidOperationException("A known panel quote variant was expected.") + }; + mutations.Add(new PlayoutSetVisible(directionObject + index, true)); + + var changeFormat = changeDecimalPlaces switch + { + 2 => "#,##0.00", + 4 => "#,##0.0000", + _ => throw new LegacySceneDataException("Unsupported panel quote precision.") + }; + mutations.Add(new PlayoutSetValue( + "changePrice" + index + "_" + variant, + Math.Abs(quote.Change).ToString(changeFormat, CultureInfo.InvariantCulture))); + + var ratePrefix = variant == 3 && quote.Rate >= 0 ? "-" : string.Empty; + mutations.Add(new PlayoutSetValue( + "rate" + index + "_" + variant, + ratePrefix + quote.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture))); + } +} + +internal static class S6001DirectionMutations +{ + private const string RedPattern = @"Images\그림_빨강.png"; + private const string FlatPattern = @"Images\그림_검정.png"; + private const string BluePattern = @"Images\그림_파랑.png"; + + private static readonly string[] ColorObjects = + ["bg", "changePrice", "wonText", "rate", "percent"]; + + public static void Add( + ICollection mutations, + ScenePriceDirection direction) + { + ClosedSceneMappings.AddDirectionHides(mutations, string.Empty); + + var (directionObject, background, foreground, pattern) = direction switch + { + ScenePriceDirection.LimitUp => ("upup", (170, 0, 0), (170, 0, 0), RedPattern), + ScenePriceDirection.Up => ("up", (170, 0, 0), (170, 0, 0), RedPattern), + ScenePriceDirection.Flat => ("flat", (95, 95, 95), (52, 52, 52), FlatPattern), + ScenePriceDirection.Down => ("down", (15, 99, 189), (15, 99, 189), BluePattern), + ScenePriceDirection.LimitDown => ("downdown", (15, 99, 189), (15, 99, 189), BluePattern), + _ => throw new LegacySceneDataException("Unknown s6001 price direction.") + }; + + mutations.Add(new PlayoutSetVisible(directionObject, true)); + for (var index = 0; index < ColorObjects.Length; index++) + { + var color = index == 0 ? background : foreground; + mutations.Add(new PlayoutSetFaceColor( + ColorObjects[index], + color.Item1, + color.Item2, + color.Item3)); + } + + mutations.Add(new PlayoutSetAssetValue("pattern", pattern)); + } +} + +internal static class ClosedSceneMappings +{ + private static readonly string[] DirectionObjects = + ["upup", "up", "flat", "down", "downdown"]; + + private static readonly string[] PanelDirectionObjects = + ["flat", "up", "upup", "downdown", "down"]; + + public static LegacyDomesticEquityMarket RequireDomesticMarket( + LegacyDomesticEquityMarket market) => market switch + { + LegacyDomesticEquityMarket.Kospi => market, + LegacyDomesticEquityMarket.Kosdaq => market, + LegacyDomesticEquityMarket.NxtKospi => market, + LegacyDomesticEquityMarket.NxtKosdaq => market, + _ => throw new LegacySceneDataException("Unknown domestic equity market.") + }; + + public static bool IsNxt(LegacyDomesticEquityMarket market) => market switch + { + LegacyDomesticEquityMarket.Kospi or LegacyDomesticEquityMarket.Kosdaq => false, + LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq => true, + _ => throw new LegacySceneDataException("Unknown domestic equity market.") + }; + + public static ScenePriceDirection RequireDirection(ScenePriceDirection direction) => + direction switch + { + ScenePriceDirection.LimitUp => direction, + ScenePriceDirection.Up => direction, + ScenePriceDirection.Flat => direction, + ScenePriceDirection.Down => direction, + ScenePriceDirection.LimitDown => direction, + _ => throw new LegacySceneDataException("Unknown price direction.") + }; + + public static string DirectionValueVariant(ScenePriceDirection direction) => direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => "1_", + ScenePriceDirection.Flat => "2_", + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => "3_", + _ => throw new LegacySceneDataException("Unknown price direction.") + }; + + public static void AddDirectionHides( + ICollection mutations, + string suffix) + { + foreach (var objectName in DirectionObjects) + { + mutations.Add(new PlayoutSetVisible(objectName + suffix, false)); + } + } + + public static void AddPanelDirectionHides( + ICollection mutations, + string suffix) + { + foreach (var objectName in PanelDirectionObjects) + { + mutations.Add(new PlayoutSetVisible(objectName + suffix, false)); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs new file mode 100644 index 0000000..528fbcb --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs @@ -0,0 +1,206 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum LegacySceneBackgroundKind +{ + None, + Texture, + Video +} + +/// +/// Trusted, native configuration for the MainForm-level scene background and fade. +/// It is constructed by the app configuration layer and is never deserialized from Web input. +/// +public sealed record LegacySceneCueCompositionOptions( + int FadeDuration, + LegacySceneBackgroundKind BackgroundKind, + string? BackgroundAssetPath = null, + int BackgroundVideoLoopCount = 2004, + bool BackgroundVideoLoopInfinite = true) +{ + public static LegacySceneCueCompositionOptions Default { get; } = new( + // MainForm initializes ComboDi.SelectedIndex to 6 and passes that value + // to every SetSceneEffectType call. + FadeDuration: 6, + BackgroundKind: LegacySceneBackgroundKind.None); +} + +/// +/// 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. +/// +public sealed record LegacySceneDataPage( + string BuilderKey, + ILegacySceneData Data, + int ItemCount); + +public interface ILegacySceneDataSource +{ + Task LoadPageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + 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 +{ + private readonly ILegacySceneDataSource _dataSource; + private readonly LegacySceneMutationBuilderRegistry _builders; + private readonly LegacySceneCueCompositionOptions _options; + + public RegisteredLegacySceneCueProvider( + ILegacySceneDataSource dataSource, + LegacySceneMutationBuilderRegistry? builders = null, + LegacySceneCueCompositionOptions? options = null) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _builders = builders ?? LegacySceneMutationBuilderRegistry.Default; + _options = ValidateOptions(options ?? LegacySceneCueCompositionOptions.Default); + } + + public async Task CreatePageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + if (pageIndexZeroBased < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased)); + } + + var loaded = await _dataSource.LoadPageAsync( + entry, + pageIndexZeroBased, + cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(loaded); + ArgumentNullException.ThrowIfNull(loaded.Data); + if (loaded.ItemCount < 0) + { + 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 pageSize = ConvertPageSize(definition.PageSize); + if (pageSize is null && pageIndexZeroBased != 0) + { + throw new LegacySceneDataException("A non-paged scene cannot load another page."); + } + + if (pageSize is { } size) + { + var window = ScenePaging.GetWindow( + loaded.ItemCount, + size, + pageIndexZeroBased + 1); + if (!window.IsValid) + { + throw new LegacySceneDataException( + "The requested scene page is outside the accessible 20-page range."); + } + } + + var builderMutations = _builders.Build(definition.BuilderKey, loaded.Data); + var mutations = new List(builderMutations.Count + 2); + AddBackgroundMutations(mutations, _options); + mutations.AddRange(builderMutations); + + return new LegacySceneCuePage( + new PlayoutCue( + entry.CutCode + ".t2s", + entry.CutCode, + FadeDuration: entry.FadeDuration ?? _options.FadeDuration, + Mutations: Array.AsReadOnly(mutations.ToArray())), + loaded.ItemCount, + pageSize, + definition.BuilderKey, + LegacyScenePreviewFactory.Create(Array.AsReadOnly(mutations.ToArray()))); + } + + private static ScenePageSize? ConvertPageSize(LegacyScenePageSize value) => value switch + { + LegacyScenePageSize.None => null, + LegacyScenePageSize.Five => ScenePageSize.Five, + LegacyScenePageSize.Six => ScenePageSize.Six, + LegacyScenePageSize.Twelve => ScenePageSize.Twelve, + _ => throw new LegacySceneDataException("The scene catalog contains an invalid page size.") + }; + + private static LegacySceneCueCompositionOptions ValidateOptions( + LegacySceneCueCompositionOptions options) + { + if (options.FadeDuration is < 0 or > 60 || + !Enum.IsDefined(options.BackgroundKind) || + (options.BackgroundKind == LegacySceneBackgroundKind.Video && + options.BackgroundVideoLoopCount is < 0 or > 10_000)) + { + throw new ArgumentException("The legacy cue composition options are invalid.", nameof(options)); + } + + if (options.BackgroundKind == LegacySceneBackgroundKind.None) + { + if (!string.IsNullOrEmpty(options.BackgroundAssetPath)) + { + throw new ArgumentException( + "A disabled background cannot specify an asset.", + nameof(options)); + } + + return options; + } + + var path = options.BackgroundAssetPath; + if (string.IsNullOrWhiteSpace(path) || path.Length > 512 || + Path.IsPathRooted(path) || path.Contains("..", StringComparison.Ordinal) || + path.Any(char.IsControl)) + { + throw new ArgumentException( + "The background asset must be a safe relative path.", + nameof(options)); + } + + return options with { BackgroundAssetPath = path.Trim() }; + } + + private static void AddBackgroundMutations( + ICollection mutations, + LegacySceneCueCompositionOptions options) + { + switch (options.BackgroundKind) + { + case LegacySceneBackgroundKind.None: + mutations.Add(new PlayoutUseBackground(false)); + break; + case LegacySceneBackgroundKind.Texture: + mutations.Add(new PlayoutSetBackgroundTexture(options.BackgroundAssetPath!)); + mutations.Add(new PlayoutUseBackground(true)); + break; + case LegacySceneBackgroundKind.Video: + mutations.Add(new PlayoutSetBackgroundVideo( + options.BackgroundAssetPath!, + options.BackgroundVideoLoopCount, + options.BackgroundVideoLoopInfinite)); + mutations.Add(new PlayoutUseBackground(true)); + break; + default: + throw new InvalidOperationException("Unknown background kind."); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs new file mode 100644 index 0000000..a4a7962 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs @@ -0,0 +1,602 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum LegacyDomesticMarket +{ + Kospi, + Kosdaq +} + +public sealed record InvestorFlowAmounts( + int Individual, + int Foreign, + int Institution); + +public sealed record DailyInvestorFlowData( + DateOnly TradingDate, + InvestorFlowAmounts Amounts); + +public sealed record S5023SceneData( + LegacyDomesticMarket Market, + IReadOnlyList DailyRows, + InvestorFlowAmounts MonthlyTotal) : ILegacySceneData; + +/// +/// Legacy index-investor table. Five nullable rows are required so empty objects are +/// deterministically cleared instead of retaining data from an earlier cue. +/// +public sealed class S5023SceneMutationBuilder : ILegacySceneMutationBuilder +{ + private const int RowCount = 5; + + public string BuilderKey => "s5023"; + + public IReadOnlyList Build(S5023SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var rows = LegacySceneBuilderGuard.Count( + data.DailyRows, + RowCount, + nameof(data.DailyRows)); + var title = MarketTitle(data.Market) + " \uB9E4\uB9E4\uB3D9\uD5A5"; + var monthlyTotal = LegacySceneBuilderGuard.NotNull( + data.MonthlyTotal, + nameof(data.MonthlyTotal)); + var normalizedRows = rows.Select((row, index) => row is null + ? null + : new DailyInvestorFlowData( + row.TradingDate, + LegacySceneBuilderGuard.NotNull( + row.Amounts, + $"DailyRows[{index}].Amounts"))) + .ToArray(); + var mutations = new List(43) + { + new PlayoutSetValue("title", title) + }; + + for (var rowIndex = 0; rowIndex < RowCount; rowIndex++) + { + var rowNumber = rowIndex + 1; + var row = normalizedRows[rowIndex]; + mutations.Add(new PlayoutSetValue( + $"row{rowNumber}_value1", + row?.TradingDate.ToString("MM/dd", CultureInfo.InvariantCulture) ?? string.Empty)); + + AddDailyAmount(mutations, rowNumber, 2, row?.Amounts.Individual); + AddDailyAmount(mutations, rowNumber, 3, row?.Amounts.Foreign); + AddDailyAmount(mutations, rowNumber, 4, row?.Amounts.Institution); + } + + AddMonthlyTotal(mutations, 1, monthlyTotal.Individual); + AddMonthlyTotal(mutations, 2, monthlyTotal.Foreign); + AddMonthlyTotal(mutations, 3, monthlyTotal.Institution); + return mutations; + } + + private static void AddDailyAmount( + ICollection mutations, + int row, + int column, + int? value) + { + var objectName = $"row{row}_value{column}"; + var color = value switch + { + > 0 => (R: 171, G: 0, B: 0), + < 0 => (R: 15, G: 99, B: 188), + _ => (R: 47, G: 47, B: 47) + }; + mutations.Add(new PlayoutSetFaceColor(objectName, color.R, color.G, color.B)); + mutations.Add(new PlayoutSetValue( + objectName, + value?.ToString("#,##0", CultureInfo.InvariantCulture) ?? string.Empty)); + } + + private static void AddMonthlyTotal( + ICollection mutations, + int column, + int value) + { + mutations.Add(new PlayoutSetValue( + "row6_value" + column, + value.ToString("#,##0", CultureInfo.InvariantCulture))); + var color = value switch + { + > 0 => (R: 171, G: 0, B: 0), + < 0 => (R: 15, G: 99, B: 189), + _ => (R: 95, G: 95, B: 95) + }; + mutations.Add(new PlayoutSetFaceColor( + "row6_bg" + column, + color.R, + color.G, + color.B)); + } + + internal static string MarketTitle(LegacyDomesticMarket market) => market switch + { + LegacyDomesticMarket.Kospi => "\uCF54\uC2A4\uD53C", + LegacyDomesticMarket.Kosdaq => "\uCF54\uC2A4\uB2E5", + _ => throw new LegacySceneDataException("Unknown domestic market.") + }; +} + +public enum InvestorParticipant +{ + Individual, + Foreign, + Institution +} + +public sealed record InvestorTradingAmountData( + InvestorParticipant Participant, + double Amount); + +public sealed record S5024SceneData( + LegacyDomesticMarket Market, + IReadOnlyList Amounts) : ILegacySceneData; + +public sealed class S5024SceneMutationBuilder : ILegacySceneMutationBuilder +{ + private const double CenterMaximumY = 245d; + private const double CenterMinimumY = -150d; + private const double TextHeight = 50d; + private const double Height = CenterMaximumY - CenterMinimumY - (TextHeight * 2d); + + public string BuilderKey => "s5024"; + + public IReadOnlyList Build(S5024SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var amounts = LegacySceneBuilderGuard.Range( + data.Amounts, + 0, + 3, + nameof(data.Amounts)); + if (amounts.Any(item => item is null || !double.IsFinite(item.Amount))) + { + throw new LegacySceneDataException("Investor trading amounts must be finite."); + } + + if (amounts.Select(item => item.Participant).Distinct().Count() != amounts.Count) + { + throw new LegacySceneDataException("Investor participants must be unique."); + } + + // Validate every enum before emitting any mutation. + foreach (var item in amounts) + { + _ = ParticipantIndex(item.Participant); + } + + var maximum = amounts.Count == 0 ? 0d : Math.Max(0d, amounts.Max(item => item.Amount)); + var minimum = amounts.Count == 0 ? 0d : Math.Min(0d, amounts.Min(item => item.Amount)); + var mutations = new List(17) + { + new PlayoutSetValue( + "title", + S5023SceneMutationBuilder.MarketTitle(data.Market) + " \uB9E4\uB9E4\uB3D9\uD5A5") + }; + + for (var participant = 1; participant <= 3; participant++) + { + for (var signVariant = 1; signVariant <= 2; signVariant++) + { + mutations.Add(new PlayoutSetVisible( + $"barG{participant}_{signVariant}", + false)); + } + } + + var centerY = minimum >= 0d + ? CenterMinimumY + : maximum <= 0d + ? CenterMaximumY + : CenterMinimumY + TextHeight + + (Height * Math.Abs(minimum) / (maximum + Math.Abs(minimum))); + mutations.Add(new PlayoutSetPosition( + "centerbar", + 0, + Convert.ToInt32(centerY), + 0, + PlayoutVectorComponents.Y)); + + foreach (var item in amounts) + { + var participant = ParticipantIndex(item.Participant); + var signVariant = item.Amount < 0 ? 2 : 1; + var ratio = ScaleRatio(item.Amount, maximum, minimum); + mutations.Add(new PlayoutSetVisible( + $"barG{participant}_{signVariant}", + true)); + mutations.Add(new PlayoutSetScale( + $"bar{participant}_{signVariant}", + 0, + ratio, + 0, + PlayoutVectorComponents.Y)); + mutations.Add(new PlayoutSetValue( + $"value{participant}_{signVariant}", + item.Amount.ToString("#,##0", CultureInfo.InvariantCulture))); + } + + return mutations; + } + + private static int ParticipantIndex(InvestorParticipant participant) => participant switch + { + InvestorParticipant.Individual => 1, + InvestorParticipant.Foreign => 2, + InvestorParticipant.Institution => 3, + _ => throw new LegacySceneDataException("Unknown investor participant.") + }; + + private static float ScaleRatio(double value, double maximum, double minimum) + { + if (value == 0d) + { + // The legacy expression produces NaN for an all-zero input. Clearing the bar is + // the approved deterministic correction and prevents an invalid SDK call. + return 0f; + } + + if (value > 0d) + { + var ratio = value / maximum; + if (minimum < 0d) + { + ratio *= maximum / (maximum + Math.Abs(minimum)); + } + + return (float)ratio; + } + + var negativeRatio = Math.Abs(value) / Math.Abs(minimum); + if (maximum > 0d) + { + negativeRatio *= Math.Abs(minimum) / (maximum + Math.Abs(minimum)); + } + + return (float)negativeRatio; + } +} + +public sealed record ManualNetSellPairData( + string LeftName, + string LeftAmount, + string RightName, + string RightAmount); + +public sealed record S5025SceneData( + string Title, + IReadOnlyList Rows) : ILegacySceneData; + +public sealed class S5025SceneMutationBuilder : ILegacySceneMutationBuilder +{ + private const int CompatibilityPairRowCount = 4; + private const int LegacyPairRowCount = 5; + + public string BuilderKey => "s5025"; + + public IReadOnlyList Build(S5025SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); + // The original .dat contract contains five pair rows plus its trailing blank + // line and therefore writes slots 1..10. Four rows remain accepted only for + // DTO compatibility with cues created before that source contract was traced. + var rows = LegacySceneBuilderGuard.Range( + data.Rows, + CompatibilityPairRowCount, + LegacyPairRowCount, + nameof(data.Rows)); + var normalized = rows.Select((row, index) => Normalize(row, index)).ToArray(); + var mutations = new List(1 + (normalized.Length * 4)) + { + new PlayoutSetValue("title", title + " \uC21C\uB9E4\uB3C4 \uC0C1\uC704") + }; + + for (var rowIndex = 0; rowIndex < normalized.Length; rowIndex++) + { + var firstSlot = (rowIndex * 2) + 1; + var row = normalized[rowIndex]; + mutations.Add(new PlayoutSetValue("\uC885\uBAA9" + firstSlot, row.LeftName)); + mutations.Add(new PlayoutSetValue("\uAE08\uC561" + firstSlot, row.LeftAmount)); + mutations.Add(new PlayoutSetValue("\uC885\uBAA9" + (firstSlot + 1), row.RightName)); + mutations.Add(new PlayoutSetValue("\uAE08\uC561" + (firstSlot + 1), row.RightAmount)); + } + + return mutations; + } + + private static ManualNetSellPairData Normalize(ManualNetSellPairData? row, int index) + { + if (row is null) + { + return new ManualNetSellPairData(string.Empty, string.Empty, string.Empty, string.Empty); + } + + return new ManualNetSellPairData( + LegacySceneBuilderGuard.Text(row.LeftName, $"Rows[{index}].LeftName", true), + LegacySceneBuilderGuard.Text(row.LeftAmount, $"Rows[{index}].LeftAmount", true), + LegacySceneBuilderGuard.Text(row.RightName, $"Rows[{index}].RightName", true), + LegacySceneBuilderGuard.Text(row.RightAmount, $"Rows[{index}].RightAmount", true)); + } +} + +public sealed record DealerVolumeData(string DealerName, long Volume); + +public sealed record S5037SceneData( + string StockName, + long CurrentPrice, + long ChangePrice, + double Rate, + ScenePriceDirection Direction, + IReadOnlyList BuyDealers, + IReadOnlyList SellDealers) : ILegacySceneData; + +public sealed class S5037SceneMutationBuilder : ILegacySceneMutationBuilder +{ + public string BuilderKey => "s5037"; + + public IReadOnlyList Build(S5037SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + if (!double.IsFinite(data.Rate)) + { + throw new LegacySceneDataException("Dealer quote rate must be finite."); + } + + var buys = NormalizeDealers(data.BuyDealers, nameof(data.BuyDealers)); + var sells = NormalizeDealers(data.SellDealers, nameof(data.SellDealers)); + var mutations = new List(44) + { + new PlayoutSetValue("title", stockName), + new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"), + new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)), + new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)), + new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture)) + }; + LegacySimpleQuoteMutations.AddDirection(mutations, data.Direction); + + for (var slot = 1; slot <= 5; slot++) + { + mutations.Add(new PlayoutSetValue("buy_title_" + slot, string.Empty)); + mutations.Add(new PlayoutSetValue("buy_vol_" + slot, string.Empty)); + mutations.Add(new PlayoutSetValue("sell_title_" + slot, string.Empty)); + mutations.Add(new PlayoutSetValue("sell_vol_" + slot, string.Empty)); + } + + for (var index = 0; index < buys.Count; index++) + { + var slot = index + 1; + mutations.Add(new PlayoutSetValue("buy_title_" + slot, buys[index].DealerName)); + mutations.Add(new PlayoutSetValue("buy_vol_" + slot, FormatInteger(buys[index].Volume))); + } + + for (var index = 0; index < sells.Count; index++) + { + var slot = index + 1; + mutations.Add(new PlayoutSetValue("sell_title_" + slot, sells[index].DealerName)); + mutations.Add(new PlayoutSetValue("sell_vol_" + slot, FormatInteger(sells[index].Volume))); + } + + return mutations; + } + + private static IReadOnlyList NormalizeDealers( + IReadOnlyList? dealers, + string name) + { + var values = LegacySceneBuilderGuard.Range(dealers, 0, 5, name); + return values.Select((dealer, index) => + { + var value = LegacySceneBuilderGuard.NotNull(dealer, $"{name}[{index}]"); + return value with + { + DealerName = LegacySceneBuilderGuard.Text( + value.DealerName, + $"{name}[{index}].DealerName") + }; + }).ToArray(); + } + + private static string FormatInteger(long value) => + value.ToString("#,##0", CultureInfo.InvariantCulture); +} + +public sealed record OrderBookLevelData(long Price, long Quantity); + +public sealed record S8003SceneData( + string StockName, + long CurrentPrice, + long ChangePrice, + double Rate, + ScenePriceDirection Direction, + IReadOnlyList BuyLevels, + IReadOnlyList SellLevels, + long TotalBuyQuantity, + long TotalSellQuantity) : ILegacySceneData; + +public sealed class S8003SceneMutationBuilder : ILegacySceneMutationBuilder +{ + private const int LevelCount = 3; + private static readonly string[] BuyPriceObjects = + ["price_buy_1", "price_buy_2", "price_buy_3"]; + private static readonly string[] BuyQuantityObjects = + ["buy_jan_1", "buy_jan_2", "buy_jan_3"]; + private static readonly string[] BuyBarObjects = + ["buy_bar_1", "buy_bar_2", "buy_bar_3"]; + private static readonly string[] SellPriceObjects = + ["price_sell_1", "price_sell_2", "price_sell_3"]; + private static readonly string[] SellQuantityObjects = + ["sell_jan_1", "sell_jan_2", "sell_jan_3"]; + private static readonly string[] SellBarObjects = + ["sell_bar_1", "sell_bar_2", "sell_bar_3"]; + + public string BuilderKey => "s8003"; + + public IReadOnlyList Build(S8003SceneData data) + { + ArgumentNullException.ThrowIfNull(data); + var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName)); + if (!double.IsFinite(data.Rate)) + { + throw new LegacySceneDataException("Order-book quote rate must be finite."); + } + + if (data.TotalBuyQuantity < 0 || data.TotalSellQuantity < 0) + { + throw new LegacySceneDataException("Order-book total quantities cannot be negative."); + } + + var buys = NormalizeLevels(data.BuyLevels, nameof(data.BuyLevels)); + var sells = NormalizeLevels(data.SellLevels, nameof(data.SellLevels)); + var mutations = new List(40) + { + new PlayoutSetValue("title", stockName), + new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"), + new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)), + new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)), + new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture)) + }; + LegacySimpleQuoteMutations.AddDirection(mutations, data.Direction); + + AddPrices(mutations, BuyPriceObjects, buys, data.CurrentPrice); + AddQuantities(mutations, BuyQuantityObjects, buys); + AddPrices(mutations, SellPriceObjects, sells, data.CurrentPrice); + AddQuantities(mutations, SellQuantityObjects, sells); + mutations.Add(new PlayoutSetValue("buy_jan_total", FormatInteger(data.TotalBuyQuantity))); + mutations.Add(new PlayoutSetValue("sell_jan_total", FormatInteger(data.TotalSellQuantity))); + + var maximumQuantity = buys.Concat(sells) + .Where(level => level is not null) + .Select(level => level!.Quantity) + .DefaultIfEmpty(0) + .Max(); + AddBarScales(mutations, BuyBarObjects, buys, maximumQuantity); + AddBarScales(mutations, SellBarObjects, sells, maximumQuantity); + return mutations; + } + + private static IReadOnlyList NormalizeLevels( + IReadOnlyList? levels, + string name) + { + var values = LegacySceneBuilderGuard.Count(levels, LevelCount, name); + if (values.Any(level => level?.Quantity < 0)) + { + throw new LegacySceneDataException("Order-book quantities cannot be negative."); + } + + return values; + } + + private static void AddPrices( + ICollection mutations, + IReadOnlyList objects, + IReadOnlyList levels, + long currentPrice) + { + for (var index = 0; index < LevelCount; index++) + { + var level = levels[index]; + mutations.Add(new PlayoutSetValue( + objects[index], + level is null ? string.Empty : FormatInteger(level.Price))); + var color = level is null || level.Price == currentPrice + ? (R: 50, G: 51, B: 50) + : currentPrice < level.Price + ? (R: 172, G: 0, B: 0) + : (R: 19, G: 73, B: 170); + mutations.Add(new PlayoutSetFaceColor( + objects[index], + color.R, + color.G, + color.B)); + } + } + + private static void AddQuantities( + ICollection mutations, + IReadOnlyList objects, + IReadOnlyList levels) + { + for (var index = 0; index < LevelCount; index++) + { + mutations.Add(new PlayoutSetValue( + objects[index], + levels[index] is null ? string.Empty : FormatInteger(levels[index]!.Quantity))); + } + } + + private static void AddBarScales( + ICollection mutations, + IReadOnlyList objects, + IReadOnlyList levels, + long maximumQuantity) + { + for (var index = 0; index < LevelCount; index++) + { + var scale = maximumQuantity == 0 || levels[index] is null + ? 0f + : (float)(levels[index]!.Quantity / (double)maximumQuantity); + mutations.Add(new PlayoutSetScale( + objects[index], + scale, + 0, + 0, + PlayoutVectorComponents.X)); + } + } + + private static string FormatInteger(long value) => + value.ToString("#,##0", CultureInfo.InvariantCulture); +} + +internal static class LegacySimpleQuoteMutations +{ + private static readonly string[] DirectionObjects = + ["upup", "up", "flat", "down", "downdown"]; + + public static void AddDirection( + ICollection mutations, + ScenePriceDirection direction) + { + foreach (var objectName in DirectionObjects) + { + mutations.Add(new PlayoutSetVisible(objectName, false)); + } + + string? active = direction switch + { + ScenePriceDirection.LimitUp => "upup", + ScenePriceDirection.Up => "up", + ScenePriceDirection.Down => "down", + ScenePriceDirection.LimitDown => "downdown", + ScenePriceDirection.Flat or ScenePriceDirection.Unknown => null, + _ => throw new LegacySceneDataException("Unknown price direction.") + }; + if (active is not null) + { + mutations.Add(new PlayoutSetVisible(active, true)); + } + + var variant = direction switch + { + ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1, + ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3, + ScenePriceDirection.Flat or ScenePriceDirection.Unknown => 2, + _ => throw new LegacySceneDataException("Unknown price direction.") + }; + for (var index = 1; index <= 3; index++) + { + mutations.Add(new PlayoutSetVisible("bg" + index, index == variant)); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TraderQuoteSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TraderQuoteSceneDataLoaders.cs new file mode 100644 index 0000000..d003017 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TraderQuoteSceneDataLoaders.cs @@ -0,0 +1,300 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public sealed record S5037SceneLoadRequest( + LegacyDomesticMarket Market, + string StockName); + +public sealed class S5037SceneDataLoader +{ + private static readonly string[] QuoteColumns = + ["STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE"]; + private static readonly string[] DealerColumns = ["DEALER_NAME", "VOLUME"]; + + private readonly IDataQueryExecutor _executor; + + public S5037SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S5037SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var stockName = LegacyTabularSceneLoaderReader.Selector(request.StockName); + var plan = TraderQuoteSceneQueries.S5037(request.Market, stockName); + var quoteTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5037_QUOTE", + plan.Quote, + cancellationToken).ConfigureAwait(false); + var buyTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5037_BUY", + plan.Buy, + cancellationToken).ConfigureAwait(false); + var sellTable = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_5037_SELL", + plan.Sell, + cancellationToken).ConfigureAwait(false); + + var quote = LegacyTabularSceneLoaderReader.ExactRows(quoteTable, 1, QuoteColumns)[0]; + var returnedName = LegacyTabularSceneLoaderReader.Text(quote, "STOCK_NAME"); + if (!string.Equals(returnedName, stockName, StringComparison.Ordinal)) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + var data = new S5037SceneData( + returnedName, + LegacyTabularSceneLoaderReader.Int64(quote, "CURRENT_PRICE"), + LegacyTabularSceneLoaderReader.Int64(quote, "CHANGE_PRICE"), + LegacyTabularSceneLoaderReader.Double(quote, "RATE"), + LegacyTabularSceneLoaderReader.Direction(quote, "DIRECTION_CODE"), + MapDealers(buyTable), + MapDealers(sellTable)); + return LegacyTabularSceneLoaderReader.Preflight(new S5037SceneMutationBuilder(), data); + } + + private static IReadOnlyList MapDealers(DataTable? table) + { + var rows = LegacyTabularSceneLoaderReader.ExactRows(table, 5, DealerColumns); + var dealers = new DealerVolumeData[rows.Count]; + long? previousVolume = null; + for (var index = 0; index < rows.Count; index++) + { + var volume = LegacyTabularSceneLoaderReader.Int64(rows[index], "VOLUME"); + if (previousVolume.HasValue && volume > previousVolume.Value) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + previousVolume = volume; + dealers[index] = new DealerVolumeData( + LegacyTabularSceneLoaderReader.Text(rows[index], "DEALER_NAME"), + volume); + } + + return dealers; + } +} + +public sealed record S8003SceneLoadRequest( + LegacyDomesticMarket Market, + string StockCode); + +public sealed class S8003SceneDataLoader +{ + private static readonly string[] Columns = + [ + "STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE", + "SELL_PRICE_1", "SELL_PRICE_2", "SELL_PRICE_3", + "SELL_QUANTITY_1", "SELL_QUANTITY_2", "SELL_QUANTITY_3", + "BUY_PRICE_1", "BUY_PRICE_2", "BUY_PRICE_3", + "BUY_QUANTITY_1", "BUY_QUANTITY_2", "BUY_QUANTITY_3", + "TOTAL_SELL_QUANTITY", "TOTAL_BUY_QUANTITY" + ]; + + private readonly IDataQueryExecutor _executor; + + public S8003SceneDataLoader(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task LoadAsync( + S8003SceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var stockCode = LegacyTabularSceneLoaderReader.StockCode(request.StockCode); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + "SCENE_8003", + TraderQuoteSceneQueries.S8003(request.Market, stockCode), + cancellationToken).ConfigureAwait(false); + var row = LegacyTabularSceneLoaderReader.ExactRows(table, 1, Columns)[0]; + var data = new S8003SceneData( + LegacyTabularSceneLoaderReader.Text(row, "STOCK_NAME"), + LegacyTabularSceneLoaderReader.Int64(row, "CURRENT_PRICE"), + LegacyTabularSceneLoaderReader.Int64(row, "CHANGE_PRICE"), + LegacyTabularSceneLoaderReader.Double(row, "RATE"), + LegacyTabularSceneLoaderReader.Direction(row, "DIRECTION_CODE"), + Levels(row, "BUY"), + Levels(row, "SELL"), + LegacyTabularSceneLoaderReader.Int64(row, "TOTAL_BUY_QUANTITY"), + LegacyTabularSceneLoaderReader.Int64(row, "TOTAL_SELL_QUANTITY")); + return LegacyTabularSceneLoaderReader.Preflight(new S8003SceneMutationBuilder(), data); + } + + private static IReadOnlyList Levels(DataRow row, string side) + { + var levels = new OrderBookLevelData?[3]; + for (var index = 0; index < levels.Length; index++) + { + var number = index + 1; + levels[index] = new OrderBookLevelData( + LegacyTabularSceneLoaderReader.Int64(row, $"{side}_PRICE_{number}"), + LegacyTabularSceneLoaderReader.Int64(row, $"{side}_QUANTITY_{number}")); + } + + return levels; + } +} + +internal static class TraderQuoteSceneQueries +{ + public static TraderQueryPlan S5037(LegacyDomesticMarket market, string stockName) + { + var source = market switch + { + LegacyDomesticMarket.Kospi => new TraderSource( + "t_online1", "t_stock", "t_dealer"), + LegacyDomesticMarket.Kosdaq => new TraderSource( + "t_kosdaq_online1", "t_kosdaq_stock", "t_kosdaq_dealer"), + _ => throw LegacyTabularSceneLoaderReader.InvalidRequest() + }; + var quote = LegacyTabularSceneLoaderReader.Spec( + DataSourceKind.Oracle, + $""" + SELECT + b.f_stock_wanname STOCK_NAME, + a.f_curr_price CURRENT_PRICE, + a.f_net_chg CHANGE_PRICE, + a.f_chg_type DIRECTION_CODE, + ROUND((a.f_net_chg / DECODE( + a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE + FROM {source.OnlineTable} a + JOIN {source.StockTable} b ON a.f_stock_code = b.f_stock_code + WHERE b.f_mkt_halt = 'N' + AND a.f_curr_price <> 0 + AND b.f_stock_wanname = :stockName + """, + new DataQueryParameter("stockName", stockName, DbType.String)); + return new TraderQueryPlan( + quote, + Dealer(source, stockName, buy: true), + Dealer(source, stockName, buy: false)); + } + + public static DataQuerySpec S8003(LegacyDomesticMarket market, string stockCode) + { + var source = market switch + { + LegacyDomesticMarket.Kospi => new QuoteSource( + "t_online1", "t_stock", "t_online1_call"), + LegacyDomesticMarket.Kosdaq => new QuoteSource( + "t_kosdaq_online1", "t_kosdaq_stock", "t_kosdaq_online1_call"), + _ => throw LegacyTabularSceneLoaderReader.InvalidRequest() + }; + return LegacyTabularSceneLoaderReader.Spec( + DataSourceKind.Oracle, + $""" + SELECT + b.f_stock_wanname STOCK_NAME, + a.f_curr_price CURRENT_PRICE, + a.f_net_chg CHANGE_PRICE, + a.f_chg_type DIRECTION_CODE, + ROUND((a.f_net_chg / DECODE( + a.f_chg_type, + '1', a.f_curr_price - a.f_net_chg, + '2', a.f_curr_price - a.f_net_chg, + '3', a.f_curr_price, + '4', -1 * (a.f_curr_price + a.f_net_chg), + '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE, + c.f_call_sell_1 SELL_PRICE_1, + c.f_call_sell_2 SELL_PRICE_2, + c.f_call_sell_3 SELL_PRICE_3, + c.f_call_sell_jan_1 SELL_QUANTITY_1, + c.f_call_sell_jan_2 SELL_QUANTITY_2, + c.f_call_sell_jan_3 SELL_QUANTITY_3, + c.f_call_buy_1 BUY_PRICE_1, + c.f_call_buy_2 BUY_PRICE_2, + c.f_call_buy_3 BUY_PRICE_3, + c.f_call_buy_jan_1 BUY_QUANTITY_1, + c.f_call_buy_jan_2 BUY_QUANTITY_2, + c.f_call_buy_jan_3 BUY_QUANTITY_3, + c.f_t_call_sell_jan TOTAL_SELL_QUANTITY, + c.f_t_call_buy_jan TOTAL_BUY_QUANTITY + FROM {source.OnlineTable} a + JOIN {source.StockTable} b ON a.f_stock_code = b.f_stock_code + JOIN {source.CallTable} c ON a.f_stock_code = c.f_stock_code + WHERE a.f_mkt_halt = 'N' + AND a.f_curr_price > 0 + AND a.f_stock_code = :stockCode + """, + new DataQueryParameter("stockCode", stockCode, DbType.String)); + } + + private static DataQuerySpec Dealer(TraderSource source, string stockName, bool buy) + { + var side = buy ? "buy" : "sell"; + return LegacyTabularSceneLoaderReader.Spec( + DataSourceKind.Oracle, + $""" + WITH target_stock AS ( + SELECT f_stock_code + FROM {source.StockTable} + WHERE f_stock_wanname = :stockName + AND f_mkt_halt = 'N' + ), dealer_rows AS ( + SELECT 1 slot_order, b.f_sale_nick_kname dealer_name, + a.f_{side}_dealer_vol_1 volume + FROM {source.DealerTable} a + JOIN target_stock c ON a.f_stock_code = c.f_stock_code + JOIN t_sale_member b ON a.f_{side}_dealer_no_1 = b.f_sale_no + UNION ALL + SELECT 2, b.f_sale_nick_kname, a.f_{side}_dealer_vol_2 + FROM {source.DealerTable} a + JOIN target_stock c ON a.f_stock_code = c.f_stock_code + JOIN t_sale_member b ON a.f_{side}_dealer_no_2 = b.f_sale_no + UNION ALL + SELECT 3, b.f_sale_nick_kname, a.f_{side}_dealer_vol_3 + FROM {source.DealerTable} a + JOIN target_stock c ON a.f_stock_code = c.f_stock_code + JOIN t_sale_member b ON a.f_{side}_dealer_no_3 = b.f_sale_no + UNION ALL + SELECT 4, b.f_sale_nick_kname, a.f_{side}_dealer_vol_4 + FROM {source.DealerTable} a + JOIN target_stock c ON a.f_stock_code = c.f_stock_code + JOIN t_sale_member b ON a.f_{side}_dealer_no_4 = b.f_sale_no + UNION ALL + SELECT 5, b.f_sale_nick_kname, a.f_{side}_dealer_vol_5 + FROM {source.DealerTable} a + JOIN target_stock c ON a.f_stock_code = c.f_stock_code + JOIN t_sale_member b ON a.f_{side}_dealer_no_5 = b.f_sale_no + ) + SELECT dealer_name DEALER_NAME, volume VOLUME + FROM dealer_rows + ORDER BY volume DESC, slot_order + """, + new DataQueryParameter("stockName", stockName, DbType.String)); + } + + internal sealed record TraderQueryPlan( + DataQuerySpec Quote, + DataQuerySpec Buy, + DataQuerySpec Sell); + + private sealed record TraderSource( + string OnlineTable, + string StockTable, + string DealerTable); + + private sealed record QuoteSource( + string OnlineTable, + string StockTable, + string CallTable); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedManualSceneDataLoader5025.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedManualSceneDataLoader5025.cs new file mode 100644 index 0000000..9b051f5 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TrustedManualSceneDataLoader5025.cs @@ -0,0 +1,91 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +public enum S5025ManualAudience +{ + Individual, + Foreign, + Institution +} + +public sealed record S5025ManualSceneLoadRequest(S5025ManualAudience Audience); + +public sealed record S5025TrustedManualRow( + string LeftName, + string LeftAmount, + string RightName, + string RightAmount); + +/// +/// Composition-root contract for the approved manual-data store. It deliberately +/// exposes a closed audience key and no path so a WebView request cannot choose a file. +/// +public interface IS5025TrustedManualDataSource +{ + Task> ReadAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default); +} + +public sealed class S5025SceneDataLoader +{ + private const int RowCount = 5; + private const int MaximumCellLength = 256; + + private readonly IS5025TrustedManualDataSource _source; + + public S5025SceneDataLoader(IS5025TrustedManualDataSource source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + } + + public async Task LoadAsync( + S5025ManualSceneLoadRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var title = request.Audience switch + { + S5025ManualAudience.Individual => "\uAC1C\uC778", + S5025ManualAudience.Foreign => "\uC678\uAD6D\uC778", + S5025ManualAudience.Institution => "\uAE30\uAD00", + _ => throw LegacyTabularSceneLoaderReader.InvalidRequest() + }; + var sourceRows = await _source.ReadAsync(request.Audience, cancellationToken) + .ConfigureAwait(false); + if (sourceRows is null || sourceRows.Count != RowCount || sourceRows.Any(row => row is null)) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + var rows = sourceRows.Select((row, index) => new ManualNetSellPairData( + Cell(row.LeftName, index, nameof(row.LeftName)), + Cell(row.LeftAmount, index, nameof(row.LeftAmount)), + Cell(row.RightName, index, nameof(row.RightName)), + Cell(row.RightAmount, index, nameof(row.RightAmount)))) + .ToArray(); + var data = new S5025SceneData(title, rows); + return LegacyTabularSceneLoaderReader.Preflight(new S5025SceneMutationBuilder(), data); + } + + private static string Cell(string? value, int row, string field) + { + if (value is null || value.Length > MaximumCellLength || value.Any(char.IsControl)) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + + try + { + return LegacySceneBuilderGuard.Text( + value, + $"Rows[{row}].{field}", + allowEmpty: true); + } + catch (LegacySceneDataException) + { + throw LegacyTabularSceneLoaderReader.InvalidResult(); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/ResilientDataQueryExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/ResilientDataQueryExecutor.cs index 8288d0a..54bdb2c 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/ResilientDataQueryExecutor.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/ResilientDataQueryExecutor.cs @@ -35,7 +35,7 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor .GetAwaiter() .GetResult(); - public async Task ExecuteAsync( + public Task ExecuteAsync( DataSourceKind source, string tableName, string query, @@ -43,6 +43,38 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor { ArgumentException.ThrowIfNullOrWhiteSpace(query); + return ExecuteAsyncCore( + source, + tableName, + query, + parameters: null, + cancellationToken); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + query.ValidateFor(source); + + return ExecuteAsyncCore( + source, + tableName, + query.Sql, + query.Parameters, + cancellationToken); + } + + private async Task ExecuteAsyncCore( + DataSourceKind source, + string tableName, + string query, + IReadOnlyList? parameters, + CancellationToken cancellationToken) + { using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(_operationTimeout); var operationToken = timeoutSource.Token; @@ -52,12 +84,19 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor { try { - return await ExecuteAttemptAsync(source, tableName, query, operationToken) + return await ExecuteAttemptAsync( + source, + tableName, + query, + parameters, + operationToken) .ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - throw; + throw new OperationCanceledException( + "The database query was canceled.", + cancellationToken); } catch (OperationCanceledException) { @@ -98,6 +137,7 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor DataSourceKind source, string tableName, string query, + IReadOnlyList? parameters, CancellationToken cancellationToken) { await using var connection = _connectionFactory.Create(source); @@ -108,6 +148,23 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor command.CommandType = CommandType.Text; command.CommandTimeout = _connectionFactory.GetCommandTimeoutSeconds(source); + if (parameters is not null) + { + foreach (var parameterSpec in parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = parameterSpec.Name; + parameter.Direction = ParameterDirection.Input; + parameter.Value = parameterSpec.Value ?? DBNull.Value; + if (parameterSpec.DbType.HasValue) + { + parameter.DbType = parameterSpec.DbType.Value; + } + + command.Parameters.Add(parameter); + } + } + await using var reader = await command .ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken) .ConfigureAwait(false); @@ -218,15 +275,31 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor "INSERT", "UPDATE", "DELETE", + "REPLACE", "MERGE", "CALL", "EXEC", + "EXECUTE", "CREATE", "ALTER", "DROP", "TRUNCATE", "GRANT", - "REVOKE" + "REVOKE", + "INTO", + "LOCK", + "SET", + "USE", + "DO", + "LOAD", + "BEGIN", + "DECLARE", + "COMMIT", + "ROLLBACK", + "SAVEPOINT", + "FUNCTION", + "PROCEDURE", + "PRAGMA" ]; foreach (var token in stateChangingTokens) diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs new file mode 100644 index 0000000..9100324 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs @@ -0,0 +1,317 @@ +using System.Security; +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +/// +/// Raised when the trusted s5025 manual-data boundary cannot prove that its +/// configuration or input file satisfies the closed production contract. +/// The exception deliberately does not retain filesystem exceptions, which can +/// contain the configured external path. +/// +public sealed class S5025TrustedManualDataException : Exception +{ + internal S5025TrustedManualDataException() + : base("The trusted s5025 manual-data source is unavailable or invalid.") + { + } +} + +/// +/// Reads the three legacy CP949 s5025 manual-data files from one directory +/// selected only by the application composition root. Callers select a closed +/// audience value and can never supply a path or filename. +/// +public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSource +{ + public const string DirectoryEnvironmentVariable = + "MBN_STOCK_S5025_MANUAL_DATA_DIRECTORY"; + + public const int MaximumFileSizeBytes = 32 * 1024; + + private const int ExpectedRowCount = 5; + private const int ExpectedColumnCount = 4; + private const int MaximumCellLength = 256; + + private static readonly Encoding Cp949Encoding = CreateCp949Encoding(); + + private readonly string _trustedDirectory; + private readonly StringComparison _pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// Creates the source from an explicit composition-root setting. The + /// directory must already exist and be an absolute, non-reparse path. + /// + public S5025TrustedManualFileDataSource(string trustedDirectory) + { + _trustedDirectory = NormalizeAndValidateTrustedDirectory(trustedDirectory); + } + + /// + /// Creates the source from the process environment. The injectable reader + /// is intended for composition-root tests and does not alter the closed + /// runtime data-source contract. + /// + public static S5025TrustedManualFileDataSource CreateFromEnvironment( + Func? readEnvironmentVariable = null) + { + var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable; + return new S5025TrustedManualFileDataSource( + read(DirectoryEnvironmentVariable) ?? string.Empty); + } + + public async Task> ReadAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var fileName = audience switch + { + S5025ManualAudience.Individual => "개인.dat", + S5025ManualAudience.Foreign => "외국인.dat", + S5025ManualAudience.Institution => "기관.dat", + _ => throw InvalidData() + }; + + try + { + EnsureDirectoryIsTrusted(_trustedDirectory); + var path = GetContainedFilePath(fileName); + EnsureRegularNonReparseFile(path); + + byte[] bytes; + await using (var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4_096, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + if (stream.Length <= 0 || 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; + } + + if (await stream.ReadAsync( + new byte[1], + cancellationToken).ConfigureAwait(false) != 0) + { + throw InvalidData(); + } + } + + // Re-check the closed path after reading. FileShare.Read prevents a + // conforming Windows writer from replacing or modifying the file + // while the handle is open; this second check also detects a path + // replacement immediately after close. + EnsureDirectoryIsTrusted(_trustedDirectory); + EnsureRegularNonReparseFile(path); + return Parse(bytes); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (S5025TrustedManualDataException) + { + throw; + } + catch (Exception exception) when (exception is + IOException or + UnauthorizedAccessException or + SecurityException or + ArgumentException or + NotSupportedException or + DecoderFallbackException) + { + throw InvalidData(); + } + } + + private string GetContainedFilePath(string fileName) + { + var candidate = Path.GetFullPath(Path.Combine(_trustedDirectory, fileName)); + var prefix = Path.EndsInDirectorySeparator(_trustedDirectory) + ? _trustedDirectory + : _trustedDirectory + Path.DirectorySeparatorChar; + + if (!candidate.StartsWith(prefix, _pathComparison)) + { + throw InvalidData(); + } + + var relative = Path.GetRelativePath(_trustedDirectory, candidate); + if (Path.IsPathRooted(relative) + || relative.Equals("..", _pathComparison) + || relative.StartsWith(".." + Path.DirectorySeparatorChar, _pathComparison) + || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, _pathComparison) + || !relative.Equals(fileName, _pathComparison)) + { + throw InvalidData(); + } + + return candidate; + } + + private static IReadOnlyList Parse(byte[] bytes) + { + RejectUnicodeBom(bytes); + + string text; + try + { + text = Cp949Encoding.GetString(bytes); + } + catch (DecoderFallbackException) + { + throw InvalidData(); + } + + var lines = new List(ExpectedRowCount); + using (var reader = new StringReader(text)) + { + while (reader.ReadLine() is { } line) + { + lines.Add(line.Trim()); + } + } + + // 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) + { + throw InvalidData(); + } + + var rows = new S5025TrustedManualRow[ExpectedRowCount]; + for (var rowIndex = 0; rowIndex < lines.Count; rowIndex++) + { + var columns = lines[rowIndex].Split('^'); + if (columns.Length != ExpectedColumnCount + || columns.Any(column => + column.Length > MaximumCellLength || column.Any(char.IsControl))) + { + throw InvalidData(); + } + + rows[rowIndex] = new S5025TrustedManualRow( + columns[0], + columns[1], + columns[2], + columns[3]); + } + + return rows; + } + + private static string NormalizeAndValidateTrustedDirectory(string trustedDirectory) + { + if (string.IsNullOrWhiteSpace(trustedDirectory) + || trustedDirectory.IndexOf('\0') >= 0 + || !Path.IsPathFullyQualified(trustedDirectory)) + { + throw InvalidData(); + } + + try + { + var fullPath = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(trustedDirectory)); + EnsureDirectoryIsTrusted(fullPath); + return fullPath; + } + catch (S5025TrustedManualDataException) + { + throw; + } + catch (Exception exception) when (exception is + IOException or + UnauthorizedAccessException or + SecurityException or + ArgumentException or + NotSupportedException) + { + throw InvalidData(); + } + } + + private static void EnsureDirectoryIsTrusted(string directory) + { + var current = new DirectoryInfo(directory); + while (current is not null) + { + var attributes = File.GetAttributes(current.FullName); + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw InvalidData(); + } + + current = current.Parent; + } + } + + private static void EnsureRegularNonReparseFile(string path) + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) != 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw InvalidData(); + } + + var length = new FileInfo(path).Length; + if (length <= 0 || length > MaximumFileSizeBytes) + { + throw InvalidData(); + } + } + + 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 S5025TrustedManualDataException InvalidData() => new(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs index fdf5e83..cf523db 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs @@ -1,4 +1,5 @@ using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.Playout.Configuration; @@ -20,6 +21,22 @@ public sealed class PlayoutOptions public int LayoutIndex { get; set; } = 10; + /// MainForm ComboDi.SelectedIndex default passed to SetSceneEffectType. + public int LegacySceneFadeDuration { get; set; } = 6; + + /// + /// Trusted MainForm-level background selection. The asset is always a path relative + /// to SceneDirectory and is never accepted from Web content. + /// + public LegacySceneBackgroundKind LegacySceneBackgroundKind { get; set; } = + LegacySceneBackgroundKind.None; + + public string? LegacySceneBackgroundAssetPath { get; set; } + + public int LegacySceneBackgroundVideoLoopCount { get; set; } = 2004; + + public bool LegacySceneBackgroundVideoLoopInfinite { get; set; } = true; + /// /// External absolute root containing vendor .t2s scenes. Required in Test and Live modes. /// @@ -27,6 +44,10 @@ public sealed class PlayoutOptions public string? TestProcessWindowTitlePattern { get; set; } + /// + /// Closed scene-name allowlist for every real Test or Live output command. + /// The property name is retained for existing local configuration files. + /// public List TestSceneAllowlist { get; set; } = []; public bool TrustedLiveOutputEnabled { get; set; } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs index da3c775..d3da9be 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.Playout.Configuration; @@ -73,13 +74,30 @@ public static class PlayoutOptionsLoader private static void ApplyEnvironment(PlayoutOptions options) { - ApplyEnum("MBN_STOCK_PLAYOUT_MODE", value => options.Mode = value); + ApplyEnum( + "MBN_STOCK_PLAYOUT_MODE", + (PlayoutMode value) => options.Mode = value); ApplyString("MBN_STOCK_PLAYOUT_HOST", value => options.Host = value); ApplyInt("MBN_STOCK_PLAYOUT_PORT", value => options.Port = value); ApplyInt("MBN_STOCK_PLAYOUT_TCP_MODE", value => options.TcpMode = value); ApplyInt("MBN_STOCK_PLAYOUT_CLIENT_PORT", value => options.ClientPort = value); ApplyNullableInt("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", value => options.OutputChannel = value); ApplyInt("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", value => options.LayoutIndex = value); + ApplyInt( + "MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION", + value => options.LegacySceneFadeDuration = value); + ApplyEnum( + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", + (LegacySceneBackgroundKind value) => options.LegacySceneBackgroundKind = value); + ApplyString( + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", + value => options.LegacySceneBackgroundAssetPath = value); + ApplyInt( + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", + value => options.LegacySceneBackgroundVideoLoopCount = value); + ApplyBool( + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", + value => options.LegacySceneBackgroundVideoLoopInfinite = value); ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value); ApplyString( "MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", @@ -177,7 +195,8 @@ public static class PlayoutOptionsLoader apply(value); } - private static void ApplyEnum(string name, Action apply) + private static void ApplyEnum(string name, Action apply) + where TEnum : struct, Enum { var text = Environment.GetEnvironmentVariable(name); if (string.IsNullOrWhiteSpace(text)) @@ -185,7 +204,7 @@ public static class PlayoutOptionsLoader return; } - if (!Enum.TryParse(text, true, out var value)) + if (!Enum.TryParse(text, true, out var value) || !Enum.IsDefined(value)) { throw InvalidEnvironment(name); } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs new file mode 100644 index 0000000..ca2941c --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs @@ -0,0 +1,109 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Playout.Configuration; + +/// +/// Validates the trusted MainForm-level fade/background configuration before the +/// first DryRun or actual PREPARE. Web content never supplies these values. +/// +public static class PlayoutSceneCompositionFactory +{ + private static readonly IReadOnlySet AllowedExtensions = new HashSet( + [ + ".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds", + ".vrv", ".avi", ".mp4", ".mov" + ], StringComparer.OrdinalIgnoreCase); + + public static LegacySceneCueCompositionOptions Create(PlayoutOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (options.LegacySceneFadeDuration is < 0 or > 60 || + !Enum.IsDefined(options.LegacySceneBackgroundKind) || + options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000) + { + throw Invalid(); + } + + if (options.LegacySceneBackgroundKind == LegacySceneBackgroundKind.None) + { + if (!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath)) + { + throw Invalid(); + } + + return new LegacySceneCueCompositionOptions( + options.LegacySceneFadeDuration, + LegacySceneBackgroundKind.None); + } + + var rootText = options.SceneDirectory?.Trim(); + var relativeText = options.LegacySceneBackgroundAssetPath?.Trim(); + if (string.IsNullOrEmpty(rootText) || !Path.IsPathFullyQualified(rootText) || + string.IsNullOrEmpty(relativeText) || Path.IsPathRooted(relativeText) || + relativeText.Contains("..", StringComparison.Ordinal) || + relativeText.Contains(':') || relativeText.Any(char.IsControl) || + !AllowedExtensions.Contains(Path.GetExtension(relativeText))) + { + throw Invalid(); + } + + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText)); + if (!Directory.Exists(root) || IsReparsePoint(root)) + { + throw Invalid(); + } + + var candidate = Path.GetFullPath(Path.Combine(root, relativeText)); + var rootPrefix = root + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) || + !File.Exists(candidate) || HasReparsePointBetween(root, candidate)) + { + throw Invalid(); + } + + var normalizedRelative = Path.GetRelativePath(root, candidate); + return new LegacySceneCueCompositionOptions( + options.LegacySceneFadeDuration, + options.LegacySceneBackgroundKind, + normalizedRelative, + options.LegacySceneBackgroundVideoLoopCount, + options.LegacySceneBackgroundVideoLoopInfinite); + } + + private static bool HasReparsePointBetween(string root, string file) + { + if (IsReparsePoint(file)) + { + return true; + } + + var directory = Path.GetDirectoryName(file); + while (!string.IsNullOrEmpty(directory) && + !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase)) + { + if (IsReparsePoint(directory)) + { + return true; + } + + directory = Path.GetDirectoryName(directory); + } + + return false; + } + + private static bool IsReparsePoint(string path) + { + try + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + catch + { + return true; + } + } + + private static PlayoutConfigurationException Invalid() => new( + "공통 장면 배경 설정이 올바르지 않거나 허용된 scene 폴더의 자산을 찾을 수 없습니다."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs index 3c1d07e..1287423 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs @@ -104,6 +104,11 @@ internal sealed record ValidatedPlayoutOptions( allowlist.Add(value!); } + if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && allowlist.Count == 0) + { + throw Invalid(nameof(options.TestSceneAllowlist)); + } + if (options.Mode == PlayoutMode.Test) { if (!IsLiteralLoopback(host)) @@ -116,11 +121,6 @@ internal sealed record ValidatedPlayoutOptions( throw Invalid(nameof(options.OutputChannel)); } - if (allowlist.Count == 0) - { - throw Invalid(nameof(options.TestSceneAllowlist)); - } - var pattern = RequiredText( options.TestProcessWindowTitlePattern, nameof(options.TestProcessWindowTitlePattern)); @@ -170,6 +170,13 @@ internal sealed record ValidatedPlayoutOptions( } public bool IsTestSceneAllowed(PlayoutCue cue) + => IsOutputSceneAllowed(cue); + + /// + /// Closed scene allowlist applied to every real Test or Live mutation command. + /// The legacy property name is retained for local-config compatibility. + /// + public bool IsOutputSceneAllowed(PlayoutCue cue) { var sceneName = cue.SceneName?.Trim(); return !string.IsNullOrEmpty(sceneName) && TestSceneAllowlist.Contains(sceneName); @@ -200,6 +207,8 @@ internal sealed record ValidatedPlayoutOptions( } } + var mutations = ResolveMutations(cue.Mutations); + var relativePath = cue.SceneFile?.Trim(); if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath)) { @@ -242,10 +251,226 @@ internal sealed record ValidatedPlayoutOptions( return cue with { SceneFile = candidate, - SceneName = sceneName! + SceneName = sceneName!, + Mutations = mutations }; } + private IReadOnlyList? ResolveMutations( + IReadOnlyList? mutations) + { + if (mutations is null) + { + return null; + } + + if (mutations.Count > 100_000) + { + throw new PlayoutRequestException("장면 변경 항목이 너무 많습니다."); + } + + var resolved = new PlayoutMutation[mutations.Count]; + for (var index = 0; index < mutations.Count; index++) + { + resolved[index] = ResolveMutation(mutations[index]); + } + + return resolved; + } + + private PlayoutMutation ResolveMutation(PlayoutMutation? mutation) + { + if (mutation is null) + { + throw new PlayoutRequestException("장면 변경 항목이 올바르지 않습니다."); + } + + switch (mutation) + { + case PlayoutSetValue value: + ValidateObjectName(value.ObjectName); + ValidateText(value.Value); + return value; + case PlayoutSetAssetValue asset: + ValidateObjectName(asset.ObjectName); + return asset with { AssetPath = ResolveAssetPath(asset.AssetPath) }; + case PlayoutSetVisible visible: + ValidateObjectName(visible.ObjectName); + return visible; + case PlayoutSetFaceColor color: + ValidateObjectName(color.ObjectName); + if (!IsByte(color.Red) || !IsByte(color.Green) || + !IsByte(color.Blue) || !IsByte(color.Alpha)) + { + throw new PlayoutRequestException("장면 색상 값이 올바르지 않습니다."); + } + + return color; + case PlayoutSetPosition position: + ValidateObjectName(position.ObjectName); + ValidatePoint(position.X, position.Y, position.Z); + ValidateVectorComponents(position.Components); + return position; + case PlayoutSetPositionKey positionKey: + ValidateObjectName(positionKey.ObjectName); + ValidateKeyIndex(positionKey.KeyIndex); + ValidatePoint(positionKey.X, positionKey.Y, positionKey.Z); + ValidateVectorComponents(positionKey.Components); + return positionKey; + case PlayoutSetScale scale: + ValidateObjectName(scale.ObjectName); + ValidatePoint(scale.X, scale.Y, scale.Z); + ValidateVectorComponents(scale.Components); + return scale; + case PlayoutSetCropKey crop: + ValidateObjectName(crop.ObjectName); + ValidateKeyIndex(crop.KeyIndex); + ValidatePoint(crop.Left, crop.Top, crop.Right); + if (!float.IsFinite(crop.Bottom) || + !Enum.IsDefined(crop.Edges) || crop.Edges == 0) + { + throw new PlayoutRequestException("장면 Crop 값이 올바르지 않습니다."); + } + + return crop; + case PlayoutSetCircleAngleKey angle: + ValidateObjectName(angle.ObjectName); + ValidateKeyIndex(angle.KeyIndex); + if (!float.IsFinite(angle.Start) || !float.IsFinite(angle.End) || + !Enum.IsDefined(angle.Components) || angle.Components == 0) + { + throw new PlayoutRequestException("장면 각도 값이 올바르지 않습니다."); + } + + return angle; + case PlayoutSetPathPoints path: + ValidateObjectName(path.ObjectName); + ValidatePoints(path.Points); + return path; + case PlayoutSetPathShapePoints shape: + ValidateObjectName(shape.ObjectName); + ValidatePoints(shape.Points); + return shape; + case PlayoutUseBackground background: + ValidateMutationTiming(background.Timing); + return background; + case PlayoutSetBackgroundTexture texture: + ValidateMutationTiming(texture.Timing); + return texture with { AssetPath = ResolveAssetPath(texture.AssetPath) }; + case PlayoutSetBackgroundVideo video: + ValidateMutationTiming(video.Timing); + // The legacy MainForm intentionally uses 2004 for the common studio + // background. Keep the input bounded while retaining that vendor value. + if (video.LoopCount is < 0 or > 10_000) + { + throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다."); + } + + return video with { AssetPath = ResolveAssetPath(video.AssetPath) }; + default: + throw new PlayoutRequestException("지원하지 않는 장면 변경 항목입니다."); + } + } + + private string ResolveAssetPath(string? relativePath) + { + relativePath = relativePath?.Trim(); + if (SceneDirectory is null || string.IsNullOrEmpty(relativePath) || + Path.IsPathRooted(relativePath) || relativePath.Contains("..", StringComparison.Ordinal) || + relativePath.Any(char.IsControl)) + { + throw new PlayoutRequestException("장면 자산 경로가 올바르지 않습니다."); + } + + var extension = Path.GetExtension(relativePath); + string[] allowedExtensions = + [ + ".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds", + ".vrv", ".avi", ".mp4", ".mov" + ]; + if (!allowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new PlayoutRequestException("지원하지 않는 장면 자산 형식입니다."); + } + + var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath)); + var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar) + ? SceneDirectory + : SceneDirectory + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) || + !File.Exists(candidate) || HasReparsePointBetween(SceneDirectory, candidate)) + { + throw new PlayoutRequestException("허용된 장면 자산을 찾을 수 없습니다."); + } + + return candidate; + } + + private static void ValidateObjectName(string? objectName) + { + if (string.IsNullOrWhiteSpace(objectName) || objectName.Length > 128 || + objectName.Any(char.IsControl)) + { + throw new PlayoutRequestException("장면 오브젝트 이름이 올바르지 않습니다."); + } + } + + private static void ValidateText(string? value) + { + if (value is null || value.Length > 16_384 || value.Any(character => + character is '\0' or '\u0001' or '\u0002' or '\u0003')) + { + throw new PlayoutRequestException("장면 텍스트 값이 올바르지 않습니다."); + } + } + + private static void ValidatePoint(float x, float y, float z) + { + if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z)) + { + throw new PlayoutRequestException("장면 좌표 값이 올바르지 않습니다."); + } + } + + private static void ValidatePoints(IReadOnlyList? points) + { + if (points is null || points.Count > 10_000) + { + throw new PlayoutRequestException("장면 그래프 점 목록이 올바르지 않습니다."); + } + + foreach (var point in points) + { + ValidatePoint(point.X, point.Y, point.Z); + } + } + + private static void ValidateVectorComponents(PlayoutVectorComponents components) + { + if (!Enum.IsDefined(components) || components == 0) + { + throw new PlayoutRequestException("장면 벡터 축 값이 올바르지 않습니다."); + } + } + + private static void ValidateKeyIndex(int keyIndex) + { + if (keyIndex is < 0 or > 10_000) + { + throw new PlayoutRequestException("장면 키 인덱스가 올바르지 않습니다."); + } + } + + private static void ValidateMutationTiming(PlayoutMutationTiming timing) + { + if (!Enum.IsDefined(timing)) + { + throw new PlayoutRequestException("장면 변경 시점이 올바르지 않습니다."); + } + } + + private static bool IsByte(int value) => value is >= byte.MinValue and <= byte.MaxValue; + private static bool CanMatchProgramTitle(Regex regex) { string[] protectedTitles = diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs index e642432..a8754e6 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs @@ -302,6 +302,10 @@ internal static class InstalledK3dInteropMetadata assembly, "K3DAsyncEngineLib.KAEventHandlerClass", K3dComConstants.KaEventHandlerClassGuid); + var eventHandlerInterfaceType = RequireComType( + assembly, + "K3DAsyncEngineLib.KAEventHandler", + Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832")); var scenePlayerType = RequireComType( assembly, "K3DAsyncEngineLib.IKAScenePlayer", @@ -315,6 +319,22 @@ internal static class InstalledK3dInteropMetadata "K3DAsyncEngineLib.IKAObject", Guid.Parse("B138B515-505B-4C7B-A3E1-F82782005ECF")); + var callbackMethods = eventHandlerInterfaceType.GetInterfaces() + .Append(eventHandlerInterfaceType) + .SelectMany(type => type.GetMethods( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + .Distinct() + .ToArray(); + if (callbackMethods.Length != 282 || + callbackMethods.Any(method => method.ReturnType != typeof(void)) || + !HasCallback(callbackMethods, "OnHello") || + !HasCallback(callbackMethods, "OnScenePlayed", typeof(int), typeof(int), typeof(int)) || + !HasCallback(callbackMethods, "OnCutOut", typeof(int), typeof(int), typeof(int)) || + !HasCallback(callbackMethods, "OnStopAll", typeof(int))) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + var connectMethod = engineType.GetMethods(BindingFlags.Public | BindingFlags.Instance) .SingleOrDefault(method => method.Name == "KTAPConnect" && @@ -326,6 +346,7 @@ internal static class InstalledK3dInteropMetadata parameters[3].ParameterType == typeof(int) && parameters[4].ParameterType.GUID == Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832") && + parameters[4].ParameterType == eventHandlerInterfaceType && parameters[4].ParameterType.IsAssignableFrom(eventHandlerType)); var disconnectMethod = engineType.GetMethod( "Disconnect", @@ -365,6 +386,7 @@ internal static class InstalledK3dInteropMetadata return new InstalledK3dInteropTypes( engineType, eventHandlerType, + eventHandlerInterfaceType, scenePlayerType, sceneType, objectType, @@ -384,6 +406,14 @@ internal static class InstalledK3dInteropMetadata return type; } + private static bool HasCallback( + IEnumerable methods, + string name, + params Type[] parameterTypes) => methods.Any(method => + method.Name == name && + method.GetParameters().Select(parameter => parameter.ParameterType) + .SequenceEqual(parameterTypes)); + private static bool IsHex(char value) => value is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; } @@ -416,6 +446,7 @@ internal sealed class InstalledK3dInteropFileLease internal sealed record InstalledK3dInteropTypes( Type EngineType, Type EventHandlerType, + Type EventHandlerInterfaceType, Type ScenePlayerType, Type SceneType, Type ObjectType, @@ -468,6 +499,9 @@ internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvo [ "SetSceneEffectType", "SetOutputChannelIndex", + "SetBackgroundTexture", + "SetBackgroundVideo", + "UseBackground", "QueryVariables", "GetObject", "Unload" @@ -476,7 +510,21 @@ internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvo private static readonly IReadOnlySet ObjectMethods = new HashSet( [ "SetValue", - "SetVisible" + "SetVisible", + "SetFaceColor", + "SetPosition", + "SetPositionKey", + "SetScale", + "SetCropKey", + "SetCircleAngleKey", + "BeginPathPoint", + "ClearPathPoints", + "AddPathPoint", + "EndPathPoint", + "BeginPathShapePoint", + "ClearPathShapePoints", + "AddPathShapePoint", + "EndPathShapePoint" ], StringComparer.Ordinal); public object? Invoke(object target, string method, params object?[] arguments) diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmCutsSequence.cs b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmCutsSequence.cs index bbb7468..b73f01d 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmCutsSequence.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmCutsSequence.cs @@ -712,6 +712,7 @@ internal sealed class PgmCutsSequenceRunner OutputChannel = null, LayoutIndex = K3dComConstants.LegacyLayoutIndex, SceneDirectory = request.SceneRoot, + TestSceneAllowlist = [FirstSceneCode, NextSceneCode], TrustedLiveOutputEnabled = true, QueueCapacity = 8, ConnectTimeoutMilliseconds = 5_000, diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dEventHandler.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dEventHandler.cs new file mode 100644 index 0000000..c7cf3f9 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dEventHandler.cs @@ -0,0 +1,349 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.InteropServices; +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; + +namespace MBN_STOCK_WEBVIEW.Playout.Interop; + +internal enum K3dLifecycleCallbackKind +{ + ScenePlayed, + CutOut, + StopAll +} + +internal readonly record struct K3dLifecycleCallback( + K3dLifecycleCallbackKind Kind, + bool IsSuccess, + int? OutputChannelIndex, + int? LayerIndex); + +public interface IK3dEventSink +{ + void OnCallback(string methodName, object?[] arguments); +} + +internal interface IK3dEventHandlerFactory +{ + bool SupportsCallbacks { get; } + + object Create(IK3dEventSink sink); +} + +internal sealed class ActivatorK3dEventHandlerFactory(ILateBoundComActivator activator) + : IK3dEventHandlerFactory +{ + private readonly ILateBoundComActivator _activator = + activator ?? throw new ArgumentNullException(nameof(activator)); + + public bool SupportsCallbacks => false; + + public object Create(IK3dEventSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + return _activator.Create(K3dComConstants.KaEventHandlerClassGuid); + } +} + +internal sealed class InstalledK3dEventHandlerFactory : IK3dEventHandlerFactory +{ + public bool SupportsCallbacks => true; + + public object Create(IK3dEventSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + var interfaceType = InstalledK3dInteropMetadata.Resolve().EventHandlerInterfaceType; + return DynamicK3dEventHandlerBuilder.Create(interfaceType, sink); + } +} + +internal sealed class K3dEventQueue : IK3dEventSink +{ + private readonly ConcurrentQueue _callbacks = new(); + private readonly int _capacity; + private int _count; + private int _overflowed; + private int _helloObserved; + + public K3dEventQueue(int capacity = 256) + { + if (capacity < 1) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _capacity = capacity; + } + + public bool HelloObserved => Volatile.Read(ref _helloObserved) != 0; + + public bool HasOverflowed => Volatile.Read(ref _overflowed) != 0; + + public int PendingCount => Volatile.Read(ref _count); + + public void OnCallback(string methodName, object?[] arguments) + { + // Nothing may escape across the native callback boundary. + try + { + if (string.Equals(methodName, "OnHello", StringComparison.Ordinal)) + { + Volatile.Write(ref _helloObserved, 1); + return; + } + + K3dLifecycleCallback callback; + if (string.Equals(methodName, "OnScenePlayed", StringComparison.Ordinal) && + TryReadInt(arguments, 0, out var playSuccess) && + TryReadInt(arguments, 1, out var playChannel) && + TryReadInt(arguments, 2, out var playLayer)) + { + callback = new K3dLifecycleCallback( + K3dLifecycleCallbackKind.ScenePlayed, + playSuccess != 0, + playChannel, + playLayer); + } + else if (string.Equals(methodName, "OnCutOut", StringComparison.Ordinal) && + TryReadInt(arguments, 0, out var cutSuccess) && + TryReadInt(arguments, 1, out var cutChannel) && + TryReadInt(arguments, 2, out var cutLayer)) + { + callback = new K3dLifecycleCallback( + K3dLifecycleCallbackKind.CutOut, + cutSuccess != 0, + cutChannel, + cutLayer); + } + else if (string.Equals(methodName, "OnStopAll", StringComparison.Ordinal) && + TryReadInt(arguments, 0, out var stopSuccess)) + { + callback = new K3dLifecycleCallback( + K3dLifecycleCallbackKind.StopAll, + stopSuccess != 0, + null, + null); + } + else + { + return; + } + + var count = Interlocked.Increment(ref _count); + if (count > _capacity) + { + Interlocked.Decrement(ref _count); + Volatile.Write(ref _overflowed, 1); + return; + } + + _callbacks.Enqueue(callback); + } + catch + { + Volatile.Write(ref _overflowed, 1); + } + } + + public bool TryDequeue(out K3dLifecycleCallback callback) + { + if (!_callbacks.TryDequeue(out callback)) + { + return false; + } + + Interlocked.Decrement(ref _count); + return true; + } + + private static bool TryReadInt(object?[] arguments, int index, out int value) + { + value = 0; + if ((uint)index >= (uint)arguments.Length || arguments[index] is null) + { + return false; + } + + try + { + value = Convert.ToInt32( + arguments[index], + System.Globalization.CultureInfo.InvariantCulture); + return true; + } + catch + { + return false; + } + } +} + +internal static class DynamicK3dEventHandlerBuilder +{ + private static readonly ConcurrentDictionary Constructors = new(); + private static readonly MethodInfo SinkCallbackMethod = typeof(IK3dEventSink).GetMethod( + nameof(IK3dEventSink.OnCallback), + BindingFlags.Public | BindingFlags.Instance) + ?? throw new MissingMethodException(nameof(IK3dEventSink.OnCallback)); + private static readonly IReadOnlySet ForwardedCallbacks = new HashSet( + [ + "OnHello", + "OnScenePlayed", + "OnCutOut", + "OnStopAll" + ], StringComparer.Ordinal); + + public static object Create(Type eventHandlerInterface, IK3dEventSink sink) + { + ArgumentNullException.ThrowIfNull(eventHandlerInterface); + ArgumentNullException.ThrowIfNull(sink); + if (!eventHandlerInterface.IsInterface || !eventHandlerInterface.IsImport || + eventHandlerInterface.GUID != Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832")) + { + throw new InvalidOperationException("The installed K3D callback interface is invalid."); + } + + var constructor = Constructors.GetOrAdd(eventHandlerInterface, BuildConstructor); + return constructor.Invoke([sink]); + } + + private static ConstructorInfo BuildConstructor(Type eventHandlerInterface) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName($"MBN.K3D.Callback.{Guid.NewGuid():N}"), + AssemblyBuilderAccess.RunAndCollect); + var module = assembly.DefineDynamicModule("Callbacks"); + var type = module.DefineType( + $"K3dEventHandler_{Guid.NewGuid():N}", + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class); + type.AddInterfaceImplementation(eventHandlerInterface); + + var comVisible = typeof(ComVisibleAttribute).GetConstructor([typeof(bool)])!; + type.SetCustomAttribute(new CustomAttributeBuilder(comVisible, [true])); + var classInterface = typeof(ClassInterfaceAttribute).GetConstructor([typeof(ClassInterfaceType)])!; + type.SetCustomAttribute(new CustomAttributeBuilder( + classInterface, + [ClassInterfaceType.None])); + + var sinkField = type.DefineField( + "_sink", + typeof(IK3dEventSink), + FieldAttributes.Private | FieldAttributes.InitOnly); + var constructor = type.DefineConstructor( + MethodAttributes.Public, + CallingConventions.Standard, + [typeof(IK3dEventSink)]); + var constructorIl = constructor.GetILGenerator(); + constructorIl.Emit(OpCodes.Ldarg_0); + constructorIl.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)!); + constructorIl.Emit(OpCodes.Ldarg_0); + constructorIl.Emit(OpCodes.Ldarg_1); + constructorIl.Emit(OpCodes.Stfld, sinkField); + constructorIl.Emit(OpCodes.Ret); + + var interfaces = eventHandlerInterface.GetInterfaces().Append(eventHandlerInterface); + var methods = interfaces + .SelectMany(item => item.GetMethods( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + .Distinct() + .ToArray(); + if (methods.Length == 0 || methods.Any(method => method.ReturnType != typeof(void))) + { + throw new InvalidOperationException("The installed K3D callback surface is unexpected."); + } + + foreach (var method in methods) + { + ImplementMethod(type, sinkField, method); + } + + var generated = type.CreateType() + ?? throw new InvalidOperationException("The K3D callback adapter could not be generated."); + return generated.GetConstructor([typeof(IK3dEventSink)]) + ?? throw new MissingMethodException("The K3D callback adapter constructor is missing."); + } + + private static void ImplementMethod( + TypeBuilder type, + FieldBuilder sinkField, + MethodInfo interfaceMethod) + { + var parameters = interfaceMethod.GetParameters(); + var method = type.DefineMethod( + interfaceMethod.Name, + MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final | + MethodAttributes.HideBySig | MethodAttributes.NewSlot, + CallingConventions.Standard, + typeof(void), + parameters.Select(parameter => parameter.ParameterType).ToArray()); + for (var index = 0; index < parameters.Length; index++) + { + method.DefineParameter(index + 1, parameters[index].Attributes, parameters[index].Name); + } + + var il = method.GetILGenerator(); + if (ForwardedCallbacks.Contains(interfaceMethod.Name)) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, sinkField); + il.Emit(OpCodes.Ldstr, interfaceMethod.Name); + EmitInt(il, parameters.Length); + il.Emit(OpCodes.Newarr, typeof(object)); + for (var index = 0; index < parameters.Length; index++) + { + il.Emit(OpCodes.Dup); + EmitInt(il, index); + EmitLoadArgument(il, index + 1); + var parameterType = parameters[index].ParameterType; + if (parameterType.IsByRef) + { + parameterType = parameterType.GetElementType() + ?? throw new InvalidOperationException("The K3D callback parameter is invalid."); + il.Emit(OpCodes.Ldobj, parameterType); + } + + if (parameterType.IsValueType) + { + il.Emit(OpCodes.Box, parameterType); + } + + il.Emit(OpCodes.Stelem_Ref); + } + + il.Emit(OpCodes.Callvirt, SinkCallbackMethod); + } + + il.Emit(OpCodes.Ret); + type.DefineMethodOverride(method, interfaceMethod); + } + + private static void EmitLoadArgument(ILGenerator il, int index) + { + switch (index) + { + case 0: il.Emit(OpCodes.Ldarg_0); break; + case 1: il.Emit(OpCodes.Ldarg_1); break; + case 2: il.Emit(OpCodes.Ldarg_2); break; + case 3: il.Emit(OpCodes.Ldarg_3); break; + default: il.Emit(OpCodes.Ldarg, index); break; + } + } + + private static void EmitInt(ILGenerator il, int value) + { + switch (value) + { + case 0: il.Emit(OpCodes.Ldc_I4_0); break; + case 1: il.Emit(OpCodes.Ldc_I4_1); break; + case 2: il.Emit(OpCodes.Ldc_I4_2); break; + case 3: il.Emit(OpCodes.Ldc_I4_3); break; + case 4: il.Emit(OpCodes.Ldc_I4_4); break; + case 5: il.Emit(OpCodes.Ldc_I4_5); break; + case 6: il.Emit(OpCodes.Ldc_I4_6); break; + case 7: il.Emit(OpCodes.Ldc_I4_7); break; + case 8: il.Emit(OpCodes.Ldc_I4_8); break; + default: il.Emit(OpCodes.Ldc_I4, value); break; + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index 7decf8c..bacd1c3 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -14,6 +14,14 @@ internal interface IK3dSession : IDisposable PlayoutKtapConnectState LastKtapConnectState { get; } + bool SupportsLifecycleCallbacks { get; } + + bool? KtapHelloObserved { get; } + + bool HasPendingLifecycleCallbacks { get; } + + bool HasPendingPlayCallbacks { get; } + bool TryPreventKtapConnect(); void Connect(ValidatedPlayoutOptions options); @@ -22,13 +30,22 @@ internal interface IK3dSession : IDisposable void Prepare(PlayoutCue cue, int layoutIndex); + void UpdateOnAir(PlayoutCue cue, int layoutIndex); + void Play(int layoutIndex); void TakeOut(int layoutIndex, PlayoutTakeOutScope scope); + K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex); + void Abandon(); } +internal readonly record struct K3dCallbackDrainResult( + int CallbackCount, + int UnloadedSceneCount, + bool HelloObserved); + internal interface IK3dGuardedConnectSession : IK3dSession { void ConnectGuarded( @@ -51,31 +68,46 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory { private readonly ILateBoundComActivator _activator; private readonly ILateBoundComMethodInvoker _invoker; + private readonly IK3dEventHandlerFactory _eventHandlerFactory; public DynamicK3dSessionFactory() : this( new InstalledK3dInteropComActivator(), - new InstalledK3dInteropMethodInvoker()) + new InstalledK3dInteropMethodInvoker(), + new InstalledK3dEventHandlerFactory()) { } internal DynamicK3dSessionFactory(ILateBoundComActivator activator) - : this(activator, new InstalledK3dInteropMethodInvoker()) + : this( + activator, + new InstalledK3dInteropMethodInvoker(), + new ActivatorK3dEventHandlerFactory(activator)) { } internal DynamicK3dSessionFactory( ILateBoundComActivator activator, ILateBoundComMethodInvoker invoker) + : this(activator, invoker, new ActivatorK3dEventHandlerFactory(activator)) + { + } + + internal DynamicK3dSessionFactory( + ILateBoundComActivator activator, + ILateBoundComMethodInvoker invoker, + IK3dEventHandlerFactory eventHandlerFactory) { _activator = activator; _invoker = invoker; + _eventHandlerFactory = eventHandlerFactory; } public IK3dSession Create() => new DynamicK3dSession( _activator, new RuntimeComObjectReleaser(), - _invoker); + _invoker, + _eventHandlerFactory); } internal interface ILateBoundComActivator @@ -120,23 +152,30 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession private readonly ILateBoundComActivator _activator; private readonly ILateBoundComObjectReleaser _releaser; private readonly ILateBoundComMethodInvoker _invoker; + private readonly IK3dEventHandlerFactory _eventHandlerFactory; + private readonly K3dEventQueue _eventQueue = new(); private object? _engine; private object? _eventHandler; private object? _player; private object? _preparedScene; private object? _currentScene; private readonly List _retiredScenes = []; + private readonly List _pendingTakeOutScenes = []; private int? _outputChannel; private int? _ownerThreadId; private int _lastKtapConnectState; private int _ktapDispatchGate; private bool _disposed; + private int _pendingPlayCallbacks; + private int _pendingCutOutCallbacks; + private int _pendingStopAllCallbacks; public DynamicK3dSession(ILateBoundComActivator activator) : this( activator, new RuntimeComObjectReleaser(), - new InstalledK3dInteropMethodInvoker()) + new InstalledK3dInteropMethodInvoker(), + new ActivatorK3dEventHandlerFactory(activator)) { } @@ -151,10 +190,24 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession ILateBoundComActivator activator, ILateBoundComObjectReleaser releaser, ILateBoundComMethodInvoker invoker) + : this( + activator, + releaser, + invoker, + new ActivatorK3dEventHandlerFactory(activator)) + { + } + + internal DynamicK3dSession( + ILateBoundComActivator activator, + ILateBoundComObjectReleaser releaser, + ILateBoundComMethodInvoker invoker, + IK3dEventHandlerFactory eventHandlerFactory) { _activator = activator; _releaser = releaser; _invoker = invoker; + _eventHandlerFactory = eventHandlerFactory; } public bool IsConnected => _engine is not null && _player is not null; @@ -162,6 +215,21 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession public PlayoutKtapConnectState LastKtapConnectState => (PlayoutKtapConnectState)Volatile.Read(ref _lastKtapConnectState); + public bool SupportsLifecycleCallbacks => _eventHandlerFactory.SupportsCallbacks; + + public bool? KtapHelloObserved => SupportsLifecycleCallbacks + ? _eventQueue.HelloObserved + : null; + + public bool HasPendingLifecycleCallbacks => SupportsLifecycleCallbacks && + (_pendingPlayCallbacks != 0 || + _pendingCutOutCallbacks != 0 || + _pendingStopAllCallbacks != 0 || + _eventQueue.PendingCount != 0); + + public bool HasPendingPlayCallbacks => + SupportsLifecycleCallbacks && _pendingPlayCallbacks != 0; + public bool TryPreventKtapConnect() { var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0); @@ -210,7 +278,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession var ktapConnectAttempted = false; try { - _eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid); + _eventHandler = _eventHandlerFactory.Create(_eventQueue); _engine = _activator.Create(K3dComConstants.KaEngineClassGuid); if (finalSafetyCheck is not null && !finalSafetyCheck()) { @@ -358,6 +426,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession K3dComConstants.SceneChangeEffectFade, cue.FadeDuration); + foreach (var mutation in cue.Mutations ?? []) + { + if (IsBeforeTransactionSceneSetupMutation(mutation)) + { + ApplySceneSetupMutation(nextScene, mutation); + } + } + Invoke(engine, "BeginTransaction"); transactionStarted = true; foreach (var field in cue.Fields ?? []) @@ -365,6 +441,16 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession ApplyField(nextScene, field); } + foreach (var mutation in cue.Mutations ?? []) + { + if (!IsBeforeTransactionSceneSetupMutation(mutation)) + { + ApplyTransactionMutation(nextScene, mutation); + } + } + + Invoke(nextScene, "QueryVariables"); + if (outputChannel.HasValue) { Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault()); @@ -375,7 +461,6 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } transactionStarted = false; - Invoke(nextScene, "QueryVariables"); Invoke(player, "Prepare", layoutIndex, nextScene); var replacedPreparedScene = _preparedScene; @@ -409,7 +494,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession { EnsureThread(); EnsureConnected(); - Invoke(_player!, "Play", layoutIndex); + InvokeTrackedPlay(layoutIndex); var preparedScene = _preparedScene; if (preparedScene is not null) @@ -426,26 +511,252 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } + private void InvokeTrackedPlay(int layoutIndex) + { + if (SupportsLifecycleCallbacks) + { + _pendingPlayCallbacks++; + } + + try + { + Invoke(_player!, "Play", layoutIndex); + } + catch + { + if (SupportsLifecycleCallbacks) + { + _pendingPlayCallbacks--; + } + + throw; + } + } + + public void UpdateOnAir(PlayoutCue cue, int layoutIndex) + { + ArgumentNullException.ThrowIfNull(cue); + EnsureThread(); + EnsureConnected(); + var engine = _engine!; + var player = _player!; + var outputChannel = _outputChannel; + object? playingScene = null; + var transactionStarted = false; + + try + { + // MainForm.timer1_Tick replays the current layout before entering + // Show_PlayList(idx: 1), then prepares and plays the mutated scene again. + InvokeTrackedPlay(layoutIndex); + playingScene = InvokeRequired(player, "GetPlayingScene", layoutIndex); + Invoke(engine, "BeginTransaction"); + transactionStarted = true; + + foreach (var field in cue.Fields ?? []) + { + ApplyField(playingScene, field); + } + + foreach (var mutation in cue.Mutations ?? []) + { + // The legacy idx == 1 path updates the playing scene in place. It does not + // reapply scene-level background setup or the scene transition effect. + if (!IsSceneLevelMutation(mutation)) + { + ApplyMutation(playingScene, mutation); + } + } + + Invoke(playingScene, "QueryVariables"); + if (outputChannel.HasValue) + { + Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault()); + } + else + { + Invoke(engine, "EndTransaction"); + } + + transactionStarted = false; + Invoke(player, "Prepare", layoutIndex, playingScene); + InvokeTrackedPlay(layoutIndex); + + // GetPlayingScene can return the same RCW or another RCW for the same COM + // scene. An in-place page update must never retire/unload the active scene. + if (_currentScene is null) + { + _currentScene = playingScene; + playingScene = null; + } + else if (ReferenceEquals(_currentScene, playingScene)) + { + playingScene = null; + } + } + catch + { + if (transactionStarted) + { + try + { + Invoke(engine, "RollbackTransaction"); + } + catch + { + // Preserve the original SDK failure. The engine quarantines this session. + } + } + + throw; + } + finally + { + ReleaseComObject(playingScene); + } + } + public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope) { EnsureThread(); EnsureConnected(); - if (scope == PlayoutTakeOutScope.All) + if (SupportsLifecycleCallbacks) { - Invoke(_player!, "StopAll"); + if (scope == PlayoutTakeOutScope.All) + { + _pendingStopAllCallbacks++; + } + else + { + _pendingCutOutCallbacks++; + } + } + + try + { + if (scope == PlayoutTakeOutScope.All) + { + Invoke(_player!, "StopAll"); + } + else + { + Invoke(_player!, "CutOut", layoutIndex); + } + } + catch + { + if (SupportsLifecycleCallbacks) + { + if (scope == PlayoutTakeOutScope.All) + { + _pendingStopAllCallbacks--; + } + else + { + _pendingCutOutCallbacks--; + } + } + + throw; + } + + if (SupportsLifecycleCallbacks) + { + MoveAllScenesToPendingTakeOut(); } else { - Invoke(_player!, "CutOut", layoutIndex); + var currentScene = _currentScene; + _currentScene = null; + if (currentScene is not null) + { + RetainRetiredScene(currentScene); + } } - var currentScene = _currentScene; - _currentScene = null; - if (currentScene is not null) + } + + public K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex) + { + EnsureThread(); + EnsureConnected(); + if (!SupportsLifecycleCallbacks) { - RetainRetiredScene(currentScene); + return new K3dCallbackDrainResult(0, 0, false); } + if (_eventQueue.HasOverflowed) + { + throw new InvalidOperationException("The K3D callback queue overflowed."); + } + + var callbackCount = 0; + var unloadedSceneCount = 0; + while (_eventQueue.TryDequeue(out var callback)) + { + callbackCount++; + switch (callback.Kind) + { + case K3dLifecycleCallbackKind.ScenePlayed: + if (_pendingPlayCallbacks == 0 || + !MatchesOutput(callback, layoutIndex)) + { + continue; + } + + _pendingPlayCallbacks--; + if (!callback.IsSuccess) + { + throw new InvalidOperationException("The K3D play callback reported failure."); + } + + unloadedSceneCount += UnloadAndRelease(_retiredScenes); + break; + case K3dLifecycleCallbackKind.CutOut: + if (_pendingCutOutCallbacks == 0 || + !MatchesOutput(callback, layoutIndex)) + { + continue; + } + + _pendingCutOutCallbacks--; + if (!callback.IsSuccess) + { + throw new InvalidOperationException("The K3D cut-out callback reported failure."); + } + + // CutOut terminates the selected layer. Any Play completion for that + // layer which has not arrived yet can no longer be required for safe + // lifetime accounting. + _pendingPlayCallbacks = 0; + unloadedSceneCount += UnloadAndRelease(_pendingTakeOutScenes); + break; + case K3dLifecycleCallbackKind.StopAll: + if (_pendingStopAllCallbacks == 0) + { + continue; + } + + _pendingStopAllCallbacks--; + if (!callback.IsSuccess) + { + throw new InvalidOperationException("The K3D stop-all callback reported failure."); + } + + // StopAll terminates every layer owned by this player. A stopped Play + // is not guaranteed to produce a later OnScenePlayed callback. + _pendingPlayCallbacks = 0; + unloadedSceneCount += UnloadAndRelease(_pendingTakeOutScenes); + break; + default: + throw new InvalidOperationException("The K3D callback is not allowlisted."); + } + } + + return new K3dCallbackDrainResult( + callbackCount, + unloadedSceneCount, + _eventQueue.HelloObserved); } public void Abandon() @@ -513,6 +824,180 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } } + private void ApplySceneSetupMutation(object scene, PlayoutMutation mutation) + { + switch (mutation) + { + case PlayoutUseBackground background: + Invoke(scene, "UseBackground", background.IsEnabled ? 1 : 0); + break; + case PlayoutSetBackgroundTexture texture: + Invoke(scene, "SetBackgroundTexture", texture.AssetPath); + break; + case PlayoutSetBackgroundVideo video: + Invoke( + scene, + "SetBackgroundVideo", + video.AssetPath, + video.LoopCount, + video.LoopInfinite ? 1 : 0); + break; + default: + throw new InvalidOperationException("The scene setup mutation is not allowlisted."); + } + } + + private void ApplyTransactionMutation(object scene, PlayoutMutation mutation) + { + if (IsSceneLevelMutation(mutation)) + { + ApplySceneSetupMutation(scene, mutation); + return; + } + + ApplyMutation(scene, mutation); + } + + private static bool IsSceneLevelMutation(PlayoutMutation mutation) => mutation is + PlayoutUseBackground or PlayoutSetBackgroundTexture or PlayoutSetBackgroundVideo; + + private static bool IsBeforeTransactionSceneSetupMutation(PlayoutMutation mutation) => + mutation switch + { + PlayoutUseBackground background => + background.Timing == PlayoutMutationTiming.BeforeTransaction, + PlayoutSetBackgroundTexture texture => + texture.Timing == PlayoutMutationTiming.BeforeTransaction, + PlayoutSetBackgroundVideo video => + video.Timing == PlayoutMutationTiming.BeforeTransaction, + _ => false + }; + + private void ApplyMutation(object scene, PlayoutMutation mutation) + { + var objectName = mutation switch + { + PlayoutSetValue value => value.ObjectName, + PlayoutSetAssetValue value => value.ObjectName, + PlayoutSetVisible visible => visible.ObjectName, + PlayoutSetFaceColor color => color.ObjectName, + PlayoutSetPosition position => position.ObjectName, + PlayoutSetPositionKey positionKey => positionKey.ObjectName, + PlayoutSetScale scale => scale.ObjectName, + PlayoutSetCropKey crop => crop.ObjectName, + PlayoutSetCircleAngleKey angle => angle.ObjectName, + PlayoutSetPathPoints path => path.ObjectName, + PlayoutSetPathShapePoints shape => shape.ObjectName, + _ => throw new InvalidOperationException("The scene mutation is not allowlisted.") + }; + + var sceneObject = InvokeRequired(scene, "GetObject", objectName); + try + { + switch (mutation) + { + case PlayoutSetValue value: + Invoke(sceneObject, "SetValue", value.Value); + break; + case PlayoutSetAssetValue asset: + Invoke(sceneObject, "SetValue", asset.AssetPath); + break; + case PlayoutSetVisible visible: + Invoke(sceneObject, "SetVisible", visible.IsVisible ? 1 : 0); + break; + case PlayoutSetFaceColor color: + Invoke( + sceneObject, + "SetFaceColor", + color.Red, + color.Green, + color.Blue, + color.Alpha); + break; + case PlayoutSetPosition position: + Invoke( + sceneObject, + "SetPosition", + position.X, + position.Y, + position.Z, + (int)position.Components); + break; + case PlayoutSetPositionKey positionKey: + Invoke( + sceneObject, + "SetPositionKey", + positionKey.KeyIndex, + positionKey.X, + positionKey.Y, + positionKey.Z, + (int)positionKey.Components); + break; + case PlayoutSetScale scale: + Invoke( + sceneObject, + "SetScale", + scale.X, + scale.Y, + scale.Z, + (int)scale.Components); + break; + case PlayoutSetCropKey crop: + Invoke( + sceneObject, + "SetCropKey", + crop.KeyIndex, + crop.Left, + crop.Top, + crop.Right, + crop.Bottom, + (int)crop.Edges); + break; + case PlayoutSetCircleAngleKey angle: + Invoke( + sceneObject, + "SetCircleAngleKey", + angle.KeyIndex, + angle.Start, + angle.End, + (int)angle.Components); + break; + case PlayoutSetPathPoints path: + ReplacePathPoints(sceneObject, path.Points, shape: false); + break; + case PlayoutSetPathShapePoints shape: + ReplacePathPoints(sceneObject, shape.Points, shape: true); + break; + default: + throw new InvalidOperationException("The scene mutation is not allowlisted."); + } + } + finally + { + ReleaseComObject(sceneObject); + } + } + + private void ReplacePathPoints( + object sceneObject, + IReadOnlyList points, + bool shape) + { + var beginMethod = shape ? "BeginPathShapePoint" : "BeginPathPoint"; + var clearMethod = shape ? "ClearPathShapePoints" : "ClearPathPoints"; + var addMethod = shape ? "AddPathShapePoint" : "AddPathPoint"; + var endMethod = shape ? "EndPathShapePoint" : "EndPathPoint"; + + Invoke(sceneObject, beginMethod); + Invoke(sceneObject, clearMethod); + foreach (var point in points) + { + Invoke(sceneObject, addMethod, point.X, point.Y, point.Z); + } + + Invoke(sceneObject, endMethod); + } + private void EnsureThread() { var currentThreadId = Environment.CurrentManagedThreadId; @@ -555,6 +1040,15 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } _retiredScenes.Clear(); + foreach (var pendingTakeOutScene in _pendingTakeOutScenes) + { + AddDistinctScene(scenes, pendingTakeOutScene); + } + + _pendingTakeOutScenes.Clear(); + _pendingPlayCallbacks = 0; + _pendingCutOutCallbacks = 0; + _pendingStopAllCallbacks = 0; var player = _player; _player = null; _outputChannel = null; @@ -584,6 +1078,42 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } } + private void MoveAllScenesToPendingTakeOut() + { + AddDistinctScene(_pendingTakeOutScenes, _preparedScene); + _preparedScene = null; + AddDistinctScene(_pendingTakeOutScenes, _currentScene); + _currentScene = null; + foreach (var retiredScene in _retiredScenes) + { + AddDistinctScene(_pendingTakeOutScenes, retiredScene); + } + + _retiredScenes.Clear(); + } + + private int UnloadAndRelease(List scenes) + { + var unloaded = 0; + while (scenes.Count > 0) + { + var scene = scenes[0]; + Invoke(scene, "Unload"); + scenes.RemoveAt(0); + ReleaseComObject(scene); + unloaded++; + } + + return unloaded; + } + + private bool MatchesOutput(K3dLifecycleCallback callback, int layoutIndex) + { + var expectedChannel = _outputChannel ?? 0; + return callback.OutputChannelIndex == expectedChannel && + callback.LayerIndex == layoutIndex; + } + private static void AddDistinctScene(List scenes, object? scene) { if (scene is not null && !scenes.Any(item => ReferenceEquals(item, scene))) diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs index 935445f..88a11b0 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs @@ -37,6 +37,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private int _reconnectAttempts; private long _sequence; private int _lastKtapConnectState; + private int _ktapHelloObservedState; private bool _connectionRequested; private volatile bool _outcomeUnknown; private volatile bool _quarantineIncomplete; @@ -118,6 +119,17 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine token => NextCoreAsync(cue, token)); } + public Task UpdateOnAirAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(cue); + return SerializedAsync( + PlayoutOperation.UpdateOnAir, + cancellationToken, + token => UpdateOnAirCoreAsync(cue, token)); + } + public Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default) => @@ -190,7 +202,18 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined) { - if (_onAirSceneName is not null || _outcomeUnknown) + if (_session.HasPendingLifecycleCallbacks && + !await ProcessLifecycleCallbacksAsync(CancellationToken.None).ConfigureAwait(false)) + { + _outcomeUnknown = true; + } + + if (_session?.HasPendingLifecycleCallbacks == true) + { + _outcomeUnknown = true; + await AbandonCurrentSessionAsync().ConfigureAwait(false); + } + else if (_onAirSceneName is not null || _outcomeUnknown) { _outcomeUnknown = true; await AbandonCurrentSessionAsync().ConfigureAwait(false); @@ -307,6 +330,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine : PlayoutConnectionState.Disconnected; } + if (_session is not null && + !await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false)) + { + MarkOutcomeUnknown(PlayoutOperation.Disconnect); + return; + } + if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled || _outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts || _timeProvider.GetUtcNow() < _nextReconnectAt) @@ -702,6 +732,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return MarkOutcomeUnknown(PlayoutOperation.Disconnect); } + if (_session?.HasPendingLifecycleCallbacks == true) + { + if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false)) + { + return MarkOutcomeUnknown(PlayoutOperation.Disconnect); + } + + if (_session?.HasPendingLifecycleCallbacks == true) + { + _connectionState = PlayoutConnectionState.Connected; + const string pendingMessage = + "Tornado 장면 완료 이벤트를 기다리는 중이므로 연결을 해제하지 않았습니다."; + PublishStatus(pendingMessage); + return Result( + PlayoutOperation.Disconnect, + PlayoutResultCode.Unavailable, + pendingMessage); + } + } + _connectionState = PlayoutConnectionState.Disconnecting; PublishStatus("Tornado 송출 연결을 해제하는 중입니다."); if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) @@ -749,9 +799,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, exception.Message)); } - if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved)) + if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live && + !_options.IsOutputSceneAllowed(resolved)) { - return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.")); + return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.")); } return PrepareActualAsync(resolved, cancellationToken); @@ -810,9 +861,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다."); } - if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(_preparedCue)) + if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live && + !_options.IsOutputSceneAllowed(_preparedCue)) { - return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."); + return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다."); } var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeIn, cancellationToken) @@ -879,9 +931,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, exception.Message); } - if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved)) + if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live && + !_options.IsOutputSceneAllowed(resolved)) { - return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."); + return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다."); } var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Next, cancellationToken) @@ -914,6 +967,108 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return result; } + private async Task UpdateOnAirCoreAsync( + PlayoutCue cue, + CancellationToken cancellationToken) + { + if (_onAirSceneName is null) + { + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Rejected, + "Same-scene refresh requires a scene that is already on air."); + } + + if (_options.Mode == PlayoutMode.Disabled) + { + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Rejected, + "Playout is disabled."); + } + + if (_options.Mode == PlayoutMode.DryRun) + { + if (!IsCurrentOnAirCue(cue)) + { + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Rejected, + "Same-scene refresh can update only the scene currently on air."); + } + + _preparedCue = null; + var message = DryRunOperationMessage("Same-scene refresh was validated."); + PublishStatus(message); + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Success, + message); + } + + if (!CanTakeIn()) + { + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Rejected, + "Live playout authorization is required."); + } + + PlayoutCue resolved; + try + { + resolved = _options.ResolveCue(cue); + } + catch (PlayoutRequestException exception) + { + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Rejected, + exception.Message); + } + + if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live && + !_options.IsOutputSceneAllowed(resolved)) + { + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Rejected, + "The scene is not approved for output."); + } + + if (!IsCurrentOnAirCue(resolved)) + { + return Result( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Rejected, + "Same-scene refresh can update only the scene currently on air."); + } + + var availability = await EnsureCommandAvailableAsync( + PlayoutOperation.UpdateOnAir, + cancellationToken).ConfigureAwait(false); + if (availability is not null) + { + return availability; + } + + var result = await RunSessionOperationAsync( + PlayoutOperation.UpdateOnAir, + (session, dispatchLatch) => ExecuteSdkDispatch( + dispatchLatch, + () => session.UpdateOnAir(resolved, _options.LayoutIndex)), + partialOutcomeUnknown: true, + cancellationToken).ConfigureAwait(false); + if (result.IsSuccess) + { + _onAirSceneName = SafeSceneName(resolved); + _preparedCue = null; + PublishStatus("The on-air scene page was updated."); + } + + return result; + } + private async Task TakeOutCoreAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken) @@ -960,7 +1115,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { _preparedCue = null; _onAirSceneName = null; - PublishStatus("장면 송출이 종료되었습니다."); + PublishStatus(_session?.HasPendingLifecycleCallbacks == true + ? "TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다." + : "장면 송출이 종료되었습니다."); } return result; @@ -1042,9 +1199,76 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return Result(operation, PlayoutResultCode.Unavailable, "Tornado 송출 연결이 필요합니다."); } + if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false)) + { + return MarkOutcomeUnknown(operation); + } + + if (RequiresCompletedPlay(operation) && + _session?.HasPendingPlayCallbacks == true) + { + const string pendingPlayMessage = + "The previous SDK Play completion callback is still pending. " + + "Only TAKE OUT is allowed until Tornado confirms the result."; + PublishStatus(pendingPlayMessage); + return Result( + operation, + PlayoutResultCode.Unavailable, + pendingPlayMessage); + } + return null; } + private static bool RequiresCompletedPlay(PlayoutOperation operation) => operation is + PlayoutOperation.Prepare or + PlayoutOperation.TakeIn or + PlayoutOperation.Next or + PlayoutOperation.UpdateOnAir; + + private async Task ProcessLifecycleCallbacksAsync( + CancellationToken cancellationToken) + { + var session = _session; + if (session is null || !session.SupportsLifecycleCallbacks) + { + CaptureKtapEvidence(session); + return true; + } + + if (_dispatcher is null || _dispatcher.IsQuarantined) + { + return false; + } + + try + { + await _dispatcher.InvokeAsync( + () => session.ProcessPendingCallbacks(_options.LayoutIndex), + _options.OperationTimeout, + CancellationToken.None, + _ => AbandonSessionInline(session), + () => AbandonSessionInline(session)).ConfigureAwait(false); + CaptureKtapEvidence(session); + return true; + } + catch + { + _session = null; + _connectedProcessSnapshot = null; + if (_dispatcher.IsQuarantined) + { + _quarantineIncomplete = true; + } + else if (!await AbandonSessionOnStaAsync(session).ConfigureAwait(false)) + { + _quarantineIncomplete = true; + } + + return false; + } + } + private async Task RunSessionOperationAsync( PlayoutOperation operation, Action callback, @@ -1177,6 +1401,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine } var session = _session; + if (session?.HasPendingLifecycleCallbacks == true) + { + // A late OnScenePlayed/OnCutOut/OnStopAll callback still owns scene lifetime. + // Never issue Disconnect while that result is unresolved. + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return false; + } + _session = null; _connectedProcessSnapshot = null; if (session is null) @@ -1599,7 +1831,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds, LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read( ref _lastKtapConnectState), - KtapHelloObserved = null + KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch + { + 1 => false, + 2 => true, + _ => null + } }; } @@ -1615,6 +1852,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { Volatile.Write(ref _lastKtapConnectState, (int)state); } + + if (session.SupportsLifecycleCallbacks) + { + Volatile.Write( + ref _ktapHelloObservedState, + session.KtapHelloObserved == true ? 2 : 1); + } } private void PreventLateKtapDispatchAndCapture(IK3dSession? session) @@ -1660,6 +1904,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return value; } + private bool IsCurrentOnAirCue(PlayoutCue cue) + { + var sceneName = cue.SceneName?.Trim(); + return !string.IsNullOrEmpty(sceneName) && + string.Equals(sceneName, _onAirSceneName, StringComparison.OrdinalIgnoreCase); + } + private string CurrentMessage() => _connectionState switch { PlayoutConnectionState.Connected => @@ -1704,6 +1955,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private static string SuccessMessage(PlayoutOperation operation) => operation switch { + PlayoutOperation.UpdateOnAir => "The on-air scene page was updated.", PlayoutOperation.Prepare => "장면이 준비되었습니다.", PlayoutOperation.TakeIn => "장면이 송출되었습니다.", PlayoutOperation.Next => "다음 장면이 송출되었습니다.", diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs new file mode 100644 index 0000000..ca82bb1 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs @@ -0,0 +1,427 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class CandleSceneBuilderTests +{ + public static TheoryData AliasCases => new() + { + { CandleCutAlias.Cut8035, CandlePeriod.Intraday, 0f, "EBE44FFF7523C76172C2B4D0BF718D22D42D41C8533D3CF44480D237D7CD5B8B" }, + { CandleCutAlias.Cut8061, CandlePeriod.FiveDays, 1188f, "D1511EE579F58F10552B22D44C02C94E6BD0E0F61F7DC668827B9FABF8E69682" }, + { CandleCutAlias.Cut8040, CandlePeriod.TwentyDays, 1398f, "B1CEA60310E547A2FA1847C5AEBEDE25CF5448AA257EB5A7798BD295591C555F" }, + { CandleCutAlias.Cut8046, CandlePeriod.SixtyDays, 1442f, "24CA30D09E5EAF9034A925509E0C6A06E0B9ED1F5FB5488B9F595E732330DA36" }, + { CandleCutAlias.Cut8051, CandlePeriod.OneHundredTwentyDays, 1452f, "C6F23FD7A55D2DC12CE69323226BA65E05B57ECACB77C90CD8858E49CD65D5CA" }, + { CandleCutAlias.Cut8056, CandlePeriod.TwoHundredFortyDays, 1464f, "D6ABC34111DAC4B32F8D1CA5563EAA248BA5B1C0C782B74E53A9C651BBC98D48" } + }; + + public static TheoryData MarketCases => new() + { + new( + CandleMarketTarget.KospiIndex, + CandleChartMode.Price, + "ignored", + "\uCF54\uC2A4\uD53C \uC9C0\uC218", + "\uB2E8\uC704 : p", + "100.00"), + new( + CandleMarketTarget.KosdaqIndex, + CandleChartMode.Price, + "ignored", + "\uCF54\uC2A4\uB2E5 \uC9C0\uC218", + "\uB2E8\uC704 : p", + "100.00"), + new( + CandleMarketTarget.Kospi200Index, + CandleChartMode.Price, + "ignored", + "\uCF54\uC2A4\uD53C200 \uC9C0\uC218", + "\uB2E8\uC704 : p", + "100.00"), + new( + CandleMarketTarget.Krx100Index, + CandleChartMode.Price, + "ignored", + "KRX100 \uC9C0\uC218", + "\uB2E8\uC704 : p", + "100.00"), + new( + CandleMarketTarget.FuturesIndex, + CandleChartMode.ExpectedExecution, + "ignored", + "\uC120\uBB3C \uC608\uC0C1\uCCB4\uACB0\uC9C0\uC218", + "\uB2E8\uC704 : p", + "100.00"), + new( + CandleMarketTarget.KospiIndustry, + CandleChartMode.Price, + "Banks", + "\uCF54\uC2A4\uD53C - Banks", + "\uB2E8\uC704 : p", + "100.00"), + new( + CandleMarketTarget.KosdaqIndustry, + CandleChartMode.ExpectedExecution, + "Tech", + "Tech \uC608\uC0C1\uCCB4\uACB0", + null, + "100.00"), + new( + CandleMarketTarget.OverseasIndex, + CandleChartMode.Price, + "Dow", + "Dow", + "\uB2E8\uC704 : p", + "100.00"), + new( + CandleMarketTarget.OverseasStock, + CandleChartMode.Price, + "NVDA", + "NVDA", + "\uB2E8\uC704 : \uB2EC\uB7EC", + "100.00"), + new( + CandleMarketTarget.KospiStock, + CandleChartMode.ExpectedExecution, + "Samsung", + "Samsung \uC608\uC0C1\uCCB4\uACB0", + null, + "100"), + new( + CandleMarketTarget.KosdaqStock, + CandleChartMode.TradingHalt, + "Halt", + "Halt", + "\uB2E8\uC704 : \uC6D0", + "100") + }; + + [Fact] + public void Volume_candle_matches_legacy_setup_quote_crops_colors_paths_and_partial_points() + { + var data = new S8010SceneData( + CandleCutAlias.Cut8061, + CandleMarketTarget.KospiStock, + CandleChartMode.PriceWithVolume, + "Stock", + new CandleQuoteData(110, -10, -8.33, ScenePriceDirection.Down), + [ + Row(1, 100, 120, 90, 110, 100, 105, null), + Row(2, 110, 125, 80, 100, 0, null, 100), + Row(3, 100, 100, 100, 100, 50, 100, 100) + ], + ShowMovingAverage5: true, + ShowMovingAverage20: true); + + var mutations = new S8010SceneMutationBuilder().Build(data); + var actual = mutations.Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=Stock", "L:ma5=", "L:ma20=", + "V:unit=\uB2E8\uC704 : \uC6D0", "V:title=Stock", + "V:price=110", "V:changePrice=-10", "V:rate=-8.33", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:down=1", "S:bg1=0", "S:bg2=0", "S:bg3=1", + "S:volrumeG=1" + ], actual.Take(18)); + Assert.Equal("S:volume1=0", actual[18]); + Assert.Equal("S:volume240=0", actual[257]); + Assert.Equal("S:candle1=0", actual[258]); + Assert.Equal("S:candle240=0", actual[497]); + Assert.Equal("V:date_begin=07/01", actual[498]); + Assert.Contains("C:candlebody1:170:0:0:255", actual); + Assert.Contains("C:candlebar2:15:99:189:255", actual); + Assert.Contains("C:candlebody3:95:95:95:255", actual); + Assert.Contains("K:volume2:1:0:100:0:100:10", actual); + + var ma5 = Assert.Single( + mutations.OfType(), + path => path.ObjectName == "ma5" && path.Points.Count != 0); + Assert.Single(ma5.Points); + Assert.Empty(mutations.OfType() + .Last(path => path.ObjectName == "ma20").Points); + Assert.Equal( + "2EEA2C83B0D3086A894A3AE17DD8DABE9447764757B83EE4F17DE2217BD70EBD", + SnapshotHash(actual)); + } + + [Theory] + [MemberData(nameof(AliasCases))] + public void Every_alias_maps_to_its_period_width_dates_and_full_golden_sequence( + CandleCutAlias alias, + CandlePeriod expectedPeriod, + float expectedSecondX, + string expectedHash) + { + var rows = new[] + { + Row(1, 100, 120, 90, 110, 10, 105, 104, new TimeOnly(9, 0)), + Row(2, 110, 125, 95, 100, 20, 106, 105, new TimeOnly(9, 5)) + }; + var data = new S8010SceneData( + alias, + CandleMarketTarget.KospiStock, + CandleChartMode.Price, + "A", + new CandleQuoteData(100, 1, 1, ScenePriceDirection.Up), + rows, + ShowMovingAverage5: true, + ShowMovingAverage20: false); + + var mutations = new S8010SceneMutationBuilder().Build(data); + var ma5 = mutations.OfType() + .Last(path => path.ObjectName == "ma5"); + + Assert.Equal(expectedPeriod, CandleCutAliasMetadata.GetPeriod(alias)); + Assert.Equal(2, ma5.Points.Count); + Assert.Equal(0f, ma5.Points[0].X); + Assert.Equal(expectedSecondX, ma5.Points[1].X); + if (expectedPeriod == CandlePeriod.Intraday) + { + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "date_begin", Value: "09:00" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "date_end", Value: "15:30" }); + } + else + { + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "date_begin", Value: "07/01" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "date_end", Value: "07/02" }); + } + + var hash = SnapshotHash(mutations.Select(Describe)); + Assert.Equal(expectedHash, hash); + } + + [Theory] + [MemberData(nameof(MarketCases))] + public void Market_and_chart_targets_use_closed_title_unit_and_precision_mapping(MarketCase item) + { + var data = new S8010SceneData( + CandleCutAlias.Cut8061, + item.Target, + item.Mode, + item.DisplayName, + new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat), + [Row(1, 100, 100, 100, 100, 0, null, null)], + false, + false); + + var mutations = new S8010SceneMutationBuilder().Build(data); + + Assert.Equal( + item.ExpectedTitle, + mutations.OfType() + .Last(mutation => mutation.ObjectName == "title").Value); + if (item.ExpectedUnit is null) + { + Assert.DoesNotContain( + mutations.OfType(), + mutation => mutation.ObjectName == "unit"); + } + else + { + Assert.Equal( + item.ExpectedUnit, + mutations.OfType() + .Last(mutation => mutation.ObjectName == "unit").Value); + } + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "price", Value: var value } && + value == item.ExpectedPrice); + } + + [Fact] + public void Equal_range_zero_volume_and_single_partial_path_are_finite() + { + var data = new S8010SceneData( + CandleCutAlias.Cut8061, + CandleMarketTarget.KospiStock, + CandleChartMode.PriceWithVolume, + "Equal", + new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat), + [Row(1, 100, 100, 100, 100, 0, 100, 100)], + true, + true); + + var mutations = new S8010SceneMutationBuilder().Build(data); + + Assert.All(mutations, AssertFinite); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetCropKey + { + ObjectName: "candlebody1", + Top: 0, + Bottom: 1 + }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetCropKey + { + ObjectName: "volume1", + Top: 100, + Bottom: 100 + }); + var ma5 = mutations.OfType() + .Last(path => path.ObjectName == "ma5"); + var point = Assert.Single(ma5.Points); + Assert.Equal(0f, point.X); + Assert.Equal(98f, point.Y); + } + + [Fact] + public void Invalid_alias_target_ohlc_volume_time_and_count_fail_closed() + { + var valid = ValidData(); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with { CutAlias = (CandleCutAlias)9999 })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with { MarketTarget = (CandleMarketTarget)99 })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with { ChartMode = (CandleChartMode)99 })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with { Rows = [] })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + Rows = Enumerable.Range(1, 241) + .Select(index => Row(((index - 1) % 28) + 1, 1, 1, 1, 1, 0, null, null)) + .ToArray() + })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + Rows = [Row(1, 100, 90, 95, 100, 0, null, null)] + })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + Rows = [Row(1, 100, 110, 90, 100, -1, null, null)] + })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + CutAlias = CandleCutAlias.Cut8035, + Rows = [Row(1, 100, 110, 90, 100, 0, null, null, null)] + })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + ChartMode = CandleChartMode.TradingHalt, + MarketTarget = CandleMarketTarget.KospiIndex + })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + Quote = valid.Quote with { CurrentPrice = double.NaN } + })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + Rows = [Row(1, 0, double.MaxValue, -double.MaxValue, 0, 0, null, null)] + })); + Assert.Throws(() => + new S8010SceneMutationBuilder().Build(valid with + { + Rows = [Row(1, 100, 110, 90, 100, 0, double.PositiveInfinity, null)] + })); + + var properties = typeof(S8010SceneData) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(property => property.Name); + Assert.DoesNotContain(properties, name => + name is "ObjectName" or "MethodName" or "AssetPath" or "SceneFile"); + } + + private static S8010SceneData ValidData() => new( + CandleCutAlias.Cut8061, + CandleMarketTarget.KospiStock, + CandleChartMode.Price, + "A", + new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat), + [Row(1, 100, 110, 90, 100, 0, null, null)], + false, + false); + + private static CandleRowData Row( + int day, + double open, + double high, + double low, + double close, + double? volume, + double? ma5, + double? ma20, + TimeOnly? time = null) => + new( + new DateOnly(2026, 7, day), + time, + open, + high, + low, + close, + volume, + ma5, + ma20); + + private static void AssertFinite(PlayoutMutation mutation) + { + switch (mutation) + { + case PlayoutSetPosition position: + Assert.True(float.IsFinite(position.X)); + Assert.True(float.IsFinite(position.Y)); + Assert.True(float.IsFinite(position.Z)); + break; + case PlayoutSetCropKey crop: + Assert.True(float.IsFinite(crop.Left)); + Assert.True(float.IsFinite(crop.Top)); + Assert.True(float.IsFinite(crop.Right)); + Assert.True(float.IsFinite(crop.Bottom)); + break; + case PlayoutSetPathPoints path: + Assert.All(path.Points, point => + { + Assert.True(float.IsFinite(point.X)); + Assert.True(float.IsFinite(point.Y)); + Assert.True(float.IsFinite(point.Z)); + }); + break; + } + } + + private static string SnapshotHash(IEnumerable snapshot) + { + var bytes = Encoding.UTF8.GetBytes(string.Join('\n', snapshot)); + return Convert.ToHexString(SHA256.HashData(bytes)); + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + PlayoutSetFaceColor color => + $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}", + PlayoutSetPosition position => FormattableString.Invariant( + $"P:{position.ObjectName}:{position.X}:{position.Y}:{position.Z}:{(int)position.Components}"), + PlayoutSetCropKey crop => FormattableString.Invariant( + $"K:{crop.ObjectName}:{crop.KeyIndex}:{crop.Left}:{crop.Top}:{crop.Right}:{crop.Bottom}:{(int)crop.Edges}"), + PlayoutSetPathPoints path => "L:" + path.ObjectName + "=" + string.Join( + ";", + path.Points.Select(point => FormattableString.Invariant($"{point.X},{point.Y},{point.Z}"))), + _ => mutation.GetType().Name + }; + + public sealed record MarketCase( + CandleMarketTarget Target, + CandleChartMode Mode, + string DisplayName, + string ExpectedTitle, + string? ExpectedUnit, + string ExpectedPrice); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs new file mode 100644 index 0000000..bfb9e8a --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneDataLoaderTests.cs @@ -0,0 +1,458 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class CandleSceneDataLoaderTests +{ + private static readonly string[] QuoteColumns = + [ + "DISPLAY_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "RATE", "DIRECTION_CODE" + ]; + + private static readonly string[] RowColumns = + [ + "TRADING_DATE", "TRADING_TIME", + "OPEN_PRICE", "HIGH_PRICE", "LOW_PRICE", "CLOSE_PRICE", + "VOLUME", "MA5", "MA20" + ]; + + public static TheoryData AliasLimits => new() + { + { CandleCutAlias.Cut8035, 78 }, + { CandleCutAlias.Cut8061, 5 }, + { CandleCutAlias.Cut8040, 20 }, + { CandleCutAlias.Cut8046, 60 }, + { CandleCutAlias.Cut8051, 120 }, + { CandleCutAlias.Cut8056, 240 } + }; + + public static TheoryData RouteCases => new() + { + Route(CandleMarketTarget.KospiIndex, CandleChartMode.Price, + CandleCutAlias.Cut8035, null, "FROM t_index", "FROM t_index_his_5M"), + Route(CandleMarketTarget.KospiIndex, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8061, null, "FROM t_index", "FROM t_index_his_day", "a.F_PART_VOL VOLUME"), + Route(CandleMarketTarget.KospiIndex, CandleChartMode.ExpectedExecution, + CandleCutAlias.Cut8061, null, "FROM t_fo_index", "FROM t_index_his_day"), + + Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.Price, + CandleCutAlias.Cut8061, null, "FROM t_kosdaq_index", "FROM t_kosdaq_index_his_day"), + Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8035, null, "FROM t_kosdaq_index", "FROM t_kosdaq_index_his_5M", "a.F_PART_VOL VOLUME"), + Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.ExpectedExecution, + CandleCutAlias.Cut8061, null, "FROM t_kosdaq_fo_index", "FROM t_kosdaq_index_his_day"), + + Route(CandleMarketTarget.Kospi200Index, CandleChartMode.Price, + CandleCutAlias.Cut8061, null, "FROM T_200_INDEX", "FROM t_200_index_his_day"), + Route(CandleMarketTarget.Kospi200Index, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8035, null, "FROM T_200_INDEX", "FROM t_200_index_his_5M", "a.F_PART_VOL VOLUME"), + Route(CandleMarketTarget.Kospi200Index, CandleChartMode.ExpectedExecution, + CandleCutAlias.Cut8061, null, "FROM T_200_FO_INDEX", "FROM t_200_index_his_day"), + + Route(CandleMarketTarget.Krx100Index, CandleChartMode.Price, + CandleCutAlias.Cut8061, null, "FROM t_krx100_index", "FROM t_krx100_index_his_day"), + Route(CandleMarketTarget.Krx100Index, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8035, null, "FROM t_krx100_index", "FROM t_krx100_index_his_5M", "a.F_PART_VOL VOLUME"), + + Route(CandleMarketTarget.FuturesIndex, CandleChartMode.Price, + CandleCutAlias.Cut8035, null, "FROM t_sunmul_online", "FROM t_sunmul_his_5M"), + Route(CandleMarketTarget.FuturesIndex, CandleChartMode.ExpectedExecution, + CandleCutAlias.Cut8061, null, "FROM t_sunmul_online", "FROM T_SUNMUL_HIS_DAY"), + + Selected(CandleMarketTarget.KospiIndustry, CandleChartMode.Price, + CandleCutAlias.Cut8035, "FROM t_index a, t_part b", "FROM t_index_his_5M"), + Selected(CandleMarketTarget.KospiIndustry, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8061, "FROM t_index a, t_part b", "FROM t_index_his_day", "a.F_PART_VOL VOLUME"), + Selected(CandleMarketTarget.KosdaqIndustry, CandleChartMode.Price, + CandleCutAlias.Cut8061, "FROM t_kosdaq_index a, t_kosdaq_part b", "FROM t_kosdaq_index_his_day"), + Selected(CandleMarketTarget.KosdaqIndustry, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8035, "FROM t_kosdaq_index a, t_kosdaq_part b", "FROM t_kosdaq_index_his_5M", "a.F_PART_VOL VOLUME"), + + Selected(CandleMarketTarget.OverseasIndex, CandleChartMode.Price, + CandleCutAlias.Cut8061, "FROM t_world_ix_eq_master", "FROM t_world_ix_eq_master"), + Selected(CandleMarketTarget.OverseasStock, CandleChartMode.Price, + CandleCutAlias.Cut8061, "FROM t_world_ix_eq_master", "FROM t_world_ix_eq_master"), + + Selected(CandleMarketTarget.KospiStock, CandleChartMode.Price, + CandleCutAlias.Cut8035, "FROM t_online1", "FROM t_online_his_5M"), + Selected(CandleMarketTarget.KospiStock, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8061, "FROM t_online1", "FROM t_candle_history", "a.F_NET_VOL VOLUME"), + Selected(CandleMarketTarget.KospiStock, CandleChartMode.ExpectedExecution, + CandleCutAlias.Cut8061, "FROM t_online1_call", "FROM t_candle_history"), + Selected(CandleMarketTarget.KospiStock, CandleChartMode.TradingHalt, + CandleCutAlias.Cut8061, "FROM t_stop_online1", "FROM t_candle_history"), + + Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.Price, + CandleCutAlias.Cut8035, "FROM t_kosdaq_online1", "FROM t_kosdaq_online_his_5M"), + Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.PriceWithVolume, + CandleCutAlias.Cut8061, "FROM t_kosdaq_online1", "FROM t_kosdaq_candle_history", "a.F_NET_VOL VOLUME"), + Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.ExpectedExecution, + CandleCutAlias.Cut8061, "FROM t_kosdaq_online1_call", "FROM t_kosdaq_candle_history"), + Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.TradingHalt, + CandleCutAlias.Cut8061, "FROM t_stop_kosdaq_online1", "FROM t_kosdaq_candle_history") + }; + + public static TheoryData UnsupportedRequests => new() + { + Request((CandleCutAlias)9999, CandleMarketTarget.KospiIndex, CandleChartMode.Price, null), + Request(CandleCutAlias.Cut8061, (CandleMarketTarget)99, CandleChartMode.Price, null), + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, (CandleChartMode)99, null), + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, null), + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, CandleChartMode.Price, "unexpected"), + Request(CandleCutAlias.Cut8061, CandleMarketTarget.FuturesIndex, CandleChartMode.PriceWithVolume, null), + Request(CandleCutAlias.Cut8061, CandleMarketTarget.Krx100Index, CandleChartMode.ExpectedExecution, null), + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndustry, CandleChartMode.ExpectedExecution, "Industry"), + Request(CandleCutAlias.Cut8035, CandleMarketTarget.OverseasIndex, CandleChartMode.Price, "World"), + Request(CandleCutAlias.Cut8061, CandleMarketTarget.OverseasStock, CandleChartMode.PriceWithVolume, "World"), + Request(CandleCutAlias.Cut8035, CandleMarketTarget.KospiStock, CandleChartMode.ExpectedExecution, "Stock"), + 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") + }; + + [Theory] + [MemberData(nameof(RouteCases))] + public async Task Every_supported_market_and_mode_uses_bound_oracle_queries_in_legacy_order( + RouteCase item) + { + var intraday = item.Alias == CandleCutAlias.Cut8035; + var executor = new RecordingExecutor(QuoteTable(), RowsTable(intraday)); + + var data = await new S8010SceneDataLoader(executor).LoadAsync( + Request(item.Alias, item.Target, item.Mode, item.Selection), + CancellationToken.None); + + Assert.Equal(item.Target, data.MarketTarget); + Assert.Equal(item.Mode, data.ChartMode); + Assert.Equal(0, executor.StringCalls); + Assert.Collection( + executor.Calls, + call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("S8010_CURRENT", call.TableName); + call.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains(item.CurrentFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + }, + call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("S8010_ROWS", call.TableName); + call.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains(item.RowsFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + if (item.RowsMustContain is not null) + { + Assert.Contains(item.RowsMustContain, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + } + + var rowLimit = Assert.Single( + call.Spec.Parameters, + parameter => parameter.Name == "rowLimit"); + Assert.Equal(AliasLimit(item.Alias), rowLimit.Value); + Assert.Equal(DbType.Int32, rowLimit.DbType); + }); + + Assert.All( + executor.Calls.SelectMany(call => call.Spec.Parameters), + parameter => Assert.NotNull(parameter.DbType)); + if (item.Selection is not null) + { + Assert.All(executor.Calls, call => + { + Assert.DoesNotContain(item.Selection, call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains(call.Spec.Parameters, parameter => + parameter.Name == "selection" && Equals(parameter.Value, item.Selection)); + }); + } + + Assert.All(executor.Calls, call => + Assert.DoesNotContain("{0}", call.Spec.Sql, StringComparison.Ordinal)); + } + + [Theory] + [MemberData(nameof(AliasLimits))] + public async Task Every_alias_binds_its_legacy_row_bound_and_maps_time_shape( + CandleCutAlias alias, + int expectedLimit) + { + var intraday = alias == CandleCutAlias.Cut8035; + var executor = new RecordingExecutor(QuoteTable(), RowsTable(intraday)); + + var data = await new S8010SceneDataLoader(executor).LoadAsync( + Request(alias, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"), + CancellationToken.None); + + var rowsSpec = executor.Calls[1].Spec; + var limit = Assert.Single(rowsSpec.Parameters, parameter => parameter.Name == "rowLimit"); + Assert.Equal(expectedLimit, limit.Value); + Assert.Equal(intraday, data.Rows[0].TradingTime.HasValue); + Assert.Contains( + intraday ? "ORDER BY TRADING_DATE, TRADING_TIME" : "ORDER BY TRADING_DATE", + rowsSpec.Sql, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Explicit_schema_maps_typed_values_and_generates_volume_candle_mutations() + { + var quote = QuoteTable("Samsung", 110m, 10m, -8.33m, "5"); + var rows = EmptyRowsTable(); + rows.Rows.Add("20260701", DBNull.Value, 100m, 120m, 90m, 110m, 25m, 105m, DBNull.Value); + rows.Rows.Add(new DateTime(2026, 7, 2), DBNull.Value, 110m, 125m, 95m, 100m, 0m, 106m, 104m); + var executor = new RecordingExecutor(quote, rows); + + var data = await new S8010SceneDataLoader(executor).LoadAsync( + Request( + CandleCutAlias.Cut8061, + CandleMarketTarget.KospiStock, + CandleChartMode.PriceWithVolume, + "Samsung"), + CancellationToken.None); + + Assert.Equal("Samsung", data.DisplayName); + Assert.Equal(ScenePriceDirection.Down, data.Quote.Direction); + Assert.Equal(new DateOnly(2026, 7, 1), data.Rows[0].TradingDate); + Assert.Equal(25d, data.Rows[0].Volume); + Assert.Equal(104d, data.Rows[1].MovingAverage20); + + var mutations = new S8010SceneMutationBuilder().Build(data); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetVisible { ObjectName: "volrumeG", IsVisible: true }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetCropKey { ObjectName: "volume1" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetFaceColor + { + ObjectName: "candlebody2", + Red: 15, + Green: 99, + Blue: 189 + }); + } + + [Theory] + [MemberData(nameof(UnsupportedRequests))] + public async Task Unsupported_or_ambiguous_requests_fail_before_database_access( + S8010SceneDataRequest request) + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S8010SceneDataLoader(executor).LoadAsync(request, CancellationToken.None)); + + Assert.Empty(executor.Calls); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task Quote_schema_is_exact_and_fails_before_rows_query() + { + var quote = Table( + ["CURRENT_PRICE", "DISPLAY_NAME", "CHANGE_PRICE", "RATE", "DIRECTION_CODE"], + [100, "Stock", 1, 1, "+"]); + var executor = new RecordingExecutor(quote, RowsTable(false)); + + await Assert.ThrowsAsync(() => + new S8010SceneDataLoader(executor).LoadAsync( + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"), + CancellationToken.None)); + + Assert.Single(executor.Calls); + } + + [Fact] + public async Task Row_schema_count_values_and_order_fail_closed_without_leaking_selection() + { + const string selection = "SENSITIVE O'Brien"; + var malformedRows = EmptyRowsTable(); + malformedRows.Rows.Add("20260702", DBNull.Value, 100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value); + malformedRows.Rows.Add("20260701", DBNull.Value, 100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value); + var executor = new RecordingExecutor(QuoteTable(), malformedRows); + + var exception = await Assert.ThrowsAsync(() => + new S8010SceneDataLoader(executor).LoadAsync( + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, selection), + CancellationToken.None)); + + Assert.DoesNotContain(selection, exception.Message, StringComparison.Ordinal); + Assert.Equal(2, executor.Calls.Count); + } + + [Fact] + public async Task More_rows_than_the_alias_bound_and_nonfinite_values_fail_closed() + { + var tooMany = EmptyRowsTable(); + for (var day = 1; day <= 6; day++) + { + tooMany.Rows.Add( + $"202607{day:00}", DBNull.Value, + 100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value); + } + + await Assert.ThrowsAsync(() => + new S8010SceneDataLoader(new RecordingExecutor(QuoteTable(), tooMany)).LoadAsync( + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"), + CancellationToken.None)); + + var nonfinite = RowsTable(false); + nonfinite.Rows[0]["HIGH_PRICE"] = double.PositiveInfinity; + await Assert.ThrowsAsync(() => + new S8010SceneDataLoader(new RecordingExecutor(QuoteTable(), nonfinite)).LoadAsync( + Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"), + CancellationToken.None)); + } + + private static RouteCase Route( + CandleMarketTarget target, + CandleChartMode mode, + CandleCutAlias alias, + string? selection, + string currentFragment, + string rowsFragment, + string? rowsMustContain = null) => + new(target, mode, alias, selection, currentFragment, rowsFragment, rowsMustContain); + + private static RouteCase Selected( + CandleMarketTarget target, + CandleChartMode mode, + CandleCutAlias alias, + string currentFragment, + string rowsFragment, + string? rowsMustContain = null) => + Route( + target, + mode, + alias, + "Sensitive O'Brien", + currentFragment, + rowsFragment, + rowsMustContain); + + private static S8010SceneDataRequest Request( + CandleCutAlias alias, + CandleMarketTarget target, + CandleChartMode mode, + string? selection) => + new(alias, target, mode, selection, true, true); + + private static int AliasLimit(CandleCutAlias alias) => alias switch + { + CandleCutAlias.Cut8035 => 78, + CandleCutAlias.Cut8061 => 5, + CandleCutAlias.Cut8040 => 20, + CandleCutAlias.Cut8046 => 60, + CandleCutAlias.Cut8051 => 120, + CandleCutAlias.Cut8056 => 240, + _ => throw new InvalidOperationException() + }; + + private static DataTable QuoteTable( + object? displayName = null, + object? price = null, + object? change = null, + object? rate = null, + object? direction = null) => + Table( + QuoteColumns, + [ + displayName ?? "Instrument", + price ?? 100m, + change ?? 1m, + rate ?? 1m, + direction ?? "+" + ]); + + private static DataTable RowsTable(bool intraday) + { + var table = EmptyRowsTable(); + table.Rows.Add( + "20260710", + intraday ? "090000" : DBNull.Value, + 100m, + 110m, + 90m, + 105m, + 10m, + 101m, + 99m); + return table; + } + + private static DataTable EmptyRowsTable() => Table(RowColumns); + + private static DataTable Table(string[] columns, object?[]? row = null) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + if (row is not null) + { + table.Rows.Add(row); + } + + return table; + } + + public sealed record RouteCase( + CandleMarketTarget Target, + CandleChartMode Mode, + CandleCutAlias Alias, + string? Selection, + string CurrentFragment, + string RowsFragment, + string? RowsMustContain); + + 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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No result was configured for the query."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartLegacySceneRequestResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartLegacySceneRequestResolverTests.cs new file mode 100644 index 0000000..51b7f52 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartLegacySceneRequestResolverTests.cs @@ -0,0 +1,238 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ChartLegacySceneRequestResolverTests +{ + private readonly ChartLegacySceneRequestResolver _resolver = new(); + + [Theory] + [InlineData("해외지수", "다우", "미국업종", SectorIndexMarket.Dow)] + [InlineData("해외지수", "나스닥", "미국업종", SectorIndexMarket.Nasdaq)] + [InlineData("해외지수", "S&P", "미국업종", SectorIndexMarket.StandardAndPoor)] + [InlineData("업종_코스피", "코스피", "섹터지수", SectorIndexMarket.Kospi)] + [InlineData("업종_코스닥", "코스닥", "섹터지수", SectorIndexMarket.Kosdaq)] + public void S5078_maps_all_original_subject_branches( + string group, + string subject, + string graphic, + SectorIndexMarket expected) + { + var request = _resolver.CreateS5078Request( + Entry("5078", group, subject, graphic)); + + Assert.Equal(expected, request.Market); + } + + [Theory] + [InlineData("FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", SectorIndexMarket.Dow)] + [InlineData("FOREIGN_INDEX", "NASDAQ", "US_SECTOR_INDEX", SectorIndexMarket.Nasdaq)] + [InlineData("FOREIGN_INDEX", "S&P500", "US_SECTOR_INDEX", SectorIndexMarket.StandardAndPoor)] + [InlineData("INDUSTRY_KOSPI", "KOSPI", "SECTOR_INDEX", SectorIndexMarket.Kospi)] + [InlineData("INDUSTRY_KOSDAQ", "KOSDAQ", "SECTOR_INDEX", SectorIndexMarket.Kosdaq)] + public void S5078_accepts_only_the_explicit_canonical_aliases( + string group, + string subject, + string graphic, + SectorIndexMarket expected) + { + Assert.Equal( + expected, + _resolver.CreateS5078Request( + Entry("5078", group, subject, graphic)).Market); + } + + [Fact] + public void S5079_and_S5080_preserve_the_original_jongmok_stock_name() + { + var growth = _resolver.CreateS5079Request(Entry( + "5079", + "코스피", + "삼성전자", + "성장성 지표", + dataCode: "005930")); + var sales = _resolver.CreateS5080Request(Entry( + "5080", + "코스닥", + "셀트리온헬스케어", + "매출액")); + + Assert.Equal("삼성전자", growth.StockName); + Assert.Equal("셀트리온헬스케어", sales.StockName); + } + + [Theory] + [InlineData("전체", "지수", "개인 매매동향", "라인그래프", InvestorFlowParticipant.Individual)] + [InlineData("전체", "지수", "기관 매매동향", "라인그래프", InvestorFlowParticipant.Institution)] + [InlineData("전체", "지수", "외국인 매매동향", "라인그래프", InvestorFlowParticipant.Foreign)] + [InlineData("ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", InvestorFlowParticipant.Individual)] + [InlineData("ALL", "INDEX", "INSTITUTION_TRADING_TREND", "LINE_GRAPH", InvestorFlowParticipant.Institution)] + [InlineData("ALL", "INDEX", "FOREIGN_TRADING_TREND", "LINE_GRAPH", InvestorFlowParticipant.Foreign)] + public void S5083_maps_the_three_forCutInfo_branches( + string group, + string subject, + string graphic, + string subtype, + InvestorFlowParticipant expected) + { + var request = _resolver.CreateS5083Request( + Entry("5083", group, subject, graphic, subtype)); + + Assert.Equal(expected, request.Participant); + } + + [Theory] + [InlineData("코스피", "지수", "코스피 매매동향", "라인그래프", InvestorFlowIndex.Kospi)] + [InlineData("코스닥", "지수", "코스닥 매매동향", "라인그래프", InvestorFlowIndex.Kosdaq)] + [InlineData("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", InvestorFlowIndex.Kospi)] + [InlineData("KOSDAQ", "INDEX", "KOSDAQ_TRADING_TREND", "LINE_GRAPH", InvestorFlowIndex.Kosdaq)] + public void S5084_maps_the_two_forCutInfo_branches_and_checks_code_consistency( + string group, + string subject, + string graphic, + string subtype, + InvestorFlowIndex expected) + { + var request = _resolver.CreateS5084Request( + Entry("5084", group, subject, graphic, subtype)); + + Assert.Equal(expected, request.Index); + } + + [Fact] + public void Resolve_returns_a_closed_wrapper_for_every_supported_cut() + { + Assert.Equal("s5078", _resolver.Resolve( + Entry("5078", "해외지수", "다우", "미국업종")).BuilderKey); + Assert.Equal("s5079", _resolver.Resolve( + Entry("5079", "코스피", "A", "성장성 지표")).BuilderKey); + Assert.Equal("s5080", _resolver.Resolve( + Entry("5080", "코스닥", "B", "매출액")).BuilderKey); + Assert.Equal("s5083", _resolver.Resolve( + Entry("5083", "전체", "지수", "개인 매매동향", "라인그래프")).BuilderKey); + Assert.Equal("s5084", _resolver.Resolve( + Entry("5084", "코스피", "지수", "코스피 매매동향", "라인그래프")).BuilderKey); + + Assert.IsType(_resolver.Resolve( + Entry("5078", "해외지수", "다우", "미국업종"))); + Assert.IsType(_resolver.Resolve( + Entry("5079", "코스피", "A", "성장성 지표"))); + Assert.IsType(_resolver.Resolve( + Entry("5080", "코스닥", "B", "매출액"))); + Assert.IsType(_resolver.Resolve( + Entry("5083", "전체", "지수", "기관 매매동향", "라인그래프"))); + Assert.IsType(_resolver.Resolve( + Entry("5084", "코스닥", "지수", "코스닥 매매동향", "라인그래프"))); + } + + [Theory] + [InlineData("업종_코스피", "다우", "섹터지수", "", "")] + [InlineData("해외지수", "코스피", "미국업종", "", "")] + [InlineData("해외지수", "DOW JONES", "미국업종", "", "")] + [InlineData("해외지수", "다우", "미국업종", "EXTRA", "")] + [InlineData("해외지수", "다우", "미국업종", "", "005930")] + public void S5078_rejects_cross_branch_and_open_ended_selectors( + string group, + string subject, + string graphic, + string subtype, + string dataCode) + { + Assert.Throws(() => + _resolver.CreateS5078Request( + Entry("5078", group, subject, graphic, subtype, dataCode))); + } + + [Theory] + [InlineData("전체", "A", "성장성 지표", "", "")] + [InlineData("코스피", "A", "성장성", "", "")] + [InlineData("코스피", "A", "성장성 지표", "LINE_GRAPH", "")] + [InlineData("코스피", " A", "성장성 지표", "", "")] + [InlineData("코스피", "A", "성장성 지표", "", "0059-30")] + public void Manual_chart_requests_reject_ambiguous_market_graphic_and_code_fields( + string group, + string subject, + string graphic, + string subtype, + string dataCode) + { + Assert.Throws(() => + _resolver.CreateS5079Request( + Entry("5079", group, subject, graphic, subtype, dataCode))); + } + + [Theory] + [InlineData("전체", "지수", "개인과기관 매매동향", "라인그래프")] + [InlineData("코스피", "지수", "개인 매매동향", "라인그래프")] + [InlineData("전체", "시장", "개인 매매동향", "라인그래프")] + [InlineData("전체", "지수", "개인 매매동향", "라인 그래프")] + public void S5083_rejects_legacy_contains_ambiguities( + string group, + string subject, + string graphic, + string subtype) + { + Assert.Throws(() => + _resolver.CreateS5083Request( + Entry("5083", group, subject, graphic, subtype))); + } + + [Theory] + [InlineData("코스피", "코스닥 매매동향")] + [InlineData("코스닥", "코스피 매매동향")] + [InlineData("전체", "코스피 매매동향")] + [InlineData("코스피", "코스피와코스닥 매매동향")] + public void S5084_rejects_mismatched_code_and_forCutInfo( + string group, + string graphic) + { + Assert.Throws(() => + _resolver.CreateS5084Request( + Entry("5084", group, "지수", graphic, "라인그래프"))); + } + + [Fact] + public void Cut_specific_APIs_reject_wrong_cut_missing_selection_and_unsafe_fields() + { + Assert.Throws(() => + _resolver.CreateS5078Request( + Entry("5079", "해외지수", "다우", "미국업종"))); + Assert.Throws(() => + _resolver.CreateS5079Request(new LegacyPlaylistEntry("entry", "5079"))); + Assert.Throws(() => + _resolver.Resolve(Entry("9999", "", "", ""))); + Assert.Throws(() => + _resolver.CreateS5080Request( + Entry("5080", "코스피", "A\r\n", "매출액"))); + Assert.Throws(() => + _resolver.CreateS5080Request( + Entry("5080", "코스피", new string('A', 257), "매출액"))); + } + + [Fact] + public void Resolver_errors_do_not_echo_selected_values() + { + const string sensitive = "SENSITIVE-STOCK-SELECTION"; + var exception = Assert.Throws(() => + _resolver.CreateS5079Request( + Entry("5079", "UNKNOWN", sensitive, "성장성 지표"))); + + Assert.DoesNotContain(sensitive, exception.Message, StringComparison.Ordinal); + } + + private static LegacyPlaylistEntry Entry( + string cutCode, + string group, + string subject, + string graphic, + string subtype = "", + string dataCode = "") => new( + "entry", + cutCode, + Selection: new LegacySceneSelection( + group, + subject, + graphic, + subtype, + dataCode)); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneBuilders5078To5084Tests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneBuilders5078To5084Tests.cs new file mode 100644 index 0000000..b4c71b5 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneBuilders5078To5084Tests.cs @@ -0,0 +1,490 @@ +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ChartSceneBuilders5078To5084Tests +{ + [Fact] + public void S5078_matches_complete_legacy_visibility_value_and_scale_order() + { + var data = new S5078SceneData( + SectorIndexMarket.Kospi, + SectorRows( + ("A", 100, SectorBarVariant.Secondary), + ("B", -50, SectorBarVariant.Primary), + ("C", 0, SectorBarVariant.Secondary), + ("D", 25, SectorBarVariant.Secondary), + ("E", -25, SectorBarVariant.Primary), + ("F", 10, SectorBarVariant.Secondary), + ("G", -10, SectorBarVariant.Primary), + ("H", 5, SectorBarVariant.Secondary), + ("I", -5, SectorBarVariant.Primary), + ("J", 50, SectorBarVariant.Secondary))); + + var actual = new S5078SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=코스피 섹터지수", + "S:bar1_1=0", "S:value1_1=0", "S:bar1_2=0", "S:value1_2=0", + "S:bar2_1=0", "S:value2_1=0", "S:bar2_2=0", "S:value2_2=0", + "S:bar3_1=0", "S:value3_1=0", "S:bar3_2=0", "S:value3_2=0", + "S:bar4_1=0", "S:value4_1=0", "S:bar4_2=0", "S:value4_2=0", + "S:bar5_1=0", "S:value5_1=0", "S:bar5_2=0", "S:value5_2=0", + "S:bar6_1=0", "S:value6_1=0", "S:bar6_2=0", "S:value6_2=0", + "S:bar7_1=0", "S:value7_1=0", "S:bar7_2=0", "S:value7_2=0", + "S:bar8_1=0", "S:value8_1=0", "S:bar8_2=0", "S:value8_2=0", + "S:bar9_1=0", "S:value9_1=0", "S:bar9_2=0", "S:value9_2=0", + "S:bar10_1=0", "S:value10_1=0", "S:bar10_2=0", "S:value10_2=0", + "V:category1=A", "S:bar1_2=1", "S:value1_2=1", + "Z:bar1_2:1:0:0:1", "V:value1_2=100.00", + "V:category2=B", "S:bar2_1=1", "S:value2_1=1", + "Z:bar2_1:-0.5:0:0:1", "V:value2_1=-50.00", + "V:category3=C", "S:bar3_2=1", "S:value3_2=1", + "Z:bar3_2:0:0:0:1", "V:value3_2=0.00", + "V:category4=D", "S:bar4_2=1", "S:value4_2=1", + "Z:bar4_2:0.25:0:0:1", "V:value4_2=25.00", + "V:category5=E", "S:bar5_1=1", "S:value5_1=1", + "Z:bar5_1:-0.25:0:0:1", "V:value5_1=-25.00", + "V:category6=F", "S:bar6_2=1", "S:value6_2=1", + "Z:bar6_2:0.1:0:0:1", "V:value6_2=10.00", + "V:category7=G", "S:bar7_1=1", "S:value7_1=1", + "Z:bar7_1:-0.1:0:0:1", "V:value7_1=-10.00", + "V:category8=H", "S:bar8_2=1", "S:value8_2=1", + "Z:bar8_2:0.05:0:0:1", "V:value8_2=5.00", + "V:category9=I", "S:bar9_1=1", "S:value9_1=1", + "Z:bar9_1:-0.05:0:0:1", "V:value9_1=-5.00", + "V:category10=J", "S:bar10_2=1", "S:value10_2=1", + "Z:bar10_2:0.5:0:0:1", "V:value10_2=50.00" + ], actual); + } + + [Fact] + public void S5078_zero_and_us_primary_values_are_finite_and_use_original_sign_factor() + { + var zeroRows = Enumerable.Range(1, 10) + .Select(index => new SectorIndexBarData( + "Z" + index, + 0, + SectorBarVariant.Primary)) + .ToArray(); + var zeros = new S5078SceneMutationBuilder().Build(new S5078SceneData( + SectorIndexMarket.Kosdaq, + zeroRows)); + + AssertFinite(zeros); + Assert.All(zeros.OfType(), scale => Assert.Equal(0, scale.X)); + + var usRows = zeroRows.ToArray(); + usRows[0] = new SectorIndexBarData("MAX", 100, SectorBarVariant.Secondary); + usRows[1] = new SectorIndexBarData("NEG", -50, SectorBarVariant.Primary); + var us = new S5078SceneMutationBuilder().Build(new S5078SceneData( + SectorIndexMarket.Dow, + usRows)); + + Assert.Contains("Z:bar2_1:0.5:0:0:1", us.Select(Describe)); + } + + [Fact] + public void S5078_rejects_empty_nonfinite_and_unmapped_data() + { + var builder = new S5078SceneMutationBuilder(); + Assert.Throws(() => builder.Build(new S5078SceneData( + SectorIndexMarket.Kospi, + []))); + Assert.Throws(() => builder.Build(new S5078SceneData( + SectorIndexMarket.Kospi, + SectorRows(("bad", double.PositiveInfinity, SectorBarVariant.Primary))))); + Assert.Throws(() => builder.Build(new S5078SceneData( + (SectorIndexMarket)99, + SectorRows()))); + Assert.Throws(() => builder.Build(new S5078SceneData( + SectorIndexMarket.Kospi, + SectorRows(("bad", 1, (SectorBarVariant)99))))); + } + + [Fact] + public void S5079_matches_complete_legacy_axis_visibility_path_and_date_order() + { + var data = new S5079SceneData( + "A사", + [ + new(GrowthMetricPath.First, [0d, 25d, 50d, 100d]), + new(GrowthMetricPath.Second, [-100d, -50d, 0d, 50d]), + new(GrowthMetricPath.Third, [null, 0d, 25d, 75d]), + new(GrowthMetricPath.Fourth, [100d, 100d, 100d, 100d]) + ], + ["2025Q1", "2025Q2", "2025Q3", "2025Q4"]); + + var actual = new S5079SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=A사 성장성 지표", + "V:value1=-60", "V:value2=-20", "V:value3=20", "V:value4=60", "V:value5=100", + "S:path1=0", "S:path1=1", "S:path1=1", "S:path1=1", "S:path1=1", + "L:path1=0,0,0|368,36,0|740,72,0|1110,144,0", + "S:path2=0", "S:path2=1", "S:path2=1", "S:path2=1", "S:path2=1", + "L:path2=0,-144,0|368,-72,0|740,0,0|1110,72,0", + "L:path3=0,0,0|368,0,0|740,36,0|1110,108,0", "S:path3=0", + "S:path4=0", "S:path4=1", "S:path4=1", "S:path4=1", "S:path4=1", + "L:path4=0,144,0|368,144,0|740,144,0|1110,144,0", + "V:date1=2025Q1", "V:date2=2025Q2", "V:date3=2025Q3", "V:date4=2025Q4" + ], actual); + } + + [Fact] + public void S5079_equal_and_empty_tokens_normalize_to_finite_center_points() + { + var equal = new S5079SceneMutationBuilder().Build(new S5079SceneData( + "A사", + Enum.GetValues() + .Select(path => new GrowthMetricSeriesData(path, [5d, 5d, 5d, 5d])) + .ToArray(), + ["", "", "", ""])); + AssertFinite(equal); + Assert.All( + equal.OfType().SelectMany(path => path.Points), + point => Assert.Equal(0, point.Y)); + + var emptyTokens = new S5079SceneMutationBuilder().Build(new S5079SceneData( + "A사", + Enum.GetValues() + .Select(path => new GrowthMetricSeriesData(path, [null, null, null, null])) + .ToArray(), + ["", "", "", ""])); + AssertFinite(emptyTokens); + Assert.All( + emptyTokens.OfType().SelectMany(path => path.Points), + point => Assert.Equal(0, point.Y)); + } + + [Fact] + public void S5079_rejects_empty_duplicate_nonfinite_and_unmapped_series() + { + var builder = new S5079SceneMutationBuilder(); + Assert.Throws(() => builder.Build(new S5079SceneData( + "A사", [], ["1", "2", "3", "4"]))); + Assert.Throws(() => builder.Build(new S5079SceneData( + "A사", + [ + new(GrowthMetricPath.First, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.First, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.Third, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.Fourth, [1d, 2d, 3d, 4d]) + ], + ["1", "2", "3", "4"]))); + Assert.Throws(() => builder.Build(new S5079SceneData( + "A사", + [ + new(GrowthMetricPath.First, [double.NaN, 2d, 3d, 4d]), + new(GrowthMetricPath.Second, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.Third, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.Fourth, [1d, 2d, 3d, 4d]) + ], + ["1", "2", "3", "4"]))); + Assert.Throws(() => builder.Build(new S5079SceneData( + "A사", + [ + new((GrowthMetricPath)99, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.Second, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.Third, [1d, 2d, 3d, 4d]), + new(GrowthMetricPath.Fourth, [1d, 2d, 3d, 4d]) + ], + ["1", "2", "3", "4"]))); + } + + [Fact] + public void S5080_matches_complete_legacy_quarter_group_center_and_scale_order() + { + var data = new S5080SceneData( + "A사", + [ + new("1Q", 100), new("2Q", -50), new("3Q", 25), + new("4Q", 0), new("5Q", 50), new("6Q", -25) + ]); + + var actual = new S5080SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=A사 매출액", + "V:quarter1=1Q", "V:quarter2=2Q", "V:quarter3=3Q", + "V:quarter4=4Q", "V:quarter5=5Q", "V:quarter6=6Q", + "S:barG1_1=0", "S:barG1_2=0", "S:barG1_1=1", "V:value1_1=100", + "S:barG2_1=0", "S:barG2_2=0", "S:barG2_2=1", "V:value2_2=-50", + "S:barG3_1=0", "S:barG3_2=0", "S:barG3_1=1", "V:value3_1=25", + "S:barG4_1=0", "S:barG4_2=0", "S:barG4_1=1", "V:value4_1=0", + "S:barG5_1=0", "S:barG5_2=0", "S:barG5_1=1", "V:value5_1=50", + "S:barG6_1=0", "S:barG6_2=0", "S:barG6_2=1", "V:value6_2=-25", + "K:centerbar:0:0:-131.66667:0:2", + "Z:bar1_1:0:1.2:0:2", "Z:bar2_2:0:0.6:0:2", + "Z:bar3_1:0:0.3:0:2", "Z:bar4_1:0:0:0:2", + "Z:bar5_1:0:0.6:0:2", "Z:bar6_2:0:0.3:0:2" + ], actual); + } + + [Fact] + public void S5080_zero_positive_negative_and_equal_values_stay_finite() + { + var cases = new[] + { + (Value: 0, Center: -250f, Scale: 0f), + (Value: 10, Center: -240f, Scale: 1.8f), + (Value: -10, Center: 115f, Scale: 1.8f) + }; + foreach (var item in cases) + { + var mutations = new S5080SceneMutationBuilder().Build(new S5080SceneData( + "A사", + Enumerable.Range(1, 6) + .Select(index => new QuarterlySalesData(index + "Q", item.Value)) + .ToArray())); + + AssertFinite(mutations); + Assert.Equal(item.Center, Assert.Single(mutations.OfType()).Y); + Assert.All(mutations.OfType(), scale => Assert.Equal(item.Scale, scale.Y)); + } + + Assert.Throws(() => + new S5080SceneMutationBuilder().Build(new S5080SceneData("A사", []))); + } + + [Fact] + public void S5083_matches_complete_legacy_summary_baseline_and_path_order() + { + var data = new S5083SceneData( + InvestorFlowParticipant.Individual, + [ + new(InvestorFlowMarket.Kospi, 1000), + new(InvestorFlowMarket.Kosdaq, -500), + new(InvestorFlowMarket.Kospi200, 0) + ], + [ + new(InvestorFlowMarket.Kospi, -100, new TimeOnly(9, 0)), + new(InvestorFlowMarket.Kosdaq, 0, new TimeOnly(9, 0)), + new(InvestorFlowMarket.Kospi200, 100, new TimeOnly(9, 0)), + new(InvestorFlowMarket.Kospi, 100, new TimeOnly(15, 0)), + new(InvestorFlowMarket.Kosdaq, -50, new TimeOnly(15, 10)), + new(InvestorFlowMarket.Kospi200, 50, new TimeOnly(15, 30)) + ]); + + var actual = new S5083SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=개인 매매동향", + "V:price1=1,000", "V:price2=-500", "V:price3=0", + "P:baseline:0:43:0:2", + "L:path1=0,-5,0|0,-138,0|1212.26,128,0", + "L:path2=0,-5,0|606.13,-5,0|1212.26,-71.5,0", + "L:path3=0,-5,0|606.13,128,0|1212.26,61.5,0" + ], actual); + } + + [Fact] + public void S5083_zero_single_points_and_preopen_time_use_explicit_finite_normalization() + { + var data = new S5083SceneData( + InvestorFlowParticipant.Foreign, + [ + new(InvestorFlowMarket.Kospi, 0), + new(InvestorFlowMarket.Kosdaq, 0), + new(InvestorFlowMarket.Kospi200, 0) + ], + [ + new(InvestorFlowMarket.Kospi, 0, new TimeOnly(8, 0)), + new(InvestorFlowMarket.Kosdaq, 0, new TimeOnly(8, 0)), + new(InvestorFlowMarket.Kospi200, 0, new TimeOnly(8, 0)) + ]); + + var mutations = new S5083SceneMutationBuilder().Build(data); + + AssertFinite(mutations); + Assert.Equal(43, Assert.Single(mutations.OfType()).Y); + Assert.All( + mutations.OfType().SelectMany(path => path.Points), + point => + { + Assert.Equal(0, point.X); + Assert.Equal(-5, point.Y); + }); + } + + [Fact] + public void S5083_rejects_empty_missing_duplicate_and_unmapped_targets() + { + var summary = new InvestorFlowSummaryData[] + { + new(InvestorFlowMarket.Kospi, 0), + new(InvestorFlowMarket.Kosdaq, 0), + new(InvestorFlowMarket.Kospi200, 0) + }; + var points = new InvestorFlowTrendPoint[] + { + new(InvestorFlowMarket.Kospi, 0, new TimeOnly(9, 0)), + new(InvestorFlowMarket.Kosdaq, 0, new TimeOnly(9, 0)), + new(InvestorFlowMarket.Kospi200, 0, new TimeOnly(9, 0)) + }; + var builder = new S5083SceneMutationBuilder(); + + Assert.Throws(() => builder.Build(new S5083SceneData( + InvestorFlowParticipant.Individual, summary, []))); + Assert.Throws(() => builder.Build(new S5083SceneData( + InvestorFlowParticipant.Individual, + [summary[0], summary[0], summary[2]], + points))); + Assert.Throws(() => builder.Build(new S5083SceneData( + InvestorFlowParticipant.Individual, + summary, + [points[0], points[0], points[2]]))); + Assert.Throws(() => builder.Build(new S5083SceneData( + (InvestorFlowParticipant)99, + summary, + points))); + Assert.Throws(() => builder.Build(new S5083SceneData( + InvestorFlowParticipant.Individual, + summary, + [points[0], points[1], new((InvestorFlowMarket)99, 0, new TimeOnly(9, 0))]))); + } + + [Fact] + public void S5084_matches_complete_legacy_totals_baseline_and_path_order() + { + var data = new S5084SceneData( + InvestorFlowIndex.Kospi, + new InvestorFlowTotalsData(1000, -500, 250), + [ + new(-100, 0, 100, new TimeOnly(9, 0)), + new(100, -50, 50, new TimeOnly(15, 30)) + ]); + + var actual = new S5084SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=코스피 매매동향", + "V:price1=1,000", "V:price2=-500", "V:price3=250", + "P:baseline:0:43:0:2", + "L:path1=0,-5,0|606.13,-138,0|1212.26,128,0", + "L:path2=0,-5,0|606.13,-5,0|1212.26,-71.5,0", + "L:path3=0,-5,0|606.13,128,0|1212.26,61.5,0" + ], actual); + } + + [Fact] + public void S5084_zero_single_row_is_finite_and_empty_or_unmapped_data_fails_closed() + { + var builder = new S5084SceneMutationBuilder(); + var zero = builder.Build(new S5084SceneData( + InvestorFlowIndex.Kosdaq, + new InvestorFlowTotalsData(0, 0, 0), + [new(0, 0, 0, new TimeOnly(15, 30))])); + + AssertFinite(zero); + Assert.Equal(43, Assert.Single(zero.OfType()).Y); + Assert.All( + zero.OfType(), + path => + { + Assert.Equal(-5, path.Points[0].Y); + Assert.Equal(1212.26f, path.Points[1].X); + Assert.Equal(-5, path.Points[1].Y); + }); + + Assert.Throws(() => builder.Build(new S5084SceneData( + InvestorFlowIndex.Kospi, + new InvestorFlowTotalsData(0, 0, 0), + []))); + Assert.Throws(() => builder.Build(new S5084SceneData( + (InvestorFlowIndex)99, + new InvestorFlowTotalsData(0, 0, 0), + [new(0, 0, 0, new TimeOnly(15, 30))]))); + } + + [Fact] + public void Builders_expose_the_closed_scene_keys() + { + Assert.Equal("s5078", new S5078SceneMutationBuilder().BuilderKey); + Assert.Equal("s5079", new S5079SceneMutationBuilder().BuilderKey); + Assert.Equal("s5080", new S5080SceneMutationBuilder().BuilderKey); + Assert.Equal("s5083", new S5083SceneMutationBuilder().BuilderKey); + Assert.Equal("s5084", new S5084SceneMutationBuilder().BuilderKey); + } + + private static SectorIndexBarData[] SectorRows( + params (string Category, double Value, SectorBarVariant Variant)[] overrides) + { + var rows = Enumerable.Range(1, 10) + .Select(index => new SectorIndexBarData( + "R" + index, + 0, + SectorBarVariant.Secondary)) + .ToArray(); + for (var index = 0; index < overrides.Length; index++) + { + rows[index] = new SectorIndexBarData( + overrides[index].Category, + overrides[index].Value, + overrides[index].Variant); + } + + return rows; + } + + private static void AssertFinite(IEnumerable mutations) + { + foreach (var mutation in mutations) + { + switch (mutation) + { + case PlayoutSetPosition position: + Assert.True(float.IsFinite(position.X)); + Assert.True(float.IsFinite(position.Y)); + Assert.True(float.IsFinite(position.Z)); + break; + case PlayoutSetPositionKey positionKey: + Assert.True(float.IsFinite(positionKey.X)); + Assert.True(float.IsFinite(positionKey.Y)); + Assert.True(float.IsFinite(positionKey.Z)); + break; + case PlayoutSetScale scale: + Assert.True(float.IsFinite(scale.X)); + Assert.True(float.IsFinite(scale.Y)); + Assert.True(float.IsFinite(scale.Z)); + break; + case PlayoutSetPathPoints path: + Assert.All(path.Points, point => + { + Assert.True(float.IsFinite(point.X)); + Assert.True(float.IsFinite(point.Y)); + Assert.True(float.IsFinite(point.Z)); + }); + break; + case PlayoutSetValue value: + Assert.DoesNotContain("NaN", value.Value, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Infinity", value.Value, StringComparison.OrdinalIgnoreCase); + break; + } + } + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + PlayoutSetPosition position => FormattableString.Invariant( + $"P:{position.ObjectName}:{position.X}:{position.Y}:{position.Z}:{(int)position.Components}"), + PlayoutSetPositionKey key => FormattableString.Invariant( + $"K:{key.ObjectName}:{key.KeyIndex}:{key.X}:{key.Y}:{key.Z}:{(int)key.Components}"), + PlayoutSetScale scale => FormattableString.Invariant( + $"Z:{scale.ObjectName}:{scale.X}:{scale.Y}:{scale.Z}:{(int)scale.Components}"), + PlayoutSetPathPoints path => "L:" + path.ObjectName + "=" + string.Join( + "|", + path.Points.Select(point => FormattableString.Invariant( + $"{point.X},{point.Y},{point.Z}"))), + _ => mutation.GetType().Name + }; +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneDataLoadersTests.cs new file mode 100644 index 0000000..4b6b077 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ChartSceneDataLoadersTests.cs @@ -0,0 +1,372 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ChartSceneDataLoadersTests +{ + [Theory] + [InlineData(SectorIndexMarket.Dow, "DJI@DJINET")] + [InlineData(SectorIndexMarket.Nasdaq, "NAS@CXBT")] + [InlineData(SectorIndexMarket.StandardAndPoor, "SPI@S5ENRS")] + [InlineData(SectorIndexMarket.Kospi, "FROM t_index")] + [InlineData(SectorIndexMarket.Kosdaq, "FROM t_kosdaq_index")] + public async Task S5078_CoversEveryClosedLegacyQueryBranch( + SectorIndexMarket market, + string queryMarker) + { + var executor = new RecordingExecutor( + new Dictionary + { + ["S5078_SECTOR_INDEX"] = SectorTable() + }); + + var data = await new S5078SceneDataLoader(executor).LoadAsync( + new S5078SceneDataRequest(market), + CancellationToken.None); + + Assert.Equal(market, data.Market); + Assert.Equal(10, data.Rows.Count); + Assert.Equal(SectorBarVariant.Primary, data.Rows[0].Variant); + Assert.Equal(SectorBarVariant.Secondary, data.Rows[1].Variant); + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Empty(call.Spec.Parameters); + Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(new S5078SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task S5078_RequiresExactlyTenRowsAndTheExactSchema() + { + var tooMany = SectorTable(11); + await Assert.ThrowsAsync(() => + new S5078SceneDataLoader(new RecordingExecutor( + new Dictionary { ["S5078_SECTOR_INDEX"] = tooMany })) + .LoadAsync(new S5078SceneDataRequest(SectorIndexMarket.Kospi))); + + var wrongSchema = SectorTable(); + wrongSchema.Columns[4].ColumnName = "UNEXPECTED"; + await Assert.ThrowsAsync(() => + new S5078SceneDataLoader(new RecordingExecutor( + new Dictionary { ["S5078_SECTOR_INDEX"] = wrongSchema })) + .LoadAsync(new S5078SceneDataRequest(SectorIndexMarket.Kospi))); + } + + [Fact] + public async Task S5079_UsesOneOracleParameterAndMapsAllFourSeriesAndDates() + { + var table = Table( + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"], + ["삼성전자", "1_2_3_4", "-1_-2_-3_-4", "_0_1_2", "5.5_6.5_7.5_8.5", "2025Q1_2025Q2_2025Q3_2025Q4"]); + var executor = new RecordingExecutor( + new Dictionary { ["INPUT_GROW"] = table }); + + var data = await new S5079SceneDataLoader(executor).LoadAsync( + new S5079SceneDataRequest("삼성전자")); + + Assert.Equal("삼성전자", data.StockName); + Assert.Equal(new double?[] { 1, 2, 3, 4 }, data.Series[0].Values); + Assert.Null(data.Series[2].Values[0]); + Assert.Equal(new[] { "2025Q1", "2025Q2", "2025Q3", "2025Q4" }, data.Dates); + var call = Assert.Single(executor.Calls); + Assert.Equal("INPUT_GROW", call.TableName); + Assert.Contains("WHERE STOCK_NAME = :stockName", call.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("stockName", parameter.Name); + Assert.Equal("삼성전자", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + Assert.NotEmpty(new S5079SceneMutationBuilder().Build(data)); + } + + [Theory] + [InlineData("1_2_3", "2025Q1_2025Q2_2025Q3_2025Q4")] + [InlineData("1_2_bad_4", "2025Q1_2025Q2_2025Q3_2025Q4")] + [InlineData("1_2_3_4", "2025Q1_2025Q2_2025Q3")] + public async Task S5079_FailsClosedForMalformedLegacyTokens( + string series, + string dates) + { + var table = Table( + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"], + ["A", series, "1_2_3_4", "1_2_3_4", "1_2_3_4", dates]); + + await Assert.ThrowsAsync(() => + new S5079SceneDataLoader(new RecordingExecutor( + new Dictionary { ["INPUT_GROW"] = table })) + .LoadAsync(new S5079SceneDataRequest("A"))); + } + + [Fact] + public async Task S5080_UsesOneOracleParameterAndMapsSixTypedQuarters() + { + var table = Table( + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"], + ["A", "1Q_100", "2Q_-50", "3Q_", "4Q_1000", "5Q_25", "6Q_-2"]); + var executor = new RecordingExecutor( + new Dictionary { ["INPUT_SELL"] = table }); + + var data = await new S5080SceneDataLoader(executor).LoadAsync( + new S5080SceneDataRequest("A")); + + Assert.Equal(new[] { "1Q", "2Q", "3Q", "4Q", "5Q", "6Q" }, + data.Quarters.Select(item => item.Quarter)); + Assert.Equal(new[] { 100, -50, 0, 1000, 25, -2 }, + data.Quarters.Select(item => item.Value)); + var call = Assert.Single(executor.Calls); + Assert.Equal("INPUT_SELL", call.TableName); + Assert.Single(call.Spec.Parameters); + Assert.NotEmpty(new S5080SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task S5080_RejectsMalformedQuarterAndAmbiguousRows() + { + var malformed = Table( + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"], + ["A", "1Q_100_extra", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]); + await Assert.ThrowsAsync(() => + new S5080SceneDataLoader(new RecordingExecutor( + new Dictionary { ["INPUT_SELL"] = malformed })) + .LoadAsync(new S5080SceneDataRequest("A"))); + + var ambiguous = malformed.Copy(); + ambiguous.Rows.Clear(); + ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"); + ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"); + await Assert.ThrowsAsync(() => + new S5080SceneDataLoader(new RecordingExecutor( + new Dictionary { ["INPUT_SELL"] = ambiguous })) + .LoadAsync(new S5080SceneDataRequest("A"))); + } + + [Theory] + [InlineData(InvestorFlowParticipant.Individual, "F_INVEST_CODE = '8000'")] + [InlineData(InvestorFlowParticipant.Institution, "'7000'")] + [InlineData(InvestorFlowParticipant.Foreign, "'9000', '9001'")] + public async Task S5083_CoversEveryParticipantSummaryAndTrendBranch( + InvestorFlowParticipant participant, + string queryMarker) + { + var executor = new RecordingExecutor(new Dictionary + { + ["S5083_SUMMARY"] = ParticipantSummaryTable(), + ["S5083_TREND"] = ParticipantTrendTable() + }); + + var data = await new S5083SceneDataLoader(executor).LoadAsync( + new S5083SceneDataRequest(participant)); + + Assert.Equal(participant, data.Participant); + Assert.Equal( + new[] { InvestorFlowMarket.Kospi, InvestorFlowMarket.Kosdaq, InvestorFlowMarket.Kospi200 }, + data.Summary.Select(item => item.Market)); + Assert.Equal(new TimeOnly(15, 30), data.TrendPoints[^1].DataTime); + Assert.Equal(2, executor.Calls.Count); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Empty(call.Spec.Parameters); + Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + }); + Assert.NotEmpty(new S5083SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task S5083_RejectsDuplicateOrUnknownMarketsAndInvalidTimes() + { + var summary = ParticipantSummaryTable(); + summary.Rows[1]["MARKET"] = "코스피"; + await Assert.ThrowsAsync(() => + new S5083SceneDataLoader(new RecordingExecutor(new Dictionary + { + ["S5083_SUMMARY"] = summary, + ["S5083_TREND"] = ParticipantTrendTable() + })).LoadAsync(new S5083SceneDataRequest(InvestorFlowParticipant.Individual))); + + var trend = ParticipantTrendTable(); + trend.Rows[2]["DATA_TIME"] = "202607109999"; + await Assert.ThrowsAsync(() => + new S5083SceneDataLoader(new RecordingExecutor(new Dictionary + { + ["S5083_SUMMARY"] = ParticipantSummaryTable(), + ["S5083_TREND"] = trend + })).LoadAsync(new S5083SceneDataRequest(InvestorFlowParticipant.Foreign))); + } + + [Theory] + [InlineData(InvestorFlowIndex.Kospi, "FROM t_invest")] + [InlineData(InvestorFlowIndex.Kosdaq, "FROM t_kosdaq_invest")] + public async Task S5084_CoversBothIndexSummaryAndTrendBranches( + InvestorFlowIndex index, + string queryMarker) + { + var executor = new RecordingExecutor(new Dictionary + { + ["S5084_SUMMARY"] = IndexSummaryTable(), + ["S5084_TREND"] = IndexTrendTable() + }); + + var data = await new S5084SceneDataLoader(executor).LoadAsync( + new S5084SceneDataRequest(index)); + + Assert.Equal(index, data.Index); + Assert.Equal(new InvestorFlowTotalsData(100, -50, 25), data.Totals); + Assert.Equal(2, data.TrendPoints.Count); + Assert.Equal(new TimeOnly(15, 30), data.TrendPoints[^1].DataTime); + Assert.Equal(2, executor.Calls.Count); + Assert.All(executor.Calls, call => + { + Assert.Empty(call.Spec.Parameters); + Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + }); + Assert.NotEmpty(new S5084SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task S5084_RejectsEmptyTrendAndFractionalIntegerValues() + { + var emptyTrend = IndexTrendTable(); + emptyTrend.Rows.Clear(); + await Assert.ThrowsAsync(() => + new S5084SceneDataLoader(new RecordingExecutor(new Dictionary + { + ["S5084_SUMMARY"] = IndexSummaryTable(), + ["S5084_TREND"] = emptyTrend + })).LoadAsync(new S5084SceneDataRequest(InvestorFlowIndex.Kospi))); + + var fractional = IndexSummaryTable(); + fractional.Rows[0]["INDIVIDUAL"] = 1.5m; + await Assert.ThrowsAsync(() => + new S5084SceneDataLoader(new RecordingExecutor(new Dictionary + { + ["S5084_SUMMARY"] = fractional, + ["S5084_TREND"] = IndexTrendTable() + })).LoadAsync(new S5084SceneDataRequest(InvestorFlowIndex.Kosdaq))); + } + + [Fact] + public async Task ClosedRequestsFailBeforeAnyDatabaseCall() + { + var executor = new RecordingExecutor(new Dictionary()); + + await Assert.ThrowsAsync(() => + new S5078SceneDataLoader(executor).LoadAsync( + new S5078SceneDataRequest((SectorIndexMarket)99))); + await Assert.ThrowsAsync(() => + new S5083SceneDataLoader(executor).LoadAsync( + new S5083SceneDataRequest((InvestorFlowParticipant)99))); + await Assert.ThrowsAsync(() => + new S5084SceneDataLoader(executor).LoadAsync( + new S5084SceneDataRequest((InvestorFlowIndex)99))); + await Assert.ThrowsAsync(() => + new S5079SceneDataLoader(executor).LoadAsync( + new S5079SceneDataRequest("\r\n"))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task ParameterizedLoaderErrorsDoNotExposeTheSelection() + { + const string selection = "SENSITIVE-STOCK-SELECTION"; + var exception = await Assert.ThrowsAsync(() => + new S5079SceneDataLoader(new RecordingExecutor( + new Dictionary { ["INPUT_GROW"] = new DataTable() })) + .LoadAsync(new S5079SceneDataRequest(selection))); + + Assert.DoesNotContain(selection, exception.Message, StringComparison.Ordinal); + } + + private static DataTable SectorTable(int count = 10) + { + var table = Table(["CATEGORY", "PART_INDEX", "CHG_TYPE", "PART_CHANGE", "RATE"]); + for (var index = 0; index < count; index++) + { + table.Rows.Add( + "Sector" + index, + 1000 + index, + index == 0 ? "-" : "+", + index, + index - 5d); + } + + return table; + } + + private static DataTable ParticipantSummaryTable() => Table( + ["MARKET", "AMOUNT"], + ["코스피", 100], + ["코스닥", -50], + ["코스피200", 25]); + + private static DataTable ParticipantTrendTable() => Table( + ["MARKET", "DATA_TIME", "PRICE"], + ["코스피", "202607100900", -10], + ["코스닥", "202607101000", 0], + ["코스피200", "202607101530", 10]); + + private static DataTable IndexSummaryTable() => Table( + ["INDIVIDUAL", "FOREIGN", "INSTITUTION"], + [100, -50, 25]); + + private static DataTable IndexTrendTable() => Table( + ["DATA_TIME", "INDIVIDUAL", "FOREIGN", "INSTITUTION"], + ["202607100900", -10, 0, 10], + ["202607101530", 10, -5, 5]); + + private static DataTable Table(string[] columns, params object?[][] rows) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + foreach (var row in rows) + { + table.Rows.Add(row); + } + + return table; + } + + private sealed record QueryCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec); + + private sealed class RecordingExecutor( + IReadOnlyDictionary results) : IDataQueryExecutor + { + public List Calls { get; } = []; + + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new InvalidOperationException("String query fallback is forbidden in this test."); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("String query fallback is forbidden in this test."); + + 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.TryGetValue(tableName, out var table)) + { + throw new InvalidOperationException("The test did not configure this table."); + } + + return Task.FromResult(table.Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs new file mode 100644 index 0000000..bde556f --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldLegacyRequestResolverTests.cs @@ -0,0 +1,402 @@ +#nullable enable + +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ComparisonAndYieldLegacyRequestResolverTests +{ + [Theory] + [InlineData("5026", "s5026")] + [InlineData("5087", "s5087")] + public async Task Fixed_pair_scenes_preserve_subject_order_and_use_five_days( + string cutCode, + string builderKey) + { + var executor = new RecordingExecutor( + Count(1), Count(0), + Count(0), Count(1)); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = await resolver.ResolveAsync(Entry( + cutCode, + groupCode: "ignored-code", + subject: " 첫번째 , 두번째 ", + subtype: "ignored-sub", + graphicType: "ignored-forCutInfo", + dataCode: "ignored-dataCode")); + + Assert.Equal(builderKey, result.BuilderKey); + var request = result switch + { + LegacyS5026SceneLoadRequest routed => routed.Request, + LegacyS5087SceneLoadRequest routed => routed.Request, + _ => throw new Xunit.Sdk.XunitException("Unexpected routed request type.") + }; + Assert.Equal(ComparisonGraphPeriod.FiveDays, request.Period); + Assert.Equal( + new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째"), + request.First); + Assert.Equal( + new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째"), + request.Second); + AssertStockLookupOrder(executor, "첫번째", "두번째"); + } + + [Theory] + [InlineData("일봉", ComparisonGraphPeriod.Daily)] + [InlineData("5일", ComparisonGraphPeriod.FiveDays)] + [InlineData("1개월", ComparisonGraphPeriod.OneMonth)] + [InlineData("3개월", ComparisonGraphPeriod.ThreeMonths)] + [InlineData("6개월", ComparisonGraphPeriod.SixMonths)] + [InlineData("12개월", ComparisonGraphPeriod.TwelveMonths)] + public async Task S5029_maps_every_original_period( + string subtype, + ComparisonGraphPeriod expected) + { + var executor = new RecordingExecutor( + Count(1), Count(0), + Count(0), Count(1)); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = Assert.IsType( + await resolver.ResolveAsync(Entry("5029", "", "A,B", subtype))); + + Assert.Equal(expected, result.Request.Period); + Assert.Equal("A", result.Request.First.StockName); + Assert.Equal("B", result.Request.Second.StockName); + } + + [Theory] + [InlineData("5일", YieldGraphPeriod.FiveDays)] + [InlineData("20일", YieldGraphPeriod.TwentyDays)] + [InlineData("60일", YieldGraphPeriod.SixtyDays)] + [InlineData("120일", YieldGraphPeriod.OneHundredTwentyDays)] + [InlineData("240일", YieldGraphPeriod.TwoHundredFortyDays)] + public async Task S5086_maps_every_original_yield_period( + string subtype, + YieldGraphPeriod expected) + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = Assert.IsType( + await resolver.ResolveAsync(Entry("5086", "환율", "원달러", subtype))); + + Assert.Equal(expected, result.Request.Period); + Assert.Equal(YieldGraphInstrumentKind.WonDollar, result.Request.Instrument.Kind); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("오늘 원달러", YieldGraphInstrumentKind.WonDollar)] + [InlineData("오늘 원엔", YieldGraphInstrumentKind.WonYen)] + [InlineData("오늘 원위엔", YieldGraphInstrumentKind.WonYuan)] + [InlineData("오늘 원유로", YieldGraphInstrumentKind.WonEuro)] + public async Task S5086_preserves_original_contains_based_fx_routing( + string subject, + YieldGraphInstrumentKind expected) + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = Assert.IsType( + await resolver.ResolveAsync(Entry("5086", "unused", subject, "5일"))); + + Assert.Equal(expected, result.Request.Instrument.Kind); + Assert.Null(result.Request.Instrument.Name); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("코스피", YieldGraphInstrumentKind.KospiIndex)] + [InlineData("코스닥", YieldGraphInstrumentKind.KosdaqIndex)] + [InlineData("코스피200", YieldGraphInstrumentKind.Kospi200Index)] + [InlineData("KRX100", YieldGraphInstrumentKind.Krx100Index)] + public async Task S5086_uses_group_code_only_for_the_original_index_branch( + string groupCode, + YieldGraphInstrumentKind expected) + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = Assert.IsType( + await resolver.ResolveAsync(Entry( + "5086", + groupCode, + "지수", + "20일", + graphicType: "must-not-route", + dataCode: "must-not-route"))); + + Assert.Equal(expected, result.Request.Instrument.Kind); + Assert.Null(result.Request.Instrument.Name); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("코스피업종", YieldGraphInstrumentKind.KospiIndustry)] + [InlineData("코스닥업종", YieldGraphInstrumentKind.KosdaqIndustry)] + [InlineData("기타업종", YieldGraphInstrumentKind.KosdaqIndustry)] + public async Task S5086_preserves_original_industry_branch_and_kosdaq_fallback( + string groupCode, + YieldGraphInstrumentKind expected) + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = Assert.IsType( + await resolver.ResolveAsync(Entry("5086", groupCode, "반도체", "60일"))); + + Assert.Equal(expected, result.Request.Instrument.Kind); + Assert.Equal("반도체", result.Request.Instrument.Name); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task S5086_stock_uses_kosdaq_later_table_precedence() + { + var executor = new RecordingExecutor(Count(1), Count(1)); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = Assert.IsType( + await resolver.ResolveAsync(Entry("5086", "종목", "중복이름", "120일"))); + + Assert.Equal(YieldGraphInstrumentKind.KosdaqStock, result.Request.Instrument.Kind); + Assert.Equal("중복이름", result.Request.Instrument.Name); + AssertStockLookupOrder(executor, "중복이름"); + } + + [Theory] + [InlineData("원달러", YieldGraphInstrumentKind.WonDollar)] + [InlineData("원엔", YieldGraphInstrumentKind.WonYen)] + [InlineData("원위엔", YieldGraphInstrumentKind.WonYuan)] + [InlineData("원유로", YieldGraphInstrumentKind.WonEuro)] + public async Task S50860_accepts_only_the_quote_capable_fx_branches( + string subject, + YieldGraphInstrumentKind expected) + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + var result = Assert.IsType( + await resolver.ResolveAsync(Entry("50860", "ignored", subject, "240일"))); + + Assert.Equal(YieldGraphPeriod.TwoHundredFortyDays, result.Request.Period); + Assert.Equal(expected, result.Request.Instrument.Kind); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("코스피", "지수")] + [InlineData("코스피업종", "반도체")] + [InlineData("종목", "삼성전자")] + public async Task S50860_rejects_non_fx_before_any_database_lookup( + string groupCode, + string subject) + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveAsync(Entry("50860", groupCode, subject, "5일"))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task Stock_lookups_are_read_only_parameterized_oracle_queries() + { + const string firstName = "A' OR '1'='1"; + const string secondName = "B; DELETE FROM T_STOCK"; + var executor = new RecordingExecutor( + Count(1), Count(0), + Count(0), Count(1)); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + _ = await resolver.ResolveAsync(Entry( + "5026", + "", + firstName + "," + secondName, + "")); + + AssertStockLookupOrder(executor, firstName, secondName); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Contains("SELECT COUNT(*)", call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("INSERT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("UPDATE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("DELETE FROM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("stockName", parameter.Name); + Assert.Equal(DbType.String, parameter.DbType); + Assert.DoesNotContain( + Assert.IsType(parameter.Value), + call.Spec.Sql, + StringComparison.Ordinal); + }); + Assert.Contains("FROM T_STOCK ", executor.Calls[0].Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM T_KOSDAQ_STOCK ", executor.Calls[1].Spec.Sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("9999", "", "A,B", "5일")] + [InlineData("5029", "", "A,B", "주봉")] + [InlineData("5029", "", "A", "5일")] + [InlineData("5029", "", "A,B,C", "5일")] + [InlineData("5086", "UNKNOWN", "지수", "5일")] + [InlineData("5086", "환율", "원달러", "1일")] + public async Task Unsupported_closed_selections_fail_before_database_access( + string cutCode, + string groupCode, + string subject, + string subtype) + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveAsync(Entry(cutCode, groupCode, subject, subtype))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task Missing_selection_fails_before_database_access() + { + var executor = new RecordingExecutor(); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveAsync(new LegacyPlaylistEntry("entry", "5026"))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task Unknown_stock_fails_after_both_original_market_lookups() + { + var executor = new RecordingExecutor(Count(0), Count(0)); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveAsync(Entry("5086", "종목", "없는종목", "5일"))); + + AssertStockLookupOrder(executor, "없는종목"); + } + + public static IEnumerable InvalidStockMasterResults() + { + var wrongColumn = new DataTable(); + wrongColumn.Columns.Add("COUNT", typeof(int)); + wrongColumn.Rows.Add(1); + yield return [wrongColumn]; + + var noRows = new DataTable(); + noRows.Columns.Add("MATCH_COUNT", typeof(int)); + yield return [noRows]; + + yield return [Count(2)]; + yield return [Count(0.5m)]; + + var nullCount = new DataTable(); + nullCount.Columns.Add("MATCH_COUNT", typeof(object)); + nullCount.Rows.Add(DBNull.Value); + yield return [nullCount]; + } + + [Theory] + [MemberData(nameof(InvalidStockMasterResults))] + public async Task Invalid_stock_master_results_fail_closed(DataTable result) + { + var executor = new RecordingExecutor(result); + var resolver = new ComparisonAndYieldLegacyRequestResolver(executor); + + await Assert.ThrowsAsync(() => + resolver.ResolveAsync(Entry("5026", "", "A,B", ""))); + + Assert.Single(executor.Calls); + } + + private static LegacyPlaylistEntry Entry( + string cutCode, + string groupCode, + string subject, + string subtype, + string graphicType = "", + string dataCode = "") => new( + "entry", + cutCode, + Selection: new LegacySceneSelection( + groupCode, + subject, + graphicType, + subtype, + dataCode)); + + private static DataTable Count(object value) + { + var table = new DataTable(); + table.Columns.Add("MATCH_COUNT", value.GetType()); + table.Rows.Add(value); + return table; + } + + private static void AssertStockLookupOrder( + RecordingExecutor executor, + params string[] stockNames) + { + Assert.Equal(stockNames.Length * 2, executor.Calls.Count); + for (var index = 0; index < stockNames.Length; index++) + { + var kospi = executor.Calls[index * 2]; + var kosdaq = executor.Calls[(index * 2) + 1]; + Assert.Equal("SCENE_COMPARISON_RESOLVE_KOSPI_STOCK", kospi.TableName); + Assert.Equal("SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK", kosdaq.TableName); + Assert.Equal(stockNames[index], Assert.Single(kospi.Spec.Parameters).Value); + Assert.Equal(stockNames[index], Assert.Single(kosdaq.Spec.Parameters).Value); + } + } + + private sealed record RecordedCall( + 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 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(); + query.ValidateFor(source); + Calls.Add(new RecordedCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No fake query result remains."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs new file mode 100644 index 0000000..0ed5265 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs @@ -0,0 +1,597 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ComparisonAndYieldSceneBuildersTests +{ + [Fact] + public void S5026_matches_legacy_header_direction_and_two_candle_series_order() + { + var actual = new S5026SceneMutationBuilder().Build(new S5026SceneData( + ComparisonGraphPeriod.FiveDays, + [CandleSeries(ComparisonSeriesTarget.Second), CandleSeries(ComparisonSeriesTarget.First)])) + .Select(Describe) + .ToArray(); + + var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5026) + .Concat(ExpectedCandleSeries(1)) + .Concat(ExpectedCandleSeries(2)); + Assert.Equal(expected, actual); + } + + [Fact] + public void S5026_equal_range_is_finite_and_missing_or_nonfinite_candles_fail_closed() + { + var equal = Enumerable.Range(1, 5) + .Select(index => new ComparisonCandlePoint( + new DateOnly(2026, 1, index), + 100, + 100, + 100, + 100)) + .ToArray(); + var first = CandleSeries(ComparisonSeriesTarget.First) with { Candles = equal }; + var second = CandleSeries(ComparisonSeriesTarget.Second) with { Candles = equal }; + var builder = new S5026SceneMutationBuilder(); + var mutations = builder.Build(new S5026SceneData( + ComparisonGraphPeriod.FiveDays, + [first, second])); + + AssertFinite(mutations); + Assert.All( + mutations.OfType().Where(item => item.ObjectName.Contains("candlebar")), + crop => + { + Assert.Equal(0, crop.Top); + Assert.Equal(0, crop.Bottom); + }); + + Assert.Throws(() => builder.Build(new S5026SceneData( + ComparisonGraphPeriod.FiveDays, + [first with { Candles = equal.Take(4).ToArray() }, second]))); + Assert.Throws(() => builder.Build(new S5026SceneData( + ComparisonGraphPeriod.FiveDays, + [ + first with + { + Candles = equal.Select((item, index) => index == 0 + ? item with { High = double.NaN } + : item).ToArray() + }, + second + ]))); + } + + [Fact] + public void S5029_matches_legacy_two_series_dates_tags_baselines_and_shapes() + { + var actual = new S5029SceneMutationBuilder().Build(new S5029SceneData( + ComparisonGraphPeriod.OneMonth, + [YieldSeries(ComparisonSeriesTarget.First), YieldSeries(ComparisonSeriesTarget.Second)])) + .Select(Describe) + .ToArray(); + + var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5029) + .Concat(ExpectedYieldSeries(1, 812f, 100f)) + .Concat(ExpectedYieldSeries(2, 812f, -50f)); + Assert.Equal(expected, actual); + } + + [Fact] + public void S5087_preserves_its_distinct_hide_order_width_and_baseline_offset() + { + var actual = new S5087SceneMutationBuilder().Build(new S5087SceneData( + ComparisonGraphPeriod.FiveDays, + [YieldSeries(ComparisonSeriesTarget.Second), YieldSeries(ComparisonSeriesTarget.First)])) + .Select(Describe) + .ToArray(); + + var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5087) + .Concat(ExpectedYieldSeries(1, 700f, -20f)) + .Concat(ExpectedYieldSeries(2, 700f, -170f)); + Assert.Equal(expected, actual); + } + + [Fact] + public void Comparison_yield_one_point_and_equal_values_correct_source_denominators_to_finite_zero() + { + var first = YieldSeries(ComparisonSeriesTarget.First) with + { + Points = [new(new DateOnly(2026, 1, 1), 10)] + }; + var second = YieldSeries(ComparisonSeriesTarget.Second) with + { + Points = [new(new DateOnly(2026, 1, 1), -10)] + }; + + var mutations = new S5029SceneMutationBuilder().Build(new S5029SceneData( + ComparisonGraphPeriod.Daily, + [first, second])); + + AssertFinite(mutations); + Assert.Equal(-50, mutations.OfType() + .Single(item => item.ObjectName == "baseline1").Y); + Assert.All( + mutations.OfType().SelectMany(item => item.Points), + point => Assert.True(float.IsFinite(point.X) && float.IsFinite(point.Y))); + + Assert.Throws(() => + new S5029SceneMutationBuilder().Build(new S5029SceneData( + ComparisonGraphPeriod.Daily, + [first with { Points = [] }, second]))); + } + + [Fact] + public void Comparison_yield_sign_crossing_adds_the_same_finite_zero_intersection_to_both_shapes() + { + var crossing = YieldSeries(ComparisonSeriesTarget.First) with + { + Points = + [ + new(new DateOnly(2026, 1, 1), 100), + new(new DateOnly(2026, 1, 2), 0), + new(new DateOnly(2026, 1, 3), 150), + new(new DateOnly(2026, 1, 4), 50), + new(new DateOnly(2026, 1, 5), 100) + ] + }; + var mutations = new S5029SceneMutationBuilder().Build(new S5029SceneData( + ComparisonGraphPeriod.FiveDays, + [crossing, YieldSeries(ComparisonSeriesTarget.Second)])); + var plus = mutations.OfType() + .Single(item => item.ObjectName == "plusGraph1"); + var minus = mutations.OfType() + .Single(item => item.ObjectName == "minusGraph1"); + + Assert.Contains(plus.Points, point => point.X == 304.5f && point.Y == 0); + Assert.Contains(minus.Points, point => point.X == 304.5f && point.Y == 0); + AssertFinite(mutations); + } + + [Fact] + public void S5086_matches_legacy_fx_header_quote_dates_tags_and_graph_order() + { + var actual = new S5086SceneMutationBuilder().Build(new S5086SceneData( + YieldGraphPeriod.TwentyDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar), + SingleYieldPoints())) + .Select(Describe) + .ToArray(); + + Assert.Equal( + [ + "V:subtitle=1개월", "V:unit=단위 : 원", "V:title=원달러 수익률", + "V:date5=01/01", "V:date4=01/02", "V:date3=01/03", + "V:date2=01/04", "V:date1=01/05", + "V:price=0.00", "V:referencePrice=100.00", "V:rate=-100.00", + "S:bg1=0", "S:bg2=0", "S:bg3=1", + "P:baseline:0:150:0:2", + "P:highTag:-647.5:0:0:1", "V:highPrice=100.00", + "P:lowTag:653.79:0:0:1", "V:lowPrice=0.00", + "P:minusGraph:-659.82:150:0:7", + "H:minusGraph=0,0,0|0,-0,0|333.25,-56.25,0|666.5,-112.5,0|999.75,-168.75,0|1333,-225,0|1333,0,0", + "P:plusGraph:-659.82:150:0:7", + "H:plusGraph=0,0,0|0,0,0|1333,0,0" + ], actual); + } + + [Fact] + public void S50860_matches_legacy_slash_title_quote_direction_color_and_graph_order() + { + var actual = new S50860SceneMutationBuilder().Build(new S50860SceneData( + YieldGraphPeriod.TwentyDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar), + -12.34, + 1.25, + YieldQuoteDirection.Up, + SingleYieldPoints())) + .Select(Describe) + .ToArray(); + + Assert.Equal( + [ + "V:subtitle=1개월", "V:unit=단위 : 원", "V:title=원/달러", + "V:date5=01/01", "V:date4=01/02", "V:date3=01/03", + "V:date2=01/04", "V:date1=01/05", + "V:price=0.00", "V:changePrice=12.34", "V:rate=1.25", + "S:up=0", "S:flat=0", "S:down=0", "S:up=1", + "C:bg:170:0:0:255", + "P:baseline:0:150:0:2", + "P:highTag:-647.5:0:0:1", "V:highPrice=100.00", + "P:lowTag:653.79:0:0:1", "V:lowPrice=0.00", + "P:minusGraph:-659.82:150:0:7", + "H:minusGraph=0,0,0|0,-0,0|333.25,-56.25,0|666.5,-112.5,0|999.75,-168.75,0|1333,-225,0|1333,0,0", + "P:plusGraph:-659.82:150:0:7", + "H:plusGraph=0,0,0|0,0,0|1333,0,0" + ], actual); + } + + [Fact] + public void S5086_missing_rows_apply_documented_forward_fill_but_first_missing_fails_closed() + { + var builder = new S5086SceneMutationBuilder(); + var corrected = builder.Build(new S5086SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"), + [ + new(new DateOnly(2026, 1, 1), 10), + new(new DateOnly(2026, 1, 2), null), + new(new DateOnly(2026, 1, 3), 20) + ])); + + AssertFinite(corrected); + Assert.Equal("V:rate=-50.00", Describe(corrected.OfType() + .Single(item => item.ObjectName == "rate"))); + Assert.Throws(() => builder.Build(new S5086SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"), + [new(new DateOnly(2026, 1, 1), null)]))); + Assert.Throws(() => builder.Build(new S5086SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"), + [new(new DateOnly(2026, 1, 1), double.NegativeInfinity)]))); + } + + [Fact] + public void Single_yield_zero_range_zero_reference_and_one_point_are_explicitly_finite() + { + var point = new YieldGraphPoint(new DateOnly(2026, 1, 1), 0); + var s5086 = new S5086SceneMutationBuilder().Build(new S5086SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KosdaqIndex), + [point])); + var s50860 = new S50860SceneMutationBuilder().Build(new S50860SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KosdaqIndex), + 0, + 0, + YieldQuoteDirection.Flat, + [point])); + + AssertFinite(s5086); + AssertFinite(s50860); + Assert.Equal("V:rate=0.00", Describe(s5086.OfType() + .Single(item => item.ObjectName == "rate"))); + Assert.All( + s5086.Concat(s50860).OfType() + .Where(item => item.ObjectName == "baseline"), + position => Assert.Equal(-75, position.Y)); + + var signed = new S5086SceneMutationBuilder().Build(new S5086SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"), + [ + new(new DateOnly(2026, 1, 1), -10), + new(new DateOnly(2026, 1, 2), 0), + new(new DateOnly(2026, 1, 3), 10) + ])); + AssertFinite(signed); + } + + [Fact] + public void S50860_rejects_missing_or_nonfinite_rows_and_quote_values() + { + var builder = new S50860SceneMutationBuilder(); + var instrument = new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"); + Assert.Throws(() => builder.Build(new S50860SceneData( + YieldGraphPeriod.FiveDays, + instrument, + 0, + 0, + YieldQuoteDirection.Flat, + [new(new DateOnly(2026, 1, 1), null)]))); + Assert.Throws(() => builder.Build(new S50860SceneData( + YieldGraphPeriod.FiveDays, + instrument, + double.PositiveInfinity, + 0, + YieldQuoteDirection.Flat, + [new(new DateOnly(2026, 1, 1), 1)]))); + } + + [Fact] + public void Closed_period_market_series_instrument_and_direction_mappings_reject_unknown_values() + { + Assert.Throws(() => + new S5026SceneMutationBuilder().Build(new S5026SceneData( + (ComparisonGraphPeriod)99, + [CandleSeries(ComparisonSeriesTarget.First), CandleSeries(ComparisonSeriesTarget.Second)]))); + + var badMarket = CandleSeries(ComparisonSeriesTarget.First) with + { + Quote = Quote(ComparisonSeriesTarget.First) with + { + Market = (ComparisonEquityMarket)99 + } + }; + Assert.Throws(() => + new S5026SceneMutationBuilder().Build(new S5026SceneData( + ComparisonGraphPeriod.FiveDays, + [badMarket, CandleSeries(ComparisonSeriesTarget.Second)]))); + + var badDirection = CandleSeries(ComparisonSeriesTarget.First) with + { + Quote = Quote(ComparisonSeriesTarget.First) with + { + Direction = (ComparisonChangeDirection)99 + } + }; + Assert.Throws(() => + new S5026SceneMutationBuilder().Build(new S5026SceneData( + ComparisonGraphPeriod.FiveDays, + [badDirection, CandleSeries(ComparisonSeriesTarget.Second)]))); + + var badSeries = YieldSeries(ComparisonSeriesTarget.First) with + { + Quote = Quote((ComparisonSeriesTarget)99) + }; + Assert.Throws(() => + new S5029SceneMutationBuilder().Build(new S5029SceneData( + ComparisonGraphPeriod.FiveDays, + [badSeries, YieldSeries(ComparisonSeriesTarget.Second)]))); + + Assert.Throws(() => + new S5086SceneMutationBuilder().Build(new S5086SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument((YieldGraphInstrumentKind)99), + [new(new DateOnly(2026, 1, 1), 1)]))); + Assert.Throws(() => + new S5086SceneMutationBuilder().Build(new S5086SceneData( + (YieldGraphPeriod)99, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiIndex), + [new(new DateOnly(2026, 1, 1), 1)]))); + Assert.Throws(() => + new S50860SceneMutationBuilder().Build(new S50860SceneData( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiIndex), + 0, + 0, + (YieldQuoteDirection)99, + [new(new DateOnly(2026, 1, 1), 1)]))); + } + + [Fact] + public void Builders_expose_closed_scene_keys() + { + Assert.Equal("s5026", new S5026SceneMutationBuilder().BuilderKey); + Assert.Equal("s5029", new S5029SceneMutationBuilder().BuilderKey); + Assert.Equal("s5086", new S5086SceneMutationBuilder().BuilderKey); + Assert.Equal("s50860", new S50860SceneMutationBuilder().BuilderKey); + Assert.Equal("s5087", new S5087SceneMutationBuilder().BuilderKey); + } + + private static ComparisonCandleSeriesData CandleSeries(ComparisonSeriesTarget target) => new( + Quote(target), + [ + new(new DateOnly(2026, 1, 1), 200, 100, 150, 180), + new(new DateOnly(2026, 1, 2), 190, 110, 180, 140), + new(new DateOnly(2026, 1, 3), 180, 120, 150, 150), + new(new DateOnly(2026, 1, 4), 170, 130, 140, 160), + new(new DateOnly(2026, 1, 5), 160, 140, 150, 145) + ]); + + private static ComparisonYieldSeriesData YieldSeries(ComparisonSeriesTarget target) + { + var values = target == ComparisonSeriesTarget.First + ? new[] { 0, 25, 50, 75, 100 } + : new[] { 100, 75, 50, 25, 0 }; + return new ComparisonYieldSeriesData( + Quote(target), + values.Select((value, index) => new ComparisonYieldPoint( + new DateOnly(2026, 1, index + 1), + value)).ToArray()); + } + + private static ComparisonQuoteData Quote(ComparisonSeriesTarget target) => new( + target, + target == ComparisonSeriesTarget.First + ? ComparisonEquityMarket.Kospi + : ComparisonEquityMarket.Kosdaq, + target == ComparisonSeriesTarget.First ? "Alpha" : "Beta", + target == ComparisonSeriesTarget.First ? 1000 : 2000, + target == ComparisonSeriesTarget.First ? 10 : -20, + target == ComparisonSeriesTarget.First ? 1.25 : -2.5, + target == ComparisonSeriesTarget.First + ? ComparisonChangeDirection.Up + : ComparisonChangeDirection.Down); + + private static YieldGraphPoint[] SingleYieldPoints() => + [ + new(new DateOnly(2026, 1, 1), 0), + new(new DateOnly(2026, 1, 2), 25), + new(new DateOnly(2026, 1, 3), 50), + new(new DateOnly(2026, 1, 4), 75), + new(new DateOnly(2026, 1, 5), 100) + ]; + + private static IEnumerable ExpectedComparisonHeader(ComparisonHeaderStyle style) + { + yield return "V:title1=Alpha"; + yield return "V:title2=Beta"; + yield return "V:price1=1,000"; + yield return "V:price2=2,000"; + yield return "V:changePrice1=10"; + yield return "V:changePrice2=-20"; + yield return "V:rate1=1.25"; + yield return "V:rate2=-2.50"; + + var orders = style switch + { + ComparisonHeaderStyle.S5026 => new[] + { + new[] { "up", "flat", "down" }, + new[] { "up", "flat", "down" } + }, + ComparisonHeaderStyle.S5029 => new[] + { + new[] { "upup", "up", "flat", "downdown", "down" }, + new[] { "upup", "up", "flat", "downdown", "down" } + }, + ComparisonHeaderStyle.S5087 => new[] + { + new[] { "up", "upup", "flat", "down", "downdown" }, + new[] { "upup", "up", "flat", "down", "downdown" } + }, + _ => throw new InvalidOperationException() + }; + for (var series = 1; series <= 2; series++) + { + foreach (var name in orders[series - 1]) + { + yield return $"S:{name}{series}=0"; + } + } + + foreach (var value in ExpectedDirection(1, "up", 1)) + { + yield return value; + } + + foreach (var value in ExpectedDirection(2, "down", 3)) + { + yield return value; + } + } + + private static IEnumerable ExpectedDirection(int series, string active, int background) + { + foreach (var name in new[] { "upup", "up", "flat", "down", "downdown" }) + { + yield return $"S:{name}{series}=0"; + } + + yield return $"S:{active}{series}=1"; + for (var variant = 1; variant <= 3; variant++) + { + yield return $"S:bg{series}_{variant}={(variant == background ? 1 : 0)}"; + } + } + + private static IEnumerable ExpectedCandleSeries(int series) + { + yield return $"V:date{series}_1=01/01"; + yield return $"R:candlebar{series}_1:1:0:0:0:100:10"; + yield return $"R:candlebody{series}_1:1:0:20:0:50:10"; + yield return $"C:candlebody{series}_1:170:0:0:255"; + yield return $"C:candlebar{series}_1:170:0:0:255"; + yield return $"R:candlebar{series}_2:1:0:10:0:90:10"; + yield return $"R:candlebody{series}_2:1:0:20:0:60:10"; + yield return $"C:candlebody{series}_2:15:99:189:255"; + yield return $"C:candlebar{series}_2:15:99:189:255"; + yield return $"V:date{series}_3=01/03"; + yield return $"R:candlebar{series}_3:1:0:20:0:80:10"; + yield return $"R:candlebody{series}_3:1:0:49.9:0:53:10"; + yield return $"C:candlebody{series}_3:95:95:95:255"; + yield return $"C:candlebar{series}_3:95:95:95:255"; + yield return $"R:candlebar{series}_4:1:0:30:0:70:10"; + yield return $"R:candlebody{series}_4:1:0:40:0:60:10"; + yield return $"C:candlebody{series}_4:170:0:0:255"; + yield return $"C:candlebar{series}_4:170:0:0:255"; + yield return $"V:date{series}_5=01/05"; + yield return $"R:candlebar{series}_5:1:0:40:0:60:10"; + yield return $"R:candlebody{series}_5:1:0:50:0:55:10"; + yield return $"C:candlebody{series}_5:15:99:189:255"; + yield return $"C:candlebar{series}_5:15:99:189:255"; + } + + private static IEnumerable ExpectedYieldSeries( + int series, + float maximumX, + float baseline) + { + yield return $"V:date{series}_5=01/01"; + yield return $"V:date{series}_4=01/02"; + yield return $"V:date{series}_3=01/03"; + yield return $"V:date{series}_2=01/04"; + yield return $"V:date{series}_1=01/05"; + yield return $"P:baseline{series}:0:{F(baseline)}:0:2"; + + var step = maximumX / 4f; + if (series == 1) + { + yield return $"P:highTag1:-383.48:0:0:1"; + yield return "V:highPrice1=100"; + yield return $"P:lowTag1:359.28:0:0:1"; + yield return "V:lowPrice1=0"; + yield return $"P:minusGraph1:0:{F(baseline)}:0:2"; + yield return "H:minusGraph1=0,0,0|0,-0,0|" + + $"{F(step)},-37.5,0|{F(step * 2)},-75,0|" + + $"{F(step * 3)},-112.5,0|{F(maximumX)},-150,0|{F(maximumX)},0,0"; + yield return $"P:plusGraph1:0:{F(baseline)}:0:2"; + yield return $"H:plusGraph1=0,0,0|0,0,0|{F(maximumX)},0,0"; + } + else + { + yield return "P:lowTag2:-383.48:0:0:1"; + yield return "V:lowPrice2=0"; + yield return "P:highTag2:359.28:0:0:1"; + yield return "V:highPrice2=100"; + yield return $"P:minusGraph2:0:{F(baseline)}:0:2"; + yield return $"H:minusGraph2=0,0,0|0,-0,0|{F(maximumX)},0,0"; + yield return $"P:plusGraph2:0:{F(baseline)}:0:2"; + yield return "H:plusGraph2=0,0,0|0,0,0|" + + $"{F(step)},-37.5,0|{F(step * 2)},-75,0|" + + $"{F(step * 3)},-112.5,0|{F(maximumX)},-150,0|{F(maximumX)},0,0"; + } + } + + private static void AssertFinite(IEnumerable mutations) + { + foreach (var mutation in mutations) + { + switch (mutation) + { + case PlayoutSetPosition position: + Assert.True(float.IsFinite(position.X)); + Assert.True(float.IsFinite(position.Y)); + Assert.True(float.IsFinite(position.Z)); + break; + case PlayoutSetCropKey crop: + Assert.True(float.IsFinite(crop.Left)); + Assert.True(float.IsFinite(crop.Top)); + Assert.True(float.IsFinite(crop.Right)); + Assert.True(float.IsFinite(crop.Bottom)); + break; + case PlayoutSetPathShapePoints path: + Assert.All(path.Points, point => + { + Assert.True(float.IsFinite(point.X)); + Assert.True(float.IsFinite(point.Y)); + Assert.True(float.IsFinite(point.Z)); + }); + break; + case PlayoutSetValue value: + Assert.DoesNotContain("NaN", value.Value, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Infinity", value.Value, StringComparison.OrdinalIgnoreCase); + break; + } + } + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + PlayoutSetFaceColor color => + $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}", + PlayoutSetPosition position => + $"P:{position.ObjectName}:{F(position.X)}:{F(position.Y)}:{F(position.Z)}:{(int)position.Components}", + PlayoutSetCropKey crop => + $"R:{crop.ObjectName}:{crop.KeyIndex}:{F(crop.Left)}:{F(crop.Top)}:{F(crop.Right)}:{F(crop.Bottom)}:{(int)crop.Edges}", + PlayoutSetPathShapePoints path => "H:" + path.ObjectName + "=" + string.Join( + "|", + path.Points.Select(point => $"{F(point.X)},{F(point.Y)},{F(point.Z)}")), + _ => mutation.GetType().Name + }; + + private static string F(float value) => + value.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + + private enum ComparisonHeaderStyle + { + S5026, + S5029, + S5087 + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneDataLoadersTests.cs new file mode 100644 index 0000000..9b44cf7 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneDataLoadersTests.cs @@ -0,0 +1,728 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ComparisonAndYieldSceneDataLoadersTests +{ + public static TheoryData ComparisonPeriods => new() + { + { ComparisonGraphPeriod.Daily, 1 }, + { ComparisonGraphPeriod.FiveDays, 5 }, + { ComparisonGraphPeriod.OneMonth, 20 }, + { ComparisonGraphPeriod.ThreeMonths, 60 }, + { ComparisonGraphPeriod.SixMonths, 120 }, + { ComparisonGraphPeriod.TwelveMonths, 240 } + }; + + public static TheoryData YieldPeriods => new() + { + { YieldGraphPeriod.FiveDays, 5 }, + { YieldGraphPeriod.TwentyDays, 20 }, + { YieldGraphPeriod.SixtyDays, 60 }, + { YieldGraphPeriod.OneHundredTwentyDays, 120 }, + { YieldGraphPeriod.TwoHundredFortyDays, 240 } + }; + + [Fact] + public async Task S5026_UsesTwoOrderedParameterizedOracleQueriesAndBuildsGoldenCandleProperties() + { + const string firstName = "First Sensitive Stock"; + const string secondName = "Second Sensitive Stock"; + var executor = new QueueExecutor( + CandleTable(firstName, 1000m, "2"), + CandleTable(secondName, 2000m, "5")); + + var data = await new S5026SceneDataLoader(executor).LoadAsync( + PairRequest( + ComparisonGraphPeriod.FiveDays, + firstName, + ComparisonEquityMarket.Kospi, + secondName, + ComparisonEquityMarket.Kosdaq)); + var mutations = new S5026SceneMutationBuilder().Build(data); + + Assert.Equal(2, executor.Calls.Count); + Assert.All(executor.Calls, call => Assert.Equal(DataSourceKind.Oracle, call.Source)); + Assert.Equal("SCENE_5026_SERIES_1", executor.Calls[0].TableName); + Assert.Equal("SCENE_5026_SERIES_2", executor.Calls[1].TableName); + Assert.Contains("FROM t_online1", executor.Calls[0].Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM t_kosdaq_online1", executor.Calls[1].Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain(firstName, executor.Calls[0].Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain(secondName, executor.Calls[1].Spec.Sql, StringComparison.Ordinal); + Assert.Equal(new[] { "stockName", "rowLimit" }, + executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Name)); + Assert.Equal(new object?[] { firstName, 5 }, + executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value)); + Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second }, + data.Series.Select(series => series.Quote.Series)); + Assert.Equal(new[] { firstName, secondName }, + data.Series.Select(series => series.Quote.StockName)); + Assert.All(data.Series, series => Assert.Equal(5, series.Candles.Count)); + Assert.Equal(ComparisonChangeDirection.Up, data.Series[0].Quote.Direction); + Assert.Equal(ComparisonChangeDirection.Down, data.Series[1].Quote.Direction); + Assert.Contains(new PlayoutSetValue("title1", firstName), mutations); + Assert.Contains(new PlayoutSetValue("title2", secondName), mutations); + Assert.Equal(20, mutations.OfType().Count()); + Assert.Equal(0, executor.StringCalls); + } + + [Theory] + [MemberData(nameof(ComparisonPeriods))] + public async Task S5029_BindsEveryClosedPeriodLimitAndPreservesTwoSeriesOrder( + ComparisonGraphPeriod period, + int rowLimit) + { + var executor = new QueueExecutor( + ComparisonYieldTable("First", 1000m, rowLimit), + ComparisonYieldTable("Second", 2000m, rowLimit)); + + var data = await new S5029SceneDataLoader(executor).LoadAsync( + PairRequest(period, "First", ComparisonEquityMarket.Kospi, + "Second", ComparisonEquityMarket.Kosdaq)); + var mutations = new S5029SceneMutationBuilder().Build(data); + + Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second }, + data.Series.Select(series => series.Quote.Series)); + Assert.All(data.Series, series => Assert.Equal(rowLimit, series.Points.Count)); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal(rowLimit, call.Spec.Parameters.Single(p => p.Name == "rowLimit").Value); + Assert.Contains(":stockName", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal); + }); + Assert.Contains("t_candle_history", executor.Calls[0].Spec.Sql, StringComparison.Ordinal); + Assert.Contains("t_kosdaq_candle_history", executor.Calls[1].Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("First", executor.Calls[0].Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("Second", executor.Calls[1].Spec.Sql, StringComparison.Ordinal); + Assert.Equal(4, mutations.OfType().Count()); + AssertFinite(mutations); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S5087_IsFiveDayOnlyAndUsesItsLoadedDtoForDistinctGraphMutations() + { + var validExecutor = new QueueExecutor( + ComparisonYieldTable("First", 1000m, 5), + ComparisonYieldTable("Second", 2000m, 5)); + var request = PairRequest( + ComparisonGraphPeriod.FiveDays, + "First", + ComparisonEquityMarket.Kospi, + "Second", + ComparisonEquityMarket.Kosdaq); + + var data = await new S5087SceneDataLoader(validExecutor).LoadAsync(request); + var mutations = new S5087SceneMutationBuilder().Build(data); + + Assert.Equal(2, validExecutor.Calls.Count); + Assert.Contains( + mutations.OfType(), + item => item.ObjectName == "plusGraph1" && item.Points[^1].X == 700f); + AssertFinite(mutations); + + var invalidExecutor = new QueueExecutor(ComparisonYieldTable("unused", 1m, 5)); + await Assert.ThrowsAsync(() => + new S5087SceneDataLoader(invalidExecutor).LoadAsync( + request with { Period = ComparisonGraphPeriod.OneMonth })); + Assert.Empty(invalidExecutor.Calls); + } + + [Fact] + public async Task ComparisonLoadersFailClosedForBadSchemaCountOrderQuoteAndValues() + { + var extraColumn = CandleTable("First", 1000m, "2"); + extraColumn.Columns.Add("EXTRA", typeof(object)); + + var wrongOrder = CandleTable("First", 1000m, "2"); + wrongOrder.Rows[1]["OPEN_DAY"] = wrongOrder.Rows[0]["OPEN_DAY"]; + + var changedQuote = CandleTable("First", 1000m, "2"); + changedQuote.Rows[1]["CURRENT_PRICE"] = 9999m; + + var nonFinite = CandleTable("First", 1000m, "2"); + nonFinite.Rows[0]["HIGH_VALUE"] = double.NaN; + var badDirection = CandleTable("First", 1000m, "sideways"); + + foreach (var invalid in new[] + { + CandleTable("First", 1000m, "2", rowCount: 4), + extraColumn, + wrongOrder, + changedQuote, + nonFinite, + badDirection + }) + { + var executor = new QueueExecutor(invalid, CandleTable("Second", 2000m, "2")); + await Assert.ThrowsAsync(() => + new S5026SceneDataLoader(executor).LoadAsync( + PairRequest( + ComparisonGraphPeriod.FiveDays, + "First", + ComparisonEquityMarket.Kospi, + "Second", + ComparisonEquityMarket.Kosdaq))); + } + } + + [Fact] + public async Task ComparisonYieldRejectsFractionalIntegerPointsAndDescendingOrderViolations() + { + var fractional = ComparisonYieldTable("First", 1000m, 5); + fractional.Rows[0]["VALUE"] = 1000.5m; + var duplicateDate = ComparisonYieldTable("First", 1000m, 5); + duplicateDate.Rows[1]["DATA_DAY"] = duplicateDate.Rows[0]["DATA_DAY"]; + + foreach (var invalid in new[] + { + ComparisonYieldTable("First", 1000m, 4), + fractional, + duplicateDate + }) + { + var executor = new QueueExecutor(invalid, ComparisonYieldTable("Second", 2000m, 5)); + await Assert.ThrowsAsync(() => + new S5029SceneDataLoader(executor).LoadAsync( + PairRequest( + ComparisonGraphPeriod.FiveDays, + "First", + ComparisonEquityMarket.Kospi, + "Second", + ComparisonEquityMarket.Kosdaq))); + } + } + + [Theory] + [InlineData(YieldGraphInstrumentKind.WonDollar, null, "t_week_index", "A09")] + [InlineData(YieldGraphInstrumentKind.WonYen, null, "t_week_index", "A10")] + [InlineData(YieldGraphInstrumentKind.WonYuan, null, "t_week_index", "A90")] + [InlineData(YieldGraphInstrumentKind.WonEuro, null, "t_week_index", "A91")] + [InlineData(YieldGraphInstrumentKind.KospiIndex, null, "t_index_his_day", null)] + [InlineData(YieldGraphInstrumentKind.KosdaqIndex, null, "t_kosdaq_index_his_day", null)] + [InlineData(YieldGraphInstrumentKind.Kospi200Index, null, "t_200_index_his_day", null)] + [InlineData(YieldGraphInstrumentKind.Krx100Index, null, "t_krx100_index_his_day", null)] + [InlineData(YieldGraphInstrumentKind.KospiIndustry, "Semiconductor", "t_part", null)] + [InlineData(YieldGraphInstrumentKind.KosdaqIndustry, "Software", "t_kosdaq_part", null)] + [InlineData(YieldGraphInstrumentKind.KospiStock, "Stock A", "t_candle_history", null)] + [InlineData(YieldGraphInstrumentKind.KosdaqStock, "Stock B", "t_kosdaq_candle_history", null)] + public async Task S5086_EveryClosedInstrumentUsesAParameterizedOracleQueryAndBuildsMutations( + YieldGraphInstrumentKind kind, + string? name, + string queryEvidence, + string? inputCode) + { + var executor = new QueueExecutor(SingleYieldTable(5, _ => 100d)); + var instrument = new YieldGraphInstrument(kind, name); + + var data = await new S5086SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest(YieldGraphPeriod.FiveDays, instrument)); + var mutations = new S5086SceneMutationBuilder().Build(data); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("SCENE_5086_YIELD", call.TableName); + Assert.Contains(queryEvidence, call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(5, call.Spec.Parameters.Single(parameter => parameter.Name == "rowLimit").Value); + if (name is not null) + { + Assert.DoesNotContain(name, call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(name, call.Spec.Parameters.Single( + parameter => parameter.Name == "instrumentName").Value); + } + + if (inputCode is not null) + { + Assert.DoesNotContain(inputCode, call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(inputCode, call.Spec.Parameters.Single( + parameter => parameter.Name == "inputCode").Value); + } + + if (kind is YieldGraphInstrumentKind.KospiStock or YieldGraphInstrumentKind.KosdaqStock) + { + Assert.Contains("LEFT JOIN STOCK_HISTORY", call.Spec.Sql, StringComparison.Ordinal); + } + + if (kind is YieldGraphInstrumentKind.KospiIndustry or YieldGraphInstrumentKind.KosdaqIndustry) + { + Assert.Contains("open_day || '1535'", call.Spec.Sql, StringComparison.Ordinal); + } + + Assert.Same(instrument, data.Instrument); + Assert.Equal(5, data.Points.Count); + Assert.Contains(mutations.OfType(), item => item.ObjectName == "price"); + Assert.Equal(2, mutations.OfType().Count()); + AssertFinite(mutations); + Assert.Equal(0, executor.StringCalls); + } + + [Theory] + [MemberData(nameof(YieldPeriods))] + public async Task S5086_BindsExactFiveTwentySixtyOneTwentyTwoFortyLimits( + YieldGraphPeriod period, + int rowLimit) + { + var executor = new QueueExecutor(SingleYieldTable(rowLimit, _ => 25d)); + + var data = await new S5086SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + period, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar))); + var mutations = new S5086SceneMutationBuilder().Build(data); + + Assert.Equal(rowLimit, data.Points.Count); + var call = Assert.Single(executor.Calls); + Assert.Equal(rowLimit, call.Spec.Parameters.Single( + parameter => parameter.Name == "rowLimit").Value); + AssertFinite(mutations); + } + + [Fact] + public async Task S5086_LaterMissingValuesReachDocumentedForwardFillAndEqualRangeStaysFinite() + { + var values = new double?[] { 100d, null, 100d, 100d, 100d }; + var executor = new QueueExecutor(SingleYieldTable(5, index => values[index])); + + var data = await new S5086SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "Stock"))); + var mutations = new S5086SceneMutationBuilder().Build(data); + + Assert.Null(data.Points[1].Value); + AssertFinite(mutations); + Assert.All( + mutations.OfType().SelectMany(item => item.Points), + point => Assert.Equal(0f, point.Y)); + } + + [Fact] + public async Task S5086_FailsClosedForFirstMissingNonFiniteWrongOrderAndUnexpectedSchema() + { + var firstMissing = SingleYieldTable(5, index => index == 0 ? null : 1d); + var nonFinite = SingleYieldTable(5, index => index == 2 ? double.PositiveInfinity : 1d); + var wrongOrder = SingleYieldTable(5, _ => 1d); + wrongOrder.Rows[1]["DATA_DAY"] = wrongOrder.Rows[0]["DATA_DAY"]; + var extraColumn = SingleYieldTable(5, _ => 1d); + extraColumn.Columns.Add("EXTRA", typeof(object)); + + foreach (var invalid in new[] + { + SingleYieldTable(4, _ => 1d), + firstMissing, + nonFinite, + wrongOrder, + extraColumn + }) + { + var executor = new QueueExecutor(invalid); + await Assert.ThrowsAsync(() => + new S5086SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)))); + } + } + + [Theory] + [InlineData(YieldGraphInstrumentKind.WonDollar, "A09", "+", YieldQuoteDirection.Up, "up")] + [InlineData(YieldGraphInstrumentKind.WonYen, "A10", "", YieldQuoteDirection.Flat, "flat")] + [InlineData(YieldGraphInstrumentKind.WonYuan, "A90", "-", YieldQuoteDirection.Down, "down")] + [InlineData(YieldGraphInstrumentKind.WonEuro, "A91", "2", YieldQuoteDirection.Up, "up")] + public async Task S50860_MapsOnlySupportedFxQuoteShapeAndGoldenDirectionMutations( + YieldGraphInstrumentKind kind, + string inputCode, + string sourceDirection, + YieldQuoteDirection expectedDirection, + string activeObject) + { + var table = QuotedFxTable( + 5, + changePrice: -12.34d, + rate: 1.25d, + direction: sourceDirection); + if (sourceDirection.Length == 0) + { + table.Rows[0]["DIRECTION"] = DBNull.Value; + } + + var executor = new QueueExecutor(table); + + var data = await new S50860SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(kind))); + var mutations = new S50860SceneMutationBuilder().Build(data); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("SCENE_50860_YIELD", call.TableName); + Assert.DoesNotContain(inputCode, call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(inputCode, call.Spec.Parameters.Single( + parameter => parameter.Name == "inputCode").Value); + Assert.Equal(5, call.Spec.Parameters.Single( + parameter => parameter.Name == "rowLimit").Value); + Assert.Equal(expectedDirection, data.Direction); + Assert.Equal(-12.34d, data.ChangePrice); + Assert.Equal(1.25d, data.Rate); + Assert.Contains(new PlayoutSetValue("changePrice", "12.34"), mutations); + Assert.Contains(new PlayoutSetVisible(activeObject, true), mutations); + AssertFinite(mutations); + Assert.Equal(0, executor.StringCalls); + } + + [Theory] + [MemberData(nameof(YieldPeriods))] + public async Task S50860_BindsTheSameClosedPeriodLimitsAsItsFxLineSource( + YieldGraphPeriod period, + int rowLimit) + { + var executor = new QueueExecutor(QuotedFxTable(rowLimit, 1d, 0.1d, "+")); + + var data = await new S50860SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + period, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar))); + + Assert.Equal(rowLimit, data.Points.Count); + Assert.Equal(rowLimit, Assert.Single(executor.Calls).Spec.Parameters.Single( + parameter => parameter.Name == "rowLimit").Value); + } + + [Fact] + public async Task S50860_RejectsSourceBranchesWhoseLegacyCellShapeCannotSupplyQuoteMetadata() + { + var unsupported = Enum.GetValues() + .Where(kind => kind is not ( + YieldGraphInstrumentKind.WonDollar or + YieldGraphInstrumentKind.WonYen or + YieldGraphInstrumentKind.WonYuan or + YieldGraphInstrumentKind.WonEuro)); + + foreach (var kind in unsupported) + { + var name = kind is + YieldGraphInstrumentKind.KospiIndustry or + YieldGraphInstrumentKind.KosdaqIndustry or + YieldGraphInstrumentKind.KospiStock or + YieldGraphInstrumentKind.KosdaqStock + ? "Named" + : null; + var executor = new QueueExecutor(QuotedFxTable(5, 1d, 1d, "+")); + await Assert.ThrowsAsync(() => + new S50860SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(kind, name)))); + Assert.Empty(executor.Calls); + } + } + + [Fact] + public async Task S50860_LoadedZeroRangeAndZeroQuoteStayFinite() + { + var table = QuotedFxTable(5, 0d, 0d, ""); + foreach (DataRow row in table.Rows) + { + row["VALUE"] = 0d; + } + table.Rows[0]["DIRECTION"] = DBNull.Value; + var executor = new QueueExecutor(table); + + var data = await new S50860SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar))); + var mutations = new S50860SceneMutationBuilder().Build(data); + + Assert.Equal(YieldQuoteDirection.Flat, data.Direction); + AssertFinite(mutations); + Assert.All( + mutations.OfType().SelectMany(item => item.Points), + point => Assert.Equal(0f, point.Y)); + } + + [Fact] + public async Task S50860_FailsClosedForBadCountSchemaDateValueQuoteAndDirection() + { + var extraColumn = QuotedFxTable(5, 1d, 1d, "+"); + extraColumn.Columns.Add("EXTRA", typeof(object)); + var duplicateDate = QuotedFxTable(5, 1d, 1d, "+"); + duplicateDate.Rows[1]["DATA_DAY"] = duplicateDate.Rows[0]["DATA_DAY"]; + var nullValue = QuotedFxTable(5, 1d, 1d, "+"); + nullValue.Rows[2]["VALUE"] = DBNull.Value; + var nonFiniteChange = QuotedFxTable(5, 1d, 1d, "+"); + nonFiniteChange.Rows[0]["CHANGE_PRICE"] = double.NaN; + var nonFiniteRate = QuotedFxTable(5, 1d, 1d, "+"); + nonFiniteRate.Rows[0]["RATE"] = double.PositiveInfinity; + var badDirection = QuotedFxTable(5, 1d, 1d, "sideways"); + + foreach (var invalid in new[] + { + QuotedFxTable(4, 1d, 1d, "+"), + extraColumn, + duplicateDate, + nullValue, + nonFiniteChange, + nonFiniteRate, + badDirection + }) + { + var executor = new QueueExecutor(invalid); + await Assert.ThrowsAsync(() => + new S50860SceneDataLoader(executor).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)))); + } + } + + [Fact] + public async Task InvalidClosedRequestsFailBeforeExecutingSql() + { + var invalidPairRequests = new[] + { + PairRequest( + (ComparisonGraphPeriod)99, + "First", + ComparisonEquityMarket.Kospi, + "Second", + ComparisonEquityMarket.Kosdaq), + PairRequest( + ComparisonGraphPeriod.FiveDays, + "First", + (ComparisonEquityMarket)99, + "Second", + ComparisonEquityMarket.Kosdaq), + new ComparisonPairSceneLoadRequest( + ComparisonGraphPeriod.FiveDays, + null!, + new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second")) + }; + foreach (var request in invalidPairRequests) + { + var executor = new QueueExecutor(ComparisonYieldTable("unused", 1m, 5)); + await Assert.ThrowsAsync(() => + new S5029SceneDataLoader(executor).LoadAsync(request)); + Assert.Empty(executor.Calls); + } + + var invalidYieldRequests = new[] + { + new YieldSceneLoadRequest( + (YieldGraphPeriod)99, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)), + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument((YieldGraphInstrumentKind)99)), + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock)), + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar, "ignored")) + }; + foreach (var request in invalidYieldRequests) + { + var executor = new QueueExecutor(SingleYieldTable(5, _ => 1d)); + await Assert.ThrowsAsync(() => + new S5086SceneDataLoader(executor).LoadAsync(request)); + Assert.Empty(executor.Calls); + } + } + + private static ComparisonPairSceneLoadRequest PairRequest( + ComparisonGraphPeriod period, + string firstName, + ComparisonEquityMarket firstMarket, + string secondName, + ComparisonEquityMarket secondMarket) => new( + period, + new ComparisonStockSelection(firstMarket, firstName), + new ComparisonStockSelection(secondMarket, secondName)); + + private static DataTable CandleTable( + string name, + decimal currentPrice, + string direction, + int rowCount = 5) + { + var table = Table( + "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION", + "OPEN_DAY", "HIGH_VALUE", "LOW_VALUE", "OPEN_VALUE", "CLOSE_VALUE"); + var start = new DateOnly(2026, 1, 1); + for (var index = 0; index < rowCount; index++) + { + var center = currentPrice + index; + table.Rows.Add( + name, + currentPrice, + 10m, + direction is "4" or "5" ? -1.25d : 1.25d, + direction, + start.AddDays(index).ToString("yyyyMMdd"), + center + 10m, + center - 10m, + center - 5m, + center + 5m); + } + + return table; + } + + private static DataTable ComparisonYieldTable( + string name, + decimal currentPrice, + int rowCount) + { + var table = Table( + "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION", + "DATA_DAY", "VALUE"); + var latest = new DateOnly(2026, 12, 31); + for (var index = 0; index < rowCount; index++) + { + table.Rows.Add( + name, + currentPrice, + 10m, + 1.25d, + "2", + latest.AddDays(-index).ToString("yyyyMMdd"), + checked((int)currentPrice) - index); + } + + return table; + } + + private static DataTable SingleYieldTable( + int rowCount, + Func valueFactory) + { + var table = Table("DATA_DAY", "VALUE"); + var latest = new DateOnly(2026, 12, 31); + for (var index = 0; index < rowCount; index++) + { + table.Rows.Add( + latest.AddDays(-index).ToString("yyyyMMdd"), + valueFactory(index) is { } value ? value : DBNull.Value); + } + + return table; + } + + private static DataTable QuotedFxTable( + int rowCount, + double changePrice, + double rate, + string direction) + { + var table = Table("DATA_DAY", "VALUE", "CHANGE_PRICE", "RATE", "DIRECTION"); + var latest = new DateOnly(2026, 12, 31); + for (var index = 0; index < rowCount; index++) + { + table.Rows.Add( + latest.AddDays(-index).ToString("yyyyMMdd"), + 1300d - index, + changePrice, + rate, + direction); + } + + return table; + } + + private static DataTable Table(params string[] columns) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + return table; + } + + private static void AssertFinite(IEnumerable mutations) + { + foreach (var position in mutations.OfType()) + { + Assert.True(float.IsFinite(position.X)); + Assert.True(float.IsFinite(position.Y)); + Assert.True(float.IsFinite(position.Z)); + } + + foreach (var crop in mutations.OfType()) + { + Assert.True(float.IsFinite(crop.Left)); + Assert.True(float.IsFinite(crop.Top)); + Assert.True(float.IsFinite(crop.Right)); + Assert.True(float.IsFinite(crop.Bottom)); + } + + foreach (var point in mutations + .OfType() + .SelectMany(item => item.Points)) + { + Assert.True(float.IsFinite(point.X)); + Assert.True(float.IsFinite(point.Y)); + Assert.True(float.IsFinite(point.Z)); + } + } + + private sealed record QueryCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec); + + private sealed class QueueExecutor : IDataQueryExecutor + { + private readonly Queue _results; + + public QueueExecutor(params DataTable[] results) + { + _results = new Queue(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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("The test did not queue a result."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQuerySpecTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQuerySpecTests.cs new file mode 100644 index 0000000..e4729d2 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/DataQuerySpecTests.cs @@ -0,0 +1,236 @@ +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class DataQuerySpecTests +{ + [Fact] + public void Constructor_ValidatesAndOrdersParametersBySqlPlaceholder() + { + var code = new DataQueryParameter("code", "005930", DbType.String); + var enabled = new DataQueryParameter("enabled", true, DbType.Boolean); + + var query = new DataQuerySpec( + "SELECT name FROM stocks WHERE enabled = :enabled AND code = :code", + [code, enabled]); + + Assert.Equal([enabled, code], query.Parameters); + Assert.Equal("Parameterized read query", query.ToString()); + Assert.Equal("Data query parameter", code.ToString()); + query.ValidateFor(DataSourceKind.Oracle); + } + + [Fact] + public void Constructor_AllowsMarkersInsideQuotedTextAndComments() + { + var query = new DataQuerySpec( + """ + -- @ignored + SELECT ':also_ignored', "@quoted_identifier", value + FROM sample /* :ignored_again */ + WHERE code = :code + """, + [new DataQueryParameter("code", 7, DbType.Int32)]); + + query.ValidateFor(DataSourceKind.Oracle); + Assert.Single(query.Parameters); + } + + [Fact] + public void Constructor_AllowsSemicolonsAndKeywordsOnlyWhenTheyAreQuotedOrCommented() + { + var query = new DataQuerySpec( + """ + SELECT 'it''s; UPDATE hidden', value /* ; DELETE hidden */ + FROM sample + WHERE code = :code -- ; REPLACE hidden + """, + [new DataQueryParameter("code", 7, DbType.Int32)]); + + query.ValidateFor(DataSourceKind.Oracle); + Assert.Single(query.Parameters); + } + + [Theory] + [InlineData("")] + [InlineData("has-dash")] + [InlineData("@prefixed")] + [InlineData(":prefixed")] + [InlineData("한글")] + [InlineData("9starts_with_digit")] + public void Parameter_RejectsUnsafeNameWithoutEchoingIt(string unsafeName) + { + var exception = Assert.Throws( + () => new DataQueryParameter(unsafeName, "value-sensitive-sentinel")); + + if (unsafeName.Length > 0) + { + Assert.DoesNotContain(unsafeName, exception.Message, StringComparison.Ordinal); + } + Assert.DoesNotContain("value-sensitive-sentinel", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("SELECT value INTO OUTFILE '/tmp/x' FROM sample")] + [InlineData("SELECT value FROM sample;")] + [InlineData("SELECT 1--1 INTO OUTFILE '/tmp/x'")] + [InlineData("SELECT 1--\u0085 INTO OUTFILE '/tmp/x'")] + [InlineData("SELECT 1 /*! INTO OUTFILE '/tmp/x' */")] + [InlineData("SELECT 1 /*M! INTO OUTFILE '/tmp/x' */")] + [InlineData("SELECT q'[; DELETE FROM sample]' FROM dual")] + [InlineData("SELECT 'c:\\temp' FROM dual")] + [InlineData("SELECT 1\u200B")] + [InlineData("SELECT 1\v")] + [InlineData("SELECT ?")] + [InlineData("SELECT 1 # provider-specific comment")] + [InlineData("WITH value AS (SELECT 1) REPLACE INTO sample VALUES (1)")] + [InlineData("WITH FUNCTION f RETURN NUMBER IS BEGIN RETURN 1; END; SELECT f FROM dual")] + [InlineData("SELECT 'unterminated FROM sample")] + [InlineData("SELECT value FROM sample /* unterminated")] + public void Constructor_RejectsUnsafeSqlWithoutEchoingIt(string unsafeSql) + { + var exception = Assert.Throws( + () => new DataQuerySpec(unsafeSql)); + + Assert.DoesNotContain(unsafeSql, exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("UPDATE sample SET value = :code")] + [InlineData("SELECT value FROM sample WHERE code = :missing")] + [InlineData("SELECT value FROM sample WHERE code = :code OR parent = :code")] + [InlineData("SELECT value FROM sample WHERE code = :code$tail")] + [InlineData("SELECT value FROM sample WHERE code = prefix:code")] + [InlineData("SELECT value FROM sample WHERE code = :code\u0301")] + [InlineData("SELECT value FROM sample WHERE code = :code\u00A0")] + public void Constructor_RejectsUnsafeParameterizedSqlWithoutEchoingSecrets(string unsafeSql) + { + const string parameterName = "code"; + const string parameterValue = "value-sensitive-sentinel"; + + var exception = Assert.Throws( + () => new DataQuerySpec( + unsafeSql, + [new DataQueryParameter(parameterName, parameterValue)])); + + Assert.DoesNotContain(parameterName, exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(parameterValue, exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(unsafeSql, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Constructor_RejectsDuplicateSuppliedNamesCaseInsensitivelyWithoutEchoingValues() + { + const string firstValue = "first-value-sensitive-sentinel"; + const string secondValue = "second-value-sensitive-sentinel"; + + var exception = Assert.Throws( + () => new DataQuerySpec( + "SELECT value FROM sample WHERE code = :code", + [ + new DataQueryParameter("code", firstValue), + new DataQueryParameter("CODE", secondValue) + ])); + + Assert.DoesNotContain(firstValue, exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(secondValue, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateFor_RejectsMixedProviderMarkers() + { + var query = new DataQuerySpec( + "SELECT value FROM sample WHERE first = :first AND second = @second", + [ + new DataQueryParameter("first", 1), + new DataQueryParameter("second", 2) + ]); + + Assert.Throws(() => query.ValidateFor(DataSourceKind.Oracle)); + Assert.Throws(() => query.ValidateFor(DataSourceKind.MariaDb)); + } + + [Fact] + public void ValidateFor_RejectsWrongProviderMarkerWithoutExposingParameterName() + { + const string sensitiveName = "account_secret_name"; + var query = new DataQuerySpec( + $"SELECT value FROM sample WHERE account = :{sensitiveName}", + [new DataQueryParameter(sensitiveName, "value-sensitive-sentinel")]); + + var exception = Assert.Throws( + () => query.ValidateFor(DataSourceKind.MariaDb)); + + Assert.DoesNotContain(sensitiveName, exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain("value-sensitive-sentinel", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task LegacyExecutor_DefaultSpecOverload_ForwardsEmptySpecAsynchronously() + { + var executor = new LegacyOnlyExecutor(); + var query = new DataQuerySpec("SELECT 1"); + using var cancellation = new CancellationTokenSource(); + + await ((IDataQueryExecutor)executor).ExecuteAsync( + DataSourceKind.MariaDb, + "Health", + query, + cancellation.Token); + + Assert.Equal(0, executor.SynchronousCallCount); + Assert.Equal(1, executor.AsynchronousCallCount); + Assert.Equal("SELECT 1", executor.ObservedSql); + Assert.Equal(cancellation.Token, executor.ObservedCancellationToken); + } + + [Fact] + public async Task LegacyExecutor_DefaultSpecOverload_FailsClosedForParameters() + { + var executor = new LegacyOnlyExecutor(); + var query = new DataQuerySpec( + "SELECT value FROM sample WHERE code = @code", + [new DataQueryParameter("code", "value-sensitive-sentinel")]); + + var exception = await Assert.ThrowsAsync( + () => ((IDataQueryExecutor)executor).ExecuteAsync( + DataSourceKind.MariaDb, + "Sample", + query)); + + Assert.Equal(0, executor.SynchronousCallCount); + Assert.Equal(0, executor.AsynchronousCallCount); + Assert.DoesNotContain("code", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("value-sensitive-sentinel", exception.Message, StringComparison.Ordinal); + } + + private sealed class LegacyOnlyExecutor : IDataQueryExecutor + { + public int SynchronousCallCount { get; private set; } + + public int AsynchronousCallCount { get; private set; } + + public string? ObservedSql { get; private set; } + + public CancellationToken ObservedCancellationToken { get; private set; } + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + SynchronousCallCount++; + throw new InvalidOperationException("The synchronous path must not be used."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + AsynchronousCallCount++; + ObservedSql = query; + ObservedCancellationToken = cancellationToken; + return Task.FromResult(new DataTable()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/EquityPanelSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/EquityPanelSceneDataLoadersTests.cs new file mode 100644 index 0000000..2927711 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/EquityPanelSceneDataLoadersTests.cs @@ -0,0 +1,286 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class EquityPanelSceneDataLoadersTests +{ + private static readonly string[] S5006Columns = + [ + "STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", + "OPEN_PRICE", "OPEN_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE" + ]; + + private static readonly string[] S5011Columns = + [ + "STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", + "DETAIL_1", "DETAIL_2", "DETAIL_3", "DETAIL_4" + ]; + + public static TheoryData Domestic5006Cases => new() + { + { LegacyDomesticEquityMarket.Kospi, "FROM V_2 a" }, + { LegacyDomesticEquityMarket.Kosdaq, "FROM V_2_KOSDAQ a" } + }; + + public static TheoryData S5011Cases => new() + { + new(LegacyDomesticEquityMarket.Kospi, S5011DetailBranch.FaceValue, + DataSourceKind.Oracle, "FROM V_2 a", "t_online1"), + new(LegacyDomesticEquityMarket.Kosdaq, S5011DetailBranch.FaceValue, + DataSourceKind.Oracle, "FROM V_2_KOSDAQ a", "t_kosdaq_online1"), + new(LegacyDomesticEquityMarket.Kospi, S5011DetailBranch.Valuation, + DataSourceKind.Oracle, "FROM V_2 a", "a.pbr DETAIL_1"), + new(LegacyDomesticEquityMarket.Kosdaq, S5011DetailBranch.Valuation, + DataSourceKind.Oracle, "FROM V_2_KOSDAQ a", "a.pbr DETAIL_1"), + new(LegacyDomesticEquityMarket.Kospi, S5011DetailBranch.Volume, + DataSourceKind.Oracle, "FROM V_2 a", "T_CANDLE_HISTORY"), + new(LegacyDomesticEquityMarket.Kosdaq, S5011DetailBranch.Volume, + DataSourceKind.Oracle, "FROM V_2_KOSDAQ a", "T_KOSDAQ_CANDLE_HISTORY"), + new(LegacyDomesticEquityMarket.NxtKospi, S5011DetailBranch.FaceValue, + DataSourceKind.MariaDb, "FROM n_online a", "RANK() OVER"), + new(LegacyDomesticEquityMarket.NxtKosdaq, S5011DetailBranch.FaceValue, + DataSourceKind.MariaDb, "FROM n_kosdaq_online a", "RANK() OVER"), + new(LegacyDomesticEquityMarket.NxtKospi, S5011DetailBranch.Volume, + DataSourceKind.MariaDb, "FROM n_online a", "0 DETAIL_3"), + new(LegacyDomesticEquityMarket.NxtKosdaq, S5011DetailBranch.Volume, + DataSourceKind.MariaDb, "FROM n_kosdaq_online a", "0 DETAIL_3") + }; + + [Theory] + [MemberData(nameof(Domestic5006Cases))] + public async Task S5006_domestic_routes_oracle_by_closed_market_and_generates_mutations( + LegacyDomesticEquityMarket market, + string sourceFragment) + { + const string stockCode = "A005930"; + var executor = new RecordingExecutor(S5006Table()); + + var data = await new S5006DomesticSceneDataLoader(executor).LoadAsync( + new S5006DomesticSceneDataRequest(market, stockCode), + CancellationToken.None); + var mutations = new S5006SceneMutationBuilder().Build(data); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("S5006_DOMESTIC", call.TableName); + Assert.Contains(sourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(stockCode, call.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("stockCode", parameter.Name); + Assert.Equal(stockCode, parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + Assert.Equal(ScenePriceDirection.Up, data.Direction); + Assert.Equal(1000, data.CurrentPrice); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "price", Value: "1,000" }); + Assert.Equal(0, executor.StringCalls); + } + + [Theory] + [MemberData(nameof(S5011Cases))] + public async Task S5011_supported_source_and_branch_matrix_is_parameterized_and_typed( + S5011RouteCase item) + { + const string stockCode = "A123456"; + var executor = new RecordingExecutor(S5011Table(item.Branch)); + + var data = await new S5011SceneDataLoader(executor).LoadAsync( + new S5011SceneDataRequest(item.Market, item.Branch, stockCode), + CancellationToken.None); + + var call = Assert.Single(executor.Calls); + Assert.Equal(item.Source, call.Source); + Assert.Equal("S5011_DETAIL", call.TableName); + call.Spec.ValidateFor(item.Source); + Assert.Contains(item.SourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains(item.BranchFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(stockCode, call.Spec.Sql, StringComparison.Ordinal); + Assert.All(call.Spec.Parameters, parameter => + { + Assert.Equal(stockCode, parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }); + Assert.Equal(item.Branch, data.Detail.Branch); + Assert.Equal(item.Market, data.Market); + Assert.Equal(0, executor.StringCalls); + + var mutations = new S5011SceneMutationBuilder().Build(data); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetVisible + { + ObjectName: "mart", + IsVisible: var visible + } && visible == item.Market.ToString().StartsWith("Nxt", StringComparison.Ordinal)); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "r_value4" }); + + if (item.Branch == S5011DetailBranch.Volume && item.Source == DataSourceKind.Oracle) + { + Assert.Equal( + ["stockCode5", "stockCode20", "stockCode"], + call.Spec.Parameters.Select(parameter => parameter.Name)); + } + else + { + Assert.Single(call.Spec.Parameters); + } + } + + [Theory] + [InlineData(LegacyDomesticEquityMarket.NxtKospi)] + [InlineData(LegacyDomesticEquityMarket.NxtKosdaq)] + public async Task S5011_nxt_valuation_fails_closed_instead_of_fabricating_zeroes( + LegacyDomesticEquityMarket market) + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S5011SceneDataLoader(executor).LoadAsync( + new S5011SceneDataRequest( + market, + S5011DetailBranch.Valuation, + "005930"), + CancellationToken.None)); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData(LegacyDomesticEquityMarket.NxtKospi, "005930")] + [InlineData(LegacyDomesticEquityMarket.NxtKosdaq, "005930")] + [InlineData(LegacyDomesticEquityMarket.Kospi, "bad-code")] + [InlineData(LegacyDomesticEquityMarket.Kosdaq, "")] + public async Task S5006_invalid_market_or_code_fails_before_query( + LegacyDomesticEquityMarket market, + string code) + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S5006DomesticSceneDataLoader(executor).LoadAsync( + new S5006DomesticSceneDataRequest(market, code), + CancellationToken.None)); + + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task Equity_loaders_require_exact_single_row_schema_and_do_not_leak_code() + { + const string stockCode = "SENSITIVE123"; + var reordered = Table( + [ + "DIRECTION_CODE", "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", + "OPEN_PRICE", "OPEN_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE" + ], + ["2", "Stock", 1000, 10, 1, 900, -1, 1100, 10, 800, -20]); + var exception = await Assert.ThrowsAsync(() => + new S5006DomesticSceneDataLoader(new RecordingExecutor(reordered)).LoadAsync( + new S5006DomesticSceneDataRequest( + LegacyDomesticEquityMarket.Kospi, + stockCode), + CancellationToken.None)); + Assert.DoesNotContain(stockCode, exception.Message, StringComparison.Ordinal); + + var duplicate = S5011Table(S5011DetailBranch.FaceValue); + duplicate.Rows.Add(duplicate.Rows[0].ItemArray); + await Assert.ThrowsAsync(() => + new S5011SceneDataLoader(new RecordingExecutor(duplicate)).LoadAsync( + new S5011SceneDataRequest( + LegacyDomesticEquityMarket.Kospi, + S5011DetailBranch.FaceValue, + stockCode), + CancellationToken.None)); + } + + private static DataTable S5006Table() => Table( + S5006Columns, + ["Stock", "2", 1000m, 50m, 5.26m, 960m, 1m, 1100m, 10m, 900m, -10m]); + + private static DataTable S5011Table(S5011DetailBranch branch) + { + object?[] details = branch switch + { + S5011DetailBranch.FaceValue => [5000m, 100_000_000m, 900_000_000m, 7m], + S5011DetailBranch.Valuation => [1.2m, 8.75m, 12_345m, -321m], + S5011DetailBranch.Volume => [12_345m, 9_876_543m, 1_111m, 2_222m], + _ => throw new InvalidOperationException() + }; + return Table( + S5011Columns, + ["Stock(NXT)", "2", 1000m, 50m, 5.26m, .. details]); + } + + private static DataTable Table(string[] columns, object?[]? row = null) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + if (row is not null) + { + table.Rows.Add(row); + } + + return table; + } + + public sealed record S5011RouteCase( + LegacyDomesticEquityMarket Market, + S5011DetailBranch Branch, + DataSourceKind Source, + string SourceFragment, + string BranchFragment); + + 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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No result was configured."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/FoundationalSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/FoundationalSceneBuildersTests.cs new file mode 100644 index 0000000..3744a36 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/FoundationalSceneBuildersTests.cs @@ -0,0 +1,176 @@ +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class FoundationalSceneBuildersTests +{ + [Fact] + public void S5076_matches_legacy_value_visibility_and_circle_order() + { + var data = new S5076SceneData( + "테스트", + "2026.07", + [ + new RevenueSliceData("국내", "25.0", 25), + new RevenueSliceData("해외", "75.0", 75), + null, + null, + null + ]); + + var actual = new S5076SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=테스트 주요매출 구성", + "V:date=2026.07 기준", + "S:circle1=0", "S:group1=0", + "S:circle2=0", "S:group2=0", + "S:circle3=0", "S:group3=0", + "S:circle4=0", "S:group4=0", + "S:circle5=0", "S:group5=0", + "S:circle1=1", "S:group1=1", + "V:word1=국내", "V:percent1=25.0", + "A:circle1:0:0:0:3", "A:circle1:1:0:90:3", + "S:circle2=1", "S:group2=1", + "V:word2=해외", "V:percent2=75.0", + "A:circle2:0:90:90:3", "A:circle2:1:90:360:3" + ], actual); + } + + [Fact] + public void S5081_matches_legacy_mixed_profit_sequence_and_scaling() + { + var data = new S5081SceneData( + "A사", + [ + new("1Q", 100), new("2Q", -50), new("3Q", 25), + new("4Q", 0), new("5Q", 50), new("6Q", -25) + ]); + + var actual = new S5081SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=A사 영업이익", + "V:quarter1=1Q", "V:quarter2=2Q", "V:quarter3=3Q", + "V:quarter4=4Q", "V:quarter5=5Q", "V:quarter6=6Q", + "S:barG1_1=0", "S:barG1_2=0", "S:barG1_1=1", "V:value1_1=100", + "S:barG2_1=0", "S:barG2_2=0", "S:barG2_2=1", "V:value2_2=-50", + "S:barG3_1=0", "S:barG3_2=0", "S:barG3_1=1", "V:value3_1=25", + "S:barG4_1=0", "S:barG4_2=0", "S:barG4_1=1", "V:value4_1=0", + "S:barG5_1=0", "S:barG5_2=0", "S:barG5_1=1", "V:value5_1=50", + "S:barG6_1=0", "S:barG6_2=0", "S:barG6_2=1", "V:value6_2=-25", + "K:centerbar:0:0:-131.66667:0:2", + "Z:bar1_1:0:1.2:0:2", "Z:bar2_2:0:0.6:0:2", + "Z:bar3_1:0:0.3:0:2", "Z:bar4_1:0:0:0:2", + "Z:bar5_1:0:0.6:0:2", "Z:bar6_2:0:0.3:0:2" + ], actual); + } + + [Fact] + public void S5081_all_zero_data_is_finite_and_does_not_repeat_a_failed_sdk_call() + { + var data = new S5081SceneData( + "A사", + Enumerable.Range(1, 6).Select(index => new QuarterlyProfitData($"{index}Q", 0)).ToArray()); + + var actual = new S5081SceneMutationBuilder().Build(data); + + Assert.DoesNotContain(actual.Select(Describe), value => value.Contains("NaN", StringComparison.Ordinal)); + Assert.Equal("K:centerbar:0:0:-250:0:2", Describe(actual[31])); + Assert.All(actual.Skip(32), mutation => Assert.Contains(":0:0:2", Describe(mutation))); + } + + [Fact] + public void S5085_maps_typed_metrics_to_the_original_objects_and_colors() + { + var actual = new S5085SceneMutationBuilder().Build(new S5085SceneData( + [ + new(ProgramTradingMetric.NetBuy, 1234.5), + new(ProgramTradingMetric.Basis, -2.25), + new(ProgramTradingMetric.Futures, 0) + ])).Select(Describe); + + Assert.Equal( + [ + "V:title1=순매수", "C:price1:171:0:0:255", "V:price1=1,234.5", + "V:title4=베이시스", "C:price4:15:99:188:255", "V:price4=-2.25", + "V:title6=선물", "C:price6:47:47:47:255", "V:price6=0" + ], actual); + } + + [Fact] + public void S6067_matches_legacy_market_and_institution_slot_mapping() + { + var actual = new S6067SceneMutationBuilder().Build(new S6067SceneData( + [ + new(InstitutionMarket.Kospi, InstitutionKind.Securities, 1000), + new(InstitutionMarket.Kosdaq, InstitutionKind.Fund, -327), + new(InstitutionMarket.Other, InstitutionKind.Bank, 0) + ])).Select(Describe); + + Assert.Equal( + [ + "C:row1_value1:171:0:0:255", "V:row1_value1=1,000", + "C:row2_value5:15:99:188:255", "V:row2_value5=-327", + "C:row3_value4:47:47:47:255", "V:row3_value4=0" + ], actual); + } + + [Fact] + public void S8067_matches_legacy_world_map_order_including_flat_background_target() + { + var actual = new S8067SceneMutationBuilder().Build(new S8067SceneData( + [ + new(WorldMarket.Korea, 2765.1234, 12.5, 1.23, SceneMarketTrend.Up), + new(WorldMarket.Nasdaq, 18000, -0.0, -0.0, SceneMarketTrend.Down) + ])).Select(Describe); + + Assert.Equal( + [ + "V:price_한국=2,765.123", "V:rate_한국=1.23", + "S:up_한국=0", "S:down_한국=0", "S:flat_한국=0", + "C:bg_한국:170:0:0:255", "S:up_한국=1", + "V:price_나스닥=18,000", "V:rate_나스닥=-0.00", + "S:up_나스닥=0", "S:down_나스닥=0", "S:flat_나스닥=0", + "C:bg:191:191:191:255", "S:flat_나스닥=1" + ], actual); + } + + [Fact] + public void S8086_matches_legacy_quote_values_and_color_order() + { + var actual = new S8086SceneMutationBuilder().Build(new S8086SceneData( + "WTI 유가", + CommodityUnit.DollarsPerBarrel, + 78.2, + -1.1, + -1.39, + SceneMarketTrend.Down)).Select(Describe); + + Assert.Equal( + [ + "V:unit=단위: 달러/배럴", "V:title=WTI 유가", + "V:price=78.20", "V:changePrice=-1.10", "V:rate=-1.39", + "C:bg:14:100:188:255", "C:changePrice:14:100:188:255", + "C:rate:14:100:188:255" + ], actual); + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + PlayoutSetFaceColor color => $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}", + PlayoutSetPositionKey key => FormattableString.Invariant( + $"K:{key.ObjectName}:{key.KeyIndex}:{key.X}:{key.Y}:{key.Z}:{(int)key.Components}"), + PlayoutSetScale scale => FormattableString.Invariant( + $"Z:{scale.ObjectName}:{scale.X}:{scale.Y}:{scale.Z}:{(int)scale.Components}"), + PlayoutSetCircleAngleKey angle => FormattableString.Invariant( + $"A:{angle.ObjectName}:{angle.KeyIndex}:{angle.Start}:{angle.End}:{(int)angle.Components}"), + _ => mutation.GetType().Name + }; +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/GridMarketSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/GridMarketSceneDataLoadersTests.cs new file mode 100644 index 0000000..325f0e9 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/GridMarketSceneDataLoadersTests.cs @@ -0,0 +1,351 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class GridMarketSceneDataLoadersTests +{ + private static readonly string[] DailyColumns = + ["TRADING_DATE", "INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"]; + private static readonly string[] TotalColumns = + ["INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"]; + private static readonly string[] ParticipantColumns = ["PARTICIPANT_CODE", "AMOUNT"]; + private static readonly string[] MetricColumns = ["METRIC_CODE", "METRIC_VALUE"]; + private static readonly string[] InstitutionColumns = + ["MARKET_CODE", "INSTITUTION_CODE", "AMOUNT"]; + + [Fact] + public async Task S5023_loads_exact_daily_and_monthly_tables_then_proves_mutations() + { + var executor = new RecordingExecutor(DailyTable(), TotalTable()); + + var data = await new S5023SceneDataLoader(executor).LoadAsync( + new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi), + CancellationToken.None); + var mutations = new S5023SceneMutationBuilder().Build(data); + + Assert.Equal(2, executor.Calls.Count); + Assert.Equal(["SCENE_5023_DAILY", "SCENE_5023_MONTHLY"], + executor.Calls.Select(call => call.TableName)); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Empty(call.Spec.Parameters); + Assert.Contains("t_invest", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("t_kosdaq_invest", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + }); + Assert.Equal(new DateOnly(2026, 7, 10), data.DailyRows[0]!.TradingDate); + Assert.Equal(-20, data.DailyRows[1]!.Amounts.Foreign); + Assert.Equal(new InvestorFlowAmounts(1500, -800, 200), data.MonthlyTotal); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "row1_value1", Value: "07/10" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "row6_value2", Value: "-800" }); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S5023_kosdaq_route_is_closed_and_never_uses_a_selector_parameter() + { + var executor = new RecordingExecutor(DailyTable(), TotalTable()); + + var data = await new S5023SceneDataLoader(executor).LoadAsync( + new S5023SceneLoadRequest(LegacyDomesticMarket.Kosdaq), + CancellationToken.None); + + Assert.Equal(LegacyDomesticMarket.Kosdaq, data.Market); + Assert.All(executor.Calls, call => + { + Assert.Contains("t_kosdaq_invest", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Empty(call.Spec.Parameters); + }); + } + + [Fact] + public async Task S5023_rejects_wrong_schema_count_order_and_fractional_integer() + { + var wrongSchema = DailyTable(); + wrongSchema.Columns[0].SetOrdinal(3); + await Assert.ThrowsAsync(() => + new S5023SceneDataLoader(new RecordingExecutor(wrongSchema, TotalTable())).LoadAsync( + new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi))); + + var wrongCount = DailyTable(); + wrongCount.Rows.RemoveAt(4); + await Assert.ThrowsAsync(() => + new S5023SceneDataLoader(new RecordingExecutor(wrongCount, TotalTable())).LoadAsync( + new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi))); + + var wrongOrder = DailyTable(); + wrongOrder.Rows[1]["TRADING_DATE"] = "20260711"; + await Assert.ThrowsAsync(() => + new S5023SceneDataLoader(new RecordingExecutor(wrongOrder, TotalTable())).LoadAsync( + new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi))); + + var fractional = TotalTable(); + fractional.Rows[0]["INDIVIDUAL_AMOUNT"] = 1.25m; + await Assert.ThrowsAsync(() => + new S5023SceneDataLoader(new RecordingExecutor(DailyTable(), fractional)).LoadAsync( + new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi))); + } + + [Theory] + [InlineData(LegacyDomesticMarket.Kospi, "t_invest")] + [InlineData(LegacyDomesticMarket.Kosdaq, "t_kosdaq_invest")] + public async Task S5024_maps_three_ordered_participants_and_proves_bar_mutations( + LegacyDomesticMarket market, + string tableName) + { + var table = Table(ParticipantColumns, + ["INDIVIDUAL", 100d], + ["FOREIGN", -50d], + ["INSTITUTION", 0d]); + var executor = new RecordingExecutor(table); + + var data = await new S5024SceneDataLoader(executor).LoadAsync( + new S5024SceneLoadRequest(market), + CancellationToken.None); + var mutations = new S5024SceneMutationBuilder().Build(data); + + var call = Assert.Single(executor.Calls); + Assert.Equal("SCENE_5024", call.TableName); + Assert.Contains(tableName, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Empty(call.Spec.Parameters); + Assert.Equal( + [InvestorParticipant.Individual, InvestorParticipant.Foreign, InvestorParticipant.Institution], + data.Amounts.Select(amount => amount.Participant)); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetVisible { ObjectName: "barG2_2", IsVisible: true }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "value1_1", Value: "100" }); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S5024_rejects_reordered_or_nonfinite_rows() + { + var reordered = Table(ParticipantColumns, + ["FOREIGN", 1d], + ["INDIVIDUAL", 2d], + ["INSTITUTION", 3d]); + await Assert.ThrowsAsync(() => + new S5024SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync( + new S5024SceneLoadRequest(LegacyDomesticMarket.Kospi))); + + var nonfinite = Table(ParticipantColumns, + ["INDIVIDUAL", double.PositiveInfinity], + ["FOREIGN", 2d], + ["INSTITUTION", 3d]); + await Assert.ThrowsAsync(() => + new S5024SceneDataLoader(new RecordingExecutor(nonfinite)).LoadAsync( + new S5024SceneLoadRequest(LegacyDomesticMarket.Kospi))); + } + + [Fact] + public async Task S5085_maps_six_ordered_metrics_and_proves_value_color_mutations() + { + var executor = new RecordingExecutor(MetricTable()); + + var data = await new S5085SceneDataLoader(executor).LoadAsync(CancellationToken.None); + var mutations = new S5085SceneMutationBuilder().Build(data); + + var call = Assert.Single(executor.Calls); + Assert.Equal("SCENE_5085", call.TableName); + Assert.Empty(call.Spec.Parameters); + Assert.Contains("WITH future_row AS", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("a.f_stock_seq = 1", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MAX(open_day)", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Equal(6, data.Metrics.Count); + Assert.Equal(ProgramTradingMetric.NetBuy, data.Metrics[0].Metric); + Assert.Equal(ProgramTradingMetric.Futures, data.Metrics[^1].Metric); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "price1", Value: "1,234.5" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetFaceColor + { + ObjectName: "price2", + Red: 15, + Green: 99, + Blue: 188 + }); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S5085_rejects_missing_reordered_or_nonfinite_metrics() + { + var missing = MetricTable(); + missing.Rows.RemoveAt(5); + await Assert.ThrowsAsync(() => + new S5085SceneDataLoader(new RecordingExecutor(missing)).LoadAsync()); + + var reordered = MetricTable(); + reordered.Rows[0]["METRIC_CODE"] = "ARBITRAGE"; + await Assert.ThrowsAsync(() => + new S5085SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync()); + + var nonfinite = MetricTable(); + nonfinite.Rows[0]["METRIC_VALUE"] = double.NaN; + await Assert.ThrowsAsync(() => + new S5085SceneDataLoader(new RecordingExecutor(nonfinite)).LoadAsync()); + } + + [Fact] + public async Task S6067_maps_the_exact_fifteen_row_grid_and_proves_slot_mutations() + { + var executor = new RecordingExecutor(InstitutionTable()); + + var data = await new S6067SceneDataLoader(executor).LoadAsync(CancellationToken.None); + var mutations = new S6067SceneMutationBuilder().Build(data); + + var call = Assert.Single(executor.Calls); + Assert.Equal("SCENE_6067", call.TableName); + Assert.Empty(call.Spec.Parameters); + Assert.Equal(15, data.Rows.Count); + Assert.Equal(InstitutionMarket.Kospi, data.Rows[0].Market); + Assert.Equal(InstitutionKind.Fund, data.Rows[4].Institution); + Assert.Equal(InstitutionMarket.Other, data.Rows[10].Market); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "row1_value1", Value: "100" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "row3_value5", Value: "-305" }); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S6067_rejects_wrong_order_schema_and_fractional_values() + { + var reordered = InstitutionTable(); + reordered.Rows[1]["INSTITUTION_CODE"] = "BANK"; + await Assert.ThrowsAsync(() => + new S6067SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync()); + + var schema = InstitutionTable(); + schema.Columns[2].ColumnName = "VALUE"; + await Assert.ThrowsAsync(() => + new S6067SceneDataLoader(new RecordingExecutor(schema)).LoadAsync()); + + var fractional = InstitutionTable(); + fractional.Rows[0]["AMOUNT"] = 1.1m; + await Assert.ThrowsAsync(() => + new S6067SceneDataLoader(new RecordingExecutor(fractional)).LoadAsync()); + } + + [Theory] + [InlineData(99)] + public async Task Closed_market_requests_fail_before_any_query(int rawMarket) + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S5023SceneDataLoader(executor).LoadAsync( + new S5023SceneLoadRequest((LegacyDomesticMarket)rawMarket))); + await Assert.ThrowsAsync(() => + new S5024SceneDataLoader(executor).LoadAsync( + new S5024SceneLoadRequest((LegacyDomesticMarket)rawMarket))); + + Assert.Empty(executor.Calls); + } + + private static DataTable DailyTable() => Table(DailyColumns, + ["20260710", 100, -10, 1], + ["20260709", 200, -20, 2], + ["20260708", 300, -30, 3], + ["20260707", 400, -40, 4], + ["20260706", 500, -50, 5]); + + private static DataTable TotalTable() => Table(TotalColumns, [1500, -800, 200]); + + private static DataTable MetricTable() => Table(MetricColumns, + ["NET_BUY", 1234.5d], + ["ARBITRAGE", -12d], + ["NON_ARBITRAGE", 24d], + ["BASIS", 1.25d], + ["KOSPI200", 300.12d], + ["FUTURES", 301.25d]); + + private static DataTable InstitutionTable() + { + string[] markets = ["KOSPI", "KOSDAQ", "KOSPI200"]; + string[] institutions = + ["SECURITIES", "INSURANCE", "INVESTMENT_TRUST", "BANK", "FUND"]; + var table = Table(InstitutionColumns); + for (var market = 0; market < markets.Length; market++) + { + for (var institution = 0; institution < institutions.Length; institution++) + { + var value = market == 2 && institution == 4 + ? -305 + : ((market + 1) * 100) + institution; + table.Rows.Add(markets[market], institutions[institution], value); + } + } + + return table; + } + + private static DataTable Table(string[] columns, params object?[][] rows) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + foreach (var row in rows) + { + table.Rows.Add(row); + } + + 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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No result was configured."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/GridSceneBuilder5082Tests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/GridSceneBuilder5082Tests.cs new file mode 100644 index 0000000..4ed870c --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/GridSceneBuilder5082Tests.cs @@ -0,0 +1,146 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class GridSceneBuilder5082Tests +{ + [Fact] + public void Build_matches_original_three_by_three_value_and_visibility_order() + { + var data = new S5082SceneData( + new(1000, -2000, 0), + new(-1, 2, 0), + new(0, 0, 3)); + + var actual = new S5082SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:row1_value1_1=1,000", + "V:row1_value1_2=1,000", + "V:row1_value1_3=1,000", + "S:row1_value1_1=1", + "S:row1_value1_2=0", + "S:row1_value1_3=0", + "V:row1_value2_1=-2,000", + "V:row1_value2_2=-2,000", + "V:row1_value2_3=-2,000", + "S:row1_value2_1=0", + "S:row1_value2_2=0", + "S:row1_value2_3=1", + "V:row1_value3_1=0", + "V:row1_value3_2=0", + "V:row1_value3_3=0", + "S:row1_value3_1=0", + "S:row1_value3_2=1", + "S:row1_value3_3=0", + "V:row2_value1_1=-1", + "V:row2_value1_2=-1", + "V:row2_value1_3=-1", + "S:row2_value1_1=0", + "S:row2_value1_2=0", + "S:row2_value1_3=1", + "V:row2_value2_1=2", + "V:row2_value2_2=2", + "V:row2_value2_3=2", + "S:row2_value2_1=1", + "S:row2_value2_2=0", + "S:row2_value2_3=0", + "V:row2_value3_1=0", + "V:row2_value3_2=0", + "V:row2_value3_3=0", + "S:row2_value3_1=0", + "S:row2_value3_2=1", + "S:row2_value3_3=0", + "V:row3_value1_1=0", + "V:row3_value1_2=0", + "V:row3_value1_3=0", + "S:row3_value1_1=0", + "S:row3_value1_2=1", + "S:row3_value1_3=0", + "V:row3_value2_1=0", + "V:row3_value2_2=0", + "V:row3_value2_3=0", + "S:row3_value2_1=0", + "S:row3_value2_2=1", + "S:row3_value2_3=0", + "V:row3_value3_1=3", + "V:row3_value3_2=3", + "V:row3_value3_3=3", + "S:row3_value3_1=1", + "S:row3_value3_2=0", + "S:row3_value3_3=0" + ], actual); + } + + [Fact] + public async Task Loader_maps_named_rows_with_a_spec_query_and_builder_consumes_them() + { + var table = new System.Data.DataTable(); + table.Columns.Add("NAME", typeof(string)); + table.Columns.Add("INDIVIDUAL_VALUE", typeof(decimal)); + table.Columns.Add("FOREIGN_VALUE", typeof(decimal)); + table.Columns.Add("INSTITUTION_VALUE", typeof(decimal)); + table.Rows.Add("코스닥", -1m, 2m, 3m); + table.Rows.Add("코스피200", 4m, -5m, 6m); + table.Rows.Add("코스피", 7m, 8m, -9m); + var executor = new SpecExecutor(table); + + var data = await new S5082SceneDataLoader(executor).LoadAsync(); + var mutations = new S5082SceneMutationBuilder().Build(data); + + Assert.Equal(new InvestorTradingValues(7, 8, -9), data.Kospi); + Assert.Equal(new InvestorTradingValues(-1, 2, 3), data.Kosdaq); + Assert.Equal(new InvestorTradingValues(4, -5, 6), data.Kospi200); + Assert.Equal(54, mutations.Count); + Assert.NotNull(executor.Spec); + Assert.Empty(executor.Spec.Parameters); + Assert.Equal(0, executor.StringCalls); + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + _ => mutation.GetType().Name + }; + + private sealed class SpecExecutor(System.Data.DataTable table) : MMoneyCoderSharp.Data.IDataQueryExecutor + { + public MMoneyCoderSharp.Data.DataQuerySpec? Spec { get; private set; } + + public int StringCalls { get; private set; } + + public System.Data.DataTable Execute( + MMoneyCoderSharp.Data.DataSourceKind source, + string tableName, + string query) + { + StringCalls++; + throw new InvalidOperationException(); + } + + public Task ExecuteAsync( + MMoneyCoderSharp.Data.DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + throw new InvalidOperationException(); + } + + public Task ExecuteAsync( + MMoneyCoderSharp.Data.DataSourceKind source, + string tableName, + MMoneyCoderSharp.Data.DataQuerySpec query, + CancellationToken cancellationToken = default) + { + Assert.Equal(MMoneyCoderSharp.Data.DataSourceKind.Oracle, source); + Assert.Equal("SCENE_5082", tableName); + Spec = query; + return Task.FromResult(table.Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyGridMarketSceneRequestResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyGridMarketSceneRequestResolverTests.cs new file mode 100644 index 0000000..f3267ee --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyGridMarketSceneRequestResolverTests.cs @@ -0,0 +1,427 @@ +using System.Data; +using System.IO; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyGridMarketSceneRequestResolverTests +{ + public static TheoryData TrendCases => new() + { + { "5023", "코스피", "코스피 매매동향", "표그래프", LegacyDomesticMarket.Kospi }, + { "5023", "KOSDAQ", "KOSDAQ_TRADING_TREND", "TABLE_GRAPH", LegacyDomesticMarket.Kosdaq }, + { "5024", "코스피", "코스피 매매동향", "막대그래프", LegacyDomesticMarket.Kospi }, + { "5024", "KOSDAQ", "KOSDAQ_TRADING_TREND", "BAR_GRAPH", LegacyDomesticMarket.Kosdaq } + }; + + public static TheoryData FixedGridCases => new() + { + { "5085", "프로그램 매매", "표그래프", typeof(LegacyS5085SceneLoadRequest) }, + { "5085", "PROGRAM_TRADING", "TABLE_GRAPH", typeof(LegacyS5085SceneLoadRequest) }, + { "6067", "기관 순매수 현황", "표그래프", typeof(LegacyS6067SceneLoadRequest) }, + { "6067", "INSTITUTION_NET_BUY", "TABLE_GRAPH", typeof(LegacyS6067SceneLoadRequest) } + }; + + [Theory] + [MemberData(nameof(TrendCases))] + public async Task Trend_cuts_map_MainForm_graphic_and_subtype_fields_without_database_calls( + string cutCode, + string groupCode, + string graphicType, + string subtype, + LegacyDomesticMarket expectedMarket) + { + var executor = new RecordingExecutor(); + var resolver = new LegacyGridMarketSceneRequestResolver(executor); + + var plan = await resolver.ResolveAsync( + Entry(cutCode, groupCode, "지수", graphicType, subtype)); + + if (cutCode == "5023") + { + Assert.Equal( + expectedMarket, + Assert.IsType(plan).Request.Market); + } + else + { + Assert.Equal( + expectedMarket, + Assert.IsType(plan).Request.Market); + } + + Assert.Empty(executor.Calls); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task Trend_cuts_reject_market_subtype_and_hidden_code_mismatches_before_database() + { + var executor = new RecordingExecutor(); + var resolver = new LegacyGridMarketSceneRequestResolver(executor); + + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + Entry("5023", "코스닥", "지수", "코스피 매매동향", "표그래프"))); + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + Entry("5023", "코스피", "지수", "코스피 매매동향", "막대그래프"))); + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + Entry("5024", "코스피", "지수", "코스피 매매동향", "막대그래프", "unexpected"))); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("5037", "코스피", LegacyDomesticMarket.Kospi)] + [InlineData("5037", "KOSDAQ", LegacyDomesticMarket.Kosdaq)] + public async Task S5037_maps_code_and_trimmed_jongmok_to_a_typed_request( + string cutCode, + string groupCode, + LegacyDomesticMarket expectedMarket) + { + var executor = new RecordingExecutor(); + var resolver = new LegacyGridMarketSceneRequestResolver(executor); + + var plan = Assert.IsType(await resolver.ResolveAsync( + Entry(cutCode, groupCode, " 삼성전자 ", "거래원", "표그래프", "A005930"))); + + Assert.Equal(expectedMarket, plan.Request.Market); + Assert.Equal("삼성전자", plan.Request.StockName); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task S5037_rejects_the_legacy_arbitrary_else_market_branch() + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync( + Entry("5037", "전체", "삼성전자", "거래원", "표그래프"))); + + Assert.Empty(executor.Calls); + } + + [Theory] + [MemberData(nameof(FixedGridCases))] + public async Task Fixed_grid_cuts_validate_selection_identity_and_return_no_parameter_markers( + string cutCode, + string graphicType, + string subtype, + Type expectedType) + { + var executor = new RecordingExecutor(); + var plan = await new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync( + Entry(cutCode, "전체", "지수", graphicType, subtype)); + + Assert.IsType(expectedType, plan); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task Fixed_grid_cuts_reject_wrong_playlist_identity_before_database() + { + var executor = new RecordingExecutor(); + var resolver = new LegacyGridMarketSceneRequestResolver(executor); + + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + Entry("5085", "코스피", "지수", "프로그램 매매", "표그래프"))); + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + Entry("6067", "전체", "지수", "기관 순매수 현황", "막대그래프"))); + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + Entry("6067", "전체", "지수", "기관 순매수 현황", "표그래프", "path.dat"))); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("개인 순매도 상위(수동)", S5025ManualAudience.Individual)] + [InlineData("FOREIGN", S5025ManualAudience.Foreign)] + [InlineData("기관 순매도 상위(수동)", S5025ManualAudience.Institution)] + public async Task S5025_maps_only_closed_audiences_when_a_native_trusted_source_is_configured( + string groupCode, + S5025ManualAudience expectedAudience) + { + var executor = new RecordingExecutor(); + var source = new RecordingManualSource(ManualRows()); + var resolver = new LegacyGridMarketSceneRequestResolver(executor, source); + + var plan = Assert.IsType(await resolver.ResolveAsync( + Entry("5025", groupCode, "지수", "순매도 상위", "순매도 상위"))); + + Assert.Equal(expectedAudience, plan.Request.Audience); + Assert.Empty(source.Calls); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task S5025_safe_runtime_loads_and_validates_trusted_rows_before_database_or_mutations() + { + var executor = new RecordingExecutor(); + var source = new RecordingManualSource(ManualRows()); + var resolver = new LegacyGridMarketSceneRequestResolver(executor, source); + var entry = Entry( + "5025", + "외국인 순매도 상위(수동)", + "지수", + "순매도 상위", + "순매도 상위"); + + var data = await resolver.LoadTrustedS5025Async(entry, CancellationToken.None); + var mutations = new S5025SceneMutationBuilder().Build(data); + + Assert.Equal(S5025ManualAudience.Foreign, Assert.Single(source.Calls)); + Assert.Equal("외국인", data.Title); + Assert.Equal(5, data.Rows.Count); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "종목10", Value: "R5" }); + Assert.Empty(executor.Calls); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S5025_missing_configuration_and_path_injection_fail_before_source_database_or_com() + { + var entry = Entry( + "5025", + "개인 순매도 상위(수동)", + "지수", + "순매도 상위", + "순매도 상위"); + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync(entry)); + + var source = new RecordingManualSource(ManualRows()); + await Assert.ThrowsAsync(() => + new LegacyGridMarketSceneRequestResolver(executor, source).ResolveAsync( + entry with + { + Selection = entry.Selection! with { DataCode = "C:\\outside\\개인.dat" } + })); + + Assert.Empty(source.Calls); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task S5025_missing_trusted_file_is_normalized_without_leaking_the_native_path() + { + const string secretPath = "C:\\trusted\\secret\\개인.dat"; + var executor = new RecordingExecutor(); + var source = new ThrowingManualSource(new FileNotFoundException("Missing " + secretPath)); + var resolver = new LegacyGridMarketSceneRequestResolver(executor, source); + + var exception = await Assert.ThrowsAsync(() => + resolver.LoadTrustedS5025Async(Entry( + "5025", + "개인 순매도 상위(수동)", + "지수", + "순매도 상위", + "순매도 상위"))); + + Assert.DoesNotContain(secretPath, exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task S8003_resolves_Kospi_name_to_code_with_two_parameterized_master_lookups() + { + const string stockName = "삼성전자"; + const string stockCode = "005930"; + var executor = new RecordingExecutor(Stock(stockCode), Stock()); + var resolver = new LegacyGridMarketSceneRequestResolver(executor); + + var plan = Assert.IsType(await resolver.ResolveAsync( + Entry("8003", "코스피", stockName, "호가창", "표그래프", stockCode))); + + Assert.Equal(LegacyDomesticMarket.Kospi, plan.Request.Market); + Assert.Equal(stockCode, plan.Request.StockCode); + Assert.Equal( + ["SCENE_RESOLVE_8003_KOSPI", "SCENE_RESOLVE_8003_KOSDAQ"], + executor.Calls.Select(call => call.TableName)); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.DoesNotContain(stockName, call.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("stockName", parameter.Name); + Assert.Equal(stockName, parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }); + Assert.Contains("FROM T_STOCK", executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FROM T_KOSDAQ_STOCK", executor.Calls[1].Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S8003_preserves_MainForm_Kosdaq_later_table_precedence() + { + var executor = new RecordingExecutor(Stock("111111"), Stock("222222")); + + var plan = Assert.IsType(await + new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync( + Entry("8003", "코스닥", "동명이인", "호가창", "표그래프", "222222"))); + + Assert.Equal(LegacyDomesticMarket.Kosdaq, plan.Request.Market); + Assert.Equal("222222", plan.Request.StockCode); + } + + [Fact] + public async Task S8003_fails_closed_for_missing_nonunique_mismatched_or_malformed_master_results() + { + await Assert.ThrowsAsync(() => + Resolve8003Async(new RecordingExecutor(Stock(), Stock()), "코스피", "")); + + var duplicate = Stock("005930"); + duplicate.Rows.Add("005930"); + await Assert.ThrowsAsync(() => + Resolve8003Async(new RecordingExecutor(duplicate, Stock()), "코스피", "005930")); + + var schema = Stock("005930"); + schema.Columns[0].ColumnName = "CODE"; + await Assert.ThrowsAsync(() => + Resolve8003Async(new RecordingExecutor(schema, Stock()), "코스피", "005930")); + + await Assert.ThrowsAsync(() => + Resolve8003Async( + new RecordingExecutor(Stock("005930"), Stock()), + "코스닥", + "005930")); + await Assert.ThrowsAsync(() => + Resolve8003Async( + new RecordingExecutor(Stock("005930"), Stock()), + "코스피", + "999999")); + } + + [Fact] + public async Task Missing_selection_and_unsupported_cut_fail_before_database() + { + var executor = new RecordingExecutor(); + var resolver = new LegacyGridMarketSceneRequestResolver(executor); + + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + new LegacyPlaylistEntry("entry", "5023"))); + await Assert.ThrowsAsync(() => resolver.ResolveAsync( + Entry("9999", "전체", "지수", "프로그램 매매", "표그래프"))); + + Assert.Empty(executor.Calls); + } + + private static Task Resolve8003Async( + RecordingExecutor executor, + string market, + string dataCode) => + new LegacyGridMarketSceneRequestResolver(executor).ResolveAsync( + Entry("8003", market, "삼성전자", "호가창", "표그래프", dataCode)); + + private static LegacyPlaylistEntry Entry( + string cutCode, + string groupCode, + string subject, + string graphicType, + string subtype, + string dataCode = "") => new( + "entry", + cutCode, + Selection: new LegacySceneSelection( + groupCode, + subject, + graphicType, + subtype, + dataCode)); + + private static DataTable Stock(params string[] codes) + { + var table = new DataTable(); + table.Columns.Add("STOCK_CODE", typeof(object)); + foreach (var code in codes) + { + table.Rows.Add(code); + } + + return table; + } + + private static IReadOnlyList ManualRows() => + [ + new("L1", "1", "R1", "2"), + new("L2", "3", "R2", "4"), + new("L3", "5", "R3", "6"), + new("L4", "7", "R4", "8"), + new("L5", "9", "R5", "10") + ]; + + 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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No result was configured."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } + + private sealed class RecordingManualSource(IReadOnlyList rows) + : IS5025TrustedManualDataSource + { + public List Calls { get; } = []; + + public Task> ReadAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Calls.Add(audience); + return Task.FromResult(rows); + } + } + + private sealed class ThrowingManualSource(Exception exception) : IS5025TrustedManualDataSource + { + public Task> ReadAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) => + Task.FromException>(exception); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs new file mode 100644 index 0000000..5ba19b6 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs @@ -0,0 +1,178 @@ +#nullable enable + +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyParameterizedSceneRequestResolverTests +{ + [Fact] + public void S5001_maps_legacy_dispatch_fields_to_closed_requests() + { + var resolver = new LegacyParameterizedSceneRequestResolver( + new RecordingExecutor(), + new FixedTimeProvider(new DateTimeOffset(2026, 7, 10, 12, 0, 0, TimeSpan.Zero))); + + var commodity = resolver.CreateS5001Request(Entry( + "5001", "원자재", "국제 금", "현재가")); + var expectedIndex = resolver.CreateS5001Request(Entry( + "5001", "코스피200", "지수", "예상지수")); + var nxt = resolver.CreateS5001Request(Entry( + "N5001", "코스피_NXT", "삼성전자(NXT)", "현재가")); + + Assert.Equal( + S5001CommodityTarget.InternationalGold, + Assert.IsType(commodity).Target); + var index = Assert.IsType(expectedIndex); + Assert.Equal(S5001DomesticIndexTarget.Kospi200, index.Target); + Assert.Equal(S5001IndexMode.Expected, index.Mode); + var stock = Assert.IsType(nxt); + Assert.Equal(LegacyDomesticEquityMarket.NxtKospi, stock.Market); + Assert.Equal(S5001NxtBadge.PreMarket, stock.Badge); + } + + [Fact] + public async Task Two_column_alias_routes_futures_to_s5032_and_normalizes_side_order() + { + var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor()); + + var result = await resolver.ResolveTwoColumnRequestAsync(Entry( + "8018", "지수", "선물,코스닥 지수", "현재가")); + + var routed = Assert.IsType(result); + var request = Assert.IsType(routed.Request); + Assert.Equal(LegacyMarketQuoteTarget.Kosdaq, request.PrimaryTarget); + } + + [Fact] + public async Task Current_stock_pair_uses_original_master_table_precedence_and_session_boundary() + { + var executor = new RecordingExecutor( + Count(1), Count(0), + Count(1), Count(1)); + var resolver = new LegacyParameterizedSceneRequestResolver( + executor, + new FixedTimeProvider(new DateTimeOffset(2026, 7, 10, 15, 30, 1, TimeSpan.Zero))); + + var result = await resolver.ResolveTwoColumnRequestAsync(Entry( + "8018", "종목", "첫째,둘째", "현재가")); + + var routed = Assert.IsType(result); + var request = Assert.IsType(routed.Request); + Assert.Equal(S8018StockMarket.Kospi, request.First.Market); + Assert.Equal(S8018StockMarket.Kosdaq, request.Second.Market); + Assert.Equal(S8018SessionPhase.AfterKrxClose, request.Session); + Assert.Equal( + [ + "SCENE_RESOLVE_KOSPI_STOCK", + "SCENE_RESOLVE_KOSDAQ_STOCK", + "SCENE_RESOLVE_KOSPI_STOCK", + "SCENE_RESOLVE_KOSDAQ_STOCK" + ], + executor.Calls.Select(call => call.TableName)); + Assert.All(executor.Calls, call => + { + var parameter = Assert.Single(call.Spec.Parameters); + var selector = Assert.IsType(parameter.Value); + Assert.DoesNotContain( + selector, + call.Spec.Sql, + StringComparison.Ordinal); + }); + } + + [Fact] + public async Task Expected_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),B", "예상체결"))); + + Assert.Empty(executor.Calls); + } + + [Fact] + public void S8001_accepts_only_the_two_original_markets() + { + var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor()); + + Assert.Equal( + S8001Market.Kospi, + resolver.CreateS8001Request(Entry("8001", "전체", "코스피", "")).Market); + Assert.Throws(() => + resolver.CreateS8001Request(Entry("8001", "전체", "나스닥", ""))); + } + + private static LegacyPlaylistEntry Entry( + string cutCode, + string groupCode, + string subject, + string subtype) => new( + "entry", + cutCode, + Selection: new LegacySceneSelection( + groupCode, + subject, + "", + subtype, + "")); + + private static DataTable Count(int value) + { + var table = new DataTable(); + table.Columns.Add("MATCH_COUNT", typeof(int)); + table.Rows.Add(value); + return table; + } + + private sealed record RecordedCall( + 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 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(); + query.ValidateFor(source); + Calls.Add(new RecordedCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No fake query result remains."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } + + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs new file mode 100644 index 0000000..6256369 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs @@ -0,0 +1,481 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyPlayoutWorkflowTests +{ + [Fact] + public async Task PrepareTakeInAndNext_UpdatesPagesBeforeMovingToNextEnabledCue() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5074"] = (6, ScenePageSize.Five), + ["5001"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("entry-1", "5074"), + new("disabled", "5006", false), + new("entry-3", "5001") + ]; + + Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind); + + Assert.True((await workflow.NextAsync()).IsSuccess); + Assert.Equal(1, workflow.State.PageIndexZeroBased); + Assert.Equal(2, workflow.State.PageCount); + Assert.True(workflow.State.IsLastPage); + Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind); + + Assert.True((await workflow.NextAsync()).IsSuccess); + Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("5001", workflow.State.OnAirCutCode); + Assert.Equal( + [ + "Prepare:5074:p0", "Prepare:5074:p0", "TakeIn", + "Next:5074:p1", "Next:5001:p0" + ], engine.Calls); + Assert.Equal( + [ + "5074:0", "5074:0", "5074:1", "5001:0" + ], provider.Calls); + } + + [Fact] + public async Task Next_AtTheEndDoesNotWrapOrDispatchTheEngine() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5088"] = (12, ScenePageSize.Twelve) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = [new("only", "5088")]; + Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + + var result = await workflow.NextAsync(); + + Assert.Equal(PlayoutResultCode.Rejected, result.Code); + Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind); + Assert.Equal(["Prepare:5088:p0", "Prepare:5088:p0", "TakeIn"], engine.Calls); + Assert.Equal(["5088:0", "5088:0"], provider.Calls); + } + + [Fact] + public async Task PageNext_RejectsProviderThatChangesTheOnAirSceneName() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5077"] = (7, ScenePageSize.Six) + }) + { + SceneNameOverride = (_, page) => page == 0 ? "5077" : "other" + }; + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5077")], 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + + await Assert.ThrowsAsync(() => workflow.NextAsync()); + + Assert.DoesNotContain(engine.Calls, call => call == "Next:other:p1"); + Assert.Equal(0, workflow.State.PageIndexZeroBased); + } + + [Fact] + public async Task TakeOut_ClearsPageAndPlaylistStateOnlyAfterEngineSuccess() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + + Assert.True((await workflow.TakeOutAsync(PlayoutTakeOutScope.All)).IsSuccess); + + Assert.Null(workflow.State.OnAirCutCode); + Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal(LegacyWorkflowNextKind.None, workflow.State.NextKind); + Assert.Equal("TakeOut:All", engine.Calls[^1]); + } + + [Fact] + public async Task Prepare_SkipsDisabledRowsLikeTheLegacyMainForm() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("disabled", "5006", false), + new("enabled", "5001") + ]; + + var result = await workflow.PrepareAsync(playlist, 0); + + Assert.True(result.IsSuccess); + Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("5001", workflow.State.PreparedCutCode); + Assert.Equal(["5001:0"], provider.Calls); + Assert.Equal(["Prepare:5001:p0"], engine.Calls); + } + + [Fact] + public async Task Prepare_WithNoEnabledRowAtOrAfterSelection_DoesNotWrapOrDispatch() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary()); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("enabled-before", "5001"), + new("disabled", "5006", false) + ]; + + var result = await workflow.PrepareAsync(playlist, 1); + + Assert.Equal(PlayoutResultCode.Rejected, result.Code); + Assert.Empty(provider.Calls); + Assert.Empty(engine.Calls); + Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased); + } + + [Fact] + public async Task Prepare_RejectsControlCharactersInLegacyLookupFieldsBeforeDataAccess() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary()); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + var selection = new LegacySceneSelection( + "코스피", + "종목\n이름", + "1열판 기본", + string.Empty, + "005930"); + + await Assert.ThrowsAsync(() => workflow.PrepareAsync( + [new LegacyPlaylistEntry("entry", "5001", Selection: selection)], + 0)); + + Assert.Empty(provider.Calls); + Assert.Empty(engine.Calls); + } + + [Fact] + public async Task Prepare_RejectsAnOutOfRangeFadeBeforeDataAccess() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary()); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + + await Assert.ThrowsAsync(() => workflow.PrepareAsync( + [new LegacyPlaylistEntry("entry", "5001", FadeDuration: 61)], + 0)); + + Assert.Empty(provider.Calls); + Assert.Empty(engine.Calls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Prepare_WhenPrepareModeIsAlreadyActive_TogglesThroughLegacyStopAll( + bool takeInFirst) + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5006"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess); + if (takeInFirst) + { + Assert.True((await workflow.TakeInAsync()).IsSuccess); + } + + var toggle = await workflow.PrepareAsync([new("other", "5006")], 0); + + Assert.True(toggle.IsSuccess); + Assert.Equal(PlayoutOperation.TakeOut, toggle.Operation); + Assert.Equal("TakeOut:All", engine.Calls[^1]); + string[] expectedProviderCalls = takeInFirst + ? ["5001:0", "5001:0"] + : ["5001:0"]; + Assert.Equal(expectedProviderCalls, provider.Calls); + Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased); + Assert.Null(workflow.State.PreparedCutCode); + Assert.Null(workflow.State.OnAirCutCode); + } + + [Fact] + public async Task TakeIn_RequeriesAndRepreparesTheSelectedPageBeforePlay() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + + Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + + Assert.Equal(["5001:0", "5001:0"], provider.Calls); + Assert.Equal( + ["Prepare:5001:p0", "Prepare:5001:p0", "TakeIn"], + engine.Calls); + Assert.Null(workflow.State.PreparedCutCode); + Assert.Equal("5001", workflow.State.OnAirCutCode); + } + + [Fact] + public async Task TakeIn_WhenFreshPrepareIsOutcomeUnknown_DoesNotPlayOrReplacePreparedState() + { + var engine = new RecordingEngine(); + engine.PrepareResults.Enqueue(PlayoutResultCode.Success); + engine.PrepareResults.Enqueue(PlayoutResultCode.OutcomeUnknown); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess); + var before = workflow.State; + + var result = await workflow.TakeInAsync(); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal(before, workflow.State); + Assert.Equal(["5001:0", "5001:0"], provider.Calls); + Assert.Equal(["Prepare:5001:p0", "Prepare:5001:p0"], engine.Calls); + Assert.DoesNotContain("TakeIn", engine.Calls); + } + + [Fact] + public async Task RefreshOnAir_RequeriesTheCurrentPagedWindowAndUsesGetPlayingSceneEnginePath() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5074"] = (6, ScenePageSize.Five) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + + var refresh = await workflow.RefreshOnAirAsync(); + + Assert.True(refresh.IsSuccess); + Assert.Equal(PlayoutOperation.UpdateOnAir, refresh.Operation); + Assert.Equal(["5074:0", "5074:0", "5074:0"], provider.Calls); + Assert.Equal( + ["Prepare:5074:p0", "Prepare:5074:p0", "TakeIn", "UpdateOnAir:5074:p0"], + engine.Calls); + Assert.Equal(0, workflow.State.PageIndexZeroBased); + Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind); + } + + [Fact] + public async Task RefreshOnAir_OutcomeUnknownPreservesTheCurrentOnAirPage() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + var before = workflow.State; + engine.UpdateOnAirResults.Enqueue(PlayoutResultCode.OutcomeUnknown); + + var result = await workflow.RefreshOnAirAsync(); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal(before, workflow.State); + Assert.Equal("UpdateOnAir:5001:p0", engine.Calls[^1]); + } + + [Fact] + public async Task RefreshOnAir_RequiresAnOnAirSceneBeforeQueryingData() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary()); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + + var result = await workflow.RefreshOnAirAsync(); + + Assert.Equal(PlayoutResultCode.Rejected, result.Code); + Assert.Equal(PlayoutOperation.UpdateOnAir, result.Operation); + Assert.Empty(provider.Calls); + Assert.Empty(engine.Calls); + } + + [Fact] + public async Task StopAllOrCutOutStatus_ClearsNativePageStateWithoutDispatchingAgain() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5074"] = (6, ScenePageSize.Five) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + + workflow.ObserveEngineStatus(engine.Status with + { + PreparedSceneName = null, + OnAirSceneName = null, + Sequence = 2 + }); + + Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal(0, workflow.State.PageCount); + Assert.Null(workflow.State.OnAirCutCode); + var next = await workflow.NextAsync(); + Assert.Equal(PlayoutResultCode.Rejected, next.Code); + Assert.DoesNotContain(engine.Calls, call => call.StartsWith("UpdateOnAir", StringComparison.Ordinal)); + } + + private sealed class RecordingProvider( + IReadOnlyDictionary definitions) + : ILegacySceneCueProvider + { + public List Calls { get; } = []; + + public Func? SceneNameOverride { get; init; } + + public Task CreatePageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}"); + var definition = definitions[entry.CutCode]; + var name = SceneNameOverride?.Invoke(entry.CutCode, pageIndexZeroBased) ?? entry.CutCode; + return Task.FromResult(new LegacySceneCuePage( + new PlayoutCue( + name + ".t2s", + name, + Mutations: [new PlayoutSetValue("page", $"p{pageIndexZeroBased}")]), + definition.Count, + definition.Size)); + } + } + + private sealed class RecordingEngine : IPlayoutEngine + { + public List Calls { get; } = []; + + public Queue PrepareResults { get; } = []; + + public Queue UpdateOnAirResults { get; } = []; + + public PlayoutStatus Status { get; } = new( + PlayoutMode.DryRun, + PlayoutConnectionState.DryRunReady, + true, + false, + true, + false, + null, + null, + "test", + DateTimeOffset.UtcNow, + 0); + + public event EventHandler? StatusChanged + { + add { } + remove { } + } + + public Task ConnectAsync(CancellationToken cancellationToken = default) => + Success(PlayoutOperation.Connect); + + public Task DisconnectAsync(CancellationToken cancellationToken = default) => + Success(PlayoutOperation.Disconnect); + + public Task PrepareAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + Calls.Add("Prepare:" + Describe(cue)); + return Complete(PlayoutOperation.Prepare, PrepareResults); + } + + public Task TakeInAsync(CancellationToken cancellationToken = default) + { + Calls.Add("TakeIn"); + return Success(PlayoutOperation.TakeIn); + } + + public Task NextAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + Calls.Add("Next:" + Describe(cue)); + return Success(PlayoutOperation.Next); + } + + public Task UpdateOnAirAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + Calls.Add("UpdateOnAir:" + Describe(cue)); + return Complete(PlayoutOperation.UpdateOnAir, UpdateOnAirResults); + } + + public Task TakeOutAsync( + PlayoutTakeOutScope scope, + CancellationToken cancellationToken = default) + { + Calls.Add("TakeOut:" + scope); + return Success(PlayoutOperation.TakeOut); + } + + public ValueTask QuarantineAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private static string Describe(PlayoutCue cue) + { + var page = Assert.IsType(Assert.Single(cue.Mutations!)).Value; + return $"{cue.SceneName}:{page}"; + } + + private static Task Success(PlayoutOperation operation) => + Complete(operation, null); + + private static Task Complete( + PlayoutOperation operation, + Queue? results) + { + var code = results is { Count: > 0 } + ? results.Dequeue() + : PlayoutResultCode.Success; + return Task.FromResult(new PlayoutResult( + operation, + code, + true, + code == PlayoutResultCode.Success ? "ok" : "failed", + DateTimeOffset.UtcNow)); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPriceDirectionMutationsTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPriceDirectionMutationsTests.cs new file mode 100644 index 0000000..b86ed25 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPriceDirectionMutationsTests.cs @@ -0,0 +1,108 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyPriceDirectionMutationsTests +{ + [Theory] + [InlineData("상한", ScenePriceDirection.LimitUp)] + [InlineData("상승", ScenePriceDirection.Up)] + [InlineData("+", ScenePriceDirection.Up)] + [InlineData("보합", ScenePriceDirection.Flat)] + [InlineData(" ", ScenePriceDirection.Flat)] + [InlineData("하락", ScenePriceDirection.Down)] + [InlineData("-", ScenePriceDirection.Down)] + [InlineData("하한", ScenePriceDirection.LimitDown)] + [InlineData("unknown", ScenePriceDirection.Unknown)] + public void Parse_matches_legacy_direction_values( + string value, + ScenePriceDirection expected) + { + Assert.Equal(expected, LegacyPriceDirectionMutations.Parse(value)); + } + + [Fact] + public void Build_up_variant_one_matches_original_object_order() + { + var mutations = LegacyPriceDirectionMutations.Build(ScenePriceDirection.Up, 1); + + Assert.Collection( + mutations, + mutation => Visible(mutation, "upup1", false), + mutation => Visible(mutation, "up1", false), + mutation => Visible(mutation, "flat1", false), + mutation => Visible(mutation, "down1", false), + mutation => Visible(mutation, "downdown1", false), + mutation => Visible(mutation, "up1", true), + mutation => Visible(mutation, "bg1_1", true), + mutation => Visible(mutation, "changePrice1_1", true), + mutation => Visible(mutation, "wonText1_1", true), + mutation => Visible(mutation, "rate1_1", true), + mutation => Visible(mutation, "percent1_1", true), + mutation => Visible(mutation, "bg2_1", false), + mutation => Visible(mutation, "changePrice2_1", false), + mutation => Visible(mutation, "wonText2_1", false), + mutation => Visible(mutation, "rate2_1", false), + mutation => Visible(mutation, "percent2_1", false), + mutation => Visible(mutation, "bg3_1", false), + mutation => Visible(mutation, "changePrice3_1", false), + mutation => Visible(mutation, "wonText3_1", false), + mutation => Visible(mutation, "rate3_1", false), + mutation => Visible(mutation, "percent3_1", false), + mutation => Visible(mutation, "unit1_1", true), + mutation => Visible(mutation, "unit2_1", false), + mutation => Visible(mutation, "unit3_1", false), + mutation => Asset(mutation, "pattern1", @"Images\그림_빨강.png")); + } + + [Fact] + public void Build_flat_variant_three_includes_original_price_switch() + { + var mutations = LegacyPriceDirectionMutations.Build(ScenePriceDirection.Flat, 3); + + Assert.Collection( + mutations.Skip(24), + mutation => Visible(mutation, "price1_", false), + mutation => Visible(mutation, "price2_", true), + mutation => Visible(mutation, "price3_", false), + mutation => Asset(mutation, "pattern", @"Images\그림_검정.png")); + } + + [Fact] + public void Build_unknown_only_hides_direction_objects() + { + var mutations = LegacyPriceDirectionMutations.Build(ScenePriceDirection.Unknown, 0); + + Assert.Collection( + mutations, + mutation => Visible(mutation, "upup", false), + mutation => Visible(mutation, "up", false), + mutation => Visible(mutation, "flat", false), + mutation => Visible(mutation, "down", false), + mutation => Visible(mutation, "downdown", false)); + } + + [Theory] + [InlineData(-1)] + [InlineData(4)] + public void Build_rejects_unknown_variant_index(int value) + { + Assert.Throws(() => + LegacyPriceDirectionMutations.Build(ScenePriceDirection.Up, value)); + } + + private static void Visible(PlayoutMutation mutation, string name, bool visible) + { + var actual = Assert.IsType(mutation); + Assert.Equal(name, actual.ObjectName); + Assert.Equal(visible, actual.IsVisible); + } + + private static void Asset(PlayoutMutation mutation, string name, string path) + { + var actual = Assert.IsType(mutation); + Assert.Equal(name, actual.ObjectName); + Assert.Equal(path, actual.AssetPath); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneCatalogTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneCatalogTests.cs new file mode 100644 index 0000000..f01214f --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneCatalogTests.cs @@ -0,0 +1,295 @@ +using System.Reflection; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacySceneCatalogTests +{ + private const LegacySceneMutationCapability Value = + LegacySceneMutationCapability.Value; + private const LegacySceneMutationCapability Asset = + LegacySceneMutationCapability.AssetValue; + private const LegacySceneMutationCapability Visible = + LegacySceneMutationCapability.Visibility; + private const LegacySceneMutationCapability Color = + LegacySceneMutationCapability.FaceColor; + private const LegacySceneMutationCapability Position = + LegacySceneMutationCapability.Position; + private const LegacySceneMutationCapability PositionKey = + LegacySceneMutationCapability.PositionKey; + private const LegacySceneMutationCapability Scale = + LegacySceneMutationCapability.Scale; + private const LegacySceneMutationCapability Crop = + LegacySceneMutationCapability.Crop; + private const LegacySceneMutationCapability CircleAngle = + LegacySceneMutationCapability.CircleAngleKey; + private const LegacySceneMutationCapability Path = + LegacySceneMutationCapability.PathPoints; + private const LegacySceneMutationCapability PathShape = + LegacySceneMutationCapability.PathShapePoints; + private const LegacySceneMutationCapability BackgroundVideo = + LegacySceneMutationCapability.BackgroundVideo; + + private static readonly ExpectedDefinition[] ExpectedDefinitions = + [ + E("s5001", "5001", ["5001", "N5001"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + E("s5006", "5006", ["5006"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | BackgroundVideo), + E("s5011", "5011", ["5011"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color), + E("s5016", "5016", ["5016"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + E("s50160", "50160", ["50160"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + E("s5023", "5023", ["5023"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + E("s5024", "5024", ["5024"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | Scale), + E("s5025", "5025", ["5025"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value), + E("s5026", "5026", ["5026"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Crop), + E("s5029", "5029", ["5029"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | PathShape), + E("s5032", "5032", ["8018", "8032", "5032"], + LegacyScenePageSize.None, + LegacySceneReachability.ConditionalMainFormDispatch, + Value | Visible | Asset), + E("s5037", "5037", ["5037"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + E("s5074", "5074", ["5074"], + LegacyScenePageSize.Five, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + E("s5076", "5076", ["5076"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | CircleAngle), + E("s5077", "5077", ["5077"], + LegacyScenePageSize.Six, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + E("s5078", "5078", ["5078"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Scale), + E("s5079", "5079", ["5079"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Path), + E("s5080", "5080", ["5080"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | PositionKey | Scale), + E("s5081", "5081", ["5081"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | PositionKey | Scale), + E("s5082", "5082", ["5082"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible), + E("s5083", "5083", ["5083"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Position | Path), + E("s5084", "5084", ["5084"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Position | Path), + E("s5085", "5085", ["5085"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + E("s5086", "5086", ["5086"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | PathShape), + E("s50860", "50860", ["50860"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Position | PathShape), + E("s5087", "5087", ["5087"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Position | PathShape), + E("s5088", "5088", ["5088"], + LegacyScenePageSize.Twelve, LegacySceneReachability.MainFormDispatch, + Value | Visible | Asset), + E("s6001", "6001", ["6001"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Asset | Visible | Color), + E("s6067", "6067", ["6067"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + E("s8001", "8001", ["8001", "8002"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Color), + E("s8003", "8003", ["8003"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Scale), + E( + "s8010", + "8010", + ["8035", "8061", "8040", "8046", "8051", "8056"], + LegacyScenePageSize.None, + LegacySceneReachability.MainFormDispatch, + Value | Visible | Color | Position | Crop | Path), + E("s8018", "8018", ["8018", "8032", "5032"], + LegacyScenePageSize.None, + LegacySceneReachability.ConditionalMainFormDispatch, + Value | Visible | Asset), + E("s8067", "8067", ["8067", "5068", "5070", "5072"], + LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch, + Value | Visible | Color), + E("s8086", "8086", [], + LegacyScenePageSize.None, LegacySceneReachability.NoMainFormDispatch, + Value | Color) + ]; + + [Fact] + public void Definitions_MatchTheReadOnly35BuilderBaselineExactly() + { + var actual = LegacySceneCatalog.Definitions; + + Assert.Equal(LegacySceneCatalog.ExpectedDefinitionCount, actual.Count); + Assert.Equal(35, actual.Count); + Assert.Equal( + ExpectedDefinitions.Select(expected => expected.BuilderKey), + actual.Select(definition => definition.BuilderKey)); + + foreach (var expected in ExpectedDefinitions) + { + var definition = Assert.Single( + actual, + candidate => candidate.BuilderKey == expected.BuilderKey); + + Assert.Equal(expected.SceneCode, definition.SceneCode); + Assert.Equal(expected.CutAliases, definition.CutAliases); + Assert.Equal(expected.PageSize, definition.PageSize); + Assert.Equal(expected.Reachability, definition.Reachability); + Assert.Equal(expected.DtoTypeMarker, definition.DtoTypeMarker); + Assert.Equal(expected.BuilderKey, definition.SourceHashKey); + Assert.Equal(expected.Capabilities, definition.MutationCapabilities); + } + } + + [Fact] + public void Definitions_HaveUniqueTypedIdentityAndOnlySupportedPageSizes() + { + var definitions = LegacySceneCatalog.Definitions; + + Assert.Equal(35, definitions.Select(item => item.BuilderKey).Distinct().Count()); + Assert.Equal(35, definitions.Select(item => item.SceneCode).Distinct().Count()); + Assert.Equal(35, definitions.Select(item => item.SourceHashKey).Distinct().Count()); + Assert.Equal(35, definitions.Select(item => item.DtoTypeMarker).Distinct().Count()); + Assert.All(definitions, definition => + { + Assert.True(typeof(ILegacySceneDtoMarker).IsAssignableFrom( + definition.DtoTypeMarker)); + Assert.Contains((int)definition.PageSize, new[] { 0, 5, 6, 12 }); + Assert.NotEqual( + LegacySceneMutationCapability.None, + definition.MutationCapabilities); + }); + + Assert.Equal( + new Dictionary + { + ["s5074"] = LegacyScenePageSize.Five, + ["s5077"] = LegacyScenePageSize.Six, + ["s5088"] = LegacyScenePageSize.Twelve + }, + definitions + .Where(definition => definition.PageSize != LegacyScenePageSize.None) + .ToDictionary(definition => definition.BuilderKey, definition => definition.PageSize)); + } + + [Fact] + public void CutAliasLookup_PreservesConditionalDispatchWithoutGuessing() + { + var direct = LegacySceneCatalog.FindByCutAlias("5001"); + var conditional = LegacySceneCatalog.FindByCutAlias("8032"); + + Assert.Equal("s5001", Assert.Single(direct).BuilderKey); + Assert.Equal( + new[] { "s5032", "s8018" }, + conditional.Select(definition => definition.BuilderKey)); + Assert.All(conditional, definition => Assert.Equal( + LegacySceneReachability.ConditionalMainFormDispatch, + definition.Reachability)); + + Assert.Empty(LegacySceneCatalog.FindByCutAlias("8086")); + Assert.Empty(LegacySceneCatalog.FindByCutAlias("../5001.t2s")); + Assert.Empty(LegacySceneCatalog.FindByCutAlias(null)); + } + + [Fact] + public void SceneCodeLookup_ReturnsMetadataOnlyAndKeeps8086Unreachable() + { + var definition = LegacySceneCatalog.FindBySceneCode("8086"); + + Assert.NotNull(definition); + Assert.Equal("s8086", definition.BuilderKey); + Assert.Equal(LegacySceneReachability.NoMainFormDispatch, definition.Reachability); + Assert.Empty(definition.CutAliases); + Assert.Null(LegacySceneCatalog.FindBySceneCode("N5001")); + Assert.Null(LegacySceneCatalog.FindBySceneCode("5001.t2s")); + Assert.Null(LegacySceneCatalog.FindBySceneCode(null)); + } + + [Fact] + public void PublicCatalogSurface_DoesNotExposeRawObjectMethodOrPathInput() + { + var propertyNames = typeof(LegacySceneDefinition) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(property => property.Name) + .ToArray(); + var disallowedPropertyNames = new[] + { + "ObjectName", + "MethodName", + "AssetPath", + "SceneFile", + "RawMutation" + }; + + Assert.DoesNotContain( + propertyNames, + property => disallowedPropertyNames.Contains(property, StringComparer.Ordinal)); + Assert.All(LegacySceneCatalog.Definitions, definition => + { + Assert.DoesNotContain('/', definition.SourceHashKey); + Assert.DoesNotContain('\\', definition.SourceHashKey); + Assert.All(definition.CutAliases, alias => + Assert.All(alias, character => Assert.True(char.IsLetterOrDigit(character)))); + Assert.Empty(definition.DtoTypeMarker.GetConstructors()); + Assert.Empty(definition.DtoTypeMarker.GetProperties( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)); + }); + } + + private static ExpectedDefinition E( + string builderKey, + string sceneCode, + string[] cutAliases, + LegacyScenePageSize pageSize, + LegacySceneReachability reachability, + LegacySceneMutationCapability capabilities) + where TDtoMarker : ILegacySceneDtoMarker => + new( + builderKey, + sceneCode, + cutAliases, + pageSize, + reachability, + typeof(TDtoMarker), + capabilities); + + private sealed record ExpectedDefinition( + string BuilderKey, + string SceneCode, + IReadOnlyList CutAliases, + LegacyScenePageSize PageSize, + LegacySceneReachability Reachability, + Type DtoTypeMarker, + LegacySceneMutationCapability Capabilities); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneDataSourceRouterTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneDataSourceRouterTests.cs new file mode 100644 index 0000000..4f0a14c --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneDataSourceRouterTests.cs @@ -0,0 +1,72 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacySceneDataSourceRouterTests +{ + [Fact] + public async Task Router_DispatchesOnlyTheClosedRegisteredCutAlias() + { + LegacyPlaylistEntry? received = null; + var router = new LegacySceneDataSourceRouter( + [ + new LegacySceneDataSourceRoute(["5001", "N5001"], (entry, page, _) => + { + received = entry; + Assert.Equal(0, page); + return Task.FromResult(new LegacySceneDataPage( + "s5085", + new S5085SceneData([]), + 1)); + }) + ]); + var request = new LegacyPlaylistEntry("entry", "N5001"); + + var result = await router.LoadPageAsync(request, 0); + + Assert.Same(request, received); + Assert.Equal("s5085", result.BuilderKey); + Assert.Equal(["5001", "N5001"], router.CutCodes); + } + + [Fact] + public async Task Router_RejectsUnknownCutBeforeCallingAnyLoader() + { + var calls = 0; + var router = new LegacySceneDataSourceRouter( + [ + new LegacySceneDataSourceRoute(["5001"], (entry, page, _) => + { + calls++; + return Task.FromResult(new LegacySceneDataPage( + "s5085", + new S5085SceneData([]), + 1)); + }) + ]); + + await Assert.ThrowsAsync(() => + router.LoadPageAsync(new LegacyPlaylistEntry("entry", "9999"), 0)); + + Assert.Equal(0, calls); + } + + [Fact] + public void Constructor_RejectsDuplicateOrUnsafeRoutes() + { + static Task Load( + LegacyPlaylistEntry entry, + int page, + CancellationToken token) => throw new NotSupportedException(); + + Assert.Throws(() => new LegacySceneDataSourceRouter( + [ + new LegacySceneDataSourceRoute(["5001"], Load), + new LegacySceneDataSourceRoute(["5001"], Load) + ])); + Assert.Throws(() => + new LegacySceneDataSourceRoute(["../5001"], Load)); + Assert.Throws(() => + new LegacySceneDataSourceRoute([], Load)); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneMutationBuilderRegistryTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneMutationBuilderRegistryTests.cs new file mode 100644 index 0000000..3dbd1eb --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneMutationBuilderRegistryTests.cs @@ -0,0 +1,146 @@ +using System.Reflection; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacySceneMutationBuilderRegistryTests +{ + [Fact] + public void Default_HasExactOneToOneParityWithThe35SceneCatalog() + { + var registry = LegacySceneMutationBuilderRegistry.Default; + var expectedKeys = LegacySceneCatalog.Definitions + .Select(definition => definition.BuilderKey) + .ToArray(); + + Assert.Equal(35, registry.Registrations.Count); + Assert.Equal(LegacySceneCatalog.ExpectedDefinitionCount, registry.Registrations.Count); + Assert.Equal(expectedKeys, registry.Registrations.Select(item => item.BuilderKey)); + Assert.Equal( + expectedKeys.OrderBy(key => key, StringComparer.Ordinal), + registry.Registrations + .Select(item => item.BuilderKey) + .Distinct(StringComparer.Ordinal) + .OrderBy(key => key, StringComparer.Ordinal)); + + Assert.All(registry.Registrations, registration => + { + Assert.True(registry.TryGetRegistration(registration.BuilderKey, out var found)); + Assert.Same(registration, found); + Assert.True(typeof(ILegacySceneData).IsAssignableFrom(registration.DataType)); + Assert.NotEqual(typeof(ILegacySceneData), registration.DataType); + Assert.False(registration.DataType.IsInterface); + Assert.False(registration.DataType.ContainsGenericParameters); + Assert.False(typeof(ILegacySceneDtoMarker).IsAssignableFrom(registration.DataType)); + }); + } + + [Fact] + public void CoreAssembly_ContainsExactly35PublicParameterlessTypedBuilders() + { + var builderContract = typeof(ILegacySceneMutationBuilder<>); + var builders = typeof(LegacySceneMutationBuilderRegistry) + .Assembly + .DefinedTypes + .Where(type => + type.IsClass && + !type.IsAbstract && + type.ImplementedInterfaces.Any(contract => + contract.IsGenericType && + contract.GetGenericTypeDefinition() == builderContract)) + .ToArray(); + + Assert.Equal(35, builders.Length); + Assert.All(builders, builder => + { + Assert.True(builder.IsPublic); + Assert.NotNull(builder.GetConstructor(Type.EmptyTypes)); + Assert.Single(builder.ImplementedInterfaces, contract => + contract.IsGenericType && + contract.GetGenericTypeDefinition() == builderContract); + }); + } + + [Fact] + public void Build_DispatchesToTheRegisteredTypedBuilderAndReturnsAReadOnlySnapshot() + { + var registry = LegacySceneMutationBuilderRegistry.Default; + var data = new S5085SceneData( + [ + new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 120.5) + ]); + + var expected = new S5085SceneMutationBuilder().Build(data); + var actual = registry.Build("s5085", data); + + Assert.Equal(expected, actual); + var mutableView = Assert.IsAssignableFrom>(actual); + Assert.Throws(() => mutableView.Add( + new PlayoutSetValue("untrusted", "untrusted"))); + } + + [Fact] + public void Build_RejectsUnknownKeysAndDataRegisteredForAnotherScene() + { + var registry = LegacySceneMutationBuilderRegistry.Default; + var wrongData = new S6067SceneData([]); + + Assert.Throws(() => registry.Build("s5085", wrongData)); + Assert.Throws(() => registry.Build("s9999", wrongData)); + Assert.Throws(() => registry.Build("../s5085", wrongData)); + Assert.Throws(() => registry.Build("s5085", null!)); + + Assert.False(registry.TryGetRegistration("s9999", out var unknown)); + Assert.Null(unknown); + Assert.False(registry.TryGetRegistration("../s5085", out var invalid)); + Assert.Null(invalid); + } + + [Fact] + public void PublicRegistryInputSurface_HasNoRawMethodObjectMutationOrPathControls() + { + var prohibitedNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "methodName", + "objectName", + "assetPath", + "sceneFile", + "rawMutation" + }; + var allowedInputTypes = new HashSet + { + typeof(string), + typeof(ILegacySceneData), + typeof(LegacySceneMutationBuilderRegistration).MakeByRefType() + }; + + var publicMethods = typeof(LegacySceneMutationBuilderRegistry) + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly); + Assert.All(publicMethods.SelectMany(method => method.GetParameters()), parameter => + { + Assert.DoesNotContain(parameter.Name!, prohibitedNames); + Assert.Contains(parameter.ParameterType, allowedInputTypes); + if (parameter.ParameterType == typeof(string)) + { + Assert.Equal("builderKey", parameter.Name); + } + }); + + Assert.Equal( + new[] { "BuilderKey", "DataType" }, + typeof(LegacySceneMutationBuilderRegistration) + .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Select(property => property.Name) + .OrderBy(name => name, StringComparer.Ordinal)); + Assert.Empty(typeof(LegacySceneMutationBuilderRegistration) + .GetConstructors(BindingFlags.Instance | BindingFlags.Public)); + + Assert.All(LegacySceneMutationBuilderRegistry.Default.Registrations, registration => + { + var rootDataProperties = registration.DataType + .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly); + Assert.DoesNotContain(rootDataProperties, property => prohibitedNames.Contains(property.Name)); + }); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs new file mode 100644 index 0000000..d65f373 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs @@ -0,0 +1,51 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyScenePreviewTests +{ + [Fact] + public void Preview_DescribesTypedValuesAndNeverExposesAssetPaths() + { + const string secretAsset = @"Video\operator-private-background.vrv"; + var preview = LegacyScenePreviewFactory.Create( + [ + new PlayoutSetValue("title", "삼성전자"), + new PlayoutSetAssetValue("logo", @"Image\005930.png"), + new PlayoutSetVisible("up", true), + new PlayoutSetFaceColor("cube", 1, 2, 3, 4), + new PlayoutSetPosition("bar", 1, 2, 3, PlayoutVectorComponents.XYZ), + new PlayoutSetPositionKey("bar", 2, 4, 5, 6, PlayoutVectorComponents.XY), + new PlayoutSetScale("bar", 7, 8, 9, PlayoutVectorComponents.XYZ), + new PlayoutSetCropKey("candle", 1, 2, 3, 4, 5, PlayoutCropEdges.All), + new PlayoutSetCircleAngleKey("pie", 3, 10, 20, PlayoutAngleComponents.All), + new PlayoutSetPathPoints("line", [new(1, 2), new(3, 4, 5)]), + new PlayoutSetPathShapePoints("shape", [new(6, 7)]), + new PlayoutSetBackgroundTexture(secretAsset), + new PlayoutSetBackgroundVideo(secretAsset, 2004, true), + new PlayoutUseBackground(true) + ]); + + Assert.Collection( + preview, + value => Assert.Equal(new("value", "title", "삼성전자"), value), + value => Assert.Equal(new("asset", "logo", "configured"), value), + value => Assert.Equal(new("visible", "up", "true"), value), + value => Assert.Equal(new("color", "cube", "1,2,3,4"), value), + value => Assert.Equal(new("position", "bar", "1,2,3 [XYZ]"), value), + value => Assert.Equal(new("position-key", "bar", "key=2;4,5,6 [XY]"), value), + value => Assert.Equal(new("scale", "bar", "7,8,9 [XYZ]"), value), + value => Assert.Equal(new("crop", "candle", "key=1;2,3,4,5 [All]"), value), + value => Assert.Equal(new("angle", "pie", "key=3;10,20 [All]"), value), + value => Assert.Equal(new("path", "line", "count=2;1,2,0;3,4,5"), value), + value => Assert.Equal(new("path-shape", "shape", "count=1;6,7,0"), value), + value => Assert.Equal(new("background-texture", "scene", "configured"), value), + value => Assert.Equal( + new("background-video", "scene", "configured;loop=2004;infinite=true"), + value), + value => Assert.Equal(new("background", "scene", "enabled"), value)); + Assert.DoesNotContain(secretAsset, string.Join("|", preview.Select(value => value.Value))); + Assert.DoesNotContain("005930.png", string.Join("|", preview.Select(value => value.Value))); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs new file mode 100644 index 0000000..e5e7389 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs @@ -0,0 +1,47 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacySceneRefreshPolicyTests +{ + public static TheoryData OriginalMainFormIntervals => new() + { + { "5001", 2 }, { "N5001", 2 }, { "5006", 5 }, { "5011", 5 }, + { "5016", 5 }, { "50160", 30 }, { "5023", 30 }, { "5024", 60 }, + { "5025", 30 }, { "5026", 8 }, { "5029", 7 }, { "5032", 5 }, + { "5037", 5 }, { "5074", 7 }, { "5076", 3_000_000 }, { "5077", 7 }, + { "5078", 30 }, { "5079", 3_000_000 }, { "5080", 3_000_000 }, + { "5081", 3_000_000 }, { "5082", 6 }, { "5083", 60 }, { "5084", 5 }, + { "5085", 30 }, { "5086", 7 }, { "50860", 7 }, { "5087", 7 }, + { "5088", 7 }, { "6001", 30 }, { "6067", 30 }, { "8001", 120 }, + { "8002", 120 }, { "8003", 8 }, { "8018", 5 }, { "8032", 5 }, + { "8035", 8 }, { "8040", 8 }, { "8046", 8 }, { "8051", 8 }, + { "8056", 8 }, { "8061", 8 }, { "8067", 30 }, { "5068", 30 }, + { "5070", 30 }, { "5072", 30 } + }; + + [Theory] + [MemberData(nameof(OriginalMainFormIntervals))] + public void EveryActiveAliasUsesOriginalMainFormInterval(string alias, int seconds) + { + Assert.True(LegacySceneRefreshPolicy.TryGetInterval(alias, out var interval)); + Assert.Equal(TimeSpan.FromSeconds(seconds), interval); + } + + [Fact] + public void PolicyCoversExactlyTheCatalogsActiveAliases() + { + var aliases = LegacySceneCatalog.Definitions + .SelectMany(definition => definition.CutAliases) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(45, aliases.Length); + Assert.Equal(aliases.Length, LegacySceneRefreshPolicy.Count); + Assert.All(aliases, alias => + Assert.True(LegacySceneRefreshPolicy.TryGetInterval(alias, out _), alias)); + Assert.False(LegacySceneRefreshPolicy.TryGetInterval("8086", out _)); + Assert.False(LegacySceneRefreshPolicy.TryGetInterval("../5001", out _)); + Assert.Equal(TimeSpan.FromSeconds(3), LegacySceneRefreshPolicy.RecurringInterval); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRuntimeCoverageTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRuntimeCoverageTests.cs new file mode 100644 index 0000000..b529ef7 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRuntimeCoverageTests.cs @@ -0,0 +1,145 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacySceneRuntimeCoverageTests +{ + private static readonly string[] ExpectedAliases = + [ + "5001", "5006", "5011", "5016", "50160", "5023", "5024", "5025", + "5026", "5029", "5032", "5037", "5068", "5070", "5072", "5074", + "5076", "5077", "5078", "5079", "5080", "5081", "5082", "5083", + "5084", "5085", "5086", "50860", "5087", "5088", "6001", "6067", + "8001", "8002", "8003", "8018", "8032", "8035", "8040", "8046", + "8051", "8056", "8061", "8067", "N5001" + ]; + + [Fact] + public void Complete_router_matches_the_independent_34_builder_45_alias_baseline() + { + var router = Router(ExpectedAliases); + + var report = LegacySceneRuntimeCoverage.Analyze(router); + + Assert.True(report.IsComplete); + Assert.Equal(34, report.ReachableBuilderKeys.Count); + Assert.Equal(45, report.ExpectedCutCodes.Count); + Assert.Equal(ExpectedAliases, report.ExpectedCutCodes); + Assert.Equal(ExpectedAliases, report.RegisteredCutCodes); + Assert.Empty(report.MissingCutCodes); + Assert.Empty(report.UnexpectedCutCodes); + Assert.Empty(report.UncoveredBuilderKeys); + Assert.Equal(["s8086"], report.UnreachableBuilderKeys); + Assert.DoesNotContain("s8086", report.ReachableBuilderKeys); + Assert.DoesNotContain("8086", report.ExpectedCutCodes); + Assert.DoesNotContain("8010", report.ExpectedCutCodes); + + var validated = LegacySceneRuntimeCoverage.Validate(router); + Assert.True(validated.IsComplete); + } + + [Fact] + public void Conditional_aliases_are_owned_only_by_s5032_and_s8018() + { + var report = LegacySceneRuntimeCoverage.Validate(Router(ExpectedAliases)); + + Assert.Equal( + new[] { "5032", "8018", "8032" }, + report.ConditionalAliasOwners.Keys.OrderBy(key => key, StringComparer.Ordinal)); + Assert.All(report.ConditionalAliasOwners.Values, owners => + Assert.Equal(new[] { "s5032", "s8018" }, owners)); + Assert.Contains("s5032", report.ReachableBuilderKeys); + Assert.Contains("s8018", report.ReachableBuilderKeys); + } + + [Fact] + public void Missing_direct_alias_identifies_the_single_uncovered_builder() + { + var router = Router(ExpectedAliases.Where(alias => alias != "5078")); + + var report = LegacySceneRuntimeCoverage.Analyze(router); + + Assert.False(report.IsComplete); + Assert.Equal(["5078"], report.MissingCutCodes); + Assert.Empty(report.UnexpectedCutCodes); + Assert.Equal(["s5078"], report.UncoveredBuilderKeys); + Assert.Throws(() => + LegacySceneRuntimeCoverage.Validate(router)); + } + + [Fact] + public void Missing_shared_alias_marks_both_conditional_builders_uncovered() + { + var router = Router(ExpectedAliases.Where(alias => alias != "8032")); + + var report = LegacySceneRuntimeCoverage.Analyze(router); + + Assert.False(report.IsComplete); + Assert.Equal(["8032"], report.MissingCutCodes); + Assert.Equal(new[] { "s5032", "s8018" }, report.UncoveredBuilderKeys); + Assert.Equal( + new[] { "s5032", "s8018" }, + report.ConditionalAliasOwners["8032"]); + } + + [Fact] + public void Unreachable_s8086_alias_is_rejected_as_an_unexpected_runtime_route() + { + var router = Router(ExpectedAliases.Append("8086")); + + var report = LegacySceneRuntimeCoverage.Analyze(router); + + Assert.False(report.IsComplete); + Assert.Empty(report.MissingCutCodes); + Assert.Equal(["8086"], report.UnexpectedCutCodes); + Assert.Empty(report.UncoveredBuilderKeys); + Assert.Equal(["s8086"], report.UnreachableBuilderKeys); + Assert.Throws(() => + LegacySceneRuntimeCoverage.Validate(router)); + } + + [Fact] + public void Unknown_extra_alias_is_rejected_even_when_all_expected_aliases_exist() + { + var router = Router(ExpectedAliases.Append("9999")); + + var report = LegacySceneRuntimeCoverage.Analyze(router); + + Assert.False(report.IsComplete); + Assert.Equal(["9999"], report.UnexpectedCutCodes); + Assert.Equal(46, report.RegisteredCutCodes.Count); + } + + [Fact] + public void Public_expected_alias_surface_is_sorted_stable_and_read_only() + { + Assert.Equal(45, LegacySceneRuntimeCoverage.ExpectedActiveAliasCount); + Assert.Equal(34, LegacySceneRuntimeCoverage.ExpectedReachableBuilderCount); + Assert.Equal(ExpectedAliases, LegacySceneRuntimeCoverage.ExpectedCutCodes); + Assert.Equal( + ExpectedAliases.OrderBy(alias => alias, StringComparer.Ordinal), + LegacySceneRuntimeCoverage.ExpectedCutCodes); + Assert.IsAssignableFrom>( + LegacySceneRuntimeCoverage.ExpectedCutCodes); + } + + [Fact] + public void Null_router_is_rejected() + { + Assert.Throws(() => + LegacySceneRuntimeCoverage.Analyze(null!)); + Assert.Throws(() => + LegacySceneRuntimeCoverage.Validate(null!)); + } + + private static LegacySceneDataSourceRouter Router(IEnumerable cutCodes) => new( + [ + new LegacySceneDataSourceRoute(cutCodes, Load) + ]); + + private static Task Load( + LegacyPlaylistEntry entry, + int page, + CancellationToken cancellationToken) => + throw new NotSupportedException(); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWebWorkflowIntegrationTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWebWorkflowIntegrationTests.cs new file mode 100644 index 0000000..feb6b95 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWebWorkflowIntegrationTests.cs @@ -0,0 +1,177 @@ +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 LegacyWebWorkflowIntegrationTests +{ + [Fact] + public async Task WebPrepareThenNext_UsesNativeDtoBuilderAndSameScenePageDecision() + { + var engine = new RecordingEngine(); + var rows = Enumerable.Range(1, 6) + .Select(index => new PagedQuoteRowData( + "종목" + index, + index * 100, + index, + index / 10d, + ScenePriceDirection.Up)) + .ToArray(); + var dataSource = new LegacySceneDataSourceRouter( + [ + new LegacySceneDataSourceRoute(["5074"], (entry, page, token) => + { + token.ThrowIfCancellationRequested(); + var data = new PagedQuoteSceneData( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "상승률", + rows, + page + 1); + return Task.FromResult(new LegacySceneDataPage("s5074", data, rows.Length)); + }) + ]); + var workflow = new LegacyPlayoutWorkflow( + engine, + new RegisteredLegacySceneCueProvider(dataSource)); + + using var prepareJson = JsonDocument.Parse(""" + { + "requestId":"REQ_PREPARE", + "command":"prepare", + "playlist":[{ + "id":"entry-1", + "code":"5074", + "enabled":true, + "selection":{ + "groupCode":"KOSPI_STOCK", + "subject":"테스트", + "graphicType":"", + "subtype":"", + "dataCode":"000001" + } + }], + "selectedIndex":0 + } + """); + var prepare = PlayoutBridgeProtocol.ParseWorkflowCommand(prepareJson.RootElement); + Assert.True(prepare.IsValid); + + Assert.True((await workflow.PrepareAsync( + prepare.Request.Playlist!, + prepare.Request.SelectedIndexZeroBased)).IsSuccess); + Assert.Equal("entry-1", workflow.State.CurrentEntryId); + Assert.Equal("s5074", workflow.State.BuilderKey); + Assert.Equal(5, workflow.State.CurrentPageItemCount); + Assert.NotEmpty(workflow.State.PreviewFields); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + + using var nextJson = JsonDocument.Parse( + "{\"requestId\":\"REQ_NEXT\",\"command\":\"next\"}"); + var next = PlayoutBridgeProtocol.ParseWorkflowCommand(nextJson.RootElement); + Assert.True(next.IsValid); + Assert.Null(next.Request.Playlist); + + Assert.True((await workflow.NextAsync()).IsSuccess); + + Assert.Equal(1, workflow.State.PageIndexZeroBased); + Assert.Equal(2, workflow.State.PageCount); + Assert.True(workflow.State.IsLastPage); + Assert.Equal(1, workflow.State.CurrentPageItemCount); + Assert.Contains(workflow.State.PreviewFields, field => + field is { Kind: "value", Name: "title1", Value: "종목6" }); + Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind); + Assert.Equal( + ["prepare:5074", "prepare:5074", "take-in", "load-next:5074"], + engine.Calls); + Assert.NotNull(engine.LastCue?.Mutations); + Assert.Equal(new PlayoutUseBackground(false), engine.LastCue!.Mutations![0]); + Assert.Contains(engine.LastCue.Mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "title1", Value: "종목6" }); + } + + private sealed class RecordingEngine : IPlayoutEngine + { + public List Calls { get; } = []; + + public PlayoutCue? LastCue { get; private set; } + + public PlayoutStatus Status { get; } = new( + PlayoutMode.DryRun, + PlayoutConnectionState.DryRunReady, + true, + false, + true, + false, + null, + null, + "test", + DateTimeOffset.UtcNow, + 0); + + public event EventHandler? StatusChanged + { + add { } + remove { } + } + + public Task ConnectAsync(CancellationToken cancellationToken = default) => + Success(PlayoutOperation.Connect); + + public Task DisconnectAsync(CancellationToken cancellationToken = default) => + Success(PlayoutOperation.Disconnect); + + public Task PrepareAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + LastCue = cue; + Calls.Add("prepare:" + cue.SceneName); + return Success(PlayoutOperation.Prepare); + } + + public Task TakeInAsync(CancellationToken cancellationToken = default) + { + Calls.Add("take-in"); + return Success(PlayoutOperation.TakeIn); + } + + public Task NextAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + LastCue = cue; + Calls.Add("load-next:" + cue.SceneName); + return Success(PlayoutOperation.Next); + } + + public Task UpdateOnAirAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + LastCue = cue; + Calls.Add("page-next:" + cue.SceneName); + return Success(PlayoutOperation.UpdateOnAir); + } + + public Task TakeOutAsync( + PlayoutTakeOutScope scope, + CancellationToken cancellationToken = default) => + Success(PlayoutOperation.TakeOut); + + public ValueTask QuarantineAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private static Task Success(PlayoutOperation operation) => + Task.FromResult(new PlayoutResult( + operation, + PlayoutResultCode.Success, + true, + "ok", + DateTimeOffset.UtcNow)); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ManualSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ManualSceneDataLoadersTests.cs new file mode 100644 index 0000000..00a76d0 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ManualSceneDataLoadersTests.cs @@ -0,0 +1,142 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ManualSceneDataLoadersTests +{ + [Fact] + public async Task S5076_UsesAParameterizedOracleQueryAndMapsExplicitDto() + { + var table = Table( + ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"], + ["A사", "2026.07", "국내_25.5", "해외_74.5", "", "", ""]); + var executor = new RecordingExecutor(table); + + var data = await new S5076SceneDataLoader(executor) + .LoadAsync("A사", CancellationToken.None); + + Assert.Equal(DataSourceKind.Oracle, executor.Source); + Assert.Equal("INPUT_PIE", executor.TableName); + Assert.NotNull(executor.Spec); + Assert.Contains("WHERE STOCK_NAME = :stockName", executor.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(executor.Spec.Parameters); + Assert.Equal("stockName", parameter.Name); + Assert.Equal("A사", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + Assert.Equal("A사", data.StockName); + Assert.Equal("2026.07", data.Basis); + Assert.Equal(new RevenueSliceData("국내", "25.5", 25.5), data.Slices[0]); + Assert.Equal(new RevenueSliceData("해외", "74.5", 74.5), data.Slices[1]); + Assert.Null(data.Slices[2]); + } + + [Fact] + public async Task S5081_MapsSixTypedQuarterValuesWithoutStringSqlFallback() + { + var table = Table( + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"], + ["A사", "1Q_100", "2Q_-50", "3Q_", "4Q_25", "5Q_1,000", "6Q_-2"]); + var executor = new RecordingExecutor(table); + + var data = await new S5081SceneDataLoader(executor) + .LoadAsync("A사", CancellationToken.None); + + Assert.Equal( + new[] { 100, -50, 0, 25, 1000, -2 }, + data.Quarters.Select(item => item.Value)); + Assert.Equal( + new[] { "1Q", "2Q", "3Q", "4Q", "5Q", "6Q" }, + data.Quarters.Select(item => item.Quarter)); + Assert.Equal(1, executor.SpecCalls); + Assert.Equal(0, executor.StringCalls); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public async Task S5076_RejectsMissingOrAmbiguousRowsBeforeMutation(int rowCount) + { + var table = Table( + ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"]); + for (var index = 0; index < rowCount; index++) + { + table.Rows.Add("A사", "2026", "A_100", "", "", "", ""); + } + + await Assert.ThrowsAsync(() => + new S5076SceneDataLoader(new RecordingExecutor(table)) + .LoadAsync("A사", CancellationToken.None)); + } + + [Fact] + public async Task LoaderErrorsDoNotIncludeTheSelectedStockName() + { + const string sensitiveSelection = "SENSITIVE-SELECTION"; + var exception = await Assert.ThrowsAsync(() => + new S5076SceneDataLoader(new RecordingExecutor(new DataTable())) + .LoadAsync(sensitiveSelection, CancellationToken.None)); + + Assert.DoesNotContain(sensitiveSelection, exception.Message, StringComparison.Ordinal); + } + + private static DataTable Table(string[] columns, object?[]? row = null) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + if (row is not null) + { + table.Rows.Add(row); + } + + return table; + } + + private sealed class RecordingExecutor(DataTable result) : IDataQueryExecutor + { + public DataSourceKind Source { get; private set; } + + public string? TableName { get; private set; } + + public DataQuerySpec? Spec { get; private set; } + + public int SpecCalls { get; private set; } + + public int StringCalls { get; private set; } + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + StringCalls++; + throw new InvalidOperationException("String query fallback is forbidden in this test."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + throw new InvalidOperationException("String query fallback is forbidden in this test."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Source = source; + TableName = tableName; + Spec = query; + SpecCalls++; + return Task.FromResult(result.Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/MarketPanelSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/MarketPanelSceneDataLoadersTests.cs new file mode 100644 index 0000000..a323d33 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/MarketPanelSceneDataLoadersTests.cs @@ -0,0 +1,388 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class MarketPanelSceneDataLoadersTests +{ + private static readonly string[] PanelColumns = + ["ORDINAL", "NAME", "PART_NAME", "PRICE", "CHANGE", "RATE", "SIGNAL"]; + + private static readonly string[] QuoteColumns = + ["TITLE", "PRICE", "CHANGE", "RATE", "SIGNAL"]; + + private static readonly string[] WorldColumns = + ["ORDINAL", "MARKET_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", "SIGNAL"]; + + public static TheoryData S5016Cases => new() + { + P(S5016PanelTarget.UnitedStatesIndices, "t_world_ix_eq_master", "symbol1", "DJI@DJI"), + P(S5016PanelTarget.GreaterChinaIndices, "t_world_ix_eq_master", "symbol1", "HSI@HSI"), + P(S5016PanelTarget.EuropeanIndices, "t_world_ix_eq_master", "symbol1", "LNS@FTSE100"), + P(S5016PanelTarget.AsianIndices, "t_world_ix_eq_master", "symbol1", "SHS@000001"), + P(S5016PanelTarget.UnitedStatesTreasuries, "t_world_ix_eq_master", "symbol1", "GVO@TR02Y"), + P(S5016PanelTarget.BondYields, "t_world_ix_eq_master", "symbol1", "KIR@CMAA"), + P(S5016PanelTarget.Oil, "t_world_future_master", "symbol1", "SPT@DU"), + P(S5016PanelTarget.Minerals, "t_world_future_master", "symbol1", "COM@GC"), + P(S5016PanelTarget.FoodMaterials, "t_world_future_master", "symbol1", "CBT$CORN"), + P(S5016PanelTarget.OverseasExchangeRates, "t_world_forex_master", "symbol1", "USDJPYCOMP"), + P(S5016PanelTarget.DomesticExchangeRates, "t_input_index", "symbol1", "A09"), + P(S5016PanelTarget.AgricultureWheatCorn, "t_world_future_master", "symbol1", "MK@WHEAT"), + P(S5016PanelTarget.AgricultureSoyRice, "t_world_future_master", "symbol1", "CBT@SOYBEAN"), + P(S5016PanelTarget.AgricultureCoffeeCocoaSugar, "t_world_future_master", "symbol1", "NYB@KC"), + P(S5016PanelTarget.AgricultureLivestock, "t_world_future_master", "symbol1", "MK@LC"), + P(S5016PanelTarget.RawMaterialsCopperIronNickel, "t_world_future_master", "symbol1", "LME@CDY"), + P(S5016PanelTarget.RawMaterialsGasPlatinumPalladium, "t_world_future_master", "symbol1", "NYM@NG"), + P(S5016PanelTarget.RawMaterialsLeadZincTin, "t_world_future_master", "symbol1", "MK@PDA"), + P(S5016PanelTarget.MajorKospiKosdaqKospi200, "FROM t_index", "partCode1", "001"), + P(S5016PanelTarget.MajorKospiKosdaqFutures, "FROM t_index", "partCode1", "001"), + P(S5016PanelTarget.ExpectedKospiKosdaqKospi200, "FROM t_fo_index", "partCode1", "001"), + P(S5016PanelTarget.ExpectedKospiKosdaqFutures, "FROM t_fo_index", "partCode1", "001") + }; + + public static TheoryData S50160Cases => new() + { + { S50160PanelTarget.Cotton, "MK@COTTON" }, + { S50160PanelTarget.InternationalPreciousMetals, "COM@GC" }, + { S50160PanelTarget.DomesticPreciousMetals, "MK@GC" } + }; + + public static TheoryData S6001Cases => new() + { + Q(S6001QuoteTarget.DowJones, "DJI@DJI", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Nasdaq, "NAS@IXIC", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.StandardAndPoor500, "SPI@SPX", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Germany, "XTR@DAX30", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.UnitedKingdom, "LNS@FTSE100", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.France, "PAS@CAC40", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Japan, "NII@NI225", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.China, "SHS@000001", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.HongKong, "HSI@HSI", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Taiwan, "TWS@TI01", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Singapore, "SGI@STI", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Thailand, "THI@SET", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Philippines, "PHI@COMP", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Malaysia, "IDI@JKSE", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.Indonesia, "MYI@KLSE", "t_world_ix_eq_master"), + Q(S6001QuoteTarget.DubaiCrude, "SPT@DU", "t_world_future_master"), + Q(S6001QuoteTarget.BrentCrude, "SPT@EB", "t_world_future_master"), + Q(S6001QuoteTarget.WtiCrude, "SPT@CL", "t_world_future_master"), + Q(S6001QuoteTarget.Gold, "COM@GC", "t_world_future_master") + }; + + public static TheoryData S8086Cases => new() + { + { S8086DiagnosticTarget.DubaiCrude, "SPT@DU", CommodityUnit.DollarsPerBarrel }, + { S8086DiagnosticTarget.BrentCrude, "SPT@EB", CommodityUnit.DollarsPerBarrel }, + { S8086DiagnosticTarget.WtiCrude, "SPT@CL", CommodityUnit.DollarsPerBarrel }, + { S8086DiagnosticTarget.Gold, "COM@GC", CommodityUnit.DollarsPerOunce }, + { S8086DiagnosticTarget.Silver, "COM@SI", CommodityUnit.DollarsPerOunce }, + { S8086DiagnosticTarget.Copper, "LME@CDY", CommodityUnit.DollarsPerOunce } + }; + + [Theory] + [MemberData(nameof(S5016Cases))] + public async Task S5016_all_closed_targets_use_ordered_parameterized_three_row_queries( + PanelRouteCase item) + { + var executor = new RecordingExecutor(PanelTable(3)); + + var data = await new S5016SceneDataLoader(executor).LoadAsync( + item.Target, + CancellationToken.None); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("S5016_PANEL", call.TableName); + Assert.Contains(item.SourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY ORDINAL", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains(call.Spec.Parameters, parameter => + parameter.Name == item.ParameterName && Equals(parameter.Value, item.ParameterValue)); + Assert.All(call.Spec.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType)); + Assert.Equal(["Name1", "Name2", "Name3"], data.Quotes.Select(quote => quote.Name)); + Assert.Equal(0, executor.StringCalls); + + var mutations = new S5016SceneMutationBuilder().Build(data); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "title3" }); + } + + [Theory] + [MemberData(nameof(S50160Cases))] + public async Task S50160_targets_bind_source_symbols_and_require_two_ordered_rows( + S50160PanelTarget target, + string firstSymbol) + { + var executor = new RecordingExecutor(PanelTable(2)); + + var data = await new S50160SceneDataLoader(executor).LoadAsync( + target, + CancellationToken.None); + + var call = Assert.Single(executor.Calls); + Assert.Equal("S50160_PANEL", call.TableName); + Assert.Contains("t_world_future_master", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains(call.Spec.Parameters, parameter => + parameter.Name == "symbol1" && Equals(parameter.Value, firstSymbol)); + Assert.Equal(2, data.Quotes.Count); + Assert.NotEmpty(new S50160SceneMutationBuilder().Build(data)); + } + + [Theory] + [MemberData(nameof(S6001Cases))] + public async Task S6001_target_selects_one_bound_symbol_and_only_builder_owned_relative_asset( + S6001RouteCase item) + { + var executor = new RecordingExecutor(QuoteTable()); + + var data = await new S6001SceneDataLoader(executor).LoadAsync( + item.Target, + CancellationToken.None); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("S6001_QUOTE", call.TableName); + Assert.Contains(item.SourceFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("symbol", parameter.Name); + Assert.Equal(item.Symbol, parameter.Value); + Assert.DoesNotContain(item.Symbol, call.Spec.Sql, StringComparison.Ordinal); + + var assets = new S6001SceneMutationBuilder().Build(data) + .OfType() + .ToArray(); + Assert.Contains(assets, asset => asset.ObjectName == "movie"); + Assert.All(assets, asset => + { + Assert.False(Path.IsPathRooted(asset.AssetPath)); + Assert.DoesNotContain(':', asset.AssetPath); + Assert.DoesNotContain("..", asset.AssetPath, StringComparison.Ordinal); + }); + Assert.DoesNotContain( + typeof(S6001SceneDataLoader).GetMethods().SelectMany(method => method.GetParameters()), + parameterInfo => parameterInfo.Name?.Contains("path", StringComparison.OrdinalIgnoreCase) == true); + } + + [Theory] + [MemberData(nameof(S8086Cases))] + public async Task S8086_diagnostic_loader_is_closed_source_only_and_has_no_cut_alias( + S8086DiagnosticTarget target, + string symbol, + CommodityUnit unit) + { + var executor = new RecordingExecutor(QuoteTable()); + + var data = await new S8086DiagnosticSceneDataLoader(executor).LoadAsync( + target, + CancellationToken.None); + + Assert.Equal(unit, data.Unit); + var call = Assert.Single(executor.Calls); + Assert.Equal("S8086_DIAGNOSTIC", call.TableName); + Assert.Equal(symbol, Assert.Single(call.Spec.Parameters).Value); + Assert.NotEmpty(new S8086SceneMutationBuilder().Build(data)); + + var definition = Assert.Single( + LegacySceneCatalog.Definitions, + item => item.BuilderKey == "s8086"); + Assert.Empty(definition.CutAliases); + } + + [Fact] + public async Task S8067_requires_all_eleven_markets_in_fixed_order_and_generates_map_mutations() + { + var executor = new RecordingExecutor(WorldTable()); + + var data = await new S8067SceneDataLoader(executor).LoadAsync(); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("S8067_WORLD_MAP", call.TableName); + Assert.Equal(11, call.Spec.Parameters.Count); + Assert.Contains("UNION ALL", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY ORDINAL", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Equal(Enum.GetValues(), data.Quotes.Select(quote => quote.Market)); + Assert.NotEmpty(new S8067SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task Panel_schemas_counts_ordinals_and_signals_fail_closed() + { + var shortPanel = PanelTable(2); + await Assert.ThrowsAsync(() => + new S5016SceneDataLoader(new RecordingExecutor(shortPanel)).LoadAsync( + S5016PanelTarget.Oil, + CancellationToken.None)); + + var wrongOrder = PanelTable(3); + wrongOrder.Rows[1]["ORDINAL"] = 3; + await Assert.ThrowsAsync(() => + new S5016SceneDataLoader(new RecordingExecutor(wrongOrder)).LoadAsync( + S5016PanelTarget.Oil, + CancellationToken.None)); + + var unknownSignal = QuoteTable(); + unknownSignal.Rows[0]["SIGNAL"] = "UNKNOWN"; + await Assert.ThrowsAsync(() => + new S6001SceneDataLoader(new RecordingExecutor(unknownSignal)).LoadAsync( + S6001QuoteTarget.Gold, + CancellationToken.None)); + + var reordered = Table( + ["NAME", "ORDINAL", "PART_NAME", "PRICE", "CHANGE", "RATE", "SIGNAL"]); + await Assert.ThrowsAsync(() => + new S50160SceneDataLoader(new RecordingExecutor(reordered)).LoadAsync( + S50160PanelTarget.Cotton, + CancellationToken.None)); + } + + [Fact] + public async Task Invalid_closed_targets_fail_before_database_access() + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S5016SceneDataLoader(executor).LoadAsync((S5016PanelTarget)999)); + await Assert.ThrowsAsync(() => + new S50160SceneDataLoader(executor).LoadAsync((S50160PanelTarget)999)); + await Assert.ThrowsAsync(() => + new S6001SceneDataLoader(executor).LoadAsync((S6001QuoteTarget)999)); + await Assert.ThrowsAsync(() => + new S8086DiagnosticSceneDataLoader(executor).LoadAsync((S8086DiagnosticTarget)999)); + + Assert.Empty(executor.Calls); + } + + private static PanelRouteCase P( + S5016PanelTarget target, + string sourceFragment, + string parameterName, + string parameterValue) => + new(target, sourceFragment, parameterName, parameterValue); + + private static S6001RouteCase Q( + S6001QuoteTarget target, + string symbol, + string sourceFragment) => + new(target, symbol, sourceFragment); + + private static DataTable PanelTable(int count) + { + var table = Table(PanelColumns); + for (var index = 1; index <= count; index++) + { + table.Rows.Add( + index, + "Name" + index, + "Part" + index, + 100m + index, + index == 2 ? -2m : index, + index == 2 ? -1.5m : index, + index switch { 1 => "+", 2 => "-", _ => "" }); + } + + return table; + } + + private static DataTable QuoteTable() => Table( + QuoteColumns, + ["Quote", 100.25m, -1.5m, -1.48m, "-"]); + + private static DataTable WorldTable() + { + string[] codes = + [ + "KOREA", "CHINA", "HONG_KONG", "JAPAN", "TAIWAN", "DOW", + "NASDAQ", "SP", "UNITED_KINGDOM", "GERMANY", "FRANCE" + ]; + var table = Table(WorldColumns); + for (var index = 0; index < codes.Length; index++) + { + table.Rows.Add( + index + 1, + codes[index], + 100d + index, + index == 2 ? 0d : 1d, + 1d, + index == 2 ? "-" : "+"); + } + + return table; + } + + private static DataTable Table(string[] columns, object?[]? row = null) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + if (row is not null) + { + table.Rows.Add(row); + } + + return table; + } + + public sealed record PanelRouteCase( + S5016PanelTarget Target, + string SourceFragment, + string ParameterName, + string ParameterValue); + + public sealed record S6001RouteCase( + S6001QuoteTarget Target, + string Symbol, + string SourceFragment); + + 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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No result was configured."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs new file mode 100644 index 0000000..1708744 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs @@ -0,0 +1,353 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class PagedQuoteSceneBuildersTests +{ + public static TheoryData HeaderCases => new() + { + new( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "Domestic", + "Domestic", + "\uB2E8\uC704 : \uC6D0", + "\uD604\uC7AC\uAC00", + null), + new( + PagedQuoteBranch.Nxt, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.PreMarket, + "NXT", + "NXT", + "\uB2E8\uC704 : \uC6D0", + "\uD604\uC7AC\uAC00", + "Images\\\uD504\uB9AC\uB9C8\uCF13.png"), + new( + PagedQuoteBranch.NxtTheme, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.ExpectedExecution, + NxtMarketSession.AfterMarket, + "Theme", + "Theme \uC608\uC0C1 \uCCB4\uACB0\uAC00", + "\uB2E8\uC704 : \uC6D0", + "\uD604\uC7AC\uAC00", + "Images\\\uC560\uD504\uD130\uB9C8\uCF13.png"), + new( + PagedQuoteBranch.Theme, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "Theme", + "Theme", + "\uB2E8\uC704 : \uC6D0", + "\uD604\uC7AC\uAC00", + null), + new( + PagedQuoteBranch.Expert, + PagedQuoteHeaderStyle.ExpertReturns, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "Expert", + "Expert", + "\uB2E8\uC704 : \uC6D0", + "\uB9E4\uC218\uAC00", + null), + new( + PagedQuoteBranch.Vi, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "VI", + "VI", + "\uB2E8\uC704 : \uC6D0", + "\uD604\uC7AC\uAC00", + null), + new( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.IndustryPoints, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "Industry", + "Industry", + "\uB2E8\uC704 : p", + "\uD604\uC7AC", + null) + }; + + [Fact] + public void S5074_nxt_asset_direction_and_partial_clear_match_golden_order() + { + var data = Data( + count: 1, + pageNumberOneBased: 1, + branch: PagedQuoteBranch.NxtTheme, + header: PagedQuoteHeaderStyle.StandardWon, + session: NxtMarketSession.PreMarket, + title: "Theme"); + var actual = new S5074SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "S:mart=0", + "S:row1=0", "S:row2=0", "S:row3=0", "S:row4=0", "S:row5=0", + "V:title=Theme", "A:mart=Images\\\uD504\uB9AC\uB9C8\uCF13.png", "S:mart=1", + "V:unit=\uB2E8\uC704 : \uC6D0", "V:column1=\uD604\uC7AC\uAC00", + "V:column2=\uB4F1\uB77D\uAC00", "V:column3=\uB4F1\uB77D\uB960", + "S:row1=1", "V:title1=Row001", "V:price1=1,001", + "V:changePrice1_1=-1", "V:changePrice1_2=-1", "V:changePrice1_3=-1", + "V:rate1=-0.25", + "S:upup1=0", "S:up1=0", "S:flat1=0", "S:down1=0", "S:downdown1=0", + "S:bg1_1=0", "S:bg1_2=0", "S:bg1_3=1", + "S:changePrice1_1=0", "S:changePrice1_2=0", "S:changePrice1_3=1", + "S:down1=1" + ], actual.Take(32)); + Assert.Equal( + [ + "S:row2=0", "V:title2=", "V:price2=", + "V:changePrice2_1=", "V:changePrice2_2=", "V:changePrice2_3=", "V:rate2=", + "S:upup2=0", "S:up2=0", "S:flat2=0", "S:down2=0", "S:downdown2=0", + "S:bg2_1=0", "S:bg2_2=0", "S:bg2_3=0", + "S:changePrice2_1=0", "S:changePrice2_2=0", "S:changePrice2_3=0" + ], actual.Skip(32).Take(18)); + Assert.Equal( + "E1BC9AAD7BC6A54A871922A5C222971A94944B66099404F08F70F972AE86CED2", + SnapshotHash(actual)); + } + + [Fact] + public void S5077_expert_header_and_wide_direction_variants_match_golden_order() + { + var data = Data( + count: 6, + pageNumberOneBased: 1, + branch: PagedQuoteBranch.Expert, + header: PagedQuoteHeaderStyle.ExpertReturns, + session: NxtMarketSession.NotApplicable, + title: "Expert"); + var actual = new S5077SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Equal( + [ + "S:mart=0", + "S:row1=0", "S:row2=0", "S:row3=0", "S:row4=0", "S:row5=0", "S:row6=0", + "V:title=Expert", "S:mart=0", + "V:unit=\uB2E8\uC704 : \uC6D0", "V:column1=\uB9E4\uC218\uAC00", + "V:column2=\uD604\uC7AC\uAC00", "V:column3=\uC218\uC775\uB960", + "S:row1=1", "V:title1=Row001", "V:price1=1,001", + "V:changePrice1_1=-1", "V:changePrice1_2=-1", "V:changePrice1_3=-1", + "V:rate1_1=-0.25", "V:rate1_2=-0.25", "V:rate1_3=-0.25", + "S:upup1=0", "S:up1=0", "S:flat1=0", "S:down1=0", "S:downdown1=0", + "S:bg1_1=0", "S:bg1_2=0", "S:bg1_3=1", + "S:changePrice1_1=0", "S:changePrice1_2=0", "S:changePrice1_3=1", + "S:rate1_1=0", "S:rate1_2=0", "S:rate1_3=1", + "S:percent1_1=0", "S:percent1_2=0", "S:percent1_3=1", + "S:down1=1" + ], actual.Take(40)); + Assert.Equal( + "5BB1952AF0BF7AAE1CEBED9B318C7216E1A61B9D4B95E729701489AF21AC78E5", + SnapshotHash(actual)); + } + + [Fact] + public void S5088_page_two_uses_twelve_row_offset_and_clears_remaining_slots() + { + var data = Data( + count: 13, + pageNumberOneBased: 2, + branch: PagedQuoteBranch.Domestic, + header: PagedQuoteHeaderStyle.StandardWon, + session: NxtMarketSession.NotApplicable, + title: "Twelve"); + var actual = new S5088SceneMutationBuilder().Build(data).Select(Describe).ToArray(); + + Assert.Contains("V:title1=Row013", actual); + Assert.DoesNotContain("V:title1=Row006", actual); + Assert.Contains("V:title2=", actual); + Assert.Equal(11, actual.Count(item => + item.StartsWith("V:title", StringComparison.Ordinal) && item.EndsWith('=') && + item != "V:title=")); + Assert.Equal( + "65D6B6A718EC77A6CA9071273E04D177F8DDFC34D736E9902BCCA0F12D8646C0", + SnapshotHash(actual)); + } + + [Theory] + [InlineData("s5074", 5)] + [InlineData("s5077", 6)] + [InlineData("s5088", 12)] + public void Builders_cover_size_boundaries_partial_pages_and_twenty_page_cap( + string builderKey, + int size) + { + var sizeMinusOne = Build(builderKey, Data(size - 1, 1)); + Assert.Contains(sizeMinusOne, mutation => + mutation is PlayoutSetValue { ObjectName: var name, Value: "" } && + name == "title" + size); + + var exactSize = Build(builderKey, Data(size, 1)); + Assert.DoesNotContain(exactSize, mutation => + mutation is PlayoutSetValue { ObjectName: var name, Value: "" } && + name.StartsWith("title", StringComparison.Ordinal)); + + var sizePlusOne = Build(builderKey, Data(size + 1, 2)); + Assert.Contains(sizePlusOne, mutation => + mutation is PlayoutSetValue { ObjectName: "title1", Value: var value } && + value == $"Row{size + 1:000}"); + Assert.Contains(sizePlusOne, mutation => + mutation is PlayoutSetValue { ObjectName: "title2", Value: "" }); + + var pageTwenty = Build(builderKey, Data(20 * size, 20)); + Assert.Contains(pageTwenty, mutation => + mutation is PlayoutSetValue { ObjectName: "title1", Value: var value } && + value == $"Row{((19 * size) + 1):000}"); + Assert.Contains(pageTwenty, mutation => + mutation is PlayoutSetValue { ObjectName: var name, Value: var value } && + name == "title" + size && value == $"Row{20 * size:000}"); + + var capped = Build(builderKey, Data((20 * size) + 1, 20)); + Assert.DoesNotContain(capped, mutation => + mutation is PlayoutSetValue { Value: var value } && + value == $"Row{(20 * size) + 1:000}"); + Assert.Throws(() => + Build(builderKey, Data((20 * size) + 1, 21))); + } + + [Theory] + [MemberData(nameof(HeaderCases))] + public void Header_branches_use_closed_title_column_unit_and_asset_mapping(HeaderCase item) + { + var actual = new S5074SceneMutationBuilder().Build(Data( + 1, + 1, + item.Branch, + item.Header, + item.Session, + item.Title, + item.TitleStyle)); + + Assert.Contains(actual, mutation => + mutation is PlayoutSetValue { ObjectName: "title", Value: var value } && + value == item.ExpectedTitle); + Assert.Contains(actual, mutation => + mutation is PlayoutSetValue { ObjectName: "unit", Value: var value } && + value == item.ExpectedUnit); + Assert.Contains(actual, mutation => + mutation is PlayoutSetValue { ObjectName: "column1", Value: var value } && + value == item.ExpectedColumn1); + if (item.ExpectedAsset is null) + { + Assert.DoesNotContain(actual, mutation => mutation is PlayoutSetAssetValue); + } + else + { + Assert.Contains(actual, mutation => + mutation is PlayoutSetAssetValue { ObjectName: "mart", AssetPath: var path } && + path == item.ExpectedAsset); + } + } + + [Fact] + public void Presentation_contract_rejects_invalid_combinations_and_raw_sdk_input_surface() + { + Assert.Throws(() => + new S5074SceneMutationBuilder().Build(Data( + 1, 1, PagedQuoteBranch.Nxt, PagedQuoteHeaderStyle.StandardWon, + NxtMarketSession.NotApplicable, "A"))); + Assert.Throws(() => + new S5074SceneMutationBuilder().Build(Data( + 1, 1, PagedQuoteBranch.Domestic, PagedQuoteHeaderStyle.StandardWon, + NxtMarketSession.PreMarket, "A"))); + Assert.Throws(() => + new S5074SceneMutationBuilder().Build(Data( + 1, 1, PagedQuoteBranch.Expert, PagedQuoteHeaderStyle.StandardWon, + NxtMarketSession.NotApplicable, "A"))); + Assert.Throws(() => + new S5074SceneMutationBuilder().Build(Data( + 1, 1, PagedQuoteBranch.Theme, PagedQuoteHeaderStyle.ExpertReturns, + NxtMarketSession.NotApplicable, "A"))); + + var publicProperties = typeof(PagedQuoteSceneData) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(property => property.Name); + Assert.DoesNotContain(publicProperties, name => + name is "ObjectName" or "MethodName" or "AssetPath" or "SceneFile"); + } + + private static PagedQuoteSceneData Data( + int count, + int pageNumberOneBased, + PagedQuoteBranch branch = PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle header = PagedQuoteHeaderStyle.StandardWon, + NxtMarketSession session = NxtMarketSession.NotApplicable, + string title = "Title", + PagedQuoteTitleStyle titleStyle = PagedQuoteTitleStyle.Exact) + { + var directions = new[] + { + ScenePriceDirection.Down, + ScenePriceDirection.Up, + ScenePriceDirection.Flat, + ScenePriceDirection.LimitDown, + ScenePriceDirection.LimitUp + }; + var rows = Enumerable.Range(1, count) + .Select(index => new PagedQuoteRowData( + $"Row{index:000}", + 1000 + index, + index % 2 == 0 ? index : -index, + index % 2 == 0 ? index / 4d : -index / 4d, + directions[(index - 1) % directions.Length])) + .ToArray(); + return new PagedQuoteSceneData( + branch, + header, + titleStyle, + session, + title, + rows, + pageNumberOneBased); + } + + private static IReadOnlyList Build( + string builderKey, + PagedQuoteSceneData data) => builderKey switch + { + "s5074" => new S5074SceneMutationBuilder().Build(data), + "s5077" => new S5077SceneMutationBuilder().Build(data), + "s5088" => new S5088SceneMutationBuilder().Build(data), + _ => throw new InvalidOperationException("Unknown test builder.") + }; + + private static string SnapshotHash(IEnumerable snapshot) + { + var bytes = Encoding.UTF8.GetBytes(string.Join('\n', snapshot)); + return Convert.ToHexString(SHA256.HashData(bytes)); + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetAssetValue asset => $"A:{asset.ObjectName}={asset.AssetPath}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + _ => mutation.GetType().Name + }; + + public sealed record HeaderCase( + PagedQuoteBranch Branch, + PagedQuoteHeaderStyle Header, + PagedQuoteTitleStyle TitleStyle, + NxtMarketSession Session, + string Title, + string ExpectedTitle, + string ExpectedUnit, + string ExpectedColumn1, + string? ExpectedAsset); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs new file mode 100644 index 0000000..6163522 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs @@ -0,0 +1,574 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class PagedQuoteSceneDataLoadersTests +{ + public static TheoryData SceneSizes => new() + { + { "s5074", 5 }, + { "s5077", 6 }, + { "s5088", 12 } + }; + + [Fact] + public async Task DomesticIndustry_UsesParameterizedOracleAndPreservesPointDecimalsInMutations() + { + var executor = new RecordingExecutor(QuoteTable( + ["Semiconductor", 1234.56m, -12.34m, -0.99m, "-"])); + + var data = await new S5074SceneDataLoader(executor).LoadAsync( + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.Industry, + new DateOnly(2026, 7, 10))); + var mutations = new S5074SceneMutationBuilder().Build(data); + + Assert.Equal(DataSourceKind.Oracle, executor.Source); + Assert.Equal("SCENE_5074_PAGED_QUOTES", executor.TableName); + Assert.NotNull(executor.Spec); + Assert.Contains("FROM t_index", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains(":maxRows", executor.Spec.Sql, StringComparison.Ordinal); + var maxRows = Assert.Single(executor.Spec.Parameters); + Assert.Equal("maxRows", maxRows.Name); + Assert.Equal(100, maxRows.Value); + Assert.Equal(DbType.Int32, maxRows.DbType); + Assert.Equal(PagedQuoteBranch.Domestic, data.Branch); + Assert.Equal(PagedQuoteHeaderStyle.IndustryPoints, data.HeaderStyle); + Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle); + Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession); + Assert.Equal("\uCF54\uC2A4\uD53C \uC5C5\uC885", data.Title); + Assert.Equal(1234.56m, data.Rows[0].PrimaryValue); + Assert.Equal(-12.34m, data.Rows[0].SecondaryValue); + Assert.Equal(ScenePriceDirection.Down, data.Rows[0].Direction); + Assert.Contains(new PlayoutSetValue("price1", "1,234.56"), mutations); + Assert.Contains(new PlayoutSetValue("changePrice1_1", "-12.34"), mutations); + Assert.Contains(new PlayoutSetValue("unit", "\uB2E8\uC704 : p"), mutations); + Assert.Equal(1, executor.SpecCalls); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task NxtRanking_UsesParameterizedMariaAndMapsAfterMarketPresentation() + { + var executor = new RecordingExecutor(QuoteTable( + ["NXT quote", 1000m, -50m, -5.0m, "5"])); + + var data = await new S5077SceneDataLoader(executor).LoadAsync( + new NxtPagedQuoteLoadRequest( + PagedQuoteMarket.Kosdaq, + NxtPagedQuoteList.Volume, + NxtMarketSession.AfterMarket)); + var mutations = new S5077SceneMutationBuilder().Build(data); + + Assert.Equal(DataSourceKind.MariaDb, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains("FROM n_kosdaq_online", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("LIMIT @maxRows", executor.Spec.Sql, StringComparison.Ordinal); + var maxRows = Assert.Single(executor.Spec.Parameters); + Assert.Equal(120, maxRows.Value); + Assert.Equal(PagedQuoteBranch.Nxt, data.Branch); + Assert.Equal(NxtMarketSession.AfterMarket, data.NxtSession); + Assert.Equal("\uCF54\uC2A4\uB2E5 \uAC70\uB798\uB7C9 \uC0C1\uC704", data.Title); + Assert.Equal(ScenePriceDirection.Down, data.Rows[0].Direction); + Assert.Contains( + new PlayoutSetAssetValue("mart", "Images\\\uC560\uD504\uD130\uB9C8\uCF13.png"), + mutations); + Assert.Contains(new PlayoutSetVisible("mart", true), mutations); + Assert.Contains(new PlayoutSetValue("price1", "1,000"), mutations); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task ThemeExpected_UsesOracleThemeParameterAndExpectedTitleVariant() + { + var executor = new RecordingExecutor(QuoteTable( + ["Alpha", 110m, 10m, 10d, "+"])); + + var data = await new S5088SceneDataLoader(executor).LoadAsync( + new ThemePagedQuoteLoadRequest( + "AI", + PagedQuoteThemeSort.GainRateDescending, + PagedQuoteThemeValueKind.ExpectedExecution)); + var mutations = new S5088SceneMutationBuilder().Build(data); + + Assert.Equal(DataSourceKind.Oracle, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains("l.SB_TITLE = :themeTitle", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("t_online1_call", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY q.RATE DESC", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(new[] { "themeTitle", "maxRows" }, + executor.Spec.Parameters.Select(parameter => parameter.Name)); + Assert.Equal("AI", executor.Spec.Parameters[0].Value); + Assert.Equal(240, executor.Spec.Parameters[1].Value); + Assert.Equal(PagedQuoteBranch.Theme, data.Branch); + Assert.Equal(PagedQuoteTitleStyle.ExpectedExecution, data.TitleStyle); + Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession); + Assert.Contains( + new PlayoutSetValue("title", "AI \uC608\uC0C1 \uCCB4\uACB0\uAC00"), + mutations); + } + + [Fact] + public async Task NxtTheme_UsesMariaThemeParameterAndPreMarketSession() + { + var executor = new RecordingExecutor(QuoteTable( + ["Beta", 200m, 20m, 11.11d, "2"])); + + var data = await new S5074SceneDataLoader(executor).LoadAsync( + new NxtThemePagedQuoteLoadRequest( + "Robotics(NXT)", + PagedQuoteThemeSort.InputOrder, + NxtMarketSession.PreMarket)); + + Assert.Equal(DataSourceKind.MariaDb, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains("l.SB_TITLE = @themeTitle", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("l.SB_MARKET = 'NXT'", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY i.SB_I_INDEX", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Equal("Robotics", executor.Spec.Parameters[0].Value); + Assert.Equal(PagedQuoteBranch.NxtTheme, data.Branch); + Assert.Equal(NxtMarketSession.PreMarket, data.NxtSession); + Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle); + Assert.Equal("Robotics", data.Title); + } + + [Fact] + public async Task Expert_UsesOracleExpertParameterAndMapsReturnHeader() + { + var executor = new RecordingExecutor(QuoteTable( + ["Gamma", 900m, 1000m, 11.11d, "2"])); + + var data = await new S5077SceneDataLoader(executor).LoadAsync( + new ExpertPagedQuoteLoadRequest("Analyst A")); + var mutations = new S5077SceneMutationBuilder().Build(data); + + Assert.Equal(DataSourceKind.Oracle, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains("e.EP_NAME = :expertName", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Equal("Analyst A", executor.Spec.Parameters[0].Value); + Assert.Equal(PagedQuoteBranch.Expert, data.Branch); + Assert.Equal(PagedQuoteHeaderStyle.ExpertReturns, data.HeaderStyle); + Assert.Contains(new PlayoutSetValue("column1", "\uB9E4\uC218\uAC00"), mutations); + Assert.Contains(new PlayoutSetValue("column2", "\uD604\uC7AC\uAC00"), mutations); + Assert.Contains(new PlayoutSetValue("column3", "\uC218\uC775\uB960"), mutations); + } + + [Fact] + public async Task Vi_UsesOneOracleParameterPerBoundedNameAndPreservesInputOrderContract() + { + var executor = new RecordingExecutor(QuoteTable( + ["First", 100m, 1m, 1d, "2"], + ["Second", 200m, 2m, 1d, "2"])); + + var data = await new S5088SceneDataLoader(executor).LoadAsync( + new ViPagedQuoteLoadRequest(["First", "Second"])); + + Assert.Equal(DataSourceKind.Oracle, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains(":stockName0", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains(":stockName1", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY r.INPUT_ORDER", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(new[] { "stockName0", "stockName1", "maxRows" }, + executor.Spec.Parameters.Select(parameter => parameter.Name)); + Assert.Equal(new object?[] { "First", "Second", 240 }, + executor.Spec.Parameters.Select(parameter => parameter.Value)); + Assert.Equal(PagedQuoteBranch.Vi, data.Branch); + Assert.Equal("VI \uBC1C\uB3D9", data.Title); + Assert.Equal(new[] { "First", "Second" }, data.Rows.Select(row => row.Name)); + } + + [Theory] + [MemberData(nameof(SceneSizes))] + public async Task SceneLoaders_UseExactFiveSixTwelveWindowsAndTwentyPageCap( + string scene, + int pageSize) + { + var partialExecutor = new RecordingExecutor(NumberedQuoteTable(pageSize + 1)); + var partial = await LoadAsync( + scene, + partialExecutor, + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.GainRate, + new DateOnly(2026, 7, 10), + PageNumberOneBased: 2)); + var partialMutations = Build(scene, partial); + + Assert.Equal(pageSize + 1, partial.Rows.Count); + Assert.Equal(2, partial.PageNumberOneBased); + Assert.Contains(new PlayoutSetValue("title1", "Name" + pageSize), partialMutations); + Assert.Contains(new PlayoutSetVisible("row1", true), partialMutations); + Assert.Contains(new PlayoutSetVisible("row2", false), partialMutations); + + var exactPageExecutor = new RecordingExecutor(NumberedQuoteTable(pageSize)); + await Assert.ThrowsAsync(() => LoadAsync( + scene, + exactPageExecutor, + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.GainRate, + new DateOnly(2026, 7, 10), + PageNumberOneBased: 2))); + + var maximumRows = pageSize * ScenePaging.MaximumPageCount; + var lastExecutor = new RecordingExecutor(NumberedQuoteTable(maximumRows)); + var last = await LoadAsync( + scene, + lastExecutor, + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.GainRate, + new DateOnly(2026, 7, 10), + PageNumberOneBased: 20)); + var lastMutations = Build(scene, last); + + Assert.Equal(maximumRows, last.Rows.Count); + Assert.Contains( + new PlayoutSetValue("title1", "Name" + (19 * pageSize)), + lastMutations); + Assert.Equal(maximumRows, Assert.Single(lastExecutor.Spec!.Parameters).Value); + + var oversizedExecutor = new RecordingExecutor(NumberedQuoteTable(maximumRows + 1)); + await Assert.ThrowsAsync(() => LoadAsync( + scene, + oversizedExecutor, + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.GainRate, + new DateOnly(2026, 7, 10)))); + } + + [Theory] + [MemberData(nameof(SceneSizes))] + public async Task SceneLoaders_RejectPageTwentyOneBeforeQuery( + string scene, + int _) + { + var executor = new RecordingExecutor(NumberedQuoteTable(1)); + + await Assert.ThrowsAsync(() => LoadAsync( + scene, + executor, + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.GainRate, + new DateOnly(2026, 7, 10), + PageNumberOneBased: 21))); + + Assert.Equal(0, executor.SpecCalls); + } + + public static TheoryData + DomesticLists => new() + { + { DomesticPagedQuoteList.ExpectedMarketCapitalization, PagedQuoteMarket.Kospi, "t_online1_call" }, + { DomesticPagedQuoteList.MarketCapitalization, PagedQuoteMarket.Kosdaq, "t_kosdaq_online1" }, + { DomesticPagedQuoteList.Industry, PagedQuoteMarket.Kospi, "t_index" }, + { DomesticPagedQuoteList.AfterHoursGainRate, PagedQuoteMarket.Kosdaq, "t_kosdaq_overtime_sise" }, + { DomesticPagedQuoteList.AfterHoursLossRate, PagedQuoteMarket.Kospi, "t_overtime_sise" }, + { DomesticPagedQuoteList.GainRate, PagedQuoteMarket.Kosdaq, "RATE DESC" }, + { DomesticPagedQuoteList.LossRate, PagedQuoteMarket.Kospi, "a.f_chg_type IN ('4', '5')" }, + { DomesticPagedQuoteList.FiftyTwoWeekHigh, PagedQuoteMarket.Kospi, "t_candle_history" }, + { DomesticPagedQuoteList.FiftyTwoWeekLow, PagedQuoteMarket.Kosdaq, "t_kosdaq_candle_history" }, + { DomesticPagedQuoteList.Kospi200, PagedQuoteMarket.Kospi, "f_kospi200_gubun" }, + { DomesticPagedQuoteList.Etf, PagedQuoteMarket.Kospi, "b.f_sect_code = 'EF'" } + }; + + [Theory] + [MemberData(nameof(DomesticLists))] + public async Task EveryDomesticLegacySelectionHasAClosedOracleQuery( + DomesticPagedQuoteList list, + PagedQuoteMarket market, + string queryEvidence) + { + var executor = new RecordingExecutor(NumberedQuoteTable(1)); + + var data = await new S5074SceneDataLoader(executor).LoadAsync( + new DomesticPagedQuoteLoadRequest( + market, + list, + new DateOnly(2026, 7, 10))); + + Assert.Equal(DataSourceKind.Oracle, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains(queryEvidence, executor.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(PagedQuoteBranch.Domestic, data.Branch); + Assert.Equal( + list == DomesticPagedQuoteList.Industry + ? PagedQuoteHeaderStyle.IndustryPoints + : PagedQuoteHeaderStyle.StandardWon, + data.HeaderStyle); + Assert.Equal(0, executor.StringCalls); + } + + public static TheoryData NxtLists => new() + { + { NxtPagedQuoteList.MarketCapitalization, "b.f_list_num * a.f_curr_price" }, + { NxtPagedQuoteList.AfterHoursGainRate, "FROM n_overtime" }, + { NxtPagedQuoteList.AfterHoursLossRate, "a.f_chg_type IN ('4', '5')" }, + { NxtPagedQuoteList.GainRate, "RATE DESC" }, + { NxtPagedQuoteList.LossRate, "a.f_chg_type IN ('4', '5')" }, + { NxtPagedQuoteList.Volume, "a.f_net_vol" } + }; + + [Theory] + [MemberData(nameof(NxtLists))] + public async Task EveryNxtLegacySelectionHasAClosedMariaQuery( + NxtPagedQuoteList list, + string queryEvidence) + { + var executor = new RecordingExecutor(NumberedQuoteTable(1)); + + var data = await new S5077SceneDataLoader(executor).LoadAsync( + new NxtPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + list, + NxtMarketSession.PreMarket)); + + Assert.Equal(DataSourceKind.MariaDb, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains(queryEvidence, executor.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("LIMIT @maxRows", executor.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(PagedQuoteBranch.Nxt, data.Branch); + Assert.Equal(0, executor.StringCalls); + } + + [Theory] + [InlineData(PagedQuoteThemeSort.InputOrder, "ORDER BY t.SB_I_INDEX")] + [InlineData(PagedQuoteThemeSort.GainRateDescending, "ORDER BY q.RATE DESC")] + [InlineData(PagedQuoteThemeSort.GainRateAscending, "ORDER BY q.RATE")] + public async Task ThemeSortsAreClosedAndGenerateTheLegacyOrder( + PagedQuoteThemeSort sort, + string orderBy) + { + var executor = new RecordingExecutor(NumberedQuoteTable(1)); + + await new S5074SceneDataLoader(executor).LoadAsync( + new ThemePagedQuoteLoadRequest( + "Theme", + sort, + PagedQuoteThemeValueKind.Current)); + + Assert.NotNull(executor.Spec); + Assert.Contains(orderBy, executor.Spec.Sql, StringComparison.Ordinal); + } + + [Fact] + public async Task FiftyTwoWeekQueryBindsDeterministicStartEndAndCapParameters() + { + var executor = new RecordingExecutor(NumberedQuoteTable(1)); + + await new S5088SceneDataLoader(executor).LoadAsync( + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.FiftyTwoWeekHigh, + new DateOnly(2026, 7, 10))); + + Assert.NotNull(executor.Spec); + Assert.Equal(new[] { "startDate", "endDate", "maxRows" }, + executor.Spec.Parameters.Select(parameter => parameter.Name)); + Assert.Equal(new object?[] { "20250711", "20260710", 240 }, + executor.Spec.Parameters.Select(parameter => parameter.Value)); + Assert.All(executor.Spec.Parameters.Take(2), + parameter => Assert.Equal(DbType.String, parameter.DbType)); + } + + [Fact] + public async Task LoaderFailsClosedForUnexpectedSchemaNullBadDirectionAndNonFiniteValues() + { + var extraColumn = NumberedQuoteTable(1); + extraColumn.Columns.Add("EXTRA", typeof(object)); + extraColumn.Rows[0]["EXTRA"] = "unexpected"; + + var nullValue = NumberedQuoteTable(1); + nullValue.Rows[0]["PRIMARY_VALUE"] = DBNull.Value; + + var badDirection = NumberedQuoteTable(1); + badDirection.Rows[0]["DIRECTION"] = "sideways"; + + var nonFinite = NumberedQuoteTable(1); + nonFinite.Rows[0]["RATE"] = double.NaN; + + var fractionalWon = NumberedQuoteTable(1); + fractionalWon.Rows[0]["PRIMARY_VALUE"] = 100.5m; + + foreach (var table in new[] + { + new DataTable(), + extraColumn, + nullValue, + badDirection, + nonFinite, + fractionalWon + }) + { + var executor = new RecordingExecutor(table); + await Assert.ThrowsAsync(() => + new S5074SceneDataLoader(executor).LoadAsync( + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.GainRate, + new DateOnly(2026, 7, 10)))); + } + } + + [Fact] + public void BuilderAlsoFailsClosedForFractionalWonTrustedData() + { + var data = new PagedQuoteSceneData( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "Title", + [new PagedQuoteRowData("Name", 100.5m, 1m, 1d, ScenePriceDirection.Up)], + 1); + + Assert.Throws(() => + new S5074SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task InvalidClosedSelectionsAreRejectedBeforeAnyDatabaseCall() + { + PagedQuoteLoadRequest[] invalidRequests = + [ + new DomesticPagedQuoteLoadRequest( + (PagedQuoteMarket)99, + DomesticPagedQuoteList.GainRate, + new DateOnly(2026, 7, 10)), + new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kosdaq, + DomesticPagedQuoteList.Kospi200, + new DateOnly(2026, 7, 10)), + new NxtPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + (NxtPagedQuoteList)99, + NxtMarketSession.PreMarket), + new NxtPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + NxtPagedQuoteList.GainRate, + NxtMarketSession.NotApplicable), + new ThemePagedQuoteLoadRequest( + "Theme", + (PagedQuoteThemeSort)99, + PagedQuoteThemeValueKind.Current), + new ThemePagedQuoteLoadRequest( + "Theme", + PagedQuoteThemeSort.InputOrder, + (PagedQuoteThemeValueKind)99), + new NxtThemePagedQuoteLoadRequest( + "Theme", + PagedQuoteThemeSort.InputOrder, + NxtMarketSession.NotApplicable), + new ViPagedQuoteLoadRequest([]) + ]; + + foreach (var request in invalidRequests) + { + var executor = new RecordingExecutor(NumberedQuoteTable(1)); + await Assert.ThrowsAsync(() => + new S5074SceneDataLoader(executor).LoadAsync(request)); + Assert.Equal(0, executor.SpecCalls); + } + } + + private static Task LoadAsync( + string scene, + IDataQueryExecutor executor, + PagedQuoteLoadRequest request) => scene switch + { + "s5074" => new S5074SceneDataLoader(executor).LoadAsync(request), + "s5077" => new S5077SceneDataLoader(executor).LoadAsync(request), + "s5088" => new S5088SceneDataLoader(executor).LoadAsync(request), + _ => throw new InvalidOperationException("Unknown test scene.") + }; + + private static IReadOnlyList Build( + string scene, + PagedQuoteSceneData data) => scene switch + { + "s5074" => new S5074SceneMutationBuilder().Build(data), + "s5077" => new S5077SceneMutationBuilder().Build(data), + "s5088" => new S5088SceneMutationBuilder().Build(data), + _ => throw new InvalidOperationException("Unknown test scene.") + }; + + private static DataTable NumberedQuoteTable(int count) + { + var table = QuoteTable(); + for (var index = 0; index < count; index++) + { + table.Rows.Add("Name" + index, 1000m + index, 10m + index, 1d, "2"); + } + + return table; + } + + private static DataTable QuoteTable(params object?[][] rows) + { + var table = new DataTable(); + foreach (var column in new[] + { + "NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION" + }) + { + table.Columns.Add(column, typeof(object)); + } + + foreach (var row in rows) + { + table.Rows.Add(row); + } + + return table; + } + + private sealed class RecordingExecutor(DataTable result) : IDataQueryExecutor + { + public DataSourceKind Source { get; private set; } + + public string? TableName { get; private set; } + + public DataQuerySpec? Spec { get; private set; } + + public int SpecCalls { get; private set; } + + public int StringCalls { get; private set; } + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + StringCalls++; + throw new InvalidOperationException("String SQL fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Source = source; + TableName = tableName; + Spec = query; + SpecCalls++; + return Task.FromResult(result.Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PanelSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PanelSceneDataLoadersTests.cs new file mode 100644 index 0000000..e36bb67 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PanelSceneDataLoadersTests.cs @@ -0,0 +1,90 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class PanelSceneDataLoadersTests +{ + [Fact] + public async Task NxtLoader_UsesMariaParameterAndMapsS5006Dto() + { + var table = new DataTable(); + foreach (var column in new[] + { + "STOCK_NAME", "CURR_PRICE", "CHG_TYPE", "NET_CHG", "RATE", + "INIT_PRICE", "INIT_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE" + }) + { + table.Columns.Add(column, typeof(object)); + } + table.Rows.Add("A사", 1000m, "상승", 50m, 5.26m, 960m, 1m, 1100m, 10m, 900m, -10m); + var executor = new RecordingExecutor(table); + + var data = await new S5006NxtSceneDataLoader(executor).LoadAsync( + LegacyDomesticEquityMarket.NxtKospi, + "005930"); + var mutations = new S5006SceneMutationBuilder().Build(data); + + Assert.Equal(DataSourceKind.MariaDb, executor.Source); + Assert.NotNull(executor.Spec); + Assert.Contains("@stockCode", executor.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(executor.Spec.Parameters); + Assert.Equal("stockCode", parameter.Name); + Assert.Equal("005930", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + Assert.Equal(ScenePriceDirection.Up, data.Direction); + Assert.Equal(1000, data.CurrentPrice); + Assert.NotEmpty(mutations); + Assert.Equal(0, executor.StringCalls); + } + + [Theory] + [InlineData(LegacyDomesticEquityMarket.Kospi)] + [InlineData(LegacyDomesticEquityMarket.Kosdaq)] + public async Task NxtLoader_RejectsNonNxtMarketsBeforeQuery(LegacyDomesticEquityMarket market) + { + var executor = new RecordingExecutor(new DataTable()); + + await Assert.ThrowsAsync(() => + new S5006NxtSceneDataLoader(executor).LoadAsync(market, "005930")); + + Assert.Null(executor.Spec); + } + + private sealed class RecordingExecutor(DataTable table) : IDataQueryExecutor + { + public DataSourceKind Source { get; private set; } + + public DataQuerySpec? Spec { get; private set; } + + public int StringCalls { get; private set; } + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + StringCalls++; + throw new InvalidOperationException(); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + throw new InvalidOperationException(); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + Source = source; + Spec = query; + return Task.FromResult(table.Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs new file mode 100644 index 0000000..18769aa --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs @@ -0,0 +1,487 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ParameterizedMarketSceneBuildersTests +{ + [Fact] + public void Builder_keys_are_the_closed_scene_codes() + { + Assert.Equal("s5001", new S5001SceneMutationBuilder().BuilderKey); + Assert.Equal("s5032", new S5032SceneMutationBuilder().BuilderKey); + Assert.Equal("s8018", new S8018SceneMutationBuilder().BuilderKey); + Assert.Equal("s8001", new S8001SceneMutationBuilder().BuilderKey); + } + + [Fact] + public void S5001_all_query_branches_are_explicit_and_emit_the_original_value_group() + { + S5001SceneData[] cases = + [ + new S5001BalticDryIndexSceneData("BDI 지수", ScenePriceDirection.Up, 1_900, 12, 0.64m), + new S5001CommoditySceneData(S5001CommodityTarget.Copper, "구리", ScenePriceDirection.Down, + 4.2500m, -0.1250m, -2.86m), + new S5001DomesticIndexSceneData(S5001DomesticIndexTarget.Kospi, S5001IndexMode.Current, + "코스피", ScenePriceDirection.Flat, 2_800.1m, 0, 0), + new S5001ForeignIndexSceneData(S5001ForeignIndexTarget.Nasdaq, "나스닥", + ScenePriceDirection.Up, 20_100.25m, 120.50m, 0.60m), + new S5001ExchangeRateSceneData(S5001ExchangeRateTarget.WonDollar, "원달러", + ScenePriceDirection.Down, 1_380.2m, -2.4m, 0.17m), + new S5001DomesticIndustrySceneData(LegacyDomesticEquityMarket.Kosdaq, "반도체", + ScenePriceDirection.Up, 1_234.5m, 5.5m, 0.45m), + new S5001ForeignIndustrySceneData("필라델피아 반도체", ScenePriceDirection.Flat, + 5_000m, 0, 0), + new S5001ForeignStockSceneData("NVIDIA", ScenePriceDirection.Down, + 190.25m, -2.15m, 1.12m), + new S5001DomesticStockSceneData(LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.Current, S5001NxtBadge.None, "삼성전자", + ScenePriceDirection.LimitDown, 70_000, -30_000, -30m) + ]; + + var builder = new S5001SceneMutationBuilder(); + foreach (var data in cases) + { + var actual = builder.Build(data).Select(Describe).ToArray(); + Assert.Equal("S:mart=0", actual[0]); + Assert.Contains(actual, item => item.StartsWith("V:title=", StringComparison.Ordinal)); + Assert.Contains(actual, item => item.StartsWith("V:price=", StringComparison.Ordinal)); + Assert.Contains(actual, item => item.StartsWith("V:unitText=", StringComparison.Ordinal)); + Assert.Contains(actual, item => item.StartsWith("A:pattern", StringComparison.Ordinal)); + } + } + + [Fact] + public void S5001_commodity_preserves_raw_decimal_scale_and_negative_magnitude_rules() + { + var actual = new S5001SceneMutationBuilder().Build(new S5001CommoditySceneData( + S5001CommodityTarget.Copper, + "구리", + ScenePriceDirection.Down, + 4.2500m, + -0.1250m, + -2.86m)).Take(7).Select(Describe); + + Assert.Equal( + [ + "S:mart=0", + "V:title=구리", + "V:price=4.2500", + "V:changePrice3_=0.1250", + "V:rate3_=-2.86", + "V:wonText3_=", + "V:unitText=단위 : 달러" + ], actual); + } + + [Fact] + public void S5001_domestic_stock_modes_preserve_title_badge_and_duplicate_unit_order() + { + var builder = new S5001SceneMutationBuilder(); + var expected = builder.Build(new S5001DomesticStockSceneData( + LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.ExpectedOpening, + S5001NxtBadge.None, + "삼성전자", + ScenePriceDirection.Down, + 71_000, + -900, + 1.25m)).Take(8).Select(Describe); + Assert.Equal( + [ + "S:mart=0", + "V:unitText=단위 : 원", + "V:title=삼성전자 예상체결가", + "V:price=71,000", + "V:changePrice3_=900", + "V:rate3_=-1.25", + "V:wonText3_=", + "V:unitText=단위 : 원" + ], expected); + + var afterHours = builder.Build(new S5001DomesticStockSceneData( + LegacyDomesticEquityMarket.Kosdaq, + S5001DomesticStockMode.AfterHoursSinglePrice, + S5001NxtBadge.None, + "테스트", + ScenePriceDirection.Flat, + 10_000, + 0, + 0)).Take(5).Select(Describe); + Assert.Equal( + [ + "S:mart=0", + "V:unitText=단위 : 원", + "V:title=테스트", + "V:title=테스트 시간외단일가", + "V:price=10,000" + ], afterHours); + + var nxt = builder.Build(new S5001DomesticStockSceneData( + LegacyDomesticEquityMarket.NxtKospi, + S5001DomesticStockMode.Current, + S5001NxtBadge.PreMarket, + "삼성전자(NXT)", + ScenePriceDirection.Up, + 72_000, + 1_000, + 1.41m)).Select(Describe).ToArray(); + Assert.Equal("V:title=삼성전자", nxt[2]); + Assert.Equal(@"A:mart=Images\프리마켓.png", nxt[8]); + Assert.Equal("S:mart=1", nxt[9]); + AssertRelativeAssetsOnly(builder.Build(new S5001DomesticStockSceneData( + LegacyDomesticEquityMarket.NxtKosdaq, + S5001DomesticStockMode.Current, + S5001NxtBadge.AfterMarket, + "테스트(NXT)", + ScenePriceDirection.Up, + 1, + 1, + 1))); + } + + [Fact] + public void S5001_direction_signals_have_golden_active_objects_and_value_variants() + { + var cases = new[] + { + (ScenePriceDirection.Up, "V:changePrice1_=10", "S:up=1"), + (ScenePriceDirection.Flat, "V:changePrice2_=10", "S:flat=1"), + (ScenePriceDirection.Down, "V:changePrice3_=10", "S:down=1"), + (ScenePriceDirection.LimitUp, "V:changePrice1_=10", "S:upup=1"), + (ScenePriceDirection.LimitDown, "V:changePrice3_=10", "S:downdown=1") + }; + var builder = new S5001SceneMutationBuilder(); + foreach (var (direction, value, visible) in cases) + { + var actual = builder.Build(new S5001BalticDryIndexSceneData( + "BDI", direction, 100, 10, 1)).Select(Describe).ToArray(); + Assert.Contains(value, actual); + Assert.Contains(visible, actual); + } + } + + [Fact] + public void S5032_index_branch_matches_primary_then_direction_then_companion_order() + { + var actual = new S5032SceneMutationBuilder().Build(new S5032IndexAndFuturesSceneData( + LegacyMarketQuoteTarget.Kospi, + LegacyMarketQuoteTarget.Futures, + new S5032PrimaryQuote("코스피", 2_812.34m, -12.30m, 0.44m, ScenePriceDirection.Down), + new S5032CompanionQuote("KOSPI200 선물", 381.25m, -1.20m, + S5032CompanionSignal.Negative, 123_456m, 98_765_432m, 456_789m))) + .Select(Describe).ToArray(); + + Assert.Equal( + [ + "V:title=코스피", + "V:price=2,812.34", + "V:changePrice=12.30", + "V:rate=-0.44", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:down=1" + ], actual.Take(10)); + var companionStart = Array.IndexOf(actual, "V:title1=KOSPI200 선물"); + Assert.True(companionStart > 20); + Assert.Equal( + [ + "V:title1=KOSPI200 선물", + "V:price1=381.25", + "V:changePrice1=-1.20", + "S:up1=0", + "S:upup1=0", + "S:down1=1", + "S:downdown1=0", + "S:flat1=0", + "V:r_value1=123,456", + "V:r_value2=99", + "V:r_value3=456,789" + ], actual.Skip(companionStart)); + } + + [Fact] + public void S5032_all_branches_and_companion_signals_are_closed() + { + var builder = new S5032SceneMutationBuilder(); + foreach (var signal in Enum.GetValues()) + { + var actual = builder.Build(new S5032DomesticIndustryPairSceneData( + LegacyDomesticEquityMarket.Kospi, + new S5032PrimaryQuote("전기전자", 100, 1, 1, ScenePriceDirection.Up), + new S5032CompanionQuote("반도체", 200, 0, signal, 1, 2_000_000, 3))) + .Select(Describe).ToArray(); + Assert.Equal("V:title=전기전자", actual[0]); + Assert.Contains("V:title1=반도체", actual); + Assert.Contains(signal switch + { + S5032CompanionSignal.Positive => "S:up1=1", + S5032CompanionSignal.Negative => "S:down1=1", + _ => "S:flat1=1" + }, actual); + } + + var stocks = builder.Build(new S5032DomesticStockPairSceneData( + LegacyDomesticEquityMarket.Kospi, + LegacyDomesticEquityMarket.Kosdaq, + new S5032StockQuote("A", 1_000, 100, 10, ScenePriceDirection.LimitUp), + new S5032StockQuote("B", 2_000, -200, -10, ScenePriceDirection.LimitDown))) + .Select(Describe).ToArray(); + Assert.Equal( + [ + "V:title1=A", "V:title2=B", "V:price1=1,000", "V:price2=2,000", + "V:changePrice1=100", "V:changePrice2=-200", "V:rate1=10.00", "V:rate2=-10.00", + "V:wonText1=", "V:wonText2=", "V:unitText1=단위 : 원", "V:unitText2=단위 : 원" + ], stocks.Take(12)); + + var limitUpPattern = Array.IndexOf(stocks, @"A:pattern1=Images\그림_빨강.png"); + var limitUpUnit = Array.IndexOf(stocks, "S:unit1_1=1"); + var limitDownPattern = Array.IndexOf(stocks, @"A:pattern2=Images\그림_파랑.png"); + var limitDownUnit = Array.IndexOf(stocks, "S:unit3_2=1"); + Assert.True(limitUpPattern < limitUpUnit); + Assert.True(limitDownUnit < limitDownPattern); + } + + [Fact] + public void S8018_market_and_industry_pairs_repeat_three_values_in_original_side_order() + { + var builder = new S8018SceneMutationBuilder(); + var market = builder.Build(new S8018MarketPairSceneData( + LegacyMarketQuoteTarget.WonDollar, + LegacyMarketQuoteTarget.Nasdaq, + new S8018MarketQuote("원달러", 1_381.2m, -2.3m, 0.17m, ScenePriceDirection.Down), + new S8018MarketQuote("나스닥", 20_100.5m, 100m, 0.50m, ScenePriceDirection.Up))) + .Select(Describe).ToArray(); + + Assert.Equal( + [ + "S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0", + "V:title1=원달러", "V:title2=나스닥", + "V:price1=1,381.20", "V:price2=20,100.50", + "V:changePrice1_1=2.30", "V:changePrice2_1=2.30", "V:changePrice3_1=2.30", + "V:changePrice1_2=100.00", "V:changePrice2_2=100.00", "V:changePrice3_2=100.00", + "V:rate1_1=-0.17", "V:rate2_1=-0.17", "V:rate3_1=-0.17", + "V:rate1_2=0.50", "V:rate2_2=0.50", "V:rate3_2=0.50", + "V:wonText1=", "V:wonText2=", "V:unitText1=단위 : p", "V:unitText2=단위 : p" + ], market.Take(24)); + + var industry = builder.Build(new S8018DomesticIndustryPairSceneData( + LegacyDomesticEquityMarket.Kosdaq, + new S8018MarketQuote("반도체", 1, 0, 0, ScenePriceDirection.Flat), + new S8018MarketQuote("IT부품", 2, 0, 0, ScenePriceDirection.Flat))) + .Select(Describe).ToArray(); + Assert.Equal("V:title1=코스닥 - 반도체", industry[4]); + Assert.Equal("V:title2=코스닥 - IT부품", industry[5]); + } + + [Fact] + public void S8018_mixed_pair_preserves_partial_DataView_then_SetDataText_order_on_both_sides() + { + var builder = new S8018SceneMutationBuilder(); + var stockFirst = builder.Build(new S8018MixedMarketAndStockSceneData( + S8018PairSide.First, + LegacyMarketQuoteTarget.Kospi, + new S8018MarketQuote("코스피", 2_800m, 10m, 0.36m, ScenePriceDirection.Up), + new S8018MixedStockQuote(LegacyDomesticEquityMarket.Kospi, "삼성전자", + 70_000, -500, -0.71m, ScenePriceDirection.Down))) + .Select(Describe).ToArray(); + Assert.Equal("V:title2=코스피", stockFirst[4]); + Assert.True(Array.IndexOf(stockFirst, "V:title1=삼성전자") > + Array.IndexOf(stockFirst, @"A:pattern2=Images\그림_빨강.png")); + Assert.Contains("V:unitText2=단위 : p", stockFirst); + Assert.Contains("V:unitText1=단위 : 원", stockFirst); + + var stockSecond = builder.Build(new S8018MixedMarketAndStockSceneData( + S8018PairSide.Second, + LegacyMarketQuoteTarget.Kosdaq, + new S8018MarketQuote("코스닥", 900m, 0m, 0m, ScenePriceDirection.Flat), + new S8018MixedStockQuote(LegacyDomesticEquityMarket.Kosdaq, "테스트", + 10_000, 0, 0, ScenePriceDirection.Flat))) + .Select(Describe).ToArray(); + Assert.Equal("V:title1=코스닥", stockSecond[4]); + Assert.DoesNotContain("V:unitText1=단위 : p", stockSecond); + Assert.Contains("V:unitText2=단위 : 원", stockSecond); + } + + [Fact] + public void S8018_expected_pair_interleaves_each_side_and_uses_limit_signals() + { + var actual = new S8018SceneMutationBuilder().Build(new S8018ExpectedStockPairSceneData( + new S8018ExpectedStockQuote(LegacyDomesticEquityMarket.Kospi, "A", + 10_000, -3_000, 30m, ScenePriceDirection.LimitUp), + new S8018ExpectedStockQuote(LegacyDomesticEquityMarket.Kosdaq, "B", + 20_000, -6_000, -30m, ScenePriceDirection.LimitDown))) + .Select(Describe).ToArray(); + + Assert.Equal( + [ + "S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0", + "V:title1=A 예상체결", "V:title2=B 예상체결", + "V:price1=10,000", "V:price2=20,000", + "V:changePrice1_1=3,000", "V:changePrice1_2=6,000", + "V:changePrice2_1=3,000", "V:changePrice2_2=6,000", + "V:changePrice3_1=3,000", "V:changePrice3_2=6,000" + ], actual.Take(14)); + Assert.Contains("S:upup1=1", actual); + Assert.Contains("S:downdown2=1", actual); + } + + [Fact] + public void S8018_current_pair_matches_units_badges_raw_scale_and_both_krx_override() + { + var builder = new S8018SceneMutationBuilder(); + var mixedVenue = builder.Build(new S8018CurrentStockPairSceneData( + S8018SessionPhase.AfterKrxClose, + new S8018CurrentStockQuote(S8018StockMarket.NxtKospi, "NXT 종목", + 70_000m, 500m, 0.72m, ScenePriceDirection.Up), + new S8018CurrentStockQuote(S8018StockMarket.World, "NVIDIA", + 190.250m, -2.150m, -1.12m, ScenePriceDirection.Down))) + .Select(Describe).ToArray(); + Assert.Equal("V:unitText1=단위 : 원", mixedVenue[4]); + Assert.Equal("V:unitText2=단위 : 달러", mixedVenue[5]); + Assert.Equal(@"A:mart1=Images\NXT.png", mixedVenue[8]); + Assert.Equal("S:mart1=1", mixedVenue[9]); + Assert.Equal("S:mart2=0", mixedVenue[10]); + Assert.Equal("S:end2=0", mixedVenue[11]); + Assert.Equal("V:price1=70,000", mixedVenue[12]); + Assert.Equal("V:price2=190.250", mixedVenue[13]); + AssertRelativeAssetsOnly(builder.Build(new S8018CurrentStockPairSceneData( + S8018SessionPhase.AfterKrxClose, + new S8018CurrentStockQuote(S8018StockMarket.Kospi, "A", 1, 1, 1, ScenePriceDirection.Up), + new S8018CurrentStockQuote(S8018StockMarket.NxtKosdaq, "B", 2, 1, 1, ScenePriceDirection.Up)))); + + var bothKrx = builder.Build(new S8018CurrentStockPairSceneData( + S8018SessionPhase.AfterKrxClose, + new S8018CurrentStockQuote(S8018StockMarket.Kospi, "A", 1, 1, 1, ScenePriceDirection.Up), + new S8018CurrentStockQuote(S8018StockMarket.Kosdaq, "B", 2, -1, -1, ScenePriceDirection.Down))) + .Select(Describe).ToArray(); + var secondEndShown = Array.IndexOf(bothKrx, "S:end2=1"); + Assert.True(secondEndShown >= 0); + Assert.Equal( + ["S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0"], + bothKrx.Skip(secondEndShown + 1).Take(4)); + } + + [Fact] + public void S8001_uses_only_fixed_sector_targets_and_original_threshold_colors() + { + var actual = new S8001SceneMutationBuilder().Build(new S8001SceneData( + S8001Market.Kospi, + [ + new S8001SectorQuote(S8001Sector.Communications, S8001SectorSignal.Positive, 0.99m), + new S8001SectorQuote(S8001Sector.Finance, S8001SectorSignal.Positive, 1.00m), + new S8001SectorQuote(S8001Sector.Construction, S8001SectorSignal.Positive, 3.00m), + new S8001SectorQuote(S8001Sector.Machinery, S8001SectorSignal.Positive, 5.00m), + new S8001SectorQuote(S8001Sector.Distribution, S8001SectorSignal.Positive, 10.00m), + new S8001SectorQuote(S8001Sector.TransportationEquipment, S8001SectorSignal.Negative, 0.99m), + new S8001SectorQuote(S8001Sector.TransportationWarehouse, S8001SectorSignal.Negative, 1.00m), + new S8001SectorQuote(S8001Sector.FoodAndBeverage, S8001SectorSignal.Negative, 3.00m), + new S8001SectorQuote(S8001Sector.SteelAndMetal, S8001SectorSignal.Negative, 5.00m), + new S8001SectorQuote(S8001Sector.MedicalPrecision, S8001SectorSignal.Negative, 10.00m), + new S8001SectorQuote(S8001Sector.TextilesAndApparel, S8001SectorSignal.Flat, 0m), + new S8001SectorQuote(S8001Sector.Chemicals, S8001SectorSignal.Positive, 1m), + new S8001SectorQuote(S8001Sector.NonMetallicMinerals, S8001SectorSignal.Negative, 1m) + ])).Select(Describe).ToArray(); + + Assert.Contains("V:통신_등락=0.99", actual); + Assert.Contains("C:통신_큐브=206,61,59,255", actual); + Assert.Contains("C:금융_큐브=181,28,39,255", actual); + Assert.Contains("C:건설_큐브=154,28,39,255", actual); + Assert.Contains("C:기계_큐브=131,28,39,255", actual); + Assert.Contains("C:유통_큐브=174,0,0,255", actual); + Assert.Contains("V:운수장비_등락=-0.99", actual); + Assert.Contains("C:운수장비_큐브=66,128,185,255", actual); + Assert.Contains("C:운수창고_큐브=52,117,157,255", actual); + Assert.Contains("C:음식료_큐브=52,78,126,255", actual); + Assert.Contains("C:철강금속_큐브=42,61,124,255", actual); + Assert.Contains("C:의료정밀_큐브=42,47,124,255", actual); + Assert.Contains("V:섬유의복_등락=0", actual); + Assert.DoesNotContain(actual, value => value.StartsWith("C:섬유의복_큐브=", StringComparison.Ordinal)); + Assert.Contains("V:화학_등락=1", actual); + Assert.Contains("V:비금속광물_등락=-1", actual); + } + + [Fact] + public void S8001_sector_enum_maps_every_cut_object_for_both_markets() + { + var kospi = Enum.GetValues() + .Where(value => value is not S8001Sector.MachineryAndEquipment and + not S8001Sector.ItComponents and not S8001Sector.Transportation and + not S8001Sector.Semiconductor and not S8001Sector.Internet) + .Select(value => new S8001SectorQuote(value, S8001SectorSignal.Flat, 0m)) + .ToArray(); + var kosdaq = new[] + { + S8001Sector.MachineryAndEquipment, + S8001Sector.ItComponents, + S8001Sector.Transportation, + S8001Sector.PaperAndWood, + S8001Sector.Semiconductor, + S8001Sector.Internet + }.Select(value => new S8001SectorQuote(value, S8001SectorSignal.Flat, 0m)).ToArray(); + + Assert.Equal(15, kospi.Length); + Assert.Equal(15, new S8001SceneMutationBuilder().Build(new S8001SceneData(S8001Market.Kospi, kospi)).Count); + Assert.Equal(6, new S8001SceneMutationBuilder().Build(new S8001SceneData(S8001Market.Kosdaq, kosdaq)).Count); + } + + [Fact] + public void Parameterized_DTOs_do_not_expose_raw_object_method_or_path_fields() + { + var dtoTypes = typeof(S5001SceneData).Assembly.GetTypes() + .Where(type => type.Namespace == typeof(S5001SceneData).Namespace) + .Where(type => typeof(ILegacySceneData).IsAssignableFrom(type) || + type.Name.StartsWith("S5001", StringComparison.Ordinal) || + type.Name.StartsWith("S5032", StringComparison.Ordinal) || + type.Name.StartsWith("S8018", StringComparison.Ordinal) || + type.Name.StartsWith("S8001", StringComparison.Ordinal)) + .Where(type => type.Name.Contains("Data", StringComparison.Ordinal) || + type.Name.Contains("Quote", StringComparison.Ordinal)); + + foreach (var property in dtoTypes.SelectMany(type => type.GetProperties())) + { + Assert.NotEqual(typeof(object), property.PropertyType); + Assert.DoesNotContain("Object", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Method", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Path", property.Name, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void Invalid_closed_enum_combinations_fail_before_emitting_unsafe_names() + { + Assert.Throws(() => new S8001SceneMutationBuilder().Build( + new S8001SceneData(S8001Market.Kosdaq, + [new S8001SectorQuote(S8001Sector.Communications, S8001SectorSignal.Positive, 1m)]))); + Assert.Throws(() => new S5001SceneMutationBuilder().Build( + new S5001DomesticStockSceneData(LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.Current, S5001NxtBadge.PreMarket, "A", + ScenePriceDirection.Up, 1, 1, 1))); + Assert.Throws(() => new S5032SceneMutationBuilder().Build( + new S5032IndexAndFuturesSceneData((LegacyMarketQuoteTarget)999, + LegacyMarketQuoteTarget.Futures, + new S5032PrimaryQuote("A", 1, 1, 1, ScenePriceDirection.Up), + new S5032CompanionQuote("B", 1, 1, S5032CompanionSignal.Flat, 1, 1, 1)))); + } + + private static void AssertRelativeAssetsOnly(IReadOnlyList mutations) + { + var assets = mutations.OfType().Select(value => value.AssetPath).ToArray(); + Assert.NotEmpty(assets); + Assert.All(assets, asset => + { + Assert.False(Path.IsPathRooted(asset)); + Assert.DoesNotContain(":", asset, StringComparison.Ordinal); + Assert.DoesNotContain("MBN_STOCK_N", asset, StringComparison.OrdinalIgnoreCase); + }); + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetAssetValue asset => $"A:{asset.ObjectName}={asset.AssetPath}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + PlayoutSetFaceColor color => + $"C:{color.ObjectName}={color.Red},{color.Green},{color.Blue},{color.Alpha}", + _ => mutation.ToString() ?? mutation.GetType().Name + }; +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs new file mode 100644 index 0000000..12c5784 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs @@ -0,0 +1,618 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ParameterizedMarketSceneDataLoadersTests +{ + [Fact] + public async Task S5001_every_top_level_branch_uses_a_bounded_parameterized_query_and_builds_mutations() + { + S5001SceneLoadRequest[] requests = + [ + new S5001BalticDryIndexLoadRequest(), + new S5001CommodityLoadRequest(S5001CommodityTarget.Copper), + new S5001DomesticIndexLoadRequest(S5001DomesticIndexTarget.Kospi, S5001IndexMode.Current), + new S5001ForeignIndexLoadRequest(S5001ForeignIndexTarget.Nasdaq), + new S5001ExchangeRateLoadRequest(S5001ExchangeRateTarget.WonDollar), + new S5001DomesticIndustryLoadRequest(LegacyDomesticEquityMarket.Kosdaq, "반도체"), + new S5001ForeignIndustryLoadRequest("필라델피아 반도체"), + new S5001ForeignStockLoadRequest("NVIDIA"), + new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.Current, + S5001NxtBadge.None, + S5001StockTradingStatus.Regular, + "삼성전자") + ]; + + foreach (var request in requests) + { + var executor = new RecordingExecutor(QuoteTable("테스트", 100m, "+", 1m, 1m)); + var data = await new S5001SceneDataLoader(executor).LoadAsync(request); + var mutations = new S5001SceneMutationBuilder().Build(data); + + Assert.Equal(request.Branch, data.Branch); + Assert.NotEmpty(mutations); + var call = Assert.Single(executor.Calls); + AssertReadOnlyBounded(call); + Assert.NotEmpty(call.Spec.Parameters); + Assert.Equal(0, executor.StringCalls); + } + } + + [Fact] + public async Task S5001_stock_modes_select_fixed_source_and_parameter_marker_without_sql_text_injection() + { + const string selected = "이름' OR '1'='1"; + var cases = new[] + { + (new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.ExpectedOpening, + S5001NxtBadge.None, + S5001StockTradingStatus.Regular, + selected), + DataSourceKind.Oracle, "SCENE_EXPECTED_KOSPI_STOCK", ":stockName"), + (new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.Kosdaq, + S5001DomesticStockMode.AfterHoursSinglePrice, + S5001NxtBadge.None, + S5001StockTradingStatus.Regular, + selected), + DataSourceKind.Oracle, "SCENE_AFTER_HOURS_KOSDAQ_STOCK", ":stockName"), + (new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.Current, + S5001NxtBadge.None, + S5001StockTradingStatus.Halted, + selected), + DataSourceKind.Oracle, "SCENE_HALTED_KOSPI_STOCK", ":stockName"), + (new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.NxtKospi, + S5001DomesticStockMode.Current, + S5001NxtBadge.PreMarket, + S5001StockTradingStatus.Regular, + selected + "(NXT)"), + DataSourceKind.MariaDb, "SCENE_NXT_KOSPI_STOCK", "@stockName") + }; + + foreach (var (request, source, tableName, placeholder) in cases) + { + var executor = new RecordingExecutor(QuoteTable("테스트", 100m, "+", 1m, 1m)); + var data = await new S5001SceneDataLoader(executor).LoadAsync(request); + var call = Assert.Single(executor.Calls); + var parameter = Assert.Single(call.Spec.Parameters); + + Assert.Equal(source, call.Source); + Assert.Equal(tableName, call.TableName); + Assert.Contains(placeholder, call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain(selected, call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(selected, parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + Assert.NotEmpty(new S5001SceneMutationBuilder().Build(data)); + } + } + + [Fact] + public async Task S5001_closed_target_sets_all_construct_valid_specs_and_generate_typed_data() + { + foreach (var target in Enum.GetValues()) + { + var executor = new RecordingExecutor(QuoteTable("상품", 1.25m, "+", 0.25m, 1m)); + var data = await new S5001SceneDataLoader(executor) + .LoadAsync(new S5001CommodityLoadRequest(target)); + Assert.Equal(target, Assert.IsType(data).Target); + AssertReadOnlyBounded(Assert.Single(executor.Calls)); + } + + foreach (var target in Enum.GetValues()) + { + foreach (var mode in Enum.GetValues()) + { + var executor = new RecordingExecutor(QuoteTable("지수", 1m, "+", 1m, 1m)); + var data = await new S5001SceneDataLoader(executor) + .LoadAsync(new S5001DomesticIndexLoadRequest(target, mode)); + Assert.Equal(target, Assert.IsType(data).Target); + AssertReadOnlyBounded(Assert.Single(executor.Calls)); + } + } + + foreach (var target in Enum.GetValues()) + { + var executor = new RecordingExecutor(QuoteTable("해외", 1m, "-", -1m, -1m)); + var data = await new S5001SceneDataLoader(executor) + .LoadAsync(new S5001ForeignIndexLoadRequest(target)); + Assert.Equal(target, Assert.IsType(data).Target); + } + + foreach (var target in Enum.GetValues()) + { + var executor = new RecordingExecutor(QuoteTable("환율", 1m, "", 0m, 0m)); + var data = await new S5001SceneDataLoader(executor) + .LoadAsync(new S5001ExchangeRateLoadRequest(target)); + Assert.Equal(target, Assert.IsType(data).Target); + Assert.Equal( + new[] { "displayName", "inputCode" }, + Assert.Single(executor.Calls).Spec.Parameters.Select(parameter => parameter.Name)); + } + } + + [Fact] + public async Task S5001_nxt_industry_fails_closed_because_original_has_no_request_implementation() + { + foreach (var market in new[] + { + LegacyDomesticEquityMarket.NxtKospi, + LegacyDomesticEquityMarket.NxtKosdaq + }) + { + var executor = new RecordingExecutor(QuoteTable("반도체", 100m, "+", 1m, 1m)); + await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(executor).LoadAsync( + new S5001DomesticIndustryLoadRequest(market, "반도체"))); + Assert.Empty(executor.Calls); + } + } + + [Fact] + public async Task Remaining_closed_stock_and_nxt_query_variants_construct_provider_valid_specs() + { + var cases = new S5001SceneLoadRequest[] + { + new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.NxtKosdaq, + S5001DomesticStockMode.Current, + S5001NxtBadge.AfterMarket, + S5001StockTradingStatus.Regular, + "A(NXT)"), + new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.Kosdaq, + S5001DomesticStockMode.Current, + S5001NxtBadge.None, + S5001StockTradingStatus.Halted, + "B"), + new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.AfterHoursSinglePrice, + S5001NxtBadge.None, + S5001StockTradingStatus.Regular, + "C") + }; + + foreach (var request in cases) + { + var executor = new RecordingExecutor(QuoteTable("테스트", 1m, "+", 1m, 1m)); + var data = await new S5001SceneDataLoader(executor).LoadAsync(request); + var call = Assert.Single(executor.Calls); + AssertReadOnlyBounded(call); + Assert.NotEmpty(new S5001SceneMutationBuilder().Build(data)); + } + } + + [Fact] + public async Task S5032_all_branches_preserve_query_order_and_generate_builder_mutations() + { + var indexExecutor = new RecordingExecutor( + QuoteTable("코스피", 2_800m, "+", 10m, 0.36m), + FutureTable("선물", 380m, "-", 2m, -0.52m, 100m, 200m, 3_000_000m)); + var index = await new S5032SceneDataLoader(indexExecutor).LoadAsync( + new S5032IndexAndFuturesLoadRequest(LegacyMarketQuoteTarget.Kospi)); + Assert.Equal( + new[] { "SCENE_KOSPI_INDEX", "SCENE_FUTURES_DETAIL" }, + indexExecutor.Calls.Select(call => call.TableName)); + Assert.NotEmpty(new S5032SceneMutationBuilder().Build(index)); + + var industryExecutor = new RecordingExecutor( + QuoteTable("기계", 100m, "+", 1m, 1m), + QuoteTable("반도체", 200m, "", 0m, 0m)); + var industry = await new S5032SceneDataLoader(industryExecutor).LoadAsync( + new S5032DomesticIndustryPairLoadRequest( + LegacyDomesticEquityMarket.Kospi, "기계", "반도체")); + Assert.Equal( + new[] { "기계", "반도체" }, + industryExecutor.Calls.Select(call => Assert.Single(call.Spec.Parameters).Value)); + Assert.NotEmpty(new S5032SceneMutationBuilder().Build(industry)); + + var stockExecutor = new RecordingExecutor( + QuoteTable("A", 1_000m, "+", 100m, 10m), + QuoteTable("B", 2_000m, "-", -200m, -10m)); + var stocks = await new S5032SceneDataLoader(stockExecutor).LoadAsync( + new S5032DomesticStockPairLoadRequest( + LegacyDomesticEquityMarket.Kospi, "A", + LegacyDomesticEquityMarket.Kosdaq, "B")); + Assert.Equal( + new[] { "SCENE_KOSPI_STOCK", "SCENE_KOSDAQ_STOCK" }, + stockExecutor.Calls.Select(call => call.TableName)); + Assert.NotEmpty(new S5032SceneMutationBuilder().Build(stocks)); + Assert.All(indexExecutor.Calls.Concat(industryExecutor.Calls).Concat(stockExecutor.Calls), AssertReadOnlyBounded); + } + + [Fact] + public async Task S8018_all_branches_preserve_original_first_then_second_query_order() + { + var marketExecutor = new RecordingExecutor( + QuoteTable("다우", 1m, "+", 1m, 1m), + QuoteTable("나스닥", 2m, "-", -1m, -1m)); + var market = await new S8018SceneDataLoader(marketExecutor).LoadAsync( + new S8018MarketPairLoadRequest( + S8018MarketDataMode.Current, + LegacyMarketQuoteTarget.Dow, + LegacyMarketQuoteTarget.Nasdaq)); + Assert.Equal( + new[] { "DJI@DJI", "NAS@IXIC" }, + marketExecutor.Calls.Select(call => Assert.Single(call.Spec.Parameters).Value)); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(market)); + + var mixedExecutor = new RecordingExecutor( + QuoteTable("삼성전자", 70_000m, "+", 500m, 0.72m), + QuoteTable("나스닥", 20_000m, "+", 100m, 0.5m)); + var mixed = await new S8018SceneDataLoader(mixedExecutor).LoadAsync( + new S8018MixedMarketAndStockLoadRequest( + S8018PairSide.First, + LegacyMarketQuoteTarget.Nasdaq, + LegacyDomesticEquityMarket.Kospi, + "삼성전자")); + Assert.Equal( + new[] { "SCENE_KOSPI_STOCK", "SCENE_FOREIGN_INDEX" }, + mixedExecutor.Calls.Select(call => call.TableName)); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(mixed)); + + var expectedExecutor = new RecordingExecutor( + QuoteTable("A", 1_000m, "+", 10m, 1m), + QuoteTable("B", 2_000m, "-", 20m, -1m)); + var expected = await new S8018SceneDataLoader(expectedExecutor).LoadAsync( + new S8018ExpectedStockPairLoadRequest( + LegacyDomesticEquityMarket.Kospi, "A", + LegacyDomesticEquityMarket.Kosdaq, "B")); + Assert.Equal( + new[] { "SCENE_EXPECTED_KOSPI_STOCK", "SCENE_EXPECTED_KOSDAQ_STOCK" }, + expectedExecutor.Calls.Select(call => call.TableName)); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(expected)); + + var currentExecutor = new RecordingExecutor( + QuoteTable("NXT A", 1_000m, "+", 10m, 1m), + QuoteTable("NVIDIA", 200.50m, "-", -2.25m, -1.1m)); + var current = await new S8018SceneDataLoader(currentExecutor).LoadAsync( + new S8018CurrentStockPairLoadRequest( + S8018SessionPhase.AfterKrxClose, + new S8018CurrentStockSelection(S8018StockMarket.NxtKospi, "A(NXT)"), + new S8018CurrentStockSelection(S8018StockMarket.World, "NVIDIA"))); + Assert.Equal( + new[] { DataSourceKind.MariaDb, DataSourceKind.Oracle }, + currentExecutor.Calls.Select(call => call.Source)); + Assert.Equal( + new[] { "SCENE_NXT_KOSPI_STOCK", "SCENE_FOREIGN_STOCK" }, + currentExecutor.Calls.Select(call => call.TableName)); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(current)); + + var industryExecutor = new RecordingExecutor( + QuoteTable("기계", 1m, "+", 1m, 1m), + QuoteTable("반도체", 2m, "", 0m, 0m)); + var industry = await new S8018SceneDataLoader(industryExecutor).LoadAsync( + new S8018DomesticIndustryPairLoadRequest( + LegacyDomesticEquityMarket.Kosdaq, "기계", "반도체")); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(industry)); + + Assert.All( + marketExecutor.Calls.Concat(mixedExecutor.Calls).Concat(expectedExecutor.Calls) + .Concat(currentExecutor.Calls).Concat(industryExecutor.Calls), + AssertReadOnlyBounded); + } + + [Fact] + public async Task S8018_expected_market_mode_is_closed_to_domestic_targets() + { + var executor = new RecordingExecutor( + QuoteTable("코스피 예상", 1m, "+", 1m, 1m), + QuoteTable("선물 예상", 2m, "-", 1m, -1m)); + var data = await new S8018SceneDataLoader(executor).LoadAsync( + new S8018MarketPairLoadRequest( + S8018MarketDataMode.Expected, + LegacyMarketQuoteTarget.Kospi, + LegacyMarketQuoteTarget.Futures)); + + Assert.Equal( + new[] { "SCENE_EXPECTED_KOSPI_INDEX", "SCENE_EXPECTED_FUTURES_INDEX" }, + executor.Calls.Select(call => call.TableName)); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(data)); + + var rejected = new RecordingExecutor(QuoteTable("X", 1m, "+", 1m, 1m)); + await Assert.ThrowsAsync(() => + new S8018SceneDataLoader(rejected).LoadAsync( + new S8018MarketPairLoadRequest( + S8018MarketDataMode.Expected, + LegacyMarketQuoteTarget.Nasdaq, + LegacyMarketQuoteTarget.Kospi))); + Assert.Empty(rejected.Calls); + } + + [Fact] + public async Task S8018_expected_mixed_pair_queries_market_then_stock_when_stock_is_second() + { + var executor = new RecordingExecutor( + QuoteTable("코스피 예상", 2_800m, "+", 10m, 0.36m), + QuoteTable("삼성전자", 70_000m, "-", 500m, -0.72m)); + + var data = await new S8018SceneDataLoader(executor).LoadAsync( + new S8018MixedMarketAndStockLoadRequest( + S8018PairSide.Second, + LegacyMarketQuoteTarget.Kospi, + LegacyDomesticEquityMarket.Kospi, + "삼성전자", + S8018MarketDataMode.Expected)); + + Assert.Equal( + new[] { "SCENE_EXPECTED_KOSPI_INDEX", "SCENE_EXPECTED_KOSPI_STOCK" }, + executor.Calls.Select(call => call.TableName)); + Assert.NotEmpty(new S8018SceneMutationBuilder().Build(data)); + } + + [Fact] + public async Task S8001_maps_only_closed_fixed_sector_names_and_preserves_parameter_order() + { + var names = new[] + { + "통신", "금융", "건설", "기계", "유통", "종이목재", "운수장비", + "운수창고", "음식료", "철강금속", "의료정밀", "섬유의복", "전기전자", + "화학", "비금속광물" + }; + var codes = new[] + { + "020", "021", "018", "012", "016", "007", "015", "019", + "005", "011", "014", "006", "013", "008", "009" + }; + var table = SectorTable(codes, names); + var executor = new RecordingExecutor(table); + + var data = await new S8001SceneDataLoader(executor) + .LoadAsync(new S8001SceneLoadRequest(S8001Market.Kospi)); + var mutations = new S8001SceneMutationBuilder().Build(data); + var call = Assert.Single(executor.Calls); + + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("SCENE_8001_KOSPI_SECTORS", call.TableName); + Assert.Contains("FETCH FIRST 15 ROWS ONLY", call.Spec.Sql, StringComparison.Ordinal); + Assert.Empty(call.Spec.Parameters); + Assert.All(codes, code => Assert.Contains($"'{code}'", call.Spec.Sql, StringComparison.Ordinal)); + Assert.Equal(15, data.Sectors.Count); + Assert.Contains(data.Sectors, row => row.Sector == S8001Sector.Chemicals); + Assert.Contains(data.Sectors, row => row.Sector == S8001Sector.NonMetallicMinerals); + Assert.NotEmpty(mutations); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S8001_kosdaq_requires_all_six_fixed_targets_and_builds_only_enum_names() + { + var names = new[] { "기계장비", "IT부품", "운송", "종이목재", "반도체", "인터넷" }; + var executor = new RecordingExecutor(SectorTable( + ["001", "002", "003", "004", "005", "006"], + names)); + + var data = await new S8001SceneDataLoader(executor) + .LoadAsync(new S8001SceneLoadRequest(S8001Market.Kosdaq)); + var call = Assert.Single(executor.Calls); + var mutations = new S8001SceneMutationBuilder().Build(data); + + Assert.Equal("SCENE_8001_KOSDAQ_SECTORS", call.TableName); + Assert.Contains("FETCH FIRST 7 ROWS ONLY", call.Spec.Sql, StringComparison.Ordinal); + Assert.Equal(names, call.Spec.Parameters.Select(parameter => parameter.Value)); + Assert.Equal(6, data.Sectors.Count); + Assert.Equal(6 + data.Sectors.Count(row => row.Signal != S8001SectorSignal.Flat), mutations.Count); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public async Task Single_quote_loaders_reject_missing_or_ambiguous_rows(int rowCount) + { + var table = QuoteTable("A", 1m, "+", 1m, 1m); + if (rowCount == 0) + { + table.Rows.Clear(); + } + else + { + table.ImportRow(table.Rows[0]); + } + + await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(new RecordingExecutor(table)).LoadAsync( + new S5001ForeignStockLoadRequest("A"))); + } + + [Fact] + public async Task Loaders_fail_closed_for_schema_null_direction_and_invalid_enum_before_mutation() + { + var wrongSchema = QuoteTable("A", 1m, "+", 1m, 1m); + wrongSchema.Columns.Add("EXTRA", typeof(object)); + await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(new RecordingExecutor(wrongSchema)).LoadAsync( + new S5001ForeignStockLoadRequest("A"))); + + var nullValue = QuoteTable("A", 1m, "+", 1m, 1m); + nullValue.Rows[0]["PRICE"] = DBNull.Value; + await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(new RecordingExecutor(nullValue)).LoadAsync( + new S5001ForeignStockLoadRequest("A"))); + + var unknownDirection = QuoteTable("A", 1m, "UNKNOWN", 1m, 1m); + await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(new RecordingExecutor(unknownDirection)).LoadAsync( + new S5001ForeignStockLoadRequest("A"))); + + var invalid = new RecordingExecutor(QuoteTable("A", 1m, "+", 1m, 1m)); + await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(invalid).LoadAsync( + new S5001CommodityLoadRequest((S5001CommodityTarget)999))); + Assert.Empty(invalid.Calls); + + var nullSelector = new RecordingExecutor(QuoteTable("A", 1m, "+", 1m, 1m)); + await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(nullSelector).LoadAsync( + new S5001DomesticStockLoadRequest( + LegacyDomesticEquityMarket.Kospi, + S5001DomesticStockMode.Current, + S5001NxtBadge.None, + S5001StockTradingStatus.Regular, + null!))); + Assert.Empty(nullSelector.Calls); + } + + [Fact] + public async Task Loader_errors_do_not_echo_selected_database_keys() + { + const string sensitive = "SENSITIVE-STOCK-SELECTION"; + var exception = await Assert.ThrowsAsync(() => + new S5001SceneDataLoader(new RecordingExecutor(new DataTable())).LoadAsync( + new S5001ForeignStockLoadRequest(sensitive))); + + Assert.DoesNotContain(sensitive, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Public_load_requests_expose_no_raw_sql_symbol_object_method_or_path_fields() + { + var requestTypes = typeof(S5001SceneLoadRequest).Assembly.GetTypes() + .Where(type => type.IsPublic) + .Where(type => typeof(S5001SceneLoadRequest).IsAssignableFrom(type) || + typeof(S5032SceneLoadRequest).IsAssignableFrom(type) || + typeof(S8018SceneLoadRequest).IsAssignableFrom(type) || + type == typeof(S8001SceneLoadRequest) || + type == typeof(S8018CurrentStockSelection)); + + foreach (var property in requestTypes.SelectMany(type => type.GetProperties())) + { + Assert.NotEqual(typeof(object), property.PropertyType); + Assert.DoesNotContain("Sql", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Query", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Symbol", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Object", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Method", property.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Path", property.Name, StringComparison.OrdinalIgnoreCase); + } + } + + private static void AssertReadOnlyBounded(RecordedCall call) + { + call.Spec.ValidateFor(call.Source); + Assert.StartsWith("SELECT", call.Spec.Sql.TrimStart(), StringComparison.OrdinalIgnoreCase); + Assert.True( + call.Spec.Sql.Contains("FETCH FIRST 2 ROWS ONLY", StringComparison.OrdinalIgnoreCase) || + call.Spec.Sql.Contains("LIMIT 2", StringComparison.OrdinalIgnoreCase)); + } + + private static DataTable QuoteTable( + string title, + decimal price, + string direction, + decimal change, + decimal rate) + { + var table = Table("TITLE", "PRICE", "DIRECTION", "NET_CHANGE", "RATE"); + table.Rows.Add(title, price, direction, change, rate); + return table; + } + + private static DataTable FutureTable( + string title, + decimal price, + string direction, + decimal change, + decimal rate, + decimal netVolume, + decimal outstandingVolume, + decimal netTurnover) + { + var table = Table( + "TITLE", "PRICE", "DIRECTION", "NET_CHANGE", "RATE", + "NET_VOLUME", "OUTSTANDING_VOLUME", "NET_TURNOVER"); + table.Rows.Add( + title, price, direction, change, rate, + netVolume, outstandingVolume, netTurnover); + return table; + } + + private static DataTable SectorTable( + IReadOnlyList codes, + IReadOnlyList names) + { + Assert.Equal(codes.Count, names.Count); + var table = Table("SECTOR_CODE", "SECTOR_NAME", "DIRECTION", "MAGNITUDE"); + for (var index = 0; index < names.Count; index++) + { + table.Rows.Add( + codes[index], + names[index], + index % 3 == 0 ? "+" : index % 3 == 1 ? "" : "-", + index / 10m); + } + + return table; + } + + private static DataTable Table(params string[] columns) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string TableName, + DataQuerySpec Spec); + + private sealed class RecordingExecutor : IDataQueryExecutor + { + private readonly Queue _results; + + public RecordingExecutor(params DataTable[] results) + { + _results = new Queue(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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new RecordedCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No fake query result remains."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs index d5c72f8..2ed2926 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.Core.Tests; @@ -84,4 +85,112 @@ public sealed class PlayoutBridgeProtocolTests Assert.Equal(string.Empty, result.Request.RequestId); Assert.DoesNotContain(unsafeRequestId, result.Error, StringComparison.Ordinal); } + + [Fact] + public void ParseWorkflowCommand_AcceptsPreparePlaylistAndNativeLookupFields() + { + using var document = JsonDocument.Parse(""" + { + "requestId":"REQ_1", + "command":"prepare", + "playlist":[ + { + "id":"entry-1", + "code":"5001", + "enabled":true, + "fadeDuration":3, + "selection":{ + "groupCode":"코스피", + "subject":"삼성전자", + "graphicType":"1열판 기본", + "subtype":"현재가", + "dataCode":"005930" + } + }, + {"id":"entry-2","code":"5074","enabled":false} + ], + "selectedIndex":0 + } + """); + + var result = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement); + + Assert.True(result.IsValid); + Assert.Equal("REQ_1", result.Request.RequestId); + Assert.Equal("prepare", result.Request.Command); + Assert.Equal(0, result.Request.SelectedIndexZeroBased); + var playlist = Assert.IsAssignableFrom>( + result.Request.Playlist); + Assert.Equal(2, playlist.Count); + Assert.Equal("005930", playlist[0].Selection?.DataCode); + Assert.Equal(3, playlist[0].FadeDuration); + Assert.False(playlist[1].IsEnabled); + } + + [Theory] + [InlineData("take-in")] + [InlineData("next")] + [InlineData("take-out")] + public void ParseWorkflowCommand_AcceptsStateCommandsWithoutCallerSelectedCue(string command) + { + using var document = JsonDocument.Parse( + $"{{\"requestId\":\"REQ\",\"command\":\"{command}\"}}"); + + var result = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement); + + Assert.True(result.IsValid); + Assert.Null(result.Request.Playlist); + Assert.Equal(-1, result.Request.SelectedIndexZeroBased); + } + + [Theory] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"next\",\"playlist\":[],\"selectedIndex\":0}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[],\"selectedIndex\":0}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"same\",\"code\":\"5001\",\"enabled\":true},{\"id\":\"same\",\"code\":\"5006\",\"enabled\":true}],\"selectedIndex\":0}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"fadeDuration\":61}],\"selectedIndex\":0}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"selection\":{\"groupCode\":\"코스피\",\"subject\":\"bad\\nvalue\",\"graphicType\":\"\",\"subtype\":\"\",\"dataCode\":\"005930\"}}],\"selectedIndex\":0}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"selection\":{\"groupCode\":\"코스피\",\"subject\":\"삼성전자\",\"graphicType\":\"\",\"subtype\":\"\",\"dataCode\":\"005930\",\"objectName\":\"title\"}}],\"selectedIndex\":0}")] + public void ParseWorkflowCommand_RejectsCueOverrideDuplicateOrUnsafeShapes(string json) + { + using var document = JsonDocument.Parse(json); + + var result = PlayoutBridgeProtocol.ParseWorkflowCommand(document.RootElement); + + Assert.False(result.IsValid); + Assert.Null(result.Request.Playlist); + } + + [Theory] + [InlineData("prepare")] + [InlineData("take-in")] + [InlineData("next")] + [InlineData("take-out")] + public void ParseTimeoutQuarantine_AcceptsOnlyKnownTimedOutCommands(string command) + { + using var document = JsonDocument.Parse( + $"{{\"requestId\":\"REQ_1\",\"command\":\"{command}\"}}"); + + var result = PlayoutBridgeProtocol.ParseTimeoutQuarantine(document.RootElement); + + Assert.True(result.IsValid); + Assert.Equal("REQ_1", result.Request.RequestId); + Assert.Equal(command, result.Request.Command); + Assert.Empty(result.Error); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"unknown\"}")] + [InlineData("{\"requestId\":\"../REQ\",\"command\":\"next\"}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"next\",\"retry\":true}")] + [InlineData("{\"requestId\":\"REQ\",\"requestId\":\"REQ2\",\"command\":\"next\"}")] + public void ParseTimeoutQuarantine_RejectsUnknownDuplicateOrUnsafeInput(string json) + { + using var document = JsonDocument.Parse(json); + + var result = PlayoutBridgeProtocol.ParseTimeoutQuarantine(document.RootElement); + + Assert.False(result.IsValid); + Assert.NotEmpty(result.Error); + } } diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs new file mode 100644 index 0000000..4e35083 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs @@ -0,0 +1,552 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ReadOnlyPanelSceneBuildersTests +{ + [Fact] + public void Builder_keys_are_the_closed_scene_codes() + { + Assert.Equal("s5006", new S5006SceneMutationBuilder().BuilderKey); + Assert.Equal("s5011", new S5011SceneMutationBuilder().BuilderKey); + Assert.Equal("s5016", new S5016SceneMutationBuilder().BuilderKey); + Assert.Equal("s50160", new S50160SceneMutationBuilder().BuilderKey); + Assert.Equal("s6001", new S6001SceneMutationBuilder().BuilderKey); + } + + [Fact] + public void S5006_matches_original_background_value_and_mainform_direction_order() + { + var actual = new S5006SceneMutationBuilder().Build(new S5006SceneData( + LegacyDomesticEquityMarket.NxtKospi, + "삼성전자(NXT)", + ScenePriceDirection.Down, + 72_100, + -900, + -1.23m, + 72_800, + -0.27m, + 73_500, + 0.69m, + 71_900, + -1.51m)).Select(Describe); + + Assert.Equal( + [ + "V:title=삼성전자(NXT)", + @"B:Video\큐브배경.vrv:1:1", + "V:price=72,100", + "V:changePrice3_=-900", + "V:rate3_=-1.23", + "V:price1=72,800", + "V:ratio1=-0.27", + "V:price2=73,500", + "V:ratio2=0.69", + "V:price3=71,900", + "V:ratio3=-1.51", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:down=1", + "S:bg1_=0", "S:changePrice1_=0", "S:wonText1_=0", "S:rate1_=0", "S:percent1_=0", + "S:bg2_=0", "S:changePrice2_=0", "S:wonText2_=0", "S:rate2_=0", "S:percent2_=0", + "S:bg3_=1", "S:changePrice3_=1", "S:wonText3_=1", "S:rate3_=1", "S:percent3_=1", + @"A:pattern=Images\그림_파랑.png" + ], actual); + } + + [Fact] + public void S5006_replaces_absolute_legacy_background_with_relative_asset_only() + { + var mutations = new S5006SceneMutationBuilder().Build(ValidS5006()); + var assets = mutations.SelectMany(mutation => mutation switch + { + PlayoutSetAssetValue asset => [asset.AssetPath], + PlayoutSetBackgroundVideo video => [video.AssetPath], + _ => Array.Empty() + }).ToArray(); + + Assert.NotEmpty(assets); + Assert.All(assets, asset => + { + Assert.False(Path.IsPathRooted(asset)); + Assert.DoesNotContain(":", asset, StringComparison.Ordinal); + Assert.DoesNotContain("MBN_STOCK_N", asset, StringComparison.OrdinalIgnoreCase); + }); + } + + [Fact] + public void S5011_face_value_branch_matches_original_order_and_nxt_marker() + { + var actual = new S5011SceneMutationBuilder().Build(new S5011SceneData( + LegacyDomesticEquityMarket.NxtKosdaq, + "테스트(NXT)", + ScenePriceDirection.Up, + 1_250, + 25, + 2.04m, + new S5011FaceValueDetail( + 500, + 1_234_567_890, + 9_876_543_210, + 3))).Select(Describe); + + Assert.Equal( + [ + "V:title=테스트", + "V:r_unit1=원", "V:r_unit2=억원", "V:r_unit3=억원", "V:r_unit4=위", + "V:r_title1=액면가", "V:r_title2=자본금", "V:r_title3=시가총액", "V:r_title4=시총순위", + "V:r_value1=500", "V:r_value2=12", "V:r_value3=98", "V:r_value4=3", + "V:price=1,250", "V:changePrice1_=25", "V:rate1_=2.04", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:up=1", + "S:bg1_=1", "S:changePrice1_=1", "S:wonText1_=1", "S:rate1_=1", "S:percent1_=1", + "S:bg2_=0", "S:changePrice2_=0", "S:wonText2_=0", "S:rate2_=0", "S:percent2_=0", + "S:bg3_=0", "S:changePrice3_=0", "S:wonText3_=0", "S:rate3_=0", "S:percent3_=0", + @"A:pattern=Images\그림_빨강.png", + "S:mart=1" + ], actual); + } + + [Fact] + public void S5011_valuation_branch_preserves_decimal_scale_and_label_order() + { + var actual = new S5011SceneMutationBuilder().Build(new S5011SceneData( + LegacyDomesticEquityMarket.Kospi, + "테스트", + ScenePriceDirection.Flat, + 1_000, + 0, + 0, + new S5011ValuationDetail(1.20m, 8.75m, 12_345, -321))); + + Assert.Equal( + [ + "V:title=테스트", + "V:r_unit1=배", "V:r_unit2=배", "V:r_unit3=원", "V:r_unit4=원", + "V:r_title1=PBR", "V:r_title2=PER", "V:r_title3=BPS", "V:r_title4=EPS", + "V:r_value1=1.20", "V:r_value2=8.75", "V:r_value3=12,345", "V:r_value4=-321" + ], actual.Take(13).Select(Describe)); + Assert.Equal("V:changePrice2_=0", Describe(actual[14])); + Assert.Equal("S:mart=0", Describe(actual[^1])); + Assert.Equal(44, actual.Count); + } + + [Fact] + public void S5011_volume_branch_preserves_integer_division_and_labels() + { + var actual = new S5011SceneMutationBuilder().Build(new S5011SceneData( + LegacyDomesticEquityMarket.Kosdaq, + "테스트", + ScenePriceDirection.Down, + 900, + -100, + -10, + new S5011VolumeDetail(12_345, 9_876_543, 1_111, 2_222))); + + Assert.Equal( + [ + "V:title=테스트", + "V:r_unit1=주", "V:r_unit2=백만", "V:r_unit3=원", "V:r_unit4=원", + "V:r_title1=거래량", "V:r_title2=거래대금", "V:r_title3=5MA", "V:r_title4=20MA", + "V:r_value1=12,345", "V:r_value2=9", "V:r_value3=1,111", "V:r_value4=2,222" + ], actual.Take(13).Select(Describe)); + Assert.Equal("V:changePrice3_=-100", Describe(actual[14])); + Assert.Equal(@"A:pattern=Images\그림_파랑.png", Describe(actual[^2])); + } + + [Fact] + public void S5016_matches_original_three_row_visibility_and_value_order() + { + var actual = new S5016SceneMutationBuilder().Build(new S5016SceneData( + S5016PanelTarget.OverseasExchangeRates, + [ + new("KOSPI A", null, 1_234.5m, 1.23456m, 1.2m, LegacyPanelQuoteSignal.Positive), + new("B", null, 2_000m, 0m, 0m, LegacyPanelQuoteSignal.Negative), + new("C", null, 3_000m, -2.5m, 3.4m, LegacyPanelQuoteSignal.Negative) + ])).Select(Describe); + + Assert.Equal( + [ + "V:title=해외 환율", + "S:flat1=0", "S:up1=0", "S:upup1=0", "S:downdown1=0", "S:down1=0", + "V:title1=코스피 A", "V:price1=1,234.50", + "S:bg1_1=1", "S:bg1_2=0", "S:bg1_3=0", + "S:rate1_1=1", "S:rate1_2=0", "S:rate1_3=0", + "S:changePrice1_1=1", "S:changePrice1_2=0", "S:changePrice1_3=0", + "S:percent1_1=1", "S:percent1_2=0", "S:percent1_3=0", + "S:unit1_1=1", "S:unit1_2=0", "S:unit1_3=0", + "S:up1=1", "V:changePrice1_1=1.2346", "V:rate1_1=1.20", "V:unitText=단위 : $", + "S:flat2=0", "S:up2=0", "S:upup2=0", "S:downdown2=0", "S:down2=0", + "V:title2=B", "V:price2=2,000.00", + "S:bg2_1=0", "S:bg2_2=1", "S:bg2_3=0", + "S:rate2_1=0", "S:rate2_2=1", "S:rate2_3=0", + "S:changePrice2_1=0", "S:changePrice2_2=1", "S:changePrice2_3=0", + "S:percent2_1=0", "S:percent2_2=1", "S:percent2_3=0", + "S:unit2_1=0", "S:unit2_2=1", "S:unit2_3=0", + "S:flat2=1", "V:changePrice2_2=0.0000", "V:rate2_2=0.00", "V:unitText=단위 : $", + "S:flat3=0", "S:up3=0", "S:upup3=0", "S:downdown3=0", "S:down3=0", + "V:title3=C", "V:price3=3,000.00", + "S:bg3_1=0", "S:bg3_2=0", "S:bg3_3=1", + "S:rate3_1=0", "S:rate3_2=0", "S:rate3_3=1", + "S:changePrice3_1=0", "S:changePrice3_2=0", "S:changePrice3_3=1", + "S:percent3_1=0", "S:percent3_2=0", "S:percent3_3=1", + "S:unit3_1=0", "S:unit3_2=0", "S:unit3_3=1", + "S:down3=1", "V:changePrice3_3=2.5000", "V:rate3_3=-3.40", "V:unitText=단위 : $" + ], actual); + } + + [Theory] + [MemberData(nameof(S5016TargetCases))] + public void S5016_target_mapping_is_closed_and_repeats_original_unit_order( + S5016PanelTarget target, + string title, + string unit) + { + var quotes = Enumerable.Range(1, 3) + .Select(index => new LegacyPanelQuoteData( + "Name" + index, + "Part" + index, + index, + 0, + 0, + LegacyPanelQuoteSignal.Flat)) + .ToArray(); + + var actual = new S5016SceneMutationBuilder().Build(new S5016SceneData(target, quotes)); + + Assert.Equal("V:title=" + title, Describe(actual[0])); + Assert.Equal("V:unitText=" + unit, Describe(actual[26])); + Assert.Equal("V:unitText=" + unit, Describe(actual[52])); + Assert.Equal("V:unitText=" + unit, Describe(actual[78])); + } + + [Fact] + public void S5016_bond_branch_uses_part_name_not_generic_name() + { + var quotes = Enumerable.Range(1, 3) + .Select(index => new LegacyPanelQuoteData( + "generic" + index, + "bond" + index, + index, + 0, + 0, + LegacyPanelQuoteSignal.Flat)) + .ToArray(); + + var actual = new S5016SceneMutationBuilder().Build(new S5016SceneData( + S5016PanelTarget.BondYields, + quotes)); + + Assert.Equal("V:title1=bond1", Describe(actual[6])); + Assert.Equal("V:title2=bond2", Describe(actual[32])); + Assert.Equal("V:title3=bond3", Describe(actual[58])); + } + + [Fact] + public void S50160_matches_original_two_row_unit_clear_and_visibility_order() + { + var actual = new S50160SceneMutationBuilder().Build(new S50160SceneData( + S50160PanelTarget.Cotton, + [ + new("Cotton", null, 100m, 2m, 1.5m, LegacyPanelQuoteSignal.Positive), + new("KOSQ Cotton", null, 90m, -1m, 2m, LegacyPanelQuoteSignal.Negative) + ])).Select(Describe); + + Assert.Equal( + [ + "V:title=농산물", + "S:flat1=0", "S:up1=0", "S:upup1=0", "S:downdown1=0", "S:down1=0", + "V:title1=Cotton", "V:price1=100.00", + "S:bg1_1=1", "S:bg1_2=0", "S:bg1_3=0", + "S:rate1_1=1", "S:rate1_2=0", "S:rate1_3=0", + "S:changePrice1_1=1", "S:changePrice1_2=0", "S:changePrice1_3=0", + "S:percent1_1=1", "S:percent1_2=0", "S:percent1_3=0", + "S:unit1_1=1", "S:unit1_2=0", "S:unit1_3=0", + "S:up1=1", "V:changePrice1_1=2.00", "V:rate1_1=1.50", + "V:unit1_1=", "V:unit1_2=", "V:unit1_3=", "V:unitText=단위 : $", + "S:flat2=0", "S:up2=0", "S:upup2=0", "S:downdown2=0", "S:down2=0", + "V:title2=코스닥 Cotton", "V:price2=90.00", + "S:bg2_1=0", "S:bg2_2=0", "S:bg2_3=1", + "S:rate2_1=0", "S:rate2_2=0", "S:rate2_3=1", + "S:changePrice2_1=0", "S:changePrice2_2=0", "S:changePrice2_3=1", + "S:percent2_1=0", "S:percent2_2=0", "S:percent2_3=1", + "S:unit2_1=0", "S:unit2_2=0", "S:unit2_3=1", + "S:down2=1", "V:changePrice2_3=1.00", "V:rate2_3=-2.00", + "V:unit2_1=", "V:unit2_2=", "V:unit2_3=", "V:unitText=단위 : $" + ], actual); + } + + [Theory] + [InlineData(S50160PanelTarget.Cotton, "농산물", "단위 : $")] + [InlineData(S50160PanelTarget.InternationalPreciousMetals, "원자재", "단위 : $")] + [InlineData(S50160PanelTarget.DomesticPreciousMetals, "원자재", "단위 : 원")] + public void S50160_target_mapping_is_closed( + S50160PanelTarget target, + string title, + string unit) + { + var rows = new[] { FlatQuote("A"), FlatQuote("B") }; + var actual = new S50160SceneMutationBuilder().Build(new S50160SceneData(target, rows)); + + Assert.Equal("V:title=" + title, Describe(actual[0])); + Assert.Equal("V:unitText=" + unit, Describe(actual[29])); + Assert.Equal("V:unitText=" + unit, Describe(actual[58])); + } + + [Fact] + public void S6001_index_branch_matches_original_asset_value_and_color_order() + { + var actual = new S6001SceneMutationBuilder().Build(new S6001SceneData( + S6001QuoteTarget.Nasdaq, + "나스닥", + 18_000.123m, + -15.5m, + 1.23m, + ScenePriceDirection.Down)).Select(Describe); + + Assert.Equal( + [ + "V:title=나스닥", + @"A:movie=Video\20201008_미국.vrv", + "V:price=18,000.12", + "V:changePrice=15.50", + "V:rate=-1.23", + "V:wonText=", + "V:unit=단위 : p", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:down=1", + "C:bg:15:99:189:255", "C:changePrice:15:99:189:255", + "C:wonText:15:99:189:255", "C:rate:15:99:189:255", "C:percent:15:99:189:255", + @"A:pattern=Images\그림_파랑.png" + ], actual); + } + + [Fact] + public void S6001_gold_branch_matches_original_asset_first_and_flat_colors() + { + var actual = new S6001SceneMutationBuilder().Build(new S6001SceneData( + S6001QuoteTarget.Gold, + "국제 금", + 2_300m, + -1.1m, + -0.05m, + ScenePriceDirection.Flat)).Select(Describe); + + Assert.Equal( + [ + @"A:movie=images\35752913_l.jpg", + "V:unit=단위 : 달러/온스", + "V:title=국제 금", + "V:price=2,300.00", + "V:changePrice=-1.10", + "V:rate=-0.05", + "V:wonText=", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:flat=1", + "C:bg:95:95:95:255", "C:changePrice:52:52:52:255", + "C:wonText:52:52:52:255", "C:rate:52:52:52:255", "C:percent:52:52:52:255", + @"A:pattern=Images\그림_검정.png" + ], actual); + } + + [Theory] + [MemberData(nameof(S6001TargetCases))] + public void S6001_target_mapping_is_closed_and_never_accepts_raw_asset_paths( + S6001QuoteTarget target, + string asset, + string unit, + bool isIndex) + { + var actual = new S6001SceneMutationBuilder().Build(new S6001SceneData( + target, + "Title", + 1, + 0, + 0, + ScenePriceDirection.Flat)); + + var assetMutation = Assert.IsType(actual[isIndex ? 1 : 0]); + Assert.Equal(asset, assetMutation.AssetPath); + Assert.Equal("V:unit=" + unit, Describe(actual[isIndex ? 6 : 1])); + Assert.False(Path.IsPathRooted(assetMutation.AssetPath)); + } + + [Fact] + public void Builders_fail_closed_for_unknown_or_missing_database_values() + { + Assert.Throws(() => + new S5006SceneMutationBuilder().Build(ValidS5006() with { StockName = "" })); + Assert.Throws(() => + new S5006SceneMutationBuilder().Build(ValidS5006() with + { + Market = (LegacyDomesticEquityMarket)999 + })); + Assert.Throws(() => + new S5011SceneMutationBuilder().Build(new S5011SceneData( + LegacyDomesticEquityMarket.Kospi, + "Name", + ScenePriceDirection.Up, + 1, + 1, + 1, + null!))); + Assert.Throws(() => + new S5016SceneMutationBuilder().Build(new S5016SceneData( + S5016PanelTarget.Oil, + [FlatQuote("A"), FlatQuote("B")]))); + Assert.Throws(() => + new S5016SceneMutationBuilder().Build(new S5016SceneData( + S5016PanelTarget.Oil, + [ + FlatQuote("A"), + FlatQuote("B"), + FlatQuote("") + ]))); + Assert.Throws(() => + new S5016SceneMutationBuilder().Build(new S5016SceneData( + (S5016PanelTarget)999, + [FlatQuote("A"), FlatQuote("B"), FlatQuote("C")]))); + Assert.Throws(() => + new S5016SceneMutationBuilder().Build(new S5016SceneData( + S5016PanelTarget.Oil, + [ + FlatQuote("A"), + FlatQuote("B"), + FlatQuote("C") with { Signal = (LegacyPanelQuoteSignal)999 } + ]))); + Assert.Throws(() => + new S50160SceneMutationBuilder().Build(new S50160SceneData( + (S50160PanelTarget)999, + [FlatQuote("A"), FlatQuote("B")]))); + Assert.Throws(() => + new S6001SceneMutationBuilder().Build(new S6001SceneData( + S6001QuoteTarget.Gold, + "", + 1, + 0, + 0, + ScenePriceDirection.Flat))); + Assert.Throws(() => + new S6001SceneMutationBuilder().Build(new S6001SceneData( + S6001QuoteTarget.Gold, + "Gold", + 1, + 0, + 0, + ScenePriceDirection.Unknown))); + Assert.Throws(() => + new S6001SceneMutationBuilder().Build(new S6001SceneData( + (S6001QuoteTarget)999, + "Gold", + 1, + 0, + 0, + ScenePriceDirection.Flat))); + } + + [Fact] + public void Scene_dtos_expose_no_raw_path_object_or_method_surface() + { + Type[] dtoTypes = + [ + typeof(S5006SceneData), + typeof(S5011SceneData), + typeof(S5011FaceValueDetail), + typeof(S5011ValuationDetail), + typeof(S5011VolumeDetail), + typeof(S5016SceneData), + typeof(S50160SceneData), + typeof(LegacyPanelQuoteData), + typeof(S6001SceneData) + ]; + + foreach (var dtoType in dtoTypes) + { + Assert.DoesNotContain(dtoType.GetProperties(), property => + property.Name.Contains("Path", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("Object", StringComparison.OrdinalIgnoreCase) || + property.Name.Contains("Method", StringComparison.OrdinalIgnoreCase) || + typeof(PlayoutMutation).IsAssignableFrom(property.PropertyType)); + } + } + + public static IEnumerable S5016TargetCases() + { + yield return [S5016PanelTarget.UnitedStatesIndices, "미국 지수", "단위 : p"]; + yield return [S5016PanelTarget.GreaterChinaIndices, "중화권 지수", "단위 : p"]; + yield return [S5016PanelTarget.EuropeanIndices, "유럽 지수", "단위 : p"]; + yield return [S5016PanelTarget.AsianIndices, "아시아 지수", "단위 : p"]; + yield return [S5016PanelTarget.UnitedStatesTreasuries, "미국 국채", "단위 : %"]; + yield return [S5016PanelTarget.BondYields, "채권 금리", "단위 : %"]; + yield return [S5016PanelTarget.Oil, "유가", "단위 : 배럴/달러"]; + yield return [S5016PanelTarget.Minerals, "광물", "단위 : $"]; + yield return [S5016PanelTarget.FoodMaterials, "식자재", "단위 : ¢"]; + yield return [S5016PanelTarget.OverseasExchangeRates, "해외 환율", "단위 : $"]; + yield return [S5016PanelTarget.DomesticExchangeRates, "환율", "단위 : 원"]; + yield return [S5016PanelTarget.AgricultureWheatCorn, "농산물", "단위 : $"]; + yield return [S5016PanelTarget.AgricultureSoyRice, "농산물", "단위 : $"]; + yield return [S5016PanelTarget.AgricultureCoffeeCocoaSugar, "농산물", "단위 : $"]; + yield return [S5016PanelTarget.AgricultureLivestock, "농산물", "단위 : $"]; + yield return [S5016PanelTarget.RawMaterialsCopperIronNickel, "원자재", "단위 : $"]; + yield return [S5016PanelTarget.RawMaterialsGasPlatinumPalladium, "원자재", "단위 : $"]; + yield return [S5016PanelTarget.RawMaterialsLeadZincTin, "원자재", "단위 : $"]; + yield return [S5016PanelTarget.MajorKospiKosdaqKospi200, "주요지수", "단위 : p"]; + yield return [S5016PanelTarget.MajorKospiKosdaqFutures, "주요지수", "단위 : p"]; + yield return [S5016PanelTarget.ExpectedKospiKosdaqKospi200, "예상체결지수", "단위 : p"]; + yield return [S5016PanelTarget.ExpectedKospiKosdaqFutures, "예상체결지수", "단위 : p"]; + } + + public static IEnumerable S6001TargetCases() + { + yield return [S6001QuoteTarget.DowJones, @"Video\20201008_미국.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Nasdaq, @"Video\20201008_미국.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.StandardAndPoor500, @"Video\20201008_미국.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Germany, @"Video\20201008_독일.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.UnitedKingdom, @"Video\20201008_영국.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.France, @"Video\20201008_프랑스.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Japan, @"Video\20201008_일본.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.China, @"Video\20201008_중국.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.HongKong, @"Video\20201008_홍콩.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Taiwan, @"Video\20201008_대만.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Singapore, @"Video\20201008_싱가포르.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Thailand, @"Video\20201008_태국.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Philippines, @"Video\20201008_필리핀.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Malaysia, @"Video\20201008_말레이시아.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.Indonesia, @"Video\20201008_인도네시아.vrv", "단위 : p", true]; + yield return [S6001QuoteTarget.DubaiCrude, @"images\주유기merge.png", "단위 : 배럴/달러", false]; + yield return [S6001QuoteTarget.BrentCrude, @"images\주유기merge.png", "단위 : 배럴/달러", false]; + yield return [S6001QuoteTarget.WtiCrude, @"images\주유기merge.png", "단위 : 배럴/달러", false]; + yield return [S6001QuoteTarget.Gold, @"images\35752913_l.jpg", "단위 : 달러/온스", false]; + } + + private static S5006SceneData ValidS5006() => new( + LegacyDomesticEquityMarket.Kospi, + "Name", + ScenePriceDirection.Flat, + 1, + 0, + 0, + 1, + 0, + 1, + 0, + 1, + 0); + + private static LegacyPanelQuoteData FlatQuote(string name) => + new(name, name, 1, 0, 0, LegacyPanelQuoteSignal.Flat); + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetAssetValue asset => $"A:{asset.ObjectName}={asset.AssetPath}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + PlayoutSetFaceColor color => + $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}", + PlayoutSetBackgroundVideo video => + $"B:{video.AssetPath}:{video.LoopCount}:{(video.LoopInfinite ? 1 : 0)}", + _ => mutation.GetType().Name + }; +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs new file mode 100644 index 0000000..648ffb2 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs @@ -0,0 +1,153 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class RegisteredLegacySceneCueProviderTests +{ + [Fact] + public async Task CreatePage_DefaultFadeMatchesLegacyMainFormSelectedIndexSix() + { + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage( + "s5085", + new S5085SceneData( + [ + new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 0d) + ]), + 1))); + + var page = await provider.CreatePageAsync(new("entry", "5085"), 0); + + Assert.Equal(6, page.Cue.FadeDuration); + } + + [Fact] + public async Task CreatePage_ComposesLegacyBackgroundThenTypedBuilderMutations() + { + var data = new S5085SceneData( + [ + new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 12.5) + ]); + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage("s5085", data, 1)), + options: new LegacySceneCueCompositionOptions( + 3, + LegacySceneBackgroundKind.Texture, + "Video\\studio.vrv")); + + var page = await provider.CreatePageAsync(new("entry", "5085", FadeDuration: 7), 0); + + Assert.Equal("5085.t2s", page.Cue.SceneFile); + Assert.Equal("5085", page.Cue.SceneName); + Assert.Equal(7, page.Cue.FadeDuration); + Assert.Null(page.PageSize); + Assert.Equal(1, page.ItemCount); + Assert.Collection( + page.Cue.Mutations!.Take(2), + mutation => Assert.Equal( + new PlayoutSetBackgroundTexture("Video\\studio.vrv"), + mutation), + mutation => Assert.Equal(new PlayoutUseBackground(true), mutation)); + Assert.Equal( + new S5085SceneMutationBuilder().Build(data), + page.Cue.Mutations!.Skip(2)); + } + + [Fact] + public async Task CreatePage_UsesCatalogPageSizeAndKeepsTheFullItemCount() + { + var data = new PagedQuoteSceneData( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "상승률", + Enumerable.Range(1, 101) + .Select(index => new PagedQuoteRowData( + "종목" + index, + index, + index, + index, + ScenePriceDirection.Up)) + .ToArray(), + PageNumberOneBased: 20); + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage("s5074", data, 101))); + + var page = await provider.CreatePageAsync(new("entry", "5074"), 19); + + Assert.Equal(ScenePageSize.Five, page.PageSize); + Assert.Equal(101, page.ItemCount); + Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]); + Assert.Contains(page.Cue.Mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "title1", Value: "종목96" }); + } + + [Theory] + [InlineData("5085", "s8086")] + [InlineData("5085", "s5001")] + [InlineData("5001", "s8018")] + public async Task CreatePage_RejectsUnreachableOrMismatchedBuilderAliases( + string cutCode, + string builderKey) + { + var data = new S5085SceneData([]); + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage(builderKey, data, 1))); + + await Assert.ThrowsAsync(() => + provider.CreatePageAsync(new("entry", cutCode), 0)); + } + + [Theory] + [InlineData("../outside.vrv")] + [InlineData("C:\\outside.vrv")] + [InlineData("")] + public void Constructor_RejectsUnsafeBackgroundConfiguration(string path) + { + Assert.Throws(() => new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage("s5085", new S5085SceneData([]), 1)), + options: new LegacySceneCueCompositionOptions( + 0, + LegacySceneBackgroundKind.Video, + path))); + } + + [Fact] + public async Task CreatePage_RejectsPage21BeforeBuildingOrDispatching() + { + var data = new PagedQuoteSceneData( + PagedQuoteBranch.Domestic, + PagedQuoteHeaderStyle.StandardWon, + PagedQuoteTitleStyle.Exact, + NxtMarketSession.NotApplicable, + "상승률", + Enumerable.Range(1, 101) + .Select(index => new PagedQuoteRowData( + "종목" + index, + index, + index, + index, + ScenePriceDirection.Up)) + .ToArray(), + PageNumberOneBased: 21); + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage("s5074", data, 101))); + + await Assert.ThrowsAsync(() => + provider.CreatePageAsync(new("entry", "5074"), 20)); + } + + private sealed class StubDataSource(LegacySceneDataPage page) : ILegacySceneDataSource + { + public Task LoadPageAsync( + LegacyPlaylistEntry entry, + int pageIndexZeroBased, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(page); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ScenePagingTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ScenePagingTests.cs new file mode 100644 index 0000000..edf8559 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ScenePagingTests.cs @@ -0,0 +1,335 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class ScenePagingTests +{ + public static TheoryData SupportedPageSizes => new() + { + { ScenePageSize.Five, 5 }, + { ScenePageSize.Six, 6 }, + { ScenePageSize.Twelve, 12 } + }; + + [Fact] + public void ScenePageSize_DefinesExactlyTheLegacyPageSizes() + { + var values = Enum.GetValues() + .Select(value => (int)value) + .OrderBy(value => value) + .ToArray(); + + Assert.Equal([5, 6, 12], values); + Assert.Equal(20, ScenePaging.MaximumPageCount); + } + + [Theory] + [MemberData(nameof(SupportedPageSizes))] + public void CreatePlan_AndWindows_CoverAllBoundaryCounts( + ScenePageSize pageSize, + int size) + { + var itemCounts = new[] + { + 0, + 1, + size - 1, + size, + size + 1, + (20 * size) - 1, + 20 * size, + (20 * size) + 1, + 21 * size, + int.MaxValue + }; + + foreach (var itemCount in itemCounts) + { + var result = ScenePaging.CreatePlan(itemCount, pageSize); + + Assert.True(result.IsValid); + Assert.Equal(ScenePagingResultKind.Valid, result.Kind); + Assert.NotNull(result.Plan); + Assert.Empty(result.Error); + + var plan = result.Plan; + var expectedUncappedPages = itemCount == 0 + ? 0 + : ((itemCount - 1) / size) + 1; + var expectedTotalPages = Math.Min(20, expectedUncappedPages); + var expectedAccessibleItems = Math.Min(itemCount, 20 * size); + + Assert.Equal(itemCount, plan.ItemCount); + Assert.Equal(pageSize, plan.PageSize); + Assert.Equal(expectedTotalPages, plan.TotalPages); + Assert.Equal(expectedAccessibleItems, plan.AccessibleItemCount); + Assert.Equal(itemCount - expectedAccessibleItems, plan.ExcludedItemCount); + + for (var page = 1; page <= plan.TotalPages; page++) + { + var pageResult = ScenePaging.GetWindow(itemCount, pageSize, page); + + Assert.True(pageResult.IsValid); + Assert.Equal(ScenePagingResultKind.Valid, pageResult.Kind); + Assert.NotNull(pageResult.Plan); + Assert.NotNull(pageResult.Window); + Assert.Empty(pageResult.Error); + + var window = pageResult.Window; + var expectedOffset = (page - 1) * size; + var expectedCount = Math.Min( + size, + expectedAccessibleItems - expectedOffset); + var expectedRemaining = + expectedAccessibleItems - expectedOffset - expectedCount; + + Assert.Equal(page, window.PageNumberOneBased); + Assert.Equal(expectedTotalPages, window.TotalPages); + Assert.Equal(expectedOffset, window.OffsetZeroBased); + Assert.Equal(expectedCount, window.ItemCount); + Assert.Equal(expectedRemaining, window.RemainingItemCount); + Assert.InRange(window.ItemCount, 1, size); + Assert.InRange( + window.OffsetZeroBased + window.ItemCount, + 1, + expectedAccessibleItems); + Assert.InRange( + window.RemainingItemCount, + 0, + expectedAccessibleItems); + } + + if (plan.TotalPages == 0) + { + var pageResult = ScenePaging.GetWindow(itemCount, pageSize, 1); + + Assert.False(pageResult.IsValid); + Assert.Equal(ScenePagingResultKind.Invalid, pageResult.Kind); + Assert.NotNull(pageResult.Plan); + Assert.Null(pageResult.Window); + Assert.NotEmpty(pageResult.Error); + } + } + } + + [Theory] + [MemberData(nameof(SupportedPageSizes))] + public void GetWindow_PartialLastPage_UsesOneBasedPageAndZeroBasedOffset( + ScenePageSize pageSize, + int size) + { + var result = ScenePaging.GetWindow(size + 1, pageSize, 2); + + Assert.True(result.IsValid); + Assert.NotNull(result.Window); + Assert.Equal(2, result.Window.PageNumberOneBased); + Assert.Equal(size, result.Window.OffsetZeroBased); + Assert.Equal(1, result.Window.ItemCount); + Assert.Equal(0, result.Window.RemainingItemCount); + } + + [Theory] + [InlineData(-1, ScenePageSize.Five)] + [InlineData(0, (ScenePageSize)0)] + [InlineData(1, (ScenePageSize)7)] + [InlineData(1, (ScenePageSize)13)] + public void CreatePlan_RejectsNegativeCountsAndUnsupportedSizes( + int itemCount, + ScenePageSize pageSize) + { + var result = ScenePaging.CreatePlan(itemCount, pageSize); + + Assert.False(result.IsValid); + Assert.Equal(ScenePagingResultKind.Invalid, result.Kind); + Assert.Null(result.Plan); + Assert.NotEmpty(result.Error); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(3)] + public void GetWindow_RejectsPagesOutsideTheOneBasedRange(int pageNumberOneBased) + { + var result = ScenePaging.GetWindow(6, ScenePageSize.Five, pageNumberOneBased); + + Assert.False(result.IsValid); + Assert.Equal(ScenePagingResultKind.Invalid, result.Kind); + Assert.NotNull(result.Plan); + Assert.Null(result.Window); + Assert.NotEmpty(result.Error); + } + + [Theory] + [MemberData(nameof(SupportedPageSizes))] + public void DecideNext_WhenCurrentPageIsBeforeTotal_ReturnsSameCueNextPage( + ScenePageSize pageSize, + int size) + { + ScenePlaylistCue[] playlist = + [ + new("before", true), + new("current", true, pageSize, size + 1), + new("after", true) + ]; + var state = new SceneNextState(playlist, 1, 1); + + var result = ScenePaging.DecideNext(state); + + Assert.True(result.IsValid); + Assert.Equal(SceneNextDecisionKind.SameCueNextPage, result.Kind); + Assert.Equal(1, result.TargetCueIndexZeroBased); + Assert.Equal(2, result.TargetPageNumberOneBased); + Assert.NotNull(result.TargetPage); + Assert.Equal(size, result.TargetPage.OffsetZeroBased); + Assert.Equal(1, result.TargetPage.ItemCount); + Assert.Equal(0, result.TargetPage.RemainingItemCount); + Assert.Empty(result.Error); + } + + [Fact] + public void DecideNext_OnLastPage_SelectsFirstEnabledCueAfterCurrent() + { + ScenePlaylistCue[] playlist = + [ + new("enabled-before", true), + new("current", true, ScenePageSize.Five, 6), + new("disabled-after", false), + new("first-enabled-after", true, ScenePageSize.Six, 7), + new("later-enabled", true) + ]; + var state = new SceneNextState(playlist, 1, 2); + + var result = ScenePaging.DecideNext(state); + + Assert.True(result.IsValid); + Assert.Equal(SceneNextDecisionKind.NextEnabledCue, result.Kind); + Assert.Equal(3, result.TargetCueIndexZeroBased); + Assert.Equal(1, result.TargetPageNumberOneBased); + Assert.NotNull(result.TargetPage); + Assert.Equal(0, result.TargetPage.OffsetZeroBased); + Assert.Equal(6, result.TargetPage.ItemCount); + Assert.Equal(1, result.TargetPage.RemainingItemCount); + Assert.Empty(result.Error); + } + + [Fact] + public void DecideNext_ForNonPagedCue_SelectsFirstEnabledCueAfterCurrent() + { + ScenePlaylistCue[] playlist = + [ + new("current", true), + new("disabled", false), + new("next", true) + ]; + + var result = ScenePaging.DecideNext(new SceneNextState(playlist, 0, null)); + + Assert.Equal(SceneNextDecisionKind.NextEnabledCue, result.Kind); + Assert.Equal(2, result.TargetCueIndexZeroBased); + Assert.Null(result.TargetPageNumberOneBased); + Assert.Null(result.TargetPage); + } + + [Fact] + public void DecideNext_ForEmptyPagedCue_SelectsNextCueWithoutInventingPageZero() + { + ScenePlaylistCue[] playlist = + [ + new("empty-current", true, ScenePageSize.Twelve, 0), + new("empty-next", true, ScenePageSize.Five, 0) + ]; + + var result = ScenePaging.DecideNext(new SceneNextState(playlist, 0, null)); + + Assert.Equal(SceneNextDecisionKind.NextEnabledCue, result.Kind); + Assert.Equal(1, result.TargetCueIndexZeroBased); + Assert.Null(result.TargetPageNumberOneBased); + Assert.Null(result.TargetPage); + } + + [Fact] + public void DecideNext_DoesNotWrapToAnEnabledCueBeforeCurrent() + { + ScenePlaylistCue[] playlist = + [ + new("enabled-before", true), + new("current", true, ScenePageSize.Five, 1), + new("disabled-after", false) + ]; + + var result = ScenePaging.DecideNext(new SceneNextState(playlist, 1, 1)); + + Assert.True(result.IsValid); + Assert.Equal(SceneNextDecisionKind.EndOfPlaylist, result.Kind); + Assert.Null(result.TargetCueIndexZeroBased); + Assert.Null(result.TargetPageNumberOneBased); + Assert.Null(result.TargetPage); + Assert.Empty(result.Error); + } + + [Theory] + [InlineData("null-state")] + [InlineData("null-playlist")] + [InlineData("empty-playlist")] + [InlineData("negative-index")] + [InlineData("index-at-count")] + [InlineData("blank-code")] + [InlineData("non-paged-items")] + [InlineData("non-paged-current-page")] + [InlineData("negative-items")] + [InlineData("unsupported-page-size")] + [InlineData("missing-current-page")] + [InlineData("page-zero")] + [InlineData("page-after-total")] + [InlineData("empty-cue-with-page")] + [InlineData("invalid-future-cue")] + public void DecideNext_RejectsInvalidState(string scenario) + { + var validPagedCue = new ScenePlaylistCue( + "current", + true, + ScenePageSize.Five, + 6); + SceneNextState? state = scenario switch + { + "null-state" => null, + "null-playlist" => new SceneNextState(null, 0, null), + "empty-playlist" => new SceneNextState([], 0, null), + "negative-index" => new SceneNextState([new("cue", true)], -1, null), + "index-at-count" => new SceneNextState([new("cue", true)], 1, null), + "blank-code" => new SceneNextState([new(" ", true)], 0, null), + "non-paged-items" => new SceneNextState([new("cue", true, null, 1)], 0, null), + "non-paged-current-page" => new SceneNextState([new("cue", true)], 0, 1), + "negative-items" => new SceneNextState( + [new("cue", true, ScenePageSize.Five, -1)], + 0, + null), + "unsupported-page-size" => new SceneNextState( + [new("cue", true, (ScenePageSize)7, 1)], + 0, + 1), + "missing-current-page" => new SceneNextState([validPagedCue], 0, null), + "page-zero" => new SceneNextState([validPagedCue], 0, 0), + "page-after-total" => new SceneNextState([validPagedCue], 0, 3), + "empty-cue-with-page" => new SceneNextState( + [new("cue", true, ScenePageSize.Five, 0)], + 0, + 1), + "invalid-future-cue" => new SceneNextState( + [new("current", true), new("next", true, null, 1)], + 0, + null), + _ => throw new InvalidOperationException($"Unknown scenario: {scenario}") + }; + + var result = ScenePaging.DecideNext(state); + + Assert.False(result.IsValid); + Assert.Equal(SceneNextDecisionKind.Invalid, result.Kind); + Assert.Null(result.TargetCueIndexZeroBased); + Assert.Null(result.TargetPageNumberOneBased); + Assert.Null(result.TargetPage); + Assert.NotEmpty(result.Error); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs new file mode 100644 index 0000000..4a42e2d --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs @@ -0,0 +1,282 @@ +using System.Reflection; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class TabularMarketSceneBuildersTests +{ + [Fact] + public void S5023_matches_daily_and_monthly_sign_order_and_clears_empty_rows() + { + var data = new S5023SceneData( + LegacyDomesticMarket.Kospi, + [ + new DailyInvestorFlowData( + new DateOnly(2026, 7, 10), + new InvestorFlowAmounts(100, -200, 0)), + null, + null, + null, + null + ], + new InvestorFlowAmounts(1000, -2000, 0)); + + var actual = new S5023SceneMutationBuilder().Build(data).Select(Describe); + + Assert.Equal( + [ + "V:title=\uCF54\uC2A4\uD53C \uB9E4\uB9E4\uB3D9\uD5A5", + "V:row1_value1=07/10", + "C:row1_value2:171:0:0:255", "V:row1_value2=100", + "C:row1_value3:15:99:188:255", "V:row1_value3=-200", + "C:row1_value4:47:47:47:255", "V:row1_value4=0", + "V:row2_value1=", + "C:row2_value2:47:47:47:255", "V:row2_value2=", + "C:row2_value3:47:47:47:255", "V:row2_value3=", + "C:row2_value4:47:47:47:255", "V:row2_value4=", + "V:row3_value1=", + "C:row3_value2:47:47:47:255", "V:row3_value2=", + "C:row3_value3:47:47:47:255", "V:row3_value3=", + "C:row3_value4:47:47:47:255", "V:row3_value4=", + "V:row4_value1=", + "C:row4_value2:47:47:47:255", "V:row4_value2=", + "C:row4_value3:47:47:47:255", "V:row4_value3=", + "C:row4_value4:47:47:47:255", "V:row4_value4=", + "V:row5_value1=", + "C:row5_value2:47:47:47:255", "V:row5_value2=", + "C:row5_value3:47:47:47:255", "V:row5_value3=", + "C:row5_value4:47:47:47:255", "V:row5_value4=", + "V:row6_value1=1,000", "C:row6_bg1:171:0:0:255", + "V:row6_value2=-2,000", "C:row6_bg2:15:99:189:255", + "V:row6_value3=0", "C:row6_bg3:95:95:95:255" + ], actual); + } + + [Fact] + public void S5024_matches_mixed_bar_center_scale_and_zero_order() + { + var data = new S5024SceneData( + LegacyDomesticMarket.Kosdaq, + [ + new(InvestorParticipant.Individual, 100), + new(InvestorParticipant.Foreign, -50), + new(InvestorParticipant.Institution, 0) + ]); + + var actual = new S5024SceneMutationBuilder().Build(data).Select(Describe); + + Assert.Equal( + [ + "V:title=\uCF54\uC2A4\uB2E5 \uB9E4\uB9E4\uB3D9\uD5A5", + "S:barG1_1=0", "S:barG1_2=0", + "S:barG2_1=0", "S:barG2_2=0", + "S:barG3_1=0", "S:barG3_2=0", + "P:centerbar:0:-2:0:2", + "S:barG1_1=1", "Z:bar1_1:0:0.6666667:0:2", "V:value1_1=100", + "S:barG2_2=1", "Z:bar2_2:0:0.33333334:0:2", "V:value2_2=-50", + "S:barG3_1=1", "Z:bar3_1:0:0:0:2", "V:value3_1=0" + ], actual); + } + + [Fact] + public void S5024_empty_slots_remain_hidden_and_emit_no_invalid_scale() + { + var actual = new S5024SceneMutationBuilder().Build(new S5024SceneData( + LegacyDomesticMarket.Kospi, + [])); + + Assert.Equal(8, actual.Count); + Assert.Equal("P:centerbar:0:-150:0:2", Describe(actual[^1])); + Assert.DoesNotContain(actual.Select(Describe), value => + value.Contains("NaN", StringComparison.Ordinal)); + } + + [Fact] + public void S5025_maps_four_closed_pair_rows_and_clears_empty_slots() + { + var data = new S5025SceneData( + "\uAC1C\uC778", + [ + new("\uC0BC\uC131\uC804\uC790", "1,000", "SK\uD558\uC774\uB2C9\uC2A4", "5,000"), + null, + new("A", "", "", ""), + null + ]); + + var actual = new S5025SceneMutationBuilder().Build(data).Select(Describe); + + Assert.Equal( + [ + "V:title=\uAC1C\uC778 \uC21C\uB9E4\uB3C4 \uC0C1\uC704", + "V:\uC885\uBAA91=\uC0BC\uC131\uC804\uC790", "V:\uAE08\uC5611=1,000", + "V:\uC885\uBAA92=SK\uD558\uC774\uB2C9\uC2A4", "V:\uAE08\uC5612=5,000", + "V:\uC885\uBAA93=", "V:\uAE08\uC5613=", "V:\uC885\uBAA94=", "V:\uAE08\uC5614=", + "V:\uC885\uBAA95=A", "V:\uAE08\uC5615=", "V:\uC885\uBAA96=", "V:\uAE08\uC5616=", + "V:\uC885\uBAA97=", "V:\uAE08\uC5617=", "V:\uC885\uBAA98=", "V:\uAE08\uC5618=" + ], actual); + } + + [Fact] + public void S5037_matches_flat_quote_clear_then_buy_sell_fill_order() + { + var data = new S5037SceneData( + "A", + 10_000, + 0, + 0, + ScenePriceDirection.Flat, + [new("BuyA", 123), new("BuyZero", 0)], + [new("SellNegative", -10)]); + + var actual = new S5037SceneMutationBuilder().Build(data).Select(Describe); + + Assert.Equal( + [ + "V:title=A", "V:unit=\uB2E8\uC704 : \uC6D0", "V:price=10,000", + "V:changePrice=0", "V:rate=0.00", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:bg1=0", "S:bg2=1", "S:bg3=0", + "V:buy_title_1=", "V:buy_vol_1=", "V:sell_title_1=", "V:sell_vol_1=", + "V:buy_title_2=", "V:buy_vol_2=", "V:sell_title_2=", "V:sell_vol_2=", + "V:buy_title_3=", "V:buy_vol_3=", "V:sell_title_3=", "V:sell_vol_3=", + "V:buy_title_4=", "V:buy_vol_4=", "V:sell_title_4=", "V:sell_vol_4=", + "V:buy_title_5=", "V:buy_vol_5=", "V:sell_title_5=", "V:sell_vol_5=", + "V:buy_title_1=BuyA", "V:buy_vol_1=123", + "V:buy_title_2=BuyZero", "V:buy_vol_2=0", + "V:sell_title_1=SellNegative", "V:sell_vol_1=-10" + ], actual); + } + + [Theory] + [InlineData(ScenePriceDirection.Up, "S:up=1", "S:bg1=1")] + [InlineData(ScenePriceDirection.Down, "S:down=1", "S:bg3=1")] + [InlineData(ScenePriceDirection.Flat, null, "S:bg2=1")] + public void S5037_direction_variants_keep_original_visibility_targets( + ScenePriceDirection direction, + string? activeDirection, + string activeBackground) + { + var actual = new S5037SceneMutationBuilder().Build(new S5037SceneData( + "A", 1, 0, 0, direction, [], [])).Select(Describe).ToArray(); + + Assert.Equal( + ["S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0"], + actual.Skip(5).Take(5)); + if (activeDirection is null) + { + Assert.DoesNotContain(actual.Skip(10).Take(1), value => value.EndsWith("=1")); + } + else + { + Assert.Contains(activeDirection, actual); + } + + Assert.Contains(activeBackground, actual); + } + + [Fact] + public void S8003_matches_quote_price_color_quantity_and_scale_order_with_empty_levels() + { + var data = new S8003SceneData( + "A", + 100, + 5, + 1.25, + ScenePriceDirection.Up, + [new(110, 100), new(90, 0), null], + [new(100, 50), new(120, 25), null], + 100, + 75); + + var actual = new S8003SceneMutationBuilder().Build(data).Select(Describe); + + Assert.Equal( + [ + "V:title=A", "V:unit=\uB2E8\uC704 : \uC6D0", "V:price=100", + "V:changePrice=5", "V:rate=1.25", + "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", + "S:up=1", "S:bg1=1", "S:bg2=0", "S:bg3=0", + "V:price_buy_1=110", "C:price_buy_1:172:0:0:255", + "V:price_buy_2=90", "C:price_buy_2:19:73:170:255", + "V:price_buy_3=", "C:price_buy_3:50:51:50:255", + "V:buy_jan_1=100", "V:buy_jan_2=0", "V:buy_jan_3=", + "V:price_sell_1=100", "C:price_sell_1:50:51:50:255", + "V:price_sell_2=120", "C:price_sell_2:172:0:0:255", + "V:price_sell_3=", "C:price_sell_3:50:51:50:255", + "V:sell_jan_1=50", "V:sell_jan_2=25", "V:sell_jan_3=", + "V:buy_jan_total=100", "V:sell_jan_total=75", + "Z:buy_bar_1:1:0:0:1", "Z:buy_bar_2:0:0:0:1", "Z:buy_bar_3:0:0:0:1", + "Z:sell_bar_1:0.5:0:0:1", "Z:sell_bar_2:0.25:0:0:1", "Z:sell_bar_3:0:0:0:1" + ], actual); + } + + [Fact] + public void S8003_all_empty_levels_emit_finite_zero_scales() + { + var actual = new S8003SceneMutationBuilder().Build(new S8003SceneData( + "A", + 0, + 0, + 0, + ScenePriceDirection.Unknown, + [null, null, null], + [null, null, null], + 0, + 0)); + + Assert.DoesNotContain(actual.Select(Describe), value => + value.Contains("NaN", StringComparison.Ordinal)); + Assert.All(actual.TakeLast(6), mutation => + Assert.Contains(":0:0:0:1", Describe(mutation), StringComparison.Ordinal)); + } + + [Fact] + public void Builders_reject_unclosed_or_malformed_data_before_dynamic_names_are_possible() + { + Assert.Throws(() => + new S5023SceneMutationBuilder().Build(new S5023SceneData( + (LegacyDomesticMarket)99, + [null, null, null, null, null], + new(0, 0, 0)))); + Assert.Throws(() => + new S5024SceneMutationBuilder().Build(new S5024SceneData( + LegacyDomesticMarket.Kospi, + [new((InvestorParticipant)99, 1)]))); + Assert.Throws(() => + new S5025SceneMutationBuilder().Build(new S5025SceneData("A", []))); + Assert.Throws(() => + new S8003SceneMutationBuilder().Build(new S8003SceneData( + "A", 1, 0, 0, ScenePriceDirection.Flat, + [new(1, -1), null, null], + [null, null, null], + 0, + 0))); + + var dtoTypes = new[] + { + typeof(S5023SceneData), typeof(S5024SceneData), typeof(S5025SceneData), + typeof(S5037SceneData), typeof(S8003SceneData) + }; + Assert.All(dtoTypes, dtoType => + { + Assert.True(typeof(ILegacySceneData).IsAssignableFrom(dtoType)); + Assert.DoesNotContain( + dtoType.GetProperties(BindingFlags.Instance | BindingFlags.Public), + property => property.Name is "ObjectName" or "MethodName" or "AssetPath" or "SceneFile"); + }); + } + + private static string Describe(PlayoutMutation mutation) => mutation switch + { + PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}", + PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}", + PlayoutSetFaceColor color => + $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}", + PlayoutSetPosition position => FormattableString.Invariant( + $"P:{position.ObjectName}:{position.X}:{position.Y}:{position.Z}:{(int)position.Components}"), + PlayoutSetScale scale => FormattableString.Invariant( + $"Z:{scale.ObjectName}:{scale.X}:{scale.Y}:{scale.Z}:{(int)scale.Components}"), + _ => mutation.GetType().Name + }; +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/TraderQuoteSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TraderQuoteSceneDataLoadersTests.cs new file mode 100644 index 0000000..6a940c2 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TraderQuoteSceneDataLoadersTests.cs @@ -0,0 +1,306 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class TraderQuoteSceneDataLoadersTests +{ + private static readonly string[] TraderQuoteColumns = + ["STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE"]; + private static readonly string[] DealerColumns = ["DEALER_NAME", "VOLUME"]; + private static readonly string[] OrderBookColumns = + [ + "STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE", + "SELL_PRICE_1", "SELL_PRICE_2", "SELL_PRICE_3", + "SELL_QUANTITY_1", "SELL_QUANTITY_2", "SELL_QUANTITY_3", + "BUY_PRICE_1", "BUY_PRICE_2", "BUY_PRICE_3", + "BUY_QUANTITY_1", "BUY_QUANTITY_2", "BUY_QUANTITY_3", + "TOTAL_SELL_QUANTITY", "TOTAL_BUY_QUANTITY" + ]; + + public static TheoryData TraderRoutes => new() + { + { LegacyDomesticMarket.Kospi, "t_online1", "t_dealer" }, + { LegacyDomesticMarket.Kosdaq, "t_kosdaq_online1", "t_kosdaq_dealer" } + }; + + public static TheoryData QuoteRoutes => new() + { + { LegacyDomesticMarket.Kospi, "t_online1_call" }, + { LegacyDomesticMarket.Kosdaq, "t_kosdaq_online1_call" } + }; + + [Theory] + [MemberData(nameof(TraderRoutes))] + public async Task S5037_routes_closed_market_binds_name_and_proves_quote_dealer_mutations( + LegacyDomesticMarket market, + string quoteTable, + string dealerTable) + { + const string stockName = "삼성전자"; + var executor = new RecordingExecutor( + TraderQuoteTable(stockName), + DealerTable("BUY"), + DealerTable("SELL")); + + var data = await new S5037SceneDataLoader(executor).LoadAsync( + new S5037SceneLoadRequest(market, stockName), + CancellationToken.None); + var mutations = new S5037SceneMutationBuilder().Build(data); + + Assert.Equal(3, executor.Calls.Count); + Assert.Equal( + ["SCENE_5037_QUOTE", "SCENE_5037_BUY", "SCENE_5037_SELL"], + executor.Calls.Select(call => call.TableName)); + Assert.Contains(quoteTable, executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains(dealerTable, executor.Calls[1].Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("f_buy_dealer_vol_1", executor.Calls[1].Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("f_sell_dealer_vol_1", executor.Calls[2].Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.DoesNotContain(stockName, call.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("stockName", parameter.Name); + Assert.Equal(stockName, parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }); + Assert.Equal(stockName, data.StockName); + Assert.Equal(ScenePriceDirection.Up, data.Direction); + Assert.Equal([500L, 400L, 300L, 200L, 100L], data.BuyDealers.Select(row => row.Volume)); + Assert.Equal("SELL5", data.SellDealers[^1].DealerName); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "price", Value: "70,000" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "buy_title_1", Value: "BUY1" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "sell_vol_5", Value: "100" }); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S5037_rejects_wrong_quote_name_schema_dealer_count_order_and_numeric_shape() + { + const string requested = "Requested"; + await Assert.ThrowsAsync(() => + new S5037SceneDataLoader(new RecordingExecutor( + TraderQuoteTable("Other"), DealerTable("B"), DealerTable("S"))).LoadAsync( + new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested))); + + var schema = TraderQuoteTable(requested); + schema.Columns[0].SetOrdinal(4); + await Assert.ThrowsAsync(() => + new S5037SceneDataLoader(new RecordingExecutor( + schema, DealerTable("B"), DealerTable("S"))).LoadAsync( + new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested))); + + var missing = DealerTable("B"); + missing.Rows.RemoveAt(4); + await Assert.ThrowsAsync(() => + new S5037SceneDataLoader(new RecordingExecutor( + TraderQuoteTable(requested), missing, DealerTable("S"))).LoadAsync( + new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested))); + + var unordered = DealerTable("B"); + unordered.Rows[2]["VOLUME"] = 900; + await Assert.ThrowsAsync(() => + new S5037SceneDataLoader(new RecordingExecutor( + TraderQuoteTable(requested), unordered, DealerTable("S"))).LoadAsync( + new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested))); + + var fractional = DealerTable("B"); + fractional.Rows[0]["VOLUME"] = 1.5m; + await Assert.ThrowsAsync(() => + new S5037SceneDataLoader(new RecordingExecutor( + TraderQuoteTable(requested), fractional, DealerTable("S"))).LoadAsync( + new S5037SceneLoadRequest(LegacyDomesticMarket.Kospi, requested))); + } + + [Theory] + [MemberData(nameof(QuoteRoutes))] + public async Task S8003_routes_closed_market_binds_code_and_proves_order_book_mutations( + LegacyDomesticMarket market, + string callTable) + { + const string stockCode = "A005930"; + var executor = new RecordingExecutor(OrderBookTable()); + + var data = await new S8003SceneDataLoader(executor).LoadAsync( + new S8003SceneLoadRequest(market, stockCode), + CancellationToken.None); + var mutations = new S8003SceneMutationBuilder().Build(data); + + var call = Assert.Single(executor.Calls); + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal("SCENE_8003", call.TableName); + Assert.Contains(callTable, call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(stockCode, call.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("stockCode", parameter.Name); + Assert.Equal(stockCode, parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + Assert.Equal(ScenePriceDirection.Down, data.Direction); + Assert.Equal(new OrderBookLevelData(69_900, 600), data.BuyLevels[0]); + Assert.Equal(new OrderBookLevelData(70_100, 300), data.SellLevels[0]); + Assert.Equal(1200, data.TotalBuyQuantity); + Assert.Equal(600, data.TotalSellQuantity); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "price_buy_1", Value: "69,900" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "sell_jan_total", Value: "600" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetScale { ObjectName: "buy_bar_1", X: 1f }); + Assert.Equal(0, executor.StringCalls); + } + + [Fact] + public async Task S8003_requires_exact_single_row_schema_finite_rate_and_integral_nonnegative_quantities() + { + var schema = OrderBookTable(); + schema.Columns[0].SetOrdinal(1); + await Assert.ThrowsAsync(() => + new S8003SceneDataLoader(new RecordingExecutor(schema)).LoadAsync( + new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930"))); + + var duplicate = OrderBookTable(); + duplicate.Rows.Add(duplicate.Rows[0].ItemArray); + await Assert.ThrowsAsync(() => + new S8003SceneDataLoader(new RecordingExecutor(duplicate)).LoadAsync( + new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930"))); + + var nonfinite = OrderBookTable(); + nonfinite.Rows[0]["RATE"] = double.NegativeInfinity; + await Assert.ThrowsAsync(() => + new S8003SceneDataLoader(new RecordingExecutor(nonfinite)).LoadAsync( + new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930"))); + + var fractional = OrderBookTable(); + fractional.Rows[0]["BUY_QUANTITY_1"] = 1.5m; + await Assert.ThrowsAsync(() => + new S8003SceneDataLoader(new RecordingExecutor(fractional)).LoadAsync( + new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930"))); + + var negative = OrderBookTable(); + negative.Rows[0]["SELL_QUANTITY_1"] = -1; + await Assert.ThrowsAsync(() => + new S8003SceneDataLoader(new RecordingExecutor(negative)).LoadAsync( + new S8003SceneLoadRequest(LegacyDomesticMarket.Kospi, "A005930"))); + } + + [Theory] + [InlineData(99, "A005930")] + [InlineData(0, "bad-code")] + [InlineData(1, "")] + public async Task Invalid_S8003_requests_fail_before_query(int rawMarket, string code) + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S8003SceneDataLoader(executor).LoadAsync( + new S8003SceneLoadRequest((LegacyDomesticMarket)rawMarket, code))); + + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData(99, "Stock")] + [InlineData(0, "")] + public async Task Invalid_S5037_requests_fail_before_query(int rawMarket, string name) + { + var executor = new RecordingExecutor(); + + await Assert.ThrowsAsync(() => + new S5037SceneDataLoader(executor).LoadAsync( + new S5037SceneLoadRequest((LegacyDomesticMarket)rawMarket, name))); + + Assert.Empty(executor.Calls); + } + + private static DataTable TraderQuoteTable(string stockName) => Table( + TraderQuoteColumns, + [stockName, 70_000, 500, "2", 0.72d]); + + private static DataTable DealerTable(string prefix) => Table( + DealerColumns, + [$"{prefix}1", 500], + [$"{prefix}2", 400], + [$"{prefix}3", 300], + [$"{prefix}4", 200], + [$"{prefix}5", 100]); + + private static DataTable OrderBookTable() => Table( + OrderBookColumns, + [ + "삼성전자", 70_000, -500, "5", -0.71d, + 70_100, 70_200, 70_300, + 300, 200, 100, + 69_900, 69_800, 69_700, + 600, 400, 200, + 600, 1200 + ]); + + private static DataTable Table(string[] columns, params object?[][] rows) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + foreach (var row in rows) + { + table.Rows.Add(row); + } + + 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 fallback is forbidden."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + StringCalls++; + 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); + Calls.Add(new QueryCall(source, tableName, query)); + if (_results.Count == 0) + { + throw new InvalidOperationException("No result was configured."); + } + + return Task.FromResult(_results.Dequeue().Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedManualSceneDataLoader5025Tests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedManualSceneDataLoader5025Tests.cs new file mode 100644 index 0000000..ee74f5e --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TrustedManualSceneDataLoader5025Tests.cs @@ -0,0 +1,130 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class TrustedManualSceneDataLoader5025Tests +{ + [Theory] + [InlineData(S5025ManualAudience.Individual, "개인")] + [InlineData(S5025ManualAudience.Foreign, "외국인")] + [InlineData(S5025ManualAudience.Institution, "기관")] + public async Task Loader_uses_only_closed_audience_and_proves_all_ten_slot_mutations( + S5025ManualAudience audience, + string expectedTitle) + { + var source = new RecordingSource(Rows()); + + var data = await new S5025SceneDataLoader(source).LoadAsync( + new S5025ManualSceneLoadRequest(audience), + CancellationToken.None); + var mutations = new S5025SceneMutationBuilder().Build(data); + + Assert.Equal(audience, Assert.Single(source.Calls)); + Assert.Equal(expectedTitle, data.Title); + Assert.Equal(5, data.Rows.Count); + Assert.Equal(21, mutations.Count); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue + { + ObjectName: "title", + Value: var value + } && value == expectedTitle + " 순매도 상위"); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "종목1", Value: "L1" }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "금액10", Value: "R5A" }); + } + + [Fact] + public void Public_manual_contract_exposes_no_path_or_free_form_selector() + { + Assert.Equal( + [typeof(S5025ManualAudience)], + typeof(S5025ManualSceneLoadRequest).GetProperties().Select(property => property.PropertyType)); + + var method = Assert.Single(typeof(IS5025TrustedManualDataSource).GetMethods()); + Assert.Equal("ReadAsync", method.Name); + Assert.DoesNotContain( + method.GetParameters(), + parameter => parameter.ParameterType == typeof(string)); + Assert.DoesNotContain( + typeof(S5025SceneDataLoader).GetMethods(), + methodInfo => methodInfo.GetParameters().Any(parameter => + parameter.ParameterType == typeof(string))); + } + + [Theory] + [InlineData(4)] + [InlineData(6)] + public async Task Loader_requires_exactly_five_trusted_rows(int rowCount) + { + var rows = Rows().Take(rowCount).ToList(); + while (rows.Count < rowCount) + { + rows.Add(new("X", "1", "Y", "2")); + } + + await Assert.ThrowsAsync(() => + new S5025SceneDataLoader(new RecordingSource(rows)).LoadAsync( + new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual))); + } + + [Fact] + public async Task Loader_rejects_null_control_and_oversized_cells_before_mutations() + { + var nullRows = Rows(); + nullRows[0] = new(null!, "1", "R", "2"); + await Assert.ThrowsAsync(() => + new S5025SceneDataLoader(new RecordingSource(nullRows)).LoadAsync( + new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual))); + + var controlRows = Rows(); + controlRows[0] = new("bad\nname", "1", "R", "2"); + await Assert.ThrowsAsync(() => + new S5025SceneDataLoader(new RecordingSource(controlRows)).LoadAsync( + new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual))); + + var oversizedRows = Rows(); + oversizedRows[0] = new(new string('X', 257), "1", "R", "2"); + await Assert.ThrowsAsync(() => + new S5025SceneDataLoader(new RecordingSource(oversizedRows)).LoadAsync( + new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual))); + } + + [Fact] + public async Task Unknown_audience_fails_before_trusted_source_is_called() + { + var source = new RecordingSource(Rows()); + + await Assert.ThrowsAsync(() => + new S5025SceneDataLoader(source).LoadAsync( + new S5025ManualSceneLoadRequest((S5025ManualAudience)99))); + + Assert.Empty(source.Calls); + } + + private static List Rows() => + [ + new("L1", "L1A", "R1", "R1A"), + new("L2", "L2A", "R2", "R2A"), + new("L3", "L3A", "R3", "R3A"), + new("L4", "L4A", "R4", "R4A"), + new("L5", "L5A", "R5", "R5A") + ]; + + private sealed class RecordingSource(IReadOnlyList rows) + : IS5025TrustedManualDataSource + { + public List Calls { get; } = []; + + public Task> ReadAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Calls.Add(audience); + return Task.FromResult(rows); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseFakes.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseFakes.cs index 90de086..55bf456 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseFakes.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseFakes.cs @@ -18,12 +18,28 @@ internal sealed class ConnectionPlan public int? ObservedCommandTimeoutSeconds { get; set; } + public string? ObservedCommandText { get; set; } + + public IReadOnlyList ObservedParameters { get; set; } = []; + + public CancellationToken ObservedExecuteCancellationToken { get; set; } + + public int SynchronousOpenCount { get; set; } + + public int SynchronousExecuteCount { get; set; } + public static ConnectionPlan Success(DataTable data) => new() { ExecuteReaderAsync = _ => Task.FromResult(data.CreateDataReader()) }; } +internal sealed record ObservedDbParameter( + string Name, + object? Value, + DbType? ExplicitDbType, + ParameterDirection Direction); + internal sealed class FakeConnectionFactory : IDatabaseConnectionFactory { private readonly Queue _plans; @@ -85,7 +101,11 @@ internal sealed class FakeDbConnection(ConnectionPlan plan) : DbConnection public override void Close() => _state = ConnectionState.Closed; - public override void Open() => OpenAsync(CancellationToken.None).GetAwaiter().GetResult(); + public override void Open() + { + plan.SynchronousOpenCount++; + throw new InvalidOperationException("The synchronous open path must not be used."); + } public override async Task OpenAsync(CancellationToken cancellationToken) { @@ -146,20 +166,50 @@ internal sealed class FakeDbCommand(DbConnection connection, ConnectionPlan plan protected override DbParameter CreateDbParameter() => new FakeDbParameter(); protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => - ExecuteDbDataReaderAsync(behavior, CancellationToken.None).GetAwaiter().GetResult(); + throw RecordSynchronousExecution(); protected override Task ExecuteDbDataReaderAsync( CommandBehavior behavior, CancellationToken cancellationToken) { plan.ObservedCommandTimeoutSeconds = CommandTimeout; + plan.ObservedCommandText = CommandText; + plan.ObservedParameters = _parameters + .Cast() + .Select(parameter => new ObservedDbParameter( + parameter.ParameterName, + parameter.Value, + parameter is FakeDbParameter fakeParameter && fakeParameter.WasDbTypeSet + ? parameter.DbType + : null, + parameter.Direction)) + .ToArray(); + plan.ObservedExecuteCancellationToken = cancellationToken; return plan.ExecuteReaderAsync(cancellationToken); } + + private Exception RecordSynchronousExecution() + { + plan.SynchronousExecuteCount++; + return new InvalidOperationException("The synchronous command path must not be used."); + } } internal sealed class FakeDbParameter : DbParameter { - public override DbType DbType { get; set; } + private DbType _dbType; + + public bool WasDbTypeSet { get; private set; } + + public override DbType DbType + { + get => _dbType; + set + { + _dbType = value; + WasDbTypeSet = true; + } + } public override ParameterDirection Direction { get; set; } @@ -179,6 +229,8 @@ internal sealed class FakeDbParameter : DbParameter public override void ResetDbType() { + _dbType = default; + WasDbTypeSet = false; } } diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ProviderDatabaseConnectionFactoryTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ProviderDatabaseConnectionFactoryTests.cs index 19d223c..490f1e4 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ProviderDatabaseConnectionFactoryTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ProviderDatabaseConnectionFactoryTests.cs @@ -7,6 +7,33 @@ namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; public sealed class ProviderDatabaseConnectionFactoryTests { + [Fact] + public void OracleCommand_AcceptsLogicalNameForColonPlaceholder() + { + using var command = new OracleCommand("SELECT :code FROM DUAL"); + var parameter = command.CreateParameter(); + parameter.ParameterName = "code"; + parameter.Value = 7; + + command.Parameters.Add(parameter); + + Assert.Equal(0, command.Parameters.IndexOf("code")); + } + + [Fact] + public void MariaDbCommand_NormalizesLogicalNameForAtPlaceholder() + { + using var command = new MySqlCommand("SELECT @code"); + var parameter = command.CreateParameter(); + parameter.ParameterName = "code"; + parameter.Value = 7; + + command.Parameters.Add(parameter); + + Assert.Equal(0, command.Parameters.IndexOf("code")); + Assert.Equal(0, command.Parameters.IndexOf("@code")); + } + [Fact] public void CreateOracleConnection_UsesSidDescriptor() { diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ResilientDataQueryExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ResilientDataQueryExecutorTests.cs index de82a9d..84e4d7d 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ResilientDataQueryExecutorTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ResilientDataQueryExecutorTests.cs @@ -6,6 +6,228 @@ namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; public sealed class ResilientDataQueryExecutorTests { + [Theory] + [InlineData(DataSourceKind.Oracle, ':')] + [InlineData(DataSourceKind.MariaDb, '@')] + public async Task ExecuteAsync_ParameterizedSpec_BindsValuesTypesAndDbNullWithoutSyncCalls( + DataSourceKind source, + char marker) + { + var data = new DataTable(); + data.Columns.Add("VALUE", typeof(int)); + data.Rows.Add(1); + var plan = ConnectionPlan.Success(data); + var factory = new FakeConnectionFactory(plan); + var executor = CreateExecutor(factory); + var query = new DataQuerySpec( + $"SELECT value FROM sample WHERE code = {marker}code AND optional_value = {marker}optional_value", + [ + new DataQueryParameter("optional_value", null), + new DataQueryParameter("code", "005930", DbType.String) + ]); + + var result = await executor.ExecuteAsync(source, "Parameterized", query); + + Assert.Single(result.Rows.Cast()); + Assert.Equal(query.Sql, plan.ObservedCommandText); + Assert.Collection( + plan.ObservedParameters, + parameter => + { + Assert.Equal("code", parameter.Name); + Assert.Equal("005930", parameter.Value); + Assert.Equal(DbType.String, parameter.ExplicitDbType); + Assert.Equal(ParameterDirection.Input, parameter.Direction); + }, + parameter => + { + Assert.Equal("optional_value", parameter.Name); + Assert.Equal(DBNull.Value, parameter.Value); + Assert.Null(parameter.ExplicitDbType); + Assert.Equal(ParameterDirection.Input, parameter.Direction); + }); + Assert.True(plan.ObservedExecuteCancellationToken.CanBeCanceled); + Assert.Equal(0, plan.SynchronousOpenCount); + Assert.Equal(0, plan.SynchronousExecuteCount); + Assert.True(plan.Disposed); + } + + [Theory] + [InlineData(DataSourceKind.Oracle, ':')] + [InlineData(DataSourceKind.MariaDb, '@')] + public async Task ExecuteAsync_ParameterizedSpec_PropagatesCancellationWithoutRetryOrSyncFallback( + DataSourceKind source, + char marker) + { + var commandStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var plan = new ConnectionPlan + { + ExecuteReaderAsync = async token => + { + commandStarted.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + throw new InvalidOperationException("Unreachable."); + } + }; + var factory = new FakeConnectionFactory(plan); + var executor = CreateExecutor(factory, transient: true, retryCount: 2); + var query = new DataQuerySpec( + $"SELECT value FROM sample WHERE code = {marker}code", + [new DataQueryParameter("code", 7, DbType.Int32)]); + using var cancellation = new CancellationTokenSource(); + + var operation = executor.ExecuteAsync(source, "Canceled", query, cancellation.Token); + await commandStarted.Task; + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => operation); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(0, plan.SynchronousOpenCount); + Assert.Equal(0, plan.SynchronousExecuteCount); + Assert.True(plan.Disposed); + } + + [Theory] + [InlineData(DataSourceKind.Oracle, ':')] + [InlineData(DataSourceKind.MariaDb, '@')] + public async Task ExecuteAsync_ParameterizedCancellation_DoesNotExposeProviderMessage( + DataSourceKind source, + char marker) + { + const string sensitiveName = "cancel_secret_name"; + const string sensitiveValue = "cancel-value-sensitive-sentinel"; + var commandStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var plan = new ConnectionPlan + { + ExecuteReaderAsync = async token => + { + commandStarted.SetResult(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + } + catch (OperationCanceledException) + { + throw new OperationCanceledException( + $"{sensitiveName}|{sensitiveValue}", + token); + } + + throw new InvalidOperationException("Unreachable."); + } + }; + var factory = new FakeConnectionFactory(plan); + var executor = CreateExecutor(factory, transient: true, retryCount: 2); + var query = new DataQuerySpec( + $"SELECT value FROM sample WHERE code = {marker}{sensitiveName}", + [new DataQueryParameter(sensitiveName, sensitiveValue)]); + using var cancellation = new CancellationTokenSource(); + + var operation = executor.ExecuteAsync(source, "Canceled", query, cancellation.Token); + await commandStarted.Task; + await cancellation.CancelAsync(); + + var exception = await Assert.ThrowsAsync(() => operation); + Assert.DoesNotContain(sensitiveName, exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(sensitiveValue, exception.Message, StringComparison.Ordinal); + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.Null(exception.InnerException); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(0, plan.SynchronousOpenCount); + Assert.Equal(0, plan.SynchronousExecuteCount); + Assert.True(plan.Disposed); + } + + [Theory] + [InlineData(DataSourceKind.Oracle, ':')] + [InlineData(DataSourceKind.MariaDb, '@')] + public async Task ExecuteAsync_ParameterizedProviderFailure_DoesNotExposeNameOrValue( + DataSourceKind source, + char marker) + { + const string sensitiveName = "account_secret_name"; + const string sensitiveValue = "value-sensitive-sentinel"; + var plan = new ConnectionPlan + { + ExecuteReaderAsync = _ => Task.FromException( + new TestDatabaseException($"{sensitiveName}|{sensitiveValue}")) + }; + var factory = new FakeConnectionFactory(plan); + var executor = CreateExecutor(factory); + var query = new DataQuerySpec( + $"SELECT value FROM sample WHERE account = {marker}{sensitiveName}", + [new DataQueryParameter(sensitiveName, sensitiveValue, DbType.String)]); + + var exception = await Assert.ThrowsAsync( + () => executor.ExecuteAsync(source, "Sensitive", query)); + + Assert.Null(exception.InnerException); + Assert.DoesNotContain(sensitiveName, exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(sensitiveValue, exception.Message, StringComparison.Ordinal); + Assert.Equal(1, factory.CreateCount); + Assert.True(plan.Disposed); + } + + [Theory] + [InlineData(DataSourceKind.Oracle, ':')] + [InlineData(DataSourceKind.MariaDb, '@')] + public async Task ExecuteAsync_ParameterizedTransientReadFailure_RecreatesBoundCommandOnRetry( + DataSourceKind source, + char marker) + { + var first = new ConnectionPlan + { + ExecuteReaderAsync = _ => Task.FromException( + new TestDatabaseException("temporary")) + }; + var data = new DataTable(); + data.Columns.Add("VALUE", typeof(int)); + data.Rows.Add(42); + var second = ConnectionPlan.Success(data); + var factory = new FakeConnectionFactory(first, second); + var executor = CreateExecutor(factory, transient: true, retryCount: 1); + var query = new DataQuerySpec( + $"SELECT value FROM sample WHERE code = {marker}code", + [new DataQueryParameter("code", 42, DbType.Int32)]); + + var result = await executor.ExecuteAsync(source, "Retry", query); + + Assert.Equal(42, Assert.Single(result.Rows.Cast())["VALUE"]); + Assert.Equal(2, factory.CreateCount); + Assert.All( + new[] { first, second }, + plan => + { + var parameter = Assert.Single(plan.ObservedParameters); + Assert.Equal("code", parameter.Name); + Assert.Equal(42, parameter.Value); + Assert.Equal(DbType.Int32, parameter.ExplicitDbType); + Assert.Equal(0, plan.SynchronousOpenCount); + Assert.Equal(0, plan.SynchronousExecuteCount); + Assert.True(plan.Disposed); + }); + } + + [Fact] + public async Task ExecuteAsync_ParameterizedSpec_RejectsProviderMarkerBeforeOpeningConnection() + { + var plan = ConnectionPlan.Success(new DataTable()); + var factory = new FakeConnectionFactory(plan); + var executor = CreateExecutor(factory); + var query = new DataQuerySpec( + "SELECT value FROM sample WHERE code = :code", + [new DataQueryParameter("code", 7)]); + + var exception = await Assert.ThrowsAsync( + () => executor.ExecuteAsync(DataSourceKind.MariaDb, "Rejected", query)); + + Assert.Equal(0, factory.CreateCount); + Assert.DoesNotContain("code", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("7", exception.Message, StringComparison.Ordinal); + } + [Fact] public async Task ExecuteAsync_Success_PreservesSchemaRowsAndDbNull() { @@ -199,6 +421,30 @@ public sealed class ResilientDataQueryExecutorTests Assert.True(plan.Disposed); } + [Theory] + [InlineData("SELECT value INTO OUTFILE '/tmp/sample' FROM sample")] + [InlineData("SELECT 1; REPLACE INTO sample VALUES (1)")] + [InlineData("SELECT 1; SET @value = 1")] + [InlineData("WITH value AS (SELECT 1) DELETE FROM sample")] + public async Task ExecuteAsync_AmbiguousOrStateChangingLegacyRead_DoesNotRetry( + string query) + { + var plan = new ConnectionPlan + { + ExecuteReaderAsync = _ => Task.FromException( + new TestDatabaseException("temporary")) + }; + var factory = new FakeConnectionFactory(plan); + var executor = CreateExecutor(factory, transient: true, retryCount: 2); + + var exception = await Assert.ThrowsAsync( + () => executor.ExecuteAsync(DataSourceKind.MariaDb, "Mutation", query)); + + Assert.True(exception.IsTransient); + Assert.Equal(1, factory.CreateCount); + Assert.True(plan.Disposed); + } + private static ResilientDataQueryExecutor CreateExecutor( FakeConnectionFactory factory, bool transient = false, diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs new file mode 100644 index 0000000..c0c6eee --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileDataSourceTests.cs @@ -0,0 +1,330 @@ +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class S5025TrustedManualFileDataSourceTests +{ + private static readonly Encoding Cp949 = CreateCp949Encoding(); + + [Theory] + [InlineData(S5025ManualAudience.Individual, "개인.dat")] + [InlineData(S5025ManualAudience.Foreign, "외국인.dat")] + [InlineData(S5025ManualAudience.Institution, "기관.dat")] + public async Task ReadAsync_maps_closed_audience_to_fixed_cp949_file( + S5025ManualAudience audience, + string expectedFileName) + { + using var directory = new TemporaryDirectory(); + WriteValidFile(Path.Combine(directory.Path, expectedFileName)); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + var rows = await source.ReadAsync(audience); + + Assert.Equal(5, rows.Count); + Assert.Equal("삼성전자", rows[0].LeftName); + Assert.Equal("1,234", rows[0].LeftAmount); + Assert.Equal("SK하이닉스", rows[0].RightName); + Assert.Equal("-987", rows[0].RightAmount); + } + + [Fact] + public async Task CreateFromEnvironment_uses_only_documented_directory_setting() + { + using var directory = new TemporaryDirectory(); + WriteValidFile(Path.Combine(directory.Path, "개인.dat")); + var requestedNames = new List(); + var source = S5025TrustedManualFileDataSource.CreateFromEnvironment(name => + { + requestedNames.Add(name); + return directory.Path; + }); + + var rows = await source.ReadAsync(S5025ManualAudience.Individual); + + Assert.Equal( + S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable, + Assert.Single(requestedNames)); + Assert.Equal(5, rows.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("relative-data")] + public void Constructor_rejects_missing_or_non_absolute_directory(string? path) + { + Assert.Throws(() => + new S5025TrustedManualFileDataSource(path!)); + } + + [Fact] + public void Constructor_rejects_nonexistent_absolute_directory() + { + var missing = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString("N"), + "missing"); + + Assert.Throws(() => + new S5025TrustedManualFileDataSource(missing)); + } + + [Theory] + [InlineData(S5025ManualAudience.Individual)] + [InlineData(S5025ManualAudience.Foreign)] + [InlineData(S5025ManualAudience.Institution)] + public async Task ReadAsync_does_not_fall_back_when_fixed_file_is_missing( + S5025ManualAudience audience) + { + using var directory = new TemporaryDirectory(); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + await Assert.ThrowsAsync(() => + source.ReadAsync(audience)); + } + + [Fact] + public async Task ReadAsync_rejects_unknown_audience_before_any_file_lookup() + { + using var directory = new TemporaryDirectory(); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + await Assert.ThrowsAsync(() => + source.ReadAsync((S5025ManualAudience)99)); + } + + [Theory] + [InlineData( + "L1^1^R1^2\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2", + "four rows")] + [InlineData( + "L1^1^R1^2\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2\r\nL6^1^R6^2", + "six rows")] + [InlineData( + "L1^1^R1\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2", + "three columns")] + [InlineData( + "L1^1^R1^2^EXTRA\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2", + "five columns")] + public async Task ReadAsync_requires_exactly_five_rows_and_four_columns( + string content, + string _) + { + using var directory = new TemporaryDirectory(); + await File.WriteAllBytesAsync( + Path.Combine(directory.Path, "개인.dat"), + Cp949.GetBytes(content)); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + await Assert.ThrowsAsync(() => + source.ReadAsync(S5025ManualAudience.Individual)); + } + + [Fact] + public async Task ReadAsync_accepts_one_trailing_line_terminator() + { + using var directory = new TemporaryDirectory(); + WriteValidFile(Path.Combine(directory.Path, "개인.dat"), trailingNewLine: true); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + var rows = await source.ReadAsync(S5025ManualAudience.Individual); + + Assert.Equal(5, rows.Count); + } + + [Fact] + public async Task ReadAsync_accepts_the_original_single_empty_terminal_record() + { + using var directory = new TemporaryDirectory(); + var content = ValidContent() + "\r\n\r\n"; + await File.WriteAllBytesAsync( + Path.Combine(directory.Path, "개인.dat"), + Cp949.GetBytes(content)); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + var rows = await source.ReadAsync(S5025ManualAudience.Individual); + + Assert.Equal(5, rows.Count); + } + + [Fact] + public async Task ReadAsync_rejects_multiple_empty_terminal_records() + { + using var directory = new TemporaryDirectory(); + var content = ValidContent() + "\r\n\r\n\r\n"; + await File.WriteAllBytesAsync( + Path.Combine(directory.Path, "개인.dat"), + Cp949.GetBytes(content)); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + await Assert.ThrowsAsync(() => + source.ReadAsync(S5025ManualAudience.Individual)); + } + + [Fact] + public async Task ReadAsync_rejects_invalid_cp949_and_unicode_bom_files() + { + using var invalidDirectory = new TemporaryDirectory(); + await File.WriteAllBytesAsync( + Path.Combine(invalidDirectory.Path, "개인.dat"), + [0x81]); + var invalidSource = new S5025TrustedManualFileDataSource(invalidDirectory.Path); + await Assert.ThrowsAsync(() => + invalidSource.ReadAsync(S5025ManualAudience.Individual)); + + using var bomDirectory = new TemporaryDirectory(); + var content = ValidContent(); + await File.WriteAllBytesAsync( + Path.Combine(bomDirectory.Path, "개인.dat"), + Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(content)).ToArray()); + var bomSource = new S5025TrustedManualFileDataSource(bomDirectory.Path); + await Assert.ThrowsAsync(() => + bomSource.ReadAsync(S5025ManualAudience.Individual)); + } + + [Fact] + public async Task ReadAsync_rejects_oversized_file_before_decoding() + { + using var directory = new TemporaryDirectory(); + await File.WriteAllBytesAsync( + Path.Combine(directory.Path, "개인.dat"), + new byte[S5025TrustedManualFileDataSource.MaximumFileSizeBytes + 1]); + var source = new S5025TrustedManualFileDataSource(directory.Path); + + await Assert.ThrowsAsync(() => + source.ReadAsync(S5025ManualAudience.Individual)); + } + + [Fact] + public async Task ReadAsync_rejects_reparse_file_in_trusted_directory() + { + using var directory = new TemporaryDirectory(); + using var outside = new TemporaryDirectory(); + var target = Path.Combine(outside.Path, "outside.dat"); + WriteValidFile(target); + var link = Path.Combine(directory.Path, "개인.dat"); + if (!TryCreateFileSymbolicLink(link, target)) + { + return; + } + + var source = new S5025TrustedManualFileDataSource(directory.Path); + + await Assert.ThrowsAsync(() => + source.ReadAsync(S5025ManualAudience.Individual)); + } + + [Fact] + public void Constructor_rejects_reparse_directory_or_ancestor() + { + using var parent = new TemporaryDirectory(); + using var outside = new TemporaryDirectory(); + var link = Path.Combine(parent.Path, "linked-data"); + if (!TryCreateDirectorySymbolicLink(link, outside.Path)) + { + return; + } + + Assert.Throws(() => + new S5025TrustedManualFileDataSource(link)); + } + + [Fact] + public async Task ReadAsync_honors_pre_cancelled_token() + { + using var directory = new TemporaryDirectory(); + WriteValidFile(Path.Combine(directory.Path, "개인.dat")); + var source = new S5025TrustedManualFileDataSource(directory.Path); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + source.ReadAsync(S5025ManualAudience.Individual, cancellation.Token)); + } + + private static void WriteValidFile(string path, bool trailingNewLine = false) + { + var content = ValidContent() + (trailingNewLine ? "\r\n" : string.Empty); + File.WriteAllBytes(path, Cp949.GetBytes(content)); + } + + private static string ValidContent() => string.Join( + "\r\n", + "삼성전자^1,234^SK하이닉스^-987", + "현대차^2^기아^-2", + "NAVER^3^카카오^-3", + "LG화학^4^셀트리온^-4", + "POSCO홀딩스^5^한화에어로스페이스^-5"); + + private static Encoding CreateCp949Encoding() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + 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 static bool TryCreateDirectorySymbolicLink(string link, string target) + { + try + { + Directory.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 (IOException) + { + // A failed cleanup must not hide the behavior under test. + } + catch (UnauthorizedAccessException) + { + // A failed cleanup must not hide the behavior under test. + } + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs index 0c9070c..4401fa5 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs @@ -39,6 +39,7 @@ public sealed class DryRunPlayoutEngineTests await engine.PrepareAsync(cue, CancellationToken.None), await engine.TakeInAsync(CancellationToken.None), await engine.NextAsync(cue, CancellationToken.None), + await engine.UpdateOnAirAsync(cue, CancellationToken.None), await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None), @@ -72,6 +73,12 @@ public sealed class DryRunPlayoutEngineTests Assert.Null(engine.Status.PreparedSceneName); Assert.Equal("first-scene", engine.Status.OnAirSceneName); + var update = await engine.UpdateOnAirAsync( + Cue("first-scene"), + CancellationToken.None); + Assert.Null(engine.Status.PreparedSceneName); + Assert.Equal("first-scene", engine.Status.OnAirSceneName); + var next = await engine.NextAsync( Cue("next-scene"), CancellationToken.None); @@ -86,12 +93,12 @@ public sealed class DryRunPlayoutEngineTests var disconnect = await engine.DisconnectAsync(CancellationToken.None); Assert.All( - new[] { connect, prepare, takeIn, next, takeOut, disconnect }, + new[] { connect, prepare, takeIn, update, next, takeOut, disconnect }, result => Assert.Equal(PlayoutResultCode.Success, result.Code)); Assert.All( - new[] { connect, prepare, takeIn, next, takeOut, disconnect }, + new[] { connect, prepare, takeIn, update, next, takeOut, disconnect }, result => Assert.True(result.IsDryRun)); - Assert.Equal(6, statuses.Count); + Assert.Equal(7, statuses.Count); Assert.True(statuses.Zip(statuses.Skip(1), (left, right) => left.Sequence < right.Sequence).All(x => x)); Assert.All(statuses, status => Assert.Equal(PlayoutConnectionState.DryRunReady, status.State)); } diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dEventHandlerTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dEventHandlerTests.cs new file mode 100644 index 0000000..e13978e --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dEventHandlerTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using MBN_STOCK_WEBVIEW.Playout.Interop; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class DynamicK3dEventHandlerTests +{ + [Fact] + public void GeneratedHandler_ImplementsImportedInterfaceAndForwardsOnlyLifecycleCallbacks() + { + var sink = new RecordingSink(); + var value = DynamicK3dEventHandlerBuilder.Create(typeof(IFakeK3dEventHandler), sink); + var handler = Assert.IsAssignableFrom(value); + + handler.OnHello(); + handler.OnScenePlayed(1, 9, 10); + handler.OnCutOut(0, 9, 10); + handler.OnStopAll(1); + handler.OnIgnored("sensitive value"); + + Assert.Equal( + new[] + { + "OnHello:", + "OnScenePlayed:1,9,10", + "OnCutOut:0,9,10", + "OnStopAll:1" + }, + sink.Calls); + Assert.Equal( + ClassInterfaceType.None, + value.GetType().GetCustomAttributes(typeof(ClassInterfaceAttribute), inherit: false) + .Cast() + .Single() + .Value); + } + + [Fact] + public void EventQueue_ParsesCallbacksWithoutThrowingAcrossNativeBoundary() + { + var queue = new K3dEventQueue(capacity: 4); + + queue.OnCallback("OnHello", []); + queue.OnCallback("OnScenePlayed", [1, 9, 10]); + queue.OnCallback("OnCutOut", [0, 9, 10]); + queue.OnCallback("OnStopAll", [1]); + queue.OnCallback("OnScenePlayed", ["invalid"]); + + Assert.True(queue.HelloObserved); + Assert.False(queue.HasOverflowed); + Assert.Equal(3, queue.PendingCount); + Assert.True(queue.TryDequeue(out var played)); + Assert.Equal( + new K3dLifecycleCallback(K3dLifecycleCallbackKind.ScenePlayed, true, 9, 10), + played); + Assert.True(queue.TryDequeue(out var cut)); + Assert.False(cut.IsSuccess); + Assert.True(queue.TryDequeue(out var stopped)); + Assert.Equal(K3dLifecycleCallbackKind.StopAll, stopped.Kind); + Assert.False(queue.TryDequeue(out _)); + } + + [Fact] + public void EventQueue_OverflowLatchesAndNeverExceedsCapacity() + { + var queue = new K3dEventQueue(capacity: 1); + + queue.OnCallback("OnStopAll", [1]); + queue.OnCallback("OnStopAll", [1]); + + Assert.True(queue.HasOverflowed); + Assert.Equal(1, queue.PendingCount); + Assert.True(queue.TryDequeue(out _)); + Assert.False(queue.TryDequeue(out _)); + } + + private sealed class RecordingSink : IK3dEventSink + { + private readonly ConcurrentQueue _calls = new(); + + public string[] Calls => _calls.ToArray(); + + public void OnCallback(string methodName, object?[] arguments) => + _calls.Enqueue($"{methodName}:{string.Join(',', arguments)}"); + } + + [ComImport] + [Guid("C21817DB-C0D3-4647-8D85-027338DCF832")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IFakeK3dEventHandler + { + void OnHello(); + + void OnScenePlayed(int success, int outputChannelIndex, int layerIndex); + + void OnCutOut(int success, int outputChannelIndex, int layerIndex); + + void OnStopAll(int success); + + void OnIgnored(string value); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index ec2217f..117679c 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; @@ -331,8 +332,8 @@ public sealed class DynamicK3dSessionTests "SetVisible:headline:1", "GetObject:badge", "SetVisible:badge:0", - "EndTransactionOnChannel:9", "QueryVariables", + "EndTransactionOnChannel:9", "Prepare:10", "Play:10", expectedTakeOutCall, @@ -343,6 +344,495 @@ public sealed class DynamicK3dSessionTests Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); } + [Fact] + public async Task Prepare_AppliesEveryLegacyMutationInOriginalTransactionOrder() + { + using var scenes = TemporarySceneDirectory.Create( + "test-scene.t2s", + "Images\\badge.png", + "Video\\background.vrv"); + var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var cue = options.ResolveCue(new PlayoutCue( + "test-scene.t2s", + "test-scene", + FadeDuration: 4, + Mutations: + [ + new PlayoutUseBackground(true), + new PlayoutSetBackgroundTexture("Video/background.vrv"), + new PlayoutSetBackgroundVideo("Video/background.vrv", 2, true), + new PlayoutSetValue("headline", "value"), + new PlayoutSetBackgroundVideo( + "Video/background.vrv", + 3, + true, + PlayoutMutationTiming.InTransaction), + new PlayoutSetAssetValue("badge", "Images/badge.png"), + new PlayoutSetVisible("badge", false), + new PlayoutSetFaceColor("headline", 1, 2, 3, 4), + new PlayoutSetPosition("position", 1, 2, 3, PlayoutVectorComponents.XYZ), + new PlayoutSetPositionKey("position", 5, 6, 7, 8, PlayoutVectorComponents.Y), + new PlayoutSetScale("scale", 9, 10, 11, PlayoutVectorComponents.X), + new PlayoutSetCropKey("crop", 1, 12, 13, 14, 15, PlayoutCropEdges.Vertical), + new PlayoutSetCircleAngleKey("circle", 2, 16, 17, PlayoutAngleComponents.All), + new PlayoutSetPathPoints( + "path", + [new PlayoutPoint(18, 19, 20), new PlayoutPoint(21, 22, 23)]), + new PlayoutSetPathShapePoints( + "shape", + [new PlayoutPoint(24, 25, 26)]) + ])); + var log = new FakeComLog(); + var scene = new FakeScene(log); + var activator = new FakeActivator( + log, + new FakeEngine(log, new FakePlayer(log), scene)); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator); + session.Connect(options); + session.Prepare(cue, options.LayoutIndex); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal( + new[] + { + "LoadScene:test-scene", + "SetOutputChannelIndex:9", + "SetSceneEffectType:1:7:4", + "UseBackground:1", + "SetBackgroundTexture:background.vrv", + "SetBackgroundVideo:background.vrv:2:1", + "BeginTransaction", + "GetObject:headline", + "SetValue:headline:value", + "SetBackgroundVideo:background.vrv:3:1", + "GetObject:badge", + "SetValue:badge:badge.png", + "GetObject:badge", + "SetVisible:badge:0", + "GetObject:headline", + "SetFaceColor:headline:1:2:3:4", + "GetObject:position", + "SetPosition:position:1:2:3:7", + "GetObject:position", + "SetPositionKey:position:5:6:7:8:2", + "GetObject:scale", + "SetScale:scale:9:10:11:1", + "GetObject:crop", + "SetCropKey:crop:1:12:13:14:15:10", + "GetObject:circle", + "SetCircleAngleKey:circle:2:16:17:3", + "GetObject:path", + "BeginPathPoint:path", + "ClearPathPoints:path", + "AddPathPoint:path:18:19:20", + "AddPathPoint:path:21:22:23", + "EndPathPoint:path", + "GetObject:shape", + "BeginPathShapePoint:shape", + "ClearPathShapePoints:shape", + "AddPathShapePoint:shape:24:25:26", + "EndPathShapePoint:shape", + "QueryVariables", + "EndTransactionOnChannel:9", + "Prepare:10" + }, + log.Names.Where(name => + name.StartsWith("LoadScene:", StringComparison.Ordinal) || + name.StartsWith("Set", StringComparison.Ordinal) || + name.StartsWith("Use", StringComparison.Ordinal) || + name.StartsWith("Begin", StringComparison.Ordinal) || + name.StartsWith("Clear", StringComparison.Ordinal) || + name.StartsWith("Add", StringComparison.Ordinal) || + name.StartsWith("End", StringComparison.Ordinal) || + name.StartsWith("GetObject:", StringComparison.Ordinal) || + name == "QueryVariables" || name == "Prepare:10")); + } + + [Fact] + public async Task UpdateOnAir_UsesPlayingSceneWithoutReloadingOrReapplyingSceneSetup() + { + using var scenes = TemporarySceneDirectory.Create( + "test-scene.t2s", + "Video\\background.vrv"); + var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var initialCue = SceneCue(options, "test-scene"); + var updateCue = options.ResolveCue(new PlayoutCue( + "test-scene.t2s", + "test-scene", + [new PlayoutField("headline", "page two", true)], + FadeDuration: 59, + Mutations: + [ + new PlayoutUseBackground(false), + new PlayoutSetBackgroundTexture("Video/background.vrv"), + new PlayoutSetBackgroundVideo("Video/background.vrv", 4, true), + new PlayoutSetVisible("badge", false) + ])); + var log = new FakeComLog(); + var scene = new FakeScene(log, "test-scene"); + var player = new FakePlayer(log) { PlayingScene = scene }; + var activator = new FakeActivator(log, new FakeEngine(log, player, scene)); + await using var dispatcher = new StaDispatcher(capacity: 2); + var updateStart = 0; + var updateEnd = 0; + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator); + session.Connect(options); + session.Prepare(initialCue, options.LayoutIndex); + session.Play(options.LayoutIndex); + + updateStart = log.Names.Length; + session.UpdateOnAir(updateCue, options.LayoutIndex); + updateEnd = log.Names.Length; + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + var updateCalls = log.Names + .Skip(updateStart) + .Take(updateEnd - updateStart) + .ToArray(); + Assert.Equal( + new[] + { + "Play:10", + "GetPlayingScene:10", + "BeginTransaction", + "GetObject:headline", + "SetValue:headline:page two", + "SetVisible:headline:1", + "GetObject:badge", + "SetVisible:badge:0", + "QueryVariables", + "EndTransactionOnChannel:9", + "Prepare:10", + "Play:10" + }, + updateCalls); + Assert.DoesNotContain(updateCalls, call => + call.StartsWith("LoadScene:", StringComparison.Ordinal) || + call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal) || + call.StartsWith("UseBackground:", StringComparison.Ordinal) || + call.StartsWith("SetBackgroundTexture:", StringComparison.Ordinal) || + call.StartsWith("SetBackgroundVideo:", StringComparison.Ordinal)); + } + + [Fact] + public async Task UpdateOnAir_CallbackSession_TracksItsPlayUntilMatchingCallback() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var cue = SceneCue(options, "5001"); + var log = new FakeComLog(); + var scene = new FakeScene(log, "5001"); + var player = new FakePlayer(log) { PlayingScene = scene }; + var engine = new FakeEngine(log, player, scene); + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + session.Prepare(cue, options.LayoutIndex); + session.Play(options.LayoutIndex); + callbacks.Fire("OnScenePlayed", 1, 8, 10); + session.ProcessPendingCallbacks(options.LayoutIndex); + Assert.True(session.HasPendingLifecycleCallbacks); + + callbacks.Fire("OnScenePlayed", 1, 9, 10); + session.ProcessPendingCallbacks(options.LayoutIndex); + Assert.False(session.HasPendingLifecycleCallbacks); + + session.UpdateOnAir(cue, options.LayoutIndex); + Assert.True(session.HasPendingLifecycleCallbacks); + + callbacks.Fire("OnScenePlayed", 1, 8, 10); + session.ProcessPendingCallbacks(options.LayoutIndex); + Assert.True(session.HasPendingLifecycleCallbacks); + + callbacks.Fire("OnScenePlayed", 1, 9, 10); + session.ProcessPendingCallbacks(options.LayoutIndex); + Assert.True(session.HasPendingLifecycleCallbacks); + + callbacks.Fire("OnScenePlayed", 1, 9, 10); + session.ProcessPendingCallbacks(options.LayoutIndex); + Assert.False(session.HasPendingLifecycleCallbacks); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal(3, log.Names.Count(name => name == "Play:10")); + Assert.Equal(1, log.Names.Count(name => name == "GetPlayingScene:10")); + Assert.DoesNotContain("Unload:5001", log.Names); + } + + [Fact] + public async Task LifecycleCallbacks_UnloadOnlyRetiredScenesAfterMatchingSuccess() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var options = SceneOptions(scenes.Path, "5001", "5006"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var scene5001 = new FakeScene(log, "5001"); + var scene5006 = new FakeScene(log, "5006"); + var engine = new FakeEngine(log, player, scene5001) + { + SceneResolver = name => name == "5006" ? scene5006 : scene5001 + }; + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (scene5001, "5001"), + (scene5006, "5006"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 3); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + Assert.True(session.SupportsLifecycleCallbacks); + Assert.False(session.KtapHelloObserved); + + callbacks.Fire("OnHello"); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + callbacks.Fire("OnScenePlayed", 1, 9, 10); + var first = session.ProcessPendingCallbacks(options.LayoutIndex); + Assert.True(first.HelloObserved); + Assert.Equal(0, first.UnloadedSceneCount); + + session.Prepare(SceneCue(options, "5006"), options.LayoutIndex); + session.Play(options.LayoutIndex); + Assert.DoesNotContain("Unload:5001", log.Names); + + callbacks.Fire("OnScenePlayed", 1, 8, 10); + Assert.Equal( + 0, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.DoesNotContain("Unload:5001", log.Names); + + callbacks.Fire("OnScenePlayed", 1, 9, 10); + Assert.Equal( + 1, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.Contains("Unload:5001", log.Names); + Assert.Contains("Release:5001", log.Names); + + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + Assert.True(session.HasPendingLifecycleCallbacks); + callbacks.Fire("OnScenePlayed", 1, 9, 10); + Assert.Equal( + 0, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.DoesNotContain("Unload:5006", log.Names); + + callbacks.Fire("OnStopAll", 1); + Assert.Equal( + 1, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.Contains("Unload:5006", log.Names); + Assert.Contains("Release:5006", log.Names); + Assert.False(session.HasPendingLifecycleCallbacks); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); + Assert.Equal(1, log.Names.Count(name => name == "Unload:5006")); + } + + [Fact] + public async Task StopAllSuccess_ClearsInterruptedPlayBeforeDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var log = new FakeComLog(); + var scene = new FakeScene(log, "5001"); + var player = new FakePlayer(log); + var engine = new FakeEngine(log, player, scene); + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + Assert.True(session.HasPendingPlayCallbacks); + + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + callbacks.Fire("OnStopAll", 1); + var drained = session.ProcessPendingCallbacks(options.LayoutIndex); + + Assert.Equal(1, drained.UnloadedSceneCount); + Assert.False(session.HasPendingPlayCallbacks); + Assert.False(session.HasPendingLifecycleCallbacks); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Contains("StopAll", log.Names); + Assert.Contains("Unload:5001", log.Names); + Assert.Contains("Disconnect", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); + } + + [Fact] + public async Task CutOutSuccess_ClearsOnlyAfterMatchingOutputBeforeDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var log = new FakeComLog(); + var scene = new FakeScene(log, "5001"); + var player = new FakePlayer(log); + var engine = new FakeEngine(log, player, scene); + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.Layout); + + callbacks.Fire("OnCutOut", 1, 8, options.LayoutIndex); + Assert.Equal( + 0, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.True(session.HasPendingPlayCallbacks); + Assert.True(session.HasPendingLifecycleCallbacks); + Assert.DoesNotContain("Unload:5001", log.Names); + + callbacks.Fire("OnCutOut", 1, 9, options.LayoutIndex); + Assert.Equal( + 1, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.False(session.HasPendingPlayCallbacks); + Assert.False(session.HasPendingLifecycleCallbacks); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Contains("CutOut:10", log.Names); + Assert.Contains("Unload:5001", log.Names); + Assert.Contains("Disconnect", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); + } + + [Fact] + public async Task LifecycleCallbackFailure_DoesNotUnloadAndFailsClosed() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var scene = new FakeScene(log, "5001"); + var engine = new FakeEngine(log, player, scene); + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + callbacks.Fire("OnStopAll", 0); + + Assert.Throws( + () => session.ProcessPendingCallbacks(options.LayoutIndex)); + Assert.DoesNotContain("Unload:5001", log.Names); + session.Abandon(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + } + [Fact] public async Task Prepare_WithoutOutputChannel_UsesDefaultPlayerAndDoesNotSetSceneChannel() { @@ -385,8 +875,8 @@ public sealed class DynamicK3dSessionTests "LoadScene:test-scene", "SetSceneEffectType:1:7:12", "BeginTransaction", - "EndTransaction", "QueryVariables", + "EndTransaction", "Prepare:10", "Disconnect" }, @@ -566,6 +1056,7 @@ public sealed class DynamicK3dSessionTests "Prepare:10", "Play:10", "LoadScene:5006", + "RollbackTransaction", "StopAll", "Disconnect", "Release:5006", @@ -853,6 +1344,25 @@ public sealed class DynamicK3dSessionTests public object Create(Guid classId) => throw _exception; } + private sealed class RecordingEventHandlerFactory : IK3dEventHandlerFactory + { + private IK3dEventSink? _sink; + + public bool SupportsCallbacks => true; + + public object Handler { get; } = new(); + + public object Create(IK3dEventSink sink) + { + _sink = sink; + return Handler; + } + + public void Fire(string methodName, params object?[] arguments) => + (_sink ?? throw new InvalidOperationException("Callback sink is not connected.")) + .OnCallback(methodName, arguments); + } + private sealed class BlockingEngineActivator : ILateBoundComActivator { private readonly FakeComLog _log; @@ -1022,6 +1532,15 @@ public sealed class DynamicK3dSessionTests _playFailure = playFailure; } + public object? PlayingScene { get; set; } + + public object GetPlayingScene(int layoutIndex) + { + _log.Add($"GetPlayingScene:{layoutIndex}"); + return PlayingScene + ?? throw new InvalidOperationException("No fake playing scene is configured."); + } + public void Prepare(int layoutIndex, object scene) { Assert.NotNull(scene); @@ -1067,6 +1586,14 @@ public sealed class DynamicK3dSessionTests public void SetSceneEffectType(int inEffect, int effectType, int duration) => _log.Add($"SetSceneEffectType:{inEffect}:{effectType}:{duration}"); + public void UseBackground(int enabled) => _log.Add($"UseBackground:{enabled}"); + + public void SetBackgroundTexture(string path) => + _log.Add($"SetBackgroundTexture:{System.IO.Path.GetFileName(path)}"); + + public void SetBackgroundVideo(string path, int loopCount, int loopInfinite) => + _log.Add($"SetBackgroundVideo:{System.IO.Path.GetFileName(path)}:{loopCount}:{loopInfinite}"); + public object GetObject(string objectName) { _log.Add($"GetObject:{objectName}"); @@ -1103,8 +1630,58 @@ public sealed class DynamicK3dSessionTests _name = name; } - public void SetValue(string value) => _log.Add($"SetValue:{_name}:{value}"); + public void SetValue(string value) => _log.Add( + $"SetValue:{_name}:{(System.IO.Path.IsPathFullyQualified(value) ? System.IO.Path.GetFileName(value) : value)}"); public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}"); + + public void SetFaceColor(int red, int green, int blue, int alpha) => + _log.Add($"SetFaceColor:{_name}:{red}:{green}:{blue}:{alpha}"); + + public void SetPosition(float x, float y, float z, int components) => + _log.Add(FormattableString.Invariant($"SetPosition:{_name}:{x}:{y}:{z}:{components}")); + + public void SetPositionKey(int keyIndex, float x, float y, float z, int components) => + _log.Add(FormattableString.Invariant( + $"SetPositionKey:{_name}:{keyIndex}:{x}:{y}:{z}:{components}")); + + public void SetScale(float x, float y, float z, int components) => + _log.Add(FormattableString.Invariant($"SetScale:{_name}:{x}:{y}:{z}:{components}")); + + public void SetCropKey( + int keyIndex, + float left, + float top, + float right, + float bottom, + int edges) => + _log.Add(FormattableString.Invariant( + $"SetCropKey:{_name}:{keyIndex}:{left}:{top}:{right}:{bottom}:{edges}")); + + public void SetCircleAngleKey( + int keyIndex, + float start, + float end, + int components) => + _log.Add(FormattableString.Invariant( + $"SetCircleAngleKey:{_name}:{keyIndex}:{start}:{end}:{components}")); + + public void BeginPathPoint() => _log.Add($"BeginPathPoint:{_name}"); + + public void ClearPathPoints() => _log.Add($"ClearPathPoints:{_name}"); + + public void AddPathPoint(float x, float y, float z) => + _log.Add(FormattableString.Invariant($"AddPathPoint:{_name}:{x}:{y}:{z}")); + + public void EndPathPoint() => _log.Add($"EndPathPoint:{_name}"); + + public void BeginPathShapePoint() => _log.Add($"BeginPathShapePoint:{_name}"); + + public void ClearPathShapePoints() => _log.Add($"ClearPathShapePoints:{_name}"); + + public void AddPathShapePoint(float x, float y, float z) => + _log.Add(FormattableString.Invariant($"AddPathShapePoint:{_name}:{x}:{y}:{z}")); + + public void EndPathShapePoint() => _log.Add($"EndPathShapePoint:{_name}"); } } diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs index 6fb9234..00725d7 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs @@ -550,6 +550,11 @@ public sealed class IsolatedTestCommandTests : IDisposable CancellationToken cancellationToken = default) => Result(PlayoutOperation.Next, "Next"); + public Task UpdateOnAirAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) => + Result(PlayoutOperation.UpdateOnAir, "UpdateOnAir"); + public Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default) => diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmCutsSequenceRunnerTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmCutsSequenceRunnerTests.cs index e8a1813..5798aaa 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmCutsSequenceRunnerTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmCutsSequenceRunnerTests.cs @@ -93,6 +93,7 @@ public sealed class PgmCutsSequenceRunnerTests Assert.Equal(30_001, plan.Options.Port); Assert.Null(plan.Options.OutputChannel); Assert.Equal(10, plan.Options.LayoutIndex); + Assert.Equal(["5001", "5006"], plan.Options.TestSceneAllowlist); Assert.True(plan.Options.TrustedLiveOutputEnabled); Assert.False(plan.Options.ReconnectEnabled); Assert.Equal(0, plan.Options.MaximumReconnectAttempts); @@ -825,6 +826,19 @@ public sealed class PgmCutsSequenceRunnerTests return Result(PgmCutsSequenceStage.Next, PlayoutOperation.Next); } + public Task UpdateOnAirAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) + { + _events.Enqueue($"UpdateOnAir:{cue.SceneName}"); + return Task.FromResult(new PlayoutResult( + PlayoutOperation.UpdateOnAir, + PlayoutResultCode.Success, + false, + "fake", + DateTimeOffset.UnixEpoch)); + } + public Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default) diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs index c3f1d93..825edf4 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs @@ -53,6 +53,16 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession public PlayoutKtapConnectState LastKtapConnectState { get; private set; } + public bool SupportsLifecycleCallbacks { get; set; } + + public bool? KtapHelloObserved { get; set; } + + public bool HasPendingLifecycleCallbacks { get; set; } + + public bool HasPendingPlayCallbacks { get; set; } + + public bool ClearPendingLifecycleCallbacksAfterProcess { get; set; } = true; + public ConcurrentQueue Calls { get; } = new(); public ConcurrentQueue ThreadIds { get; } = new(); @@ -69,10 +79,14 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession public Action? PrepareAction { get; set; } + public Action? UpdateOnAirAction { get; set; } + public Action? PlayAction { get; set; } public Action? TakeOutAction { get; set; } + public Func? ProcessPendingCallbacksAction { get; set; } + public Action? AbandonAction { get; set; } public Action? CallObserver { get; set; } @@ -155,6 +169,12 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession PrepareAction?.Invoke(cue, layoutIndex); } + public void UpdateOnAir(PlayoutCue cue, int layoutIndex) + { + Record($"UpdateOnAir:{cue.SceneName}"); + UpdateOnAirAction?.Invoke(cue, layoutIndex); + } + public void Play(int layoutIndex) { Record("Play"); @@ -167,6 +187,20 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession TakeOutAction?.Invoke(layoutIndex, scope); } + public K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex) + { + Record("ProcessPendingCallbacks"); + var result = ProcessPendingCallbacksAction?.Invoke(layoutIndex) + ?? new K3dCallbackDrainResult(0, 0, KtapHelloObserved == true); + if (ClearPendingLifecycleCallbacksAfterProcess) + { + HasPendingLifecycleCallbacks = false; + HasPendingPlayCallbacks = false; + } + + return result; + } + public void Abandon() { TryPreventKtapConnect(); @@ -268,7 +302,7 @@ internal sealed class FakeLiveAuthorization : ILiveAuthorization IsAuthorizedForThisLaunch = isAuthorizedForThisLaunch; } - public bool IsAuthorizedForThisLaunch { get; } + public bool IsAuthorizedForThisLaunch { get; set; } } internal sealed class TrackingStaDispatcherFactory : IStaDispatcherFactory diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs index a563f1b..88acce1 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs @@ -1,3 +1,5 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class PlayoutOptionsLoaderTests @@ -11,6 +13,11 @@ public sealed class PlayoutOptionsLoaderTests "MBN_STOCK_PLAYOUT_CLIENT_PORT", "MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "MBN_STOCK_PLAYOUT_LAYOUT_INDEX", + "MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION", + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", "MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", @@ -41,6 +48,11 @@ public sealed class PlayoutOptionsLoaderTests Assert.Equal(0, options.ClientPort); Assert.Null(options.OutputChannel); Assert.Null(options.SceneDirectory); + Assert.Equal(6, options.LegacySceneFadeDuration); + Assert.Equal(LegacySceneBackgroundKind.None, options.LegacySceneBackgroundKind); + Assert.Null(options.LegacySceneBackgroundAssetPath); + Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount); + Assert.True(options.LegacySceneBackgroundVideoLoopInfinite); Assert.Empty(options.TestSceneAllowlist); Assert.False(options.TrustedLiveOutputEnabled); Assert.True(options.ReconnectEnabled); @@ -57,6 +69,11 @@ public sealed class PlayoutOptionsLoaderTests .Set("MBN_STOCK_PLAYOUT_CLIENT_PORT", "32002") .Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "12") .Set("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", "13") + .Set("MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION", "9") + .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "Video") + .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", "Video\\studio.vrv") + .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", "2004") + .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", "false") .Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\env-scenes") .Set("MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "ENV TEST") .Set("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "14") @@ -103,6 +120,11 @@ public sealed class PlayoutOptionsLoaderTests Assert.Equal(32002, options.ClientPort); Assert.Equal(12, options.OutputChannel); Assert.Equal(13, options.LayoutIndex); + Assert.Equal(9, options.LegacySceneFadeDuration); + Assert.Equal(LegacySceneBackgroundKind.Video, options.LegacySceneBackgroundKind); + Assert.Equal("Video\\studio.vrv", options.LegacySceneBackgroundAssetPath); + Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount); + Assert.False(options.LegacySceneBackgroundVideoLoopInfinite); Assert.Equal("C:\\env-scenes", options.SceneDirectory); Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern); Assert.Equal(14, options.QueueCapacity); @@ -153,6 +175,7 @@ public sealed class PlayoutOptionsLoaderTests [Theory] [InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")] + [InlineData("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "not-a-background")] [InlineData("MBN_STOCK_PLAYOUT_PORT", "not-a-number")] [InlineData("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "not-a-bool")] public void Load_InvalidEnvironmentValue_IdentifiesVariableWithoutLeakingValue( diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs index b41ee44..e7960cd 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs @@ -6,7 +6,7 @@ namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class PlayoutSafetyValidationTests : IDisposable { private readonly TemporarySceneDirectory _scenes = - TemporarySceneDirectory.Create("test-scene.t2s"); + TemporarySceneDirectory.Create("test-scene.t2s", "Video\\background.vrv"); [Theory] [InlineData("Tornado2", true)] @@ -94,6 +94,24 @@ public sealed class PlayoutSafetyValidationTests : IDisposable Assert.Contains(nameof(PlayoutOptions.TestSceneAllowlist), exception.Message, StringComparison.Ordinal); } + [Fact] + public void Validate_LiveModeRequiresClosedSceneAllowlist() + { + var options = ValidTestOptions(); + options.Mode = PlayoutMode.Live; + options.OutputChannel = null; + options.TestProcessWindowTitlePattern = null; + options.TestSceneAllowlist = []; + + var exception = Assert.Throws( + () => ValidatedPlayoutOptions.Create(options)); + + Assert.Contains( + nameof(PlayoutOptions.TestSceneAllowlist), + exception.Message, + StringComparison.Ordinal); + } + [Theory] [InlineData("localhost")] [InlineData("test-tornado.local")] @@ -202,6 +220,65 @@ public sealed class PlayoutSafetyValidationTests : IDisposable Assert.DoesNotContain(rootedPath, exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void ResolveCue_CanonicalizesAllowlistedBackgroundTextureUnderSceneRoot() + { + var validated = ValidatedPlayoutOptions.Create(ValidTestOptions()); + + var resolved = validated.ResolveCue(new PlayoutCue( + "test-scene.t2s", + "test-scene", + Mutations: [new PlayoutSetBackgroundTexture("Video/background.vrv")])); + + var texture = Assert.IsType(Assert.Single(resolved.Mutations!)); + Assert.Equal( + Path.Combine(_scenes.Path, "Video", "background.vrv"), + texture.AssetPath, + ignoreCase: true); + } + + [Fact] + public void ResolveCue_AcceptsTheLegacy2004BackgroundVideoLoopValue() + { + var validated = ValidatedPlayoutOptions.Create(ValidTestOptions()); + + var resolved = validated.ResolveCue(new PlayoutCue( + "test-scene.t2s", + "test-scene", + Mutations: + [ + new PlayoutSetBackgroundVideo( + "Video/background.vrv", + LoopCount: 2004, + LoopInfinite: true) + ])); + + var video = Assert.IsType(Assert.Single(resolved.Mutations!)); + Assert.Equal(2004, video.LoopCount); + Assert.Equal( + Path.Combine(_scenes.Path, "Video", "background.vrv"), + video.AssetPath, + ignoreCase: true); + } + + [Theory] + [InlineData("../outside.vrv")] + [InlineData("Video/missing.vrv")] + [InlineData("Video/background.exe")] + public void ResolveCue_RejectsUnsafeBackgroundTextureWithoutLeakingInput(string assetPath) + { + var validated = ValidatedPlayoutOptions.Create(ValidTestOptions()); + + var exception = Assert.Throws(() => + validated.ResolveCue(new PlayoutCue( + "test-scene.t2s", + "test-scene", + Mutations: [new PlayoutSetBackgroundTexture(assetPath)]))); + + Assert.DoesNotContain(assetPath, exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Theory] [InlineData(0, 5000, 64, nameof(PlayoutOptions.Port))] [InlineData(30001, 99, 64, nameof(PlayoutOptions.OperationTimeoutMilliseconds))] diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs new file mode 100644 index 0000000..04a98c4 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs @@ -0,0 +1,53 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class PlayoutSceneCompositionFactoryTests +{ + [Fact] + public void Default_MatchesLegacyFadeAndRequiresNoExternalAsset() + { + var result = PlayoutSceneCompositionFactory.Create(new PlayoutOptions()); + + Assert.Equal(6, result.FadeDuration); + Assert.Equal(LegacySceneBackgroundKind.None, result.BackgroundKind); + Assert.Null(result.BackgroundAssetPath); + } + + [Fact] + public void TrustedRelativeVideo_IsVerifiedEvenInDryRun() + { + using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv"); + var result = PlayoutSceneCompositionFactory.Create(new PlayoutOptions + { + Mode = PlayoutMode.DryRun, + SceneDirectory = scenes.Path, + LegacySceneBackgroundKind = LegacySceneBackgroundKind.Video, + LegacySceneBackgroundAssetPath = "Video\\studio.vrv" + }); + + Assert.Equal(LegacySceneBackgroundKind.Video, result.BackgroundKind); + Assert.Equal(Path.Combine("Video", "studio.vrv"), result.BackgroundAssetPath); + Assert.Equal(2004, result.BackgroundVideoLoopCount); + } + + [Theory] + [InlineData("Video:studio.vrv")] + [InlineData("..\\outside.vrv")] + [InlineData("Video\\missing.vrv")] + [InlineData("Video\\studio.exe")] + public void InvalidOrMissingBackground_FailsBeforeDryRunPrepare(string relativePath) + { + using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv"); + var options = new PlayoutOptions + { + Mode = PlayoutMode.DryRun, + SceneDirectory = scenes.Path, + LegacySceneBackgroundKind = LegacySceneBackgroundKind.Video, + LegacySceneBackgroundAssetPath = relativePath + }; + + Assert.Throws(() => + PlayoutSceneCompositionFactory.Create(options)); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs index e3b4249..e69237f 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; namespace MBN_STOCK_WEBVIEW.Playout.Tests; @@ -1041,6 +1042,116 @@ public sealed class TornadoPlayoutEngineTests Assert.Null(engine.Status.OnAirSceneName); } + [Fact] + public async Task UpdateOnAir_TestMode_UsesSerializedSessionPathAndKeepsSceneOnAir() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession(); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + + var result = await engine.UpdateOnAirAsync( + Cue("test-scene") with + { + Fields = [new PlayoutField("headline", "page two", true)] + }, + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(PlayoutOperation.UpdateOnAir, result.Operation); + Assert.False(result.IsDryRun); + Assert.Equal("test-scene", engine.Status.OnAirSceneName); + Assert.Null(engine.Status.PreparedSceneName); + Assert.Equal( + new[] + { + "Connect", + "Prepare:test-scene", + "Play", + "UpdateOnAir:test-scene" + }, + session.Calls); + Assert.Single(session.ThreadIds.Distinct()); + Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); + } + + [Fact] + public async Task UpdateOnAir_RejectsIdleOrDifferentSceneBeforeSessionDispatch() + { + using var scenes = TemporarySceneDirectory.Create("first.t2s", "other.t2s"); + var session = new FakeK3dSession(); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "first", "other"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + var idle = await engine.UpdateOnAirAsync(Cue("first"), CancellationToken.None); + Assert.True((await engine.PrepareAsync(Cue("first"), CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + var different = await engine.UpdateOnAirAsync( + Cue("other"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, idle.Code); + Assert.Equal(PlayoutResultCode.Rejected, different.Code); + Assert.Equal("first", engine.Status.OnAirSceneName); + Assert.DoesNotContain(session.Calls, call => + call.StartsWith("UpdateOnAir:", StringComparison.Ordinal)); + } + + [Fact] + public async Task UpdateOnAir_AmbiguousFailureLatchesOutcomeUnknownAndIsNeverRetried() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + UpdateOnAirAction = (_, _) => + throw new COMException("fake ambiguous on-air update failure") + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + + var failed = await engine.UpdateOnAirAsync( + Cue("test-scene"), + CancellationToken.None); + var later = await engine.UpdateOnAirAsync( + Cue("test-scene"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code); + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.Equal( + 1, + session.Calls.Count(call => call == "UpdateOnAir:test-scene")); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + [Fact] public async Task UnchangedProcessGeneration_DoesNotReconnectAnExistingSession() { @@ -1248,6 +1359,7 @@ public sealed class TornadoPlayoutEngineTests await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None), await engine.TakeInAsync(CancellationToken.None), await engine.NextAsync(Cue("live-scene"), CancellationToken.None), + await engine.UpdateOnAirAsync(Cue("live-scene"), CancellationToken.None), await engine.TakeOutAsync(PlayoutTakeOutScope.All, CancellationToken.None) }; @@ -1272,12 +1384,77 @@ public sealed class TornadoPlayoutEngineTests var connect = await engine.ConnectAsync(CancellationToken.None); var prepare = await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None); var takeIn = await engine.TakeInAsync(CancellationToken.None); + var update = await engine.UpdateOnAirAsync( + Cue("live-scene"), + CancellationToken.None); Assert.True(connect.IsSuccess); Assert.True(prepare.IsSuccess); Assert.True(takeIn.IsSuccess); + Assert.True(update.IsSuccess); Assert.True(engine.Status.LiveTakeInAllowed); Assert.Contains("Play", session.Calls); + Assert.Contains("UpdateOnAir:live-scene", session.Calls); + } + + [Fact] + public async Task LiveWithBothAuthorizations_RejectsSceneOutsideClosedAllowlist() + { + using var scenes = TemporarySceneDirectory.Create( + "live-scene.t2s", + "blocked-scene.t2s"); + var session = new FakeK3dSession(); + await using var engine = CreateEngine( + LiveOptions(scenes.Path, trustedLiveOutputEnabled: true), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: true); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + + var prepare = await engine.PrepareAsync( + Cue("blocked-scene"), + CancellationToken.None); + var next = await engine.NextAsync( + Cue("blocked-scene"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, prepare.Code); + Assert.Equal(PlayoutResultCode.Rejected, next.Code); + Assert.DoesNotContain( + session.Calls, + call => call.Contains("blocked-scene", StringComparison.Ordinal)); + } + + [Fact] + public async Task UpdateOnAir_LiveMode_RechecksAuthorizationBeforeSessionDispatch() + { + using var scenes = TemporarySceneDirectory.Create("live-scene.t2s"); + var session = new FakeK3dSession(); + var authorization = new FakeLiveAuthorization(true); + await using var engine = CreateEngine( + LiveOptions(scenes.Path, trustedLiveOutputEnabled: true), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: true, + liveAuthorization: authorization); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("live-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + authorization.IsAuthorizedForThisLaunch = false; + + var result = await engine.UpdateOnAirAsync( + Cue("live-scene"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, result.Code); + Assert.DoesNotContain("UpdateOnAir:live-scene", session.Calls); + Assert.Equal("live-scene", engine.Status.OnAirSceneName); } [Fact] @@ -1876,6 +2053,390 @@ public sealed class TornadoPlayoutEngineTests Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); } + [Fact] + public async Task ProcessMonitor_DrainsLifecycleCallbacksOnTheStaAndPublishesHello() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + KtapHelloObserved = false, + HasPendingLifecycleCallbacks = true, + ProcessPendingCallbacksAction = _ => new K3dCallbackDrainResult(2, 1, true) + }; + var sessionFactory = new FakeK3dSessionFactory(() => session); + var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + sessionFactory, + new FakeRegistrationProbe(), + process, + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.False(engine.Status.KtapHelloObserved); + session.KtapHelloObserved = true; + + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.True(engine.Status.KtapHelloObserved); + Assert.Contains("ProcessPendingCallbacks", session.Calls); + Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); + Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); + } + + [Fact] + public async Task Disconnect_DoesNotDispatchWhileTakeOutCompletionCallbackIsPending() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None)).IsSuccess); + + var disconnect = await engine.DisconnectAsync(CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Unavailable, disconnect.Code); + Assert.Contains("ProcessPendingCallbacks", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Abandon", session.Calls); + Assert.True(session.IsConnected); + + session.HasPendingLifecycleCallbacks = false; + } + + [Fact] + public async Task Dispose_AbandonsWithoutDisconnectWhenTakeOutCallbackNeverArrives() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true; + var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None)).IsSuccess); + + await engine.DisposeAsync(); + + Assert.Contains("ProcessPendingCallbacks", session.Calls); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); + } + + [Fact] + public async Task LifecycleCallbackFailure_LatchesOutcomeUnknownWithoutDisconnectOrReplay() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + HasPendingLifecycleCallbacks = true, + ProcessPendingCallbacksAction = _ => + throw new InvalidOperationException("fake callback failure") + }; + var sessionFactory = new FakeK3dSessionFactory(() => session); + var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + sessionFactory, + new FakeRegistrationProbe(), + process, + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.Contains("ProcessPendingCallbacks", session.Calls); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + var later = await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code); + Assert.Equal(1, session.Calls.Count(call => call == "ProcessPendingCallbacks")); + } + + [Fact] + public async Task PendingPlayCallback_BlocksDelayedMultiNextUntilEachCompletionIsDrained() + { + using var scenes = TemporarySceneDirectory.Create( + "5001.t2s", + "5006.t2s", + "5011.t2s"); + var callbackReady = false; + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.PlayAction = _ => + { + session.HasPendingPlayCallbacks = true; + session.HasPendingLifecycleCallbacks = true; + }; + session.ProcessPendingCallbacksAction = _ => + { + if (!callbackReady) + { + return new K3dCallbackDrainResult(0, 0, false); + } + + callbackReady = false; + session.HasPendingPlayCallbacks = false; + session.HasPendingLifecycleCallbacks = false; + return new K3dCallbackDrainResult(1, 1, false); + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "5001", "5006", "5011"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("5001"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + Assert.True(session.HasPendingPlayCallbacks); + + var firstEarlyNext = await engine.NextAsync( + Cue("5006"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Unavailable, firstEarlyNext.Code); + Assert.Contains("completion callback", firstEarlyNext.Message, StringComparison.Ordinal); + Assert.DoesNotContain("Prepare:5006", session.Calls); + + callbackReady = true; + Assert.True((await engine.NextAsync( + Cue("5006"), + CancellationToken.None)).IsSuccess); + Assert.True(session.HasPendingPlayCallbacks); + Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5006")); + + var secondEarlyNext = await engine.NextAsync( + Cue("5011"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Unavailable, secondEarlyNext.Code); + Assert.DoesNotContain("Prepare:5011", session.Calls); + + callbackReady = true; + Assert.True((await engine.NextAsync( + Cue("5011"), + CancellationToken.None)).IsSuccess); + Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5011")); + + callbackReady = true; + await engine.PollProcessOnceAsync(CancellationToken.None); + Assert.False(session.HasPendingPlayCallbacks); + Assert.True((await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None)).IsSuccess); + Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess); + Assert.Contains("Disconnect", session.Calls); + } + + [Theory] + [InlineData("prepare")] + [InlineData("take-in")] + [InlineData("next")] + [InlineData("update-on-air")] + public async Task PendingPlayCallback_RejectsEveryNonTakeOutSceneMutation(string operation) + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.PlayAction = _ => + { + session.HasPendingPlayCallbacks = true; + session.HasPendingLifecycleCallbacks = true; + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "5001", "5006"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + if (operation != "prepare") + { + Assert.True((await engine.PrepareAsync( + Cue("5001"), + CancellationToken.None)).IsSuccess); + } + + if (operation is "next" or "update-on-air") + { + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + } + else + { + session.HasPendingPlayCallbacks = true; + session.HasPendingLifecycleCallbacks = true; + } + + var result = operation switch + { + "prepare" => await engine.PrepareAsync( + Cue("5006"), + CancellationToken.None), + "take-in" => await engine.TakeInAsync(CancellationToken.None), + "next" => await engine.NextAsync( + Cue("5006"), + CancellationToken.None), + "update-on-air" => await engine.UpdateOnAirAsync( + Cue("5001"), + CancellationToken.None), + _ => throw new InvalidOperationException("Unknown test operation.") + }; + + Assert.Equal(PlayoutResultCode.Unavailable, result.Code); + Assert.Contains("completion callback", result.Message, StringComparison.Ordinal); + if (operation == "prepare") + { + Assert.DoesNotContain("Prepare:5006", session.Calls); + } + else if (operation == "take-in") + { + Assert.DoesNotContain("Play", session.Calls); + } + else if (operation == "next") + { + Assert.DoesNotContain("Prepare:5006", session.Calls); + } + else + { + Assert.DoesNotContain("UpdateOnAir:5001", session.Calls); + } + } + + [Fact] + public async Task PendingPlayCallback_DoesNotBlockEmergencyTakeOut() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.PlayAction = _ => + { + session.HasPendingPlayCallbacks = true; + session.HasPendingLifecycleCallbacks = true; + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "5001"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("5001"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + Assert.True(session.HasPendingPlayCallbacks); + + var result = await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Contains("TakeOut:All", session.Calls); + } + + [Theory] + [InlineData(PlayoutTakeOutScope.All)] + [InlineData(PlayoutTakeOutScope.Layout)] + public async Task TakeOutCompletion_ClearsInterruptedPlayAndAllowsDisconnect( + PlayoutTakeOutScope scope) + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var takeOutCompletionReady = false; + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.PlayAction = _ => + { + session.HasPendingPlayCallbacks = true; + session.HasPendingLifecycleCallbacks = true; + }; + session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true; + session.ProcessPendingCallbacksAction = _ => + { + if (!takeOutCompletionReady) + { + return new K3dCallbackDrainResult(0, 0, false); + } + + session.HasPendingPlayCallbacks = false; + session.HasPendingLifecycleCallbacks = false; + return new K3dCallbackDrainResult(1, 1, false); + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "5001"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("5001"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + Assert.True(session.HasPendingPlayCallbacks); + Assert.True((await engine.TakeOutAsync(scope, CancellationToken.None)).IsSuccess); + + takeOutCompletionReady = true; + var disconnect = await engine.DisconnectAsync(CancellationToken.None); + + Assert.True(disconnect.IsSuccess); + Assert.False(session.HasPendingPlayCallbacks); + Assert.Contains($"TakeOut:{scope}", session.Calls); + Assert.Contains("ProcessPendingCallbacks", session.Calls); + Assert.Contains("Disconnect", session.Calls); + } + private static TornadoPlayoutEngine CreateEngine( PlayoutOptions rawOptions, FakeK3dSessionFactory sessionFactory, @@ -1884,7 +2445,8 @@ public sealed class TornadoPlayoutEngineTests bool liveAuthorized, Func? finalConnectSafetyCheck = null, bool abandonOnTargetMismatch = false, - Action? beforeSdkDispatchClaim = null) + Action? beforeSdkDispatchClaim = null, + FakeLiveAuthorization? liveAuthorization = null) { var options = ValidatedPlayoutOptions.Create(rawOptions); IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live @@ -1896,7 +2458,7 @@ public sealed class TornadoPlayoutEngineTests registrationProbe, processProbe, dispatcher, - new FakeLiveAuthorization(liveAuthorized), + liveAuthorization ?? new FakeLiveAuthorization(liveAuthorized), TimeProvider.System, startMonitor: false, finalConnectSafetyCheck: finalConnectSafetyCheck, @@ -1927,6 +2489,7 @@ public sealed class TornadoPlayoutEngineTests { Mode = PlayoutMode.Live, SceneDirectory = sceneDirectory, + TestSceneAllowlist = ["live-scene"], TrustedLiveOutputEnabled = trustedLiveOutputEnabled, ConnectTimeoutMilliseconds = 500, OperationTimeoutMilliseconds = 500, diff --git a/tests/Web/playout-safety.test.cjs b/tests/Web/playout-safety.test.cjs index e0879a8..8c6f041 100644 --- a/tests/Web/playout-safety.test.cjs +++ b/tests/Web/playout-safety.test.cjs @@ -48,6 +48,21 @@ test("TAKE IN and TAKE OUT preserve the native state prerequisites", () => { null); }); +test("Refresh faults block mutations but preserve emergency TAKE OUT", () => { + const faulted = ready({ + refreshFaulted: true, + engineState: "PROGRAM", + onAirCode: "5001" + }); + for (const command of ["prepare", "take-in", "next"]) { + assert.equal(safety.commandBlockReason(faulted, command), "refresh-fault"); + } + assert.equal(safety.commandBlockReason(faulted, "take-out"), null); + assert.equal( + safety.canClearCommandError({ code: "REFRESH_FAULT", nonClearable: true }, false), + false); +}); + test("Browser response timeout follows the bounded native operation timeout", () => { assert.equal(safety.responseTimeoutFromNative(5000), 10000); assert.equal(safety.responseTimeoutFromNative(300000), 305000); @@ -75,3 +90,54 @@ test("Late or mismatched native responses cannot complete a pending command", () false); assert.equal(safety.matchesPendingCommand(null, { requestId: "REQ-2", command: "next" }), false); }); + +test("Timeout and OutcomeUnknown errors cannot clear the session quarantine", () => { + assert.equal( + safety.canClearCommandError({ code: "WEB_TIMEOUT", outcomeUnknown: true }, true), + false); + assert.equal( + safety.canClearCommandError({ code: "OUTCOME_UNKNOWN", outcomeUnknown: true }, false), + false); + assert.equal( + safety.canClearCommandError({ code: "DATABASE_UNAVAILABLE", outcomeUnknown: false }, false), + true); +}); + +test("Playlist snapshot locks before PREPARE completes and through TAKE OUT", () => { + assert.equal(safety.playlistSnapshotLocked(ready()), false); + assert.equal(safety.playlistSnapshotLocked(ready({ pending: true })), true); + assert.equal(safety.playlistSnapshotLocked(ready({ outcomeUnknown: true })), true); + assert.equal( + safety.playlistSnapshotLocked(ready({ engineState: "PREPARED", preparedCode: "5001" })), + true); + assert.equal( + safety.playlistSnapshotLocked(ready({ engineState: "PROGRAM", onAirCode: "5001" })), + true); +}); + +test("A database refresh fault clears only after TAKE OUT resets refresh state", () => { + const fault = { + code: "DATABASE_UNAVAILABLE", + message: "Read failed", + nonClearable: true, + isRefreshFault: true + }; + assert.equal(fault.code, "DATABASE_UNAVAILABLE"); + assert.equal(fault.nonClearable, true); + assert.equal(fault.isRefreshFault, true); + assert.equal(safety.shouldClearRefreshError(fault, true), false); + assert.equal(safety.shouldClearRefreshError(fault, false), true); + + const unrelated = { code: "SCENE_DATA_INVALID", outcomeUnknown: false }; + assert.equal(safety.shouldClearRefreshError(unrelated, false), false); +}); + +test("Stored conditional aliases preserve the exact builder owner", () => { + const catalog = [ + { builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"] }, + { builderKey: "s8018", code: "8018", aliases: ["8018", "8032", "5032"] } + ]; + assert.equal(safety.resolveStoredCatalogIndex(catalog, "s5032", "8018"), 0); + assert.equal(safety.resolveStoredCatalogIndex(catalog, "s8018", "5032"), 1); + assert.equal(safety.resolveStoredCatalogIndex(catalog, "", "8032"), 0); +}); diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs index 4e54105..589ab63 100644 --- a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs @@ -5,7 +5,7 @@ using MBN_STOCK_WEBVIEW.Infrastructure; using MMoneyCoderSharp.Data; Console.OutputEncoding = Encoding.UTF8; -using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(3)); +using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10)); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; @@ -27,7 +27,8 @@ try if (statuses.Any(status => status.State != DatabaseHealthState.Healthy)) { - Console.Error.WriteLine("Smoke test stopped because both database health probes must be healthy."); + Console.Error.WriteLine( + "Smoke test stopped because both database health probes must be healthy."); return 2; } @@ -38,6 +39,7 @@ try await VerifySnapshotAsync(service, MarketDataView.Kosdaq, cancellation.Token); await VerifySnapshotAsync(service, MarketDataView.Index, cancellation.Token); await VerifySnapshotAsync(service, MarketDataView.Overseas, cancellation.Token); + await RealSceneDbSmoke.RunAsync(runtime.Executor, cancellation.Token); Console.WriteLine("REAL_DB_SMOKE: PASS"); return 0; @@ -54,7 +56,8 @@ catch (DatabaseInfrastructureException exception) } catch { - Console.Error.WriteLine("REAL_DB_SMOKE: FAIL - An unexpected error occurred. No provider details were printed."); + Console.Error.WriteLine( + "REAL_DB_SMOKE: FAIL - An unexpected error occurred. No provider details were printed."); return 1; } @@ -65,12 +68,14 @@ static string? GetConfigurationPath(string[] arguments) return null; } - if (arguments.Length == 2 && arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase)) + if (arguments.Length == 2 && + arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase)) { return arguments[1]; } - throw new DatabaseConfigurationException("Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config ]"); + throw new DatabaseConfigurationException( + "Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config ]"); } static async Task VerifyOracleServerVersionAsync( @@ -89,22 +94,33 @@ static async Task VerifyOracleServerVersionAsync( if (table.Rows.Count == 0 || table.Columns.Count == 0) { - throw new DatabaseConfigurationException("Oracle 서버 버전을 확인할 수 없습니다."); + throw new DatabaseConfigurationException( + "The Oracle server version query returned no version."); } - var version = Convert.ToString(table.Rows[0][0], CultureInfo.InvariantCulture) ?? string.Empty; - var match = Regex.Match(version, @"(?\d{2})(?:\.\d+)", RegexOptions.CultureInvariant); + var version = Convert.ToString( + table.Rows[0][0], + CultureInfo.InvariantCulture) ?? string.Empty; + var match = Regex.Match( + version, + @"(?\d{2})(?:\.\d+)", + RegexOptions.CultureInvariant); if (!match.Success || - !int.TryParse(match.Groups["major"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var major)) + !int.TryParse( + match.Groups["major"].Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var major)) { - throw new DatabaseConfigurationException("Oracle 서버 주 버전을 판별할 수 없습니다."); + throw new DatabaseConfigurationException( + "The Oracle server major version could not be determined."); } Console.WriteLine($"Oracle server major version: {major}"); if (major < 19) { throw new DatabaseConfigurationException( - "Oracle.ManagedDataAccess.Core 23.26.200은 Oracle Database 19c 이상이 필요합니다."); + "Oracle.ManagedDataAccess.Core requires Oracle Database 19c or newer."); } } @@ -113,20 +129,26 @@ static async Task VerifySnapshotAsync( MarketDataView view, CancellationToken cancellationToken) { - var snapshot = await service.GetAsync(view, maximumRowsPerTable: 5, cancellationToken); + var snapshot = await service.GetAsync( + view, + maximumRowsPerTable: 5, + cancellationToken); if (snapshot.Tables.Count == 0) { - throw new DatabaseConfigurationException($"{view} 조회 결과에 데이터셋이 없습니다."); + throw new DatabaseConfigurationException( + $"{view} returned no database result set."); } foreach (var table in snapshot.Tables) { if (table.Columns.Count == 0) { - throw new DatabaseConfigurationException($"{view}/{table.Name} 결과 열이 없습니다."); + throw new DatabaseConfigurationException( + $"{view}/{table.Name} returned no columns."); } Console.WriteLine( - $"{view}/{table.Source}/{table.Name}: {table.TotalRowCount.ToString(CultureInfo.InvariantCulture)} rows"); + $"{view}/{table.Source}/{table.Name}: " + + $"{table.TotalRowCount.ToString(CultureInfo.InvariantCulture)} rows"); } } diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/RealSceneDbSmoke.cs b/tools/MBN_STOCK_WEBVIEW.DbSmoke/RealSceneDbSmoke.cs new file mode 100644 index 0000000..77d98c2 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/RealSceneDbSmoke.cs @@ -0,0 +1,811 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +internal static class RealSceneDbSmoke +{ + public static async Task RunAsync( + IDataQueryExecutor executor, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(executor); + cancellationToken.ThrowIfCancellationRequested(); + + var traced = new QueryTracingDataQueryExecutor(executor); + var run = new SceneSmokeRun(); + + Console.WriteLine("DB_SMOKE_STEP: bounded scene selectors"); + var pieName = await SelectOneAsync( + traced, + "SELECTOR_INPUT_PIE", + "SELECT STOCK_NAME FROM INPUT_PIE WHERE STOCK_NAME IS NOT NULL AND ROWNUM = 1", + "STOCK_NAME", + cancellationToken).ConfigureAwait(false); + var profitName = await SelectOneAsync( + traced, + "SELECTOR_INPUT_PROFIT", + "SELECT STOCK_NAME FROM INPUT_PROFIT WHERE STOCK_NAME IS NOT NULL AND ROWNUM = 1", + "STOCK_NAME", + cancellationToken).ConfigureAwait(false); + var growthName = await SelectOneAsync( + traced, + "SELECTOR_INPUT_GROW", + "SELECT STOCK_NAME FROM INPUT_GROW WHERE STOCK_NAME IS NOT NULL AND ROWNUM = 1", + "STOCK_NAME", + cancellationToken).ConfigureAwait(false); + var salesName = await SelectOneAsync( + traced, + "SELECTOR_INPUT_SELL", + "SELECT STOCK_NAME FROM INPUT_SELL WHERE STOCK_NAME IS NOT NULL AND ROWNUM = 1", + "STOCK_NAME", + cancellationToken).ConfigureAwait(false); + var nxtStockCode = await SelectOneAsync( + traced, + DataSourceKind.MariaDb, + "SELECTOR_NXT_STOCK", + """ + SELECT a.f_stock_code STOCK_CODE + FROM n_online a + JOIN n_stock b ON a.f_stock_code = b.f_stock_code + JOIN n_batch_his c ON a.f_stock_code = c.f_stock_code + WHERE a.f_curr_price <> 0 + AND c.f_data_day = ( + SELECT MAX(h.f_data_day) + FROM n_batch_his h + WHERE h.f_stock_code = a.f_stock_code) + LIMIT 1 + """, + "STOCK_CODE", + cancellationToken).ConfigureAwait(false); + var domesticStock = await SelectStockAsync( + traced, + "SELECTOR_DOMESTIC_STOCK", + """ + SELECT STOCK_CODE, STOCK_NAME + FROM ( + SELECT a.f_stock_code STOCK_CODE, b.f_stock_wanname STOCK_NAME + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE a.f_curr_price <> 0 + AND b.f_mkt_halt = 'N' + AND b.f_stock_wanname IS NOT NULL + ORDER BY a.f_stock_code + ) + WHERE ROWNUM = 1 + """, + cancellationToken).ConfigureAwait(false); + var comparisonNames = await SelectManyAsync( + traced, + "SELECTOR_COMPARISON_STOCKS", + """ + SELECT STOCK_NAME + FROM ( + SELECT b.f_stock_wanname STOCK_NAME + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + WHERE a.f_curr_price <> 0 + AND b.f_mkt_halt = 'N' + AND b.f_stock_wanname IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM t_candle_history h + WHERE h.f_stock_code = a.f_stock_code) + ORDER BY a.f_stock_code + ) + WHERE ROWNUM <= 2 + """, + "STOCK_NAME", + maximumRows: 2, + cancellationToken).ConfigureAwait(false); + var dealerStockName = await SelectOneAsync( + traced, + "SELECTOR_DEALER_STOCK", + """ + SELECT STOCK_NAME + FROM ( + SELECT b.f_stock_wanname STOCK_NAME + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + JOIN t_dealer d ON a.f_stock_code = d.f_stock_code + WHERE a.f_curr_price <> 0 + AND b.f_mkt_halt = 'N' + AND d.f_buy_dealer_no_1 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_buy_dealer_no_2 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_buy_dealer_no_3 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_buy_dealer_no_4 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_buy_dealer_no_5 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_sell_dealer_no_1 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_sell_dealer_no_2 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_sell_dealer_no_3 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_sell_dealer_no_4 IN (SELECT f_sale_no FROM t_sale_member) + AND d.f_sell_dealer_no_5 IN (SELECT f_sale_no FROM t_sale_member) + ORDER BY a.f_stock_code + ) + WHERE ROWNUM = 1 + """, + "STOCK_NAME", + cancellationToken).ConfigureAwait(false); + var orderBookStockCode = await SelectOneAsync( + traced, + "SELECTOR_ORDER_BOOK_STOCK", + """ + SELECT STOCK_CODE + FROM ( + SELECT a.f_stock_code STOCK_CODE + FROM t_online1 a + JOIN t_stock b ON a.f_stock_code = b.f_stock_code + JOIN t_online1_call c ON a.f_stock_code = c.f_stock_code + WHERE a.f_curr_price > 0 + AND b.f_mkt_halt = 'N' + AND c.f_call_sell_1 IS NOT NULL + AND c.f_call_sell_2 IS NOT NULL + AND c.f_call_sell_3 IS NOT NULL + AND c.f_call_sell_jan_1 IS NOT NULL + AND c.f_call_sell_jan_2 IS NOT NULL + AND c.f_call_sell_jan_3 IS NOT NULL + AND c.f_call_buy_1 IS NOT NULL + AND c.f_call_buy_2 IS NOT NULL + AND c.f_call_buy_3 IS NOT NULL + AND c.f_call_buy_jan_1 IS NOT NULL + AND c.f_call_buy_jan_2 IS NOT NULL + AND c.f_call_buy_jan_3 IS NOT NULL + AND c.f_t_call_sell_jan IS NOT NULL + AND c.f_t_call_buy_jan IS NOT NULL + ORDER BY a.f_stock_code + ) + WHERE ROWNUM = 1 + """, + "STOCK_CODE", + cancellationToken).ConfigureAwait(false); + + var domesticCode = domesticStock?.Code; + var pageRequest = new DomesticPagedQuoteLoadRequest( + PagedQuoteMarket.Kospi, + DomesticPagedQuoteList.MarketCapitalization, + DateOnly.FromDateTime(DateTime.Today)); + + await run.RequiredAsync( + "s5001", + "Oracle/BalticDryIndex", + () => new S5001SceneDataLoader(traced).LoadAsync( + new S5001BalticDryIndexLoadRequest(), cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5006", + "Oracle/domestic", + () => new S5006DomesticSceneDataLoader(traced).LoadAsync( + new S5006DomesticSceneDataRequest( + LegacyDomesticEquityMarket.Kospi, + RequireSelector(domesticCode, "domestic stock code")), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5006", + "MariaDb/NXT", + () => new S5006NxtSceneDataLoader(traced).LoadAsync( + LegacyDomesticEquityMarket.NxtKospi, + RequireSelector(nxtStockCode, "NXT stock code"), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5011", + "Oracle/face-value", + () => new S5011SceneDataLoader(traced).LoadAsync( + new S5011SceneDataRequest( + LegacyDomesticEquityMarket.Kospi, + S5011DetailBranch.FaceValue, + RequireSelector(domesticCode, "domestic stock code")), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5016", + "Oracle/US-indices", + () => new S5016SceneDataLoader(traced).LoadAsync( + S5016PanelTarget.UnitedStatesIndices, + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s50160", + "Oracle/cotton", + () => new S50160SceneDataLoader(traced).LoadAsync( + S50160PanelTarget.Cotton, + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5023", + "Oracle/Kospi", + () => new S5023SceneDataLoader(traced).LoadAsync( + new S5023SceneLoadRequest(LegacyDomesticMarket.Kospi), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5024", + "Oracle/Kospi", + () => new S5024SceneDataLoader(traced).LoadAsync( + new S5024SceneLoadRequest(LegacyDomesticMarket.Kospi), + cancellationToken)).ConfigureAwait(false); + + var manualDirectory = Environment.GetEnvironmentVariable( + S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable); + var manualConfigured = !string.IsNullOrWhiteSpace(manualDirectory); + if (manualConfigured) + { + await run.RequiredAsync( + "s5025", + "trusted-external/manual-individual", + () => new S5025SceneDataLoader( + S5025TrustedManualFileDataSource.CreateFromEnvironment()).LoadAsync( + new S5025ManualSceneLoadRequest(S5025ManualAudience.Individual), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5025", + "trusted-external/manual-foreign", + () => new S5025SceneDataLoader( + S5025TrustedManualFileDataSource.CreateFromEnvironment()).LoadAsync( + new S5025ManualSceneLoadRequest(S5025ManualAudience.Foreign), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5025", + "trusted-external/manual-institution", + () => new S5025SceneDataLoader( + S5025TrustedManualFileDataSource.CreateFromEnvironment()).LoadAsync( + new S5025ManualSceneLoadRequest(S5025ManualAudience.Institution), + cancellationToken)).ConfigureAwait(false); + } + else + { + Console.WriteLine( + "SCENE_SMOKE:s5025:PREREQUISITE - trusted external manual-data directory is not configured"); + } + + await run.RequiredAsync( + "s5026", + "Oracle/five-day-candle-pair", + () => new S5026SceneDataLoader(traced).LoadAsync( + new ComparisonPairSceneLoadRequest( + ComparisonGraphPeriod.FiveDays, + new ComparisonStockSelection( + ComparisonEquityMarket.Kospi, + RequireComparisonSelector(comparisonNames, 0)), + new ComparisonStockSelection( + ComparisonEquityMarket.Kospi, + RequireComparisonSelector(comparisonNames, 1))), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5029", + "Oracle/five-day-yield-pair", + () => new S5029SceneDataLoader(traced).LoadAsync( + new ComparisonPairSceneLoadRequest( + ComparisonGraphPeriod.FiveDays, + new ComparisonStockSelection( + ComparisonEquityMarket.Kospi, + RequireComparisonSelector(comparisonNames, 0)), + new ComparisonStockSelection( + ComparisonEquityMarket.Kospi, + RequireComparisonSelector(comparisonNames, 1))), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5032", + "Oracle/index-and-futures", + () => new S5032SceneDataLoader(traced).LoadAsync( + new S5032IndexAndFuturesLoadRequest(LegacyMarketQuoteTarget.Kospi), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5037", + "Oracle/dealer-quote", + () => new S5037SceneDataLoader(traced).LoadAsync( + new S5037SceneLoadRequest( + LegacyDomesticMarket.Kospi, + RequireSelector(dealerStockName, "dealer stock name")), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5074", + "Oracle/paged-5", + () => new S5074SceneDataLoader(traced).LoadAsync( + pageRequest, + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5076", + "Oracle/manual-pie", + () => new S5076SceneDataLoader(traced).LoadAsync( + RequireSelector(pieName, "INPUT_PIE stock name"), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5077", + "Oracle/paged-6", + () => new S5077SceneDataLoader(traced).LoadAsync( + pageRequest, + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5078", + "Oracle/Kospi-sectors", + () => new S5078SceneDataLoader(traced).LoadAsync( + new S5078SceneDataRequest(SectorIndexMarket.Kospi), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5079", + "Oracle/manual-growth", + () => new S5079SceneDataLoader(traced).LoadAsync( + new S5079SceneDataRequest(RequireSelector(growthName, "INPUT_GROW stock name")), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5080", + "Oracle/manual-sales", + () => new S5080SceneDataLoader(traced).LoadAsync( + new S5080SceneDataRequest(RequireSelector(salesName, "INPUT_SELL stock name")), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5081", + "Oracle/manual-profit", + () => new S5081SceneDataLoader(traced).LoadAsync( + RequireSelector(profitName, "INPUT_PROFIT stock name"), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5082", + "Oracle/grid", + () => new S5082SceneDataLoader(traced).LoadAsync(cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5083", + "Oracle/investor-flow", + () => new S5083SceneDataLoader(traced).LoadAsync( + new S5083SceneDataRequest(InvestorFlowParticipant.Individual), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5084", + "Oracle/index-flow", + () => new S5084SceneDataLoader(traced).LoadAsync( + new S5084SceneDataRequest(InvestorFlowIndex.Kospi), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5085", + "Oracle/program-trading", + () => new S5085SceneDataLoader(traced).LoadAsync(cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5086", + "Oracle/five-day-FX-yield", + () => new S5086SceneDataLoader(traced).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s50860", + "Oracle/five-day-quoted-FX-yield", + () => new S50860SceneDataLoader(traced).LoadAsync( + new YieldSceneLoadRequest( + YieldGraphPeriod.FiveDays, + new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5087", + "Oracle/five-day-comparison", + () => new S5087SceneDataLoader(traced).LoadAsync( + new ComparisonPairSceneLoadRequest( + ComparisonGraphPeriod.FiveDays, + new ComparisonStockSelection( + ComparisonEquityMarket.Kospi, + RequireComparisonSelector(comparisonNames, 0)), + new ComparisonStockSelection( + ComparisonEquityMarket.Kospi, + RequireComparisonSelector(comparisonNames, 1))), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s5088", + "Oracle/paged-12", + () => new S5088SceneDataLoader(traced).LoadAsync( + pageRequest, + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s6001", + "Oracle/DowJones", + () => new S6001SceneDataLoader(traced).LoadAsync( + S6001QuoteTarget.DowJones, + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s6067", + "Oracle/institution-grid", + () => new S6067SceneDataLoader(traced).LoadAsync(cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s8001", + "Oracle/Kospi-sector-map", + () => new S8001SceneDataLoader(traced).LoadAsync( + new S8001SceneLoadRequest(S8001Market.Kospi), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s8003", + "Oracle/order-book", + () => new S8003SceneDataLoader(traced).LoadAsync( + new S8003SceneLoadRequest( + LegacyDomesticMarket.Kospi, + RequireSelector(orderBookStockCode, "order-book stock code")), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s8010", + "Oracle/Kospi-index-candle", + () => new S8010SceneDataLoader(traced).LoadAsync( + new S8010SceneDataRequest( + CandleCutAlias.Cut8061, + CandleMarketTarget.KospiIndex, + CandleChartMode.Price, + InstrumentName: null, + ShowMovingAverage5: false, + ShowMovingAverage20: false), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s8018", + "Oracle/current-market-pair", + () => new S8018SceneDataLoader(traced).LoadAsync( + new S8018MarketPairLoadRequest( + S8018MarketDataMode.Current, + LegacyMarketQuoteTarget.Kospi, + LegacyMarketQuoteTarget.Kosdaq), + cancellationToken)).ConfigureAwait(false); + await run.RequiredAsync( + "s8067", + "Oracle/world-map", + () => new S8067SceneDataLoader(traced).LoadAsync(cancellationToken)).ConfigureAwait(false); + + await run.DiagnosticAsync( + "s8086", + "Oracle/unreachable-no-cut-alias", + () => new S8086DiagnosticSceneDataLoader(traced).LoadAsync( + S8086DiagnosticTarget.Gold, + cancellationToken)).ConfigureAwait(false); + + run.Complete(manualConfigured); + } + + private static async Task SelectOneAsync( + IDataQueryExecutor executor, + string tableName, + string sql, + string column, + CancellationToken cancellationToken) => + await SelectOneAsync( + executor, + DataSourceKind.Oracle, + tableName, + sql, + column, + cancellationToken).ConfigureAwait(false); + + private static async Task SelectOneAsync( + IDataQueryExecutor executor, + DataSourceKind source, + string tableName, + string sql, + string column, + CancellationToken cancellationToken) + { + try + { + var table = await executor.ExecuteAsync( + source, + tableName, + new DataQuerySpec(sql), + cancellationToken).ConfigureAwait(false); + var value = table.Rows.Count == 1 && table.Columns.Contains(column) + ? Convert.ToString(table.Rows[0][column], CultureInfo.InvariantCulture)?.Trim() + : null; + Console.WriteLine( + string.IsNullOrEmpty(value) + ? $"DB_SMOKE_SELECTOR:{tableName}:NO_ELIGIBLE_ROW" + : $"DB_SMOKE_SELECTOR:{tableName}:PASS"); + return string.IsNullOrEmpty(value) ? null : value; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + Console.WriteLine($"DB_SMOKE_SELECTOR:{tableName}:FAIL:{SafeFailure(exception)}"); + return null; + } + } + + private static async Task SelectStockAsync( + IDataQueryExecutor executor, + string tableName, + string sql, + CancellationToken cancellationToken) + { + try + { + var table = await executor.ExecuteAsync( + DataSourceKind.Oracle, + tableName, + new DataQuerySpec(sql), + cancellationToken).ConfigureAwait(false); + if (table.Rows.Count != 1 || + !table.Columns.Contains("STOCK_CODE") || + !table.Columns.Contains("STOCK_NAME")) + { + Console.WriteLine($"DB_SMOKE_SELECTOR:{tableName}:NO_ELIGIBLE_ROW"); + return null; + } + + var code = Convert.ToString(table.Rows[0]["STOCK_CODE"], CultureInfo.InvariantCulture)?.Trim(); + var name = Convert.ToString(table.Rows[0]["STOCK_NAME"], CultureInfo.InvariantCulture)?.Trim(); + if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(name)) + { + Console.WriteLine($"DB_SMOKE_SELECTOR:{tableName}:NO_ELIGIBLE_ROW"); + return null; + } + + Console.WriteLine($"DB_SMOKE_SELECTOR:{tableName}:PASS"); + return new StockSelector(code, name); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + Console.WriteLine($"DB_SMOKE_SELECTOR:{tableName}:FAIL:{SafeFailure(exception)}"); + return null; + } + } + + private static async Task> SelectManyAsync( + IDataQueryExecutor executor, + string tableName, + string sql, + string column, + int maximumRows, + CancellationToken cancellationToken) + { + try + { + var table = await executor.ExecuteAsync( + DataSourceKind.Oracle, + tableName, + new DataQuerySpec(sql), + cancellationToken).ConfigureAwait(false); + if (!table.Columns.Contains(column) || table.Rows.Count > maximumRows) + { + Console.WriteLine($"DB_SMOKE_SELECTOR:{tableName}:INVALID_RESULT"); + return []; + } + + var values = table.Rows.Cast() + .Select(row => Convert.ToString(row[column], CultureInfo.InvariantCulture)?.Trim()) + .Where(value => !string.IsNullOrEmpty(value)) + .Cast() + .Distinct(StringComparer.Ordinal) + .ToArray(); + Console.WriteLine( + values.Length == 0 + ? $"DB_SMOKE_SELECTOR:{tableName}:NO_ELIGIBLE_ROW" + : $"DB_SMOKE_SELECTOR:{tableName}:PASS:{values.Length.ToString(CultureInfo.InvariantCulture)}"); + return values; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + Console.WriteLine($"DB_SMOKE_SELECTOR:{tableName}:FAIL:{SafeFailure(exception)}"); + return []; + } + } + + private static string RequireSelector(string? value, string selector) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new LegacySceneDataException( + $"The {selector} selector returned no eligible database row."); + } + + return value; + } + + private static string RequireComparisonSelector( + IReadOnlyList values, + int index) + { + if (values.Count != 2 || index is < 0 or > 1) + { + return RequireSelector(null, "comparison stock pair"); + } + + return values[index]; + } + + private static string SafeFailure(Exception exception) => exception switch + { + LegacySceneDataException => "DATA_UNAVAILABLE_OR_INVALID", + DatabaseOperationException operation when operation.ProviderErrorCode.HasValue => + "DATABASE_QUERY_FAILED_CODE_" + + operation.ProviderErrorCode.Value.ToString(CultureInfo.InvariantCulture), + DatabaseInfrastructureException => "DATABASE_QUERY_FAILED", + S5025TrustedManualDataException => "TRUSTED_MANUAL_DATA_UNAVAILABLE", + _ => exception.GetType().Name.ToUpperInvariant() + }; + + private sealed record StockSelector(string Code, string Name); + + private sealed class SceneSmokeRun + { + private readonly HashSet _covered = new(StringComparer.Ordinal); + private readonly List _requiredFailures = []; + + public async Task RequiredAsync( + string builderKey, + string caseName, + Func> loadAsync) + where TData : ILegacySceneData => + await VerifyAsync(builderKey, caseName, loadAsync, required: true).ConfigureAwait(false); + + public async Task DiagnosticAsync( + string builderKey, + string caseName, + Func> loadAsync) + where TData : ILegacySceneData => + await VerifyAsync(builderKey, caseName, loadAsync, required: false).ConfigureAwait(false); + + public void Complete(bool manualConfigured) + { + var expected = LegacySceneMutationBuilderRegistry.Default.Registrations + .Select(registration => registration.BuilderKey) + .Where(key => !string.Equals(key, "s8086", StringComparison.Ordinal)) + .Where(key => manualConfigured || !string.Equals(key, "s5025", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + var missing = expected.Except(_covered, StringComparer.Ordinal) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray(); + var requiredCovered = expected.Count(_covered.Contains); + var diagnosticStatus = _covered.Contains("s8086") + ? "pass-unreachable-no-cut-alias" + : "failed-unreachable-no-cut-alias"; + + Console.WriteLine( + $"SCENE_SMOKE_SUMMARY: requiredCovered={requiredCovered.ToString(CultureInfo.InvariantCulture)} " + + $"required={expected.Count.ToString(CultureInfo.InvariantCulture)} " + + $"requiredFailures={_requiredFailures.Count.ToString(CultureInfo.InvariantCulture)} " + + $"diagnostic={diagnosticStatus} " + + $"manualS5025={(manualConfigured ? "configured" : "prerequisite")}"); + if (missing.Length > 0) + { + Console.WriteLine("SCENE_SMOKE_MISSING: " + string.Join(",", missing)); + } + + if (_requiredFailures.Count > 0 || missing.Length > 0) + { + throw new DatabaseConfigurationException( + "One or more required real-data scene loader cases did not produce validated mutations."); + } + } + + private async Task VerifyAsync( + string builderKey, + string caseName, + Func> loadAsync, + bool required) + where TData : ILegacySceneData + { + Console.WriteLine($"SCENE_SMOKE:{builderKey}:{caseName}:START"); + try + { + var data = await loadAsync().ConfigureAwait(false); + var mutations = LegacySceneMutationBuilderRegistry.Default.Build(builderKey, data); + if (mutations.Count == 0) + { + throw new LegacySceneDataException( + "The scene mutation builder produced no mutations."); + } + + _covered.Add(builderKey); + Console.WriteLine( + $"SCENE_SMOKE:{builderKey}:{caseName}:PASS:" + + mutations.Count.ToString(CultureInfo.InvariantCulture)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + var failure = SafeFailure(exception); + Console.WriteLine($"SCENE_SMOKE:{builderKey}:{caseName}:FAIL:{failure}"); + if (required) + { + _requiredFailures.Add(builderKey + "/" + caseName); + } + } + } + } + + private sealed class QueryTracingDataQueryExecutor : IDataQueryExecutor + { + private readonly IDataQueryExecutor _inner; + private int _sequence; + + public QueryTracingDataQueryExecutor(IDataQueryExecutor inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + var sequence = Start(source, tableName); + try + { + var result = _inner.Execute(source, tableName, query); + End(sequence, result); + return result; + } + catch (Exception exception) + { + Fail(sequence, exception); + throw; + } + } + + public async Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + var sequence = Start(source, tableName); + try + { + var result = await _inner.ExecuteAsync( + source, + tableName, + query, + cancellationToken).ConfigureAwait(false); + End(sequence, result); + return result; + } + catch (Exception exception) + { + Fail(sequence, exception); + throw; + } + } + + public async Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + var sequence = Start(source, tableName); + try + { + var result = await _inner.ExecuteAsync( + source, + tableName, + query, + cancellationToken).ConfigureAwait(false); + End(sequence, result); + return result; + } + catch (Exception exception) + { + Fail(sequence, exception); + throw; + } + } + + private int Start(DataSourceKind source, string tableName) + { + var sequence = Interlocked.Increment(ref _sequence); + Console.WriteLine( + $"DB_SMOKE_QUERY:{sequence.ToString(CultureInfo.InvariantCulture)}:" + + $"START:{source}:{tableName}"); + return sequence; + } + + private static void End(int sequence, DataTable result) => + Console.WriteLine( + $"DB_SMOKE_QUERY:{sequence.ToString(CultureInfo.InvariantCulture)}:" + + $"PASS:rows={result.Rows.Count.ToString(CultureInfo.InvariantCulture)}"); + + private static void Fail(int sequence, Exception exception) => + Console.WriteLine( + $"DB_SMOKE_QUERY:{sequence.ToString(CultureInfo.InvariantCulture)}:" + + $"FAIL:{SafeFailure(exception)}"); + } +}