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,326 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/operator-workflow.js");
const legacyLabels = [
"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일",
"호가창_표그래프",
"거래원_표그래프"
];
const kospiStock = Object.freeze({
market: "kospi",
stockName: "테스트전자",
displayName: "테스트전자",
stockCode: "A123456",
isNxt: false
});
test("stock catalog is pinned to the 31-row runtime 종목.ini workflow", () => {
assert.deepEqual(workflow.runtimeStockAsset, {
relativePath: "bin/Debug/Res/종목.ini",
sha256: "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20"
});
assert.equal(workflow.stockCuts.length, 31);
assert.deepEqual(workflow.stockCuts.map(cut => cut.label), legacyLabels);
assert.equal(new Set(workflow.stockCuts.map(cut => cut.id)).size, 31);
assert.equal(workflow.stockCuts.some(cut => cut.label === "-"), false);
for (const cut of workflow.stockCuts) {
assert.equal(typeof cut.id, "string");
assert.equal(typeof cut.section, "string");
assert.equal(typeof cut.group, "string");
assert.equal(typeof cut.detail, "string");
assert.match(cut.builderKey, /^s\d+$/);
assert.match(cut.alias, /^[A-Z]?\d+$/);
assert.equal(cut.cutCode, cut.alias);
assert.equal(cut.aliases.includes(cut.alias), true);
}
});
test("native stock search rows normalize to a typed stock without sample fallbacks", () => {
assert.deepEqual(
workflow.normalizeStockResult({
market: "kospi",
source: "oracle",
name: "실제조회종목",
code: "005930"
}),
{
market: "kospi",
stockName: "실제조회종목",
displayName: "실제조회종목",
stockCode: "005930",
isNxt: false
});
assert.deepEqual(
workflow.normalizeStockResult({
market: "nxt-kosdaq",
source: "mariaDb",
name: "NXT조회종목",
code: "123456"
}),
{
market: "kosdaq",
stockName: "NXT조회종목",
displayName: "NXT조회종목(NXT)",
stockCode: "123456",
isNxt: true
});
assert.deepEqual(
workflow.normalizeStockResult({
market: "코스피_NXT",
stockName: "표시종목(NXT)",
displayName: "표시종목(NXT)",
stockCode: "A000001",
isNxt: true
}),
{
market: "kospi",
stockName: "표시종목",
displayName: "표시종목(NXT)",
stockCode: "A000001",
isNxt: true
});
assert.equal(workflow.normalizeStockResult(null), null);
assert.equal(workflow.normalizeStockResult({}), null);
assert.equal(workflow.normalizeStockResult({ market: "kospi", name: "", code: "005930" }), null);
assert.equal(workflow.normalizeStockResult({ market: "overseas", name: "종목", code: "005930" }), null);
assert.equal(workflow.normalizeStockResult({ market: "kospi", name: "종목", code: "../cut" }), null);
assert.equal(workflow.normalizeStockResult({ market: "nxt-kospi", name: "종목", code: "005930", isNxt: false }), null);
});
test("NXT selection permits only 1열판기본_현재가", () => {
const nxt = {
market: "nxt-kospi",
stockName: "NXT테스트",
displayName: "NXT테스트(NXT)",
stockCode: "123456",
isNxt: true
};
for (const cut of workflow.stockCuts) {
assert.equal(
workflow.isStockCutAllowed(nxt, cut.id),
cut.id === "basic-current",
cut.label);
}
assert.equal(workflow.isStockCutAllowed(kospiStock, "not-a-cut"), false);
});
test("every regular stock cut maps to its closed builder, alias and LegacySceneSelection", () => {
const expected = [
["basic-expected", "s5001", "5001", "KOSPI", "1열판기본", "EXPECTED_OPENING"],
["basic-current", "s5001", "5001", "KOSPI", "1열판기본", "CURRENT"],
["basic-after-hours", "s5001", "5001", "KOSPI", "1열판기본", "AFTER_HOURS_SINGLE_PRICE"],
["detail-open", "s5006", "5006", "KOSPI_STOCK", "1열판상세", "시가"],
["detail-face-value", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "FaceValue"],
["detail-pbr", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "Valuation"],
["detail-volume", "s5011", "5011", "KOSPI_STOCK", "1열판상세", "Volume"],
["yield-5d", "s5086", "5086", "KOSPI", "수익률그래프", "5일"],
["yield-20d", "s5086", "5086", "KOSPI", "수익률그래프", "20일"],
["yield-60d", "s5086", "5086", "KOSPI", "수익률그래프", "60일"],
["yield-120d", "s5086", "5086", "KOSPI", "수익률그래프", "120일"],
["yield-240d", "s5086", "5086", "KOSPI", "수익률그래프", "240일"],
["candle-daily", "s8010", "8035", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "PRICE"],
["candle-volume-daily", "s8010", "8035", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-volume-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "VOLUME"],
["candle-expected-5d", "s8010", "8061", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-20d", "s8010", "8040", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-60d", "s8010", "8046", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-120d", "s8010", "8051", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["candle-expected-240d", "s8010", "8056", "KOSPI_STOCK", "MA5,MA20", "EXPECTED"],
["order-book", "s8003", "8003", "KOSPI", "ORDER_BOOK", "TABLE_GRAPH"],
["traders", "s5037", "5037", "KOSPI", "TRADER", "TABLE_GRAPH"]
];
assert.equal(expected.length, 31);
for (const [id, builderKey, code, groupCode, graphicType, subtype] of expected) {
const entry = workflow.createStockPlaylistEntry(kospiStock, id, {
id: `entry-${id}`,
ma5: true,
ma20: true,
fadeDuration: 8
});
assert.equal(entry.builderKey, builderKey, id);
assert.equal(entry.code, code, id);
assert.equal(entry.selection.groupCode, groupCode, id);
assert.equal(entry.selection.subject, "테스트전자", id);
assert.equal(entry.selection.graphicType, graphicType, id);
assert.equal(entry.selection.subtype, subtype, id);
assert.equal(entry.selection.dataCode, "A123456", id);
assert.equal(entry.category, "stock", id);
assert.equal(entry.market, "kospi", id);
assert.equal(entry.enabled, true, id);
assert.equal(entry.fadeDuration, 8, id);
assert.equal(entry.operator.cutId, id, id);
assert.equal(entry.operator.source, "legacy-stock-workflow", id);
}
});
test("moving-average flags are ordered and apply only to candle cuts", () => {
const ma20 = workflow.createStockPlaylistEntry(kospiStock, "candle-20d", {
id: "ma20",
ma5: false,
ma20: true
});
assert.equal(ma20.selection.graphicType, "MA20");
assert.deepEqual(ma20.operator.movingAverages, { ma5: false, ma20: true });
const none = workflow.createStockPlaylistEntry(kospiStock, "candle-5d", { id: "none" });
assert.equal(none.selection.graphicType, "");
const nonCandle = workflow.createStockPlaylistEntry(kospiStock, "basic-current", {
id: "quote",
ma5: true,
ma20: true
});
assert.equal(nonCandle.selection.graphicType, "1열판기본");
assert.deepEqual(nonCandle.operator.movingAverages, { ma5: false, ma20: false });
});
test("NXT current quote uses N5001 and the native NXT market selection", () => {
const entry = workflow.createStockPlaylistEntry({
market: "nxt-kosdaq",
stockName: "NXT테스트",
displayName: "NXT테스트",
stockCode: "654321",
isNxt: true
}, "basic-current", { id: "nxt-current" });
assert.equal(entry.builderKey, "s5001");
assert.equal(entry.code, "N5001");
assert.equal(entry.market, "kosdaq");
assert.equal(entry.title, "NXT테스트(NXT)");
assert.deepEqual(entry.selection, {
groupCode: "NXT_KOSDAQ",
subject: "NXT테스트(NXT)",
dataCode: "654321",
graphicType: "1열판기본",
subtype: "CURRENT"
});
assert.throws(
() => workflow.createStockPlaylistEntry(entry.operator.stock, "order-book", { id: "bad" }),
/NXT stocks support only/);
});
test("entry construction fails closed and never supplies a sample stock", () => {
assert.throws(
() => workflow.createStockPlaylistEntry({}, "basic-current", { id: "missing-stock" }),
/valid selected stock/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "missing-cut", { id: "missing-cut" }),
/unknown/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", {}),
/entry id/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "unsafe id" }),
/entry id/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "fade", fadeDuration: 61 }),
/Fade duration/);
assert.throws(
() => workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "flags", ma5: "yes" }),
/Moving-average flags/);
const entry = workflow.createStockPlaylistEntry(kospiStock, "basic-current", { id: "default-fade" });
assert.equal(entry.fadeDuration, 6);
assert.equal(entry.stockName, "테스트전자");
});
test("stored operator stock entries restore only when every trusted mapping still matches", () => {
const stored = workflow.createStockPlaylistEntry(kospiStock, "candle-volume-60d", {
id: "stored-candle",
ma5: true,
ma20: false,
fadeDuration: 9
});
stored.enabled = false;
const restored = workflow.restoreStockPlaylistEntry(JSON.parse(JSON.stringify(stored)));
assert.ok(restored);
assert.equal(restored.enabled, false);
assert.equal(restored.code, "8046");
assert.equal(restored.detail, "캔들그래프(거래량)_60일");
assert.equal(restored.selection.graphicType, "MA5");
for (const tamper of [
value => { value.builderKey = "s5001"; },
value => { value.code = "5001"; },
value => { value.selection.subject = "다른종목"; },
value => { value.operator.cutId = "basic-current"; },
value => { value.operator.source = "unknown"; },
value => { value.operator.legacyLabel = "변조"; },
value => { value.enabled = "false"; }
]) {
const candidate = JSON.parse(JSON.stringify(stored));
tamper(candidate);
assert.equal(workflow.restoreStockPlaylistEntry(candidate), null);
}
assert.equal(workflow.restoreStockPlaylistEntry(null), null);
});
test("four legacy manual-data shortcuts are explicit unavailable prerequisites", () => {
assert.deepEqual(
workflow.manualStockActions.map(action => [
action.label,
action.builderKey,
action.alias,
action.prerequisiteTable
]),
[
["주요매출 구성", "s5076", "5076", "INPUT_PIE"],
["성장성 지표", "s5079", "5079", "INPUT_GROW"],
["매출액", "s5080", "5080", "INPUT_SELL"],
["영업이익", "s5081", "5081", "INPUT_PROFIT"]
]);
for (const action of workflow.manualStockActions) {
assert.equal(action.available, false);
assert.equal(action.autoAdd, false);
assert.equal(action.status, "unavailable");
assert.equal(action.prerequisites.length, 3);
assert.match(action.prerequisites[0], new RegExp(action.prerequisiteTable));
}
});