fix(frontend): clavier texte et normalisation du code d'appairage (#75)

Le code d'appairage est de l'hexadécimal majuscule (p. ex. 3F7A9C21),
pas une suite de chiffres. Le champ appelait pourtant le pavé numérique
des mobiles, rendant les lettres A-F non saisissables.

Le champ passe donc en inputMode="text" avec autoCapitalize="characters",
ce qui revient sur le choix inverse fait en #69 : ce ticket avait supposé
un code purement numérique. Le placeholder et l'aide du champ décrivent
désormais le format réel.

La saisie est normalisée (majuscules, espaces retirés) avant pair() car
le serveur compare le code strictement ; la dette correspondante côté
backend est suivie en #76.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 10:40:26 +02:00
parent aec4293750
commit 120d0d1ac2
2 changed files with 95 additions and 10 deletions

View File

@ -0,0 +1,77 @@
/**
* #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.
*/
import { describe, it, expect, vi } 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<string, string>();
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 () => "{}",
});
function setup() {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
const pair = vi.spyOn(session, "pair");
const onPaired = vi.fn();
render(<PairingScreen session={session} onPaired={onPaired} />);
return { pair, onPaired };
}
describe("PairingScreen code field", () => {
it("asks for the text keyboard, not the numeric keypad", () => {
setup();
const field = screen.getByLabelText("Code d'appairage");
expect(field.getAttribute("inputmode")).toBe("text");
expect(field.getAttribute("autocapitalize")).toBe("characters");
expect(field.getAttribute("autocomplete")).toBe("one-time-code");
});
it("describes the code without lying about its shape", () => {
setup();
const field = screen.getByLabelText("Code d'appairage");
expect(field.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(screen.getByLabelText("Code d'appairage"), {
target: { value: " ab12cd34 " },
});
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34");
});
it("keeps submit disabled for a blank code", () => {
setup();
const submit = screen.getByRole("button", { name: "Appairer" });
expect((submit as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: " " } });
expect((submit as HTMLButtonElement).disabled).toBe(true);
});
});

View File

@ -31,6 +31,14 @@ function describe(e: unknown): string {
return String(e); return String(e);
} }
/**
* The server compares the code strictly against the uppercase hex it generated,
* so the UI uppercases (and drops stray spaces) before sending.
*/
function normalize(raw: string): string {
return raw.replace(/\s+/g, "").toUpperCase();
}
export function PairingScreen({ session, onPaired }: PairingScreenProps) { export function PairingScreen({ session, onPaired }: PairingScreenProps) {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -38,12 +46,12 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
async function submit(e: FormEvent): Promise<void> { async function submit(e: FormEvent): Promise<void> {
e.preventDefault(); e.preventDefault();
const trimmed = code.trim(); const normalized = normalize(code);
if (!trimmed || busy) return; if (!normalized || busy) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
await session.pair(trimmed); await session.pair(normalized);
onPaired(); onPaired();
} catch (err) { } catch (err) {
setError(describe(err)); setError(describe(err));
@ -64,20 +72,20 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
</p> </p>
</div> </div>
<Field label="Code d'appairage"> <Field label="Code d'appairage" hint="8 caractères : chiffres et lettres A-F">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <Input
id={id} id={id}
aria-describedby={describedBy} aria-describedby={describedBy}
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
placeholder="p. ex. 4821-93" placeholder="p. ex. AB12CD34"
autoFocus autoFocus
autoComplete="one-time-code" autoComplete="one-time-code"
// #69phones: summon the numeric-ish keypad for a pairing code // #75the code mixes digits and letters, so phones need the
// and keep the OS from "helpfully" capitalising/correcting it. // text keyboard; uppercase it on the way in, never autocorrect.
inputMode="numeric" inputMode="text"
autoCapitalize="off" autoCapitalize="characters"
autoCorrect="off" autoCorrect="off"
spellCheck={false} spellCheck={false}
disabled={busy} disabled={busy}
@ -92,7 +100,7 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
</p> </p>
)} )}
<Button type="submit" disabled={busy || code.trim().length === 0}> <Button type="submit" disabled={busy || normalize(code).length === 0}>
{busy ? "Appairage…" : "Appairer"} {busy ? "Appairage…" : "Appairer"}
</Button> </Button>
</form> </form>