1431 lines
52 KiB
JavaScript
1431 lines
52 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { openDatabase } = require("../src/database");
|
|
const { runMigrations } = require("../src/database/migrate");
|
|
const {
|
|
MAX_TIMEOUT_MS,
|
|
createPlaybackService,
|
|
} = require("../src/playback/service");
|
|
|
|
const BASE_TIME = 2_000_000_000_000;
|
|
|
|
function createFakeClock(initial = BASE_TIME) {
|
|
let current = initial;
|
|
let sequence = 0;
|
|
const timers = new Set();
|
|
|
|
function nextTimer() {
|
|
return [...timers]
|
|
.filter((timer) => !timer.cleared && !timer.fired)
|
|
.sort((left, right) => left.at - right.at || left.id - right.id)[0] || null;
|
|
}
|
|
|
|
return {
|
|
now: () => current,
|
|
set(value) {
|
|
assert.ok(value >= current, "fake time cannot move backwards");
|
|
current = value;
|
|
},
|
|
setTimeout(callback, delay) {
|
|
const timer = {
|
|
at: current + delay,
|
|
callback,
|
|
cleared: false,
|
|
delay,
|
|
fired: false,
|
|
id: ++sequence,
|
|
};
|
|
timers.add(timer);
|
|
return timer;
|
|
},
|
|
clearTimeout(timer) {
|
|
if (timer) timer.cleared = true;
|
|
},
|
|
nextTimer,
|
|
runTo(value) {
|
|
assert.ok(value >= current, "fake time cannot move backwards");
|
|
current = value;
|
|
let callbacks = 0;
|
|
while (true) {
|
|
const timer = nextTimer();
|
|
if (!timer || timer.at > current) break;
|
|
timer.fired = true;
|
|
timer.callback();
|
|
callbacks += 1;
|
|
assert.ok(callbacks < 1_000, "timer callback loop did not settle");
|
|
}
|
|
return callbacks;
|
|
},
|
|
invokeStale(timer) {
|
|
timer.callback();
|
|
},
|
|
};
|
|
}
|
|
|
|
function createHarness({ at = BASE_TIME, defaultDurationMs = 1_000 } = {}) {
|
|
const database = openDatabase({ path: ":memory:" });
|
|
runMigrations(database);
|
|
database
|
|
.prepare(
|
|
`UPDATE app_settings
|
|
SET default_duration_ms = ?, updated_at = ?
|
|
WHERE id = 1`,
|
|
)
|
|
.run(defaultDurationMs, at);
|
|
|
|
const clock = createFakeClock(at);
|
|
const stateEvents = [];
|
|
const activationEvents = [];
|
|
let assetSequence = 0;
|
|
let playlistSequence = 0;
|
|
let entrySequence = 0;
|
|
let service;
|
|
|
|
function seedAsset({
|
|
id,
|
|
name,
|
|
status = "ready",
|
|
expirationAt = null,
|
|
contentType = "image/webp",
|
|
width = 1920,
|
|
height = 1080,
|
|
} = {}) {
|
|
const ordinal = ++assetSequence;
|
|
const assetId = id || `asset-${ordinal}`;
|
|
const assetName = name || assetId;
|
|
const sha = ordinal.toString(16).padStart(64, "0");
|
|
database
|
|
.prepare(
|
|
`INSERT INTO assets (
|
|
id, source_sha256, display_name, original_filename, status,
|
|
expiration_at, display_object_key, display_sha256,
|
|
display_content_type, display_byte_size, display_width, display_height,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 12345, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
assetId,
|
|
sha,
|
|
assetName,
|
|
`${assetName}.png`,
|
|
status,
|
|
expirationAt,
|
|
`private/display/${assetId}.webp`,
|
|
sha,
|
|
contentType,
|
|
width,
|
|
height,
|
|
clock.now(),
|
|
clock.now(),
|
|
);
|
|
return assetId;
|
|
}
|
|
|
|
function seedPlaylist({ id, name, entries = [] } = {}) {
|
|
const ordinal = ++playlistSequence;
|
|
const playlistId = id || `playlist-${ordinal}`;
|
|
database
|
|
.prepare(
|
|
`INSERT INTO playlists (id, name, revision, created_at, updated_at)
|
|
VALUES (?, ?, 1, ?, ?)`,
|
|
)
|
|
.run(
|
|
playlistId,
|
|
name || `Playlist ${ordinal}`,
|
|
clock.now(),
|
|
clock.now(),
|
|
);
|
|
const seededEntries = entries.map((options, position) => {
|
|
const entryId = options.id || `entry-${++entrySequence}`;
|
|
const assetId =
|
|
options.assetId ||
|
|
seedAsset({
|
|
id: options.asset?.id,
|
|
name: options.asset?.name,
|
|
status: options.asset?.status,
|
|
expirationAt: options.asset?.expirationAt,
|
|
});
|
|
database
|
|
.prepare(
|
|
`INSERT INTO playlist_entries (
|
|
id, playlist_id, asset_id, enabled, duration_override_ms,
|
|
background_mode_override, background_color_override,
|
|
background_darkened_override, schedule_start_at, schedule_end_at,
|
|
position, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
entryId,
|
|
playlistId,
|
|
assetId,
|
|
options.enabled === false ? 0 : 1,
|
|
options.durationMs ?? null,
|
|
options.backgroundMode ?? null,
|
|
options.backgroundColor ?? null,
|
|
options.backgroundDarkened == null
|
|
? null
|
|
: options.backgroundDarkened
|
|
? 1
|
|
: 0,
|
|
options.scheduleStartAt ?? null,
|
|
options.scheduleEndAt ?? null,
|
|
position,
|
|
clock.now(),
|
|
clock.now(),
|
|
);
|
|
return { assetId, id: entryId };
|
|
});
|
|
return { entries: seededEntries, id: playlistId };
|
|
}
|
|
|
|
function playback() {
|
|
return database
|
|
.prepare("SELECT * FROM playback_state WHERE output_id = 'global'")
|
|
.get();
|
|
}
|
|
|
|
function makeService({
|
|
clearTimeoutFn = clock.clearTimeout,
|
|
initialize = true,
|
|
logger,
|
|
setTimeoutFn = clock.setTimeout,
|
|
start = false,
|
|
} = {}) {
|
|
service = createPlaybackService({
|
|
database,
|
|
now: clock.now,
|
|
setTimeoutFn,
|
|
clearTimeoutFn,
|
|
logger,
|
|
onStateChanged: (snapshot) => stateEvents.push(snapshot),
|
|
onActivationChanged: (event) => activationEvents.push(event),
|
|
});
|
|
if (initialize) service.initialize();
|
|
if (start) service.start();
|
|
return service;
|
|
}
|
|
|
|
function close() {
|
|
service?.stop();
|
|
database.close();
|
|
}
|
|
|
|
return {
|
|
activationEvents,
|
|
clock,
|
|
close,
|
|
database,
|
|
makeService,
|
|
playback,
|
|
seedAsset,
|
|
seedPlaylist,
|
|
stateEvents,
|
|
get service() {
|
|
return service;
|
|
},
|
|
};
|
|
}
|
|
|
|
function expectPlaybackError(action, code, statusCode) {
|
|
assert.throws(action, (error) => {
|
|
assert.equal(error.code, code);
|
|
if (statusCode !== undefined) assert.equal(error.statusCode, statusCode);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
test("absolute deadlines honor mixed durations, wrap, and catch up without drift", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{ id: "a", durationMs: 1_000 },
|
|
{ id: "b", durationMs: 2_000 },
|
|
{ id: "c", durationMs: 3_000 },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
|
|
service.activatePlaylist(playlist.id);
|
|
assert.equal(harness.playback().current_entry_id, "a");
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 1_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().current_entry_id, "b");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 3_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 6_000);
|
|
assert.equal(harness.playback().current_entry_id, "a");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 6_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 7_000);
|
|
|
|
// The callback due at +7s executes very late. The final deadline remains on
|
|
// the original timeline rather than drifting to late-now + duration.
|
|
harness.clock.runTo(BASE_TIME + 15_500);
|
|
assert.equal(harness.playback().current_entry_id, "c");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 15_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 18_000);
|
|
});
|
|
|
|
test("long restart catch-up skips whole cycles across an eligibility boundary", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{ id: "always" },
|
|
{ id: "joins-later", scheduleStartAt: BASE_TIME + 20_000_000 },
|
|
],
|
|
});
|
|
let service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
service.stop();
|
|
|
|
harness.clock.set(BASE_TIME + 30_000_000);
|
|
service = harness.makeService({ start: true });
|
|
assert.equal(harness.playback().current_entry_id, "joins-later");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 30_000_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 30_001_000);
|
|
});
|
|
|
|
test("startup recovers mid-slide and catches up across multiple deadlines", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{ id: "one", durationMs: 1_000 },
|
|
{ id: "two", durationMs: 2_000 },
|
|
],
|
|
});
|
|
let service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
const originalRevision = harness.playback().revision;
|
|
|
|
service.stop();
|
|
harness.clock.set(BASE_TIME + 500);
|
|
service = harness.makeService({ start: true });
|
|
assert.equal(harness.playback().current_entry_id, "one");
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().revision, originalRevision);
|
|
assert.equal(harness.clock.nextTimer().delay, 500);
|
|
|
|
service.stop();
|
|
harness.clock.set(BASE_TIME + 8_500);
|
|
service = harness.makeService({ start: true });
|
|
assert.equal(harness.playback().current_entry_id, "two");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 7_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 9_000);
|
|
assert.equal(harness.playback().revision, originalRevision + 1);
|
|
});
|
|
|
|
test("startup conservatively repairs a malformed non-advancing timeline", (t) => {
|
|
const harness = createHarness({ at: BASE_TIME + 500 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({ entries: [{ id: "repair-current" }] });
|
|
harness.database
|
|
.prepare(
|
|
`UPDATE playback_state
|
|
SET active_playlist_id = ?, current_entry_id = ?,
|
|
slide_started_at = ?, next_slide_at = ?, updated_at = ?
|
|
WHERE output_id = 'global'`,
|
|
)
|
|
.run(
|
|
playlist.id,
|
|
"repair-current",
|
|
BASE_TIME,
|
|
BASE_TIME,
|
|
BASE_TIME,
|
|
);
|
|
|
|
harness.makeService({ start: true });
|
|
assert.equal(harness.playback().current_entry_id, "repair-current");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 500);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 1_500);
|
|
});
|
|
|
|
test("startup preserves pause, static hold, and off-gated transport semantics", (t) => {
|
|
const paused = createHarness();
|
|
const staticHold = createHarness();
|
|
const modeOff = createHarness();
|
|
t.after(() => paused.close());
|
|
t.after(() => staticHold.close());
|
|
t.after(() => modeOff.close());
|
|
|
|
const pausedList = paused.seedPlaylist({
|
|
entries: [{ id: "paused-one" }, { id: "paused-two" }],
|
|
});
|
|
let service = paused.makeService({ start: true });
|
|
service.activatePlaylist(pausedList.id);
|
|
paused.clock.set(BASE_TIME + 250);
|
|
service.pause();
|
|
const pausedRevision = paused.playback().revision;
|
|
service.stop();
|
|
paused.clock.set(BASE_TIME + 20_000);
|
|
service = paused.makeService({ start: true });
|
|
assert.equal(paused.playback().current_entry_id, "paused-one");
|
|
assert.equal(paused.playback().paused_remaining_ms, 750);
|
|
assert.equal(paused.playback().revision, pausedRevision);
|
|
assert.equal(paused.clock.nextTimer(), null);
|
|
|
|
const staticList = staticHold.seedPlaylist({ entries: [{ id: "under-static" }] });
|
|
const staticAsset = staticHold.seedAsset({ id: "arbitrary-static" });
|
|
service = staticHold.makeService({ start: true });
|
|
service.activatePlaylist(staticList.id);
|
|
staticHold.clock.set(BASE_TIME + 200);
|
|
service.enterStatic(staticAsset);
|
|
const staticRevision = staticHold.playback().revision;
|
|
service.stop();
|
|
staticHold.clock.set(BASE_TIME + 40_000);
|
|
service = staticHold.makeService({ start: true });
|
|
assert.equal(staticHold.playback().static_asset_id, staticAsset);
|
|
assert.equal(staticHold.playback().current_entry_id, "under-static");
|
|
assert.equal(staticHold.playback().paused_remaining_ms, 800);
|
|
assert.equal(staticHold.playback().revision, staticRevision);
|
|
|
|
const offList = modeOff.seedPlaylist({
|
|
entries: [{ id: "off-one" }, { id: "off-two" }],
|
|
});
|
|
service = modeOff.makeService({ start: true });
|
|
service.activatePlaylist(offList.id);
|
|
service.setMode("off");
|
|
service.stop();
|
|
modeOff.clock.set(BASE_TIME + 3_500);
|
|
service = modeOff.makeService({ start: true });
|
|
assert.equal(modeOff.playback().mode, "off");
|
|
assert.equal(modeOff.playback().current_entry_id, "off-two");
|
|
assert.equal(modeOff.playback().slide_started_at, BASE_TIME + 3_000);
|
|
assert.equal(modeOff.playback().next_slide_at, BASE_TIME + 4_000);
|
|
});
|
|
|
|
test("restart consumes pending activation at its old boundary and catches up in the target", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const oldPlaylist = harness.seedPlaylist({
|
|
name: "Old",
|
|
entries: [{ id: "old", durationMs: 4_000 }],
|
|
});
|
|
const target = harness.seedPlaylist({
|
|
name: "Target",
|
|
entries: [
|
|
{ id: "target-one", durationMs: 1_000 },
|
|
{ id: "target-two", durationMs: 2_000 },
|
|
],
|
|
});
|
|
let service = harness.makeService({ start: true });
|
|
service.activatePlaylist(oldPlaylist.id);
|
|
harness.clock.set(BASE_TIME + 500);
|
|
assert.equal(service.activatePlaylist(target.id).activation, "pending");
|
|
harness.activationEvents.length = 0;
|
|
service.stop();
|
|
|
|
harness.clock.set(BASE_TIME + 6_500);
|
|
service = harness.makeService({ start: true });
|
|
assert.equal(harness.playback().active_playlist_id, target.id);
|
|
assert.equal(harness.playback().pending_playlist_id, null);
|
|
assert.equal(harness.playback().current_entry_id, "target-two");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 5_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 7_000);
|
|
assert.deepEqual(harness.activationEvents, [
|
|
{
|
|
reason: "activated",
|
|
playlistId: target.id,
|
|
activePlaylistId: target.id,
|
|
pendingPlaylistId: null,
|
|
playbackRevision: harness.playback().revision,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("pending activation finishes the old slide and uses target eligibility at the boundary", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const oldPlaylist = harness.seedPlaylist({
|
|
name: "Old",
|
|
entries: [{ id: "old", durationMs: 2_000 }],
|
|
});
|
|
const target = harness.seedPlaylist({
|
|
name: "New",
|
|
entries: [
|
|
{ id: "disabled", enabled: false },
|
|
{ id: "future", scheduleStartAt: BASE_TIME + 5_000 },
|
|
{ id: "removed-before-boundary" },
|
|
{ id: "first-at-boundary", durationMs: 3_000 },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(oldPlaylist.id);
|
|
service.setMode("off");
|
|
harness.clock.set(BASE_TIME + 500);
|
|
const pending = service.activatePlaylist(target.id);
|
|
assert.equal(pending.activation, "pending");
|
|
assert.equal(harness.playback().active_playlist_id, oldPlaylist.id);
|
|
|
|
// The target is mutable while pending. Eligibility must be read at the
|
|
// boundary, not frozen when activation was requested.
|
|
harness.database
|
|
.prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = ?")
|
|
.run("removed-before-boundary");
|
|
service.domainChanged({
|
|
reason: "entry_updated",
|
|
playlistId: target.id,
|
|
});
|
|
harness.clock.runTo(BASE_TIME + 2_000);
|
|
|
|
const playback = harness.playback();
|
|
assert.equal(playback.mode, "off");
|
|
assert.equal(playback.active_playlist_id, target.id);
|
|
assert.equal(playback.pending_playlist_id, null);
|
|
assert.equal(playback.current_entry_id, "first-at-boundary");
|
|
assert.equal(playback.slide_started_at, BASE_TIME + 2_000);
|
|
assert.equal(playback.next_slide_at, BASE_TIME + 5_000);
|
|
});
|
|
|
|
test("an activation command arriving exactly at a slide deadline owns that boundary", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const oldPlaylist = harness.seedPlaylist({
|
|
name: "Boundary old",
|
|
entries: [{ id: "boundary-old" }],
|
|
});
|
|
const target = harness.seedPlaylist({
|
|
name: "Boundary target",
|
|
entries: [{ id: "boundary-target", durationMs: 2_000 }],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(oldPlaylist.id);
|
|
|
|
// A freshly started slide is still a real current slide even when two REST
|
|
// commands happen to receive the same millisecond timestamp.
|
|
assert.equal(service.activatePlaylist(target.id).activation, "pending");
|
|
assert.equal(service.activatePlaylist(oldPlaylist.id).activation, "already_active");
|
|
assert.equal(harness.playback().pending_playlist_id, null);
|
|
|
|
// Simulate the REST command winning the event-loop turn at the same instant
|
|
// as the queued timer. It must not start another old-playlist duration.
|
|
harness.clock.set(BASE_TIME + 1_000);
|
|
const result = service.activatePlaylist(target.id);
|
|
assert.equal(result.activation, "active");
|
|
assert.equal(harness.playback().active_playlist_id, target.id);
|
|
assert.equal(harness.playback().pending_playlist_id, null);
|
|
assert.equal(harness.playback().current_entry_id, "boundary-target");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 3_000);
|
|
});
|
|
|
|
test("activation is immediate with no normal target or under static without breaking the hold", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const empty = harness.seedPlaylist({ name: "Empty" });
|
|
const oldPlaylist = harness.seedPlaylist({
|
|
name: "Old",
|
|
entries: [{ id: "old-normal", durationMs: 2_000 }],
|
|
});
|
|
const target = harness.seedPlaylist({
|
|
name: "Target",
|
|
entries: [
|
|
{ id: "target-disabled", enabled: false },
|
|
{ id: "target-normal", durationMs: 4_000 },
|
|
],
|
|
});
|
|
const staticAsset = harness.seedAsset({ id: "library-only-static" });
|
|
const service = harness.makeService({ start: true });
|
|
|
|
const emptyResult = service.activatePlaylist(empty.id);
|
|
assert.equal(emptyResult.activation, "active");
|
|
assert.equal(harness.playback().active_playlist_id, empty.id);
|
|
assert.equal(harness.playback().current_entry_id, null);
|
|
assert.equal(service.getSnapshot().outputTarget.type, "black");
|
|
|
|
service.activatePlaylist(oldPlaylist.id);
|
|
harness.clock.set(BASE_TIME + 250);
|
|
service.enterStatic(staticAsset);
|
|
const heldRemaining = harness.playback().paused_remaining_ms;
|
|
const staticResult = service.activatePlaylist(target.id);
|
|
assert.equal(staticResult.activation, "active");
|
|
assert.equal(harness.playback().active_playlist_id, target.id);
|
|
assert.equal(harness.playback().pending_playlist_id, null);
|
|
assert.equal(harness.playback().current_entry_id, "target-normal");
|
|
assert.equal(harness.playback().static_asset_id, staticAsset);
|
|
assert.equal(harness.playback().paused_remaining_ms, 4_000);
|
|
assert.notEqual(harness.playback().paused_remaining_ms, heldRemaining);
|
|
assert.equal(service.getSnapshot().outputTarget.type, "static");
|
|
});
|
|
|
|
test("eligibility skips invalid entries and exact schedule boundaries recover an empty playlist", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{ id: "disabled", enabled: false },
|
|
{ id: "processing", asset: { status: "processing" } },
|
|
{
|
|
id: "expired",
|
|
asset: { expirationAt: BASE_TIME },
|
|
},
|
|
{
|
|
id: "future",
|
|
scheduleStartAt: BASE_TIME + 2_000,
|
|
scheduleEndAt: BASE_TIME + 3_000,
|
|
},
|
|
{ id: "fallback", durationMs: 10_000 },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
assert.equal(harness.playback().current_entry_id, "fallback");
|
|
|
|
harness.database
|
|
.prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = 'fallback'")
|
|
.run();
|
|
service.domainChanged({ reason: "entry_updated", playlistId: playlist.id });
|
|
assert.equal(harness.playback().current_entry_id, null);
|
|
assert.equal(harness.playback().next_slide_at, null);
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 2_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 2_000);
|
|
assert.equal(harness.playback().current_entry_id, "future");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 2_000);
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 3_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 3_000);
|
|
assert.equal(harness.playback().current_entry_id, null);
|
|
assert.equal(harness.playback().next_slide_at, null);
|
|
assert.equal(service.getSnapshot().outputTarget.type, "black");
|
|
});
|
|
|
|
test("a late empty-playlist schedule callback catches up from the exact start", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{ id: "scheduled-only", scheduleStartAt: BASE_TIME + 1_000 },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
assert.equal(harness.playback().current_entry_id, null);
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 3_500);
|
|
assert.equal(harness.playback().current_entry_id, "scheduled-only");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 3_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 4_000);
|
|
});
|
|
|
|
test("schedule end and asset expiration preempt a longer normal duration", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{
|
|
id: "scheduled",
|
|
durationMs: 10_000,
|
|
scheduleEndAt: BASE_TIME + 750,
|
|
},
|
|
{
|
|
id: "expiring",
|
|
durationMs: 10_000,
|
|
asset: { expirationAt: BASE_TIME + 1_500 },
|
|
},
|
|
{ id: "permanent", durationMs: 10_000 },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 750);
|
|
|
|
harness.clock.runTo(BASE_TIME + 750);
|
|
assert.equal(harness.playback().current_entry_id, "expiring");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 750);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 10_750);
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_500);
|
|
|
|
harness.clock.runTo(BASE_TIME + 1_500);
|
|
assert.equal(harness.playback().current_entry_id, "permanent");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_500);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 11_500);
|
|
});
|
|
|
|
test("past-dated live eligibility edits preserve old catch-up then apply now", (t) => {
|
|
const expiration = createHarness({ defaultDurationMs: 1_000 });
|
|
const schedule = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => expiration.close());
|
|
t.after(() => schedule.close());
|
|
|
|
const expirationPlaylist = expiration.seedPlaylist({
|
|
entries: [{ id: "expires-current" }, { id: "expires-next" }],
|
|
});
|
|
let service = expiration.makeService({ start: true });
|
|
service.activatePlaylist(expirationPlaylist.id);
|
|
expiration.clock.set(BASE_TIME + 2_500);
|
|
service.reconcile("before_domain_change");
|
|
assert.equal(expiration.playback().current_entry_id, "expires-current");
|
|
assert.equal(expiration.playback().slide_started_at, BASE_TIME + 2_000);
|
|
const currentAssetId = expirationPlaylist.entries[0].assetId;
|
|
expiration.database
|
|
.prepare("UPDATE assets SET expiration_at = ?, updated_at = ? WHERE id = ?")
|
|
.run(BASE_TIME + 500, expiration.clock.now(), currentAssetId);
|
|
service.domainChanged({
|
|
reason: "asset_updated",
|
|
assetId: currentAssetId,
|
|
playlistIds: [expirationPlaylist.id],
|
|
invalidBoundaryChanged: true,
|
|
});
|
|
assert.equal(expiration.playback().current_entry_id, "expires-next");
|
|
assert.equal(expiration.playback().slide_started_at, BASE_TIME + 2_500);
|
|
assert.equal(expiration.playback().next_slide_at, BASE_TIME + 3_500);
|
|
|
|
const schedulePlaylist = schedule.seedPlaylist({
|
|
entries: [{ id: "scheduled-current" }, { id: "scheduled-next" }],
|
|
});
|
|
service = schedule.makeService({ start: true });
|
|
service.activatePlaylist(schedulePlaylist.id);
|
|
schedule.clock.set(BASE_TIME + 2_600);
|
|
service.reconcile("before_domain_change");
|
|
assert.equal(schedule.playback().current_entry_id, "scheduled-current");
|
|
assert.equal(schedule.playback().slide_started_at, BASE_TIME + 2_000);
|
|
schedule.database
|
|
.prepare(
|
|
"UPDATE playlist_entries SET schedule_end_at = ?, updated_at = ? WHERE id = ?",
|
|
)
|
|
.run(BASE_TIME + 500, schedule.clock.now(), "scheduled-current");
|
|
service.domainChanged({
|
|
reason: "entry_updated",
|
|
playlistId: schedulePlaylist.id,
|
|
invalidBoundaryChanged: true,
|
|
});
|
|
assert.equal(schedule.playback().current_entry_id, "scheduled-next");
|
|
assert.equal(schedule.playback().slide_started_at, BASE_TIME + 2_600);
|
|
assert.equal(schedule.playback().next_slide_at, BASE_TIME + 3_600);
|
|
});
|
|
|
|
test("unrelated domain edits retain a known overdue expiration boundary", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 10_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{
|
|
id: "known-expiration",
|
|
asset: { expirationAt: BASE_TIME + 1_000 },
|
|
},
|
|
{ id: "known-expiration-next" },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
|
|
harness.clock.set(BASE_TIME + 5_000);
|
|
service.reconcile("before_domain_change");
|
|
assert.equal(harness.playback().current_entry_id, "known-expiration-next");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 11_000);
|
|
|
|
harness.database
|
|
.prepare(
|
|
"UPDATE app_settings SET default_blur_px = 21, revision = revision + 1 WHERE id = 1",
|
|
)
|
|
.run();
|
|
service.domainChanged({ reason: "settings_updated", settingsRevision: 2 });
|
|
assert.equal(harness.playback().current_entry_id, "known-expiration-next");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 11_000);
|
|
});
|
|
|
|
test("live playlist mutations preserve stable cursors and reconcile invalid current entries", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 5_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "first" }, { id: "second" }, { id: "third" }],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
harness.clock.set(BASE_TIME + 500);
|
|
const originalDeadline = harness.playback().next_slide_at;
|
|
const originalRevision = harness.playback().revision;
|
|
const eventCount = harness.stateEvents.length;
|
|
|
|
// Move the current stable ID around without replacing it.
|
|
harness.database
|
|
.prepare("UPDATE playlist_entries SET position = position + 10 WHERE playlist_id = ?")
|
|
.run(playlist.id);
|
|
for (const [position, id] of [
|
|
[0, "second"],
|
|
[1, "third"],
|
|
[2, "first"],
|
|
]) {
|
|
harness.database
|
|
.prepare("UPDATE playlist_entries SET position = ? WHERE id = ?")
|
|
.run(position, id);
|
|
}
|
|
harness.database
|
|
.prepare("UPDATE playlists SET revision = revision + 1 WHERE id = ?")
|
|
.run(playlist.id);
|
|
service.domainChanged({ reason: "entries_reordered", playlistId: playlist.id });
|
|
assert.equal(harness.playback().current_entry_id, "first");
|
|
assert.equal(harness.playback().next_slide_at, originalDeadline);
|
|
assert.equal(harness.playback().revision, originalRevision);
|
|
assert.equal(harness.stateEvents.length, eventCount + 1);
|
|
|
|
// A newly added entry participates after the current slide's stable ID.
|
|
const newAsset = harness.seedAsset({ id: "new-live-asset" });
|
|
harness.database
|
|
.prepare(
|
|
`INSERT INTO playlist_entries (
|
|
id, playlist_id, asset_id, enabled, position, created_at, updated_at
|
|
) VALUES ('new-live', ?, ?, 1, 3, ?, ?)`,
|
|
)
|
|
.run(playlist.id, newAsset, harness.clock.now(), harness.clock.now());
|
|
service.domainChanged({ reason: "entry_added", playlistId: playlist.id });
|
|
harness.clock.runTo(originalDeadline);
|
|
assert.equal(harness.playback().current_entry_id, "new-live");
|
|
|
|
harness.database
|
|
.prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = 'new-live'")
|
|
.run();
|
|
service.domainChanged({ reason: "entry_updated", playlistId: playlist.id });
|
|
assert.equal(harness.playback().current_entry_id, "second");
|
|
|
|
harness.database.prepare("DELETE FROM playlist_entries WHERE id = 'second'").run();
|
|
service.domainChanged({ reason: "entry_deleted", playlistId: playlist.id });
|
|
assert.equal(harness.playback().current_entry_id, "third");
|
|
|
|
harness.database
|
|
.prepare("UPDATE playlist_entries SET schedule_start_at = ? WHERE id = 'third'")
|
|
.run(BASE_TIME + 50_000);
|
|
service.domainChanged({ reason: "entry_updated", playlistId: playlist.id });
|
|
assert.equal(harness.playback().current_entry_id, "first");
|
|
});
|
|
|
|
test("inactive playlist edits do not touch playback state or emit playback snapshots", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const active = harness.seedPlaylist({ entries: [{ id: "active" }] });
|
|
const inactive = harness.seedPlaylist({ entries: [{ id: "inactive" }] });
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(active.id);
|
|
const before = harness.playback();
|
|
const eventCount = harness.stateEvents.length;
|
|
|
|
harness.database
|
|
.prepare("UPDATE playlist_entries SET enabled = 0 WHERE id = 'inactive'")
|
|
.run();
|
|
service.domainChanged({ reason: "entry_updated", playlistId: inactive.id });
|
|
assert.deepEqual(harness.playback(), before);
|
|
assert.equal(harness.stateEvents.length, eventCount);
|
|
});
|
|
|
|
test("pause preserves remaining time and resume continues it, including an exact-boundary race", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "first" }, { id: "second" }],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
const timerBeforePause = harness.clock.nextTimer();
|
|
|
|
harness.clock.set(BASE_TIME + 250);
|
|
service.pause();
|
|
let playback = harness.playback();
|
|
assert.equal(playback.rotation_paused, 1);
|
|
assert.equal(playback.current_entry_id, "first");
|
|
assert.equal(playback.paused_remaining_ms, 750);
|
|
assert.equal(playback.slide_started_at, null);
|
|
assert.equal(playback.next_slide_at, null);
|
|
assert.equal(harness.clock.nextTimer(), null);
|
|
|
|
const pausedRevision = playback.revision;
|
|
harness.clock.invokeStale(timerBeforePause);
|
|
assert.equal(harness.playback().revision, pausedRevision);
|
|
harness.clock.set(BASE_TIME + 10_000);
|
|
service.setMode("off");
|
|
service.resume();
|
|
playback = harness.playback();
|
|
assert.equal(playback.mode, "off");
|
|
assert.equal(playback.rotation_paused, 0);
|
|
assert.equal(playback.current_entry_id, "first");
|
|
assert.equal(playback.next_slide_at, BASE_TIME + 10_750);
|
|
harness.clock.runTo(BASE_TIME + 10_750);
|
|
assert.equal(harness.playback().current_entry_id, "second");
|
|
|
|
// A command arriving at a due instant reconciles the boundary first, then
|
|
// applies pause to the newly authoritative slide.
|
|
harness.clock.set(BASE_TIME + 11_750);
|
|
service.pause();
|
|
playback = harness.playback();
|
|
assert.equal(playback.current_entry_id, "first");
|
|
assert.equal(playback.rotation_paused, 1);
|
|
assert.equal(playback.paused_remaining_ms, 1_000);
|
|
});
|
|
|
|
test("resume clears static and pause together while preserving the suspended normal remainder", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 2_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({ entries: [{ id: "normal" }] });
|
|
const staticAsset = harness.seedAsset({ id: "static" });
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
harness.clock.set(BASE_TIME + 400);
|
|
service.enterStatic(staticAsset);
|
|
service.pause();
|
|
assert.equal(harness.playback().static_asset_id, staticAsset);
|
|
assert.equal(harness.playback().rotation_paused, 1);
|
|
assert.equal(harness.playback().paused_remaining_ms, 1_600);
|
|
|
|
harness.clock.set(BASE_TIME + 20_000);
|
|
service.resume();
|
|
const playback = harness.playback();
|
|
assert.equal(playback.static_asset_id, null);
|
|
assert.equal(playback.rotation_paused, 0);
|
|
assert.equal(playback.current_entry_id, "normal");
|
|
assert.equal(playback.paused_remaining_ms, null);
|
|
assert.equal(playback.next_slide_at, BASE_TIME + 21_600);
|
|
});
|
|
|
|
test("next and previous wrap, skip invalid entries, respect pause, static, pending, and mode off", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
name: "Controls",
|
|
entries: [
|
|
{ id: "a", durationMs: 1_500 },
|
|
{ id: "disabled", enabled: false },
|
|
{ id: "c", durationMs: 2_500 },
|
|
{ id: "future", scheduleStartAt: BASE_TIME + 50_000 },
|
|
],
|
|
});
|
|
const target = harness.seedPlaylist({
|
|
name: "Pending target",
|
|
entries: [{ id: "target" }],
|
|
});
|
|
const staticAsset = harness.seedAsset({ id: "static-control" });
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
|
|
harness.clock.set(BASE_TIME + 100);
|
|
service.next();
|
|
assert.equal(harness.playback().current_entry_id, "c");
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 2_600);
|
|
service.next();
|
|
assert.equal(harness.playback().current_entry_id, "a");
|
|
service.previous();
|
|
assert.equal(harness.playback().current_entry_id, "c");
|
|
|
|
service.pause();
|
|
service.previous();
|
|
let playback = harness.playback();
|
|
assert.equal(playback.current_entry_id, "a");
|
|
assert.equal(playback.rotation_paused, 1);
|
|
assert.equal(playback.paused_remaining_ms, 1_500);
|
|
|
|
service.resume();
|
|
service.setMode("off");
|
|
service.next();
|
|
playback = harness.playback();
|
|
assert.equal(playback.mode, "off");
|
|
assert.equal(playback.current_entry_id, "c");
|
|
|
|
service.activatePlaylist(target.id);
|
|
assert.equal(harness.playback().pending_playlist_id, target.id);
|
|
service.next();
|
|
assert.equal(harness.playback().active_playlist_id, target.id);
|
|
assert.equal(harness.playback().pending_playlist_id, null);
|
|
assert.equal(harness.playback().current_entry_id, "target");
|
|
|
|
service.enterStatic(staticAsset);
|
|
const beforeStaticNext = harness.playback();
|
|
service.next();
|
|
service.previous();
|
|
assert.deepEqual(harness.playback(), beforeStaticNext);
|
|
});
|
|
|
|
test("static accepts an arbitrary ready asset, rejects invalid assets, and expires safely", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 2_000 });
|
|
t.after(() => harness.close());
|
|
const normal = harness.seedPlaylist({ entries: [{ id: "normal" }] });
|
|
const unrelated = harness.seedAsset({
|
|
id: "unrelated",
|
|
expirationAt: BASE_TIME + 1_000,
|
|
});
|
|
const disabledList = harness.seedPlaylist({
|
|
entries: [{ id: "disabled-static-ref", assetId: unrelated, enabled: false }],
|
|
});
|
|
assert.ok(disabledList.id);
|
|
const failed = harness.seedAsset({ id: "failed", status: "failed" });
|
|
const expired = harness.seedAsset({
|
|
id: "expired-static",
|
|
expirationAt: BASE_TIME,
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(normal.id);
|
|
harness.clock.set(BASE_TIME + 200);
|
|
|
|
expectPlaybackError(() => service.enterStatic("missing"), "ASSET_NOT_FOUND", 404);
|
|
expectPlaybackError(() => service.enterStatic(failed), "ASSET_NOT_READY", 409);
|
|
expectPlaybackError(() => service.enterStatic(expired), "ASSET_EXPIRED", 409);
|
|
service.enterStatic(unrelated);
|
|
assert.equal(harness.playback().static_asset_id, unrelated);
|
|
assert.equal(harness.playback().paused_remaining_ms, 1_800);
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 1_000);
|
|
const playback = harness.playback();
|
|
assert.equal(playback.static_asset_id, null);
|
|
assert.equal(playback.current_entry_id, "normal");
|
|
assert.equal(playback.next_slide_at, BASE_TIME + 2_800);
|
|
assert.equal(service.getSnapshot().outputTarget.type, "normal");
|
|
});
|
|
|
|
test("a static asset invalidated by a domain change clears immediately and honors operator pause", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 2_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({ entries: [{ id: "normal" }] });
|
|
const staticAsset = harness.seedAsset({ id: "mutable-static" });
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
harness.clock.set(BASE_TIME + 300);
|
|
service.enterStatic(staticAsset);
|
|
service.pause();
|
|
harness.database
|
|
.prepare("UPDATE assets SET status = 'failed' WHERE id = ?")
|
|
.run(staticAsset);
|
|
service.domainChanged({
|
|
reason: "asset_updated",
|
|
assetId: staticAsset,
|
|
playlistIds: [],
|
|
});
|
|
|
|
const playback = harness.playback();
|
|
assert.equal(playback.static_asset_id, null);
|
|
assert.equal(playback.rotation_paused, 1);
|
|
assert.equal(playback.current_entry_id, "normal");
|
|
assert.equal(playback.paused_remaining_ms, 1_700);
|
|
assert.equal(playback.next_slide_at, null);
|
|
});
|
|
|
|
test("a retroactive static expiration edit resumes at mutation time rather than time-travelling", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "retro-normal-a" }, { id: "retro-normal-b" }],
|
|
});
|
|
const staticAsset = harness.seedAsset({ id: "retro-static" });
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
harness.clock.set(BASE_TIME + 200);
|
|
service.enterStatic(staticAsset);
|
|
assert.equal(harness.playback().paused_remaining_ms, 800);
|
|
|
|
harness.clock.set(BASE_TIME + 500);
|
|
harness.database
|
|
.prepare("UPDATE assets SET expiration_at = ?, updated_at = ? WHERE id = ?")
|
|
.run(BASE_TIME + 100, harness.clock.now(), staticAsset);
|
|
service.domainChanged({
|
|
reason: "asset_updated",
|
|
assetId: staticAsset,
|
|
playlistIds: [],
|
|
});
|
|
|
|
const playback = harness.playback();
|
|
assert.equal(playback.static_asset_id, null);
|
|
assert.equal(playback.current_entry_id, "retro-normal-a");
|
|
assert.equal(playback.next_slide_at, BASE_TIME + 1_300);
|
|
});
|
|
|
|
test("pending activation never continues the old playlist after static invalidation", (t) => {
|
|
const resumed = createHarness({ defaultDurationMs: 1_000 });
|
|
const expired = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => resumed.close());
|
|
t.after(() => expired.close());
|
|
|
|
function prepare(harness, staticExpirationAt = null) {
|
|
const oldPlaylist = harness.seedPlaylist({
|
|
entries: [
|
|
{
|
|
id: "static-old-current",
|
|
asset: { expirationAt: BASE_TIME + 500 },
|
|
},
|
|
{ id: "static-old-next" },
|
|
],
|
|
});
|
|
const target = harness.seedPlaylist({
|
|
entries: [{ id: "static-pending-target" }],
|
|
});
|
|
const staticAsset = harness.seedAsset({
|
|
id: "pending-static",
|
|
expirationAt: staticExpirationAt,
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(oldPlaylist.id);
|
|
harness.clock.set(BASE_TIME + 100);
|
|
service.activatePlaylist(target.id);
|
|
harness.clock.set(BASE_TIME + 200);
|
|
service.enterStatic(staticAsset);
|
|
return { service, target };
|
|
}
|
|
|
|
let prepared = prepare(resumed);
|
|
resumed.clock.set(BASE_TIME + 700);
|
|
prepared.service.resume();
|
|
assert.equal(resumed.playback().active_playlist_id, prepared.target.id);
|
|
assert.equal(resumed.playback().pending_playlist_id, null);
|
|
assert.equal(resumed.playback().current_entry_id, "static-pending-target");
|
|
assert.equal(resumed.playback().slide_started_at, BASE_TIME + 700);
|
|
assert.equal(resumed.playback().next_slide_at, BASE_TIME + 1_700);
|
|
assert.equal(resumed.playback().static_asset_id, null);
|
|
assert.equal(resumed.activationEvents.at(-1).reason, "activated");
|
|
|
|
prepared = prepare(expired, BASE_TIME + 700);
|
|
expired.clock.runTo(BASE_TIME + 700);
|
|
assert.equal(expired.playback().active_playlist_id, prepared.target.id);
|
|
assert.equal(expired.playback().pending_playlist_id, null);
|
|
assert.equal(expired.playback().current_entry_id, "static-pending-target");
|
|
assert.equal(expired.playback().slide_started_at, BASE_TIME + 700);
|
|
assert.equal(expired.playback().next_slide_at, BASE_TIME + 1_700);
|
|
assert.equal(expired.playback().static_asset_id, null);
|
|
assert.equal(expired.activationEvents.at(-1).reason, "activated");
|
|
});
|
|
|
|
test("mode is a black visual gate while hidden normal transport keeps advancing", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "visible-one" }, { id: "visible-two" }],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
harness.clock.set(BASE_TIME + 200);
|
|
service.setMode("off");
|
|
const deadline = harness.playback().next_slide_at;
|
|
assert.equal(deadline, BASE_TIME + 1_000);
|
|
assert.deepEqual(service.getSnapshot().outputTarget, {
|
|
type: "black",
|
|
reason: "mode_off",
|
|
});
|
|
|
|
harness.clock.runTo(BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().mode, "off");
|
|
assert.equal(harness.playback().current_entry_id, "visible-two");
|
|
const offRevision = harness.playback().revision;
|
|
service.setMode("off");
|
|
assert.equal(harness.playback().revision, offRevision);
|
|
|
|
service.setMode("on");
|
|
assert.equal(service.getSnapshot().outputTarget.type, "normal");
|
|
assert.equal(harness.playback().current_entry_id, "visible-two");
|
|
expectPlaybackError(
|
|
() => service.setMode("dim"),
|
|
"INVALID_PLAYBACK_MODE",
|
|
400,
|
|
);
|
|
});
|
|
|
|
test("restart clears an expired static hold at the expiration instant and catches normal playback up", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "normal-a" }, { id: "normal-b" }],
|
|
});
|
|
const staticAsset = harness.seedAsset({
|
|
id: "restart-static",
|
|
expirationAt: BASE_TIME + 500,
|
|
});
|
|
let service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
harness.clock.set(BASE_TIME + 100);
|
|
service.enterStatic(staticAsset);
|
|
service.stop();
|
|
|
|
harness.clock.set(BASE_TIME + 3_000);
|
|
service = harness.makeService({ start: true });
|
|
const playback = harness.playback();
|
|
assert.equal(playback.static_asset_id, null);
|
|
assert.equal(playback.current_entry_id, "normal-a");
|
|
assert.equal(playback.slide_started_at, BASE_TIME + 2_400);
|
|
assert.equal(playback.next_slide_at, BASE_TIME + 3_400);
|
|
});
|
|
|
|
test("paused playback still reconciles eligibility expirations without advancing time", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 5_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{
|
|
id: "expires-paused",
|
|
asset: { expirationAt: BASE_TIME + 1_000 },
|
|
},
|
|
{ id: "held-fallback", durationMs: 3_000 },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
harness.clock.set(BASE_TIME + 100);
|
|
service.pause();
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 1_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 1_000);
|
|
const playback = harness.playback();
|
|
assert.equal(playback.rotation_paused, 1);
|
|
assert.equal(playback.current_entry_id, "held-fallback");
|
|
assert.equal(playback.paused_remaining_ms, 3_000);
|
|
assert.equal(playback.next_slide_at, null);
|
|
});
|
|
|
|
test("reload and domain refresh publish fresh snapshots without meaningless playback revision churn", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 2_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "current" }, { id: "replacement" }],
|
|
});
|
|
const unrelated = harness.seedAsset({ id: "unrelated-refresh" });
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
const revision = harness.playback().revision;
|
|
const eventsAfterActivation = harness.stateEvents.length;
|
|
|
|
service.getSnapshot();
|
|
service.getSnapshot();
|
|
service.setMode("on");
|
|
assert.equal(harness.playback().revision, revision);
|
|
assert.equal(harness.stateEvents.length, eventsAfterActivation);
|
|
|
|
service.reload();
|
|
assert.equal(harness.playback().revision, revision);
|
|
assert.equal(harness.stateEvents.length, eventsAfterActivation + 1);
|
|
|
|
harness.database
|
|
.prepare(
|
|
`UPDATE app_settings
|
|
SET revision = revision + 1, default_blur_px = 27
|
|
WHERE id = 1`,
|
|
)
|
|
.run();
|
|
service.domainChanged({ reason: "settings_updated", settingsRevision: 2 });
|
|
assert.equal(harness.playback().revision, revision);
|
|
assert.equal(harness.stateEvents.at(-1).settingsRevision, 2);
|
|
|
|
const eventCount = harness.stateEvents.length;
|
|
harness.database
|
|
.prepare("UPDATE assets SET display_name = 'Unrelated renamed' WHERE id = ?")
|
|
.run(unrelated);
|
|
service.domainChanged({
|
|
reason: "asset_updated",
|
|
assetId: unrelated,
|
|
playlistIds: [],
|
|
});
|
|
assert.equal(harness.stateEvents.length, eventCount);
|
|
assert.equal(harness.playback().revision, revision);
|
|
|
|
// Reload is also a reconciliation command, not merely a broadcast command.
|
|
const currentAssetId = harness.database
|
|
.prepare("SELECT asset_id FROM playlist_entries WHERE id = 'current'")
|
|
.get().asset_id;
|
|
harness.database
|
|
.prepare("UPDATE assets SET status = 'failed' WHERE id = ?")
|
|
.run(currentAssetId);
|
|
service.reload();
|
|
assert.equal(harness.playback().current_entry_id, "replacement");
|
|
assert.equal(harness.playback().revision, revision + 1);
|
|
});
|
|
|
|
test("authoritative snapshot contains cache metadata and presentation without private storage details", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 7_000 });
|
|
t.after(() => harness.close());
|
|
harness.database
|
|
.prepare(
|
|
`UPDATE app_settings
|
|
SET revision = 8,
|
|
default_background_mode = 'solid',
|
|
default_solid_background_color = '#123456',
|
|
default_background_darkened = 1,
|
|
default_blur_px = 22,
|
|
crossfade_duration_ms = 444
|
|
WHERE id = 1`,
|
|
)
|
|
.run();
|
|
const playlist = harness.seedPlaylist({
|
|
id: "snapshot-playlist",
|
|
name: "Sunday",
|
|
entries: [
|
|
{
|
|
id: "snapshot-current",
|
|
durationMs: 2_500,
|
|
backgroundMode: "blurred",
|
|
backgroundColor: "#ABCDEF",
|
|
backgroundDarkened: false,
|
|
},
|
|
{
|
|
id: "snapshot-future",
|
|
scheduleStartAt: BASE_TIME + 50_000,
|
|
},
|
|
{ id: "snapshot-disabled", enabled: false },
|
|
{ id: "snapshot-ended", scheduleEndAt: BASE_TIME },
|
|
{
|
|
id: "snapshot-expired",
|
|
asset: { expirationAt: BASE_TIME },
|
|
},
|
|
{
|
|
id: "snapshot-processing",
|
|
asset: { status: "processing" },
|
|
},
|
|
],
|
|
});
|
|
const staticAsset = harness.seedAsset({ id: "snapshot-static" });
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
const snapshot = service.getSnapshot();
|
|
|
|
assert.equal(snapshot.protocolVersion, 1);
|
|
assert.equal(snapshot.outputId, "global");
|
|
assert.equal(snapshot.serverTime, BASE_TIME);
|
|
assert.equal(snapshot.playbackRevision, harness.playback().revision);
|
|
assert.equal(snapshot.mode, "on");
|
|
assert.equal(snapshot.transportState, "playing");
|
|
assert.deepEqual(snapshot.activePlaylist, {
|
|
id: playlist.id,
|
|
name: "Sunday",
|
|
revision: 1,
|
|
});
|
|
assert.equal(snapshot.settingsRevision, 8);
|
|
assert.equal(snapshot.crossfadeDurationMs, 444);
|
|
assert.deepEqual(snapshot.timeline, {
|
|
slideStartedAt: BASE_TIME,
|
|
nextSlideAt: BASE_TIME + 2_500,
|
|
pausedRemainingMs: null,
|
|
remainingMs: 2_500,
|
|
});
|
|
assert.deepEqual(
|
|
snapshot.manifest.entries.map((entry) => entry.id),
|
|
["snapshot-current", "snapshot-future"],
|
|
);
|
|
const current = snapshot.manifest.entries[0];
|
|
assert.equal(current.effectiveDurationMs, 2_500);
|
|
assert.deepEqual(current.effectivePresentation, {
|
|
backgroundMode: "blurred",
|
|
backgroundColor: "#ABCDEF",
|
|
backgroundDarkened: false,
|
|
blurPx: 22,
|
|
backgroundDarkeningOpacity: 0,
|
|
});
|
|
assert.equal(current.asset.media.mimeType, "image/webp");
|
|
assert.equal(current.asset.media.byteSize, 12_345);
|
|
assert.equal(current.asset.media.width, 1920);
|
|
assert.equal(current.asset.media.height, 1080);
|
|
assert.match(
|
|
current.asset.media.url,
|
|
/^\/media\/assets\/[^/]+\/display\/[0-9a-f]{64}\.webp$/,
|
|
);
|
|
assert.equal(current.asset.media.sha256.length, 64);
|
|
assert.equal(snapshot.outputTarget.type, "normal");
|
|
assert.deepEqual(snapshot.currentTarget, snapshot.outputTarget);
|
|
|
|
const serialized = JSON.stringify(snapshot);
|
|
assert.doesNotMatch(serialized, /private\/display/);
|
|
assert.doesNotMatch(serialized, /object_key|objectKey|bucket|endpoint/i);
|
|
|
|
service.enterStatic(staticAsset);
|
|
const held = service.getSnapshot();
|
|
assert.equal(held.transportState, "static");
|
|
assert.equal(held.staticTarget.assetId, staticAsset);
|
|
assert.deepEqual(held.staticTarget.effectivePresentation, {
|
|
backgroundMode: "solid",
|
|
backgroundColor: "#123456",
|
|
backgroundDarkened: true,
|
|
blurPx: 22,
|
|
backgroundDarkeningOpacity: 0.25,
|
|
});
|
|
});
|
|
|
|
test("schedule-only manifest changes broadcast at the boundary without bumping playback revision", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 5_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [
|
|
{ id: "long-current" },
|
|
{ id: "cache-future", scheduleStartAt: BASE_TIME + 1_000 },
|
|
],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
const revision = harness.playback().revision;
|
|
const eventCount = harness.stateEvents.length;
|
|
assert.equal(
|
|
service
|
|
.getSnapshot()
|
|
.manifest.entries.find((entry) => entry.id === "cache-future").playable,
|
|
false,
|
|
);
|
|
|
|
harness.clock.runTo(BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().current_entry_id, "long-current");
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 5_000);
|
|
assert.equal(harness.playback().revision, revision);
|
|
assert.equal(harness.stateEvents.length, eventCount + 1);
|
|
assert.equal(
|
|
harness.stateEvents
|
|
.at(-1)
|
|
.manifest.entries.find((entry) => entry.id === "cache-future").playable,
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("long timer delays are safely chained and cleared callbacks cannot mutate state", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.close());
|
|
const startAt = BASE_TIME + MAX_TIMEOUT_MS + 5_000;
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "far-future", scheduleStartAt: startAt }],
|
|
});
|
|
const service = harness.makeService({ start: true });
|
|
service.activatePlaylist(playlist.id);
|
|
const firstTimer = harness.clock.nextTimer();
|
|
assert.equal(firstTimer.delay, MAX_TIMEOUT_MS);
|
|
|
|
harness.clock.runTo(BASE_TIME + MAX_TIMEOUT_MS);
|
|
assert.equal(harness.playback().current_entry_id, null);
|
|
assert.equal(harness.clock.nextTimer().delay, 5_000);
|
|
harness.clock.runTo(startAt);
|
|
assert.equal(harness.playback().current_entry_id, "far-future");
|
|
assert.equal(harness.playback().slide_started_at, startAt);
|
|
|
|
const advancingTimer = harness.clock.nextTimer();
|
|
harness.clock.set(startAt + 100);
|
|
service.pause();
|
|
const beforeStale = harness.playback();
|
|
harness.clock.invokeStale(advancingTimer);
|
|
assert.deepEqual(harness.playback(), beforeStale);
|
|
});
|
|
|
|
test("a timer rearm failure preserves the committed state and schedules recovery", (t) => {
|
|
const harness = createHarness({ defaultDurationMs: 1_000 });
|
|
t.after(() => harness.close());
|
|
const playlist = harness.seedPlaylist({
|
|
entries: [{ id: "rearm-a" }, { id: "rearm-b" }],
|
|
});
|
|
let failNextArm = false;
|
|
const service = harness.makeService({
|
|
start: true,
|
|
logger: { warn() {} },
|
|
setTimeoutFn(callback, delay) {
|
|
if (failNextArm) {
|
|
failNextArm = false;
|
|
throw new Error("injected timer arm failure");
|
|
}
|
|
return harness.clock.setTimeout(callback, delay);
|
|
},
|
|
});
|
|
service.activatePlaylist(playlist.id);
|
|
failNextArm = true;
|
|
|
|
harness.clock.runTo(BASE_TIME + 1_000);
|
|
assert.equal(harness.playback().current_entry_id, "rearm-b");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 1_000);
|
|
assert.equal(harness.stateEvents.at(-1).currentEntryId, "rearm-b");
|
|
assert.equal(harness.clock.nextTimer().at, BASE_TIME + 2_000);
|
|
|
|
harness.clock.runTo(BASE_TIME + 2_000);
|
|
assert.equal(harness.playback().current_entry_id, "rearm-a");
|
|
assert.equal(harness.playback().slide_started_at, BASE_TIME + 2_000);
|
|
assert.equal(harness.playback().next_slide_at, BASE_TIME + 3_000);
|
|
});
|