632 lines
21 KiB
TypeScript
632 lines
21 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { buildDisplayChannelState } from "../src/server/display/state.js";
|
|
import { SignageError } from "../src/server/errors.js";
|
|
import { ingestNativeImage } from "../src/server/images/service.js";
|
|
import {
|
|
createPlaybackService,
|
|
type SignagePlaybackService,
|
|
} from "../src/server/playback/service.js";
|
|
import { createNativeImageProvider } from "../src/server/providers/native-provider.js";
|
|
import {
|
|
createProviderRegistry,
|
|
type SignageProviderRegistry,
|
|
} from "../src/server/providers/registry.js";
|
|
import type { StoredChannel } from "../src/server/store/types.js";
|
|
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
|
|
import { createDisplaysCapability } from "../src/server/viewers/capability.js";
|
|
import {
|
|
describeViewerHealth,
|
|
openViewerSession,
|
|
recordViewerHeartbeat,
|
|
SIGNAGE_VIEWER_OFFLINE_AFTER_MS,
|
|
updateViewerAssignment,
|
|
} from "../src/server/viewers/service.js";
|
|
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js";
|
|
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
|
|
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
|
|
import { announcementsFixture, type FixtureProvider } from "./support/fixture-provider.js";
|
|
import { makeImage } from "./support/images.js";
|
|
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
|
|
|
const START = Date.parse("2026-08-30T15:00:00.000Z");
|
|
|
|
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
|
|
try {
|
|
await promise;
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(SignageError);
|
|
const signageError = error as SignageError;
|
|
expect(signageError.code).toBe(code);
|
|
return signageError;
|
|
}
|
|
throw new Error(`Expected ${code}, but the operation succeeded.`);
|
|
}
|
|
|
|
describe("viewers", () => {
|
|
let store: MemorySignageStore;
|
|
let registry: SignageProviderRegistry;
|
|
let service: SignagePlaybackService;
|
|
let announcements: FixtureProvider;
|
|
let clock: number;
|
|
let counter: number;
|
|
|
|
const newId = () => {
|
|
counter += 1;
|
|
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
clock = START;
|
|
counter = 0;
|
|
store = createMemorySignageStore({ now: () => new Date(clock) });
|
|
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
|
|
announcements = announcementsFixture();
|
|
registry.capability.register(announcements.registration);
|
|
registry.capability.register(
|
|
createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store })
|
|
.registration,
|
|
);
|
|
service = createPlaybackService({
|
|
newId,
|
|
now: () => clock,
|
|
registry,
|
|
setTimer: () => undefined,
|
|
settings: SIGNAGE_DEFAULT_SETTINGS,
|
|
store,
|
|
});
|
|
await service.ensureDefaultGroup();
|
|
});
|
|
|
|
async function defaultChannel(): Promise<StoredChannel> {
|
|
const groups = await store.listChannelGroups();
|
|
const channel = groups[0]?.channels[0];
|
|
if (!channel) {
|
|
throw new Error("Expected a default channel.");
|
|
}
|
|
return channel;
|
|
}
|
|
|
|
it("registers a display on the default channel, and never lets it pick one", async () => {
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
|
|
const channel = await defaultChannel();
|
|
expect(session.channel.id).toBe(channel.id);
|
|
expect(session.channel.isDefault).toBe(true);
|
|
// There is no input by which a display could have asked for another channel: `openViewerSession`
|
|
// takes an identity and nothing else.
|
|
expect(session.viewer.name).toBe("New display");
|
|
});
|
|
|
|
it("recognizes a returning display rather than creating a ghost", async () => {
|
|
const first = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
await updateViewerAssignment({ name: "Lobby north", store, viewerId: first.viewer.id });
|
|
|
|
clock += 60_000;
|
|
const second = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
|
|
// Same screen, same row, same name. A television that reboots does not fill the roster.
|
|
expect(second.viewer.id).toBe(first.viewer.id);
|
|
expect(second.viewer.name).toBe("Lobby north");
|
|
expect(store.viewers.size).toBe(1);
|
|
});
|
|
|
|
it("moves an assigned display atomically when its channel group is deleted", async () => {
|
|
const original = (await store.listChannelGroups())[0];
|
|
if (!original) {
|
|
throw new Error("Expected the default group.");
|
|
}
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0003",
|
|
});
|
|
expect(original.channels.some((channel) => channel.id === session.channel.id)).toBe(true);
|
|
const fallbackGroupId = newId();
|
|
const fallbackChannelId = newId();
|
|
await store.createChannelGroup({
|
|
channelId: fallbackChannelId,
|
|
channelName: "Default",
|
|
groupId: fallbackGroupId,
|
|
name: "Foyer",
|
|
});
|
|
|
|
await store.deleteChannelGroup(original.group.id, fallbackChannelId);
|
|
|
|
expect(await store.findChannelGroup(original.group.id)).toBeUndefined();
|
|
expect((await store.findViewer(session.viewer.id))?.channelId).toBe(fallbackChannelId);
|
|
});
|
|
|
|
it("stores only the safe device identifier a paired principal carries", async () => {
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: "3f2c1a9e-1111-4222-8333-444455556666",
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0002",
|
|
});
|
|
|
|
expect(session.viewer.deviceId).toBe("3f2c1a9e-1111-4222-8333-444455556666");
|
|
// Nothing about pairing, and no credential of any kind, is anywhere in the record.
|
|
const serialized = JSON.stringify(session.viewer);
|
|
for (const forbidden of ["token", "secret", "credential", "password", "pairing"]) {
|
|
expect(serialized.toLowerCase()).not.toContain(forbidden);
|
|
}
|
|
});
|
|
|
|
it("refuses a viewer key that is not one Signage issued the shape of", async () => {
|
|
for (const viewerKey of ["", "short", "has spaces in it", "x".repeat(200)]) {
|
|
await expectSignageCode(
|
|
openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey }),
|
|
"SIGNAGE_INVALID_REQUEST",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("coalesces a steady display's heartbeats into almost no writes", async () => {
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
const before = store.viewerWrites.count;
|
|
|
|
// Ten heartbeats over forty seconds, saying exactly the same thing each time.
|
|
for (let index = 0; index < 10; index += 1) {
|
|
clock += 4000;
|
|
await recordViewerHeartbeat({
|
|
at: new Date(clock),
|
|
heartbeat: { cachedAssetCount: 0, offlineReady: false, reportedSelectionId: null },
|
|
minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs,
|
|
store,
|
|
viewerId: session.viewer.id,
|
|
});
|
|
}
|
|
|
|
// Forty seconds at a thirty-second floor is one write, not ten. A wall of televisions is a wall
|
|
// of these, and writing every one would say nothing new very expensively.
|
|
expect(store.viewerWrites.count - before).toBe(1);
|
|
});
|
|
|
|
it("writes immediately when a display says something an operator would notice", async () => {
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
const before = store.viewerWrites.count;
|
|
|
|
clock += 1000;
|
|
const changed = await recordViewerHeartbeat({
|
|
at: new Date(clock),
|
|
heartbeat: { reportedSelectionId: "sel-1" },
|
|
minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs,
|
|
store,
|
|
viewerId: session.viewer.id,
|
|
});
|
|
clock += 1000;
|
|
const failing = await recordViewerHeartbeat({
|
|
at: new Date(clock),
|
|
heartbeat: {
|
|
error: { code: "SLIDE_DECODE_FAILED", detail: "one image could not be decoded" },
|
|
reportedSelectionId: "sel-1",
|
|
},
|
|
minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs,
|
|
store,
|
|
viewerId: session.viewer.id,
|
|
});
|
|
|
|
expect(changed.written).toBe(true);
|
|
expect(failing.written).toBe(true);
|
|
expect(store.viewerWrites.count - before).toBe(2);
|
|
expect(failing.viewer.lastError).toMatchObject({ code: "SLIDE_DECODE_FAILED" });
|
|
});
|
|
|
|
it("refuses an unbounded or malformed error report from a display", async () => {
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
|
|
await expectSignageCode(
|
|
recordViewerHeartbeat({
|
|
at: new Date(clock),
|
|
heartbeat: { error: { code: "x".repeat(200) } },
|
|
minimumWriteIntervalMs: 0,
|
|
store,
|
|
viewerId: session.viewer.id,
|
|
}),
|
|
"SIGNAGE_INVALID_REQUEST",
|
|
);
|
|
await expectSignageCode(
|
|
recordViewerHeartbeat({
|
|
at: new Date(clock),
|
|
heartbeat: { error: { code: "OK", detail: "y".repeat(5000) } },
|
|
minimumWriteIntervalMs: 0,
|
|
store,
|
|
viewerId: session.viewer.id,
|
|
}),
|
|
"SIGNAGE_INVALID_REQUEST",
|
|
);
|
|
});
|
|
|
|
it("reports a display as offline once it stops checking in", async () => {
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
|
|
expect(describeViewerHealth(session.viewer, clock).online).toBe(true);
|
|
expect(
|
|
describeViewerHealth(session.viewer, clock + SIGNAGE_VIEWER_OFFLINE_AFTER_MS + 1).online,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("moves a display between channels, and refuses a channel that does not exist", async () => {
|
|
const groups = await store.listChannelGroups();
|
|
const group = groups[0];
|
|
if (!group) {
|
|
throw new Error("Expected a group.");
|
|
}
|
|
const onlineChannelId = newId();
|
|
await store.addChannel({
|
|
groupId: group.group.id,
|
|
id: onlineChannelId,
|
|
isDefault: false,
|
|
key: "online",
|
|
name: "Online",
|
|
});
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: null,
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
|
|
const moved = await updateViewerAssignment({
|
|
channelId: onlineChannelId,
|
|
store,
|
|
viewerId: session.viewer.id,
|
|
});
|
|
expect(moved.channelId).toBe(onlineChannelId);
|
|
|
|
await expectSignageCode(
|
|
updateViewerAssignment({
|
|
channelId: "11111111-1111-4111-8111-111111111111",
|
|
store,
|
|
viewerId: session.viewer.id,
|
|
}),
|
|
"SIGNAGE_NOT_FOUND",
|
|
);
|
|
});
|
|
|
|
it("exposes a sanitized roster through the displays capability", async () => {
|
|
const session = await openViewerSession({
|
|
at: new Date(clock),
|
|
deviceId: "3f2c1a9e-1111-4222-8333-444455556666",
|
|
newId,
|
|
store,
|
|
viewerKey: "kiosk-lobby-0001",
|
|
});
|
|
const capability = createDisplaysCapability(
|
|
() => store,
|
|
() => new Date(clock),
|
|
);
|
|
|
|
const viewers = await capability.listViewers();
|
|
const channels = await capability.listChannels();
|
|
|
|
expect(viewers).toHaveLength(1);
|
|
expect(viewers[0]).toMatchObject({ id: session.viewer.id, online: true });
|
|
expect(channels[0]).toMatchObject({ isDefault: true, key: "default" });
|
|
// A consuming module sees identity and health. It never sees a credential, an object key, a
|
|
// delivery URL, or a provider payload.
|
|
const serialized = JSON.stringify({ channels, viewers });
|
|
for (const forbidden of ["token", "secret", "objectKey", "https://", "payload"]) {
|
|
expect(serialized).not.toContain(forbidden);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("display channel state", () => {
|
|
let store: MemorySignageStore;
|
|
let storage: FakeStorage;
|
|
let registry: SignageProviderRegistry;
|
|
let service: SignagePlaybackService;
|
|
let announcements: FixtureProvider;
|
|
let clock: number;
|
|
let counter: number;
|
|
|
|
const newId = () => {
|
|
counter += 1;
|
|
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
clock = START;
|
|
counter = 0;
|
|
store = createMemorySignageStore({ now: () => new Date(clock) });
|
|
storage = createFakeStorage({ now: () => new Date(clock) });
|
|
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
|
|
announcements = announcementsFixture();
|
|
registry.capability.register(announcements.registration);
|
|
registry.capability.register(
|
|
createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store })
|
|
.registration,
|
|
);
|
|
service = createPlaybackService({
|
|
newId,
|
|
now: () => clock,
|
|
registry,
|
|
setTimer: () => undefined,
|
|
settings: SIGNAGE_DEFAULT_SETTINGS,
|
|
store,
|
|
});
|
|
await service.ensureDefaultGroup();
|
|
});
|
|
|
|
async function channelsOf(): Promise<readonly StoredChannel[]> {
|
|
const groups = await store.listChannelGroups();
|
|
return groups[0]?.channels ?? [];
|
|
}
|
|
|
|
async function stateFor(channel: StoredChannel) {
|
|
return buildDisplayChannelState({
|
|
atMs: clock,
|
|
channel,
|
|
playback: await service.snapshot(channel.groupId),
|
|
registry,
|
|
settings: SIGNAGE_DEFAULT_SETTINGS,
|
|
storage,
|
|
store,
|
|
});
|
|
}
|
|
|
|
it("carries the frame, the timeline's shape, and recent selections — and no object key", async () => {
|
|
const groups = await store.listChannelGroups();
|
|
const groupId = groups[0]?.group.id as string;
|
|
let timeline = await createTimeline({ name: "Loop", newId, store });
|
|
timeline = await addProviderSource({
|
|
label: "Announcements",
|
|
newId,
|
|
providerId: "fixture.announcements",
|
|
sourceId: "active-slides",
|
|
store,
|
|
timelineId: timeline.timeline.id,
|
|
});
|
|
const source = timeline.sources[0];
|
|
if (!source) {
|
|
throw new Error("Expected a source.");
|
|
}
|
|
await addSlot({
|
|
newId,
|
|
source: { sourceId: source.id },
|
|
store,
|
|
timelineId: timeline.timeline.id,
|
|
});
|
|
await addSlot({
|
|
newId,
|
|
source: { sourceId: source.id },
|
|
store,
|
|
timelineId: timeline.timeline.id,
|
|
});
|
|
await service.activate(groupId, timeline.timeline.id, "now");
|
|
|
|
const [channel] = await channelsOf();
|
|
if (!channel) {
|
|
throw new Error("Expected a channel.");
|
|
}
|
|
const state = await stateFor(channel);
|
|
|
|
expect(state.current?.itemId).toBe("welcome");
|
|
expect(state.current?.renderer).toEqual({
|
|
contributionId: "fixture.announcements.slide",
|
|
contributionVersion: "1.0.0",
|
|
kind: "contribution",
|
|
});
|
|
// The shape a disconnected display advances its own cursor over, with the same estimates the
|
|
// server skips passes with.
|
|
expect(state.timeline?.slots).toHaveLength(2);
|
|
expect(state.timeline?.slots[0]?.estimatedDurationMs).toBe(5000);
|
|
expect(state.selections.length).toBeGreaterThan(0);
|
|
// A display is handed identity, timing, and payloads — never an object key.
|
|
expect(JSON.stringify(state)).not.toContain("objectKey");
|
|
});
|
|
|
|
it("gives each channel its own audience's payload, from one logical selection", async () => {
|
|
const groups = await store.listChannelGroups();
|
|
const groupId = groups[0]?.group.id as string;
|
|
await store.addChannel({
|
|
groupId,
|
|
id: newId(),
|
|
isDefault: false,
|
|
key: "online",
|
|
name: "Online",
|
|
});
|
|
let timeline = await createTimeline({ name: "Loop", newId, store });
|
|
timeline = await addProviderSource({
|
|
label: "Announcements",
|
|
newId,
|
|
providerId: "fixture.announcements",
|
|
sourceId: "active-slides",
|
|
store,
|
|
timelineId: timeline.timeline.id,
|
|
});
|
|
const source = timeline.sources[0];
|
|
if (!source) {
|
|
throw new Error("Expected a source.");
|
|
}
|
|
await addSlot({
|
|
newId,
|
|
source: { sourceId: source.id },
|
|
store,
|
|
timelineId: timeline.timeline.id,
|
|
});
|
|
await service.activate(groupId, timeline.timeline.id, "now");
|
|
// Move to the announcement that has an audience-specific variant.
|
|
clock += 5000;
|
|
await service.reconcile(groupId, "timer");
|
|
|
|
const channels = await channelsOf();
|
|
const base = channels.find((channel) => channel.key === "default") as StoredChannel;
|
|
const online = channels.find((channel) => channel.key === "online") as StoredChannel;
|
|
const baseState = await stateFor(base);
|
|
const onlineState = await stateFor(online);
|
|
|
|
expect(baseState.current?.payload).toEqual({ headline: "Good morning" });
|
|
expect(onlineState.current?.payload).toEqual({ headline: "Watching from home" });
|
|
// One selection, one slot, one deadline — two renderings.
|
|
expect(onlineState.current?.id).toBe(baseState.current?.id);
|
|
expect(onlineState.current?.deadlineAtMs).toBe(baseState.current?.deadlineAtMs);
|
|
expect(onlineState.playback.startedAtMs).toBe(baseState.playback.startedAtMs);
|
|
});
|
|
|
|
it("resolves a native image into a scene and fresh delivery URLs", async () => {
|
|
const groups = await store.listChannelGroups();
|
|
const groupId = groups[0]?.group.id as string;
|
|
const ingested = await ingestNativeImage({
|
|
bytes: await makeImage({ format: "png", height: 1080, width: 1080 }),
|
|
displayName: "Square",
|
|
expiresAt: null,
|
|
filename: "square.png",
|
|
storage,
|
|
store,
|
|
});
|
|
let timeline = await createTimeline({ name: "Images", newId, store });
|
|
await addSlot({
|
|
newId,
|
|
pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID },
|
|
presentation: { backgroundMode: "blurred", blurPixels: 24 },
|
|
store,
|
|
timelineId: timeline.timeline.id,
|
|
});
|
|
timeline = (await store.findTimeline(timeline.timeline.id)) as never;
|
|
await service.activate(groupId, timeline.timeline.id, "now");
|
|
|
|
const [channel] = await channelsOf();
|
|
if (!channel) {
|
|
throw new Error("Expected a channel.");
|
|
}
|
|
const state = await stateFor(channel);
|
|
|
|
expect(state.current?.renderer).toEqual({ kind: "native-image" });
|
|
// A square image is not 16:9, so it is shown whole on the background the slot asked for.
|
|
expect(state.current?.scene).toMatchObject({
|
|
backgroundImage: true,
|
|
blurPixels: 24,
|
|
fit: "contain",
|
|
shadow: true,
|
|
});
|
|
expect(state.current?.media?.display.url).toContain("https://storage.invalid/");
|
|
expect(state.current?.media?.display.expiresAtMs).toBeGreaterThan(clock);
|
|
// The URL was minted for this response and is nowhere in the database.
|
|
expect(JSON.stringify([...store.selections.values()])).not.toContain("https://");
|
|
});
|
|
|
|
it("shapes a static hold exactly like a selection, so a display has one renderer path", async () => {
|
|
const groups = await store.listChannelGroups();
|
|
const groupId = groups[0]?.group.id as string;
|
|
const ingested = await ingestNativeImage({
|
|
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
|
|
displayName: "Hold",
|
|
expiresAt: null,
|
|
filename: "hold.png",
|
|
storage,
|
|
store,
|
|
});
|
|
await service.setStaticHold(groupId, ingested.image.id);
|
|
|
|
const [channel] = await channelsOf();
|
|
if (!channel) {
|
|
throw new Error("Expected a channel.");
|
|
}
|
|
const state = await stateFor(channel);
|
|
|
|
expect(state.playback.transport).toBe("static");
|
|
expect(state.staticHold?.renderer).toEqual({ kind: "native-image" });
|
|
// A true 16:9 image fills the frame on its own: no blurred duplicate, no shadow.
|
|
expect(state.staticHold?.scene).toMatchObject({
|
|
backgroundImage: false,
|
|
fit: "fill",
|
|
shadow: false,
|
|
});
|
|
});
|
|
|
|
it("keeps a display's state usable when storage is briefly unreachable", async () => {
|
|
const groups = await store.listChannelGroups();
|
|
const groupId = groups[0]?.group.id as string;
|
|
const ingested = await ingestNativeImage({
|
|
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
|
|
displayName: "Slide",
|
|
expiresAt: null,
|
|
filename: "slide.png",
|
|
storage,
|
|
store,
|
|
});
|
|
const timeline = await createTimeline({ name: "Images", newId, store });
|
|
await addSlot({
|
|
newId,
|
|
pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID },
|
|
store,
|
|
timelineId: timeline.timeline.id,
|
|
});
|
|
await service.activate(groupId, timeline.timeline.id, "now");
|
|
|
|
const [channel] = await channelsOf();
|
|
if (!channel) {
|
|
throw new Error("Expected a channel.");
|
|
}
|
|
const broken = {
|
|
...storage,
|
|
bucket: () => {
|
|
throw new Error("storage is down");
|
|
},
|
|
};
|
|
const state = await buildDisplayChannelState({
|
|
atMs: clock,
|
|
channel,
|
|
playback: await service.snapshot(groupId),
|
|
registry,
|
|
settings: SIGNAGE_DEFAULT_SETTINGS,
|
|
storage: broken as never,
|
|
store,
|
|
});
|
|
|
|
// The frame's identity, timing, and scene all survive. Only the URLs are missing, which costs a
|
|
// display that already cached the bytes nothing at all.
|
|
expect(state.current?.itemId).toBe(ingested.image.id);
|
|
expect(state.current?.scene).toBeDefined();
|
|
expect(state.current?.media).toBeUndefined();
|
|
});
|
|
});
|