Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

View 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]));
});

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

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

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

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

View 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/);
});

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

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

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

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

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

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

View 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\(/);
});

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

View 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\)/);
});

View 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*\(/);
});

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

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

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

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

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

View File

@@ -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,

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

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