fix(display): drop the previous assignment's frame when the new one has nothing
CI / typecheck (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 26s
CI / test (pull_request) Successful in 28s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s

Moved from a group that was playing to one with nothing to show, a
display adopted the new assignment but kept the old group's last image
on screen indefinitely. With nothing renderable, the controller keeps
whatever frame is up, which is right for a gap in the same assignment
and wrong for a frame from an assignment the display no longer has. The
same early return also skipped persisting, so an outage or a restart
resumed the old group's content from local state.

The controller now records which assignment the frame on screen came
from. When the server says the current assignment has nothing, a frame
from another assignment is taken down (a new stage clear(), which also
abandons any transition still preparing) and the slate shows; and the
new assignment's state is persisted if the stored one belongs to a
different assignment. A gap within the same assignment still keeps its
last good frame, and every path with content is unchanged.

Regression tests: populated-group to empty-group reassignment drops the
old image and shows the slate; and a restart with no server after that
move does not resume the old group's content.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-13 20:38:42 -05:00
co-authored by Claude Opus 5.5
parent ab5b02960a
commit 51d9085cdb
3 changed files with 161 additions and 2 deletions
+122
View File
@@ -546,6 +546,128 @@ describe("display controller", () => {
await controller.stop();
});
/**
* Stage 0: moved from a group that was playing to one with nothing to show, the display adopted
* the new assignment and kept the old group's last image on screen indefinitely. "Keep the last
* good frame" protects a frame of the *same* assignment from a gap in it; a frame from an
* assignment this display no longer has is not a good frame, it is stale content.
*/
function movedToEmptyGroup(store: ReturnType<typeof createMemoryStore>) {
const moved = { groupId: "group-2", id: "chan-2", key: "online" };
let stateFetches = 0;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: /^viewers\/[^/]+\/display-state$/u,
respond: () => {
stateFetches += 1;
// Playing in group-1; moved to group-2, which has nothing, right after its first fetch.
return jsonResponse(
state(
stateFetches === 1
? {
timeline: {
eligibleFromMs: null,
eligibleUntilMs: null,
id: "timeline-1",
revision: 1,
slots: [
{
eligibleFromMs: null,
eligibleUntilMs: null,
enabled: true,
estimatedDurationMs: 5000,
id: "slot-1",
position: 0,
},
],
},
}
: {
channel: moved,
current: null,
playback: {
activeTimelineId: null,
blackout: false,
currentSelectionId: null,
currentSlotId: null,
deadlineAtMs: null,
pendingTimelineId: null,
remainingMs: null,
revision: 1,
selectionRevision: null,
startedAtMs: null,
transport: "playing",
},
selections: [],
},
),
);
},
},
{
method: "POST",
path: /^viewers\/.*\/heartbeat$/u,
respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }),
},
],
});
return { fetches: () => stateFetches, host, store };
}
it("stops showing the previous group's image when moved to a group with nothing to show", async () => {
const media = createMemoryMediaCache();
const scenario = movedToEmptyGroup(createMemoryStore());
const controller = build({ host: scenario.host, media, store: scenario.store });
await controller.start();
await settle();
expect(scenario.fetches()).toBe(2);
const diagnostics = controller.diagnostics();
expect(diagnostics.channelId).toBe("chan-2");
// The old group's image is gone — not merely covered — and the wall says so plainly.
expect(diagnostics.renderedSelectionId).toBeUndefined();
expect(diagnostics.renderedKey).toBeUndefined();
expect(diagnostics.slate).toBe(true);
expect(root.dataset.slate).toBe("true");
expect(root.querySelector("img.signage-layer__foreground[src]")).toBeNull();
await controller.stop();
});
it("does not bring the previous group's content back from local state after that move", async () => {
const media = createMemoryMediaCache();
const scenario = movedToEmptyGroup(createMemoryStore());
const first = build({ host: scenario.host, media, store: scenario.store });
await first.start();
await settle();
await first.stop();
// The kiosk restarts while the server is unreachable. It resumes from what it persisted — which
// must be its current assignment, not the group it was moved away from.
const unreachable = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
throw new Error("network is gone");
},
},
],
});
const restarted = build({ host: unreachable, media, store: scenario.store });
await restarted.start();
await settle();
expect(restarted.diagnostics().renderedSelectionId).toBeUndefined();
expect(root.dataset.slate).toBe("true");
await restarted.stop();
});
it("registers again, exactly once, when it was removed from the roster", async () => {
let sessions = 0;
let subscriptions = 0;