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:
@ -8177,10 +8177,88 @@
|
||||
"ticket_id": "8bf414e0-60f1-4fb8-87ff-76d618150fb0",
|
||||
"conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4"
|
||||
},
|
||||
"state": "running",
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"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\nL’UI doit normaliser la saisie en majuscules avant affichage et avant envoi.\n\nComportement attendu :\n- si l’utilisateur 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 à l’utilisateur 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 d’acceptation 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- l’aide reste courte, non technique, et ne prend pas plus d’une 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,
|
||||
"result": null,
|
||||
"completionDelivered": false
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
issueRef: "#68"
|
||||
version: 7
|
||||
version: 8
|
||||
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
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "13821b24-f566-402e-bc10-02ac6e8a5f7c"
|
||||
number: 68
|
||||
title: "Ajouter dans l'app desktop, la possibilité d'activer le serveur"
|
||||
status: "qa"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: [{"target":"#65","kind":"dependsOn"}]
|
||||
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784193318419
|
||||
updatedAt: 1784238387151
|
||||
version: 7
|
||||
updatedAt: 1784277313706
|
||||
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
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#74"
|
||||
version: 2
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784272247901
|
||||
updatedAt: 1784277308704
|
||||
---
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "249bb0de-118f-4d6f-b0c4-1d4e2aadd83f"
|
||||
number: 74
|
||||
title: "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined"
|
||||
status: "qa"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
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"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784271086298
|
||||
updatedAt: 1784272247901
|
||||
version: 2
|
||||
updatedAt: 1784277308704
|
||||
version: 3
|
||||
---
|
||||
## Symptôme (constaté live, exposition derrière reverse proxy)
|
||||
|
||||
|
||||
6
.ideai/tickets/75/carnet.md
Normal file
6
.ideai/tickets/75/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#75"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784277530792
|
||||
---
|
||||
48
.ideai/tickets/75/issue.md
Normal file
48
.ideai/tickets/75/issue.md
Normal 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.
|
||||
6
.ideai/tickets/76/carnet.md
Normal file
6
.ideai/tickets/76/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#76"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784277540034
|
||||
---
|
||||
40
.ideai/tickets/76/issue.md
Normal file
40
.ideai/tickets/76/issue.md
Normal 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`.
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 75
|
||||
"nextNumber": 77
|
||||
}
|
||||
@ -707,13 +707,13 @@
|
||||
"issueRef": "#68",
|
||||
"path": "68",
|
||||
"title": "Ajouter dans l'app desktop, la possibilité d'activer le serveur",
|
||||
"status": "qa",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1784238387151
|
||||
"updatedAt": 1784277313706
|
||||
},
|
||||
{
|
||||
"issueRef": "#69",
|
||||
@ -773,11 +773,31 @@
|
||||
"issueRef": "#74",
|
||||
"path": "74",
|
||||
"title": "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined",
|
||||
"status": "qa",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
77
frontend/src/features/web/PairingScreen.test.tsx
Normal file
77
frontend/src/features/web/PairingScreen.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -31,6 +31,14 @@ function describe(e: unknown): string {
|
||||
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) {
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -38,12 +46,12 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed || busy) return;
|
||||
const normalized = normalize(code);
|
||||
if (!normalized || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await session.pair(trimmed);
|
||||
await session.pair(normalized);
|
||||
onPaired();
|
||||
} catch (err) {
|
||||
setError(describe(err));
|
||||
@ -64,20 +72,20 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field label="Code d'appairage">
|
||||
<Field label="Code d'appairage" hint="8 caractères : chiffres et lettres A-F">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="p. ex. 4821-93"
|
||||
placeholder="p. ex. AB12CD34"
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
// #69 — phones: summon the numeric-ish keypad for a pairing code
|
||||
// and keep the OS from "helpfully" capitalising/correcting it.
|
||||
inputMode="numeric"
|
||||
autoCapitalize="off"
|
||||
// #75 — the code mixes digits and letters, so phones need the
|
||||
// text keyboard; uppercase it on the way in, never autocorrect.
|
||||
inputMode="text"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
@ -92,7 +100,7 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={busy || code.trim().length === 0}>
|
||||
<Button type="submit" disabled={busy || normalize(code).length === 0}>
|
||||
{busy ? "Appairage…" : "Appairer"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user