"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const workflow = require("../../Web/trading-halt-workflow.js"); const kospi = Object.freeze({ market: "kospi", stockCode: "005930", displayName: "삼성전자" }); const kosdaq = Object.freeze({ market: "kosdaq", stockCode: "A123456", displayName: "코스닥정지종목" }); function clone(value) { return JSON.parse(JSON.stringify(value)); } function validResponse(overrides = {}) { return { requestId: "halt-request-1", query: "", retrievedAt: "2026-07-11T12:34:56+09:00", totalRowCount: 2, truncated: false, results: [ { market: "kospi", stockCode: "000001", displayName: "가나다" }, { market: "kosdaq", stockCode: "000002", displayName: "라마바" } ], ...overrides }; } test("workflow pins the UC7 bridge and two closed scene actions", () => { assert.deepEqual(workflow.bridgeContract, { requestType: "search-trading-halts", resultType: "trading-halt-results", errorType: "trading-halt-error", defaultMaximumResults: 100, maximumResults: 500, maximumQueryLength: 64 }); assert.deepEqual(workflow.sourceContract, { originalControl: "Control/UC7.cs", currentCutCode: "5001", candleCutCode: "8051", candlePeriodDays: 120, currentResolverGroups: { kospi: "KOSPI", kosdaq: "KOSDAQ" }, candleResolverGroups: { kospi: "KOSPI_STOCK", kosdaq: "KOSDAQ_STOCK" } }); assert.deepEqual(workflow.actions.map(value => [ value.id, value.builderKey, value.code, value.kind ]), [ ["current", "s5001", "5001", "current"], ["candle-120d", "s8010", "8051", "candle"] ]); }); test("search requests match the exact native bridge payload and normalize the query", () => { assert.deepEqual( workflow.createTradingHaltSearchRequest("request-1", " 삼성 "), { requestId: "request-1", query: "삼성" }); assert.deepEqual( workflow.createTradingHaltSearchRequest("request-2", "", 200), { requestId: "request-2", query: "", maximumResults: 200 }); assert.throws(() => workflow.createTradingHaltSearchRequest("bad id", ""), /safe/i); assert.throws(() => workflow.createTradingHaltSearchRequest("request", "bad\nquery"), /safe/i); assert.throws(() => workflow.createTradingHaltSearchRequest("request", "x".repeat(65)), /64/); assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", 0), /1 through 500/); assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", 501), /1 through 500/); assert.throws(() => workflow.createTradingHaltSearchRequest("request", "", "100"), /1 through 500/); }); test("native rows normalize only exact typed market, stock-code and display-name values", () => { assert.deepEqual(workflow.normalizeTradingHalt(kospi), kospi); assert.deepEqual(workflow.normalizeTradingHalt(kosdaq), kosdaq); assert.equal(workflow.normalizeTradingHalt({ ...kospi, market: "KOSPI" }), null); assert.equal(workflow.normalizeTradingHalt({ ...kospi, market: "nxt-kospi" }), null); assert.equal(workflow.normalizeTradingHalt({ ...kospi, stockCode: "../005930" }), null); assert.equal(workflow.normalizeTradingHalt({ ...kospi, stockCode: " 005930" }), null); assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: " 삼성전자" }), null); assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: "삼성\n전자" }), null); assert.equal(workflow.normalizeTradingHalt({ ...kospi, displayName: "삼성\u200B전자" }), null); assert.equal(workflow.normalizeTradingHalt({ ...kospi, code: "005930" }), null); assert.equal(workflow.normalizeTradingHalt({ market: "kospi", code: "005930", name: "삼성전자" }), null); }); test("search responses are request-bound, deterministically ordered and deeply normalized", () => { const request = workflow.createTradingHaltSearchRequest("halt-request-1", "", 2); const response = workflow.normalizeTradingHaltSearchResponse(validResponse(), request); assert.ok(response); assert.deepEqual(response, validResponse()); assert.equal(Object.isFrozen(response), true); assert.equal(Object.isFrozen(response.results), true); assert.equal(Object.isFrozen(response.results[0]), true); assert.equal(workflow.normalizeTradingHaltSearchResponse( validResponse({ requestId: "other-request" }), request), null); assert.equal(workflow.normalizeTradingHaltSearchResponse( validResponse({ query: "삼성" }), request), null); assert.equal(workflow.normalizeTradingHaltSearchResponse( validResponse({ totalRowCount: 1 }), request), null); assert.equal(workflow.normalizeTradingHaltSearchResponse( validResponse({ retrievedAt: "not-a-date" }), request), null); assert.equal(workflow.normalizeTradingHaltSearchResponse( { ...validResponse(), unexpected: true }, request), null); }); test("search responses reject duplicates, ambiguous names, invalid rows and reordered markets", () => { const duplicateIdentity = validResponse({ results: [ { market: "kospi", stockCode: "000001", displayName: "가나다" }, { market: "kospi", stockCode: "000001", displayName: "라마바" } ] }); assert.equal(workflow.normalizeTradingHaltSearchResponse(duplicateIdentity), null); const ambiguousName = validResponse({ results: [ { market: "kospi", stockCode: "000001", displayName: "가나다" }, { market: "kospi", stockCode: "000002", displayName: "가나다" } ] }); assert.equal(workflow.normalizeTradingHaltSearchResponse(ambiguousName), null); const crossMarketSameName = validResponse({ results: [ { market: "kospi", stockCode: "000001", displayName: "가나다" }, { market: "kosdaq", stockCode: "000001", displayName: "가나다" } ] }); assert.ok(workflow.normalizeTradingHaltSearchResponse(crossMarketSameName)); const reordered = validResponse({ results: [...validResponse().results].reverse() }); assert.equal(workflow.normalizeTradingHaltSearchResponse(reordered), null); assert.equal(workflow.normalizeTradingHaltSearchResponse(validResponse({ results: [validResponse().results[0], { ...validResponse().results[1], market: "nxt-kosdaq" }] })), null); }); test("5001 current selections bind the exact halted-stock Core contract", () => { for (const [stock, groupCode] of [[kospi, "KOSPI"], [kosdaq, "KOSDAQ"]]) { const mapping = workflow.buildTradingHaltSelection("current", stock, { ma5: true, ma20: true }); assert.equal(mapping.builderKey, "s5001"); assert.equal(mapping.code, "5001"); assert.deepEqual(mapping.aliases, ["5001"]); assert.deepEqual(mapping.selection, { groupCode, subject: stock.displayName, graphicType: "1열판기본", subtype: "HALTED", dataCode: stock.stockCode }); assert.deepEqual(mapping.movingAverages, { ma5: false, ma20: false }); } }); test("8051 selections expose only the four closed moving-average flag combinations", () => { const cases = [ [false, false, ""], [true, false, "MA5"], [false, true, "MA20"], [true, true, "MA5,MA20"] ]; for (const [ma5, ma20, graphicType] of cases) { const mapping = workflow.buildTradingHaltSelection("candle-120d", kosdaq, { ma5, ma20 }); assert.equal(mapping.builderKey, "s8010"); assert.equal(mapping.code, "8051"); assert.deepEqual(mapping.aliases, ["8051"]); assert.deepEqual(mapping.selection, { groupCode: "KOSDAQ_STOCK", subject: "코스닥정지종목", graphicType, subtype: "TRADING_HALT", dataCode: "A123456" }); } assert.throws(() => workflow.buildTradingHaltSelection("candle-120d", kospi, { ma5: "true", ma20: false }), /boolean/i); }); test("playlist entries preserve the typed identity and reject unsafe options", () => { const entry = workflow.createTradingHaltPlaylistEntry(kospi, "candle-120d", { id: "halt-candle", fadeDuration: 8, ma5: true, ma20: false }); assert.equal(entry.builderKey, "s8010"); assert.equal(entry.code, "8051"); assert.equal(entry.category, "chart"); assert.equal(entry.market, "kospi"); assert.equal(entry.stockCode, "005930"); assert.equal(entry.displayName, "삼성전자"); assert.equal(entry.selection.graphicType, "MA5"); assert.equal(entry.operator.source, "legacy-trading-halt-workflow"); assert.equal(entry.operator.schemaVersion, 1); assert.deepEqual(entry.operator.tradingHalt, kospi); assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "unknown", { id: "unknown" }), /unknown/i); assert.throws(() => workflow.createTradingHaltPlaylistEntry({}, "current", { id: "invalid" }), /valid/i); assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", { id: "bad id" }), /safe/i); assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", { id: "fade", fadeDuration: 61 }), /0 through 60/i); assert.throws(() => workflow.createTradingHaltPlaylistEntry(kospi, "current", { id: "flags", ma20: 1 }), /boolean/i); }); test("trusted restore reconstructs entries and rejects mapping, identity and flag tampering", () => { const original = workflow.createTradingHaltPlaylistEntry(kosdaq, "candle-120d", { id: "stored-halt", fadeDuration: 9, ma5: true, ma20: true }); original.enabled = false; const restored = workflow.restoreTradingHaltPlaylistEntry(clone(original)); assert.ok(restored); assert.equal(restored.enabled, false); assert.equal(restored.selection.graphicType, "MA5,MA20"); const tampers = [ value => { value.builderKey = "s5001"; }, value => { value.code = "5001"; }, value => { value.selection.groupCode = "KOSPI_STOCK"; }, value => { value.selection.subject = "다른종목"; }, value => { value.selection.dataCode = "000001"; }, value => { value.selection.graphicType = "MA20,MA5"; }, value => { value.selection.subtype = "PRICE"; }, value => { value.operator.actionId = "current"; }, value => { value.operator.schemaVersion = 2; }, value => { value.operator.resolverGroup = "KOSPI_STOCK"; }, value => { value.operator.movingAverages.ma5 = false; }, value => { value.operator.tradingHalt.stockCode = "000001"; }, value => { value.enabled = "false"; } ]; for (const tamper of tampers) { const candidate = clone(original); tamper(candidate); assert.equal(workflow.restoreTradingHaltPlaylistEntry(candidate), null); } assert.equal(workflow.restoreTradingHaltPlaylistEntry(null), null); }); test("native error payloads are exact and bound to the active request", () => { const request = workflow.createTradingHaltSearchRequest("halt-request-1", "삼성", 100); const error = { requestId: "halt-request-1", query: "삼성", message: "거래정지 종목을 검색하지 못했습니다." }; assert.deepEqual(workflow.normalizeTradingHaltError(error, request), error); assert.equal(workflow.normalizeTradingHaltError({ ...error, requestId: "old" }, request), null); assert.equal(workflow.normalizeTradingHaltError({ ...error, message: "bad\nmessage" }, request), null); assert.equal(workflow.normalizeTradingHaltError({ ...error, detail: "extra" }, request), null); });