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