487 lines
15 KiB
TypeScript
487 lines
15 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|