feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { SignageError } from "../src/server/errors.js";
|
||||
import type { StoredTimelineDetail } from "../src/server/store/types.js";
|
||||
import {
|
||||
addProviderSource,
|
||||
addSlot,
|
||||
createTimeline,
|
||||
deleteTimeline,
|
||||
duplicateTimeline,
|
||||
removeProviderSource,
|
||||
removeSlot,
|
||||
reorderSlots,
|
||||
updateSlot,
|
||||
updateTimeline,
|
||||
} from "../src/server/timelines/service.js";
|
||||
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||
|
||||
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("timeline management", () => {
|
||||
let store: MemorySignageStore;
|
||||
let counter: number;
|
||||
|
||||
const newId = () => {
|
||||
counter += 1;
|
||||
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMemorySignageStore();
|
||||
counter = 0;
|
||||
});
|
||||
|
||||
async function seedTimeline(name = "Sunday morning"): Promise<StoredTimelineDetail> {
|
||||
return createTimeline({ name, newId, store });
|
||||
}
|
||||
|
||||
/** The pattern from the product definition: three announcement slots, one trivia, two more
|
||||
* announcements, one scripture — with the five announcement occurrences sharing one source. */
|
||||
async function seedPattern(): Promise<StoredTimelineDetail> {
|
||||
let detail = await seedTimeline();
|
||||
const timelineId = detail.timeline.id;
|
||||
detail = await addProviderSource({
|
||||
label: "Active announcements",
|
||||
newId,
|
||||
providerId: "fixture.announcements",
|
||||
sourceId: "active-slides",
|
||||
store,
|
||||
timelineId,
|
||||
});
|
||||
detail = await addProviderSource({
|
||||
label: "Open questions",
|
||||
newId,
|
||||
providerId: "fixture.trivia",
|
||||
sourceId: "open-questions",
|
||||
store,
|
||||
timelineId,
|
||||
});
|
||||
detail = await addProviderSource({
|
||||
label: "Reading plan",
|
||||
newId,
|
||||
providerId: "fixture.scripture",
|
||||
sourceId: "reading-plan",
|
||||
store,
|
||||
timelineId,
|
||||
});
|
||||
const [announcements, trivia, scripture] = detail.sources;
|
||||
if (!announcements || !trivia || !scripture) {
|
||||
throw new Error("Expected three sources.");
|
||||
}
|
||||
for (const source of [
|
||||
announcements,
|
||||
announcements,
|
||||
announcements,
|
||||
trivia,
|
||||
announcements,
|
||||
announcements,
|
||||
scripture,
|
||||
]) {
|
||||
detail = await addSlot({
|
||||
newId,
|
||||
source: { sourceId: source.id },
|
||||
store,
|
||||
timelineId,
|
||||
});
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
it("creates a timeline on demand, with no preset pattern of any kind", async () => {
|
||||
const detail = await seedTimeline("Christmas Eve");
|
||||
|
||||
expect(detail.timeline.name).toBe("Christmas Eve");
|
||||
expect(detail.timeline.revision).toBe(1);
|
||||
// No slots, no sources, no "Sunday Morning" a church never asked for.
|
||||
expect(detail.slots).toEqual([]);
|
||||
expect(detail.sources).toEqual([]);
|
||||
expect(await store.listTimelines()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lets five slots share one source, so they are one rotation rather than five", async () => {
|
||||
const detail = await seedPattern();
|
||||
const announcements = detail.sources[0];
|
||||
|
||||
expect(detail.slots).toHaveLength(7);
|
||||
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
// Five occurrences, one selection scope. The provider advances that one scope each time.
|
||||
const announcementSlots = detail.slots.filter((slot) => slot.sourceId === announcements?.id);
|
||||
expect(announcementSlots).toHaveLength(5);
|
||||
expect(new Set(announcementSlots.map((slot) => slot.sourceId)).size).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps positions unique and contiguous through every kind of edit", async () => {
|
||||
let detail = await seedPattern();
|
||||
const timelineId = detail.timeline.id;
|
||||
|
||||
// Insert in the middle.
|
||||
const source = detail.sources[1];
|
||||
if (!source) {
|
||||
throw new Error("Expected a source.");
|
||||
}
|
||||
detail = await addSlot({
|
||||
newId,
|
||||
position: 2,
|
||||
source: { sourceId: source.id },
|
||||
store,
|
||||
timelineId,
|
||||
});
|
||||
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
expect(detail.slots[2]?.sourceId).toBe(source.id);
|
||||
|
||||
// Remove from the middle.
|
||||
const removed = detail.slots[3];
|
||||
if (!removed) {
|
||||
throw new Error("Expected a slot.");
|
||||
}
|
||||
detail = await removeSlot({ slotId: removed.id, store, timelineId });
|
||||
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
expect(detail.slots.some((slot) => slot.id === removed.id)).toBe(false);
|
||||
|
||||
// Reverse the whole order.
|
||||
const reversed = [...detail.slots].reverse().map((slot) => slot.id);
|
||||
detail = await reorderSlots({ orderedSlotIds: reversed, store, timelineId });
|
||||
expect(detail.slots.map((slot) => slot.id)).toEqual(reversed);
|
||||
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it("keeps a slot's identity across a reorder, so history and a display cursor survive it", async () => {
|
||||
let detail = await seedPattern();
|
||||
const before = detail.slots.map((slot) => slot.id);
|
||||
|
||||
detail = await reorderSlots({
|
||||
orderedSlotIds: [...before].reverse(),
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
|
||||
// Every id still exists. Only position changed.
|
||||
expect(new Set(detail.slots.map((slot) => slot.id))).toEqual(new Set(before));
|
||||
});
|
||||
|
||||
it("refuses a reorder that is not exactly this timeline's slots", async () => {
|
||||
const detail = await seedPattern();
|
||||
const ids = detail.slots.map((slot) => slot.id);
|
||||
const first = ids[0] as string;
|
||||
|
||||
for (const bad of [
|
||||
ids.slice(1), // missing one
|
||||
[...ids, first], // duplicated
|
||||
[...ids.slice(1), "11111111-1111-4111-8111-111111111111"], // a stranger
|
||||
]) {
|
||||
await expectSignageCode(
|
||||
reorderSlots({ orderedSlotIds: bad, store, timelineId: detail.timeline.id }),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
}
|
||||
// Nothing moved.
|
||||
const after = await store.findTimeline(detail.timeline.id);
|
||||
expect(after?.slots.map((slot) => slot.id)).toEqual(ids);
|
||||
});
|
||||
|
||||
it("removes every slot that referenced a deleted source, and closes the gap", async () => {
|
||||
let detail = await seedPattern();
|
||||
const announcements = detail.sources[0];
|
||||
if (!announcements) {
|
||||
throw new Error("Expected a source.");
|
||||
}
|
||||
|
||||
detail = await removeProviderSource({
|
||||
sourceId: announcements.id,
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
|
||||
// The five announcement occurrences went with it; trivia and scripture remain, renumbered.
|
||||
expect(detail.slots).toHaveLength(2);
|
||||
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1]);
|
||||
expect(detail.sources).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("bumps the revision on a semantic change and not on an exact no-op", async () => {
|
||||
let detail = await seedTimeline("Sunday morning");
|
||||
const initial = detail.timeline.revision;
|
||||
|
||||
detail = await updateTimeline({
|
||||
changes: { name: "Sunday morning" },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
expect(detail.timeline.revision).toBe(initial);
|
||||
|
||||
detail = await updateTimeline({
|
||||
changes: { name: "Sunday evening" },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
expect(detail.timeline.revision).toBe(initial + 1);
|
||||
|
||||
// A slot edit is a timeline change too: a client comparing revisions must be woken by it.
|
||||
const withSource = await addProviderSource({
|
||||
label: "Active announcements",
|
||||
newId,
|
||||
providerId: "fixture.announcements",
|
||||
sourceId: "active-slides",
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
const source = withSource.sources[0];
|
||||
if (!source) {
|
||||
throw new Error("Expected a source.");
|
||||
}
|
||||
const withSlot = await addSlot({
|
||||
newId,
|
||||
source: { sourceId: source.id },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
const slot = withSlot.slots[0];
|
||||
if (!slot) {
|
||||
throw new Error("Expected a slot.");
|
||||
}
|
||||
const beforeSlotEdit = withSlot.timeline.revision;
|
||||
|
||||
const unchanged = await updateSlot({
|
||||
changes: { enabled: true },
|
||||
slotId: slot.id,
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
expect(unchanged.timeline.revision).toBe(beforeSlotEdit);
|
||||
|
||||
const disabled = await updateSlot({
|
||||
changes: { enabled: false },
|
||||
slotId: slot.id,
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
expect(disabled.timeline.revision).toBe(beforeSlotEdit + 1);
|
||||
expect(disabled.slots[0]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("duplicates a timeline as a genuinely separate program", async () => {
|
||||
const original = await seedPattern();
|
||||
|
||||
const copy = await duplicateTimeline({
|
||||
name: "Sunday evening",
|
||||
newId,
|
||||
store,
|
||||
timelineId: original.timeline.id,
|
||||
});
|
||||
|
||||
expect(copy.timeline.name).toBe("Sunday evening");
|
||||
expect(copy.slots).toHaveLength(original.slots.length);
|
||||
expect(copy.slots.map((slot) => slot.position)).toEqual(original.slots.map((s) => s.position));
|
||||
// Every identity is new: a copy that shared them would share history and, worse, a rotation.
|
||||
expect(copy.timeline.id).not.toBe(original.timeline.id);
|
||||
for (const slot of copy.slots) {
|
||||
expect(original.slots.some((other) => other.id === slot.id)).toBe(false);
|
||||
}
|
||||
const originalScopes = new Set(
|
||||
[...store.sources.values()]
|
||||
.filter((source) => source.timelineId === original.timeline.id)
|
||||
.map((source) => source.selectionScope),
|
||||
);
|
||||
const copyScopes = [...store.sources.values()]
|
||||
.filter((source) => source.timelineId === copy.timeline.id)
|
||||
.map((source) => source.selectionScope);
|
||||
expect(copyScopes).toHaveLength(3);
|
||||
for (const scope of copyScopes) {
|
||||
// The copy rotates independently: an evening loop does not continue the morning's cursor.
|
||||
expect(originalScopes.has(scope)).toBe(false);
|
||||
}
|
||||
// The copy's slots still share one source the same way the original's did.
|
||||
const copyAnnouncements = copy.slots.filter((slot) => slot.sourceId === copy.sources[0]?.id);
|
||||
expect(copyAnnouncements).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("refuses to delete a timeline playback still points at", async () => {
|
||||
const detail = await seedTimeline();
|
||||
store.setTimelineUsage(detail.timeline.id, {
|
||||
activeGroups: 1,
|
||||
pendingGroups: 0,
|
||||
scheduledGroups: 1,
|
||||
});
|
||||
|
||||
const error = await expectSignageCode(
|
||||
deleteTimeline({ store, timelineId: detail.timeline.id }),
|
||||
"SIGNAGE_CONFLICT",
|
||||
);
|
||||
|
||||
expect(error.detail).toEqual({ activeGroups: 1, pendingGroups: 0, scheduledGroups: 1 });
|
||||
expect(store.timelines.size).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes an unused timeline and everything inside it", async () => {
|
||||
const detail = await seedPattern();
|
||||
|
||||
await deleteTimeline({ store, timelineId: detail.timeline.id });
|
||||
|
||||
expect(store.timelines.size).toBe(0);
|
||||
expect(store.slots.size).toBe(0);
|
||||
expect(store.sources.size).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a slot that is both a source and a pinned item, or neither", async () => {
|
||||
const detail = await seedTimeline();
|
||||
const withSource = await addProviderSource({
|
||||
label: "Active announcements",
|
||||
newId,
|
||||
providerId: "fixture.announcements",
|
||||
sourceId: "active-slides",
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
const source = withSource.sources[0];
|
||||
if (!source) {
|
||||
throw new Error("Expected a source.");
|
||||
}
|
||||
|
||||
await expectSignageCode(
|
||||
addSlot({ newId, store, timelineId: detail.timeline.id }),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
await expectSignageCode(
|
||||
addSlot({
|
||||
newId,
|
||||
pinned: { itemId: "welcome", providerId: "fixture.announcements" },
|
||||
source: { sourceId: source.id },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
}),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a slot referencing a source configured on a different timeline", async () => {
|
||||
const first = await seedTimeline("First");
|
||||
const second = await seedTimeline("Second");
|
||||
const withSource = await addProviderSource({
|
||||
label: "Active announcements",
|
||||
newId,
|
||||
providerId: "fixture.announcements",
|
||||
sourceId: "active-slides",
|
||||
store,
|
||||
timelineId: first.timeline.id,
|
||||
});
|
||||
const source = withSource.sources[0];
|
||||
if (!source) {
|
||||
throw new Error("Expected a source.");
|
||||
}
|
||||
|
||||
await expectSignageCode(
|
||||
addSlot({ newId, source: { sourceId: source.id }, store, timelineId: second.timeline.id }),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a pinned item as an opaque identity from another module", async () => {
|
||||
const detail = await seedTimeline();
|
||||
|
||||
const withPinned = await addSlot({
|
||||
newId,
|
||||
pinned: { itemId: "advent-service", providerId: "fixture.announcements" },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
|
||||
expect(withPinned.slots[0]).toMatchObject({
|
||||
itemId: "advent-service",
|
||||
kind: "pinned-item",
|
||||
providerId: "fixture.announcements",
|
||||
sourceId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an inverted or empty eligibility window rather than silently never showing a slot", async () => {
|
||||
const detail = await seedTimeline();
|
||||
const from = new Date("2026-12-24T18:00:00.000Z");
|
||||
const until = new Date("2026-12-24T17:00:00.000Z");
|
||||
|
||||
await expectSignageCode(
|
||||
createTimeline({ eligibleFrom: from, eligibleUntil: until, name: "Bad", newId, store }),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
await expectSignageCode(
|
||||
addSlot({
|
||||
eligibleFrom: from,
|
||||
eligibleUntil: from,
|
||||
newId,
|
||||
pinned: { itemId: "x", providerId: "p" },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
}),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses presentation values it cannot render, and stores the ones it can", async () => {
|
||||
const detail = await seedTimeline();
|
||||
|
||||
await expectSignageCode(
|
||||
addSlot({
|
||||
newId,
|
||||
pinned: { itemId: "x", providerId: "p" },
|
||||
presentation: { backgroundColor: "red; background: url(x)" },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
}),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
|
||||
const stored = await addSlot({
|
||||
newId,
|
||||
pinned: { itemId: "x", providerId: "p" },
|
||||
presentation: { backgroundColor: "#112233", backgroundMode: "solid", blurPixels: 30 },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
});
|
||||
|
||||
expect(stored.slots[0]?.presentation).toEqual({
|
||||
backgroundColor: "#112233",
|
||||
backgroundDarkened: null,
|
||||
backgroundMode: "solid",
|
||||
blurPixels: 30,
|
||||
foregroundShadow: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a duration outside what a slide can usefully be", async () => {
|
||||
const detail = await seedTimeline();
|
||||
|
||||
for (const durationOverrideMs of [0, 999, 3_600_001, 1.5]) {
|
||||
await expectSignageCode(
|
||||
addSlot({
|
||||
durationOverrideMs,
|
||||
newId,
|
||||
pinned: { itemId: "x", providerId: "p" },
|
||||
store,
|
||||
timelineId: detail.timeline.id,
|
||||
}),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports an unknown timeline as not found without saying whether it ever existed", async () => {
|
||||
await expectSignageCode(
|
||||
updateTimeline({
|
||||
changes: { name: "x" },
|
||||
store,
|
||||
timelineId: "11111111-1111-4111-8111-111111111111",
|
||||
}),
|
||||
"SIGNAGE_NOT_FOUND",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user