Files
MBN_STOCK_WEBVIEW/tests/Web/trading-halt-workflow.test.cjs

273 lines
11 KiB
JavaScript

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