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