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:
@@ -288,6 +288,30 @@ test("long restart catch-up skips whole cycles across an eligibility boundary",
|
||||
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());
|
||||
@@ -882,6 +906,45 @@ test("resume clears static and pause together while preserving the suspended nor
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user