Files
Announcements/server/test/playback-service.test.js
MediaandClaude Opus 5 993986c25e Complete Phase 3B authoritative playback
Audit, fix, and verify the Phase 3B playback engine under Node 22 with a
full backend suite, project lint/build checks, Docker builds, Compose
validation, and isolated runtime playback verification.

Two concrete correctness bugs were found and fixed, each with a
regression test that fails without the fix:

reconcileRotating() evaluated boundaries against a stale entry. After
fastForwardStable() skipped whole cycles it could move the stable cursor,
but the loop kept using the pre-skip entry, so that entry's schedule end
or expiration cut the successor's slide short and shifted every later
absolute deadline. Re-evaluate the loop when the cursor moves.

resumeHeldEntry() could reconstruct a timeline whose slide_started_at was
in the future. A remainder captured under a longer duration was replayed
verbatim after the effective duration shrank, publishing a snapshot whose
current slide had not started yet and whose remaining time exceeded its
own effective duration. Clamp the remainder to the effective duration.

Two Phase 3A tests built playback_state rows by raw SQL that the service
can never hold: an activation queued behind a playlist with no playable
entry, and a static hold that still carried a live absolute timeline.
Both now drive the authoritative service, keeping their original
assertions about deletion protection and revision bookkeeping.

Record the static-target deletion refusal as a known interim integration
point rather than final behavior; playback already tolerates a deleted
static asset, so Phase 3B does not depend on it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:22:03 -05:00

1494 lines
55 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("catch-up past a skipped entry's schedule end keeps the survivor on its own timeline", (t) => {
const harness = createHarness({ defaultDurationMs: 1_000 });
t.after(() => harness.close());
const playlist = harness.seedPlaylist({
entries: [
{ id: "ends-midway", scheduleEndAt: BASE_TIME + 3_500 },
{ id: "survivor" },
],
});
let service = harness.makeService({ start: true });
service.activatePlaylist(playlist.id);
service.stop();
// Missed timeline: ends-midway [0s,1s), survivor [1s,2s), ends-midway
// [2s,3s), survivor [3s,4s), survivor [4s,5s), survivor [5s,6s). The 3.5s
// schedule end belongs to an entry that is not current at that instant, so
// it must not cut the survivor's slide short and shift every later deadline.
harness.clock.set(BASE_TIME + 5_000);
service = harness.makeService({ start: true });
assert.equal(harness.playback().current_entry_id, "survivor");
assert.equal(harness.playback().slide_started_at, BASE_TIME + 5_000);
assert.equal(harness.playback().next_slide_at, BASE_TIME + 6_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("resuming a held slide after a shortened duration never starts it in the future", (t) => {
const harness = createHarness({ defaultDurationMs: 7_000 });
t.after(() => harness.close());
const playlist = harness.seedPlaylist({
entries: [{ id: "held" }, { id: "after-held" }],
});
const service = harness.makeService({ start: true });
service.activatePlaylist(playlist.id);
harness.clock.set(BASE_TIME + 1_000);
service.pause();
assert.equal(harness.playback().paused_remaining_ms, 6_000);
// The operator shortens the global default while the slide is held. The
// preserved remainder can no longer outlive the entry's effective duration,
// or the absolute timeline would claim the slide starts after it resumes.
harness.database
.prepare(
`UPDATE app_settings
SET default_duration_ms = 2000, revision = revision + 1
WHERE id = 1`,
)
.run();
service.domainChanged({ reason: "settings_updated", settingsRevision: 2 });
harness.clock.set(BASE_TIME + 2_000);
service.resume();
const playback = harness.playback();
assert.equal(playback.slide_started_at, BASE_TIME + 2_000);
assert.equal(playback.next_slide_at, BASE_TIME + 4_000);
const snapshot = service.getSnapshot();
assert.ok(
snapshot.timeline.slideStartedAt <= snapshot.serverTime,
"a current slide can never have started in the future",
);
assert.equal(snapshot.timeline.remainingMs, 2_000);
});
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);
});