Complete legacy operator UI and playout migration
This commit is contained in:
377
tests/Web/overseas-workflow.test.cjs
Normal file
377
tests/Web/overseas-workflow.test.cjs
Normal file
@@ -0,0 +1,377 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/overseas-workflow.js");
|
||||
|
||||
const industry = Object.freeze({
|
||||
koreanName: "Philadelphia Semiconductor",
|
||||
inputName: "US Semiconductor Index",
|
||||
symbol: "NAS@SOX",
|
||||
nationCode: "US",
|
||||
fdtc: "0"
|
||||
});
|
||||
const usStock = Object.freeze({
|
||||
inputName: "NVIDIA",
|
||||
symbol: "NAS@NVDA",
|
||||
nationCode: "US",
|
||||
fdtc: "1"
|
||||
});
|
||||
const twStock = Object.freeze({
|
||||
inputName: "Taiwan Semiconductor",
|
||||
symbol: "TWS@2330",
|
||||
nationCode: "TW",
|
||||
fdtc: "1"
|
||||
});
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function industryResponse(overrides = {}) {
|
||||
return {
|
||||
requestId: "overseas-industry-1",
|
||||
query: "",
|
||||
retrievedAt: "2026-07-11T12:34:56+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
results: [
|
||||
{
|
||||
koreanName: "Alpha Industry",
|
||||
inputName: "ALPHA_INDEX",
|
||||
symbol: "NAS@ALPHA",
|
||||
nationCode: "US",
|
||||
fdtc: "0"
|
||||
},
|
||||
{
|
||||
koreanName: "Beta Industry",
|
||||
inputName: "BETA_INDEX",
|
||||
symbol: "NAS@BETA",
|
||||
nationCode: "US",
|
||||
fdtc: "0"
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function stockResponse(overrides = {}) {
|
||||
return {
|
||||
requestId: "overseas-stock-1",
|
||||
query: "",
|
||||
retrievedAt: "2026-07-11T12:34:56+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
results: [
|
||||
{ inputName: "Apple", symbol: "NAS@AAPL", nationCode: "US", fdtc: "1" },
|
||||
{ inputName: "Taiwan Semiconductor", symbol: "TWS@2330", nationCode: "TW", fdtc: "1" }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("UC5 bridge contracts expose one industry action and six stock actions", () => {
|
||||
assert.deepEqual(workflow.bridgeContracts.industry, {
|
||||
requestType: "search-overseas-industries",
|
||||
resultType: "overseas-industry-results",
|
||||
errorType: "overseas-industry-error",
|
||||
defaultMaximumResults: 100,
|
||||
maximumResults: 500,
|
||||
maximumQueryLength: 64
|
||||
});
|
||||
assert.deepEqual(workflow.bridgeContracts.stock, {
|
||||
requestType: "search-world-stocks",
|
||||
resultType: "world-stock-search-results",
|
||||
errorType: "world-stock-search-error",
|
||||
defaultMaximumResults: 100,
|
||||
maximumResults: 500,
|
||||
maximumQueryLength: 64
|
||||
});
|
||||
assert.deepEqual(workflow.industryActions.map(value => [value.id, value.code]), [
|
||||
["industry-current", "5001"]
|
||||
]);
|
||||
assert.deepEqual(workflow.stockActions.map(value => [value.id, value.code, value.periodDays]), [
|
||||
["stock-current", "5001", null],
|
||||
["stock-candle-5d", "8061", 5],
|
||||
["stock-candle-20d", "8040", 20],
|
||||
["stock-candle-60d", "8046", 60],
|
||||
["stock-candle-120d", "8051", 120],
|
||||
["stock-candle-240d", "8056", 240]
|
||||
]);
|
||||
assert.deepEqual(workflow.sourceContract.industry, {
|
||||
fdtc: "0", nationCodes: ["US"], lookupField: "F_KNAM", currentCutCode: "5001"
|
||||
});
|
||||
assert.deepEqual(workflow.sourceContract.stock.candleCutCodes, [
|
||||
"8061", "8040", "8046", "8051", "8056"
|
||||
]);
|
||||
});
|
||||
|
||||
test("industry and stock search requests normalize blank initial lists and exact limits", () => {
|
||||
assert.deepEqual(
|
||||
workflow.createIndustrySearchRequest("industry-request", " "),
|
||||
{ requestId: "industry-request", query: "" });
|
||||
assert.deepEqual(
|
||||
workflow.createStockSearchRequest("stock-request", " NVDA ", 200),
|
||||
{ requestId: "stock-request", query: "NVDA", maximumResults: 200 });
|
||||
|
||||
for (const create of [workflow.createIndustrySearchRequest, workflow.createStockSearchRequest]) {
|
||||
assert.throws(() => create("bad id", ""), /safe/i);
|
||||
assert.throws(() => create("request", "bad\nquery"), /safe/i);
|
||||
assert.throws(() => create("request", "x".repeat(65)), /64/);
|
||||
assert.throws(() => create("request", "", 0), /1 through 500/);
|
||||
assert.throws(() => create("request", "", 501), /1 through 500/);
|
||||
assert.throws(() => create("request", "", "100"), /1 through 500/);
|
||||
}
|
||||
});
|
||||
|
||||
test("native identities require exact name, symbol, nation and FDTC fields", () => {
|
||||
assert.deepEqual(workflow.normalizeIndustry(industry), industry);
|
||||
assert.deepEqual(workflow.normalizeStock(usStock), usStock);
|
||||
assert.deepEqual(workflow.normalizeStock(twStock), twStock);
|
||||
|
||||
assert.equal(workflow.normalizeIndustry({ ...industry, nationCode: "TW" }), null);
|
||||
assert.equal(workflow.normalizeIndustry({ ...industry, fdtc: "1" }), null);
|
||||
assert.equal(workflow.normalizeIndustry({ ...industry, koreanName: " Industry" }), null);
|
||||
assert.equal(workflow.normalizeIndustry({ ...industry, inputName: "A|B" }), null);
|
||||
assert.equal(workflow.normalizeStock({ ...usStock, nationCode: "JP" }), null);
|
||||
assert.equal(workflow.normalizeStock({ ...usStock, fdtc: 1 }), null);
|
||||
assert.equal(workflow.normalizeStock({ ...usStock, symbol: "../../NVDA" }), null);
|
||||
assert.equal(workflow.normalizeStock({ ...usStock, inputName: "NV\u200BIDIA" }), null);
|
||||
assert.equal(workflow.normalizeStock({ ...usStock, koreanName: "extra" }), null);
|
||||
});
|
||||
|
||||
test("industry responses are request-bound, ordered and deeply normalized", () => {
|
||||
const request = workflow.createIndustrySearchRequest("overseas-industry-1", "", 2);
|
||||
const normalized = workflow.normalizeIndustrySearchResponse(industryResponse(), request);
|
||||
assert.ok(normalized);
|
||||
assert.deepEqual(normalized, industryResponse());
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
assert.equal(Object.isFrozen(normalized.results), true);
|
||||
assert.equal(Object.isFrozen(normalized.results[0]), true);
|
||||
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse(
|
||||
industryResponse({ requestId: "stale" }), request), null);
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse(
|
||||
industryResponse({ totalRowCount: 1 }), request), null);
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse(
|
||||
industryResponse({ retrievedAt: "invalid" }), request), null);
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse(
|
||||
{ ...industryResponse(), extra: true }, request), null);
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({
|
||||
results: [...industryResponse().results].reverse()
|
||||
}), request), null);
|
||||
|
||||
const overDefault = Array.from({ length: 101 }, (_, index) => {
|
||||
const key = String(index).padStart(3, "0");
|
||||
return {
|
||||
koreanName: `Industry ${key}`,
|
||||
inputName: `INDEX_${key}`,
|
||||
symbol: `NAS@I${key}`,
|
||||
nationCode: "US",
|
||||
fdtc: "0"
|
||||
};
|
||||
});
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse({
|
||||
...industryResponse(),
|
||||
totalRowCount: overDefault.length,
|
||||
results: overDefault
|
||||
}, workflow.createIndustrySearchRequest("overseas-industry-1", "")), null);
|
||||
});
|
||||
|
||||
test("industry responses reject every ambiguous downstream lookup key", () => {
|
||||
for (const key of ["koreanName", "inputName", "symbol"]) {
|
||||
const rows = clone(industryResponse().results);
|
||||
rows[1][key] = rows[0][key];
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({ results: rows })), null);
|
||||
}
|
||||
const wrongScope = clone(industryResponse());
|
||||
wrongScope.results[1].fdtc = "1";
|
||||
assert.equal(workflow.normalizeIndustrySearchResponse(wrongScope), null);
|
||||
});
|
||||
|
||||
test("stock responses preserve US/TW identity and reject duplicate input-name lookups", () => {
|
||||
const request = workflow.createStockSearchRequest("overseas-stock-1", "", 2);
|
||||
assert.ok(workflow.normalizeStockSearchResponse(stockResponse(), request));
|
||||
|
||||
const duplicateName = clone(stockResponse());
|
||||
duplicateName.results[1].inputName = duplicateName.results[0].inputName;
|
||||
assert.equal(workflow.normalizeStockSearchResponse(duplicateName), null);
|
||||
|
||||
const sameSymbol = clone(stockResponse());
|
||||
sameSymbol.results[1].symbol = sameSymbol.results[0].symbol;
|
||||
assert.ok(workflow.normalizeStockSearchResponse(sameSymbol));
|
||||
|
||||
const wrongNation = clone(stockResponse());
|
||||
wrongNation.results[1].nationCode = "JP";
|
||||
assert.equal(workflow.normalizeStockSearchResponse(wrongNation), null);
|
||||
assert.equal(workflow.normalizeStockSearchResponse(stockResponse({
|
||||
results: [...stockResponse().results].reverse()
|
||||
})), null);
|
||||
});
|
||||
|
||||
test("bridge errors use exact payloads and reject stale request correlation", () => {
|
||||
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
|
||||
const industryError = {
|
||||
requestId: "industry-error-1",
|
||||
query: "AI",
|
||||
message: "Industry lookup failed."
|
||||
};
|
||||
assert.deepEqual(
|
||||
workflow.normalizeIndustrySearchError(industryError, industryRequest),
|
||||
industryError);
|
||||
assert.equal(workflow.normalizeIndustrySearchError({
|
||||
...industryError, requestId: "stale"
|
||||
}, industryRequest), null);
|
||||
assert.equal(workflow.normalizeIndustrySearchError({
|
||||
...industryError, extra: true
|
||||
}, industryRequest), null);
|
||||
|
||||
const stockRequest = workflow.createStockSearchRequest("stock-error-1", "NVDA");
|
||||
const stockError = { requestId: "stock-error-1", query: "NVDA", message: "Failed." };
|
||||
assert.deepEqual(workflow.normalizeStockSearchError(stockError, stockRequest), stockError);
|
||||
assert.equal(workflow.normalizeStockSearchError({
|
||||
...stockError, message: "bad\nmessage"
|
||||
}, stockRequest), null);
|
||||
});
|
||||
|
||||
test("US overseas industries expose only the 5001 current one-column mapping", () => {
|
||||
assert.equal(workflow.isActionAllowed("industry", "industry-current", industry), true);
|
||||
assert.equal(workflow.isActionAllowed("industry", "stock-candle-5d", industry), false);
|
||||
const mapping = workflow.buildOverseasIndustrySelection(industry);
|
||||
assert.equal(mapping.builderKey, "s5001");
|
||||
assert.equal(mapping.code, "5001");
|
||||
assert.deepEqual(mapping.aliases, ["5001"]);
|
||||
assert.deepEqual(mapping.selection, {
|
||||
groupCode: "FOREIGN_INDUSTRY",
|
||||
subject: "Philadelphia Semiconductor",
|
||||
graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
|
||||
subtype: "CURRENT",
|
||||
dataCode: "US Semiconductor Index|NAS@SOX|US|0"
|
||||
});
|
||||
assert.throws(() => workflow.buildOverseasIndustrySelection({
|
||||
...industry, nationCode: "TW"
|
||||
}), /valid/i);
|
||||
});
|
||||
|
||||
test("US/TW stocks expose 5001 current and the five exact UC5 candle aliases", () => {
|
||||
const current = workflow.buildOverseasStockSelection(usStock, "stock-current", {
|
||||
ma5: true,
|
||||
ma20: true
|
||||
});
|
||||
assert.equal(current.builderKey, "s5001");
|
||||
assert.deepEqual(current.selection, {
|
||||
groupCode: "FOREIGN_STOCK",
|
||||
subject: "NVIDIA",
|
||||
graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
|
||||
subtype: "CURRENT",
|
||||
dataCode: "NAS@NVDA|US|1"
|
||||
});
|
||||
assert.deepEqual(current.movingAverages, { ma5: false, ma20: false });
|
||||
|
||||
for (const action of workflow.stockActions.filter(value => value.kind === "candle")) {
|
||||
const mapping = workflow.buildOverseasStockSelection(twStock, action.id, {
|
||||
ma5: true,
|
||||
ma20: false
|
||||
});
|
||||
assert.equal(mapping.builderKey, "s8010");
|
||||
assert.equal(mapping.code, action.code);
|
||||
assert.deepEqual(mapping.aliases, [action.code]);
|
||||
assert.deepEqual(mapping.selection, {
|
||||
groupCode: "FOREIGN_STOCK",
|
||||
subject: "Taiwan Semiconductor",
|
||||
graphicType: "MA5",
|
||||
subtype: "PRICE",
|
||||
dataCode: "TWS@2330|TW|1"
|
||||
});
|
||||
}
|
||||
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-candle-intraday"), /unknown/i);
|
||||
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "industry-current"), /unknown/i);
|
||||
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-current", {
|
||||
ma5: "true"
|
||||
}), /boolean/i);
|
||||
});
|
||||
|
||||
test("playlist entries preserve typed FDTC identity and runtime candle bindings", () => {
|
||||
const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
|
||||
id: "industry-entry",
|
||||
fadeDuration: 7
|
||||
});
|
||||
assert.equal(industryEntry.category, "plate");
|
||||
assert.equal(industryEntry.symbol, "NAS@SOX");
|
||||
assert.equal(industryEntry.nationCode, "US");
|
||||
assert.equal(industryEntry.fdtc, "0");
|
||||
assert.equal(industryEntry.operator.kind, "industry");
|
||||
assert.equal(industryEntry.operator.schemaVersion, 1);
|
||||
|
||||
const candleEntry = workflow.createOverseasStockPlaylistEntry(
|
||||
twStock,
|
||||
"stock-candle-240d",
|
||||
{ id: "tw-candle", fadeDuration: 8, ma5: true, ma20: true });
|
||||
assert.equal(candleEntry.builderKey, "s8010");
|
||||
assert.equal(candleEntry.code, "8056");
|
||||
assert.equal(candleEntry.category, "chart");
|
||||
assert.equal(candleEntry.selection.dataCode, "TWS@2330|TW|1");
|
||||
assert.equal(candleEntry.selection.graphicType, "MA5,MA20");
|
||||
assert.equal(candleEntry.operator.kind, "stock");
|
||||
assert.deepEqual(candleEntry.operator.identity, twStock);
|
||||
});
|
||||
|
||||
test("trusted restore reconstructs entries and rejects identity or mapping tampering", () => {
|
||||
const original = workflow.createOverseasStockPlaylistEntry(
|
||||
usStock,
|
||||
"stock-candle-120d",
|
||||
{ id: "stored-overseas", fadeDuration: 9, ma5: true, ma20: false });
|
||||
original.enabled = false;
|
||||
const restored = workflow.restoreOverseasPlaylistEntry(clone(original));
|
||||
assert.ok(restored);
|
||||
assert.equal(restored.enabled, false);
|
||||
|
||||
const tampers = [
|
||||
value => { value.builderKey = "s5001"; },
|
||||
value => { value.code = "8040"; },
|
||||
value => { value.selection.groupCode = "FOREIGN_INDEX"; },
|
||||
value => { value.selection.subject = "Other"; },
|
||||
value => { value.selection.dataCode = "NAS@NVDA|TW|1"; },
|
||||
value => { value.selection.subtype = "VOLUME"; },
|
||||
value => { value.symbol = "NAS@OTHER"; },
|
||||
value => { value.nationCode = "TW"; },
|
||||
value => { value.fdtc = "0"; },
|
||||
value => { value.operator.actionId = "stock-current"; },
|
||||
value => { value.operator.schemaVersion = 2; },
|
||||
value => { value.operator.identity.symbol = "NAS@OTHER"; },
|
||||
value => { value.operator.identity.nationCode = "TW"; },
|
||||
value => { value.operator.identity.fdtc = "0"; },
|
||||
value => { value.operator.movingAverages.ma5 = false; },
|
||||
value => { value.enabled = "false"; }
|
||||
];
|
||||
for (const tamper of tampers) {
|
||||
const candidate = clone(original);
|
||||
tamper(candidate);
|
||||
assert.equal(workflow.restoreOverseasPlaylistEntry(candidate), null);
|
||||
}
|
||||
|
||||
const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
|
||||
id: "stored-industry"
|
||||
});
|
||||
assert.ok(workflow.restoreOverseasPlaylistEntry(clone(industryEntry)));
|
||||
const tamperedIndustry = clone(industryEntry);
|
||||
tamperedIndustry.operator.identity.koreanName = "Other Industry";
|
||||
assert.equal(workflow.restoreOverseasPlaylistEntry(tamperedIndustry), null);
|
||||
assert.equal(workflow.restoreOverseasPlaylistEntry(null), null);
|
||||
});
|
||||
|
||||
test("playlist option validation rejects unsafe ids, fades and identities", () => {
|
||||
assert.throws(() => workflow.createOverseasIndustryPlaylistEntry(industry, {
|
||||
id: "bad id"
|
||||
}), /safe/i);
|
||||
assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
|
||||
id: "fade", fadeDuration: 61
|
||||
}), /0 through 60/i);
|
||||
assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
|
||||
id: "flags", ma20: 1
|
||||
}), /boolean/i);
|
||||
assert.throws(() => workflow.createOverseasStockPlaylistEntry(
|
||||
{ ...usStock, nationCode: "JP" },
|
||||
"stock-current",
|
||||
{ id: "invalid" }), /valid/i);
|
||||
});
|
||||
Reference in New Issue
Block a user