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