250 lines
9.4 KiB
JavaScript
250 lines
9.4 KiB
JavaScript
"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);
|
|
});
|