Checkpoint: current working state
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-09-29 21:33:33 -05:00
commit e83946c207
57 changed files with 6257 additions and 775 deletions

View file

@ -341,30 +341,37 @@ describe("dataset metadata", () => {
describe("readiness inputs (time-independent per SPIKE-05)", () => {
it("readiness is data-driven, not clock", async () => {
const { computeReadiness } = await import("../../src/domain/readiness/types.js");
const { evaluateReadiness } = await import("../../src/domain/readiness/logic.js");
const allOk = {
shellValid: true,
datasetPresent: true,
authenticity: "verified" as const,
integrity: "ok" as const,
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
missing: [] as readonly string[],
hadVerifiedDataset: true,
manifestOk: true,
incompatible: false,
failure: null,
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
stagedPending: null,
};
expect(evaluateReadiness(allOk).state).toBe("READY");
expect(
computeReadiness({
shellValid: true,
datasetPresent: true,
authenticityOk: true,
integrityOk: true,
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
}),
).toBe("READY");
expect(
computeReadiness({
shellValid: true,
evaluateReadiness({
...allOk,
datasetPresent: false,
authenticityOk: false,
integrityOk: false,
authenticity: "record-missing",
integrity: "files-missing",
schemaCompatible: false,
requiredSectionsOk: false,
requiredAssetsOk: false,
emergencyFloorOk: true,
}),
hadVerifiedDataset: false,
presentSections: [],
}).state,
).toBe("NOT_READY");
});
});

View file

@ -0,0 +1,350 @@
/**
* @vitest-environment jsdom
* Stage 10 — Emergency three tiers: resolution matrix, Tier 2 rendering,
* demotion guidance, zero-IDB floor, loader, and boot wiring.
* Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06.
*/
/* 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 { loadDatasetEmergency } from "../../src/app/emergency.js";
import {
demotedBaseline,
isSupportedEmergencySchema,
narrowEmergencySection,
resolveEmergency,
visibleNotices,
} from "../../src/domain/emergency/logic.js";
import type { VerifiedEmergencyData } from "../../src/domain/emergency/types.js";
import type { EmergencySection } from "../../src/data/festival-package/types.js";
import { createEmergencyView } from "../../src/ui/views/emergency/emergency.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 { openSystemDB } from "../../src/data/system-meta/store.js";
import { openSlotDB, writeSlotFile } from "../../src/data/slot/store.js";
import { DB } from "../../src/platform/idb/names.js";
const g = globalThis as unknown as Record<string, unknown>;
const NOW = 1_750_000_000_000;
function section(patch: Record<string, unknown> = {}): EmergencySection {
return {
section: "emergency",
emergencySchemaVersion: 1,
contentVersion: 2,
updatedAt: "2026-09-01T00:00:00.000Z",
services: {
emergencyNumber: "911",
security: { phone: "555-0100", location: "North gate" },
firstAid: { location: "Medical tent", hours: "10:00–22:00" },
},
locations: {
musterPoints: [{ id: "m1", name: "North field", poi: "poi-muster" }],
exits: [{ id: "e1", name: "Main gate", poi: "poi-exit" }],
aeds: [{ poi: "poi-aed" }],
},
address: { lines: ["1 Festival Way"], coordinates: { lat: 29.9, lon: -97.9 } },
procedures: [{ id: "medical", title: "Medical", steps: ["Call 911", "Find staff"] }],
notices: [
{
id: "n1",
severity: "info",
title: "Gates open",
body: ["Gates open at 9am"],
expiresAtUtc: null,
},
],
...patch,
};
}
function dataset(patch: Partial<VerifiedEmergencyData> = {}): VerifiedEmergencyData {
return {
section: section(),
packageVersion: 3,
generatedAt: "2026-08-20T00:00:00.000Z",
emergencySchemaVersion: 1,
...patch,
};
}
describe("resolveEmergency — tier matrix (SPIKE-06)", () => {
it("no dataset → baseline with no-dataset demotion", () => {
const model = resolveEmergency(undefined, NOW);
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("no-dataset");
expect(model.provenance).toMatch(/^BASELINE v/);
expect(model.section).toBeNull();
});
it("valid dataset → dataset tier with merged floor fields and provenance", () => {
const sparse = section();
const { services: _dropServices, ...withoutServices } = sparse;
void _dropServices;
const model = resolveEmergency(dataset({ section: withoutServices as EmergencySection }), NOW);
expect(model.source).toBe("dataset");
expect(model.demotion).toBeNull();
// Floor-only services backfill the sparse section (defensive merge).
expect(model.section?.services.emergencyNumber).toBeTruthy();
expect(model.provenance).toContain("FESTIVAL DATA v3");
expect(model.provenance).toContain("generated 2026-08-20");
expect(model.provenance).toContain("updated 2026-09-01");
});
it("sparse sections merge floor fields instead of demoting", () => {
const sparse = section({ services: {}, procedures: [] });
const model = resolveEmergency(dataset({ section: sparse }), NOW);
expect(model.source).toBe("dataset");
expect(model.section?.services.emergencyNumber).toBeTruthy();
expect(model.section?.procedures.length).toBeGreaterThan(0);
});
it("unsupported schema → baseline with unsupported-schema demotion", () => {
const model = resolveEmergency(dataset({ emergencySchemaVersion: 99 }), NOW);
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("unsupported-schema");
expect(isSupportedEmergencySchema(99)).toBe(false);
expect(isSupportedEmergencySchema(1)).toBe(true);
});
it("malformed section → baseline with malformed-section demotion", () => {
const badValues: unknown[] = [
"nope",
42,
null,
{},
{ section: "emergency" },
{ section: "emergency", emergencySchemaVersion: 0 },
{ section: "emergency", emergencySchemaVersion: "1" },
{ section: "emergency", emergencySchemaVersion: 1, services: "911" },
{
section: "emergency",
emergencySchemaVersion: 1,
procedures: [{ id: "x" }],
},
];
for (const bad of badValues) {
const model = resolveEmergency(dataset({ section: bad as EmergencySection }), NOW);
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("malformed-section");
}
});
it("unknown future fields never throw (forward tolerance)", () => {
const future = {
...section(),
brandNewField: { deep: [1, 2, 3] },
services: { ...section().services, another: "x" },
};
const model = resolveEmergency(dataset({ section: future }), NOW);
expect(model.source).toBe("dataset");
});
it("expired notices hide; unexpiring and future notices show", () => {
const withNotices = section({
notices: [
{ id: "old", severity: "info", title: "Old", body: ["x"], expiresAtUtc: NOW - 1000 },
{ id: "live", severity: "urgent", title: "Live", body: ["y"], expiresAtUtc: NOW + 1000 },
{ id: "forever", severity: "info", title: "Forever", body: ["z"], expiresAtUtc: null },
],
});
const filtered = visibleNotices(withNotices, NOW);
expect(filtered.notices?.map((n) => n.id)).toEqual(["live", "forever"]);
const model = resolveEmergency(dataset({ section: withNotices }), NOW);
expect(model.section?.notices?.map((n) => n.id)).toEqual(["live", "forever"]);
});
it("demotedBaseline carries the reason with floor provenance", () => {
const model = demotedBaseline("unsupported-schema");
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("unsupported-schema");
expect(model.provenance).toMatch(/^BASELINE v/);
});
});
describe("narrowEmergencySection", () => {
it("accepts a valid section, rejects everything else without throwing", () => {
expect(narrowEmergencySection(section())).toBeTruthy();
for (const bad of [undefined, null, 7, "x", [], {}, { section: "schedule" }]) {
expect(narrowEmergencySection(bad)).toBeNull();
}
});
});
describe("emergency view", () => {
it("floor tier renders number, dial link, procedures, and guidance", () => {
const view = createEmergencyView(undefined, NOW);
expect(view.querySelector("h1")?.textContent).toBe("Emergency");
expect(view.querySelector('a[href^="tel:"]')).toBeTruthy();
expect(view.textContent).toContain("BASELINE v");
expect(view.textContent).toContain("isn't on this device yet");
});
it("dataset tier renders locations with map handoffs and notices", () => {
const view = createEmergencyView(dataset(), NOW);
expect(view.textContent).toContain("FESTIVAL DATA v3");
expect(view.textContent).not.toContain("isn't on this device yet");
expect(view.textContent).toContain("North field");
expect(view.textContent).toContain("[info] Gates open");
const mapLinks = [...view.querySelectorAll('a[data-route="/map"]')];
expect(mapLinks.length).toBeGreaterThan(0);
expect(view.textContent).toContain("First-aid hours: 10:00–22:00");
});
it("each demotion reason renders its own guidance", () => {
for (const [reason, hint] of [
["no-dataset", "isn't on this device yet"],
["unsupported-schema", "newer app version"],
["malformed-section", "couldn't be read"],
] as const) {
const view = createEmergencyView(demotedBaseline(reason), NOW);
expect(view.textContent).toContain("BASELINE v");
expect(view.textContent).toContain(hint);
}
});
it("renders with zero storage access (Ring-0: no indexedDB, no caches)", () => {
const idx = g.indexedDB;
const cachesGlobal = (globalThis as Record<string, unknown>).caches;
// Ring-0 proof — storage must not exist here.
delete g.indexedDB;
delete (globalThis as Record<string, unknown>).caches;
try {
const view = createEmergencyView(undefined, NOW);
expect(view.querySelector("h1")?.textContent).toBe("Emergency");
expect(view.querySelector('a[href^="tel:"]')).toBeTruthy();
} finally {
g.indexedDB = idx;
if (cachesGlobal !== undefined) {
(globalThis as Record<string, unknown>).caches = cachesGlobal;
}
}
});
it("never renders raw HTML from content", () => {
const evil = section({ procedures: [{ id: "x", title: "<img src=x>", steps: ["<b>do</b>"] }] });
const view = createEmergencyView(dataset({ section: evil }), NOW);
expect(view.innerHTML).not.toContain("<img src=x>");
expect(view.innerHTML).not.toContain("<b>do</b>");
});
});
describe("dataset emergency loader", () => {
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;
await deleteDb(DB.SYSTEM);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
});
afterEach(() => {
// Leave no polyfill behind for the zero-storage test above.
delete g.indexedDB;
});
it("fresh install → absent", async () => {
expect(await loadDatasetEmergency()).toEqual({ status: "absent" });
});
it("staged + activated fixture → ready with version detail", async () => {
const pkg = await fixturePackage(2);
const staged = await stageVerifiedPackage(pkg);
const activated = await activateStagedPackage(pkg, staged, "1.0.0");
expect(activated.ok).toBe(true);
const loaded = await loadDatasetEmergency();
expect(loaded.status).toBe("ready");
if (loaded.status !== "ready") throw new Error("unreachable");
expect(loaded.data.packageVersion).toBe(2);
expect(loaded.data.section.services.emergencyNumber).toBeTruthy();
expect(loaded.data.generatedAt).toBe(pkg.manifest.generatedAt);
});
it("malformed stored section → unusable malformed-section", async () => {
const system = await openSystemDB();
const { writeSystemMeta } = await import("../../src/data/system-meta/store.js");
const { INITIAL_SYSTEM_META } = await import("../../src/data/system-meta/types.js");
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 1,
});
system.close();
const slot = await openSlotDB("A");
await writeSlotFile(slot, "emergency", {
bytes: 32,
sha256: "0".repeat(64),
json: { section: "emergency", services: {} },
});
slot.close();
expect(await loadDatasetEmergency()).toEqual({
status: "unusable",
reason: "malformed-section",
});
});
it("boot renders Tier 2 through the real path after staging", 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, "", "/emergency");
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
boot();
await vi.waitFor(() => {
expect(document.querySelector(".emergency-view")?.textContent).toContain("FESTIVAL DATA v1");
});
document.body.innerHTML = "";
});
});

View file

@ -0,0 +1,627 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/**
* Stage 9 — Offline Ready predicate C1–C8, six-state taxonomy, boot evaluation.
* Pure truth table (every state reachable) + boot integration against
* fake-indexeddb using the Stage 8 staging/activation path.
* Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.
*/
import { describe, it, expect, beforeEach, 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 { readFileSync } from "node:fs";
const g = globalThis as unknown as Record<string, unknown>;
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
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 type { VerifiedPackage } from "../../src/sync/verifier/types.js";
import {
activateStagedPackage,
restorePreviousSlot,
stageVerifiedPackage,
} from "../../src/sync/activation.js";
import { openSystemDB, readSystemMeta, writeSystemMeta } from "../../src/data/system-meta/store.js";
import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js";
import {
initializeStaging,
markStagingComplete,
openSlotDB,
readSlotFileMeta,
writeSlotFile,
writeSlotFileWithProgress,
writeSlotManifest,
} from "../../src/data/slot/store.js";
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
import { withTx } from "../../src/platform/idb/wrapper.js";
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../../src/domain/readiness/config.js";
import {
chipForState,
evaluateReadiness,
preparationLevel,
} from "../../src/domain/readiness/logic.js";
import type { ReadinessEvidence } from "../../src/domain/readiness/types.js";
import { checkShellCache } from "../../src/platform/cache/shell-cache.js";
import { defaultBootDeps, evaluateBootReadiness } from "../../src/app/readiness.js";
import type { BootReadinessDeps } from "../../src/app/readiness.js";
const NOW = 1_750_000_000_000;
function okEvidence(patch: Partial<ReadinessEvidence> = {}): ReadinessEvidence {
return {
shellValid: true,
datasetPresent: true,
authenticity: "verified",
integrity: "ok",
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
missing: [],
hadVerifiedDataset: true,
manifestOk: true,
incompatible: false,
failure: null,
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
stagedPending: null,
...patch,
};
}
describe("readiness predicate — C1–C8 truth table (SPIKE-05 §5)", () => {
it("all true → READY, level L2, green chip", () => {
const result = evaluateReadiness(okEvidence());
expect(result.state).toBe("READY");
expect(result.missing).toEqual([]);
expect(result.level).toBe("L2");
const chip = chipForState("READY");
expect(chip.text).toBe("OFFLINE READY ✓");
expect(chip.tone).toBe("ready");
});
it("no dataset + never verified → NOT_READY; + previously verified → RECOVERY(missing)", () => {
expect(
evaluateReadiness(okEvidence({ datasetPresent: false, hadVerifiedDataset: false })).state,
).toBe("NOT_READY");
const recovery = evaluateReadiness(
okEvidence({ datasetPresent: false, hadVerifiedDataset: true }),
);
expect(recovery.state).toBe("RECOVERY");
expect(recovery.reason).toBe("missing");
});
it("record present but files gone → RECOVERY(missing), not corrupt", () => {
const result = evaluateReadiness(
okEvidence({ authenticity: "mismatch", integrity: "files-missing", missing: ["schedule"] }),
);
expect(result.state).toBe("RECOVERY");
expect(result.reason).toBe("missing");
});
it("size tampering → RECOVERY(corrupt); record mismatch alone → RECOVERY(corrupt)", () => {
expect(evaluateReadiness(okEvidence({ integrity: "corrupt" }))).toMatchObject({
state: "RECOVERY",
reason: "corrupt",
});
expect(evaluateReadiness(okEvidence({ authenticity: "mismatch" }))).toMatchObject({
state: "RECOVERY",
reason: "corrupt",
});
});
it("incompatible schema demotes to RECOVERY(incompatible), ahead of PARTIAL", () => {
const result = evaluateReadiness(
okEvidence({
schemaCompatible: false,
incompatible: true,
requiredSectionsOk: false,
missing: ["map"],
}),
);
expect(result.state).toBe("RECOVERY");
expect(result.reason).toBe("incompatible");
});
it("files present but record missing → PARTIAL(verification), full re-verify path", () => {
const result = evaluateReadiness(okEvidence({ authenticity: "record-missing" }));
expect(result.state).toBe("PARTIAL");
expect(result.missing).toContain("verification");
});
it("staged manifest missing → PARTIAL(manifest)", () => {
const result = evaluateReadiness(
okEvidence({ manifestOk: false, requiredSectionsOk: false, missing: ["manifest"] }),
);
expect(result.state).toBe("PARTIAL");
expect(result.missing).toContain("manifest");
});
it("missing required sections/assets → PARTIAL(list); optional never gates", () => {
const partial = evaluateReadiness(
okEvidence({ requiredAssetsOk: false, missing: ["asset:map-base-overview"] }),
);
expect(partial.state).toBe("PARTIAL");
expect(partial.missing).toEqual(["asset:map-base-overview"]);
// Optional sections are not evidence at all — absence changes nothing.
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
});
it("session failure → FAILED ahead of everything, even READY evidence", () => {
const result = evaluateReadiness(okEvidence({ failure: { op: "activation" } }));
expect(result.state).toBe("FAILED");
expect(result.failedOp).toBe("activation");
expect(chipForState("FAILED", "activation").tone).toBe("failed");
});
it("broken floor build → FAILED(emergency-floor)", () => {
const result = evaluateReadiness(okEvidence({ emergencyFloorOk: false }));
expect(result.state).toBe("FAILED");
expect(result.failedOp).toBe("emergency-floor");
});
it("shell cache missing + dataset → PARTIAL(shell-cache); without dataset → NOT_READY", () => {
expect(evaluateReadiness(okEvidence({ shellValid: false })).state).toBe("PARTIAL");
expect(evaluateReadiness(okEvidence({ shellValid: false })).missing).toContain("shell-cache");
expect(
evaluateReadiness(
okEvidence({ shellValid: false, datasetPresent: false, hadVerifiedDataset: false }),
).state,
).toBe("NOT_READY");
});
it("interrupted staging with no active pointer → PARTIAL (resumable)", () => {
const result = evaluateReadiness(
okEvidence({
datasetPresent: false,
hadVerifiedDataset: false,
requiredSectionsOk: false,
requiredAssetsOk: false,
missing: ["info", "map", "assets"],
presentSections: ["emergency", "schedule"],
stagedPending: { missing: ["info", "map", "assets"] },
}),
);
expect(result.state).toBe("PARTIAL");
expect(result.level).toBe("L1");
});
it("preparation levels L0/L1/L2 follow staged sections, not trust", () => {
expect(preparationLevel([])).toBe("L0");
expect(preparationLevel(["emergency"])).toBe("L0");
expect(preparationLevel(["emergency", "schedule"])).toBe("L1");
expect(preparationLevel(["emergency", "schedule", "info", "map", "assets"])).toBe("L2");
});
it("chip mapping covers every state", () => {
expect(chipForState("PARTIAL", "map").tone).toBe("partial");
expect(chipForState("NOT_READY").text).toBe("Get festival data");
expect(chipForState("RECOVERY", "missing").text).toContain("missing");
expect(chipForState("BASELINE_ONLY").tone).toBe("baseline");
});
it("verdicts do not consult clocks", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2031-05-04T12:00:00Z"));
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
vi.setSystemTime(new Date("1999-01-01T00:00:00Z"));
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
} finally {
vi.useRealTimers();
}
});
it("APP_VERSION matches package.json; schema range covers v1", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
expect(APP_VERSION).toBe(pkg.version);
expect(SUPPORTED_SCHEMA_RANGE).toContain(1);
});
});
describe("shell cache check (C1)", () => {
it("reports unavailable where Cache Storage is absent (Node)", async () => {
expect(await checkShellCache()).toMatchObject({ ok: false, cacheName: null });
});
});
// ——— Boot integration ———
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 database failed"));
};
request.onblocked = () => {
resolve();
};
});
}
async function fixture(version = 1): Promise<VerifiedPackage> {
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(`fixture verify failed: ${result.reason}`);
return result;
}
function testDeps(patch: Partial<BootReadinessDeps> = {}): BootReadinessDeps {
return {
...defaultBootDeps,
checkShell: () => Promise.resolve({ ok: true, cacheName: "lumen-shell-test" }),
estimate: () => Promise.resolve(null),
// Pipeline fixtures declare minAppVersion 1.0.0 / schema 1 (activation.test
// does the same) — the dev shell version in package.json is younger.
appVersion: "1.0.0",
supportedSchemaRange: [1],
now: () => NOW,
...patch,
};
}
async function activateFixture(version: number): Promise<VerifiedPackage> {
const pkg = await fixture(version);
const staged = await stageVerifiedPackage(pkg);
const activated = await activateStagedPackage(pkg, staged, APP_VERSION, () => NOW);
expect(activated.ok).toBe(true);
return pkg;
}
beforeEach(async () => {
await deleteDb(DB.SYSTEM);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
await deleteDb(DB.USER);
});
describe("boot evaluation", () => {
it("fresh install → NOT_READY, L0, floor present", async () => {
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("NOT_READY");
expect(report.level).toBe("L0");
expect(report.floorVersion).toBeTruthy();
expect(report.canRestore).toBe(false);
});
it("staged + activated fixture → READY with version detail, L2", async () => {
const pkg = await activateFixture(1);
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
expect(report.edition).toBe(pkg.manifest.edition);
expect(report.packageVersion).toBe(1);
expect(report.generatedAt).toBe(pkg.manifest.generatedAt);
// Journal progress timestamps come from the real clock at staging time —
// display only, never a verdict input.
expect(report.fetchedAt).toBeGreaterThan(0);
expect(report.sections).toHaveLength(5);
expect(report.sections.every((s) => s.present && s.parseable)).toBe(true);
expect(report.assets.total).toBeGreaterThan(0);
expect(report.assets.present).toBe(report.assets.total);
expect(report.level).toBe("L2");
expect(report.shellCache).toBe("lumen-shell-test");
});
it("second update retains rollback slot → canRestore; restore returns to v1 READY", async () => {
await activateFixture(1);
await activateFixture(2);
const updated = await evaluateBootReadiness(testDeps());
expect(updated.state).toBe("READY");
expect(updated.packageVersion).toBe(2);
expect(updated.canRestore).toBe(true);
expect(await restorePreviousSlot()).toBe(true);
const restored = await evaluateBootReadiness(testDeps());
expect(restored.state).toBe("READY");
expect(restored.packageVersion).toBe(1);
});
it("slot loss with surviving record → RECOVERY(missing)", async () => {
await activateFixture(1);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("missing");
});
it("size tampering post-activation → RECOVERY(corrupt)", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
system.close();
const slot = await openSlotDB(meta.activeSlot ?? "A");
const current = await readSlotFileMeta(slot, "schedule");
await writeSlotFile(slot, "schedule", {
bytes: (current?.bytes ?? 100) + 7,
sha256: current?.sha256 ?? "0".repeat(64),
json: { section: "schedule" },
});
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("corrupt");
});
it("staged manifest deleted → PARTIAL(manifest), re-verify path", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
system.close();
const slot = await openSlotDB(meta.activeSlot ?? "A");
await withTx(slot, SLOT_FILES, "readwrite", async (tx) => {
await new Promise<void>((resolve, reject) => {
const req = tx.objectStore(SLOT_FILES).delete("manifest");
req.onsuccess = () => {
resolve();
};
req.onerror = () => {
reject(req.error ?? new Error("delete failed"));
};
});
});
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.missing).toContain("manifest");
});
it("verification record wiped but files present → PARTIAL(verification)", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
await writeSystemMeta(system, { ...meta, verification: null });
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.missing).toContain("verification");
});
it("interrupted staging (emergency+schedule) with no active → PARTIAL, L1 core-ready", async () => {
const pkg = await fixture(1);
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: pkg.manifest.edition,
packageVersion: 1,
manifestSha256: pkg.manifestSha256,
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule"] as const) {
const entry = pkg.manifest.sections[id];
const bytes = pkg.files.get(entry.file);
if (!bytes) throw new Error("fixture file missing");
journal = await writeSlotFileWithProgress(
slot,
id,
{
bytes: entry.bytes,
sha256: entry.sha256,
json: JSON.parse(new TextDecoder().decode(bytes)) as unknown,
},
journal,
);
}
expect(journal.complete).toBe(false);
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.level).toBe("L1");
expect(report.missing).toEqual(expect.arrayContaining(["info", "map", "assets"]));
expect(report.activationPending).toBe(false);
});
it("hand-built incompatible package (schema 99) → RECOVERY(incompatible)", async () => {
const manifest = {
format: "lumen.package/1",
edition: "test-edition",
packageVersion: 9,
schemaVersion: 99,
generatedAt: "2026-09-01T00:00:00.000Z",
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10 },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
},
counts: { events: 0, pois: 0, assets: 0 },
limits: { totalBytes: 52 },
} as unknown as FestivalManifest;
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: "test-edition",
packageVersion: 9,
manifestSha256: "f".repeat(64),
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule", "map", "info"] as const) {
journal = await writeSlotFileWithProgress(
slot,
id,
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
journal,
);
}
journal = await writeSlotFileWithProgress(
slot,
"assets",
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
journal,
);
await writeSlotManifest(slot, manifest);
journal = await markStagingComplete(slot);
expect(journal.complete).toBe(true);
slot.close();
const system = await openSystemDB();
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 9,
verification: {
packageVersion: 9,
edition: "test-edition",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "test-key",
verifiedAt: NOW,
appVersionAtActivation: APP_VERSION,
},
appVersionAtActivation: APP_VERSION,
});
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("incompatible");
});
it("optional section (required:false) absent → still READY", async () => {
const manifest = {
format: "lumen.package/1",
edition: "test-edition",
packageVersion: 3,
schemaVersion: 1,
generatedAt: "2026-09-01T00:00:00.000Z",
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10, required: false },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
},
counts: { events: 0, pois: 0, assets: 0 },
limits: { totalBytes: 42 },
} as unknown as FestivalManifest;
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: "test-edition",
packageVersion: 3,
manifestSha256: "f".repeat(64),
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule", "map"] as const) {
journal = await writeSlotFileWithProgress(
slot,
id,
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
journal,
);
}
journal = await writeSlotFileWithProgress(
slot,
"assets",
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
journal,
);
await writeSlotManifest(slot, manifest);
await markStagingComplete(slot);
slot.close();
const system = await openSystemDB();
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 3,
verification: {
packageVersion: 3,
edition: "test-edition",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "test-key",
verifiedAt: NOW,
appVersionAtActivation: APP_VERSION,
},
appVersionAtActivation: APP_VERSION,
});
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
expect(report.sections.find((s) => s.id === "info")).toMatchObject({
required: false,
present: false,
});
});
it("readbackPending left by a kill resolves at next boot → READY", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
expect(meta.readbackPending).toBe(false);
await writeSystemMeta(system, { ...meta, readbackPending: true });
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
const reopened = await openSystemDB();
expect((await readSystemMeta(reopened)).readbackPending).toBe(false);
reopened.close();
});
it("unavailable storage → BASELINE_ONLY; slot IDB error → FAILED", async () => {
const baseline = await evaluateBootReadiness(
testDeps({
openSystem: () => Promise.reject(new Error("denied")),
}),
);
expect(baseline.state).toBe("BASELINE_ONLY");
expect(baseline.floorVersion).toBeTruthy();
await activateFixture(1);
const failed = await evaluateBootReadiness(
testDeps({
openSlot: () => Promise.reject(new Error("IDB exploded")),
}),
);
expect(failed.state).toBe("FAILED");
expect(failed.failedOp).toBe("boot-check");
});
it("stale staging (>7d) is not resumable → NOT_READY", async () => {
const pkg = await fixture(1);
const slot = await openSlotDB("A");
const journal = await initializeStaging(slot, {
edition: pkg.manifest.edition,
packageVersion: 1,
manifestSha256: pkg.manifestSha256,
startedAt: NOW - 8 * 24 * 60 * 60 * 1000,
lastProgressAt: NOW - 8 * 24 * 60 * 60 * 1000,
});
expect(journal.complete).toBe(false);
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("NOT_READY");
});
});

528
tests/unit/schedule.test.ts Normal file
View 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 = "";
});
});

177
tests/unit/status.test.ts Normal file
View file

@ -0,0 +1,177 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/**
* @vitest-environment jsdom
* Stage 9 — Status screen rendering and header-chip boot wiring.
* Trace: IMPLEMENTATION-CONTRACT.md §20.5, SPIKE-05 §3.
*/
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 { createPrepGuidanceView, createStatusView } from "../../src/ui/views/status/status.js";
import type { BootReadinessReport } from "../../src/domain/readiness/types.js";
// jsdom implements <dialog> but not showModal — stub it like a real browser.
function stubDialog(): void {
const proto = window.HTMLDialogElement.prototype as unknown as Record<string, unknown>;
if (
typeof proto.showModal !== "function" ||
proto.showModal.toString().includes("not implemented")
) {
proto.showModal = function (this: Element): void {
this.setAttribute("open", "");
};
}
}
function report(patch: Partial<BootReadinessReport> = {}): BootReadinessReport {
return {
state: "READY",
reason: null,
missing: [],
failedOp: null,
level: "L2",
edition: "lumen-2026",
packageVersion: 1,
generatedAt: "2026-09-01T00:00:00.000Z",
fetchedAt: 1_750_000_000_000,
sections: [
{ id: "emergency", required: true, present: true, parseable: true },
{ id: "schedule", required: true, present: true, parseable: true },
],
assets: { total: 2, present: 2, missing: [] },
floorVersion: "1.0.0+1",
storage: { usage: 1024, quota: 2048 },
shellCache: "lumen-shell-test",
canRestore: false,
activationPending: false,
...patch,
};
}
function actions() {
return {
onCheckData: vi.fn(),
onGetData: vi.fn(),
onRestore: null as (() => void) | null,
onClose: vi.fn(),
};
}
describe("Status view", () => {
it("READY shows checklist, versions, storage, floor — no recovery actions", () => {
const view = createStatusView(report(), actions());
expect(view.querySelector("h1")?.textContent).toBe("Offline status");
expect(view.textContent).toContain("State: READY");
expect(view.textContent).toContain("v1");
expect(view.textContent).toContain("1.0 KB used of 2.0 KB");
expect(view.textContent).toContain("Emergency floor");
expect(view.textContent).not.toContain("Restore previous version");
expect(view.querySelectorAll(".status-check--ok")).toHaveLength(2);
});
it("PARTIAL enumerates missing and offers Continue setup", () => {
const a = actions();
const view = createStatusView(
report({ state: "PARTIAL", level: "L1", missing: ["map", "assets"] }),
a,
);
expect(view.textContent).toContain("Missing: map, assets");
const buttons = [...view.querySelectorAll("button")].map((b) => b.textContent);
expect(buttons).toContain("Continue setup");
view.querySelectorAll("button")[0]?.click();
expect(a.onGetData).toHaveBeenCalledTimes(1);
});
it("RECOVERY with retained slot offers both restore paths", () => {
const onRestore = vi.fn();
const view = createStatusView(report({ state: "RECOVERY", reason: "missing", level: "L2" }), {
...actions(),
onRestore,
});
const labels = [...view.querySelectorAll("button")].map((b) => b.textContent);
expect(labels).toContain("Restore festival data");
expect(labels).toContain("Restore previous version");
[...view.querySelectorAll("button")]
.find((b) => b.textContent === "Restore previous version")
?.click();
expect(onRestore).toHaveBeenCalledTimes(1);
});
it("FAILED offers Retry and keeps diagnostics on-device", () => {
const a = actions();
const view = createStatusView(report({ state: "FAILED", failedOp: "activation" }), a);
expect(view.textContent).toContain("Failed operation: activation");
[...view.querySelectorAll("button").values()].find((b) => b.textContent === "Retry")?.click();
expect(a.onCheckData).toHaveBeenCalledTimes(1);
});
it("BASELINE_ONLY explains itself with install guidance, no dead actions", () => {
const view = createStatusView(report({ state: "BASELINE_ONLY", level: "L0" }), actions());
expect(view.textContent).toContain("cannot save offline data");
expect(view.querySelector("button")?.textContent).toBe("Check my data");
});
it("renders no raw HTML — all content is text", () => {
const view = createStatusView(
report({ edition: "<img src=x>", missing: ["<b>map</b>"] }),
actions(),
);
expect(view.innerHTML).not.toContain("<img src=x>");
expect(view.innerHTML).not.toContain("<b>map</b>");
});
it("prep guidance has Back and Close", () => {
const onBack = vi.fn();
const onClose = vi.fn();
const view = createPrepGuidanceView(onBack, onClose);
expect(view.querySelector("h1")?.textContent).toBe("Get festival data");
[...view.querySelectorAll("button").values()]
.find((b) => b.textContent === "Back to status")
?.click();
[...view.querySelectorAll("button").values()].find((b) => b.textContent === "Close")?.click();
expect(onBack).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe("header chip boot wiring", () => {
beforeEach(() => {
stubDialog();
history.replaceState(null, "", "/");
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
});
afterEach(() => {
document.body.innerHTML = "";
// Remove the polyfill for the no-IDB case.
delete (globalThis as Record<string, unknown>).indexedDB;
});
it("without storage the chip reports BASELINE_ONLY, never blank", async () => {
boot();
const chip = document.querySelector(".app-header__status");
await vi.waitFor(() => {
expect(chip?.textContent).toBe("SAVING UNAVAILABLE");
});
expect(chip?.getAttribute("aria-label")).toContain("cannot save offline data");
});
it("clicking the chip re-evaluates and opens the Status dialog, not a stale report", async () => {
const g = globalThis as unknown as Record<string, unknown>;
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
boot();
const chip = document.querySelector<HTMLElement>(".app-header__status");
await vi.waitFor(() => {
expect(chip?.textContent).toBe("Get festival data");
});
// Fresh boot: NOT_READY. Clicking must re-evaluate (not reuse the
// boot-time report) — the dialog proves which report was used.
chip?.click();
await vi.waitFor(() => {
expect(document.querySelector("dialog[open]")?.textContent).toContain("State: NOT_READY");
});
});
});