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]>
This commit is contained in:
@@ -155,15 +155,21 @@ test("active and pending playlists are conservatively protected from deletion",
|
||||
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" }),
|
||||
});
|
||||
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET active_playlist_id = ?, pending_playlist_id = ?,
|
||||
pending_playlist_requested_at = ?, updated_at = ?
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run(active.id, pending.id, BASE_TIME, BASE_TIME);
|
||||
// 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(
|
||||
@@ -171,6 +177,8 @@ test("active and pending playlists are conservatively protected from deletion",
|
||||
"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);
|
||||
});
|
||||
|
||||
@@ -631,10 +639,15 @@ test("empty activation is immediate and current-entry deletion coordinates playb
|
||||
);
|
||||
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
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user