Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
97 lines
3.1 KiB
TypeScript
97 lines
3.1 KiB
TypeScript
import type { ModuleDrizzleDatabase } from "@ekklesia/module-database/drizzle";
|
|
import { describe, expect, it } from "vitest";
|
|
import { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
|
|
import type { NewStoredSlot } from "../src/server/store/types.js";
|
|
|
|
const TIMELINE_ID = "00000000-0000-4000-8000-000000000001";
|
|
|
|
function slot(id: string): NewStoredSlot {
|
|
return {
|
|
durationOverrideMs: null,
|
|
eligibleFrom: null,
|
|
eligibleUntil: null,
|
|
enabled: true,
|
|
id,
|
|
itemId: "image-1",
|
|
kind: "pinned-item",
|
|
presentation: {
|
|
backgroundColor: null,
|
|
backgroundDarkened: null,
|
|
backgroundMode: null,
|
|
blurPixels: null,
|
|
foregroundShadow: null,
|
|
},
|
|
providerId: "signage.native-images",
|
|
sourceId: null,
|
|
timelineId: TIMELINE_ID,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A narrow database contract double for the production timeline store.
|
|
*
|
|
* Unlike the service-level memory store, it enforces the shipped PostgreSQL
|
|
* `signage_timeline_slots_position_check` on every insert and update. That is the boundary the
|
|
* deployed bug crossed: the old collision-avoidance algorithm used negative positions, so the
|
|
* actual database rejected a native-image slot before playback ever had content to resolve.
|
|
*/
|
|
function constraintDatabase(existing: readonly { id: string; position: number }[]): {
|
|
readonly database: ModuleDrizzleDatabase;
|
|
readonly positions: number[];
|
|
} {
|
|
const positions: number[] = [];
|
|
const enforce = (values: { position?: unknown }): void => {
|
|
if (typeof values.position === "number") {
|
|
expect(values.position).toBeGreaterThanOrEqual(0);
|
|
positions.push(values.position);
|
|
}
|
|
};
|
|
const transaction = {
|
|
insert: () => ({
|
|
values: async (values: { position?: unknown }) => enforce(values),
|
|
}),
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
orderBy: async () => existing,
|
|
}),
|
|
}),
|
|
}),
|
|
update: () => ({
|
|
set: (values: { position?: unknown }) => ({
|
|
where: async () => enforce(values),
|
|
}),
|
|
}),
|
|
};
|
|
return {
|
|
database: {
|
|
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) =>
|
|
operation(transaction),
|
|
} as unknown as ModuleDrizzleDatabase,
|
|
positions,
|
|
};
|
|
}
|
|
|
|
describe("production timeline slot ordering", () => {
|
|
it("creates the first pinned native image without violating the database position constraint", async () => {
|
|
const { database, positions } = constraintDatabase([]);
|
|
|
|
await createTimelineStore(database).addSlot(slot("00000000-0000-4000-8000-000000000002"));
|
|
|
|
expect(positions).toEqual([1, 2, 0]);
|
|
});
|
|
|
|
it("reorders through a non-negative temporary range before writing the final order", async () => {
|
|
const first = "00000000-0000-4000-8000-000000000002";
|
|
const second = "00000000-0000-4000-8000-000000000003";
|
|
const { database, positions } = constraintDatabase([
|
|
{ id: first, position: 0 },
|
|
{ id: second, position: 1 },
|
|
]);
|
|
|
|
await createTimelineStore(database).reorderSlots(TIMELINE_ID, [second, first]);
|
|
|
|
expect(positions).toEqual([3, 4, 0, 1]);
|
|
});
|
|
});
|