Files
MBN_STOCK_WEBVIEW/tests/Web/named-playlist-workflow.test.cjs

453 lines
18 KiB
JavaScript

"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("legacy LIST_TEXT accepts a bounded 100-name VI subject beyond 256 characters", () => {
const names = Array.from(
{ length: 100 },
(_, index) => `종목${String(index).padStart(3, "0")}`).join(",");
assert.ok(names.length > 256);
const fields = ["1", "", names, "5단 표그래프", "VI 발동(수동)", "1/20", ""];
const serialized = workflow.legacyListText(fields);
assert.ok(serialized.length <= 4000);
const response = workflow.normalizeLoadResponse(loadResponse([nativeRow({
groupCode: "",
subject: names,
graphicType: "5단 표그래프",
subtype: "VI 발동(수동)",
pageText: "1/20",
dataCode: ""
})]));
assert.equal(response.rawRows[0].subject, names);
assert.throws(() => workflow.legacyListText([
"1", "", "가".repeat(3990), "5단 표그래프", "VI 발동(수동)", "1/20", ""
]), /too long/u);
});
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);
});