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