feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

@@ -25,9 +25,9 @@
<PublishProfile>Properties\PublishProfiles\win-x64.pubxml</PublishProfile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<ApplicationDisplayVersion>1.0.3</ApplicationDisplayVersion>
<ApplicationVersion>4</ApplicationVersion>
<Version>1.0.3</Version>
<ApplicationDisplayVersion>1.0.4</ApplicationDisplayVersion>
<ApplicationVersion>5</ApplicationVersion>
<Version>1.0.4</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -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<NamedPlaylistStoredItem> 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
});
}
}

View File

@@ -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<NamedPlaylistStoredItem> 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
});
}
}

View File

@@ -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,

View File

@@ -10,7 +10,7 @@
<Identity
Name="Wickedness.MBNStockWebView"
Publisher="CN=Comtrophy"
Version="1.0.3.0" />
Version="1.0.4.0" />
<mp:PhoneIdentity PhoneProductId="b4129399-858c-4b31-90d7-28a0f6c677d0" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>

View File

@@ -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;

View File

@@ -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;

View File

@@ -570,7 +570,9 @@
<script src="manual-financial-workflow.js"></script>
<script src="named-manual-restore-workflow.js"></script>
<script src="named-comparison-restore-workflow.js"></script>
<script src="named-foreign-stock-restore-workflow.js"></script>
<script src="legacy-foreign-index-candle-workflow.js"></script>
<script src="named-krx-theme-restore-workflow.js"></script>
<script src="named-nxt-theme-restore-workflow.js"></script>
<script src="legacy-named-row-workflow.js"></script>
<script src="operator-catalog-workflow.js"></script>

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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
};
});

View File

@@ -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
};
});

View File

@@ -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
};
});

View File

@@ -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
};
});

View File

@@ -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,

View File

@@ -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({

View File

@@ -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로 유지한다.

View File

@@ -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개 필드를 글자 단위로 비교한다. `<60>` 또는 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 검사 보강 기준

View File

@@ -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-<GUID>`에만
쓴다. 전체 실행은 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 버튼이다. 비교쌍은
상단 `비교` 업무 탭 안의 `원본 비교쌍 가져오기` 버튼으로 진입한다.

View File

@@ -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

View File

@@ -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
승인 회차에서만 수행한다.

View File

@@ -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 값이나
인증정보도 출력하지 않았다.

View File

@@ -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`여야 한다.

View File

@@ -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을 승인 없이 실행하지 않는다. |

View File

@@ -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("<script src=`"$asset`"></script>")) {
throw "Operator UI script is not loaded: $asset"

View File

@@ -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
}
/// <summary>
/// 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.
/// </summary>
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
}
/// <summary>
/// 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.
/// </summary>
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<LegacyNamedForeignStockRestoreOutcome> Rows);
/// <summary>
/// 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.
/// </summary>
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<string, ActionProfile> CandleActions =
new Dictionary<string, ActionProfile>(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));
}
/// <summary>
/// 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.
/// </summary>
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<LegacyNamedForeignStockRestoreResult> ResolveAsync(
IReadOnlyList<LegacyNamedForeignStockRestoreRow> 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<int>();
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<string, IdentityResolution>(StringComparer.Ordinal);
var outcomes = new List<LegacyNamedForeignStockRestoreOutcome>(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<LegacyNamedForeignStockRestoreOutcome> ResolveRowAsync(
LegacyNamedForeignStockRestoreRow row,
IDictionary<string, IdentityResolution> 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<IdentityResolution> 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<WorldStockSearchItem>(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);
}

View File

@@ -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
}
/// <summary>
/// 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.
/// </summary>
public sealed record LegacyNamedKrxThemeRestoreRow(
int ItemIndex,
bool IsEnabled,
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string PageText,
string DataCode);
/// <summary>
/// A closed current KRX identity. StoredThemeCode is evidence only and must
/// never be used by PREPARE after this identity has been resolved.
/// </summary>
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<LegacyNamedKrxThemeRestoreOutcome> Rows);
/// <summary>
/// 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.
/// </summary>
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<string, SubtypeProfile> Subtypes =
new Dictionary<string, SubtypeProfile>(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<string, ActionProfile> Actions =
new Dictionary<string, ActionProfile>(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));
}
/// <summary>
/// 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.
/// </summary>
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<LegacyNamedKrxThemeRestoreResult> ResolveAsync(
IReadOnlyList<LegacyNamedKrxThemeRestoreRow> 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<int>();
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<string, ThemeVerification>(StringComparer.Ordinal);
var outcomes = new List<LegacyNamedKrxThemeRestoreOutcome>(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<LegacyNamedKrxThemeRestoreOutcome> ResolveRowAsync(
LegacyNamedKrxThemeRestoreRow row,
IDictionary<string, ThemeVerification> 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<ThemeVerification> 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();
}

View File

@@ -0,0 +1,511 @@
#nullable enable
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// 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.
/// </summary>
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<string>? 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<string>? HistoricalViNames { get; }
public string? ExpectedViVersion { get; }
public int ExpectedPageCount { get; }
public override string ToString() => $"Named manual restore candidate ({Kind})";
}
/// <summary>
/// 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.
/// </summary>
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<string> names)
{
names = Array.Empty<string>();
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);
}
/// <summary>
/// Pure checks for the evidence gathered through production read boundaries.
/// No method opens a database or file and no result contains source text.
/// </summary>
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<S5025TrustedManualRow> 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))
};
}

View File

@@ -0,0 +1,341 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// 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.
/// </summary>
public sealed record LegacyNxtThemeJoinAuditIdentity(
string CurrentThemeCode,
string StoredThemeCode);
/// <summary>
/// Aggregate evidence for one current theme code. No theme or stock identifier
/// is returned, so callers can safely log only cardinalities.
/// </summary>
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<LegacyNxtThemeJoinAuditEvidence> Themes);
/// <summary>
/// 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.
/// </summary>
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<LegacyNxtThemeJoinAuditResult> AuditAsync(
IReadOnlyList<LegacyNxtThemeJoinAuditIdentity> 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<string>(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<LegacyNxtThemeJoinAuditEvidence>(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();
}

View File

@@ -0,0 +1,524 @@
#nullable enable
using System.Globalization;
using System.Text;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// 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.
/// </summary>
public sealed record LegacyPListAuditRow(
string ProgramCode,
string ProgramTitle,
int ItemIndex,
bool IsEnabled,
IReadOnlyList<string> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public static class LegacyPListReadAuditEvidence
{
public static bool HasContiguousIndexes(IReadOnlyList<LegacyPListAuditRow> 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<string> 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<SelectionKey> 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);
}

View File

@@ -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)
{

View File

@@ -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<string, string, string, string, int?> 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<string>(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<string>(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<DataException>(() => service.ResolveAsync([Row()]));
Assert.Single(failed.Calls);
var unused = new RecordingExecutor(_ => throw new InvalidOperationException());
service = new LegacyNamedForeignStockRestoreService(unused);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([Row(4), Row(4)]));
await Assert.ThrowsAsync<ArgumentException>(() => 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<string>(Assert.Single(call.Spec.Parameters).Value);
return IdentityTable((name, "NAS@NVDA", "US", "1"));
}, throwBeforeHandlerWhenCanceled: false);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
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<RecordedCall, DataTable> handler,
bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor
{
private int _stringSqlCallCount;
public List<RecordedCall> 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<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException();
}
public Task<DataTable> 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());
}
}
}

View File

@@ -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<string, string, string, string, int, string> 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<string>(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<DataException>(() =>
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<OperationCanceledException>(() =>
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<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
await Assert.ThrowsAsync<ArgumentException>(() => 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<RecordedCall, DataTable> handler)
: IDataQueryExecutor
{
public List<RecordedCall> 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<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string sql,
CancellationToken cancellationToken = default)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> 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));
}
}
}

View File

@@ -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<LegacyNamedManualRestoreCandidate>(
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<LegacyNamedManualRestoreCandidate>(
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<LegacyNamedManualRestoreCandidate>(
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<LegacyNamedManualRestoreCandidate>(
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);
}

View File

@@ -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<string>(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<string>(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<LegacyNxtThemeJoinAuditIdentity> identities)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNxtThemeJoinAuditService(executor);
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.AuditAsync(identities));
Assert.Empty(executor.Calls);
Assert.Equal(0, executor.StringSqlCallCount);
}
public static TheoryData<IReadOnlyList<LegacyNxtThemeJoinAuditIdentity>> InvalidIdentities =>
new()
{
Array.Empty<LegacyNxtThemeJoinAuditIdentity>(),
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<DataTable>(
[
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<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => 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<OperationCanceledException>(() =>
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<object>().ToArray());
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: IDataQueryExecutor
{
public List<RecordedCall> 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<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string sql,
CancellationToken cancellationToken = default)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call));
}
}
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => value.ToUniversalTime();
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}

View File

@@ -0,0 +1,171 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyPListReadAuditParserTests
{
[Fact]
public void Parse_PreservesEveryOriginalFieldAndOnlyNormalizesDisplayPageSpaces()
{
const string text = "1^테마^반도체 장비^5단 표그래프^테마-현재가(입력순)^ 1 / 12 ^00123";
var result = LegacyPListReadAuditParser.Parse(
"00000001",
"아침 방송",
"0",
text);
Assert.True(result.IsSuccess);
var row = Assert.IsType<LegacyPListAuditRow>(result.Row);
Assert.Equal(text, row.RawListText);
Assert.Equal(text, string.Join('^', row.Fields));
Assert.True(row.IsLossless);
Assert.True(row.IsEnabled);
Assert.Equal("테마", row.GroupCode);
Assert.Equal("반도체 장비", row.Subject);
Assert.Equal(" 1 / 12 ", row.RawPageText);
Assert.Equal("1/12", row.PageTextForDisplay);
}
[Theory]
[InlineData("1^테마^반도체^5단^현재가^1/1", LegacyPListAuditParseFailure.InvalidFieldCount)]
[InlineData("2^테마^반도체^5단^현재가^1/1^1", LegacyPListAuditParseFailure.InvalidEnabledField)]
[InlineData("1^테마^반도체^5단^현재가^1/1^1^extra", LegacyPListAuditParseFailure.InvalidFieldCount)]
public void Parse_RejectsAnythingOutsideTheOriginalSevenFieldGrammar(
string text,
LegacyPListAuditParseFailure expected)
{
var result = LegacyPListReadAuditParser.Parse("00000001", "방송", "0", text);
Assert.False(result.IsSuccess);
Assert.Equal(expected, result.Failure);
Assert.Null(result.Row);
}
[Fact]
public void Parse_RejectsReplacementAndKnownCodepageDamageWithoutEchoingText()
{
var replacement = LegacyPListReadAuditParser.Parse(
"00000001", "방송", "0", "1^테마^반도체<EB8F84>^5단^현재가^1/1^1");
var mojibake = LegacyPListReadAuditParser.Parse(
"00000001", "방송", "0", "1^테마^정^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.ReplacementCharacter, replacement.Failure);
Assert.Equal(LegacyPListAuditParseFailure.MojibakeSignal, mojibake.Failure);
}
[Fact]
public void Parse_RejectsUnpairedUtf16SurrogatesButPreservesValidScalarPairs()
{
var unpaired = LegacyPListReadAuditParser.Parse(
"00000001", "방송", "0", "1^테마^반도체\uD800^5단^현재가^1/1^1");
var validPair = LegacyPListReadAuditParser.Parse(
"00000001", "방송 📈", "0", "1^테마^반도체 📈^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.UnsafeText, unpaired.Failure);
Assert.True(validPair.IsSuccess);
Assert.True(validPair.Row!.IsLossless);
}
[Theory]
[InlineData("0000001")]
[InlineData("0000000A")]
[InlineData(" 00000001")]
public void Parse_RequiresTheOriginalEightDigitProgramIdentity(string code)
{
var result = LegacyPListReadAuditParser.Parse(
code, "방송", "0", "1^테마^반도체^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.InvalidProgramCode, result.Failure);
}
[Theory]
[InlineData("00")]
[InlineData("-1")]
[InlineData("1000")]
[InlineData(" 0")]
public void Parse_RequiresCanonicalBoundedItemIndex(string itemIndex)
{
var result = LegacyPListReadAuditParser.Parse(
"00000001", "방송", itemIndex, "1^테마^반도체^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.InvalidItemIndex, result.Failure);
}
[Fact]
public void Parse_AllowsEmptyLegacySelectionFieldsAndLiteralQuestionMarks()
{
const string text = "0^^확인?^^^1/1^";
var result = LegacyPListReadAuditParser.Parse("00000001", "질문?", "0", text);
Assert.True(result.IsSuccess);
Assert.False(result.Row!.IsEnabled);
Assert.Equal(string.Empty, result.Row.GroupCode);
Assert.Equal(string.Empty, result.Row.DataCode);
}
[Fact]
public void Evidence_RequiresContiguousIndexesAndExactPersistenceFields()
{
var first = Parse("0", "1^업종_코스피^코스피^네모그래프^^1/1^");
var second = Parse("1", "0^업종_코스피^코스피^섹터지수^^^");
var stored = new NamedPlaylistStoredItem(
0,
true,
new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
"업종_코스피", "코스피", "네모그래프", "", ""),
new NamedPlaylistPageState(1, 1));
Assert.True(LegacyPListReadAuditEvidence.HasContiguousIndexes([first, second]));
Assert.False(LegacyPListReadAuditEvidence.HasContiguousIndexes([
first,
second with { ItemIndex = 2 }
]));
Assert.True(LegacyPListReadAuditEvidence.MatchesPersistenceResult(first, stored));
Assert.False(LegacyPListReadAuditEvidence.MatchesPersistenceResult(
first with { RawListText = first.RawListText.Replace("코스피", "코스닥") },
stored with { IsEnabled = false }));
}
[Theory]
[InlineData("1^업종_코스피^코스피^네모그래프^^1/1^", LegacyPListAuditRoute.IndustryFixedClosedMapper)]
[InlineData("1^해외지수^다우존스^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity)]
[InlineData("1^테마_NXT^반도체(NXT)^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.NxtThemeFreshIdentity)]
[InlineData("1^테마^반도체^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.KrxThemeFreshIdentity)]
[InlineData("1^해외종목^APPLE^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignStockFreshIdentity)]
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@𐐀|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^FOREIGN_STOCK^NVIDIA^MA5^PRICE^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.Unmapped)]
[InlineData("1^코스피^삼성전자^성장성 지표^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^KOSPI^삼성전자^GROWTH_METRICS^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^INDIVIDUAL^INDEX^MANUAL_NET_SELL^MANUAL_NET_SELL^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^PAGED_VI^@MBN_VI_SNAPSHOT_V1^INPUT_ORDER^CURRENT^1/1^aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^코스피^삼성전자^캔들그래프^20일^1/1^005930", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^ALL^INDEX^PROGRAM_TRADING^TABLE_GRAPH^1/1^", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^알수없는그룹^알수없는대상^비슷한그래프^^1/1^", LegacyPListAuditRoute.Unmapped)]
public void Evidence_RoutesSpecialRowsWithoutGuessing(
string text,
LegacyPListAuditRoute expected)
{
Assert.Equal(expected, LegacyPListReadAuditEvidence.ClassifyRoute(Parse("0", text)));
}
[Fact]
public void Evidence_DoesNotTreatNearestStockLabelAsAClosedMapperMatch()
{
var exact = Parse("0", "1^코스피^삼성전자^수익률그래프^20일^1/1^005930");
var nearest = Parse("0", "1^코스피^삼성전자^수익률 그래프^20일^1/1^005930");
Assert.Equal(
LegacyPListAuditRoute.ExistingClosedMapper,
LegacyPListReadAuditEvidence.ClassifyRoute(exact));
Assert.Equal(
LegacyPListAuditRoute.Unmapped,
LegacyPListReadAuditEvidence.ClassifyRoute(nearest));
}
private static LegacyPListAuditRow Parse(string itemIndex, string text) =>
Assert.IsType<LegacyPListAuditRow>(LegacyPListReadAuditParser.Parse(
"00000001", "방송", itemIndex, text).Row);
}

View File

@@ -7,6 +7,7 @@ const stock = require("../../Web/operator-workflow.js");
const fixed = require("../../Web/legacy-fixed-workflow.js");
const industry = require("../../Web/industry-workflow.js");
const overseas = require("../../Web/overseas-workflow.js");
const namedForeignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
const tradingHalt = require("../../Web/trading-halt-workflow.js");
const adapters = { stock, fixed, industry, overseas, tradingHalt };
@@ -35,6 +36,37 @@ const haltedStock = Object.freeze({
function entries() {
const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available);
const industryCut = industry.cuts.find(value => value.kind === "candle");
const pendingNamedForeign = namedForeignStock.classify({
itemIndex: 0,
enabled: false,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "120일",
pageText: "1/1",
dataCode: ""
}, options("named-overseas-candle"));
const namedResponse = namedForeignStock.normalizeResponse({
requestId: "named-overseas-load",
retrievedAt: "2026-07-12T12:34:56+09:00",
totalRowCount: 1,
rows: [{
itemIndex: 0,
status: "resolved",
actionId: "stock-candle-120d",
inputName: "NVIDIA",
symbol: "NAS@NVDA",
nationCode: "US",
foreignDomesticCode: "1",
builderKey: "s8010",
cutCode: "8051",
valueType: "PRICE",
periodDays: 120
}]
}, "named-overseas-load", [pendingNamedForeign]);
const namedForeignEntry = namedForeignStock.materialize(
pendingNamedForeign,
namedResponse.rows[0]);
return [
stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")),
fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")),
@@ -46,6 +78,7 @@ function entries() {
overseasStock,
"stock-candle-120d",
options("overseas-candle")),
namedForeignEntry,
tradingHalt.createTradingHaltPlaylistEntry(
haltedStock,
"candle-120d",
@@ -61,13 +94,35 @@ test("global flags map to the exact closed candle graphic type", () => {
assert.throws(() => workflow.expectedGraphicType("yes", true));
});
test("all five original candle entry sources can be recreated with global flags", () => {
test("all original and DB-restored candle entry sources can be recreated with global flags", () => {
for (const entry of entries()) {
const recreated = workflow.recreateCandleEntry(entry, true, true, adapters);
assert.ok(recreated, entry.operator.source);
assert.equal(recreated.selection.graphicType, "MA5,MA20");
assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true });
assert.equal(recreated.enabled, entry.enabled);
if (entry.operator.source === namedForeignStock.source) {
assert.equal(recreated.operator.source, namedForeignStock.source);
assert.deepEqual(recreated.operator.rawSelection, entry.operator.rawSelection);
assert.ok(namedForeignStock.restorePlaylistEntry(recreated));
assert.deepEqual(namedForeignStock.legacySelectionForEntry(recreated), {
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "120일",
dataCode: ""
});
assert.equal(namedForeignStock.matchesRaw(recreated, {
itemIndex: 0,
enabled: false,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "120일",
pageText: "1/1",
dataCode: ""
}), true);
}
}
});

View File

@@ -209,6 +209,25 @@ test("legacy signature round-trips only the exact verified entry", () => {
}
});
test("enabled replacement preserves the branded foreign-index proof immutably", () => {
const storedRaw = raw("S&P500", "20일", 6, true);
const pending = workflow.classify(storedRaw, {
id: "foreign_toggle", fadeDuration: 6
});
const normalized = workflow.normalizeResponse(response("load_toggle", [pending]),
"load_toggle", [pending]);
const entry = workflow.materialize(pending, normalized.rows[0]);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});
test("bridge errors are request-bound and never retryable", () => {
const error = {
requestId: "load_error",

View File

@@ -605,6 +605,24 @@ test("playlist entry has the complete standard operator metadata and native trus
}, { id: "forged" }), /native-confirmed/);
});
test("enabled replacement keeps native financial trust without mutating the frozen entry", () => {
const snapshot = trustedSnapshot("sales");
const stock = verifiedStock(snapshot.record);
const selection = trustedSelection(snapshot, stock);
const entry = workflow.createPlaylistEntry(snapshot, stock, selection, {
id: "sales-toggle",
fadeDuration: 6
});
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});
test("stored entries restore only as non-playable refresh-required descriptors", () => {
const snapshot = trustedSnapshot("sales");
const stock = verifiedStock(snapshot.record);

View File

@@ -361,6 +361,29 @@ test("only entries bound to a correlated fresh read are playable", () => {
viRead)), false);
});
test("enabled replacement transfers only the correlated manual-list read capability", () => {
const netRead = workflow.normalizeNetSellDataResponse({
requestId,
audience: "FOREIGN",
rows: rows()
}, { requestId, audience: "FOREIGN" });
const entry = workflow.createNetSellPlaylistEntry("FOREIGN", {
id: "entry_net_toggle",
fadeDuration: 6
}, netRead);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
assert.equal(workflow.withEnabled(workflow.createNetSellPlaylistEntry("FOREIGN", {
id: "entry_net_toggle_untrusted",
fadeDuration: 6
}), false), null);
});
test("request identifiers and option bounds are strict", () => {
assert.throws(() => workflow.createStatusRequest("bad id"));
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {

View File

@@ -0,0 +1,136 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const foreignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
const legacyRows = require("../../Web/legacy-named-row-workflow.js");
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
const root = path.resolve(__dirname, "../..");
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
const bridge = fs.readFileSync(
path.join(root, "MainWindow.NamedForeignStockRestore.cs"), "utf8");
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
const core = fs.readFileSync(
path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs"),
"utf8");
function rawRow(enabled = false) {
return {
itemIndex: 0,
enabled,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "240일",
pageText: "1/1",
dataCode: ""
};
}
function materializedEntry() {
const raw = rawRow();
const pending = foreignStock.classify(raw, {
id: "db-foreign-stock-240",
fadeDuration: 6,
ma5: true,
ma20: false
});
const response = foreignStock.normalizeResponse({
requestId: "foreign-stock-load",
retrievedAt: "2026-07-12T12:34:56+09:00",
totalRowCount: 1,
rows: [{
itemIndex: 0,
status: "resolved",
actionId: "stock-candle-240d",
inputName: "NVIDIA",
symbol: "NAS@NVDA",
nationCode: "US",
foreignDomesticCode: "1",
builderKey: "s8010",
cutCode: "8056",
valueType: "PRICE",
periodDays: 240
}]
}, "foreign-stock-load", [pending]);
return foreignStock.materialize(pending, response.rows[0]);
}
test("named load connects one correlated native foreign-stock restore batch end to end", () => {
assert.match(index, /<script src="named-foreign-stock-restore-workflow\.js"><\/script>[\s\S]*<script src="legacy-named-row-workflow\.js"><\/script>/u);
assert.match(app, /MbnNamedForeignStockRestoreWorkflow/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.classify/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.normalizeResponse/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.materialize/u);
assert.match(app, /case "named-foreign-stock-restore-results"/u);
assert.match(app, /case "named-foreign-stock-restore-error"/u);
assert.match(namedBridge, /await PostNamedForeignStockRestoreAsync\(/u);
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-results"/u);
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-error"/u);
});
test("Core uses one exact bound SELECT and native bridge never writes or calls playout", () => {
assert.match(core, /F_INPUT_NAME = :input_name/u);
assert.match(core, /F_FDTC = '1'/u);
assert.match(core, /F_NATC IN \('US', 'TW'\)/u);
assert.match(core, /WHERE ROWNUM <= 2/u);
assert.match(core, /new DataQueryParameter\("input_name"/u);
assert.doesNotMatch(core, /\bLIKE\b/iu);
assert.doesNotMatch(core, /\b(?:INSERT\s+INTO|UPDATE\s+[A-Z_]|DELETE\s+FROM|MERGE\s+INTO)\b/iu);
assert.doesNotMatch(bridge, /Tornado|TakeIn|PrepareAsync|ExecuteNonQuery/iu);
assert.match(bridge, /retryable = false/u);
assert.match(bridge, /자동 재시도하지 않습니다/u);
});
test("pending state blocks edits and timeout is terminal without an automatic retry", () => {
assert.match(app, /state\.namedPlaylist\.foreignStockRestorePending/u);
assert.match(app, /armNamedForeignStockRestoreTimeout/u);
assert.match(app, /setTimeout\(\(\) => \{[\s\S]*자동 재시도하지 않습니다\.[\s\S]*\}, 30000\)/u);
assert.match(app, /comparisonRestorePending \|\|[\s\S]*foreignStockRestorePending \|\|[\s\S]*foreignIndexCandleRestorePending/u);
assert.match(app, /pendingForeignStocks\.length === 0/u);
assert.match(app, /clearNamedForeignStockRestoreTimeout\(record\)/u);
assert.doesNotMatch(app, /retryNamedForeignStock|requestNamedForeignStockRestore/u);
});
test("verified identity and moving averages preserve the exact original seven fields for save", () => {
const entry = materializedEntry();
const raw = rawRow();
assert.ok(entry);
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
const selection = legacyRows.legacySelectionForEntry(entry);
assert.deepEqual(selection, {
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "240일",
dataCode: ""
});
assert.deepEqual(entry.operator.movingAverages, { ma5: true, ma20: false });
assert.deepEqual(namedPlaylists.toLegacySevenFields({ ...entry, selection }, "1/1"), [
"0", "해외종목", "NVIDIA", "캔들그래프", "240일", "1/1", ""
]);
assert.match(app, /해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.matchesRaw\(restoredForeignStock, rawRow\)/u);
assert.match(app, /item\.operator\?\.source === namedForeignStockRestoreWorkflow\.source\) return item/u);
});
test("canonical FOREIGN_STOCK remains on the existing workflow and cannot collide", () => {
assert.equal(foreignStock.classify({
...rawRow(true),
groupCode: "FOREIGN_STOCK",
graphicType: "MA5",
subtype: "PRICE",
dataCode: "NAS@NVDA|US|1"
}, {
id: "canonical-does-not-route",
fadeDuration: 6,
ma5: true,
ma20: false
}), null);
assert.match(app, /addOverseasNamedCandidates/u);
assert.match(app, /rawRow\.groupCode !== "FOREIGN_STOCK"/u);
});

View File

@@ -0,0 +1,251 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/named-foreign-stock-restore-workflow.js");
const actions = [
["1열판기본", "", "stock-current", "s5001", "5001", "CURRENT", null],
["캔들그래프", "5일", "stock-candle-5d", "s8010", "8061", "PRICE", 5],
["캔들그래프", "20일", "stock-candle-20d", "s8010", "8040", "PRICE", 20],
["캔들그래프", "60일", "stock-candle-60d", "s8010", "8046", "PRICE", 60],
["캔들그래프", "120일", "stock-candle-120d", "s8010", "8051", "PRICE", 120],
["캔들그래프", "240일", "stock-candle-240d", "s8010", "8056", "PRICE", 240]
];
function raw(graphicType = "1열판기본", subtype = "", itemIndex = 0, enabled = true) {
return {
itemIndex,
enabled,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType,
subtype,
pageText: "1/1",
dataCode: ""
};
}
function options(id = "foreign-stock-entry") {
return { id, fadeDuration: 6, ma5: true, ma20: false };
}
function outcome(pending, overrides = {}) {
return {
itemIndex: pending.itemIndex,
status: "resolved",
actionId: pending.actionId,
inputName: pending.inputName,
symbol: "NAS@NVDA",
nationCode: "US",
foreignDomesticCode: "1",
builderKey: pending.builderKey,
cutCode: pending.cutCode,
valueType: pending.valueType,
periodDays: pending.periodDays,
...overrides
};
}
function response(requestId, pendingRows, rows = pendingRows.map(value => outcome(value))) {
return {
requestId,
retrievedAt: "2026-07-12T12:34:56+09:00",
totalRowCount: rows.length,
rows
};
}
test("only the exact legacy overseas-stock grammar classifies all six UC5 actions", () => {
actions.forEach(([graphicType, subtype, actionId, builderKey, cutCode, valueType, periodDays], index) => {
const pending = workflow.classify(raw(graphicType, subtype, index, index !== 5), options(`foreign-${index}`));
assert.ok(pending);
assert.equal(workflow.isPending(pending), true);
assert.equal(pending.actionId, actionId);
assert.equal(pending.builderKey, builderKey);
assert.equal(pending.cutCode, cutCode);
assert.equal(pending.valueType, valueType);
assert.equal(pending.periodDays, periodDays);
assert.equal(pending.enabled, index !== 5);
assert.deepEqual(pending.movingAverages,
builderKey === "s8010" ? { ma5: true, ma20: false } : { ma5: false, ma20: false });
});
for (const invalid of [
{ ...raw(), groupCode: "FOREIGN_STOCK", dataCode: "NAS@NVDA|US|1" },
{ ...raw(), groupCode: " 해외종목" },
{ ...raw(), subject: " NVIDIA" },
{ ...raw(), graphicType: "CURRENT" },
{ ...raw("캔들그래프", "5일"), subtype: "1년" },
{ ...raw(), pageText: "1/2" },
{ ...raw(), dataCode: "NAS@NVDA" },
{ ...raw(), itemIndex: 1000 }
]) {
assert.equal(workflow.classify(invalid, options("invalid-row")), null);
}
assert.equal(workflow.classify(raw(), { ...options(), extra: true }), null);
});
test("a correlated unique Oracle identity materializes current and candle entries", () => {
for (const [graphicType, subtype, , builderKey, cutCode] of actions) {
const pending = workflow.classify(raw(graphicType, subtype, 7, false), options(`entry-${cutCode}`));
const normalized = workflow.normalizeResponse(
response("load-one", [pending]),
"load-one",
[pending]);
assert.ok(normalized);
const entry = workflow.materialize(pending, normalized.rows[0]);
assert.ok(entry);
assert.equal(workflow.isMaterializedEntry(entry), true);
assert.equal(entry.builderKey, builderKey);
assert.equal(entry.code, cutCode);
assert.equal(entry.enabled, false);
assert.equal(entry.symbol, "NAS@NVDA");
assert.equal(entry.nationCode, "US");
assert.equal(entry.fdtc, "1");
assert.equal(entry.operator.source, workflow.source);
assert.equal(entry.selection.groupCode, "FOREIGN_STOCK");
assert.equal(entry.selection.dataCode, "NAS@NVDA|US|1");
assert.equal(entry.selection.graphicType, builderKey === "s8010" ? "MA5" : "1열판기본");
}
});
test("a correlated Hangul-letter provider symbol remains a bound closed identity", () => {
const pending = workflow.classify(
raw("캔들그래프", "5일", 8),
options("hangul-symbol"));
const normalized = workflow.normalizeResponse(response(
"hangul-symbol-load",
[pending],
[outcome(pending, { symbol: "NAS@해외 종목1" })]),
"hangul-symbol-load",
[pending]);
assert.ok(normalized);
const entry = workflow.materialize(pending, normalized.rows[0]);
assert.ok(entry);
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
assert.ok(workflow.restorePlaylistEntry(entry));
});
test("safe supplementary input-name and letter symbols match the native scalar contract", () => {
const pending = workflow.classify(
{ ...raw("1열판기본", "", 10), subject: "회사🚀" },
options("supplementary-symbol"));
const normalized = workflow.normalizeResponse(response(
"supplementary-symbol-load",
[pending],
[outcome(pending, { inputName: "회사🚀", symbol: "NAS@𐐀1" })]),
"supplementary-symbol-load",
[pending]);
assert.ok(normalized);
const entry = workflow.materialize(pending, normalized.rows[0]);
assert.ok(entry);
assert.equal(entry.selection.dataCode, "NAS@𐐀1|US|1");
assert.ok(workflow.restorePlaylistEntry(entry));
});
test("batch count, order, request, action and identity correlation are strict", () => {
const first = workflow.classify(raw("캔들그래프", "5일", 4), options("first"));
const secondRaw = { ...raw("1열판기본", "", 9), subject: "TSMC" };
const second = workflow.classify(secondRaw, options("second"));
const rows = [outcome(first), outcome(second, { inputName: "TSMC", symbol: "TPE@2330", nationCode: "TW" })];
const exact = response("load-batch", [first, second], rows);
assert.ok(workflow.normalizeResponse(exact, "load-batch", [first, second]));
assert.equal(workflow.normalizeResponse(exact, "stale", [first, second]), null);
assert.equal(workflow.normalizeResponse({ ...exact, totalRowCount: 1 }, "load-batch", [first, second]), null);
assert.equal(workflow.normalizeResponse({ ...exact, rows: [...rows].reverse() }, "load-batch", [first, second]), null);
assert.equal(workflow.normalizeResponse({ ...exact, extra: true }, "load-batch", [first, second]), null);
for (const [key, value] of [
["actionId", "stock-current"],
["inputName", "TSMC"],
["nationCode", "KR"],
["foreignDomesticCode", "0"],
["builderKey", "s5001"],
["cutCode", "5001"],
["valueType", "VOLUME"],
["periodDays", 20]
]) {
const tampered = [{ ...rows[0], [key]: value }, rows[1]];
assert.equal(workflow.normalizeResponse({ ...exact, rows: tampered }, "load-batch", [first, second]), null, key);
}
});
test("terminal per-row failures, forged capability objects and retryable errors fail closed", () => {
const pending = workflow.classify(raw("캔들그래프", "120일"), options("failed"));
for (const failure of [
"INVALID_ROW", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
"DATABASE_DATA_INVALID", "RESTORE_FAILED"
]) {
const normalized = workflow.normalizeResponse(response("failed-load", [pending], [{
itemIndex: pending.itemIndex,
status: "failed",
failure
}]), "failed-load", [pending]);
assert.ok(normalized);
assert.equal(workflow.materialize(pending, normalized.rows[0]), null);
}
const normalized = workflow.normalizeResponse(response("resolved-load", [pending]), "resolved-load", [pending]);
assert.equal(workflow.materialize({ ...pending }, normalized.rows[0]), null);
assert.equal(workflow.materialize(pending, { ...normalized.rows[0] }), null);
const error = {
requestId: "error-load",
code: "PLAYOUT_PRIORITY",
message: "송출 우선 처리로 취소했습니다.",
retryable: false
};
assert.deepEqual(workflow.normalizeError(error, "error-load"), error);
assert.equal(workflow.normalizeError({ ...error, retryable: true }, "error-load"), null);
assert.equal(workflow.normalizeError(error, "stale"), null);
});
test("verified identity, moving averages and original seven-field signature round-trip together", () => {
const storedRaw = raw("캔들그래프", "240일", 3, false);
const pending = workflow.classify(storedRaw, options("round-trip"));
const normalized = workflow.normalizeResponse(response("round-trip-load", [pending]), "round-trip-load", [pending]);
const entry = workflow.materialize(pending, normalized.rows[0]);
const restored = workflow.restorePlaylistEntry(entry);
assert.ok(restored);
assert.deepEqual(restored.operator.movingAverages, { ma5: true, ma20: false });
assert.deepEqual(workflow.legacySelectionForEntry(restored), {
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "240일",
dataCode: ""
});
assert.equal(workflow.matchesRaw(restored, storedRaw), true);
assert.equal(workflow.matchesRaw(restored, { ...storedRaw, subtype: "120일" }), false);
for (const tampered of [
{ ...entry, code: "8040" },
{ ...entry, selection: { ...entry.selection, dataCode: "TPE@2330|TW|1" } },
{ ...entry, operator: { ...entry.operator, identity: { ...entry.operator.identity, symbol: "TPE@2330" } } },
{ ...entry, operator: { ...entry.operator, rawSelection: { ...entry.operator.rawSelection, subtype: "120일" } } },
{ ...entry, operator: { ...entry.operator, movingAverages: { ma5: false, ma20: false } } },
{ ...entry, extra: true }
]) {
assert.equal(workflow.restorePlaylistEntry(tampered), null);
}
});
test("enabled replacement preserves foreign-stock identity proof without mutating the frozen entry", () => {
const storedRaw = raw("1열판기본", "", 2, true);
const pending = workflow.classify(storedRaw, options("foreign-toggle"));
const normalized = workflow.normalizeResponse(response("foreign-toggle-load", [pending]),
"foreign-toggle-load", [pending]);
const entry = workflow.materialize(pending, normalized.rows[0]);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true);
assert.deepEqual(workflow.legacySelectionForEntry(replacement),
workflow.legacySelectionForEntry(entry));
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});

View File

@@ -0,0 +1,83 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..", "..");
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
const index = fs.readFileSync(path.join(root, "Web", "index.html"), "utf8");
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
function functionBody(name, nextName) {
const start = app.indexOf(`function ${name}(`);
const end = app.indexOf(`function ${nextName}(`, start + 1);
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
return app.slice(start, end);
}
test("named KRX restore is correlated from one native load through page preflight", () => {
assert.match(index,
/src="named-krx-theme-restore-workflow\.js"[\s\S]*src="named-nxt-theme-restore-workflow\.js"/u);
assert.match(namedBridge,
/await PostNamedKrxThemeRestoreAsync\([\s\S]*await PostNamedNxtThemeRestoreAsync\(/u);
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
assert.match(app, /krxThemeRestorePending/u);
assert.match(app, /case "named-krx-theme-restore-results"/u);
assert.match(app, /case "named-krx-theme-restore-error"/u);
const finalize = functionBody("finalizeNamedManualRestores", "handleNamedManualListResults");
assert.match(finalize, /state\.namedPlaylist\.krxThemeRestorePending/u);
assert.match(finalize, /prepareNamedPagePreflight/u);
});
test("generic theme restore cannot trust a historical KRX code before native verification", () => {
const addTheme = functionBody("addThemeNamedCandidates", "addOverseasNamedCandidates");
assert.match(addTheme,
/rawRow\.groupCode === "테마" \|\| rawRow\.groupCode === "테마_NXT"/u);
const load = functionBody("handleNamedPlaylistLoadResults", "failNamedComparisonRestore");
assert.match(load, /namedKrxThemeRestoreWorkflow\.classify/u);
assert.match(load, /pendingKrxThemes\.length === 0/u);
assert.match(load, /krxThemeRestorePending = pendingKrxThemes\.length/u);
const resolve = functionBody("resolveNamedPlaylistRawRow", "prepareNamedPagePreflight");
assert.match(resolve,
/rawRow\?\.groupCode === "테마" \|\| rawRow\?\.groupCode === "테마_NXT"[\s\S]*return null/u);
});
test("current KRX identity stays branded in memory and old code is retained only for save", () => {
const materialize = functionBody("materializeNamedRestoration", "namedPlaylistPrepareBlockers");
assert.match(materialize,
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(row\.entry\)/u);
const synchronize = functionBody("synchronizeThemeEntries", "clearOverseasTimeout");
assert.match(synchronize,
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(item\)[\s\S]*\? item/u);
const fields = functionBody("namedLegacyFieldsForEntry", "isTrustedNamedPlaylistEntry");
assert.match(fields, /namedKrxThemeRestoreWorkflow\.legacySelectionForEntry\(entry\)/u);
assert.match(fields, /namedKrxThemeRestoreWorkflow\.matchesRaw\(entry, rawRow\)/u);
assert.match(fields, /KRX 테마의 과거 code와 현재 송출 code/u);
});
test("KRX failures are terminal and never trigger an automatic retry or playout", () => {
const failed = functionBody("failNamedKrxThemeRestore", "handleNamedKrxThemeRestoreResults");
assert.match(failed, /krxThemeRestorePending = null/u);
assert.doesNotMatch(app, /retryNamedKrxTheme|requestNamedKrxThemeRestore/u);
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
assert.match(native, /retryable = false/u);
});
test("KRX success accounting and post-load cancellation close every correlated busy record", () => {
const results = functionBody("handleNamedKrxThemeRestoreResults", "handleNamedKrxThemeRestoreError");
assert.match(results,
/state\.playlist\[pending\.itemIndex\] = entry;\s*restoredCount \+= 1;/u);
const clear = functionBody("clearNamedPlaylistLoadState", "saveNamedPlaylistBackup");
assert.match(clear, /krxThemeRestorePending = null/u);
const busy = functionBody("namedPlaylistBusy", "renderNamedPlaylist");
assert.match(busy, /state\.namedPlaylist\.krxThemeRestorePending/u);
const loadError = functionBody("handleNamedPlaylistError", "normalizePlayoutState");
assert.match(loadError, /error\.operation !== "load"/u);
assert.match(loadError,
/state\.namedPlaylist\.krxThemeRestorePending\?\.requestId === error\.requestId[\s\S]*krxThemeRestorePending = null/u);
assert.match(loadError, /복원 배치는 자동 재시도하지 않습니다/u);
});

View File

@@ -0,0 +1,249 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflow = require("../../Web/named-krx-theme-restore-workflow.js");
function raw(overrides = {}) {
return {
itemIndex: 4,
enabled: true,
groupCode: "테마",
subject: "반도체",
graphicType: "5단 표그래프",
subtype: "테마-현재가(입력순)",
pageText: "1/9",
dataCode: "0123",
...overrides
};
}
function resolved(overrides = {}) {
return {
itemIndex: 4,
status: "resolved",
actionId: "five-row-current",
builderKey: "s5074",
cutCode: "5074",
pageSize: 5,
sort: "INPUT_ORDER",
themeTitle: "반도체",
storedThemeCode: "0123",
currentThemeCode: "87654321",
previewItemCount: 7,
previewIsTruncated: false,
codeWasRemapped: true,
...overrides
};
}
function response(row) {
return {
requestId: "load-1",
retrievedAt: "2026-07-12T12:59:00+09:00",
totalRowCount: 1,
rows: [row]
};
}
function normalize(sourceRaw = raw(), outcome = resolved()) {
const pending = workflow.classify(sourceRaw, { id: "db-4", fadeDuration: 6 });
const normalized = workflow.normalizeResponse(response(outcome), "load-1", [pending]);
return { pending, normalized, outcome: normalized?.rows[0] ?? null };
}
test("classify routes every exact KRX theme group row for native fail-closed verification", () => {
const pending = workflow.classify(raw({
subject: "",
graphicType: "unsupported",
subtype: "",
pageText: "",
dataCode: ""
}), { id: "db-4", fadeDuration: 6 });
assert.equal(workflow.isPending(pending), true);
assert.equal(pending.rawSelection.groupCode, "테마");
assert.equal(workflow.classify(raw({ groupCode: "테마_NXT" }), {
id: "db-4",
fadeDuration: 6
}), null);
});
test("normalizeResponse accepts one exact correlated current identity", () => {
const { normalized } = normalize();
assert.equal(normalized.rows[0].currentThemeCode, "87654321");
assert.equal(normalized.rows[0].previewItemCount, 7);
assert.deepEqual(workflow.bridgeContract, {
resultType: "named-krx-theme-restore-results",
errorType: "named-krx-theme-restore-error"
});
});
test("normalizeResponse enforces exact wire schema, raw action correlation and preview bounds", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
for (const invalid of [
{ ...resolved(), extra: true },
resolved({ storedThemeCode: "999" }),
resolved({ themeTitle: "다른제목" }),
resolved({ actionId: "six-row-current", builderKey: "s5077", cutCode: "5077", pageSize: 6 }),
resolved({ sort: "GAIN_ASC" }),
resolved({ previewItemCount: 0 }),
resolved({ previewItemCount: 239, previewIsTruncated: true }),
resolved({ previewItemCount: 241, previewIsTruncated: true }),
resolved({ codeWasRemapped: false })
]) {
assert.equal(workflow.normalizeResponse(response(invalid), "load-1", [pending]), null);
}
assert.equal(workflow.normalizeResponse({
requestId: "load-1",
retrievedAt: "2026-07-12T12:59:00+09:00",
totalRowCount: 2,
rows: [resolved({ itemIndex: 5 }), resolved({ itemIndex: 4 })]
}, "load-1", [
workflow.classify(raw({ itemIndex: 4 }), { id: "db-4", fadeDuration: 6 }),
workflow.classify(raw({ itemIndex: 5 }), { id: "db-5", fadeDuration: 6 })
]), null);
});
test("failed rows preserve a closed no-retry reason", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
for (const failure of [
"INVALID_ROW", "UNSUPPORTED_ACTION", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
"PREVIEW_EMPTY", "DATABASE_DATA_INVALID", "RESTORE_FAILED"
]) {
const result = workflow.normalizeResponse(response({
itemIndex: 4,
status: "failed",
failure
}), "load-1", [pending]);
assert.equal(result.rows[0].failure, failure);
}
assert.equal(workflow.normalizeError({
requestId: "load-1",
code: "DATABASE_UNAVAILABLE",
message: "조회 실패",
retryable: false
}, "load-1").retryable, false);
assert.equal(workflow.normalizeError({
requestId: "load-1",
code: "DATABASE_UNAVAILABLE",
message: "조회 실패",
retryable: true
}, "load-1"), null);
});
test("materialize uses only current code and retains the old raw identity for lossless save", () => {
const sourceRaw = raw({ enabled: false });
const { pending, outcome } = normalize(sourceRaw);
const entry = workflow.materialize(pending, outcome);
assert.equal(workflow.isMaterializedEntry(entry), true);
assert.equal(entry.enabled, false);
assert.equal(entry.selection.groupCode, "PAGED_THEME");
assert.equal(entry.selection.dataCode, "87654321");
assert.equal(entry.operator.theme.themeCode, "87654321");
assert.equal(entry.pagePreview.itemCount, 7);
assert.deepEqual(workflow.legacySelectionForEntry(entry), {
groupCode: "테마",
subject: "반도체",
graphicType: "5단 표그래프",
subtype: "테마-현재가(입력순)",
dataCode: "0123"
});
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
assert.equal(workflow.matchesRaw(entry, { ...sourceRaw, dataCode: "87654321" }), false);
});
test("expected price and the original no-token loss-rate branch remain closed", () => {
const sourceRaw = raw({
subtype: "테마-예상체결가",
pageText: "1/2"
});
const native = resolved({
actionId: "five-row-expected",
sort: "GAIN_ASC"
});
const { pending, outcome } = normalize(sourceRaw, native);
const entry = workflow.materialize(pending, outcome);
assert.equal(entry.operator.actionId, "five-row-expected");
assert.equal(entry.operator.sort, "GAIN_ASC");
assert.equal(entry.selection.subtype, "EXPECTED");
assert.equal(entry.selection.graphicType, "GAIN_ASC");
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
});
test("5-current, 5-expected, 6 and 12 row wire profiles retain the final sort fallback", () => {
const cases = [
["5단 표그래프", "테마-현재가", "five-row-current", "s5074", "5074", 5],
["5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", "5074", 5],
["6종목 현재가", "테마-현재가", "six-row-current", "s5077", "5077", 6],
["12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", "5088", 12]
];
for (const [graphicType, subtype, actionId, builderKey, cutCode, pageSize] of cases) {
const sourceRaw = raw({ graphicType, subtype });
const native = resolved({ actionId, builderKey, cutCode, pageSize, sort: "GAIN_ASC" });
const { pending, outcome } = normalize(sourceRaw, native);
const entry = workflow.materialize(pending, outcome);
assert.equal(entry.builderKey, builderKey);
assert.equal(entry.pageSize, pageSize);
assert.equal(entry.operator.sort, "GAIN_ASC");
}
});
test("240 visible rows with truncation is the only accepted truncated preview boundary", () => {
const { pending, outcome } = normalize(raw(), resolved({
previewItemCount: 240,
previewIsTruncated: true
}));
const entry = workflow.materialize(pending, outcome);
assert.equal(entry.operator.namedKrxRestore.previewItemCount, 240);
assert.equal(entry.operator.namedKrxRestore.previewIsTruncated, true);
assert.equal(entry.pagePreview.pageCount, 20);
assert.equal(entry.pagePreview.isTruncated, true);
});
test("materialization accepts only branded normalized responses", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
assert.equal(workflow.materialize(pending, resolved()), null);
const failed = workflow.normalizeResponse(response({
itemIndex: 4,
status: "failed",
failure: "PREVIEW_EMPTY"
}), "load-1", [pending]).rows[0];
assert.equal(workflow.materialize(pending, failed), null);
assert.equal(workflow.materialize({}, resolved()), null);
});
test("enabled replacement is immutable and preserves only the trusted KRX brand", () => {
const sourceRaw = raw({ enabled: true });
const { pending, outcome } = normalize(sourceRaw);
const entry = workflow.materialize(pending, outcome);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.deepEqual(workflow.legacySelectionForEntry(replacement),
workflow.legacySelectionForEntry(entry));
assert.equal(workflow.matchesRaw(replacement, { ...sourceRaw, enabled: false }), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
assert.equal(workflow.withEnabled(entry, "false"), null);
});
test("native partial emits the exact result and non-retry error contract", () => {
const root = path.resolve(__dirname, "..", "..");
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
assert.match(native, /PostMessage\("named-krx-theme-restore-error"/u);
assert.match(native, /identity\.StoredThemeCode/u);
assert.match(native, /identity\.CurrentThemeCode/u);
assert.match(native, /identity\.PreviewIsTruncated/u);
assert.match(native, /retryable = false/u);
assert.doesNotMatch(native, /retryable = true/u);
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
});

View File

@@ -132,6 +132,24 @@ test("dynamic NXT session changes exactly at local 13:00 without losing native p
assert.equal(workflow.matchesRaw(atThirteen, sourceRaw), true);
});
test("enabled replacement is immutable and retains NXT proof across session refresh", () => {
const sourceRaw = raw({ enabled: true });
const pending = workflow.classify(sourceRaw, { id: "db-toggle", fadeDuration: 6 });
const outcome = workflow.normalizeResponse(response(resolved({ itemIndex: 4 })),
"load-1", [pending]).rows[0];
const entry = workflow.materialize(pending, outcome);
const replacement = workflow.withEnabled(entry, false);
const refreshed = workflow.refreshDynamicSession(replacement, new Date(2026, 6, 12, 13, 0));
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.equal(workflow.matchesRaw(refreshed, { ...sourceRaw, enabled: false }), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});
test("materialization refuses a failed outcome and non-pending objects", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
assert.equal(workflow.materialize(pending, {

View File

@@ -209,6 +209,25 @@ test("stock responses preserve US/TW identity and reject duplicate input-name lo
})), null);
});
test("provider symbols accept Unicode letters but keep serialized delimiters closed", () => {
const unicode = workflow.normalizeStock({
inputName: "Provider Unicode Symbol",
symbol: "NAS@해외 종목1",
nationCode: "US",
fdtc: "1"
});
assert.ok(unicode);
const entry = workflow.createOverseasStockPlaylistEntry(
unicode,
"stock-candle-5d",
{ id: "unicode-symbol", fadeDuration: 6, ma5: true, ma20: false });
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
for (const symbol of ["NAS|NVDA", "NAS^NVDA", " NAS@NVDA", "NAS@NVDA ", "NAS!NVDA", "NAS\nNVDA", "NAS😀NVDA"]) {
assert.equal(workflow.normalizeStock({ ...unicode, symbol }), null, symbol);
}
});
test("bridge errors use exact payloads and reject stale request correlation", () => {
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
const industryError = {

View File

@@ -0,0 +1,122 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..", "..");
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
function functionSource(name, nextName) {
const start = app.indexOf(`function ${name}(`);
const end = app.indexOf(`function ${nextName}(`, start + 1);
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
return app.slice(start, end).trim();
}
function recreateWith(stubs) {
const source = functionSource(
"recreatePlaylistEntryWithEnabled",
"playlistEntryManualEditBlockReason");
return new Function(
"namedKrxThemeRestoreWorkflow",
"namedNxtThemeRestoreWorkflow",
"namedForeignStockRestoreWorkflow",
"foreignIndexCandleRestoreWorkflow",
"manualFinancialWorkflow",
"manualListsWorkflow",
`return (${source});`)(
stubs.krx,
stubs.nxt,
stubs.foreignStock,
stubs.foreignIndex,
stubs.manualFinancial,
stubs.manualLists);
}
function stubSet() {
const recreate = label => (entry, enabled) => entry.trusted === label
? Object.freeze({ ...entry, enabled, retainedBrand: label })
: null;
return {
krx: {
isMaterializedEntry: entry => entry.trusted === "krx",
withEnabled: recreate("krx")
},
nxt: {
isMaterializedEntry: entry => entry.trusted === "nxt",
withEnabled: recreate("nxt")
},
foreignStock: {
source: "legacy-named-foreign-stock-workflow",
withEnabled: recreate("foreign-stock")
},
foreignIndex: { withEnabled: recreate("foreign-index") },
manualFinancial: { withEnabled: recreate("manual-financial") },
manualLists: { withEnabled: recreate("manual-lists") }
};
}
test("a frozen ordinary entry toggles through an immutable replacement", () => {
const recreate = recreateWith(stubSet());
const entry = Object.freeze({
id: "ordinary",
code: "5001",
enabled: true,
operator: Object.freeze({ source: "legacy-stock-workflow" })
});
const replacement = recreate(entry, false);
assert.notEqual(replacement, entry);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(replacement.id, entry.id);
});
test("every capability-branded entry delegates to its trust-preserving workflow", () => {
const recreate = recreateWith(stubSet());
const cases = [
["krx", { source: "legacy-theme-workflow", namedKrxRestore: {} }],
["nxt", { source: "legacy-theme-workflow", namedNxtRestore: {} }],
["foreign-stock", { source: "legacy-named-foreign-stock-workflow" }],
["foreign-index", { source: "legacy-foreign-index-candle-workflow" }],
["manual-financial", { source: "manual-financial-workflow" }],
["manual-lists", { source: "legacy-manual-lists-workflow" }]
];
for (const [trusted, operator] of cases) {
const entry = Object.freeze({ id: trusted, enabled: true, trusted, operator });
const replacement = recreate(entry, false);
assert.equal(replacement.retainedBrand, trusted);
assert.equal(replacement.enabled, false);
assert.equal(entry.enabled, true);
}
});
test("a proof-shaped but unbranded named entry fails closed instead of becoming a plain clone", () => {
const recreate = recreateWith(stubSet());
const forgedKrx = Object.freeze({
id: "forged-krx",
enabled: true,
operator: Object.freeze({
source: "legacy-theme-workflow",
namedKrxRestore: Object.freeze({ schemaVersion: 1 })
})
});
assert.equal(recreate(forgedKrx, false), null);
});
test("checkbox, enabled-all and current-row paths never assign into playlist entries", () => {
const render = functionSource("renderPlaylist", "selectPlaylistRow");
const all = functionSource("toggleAllEntriesEnabled", "moveSelected");
const current = functionSource("toggleCurrentEntryEnabled", "savePlaylist");
assert.match(render, /replacePlaylistEntryEnabledAt\(index, checkbox\.checked\)/u);
assert.match(render, /renderPlaylist\(\)/u);
assert.match(all, /state\.playlist\.map\(item => recreatePlaylistEntryWithEnabled/u);
assert.match(all, /replacements\.some\(item => !item\)/u);
assert.match(current,
/replacePlaylistEntryEnabledAt\(state\.currentIndex, item\.enabled === false\)/u);
assert.doesNotMatch(`${render}\n${all}\n${current}`, /item\.enabled\s*=(?!=)/u);
});

View File

@@ -0,0 +1,180 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..", "..");
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
function functionSource(name, nextName) {
const start = app.indexOf(`function ${name}(`);
const end = app.indexOf(`function ${nextName}(`, start + 1);
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
return app.slice(start, end).trim();
}
function evaluateFunction(name, nextName, dependencies = {}) {
const names = Object.keys(dependencies);
return new Function(...names,
`return (${functionSource(name, nextName)});`)(...names.map(key => dependencies[key]));
}
function stateStub() {
return {
namedPlaylist: { guardByEntryId: new Map() },
manualFinancialRestore: { entries: new Map() }
};
}
test("every restored workflow boundary blocks direct scene and DB-row edits", () => {
const state = stateStub();
const blockReason = evaluateFunction(
"playlistEntryManualEditBlockReason",
"normalizeManualSceneSelection",
{ state });
const protectedEntries = [
["krx", "legacy-theme-workflow", { namedKrxRestore: {} }],
["nxt", "legacy-theme-workflow", { namedNxtRestore: {} }],
["foreign-stock", "legacy-named-foreign-stock-workflow", {}],
["foreign-index", "legacy-foreign-index-candle-workflow", {}],
["manual-financial", "manual-financial-workflow", {}],
["manual-lists", "legacy-manual-lists-workflow", {}]
];
for (const [id, source, proof] of protectedEntries) {
const entry = Object.freeze({
id,
enabled: true,
operator: Object.freeze({ source, ...proof })
});
assert.equal(blockReason(entry), "trusted-workflow-entry", id);
}
const guarded = Object.freeze({ id: "guarded", enabled: true });
state.namedPlaylist.guardByEntryId.set(guarded.id, Object.freeze({ trusted: false }));
assert.equal(blockReason(guarded), "named-playlist-entry");
const pending = Object.freeze({ id: "pending", enabled: true });
state.manualFinancialRestore.entries.set(pending.id, Object.freeze({ status: "load" }));
assert.equal(blockReason(pending), "manual-restore-entry");
});
test("a frozen ordinary entry receives scene values through an immutable replacement", () => {
const state = stateStub();
const blockReason = evaluateFunction(
"playlistEntryManualEditBlockReason",
"normalizeManualSceneSelection",
{ state });
const normalize = evaluateFunction(
"normalizeManualSceneSelection",
"recreatePlaylistEntryWithSceneSelection");
const sceneAliases = item => item.aliases;
const recreate = evaluateFunction(
"recreatePlaylistEntryWithSceneSelection",
"recreatePlaylistEntryWithLiveSelection",
{
playlistEntryManualEditBlockReason: blockReason,
normalizeManualSceneSelection: normalize,
sceneAliases
});
const entry = Object.freeze({
id: "ordinary",
code: "5001",
aliases: Object.freeze(["5001", "N5001"]),
enabled: true,
fadeDuration: 6,
selection: Object.freeze({
groupCode: "KOSPI",
subject: "OLD",
graphicType: "",
subtype: "",
dataCode: ""
})
});
const replacement = recreate(entry, {
groupCode: " NXT_KOSPI ",
subject: " 삼성전자 ",
graphicType: "CURRENT",
subtype: "",
dataCode: "005930"
}, "N5001", 4);
assert.notEqual(replacement, entry);
assert.equal(entry.code, "5001");
assert.equal(entry.fadeDuration, 6);
assert.equal(entry.selection.subject, "OLD");
assert.equal(replacement.code, "N5001");
assert.equal(replacement.fadeDuration, 4);
assert.equal(replacement.selection.groupCode, "NXT_KOSPI");
assert.equal(replacement.selection.subject, "삼성전자");
assert.equal(Object.isFrozen(replacement.selection), true);
assert.equal(recreate(entry, replacement.selection, "invalid", 4), null);
});
test("live DB selection also replaces only an unprotected ordinary entry", () => {
const state = stateStub();
const blockReason = evaluateFunction(
"playlistEntryManualEditBlockReason",
"normalizeManualSceneSelection",
{ state });
const normalize = evaluateFunction(
"normalizeManualSceneSelection",
"recreatePlaylistEntryWithSceneSelection");
const recreate = evaluateFunction(
"recreatePlaylistEntryWithLiveSelection",
"replacePlaylistEntryAt",
{
playlistEntryManualEditBlockReason: blockReason,
normalizeManualSceneSelection: normalize
});
const ordinary = Object.freeze({
id: "ordinary-live",
code: "5001",
enabled: true,
selection: Object.freeze({
groupCode: "KOSPI_STOCK",
subject: "OLD",
graphicType: "CURRENT",
subtype: "",
dataCode: "000001"
})
});
const selection = {
groupCode: "KOSPI_STOCK",
subject: "삼성전자",
graphicType: "CURRENT",
subtype: "",
dataCode: "005930"
};
const replacement = recreate(ordinary, selection);
assert.notEqual(replacement, ordinary);
assert.equal(ordinary.selection.subject, "OLD");
assert.equal(replacement.selection.subject, "삼성전자");
assert.equal(replacement.subject, "삼성전자");
assert.equal(replacement.dataCode, "005930");
const protectedEntry = Object.freeze({
...ordinary,
id: "protected-live",
operator: Object.freeze({ source: "legacy-stock-workflow" })
});
assert.equal(recreate(protectedEntry, selection), null);
});
test("both edit handlers keep snapshot locking and contain no playlist-entry assignment", () => {
const render = functionSource("renderSceneSelectionForm", "applySceneSelection");
const scene = functionSource("applySceneSelection", "applyCutAliasPreset");
const live = functionSource("applyLiveRowToCurrentCut", "renderLiveTables");
assert.match(render, /playlistEntryManualEditBlockReason\(item\)/u);
assert.match(scene, /if \(!requirePlaylistEditable\(\)\) return;/u);
assert.match(scene, /playlistEntryManualEditBlockReason\(item\)/u);
assert.match(scene, /recreatePlaylistEntryWithSceneSelection/u);
assert.match(scene, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
assert.match(live, /if \(!requirePlaylistEditable\(\)\) return;/u);
assert.match(live, /playlistEntryManualEditBlockReason\(item\)/u);
assert.match(live, /recreatePlaylistEntryWithLiveSelection/u);
assert.match(live, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
assert.doesNotMatch(app,
/\bitem\.[A-Za-z_$][A-Za-z0-9_$]*\s*=(?!=)/u);
});

View File

@@ -0,0 +1,571 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
public sealed record NamedManualRestoreReadOnlyAuditSummary(
int CandidateCount,
int FreshMaterializedCount,
int BlockedCount,
LegacyManualOperatorDataRuntimeState RuntimeState,
bool OperatorFilesStable,
IReadOnlyDictionary<LegacyNamedManualRestoreKind, int> KindCounts,
IReadOnlyDictionary<string, int> BlockReasons);
/// <summary>
/// Production-data audit for the manual subset of PLAY_LIST. Every database
/// command is a SELECT through an IDataQueryExecutor. Operator files are opened
/// only through their production read paths; the before/after snapshot proves
/// that no file, marker, or intent entry was created or changed.
/// </summary>
public static class NamedManualRestoreReadOnlyAudit
{
private const string QueryName = "AUDIT_NAMED_MANUAL_RESTORE_ROWS";
private const int MaximumRows = 20_000;
private const int BrowserStockSearchLimit = 200;
private const string Sql = """
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
FROM (
SELECT TRIM(d.DC_CODE) DC_CODE,
d.DC_TITLE DC_TITLE,
p.LIST_TEXT LIST_TEXT,
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
FROM DC_LIST d
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
)
WHERE ROWNUM <= :row_limit
""";
public static async Task<NamedManualRestoreReadOnlyAuditSummary> RunAsync(
IDataQueryExecutor executor,
string trustedDirectory,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(executor);
if (string.IsNullOrWhiteSpace(trustedDirectory) ||
!Path.IsPathFullyQualified(trustedDirectory))
{
throw InvalidBoundary("trusted directory composition");
}
cancellationToken.ThrowIfCancellationRequested();
var candidates = await LoadCandidatesAsync(executor, cancellationToken)
.ConfigureAwait(false);
var runtime = LegacyManualOperatorDataRuntimeGuard.Inspect(trustedDirectory);
if (!runtime.IsReady)
{
throw InvalidBoundary("trusted runtime state");
}
var before = SnapshotDirectory(trustedDirectory);
var results = new List<RestoreResult>(candidates.Count);
var financialService = new LegacyManualFinancialScreenService(executor);
var stockSearchService = new LegacyStockSearchService(executor);
var netSellSource = new S5025TrustedManualFileDataSource(trustedDirectory);
TrustedSourceEvidence trustedSources;
using (var viSource = new ViTrustedManualFileStore(trustedDirectory))
{
trustedSources = await ReadTrustedSourcesAsync(
netSellSource,
viSource,
cancellationToken)
.ConfigureAwait(false);
foreach (var candidate in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
results.Add(candidate.Kind switch
{
LegacyNamedManualRestoreKind.Financial =>
await ResolveFinancialAsync(
candidate,
financialService,
stockSearchService,
cancellationToken)
.ConfigureAwait(false),
LegacyNamedManualRestoreKind.NetSell =>
await ResolveNetSellAsync(candidate, netSellSource, cancellationToken)
.ConfigureAwait(false),
LegacyNamedManualRestoreKind.Vi =>
await ResolveViAsync(candidate, viSource, cancellationToken)
.ConfigureAwait(false),
_ => throw InvalidBoundary("manual candidate kind")
});
}
}
var after = SnapshotDirectory(trustedDirectory);
var filesStable = before.Matches(after);
if (!filesStable)
{
throw InvalidBoundary("operator file immutability");
}
var kindCounts = candidates
.GroupBy(static candidate => candidate.Kind)
.ToDictionary(static group => group.Key, static group => group.Count());
var screenCounts = candidates
.Where(static candidate =>
candidate.Kind == LegacyNamedManualRestoreKind.Financial)
.GroupBy(static candidate => candidate.FinancialScreen!.Value)
.OrderBy(static group => group.Key)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var audienceCounts = candidates
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.NetSell)
.GroupBy(static candidate => candidate.Audience!.Value)
.OrderBy(static group => group.Key)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var viRows = candidates
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.Vi)
.ToArray();
var financialOutcomes = candidates
.Select((candidate, index) => (Candidate: candidate, Result: results[index]))
.Where(static pair =>
pair.Candidate.Kind == LegacyNamedManualRestoreKind.Financial)
.GroupBy(static pair => pair.Candidate.FinancialScreen!.Value)
.OrderBy(static group => group.Key)
.Select(group =>
{
var freshCount = group.Count(static pair => pair.Result.IsFresh);
var blocked = group
.Where(static pair => !pair.Result.IsFresh)
.GroupBy(static pair => pair.Result.Reason, StringComparer.Ordinal)
.OrderBy(static reason => reason.Key, StringComparer.Ordinal)
.Select(reason =>
$"{reason.Key}:{reason.Count().ToString(CultureInfo.InvariantCulture)}");
return $"{group.Key}=fresh:{freshCount.ToString(CultureInfo.InvariantCulture)}" +
(freshCount == group.Count()
? string.Empty
: $"/blocked:{string.Join('+', blocked)}");
});
var blockReasons = results
.Where(static result => !result.IsFresh)
.GroupBy(static result => result.Reason, StringComparer.Ordinal)
.ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
var fresh = results.Count(static result => result.IsFresh);
Console.WriteLine(
"PLIST_MANUAL_GRAMMAR_AUDIT: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"financial={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Financial).ToString(CultureInfo.InvariantCulture)} " +
$"netSell={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.NetSell).ToString(CultureInfo.InvariantCulture)} " +
$"vi={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Vi).ToString(CultureInfo.InvariantCulture)} " +
$"historical={candidates.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
$"canonical={candidates.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine("PLIST_MANUAL_FINANCIAL_SCREENS: " + string.Join(',', screenCounts));
Console.WriteLine(
"PLIST_MANUAL_FINANCIAL_OUTCOMES: " + string.Join(',', financialOutcomes));
Console.WriteLine("PLIST_MANUAL_NET_SELL_AUDIENCES: " + string.Join(',', audienceCounts));
Console.WriteLine(
"PLIST_MANUAL_VI_GRAMMAR: " +
$"historical={viRows.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
$"versioned={viRows.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"PLIST_MANUAL_TRUSTED_SOURCES: " +
$"netSellAudiencesReady={trustedSources.NetSellAudiencesReady.ToString(CultureInfo.InvariantCulture)} " +
$"viSnapshotReady={trustedSources.ViSnapshotReady.ToString().ToLowerInvariant()} " +
$"viItems={trustedSources.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
$"viPages={trustedSources.ViPageCount.ToString(CultureInfo.InvariantCulture)} " +
"openedHandle=true");
Console.WriteLine(
"PLIST_MANUAL_FRESH_PROOF: " +
$"freshMaterialized={fresh.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(results.Count - fresh).ToString(CultureInfo.InvariantCulture)} " +
"graphE=double-read-row-version+fresh-stock-identity " +
"netSell=opened-handle-production-parser " +
"vi=opened-handle-version+identity+page-check " +
"sceneDataAndAssetsNotAsserted=true");
Console.WriteLine(
"PLIST_MANUAL_BLOCK_REASONS: " +
(blockReasons.Count == 0
? "none"
: string.Join(
',',
blockReasons.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(pair =>
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
Console.WriteLine(
"PLIST_MANUAL_LOCAL_READ_AUDIT: " +
$"runtimeState={runtime.State} " +
$"entries={before.Entries.Count.ToString(CultureInfo.InvariantCulture)} " +
"filesStable=true writesAttempted=false markerOrIntentCreated=false");
return new NamedManualRestoreReadOnlyAuditSummary(
candidates.Count,
fresh,
results.Count - fresh,
runtime.State,
filesStable,
kindCounts,
blockReasons);
}
public static string DefaultTrustedDirectory()
{
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(localAppData))
{
throw InvalidBoundary("local application-data root");
}
return Path.GetFullPath(Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"OperatorData"));
}
private static async Task<IReadOnlyList<LegacyNamedManualRestoreCandidate>> LoadCandidatesAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
Sql,
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await executor.ExecuteAsync(
DataSourceKind.Oracle,
QueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
if (table is null ||
table.Columns.Count != columns.Length ||
table.Rows.Count is < 1 or > MaximumRows)
{
throw InvalidBoundary("PList query schema or row bound");
}
for (var index = 0; index < columns.Length; index++)
{
if (table.Columns[index].ColumnName != columns[index] ||
table.Columns[index].DataType != typeof(string))
{
throw InvalidBoundary("PList query schema");
}
}
var candidates = new List<LegacyNamedManualRestoreCandidate>();
var routedManualCount = 0;
foreach (DataRow dataRow in table.Rows)
{
var parsed = LegacyPListReadAuditParser.Parse(
dataRow[0] as string,
dataRow[1] as string,
dataRow[3] as string,
dataRow[2] as string);
if (!parsed.IsSuccess)
{
throw InvalidBoundary("PList row parse");
}
var row = parsed.Row!;
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
{
routedManualCount++;
}
var candidate = LegacyNamedManualRestoreClassifier.Classify(row);
if (candidate is not null)
{
candidates.Add(candidate);
}
else if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
{
throw InvalidBoundary("manual route closed grammar");
}
}
if (candidates.Count != routedManualCount)
{
throw InvalidBoundary("manual route/classifier agreement");
}
return candidates.AsReadOnly();
}
private static async Task<RestoreResult> ResolveFinancialAsync(
LegacyNamedManualRestoreCandidate candidate,
LegacyManualFinancialScreenService financialService,
LegacyStockSearchService stockSearchService,
CancellationToken cancellationToken)
{
try
{
var identity = new ManualFinancialIdentity(
candidate.FinancialScreen!.Value,
candidate.StockName!);
var firstRead = await financialService.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
var query = LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate);
var stockSearch = await stockSearchService.SearchAsync(
query,
BrowserStockSearchLimit,
cancellationToken)
.ConfigureAwait(false);
var secondRead = await financialService.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
var failure = LegacyNamedManualFreshProof.EvaluateFinancial(
candidate,
firstRead,
secondRead,
stockSearch);
return RestoreResult.From(failure);
}
catch (ManualFinancialNotFoundException)
{
return RestoreResult.Blocked("GRAPH_E_NOT_FOUND");
}
catch (ManualFinancialAmbiguousIdentityException)
{
return RestoreResult.Blocked("GRAPH_E_AMBIGUOUS");
}
catch (ManualFinancialDataException)
{
return RestoreResult.Blocked("GRAPH_E_DATA_INVALID");
}
catch (StockSearchDataException)
{
return RestoreResult.Blocked("STOCK_MASTER_DATA_INVALID");
}
catch (ArgumentException)
{
return RestoreResult.Blocked("GRAPH_E_IDENTITY_INVALID");
}
}
private static async Task<RestoreResult> ResolveNetSellAsync(
LegacyNamedManualRestoreCandidate candidate,
S5025TrustedManualFileDataSource source,
CancellationToken cancellationToken)
{
try
{
var rows = await source.ReadAsync(candidate.Audience!.Value, cancellationToken)
.ConfigureAwait(false);
return RestoreResult.From(
LegacyNamedManualFreshProof.EvaluateNetSell(candidate, rows));
}
catch (S5025TrustedManualDataException)
{
return RestoreResult.Blocked("NET_SELL_TRUSTED_READ_INVALID");
}
}
private static async Task<TrustedSourceEvidence> ReadTrustedSourcesAsync(
S5025TrustedManualFileDataSource netSellSource,
ViTrustedManualFileStore viSource,
CancellationToken cancellationToken)
{
var audiencesReady = 0;
foreach (var audience in Enum.GetValues<S5025ManualAudience>())
{
var rows = await netSellSource.ReadAsync(audience, cancellationToken)
.ConfigureAwait(false);
if (rows.Count != 5)
{
throw InvalidBoundary("net-sell production parser");
}
audiencesReady++;
}
var vi = await viSource.ReadStockNameSnapshotAsync(cancellationToken)
.ConfigureAwait(false);
if (!TrustedViManualReference.IsRowVersion(vi.RowVersion) ||
vi.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount)
{
throw InvalidBoundary("VI production parser");
}
var canonical = new LegacySceneSelection(
"PAGED_VI",
TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
vi.RowVersion);
var secondRead = await TrustedViManualReference.ResolveAsync(
canonical,
pageIndexZeroBased: 0,
viSource,
cancellationToken)
.ConfigureAwait(false);
if (!secondRead.StockNames.SequenceEqual(vi.StockNames, StringComparer.Ordinal))
{
throw InvalidBoundary("VI stable second read");
}
return new TrustedSourceEvidence(
audiencesReady,
ViSnapshotReady: true,
vi.StockNames.Count,
checked((vi.StockNames.Count + 4) / 5));
}
private static async Task<RestoreResult> ResolveViAsync(
LegacyNamedManualRestoreCandidate candidate,
ViTrustedManualFileStore source,
CancellationToken cancellationToken)
{
try
{
var snapshot = await source.ReadStockNameSnapshotAsync(cancellationToken)
.ConfigureAwait(false);
var failure = LegacyNamedManualFreshProof.EvaluateVi(candidate, snapshot);
if (failure != LegacyNamedManualFreshProofFailure.None)
{
return RestoreResult.From(failure);
}
var canonical = new LegacySceneSelection(
"PAGED_VI",
TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
snapshot.RowVersion);
var resolved = await TrustedViManualReference.ResolveAsync(
canonical,
pageIndexZeroBased: 0,
source,
cancellationToken)
.ConfigureAwait(false);
return resolved.PageNumberOneBased == 1 &&
resolved.StockNames.SequenceEqual(
snapshot.StockNames,
StringComparer.Ordinal)
? RestoreResult.Fresh()
: RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
}
catch (ViTrustedManualDataException)
{
return RestoreResult.Blocked("VI_TRUSTED_READ_INVALID");
}
catch (LegacySceneDataException)
{
return RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
}
}
private static DirectorySnapshot SnapshotDirectory(string trustedDirectory)
{
try
{
var directory = new DirectoryInfo(trustedDirectory);
if (!directory.Exists ||
(directory.Attributes & (FileAttributes.ReparsePoint | FileAttributes.Directory)) !=
FileAttributes.Directory)
{
throw InvalidBoundary("operator directory");
}
var entries = new List<FileSnapshot>();
foreach (var path in Directory.EnumerateFileSystemEntries(trustedDirectory)
.OrderBy(static value => Path.GetFileName(value), StringComparer.Ordinal))
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw InvalidBoundary("operator directory entry");
}
var before = new FileInfo(path);
byte[] digest;
long length;
using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4_096,
FileOptions.SequentialScan))
{
length = stream.Length;
digest = SHA256.HashData(stream);
if (stream.Length != length)
{
throw InvalidBoundary("operator file stable read");
}
}
var after = new FileInfo(path);
if (after.Length != length ||
before.LastWriteTimeUtc != after.LastWriteTimeUtc ||
before.CreationTimeUtc != after.CreationTimeUtc)
{
throw InvalidBoundary("operator file stable read");
}
entries.Add(new FileSnapshot(
Path.GetFileName(path),
length,
after.CreationTimeUtc,
after.LastWriteTimeUtc,
Convert.ToHexString(digest)));
}
directory.Refresh();
return new DirectorySnapshot(directory.LastWriteTimeUtc, entries.AsReadOnly());
}
catch (InvalidDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
ArgumentException or
NotSupportedException)
{
throw InvalidBoundary("operator file snapshot");
}
}
private static InvalidDataException InvalidBoundary(string detail) =>
new($"The read-only named manual restore audit failed its {detail} boundary. " +
"No source values were printed.");
private sealed record RestoreResult(bool IsFresh, string Reason)
{
public static RestoreResult Fresh() => new(true, "NONE");
public static RestoreResult Blocked(string reason) => new(false, reason);
public static RestoreResult From(LegacyNamedManualFreshProofFailure failure) =>
failure == LegacyNamedManualFreshProofFailure.None
? Fresh()
: Blocked(failure.ToString().ToUpperInvariant());
}
private sealed record TrustedSourceEvidence(
int NetSellAudiencesReady,
bool ViSnapshotReady,
int ViItemCount,
int ViPageCount);
private sealed record FileSnapshot(
string Name,
long Length,
DateTime CreationTimeUtc,
DateTime LastWriteTimeUtc,
string Sha256);
private sealed record DirectorySnapshot(
DateTime LastWriteTimeUtc,
IReadOnlyList<FileSnapshot> Entries)
{
public bool Matches(DirectorySnapshot other) =>
other is not null &&
LastWriteTimeUtc == other.LastWriteTimeUtc &&
Entries.SequenceEqual(other.Entries);
}
}

View File

@@ -0,0 +1,802 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using MMoneyCoderSharp.Data;
internal static class NamedPlaylistReadOnlyDbAudit
{
private const string QueryName = "AUDIT_ACTIVE_NAMED_PLAYLIST_ROWS";
private const int MaximumRows = 20_000;
private const string Sql = """
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
FROM (
SELECT TRIM(d.DC_CODE) DC_CODE,
d.DC_TITLE DC_TITLE,
p.LIST_TEXT LIST_TEXT,
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
FROM DC_LIST d
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
)
WHERE ROWNUM <= :row_limit
""";
internal static async Task RunAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executor);
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine("PLIST_READ_AUDIT: BEGIN");
var rawRows = await LoadRawRowsAsync(executor, cancellationToken).ConfigureAwait(false);
Console.WriteLine(
$"PLIST_READ_AUDIT_RAW: rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)}");
var persistence = new LegacyNamedPlaylistPersistenceService(executor);
var definitions = await persistence
.ListAsync(LegacyNamedPlaylistPersistenceService.MaximumDefinitions, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (definitions.IsTruncated || definitions.Items.Count == 0)
{
throw InvalidBoundary("definition cardinality");
}
Console.WriteLine(
$"PLIST_READ_AUDIT_DEFINITIONS: count={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)}");
var rawByProgram = rawRows
.GroupBy(static row => row.ProgramCode, StringComparer.Ordinal)
.ToDictionary(
static group => group.Key,
static group => (IReadOnlyList<LegacyPListAuditRow>)group.ToArray(),
StringComparer.Ordinal);
if (rawByProgram.Count != definitions.Items.Count ||
rawRows.Count != rawByProgram.Values.Sum(static rows => rows.Count))
{
throw InvalidBoundary("active definition join");
}
var loadedRows = 0;
foreach (var definition in definitions.Items)
{
cancellationToken.ThrowIfCancellationRequested();
if (!rawByProgram.TryGetValue(definition.ProgramCode, out var programRows) ||
!LegacyPListReadAuditEvidence.HasContiguousIndexes(programRows) ||
programRows.Any(row =>
!string.Equals(row.ProgramTitle, definition.Title, StringComparison.Ordinal)))
{
throw InvalidBoundary("program identity or item order");
}
var loaded = await persistence
.LoadAsync(definition.ProgramCode, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (loaded.Definition != definition || loaded.Items.Count != programRows.Count)
{
throw InvalidBoundary("typed load cardinality");
}
for (var index = 0; index < programRows.Count; index++)
{
if (!LegacyPListReadAuditEvidence.MatchesPersistenceResult(
programRows[index],
loaded.Items[index]))
{
throw InvalidBoundary("lossless typed load comparison");
}
}
loadedRows += loaded.Items.Count;
}
if (loadedRows != rawRows.Count)
{
throw InvalidBoundary("typed load total");
}
Console.WriteLine(
$"PLIST_READ_AUDIT_TYPED: rows={loadedRows.ToString(CultureInfo.InvariantCulture)}");
IReadOnlySet<SelectionKey> fixedSignatures;
try
{
fixedSignatures = LoadFixedSignatures();
}
catch (InvalidDataException exception) when (
exception.Message.StartsWith(
"The read-only PList audit failed its ",
StringComparison.Ordinal))
{
Console.WriteLine("PLIST_FIXED_MAPPER_BOUNDARY: " + exception.Message);
throw;
}
Console.WriteLine(
"PLIST_FIXED_MAPPER_AUDIT: " +
$"sourceActions=328 unavailable=6 " +
$"uniqueSignatures={fixedSignatures.Count.ToString(CultureInfo.InvariantCulture)} " +
"sourceHashes=verified");
var routedRows = rawRows
.Select(row => new RoutedRow(row, ClassifyRoute(row, fixedSignatures)))
.ToArray();
var routeCounts = routedRows
.GroupBy(static routed => routed.Route)
.ToDictionary(static group => group.Key, static group => group.Count());
MaterializationSummary outcomes;
try
{
outcomes = await ResolveMaterializationAsync(
executor,
routedRows,
routeCounts,
cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidDataException exception) when (
exception.Message.StartsWith(
"The read-only PList audit failed its ",
StringComparison.Ordinal))
{
Console.WriteLine("PLIST_MATERIALIZATION_BOUNDARY: " + exception.Message);
throw;
}
if (outcomes.Playable + outcomes.Blocked != rawRows.Count)
{
throw InvalidBoundary("materialization total");
}
Console.WriteLine(
"PLIST_READ_AUDIT: " +
$"definitions={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
$"rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
$"parsed={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
$"lossless={rawRows.Count(static row => row.IsLossless).ToString(CultureInfo.InvariantCulture)} " +
"replacement=0 mojibake=0 nonContiguousPrograms=0 typedMismatch=0");
Console.WriteLine(
"PLIST_ROUTE_AUDIT: " + string.Join(
",",
Enum.GetValues<LegacyPListAuditRoute>().Select(route =>
$"{route}={routeCounts.GetValueOrDefault(route).ToString(CultureInfo.InvariantCulture)}")));
Console.WriteLine(
"PLIST_MATERIALIZATION_AUDIT: " +
$"unique={outcomes.Unique.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={outcomes.Playable.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={outcomes.Blocked.ToString(CultureInfo.InvariantCulture)} " +
"sceneDataAndAssetsNotAsserted=true");
Console.WriteLine(
"PLIST_BLOCK_REASONS: " +
(outcomes.BlockReasons.Count == 0
? "none"
: string.Join(
",",
outcomes.BlockReasons
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(pair =>
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
}
private static async Task<IReadOnlyList<LegacyPListAuditRow>> LoadRawRowsAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
Sql,
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await executor
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
if (table is null || table.Columns.Count != columns.Length ||
table.Rows.Count is < 1 or > MaximumRows)
{
throw InvalidBoundary("query schema or row bound");
}
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 InvalidBoundary("query schema");
}
}
var rows = new List<LegacyPListAuditRow>(table.Rows.Count);
foreach (DataRow dataRow in table.Rows)
{
var result = LegacyPListReadAuditParser.Parse(
dataRow[0] as string,
dataRow[1] as string,
dataRow[3] as string,
dataRow[2] as string);
if (!result.IsSuccess)
{
// The failure enum contains no source text. Never echo raw
// operator data or identifiers from this audit boundary.
throw InvalidBoundary($"row parse ({result.Failure})");
}
rows.Add(result.Row!);
}
return rows.AsReadOnly();
}
private static async Task<MaterializationSummary> ResolveMaterializationAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> routedRows,
IReadOnlyDictionary<LegacyPListAuditRoute, int> routeCounts,
CancellationToken cancellationToken)
{
var summary = new MaterializationSummary();
// These closed classifiers create one immutable selection candidate.
// This audit does not call the scene data loaders or assert asset and
// PREPARE readiness. Industry fixed rows are independently constrained
// to two markets and three actions by LegacyPListReadAuditEvidence.
summary.AddPlayable(routeCounts.GetValueOrDefault(
LegacyPListAuditRoute.ExistingClosedMapper));
summary.AddPlayable(routeCounts.GetValueOrDefault(
LegacyPListAuditRoute.IndustryFixedClosedMapper));
var comparisonRows = RowsFor(
routedRows,
LegacyPListAuditRoute.ComparisonFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToComparisonRow(row) with { ItemIndex = index });
if (comparisonRows.Count > 0)
{
var resolved = await new LegacyNamedComparisonRestoreService(executor)
.ResolveAsync(comparisonRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("COMPARISON_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
var foreignIndexRows = RowsFor(
routedRows,
LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToForeignIndexCandleRow(row) with { ItemIndex = index });
if (foreignIndexRows.Count > 0)
{
var resolved = await new LegacyForeignIndexCandleRestoreService(executor)
.ResolveAsync(foreignIndexRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("FOREIGN_INDEX_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
var nxtRows = RowsFor(
routedRows,
LegacyPListAuditRoute.NxtThemeFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToNxtThemeRow(row) with { ItemIndex = index });
if (nxtRows.Count > 0)
{
var resolved = await new LegacyNamedNxtThemeRestoreService(executor)
.ResolveAsync(nxtRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("NXT_THEME_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
await ResolveKrxThemesAsync(executor, routedRows, summary, cancellationToken)
.ConfigureAwait(false);
await ResolveForeignStocksAsync(executor, routedRows, summary, cancellationToken)
.ConfigureAwait(false);
// GraphE/FSell/VI require a fresh row-version or opened-handle trusted
// file read. The DB-only audit intentionally does not claim those
// external proofs and keeps every such row blocked with one reason.
summary.AddBlocked(
"MANUAL_TRUSTED_SOURCE_FRESH_READ_REQUIRED",
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.ManualFreshMaterialization));
summary.AddBlocked(
"NO_UNIQUE_CLOSED_MAPPER",
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.Unmapped));
return summary;
}
private static async Task ResolveKrxThemesAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> rows,
MaterializationSummary summary,
CancellationToken cancellationToken)
{
var candidates = RowsFor(
rows,
LegacyPListAuditRoute.KrxThemeFreshIdentity,
static (row, index) => new LegacyNamedKrxThemeRestoreRow(
index,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode));
if (candidates.Count == 0)
{
return;
}
var resolved = await new LegacyNamedKrxThemeRestoreService(executor)
.ResolveAsync(candidates, cancellationToken)
.ConfigureAwait(false);
var mapped = 0;
var remapped = 0;
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
mapped++;
if (row.Identity!.CodeWasRemapped)
{
remapped++;
}
}
else
{
summary.AddBlocked("KRX_THEME_" + KrxThemeFailureToken(row.Failure!.Value));
}
}
Console.WriteLine(
"PLIST_KRX_THEME_RESTORE: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
$"codeRemapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)}");
}
private static async Task ResolveForeignStocksAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> rows,
MaterializationSummary summary,
CancellationToken cancellationToken)
{
var candidates = RowsFor(
rows,
LegacyPListAuditRoute.ForeignStockFreshIdentity,
static (row, index) => new LegacyNamedForeignStockRestoreRow(
index,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode));
if (candidates.Count == 0)
{
return;
}
var resolved = await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync(candidates, cancellationToken)
.ConfigureAwait(false);
var mapped = 0;
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
mapped++;
}
else
{
summary.AddBlocked(
"FOREIGN_STOCK_" + ForeignStockFailureToken(row.Failure!.Value));
}
}
var actions = resolved.Rows
.Where(static row => row.IsResolved)
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var databaseIssues = resolved.Rows
.Where(static row =>
row.Failure == LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid)
.GroupBy(static row => row.DatabaseIssue?.ToString() ?? "UNCLASSIFIED", StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var slashSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Contains('/'));
var nonAsciiSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Any(static character => !char.IsAscii(character)));
var hangulSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit));
var internalSpaceSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Contains(' '));
var hangulActions = resolved.Rows
.Where(static row =>
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit))
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"PLIST_FOREIGN_STOCK_RESTORE: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)} " +
$"slashSymbols={slashSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"nonAsciiSymbols={nonAsciiSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"hangulSymbols={hangulSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"internalSpaceSymbols={internalSpaceSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"actions={string.Join(',', actions)} " +
$"hangulActions={string.Join(',', hangulActions)} " +
$"databaseIssues={(databaseIssues.Any() ? string.Join(',', databaseIssues) : "none")} " +
"rawValuesPrinted=false");
}
private static string KrxThemeFailureToken(LegacyNamedKrxThemeRestoreFailure failure) =>
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",
_ => throw new ArgumentOutOfRangeException(nameof(failure))
};
private static string ForeignStockFailureToken(
LegacyNamedForeignStockRestoreFailure failure) =>
failure switch
{
LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
_ => throw new ArgumentOutOfRangeException(nameof(failure))
};
private static IReadOnlyList<T> RowsFor<T>(
IReadOnlyList<RoutedRow> rows,
LegacyPListAuditRoute route,
Func<LegacyPListAuditRow, int, T> convert) =>
rows
.Where(row => row.Route == route)
.Select(static row => row.Row)
.Select(convert)
.ToArray();
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsHangulCodeUnit(char value) =>
value is >= '\uAC00' and <= '\uD7AF' or
>= '\u1100' and <= '\u11FF' or
>= '\u3130' and <= '\u318F';
private static InvalidDataException InvalidBoundary(string detail) =>
new($"The read-only PList audit failed its {detail} boundary. No source values were printed.");
private static LegacyPListAuditRoute ClassifyRoute(
LegacyPListAuditRow row,
IReadOnlySet<SelectionKey> fixedSignatures)
{
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
if (route != LegacyPListAuditRoute.Unmapped)
{
return route;
}
var key = new SelectionKey(
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.DataCode);
return fixedSignatures.Contains(key)
? LegacyPListAuditRoute.ExistingClosedMapper
: LegacyPListAuditRoute.Unmapped;
}
private static IReadOnlySet<SelectionKey> LoadFixedSignatures()
{
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile))
{
throw InvalidBoundary("legacy fixed-profile root");
}
var directory = Path.GetFullPath(Path.Combine(
profile,
"source",
"repos",
"MBN_STOCK_N",
"MBN_STOCK_N",
"bin",
"Debug",
"Res"));
if (!Directory.Exists(directory) ||
(File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
{
throw InvalidBoundary("legacy fixed source directory");
}
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var encoding = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
var signatureCounts = new Dictionary<SelectionKey, int>();
var actionCount = 0;
foreach (var source in FixedSources)
{
var path = Path.GetFullPath(Path.Combine(directory, source.FileName));
if (!string.Equals(
Path.GetDirectoryName(path),
directory,
StringComparison.OrdinalIgnoreCase) ||
!File.Exists(path) ||
(File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
{
throw InvalidBoundary("legacy fixed source file");
}
byte[] bytes;
using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read))
{
if (stream.Length is < 1 or > 32_768)
{
throw InvalidBoundary("legacy fixed source size");
}
bytes = new byte[stream.Length];
stream.ReadExactly(bytes);
}
if (!string.Equals(
Convert.ToHexString(SHA256.HashData(bytes)),
source.ExpectedSha256,
StringComparison.Ordinal))
{
throw InvalidBoundary("legacy fixed source hash");
}
string text;
try
{
text = encoding.GetString(bytes);
}
catch (DecoderFallbackException)
{
throw InvalidBoundary("legacy fixed source encoding");
}
AppendFixedSignatures(signatureCounts, source.Market, text, ref actionCount);
}
// The three audited runtime INI files contain 328 source actions. Six
// unavailable manual placeholders are deliberately excluded below.
if (actionCount != 322)
{
throw InvalidBoundary("legacy fixed signature cardinality");
}
// A raw row must identify exactly one current action. If two source
// actions collapse to one historical five-field signature, retain no
// signature for that key and let the row fail closed as unmapped.
return signatureCounts
.Where(static pair => pair.Value == 1)
.Select(static pair => pair.Key)
.ToHashSet();
}
private static void AppendFixedSignatures(
IDictionary<SelectionKey, int> signatureCounts,
string market,
string text,
ref int actionCount)
{
string? section = null;
foreach (var rawLine in text.Split(["\r\n"], StringSplitOptions.None))
{
// MainForm's runtime INI loader calls Trim() on every line before
// it builds the tree and playlist signature.
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line.Length >= 3 && line[0] == '[' && line[^1] == ']')
{
section = line[1..^1];
if (section.Length == 0 || LegacyPListReadAuditParser.ContainsMojibakeSignal(section))
{
throw InvalidBoundary("legacy fixed section");
}
continue;
}
if (section is null || line.Contains('^') ||
LegacyPListReadAuditParser.ContainsMojibakeSignal(line))
{
throw InvalidBoundary("legacy fixed action");
}
if (line == "VI 발동(수동)" ||
section == "매매동향" && line is
("개인 순매도 상위(수동)" or
"외국인 순매도 상위(수동)" or
"기관 순매도 상위(수동)"))
{
continue;
}
actionCount = checked(actionCount + 1);
var separator = line.IndexOf('_');
var first = separator < 0 ? line : line[..separator];
var remainder = separator < 0 ? string.Empty : line[(separator + 1)..];
SelectionKey signature;
if (market is "overseas" or "exchange")
{
signature = new SelectionKey(
market == "overseas" ? "해외지수" : "환율",
first,
section,
remainder,
string.Empty);
}
else if (section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
{
signature = new SelectionKey(
FixedIndexGroup(line),
"지수",
section,
line,
string.Empty);
}
else if (section == "매매동향")
{
signature = new SelectionKey(
FixedIndexGroup(line),
"지수",
first,
remainder,
string.Empty);
}
else
{
signature = new SelectionKey(
section,
"지수",
first,
remainder,
string.Empty);
}
signatureCounts.TryGetValue(signature, out var existingCount);
signatureCounts[signature] = checked(existingCount + 1);
}
}
private static string FixedIndexGroup(string label)
{
if (label.Contains("코스피_NXT", StringComparison.Ordinal)) return "코스피_NXT";
if (label.Contains("코스닥_NXT", StringComparison.Ordinal)) return "코스닥_NXT";
if (label.Contains("코스피", StringComparison.Ordinal)) return "코스피";
if (label.Contains("코스닥", StringComparison.Ordinal)) return "코스닥";
return "전체";
}
private sealed class MaterializationSummary
{
public int Unique { get; private set; }
public int Playable { get; private set; }
public int Blocked { get; private set; }
public Dictionary<string, int> BlockReasons { get; } = new(StringComparer.Ordinal);
public void AddPlayable(int count)
{
if (count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
Unique = checked(Unique + count);
Playable = checked(Playable + count);
}
public void AddBlocked(string reason, int count = 1)
{
if (string.IsNullOrWhiteSpace(reason) || count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
Unique = checked(Unique + count);
Blocked = checked(Blocked + count);
BlockReasons[reason] = checked(BlockReasons.GetValueOrDefault(reason) + count);
}
}
private readonly record struct RoutedRow(
LegacyPListAuditRow Row,
LegacyPListAuditRoute Route);
private readonly record struct SelectionKey(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
private sealed record FixedSource(
string FileName,
string Market,
string ExpectedSha256);
private static readonly IReadOnlyList<FixedSource> FixedSources =
[
new(
"해외.ini",
"overseas",
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
new(
"환율.ini",
"exchange",
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
new(
"지수.ini",
"index",
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
];
}

View File

@@ -1,9 +1,14 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
internal static class NxtThemeRestoreDbAudit
{
private const string OriginalConfigurationPath =
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\RES\MmoneyCoder.ini";
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
private const string ActiveRowsSql = """
@@ -20,8 +25,12 @@ internal static class NxtThemeRestoreDbAudit
internal static async Task RunAsync(
IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions,
CancellationToken cancellationToken)
{
await RunEndpointAuditAsync(executor, currentOptions, cancellationToken)
.ConfigureAwait(false);
const int maximumRows = 1_000;
var spec = new DataQuerySpec(
ActiveRowsSql,
@@ -116,9 +125,11 @@ internal static class NxtThemeRestoreDbAudit
$"remapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
$"blocked={(rows.Count - resolved).ToString(CultureInfo.InvariantCulture)} " +
$"session={LegacyNamedNxtThemeRestoreService.SessionForLocalHour(DateTime.Now.Hour)}");
Console.WriteLine("NXT_THEME_RESTORE_GRAMMAR: " + string.Join(",", grammar
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}")));
Console.WriteLine(
"NXT_THEME_RESTORE_GRAMMAR: " +
$"variants={grammar.Count.ToString(CultureInfo.InvariantCulture)} " +
$"rows={grammar.Values.Sum().ToString(CultureInfo.InvariantCulture)} " +
"rawValuesPrinted=false");
Console.WriteLine("NXT_THEME_RESTORE_ACTIONS: " +
(actions.Length == 0 ? "none" : string.Join(",", actions)));
Console.WriteLine("NXT_THEME_RESTORE_FAILURES: " +
@@ -129,5 +140,323 @@ internal static class NxtThemeRestoreDbAudit
: $"min={liveCounts.Min().ToString(CultureInfo.InvariantCulture)}," +
$"max={liveCounts.Max().ToString(CultureInfo.InvariantCulture)}," +
$"sum={liveCounts.Sum().ToString(CultureInfo.InvariantCulture)}"));
await RunJoinDiagnosticAsync(executor, rows, result, cancellationToken)
.ConfigureAwait(false);
}
private static async Task RunJoinDiagnosticAsync(
IDataQueryExecutor executor,
IReadOnlyList<LegacyNamedNxtThemeRestoreRow> sourceRows,
LegacyNamedNxtThemeRestoreResult restore,
CancellationToken cancellationToken)
{
var codes = new Dictionary<string, string>(StringComparer.Ordinal);
for (var index = 0; index < restore.Rows.Count; index++)
{
var current = restore.Rows[index].ObservedCurrentThemeCode;
if (current is null)
{
continue;
}
var stored = sourceRows[index].DataCode;
if (codes.TryGetValue(current, out var previous) &&
!string.Equals(previous, stored, StringComparison.Ordinal))
{
throw new InvalidDataException(
"The NXT join audit found conflicting stored codes for one current identity.");
}
codes[current] = stored;
}
if (codes.Count == 0)
{
Console.WriteLine("NXT_THEME_JOIN_DIAGNOSTIC: currentThemes=0");
return;
}
// Sort only to make repeated aggregate runs deterministic. Neither key
// is printed or included in an exception.
var identities = codes
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(static pair => new LegacyNxtThemeJoinAuditIdentity(pair.Key, pair.Value))
.ToArray();
var audit = await new LegacyNxtThemeJoinAuditService(executor)
.AuditAsync(identities, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var evidence = audit.Themes;
var rawCurrent = evidence.Sum(static row => row.RawCurrentItemCount);
var rawStored = evidence.Sum(static row => row.RawStoredItemCount);
var currentOriginal = evidence.Sum(static row => row.CurrentOriginalJoinCount);
var currentNonStopped = evidence.Sum(static row => row.CurrentNonStoppedJoinCount);
var currentLive = evidence.Sum(static row => row.CurrentLiveJoinCount);
var storedOriginal = evidence.Sum(static row => row.StoredOriginalJoinCount);
var storedNonStopped = evidence.Sum(static row => row.StoredNonStoppedJoinCount);
var storedLive = evidence.Sum(static row => row.StoredLiveJoinCount);
var exactCandidate = evidence.Sum(static row => row.CurrentExactJoinCount);
var stripBothCandidate = evidence.Sum(static row => row.CurrentStripBothJoinCount);
var rightSixCandidate = evidence.Sum(static row => row.CurrentRightSixJoinCount);
var validCodes = evidence.Sum(static row => row.CurrentValidItemCodeCount);
var kospiMaster = evidence.Sum(static row => row.CurrentKospiMasterJoinCount);
var kosdaqMaster = evidence.Sum(static row => row.CurrentKosdaqMasterJoinCount);
var kospiOnline = evidence.Sum(static row => row.CurrentKospiOnlineJoinCount);
var kosdaqOnline = evidence.Sum(static row => row.CurrentKosdaqOnlineJoinCount);
var rawCurrentZero = evidence.Count(static row => row.RawCurrentItemCount == 0);
var rawStoredPositive = evidence.Count(static row => row.RawStoredItemCount > 0);
var originalCurrentPositive = evidence.Count(static row => row.CurrentOriginalJoinCount > 0);
Console.WriteLine(
"NXT_THEME_JOIN_DIAGNOSTIC: " +
$"currentThemes={evidence.Count.ToString(CultureInfo.InvariantCulture)} " +
$"remappedThemes={identities.Count(identity => !string.Equals(identity.CurrentThemeCode, identity.StoredThemeCode, StringComparison.Ordinal)).ToString(CultureInfo.InvariantCulture)} " +
$"rawCurrent={rawCurrent.ToString(CultureInfo.InvariantCulture)} " +
$"rawStored={rawStored.ToString(CultureInfo.InvariantCulture)} " +
$"currentOriginalJoin={currentOriginal.ToString(CultureInfo.InvariantCulture)} " +
$"currentNonStopped={currentNonStopped.ToString(CultureInfo.InvariantCulture)} " +
$"currentLive={currentLive.ToString(CultureInfo.InvariantCulture)} " +
$"storedOriginalJoin={storedOriginal.ToString(CultureInfo.InvariantCulture)} " +
$"storedNonStopped={storedNonStopped.ToString(CultureInfo.InvariantCulture)} " +
$"storedLive={storedLive.ToString(CultureInfo.InvariantCulture)} " +
$"currentValidItemCodes={validCodes.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"NXT_THEME_MASTER_JOIN_DIAGNOSTIC: " +
$"kospiMaster={kospiMaster.ToString(CultureInfo.InvariantCulture)} " +
$"kosdaqMaster={kosdaqMaster.ToString(CultureInfo.InvariantCulture)} " +
$"kospiOnline={kospiOnline.ToString(CultureInfo.InvariantCulture)} " +
$"kosdaqOnline={kosdaqOnline.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"NXT_THEME_JOIN_CANDIDATES_DIAGNOSTIC_ONLY: " +
$"exact={exactCandidate.ToString(CultureInfo.InvariantCulture)} " +
$"stripBoth={stripBothCandidate.ToString(CultureInfo.InvariantCulture)} " +
$"rightSix={rightSixCandidate.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"NXT_THEME_JOIN_CLASSIFICATION: " +
$"currentRawZeroThemes={rawCurrentZero.ToString(CultureInfo.InvariantCulture)} " +
$"storedRawPositiveThemes={rawStoredPositive.ToString(CultureInfo.InvariantCulture)} " +
$"currentOriginalPositiveThemes={originalCurrentPositive.ToString(CultureInfo.InvariantCulture)} " +
$"root={ClassifyRoot(evidence)} " +
"action=read-only-review-no-fallback");
}
private static string ClassifyRoot(
IReadOnlyList<LegacyNxtThemeJoinAuditEvidence> evidence)
{
if (evidence.All(static row => row.RawCurrentItemCount == 0) &&
evidence.All(static row => row.RawStoredItemCount > 0))
{
return "CurrentThemeCodeHasNoSbItemRowsStoredCodeHasRows";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
evidence.All(static row =>
row.CurrentKospiMasterJoinCount == 0 &&
row.CurrentKosdaqMasterJoinCount == 0))
{
return "CurrentItemsMissingFromNxtStockMasters";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
evidence.Any(static row =>
row.CurrentKospiMasterJoinCount > 0 ||
row.CurrentKosdaqMasterJoinCount > 0))
{
return "VAllStockExcludesCurrentNxtMasterRows";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
evidence.Any(static row =>
row.CurrentExactJoinCount > 0 ||
row.CurrentStripBothJoinCount > 0 ||
row.CurrentRightSixJoinCount > 0))
{
return "ItemCodeJoinShapeMismatch";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0))
{
return "CurrentItemsHaveNoStockIdentityJoin";
}
return "MixedOrUnclassifiedReadOnlyEvidence";
}
private static async Task RunEndpointAuditAsync(
IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executor);
ArgumentNullException.ThrowIfNull(currentOptions);
cancellationToken.ThrowIfCancellationRequested();
var original = ReadOriginalEndpoint(OriginalConfigurationPath);
var current = NormalizeEndpoint(
currentOptions.Host,
currentOptions.Port,
currentOptions.Database);
var spec = new DataQuerySpec(
"SELECT DATABASE() CURRENT_DATABASE, VERSION() SERVER_VERSION");
spec.ValidateFor(DataSourceKind.MariaDb);
var table = await executor.ExecuteAsync(
DataSourceKind.MariaDb,
"AUDIT_NXT_ENDPOINT_METADATA",
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (table is null || table.Columns.Count != 2 || table.Rows.Count != 1 ||
!HasStringColumn(table.Columns[0], "CURRENT_DATABASE") ||
!HasStringColumn(table.Columns[1], "SERVER_VERSION") ||
table.Rows[0][0] is not string actualDatabase ||
table.Rows[0][1] is not string serverVersion ||
string.IsNullOrWhiteSpace(actualDatabase) ||
string.IsNullOrWhiteSpace(serverVersion))
{
throw new InvalidDataException(
"The NXT endpoint metadata audit returned an invalid closed result.");
}
var originalHash = HashEndpoint(original);
var currentHash = HashEndpoint(current);
var configuredEqual = string.Equals(original, current, StringComparison.Ordinal);
var actualDatabaseMatches = string.Equals(
NormalizeDatabase(actualDatabase),
NormalizeDatabase(currentOptions.Database),
StringComparison.Ordinal);
var serverFamily = serverVersion.Contains(
"MariaDB",
StringComparison.OrdinalIgnoreCase)
? "MariaDB"
: "MySqlCompatible";
var versionParts = serverVersion.Split('.', '-', '+');
var versionMajorMinor = versionParts.Length >= 2 &&
int.TryParse(versionParts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) &&
int.TryParse(versionParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
? $"{major.ToString(CultureInfo.InvariantCulture)}.{minor.ToString(CultureInfo.InvariantCulture)}"
: "unclassified";
Console.WriteLine(
"NXT_ENDPOINT_AUDIT: " +
$"originalSha256={originalHash} " +
$"currentSha256={currentHash} " +
$"configuredEqual={configuredEqual.ToString().ToLowerInvariant()} " +
$"actualDatabaseMatchesCurrent={actualDatabaseMatches.ToString().ToLowerInvariant()} " +
$"serverFamily={serverFamily} " +
$"serverMajorMinor={versionMajorMinor}");
}
private static string ReadOriginalEndpoint(string path)
{
if (!File.Exists(path))
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration is unavailable.");
}
var source = new FileInfo(path);
if ((source.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
source.Length is < 1 or > 64 * 1024)
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration failed its file boundary.");
}
var inMariaSection = false;
string? connectionName = null;
foreach (var rawLine in File.ReadLines(path))
{
var line = rawLine.Trim();
if (line.StartsWith("[", StringComparison.Ordinal) &&
line.EndsWith("]", StringComparison.Ordinal))
{
inMariaSection = string.Equals(line, "[Maria]", StringComparison.Ordinal);
continue;
}
if (!inMariaSection || line.Length == 0 ||
line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
const string prefix = "ConnectionName=";
if (line.StartsWith(prefix, StringComparison.Ordinal))
{
if (connectionName is not null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is ambiguous.");
}
connectionName = line[prefix.Length..];
}
}
if (connectionName is null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is unavailable.");
}
var slash = connectionName.LastIndexOf('/');
var colon = connectionName.LastIndexOf(':', slash - 1);
if (slash <= 0 || colon <= 0 || colon >= slash - 1 || slash == connectionName.Length - 1 ||
!int.TryParse(
connectionName.AsSpan(colon + 1, slash - colon - 1),
NumberStyles.None,
CultureInfo.InvariantCulture,
out var port))
{
throw new InvalidDataException(
"The original MariaDB endpoint has an invalid closed shape.");
}
return NormalizeEndpoint(
connectionName[..colon],
port,
connectionName[(slash + 1)..]);
}
private static string NormalizeEndpoint(string? host, int port, string? database)
{
var normalizedHost = (host ?? string.Empty)
.Trim()
.TrimEnd('.')
.ToLowerInvariant();
var normalizedDatabase = NormalizeDatabase(database);
if (normalizedHost.Length == 0 || normalizedHost.Length > 253 ||
normalizedDatabase.Length == 0 || normalizedDatabase.Length > 128 ||
port is < 1 or > 65_535 ||
normalizedHost.Any(static value => char.IsControl(value)) ||
normalizedDatabase.Any(static value => char.IsControl(value)))
{
throw new InvalidDataException(
"A MariaDB endpoint is invalid at the closed audit boundary.");
}
return string.Join(
'\u001f',
normalizedHost,
port.ToString(CultureInfo.InvariantCulture),
normalizedDatabase);
}
private static string NormalizeDatabase(string? database) =>
(database ?? string.Empty).Trim().Normalize(NormalizationForm.FormC);
private static string HashEndpoint(string endpoint) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint)));
private static bool HasStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
}

View File

@@ -1,10 +1,23 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
Console.OutputEncoding = Encoding.UTF8;
if (args.Length == 1 &&
args[0].Equals("--manual-import-audit", StringComparison.OrdinalIgnoreCase))
{
return RunManualImportAudit();
}
if (args.Length == 1 &&
args[0].Equals("--manual-import-fixture", StringComparison.OrdinalIgnoreCase))
{
return await RunManualImportFixtureAsync();
}
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
Console.CancelKeyPress += (_, eventArgs) =>
{
@@ -38,7 +51,16 @@ try
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token);
await NxtThemeRestoreDbAudit.RunAsync(runtime.Executor, cancellation.Token);
await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token);
await NamedManualRestoreReadOnlyAudit.RunAsync(
runtime.Executor,
NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(),
cancellation.Token);
await NxtThemeRestoreDbAudit.RunAsync(
runtime.Executor,
runtime.Options.MariaDb,
cancellation.Token);
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
var service = new LegacyMarketDataService(runtime.Executor);
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
@@ -123,7 +145,362 @@ static string? GetConfigurationPath(string[] arguments)
}
throw new DatabaseConfigurationException(
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>]");
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--manual-import-audit|--manual-import-fixture]");
}
static async Task VerifyLegacyComparisonImportAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
const string expectedSourceSha256 =
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
var result = await new LegacyComparisonPairImportService(
executor,
TrustedLegacyComparisonFileSource.CreateDefault())
.ImportAsync(cancellationToken);
if (!string.Equals(
result.SourceSha256,
expectedSourceSha256,
StringComparison.Ordinal) ||
result.SourceRowCount != 8 ||
result.Pairs.Count != 8 ||
result.Pairs.Where((pair, index) => pair.RowNumber != index + 1).Any())
{
throw new DatabaseConfigurationException(
"The fixed legacy comparison import did not reproduce its audited 8-row identity set.");
}
Console.WriteLine(
$"LegacyComparisonImport: rows={result.SourceRowCount.ToString(CultureInfo.InvariantCulture)}, " +
$"sha256={result.SourceSha256}, identities=fresh-read-only");
}
static async Task<int> RunManualImportFixtureAsync()
{
const string expectedSourceSha256 =
"fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114";
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
{
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: FAIL - required profile roots are unavailable.");
return 1;
}
var sourceDirectory = Path.Combine(
profile,
"source",
"repos",
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
var operatingDirectory = Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"OperatorData");
var temporaryDirectory = Path.GetFullPath(Path.Combine(
Path.GetTempPath(),
"MBN_STOCK_WEBVIEW-manual-import-fixture-" + Guid.NewGuid().ToString("N")));
var expectedDataFiles = new[]
{
"개인.dat",
"외국인.dat",
"기관.dat",
"VI발동.dat"
};
var trackedOperatingFiles = expectedDataFiles.Concat(new[]
{
LegacyManualOperatorDataImporter.MarkerFileName,
LegacyManualOperatorDataImporter.IntentFileName
}).ToArray();
var sourceBefore = SnapshotFiles(sourceDirectory, expectedDataFiles);
var operatingBefore = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
try
{
var destination = TrustedManualDirectory.PreparePrivateWritableDirectory(
temporaryDirectory);
LegacyManualOperatorImportResult result;
LegacyManualOperatorImportException? secondFailure = null;
using (var importer = new LegacyManualOperatorDataImporter(
destination,
new FixedLegacyManualOperatorDataSource()))
{
result = await importer.ImportAsync(cancellation.Token);
try
{
_ = await importer.ImportAsync(cancellation.Token);
}
catch (LegacyManualOperatorImportException exception)
{
secondFailure = exception;
}
}
var sourceAfter = SnapshotFiles(sourceDirectory, expectedDataFiles);
var operatingAfter = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
var fixtureFiles = SnapshotFiles(destination, trackedOperatingFiles);
var dataHashesMatch = expectedDataFiles.All(name =>
sourceBefore.TryGetValue(name, out var sourceValue) &&
fixtureFiles.TryGetValue(name, out var fixtureValue) &&
string.Equals(sourceValue, fixtureValue, StringComparison.Ordinal));
var markerPresent = fixtureFiles.TryGetValue(
LegacyManualOperatorDataImporter.MarkerFileName,
out var markerValue) && markerValue != "missing";
var intentAbsent = fixtureFiles.TryGetValue(
LegacyManualOperatorDataImporter.IntentFileName,
out var intentValue) && intentValue == "missing";
var runtimeState = LegacyManualOperatorDataRuntimeGuard.Inspect(destination).State;
if (!SnapshotsEqual(sourceBefore, sourceAfter) ||
!SnapshotsEqual(operatingBefore, operatingAfter) ||
!dataHashesMatch || !markerPresent || !intentAbsent ||
result.MarkerVersion != LegacyManualOperatorDataImporter.MarkerVersion ||
result.NetSellRowCount != 15 ||
result.ViItemCount != 9 ||
!string.Equals(
result.SourceSha256,
expectedSourceSha256,
StringComparison.Ordinal) ||
runtimeState != LegacyManualOperatorDataRuntimeState.ReadyImportedData ||
secondFailure is null ||
secondFailure.Failure != LegacyManualOperatorImportFailure.AlreadyImported ||
secondFailure.OutcomeUnknown)
{
Console.Error.WriteLine(
"MANUAL_IMPORT_FIXTURE: FAIL - an isolated import invariant was not reproduced.");
return 1;
}
Console.WriteLine(
$"MANUAL_IMPORT_FIXTURE: PASS rows={result.NetSellRowCount.ToString(CultureInfo.InvariantCulture)} " +
$"vi={result.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
$"sourceSha256={result.SourceSha256} state={runtimeState} " +
"secondAttempt=AlreadyImported outcomeUnknown=false " +
"sourceUnchanged=true operatingImportSetUnchanged=true");
return 0;
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: CANCELED_OR_TIMED_OUT");
return 3;
}
catch (LegacyManualOperatorImportException exception)
{
Console.Error.WriteLine(
$"MANUAL_IMPORT_FIXTURE: FAIL - failure={exception.Failure} " +
$"outcomeUnknown={exception.OutcomeUnknown.ToString().ToLowerInvariant()}");
return exception.OutcomeUnknown ? 5 : 1;
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"MANUAL_IMPORT_FIXTURE: FAIL - {exception.GetType().Name}");
return 1;
}
finally
{
RemoveOwnedFixtureDirectory(temporaryDirectory);
}
}
static int RunManualImportAudit()
{
try
{
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
{
Console.Error.WriteLine("MANUAL_IMPORT_AUDIT: FAIL - required profile roots are unavailable.");
return 1;
}
var sourceDirectory = Path.Combine(
profile,
"source",
"repos",
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
var operatingDirectory = Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"OperatorData");
var expectedDataFiles = new[]
{
"개인.dat",
"외국인.dat",
"기관.dat",
"VI발동.dat"
};
var source = SnapshotFiles(sourceDirectory, expectedDataFiles);
var operating = SnapshotFiles(
operatingDirectory,
expectedDataFiles.Concat(new[]
{
LegacyManualOperatorDataImporter.MarkerFileName,
LegacyManualOperatorDataImporter.IntentFileName
}));
var markerPresent = operating[LegacyManualOperatorDataImporter.MarkerFileName] != "missing";
var intentPresent = operating[LegacyManualOperatorDataImporter.IntentFileName] != "missing";
var dataPresent = expectedDataFiles.Count(name => operating[name] != "missing");
var sourcePresent = expectedDataFiles.Count(name => source[name] != "missing");
var byteIdentical = expectedDataFiles.All(name =>
source[name] != "missing" &&
string.Equals(source[name], operating[name], StringComparison.Ordinal));
var stagedPresent = Directory.Exists(operatingDirectory) &&
Directory.EnumerateFileSystemEntries(
operatingDirectory,
".legacy-manual-import-v1-*",
SearchOption.TopDirectoryOnly).Any();
var runtimeState = LegacyManualOperatorDataRuntimeGuard
.Inspect(operatingDirectory)
.State;
var (decision, outcomeUnknown) = runtimeState switch
{
LegacyManualOperatorDataRuntimeState.InterruptedImport =>
("InterruptedImport", true),
LegacyManualOperatorDataRuntimeState.InvalidImportedState =>
("InvalidImportedState", false),
LegacyManualOperatorDataRuntimeState.ReadyImportedData =>
("AlreadyImported", false),
_ when dataPresent > 0 || stagedPresent =>
("DestinationNotEmpty", false),
_ when sourcePresent == expectedDataFiles.Length =>
("Eligible", false),
_ => ("SourceUnavailable", false)
};
Console.WriteLine(
$"MANUAL_IMPORT_AUDIT: PASS sourceFiles={sourcePresent.ToString(CultureInfo.InvariantCulture)}/4 " +
$"operatingFiles={dataPresent.ToString(CultureInfo.InvariantCulture)}/4 " +
$"sourceByteIdentical={byteIdentical.ToString().ToLowerInvariant()} " +
$"marker={markerPresent.ToString().ToLowerInvariant()} " +
$"intent={intentPresent.ToString().ToLowerInvariant()} " +
$"staged={stagedPresent.ToString().ToLowerInvariant()} " +
$"runtime={runtimeState} " +
$"decision={decision} outcomeUnknown={outcomeUnknown.ToString().ToLowerInvariant()} " +
"writesAttempted=false");
return 0;
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"MANUAL_IMPORT_AUDIT: FAIL - {exception.GetType().Name}");
return 1;
}
}
static IReadOnlyDictionary<string, string> SnapshotFiles(
string directory,
IEnumerable<string> fileNames)
{
const long maximumAuditFileBytes = 32 * 1024;
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var fileName in fileNames)
{
var path = Path.Combine(directory, fileName);
if (!File.Exists(path))
{
result.Add(fileName, "missing");
continue;
}
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
if (stream.Length is < 0 or > maximumAuditFileBytes)
{
throw new InvalidDataException(
"A manual-import audit file exceeded its closed size boundary.");
}
var bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
if (stream.ReadByte() != -1)
{
throw new InvalidDataException(
"A manual-import audit file changed beyond its closed size boundary.");
}
var hash = Convert.ToHexString(SHA256.HashData(bytes));
result.Add(
fileName,
bytes.Length.ToString(CultureInfo.InvariantCulture) + ":" + hash);
}
return result;
}
static bool SnapshotsEqual(
IReadOnlyDictionary<string, string> first,
IReadOnlyDictionary<string, string> second) =>
first.Count == second.Count && first.All(pair =>
second.TryGetValue(pair.Key, out var value) &&
string.Equals(pair.Value, value, StringComparison.Ordinal));
static void RemoveOwnedFixtureDirectory(string directory)
{
if (!Directory.Exists(directory))
{
return;
}
var temporaryRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(Path.GetTempPath()));
var resolved = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
var prefix = temporaryRoot + Path.DirectorySeparatorChar;
const string ownedNamePrefix = "MBN_STOCK_WEBVIEW-manual-import-fixture-";
var ownedName = Path.GetFileName(resolved);
var ownedSuffix = ownedName.StartsWith(ownedNamePrefix, StringComparison.Ordinal)
? ownedName[ownedNamePrefix.Length..]
: string.Empty;
if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
ownedSuffix.Length != 32 ||
ownedSuffix.Any(static value => !char.IsAsciiHexDigit(value)) ||
(File.GetAttributes(resolved) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidOperationException(
"Refusing to remove a directory outside the owned fixture boundary.");
}
var pendingDirectories = new Stack<string>();
pendingDirectories.Push(resolved);
while (pendingDirectories.TryPop(out var current))
{
foreach (var entry in Directory.EnumerateFileSystemEntries(
current,
"*",
new EnumerationOptions
{
RecurseSubdirectories = false,
ReturnSpecialDirectories = false,
AttributesToSkip = 0
}))
{
var attributes = File.GetAttributes(entry);
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidOperationException(
"Refusing to recursively remove a fixture containing a reparse point.");
}
if ((attributes & FileAttributes.Directory) != 0)
{
pendingDirectories.Push(entry);
}
}
}
Directory.Delete(resolved, recursive: true);
}
static async Task VerifyOracleServerVersionAsync(