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