feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+486
View File
@@ -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",
);
});
});