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, code: string): Promise { 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 { 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 { 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", ); }); });