273 lines
11 KiB
JavaScript
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);
|
|
});
|