feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPlaybackService,
|
||||
type PlaybackSnapshot,
|
||||
type SignagePlaybackService,
|
||||
} from "../src/server/playback/service.js";
|
||||
import {
|
||||
createProviderRegistry,
|
||||
type SignageProviderRegistry,
|
||||
} from "../src/server/providers/registry.js";
|
||||
import type { StoredChannelGroupDetail, StoredTimelineDetail } from "../src/server/store/types.js";
|
||||
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
|
||||
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
|
||||
import {
|
||||
announcementsFixture,
|
||||
type FixtureProvider,
|
||||
scriptureFixture,
|
||||
triviaFixture,
|
||||
} from "./support/fixture-provider.js";
|
||||
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||
|
||||
const START = Date.parse("2026-08-30T15:00:00.000Z");
|
||||
|
||||
describe("authoritative playback", () => {
|
||||
let store: MemorySignageStore;
|
||||
let registry: SignageProviderRegistry;
|
||||
let service: SignagePlaybackService;
|
||||
let announcements: FixtureProvider;
|
||||
let trivia: FixtureProvider;
|
||||
let scripture: FixtureProvider;
|
||||
let clock: number;
|
||||
let counter: number;
|
||||
let published: { snapshot: PlaybackSnapshot; reason: string }[];
|
||||
/** Timers are captured rather than run: a test drives the clock, so nothing depends on real time
|
||||
* and a reconciliation is never racing a test's assertions. */
|
||||
let armed: { handler: () => void; delayMs: number } | undefined;
|
||||
|
||||
const newId = () => {
|
||||
counter += 1;
|
||||
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clock = START;
|
||||
counter = 0;
|
||||
published = [];
|
||||
armed = undefined;
|
||||
store = createMemorySignageStore({ now: () => new Date(clock) });
|
||||
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
|
||||
announcements = announcementsFixture();
|
||||
trivia = triviaFixture();
|
||||
scripture = scriptureFixture();
|
||||
registry.capability.register(announcements.registration);
|
||||
registry.capability.register(trivia.registration);
|
||||
registry.capability.register(scripture.registration);
|
||||
service = createPlaybackService({
|
||||
clearTimer: () => {
|
||||
armed = undefined;
|
||||
},
|
||||
newId,
|
||||
now: () => clock,
|
||||
onSnapshot: (snapshot, reason) => published.push({ reason, snapshot }),
|
||||
registry,
|
||||
setTimer: (handler, delayMs) => {
|
||||
armed = { delayMs, handler };
|
||||
return armed;
|
||||
},
|
||||
settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 },
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
/** Advances the clock and fires whatever timer was armed for that moment, the way a real one
|
||||
* would. Nothing here reaches a real timer, so a long outage costs nothing but arithmetic. */
|
||||
async function tick(ms: number): Promise<void> {
|
||||
clock += ms;
|
||||
const pending = armed;
|
||||
armed = undefined;
|
||||
if (pending) {
|
||||
pending.handler();
|
||||
// Let the reconciliation the timer started settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
async function seedGroup(): Promise<StoredChannelGroupDetail> {
|
||||
await service.start();
|
||||
return service.ensureDefaultGroup();
|
||||
}
|
||||
|
||||
/** The pattern from the product definition: three announcements, one trivia, two announcements,
|
||||
* one scripture — the five announcement occurrences sharing one source. */
|
||||
async function seedPattern(): Promise<StoredTimelineDetail> {
|
||||
let detail = await createTimeline({ name: "Sunday morning", newId, store });
|
||||
const timelineId = detail.timeline.id;
|
||||
for (const [providerId, sourceId, label] of [
|
||||
["fixture.announcements", "active-slides", "Announcements"],
|
||||
["fixture.trivia", "open-questions", "Trivia"],
|
||||
["fixture.scripture", "reading-plan", "Scripture"],
|
||||
] as const) {
|
||||
detail = await addProviderSource({ label, newId, providerId, sourceId, store, timelineId });
|
||||
}
|
||||
const [a, t, s] = detail.sources;
|
||||
if (!a || !t || !s) {
|
||||
throw new Error("Expected three sources.");
|
||||
}
|
||||
for (const source of [a, a, a, t, a, a, s]) {
|
||||
detail = await addSlot({ newId, source: { sourceId: source.id }, store, timelineId });
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
async function currentItem(groupId: string): Promise<string | null> {
|
||||
const snapshot = await service.snapshot(groupId);
|
||||
if (snapshot.currentSelectionId === null) {
|
||||
return null;
|
||||
}
|
||||
const selection = await store.findSelection(snapshot.currentSelectionId);
|
||||
return selection?.itemId ?? null;
|
||||
}
|
||||
|
||||
it("creates exactly one default channel group with one default channel, and nothing else", async () => {
|
||||
const group = await seedGroup();
|
||||
|
||||
expect(group.channels).toHaveLength(1);
|
||||
expect(group.channels[0]).toMatchObject({ isDefault: true, key: "default" });
|
||||
// No `in-person`, no `online`. Those are an administrator's decision, not an install-time one.
|
||||
expect(group.channels.map((channel) => channel.key)).toEqual(["default"]);
|
||||
// Calling again is idempotent: an installation has one default group, not one per call.
|
||||
const again = await service.ensureDefaultGroup();
|
||||
expect(again.group.id).toBe(group.group.id);
|
||||
});
|
||||
|
||||
it("plays the operator's pattern, asking each provider once per slot occurrence", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const played: (string | null)[] = [await currentItem(group.group.id)];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await tick(5000);
|
||||
played.push(await currentItem(group.group.id));
|
||||
}
|
||||
|
||||
// Three announcements, one trivia, two announcements, one scripture — with the five
|
||||
// announcement occurrences walking **one** rotation, because they share one selection scope.
|
||||
expect(played).toEqual(["welcome", "greeting", "giving", "q1", "kids", "welcome", "psalm-23"]);
|
||||
expect(announcements.served).toEqual(["welcome", "greeting", "giving", "kids", "welcome"]);
|
||||
expect(trivia.served).toEqual(["q1"]);
|
||||
expect(scripture.served).toEqual(["psalm-23"]);
|
||||
});
|
||||
|
||||
it("resolves one logical selection for every channel in the group, and one variant each", async () => {
|
||||
const group = await seedGroup();
|
||||
await store.addChannel({
|
||||
groupId: group.group.id,
|
||||
id: newId(),
|
||||
isDefault: false,
|
||||
key: "online",
|
||||
name: "Online",
|
||||
});
|
||||
const timeline = await seedPattern();
|
||||
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
// The second announcement is the one with an audience-specific variant.
|
||||
await tick(5000);
|
||||
const snapshot = await service.snapshot(group.group.id);
|
||||
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
|
||||
|
||||
expect(selection?.itemId).toBe("greeting");
|
||||
// Asked once — never once per channel — which is what keeps two channels on the same slide.
|
||||
expect(
|
||||
announcements.resolveRequests.filter((request) => request.audienceKeys.includes("online")),
|
||||
).toHaveLength(announcements.resolveRequests.length);
|
||||
expect(selection?.variants.map((variant) => variant.audienceKey).sort()).toEqual([
|
||||
"default",
|
||||
"online",
|
||||
]);
|
||||
expect(
|
||||
selection?.variants.find((variant) => variant.audienceKey === "online")?.payload,
|
||||
).toEqual({ headline: "Watching from home" });
|
||||
// Both channels read the same start and the same deadline. There is one clock.
|
||||
expect(snapshot.startedAtMs).not.toBeNull();
|
||||
expect(snapshot.deadlineAtMs).toBe((snapshot.startedAtMs ?? 0) + 5000);
|
||||
});
|
||||
|
||||
it("falls back to the default variant for an audience the provider said nothing about", async () => {
|
||||
const group = await seedGroup();
|
||||
await store.addChannel({
|
||||
groupId: group.group.id,
|
||||
id: newId(),
|
||||
isDefault: false,
|
||||
key: "in-person",
|
||||
name: "In person",
|
||||
});
|
||||
const timeline = await seedPattern();
|
||||
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const snapshot = await service.snapshot(group.group.id);
|
||||
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
|
||||
|
||||
// "welcome" has no audience-specific payload at all. That is the contract, not a gap.
|
||||
expect(selection?.variants).toHaveLength(1);
|
||||
expect(selection?.variants[0]).toMatchObject({ audienceKey: "default", isDefault: true });
|
||||
});
|
||||
|
||||
it("chains each slot from the previous deadline, so a late timer costs nothing twice", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const first = await service.snapshot(group.group.id);
|
||||
|
||||
// The timer fires 900 ms late, as a busy process's timer does.
|
||||
await tick(5900);
|
||||
const second = await service.snapshot(group.group.id);
|
||||
|
||||
// The new slot starts at the old slot's deadline — not at "now" — so the lateness is absorbed
|
||||
// once instead of stretching the loop by 900 ms on every slide for the rest of the day.
|
||||
expect(second.startedAtMs).toBe(first.deadlineAtMs);
|
||||
expect(second.deadlineAtMs).toBe((first.deadlineAtMs ?? 0) + 5000);
|
||||
});
|
||||
|
||||
it("catches up a long outage with arithmetic, not by replaying every missed slide", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const callsBefore =
|
||||
announcements.resolveRequests.length +
|
||||
trivia.resolveRequests.length +
|
||||
scripture.resolveRequests.length;
|
||||
|
||||
// A week offline. Seven slots at five seconds each is more than a hundred thousand slides.
|
||||
clock += 7 * 24 * 60 * 60 * 1000;
|
||||
await service.reconcile(group.group.id, "startup");
|
||||
|
||||
const callsAfter =
|
||||
announcements.resolveRequests.length +
|
||||
trivia.resolveRequests.length +
|
||||
scripture.resolveRequests.length;
|
||||
// Exactly one provider call: the slot actually landed on. A replay would have burned the
|
||||
// providers' rotations and taken longer than the outage.
|
||||
expect(callsAfter - callsBefore).toBe(1);
|
||||
const snapshot = await service.snapshot(group.group.id);
|
||||
expect(snapshot.currentSelectionId).not.toBeNull();
|
||||
// And the slot it landed on is genuinely current rather than in the past.
|
||||
expect(snapshot.deadlineAtMs).toBeGreaterThan(clock);
|
||||
});
|
||||
|
||||
it("skips a slot whose provider has nothing right now, immediately and without an error", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
trivia.setItems([]);
|
||||
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
await tick(5000);
|
||||
await tick(5000);
|
||||
// The fourth slot is Trivia, which has nothing. It is skipped in the same instant.
|
||||
const snapshot = await service.snapshot(group.group.id);
|
||||
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
|
||||
|
||||
await tick(5000);
|
||||
const afterTrivia = await service.snapshot(group.group.id);
|
||||
const next = await store.findSelection(afterTrivia.currentSelectionId ?? "");
|
||||
|
||||
expect(selection?.itemId).toBe("giving");
|
||||
// Straight past Trivia to the next announcement. No gap, no black frame, no error.
|
||||
expect(next?.itemId).toBe("kids");
|
||||
expect(next?.startedAt?.getTime()).toBe(selection?.deadlineAt?.getTime());
|
||||
expect(
|
||||
registry.health().find((p) => p.providerId === "fixture.trivia")?.consecutiveFailures,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("goes idle rather than inventing content when nothing at all is available", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
announcements.setItems([]);
|
||||
trivia.setItems([]);
|
||||
scripture.setItems([]);
|
||||
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const snapshot = await service.snapshot(group.group.id);
|
||||
|
||||
expect(snapshot.transport).toBe("idle");
|
||||
expect(snapshot.currentSelectionId).toBeNull();
|
||||
// The display shows its slate. Signage did not fabricate a slide to fill the gap.
|
||||
});
|
||||
|
||||
it("freezes on pause and continues the same slide from its remainder on resume", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const item = await currentItem(group.group.id);
|
||||
|
||||
clock += 2000;
|
||||
const paused = await service.pause(group.group.id);
|
||||
|
||||
expect(paused.transport).toBe("paused");
|
||||
expect(paused.remainingMs).toBe(3000);
|
||||
expect(paused.deadlineAtMs).toBeNull();
|
||||
|
||||
// Two minutes of a paused screen must not advance anything.
|
||||
clock += 120_000;
|
||||
expect(await currentItem(group.group.id)).toBe(item);
|
||||
|
||||
const resumed = await service.resume(group.group.id);
|
||||
expect(resumed.transport).toBe("playing");
|
||||
expect(await currentItem(group.group.id)).toBe(item);
|
||||
// Three seconds left, and the reconstructed window is still a whole slot long.
|
||||
expect(resumed.deadlineAtMs).toBe(clock + 3000);
|
||||
expect((resumed.deadlineAtMs ?? 0) - (resumed.startedAtMs ?? 0)).toBe(5000);
|
||||
});
|
||||
|
||||
it("holds a static image while preserving the cursor underneath, and continues on release", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const item = await currentItem(group.group.id);
|
||||
const imageId = newId();
|
||||
store.images.set(imageId, {
|
||||
contentSha256: "a".repeat(64),
|
||||
createdAt: new Date(clock),
|
||||
displayName: "Hold",
|
||||
encodedHeight: 1080,
|
||||
encodedWidth: 1920,
|
||||
expiresAt: null,
|
||||
hasAlpha: false,
|
||||
height: 1080,
|
||||
id: imageId,
|
||||
orientation: null,
|
||||
originalByteSize: 1,
|
||||
originalContentType: "image/png",
|
||||
originalFilename: "hold.png",
|
||||
originalObjectKey: "assets/hold/original.png",
|
||||
renditions: [],
|
||||
status: "ready",
|
||||
width: 1920,
|
||||
});
|
||||
|
||||
clock += 1000;
|
||||
const held = await service.setStaticHold(group.group.id, imageId);
|
||||
expect(held.transport).toBe("static");
|
||||
expect(held.staticAssetId).toBe(imageId);
|
||||
// The normal cursor is preserved, not discarded.
|
||||
expect(held.remainingMs).toBe(4000);
|
||||
expect(held.currentSelectionId).not.toBeNull();
|
||||
|
||||
clock += 60_000;
|
||||
const released = await service.setStaticHold(group.group.id, null);
|
||||
|
||||
expect(released.transport).toBe("playing");
|
||||
// The same slide continues with the remainder it had, rather than the loop restarting.
|
||||
expect(await currentItem(group.group.id)).toBe(item);
|
||||
expect(released.deadlineAtMs).toBe(clock + 4000);
|
||||
});
|
||||
|
||||
it("keeps the hidden timeline running through a blackout", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
|
||||
const dark = await service.setBlackout(group.group.id, true);
|
||||
expect(dark.blackout).toBe(true);
|
||||
// Blackout covers output. It does not pause, reset, or stop resolving.
|
||||
expect(dark.transport).toBe("playing");
|
||||
expect(dark.deadlineAtMs).not.toBeNull();
|
||||
|
||||
await tick(5000);
|
||||
const later = await service.snapshot(group.group.id);
|
||||
expect(later.blackout).toBe(true);
|
||||
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||
|
||||
const lit = await service.setBlackout(group.group.id, false);
|
||||
// Clearing it lands exactly where the hidden timeline had got to.
|
||||
expect(lit.blackout).toBe(false);
|
||||
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||
});
|
||||
|
||||
it("walks actual resolved playback with previous, including past a skipped slot", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
trivia.setItems([]);
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
await tick(5000);
|
||||
await tick(5000);
|
||||
await tick(5000);
|
||||
expect(await currentItem(group.group.id)).toBe("kids");
|
||||
|
||||
const back = await service.previous(group.group.id);
|
||||
// Trivia was never resolved, so it is not in history. "Previous" means the slide that was
|
||||
// actually on screen, which is the third announcement.
|
||||
expect(await currentItem(group.group.id)).toBe("giving");
|
||||
expect(back.deadlineAtMs).toBe(clock + 5000);
|
||||
|
||||
await service.previous(group.group.id);
|
||||
// And again, further back — not oscillating between two slides.
|
||||
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||
});
|
||||
|
||||
it("ends the current slot immediately on next, and still applies eligibility and wraparound", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
|
||||
clock += 1000;
|
||||
const skipped = await service.next(group.group.id);
|
||||
|
||||
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||
expect(skipped.startedAtMs).toBe(clock);
|
||||
expect(skipped.deadlineAtMs).toBe(clock + 5000);
|
||||
});
|
||||
|
||||
it("keeps the last known good item and marks it stale when a provider fails", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const before = await service.snapshot(group.group.id);
|
||||
announcements.failNext(5);
|
||||
|
||||
await service.reload(group.group.id);
|
||||
const after = await service.snapshot(group.group.id);
|
||||
const selection = await store.findSelection(after.currentSelectionId ?? "");
|
||||
|
||||
// The slide stays up, and the deadline is untouched: a failure is not a reason to blank a wall.
|
||||
expect(after.currentSelectionId).toBe(before.currentSelectionId);
|
||||
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||
expect(selection?.stale).toBe(true);
|
||||
expect(selection?.itemId).toBe("welcome");
|
||||
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||
});
|
||||
|
||||
it("updates the current slide in place when a provider invalidates it, without restarting the slot", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const before = await service.snapshot(group.group.id);
|
||||
const beforeSelection = await store.findSelection(before.currentSelectionId ?? "");
|
||||
|
||||
announcements.reviseItem("welcome", { headline: "Cancelled" });
|
||||
clock += 1500;
|
||||
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
|
||||
|
||||
const after = await service.snapshot(group.group.id);
|
||||
const afterSelection = await store.findSelection(after.currentSelectionId ?? "");
|
||||
|
||||
// Same slide, same slot, same deadline — new payload and a new revision, which is exactly what
|
||||
// updates a mounted React renderer in place.
|
||||
expect(after.currentSelectionId).toBe(before.currentSelectionId);
|
||||
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||
expect(after.selectionRevision).toBe((before.selectionRevision ?? 0) + 1);
|
||||
expect(afterSelection?.variants[0]?.payload).toEqual({ headline: "Cancelled" });
|
||||
expect(beforeSelection?.variants[0]?.payload).toEqual({ headline: "Welcome" });
|
||||
});
|
||||
|
||||
it("does not bump a revision when an invalidation changed nothing", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const before = await service.snapshot(group.group.id);
|
||||
|
||||
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
|
||||
|
||||
const after = await service.snapshot(group.group.id);
|
||||
// A client polling on revision must not be woken by nothing.
|
||||
expect(after.selectionRevision).toBe(before.selectionRevision);
|
||||
});
|
||||
|
||||
it("marks an explicitly withdrawn item and removes it at the next boundary, not mid-slide", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const before = await service.snapshot(group.group.id);
|
||||
|
||||
announcements.withdraw("welcome");
|
||||
clock += 1000;
|
||||
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
|
||||
const during = await service.snapshot(group.group.id);
|
||||
const selection = await store.findSelection(during.currentSelectionId ?? "");
|
||||
|
||||
// Still on screen, still on its own deadline — marked, not cut.
|
||||
expect(during.currentSelectionId).toBe(before.currentSelectionId);
|
||||
expect(during.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||
expect(selection?.withdrawn).toBe(true);
|
||||
|
||||
await tick(4000);
|
||||
// Gone at the boundary — and *which* item follows is the provider's decision, made from a pool
|
||||
// that no longer contains the withdrawn one. Signage neither chose it nor knows why.
|
||||
const next = await currentItem(group.group.id);
|
||||
expect(next).not.toBe("welcome");
|
||||
expect(["greeting", "giving", "kids"]).toContain(next);
|
||||
});
|
||||
|
||||
it("activates immediately, or at the next slot boundary, exactly as asked", async () => {
|
||||
const group = await seedGroup();
|
||||
const first = await seedPattern();
|
||||
const second = await createTimeline({ name: "Evening", newId, store });
|
||||
const withSource = await addProviderSource({
|
||||
label: "Scripture",
|
||||
newId,
|
||||
providerId: "fixture.scripture",
|
||||
sourceId: "reading-plan",
|
||||
store,
|
||||
timelineId: second.timeline.id,
|
||||
});
|
||||
const source = withSource.sources[0];
|
||||
if (!source) {
|
||||
throw new Error("Expected a source.");
|
||||
}
|
||||
await addSlot({
|
||||
newId,
|
||||
source: { sourceId: source.id },
|
||||
store,
|
||||
timelineId: second.timeline.id,
|
||||
});
|
||||
|
||||
await service.activate(group.group.id, first.timeline.id, "now");
|
||||
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||
|
||||
clock += 1000;
|
||||
const queued = await service.activate(group.group.id, second.timeline.id, "next-boundary");
|
||||
// Queued, and the current slide is not cut short.
|
||||
expect(queued.pendingTimelineId).toBe(second.timeline.id);
|
||||
expect(queued.activeTimelineId).toBe(first.timeline.id);
|
||||
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||
|
||||
await tick(4000);
|
||||
const switched = await service.snapshot(group.group.id);
|
||||
expect(switched.activeTimelineId).toBe(second.timeline.id);
|
||||
expect(switched.pendingTimelineId).toBeNull();
|
||||
expect(await currentItem(group.group.id)).toBe("psalm-23");
|
||||
});
|
||||
|
||||
it("applies a scheduled activation at its own instant, not when the timer happened to fire", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
const at = new Date(clock + 60_000);
|
||||
|
||||
const scheduled = await service.activate(group.group.id, timeline.timeline.id, { at });
|
||||
expect(scheduled.scheduledTimelineId).toBe(timeline.timeline.id);
|
||||
expect(scheduled.activeTimelineId).toBeNull();
|
||||
|
||||
// The callback arrives two seconds late, as a real one does.
|
||||
clock += 62_000;
|
||||
const after = await service.reconcile(group.group.id, "timer");
|
||||
|
||||
expect(after.activeTimelineId).toBe(timeline.timeline.id);
|
||||
expect(after.scheduledTimelineId).toBeNull();
|
||||
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||
});
|
||||
|
||||
it("refuses a scheduled activation in the past rather than firing it instantly", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
|
||||
await expect(
|
||||
service.activate(group.group.id, timeline.timeline.id, { at: new Date(clock - 1000) }),
|
||||
).rejects.toMatchObject({ code: "SIGNAGE_INVALID_REQUEST" });
|
||||
});
|
||||
|
||||
it("keeps history bounded and still lets previous work", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
await tick(5000);
|
||||
}
|
||||
|
||||
const history = await store.listSelections(group.group.id, 100);
|
||||
expect(history.length).toBeLessThanOrEqual(20);
|
||||
const back = await service.previous(group.group.id);
|
||||
expect(back.currentSelectionId).not.toBeNull();
|
||||
});
|
||||
|
||||
it("recovers a restart onto the item that was already showing, not a new one", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
const before = await service.snapshot(group.group.id);
|
||||
const callsBefore = announcements.resolveRequests.length;
|
||||
|
||||
// A restart two seconds into a five-second slide: reconciliation runs with the persisted state.
|
||||
clock += 2000;
|
||||
const after = await service.reconcile(group.group.id, "startup");
|
||||
|
||||
expect(after.currentSelectionId).toBe(before.currentSelectionId);
|
||||
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||
// And no provider was asked, so no rotation was consumed by a restart.
|
||||
expect(announcements.resolveRequests.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
it("publishes a compact snapshot, with no payload or timeline in it", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
|
||||
const latest = published.at(-1);
|
||||
expect(latest?.reason).toBe("activated");
|
||||
const serialized = JSON.stringify(latest?.snapshot);
|
||||
// Compact enough for the platform's realtime ceiling many times over, and carrying identity and
|
||||
// revisions rather than content: large state is fetched over authorized HTTP keyed by revision.
|
||||
expect(serialized.length).toBeLessThan(1024);
|
||||
expect(serialized).not.toContain("Welcome");
|
||||
expect(serialized).not.toContain("headline");
|
||||
});
|
||||
|
||||
it("serializes commands so two at once cannot interleave", async () => {
|
||||
const group = await seedGroup();
|
||||
const timeline = await seedPattern();
|
||||
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
service.next(group.group.id),
|
||||
service.next(group.group.id),
|
||||
]);
|
||||
|
||||
// Two "next" presses land on two different slides, in order — never on the same one, and never
|
||||
// on a state neither command asked for.
|
||||
expect(first.currentSelectionId).not.toBe(second.currentSelectionId);
|
||||
expect(second.revision).toBeGreaterThan(first.revision);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user