431 lines
17 KiB
JavaScript
431 lines
17 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("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);
|
|
});
|