Lumen/tests/unit/schedule.test.ts
Lumen Stage1 e83946c207
Some checks failed
ci / check (push) Has been cancelled
Checkpoint: current working state
2026-09-29 21:33:33 -05:00

528 lines
20 KiB
TypeScript

/**
* @vitest-environment jsdom
* Stage 11 — Schedule queries, favorites, ClockService state, view, wiring.
* Trace: IMPLEMENTATION-CONTRACT.md §21/§24, SPIKE-08, ADR-009.
*/
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
import { mount as boot } from "../../src/app/main.js";
import {
allTags,
artistNames,
detectOverlaps,
filterEvents,
groupByDay,
nowEvents,
stageName,
upNext,
upNextByStage,
} from "../../src/domain/schedule/logic.js";
import { buildMySchedule, myScheduleConflicts } from "../../src/domain/favorites/logic.js";
import {
dayKeyFor,
deviceTimeZone,
formatClockTime,
formatDayLabel,
formatEventTime,
} from "../../src/domain/clock/logic.js";
import { captureSkew, resolveClock } from "../../src/domain/clock/service.js";
import { shouldWarnOutsideWindow } from "../../src/domain/clock/logic.js";
import { narrowScheduleSection } from "../../src/app/schedule.js";
import { loadScheduleData } from "../../src/app/schedule.js";
import { loadPersistedSkew, persistSkew, resetClockSession } from "../../src/app/clock.js";
import { createScheduleView } from "../../src/ui/views/schedule/schedule.js";
import type { ScheduleViewInput } from "../../src/ui/views/schedule/schedule.js";
import type { ScheduleSection, ScheduleEvent } from "../../src/data/festival-package/types.js";
import { buildPackage } from "../../pipeline/package.js";
import { generateTestKeyPair } from "../../pipeline/sign.js";
import { makeValidInput } from "../../pipeline/fixtures.js";
import { canonicalJson } from "../../pipeline/canonical-json.js";
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
import { verifyPackage } from "../../src/sync/verifier/package.js";
import { activateStagedPackage, stageVerifiedPackage } from "../../src/sync/activation.js";
import { openUserDB, listFavorites } from "../../src/data/user/store.js";
import { DB } from "../../src/platform/idb/names.js";
const g = globalThis as unknown as Record<string, unknown>;
const CHI = "America/Chicago";
// 2026-09-10 12:00 UTC = 07:00 CDT.
const T0 = Date.UTC(2026, 8, 10, 12, 0, 0);
const HOUR = 3600_000;
function event(patch: Partial<ScheduleEvent> & { id: string }): ScheduleEvent {
return {
title: `Title ${patch.id}`,
stageId: "stage-a",
artistIds: ["art-1"],
startUtc: T0,
endUtc: T0 + HOUR,
dayKey: dayKeyFor(T0, CHI),
tags: [],
status: "scheduled",
...patch,
};
}
function section(): ScheduleSection {
return {
section: "schedule",
stages: [
{ id: "stage-a", name: "Main Stage" },
{ id: "stage-b", name: "Second Stage" },
],
artists: [
{ id: "art-1", name: "The Luminants" },
{ id: "art-2", name: "Solar Echo" },
],
events: [
event({ id: "e1", startUtc: T0, endUtc: T0 + HOUR, tags: ["music"] }),
event({ id: "e2", startUtc: T0 + 30 * 60_000, endUtc: T0 + 90 * 60_000, tags: ["music"] }),
event({
id: "e3",
stageId: "stage-b",
artistIds: ["art-2"],
startUtc: T0 + 3 * HOUR,
endUtc: T0 + 4 * HOUR,
tags: ["talk"],
status: "moved",
originalStartUtc: T0 + 2 * HOUR,
}),
event({
id: "e4",
stageId: "stage-b",
startUtc: T0 + 6 * HOUR,
endUtc: T0 + 7 * HOUR,
tags: ["talk"],
status: "cancelled",
}),
event({
id: "e5",
startUtc: Date.UTC(2026, 8, 11, 12, 0, 0),
endUtc: Date.UTC(2026, 8, 11, 13, 0, 0),
dayKey: "2026-09-11",
tags: ["music"],
}),
],
};
}
function viewInput(patch: Partial<ScheduleViewInput> = {}): ScheduleViewInput {
return {
section: section(),
favorites: new Set<string>(),
clock: {
now: T0 + 15 * 60_000,
zone: CHI,
festivalZone: CHI,
useDeviceZone: false,
outsideWarn: false,
driftWarn: false,
},
...patch,
};
}
function viewActions() {
return {
onToggleFavorite: vi.fn(() => true),
onZoneToggle: vi.fn(),
};
}
describe("schedule queries", () => {
it("groups by publish-time dayKey in order", () => {
const groups = groupByDay(section());
expect(groups.map((d) => d.dayKey)).toEqual(["2026-09-10", "2026-09-11"]);
expect(groups[0]?.events.map((e) => e.id)).toEqual(["e1", "e2", "e3", "e4"]);
expect(groups[0]?.anchorUtc).toBe(T0);
});
it("now/next: overlaps all show; upcoming sorted", () => {
const data = section();
expect(nowEvents(data.events, T0 + 45 * 60_000).map((e) => e.id)).toEqual(["e1", "e2"]);
expect(nowEvents(data.events, T0 - 1).map((e) => e.id)).toEqual([]);
expect(upNext(data.events, T0 + 45 * 60_000, 2).map((e) => e.id)).toEqual(["e3", "e4"]);
expect(
upNextByStage(data, T0 + 15 * 60_000, 1)
.get("stage-b")
?.map((e) => e.id),
).toEqual(["e3"]);
});
it("filters combine: day, stage, tag, status, favorites, query", () => {
const data = section();
expect(filterEvents(data, { dayKey: "2026-09-11" }).map((e) => e.id)).toEqual(["e5"]);
expect(filterEvents(data, { stageId: "stage-b" }).map((e) => e.id)).toEqual(["e3", "e4"]);
expect(filterEvents(data, { tag: "talk" }).map((e) => e.id)).toEqual(["e3", "e4"]);
expect(filterEvents(data, { statuses: ["scheduled"] }).map((e) => e.id)).toEqual([
"e1",
"e2",
"e5",
]);
expect(
filterEvents(data, { favoritesOnly: true, favoriteIds: new Set(["e3"]) }).map((e) => e.id),
).toEqual(["e3"]);
expect(filterEvents(data, { query: "luminants" }).map((e) => e.id)).toEqual([
"e1",
"e2",
"e4",
"e5",
]);
expect(filterEvents(data, { query: "SECOND" }).map((e) => e.id)).toEqual(["e3", "e4"]);
expect(filterEvents(data, { dayKey: "2026-09-11", tag: "music" }).map((e) => e.id)).toEqual([
"e5",
]);
});
it("conflicts: overlaps pair up, adjacency and cancelled excluded", () => {
const data = section();
const pairs = detectOverlaps(
[data.events[0], data.events[1], data.events[3]].filter((e): e is ScheduleEvent =>
Boolean(e),
),
);
expect(pairs).toHaveLength(1);
expect(pairs[0]?.map((e) => e.id)).toEqual(["e1", "e2"]);
const adjacent = [
event({ id: "a", startUtc: T0, endUtc: T0 + HOUR }),
event({ id: "b", startUtc: T0 + HOUR, endUtc: T0 + 2 * HOUR }),
];
expect(detectOverlaps(adjacent)).toEqual([]);
});
it("name lookups fall back to ids; tags sorted", () => {
const data = section();
expect(stageName(data, "stage-a")).toBe("Main Stage");
expect(stageName(data, "nope")).toBe("nope");
expect(artistNames(data, ["art-2", "ghost"])).toBe("Solar Echo, ghost");
expect(allTags(data)).toEqual(["music", "talk"]);
});
it("tolerates missing per-event arrays", () => {
const sparse = section();
const target = {
...sparse.events[0],
tags: undefined,
artistIds: undefined,
} as unknown as ScheduleEvent;
expect(filterEvents({ ...sparse, events: [target] }, { tag: "music" })).toEqual([]);
expect(
filterEvents({ ...sparse, events: [target] }, { query: "title" }).map((e) => e.id),
).toEqual([target.id]);
});
});
describe("favorites domain", () => {
it("builds My Schedule sorted, orphans last and kept", () => {
const items = buildMySchedule(section(), [
{ eventId: "ghost", addedAt: 2 },
{ eventId: "e3", addedAt: 1 },
]);
expect(items.map((i) => i.status)).toEqual(["moved", "orphan"]);
expect(items[1]?.event).toBeUndefined();
});
it("moved/cancelled mark; conflicts surface overlaps", () => {
const items = buildMySchedule(section(), [
{ eventId: "e1", addedAt: 1 },
{ eventId: "e2", addedAt: 2 },
{ eventId: "e4", addedAt: 3 },
]);
expect(items.map((i) => i.status)).toEqual(["scheduled", "scheduled", "cancelled"]);
const conflicts = myScheduleConflicts(items);
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.map((e) => e.id)).toEqual(["e1", "e2"]);
});
});
describe("ClockService state", () => {
it("captures skew as server minus device", () => {
const skew = captureSkew(1000, { deviceNow: 700, monoNow: 50 }, "Date header");
expect(skew).toMatchObject({ skewMs: 300, capturedAtDevice: 700, capturedAtMono: 50 });
});
it("resolves now with skew; drift re-derives base and warns once", () => {
// Threshold is 120s — jumps below it are clock noise, not drift.
const first = resolveClock({ deviceNow: 1_000_000, monoNow: 0 }, null, null);
expect(first.now).toBe(1_000_000);
expect(first.driftWarn).toBe(false);
const steady = resolveClock({ deviceNow: 1_000_010, monoNow: 10 }, null, first.session);
expect(steady.driftWarn).toBe(false);
const jumped = resolveClock({ deviceNow: 1_300_000, monoNow: 20 }, null, first.session);
expect(jumped.driftWarn).toBe(true);
const settled = resolveClock({ deviceNow: 1_300_010, monoNow: 30 }, null, jumped.session);
expect(settled.driftWarn).toBe(false);
});
it("keeps server skew across a mid-session jump", () => {
const skew = { skewMs: 500, capturedAtDevice: 0, capturedAtMono: 0, source: "t" };
const base = resolveClock({ deviceNow: 1_000_000, monoNow: 0 }, skew, null);
const jumped = resolveClock({ deviceNow: 1_300_000, monoNow: 10 }, skew, base.session);
expect(jumped.now).toBe(1_300_500);
expect(jumped.driftWarn).toBe(true);
});
it("F-3: early prep quiet; near-festival or captured skew warns", () => {
const win = { startUtc: T0, endUtc: T0 + 4 * 24 * HOUR, timezone: CHI };
const july = Date.UTC(2026, 6, 1);
expect(shouldWarnOutsideWindow(july, win, null)).toBe(false);
expect(
shouldWarnOutsideWindow(july, win, {
skewMs: 1,
capturedAtDevice: 0,
capturedAtMono: 0,
source: "t",
}),
).toBe(true);
expect(shouldWarnOutsideWindow(T0 + 60 * 24 * HOUR, win, null)).toBe(true);
expect(shouldWarnOutsideWindow(T0 + HOUR, win, null)).toBe(false);
});
it("formats in explicit zones; device zone resolves", () => {
expect(formatEventTime(Date.UTC(2026, 6, 15, 18, 0, 0), CHI)).toContain("1:00 PM");
expect(formatDayLabel(Date.UTC(2026, 8, 10, 12, 0, 0), CHI)).toContain("Thursday");
expect(formatClockTime(Date.UTC(2026, 8, 10, 12, 0, 0), CHI)).toContain("7:00 AM");
expect(deviceTimeZone()).toBeTruthy();
});
});
describe("schedule narrowing", () => {
it("accepts valid sections, rejects the rest", () => {
expect(narrowScheduleSection(section())).toBeTruthy();
for (const bad of [
null,
{},
{ section: "schedule" },
{ section: "map", stages: [], artists: [], events: [] },
]) {
expect(narrowScheduleSection(bad)).toBeNull();
}
});
});
describe("schedule view", () => {
it("null input renders the honest not-downloaded card", () => {
const view = createScheduleView(null, viewActions());
expect(view.querySelector("h1")?.textContent).toBe("Schedule");
expect(view.textContent).toContain("isn't on this device yet");
});
it("renders days, now/next, and events with statuses", () => {
const view = createScheduleView(viewInput(), viewActions());
expect(view.querySelectorAll(".schedule-day")).toHaveLength(2);
expect(view.textContent).toContain("On now");
expect(view.textContent).toContain("Up next");
expect(view.textContent).toContain("Title e1");
expect(view.textContent).toContain("Moved (was");
expect(view.textContent).toContain("Cancelled");
expect(view.querySelector(".schedule-badge--cancelled")).toBeTruthy();
});
it("day tabs and stage filter narrow the list", () => {
const view = createScheduleView(viewInput(), viewActions());
const tabs = [...view.querySelectorAll<HTMLButtonElement>(".schedule-day")];
tabs[1]?.click();
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e5");
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1");
const select = view.querySelector<HTMLSelectElement>("select");
if (!select) throw new Error("stage select missing");
select.value = "stage-b";
select.dispatchEvent(new Event("change", { bubbles: true }));
tabs[0]?.click();
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e3");
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1");
});
it("search narrows; empty result explains itself", () => {
const view = createScheduleView(viewInput(), viewActions());
const search = view.querySelector<HTMLInputElement>('input[type="search"]');
if (!search) throw new Error("search missing");
search.value = "Solar Echo";
search.dispatchEvent(new Event("input", { bubbles: true }));
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e3");
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1");
search.value = "zzz-no-match";
search.dispatchEvent(new Event("input", { bubbles: true }));
expect(view.querySelector(".schedule-list")?.textContent).toContain("No events match");
});
it("favorite toggle flips, persists via callback, and Mine filters", () => {
const actions = viewActions();
const view = createScheduleView(viewInput(), actions);
const card = view.querySelector('[data-event-id="e1"]');
const star = card?.querySelector<HTMLButtonElement>(".schedule-fav");
if (!star) throw new Error("fav button missing");
expect(star.getAttribute("aria-pressed")).toBe("false");
star.click();
expect(star.getAttribute("aria-pressed")).toBe("true");
expect(actions.onToggleFavorite).toHaveBeenCalledWith("e1", true);
const mine = view.querySelector<HTMLButtonElement>(".schedule-mine");
mine?.click();
expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e1");
expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e3");
});
it("overlap among favorites surfaces a conflict note", () => {
const view = createScheduleView(viewInput({ favorites: new Set(["e1", "e2"]) }), viewActions());
const mine = view.querySelector<HTMLButtonElement>(".schedule-mine");
mine?.click();
expect(view.querySelector(".schedule-conflicts")?.textContent).toContain(
"Overlap in My Schedule",
);
});
it("clock warning shows; zone toggle reports the flip", () => {
const actions = viewActions();
const warned = createScheduleView(
viewInput({ clock: { ...viewInput().clock, outsideWarn: true } }),
actions,
);
expect(warned.querySelector(".schedule-warning")?.textContent).toContain("clock looks off");
const zone = warned.querySelector<HTMLButtonElement>(".schedule-zone");
expect(zone?.textContent).toBe("Showing festival time");
zone?.click();
expect(actions.onZoneToggle).toHaveBeenCalledWith(true);
});
it("never creates elements from content (inert text only)", () => {
const evil = section();
const target = evil.events[0] as unknown as Record<string, unknown>;
target.title = "<img src=x onerror=alert(1)>";
const view = createScheduleView(viewInput({ section: evil }), viewActions());
// No element is ever parsed out of content — textContent only (B-7).
expect(view.querySelector("img")).toBeNull();
expect(view.querySelector('[data-event-id="e1"] h2')?.textContent).toContain("<img src=x");
});
});
describe("schedule loader + clock persistence + wiring", () => {
function deleteDb(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error ?? new Error("delete failed"));
};
request.onblocked = () => {
resolve();
};
});
}
async function fixturePackage(version: number) {
const keyPair = generateTestKeyPair();
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
const files = new Map<string, Uint8Array>();
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
const result = await verifyPackage(
{
manifestBytes,
signature: built.pkg.signature,
files: { getFile: (name) => Promise.resolve(files.get(name)) },
emergencyFloor: built.pkg.emergencyFloor,
},
{
trustedKeys: new Map([
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
},
);
if (!result.ok) throw new Error(`verify failed: ${result.reason}`);
return result;
}
beforeEach(async () => {
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
resetClockSession();
await deleteDb(DB.SYSTEM);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
await deleteDb(DB.USER);
});
afterEach(() => {
document.body.innerHTML = "";
delete g.indexedDB;
});
it("fresh install loads nothing", async () => {
expect(await loadScheduleData()).toBeNull();
});
it("staged fixture loads section, window, and empty favorites", async () => {
const pkg = await fixturePackage(1);
const staged = await stageVerifiedPackage(pkg);
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
const loaded = await loadScheduleData();
expect(loaded?.section.events.length).toBeGreaterThan(0);
expect(loaded?.window.timezone).toBe("America/Chicago");
expect(loaded?.favorites).toEqual([]);
expect(loaded?.skew).toBeNull();
});
it("skew persists across loads", async () => {
await persistSkew({ skewMs: 500, capturedAtDevice: 1000, capturedAtMono: 10, source: "test" });
expect(await loadPersistedSkew()).toMatchObject({ skewMs: 500 });
const loaded = await loadScheduleData();
// No dataset staged — loader is null, but skew survives independently.
expect(loaded).toBeNull();
expect(await loadPersistedSkew()).toMatchObject({ skewMs: 500 });
});
it("favorite survives a dataset update and still joins (X3 at schedule level)", async () => {
const first = await fixturePackage(1);
const stagedFirst = await stageVerifiedPackage(first);
expect((await activateStagedPackage(first, stagedFirst, "1.0.0")).ok).toBe(true);
const user = await openUserDB();
const { addFavorite } = await import("../../src/data/user/store.js");
await addFavorite(user, { eventId: "evt-0001", addedAt: 7 });
user.close();
const second = await fixturePackage(2);
const stagedSecond = await stageVerifiedPackage(second);
expect((await activateStagedPackage(second, stagedSecond, "1.0.0")).ok).toBe(true);
const loaded = await loadScheduleData();
expect(loaded?.favorites).toEqual([{ eventId: "evt-0001", addedAt: 7 }]);
expect(loaded?.section.events.some((e) => e.id === "evt-0001")).toBe(true);
});
it("boot at /schedule renders dataset events; star toggle persists", async () => {
const pkg = await fixturePackage(1);
const staged = await stageVerifiedPackage(pkg);
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
history.replaceState(null, "", "/schedule");
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
boot();
await vi.waitFor(() => {
expect(document.querySelector(".schedule-view")?.textContent).toContain("Opening Ceremony");
});
const star = document.querySelector<HTMLButtonElement>(
'[data-event-id="evt-0001"] .schedule-fav',
);
if (!star) throw new Error("fav button missing in wired view");
star.click();
await vi.waitFor(async () => {
const db = await openUserDB();
try {
const favs = await listFavorites(db);
expect(favs).toHaveLength(1);
expect(favs[0]?.eventId).toBe("evt-0001");
expect(typeof favs[0]?.addedAt).toBe("number");
} finally {
db.close();
}
});
document.body.innerHTML = "";
});
});