feat: restore legacy named playlist workflows
This commit is contained in:
@@ -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>
|
||||
|
||||
137
MainWindow.NamedForeignStockRestore.cs
Normal file
137
MainWindow.NamedForeignStockRestore.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
141
MainWindow.NamedKrxThemeRestore.cs
Normal file
141
MainWindow.NamedKrxThemeRestore.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
|
||||
555
Web/app.js
555
Web/app.js
@@ -35,8 +35,12 @@
|
||||
if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable.");
|
||||
const namedComparisonRestoreWorkflow = globalThis.MbnNamedComparisonRestoreWorkflow;
|
||||
if (!namedComparisonRestoreWorkflow) throw new Error("Named comparison restore workflow module is unavailable.");
|
||||
const namedForeignStockRestoreWorkflow = globalThis.MbnNamedForeignStockRestoreWorkflow;
|
||||
if (!namedForeignStockRestoreWorkflow) throw new Error("Named foreign-stock restore workflow module is unavailable.");
|
||||
const foreignIndexCandleRestoreWorkflow = globalThis.MbnLegacyForeignIndexCandleWorkflow;
|
||||
if (!foreignIndexCandleRestoreWorkflow) throw new Error("Legacy foreign-index candle restore workflow module is unavailable.");
|
||||
const namedKrxThemeRestoreWorkflow = globalThis.MbnNamedKrxThemeRestoreWorkflow;
|
||||
if (!namedKrxThemeRestoreWorkflow) throw new Error("Named KRX-theme restore workflow module is unavailable.");
|
||||
const namedNxtThemeRestoreWorkflow = globalThis.MbnNamedNxtThemeRestoreWorkflow;
|
||||
if (!namedNxtThemeRestoreWorkflow) throw new Error("Named NXT-theme restore workflow module is unavailable.");
|
||||
const legacyNamedRowWorkflow = globalThis.MbnLegacyNamedRowWorkflow;
|
||||
@@ -223,7 +227,9 @@
|
||||
guardByEntryId: new Map(),
|
||||
manualRestoreFinalized: true,
|
||||
comparisonRestorePending: null,
|
||||
foreignStockRestorePending: null,
|
||||
foreignIndexCandleRestorePending: null,
|
||||
krxThemeRestorePending: null,
|
||||
nxtThemeRestorePending: null,
|
||||
selectAfterRefresh: ""
|
||||
},
|
||||
@@ -703,7 +709,9 @@
|
||||
restoreRecords.every(record => record.status === "error");
|
||||
if (state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
|
||||
state.namedPlaylist.comparisonRestorePending ||
|
||||
state.namedPlaylist.foreignStockRestorePending ||
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
||||
state.namedPlaylist.krxThemeRestorePending ||
|
||||
state.namedPlaylist.nxtThemeRestorePending ||
|
||||
(restoreRecords.length > 0 && !(allowFailedManualRestore && failedRestoreOnly))) {
|
||||
showToast("DB 플레이리스트 복원과 현재 identity 재검증이 끝날 때까지 순서를 변경할 수 없습니다.");
|
||||
@@ -2423,9 +2431,12 @@
|
||||
let invalid = false;
|
||||
state.playlist = state.playlist.map(item => {
|
||||
if (item?.operator?.source !== "legacy-theme-workflow") return item;
|
||||
const refreshed = namedNxtThemeRestoreWorkflow.isMaterializedEntry(item)
|
||||
? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date())
|
||||
: themeWorkflow.refreshThemePlaylistEntry(item);
|
||||
const namedKrx = namedKrxThemeRestoreWorkflow.isMaterializedEntry(item);
|
||||
const refreshed = namedKrx
|
||||
? item
|
||||
: namedNxtThemeRestoreWorkflow.isMaterializedEntry(item)
|
||||
? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date())
|
||||
: themeWorkflow.refreshThemePlaylistEntry(item);
|
||||
if (!refreshed) {
|
||||
invalid = true;
|
||||
return item;
|
||||
@@ -2436,10 +2447,10 @@
|
||||
invalid = true;
|
||||
return item;
|
||||
}
|
||||
// A native-verified named NXT entry already has the complete theme
|
||||
// operator shape. Do not clone it here: its in-memory verification mark
|
||||
// must survive until PREPARE/save validation.
|
||||
return namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed)
|
||||
// Native-verified named theme entries already have the complete operator
|
||||
// shape. Do not clone them here: the in-memory verification mark must
|
||||
// survive until PREPARE/save validation.
|
||||
return namedKrx || namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed)
|
||||
? refreshed
|
||||
: { ...definition, ...refreshed };
|
||||
});
|
||||
@@ -3532,6 +3543,10 @@
|
||||
state.candleOptions.invalidIds = [...result.invalidIds];
|
||||
state.playlist = [...result.playlist].map(item => {
|
||||
if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item;
|
||||
// The named foreign-stock entry carries a strict native identity proof
|
||||
// and original seven-field signature. Keep that exact branded object;
|
||||
// catalog decoration would make its save round-trip unverifiable.
|
||||
if (item.operator?.source === namedForeignStockRestoreWorkflow.source) return item;
|
||||
const definition = catalog.find(value =>
|
||||
value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || "")));
|
||||
return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item;
|
||||
@@ -3832,6 +3847,92 @@
|
||||
showToast("플레이리스트에 추가했습니다.");
|
||||
}
|
||||
|
||||
function recreatePlaylistEntryWithEnabled(item, enabled) {
|
||||
if (!item || typeof item !== "object" || typeof item.enabled !== "boolean" ||
|
||||
typeof enabled !== "boolean") return null;
|
||||
if (item.enabled === enabled) return item;
|
||||
|
||||
const operator = item.operator;
|
||||
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(item) ||
|
||||
operator?.namedKrxRestore !== undefined) {
|
||||
return namedKrxThemeRestoreWorkflow.withEnabled(item, enabled);
|
||||
}
|
||||
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) ||
|
||||
operator?.namedNxtRestore !== undefined) {
|
||||
return namedNxtThemeRestoreWorkflow.withEnabled(item, enabled);
|
||||
}
|
||||
if (operator?.source === namedForeignStockRestoreWorkflow.source) {
|
||||
return namedForeignStockRestoreWorkflow.withEnabled(item, enabled);
|
||||
}
|
||||
if (operator?.source === "legacy-foreign-index-candle-workflow") {
|
||||
return foreignIndexCandleRestoreWorkflow.withEnabled(item, enabled);
|
||||
}
|
||||
if (operator?.source === "manual-financial-workflow") {
|
||||
return manualFinancialWorkflow.withEnabled(item, enabled);
|
||||
}
|
||||
if (operator?.source === "legacy-manual-lists-workflow") {
|
||||
return manualListsWorkflow.withEnabled(item, enabled);
|
||||
}
|
||||
return { ...item, enabled };
|
||||
}
|
||||
|
||||
function playlistEntryManualEditBlockReason(item) {
|
||||
if (!item || typeof item !== "object") return "missing-entry";
|
||||
if (item.operator?.source) return "trusted-workflow-entry";
|
||||
if (state.namedPlaylist.guardByEntryId.has(item.id)) return "named-playlist-entry";
|
||||
if (state.manualFinancialRestore.entries.has(item.id)) return "manual-restore-entry";
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeManualSceneSelection(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
|
||||
if (Object.keys(value).length !== keys.length || keys.some(key => !(key in value))) return null;
|
||||
const selection = {};
|
||||
for (const key of keys) {
|
||||
if (typeof value[key] !== "string") return null;
|
||||
const text = value[key].trim();
|
||||
if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return null;
|
||||
selection[key] = text;
|
||||
}
|
||||
return Object.freeze(selection);
|
||||
}
|
||||
|
||||
function recreatePlaylistEntryWithSceneSelection(item, selection, code, fadeDuration) {
|
||||
if (playlistEntryManualEditBlockReason(item)) return null;
|
||||
const normalizedSelection = normalizeManualSceneSelection(selection);
|
||||
if (!normalizedSelection || typeof code !== "string" || !sceneAliases(item).includes(code) ||
|
||||
!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) return null;
|
||||
return { ...item, selection: normalizedSelection, code, fadeDuration };
|
||||
}
|
||||
|
||||
function recreatePlaylistEntryWithLiveSelection(item, selection) {
|
||||
if (playlistEntryManualEditBlockReason(item)) return null;
|
||||
const normalizedSelection = normalizeManualSceneSelection(selection);
|
||||
if (!normalizedSelection || (!normalizedSelection.subject && !normalizedSelection.dataCode)) return null;
|
||||
return {
|
||||
...item,
|
||||
selection: normalizedSelection,
|
||||
subject: normalizedSelection.subject,
|
||||
dataCode: normalizedSelection.dataCode
|
||||
};
|
||||
}
|
||||
|
||||
function replacePlaylistEntryAt(index, replacement) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length ||
|
||||
!replacement || replacement.id !== state.playlist[index]?.id) return false;
|
||||
const playlist = [...state.playlist];
|
||||
playlist[index] = replacement;
|
||||
state.playlist = playlist;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replacePlaylistEntryEnabledAt(index, enabled) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length) return null;
|
||||
const replacement = recreatePlaylistEntryWithEnabled(state.playlist[index], enabled);
|
||||
return replacement && replacePlaylistEntryAt(index, replacement) ? replacement : null;
|
||||
}
|
||||
|
||||
function renderPlaylist() {
|
||||
renderGlobalCandleOptions();
|
||||
elements.playlistBody.replaceChildren();
|
||||
@@ -3874,8 +3975,14 @@
|
||||
checkbox.checked = item.enabled !== false;
|
||||
return;
|
||||
}
|
||||
item.enabled = checkbox.checked;
|
||||
addLog(`${item.code} ${checkbox.checked ? "송출 포함" : "송출 제외"}`);
|
||||
const replacement = replacePlaylistEntryEnabledAt(index, checkbox.checked);
|
||||
if (!replacement) {
|
||||
checkbox.checked = item.enabled !== false;
|
||||
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
renderPlaylist();
|
||||
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
|
||||
});
|
||||
checkCell.append(checkbox);
|
||||
|
||||
@@ -3999,7 +4106,8 @@
|
||||
|
||||
function renderSceneSelectionForm(item) {
|
||||
const selection = item?.selection || {};
|
||||
const disabled = !item || isPlaylistSnapshotLocked() || Boolean(item.operator?.source);
|
||||
const disabled = !item || isPlaylistSnapshotLocked() ||
|
||||
Boolean(playlistEntryManualEditBlockReason(item));
|
||||
elements.sceneGroupCode.value = String(selection.groupCode || "");
|
||||
elements.sceneSubject.value = String(selection.subject || "");
|
||||
elements.sceneGraphicType.value = String(selection.graphicType || "");
|
||||
@@ -4033,6 +4141,11 @@
|
||||
if (!requirePlaylistEditable()) return;
|
||||
const item = state.playlist[state.currentIndex];
|
||||
if (!item) return;
|
||||
if (playlistEntryManualEditBlockReason(item)) {
|
||||
renderSceneSelectionForm(item);
|
||||
showToast("검증된 항목은 조회값을 직접 편집할 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
|
||||
return;
|
||||
}
|
||||
const values = [
|
||||
elements.sceneGroupCode.value,
|
||||
elements.sceneSubject.value,
|
||||
@@ -4054,18 +4167,21 @@
|
||||
showToast("허용되지 않은 CUT alias입니다.");
|
||||
return;
|
||||
}
|
||||
item.selection = {
|
||||
const replacement = recreatePlaylistEntryWithSceneSelection(item, {
|
||||
groupCode: values[0].trim(),
|
||||
subject: values[1].trim(),
|
||||
graphicType: values[2].trim(),
|
||||
subtype: values[3].trim(),
|
||||
dataCode: values[4].trim()
|
||||
};
|
||||
item.code = cutAlias;
|
||||
item.fadeDuration = fadeDuration;
|
||||
}, cutAlias, fadeDuration);
|
||||
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
|
||||
renderSceneSelectionForm(item);
|
||||
showToast("장면 조회값을 안전하게 적용할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
addLog(`${item.code} 장면 조회값 적용`);
|
||||
addLog(`${replacement.code} 장면 조회값 적용`);
|
||||
showToast("장면 조회값을 적용했습니다.");
|
||||
}
|
||||
|
||||
@@ -4131,7 +4247,12 @@
|
||||
if (!requirePlaylistEditable()) return;
|
||||
if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다.");
|
||||
const enable = state.playlist.some(item => item.enabled === false);
|
||||
state.playlist.forEach(item => { item.enabled = enable; });
|
||||
const replacements = state.playlist.map(item => recreatePlaylistEntryWithEnabled(item, enable));
|
||||
if (replacements.some(item => !item)) {
|
||||
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
state.playlist = replacements;
|
||||
renderPlaylist();
|
||||
addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`);
|
||||
}
|
||||
@@ -4222,9 +4343,13 @@
|
||||
if (!requirePlaylistEditable()) return;
|
||||
const item = state.playlist[state.currentIndex];
|
||||
if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요.");
|
||||
item.enabled = item.enabled === false;
|
||||
const replacement = replacePlaylistEntryEnabledAt(state.currentIndex, item.enabled === false);
|
||||
if (!replacement) {
|
||||
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
renderPlaylist();
|
||||
addLog(`${item.code} ${item.enabled ? "송출 포함" : "송출 제외"}`);
|
||||
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
|
||||
}
|
||||
|
||||
function savePlaylist() {
|
||||
@@ -4284,6 +4409,16 @@
|
||||
? [{ ...themeDefinition, ...restoredThemeEntry }]
|
||||
: [];
|
||||
}
|
||||
const restoredNamedForeignStockEntry =
|
||||
namedForeignStockRestoreWorkflow.restorePlaylistEntry(item);
|
||||
if (restoredNamedForeignStockEntry) {
|
||||
const overseasDefinition = catalog.find(definition =>
|
||||
definition.builderKey === restoredNamedForeignStockEntry.builderKey &&
|
||||
sceneAliases(definition).includes(restoredNamedForeignStockEntry.code));
|
||||
return overseasDefinition
|
||||
? [{ ...overseasDefinition, ...restoredNamedForeignStockEntry }]
|
||||
: [];
|
||||
}
|
||||
const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item);
|
||||
if (restoredOverseasEntry) {
|
||||
const overseasDefinition = catalog.find(definition =>
|
||||
@@ -4690,7 +4825,9 @@
|
||||
return;
|
||||
}
|
||||
if (state.namedPlaylist.comparisonRestorePending ||
|
||||
state.namedPlaylist.foreignStockRestorePending ||
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
||||
state.namedPlaylist.krxThemeRestorePending ||
|
||||
state.namedPlaylist.nxtThemeRestorePending) return;
|
||||
const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration);
|
||||
const materialized = materializeNamedRestoration(restoration);
|
||||
@@ -4868,10 +5005,10 @@
|
||||
}
|
||||
|
||||
function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) {
|
||||
// Historical NXT codes are not authoritative. Keep every original
|
||||
// `테마_NXT` row untrusted until the correlated native MariaDB batch maps
|
||||
// its exact title to the current unique code and proves a live item.
|
||||
if (rawRow.groupCode === "테마_NXT") return;
|
||||
// Historical theme codes are not authoritative. Keep every original KRX
|
||||
// and NXT row untrusted until its correlated native batch maps the exact
|
||||
// title to one current code and validates the item preview.
|
||||
if (rawRow.groupCode === "테마" || rawRow.groupCode === "테마_NXT") return;
|
||||
let theme;
|
||||
let sort;
|
||||
let actionIds = themeWorkflow.actions.map(action => action.id);
|
||||
@@ -4994,6 +5131,10 @@
|
||||
}
|
||||
|
||||
function resolveNamedPlaylistRawRow(rawRow, itemIndex) {
|
||||
// The stored theme code is historical evidence, not a playable identity.
|
||||
// Route both legacy theme groups only through their correlated native
|
||||
// verifiers; no stock/default candidate may accept the stale code first.
|
||||
if (rawRow?.groupCode === "테마" || rawRow?.groupCode === "테마_NXT") return null;
|
||||
const candidates = [];
|
||||
const now = namedNowForRawRow(rawRow);
|
||||
addFixedNamedCandidates(candidates, rawRow, itemIndex, now);
|
||||
@@ -5053,7 +5194,9 @@
|
||||
for (const row of restoration.rows) {
|
||||
const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" ||
|
||||
row.entry?.operator?.source === "legacy-manual-lists-workflow" ||
|
||||
namedKrxThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
|
||||
namedNxtThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
|
||||
namedForeignStockRestoreWorkflow.isMaterializedEntry(row.entry) ||
|
||||
foreignIndexCandleRestoreWorkflow.isMaterializedEntry(row.entry);
|
||||
const entry = row.trusted
|
||||
? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText })
|
||||
@@ -5099,7 +5242,10 @@
|
||||
state.namedPlaylist.pagePlanError = null;
|
||||
state.namedPlaylist.manualRestoreFinalized = true;
|
||||
state.namedPlaylist.comparisonRestorePending = null;
|
||||
clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
|
||||
state.namedPlaylist.foreignStockRestorePending = null;
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
||||
state.namedPlaylist.krxThemeRestorePending = null;
|
||||
state.namedPlaylist.nxtThemeRestorePending = null;
|
||||
}
|
||||
|
||||
@@ -5173,7 +5319,9 @@
|
||||
}
|
||||
return manualFields;
|
||||
}
|
||||
const selection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
|
||||
const selection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
|
||||
namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
|
||||
namedForeignStockRestoreWorkflow.legacySelectionForEntry(entry) ||
|
||||
legacyNamedRowWorkflow.legacySelectionForEntry(entry);
|
||||
if (!selection) {
|
||||
throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다.");
|
||||
@@ -5191,6 +5339,12 @@
|
||||
pageText: fields[5],
|
||||
dataCode: fields[6]
|
||||
};
|
||||
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||
if (!namedKrxThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
|
||||
throw new Error("KRX 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||
if (!namedNxtThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
|
||||
throw new Error("NXT 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
|
||||
@@ -5215,6 +5369,15 @@
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
if (entry.operator?.source === "legacy-named-foreign-stock-workflow") {
|
||||
const restoredForeignStock = namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
|
||||
if (!restoredForeignStock || restoredForeignStock.builderKey !== entry.builderKey ||
|
||||
restoredForeignStock.code !== entry.code ||
|
||||
!namedForeignStockRestoreWorkflow.matchesRaw(restoredForeignStock, rawRow)) {
|
||||
throw new Error("해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
const roundTrip = resolveNamedPlaylistRawRow(rawRow, itemIndex);
|
||||
if (!roundTrip || roundTrip.builderKey !== entry.builderKey || roundTrip.code !== entry.code) {
|
||||
throw new Error("원본 PList 7필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다.");
|
||||
@@ -5225,6 +5388,27 @@
|
||||
function isTrustedNamedPlaylistEntry(entry, itemIndex = 0) {
|
||||
const guard = state.namedPlaylist.guardByEntryId.get(entry?.id);
|
||||
if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false;
|
||||
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||
if (!guard || !guard.trusted) return false;
|
||||
try {
|
||||
const legacySelection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry);
|
||||
const fields = namedPlaylistWorkflow.toLegacySevenFields(
|
||||
{ ...entry, selection: legacySelection },
|
||||
namedPageTextForEntry(entry));
|
||||
return namedKrxThemeRestoreWorkflow.matchesRaw(entry, {
|
||||
itemIndex,
|
||||
enabled: fields[0] === "1",
|
||||
groupCode: fields[1],
|
||||
subject: fields[2],
|
||||
graphicType: fields[3],
|
||||
subtype: fields[4],
|
||||
pageText: fields[5],
|
||||
dataCode: fields[6]
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||
if (!guard || !guard.trusted) return false;
|
||||
try {
|
||||
@@ -5248,19 +5432,24 @@
|
||||
}
|
||||
if (entry?.operator?.source === "legacy-foreign-index-candle-workflow" &&
|
||||
(!guard || !guard.trusted)) return false;
|
||||
if (entry?.operator?.source === "legacy-named-foreign-stock-workflow" &&
|
||||
(!guard || !guard.trusted)) return false;
|
||||
if (namedManualRestoreWorkflow.isTrustedEntryForSave(entry, itemIndex)) return true;
|
||||
const selection = entry?.selection;
|
||||
if (!selection) return false;
|
||||
const now = namedNowForRawRow(selection);
|
||||
const restored = entry.operator?.source === "legacy-foreign-index-candle-workflow"
|
||||
? [foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
|
||||
: normalizeStoredPlaylist([entry], { now });
|
||||
: entry.operator?.source === "legacy-named-foreign-stock-workflow"
|
||||
? [namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
|
||||
: normalizeStoredPlaylist([entry], { now });
|
||||
if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey ||
|
||||
restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false;
|
||||
if ([
|
||||
"legacy-comparison-workflow",
|
||||
"legacy-industry-workflow",
|
||||
"legacy-foreign-index-candle-workflow"
|
||||
"legacy-foreign-index-candle-workflow",
|
||||
"legacy-named-foreign-stock-workflow"
|
||||
]
|
||||
.includes(entry.operator?.source)) {
|
||||
const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry);
|
||||
@@ -5287,7 +5476,9 @@
|
||||
? comparisonWorkflow.restoreComparisonPlaylistEntry(entry)
|
||||
: entry.operator.source === "legacy-industry-workflow"
|
||||
? industryWorkflow.restoreIndustryPlaylistEntry(entry)
|
||||
: foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry);
|
||||
: entry.operator.source === "legacy-foreign-index-candle-workflow"
|
||||
? foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)
|
||||
: namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
|
||||
return Boolean(restoredClosedEntry && restoredClosedEntry.builderKey === entry.builderKey &&
|
||||
restoredClosedEntry.code === entry.code &&
|
||||
legacyNamedRowWorkflow.matchesCanonicalEntry(restoredClosedEntry, rawRow));
|
||||
@@ -5319,7 +5510,9 @@
|
||||
function namedPlaylistBusy() {
|
||||
return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
|
||||
state.namedPlaylist.comparisonRestorePending ||
|
||||
state.namedPlaylist.foreignStockRestorePending ||
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
||||
state.namedPlaylist.krxThemeRestorePending ||
|
||||
state.namedPlaylist.nxtThemeRestorePending ||
|
||||
state.manualFinancialRestore.entries.size > 0);
|
||||
}
|
||||
@@ -5330,7 +5523,9 @@
|
||||
const pending = workflow.pending || workflow.pagePlanPending;
|
||||
const freshRestorePending = state.manualFinancialRestore.entries.size > 0 ||
|
||||
Boolean(workflow.comparisonRestorePending) ||
|
||||
Boolean(workflow.foreignStockRestorePending) ||
|
||||
Boolean(workflow.foreignIndexCandleRestorePending) ||
|
||||
Boolean(workflow.krxThemeRestorePending) ||
|
||||
Boolean(workflow.nxtThemeRestorePending);
|
||||
const busy = Boolean(pending) || freshRestorePending;
|
||||
const selected = selectedNamedPlaylistDefinition();
|
||||
@@ -5619,6 +5814,16 @@
|
||||
});
|
||||
return pending ? [pending] : [];
|
||||
});
|
||||
const pendingForeignStocks = restoration.rows.flatMap(row => {
|
||||
if (row.trusted) return [];
|
||||
const pending = namedForeignStockRestoreWorkflow.classify(row.rawRow, {
|
||||
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
|
||||
fadeDuration: DEFAULT_FADE_DURATION,
|
||||
ma5: state.candleOptions.ma5,
|
||||
ma20: state.candleOptions.ma20
|
||||
});
|
||||
return pending ? [pending] : [];
|
||||
});
|
||||
const pendingForeignIndexCandles = restoration.rows.flatMap(row => {
|
||||
if (row.trusted) return [];
|
||||
const pending = foreignIndexCandleRestoreWorkflow.classify(row.rawRow, {
|
||||
@@ -5627,6 +5832,14 @@
|
||||
});
|
||||
return pending ? [pending] : [];
|
||||
});
|
||||
const pendingKrxThemes = restoration.rows.flatMap(row => {
|
||||
if (row.trusted) return [];
|
||||
const pending = namedKrxThemeRestoreWorkflow.classify(row.rawRow, {
|
||||
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
|
||||
fadeDuration: DEFAULT_FADE_DURATION
|
||||
});
|
||||
return pending ? [pending] : [];
|
||||
});
|
||||
const pendingNxtThemes = restoration.rows.flatMap(row => {
|
||||
if (row.trusted) return [];
|
||||
const pending = namedNxtThemeRestoreWorkflow.classify(row.rawRow, {
|
||||
@@ -5637,7 +5850,9 @@
|
||||
});
|
||||
state.namedPlaylist.manualRestoreFinalized =
|
||||
pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 &&
|
||||
pendingForeignIndexCandles.length === 0 && pendingNxtThemes.length === 0;
|
||||
pendingForeignStocks.length === 0 &&
|
||||
pendingForeignIndexCandles.length === 0 && pendingKrxThemes.length === 0 &&
|
||||
pendingNxtThemes.length === 0;
|
||||
if (state.namedPlaylist.manualRestoreFinalized) {
|
||||
restoration = prepareNamedPagePreflight(restoration);
|
||||
}
|
||||
@@ -5652,12 +5867,26 @@
|
||||
rows: Object.freeze(pendingNamedComparison)
|
||||
})
|
||||
: null;
|
||||
state.namedPlaylist.foreignStockRestorePending = pendingForeignStocks.length
|
||||
? {
|
||||
requestId: load.requestId,
|
||||
rows: Object.freeze(pendingForeignStocks),
|
||||
timeoutId: null
|
||||
}
|
||||
: null;
|
||||
armNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending = pendingForeignIndexCandles.length
|
||||
? Object.freeze({
|
||||
requestId: load.requestId,
|
||||
rows: Object.freeze(pendingForeignIndexCandles)
|
||||
})
|
||||
: null;
|
||||
state.namedPlaylist.krxThemeRestorePending = pendingKrxThemes.length
|
||||
? Object.freeze({
|
||||
requestId: load.requestId,
|
||||
rows: Object.freeze(pendingKrxThemes)
|
||||
})
|
||||
: null;
|
||||
state.namedPlaylist.nxtThemeRestorePending = pendingNxtThemes.length
|
||||
? Object.freeze({
|
||||
requestId: load.requestId,
|
||||
@@ -5776,6 +6005,115 @@
|
||||
error?.message || "비교 행의 현재 종목 마스터 재검증을 완료하지 못했습니다.");
|
||||
}
|
||||
|
||||
function clearNamedForeignStockRestoreTimeout(record) {
|
||||
if (record?.timeoutId !== null && record?.timeoutId !== undefined) {
|
||||
clearTimeout(record.timeoutId);
|
||||
record.timeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function armNamedForeignStockRestoreTimeout(record) {
|
||||
if (!record) return;
|
||||
clearNamedForeignStockRestoreTimeout(record);
|
||||
record.timeoutId = setTimeout(() => {
|
||||
if (state.namedPlaylist.foreignStockRestorePending !== record) return;
|
||||
failNamedForeignStockRestore(
|
||||
record,
|
||||
"해외종목 identity 재검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.");
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function failNamedForeignStockRestore(record, message) {
|
||||
if (!record || state.namedPlaylist.foreignStockRestorePending !== record) return;
|
||||
clearNamedForeignStockRestoreTimeout(record);
|
||||
state.namedPlaylist.foreignStockRestorePending = null;
|
||||
state.namedPlaylist.error = message;
|
||||
state.namedPlaylist.status = "error";
|
||||
finalizeNamedManualRestores();
|
||||
renderPlaylist();
|
||||
renderNamedPlaylist();
|
||||
renderPlayout();
|
||||
}
|
||||
|
||||
function handleNamedForeignStockRestoreResults(payload) {
|
||||
const record = state.namedPlaylist.foreignStockRestorePending;
|
||||
if (!record || payload?.requestId !== record.requestId) return;
|
||||
const response = namedForeignStockRestoreWorkflow.normalizeResponse(
|
||||
payload,
|
||||
record.requestId,
|
||||
record.rows);
|
||||
if (!response) {
|
||||
failNamedForeignStockRestore(
|
||||
record,
|
||||
"Native Bridge의 해외종목 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
const restoration = state.namedPlaylist.restoration;
|
||||
if (!restoration || isPlaylistSnapshotLocked()) {
|
||||
failNamedForeignStockRestore(
|
||||
record,
|
||||
"해외종목 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [...restoration.rows];
|
||||
let restoredCount = 0;
|
||||
const failures = new Map();
|
||||
for (let index = 0; index < response.rows.length; index += 1) {
|
||||
const pending = record.rows[index];
|
||||
const outcome = response.rows[index];
|
||||
if (outcome.status === "failed") {
|
||||
failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
|
||||
continue;
|
||||
}
|
||||
const storedRow = rows[pending.itemIndex];
|
||||
const entry = namedForeignStockRestoreWorkflow.materialize(pending, outcome);
|
||||
if (!storedRow || storedRow.trusted || !entry ||
|
||||
!namedForeignStockRestoreWorkflow.isMaterializedEntry(entry) ||
|
||||
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
|
||||
entry.enabled !== storedRow.rawRow.enabled ||
|
||||
!namedForeignStockRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
|
||||
failures.set("MATERIALIZATION_MISMATCH",
|
||||
(failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
|
||||
continue;
|
||||
}
|
||||
rows[pending.itemIndex] = Object.freeze({
|
||||
...storedRow,
|
||||
entry,
|
||||
trusted: true
|
||||
});
|
||||
state.playlist[pending.itemIndex] = entry;
|
||||
restoredCount += 1;
|
||||
}
|
||||
state.namedPlaylist.restoration = Object.freeze({
|
||||
...restoration,
|
||||
rows: Object.freeze(rows)
|
||||
});
|
||||
clearNamedForeignStockRestoreTimeout(record);
|
||||
state.namedPlaylist.foreignStockRestorePending = null;
|
||||
addLog(`DB 해외종목 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`);
|
||||
if (failures.size) {
|
||||
const summary = [...failures.entries()]
|
||||
.map(([code, count]) => `${code} ${count}`)
|
||||
.join(" · ");
|
||||
addLog(`DB 해외종목 차단 사유 · ${summary}`);
|
||||
}
|
||||
finalizeNamedManualRestores();
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
renderNamedPlaylist();
|
||||
renderPlayout();
|
||||
}
|
||||
|
||||
function handleNamedForeignStockRestoreError(payload) {
|
||||
const record = state.namedPlaylist.foreignStockRestorePending;
|
||||
if (!record || payload?.requestId !== record.requestId) return;
|
||||
const error = namedForeignStockRestoreWorkflow.normalizeError(payload, record.requestId);
|
||||
failNamedForeignStockRestore(
|
||||
record,
|
||||
error?.message || "해외종목의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
|
||||
}
|
||||
|
||||
function failNamedForeignIndexCandleRestore(record, message) {
|
||||
if (!record || state.namedPlaylist.foreignIndexCandleRestorePending !== record) return;
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
||||
@@ -5865,6 +6203,97 @@
|
||||
error?.message || "해외지수 캔들의 현재 master 재검증을 완료하지 못했습니다.");
|
||||
}
|
||||
|
||||
function failNamedKrxThemeRestore(record, message) {
|
||||
if (!record || state.namedPlaylist.krxThemeRestorePending !== record) return;
|
||||
state.namedPlaylist.krxThemeRestorePending = null;
|
||||
state.namedPlaylist.error = message;
|
||||
state.namedPlaylist.status = "error";
|
||||
finalizeNamedManualRestores();
|
||||
renderPlaylist();
|
||||
renderNamedPlaylist();
|
||||
renderPlayout();
|
||||
}
|
||||
|
||||
function handleNamedKrxThemeRestoreResults(payload) {
|
||||
const record = state.namedPlaylist.krxThemeRestorePending;
|
||||
if (!record || payload?.requestId !== record.requestId) return;
|
||||
const response = namedKrxThemeRestoreWorkflow.normalizeResponse(
|
||||
payload,
|
||||
record.requestId,
|
||||
record.rows);
|
||||
if (!response) {
|
||||
failNamedKrxThemeRestore(
|
||||
record,
|
||||
"Native Bridge의 KRX 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
const restoration = state.namedPlaylist.restoration;
|
||||
if (!restoration || isPlaylistSnapshotLocked()) {
|
||||
failNamedKrxThemeRestore(
|
||||
record,
|
||||
"KRX 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [...restoration.rows];
|
||||
let restoredCount = 0;
|
||||
let remappedCount = 0;
|
||||
const failures = new Map();
|
||||
for (let index = 0; index < response.rows.length; index += 1) {
|
||||
const pending = record.rows[index];
|
||||
const outcome = response.rows[index];
|
||||
if (outcome.status === "failed") {
|
||||
failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
|
||||
continue;
|
||||
}
|
||||
const storedRow = rows[pending.itemIndex];
|
||||
const entry = namedKrxThemeRestoreWorkflow.materialize(pending, outcome);
|
||||
if (!storedRow || storedRow.trusted || !entry ||
|
||||
!namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry) ||
|
||||
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
|
||||
entry.enabled !== storedRow.rawRow.enabled ||
|
||||
!namedKrxThemeRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
|
||||
failures.set("MATERIALIZATION_MISMATCH",
|
||||
(failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
|
||||
continue;
|
||||
}
|
||||
rows[pending.itemIndex] = Object.freeze({
|
||||
...storedRow,
|
||||
entry,
|
||||
trusted: true
|
||||
});
|
||||
state.playlist[pending.itemIndex] = entry;
|
||||
restoredCount += 1;
|
||||
if (outcome.codeWasRemapped) remappedCount += 1;
|
||||
}
|
||||
state.namedPlaylist.restoration = Object.freeze({
|
||||
...restoration,
|
||||
rows: Object.freeze(rows)
|
||||
});
|
||||
state.namedPlaylist.krxThemeRestorePending = null;
|
||||
addLog(`DB KRX 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}건`);
|
||||
if (failures.size) {
|
||||
const summary = [...failures.entries()]
|
||||
.map(([code, count]) => `${code} ${count}`)
|
||||
.join(" · ");
|
||||
addLog(`DB KRX 테마 차단 사유 · ${summary}`);
|
||||
}
|
||||
finalizeNamedManualRestores();
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
renderNamedPlaylist();
|
||||
renderPlayout();
|
||||
}
|
||||
|
||||
function handleNamedKrxThemeRestoreError(payload) {
|
||||
const record = state.namedPlaylist.krxThemeRestorePending;
|
||||
if (!record || payload?.requestId !== record.requestId) return;
|
||||
const error = namedKrxThemeRestoreWorkflow.normalizeError(payload, record.requestId);
|
||||
failNamedKrxThemeRestore(
|
||||
record,
|
||||
error?.message || "KRX 테마의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
|
||||
}
|
||||
|
||||
function failNamedNxtThemeRestore(record, message) {
|
||||
if (!record || state.namedPlaylist.nxtThemeRestorePending !== record) return;
|
||||
state.namedPlaylist.nxtThemeRestorePending = null;
|
||||
@@ -6084,7 +6513,48 @@
|
||||
function handleNamedPlaylistError(payload) {
|
||||
const error = namedPlaylistWorkflow.normalizeError(payload);
|
||||
const pending = state.namedPlaylist.pending;
|
||||
if (!error || !pending || error.requestId !== pending.requestId || error.operation !== pending.operation) return;
|
||||
if (!error) return;
|
||||
if (!pending || error.requestId !== pending.requestId || error.operation !== pending.operation) {
|
||||
// A load result is posted before its correlated restore batches. If the
|
||||
// native read is then canceled (for example by playout priority), the
|
||||
// primary load is no longer pending but one or more restore batches can
|
||||
// still be waiting. Close every matching batch exactly once so PREPARE
|
||||
// stays blocked by the untrusted rows instead of an orphaned busy flag.
|
||||
if (error.operation !== "load") return;
|
||||
const restoreRecords = [
|
||||
state.namedPlaylist.comparisonRestorePending,
|
||||
state.namedPlaylist.foreignStockRestorePending,
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending,
|
||||
state.namedPlaylist.krxThemeRestorePending,
|
||||
state.namedPlaylist.nxtThemeRestorePending
|
||||
];
|
||||
if (!restoreRecords.some(record => record?.requestId === error.requestId)) return;
|
||||
if (state.namedPlaylist.comparisonRestorePending?.requestId === error.requestId) {
|
||||
state.namedPlaylist.comparisonRestorePending = null;
|
||||
}
|
||||
if (state.namedPlaylist.foreignStockRestorePending?.requestId === error.requestId) {
|
||||
clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
|
||||
state.namedPlaylist.foreignStockRestorePending = null;
|
||||
}
|
||||
if (state.namedPlaylist.foreignIndexCandleRestorePending?.requestId === error.requestId) {
|
||||
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
||||
}
|
||||
if (state.namedPlaylist.krxThemeRestorePending?.requestId === error.requestId) {
|
||||
state.namedPlaylist.krxThemeRestorePending = null;
|
||||
}
|
||||
if (state.namedPlaylist.nxtThemeRestorePending?.requestId === error.requestId) {
|
||||
state.namedPlaylist.nxtThemeRestorePending = null;
|
||||
}
|
||||
state.namedPlaylist.status = "error";
|
||||
state.namedPlaylist.error = `${error.message} [${error.code}] 복원 배치는 자동 재시도하지 않습니다.`;
|
||||
finalizeNamedManualRestores();
|
||||
renderPlaylist();
|
||||
renderNamedPlaylist();
|
||||
renderPlayout();
|
||||
addLog(`DB 플레이리스트 LOAD 후속 복원 종료 · ${error.code}`);
|
||||
showToast(state.namedPlaylist.error);
|
||||
return;
|
||||
}
|
||||
state.namedPlaylist.pending = null;
|
||||
state.namedPlaylist.status = "error";
|
||||
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined;
|
||||
@@ -7132,11 +7602,16 @@
|
||||
}
|
||||
|
||||
function applyLiveRowToCurrentCut(table, row, columns) {
|
||||
if (!requirePlaylistEditable()) return;
|
||||
const item = state.playlist[state.currentIndex];
|
||||
if (!item) {
|
||||
showToast("먼저 적용할 플레이리스트 장면을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
if (playlistEntryManualEditBlockReason(item)) {
|
||||
showToast("검증된 항목에는 DB 행을 직접 덮어쓸 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const values = liveRowValues(row, columns);
|
||||
const subject = firstLiveValue(values, [
|
||||
@@ -7152,18 +7627,20 @@
|
||||
}
|
||||
|
||||
const previousSelection = item.selection || {};
|
||||
item.selection = {
|
||||
const replacement = recreatePlaylistEntryWithLiveSelection(item, {
|
||||
groupCode: liveSelectionGroup(table, item),
|
||||
subject: subject || dataCode,
|
||||
graphicType: String(previousSelection.graphicType || item.graphicType || ""),
|
||||
subtype: String(previousSelection.subtype || item.subtype || ""),
|
||||
dataCode
|
||||
};
|
||||
item.subject = item.selection.subject;
|
||||
item.dataCode = dataCode;
|
||||
});
|
||||
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
|
||||
showToast("선택한 DB 행을 현재 장면에 안전하게 적용할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
addLog(`${item.code} DB 선택 적용 · ${item.selection.subject}`);
|
||||
addLog(`${replacement.code} DB 선택 적용 · ${replacement.selection.subject}`);
|
||||
showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
|
||||
}
|
||||
|
||||
@@ -7490,12 +7967,24 @@
|
||||
case "named-comparison-restore-error":
|
||||
handleNamedComparisonRestoreError(message.payload);
|
||||
break;
|
||||
case "named-foreign-stock-restore-results":
|
||||
handleNamedForeignStockRestoreResults(message.payload);
|
||||
break;
|
||||
case "named-foreign-stock-restore-error":
|
||||
handleNamedForeignStockRestoreError(message.payload);
|
||||
break;
|
||||
case "named-foreign-index-candle-restore-results":
|
||||
handleNamedForeignIndexCandleRestoreResults(message.payload);
|
||||
break;
|
||||
case "named-foreign-index-candle-restore-error":
|
||||
handleNamedForeignIndexCandleRestoreError(message.payload);
|
||||
break;
|
||||
case "named-krx-theme-restore-results":
|
||||
handleNamedKrxThemeRestoreResults(message.payload);
|
||||
break;
|
||||
case "named-krx-theme-restore-error":
|
||||
handleNamedKrxThemeRestoreError(message.payload);
|
||||
break;
|
||||
case "named-nxt-theme-restore-results":
|
||||
handleNamedNxtThemeRestoreResults(message.payload);
|
||||
break;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
402
Web/named-foreign-stock-restore-workflow.js
Normal file
402
Web/named-foreign-stock-restore-workflow.js
Normal 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
|
||||
};
|
||||
});
|
||||
279
Web/named-krx-theme-restore-workflow.js
Normal file
279
Web/named-krx-theme-restore-workflow.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
64
docs/EXTERNAL_ASSET_SEARCH_AUDIT.md
Normal file
64
docs/EXTERNAL_ASSET_SEARCH_AUDIT.md
Normal 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로 유지한다.
|
||||
@@ -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 검사 보강 기준
|
||||
|
||||
|
||||
136
docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md
Normal file
136
docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md
Normal 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 버튼이다. 비교쌍은
|
||||
상단 `비교` 업무 탭 안의 `원본 비교쌍 가져오기` 버튼으로 진입한다.
|
||||
@@ -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
|
||||
|
||||
118
docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md
Normal file
118
docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md
Normal 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
|
||||
승인 회차에서만 수행한다.
|
||||
178
docs/NAMED_PLAYLIST_READ_AUDIT.md
Normal file
178
docs/NAMED_PLAYLIST_READ_AUDIT.md
Normal 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 값이나
|
||||
인증정보도 출력하지 않았다.
|
||||
72
docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md
Normal file
72
docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md
Normal 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`여야 한다.
|
||||
@@ -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을 승인 없이 실행하지 않는다. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
487
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs
Normal file
487
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs
Normal 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();
|
||||
}
|
||||
511
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs
Normal file
511
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs
Normal 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))
|
||||
};
|
||||
}
|
||||
341
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs
Normal file
341
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs
Normal 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();
|
||||
}
|
||||
524
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs
Normal file
524
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal file
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal file
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal file
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal file
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal file
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal file
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
802
tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs
Normal file
802
tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs
Normal 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")
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user