Merge feature/ticket75-pairing-code-keyboard into develop

Intègre le ticket #75 : l'écran d'appairage web appelait le pavé
numérique des mobiles alors que le code d'appairage est de
l'hexadécimal majuscule, rendant les lettres A-F non saisissables.

Le champ passe en clavier texte, annonce le format réel et normalise
la saisie avant envoi. Le correctif revient sur le choix de #69, qui
avait supposé un code purement numérique.

Suite verte rejouée avant merge : 88 fichiers, 801 tests, tsc exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 10:41:01 +02:00
13 changed files with 310 additions and 27 deletions

View File

@ -8177,10 +8177,88 @@
"ticket_id": "8bf414e0-60f1-4fb8-87ff-76d618150fb0", "ticket_id": "8bf414e0-60f1-4fb8-87ff-76d618150fb0",
"conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4"
}, },
"state": "running", "state": "completed",
"wakePolicy": "recordOnly", "wakePolicy": "recordOnly",
"createdAtMs": 1784277230713, "createdAtMs": 1784277230713,
"updatedAtMs": 1784277230713, "updatedAtMs": 1784277293596,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784277293596,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "**#74 est intégré dans `develop`. Working tree propre, rien en attente, aucun push effectué.**\n\n## Ce que j'ai commité\n\nUn seul commit restait à faire : `4de25d2 — chore(ideai): état runtime du ticket #74 (passage en QA)`. Il porte les 4 fichiers non commités (issue, carnet, index des tickets, accusés de complétion des rendez-vous headless). C'est de l'état runtime pur, donc isolé du code de feature selon ma convention. J'ai vérifié avec `--untracked-files=all` qu'aucun artefact de build ne traînait (`dist/`, `dist-web/`, `target/`, AppImage) — rien.\n\n## Ce que j'ai mergé\n\n`aec4293` — merge `--no-ff` de `feature/ticket74-web-transport-bundle` vers `develop`. Aucun conflit, `develop` n'avait pas bougé depuis la création de la branche (0 commit de retard), donc pas de rebase préalable nécessaire.\n\n**Un point mérite ton attention** : ce merge intègre **deux tickets**, pas un. La branche #74 était empilée sur `feature/ticket68-appimage-web-assets` et contenait `166adc3` (#68, packaging des assets web en ressource Tauri), qui n'était pas encore dans `develop`. C'était voulu et je l'assume : le packaging #68 servait précisément le bundle cassé que #74 corrige, il ne pouvait pas être déclaré vert tout seul. Les deux ferment ensemble. Si tu considérais #68 comme ayant sa propre porte QA à franchir séparément, dis-le moi — mais techniquement, le séparer était impossible sans conflit frontal sur les mêmes lignes de `tauri.conf.json`.\n\nJ'ai aussi supprimé `feature/ticket68-appimage-web-assets` : elle était un ancêtre strict de la branche #74, sans aucun commit propre. La garder n'apportait rien.\n\n## État final\n\n`develop` est sur `aec4293`, en avance de **34 commits sur `origin/develop`**, arbre propre. Les deux branches de feature (`ticket74-web-transport-bundle`, `ticket68-appimage-web-assets`) sont supprimées, leur travail vivant dans `develop`.\n\n## Ce qui reste en suspens\n\nLe **statut des tickets #68 et #74 est resté à `qa`** dans les métadonnées. Le code est mergé et validé live, mais la clôture formelle d'un ticket n'est pas ma décision — c'est Main qui arbitre. À faire passer en `done` de son côté.\n\nCôté remote, **les 34 commits d'avance sur `origin/develop` ne sont pas poussés**, conformément à mon périmètre strictement local. Un `git push origin develop` les publierait ; dis-moi si tu le veux et je m'en occupe. Enfin, `develop → main` (release) n'a pas été touché, ça reste une décision explicite de ta part.",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "078855de-999f-40a8-97fc-0b5c1be2fe5c",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "c3e0d9d8-df36-4b00-b271-62d8aa78892c",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "c3e0d9d8-df36-4b00-b271-62d8aa78892c",
"ticket_id": "1a3657bd-1067-4a0c-96ab-28bb326a3e6c",
"conversation_id": "652e01c1-67a5-0913-0272-fc456de31f6d"
},
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784277366856,
"updatedAtMs": 1784277379813,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784277379813,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "Décision UX pour Ticket #75 :\n\n1. `inputMode`\nUtiliser `inputMode=\"text\"`.\n\nRaison : le code contient chiffres + lettres `A-F`; un pavé numérique rend la saisie partiellement impossible. Ne pas utiliser `numeric`, `decimal` ni `tel`.\n\n2. Placeholder / aide visuelle\nRemplacer le placeholder actuel par un exemple fidèle :\n\n`AB12-CD34`\n\nAjouter ou conserver une aide courte sous le champ si lécran en a déjà une :\n\n`8 caractères : chiffres et lettres A-F`\n\nÉviter le mot “hexadécimal”. Il est exact techniquement, mais inutile côté utilisateur.\n\n3. Casse\nLUI doit normaliser la saisie en majuscules avant affichage et avant envoi.\n\nComportement attendu :\n- si lutilisateur tape `ab12cd34`, le champ affiche `AB12-CD34` ou `AB12CD34` selon le formatage existant retenu ;\n- la valeur envoyée au serveur est toujours `AB12CD34`, sans tiret ni espace ;\n- ne pas imposer à lutilisateur de changer manuellement de casse.\n\nPérimètre frontend pur suffisant : normalisation côté UI avant submit. \nÀ noter comme robustesse future, mais hors périmètre demandé : le backend devrait idéalement comparer en normalisant aussi, car la tolérance ne doit pas dépendre uniquement du client.\n\n4. `autoCapitalize` / `autoComplete`\nMettre :\n\n- `autoCapitalize=\"characters\"`\n- `autoCorrect=\"off\"`\n- `autoComplete=\"one-time-code\"`\n- `spellCheck={false}` si le composant le permet\n\nCritères dacceptation visuels/fonctionnels :\n- sur mobile, le clavier permet lettres et chiffres sans bascule manuelle obligatoire ;\n- le placeholder ne suggère plus un code numérique ;\n- une saisie minuscule aboutit à un code accepté par le serveur via normalisation frontend ;\n- laide reste courte, non technique, et ne prend pas plus dune ligne sur mobile.",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "fecf3304-b299-45f0-9ec6-0dff06d4a990",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800",
"ticket_id": "e315464e-656f-4484-b444-18245987e85f",
"conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41"
},
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784277402194,
"updatedAtMs": 1784277521434,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784277521434,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "Ticket #75 est corrigé et vert. Le champ demande maintenant le clavier texte et la valeur envoyée au serveur est normalisée en majuscules.\n\n## Ce que j'ai changé\n\n**`frontend/src/features/web/PairingScreen.tsx`** — les 5 points de la spec, tels quels :\n\n- `inputMode=\"numeric\"` → `inputMode=\"text\"`, et `autoCapitalize=\"off\"` → `\"characters\"`. Les autres attributs (`autoCorrect=\"off\"`, `autoComplete=\"one-time-code\"`, `spellCheck={false}`) sont conservés. J'ai remplacé le commentaire `#69` qui justifiait le pavé numérique par un commentaire `#75` expliquant pourquoi le clavier texte est requis.\n- Placeholder `p. ex. 4821-93` → `p. ex. AB12CD34`, sans séparateur comme tu l'as tranché.\n- L'aide passe par le `hint` de `Field`, qui la rend dans le `<p id={...}-msg>` déjà câblé à `aria-describedby` — donc pas de `<p>` libre, et le lien accessibilité est automatique. Texte : « 8 caractères : chiffres et lettres A-F ».\n- Une fonction `normalize()` locale remplace le `trim()` : elle enlève tous les espaces (pas seulement en bord) et met en majuscules. C'est elle qui alimente `session.pair()`.\n- Le `disabled` du bouton submit passe de `code.trim().length === 0` à `normalize(code).length === 0`, donc une saisie de seuls espaces reste refusée, cohérente avec ce qui serait envoyé.\n\nPas de masque de saisie ni de formatage à la frappe, et l'écran ne parle toujours qu'à la `WebSession` injectée — aucun import `@tauri-apps` ajouté, le garde-fou `no-direct-invoke` n'est pas touché.\n\n**`frontend/src/features/web/PairingScreen.test.tsx`** (nouveau) — j'ai préféré un fichier dédié plutôt que d'alourdir `WebApp.test.tsx`, qui teste le routage et non le champ. Quatre tests, avec un `vi.spyOn(session, \"pair\")` sur une vraie `WebSession` à fetch injecté : le `inputMode` est bien `text`, le placeholder et l'aide sont fidèles, ` ab12cd34 ` part en `AB12CD34`, et le submit reste désactivé sur une saisie vide ou blanche.\n\n## Sortie de test réelle\n\n`cd frontend && npx vitest run` (suite complète) :\n\n```\n Test Files 88 passed (88)\n Tests 801 passed (801)\n Duration 16.78s\n```\n\n`npx tsc --noEmit` sort en 0. Les tests existants de `WebApp.test.tsx` passent sans modification : leur code `4821-93` traverse `normalize()` inchangé.\n\n## Dette à ouvrir, hors périmètre\n\nLa comparaison serveur (`crates/web-server/src/lib.rs:1612`) reste stricte sur la casse. Le fix d'aujourd'hui rend l'écran web tolérant, mais la tolérance vit côté client : tout autre client de l'API (curl, un futur client mobile natif, un test manuel) échouera encore sur `3f7a9c21`. Le backend devrait normaliser en majuscules à la comparaison, ce qui rendrait la normalisation UI redondante mais inoffensive. À toi de décider si ça mérite un ticket.",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "cef21a7e-3eab-4bc5-a4d8-46af0cab4496",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"ticket_id": "690948d6-4063-4d28-ba3c-a6b6545f03c0",
"conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4"
},
"state": "running",
"wakePolicy": "recordOnly",
"createdAtMs": 1784277549807,
"updatedAtMs": 1784277549807,
"deadlineMs": null, "deadlineMs": null,
"result": null, "result": null,
"completionDelivered": false "completionDelivered": false

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#68" issueRef: "#68"
version: 7 version: 8
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784238387151 updatedAt: 1784277313706
--- ---
# Ticket #68 — Serveur embarqué activable depuis le desktop : carnet de chantier # Ticket #68 — Serveur embarqué activable depuis le desktop : carnet de chantier

View File

@ -2,7 +2,7 @@
id: "13821b24-f566-402e-bc10-02ac6e8a5f7c" id: "13821b24-f566-402e-bc10-02ac6e8a5f7c"
number: 68 number: 68
title: "Ajouter dans l'app desktop, la possibilité d'activer le serveur" title: "Ajouter dans l'app desktop, la possibilité d'activer le serveur"
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [{"target":"#65","kind":"dependsOn"}] links: [{"target":"#65","kind":"dependsOn"}]
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784193318419 createdAt: 1784193318419
updatedAt: 1784238387151 updatedAt: 1784277313706
version: 7 version: 8
--- ---
J'iamerais que depuis l'app desktop, on puisse quand même embarquer le serveur. Comme ça un utilisateur pourrait continuer son travail en cours en remote J'iamerais que depuis l'app desktop, on puisse quand même embarquer le serveur. Comme ça un utilisateur pourrait continuer son travail en cours en remote

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#74" issueRef: "#74"
version: 2 version: 3
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784272247901 updatedAt: 1784277308704
--- ---

View File

@ -2,7 +2,7 @@
id: "249bb0de-118f-4d6f-b0c4-1d4e2aadd83f" id: "249bb0de-118f-4d6f-b0c4-1d4e2aadd83f"
number: 74 number: 74
title: "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined" title: "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined"
status: "qa" status: "closed"
priority: "high" priority: "high"
sprint: null sprint: null
links: [{"target":"#68","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#13","kind":"relatesTo"}] links: [{"target":"#68","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#13","kind":"relatesTo"}]
@ -10,8 +10,8 @@ agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784271086298 createdAt: 1784271086298
updatedAt: 1784272247901 updatedAt: 1784277308704
version: 2 version: 3
--- ---
## Symptôme (constaté live, exposition derrière reverse proxy) ## Symptôme (constaté live, exposition derrière reverse proxy)

View File

@ -0,0 +1,6 @@
---
issueRef: "#75"
version: 2
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784277530792
---

View File

@ -0,0 +1,48 @@
---
id: "d54a1f20-03e6-4925-982b-923a37331749"
number: 75
title: "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres"
status: "qa"
priority: "medium"
sprint: null
links: [{"target":"#69","kind":"relatesTo"},{"target":"#74","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784277353320
updatedAt: 1784277530792
version: 2
---
## Symptôme (constaté live, téléphone)
Sur l'écran d'appairage web (`PairingScreen`), le clavier virtuel qui s'ouvre est un pavé **numérique uniquement**, alors que le code d'appairage à saisir contient des **lettres**. Impossible de saisir le code sans changer de clavier à la main.
## Cause racine (confirmée)
Le code généré côté serveur est **hexadécimal, 8 caractères, majuscules** (`0-9A-F`) :
```rust
// crates/web-server/src/lib.rs:2220
fn new_pairing_code() -> String {
Uuid::new_v4().simple().to_string().chars().take(8)
.collect::<String>().to_ascii_uppercase()
}
```
Or `frontend/src/features/web/PairingScreen.tsx:79` force `inputMode="numeric"` (introduit par #69 en supposant un code purement chiffré), et le placeholder `p. ex. 4821-93` (ligne 74) décrit un format qui n'existe pas.
## Attendu
- Le clavier mobile permet de saisir lettres **et** chiffres.
- Le placeholder reflète le format réel (hex 8 caractères majuscules).
- La saisie reste sans autocorrection ni capitalisation surprise ; le code étant en majuscules, la casse ne doit pas être un piège pour l'utilisateur.
## Périmètre
Frontend pur. Le format du code côté serveur n'est pas remis en cause par ce ticket.
## DoD
- Saisie possible au clavier mobile standard, lettres incluses.
- Placeholder cohérent avec `new_pairing_code()`.
- Test de non-régression sur le champ.

View File

@ -0,0 +1,6 @@
---
issueRef: "#76"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784277540034
---

View File

@ -0,0 +1,40 @@
---
id: "c91f547f-96f0-4c9c-9fde-dbe7d2772dbc"
number: 76
title: "Appairage : la comparaison du code est sensible à la casse côté serveur"
status: "open"
priority: "low"
sprint: null
links: [{"target":"#75","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784277540034
updatedAt: 1784277540034
version: 1
---
## Constat
`crates/web-server/src/lib.rs:1612` compare le code d'appairage reçu au code attendu par égalité stricte :
```rust
if request.code != state.pairing_code() { }
```
Or `new_pairing_code()` (ligne 2220) génère le code en majuscules. Un client qui envoie `3f7a9c21` au lieu de `3F7A9C21` est donc rejeté.
## Pourquoi c'est une dette et pas un bug bloquant
Le fix #75 rend l'écran web tolérant en normalisant la saisie en majuscules avant `POST /api/pair`. La tolérance vit donc **côté client** : tout autre client de l'API (curl, un futur client mobile natif, un test manuel) échouera encore sur une saisie minuscule, avec un message d'erreur qui n'explique pas pourquoi.
## Attendu
La comparaison serveur normalise la casse (et les espaces) avant égalité. La normalisation côté UI de #75 devient alors redondante mais inoffensive — pas besoin de la retirer.
## Vigilance
La comparaison doit rester en temps constant si elle l'est aujourd'hui : normaliser ne doit pas introduire de court-circuit exploitable en timing. À vérifier lors de l'implémentation.
## Périmètre
Backend pur, `crates/web-server`.

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 75 "nextNumber": 77
} }

View File

@ -707,13 +707,13 @@
"issueRef": "#68", "issueRef": "#68",
"path": "68", "path": "68",
"title": "Ajouter dans l'app desktop, la possibilité d'activer le serveur", "title": "Ajouter dans l'app desktop, la possibilité d'activer le serveur",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [ "assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641" "a6ced819-b893-4213-b003-9e9dc79b9641"
], ],
"updatedAt": 1784238387151 "updatedAt": 1784277313706
}, },
{ {
"issueRef": "#69", "issueRef": "#69",
@ -773,11 +773,31 @@
"issueRef": "#74", "issueRef": "#74",
"path": "74", "path": "74",
"title": "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined", "title": "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined",
"status": "qa", "status": "closed",
"priority": "high", "priority": "high",
"sprint": null, "sprint": null,
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784272247901 "updatedAt": 1784277308704
},
{
"issueRef": "#75",
"path": "75",
"title": "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres",
"status": "qa",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784277530792
},
{
"issueRef": "#76",
"path": "76",
"title": "Appairage : la comparaison du code est sensible à la casse côté serveur",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784277540034
} }
] ]
} }

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>