This commit is contained in:
parent
fcc18ddcc8
commit
e83946c207
57 changed files with 6257 additions and 775 deletions
528
tests/unit/schedule.test.ts
Normal file
528
tests/unit/schedule.test.ts
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
/**
|
||||
* @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 = "";
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue