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]>
707 lines
25 KiB
JavaScript
707 lines
25 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const crypto = require("node:crypto");
|
|
const { openDatabase } = require("../src/database");
|
|
const { runMigrations } = require("../src/database/migrate");
|
|
const { createAssetService } = require("../src/assets/service");
|
|
const { createPlaybackService } = require("../src/playback");
|
|
const {
|
|
MAX_DURATION_MS,
|
|
MIN_DURATION_MS,
|
|
createPlaylistService,
|
|
} = require("../src/playlists/service");
|
|
|
|
const BASE_TIME = 2_000_000_000_000;
|
|
|
|
function createHarness() {
|
|
const database = openDatabase({ path: ":memory:" });
|
|
runMigrations(database);
|
|
let clock = BASE_TIME;
|
|
let idCounter = 0;
|
|
let assetCounter = 0;
|
|
const changes = [];
|
|
let playbackService;
|
|
const service = createPlaylistService({
|
|
database,
|
|
now: () => clock,
|
|
createId: () =>
|
|
`00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`,
|
|
onBeforeChange: () => {
|
|
playbackService?.reconcile("before_domain_change");
|
|
},
|
|
onChanged: (change) => {
|
|
changes.push(change);
|
|
playbackService?.domainChanged(change);
|
|
},
|
|
});
|
|
playbackService = createPlaybackService({ database, now: () => clock });
|
|
playbackService.initialize();
|
|
|
|
function setNow(value) {
|
|
clock = value;
|
|
}
|
|
|
|
function seedAsset(options = {}) {
|
|
const sequence = ++assetCounter;
|
|
const id = options.id || `asset-${sequence}`;
|
|
const name = options.name || id;
|
|
const status = options.status || "ready";
|
|
const expirationAt = options.expirationAt ?? null;
|
|
const hash = crypto
|
|
.createHash("sha256")
|
|
.update(`${sequence}:${id}`)
|
|
.digest("hex");
|
|
database
|
|
.prepare(
|
|
`INSERT INTO assets (
|
|
id, source_sha256, display_name, original_filename, status,
|
|
expiration_at, display_sha256, display_width, display_height,
|
|
thumbnail_sha256, thumbnail_width, thumbnail_height,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 1920, 1080, ?, 480, 270, ?, ?)`,
|
|
)
|
|
.run(
|
|
id,
|
|
hash,
|
|
name,
|
|
`${name}.png`,
|
|
status,
|
|
expirationAt,
|
|
hash,
|
|
hash,
|
|
clock,
|
|
clock,
|
|
);
|
|
return id;
|
|
}
|
|
|
|
function playback() {
|
|
return database
|
|
.prepare("SELECT * FROM playback_state WHERE output_id = 'global'")
|
|
.get();
|
|
}
|
|
|
|
return {
|
|
changes,
|
|
database,
|
|
playback,
|
|
playbackService,
|
|
seedAsset,
|
|
service,
|
|
setNow,
|
|
};
|
|
}
|
|
|
|
function expectError(action, code, statusCode) {
|
|
assert.throws(action, (error) => {
|
|
assert.equal(error.code, code);
|
|
if (statusCode !== undefined) assert.equal(error.statusCode, statusCode);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
test("playlist CRUD uses UUIDs, case-insensitive names, revisions, and post-commit events", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
|
|
const sunday = harness.service.create({ name: " Sunday Service " });
|
|
const midweek = harness.service.create({ name: "Midweek" });
|
|
assert.match(sunday.id, /^[0-9a-f-]{36}$/);
|
|
assert.equal(sunday.name, "Sunday Service");
|
|
assert.equal(sunday.revision, 1);
|
|
assert.deepEqual(
|
|
harness.service.list().map((playlist) => playlist.name),
|
|
["Midweek", "Sunday Service"],
|
|
);
|
|
|
|
const revisionBeforeRead = harness.service.list()[1].revision;
|
|
const changesBeforeRead = harness.changes.length;
|
|
assert.equal(harness.service.get(sunday.id).entries.length, 0);
|
|
assert.equal(harness.service.list()[1].revision, revisionBeforeRead);
|
|
assert.equal(harness.changes.length, changesBeforeRead);
|
|
|
|
const renamed = harness.service.update(sunday.id, { name: "Sunday Worship" });
|
|
assert.equal(renamed.revision, 2);
|
|
assert.equal(harness.service.update(sunday.id, { name: "Sunday Worship" }).revision, 2);
|
|
expectError(
|
|
() => harness.service.create({ name: "sUnDaY wOrShIp" }),
|
|
"PLAYLIST_NAME_CONFLICT",
|
|
409,
|
|
);
|
|
expectError(
|
|
() => harness.service.update(midweek.id, { name: "SUNDAY WORSHIP" }),
|
|
"PLAYLIST_NAME_CONFLICT",
|
|
409,
|
|
);
|
|
assert.equal(harness.service.get(midweek.id).revision, 1);
|
|
|
|
assert.deepEqual(harness.service.remove(midweek.id), {
|
|
id: midweek.id,
|
|
deleted: true,
|
|
});
|
|
expectError(
|
|
() => harness.service.get(midweek.id),
|
|
"PLAYLIST_NOT_FOUND",
|
|
404,
|
|
);
|
|
assert.deepEqual(
|
|
harness.changes.map((change) => change.reason),
|
|
["created", "created", "renamed", "deleted"],
|
|
);
|
|
});
|
|
|
|
test("active and pending playlists are conservatively protected from deletion", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const active = harness.service.create({ name: "Active" });
|
|
const pending = harness.service.create({ name: "Pending" });
|
|
harness.service.addEntry(active.id, {
|
|
assetId: harness.seedAsset({ id: "held", name: "Held" }),
|
|
});
|
|
harness.service.addEntry(pending.id, {
|
|
assetId: harness.seedAsset({ id: "queued", name: "Queued" }),
|
|
});
|
|
|
|
// A mid-duration normal slide is what keeps an activation genuinely pending.
|
|
// With no playable normal target the authority promotes the request straight
|
|
// away, so an activation cannot be left queued behind an empty playlist.
|
|
harness.playbackService.activatePlaylist(active.id);
|
|
assert.equal(
|
|
harness.playbackService.activatePlaylist(pending.id).activation,
|
|
"pending",
|
|
);
|
|
|
|
expectError(() => harness.service.remove(active.id), "PLAYLIST_ACTIVE", 409);
|
|
expectError(
|
|
() => harness.service.remove(pending.id),
|
|
"PLAYLIST_PENDING_ACTIVATION",
|
|
409,
|
|
);
|
|
assert.equal(harness.playback().active_playlist_id, active.id);
|
|
assert.equal(harness.playback().pending_playlist_id, pending.id);
|
|
assert.equal(harness.service.list().length, 2);
|
|
});
|
|
|
|
test("entry adds require a ready, unexpired asset and enforce one occurrence per playlist", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const first = harness.service.create({ name: "First" });
|
|
const second = harness.service.create({ name: "Second" });
|
|
const ready = harness.seedAsset({ id: "ready", name: "Ready" });
|
|
for (const status of ["processing", "failed", "deleting"]) {
|
|
harness.seedAsset({ id: status, name: status, status });
|
|
}
|
|
harness.seedAsset({
|
|
id: "expired",
|
|
name: "Expired",
|
|
expirationAt: BASE_TIME,
|
|
});
|
|
|
|
const firstEntry = harness.service.addEntry(first.id, { assetId: ready });
|
|
const secondEntry = harness.service.addEntry(second.id, { assetId: ready });
|
|
assert.equal(firstEntry.position, 0);
|
|
assert.equal(secondEntry.position, 0);
|
|
assert.notEqual(firstEntry.id, secondEntry.id);
|
|
expectError(
|
|
() => harness.service.addEntry(first.id, { assetId: ready }),
|
|
"PLAYLIST_ENTRY_EXISTS",
|
|
409,
|
|
);
|
|
for (const status of ["processing", "failed", "deleting"]) {
|
|
expectError(
|
|
() => harness.service.addEntry(first.id, { assetId: status }),
|
|
"ASSET_NOT_READY",
|
|
409,
|
|
);
|
|
}
|
|
expectError(
|
|
() => harness.service.addEntry(first.id, { assetId: "missing" }),
|
|
"ASSET_NOT_FOUND",
|
|
404,
|
|
);
|
|
expectError(
|
|
() => harness.service.addEntry(first.id, { assetId: "expired" }),
|
|
"ASSET_EXPIRED",
|
|
409,
|
|
);
|
|
assert.equal(harness.service.get(first.id).revision, 2);
|
|
});
|
|
|
|
test("entry enablement, duration, and presentation overrides are playlist-local", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const a = harness.service.create({ name: "A" });
|
|
const b = harness.service.create({ name: "B" });
|
|
const assetId = harness.seedAsset({ id: "shared", name: "Shared" });
|
|
const entryA = harness.service.addEntry(a.id, { assetId });
|
|
const entryB = harness.service.addEntry(b.id, { assetId });
|
|
|
|
assert.equal(entryA.durationOverrideMs, null);
|
|
assert.equal(entryA.effectiveDurationMs, 7000);
|
|
assert.deepEqual(entryA.effectivePresentation, {
|
|
backgroundMode: "blurred",
|
|
backgroundColor: "#000000",
|
|
backgroundDarkened: false,
|
|
blurPx: 20,
|
|
});
|
|
|
|
const patched = harness.service.updateEntry(a.id, entryA.id, {
|
|
enabled: false,
|
|
durationOverrideMs: 4000,
|
|
backgroundModeOverride: "solid",
|
|
backgroundColorOverride: "#a1b2c3",
|
|
backgroundDarkenedOverride: true,
|
|
});
|
|
assert.equal(patched.enabled, false);
|
|
assert.equal(patched.effectiveDurationMs, 4000);
|
|
assert.deepEqual(patched.effectivePresentation, {
|
|
backgroundMode: "solid",
|
|
backgroundColor: "#A1B2C3",
|
|
backgroundDarkened: true,
|
|
blurPx: 20,
|
|
});
|
|
assert.equal(harness.service.get(b.id).entries[0].enabled, true);
|
|
assert.equal(harness.service.get(b.id).entries[0].effectiveDurationMs, 7000);
|
|
assert.equal(
|
|
harness.database.prepare("SELECT status FROM assets WHERE id = ?").get(assetId)
|
|
.status,
|
|
"ready",
|
|
);
|
|
|
|
const inheritedAgain = harness.service.updateEntry(a.id, entryA.id, {
|
|
durationOverrideMs: null,
|
|
backgroundModeOverride: null,
|
|
backgroundColorOverride: null,
|
|
backgroundDarkenedOverride: null,
|
|
});
|
|
assert.equal(inheritedAgain.effectiveDurationMs, 7000);
|
|
assert.equal(inheritedAgain.backgroundDarkenedOverride, null);
|
|
|
|
for (const invalid of [MIN_DURATION_MS - 1, MAX_DURATION_MS + 1, 1.5]) {
|
|
expectError(
|
|
() =>
|
|
harness.service.updateEntry(a.id, entryA.id, {
|
|
durationOverrideMs: invalid,
|
|
}),
|
|
"INVALID_PLAYLIST_ENTRY",
|
|
400,
|
|
);
|
|
}
|
|
for (const patch of [
|
|
{ backgroundModeOverride: "gradient" },
|
|
{ backgroundColorOverride: "red" },
|
|
{ backgroundDarkenedOverride: 1 },
|
|
]) {
|
|
expectError(
|
|
() => harness.service.updateEntry(a.id, entryA.id, patch),
|
|
"INVALID_PLAYLIST_ENTRY",
|
|
400,
|
|
);
|
|
}
|
|
assert.equal(harness.service.get(a.id).revision, 4);
|
|
assert.equal(harness.service.get(b.id).revision, 2);
|
|
assert.equal(entryB.enabled, true);
|
|
});
|
|
|
|
test("resolved entries centralize schedule and expiration eligibility at exact boundaries", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const playlist = harness.service.create({ name: "Scheduled" });
|
|
const permanent = harness.seedAsset({ id: "permanent", name: "Permanent" });
|
|
const futureExpiry = harness.seedAsset({
|
|
id: "future-expiry",
|
|
name: "Future expiry",
|
|
expirationAt: BASE_TIME + 20_000,
|
|
});
|
|
const noSchedule = harness.service.addEntry(playlist.id, { assetId: permanent });
|
|
const bounded = harness.service.addEntry(playlist.id, {
|
|
assetId: futureExpiry,
|
|
scheduleStartAt: BASE_TIME + 10_000,
|
|
scheduleEndAt: BASE_TIME + 30_000,
|
|
});
|
|
|
|
let detail = harness.service.get(playlist.id);
|
|
assert.equal(detail.evaluatedAt, BASE_TIME);
|
|
assert.equal(detail.settingsRevision, 1);
|
|
assert.deepEqual(
|
|
detail.entries.map((entry) => [entry.id, entry.playable, entry.notPlayableReason]),
|
|
[
|
|
[noSchedule.id, true, null],
|
|
[bounded.id, false, "scheduled_future"],
|
|
],
|
|
);
|
|
|
|
harness.setNow(BASE_TIME + 10_000);
|
|
assert.equal(harness.service.get(playlist.id).entries[1].playable, true);
|
|
harness.setNow(BASE_TIME + 20_000);
|
|
assert.equal(
|
|
harness.service.get(playlist.id).entries[1].notPlayableReason,
|
|
"asset_expired",
|
|
);
|
|
harness.setNow(BASE_TIME + 30_000);
|
|
assert.equal(
|
|
harness.service.get(playlist.id).entries[1].notPlayableReason,
|
|
"scheduled_ended",
|
|
);
|
|
|
|
harness.setNow(BASE_TIME);
|
|
const endOnlyAsset = harness.seedAsset({ id: "end-only", name: "End only" });
|
|
const endOnly = harness.service.addEntry(playlist.id, {
|
|
assetId: endOnlyAsset,
|
|
scheduleEndAt: BASE_TIME + 1,
|
|
});
|
|
assert.equal(endOnly.playable, true);
|
|
harness.setNow(BASE_TIME + 1);
|
|
assert.equal(
|
|
harness.service.get(playlist.id).entries.find((entry) => entry.id === endOnly.id)
|
|
.notPlayableReason,
|
|
"scheduled_ended",
|
|
);
|
|
|
|
expectError(
|
|
() =>
|
|
harness.service.updateEntry(playlist.id, noSchedule.id, {
|
|
scheduleStartAt: BASE_TIME + 2,
|
|
scheduleEndAt: BASE_TIME + 1,
|
|
}),
|
|
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
|
|
400,
|
|
);
|
|
expectError(
|
|
() =>
|
|
harness.service.updateEntry(playlist.id, noSchedule.id, {
|
|
scheduleStartAt: "2033-05-18T03:33:20",
|
|
}),
|
|
"INVALID_PLAYLIST_ENTRY",
|
|
400,
|
|
);
|
|
const iso = harness.service.updateEntry(playlist.id, noSchedule.id, {
|
|
scheduleStartAt: "2033-05-18T03:33:20Z",
|
|
});
|
|
assert.equal(iso.scheduleStartAt, 2_000_000_000_000);
|
|
});
|
|
|
|
test("full-list reorder validates membership and persists contiguous zero-based positions", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const playlist = harness.service.create({ name: "Ordered" });
|
|
const other = harness.service.create({ name: "Other" });
|
|
const entries = ["one", "two", "three"].map((id) =>
|
|
harness.service.addEntry(playlist.id, {
|
|
assetId: harness.seedAsset({ id, name: id }),
|
|
}),
|
|
);
|
|
const foreign = harness.service.addEntry(other.id, {
|
|
assetId: harness.seedAsset({ id: "foreign", name: "foreign" }),
|
|
});
|
|
const revision = harness.service.get(playlist.id).revision;
|
|
|
|
const reordered = harness.service.reorderEntries(
|
|
playlist.id,
|
|
[entries[2].id, entries[0].id, entries[1].id],
|
|
);
|
|
assert.deepEqual(
|
|
reordered.entries.map((entry) => [entry.id, entry.position]),
|
|
[
|
|
[entries[2].id, 0],
|
|
[entries[0].id, 1],
|
|
[entries[1].id, 2],
|
|
],
|
|
);
|
|
assert.equal(reordered.revision, revision + 1);
|
|
|
|
const failedRevision = reordered.revision;
|
|
expectError(
|
|
() =>
|
|
harness.service.reorderEntries(playlist.id, [
|
|
entries[2].id,
|
|
entries[2].id,
|
|
entries[1].id,
|
|
]),
|
|
"PLAYLIST_ORDER_DUPLICATE_IDS",
|
|
);
|
|
expectError(
|
|
() => harness.service.reorderEntries(playlist.id, [entries[2].id]),
|
|
"PLAYLIST_ORDER_INCOMPLETE",
|
|
);
|
|
expectError(
|
|
() =>
|
|
harness.service.reorderEntries(playlist.id, [
|
|
entries[2].id,
|
|
entries[0].id,
|
|
foreign.id,
|
|
]),
|
|
"PLAYLIST_ORDER_FOREIGN_ENTRY",
|
|
);
|
|
expectError(
|
|
() =>
|
|
harness.service.reorderEntries(playlist.id, [
|
|
entries[2].id,
|
|
entries[0].id,
|
|
"missing-entry",
|
|
]),
|
|
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
|
|
);
|
|
assert.equal(harness.service.get(playlist.id).revision, failedRevision);
|
|
|
|
harness.service.removeEntry(playlist.id, entries[0].id);
|
|
assert.deepEqual(
|
|
harness.service.get(playlist.id).entries.map((entry) => entry.position),
|
|
[0, 1],
|
|
);
|
|
});
|
|
|
|
test("no-op and failed mutations do not increment revisions or emit invalidations", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const playlist = harness.service.create({ name: "Stable" });
|
|
const entry = harness.service.addEntry(playlist.id, {
|
|
assetId: harness.seedAsset({ id: "stable-asset", name: "Stable asset" }),
|
|
});
|
|
const baseline = harness.service.get(playlist.id).revision;
|
|
const eventCount = harness.changes.length;
|
|
|
|
harness.service.update(playlist.id, { name: "Stable" });
|
|
harness.service.updateEntry(playlist.id, entry.id, { enabled: true });
|
|
harness.service.reorderEntries(playlist.id, [entry.id]);
|
|
expectError(
|
|
() => harness.service.updateEntry(playlist.id, "missing", { enabled: false }),
|
|
"PLAYLIST_ENTRY_NOT_FOUND",
|
|
404,
|
|
);
|
|
assert.equal(harness.service.get(playlist.id).revision, baseline);
|
|
assert.equal(harness.changes.length, eventCount);
|
|
});
|
|
|
|
test("entry references protect assets and final removal restores delete eligibility", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const playlist = harness.service.create({ name: "References" });
|
|
const assetId = harness.seedAsset({ id: "referenced", name: "Referenced" });
|
|
const entry = harness.service.addEntry(playlist.id, { assetId, enabled: false });
|
|
const assetService = createAssetService({
|
|
database: harness.database,
|
|
storage: {},
|
|
imageProcessor: {},
|
|
});
|
|
|
|
const referenced = assetService.references(assetId);
|
|
assert.equal(referenced.canDelete, false);
|
|
assert.equal(referenced.playlists[0].playlistEntryId, entry.id);
|
|
harness.service.removeEntry(playlist.id, entry.id);
|
|
assert.equal(assetService.references(assetId).canDelete, true);
|
|
});
|
|
|
|
test("activation queues at a live boundary, is idempotent, and mode off is only a gate", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const oldPlaylist = harness.service.create({ name: "Old" });
|
|
const newPlaylist = harness.service.create({ name: "New" });
|
|
const oldEntry = harness.service.addEntry(oldPlaylist.id, {
|
|
assetId: harness.seedAsset({ id: "old-asset", name: "Old asset" }),
|
|
});
|
|
const newEntry = harness.service.addEntry(newPlaylist.id, {
|
|
assetId: harness.seedAsset({ id: "new-asset", name: "New asset" }),
|
|
durationOverrideMs: 4000,
|
|
});
|
|
|
|
const initial = harness.playbackService.activatePlaylist(oldPlaylist.id);
|
|
assert.equal(initial.activation, "active");
|
|
assert.equal(initial.playback.currentEntryId, oldEntry.id);
|
|
assert.equal(initial.playback.slideStartedAt, BASE_TIME);
|
|
assert.equal(initial.playback.nextSlideAt, BASE_TIME + 7000);
|
|
|
|
harness.database
|
|
.prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'")
|
|
.run();
|
|
harness.setNow(BASE_TIME + 1000);
|
|
const pending = harness.playbackService.activatePlaylist(newPlaylist.id);
|
|
assert.equal(pending.activation, "pending");
|
|
assert.equal(pending.playback.mode, "off");
|
|
assert.equal(pending.playback.activePlaylistId, oldPlaylist.id);
|
|
assert.equal(pending.playback.pendingPlaylistId, newPlaylist.id);
|
|
assert.equal(pending.playback.currentEntryId, oldEntry.id);
|
|
const pendingRevision = pending.playback.revision;
|
|
const requestedAt = pending.playback.pendingPlaylistRequestedAt;
|
|
|
|
const repeated = harness.playbackService.activatePlaylist(newPlaylist.id);
|
|
assert.equal(repeated.activation, "pending");
|
|
assert.equal(repeated.playback.revision, pendingRevision);
|
|
assert.equal(repeated.playback.pendingPlaylistRequestedAt, requestedAt);
|
|
|
|
const cancel = harness.playbackService.activatePlaylist(oldPlaylist.id);
|
|
assert.equal(cancel.activation, "already_active");
|
|
assert.equal(cancel.playback.pendingPlaylistId, null);
|
|
assert.equal(cancel.playback.currentEntryId, oldEntry.id);
|
|
|
|
harness.setNow(BASE_TIME + 7000);
|
|
const immediate = harness.playbackService.activatePlaylist(newPlaylist.id);
|
|
assert.equal(immediate.activation, "active");
|
|
assert.equal(immediate.playback.activePlaylistId, newPlaylist.id);
|
|
assert.equal(immediate.playback.pendingPlaylistId, null);
|
|
assert.equal(immediate.playback.currentEntryId, newEntry.id);
|
|
assert.equal(immediate.playback.slideStartedAt, BASE_TIME + 7000);
|
|
assert.equal(immediate.playback.nextSlideAt, BASE_TIME + 11_000);
|
|
assert.equal(immediate.playback.mode, "off");
|
|
});
|
|
|
|
test("paused normal playback queues and can replace a pending activation", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const current = harness.service.create({ name: "Paused current" });
|
|
const firstTarget = harness.service.create({ name: "First target" });
|
|
const secondTarget = harness.service.create({ name: "Second target" });
|
|
harness.service.addEntry(current.id, {
|
|
assetId: harness.seedAsset({ id: "paused-current", name: "Paused current" }),
|
|
});
|
|
harness.service.addEntry(firstTarget.id, {
|
|
assetId: harness.seedAsset({ id: "first-target", name: "First target" }),
|
|
});
|
|
harness.service.addEntry(secondTarget.id, {
|
|
assetId: harness.seedAsset({ id: "second-target", name: "Second target" }),
|
|
});
|
|
harness.playbackService.activatePlaylist(current.id);
|
|
harness.database
|
|
.prepare(
|
|
`UPDATE playback_state
|
|
SET rotation_paused = 1, paused_remaining_ms = 5000,
|
|
slide_started_at = NULL, next_slide_at = NULL
|
|
WHERE output_id = 'global'`,
|
|
)
|
|
.run();
|
|
|
|
harness.setNow(BASE_TIME + 1000);
|
|
const first = harness.playbackService.activatePlaylist(firstTarget.id);
|
|
assert.equal(first.activation, "pending");
|
|
assert.equal(first.playback.activePlaylistId, current.id);
|
|
assert.equal(first.playback.pendingPlaylistId, firstTarget.id);
|
|
assert.equal(first.playback.pausedRemainingMs, 5000);
|
|
|
|
harness.setNow(BASE_TIME + 2000);
|
|
const replacement = harness.playbackService.activatePlaylist(secondTarget.id);
|
|
assert.equal(replacement.activation, "pending");
|
|
assert.equal(replacement.playback.activePlaylistId, current.id);
|
|
assert.equal(replacement.playback.pendingPlaylistId, secondTarget.id);
|
|
assert.equal(replacement.playback.pendingPlaylistRequestedAt, BASE_TIME + 2000);
|
|
assert.equal(replacement.playback.revision, first.playback.revision + 1);
|
|
assert.equal(replacement.playback.pausedRemainingMs, 5000);
|
|
});
|
|
|
|
test("static and paused activation prepare the first playable normal target without breaking hold", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const oldPlaylist = harness.service.create({ name: "Old static" });
|
|
const target = harness.service.create({ name: "Target static" });
|
|
const staticAssetId = harness.seedAsset({ id: "static", name: "Static" });
|
|
harness.service.addEntry(oldPlaylist.id, {
|
|
assetId: harness.seedAsset({ id: "old-normal", name: "Old normal" }),
|
|
});
|
|
const skipped = harness.service.addEntry(target.id, {
|
|
assetId: harness.seedAsset({ id: "disabled", name: "Disabled" }),
|
|
enabled: false,
|
|
});
|
|
const firstPlayable = harness.service.addEntry(target.id, {
|
|
assetId: harness.seedAsset({ id: "playable", name: "Playable" }),
|
|
durationOverrideMs: 9000,
|
|
});
|
|
assert.equal(skipped.position, 0);
|
|
harness.playbackService.activatePlaylist(oldPlaylist.id);
|
|
harness.database
|
|
.prepare(
|
|
`UPDATE playback_state
|
|
SET static_asset_id = ?, rotation_paused = 1,
|
|
paused_remaining_ms = 1234, slide_started_at = NULL,
|
|
next_slide_at = NULL
|
|
WHERE output_id = 'global'`,
|
|
)
|
|
.run(staticAssetId);
|
|
|
|
const result = harness.playbackService.activatePlaylist(target.id);
|
|
assert.equal(result.activation, "active");
|
|
assert.equal(result.playback.activePlaylistId, target.id);
|
|
assert.equal(result.playback.staticAssetId, staticAssetId);
|
|
assert.equal(result.playback.rotationPaused, true);
|
|
assert.equal(result.playback.currentEntryId, firstPlayable.id);
|
|
assert.equal(result.playback.pausedRemainingMs, 9000);
|
|
assert.equal(result.playback.slideStartedAt, null);
|
|
assert.equal(result.playback.nextSlideAt, null);
|
|
});
|
|
|
|
test("empty activation is immediate and current-entry deletion coordinates playback revision safely", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const populated = harness.service.create({ name: "Populated" });
|
|
const empty = harness.service.create({ name: "Empty" });
|
|
const entries = ["first", "second"].map((id) =>
|
|
harness.service.addEntry(populated.id, {
|
|
assetId: harness.seedAsset({ id: `asset-${id}`, name: id }),
|
|
}),
|
|
);
|
|
harness.playbackService.activatePlaylist(populated.id);
|
|
const futurePlaybackTimestamp = BASE_TIME + 100_000;
|
|
// Static and pause suspend the normal timeline. Persist the same shape the
|
|
// playback authority itself writes, so the pre-change reconciliation has no
|
|
// malformed timeline to repair and only the deletion moves the revision.
|
|
harness.database
|
|
.prepare(
|
|
`UPDATE playback_state
|
|
SET updated_at = ?, mode = 'off', static_asset_id = ?,
|
|
rotation_paused = 1, paused_remaining_ms = 7000,
|
|
slide_started_at = NULL, next_slide_at = NULL
|
|
WHERE output_id = 'global'`,
|
|
)
|
|
.run(futurePlaybackTimestamp, "asset-second");
|
|
const beforeDelete = harness.playback();
|
|
|
|
harness.service.removeEntry(populated.id, entries[0].id);
|
|
const afterDelete = harness.playback();
|
|
assert.equal(afterDelete.current_entry_id, entries[1].id);
|
|
assert.equal(afterDelete.slide_started_at, null);
|
|
assert.equal(afterDelete.next_slide_at, null);
|
|
assert.equal(afterDelete.paused_remaining_ms, 7000);
|
|
assert.equal(afterDelete.active_playlist_id, populated.id);
|
|
assert.equal(afterDelete.mode, "off");
|
|
assert.equal(afterDelete.static_asset_id, "asset-second");
|
|
assert.equal(afterDelete.rotation_paused, 1);
|
|
assert.equal(afterDelete.revision, beforeDelete.revision + 1);
|
|
assert.equal(afterDelete.updated_at, futurePlaybackTimestamp + 1);
|
|
assert.deepEqual(
|
|
harness.service.get(populated.id).entries.map((entry) => entry.position),
|
|
[0],
|
|
);
|
|
|
|
harness.database
|
|
.prepare(
|
|
`UPDATE playback_state
|
|
SET static_asset_id = NULL, rotation_paused = 0
|
|
WHERE output_id = 'global'`,
|
|
)
|
|
.run();
|
|
const result = harness.playbackService.activatePlaylist(empty.id);
|
|
assert.equal(result.activation, "active");
|
|
assert.equal(result.playback.activePlaylistId, empty.id);
|
|
assert.equal(result.playback.currentEntryId, null);
|
|
assert.equal(result.playback.slideStartedAt, null);
|
|
assert.equal(result.playback.nextSlideAt, null);
|
|
});
|
|
|
|
test("inactive playlist edits do not mutate authoritative playback state", (t) => {
|
|
const harness = createHarness();
|
|
t.after(() => harness.database.close());
|
|
const active = harness.service.create({ name: "Live" });
|
|
const inactive = harness.service.create({ name: "Draft" });
|
|
harness.service.addEntry(active.id, {
|
|
assetId: harness.seedAsset({ id: "live", name: "Live asset" }),
|
|
});
|
|
const draftEntry = harness.service.addEntry(inactive.id, {
|
|
assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }),
|
|
});
|
|
harness.playbackService.activatePlaylist(active.id);
|
|
const before = harness.playback();
|
|
|
|
harness.service.update(inactive.id, { name: "Draft edited" });
|
|
harness.service.updateEntry(inactive.id, draftEntry.id, { enabled: false });
|
|
harness.service.reorderEntries(inactive.id, [draftEntry.id]);
|
|
assert.deepEqual(harness.playback(), before);
|
|
});
|