Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

View File

@@ -0,0 +1,242 @@
"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.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: "1", 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);
});