Complete legacy operator UI and playout migration
This commit is contained in:
297
tests/Web/manual-lists-workflow.test.cjs
Normal file
297
tests/Web/manual-lists-workflow.test.cjs
Normal file
@@ -0,0 +1,297 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const workflow = require("../../Web/manual-lists-workflow.js");
|
||||
|
||||
const requestId = "manual_req_1";
|
||||
const rowVersion = "a".repeat(64);
|
||||
const rows = () => Array.from({ length: 5 }, (_, index) => ({
|
||||
leftName: `왼쪽${index + 1}`,
|
||||
leftAmount: `${index + 1},000`,
|
||||
rightName: `오른쪽${index + 1}`,
|
||||
rightAmount: `-${index + 1},000`
|
||||
}));
|
||||
const viItems = () => [
|
||||
{ code: "005930", name: "삼성전자" },
|
||||
{ code: "000660", name: "SK하이닉스" },
|
||||
{ code: "005930", name: "삼성전자" }
|
||||
];
|
||||
|
||||
test("bridge requests expose only closed operations and keys", () => {
|
||||
assert.deepEqual(workflow.createStatusRequest(requestId), {
|
||||
type: "request-manual-operator-data-status",
|
||||
payload: { requestId }
|
||||
});
|
||||
assert.deepEqual(workflow.createNetSellReadRequest(requestId, "FOREIGN"), {
|
||||
type: "request-manual-net-sell-data",
|
||||
payload: { requestId, audience: "FOREIGN" }
|
||||
});
|
||||
assert.deepEqual(workflow.createViReadRequest(requestId), {
|
||||
type: "request-vi-manual-list",
|
||||
payload: { requestId }
|
||||
});
|
||||
});
|
||||
|
||||
test("net-sell save request requires exactly five strict rows", () => {
|
||||
const request = workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows());
|
||||
assert.equal(request.type, "save-manual-net-sell-data");
|
||||
assert.equal(request.payload.rows.length, 5);
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows().slice(0, 4)));
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "OTHER", rows()));
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", [
|
||||
...rows().slice(0, 4),
|
||||
{ ...rows()[4], extra: "not allowed" }
|
||||
]));
|
||||
});
|
||||
|
||||
test("manual value delimiter and control characters fail closed", () => {
|
||||
for (const leftName of ["bad^name", "bad\nname", "bad\u200Ename", "bad\uFFFDname"]) {
|
||||
const invalid = rows();
|
||||
invalid[0].leftName = leftName;
|
||||
assert.throws(() => workflow.createNetSellSaveRequest(requestId, "FOREIGN", invalid));
|
||||
}
|
||||
});
|
||||
|
||||
test("VI save preserves order and duplicates but never exposes a path", () => {
|
||||
const request = workflow.createViSaveRequest(requestId, viItems(), rowVersion);
|
||||
assert.deepEqual(request, {
|
||||
type: "save-vi-manual-list",
|
||||
payload: { requestId, expectedVersion: rowVersion, items: viItems() }
|
||||
});
|
||||
assert.equal(JSON.stringify(request).includes("path"), false);
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, viItems()));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "A".repeat(64)));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "a".repeat(63)));
|
||||
});
|
||||
|
||||
test("VI list rejects comma ambiguity, unsafe text, and more than twenty pages", () => {
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A,B" }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1^2", name: "A" }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: " 1", name: "A" }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A " }], rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(
|
||||
requestId,
|
||||
Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `Item${index + 1}` })),
|
||||
rowVersion));
|
||||
assert.throws(() => workflow.createViSaveRequest(
|
||||
requestId,
|
||||
Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `종목${index + 1}` }))));
|
||||
});
|
||||
|
||||
test("status response preserves process-lifetime write quarantine", () => {
|
||||
assert.deepEqual(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: true,
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다."
|
||||
}, requestId), {
|
||||
requestId,
|
||||
available: true,
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다."
|
||||
});
|
||||
assert.equal(workflow.normalizeStatusResponse({
|
||||
requestId,
|
||||
available: true,
|
||||
writeQuarantined: true,
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
extra: true
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("net-sell data response is correlated and exact", () => {
|
||||
const response = {
|
||||
requestId,
|
||||
audience: "INSTITUTION",
|
||||
rows: rows()
|
||||
};
|
||||
assert.ok(workflow.normalizeNetSellDataResponse(response, {
|
||||
requestId,
|
||||
audience: "INSTITUTION"
|
||||
}));
|
||||
assert.equal(workflow.normalizeNetSellDataResponse(response, {
|
||||
requestId: "other",
|
||||
audience: "INSTITUTION"
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("net-sell write result distinguishes verified recovery", () => {
|
||||
const response = {
|
||||
requestId,
|
||||
audience: "FOREIGN",
|
||||
saved: true,
|
||||
recovered: true
|
||||
};
|
||||
assert.ok(workflow.normalizeNetSellSaveResponse(response, { requestId, audience: "FOREIGN" }));
|
||||
assert.equal(workflow.normalizeNetSellSaveResponse({ ...response, saved: false }), null);
|
||||
});
|
||||
|
||||
test("VI data response verifies item and page counts", () => {
|
||||
const response = {
|
||||
requestId,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
items: viItems(),
|
||||
version: rowVersion
|
||||
};
|
||||
assert.ok(workflow.normalizeViDataResponse(response, requestId));
|
||||
assert.equal(workflow.normalizeViDataResponse({ ...response, pageCount: 2 }), null);
|
||||
assert.equal(workflow.normalizeViDataResponse({ ...response, itemCount: 2 }), null);
|
||||
});
|
||||
|
||||
test("VI save response rejects impossible page counts", () => {
|
||||
assert.ok(workflow.normalizeViSaveResponse({
|
||||
requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: true,
|
||||
itemCount: 100,
|
||||
pageCount: 20,
|
||||
version: rowVersion
|
||||
}, requestId));
|
||||
assert.equal(workflow.normalizeViSaveResponse({
|
||||
requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: true,
|
||||
itemCount: 100,
|
||||
pageCount: 19,
|
||||
version: rowVersion
|
||||
}), null);
|
||||
assert.ok(workflow.normalizeViSaveResponse({
|
||||
requestId,
|
||||
saved: true,
|
||||
recovered: false,
|
||||
persisted: false,
|
||||
itemCount: 3,
|
||||
pageCount: 1,
|
||||
version: rowVersion
|
||||
}, requestId));
|
||||
});
|
||||
|
||||
test("OutcomeUnknown errors are never retryable", () => {
|
||||
assert.ok(workflow.normalizeError({
|
||||
requestId,
|
||||
operation: "save-vi",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true
|
||||
}, requestId));
|
||||
assert.equal(workflow.normalizeError({
|
||||
requestId,
|
||||
operation: "save-vi",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "저장 결과를 확인할 수 없습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: true
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("manual net-sell playlist mapping is original s5025 contract", () => {
|
||||
const entry = workflow.createNetSellPlaylistEntry("INDIVIDUAL", {
|
||||
id: "entry_1",
|
||||
fadeDuration: 6
|
||||
});
|
||||
assert.equal(entry.builderKey, "s5025");
|
||||
assert.equal(entry.code, "5025");
|
||||
assert.deepEqual(entry.selection, {
|
||||
groupCode: "INDIVIDUAL",
|
||||
subject: "INDEX",
|
||||
graphicType: "MANUAL_NET_SELL",
|
||||
subtype: "MANUAL_NET_SELL",
|
||||
dataCode: ""
|
||||
});
|
||||
});
|
||||
|
||||
test("VI playlist mapping uses a bounded trusted snapshot reference", () => {
|
||||
const entry = workflow.createViPlaylistEntry(viItems(), rowVersion, {
|
||||
id: "entry_2",
|
||||
fadeDuration: 4
|
||||
});
|
||||
assert.equal(entry.builderKey, "s5074");
|
||||
assert.equal(entry.code, "5074");
|
||||
assert.equal(entry.itemCount, 3);
|
||||
assert.equal(entry.pageSize, 5);
|
||||
assert.equal(entry.pageCount, 1);
|
||||
assert.equal(entry.selection.groupCode, "PAGED_VI");
|
||||
assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
|
||||
assert.equal(entry.selection.dataCode, rowVersion);
|
||||
assert.ok(entry.selection.subject.length <= 256);
|
||||
});
|
||||
|
||||
test("one hundred realistic long names still create a bounded twenty-page reference", () => {
|
||||
const items = Array.from({ length: 100 }, (_, index) => ({
|
||||
code: `P${String(index + 1).padStart(6, "0")}`,
|
||||
name: `현실적인긴종목명테스트${index + 1}`
|
||||
}));
|
||||
const entry = workflow.createViPlaylistEntry(items, rowVersion, {
|
||||
id: "entry_20_pages",
|
||||
fadeDuration: 4
|
||||
});
|
||||
assert.equal(entry.pageCount, 20);
|
||||
assert.equal(entry.selection.subject, workflow.viSnapshotSubject);
|
||||
assert.equal(entry.selection.dataCode.length, 64);
|
||||
assert.equal(JSON.stringify(entry.selection).includes(items[0].name), false);
|
||||
});
|
||||
|
||||
test("empty VI list can be saved but cannot create a playout entry", () => {
|
||||
assert.deepEqual(workflow.createViSaveRequest(requestId, [], rowVersion), {
|
||||
type: "save-vi-manual-list",
|
||||
payload: { requestId, expectedVersion: rowVersion, items: [] }
|
||||
});
|
||||
assert.throws(() => workflow.createViPlaylistEntry([], rowVersion, {
|
||||
id: "entry_empty",
|
||||
fadeDuration: 6
|
||||
}));
|
||||
});
|
||||
|
||||
test("trusted restore recreates manual entries and rejects tampering", () => {
|
||||
const netSell = workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry_net",
|
||||
fadeDuration: 6
|
||||
});
|
||||
const vi = workflow.createViPlaylistEntry(viItems(), rowVersion, {
|
||||
id: "entry_vi",
|
||||
fadeDuration: 6
|
||||
});
|
||||
assert.deepEqual(workflow.restorePlaylistEntry(netSell), netSell);
|
||||
assert.deepEqual(workflow.restorePlaylistEntry(vi), vi);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...vi,
|
||||
selection: { ...vi.selection, subject: "다른종목" }
|
||||
}), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({ ...vi, extra: true }), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...vi,
|
||||
operator: {
|
||||
...vi.operator,
|
||||
pagePreview: { ...vi.operator.pagePreview, pageCount: 20 }
|
||||
}
|
||||
}), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...vi,
|
||||
selection: { ...vi.selection, extra: true }
|
||||
}), null);
|
||||
assert.equal(workflow.restorePlaylistEntry({
|
||||
...netSell,
|
||||
operator: { ...netSell.operator, audience: "OTHER" }
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("request identifiers and option bounds are strict", () => {
|
||||
assert.throws(() => workflow.createStatusRequest("bad id"));
|
||||
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry",
|
||||
fadeDuration: 61
|
||||
}));
|
||||
assert.throws(() => workflow.createViPlaylistEntry(viItems(), rowVersion, {
|
||||
id: "entry",
|
||||
fadeDuration: -1
|
||||
}));
|
||||
assert.throws(() => workflow.createViPlaylistEntry(viItems(), "A".repeat(64), {
|
||||
id: "entry",
|
||||
fadeDuration: 4
|
||||
}));
|
||||
});
|
||||
Reference in New Issue
Block a user