Fix/signage stabilization #1

Merged
Zayin_Admin merged 2 commits from fix/signage-stabilization into main 2026-09-29 23:57:32 +00:00
13 changed files with 399 additions and 30 deletions
+10 -1
View File
@@ -91,11 +91,20 @@ export function registerPlaybackRoutes(
const groupId = requireUuidParam(context, "groupId"); const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database); const store = createSignageStore(context.services.database);
const groups = await store.listChannelGroups(); const groups = await store.listChannelGroups();
const group = groups.find((candidate) => candidate.group.id === groupId);
if (!group) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
if (groups.length <= 1) { if (groups.length <= 1) {
// An installation always has somewhere for a viewer to land. // An installation always has somewhere for a viewer to land.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" }); throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
} }
await store.deleteChannelGroup(groupId); const fallbackGroup = groups.find((candidate) => candidate.group.id !== groupId);
const fallbackChannel = fallbackGroup?.channels.find((channel) => channel.isDefault);
if (!fallbackChannel) {
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-fallback-channel" });
}
await store.deleteChannelGroup(groupId, fallbackChannel.id);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
}); });
}, },
+16 -2
View File
@@ -7,6 +7,7 @@ import {
playbackStates, playbackStates,
selections, selections,
selectionVariants, selectionVariants,
viewers,
} from "../../schema/tables.js"; } from "../../schema/tables.js";
import { import {
type NewStoredSelection, type NewStoredSelection,
@@ -176,8 +177,21 @@ export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackSt
}); });
}, },
async deleteChannelGroup(groupId: string): Promise<void> { async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
await database.delete(channelGroups).where(eq(channelGroups.id, groupId)); await database.transaction(async (transaction) => {
const removedChannels = await transaction
.select({ id: channels.id })
.from(channels)
.where(eq(channels.groupId, groupId));
const removedChannelIds = removedChannels.map((channel) => channel.id);
if (removedChannelIds.length > 0) {
await transaction
.update(viewers)
.set({ channelId: fallbackChannelId, updatedAt: new Date() })
.where(inArray(viewers.channelId, removedChannelIds));
}
await transaction.delete(channelGroups).where(eq(channelGroups.id, groupId));
});
}, },
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> { async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
+3 -1
View File
@@ -351,7 +351,9 @@ export interface SignagePlaybackStore {
readonly channelId: string; readonly channelId: string;
readonly channelName: string; readonly channelName: string;
}): Promise<void>; }): Promise<void>;
deleteChannelGroup(groupId: string): Promise<void>; /** Deletes a group and moves every assigned viewer to the supplied fallback channel atomically,
* so removing a group can never leave a display unassigned. */
deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void>;
addChannel(channel: StoredChannel): Promise<void>; addChannel(channel: StoredChannel): Promise<void>;
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */ /** Refuses to remove a group's default channel: something must remain for a viewer to join. */
removeChannel(channelId: string): Promise<void>; removeChannel(channelId: string): Promise<void>;
+25 -7
View File
@@ -62,6 +62,10 @@ export function SignageDashboard({
const selectedGroupId = const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
const selectedGroup =
groups.state.status === "ready"
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
: undefined;
return ( return (
<> <>
@@ -93,18 +97,23 @@ export function SignageDashboard({
{ {
content: ( content: (
<TimelinesPanel <TimelinesPanel
{...(selectedGroup
? {
activeGroupName: selectedGroup.name,
activeTimelineId: selectedGroup.playback.activeTimelineId,
}
: {})}
api={api} api={api}
canManage={mayManageTimelines} canManage={mayManageTimelines}
onActivate={ onActivate={
mayControl && selectedGroupId !== undefined mayControl && selectedGroupId !== undefined
? (timelineId) => { ? async (timelineId) => {
void api await api.send("POST", `playback/${selectedGroupId}/activate`, {
.send("POST", `playback/${selectedGroupId}/activate`, {
timelineId, timelineId,
when: "now", when: "now",
}) });
.then(() => setTab("playback")) groups.reload();
.catch(() => setTab("playback")); setTab("playback");
} }
: undefined : undefined
} }
@@ -122,7 +131,16 @@ export function SignageDashboard({
label: "Images", label: "Images",
}, },
{ {
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />, content: (
<DisplaysPanel
api={api}
canManage={mayManageDisplays}
onGroupsChanged={() => {
setGroupId(undefined);
groups.reload();
}}
/>
),
id: "displays", id: "displays",
label: "Displays", label: "Displays",
}, },
+77 -13
View File
@@ -18,6 +18,8 @@ import { useAction, useResource, useTick } from "../hooks.js";
import type { ChannelGroupView, ViewerHealthView } from "../types.js"; import type { ChannelGroupView, ViewerHealthView } from "../types.js";
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js"; import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
/** /**
* Channel groups, audience channels, and the display roster. * Channel groups, audience channels, and the display roster.
* *
@@ -28,9 +30,11 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.
export function DisplaysPanel({ export function DisplaysPanel({
api, api,
canManage, canManage,
onGroupsChanged,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly canManage: boolean; readonly canManage: boolean;
readonly onGroupsChanged?: () => void;
}): ReactElement { }): ReactElement {
const groups = useResource( const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
@@ -43,8 +47,11 @@ export function DisplaysPanel({
const edit = useAction(() => { const edit = useAction(() => {
groups.reload(); groups.reload();
viewers.reload(); viewers.reload();
onGroupsChanged?.();
}); });
const viewerEdit = useAction(viewers.reload);
const [groupName, setGroupName] = useState(""); const [groupName, setGroupName] = useState("");
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
groupId: "", groupId: "",
key: "", key: "",
@@ -105,7 +112,41 @@ export function DisplaysPanel({
<div className="signage-group__header"> <div className="signage-group__header">
<h4 className="signage-editor__subtitle">{group.name}</h4> <h4 className="signage-editor__subtitle">{group.name}</h4>
<Badge tone="info">{group.playback.transport}</Badge> <Badge tone="info">{group.playback.transport}</Badge>
{canManage && value.groups.length > 1 ? (
<Button
disabled={edit.pending}
onClick={() => setConfirmGroupId(group.id)}
variant="danger"
>
Delete group
</Button>
) : canManage ? (
<Badge tone="neutral">At least one group required</Badge>
) : undefined}
</div> </div>
{confirmGroupId === group.id ? (
<div className="signage-confirm" role="alert">
<p>
Delete {group.name}? Its playback history will be removed. Displays assigned
here will move to the default channel of another group.
</p>
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
Cancel
</Button>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(async () => {
await api.send("DELETE", `channel-groups/${group.id}`);
setConfirmGroupId(undefined);
})
}
variant="danger"
>
Confirm delete
</Button>
</div>
) : undefined}
<ul className="ek-list"> <ul className="ek-list">
{group.channels.map((channel) => ( {group.channels.map((channel) => (
<li className="ek-list__item signage-row" key={channel.id}> <li className="ek-list__item signage-row" key={channel.id}>
@@ -129,32 +170,43 @@ export function DisplaysPanel({
</ul> </ul>
{canManage ? ( {canManage ? (
<div className="signage-inline-form" data-testid="signage-add-channel"> <div className="signage-inline-form" data-testid="signage-add-channel">
<Field hint="Lowercase, e.g. online" label="Audience key"> <Field
hint="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online."
label="Provider audience key"
>
{(control) => ( {(control) => (
<TextInput <TextInput
{...control} {...control}
maxLength={64}
onChange={(event) => onChange={(event) =>
setChannelDraft({ setChannelDraft((current) => ({
groupId: group.id, groupId: group.id,
key: event.target.value, key: event.target.value,
name: channelDraft.name, name: current.groupId === group.id ? current.name : "",
}) }))
} }
pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
placeholder="online"
value={channelDraft.groupId === group.id ? channelDraft.key : ""} value={channelDraft.groupId === group.id ? channelDraft.key : ""}
/> />
)} )}
</Field> </Field>
<Field label="Channel name"> <Field
hint="Friendly label shown to administrators and used when assigning displays."
label="Dashboard channel name"
>
{(control) => ( {(control) => (
<TextInput <TextInput
{...control} {...control}
maxLength={120}
onChange={(event) => onChange={(event) =>
setChannelDraft({ setChannelDraft((current) => ({
groupId: group.id, groupId: group.id,
key: channelDraft.key, key: current.groupId === group.id ? current.key : "",
name: event.target.value, name: event.target.value,
}) }))
} }
placeholder="Online audience"
value={channelDraft.groupId === group.id ? channelDraft.name : ""} value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/> />
)} )}
@@ -163,7 +215,8 @@ export function DisplaysPanel({
disabled={ disabled={
edit.pending || edit.pending ||
channelDraft.groupId !== group.id || channelDraft.groupId !== group.id ||
channelDraft.key.trim() === "" || !AUDIENCE_KEY.test(channelDraft.key.trim()) ||
channelDraft.key.trim() === "default" ||
channelDraft.name.trim() === "" channelDraft.name.trim() === ""
} }
onClick={() => onClick={() =>
@@ -189,15 +242,25 @@ export function DisplaysPanel({
</Panel> </Panel>
<Panel <Panel
description="Every screen Signage has seen, and what it last reported." description="Pair a screen through Ekklesia, then name it and choose which audience channel it shows."
title="Displays" title="Displays"
> >
{canManage ? (
<div className="signage-registration-guide">
<h4 className="signage-editor__subtitle">Register a display</h4>
<ol>
<li>Open the Ekklesia path /display/signage on the screen.</li>
<li>Enter its six-digit code under Display devices in the Dashboard sidebar.</li>
<li>When it appears below, give it a clear name and assign its channel.</li>
</ol>
</div>
) : undefined}
{renderLoadable( {renderLoadable(
viewers.state, viewers.state,
(value) => (value) =>
value.viewers.length === 0 ? ( value.viewers.length === 0 ? (
<EmptyState <EmptyState
description="Open a Signage display on a screen and it will register itself here." description="Open /display/signage on the screen, approve its code under Display devices, and it will appear here automatically."
title="No displays yet" title="No displays yet"
/> />
) : ( ) : (
@@ -222,7 +285,7 @@ export function DisplaysPanel({
defaultValue={viewer.name} defaultValue={viewer.name}
onBlur={(event) => onBlur={(event) =>
event.target.value !== viewer.name && event.target.value !== viewer.name &&
edit.run(() => viewerEdit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, { api.send("PATCH", `viewers/${viewer.id}`, {
name: event.target.value, name: event.target.value,
}), }),
@@ -241,7 +304,7 @@ export function DisplaysPanel({
<Select <Select
aria-label={`Channel for ${viewer.name}`} aria-label={`Channel for ${viewer.name}`}
onChange={(event) => onChange={(event) =>
edit.run(() => viewerEdit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, { api.send("PATCH", `viewers/${viewer.id}`, {
channelId: event.target.value, channelId: event.target.value,
}), }),
@@ -282,6 +345,7 @@ export function DisplaysPanel({
{ label: "Loading displays…", onRetry: viewers.reload }, { label: "Loading displays…", onRetry: viewers.reload },
)} )}
<FailureNote failure={edit.failure} /> <FailureNote failure={edit.failure} />
<FailureNote failure={viewerEdit.failure} />
</Panel> </Panel>
</div> </div>
); );
+15
View File
@@ -70,6 +70,16 @@ export function PlaybackPanel({
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
} }
function timelineLabel(timelineId: string): string {
if (timelines.state.status !== "ready") {
return timelineId;
}
return (
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
timelineId
);
}
return ( return (
<div className="signage-columns"> <div className="signage-columns">
<Panel <Panel
@@ -108,6 +118,11 @@ export function PlaybackPanel({
) : ( ) : (
<div className="signage-playback" data-testid="signage-playback"> <div className="signage-playback" data-testid="signage-playback">
<div className="signage-playback__status"> <div className="signage-playback__status">
{value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success">
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
</Badge>
)}
<Badge <Badge
tone={ tone={
value.snapshot.transport === "playing" value.snapshot.transport === "playing"
+18 -3
View File
@@ -42,11 +42,15 @@ export function TimelinesPanel({
settings, settings,
canManage, canManage,
onActivate, onActivate,
activeTimelineId,
activeGroupName,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
readonly canManage: boolean; readonly canManage: boolean;
readonly onActivate: ((timelineId: string) => void) | undefined; readonly onActivate: ((timelineId: string) => Promise<void>) | undefined;
readonly activeTimelineId?: string | null;
readonly activeGroupName?: string;
}): ReactElement { }): ReactElement {
const timelines = useResource( const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
@@ -62,6 +66,7 @@ export function TimelinesPanel({
setNewName(""); setNewName("");
timelines.reload(); timelines.reload();
}); });
const activation = useAction();
return ( return (
<div className="signage-columns"> <div className="signage-columns">
@@ -117,6 +122,11 @@ export function TimelinesPanel({
<TableRow key={timeline.id} selected={timeline.id === selectedId}> <TableRow key={timeline.id} selected={timeline.id === selectedId}>
<TableCell> <TableCell>
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button> <Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
{timeline.id === activeTimelineId ? (
<Badge tone="success">
{activeGroupName ? `Active in ${activeGroupName}` : "Active"}
</Badge>
) : undefined}
</TableCell> </TableCell>
<TableCell> <TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null {timeline.eligibleFrom === null && timeline.eligibleUntil === null
@@ -126,8 +136,12 @@ export function TimelinesPanel({
<TableCell>{timeline.revision}</TableCell> <TableCell>{timeline.revision}</TableCell>
<TableCell> <TableCell>
{onActivate === undefined ? undefined : ( {onActivate === undefined ? undefined : (
<Button onClick={() => onActivate(timeline.id)} variant="primary"> <Button
Activate disabled={activation.pending || timeline.id === activeTimelineId}
onClick={() => activation.run(() => onActivate(timeline.id))}
variant="primary"
>
{timeline.id === activeTimelineId ? "Active" : "Activate"}
</Button> </Button>
)} )}
</TableCell> </TableCell>
@@ -139,6 +153,7 @@ export function TimelinesPanel({
{ label: "Loading timelines…", onRetry: timelines.reload }, { label: "Loading timelines…", onRetry: timelines.reload },
)} )}
<FailureNote failure={create.failure} /> <FailureNote failure={create.failure} />
<FailureNote failure={activation.failure} />
</Panel> </Panel>
{selectedId === undefined ? undefined : ( {selectedId === undefined ? undefined : (
+34
View File
@@ -349,6 +349,40 @@
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
} }
.signage-group__header .ek-button:first-of-type {
margin-inline-start: auto;
}
.signage-confirm {
align-items: center;
background: var(--ekklesia-status-danger-background);
border-radius: var(--ekklesia-radius-md);
color: var(--ekklesia-text-danger);
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
padding: var(--ekklesia-spacing-sm);
}
.signage-confirm p {
flex: 1 1 30rem;
margin: 0;
}
.signage-registration-guide {
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--ekklesia-spacing-xs);
margin-block-end: var(--signage-gap);
padding: var(--ekklesia-spacing-md);
}
.signage-registration-guide ol {
margin: 0;
padding-inline-start: var(--ekklesia-spacing-xl);
}
.signage-provider__id { .signage-provider__id {
font-weight: var(--ekklesia-typography-weight-medium); font-weight: var(--ekklesia-typography-weight-medium);
} }
+132
View File
@@ -120,6 +120,16 @@ const PLAYBACK_DETAIL = {
snapshot: GROUP.playback, snapshot: GROUP.playback,
}; };
const TIMELINE = {
description: null,
eligibleFrom: null,
eligibleUntil: null,
id: "timeline-1",
name: "Sunday morning",
revision: 3,
updatedAt: "2026-08-30T15:00:00.000Z",
};
beforeEach(() => { beforeEach(() => {
container = document.createElement("div"); container = document.createElement("div");
document.body.append(container); document.body.append(container);
@@ -194,6 +204,78 @@ describe("Signage dashboard", () => {
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); expect(container.querySelector("[data-testid='signage-controls']")).toBeNull();
}); });
it("shows which timeline remains active after the dashboard data is reloaded", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }),
},
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
],
});
await render(<SignageDashboard context={host.context} />);
expect(container.textContent).toContain("Active: Sunday morning");
await click(
[...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Timelines",
),
);
expect(container.textContent).toContain("Active in Main");
expect(buttonNamed("Active")?.disabled).toBe(true);
});
it("keeps a failed activation visible instead of pretending it succeeded", async () => {
const inactiveGroup = {
...GROUP,
playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null },
};
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [inactiveGroup] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }),
},
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{
method: "POST",
path: "playback/group-1/activate",
respond: () =>
errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409),
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Timelines",
),
);
await click(buttonNamed("Activate"));
expect(container.textContent).toContain("Sunday morning");
expect(container.querySelector("[role='alert']")?.textContent).toContain(
"The request conflicts with the current state.",
);
});
it("shows Signage's own sanitized reason when a save is refused", async () => { it("shows Signage's own sanitized reason when a save is refused", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"], permissions: ["signage.view", "signage.timelines.manage"],
@@ -405,6 +487,56 @@ describe("Signage dashboard", () => {
expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
}); });
it("explains display registration and deletes a channel group only after confirmation", async () => {
const secondGroup = {
...GROUP,
channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }],
id: "group-2",
name: "Foyer",
playback: { ...GROUP.playback, groupId: "group-2" },
};
let currentGroups = [GROUP, secondGroup];
const deleted: string[] = [];
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: currentGroups }),
},
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) },
{
method: "DELETE",
path: "channel-groups/group-1",
respond: ({ path }) => {
deleted.push(path);
currentGroups = [secondGroup];
return new Response(null, { status: 204 });
},
},
],
});
await render(<SignageDashboard context={host.context} />);
await click(
[...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Displays",
),
);
expect(container.textContent).toContain("/display/signage");
expect(container.textContent).toContain("Provider audience key");
await click(buttonNamed("Delete group"));
expect(deleted).toEqual([]);
expect(container.textContent).toContain("Displays assigned here will move");
await click(buttonNamed("Confirm delete"));
expect(deleted).toEqual(["channel-groups/group-1"]);
});
it("uploads one request per file and reports each one's own outcome", async () => { it("uploads one request per file and reports each one's own outcome", async () => {
const uploaded: string[] = []; const uploaded: string[] = [];
const host = createFakeHost({ const host = createFakeHost({
+24
View File
@@ -589,6 +589,30 @@ describe("authoritative playback", () => {
expect(announcements.resolveRequests.length).toBe(callsBefore); expect(announcements.resolveRequests.length).toBe(callsBefore);
}); });
it("keeps the active timeline through a complete playback-service restart", 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);
service.stop();
clock += 2000;
service = createPlaybackService({
newId,
now: () => clock,
registry,
setTimer: () => undefined,
settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 },
store,
});
await service.start();
const after = await service.snapshot(group.group.id);
expect(after.activeTimelineId).toBe(timeline.timeline.id);
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
});
it("publishes a compact snapshot, with no payload or timeline in it", async () => { it("publishes a compact snapshot, with no payload or timeline in it", async () => {
const group = await seedGroup(); const group = await seedGroup();
const timeline = await seedPattern(); const timeline = await seedPattern();
+1 -1
View File
@@ -103,7 +103,7 @@ export function createMemoryPlaybackStore(
return Promise.resolve(); return Promise.resolve();
}, },
deleteChannelGroup(groupId: string): Promise<void> { deleteChannelGroup(groupId: string, _fallbackChannelId: string): Promise<void> {
groups.delete(groupId); groups.delete(groupId);
playback.delete(groupId); playback.delete(groupId);
for (const [id, channel] of channels) { for (const [id, channel] of channels) {
+14
View File
@@ -46,6 +46,20 @@ export function createMemorySignageStore(
...playbackStore, ...playbackStore,
...viewerStore, ...viewerStore,
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
const removedChannelIds = new Set(
[...playbackStore.channels.values()]
.filter((channel) => channel.groupId === groupId)
.map((channel) => channel.id),
);
for (const viewer of viewerStore.viewers.values()) {
if (viewer.channelId !== null && removedChannelIds.has(viewer.channelId)) {
await viewerStore.updateViewer(viewer.id, { channelId: fallbackChannelId });
}
}
await playbackStore.deleteChannelGroup(groupId, fallbackChannelId);
},
addImageReference(imageId: string, kind: "slot" | "static-hold"): void { addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 }; const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
references.set( references.set(
+28
View File
@@ -128,6 +128,34 @@ describe("viewers", () => {
expect(store.viewers.size).toBe(1); 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 () => { it("stores only the safe device identifier a paired principal carries", async () => {
const session = await openViewerSession({ const session = await openViewerSession({
at: new Date(clock), at: new Date(clock),