cameras/src/app/icons.test.tsx
Michael Mainguy f63fb664dd Click the picture to pop out; put settings behind an icon
The snapshot is now the way into the live pop-out, as a link, so keyboard
and a blocked popup both still work. The icon over the top right is the
camera's own page instead: a pencil, named "Settings for <camera>". The
pop-out icon is gone, so a card carries one icon, not two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 07:25:29 -05:00

205 lines
9.1 KiB
TypeScript

// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraSummary } from "@/lib/camera-registry";
import { json, renderWithQuery, stubFetch } from "../../test/dom";
/**
* Icons (vrek gol-m8je1wc, dec-1vnz5fw): each control and warning carries the agreed
* Lucide icon, icons are always decorative (aria-hidden), and accessible names stay the
* visible text.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }),
usePathname: () => "/",
redirect: (to: string) => {
throw new Error(`redirect ${to}`);
},
}));
const getAdmin = vi.fn();
const verifySession = vi.fn();
vi.mock("@/lib/admin-auth", () => ({ getAdmin, hasAdmin: async () => false }));
vi.mock("@/lib/session", () => ({ verifySession, authState: async () => "signed-out" }));
vi.mock("@/lib/setup-code", () => ({ ensureSetupCode: () => "CODE" }));
const actions = vi.hoisted(() => ({ login: vi.fn(), setupAdmin: vi.fn() }));
vi.mock("./auth-actions", () => ({
login: actions.login,
setupAdmin: actions.setupAdmin,
logout: async () => {},
skipSetup: async () => {},
}));
const { default: CameraCard } = await import("./camera-card");
const { default: CameraScanner } = await import("./camera-scanner");
const { default: RefreshRateSelect } = await import("./refresh-rate-select");
const { default: SecurityBar } = await import("./security-bar");
const { default: LoginPage } = await import("./login/page");
const { default: SetupPage } = await import("./setup/page");
const { default: LoginForm } = await import("./login/login-form");
const { default: SetupForm } = await import("./setup/setup-form");
/**
* The Lucide icon names on the first icon inside an element, e.g. ["key-round"]. Lucide also
* adds classes for older aliases (Trash2 → "trash", "trash-2"), so compare with toContain.
*/
const iconIn = (el: Element) =>
[...(el.querySelector("svg")?.getAttribute("class") ?? "").matchAll(/lucide-([a-z0-9-]+)/g)].map(
(m) => m[1],
);
const allIconsDecorative = (root: ParentNode = document) => {
const svgs = [...root.querySelectorAll("svg.lucide")];
expect(svgs.length).toBeGreaterThan(0);
for (const svg of svgs) expect(svg.getAttribute("aria-hidden")).toBe("true");
};
const cam: CameraSummary = { id: "cam1", host: "192.168.1.10", port: 80, name: "Porch", lastSeen: "" };
const base = "/api/cameras/cam1";
const info = { manufacturer: "Hikvision", model: "DS", firmwareVersion: "1", profiles: [] };
beforeEach(() => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
describe("camera card", () => {
it("marks the settings icon, the only icon control left on a card", async () => {
stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: () => new Response(new Blob(["x"])),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
// Icon-only, so its name has to say what it does and to which camera (pri-8ev5m89).
const settings = await screen.findByRole("link", { name: "Settings for Porch" });
expect(iconIn(settings)).toContain("pencil");
allIconsDecorative(settings);
});
it("marks the login form: title, Save, Forget, and errors", async () => {
stubFetch({
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/credentials`]: () => json({ source: "stored", username: "admin", hasPassword: true }),
[`PUT ${base}/credentials`]: () => json({ error: "nope", code: "auth" }, 401),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(iconIn((await screen.findByText("Camera login")).closest("div")!)).toContain("key-round");
expect(iconIn(screen.getByRole("button", { name: "Save" }))).toContain("lock");
expect(iconIn(await screen.findByRole("button", { name: "Forget saved login" }))).toContain("trash-2");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
const error = await screen.findByText("Camera rejected that login.");
expect(iconIn(error)).toContain("circle-alert");
allIconsDecorative();
});
it("marks the setup panel and its Check again button", async () => {
stubFetch({ [`GET ${base}/info`]: () => json({ error: "x", code: "inactive" }, 409) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
const title = await screen.findByText("Setup required: camera not activated");
expect(iconIn(title)).toContain("triangle-alert");
expect(iconIn(screen.getByRole("button", { name: "Check again" }))).toContain("refresh-cw");
allIconsDecorative();
});
it("marks the stopped error and Retry", async () => {
stubFetch({ [`GET ${base}/info`]: () => json({ error: "Camera request failed" }, 502) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(iconIn(await screen.findByText(/^Stopped:/))).toContain("circle-alert");
expect(iconIn(screen.getByRole("button", { name: "Retry" }))).toContain("refresh-cw");
});
it("marks the session-ended notice and its sign-in link", async () => {
stubFetch({ [`GET ${base}/info`]: () => json({ error: "Sign in required" }, 401) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(iconIn(await screen.findByText("Your session has ended."))).toContain("circle-alert");
expect(iconIn(screen.getByRole("link", { name: "Sign in again" }))).toContain("log-in");
});
});
describe("dashboard controls", () => {
it("Scan network shows a radar, then a spinner while scanning", async () => {
let finish!: (r: Response) => void;
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });
renderWithQuery(<CameraScanner />);
const scan = screen.getByRole("button", { name: /Scan network/ });
expect(iconIn(scan)).toContain("radar");
fireEvent.click(scan);
const busy = await screen.findByRole("button", { name: /Scanning…/ });
expect(iconIn(busy)).toContain("loader-circle");
expect(busy.querySelector("svg")!.getAttribute("class")).toContain("motion-reduce:animate-none");
finish(json({ error: "Network scan failed" }, 500));
expect(iconIn(await screen.findByText(/^Error:/))).toContain("circle-alert");
allIconsDecorative();
});
it("the refresh selector has a timer and keeps its label", () => {
renderWithQuery(<RefreshRateSelect value={1000} />);
const select = screen.getByRole("combobox", { name: /Snapshot refresh/ });
expect(iconIn(select.closest("label")!)).toContain("timer");
});
});
describe("security bar and auth pages", () => {
const markup = async (node: Promise<React.ReactNode> | React.ReactNode) => {
const div = document.createElement("div");
div.innerHTML = renderToStaticMarkup(<>{await node}</>);
return div;
};
it("the unsecured banner carries a shield alert", async () => {
getAdmin.mockResolvedValue(null);
const bar = await markup(SecurityBar());
expect(iconIn(bar.querySelector('[role="alert"] p')!)).toContain("shield-alert");
allIconsDecorative(bar);
});
it("the signed-in bar has a shield check and Sign out has a log-out icon", async () => {
getAdmin.mockResolvedValue({ username: "admin" });
verifySession.mockResolvedValue({ username: "admin", expiresAt: 0 });
const bar = await markup(SecurityBar());
expect(iconIn(bar)).toContain("shield-check");
expect(iconIn(bar.querySelector("button")!)).toContain("log-out");
allIconsDecorative(bar);
});
it("/login has a lock heading and a sign-in button icon", async () => {
const page = await markup(
LoginPage({ params: Promise.resolve({}), searchParams: Promise.resolve({}) }),
);
expect(iconIn(page.querySelector("h1")!)).toContain("lock");
expect(iconIn(page.querySelector('button[type="submit"]')!)).toContain("log-in");
allIconsDecorative(page);
});
it("/setup has a shield-check heading and a shield alert on the skip warning", async () => {
const page = await markup(SetupPage());
expect(iconIn(page.querySelector("h1")!)).toContain("shield-check");
const warning = [...page.querySelectorAll("p")].find((p) => p.textContent === "Skip at your own risk")!;
expect(iconIn(warning)).toContain("shield-alert");
allIconsDecorative(page);
});
it.each([
["login", () => <LoginForm next="/" />, actions.login, "Sign in"],
["setup", () => <SetupForm />, actions.setupAdmin, "Create admin and sign in"],
] as const)("the %s form marks its error with a circle alert", async (_n, Form, action, submit) => {
action.mockResolvedValue({ error: "Something went wrong." });
renderWithQuery(<Form />);
for (const input of document.querySelectorAll<HTMLInputElement>("input[required]")) {
fireEvent.change(input, { target: { value: "correct horse battery" } });
}
fireEvent.click(screen.getByRole("button", { name: submit }));
await waitFor(() => expect(iconIn(screen.getByRole("alert"))).toContain("circle-alert"));
allIconsDecorative();
});
});