From af80c36cdee6f0e2cbd578a4c94359df10e9e222 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 12 Jul 2026 11:59:18 +0900 Subject: [PATCH] feat: restore legacy named playlist workflows --- MBN_STOCK_WEBVIEW.csproj | 6 +- MainWindow.NamedForeignStockRestore.cs | 137 +++ MainWindow.NamedKrxThemeRestore.cs | 141 +++ MainWindow.NamedPlaylists.cs | 13 +- Package.appxmanifest | 2 +- Web/app.js | 555 +++++++++++- Web/candle-options-workflow.js | 19 + Web/index.html | 2 + Web/legacy-foreign-index-candle-workflow.js | 9 + Web/legacy-named-row-workflow.js | 18 +- Web/manual-financial-workflow.js | 9 + Web/manual-lists-workflow.js | 11 + Web/named-foreign-stock-restore-workflow.js | 402 +++++++++ Web/named-krx-theme-restore-workflow.js | 279 ++++++ Web/named-nxt-theme-restore-workflow.js | 9 + Web/overseas-workflow.js | 7 +- docs/EXTERNAL_ASSET_SEARCH_AUDIT.md | 64 ++ docs/LEGACY_FEATURE_AUDIT.md | 47 +- docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md | 136 +++ docs/MIGRATION.md | 4 +- docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md | 118 +++ docs/NAMED_PLAYLIST_READ_AUDIT.md | 178 ++++ docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md | 72 ++ docs/OPERATOR_UI_PARITY.md | 8 +- scripts/Test-WebPlayout.ps1 | 17 +- .../Data/LegacyNamedForeignStockRestore.cs | 529 ++++++++++++ .../Data/LegacyNamedKrxThemeRestore.cs | 487 +++++++++++ .../Data/LegacyNamedManualRestoreAudit.cs | 511 +++++++++++ .../Data/LegacyNxtThemeJoinAudit.cs | 341 ++++++++ .../Data/LegacyPListReadAudit.cs | 524 ++++++++++++ .../Data/ThemeSelection.cs | 11 +- ...acyNamedForeignStockRestoreServiceTests.cs | 377 ++++++++ .../LegacyNamedKrxThemeRestoreServiceTests.cs | 406 +++++++++ .../LegacyNamedManualRestoreAuditTests.cs | 238 ++++++ .../LegacyNxtThemeJoinAuditServiceTests.cs | 222 +++++ .../LegacyPListReadAuditParserTests.cs | 171 ++++ tests/Web/candle-options-workflow.test.cjs | 57 +- ...acy-foreign-index-candle-workflow.test.cjs | 19 + tests/Web/manual-financial-workflow.test.cjs | 18 + tests/Web/manual-lists-workflow.test.cjs | 23 + ...ign-stock-restore-app-integration.test.cjs | 136 +++ ...ed-foreign-stock-restore-workflow.test.cjs | 251 ++++++ ...krx-theme-restore-app-integration.test.cjs | 83 ++ .../named-krx-theme-restore-workflow.test.cjs | 249 ++++++ .../named-nxt-theme-restore-workflow.test.cjs | 18 + tests/Web/overseas-workflow.test.cjs | 19 + ...st-enabled-toggle-app-integration.test.cjs | 122 +++ ...entry-edit-safety-app-integration.test.cjs | 180 ++++ .../NamedManualRestoreReadOnlyAudit.cs | 571 +++++++++++++ .../NamedPlaylistReadOnlyDbAudit.cs | 802 ++++++++++++++++++ .../NxtThemeRestoreDbAudit.cs | 335 +++++++- tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs | 381 ++++++++- 52 files changed, 9261 insertions(+), 83 deletions(-) create mode 100644 MainWindow.NamedForeignStockRestore.cs create mode 100644 MainWindow.NamedKrxThemeRestore.cs create mode 100644 Web/named-foreign-stock-restore-workflow.js create mode 100644 Web/named-krx-theme-restore-workflow.js create mode 100644 docs/EXTERNAL_ASSET_SEARCH_AUDIT.md create mode 100644 docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md create mode 100644 docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md create mode 100644 docs/NAMED_PLAYLIST_READ_AUDIT.md create mode 100644 docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedForeignStockRestoreServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedKrxThemeRestoreServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedManualRestoreAuditTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNxtThemeJoinAuditServiceTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPListReadAuditParserTests.cs create mode 100644 tests/Web/named-foreign-stock-restore-app-integration.test.cjs create mode 100644 tests/Web/named-foreign-stock-restore-workflow.test.cjs create mode 100644 tests/Web/named-krx-theme-restore-app-integration.test.cjs create mode 100644 tests/Web/named-krx-theme-restore-workflow.test.cjs create mode 100644 tests/Web/playlist-enabled-toggle-app-integration.test.cjs create mode 100644 tests/Web/playlist-entry-edit-safety-app-integration.test.cjs create mode 100644 tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs create mode 100644 tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs diff --git a/MBN_STOCK_WEBVIEW.csproj b/MBN_STOCK_WEBVIEW.csproj index 3a4ce61..fc92b85 100644 --- a/MBN_STOCK_WEBVIEW.csproj +++ b/MBN_STOCK_WEBVIEW.csproj @@ -25,9 +25,9 @@ Properties\PublishProfiles\win-x64.pubxml false false - 1.0.3 - 4 - 1.0.3 + 1.0.4 + 5 + 1.0.4 diff --git a/MainWindow.NamedForeignStockRestore.cs b/MainWindow.NamedForeignStockRestore.cs new file mode 100644 index 0000000..03b305f --- /dev/null +++ b/MainWindow.NamedForeignStockRestore.cs @@ -0,0 +1,137 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private LegacyNamedForeignStockRestoreService? _namedForeignStockRestoreService; + + private async Task PostNamedForeignStockRestoreAsync( + string requestId, + IReadOnlyList items, + CancellationToken cancellationToken) + { + var candidates = items + .Select(static item => new LegacyNamedForeignStockRestoreRow( + item.ItemIndex, + item.IsEnabled, + item.Selection.GroupCode, + item.Selection.Subject, + item.Selection.GraphicType, + item.Selection.Subtype, + item.Page?.ToString() ?? string.Empty, + item.Selection.DataCode)) + .Where(LegacyNamedForeignStockRestoreService.IsCandidate) + .ToArray(); + if (candidates.Length == 0) + { + return; + } + + try + { + var runtime = _databaseRuntime; + if (runtime is null) + { + PostNamedForeignStockRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 해외종목 master DB를 사용할 수 없어 저장 행을 재검증하지 못했습니다."); + return; + } + + var service = _namedForeignStockRestoreService ??= + new LegacyNamedForeignStockRestoreService(runtime.Executor); + var result = await service.ResolveAsync(candidates, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PostMessage("named-foreign-stock-restore-results", new + { + requestId, + result.RetrievedAt, + totalRowCount = result.Rows.Count, + rows = result.Rows.Select(ToNamedForeignStockRestoreWireRow) + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 && + !_lifetimeCancellation.IsCancellationRequested) + { + PostNamedForeignStockRestoreError( + requestId, + "PLAYOUT_PRIORITY", + "송출 명령 우선 처리로 해외종목 재검증을 취소했습니다. 자동 재시도하지 않습니다."); + } + + throw; + } + catch (DatabaseOperationException) + { + PostNamedForeignStockRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 해외종목 master DB에서 저장 행을 재검증하지 못했습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostNamedForeignStockRestoreError( + requestId, + "RESTORE_FAILED", + "저장된 해외종목 행을 현재의 안전한 종목 선택으로 복원하지 못했습니다."); + } + } + + private static object ToNamedForeignStockRestoreWireRow( + LegacyNamedForeignStockRestoreOutcome outcome) + { + if (outcome.IsResolved) + { + var identity = outcome.Identity!; + return new + { + outcome.ItemIndex, + status = "resolved", + identity.ActionId, + identity.InputName, + identity.Symbol, + identity.NationCode, + identity.ForeignDomesticCode, + identity.BuilderKey, + identity.CutCode, + identity.ValueType, + identity.PeriodDays + }; + } + + return new + { + outcome.ItemIndex, + status = "failed", + failure = outcome.Failure switch + { + LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW", + LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND", + LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS", + LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid => + "DATABASE_DATA_INVALID", + _ => "RESTORE_FAILED" + } + }; + } + + private void PostNamedForeignStockRestoreError( + string requestId, + string code, + string message) + { + PostMessage("named-foreign-stock-restore-error", new + { + requestId, + code, + message, + retryable = false + }); + } +} diff --git a/MainWindow.NamedKrxThemeRestore.cs b/MainWindow.NamedKrxThemeRestore.cs new file mode 100644 index 0000000..2705a4e --- /dev/null +++ b/MainWindow.NamedKrxThemeRestore.cs @@ -0,0 +1,141 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private LegacyNamedKrxThemeRestoreService? _namedKrxThemeRestoreService; + + private async Task PostNamedKrxThemeRestoreAsync( + string requestId, + IReadOnlyList items, + CancellationToken cancellationToken) + { + var candidates = items + .Select(static item => new LegacyNamedKrxThemeRestoreRow( + item.ItemIndex, + item.IsEnabled, + item.Selection.GroupCode, + item.Selection.Subject, + item.Selection.GraphicType, + item.Selection.Subtype, + item.Page?.ToString() ?? string.Empty, + item.Selection.DataCode)) + .Where(LegacyNamedKrxThemeRestoreService.IsCandidate) + .ToArray(); + if (candidates.Length == 0) + { + return; + } + + try + { + var runtime = _databaseRuntime; + if (runtime is null) + { + PostNamedKrxThemeRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 Oracle DB를 사용할 수 없어 저장된 KRX 테마를 재검증하지 못했습니다."); + return; + } + + var service = _namedKrxThemeRestoreService ??= + new LegacyNamedKrxThemeRestoreService(runtime.Executor); + var result = await service.ResolveAsync(candidates, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PostMessage("named-krx-theme-restore-results", new + { + requestId, + result.RetrievedAt, + totalRowCount = result.Rows.Count, + rows = result.Rows.Select(ToNamedKrxThemeRestoreWireRow) + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 && + !_lifetimeCancellation.IsCancellationRequested) + { + PostNamedKrxThemeRestoreError( + requestId, + "PLAYOUT_PRIORITY", + "송출 명령 우선 처리로 KRX 테마 재검증을 취소했습니다. 자동 재시도하지 않습니다."); + } + + throw; + } + catch (DatabaseOperationException) + { + PostNamedKrxThemeRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 Oracle DB에서 KRX 테마를 재검증하지 못했습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostNamedKrxThemeRestoreError( + requestId, + "RESTORE_FAILED", + "저장된 KRX 테마 행을 안전한 현재 선택으로 복원하지 못했습니다."); + } + } + + private static object ToNamedKrxThemeRestoreWireRow( + LegacyNamedKrxThemeRestoreOutcome outcome) + { + if (outcome.IsResolved) + { + var identity = outcome.Identity!; + return new + { + outcome.ItemIndex, + status = "resolved", + identity.ActionId, + identity.BuilderKey, + identity.CutCode, + identity.PageSize, + identity.Sort, + identity.ThemeTitle, + identity.StoredThemeCode, + identity.CurrentThemeCode, + identity.PreviewItemCount, + identity.PreviewIsTruncated, + identity.CodeWasRemapped + }; + } + + return new + { + outcome.ItemIndex, + status = "failed", + failure = outcome.Failure switch + { + LegacyNamedKrxThemeRestoreFailure.InvalidRow => "INVALID_ROW", + LegacyNamedKrxThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION", + LegacyNamedKrxThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND", + LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS", + LegacyNamedKrxThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY", + LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid => + "DATABASE_DATA_INVALID", + _ => "RESTORE_FAILED" + } + }; + } + + private void PostNamedKrxThemeRestoreError( + string requestId, + string code, + string message) + { + PostMessage("named-krx-theme-restore-error", new + { + requestId, + code, + message, + retryable = false + }); + } +} diff --git a/MainWindow.NamedPlaylists.cs b/MainWindow.NamedPlaylists.cs index 1bf3b2c..59f4902 100644 --- a/MainWindow.NamedPlaylists.cs +++ b/MainWindow.NamedPlaylists.cs @@ -126,13 +126,22 @@ public sealed partial class MainWindow requestId, result.Items, cancellationToken); + await PostNamedForeignStockRestoreAsync( + requestId, + result.Items, + cancellationToken); await PostNamedForeignIndexCandleRestoreAsync( requestId, result.Items, cancellationToken); + await PostNamedKrxThemeRestoreAsync( + requestId, + result.Items, + cancellationToken); // Keep all named restore batches correlated to this load. NXT - // theme verification runs last because it can perform both an - // exact identity lookup and a bounded item preview per title. + // theme verification runs last because it uses MariaDB after + // the Oracle-backed identity batches and performs a bounded + // item preview per title. await PostNamedNxtThemeRestoreAsync( requestId, result.Items, diff --git a/Package.appxmanifest b/Package.appxmanifest index 51959bb..0081f9a 100644 --- a/Package.appxmanifest +++ b/Package.appxmanifest @@ -10,7 +10,7 @@ + Version="1.0.4.0" /> diff --git a/Web/app.js b/Web/app.js index 2406966..218977c 100644 --- a/Web/app.js +++ b/Web/app.js @@ -35,8 +35,12 @@ if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable."); const namedComparisonRestoreWorkflow = globalThis.MbnNamedComparisonRestoreWorkflow; if (!namedComparisonRestoreWorkflow) throw new Error("Named comparison restore workflow module is unavailable."); + const namedForeignStockRestoreWorkflow = globalThis.MbnNamedForeignStockRestoreWorkflow; + if (!namedForeignStockRestoreWorkflow) throw new Error("Named foreign-stock restore workflow module is unavailable."); const foreignIndexCandleRestoreWorkflow = globalThis.MbnLegacyForeignIndexCandleWorkflow; if (!foreignIndexCandleRestoreWorkflow) throw new Error("Legacy foreign-index candle restore workflow module is unavailable."); + const namedKrxThemeRestoreWorkflow = globalThis.MbnNamedKrxThemeRestoreWorkflow; + if (!namedKrxThemeRestoreWorkflow) throw new Error("Named KRX-theme restore workflow module is unavailable."); const namedNxtThemeRestoreWorkflow = globalThis.MbnNamedNxtThemeRestoreWorkflow; if (!namedNxtThemeRestoreWorkflow) throw new Error("Named NXT-theme restore workflow module is unavailable."); const legacyNamedRowWorkflow = globalThis.MbnLegacyNamedRowWorkflow; @@ -223,7 +227,9 @@ guardByEntryId: new Map(), manualRestoreFinalized: true, comparisonRestorePending: null, + foreignStockRestorePending: null, foreignIndexCandleRestorePending: null, + krxThemeRestorePending: null, nxtThemeRestorePending: null, selectAfterRefresh: "" }, @@ -703,7 +709,9 @@ restoreRecords.every(record => record.status === "error"); if (state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending || state.namedPlaylist.comparisonRestorePending || + state.namedPlaylist.foreignStockRestorePending || state.namedPlaylist.foreignIndexCandleRestorePending || + state.namedPlaylist.krxThemeRestorePending || state.namedPlaylist.nxtThemeRestorePending || (restoreRecords.length > 0 && !(allowFailedManualRestore && failedRestoreOnly))) { showToast("DB 플레이리스트 복원과 현재 identity 재검증이 끝날 때까지 순서를 변경할 수 없습니다."); @@ -2423,9 +2431,12 @@ let invalid = false; state.playlist = state.playlist.map(item => { if (item?.operator?.source !== "legacy-theme-workflow") return item; - const refreshed = namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) - ? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date()) - : themeWorkflow.refreshThemePlaylistEntry(item); + const namedKrx = namedKrxThemeRestoreWorkflow.isMaterializedEntry(item); + const refreshed = namedKrx + ? item + : namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) + ? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date()) + : themeWorkflow.refreshThemePlaylistEntry(item); if (!refreshed) { invalid = true; return item; @@ -2436,10 +2447,10 @@ invalid = true; return item; } - // A native-verified named NXT entry already has the complete theme - // operator shape. Do not clone it here: its in-memory verification mark - // must survive until PREPARE/save validation. - return namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed) + // Native-verified named theme entries already have the complete operator + // shape. Do not clone them here: the in-memory verification mark must + // survive until PREPARE/save validation. + return namedKrx || namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed) ? refreshed : { ...definition, ...refreshed }; }); @@ -3532,6 +3543,10 @@ state.candleOptions.invalidIds = [...result.invalidIds]; state.playlist = [...result.playlist].map(item => { if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item; + // The named foreign-stock entry carries a strict native identity proof + // and original seven-field signature. Keep that exact branded object; + // catalog decoration would make its save round-trip unverifiable. + if (item.operator?.source === namedForeignStockRestoreWorkflow.source) return item; const definition = catalog.find(value => value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || ""))); return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item; @@ -3832,6 +3847,92 @@ showToast("플레이리스트에 추가했습니다."); } + function recreatePlaylistEntryWithEnabled(item, enabled) { + if (!item || typeof item !== "object" || typeof item.enabled !== "boolean" || + typeof enabled !== "boolean") return null; + if (item.enabled === enabled) return item; + + const operator = item.operator; + if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(item) || + operator?.namedKrxRestore !== undefined) { + return namedKrxThemeRestoreWorkflow.withEnabled(item, enabled); + } + if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) || + operator?.namedNxtRestore !== undefined) { + return namedNxtThemeRestoreWorkflow.withEnabled(item, enabled); + } + if (operator?.source === namedForeignStockRestoreWorkflow.source) { + return namedForeignStockRestoreWorkflow.withEnabled(item, enabled); + } + if (operator?.source === "legacy-foreign-index-candle-workflow") { + return foreignIndexCandleRestoreWorkflow.withEnabled(item, enabled); + } + if (operator?.source === "manual-financial-workflow") { + return manualFinancialWorkflow.withEnabled(item, enabled); + } + if (operator?.source === "legacy-manual-lists-workflow") { + return manualListsWorkflow.withEnabled(item, enabled); + } + return { ...item, enabled }; + } + + function playlistEntryManualEditBlockReason(item) { + if (!item || typeof item !== "object") return "missing-entry"; + if (item.operator?.source) return "trusted-workflow-entry"; + if (state.namedPlaylist.guardByEntryId.has(item.id)) return "named-playlist-entry"; + if (state.manualFinancialRestore.entries.has(item.id)) return "manual-restore-entry"; + return ""; + } + + function normalizeManualSceneSelection(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"]; + if (Object.keys(value).length !== keys.length || keys.some(key => !(key in value))) return null; + const selection = {}; + for (const key of keys) { + if (typeof value[key] !== "string") return null; + const text = value[key].trim(); + if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return null; + selection[key] = text; + } + return Object.freeze(selection); + } + + function recreatePlaylistEntryWithSceneSelection(item, selection, code, fadeDuration) { + if (playlistEntryManualEditBlockReason(item)) return null; + const normalizedSelection = normalizeManualSceneSelection(selection); + if (!normalizedSelection || typeof code !== "string" || !sceneAliases(item).includes(code) || + !Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) return null; + return { ...item, selection: normalizedSelection, code, fadeDuration }; + } + + function recreatePlaylistEntryWithLiveSelection(item, selection) { + if (playlistEntryManualEditBlockReason(item)) return null; + const normalizedSelection = normalizeManualSceneSelection(selection); + if (!normalizedSelection || (!normalizedSelection.subject && !normalizedSelection.dataCode)) return null; + return { + ...item, + selection: normalizedSelection, + subject: normalizedSelection.subject, + dataCode: normalizedSelection.dataCode + }; + } + + function replacePlaylistEntryAt(index, replacement) { + if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length || + !replacement || replacement.id !== state.playlist[index]?.id) return false; + const playlist = [...state.playlist]; + playlist[index] = replacement; + state.playlist = playlist; + return true; + } + + function replacePlaylistEntryEnabledAt(index, enabled) { + if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length) return null; + const replacement = recreatePlaylistEntryWithEnabled(state.playlist[index], enabled); + return replacement && replacePlaylistEntryAt(index, replacement) ? replacement : null; + } + function renderPlaylist() { renderGlobalCandleOptions(); elements.playlistBody.replaceChildren(); @@ -3874,8 +3975,14 @@ checkbox.checked = item.enabled !== false; return; } - item.enabled = checkbox.checked; - addLog(`${item.code} ${checkbox.checked ? "송출 포함" : "송출 제외"}`); + const replacement = replacePlaylistEntryEnabledAt(index, checkbox.checked); + if (!replacement) { + checkbox.checked = item.enabled !== false; + showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다."); + return; + } + renderPlaylist(); + addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`); }); checkCell.append(checkbox); @@ -3999,7 +4106,8 @@ function renderSceneSelectionForm(item) { const selection = item?.selection || {}; - const disabled = !item || isPlaylistSnapshotLocked() || Boolean(item.operator?.source); + const disabled = !item || isPlaylistSnapshotLocked() || + Boolean(playlistEntryManualEditBlockReason(item)); elements.sceneGroupCode.value = String(selection.groupCode || ""); elements.sceneSubject.value = String(selection.subject || ""); elements.sceneGraphicType.value = String(selection.graphicType || ""); @@ -4033,6 +4141,11 @@ if (!requirePlaylistEditable()) return; const item = state.playlist[state.currentIndex]; if (!item) return; + if (playlistEntryManualEditBlockReason(item)) { + renderSceneSelectionForm(item); + showToast("검증된 항목은 조회값을 직접 편집할 수 없습니다. 원본 입력 화면에서 다시 생성하세요."); + return; + } const values = [ elements.sceneGroupCode.value, elements.sceneSubject.value, @@ -4054,18 +4167,21 @@ showToast("허용되지 않은 CUT alias입니다."); return; } - item.selection = { + const replacement = recreatePlaylistEntryWithSceneSelection(item, { 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; + }, cutAlias, fadeDuration); + if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) { + renderSceneSelectionForm(item); + showToast("장면 조회값을 안전하게 적용할 수 없습니다."); + return; + } renderPlaylist(); updatePreview(); - addLog(`${item.code} 장면 조회값 적용`); + addLog(`${replacement.code} 장면 조회값 적용`); showToast("장면 조회값을 적용했습니다."); } @@ -4131,7 +4247,12 @@ if (!requirePlaylistEditable()) return; if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다."); const enable = state.playlist.some(item => item.enabled === false); - state.playlist.forEach(item => { item.enabled = enable; }); + const replacements = state.playlist.map(item => recreatePlaylistEntryWithEnabled(item, enable)); + if (replacements.some(item => !item)) { + showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다."); + return; + } + state.playlist = replacements; renderPlaylist(); addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`); } @@ -4222,9 +4343,13 @@ if (!requirePlaylistEditable()) return; const item = state.playlist[state.currentIndex]; if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요."); - item.enabled = item.enabled === false; + const replacement = replacePlaylistEntryEnabledAt(state.currentIndex, item.enabled === false); + if (!replacement) { + showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다."); + return; + } renderPlaylist(); - addLog(`${item.code} ${item.enabled ? "송출 포함" : "송출 제외"}`); + addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`); } function savePlaylist() { @@ -4284,6 +4409,16 @@ ? [{ ...themeDefinition, ...restoredThemeEntry }] : []; } + const restoredNamedForeignStockEntry = + namedForeignStockRestoreWorkflow.restorePlaylistEntry(item); + if (restoredNamedForeignStockEntry) { + const overseasDefinition = catalog.find(definition => + definition.builderKey === restoredNamedForeignStockEntry.builderKey && + sceneAliases(definition).includes(restoredNamedForeignStockEntry.code)); + return overseasDefinition + ? [{ ...overseasDefinition, ...restoredNamedForeignStockEntry }] + : []; + } const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item); if (restoredOverseasEntry) { const overseasDefinition = catalog.find(definition => @@ -4690,7 +4825,9 @@ return; } if (state.namedPlaylist.comparisonRestorePending || + state.namedPlaylist.foreignStockRestorePending || state.namedPlaylist.foreignIndexCandleRestorePending || + state.namedPlaylist.krxThemeRestorePending || state.namedPlaylist.nxtThemeRestorePending) return; const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration); const materialized = materializeNamedRestoration(restoration); @@ -4868,10 +5005,10 @@ } function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) { - // Historical NXT codes are not authoritative. Keep every original - // `테마_NXT` row untrusted until the correlated native MariaDB batch maps - // its exact title to the current unique code and proves a live item. - if (rawRow.groupCode === "테마_NXT") return; + // Historical theme codes are not authoritative. Keep every original KRX + // and NXT row untrusted until its correlated native batch maps the exact + // title to one current code and validates the item preview. + if (rawRow.groupCode === "테마" || rawRow.groupCode === "테마_NXT") return; let theme; let sort; let actionIds = themeWorkflow.actions.map(action => action.id); @@ -4994,6 +5131,10 @@ } function resolveNamedPlaylistRawRow(rawRow, itemIndex) { + // The stored theme code is historical evidence, not a playable identity. + // Route both legacy theme groups only through their correlated native + // verifiers; no stock/default candidate may accept the stale code first. + if (rawRow?.groupCode === "테마" || rawRow?.groupCode === "테마_NXT") return null; const candidates = []; const now = namedNowForRawRow(rawRow); addFixedNamedCandidates(candidates, rawRow, itemIndex, now); @@ -5053,7 +5194,9 @@ for (const row of restoration.rows) { const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" || row.entry?.operator?.source === "legacy-manual-lists-workflow" || + namedKrxThemeRestoreWorkflow.isMaterializedEntry(row.entry) || namedNxtThemeRestoreWorkflow.isMaterializedEntry(row.entry) || + namedForeignStockRestoreWorkflow.isMaterializedEntry(row.entry) || foreignIndexCandleRestoreWorkflow.isMaterializedEntry(row.entry); const entry = row.trusted ? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText }) @@ -5099,7 +5242,10 @@ state.namedPlaylist.pagePlanError = null; state.namedPlaylist.manualRestoreFinalized = true; state.namedPlaylist.comparisonRestorePending = null; + clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending); + state.namedPlaylist.foreignStockRestorePending = null; state.namedPlaylist.foreignIndexCandleRestorePending = null; + state.namedPlaylist.krxThemeRestorePending = null; state.namedPlaylist.nxtThemeRestorePending = null; } @@ -5173,7 +5319,9 @@ } return manualFields; } - const selection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) || + const selection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry) || + namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) || + namedForeignStockRestoreWorkflow.legacySelectionForEntry(entry) || legacyNamedRowWorkflow.legacySelectionForEntry(entry); if (!selection) { throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다."); @@ -5191,6 +5339,12 @@ pageText: fields[5], dataCode: fields[6] }; + if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) { + if (!namedKrxThemeRestoreWorkflow.matchesRaw(entry, rawRow)) { + throw new Error("KRX 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다."); + } + return fields; + } if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) { if (!namedNxtThemeRestoreWorkflow.matchesRaw(entry, rawRow)) { throw new Error("NXT 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다."); @@ -5215,6 +5369,15 @@ } return fields; } + if (entry.operator?.source === "legacy-named-foreign-stock-workflow") { + const restoredForeignStock = namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry); + if (!restoredForeignStock || restoredForeignStock.builderKey !== entry.builderKey || + restoredForeignStock.code !== entry.code || + !namedForeignStockRestoreWorkflow.matchesRaw(restoredForeignStock, rawRow)) { + throw new Error("해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다."); + } + return fields; + } const roundTrip = resolveNamedPlaylistRawRow(rawRow, itemIndex); if (!roundTrip || roundTrip.builderKey !== entry.builderKey || roundTrip.code !== entry.code) { throw new Error("원본 PList 7필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다."); @@ -5225,6 +5388,27 @@ function isTrustedNamedPlaylistEntry(entry, itemIndex = 0) { const guard = state.namedPlaylist.guardByEntryId.get(entry?.id); if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false; + if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) { + if (!guard || !guard.trusted) return false; + try { + const legacySelection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry); + const fields = namedPlaylistWorkflow.toLegacySevenFields( + { ...entry, selection: legacySelection }, + namedPageTextForEntry(entry)); + return namedKrxThemeRestoreWorkflow.matchesRaw(entry, { + itemIndex, + enabled: fields[0] === "1", + groupCode: fields[1], + subject: fields[2], + graphicType: fields[3], + subtype: fields[4], + pageText: fields[5], + dataCode: fields[6] + }); + } catch { + return false; + } + } if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) { if (!guard || !guard.trusted) return false; try { @@ -5248,19 +5432,24 @@ } if (entry?.operator?.source === "legacy-foreign-index-candle-workflow" && (!guard || !guard.trusted)) return false; + if (entry?.operator?.source === "legacy-named-foreign-stock-workflow" && + (!guard || !guard.trusted)) return false; if (namedManualRestoreWorkflow.isTrustedEntryForSave(entry, itemIndex)) return true; const selection = entry?.selection; if (!selection) return false; const now = namedNowForRawRow(selection); const restored = entry.operator?.source === "legacy-foreign-index-candle-workflow" ? [foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean) - : normalizeStoredPlaylist([entry], { now }); + : entry.operator?.source === "legacy-named-foreign-stock-workflow" + ? [namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean) + : normalizeStoredPlaylist([entry], { now }); if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey || restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false; if ([ "legacy-comparison-workflow", "legacy-industry-workflow", - "legacy-foreign-index-candle-workflow" + "legacy-foreign-index-candle-workflow", + "legacy-named-foreign-stock-workflow" ] .includes(entry.operator?.source)) { const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry); @@ -5287,7 +5476,9 @@ ? comparisonWorkflow.restoreComparisonPlaylistEntry(entry) : entry.operator.source === "legacy-industry-workflow" ? industryWorkflow.restoreIndustryPlaylistEntry(entry) - : foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry); + : entry.operator.source === "legacy-foreign-index-candle-workflow" + ? foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry) + : namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry); return Boolean(restoredClosedEntry && restoredClosedEntry.builderKey === entry.builderKey && restoredClosedEntry.code === entry.code && legacyNamedRowWorkflow.matchesCanonicalEntry(restoredClosedEntry, rawRow)); @@ -5319,7 +5510,9 @@ function namedPlaylistBusy() { return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending || state.namedPlaylist.comparisonRestorePending || + state.namedPlaylist.foreignStockRestorePending || state.namedPlaylist.foreignIndexCandleRestorePending || + state.namedPlaylist.krxThemeRestorePending || state.namedPlaylist.nxtThemeRestorePending || state.manualFinancialRestore.entries.size > 0); } @@ -5330,7 +5523,9 @@ const pending = workflow.pending || workflow.pagePlanPending; const freshRestorePending = state.manualFinancialRestore.entries.size > 0 || Boolean(workflow.comparisonRestorePending) || + Boolean(workflow.foreignStockRestorePending) || Boolean(workflow.foreignIndexCandleRestorePending) || + Boolean(workflow.krxThemeRestorePending) || Boolean(workflow.nxtThemeRestorePending); const busy = Boolean(pending) || freshRestorePending; const selected = selectedNamedPlaylistDefinition(); @@ -5619,6 +5814,16 @@ }); return pending ? [pending] : []; }); + const pendingForeignStocks = restoration.rows.flatMap(row => { + if (row.trusted) return []; + const pending = namedForeignStockRestoreWorkflow.classify(row.rawRow, { + id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`, + fadeDuration: DEFAULT_FADE_DURATION, + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }); + return pending ? [pending] : []; + }); const pendingForeignIndexCandles = restoration.rows.flatMap(row => { if (row.trusted) return []; const pending = foreignIndexCandleRestoreWorkflow.classify(row.rawRow, { @@ -5627,6 +5832,14 @@ }); return pending ? [pending] : []; }); + const pendingKrxThemes = restoration.rows.flatMap(row => { + if (row.trusted) return []; + const pending = namedKrxThemeRestoreWorkflow.classify(row.rawRow, { + id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`, + fadeDuration: DEFAULT_FADE_DURATION + }); + return pending ? [pending] : []; + }); const pendingNxtThemes = restoration.rows.flatMap(row => { if (row.trusted) return []; const pending = namedNxtThemeRestoreWorkflow.classify(row.rawRow, { @@ -5637,7 +5850,9 @@ }); state.namedPlaylist.manualRestoreFinalized = pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 && - pendingForeignIndexCandles.length === 0 && pendingNxtThemes.length === 0; + pendingForeignStocks.length === 0 && + pendingForeignIndexCandles.length === 0 && pendingKrxThemes.length === 0 && + pendingNxtThemes.length === 0; if (state.namedPlaylist.manualRestoreFinalized) { restoration = prepareNamedPagePreflight(restoration); } @@ -5652,12 +5867,26 @@ rows: Object.freeze(pendingNamedComparison) }) : null; + state.namedPlaylist.foreignStockRestorePending = pendingForeignStocks.length + ? { + requestId: load.requestId, + rows: Object.freeze(pendingForeignStocks), + timeoutId: null + } + : null; + armNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending); state.namedPlaylist.foreignIndexCandleRestorePending = pendingForeignIndexCandles.length ? Object.freeze({ requestId: load.requestId, rows: Object.freeze(pendingForeignIndexCandles) }) : null; + state.namedPlaylist.krxThemeRestorePending = pendingKrxThemes.length + ? Object.freeze({ + requestId: load.requestId, + rows: Object.freeze(pendingKrxThemes) + }) + : null; state.namedPlaylist.nxtThemeRestorePending = pendingNxtThemes.length ? Object.freeze({ requestId: load.requestId, @@ -5776,6 +6005,115 @@ error?.message || "비교 행의 현재 종목 마스터 재검증을 완료하지 못했습니다."); } + function clearNamedForeignStockRestoreTimeout(record) { + if (record?.timeoutId !== null && record?.timeoutId !== undefined) { + clearTimeout(record.timeoutId); + record.timeoutId = null; + } + } + + function armNamedForeignStockRestoreTimeout(record) { + if (!record) return; + clearNamedForeignStockRestoreTimeout(record); + record.timeoutId = setTimeout(() => { + if (state.namedPlaylist.foreignStockRestorePending !== record) return; + failNamedForeignStockRestore( + record, + "해외종목 identity 재검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다."); + }, 30000); + } + + function failNamedForeignStockRestore(record, message) { + if (!record || state.namedPlaylist.foreignStockRestorePending !== record) return; + clearNamedForeignStockRestoreTimeout(record); + state.namedPlaylist.foreignStockRestorePending = null; + state.namedPlaylist.error = message; + state.namedPlaylist.status = "error"; + finalizeNamedManualRestores(); + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedForeignStockRestoreResults(payload) { + const record = state.namedPlaylist.foreignStockRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const response = namedForeignStockRestoreWorkflow.normalizeResponse( + payload, + record.requestId, + record.rows); + if (!response) { + failNamedForeignStockRestore( + record, + "Native Bridge의 해외종목 재검증 응답이 현재 DB 목록과 일치하지 않습니다."); + return; + } + const restoration = state.namedPlaylist.restoration; + if (!restoration || isPlaylistSnapshotLocked()) { + failNamedForeignStockRestore( + record, + "해외종목 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다."); + return; + } + + const rows = [...restoration.rows]; + let restoredCount = 0; + const failures = new Map(); + for (let index = 0; index < response.rows.length; index += 1) { + const pending = record.rows[index]; + const outcome = response.rows[index]; + if (outcome.status === "failed") { + failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1); + continue; + } + const storedRow = rows[pending.itemIndex]; + const entry = namedForeignStockRestoreWorkflow.materialize(pending, outcome); + if (!storedRow || storedRow.trusted || !entry || + !namedForeignStockRestoreWorkflow.isMaterializedEntry(entry) || + state.playlist[pending.itemIndex]?.id !== pending.entry.id || + entry.enabled !== storedRow.rawRow.enabled || + !namedForeignStockRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) { + failures.set("MATERIALIZATION_MISMATCH", + (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1); + continue; + } + rows[pending.itemIndex] = Object.freeze({ + ...storedRow, + entry, + trusted: true + }); + state.playlist[pending.itemIndex] = entry; + restoredCount += 1; + } + state.namedPlaylist.restoration = Object.freeze({ + ...restoration, + rows: Object.freeze(rows) + }); + clearNamedForeignStockRestoreTimeout(record); + state.namedPlaylist.foreignStockRestorePending = null; + addLog(`DB 해외종목 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`); + if (failures.size) { + const summary = [...failures.entries()] + .map(([code, count]) => `${code} ${count}`) + .join(" · "); + addLog(`DB 해외종목 차단 사유 · ${summary}`); + } + finalizeNamedManualRestores(); + renderPlaylist(); + updatePreview(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedForeignStockRestoreError(payload) { + const record = state.namedPlaylist.foreignStockRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const error = namedForeignStockRestoreWorkflow.normalizeError(payload, record.requestId); + failNamedForeignStockRestore( + record, + error?.message || "해외종목의 현재 Oracle identity 재검증을 완료하지 못했습니다."); + } + function failNamedForeignIndexCandleRestore(record, message) { if (!record || state.namedPlaylist.foreignIndexCandleRestorePending !== record) return; state.namedPlaylist.foreignIndexCandleRestorePending = null; @@ -5865,6 +6203,97 @@ error?.message || "해외지수 캔들의 현재 master 재검증을 완료하지 못했습니다."); } + function failNamedKrxThemeRestore(record, message) { + if (!record || state.namedPlaylist.krxThemeRestorePending !== record) return; + state.namedPlaylist.krxThemeRestorePending = null; + state.namedPlaylist.error = message; + state.namedPlaylist.status = "error"; + finalizeNamedManualRestores(); + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedKrxThemeRestoreResults(payload) { + const record = state.namedPlaylist.krxThemeRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const response = namedKrxThemeRestoreWorkflow.normalizeResponse( + payload, + record.requestId, + record.rows); + if (!response) { + failNamedKrxThemeRestore( + record, + "Native Bridge의 KRX 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다."); + return; + } + const restoration = state.namedPlaylist.restoration; + if (!restoration || isPlaylistSnapshotLocked()) { + failNamedKrxThemeRestore( + record, + "KRX 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다."); + return; + } + + const rows = [...restoration.rows]; + let restoredCount = 0; + let remappedCount = 0; + const failures = new Map(); + for (let index = 0; index < response.rows.length; index += 1) { + const pending = record.rows[index]; + const outcome = response.rows[index]; + if (outcome.status === "failed") { + failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1); + continue; + } + const storedRow = rows[pending.itemIndex]; + const entry = namedKrxThemeRestoreWorkflow.materialize(pending, outcome); + if (!storedRow || storedRow.trusted || !entry || + !namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry) || + state.playlist[pending.itemIndex]?.id !== pending.entry.id || + entry.enabled !== storedRow.rawRow.enabled || + !namedKrxThemeRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) { + failures.set("MATERIALIZATION_MISMATCH", + (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1); + continue; + } + rows[pending.itemIndex] = Object.freeze({ + ...storedRow, + entry, + trusted: true + }); + state.playlist[pending.itemIndex] = entry; + restoredCount += 1; + if (outcome.codeWasRemapped) remappedCount += 1; + } + state.namedPlaylist.restoration = Object.freeze({ + ...restoration, + rows: Object.freeze(rows) + }); + state.namedPlaylist.krxThemeRestorePending = null; + addLog(`DB KRX 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}건`); + if (failures.size) { + const summary = [...failures.entries()] + .map(([code, count]) => `${code} ${count}`) + .join(" · "); + addLog(`DB KRX 테마 차단 사유 · ${summary}`); + } + finalizeNamedManualRestores(); + renderPlaylist(); + updatePreview(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedKrxThemeRestoreError(payload) { + const record = state.namedPlaylist.krxThemeRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const error = namedKrxThemeRestoreWorkflow.normalizeError(payload, record.requestId); + failNamedKrxThemeRestore( + record, + error?.message || "KRX 테마의 현재 Oracle identity 재검증을 완료하지 못했습니다."); + } + function failNamedNxtThemeRestore(record, message) { if (!record || state.namedPlaylist.nxtThemeRestorePending !== record) return; state.namedPlaylist.nxtThemeRestorePending = null; @@ -6084,7 +6513,48 @@ function handleNamedPlaylistError(payload) { const error = namedPlaylistWorkflow.normalizeError(payload); const pending = state.namedPlaylist.pending; - if (!error || !pending || error.requestId !== pending.requestId || error.operation !== pending.operation) return; + if (!error) return; + if (!pending || error.requestId !== pending.requestId || error.operation !== pending.operation) { + // A load result is posted before its correlated restore batches. If the + // native read is then canceled (for example by playout priority), the + // primary load is no longer pending but one or more restore batches can + // still be waiting. Close every matching batch exactly once so PREPARE + // stays blocked by the untrusted rows instead of an orphaned busy flag. + if (error.operation !== "load") return; + const restoreRecords = [ + state.namedPlaylist.comparisonRestorePending, + state.namedPlaylist.foreignStockRestorePending, + state.namedPlaylist.foreignIndexCandleRestorePending, + state.namedPlaylist.krxThemeRestorePending, + state.namedPlaylist.nxtThemeRestorePending + ]; + if (!restoreRecords.some(record => record?.requestId === error.requestId)) return; + if (state.namedPlaylist.comparisonRestorePending?.requestId === error.requestId) { + state.namedPlaylist.comparisonRestorePending = null; + } + if (state.namedPlaylist.foreignStockRestorePending?.requestId === error.requestId) { + clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending); + state.namedPlaylist.foreignStockRestorePending = null; + } + if (state.namedPlaylist.foreignIndexCandleRestorePending?.requestId === error.requestId) { + state.namedPlaylist.foreignIndexCandleRestorePending = null; + } + if (state.namedPlaylist.krxThemeRestorePending?.requestId === error.requestId) { + state.namedPlaylist.krxThemeRestorePending = null; + } + if (state.namedPlaylist.nxtThemeRestorePending?.requestId === error.requestId) { + state.namedPlaylist.nxtThemeRestorePending = null; + } + state.namedPlaylist.status = "error"; + state.namedPlaylist.error = `${error.message} [${error.code}] 복원 배치는 자동 재시도하지 않습니다.`; + finalizeNamedManualRestores(); + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + addLog(`DB 플레이리스트 LOAD 후속 복원 종료 · ${error.code}`); + showToast(state.namedPlaylist.error); + return; + } state.namedPlaylist.pending = null; state.namedPlaylist.status = "error"; state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined; @@ -7132,11 +7602,16 @@ } function applyLiveRowToCurrentCut(table, row, columns) { + if (!requirePlaylistEditable()) return; const item = state.playlist[state.currentIndex]; if (!item) { showToast("먼저 적용할 플레이리스트 장면을 선택하세요."); return; } + if (playlistEntryManualEditBlockReason(item)) { + showToast("검증된 항목에는 DB 행을 직접 덮어쓸 수 없습니다. 원본 입력 화면에서 다시 생성하세요."); + return; + } const values = liveRowValues(row, columns); const subject = firstLiveValue(values, [ @@ -7152,18 +7627,20 @@ } const previousSelection = item.selection || {}; - item.selection = { + const replacement = recreatePlaylistEntryWithLiveSelection(item, { 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; + }); + if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) { + showToast("선택한 DB 행을 현재 장면에 안전하게 적용할 수 없습니다."); + return; + } renderPlaylist(); updatePreview(); - addLog(`${item.code} DB 선택 적용 · ${item.selection.subject}`); + addLog(`${replacement.code} DB 선택 적용 · ${replacement.selection.subject}`); showToast("선택한 DB 행을 현재 장면에 적용했습니다."); } @@ -7490,12 +7967,24 @@ case "named-comparison-restore-error": handleNamedComparisonRestoreError(message.payload); break; + case "named-foreign-stock-restore-results": + handleNamedForeignStockRestoreResults(message.payload); + break; + case "named-foreign-stock-restore-error": + handleNamedForeignStockRestoreError(message.payload); + break; case "named-foreign-index-candle-restore-results": handleNamedForeignIndexCandleRestoreResults(message.payload); break; case "named-foreign-index-candle-restore-error": handleNamedForeignIndexCandleRestoreError(message.payload); break; + case "named-krx-theme-restore-results": + handleNamedKrxThemeRestoreResults(message.payload); + break; + case "named-krx-theme-restore-error": + handleNamedKrxThemeRestoreError(message.payload); + break; case "named-nxt-theme-restore-results": handleNamedNxtThemeRestoreResults(message.payload); break; diff --git a/Web/candle-options-workflow.js b/Web/candle-options-workflow.js index 6b25562..a9aa4b7 100644 --- a/Web/candle-options-workflow.js +++ b/Web/candle-options-workflow.js @@ -13,6 +13,7 @@ "legacy-fixed-workflow", "legacy-industry-workflow", "legacy-overseas-workflow", + "legacy-named-foreign-stock-workflow", "legacy-trading-halt-workflow" ]); @@ -84,6 +85,24 @@ options); break; } + case "legacy-named-foreign-stock-workflow": { + const action = adapters.overseas.stockActions.find(item => item.id === operator.actionId); + if (operator.kind !== "stock" || action?.kind !== "candle" || + !operator.rawSelection || typeof operator.verifiedAt !== "string") return null; + const canonical = adapters.overseas.createOverseasStockPlaylistEntry( + operator.identity, + action.id, + options); + recreated = { + ...canonical, + operator: Object.freeze({ + ...operator, + identity: canonical.operator.identity, + movingAverages: canonical.operator.movingAverages + }) + }; + break; + } case "legacy-trading-halt-workflow": { const action = adapters.tradingHalt.getAction(operator.actionId); if (action?.kind !== "candle") return null; diff --git a/Web/index.html b/Web/index.html index 006005a..fac20d0 100644 --- a/Web/index.html +++ b/Web/index.html @@ -570,7 +570,9 @@ + + diff --git a/Web/legacy-foreign-index-candle-workflow.js b/Web/legacy-foreign-index-candle-workflow.js index 0950e94..3d051eb 100644 --- a/Web/legacy-foreign-index-candle-workflow.js +++ b/Web/legacy-foreign-index-candle-workflow.js @@ -308,6 +308,14 @@ }) : null; } + function withEnabled(entry, enabled) { + if (!materializedEntries.has(entry) || typeof enabled !== "boolean") return null; + if (entry.enabled === enabled) return entry; + const replacement = Object.freeze({ ...entry, enabled }); + materializedEntries.add(replacement); + return replacement; + } + function matchesRaw(entry, raw) { const signature = legacySelectionForEntry(entry); return Boolean(signature && raw && typeof raw.enabled === "boolean" && @@ -333,6 +341,7 @@ normalizeError, materialize, restorePlaylistEntry, + withEnabled, legacySelectionForEntry, matchesRaw, isPending, diff --git a/Web/legacy-named-row-workflow.js b/Web/legacy-named-row-workflow.js index 3e75381..4a899d6 100644 --- a/Web/legacy-named-row-workflow.js +++ b/Web/legacy-named-row-workflow.js @@ -22,6 +22,9 @@ const foreignIndexCandleWorkflow = typeof module === "object" && module.exports ? require("./legacy-foreign-index-candle-workflow.js") : root.MbnLegacyForeignIndexCandleWorkflow; + const foreignStockWorkflow = typeof module === "object" && module.exports + ? require("./named-foreign-stock-restore-workflow.js") + : root.MbnNamedForeignStockRestoreWorkflow; const api = Object.freeze(factory( stockWorkflow, fixedWorkflow, @@ -29,7 +32,8 @@ expertWorkflow, comparisonWorkflow, industryWorkflow, - foreignIndexCandleWorkflow)); + foreignIndexCandleWorkflow, + foreignStockWorkflow)); if (typeof module === "object" && module.exports) module.exports = api; if (root) root.MbnLegacyNamedRowWorkflow = api; })(typeof globalThis === "object" ? globalThis : this, function ( @@ -39,11 +43,13 @@ expertWorkflow, comparisonWorkflow, industryWorkflow, - foreignIndexCandleWorkflow) { + foreignIndexCandleWorkflow, + foreignStockWorkflow) { "use strict"; if (!stockWorkflow || !fixedWorkflow || !themeWorkflow || !expertWorkflow || - !comparisonWorkflow || !industryWorkflow || !foreignIndexCandleWorkflow) { + !comparisonWorkflow || !industryWorkflow || !foreignIndexCandleWorkflow || + !foreignStockWorkflow) { throw new Error("Legacy named-row dependencies are unavailable."); } @@ -382,6 +388,8 @@ break; case "legacy-foreign-index-candle-workflow": return foreignIndexCandleWorkflow.matchesRaw(entry, rawRow); + case "legacy-named-foreign-stock-workflow": + return foreignStockWorkflow.matchesRaw(entry, rawRow); default: return false; } @@ -415,6 +423,8 @@ return industryWorkflow.restoreIndustryPlaylistEntry(entry); case "legacy-foreign-index-candle-workflow": return foreignIndexCandleWorkflow.restorePlaylistEntry(entry); + case "legacy-named-foreign-stock-workflow": + return foreignStockWorkflow.restorePlaylistEntry(entry); default: return null; } @@ -445,6 +455,8 @@ return industryFixedLegacySignature(trusted.operator); case "legacy-foreign-index-candle-workflow": return foreignIndexCandleWorkflow.legacySelectionForEntry(trusted); + case "legacy-named-foreign-stock-workflow": + return foreignStockWorkflow.legacySelectionForEntry(trusted); default: return null; } diff --git a/Web/manual-financial-workflow.js b/Web/manual-financial-workflow.js index e867dbc..6642e8f 100644 --- a/Web/manual-financial-workflow.js +++ b/Web/manual-financial-workflow.js @@ -997,6 +997,14 @@ return !!value && trustedPlaylistEntries.has(value); } + function withEnabled(entry, enabled) { + if (!trustedPlaylistEntries.has(entry) || typeof enabled !== "boolean") return null; + if (entry.enabled === enabled) return entry; + const replacement = Object.freeze({ ...entry, enabled }); + trustedPlaylistEntries.add(replacement); + return replacement; + } + return { bridgeContract, sourceContract, @@ -1028,6 +1036,7 @@ createRestoreLoadRequest, createRestoreSelectionRequest, materializeRestoredPlaylistEntry, + withEnabled, isPlaylistEntryPlayable }; }); diff --git a/Web/manual-lists-workflow.js b/Web/manual-lists-workflow.js index 618054b..2ab824f 100644 --- a/Web/manual-lists-workflow.js +++ b/Web/manual-lists-workflow.js @@ -441,6 +441,16 @@ return recreated; } + function withEnabled(entry, enabled) { + const freshRead = trustedPlaylistEntries.get(entry); + if (!freshRead || typeof enabled !== "boolean") return null; + if (entry.enabled === enabled) return entry; + const replacement = restorePlaylistEntry({ ...entry, enabled }); + if (!replacement) return null; + trustedPlaylistEntries.set(replacement, freshRead); + return replacement; + } + return { bridgeContract, audiences, @@ -471,6 +481,7 @@ createViPlaylistEntry, isPlaylistEntryPlayable, restorePlaylistEntry, + withEnabled, refreshPlaylistEntry: restorePlaylistEntry }; }); diff --git a/Web/named-foreign-stock-restore-workflow.js b/Web/named-foreign-stock-restore-workflow.js new file mode 100644 index 0000000..d9fd1cd --- /dev/null +++ b/Web/named-foreign-stock-restore-workflow.js @@ -0,0 +1,402 @@ +(function (root, factory) { + "use strict"; + + const overseas = typeof module === "object" && module.exports + ? require("./overseas-workflow.js") + : root.MbnOverseasWorkflow; + const api = Object.freeze(factory(overseas)); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnNamedForeignStockRestoreWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function (overseas) { + "use strict"; + + if (!overseas) throw new Error("Overseas workflow is unavailable."); + + const SCHEMA_VERSION = 1; + const SOURCE = "legacy-named-foreign-stock-workflow"; + const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; + const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + const pendingValues = new WeakSet(); + const resolvedValues = new WeakSet(); + const materializedEntries = new WeakSet(); + const failureCodes = new Set([ + "INVALID_ROW", + "IDENTITY_NOT_FOUND", + "IDENTITY_AMBIGUOUS", + "DATABASE_DATA_INVALID", + "RESTORE_FAILED" + ]); + + const actionProfiles = Object.freeze([ + Object.freeze({ + actionId: "stock-current", + graphicType: "1열판기본", + subtype: "", + builderKey: "s5001", + cutCode: "5001", + valueType: "CURRENT", + periodDays: null + }), + Object.freeze({ + actionId: "stock-candle-5d", + graphicType: "캔들그래프", + subtype: "5일", + builderKey: "s8010", + cutCode: "8061", + valueType: "PRICE", + periodDays: 5 + }), + Object.freeze({ + actionId: "stock-candle-20d", + graphicType: "캔들그래프", + subtype: "20일", + builderKey: "s8010", + cutCode: "8040", + valueType: "PRICE", + periodDays: 20 + }), + Object.freeze({ + actionId: "stock-candle-60d", + graphicType: "캔들그래프", + subtype: "60일", + builderKey: "s8010", + cutCode: "8046", + valueType: "PRICE", + periodDays: 60 + }), + Object.freeze({ + actionId: "stock-candle-120d", + graphicType: "캔들그래프", + subtype: "120일", + builderKey: "s8010", + cutCode: "8051", + valueType: "PRICE", + periodDays: 120 + }), + Object.freeze({ + actionId: "stock-candle-240d", + graphicType: "캔들그래프", + subtype: "240일", + builderKey: "s8010", + cutCode: "8056", + valueType: "PRICE", + periodDays: 240 + }) + ]); + const profilesByAction = new Map(actionProfiles.map(value => [value.actionId, value])); + const profilesByRaw = new Map(actionProfiles.map(value => [ + `${value.graphicType}\u0000${value.subtype}`, + value + ])); + + const bridgeContract = Object.freeze({ + resultType: "named-foreign-stock-restore-results", + errorType: "named-foreign-stock-restore-error" + }); + + function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); + } + + function hasExactKeys(value, keys) { + if (!isPlainObject(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); + } + + function safeText(value, maximumLength, allowEmpty = false) { + return typeof value === "string" && value.length <= maximumLength && + (allowEmpty || value.length > 0) && value === value.trim() && + !value.includes("^") && !unsafeTextPattern.test(value); + } + + function requireOptions(options) { + if (!hasExactKeys(options, ["id", "fadeDuration", "ma5", "ma20"]) || + !requestIdPattern.test(options.id) || + !Number.isInteger(options.fadeDuration) || + options.fadeDuration < 0 || options.fadeDuration > 60 || + typeof options.ma5 !== "boolean" || typeof options.ma20 !== "boolean") return null; + return Object.freeze({ ...options }); + } + + function rawSelection(raw) { + return Object.freeze({ + groupCode: raw.groupCode, + subject: raw.subject, + graphicType: raw.graphicType, + subtype: raw.subtype, + dataCode: raw.dataCode + }); + } + + function profileForRaw(raw) { + return profilesByRaw.get(`${raw?.graphicType}\u0000${raw?.subtype}`) || null; + } + + function safeRaw(raw) { + return isPlainObject(raw) && Number.isInteger(raw.itemIndex) && + raw.itemIndex >= 0 && raw.itemIndex < 1000 && + typeof raw.enabled === "boolean" && raw.groupCode === "해외종목" && + safeText(raw.subject, 200) && raw.pageText === "1/1" && raw.dataCode === "" && + safeText(raw.graphicType, 256) && safeText(raw.subtype, 256, true) && + safeText(raw.pageText, 9) && safeText(raw.dataCode, 1024, true) && + Boolean(profileForRaw(raw)); + } + + function classify(raw, options) { + const normalizedOptions = requireOptions(options); + const profile = profileForRaw(raw); + if (!normalizedOptions || !safeRaw(raw) || !profile) return null; + const movingAverages = Object.freeze(profile.builderKey === "s8010" + ? { ma5: normalizedOptions.ma5, ma20: normalizedOptions.ma20 } + : { ma5: false, ma20: false }); + const pending = Object.freeze({ + itemIndex: raw.itemIndex, + enabled: raw.enabled, + actionId: profile.actionId, + builderKey: profile.builderKey, + cutCode: profile.cutCode, + valueType: profile.valueType, + periodDays: profile.periodDays, + inputName: raw.subject, + entry: Object.freeze({ id: normalizedOptions.id }), + fadeDuration: normalizedOptions.fadeDuration, + movingAverages, + rawSelection: rawSelection(raw) + }); + pendingValues.add(pending); + return pending; + } + + function normalizeOutcome(value, pending, verifiedAt) { + if (!pendingValues.has(pending) || !isPlainObject(value) || + value.itemIndex !== pending.itemIndex) return null; + if (value.status === "failed") { + if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) || + !failureCodes.has(value.failure)) return null; + return Object.freeze({ ...value }); + } + if (value.status !== "resolved" || !hasExactKeys(value, [ + "itemIndex", "status", "actionId", "inputName", "symbol", "nationCode", + "foreignDomesticCode", "builderKey", "cutCode", "valueType", "periodDays" + ]) || value.actionId !== pending.actionId || value.inputName !== pending.inputName || + value.foreignDomesticCode !== "1" || value.builderKey !== pending.builderKey || + value.cutCode !== pending.cutCode || value.valueType !== pending.valueType || + value.periodDays !== pending.periodDays) return null; + const identity = overseas.normalizeStock({ + inputName: value.inputName, + symbol: value.symbol, + nationCode: value.nationCode, + fdtc: value.foreignDomesticCode + }); + if (!identity) return null; + const resolved = Object.freeze({ + itemIndex: value.itemIndex, + status: "resolved", + actionId: value.actionId, + inputName: identity.inputName, + symbol: identity.symbol, + nationCode: identity.nationCode, + foreignDomesticCode: identity.fdtc, + builderKey: value.builderKey, + cutCode: value.cutCode, + valueType: value.valueType, + periodDays: value.periodDays, + verifiedAt + }); + resolvedValues.add(resolved); + return resolved; + } + + function normalizeResponse(value, expectedRequestId, pendingRows) { + if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) || + pendingRows.length < 1 || pendingRows.length > 1000 || + pendingRows.some(pending => !pendingValues.has(pending)) || + !hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) || + value.requestId !== expectedRequestId || typeof value.retrievedAt !== "string" || + !isoTimestampPattern.test(value.retrievedAt) || !Number.isFinite(Date.parse(value.retrievedAt)) || + value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) || + value.rows.length !== pendingRows.length) return null; + const rows = []; + for (let index = 0; index < pendingRows.length; index += 1) { + const outcome = normalizeOutcome(value.rows[index], pendingRows[index], value.retrievedAt); + if (!outcome) return null; + rows.push(outcome); + } + return Object.freeze({ + requestId: value.requestId, + retrievedAt: value.retrievedAt, + rows: Object.freeze(rows) + }); + } + + function normalizeError(value, expectedRequestId) { + return requestIdPattern.test(expectedRequestId) && + hasExactKeys(value, ["requestId", "code", "message", "retryable"]) && + value.requestId === expectedRequestId && + ["PLAYOUT_PRIORITY", "DATABASE_UNAVAILABLE", "RESTORE_FAILED"].includes(value.code) && + safeText(value.message, 1024) && value.retryable === false + ? Object.freeze({ ...value }) + : null; + } + + function customOperator(canonical, raw, verifiedAt) { + return Object.freeze({ + source: SOURCE, + schemaVersion: SCHEMA_VERSION, + kind: "stock", + actionId: canonical.operator.actionId, + identity: canonical.operator.identity, + movingAverages: canonical.operator.movingAverages, + rawSelection: raw, + verifiedAt + }); + } + + function buildEntry(pending, outcome) { + const canonical = overseas.createOverseasStockPlaylistEntry({ + inputName: outcome.inputName, + symbol: outcome.symbol, + nationCode: outcome.nationCode, + fdtc: outcome.foreignDomesticCode + }, outcome.actionId, { + id: pending.entry.id, + fadeDuration: pending.fadeDuration, + ma5: pending.movingAverages.ma5, + ma20: pending.movingAverages.ma20 + }); + if (canonical.builderKey !== outcome.builderKey || canonical.code !== outcome.cutCode) return null; + return Object.freeze({ + ...canonical, + enabled: pending.enabled, + operator: customOperator(canonical, pending.rawSelection, outcome.verifiedAt) + }); + } + + function materialize(pending, outcome) { + if (!pendingValues.has(pending) || !resolvedValues.has(outcome) || + outcome.itemIndex !== pending.itemIndex || outcome.actionId !== pending.actionId) return null; + let entry; + try { + entry = buildEntry(pending, outcome); + } catch { + return null; + } + if (!entry) return null; + materializedEntries.add(entry); + return entry; + } + + function sameSelection(left, right) { + return hasExactKeys(left, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) && + hasExactKeys(right, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) && + ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => left[key] === right[key]); + } + + function restorePlaylistEntry(value) { + if (!hasExactKeys(value, [ + "id", "builderKey", "code", "aliases", "title", "detail", "category", "market", + "symbol", "nationCode", "fdtc", "selection", "enabled", "fadeDuration", + "graphicType", "subtype", "operator" + ]) || typeof value.enabled !== "boolean" || + !isPlainObject(value.operator) || !hasExactKeys(value.operator, [ + "source", "schemaVersion", "kind", "actionId", "identity", "movingAverages", + "rawSelection", "verifiedAt" + ]) || value.operator.source !== SOURCE || + value.operator.schemaVersion !== SCHEMA_VERSION || value.operator.kind !== "stock" || + !profilesByAction.has(value.operator.actionId) || + !hasExactKeys(value.operator.identity, ["inputName", "symbol", "nationCode", "fdtc"]) || + !hasExactKeys(value.operator.movingAverages, ["ma5", "ma20"]) || + typeof value.operator.movingAverages.ma5 !== "boolean" || + typeof value.operator.movingAverages.ma20 !== "boolean" || + typeof value.operator.verifiedAt !== "string" || + !isoTimestampPattern.test(value.operator.verifiedAt) || + !Number.isFinite(Date.parse(value.operator.verifiedAt))) return null; + const identity = overseas.normalizeStock(value.operator.identity); + const raw = value.operator.rawSelection; + const profile = profileForRaw(raw); + if (!identity || !profile || profile.actionId !== value.operator.actionId || + !hasExactKeys(raw, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) || + raw.groupCode !== "해외종목" || raw.subject !== identity.inputName || raw.dataCode !== "" || + !safeText(raw.subject, 200) || + (profile.builderKey !== "s8010" && + (value.operator.movingAverages.ma5 || value.operator.movingAverages.ma20))) return null; + + let canonical; + try { + canonical = overseas.createOverseasStockPlaylistEntry( + identity, + profile.actionId, + { + id: value.id, + fadeDuration: value.fadeDuration, + ma5: value.operator.movingAverages.ma5, + ma20: value.operator.movingAverages.ma20 + }); + } catch { + return null; + } + const rebuilt = Object.freeze({ + ...canonical, + enabled: value.enabled, + operator: customOperator(canonical, raw, value.operator.verifiedAt) + }); + const scalarKeys = [ + "id", "builderKey", "code", "title", "detail", "category", "market", "symbol", + "nationCode", "fdtc", "enabled", "fadeDuration", "graphicType", "subtype" + ]; + if (!scalarKeys.every(key => value[key] === rebuilt[key]) || + !Array.isArray(value.aliases) || value.aliases.length !== 1 || + value.aliases[0] !== rebuilt.code || !sameSelection(value.selection, rebuilt.selection) || + JSON.stringify(value.operator) !== JSON.stringify(rebuilt.operator)) return null; + materializedEntries.add(rebuilt); + return rebuilt; + } + + function legacySelectionForEntry(entry) { + const restored = restorePlaylistEntry(entry); + return restored ? restored.operator.rawSelection : null; + } + + function withEnabled(entry, enabled) { + if (!materializedEntries.has(entry) || typeof enabled !== "boolean") return null; + if (entry.enabled === enabled) return entry; + const replacement = Object.freeze({ ...entry, enabled }); + materializedEntries.add(replacement); + return replacement; + } + + function matchesRaw(entry, raw) { + const signature = legacySelectionForEntry(entry); + return Boolean(signature && safeRaw(raw) && entry.enabled === raw.enabled && + ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => signature[key] === raw[key])); + } + + function isPending(value) { + return pendingValues.has(value); + } + + function isMaterializedEntry(value) { + return materializedEntries.has(value); + } + + return { + source: SOURCE, + bridgeContract, + actionProfiles, + classify, + normalizeResponse, + normalizeError, + materialize, + restorePlaylistEntry, + withEnabled, + legacySelectionForEntry, + matchesRaw, + isPending, + isMaterializedEntry + }; +}); diff --git a/Web/named-krx-theme-restore-workflow.js b/Web/named-krx-theme-restore-workflow.js new file mode 100644 index 0000000..083db01 --- /dev/null +++ b/Web/named-krx-theme-restore-workflow.js @@ -0,0 +1,279 @@ +(function (root, factory) { + "use strict"; + + const themes = typeof module === "object" && module.exports + ? require("./theme-workflow.js") + : root.MbnThemeWorkflow; + const api = Object.freeze(factory(themes)); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnNamedKrxThemeRestoreWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function (themes) { + "use strict"; + + if (!themes) throw new Error("Theme workflow is unavailable."); + + const SCHEMA_VERSION = 1; + const MAXIMUM_PREVIEW_ITEMS = 240; + const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; + const themeCodePattern = /^[0-9]{3,8}$/; + const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/; + const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; + const pendingValues = new WeakSet(); + const resolvedValues = new WeakSet(); + const materializedValues = new WeakSet(); + const failureCodes = new Set([ + "INVALID_ROW", + "UNSUPPORTED_ACTION", + "IDENTITY_NOT_FOUND", + "IDENTITY_AMBIGUOUS", + "PREVIEW_EMPTY", + "DATABASE_DATA_INVALID", + "RESTORE_FAILED" + ]); + const profiles = Object.freeze({ + "five-row-current": Object.freeze({ builderKey: "s5074", cutCode: "5074", pageSize: 5 }), + "five-row-expected": Object.freeze({ builderKey: "s5074", cutCode: "5074", pageSize: 5 }), + "six-row-current": Object.freeze({ builderKey: "s5077", cutCode: "5077", pageSize: 6 }), + "twelve-row-current": Object.freeze({ builderKey: "s5088", cutCode: "5088", pageSize: 12 }) + }); + const subtypes = Object.freeze({ + "테마-현재가(입력순)": Object.freeze({ valueKind: "CURRENT", sort: "INPUT_ORDER" }), + "테마-현재가(상승률순)": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_DESC" }), + "테마-현재가(하락률순)": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_ASC" }), + "테마-현재가": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_ASC" }), + "테마-예상체결가(입력순)": Object.freeze({ valueKind: "EXPECTED", sort: "INPUT_ORDER" }), + "테마-예상체결가(상승률순)": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_DESC" }), + "테마-예상체결가(하락률순)": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_ASC" }), + "테마-예상체결가": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_ASC" }) + }); + const actionIds = Object.freeze({ + "5단 표그래프|CURRENT": "five-row-current", + "5단 표그래프|EXPECTED": "five-row-expected", + "6종목 현재가|CURRENT": "six-row-current", + "12종목 현재가|CURRENT": "twelve-row-current" + }); + + const bridgeContract = Object.freeze({ + resultType: "named-krx-theme-restore-results", + errorType: "named-krx-theme-restore-error" + }); + + function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); + } + + function hasExactKeys(value, keys) { + if (!isPlainObject(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); + } + + function safeText(value, maximumLength, allowEmpty = false) { + return typeof value === "string" && value.length <= maximumLength && + (allowEmpty || value.length > 0) && value === value.trim() && + !value.includes("^") && !unsafeTextPattern.test(value); + } + + function safeRaw(raw) { + return isPlainObject(raw) && typeof raw.enabled === "boolean" && + Number.isInteger(raw.itemIndex) && raw.itemIndex >= 0 && raw.itemIndex < 1000 && + safeText(raw.groupCode, 256) && safeText(raw.subject, 256, true) && + safeText(raw.graphicType, 256, true) && safeText(raw.subtype, 256, true) && + safeText(raw.pageText, 9, true) && safeText(raw.dataCode, 256, true); + } + + function expectedResolvedProfile(rawSelection) { + const subtype = subtypes[rawSelection?.subtype]; + const actionId = subtype + ? actionIds[`${rawSelection.graphicType}|${subtype.valueKind}`] + : null; + return actionId ? Object.freeze({ actionId, sort: subtype.sort }) : null; + } + + // Route every exact group row. Malformed and unsupported rows remain a + // correlated native request so a stale code can never escape via fallback. + function classify(raw, options = {}) { + if (!safeRaw(raw) || raw.groupCode !== "테마" || + !requestIdPattern.test(options.id) || !Number.isInteger(options.fadeDuration) || + options.fadeDuration < 0 || options.fadeDuration > 60) return null; + const pending = Object.freeze({ + itemIndex: raw.itemIndex, + enabled: raw.enabled, + fadeDuration: options.fadeDuration, + entry: Object.freeze({ id: options.id }), + rawSelection: Object.freeze({ + groupCode: raw.groupCode, + subject: raw.subject, + graphicType: raw.graphicType, + subtype: raw.subtype, + dataCode: raw.dataCode + }) + }); + pendingValues.add(pending); + return pending; + } + + function normalizeOutcome(value, pending) { + if (!pendingValues.has(pending) || !isPlainObject(value) || + value.itemIndex !== pending.itemIndex) return null; + if (value.status === "failed") { + if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) || + !failureCodes.has(value.failure)) return null; + return Object.freeze({ ...value }); + } + + const keys = [ + "itemIndex", "status", "actionId", "builderKey", "cutCode", "pageSize", "sort", + "themeTitle", "storedThemeCode", "currentThemeCode", "previewItemCount", + "previewIsTruncated", "codeWasRemapped" + ]; + if (value.status !== "resolved" || !hasExactKeys(value, keys)) return null; + const profile = profiles[value.actionId]; + const expected = expectedResolvedProfile(pending.rawSelection); + if (!profile || !expected || value.actionId !== expected.actionId || + value.sort !== expected.sort || value.builderKey !== profile.builderKey || + value.cutCode !== profile.cutCode || value.pageSize !== profile.pageSize || + !safeText(value.themeTitle, 128) || value.themeTitle.includes("(NXT)") || + pending.rawSelection.subject !== value.themeTitle || + !themeCodePattern.test(value.storedThemeCode) || + value.storedThemeCode !== pending.rawSelection.dataCode || + !themeCodePattern.test(value.currentThemeCode) || + value.codeWasRemapped !== (value.storedThemeCode !== value.currentThemeCode) || + !Number.isInteger(value.previewItemCount) || value.previewItemCount < 1 || + value.previewItemCount > MAXIMUM_PREVIEW_ITEMS || + typeof value.previewIsTruncated !== "boolean" || + value.previewIsTruncated && value.previewItemCount !== MAXIMUM_PREVIEW_ITEMS) return null; + const normalized = Object.freeze({ ...value }); + resolvedValues.add(normalized); + return normalized; + } + + function normalizeResponse(value, expectedRequestId, pendingRows) { + if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) || + pendingRows.length < 1 || pendingRows.length > 1000 || + pendingRows.some(pending => !pendingValues.has(pending)) || + !hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) || + value.requestId !== expectedRequestId || typeof value.retrievedAt !== "string" || + !isoTimestampPattern.test(value.retrievedAt) || !Number.isFinite(Date.parse(value.retrievedAt)) || + value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) || + value.rows.length !== pendingRows.length) return null; + const rows = []; + for (let index = 0; index < pendingRows.length; index += 1) { + const outcome = normalizeOutcome(value.rows[index], pendingRows[index]); + if (!outcome) return null; + rows.push(outcome); + } + return Object.freeze({ + requestId: value.requestId, + retrievedAt: value.retrievedAt, + rows: Object.freeze(rows) + }); + } + + function normalizeError(value, expectedRequestId) { + return requestIdPattern.test(expectedRequestId) && + hasExactKeys(value, ["requestId", "code", "message", "retryable"]) && + value.requestId === expectedRequestId && safeText(value.code, 64) && + safeText(value.message, 1024) && value.retryable === false + ? Object.freeze({ ...value }) + : null; + } + + function materialize(pending, outcome) { + if (!pendingValues.has(pending) || !resolvedValues.has(outcome) || + outcome?.status !== "resolved" || + outcome.itemIndex !== pending.itemIndex) return null; + try { + const created = themes.createThemePlaylistEntry( + outcome.actionId, + { + market: "krx", + themeCode: outcome.currentThemeCode, + title: outcome.themeTitle, + session: null, + itemCount: outcome.previewItemCount + }, + outcome.sort, + { id: pending.entry.id, fadeDuration: pending.fadeDuration }); + const entry = themes.restoreThemePlaylistEntry({ + ...created, + enabled: pending.enabled, + operator: Object.freeze({ + ...created.operator, + namedKrxRestore: Object.freeze({ + schemaVersion: SCHEMA_VERSION, + rawSelection: pending.rawSelection, + currentThemeCode: outcome.currentThemeCode, + previewItemCount: outcome.previewItemCount, + previewIsTruncated: outcome.previewIsTruncated, + codeWasRemapped: outcome.codeWasRemapped + }) + }) + }); + if (!entry) return null; + // The generic theme restore validates and recreates the closed entry. + // Add the correlated native proof only after that round-trip succeeds. + const materialized = Object.freeze({ + ...entry, + operator: Object.freeze({ + ...entry.operator, + namedKrxRestore: Object.freeze({ + schemaVersion: SCHEMA_VERSION, + rawSelection: pending.rawSelection, + currentThemeCode: outcome.currentThemeCode, + previewItemCount: outcome.previewItemCount, + previewIsTruncated: outcome.previewIsTruncated, + codeWasRemapped: outcome.codeWasRemapped + }) + }) + }); + materializedValues.add(materialized); + return materialized; + } catch { + return null; + } + } + + function matchesRaw(entry, raw) { + if (!materializedValues.has(entry) || !safeRaw(raw)) return false; + const saved = entry.operator?.namedKrxRestore?.rawSelection; + return saved && raw.enabled === entry.enabled && + ["groupCode", "subject", "graphicType", "subtype", "dataCode"] + .every(key => saved[key] === raw[key]); + } + + function legacySelectionForEntry(entry) { + if (!materializedValues.has(entry)) return null; + return entry.operator.namedKrxRestore.rawSelection; + } + + function withEnabled(entry, enabled) { + if (!materializedValues.has(entry) || typeof enabled !== "boolean") return null; + if (entry.enabled === enabled) return entry; + const replacement = Object.freeze({ ...entry, enabled }); + materializedValues.add(replacement); + return replacement; + } + + function isPending(value) { + return pendingValues.has(value); + } + + function isMaterializedEntry(value) { + return materializedValues.has(value); + } + + return { + bridgeContract, + classify, + normalizeResponse, + normalizeError, + materialize, + withEnabled, + matchesRaw, + legacySelectionForEntry, + isPending, + isMaterializedEntry + }; +}); diff --git a/Web/named-nxt-theme-restore-workflow.js b/Web/named-nxt-theme-restore-workflow.js index b459203..8e31c88 100644 --- a/Web/named-nxt-theme-restore-workflow.js +++ b/Web/named-nxt-theme-restore-workflow.js @@ -240,6 +240,14 @@ return entry.operator.namedNxtRestore.rawSelection; } + function withEnabled(entry, enabled) { + if (!materializedValues.has(entry) || typeof enabled !== "boolean") return null; + if (entry.enabled === enabled) return entry; + const replacement = Object.freeze({ ...entry, enabled }); + materializedValues.add(replacement); + return replacement; + } + function isPending(value) { return pendingValues.has(value); } @@ -255,6 +263,7 @@ normalizeError, materialize, refreshDynamicSession, + withEnabled, matchesRaw, legacySelectionForEntry, isPending, diff --git a/Web/overseas-workflow.js b/Web/overseas-workflow.js index 420d8ec..b7c768a 100644 --- a/Web/overseas-workflow.js +++ b/Web/overseas-workflow.js @@ -13,7 +13,12 @@ const MAXIMUM_QUERY_LENGTH = 64; const SCHEMA_VERSION = 1; const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; - const symbolPattern = /^[A-Za-z0-9@._$:-]{1,64}$/; + // F_SYMB is provider identity data, not executable syntax. Current Oracle + // contains Hangul-letter symbols with internal ASCII spaces; keep letters and + // numbers Unicode-aware and allow only that observed space plus a closed + // punctuation set. Canonical trim and this pattern still exclude edge space, + // `|`, `^`, controls and arbitrary punctuation from the dataCode identity. + const symbolPattern = /^[\p{L}\p{N}@._$:\x20-]{1,64}$/u; const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; const bridgeContracts = Object.freeze({ diff --git a/docs/EXTERNAL_ASSET_SEARCH_AUDIT.md b/docs/EXTERNAL_ASSET_SEARCH_AUDIT.md new file mode 100644 index 0000000..3de9964 --- /dev/null +++ b/docs/EXTERNAL_ASSET_SEARCH_AUDIT.md @@ -0,0 +1,64 @@ +# 외부 송출 자산 검색 감사 (2026-07-12) + +## 결론 + +현재 장비의 `D:` 및 `E:`에서 아래 운영 대상 15개와 정확히 일치하는 실제 +파일은 발견되지 않았다. 압축파일은 추출하지 않고 목차만 읽었으며, 그 안에서도 +동일 이름의 항목은 발견되지 않았다. 따라서 다른 프로젝트의 `.vrv`나 테스트 +fixture를 승인 Cuts 또는 배경 root에 복사해 대체해서는 안 된다. + +- `큐브배경.vrv` +- `기본.vrv` +- `20201008_미국.vrv` +- `20201008_독일.vrv` +- `20201008_영국.vrv` +- `20201008_프랑스.vrv` +- `20201008_일본.vrv` +- `20201008_중국.vrv` +- `20201008_홍콩.vrv` +- `20201008_대만.vrv` +- `20201008_싱가포르.vrv` +- `20201008_태국.vrv` +- `20201008_필리핀.vrv` +- `20201008_말레이시아.vrv` +- `20201008_인도네시아.vrv` + +## 검색 범위와 결과 + +검색일은 2026-07-12, 대상 볼륨은 로컬 `D:`와 `E:`다. 검색은 읽기 전용으로 +수행했고 `$RECYCLE.BIN`과 `System Volume Information`은 최종 수치에서 +제외했다. 조사 중 자산 파일 쓰기·복사·추출은 0건이다. + +| 범위 | 조사 결과 | 정확한 대상 일치 | +| --- | ---: | ---: | +| `D:` 실제 `.vrv` | 233개 | 0개 | +| `E:` 실제 `.vrv` | 452개 | 0개 | +| `D:` ZIP | 2,967개 중 2,955개, 215,416 entry 읽음 | 0개 | +| `E:` ZIP | 7,313개 중 7,310개, 863,696 entry 읽음 | 0개 | +| `D:` 7z/RAR/TAR/TGZ | 93개 중 92개, 1,093 entry 읽음 | 0개 | +| `E:` 7z/RAR/TAR/TGZ | 5개 모두, 10,858 entry 읽음 | 0개 | + +`MBN`, `V-Stock`, `매일경제`, `증권정보`, `MBN_STOCK` 경로명으로 별도 확인한 +결과도 `D:`의 무관한 `MBN Candle그래프.PNG` 1개뿐이었고 `E:`는 0개였다. + +읽지 못한 ZIP은 `D:`의 게임기 BIOS 12개, `E:`의 만화책 2개 및 아마야구 PSD +묶음 1개다. 읽지 못한 기타 archive 1개는 `D:`의 Adobe Photoshop 설치 +RAR이다. 파일 경로와 용도가 이번 MBN 송출 자산과 무관하므로 이 예외가 대상 +자산 존재 가능성을 남기지는 않는다. + +## C: 선행 검색과의 합산 판정 + +선행 검색에서는 `C:`의 사용자/K3D/Tornado/ProgramData/Program Files 범위에 +실제 대상 자산이 없었고, `C:\Tornado2`에는 무관한 `Weather.vrv` 1개만 있었다. +임시 폴더에서 발견된 대상명 1-byte 파일 14개는 자동 테스트 fixture이므로 운영 +자산으로 사용할 수 없다. + +이에 따라 현재 외부 의존성 판정은 그대로다. + +- `s5006`: 승인 Cuts의 `Video\큐브배경.vrv` 제공 전 차단 +- `s6001`: 승인 Cuts의 국가별 `Video\20201008_<국가>.vrv` 13개 제공 전 차단 +- F2/F3 기본 배경: 별도 신뢰 배경 root의 `기본.vrv` 제공 전 차단 + +자산은 원 제작/운영 보관처에서 승인된 원본으로 제공받아야 하며, 제공 후 기존 +cut coverage와 opened-handle/reparse/hardlink 검사를 다시 통과해야 한다. +외부에서 승인된 원본이 제공되기 전에는 위 세 경로를 모두 fail closed로 유지한다. diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md index 6443402..966c428 100644 --- a/docs/LEGACY_FEATURE_AUDIT.md +++ b/docs/LEGACY_FEATURE_AUDIT.md @@ -29,17 +29,17 @@ | LF-006 | P1 | 시스템 종료 확인 | 완료 | `MainWindow.CloseConfirmation.cs`, `EnsureCloseConfirmationAttached`, `OnAppWindowClosing`, `BuildCloseConfirmationMessage`, `DetachCloseConfirmation`; 연결부 `MainWindow.xaml.cs:54-63,101-112,OnClosed`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:384-408` | 모든 시스템 close를 먼저 취소하고 기본 버튼이 취소인 확인창을 연다. PREPARED/PROGRAM, command pending, OutcomeUnknown에 맞는 경고를 표시하며 종료 과정에서 TAKE OUT이나 반대 명령을 자동 실행하지 않는다. | Web 통합 테스트를 실행하고 패키지 앱에서 idle, prepared/on-air 경고, 취소, 명시적 종료를 수동 확인한다. | | LF-007 | P1 | 패키지 앱 단일 인스턴스 | 완료 | `App.xaml.cs:9-57`, `OnLaunched`, `OnMainInstanceActivated`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:72-88` | `AppInstance.FindOrRegisterForKey`가 MainWindow와 DB/WebView/playout runtime 생성 전에 실행된다. 두 번째 인스턴스는 activation을 primary로 전달한 뒤 종료하며 기존 프로세스를 이름으로 찾아 강제 종료하지 않는다. | 통합 테스트 후 설치된 MSIX를 두 번 실행해 프로세스 하나, 창 하나, 기존 창 활성화를 확인한다. | | LF-008 | P1 | 운영자 모달의 전역 단축키 격리 | 완료 | `Web/app.js:6808-6845`, `hasOpenOperatorModal`, `isolateOperatorModalShortcut`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:410-429` | GraphE, FSell/VI, ThemeA/EList 등 modal이 열려 있으면 F2/F3/F8/Escape, Home/End/Delete/Space, Ctrl+K가 workspace 송출·playlist handler로 전달되지 않는다. F8/Escape에는 modal을 먼저 닫으라는 안내를 표시한다. | Web 통합 테스트 후 각 modal 입력 필드에서 해당 키를 눌러 playout command와 playlist mutation이 발생하지 않는지 확인한다. | -| LF-009 | P1 | PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원 | 적용 중 | 원본 `MBN_STOCK_N/Form/PList.cs:81-124`, `data_load`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs:442-540,742-787`, `LoadAsync`/`ParseItem`; `Web/named-playlist-workflow.js:334-412`, `restoreRawRows`; `Web/app.js:4317-4619,5017-5052` | 운영 Oracle read-only 집계에서 활성 정의 23개와 연결된 2,213행은 모두 정확히 7필드였고 canonical ASCII group은 0행, malformed/null은 0행이었다. 즉 전 행이 기존 한국어 형식이므로 legacy→canonical 복원이 필수다. 삭제된 정의에 연결된 orphan 88개 프로그램/9,369행은 현재 UI load 대상이 아니다. 닫힌 매퍼와 fresh identity 재검증을 보강 중이며 전체 2,213행의 unique/blocked 판정이 끝나기 전에는 완료로 보지 않는다. | 실제 read-only PList 행을 원본과 새 앱에서 같은 프로그램 코드로 읽어 `DC_TITLE`과 caret 7개 필드를 글자 단위로 비교한다. `�` 또는 mojibake가 없어야 하며 trusted restore와 fresh page plan 전에는 PREPARE가 계속 차단돼야 한다. raw 값은 로그·문서에 출력하지 않고 분류 건수만 기록한다. | +| LF-009 | P1 | PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원 | 적용 중 | 원본 `MBN_STOCK_N/Form/PList.cs:81-124`, `data_load`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`; KRX/해외종목/manual 전용 restore와 테스트; 상세 `docs/NAMED_PLAYLIST_READ_AUDIT.md` | 실제 Oracle 활성 정의 23개와 2,213행 모두 정확히 7필드이고 원문 재결합, 한국어 제목·필드, enabled, page 공백 제거 및 typed persistence가 일치했다. 기본 감사는 KRX 94행 current-code remap과 해외종목 78/78을 포함해 `selectionMapped=1,581`, 차단 632다. 별도 GraphE fresh 감사가 manual 36행 중 22행을 materialize하고 저장값 무효 9행·identity 중복 5행을 차단해 두 증거의 통합 결과는 `selectionMapped=1,603`, 차단 610이다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니다. | 집중 테스트, Release 실제 read-only DB 감사와 전체 `REAL_DB_SMOKE: PASS`. FSell/VI PList 행은 0건이지만 운영 production read는 FSell 3×5행, VI 9항목·2 page이고 파일 전후 불변이다. raw 값·인증정보 출력, DB write, Tornado/PGM 명령은 0건이다. 남은 KRX/NXT/live-data·GraphE identity/data·unique mapper 차단을 해소하고 scene/asset/package/PGM gate를 별도로 통과하기 전에는 전체 복원 완료로 올리지 않는다. | | LF-010 | P1 | 원본 F2/F3 배경 디렉터리 동등성 | 코드 적용 / 외부자산·패키지 검증 대기 | 원본 `MBN_STOCK_N/MainForm.cs:4008-4054`, `btnback_Click`, `ckback_CheckedChanged`; 현재 `MainWindow.Background.cs`, `PlayoutSceneCompositionFactory`, `TrustedPlayoutAssetPath`, `ValidatedPlayoutOptions`; 문서 `docs/TRUSTED_BACKGROUND_ROOT.md` | Cuts gate를 유지한 채 별도 `LegacyBackgroundDirectory`를 추가했고, 생략 시 원본처럼 `SceneDirectory\..\배경`을 계산한다. F2는 유효할 때만 picker 전 `기본.vrv`를 복원하고 F3-on은 항상 기본 파일을 다시 검증한다. root escape·조상/파일 reparse·hardlink·누락·opened-handle final path를 fail closed로 검사하며 Cuts fallback은 없다. 이 장비의 원본 `bin\Debug\배경`과 `기본.vrv`는 실제로 없어 현재 기본 배경은 잠긴다. | Core Debug/Release 1,259/1,259, Playout Debug/Release 387/387과 앱 Debug/Release x64 build를 통과했다. signed 패키지에서 별도 root 상태 메시지와 F2/F3를 확인하고, 자산 제공 뒤 다음 PREPARE mutation/PGM은 승인 회차에서 검증한다. | -| LF-011 | P1 | `종목비교.dat` 기존 저장쌍 이전 | 적용 중 | 원본 `MBN_STOCK_N/Control/UC3.cs:34-55,181-185`, `UC3_Load`, `Data_Load`, `File_Save`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs`, `MainWindow.LegacyComparisonImport.cs`, `Web/comparison-import-workflow.js`, `Web/app.js`; 테스트 `LegacyComparisonPairImportServiceTests.cs`, `comparison-import-workflow.test.cjs`, `comparison-import-app-integration.test.cjs` | 원본 파일은 CP949 358바이트, 비어 있지 않은 8행, 각 행 정확히 9필드이며 0~3열만 값이 있다는 감사 fixture를 SHA-256 `1EE76BC4…BFC0D2F2`로 고정했다. Web이 경로를 전달하지 않는 고정 read-only source, reparse/크기/2회 quiescent snapshot 검사, strict CP949/9필드 parser, 현재 Oracle/MariaDB 종목·해외 identity exact read, 고정 시장명과 해외 동명이름 충돌 차단, 전체 중복·500쌍 경계, 명시적 버튼과 one-time marker/rollback을 구현했다. 원본은 수정하지 않고 성공 뒤 WebView private `localStorage` schema만 사용한다. 이 장비의 실제 설정으로 동일 서비스 read-only smoke를 실행해 8행 전부와 SHA-256이 일치하고 모든 identity가 해소됨을 확인했다. 다만 패키지 UI 버튼→localStorage→재시작 marker까지의 1회 실행은 아직 하지 않았으므로 완료로 올리지 않는다. | 실제 패키지에서 명시적 버튼 1회로 8행을 모두 fresh DB 재검증하고 local schema/hash/순서를 확인한다. 같은 profile의 두 번째 실행은 marker로 차단되고, 원본 변경·손상·동명이름·기존쌍 중복은 부분 저장 없이 전체 fail closed여야 한다. | -| LF-012 | P1 | FSell/VI 원본 `Data`의 안전한 이전과 설정 문서 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:338-339`, `FSell.cs:31-88`, `VIList.cs:31-34,137-139`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyManualOperatorDataImport.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/LegacyManualOperatorDataImporter.cs`, `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, `Web/manual-lists-ui.js` | UserProfile 아래 고정 원본 경로만 read-only로 두 번 읽고 strict CP949, FSell 파일별 5행+종료 레코드, VI 9열·최대 100건·4,000바이트를 검사한다. 명시적 확인 뒤 private store가 완전히 비어 있을 때만 4개 staged 파일을 no-overwrite로 옮기고 production parser readback 후 v1 marker를 마지막에 기록한다. rollback을 확정하지 못하면 OutcomeUnknown quarantine이며 자동 재시도하지 않는다. 실제 source smoke는 FSell 15행·VI 9건·aggregate SHA-256 `fd39a363…a2114`였고, 이 장비의 private 4개 파일은 이미 원본과 각각 byte-identical이어서 `DestinationNotEmpty`로 source read 전 중단했으며 전후 SHA가 같았다. | 새 빈 package profile fixture에서 버튼→4파일→marker→재시작 차단을 확인한다. 현재 운영 profile에서는 기존 동일 파일을 덮어쓰거나 marker를 임의 생성하지 않는다. | +| LF-011 | P1 | `종목비교.dat` 기존 저장쌍 이전 | 완료 | 원본 `MBN_STOCK_N/Control/UC3.cs:34-55,181-185`, `UC3_Load`, `Data_Load`, `File_Save`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs`, `MainWindow.LegacyComparisonImport.cs`, `Web/comparison-import-workflow.js`, `Web/app.js`; 테스트 `LegacyComparisonPairImportServiceTests.cs`, `comparison-import-workflow.test.cjs`, `comparison-import-app-integration.test.cjs`; 실행 증거 `docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md` | 원본 CP949 358바이트·8행·9필드와 SHA-256 `1EE76BC4…BFC0D2F2`를 고정하고, 고정 read-only source·strict parser·현재 Oracle/MariaDB exact identity·전체 원자 저장·one-time marker를 적용했다. 설치된 signed 1.0.3 package에서 빈 상태 `0 PAIRS`를 확인한 뒤 명시적 버튼을 정확히 한 번 실행해 8쌍을 저장했다. 정상 종료·재시작 후에도 `8 PAIRS`, 원본 순서의 8행과 완료형 `원본 8쌍 가져옴` gate가 유지됐고, 원본 파일 SHA-256은 불변이었다. 원본·DB·Tornado에는 쓰기나 명령을 보내지 않았다. | Core 20/20, Web 12/12, 실제 read-only DB identity 8/8, package 0→8 및 재시작 marker 영속성 PASS. 손상·동명이름·기존쌍 중복·부분 실패는 계속 전체 fail closed이며 package family data를 초기화해 같은 회차를 재현하지 않는다. | +| LF-012 | P1 | FSell/VI 원본 `Data`의 안전한 이전과 설정 문서 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:338-339`, `FSell.cs:31-88`, `VIList.cs:31-34,137-139`; importer `LegacyManualOperatorDataImport`, `LegacyManualOperatorDataImporter`; production read `S5025TrustedManualFileDataSource`, `ViTrustedManualFileStore`; 감사 `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`, `docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md` | 고정 원본 경로·strict CP949·source/hash 재검증·no-overwrite staged commit·marker-last·OutcomeUnknown quarantine를 유지한다. 격리 fixture는 FSell 15행·VI 9건, aggregate SHA-256 `fd39a363…a2114`, 첫 실행 성공과 두 번째 `AlreadyImported`를 확인했다. 운영 profile은 이미 원본과 byte-identical한 4파일이 있어 importer가 `DestinationNotEmpty`로 차단되며 marker/intent를 만들지 않는다. production opened-handle read는 FSell 3 audience 각각 5행, VI 9항목·2 page와 안정된 두 번째 version을 확인했고 감사 전후 4파일은 불변이다. 현재 PList에는 FSell/VI 행이 0건이다. | 새 빈 signed package profile의 명시적 UI importer 검증은 남아 있다. 현재 운영 profile의 동일 파일을 덮어쓰거나 marker를 임의 생성하지 않는다. PList에 향후 FSell/VI 행이 생기면 같은 fresh source/version gate를 거치며 scene data·asset·PREPARE·PGM은 별도 검증한다. | | LF-013 | P1 | generic native `bridge-error` 표시 | 완료 | producer `MainWindow.xaml.cs`, `MainWindow.Playout.cs`; consumer `Web/app.js` `handleBridgeError`/`handleNativeMessage`; 테스트 `operator-ui-app-integration.test.cjs` | bounded toast/log로 소비하며 malformed status/quarantine payload에서 playout state를 변경하지 않는다. 전체 Web 371/371과 signed 1.0.3 패키지 반영을 확인했다. | 새 native error producer를 추가할 때마다 같은 bounded consumer 계약을 유지한다. | | LF-014 | P2 | 영구 운영 로그 | 완료 | 원본 `MBN_STOCK_N/MainForm.cs:84-180`; 현재 `NativeOperatorLogWriter.cs`, `NativeOperatorLogTransitions.cs`, `MainWindow.NativeOperatorLog.cs` | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Logs\Log_MMdd.log`에 폐쇄 event/state만 기록하며 4MiB/31일, 중복 억제, reparse/handle 검증, single terminal/OutcomeUnknown 규칙을 적용한다. signed 1.0.3 package context에서 Startup→DryRunReady→Oracle/MariaDB Healthy→Shutdown→재시작이 같은 파일에 잔존했고 SQL·경로·인증·requestId 금지 문자열은 0건이었다. | 운영 회차에서도 로그를 명령 재시도 근거로 사용하지 않고 authoritative 출력과 함께 판독한다. | | LF-015 | P2 | 호출자가 없는 native bridge handler | 완료 | `MainWindow.xaml.cs`, `OnWebMessageReceived`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs` | Web 호출자가 없던 `open-external`과 `reload` message case를 native allowlist에서 제거했다. 외부 navigation/new-window 처리와 WebView process failure의 내부 reload 복구 메서드는 별도 native event 경로로 유지한다. | 정적 계약 테스트에서 두 message case와 Web 발신 literal이 모두 없음을 확인했다. | | LF-016 | P2 | 실제 preview 이미지·영상 | 외부자산 필요 | `docs/MIGRATION.md:73-74`; 원본 `MBN_STOCK_N/MainForm.cs:1448-1462,1894-1911,2919-2921` | 현재 preview는 안전한 DTO/mutation 텍스트 중심이다. 실제 이미지·영상 연결은 후속 자산이다. 다만 원본 thumbnail 호출과 watcher는 주석 또는 미호출이므로 활성 원본 동등성의 P0/P1 blocker로 보지 않는다. | 승인된 preview 자산이 생기면 asset root와 노출 정보 검사를 거쳐 패키지 화면에서 확인한다. | | LF-017 | P1 | 모든 업무 탭에서 계속 보이는 종목 검색·31컷 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.Designer.cs:2022-2036,2454-2462`; 현재 `Web/index.html`의 독립 `stock-panel`, `Web/app.js`의 `renderStockWorkflow`; 테스트 `operator-ui-app-integration.test.cjs` | 종목 검색·31컷을 catalog panel 밖의 독립 열로 옮기고 모든 업무 메뉴에서 항상 표시한다. 1920 브라우저에서 11개 메뉴를 이동해 검색어, 표시, 31개 행이 유지되고 문서 가로 overflow가 없음을 확인했다. | signed MSIX에서 실제 DB 검색 결과·선택 종목이 모든 탭 전환 뒤 유지되고 컷 추가가 같은 identity를 쓰는지 확인한 뒤 완료로 올린다. | | LF-018 | P2 | 원본 트리·컷 목록 일괄 추가 제스처 | 적용 중 | 원본 부모 double-click `MBN_STOCK_N/Control/UC1.cs:141-180`; 소스 컷 Ctrl/Shift 선택 `MBN_STOCK_N/MainForm.cs:3427-3525,3534-3549,3635-3643`; 현재 `Web/legacy-batch-selection-workflow.js`, `Web/app.js`; 테스트 `legacy-batch-selection-*.test.cjs` | 종목 31컷의 plain/Ctrl/Shift/Ctrl+Shift 선택, 단일 double-click, `선택 컷 추가`를 포팅했다. 해외·환율·지수 fixed source의 섹션 `전체 추가`는 모든 자식을 먼저 materialize한 뒤 원본 순서로 한 번에 commit하며, 수동/지원불가가 한 건이라도 있으면 부분 추가 없이 전체 차단한다. 실제 브라우저에서 5개 선택, 해외 43개 순서 추가, 수동 혼합 섹션 0건 추가를 확인했다. | signed MSIX 확인 뒤 업종·비교·테마 등 전용 source group의 부모 전체 추가가 원본에서 실제로 필요한 범위를 계속 분리 포팅한다. source drag/drop 자체는 오조작 검증 뒤 결정한다. | -| LF-019 | P1 | 저장 PList의 `테마_NXT` stale code 복원 | 적용 중 | 원본 `Control/UC4.cs`, `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`; 현재 `LegacyNamedNxtThemeRestoreService`, `MainWindow.NamedNxtThemeRestore.cs`, `Web/named-nxt-theme-restore-workflow.js`; 상세 `docs/NAMED_NXT_THEME_RESTORE.md` | 원본처럼 제목 exact lookup으로 현재 Maria code를 찾고 현재가 전용 5/6/12행, 정렬 fallback, 00~12 PRE/13~23 AFTER, fresh PageN을 닫힌 batch로 복원한다. 실제 활성 48행/24제목은 모두 current identity가 유일하고 저장 code 48개가 전부 stale였지만 현재 preview가 전부 비어 있어 48행 모두 `PreviewEmpty`로 정상 차단됐다. KRX 테마는 후보가 아니다. | Maria `SB_ITEM`/`v_all_stock` live 연계가 복구된 뒤 같은 read-only audit에서 한 행 이상 live item을 확인하고, fresh page preflight와 Debug/Release/MSIX UI를 검증한다. 실제 PGM은 별도 승인 회차가 필요하다. | +| LF-019 | P1 | 저장 PList의 `테마_NXT` stale code 복원 | 코드 완료 / DB view 정합성 대기 | 원본 `Control/UC4.cs`, `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedNxtThemeRestore.cs`, `MainWindow.NamedNxtThemeRestore.cs`, `Web/named-nxt-theme-restore-workflow.js`; 테스트 `LegacyNamedNxtThemeRestoreServiceTests.cs`, `named-nxt-theme-restore-workflow.test.cjs`, `named-nxt-theme-restore-app-integration.test.cjs`; 상세 `docs/NAMED_NXT_THEME_RESTORE.md`, `docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md` | 제목 exact lookup, 현재 Maria code, 5/6/12행, 정렬 fallback, 00~12 PRE/13~23 AFTER와 fresh PageN을 닫힌 batch로 복원한다. 실제 48행/24제목은 current identity가 유일하고 저장 code는 전부 stale다. `SB_ITEM` 446행과 NXT master 446행은 존재하지만 원본과 새 앱이 공통으로 쓰는 `v_all_stock` 조인은 0행이어서 48행 모두 `PreviewEmpty`로 차단된다. 통합 PList 결과에서도 `selectionMapped`로 계산하지 않았고 추측성 직접-table fallback을 적용하지 않았다. | Core Debug/Release와 실제 read-only DB smoke PASS, DB write·Tornado/PGM 명령 0건. DBA가 `v_all_stock`의 NXT 포함 의도를 확인해 승인된 변경·rollback으로 복구하거나 직접-table 경로를 별도 승인한 뒤 fresh page preflight/MSIX/PGM을 검증한다. | | LF-020 | P2 | 원본 초기 업무 화면 `해외` | 완료 | 원본 `MainForm.Designer.cs` 첫 TabPage `해외`; 현재 `Web/index.html`, `Web/app.js`; 테스트 `operator-ui-app-integration.test.cjs` | dashboard는 진단 화면으로 유지하되 초기 active identity/제목/요청을 해외로 맞춰다. 실제 브라우저에서 해외만 active, 최초 요청 1회, dashboard 진입·복귀를 확인했고 signed 1.0.3에 같은 Web 31개가 byte 일치했다. | 후속 탭 추가 시에도 초기 identity를 순번이 아닌 `data-market`으로 유지한다. | | LF-021 | P2 | 업무 탭 드래그 순서 변경 | 완료 | 원본 `MainForm.cs:3183-3312`; 현재 `Web/market-nav-reorder.js`, `Web/app.js`; 테스트 `market-nav-reorder*.test.cjs` | dashboard는 첫 위치에 고정하고 10개 업무 identity만 drag 교환/Alt+위·아래로 이동한다. active view/DB/playlist는 불변이고 입력·modal·PREPARE 잠금에서 fail closed하며 세션 종료 시 기본 순서로 돌아간다. 실제 브라우저에서 `Alt+Down` 후 active 해외 유지와 reload 초기화를 확인했다. | signed 1.0.3 패키지에 Web 31개 일치로 반영했다. | | LF-022 | P2 | GraphE 위/아래 찾기 | 완료 | 원본 `GraphE.cs:338-379,605-611`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `위로 찾기`/`아래로 찾기` 버튼과 Enter 다음 찾기를 포팅했다. trim·대소문자 무시 Contains, 현재 행 전/후, no-wrap, 모든 pending·잠금·hidden 차단을 유지한다. 원본의 `i > 0` 첫 행 누락은 명백한 off-by-one으로 보고 첫 행도 정상 후보에 포함했다. | GraphE 집중 20/20, 전체 Web 371/371과 signed 1.0.3 Web 일치 통과. | @@ -47,26 +47,33 @@ | LF-024 | P2 | GraphE 종목명 헤더 정렬 | 완료 | 원본 `GraphE.cs:258-280`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 세션 로컬 display order로 적용했다. 선택 snapshot/native/playlist는 불변이고 위/아래 찾기는 현재 표시 순서를 따른다. 새 화면/목록에서 오름차순으로 초기화한다. | 네 모드·한글/ASCII·빈/단일·pending·정렬된 find 집중 20/20, 전체 Web 371/371 통과. | | LF-025 | P2 | UC7 거래정지 종목명 헤더 정렬 | 완료 | 원본 `Control/UC7.cs:173-188`; 현재 `Web/trading-halt-workflow.js`, `Web/app.js`, `Web/index.html`; 테스트 `trading-halt-name-sort-app-integration.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 표시 order에만 적용했다. 동명은 market+stockCode로 결정적으로 정렬하고 선택 identity를 유지하며 새 검색은 server 순서로 초기화한다. DB/native/playlist 변경은 없다. | 집중 18/18, 전체 Web 371/371, 실제 브라우저에서 접근 라벨/가로 overflow 0, signed 1.0.3 Web 일치 통과. | -## 활성 PLAY_LIST 2,213행 재분류 +## 활성 PLAY_LIST 2,213행 read-only 재분류 -운영 Oracle의 활성 프로그램 정의 23개와 연결된 2,213행을 원문을 출력하지 않는 read-only 집계로 다시 분류했다. 삭제된 정의에 매달린 orphan 데이터 88개 프로그램/9,369행은 현재 PList의 정상 load 대상이 아니므로 아래 수치에 포함하지 않았다. +운영 Oracle의 활성 프로그램 정의 23개와 연결된 2,213행을 원문을 출력하지 않는 +감사로 다시 분류했다. 원본 `PList.data_load`의 page 공백 제거를 제외하면 모든 +필드를 그대로 보존했고, 현재 persistence 결과와 2,213/2,213 일치했다. -| 복원 분류 | 건수 | 판정 | +| route | 건수 | 현재 결과 | |---|---:|---| -| 기존 닫힌 매퍼와 fresh 검증으로 복원 | 1,573 | stock/fixed/expert/GraphE 등 기존 계약과 현재 master를 모두 통과하는 행 | -| DB 없이 추가 복원 가능 | 57 | 업종 fixed 24, 고정 시장·지수 2열판 33 | -| 현재 DB exact lookup 뒤 추가 복원 가능 | 228 | NXT theme 48(복원 경로 적용, 현재 preview empty로 전부 차단), 비교 graph 41, 해외종목 78, 해외지수 candle 61 | -| 현재 identity를 안전하게 증명할 수 없음 | 325 | KRX theme 300, 원본이 시장을 저장하지 않은 종목 2열판 25. 이름이나 과거 코드를 추정하지 않고 재선택이 필요함 | -| whitelist 불일치로 자동 복원 금지 | 30 | `s5001` fixed-nearest 기존 집계가 재감사 후보 40건과 달라 명시적 CASE 확정 전까지 차단 | -| 합계 | 2,213 | raw 행은 closed mapping과 fresh identity가 모두 성공해야만 playable entry가 됨 | +| 기존 닫힌 매퍼 | 1,250 | hash 고정 원본 INI와 현재 exact signature로 selection 1,250개 매핑 | +| 업종 fixed 닫힌 매퍼 | 24 | selection 24개 매핑 | +| 비교 fresh identity | 99 | selection 74개 매핑, 시장 identity 유실 25개 차단 | +| 해외지수 candle fresh identity | 61 | selection 61개 매핑 | +| NXT theme fresh identity | 48 | validated preview empty 48 차단 | +| KRX theme fresh identity | 587 | current-code remap 94, identity 없음 271, 저장 행 무효 16, preview empty 193, 지원 불가 action 13 | +| 해외종목 fresh identity | 78 | exact 입력명에서 unique symbol/nation을 correlated materialize해 78개 매핑 | +| GraphE fresh materialization | 36 | 별도 double-read row-version·stock identity·cut 계약으로 22개 매핑, 저장값 무효 9·identity 중복 5 차단. FSell/VI PList행은 0 | +| unique closed mapper 없음 | 30 | 원본 328-action runtime hash와 현재 exact signature 중 단일 후보가 없어 nearest scene 추정 없이 차단 | +| 합계 | 2,213 | 기본 감사 `selectionMapped=1,581`/차단 632; 별도 GraphE proof를 합치면 `selectionMapped=1,603`/차단 610 | -테마 635행은 별도로 다음과 같이 판정했다. KRX exact 문법 375행 중 현재 제목이 유일한 287행만 즉시 신뢰할 수 있고 88행은 stale이다. 정렬 토큰이 없는 199행과 기타 문법 13행은 원본 `s5074/s5077/s5088`의 최종 분기와 같이 모두 `GAIN_ASC` 의미지만, 현재 `SB_LIST`에서 제목이 사라져 playable하지 않다. NXT 48행은 24개 저장 제목으로 구성되고 raw grammar는 `5단 표그래프 / 테마-현재가(입력순)` 한 종류다. 48행 모두 제목의 현재 Maria identity는 유일하고 저장 code가 현재 code와 다르지만, 현재 validated preview가 0건이어서 전부 `PreviewEmpty`로 차단한다. live item이 생긴 경우에만 현재 code를 송출 selection에 사용하며, 원본과 같이 PREPARE 직전 로컬 시각 00~12시는 PRE, 13~23시는 AFTER로 다시 결정한다. - -비교 후보 99행은 고정 지수쌍 33, 시장쌍이 보존된 KRX graph 41, 시장이 사라진 `종목` 2열판 25로 확정했다. 앞의 74행만 closed target 또는 Oracle의 시장+이름 exact lookup으로 복원한다. 마지막 25행은 오늘의 master에서 이름이 우연히 유일해도 원본 행이 KOSPI/KOSDAQ/NXT/해외 구분을 보존하지 않았으므로 자동 복원하지 않는다. NXT graph는 원본 UC1 자체가 지원하지 않았고 현재 graph loader도 Oracle KRX pair만 허용하므로 계속 차단한다. - -해외지수 candle 61행은 UC5 고정 이름 세 개(Dow 22, Nasdaq 21, S&P500 18)와 5/20/60/120일 기간만 사용하며 240일 활성 행은 없다. 이름과 기간은 닫힌 표이지만 원본 LIST_TEXT가 symbol/nation을 저장하지 않으므로 DB-free로 분류하지 않는다. Oracle master에서 `F_INPUT_NAME`, expected symbol, `F_NATC=US`, `F_FDTC=0`의 exact identity를 다시 확인한 뒤에만 `s8010/PRICE` 선택을 발급한다. - -이 수치는 “문자열을 해석할 수 있음”과 “현재 데이터로 송출할 수 있음”을 분리한다. identity 미확정 325행과 whitelist 미확정 30행에는 `R...` 임시 code나 가장 가까운 장면을 넣지 않는다. 화면에는 실패 행을 유지하고 PREPARE를 차단하며, 운영자가 현재 항목으로 다시 선택해야 한다. +`unique=2,213`은 전부 송출 가능하다는 뜻이 아니라 모든 행이 단일 route의 +selection 결과 또는 명시적 차단 사유를 가졌다는 뜻이다. `selectionMapped`도 +장면 데이터·자산, package context, PREPARE 또는 PGM 성공이 아니다. 이들은 집계 +밖의 별도 gate다. KRX/NXT theme은 +원본 `s5074/s5077/s5088`처럼 제목 exact lookup을 사용하며, 저장 code를 가장 가까운 +현재 code로 추정하지 않는다. 비교 25행도 현재 이름이 우연히 유일하더라도 저장 +행이 시장을 보존하지 않았으므로 계속 차단한다. 상세 SQL 경계와 결과는 +[`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)에 기록했다. ## s6001 asset 검사 보강 기준 diff --git a/docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md b/docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md new file mode 100644 index 0000000..67a459c --- /dev/null +++ b/docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md @@ -0,0 +1,136 @@ +# 원본 one-time importer 안전 감사 + +기준일은 2026-07-12이며, 설치된 패키지는 +`Wickedness.MBNStockWebView_1.0.3.0_x64__qbv3jkvsn3aj0`이다. 이 감사에서는 +원본 `Data`를 읽기 전용으로 사용했고 Oracle/MariaDB에는 SELECT만 실행했다. +운영 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`와 Tornado2에는 쓰기나 +명령을 보내지 않았다. 비교쌍에 한해서는 설치된 signed package의 명시적 +버튼을 한 번 눌러 WebView2 `localStorage`에 8쌍과 one-time marker를 같은 +envelope로 저장했다. + +## 감사 시점 운영 profile 분류 + +FSell/VI 저장소에는 marker와 intent 없이 원본과 byte 단위로 같은 네 파일이 +이미 있다. + +| 파일 | 길이 | SHA-256 | 고정 원본과 일치 | +|---|---:|---|---| +| `개인.dat` | 56 | `F953366A5D9F940CBE54F05C256F8BFB0AF4674C8F4D46CCBF839A63113342FA` | 예 | +| `외국인.dat` | 58 | `45F62038E3216E80FCF22F4E923056858E9C3537BF0DC46BC359148E82DD7CD8` | 예 | +| `기관.dat` | 27 | `BC4D62D734E701744E10DD830A2D6B9AEBEBEB795BB4DABC77CDE6577F016EB7` | 예 | +| `VI발동.dat` | 276 | `46AFBA07D2C4AF50717450582DEB10EC10367701D09161BECF5E32342AD4AA45` | 예 | + +- `.legacy-manual-operator-import.v1.json`: 없음 +- `.legacy-manual-operator-import.v1.in-progress.json`: 없음 +- importer staging 파일: 없음 +- startup 상태: marker가 없는 기존 원본 파일을 그대로 쓰는 `ReadyLegacyData` +- 현재 버튼 실행 판정: `DestinationNotEmpty`, `OutcomeUnknown=false` + +따라서 현재 profile에서 `원본 데이터 가져오기`를 누르면 네 파일을 덮어쓰지 +않고 source read 전에 전체 import가 중단된다. marker를 임의로 만들어 +`AlreadyImported`로 바꾸거나 파일을 지워 import를 재현하면 안 된다. + +다음 명령은 운영 profile을 쓰지 않고 이 판정을 다시 출력한다. + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj ` + -c Release -p:Platform=x64 -- --manual-import-audit +``` + +현재 결과: + +```text +MANUAL_IMPORT_AUDIT: PASS sourceFiles=4/4 operatingFiles=4/4 sourceByteIdentical=true marker=false intent=false staged=false runtime=ReadyLegacyData decision=DestinationNotEmpty outcomeUnknown=false writesAttempted=false +``` + +## 격리 FSell/VI 실제 원본 import + +`--manual-import-fixture`는 사용자가 destination을 지정할 수 없게 닫혀 있다. +고정 원본을 읽고 `%TEMP%\MBN_STOCK_WEBVIEW-manual-import-fixture-`에만 +쓴다. 전체 실행은 1분 timeout으로 제한되며 timeout/cancel 뒤 자동 재시도하지 +않는다. 다음 항목을 모두 확인한 뒤 자신이 만든 prefix의 non-reparse directory만 +삭제한다. + +1. 4파일 stage 및 no-overwrite commit +2. production parser read-back +3. marker-last commit과 intent 제거 +4. 재시작 guard의 `ReadyImportedData` +5. 같은 fixture의 두 번째 실행 `AlreadyImported`, `OutcomeUnknown=false` +6. 원본 4파일과 운영 import 대상 6항목의 실행 전후 SHA-256 불변 +7. 원본 aggregate SHA-256 `fd39a363…a2114`, FSell 15행과 VI 9건 고정 + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj ` + -c Release -p:Platform=x64 -- --manual-import-fixture +``` + +실제 결과: + +```text +MANUAL_IMPORT_FIXTURE: PASS rows=15 vi=9 sourceSha256=fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114 state=ReadyImportedData secondAttempt=AlreadyImported outcomeUnknown=false sourceUnchanged=true operatingImportSetUnchanged=true +``` + +## `종목비교.dat` 분류 + +- 고정 원본: 358바이트, 8행 +- SHA-256: `1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2` +- 실제 Oracle/MariaDB read-only 재검증: 8/8 identity 해소 +- 실행 경로: `비교` 화면의 `원본 비교쌍 가져오기` +- 성공 저장 위치: package WebView2 `localStorage`의 단일 comparison envelope + +감사 초기에 package WebView2가 닫힌 상태에서 LevelDB에는 87바이트 +`VERSION=1` log 외 comparison key byte가 없었고 비교 화면은 `0 PAIRS`였다. +signed 1.0.3 패키지에서 `원본 비교쌍 가져오기`를 명시적으로 한 번 실행한 +결과, native가 고정 원본의 SHA-256과 8행을 다시 읽고 현재 DB에서 모든 +identity를 해소한 뒤 화면이 `8 PAIRS`와 `원본 8쌍 가져옴`으로 바뀌었다. +부분 저장이나 `복구 필요` 상태는 발생하지 않았다. + +이 성공은 같은 package profile의 단일 comparison envelope에 pair list와 +marker를 함께 기록한 결과다. 앱을 정상 종료한 뒤 설치된 signed 1.0.3을 +다시 실행해 다음 상태를 확인했다. + +- 비교 화면: `8 PAIRS` +- 저장 비교쌍: 원본 순서의 8개 항목 유지 +- one-time gate: 완료형 `원본 8쌍 가져옴`, 다시 실행할 수 없는 상태 +- 고정 원본 SHA-256: `1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2`로 불변 + +따라서 package UI 저장과 재시작 marker 영속성은 PASS다. 실행 중 LevelDB는 +process lock 상태이므로 raw 파일 검색으로 live logical state를 단정하거나 +파일을 복사·수정하지 않았다. + +정규 DB smoke에는 실제 importer를 추가했다. 아래 명령은 원본을 읽고 8개 +identity를 fresh SELECT로 해소하지만 package `localStorage`에는 쓰지 않는다. + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj ` + -c Release -p:Platform=x64 --no-build +``` + +확인 결과는 다음과 같다. + +```text +LegacyComparisonImport: rows=8, sha256=1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2, identities=fresh-read-only +REAL_DB_SMOKE: PASS +``` + +현재 package profile에서는 실제 0→8 import와 정상 종료 후 재시작 marker +검증을 완료했으므로 이를 재현하려고 package family 데이터를 초기화하지 +않는다. 빈 profile의 실패 fixture가 추가로 필요하면 별도 test package +identity를 사용한다. 운영 identity에서 `Remove-AppxPackage`, app data reset, +LevelDB 파일 복사·삭제로 빈 profile을 만들지 않는다. + +## 차단 판정표 + +| 상태 | FSell/VI 판정 | 비교쌍 판정 | 자동 재시도 | +|---|---|---|---| +| 대상 파일 없음, source 정상 | import 가능 | marker 없고 envelope 정상일 때 가능 | 명시적 버튼만 | +| 기존 4파일, marker 없음 | `DestinationNotEmpty` | 해당 없음 | 금지 | +| 유효 marker 있음 | `AlreadyImported` | 버튼이 `원본 N쌍 가져옴`으로 비활성 | 금지 | +| intent 있음 | `InterruptedImport`, `OutcomeUnknown=true` | 해당 없음 | 금지, 수동 대조 | +| marker/4파일 불일치 | `InvalidImportedState` | 해당 없음 | 금지, 수동 대조 | +| split/손상 envelope | 해당 없음 | `복구 필요`, 전체 차단 | 금지, local state 대조 | +| DB timeout/identity 오류 | 해당 없음 | 저장 전 전체 취소 | 금지 | + +FSell/VI 전용 화면은 원본 운영 항목의 개인·외국인·기관 또는 VI 수동 항목을 +열어 진입하며, header의 `원본 데이터 가져오기`가 importer 버튼이다. 비교쌍은 +상단 `비교` 업무 탭 안의 `원본 비교쌍 가져오기` 버튼으로 진입한다. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 8101c39..f1889fa 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -70,8 +70,8 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일 - 화면/계약 구현: MainForm과 UC1~UC7의 종목·고정·업종·비교·테마·해외·전문가·거래정지 workflow, GraphE·FSell·VIList·PList/AList·ThemeA·EList 전용 화면과 native bridge - 자동 계약 검증: 328 fixed + 44 industry + 31 stock + 9 comparison의 412 action 매트릭스, typed DTO, 저장·복원 fail-closed 경계 -- 적용 중: PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원. 기존 운영 DB 행을 원본과 문자 단위로 대조하기 전에는 완료로 판정하지 않음 -- 화면/계약 구현·운영 검증 대기: GraphE·FSell·VI 항목을 포함한 manual named save→reload→fresh identity/version/page plan 복원, ThemeA/EList의 KRX/NXT·동명이름·코드 identity 보존 +- 적용 중: PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 원문 round-trip과 typed persistence가 전부 일치했다. KRX·해외종목 및 별도 GraphE fresh proof를 합쳐 `selectionMapped=1,603`, 차단 610이며 이 수치는 scene data·asset·PREPARE·PGM 성공을 뜻하지 않는다. +- 화면/계약 구현·운영 검증 대기: GraphE 36행 중 22행은 fresh row-version/stock identity로 복원되고 14행은 저장값 무효·identity 중복으로 차단된다. FSell/VI PList행은 현재 0건이며 production source read만 FSell 3×5행, VI 9항목·2 page로 확인했다. ThemeA/EList 운영 write와 PGM은 별도 승인 범위다. - 코드 적용·운영 검증 대기: 원본 `Data\종목비교.dat`, FSell, VI를 고정 read-only source에서 strict parser·hash·fresh identity·durable intent로 검증한 뒤 private 저장소로 이전하는 one-time importer. 실제 package profile의 명시적 1회 실행은 아직 수행하지 않음 - 외부자산 필요: `s5006`의 `Video\큐브배경.vrv`, `s6001` 해외지수의 국가별 영상 13개. 현재 alias-only cut 검사를 asset-ready 근거로 사용하지 않음 - 운영 동등성 미검증: GraphE, PList/AList, ThemeA, EList의 실제 Oracle DB-W와 FSell/VI 운영 파일 write/read-back diff --git a/docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md b/docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md new file mode 100644 index 0000000..4fb6979 --- /dev/null +++ b/docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md @@ -0,0 +1,118 @@ +# Named manual restore read-only audit + +감사일: 2026-07-12 (KST) + +범위는 운영 Oracle/MariaDB의 `SELECT`와 +`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 production read path뿐이다. +원본 저장소, 운영 DB, 운영 파일, import marker/intent, Tornado2에는 쓰기나 명령을 +보내지 않았다. 감사 출력에는 program code, title, stock name/code, VI 이름 목록, +파일 경로, 해시 또는 인증 정보를 포함하지 않는다. + +## 현재 PList 문법 분포 + +실제 `DC_LIST`/`PLAY_LIST` 2,213행을 lossless parser로 읽고 +`LegacyPListAuditRoute.ManualFreshMaterialization` 36행을 독립 closed grammar로 다시 +분류했다. + +| 종류 | 행 | 세부 분포 | +| --- | ---: | --- | +| GraphE | 36 | 주요매출 구성 12, 매출액 12, 영업이익 12 | +| GraphE 성장성 지표 | 0 | 현재 저장 행 없음 | +| FSell 순매도 | 0 | 현재 저장 행 없음 | +| VI 수동 목록 | 0 | 현재 저장 행 없음 | +| 합계 | 36 | 역사형 36, canonical형 0 | + +따라서 이전 감사 문구의 `GraphE/FSell/VI 36행`은 “세 manual 복원 경로가 차단 +대상”이라는 범주명이었고, 실제 36행의 내용은 전부 GraphE였다. FSell/VI 경로는 +구현돼 있으나 현재 named PList에는 해당 행이 없다. + +분류기는 Web의 `named-manual-restore-workflow.js`와 같은 두 문법을 닫힌 값으로 +허용한다. + +| 종류 | 역사형 | fresh materialize 후 canonical형 | +| --- | --- | --- | +| GraphE | 한국어 시장 + 원본 GraphE 화면명 + 종목 표시명 + `1/1` | canonical 시장 + canonical 화면명 + 동일 표시명 + `1/1` | +| FSell | 수동 순매도 3개 audience + 지수/순매도 + `1/1` | `INDIVIDUAL`/`FOREIGN`/`INSTITUTION` + `MANUAL_NET_SELL` | +| VI | 쉼표 구분 이름 목록 + 계산된 `1/N` | `PAGED_VI` + reserved snapshot subject + lowercase SHA-256 version | + +## 실제 fresh materialization 결과 + +GraphE는 브라우저/네이티브 복원 순서를 그대로 축약해 다음 증거를 요구했다. + +1. `LegacyManualFinancialScreenService.GetAsync` 첫 조회 +2. 현재 종목 마스터 검색(브라우저와 같은 최대 200건, truncated 금지) +3. 같은 GraphE identity를 두 번째로 조회 +4. 두 조회의 64자리 Oracle row version이 동일한지 확인 +5. 시장·provider·종목 code가 유일한지 확인 +6. `ManualFinancialCutContracts.CreateSelection` 결과가 화면별 cut 계약과 일치하는지 확인 + +| 화면 | 후보 | fresh materialize 가능 | 차단 | +| --- | ---: | ---: | ---: | +| 주요매출 구성 | 12 | 5 | 7 (`GRAPH_E_DATA_INVALID`) | +| 매출액 | 12 | 6 | 5 (`GRAPH_E_AMBIGUOUS`), 1 (`GRAPH_E_DATA_INVALID`) | +| 영업이익 | 12 | 11 | 1 (`GRAPH_E_DATA_INVALID`) | +| 합계 | 36 | 22 | 14 | + +`GRAPH_E_DATA_INVALID` 9행은 production GraphE parser가 현재 INPUT_* 저장값을 +typed record로 만들 수 없는 경우다. `GRAPH_E_AMBIGUOUS` 5행은 같은 화면에서 +하나의 저장 identity가 둘 이상 조회되어 원본 schema의 이름-only identity로는 +어느 행인지 결정할 수 없는 경우다. 원시 이름과 저장값은 출력하지 않았다. + +22행의 성공은 “현재 raw PList 행을 fresh GraphE selection으로 복원 가능”하다는 +뜻이다. 각 scene의 시세 조회, cut asset, PREPARE 또는 Tornado/PGM 송출 성공을 +의미하지 않는다. + +## FSell/VI production source 확인 + +현재 named PList 후보는 0행이지만 향후 저장/복원을 막는 source 문제인지 별도로 +검사했다. + +- runtime guard: `ReadyLegacyData` +- FSell: 세 audience 모두 production CP949 parser로 opened-handle read 성공, + audience마다 정확히 5행 +- VI: production opened-handle read 성공, 9개 항목, 2 page, versioned 두 번째 read 일치 +- 운영 디렉터리: 4개 data entry가 감사 전후 이름·크기·생성/수정 시각·SHA-256 모두 동일 +- marker/intent: 감사 전후 생성 없음 +- writes attempted: `false` + +`ReadyLegacyData`인 현재 profile에 byte-identical 4파일이 이미 존재하므로 importer를 +실행하거나 완료 marker를 임의로 생성하지 않는다. 이번 결과는 기존 trusted store의 +read 가능성만 증명한다. + +## Native request 대응 + +| 복원 종류 | fresh proof에 사용되는 native request | +| --- | --- | +| GraphE | `request-manual-financial-load` → `search-stocks` → `request-manual-financial-selection` | +| FSell | `request-manual-net-sell-data` | +| VI | `request-vi-manual-list` 후 반환 version을 reserved snapshot reference로 고정 | + +구현 API는 +`src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs`의 closed classifier와 +pure fresh-proof evaluator, 실행 경계는 +`tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`의 +`NamedManualRestoreReadOnlyAudit.RunAsync`다. 실행 경계는 root smoke에서 다음처럼 +호출할 수 있다. + +```csharp +await NamedManualRestoreReadOnlyAudit.RunAsync( + runtime.Executor, + NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(), + cancellationToken); +``` + +집중 Core 테스트 21건은 역사형/canonical GraphE·FSell·VI grammar, NXT 표시명 검색, +GraphE double-read version, master identity 중복, FSell 5행, VI version/name/page drift를 +검증한다. + +## 남은 조치 + +1. `GRAPH_E_DATA_INVALID` 9행은 운영자가 승인한 snapshot을 먼저 만들고 각 INPUT_* + 원본 필드 형식을 확인한다. +2. `GRAPH_E_AMBIGUOUS` 5행은 이름-only identity 중 어느 행을 유지할지 운영자가 + 결정해야 한다. 감사 코드가 임의 삭제하거나 첫 행을 선택하지 않는다. +3. 수정은 별도의 운영 DB write 승인, 대상 identity, rollback 계획과 OutcomeUnknown + 규칙을 갖춘 회차에서만 수행한다. +4. 수정 후 같은 read-only 감사를 다시 실행해 36/36 fresh materialization을 확인한다. +5. 그 다음 scene data/asset DryRun을 확인하고, 실제 PGM TAKE IN은 별도 Gate A/B + 승인 회차에서만 수행한다. diff --git a/docs/NAMED_PLAYLIST_READ_AUDIT.md b/docs/NAMED_PLAYLIST_READ_AUDIT.md new file mode 100644 index 0000000..4bfcaa7 --- /dev/null +++ b/docs/NAMED_PLAYLIST_READ_AUDIT.md @@ -0,0 +1,178 @@ +# PList 기존 데이터 읽기 전용 동등성 감사 + +기준일: 2026-07-12 + +원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\Form\PList.cs` +감사 구현: +[LegacyPListReadAuditParser](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs), +[NamedPlaylistReadOnlyDbAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs), +[NamedManualRestoreReadOnlyAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs) + +## 원본 읽기 의미 + +원본 `PList.data_load`는 `PLAY_LIST`를 `ITEM_INDEX` 순으로 읽고 +`LIST_TEXT`를 `^`로 나눈 뒤 FarPoint의 0~6열에 넣는다. 첫 필드는 `1`일 때만 +활성이고, 여섯 번째 필드인 PageN 문자열에서만 ASCII 공백을 제거한다. 다른 +필드는 공백·한글·기호를 포함해 변경하지 않는다. + +새 감사도 이 경계를 그대로 사용한다. + +- 정확히 7필드인지 검사한다. +- 원문을 다시 `^`로 결합했을 때 DB 문자열과 code-unit 단위로 같아야 한다. +- 페이지 표시값 이외의 필드는 정규화하지 않는다. +- 프로그램 코드는 8자리 숫자, `ITEM_INDEX`는 0부터 연속이어야 한다. +- 제어문자, U+FFFD replacement 문자와 알려진 UTF-8/codepage 손상 신호를 + 거부한다. +- 같은 프로그램을 현재 `LegacyNamedPlaylistPersistenceService.LoadAsync`로 + 다시 읽어 활성값, 5개 selection 필드와 페이지 표시값을 모두 대조한다. + +감사 오류와 정상 출력에는 `DC_TITLE`, `LIST_TEXT`, 종목명, 테마명, 프로그램 +코드 또는 인증정보를 포함하지 않는다. + +## 실제 Oracle/MariaDB 결과 + +실제 운영 연결에 고정 SQL과 bind 변수로 `SELECT`만 실행했다. DB write, +Tornado2 명령과 원본 저장소 변경은 수행하지 않았다. + +| 항목 | 결과 | +|---|---:| +| 활성 `DC_LIST` 정의 | 23 | +| 연결된 `PLAY_LIST` 행 | 2,213 | +| 정확히 7필드로 parse | 2,213 | +| 원문 무손상 round-trip | 2,213 | +| replacement/mojibake 신호 | 0 | +| 비연속 `ITEM_INDEX` 프로그램 | 0 | +| 현재 persistence 결과 불일치 | 0 | +| 단일 route와 결과로 분류 | 2,213 | +| 기본 감사에서 `selectionMapped` | 1,581 | +| 기본 감사에서 안전 차단 | 632 | +| 별도 GraphE fresh proof에서 추가 `selectionMapped` | 22 | +| 별도 GraphE fresh proof에서 안전 차단 | 14 | +| 두 read-only 증거를 합친 `selectionMapped` | 1,603 | +| 두 read-only 증거를 합친 안전 차단 | 610 | + +`단일 route와 결과로 분류`는 모든 행이 송출 가능하다는 뜻이 아니다. 기본 +`NamedPlaylistReadOnlyDbAudit`는 KRX·해외종목 복원까지 적용해 1,581행을 단일 +current selection으로 매핑하고 632행을 차단한다. 이 632행에는 별도 production +source 증거가 필요한 manual 36행이 들어 있다. `NamedManualRestoreReadOnlyAudit`가 +그 36행을 독립적으로 다시 읽어 GraphE 22행을 fresh selection으로 만들고 14행을 +차단하므로, 두 read-only 증거를 행별로 합친 결과는 1,603행 `selectionMapped`, +610행 차단이다. + +`selectionMapped`는 raw 7필드에서 현재 typed selection을 하나 만들었다는 뜻만 +갖는다. 각 장면의 시세·재무 데이터 결과, 컷·배경 자산, package context, +PREPARE 성공과 Tornado2/PGM 출력은 포함하지 않으며 각각 별도 gate를 통과해야 +한다. + +### route 집계 + +| route | 전체 | `selectionMapped` | 차단 | +|---|---:|---:|---:| +| 기존 닫힌 매퍼 | 1,250 | 1,250 | 0 | +| 업종 fixed 닫힌 매퍼 | 24 | 24 | 0 | +| 비교 fresh identity | 99 | 74 | 25 | +| 해외지수 candle fresh identity | 61 | 61 | 0 | +| NXT theme fresh identity | 48 | 0 | 48 | +| KRX theme fresh identity | 587 | 94 | 493 | +| 해외종목 fresh identity | 78 | 78 | 0 | +| GraphE fresh materialization | 36 | 22 | 14 | +| unique closed mapper 없음 | 30 | 0 | 30 | +| 합계 | 2,213 | 1,603 | 610 | + +기본 감사 수치 1,581은 기존 닫힌 매퍼 1,250행, 업종 fixed 24행, 비교 +74행, 해외지수 candle 61행, KRX theme 94행 및 해외종목 78행의 합이다. +GraphE 22행은 별도 fresh proof의 성공분이다. 현재 PList의 manual 36행은 모두 +GraphE이고 FSell/VI 저장 행은 0건이다. + +기존 fixed 매퍼는 임의 문자열 허용으로 판정하지 않았다. 원본 runtime +`해외.ini`·`환율.ini`·`지수.ini`를 각각 현재 Web 계약에 고정된 SHA-256으로 +검증하고, 원본 `MainForm`과 같이 각 줄을 trim한 뒤 현재 +`fixedLegacySignature`의 exact 5필드 규칙으로 변환했다. 원본 328 action에서 +현재 명시적으로 unavailable인 수동 6개를 제외하고, 같은 raw 서명으로 겹치는 +후보는 unique로 인정하지 않았다. 이 exact whitelist와 일치하지 않은 30행은 +가까운 `s5001` 등을 추정하지 않고 차단했다. + +### fresh restore 결과와 차단 사유 + +| 사유 | 행 | 의미 | +|---|---:|---| +| comparison market identity 유실 | 25 | 원본 2열판이 KOSPI/KOSDAQ/NXT/해외 구분을 저장하지 않았다. | +| KRX theme identity 없음 | 271 | 원본 제목 exact lookup으로 현재 유일한 KRX identity를 찾을 수 없다. | +| KRX theme 저장 행 무효 | 16 | 닫힌 5·6·12행 문법과 필수 field를 만족하지 않는다. | +| KRX theme preview empty | 193 | 현재 제목/code는 찾았지만 validated live item이 없다. | +| KRX theme 지원 불가 action | 13 | 현재 5·6·12행 장면 계약으로 유일하게 해석할 수 없다. | +| GraphE 현재 저장값 무효 | 9 | production GraphE parser가 현재 INPUT_* 저장값을 typed record로 만들 수 없다. | +| GraphE 이름 identity 중복 | 5 | 원본 이름-only identity로 현재 저장 행 하나를 결정할 수 없다. | +| NXT theme preview empty | 48 | 현재 title/code 매핑은 성공했으나 MariaDB validated preview가 비어 있다. | +| unique closed mapper 없음 | 30 | hash가 고정된 328-action runtime 계약의 어느 단일 서명과도 일치하지 않아 nearest scene을 추정하지 않는다. | + +KRX theme 587행 중 94행은 제목 exact lookup, 현재 code 및 validated preview를 +같은 correlated batch에서 확인해 모두 현재 code로 remap했다(`codeRemapped=94`). +나머지 493행은 위 네 KRX 사유로 차단했다. 해외종목 78행은 현재 Oracle의 exact +입력명에서 유일한 symbol/nation을 얻고 original action 문법을 보존해 78/78을 +매핑했다. 두 경로 모두 timeout/불명확 응답을 재시도하지 않으며 raw 이름·code를 +감사 출력에 기록하지 않는다. + +GraphE 36행의 별도 감사는 같은 identity를 두 번 읽어 64자리 row version이 +변하지 않는지 확인하고 현재 종목 master의 유일한 identity와 cut 계약을 함께 +검증했다. 22행만 materialize했고 저장값 무효 9행과 identity 중복 5행은 첫 후보를 +고르지 않고 차단했다. 상세 경계는 +[manual read-only 감사](NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md)에 있다. + +FSell/VI는 현재 PList 후보가 0행이지만 운영 production source를 opened-handle로 +별도 읽었다. FSell은 3 audience마다 5행, VI는 9항목·2 page였고 두 번째 VI read의 +version도 같았다. 네 운영 파일의 이름·크기·시각·SHA-256은 감사 전후 동일했고 +import marker/intent를 만들거나 파일을 쓰지 않았다. + +## 구현과 자동 근거 + +- KRX theme: + [Core restore](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs), + [native bridge](../MainWindow.NamedKrxThemeRestore.cs), + [Web workflow](../Web/named-krx-theme-restore-workflow.js), + [Core tests](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedKrxThemeRestoreServiceTests.cs), + [Web tests](../tests/Web/named-krx-theme-restore-workflow.test.cjs), + [app integration](../tests/Web/named-krx-theme-restore-app-integration.test.cjs) +- 해외종목: + [Core restore](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs), + [native bridge](../MainWindow.NamedForeignStockRestore.cs), + [Web workflow](../Web/named-foreign-stock-restore-workflow.js), + [Core tests](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedForeignStockRestoreServiceTests.cs), + [Web tests](../tests/Web/named-foreign-stock-restore-workflow.test.cjs), + [app integration](../tests/Web/named-foreign-stock-restore-app-integration.test.cjs) +- manual: + [closed grammar/fresh proof](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs), + [Web workflow](../Web/named-manual-restore-workflow.js), + [read-only runner](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs), + [Core tests](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedManualRestoreAuditTests.cs), + [Web tests](../tests/Web/named-manual-restore-workflow.test.cjs) + +## 반복 실행 + +전체 실제 DB smoke에 이 감사가 포함된다. + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj ` + -c Release --no-restore +``` + +감사는 실패 시 원문을 출력하지 않고 boundary 이름만 남긴다. timeout 또는 +불명확 결과를 재시도하거나 반대 명령으로 보정하지 않는다. + +## 자동 검증 + +`LegacyPListReadAuditParserTests`는 다음을 고정한다. + +- 한글·공백·빈 필드를 포함한 7필드 무손상 round-trip +- 페이지 필드에만 적용되는 원본 ASCII 공백 제거 +- field 수, enabled 값, 프로그램 코드, item index 경계 +- replacement와 codepage 손상 신호 차단 +- typed persistence 대조와 연속 index +- comparison/업종/해외지수/NXT·KRX theme/해외종목/manual route 분리 +- KRX current-code correlated remap과 5·6·12행 문법, 해외종목 symbol/nation + correlation, GraphE double-read row-version·identity 중복 fail-closed +- exact stock label과 nearest-label 거부, 알 수 없는 행의 `Unmapped` fail-closed + +실제 기본 감사, 별도 manual fresh 감사와 전체 DB smoke는 +`REAL_DB_SMOKE: PASS`로 끝났다. DB write와 Tornado2/PGM 명령은 0건이며 raw 값이나 +인증정보도 출력하지 않았다. diff --git a/docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md b/docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md new file mode 100644 index 0000000..96ae818 --- /dev/null +++ b/docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md @@ -0,0 +1,72 @@ +# NXT 테마 복원 read-only 진단 + +검증일: 2026-07-12 (KST) + +## 결론 + +활성 NXT 테마 플레이리스트 48행(현재 고유 테마 24개)이 `PreviewEmpty`로 +차단되는 원인은 마이그레이션의 테마 제목/코드 선택이나 DB endpoint 차이가 +아니다. 현재 MariaDB의 `SB_ITEM`에는 해당 테마 구성종목 446행이 있고 그 +446행 모두 폐쇄된 X/T 종목코드 형식을 만족하지만, 원본 장면이 사용하는 +`v_all_stock` 조인에는 단 한 행도 결합되지 않는다. + +같은 prefix 제거 규칙으로 기반 NXT 테이블을 확인하면 다음과 같다. + +| read-only 집계 | 행 수 | +|---|---:| +| current theme `SB_ITEM` | 446 | +| `N_STOCK` 결합 | 187 | +| `N_KOSDAQ_STOCK` 결합 | 259 | +| 두 master 결합 합계 | 446 | +| `N_ONLINE` 결합 | 151 | +| `N_KOSDAQ_ONLINE` 결합 | 169 | +| 원본 `v_all_stock` 결합 | 0 | +| non-stop / live 결합 | 0 / 0 | + +즉 구성종목은 두 NXT master에 전부 존재하고 그중 320행은 online 테이블에도 +존재한다. 현재 `v_all_stock` 정의/데이터 범위가 이 NXT master 행을 포함하지 +않는 것이 직접 원인이다. + +## 원본 동등성 확인 + +읽기 전용으로 조사한 원본 `s5074`, `s5077`, `s5088`은 모두 NXT 구성종목을 +다음 조건으로 읽는다. + +```sql +SUBSTRING(SB_ITEM.SB_I_CODE, 2, 12) = v_all_stock.F_STOCK_CODE +``` + +새 `LegacyThemeSelectionService`와 +`LegacyNamedNxtThemeRestoreService`도 같은 prefix 제거 규칙, 같은 +`F_STOP_GUBUN = 'N'` 조건을 사용한다. 원본 설정과 현재 runtime 설정의 +host/port/database를 비밀값 없이 정규화해 비교한 결과 SHA-256과 값이 +동일했고, 실제 `DATABASE()`도 현재 runtime database와 일치했다. 서버는 +MariaDB 10.5 계열이다. + +따라서 다른 DB를 바라보는 마이그레이션 오류가 아니며, 현 데이터 상태에서는 +원본 프로그램도 같은 `v_all_stock` 경로로 NXT 테마 구성종목을 얻지 못한다. + +## 안전 결정 + +- 운영 DB write는 수행하지 않았다. +- endpoint, 계정, 비밀번호, 테마/종목 제목과 코드는 출력하거나 기록하지 않았다. +- exact, 양쪽 prefix 제거, right-six 후보도 모두 0이어서 코드 형식 추측 + fallback의 근거가 없다. +- `N_STOCK`/`N_KOSDAQ_STOCK` 직접 fallback은 데이터를 찾지만 원본 SQL과 다른 + 동작이므로 자동 적용하지 않았다. + +복구는 DBA가 `v_all_stock`의 NXT 포함 의도를 확인하고 승인된 변경/롤백 +계획으로 view를 교정하거나, 제품 요구사항으로 원본과 다른 NXT master 직접 +경로를 명시적으로 승인한 뒤 진행해야 한다. 그 전까지 복원은 `PreviewEmpty`로 +fail-closed 상태를 유지한다. + +## 재현 + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj ` + -c Release --no-restore +``` + +스모크는 식별자를 출력하지 않고 endpoint equality, aggregate join 수, +분류(`VAllStockExcludesCurrentNxtMasterRows`)만 출력한다. 전체 결과는 +`REAL_DB_SMOKE: PASS`여야 한다. diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md index dc0a240..4f0678e 100644 --- a/docs/OPERATOR_UI_PARITY.md +++ b/docs/OPERATOR_UI_PARITY.md @@ -218,11 +218,11 @@ | `PList_Load`, `Get_FileName_Folder` | `DC_LIST`의 프로그램 제목·코드를 조회한다. | native `request-named-playlist-list`; `named-playlist-workflow.js` | 화면 통합 완료 | | `AList_Load`, `GetNextCode`, `btnok_Click` | 다음 8자리 코드와 프로그램명을 생성해 `DC_LIST`에 추가한다. | native `create-named-playlist` | 화면 통합 완료. parameterized transaction 사용. | | `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면/계약 구현. raw·untrusted entry는 저장 또는 PREPARE 불가. GraphE·FSell·VI 등 manual 항목의 실제 save→reload→fresh identity/version 보존은 미검증. | -| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면/계약 구현. raw DB row는 builder/selection/page를 재검증할 때까지 PREPARE 불가. 기존 한국어 `DC_TITLE`/7필드 `LIST_TEXT` 무손상 복원은 적용 중. | +| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면/계약 구현. 실제 2,213행의 한국어 `DC_TITLE`/7필드 `LIST_TEXT` round-trip과 typed persistence는 전부 일치했다. KRX·해외종목·GraphE를 포함한 통합 read-only 증거는 `selectionMapped=1,603`, 차단 610이며 raw DB row는 scene data/asset/page를 재검증할 때까지 PREPARE 불가다. | | `PList.button1_Click` | 프로그램 정의 삭제 | native `delete-named-playlist` | 화면 통합 완료. 실제 운영 DB-W 없음. | | 취소 | 아무 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. | -구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트다. 실제 운영 DB write, 기존 한국어 데이터의 원본 대조, manual named save의 fresh restore 및 named playlist 전용 PGM 검증은 아직 없다. +구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, KRX/NXT/해외종목/manual 전용 restore, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트이며 실제 기존 데이터의 read-only 대조는 `docs/NAMED_PLAYLIST_READ_AUDIT.md`에 있다. 실제 운영 DB write와 named playlist 전용 scene/asset/PREPARE/PGM 검증은 아직 없다. ### ThemeA — 테마 추가·편집 @@ -281,8 +281,8 @@ | 자동 테스트 | Core 1,259개, Infrastructure 173개, Playout 387개가 Debug/Release x64에서 각각 통과했고 Web workflow·bridge 371/371도 통과했다. 412 action의 폐쇄형 매트릭스, 계약·경계·복원 실패와 native fail-closed gate를 검증한다. | | 실제 운영 DB write | **미실행.** GraphE, named playlist, ThemeA, EList의 mutation executor는 fake/계약 테스트로 검증했으며 운영 DB에는 쓰지 않았다. FSell/VI는 테스트용 격리 저장소만 사용했다. | | 실제 Tornado2 PGM | 제한된 승인 회차에서 `5001`, `5074` page 1·2, TAKE OUT을 확인했다. 이는 전체 412 action, GraphE, FSell, VI, ThemeA, EList, 이름 있는 playlist 각각의 UI 동등성 검증이 아니다. | -| PList legacy 복원 | raw restore와 fail-closed materialization 계약은 구현했다. 실제 기존 `DC_TITLE`/`LIST_TEXT`의 한국어 무손상 복원은 적용 중이며 완료 증거가 없다. | -| manual named save | GraphE·FSell·VI descriptor 계약과 복원 테스트는 있다. 실제 Oracle save→reload→fresh manual identity/version→page plan→PGM은 미검증이다. | +| PList legacy 복원 | 실제 `DC_TITLE`/`LIST_TEXT` 2,213행의 한국어·7필드 무손상과 typed persistence 일치를 확인했다. 기본 `selectionMapped=1,581`/차단 632와 별도 GraphE 22/14를 합친 결과는 1,603/610이다. selection 매핑은 scene data·asset·PREPARE·PGM 완료 증거가 아니다. | +| manual named save | 현재 legacy PList manual 36행은 모두 GraphE이며 fresh proof로 22행을 복원하고 저장값 무효 9행·identity 중복 5행을 차단했다. FSell/VI PList행은 0건이고 production source read는 FSell 3×5행, VI 9항목·2 page로 전후 불변이다. 실제 Oracle save→reload와 PGM은 미검증이다. | | ThemeA/EList identity | expected identity·상관 응답 계약은 구현했다. 실제 운영 DB의 KRX/NXT·동명이름·코드 경계와 정확한 선택 행 replace/delete는 미검증이다. | | legacy file import | `종목비교.dat`·FSell·VI importer는 strict parser, source/hash 재검증, durable intent/marker, rollback·OutcomeUnknown 차단까지 구현했다. 실제 패키지 profile의 명시적 1회 실행은 미검증이며 기존 파일을 덮어쓰지 않는다. | | 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 signed `1.0.3.0` Release MSIX package-context 실행은 화면/계약 검증 범위에서 확인됐다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·롤백 계획이 있는 회차에서만 수행한다. 운영 DB write 또는 새 Live TAKE IN을 승인 없이 실행하지 않는다. | diff --git a/scripts/Test-WebPlayout.ps1 b/scripts/Test-WebPlayout.ps1 index 1b27307..fd69a3c 100644 --- a/scripts/Test-WebPlayout.ps1 +++ b/scripts/Test-WebPlayout.ps1 @@ -60,7 +60,9 @@ $scripts = @( (Join-Path $repositoryRoot 'Web\manual-financial-workflow.js'), (Join-Path $repositoryRoot 'Web\named-manual-restore-workflow.js'), (Join-Path $repositoryRoot 'Web\named-comparison-restore-workflow.js'), + (Join-Path $repositoryRoot 'Web\named-foreign-stock-restore-workflow.js'), (Join-Path $repositoryRoot 'Web\legacy-foreign-index-candle-workflow.js'), + (Join-Path $repositoryRoot 'Web\named-krx-theme-restore-workflow.js'), (Join-Path $repositoryRoot 'Web\named-nxt-theme-restore-workflow.js'), (Join-Path $repositoryRoot 'Web\legacy-named-row-workflow.js'), (Join-Path $repositoryRoot 'Web\operator-catalog-workflow.js'), @@ -129,7 +131,11 @@ if ($appScript -notmatch 'normalizeScenePreviewFields' -or throw 'Web playout must render bounded authoritative scene data and complete page state.' } if ($appScript -notmatch 'checkbox\.checked = item\.enabled !== false' -or - $appScript -notmatch 'item\.enabled = checkbox\.checked' -or + $appScript -notmatch 'replacePlaylistEntryEnabledAt\(index, checkbox\.checked\)' -or + $appScript -match 'item\.enabled\s*=(?!=)' -or + $appScript -notmatch 'namedKrxThemeRestoreWorkflow\.withEnabled' -or + $appScript -notmatch 'namedNxtThemeRestoreWorkflow\.withEnabled' -or + $appScript -notmatch 'namedForeignStockRestoreWorkflow\.withEnabled' -or $appScript -notmatch 'isPlaylistSnapshotLocked' -or $appScript -notmatch 'pending: Boolean\(state\.playout\.pending\)' -or $appScript -notmatch 'outcomeUnknown: state\.playout\.sessionQuarantined') { @@ -142,6 +148,13 @@ if ($appScript -notmatch 'toggleAllEntriesEnabled' -or $appScript -notmatch 'event\.key === "End"') { throw 'Legacy playlist enabled-all, delete-all, range, Home and End controls are incomplete.' } +if ($appScript -match '\bitem\.[A-Za-z_$][A-Za-z0-9_$]*\s*=(?!=)' -or + $appScript -notmatch 'playlistEntryManualEditBlockReason' -or + $appScript -notmatch 'recreatePlaylistEntryWithSceneSelection' -or + $appScript -notmatch 'recreatePlaylistEntryWithLiveSelection' -or + $appScript -notmatch 'replacePlaylistEntryAt\(state\.currentIndex, replacement\)') { + throw 'Playlist scene and live-row edits must use immutable replacement and protect branded entries.' +} if ($appScript -notmatch 'postNative\("playout-timeout-quarantine", \{ requestId, command \}\)' -or $appScript -notmatch 'browserCorrelationQuarantined') { throw 'Web response timeouts must latch the process-lifetime native quarantine.' @@ -163,7 +176,7 @@ if ($indexMarkup -notmatch 'id="globalMa5"' -or throw 'The original global 5-day/20-day candle options are not synchronized across every candle workflow.' } foreach ($asset in @( - 'market-nav-reorder.js', 'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'legacy-foreign-index-candle-workflow.js', 'named-nxt-theme-restore-workflow.js', 'legacy-named-row-workflow.js', 'operator-catalog-workflow.js', + 'market-nav-reorder.js', 'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'named-foreign-stock-restore-workflow.js', 'legacy-foreign-index-candle-workflow.js', 'named-krx-theme-restore-workflow.js', 'named-nxt-theme-restore-workflow.js', 'legacy-named-row-workflow.js', 'operator-catalog-workflow.js', 'manual-lists-ui.js', 'manual-financial-ui.js', 'operator-catalog-ui.js')) { if ($indexMarkup -notmatch [regex]::Escape("")) { throw "Operator UI script is not loaded: $asset" diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs new file mode 100644 index 0000000..15404f6 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs @@ -0,0 +1,529 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using System.Text; + +namespace MMoneyCoderSharp.Data; + +public enum LegacyNamedForeignStockRestoreFailure +{ + InvalidRow, + IdentityNotFound, + IdentityAmbiguous, + DatabaseDataInvalid +} + +/// +/// Non-sensitive reason token for read-only diagnostics. No database value is +/// carried, so aggregate audits can distinguish schema, correlation and symbol +/// contract failures without printing an input name or symbol. +/// +public enum LegacyNamedForeignStockDatabaseIssue +{ + SchemaOrRowBound, + ValueType, + InputNameMismatch, + InputNameInvalid, + SymbolEmpty, + SymbolTooLong, + SymbolNotCanonical, + SymbolDataDelimiter, + SymbolCaret, + SymbolUnsupportedSlash, + SymbolUnsupportedPlus, + SymbolUnsupportedAmpersand, + SymbolUnsupportedApostrophe, + SymbolUnsupportedComma, + SymbolUnsupportedParenthesis, + SymbolUnsupportedHash, + SymbolUnsupportedPercent, + SymbolUnsupportedAsterisk, + SymbolUnsupportedEquals, + SymbolUnsupportedBackslash, + SymbolUnsupportedAsciiPunctuation, + SymbolNonAsciiFullWidth, + SymbolNonAsciiHangul, + SymbolNonAsciiCjk, + SymbolNonAsciiWhitespace, + SymbolNonAsciiLetterOrNumber, + SymbolNonAsciiPunctuation, + SymbolNonAsciiOther, + NationInvalid, + ForeignDomesticInvalid +} + +/// +/// One untrusted seven-field PLAY_LIST row from the original UC5 overseas-stock +/// selector. The row has an input name but did not persist its symbol or nation. +/// +public sealed record LegacyNamedForeignStockRestoreRow( + int ItemIndex, + bool IsEnabled, + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string PageText, + string DataCode); + +public sealed record LegacyNamedForeignStockRestoreIdentity( + string ActionId, + string InputName, + string Symbol, + string NationCode, + string ForeignDomesticCode, + string BuilderKey, + string CutCode, + string ValueType, + int? PeriodDays); + +public sealed record LegacyNamedForeignStockRestoreOutcome( + int ItemIndex, + LegacyNamedForeignStockRestoreIdentity? Identity, + LegacyNamedForeignStockRestoreFailure? Failure, + LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null) +{ + public bool IsResolved => + Identity is not null && Failure is null && DatabaseIssue is null; +} + +public sealed record LegacyNamedForeignStockRestoreResult( + DateTimeOffset RetrievedAt, + IReadOnlyList Rows); + +/// +/// Resolves the original UC5 `해외종목` rows against today's Oracle master. +/// Only an exact F_INPUT_NAME that identifies one distinct US/TW symbol is +/// accepted. Queries are bound and read-only, and this service never retries. +/// +public sealed class LegacyNamedForeignStockRestoreService +{ + public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems; + public const string QueryName = "LEGACY_NAMED_FOREIGN_STOCK_RESTORE"; + + private const string ExpectedGroupCode = "해외종목"; + private const string ExpectedPageText = "1/1"; + private const string ForeignDomesticCode = "1"; + private const string InputNameColumn = "F_INPUT_NAME"; + private const string SymbolColumn = "F_SYMB"; + private const string NationCodeColumn = "F_NATC"; + private const string ForeignDomesticColumn = "F_FDTC"; + private const int MaximumInputNameLength = 200; + private const int MaximumSymbolLength = 64; + + private const string IdentitySql = """ + SELECT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC + FROM ( + SELECT DISTINCT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC + FROM T_WORLD_IX_EQ_MASTER + WHERE F_FDTC = '1' + AND F_NATC IN ('US', 'TW') + AND F_INPUT_NAME = :input_name + ORDER BY F_SYMB, F_NATC + ) + WHERE ROWNUM <= 2 + """; + + private static readonly IReadOnlyDictionary CandleActions = + new Dictionary(StringComparer.Ordinal) + { + ["5일"] = new("stock-candle-5d", "s8010", "8061", "PRICE", 5), + ["20일"] = new("stock-candle-20d", "s8010", "8040", "PRICE", 20), + ["60일"] = new("stock-candle-60d", "s8010", "8046", "PRICE", 60), + ["120일"] = new("stock-candle-120d", "s8010", "8051", "PRICE", 120), + ["240일"] = new("stock-candle-240d", "s8010", "8056", "PRICE", 240) + }; + + private static readonly ActionProfile CurrentAction = + new("stock-current", "s5001", "5001", "CURRENT", PeriodDays: null); + + private readonly IDataQueryExecutor _executor; + private readonly TimeProvider _timeProvider; + + public LegacyNamedForeignStockRestoreService(IDataQueryExecutor executor) + : this(executor, TimeProvider.System) + { + } + + internal LegacyNamedForeignStockRestoreService( + IDataQueryExecutor executor, + TimeProvider timeProvider) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + /// + /// Routes only the exact legacy group and closed UC5 action grammar. The + /// canonical FOREIGN_STOCK format is deliberately handled by its existing + /// identity-bearing workflow and cannot collide with this restore pass. + /// + public static bool IsCandidate(LegacyNamedForeignStockRestoreRow? row) => + row is not null && + row.ItemIndex is >= 0 and < MaximumRows && + string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) && + IsCanonicalText(row.Subject, MaximumInputNameLength, allowEmpty: false) && + TryGetAction(row.GraphicType, row.Subtype, out _) && + string.Equals(row.PageText, ExpectedPageText, StringComparison.Ordinal) && + string.Equals(row.DataCode, string.Empty, StringComparison.Ordinal); + + public async Task ResolveAsync( + IReadOnlyList rows, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(rows); + if (rows.Count is < 1 or > MaximumRows) + { + throw new ArgumentOutOfRangeException( + nameof(rows), + $"A named foreign-stock restore must contain 1 through {MaximumRows} rows."); + } + + var itemIndexes = new HashSet(); + foreach (var row in rows) + { + if (row is null || row.ItemIndex is < 0 or >= MaximumRows || + !itemIndexes.Add(row.ItemIndex)) + { + throw new ArgumentException( + "Named foreign-stock restore rows must have unique bounded item indexes.", + nameof(rows)); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + var identityCache = new Dictionary(StringComparer.Ordinal); + var outcomes = new List(rows.Count); + foreach (var row in rows) + { + cancellationToken.ThrowIfCancellationRequested(); + outcomes.Add(await ResolveRowAsync(row, identityCache, cancellationToken) + .ConfigureAwait(false)); + } + + return new LegacyNamedForeignStockRestoreResult( + _timeProvider.GetLocalNow(), + outcomes.AsReadOnly()); + } + + private async Task ResolveRowAsync( + LegacyNamedForeignStockRestoreRow row, + IDictionary identityCache, + CancellationToken cancellationToken) + { + if (!IsCandidate(row) || + !TryGetAction(row.GraphicType, row.Subtype, out var action)) + { + return Failed(row.ItemIndex, LegacyNamedForeignStockRestoreFailure.InvalidRow); + } + + if (!identityCache.TryGetValue(row.Subject, out var resolution)) + { + resolution = await ResolveIdentityAsync(row.Subject, cancellationToken) + .ConfigureAwait(false); + identityCache.Add(row.Subject, resolution); + } + + if (resolution.Failure.HasValue) + { + return Failed( + row.ItemIndex, + resolution.Failure.Value, + resolution.DatabaseIssue); + } + + var identity = resolution.Identity!; + return new LegacyNamedForeignStockRestoreOutcome( + row.ItemIndex, + new LegacyNamedForeignStockRestoreIdentity( + action.ActionId, + identity.InputName, + identity.Symbol, + identity.NationCode, + ForeignDomesticCode, + action.BuilderKey, + action.CutCode, + action.ValueType, + action.PeriodDays), + Failure: null, + DatabaseIssue: null); + } + + private async Task ResolveIdentityAsync( + string inputName, + CancellationToken cancellationToken) + { + var spec = new DataQuerySpec( + IdentitySql, + [new DataQueryParameter("input_name", inputName, DbType.String)]); + spec.ValidateFor(DataSourceKind.Oracle); + + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + QueryName, + spec, + cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return ValidateIdentityTable(table, inputName); + } + + private static IdentityResolution ValidateIdentityTable(DataTable? table, string inputName) + { + if (table is null || table.Columns.Count != 4 || table.Rows.Count > 2 || + !HasExactStringColumn(table.Columns[0], InputNameColumn) || + !HasExactStringColumn(table.Columns[1], SymbolColumn) || + !HasExactStringColumn(table.Columns[2], NationCodeColumn) || + !HasExactStringColumn(table.Columns[3], ForeignDomesticColumn)) + { + return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound); + } + + var identities = new List(table.Rows.Count); + foreach (DataRow row in table.Rows) + { + if (row[0] is not string foundInputName || + row[1] is not string symbol || + row[2] is not string nationCode || + row[3] is not string foreignDomestic) + { + return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ValueType); + } + + if (!string.Equals(foundInputName, inputName, StringComparison.Ordinal)) + { + return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameMismatch); + } + + if (!IsCanonicalText(foundInputName, MaximumInputNameLength, allowEmpty: false)) + { + return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameInvalid); + } + + var symbolIssue = GetSymbolIssue(symbol); + if (symbolIssue.HasValue) + { + return InvalidDatabaseData(symbolIssue.Value); + } + + if (nationCode is not ("US" or "TW")) + { + return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.NationInvalid); + } + + if (!string.Equals(foreignDomestic, ForeignDomesticCode, StringComparison.Ordinal)) + { + return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ForeignDomesticInvalid); + } + + identities.Add(new WorldStockSearchItem(foundInputName, symbol, nationCode)); + } + + return identities.Count switch + { + 0 => new IdentityResolution( + Identity: null, + LegacyNamedForeignStockRestoreFailure.IdentityNotFound), + 1 => new IdentityResolution(identities[0], Failure: null), + 2 => new IdentityResolution( + Identity: null, + LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous), + _ => InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound) + }; + } + + private static bool TryGetAction( + string? graphicType, + string? subtype, + out ActionProfile action) + { + action = null!; + if (string.Equals(graphicType, "1열판기본", StringComparison.Ordinal) && + string.Equals(subtype, string.Empty, StringComparison.Ordinal)) + { + action = CurrentAction; + return true; + } + + return string.Equals(graphicType, "캔들그래프", StringComparison.Ordinal) && + subtype is not null && + CandleActions.TryGetValue(subtype, out action!); + } + + internal static bool IsCanonicalBoundSymbol(string? value) => + value is not null && GetSymbolIssue(value) is null; + + private static LegacyNamedForeignStockDatabaseIssue? GetSymbolIssue(string value) + { + if (value.Length == 0) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolEmpty; + } + + if (value.Length > MaximumSymbolLength) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolTooLong; + } + + if (!string.Equals(value, value.Trim(), StringComparison.Ordinal)) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical; + } + + foreach (var rune in value.EnumerateRunes()) + { + if (rune.Value == '|') + { + return LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter; + } + + if (rune.Value == '^') + { + return LegacyNamedForeignStockDatabaseIssue.SymbolCaret; + } + + var category = Rune.GetUnicodeCategory(rune); + if (Rune.IsControl(rune) || rune.Value == '\uFFFD' || + category is UnicodeCategory.Format or UnicodeCategory.Surrogate or + UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical; + } + + // Oracle currently contains a small set of provider symbols with + // Hangul letters and internal ASCII spaces. They are safe identity + // data: the runtime passes the symbol only as a bound parameter, + // canonical validation rejects edge whitespace, and serialized + // identity uses '|' (not a letter/number/space) as its delimiter. + if (Rune.IsLetterOrDigit(rune)) + { + continue; + } + + if (rune.Value > 0x7F) + { + if (rune.Value is >= 0xFF01 and <= 0xFF5E) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiFullWidth; + } + + if (rune.Value is >= 0xAC00 and <= 0xD7AF or + >= 0x1100 and <= 0x11FF or + >= 0x3130 and <= 0x318F) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiHangul; + } + + if (rune.Value is >= 0x3400 and <= 0x9FFF or + >= 0xF900 and <= 0xFAFF) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiCjk; + } + + if (Rune.IsWhiteSpace(rune)) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiWhitespace; + } + + if (category is UnicodeCategory.ConnectorPunctuation or + UnicodeCategory.DashPunctuation or + UnicodeCategory.OpenPunctuation or + UnicodeCategory.ClosePunctuation or + UnicodeCategory.InitialQuotePunctuation or + UnicodeCategory.FinalQuotePunctuation or + UnicodeCategory.OtherPunctuation) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiPunctuation; + } + + return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther; + } + + var character = (char)rune.Value; + if (character == '/') + { + return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedSlash; + } + + var punctuationIssue = character switch + { + '+' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPlus, + '&' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAmpersand, + '\'' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedApostrophe, + ',' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedComma, + '(' or ')' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedParenthesis, + '#' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedHash, + '%' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPercent, + '*' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsterisk, + '=' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedEquals, + '\\' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedBackslash, + _ => (LegacyNamedForeignStockDatabaseIssue?)null + }; + if (punctuationIssue.HasValue) + { + return punctuationIssue.Value; + } + + if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' ')) + { + return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation; + } + } + + return null; + } + + private static bool IsCanonicalText(string? value, int maximumLength, bool allowEmpty) + { + if (value is null || value.Length > maximumLength || + (!allowEmpty && value.Length == 0) || + !string.Equals(value, value.Trim(), StringComparison.Ordinal)) + { + return false; + } + + foreach (var rune in value.EnumerateRunes()) + { + var category = Rune.GetUnicodeCategory(rune); + if (Rune.IsControl(rune) || rune.Value == '\uFFFD' || + category is UnicodeCategory.Format or UnicodeCategory.Surrogate or + UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return !value.Contains('^'); + } + + private static bool HasExactStringColumn(DataColumn column, string name) => + string.Equals(column.ColumnName, name, StringComparison.Ordinal) && + column.DataType == typeof(string); + + private static LegacyNamedForeignStockRestoreOutcome Failed( + int itemIndex, + LegacyNamedForeignStockRestoreFailure failure, + LegacyNamedForeignStockDatabaseIssue? databaseIssue = null) => + new(itemIndex, Identity: null, failure, databaseIssue); + + private static IdentityResolution InvalidDatabaseData( + LegacyNamedForeignStockDatabaseIssue issue) => + new( + Identity: null, + LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid, + issue); + + private sealed record ActionProfile( + string ActionId, + string BuilderKey, + string CutCode, + string ValueType, + int? PeriodDays); + + private sealed record IdentityResolution( + WorldStockSearchItem? Identity, + LegacyNamedForeignStockRestoreFailure? Failure, + LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs new file mode 100644 index 0000000..c1a1d6f --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs @@ -0,0 +1,487 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace MMoneyCoderSharp.Data; + +public enum LegacyNamedKrxThemeRestoreFailure +{ + InvalidRow, + UnsupportedAction, + IdentityNotFound, + IdentityAmbiguous, + PreviewEmpty, + DatabaseDataInvalid +} + +/// +/// One untrusted seven-field PLAY_LIST row routed to the KRX-theme verifier. +/// Every original field remains evidence; a successful restore uses the +/// current Oracle identity for scene selection. +/// +public sealed record LegacyNamedKrxThemeRestoreRow( + int ItemIndex, + bool IsEnabled, + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string PageText, + string DataCode); + +/// +/// A closed current KRX identity. StoredThemeCode is evidence only and must +/// never be used by PREPARE after this identity has been resolved. +/// +public sealed record LegacyNamedKrxThemeRestoreIdentity( + string ActionId, + string BuilderKey, + string CutCode, + int PageSize, + string Sort, + string ThemeTitle, + string StoredThemeCode, + string CurrentThemeCode, + int PreviewItemCount, + bool PreviewIsTruncated) +{ + public bool CodeWasRemapped => + !string.Equals(StoredThemeCode, CurrentThemeCode, StringComparison.Ordinal); +} + +public sealed record LegacyNamedKrxThemeRestoreOutcome( + int ItemIndex, + LegacyNamedKrxThemeRestoreIdentity? Identity, + LegacyNamedKrxThemeRestoreFailure? Failure, + string? ObservedCurrentThemeCode = null) +{ + public bool IsResolved => Identity is not null && Failure is null; +} + +public sealed record LegacyNamedKrxThemeRestoreResult( + DateTimeOffset RetrievedAt, + IReadOnlyList Rows); + +/// +/// Rehydrates legacy `테마` rows through exact, bound Oracle reads. The old +/// s5074/s5077/s5088 constructors looked the code up by exact title immediately +/// before loading data. This service closes that lookup to one title and one +/// code, validates the selected items through LegacyThemeSelectionService, and +/// retains the stale stored code solely for lossless PLAY_LIST persistence. +/// +public sealed partial class LegacyNamedKrxThemeRestoreService +{ + public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems; + public const string IdentityQueryName = "LEGACY_NAMED_KRX_THEME_IDENTITY"; + + private const string ExpectedGroupCode = "테마"; + private const int MaximumTitleLength = 128; + + private static readonly IReadOnlyDictionary Subtypes = + new Dictionary(StringComparer.Ordinal) + { + ["테마-현재가(입력순)"] = new("CURRENT", "INPUT_ORDER"), + ["테마-현재가(상승률순)"] = new("CURRENT", "GAIN_DESC"), + ["테마-현재가(하락률순)"] = new("CURRENT", "GAIN_ASC"), + // All three original scene constructors fall through to their + // final loss-rate branch when no parenthesized token is present. + ["테마-현재가"] = new("CURRENT", "GAIN_ASC"), + ["테마-예상체결가(입력순)"] = new("EXPECTED", "INPUT_ORDER"), + ["테마-예상체결가(상승률순)"] = new("EXPECTED", "GAIN_DESC"), + ["테마-예상체결가(하락률순)"] = new("EXPECTED", "GAIN_ASC"), + ["테마-예상체결가"] = new("EXPECTED", "GAIN_ASC") + }; + + private static readonly IReadOnlyDictionary Actions = + new Dictionary(StringComparer.Ordinal) + { + [ActionKey("5단 표그래프", "CURRENT")] = + new("five-row-current", "s5074", "5074", 5), + [ActionKey("5단 표그래프", "EXPECTED")] = + new("five-row-expected", "s5074", "5074", 5), + [ActionKey("6종목 현재가", "CURRENT")] = + new("six-row-current", "s5077", "5077", 6), + [ActionKey("12종목 현재가", "CURRENT")] = + new("twelve-row-current", "s5088", "5088", 12) + }; + + private const string IdentitySql = """ + SELECT THEME_TITLE, THEME_CODE, THEME_MARKET, CODE_IDENTITY_COUNT + FROM ( + SELECT s.SB_TITLE THEME_TITLE, + s.SB_CODE THEME_CODE, + s.SB_MARKET THEME_MARKET, + TO_CHAR((SELECT COUNT(*) + FROM SB_LIST d + WHERE d.SB_MARKET = 'KRX' + AND d.SB_CODE = s.SB_CODE), 'FM999999990') + CODE_IDENTITY_COUNT + FROM SB_LIST s + WHERE s.SB_MARKET = 'KRX' + AND s.SB_TITLE = :theme_title + ORDER BY s.SB_CODE + ) + WHERE ROWNUM <= 2 + """; + + private static readonly string[] IdentityColumns = + ["THEME_TITLE", "THEME_CODE", "THEME_MARKET", "CODE_IDENTITY_COUNT"]; + + private readonly IDataQueryExecutor _executor; + private readonly LegacyThemeSelectionService _themeSelectionService; + private readonly TimeProvider _timeProvider; + + public LegacyNamedKrxThemeRestoreService(IDataQueryExecutor executor) + : this(executor, TimeProvider.System) + { + } + + internal LegacyNamedKrxThemeRestoreService( + IDataQueryExecutor executor, + TimeProvider timeProvider) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _themeSelectionService = new LegacyThemeSelectionService(_executor); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + /// + /// Routes every exact `테마` group row, including malformed grammar, so + /// native verification returns an explicit closed result rather than + /// allowing the stale code through the generic mapper. + /// + public static bool IsCandidate(LegacyNamedKrxThemeRestoreRow? row) => + row is not null && + row.ItemIndex is >= 0 and < MaximumRows && + string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal); + + public async Task ResolveAsync( + IReadOnlyList rows, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(rows); + if (rows.Count is < 1 or > MaximumRows) + { + throw new ArgumentOutOfRangeException( + nameof(rows), + $"A KRX-theme restore must contain 1 through {MaximumRows} rows."); + } + + var indexes = new HashSet(); + foreach (var row in rows) + { + if (row is null || row.ItemIndex is < 0 or >= MaximumRows || + !indexes.Add(row.ItemIndex)) + { + throw new ArgumentException( + "KRX-theme restore rows must have unique bounded item indexes.", + nameof(rows)); + } + } + + var verifications = new Dictionary(StringComparer.Ordinal); + var outcomes = new List(rows.Count); + foreach (var row in rows) + { + cancellationToken.ThrowIfCancellationRequested(); + outcomes.Add(await ResolveRowAsync(row, verifications, cancellationToken) + .ConfigureAwait(false)); + } + + return new LegacyNamedKrxThemeRestoreResult( + _timeProvider.GetLocalNow(), + outcomes.AsReadOnly()); + } + + private async Task ResolveRowAsync( + LegacyNamedKrxThemeRestoreRow row, + IDictionary verifications, + CancellationToken cancellationToken) + { + var classification = Classify(row); + if (classification.Failure.HasValue) + { + return Failed(row.ItemIndex, classification.Failure.Value); + } + + var title = classification.Title!; + if (!verifications.TryGetValue(title, out var verification)) + { + verification = await VerifyThemeAsync(title, cancellationToken).ConfigureAwait(false); + verifications.Add(title, verification); + } + + if (verification.Failure.HasValue) + { + return Failed( + row.ItemIndex, + verification.Failure.Value, + verification.CurrentThemeCode); + } + + var profile = classification.Action!; + return new LegacyNamedKrxThemeRestoreOutcome( + row.ItemIndex, + new LegacyNamedKrxThemeRestoreIdentity( + profile.ActionId, + profile.BuilderKey, + profile.CutCode, + profile.PageSize, + classification.Sort!, + title, + row.DataCode, + verification.CurrentThemeCode!, + verification.PreviewItemCount, + verification.PreviewIsTruncated), + Failure: null, + ObservedCurrentThemeCode: verification.CurrentThemeCode); + } + + private static RowClassification Classify(LegacyNamedKrxThemeRestoreRow row) + { + if (!string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) || + !IsSafeCanonicalText(row.Subject, MaximumTitleLength) || + row.Subject.Contains("(NXT)", StringComparison.Ordinal) || + !StoredThemeCodePattern().IsMatch(row.DataCode ?? string.Empty) || + !IsValidStoredPage(row.PageText)) + { + return new RowClassification(Failure: LegacyNamedKrxThemeRestoreFailure.InvalidRow); + } + + if (!Subtypes.TryGetValue(row.Subtype ?? string.Empty, out var subtype)) + { + return new RowClassification( + Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction); + } + + if (!Actions.TryGetValue( + ActionKey(row.GraphicType ?? string.Empty, subtype.ValueKind), + out var action)) + { + return new RowClassification( + Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction); + } + + return new RowClassification(row.Subject, action, subtype.Sort, Failure: null); + } + + private async Task VerifyThemeAsync( + string title, + CancellationToken cancellationToken) + { + var spec = new DataQuerySpec( + IdentitySql, + [new DataQueryParameter("theme_title", title, DbType.String)]); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await _executor.ExecuteAsync( + DataSourceKind.Oracle, + IdentityQueryName, + spec, + cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var identity = ValidateIdentityTable(table, title); + if (identity.Failure.HasValue) + { + return identity; + } + + var selectedIdentity = new ThemeSelectionIdentity( + ThemeMarket.Krx, + ThemeSession.NotApplicable, + identity.CurrentThemeCode!, + title); + ThemePreviewResult preview; + try + { + preview = await _themeSelectionService.PreviewAsync( + selectedIdentity, + LegacyThemeSelectionService.MaximumPreviewItems, + cancellationToken) + .ConfigureAwait(false); + } + catch (ThemeSelectionDataException) + { + return InvalidDatabaseData(identity.CurrentThemeCode); + } + + if (!Equals(preview.Identity, selectedIdentity) || + preview.Items.Count > LegacyThemeSelectionService.MaximumPreviewItems || + preview.IsTruncated && + preview.Items.Count != LegacyThemeSelectionService.MaximumPreviewItems) + { + return InvalidDatabaseData(identity.CurrentThemeCode); + } + + if (preview.Items.Count == 0) + { + return identity with + { + Failure = LegacyNamedKrxThemeRestoreFailure.PreviewEmpty + }; + } + + return identity with + { + PreviewItemCount = preview.Items.Count, + PreviewIsTruncated = preview.IsTruncated + }; + } + + private static ThemeVerification ValidateIdentityTable(DataTable? table, string title) + { + if (table is null || table.Columns.Count != IdentityColumns.Length || + table.Rows.Count > 2) + { + return InvalidDatabaseData(); + } + + for (var index = 0; index < IdentityColumns.Length; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + IdentityColumns[index], + StringComparison.Ordinal) || + table.Columns[index].DataType != typeof(string)) + { + return InvalidDatabaseData(); + } + } + + if (table.Rows.Count == 0) + { + return new ThemeVerification( + Failure: LegacyNamedKrxThemeRestoreFailure.IdentityNotFound); + } + + if (table.Rows.Count > 1) + { + return new ThemeVerification( + Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous); + } + + var row = table.Rows[0]; + if (row[0] is not string actualTitle || + row[1] is not string code || + row[2] is not string market || + !string.Equals(actualTitle, title, StringComparison.Ordinal) || + !string.Equals(market, "KRX", StringComparison.Ordinal) || + !StoredThemeCodePattern().IsMatch(code) || + !TryReadCount(row[3], out var codeIdentityCount)) + { + return InvalidDatabaseData(); + } + + if (codeIdentityCount != 1) + { + return new ThemeVerification( + CurrentThemeCode: code, + Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous); + } + + return new ThemeVerification(CurrentThemeCode: code, Failure: null); + } + + private static bool TryReadCount(object value, out int result) + { + result = 0; + return value is string text && + int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out result) && + result is >= 0 and <= 1_000_000 && + string.Equals( + text, + result.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + + private static bool IsValidStoredPage(string value) + { + var match = StoredPagePattern().Match(value ?? string.Empty); + if (!match.Success || + !int.TryParse( + match.Groups[1].Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var current) || + !int.TryParse( + match.Groups[2].Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var total)) + { + return false; + } + + return current is >= 1 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount && + total is >= 0 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount && + (total == 0 ? current == 1 : current <= total); + } + + private static bool IsSafeCanonicalText(string? value, int maximumLength) + { + if (string.IsNullOrEmpty(value) || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + value.Contains('^')) + { + return false; + } + + foreach (var rune in value.EnumerateRunes()) + { + var category = Rune.GetUnicodeCategory(rune); + if (Rune.IsControl(rune) || rune.Value == '\uFFFD' || + category is UnicodeCategory.Format or UnicodeCategory.Surrogate or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static string ActionKey(string graphicType, string valueKind) => + string.Concat(graphicType, "\u001F", valueKind); + + private static LegacyNamedKrxThemeRestoreOutcome Failed( + int itemIndex, + LegacyNamedKrxThemeRestoreFailure failure, + string? observedCurrentThemeCode = null) => + new(itemIndex, Identity: null, failure, observedCurrentThemeCode); + + private static ThemeVerification InvalidDatabaseData(string? currentThemeCode = null) => + new( + CurrentThemeCode: currentThemeCode, + Failure: LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid); + + private sealed record ActionProfile( + string ActionId, + string BuilderKey, + string CutCode, + int PageSize); + + private sealed record SubtypeProfile(string ValueKind, string Sort); + + private sealed record RowClassification( + string? Title = null, + ActionProfile? Action = null, + string? Sort = null, + LegacyNamedKrxThemeRestoreFailure? Failure = null); + + private sealed record ThemeVerification( + string? CurrentThemeCode = null, + int PreviewItemCount = 0, + bool PreviewIsTruncated = false, + LegacyNamedKrxThemeRestoreFailure? Failure = null); + + [GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)] + private static partial Regex StoredThemeCodePattern(); + + [GeneratedRegex("^([1-9][0-9]{0,3})/([0-9]{1,4})$", RegexOptions.CultureInvariant)] + private static partial Regex StoredPagePattern(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs new file mode 100644 index 0000000..056b291 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs @@ -0,0 +1,511 @@ +#nullable enable + +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MMoneyCoderSharp.Data; + +/// +/// Closed kinds understood by the named-playlist manual restore workflow. The +/// candidate deliberately has a non-data-bearing ToString implementation so an +/// audit failure cannot accidentally print an operator stock name or VI list. +/// +public enum LegacyNamedManualRestoreKind +{ + Financial, + NetSell, + Vi +} + +public enum LegacyNamedManualFreshProofFailure +{ + None, + FinancialSnapshotChanged, + FinancialStockSearchTruncated, + FinancialStockIdentityNotUnique, + FinancialSelectionMismatch, + NetSellSnapshotInvalid, + ViSnapshotInvalid, + ViSnapshotVersionChanged, + ViHistoricalNamesChanged, + ViPageCountChanged +} + +public sealed class LegacyNamedManualRestoreCandidate +{ + internal LegacyNamedManualRestoreCandidate( + int itemIndex, + LegacyNamedManualRestoreKind kind, + bool isHistorical, + ManualFinancialScreenKind? financialScreen = null, + StockMarket? market = null, + string? stockName = null, + S5025ManualAudience? audience = null, + IReadOnlyList? historicalViNames = null, + string? expectedViVersion = null, + int expectedPageCount = 1) + { + ItemIndex = itemIndex; + Kind = kind; + IsHistorical = isHistorical; + FinancialScreen = financialScreen; + Market = market; + StockName = stockName; + Audience = audience; + HistoricalViNames = historicalViNames; + ExpectedViVersion = expectedViVersion; + ExpectedPageCount = expectedPageCount; + } + + public int ItemIndex { get; } + + public LegacyNamedManualRestoreKind Kind { get; } + + public bool IsHistorical { get; } + + public ManualFinancialScreenKind? FinancialScreen { get; } + + public StockMarket? Market { get; } + + public string? StockName { get; } + + public S5025ManualAudience? Audience { get; } + + public IReadOnlyList? HistoricalViNames { get; } + + public string? ExpectedViVersion { get; } + + public int ExpectedPageCount { get; } + + public override string ToString() => $"Named manual restore candidate ({Kind})"; +} + +/// +/// C# equivalent of Web/named-manual-restore-workflow.js classify(). It accepts +/// only the historical seven-field PList grammar and the canonical grammar +/// emitted after a fresh materialization. It performs no I/O. +/// +public static class LegacyNamedManualRestoreClassifier +{ + private const int ViPageSize = 5; + private const int MaximumViItems = 100; + + public static LegacyNamedManualRestoreCandidate? Classify(LegacyPListAuditRow row) + { + ArgumentNullException.ThrowIfNull(row); + return ClassifyNetSell(row) ?? ClassifyVi(row) ?? ClassifyFinancial(row); + } + + private static LegacyNamedManualRestoreCandidate? ClassifyNetSell( + LegacyPListAuditRow row) + { + if (row.PageTextForDisplay != "1/1" || row.DataCode.Length != 0) + { + return null; + } + + if (TryCanonicalAudience(row.GroupCode, out var canonical) && + row.Subject == "INDEX" && + row.GraphicType == "MANUAL_NET_SELL" && + row.Subtype == "MANUAL_NET_SELL") + { + return new LegacyNamedManualRestoreCandidate( + row.ItemIndex, + LegacyNamedManualRestoreKind.NetSell, + isHistorical: false, + audience: canonical); + } + + if (TryHistoricalAudience(row.GroupCode, out var historical) && + row.Subject == "지수" && + row.GraphicType == "순매도 상위" && + row.Subtype == "순매도 상위") + { + return new LegacyNamedManualRestoreCandidate( + row.ItemIndex, + LegacyNamedManualRestoreKind.NetSell, + isHistorical: true, + audience: historical); + } + + return null; + } + + private static LegacyNamedManualRestoreCandidate? ClassifyVi( + LegacyPListAuditRow row) + { + if (!TryPageCount(row.PageTextForDisplay, out var pageCount)) + { + return null; + } + + if (row.GroupCode == "PAGED_VI" && + row.Subject == TrustedViManualReference.SubjectSentinel && + row.GraphicType == "INPUT_ORDER" && + row.Subtype == "CURRENT" && + TrustedViManualReference.IsRowVersion(row.DataCode)) + { + return new LegacyNamedManualRestoreCandidate( + row.ItemIndex, + LegacyNamedManualRestoreKind.Vi, + isHistorical: false, + expectedViVersion: row.DataCode, + expectedPageCount: pageCount); + } + + if (row.GroupCode.Length != 0 || + row.GraphicType != "5단 표그래프" || + row.Subtype != "VI 발동(수동)" || + row.DataCode.Length != 0 || + !TryHistoricalViNames(row.Subject, out var names) || + pageCount != DivideRoundUp(names.Count, ViPageSize)) + { + return null; + } + + return new LegacyNamedManualRestoreCandidate( + row.ItemIndex, + LegacyNamedManualRestoreKind.Vi, + isHistorical: true, + historicalViNames: names, + expectedPageCount: pageCount); + } + + private static LegacyNamedManualRestoreCandidate? ClassifyFinancial( + LegacyPListAuditRow row) + { + if (row.PageTextForDisplay != "1/1" || + row.Subtype.Length != 0 || + row.DataCode.Length != 0 || + !IsTrimmedVisible(row.Subject, 200)) + { + return null; + } + + var canonicalMarket = TryCanonicalMarket(row.GroupCode, out var canonical); + var historicalMarket = TryHistoricalMarket(row.GroupCode, out var historical); + foreach (var contract in ManualFinancialCutContracts.All) + { + if (canonicalMarket && row.GraphicType == contract.CanonicalGraphicType) + { + return Financial(row, contract.Screen, canonical, isHistorical: false); + } + + if (historicalMarket && row.GraphicType == contract.OriginalGraphicType) + { + return Financial(row, contract.Screen, historical, isHistorical: true); + } + } + + return null; + } + + private static LegacyNamedManualRestoreCandidate Financial( + LegacyPListAuditRow row, + ManualFinancialScreenKind screen, + StockMarket market, + bool isHistorical) => + new( + row.ItemIndex, + LegacyNamedManualRestoreKind.Financial, + isHistorical, + financialScreen: screen, + market: market, + stockName: row.Subject); + + private static bool TryCanonicalAudience( + string value, + out S5025ManualAudience audience) + { + audience = value switch + { + "INDIVIDUAL" => S5025ManualAudience.Individual, + "FOREIGN" => S5025ManualAudience.Foreign, + "INSTITUTION" => S5025ManualAudience.Institution, + _ => default + }; + return value is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION"; + } + + private static bool TryHistoricalAudience( + string value, + out S5025ManualAudience audience) + { + audience = value switch + { + "개인 순매도 상위(수동)" => S5025ManualAudience.Individual, + "외국인 순매도 상위(수동)" => S5025ManualAudience.Foreign, + "기관 순매도 상위(수동)" => S5025ManualAudience.Institution, + _ => default + }; + return value is + "개인 순매도 상위(수동)" or + "외국인 순매도 상위(수동)" or + "기관 순매도 상위(수동)"; + } + + private static bool TryCanonicalMarket(string value, out StockMarket market) + { + market = value switch + { + "KOSPI" => StockMarket.Kospi, + "KOSDAQ" => StockMarket.Kosdaq, + "NXT_KOSPI" => StockMarket.NxtKospi, + "NXT_KOSDAQ" => StockMarket.NxtKosdaq, + _ => default + }; + return value is "KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ"; + } + + private static bool TryHistoricalMarket(string value, out StockMarket market) + { + market = value switch + { + "코스피" => StockMarket.Kospi, + "코스닥" => StockMarket.Kosdaq, + "코스피_NXT" => StockMarket.NxtKospi, + "코스닥_NXT" => StockMarket.NxtKosdaq, + _ => default + }; + return value is "코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT"; + } + + private static bool TryPageCount(string value, out int pageCount) + { + pageCount = 0; + if (!value.StartsWith("1/", StringComparison.Ordinal) || + !int.TryParse(value.AsSpan(2), out var parsed) || + parsed is < 1 or > 20 || + value != $"1/{parsed}") + { + return false; + } + + pageCount = parsed; + return true; + } + + private static bool TryHistoricalViNames( + string value, + out IReadOnlyList names) + { + names = Array.Empty(); + if (!IsTrimmedVisible(value, LegacyPListReadAuditParser.MaximumListTextLength)) + { + return false; + } + + var values = value.Split(',', StringSplitOptions.None); + if (values.Length is < 1 or > MaximumViItems || + values.Any(name => !IsTrimmedVisible(name, 200))) + { + return false; + } + + names = Array.AsReadOnly(values); + return true; + } + + private static bool IsTrimmedVisible(string value, int maximumLength) => + value.Length is > 0 && + value.Length <= maximumLength && + value == value.Trim() && + !value.Any(char.IsControl) && + !value.Contains('\uFFFD'); + + private static int DivideRoundUp(int value, int divisor) => + checked((value + divisor - 1) / divisor); +} + +/// +/// Pure checks for the evidence gathered through production read boundaries. +/// No method opens a database or file and no result contains source text. +/// +public static class LegacyNamedManualFreshProof +{ + private const int ViPageSize = 5; + + public static string FinancialStockSearchQuery( + LegacyNamedManualRestoreCandidate candidate) + { + RequireKind(candidate, LegacyNamedManualRestoreKind.Financial); + var stockName = candidate.StockName!; + if (candidate.Market is not (StockMarket.NxtKospi or StockMarket.NxtKosdaq)) + { + return stockName; + } + + const string suffix = "(NXT)"; + if (!stockName.EndsWith(suffix, StringComparison.Ordinal) || + stockName.Length == suffix.Length) + { + throw new ArgumentException( + "An NXT manual-financial identity requires its original display suffix.", + nameof(candidate)); + } + + var query = stockName[..^suffix.Length]; + if (query.Length == 0 || query != query.Trim() || query.Contains(suffix)) + { + throw new ArgumentException( + "An NXT manual-financial identity has an invalid search identity.", + nameof(candidate)); + } + + return query; + } + + public static LegacyNamedManualFreshProofFailure EvaluateFinancial( + LegacyNamedManualRestoreCandidate candidate, + ManualFinancialSnapshot firstRead, + ManualFinancialSnapshot secondRead, + StockSearchResult stockSearch) + { + RequireKind(candidate, LegacyNamedManualRestoreKind.Financial); + ArgumentNullException.ThrowIfNull(firstRead); + ArgumentNullException.ThrowIfNull(secondRead); + ArgumentNullException.ThrowIfNull(stockSearch); + + var identity = new ManualFinancialIdentity( + candidate.FinancialScreen!.Value, + candidate.StockName!); + if (firstRead.Record.Identity != identity || + secondRead.Record.Identity != identity || + !string.Equals(firstRead.RowVersion, secondRead.RowVersion, StringComparison.Ordinal)) + { + return LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged; + } + + if (stockSearch.IsTruncated || + !string.Equals( + stockSearch.Query, + FinancialStockSearchQuery(candidate), + StringComparison.Ordinal)) + { + return LegacyNamedManualFreshProofFailure.FinancialStockSearchTruncated; + } + + var matches = stockSearch.Items + .Where(item => + item.Market == candidate.Market && + string.Equals(item.Name, candidate.StockName, StringComparison.Ordinal)) + .ToArray(); + if (matches.Length != 1) + { + return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique; + } + + try + { + var verified = ManualFinancialCutContracts.VerifyStock( + identity, + stockSearch.Items, + candidate.Market!.Value, + matches[0].Code); + var selection = ManualFinancialCutContracts.CreateSelection(secondRead, verified); + var contract = ManualFinancialCutContracts.Get(identity.Screen); + if (selection.BuilderKey != contract.BuilderKey || + selection.CutCode != contract.CutCode || + selection.CurrentPage != 1 || + selection.TotalPages != 1 || + selection.Selection.GroupCode != CanonicalMarket(candidate.Market.Value) || + selection.Selection.Subject != candidate.StockName || + selection.Selection.GraphicType != contract.CanonicalGraphicType || + selection.Selection.Subtype.Length != 0 || + selection.Selection.DataCode.Length != 0) + { + return LegacyNamedManualFreshProofFailure.FinancialSelectionMismatch; + } + } + catch (ArgumentException) + { + return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique; + } + + return LegacyNamedManualFreshProofFailure.None; + } + + public static LegacyNamedManualFreshProofFailure EvaluateNetSell( + LegacyNamedManualRestoreCandidate candidate, + IReadOnlyList rows) + { + RequireKind(candidate, LegacyNamedManualRestoreKind.NetSell); + if (rows is null || rows.Count != 5 || rows.Any(row => + row is null || + !ValidManualCell(row.LeftName) || + !ValidManualCell(row.LeftAmount) || + !ValidManualCell(row.RightName) || + !ValidManualCell(row.RightAmount))) + { + return LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid; + } + + return LegacyNamedManualFreshProofFailure.None; + } + + public static LegacyNamedManualFreshProofFailure EvaluateVi( + LegacyNamedManualRestoreCandidate candidate, + TrustedViStockNameSnapshot snapshot) + { + RequireKind(candidate, LegacyNamedManualRestoreKind.Vi); + if (snapshot is null || + !TrustedViManualReference.IsRowVersion(snapshot.RowVersion) || + snapshot.StockNames is null || + snapshot.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount || + snapshot.StockNames.Any(name => + name is null || + name.Length is < 1 or > 200 || + name != name.Trim() || + name.Contains(',') || + name.Any(char.IsControl))) + { + return LegacyNamedManualFreshProofFailure.ViSnapshotInvalid; + } + + if (candidate.ExpectedViVersion is not null && + !string.Equals( + candidate.ExpectedViVersion, + snapshot.RowVersion, + StringComparison.Ordinal)) + { + return LegacyNamedManualFreshProofFailure.ViSnapshotVersionChanged; + } + + if (candidate.HistoricalViNames is not null && + !candidate.HistoricalViNames.SequenceEqual( + snapshot.StockNames, + StringComparer.Ordinal)) + { + return LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged; + } + + var pageCount = checked((snapshot.StockNames.Count + ViPageSize - 1) / ViPageSize); + return pageCount == candidate.ExpectedPageCount + ? LegacyNamedManualFreshProofFailure.None + : LegacyNamedManualFreshProofFailure.ViPageCountChanged; + } + + private static void RequireKind( + LegacyNamedManualRestoreCandidate candidate, + LegacyNamedManualRestoreKind expected) + { + ArgumentNullException.ThrowIfNull(candidate); + if (candidate.Kind != expected) + { + throw new ArgumentException("The manual restore candidate kind is invalid.", nameof(candidate)); + } + } + + private static bool ValidManualCell(string value) => + value is not null && + value.Length <= 256 && + !value.Any(char.IsControl); + + private static string CanonicalMarket(StockMarket market) => market switch + { + StockMarket.Kospi => "KOSPI", + StockMarket.Kosdaq => "KOSDAQ", + StockMarket.NxtKospi => "NXT_KOSPI", + StockMarket.NxtKosdaq => "NXT_KOSDAQ", + _ => throw new ArgumentOutOfRangeException(nameof(market)) + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs new file mode 100644 index 0000000..a5613af --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs @@ -0,0 +1,341 @@ +#nullable enable + +using System.Data; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace MMoneyCoderSharp.Data; + +/// +/// A closed pair of current and persisted NXT theme codes used only by the +/// read-only restore diagnostic. Titles and stock identities never cross this +/// boundary. +/// +public sealed record LegacyNxtThemeJoinAuditIdentity( + string CurrentThemeCode, + string StoredThemeCode); + +/// +/// Aggregate evidence for one current theme code. No theme or stock identifier +/// is returned, so callers can safely log only cardinalities. +/// +public sealed record LegacyNxtThemeJoinAuditEvidence( + int InputIndex, + int RawCurrentItemCount, + int RawStoredItemCount, + int CurrentOriginalJoinCount, + int CurrentNonStoppedJoinCount, + int CurrentLiveJoinCount, + int StoredOriginalJoinCount, + int StoredNonStoppedJoinCount, + int StoredLiveJoinCount, + int CurrentExactJoinCount, + int CurrentStripBothJoinCount, + int CurrentRightSixJoinCount, + int CurrentValidItemCodeCount, + int CurrentKospiMasterJoinCount, + int CurrentKosdaqMasterJoinCount, + int CurrentKospiOnlineJoinCount, + int CurrentKosdaqOnlineJoinCount); + +public sealed record LegacyNxtThemeJoinAuditResult( + DateTimeOffset RetrievedAt, + IReadOnlyList Themes); + +/// +/// Executes only bounded aggregate MariaDB reads. The original s5074, s5077, +/// and s5088 NXT loaders all use SUBSTRING(SB_I_CODE, 2, 12) = F_STOCK_CODE; +/// the other joins are diagnostic evidence only and are never used to resolve +/// or play a scene. +/// +public sealed partial class LegacyNxtThemeJoinAuditService +{ + public const int MaximumThemes = 100; + public const string QueryName = "LEGACY_NXT_THEME_JOIN_AUDIT"; + + private static readonly string[] Columns = + [ + "RAW_CURRENT_ITEM_COUNT", + "RAW_STORED_ITEM_COUNT", + "CURRENT_ORIGINAL_JOIN_COUNT", + "CURRENT_NON_STOPPED_JOIN_COUNT", + "CURRENT_LIVE_JOIN_COUNT", + "STORED_ORIGINAL_JOIN_COUNT", + "STORED_NON_STOPPED_JOIN_COUNT", + "STORED_LIVE_JOIN_COUNT", + "CURRENT_EXACT_JOIN_COUNT", + "CURRENT_STRIP_BOTH_JOIN_COUNT", + "CURRENT_RIGHT_SIX_JOIN_COUNT", + "CURRENT_VALID_ITEM_CODE_COUNT", + "CURRENT_KOSPI_MASTER_JOIN_COUNT", + "CURRENT_KOSDAQ_MASTER_JOIN_COUNT", + "CURRENT_KOSPI_ONLINE_JOIN_COUNT", + "CURRENT_KOSDAQ_ONLINE_JOIN_COUNT" + ]; + + private const string Sql = """ + SELECT + CAST((SELECT COUNT(*) + FROM SB_ITEM i + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR) + RAW_CURRENT_ITEM_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR) + RAW_STORED_ITEM_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR) + CURRENT_ORIGINAL_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE + AND q.F_STOP_GUBUN = 'N') AS CHAR) + CURRENT_NON_STOPPED_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE + AND q.F_STOP_GUBUN = 'N' + AND q.F_CURR_PRICE != 0) AS CHAR) + CURRENT_LIVE_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR) + STORED_ORIGINAL_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE + AND q.F_STOP_GUBUN = 'N') AS CHAR) + STORED_NON_STOPPED_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE + AND q.F_STOP_GUBUN = 'N' + AND q.F_CURR_PRICE != 0) AS CHAR) + STORED_LIVE_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON i.SB_I_CODE = q.F_STOCK_CODE + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR) + CURRENT_EXACT_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON SUBSTRING(i.SB_I_CODE, 2, 12) = + SUBSTRING(q.F_STOCK_CODE, 2, 12) + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR) + CURRENT_STRIP_BOTH_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + JOIN v_all_stock q + ON RIGHT(i.SB_I_CODE, 6) = RIGHT(q.F_STOCK_CODE, 6) + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR) + CURRENT_RIGHT_SIX_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE + AND i.SB_I_CODE REGEXP BINARY '^[XT][A-Za-z0-9]{1,12}$') AS CHAR) + CURRENT_VALID_ITEM_CODE_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE + AND EXISTS ( + SELECT 1 + FROM N_STOCK q + WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + )) AS CHAR) + CURRENT_KOSPI_MASTER_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE + AND EXISTS ( + SELECT 1 + FROM N_KOSDAQ_STOCK q + WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + )) AS CHAR) + CURRENT_KOSDAQ_MASTER_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE + AND EXISTS ( + SELECT 1 + FROM N_ONLINE q + WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + )) AS CHAR) + CURRENT_KOSPI_ONLINE_JOIN_COUNT, + CAST((SELECT COUNT(*) + FROM SB_ITEM i + WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE + AND EXISTS ( + SELECT 1 + FROM N_KOSDAQ_ONLINE q + WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE + )) AS CHAR) + CURRENT_KOSDAQ_ONLINE_JOIN_COUNT + FROM ( + SELECT @current_theme_code CURRENT_THEME_CODE, + @stored_theme_code STORED_THEME_CODE + ) p + """; + + private readonly IDataQueryExecutor _executor; + private readonly TimeProvider _timeProvider; + + public LegacyNxtThemeJoinAuditService(IDataQueryExecutor executor) + : this(executor, TimeProvider.System) + { + } + + internal LegacyNxtThemeJoinAuditService( + IDataQueryExecutor executor, + TimeProvider timeProvider) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public async Task AuditAsync( + IReadOnlyList identities, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identities); + if (identities.Count is < 1 or > MaximumThemes) + { + throw new ArgumentOutOfRangeException( + nameof(identities), + $"A join audit must contain 1 through {MaximumThemes} themes."); + } + + var currentCodes = new HashSet(StringComparer.Ordinal); + foreach (var identity in identities) + { + var current = identity?.CurrentThemeCode; + var stored = identity?.StoredThemeCode; + if (!ThemeCodePattern().IsMatch(current ?? string.Empty) || + !ThemeCodePattern().IsMatch(stored ?? string.Empty) || + !currentCodes.Add(current!)) + { + throw new ArgumentException( + "NXT join audit identities must have unique canonical current codes.", + nameof(identities)); + } + } + + var evidence = new List(identities.Count); + for (var index = 0; index < identities.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var identity = identities[index]; + var spec = new DataQuerySpec( + Sql, + [ + new DataQueryParameter( + "current_theme_code", + identity.CurrentThemeCode, + DbType.String), + new DataQueryParameter( + "stored_theme_code", + identity.StoredThemeCode, + DbType.String) + ]); + spec.ValidateFor(DataSourceKind.MariaDb); + var table = await _executor.ExecuteAsync( + DataSourceKind.MariaDb, + QueryName, + spec, + cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + evidence.Add(MapEvidence(table, index)); + } + + return new LegacyNxtThemeJoinAuditResult( + _timeProvider.GetLocalNow(), + evidence.AsReadOnly()); + } + + private static LegacyNxtThemeJoinAuditEvidence MapEvidence( + DataTable? table, + int inputIndex) + { + if (table is null || table.Columns.Count != Columns.Length || table.Rows.Count != 1) + { + throw InvalidData(); + } + + for (var index = 0; index < Columns.Length; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + Columns[index], + StringComparison.Ordinal) || + table.Columns[index].DataType != typeof(string)) + { + throw InvalidData(); + } + } + + var counts = table.Rows[0].ItemArray.Select(ReadCount).ToArray(); + if (counts[2] > counts[0] || counts[3] > counts[2] || counts[4] > counts[3] || + counts[5] > counts[1] || counts[6] > counts[5] || counts[7] > counts[6] || + counts[8] > counts[0] || counts[9] > counts[0] || counts[10] > counts[0] || + counts[11] > counts[0] || counts[12] > counts[0] || counts[13] > counts[0] || + counts[14] > counts[0] || counts[15] > counts[0] || + counts[12] + counts[13] > counts[0] || + counts[14] + counts[15] > counts[0]) + { + throw InvalidData(); + } + + return new LegacyNxtThemeJoinAuditEvidence( + inputIndex, + counts[0], + counts[1], + counts[2], + counts[3], + counts[4], + counts[5], + counts[6], + counts[7], + counts[8], + counts[9], + counts[10], + counts[11], + counts[12], + counts[13], + counts[14], + counts[15]); + } + + private static int ReadCount(object? value) + { + if (value is not string text || + !int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var count) || + count is < 0 or > 1_000_000 || + !string.Equals(text, count.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) + { + throw InvalidData(); + } + + return count; + } + + private static InvalidDataException InvalidData() => + new("The NXT theme join audit returned invalid aggregate data."); + + [GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)] + private static partial Regex ThemeCodePattern(); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs new file mode 100644 index 0000000..f92a75a --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs @@ -0,0 +1,524 @@ +#nullable enable + +using System.Globalization; +using System.Text; + +namespace MMoneyCoderSharp.Data; + +/// +/// The exact seven values written by the original PList.data_save method. +/// RawListText and Fields are retained so a read-only audit can prove that +/// parsing did not normalize any operator text. PageTextForDisplay is the one +/// original load-time transformation: ASCII spaces are removed from field 5. +/// +public sealed record LegacyPListAuditRow( + string ProgramCode, + string ProgramTitle, + int ItemIndex, + bool IsEnabled, + IReadOnlyList Fields, + string RawListText, + string PageTextForDisplay) +{ + public string GroupCode => Fields[1]; + + public string Subject => Fields[2]; + + public string GraphicType => Fields[3]; + + public string Subtype => Fields[4]; + + public string RawPageText => Fields[5]; + + public string DataCode => Fields[6]; + + public bool IsLossless => + string.Equals(string.Join('^', Fields), RawListText, StringComparison.Ordinal); +} + +public enum LegacyPListAuditParseFailure +{ + InvalidProgramCode, + InvalidProgramTitle, + InvalidItemIndex, + InvalidListText, + InvalidFieldCount, + InvalidEnabledField, + UnsafeText, + ReplacementCharacter, + MojibakeSignal +} + +public enum LegacyPListAuditRoute +{ + ExistingClosedMapper, + IndustryFixedClosedMapper, + ComparisonFreshIdentity, + ForeignIndexCandleFreshIdentity, + NxtThemeFreshIdentity, + KrxThemeFreshIdentity, + ForeignStockFreshIdentity, + ManualFreshMaterialization, + Unmapped +} + +public sealed record LegacyPListAuditParseResult( + LegacyPListAuditRow? Row, + LegacyPListAuditParseFailure? Failure) +{ + public bool IsSuccess => Row is not null && Failure is null; +} + +/// +/// Parser used only for the read-only production-data audit. It intentionally +/// accepts the original PList grammar before any current scene mapping is +/// attempted and never includes source text in exception or diagnostic output. +/// +public static class LegacyPListReadAuditParser +{ + public const int MaximumProgramTitleLength = 128; + public const int MaximumListTextLength = 4_000; + public const int MaximumItems = LegacyNamedPlaylistPersistenceService.MaximumItems; + + public static LegacyPListAuditParseResult Parse( + string? programCode, + string? programTitle, + string? itemIndex, + string? listText) + { + if (programCode is null || programCode.Length != 8 || + programCode.Any(static value => !char.IsAsciiDigit(value))) + { + return Failed(LegacyPListAuditParseFailure.InvalidProgramCode); + } + + if (!IsCanonicalText(programTitle, MaximumProgramTitleLength, allowEmpty: false)) + { + return Failed(programTitle?.Contains('\uFFFD') == true + ? LegacyPListAuditParseFailure.ReplacementCharacter + : ContainsMojibakeSignal(programTitle) + ? LegacyPListAuditParseFailure.MojibakeSignal + : LegacyPListAuditParseFailure.InvalidProgramTitle); + } + + if (itemIndex is null || + !int.TryParse( + itemIndex, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var parsedIndex) || + parsedIndex is < 0 or >= MaximumItems || + !string.Equals( + itemIndex, + parsedIndex.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal)) + { + return Failed(LegacyPListAuditParseFailure.InvalidItemIndex); + } + + if (listText is null || listText.Length is < 1 or > MaximumListTextLength) + { + return Failed(LegacyPListAuditParseFailure.InvalidListText); + } + + if (listText.Contains('\uFFFD')) + { + return Failed(LegacyPListAuditParseFailure.ReplacementCharacter); + } + + if (ContainsMojibakeSignal(listText)) + { + return Failed(LegacyPListAuditParseFailure.MojibakeSignal); + } + + if (!IsSafeText(listText)) + { + return Failed(LegacyPListAuditParseFailure.UnsafeText); + } + + var fields = listText.Split('^', StringSplitOptions.None); + if (fields.Length != 7) + { + return Failed(LegacyPListAuditParseFailure.InvalidFieldCount); + } + + if (fields[0] is not ("0" or "1")) + { + return Failed(LegacyPListAuditParseFailure.InvalidEnabledField); + } + + // A caret cannot occur inside a field after an exact seven-way split. + // Retain empty strings and every code unit exactly as the original row. + if (fields.Any(static field => !IsSafeText(field))) + { + return Failed(LegacyPListAuditParseFailure.UnsafeText); + } + + var frozenFields = Array.AsReadOnly(fields); + var row = new LegacyPListAuditRow( + programCode, + programTitle!, + parsedIndex, + fields[0] == "1", + frozenFields, + listText, + fields[5].Replace(" ", string.Empty, StringComparison.Ordinal)); + return row.IsLossless + ? new LegacyPListAuditParseResult(row, Failure: null) + : Failed(LegacyPListAuditParseFailure.InvalidListText); + } + + public static bool ContainsMojibakeSignal(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + // U+FFFD is reported separately. These code points and byte-order + // marker are reliable signs of UTF-8/legacy-codepage damage in the + // Korean operator data. Literal question marks are not rejected because + // they are valid operator text and are not proof of corruption alone. + foreach (var character in value) + { + if (character is '\u00C2' or '\u00C3' or '\u00EC' or '\u00ED' or + '\u00EA' or '\u00EB' or '\uFEFF') + { + return true; + } + } + + return value.Contains("â€", StringComparison.Ordinal) || + value.Contains("", StringComparison.Ordinal); + } + + private static LegacyPListAuditParseResult Failed( + LegacyPListAuditParseFailure failure) => + new(Row: null, failure); + + private static bool IsCanonicalText( + string? value, + int maximumLength, + bool allowEmpty) + { + if (value is null || value.Length > maximumLength || + (!allowEmpty && value.Length == 0) || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + value.Contains('\uFFFD') || ContainsMojibakeSignal(value)) + { + return false; + } + + return IsSafeText(value); + } + + private static bool IsSafeText(string value) + { + for (var index = 0; index < value.Length; index++) + { + if (char.IsHighSurrogate(value[index])) + { + if (index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1])) + { + return false; + } + + index++; + } + else if (char.IsLowSurrogate(value[index])) + { + return false; + } + } + + foreach (var rune in value.EnumerateRunes()) + { + var category = Rune.GetUnicodeCategory(rune); + if (Rune.IsControl(rune) || category is + UnicodeCategory.Format or + UnicodeCategory.Surrogate or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } +} + +/// +/// Side-effect-free evidence checks shared by the database smoke audit and its +/// fixtures. This is deliberately a route classifier, not a resolver: routes +/// that require current database or trusted-file evidence remain unplayable +/// until the corresponding service reports a unique materialization result. +/// +public static class LegacyPListReadAuditEvidence +{ + public static bool HasContiguousIndexes(IReadOnlyList rows) + { + ArgumentNullException.ThrowIfNull(rows); + for (var index = 0; index < rows.Count; index++) + { + if (rows[index] is null || rows[index].ItemIndex != index) + { + return false; + } + } + + return true; + } + + public static bool MatchesPersistenceResult( + LegacyPListAuditRow raw, + NamedPlaylistStoredItem stored) + { + ArgumentNullException.ThrowIfNull(raw); + ArgumentNullException.ThrowIfNull(stored); + return raw.ItemIndex == stored.ItemIndex && + raw.IsEnabled == stored.IsEnabled && + string.Equals(raw.GroupCode, stored.Selection.GroupCode, StringComparison.Ordinal) && + string.Equals(raw.Subject, stored.Selection.Subject, StringComparison.Ordinal) && + string.Equals(raw.GraphicType, stored.Selection.GraphicType, StringComparison.Ordinal) && + string.Equals(raw.Subtype, stored.Selection.Subtype, StringComparison.Ordinal) && + string.Equals(raw.DataCode, stored.Selection.DataCode, StringComparison.Ordinal) && + string.Equals( + raw.PageTextForDisplay, + stored.Page?.ToString() ?? string.Empty, + StringComparison.Ordinal); + } + + public static LegacyPListAuditRoute ClassifyRoute(LegacyPListAuditRow row) + { + ArgumentNullException.ThrowIfNull(row); + var comparison = ToComparisonRow(row); + if (LegacyNamedComparisonRestoreService.IsCandidate(comparison)) + { + return LegacyPListAuditRoute.ComparisonFreshIdentity; + } + + var foreignIndex = ToForeignIndexCandleRow(row); + if (LegacyForeignIndexCandleRestoreService.IsCandidate(foreignIndex)) + { + return LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity; + } + + var nxt = ToNxtThemeRow(row); + if (LegacyNamedNxtThemeRestoreService.IsCandidate(nxt)) + { + return LegacyPListAuditRoute.NxtThemeFreshIdentity; + } + + if (IsIndustryFixed(row)) + { + return LegacyPListAuditRoute.IndustryFixedClosedMapper; + } + + if (string.Equals(row.GroupCode, "테마", StringComparison.Ordinal)) + { + return LegacyPListAuditRoute.KrxThemeFreshIdentity; + } + + if (row.GroupCode == "해외종목") + { + return LegacyPListAuditRoute.ForeignStockFreshIdentity; + } + + // The identity-bearing canonical format is already handled by the + // current overseas workflow. Only its one unambiguous current-price + // action is a closed seven-field mapping; the five candle periods share + // the same selection fields and therefore remain deliberately unmapped. + if (row.GroupCode == "FOREIGN_STOCK") + { + return IsCanonicalForeignStockCurrent(row) + ? LegacyPListAuditRoute.ExistingClosedMapper + : LegacyPListAuditRoute.Unmapped; + } + + if (IsManualCandidate(row)) + { + return LegacyPListAuditRoute.ManualFreshMaterialization; + } + + return IsExistingClosedMapper(row) + ? LegacyPListAuditRoute.ExistingClosedMapper + : LegacyPListAuditRoute.Unmapped; + } + + public static LegacyNamedComparisonRestoreRow ToComparisonRow(LegacyPListAuditRow row) => + new( + row.ItemIndex, + row.IsEnabled, + row.GroupCode, + row.Subject, + row.GraphicType, + row.Subtype, + row.PageTextForDisplay, + row.DataCode); + + public static LegacyForeignIndexCandleRestoreRow ToForeignIndexCandleRow( + LegacyPListAuditRow row) => + new( + row.ItemIndex, + row.IsEnabled, + row.GroupCode, + row.Subject, + row.GraphicType, + row.Subtype, + row.PageTextForDisplay, + row.DataCode); + + public static LegacyNamedNxtThemeRestoreRow ToNxtThemeRow(LegacyPListAuditRow row) => + new( + row.ItemIndex, + row.IsEnabled, + row.GroupCode, + row.Subject, + row.GraphicType, + row.Subtype, + row.PageTextForDisplay, + row.DataCode); + + private static bool IsIndustryFixed(LegacyPListAuditRow row) + { + if (row.Subtype.Length != 0 || row.DataCode.Length != 0 || + row.GraphicType is not ("5단 표그래프" or "네모그래프" or "섹터지수")) + { + return false; + } + + return row.GroupCode switch + { + "업종_코스피" => string.Equals(row.Subject, "코스피", StringComparison.Ordinal), + "업종_코스닥" => string.Equals(row.Subject, "코스닥", StringComparison.Ordinal), + _ => false + }; + } + + private static bool IsManualCandidate(LegacyPListAuditRow row) => + LegacyNamedManualRestoreClassifier.Classify(row) is not null; + + private static bool IsCanonicalForeignStockCurrent(LegacyPListAuditRow row) + { + if (row.PageTextForDisplay != "1/1" || + row.Subject.Length is < 1 or > 200 || + row.Subject != row.Subject.Trim() || + row.Subject.Contains('|') || + row.GraphicType != "1열판기본" || + row.Subtype != "CURRENT") + { + return false; + } + + var parts = row.DataCode.Split('|', StringSplitOptions.None); + return parts.Length == 3 && + LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[0]) && + parts[1] is "US" or "TW" && + parts[2] == "1"; + } + + private static bool IsExistingClosedMapper(LegacyPListAuditRow row) => + IsLegacyStock(row) || IsLegacyExpert(row) || IsCanonicalDefault(row); + + private static bool IsLegacyStock(LegacyPListAuditRow row) + { + if (row.PageTextForDisplay != "1/1" || row.Subject.Length is < 1 or > 128 || + row.DataCode.Length is < 1 or > 32 || + row.DataCode.Any(static value => !char.IsAsciiLetterOrDigit(value))) + { + return false; + } + + var isNxt = row.GroupCode is "코스피_NXT" or "코스닥_NXT"; + if (row.GroupCode is not ("코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT")) + { + return false; + } + + var label = row.Subtype.Length == 0 + ? row.GraphicType + : string.Concat(row.GraphicType, "_", row.Subtype); + if (isNxt) + { + return label == "1열판기본_현재가"; + } + + return StockLegacyLabels.Contains(label); + } + + private static bool IsLegacyExpert(LegacyPListAuditRow row) => + row.PageTextForDisplay.StartsWith("1/", StringComparison.Ordinal) && + row.GroupCode == "전문가 추천" && row.Subject.Length is > 0 and <= 128 && + row.GraphicType == "5단 표그래프" && row.Subtype == "현재가" && + row.DataCode.Length == 4 && row.DataCode.All(char.IsAsciiDigit); + + private static bool IsCanonicalDefault(LegacyPListAuditRow row) => + CanonicalDefaults.Contains(new SelectionKey( + row.GroupCode, + row.Subject, + row.GraphicType, + row.Subtype, + row.DataCode)); + + private static readonly HashSet StockLegacyLabels = new(StringComparer.Ordinal) + { + "1열판기본_예상체결가", + "1열판기본_현재가", + "1열판기본_시간외단일가", + "1열판상세_시가", + "1열판상세_액면가", + "1열판상세_PBR", + "1열판상세_거래량", + "수익률그래프_5일", + "수익률그래프_20일", + "수익률그래프_60일", + "수익률그래프_120일", + "수익률그래프_240일", + "캔들그래프_일봉", + "캔들그래프_5일", + "캔들그래프_20일", + "캔들그래프_60일", + "캔들그래프_120일", + "캔들그래프_240일", + "캔들그래프(거래량)_일봉", + "캔들그래프(거래량)_5일", + "캔들그래프(거래량)_20일", + "캔들그래프(거래량)_60일", + "캔들그래프(거래량)_120일", + "캔들그래프(거래량)_240일", + "캔들그래프(예상체결가)_5일", + "캔들그래프(예상체결가)_20일", + "캔들그래프(예상체결가)_60일", + "캔들그래프(예상체결가)_120일", + "캔들그래프(예상체결가)_240일", + "호가창_표그래프", + "거래원_표그래프" + }; + + private static readonly HashSet CanonicalDefaults = new() + { + new("ALL", "INDEX", string.Empty, "UnitedStatesIndices", string.Empty), + new("ALL", "INDEX", string.Empty, "Cotton", string.Empty), + new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", string.Empty), + new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", string.Empty), + new("INDEX", "FUTURES,Kospi", string.Empty, "CURRENT", string.Empty), + new("FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", string.Empty, string.Empty), + new("ALL", "INDEX", string.Empty, string.Empty, string.Empty), + new("ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", string.Empty), + new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", string.Empty), + new("ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", string.Empty), + new("EXCHANGE_RATE", "원달러", string.Empty, "20일", string.Empty), + new("FOREIGN_INDEX", "DOW", string.Empty, "DowJones", string.Empty), + new("ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", string.Empty), + new("ALL", "KOSPI", string.Empty, string.Empty, string.Empty), + new("INDEX", "Kospi,Kosdaq", string.Empty, "CURRENT", string.Empty) + }; + + private readonly record struct SelectionKey( + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string DataCode); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs index 887a9a0..1e7d134 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs @@ -2,6 +2,7 @@ using System.Data; using System.Globalization; +using System.Text; using System.Text.RegularExpressions; namespace MMoneyCoderSharp.Data; @@ -459,13 +460,11 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService private static bool IsSafeText(string value) { - for (var index = 0; index < value.Length; index++) + foreach (var rune in value.EnumerateRunes()) { - var character = value[index]; - var category = CharUnicodeInfo.GetUnicodeCategory(character); - if (char.IsControl(character) || char.IsSurrogate(character) || - character == '\uFFFD' || - category is UnicodeCategory.Format or + var category = Rune.GetUnicodeCategory(rune); + if (Rune.IsControl(rune) || rune.Value == '\uFFFD' || + category is UnicodeCategory.Format or UnicodeCategory.Surrogate or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedForeignStockRestoreServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedForeignStockRestoreServiceTests.cs new file mode 100644 index 0000000..0490fc3 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedForeignStockRestoreServiceTests.cs @@ -0,0 +1,377 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyNamedForeignStockRestoreServiceTests +{ + public static TheoryData Actions => + new() + { + { "1열판기본", "", "stock-current", "5001", null }, + { "캔들그래프", "5일", "stock-candle-5d", "8061", 5 }, + { "캔들그래프", "20일", "stock-candle-20d", "8040", 20 }, + { "캔들그래프", "60일", "stock-candle-60d", "8046", 60 }, + { "캔들그래프", "120일", "stock-candle-120d", "8051", 120 }, + { "캔들그래프", "240일", "stock-candle-240d", "8056", 240 } + }; + + [Theory] + [MemberData(nameof(Actions))] + public async Task ResolveAsync_MapsEveryClosedUc5ActionAfterExactBoundIdentity( + string graphicType, + string subtype, + string actionId, + string cutCode, + int? periodDays) + { + var executor = new RecordingExecutor(call => + { + Assert.Equal(DataSourceKind.Oracle, call.Source); + Assert.Equal(LegacyNamedForeignStockRestoreService.QueryName, call.QueryName); + call.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("F_FDTC = '1'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("SELECT DISTINCT", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("NVIDIA", call.Spec.Sql, StringComparison.Ordinal); + var parameter = Assert.Single(call.Spec.Parameters); + Assert.Equal("input_name", parameter.Name); + Assert.Equal("NVIDIA", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + return IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1")); + }); + var row = Row(graphicType: graphicType, subtype: subtype); + + Assert.True(LegacyNamedForeignStockRestoreService.IsCandidate(row)); + var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor) + .ResolveAsync([row])).Rows); + + Assert.True(outcome.IsResolved); + Assert.Equal(actionId, outcome.Identity!.ActionId); + Assert.Equal("NVIDIA", outcome.Identity.InputName); + Assert.Equal("NAS@NVDA", outcome.Identity.Symbol); + Assert.Equal("US", outcome.Identity.NationCode); + Assert.Equal("1", outcome.Identity.ForeignDomesticCode); + Assert.Equal(periodDays is null ? "s5001" : "s8010", outcome.Identity.BuilderKey); + Assert.Equal(cutCode, outcome.Identity.CutCode); + Assert.Equal(periodDays is null ? "CURRENT" : "PRICE", outcome.Identity.ValueType); + Assert.Equal(periodDays, outcome.Identity.PeriodDays); + Assert.Single(executor.Calls); + Assert.Equal(0, executor.StringSqlCallCount); + } + + [Fact] + public async Task ResolveAsync_PreservesOrderAndCachesRepeatedExactInputName() + { + var executor = new RecordingExecutor(call => + { + var name = Assert.IsType(Assert.Single(call.Spec.Parameters).Value); + return name == "TSMC" + ? IdentityTable((name, "TPE@2330", "TW", "1")) + : IdentityTable((name, "NAS@NVDA", "US", "1")); + }); + var rows = new[] + { + Row(itemIndex: 12, subject: "TSMC", graphicType: "캔들그래프", subtype: "240일"), + Row(itemIndex: 2, graphicType: "1열판기본", subtype: ""), + Row(itemIndex: 900, graphicType: "캔들그래프", subtype: "5일") + }; + + var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(rows); + + Assert.Equal([12, 2, 900], result.Rows.Select(value => value.ItemIndex)); + Assert.Equal(["TPE@2330", "NAS@NVDA", "NAS@NVDA"], + result.Rows.Select(value => value.Identity!.Symbol)); + Assert.Equal(2, executor.Calls.Count); + } + + [Fact] + public async Task ResolveAsync_FailsClosedPerRowForMissingAmbiguousAndInvalidData() + { + var executor = new RecordingExecutor(call => + { + var name = Assert.IsType(Assert.Single(call.Spec.Parameters).Value); + return name switch + { + "MISSING" => IdentityTable(), + "AMBIGUOUS" => IdentityTable( + (name, "NAS@ONE", "US", "1"), + (name, "TPE@TWO", "TW", "1")), + "INVALID" => IdentityTable((name, "bad symbol!", "US", "1")), + _ => throw new InvalidOperationException(name) + }; + }); + + var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync( + [ + Row(1, "MISSING"), + Row(2, "AMBIGUOUS"), + Row(3, "INVALID") + ]); + + Assert.Equal( + new LegacyNamedForeignStockRestoreFailure?[] + { + LegacyNamedForeignStockRestoreFailure.IdentityNotFound, + LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous, + LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid + }, + result.Rows.Select(value => value.Failure)); + Assert.All(result.Rows, value => Assert.False(value.IsResolved)); + Assert.Null(result.Rows[0].DatabaseIssue); + Assert.Null(result.Rows[1].DatabaseIssue); + Assert.Equal( + LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation, + result.Rows[2].DatabaseIssue); + } + + [Theory] + [InlineData("FOREIGN_STOCK", "1열판기본", "", "1/1", "")] + [InlineData("해외종목", "CURRENT", "", "1/1", "")] + [InlineData("해외종목", "캔들그래프", "1년", "1/1", "")] + [InlineData("해외종목", "캔들그래프", "5일", "1/2", "")] + [InlineData("해외종목", "캔들그래프", "5일", "1/1", "NAS@NVDA|US|1")] + public async Task ResolveAsync_RejectsEverythingOutsideExactLegacyGrammarWithoutQuery( + string groupCode, + string graphicType, + string subtype, + string pageText, + string dataCode) + { + var executor = new RecordingExecutor(_ => throw new InvalidOperationException()); + var row = Row( + groupCode: groupCode, + graphicType: graphicType, + subtype: subtype, + pageText: pageText, + dataCode: dataCode); + + Assert.False(LegacyNamedForeignStockRestoreService.IsCandidate(row)); + var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor) + .ResolveAsync([row])).Rows); + + Assert.Equal(LegacyNamedForeignStockRestoreFailure.InvalidRow, outcome.Failure); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task ResolveAsync_PreservesDisabledRowAndTaiwanNation() + { + var executor = new RecordingExecutor(_ => + IdentityTable(("TSMC", "TPE@2330", "TW", "1"))); + var row = Row(subject: "TSMC", isEnabled: false); + + var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor) + .ResolveAsync([row])).Rows); + + Assert.True(outcome.IsResolved); + Assert.Equal("TW", outcome.Identity!.NationCode); + } + + [Fact] + public async Task ResolveAsync_AcceptsBoundProviderSymbolContainingHangulLetters() + { + const string symbol = "NAS@해외 종목1"; + var executor = new RecordingExecutor(_ => + IdentityTable(("NVIDIA", symbol, "US", "1"))); + + var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor) + .ResolveAsync([Row()])).Rows); + + Assert.True(outcome.IsResolved); + Assert.Equal(symbol, outcome.Identity!.Symbol); + Assert.Null(outcome.DatabaseIssue); + } + + [Fact] + public async Task ResolveAsync_AcceptsSafeSupplementaryScalarsConsistentlyWithWebContract() + { + const string inputName = "회사🚀"; + const string symbol = "NAS@𐐀1"; + var executor = new RecordingExecutor(_ => + IdentityTable((inputName, symbol, "US", "1"))); + + var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor) + .ResolveAsync([Row(subject: inputName)])).Rows); + + Assert.True(outcome.IsResolved); + Assert.Equal(inputName, outcome.Identity!.InputName); + Assert.Equal(symbol, outcome.Identity.Symbol); + Assert.Null(outcome.DatabaseIssue); + } + + [Theory] + [InlineData("NAS|NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter)] + [InlineData("NAS^NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolCaret)] + [InlineData(" NAS@NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)] + [InlineData("NAS@NVDA ", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)] + [InlineData("NAS!NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation)] + [InlineData("NAS\nNVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)] + [InlineData("NAS😀NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther)] + public async Task ResolveAsync_DiagnosticReasonNeverPrintsOrRelaxesUnsafeSymbol( + string symbol, + LegacyNamedForeignStockDatabaseIssue expectedIssue) + { + var executor = new RecordingExecutor(_ => + IdentityTable(("NVIDIA", symbol, "US", "1"))); + + var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor) + .ResolveAsync([Row()])).Rows); + + Assert.False(outcome.IsResolved); + Assert.Equal(LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid, outcome.Failure); + Assert.Equal(expectedIssue, outcome.DatabaseIssue); + Assert.DoesNotContain(symbol, outcome.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ResolveAsync_UsesInjectedClockForCorrelatedBatchTimestamp() + { + var now = new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero); + var executor = new RecordingExecutor(_ => + IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1"))); + var service = new LegacyNamedForeignStockRestoreService( + executor, + new FixedTimeProvider(now)); + + var result = await service.ResolveAsync([Row()]); + + Assert.Equal(now, result.RetrievedAt); + } + + [Fact] + public async Task ResolveAsync_RejectsMalformedBatchAndNeverRetriesFailure() + { + var failed = new RecordingExecutor(_ => throw new DataException("one read failure")); + var service = new LegacyNamedForeignStockRestoreService(failed); + + await Assert.ThrowsAsync(() => service.ResolveAsync([Row()])); + Assert.Single(failed.Calls); + + var unused = new RecordingExecutor(_ => throw new InvalidOperationException()); + service = new LegacyNamedForeignStockRestoreService(unused); + await Assert.ThrowsAsync(() => service.ResolveAsync([])); + await Assert.ThrowsAsync(() => service.ResolveAsync([Row(4), Row(4)])); + await Assert.ThrowsAsync(() => service.ResolveAsync([null!])); + Assert.Empty(unused.Calls); + } + + [Fact] + public async Task ResolveAsync_HonorsCancellationWithoutASecondQuery() + { + using var cancellation = new CancellationTokenSource(); + var executor = new RecordingExecutor(call => + { + cancellation.Cancel(); + var name = Assert.IsType(Assert.Single(call.Spec.Parameters).Value); + return IdentityTable((name, "NAS@NVDA", "US", "1")); + }, throwBeforeHandlerWhenCanceled: false); + + await Assert.ThrowsAnyAsync(() => + new LegacyNamedForeignStockRestoreService(executor).ResolveAsync( + [ + Row(0), + Row(1, "TSMC") + ], + cancellation.Token)); + + Assert.Single(executor.Calls); + } + + private static LegacyNamedForeignStockRestoreRow Row( + int itemIndex = 0, + string subject = "NVIDIA", + bool isEnabled = true, + string groupCode = "해외종목", + string graphicType = "1열판기본", + string subtype = "", + string pageText = "1/1", + string dataCode = "") => + new( + itemIndex, + isEnabled, + groupCode, + subject, + graphicType, + subtype, + pageText, + dataCode); + + private static DataTable IdentityTable( + params (string InputName, string Symbol, string Nation, string Fdtc)[] rows) + { + var table = new DataTable(); + table.Columns.Add("F_INPUT_NAME", typeof(string)); + table.Columns.Add("F_SYMB", typeof(string)); + table.Columns.Add("F_NATC", typeof(string)); + table.Columns.Add("F_FDTC", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.InputName, row.Symbol, row.Nation, row.Fdtc); + } + + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string QueryName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => value; + + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + } + + private sealed class RecordingExecutor( + Func handler, + bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor + { + private int _stringSqlCallCount; + + public List Calls { get; } = []; + + public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount); + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException(); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _stringSqlCallCount); + throw new InvalidOperationException(); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + if (throwBeforeHandlerWhenCanceled) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + query.ValidateFor(source); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call).Copy()); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedKrxThemeRestoreServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedKrxThemeRestoreServiceTests.cs new file mode 100644 index 0000000..3846712 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedKrxThemeRestoreServiceTests.cs @@ -0,0 +1,406 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyNamedKrxThemeRestoreServiceTests +{ + public static TheoryData SupportedRows => + new() + { + { "5단 표그래프", "테마-현재가(입력순)", "five-row-current", "s5074", 5, "INPUT_ORDER" }, + { "5단 표그래프", "테마-현재가(상승률순)", "five-row-current", "s5074", 5, "GAIN_DESC" }, + { "5단 표그래프", "테마-현재가(하락률순)", "five-row-current", "s5074", 5, "GAIN_ASC" }, + { "5단 표그래프", "테마-현재가", "five-row-current", "s5074", 5, "GAIN_ASC" }, + { "5단 표그래프", "테마-예상체결가(입력순)", "five-row-expected", "s5074", 5, "INPUT_ORDER" }, + { "5단 표그래프", "테마-예상체결가(상승률순)", "five-row-expected", "s5074", 5, "GAIN_DESC" }, + { "5단 표그래프", "테마-예상체결가(하락률순)", "five-row-expected", "s5074", 5, "GAIN_ASC" }, + { "5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", 5, "GAIN_ASC" }, + { "6종목 현재가", "테마-현재가(입력순)", "six-row-current", "s5077", 6, "INPUT_ORDER" }, + { "6종목 현재가", "테마-현재가(상승률순)", "six-row-current", "s5077", 6, "GAIN_DESC" }, + { "6종목 현재가", "테마-현재가(하락률순)", "six-row-current", "s5077", 6, "GAIN_ASC" }, + { "6종목 현재가", "테마-현재가", "six-row-current", "s5077", 6, "GAIN_ASC" }, + { "12종목 현재가", "테마-현재가(입력순)", "twelve-row-current", "s5088", 12, "INPUT_ORDER" }, + { "12종목 현재가", "테마-현재가(상승률순)", "twelve-row-current", "s5088", 12, "GAIN_DESC" }, + { "12종목 현재가", "테마-현재가(하락률순)", "twelve-row-current", "s5088", 12, "GAIN_ASC" }, + { "12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", 12, "GAIN_ASC" } + }; + + [Theory] + [MemberData(nameof(SupportedRows))] + public async Task ResolveAsync_PreservesOriginalActionsAndFinalSortBranchWhileRemappingCode( + string graphicType, + string subtype, + string actionId, + string builderKey, + int pageSize, + string sort) + { + var executor = GoodExecutor("반도체", "87654321", previewCount: 2); + var service = new LegacyNamedKrxThemeRestoreService(executor); + + var outcome = Assert.Single((await service.ResolveAsync( + [Row(graphicType: graphicType, subtype: subtype)])).Rows); + + Assert.True(outcome.IsResolved); + Assert.Null(outcome.Failure); + Assert.NotNull(outcome.Identity); + Assert.Equal(actionId, outcome.Identity.ActionId); + Assert.Equal(builderKey, outcome.Identity.BuilderKey); + Assert.Equal(builderKey[1..], outcome.Identity.CutCode); + Assert.Equal(pageSize, outcome.Identity.PageSize); + Assert.Equal(sort, outcome.Identity.Sort); + Assert.Equal("반도체", outcome.Identity.ThemeTitle); + Assert.Equal("0123", outcome.Identity.StoredThemeCode); + Assert.Equal("87654321", outcome.Identity.CurrentThemeCode); + Assert.True(outcome.Identity.CodeWasRemapped); + Assert.Equal(2, outcome.Identity.PreviewItemCount); + Assert.False(outcome.Identity.PreviewIsTruncated); + Assert.Equal(2, executor.Calls.Count); + + var identityCall = executor.Calls[0]; + Assert.Equal(DataSourceKind.Oracle, identityCall.Source); + Assert.Equal(LegacyNamedKrxThemeRestoreService.IdentityQueryName, identityCall.QueryName); + identityCall.Spec.ValidateFor(DataSourceKind.Oracle); + Assert.Contains("s.SB_TITLE = :theme_title", identityCall.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("d.SB_CODE = s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("ROWNUM <= 2", identityCall.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("반도체", identityCall.Spec.Sql, StringComparison.Ordinal); + Assert.Collection(identityCall.Spec.Parameters, parameter => + { + Assert.Equal("theme_title", parameter.Name); + Assert.Equal("반도체", parameter.Value); + Assert.Equal(DbType.String, parameter.DbType); + }); + + var previewCall = executor.Calls[1]; + Assert.Equal(DataSourceKind.Oracle, previewCall.Source); + Assert.Equal("THEME_PREVIEW_KRX", previewCall.QueryName); + Assert.Equal("87654321", previewCall.Spec.Parameters[0].Value); + Assert.Equal("반도체", previewCall.Spec.Parameters[1].Value); + Assert.Equal(241, previewCall.Spec.Parameters[2].Value); + Assert.Equal(0, executor.StringSqlCallCount); + Assert.All(executor.Calls, call => + { + var sql = call.Spec.Sql.TrimStart().ToUpperInvariant(); + Assert.StartsWith("SELECT", sql, StringComparison.Ordinal); + Assert.DoesNotContain("INSERT ", sql, StringComparison.Ordinal); + Assert.DoesNotContain("UPDATE ", sql, StringComparison.Ordinal); + Assert.DoesNotContain("DELETE ", sql, StringComparison.Ordinal); + Assert.DoesNotContain("MERGE ", sql, StringComparison.Ordinal); + }); + } + + [Fact] + public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes() + { + var executor = GoodExecutor("AI", "90000001", previewCount: 1); + var service = new LegacyNamedKrxThemeRestoreService(executor); + var rows = new[] + { + Row(itemIndex: 7, subject: "AI", dataCode: "111"), + Row(itemIndex: 2, subject: "AI", dataCode: "2222", graphicType: "6종목 현재가") + }; + + var result = await service.ResolveAsync(rows); + + Assert.Equal([7, 2], result.Rows.Select(row => row.ItemIndex)); + Assert.Equal(["111", "2222"], result.Rows.Select(row => row.Identity!.StoredThemeCode)); + Assert.All(result.Rows, row => Assert.Equal("90000001", row.Identity!.CurrentThemeCode)); + Assert.Equal(2, executor.Calls.Count); + } + + [Fact] + public async Task ResolveAsync_AcceptsSafeSupplementaryThemeTitleConsistentlyWithWebContract() + { + const string title = "우주🚀"; + var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService( + GoodExecutor(title, "90000001", previewCount: 1)) + .ResolveAsync([Row(subject: title)])).Rows); + + Assert.True(outcome.IsResolved); + Assert.Equal(title, outcome.Identity!.ThemeTitle); + } + + [Theory] + [InlineData("테마_NXT", "반도체", "1/1", "0123")] + [InlineData("테마", "반도체(NXT)", "1/1", "0123")] + [InlineData("테마", " 반도체", "1/1", "0123")] + [InlineData("테마", "반도체", "01/1", "0123")] + [InlineData("테마", "반도체", "1/1", "R0123")] + public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess( + string groupCode, + string subject, + string pageText, + string dataCode) + { + var executor = new RecordingExecutor(_ => throw new InvalidOperationException()); + var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor) + .ResolveAsync([Row( + groupCode: groupCode, + subject: subject, + pageText: pageText, + dataCode: dataCode)])).Rows); + + Assert.Equal(LegacyNamedKrxThemeRestoreFailure.InvalidRow, outcome.Failure); + Assert.Empty(executor.Calls); + } + + [Theory] + [InlineData("6종목 현재가", "테마-예상체결가(입력순)")] + [InlineData("12종목 현재가", "테마-예상체결가")] + [InlineData("네모그래프", "테마-현재가(입력순)")] + [InlineData("5단 표그래프", "테마-현재가(거래량순)")] + public async Task ResolveAsync_UnsupportedActionsFailClosedPerRow( + string graphicType, + string subtype) + { + var executor = new RecordingExecutor(_ => throw new InvalidOperationException()); + var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor) + .ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows); + + Assert.Equal(LegacyNamedKrxThemeRestoreFailure.UnsupportedAction, outcome.Failure); + Assert.Empty(executor.Calls); + } + + [Fact] + public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerExactTitleWithoutPreview() + { + var executor = new RecordingExecutor(call => + { + var title = Assert.IsType(call.Spec.Parameters[0].Value); + return title == "없음" + ? IdentityTable() + : IdentityTable( + (title, "111", "KRX", "1"), + (title, "222", "KRX", "1")); + }); + var result = await new LegacyNamedKrxThemeRestoreService(executor).ResolveAsync( + [ + Row(itemIndex: 0, subject: "없음"), + Row(itemIndex: 1, subject: "중복") + ]); + + Assert.Equal( + [ + LegacyNamedKrxThemeRestoreFailure.IdentityNotFound, + LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous + ], + result.Rows.Select(row => row.Failure)); + Assert.Equal(2, executor.Calls.Count); + } + + [Fact] + public async Task ResolveAsync_RequiresNonemptyValidatedKrxPreview() + { + var executor = new RecordingExecutor(call => call.QueryName switch + { + LegacyNamedKrxThemeRestoreService.IdentityQueryName => + IdentityTable(("반도체", "00000001", "KRX", "1")), + "THEME_PREVIEW_KRX" => EmptyPreviewTable("반도체", "00000001"), + _ => throw new InvalidOperationException(call.QueryName) + }); + + var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor) + .ResolveAsync([Row()])).Rows); + + Assert.Equal(LegacyNamedKrxThemeRestoreFailure.PreviewEmpty, outcome.Failure); + Assert.False(outcome.IsResolved); + Assert.Equal("00000001", outcome.ObservedCurrentThemeCode); + Assert.Equal(2, executor.Calls.Count); + } + + [Fact] + public async Task ResolveAsync_PreservesValidatedPreviewTruncationEvidence() + { + var executor = GoodExecutor("반도체", "00000001", previewCount: 241); + + var identity = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor) + .ResolveAsync([Row()])).Rows).Identity!; + + Assert.Equal(LegacyThemeSelectionService.MaximumPreviewItems, identity.PreviewItemCount); + Assert.True(identity.PreviewIsTruncated); + } + + [Fact] + public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaCodeOrPreview() + { + var wrongSchema = Assert.Single((await new LegacyNamedKrxThemeRestoreService( + new RecordingExecutor(_ => new DataTable())).ResolveAsync([Row()])).Rows); + Assert.Equal(LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure); + + var duplicateCode = Assert.Single((await new LegacyNamedKrxThemeRestoreService( + new RecordingExecutor(_ => + IdentityTable(("반도체", "00000001", "KRX", "2")))) + .ResolveAsync([Row()])).Rows); + Assert.Equal(LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure); + + var invalidPreviewExecutor = new RecordingExecutor(call => call.QueryName switch + { + LegacyNamedKrxThemeRestoreService.IdentityQueryName => + IdentityTable(("반도체", "00000001", "KRX", "1")), + "THEME_PREVIEW_KRX" => new DataTable(), + _ => throw new InvalidOperationException(call.QueryName) + }); + var invalidPreview = Assert.Single((await new LegacyNamedKrxThemeRestoreService( + invalidPreviewExecutor).ResolveAsync([Row()])).Rows); + Assert.Equal( + LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid, + invalidPreview.Failure); + Assert.Equal("00000001", invalidPreview.ObservedCurrentThemeCode); + } + + [Fact] + public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation() + { + var failing = new RecordingExecutor(_ => throw new DataException("simulated")); + await Assert.ThrowsAsync(() => + new LegacyNamedKrxThemeRestoreService(failing).ResolveAsync([Row()])); + Assert.Single(failing.Calls); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var canceled = new RecordingExecutor(_ => throw new InvalidOperationException()); + await Assert.ThrowsAnyAsync(() => + new LegacyNamedKrxThemeRestoreService(canceled) + .ResolveAsync([Row()], cancellation.Token)); + Assert.Empty(canceled.Calls); + } + + [Fact] + public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows() + { + var executor = GoodExecutor("반도체", "00000001", previewCount: 1); + var service = new LegacyNamedKrxThemeRestoreService(executor); + await Assert.ThrowsAsync(() => service.ResolveAsync([])); + await Assert.ThrowsAsync(() => service.ResolveAsync( + [Row(itemIndex: 3), Row(itemIndex: 3)])); + + var outcome = Assert.Single((await service.ResolveAsync([Row(isEnabled: false)])).Rows); + Assert.True(outcome.IsResolved); + Assert.True(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(isEnabled: false))); + Assert.False(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(groupCode: "테마_NXT"))); + } + + private static RecordingExecutor GoodExecutor( + string title, + string code, + int previewCount) => + new(call => call.QueryName switch + { + LegacyNamedKrxThemeRestoreService.IdentityQueryName => + IdentityTable((title, code, "KRX", "1")), + "THEME_PREVIEW_KRX" => PreviewTable(title, code, previewCount), + _ => throw new InvalidOperationException(call.QueryName) + }); + + private static LegacyNamedKrxThemeRestoreRow Row( + int itemIndex = 0, + bool isEnabled = true, + string groupCode = "테마", + string subject = "반도체", + string graphicType = "5단 표그래프", + string subtype = "테마-현재가(입력순)", + string pageText = "1/1", + string dataCode = "0123") => + new(itemIndex, isEnabled, groupCode, subject, graphicType, subtype, pageText, dataCode); + + private static DataTable IdentityTable( + params (string Title, string Code, string Market, string CodeCount)[] rows) + { + var table = new DataTable(); + table.Columns.Add("THEME_TITLE", typeof(string)); + table.Columns.Add("THEME_CODE", typeof(string)); + table.Columns.Add("THEME_MARKET", typeof(string)); + table.Columns.Add("CODE_IDENTITY_COUNT", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.Title, row.Code, row.Market, row.CodeCount); + } + + return table; + } + + private static DataTable PreviewTable(string title, string code, int count) + { + var table = CreatePreviewTable(); + for (var index = 0; index < count; index++) + { + table.Rows.Add( + title, + code, + "KRX", + $"종목{index + 1}", + $"P{index + 1:D6}", + index.ToString()); + } + + return table; + } + + private static DataTable EmptyPreviewTable(string title, string code) + { + var table = CreatePreviewTable(); + table.Rows.Add(title, code, "KRX", DBNull.Value, DBNull.Value, DBNull.Value); + return table; + } + + private static DataTable CreatePreviewTable() + { + var table = new DataTable(); + table.Columns.Add("THEME_TITLE", typeof(string)); + table.Columns.Add("THEME_CODE", typeof(string)); + table.Columns.Add("THEME_MARKET", typeof(string)); + table.Columns.Add("ITEM_NAME", typeof(string)); + table.Columns.Add("ITEM_CODE", typeof(string)); + table.Columns.Add("ITEM_INDEX", typeof(string)); + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string QueryName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) + : IDataQueryExecutor + { + public List Calls { get; } = []; + + public int StringSqlCallCount { get; private set; } + + public DataTable Execute( + DataSourceKind source, + string tableName, + string query) + { + StringSqlCallCount++; + throw new InvalidOperationException("String SQL must not be used."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string sql, + CancellationToken cancellationToken = default) + { + StringSqlCallCount++; + throw new InvalidOperationException("String SQL must not be used."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call)); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedManualRestoreAuditTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedManualRestoreAuditTests.cs new file mode 100644 index 0000000..dd70b8f --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedManualRestoreAuditTests.cs @@ -0,0 +1,238 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyNamedManualRestoreAuditTests +{ + [Theory] + [InlineData("코스피", "주요매출 구성", ManualFinancialScreenKind.RevenueComposition, StockMarket.Kospi)] + [InlineData("코스닥", "성장성 지표", ManualFinancialScreenKind.GrowthMetrics, StockMarket.Kosdaq)] + [InlineData("코스피_NXT", "매출액", ManualFinancialScreenKind.Sales, StockMarket.NxtKospi)] + [InlineData("코스닥_NXT", "영업이익", ManualFinancialScreenKind.OperatingProfit, StockMarket.NxtKosdaq)] + public void ClassifyHistoricalFinancialGrammar( + string group, + string graphic, + ManualFinancialScreenKind expectedScreen, + StockMarket expectedMarket) + { + var candidate = LegacyNamedManualRestoreClassifier.Classify( + Row(group, expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq + ? "테스트(NXT)" + : "테스트", graphic, string.Empty, "1/1", string.Empty)); + + Assert.NotNull(candidate); + Assert.Equal(LegacyNamedManualRestoreKind.Financial, candidate.Kind); + Assert.True(candidate.IsHistorical); + Assert.Equal(expectedScreen, candidate.FinancialScreen); + Assert.Equal(expectedMarket, candidate.Market); + Assert.Equal("테스트" + (expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq + ? "(NXT)" + : string.Empty), candidate.StockName); + Assert.Equal("Named manual restore candidate (Financial)", candidate.ToString()); + } + + [Theory] + [InlineData("KOSPI", "REVENUE_COMPOSITION", ManualFinancialScreenKind.RevenueComposition)] + [InlineData("KOSDAQ", "GROWTH_METRICS", ManualFinancialScreenKind.GrowthMetrics)] + [InlineData("NXT_KOSPI", "SALES", ManualFinancialScreenKind.Sales)] + [InlineData("NXT_KOSDAQ", "OPERATING_PROFIT", ManualFinancialScreenKind.OperatingProfit)] + public void ClassifyCanonicalFinancialGrammar( + string group, + string graphic, + ManualFinancialScreenKind expectedScreen) + { + var candidate = LegacyNamedManualRestoreClassifier.Classify( + Row(group, group.StartsWith("NXT_", StringComparison.Ordinal) + ? "테스트(NXT)" + : "테스트", graphic, string.Empty, "1/1", string.Empty)); + + Assert.NotNull(candidate); + Assert.False(candidate.IsHistorical); + Assert.Equal(expectedScreen, candidate.FinancialScreen); + } + + [Theory] + [InlineData("개인 순매도 상위(수동)", S5025ManualAudience.Individual)] + [InlineData("외국인 순매도 상위(수동)", S5025ManualAudience.Foreign)] + [InlineData("기관 순매도 상위(수동)", S5025ManualAudience.Institution)] + public void ClassifyHistoricalNetSellGrammar( + string group, + S5025ManualAudience expected) + { + var candidate = LegacyNamedManualRestoreClassifier.Classify( + Row(group, "지수", "순매도 상위", "순매도 상위", "1/1", string.Empty)); + + Assert.NotNull(candidate); + Assert.Equal(LegacyNamedManualRestoreKind.NetSell, candidate.Kind); + Assert.True(candidate.IsHistorical); + Assert.Equal(expected, candidate.Audience); + } + + [Theory] + [InlineData("INDIVIDUAL", S5025ManualAudience.Individual)] + [InlineData("FOREIGN", S5025ManualAudience.Foreign)] + [InlineData("INSTITUTION", S5025ManualAudience.Institution)] + public void ClassifyCanonicalNetSellGrammar( + string group, + S5025ManualAudience expected) + { + var candidate = LegacyNamedManualRestoreClassifier.Classify( + Row(group, "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty)); + + Assert.NotNull(candidate); + Assert.False(candidate.IsHistorical); + Assert.Equal(expected, candidate.Audience); + } + + [Fact] + public void ClassifyHistoricalAndVersionedViGrammar() + { + var historical = LegacyNamedManualRestoreClassifier.Classify( + Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty)); + var version = new string('a', 64); + var canonical = LegacyNamedManualRestoreClassifier.Classify( + Row("PAGED_VI", TrustedViManualReference.SubjectSentinel, "INPUT_ORDER", "CURRENT", "1/2", version)); + + Assert.NotNull(historical); + Assert.True(historical.IsHistorical); + Assert.Equal(6, historical.HistoricalViNames!.Count); + Assert.Equal(2, historical.ExpectedPageCount); + Assert.NotNull(canonical); + Assert.False(canonical.IsHistorical); + Assert.Equal(version, canonical.ExpectedViVersion); + Assert.Equal(2, canonical.ExpectedPageCount); + } + + [Theory] + [InlineData("1/1", "가,나,다,라,마,바")] + [InlineData("1/21", "가")] + [InlineData("01/1", "가")] + public void RejectViPageGrammarThatCannotMatchMaterialization( + string page, + string subject) + { + Assert.Null(LegacyNamedManualRestoreClassifier.Classify( + Row(string.Empty, subject, "5단 표그래프", "VI 발동(수동)", page, string.Empty))); + } + + [Fact] + public void FinancialFreshProofRequiresTwoStableReadsAndOneMasterIdentity() + { + var candidate = Assert.IsType( + LegacyNamedManualRestoreClassifier.Classify( + Row("코스피", "테스트", "매출액", string.Empty, "1/1", string.Empty))); + var first = SalesSnapshot("테스트", 'A'); + var second = SalesSnapshot("테스트", 'A'); + var search = Search( + "테스트", + new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001")); + + Assert.Equal( + LegacyNamedManualFreshProofFailure.None, + LegacyNamedManualFreshProof.EvaluateFinancial(candidate, first, second, search)); + Assert.Equal( + LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged, + LegacyNamedManualFreshProof.EvaluateFinancial( + candidate, + first, + SalesSnapshot("테스트", 'B'), + search)); + Assert.Equal( + LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique, + LegacyNamedManualFreshProof.EvaluateFinancial( + candidate, + first, + second, + Search( + "테스트", + new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001"), + new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "테스트", "A0002")))); + } + + [Fact] + public void FinancialFreshProofUsesOriginalNxtDisplayIdentityForSearch() + { + var candidate = Assert.IsType( + LegacyNamedManualRestoreClassifier.Classify( + Row("코스피_NXT", "테스트(NXT)", "매출액", string.Empty, "1/1", string.Empty))); + + Assert.Equal("테스트", LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate)); + Assert.Equal( + LegacyNamedManualFreshProofFailure.None, + LegacyNamedManualFreshProof.EvaluateFinancial( + candidate, + SalesSnapshot("테스트(NXT)", 'A'), + SalesSnapshot("테스트(NXT)", 'A'), + Search( + "테스트", + new StockSearchItem( + StockMarket.NxtKospi, + DataSourceKind.MariaDb, + "테스트(NXT)", + "A0001")))); + } + + [Fact] + public void ManualFileFreshProofRejectsShapeOrIdentityDrift() + { + var netSell = Assert.IsType( + LegacyNamedManualRestoreClassifier.Classify( + Row("INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty))); + var rows = Enumerable.Range(0, 5) + .Select(_ => new S5025TrustedManualRow("", "", "", "")) + .ToArray(); + Assert.Equal( + LegacyNamedManualFreshProofFailure.None, + LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows)); + Assert.Equal( + LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid, + LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows.Take(4).ToArray())); + + var vi = Assert.IsType( + LegacyNamedManualRestoreClassifier.Classify( + Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty))); + var snapshot = new TrustedViStockNameSnapshot( + ["가", "나", "다", "라", "마", "바"], + new string('a', 64)); + Assert.Equal( + LegacyNamedManualFreshProofFailure.None, + LegacyNamedManualFreshProof.EvaluateVi(vi, snapshot)); + Assert.Equal( + LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged, + LegacyNamedManualFreshProof.EvaluateVi( + vi, + snapshot with { StockNames = ["가", "나", "다", "라", "마", "변경"] })); + } + + private static LegacyPListAuditRow Row( + string group, + string subject, + string graphic, + string subtype, + string page, + string dataCode) + { + var fields = new[] { "1", group, subject, graphic, subtype, page, dataCode }; + return new LegacyPListAuditRow( + "20260712", + "감사", + 0, + true, + Array.AsReadOnly(fields), + string.Join('^', fields), + page.Replace(" ", string.Empty, StringComparison.Ordinal)); + } + + private static ManualFinancialSnapshot SalesSnapshot(string stockName, char version) => + new( + new ManualSalesRecord( + new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName), + Enumerable.Range(1, 6) + .Select(index => new ManualQuarterValue($"Q{index}", index)) + .ToArray()), + new string(version, 64)); + + private static StockSearchResult Search(string query, params StockSearchItem[] items) => + new(query, DateTimeOffset.UnixEpoch, Array.AsReadOnly(items), IsTruncated: false); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNxtThemeJoinAuditServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNxtThemeJoinAuditServiceTests.cs new file mode 100644 index 0000000..a6ac64d --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNxtThemeJoinAuditServiceTests.cs @@ -0,0 +1,222 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyNxtThemeJoinAuditServiceTests +{ + [Fact] + public async Task AuditAsync_UsesBoundAggregateReadsAndReturnsNoIdentifiers() + { + var executor = new RecordingExecutor(call => + { + var current = Assert.IsType(call.Spec.Parameters[0].Value); + return current == "90000001" + ? Table("3", "0", "2", "2", "1", "0", "0", "0", "0", "0", "0", "3", + "2", "0", "1", "0") + : Table("4", "1", "0", "0", "0", "1", "1", "1", "0", "0", "0", "4", + "0", "0", "0", "0"); + }); + var service = new LegacyNxtThemeJoinAuditService( + executor, + new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero))); + + var result = await service.AuditAsync( + [ + new LegacyNxtThemeJoinAuditIdentity("90000001", "111"), + new LegacyNxtThemeJoinAuditIdentity("90000002", "222") + ]); + + Assert.Equal(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero), result.RetrievedAt); + Assert.Equal(2, result.Themes.Count); + Assert.Equal(0, result.Themes[0].InputIndex); + Assert.Equal(3, result.Themes[0].RawCurrentItemCount); + Assert.Equal(2, result.Themes[0].CurrentOriginalJoinCount); + Assert.Equal(1, result.Themes[0].CurrentLiveJoinCount); + Assert.Equal(2, result.Themes[0].CurrentKospiMasterJoinCount); + Assert.Equal(1, result.Themes[0].CurrentKospiOnlineJoinCount); + Assert.Equal(1, result.Themes[1].InputIndex); + Assert.Equal(4, result.Themes[1].RawCurrentItemCount); + Assert.Equal(1, result.Themes[1].RawStoredItemCount); + Assert.Equal(1, result.Themes[1].StoredLiveJoinCount); + Assert.Equal(2, executor.Calls.Count); + Assert.Equal(0, executor.StringSqlCallCount); + + Assert.All(executor.Calls, call => + { + Assert.Equal(DataSourceKind.MariaDb, call.Source); + Assert.Equal(LegacyNxtThemeJoinAuditService.QueryName, call.QueryName); + call.Spec.ValidateFor(DataSourceKind.MariaDb); + Assert.Contains( + "SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE", + call.Spec.Sql, + StringComparison.Ordinal); + Assert.Contains("RIGHT(i.SB_I_CODE, 6)", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM N_STOCK q", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM N_KOSDAQ_ONLINE q", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("AND EXISTS (", call.Spec.Sql, StringComparison.Ordinal); + Assert.DoesNotContain( + Assert.IsType(call.Spec.Parameters[0].Value), + call.Spec.Sql, + StringComparison.Ordinal); + Assert.Collection( + call.Spec.Parameters, + current => + { + Assert.Equal("current_theme_code", current.Name); + Assert.Equal(DbType.String, current.DbType); + }, + stored => + { + Assert.Equal("stored_theme_code", stored.Name); + Assert.Equal(DbType.String, stored.DbType); + }); + }); + } + + [Theory] + [MemberData(nameof(InvalidIdentities))] + public async Task AuditAsync_RejectsInvalidOrDuplicateIdentitiesBeforeDatabaseAccess( + IReadOnlyList identities) + { + var executor = new RecordingExecutor(_ => throw new InvalidOperationException()); + var service = new LegacyNxtThemeJoinAuditService(executor); + + await Assert.ThrowsAnyAsync(() => service.AuditAsync(identities)); + + Assert.Empty(executor.Calls); + Assert.Equal(0, executor.StringSqlCallCount); + } + + public static TheoryData> InvalidIdentities => + new() + { + Array.Empty(), + new LegacyNxtThemeJoinAuditIdentity[] { new("12", "123") }, + new LegacyNxtThemeJoinAuditIdentity[] { new("123", "A123") }, + new LegacyNxtThemeJoinAuditIdentity[] + { + new("123", "111"), + new("123", "222") + } + }; + + [Fact] + public async Task AuditAsync_FailsClosedOnWrongSchemaNonCanonicalCountOrInconsistentSubset() + { + var tables = new Queue( + [ + new DataTable(), + Table("01", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", + "0", "0", "0", "0"), + Table("1", "0", "1", "2", "0", "0", "0", "0", "0", "0", "0", "1", + "0", "0", "0", "0"), + Table("1", "0", "2", "0", "0", "0", "0", "0", "0", "0", "0", "1", + "0", "0", "0", "0"), + Table("1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "1", + "2", "0", "0", "0"), + Table("2", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "2", + "1", "2", "0", "0") + ]); + var service = new LegacyNxtThemeJoinAuditService( + new RecordingExecutor(_ => tables.Dequeue())); + var identity = new[] { new LegacyNxtThemeJoinAuditIdentity("123", "456") }; + + await Assert.ThrowsAsync(() => service.AuditAsync(identity)); + await Assert.ThrowsAsync(() => service.AuditAsync(identity)); + await Assert.ThrowsAsync(() => service.AuditAsync(identity)); + await Assert.ThrowsAsync(() => service.AuditAsync(identity)); + await Assert.ThrowsAsync(() => service.AuditAsync(identity)); + await Assert.ThrowsAsync(() => service.AuditAsync(identity)); + } + + [Fact] + public async Task AuditAsync_HonorsPreCanceledTokenWithoutDatabaseAccess() + { + var executor = new RecordingExecutor(_ => throw new InvalidOperationException()); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + new LegacyNxtThemeJoinAuditService(executor).AuditAsync( + [new("123", "456")], + cancellation.Token)); + + Assert.Empty(executor.Calls); + } + + private static DataTable Table(params string[] counts) + { + Assert.Equal(16, counts.Length); + var table = new DataTable(); + table.Columns.Add("RAW_CURRENT_ITEM_COUNT", typeof(string)); + table.Columns.Add("RAW_STORED_ITEM_COUNT", typeof(string)); + table.Columns.Add("CURRENT_ORIGINAL_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_NON_STOPPED_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_LIVE_JOIN_COUNT", typeof(string)); + table.Columns.Add("STORED_ORIGINAL_JOIN_COUNT", typeof(string)); + table.Columns.Add("STORED_NON_STOPPED_JOIN_COUNT", typeof(string)); + table.Columns.Add("STORED_LIVE_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_EXACT_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_STRIP_BOTH_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_RIGHT_SIX_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_VALID_ITEM_CODE_COUNT", typeof(string)); + table.Columns.Add("CURRENT_KOSPI_MASTER_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_KOSDAQ_MASTER_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_KOSPI_ONLINE_JOIN_COUNT", typeof(string)); + table.Columns.Add("CURRENT_KOSDAQ_ONLINE_JOIN_COUNT", typeof(string)); + table.Rows.Add(counts.Cast().ToArray()); + return table; + } + + private sealed record RecordedCall( + DataSourceKind Source, + string QueryName, + DataQuerySpec Spec, + CancellationToken CancellationToken); + + private sealed class RecordingExecutor(Func handler) + : IDataQueryExecutor + { + public List Calls { get; } = []; + + public int StringSqlCallCount { get; private set; } + + public DataTable Execute(DataSourceKind source, string tableName, string query) + { + StringSqlCallCount++; + throw new InvalidOperationException("String SQL must not be used."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string sql, + CancellationToken cancellationToken = default) + { + StringSqlCallCount++; + throw new InvalidOperationException("String SQL must not be used."); + } + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var call = new RecordedCall(source, tableName, query, cancellationToken); + Calls.Add(call); + return Task.FromResult(handler(call)); + } + } + + private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => value.ToUniversalTime(); + + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPListReadAuditParserTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPListReadAuditParserTests.cs new file mode 100644 index 0000000..69f1674 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPListReadAuditParserTests.cs @@ -0,0 +1,171 @@ +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class LegacyPListReadAuditParserTests +{ + [Fact] + public void Parse_PreservesEveryOriginalFieldAndOnlyNormalizesDisplayPageSpaces() + { + const string text = "1^테마^반도체 장비^5단 표그래프^테마-현재가(입력순)^ 1 / 12 ^00123"; + + var result = LegacyPListReadAuditParser.Parse( + "00000001", + "아침 방송", + "0", + text); + + Assert.True(result.IsSuccess); + var row = Assert.IsType(result.Row); + Assert.Equal(text, row.RawListText); + Assert.Equal(text, string.Join('^', row.Fields)); + Assert.True(row.IsLossless); + Assert.True(row.IsEnabled); + Assert.Equal("테마", row.GroupCode); + Assert.Equal("반도체 장비", row.Subject); + Assert.Equal(" 1 / 12 ", row.RawPageText); + Assert.Equal("1/12", row.PageTextForDisplay); + } + + [Theory] + [InlineData("1^테마^반도체^5단^현재가^1/1", LegacyPListAuditParseFailure.InvalidFieldCount)] + [InlineData("2^테마^반도체^5단^현재가^1/1^1", LegacyPListAuditParseFailure.InvalidEnabledField)] + [InlineData("1^테마^반도체^5단^현재가^1/1^1^extra", LegacyPListAuditParseFailure.InvalidFieldCount)] + public void Parse_RejectsAnythingOutsideTheOriginalSevenFieldGrammar( + string text, + LegacyPListAuditParseFailure expected) + { + var result = LegacyPListReadAuditParser.Parse("00000001", "방송", "0", text); + + Assert.False(result.IsSuccess); + Assert.Equal(expected, result.Failure); + Assert.Null(result.Row); + } + + [Fact] + public void Parse_RejectsReplacementAndKnownCodepageDamageWithoutEchoingText() + { + var replacement = LegacyPListReadAuditParser.Parse( + "00000001", "방송", "0", "1^테마^반도체�^5단^현재가^1/1^1"); + var mojibake = LegacyPListReadAuditParser.Parse( + "00000001", "방송", "0", "1^테마^정^5단^현재가^1/1^1"); + + Assert.Equal(LegacyPListAuditParseFailure.ReplacementCharacter, replacement.Failure); + Assert.Equal(LegacyPListAuditParseFailure.MojibakeSignal, mojibake.Failure); + } + + [Fact] + public void Parse_RejectsUnpairedUtf16SurrogatesButPreservesValidScalarPairs() + { + var unpaired = LegacyPListReadAuditParser.Parse( + "00000001", "방송", "0", "1^테마^반도체\uD800^5단^현재가^1/1^1"); + var validPair = LegacyPListReadAuditParser.Parse( + "00000001", "방송 📈", "0", "1^테마^반도체 📈^5단^현재가^1/1^1"); + + Assert.Equal(LegacyPListAuditParseFailure.UnsafeText, unpaired.Failure); + Assert.True(validPair.IsSuccess); + Assert.True(validPair.Row!.IsLossless); + } + + [Theory] + [InlineData("0000001")] + [InlineData("0000000A")] + [InlineData(" 00000001")] + public void Parse_RequiresTheOriginalEightDigitProgramIdentity(string code) + { + var result = LegacyPListReadAuditParser.Parse( + code, "방송", "0", "1^테마^반도체^5단^현재가^1/1^1"); + + Assert.Equal(LegacyPListAuditParseFailure.InvalidProgramCode, result.Failure); + } + + [Theory] + [InlineData("00")] + [InlineData("-1")] + [InlineData("1000")] + [InlineData(" 0")] + public void Parse_RequiresCanonicalBoundedItemIndex(string itemIndex) + { + var result = LegacyPListReadAuditParser.Parse( + "00000001", "방송", itemIndex, "1^테마^반도체^5단^현재가^1/1^1"); + + Assert.Equal(LegacyPListAuditParseFailure.InvalidItemIndex, result.Failure); + } + + [Fact] + public void Parse_AllowsEmptyLegacySelectionFieldsAndLiteralQuestionMarks() + { + const string text = "0^^확인?^^^1/1^"; + + var result = LegacyPListReadAuditParser.Parse("00000001", "질문?", "0", text); + + Assert.True(result.IsSuccess); + Assert.False(result.Row!.IsEnabled); + Assert.Equal(string.Empty, result.Row.GroupCode); + Assert.Equal(string.Empty, result.Row.DataCode); + } + + [Fact] + public void Evidence_RequiresContiguousIndexesAndExactPersistenceFields() + { + var first = Parse("0", "1^업종_코스피^코스피^네모그래프^^1/1^"); + var second = Parse("1", "0^업종_코스피^코스피^섹터지수^^^"); + var stored = new NamedPlaylistStoredItem( + 0, + true, + new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( + "업종_코스피", "코스피", "네모그래프", "", ""), + new NamedPlaylistPageState(1, 1)); + + Assert.True(LegacyPListReadAuditEvidence.HasContiguousIndexes([first, second])); + Assert.False(LegacyPListReadAuditEvidence.HasContiguousIndexes([ + first, + second with { ItemIndex = 2 } + ])); + Assert.True(LegacyPListReadAuditEvidence.MatchesPersistenceResult(first, stored)); + Assert.False(LegacyPListReadAuditEvidence.MatchesPersistenceResult( + first with { RawListText = first.RawListText.Replace("코스피", "코스닥") }, + stored with { IsEnabled = false })); + } + + [Theory] + [InlineData("1^업종_코스피^코스피^네모그래프^^1/1^", LegacyPListAuditRoute.IndustryFixedClosedMapper)] + [InlineData("1^해외지수^다우존스^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity)] + [InlineData("1^테마_NXT^반도체(NXT)^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.NxtThemeFreshIdentity)] + [InlineData("1^테마^반도체^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.KrxThemeFreshIdentity)] + [InlineData("1^해외종목^APPLE^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignStockFreshIdentity)] + [InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.ExistingClosedMapper)] + [InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@𐐀|US|1", LegacyPListAuditRoute.ExistingClosedMapper)] + [InlineData("1^FOREIGN_STOCK^NVIDIA^MA5^PRICE^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.Unmapped)] + [InlineData("1^코스피^삼성전자^성장성 지표^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)] + [InlineData("1^KOSPI^삼성전자^GROWTH_METRICS^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)] + [InlineData("1^INDIVIDUAL^INDEX^MANUAL_NET_SELL^MANUAL_NET_SELL^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)] + [InlineData("1^PAGED_VI^@MBN_VI_SNAPSHOT_V1^INPUT_ORDER^CURRENT^1/1^aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", LegacyPListAuditRoute.ManualFreshMaterialization)] + [InlineData("1^코스피^삼성전자^캔들그래프^20일^1/1^005930", LegacyPListAuditRoute.ExistingClosedMapper)] + [InlineData("1^ALL^INDEX^PROGRAM_TRADING^TABLE_GRAPH^1/1^", LegacyPListAuditRoute.ExistingClosedMapper)] + [InlineData("1^알수없는그룹^알수없는대상^비슷한그래프^^1/1^", LegacyPListAuditRoute.Unmapped)] + public void Evidence_RoutesSpecialRowsWithoutGuessing( + string text, + LegacyPListAuditRoute expected) + { + Assert.Equal(expected, LegacyPListReadAuditEvidence.ClassifyRoute(Parse("0", text))); + } + + [Fact] + public void Evidence_DoesNotTreatNearestStockLabelAsAClosedMapperMatch() + { + var exact = Parse("0", "1^코스피^삼성전자^수익률그래프^20일^1/1^005930"); + var nearest = Parse("0", "1^코스피^삼성전자^수익률 그래프^20일^1/1^005930"); + + Assert.Equal( + LegacyPListAuditRoute.ExistingClosedMapper, + LegacyPListReadAuditEvidence.ClassifyRoute(exact)); + Assert.Equal( + LegacyPListAuditRoute.Unmapped, + LegacyPListReadAuditEvidence.ClassifyRoute(nearest)); + } + + private static LegacyPListAuditRow Parse(string itemIndex, string text) => + Assert.IsType(LegacyPListReadAuditParser.Parse( + "00000001", "방송", itemIndex, text).Row); +} diff --git a/tests/Web/candle-options-workflow.test.cjs b/tests/Web/candle-options-workflow.test.cjs index 5b48846..16c8cd7 100644 --- a/tests/Web/candle-options-workflow.test.cjs +++ b/tests/Web/candle-options-workflow.test.cjs @@ -7,6 +7,7 @@ const stock = require("../../Web/operator-workflow.js"); const fixed = require("../../Web/legacy-fixed-workflow.js"); const industry = require("../../Web/industry-workflow.js"); const overseas = require("../../Web/overseas-workflow.js"); +const namedForeignStock = require("../../Web/named-foreign-stock-restore-workflow.js"); const tradingHalt = require("../../Web/trading-halt-workflow.js"); const adapters = { stock, fixed, industry, overseas, tradingHalt }; @@ -35,6 +36,37 @@ const haltedStock = Object.freeze({ function entries() { const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available); const industryCut = industry.cuts.find(value => value.kind === "candle"); + const pendingNamedForeign = namedForeignStock.classify({ + itemIndex: 0, + enabled: false, + groupCode: "해외종목", + subject: "NVIDIA", + graphicType: "캔들그래프", + subtype: "120일", + pageText: "1/1", + dataCode: "" + }, options("named-overseas-candle")); + const namedResponse = namedForeignStock.normalizeResponse({ + requestId: "named-overseas-load", + retrievedAt: "2026-07-12T12:34:56+09:00", + totalRowCount: 1, + rows: [{ + itemIndex: 0, + status: "resolved", + actionId: "stock-candle-120d", + inputName: "NVIDIA", + symbol: "NAS@NVDA", + nationCode: "US", + foreignDomesticCode: "1", + builderKey: "s8010", + cutCode: "8051", + valueType: "PRICE", + periodDays: 120 + }] + }, "named-overseas-load", [pendingNamedForeign]); + const namedForeignEntry = namedForeignStock.materialize( + pendingNamedForeign, + namedResponse.rows[0]); return [ stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")), fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")), @@ -46,6 +78,7 @@ function entries() { overseasStock, "stock-candle-120d", options("overseas-candle")), + namedForeignEntry, tradingHalt.createTradingHaltPlaylistEntry( haltedStock, "candle-120d", @@ -61,13 +94,35 @@ test("global flags map to the exact closed candle graphic type", () => { assert.throws(() => workflow.expectedGraphicType("yes", true)); }); -test("all five original candle entry sources can be recreated with global flags", () => { +test("all original and DB-restored candle entry sources can be recreated with global flags", () => { for (const entry of entries()) { const recreated = workflow.recreateCandleEntry(entry, true, true, adapters); assert.ok(recreated, entry.operator.source); assert.equal(recreated.selection.graphicType, "MA5,MA20"); assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true }); assert.equal(recreated.enabled, entry.enabled); + if (entry.operator.source === namedForeignStock.source) { + assert.equal(recreated.operator.source, namedForeignStock.source); + assert.deepEqual(recreated.operator.rawSelection, entry.operator.rawSelection); + assert.ok(namedForeignStock.restorePlaylistEntry(recreated)); + assert.deepEqual(namedForeignStock.legacySelectionForEntry(recreated), { + groupCode: "해외종목", + subject: "NVIDIA", + graphicType: "캔들그래프", + subtype: "120일", + dataCode: "" + }); + assert.equal(namedForeignStock.matchesRaw(recreated, { + itemIndex: 0, + enabled: false, + groupCode: "해외종목", + subject: "NVIDIA", + graphicType: "캔들그래프", + subtype: "120일", + pageText: "1/1", + dataCode: "" + }), true); + } } }); diff --git a/tests/Web/legacy-foreign-index-candle-workflow.test.cjs b/tests/Web/legacy-foreign-index-candle-workflow.test.cjs index 8a3fae4..19ac239 100644 --- a/tests/Web/legacy-foreign-index-candle-workflow.test.cjs +++ b/tests/Web/legacy-foreign-index-candle-workflow.test.cjs @@ -209,6 +209,25 @@ test("legacy signature round-trips only the exact verified entry", () => { } }); +test("enabled replacement preserves the branded foreign-index proof immutably", () => { + const storedRaw = raw("S&P500", "20일", 6, true); + const pending = workflow.classify(storedRaw, { + id: "foreign_toggle", fadeDuration: 6 + }); + const normalized = workflow.normalizeResponse(response("load_toggle", [pending]), + "load_toggle", [pending]); + const entry = workflow.materialize(pending, normalized.rows[0]); + const replacement = workflow.withEnabled(entry, false); + + assert.notEqual(replacement, entry); + assert.equal(Object.isFrozen(replacement), true); + assert.equal(entry.enabled, true); + assert.equal(replacement.enabled, false); + assert.equal(workflow.isMaterializedEntry(replacement), true); + assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true); + assert.equal(workflow.withEnabled({ ...entry }, false), null); +}); + test("bridge errors are request-bound and never retryable", () => { const error = { requestId: "load_error", diff --git a/tests/Web/manual-financial-workflow.test.cjs b/tests/Web/manual-financial-workflow.test.cjs index bb3256f..3754299 100644 --- a/tests/Web/manual-financial-workflow.test.cjs +++ b/tests/Web/manual-financial-workflow.test.cjs @@ -605,6 +605,24 @@ test("playlist entry has the complete standard operator metadata and native trus }, { id: "forged" }), /native-confirmed/); }); +test("enabled replacement keeps native financial trust without mutating the frozen entry", () => { + const snapshot = trustedSnapshot("sales"); + const stock = verifiedStock(snapshot.record); + const selection = trustedSelection(snapshot, stock); + const entry = workflow.createPlaylistEntry(snapshot, stock, selection, { + id: "sales-toggle", + fadeDuration: 6 + }); + const replacement = workflow.withEnabled(entry, false); + + assert.notEqual(replacement, entry); + assert.equal(Object.isFrozen(replacement), true); + assert.equal(entry.enabled, true); + assert.equal(replacement.enabled, false); + assert.equal(workflow.isPlaylistEntryPlayable(replacement), true); + assert.equal(workflow.withEnabled({ ...entry }, false), null); +}); + test("stored entries restore only as non-playable refresh-required descriptors", () => { const snapshot = trustedSnapshot("sales"); const stock = verifiedStock(snapshot.record); diff --git a/tests/Web/manual-lists-workflow.test.cjs b/tests/Web/manual-lists-workflow.test.cjs index 051c14a..f0b5298 100644 --- a/tests/Web/manual-lists-workflow.test.cjs +++ b/tests/Web/manual-lists-workflow.test.cjs @@ -361,6 +361,29 @@ test("only entries bound to a correlated fresh read are playable", () => { viRead)), false); }); +test("enabled replacement transfers only the correlated manual-list read capability", () => { + const netRead = workflow.normalizeNetSellDataResponse({ + requestId, + audience: "FOREIGN", + rows: rows() + }, { requestId, audience: "FOREIGN" }); + const entry = workflow.createNetSellPlaylistEntry("FOREIGN", { + id: "entry_net_toggle", + fadeDuration: 6 + }, netRead); + const replacement = workflow.withEnabled(entry, false); + + assert.notEqual(replacement, entry); + assert.equal(entry.enabled, true); + assert.equal(replacement.enabled, false); + assert.equal(workflow.isPlaylistEntryPlayable(replacement), true); + assert.equal(workflow.withEnabled({ ...entry }, false), null); + assert.equal(workflow.withEnabled(workflow.createNetSellPlaylistEntry("FOREIGN", { + id: "entry_net_toggle_untrusted", + fadeDuration: 6 + }), false), null); +}); + test("request identifiers and option bounds are strict", () => { assert.throws(() => workflow.createStatusRequest("bad id")); assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", { diff --git a/tests/Web/named-foreign-stock-restore-app-integration.test.cjs b/tests/Web/named-foreign-stock-restore-app-integration.test.cjs new file mode 100644 index 0000000..e3ca85e --- /dev/null +++ b/tests/Web/named-foreign-stock-restore-app-integration.test.cjs @@ -0,0 +1,136 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const foreignStock = require("../../Web/named-foreign-stock-restore-workflow.js"); +const legacyRows = require("../../Web/legacy-named-row-workflow.js"); +const namedPlaylists = require("../../Web/named-playlist-workflow.js"); + +const root = path.resolve(__dirname, "../.."); +const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8"); +const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8"); +const bridge = fs.readFileSync( + path.join(root, "MainWindow.NamedForeignStockRestore.cs"), "utf8"); +const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8"); +const core = fs.readFileSync( + path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs"), + "utf8"); + +function rawRow(enabled = false) { + return { + itemIndex: 0, + enabled, + groupCode: "해외종목", + subject: "NVIDIA", + graphicType: "캔들그래프", + subtype: "240일", + pageText: "1/1", + dataCode: "" + }; +} + +function materializedEntry() { + const raw = rawRow(); + const pending = foreignStock.classify(raw, { + id: "db-foreign-stock-240", + fadeDuration: 6, + ma5: true, + ma20: false + }); + const response = foreignStock.normalizeResponse({ + requestId: "foreign-stock-load", + retrievedAt: "2026-07-12T12:34:56+09:00", + totalRowCount: 1, + rows: [{ + itemIndex: 0, + status: "resolved", + actionId: "stock-candle-240d", + inputName: "NVIDIA", + symbol: "NAS@NVDA", + nationCode: "US", + foreignDomesticCode: "1", + builderKey: "s8010", + cutCode: "8056", + valueType: "PRICE", + periodDays: 240 + }] + }, "foreign-stock-load", [pending]); + return foreignStock.materialize(pending, response.rows[0]); +} + +test("named load connects one correlated native foreign-stock restore batch end to end", () => { + assert.match(index, /