"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const workflow = require("../../Web/theme-workflow.js"); const krxTheme = Object.freeze({ market: "krx", themeCode: "00000001", title: "AI 반도체", displayTitle: "AI 반도체", session: null, itemCount: 17 }); const nxtPreTheme = Object.freeze({ market: "nxt", themeCode: "00000002", title: "로봇", displayTitle: "로봇(NXT)", session: "PRE_MARKET", itemCount: 121 }); const nxtAfterTheme = Object.freeze({ ...nxtPreTheme, session: "AFTER_MARKET" }); test("theme workflow exposes the UC4 and closed resolver contract", () => { assert.deepEqual(workflow.sourceContract, { originalControl: "Control/UC4.cs", krxResolverGroup: "PAGED_THEME", nxtResolverGroup: "PAGED_NXT_THEME", maximumPageCount: 20, maximumPreviewItems: 240 }); assert.deepEqual(workflow.themeSorts, [ { id: "INPUT_ORDER", label: "입력순" }, { id: "GAIN_DESC", label: "상승률순" }, { id: "GAIN_ASC", label: "하락률순" } ]); assert.deepEqual(workflow.nxtSessions.map(value => value.id), ["PRE_MARKET", "AFTER_MARKET"]); assert.deepEqual(workflow.actions.map(value => [value.id, value.builderKey, value.code, value.pageSize]), [ ["five-row-current", "s5074", "5074", 5], ["five-row-expected", "s5074", "5074", 5], ["six-row-current", "s5077", "5077", 6], ["twelve-row-current", "s5088", "5088", 12] ]); }); test("KRX and NXT database identities normalize without inferring market from a suffix", () => { assert.deepEqual(workflow.normalizeTheme({ Market: "Krx", ThemeCode: "00000001", ThemeTitle: "AI 반도체", Session: "NotApplicable", previewItemCount: 17 }), krxTheme); assert.equal(workflow.normalizeTheme({ market: "krx", themeCode: "123", title: "Legacy" }).themeCode, "123"); assert.equal(workflow.normalizeTheme({ market: "krx", themeCode: "0123", title: "Legacy" }).themeCode, "0123"); assert.deepEqual(workflow.normalizeTheme({ market: "NXT", themeCode: "00000002", title: "로봇", session: "PreMarket", items: new Array(121) }), nxtPreTheme); assert.deepEqual(workflow.normalizeTheme({ market: "nxt", code: "00000002", themeTitle: "로봇", session: "after-market", itemCount: 121 }), nxtAfterTheme); assert.equal(workflow.normalizeTheme({ market: "nxt", themeCode: "00000002", title: "로봇(NXT)", session: "PRE_MARKET" }), null); assert.equal(workflow.normalizeTheme({ market: "krx", themeCode: "00000001", title: " AI 반도체 " }), null); }); test("typed theme validation reports clear market, code, session and preview failures", () => { assert.equal(workflow.getCompatibility("five-row-current", { market: "other", themeCode: "00000001", title: "AI" }, "INPUT_ORDER").reason, "invalid-theme-market"); assert.equal(workflow.getCompatibility("five-row-current", { market: "krx", themeCode: "12", title: "AI" }, "INPUT_ORDER").reason, "invalid-theme-code"); assert.equal(workflow.getCompatibility("five-row-current", { market: "krx", themeCode: " 00000001 ", title: "AI" }, "INPUT_ORDER").reason, "invalid-theme-code"); assert.equal(workflow.getCompatibility("five-row-current", { market: "nxt", themeCode: "00000001", title: "AI" }, "INPUT_ORDER").reason, "nxt-session-required"); assert.equal(workflow.getCompatibility("five-row-current", { market: "krx", themeCode: "00000001", title: "AI", session: "PRE_MARKET" }, "INPUT_ORDER").reason, "krx-session-must-be-empty"); assert.equal(workflow.getCompatibility("five-row-current", { ...krxTheme, itemCount: 241 }, "INPUT_ORDER").reason, "invalid-theme-item-count"); assert.equal(workflow.getCompatibility("five-row-current", krxTheme, "RANDOM").reason, "invalid-theme-sort"); }); test("only three current row sizes and KRX five-row expected are compatible", () => { for (const actionId of ["five-row-current", "six-row-current", "twelve-row-current"]) { for (const sort of workflow.themeSorts) { assert.equal(workflow.isActionAllowed(actionId, krxTheme, sort.id), true); assert.equal(workflow.isActionAllowed(actionId, nxtPreTheme, sort.id), true); assert.equal(workflow.isActionAllowed(actionId, nxtAfterTheme, sort.id), true); } } assert.equal(workflow.isActionAllowed("five-row-expected", krxTheme, "INPUT_ORDER"), true); assert.equal(workflow.getCompatibility("five-row-expected", nxtPreTheme, "INPUT_ORDER").reason, "expected-theme-is-krx-only"); assert.equal(workflow.getCompatibility("six-row-expected", krxTheme, "INPUT_ORDER").reason, "unknown-action"); assert.equal(workflow.getCompatibility("five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER").reason, "theme-preview-is-empty"); }); test("KRX current and expected selections map sort and value kind to separate fields", () => { const current = workflow.buildThemeSelection("five-row-current", krxTheme, "GAIN_DESC"); assert.equal(current.builderKey, "s5074"); assert.equal(current.code, "5074"); assert.deepEqual(current.aliases, ["5074"]); assert.deepEqual(current.selection, { groupCode: "PAGED_THEME", subject: "AI 반도체", graphicType: "GAIN_DESC", subtype: "CURRENT", dataCode: "00000001" }); const expected = workflow.buildThemeSelection("five-row-expected", krxTheme, "GAIN_ASC"); assert.equal(expected.builderKey, "s5074"); assert.deepEqual(expected.selection, { groupCode: "PAGED_THEME", subject: "AI 반도체", graphicType: "GAIN_ASC", subtype: "EXPECTED", dataCode: "00000001" }); }); test("NXT selections map explicit session to GraphicType and sort to Subtype", () => { const five = workflow.buildThemeSelection("five-row-current", nxtPreTheme, "INPUT_ORDER"); assert.deepEqual(five.selection, { groupCode: "PAGED_NXT_THEME", subject: "로봇(NXT)", graphicType: "PRE_MARKET", subtype: "INPUT_ORDER", dataCode: "00000002" }); const six = workflow.buildThemeSelection("six-row-current", nxtAfterTheme, "GAIN_DESC"); assert.equal(six.builderKey, "s5077"); assert.equal(six.code, "5077"); assert.equal(six.selection.graphicType, "AFTER_MARKET"); assert.equal(six.selection.subtype, "GAIN_DESC"); const twelve = workflow.buildThemeSelection("twelve-row-current", nxtAfterTheme, "GAIN_ASC"); assert.equal(twelve.builderKey, "s5088"); assert.equal(twelve.code, "5088"); assert.deepEqual(twelve.aliases, ["5088"]); assert.equal(twelve.selection.subtype, "GAIN_ASC"); }); test("item-count previews calculate 5, 6 and 12 row pages within the 20-page boundary", () => { assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 17 }), { itemCount: 17, accessibleItemCount: 17, pageSize: 5, pageCount: 4, maximumPageCount: 20, isTruncated: false }); assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 101 }), { itemCount: 101, accessibleItemCount: 100, pageSize: 5, pageCount: 20, maximumPageCount: 20, isTruncated: true }); assert.deepEqual(workflow.calculatePagePreview("six-row-current", { ...krxTheme, itemCount: 120 }), { itemCount: 120, accessibleItemCount: 120, pageSize: 6, pageCount: 20, maximumPageCount: 20, isTruncated: false }); assert.deepEqual(workflow.calculatePagePreview("twelve-row-current", { ...krxTheme, itemCount: 240 }), { itemCount: 240, accessibleItemCount: 240, pageSize: 12, pageCount: 20, maximumPageCount: 20, isTruncated: false }); assert.deepEqual(workflow.calculatePagePreview("five-row-current", { market: "krx", themeCode: "00000001", title: "AI 반도체" }), { itemCount: null, accessibleItemCount: null, pageSize: 5, pageCount: null, maximumPageCount: 20, isTruncated: false }); }); test("playlist entries preserve the alias, preview and explicit theme identity", () => { const entry = workflow.createThemePlaylistEntry("six-row-current", nxtAfterTheme, "GAIN_DESC", { id: "nxt-theme", fadeDuration: 4 }); assert.equal(entry.builderKey, "s5077"); assert.equal(entry.code, "5077"); assert.equal(entry.title, "로봇(NXT)"); assert.equal(entry.detail, "6종목 현재가 · 상승률순"); assert.equal(entry.pageSize, 6); assert.equal(entry.pagePreview.pageCount, 20); assert.equal(entry.pagePreview.isTruncated, true); assert.equal(entry.fadeDuration, 4); assert.deepEqual(entry.operator.theme, nxtAfterTheme); assert.equal(entry.operator.resolverGroup, "PAGED_NXT_THEME"); }); test("trusted theme restore reconstructs fields and rejects action, identity and preview tampering", () => { const original = workflow.createThemePlaylistEntry("five-row-expected", krxTheme, "GAIN_ASC", { id: "stored-theme", fadeDuration: 8 }); assert.ok(workflow.restoreThemePlaylistEntry(original)); assert.equal(workflow.restoreThemePlaylistEntry({ ...original, enabled: false }).enabled, false); assert.equal(workflow.restoreThemePlaylistEntry({ ...original, code: "5088" }), null); assert.equal(workflow.restoreThemePlaylistEntry({ ...original, selection: { ...original.selection, subtype: "CURRENT" } }), null); assert.equal(workflow.restoreThemePlaylistEntry({ ...original, pagePreview: { ...original.pagePreview, pageCount: 20 } }), null); assert.equal(workflow.restoreThemePlaylistEntry({ ...original, operator: { ...original.operator, schemaVersion: 2 } }), null); assert.equal(workflow.restoreThemePlaylistEntry({ ...original, operator: { ...original.operator, theme: { ...original.operator.theme, themeCode: "00000003" } } }), null); assert.equal(workflow.restoreThemePlaylistEntry({ ...original, operator: { ...original.operator, resolverGroup: "PAGED_NXT_THEME" } }), null); }); test("unknown actions, unsafe ids, invalid fades and unsupported known-empty themes cannot be created", () => { assert.throws(() => workflow.createThemePlaylistEntry("unknown", krxTheme, "INPUT_ORDER", { id: "unknown" }), /unknown/i); assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", { id: "bad id" }), /safe theme playlist id/i); assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", { id: "bad-fade", fadeDuration: 61 }), /0 through 60/i); assert.throws(() => workflow.createThemePlaylistEntry( "five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER", { id: "empty" }), /theme-preview-is-empty/i); assert.throws(() => workflow.createThemePlaylistEntry( "five-row-expected", nxtPreTheme, "INPUT_ORDER", { id: "nxt-expected" }), /expected-theme-is-krx-only/i); });