201 lines
7.6 KiB
TypeScript
201 lines
7.6 KiB
TypeScript
/* 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("READY offers Check for updates wired to the sync flow", () => {
|
|
const a = actions();
|
|
const view = createStatusView(report(), a);
|
|
const btn = [...view.querySelectorAll("button")].find(
|
|
(b) => b.textContent === "Check for updates",
|
|
);
|
|
expect(btn).toBeTruthy();
|
|
btn?.dispatchEvent(new MouseEvent("click"));
|
|
expect(a.onGetData).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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("check notice renders as live status text when provided", () => {
|
|
const view = createStatusView(
|
|
report(),
|
|
actions(),
|
|
"All festival data checked — v1 verified on this device.",
|
|
);
|
|
const notice = view.querySelector(".status-check-notice");
|
|
expect(notice?.textContent).toContain("v1 verified");
|
|
expect(notice?.getAttribute("role")).toBe("status");
|
|
const plain = createStatusView(report(), actions());
|
|
expect(plain.querySelector(".status-check-notice")).toBeNull();
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|