Complete legacy operator UI and playout migration
This commit is contained in:
119
tests/Web/candle-options-workflow.test.cjs
Normal file
119
tests/Web/candle-options-workflow.test.cjs
Normal file
@@ -0,0 +1,119 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/candle-options-workflow.js");
|
||||
const stock = require("../../Web/operator-workflow.js");
|
||||
const fixed = require("../../Web/legacy-fixed-workflow.js");
|
||||
const industry = require("../../Web/industry-workflow.js");
|
||||
const overseas = require("../../Web/overseas-workflow.js");
|
||||
const tradingHalt = require("../../Web/trading-halt-workflow.js");
|
||||
|
||||
const adapters = { stock, fixed, industry, overseas, tradingHalt };
|
||||
const options = id => ({ id, fadeDuration: 6, ma5: false, ma20: false });
|
||||
|
||||
const domesticStock = Object.freeze({
|
||||
market: "kospi",
|
||||
stockName: "삼성전자",
|
||||
displayName: "삼성전자",
|
||||
stockCode: "005930",
|
||||
isNxt: false
|
||||
});
|
||||
const industryItem = Object.freeze({ market: "kospi", name: "전기전자", code: "013" });
|
||||
const overseasStock = Object.freeze({
|
||||
inputName: "NVIDIA",
|
||||
symbol: "NAS@NVDA",
|
||||
nationCode: "US",
|
||||
fdtc: "1"
|
||||
});
|
||||
const haltedStock = Object.freeze({
|
||||
market: "kospi",
|
||||
stockCode: "005930",
|
||||
displayName: "삼성전자"
|
||||
});
|
||||
|
||||
function entries() {
|
||||
const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available);
|
||||
const industryCut = industry.cuts.find(value => value.kind === "candle");
|
||||
return [
|
||||
stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")),
|
||||
fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")),
|
||||
industry.createIndustryPlaylistEntry("kospi", industryCut.id, {
|
||||
...options("industry-candle"),
|
||||
selected: industryItem
|
||||
}),
|
||||
overseas.createOverseasStockPlaylistEntry(
|
||||
overseasStock,
|
||||
"stock-candle-120d",
|
||||
options("overseas-candle")),
|
||||
tradingHalt.createTradingHaltPlaylistEntry(
|
||||
haltedStock,
|
||||
"candle-120d",
|
||||
options("halt-candle"))
|
||||
];
|
||||
}
|
||||
|
||||
test("global flags map to the exact closed candle graphic type", () => {
|
||||
assert.equal(workflow.expectedGraphicType(false, false), "");
|
||||
assert.equal(workflow.expectedGraphicType(true, false), "MA5");
|
||||
assert.equal(workflow.expectedGraphicType(false, true), "MA20");
|
||||
assert.equal(workflow.expectedGraphicType(true, true), "MA5,MA20");
|
||||
assert.throws(() => workflow.expectedGraphicType("yes", true));
|
||||
});
|
||||
|
||||
test("all five original candle entry sources can be recreated with global flags", () => {
|
||||
for (const entry of entries()) {
|
||||
const recreated = workflow.recreateCandleEntry(entry, true, true, adapters);
|
||||
assert.ok(recreated, entry.operator.source);
|
||||
assert.equal(recreated.selection.graphicType, "MA5,MA20");
|
||||
assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true });
|
||||
assert.equal(recreated.enabled, entry.enabled);
|
||||
}
|
||||
});
|
||||
|
||||
test("playlist synchronization changes only s8010 entries and preserves order", () => {
|
||||
const candles = entries();
|
||||
const nonCandle = stock.createStockPlaylistEntry(domesticStock, "basic-current", {
|
||||
id: "non-candle",
|
||||
fadeDuration: 6,
|
||||
ma5: false,
|
||||
ma20: false
|
||||
});
|
||||
const input = [candles[0], nonCandle, ...candles.slice(1)];
|
||||
|
||||
const result = workflow.synchronizePlaylist(input, true, false, adapters);
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.playlist[1], nonCandle);
|
||||
assert.deepEqual(result.playlist.map(value => value.id), input.map(value => value.id));
|
||||
for (const entry of result.playlist.filter(value => value.builderKey === "s8010")) {
|
||||
assert.equal(entry.selection.graphicType, "MA5");
|
||||
}
|
||||
});
|
||||
|
||||
test("an untrusted s8010 entry blocks synchronization instead of being rewritten", () => {
|
||||
const untrusted = {
|
||||
id: "unknown-candle",
|
||||
builderKey: "s8010",
|
||||
code: "8051",
|
||||
enabled: true,
|
||||
fadeDuration: 6,
|
||||
selection: { graphicType: "" },
|
||||
operator: { source: "unknown" }
|
||||
};
|
||||
const result = workflow.synchronizePlaylist([untrusted], true, true, adapters);
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.changed, false);
|
||||
assert.deepEqual(result.invalidIds, ["unknown-candle"]);
|
||||
assert.equal(result.playlist[0], untrusted);
|
||||
});
|
||||
|
||||
test("a second synchronization with identical flags is stable", () => {
|
||||
const first = workflow.synchronizePlaylist(entries(), true, true, adapters);
|
||||
const second = workflow.synchronizePlaylist([...first.playlist], true, true, adapters);
|
||||
assert.equal(first.valid, true);
|
||||
assert.equal(second.valid, true);
|
||||
assert.equal(second.changed, false);
|
||||
second.playlist.forEach((entry, index) => assert.equal(entry, first.playlist[index]));
|
||||
});
|
||||
266
tests/Web/comparison-workflow.test.cjs
Normal file
266
tests/Web/comparison-workflow.test.cjs
Normal file
@@ -0,0 +1,266 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/comparison-workflow.js");
|
||||
|
||||
const kospi = workflow.getMarketTarget("Kospi");
|
||||
const kospi200 = workflow.getMarketTarget("코스피200 지수");
|
||||
const futures = workflow.getMarketTarget("futures");
|
||||
const wonDollar = workflow.getMarketTarget("WonDollar");
|
||||
const samsung = Object.freeze({
|
||||
kind: "krx-stock", market: "kospi", stockName: "삼성전자",
|
||||
stockCode: "005930", displayName: "삼성전자"
|
||||
});
|
||||
const skHynix = Object.freeze({
|
||||
kind: "krx-stock", market: "kospi", stockName: "SK하이닉스",
|
||||
stockCode: "000660", displayName: "SK하이닉스"
|
||||
});
|
||||
const nxtSamsung = Object.freeze({
|
||||
kind: "nxt-stock", market: "kospi", stockName: "삼성전자",
|
||||
stockCode: "005930", displayName: "삼성전자(NXT)"
|
||||
});
|
||||
const apple = Object.freeze({
|
||||
kind: "world-stock", inputName: "Apple Inc", displayName: "애플",
|
||||
symbol: "NAS@AAPL", nation: "US"
|
||||
});
|
||||
|
||||
test("comparison inventory pins the exact runtime asset, 24 targets and nine actions", () => {
|
||||
assert.deepEqual(workflow.runtimeAsset, {
|
||||
relativePath: "bin/Debug/Res/종목비교.ini",
|
||||
sha256: "980F74C000EF6A34B85FDAAF6D2756269483637186C91A93548F35C5CFC1B7E4"
|
||||
});
|
||||
assert.equal(workflow.marketTargets.length, 24);
|
||||
assert.equal(new Set(workflow.marketTargets.map(value => value.target)).size, 24);
|
||||
assert.deepEqual(workflow.marketTargets.map(value => [value.displayName, value.target]), [
|
||||
["코스피 지수", "Kospi"], ["코스닥 지수", "Kosdaq"],
|
||||
["코스피200 지수", "Kospi200"], ["선물 지수", "Futures"],
|
||||
["KRX100지수", "Krx100"], ["환율-원달러", "WonDollar"],
|
||||
["환율-원엔", "WonYen"], ["환율-원위엔", "WonYuan"],
|
||||
["환율-원유로", "WonEuro"], ["해외지수-다우", "Dow"],
|
||||
["해외지수-나스닥", "Nasdaq"], ["해외지수-S&P", "Sp500"],
|
||||
["해외지수-독일", "GermanyDax"], ["해외지수-영국", "UnitedKingdomFtse"],
|
||||
["해외지수-프랑스", "FranceCac"], ["해외지수-일본", "Nikkei"],
|
||||
["해외지수-홍콩", "HangSeng"], ["해외지수-대만", "TaiwanWeighted"],
|
||||
["해외지수-싱가포르", "SingaporeStraitsTimes"], ["해외지수-태국", "ThailandSet"],
|
||||
["해외지수-필리핀", "PhilippinesComposite"], ["해외지수-말레이시아", "MalaysiaKlse"],
|
||||
["해외지수-인도네시아", "IndonesiaComposite"], ["해외지수-중국", "ShanghaiComposite"]
|
||||
]);
|
||||
assert.deepEqual(workflow.actions.map(value => value.label), [
|
||||
"2열판 예상체결", "2열판", "종목별 비교분석_캔들 그래프", "종목별 비교분석_라인 그래프",
|
||||
"5일", "1개월", "3개월", "6개월", "12개월"
|
||||
]);
|
||||
assert.deepEqual(workflow.yieldActionIds, [
|
||||
"return-5d", "return-1m", "return-3m", "return-6m", "return-12m"
|
||||
]);
|
||||
});
|
||||
|
||||
test("typed market, KRX, NXT and world targets normalize without heuristic aliases", () => {
|
||||
assert.equal(workflow.getMarketTarget("코스피200 지수"), kospi200);
|
||||
assert.equal(workflow.getMarketTarget("sp500").target, "Sp500");
|
||||
assert.deepEqual(workflow.normalizeTarget({
|
||||
kind: "krx-stock", market: "KOSPI", name: " 삼성전자 ", code: "005930"
|
||||
}), samsung);
|
||||
assert.deepEqual(workflow.normalizeTarget({
|
||||
kind: "nxt-stock", market: "KOSPI", name: "삼성전자", code: "005930"
|
||||
}), nxtSamsung);
|
||||
assert.deepEqual(workflow.normalizeTarget(apple), apple);
|
||||
assert.equal(workflow.subjectTokenForTarget(kospi200), "Kospi200");
|
||||
assert.equal(workflow.subjectTokenForTarget(nxtSamsung), "삼성전자(NXT)");
|
||||
assert.equal(workflow.subjectTokenForTarget(apple), "Apple Inc");
|
||||
assert.equal(workflow.targetKey(nxtSamsung), "nxt:kospi:005930");
|
||||
|
||||
assert.equal(workflow.normalizeTarget({ ...kospi, displayName: "코스피" }), null);
|
||||
assert.equal(workflow.normalizeTarget({ ...samsung, stockName: "삼성,전자" }), null);
|
||||
assert.equal(workflow.normalizeTarget({ ...nxtSamsung, displayName: "삼성전자" }), null);
|
||||
assert.equal(workflow.normalizeTarget({ ...apple, nation: "JP" }), null);
|
||||
assert.equal(workflow.normalizeTarget({ ...apple, symbol: "AAPL;DROP" }), null);
|
||||
});
|
||||
|
||||
test("target double-click rotation, swap and clear preserve the original slot rules", () => {
|
||||
const empty = workflow.clearPair();
|
||||
assert.deepEqual(empty, [null, null]);
|
||||
const first = workflow.rotatePair(empty, samsung);
|
||||
assert.deepEqual(first, [samsung, null]);
|
||||
const second = workflow.rotatePair(first, skHynix);
|
||||
assert.deepEqual(second, [skHynix, samsung]);
|
||||
assert.deepEqual(workflow.swapPair(second), [samsung, skHynix]);
|
||||
assert.deepEqual(workflow.rotatePair(second, skHynix), [skHynix, skHynix]);
|
||||
assert.equal(workflow.normalizePair(first), null);
|
||||
});
|
||||
|
||||
test("CURRENT compatibility mirrors supported s8018 and s5032 branches and fails closed", () => {
|
||||
assert.equal(workflow.isActionAllowed("two-column-current", [kospi, kospi200]), true);
|
||||
assert.equal(workflow.isActionAllowed("two-column-current", [kospi, samsung]), true);
|
||||
assert.equal(workflow.isActionAllowed("two-column-current", [kospi, nxtSamsung]), true);
|
||||
assert.equal(workflow.isActionAllowed("two-column-current", [samsung, nxtSamsung]), true);
|
||||
assert.equal(workflow.isActionAllowed("two-column-current", [apple, samsung]), true);
|
||||
assert.equal(workflow.isActionAllowed("two-column-current", [apple, nxtSamsung]), true);
|
||||
|
||||
assert.equal(workflow.getCompatibility("two-column-current", [kospi, apple]).reason,
|
||||
"mixed-market-and-world-is-unsupported");
|
||||
assert.equal(workflow.getCompatibility("two-column-current", [futures, samsung]).reason,
|
||||
"futures-requires-a-market-target-counterpart");
|
||||
assert.equal(workflow.getCompatibility("two-column-current", [futures, futures]).reason,
|
||||
"two-futures-targets-are-unsupported");
|
||||
assert.equal(workflow.isActionAllowed("two-column-current", [futures, kospi]), true);
|
||||
});
|
||||
|
||||
test("EXPECTED and graph compatibility expose only combinations the Core can resolve", () => {
|
||||
assert.equal(workflow.isActionAllowed("two-column-expected", [kospi, samsung]), true);
|
||||
assert.equal(workflow.isActionAllowed("two-column-expected", [samsung, skHynix]), true);
|
||||
assert.equal(workflow.isActionAllowed("two-column-expected", [wonDollar, samsung]), false);
|
||||
assert.equal(workflow.isActionAllowed("two-column-expected", [nxtSamsung, samsung]), false);
|
||||
assert.equal(workflow.isActionAllowed("two-column-expected", [apple, samsung]), false);
|
||||
|
||||
const futuresExpected = workflow.getCompatibility("two-column-expected", [futures, wonDollar]);
|
||||
assert.equal(futuresExpected.supported, true);
|
||||
assert.equal(futuresExpected.effectiveMode, "CURRENT");
|
||||
assert.match(futuresExpected.warning, /current s5032/i);
|
||||
|
||||
for (const actionId of ["comparison-candle", "comparison-line", ...workflow.yieldActionIds]) {
|
||||
assert.equal(workflow.isActionAllowed(actionId, [samsung, skHynix]), true);
|
||||
assert.equal(workflow.isActionAllowed(actionId, [samsung, nxtSamsung]), false);
|
||||
assert.equal(workflow.isActionAllowed(actionId, [samsung, apple]), false);
|
||||
assert.equal(workflow.isActionAllowed(actionId, [kospi, samsung]), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("two-column builders use canonical market subjects and the correct dynamic alias", () => {
|
||||
const current = workflow.createComparisonPlaylistEntry("two-column-current", [kospi200, samsung], {
|
||||
id: "current-pair"
|
||||
});
|
||||
assert.equal(current.builderKey, "s8018");
|
||||
assert.equal(current.code, "8032");
|
||||
assert.deepEqual(current.aliases, ["8032"]);
|
||||
assert.deepEqual(current.selection, {
|
||||
groupCode: "INDEX", subject: "Kospi200,삼성전자",
|
||||
graphicType: "2열판", subtype: "CURRENT", dataCode: ""
|
||||
});
|
||||
|
||||
const stockPair = workflow.buildComparisonSelection("two-column-current", [nxtSamsung, apple]);
|
||||
assert.equal(stockPair.builderKey, "s8018");
|
||||
assert.equal(stockPair.selection.groupCode, "STOCK");
|
||||
assert.equal(stockPair.selection.subject, "삼성전자(NXT),Apple Inc");
|
||||
|
||||
const futuresPair = workflow.createComparisonPlaylistEntry("two-column-expected", [wonDollar, futures], {
|
||||
id: "futures-pair", fadeDuration: 0
|
||||
});
|
||||
assert.equal(futuresPair.builderKey, "s5032");
|
||||
assert.equal(futuresPair.code, "5032");
|
||||
assert.equal(futuresPair.selection.subtype, "EXPECTED");
|
||||
assert.equal(futuresPair.effectiveMode, "CURRENT");
|
||||
assert.equal(futuresPair.fadeDuration, 0);
|
||||
});
|
||||
|
||||
test("candle, line and all five return actions create explicit selections in legacy order", () => {
|
||||
const candle = workflow.createComparisonPlaylistEntry("comparison-candle", [samsung, skHynix], {
|
||||
id: "candle"
|
||||
});
|
||||
assert.equal(candle.builderKey, "s5026");
|
||||
assert.equal(candle.code, "5026");
|
||||
assert.deepEqual(candle.selection, {
|
||||
groupCode: "STOCK", subject: "삼성전자,SK하이닉스",
|
||||
graphicType: "COMPARISON_CANDLE", subtype: "FiveDays", dataCode: ""
|
||||
});
|
||||
|
||||
const line = workflow.createComparisonPlaylistEntry("comparison-line", [samsung, skHynix], {
|
||||
id: "line"
|
||||
});
|
||||
assert.equal(line.builderKey, "s5087");
|
||||
assert.equal(line.selection.graphicType, "COMPARISON_LINE");
|
||||
|
||||
const batch = workflow.createYieldBatchEntries([samsung, skHynix], {
|
||||
idPrefix: "pair-yield", fadeDuration: 4
|
||||
});
|
||||
assert.equal(batch.length, 5);
|
||||
assert.deepEqual(batch.map(value => value.id), [
|
||||
"pair-yield-5d", "pair-yield-1m", "pair-yield-3m", "pair-yield-6m", "pair-yield-12m"
|
||||
]);
|
||||
assert.deepEqual(batch.map(value => value.selection.subtype), [
|
||||
"FiveDays", "OneMonth", "ThreeMonths", "SixMonths", "TwelveMonths"
|
||||
]);
|
||||
assert.ok(batch.every(value => value.builderKey === "s5029" && value.code === "5029"));
|
||||
});
|
||||
|
||||
test("trusted restore reconstructs selections and rejects stored-entry tampering", () => {
|
||||
const original = workflow.createComparisonPlaylistEntry("two-column-current", [kospi, nxtSamsung], {
|
||||
id: "stored", fadeDuration: 7
|
||||
});
|
||||
assert.ok(workflow.restoreComparisonPlaylistEntry(original));
|
||||
assert.equal(workflow.restoreComparisonPlaylistEntry({ ...original, enabled: false }).enabled, false);
|
||||
assert.equal(workflow.restoreComparisonPlaylistEntry({ ...original, code: "5032" }), null);
|
||||
assert.equal(workflow.restoreComparisonPlaylistEntry({
|
||||
...original, selection: { ...original.selection, subject: "Kospi,삼성전자" }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreComparisonPlaylistEntry({
|
||||
...original, operator: { ...original.operator, assetSha256: "0".repeat(64) }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreComparisonPlaylistEntry({
|
||||
...original, operator: { ...original.operator, schemaVersion: 2 }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreComparisonPlaylistEntry({
|
||||
...original,
|
||||
operator: {
|
||||
...original.operator,
|
||||
pair: [{ ...kospi, displayName: "변조" }, nxtSamsung]
|
||||
}
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("saved pair list normalization is schema-versioned, bounded and duplicate-safe", () => {
|
||||
const first = workflow.createPairRecord("first", [samsung, skHynix]);
|
||||
const second = workflow.createPairRecord("second", [kospi, nxtSamsung]);
|
||||
assert.ok(first);
|
||||
assert.equal(workflow.createPairRecord("bad id", [samsung, skHynix]), null);
|
||||
assert.deepEqual(workflow.normalizePairList({ schemaVersion: 2, pairs: [first] }), {
|
||||
schemaVersion: 1, pairs: []
|
||||
});
|
||||
|
||||
const normalized = workflow.normalizePairList({
|
||||
schemaVersion: 1,
|
||||
pairs: [first, { id: "invalid", pair: [samsung, null] }, first, second]
|
||||
});
|
||||
assert.deepEqual(normalized.pairs.map(value => value.id), ["first", "second"]);
|
||||
assert.ok(Object.isFrozen(normalized));
|
||||
assert.ok(Object.isFrozen(normalized.pairs));
|
||||
|
||||
const appended = workflow.appendPair(normalized, "third", [apple, nxtSamsung]);
|
||||
assert.deepEqual(appended.pairs.map(value => value.id), ["first", "second", "third"]);
|
||||
assert.deepEqual(normalized.pairs.map(value => value.id), ["first", "second"]);
|
||||
assert.throws(() => workflow.appendPair(appended, first), /already exists/i);
|
||||
});
|
||||
|
||||
test("saved pair rows reorder and delete persistently even at the empty-list boundary", () => {
|
||||
let list = workflow.deleteAllPairs();
|
||||
list = workflow.appendPair(list, "first", [samsung, skHynix]);
|
||||
list = workflow.appendPair(list, "second", [kospi, nxtSamsung]);
|
||||
list = workflow.appendPair(list, "third", [apple, samsung]);
|
||||
|
||||
assert.deepEqual(workflow.reorderPairList(list, "first", "up").pairs.map(value => value.id),
|
||||
["first", "second", "third"]);
|
||||
list = workflow.reorderPairList(list, "third", -1);
|
||||
assert.deepEqual(list.pairs.map(value => value.id), ["first", "third", "second"]);
|
||||
list = workflow.reorderPairList(list, "first", "down");
|
||||
assert.deepEqual(list.pairs.map(value => value.id), ["third", "first", "second"]);
|
||||
list = workflow.deletePair(list, "first");
|
||||
assert.deepEqual(list.pairs.map(value => value.id), ["third", "second"]);
|
||||
list = workflow.deleteAllPairs();
|
||||
assert.deepEqual(list, { schemaVersion: 1, pairs: [] });
|
||||
});
|
||||
|
||||
test("unsafe ids, fades, incomplete pairs and unsupported combinations cannot create entries", () => {
|
||||
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, null], {
|
||||
id: "missing"
|
||||
}), /two comparison targets/i);
|
||||
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [kospi, apple], {
|
||||
id: "unsupported"
|
||||
}), /mixed-market-and-world/i);
|
||||
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, skHynix], {
|
||||
id: "bad id"
|
||||
}), /safe comparison playlist id/i);
|
||||
assert.throws(() => workflow.createComparisonPlaylistEntry("two-column-current", [samsung, skHynix], {
|
||||
id: "bad-fade", fadeDuration: 61
|
||||
}), /0 through 60/i);
|
||||
assert.equal(workflow.getCompatibility("unknown", [samsung, skHynix]).reason, "unknown-action");
|
||||
});
|
||||
310
tests/Web/expert-workflow.test.cjs
Normal file
310
tests/Web/expert-workflow.test.cjs
Normal file
@@ -0,0 +1,310 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/expert-workflow.js");
|
||||
|
||||
const expert = Object.freeze({
|
||||
expertCode: "0001",
|
||||
expertName: "Analyst A",
|
||||
source: "oracle"
|
||||
});
|
||||
|
||||
function previewRequest(id = "preview-1", maximumItems) {
|
||||
return workflow.createExpertPreviewRequest(id, expert, maximumItems);
|
||||
}
|
||||
|
||||
function previewPayload(items, overrides = {}) {
|
||||
return {
|
||||
requestId: "preview-1",
|
||||
selection: { expertCode: "0001", expertName: "Analyst A" },
|
||||
retrievedAt: "2026-07-11T23:10:00+09:00",
|
||||
totalRowCount: items.length,
|
||||
truncated: false,
|
||||
items,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const recommendations = Object.freeze([
|
||||
Object.freeze({ playIndex: 0, stockCode: "005930", stockName: "Samsung", buyAmount: 70000 }),
|
||||
Object.freeze({ playIndex: 2, stockCode: "035720", stockName: "Kakao", buyAmount: 42000 })
|
||||
]);
|
||||
|
||||
test("workflow pins the Oracle UC6 reader and one exact 5074 action", () => {
|
||||
assert.deepEqual(workflow.bridgeContract, {
|
||||
searchRequestType: "search-experts",
|
||||
searchResultType: "expert-search-results",
|
||||
searchErrorType: "expert-search-error",
|
||||
previewRequestType: "request-expert-preview",
|
||||
previewResultType: "expert-preview-results",
|
||||
previewErrorType: "expert-preview-error",
|
||||
defaultMaximumResults: 100,
|
||||
maximumResults: 500,
|
||||
maximumQueryLength: 64,
|
||||
defaultMaximumPreviewItems: 100,
|
||||
maximumPreviewItems: 100
|
||||
});
|
||||
assert.equal(workflow.sourceContract.provider, "oracle");
|
||||
assert.equal(workflow.sourceContract.expertTable, "EXPERT_LIST");
|
||||
assert.equal(workflow.sourceContract.recommendationTable, "RECOMMEND_LIST");
|
||||
assert.equal(workflow.sourceContract.pageSize, 5);
|
||||
assert.equal(workflow.sourceContract.maximumPageCount, 20);
|
||||
assert.deepEqual(workflow.actions, [{
|
||||
id: "five-row-current",
|
||||
label: "전문가 추천 · 5단 표그래프 · 현재가",
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
aliases: ["5074"],
|
||||
pageSize: 5
|
||||
}]);
|
||||
});
|
||||
|
||||
test("expert search requests are exact, bounded and normalize the original blank load", () => {
|
||||
assert.deepEqual(workflow.createExpertSearchRequest("search-1", " "), {
|
||||
requestId: "search-1",
|
||||
query: ""
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertSearchRequest("search-2", " Kim ", 25), {
|
||||
requestId: "search-2",
|
||||
query: "Kim",
|
||||
maximumResults: 25
|
||||
});
|
||||
assert.throws(() => workflow.createExpertSearchRequest("bad id", ""), /request id/);
|
||||
assert.throws(() => workflow.createExpertSearchRequest("safe", "bad\u0000query"), /query/);
|
||||
assert.throws(() => workflow.createExpertSearchRequest("safe", "", 0), /limited/);
|
||||
assert.throws(() => workflow.createExpertSearchRequest("safe", "", 501), /limited/);
|
||||
});
|
||||
|
||||
test("search responses keep typed code and name and reject ambiguity or reordering", () => {
|
||||
const request = workflow.createExpertSearchRequest("search-1", "", 5);
|
||||
const payload = {
|
||||
requestId: "search-1",
|
||||
query: "",
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
results: [
|
||||
{ expertCode: "0001", expertName: "Alpha", source: "oracle" },
|
||||
{ expertCode: "0002", expertName: "Beta", source: "oracle" }
|
||||
]
|
||||
};
|
||||
const normalized = workflow.normalizeExpertSearchResponse(payload, request);
|
||||
assert.ok(normalized);
|
||||
assert.deepEqual(normalized.results[0], {
|
||||
expertCode: "0001",
|
||||
expertName: "Alpha",
|
||||
source: "oracle"
|
||||
});
|
||||
assert.ok(Object.isFrozen(normalized.results));
|
||||
assert.equal(workflow.normalizeExpertSearchResponse({
|
||||
...payload,
|
||||
results: [...payload.results].reverse()
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeExpertSearchResponse({
|
||||
...payload,
|
||||
results: [payload.results[0], { ...payload.results[1], expertName: "Alpha" }]
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeExpertSearchResponse({
|
||||
...payload,
|
||||
results: [payload.results[0], { ...payload.results[1], expertCode: "0001" }]
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeExpertSearchResponse({
|
||||
...payload,
|
||||
results: [{ ...payload.results[0], source: "mariaDb" }, payload.results[1]]
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeExpertSearchResponse({ ...payload, requestId: "stale" }, request), null);
|
||||
});
|
||||
|
||||
test("search errors are exact and correlated to the active request", () => {
|
||||
const request = workflow.createExpertSearchRequest("search-1", "Kim");
|
||||
assert.deepEqual(workflow.normalizeExpertSearchError({
|
||||
requestId: "search-1",
|
||||
query: "Kim",
|
||||
message: "Database unavailable"
|
||||
}, request), {
|
||||
requestId: "search-1",
|
||||
query: "Kim",
|
||||
message: "Database unavailable"
|
||||
});
|
||||
assert.equal(workflow.normalizeExpertSearchError({
|
||||
requestId: "other",
|
||||
query: "Kim",
|
||||
message: "Database unavailable"
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeExpertSearchError({
|
||||
requestId: "search-1",
|
||||
query: "Kim",
|
||||
message: "bad\nmessage"
|
||||
}, request), null);
|
||||
});
|
||||
|
||||
test("preview requests bind the exact expert code and name", () => {
|
||||
assert.deepEqual(previewRequest("preview-1", 25), {
|
||||
requestId: "preview-1",
|
||||
expertCode: "0001",
|
||||
expertName: "Analyst A",
|
||||
maximumItems: 25
|
||||
});
|
||||
assert.throws(() => workflow.createExpertPreviewRequest("preview", {
|
||||
expertCode: "0001",
|
||||
expertName: "Analyst A",
|
||||
source: "mariaDb"
|
||||
}), /Oracle expert/);
|
||||
assert.throws(() => workflow.createExpertPreviewRequest("preview", {
|
||||
expertCode: "001",
|
||||
expertName: "Analyst A",
|
||||
source: "oracle"
|
||||
}), /Oracle expert/);
|
||||
assert.throws(() => previewRequest("preview", 101), /limited/);
|
||||
});
|
||||
|
||||
test("preview responses preserve PLAYINDEX and reject duplicate or unsafe rows", () => {
|
||||
const request = previewRequest();
|
||||
const normalized = workflow.normalizeExpertPreviewResponse(
|
||||
previewPayload(recommendations),
|
||||
request);
|
||||
assert.ok(normalized);
|
||||
assert.deepEqual(normalized.items, recommendations);
|
||||
assert.ok(Object.isFrozen(normalized.items));
|
||||
|
||||
for (const items of [
|
||||
[recommendations[0], { ...recommendations[1], playIndex: 0 }],
|
||||
[recommendations[0], { ...recommendations[1], stockCode: "005930" }],
|
||||
[recommendations[0], { ...recommendations[1], stockName: "Samsung" }],
|
||||
[recommendations[1], recommendations[0]],
|
||||
[{ ...recommendations[0], buyAmount: 0 }],
|
||||
[{ ...recommendations[0], buyAmount: 100.5 }],
|
||||
[{ ...recommendations[0], stockCode: "../005930" }]
|
||||
]) {
|
||||
assert.equal(workflow.normalizeExpertPreviewResponse(previewPayload(items), request), null);
|
||||
}
|
||||
assert.equal(workflow.normalizeExpertPreviewResponse(
|
||||
previewPayload(recommendations, {
|
||||
selection: { expertCode: "0002", expertName: "Analyst A" }
|
||||
}), request), null);
|
||||
});
|
||||
|
||||
test("only a native-normalized preview can create the closed PAGED_EXPERT selection", () => {
|
||||
const raw = previewPayload(recommendations);
|
||||
assert.equal(workflow.createSelectableExpert(expert, raw), null);
|
||||
const preview = workflow.normalizeExpertPreviewResponse(raw, previewRequest());
|
||||
const selected = workflow.createSelectableExpert(expert, preview);
|
||||
assert.deepEqual(selected, {
|
||||
expertCode: "0001",
|
||||
expertName: "Analyst A",
|
||||
source: "oracle",
|
||||
itemCount: 2,
|
||||
previewTruncated: false
|
||||
});
|
||||
assert.deepEqual(workflow.buildExpertSelection(selected), {
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
aliases: ["5074"],
|
||||
selection: {
|
||||
groupCode: "PAGED_EXPERT",
|
||||
subject: "Analyst A",
|
||||
graphicType: "INPUT_ORDER",
|
||||
subtype: "CURRENT",
|
||||
dataCode: "0001"
|
||||
}
|
||||
});
|
||||
assert.deepEqual(workflow.calculatePagePreview(selected), {
|
||||
itemCount: 2,
|
||||
accessibleItemCount: 2,
|
||||
pageSize: 5,
|
||||
pageCount: 1,
|
||||
maximumPageCount: 20,
|
||||
isTruncated: false
|
||||
});
|
||||
});
|
||||
|
||||
test("the 5 by 20 boundary is explicit and empty experts cannot create a cut", () => {
|
||||
const hundred = Array.from({ length: 100 }, (_, index) => ({
|
||||
playIndex: index,
|
||||
stockCode: String(index + 1).padStart(6, "0"),
|
||||
stockName: `Stock ${String(index + 1).padStart(3, "0")}`,
|
||||
buyAmount: 1000 + index
|
||||
}));
|
||||
const request = previewRequest("preview-1", 100);
|
||||
const preview = workflow.normalizeExpertPreviewResponse(
|
||||
previewPayload(hundred, { truncated: true }),
|
||||
request);
|
||||
const selected = workflow.createSelectableExpert(expert, preview);
|
||||
assert.deepEqual(workflow.calculatePagePreview(selected), {
|
||||
itemCount: 100,
|
||||
accessibleItemCount: 100,
|
||||
pageSize: 5,
|
||||
pageCount: 20,
|
||||
maximumPageCount: 20,
|
||||
isTruncated: true
|
||||
});
|
||||
|
||||
const emptyPreview = workflow.normalizeExpertPreviewResponse(
|
||||
previewPayload([]),
|
||||
previewRequest());
|
||||
const empty = workflow.createSelectableExpert(expert, emptyPreview);
|
||||
assert.throws(() => workflow.createExpertPlaylistEntry(empty, { id: "empty" }), /no recommendation/);
|
||||
});
|
||||
|
||||
test("playlist creation and trusted restore bind every identity and page field", () => {
|
||||
const preview = workflow.normalizeExpertPreviewResponse(
|
||||
previewPayload(recommendations),
|
||||
previewRequest());
|
||||
const selected = workflow.createSelectableExpert(expert, preview);
|
||||
const entry = workflow.createExpertPlaylistEntry(selected, {
|
||||
id: "expert-1",
|
||||
fadeDuration: 8
|
||||
});
|
||||
assert.equal(entry.builderKey, "s5074");
|
||||
assert.equal(entry.code, "5074");
|
||||
assert.equal(entry.pageSize, 5);
|
||||
assert.equal(entry.pageCount, 1);
|
||||
assert.equal(entry.fadeDuration, 8);
|
||||
assert.equal(entry.operator.schemaVersion, 1);
|
||||
|
||||
const stored = JSON.parse(JSON.stringify(entry));
|
||||
stored.enabled = false;
|
||||
const restored = workflow.restoreExpertPlaylistEntry(stored);
|
||||
assert.ok(restored);
|
||||
assert.equal(restored.enabled, false);
|
||||
assert.equal(restored.selection.dataCode, "0001");
|
||||
|
||||
for (const tamper of [
|
||||
value => { value.builderKey = "s5077"; },
|
||||
value => { value.code = "5077"; },
|
||||
value => { value.selection.subject = "Other"; },
|
||||
value => { value.selection.dataCode = "0002"; },
|
||||
value => { value.operator.expert.expertName = "Other"; },
|
||||
value => { value.operator.pagePreview.pageCount = 2; },
|
||||
value => { value.operator.actionId = "unknown"; },
|
||||
value => { value.enabled = "false"; }
|
||||
]) {
|
||||
const candidate = JSON.parse(JSON.stringify(entry));
|
||||
tamper(candidate);
|
||||
assert.equal(workflow.restoreExpertPlaylistEntry(candidate), null);
|
||||
}
|
||||
assert.throws(() => workflow.createExpertPlaylistEntry(selected, { id: "bad id" }), /options/);
|
||||
assert.throws(() => workflow.createExpertPlaylistEntry(selected, { id: "safe", fadeDuration: 61 }), /Fade/);
|
||||
});
|
||||
|
||||
test("preview errors are exact and bound to expert identity", () => {
|
||||
const request = previewRequest();
|
||||
assert.deepEqual(workflow.normalizeExpertPreviewError({
|
||||
requestId: "preview-1",
|
||||
selection: { expertCode: "0001", expertName: "Analyst A" },
|
||||
message: "Preview unavailable"
|
||||
}, request), {
|
||||
requestId: "preview-1",
|
||||
selection: { expertCode: "0001", expertName: "Analyst A" },
|
||||
message: "Preview unavailable"
|
||||
});
|
||||
assert.equal(workflow.normalizeExpertPreviewError({
|
||||
requestId: "preview-1",
|
||||
selection: { expertCode: "0002", expertName: "Analyst A" },
|
||||
message: "Preview unavailable"
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeExpertPreviewError({
|
||||
requestId: "preview-1",
|
||||
selection: { expertCode: "0001", expertName: "Analyst A" },
|
||||
message: "bad\u200Bmessage"
|
||||
}, request), null);
|
||||
});
|
||||
142
tests/Web/industry-workflow.test.cjs
Normal file
142
tests/Web/industry-workflow.test.cjs
Normal file
@@ -0,0 +1,142 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/industry-workflow.js");
|
||||
|
||||
const kospi = Object.freeze({ market: "kospi", name: "전기전자", code: "013" });
|
||||
const kospiSecond = Object.freeze({ market: "kospi", name: "운수장비", code: "015" });
|
||||
const kosdaq = Object.freeze({ market: "kosdaq", name: "반도체", code: "027" });
|
||||
const kosdaqSecond = Object.freeze({ market: "kosdaq", name: "IT부품", code: "029" });
|
||||
|
||||
test("industry runtime assets expose 22 exact cut templates per market", () => {
|
||||
assert.equal(workflow.cuts.length, 22);
|
||||
assert.equal(new Set(workflow.cuts.map(value => value.id)).size, 22);
|
||||
assert.equal(workflow.runtimeAssets.kospi.sha256, "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52");
|
||||
assert.equal(workflow.runtimeAssets.kosdaq.sha256, "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4");
|
||||
});
|
||||
|
||||
test("native industry rows normalize only bounded KOSPI and KOSDAQ identities", () => {
|
||||
assert.deepEqual(workflow.normalizeIndustry({ market: "KOSPI", name: " 전기전자 ", code: "013" }), kospi);
|
||||
assert.equal(workflow.normalizeIndustry({ market: "nxt-kospi", name: "전기전자", code: "013" }), null);
|
||||
assert.equal(workflow.normalizeIndustry({ market: "kospi", name: "전기\n전자", code: "013" }), null);
|
||||
assert.equal(workflow.normalizeIndustry({ market: "kospi", name: "전기전자", code: "01-3" }), null);
|
||||
});
|
||||
|
||||
test("single, pair, yield and candle actions project closed legacy selections", () => {
|
||||
const single = workflow.createIndustryPlaylistEntry("kospi", "one-column", {
|
||||
id: "single", selected: kospi
|
||||
});
|
||||
assert.equal(single.builderKey, "s5001");
|
||||
assert.equal(single.code, "5001");
|
||||
assert.deepEqual(single.selection, {
|
||||
groupCode: "INDUSTRY_KOSPI", subject: "전기전자",
|
||||
graphicType: "1열판", subtype: "CURRENT", dataCode: ""
|
||||
});
|
||||
|
||||
const pair = workflow.createIndustryPlaylistEntry("kosdaq", "two-column", {
|
||||
id: "pair", pair: [kosdaq, kosdaqSecond]
|
||||
});
|
||||
assert.equal(pair.builderKey, "s8018");
|
||||
assert.equal(pair.code, "8032");
|
||||
assert.equal(pair.selection.subject, "반도체-IT부품");
|
||||
|
||||
const yieldEntry = workflow.createIndustryPlaylistEntry("kospi", "yield-120일", {
|
||||
id: "yield", selected: kospi
|
||||
});
|
||||
assert.equal(yieldEntry.builderKey, "s5086");
|
||||
assert.equal(yieldEntry.selection.subtype, "OneHundredTwentyDays");
|
||||
|
||||
const candle = workflow.createIndustryPlaylistEntry("kosdaq", "candle-volume-240일", {
|
||||
id: "candle", selected: kosdaq
|
||||
});
|
||||
assert.equal(candle.builderKey, "s8010");
|
||||
assert.equal(candle.code, "8056");
|
||||
assert.equal(candle.selection.groupCode, "KOSDAQ_INDUSTRY");
|
||||
assert.equal(candle.selection.subtype, "VOLUME");
|
||||
});
|
||||
|
||||
test("market-wide industry actions need no selected industry and use explicit aliases", () => {
|
||||
const now = new Date(2026, 6, 11, 10, 0, 0);
|
||||
const five = workflow.createIndustryPlaylistEntry("kosdaq", "five-row", { id: "five", now });
|
||||
assert.equal(five.builderKey, "s5074");
|
||||
assert.equal(five.selection.groupCode, "PAGED_DOMESTIC_KOSDAQ");
|
||||
assert.equal(five.selection.subtype, "INDUSTRY");
|
||||
assert.equal(five.selection.dataCode, "2026-07-11");
|
||||
|
||||
const square = workflow.createIndustryPlaylistEntry("kosdaq", "square", { id: "square" });
|
||||
assert.equal(square.builderKey, "s8001");
|
||||
assert.equal(square.code, "8002");
|
||||
assert.equal(square.selection.subject, "KOSDAQ");
|
||||
|
||||
const sector = workflow.createIndustryPlaylistEntry("kospi", "sector", { id: "sector" });
|
||||
assert.equal(sector.builderKey, "s5078");
|
||||
assert.equal(sector.selection.graphicType, "SECTOR_INDEX");
|
||||
});
|
||||
|
||||
test("industry candles carry and restore the global moving-average flags", () => {
|
||||
const value = workflow.createIndustryPlaylistEntry("kospi", "candle-120일", {
|
||||
id: "industry-candle-flags",
|
||||
selected: kospi,
|
||||
ma5: true,
|
||||
ma20: true
|
||||
});
|
||||
assert.equal(value.selection.graphicType, "MA5,MA20");
|
||||
assert.deepEqual(value.operator.movingAverages, { ma5: true, ma20: true });
|
||||
assert.deepEqual(workflow.restoreIndustryPlaylistEntry(value), value);
|
||||
assert.equal(workflow.restoreIndustryPlaylistEntry({
|
||||
...value,
|
||||
selection: { ...value.selection, graphicType: "MA20" }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("legacy industry candle entries gain closed moving-average metadata on restore", () => {
|
||||
const value = workflow.createIndustryPlaylistEntry("kosdaq", "candle-volume-20일", {
|
||||
id: "industry-candle-legacy",
|
||||
selected: kosdaq
|
||||
});
|
||||
const legacy = {
|
||||
...value,
|
||||
operator: Object.freeze(Object.fromEntries(
|
||||
Object.entries(value.operator).filter(([key]) => key !== "movingAverages")))
|
||||
};
|
||||
assert.deepEqual(
|
||||
workflow.restoreIndustryPlaylistEntry(legacy).operator.movingAverages,
|
||||
{ ma5: false, ma20: false });
|
||||
});
|
||||
|
||||
test("selection prerequisites and cross-market values fail closed", () => {
|
||||
assert.equal(workflow.isCutAllowed("kospi", "one-column", null, null, null), false);
|
||||
assert.equal(workflow.isCutAllowed("kospi", "two-column", kospi, kospi, null), false);
|
||||
assert.equal(workflow.isCutAllowed("kospi", "two-column", null, kospi, kospiSecond), true);
|
||||
assert.equal(workflow.isCutAllowed("kospi", "five-row", null, null, null), true);
|
||||
assert.throws(() => workflow.createIndustryPlaylistEntry("kospi", "one-column", {
|
||||
id: "cross", selected: kosdaq
|
||||
}));
|
||||
});
|
||||
|
||||
test("stored industry entries restore from trusted bindings and reject tampering", () => {
|
||||
const value = workflow.createIndustryPlaylistEntry("kospi", "two-column", {
|
||||
id: "stored-pair", pair: [kospi, kospiSecond]
|
||||
});
|
||||
assert.ok(workflow.restoreIndustryPlaylistEntry(value));
|
||||
assert.equal(workflow.restoreIndustryPlaylistEntry({ ...value, code: "5032" }), null);
|
||||
assert.equal(workflow.restoreIndustryPlaylistEntry({
|
||||
...value,
|
||||
selection: { ...value.selection, subject: "전기전자-화학" }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreIndustryPlaylistEntry({
|
||||
...value,
|
||||
operator: { ...value.operator, assetSha256: "0".repeat(64) }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("paged industry dates refresh at restore time", () => {
|
||||
const original = workflow.createIndustryPlaylistEntry("kospi", "five-row", {
|
||||
id: "paged", now: new Date(2026, 6, 10, 23, 59, 0)
|
||||
});
|
||||
const refreshed = workflow.restoreIndustryPlaylistEntry(original, {
|
||||
now: new Date(2026, 6, 11, 0, 1, 0)
|
||||
});
|
||||
assert.equal(refreshed.selection.dataCode, "2026-07-11");
|
||||
});
|
||||
210
tests/Web/legacy-fixed-workflow.test.cjs
Normal file
210
tests/Web/legacy-fixed-workflow.test.cjs
Normal file
@@ -0,0 +1,210 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/legacy-fixed-workflow.js");
|
||||
|
||||
const knownAliases = new Map([
|
||||
["s5001", new Set(["5001"])],
|
||||
["s5016", new Set(["5016"])],
|
||||
["s50160", new Set(["50160"])],
|
||||
["s5023", new Set(["5023"])],
|
||||
["s5024", new Set(["5024"])],
|
||||
["s5025", new Set(["5025"])],
|
||||
["s5074", new Set(["5074"])],
|
||||
["s5077", new Set(["5077"])],
|
||||
["s5078", new Set(["5078"])],
|
||||
["s5082", new Set(["5082"])],
|
||||
["s5083", new Set(["5083"])],
|
||||
["s5084", new Set(["5084"])],
|
||||
["s5085", new Set(["5085"])],
|
||||
["s5086", new Set(["5086"])],
|
||||
["s50860", new Set(["50860"])],
|
||||
["s5088", new Set(["5088"])],
|
||||
["s6001", new Set(["6001"])],
|
||||
["s6067", new Set(["6067"])],
|
||||
["s8010", new Set(["8035", "8061", "8040", "8046", "8051", "8056"])],
|
||||
["s8067", new Set(["8067", "5068", "5070", "5072"])]
|
||||
]);
|
||||
|
||||
function action(market, section, label) {
|
||||
const result = workflow.actions.find(value =>
|
||||
value.market === market && value.section === section && value.label === label);
|
||||
assert.ok(result, `${market}/${section}/${label} action is missing`);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("runtime overseas, exchange and index assets expose all 328 leaf actions", () => {
|
||||
assert.equal(workflow.actions.length, 328);
|
||||
assert.equal(workflow.actionsForMarket("overseas").length, 78);
|
||||
assert.equal(workflow.actionsForMarket("exchange").length, 46);
|
||||
assert.equal(workflow.actionsForMarket("index").length, 204);
|
||||
assert.equal(workflow.actions.filter(value => value.available).length, 322);
|
||||
assert.equal(workflow.actions.filter(value => !value.available).length, 6);
|
||||
assert.equal(new Set(workflow.actions.map(value => value.id)).size, 328);
|
||||
assert.equal(workflow.runtimeAssets.overseas.sha256, "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A");
|
||||
assert.equal(workflow.runtimeAssets.exchange.sha256, "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C");
|
||||
assert.equal(workflow.runtimeAssets.index.sha256, "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6");
|
||||
});
|
||||
|
||||
test("every available fixed action creates a closed registered builder and alias", () => {
|
||||
for (const value of workflow.actions) {
|
||||
if (!value.available) {
|
||||
assert.throws(() => workflow.createFixedPlaylistEntry(value.id, { id: `entry-${value.id}` }));
|
||||
continue;
|
||||
}
|
||||
const entry = workflow.createFixedPlaylistEntry(value.id, {
|
||||
id: `entry-${value.id}`,
|
||||
now: new Date(2026, 6, 11, 9, 30, 0)
|
||||
});
|
||||
assert.equal(entry.operator.source, "legacy-fixed-workflow");
|
||||
assert.equal(entry.operator.actionId, value.id);
|
||||
assert.ok(knownAliases.get(entry.builderKey)?.has(entry.code), `${entry.builderKey}/${entry.code}`);
|
||||
assert.deepEqual(entry.aliases, [entry.code]);
|
||||
assert.equal(typeof entry.selection.groupCode, "string");
|
||||
assert.equal(typeof entry.selection.subject, "string");
|
||||
assert.equal(typeof entry.selection.graphicType, "string");
|
||||
assert.equal(typeof entry.selection.subtype, "string");
|
||||
assert.equal(typeof entry.selection.dataCode, "string");
|
||||
}
|
||||
});
|
||||
|
||||
test("representative overseas and exchange actions preserve closed native selectors", () => {
|
||||
const gold = workflow.createFixedPlaylistEntry(
|
||||
action("overseas", "1열판기본", "국제 금").id,
|
||||
{ id: "gold" });
|
||||
assert.equal(gold.builderKey, "s5001");
|
||||
assert.deepEqual(gold.selection, {
|
||||
groupCode: "COMMODITY", subject: "InternationalGold",
|
||||
graphicType: "1열판기본", subtype: "CURRENT", dataCode: ""
|
||||
});
|
||||
|
||||
const fx = workflow.createFixedPlaylistEntry(
|
||||
action("exchange", "라인 그래프", "원달러_20일").id,
|
||||
{ id: "fx" });
|
||||
assert.equal(fx.builderKey, "s50860");
|
||||
assert.equal(fx.selection.subject, "WonDollar");
|
||||
assert.equal(fx.selection.subtype, "TwentyDays");
|
||||
|
||||
const map = workflow.createFixedPlaylistEntry(
|
||||
action("overseas", "글로벌 증시 지도", "미국 증시 지도").id,
|
||||
{ id: "map" });
|
||||
assert.equal(map.builderKey, "s8067");
|
||||
assert.equal(map.code, "5068");
|
||||
});
|
||||
|
||||
test("index variants map current, candle, trend and paged rows without free text", () => {
|
||||
const current = workflow.createFixedPlaylistEntry(
|
||||
action("index", "코스피200", "1열판기본_현재지수").id,
|
||||
{ id: "current" });
|
||||
assert.deepEqual(current.selection, {
|
||||
groupCode: "KOSPI200", subject: "INDEX",
|
||||
graphicType: "1열판기본", subtype: "CURRENT", dataCode: ""
|
||||
});
|
||||
|
||||
const candle = workflow.createFixedPlaylistEntry(
|
||||
action("index", "선물", "캔들그래프(예상지수)_240일").id,
|
||||
{ id: "candle" });
|
||||
assert.equal(candle.builderKey, "s8010");
|
||||
assert.equal(candle.code, "8056");
|
||||
assert.equal(candle.selection.groupCode, "FUTURES_INDEX");
|
||||
assert.equal(candle.selection.subtype, "EXPECTED");
|
||||
|
||||
const trend = workflow.createFixedPlaylistEntry(
|
||||
action("index", "매매동향", "외국인 매매동향_라인그래프").id,
|
||||
{ id: "trend" });
|
||||
assert.equal(trend.selection.graphicType, "FOREIGN_TRADING_TREND");
|
||||
|
||||
const page = workflow.createFixedPlaylistEntry(
|
||||
action("index", "12종목 현재가", "코스닥 52주 신저가").id,
|
||||
{ id: "page", now: new Date(2026, 6, 11, 13, 0, 0) });
|
||||
assert.equal(page.builderKey, "s5088");
|
||||
assert.equal(page.selection.groupCode, "PAGED_DOMESTIC_KOSDAQ");
|
||||
assert.equal(page.selection.subtype, "52_WEEK_LOW");
|
||||
assert.equal(page.selection.dataCode, "2026-07-11");
|
||||
});
|
||||
|
||||
test("fixed candle actions carry the original global 5-day and 20-day flags", () => {
|
||||
const candleAction = workflow.actions.find(value => value.builderKey === "s8010");
|
||||
assert.ok(candleAction);
|
||||
const both = workflow.createFixedPlaylistEntry(candleAction.id, {
|
||||
id: "candle-both",
|
||||
ma5: true,
|
||||
ma20: true
|
||||
});
|
||||
assert.equal(both.selection.graphicType, "MA5,MA20");
|
||||
assert.deepEqual(both.operator.movingAverages, { ma5: true, ma20: true });
|
||||
assert.deepEqual(workflow.restoreFixedPlaylistEntry(both), both);
|
||||
|
||||
const ma20 = workflow.createFixedPlaylistEntry(candleAction.id, {
|
||||
id: "candle-ma20",
|
||||
ma5: false,
|
||||
ma20: true
|
||||
});
|
||||
assert.equal(ma20.selection.graphicType, "MA20");
|
||||
assert.deepEqual(workflow.restoreFixedPlaylistEntry(ma20), ma20);
|
||||
assert.equal(workflow.restoreFixedPlaylistEntry({
|
||||
...both,
|
||||
selection: { ...both.selection, graphicType: "" }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("pre-global-option fixed candle entries restore once through a closed flag migration", () => {
|
||||
const candleAction = workflow.actions.find(value => value.builderKey === "s8010");
|
||||
const legacy = workflow.createFixedPlaylistEntry(candleAction.id, { id: "legacy-candle" });
|
||||
const withoutMetadata = {
|
||||
...legacy,
|
||||
operator: Object.freeze(Object.fromEntries(
|
||||
Object.entries(legacy.operator).filter(([key]) => key !== "movingAverages")))
|
||||
};
|
||||
const restored = workflow.restoreFixedPlaylistEntry(withoutMetadata);
|
||||
assert.deepEqual(restored.operator.movingAverages, { ma5: false, ma20: false });
|
||||
});
|
||||
|
||||
test("NXT session and domestic date refresh at restore instead of remaining stale", () => {
|
||||
const nxtAction = action("index", "5단 표그래프", "코스피_NXT 거래량 상위");
|
||||
const original = workflow.createFixedPlaylistEntry(nxtAction.id, {
|
||||
id: "nxt-page", now: new Date(2026, 6, 11, 9, 0, 0)
|
||||
});
|
||||
assert.equal(original.selection.graphicType, "PRE_MARKET");
|
||||
const refreshed = workflow.restoreFixedPlaylistEntry(original, {
|
||||
now: new Date(2026, 6, 11, 15, 0, 0)
|
||||
});
|
||||
assert.equal(refreshed.selection.graphicType, "AFTER_MARKET");
|
||||
|
||||
const domesticAction = action("index", "6종목 현재가", "코스피 시가총액");
|
||||
const domestic = workflow.createFixedPlaylistEntry(domesticAction.id, {
|
||||
id: "domestic-page", now: new Date(2026, 6, 10, 23, 50, 0)
|
||||
});
|
||||
const nextDay = workflow.restoreFixedPlaylistEntry(domestic, {
|
||||
now: new Date(2026, 6, 11, 0, 10, 0)
|
||||
});
|
||||
assert.equal(nextDay.selection.dataCode, "2026-07-11");
|
||||
});
|
||||
|
||||
test("stored fixed entries fail closed after action, mapping, asset or selection tampering", () => {
|
||||
const value = workflow.createFixedPlaylistEntry(
|
||||
action("exchange", "1열판기본", "원엔").id,
|
||||
{ id: "stored" });
|
||||
assert.ok(workflow.restoreFixedPlaylistEntry(value));
|
||||
assert.equal(workflow.restoreFixedPlaylistEntry({ ...value, code: "5006" }), null);
|
||||
assert.equal(workflow.restoreFixedPlaylistEntry({
|
||||
...value,
|
||||
selection: { ...value.selection, subject: "WonDollar" }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreFixedPlaylistEntry({
|
||||
...value,
|
||||
operator: { ...value.operator, assetSha256: "0".repeat(64) }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreFixedPlaylistEntry({
|
||||
...value,
|
||||
operator: { ...value.operator, actionId: "fixed-999" }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("all three VI placeholders preserve the original VILIST five-row outcome", () => {
|
||||
const vi = workflow.actions.filter(value => value.label === "VI 발동(수동)");
|
||||
assert.equal(vi.length, 3);
|
||||
assert.ok(vi.every(value => !value.available));
|
||||
assert.ok(vi.every(value => value.builderKey === "s5074" && value.code === "5074"));
|
||||
});
|
||||
123
tests/Web/manual-financial-bridge-integration.test.cjs
Normal file
123
tests/Web/manual-financial-bridge-integration.test.cjs
Normal file
@@ -0,0 +1,123 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const workflow = require("../../Web/manual-financial-workflow.js");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const bridge = fs.readFileSync(
|
||||
path.join(repositoryRoot, "MainWindow.ManualFinancial.cs"),
|
||||
"utf8");
|
||||
const playoutBridge = fs.readFileSync(
|
||||
path.join(repositoryRoot, "MainWindow.Playout.cs"),
|
||||
"utf8");
|
||||
const operatorGate = fs.readFileSync(
|
||||
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"),
|
||||
"utf8");
|
||||
|
||||
test("native partial exposes every strict workflow request and response", () => {
|
||||
for (const [name, value] of Object.entries(workflow.bridgeContract)) {
|
||||
if (name.endsWith("RequestType") || name.endsWith("ResultType") || name.endsWith("ErrorType")) {
|
||||
assert.match(bridge, new RegExp(`"${value}"`));
|
||||
}
|
||||
}
|
||||
assert.match(bridge, /private bool TryHandleManualFinancialRequest\(/);
|
||||
assert.match(bridge, /HasOnlyProperties\(payload, "requestId", "screen", "stock", "record"\)/);
|
||||
assert.match(bridge, /element\.GetArrayLength\(\) != 5/);
|
||||
assert.match(bridge, /element\.GetArrayLength\(\) != 4/);
|
||||
assert.match(bridge, /element\.GetArrayLength\(\) != 6/);
|
||||
});
|
||||
|
||||
test("runtime helper composes the typed service with the non-retrying Oracle executor", () => {
|
||||
assert.match(bridge,
|
||||
/new LegacyManualFinancialScreenService\(\s*runtime\.Executor,\s*new OracleManualFinancialMutationExecutor\(/s);
|
||||
assert.match(bridge, /runtime\.ConnectionFactory/);
|
||||
assert.match(bridge, /runtime\.Options\.Resilience/);
|
||||
});
|
||||
|
||||
test("list, load and selection reads own independent correlation lanes", () => {
|
||||
assert.match(bridge,
|
||||
/Dictionary<string, ManualFinancialReadRequest> _manualFinancialReadLanes/);
|
||||
assert.match(bridge,
|
||||
/new ManualFinancialReadRequest\(\s*operation,\s*requestId,/s);
|
||||
assert.match(bridge, /RegisterManualFinancialRead\(request\)/);
|
||||
assert.match(bridge, /_manualFinancialReadLanes\[request\.Lane\] = request/);
|
||||
assert.doesNotMatch(bridge, /_manualFinancialReadCancellation/);
|
||||
assert.match(bridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/);
|
||||
assert.ok((bridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 2);
|
||||
assert.match(bridge, /browserGeneration != Volatile\.Read\(ref _manualFinancialBrowserGeneration\)/);
|
||||
assert.match(bridge, /!TryCompleteManualFinancialReadIfCurrent\(request\)/);
|
||||
});
|
||||
|
||||
test("same-lane supersede emits one correlated terminal cancellation", () => {
|
||||
assert.match(bridge, /CancelRequest\(superseded\.Cancellation\)/);
|
||||
assert.match(bridge, /superseded\?\.TryComplete\(\)/);
|
||||
assert.match(bridge, /"CANCELLED"/);
|
||||
assert.match(bridge, /newer request replaced it/);
|
||||
assert.match(bridge,
|
||||
/Interlocked\.CompareExchange\(ref _terminalState, 1, 0\) == 0/);
|
||||
assert.match(bridge,
|
||||
/TryCompleteManualFinancialReadIfCurrent\(request\)[\s\S]*PostMessage\(reply\.MessageType/s);
|
||||
assert.match(bridge,
|
||||
/ReferenceEquals\(current, request\) &&[\s\S]*request\.TryComplete\(\)/s);
|
||||
});
|
||||
|
||||
test("writes are single-flight and impossible during command, PREPARED, PROGRAM or refresh", () => {
|
||||
assert.match(bridge, /_manualFinancialWriteGate\.WaitAsync\(0\)/);
|
||||
assert.match(bridge, /_playoutCommandGate\.WaitAsync\(0, requestToken\)/);
|
||||
assert.match(bridge, /_databaseActivityGate\.WaitAsync\(0, requestToken\)/);
|
||||
assert.match(bridge, /CanStartManualFinancialWrite\(\)/);
|
||||
assert.match(bridge,
|
||||
/CanStartOperatorMutation\(IsManualFinancialWriteQuarantined\(\)\)/);
|
||||
assert.match(playoutBridge, /private bool CanStartOperatorMutation\(/);
|
||||
assert.match(playoutBridge, /new OperatorMutationGateSnapshot\(/);
|
||||
assert.match(playoutBridge, /IsEngineAvailable: engine is not null/);
|
||||
assert.match(playoutBridge, /IsWorkflowAvailable: workflow is not null/);
|
||||
assert.match(playoutBridge, /IsRefreshTaskRunning: _legacyRefreshTask is \{ IsCompleted: false \}/);
|
||||
for (const condition of [
|
||||
"IsCommandAvailable", "IsPlayCompletionPending", "PreparedSceneName", "OnAirSceneName",
|
||||
"PreparedCutCode", "OnAirCutCode", "IsRefreshActive", "IsRefreshTaskRunning"
|
||||
]) {
|
||||
assert.match(operatorGate, new RegExp(`snapshot\\.${condition}`));
|
||||
}
|
||||
assert.match(bridge, /"PLAYOUT_ACTIVE"/);
|
||||
});
|
||||
|
||||
test("OutcomeUnknown and browser correlation loss latch a process-lifetime no-retry quarantine", () => {
|
||||
assert.match(bridge, /catch \(ManualFinancialMutationException exception\)/);
|
||||
assert.match(bridge, /if \(exception\.OutcomeUnknown\)/);
|
||||
assert.match(bridge,
|
||||
/Interlocked\.CompareExchange\(ref _manualFinancialWriteQuarantined, 1, 0\)/);
|
||||
assert.match(bridge, /retryable = false/);
|
||||
assert.match(bridge, /rolled back and was not retried/i);
|
||||
assert.doesNotMatch(bridge, /for\s*\([^)]*retry|while\s*\([^)]*retry/i);
|
||||
});
|
||||
|
||||
test("browser, playout and shutdown hooks cancel correlation without clearing quarantine", () => {
|
||||
assert.match(bridge, /private void InvalidateManualFinancialBrowserRequests\(\)/);
|
||||
assert.match(bridge, /Interlocked\.Increment\(ref _manualFinancialBrowserGeneration\)/);
|
||||
assert.match(bridge, /Volatile\.Read\(ref _manualFinancialWriteInFlight\) != 0/);
|
||||
assert.match(bridge,
|
||||
/Interlocked\.Exchange\(ref _manualFinancialWriteCancellation, null\)/);
|
||||
assert.match(bridge, /private void CancelManualFinancialReadsForPlayout\(\)/);
|
||||
assert.match(bridge, /private void ShutdownManualFinancialRuntime\(\)/);
|
||||
assert.ok((bridge.match(/SnapshotManualFinancialReads\(clear: true\)/g) || []).length >= 2);
|
||||
assert.match(bridge,
|
||||
/CancelManualFinancialReadsForPlayout\(\)[\s\S]*SnapshotManualFinancialReads\(clear: false\)/s);
|
||||
});
|
||||
|
||||
test("selection and restore boundaries re-read row version before exposing a cut", () => {
|
||||
assert.match(bridge, /var snapshot = await service\.GetAsync\(identity, cancellationToken\)/);
|
||||
assert.match(bridge, /EnsureManualFinancialRowVersion\(snapshot, rowVersion\)/);
|
||||
assert.match(bridge, /ResolveManualFinancialStockAsync\(/);
|
||||
assert.match(bridge, /ManualFinancialCutContracts\.CreateSelection\(snapshot, verified\)/);
|
||||
assert.match(bridge, /"manual-financial-selection-results"/);
|
||||
});
|
||||
|
||||
test("successful writes return the real transaction receipt fields", () => {
|
||||
assert.match(bridge, /operationId = receipt\.OperationId\.ToString\("N"/);
|
||||
assert.match(bridge, /receipt\.CommittedAt/);
|
||||
assert.match(bridge, /receipt\.AffectedRows/);
|
||||
});
|
||||
372
tests/Web/manual-financial-ui.test.cjs
Normal file
372
tests/Web/manual-financial-ui.test.cjs
Normal file
@@ -0,0 +1,372 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/manual-financial-workflow.js");
|
||||
const ui = require("../../Web/manual-financial-ui.js");
|
||||
|
||||
const rowVersion = "A".repeat(64);
|
||||
const STOCK_RESULT = "stock-search-results";
|
||||
|
||||
function quarters() {
|
||||
return [
|
||||
{ quarter: "1Q", value: 10 },
|
||||
{ quarter: "2Q", value: -20 },
|
||||
{ quarter: "3Q", value: 30 },
|
||||
{ quarter: "4Q", value: 40 },
|
||||
{ quarter: "5Q", value: 50 },
|
||||
{ quarter: "6Q", value: 60 }
|
||||
];
|
||||
}
|
||||
|
||||
function records() {
|
||||
return {
|
||||
"revenue-composition": {
|
||||
kind: "revenue-composition",
|
||||
stockName: "Alpha",
|
||||
baseDate: "2026-06",
|
||||
slices: [
|
||||
{ label: "Semiconductor", percentageText: "50.0", percentage: 50 },
|
||||
{ label: "Service", percentageText: "-", percentage: 0 },
|
||||
null,
|
||||
{ label: "Overseas", percentageText: "25", percentage: 25 },
|
||||
null
|
||||
]
|
||||
},
|
||||
"growth-metrics": {
|
||||
kind: "growth-metrics",
|
||||
stockName: "Alpha",
|
||||
salesGrowth: [1, 2.5, null, -4],
|
||||
operatingProfitGrowth: [5, 6, 7, 8],
|
||||
netAssetGrowth: [9, null, 11, 12],
|
||||
netIncomeGrowth: [13, 14, 15, 16],
|
||||
periods: ["1Q", "2Q", "3Q", "4Q"]
|
||||
},
|
||||
sales: {
|
||||
kind: "sales",
|
||||
stockName: "Alpha",
|
||||
quarters: quarters()
|
||||
},
|
||||
"operating-profit": {
|
||||
kind: "operating-profit",
|
||||
stockName: "Alpha",
|
||||
quarters: quarters().map(item => ({ ...item, value: item.value * 2 }))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const posted = [];
|
||||
const entries = [];
|
||||
const toasts = [];
|
||||
const logs = [];
|
||||
let ordinal = 0;
|
||||
const controller = ui.createController({
|
||||
postNative(type, payload) {
|
||||
posted.push({ type, payload });
|
||||
},
|
||||
appendPlaylistEntry(entry) {
|
||||
entries.push(entry);
|
||||
return true;
|
||||
},
|
||||
isLocked() {
|
||||
return false;
|
||||
},
|
||||
getSelectedStock() {
|
||||
return { market: "kospi", source: "oracle", name: "Alpha", code: "000001" };
|
||||
},
|
||||
showToast(message) {
|
||||
toasts.push(message);
|
||||
},
|
||||
addLog(message) {
|
||||
logs.push(message);
|
||||
},
|
||||
createId() {
|
||||
ordinal += 1;
|
||||
return `manual-ui-${ordinal}`;
|
||||
}
|
||||
});
|
||||
return { controller, posted, entries, toasts, logs };
|
||||
}
|
||||
|
||||
test("all four GraphE records round-trip through pure form conversion", () => {
|
||||
for (const [screen, record] of Object.entries(records())) {
|
||||
const form = ui.recordToForm(screen, record);
|
||||
assert.ok(form, screen);
|
||||
assert.deepEqual(ui.formToRecord(screen, form), workflow.normalizeRecord(screen, record));
|
||||
}
|
||||
|
||||
const revenue = ui.recordToForm("revenue-composition", records()["revenue-composition"]);
|
||||
assert.equal(ui.formToRecord("revenue-composition", {
|
||||
...revenue,
|
||||
slice3Label: "Only label",
|
||||
slice3Percentage: ""
|
||||
}), null);
|
||||
|
||||
const growth = ui.recordToForm("growth-metrics", records()["growth-metrics"]);
|
||||
assert.equal(ui.formToRecord("growth-metrics", {
|
||||
...growth,
|
||||
salesGrowth2: "not-a-number"
|
||||
}), null);
|
||||
|
||||
const sales = ui.recordToForm("sales", records().sales);
|
||||
assert.equal(ui.formToRecord("sales", { ...sales, value4: "10.5" }), null);
|
||||
assert.equal(ui.resolveScreen("manual-operating-profit"), "operating-profit");
|
||||
assert.equal(ui.resolveScreen({ id: "manual-revenue-mix" }), "revenue-composition");
|
||||
assert.equal(ui.resolveScreen({ screen: "growth-metrics" }), "growth-metrics");
|
||||
});
|
||||
|
||||
test("controller accepts only the pending list correlation and ignores shared stock traffic", () => {
|
||||
const harness = createHarness();
|
||||
assert.equal(harness.controller.open({ id: "manual-sales" }), true);
|
||||
assert.equal(harness.posted.length, 1);
|
||||
assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType);
|
||||
const request = harness.posted[0].payload;
|
||||
assert.deepEqual(request, {
|
||||
requestId: "manual-ui-1",
|
||||
screen: "sales",
|
||||
query: "",
|
||||
maximumResults: workflow.bridgeContract.defaultMaximumResults
|
||||
});
|
||||
assert.equal(harness.controller.render().pending.list, request.requestId);
|
||||
|
||||
const payload = {
|
||||
requestId: request.requestId,
|
||||
screen: "sales",
|
||||
query: "",
|
||||
retrievedAt: "2026-07-12T10:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
items: [{
|
||||
stockName: "Alpha",
|
||||
rowVersion,
|
||||
record: records().sales
|
||||
}]
|
||||
};
|
||||
assert.equal(harness.controller.handleMessage(
|
||||
workflow.bridgeContract.listResultType,
|
||||
{ ...payload, requestId: "stale-list" }), false);
|
||||
assert.equal(harness.controller.render().pending.list, request.requestId);
|
||||
assert.equal(harness.controller.render().itemCount, 0);
|
||||
|
||||
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, payload), true);
|
||||
assert.equal(harness.controller.render().pending.list, undefined);
|
||||
assert.equal(harness.controller.render().itemCount, 1);
|
||||
assert.equal(harness.controller.render().status, "ready");
|
||||
|
||||
assert.equal(harness.controller.handleMessage(STOCK_RESULT, {
|
||||
requestId: "another-component",
|
||||
query: "Alpha",
|
||||
retrievedAt: "2026-07-12T10:01:00+09:00",
|
||||
totalRowCount: 0,
|
||||
truncated: false,
|
||||
results: []
|
||||
}), false);
|
||||
assert.equal(harness.controller.setLocked(true).locked, true);
|
||||
assert.equal(harness.controller.setLocked(false).locked, false);
|
||||
});
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName, ownerDocument) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.dataset = {};
|
||||
this.textContent = "";
|
||||
this.value = "";
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.readOnly = false;
|
||||
this.className = "";
|
||||
}
|
||||
|
||||
append(...values) {
|
||||
for (const value of values) this.appendChild(value);
|
||||
}
|
||||
|
||||
appendChild(value) {
|
||||
this.children.push(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
replaceChildren(...values) {
|
||||
this.children = [];
|
||||
this.append(...values);
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
|
||||
addEventListener(name, callback) {
|
||||
this.listeners.set(name, callback);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
constructor() {
|
||||
this.body = new FakeElement("body", this);
|
||||
}
|
||||
|
||||
createElement(tagName) {
|
||||
return new FakeElement(tagName, this);
|
||||
}
|
||||
}
|
||||
|
||||
function findByClass(node, className) {
|
||||
if (String(node.className || "").split(/\s+/).includes(className)) return node;
|
||||
for (const child of node.children || []) {
|
||||
const found = findByClass(child, className);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test("mount creates the isolated text-only dialog and exposes exactly the integration methods", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
assert.equal(overlay.tagName, "DIV");
|
||||
assert.equal(overlay.className, "mfui-overlay");
|
||||
assert.equal(document.body.children.length, 1);
|
||||
assert.deepEqual(Object.keys(harness.controller), [
|
||||
"mount", "open", "handleMessage", "render", "setLocked"
|
||||
]);
|
||||
assert.equal(harness.controller.open("revenue-composition"), true);
|
||||
assert.equal(overlay.hidden, false);
|
||||
assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType);
|
||||
});
|
||||
|
||||
test("fresh load, exact stock revalidation and native selection create one playable entry", () => {
|
||||
const harness = createHarness();
|
||||
const document = new FakeDocument();
|
||||
const overlay = harness.controller.mount(document.body);
|
||||
harness.controller.open("sales");
|
||||
const listRequest = harness.posted.at(-1).payload;
|
||||
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, {
|
||||
requestId: listRequest.requestId,
|
||||
screen: "sales",
|
||||
query: "",
|
||||
retrievedAt: "2026-07-12T10:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
items: [{ stockName: "Alpha", rowVersion, record: records().sales }]
|
||||
}), true);
|
||||
|
||||
const listRow = findByClass(overlay, "mfui-list-row");
|
||||
assert.ok(listRow);
|
||||
listRow.listeners.get("click")();
|
||||
const loadCall = harness.posted.at(-1);
|
||||
assert.equal(loadCall.type, workflow.bridgeContract.loadRequestType);
|
||||
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.loadResultType, {
|
||||
requestId: loadCall.payload.requestId,
|
||||
screen: "sales",
|
||||
retrievedAt: "2026-07-12T10:01:00+09:00",
|
||||
snapshot: { stockName: "Alpha", rowVersion, record: records().sales }
|
||||
}), true);
|
||||
assert.equal(harness.controller.render().hasSnapshot, true);
|
||||
|
||||
const playlistButton = findByClass(overlay, "mfui-accent");
|
||||
assert.ok(playlistButton);
|
||||
playlistButton.listeners.get("click")();
|
||||
const truncatedSearch = harness.posted.at(-1);
|
||||
assert.equal(truncatedSearch.type, "search-stocks");
|
||||
harness.controller.handleMessage(STOCK_RESULT, {
|
||||
requestId: truncatedSearch.payload.requestId,
|
||||
query: "Alpha",
|
||||
retrievedAt: "2026-07-12T10:02:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: true,
|
||||
results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }]
|
||||
});
|
||||
assert.equal(harness.entries.length, 0);
|
||||
assert.equal(harness.controller.render().stockStatus, "error");
|
||||
|
||||
playlistButton.listeners.get("click")();
|
||||
const stockCall = harness.posted.at(-1);
|
||||
harness.controller.handleMessage(STOCK_RESULT, {
|
||||
requestId: stockCall.payload.requestId,
|
||||
query: "Alpha",
|
||||
retrievedAt: "2026-07-12T10:03:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }]
|
||||
});
|
||||
const selectionCall = harness.posted.at(-1);
|
||||
assert.equal(selectionCall.type, workflow.bridgeContract.selectionRequestType);
|
||||
const profile = workflow.getScreenDefinition("sales");
|
||||
harness.controller.handleMessage(workflow.bridgeContract.selectionResultType, {
|
||||
requestId: selectionCall.payload.requestId,
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
rowVersion,
|
||||
builderKey: profile.builderKey,
|
||||
code: profile.code,
|
||||
aliases: [profile.code],
|
||||
selection: {
|
||||
groupCode: "KOSPI",
|
||||
subject: "Alpha",
|
||||
graphicType: profile.graphicType,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
},
|
||||
currentPage: 1,
|
||||
totalPages: 1
|
||||
});
|
||||
assert.equal(harness.entries.length, 1);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(harness.entries[0]), true);
|
||||
assert.equal(harness.entries[0].code, "5080");
|
||||
});
|
||||
|
||||
test("UI source uses DOM construction and documents the isolated app integration hook", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "Web", "manual-financial-ui.js"),
|
||||
"utf8");
|
||||
assert.match(source, /App integration hook/);
|
||||
assert.match(source, /createElement/);
|
||||
assert.doesNotMatch(source, /\.innerHTML\s*=/);
|
||||
assert.doesNotMatch(source, /insertAdjacentHTML/);
|
||||
});
|
||||
|
||||
test("pending writes correlate exactly and OutcomeUnknown latches process quarantine", () => {
|
||||
const coordinator = ui.createPendingCoordinator();
|
||||
const expected = {
|
||||
requestId: "write-1",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
expectedRowVersion: rowVersion
|
||||
};
|
||||
coordinator.set("write", expected, { operation: "delete-one" });
|
||||
|
||||
const unknown = {
|
||||
requestId: "write-1",
|
||||
operation: "delete-one",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "Commit result is unknown",
|
||||
retryable: false,
|
||||
outcomeUnknown: true
|
||||
};
|
||||
assert.equal(coordinator.acceptMutationError({
|
||||
...unknown,
|
||||
requestId: "stale-write"
|
||||
}).status, "stale");
|
||||
assert.equal(coordinator.snapshot().write, "write-1");
|
||||
assert.equal(ui.getProcessWriteQuarantine().active, false);
|
||||
|
||||
const accepted = coordinator.acceptMutationError(unknown);
|
||||
assert.equal(accepted.status, "accepted");
|
||||
assert.equal(accepted.value.outcomeUnknown, true);
|
||||
assert.equal(coordinator.snapshot().write, undefined);
|
||||
assert.equal(ui.getProcessWriteQuarantine().active, true);
|
||||
assert.match(ui.getProcessWriteQuarantine().message, /unknown/i);
|
||||
|
||||
const laterController = createHarness().controller;
|
||||
assert.equal(laterController.render().writeQuarantined, true);
|
||||
assert.equal(laterController.render().canWrite, false);
|
||||
});
|
||||
740
tests/Web/manual-financial-workflow.test.cjs
Normal file
740
tests/Web/manual-financial-workflow.test.cjs
Normal file
@@ -0,0 +1,740 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/manual-financial-workflow.js");
|
||||
|
||||
const version = "A".repeat(64);
|
||||
|
||||
function quarters() {
|
||||
return [
|
||||
{ quarter: "1Q", value: 10 },
|
||||
{ quarter: "2Q", value: -20 },
|
||||
{ quarter: "3Q", value: 30 },
|
||||
{ quarter: "4Q", value: 40 },
|
||||
{ quarter: "5Q", value: 50 },
|
||||
{ quarter: "6Q", value: 60 }
|
||||
];
|
||||
}
|
||||
|
||||
function record(screen, stockName = "Alpha") {
|
||||
switch (screen) {
|
||||
case "revenue-composition":
|
||||
return {
|
||||
kind: screen,
|
||||
stockName,
|
||||
baseDate: "2026-06",
|
||||
slices: [
|
||||
{ label: "Semiconductor", percentageText: "50.0", percentage: 50 },
|
||||
{ label: "Service", percentageText: "-", percentage: 0 },
|
||||
{ label: "Overseas", percentageText: "25", percentage: 25 },
|
||||
null,
|
||||
null
|
||||
]
|
||||
};
|
||||
case "growth-metrics":
|
||||
return {
|
||||
kind: screen,
|
||||
stockName,
|
||||
salesGrowth: [1, 2, 3, 4],
|
||||
operatingProfitGrowth: [5, 6, null, 8],
|
||||
netAssetGrowth: [9, 10, 11, 12],
|
||||
netIncomeGrowth: [13, 14, 15, 16],
|
||||
periods: ["1Q", "2Q", "3Q", "4Q"]
|
||||
};
|
||||
case "sales":
|
||||
case "operating-profit":
|
||||
return { kind: screen, stockName, quarters: quarters() };
|
||||
default:
|
||||
throw new Error("bad screen");
|
||||
}
|
||||
}
|
||||
|
||||
function listRequest(screen = "sales") {
|
||||
return workflow.createListRequest("list-1", screen, "Alpha", 25);
|
||||
}
|
||||
|
||||
function listPayload(screen = "sales", records = [record(screen)]) {
|
||||
return {
|
||||
requestId: "list-1",
|
||||
screen,
|
||||
query: "Alpha",
|
||||
retrievedAt: "2026-07-12T00:10:00+09:00",
|
||||
totalRowCount: records.length,
|
||||
truncated: false,
|
||||
items: records.map((item, index) => ({
|
||||
stockName: item.stockName,
|
||||
rowVersion: String.fromCharCode(65 + index).repeat(64),
|
||||
record: item
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function trustedSnapshot(screen = "sales", stockName = "Alpha") {
|
||||
const request = workflow.createListRequest("list-1", screen, "Alpha", 25);
|
||||
const response = workflow.normalizeListResponse(
|
||||
listPayload(screen, [record(screen, stockName)]),
|
||||
request);
|
||||
assert.ok(response);
|
||||
return response.items[0];
|
||||
}
|
||||
|
||||
function stockPayload(results = [{
|
||||
market: "kospi",
|
||||
source: "oracle",
|
||||
name: "Alpha",
|
||||
code: "000001"
|
||||
}]) {
|
||||
return {
|
||||
requestId: "stock-1",
|
||||
query: "Alpha",
|
||||
retrievedAt: "2026-07-12T00:11:00+09:00",
|
||||
totalRowCount: results.length,
|
||||
truncated: false,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
function verifiedStock(recordValue = record("sales"), results) {
|
||||
const response = workflow.normalizeStockSearchResponse(stockPayload(results));
|
||||
assert.ok(response);
|
||||
const verified = workflow.verifyStockForRecord(
|
||||
recordValue,
|
||||
response,
|
||||
"kospi",
|
||||
"000001");
|
||||
assert.ok(verified);
|
||||
return verified;
|
||||
}
|
||||
|
||||
function trustedSelection(snapshot, stock, requestId = "selection-1") {
|
||||
const request = workflow.createSelectionRequest(requestId, snapshot, stock);
|
||||
const profile = workflow.getScreenDefinition(snapshot.screen);
|
||||
const groupCode = {
|
||||
kospi: "KOSPI",
|
||||
kosdaq: "KOSDAQ",
|
||||
"nxt-kospi": "NXT_KOSPI",
|
||||
"nxt-kosdaq": "NXT_KOSDAQ"
|
||||
}[stock.market];
|
||||
const response = workflow.normalizeSelectionResponse({
|
||||
requestId,
|
||||
screen: snapshot.screen,
|
||||
stockName: snapshot.stockName,
|
||||
rowVersion: snapshot.rowVersion,
|
||||
builderKey: profile.builderKey,
|
||||
code: profile.code,
|
||||
aliases: [profile.code],
|
||||
selection: {
|
||||
groupCode,
|
||||
subject: snapshot.stockName,
|
||||
graphicType: profile.graphicType,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
},
|
||||
currentPage: 1,
|
||||
totalPages: 1
|
||||
}, request);
|
||||
assert.ok(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
function trustedLoad(snapshot, requestId = "load-1") {
|
||||
const request = workflow.createLoadRequest(requestId, snapshot.screen, snapshot.stockName);
|
||||
const response = workflow.normalizeLoadResponse({
|
||||
requestId,
|
||||
screen: snapshot.screen,
|
||||
retrievedAt: "2026-07-12T00:20:00+09:00",
|
||||
snapshot: {
|
||||
stockName: snapshot.stockName,
|
||||
rowVersion: snapshot.rowVersion,
|
||||
record: snapshot.record
|
||||
}
|
||||
}, request);
|
||||
assert.ok(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
test("workflow pins GraphE Oracle tables, CRUD bridge and four scene actions", () => {
|
||||
assert.deepEqual(workflow.bridgeContract, {
|
||||
requestErrorType: "manual-financial-request-error",
|
||||
listRequestType: "request-manual-financial-list",
|
||||
listResultType: "manual-financial-list-results",
|
||||
listErrorType: "manual-financial-list-error",
|
||||
loadRequestType: "request-manual-financial-load",
|
||||
loadResultType: "manual-financial-load-results",
|
||||
loadErrorType: "manual-financial-load-error",
|
||||
selectionRequestType: "request-manual-financial-selection",
|
||||
selectionResultType: "manual-financial-selection-results",
|
||||
selectionErrorType: "manual-financial-selection-error",
|
||||
createRequestType: "create-manual-financial-record",
|
||||
updateRequestType: "update-manual-financial-record",
|
||||
deleteRequestType: "delete-manual-financial-record",
|
||||
deleteAllRequestType: "delete-all-manual-financial-records",
|
||||
mutationResultType: "manual-financial-mutation-result",
|
||||
mutationErrorType: "manual-financial-mutation-error",
|
||||
defaultMaximumResults: 200,
|
||||
maximumResults: 1000,
|
||||
maximumQueryLength: 64
|
||||
});
|
||||
assert.equal(workflow.sourceContract.originalForm, "Form/GraphE.cs");
|
||||
assert.equal(workflow.sourceContract.provider, "oracle");
|
||||
assert.equal(workflow.sourceContract.operatorSchemaVersion, 1);
|
||||
assert.equal(workflow.sourceContract.restoreRequiresNativeLoad, true);
|
||||
assert.equal(workflow.sourceContract.rawNamedPlaylistRowPlayable, false);
|
||||
assert.equal(workflow.sourceContract.writeSafety.outcomeUnknownAutomaticRetry, false);
|
||||
assert.deepEqual(workflow.actions.map(action => [action.screen, action.builderKey, action.code]), [
|
||||
["revenue-composition", "s5076", "5076"],
|
||||
["growth-metrics", "s5079", "5079"],
|
||||
["sales", "s5080", "5080"],
|
||||
["operating-profit", "s5081", "5081"]
|
||||
]);
|
||||
});
|
||||
|
||||
test("generic native request errors are exact and operation-correlated", () => {
|
||||
const error = {
|
||||
requestId: "",
|
||||
operation: "load",
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Invalid screen."
|
||||
};
|
||||
assert.deepEqual(workflow.normalizeRequestError(error, "load"), error);
|
||||
assert.equal(workflow.normalizeRequestError(error, "list"), null);
|
||||
assert.equal(workflow.normalizeRequestError({ ...error, extra: true }, "load"), null);
|
||||
assert.equal(workflow.normalizeRequestError({ ...error, requestId: "bad id" }, "load"), null);
|
||||
assert.equal(workflow.normalizeRequestError({ ...error, operation: "unknown" }), null);
|
||||
assert.equal(workflow.normalizeRequestError({ ...error, code: "OTHER" }), null);
|
||||
assert.equal(workflow.normalizeRequestError({ ...error, message: "bad\nmessage" }), null);
|
||||
});
|
||||
|
||||
test("all closed records serialize exactly to the existing underscore loader shapes", () => {
|
||||
assert.deepEqual(workflow.serializeRecordForStorage(
|
||||
"revenue-composition",
|
||||
record("revenue-composition")), {
|
||||
table: "INPUT_PIE",
|
||||
columns: ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"],
|
||||
values: ["Alpha", "2026-06", "Semiconductor_50.0", "Service_-", "Overseas_25", "", ""]
|
||||
});
|
||||
assert.deepEqual(workflow.serializeRecordForStorage(
|
||||
"growth-metrics",
|
||||
record("growth-metrics")), {
|
||||
table: "INPUT_GROW",
|
||||
columns: ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"],
|
||||
values: ["Alpha", "1_2_3_4", "5_6__8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q"]
|
||||
});
|
||||
assert.deepEqual(workflow.serializeRecordForStorage("sales", record("sales")).values, [
|
||||
"Alpha", "1Q_10", "2Q_-20", "3Q_30", "4Q_40", "5Q_50", "6Q_60"
|
||||
]);
|
||||
assert.equal(workflow.serializeRecordForStorage(
|
||||
"operating-profit",
|
||||
record("operating-profit")).table, "INPUT_PROFIT");
|
||||
});
|
||||
|
||||
test("closed DTO validation rejects malformed counts, separators and unsafe numbers", () => {
|
||||
const revenue = record("revenue-composition");
|
||||
assert.equal(workflow.normalizeRecord("revenue-composition", {
|
||||
...revenue,
|
||||
slices: revenue.slices.slice(0, 4)
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeRecord("revenue-composition", {
|
||||
...revenue,
|
||||
slices: [{ label: "bad_label", percentageText: "50", percentage: 50 }, ...revenue.slices.slice(1)]
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeRecord("revenue-composition", {
|
||||
...revenue,
|
||||
slices: [{ label: "A", percentageText: "50", percentage: 49 }, ...revenue.slices.slice(1)]
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeRecord("growth-metrics", {
|
||||
...record("growth-metrics"),
|
||||
salesGrowth: [1, 2, Number.POSITIVE_INFINITY, 4]
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeRecord("sales", {
|
||||
...record("sales"),
|
||||
quarters: quarters().slice(1)
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeRecord("sales", {
|
||||
...record("sales"),
|
||||
stockName: "bad\nname"
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("list requests normalize blank/search text and enforce exact screen bounds", () => {
|
||||
assert.deepEqual(workflow.createListRequest("list-1", "sales", " "), {
|
||||
requestId: "list-1",
|
||||
screen: "sales",
|
||||
query: ""
|
||||
});
|
||||
assert.deepEqual(listRequest(), {
|
||||
requestId: "list-1",
|
||||
screen: "sales",
|
||||
query: "Alpha",
|
||||
maximumResults: 25
|
||||
});
|
||||
assert.throws(() => workflow.createListRequest("bad id", "sales"), /request id/);
|
||||
assert.throws(() => workflow.createListRequest("list", "unknown"), /screen/);
|
||||
assert.throws(() => workflow.createListRequest("list", "sales", "", 1001), /limited/);
|
||||
});
|
||||
|
||||
test("native list responses create trusted snapshots and reject duplicate or reordered names", () => {
|
||||
const request = workflow.createListRequest("list-1", "sales", "Alpha", 25);
|
||||
const payload = listPayload("sales", [record("sales", "Alpha"), record("sales", "Beta")]);
|
||||
const normalized = workflow.normalizeListResponse(payload, request);
|
||||
assert.ok(normalized);
|
||||
assert.equal(normalized.items[0].rowVersion, version);
|
||||
assert.ok(Object.isFrozen(normalized.items));
|
||||
|
||||
assert.equal(workflow.normalizeListResponse({
|
||||
...payload,
|
||||
items: [...payload.items].reverse()
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeListResponse(listPayload("sales", [
|
||||
record("sales", "Alpha"), record("sales", "ALPHA")
|
||||
]), request), null);
|
||||
assert.equal(workflow.normalizeListResponse({ ...payload, requestId: "stale" }, request), null);
|
||||
assert.equal(workflow.normalizeListResponse({
|
||||
...payload,
|
||||
items: [{ ...payload.items[0], rowVersion: "bad" }, payload.items[1]]
|
||||
}, request), null);
|
||||
});
|
||||
|
||||
test("list errors are exact and correlated", () => {
|
||||
const request = listRequest();
|
||||
assert.deepEqual(workflow.normalizeListError({
|
||||
requestId: "list-1",
|
||||
screen: "sales",
|
||||
query: "Alpha",
|
||||
message: "Database unavailable"
|
||||
}, request), {
|
||||
requestId: "list-1",
|
||||
screen: "sales",
|
||||
query: "Alpha",
|
||||
message: "Database unavailable"
|
||||
});
|
||||
assert.equal(workflow.normalizeListError({
|
||||
requestId: "other",
|
||||
screen: "sales",
|
||||
query: "Alpha",
|
||||
message: "Database unavailable"
|
||||
}, request), null);
|
||||
});
|
||||
|
||||
test("load responses are exact native refreshes and never trust a stale request", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const request = workflow.createLoadRequest("load-1", "sales", "Alpha");
|
||||
assert.deepEqual(request, {
|
||||
requestId: "load-1",
|
||||
screen: "sales",
|
||||
stockName: "Alpha"
|
||||
});
|
||||
const loaded = trustedLoad(snapshot);
|
||||
assert.deepEqual(loaded.snapshot.record, snapshot.record);
|
||||
assert.equal(workflow.normalizeLoadResponse({
|
||||
requestId: "load-1",
|
||||
screen: "sales",
|
||||
retrievedAt: "2026-07-12T00:20:00+09:00",
|
||||
snapshot: {
|
||||
stockName: "Beta",
|
||||
rowVersion: snapshot.rowVersion,
|
||||
record: record("sales", "Beta")
|
||||
}
|
||||
}, request), null);
|
||||
assert.deepEqual(workflow.normalizeLoadError({
|
||||
requestId: "load-1",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
code: "NOT_FOUND",
|
||||
message: "Missing",
|
||||
retryable: false
|
||||
}, request).code, "NOT_FOUND");
|
||||
});
|
||||
|
||||
test("stock master normalization enforces provider identity and rejects duplicate rows", () => {
|
||||
const normalized = workflow.normalizeStockSearchResponse(stockPayload());
|
||||
assert.ok(normalized);
|
||||
assert.equal(normalized.results[0].source, "oracle");
|
||||
assert.equal(workflow.normalizeStockSearchResponse(stockPayload([{
|
||||
market: "kospi", source: "mariaDb", name: "Alpha", code: "000001"
|
||||
}])), null);
|
||||
assert.equal(workflow.normalizeStockSearchResponse(stockPayload([
|
||||
{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" },
|
||||
{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }
|
||||
])), null);
|
||||
});
|
||||
|
||||
test("verification rejects same-name market ambiguity and fabricated responses", () => {
|
||||
const value = record("sales");
|
||||
assert.equal(workflow.verifyStockForRecord(value, stockPayload(), "kospi", "000001"), null);
|
||||
const response = workflow.normalizeStockSearchResponse(stockPayload([
|
||||
{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" },
|
||||
{ market: "kosdaq", source: "oracle", name: "Alpha", code: "000002" }
|
||||
]));
|
||||
assert.ok(response);
|
||||
assert.equal(workflow.verifyStockForRecord(value, response, "kospi", "000001"), null);
|
||||
const one = workflow.normalizeStockSearchResponse(stockPayload());
|
||||
assert.equal(workflow.verifyStockForRecord(value, one, "kosdaq", "000001"), null);
|
||||
});
|
||||
|
||||
test("selection requests bind snapshot version and freshly verified stock to native result", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const stock = verifiedStock(snapshot.record);
|
||||
const request = workflow.createSelectionRequest("selection-1", snapshot, stock);
|
||||
assert.deepEqual(request, {
|
||||
requestId: "selection-1",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
rowVersion: version,
|
||||
stock: {
|
||||
market: "kospi",
|
||||
source: "oracle",
|
||||
name: "Alpha",
|
||||
code: "000001"
|
||||
}
|
||||
});
|
||||
const response = trustedSelection(snapshot, stock);
|
||||
assert.equal(response.builderKey, "s5080");
|
||||
assert.equal(workflow.normalizeSelectionResponse({
|
||||
...response,
|
||||
rowVersion: "B".repeat(64)
|
||||
}, request), null);
|
||||
assert.deepEqual(workflow.normalizeSelectionError({
|
||||
requestId: "selection-1",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
code: "STALE_ROW",
|
||||
message: "Reload",
|
||||
retryable: false
|
||||
}, request).code, "STALE_ROW");
|
||||
});
|
||||
|
||||
test("create requires a verified exact stock and sends no row version", () => {
|
||||
const value = record("sales");
|
||||
const stock = verifiedStock(value);
|
||||
assert.deepEqual(workflow.createCreateRequest("create-1", value, stock), {
|
||||
requestId: "create-1",
|
||||
screen: "sales",
|
||||
stock: {
|
||||
market: "kospi",
|
||||
source: "oracle",
|
||||
name: "Alpha",
|
||||
code: "000001"
|
||||
},
|
||||
record: value
|
||||
});
|
||||
assert.throws(() => workflow.createCreateRequest("create-1", value, {
|
||||
market: "kospi", source: "oracle", name: "Alpha", code: "000001"
|
||||
}), /verified stock/);
|
||||
assert.throws(() => workflow.createCreateRequest(
|
||||
"create-1",
|
||||
record("sales", "Beta"),
|
||||
stock), /verified stock/);
|
||||
});
|
||||
|
||||
test("update and single delete require trusted snapshots and optimistic row version", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const replacement = record("sales");
|
||||
assert.deepEqual(workflow.createUpdateRequest("update-1", snapshot, replacement), {
|
||||
requestId: "update-1",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
expectedRowVersion: version,
|
||||
record: replacement
|
||||
});
|
||||
assert.deepEqual(workflow.createDeleteRequest("delete-1", snapshot), {
|
||||
requestId: "delete-1",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
expectedRowVersion: version
|
||||
});
|
||||
assert.throws(() => workflow.createUpdateRequest(
|
||||
"update-1",
|
||||
snapshot,
|
||||
record("sales", "Beta")), /cannot rename/);
|
||||
assert.throws(() => workflow.createDeleteRequest("delete-1", {
|
||||
...snapshot
|
||||
}), /native-normalized/);
|
||||
});
|
||||
|
||||
test("delete-all requires the exact table-specific confirmation token", () => {
|
||||
assert.deepEqual(workflow.createDeleteAllRequest(
|
||||
"delete-all-1",
|
||||
"sales",
|
||||
"DELETE_ALL_INPUT_SELL"), {
|
||||
requestId: "delete-all-1",
|
||||
screen: "sales",
|
||||
confirmationToken: "DELETE_ALL_INPUT_SELL"
|
||||
});
|
||||
assert.throws(() => workflow.createDeleteAllRequest(
|
||||
"delete-all-1",
|
||||
"sales",
|
||||
"DELETE_ALL_INPUT_PROFIT"), /confirmation token/);
|
||||
});
|
||||
|
||||
test("mutation results correlate and errors can never request automatic retry", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const request = workflow.createDeleteRequest("delete-1", snapshot);
|
||||
assert.deepEqual(workflow.normalizeMutationResult({
|
||||
requestId: "delete-1",
|
||||
operationId: "operation-1",
|
||||
operation: "delete-one",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
committedAt: "2026-07-12T00:12:00+09:00",
|
||||
affectedRows: 1
|
||||
}, request), {
|
||||
requestId: "delete-1",
|
||||
operationId: "operation-1",
|
||||
operation: "delete-one",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
committedAt: "2026-07-12T00:12:00+09:00",
|
||||
affectedRows: 1
|
||||
});
|
||||
assert.deepEqual(workflow.normalizeMutationError({
|
||||
requestId: "delete-1",
|
||||
operation: "delete-one",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "Do not retry automatically",
|
||||
retryable: false,
|
||||
outcomeUnknown: true
|
||||
}, request).outcomeUnknown, true);
|
||||
assert.equal(workflow.normalizeMutationResult({
|
||||
requestId: "delete-1",
|
||||
operationId: "operation-1",
|
||||
operation: "update",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
committedAt: "2026-07-12T00:12:00+09:00",
|
||||
affectedRows: 1
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizeMutationError({
|
||||
requestId: "delete-1",
|
||||
operation: "delete-one",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "retry",
|
||||
retryable: true,
|
||||
outcomeUnknown: true
|
||||
}, request), null);
|
||||
});
|
||||
|
||||
test("only trusted DB snapshots and verified master rows create the four exact one-page cuts", () => {
|
||||
const expectations = [
|
||||
["revenue-composition", "s5076", "5076", "REVENUE_COMPOSITION"],
|
||||
["growth-metrics", "s5079", "5079", "GROWTH_METRICS"],
|
||||
["sales", "s5080", "5080", "SALES"],
|
||||
["operating-profit", "s5081", "5081", "OPERATING_PROFIT"]
|
||||
];
|
||||
for (const [screen, builderKey, code, graphicType] of expectations) {
|
||||
const snapshot = trustedSnapshot(screen);
|
||||
const stock = verifiedStock(snapshot.record);
|
||||
assert.deepEqual(workflow.buildSelection(snapshot, stock), {
|
||||
builderKey,
|
||||
code,
|
||||
aliases: [code],
|
||||
selection: {
|
||||
groupCode: "KOSPI",
|
||||
subject: "Alpha",
|
||||
graphicType,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
},
|
||||
currentPage: 1,
|
||||
pageCount: 1
|
||||
});
|
||||
}
|
||||
const rawSnapshot = listPayload("sales").items[0];
|
||||
const stock = verifiedStock(record("sales"));
|
||||
assert.equal(workflow.buildSelection(rawSnapshot, stock), null);
|
||||
});
|
||||
|
||||
test("playlist entry has the complete standard operator metadata and native trust", () => {
|
||||
const snapshot = trustedSnapshot("operating-profit");
|
||||
const stock = verifiedStock(snapshot.record);
|
||||
const selection = trustedSelection(snapshot, stock);
|
||||
const entry = workflow.createPlaylistEntry(snapshot, stock, selection, {
|
||||
id: "profit-1",
|
||||
fadeDuration: 8
|
||||
});
|
||||
assert.deepEqual(entry, {
|
||||
id: "profit-1",
|
||||
builderKey: "s5081",
|
||||
code: "5081",
|
||||
aliases: ["5081"],
|
||||
title: "Alpha",
|
||||
detail: "영업이익",
|
||||
category: "manual-financial",
|
||||
market: "kospi",
|
||||
stockName: "Alpha",
|
||||
stockCode: "000001",
|
||||
isNxt: false,
|
||||
selection: {
|
||||
groupCode: "KOSPI",
|
||||
subject: "Alpha",
|
||||
graphicType: "OPERATING_PROFIT",
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
},
|
||||
enabled: true,
|
||||
fadeDuration: 8,
|
||||
graphicType: "OPERATING_PROFIT",
|
||||
subtype: "",
|
||||
currentPage: 1,
|
||||
pageCount: 1,
|
||||
operator: {
|
||||
source: "manual-financial-workflow",
|
||||
schemaVersion: 1,
|
||||
screen: "operating-profit",
|
||||
snapshot: {
|
||||
screen: "operating-profit",
|
||||
stockName: "Alpha",
|
||||
rowVersion: version,
|
||||
record: snapshot.record
|
||||
},
|
||||
verifiedStock: {
|
||||
market: "kospi",
|
||||
source: "oracle",
|
||||
name: "Alpha",
|
||||
code: "000001"
|
||||
}
|
||||
}
|
||||
});
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(entry), true);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(JSON.parse(JSON.stringify(entry))), false);
|
||||
assert.throws(() => workflow.createPlaylistEntry(snapshot, stock, {
|
||||
...selection
|
||||
}, { id: "forged" }), /native-confirmed/);
|
||||
});
|
||||
|
||||
test("stored entries restore only as non-playable refresh-required descriptors", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const stock = verifiedStock(snapshot.record);
|
||||
const selection = trustedSelection(snapshot, stock);
|
||||
const live = workflow.createPlaylistEntry(snapshot, stock, selection, {
|
||||
id: "sales-restore",
|
||||
fadeDuration: 7,
|
||||
enabled: false
|
||||
});
|
||||
const stored = JSON.parse(JSON.stringify(live));
|
||||
const pending = workflow.restorePlaylistEntry(stored);
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.status, "refresh-required");
|
||||
assert.equal(pending.playable, false);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(pending.entry), false);
|
||||
assert.deepEqual(workflow.createRestoreLoadRequest("restore-load", pending), {
|
||||
requestId: "restore-load",
|
||||
screen: "sales",
|
||||
stockName: "Alpha"
|
||||
});
|
||||
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...stored,
|
||||
selection: { ...stored.selection, dataCode: "tampered" }
|
||||
}), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...stored,
|
||||
operator: { ...stored.operator, schemaVersion: 2 }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("native load, fresh stock verification and native selection materialize a stored entry", () => {
|
||||
const originalSnapshot = trustedSnapshot("sales");
|
||||
const originalStock = verifiedStock(originalSnapshot.record);
|
||||
const originalSelection = trustedSelection(originalSnapshot, originalStock);
|
||||
const stored = JSON.parse(JSON.stringify(workflow.createPlaylistEntry(
|
||||
originalSnapshot,
|
||||
originalStock,
|
||||
originalSelection,
|
||||
{ id: "sales-restore", fadeDuration: 7, enabled: false })));
|
||||
const pending = workflow.restorePlaylistEntry(stored);
|
||||
|
||||
const loadRequest = workflow.createRestoreLoadRequest("restore-load", pending);
|
||||
const loadResponse = workflow.normalizeLoadResponse({
|
||||
requestId: "restore-load",
|
||||
screen: "sales",
|
||||
retrievedAt: "2026-07-12T00:20:00+09:00",
|
||||
snapshot: {
|
||||
stockName: stored.operator.snapshot.stockName,
|
||||
rowVersion: stored.operator.snapshot.rowVersion,
|
||||
record: stored.operator.snapshot.record
|
||||
}
|
||||
}, loadRequest);
|
||||
assert.ok(loadResponse);
|
||||
const freshStockResponse = workflow.normalizeStockSearchResponse(stockPayload());
|
||||
const freshStock = workflow.verifyStockForRecord(
|
||||
loadResponse.snapshot.record,
|
||||
freshStockResponse,
|
||||
pending.verifiedStock.market,
|
||||
pending.verifiedStock.code);
|
||||
assert.ok(freshStock);
|
||||
const selectionRequest = workflow.createRestoreSelectionRequest(
|
||||
"restore-selection",
|
||||
pending,
|
||||
loadResponse,
|
||||
freshStock);
|
||||
const profile = workflow.getScreenDefinition("sales");
|
||||
const selectionResponse = workflow.normalizeSelectionResponse({
|
||||
requestId: "restore-selection",
|
||||
screen: "sales",
|
||||
stockName: "Alpha",
|
||||
rowVersion: version,
|
||||
builderKey: profile.builderKey,
|
||||
code: profile.code,
|
||||
aliases: [profile.code],
|
||||
selection: {
|
||||
groupCode: "KOSPI",
|
||||
subject: "Alpha",
|
||||
graphicType: profile.graphicType,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
},
|
||||
currentPage: 1,
|
||||
totalPages: 1
|
||||
}, selectionRequest);
|
||||
const restored = workflow.materializeRestoredPlaylistEntry(
|
||||
pending,
|
||||
loadResponse,
|
||||
freshStock,
|
||||
selectionResponse);
|
||||
assert.deepEqual(restored, stored);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(restored), true);
|
||||
});
|
||||
|
||||
test("row-version or record drift keeps a stored entry permanently unplayable", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const stock = verifiedStock(snapshot.record);
|
||||
const selection = trustedSelection(snapshot, stock);
|
||||
const stored = JSON.parse(JSON.stringify(workflow.createPlaylistEntry(
|
||||
snapshot,
|
||||
stock,
|
||||
selection,
|
||||
{ id: "stale-entry" })));
|
||||
const pending = workflow.restorePlaylistEntry(stored);
|
||||
const request = workflow.createRestoreLoadRequest("restore-load", pending);
|
||||
const changed = workflow.normalizeLoadResponse({
|
||||
requestId: "restore-load",
|
||||
screen: "sales",
|
||||
retrievedAt: "2026-07-12T00:20:00+09:00",
|
||||
snapshot: {
|
||||
stockName: "Alpha",
|
||||
rowVersion: "B".repeat(64),
|
||||
record: record("sales")
|
||||
}
|
||||
}, request);
|
||||
const freshStockResponse = workflow.normalizeStockSearchResponse(stockPayload());
|
||||
const freshStock = workflow.verifyStockForRecord(
|
||||
changed.snapshot.record,
|
||||
freshStockResponse,
|
||||
"kospi",
|
||||
"000001");
|
||||
assert.throws(() => workflow.createRestoreSelectionRequest(
|
||||
"restore-selection",
|
||||
pending,
|
||||
changed,
|
||||
freshStock), /did not reproduce/);
|
||||
assert.equal(workflow.materializeRestoredPlaylistEntry(
|
||||
pending,
|
||||
changed,
|
||||
freshStock,
|
||||
selection), null);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(stored), false);
|
||||
});
|
||||
56
tests/Web/manual-lists-bridge-integration.test.cjs
Normal file
56
tests/Web/manual-lists-bridge-integration.test.cjs
Normal file
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const native = fs.readFileSync(path.join(root, "MainWindow.ManualLists.cs"), "utf8");
|
||||
const playout = fs.readFileSync(path.join(root, "MainWindow.Playout.cs"), "utf8");
|
||||
const factory = fs.readFileSync(path.join(root, "LegacySceneRuntimeFactory.cs"), "utf8");
|
||||
|
||||
test("native VI responses expose the canonical version and no-op persistence receipt", () => {
|
||||
assert.match(native, /ReadSnapshotAsync\(_lifetimeCancellation\.Token\)/u);
|
||||
assert.match(native, /version = snapshot\.RowVersion/u);
|
||||
assert.match(native, /persisted = result\.Persisted/u);
|
||||
assert.match(native, /version = result\.Snapshot\.RowVersion/u);
|
||||
});
|
||||
|
||||
test("VI writes require a closed expected version and compare it under the data gate before mutation", () => {
|
||||
assert.match(native, /HasOnlyProperties\(payload, "requestId", "expectedVersion", "items"\)/u);
|
||||
assert.match(native, /TrustedViManualReference\.IsRowVersion\(expectedVersion\)/u);
|
||||
|
||||
const methodStart = native.indexOf("private async Task HandleViManualListWriteAsync");
|
||||
const methodEnd = native.indexOf("private async Task<bool> TryEnterManualMutationGatesAsync", methodStart);
|
||||
const method = native.slice(methodStart, methodEnd);
|
||||
const gate = method.indexOf("TryEnterManualMutationGatesAsync");
|
||||
const reread = method.indexOf("var currentSnapshot = await store.ReadSnapshotAsync");
|
||||
const compare = method.indexOf("currentSnapshot.RowVersion");
|
||||
const stale = method.indexOf('"STALE_DATA"');
|
||||
const mutation = method.indexOf("var result = await store.WriteAsync");
|
||||
assert.ok(methodStart >= 0 && methodEnd > methodStart);
|
||||
assert.ok(gate >= 0 && gate < reread && reread < compare && compare < stale && stale < mutation);
|
||||
assert.match(method.slice(stale, mutation), /retryable: false,[\s\S]*outcomeUnknown: false/u);
|
||||
});
|
||||
|
||||
test("different post-error readback always enters OutcomeUnknown quarantine", () => {
|
||||
assert.equal(native.includes('"SAVE_FAILED"'), false);
|
||||
assert.match(native, /수동 순매도 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다/u);
|
||||
assert.match(native, /VI 수동 목록 저장 중 파일 내용이 달라져 결과를 확정할 수 없습니다/u);
|
||||
assert.ok((native.match(/PostManualOperatorQuarantine\(requestId, "save-/gu) || []).length >= 4);
|
||||
});
|
||||
|
||||
test("configured external operator paths are disabled before any directory creation", () => {
|
||||
const configuredGuard = native.indexOf("if (!string.IsNullOrWhiteSpace(configuredDirectory))");
|
||||
const privatePrepare = native.indexOf("TrustedManualDirectory.PreparePrivateWritableDirectory");
|
||||
assert.ok(configuredGuard >= 0);
|
||||
assert.ok(privatePrepare > configuredGuard);
|
||||
assert.equal(native.includes("Directory.CreateDirectory(directory)"), false);
|
||||
});
|
||||
|
||||
test("trusted VI source is injected and checked for both page plan and page load", () => {
|
||||
assert.match(playout, /trustedViSource: _viManualListStore/u);
|
||||
assert.match(factory, /ITrustedViStockNameSnapshotSource\? trustedViSource/u);
|
||||
assert.ok((factory.match(/TrustedViManualReference\.ResolveAsync/gu) || []).length >= 2);
|
||||
});
|
||||
353
tests/Web/manual-lists-ui.test.cjs
Normal file
353
tests/Web/manual-lists-ui.test.cjs
Normal file
@@ -0,0 +1,353 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const ui = require("../../Web/manual-lists-ui.js");
|
||||
|
||||
const versionA = "a".repeat(64);
|
||||
const versionB = "b".repeat(64);
|
||||
|
||||
const netRows = () => Array.from({ length: 5 }, (_, index) => ({
|
||||
leftName: `좌측${index + 1}`,
|
||||
leftAmount: `${index + 1},000`,
|
||||
rightName: `우측${index + 1}`,
|
||||
rightAmount: `-${index + 1},000`
|
||||
}));
|
||||
|
||||
const viItems = () => [
|
||||
{ code: "P005930", name: "삼성전자" },
|
||||
{ code: "P000660", name: "SK하이닉스" },
|
||||
{ code: "D035720", name: "카카오" }
|
||||
];
|
||||
|
||||
function fixture() {
|
||||
let sequence = 0;
|
||||
let externallyLocked = false;
|
||||
const posts = [];
|
||||
const entries = [];
|
||||
const toasts = [];
|
||||
const logs = [];
|
||||
const controller = ui.createController({
|
||||
postNative: (type, payload) => posts.push({ type, payload }),
|
||||
appendPlaylistEntry: entry => entries.push(entry),
|
||||
isLocked: () => externallyLocked,
|
||||
showToast: (message, kind) => toasts.push({ message, kind }),
|
||||
addLog: message => logs.push(message),
|
||||
createId: () => `manual_ui_${++sequence}`
|
||||
});
|
||||
return {
|
||||
controller,
|
||||
posts,
|
||||
entries,
|
||||
toasts,
|
||||
logs,
|
||||
setExternalLock: value => { externallyLocked = value; },
|
||||
latest: type => [...posts].reverse().find(item => item.type === type)
|
||||
};
|
||||
}
|
||||
|
||||
function enableManualStore(context) {
|
||||
const requestId = context.controller.getState().pending.status;
|
||||
assert.equal(context.controller.handleMessage("manual-operator-data-status", {
|
||||
requestId,
|
||||
available: true,
|
||||
writeQuarantined: false,
|
||||
message: null
|
||||
}), true);
|
||||
}
|
||||
|
||||
test("FSell cut opens only after five saved rows are re-read unchanged", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openNetSell("FOREIGN");
|
||||
enableManualStore(context);
|
||||
const initialRead = controller.getState().pending.netRead;
|
||||
assert.equal(controller.handleMessage("manual-net-sell-data", {
|
||||
requestId: initialRead,
|
||||
audience: "FOREIGN",
|
||||
rows: netRows()
|
||||
}), true);
|
||||
assert.equal(controller.actions.addNetSellCut(), false);
|
||||
|
||||
assert.equal(controller.actions.saveNetSell(), true);
|
||||
const save = context.latest("save-manual-net-sell-data");
|
||||
assert.equal(save.payload.rows.length, 5);
|
||||
assert.equal(controller.handleMessage("manual-net-sell-save-result", {
|
||||
requestId: save.payload.requestId,
|
||||
audience: "FOREIGN",
|
||||
saved: true,
|
||||
recovered: false
|
||||
}), true);
|
||||
assert.equal(controller.actions.addNetSellCut(), false);
|
||||
|
||||
const verificationRead = controller.getState().pending.netRead;
|
||||
assert.equal(controller.handleMessage("manual-net-sell-data", {
|
||||
requestId: verificationRead,
|
||||
audience: "FOREIGN",
|
||||
rows: netRows()
|
||||
}), true);
|
||||
assert.equal(controller.getState().netVerified, true);
|
||||
assert.equal(controller.actions.addNetSellCut(), true);
|
||||
assert.equal(context.entries[0].builderKey, "s5025");
|
||||
|
||||
controller.actions.setNetCell(0, "leftAmount", "변경");
|
||||
assert.equal(controller.actions.addNetSellCut(), false);
|
||||
});
|
||||
|
||||
test("VI cut binds the latest version and reorder requires save plus matching reread", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openVi();
|
||||
enableManualStore(context);
|
||||
const read = controller.getState().pending.viRead;
|
||||
assert.equal(controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: read,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: versionA
|
||||
}), true);
|
||||
assert.equal(controller.actions.addViCut(), true);
|
||||
assert.equal(context.entries[0].selection.subject, "@MBN_VI_SNAPSHOT_V1");
|
||||
assert.equal(context.entries[0].selection.dataCode, versionA);
|
||||
|
||||
assert.equal(controller.actions.moveViItem(2, -1), true);
|
||||
assert.deepEqual(controller.getState().viItems.map(item => item.name), [
|
||||
"삼성전자", "카카오", "SK하이닉스"
|
||||
]);
|
||||
assert.equal(controller.getState().viBaseVersion, versionA);
|
||||
assert.equal(controller.getState().viVersion, null);
|
||||
assert.equal(controller.actions.addViCut(), false);
|
||||
|
||||
assert.equal(controller.actions.saveVi(), true);
|
||||
const save = context.latest("save-vi-manual-list");
|
||||
assert.equal(save.payload.expectedVersion, versionA);
|
||||
assert.equal(controller.handleMessage("vi-manual-list-save-result", {
|
||||
requestId: save.payload.requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: true,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
version: versionB
|
||||
}), true);
|
||||
const verify = controller.getState().pending.viRead;
|
||||
assert.equal(controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: verify,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: save.payload.items,
|
||||
version: versionB
|
||||
}), true);
|
||||
assert.equal(controller.getState().viVerified, true);
|
||||
assert.equal(controller.actions.addViCut(), true);
|
||||
assert.equal(context.entries[1].selection.dataCode, versionB);
|
||||
assert.deepEqual(context.entries[1].operator.items, save.payload.items);
|
||||
});
|
||||
|
||||
test("VI mismatched post-save version never opens the playlist gate", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openVi();
|
||||
enableManualStore(context);
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: controller.getState().pending.viRead,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: versionA
|
||||
});
|
||||
controller.actions.moveViItem(2, -1);
|
||||
controller.actions.saveVi();
|
||||
const save = context.latest("save-vi-manual-list");
|
||||
controller.handleMessage("vi-manual-list-save-result", {
|
||||
requestId: save.payload.requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: true,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
version: versionB
|
||||
});
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: controller.getState().pending.viRead,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: save.payload.items,
|
||||
version: versionA
|
||||
});
|
||||
assert.equal(controller.getState().viVerified, false);
|
||||
assert.equal(controller.actions.addViCut(), false);
|
||||
});
|
||||
|
||||
test("VI stale-data conflict is terminal, preserves the draft, and requires reread", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openVi();
|
||||
enableManualStore(context);
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: controller.getState().pending.viRead,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: versionA
|
||||
});
|
||||
controller.actions.moveViItem(2, -1);
|
||||
const draft = controller.getState().viItems;
|
||||
assert.equal(controller.actions.saveVi(), true);
|
||||
const save = context.latest("save-vi-manual-list");
|
||||
const postCount = context.posts.length;
|
||||
|
||||
assert.equal(controller.handleMessage("manual-operator-data-error", {
|
||||
requestId: save.payload.requestId,
|
||||
operation: "save-vi",
|
||||
code: "STALE_DATA",
|
||||
message: "The VI list changed. Reload before saving.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false
|
||||
}), true);
|
||||
assert.deepEqual(controller.getState().viItems, draft);
|
||||
assert.equal(controller.getState().viBaseVersion, null);
|
||||
assert.equal(controller.getState().viVersion, null);
|
||||
assert.equal(controller.getState().viVerified, false);
|
||||
assert.equal(controller.getState().quarantined, false);
|
||||
assert.equal(controller.actions.saveVi(), false);
|
||||
assert.equal(context.posts.length, postCount);
|
||||
});
|
||||
|
||||
test("empty VI save is a successful no-op and can never create an empty cut", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openVi();
|
||||
enableManualStore(context);
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: controller.getState().pending.viRead,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: versionA
|
||||
});
|
||||
controller.actions.clearViItems();
|
||||
assert.equal(controller.actions.addViCut(), false);
|
||||
assert.equal(controller.actions.saveVi(), true);
|
||||
const save = context.latest("save-vi-manual-list");
|
||||
assert.equal(save.payload.expectedVersion, versionA);
|
||||
assert.deepEqual(save.payload.items, []);
|
||||
assert.equal(controller.handleMessage("vi-manual-list-save-result", {
|
||||
requestId: save.payload.requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: false,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
version: versionA
|
||||
}), true);
|
||||
assert.ok(context.toasts.some(item => item.message.includes("변경하지 않았습니다")));
|
||||
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: controller.getState().pending.viRead,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: versionA
|
||||
});
|
||||
assert.equal(controller.getState().viItems.length, 3);
|
||||
controller.actions.clearViItems();
|
||||
assert.equal(controller.actions.addViCut(), false);
|
||||
});
|
||||
|
||||
test("stale correlations are ignored and OutcomeUnknown quarantines every later save", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openVi();
|
||||
enableManualStore(context);
|
||||
const staleRead = controller.getState().pending.viRead;
|
||||
controller.actions.requestViRead();
|
||||
const currentRead = controller.getState().pending.viRead;
|
||||
assert.notEqual(staleRead, currentRead);
|
||||
assert.equal(controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: staleRead,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: versionA
|
||||
}), false);
|
||||
assert.equal(controller.getState().viItems.length, 0);
|
||||
controller.handleMessage("vi-manual-list-data", {
|
||||
requestId: currentRead,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: versionA
|
||||
});
|
||||
assert.equal(controller.actions.saveVi(), true);
|
||||
const save = context.latest("save-vi-manual-list");
|
||||
assert.equal(controller.handleMessage("manual-operator-data-error", {
|
||||
requestId: save.payload.requestId,
|
||||
operation: "save-vi",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true
|
||||
}), true);
|
||||
assert.equal(controller.getState().quarantined, true);
|
||||
const postCount = context.posts.length;
|
||||
assert.equal(controller.actions.saveVi(), false);
|
||||
assert.equal(context.posts.length, postCount);
|
||||
});
|
||||
|
||||
test("stock search is exact and correlated before it can append a VI row", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openVi();
|
||||
assert.equal(controller.actions.searchStocks("삼성"), true);
|
||||
const search = context.latest("search-stocks");
|
||||
assert.equal(controller.handleMessage("stock-search-results", {
|
||||
requestId: "stale_request",
|
||||
query: "삼성",
|
||||
retrievedAt: "2026-07-12T00:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }]
|
||||
}), false);
|
||||
assert.equal(controller.handleMessage("stock-search-results", {
|
||||
requestId: search.payload.requestId,
|
||||
query: "삼성",
|
||||
retrievedAt: "2026-07-12T00:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }]
|
||||
}), true);
|
||||
assert.equal(controller.actions.addSearchResult(0), true);
|
||||
assert.deepEqual(controller.getState().viItems, [{ code: "P005930", name: "삼성전자" }]);
|
||||
});
|
||||
|
||||
test("explicit and host locks block edits, saves, and cut creation", () => {
|
||||
const context = fixture();
|
||||
const { controller } = context;
|
||||
controller.openNetSell("INDIVIDUAL");
|
||||
enableManualStore(context);
|
||||
controller.handleMessage("manual-net-sell-data", {
|
||||
requestId: controller.getState().pending.netRead,
|
||||
audience: "INDIVIDUAL",
|
||||
rows: netRows()
|
||||
});
|
||||
controller.setLocked(true);
|
||||
assert.equal(controller.actions.setNetCell(0, "leftName", "변경"), false);
|
||||
assert.equal(controller.actions.saveNetSell(), false);
|
||||
controller.setLocked(false);
|
||||
context.setExternalLock(true);
|
||||
assert.equal(controller.actions.saveNetSell(), false);
|
||||
assert.equal(controller.getState().locked, true);
|
||||
});
|
||||
|
||||
test("controller source constructs DOM without HTML injection or filesystem paths", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "..", "..", "Web", "manual-lists-ui.js"),
|
||||
"utf8");
|
||||
assert.equal(source.includes("innerHTML"), false);
|
||||
assert.equal(source.includes("insertAdjacentHTML"), false);
|
||||
assert.equal(/(?:[A-Z]:\\|file:\/\/)/u.test(source), false);
|
||||
});
|
||||
297
tests/Web/manual-lists-workflow.test.cjs
Normal file
297
tests/Web/manual-lists-workflow.test.cjs
Normal file
@@ -0,0 +1,297 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/manual-lists-workflow.js");
|
||||
|
||||
const requestId = "manual_req_1";
|
||||
const rowVersion = "a".repeat(64);
|
||||
const rows = () => Array.from({ length: 5 }, (_, index) => ({
|
||||
leftName: `왼쪽${index + 1}`,
|
||||
leftAmount: `${index + 1},000`,
|
||||
rightName: `오른쪽${index + 1}`,
|
||||
rightAmount: `-${index + 1},000`
|
||||
}));
|
||||
const viItems = () => [
|
||||
{ code: "005930", name: "삼성전자" },
|
||||
{ code: "000660", name: "SK하이닉스" },
|
||||
{ code: "005930", name: "삼성전자" }
|
||||
];
|
||||
|
||||
test("bridge requests expose only closed operations and keys", () => {
|
||||
assert.deepEqual(workflow.createStatusRequest(requestId), {
|
||||
type: "request-manual-operator-data-status",
|
||||
payload: { requestId }
|
||||
});
|
||||
assert.deepEqual(workflow.createNetSellReadRequest(requestId, "FOREIGN"), {
|
||||
type: "request-manual-net-sell-data",
|
||||
payload: { requestId, audience: "FOREIGN" }
|
||||
});
|
||||
assert.deepEqual(workflow.createViReadRequest(requestId), {
|
||||
type: "request-vi-manual-list",
|
||||
payload: { requestId }
|
||||
});
|
||||
});
|
||||
|
||||
test("net-sell save request requires exactly five strict rows", () => {
|
||||
const request = workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows());
|
||||
assert.equal(request.type, "save-manual-net-sell-data");
|
||||
assert.equal(request.payload.rows.length, 5);
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows().slice(0, 4)));
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "OTHER", rows()));
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", [
|
||||
...rows().slice(0, 4),
|
||||
{ ...rows()[4], extra: "not allowed" }
|
||||
]));
|
||||
});
|
||||
|
||||
test("manual value delimiter and control characters fail closed", () => {
|
||||
for (const leftName of ["bad^name", "bad\nname", "bad\u200Ename", "bad\uFFFDname"]) {
|
||||
const invalid = rows();
|
||||
invalid[0].leftName = leftName;
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "FOREIGN", invalid));
|
||||
}
|
||||
});
|
||||
|
||||
test("VI save preserves order and duplicates but never exposes a path", () => {
|
||||
const request = workflow.createViSaveRequest(requestId, viItems(), rowVersion);
|
||||
assert.deepEqual(request, {
|
||||
type: "save-vi-manual-list",
|
||||
payload: { requestId, expectedVersion: rowVersion, items: viItems() }
|
||||
});
|
||||
assert.equal(JSON.stringify(request).includes("path"), false);
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, viItems()));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "A".repeat(64)));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "a".repeat(63)));
|
||||
});
|
||||
|
||||
test("VI list rejects comma ambiguity, unsafe text, and more than twenty pages", () => {
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A,B" }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1^2", name: "A" }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: " 1", name: "A" }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A " }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(
|
||||
requestId,
|
||||
Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `Item${index + 1}` })),
|
||||
rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(
|
||||
requestId,
|
||||
Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `종목${index + 1}` }))));
|
||||
});
|
||||
|
||||
test("status response preserves process-lifetime write quarantine", () => {
|
||||
assert.deepEqual(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: true,
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다."
|
||||
}, requestId), {
|
||||
requestId,
|
||||
available: true,
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다."
|
||||
});
|
||||
assert.equal(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: true,
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
extra: true
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("net-sell data response is correlated and exact", () => {
|
||||
const response = {
|
||||
requestId,
|
||||
audience: "INSTITUTION",
|
||||
rows: rows()
|
||||
};
|
||||
assert.ok(workflow.normalizeNetSellDataResponse(response, {
|
||||
requestId,
|
||||
audience: "INSTITUTION"
|
||||
}));
|
||||
assert.equal(workflow.normalizeNetSellDataResponse(response, {
|
||||
requestId: "other",
|
||||
audience: "INSTITUTION"
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("net-sell write result distinguishes verified recovery", () => {
|
||||
const response = {
|
||||
requestId,
|
||||
audience: "FOREIGN",
|
||||
saved: true,
|
||||
recovered: true
|
||||
};
|
||||
assert.ok(workflow.normalizeNetSellSaveResponse(response, { requestId, audience: "FOREIGN" }));
|
||||
assert.equal(workflow.normalizeNetSellSaveResponse({ ...response, saved: false }), null);
|
||||
});
|
||||
|
||||
test("VI data response verifies item and page counts", () => {
|
||||
const response = {
|
||||
requestId,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: rowVersion
|
||||
};
|
||||
assert.ok(workflow.normalizeViDataResponse(response, requestId));
|
||||
assert.equal(workflow.normalizeViDataResponse({ ...response, pageCount: 2 }), null);
|
||||
assert.equal(workflow.normalizeViDataResponse({ ...response, itemCount: 2 }), null);
|
||||
});
|
||||
|
||||
test("VI save response rejects impossible page counts", () => {
|
||||
assert.ok(workflow.normalizeViSaveResponse({
|
||||
requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: true,
|
||||
itemCount: 100,
|
||||
pageCount: 20,
|
||||
version: rowVersion
|
||||
}, requestId));
|
||||
assert.equal(workflow.normalizeViSaveResponse({
|
||||
requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: true,
|
||||
itemCount: 100,
|
||||
pageCount: 19,
|
||||
version: rowVersion
|
||||
}), null);
|
||||
assert.ok(workflow.normalizeViSaveResponse({
|
||||
requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: false,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
version: rowVersion
|
||||
}, requestId));
|
||||
});
|
||||
|
||||
test("OutcomeUnknown errors are never retryable", () => {
|
||||
assert.ok(workflow.normalizeError({
|
||||
requestId,
|
||||
operation: "save-vi",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true
|
||||
}, requestId));
|
||||
assert.equal(workflow.normalizeError({
|
||||
requestId,
|
||||
operation: "save-vi",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: true
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("manual net-sell playlist mapping is original s5025 contract", () => {
|
||||
const entry = workflow.createNetSellPlaylistEntry("INDIVIDUAL", {
|
||||
id: "entry_1",
|
||||
fadeDuration: 6
|
||||
});
|
||||
assert.equal(entry.builderKey, "s5025");
|
||||
assert.equal(entry.code, "5025");
|
||||
assert.deepEqual(entry.selection, {
|
||||
groupCode: "INDIVIDUAL",
|
||||
subject: "INDEX",
|
||||
graphicType: "MANUAL_NET_SELL",
|
||||
subtype: "MANUAL_NET_SELL",
|
||||
dataCode: ""
|
||||
});
|
||||
});
|
||||
|
||||
test("VI playlist mapping uses a bounded trusted snapshot reference", () => {
|
||||
const entry = workflow.createViPlaylistEntry(viItems(), rowVersion, {
|
||||
id: "entry_2",
|
||||
fadeDuration: 4
|
||||
});
|
||||
assert.equal(entry.builderKey, "s5074");
|
||||
assert.equal(entry.code, "5074");
|
||||
assert.equal(entry.itemCount, 3);
|
||||
assert.equal(entry.pageSize, 5);
|
||||
assert.equal(entry.pageCount, 1);
|
||||
assert.equal(entry.selection.groupCode, "PAGED_VI");
|
||||
assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
|
||||
assert.equal(entry.selection.dataCode, rowVersion);
|
||||
assert.ok(entry.selection.subject.length <= 256);
|
||||
});
|
||||
|
||||
test("one hundred realistic long names still create a bounded twenty-page reference", () => {
|
||||
const items = Array.from({ length: 100 }, (_, index) => ({
|
||||
code: `P${String(index + 1).padStart(6, "0")}`,
|
||||
name: `현실적인긴종목명테스트${index + 1}`
|
||||
}));
|
||||
const entry = workflow.createViPlaylistEntry(items, rowVersion, {
|
||||
id: "entry_20_pages",
|
||||
fadeDuration: 4
|
||||
});
|
||||
assert.equal(entry.pageCount, 20);
|
||||
assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
|
||||
assert.equal(entry.selection.dataCode.length, 64);
|
||||
assert.equal(JSON.stringify(entry.selection).includes(items[0].name), false);
|
||||
});
|
||||
|
||||
test("empty VI list can be saved but cannot create a playout entry", () => {
|
||||
assert.deepEqual(workflow.createViSaveRequest(requestId, [], rowVersion), {
|
||||
type: "save-vi-manual-list",
|
||||
payload: { requestId, expectedVersion: rowVersion, items: [] }
|
||||
});
|
||||
assert.throws(() => workflow.createViPlaylistEntry([], rowVersion, {
|
||||
id: "entry_empty",
|
||||
fadeDuration: 6
|
||||
}));
|
||||
});
|
||||
|
||||
test("trusted restore recreates manual entries and rejects tampering", () => {
|
||||
const netSell = workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry_net",
|
||||
fadeDuration: 6
|
||||
});
|
||||
const vi = workflow.createViPlaylistEntry(viItems(), rowVersion, {
|
||||
id: "entry_vi",
|
||||
fadeDuration: 6
|
||||
});
|
||||
assert.deepEqual(workflow.restorePlaylistEntry(netSell), netSell);
|
||||
assert.deepEqual(workflow.restorePlaylistEntry(vi), vi);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...vi,
|
||||
selection: { ...vi.selection, subject: "다른종목" }
|
||||
}), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({ ...vi, extra: true }), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...vi,
|
||||
operator: {
|
||||
...vi.operator,
|
||||
pagePreview: { ...vi.operator.pagePreview, pageCount: 20 }
|
||||
}
|
||||
}), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...vi,
|
||||
selection: { ...vi.selection, extra: true }
|
||||
}), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...netSell,
|
||||
operator: { ...netSell.operator, audience: "OTHER" }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("request identifiers and option bounds are strict", () => {
|
||||
assert.throws(() => workflow.createStatusRequest("bad id"));
|
||||
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry",
|
||||
fadeDuration: 61
|
||||
}));
|
||||
assert.throws(() => workflow.createViPlaylistEntry(viItems(), rowVersion, {
|
||||
id: "entry",
|
||||
fadeDuration: -1
|
||||
}));
|
||||
assert.throws(() => workflow.createViPlaylistEntry(viItems(), "A".repeat(64), {
|
||||
id: "entry",
|
||||
fadeDuration: 4
|
||||
}));
|
||||
});
|
||||
149
tests/Web/named-manual-restore-workflow.test.cjs
Normal file
149
tests/Web/named-manual-restore-workflow.test.cjs
Normal file
@@ -0,0 +1,149 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const workflow = require("../../Web/named-manual-restore-workflow.js");
|
||||
const manualLists = require("../../Web/manual-lists-workflow.js");
|
||||
const manualFinancial = require("../../Web/manual-financial-workflow.js");
|
||||
|
||||
function raw(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 0,
|
||||
enabled: true,
|
||||
groupCode: "INDIVIDUAL",
|
||||
subject: "INDEX",
|
||||
graphicType: "MANUAL_NET_SELL",
|
||||
subtype: "MANUAL_NET_SELL",
|
||||
dataCode: "",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const options = { id: "db-invalid-00000001-0", fadeDuration: 6 };
|
||||
const rows = Array.from({ length: 5 }, (_, index) => ({
|
||||
leftName: `L${index}`, leftAmount: `${index}`,
|
||||
rightName: `R${index}`, rightAmount: `${index + 10}`
|
||||
}));
|
||||
|
||||
test("s5025 raw restoration accepts only canonical or exact legacy audience signatures", () => {
|
||||
const canonical = workflow.classify(raw(), options);
|
||||
assert.equal(canonical.kind, "net-sell");
|
||||
assert.equal(canonical.audience, "INDIVIDUAL");
|
||||
|
||||
const legacy = workflow.classify(raw({
|
||||
groupCode: "외국인 순매도 상위(수동)",
|
||||
subject: "",
|
||||
graphicType: "순매도 상위",
|
||||
subtype: "순매도 상위"
|
||||
}), options);
|
||||
assert.equal(legacy.audience, "FOREIGN");
|
||||
assert.equal(legacy.historical, true);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "외국인 순매도 상위",
|
||||
subject: "",
|
||||
graphicType: "순매도 상위",
|
||||
subtype: "순매도 상위"
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({ dataCode: "path.dat" }), options), null);
|
||||
});
|
||||
|
||||
test("s5025 becomes playable only after one correlated trusted five-row read", () => {
|
||||
const pending = workflow.classify(raw({ enabled: false }), options);
|
||||
const request = workflow.createManualListReadRequest("named-net-read", pending);
|
||||
const entry = workflow.materializeManualList(pending, {
|
||||
requestId: "named-net-read",
|
||||
audience: "INDIVIDUAL",
|
||||
rows
|
||||
}, request);
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.enabled, false);
|
||||
assert.equal(entry.builderKey, "s5025");
|
||||
assert.equal(manualLists.restorePlaylistEntry(entry).operator.audience, "INDIVIDUAL");
|
||||
assert.equal(workflow.materializeManualList(pending, {
|
||||
requestId: "stale-read",
|
||||
audience: "INDIVIDUAL",
|
||||
rows
|
||||
}, request), null);
|
||||
});
|
||||
|
||||
test("versioned and historical VI raw rows require the current exact native snapshot", () => {
|
||||
const version = "a".repeat(64);
|
||||
const current = workflow.classify(raw({
|
||||
groupCode: "PAGED_VI",
|
||||
subject: manualLists.viSnapshotSubject,
|
||||
graphicType: "INPUT_ORDER",
|
||||
subtype: "CURRENT",
|
||||
dataCode: version
|
||||
}), options);
|
||||
const request = workflow.createManualListReadRequest("named-vi-read", current);
|
||||
const payload = {
|
||||
requestId: "named-vi-read",
|
||||
itemCount: 2,
|
||||
pageCount: 1,
|
||||
items: [{ code: "005930", name: "삼성전자" }, { code: "000660", name: "SK하이닉스" }],
|
||||
version
|
||||
};
|
||||
assert.equal(workflow.materializeManualList(current, payload, request).builderKey, "s5074");
|
||||
assert.equal(workflow.materializeManualList(current, { ...payload, version: "b".repeat(64) }, request), null);
|
||||
|
||||
const historical = workflow.classify(raw({
|
||||
groupCode: "",
|
||||
subject: "삼성전자,SK하이닉스",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "VI 발동(수동)",
|
||||
dataCode: ""
|
||||
}), options);
|
||||
const historicalRequest = workflow.createManualListReadRequest("named-vi-history", historical);
|
||||
assert.ok(workflow.materializeManualList(historical, {
|
||||
...payload,
|
||||
requestId: "named-vi-history"
|
||||
}, historicalRequest));
|
||||
assert.equal(workflow.materializeManualList(historical, {
|
||||
...payload,
|
||||
requestId: "named-vi-history",
|
||||
items: [...payload.items].reverse()
|
||||
}, historicalRequest), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "",
|
||||
subject: "삼성전자,삼성전자",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "VI 발동(수동)"
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "",
|
||||
subject: "삼성전자, SK하이닉스",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "VI 발동(수동)"
|
||||
}), options), null);
|
||||
});
|
||||
|
||||
test("GraphE raw rows yield only a fresh-load descriptor with exact screen, market and name", () => {
|
||||
for (const profile of manualFinancial.sourceContract.screens) {
|
||||
const pending = workflow.classify(raw({
|
||||
groupCode: "코스피",
|
||||
subject: "삼성전자",
|
||||
graphicType: profile.label,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
}), options);
|
||||
assert.equal(pending.kind, "financial");
|
||||
assert.equal(pending.screen, profile.screen);
|
||||
assert.equal(pending.builderKey, profile.builderKey);
|
||||
assert.equal(pending.market, "kospi");
|
||||
assert.deepEqual(pending.identity, { screen: profile.screen, stockName: "삼성전자" });
|
||||
}
|
||||
const profile = manualFinancial.sourceContract.screens[0];
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "UNKNOWN",
|
||||
subject: "삼성전자",
|
||||
graphicType: profile.graphicType,
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
}), options), null);
|
||||
assert.equal(workflow.classify(raw({
|
||||
groupCode: "KOSPI",
|
||||
subject: "삼성전자",
|
||||
graphicType: profile.graphicType,
|
||||
subtype: "",
|
||||
dataCode: "005930"
|
||||
}), options), null);
|
||||
});
|
||||
93
tests/Web/named-playlist-bridge-integration.test.cjs
Normal file
93
tests/Web/named-playlist-bridge-integration.test.cjs
Normal file
@@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const workflow = require("../../Web/named-playlist-workflow.js");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const mainWindow = fs.readFileSync(path.join(repositoryRoot, "MainWindow.xaml.cs"), "utf8");
|
||||
const namedBridge = fs.readFileSync(path.join(repositoryRoot, "MainWindow.NamedPlaylists.cs"), "utf8");
|
||||
const pagePlanBridge = fs.readFileSync(path.join(repositoryRoot, "MainWindow.PlaylistPagePlans.cs"), "utf8");
|
||||
const appScript = fs.readFileSync(path.join(repositoryRoot, "Web", "app.js"), "utf8");
|
||||
|
||||
test("native switch and JavaScript contract use the same five requests and four responses", () => {
|
||||
for (const requestType of Object.values(workflow.bridgeContract.requests)) {
|
||||
assert.match(mainWindow, new RegExp(`case "${requestType}"`));
|
||||
}
|
||||
for (const responseType of Object.values(workflow.bridgeContract.responses)) {
|
||||
assert.match(namedBridge, new RegExp(`"${responseType}"`));
|
||||
}
|
||||
assert.match(mainWindow, /new LegacyNamedPlaylistPersistenceService\(/);
|
||||
assert.match(mainWindow, /new OracleNamedPlaylistMutationExecutor\(/);
|
||||
});
|
||||
|
||||
test("named reads use latest-request cancellation and the shared playout-priority database gate", () => {
|
||||
assert.match(namedBridge,
|
||||
/Interlocked\.Exchange\(\s*ref _namedPlaylistReadCancellation,\s*requestCancellation\)/s);
|
||||
assert.match(namedBridge, /CancelRequest\(previousCancellation\)/);
|
||||
assert.ok((namedBridge.match(/await _databaseActivityGate\.WaitAsync\(requestToken\)/g) || []).length >= 2);
|
||||
assert.ok((namedBridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 4);
|
||||
assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _namedPlaylistReadCancellation\)\)/);
|
||||
assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _namedPlaylistWriteCancellation\)\)/);
|
||||
});
|
||||
|
||||
test("writes are single-flight, never superseded, and permanently quarantine OutcomeUnknown", () => {
|
||||
assert.match(namedBridge, /_namedPlaylistWriteGate\.WaitAsync\(0\)/);
|
||||
assert.match(namedBridge, /catch \(NamedPlaylistMutationException exception\)/);
|
||||
assert.match(namedBridge, /if \(exception\.OutcomeUnknown\)/);
|
||||
assert.match(namedBridge,
|
||||
/Interlocked\.CompareExchange\(ref _namedPlaylistWriteQuarantined, 1, 0\)/);
|
||||
assert.match(namedBridge, /IsNamedPlaylistWriteQuarantined\(\)/);
|
||||
assert.match(namedBridge, /do not retry|was not retried/i);
|
||||
assert.doesNotMatch(namedBridge, /for\s*\([^)]*retry|while\s*\([^)]*retry/i);
|
||||
});
|
||||
|
||||
test("browser correlation loss cancels active requests and latches an in-flight write", () => {
|
||||
assert.match(mainWindow,
|
||||
/private void QuarantineIfBrowserCorrelationCanBeLost\(\)\s*\{\s*InvalidateNamedPlaylistBrowserRequests\(\)/s);
|
||||
assert.match(namedBridge, /Interlocked\.Increment\(ref _namedPlaylistBrowserGeneration\)/);
|
||||
assert.match(namedBridge, /Volatile\.Read\(ref _namedPlaylistWriteInFlight\) != 0/);
|
||||
assert.match(namedBridge,
|
||||
/Interlocked\.Exchange\(\s*ref _namedPlaylistWriteCancellation,\s*null\)/s);
|
||||
});
|
||||
|
||||
test("native replacement accepts only the exact eight raw-row properties and 1000 rows", () => {
|
||||
for (const property of [
|
||||
"itemIndex", "enabled", "groupCode", "subject",
|
||||
"graphicType", "subtype", "pageText", "dataCode"
|
||||
]) {
|
||||
assert.match(namedBridge, new RegExp(`"${property}"`));
|
||||
}
|
||||
assert.match(namedBridge,
|
||||
/itemsElement\.GetArrayLength\(\) > LegacyNamedPlaylistPersistenceService\.MaximumItems/);
|
||||
assert.match(namedBridge, /itemIndex != expectedIndex/);
|
||||
assert.match(namedBridge, /value\.Contains\('\^'\)/);
|
||||
assert.match(namedBridge,
|
||||
/return string\.Equals\(page\.ToString\(\), value, StringComparison\.Ordinal\)/);
|
||||
});
|
||||
|
||||
test("page preflight uses one correlated read-only bridge with playout priority", () => {
|
||||
assert.match(mainWindow, /case "request-playlist-page-plans"/);
|
||||
assert.match(mainWindow, /CancelRequest\(Volatile\.Read\(ref _playlistPagePlanCancellation\)\)/);
|
||||
assert.match(pagePlanBridge,
|
||||
/Interlocked\.Exchange\(\s*ref _playlistPagePlanCancellation,\s*requestCancellation\)/s);
|
||||
assert.match(pagePlanBridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/);
|
||||
assert.ok((pagePlanBridge.match(/Volatile\.Read\(ref _playoutCommandInFlight\) != 0/g) || []).length >= 2);
|
||||
assert.match(pagePlanBridge, /PreflightPagePlansAsync\(/);
|
||||
assert.match(pagePlanBridge, /PostMessage\("playlist-page-plans-results"/);
|
||||
assert.match(pagePlanBridge, /PostMessage\("playlist-page-plans-error"/);
|
||||
assert.doesNotMatch(pagePlanBridge, /PrepareAsync\(|PlayAsync\(|ClearAsync\(|UnloadAsync\(/);
|
||||
});
|
||||
|
||||
test("browser page-plan handling requires exact correlation and exposes explicit retry", () => {
|
||||
assert.match(appScript, /normalizePagePlanResponse\(payload, pending\.request\)/);
|
||||
assert.match(appScript, /payload\?\.requestId !== pending\.requestId/);
|
||||
assert.match(appScript, /applyPagePlanResponse\(/);
|
||||
assert.match(appScript, /page-result-empty/);
|
||||
assert.match(appScript, /namedPlaylistPageRetryButton/);
|
||||
assert.match(appScript, /case "playlist-page-plans-results"/);
|
||||
assert.match(appScript, /case "playlist-page-plans-error"/);
|
||||
assert.doesNotMatch(appScript, /markPageRecalculated\(/);
|
||||
});
|
||||
430
tests/Web/named-playlist-workflow.test.cjs
Normal file
430
tests/Web/named-playlist-workflow.test.cjs
Normal file
@@ -0,0 +1,430 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/named-playlist-workflow.js");
|
||||
|
||||
const retrievedAt = "2026-07-11T09:30:00+09:00";
|
||||
const selection = Object.freeze({
|
||||
groupCode: "KOSPI",
|
||||
subject: "Samsung Electronics",
|
||||
graphicType: "ONE_COLUMN",
|
||||
subtype: "CURRENT",
|
||||
dataCode: "005930"
|
||||
});
|
||||
|
||||
function trustedEntry(overrides = {}) {
|
||||
return {
|
||||
id: "entry-1",
|
||||
builderKey: "s5001",
|
||||
code: "5001",
|
||||
enabled: true,
|
||||
selection,
|
||||
operator: { source: "trusted-test", schemaVersion: 1 },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function nativeRow(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 0,
|
||||
enabled: true,
|
||||
groupCode: "KOSPI",
|
||||
subject: "Samsung Electronics",
|
||||
graphicType: "ONE_COLUMN",
|
||||
subtype: "CURRENT",
|
||||
pageText: "1/1",
|
||||
dataCode: "005930",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function loadResponse(rows = [nativeRow()], overrides = {}) {
|
||||
return {
|
||||
requestId: "load-1",
|
||||
definition: { programCode: "00000001", title: "Morning" },
|
||||
retrievedAt,
|
||||
totalRowCount: rows.length,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
rows,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("named-playlist bridge contract is closed and bounded", () => {
|
||||
assert.deepEqual(workflow.bridgeContract, {
|
||||
requests: {
|
||||
list: "request-named-playlist-list",
|
||||
load: "request-named-playlist-load",
|
||||
create: "create-named-playlist",
|
||||
replace: "replace-named-playlist",
|
||||
delete: "delete-named-playlist"
|
||||
},
|
||||
responses: {
|
||||
list: "named-playlist-list-results",
|
||||
load: "named-playlist-load-results",
|
||||
mutation: "named-playlist-mutation-results",
|
||||
error: "named-playlist-error"
|
||||
},
|
||||
schemaVersion: 1,
|
||||
maximumDefinitions: 1000,
|
||||
maximumItems: 1000,
|
||||
maximumPageCount: 20,
|
||||
maximumStoredPageCount: 9999
|
||||
});
|
||||
assert.deepEqual(workflow.operations, ["list", "load", "create", "replace", "delete"]);
|
||||
});
|
||||
|
||||
test("list, load, create and delete request payloads require canonical identities", () => {
|
||||
assert.deepEqual(workflow.createListRequest("list-1"), {
|
||||
requestId: "list-1", maximumResults: 200
|
||||
});
|
||||
assert.deepEqual(workflow.createListRequest("list-2", 1000), {
|
||||
requestId: "list-2", maximumResults: 1000
|
||||
});
|
||||
assert.deepEqual(workflow.createLoadRequest("load-1", "00000001"), {
|
||||
requestId: "load-1", programCode: "00000001"
|
||||
});
|
||||
assert.deepEqual(workflow.createDefinitionRequest("create-1", "00000001", "Morning"), {
|
||||
requestId: "create-1", programCode: "00000001", title: "Morning"
|
||||
});
|
||||
assert.deepEqual(workflow.createDeleteRequest("delete-1", "00000001"), {
|
||||
requestId: "delete-1", programCode: "00000001"
|
||||
});
|
||||
assert.throws(() => workflow.createListRequest("bad id"), /safe/i);
|
||||
assert.throws(() => workflow.createListRequest("list", 1001), /1 through 1000/i);
|
||||
assert.throws(() => workflow.createLoadRequest("load", "1"), /eight-digit/i);
|
||||
assert.throws(() => workflow.createDefinitionRequest("create", "00000001", " Morning"), /canonical/i);
|
||||
});
|
||||
|
||||
test("trusted entries serialize to the exact original seven LIST_TEXT fields", () => {
|
||||
const entry = trustedEntry();
|
||||
assert.deepEqual(workflow.toLegacySevenFields(entry, "2/4"), [
|
||||
"1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "2/4", "005930"
|
||||
]);
|
||||
assert.equal(
|
||||
workflow.legacyListText(workflow.toLegacySevenFields(entry, "2/4")),
|
||||
"1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^2/4^005930");
|
||||
|
||||
const request = workflow.createReplaceRequest(
|
||||
"replace-1",
|
||||
"00000001",
|
||||
[entry, trustedEntry({ id: "entry-2", enabled: false })],
|
||||
{
|
||||
isTrustedEntry: () => true,
|
||||
pageTextForEntry: (_, index) => index === 0 ? "2/4" : "1/0"
|
||||
});
|
||||
assert.deepEqual(request.items, [
|
||||
{
|
||||
itemIndex: 0, enabled: true, groupCode: "KOSPI", subject: "Samsung Electronics",
|
||||
graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "2/4", dataCode: "005930"
|
||||
},
|
||||
{
|
||||
itemIndex: 1, enabled: false, groupCode: "KOSPI", subject: "Samsung Electronics",
|
||||
graphicType: "ONE_COLUMN", subtype: "CURRENT", pageText: "1/0", dataCode: "005930"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("database replacement requires trusted verification and enforces the 1000-row bound", () => {
|
||||
assert.throws(() => workflow.createReplaceRequest(
|
||||
"replace", "00000001", [trustedEntry()]), /trusted-entry verifier/i);
|
||||
assert.throws(() => workflow.createReplaceRequest(
|
||||
"replace", "00000001", [trustedEntry()], { isTrustedEntry: () => false }), /trusted restoration/i);
|
||||
|
||||
const entries = Array.from({ length: 1000 }, (_, index) => trustedEntry({ id: `entry-${index}` }));
|
||||
const bounded = workflow.createReplaceRequest(
|
||||
"replace", "00000001", entries, { isTrustedEntry: () => true });
|
||||
assert.equal(bounded.items.length, 1000);
|
||||
assert.throws(() => workflow.createReplaceRequest(
|
||||
"replace", "00000001", [...entries, trustedEntry({ id: "overflow" })],
|
||||
{ isTrustedEntry: () => true }), /at most 1000/i);
|
||||
});
|
||||
|
||||
test("unsafe selection delimiters, controls and noncanonical pages never reach the bridge", () => {
|
||||
assert.throws(() => workflow.toLegacySevenFields(trustedEntry({
|
||||
selection: { ...selection, subject: "Alpha^Beta" }
|
||||
})), /cannot be stored safely/i);
|
||||
assert.throws(() => workflow.toLegacySevenFields(trustedEntry({
|
||||
selection: { ...selection, subject: "Alpha\nBeta" }
|
||||
})), /cannot be stored safely/i);
|
||||
assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "01/02"), /page text/i);
|
||||
assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "0/0"), /page text/i);
|
||||
assert.throws(() => workflow.toLegacySevenFields(trustedEntry(), "1/10000"), /page text/i);
|
||||
assert.throws(() => workflow.legacyListText([
|
||||
"yes", "KOSPI", "Alpha", "ONE_COLUMN", "CURRENT", "1/1", "000001"
|
||||
]), /seven safe/i);
|
||||
});
|
||||
|
||||
test("stored page parsing preserves historical values but marks the current 20-page boundary", () => {
|
||||
assert.deepEqual(workflow.parseStoredPageText("4/20"), {
|
||||
pageText: "4/20", currentPage: 4, totalPages: 20,
|
||||
isWithinPlayoutBounds: true,
|
||||
historicalPageOutOfBounds: false,
|
||||
pageRecalculationRequired: true
|
||||
});
|
||||
assert.deepEqual(workflow.parseStoredPageText("1/0"), {
|
||||
pageText: "1/0", currentPage: 1, totalPages: 0,
|
||||
isWithinPlayoutBounds: false,
|
||||
historicalPageOutOfBounds: true,
|
||||
pageRecalculationRequired: true
|
||||
});
|
||||
assert.deepEqual(workflow.parseStoredPageText("1/24"), {
|
||||
pageText: "1/24", currentPage: 1, totalPages: 24,
|
||||
isWithinPlayoutBounds: false,
|
||||
historicalPageOutOfBounds: true,
|
||||
pageRecalculationRequired: true
|
||||
});
|
||||
for (const value of ["", "01/1", "1/01", "2/1", "2/0", "1/10000", "text"]) {
|
||||
assert.equal(workflow.parseStoredPageText(value), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("list responses are exact, duplicate-safe and expose permanent write quarantine", () => {
|
||||
const response = {
|
||||
requestId: "list-1",
|
||||
retrievedAt,
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
suggestedProgramCode: "00000003",
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
definitions: [
|
||||
{ programCode: "00000001", title: "Morning" },
|
||||
{ programCode: "00000002", title: "Noon" }
|
||||
]
|
||||
};
|
||||
assert.deepEqual(workflow.normalizeListResponse(response), response);
|
||||
assert.equal(workflow.normalizeListResponse({ ...response, extra: true }), null);
|
||||
assert.equal(workflow.normalizeListResponse({
|
||||
...response,
|
||||
definitions: [response.definitions[0], response.definitions[0]]
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeListResponse({
|
||||
...response, canMutate: true, writeQuarantined: true
|
||||
}), null);
|
||||
const quarantined = workflow.normalizeListResponse({
|
||||
...response, canMutate: false, writeQuarantined: true
|
||||
});
|
||||
assert.equal(workflow.isWriteBlocked(quarantined), true);
|
||||
});
|
||||
|
||||
test("load normalization preserves every raw field before trusted restoration", () => {
|
||||
const normalized = workflow.normalizeLoadResponse(loadResponse([
|
||||
nativeRow({ pageText: "1/24" }),
|
||||
nativeRow({ itemIndex: 1, enabled: false, subject: "SK hynix", dataCode: "000660", pageText: "" })
|
||||
]));
|
||||
assert.ok(normalized);
|
||||
assert.equal(normalized.rawRows.length, 2);
|
||||
assert.deepEqual(normalized.rawRows[0].legacyFields, [
|
||||
"1", "KOSPI", "Samsung Electronics", "ONE_COLUMN", "CURRENT", "1/24", "005930"
|
||||
]);
|
||||
assert.equal(normalized.rawRows[0].listText,
|
||||
"1^KOSPI^Samsung Electronics^ONE_COLUMN^CURRENT^1/24^005930");
|
||||
assert.equal(normalized.rawRows[0].requiresTrustedRestore, true);
|
||||
assert.equal(normalized.rawRows[0].pageRecalculationRequired, true);
|
||||
assert.equal(normalized.rawRows[0].historicalPageOutOfBounds, true);
|
||||
assert.equal(normalized.rawRows[1].requiresTrustedRestore, true);
|
||||
assert.equal(normalized.rawRows[1].pageRecalculationRequired, false);
|
||||
assert.match(workflow.pageStatusLabel(normalized.rawRows[0]), /20-page maximum/i);
|
||||
});
|
||||
|
||||
test("load responses reject extra fields, reordered indexes and malformed historical pages", () => {
|
||||
assert.equal(workflow.normalizeLoadResponse({ ...loadResponse(), extra: true }), null);
|
||||
assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ itemIndex: 1 })])), null);
|
||||
assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "01/1" })])), null);
|
||||
assert.equal(workflow.normalizeLoadResponse(loadResponse([nativeRow({ subject: " Alpha" })])), null);
|
||||
assert.equal(workflow.normalizeLoadResponse(loadResponse([], { totalRowCount: 1 })), null);
|
||||
});
|
||||
|
||||
test("raw database rows cannot become playable until a trusted resolver reconstructs them", () => {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse());
|
||||
const unresolved = workflow.restoreRawRows(load, () => null);
|
||||
assert.equal(unresolved.readyForPrepare, false);
|
||||
assert.deepEqual(unresolved.blockers.map(value => value.reason), [
|
||||
"trusted-restore-required", "page-recalculation-required"
|
||||
]);
|
||||
assert.throws(() => workflow.materializeTrustedPlaylist(unresolved), /trusted restoration/i);
|
||||
assert.equal(unresolved.rows[0].rawRow, load.rawRows[0]);
|
||||
});
|
||||
|
||||
test("trusted resolver output must reproduce enabled state and all five selection fields", () => {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse());
|
||||
assert.equal(workflow.restoreRawRows(load, () => trustedEntry()).rows[0].trusted, true);
|
||||
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ enabled: false })).rows[0].trusted, false);
|
||||
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({
|
||||
selection: { ...selection, subject: "Tampered" }
|
||||
})).rows[0].trusted, false);
|
||||
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ code: "bad" })).rows[0].trusted, false);
|
||||
assert.equal(workflow.restoreRawRows(load, () => trustedEntry({ builderKey: "5001" })).rows[0].trusted, false);
|
||||
});
|
||||
|
||||
test("fresh page plans are mandatory before PREPARE and cannot exceed 20 pages", () => {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
|
||||
const restored = workflow.restoreRawRows(load, () => trustedEntry());
|
||||
assert.equal(restored.readyForPrepare, false);
|
||||
assert.throws(() => workflow.markPageRecalculated(restored, 0, {
|
||||
itemCount: 101, pageSize: 5, pageCount: 21
|
||||
}), /exceeds 20 pages/i);
|
||||
|
||||
const recalculated = workflow.markPageRecalculated(restored, 0, {
|
||||
itemCount: 100, pageSize: 5, pageCount: 20
|
||||
});
|
||||
assert.equal(recalculated.readyForPrepare, true);
|
||||
assert.equal(workflow.canPrepareRestoredRows(recalculated), true);
|
||||
assert.deepEqual(recalculated.rows[0].recalculatedPagePlan, {
|
||||
itemCount: 100, pageSize: 5, pageCount: 20
|
||||
});
|
||||
assert.equal(recalculated.rows[0].rawRow.pageText, "1/24");
|
||||
assert.deepEqual(workflow.materializeTrustedPlaylist(recalculated), [trustedEntry()]);
|
||||
});
|
||||
|
||||
test("paged trusted rows create a strict preflight request without using stored page text", () => {
|
||||
function prepare(pageText) {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText })]));
|
||||
const restored = workflow.restoreRawRows(load, () => trustedEntry({
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
fadeDuration: 6
|
||||
}));
|
||||
return workflow.preparePagePreflight(restored, entry => entry.builderKey === "s5074");
|
||||
}
|
||||
|
||||
const request = workflow.createPagePlanRequest("page-plan-1", prepare("1/24"));
|
||||
const requestFromDifferentHistoricalPage = workflow.createPagePlanRequest(
|
||||
"page-plan-1",
|
||||
prepare("4/20"));
|
||||
|
||||
assert.deepEqual(request, {
|
||||
requestId: "page-plan-1",
|
||||
entries: [{
|
||||
id: "entry-1",
|
||||
code: "5074",
|
||||
enabled: true,
|
||||
fadeDuration: 6,
|
||||
selection
|
||||
}]
|
||||
});
|
||||
assert.deepEqual(requestFromDifferentHistoricalPage, request);
|
||||
assert.equal(Object.hasOwn(request.entries[0], "pageText"), false);
|
||||
});
|
||||
|
||||
test("correlated capped page plans enable PREPARE and preserve explicit truncation", () => {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
|
||||
let restored = workflow.restoreRawRows(load, () => trustedEntry({
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
fadeDuration: 6
|
||||
}));
|
||||
restored = workflow.preparePagePreflight(restored, () => true);
|
||||
const request = workflow.createPagePlanRequest("page-plan-2", restored);
|
||||
const rawResponse = {
|
||||
requestId: "page-plan-2",
|
||||
calculatedAt: retrievedAt,
|
||||
plans: [{
|
||||
entryId: "entry-1",
|
||||
itemCount: 101,
|
||||
pageSize: 5,
|
||||
pageCount: 20,
|
||||
accessibleItemCount: 100,
|
||||
isTruncated: true,
|
||||
builderKey: "s5074"
|
||||
}]
|
||||
};
|
||||
const response = workflow.normalizePagePlanResponse(rawResponse, request);
|
||||
assert.ok(response);
|
||||
restored = workflow.applyPagePlanResponse(restored, response);
|
||||
|
||||
assert.equal(restored.readyForPrepare, true);
|
||||
assert.equal(restored.rows[0].pageRecalculated, true);
|
||||
assert.deepEqual(restored.rows[0].recalculatedPagePlan, rawResponse.plans[0]);
|
||||
assert.equal(workflow.normalizePagePlanResponse({
|
||||
...rawResponse,
|
||||
requestId: "stale-request"
|
||||
}, request), null);
|
||||
assert.equal(workflow.normalizePagePlanResponse({
|
||||
...rawResponse,
|
||||
plans: [{ ...rawResponse.plans[0], accessibleItemCount: 101 }]
|
||||
}, request), null);
|
||||
});
|
||||
|
||||
test("zero-row page preflight is complete but explicitly unavailable for PREPARE", () => {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })]));
|
||||
let restored = workflow.restoreRawRows(load, () => trustedEntry({
|
||||
builderKey: "s5077",
|
||||
code: "5077",
|
||||
fadeDuration: 6
|
||||
}));
|
||||
restored = workflow.preparePagePreflight(restored, () => true);
|
||||
const request = workflow.createPagePlanRequest("page-plan-zero", restored);
|
||||
const response = workflow.normalizePagePlanResponse({
|
||||
requestId: "page-plan-zero",
|
||||
calculatedAt: retrievedAt,
|
||||
plans: [{
|
||||
entryId: "entry-1",
|
||||
itemCount: 0,
|
||||
pageSize: 6,
|
||||
pageCount: 0,
|
||||
accessibleItemCount: 0,
|
||||
isTruncated: false,
|
||||
builderKey: "s5077"
|
||||
}]
|
||||
}, request);
|
||||
restored = workflow.applyPagePlanResponse(restored, response);
|
||||
|
||||
assert.equal(restored.readyForPrepare, false);
|
||||
assert.equal(restored.rows[0].pagePreflightCompleted, true);
|
||||
assert.equal(restored.rows[0].pageRecalculated, false);
|
||||
assert.deepEqual(restored.blockers.map(value => value.reason), ["page-result-empty"]);
|
||||
});
|
||||
|
||||
test("nonpaged rows become ready without a synthetic page-plan result", () => {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "1/24" })]));
|
||||
const restored = workflow.preparePagePreflight(
|
||||
workflow.restoreRawRows(load, () => trustedEntry()),
|
||||
() => false);
|
||||
assert.equal(restored.readyForPrepare, true);
|
||||
assert.equal(workflow.createPagePlanRequest("no-page-plan", restored), null);
|
||||
assert.equal(restored.rows[0].recalculatedPagePlan, null);
|
||||
});
|
||||
|
||||
test("rows without stored page text still require trust but no artificial page plan", () => {
|
||||
const load = workflow.normalizeLoadResponse(loadResponse([nativeRow({ pageText: "" })]));
|
||||
const restored = workflow.restoreRawRows(load, () => trustedEntry());
|
||||
assert.equal(restored.readyForPrepare, true);
|
||||
assert.deepEqual(restored.blockers, []);
|
||||
assert.equal(restored.rows[0].pageRecalculated, true);
|
||||
});
|
||||
|
||||
test("mutation responses are strict and retain quarantine state", () => {
|
||||
const response = {
|
||||
requestId: "replace-1",
|
||||
operation: "replace",
|
||||
programCode: "00000001",
|
||||
completedAt: retrievedAt,
|
||||
writeQuarantined: false
|
||||
};
|
||||
assert.deepEqual(workflow.normalizeMutationResponse(response), response);
|
||||
assert.equal(workflow.normalizeMutationResponse({ ...response, operation: "load" }), null);
|
||||
assert.equal(workflow.normalizeMutationResponse({ ...response, extra: true }), null);
|
||||
});
|
||||
|
||||
test("OutcomeUnknown errors permanently block writes and can never be retryable", () => {
|
||||
const error = {
|
||||
requestId: "replace-1",
|
||||
operation: "replace",
|
||||
programCode: "00000001",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "Restart before another write.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true,
|
||||
writeQuarantined: true
|
||||
};
|
||||
const normalized = workflow.normalizeError(error);
|
||||
assert.deepEqual(normalized, error);
|
||||
assert.equal(workflow.isWriteBlocked(normalized), true);
|
||||
assert.equal(workflow.normalizeError({ ...error, retryable: true }), null);
|
||||
assert.equal(workflow.normalizeError({ ...error, writeQuarantined: false }), null);
|
||||
assert.equal(workflow.normalizeError({ ...error, extra: true }), null);
|
||||
});
|
||||
53
tests/Web/operator-catalog-bridge-integration.test.cjs
Normal file
53
tests/Web/operator-catalog-bridge-integration.test.cjs
Normal file
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const bridge = fs.readFileSync(
|
||||
path.join(repositoryRoot, "MainWindow.OperatorCatalogs.cs"),
|
||||
"utf8");
|
||||
|
||||
test("theme, expert, schema and next-code reads use entity-operation lanes", () => {
|
||||
assert.match(bridge,
|
||||
/Dictionary<string, OperatorCatalogReadRequest> _operatorCatalogReadLanes/);
|
||||
assert.match(bridge,
|
||||
/new OperatorCatalogReadRequest\(\s*\$"\{entity\}:\{operation\}"/s);
|
||||
assert.match(bridge, /_operatorCatalogReadLanes\[request\.Lane\] = request/);
|
||||
assert.match(bridge, /_operatorCatalogReadLanes\.TryGetValue\(request\.Lane/);
|
||||
assert.doesNotMatch(bridge, /_operatorCatalogReadCancellation/);
|
||||
assert.match(bridge, /await _databaseActivityGate\.WaitAsync\(requestToken\)/);
|
||||
assert.match(bridge, /OperatorCatalogBridgeReply/);
|
||||
});
|
||||
|
||||
test("each catalog request publishes at most one terminal result or error", () => {
|
||||
assert.match(bridge, /CancelRequest\(superseded\.Cancellation\)/);
|
||||
assert.match(bridge, /superseded\?\.TryComplete\(\)/);
|
||||
assert.match(bridge, /"CANCELLED"/);
|
||||
assert.match(bridge, /newer request replaced it/);
|
||||
assert.match(bridge,
|
||||
/Interlocked\.CompareExchange\(ref _terminalState, 1, 0\) == 0/);
|
||||
assert.match(bridge,
|
||||
/TryCompleteOperatorCatalogReadIfCurrent\(request\)[\s\S]*PostMessage\(reply\.MessageType/s);
|
||||
assert.match(bridge,
|
||||
/ReferenceEquals\(current, request\) &&[\s\S]*request\.TryComplete\(\)/s);
|
||||
assert.match(bridge,
|
||||
/PostOperatorCatalogReadErrorIfCurrent\([\s\S]*!TryCompleteOperatorCatalogReadIfCurrent\(request\)/s);
|
||||
});
|
||||
|
||||
test("browser invalidation, playout priority and shutdown cover every active lane", () => {
|
||||
assert.match(bridge, /private OperatorCatalogReadRequest\[\] SnapshotOperatorCatalogReads/);
|
||||
assert.ok((bridge.match(/SnapshotOperatorCatalogReads\(clear: true\)/g) || []).length >= 2);
|
||||
assert.match(bridge,
|
||||
/CancelOperatorCatalogReadsForPlayout\(\)[\s\S]*SnapshotOperatorCatalogReads\(clear: false\)/s);
|
||||
});
|
||||
|
||||
test("write safety gate and process quarantine remain intact", () => {
|
||||
assert.match(bridge,
|
||||
/CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/);
|
||||
assert.match(bridge, /LatchOperatorCatalogWriteQuarantine/);
|
||||
assert.match(bridge, /Interlocked\.Exchange\(ref _operatorCatalogWriteCancellation, null\)/);
|
||||
assert.doesNotMatch(bridge, /operationBody\(requestToken\)\.ConfigureAwait\(false\)/);
|
||||
});
|
||||
348
tests/Web/operator-catalog-ui.test.cjs
Normal file
348
tests/Web/operator-catalog-ui.test.cjs
Normal file
@@ -0,0 +1,348 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const workflow = require("../../Web/operator-catalog-workflow.js");
|
||||
const ui = require("../../Web/operator-catalog-ui.js");
|
||||
|
||||
const retrievedAt = "2026-07-12T10:00:00+09:00";
|
||||
|
||||
function themeContext(title = "AI 반도체") {
|
||||
return {
|
||||
selection: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: title,
|
||||
source: "oracle"
|
||||
},
|
||||
preview: {
|
||||
requestId: "theme-preview-1",
|
||||
selection: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
title
|
||||
},
|
||||
retrievedAt,
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 4, itemCode: "D035720", itemName: "카카오" }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function expertContext() {
|
||||
return {
|
||||
selection: { expertCode: "0001", expertName: "홍길동", source: "oracle" },
|
||||
preview: {
|
||||
requestId: "expert-preview-1",
|
||||
selection: { expertCode: "0001", expertName: "홍길동" },
|
||||
retrievedAt,
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 8, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const posted = [];
|
||||
const toasts = [];
|
||||
const logs = [];
|
||||
let sequence = 0;
|
||||
let isLocked = false;
|
||||
const controller = ui.createController({
|
||||
postNative(type, payload) { posted.push({ type, payload }); },
|
||||
isLocked() { return isLocked; },
|
||||
showToast(message) { toasts.push(message); },
|
||||
addLog(message) { logs.push(message); },
|
||||
createId() { sequence += 1; return String(sequence); }
|
||||
});
|
||||
return {
|
||||
controller,
|
||||
posted,
|
||||
toasts,
|
||||
logs,
|
||||
setExternalLocked(value) { isLocked = value; }
|
||||
};
|
||||
}
|
||||
|
||||
function completeSchema(h, overrides = {}) {
|
||||
const pending = h.controller.render().activeRead;
|
||||
assert.equal(pending.type, workflow.bridgeContract.requests.schemaPreflight);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.schemaPreflight, {
|
||||
requestId: pending.request.requestId,
|
||||
validatedAt: retrievedAt,
|
||||
validatedSources: ["oracle", "mariaDb"],
|
||||
themeCanMutate: true,
|
||||
expertCanMutate: true,
|
||||
writeQuarantined: false,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
function mutationResult(active, overrides = {}) {
|
||||
return {
|
||||
requestId: active.requestId,
|
||||
entity: active.entity,
|
||||
operation: active.operation,
|
||||
identityCode: active.identityCode,
|
||||
operationId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
committedAt: retrievedAt,
|
||||
writeQuarantined: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("controller exposes the independent integration API", () => {
|
||||
const h = harness();
|
||||
for (const method of [
|
||||
"mount", "openTheme", "openExpert", "handleMessage", "render", "setLocked"
|
||||
]) assert.equal(typeof h.controller[method], "function");
|
||||
});
|
||||
|
||||
test("theme edit starts only from a closed selection and complete preview", () => {
|
||||
const h = harness();
|
||||
const opened = h.controller.openTheme(themeContext());
|
||||
|
||||
assert.equal(opened.entity, "theme");
|
||||
assert.equal(opened.mode, "edit");
|
||||
assert.deepEqual(opened.draft.items, [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "D035720", itemName: "카카오" }
|
||||
]);
|
||||
assert.equal(h.posted[0].type, workflow.bridgeContract.requests.schemaPreflight);
|
||||
assert.throws(() => h.controller.openTheme({ selection: themeContext().selection }), /exactly/i);
|
||||
});
|
||||
|
||||
test("theme prefixes, duplicate checks and reordering remain explicit in replace requests", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme(themeContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.addThemeItem("X", "000660", "SK하이닉스"), false);
|
||||
assert.equal(h.controller.addThemeItem("P", "000660", "SK하이닉스"), true);
|
||||
assert.equal(h.controller.moveDraftItem(2, -1), true);
|
||||
assert.equal(h.controller.updateDraft({ themeTitle: "AI·로봇" }), true);
|
||||
|
||||
assert.deepEqual(h.controller.render().draft.items, [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" },
|
||||
{ inputIndex: 2, itemCode: "D035720", itemName: "카카오" }
|
||||
]);
|
||||
assert.equal(h.controller.submit(), true);
|
||||
const sent = h.posted.at(-1);
|
||||
assert.equal(sent.type, workflow.bridgeContract.requests.themeReplace);
|
||||
assert.equal(sent.payload.newTitle, "AI·로봇");
|
||||
assert.deepEqual(sent.payload.items, h.controller.render().draft.items);
|
||||
|
||||
const active = h.controller.render().mutation;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.mutation,
|
||||
mutationResult(active, { requestId: "stale" }));
|
||||
assert.deepEqual(h.controller.render().mutation, active);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.mutation, mutationResult(active));
|
||||
assert.equal(h.controller.render().mutation, null);
|
||||
assert.equal(h.controller.render().mode, "list");
|
||||
});
|
||||
|
||||
test("schema, catalog search and next-code reads are serialized and exactly correlated", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme();
|
||||
assert.equal(h.posted.length, 1);
|
||||
completeSchema(h);
|
||||
assert.equal(h.posted.at(-1).type, workflow.bridgeContract.requests.themeList);
|
||||
|
||||
let pending = h.controller.render().activeRead;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
|
||||
requestId: pending.request.requestId,
|
||||
query: "",
|
||||
nxtSession: "preMarket",
|
||||
retrievedAt,
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: [{
|
||||
market: "krx", session: "notApplicable", themeCode: "00000001",
|
||||
themeTitle: "AI", source: "oracle"
|
||||
}]
|
||||
});
|
||||
assert.equal(h.controller.render().themeResults.length, 1);
|
||||
|
||||
h.controller.search("old", "afterMarket");
|
||||
const old = h.controller.render().activeRead;
|
||||
h.controller.search("latest", "afterMarket");
|
||||
assert.equal(h.controller.render().activeRead.superseded, true);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
|
||||
requestId: old.request.requestId,
|
||||
query: "old",
|
||||
nxtSession: "afterMarket",
|
||||
retrievedAt,
|
||||
totalRowCount: 0,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: []
|
||||
});
|
||||
pending = h.controller.render().activeRead;
|
||||
assert.equal(pending.request.query, "latest");
|
||||
assert.equal(h.controller.render().themeResults.length, 1);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
|
||||
requestId: pending.request.requestId,
|
||||
query: "latest",
|
||||
nxtSession: "afterMarket",
|
||||
retrievedAt,
|
||||
totalRowCount: 0,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: []
|
||||
});
|
||||
assert.equal(h.controller.render().themeResults.length, 0);
|
||||
|
||||
assert.equal(h.controller.beginNewTheme("nxt"), true);
|
||||
pending = h.controller.render().activeRead;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeNextCode, {
|
||||
requestId: pending.request.requestId,
|
||||
market: "nxt",
|
||||
themeCode: "00000007",
|
||||
canMutate: true,
|
||||
writeQuarantined: false
|
||||
});
|
||||
assert.equal(h.controller.render().mode, "new");
|
||||
assert.equal(h.controller.addThemeItem("P", "005930", "삼성전자"), false);
|
||||
assert.equal(h.controller.addThemeItem("X", "005930", "삼성전자"), true);
|
||||
assert.equal(h.controller.addThemeItem("T", "000660", "SK하이닉스"), true);
|
||||
});
|
||||
|
||||
test("expert recommendations preserve exact code, name, buy amount and order", () => {
|
||||
const h = harness();
|
||||
h.controller.openExpert(expertContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.addExpertRecommendation("005930", "중복", 1), false);
|
||||
assert.equal(h.controller.addExpertRecommendation("000660", "SK하이닉스", "123456"), true);
|
||||
assert.equal(h.controller.moveDraftItem(2, -1), true);
|
||||
assert.equal(h.controller.updateDraft({ expertName: "홍길동 위원" }), true);
|
||||
assert.equal(h.controller.submit(), true);
|
||||
|
||||
const sent = h.posted.at(-1);
|
||||
assert.equal(sent.type, workflow.bridgeContract.requests.expertReplace);
|
||||
assert.equal(sent.payload.newName, "홍길동 위원");
|
||||
assert.deepEqual(sent.payload.recommendations, [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 1, stockCode: "000660", stockName: "SK하이닉스", buyAmount: 123456 },
|
||||
{ playIndex: 2, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
]);
|
||||
});
|
||||
|
||||
test("OutcomeUnknown latches a process quarantine and never retries", () => {
|
||||
const h = harness();
|
||||
h.controller.openExpert(expertContext());
|
||||
completeSchema(h);
|
||||
h.controller.updateDraft({ expertName: "홍길동 위원" });
|
||||
assert.equal(h.controller.submit(), true);
|
||||
const active = h.controller.render().mutation;
|
||||
const writeCount = h.posted.length;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.error, {
|
||||
requestId: active.requestId,
|
||||
entity: active.entity,
|
||||
operation: active.operation,
|
||||
identityCode: active.identityCode,
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "Restart and reconcile before another write.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true,
|
||||
writeQuarantined: true
|
||||
});
|
||||
|
||||
assert.equal(h.controller.render().quarantined, true);
|
||||
assert.equal(h.controller.render().mutation, null);
|
||||
assert.equal(h.controller.submit(), false);
|
||||
assert.equal(h.posted.length, writeCount);
|
||||
});
|
||||
|
||||
test("a malformed same-request mutation result loses correlation and quarantines", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme(themeContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.submit(), true);
|
||||
const active = h.controller.render().mutation;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.mutation, {
|
||||
...mutationResult(active),
|
||||
operationId: "not-a-uuid"
|
||||
});
|
||||
assert.equal(h.controller.render().quarantined, true);
|
||||
assert.equal(h.controller.render().mutation, null);
|
||||
});
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName, ownerDocument) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.textContent = "";
|
||||
this.value = "";
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
}
|
||||
append(...values) { values.forEach(value => this.appendChild(value)); }
|
||||
appendChild(value) { this.children.push(value); return value; }
|
||||
replaceChildren(...values) { this.children = []; this.append(...values); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
removeAttribute(name) { this.attributes.delete(name); }
|
||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
constructor() {
|
||||
this.head = new FakeElement("head", this);
|
||||
this.body = new FakeElement("body", this);
|
||||
}
|
||||
createElement(tagName) { return new FakeElement(tagName, this); }
|
||||
getElementById(id) {
|
||||
function find(node) {
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children) {
|
||||
const match = find(child);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(this.head) || find(this.body);
|
||||
}
|
||||
}
|
||||
|
||||
function findRole(node, role) {
|
||||
if (node.attributes?.get("data-role") === role) return node;
|
||||
for (const child of node.children || []) {
|
||||
const match = findRole(child, role);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test("mount creates a dialog with text-only rendering and its own stylesheet", () => {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
h.controller.mount(document.body);
|
||||
h.controller.openTheme(themeContext("<b>AI</b>"));
|
||||
assert.equal(document.body.children[0].tagName, "DIALOG");
|
||||
assert.equal(document.head.children[0].href, "operator-catalog-ui.css");
|
||||
assert.equal(findRole(document.body, "identity-name").value, "<b>AI</b>");
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../Web/operator-catalog-ui.js"),
|
||||
"utf8");
|
||||
assert.doesNotMatch(source, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(/);
|
||||
});
|
||||
446
tests/Web/operator-catalog-workflow.test.cjs
Normal file
446
tests/Web/operator-catalog-workflow.test.cjs
Normal file
@@ -0,0 +1,446 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const workflow = require("../../Web/operator-catalog-workflow.js");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const nativeBridge = fs.readFileSync(
|
||||
path.join(repositoryRoot, "MainWindow.OperatorCatalogs.cs"),
|
||||
"utf8");
|
||||
const playoutBridge = fs.readFileSync(
|
||||
path.join(repositoryRoot, "MainWindow.Playout.cs"),
|
||||
"utf8");
|
||||
const operatorGate = fs.readFileSync(
|
||||
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"),
|
||||
"utf8");
|
||||
|
||||
const krxSelection = Object.freeze({
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체",
|
||||
source: "oracle"
|
||||
});
|
||||
|
||||
const expertSelection = Object.freeze({
|
||||
expertCode: "0001",
|
||||
expertName: "홍길동",
|
||||
source: "oracle"
|
||||
});
|
||||
|
||||
function themePreview(overrides = {}) {
|
||||
return {
|
||||
requestId: "theme-preview-1",
|
||||
selection: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
title: "AI 반도체"
|
||||
},
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 3, itemCode: "D035720", itemName: "카카오" }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function expertPreview(overrides = {}) {
|
||||
return {
|
||||
requestId: "expert-preview-1",
|
||||
selection: { expertCode: "0001", expertName: "홍길동" },
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 4, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("bridge contract pins every independent request and event name", () => {
|
||||
assert.deepEqual(workflow.bridgeContract.requests, {
|
||||
themeList: "request-theme-catalog-list",
|
||||
expertList: "request-expert-catalog-list",
|
||||
schemaPreflight: "request-operator-catalog-schema",
|
||||
themeNextCode: "request-theme-catalog-next-code",
|
||||
expertNextCode: "request-expert-catalog-next-code",
|
||||
themeCreate: "create-theme-catalog",
|
||||
themeReplace: "replace-theme-catalog",
|
||||
themeDelete: "delete-theme-catalog",
|
||||
expertCreate: "create-expert-catalog",
|
||||
expertReplace: "replace-expert-catalog",
|
||||
expertDelete: "delete-expert-catalog"
|
||||
});
|
||||
assert.deepEqual(workflow.bridgeContract.events, {
|
||||
themeList: "theme-catalog-list-results",
|
||||
expertList: "expert-catalog-list-results",
|
||||
schemaPreflight: "operator-catalog-schema-results",
|
||||
themeNextCode: "theme-catalog-next-code-results",
|
||||
expertNextCode: "expert-catalog-next-code-results",
|
||||
mutation: "operator-catalog-mutation-results",
|
||||
error: "operator-catalog-error"
|
||||
});
|
||||
assert.deepEqual(workflow.limits, {
|
||||
maximumThemeItems: 240,
|
||||
maximumRecommendations: 100,
|
||||
maximumResults: 500,
|
||||
maximumQueryLength: 64
|
||||
});
|
||||
});
|
||||
|
||||
test("native partial pins the same contract and fail-closed write gates without a retry loop", () => {
|
||||
for (const requestType of Object.values(workflow.bridgeContract.requests)) {
|
||||
assert.match(nativeBridge, new RegExp(`"${requestType}"`));
|
||||
}
|
||||
for (const eventType of Object.values(workflow.bridgeContract.events)) {
|
||||
assert.match(nativeBridge, new RegExp(`"${eventType}"`));
|
||||
}
|
||||
assert.match(nativeBridge, /_operatorCatalogWriteGate\.WaitAsync\(0\)/);
|
||||
assert.match(nativeBridge, /_playoutCommandGate\.WaitAsync\(0, requestToken\)/);
|
||||
assert.match(nativeBridge, /_databaseActivityGate\.WaitAsync\(0, requestToken\)/);
|
||||
assert.match(nativeBridge, /CanStartOperatorCatalogWrite\(\)/);
|
||||
assert.match(nativeBridge,
|
||||
/CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/);
|
||||
assert.match(playoutBridge, /private bool CanStartOperatorMutation\(/);
|
||||
assert.match(playoutBridge, /new OperatorMutationGateSnapshot\(/);
|
||||
assert.match(operatorGate, /!snapshot\.IsPlayCompletionPending/);
|
||||
assert.match(operatorGate, /snapshot\.PreparedSceneName/);
|
||||
assert.match(operatorGate, /snapshot\.OnAirSceneName/);
|
||||
assert.match(operatorGate, /snapshot\.PreparedCutCode/);
|
||||
assert.match(operatorGate, /snapshot\.OnAirCutCode/);
|
||||
assert.match(nativeBridge, /LatchOperatorCatalogWriteQuarantine/);
|
||||
assert.match(nativeBridge, /OUTCOME_UNKNOWN/);
|
||||
assert.match(nativeBridge, /OperatorCatalogMutationExecutor/);
|
||||
assert.doesNotMatch(nativeBridge, /MaximumRetryCount|Task\.Delay|while\s*\(/);
|
||||
});
|
||||
|
||||
test("list, schema and next-code requests are exact, normalized and bounded", () => {
|
||||
assert.deepEqual(workflow.createThemeCatalogListRequest(
|
||||
"theme-list-1", " AI ", "preMarket", 25), {
|
||||
requestId: "theme-list-1",
|
||||
query: "AI",
|
||||
nxtSession: "preMarket",
|
||||
maximumResults: 25
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertCatalogListRequest("expert-list-1", " 홍 "), {
|
||||
requestId: "expert-list-1",
|
||||
query: "홍"
|
||||
});
|
||||
assert.deepEqual(workflow.createSchemaPreflightRequest("schema-1"), {
|
||||
requestId: "schema-1"
|
||||
});
|
||||
assert.deepEqual(workflow.createThemeNextCodeRequest("next-1", "nxt"), {
|
||||
requestId: "next-1",
|
||||
market: "nxt"
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertNextCodeRequest("next-2"), {
|
||||
requestId: "next-2"
|
||||
});
|
||||
assert.throws(() => workflow.createThemeCatalogListRequest("bad id", "", "preMarket"), /request id/);
|
||||
assert.throws(() => workflow.createThemeCatalogListRequest("safe", "", "none"), /session/);
|
||||
assert.throws(() => workflow.createExpertCatalogListRequest("safe", "bad\u0000query"), /query/);
|
||||
assert.throws(() => workflow.createExpertCatalogListRequest("safe", "", 501), /limited/);
|
||||
});
|
||||
|
||||
test("theme selection plus complete preview becomes an editable explicit identity and reindexed draft", () => {
|
||||
const edit = workflow.createThemeEditDraft(krxSelection, themePreview());
|
||||
assert.deepEqual(edit, {
|
||||
expectedIdentity: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체"
|
||||
},
|
||||
draft: {
|
||||
market: "krx",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체",
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "D035720", itemName: "카카오" }
|
||||
]
|
||||
}
|
||||
});
|
||||
edit.draft.themeTitle = "AI·로봇";
|
||||
edit.draft.items.reverse();
|
||||
edit.draft.items.forEach((item, index) => { item.inputIndex = index; });
|
||||
const request = workflow.createThemeReplaceRequest("theme-replace-1", edit);
|
||||
assert.deepEqual(request, {
|
||||
requestId: "theme-replace-1",
|
||||
expectedIdentity: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체"
|
||||
},
|
||||
newTitle: "AI·로봇",
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "D035720", itemName: "카카오" },
|
||||
{ inputIndex: 1, itemCode: "P005930", itemName: "삼성전자" }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test("theme edit conversion rejects truncated, stale, duplicate and market-mismatched previews", () => {
|
||||
assert.throws(() => workflow.createThemeEditDraft(
|
||||
krxSelection,
|
||||
themePreview({ truncated: true })), /complete/);
|
||||
assert.throws(() => workflow.createThemeEditDraft(
|
||||
krxSelection,
|
||||
themePreview({ selection: { ...themePreview().selection, themeCode: "00000002" } })), /correlated/);
|
||||
assert.throws(() => workflow.createThemeEditDraft(
|
||||
krxSelection,
|
||||
themePreview({
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "D035720", itemName: "삼성전자" }
|
||||
]
|
||||
})), /complete/);
|
||||
assert.equal(workflow.normalizeThemeDraft({
|
||||
market: "nxt",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "로봇(NXT)",
|
||||
items: []
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeThemeDraft({
|
||||
market: "krx",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI",
|
||||
items: [{ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" }]
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("new theme drafts require a valid title before create and delete keeps full identity", () => {
|
||||
const draft = workflow.createBlankThemeDraft("nxt", "00000003");
|
||||
assert.deepEqual(draft, {
|
||||
market: "nxt",
|
||||
themeCode: "00000003",
|
||||
themeTitle: "",
|
||||
items: []
|
||||
});
|
||||
assert.throws(() => workflow.createThemeCreateRequest("create-1", draft), /complete/);
|
||||
draft.themeTitle = "로봇";
|
||||
draft.items.push({ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" });
|
||||
assert.deepEqual(workflow.createThemeCreateRequest("create-1", draft), {
|
||||
requestId: "create-1",
|
||||
draft
|
||||
});
|
||||
assert.deepEqual(workflow.createThemeDeleteRequest(
|
||||
"delete-1",
|
||||
{ market: "nxt", session: "afterMarket", themeCode: "00000003", themeTitle: "로봇" }), {
|
||||
requestId: "delete-1",
|
||||
identity: {
|
||||
market: "nxt",
|
||||
session: "afterMarket",
|
||||
themeCode: "00000003",
|
||||
themeTitle: "로봇"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("expert selection plus preview becomes editable and preserves buy amounts while reindexing", () => {
|
||||
const edit = workflow.createExpertEditDraft(expertSelection, expertPreview());
|
||||
assert.deepEqual(edit, {
|
||||
expectedIdentity: { expertCode: "0001", expertName: "홍길동" },
|
||||
draft: {
|
||||
expertCode: "0001",
|
||||
expertName: "홍길동",
|
||||
recommendations: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 1, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
]
|
||||
}
|
||||
});
|
||||
edit.draft.expertName = "홍길동 위원";
|
||||
assert.deepEqual(workflow.createExpertReplaceRequest("expert-replace-1", edit), {
|
||||
requestId: "expert-replace-1",
|
||||
expectedIdentity: { expertCode: "0001", expertName: "홍길동" },
|
||||
newName: "홍길동 위원",
|
||||
recommendations: edit.draft.recommendations
|
||||
});
|
||||
assert.throws(() => workflow.createExpertEditDraft(
|
||||
expertSelection,
|
||||
expertPreview({ truncated: true })), /complete/);
|
||||
assert.equal(workflow.normalizeExpertDraft({
|
||||
expertCode: "0001",
|
||||
expertName: "홍길동",
|
||||
recommendations: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 1, stockCode: "005930", stockName: "다른이름", buyAmount: 1 }
|
||||
]
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("new expert create and delete requests carry exact draft and identity only", () => {
|
||||
const draft = workflow.createBlankExpertDraft("0002");
|
||||
assert.throws(() => workflow.createExpertCreateRequest("expert-create-1", draft), /complete/);
|
||||
draft.expertName = "김전문";
|
||||
draft.recommendations.push({
|
||||
playIndex: 0,
|
||||
stockCode: "005930",
|
||||
stockName: "삼성전자",
|
||||
buyAmount: 71000
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertCreateRequest("expert-create-1", draft), {
|
||||
requestId: "expert-create-1",
|
||||
draft
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertDeleteRequest(
|
||||
"expert-delete-1",
|
||||
{ expertCode: "0002", expertName: "김전문" }), {
|
||||
requestId: "expert-delete-1",
|
||||
identity: { expertCode: "0002", expertName: "김전문" }
|
||||
});
|
||||
assert.throws(() => workflow.createExpertCreateRequest("safe", {
|
||||
...draft,
|
||||
recommendations: [{ ...draft.recommendations[0], buyAmount: 1.5 }]
|
||||
}), /complete/);
|
||||
});
|
||||
|
||||
test("read responses reject stale correlation, ambiguous identities and quarantined mutation flags", () => {
|
||||
const themeRequest = workflow.createThemeCatalogListRequest("theme-list-1", "", "preMarket", 10);
|
||||
const themeResult = {
|
||||
requestId: "theme-list-1",
|
||||
query: "",
|
||||
nxtSession: "preMarket",
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: [krxSelection]
|
||||
};
|
||||
assert.ok(workflow.normalizeThemeListResult(themeResult, themeRequest));
|
||||
assert.equal(workflow.normalizeThemeListResult({ ...themeResult, requestId: "stale" }, themeRequest), null);
|
||||
assert.equal(workflow.normalizeThemeListResult({
|
||||
...themeResult,
|
||||
canMutate: true,
|
||||
writeQuarantined: true
|
||||
}, themeRequest), null);
|
||||
|
||||
const expertRequest = workflow.createExpertCatalogListRequest("expert-list-1", "", 10);
|
||||
const expertResult = {
|
||||
requestId: "expert-list-1",
|
||||
query: "",
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: [
|
||||
expertSelection,
|
||||
{ expertCode: "0002", expertName: "김전문", source: "oracle" }
|
||||
]
|
||||
};
|
||||
assert.ok(workflow.normalizeExpertListResult(expertResult, expertRequest));
|
||||
assert.equal(workflow.normalizeExpertListResult({
|
||||
...expertResult,
|
||||
results: [expertSelection, { expertCode: "0002", expertName: "홍길동", source: "oracle" }]
|
||||
}, expertRequest), null);
|
||||
});
|
||||
|
||||
test("schema and next-code results are exact and correlated", () => {
|
||||
const schemaRequest = workflow.createSchemaPreflightRequest("schema-1");
|
||||
assert.deepEqual(workflow.normalizeSchemaResult({
|
||||
requestId: "schema-1",
|
||||
validatedAt: "2026-07-11T23:00:00+09:00",
|
||||
validatedSources: ["oracle", "mariaDb"],
|
||||
themeCanMutate: true,
|
||||
expertCanMutate: true,
|
||||
writeQuarantined: false
|
||||
}, schemaRequest).validatedSources, ["oracle", "mariaDb"]);
|
||||
const themeNextRequest = workflow.createThemeNextCodeRequest("next-1", "krx");
|
||||
assert.ok(workflow.normalizeThemeNextCodeResult({
|
||||
requestId: "next-1",
|
||||
market: "krx",
|
||||
themeCode: "00000003",
|
||||
canMutate: true,
|
||||
writeQuarantined: false
|
||||
}, themeNextRequest));
|
||||
assert.equal(workflow.normalizeThemeNextCodeResult({
|
||||
requestId: "next-1",
|
||||
market: "nxt",
|
||||
themeCode: "00000003",
|
||||
canMutate: true,
|
||||
writeQuarantined: false
|
||||
}, themeNextRequest), null);
|
||||
assert.ok(workflow.normalizeExpertNextCodeResult({
|
||||
requestId: "next-2",
|
||||
expertCode: "0007",
|
||||
canMutate: false,
|
||||
writeQuarantined: true
|
||||
}, workflow.createExpertNextCodeRequest("next-2")));
|
||||
});
|
||||
|
||||
test("mutation coordinator permits one correlated write and latches process quarantine on unknown outcome", () => {
|
||||
const request = workflow.createThemeCreateRequest("create-1", {
|
||||
market: "krx",
|
||||
themeCode: "00000003",
|
||||
themeTitle: "로봇",
|
||||
items: []
|
||||
});
|
||||
const coordinator = workflow.createMutationCoordinator();
|
||||
assert.deepEqual(coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), {
|
||||
requestId: "create-1",
|
||||
entity: "theme",
|
||||
operation: "create",
|
||||
identityCode: "00000003"
|
||||
});
|
||||
assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /already active/);
|
||||
assert.equal(coordinator.acceptResult({
|
||||
requestId: "stale",
|
||||
entity: "theme",
|
||||
operation: "create",
|
||||
identityCode: "00000003",
|
||||
operationId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
committedAt: "2026-07-11T23:00:00+09:00",
|
||||
writeQuarantined: false
|
||||
}), null);
|
||||
assert.ok(coordinator.getState().active);
|
||||
const error = coordinator.acceptError({
|
||||
requestId: "create-1",
|
||||
entity: "theme",
|
||||
operation: "create",
|
||||
identityCode: "00000003",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "Restart and reconcile before another write.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true,
|
||||
writeQuarantined: true
|
||||
});
|
||||
assert.ok(error);
|
||||
assert.deepEqual(coordinator.getState(), { active: null, quarantined: true });
|
||||
assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /quarantined/);
|
||||
});
|
||||
|
||||
test("known rollback clears the active coordinator without inventing an automatic retry", () => {
|
||||
const request = workflow.createExpertDeleteRequest(
|
||||
"delete-1",
|
||||
{ expertCode: "0001", expertName: "홍길동" });
|
||||
const coordinator = workflow.createMutationCoordinator();
|
||||
coordinator.begin(workflow.bridgeContract.requests.expertDelete, request);
|
||||
assert.ok(coordinator.acceptError({
|
||||
requestId: "delete-1",
|
||||
entity: "expert",
|
||||
operation: "delete",
|
||||
identityCode: "0001",
|
||||
code: "CONFLICT",
|
||||
message: "Refresh before a new edit.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false,
|
||||
writeQuarantined: false
|
||||
}));
|
||||
assert.deepEqual(coordinator.getState(), { active: null, quarantined: false });
|
||||
coordinator.begin(workflow.bridgeContract.requests.expertDelete, request);
|
||||
coordinator.loseCorrelation();
|
||||
assert.deepEqual(coordinator.getState(), { active: null, quarantined: true });
|
||||
});
|
||||
156
tests/Web/operator-command-matrix.test.cjs
Normal file
156
tests/Web/operator-command-matrix.test.cjs
Normal file
@@ -0,0 +1,156 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const fixed = require("../../Web/legacy-fixed-workflow.js");
|
||||
const industry = require("../../Web/industry-workflow.js");
|
||||
const stock = require("../../Web/operator-workflow.js");
|
||||
const comparison = require("../../Web/comparison-workflow.js");
|
||||
|
||||
const now = new Date("2026-07-11T12:00:00+09:00");
|
||||
const kospiStock = Object.freeze({
|
||||
market: "kospi",
|
||||
stockName: "삼성전자",
|
||||
displayName: "삼성전자",
|
||||
stockCode: "005930",
|
||||
isNxt: false
|
||||
});
|
||||
const primaryIndustry = Object.freeze({
|
||||
market: "kospi",
|
||||
code: "001",
|
||||
name: "전기전자"
|
||||
});
|
||||
const secondaryIndustry = Object.freeze({
|
||||
market: "kospi",
|
||||
code: "002",
|
||||
name: "금융업"
|
||||
});
|
||||
const kosdaqPrimaryIndustry = Object.freeze({
|
||||
market: "kosdaq",
|
||||
code: "101",
|
||||
name: "IT"
|
||||
});
|
||||
const kosdaqSecondaryIndustry = Object.freeze({
|
||||
market: "kosdaq",
|
||||
code: "102",
|
||||
name: "제약"
|
||||
});
|
||||
const comparisonPair = Object.freeze([
|
||||
Object.freeze({ kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930" }),
|
||||
Object.freeze({ kind: "krx-stock", market: "kospi", stockName: "SK하이닉스", stockCode: "000660" })
|
||||
]);
|
||||
|
||||
test("the original operator command matrix has exactly 412 visible slots", () => {
|
||||
assert.equal(fixed.actions.length, 328);
|
||||
assert.equal(industry.cuts.length * industry.markets.length, 44);
|
||||
assert.equal(stock.stockCuts.length, 31);
|
||||
assert.equal(comparison.actions.length, 9);
|
||||
assert.equal(
|
||||
fixed.actions.length + industry.cuts.length * industry.markets.length +
|
||||
stock.stockCuts.length + comparison.actions.length,
|
||||
412);
|
||||
|
||||
assert.equal(new Set(fixed.actions.map(value => value.id)).size, 328);
|
||||
assert.equal(new Set(industry.cuts.map(value => value.id)).size, 22);
|
||||
assert.equal(new Set(stock.stockCuts.map(value => value.id)).size, 31);
|
||||
assert.equal(new Set(comparison.actions.map(value => value.id)).size, 9);
|
||||
});
|
||||
|
||||
test("all 322 non-manual fixed slots create and strictly round-trip", () => {
|
||||
const available = fixed.actions.filter(value => value.available);
|
||||
assert.equal(available.length, 322);
|
||||
|
||||
for (const [index, action] of available.entries()) {
|
||||
const created = fixed.createFixedPlaylistEntry(action.id, {
|
||||
id: `fixed-matrix-${index + 1}`,
|
||||
fadeDuration: 6,
|
||||
now,
|
||||
ma5: true,
|
||||
ma20: true
|
||||
});
|
||||
const restored = fixed.restoreFixedPlaylistEntry(created, { now });
|
||||
assert.ok(restored, `${action.id} ${action.label}`);
|
||||
assert.equal(restored.builderKey, action.builderKey);
|
||||
assert.equal(restored.code, action.code);
|
||||
}
|
||||
});
|
||||
|
||||
test("the six fixed manual placeholders resolve only through their dedicated editors", () => {
|
||||
const manual = fixed.actions.filter(value => !value.available);
|
||||
assert.deepEqual(
|
||||
manual.map(value => [value.id, value.label, value.builderKey, value.code]),
|
||||
[
|
||||
["fixed-245", "개인 순매도 상위(수동)", "s5025", "5025"],
|
||||
["fixed-246", "외국인 순매도 상위(수동)", "s5025", "5025"],
|
||||
["fixed-247", "기관 순매도 상위(수동)", "s5025", "5025"],
|
||||
["fixed-262", "VI 발동(수동)", "s5074", "5074"],
|
||||
["fixed-293", "VI 발동(수동)", "s5074", "5074"],
|
||||
["fixed-320", "VI 발동(수동)", "s5074", "5074"]
|
||||
]);
|
||||
for (const action of manual) {
|
||||
assert.throws(() => fixed.createFixedPlaylistEntry(action.id, {
|
||||
id: `blocked-${action.id}`,
|
||||
fadeDuration: 6,
|
||||
now
|
||||
}), /manual|수동|입력/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("all 44 industry slots create and strictly round-trip with explicit selections", () => {
|
||||
let ordinal = 0;
|
||||
for (const market of industry.markets) {
|
||||
const selected = market === "kospi" ? primaryIndustry : kosdaqPrimaryIndustry;
|
||||
const pair = market === "kospi"
|
||||
? [primaryIndustry, secondaryIndustry]
|
||||
: [kosdaqPrimaryIndustry, kosdaqSecondaryIndustry];
|
||||
for (const cut of industry.cuts) {
|
||||
ordinal += 1;
|
||||
const created = industry.createIndustryPlaylistEntry(market, cut.id, {
|
||||
id: `industry-matrix-${ordinal}`,
|
||||
fadeDuration: 6,
|
||||
selected,
|
||||
pair,
|
||||
now,
|
||||
ma5: true,
|
||||
ma20: true
|
||||
});
|
||||
const restored = industry.restoreIndustryPlaylistEntry(created, { now });
|
||||
assert.ok(restored, `${market} ${cut.id}`);
|
||||
assert.equal(restored.builderKey, created.builderKey);
|
||||
assert.equal(restored.code, created.code);
|
||||
}
|
||||
}
|
||||
assert.equal(ordinal, 44);
|
||||
});
|
||||
|
||||
test("all 31 stock and nine comparison slots create and strictly round-trip", () => {
|
||||
for (const [index, cut] of stock.stockCuts.entries()) {
|
||||
const created = stock.createStockPlaylistEntry(kospiStock, cut.id, {
|
||||
id: `stock-matrix-${index + 1}`,
|
||||
fadeDuration: 6,
|
||||
ma5: true,
|
||||
ma20: true
|
||||
});
|
||||
assert.ok(stock.restoreStockPlaylistEntry(created), cut.id);
|
||||
}
|
||||
|
||||
for (const [index, action] of comparison.actions.entries()) {
|
||||
const created = comparison.createComparisonPlaylistEntry(
|
||||
action.id,
|
||||
comparisonPair,
|
||||
{ id: `comparison-matrix-${index + 1}`, fadeDuration: 6 });
|
||||
assert.ok(comparison.restoreComparisonPlaylistEntry(created), action.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("the four GraphE buttons remain outside the 412 slots and map to exact INPUT tables", () => {
|
||||
assert.deepEqual(
|
||||
stock.manualStockActions.map(value => [value.id, value.builderKey, value.prerequisiteTable]),
|
||||
[
|
||||
["manual-revenue-mix", "s5076", "INPUT_PIE"],
|
||||
["manual-growth-metrics", "s5079", "INPUT_GROW"],
|
||||
["manual-sales", "s5080", "INPUT_SELL"],
|
||||
["manual-operating-profit", "s5081", "INPUT_PROFIT"]
|
||||
]);
|
||||
});
|
||||
371
tests/Web/operator-ui-app-integration.test.cjs
Normal file
371
tests/Web/operator-ui-app-integration.test.cjs
Normal file
@@ -0,0 +1,371 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const manualFinancialUiModule = require("../../Web/manual-financial-ui.js");
|
||||
const manualFinancialWorkflow = require("../../Web/manual-financial-workflow.js");
|
||||
const manualListsUiModule = require("../../Web/manual-lists-ui.js");
|
||||
const manualListsWorkflow = require("../../Web/manual-lists-workflow.js");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const read = relativePath => fs.readFileSync(path.join(repositoryRoot, relativePath), "utf8");
|
||||
const app = read("Web/app.js");
|
||||
const index = read("Web/index.html");
|
||||
const manualFinancialUi = read("Web/manual-financial-ui.js");
|
||||
const mainWindow = read("MainWindow.xaml.cs");
|
||||
const manualFinancialBridge = read("MainWindow.ManualFinancial.cs");
|
||||
const manualListsBridge = read("MainWindow.ManualLists.cs");
|
||||
const namedPlaylistBridge = read("MainWindow.NamedPlaylists.cs");
|
||||
const operatorCatalogBridge = read("MainWindow.OperatorCatalogs.cs");
|
||||
const playoutBridge = read("MainWindow.Playout.cs");
|
||||
const operatorMutationGate = read("src/MBN_STOCK_WEBVIEW.Core/Playout/OperatorMutationGate.cs");
|
||||
|
||||
function functionBody(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
assert.ok(start >= 0, `${name} must exist`);
|
||||
const end = nextName ? source.indexOf(`function ${nextName}(`, start + 1) : source.length;
|
||||
assert.ok(end > start, `${name} must precede ${nextName || "the end of the file"}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function sliceBetween(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
||||
assert.ok(start >= 0, `${startMarker} must exist`);
|
||||
assert.ok(end > start, `${endMarker} must follow ${startMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function assertOrdered(source, values) {
|
||||
let previous = -1;
|
||||
for (const value of values) {
|
||||
const offset = source.indexOf(value);
|
||||
assert.ok(offset >= 0, `${value} must be loaded`);
|
||||
assert.ok(offset > previous, `${value} must preserve dependency order`);
|
||||
assert.equal(source.indexOf(value, offset + value.length), -1, `${value} must be loaded once`);
|
||||
previous = offset;
|
||||
}
|
||||
}
|
||||
|
||||
test("operator styles and scripts load once in dependency order before app.js", () => {
|
||||
assertOrdered(index, [
|
||||
'href="manual-lists-ui.css"',
|
||||
'href="manual-financial-ui.css"',
|
||||
'href="operator-catalog-ui.css"'
|
||||
]);
|
||||
assertOrdered(index, [
|
||||
'src="manual-lists-workflow.js"',
|
||||
'src="manual-financial-workflow.js"',
|
||||
'src="operator-catalog-workflow.js"',
|
||||
'src="manual-lists-ui.js"',
|
||||
'src="manual-financial-ui.js"',
|
||||
'src="operator-catalog-ui.js"',
|
||||
'src="app.js"'
|
||||
]);
|
||||
});
|
||||
|
||||
test("legacy GraphE, FSell, VIList, ThemeA and EList controls open their real controllers", () => {
|
||||
assert.match(index, /id="themeCatalogManageButton"/u);
|
||||
assert.match(index, /id="expertCatalogManageButton"/u);
|
||||
|
||||
const fixedManual = functionBody(app, "openFixedManualAction", "addFixedAction");
|
||||
assert.match(fixedManual,
|
||||
/action\.builderKey === "s5025"[\s\S]*manualListsUi\?\.openNetSell\(action\.selection\?\.groupCode\)/u);
|
||||
assert.match(fixedManual,
|
||||
/action\.builderKey === "s5074"[\s\S]*action\.selection\?\.groupCode === "PAGED_VI"[\s\S]*manualListsUi\?\.openVi\(\)/u);
|
||||
|
||||
const stockRender = functionBody(app, "renderStockWorkflow", "requestStockSearch");
|
||||
assert.match(stockRender, /for \(const action of operatorWorkflow\.manualStockActions\)/u);
|
||||
assert.match(stockRender, /button\.disabled = !search\.selected \|\| operatorUiLocked\(\)/u);
|
||||
assert.match(stockRender, /manualFinancialUi\?\.open\(action\)/u);
|
||||
|
||||
assert.match(app,
|
||||
/themeCatalogManageButton\.addEventListener\("click", \(\) => \{[\s\S]*operatorCatalogUi\?\.openTheme\(context \|\| undefined\)/u);
|
||||
assert.match(app,
|
||||
/expertCatalogManageButton\.addEventListener\("click", \(\) => \{[\s\S]*operatorCatalogUi\?\.openExpert\(context \|\| undefined\)/u);
|
||||
assert.match(app, /themeCatalogManageButton\.disabled = operatorUiLocked\(\)/u);
|
||||
assert.match(app, /expertCatalogManageButton\.disabled = operatorUiLocked\(\)/u);
|
||||
});
|
||||
|
||||
test("first-routing consumes only messages owned by an operator controller", () => {
|
||||
const nativeHandler = functionBody(app, "handleNativeMessage", "bindEvents");
|
||||
const financialOffset = nativeHandler.indexOf("manualFinancialUi?.handleMessage");
|
||||
const listsOffset = nativeHandler.indexOf("manualListsUi?.handleMessage");
|
||||
const catalogOffset = nativeHandler.indexOf("operatorCatalogUi?.handleMessage");
|
||||
const switchOffset = nativeHandler.indexOf("switch (message.type)");
|
||||
assert.ok(financialOffset >= 0 && financialOffset < listsOffset);
|
||||
assert.ok(listsOffset < catalogOffset && catalogOffset < switchOffset);
|
||||
|
||||
let id = 0;
|
||||
const financial = manualFinancialUiModule.createController({
|
||||
postNative() {},
|
||||
appendPlaylistEntry() { return true; },
|
||||
isLocked() { return false; },
|
||||
getSelectedStock() { return null; },
|
||||
showToast() {},
|
||||
addLog() {},
|
||||
createId() { id += 1; return `test-${id}`; }
|
||||
});
|
||||
const unownedFinancialTypes = [
|
||||
manualFinancialWorkflow.bridgeContract.listResultType,
|
||||
manualFinancialWorkflow.bridgeContract.listErrorType,
|
||||
manualFinancialWorkflow.bridgeContract.loadResultType,
|
||||
manualFinancialWorkflow.bridgeContract.loadErrorType,
|
||||
"stock-search-results",
|
||||
"stock-search-error",
|
||||
manualFinancialWorkflow.bridgeContract.selectionResultType,
|
||||
manualFinancialWorkflow.bridgeContract.selectionErrorType,
|
||||
manualFinancialWorkflow.bridgeContract.mutationResultType,
|
||||
manualFinancialWorkflow.bridgeContract.mutationErrorType,
|
||||
manualFinancialWorkflow.bridgeContract.requestErrorType
|
||||
];
|
||||
for (const type of unownedFinancialTypes) {
|
||||
assert.equal(financial.handleMessage(type, { requestId: "not-owned" }), false, type);
|
||||
}
|
||||
|
||||
const lists = manualListsUiModule.createController({
|
||||
postNative() {},
|
||||
appendPlaylistEntry() { return true; },
|
||||
isLocked() { return false; },
|
||||
showToast() {},
|
||||
addLog() {},
|
||||
createId() { id += 1; return `test-${id}`; }
|
||||
});
|
||||
for (const type of [
|
||||
manualListsWorkflow.bridgeContract.statusResultType,
|
||||
manualListsWorkflow.bridgeContract.netSellDataType,
|
||||
manualListsWorkflow.bridgeContract.netSellSaveResultType,
|
||||
manualListsWorkflow.bridgeContract.viDataType,
|
||||
manualListsWorkflow.bridgeContract.viSaveResultType,
|
||||
manualListsWorkflow.bridgeContract.errorType,
|
||||
"stock-search-results",
|
||||
"stock-search-error"
|
||||
]) {
|
||||
assert.equal(lists.handleMessage(type, { requestId: "not-owned" }), false, type);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator locks and trusted append checks cover every migrated manual surface", () => {
|
||||
const lock = functionBody(app, "operatorUiLocked", "selectedStockForManualFinancial");
|
||||
assert.match(lock, /isPlaylistSnapshotLocked\(\)/u);
|
||||
assert.match(lock, /namedPlaylistBusy\(\)/u);
|
||||
assert.match(lock, /state\.manualFinancialRestore\.entries\.size > 0/u);
|
||||
|
||||
const renderPlayout = functionBody(app, "renderPlayout", "clearPlayoutError");
|
||||
for (const controller of ["manualListsUi", "manualFinancialUi", "operatorCatalogUi"]) {
|
||||
assert.match(renderPlayout, new RegExp(`${controller}\\?\\.setLocked\\(operatorLocked\\)`));
|
||||
}
|
||||
|
||||
const append = functionBody(app, "appendTrustedOperatorEntry", "isPlaylistSnapshotLocked");
|
||||
assert.match(append, /if \(operatorUiLocked\(\)\) throw/u);
|
||||
assert.match(append, /manualListsWorkflow\.restorePlaylistEntry\(entry\)/u);
|
||||
assert.match(append, /manualFinancialWorkflow\.isPlaylistEntryPlayable\(entry\)/u);
|
||||
assert.match(append,
|
||||
/catalog\.find\(value => value\.builderKey === entry\.builderKey &&[\s\S]*sceneAliases\(value\)\.includes/u);
|
||||
assert.match(append, /state\.playlist\.push\(entry\)/u);
|
||||
assert.match(manualFinancialUi,
|
||||
/if \(!workflow\.isPlaylistEntryPlayable\(entry\)\) throw new TypeError\("Untrusted entry\."\)/u);
|
||||
});
|
||||
|
||||
test("stored GraphE entries remain unplayable until exact non-truncated DB revalidation", () => {
|
||||
const normalizeStored = functionBody(app, "normalizeStoredPlaylist", "manualFinancialRestoreRecordForRequest");
|
||||
assert.match(normalizeStored, /manualFinancialWorkflow\.restorePlaylistEntry\(item\)/u);
|
||||
assert.match(normalizeStored, /options\.manualFinancialRestores\.push\(pendingManualFinancialEntry\)/u);
|
||||
assert.match(normalizeStored,
|
||||
/operatorInputBuilderKeys\.has\(String\(item\.builderKey \|\| ""\)\) \|\| item\.operator !== undefined[\s\S]*return \[\]/u);
|
||||
|
||||
const stockRestore = functionBody(
|
||||
app,
|
||||
"handleManualFinancialRestoreStockResults",
|
||||
"handleManualFinancialRestoreStockError");
|
||||
assert.match(stockRestore, /response\.query\s*[!=]==?\s*record\.request\.query|record\.request\.query\s*[!=]==?\s*response\.query/u);
|
||||
assert.match(stockRestore, /response\.truncated/u);
|
||||
assert.match(stockRestore, /response\.totalRowCount[\s\S]*record\.request\.maximumResults/u);
|
||||
assert.match(stockRestore,
|
||||
/const expectedMarket = [\s\S]*record\.pending\.verifiedStock\.market/u);
|
||||
assert.match(stockRestore,
|
||||
/const expectedCode = [\s\S]*record\.pending\.verifiedStock\.code/u);
|
||||
assert.match(stockRestore,
|
||||
/verifyStockForRecord\([\s\S]*expectedMarket,[\s\S]*expectedCode/u);
|
||||
assert.match(stockRestore,
|
||||
/record\.restoreKind === "named-financial"[\s\S]*namedMatch\.length === 1/u);
|
||||
|
||||
const prepareBlock = functionBody(app, "manualFinancialPrepareBlocked", "loadPlaylist");
|
||||
assert.match(prepareBlock, /state\.manualFinancialRestore\.entries\.size > 0/u);
|
||||
assert.match(prepareBlock, /manualFinancialWorkflow\.isPlaylistEntryPlayable\(item\)/u);
|
||||
assert.match(app,
|
||||
/prepareButton\.disabled = [^;]*manualFinancialBlocked/u);
|
||||
const requestCommand = functionBody(app, "requestPlayoutCommand", "requestPlayoutStatus");
|
||||
assert.match(requestCommand,
|
||||
/if \(command === "prepare"\)[\s\S]*if \(manualFinancialPrepareBlocked\(\)\)[\s\S]*return false/u);
|
||||
});
|
||||
|
||||
test("native root routes all operator families and invalidates every browser correlation", () => {
|
||||
assert.match(mainWindow, /TryHandleOperatorCatalogRequest\(request\.Type, request\.Payload\)/u);
|
||||
assert.match(mainWindow, /TryHandleManualFinancialRequest\(request\.Type, request\.Payload\)/u);
|
||||
assert.match(mainWindow, /case "request-manual-operator-data-status"/u);
|
||||
assert.match(mainWindow, /case "save-manual-net-sell-data"/u);
|
||||
assert.match(mainWindow, /case "save-vi-manual-list"/u);
|
||||
|
||||
assert.match(mainWindow, /InvalidateOperatorCatalogBrowserRequests\(\)/u);
|
||||
assert.match(mainWindow, /InvalidateManualOperatorBrowserRequests\(\)/u);
|
||||
assert.match(mainWindow, /InvalidateManualFinancialBrowserRequests\(\)/u);
|
||||
|
||||
assert.match(manualFinancialBridge, /_playoutCommandGate\.WaitAsync\(0,/u);
|
||||
assert.match(manualFinancialBridge, /_databaseActivityGate\.WaitAsync\(0,/u);
|
||||
assert.match(operatorCatalogBridge, /_playoutCommandGate\.WaitAsync\(0,/u);
|
||||
assert.match(operatorCatalogBridge, /_databaseActivityGate\.WaitAsync\(0,/u);
|
||||
assert.match(manualListsBridge, /TryEnterManualMutationGatesAsync/u);
|
||||
assert.match(manualListsBridge, /_playoutCommandGate\.WaitAsync\(0\)/u);
|
||||
});
|
||||
|
||||
test("native operator writes share one fail-closed idle gate at their dispatch boundary", () => {
|
||||
const gateStart = playoutBridge.indexOf("private bool CanStartOperatorMutation(");
|
||||
const gateEnd = playoutBridge.indexOf("private void ShutdownPlayoutRuntime(", gateStart);
|
||||
assert.ok(gateStart >= 0 && gateEnd > gateStart);
|
||||
const gate = playoutBridge.slice(gateStart, gateEnd);
|
||||
assert.match(gate, /familyWriteQuarantined/u);
|
||||
assert.match(gate, /_lifetimeCancellation\.IsCancellationRequested/u);
|
||||
assert.match(gate, /_playoutCommandInFlight/u);
|
||||
assert.match(gate, /_playoutShutdownStarted/u);
|
||||
assert.match(gate, /IsBrowserCorrelationQuarantined\(\)/u);
|
||||
assert.match(gate, /IsEngineAvailable: engine is not null/u);
|
||||
assert.match(gate, /IsWorkflowAvailable: workflow is not null/u);
|
||||
assert.match(gate, /IsCommandAvailable: status\?\.IsCommandAvailable \?\? false/u);
|
||||
assert.match(gate, /IsPlayCompletionPending: status\?\.IsPlayCompletionPending \?\? true/u);
|
||||
assert.match(gate, /PreparedSceneName: status\?\.PreparedSceneName/u);
|
||||
assert.match(gate, /OnAirSceneName: status\?\.OnAirSceneName/u);
|
||||
assert.match(gate, /PreparedCutCode: workflowState\?\.PreparedCutCode/u);
|
||||
assert.match(gate, /OnAirCutCode: workflowState\?\.OnAirCutCode/u);
|
||||
assert.match(gate, /IsRefreshActive: refreshStatus\.IsActive/u);
|
||||
assert.match(gate, /IsRefreshTaskRunning: _legacyRefreshTask is \{ IsCompleted: false \}/u);
|
||||
assert.match(gate, /OperatorMutationGate\.CanStart\(snapshot\)/u);
|
||||
assert.match(operatorMutationGate, /snapshot is not null/u);
|
||||
assert.match(operatorMutationGate, /snapshot\.IsEngineAvailable/u);
|
||||
assert.match(operatorMutationGate, /snapshot\.IsWorkflowAvailable/u);
|
||||
|
||||
assert.match(manualFinancialBridge,
|
||||
/CanStartOperatorMutation\(IsManualFinancialWriteQuarantined\(\)\)/u);
|
||||
assert.ok((manualFinancialBridge.match(/CanStartManualFinancialWrite\(\)/gu) || []).length >= 3);
|
||||
assert.match(operatorCatalogBridge,
|
||||
/CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/u);
|
||||
assert.ok((operatorCatalogBridge.match(/CanStartOperatorCatalogWrite\(\)/gu) || []).length >= 2);
|
||||
assert.match(manualListsBridge,
|
||||
/CanStartOperatorMutation\(IsManualOperatorWriteQuarantined\(\)\)/u);
|
||||
assert.ok((manualListsBridge.match(
|
||||
/CanStartOperatorMutation\(IsManualOperatorWriteQuarantined\(\)\)/gu) || []).length >= 3);
|
||||
|
||||
for (const write of [
|
||||
sliceBetween(
|
||||
manualListsBridge,
|
||||
"private async Task HandleManualNetSellWriteAsync(",
|
||||
"private async Task HandleViManualListReadAsync("),
|
||||
sliceBetween(
|
||||
manualListsBridge,
|
||||
"private async Task HandleViManualListWriteAsync(",
|
||||
"private async Task<bool> TryEnterManualMutationGatesAsync(")
|
||||
]) {
|
||||
assert.match(write,
|
||||
/TryEnterManualMutationGatesAsync\([\s\S]*Interlocked\.Exchange\(ref _manualOperatorWriteInFlight, 1\)[\s\S]*await (?:store\.)?Write/su);
|
||||
}
|
||||
});
|
||||
|
||||
test("an unknown write outcome globally quarantines GraphE, ThemeA/EList, FSell/VI and named playlists", () => {
|
||||
assert.match(playoutBridge,
|
||||
/private int _operatorMutationQuarantined;[\s\S]*private string\? _operatorMutationQuarantineMessage;/u);
|
||||
assert.match(playoutBridge,
|
||||
/private bool IsOperatorMutationQuarantined\(\) =>[\s\S]*_operatorMutationQuarantined/u);
|
||||
assert.match(playoutBridge,
|
||||
/private void LatchOperatorMutationQuarantine\(string message\)[\s\S]*Interlocked\.CompareExchange\(ref _operatorMutationQuarantined, 1, 0\)/u);
|
||||
|
||||
for (const [surface, source, familyCheck, familyLatch] of [
|
||||
["GraphE", manualFinancialBridge,
|
||||
"IsManualFinancialWriteQuarantined", "LatchManualFinancialWriteQuarantine"],
|
||||
["ThemeA/EList", operatorCatalogBridge,
|
||||
"IsOperatorCatalogWriteQuarantined", "LatchOperatorCatalogWriteQuarantine"],
|
||||
["FSell/VI", manualListsBridge,
|
||||
"IsManualOperatorWriteQuarantined", "QuarantineManualOperatorWrites"],
|
||||
["named playlist", namedPlaylistBridge,
|
||||
"IsNamedPlaylistWriteQuarantined", "LatchNamedPlaylistWriteQuarantine"]
|
||||
]) {
|
||||
assert.match(source, new RegExp(
|
||||
`private bool ${familyCheck}\\(\\) =>[\\s\\S]{0,180}IsOperatorMutationQuarantined\\(\\)`, "u"),
|
||||
`${surface} must observe the process-wide quarantine`);
|
||||
assert.match(source, new RegExp(
|
||||
`private void ${familyLatch}\\(string message\\)[\\s\\S]{0,260}LatchOperatorMutationQuarantine\\(message\\)`, "u"),
|
||||
`${surface} must latch the process-wide quarantine`);
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed operator payloads retain a family-specific fail-closed fallback", () => {
|
||||
assert.match(manualFinancialBridge,
|
||||
/TryHandleManualFinancialRequest\(string\? requestType, JsonElement payload\)[\s\S]*case "create-manual-financial-record"[\s\S]*return true/u);
|
||||
assert.match(manualFinancialBridge,
|
||||
/private static string SafeManualFinancialRequestId\(JsonElement payload\) =>[\s\S]*payload\.ValueKind == JsonValueKind\.Object/u);
|
||||
assert.match(operatorCatalogBridge,
|
||||
/TryHandleOperatorCatalogRequest\(string\? requestType, JsonElement payload\)[\s\S]*case "create-theme-catalog"[\s\S]*case "create-expert-catalog"[\s\S]*return true/u);
|
||||
assert.match(operatorCatalogBridge,
|
||||
/private static string SafeOperatorCatalogRequestId\(JsonElement payload\)[\s\S]*payload\.ValueKind == JsonValueKind\.Object/u);
|
||||
|
||||
for (const [parser, nextParser, readToken] of [
|
||||
["TryParseAudienceRequest", "SafeManualOperatorRequestId", 'GetString(payload, "audience")'],
|
||||
["TryParseManualNetSellWrite", "TryParseViWrite", 'GetString(payload, "audience")'],
|
||||
["TryParseViWrite", "TryGetViExpectedVersion", "TryGetViExpectedVersion(payload"]
|
||||
]) {
|
||||
const body = sliceBetween(
|
||||
manualListsBridge,
|
||||
`private static bool ${parser}(`,
|
||||
`private static ${nextParser === "SafeManualOperatorRequestId" ? "string" : "bool"} ${nextParser}(`);
|
||||
assert.match(body, /requestId = SafeManualOperatorRequestId\(payload\)/u, parser);
|
||||
const objectGuard = body.indexOf("payload.ValueKind");
|
||||
const firstPropertyRead = body.indexOf(readToken);
|
||||
assert.ok(objectGuard >= 0 && firstPropertyRead > objectGuard,
|
||||
`${parser} must check object kind before reading a property`);
|
||||
}
|
||||
assert.ok((manualListsBridge.match(/"INVALID_REQUEST"/gu) || []).length >= 5);
|
||||
|
||||
const viVersion = sliceBetween(
|
||||
manualListsBridge,
|
||||
"private static bool TryGetViExpectedVersion(",
|
||||
"private static bool TryGetManualValue(");
|
||||
assert.ok(viVersion.indexOf("payload.ValueKind") <
|
||||
viVersion.indexOf('payload.TryGetProperty("expectedVersion"'));
|
||||
|
||||
assert.match(mainWindow,
|
||||
/case "request-named-playlist-list":[\s\S]*case "delete-named-playlist":[\s\S]*PostNamedPlaylistError\([\s\S]*"INVALID_REQUEST"/u);
|
||||
});
|
||||
|
||||
test("window shutdown detaches and disposes both trusted manual stores after their gate is idle", () => {
|
||||
const closed = sliceBetween(mainWindow, "private void OnClosed(", "private sealed record WebRequest(");
|
||||
assert.match(closed, /ShutdownManualOperatorDataRuntime\(\)/u);
|
||||
|
||||
const shutdown = sliceBetween(
|
||||
manualListsBridge,
|
||||
"private void ShutdownManualOperatorDataRuntime(",
|
||||
"private async Task DisposeManualOperatorStoresWhenIdleAsync(");
|
||||
assert.match(shutdown, /InvalidateManualOperatorBrowserRequests\(\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _manualNetSellStore, null\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _viManualListStore, null\)/u);
|
||||
assert.match(shutdown, /DisposeManualOperatorStoresWhenIdleAsync\(netSellStore, viStore\)/u);
|
||||
|
||||
const dispose = sliceBetween(
|
||||
manualListsBridge,
|
||||
"private async Task DisposeManualOperatorStoresWhenIdleAsync(",
|
||||
"private void InvalidateManualOperatorBrowserRequests(");
|
||||
assert.match(dispose, /await _manualOperatorDataGate\.WaitAsync\(\)/u);
|
||||
assert.match(dispose, /viStore\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /netSellStore\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /_manualOperatorDataGate\.Release\(\)/u);
|
||||
});
|
||||
|
||||
test("GraphE async UI boundaries retain the dispatcher context before posting WebView replies", () => {
|
||||
assert.doesNotMatch(manualFinancialBridge,
|
||||
/operationBody\(requestToken\)\.ConfigureAwait\(false\)/u);
|
||||
assert.doesNotMatch(manualFinancialBridge,
|
||||
/preflight\(requestToken\)\.ConfigureAwait\(false\)/u);
|
||||
assert.doesNotMatch(manualFinancialBridge,
|
||||
/operationBody\(service,\s*requestToken\)\.ConfigureAwait\(false\)/u);
|
||||
});
|
||||
326
tests/Web/operator-workflow.test.cjs
Normal file
326
tests/Web/operator-workflow.test.cjs
Normal file
@@ -0,0 +1,326 @@
|
||||
"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));
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
@@ -48,6 +48,32 @@ test("TAKE IN and TAKE OUT preserve the native state prerequisites", () => {
|
||||
null);
|
||||
});
|
||||
|
||||
test("legacy F8 chooses exactly one safe PREPARE or TAKE IN action", () => {
|
||||
assert.equal(
|
||||
safety.legacyTakeInShortcutAction(ready({ takeInAllowed: true })),
|
||||
"prepare-then-take-in");
|
||||
assert.equal(
|
||||
safety.legacyTakeInShortcutAction(ready({
|
||||
takeInAllowed: true,
|
||||
engineState: "PREPARED",
|
||||
preparedCode: "5001"
|
||||
})),
|
||||
"take-in");
|
||||
assert.equal(
|
||||
safety.legacyTakeInShortcutAction(ready({
|
||||
takeInAllowed: true,
|
||||
engineState: "PROGRAM",
|
||||
onAirCode: "5001"
|
||||
})),
|
||||
"next-required");
|
||||
assert.equal(
|
||||
safety.legacyTakeInShortcutAction(ready({ takeInAllowed: false })),
|
||||
"take-in-locked");
|
||||
assert.equal(
|
||||
safety.legacyTakeInShortcutAction(ready({ takeInAllowed: true, pending: true })),
|
||||
"blocked");
|
||||
});
|
||||
|
||||
test("Refresh faults block mutations but preserve emergency TAKE OUT", () => {
|
||||
const faulted = ready({
|
||||
refreshFaulted: true,
|
||||
|
||||
242
tests/Web/theme-workflow.test.cjs
Normal file
242
tests/Web/theme-workflow.test.cjs
Normal file
@@ -0,0 +1,242 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/theme-workflow.js");
|
||||
|
||||
const krxTheme = Object.freeze({
|
||||
market: "krx",
|
||||
themeCode: "00000001",
|
||||
title: "AI 반도체",
|
||||
displayTitle: "AI 반도체",
|
||||
session: null,
|
||||
itemCount: 17
|
||||
});
|
||||
const nxtPreTheme = Object.freeze({
|
||||
market: "nxt",
|
||||
themeCode: "00000002",
|
||||
title: "로봇",
|
||||
displayTitle: "로봇(NXT)",
|
||||
session: "PRE_MARKET",
|
||||
itemCount: 121
|
||||
});
|
||||
const nxtAfterTheme = Object.freeze({
|
||||
...nxtPreTheme,
|
||||
session: "AFTER_MARKET"
|
||||
});
|
||||
|
||||
test("theme workflow exposes the UC4 and closed resolver contract", () => {
|
||||
assert.deepEqual(workflow.sourceContract, {
|
||||
originalControl: "Control/UC4.cs",
|
||||
krxResolverGroup: "PAGED_THEME",
|
||||
nxtResolverGroup: "PAGED_NXT_THEME",
|
||||
maximumPageCount: 20,
|
||||
maximumPreviewItems: 240
|
||||
});
|
||||
assert.deepEqual(workflow.themeSorts, [
|
||||
{ id: "INPUT_ORDER", label: "입력순" },
|
||||
{ id: "GAIN_DESC", label: "상승률순" },
|
||||
{ id: "GAIN_ASC", label: "하락률순" }
|
||||
]);
|
||||
assert.deepEqual(workflow.nxtSessions.map(value => value.id), ["PRE_MARKET", "AFTER_MARKET"]);
|
||||
assert.deepEqual(workflow.actions.map(value => [value.id, value.builderKey, value.code, value.pageSize]), [
|
||||
["five-row-current", "s5074", "5074", 5],
|
||||
["five-row-expected", "s5074", "5074", 5],
|
||||
["six-row-current", "s5077", "5077", 6],
|
||||
["twelve-row-current", "s5088", "5088", 12]
|
||||
]);
|
||||
});
|
||||
|
||||
test("KRX and NXT database identities normalize without inferring market from a suffix", () => {
|
||||
assert.deepEqual(workflow.normalizeTheme({
|
||||
Market: "Krx", ThemeCode: "00000001", ThemeTitle: "AI 반도체", Session: "NotApplicable",
|
||||
previewItemCount: 17
|
||||
}), krxTheme);
|
||||
assert.deepEqual(workflow.normalizeTheme({
|
||||
market: "NXT", themeCode: "00000002", title: "로봇", session: "PreMarket", items: new Array(121)
|
||||
}), nxtPreTheme);
|
||||
assert.deepEqual(workflow.normalizeTheme({
|
||||
market: "nxt", code: "00000002", themeTitle: "로봇", session: "after-market", itemCount: 121
|
||||
}), nxtAfterTheme);
|
||||
assert.equal(workflow.normalizeTheme({
|
||||
market: "nxt", themeCode: "00000002", title: "로봇(NXT)", session: "PRE_MARKET"
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeTheme({
|
||||
market: "krx", themeCode: "00000001", title: " AI 반도체 "
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("typed theme validation reports clear market, code, session and preview failures", () => {
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
market: "other", themeCode: "00000001", title: "AI"
|
||||
}, "INPUT_ORDER").reason, "invalid-theme-market");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
market: "krx", themeCode: "1", title: "AI"
|
||||
}, "INPUT_ORDER").reason, "invalid-theme-code");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
market: "krx", themeCode: " 00000001 ", title: "AI"
|
||||
}, "INPUT_ORDER").reason, "invalid-theme-code");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
market: "nxt", themeCode: "00000001", title: "AI"
|
||||
}, "INPUT_ORDER").reason, "nxt-session-required");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
market: "krx", themeCode: "00000001", title: "AI", session: "PRE_MARKET"
|
||||
}, "INPUT_ORDER").reason, "krx-session-must-be-empty");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", {
|
||||
...krxTheme, itemCount: 241
|
||||
}, "INPUT_ORDER").reason, "invalid-theme-item-count");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", krxTheme, "RANDOM").reason,
|
||||
"invalid-theme-sort");
|
||||
});
|
||||
|
||||
test("only three current row sizes and KRX five-row expected are compatible", () => {
|
||||
for (const actionId of ["five-row-current", "six-row-current", "twelve-row-current"]) {
|
||||
for (const sort of workflow.themeSorts) {
|
||||
assert.equal(workflow.isActionAllowed(actionId, krxTheme, sort.id), true);
|
||||
assert.equal(workflow.isActionAllowed(actionId, nxtPreTheme, sort.id), true);
|
||||
assert.equal(workflow.isActionAllowed(actionId, nxtAfterTheme, sort.id), true);
|
||||
}
|
||||
}
|
||||
assert.equal(workflow.isActionAllowed("five-row-expected", krxTheme, "INPUT_ORDER"), true);
|
||||
assert.equal(workflow.getCompatibility("five-row-expected", nxtPreTheme, "INPUT_ORDER").reason,
|
||||
"expected-theme-is-krx-only");
|
||||
assert.equal(workflow.getCompatibility("six-row-expected", krxTheme, "INPUT_ORDER").reason,
|
||||
"unknown-action");
|
||||
assert.equal(workflow.getCompatibility("five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER").reason,
|
||||
"theme-preview-is-empty");
|
||||
});
|
||||
|
||||
test("KRX current and expected selections map sort and value kind to separate fields", () => {
|
||||
const current = workflow.buildThemeSelection("five-row-current", krxTheme, "GAIN_DESC");
|
||||
assert.equal(current.builderKey, "s5074");
|
||||
assert.equal(current.code, "5074");
|
||||
assert.deepEqual(current.aliases, ["5074"]);
|
||||
assert.deepEqual(current.selection, {
|
||||
groupCode: "PAGED_THEME",
|
||||
subject: "AI 반도체",
|
||||
graphicType: "GAIN_DESC",
|
||||
subtype: "CURRENT",
|
||||
dataCode: "00000001"
|
||||
});
|
||||
|
||||
const expected = workflow.buildThemeSelection("five-row-expected", krxTheme, "GAIN_ASC");
|
||||
assert.equal(expected.builderKey, "s5074");
|
||||
assert.deepEqual(expected.selection, {
|
||||
groupCode: "PAGED_THEME",
|
||||
subject: "AI 반도체",
|
||||
graphicType: "GAIN_ASC",
|
||||
subtype: "EXPECTED",
|
||||
dataCode: "00000001"
|
||||
});
|
||||
});
|
||||
|
||||
test("NXT selections map explicit session to GraphicType and sort to Subtype", () => {
|
||||
const five = workflow.buildThemeSelection("five-row-current", nxtPreTheme, "INPUT_ORDER");
|
||||
assert.deepEqual(five.selection, {
|
||||
groupCode: "PAGED_NXT_THEME",
|
||||
subject: "로봇(NXT)",
|
||||
graphicType: "PRE_MARKET",
|
||||
subtype: "INPUT_ORDER",
|
||||
dataCode: "00000002"
|
||||
});
|
||||
|
||||
const six = workflow.buildThemeSelection("six-row-current", nxtAfterTheme, "GAIN_DESC");
|
||||
assert.equal(six.builderKey, "s5077");
|
||||
assert.equal(six.code, "5077");
|
||||
assert.equal(six.selection.graphicType, "AFTER_MARKET");
|
||||
assert.equal(six.selection.subtype, "GAIN_DESC");
|
||||
|
||||
const twelve = workflow.buildThemeSelection("twelve-row-current", nxtAfterTheme, "GAIN_ASC");
|
||||
assert.equal(twelve.builderKey, "s5088");
|
||||
assert.equal(twelve.code, "5088");
|
||||
assert.deepEqual(twelve.aliases, ["5088"]);
|
||||
assert.equal(twelve.selection.subtype, "GAIN_ASC");
|
||||
});
|
||||
|
||||
test("item-count previews calculate 5, 6 and 12 row pages within the 20-page boundary", () => {
|
||||
assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 17 }), {
|
||||
itemCount: 17, accessibleItemCount: 17, pageSize: 5,
|
||||
pageCount: 4, maximumPageCount: 20, isTruncated: false
|
||||
});
|
||||
assert.deepEqual(workflow.calculatePagePreview("five-row-current", { ...krxTheme, itemCount: 101 }), {
|
||||
itemCount: 101, accessibleItemCount: 100, pageSize: 5,
|
||||
pageCount: 20, maximumPageCount: 20, isTruncated: true
|
||||
});
|
||||
assert.deepEqual(workflow.calculatePagePreview("six-row-current", { ...krxTheme, itemCount: 120 }), {
|
||||
itemCount: 120, accessibleItemCount: 120, pageSize: 6,
|
||||
pageCount: 20, maximumPageCount: 20, isTruncated: false
|
||||
});
|
||||
assert.deepEqual(workflow.calculatePagePreview("twelve-row-current", { ...krxTheme, itemCount: 240 }), {
|
||||
itemCount: 240, accessibleItemCount: 240, pageSize: 12,
|
||||
pageCount: 20, maximumPageCount: 20, isTruncated: false
|
||||
});
|
||||
assert.deepEqual(workflow.calculatePagePreview("five-row-current", {
|
||||
market: "krx", themeCode: "00000001", title: "AI 반도체"
|
||||
}), {
|
||||
itemCount: null, accessibleItemCount: null, pageSize: 5,
|
||||
pageCount: null, maximumPageCount: 20, isTruncated: false
|
||||
});
|
||||
});
|
||||
|
||||
test("playlist entries preserve the alias, preview and explicit theme identity", () => {
|
||||
const entry = workflow.createThemePlaylistEntry("six-row-current", nxtAfterTheme, "GAIN_DESC", {
|
||||
id: "nxt-theme", fadeDuration: 4
|
||||
});
|
||||
assert.equal(entry.builderKey, "s5077");
|
||||
assert.equal(entry.code, "5077");
|
||||
assert.equal(entry.title, "로봇(NXT)");
|
||||
assert.equal(entry.detail, "6종목 현재가 · 상승률순");
|
||||
assert.equal(entry.pageSize, 6);
|
||||
assert.equal(entry.pagePreview.pageCount, 20);
|
||||
assert.equal(entry.pagePreview.isTruncated, true);
|
||||
assert.equal(entry.fadeDuration, 4);
|
||||
assert.deepEqual(entry.operator.theme, nxtAfterTheme);
|
||||
assert.equal(entry.operator.resolverGroup, "PAGED_NXT_THEME");
|
||||
});
|
||||
|
||||
test("trusted theme restore reconstructs fields and rejects action, identity and preview tampering", () => {
|
||||
const original = workflow.createThemePlaylistEntry("five-row-expected", krxTheme, "GAIN_ASC", {
|
||||
id: "stored-theme", fadeDuration: 8
|
||||
});
|
||||
assert.ok(workflow.restoreThemePlaylistEntry(original));
|
||||
assert.equal(workflow.restoreThemePlaylistEntry({ ...original, enabled: false }).enabled, false);
|
||||
assert.equal(workflow.restoreThemePlaylistEntry({ ...original, code: "5088" }), null);
|
||||
assert.equal(workflow.restoreThemePlaylistEntry({
|
||||
...original,
|
||||
selection: { ...original.selection, subtype: "CURRENT" }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreThemePlaylistEntry({
|
||||
...original,
|
||||
pagePreview: { ...original.pagePreview, pageCount: 20 }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreThemePlaylistEntry({
|
||||
...original,
|
||||
operator: { ...original.operator, schemaVersion: 2 }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreThemePlaylistEntry({
|
||||
...original,
|
||||
operator: { ...original.operator, theme: { ...original.operator.theme, themeCode: "00000003" } }
|
||||
}), null);
|
||||
assert.equal(workflow.restoreThemePlaylistEntry({
|
||||
...original,
|
||||
operator: { ...original.operator, resolverGroup: "PAGED_NXT_THEME" }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("unknown actions, unsafe ids, invalid fades and unsupported known-empty themes cannot be created", () => {
|
||||
assert.throws(() => workflow.createThemePlaylistEntry("unknown", krxTheme, "INPUT_ORDER", {
|
||||
id: "unknown"
|
||||
}), /unknown/i);
|
||||
assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", {
|
||||
id: "bad id"
|
||||
}), /safe theme playlist id/i);
|
||||
assert.throws(() => workflow.createThemePlaylistEntry("five-row-current", krxTheme, "INPUT_ORDER", {
|
||||
id: "bad-fade", fadeDuration: 61
|
||||
}), /0 through 60/i);
|
||||
assert.throws(() => workflow.createThemePlaylistEntry(
|
||||
"five-row-current", { ...krxTheme, itemCount: 0 }, "INPUT_ORDER", { id: "empty" }),
|
||||
/theme-preview-is-empty/i);
|
||||
assert.throws(() => workflow.createThemePlaylistEntry(
|
||||
"five-row-expected", nxtPreTheme, "INPUT_ORDER", { id: "nxt-expected" }),
|
||||
/expected-theme-is-krx-only/i);
|
||||
});
|
||||
272
tests/Web/trading-halt-workflow.test.cjs
Normal file
272
tests/Web/trading-halt-workflow.test.cjs
Normal file
@@ -0,0 +1,272 @@
|
||||
"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);
|
||||
});
|
||||
Reference in New Issue
Block a user