256 lines
8.9 KiB
JavaScript
256 lines
8.9 KiB
JavaScript
"use strict";
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const safety = require("../../Web/playout-safety.js");
|
|
|
|
function ready(overrides = {}) {
|
|
return {
|
|
pending: false,
|
|
outcomeUnknown: false,
|
|
commandAvailable: true,
|
|
engineState: "IDLE",
|
|
preparedCode: null,
|
|
onAirCode: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
test("NEXT is blocked until a scene is on air", () => {
|
|
assert.equal(safety.commandBlockReason(ready(), "next"), "next-requires-on-air");
|
|
assert.equal(
|
|
safety.commandBlockReason(ready({ engineState: "PREPARED", preparedCode: "5001" }), "next"),
|
|
"next-requires-on-air");
|
|
assert.equal(
|
|
safety.commandBlockReason(ready({ engineState: "PROGRAM", onAirCode: "5001" }), "next"),
|
|
null);
|
|
});
|
|
|
|
test("OutcomeUnknown and unavailable states block every command", () => {
|
|
for (const command of ["prepare", "take-in", "next", "take-out"]) {
|
|
assert.equal(
|
|
safety.commandBlockReason(ready({ outcomeUnknown: true }), command),
|
|
"outcome-unknown");
|
|
assert.equal(
|
|
safety.commandBlockReason(ready({ commandAvailable: false }), command),
|
|
"unavailable");
|
|
}
|
|
});
|
|
|
|
test("TAKE IN and TAKE OUT preserve the native state prerequisites", () => {
|
|
assert.equal(safety.commandBlockReason(ready(), "take-in"), "take-in-requires-prepare");
|
|
assert.equal(safety.commandBlockReason(ready(), "take-out"), "nothing-to-take-out");
|
|
assert.equal(
|
|
safety.commandBlockReason(ready({ preparedCode: "5001" }), "take-in"),
|
|
null);
|
|
assert.equal(
|
|
safety.commandBlockReason(ready({ onAirCode: "5001" }), "take-out"),
|
|
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,
|
|
engineState: "PROGRAM",
|
|
onAirCode: "5001"
|
|
});
|
|
for (const command of ["prepare", "take-in", "next"]) {
|
|
assert.equal(safety.commandBlockReason(faulted, command), "refresh-fault");
|
|
}
|
|
assert.equal(safety.commandBlockReason(faulted, "take-out"), null);
|
|
assert.equal(
|
|
safety.canClearCommandError({ code: "REFRESH_FAULT", nonClearable: true }, false),
|
|
false);
|
|
});
|
|
|
|
test("Browser response timeout follows the bounded native operation timeout", () => {
|
|
assert.equal(safety.responseTimeoutFromNative(5000), 10000);
|
|
assert.equal(safety.responseTimeoutFromNative(300000), 305000);
|
|
assert.equal(safety.responseTimeoutFromNative(100), 5100);
|
|
assert.equal(safety.responseTimeoutFromNative(99), 15000);
|
|
assert.equal(safety.responseTimeoutFromNative("not-a-number"), 15000);
|
|
});
|
|
|
|
test("Connection state normalization is allowlisted", () => {
|
|
assert.equal(safety.normalizeConnectionState("Outcome_Unknown"), "outcome-unknown");
|
|
assert.equal(safety.normalizeConnectionState("RECONNECTING"), "reconnecting");
|
|
assert.equal(safety.normalizeConnectionState("attacker-defined"), "unavailable");
|
|
});
|
|
|
|
test("Late or mismatched native responses cannot complete a pending command", () => {
|
|
const pending = { requestId: "REQ-2", command: "next" };
|
|
assert.equal(
|
|
safety.matchesPendingCommand(pending, { requestId: "REQ-2", command: "next" }),
|
|
true);
|
|
assert.equal(
|
|
safety.matchesPendingCommand(pending, { requestId: "REQ-1", command: "next" }),
|
|
false);
|
|
assert.equal(
|
|
safety.matchesPendingCommand(pending, { requestId: "REQ-2", command: "take-in" }),
|
|
false);
|
|
assert.equal(safety.matchesPendingCommand(null, { requestId: "REQ-2", command: "next" }), false);
|
|
});
|
|
|
|
test("Timeout and OutcomeUnknown errors cannot clear the session quarantine", () => {
|
|
assert.equal(
|
|
safety.canClearCommandError({ code: "WEB_TIMEOUT", outcomeUnknown: true }, true),
|
|
false);
|
|
assert.equal(
|
|
safety.canClearCommandError({ code: "OUTCOME_UNKNOWN", outcomeUnknown: true }, false),
|
|
false);
|
|
assert.equal(
|
|
safety.canClearCommandError({ code: "DATABASE_UNAVAILABLE", outcomeUnknown: false }, false),
|
|
true);
|
|
});
|
|
|
|
test("Playlist snapshot locks before PREPARE completes and through TAKE OUT", () => {
|
|
assert.equal(safety.playlistSnapshotLocked(ready()), false);
|
|
assert.equal(safety.playlistSnapshotLocked(ready({ pending: true })), true);
|
|
assert.equal(safety.playlistSnapshotLocked(ready({ outcomeUnknown: true })), true);
|
|
assert.equal(
|
|
safety.playlistSnapshotLocked(ready({ engineState: "PREPARED", preparedCode: "5001" })),
|
|
true);
|
|
assert.equal(
|
|
safety.playlistSnapshotLocked(ready({ engineState: "PROGRAM", onAirCode: "5001" })),
|
|
true);
|
|
});
|
|
|
|
test("A database refresh fault clears only after TAKE OUT resets refresh state", () => {
|
|
const fault = {
|
|
code: "DATABASE_UNAVAILABLE",
|
|
message: "Read failed",
|
|
nonClearable: true,
|
|
isRefreshFault: true
|
|
};
|
|
assert.equal(fault.code, "DATABASE_UNAVAILABLE");
|
|
assert.equal(fault.nonClearable, true);
|
|
assert.equal(fault.isRefreshFault, true);
|
|
assert.equal(safety.shouldClearRefreshError(fault, true), false);
|
|
assert.equal(safety.shouldClearRefreshError(fault, false), true);
|
|
|
|
const unrelated = { code: "SCENE_DATA_INVALID", outcomeUnknown: false };
|
|
assert.equal(safety.shouldClearRefreshError(unrelated, false), false);
|
|
});
|
|
|
|
test("Refresh budget status requires a closed nonnegative maximum and native drain proof", () => {
|
|
assert.deepEqual(safety.normalizeRefreshBudget({}), {
|
|
completed: 0,
|
|
maximum: null,
|
|
limitReached: false
|
|
});
|
|
assert.deepEqual(safety.normalizeRefreshBudget({
|
|
refreshCompletedCount: 1,
|
|
refreshMaximumCount: 1,
|
|
refreshLimitReached: false
|
|
}), {
|
|
completed: 1,
|
|
maximum: 1,
|
|
limitReached: false
|
|
});
|
|
assert.deepEqual(safety.normalizeRefreshBudget({
|
|
refreshCompletedCount: 1,
|
|
refreshMaximumCount: 1,
|
|
refreshLimitReached: true,
|
|
refreshActive: false,
|
|
playCompletionPending: false,
|
|
refreshFaulted: false
|
|
}), {
|
|
completed: 1,
|
|
maximum: 1,
|
|
limitReached: true
|
|
});
|
|
assert.deepEqual(safety.normalizeRefreshBudget({
|
|
refreshCompletedCount: 2,
|
|
refreshMaximumCount: -1,
|
|
refreshLimitReached: true
|
|
}), {
|
|
completed: 2,
|
|
maximum: null,
|
|
limitReached: false
|
|
});
|
|
for (const contradiction of [
|
|
{ refreshActive: true },
|
|
{ playCompletionPending: true },
|
|
{ refreshFaulted: true }
|
|
]) {
|
|
assert.equal(safety.normalizeRefreshBudget({
|
|
refreshCompletedCount: 1,
|
|
refreshMaximumCount: 1,
|
|
refreshLimitReached: true,
|
|
...contradiction
|
|
}).limitReached, false);
|
|
}
|
|
assert.equal(safety.normalizeRefreshBudget({
|
|
refreshCompletedCount: Number.MAX_SAFE_INTEGER,
|
|
refreshMaximumCount: 1000001,
|
|
refreshLimitReached: true
|
|
}).maximum, null);
|
|
});
|
|
|
|
test("Refresh budget labels distinguish active, callback-drained capped, fault and default states", () => {
|
|
assert.equal(safety.formatRefreshBudgetState({}), "STOPPED");
|
|
assert.equal(safety.formatRefreshBudgetState({
|
|
refreshActive: true,
|
|
refreshCompletedCount: 0,
|
|
refreshMaximumCount: 1
|
|
}, "18:00:00"), "ACTIVE · 0/1 · 18:00:00");
|
|
assert.equal(safety.formatRefreshBudgetState({
|
|
refreshCompletedCount: 1,
|
|
refreshMaximumCount: 1,
|
|
refreshLimitReached: true,
|
|
refreshActive: false,
|
|
playCompletionPending: false,
|
|
refreshFaulted: false
|
|
}), "CAPPED · 1/1");
|
|
assert.equal(safety.formatRefreshBudgetState({
|
|
refreshFaulted: true,
|
|
refreshCompletedCount: 1,
|
|
refreshMaximumCount: 1
|
|
}), "FAULT");
|
|
});
|
|
|
|
test("Database health snapshots accept only a strictly increasing native sequence", () => {
|
|
assert.equal(safety.isNewerDatabaseStatusSequence(0, 1), true);
|
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, 8), true);
|
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, 7), false);
|
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, 6), false);
|
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, "8"), false);
|
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, null), false);
|
|
});
|
|
|
|
test("Stored conditional aliases preserve the exact builder owner", () => {
|
|
const catalog = [
|
|
{ builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"] },
|
|
{ builderKey: "s8018", code: "8018", aliases: ["8018", "8032", "5032"] }
|
|
];
|
|
assert.equal(safety.resolveStoredCatalogIndex(catalog, "s5032", "8018"), 0);
|
|
assert.equal(safety.resolveStoredCatalogIndex(catalog, "s8018", "5032"), 1);
|
|
assert.equal(safety.resolveStoredCatalogIndex(catalog, "", "8032"), 0);
|
|
});
|