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