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