/** * #75 — the pairing code is uppercase hex (`3F7A9C21`), not digits: * - the field must summon the text keyboard, not the numeric keypad; * - a lowercase entry must reach `session.pair()` uppercased and space-free, * because the server compares the code strictly. * * #77 lot F1 — the handshake becomes `{code, name}`: * - dashes normalise away too, so a code retyped as `AB12-CD34` still pairs; * - the device name is prefilled from a readable derivation, editable, and * required; it is never a raw User-Agent. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { WebSession } from "@/adapters/http"; import type { FetchLike } from "@/adapters/http/httpInvoker"; import type { FlagStore } from "@/adapters/http/webSession"; import { PairingScreen } from "./PairingScreen"; function memStore(): FlagStore { const map = new Map(); return { getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, v), removeItem: (k) => void map.delete(k), }; } const okFetch: FetchLike = async () => ({ ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}", }); /** A `/api/pair` that always fails with the given wire code. */ function failingFetch(status: number, code: string): FetchLike { return async () => ({ ok: false, status, json: async () => ({ code, message: "server wording" }), text: async () => "{}", }); } /** Pins the UA so the name prefill is deterministic. */ function stubUserAgent(ua: string): void { Object.defineProperty(window.navigator, "userAgent", { value: ua, configurable: true, }); } const ORIGINAL_UA = window.navigator.userAgent; function setup(fetchImpl: FetchLike = okFetch) { const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() }); const pair = vi.spyOn(session, "pair"); const onPaired = vi.fn(); render(); return { pair, onPaired }; } const codeField = () => screen.getByLabelText("Code d'appairage"); const nameField = () => screen.getByLabelText("Nom de cet appareil") as HTMLInputElement; const submit = () => screen.getByRole("button", { name: "Appairer" }) as HTMLButtonElement; beforeEach(() => stubUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)")); afterEach(() => stubUserAgent(ORIGINAL_UA)); describe("PairingScreen code field", () => { it("asks for the text keyboard, not the numeric keypad", () => { setup(); expect(codeField().getAttribute("inputmode")).toBe("text"); expect(codeField().getAttribute("autocapitalize")).toBe("characters"); expect(codeField().getAttribute("autocomplete")).toBe("one-time-code"); }); it("describes the code without lying about its shape", () => { setup(); expect(codeField().getAttribute("placeholder")).toBe("p. ex. AB12CD34"); expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy(); }); it("uppercases and strips spaces before pairing", async () => { const { pair, onPaired } = setup(); fireEvent.change(codeField(), { target: { value: " ab12cd34 " } }); fireEvent.click(submit()); await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone"); }); it("strips a dash the user retyped from the grouped display (#77)", async () => { const { pair, onPaired } = setup(); fireEvent.change(codeField(), { target: { value: "AB12-CD34" } }); fireEvent.click(submit()); await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone"); }); }); describe("PairingScreen device name", () => { it("prefills a readable name, never the raw User-Agent", () => { setup(); expect(nameField().value).toBe("iPhone"); expect(document.body.textContent).not.toContain("Mozilla/5.0"); }); it("sends the edited name with the code", async () => { const { pair, onPaired } = setup(); fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); fireEvent.change(nameField(), { target: { value: " Téléphone de Marie " } }); fireEvent.click(submit()); await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); expect(pair).toHaveBeenCalledWith("AB12CD34", "Téléphone de Marie"); }); it("caps the name at 40 characters", () => { setup(); expect(nameField().maxLength).toBe(40); }); it("requires both a code and a name", () => { setup(); // A name alone (prefilled) is not enough. expect(submit().disabled).toBe(true); fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); expect(submit().disabled).toBe(false); // Clearing the name blocks it again: the list must never show a blank row. fireEvent.change(nameField(), { target: { value: " " } }); expect(submit().disabled).toBe(true); }); it("keeps submit disabled for a blank or separator-only code", () => { setup(); fireEvent.change(codeField(), { target: { value: " " } }); expect(submit().disabled).toBe(true); fireEvent.change(codeField(), { target: { value: " - " } }); expect(submit().disabled).toBe(true); }); }); describe("PairingScreen error placement", () => { async function submitAndFail(status: number, code: string) { setup(failingFetch(status, code)); fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); fireEvent.click(submit()); await screen.findByRole("alert"); } it("shows a rejected name on the name field, not as a form error", async () => { await submitAndFail(400, "invalid_name"); // Attached to the field: the fix is one edit away, and the code is still // valid — a form-level error would read as "start over". const message = screen.getByRole("alert"); expect(message.textContent).toBe( "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", ); expect(nameField().getAttribute("aria-describedby")).toBe(message.id); expect(nameField().getAttribute("aria-invalid")).toBe("true"); expect(screen.queryByTestId("pairing-error")).toBeNull(); // The code was never consumed, so it must not be painted as the culprit. expect(codeField().getAttribute("aria-invalid")).not.toBe("true"); }); it("keeps a rejected code as a form error", async () => { await submitAndFail(401, "invalid_or_expired"); expect(screen.getByTestId("pairing-error").textContent).toBe("Code invalide ou expiré."); // The name is not at fault, so it must not be marked as such. expect(nameField().getAttribute("aria-invalid")).not.toBe("true"); }); });