From 19db289abffbb859ed8c16ffe9895c728e9423b3 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Mon, 7 Sep 2026 11:26:47 +0200 Subject: [PATCH 01/34] docs(m11-02): retirer le Play Store, publication finale APK sur GitHub (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eas.json : retrait du profil production (AAB) et de submit ; le profil preview (buildType apk) devient l'artefact de la release finale - Remplacement des references Play Store / AAB par la strategie « GitHub Release APK » dans VISION, GLOSSAIRE, TECH-SPEC, CI-CD, ROADMAP, USER-STORIES, TESTING, ISSUE, PRIVACY et README - 10-CI-CD : ajout §5.3 « Installer une release (APK depuis GitHub) » - 09-ISSUE : registre M-09 recentre sur v0.9.3, nouvelle ligne M-11 - setup-github.sh : descriptifs M-09/US-QA-03/SETUP-08 synchronises --- README.md | 2 +- docs/00-GLOSSAIRE.md | 6 +++--- docs/01-VISION.md | 14 +++++++------- docs/03-TECHNICAL-SPEC.md | 16 ++++++---------- docs/08-USER-STORIES.md | 9 +++++---- docs/09-ISSUE.md | 3 ++- docs/10-CI-CD.md | 25 +++++++++++++++++++++---- docs/11-ROADMAP.md | 18 +++++++++--------- docs/12-TESTING.md | 2 +- docs/13-PRIVACY.md | 2 +- eas.json | 7 ------- scripts/setup-github.sh | 6 +++--- 12 files changed, 59 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index a5c5c3d..d49b6d0 100755 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ PicsouCollectionApp/ | **10R — Retours test** | Drawer + tabs, formulaire au clavier, torche, import CSV | ✅ Terminé — **v0.9.1** | | **10R2 — Retours test** | Drawer SafeZone, onboard + permission, paramètres sous-menus, header fiche, tri, OCR numéro, recherche | ✅ Terminé — **v0.9.2** | | **10R3 — Retours test** | Panneau de recherche repliable Collection, drawer scrollable, popup/form, renommage **Mag Collection** | ✅ Terminé — **v0.9.3** | -| **9 — Tests terrain & publication** | Vrais magazines, Play Store | ⬜ À venir | +| **9 — Tests terrain & publication** | Vrais magazines, release **APK sur GitHub** (v1.0.0) | ⬜ À venir | Détail complet : [docs/11-ROADMAP.md](docs/11-ROADMAP.md) diff --git a/docs/00-GLOSSAIRE.md b/docs/00-GLOSSAIRE.md index c0fb4e9..33b64ff 100644 --- a/docs/00-GLOSSAIRE.md +++ b/docs/00-GLOSSAIRE.md @@ -120,7 +120,7 @@ Le statut est **toujours dérivé** du nombre réel d'exemplaires, jamais d'un c ### EAS Build **Général :** service cloud d'Expo pour construire des builds. -**Contexte :** utilisé pour générer l'**AAB** (Android App Bundle) de production pour le Play Store. Le build local (Gradle) reste disponible pour le développement. +**Contexte :** utilisé pour générer l'**APK** (Android Package) de la release finale (profil `preview`, `buildType: apk`), publié comme **GitHub Release** téléchargeable. Le build local (Gradle) reste disponible pour le développement. ### Development Build **Contexte :** build Expo compilé nativement, installé sur le téléphone de développement. Il permet d'intégrer des modules natifs (comme l'OCR ML Kit) et de déboguer sur le matériel réel. @@ -190,12 +190,12 @@ Le statut est **toujours dérivé** du nombre réel d'exemplaires, jamais d'un c ### CI / CD (Intégration/Déploiement continus) **Général :** automatisation des contrôles (CI) et de la livraison (CD). -**Contexte :** via GitHub Actions : typecheck, lint, tests, coverage, et build EAS de production. Une PR ne fusionne que si la CI est verte. +**Contexte :** via GitHub Actions : typecheck, lint, format, tests, coverage et doctor. Une PR ne fusionne que si la CI est verte. Un build EAS produit l'APK de release. ### Release **Général :** version livrée d'une application, avec notes de version. -**Contexte :** une version est taguée `vX.Y.Z` (Semantic Versioning) sur `main` et publiée comme GitHub Release. Le `CHANGELOG.md` suit les changements. +**Contexte :** une version est taguée `vX.Y.Z` (Semantic Versioning) sur `main` et publiée comme GitHub Release. Le `CHANGELOG.md` suit les changements. L'**artefact final de l'app (APK Android)** y est joint et téléchargeable. ### Tag **Contexte :** marqueur Git d'un commit correspondant à une version (`v0.1.0`). diff --git a/docs/01-VISION.md b/docs/01-VISION.md index e9ad680..b9c8706 100644 --- a/docs/01-VISION.md +++ b/docs/01-VISION.md @@ -77,13 +77,13 @@ Fournir un outil de terrain fiable, rapide et hors ligne pour vérifier la posse Ce projet est avant tout un **projet personnel** conçu pour un usage individuel. Il s'inscrit dans une démarche d'apprentissage et de démonstration de compétences. ### 4.3 Objectif de publication -L'application a pour objectif final la **publication sur le Google Play Store**, dans une perspective de **portfolio professionnel**. Cela conditionne : +L'application a pour objectif final la **livraison d'une version 1.0.0 stable**, publiée sous forme d'**APK téléchargeable sur le dépôt GitHub** (GitHub Releases), dans une perspective de portfolio professionnel. Cela conditionne : - la qualité et la maintenabilité du code ; - la documentation ; - la couverture de tests ; - la politique de confidentialité ; -- les métadonnées du store (ASO). +- la notice d'installation de la release. --- @@ -123,7 +123,7 @@ Si elle ajoute de la complexité sans améliorer cette expérience, elle reste s Le créateur du projet lui-même, collectionneur de magazines Disney. ### 7.2 Public secondaire -Tout collectionneur de magazines Disney/Picsou présentant le même besoin, susceptible de télécharger l'application depuis le Play Store. +Tout collectionneur de magazines Disney/Picsou présentant le même besoin, susceptible de télécharger l'APK de la release depuis le dépôt GitHub. ### 7.3 Persona type @@ -199,13 +199,13 @@ L'application sera considérée comme réussie si le collectionneur peut : ## 11. La stratégie de publication ### 11.1 Objectif final -Publication sur le **Google Play Store** dans le cadre d'un portfolio professionnel. +Livraison d'une **release v1.0.0** publiée sur **GitHub Releases** : l'**APK final** est téléchargeable depuis le dépôt du projet (le Google Play Store est écarté, trop contraignant pour le test). ### 11.2 Implications -- Les métadonnées ASO et les captures d'écran existent déjà (`Docs Design/`) ; -- Une **politique de confidentialité** est requise (voir `13-PRIVACY.md`) ; +- L'**APK** de release est généré par le profil `preview` d'EAS Build (voir `03-TECHNICAL-SPEC.md` et `10-CI-CD.md`) ; +- Chaque release est accompagnée d'un **CHANGELOG** et de **notes de version** ; +- Une **politique de confidentialité** est fournie (voir `13-PRIVACY.md`) ; - La qualité de code et la couverture de tests sont des priorités (voir `12-TESTING.md`) ; -- Le build de production utilise **EAS Build** (voir `03-TECHNICAL-SPEC.md`) ; - L'application doit être utilisable par un **tiers** sans documentation (onboarding implicite). ### 11.3 Non-engagement diff --git a/docs/03-TECHNICAL-SPEC.md b/docs/03-TECHNICAL-SPEC.md index 86cde24..fe0419a 100644 --- a/docs/03-TECHNICAL-SPEC.md +++ b/docs/03-TECHNICAL-SPEC.md @@ -165,8 +165,8 @@ Deux modes de build : - Gradle local pour générer un APK de test. ### 7.2 EAS Build (release) -- Configuration de `eas.json` pour les builds de production (AAB) ; -- Génère l'**AAB** (Android App Bundle) pour le Play Store. +- Le profil `preview` (`buildType: apk`) génère l'**APK** de la release finale ; +- L'**APK** est publié comme **GitHub Release** téléchargeable (pas de Play Store, ni d'AAB). ### 7.3 Configuration `eas.json` (indicative) @@ -181,15 +181,11 @@ Deux modes de build : }, "preview": { "distribution": "internal", - "channel": "preview" - }, - "production": { - "channel": "production", - "autoIncrement": true + "channel": "preview", + "android": { + "buildType": "apk" + } } - }, - "submit": { - "production": {} } } ``` diff --git a/docs/08-USER-STORIES.md b/docs/08-USER-STORIES.md index a4766a1..c995332 100644 --- a/docs/08-USER-STORIES.md +++ b/docs/08-USER-STORIES.md @@ -439,12 +439,13 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : **Priorité** : haute. ### US-QA-03 — Build de production -> En tant que **developpeur**, je veux **générer un build de production** afin de **préparer la publication Play Store**. +> En tant que **developpeur**, je veux **générer un build de production (APK de release)** afin de **publier la version finale (v1.0.0)**. **Critères d'acceptation** : -- EAS Build produit un AAB ; -- Le build local produit un APK ; -- L'application fonctionne en production. +- Le build local produit un APK installable ; +- Le profil EAS `preview` produit l'APK de la release finale ; +- L'application fonctionne en production ; +- L'APK est publié comme **GitHub Release** téléchargeable. **Priorité** : moyenne. diff --git a/docs/09-ISSUE.md b/docs/09-ISSUE.md index e5ef50e..8fdeea7 100644 --- a/docs/09-ISSUE.md +++ b/docs/09-ISSUE.md @@ -210,7 +210,8 @@ Les milestones correspondent aux **phases de la roadmap** (`11-ROADMAP.md`). | `M-10` | Refonte UI/UX « Vault Lisible » | SafeZone, contraste AA, TabBar icônes, parcours <3s → v0.9.0 | | `M-10R` | Retours test physique (M-10) | Import CSV, drawer + tabs, form clavier, torche/FAB/safezone → v0.9.1 | | `M-10R2` | 2ᵉ passe retours test physique | SafeZone drawer, permission caméra + onboarding 1er lancement, popup résultat, form valider header, paramètres sous-menus, header fiche, tri collection, OCR numéro, écran Recherche → v0.9.2 (livrée, PR #176) | -| `M-09` | Tests terrain & publication | Validation réelle, build Play Store → v1.0.0 (M09-01 panneau recherche #177) | +| `M-09` | Tests terrain & publication | Validation réelle, retours physique → v0.9.3 (M09-01 panneau recherche #177) | +| `M-11` | Review & Qualité v1.0.0 | Audit doc ↔ code, revue, refactor, tests, retrait Play Store, publication APK GitHub → v1.0.0 (#181-#190) | ### Règle d'attribution - Chaque milestone a une **date de fin cible** (indicative) et une **description** ; diff --git a/docs/10-CI-CD.md b/docs/10-CI-CD.md index 5bd433c..d733339 100644 --- a/docs/10-CI-CD.md +++ b/docs/10-CI-CD.md @@ -165,7 +165,7 @@ jobs: ## 5. Build EAS -> **Actuellement** : les builds EAS sont lancés **manuellement** via `eas build` (profils `development`, `preview`, `production` définis dans `eas.json`). Le build `preview` produit un **APK** installable sur téléphone ; le build `production` produit un **AAB** prêt pour le Play Store. +> **Actuellement** : les builds EAS sont lancés **manuellement** via `eas build` (profils `development`, `preview` définis dans `eas.json`). Le build `preview` (`buildType: apk`) produit l'**APK installable** publié comme **GitHub Release** ; pas de Play Store ni d'AAB. ### 5.0 Validation M-05 sur téléphone physique (OCR) @@ -208,7 +208,7 @@ Points clés pour éviter les pièges rencontrés : ### 5.1 À venir — Workflow GitHub (build automatique sur release) -Le workflow ci-dessous sera activé pour automatiser le build de production sur `main` ou sur un tag `v*` : +Le workflow ci-dessous sera activé pour automatiser le build de l'**APK** de release sur un tag `v*` (artefact joint à la GitHub Release) : ```yaml name: EAS Build @@ -221,7 +221,7 @@ on: jobs: build: - name: EAS Build production + name: EAS Build APK (release) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -236,7 +236,7 @@ jobs: with: eas-version: latest token: ${{ secrets.EAS_TOKEN }} - command: eas build --platform android --profile production --non-interactive + command: eas build --platform android --profile preview --non-interactive ``` ### 5.2 Retour d'expérience (M-01) @@ -244,6 +244,23 @@ jobs: - Le lockfile doit être généré avec **npm 10** (voir 3.2) ; - Le **keystore Android** est hébergé côté EAS (généré au premier build). +### 5.3 Installer une release (APK depuis GitHub) + +La version finale est publiée sous forme de **GitHub Release** : l'APK est +téléchargeable directement depuis le dépôt, sans Play Store. + +```text +1. Ouvrir https://github.com/donovan-dev-web/PicsouCollectionApp/releases +2. Choisir la version voulue (ex. v1.0.0) +3. Télécharger l'artefact « Mag-Collection-vX.Y.Z.apk » +4. Sur Android : autoriser l'installation de sources inconnues pour le + navigateur/fichiers (invitation présentée à l'installation), puis ouvrir + le fichier téléchargé et confirmer +``` + +> L'APK est signé par EAS (keystore géré côté EAS) et couvert par la +> politique de confidentialité (`13-PRIVACY.md`). + --- ## 6. Notification de qualité (coverage) diff --git a/docs/11-ROADMAP.md b/docs/11-ROADMAP.md index 200c2ec..2dc8f02 100644 --- a/docs/11-ROADMAP.md +++ b/docs/11-ROADMAP.md @@ -397,26 +397,26 @@ magazine, tri de la collection, OCR numéro affiné, écran Recherche. Détail : [09-ISSUE.md §6 – Milestones](09-ISSUE.md#6-milestones). **Statut : Done (v0.9.2)** — issues #166-#175 fermées, **PR #176** mergée sur -`develop` (CI Quality verte, 337 tests). Le build de validation / tag sont à -poser au moment de la publication (phase M-09). +`develop` (CI Quality verte, 337 tests). Le build de validation / tag de la +v1.0.0 sont vérifiés dans la **phase M-11** (#182, #189). --- ## 17. Phase 9 — Tests terrain & publication -**Objectif** : Validation réelle et publication Play Store. +**Objectif** : Validation réelle puis **publication finale v1.0.0** sous forme d'APK téléchargeable sur GitHub. -| Tâche | Story | +| Tâche | Story / Issue | |---|---| | Test avec magazines réels (nouveaux, anciens, abîmés) | — | | Test en conditions réelles (brocante, réseau absent) | — | | Corrections finales | — | | Collection : panneau de recherche repliable (bouton Rechercher) | US-UX-23 / M09-01 (#177) | -| Build AAB (EAS Build) | US-QA-03 | -| Préparer la fiche Play Store (ASO) | — | -| Publication Play Store | — | +| Build APK final (profil `preview`) | US-QA-03 | +| Retrait des références Play Store de la doc | M11-02 (#182) | +| GitHub Release v1.0.0 : APK téléchargeable + notes | M11-09 (#189) | -**Livrables** : application validée et publiée. +**Livrables** : application validée, release v1.0.0 publiée avec son APK sur GitHub. --- @@ -453,5 +453,5 @@ Pour chaque phase, les critères de sortie sont : | 10 — Refonte UI/UX | Vault Lisible (v0.9.0) | US-UX-01..06 (M10-01..12) | 12 | | 10R — Retours test M-10 | Retours v0.9.1 (drawer, tabs, OCR) | US-UX-07..12 (M10R-01..11) | 11 | | 10R2 — 2ᵉ passe retours | Retours v0.9.2 (safezone, onboarding) | US-UX-13..22 (M10R2-01..10) | 10 | -| 9 — Publication | Play Store | US-QA-03, US-UX-23 (M09-01) | 2 | +| 9 — Publication | APK GitHub (release) | US-QA-03, US-UX-23 (M09-01) | 2 | | **Total** | | | **103** | diff --git a/docs/12-TESTING.md b/docs/12-TESTING.md index 81bfe47..d98fef6 100644 --- a/docs/12-TESTING.md +++ b/docs/12-TESTING.md @@ -24,7 +24,7 @@ ## 1. Objectif -Garantir la **fiabilité** et la **robustesse** de l'application, élément crucial pour objectif de publication Play Store et de qualité professionnelle. +Garantir la **fiabilité** et la **robustesse** de l'application, élément crucial pour l'objectif de **publication finale (v1.0.0, APK sur GitHub)** et de qualité professionnelle. > Vision : *qualité pro et bonnes pratiques*, avec un suivi continu de la santé du projet, mais aussi une **attention constante à l'expérience utilisateur**. diff --git a/docs/13-PRIVACY.md b/docs/13-PRIVACY.md index 1bbbac0..c576f5c 100644 --- a/docs/13-PRIVACY.md +++ b/docs/13-PRIVACY.md @@ -2,7 +2,7 @@ > **Document — v1.0** > -> Ce document constitue la politique de confidentialité de l'application Picsou Collection. Il est destiné à être publié sur le **Google Play Store** (exigence) et à informer les utilisateurs de la gestion de leurs données. +> Ce document constitue la politique de confidentialité de l'application Mag Collection. Il accompagne la **release** (disponible dans le dépôt GitHub) et informe les utilisateurs de la gestion de leurs données. --- diff --git a/eas.json b/eas.json index 8298dfe..679e43e 100644 --- a/eas.json +++ b/eas.json @@ -15,13 +15,6 @@ "android": { "buildType": "apk" } - }, - "production": { - "autoIncrement": true, - "channel": "production" } - }, - "submit": { - "production": {} } } diff --git a/scripts/setup-github.sh b/scripts/setup-github.sh index dda24b1..0c6c8c4 100644 --- a/scripts/setup-github.sh +++ b/scripts/setup-github.sh @@ -124,7 +124,7 @@ create_milestone "M-05 — Caméra / OCR" "Identification par OCR" "2027-01-31" create_milestone "M-06 — Parcours complet" "Identification → Possédé / Absent" "2027-02-28" create_milestone "M-07 — Export / Import" "Sauvegarde et restauration JSON" "2027-03-31" create_milestone "M-08 — Optimisation & qualité" "Performance, tests, couverture" "2027-04-30" -create_milestone "M-09 — Tests terrain & publication" "Validation réelle, build Play Store" "2027-05-31" +create_milestone "M-09 — Tests terrain & publication" "Validation réelle, publication APK GitHub (release v1.0.0)" "2027-05-31" # --------------------------------------------------------- # 4. ISSUES (1 issue = 1 user story) @@ -170,7 +170,7 @@ declare -a ISSUES=( # ---- Épique 6 — Qualité & publication ---- "US-QA-01|[Quality] Respect du design system|M-03 — Interface principale|epic/quality,priority-medium,to-do|En tant que **developpeur**, je veux **respecter le design system** (clair/sombre) afin de **garantir une cohérence visuelle**.|- Les couleurs et typographies du design system sont appliquées||- Le mode clair et le mode sombre fonctionnent" "US-QA-02|[Quality] Couverture de tests|M-08 — Optimisation & qualité|epic/quality,priority-high,to-do|En tant que **developpeur**, je veux **des tests unitaires et composants** afin de **garantir la fiabilité du code**.|- Les services/repositories sont testés||- Les écrans critiques sont testés||- Un rapport de coverage est généré" - "US-QA-03|[Quality] Build de production|M-09 — Tests terrain & publication|epic/quality,priority-medium,to-do|En tant que **developpeur**, je veux **générer un build de production** afin de **préparer la publication Play Store**.|- EAS Build produit un AAB||- Le build local produit un APK||- L'application fonctionne en production" + "US-QA-03|[Quality] Build de production|M-09 — Tests terrain & publication|epic/quality,priority-medium,to-do|En tant que **developpeur**, je veux **générer un build de production (APK de release)** afin de **publier la version finale (v1.0.0)**.|- Le build local produit un APK installable||- Le profil EAS preview produit l'APK de la release finale||- L'application fonctionne en production||- L'APK est publié comme GitHub Release téléchargeable" ) # --------------------------------------------------------- @@ -208,7 +208,7 @@ declare -a ISSUES_EXTRA=( "SETUP-05|Installer Zustand (gestion d'état)|M-01 — Initialisation technique|infra,to-do|Installer Zustand pour la gestion d'état global.||- Ajouter la dépendance||- Définir les stores de base" "SETUP-06|Configurer ESLint + Prettier|M-01 — Initialisation technique|infra,to-do|Configurer les outils de qualité de code.||- Configurer ESLint||- Configurer Prettier||- Ajouter le script lint" "SETUP-07|Configurer Jest et la couverture|M-01 — Initialisation technique|test,to-do|Configurer Jest + React Native Testing Library.||- Initialiser la config Jest||- Ajouter une config de couverture (>80 %)" - "SETUP-08|Configurer EAS Build (eas.json)|M-01 — Initialisation technique|infra,to-do|Configurer le build de production EAS.||- Créer eas.json (profils dev/preview/production)||- Vérifier la génération d'un AAB" + "SETUP-08|Configurer EAS Build (eas.json)|M-01 — Initialisation technique|infra,to-do|Configurer le build EAS (APK de release).||- Créer eas.json (profils dev/preview)||- Vérifier la génération d'un APK (buildType apk)" "SETUP-09|Configurer le setup GitHub (script kanban)|M-01 — Initialisation technique|infra,done|Automatiser la création des labels, milestones, issues et l'association au kanban.|- Créer le script d'initialisation idempotent||- Créer labels, milestones, 27 user stories + issues complémentaires||- Associer toutes les issues au GitHub Project v2" "SETUP-10|Tester l'installation sur téléphone|M-01 — Initialisation technique|infra,to-do|Vérifier l'installation et le débogage sur un téléphone Android physique.|- Lancer le développement build sur téléphone||- Valider le fonctionnement de base" ) From dcd5377bbd6625df0c7466cd0da8135691b03896 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Mon, 7 Sep 2026 11:49:45 +0200 Subject: [PATCH 02/34] =?UTF-8?q?docs(m11-01):=20audit=20&=20r=C3=A9aligne?= =?UTF-8?q?ment=20documentation=20sur=20le=20code=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- docs/00-GLOSSAIRE.md | 4 +- docs/02-CONCEPTUAL-MODEL.md | 4 +- docs/03-TECHNICAL-SPEC.md | 45 ++++---- docs/04-FONCTIONAL-SPEC.md | 17 ++- docs/05-ARCHITECTURE.md | 224 +++++++++++++++++++++++------------- docs/06-DATA-MODEL.md | 5 +- docs/07-DATABASE-SCHEMA.md | 2 +- docs/09-ISSUE.md | 4 +- docs/11-ROADMAP.md | 80 ++++++++++--- docs/12-TESTING.md | 37 +++--- 11 files changed, 285 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index d49b6d0..abcb6e6 100755 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ PicsouCollectionApp/ │ ├── store/ # Zustand stores │ └── __tests__/ # Tests Jest (co-localisés) │ -├── eas.json # Profils EAS Build (development / preview / production) +├── eas.json # Profils EAS Build (development / preview) ├── package.json # Scripts & dépendances └── CONTRIBUTING.md # Conventions de contribution ``` @@ -129,8 +129,8 @@ PicsouCollectionApp/ | **Caméra** | `expo-camera` | | **OCR** | Google ML Kit Text Recognition (`expo-mlkit-ocr`) | | **Fichiers** | `expo-file-system` + `expo-sharing` | -| **Tests** | Jest (`jest-expo`) + `expo-doctor` | -| **Build / CI** | EAS Build (development / preview / production) + Git Flow, GitHub Actions | +| **Tests** | Jest (`jest-expo`) + React Native Testing Library + `expo-doctor` | +| **Build / CI** | EAS Build (development / preview) + Git Flow, GitHub Actions | --- @@ -172,7 +172,7 @@ La documentation complète du projet se trouve dans [`docs/`](docs/), organisée | [04-FONCTIONAL-SPEC.md](docs/04-FONCTIONAL-SPEC.md) | Spécifications fonctionnelles | | [05-ARCHITECTURE.md](docs/05-ARCHITECTURE.md) | Architecture logicielle | | [06-DATA-MODEL.md](docs/06-DATA-MODEL.md) | Modèle de données | -| [07-DATABASE-SCHEMA.md](docs/07-DATABASE-SCHEMA.md) | Schéma SQLite (2 tables) | +| [07-DATABASE-SCHEMA.md](docs/07-DATABASE-SCHEMA.md) | Schéma SQLite (3 tables) | | [08-USER-STORIES.md](docs/08-USER-STORIES.md) | User stories | | [09-ISSUE.md](docs/09-ISSUE.md) | Workflow agile | | [10-CI-CD.md](docs/10-CI-CD.md) | Pipeline CI/CD | diff --git a/docs/00-GLOSSAIRE.md b/docs/00-GLOSSAIRE.md index 33b64ff..655e668 100644 --- a/docs/00-GLOSSAIRE.md +++ b/docs/00-GLOSSAIRE.md @@ -80,7 +80,7 @@ Le statut est **toujours dérivé** du nombre réel d'exemplaires, jamais d'un c ### Schéma **Général :** définition structurelle d'une base de données (tables, colonnes, index). -**Contexte :** le schéma cible du MVP comporte **2 tables** : `magazines` (édition) et `collection_items` (exemplaire). La version de schéma est stockée dans `PRAGMA user_version`. +**Contexte :** le schéma cible du MVP comporte **3 tables** : `magazines` (édition), `collection_items` (exemplaire) et `settings` (paramètres). La version de schéma est stockée dans `PRAGMA user_version`. ### Migration **Général :** procédure de passage d'une version de schéma à une autre. @@ -115,7 +115,7 @@ Le statut est **toujours dérivé** du nombre réel d'exemplaires, jamais d'un c ### Expo Router **Général :** bibliothèque de navigation basée sur les fichiers pour React Native/Expo. -**Contexte :** navigation par arborescence de fichiers dans `app/` (accueil, scan, collection, settings). +**Contexte :** navigation par arborescence de fichiers dans `src/app/` (accueil, scan, collection, settings). ### EAS Build **Général :** service cloud d'Expo pour construire des builds. diff --git a/docs/02-CONCEPTUAL-MODEL.md b/docs/02-CONCEPTUAL-MODEL.md index d01a27d..436f039 100644 --- a/docs/02-CONCEPTUAL-MODEL.md +++ b/docs/02-CONCEPTUAL-MODEL.md @@ -112,13 +112,13 @@ La publication est représentée textuellement par le champ `publication` de l' ## 5. Relations entre entités ``` -magazines ─┬── 1:N ──> code-barres (dans edition.barcode) +magazines ─┬── 0..1 ──> barcode (valeur EAN-13 / ISBN) │ └── 1:N ──> collection_items (exemplaires possédés) ``` ### 5.1 Édition → Code-barres -Une édition peut avoir **au plus un code-barres principal** dans le MVP (attribut `barcode`). Le passage à plusieurs codes-barres par édition est une évolution possible. +Une édition peut avoir **au plus un code-barres principal** dans le MVP (attribut `barcode`). Plusieurs éditions peuvent partager la **même valeur** de code-barres (index non unique) — cas d'une réédition au même code. Le passage à plusieurs codes-barres par édition est une évolution possible. ### 5.2 Édition → Exemplaires Une édition peut avoir **zéro ou plusieurs** exemplaires. Le nombre d'exemplaires s'obtient en comptant les `collection_items`. diff --git a/docs/03-TECHNICAL-SPEC.md b/docs/03-TECHNICAL-SPEC.md index fe0419a..7a03910 100644 --- a/docs/03-TECHNICAL-SPEC.md +++ b/docs/03-TECHNICAL-SPEC.md @@ -24,14 +24,14 @@ | Domaine | Technologie | Version cible | |---|---|---| -| Framework | React Native | via Expo (SDK ~52) | +| Framework | React Native | via Expo (SDK ~57) | | Runtime | Expo (Development Build) | — | -| Langage | TypeScript | ~5.x | +| Langage | TypeScript | ~6.x | | Navigation | Expo Router | — | | Base de données | SQLite (`expo-sqlite`) | — | | Gestion d'état | Zustand | — | | Caméra | `expo-camera` | — | -| Scan code-barres | `expo-barcode-scanner` ou `expo-camera` (scan) | — | +| Scan code-barres | `expo-camera` (scan EAN-13 / ISBN) | — | | OCR | Google ML Kit Text Recognition (module natif) | — | | Fichiers | `expo-file-system` + `expo-document-picker` + `expo-sharing` | — | | Tests | Jest + React Native Testing Library | — | @@ -75,8 +75,8 @@ Navigation par fichiers, alignée sur la structure décrite dans `05-ARCHITECTUR | Store | Responsabilité | |---|---| | `useCollectionStore` | Liste de la collection, compteur, opérations CRUD sur magazines/exemplaires | -| `useSettingsStore` | Thème, langue (FR), préférences | -| `useIdentificationStore` | État du flux d'identification (méthode courante, résultat, confiance) | +| `useSettingsStore` | Thème (système / clair / sombre), langue (FR) | +| `useBackupStore` | État du flux export / import (fichier choisi, busy, erreurs) | > La source de vérité de la collection reste **SQLite** : les stores Zustand servent de cache/état UI synchronisé avec la base via les repositories. @@ -220,16 +220,16 @@ Priorités : ```json { - "expo": "^52.0.0", - "expo-router": "^4.0.0", - "expo-sqlite": "^15.0.0", - "expo-camera": "^16.0.0", - "expo-file-system": "^18.0.0", - "expo-document-picker": "^13.0.0", - "expo-sharing": "^13.0.0", - "react-native": "0.76.x", - "react": "18.3.x", - "zustand": "^5.0.0" + "expo": "~57.0.20", + "expo-router": "~57.0.19", + "expo-sqlite": "~57.0.2", + "expo-camera": "~57.0.4", + "expo-file-system": "~57.0.6", + "expo-document-picker": "~57.0.1", + "expo-sharing": "~57.0.18", + "react-native": "0.86.3", + "react": "19.2.3", + "zustand": "^5.0.15" } ``` @@ -237,12 +237,13 @@ Priorités : ```json { - "typescript": "^5.x", - "jest": "^29.x", - "jest-expo": "~52.0.0", - "@testing-library/react-native": "^12.x", - "eslint": "^8.x", - "eslint-config-expo": "~8.0.0" + "typescript": "~6.0.3", + "jest": "^29.7.0", + "jest-expo": "^57.0.5", + "@testing-library/react-native": "13.2.0", + "eslint": "^9.39.5", + "eslint-config-expo": "~57.0.2", + "prettier": "^3.9.6" } ``` @@ -250,7 +251,7 @@ Priorités : ```json { "expo-mlkit-ocr": "^0.2.7", - "expo-build-properties": "~57.0.16" + "expo-build-properties": "~57.0.17" } ``` Plugins (`app.json`) : `["expo-mlkit-ocr", { "iosEngine": "auto" }]` et `["expo-build-properties", { "ios": { "deploymentTarget": "16.4" } }]`. diff --git a/docs/04-FONCTIONAL-SPEC.md b/docs/04-FONCTIONAL-SPEC.md index 3fa5eb3..c4796b8 100644 --- a/docs/04-FONCTIONAL-SPEC.md +++ b/docs/04-FONCTIONAL-SPEC.md @@ -31,18 +31,25 @@ L'application comporte les écrans suivants (navigation Expo Router) : | Écran | Route | Objectif | |---|---|---| -| Accueil | `/` | Cockpit : compteur + actions principales | +| Onboarding | `/onboarding` | Accueil (thème, modèle de franchise, fin) | +| Accueil | `/` (tabs) | Cockpit : compteur + actions principales | +| TabBar | `(tabs)` | Navigation Accueil \| Ma Collection \| Paramètres | | Choix méthode | `/scan` | Choix code-barres / caméra / manuel | | Scanner code-barres | `/scan/barcode` | Scan EAN-13 / ISBN | | Caméra / OCR | `/scan/camera` | Reconnaissance par flux caméra | | Saisie manuelle | `/scan/manual` | Formulaire de saisie | +| Saisie formulaire | `/scan/form-barcode` | Enregistrement d'un code-barres reconnu | +| Rechercher | `/scan/search` | Recherche par franchise / titre / numéro | | Résultat | `/scan/result` | Possédé / Absent | | Plusieurs éditions | `/scan/multiple` | Code-barres → liste d'éditions | -| Ma Collection | `/collection` | Liste, recherche, filtres | +| Ma Collection | `/collection` (tabs) | Liste, recherche, filtres | | Fiche Magazine | `/collection/[id]` | Détail d'une édition | -| Paramètres | `/settings` | Sauvegarde, données, version | -| Export | `/settings/export` | Export JSON | -| Import | `/settings/import` | Import JSON | +| Modifier | `/collection/[id]/edit` | Édition de la fiche | +| Paramètres | `/settings` (tabs) | Sous-menus, sauvegarde, version | +| Sauvegarde | `/settings/backup` | Export / Import JSON ou CSV | +| Apparence | `/settings/appearance` | Thème (système / clair / sombre) | +| Accessibilité | `/settings/accessibility` | Réductions de mouvements | +| Aide | `/settings/help` | À propos, version | --- diff --git a/docs/05-ARCHITECTURE.md b/docs/05-ARCHITECTURE.md index fa6d38e..c52fa05 100644 --- a/docs/05-ARCHITECTURE.md +++ b/docs/05-ARCHITECTURE.md @@ -32,33 +32,34 @@ ▼ ┌─────────────────────────────────────────────────────┐ │ Stores Zustand (état UI) │ -│ useCollectionStore · useSettingsStore · ... │ +│ useCollectionStore · useSettingsStore · │ +│ useBackupStore │ └──────────────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Services │ -│ IdentificationService · CollectionService · │ -│ BackupService │ +│ IdentificationService · BackupService │ └──────────────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Repositories │ -│ magazineRepository · collectionRepository │ +│ magazine-repository · collection-repository · │ +│ settings-repository │ └──────────────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ SQLite (expo-sqlite) │ -│ magazines · collection_items │ +│ magazines · collection_items · settings │ └─────────────────────────────────────────────────────┘ ``` L'architecture sépare clairement les responsabilités : - **UI** : affichage et interaction ; -- **Stores** : état applicatif synchronisé avec la source de vérité ; -- **Services** : logique métier (identification, collection, sauvegarde) ; +- **Stores** : état applicatif + logique de collection (via repositories) ; +- **Services** : logique métier (identification, sauvegarde) ; - **Repositories** : accès aux données ; - **SQLite** : persistance. @@ -81,84 +82,130 @@ Les trois méthodes d'identification produisent une structure commune `MagazineI ``` src/ +├── app/ # Écrans + navigation (Expo Router) +│ ├── _layout.tsx # Layout racine (providers, stack racine) +│ ├── +not-found.tsx # 404 +│ ├── onboarding.tsx +│ ├── collection/[id]/edit.tsx +│ ├── collection/[id]/index.tsx +│ ├── scan/ +│ │ ├── barcode.tsx +│ │ ├── camera.tsx +│ │ ├── form-barcode.tsx +│ │ ├── index.tsx +│ │ ├── manual.tsx +│ │ ├── multiple.tsx +│ │ ├── result.tsx +│ │ └── search.tsx +│ └── (tabs)/ +│ ├── _layout.tsx # TabBar Accueil | Ma Collection | Paramètres +│ ├── index.tsx # Accueil +│ └── collection/index.tsx # Ma Collection +│ └── settings/ +│ ├── _layout.tsx +│ ├── index.tsx +│ ├── accessibility.tsx +│ ├── appearance.tsx +│ ├── backup.tsx +│ └── help.tsx +│ ├── database/ -│ ├── schema.ts # DDL + migrations -│ ├── migrations.ts # Gestion de version de schéma -│ ├── database.ts # Initialisation de la connexion +│ ├── schema.ts # DDL + migrations +│ ├── migrations.ts # Gestion de version de schéma +│ ├── database.ts # Initialisation de la connexion +│ ├── types.ts # Types SQLite / rows │ └── repositories/ -│ ├── magazineRepository.ts -│ └── collectionRepository.ts +│ ├── magazine-repository.ts +│ ├── collection-repository.ts +│ └── settings-repository.ts │ ├── identification/ -│ ├── scanBarcode.ts # Détection / lecture code-barres -│ ├── ocr.ts # Reconnaissance texte + extraction -│ ├── confidence.ts # Calcul de confiance -│ └── identificationService.ts -│ -├── collection/ -│ └── collectionService.ts +│ ├── barcodeStabilizer.ts # Lissage des lectures récurrentes +│ ├── scanBarcode.ts # Nettoyage / validation EAN-13, ISBN +│ ├── identificationService.ts # Orchestration scan + recherche +│ └── ocr/ +│ ├── ocrTypes.ts # Interface OcrEngine + OcrFrameResult +│ ├── ocrTextParser.ts # Extraction publication / numéro / date +│ ├── ocrTextStabilizer.ts # Vote multi-frames (2 lectures concordantes) +│ ├── ocrEngine.ts # NoopOcrEngine (repli CI-safe) +│ └── mlKitOcrEngine.ts # Moteur natif Google ML Kit (par défaut) │ ├── backup/ -│ ├── format.ts # Types du format JSON -│ ├── export.ts # Export SQLite → JSON -│ └── import.ts # Import JSON → SQLite +│ ├── backup-service.ts # Export / import JSON + CSV +│ ├── backup-format.ts # Types du format JSON +│ └── file-gateway.ts # Lecture / écriture fichiers (partage) │ ├── store/ -│ ├── useCollectionStore.ts -│ ├── useSettingsStore.ts -│ └── useIdentificationStore.ts +│ ├── useCollectionStore.ts # Collection, compteur, CRUD +│ ├── useSettingsStore.ts # Thème +│ └── useBackupStore.ts # État du flux export / import +│ +├── lib/ +│ ├── toast.ts # Toast partagé +│ ├── slug.ts # Génération d'identifiants de route +│ ├── pending-barcode.ts # Code-barres en attente (focus) +│ └── drawer-context.tsx # Contexte du drawer latéral │ -├── components/ -│ ├── ScanButton.tsx -│ ├── StatusBadge.tsx -│ ├── MagazineCard.tsx -│ └── ... # composants réutilisables +├── components/ # Composants réutilisables +│ ├── ErrorState.tsx +│ ├── EmptyState.tsx +│ ├── LoadingState.tsx +│ ├── Toast.tsx # Toast partagé (ex. nursery, import) +│ └── ... │ -├── theme/ -│ ├── colors.ts # Palette (clair + sombre) -│ ├── typography.ts -│ └── index.ts # Thème contextuel +├── constants/ +│ └── theme.ts # Tokens + palette (clair + sombre) +│ +├── hooks/ +│ └── use-theme.ts # Hook thème contextuel │ ├── types/ -│ └── index.ts # Types de domaine +│ └── index.ts # Types de domaine │ -└── utils/ - ├── id.ts # Génération UUID - ├── date.ts # Formatage dates - └── normalize.ts # Normalisation texte (recherche) +├── dependencies.ts # Injection de dépendances (stores, DB) +└── test-utils.tsx # Helpers de test (RTL) ``` --- ## 4. Navigation (Expo Router) -Structure des écrans via `app/` (Expo Router). +Structure des écrans via `src/app/` (Expo Router, SDK 57). ``` -app/ -├── _layout.tsx # Layout racine (nav inférieure) -├── index.tsx # Accueil +src/app/ +├── _layout.tsx # Stack racine : onboarding, (tabs), scan/*, collection/[id]/* +├── onboarding.tsx # Onboarding (thème, modèle de franchise) +├── +not-found.tsx # 404 │ -├── scan/ +├── (tabs)/ # TabBar : Accueil | Ma Collection | Paramètres │ ├── _layout.tsx -│ ├── index.tsx # Choix de méthode -│ ├── barcode.tsx # Scanner code-barres -│ ├── camera.tsx # Caméra / OCR -│ ├── manual.tsx # Saisie manuelle -│ └── result.tsx # Résultat (Possédé / Absent) +│ ├── index.tsx # Accueil (cockpit) +│ ├── collection/index.tsx # Ma Collection +│ └── settings/ +│ ├── index.tsx # Paramètres (sous-menus) +│ ├── backup.tsx # Export / Import JSON ou CSV +│ ├── appearance.tsx # Thème +│ ├── accessibility.tsx # Réductions de mouvements +│ └── help.tsx # À propos, version │ -├── collection/ -│ ├── index.tsx # Liste de la collection -│ └── [id].tsx # Fiche magazine +├── scan/ # Écrans hors onglets (modal) +│ ├── index.tsx # Choix de méthode +│ ├── barcode.tsx # Scanner code-barres +│ ├── camera.tsx # Caméra / OCR +│ ├── manual.tsx # Saisie manuelle +│ ├── form-barcode.tsx # Enregistrement d'un code-barres +│ ├── search.tsx # Recherche franchise / titre / numéro +│ ├── result.tsx # Résultat (Possédé / Absent) +│ └── multiple.tsx # Code-barres → liste d'éditions │ -└── settings/ - ├── index.tsx # Paramètres - ├── export.tsx # Export - └── import.tsx # Import +└── collection/ + ├── [id]/index.tsx # Fiche magazine + └── [id]/edit.tsx # Édition ``` ### Racine de navigation -Le `_layout.tsx` racine déclare les écrans **Accueil**, **Ma Collection**, **Paramètres** dans la barre de navigation inférieure. Les écrans de **Scanner** et **Fiche magazine** sont présentés en modal/hors onglets pour concentrer l'attention. +Le `_layout.tsx` racine déclare une **stack** : `onboarding`, `(tabs)` (Accueil, Ma Collection, Paramètres), les écrans de **Scan** et la **Fiche magazine** hors onglets pour concentrer l'attention. Un drawer latéral custom (sans `@react-navigation/drawer`) est câblé dans la barre d'entête (burger + scan), avec éditions repliables et pré-filtrage. --- @@ -166,13 +213,12 @@ Le `_layout.tsx` racine déclare les écrans **Accueil**, **Ma Collection**, **P ### 5.1 Repositories -#### `magazineRepository.ts` +#### `magazine-repository.ts` (`MagazineRepository`) Opérations sur les éditions : ```ts findByBarcode(barcode): Promise findManyByBarcode(barcode): Promise -findByPublicationAndIssue(publication, issueNumber): Promise findById(id): Promise list(): Promise search(query): Promise @@ -181,16 +227,29 @@ update(id, input): Promise delete(id): Promise ``` -#### `collectionRepository.ts` +#### `collection-repository.ts` (`CollectionRepository`) Opérations sur les exemplaires : ```ts countByMagazine(magazineId): Promise +countAllCopies(): Promise listByMagazine(magazineId): Promise -addCopy(magazineId, input): Promise +listRecentCopies(limit = 5): Promise deleteCopy(id): Promise ``` +#### `settings-repository.ts` (`SettingsRepository`) +Paramètres applicatifs (table `settings`) : + +```ts +getColorScheme(): Promise +setColorScheme(colorScheme): Promise +getOnboardingDone(): Promise +setOnboardingDone(done): Promise +getReducedMotion(): Promise +setReducedMotion(reduced): Promise +``` + ### 5.2 Services #### `identificationService.ts` @@ -199,7 +258,8 @@ identifyByBarcode(barcode): Promise // found (1 édition) / ambiguous (plusieurs pour le même code) / unknown / invalid identifyByOCR(text): Promise // found / weak (confiance insuffisante) / unknown / no-text -identifyManually(data): Promise +searchByOcrFields(publication, issueNumber, date): Promise +// recherche ciblée depuis les champs extraits (écran caméra) ``` #### `ocr/` — moteur OCR (M-05, US-ID-03 / M10R2-09, US-UX-21) @@ -241,22 +301,32 @@ retourne `null` sans bloquer les tests. `NoopOcrEngine` reste disponible comme r - Le **repli code-barres** en confiance faible (M-05) et la règle nom + numéro (`isConfident`, US-ID-08) sont inchangés. -#### `collectionService.ts` +#### Couche métier collection — `store/useCollectionStore.ts` +La logique de collection (ancien `collectionService.ts`) vit dans le store Zustand, +synchronisé avec SQLite via les repositories : + ```ts -checkPossession(magazineId): Promise<{ owned: boolean; quantity: number }> -addCopy(magazineId): Promise +load(): Promise +loadSummary(): Promise // accueil léger : COUNT(*) + 5 récents +loadDetail(id): Promise +addMagazine(input): Promise +addExistingCopy(magazineId): Promise +updateMagazine(id, input): Promise +removeMagazine(id): Promise ``` -#### `backup` (export/import) +#### `backup` (export / import) — `backup-service.ts` (`BackupService`) ```ts -// export.ts -exportCollection(): Promise - -// import.ts -importCollection(file): Promise -validateAndRead(file): Promise +exportCollection(): Promise // JSON + CSV +toJson(file): string +toCsv(file): string +validateCollection(raw, format = 'json'): Promise +importCollection(raw, format = 'json'): Promise ``` +- `backup-format.ts` : types des formats JSON / CSV ; +- `file-gateway.ts` : sélection / lecture / écriture de fichiers (via `expo-document-picker` + `expo-sharing`), injectable pour les tests. + ### 5.3 Stores Zustand Un store par domaine, comme décrit dans `03-TECHNICAL-SPEC.md`. @@ -276,9 +346,9 @@ useCollectionStore.magazines ### 6.2 Écriture (ajout d'un magazine) ``` -Écran → CollectionService.addCopy() +Écran → useCollectionStore.addMagazine() / addExistingCopy() ↓ -magazineRepository / collectionRepository (SQL) +magazine-repository / collection-repository (SQL) ↓ mise à jour du store → re-render ``` @@ -289,7 +359,7 @@ magazineRepository / collectionRepository (SQL) ↓ code détecté identificationService.identifyByBarcode(code) ↓ -magazineRepository.findManyByBarcode(code) // liste des éditions partageant ce code +magazine-repository.findManyByBarcode(code) // liste des éditions partageant ce code ↓ found (1) → Navigation vers result.tsx ambiguous (>1) → Navigation vers multiple.tsx (compte + liste cliquable) @@ -323,9 +393,9 @@ Pour faciliter les tests, les repositories et services sont **injetés** plutôt export const deps = { magazineRepository: new MagazineRepository(getDb()), collectionRepository: new CollectionRepository(getDb()), + settingsRepository: new SettingsRepository(getDb()), identificationService: new IdentificationService(...), - collectionService: new CollectionService(...), - backupService: new BackupService(...), + backupService: new BackupService(getDb()), }; ``` diff --git a/docs/06-DATA-MODEL.md b/docs/06-DATA-MODEL.md index 2299691..d08ccba 100644 --- a/docs/06-DATA-MODEL.md +++ b/docs/06-DATA-MODEL.md @@ -23,10 +23,11 @@ > **SQLite via `expo-sqlite`, avec un schéma simple et orienté document.** -Le MVP utilise **deux tables** : +Le MVP utilise **trois tables** : - `magazines` — l'édition (entité centrale) ; -- `collection_items` — les exemplaires physiques possédés. +- `collection_items` — les exemplaires physiques possédés ; +- `settings` — les paramètres applicatifs (thème, etc.). Contrairement à la version 0.2 qui prévoyait quatre tables (`magazines`, `magazine_barcodes`, `magazine_details`, `collection_items`), le schéma a été **rationalisé** pour un projet personnel de volume modéré (moins de ~5 000 magazines). diff --git a/docs/07-DATABASE-SCHEMA.md b/docs/07-DATABASE-SCHEMA.md index d63cb65..6be2df0 100644 --- a/docs/07-DATABASE-SCHEMA.md +++ b/docs/07-DATABASE-SCHEMA.md @@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS magazines ( - **`language`** : langue de l'édition ou valeur normalisée (ex. `FR`) ; - **`condition`** : état de l'édition (ex. `neuf`, `usé`) ; - **`publication_date`** : texte ISO selon la précision disponible (`2023-03-01` ou `2023-03`) ; -- **`barcode`** : EAN-13 ou ISBN. Index unique (voir section Index). `NULL` autorisé (magazine sans code-barres) ; +- **`barcode`** : EAN-13 ou ISBN. Index non unique (voir section Index). `NULL` autorisé (magazine sans code-barres) ; - **`notes`** : notes libres sur l'édition ; - **`ocr_text`** : texte brut OCR éventuellement conservé. Ne contient **jamais** d'image ; - **`created_at` / `updated_at`** : timestamps ISO 8601. diff --git a/docs/09-ISSUE.md b/docs/09-ISSUE.md index 8fdeea7..5a2f7a8 100644 --- a/docs/09-ISSUE.md +++ b/docs/09-ISSUE.md @@ -539,7 +539,7 @@ Utiliser les **workflows natifs de GitHub Projects** pour déplacer automatiquem À l'initialisation du dépôt GitHub, configurer : - [ ] **Labels** : types, priorités, épiques, complexité (section 5) ; -- [ ] **Milestones** : M-01 à M-10 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (section 6) ; +- [ ] **Milestones** : M-01 à M-11 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (section 6) ; - [ ] **Project** : board Kanban "Picsou Collection" (section 10) ; - [ ] **Branch protection** sur `main` et `develop` (section 7) ; - [ ] **Templates d'issue** (bug + tâche) via `.github/ISSUE_TEMPLATE/` ; @@ -557,6 +557,6 @@ Utiliser les **workflows natifs de GitHub Projects** pour déplacer automatiquem | Outil de suivi | GitHub Projects (Kanban) | | Découpage | User story → issues → tasks | | Labels | types, priorités, épiques, complexité, statuts | -| Milestones | M-01 à M-10 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (phases roadmap) | +| Milestones | M-01 à M-11 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (phases roadmap) | | Versionnage | Semantic Versioning | | PR | Template + CI + revue + DoD | diff --git a/docs/11-ROADMAP.md b/docs/11-ROADMAP.md index 2dc8f02..7c6d0e5 100644 --- a/docs/11-ROADMAP.md +++ b/docs/11-ROADMAP.md @@ -55,7 +55,9 @@ Phase 8 ███████████████████████ Phase 10 ██████████████████████████████ Terminé ✓ (v0.9.0, PR #152) Phase 10R ██████████████████████████████ Terminé ✓ (v0.9.1) Phase 10R2 ██████████████████████████████ Terminé ✓ (v0.9.2) +Phase 10R3 ██████████████████████████████ Terminé ✓ (v0.9.3) Phase 9 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ À venir (M-09 → v1.0.0) +Phase 11 ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░ En cours (M-11 → v1.0.0) ``` ### 2.1 Correspondance phase ↔ milestone ↔ issues @@ -78,8 +80,10 @@ Chaque phase de la roadmap correspond à un **milestone GitHub** (`M-0x`) et à | 10 — Refonte UI/UX | `M-10 — Refonte UI/UX « Vault Lisible »` | `US-UX-01..06` (issues M10-01..12) | 12 | | 10R — Retours test M-10 | `M-10R — Retours test physique (v0.9.1)` | `US-UX-07..12` (issues M10R-01..11) | 11 | | 10R2 — 2ᵉ passe retours | `M-10R2 — 2ᵉ passe retours test physique (v0.9.2)` | `US-UX-13..22` (issues M10R2-01..10) | 10 | -| 9 — Tests & publication | `M-09 — Tests terrain & publication` | `US-QA-03`, `US-UX-23` (#177) | 2 | -| **Total** | | | **103** | +| 10R3 — Retours test v0.9.3 | `M-09 — Tests terrain & publication` | `US-UX-23` (#177), retours physiques v0.9.3 | 1 | +| 9 — Tests & publication | `M-09 — Tests terrain & publication` | `US-QA-03` | 1 | +| 11 — Review & Qualité | `M-11 — Review & Qualité v1.0.0` | issues M11-01..10 (#181-#190) | 10 | +| **Total** | | | **114** | --- @@ -137,9 +141,9 @@ Chaque phase de la roadmap correspond à un **milestone GitHub** (`M-0x`) et à |---|---| | Schéma SQL `magazines` + `collection_items` | US-DB-01 | | Gestion des migrations (`PRAGMA user_version`) | US-DB-01 | -| Repository `magazineRepository` (CRUD) | US-DB-02, US-DB-03, US-DB-04 | -| Repository `collectionRepository` (exemplaires) | US-DB-05 | -| Service `collectionService` | — | +| Repository `magazine-repository` (CRUD) | US-DB-02, US-DB-03, US-DB-04 | +| Repository `collection-repository` (exemplaires) | US-DB-05 | +| Logique de collection (via `useCollectionStore`) | — | | Tests unitaires repositories | US-QA-02 | **Livrables** : couche persistance testée et fonctionnelle (29 tests / 7 suites, 100 % couverture). @@ -338,7 +342,8 @@ parcours brocante < 3s à 1 main. **Livrables** : 12 issues M10-01..12 (#140-#151), `docs/design/M10-TOKENS.md`. Détail : [09-ISSUE.md §6 – Milestones](09-ISSUE.md#6-milestones). -**Statut : À venir** — prérequis de Phase 9. +**Statut : Terminé ✓ (release `v0.9.0`)** — issues M10-01..12 (#140-#151), +build preview (PR #152) puis livraison PR #148. --- @@ -402,7 +407,30 @@ v1.0.0 sont vérifiés dans la **phase M-11** (#182, #189). --- -## 17. Phase 9 — Tests terrain & publication +## 17. Phase 10R3 — Retours test physique (M-09 → v0.9.3) + +**Objectif** : derniers retours du test physique sur le build v0.9.2 — recherche +repliable sur la Collection, drawer scrollable, formulaire au clavier et popup +« Couverture reconnue », renommage en **Mag Collection**. + +| Tâche | Story / Issue | Statut | +|---|---|---| +| Collection : panneau de recherche repliable (bouton Rechercher) | US-UX-23 / M09-01 (#177) | Done | +| Drawer : scroll quand « Par édition » dépasse l'écran | retours v0.9.2 | Done | +| Popup « Couverture reconnue » : espacement boutons | retours v0.9.2 | Done | +| Formulaire : bouton Enregistrer accessible sans scroll | retours v0.9.2 | Done | +| Renommage : PicsouCollection → **Mag Collection** | — | Done | + +**Livrables** : release **v0.9.3** — **PR #178** mergée sur `develop` (CI verte, +40 suites / 339 tests), tag `v0.9.3`, GitHub Release + APK +`Mag-Collection-v0.9.3.apk`. + +**Statut : Done (v0.9.3)** — le build de validation / tag de la v1.0.0 sont +vérifiés dans la **phase M-11** (#182, #189). + +--- + +## 18. Phase 9 — Tests terrain & publication **Objectif** : Validation réelle puis **publication finale v1.0.0** sous forme d'APK téléchargeable sur GitHub. @@ -411,16 +439,40 @@ v1.0.0 sont vérifiés dans la **phase M-11** (#182, #189). | Test avec magazines réels (nouveaux, anciens, abîmés) | — | | Test en conditions réelles (brocante, réseau absent) | — | | Corrections finales | — | -| Collection : panneau de recherche repliable (bouton Rechercher) | US-UX-23 / M09-01 (#177) | | Build APK final (profil `preview`) | US-QA-03 | -| Retrait des références Play Store de la doc | M11-02 (#182) | | GitHub Release v1.0.0 : APK téléchargeable + notes | M11-09 (#189) | **Livrables** : application validée, release v1.0.0 publiée avec son APK sur GitHub. --- -## 18. Critères de sortie de chaque phase +## 19. Phase 11 — Review & Qualité v1.0.0 (M-11) + +**Objectif** : préparer la **v1.0.0** — audit documentation ↔ code, revue +complète du code, refactor & nettoyage, renfort des tests, retrait du Play +Store et publication finale en APK GitHub. + +| Tâche | Issue | Statut | +|---|---|---| +| Audit & réalignement documentation ↔ code | M11-01 (#181) | En cours | +| Retirer le Play Store → publication APK GitHub | M11-02 (#182) | Done | +| Revue complète du code | M11-03 (#183) | À faire | +| Découper les gros écrans (`camera.tsx`, `barcode.tsx`) | M11-04 (#184) | À faire | +| Découper services & composants métier | M11-05 (#185) | À faire | +| Code mort, commentaires, conventions | M11-06 (#186) | À faire | +| Tests orienté utilisateur (acceptance) | M11-07 (#187) | À faire | +| Tests fonctions/execution + couverture alignée doc | M11-08 (#188) | À faire | +| Release v1.0.0 : version, tag, APK téléchargeable | M11-09 (#189) | À faire | +| Hygiène des milestones obsolètes | M11-10 (#190) | À faire | + +**Livrables** : 10 issues M11-01..10 (#181-#190). +Détail : [09-ISSUE.md §6 – Milestones](09-ISSUE.md#6-milestones). + +**Statut : En cours** — milestone **M-11 — Review & Qualité v1.0.0** ouvert. + +--- + +## 20. Critères de sortie de chaque phase Pour chaque phase, les critères de sortie sont : @@ -449,9 +501,11 @@ Pour chaque phase, les critères de sortie sont : | 6 — Parcours complet | Boucle complète | US-ID-04, US-ID-06, US-COL-06 | 3 | | 7 — Export / Import | Sauvegarde | US-BK-01..03 | 3 | | 7R — Retours test | Retours v0.7.0 (OCR + format) | US-ID-08..09 (1 bug), US-BK-04..05 | 5 | -| 8 — Optimisation | Performance | US-QA-02 | 1 | +| 8 — Optimisation | Performance | US-QA-02, #136 | 2 | | 10 — Refonte UI/UX | Vault Lisible (v0.9.0) | US-UX-01..06 (M10-01..12) | 12 | | 10R — Retours test M-10 | Retours v0.9.1 (drawer, tabs, OCR) | US-UX-07..12 (M10R-01..11) | 11 | | 10R2 — 2ᵉ passe retours | Retours v0.9.2 (safezone, onboarding) | US-UX-13..22 (M10R2-01..10) | 10 | -| 9 — Publication | APK GitHub (release) | US-QA-03, US-UX-23 (M09-01) | 2 | -| **Total** | | | **103** | +| 10R3 — Retours test v0.9.3 | Recherche repliable, drawer scroll, renommage | US-UX-23 (M09-01) | 1 | +| 9 — Publication | APK GitHub (release v1.0.0) | US-QA-03 | 1 | +| 11 — Review & Qualité | Préparation v1.0.0 | M11-01..10 (#181-#190) | 10 | +| **Total** | | | **114** | diff --git a/docs/12-TESTING.md b/docs/12-TESTING.md index d98fef6..3c7904f 100644 --- a/docs/12-TESTING.md +++ b/docs/12-TESTING.md @@ -56,7 +56,7 @@ Les deux catégories sont **réalisées en parallèle et sont toutes deux néces Ces tests valident les **contrats de code** : entrées → sorties, gestion des cas limites, intégrité des données. Ils sont **indépendants de l'interface**. ### Exemples de volets vérifiés -- le `collectionService` retourne le bon statut Possédé / Absent selon le nombre d'exemplaires ; +- la logique de collection (`useCollectionStore`) retourne le bon statut Possédé / Absent selon le nombre d'exemplaires ; - le parseur EAN-13/ISBN lit correctement un code donné ; - la validation d'import rejette un fichier au mauvais format ; - l'export JSON produit un fichier bien formé ; @@ -97,12 +97,12 @@ Ces tests s'appuient sur les **user stories** et leurs **critères d'acceptation Couvrent les **services** et **repositories** de façon isolée. **Cibles :** -- `collectionService` : logique de possession (Possédé / Absent) ; +- store Collection : logique de possession (Possédé / Absent) ; - `identificationService` : identification par barcode / OCR ; -- `confidence` : calcul et seuils de confiance ; -- `backup/export` : sérialisation JSON ; -- `backup/import` : validation et import ; -- `magazineRepository` : CRUD et requêtes ; +- `ocrTextParser` : extraction et seuils de confiance ; +- `backup-service` : sérialisation JSON / CSV ; +- `backup-service` : validation et import ; +- `magazine-repository` : CRUD et requêtes ; **But :** vérifier la logique métier sans dépendre de l'UI ni du matériel. @@ -130,10 +130,11 @@ Tests sur **téléphone physique** avec de vrais magazines, notamment pour camé | **Jest** | Framework de test (runner + assertions) | | **jest-expo** | Preset Jest pour React Native / Expo | | **@types/jest** | Types TS pour Jest (29.5.14, aligné Expo SDK 57) | +| **@testing-library/react-native** | Rendu et interactions des composants en environnement de test | | **react-test-renderer** | Rendu des composants en environnement de test | | **expo-doctor** | Vérification de la santé du projet / écosystème Expo (21 checks) | -> **À venir** : React Native Testing Library (tests de composants interactifs) et `ts-jest` seront ajoutés quand les écrans seront développés (M-03). +> React Native Testing Library est utilisé pour les tests de composants et d'écrans (convention `*.test.tsx`). --- @@ -143,18 +144,24 @@ Les tests sont placés **à côté du code source** qu'ils couvrent, convention ``` src/ -├── collection/ -│ ├── collectionService.ts -│ └── __tests__/ -│ └── collectionService.test.ts -│ ├── identification/ │ ├── scanBarcode.ts │ └── __tests__/ │ └── scanBarcode.test.ts +│ +├── backup/ +│ ├── backup-service.ts +│ └── __tests__/ +│ └── backup-service.test.ts +│ +└── app/ + ├── settings/ + │ ├── backup.tsx + │ └── backup.test.tsx + └── ... ``` -Alternativement, les tests peuvent être co-localisés `collectionService.test.ts` au même niveau. +Les tests peuvent également être co-localisés au niveau du fichier couvert (convention `*.test.ts` / `*.test.tsx`). --- @@ -175,11 +182,11 @@ Alternativement, les tests peuvent être co-localisés `collectionService.test.t | Zone | Couverture actuelle | Couverture cible | |---|---|---| -| Global | **90,24 %** (seuil CI ≥ 80 %, M-08) | ≥ 80 % | +| Global | **87,38 %** (statements, seuil CI ≥ 80 %) | ≥ 80 % | | Services / repositories | consolidée (M-02) | ≥ 85 % | | Composants critiques | consolidée (M-03) | ≥ 70 % | -> État M-08 : 270 tests / 33 suites, couverture globale 90,24 % (statements), branches 85,86 %, seuils globaux 80 %. +> État M-11 : **339 tests / 40 suites**, couverture globale — statements **87,38 %**, branches **80,78 %**, functions **85,21 %**, lines **87,77 %** (seuil global CI ≥ 80 %). État mesuré en M-08 : 270 tests / 33 suites, statements 90,24 %, branches 85,86 %. ### 8.2 Exclusion de couverture Certains fichiers sont exclus du calcul : From 5d22ee64f225be497f7bf5b8494adda4b733ca55 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 07:39:52 +0200 Subject: [PATCH 03/34] =?UTF-8?q?fix(m11-03):=20corrections=20triviales=20?= =?UTF-8?q?de=20la=20revue=20compl=C3=A8te=20du=20code=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/collection/[id]/index.tsx | 6 ------ src/app/scan/multiple.tsx | 12 ------------ src/app/scan/result.tsx | 4 +++- src/components/empty-state.tsx | 2 +- src/components/status-badge.tsx | 2 +- .../__tests__/magazine-repository.test.ts | 11 +++++++---- src/database/repositories/magazine-repository.ts | 7 ++++++- src/dependencies.ts | 5 +++-- 8 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/app/collection/[id]/index.tsx b/src/app/collection/[id]/index.tsx index e699fac..ba556f4 100644 --- a/src/app/collection/[id]/index.tsx +++ b/src/app/collection/[id]/index.tsx @@ -229,12 +229,6 @@ function makeStyles(colors: ThemeColors) { padding: Spacing.four, gap: Spacing.three, }, - center: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: Spacing.four, - }, publication: { fontSize: 24, lineHeight: 32, diff --git a/src/app/scan/multiple.tsx b/src/app/scan/multiple.tsx index 3f7b403..fa97c47 100644 --- a/src/app/scan/multiple.tsx +++ b/src/app/scan/multiple.tsx @@ -157,18 +157,6 @@ function makeStyles(colors: ThemeColors) { color: colors.textSecondary, textAlign: 'center', }, - message: { - fontSize: 15, - color: colors.textSecondary, - textAlign: 'center', - marginTop: Spacing.four, - }, - error: { - fontSize: 15, - color: colors.danger, - textAlign: 'center', - marginTop: Spacing.four, - }, list: { gap: Spacing.two, }, diff --git a/src/app/scan/result.tsx b/src/app/scan/result.tsx index b6c1ce1..2e28c44 100644 --- a/src/app/scan/result.tsx +++ b/src/app/scan/result.tsx @@ -84,7 +84,9 @@ export default function ScanResultScreen() { {params.publication ?? 'Magazine'} - {params.issueNumber ? N° {params.issueNumber} : null} + {params.issueNumber != null && params.issueNumber !== '' ? ( + N° {params.issueNumber} + ) : null} {resolved ? ( + {title} {message ? {message} : null} diff --git a/src/components/status-badge.tsx b/src/components/status-badge.tsx index c9868ec..ca2b289 100644 --- a/src/components/status-badge.tsx +++ b/src/components/status-badge.tsx @@ -12,7 +12,7 @@ type Props = { /** * Badge Possédé / Absent — M10-01/M10-07 : sémantique inversée validée. * Possédé = vert positif (+ check), Absent = neutre. Thématisé clair/sombre, - * `accessibilityRole="status"`, 13px min (M10-10). + * `accessibilityRole="summary"`, 13px min (M10-10). */ export function StatusBadge({ owned, quantity = 0 }: Props) { const colors = useThemeColors(); diff --git a/src/database/repositories/__tests__/magazine-repository.test.ts b/src/database/repositories/__tests__/magazine-repository.test.ts index 761a975..595629c 100644 --- a/src/database/repositories/__tests__/magazine-repository.test.ts +++ b/src/database/repositories/__tests__/magazine-repository.test.ts @@ -469,7 +469,7 @@ describe('magazineRepository.update', () => { expect(updated?.createdAt).toBe(created.createdAt); }); - it('preserve notes et ocr_text lors de la modification', async () => { + it('met à jour les notes et préserve l’ocr_text lors de la modification', async () => { const created = await repo.create({ publication: 'Mickey Parade', issueNumber: 2, @@ -477,15 +477,18 @@ describe('magazineRepository.update', () => { ocrText: 'raw', }); - const updated = await repo.update(created.id, { publication: 'Mickey Parade' }); + const updated = await repo.update(created.id, { + publication: 'Mickey Parade', + notes: 'annotations modifiées', + }); - expect(updated?.notes).toBe('annotation'); + expect(updated?.notes).toBe('annotations modifiées'); expect(updated?.ocrText).toBe('raw'); const row = await testDb.getFirstAsync<{ notes: string; ocr_text: string }>( 'SELECT notes, ocr_text FROM magazines WHERE id = ?', created.id, ); - expect(row?.notes).toBe('annotation'); + expect(row?.notes).toBe('annotations modifiées'); expect(row?.ocr_text).toBe('raw'); }); diff --git a/src/database/repositories/magazine-repository.ts b/src/database/repositories/magazine-repository.ts index 719eb49..60add7c 100644 --- a/src/database/repositories/magazine-repository.ts +++ b/src/database/repositories/magazine-repository.ts @@ -251,7 +251,8 @@ export class MagazineRepository { await this.db.runAsync( `UPDATE magazines SET publication = ?, issue_number = ?, edition = ?, language = ?, - condition = ?, publication_date = ?, barcode = ?, updated_at = ? + condition = ?, publication_date = ?, barcode = ?, notes = ?, + ocr_text = COALESCE(?, ocr_text), updated_at = ? WHERE id = ?`, publication, input.issueNumber ?? null, @@ -260,6 +261,8 @@ export class MagazineRepository { input.condition ?? null, input.publicationDate ?? null, input.barcode ?? null, + input.notes ?? null, + input.ocrText ?? null, updatedAt, id, ); @@ -273,6 +276,8 @@ export class MagazineRepository { condition: input.condition ?? null, publicationDate: input.publicationDate ?? null, barcode: input.barcode ?? null, + notes: input.notes ?? null, + ocrText: input.ocrText ?? current.ocr_text, updatedAt, }; } diff --git a/src/dependencies.ts b/src/dependencies.ts index ce2738e..3319e82 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -46,11 +46,12 @@ export async function initialize(): Promise { // Moteur OCR natif (ML Kit via expo-mlkit-ocr). Sur CI / hors Dev Build, // l'import est paresseux dans `recognize` : il ne casse pas les tests. const ocrEngine: OcrEngine = new MlKitOcrEngine(); + const magazineRepository = new MagazineRepository(db); deps = { - magazineRepository: new MagazineRepository(db), + magazineRepository, collectionRepository: new CollectionRepository(db), settingsRepository: new SettingsRepository(db), - identificationService: new IdentificationService(new MagazineRepository(db)), + identificationService: new IdentificationService(magazineRepository), ocrEngine, backupService: new BackupService(db), fileGateway: new NativeFileGateway(), From e1d1c8d43996215187c819b647e976e38e9f46c3 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 08:21:42 +0200 Subject: [PATCH 04/34] =?UTF-8?q?test(m11-07):=20revue=20des=20tests=20ori?= =?UTF-8?q?ent=C3=A9e=20utilisateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renommage des libellés en parcours utilisateur (retrait des tags US-xx-xx et du jargon technique) - Tests collection triés/filtrés vérifiés côté UI (rendu utilisateur) au lieu de props.data - Nouveaux tests parcours : validation formulaire manuel, erreur à la sauvegarde, pré-remplissage code-barres - Test du FAB (header-scan) sur accueil et collection - Accessibilité : magazine-card annonce le statut possédé/quantité au lecteur d'écran (+ tests) - Anti-flakiness : testTimeout 15000 dans la config jest --- package.json | 1 + src/__tests__/app/barcode-screen.test.tsx | 2 +- src/__tests__/app/camera-ocr-screen.test.tsx | 4 +-- src/__tests__/app/collection-screen.test.tsx | 28 +++++++++------ src/__tests__/app/home-screen.test.tsx | 12 ++++++- .../app/magazine-detail-screen.test.tsx | 2 +- .../app/manual-entry-screen.test.tsx | 36 +++++++++++++++++++ src/__tests__/app/scan-method-screen.test.tsx | 2 +- src/__tests__/app/settings-screen.test.tsx | 8 ++--- .../__tests__/magazine-card.test.tsx | 16 +++++++++ src/components/magazine-card.tsx | 6 +--- .../ocr/__tests__/ocrTextParser.test.ts | 2 +- 12 files changed, 92 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 3f1c182..20ed1e0 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "jest": { "preset": "jest-expo", "clearMocks": true, + "testTimeout": 15000, "setupFiles": [ "/jest.setup.js" ], diff --git a/src/__tests__/app/barcode-screen.test.tsx b/src/__tests__/app/barcode-screen.test.tsx index bac54db..9f4f75d 100644 --- a/src/__tests__/app/barcode-screen.test.tsx +++ b/src/__tests__/app/barcode-screen.test.tsx @@ -119,7 +119,7 @@ describe('BarcodeScreen', () => { expect(screen.getByText(/Alignez le code-barres/)).toBeTruthy(); }); - it('ne lance la recherche qu’après stabilisation de la lecture (3 lectures identiques)', async () => { + it('ne cherche le magazine qu’après plusieurs lectures identiques du même code', async () => { const identifyByBarcode = jest.fn().mockResolvedValue({ status: 'unknown' }); setDepsForTest( stubDeps({ diff --git a/src/__tests__/app/camera-ocr-screen.test.tsx b/src/__tests__/app/camera-ocr-screen.test.tsx index 74eb7c2..9a789f7 100644 --- a/src/__tests__/app/camera-ocr-screen.test.tsx +++ b/src/__tests__/app/camera-ocr-screen.test.tsx @@ -167,7 +167,7 @@ describe('CameraOcrScreen', () => { expect(mockReplace).toHaveBeenCalledWith('/collection/mag-547'); }); - it('maintient l’analyse en lecture partielle et affiche les champs détectés en surcouche (US-ID-08)', async () => { + it('quand seul le nom est détecté, affiche les champs trouvés et oriente vers le numéro', async () => { setDepsForTest( stubDeps({ identificationService: { @@ -194,7 +194,7 @@ describe('CameraOcrScreen', () => { expect(screen.getByTestId('ocr-barcode')).toBeTruthy(); }); - it('relance la recherche dès que nom + numéro sont détectés à la frame suivante', async () => { + it('affiche la fiche dès que publication et numéro sont tous deux reconnus', async () => { const magazine = makeMagazine(); const identity = jest .fn() diff --git a/src/__tests__/app/collection-screen.test.tsx b/src/__tests__/app/collection-screen.test.tsx index 6967c11..23fdd00 100644 --- a/src/__tests__/app/collection-screen.test.tsx +++ b/src/__tests__/app/collection-screen.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { fireEvent, render, screen, within } from '@testing-library/react-native'; import CollectionScreen from '@/app/(tabs)/collection/index'; import { useCollectionStore } from '@/store/use-collection-store'; @@ -6,10 +6,11 @@ import type { MagazineListItem } from '@/types'; const mockUseFocusEffect = jest.fn(); const mockUseLocalSearchParams = jest.fn, []>(); +const mockPush = jest.fn(); jest.mock('expo-router', () => ({ useFocusEffect: (cb: () => void) => mockUseFocusEffect(cb), - useRouter: () => ({ push: jest.fn(), setParams: jest.fn() }), + useRouter: () => ({ push: mockPush, setParams: jest.fn() }), useLocalSearchParams: () => mockUseLocalSearchParams(), })); @@ -64,6 +65,7 @@ const magazines: MagazineListItem[] = [ describe('CollectionScreen', () => { beforeEach(() => { mockUseFocusEffect.mockClear(); + mockPush.mockClear(); mockUseFocusEffect.mockImplementation((cb: () => void) => cb()); mockUseLocalSearchParams.mockReturnValue({}); useCollectionStore.setState({ @@ -192,12 +194,8 @@ describe('CollectionScreen', () => { fireEvent.press(screen.getByTestId('filter-sort')); fireEvent.press(screen.getByTestId('filter-sort-option-Numéro ↓')); - const data = screen.getByTestId('collection-list').props.data; - expect(data.map((m: MagazineListItem) => m.publication)).toEqual([ - 'Picsou Magazine', - 'Mickey Parade', - 'Super Picsou Géant', - ]); + const cards = screen.getAllByTestId('magazine-card'); + expect(within(cards[0]).getByText('Picsou Magazine')).toBeTruthy(); }); it('combine tri decroissant et filtre edition', () => { @@ -209,9 +207,17 @@ describe('CollectionScreen', () => { fireEvent.press(screen.getByTestId('filter-sort')); fireEvent.press(screen.getByTestId('filter-sort-option-Numéro ↓')); - const data = screen.getByTestId('collection-list').props.data; - expect(data).toHaveLength(1); - expect(data[0].publication).toBe('Mickey Parade'); + const cards = screen.getAllByTestId('magazine-card'); + expect(cards).toHaveLength(1); + expect(within(cards[0]).getByText('Mickey Parade')).toBeTruthy(); + }); + + it('propose un accès rapide au scan dans l’en-tête (FAB)', () => { + render(); + + fireEvent.press(screen.getByTestId('header-scan')); + + expect(mockPush).toHaveBeenCalledWith('/scan'); }); }); diff --git a/src/__tests__/app/home-screen.test.tsx b/src/__tests__/app/home-screen.test.tsx index a71ba1a..315cb2f 100644 --- a/src/__tests__/app/home-screen.test.tsx +++ b/src/__tests__/app/home-screen.test.tsx @@ -161,7 +161,7 @@ describe('HomeScreen (ajouts recents)', () => { expect(mockPush).toHaveBeenCalledWith('/scan'); }); - it('affiche l erreur du store dans le compteur', () => { + it('affiche un message quand la base ne répond pas', () => { useCollectionStore.setState({ loading: false, loaded: true, error: 'Base inaccessible' }); render(); @@ -169,4 +169,14 @@ describe('HomeScreen (ajouts recents)', () => { expect(screen.getByTestId('counter-error')).toBeTruthy(); expect(screen.getByText('Base inaccessible')).toBeTruthy(); }); + + it('propose un accès rapide au scan dans l’en-tête (FAB)', () => { + useCollectionStore.setState({ loading: false, loaded: true }); + + render(); + + fireEvent.press(screen.getByTestId('header-scan')); + + expect(mockPush).toHaveBeenCalledWith('/scan'); + }); }); diff --git a/src/__tests__/app/magazine-detail-screen.test.tsx b/src/__tests__/app/magazine-detail-screen.test.tsx index d364fa5..333844b 100644 --- a/src/__tests__/app/magazine-detail-screen.test.tsx +++ b/src/__tests__/app/magazine-detail-screen.test.tsx @@ -56,7 +56,7 @@ describe('MagazineDetailScreen', () => { mockLoadDetail.mockResolvedValue(detail); }); - it('charge le detail au focus', () => { + it('charge les données de la fiche à son ouverture', () => { render(); expect(mockLoadDetail).toHaveBeenCalledWith('mag-1'); diff --git a/src/__tests__/app/manual-entry-screen.test.tsx b/src/__tests__/app/manual-entry-screen.test.tsx index 357df8c..21e28ef 100644 --- a/src/__tests__/app/manual-entry-screen.test.tsx +++ b/src/__tests__/app/manual-entry-screen.test.tsx @@ -78,4 +78,40 @@ describe('ManualEntryScreen', () => { await waitFor(() => expect(addMagazine).toHaveBeenCalledTimes(1)); expect(mockBack).toHaveBeenCalled(); }); + + it('bloque l’enregistrement tant que la publication est vide', () => { + const addMagazine = jest.fn().mockResolvedValue({ id: 'mag-1', publication: 'X', quantity: 1 }); + useCollectionStore.setState({ addMagazine }); + + render(); + + const submit = screen.getByTestId('form-submit'); + expect(submit.props.accessibilityState?.disabled).toBe(true); + fireEvent.press(submit); + expect(addMagazine).not.toHaveBeenCalled(); + expect(screen.getByTestId('form-submit-hint')).toBeTruthy(); + }); + + it('affiche un message quand l’enregistrement échoue', async () => { + useCollectionStore.setState({ + addMagazine: jest.fn().mockRejectedValue(new Error('Code-barres déjà enregistré.')), + }); + + render(); + + fireEvent.changeText(screen.getByTestId('field-publication'), 'Picsou Magazine'); + fireEvent.press(screen.getByTestId('form-submit')); + + await waitFor(() => expect(screen.getByTestId('form-error')).toBeTruthy()); + expect(screen.getByText('Code-barres déjà enregistré.')).toBeTruthy(); + }); + + it('pré-remplit le code-barres scanné depuis le formulaire', () => { + mockLocalSearchParams.current = { barcode: '5901234123457' }; + + render(); + fireEvent.press(screen.getByTestId('details-toggle')); + + expect(screen.getByTestId('field-barcode')).toHaveProp('value', '5901234123457'); + }); }); diff --git a/src/__tests__/app/scan-method-screen.test.tsx b/src/__tests__/app/scan-method-screen.test.tsx index 70810fd..a623b4e 100644 --- a/src/__tests__/app/scan-method-screen.test.tsx +++ b/src/__tests__/app/scan-method-screen.test.tsx @@ -55,7 +55,7 @@ describe('ScanMethodScreen', () => { expect(mockBack).toHaveBeenCalled(); }); - it('redirige vers l accueil si aucune navigation arrière (deep-link)', () => { + it('revient à l’accueil quand l’écran est ouvert directement sans historique', () => { mockCanGoBack.mockReturnValueOnce(false); render(); fireEvent.press(screen.getByTestId('method-cancel')); diff --git a/src/__tests__/app/settings-screen.test.tsx b/src/__tests__/app/settings-screen.test.tsx index 9028428..247a72c 100644 --- a/src/__tests__/app/settings-screen.test.tsx +++ b/src/__tests__/app/settings-screen.test.tsx @@ -133,7 +133,7 @@ describe('BackupScreen — Sauvegarde', () => { expect(screen.getByTestId('backup-import')).toBeTruthy(); }); - it('exporte la collection en JSON au tap du bouton (US-BK-04)', async () => { + it('exporte la collection en JSON quand je choisis le format JSON', async () => { const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); writeExport.mockResolvedValue({ uri: 'file:///d/backup.json', @@ -151,7 +151,7 @@ describe('BackupScreen — Sauvegarde', () => { expect(screen.getByTestId('backup-message')).toBeTruthy(); }); - it('exporte la collection en CSV au tap du bouton (US-BK-04)', async () => { + it('exporte la collection en CSV quand je choisis le format CSV', async () => { const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); writeExport.mockResolvedValue({ uri: 'file:///d/backup.csv', @@ -174,7 +174,7 @@ describe('BackupScreen — Sauvegarde', () => { ); }); - it('demande le format puis rejette un fichier JSON invalide (US-BK-03)', async () => { + it('rejette un fichier invalide et affiche une erreur sans toucher à la collection', async () => { const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); pickFile.mockResolvedValue({ name: 'faux.json', @@ -190,7 +190,7 @@ describe('BackupScreen — Sauvegarde', () => { expect(screen.getByTestId('backup-error').props.children).toContain('Fichier invalide'); }); - it('confirme l’import CSV avant de remplacer la collection (US-BK-05)', async () => { + it('demande confirmation puis remplace la collection par l’import CSV', async () => { const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); const magazineRepo = new MagazineRepository(testDb); const collectionRepo = new CollectionRepository(testDb); diff --git a/src/components/__tests__/magazine-card.test.tsx b/src/components/__tests__/magazine-card.test.tsx index 126df07..fddcb1e 100644 --- a/src/components/__tests__/magazine-card.test.tsx +++ b/src/components/__tests__/magazine-card.test.tsx @@ -40,4 +40,20 @@ describe('MagazineCard', () => { expect(screen.getByTestId('status-absent')).toBeTruthy(); expect(screen.getByText('Absent')).toBeTruthy(); }); + + it('annonce le statut possédé au lecteur d’écran', () => { + render(); + + expect(screen.getByRole('button')).toHaveAccessibleName( + 'Picsou Magazine numéro 547, possédé 2 fois', + ); + }); + + it('annonce l’absence au lecteur d’écran', () => { + render(); + + expect(screen.getByRole('button')).toHaveAccessibleName( + 'Picsou Magazine numéro 547, absent de la collection', + ); + }); }); diff --git a/src/components/magazine-card.tsx b/src/components/magazine-card.tsx index 5ee8bb3..4c4da8a 100644 --- a/src/components/magazine-card.tsx +++ b/src/components/magazine-card.tsx @@ -22,11 +22,7 @@ export function MagazineCard({ magazine, onPress }: Props) { onPress={onPress} testID="magazine-card" accessibilityRole="button" - accessibilityLabel={ - magazine.issueNumber != null - ? `${magazine.publication} numéro ${magazine.issueNumber}` - : magazine.publication - } + accessibilityLabel={`${magazine.publication}${magazine.issueNumber != null ? ` numéro ${magazine.issueNumber}` : ''}${owned ? `, possédé ${magazine.quantity} fois` : ', absent de la collection'}`} accessibilityHint="Voir la fiche de l'édition" android_ripple={{ color: 'rgba(0,0,0,0.08)' }}> diff --git a/src/identification/ocr/__tests__/ocrTextParser.test.ts b/src/identification/ocr/__tests__/ocrTextParser.test.ts index 26272d5..1f0cea9 100644 --- a/src/identification/ocr/__tests__/ocrTextParser.test.ts +++ b/src/identification/ocr/__tests__/ocrTextParser.test.ts @@ -142,7 +142,7 @@ describe('parseOcrText — M10R2-09 exclusions renforcées', () => { }); describe('isConfident', () => { - it('exige nom + numéro pour déclencher la recherche (US-ID-08)', () => { + it('exige à la fois publication et numéro pour déclencher la recherche', () => { const publicationOnly = parseOcrText('Picsou Magazine'); const numberOnly = parseOcrText('547'); const complete = parseOcrText('Picsou Magazine\nN° 547'); From f0c8aa84d2987c5bc7b491dc3518a87c5416201f Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 09:05:16 +0200 Subject: [PATCH 05/34] =?UTF-8?q?test(m11-08):=20couverture=20des=20zones?= =?UTF-8?q?=20critiques=20align=C3=A9e=20sur=20la=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seuils coverageThreshold par zone dans package.json (global 80, services/repositories 85, composants critiques 70) et exclusion des ponts natifs (native-file-gateway, mlKitOcrEngine). - Nouveaux tests : app-header (leading/pressed), status-badge, error-view, loading-view, NoopOcrEngine, reduced motion repository, compteurs/limites collection-repository, migration sans version, branches d'erreur des stores, repli OCR non conforme. - drawer-content : extraction de fonctions pures pour le geste de pan et tests des chemins animés (mouvements réduits désactivés). - 416 tests / 46 suites PASS, couverture globale statements 98,35 %. - docs/12-TESTING §8.1-8.2 : sémantique par fichier, seuils, exclusions. --- docs/12-TESTING.md | 37 ++- package.json | 40 +++- src/backup/__tests__/backup-service.test.ts | 109 +++++++++ src/components/__tests__/app-header.test.tsx | 35 +++ .../__tests__/drawer-content.test.tsx | 211 +++++++++++++++++- src/components/__tests__/error-view.test.tsx | 30 +++ .../__tests__/loading-view.test.tsx | 19 ++ .../__tests__/status-badge.test.tsx | 30 +++ src/components/drawer-content.tsx | 14 +- src/database/__tests__/migrations.test.ts | 13 ++ .../__tests__/collection-repository.test.ts | 29 +++ .../__tests__/settings-repository.test.ts | 22 ++ .../ocr/__tests__/ocrEngine.test.ts | 10 + .../ocr/__tests__/ocrTextParser.test.ts | 14 ++ src/lib/__tests__/drawer-context.test.tsx | 25 +++ src/lib/__tests__/toast.test.ts | 27 +++ src/store/__tests__/use-backup-store.test.ts | 102 ++++++++- .../__tests__/use-collection-store.test.ts | 106 +++++++++ .../__tests__/use-settings-store.test.ts | 58 +++++ 19 files changed, 917 insertions(+), 14 deletions(-) create mode 100644 src/components/__tests__/error-view.test.tsx create mode 100644 src/components/__tests__/loading-view.test.tsx create mode 100644 src/components/__tests__/status-badge.test.tsx create mode 100644 src/identification/ocr/__tests__/ocrEngine.test.ts create mode 100644 src/lib/__tests__/drawer-context.test.tsx create mode 100644 src/lib/__tests__/toast.test.ts diff --git a/docs/12-TESTING.md b/docs/12-TESTING.md index 3c7904f..c718bc7 100644 --- a/docs/12-TESTING.md +++ b/docs/12-TESTING.md @@ -169,6 +169,11 @@ Les tests peuvent également être co-localisés au niveau du fichier couvert (c ### 8.1 Seuils (Jest `coverageThreshold` — config dans `package.json`) +> **Sémantique :** Jest applique `coverageThreshold` **fichier par fichier** : chaque +> fichier d'une zone doit atteindre les seuils de la zone sur *chacune* des quatre +> métriques (`branches`, `functions`, `lines`, `statements`). Un fichier isolé sous +> le seuil fait échouer la CI même si la moyenne de la zone est au-dessus. + ```json "coverageThreshold": { "global": { @@ -176,23 +181,39 @@ Les tests peuvent également être co-localisés au niveau du fichier couvert (c "functions": 80, "lines": 80, "statements": 80 - } + }, + "src/backup/**": { "branches": 85, "functions": 85, "lines": 85, "statements": 85 }, + "src/database/**": { "branches": 85, "functions": 85, "lines": 85, "statements": 85 }, + "src/identification/**": { "branches": 85, "functions": 85, "lines": 85, "statements": 85 }, + "src/lib/**": { "branches": 80, "functions": 80, "lines": 80, "statements": 80 }, + "src/store/**": { "branches": 85, "functions": 85, "lines": 85, "statements": 85 }, + "src/components/**": { "branches": 70, "functions": 70, "lines": 70, "statements": 70 } } ``` -| Zone | Couverture actuelle | Couverture cible | +| Zone | Couverture mesurée (M-11 copie 2) | Couverture cible | |---|---|---| -| Global | **87,38 %** (statements, seuil CI ≥ 80 %) | ≥ 80 % | -| Services / repositories | consolidée (M-02) | ≥ 85 % | -| Composants critiques | consolidée (M-03) | ≥ 70 % | - -> État M-11 : **339 tests / 40 suites**, couverture globale — statements **87,38 %**, branches **80,78 %**, functions **85,21 %**, lines **87,77 %** (seuil global CI ≥ 80 %). État mesuré en M-08 : 270 tests / 33 suites, statements 90,24 %, branches 85,86 %. +| Global | **98,35 %** (statements), lines **97,84 %**, functions **96,83 %**, branches **92,99 %** | ≥ 80 % | +| Services / repositories (`src/backup`, `src/database`, `src/store`) | ≥ 90 % sur les 4 métriques (100 % pour database & store) | ≥ 85 % | +| Bibliothèque (`src/lib`) | 100 % lignes, 90 % functions | ≥ 80 % | +| OCR (`src/identification`) | **96,82 %** branches, 100 % functions | ≥ 85 % | +| Composants critiques (`src/components`) | **90,47 %** branches, 94,41 % lines | ≥ 70 % | + +> État M-11 (2e passe) : **416 tests / 46 suites**, couverture globale — statements +> **98,35 %**, branches **92,99 %**, functions **96,83 %**, lines **97,84 %** +> (seuil global CI ≥ 80 %). État M-11 (1re passe) : 346 tests / 40 suites et +> statements 87,40 % — état mesuré en M-08 : 270 tests / 33 suites, statements +> 90,24 %, branches 85,86 %. ### 8.2 Exclusion de couverture Certains fichiers sont exclus du calcul : - fichiers de configuration ; - code purement de démarrage (entry points) ; -- types uniquement. +- types uniquement ; +- **couche plateforme native** (`src/backup/native-file-gateway.ts`, + `src/identification/ocr/mlKitOcrEngine.ts`) : ponts dépendant d'un + Development Build / matériel physique (tests manuels ou tests physiques + prévus au §7), non exécutables sous Jest (import dynamique natif). --- diff --git a/package.json b/package.json index 20ed1e0..b24246e 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,9 @@ "!src/types/**", "!src/database/types.ts", "!src/test-utils/**", - "!src/**/*.d.ts" + "!src/**/*.d.ts", + "!src/backup/native-file-gateway.ts", + "!src/identification/ocr/mlKitOcrEngine.ts" ], "coverageThreshold": { "global": { @@ -87,6 +89,42 @@ "functions": 80, "lines": 80, "statements": 80 + }, + "src/backup/**": { + "branches": 85, + "functions": 85, + "lines": 85, + "statements": 85 + }, + "src/identification/**": { + "branches": 85, + "functions": 85, + "lines": 85, + "statements": 85 + }, + "src/database/**": { + "branches": 85, + "functions": 85, + "lines": 85, + "statements": 85 + }, + "src/store/**": { + "branches": 85, + "functions": 85, + "lines": 85, + "statements": 85 + }, + "src/lib/**": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + }, + "src/components/**": { + "branches": 70, + "functions": 70, + "lines": 70, + "statements": 70 } } }, diff --git a/src/backup/__tests__/backup-service.test.ts b/src/backup/__tests__/backup-service.test.ts index e964c62..4803077 100644 --- a/src/backup/__tests__/backup-service.test.ts +++ b/src/backup/__tests__/backup-service.test.ts @@ -287,3 +287,112 @@ describe('BackupService CSV import (US-BK-05)', () => { expect(after.magazines[0].copies[0].notes).toBe('Acheté 0,50 €'); }); }); + +describe('BackupService — validation de structure (JSON)', () => { + const base = { format: BACKUP_FORMAT, version: BACKUP_VERSION }; + + it('rejette une valeur de texte non conforme', async () => { + const raw = JSON.stringify({ + ...base, + magazines: [{ id: 'x', publication: 'P', edition: 42, copies: [] }], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'certaines valeurs de texte sont mal formées', + ); + }); + + it('rejette une valeur numérique non conforme', async () => { + const raw = JSON.stringify({ + ...base, + magazines: [{ id: 'x', publication: 'P', issueNumber: '547', copies: [] }], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'certaines valeurs numériques sont mal formées', + ); + }); + + it('rejette une édition mal formée', async () => { + const raw = JSON.stringify({ ...base, magazines: [null] }); + await expect(service.importCollection(raw)).rejects.toThrow('une édition est mal formée'); + }); + + it('rejette un exemplaire mal formé', async () => { + const raw = JSON.stringify({ + ...base, + magazines: [{ id: 'x', publication: 'P', copies: [null] }], + }); + await expect(service.importCollection(raw)).rejects.toThrow('un exemplaire est mal formé'); + }); + + it('rejette un exemplaire sans identifiant', async () => { + const raw = JSON.stringify({ + ...base, + magazines: [{ id: 'x', publication: 'P', copies: [{ id: '', notes: null }] }], + }); + await expect(service.importCollection(raw)).rejects.toThrow('identifiant d’exemplaire'); + }); +}); + +describe('BackupService — CSV cas limites', () => { + const headers = + 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded'; + + it('accepte les fins de ligne Windows (CRLF)', async () => { + const summary = await service.validateCollection( + `${headers}\r\nPicsou,5,,,,,,,,,2026-09-01T00:00:00Z\r\n`, + 'csv', + ); + expect(summary).toEqual({ magazines: 1, copies: 1 }); + }); + + it('ignore les lignes vides en fin de fichier', async () => { + const summary = await service.validateCollection(`${headers}\nPicsou,5,,,,,,,,,\n\n\n`, 'csv'); + expect(summary).toEqual({ magazines: 1, copies: 1 }); + }); + + it('déchiffre les guillemets doublés à l’intérieur d’un champ', async () => { + const csv = `${headers}\nPicsou,,,,,,,,,"il a dit ""OK""",\n`; + await service.importCollection(csv, 'csv'); + const after = await service.exportCollection(); + expect(after.magazines[0].copies[0].notes).toBe('il a dit "OK"'); + }); + + it('préserve les espaces autour des champs texte non vides', async () => { + const csv = `${headers}\nPicsou,5, edition , FR ,,2020-01,,, ,,\n`; + await service.importCollection(csv, 'csv'); + const after = await service.exportCollection(); + expect(after.magazines[0].edition).toBe(' edition '); + expect(after.magazines[0].language).toBe(' FR '); + expect(after.magazines[0].notes).toBeNull(); + }); + + it('assigne un statut et une date par défaut aux champs exemplaire vides', async () => { + const csv = `${headers}\nPicsou,5,,,,,,,,,\n`; + await service.importCollection(csv, 'csv'); + const after = await service.exportCollection(); + expect(after.magazines[0].copies).toHaveLength(1); + expect(after.magazines[0].copies[0].notes).toBeNull(); + expect(after.magazines[0].copies[0].dateAdded).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe('BackupService.importCollection — rollback', () => { + it('renvoie l’erreur d’origine même si le rollback échoue', async () => { + await seedCollection(); + const originalExec = testDb.execAsync; + testDb.execAsync = jest.fn(async (sql: string) => { + if (sql === 'DELETE FROM magazines') { + throw new Error('Échec suppression'); + } + if (sql === 'ROLLBACK') { + throw new Error('Échec rollback'); + } + return originalExec(sql); + }); + + const file = await service.exportCollection(); + await expect(service.importCollection(service.toJson(file))).rejects.toThrow( + 'Échec suppression', + ); + }); +}); diff --git a/src/components/__tests__/app-header.test.tsx b/src/components/__tests__/app-header.test.tsx index 2949c38..41a9014 100644 --- a/src/components/__tests__/app-header.test.tsx +++ b/src/components/__tests__/app-header.test.tsx @@ -53,4 +53,39 @@ describe('AppHeader', () => { expect(mockPush).toHaveBeenCalledWith('/scan'); }); + + it('remplace le bouton menu par l’élément leading fourni', () => { + renderWithDrawer( + Retour} />, + ); + + expect(screen.getByTestId('header-retour')).toBeTruthy(); + expect(screen.queryByTestId('header-menu')).toBeNull(); + }); + + it('affiche l’élément trailing fourni', () => { + renderWithDrawer(✚} />); + + expect(screen.getByTestId('header-extra')).toBeTruthy(); + }); + + it('applique l’état pressé sur les boutons menu et scan', () => { + renderWithDrawer(); + const fakeEvent = () => ({ + persist: jest.fn(), + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + nativeEvent: {}, + }); + + const menu = screen.getByTestId('header-menu'); + fireEvent(menu, 'responderGrant', fakeEvent()); + expect(menu).toHaveStyle({ opacity: 0.6 }); + fireEvent(menu, 'responderRelease', fakeEvent()); + + const scan = screen.getByTestId('header-scan'); + fireEvent(scan, 'responderGrant', fakeEvent()); + expect(scan).toHaveStyle({ opacity: 0.6 }); + fireEvent(scan, 'responderRelease', fakeEvent()); + }); }); diff --git a/src/components/__tests__/drawer-content.test.tsx b/src/components/__tests__/drawer-content.test.tsx index 817c16f..99826b3 100644 --- a/src/components/__tests__/drawer-content.test.tsx +++ b/src/components/__tests__/drawer-content.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; -import { DrawerMenu } from '@/components/drawer-content'; +import { DrawerMenu, isLeftEdgeGesture, isOpenGesture } from '@/components/drawer-content'; +import { useSettingsStore } from '@/store/use-settings-store'; const mockPush = jest.fn(); @@ -14,6 +15,7 @@ describe('DrawerMenu', () => { beforeEach(() => { mockPush.mockClear(); onClose.mockClear(); + useSettingsStore.setState({ reducedMotion: true }); }); afterEach(() => { @@ -104,4 +106,209 @@ describe('DrawerMenu', () => { expect.arrayContaining([expect.objectContaining({ paddingTop: 0 + 16 })]), ); }); + + it('navigue vers l’accueil puis ferme le menu (mouvements réduits)', () => { + render(); + + fireEvent.press(screen.getByTestId('drawer-item-accueil')); + + expect(mockPush).toHaveBeenCalledWith('/'); + expect(onClose).toHaveBeenCalled(); + }); + + it('navigue vers une sous-catégorie Scan après ouverture de la section', () => { + render(); + + fireEvent.press(screen.getByTestId('drawer-collapsible-scan')); + fireEvent.press(screen.getByTestId('drawer-sub-ocr-couverture')); + + expect(mockPush).toHaveBeenCalledWith('/scan/camera'); + }); + + it('navigue vers la collection filtrée par édition', () => { + render(); + + fireEvent.press(screen.getByTestId('drawer-sub-panini')); + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/collection', + params: { edition: 'Panini' }, + }); + }); + + it('considère un glissement comme ouverture au-delà de 40 % de la largeur', () => { + expect(isOpenGesture(150)).toBe(true); + expect(isOpenGesture(10)).toBe(false); + }); + + it('ne prend en compte que les glissements débutant sur le bord gauche', () => { + expect(isLeftEdgeGesture(20, 5)).toBe(true); + expect(isLeftEdgeGesture(5, 5)).toBe(false); + expect(isLeftEdgeGesture(20, 50)).toBe(false); + }); + + it('ferme le menu via le fond lorsque le panneau est ouvert', () => { + render(); + + fireEvent.press(screen.getByLabelText('Fermer le menu')); + + expect(onClose).toHaveBeenCalled(); + }); + + it('replie « Par édition » automatiquement quand peu d’éditions', () => { + render(); + + expect(screen.getByTestId('drawer-sub-panini')).toBeTruthy(); + }); + + it('reste replié sur beaucoup d’éditions', () => { + const editions = Array.from({ length: 8 }, (_, i) => `Édition ${i + 1}`); + render(); + + expect(screen.queryByTestId('drawer-sub-edition-1')).toBeNull(); + }); + + it('n’ajuste plus « Par édition » quand l’utilisateur a replié manuellement', () => { + render(); + + fireEvent.press(screen.getByTestId('drawer-collapsible-par-edition')); + act(() => { + swipe(screen.getByTestId('drawer-panel'), [ + { pageX: 10, ts: 2000 }, + { pageX: 40, ts: 2100 }, + { pageX: 300, ts: 2200 }, + { pageX: 300, ts: 2300 }, + ]); + }); + + expect(screen.queryByTestId('drawer-sub-panini')).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe('DrawerMenu — animations (mouvements réduits désactivés)', () => { + const animOnClose = jest.fn(); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + animOnClose.mockClear(); + useSettingsStore.setState({ reducedMotion: false }); + }); + + it('ferme le panneau via l’animation puis rappelle onClose', () => { + render(); + + fireEvent.press(screen.getByLabelText('Fermer le menu')); + act(() => { + jest.runAllTimers(); + }); + + expect(animOnClose).toHaveBeenCalled(); + }); + + it('navigue après la fermeture animée', () => { + render(); + + fireEvent.press(screen.getByTestId('drawer-item-accueil')); + act(() => { + jest.runAllTimers(); + }); + + expect(mockPush).toHaveBeenCalledWith('/'); + expect(animOnClose).toHaveBeenCalled(); + }); + + it('suit le glissement et ouvre le panneau au-delà du seuil (animation)', () => { + render(); + const panel = screen.getByTestId('drawer-panel'); + + act(() => { + swipe(panel, [ + { pageX: 10, ts: 1000 }, + { pageX: 60, ts: 1100 }, + { pageX: 250, ts: 1200 }, + { pageX: 250, ts: 1300 }, + ]); + }); + + expect(animOnClose).not.toHaveBeenCalled(); + }); + + it('ferme le panneau lors d’un court glissement (animation)', () => { + render(); + const panel = screen.getByTestId('drawer-panel'); + + act(() => { + swipe(panel, [ + { pageX: 10, ts: 1000 }, + { pageX: 30, ts: 1100 }, + { pageX: 30, ts: 1200 }, + ]); + }); + act(() => { + jest.runAllTimers(); + }); + + expect(animOnClose).toHaveBeenCalled(); + }); }); + +function touchHistory(pageX: number, ts: number) { + return { + mostRecentTimeStamp: ts, + numberActiveTouches: 1, + indexOfSingleActiveTouch: 0, + touchBank: [ + { + startPageX: 10, + currentPageX: pageX, + startPageY: 0, + currentPageY: 0, + startTimeStamp: 900, + currentTimeStamp: ts, + previousPageX: 10, + previousPageY: 0, + previousTimeStamp: ts - 60, + touchActive: true, + }, + ], + }; +} + +function fireResponder( + panel: ReturnType, + handler: string, + pageX: number, + ts: number, +) { + const props = panel.props as Record; + const fn = props[handler] as ((arg: unknown) => void) | undefined; + expect(fn).toBeDefined(); + fn?.({ + persist: jest.fn(), + preventDefault: jest.fn(), + nativeEvent: { touches: [{ pageX, pageY: 0 }], changedTouches: [] }, + touchHistory: touchHistory(pageX, ts), + }); +} + +function swipe( + panel: ReturnType, + steps: { pageX: number; ts: number }[], +) { + const [first, ...rest] = steps; + fireResponder(panel, 'onMoveShouldSetResponder', first.pageX, first.ts); + fireResponder(panel, 'onResponderGrant', first.pageX, first.ts); + for (const { pageX, ts } of rest) { + fireResponder(panel, 'onResponderMove', pageX, ts); + } + const last = rest[rest.length - 1] ?? first; + fireResponder(panel, 'onResponderRelease', last.pageX, last.ts); +} diff --git a/src/components/__tests__/error-view.test.tsx b/src/components/__tests__/error-view.test.tsx new file mode 100644 index 0000000..dec256b --- /dev/null +++ b/src/components/__tests__/error-view.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { ErrorView } from '@/components/error-view'; + +describe('ErrorView', () => { + it('affiche le message sans bouton quand aucune action de reprise', () => { + render(); + + expect(screen.getByText('Base indisponible')).toBeTruthy(); + expect(screen.getByTestId('error-view')).toBeTruthy(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('permet de réessayer via le bouton dédié', () => { + const onRetry = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('error-retry')); + + expect(screen.getByLabelText('Réessayer')).toBeTruthy(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/__tests__/loading-view.test.tsx b/src/components/__tests__/loading-view.test.tsx new file mode 100644 index 0000000..ac7c2d6 --- /dev/null +++ b/src/components/__tests__/loading-view.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react-native'; + +import { LoadingView } from '@/components/loading-view'; + +describe('LoadingView', () => { + it('affiche le message par défaut quand aucun n est fourni', () => { + render(); + + expect(screen.getByText('Chargement…')).toBeTruthy(); + expect(screen.getByTestId('loading-spinner')).toBeTruthy(); + }); + + it('affiche le message et un identifiant de test fournis', () => { + render(); + + expect(screen.getByText('Recherche…')).toBeTruthy(); + expect(screen.getByTestId('scan-loading-spinner')).toBeTruthy(); + }); +}); diff --git a/src/components/__tests__/status-badge.test.tsx b/src/components/__tests__/status-badge.test.tsx new file mode 100644 index 0000000..b6f57f7 --- /dev/null +++ b/src/components/__tests__/status-badge.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react-native'; + +import { StatusBadge } from '@/components/status-badge'; + +describe('StatusBadge', () => { + it('annonce la quantité possédée quand elle est positive', () => { + render(); + + const badge = screen.getByTestId('status-owned'); + expect(badge).toBeTruthy(); + expect(screen.getByText('Possédé (3)')).toBeTruthy(); + expect(badge).toHaveAccessibleName('Possédé, 3 exemplaires'); + }); + + it('reste générique quand le nombre d exemplaires est nul', () => { + render(); + + const badge = screen.getByTestId('status-owned'); + expect(screen.getByText('Possédé')).toBeTruthy(); + expect(badge).toHaveAccessibleName('Possédé'); + }); + + it('indique l’absence sans quantification', () => { + render(); + + const badge = screen.getByTestId('status-absent'); + expect(screen.getByText('Absent')).toBeTruthy(); + expect(badge).toHaveAccessibleName('Absent de la collection'); + }); +}); diff --git a/src/components/drawer-content.tsx b/src/components/drawer-content.tsx index 8014667..3916449 100644 --- a/src/components/drawer-content.tsx +++ b/src/components/drawer-content.tsx @@ -21,6 +21,16 @@ import { useSettingsStore } from '@/store/use-settings-store'; const DRAWER_WIDTH = 300; const CLOSE_MS = 250; +/** Un glissement ouvre le panneau au-delà de 40 % de sa largeur. */ +export function isOpenGesture(dx: number): boolean { + return dx > DRAWER_WIDTH * 0.4; +} + +/** Un glissement n'est pris en compte que s'il débute sur le bord gauche. */ +export function isLeftEdgeGesture(dx: number, x0: number): boolean { + return dx > 10 && x0 < 30; +} + type DrawerItemProps = { icon: React.ComponentProps['name']; label: string; @@ -177,14 +187,14 @@ export function DrawerMenu({ const panResponder = useMemo( () => PanResponder.create({ - onMoveShouldSetPanResponder: (_, g) => g.dx > 10 && g.x0 < 30, + onMoveShouldSetPanResponder: (_, g) => isLeftEdgeGesture(g.dx, g.x0), onPanResponderMove: (_, g) => { if (g.dx > 0 && g.dx < DRAWER_WIDTH) { translateX.setValue(g.dx - DRAWER_WIDTH); } }, onPanResponderRelease: (_, g) => { - if (g.dx > DRAWER_WIDTH * 0.4) { + if (isOpenGesture(g.dx)) { open(); } else { close(); diff --git a/src/database/__tests__/migrations.test.ts b/src/database/__tests__/migrations.test.ts index 20c451a..4107c78 100644 --- a/src/database/__tests__/migrations.test.ts +++ b/src/database/__tests__/migrations.test.ts @@ -1,5 +1,7 @@ import { createTestDatabase } from '@/test-utils/test-db'; import { migrate, getSchemaVersion } from '@/database/migrations'; +import { MIGRATION_001 } from '@/database/schema'; +import type { Database } from '@/database/types'; let testDb: ReturnType; @@ -48,4 +50,15 @@ describe('migrate', () => { const row = await testDb.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); expect(row?.user_version).toBe(getSchemaVersion()); }); + + it('migre depuis une base dont la version est indisponible', async () => { + const db = { + getFirstAsync: jest.fn().mockResolvedValue(undefined), + execAsync: jest.fn().mockResolvedValue(undefined), + } as unknown as Database; + + await migrate(db); + + expect(db.execAsync).toHaveBeenCalledWith(MIGRATION_001); + }); }); diff --git a/src/database/repositories/__tests__/collection-repository.test.ts b/src/database/repositories/__tests__/collection-repository.test.ts index 8ea5c21..a29168c 100644 --- a/src/database/repositories/__tests__/collection-repository.test.ts +++ b/src/database/repositories/__tests__/collection-repository.test.ts @@ -158,6 +158,15 @@ describe('collectionRepository.listRecentCopies', () => { it('respecte la limite et retourne une liste vide si aucun exemplaire', async () => { expect(await repo.listRecentCopies(5)).toEqual([]); }); + + it('utilise la limite par défaut de 5 quand aucune n est fournie', async () => { + await repo.addCopy(magazineId); + + const recent = await repo.listRecentCopies(); + + expect(recent).toHaveLength(1); + expect(recent[0].copy.id).toBeDefined(); + }); }); describe('collectionRepository.countAllCopies', () => { @@ -175,3 +184,23 @@ describe('collectionRepository.countAllCopies', () => { expect(await repo.countAllCopies()).toBe(3); }); }); + +describe('compteurs sans résultat renvoyé', () => { + it('traite un compteur absent comme zéro', async () => { + const originalGet = testDb.getFirstAsync; + const spy = jest.spyOn(testDb, 'getFirstAsync').mockImplementation((async ( + sql: string, + ...args: unknown[] + ) => { + if (String(sql).includes('COUNT(*)')) { + return undefined; + } + return originalGet(sql, ...args); + }) as never); + + expect(await repo.countByMagazine(magazineId)).toBe(0); + expect(await repo.countAllCopies()).toBe(0); + + spy.mockRestore(); + }); +}); diff --git a/src/database/repositories/__tests__/settings-repository.test.ts b/src/database/repositories/__tests__/settings-repository.test.ts index 814f148..b3dda7a 100644 --- a/src/database/repositories/__tests__/settings-repository.test.ts +++ b/src/database/repositories/__tests__/settings-repository.test.ts @@ -71,3 +71,25 @@ describe('settingsRepository onboarding (M10R2-05)', () => { expect(rows).toHaveLength(1); }); }); + +describe('settingsRepository reduced motion (M10R2-06)', () => { + it('retourne false par défaut avant activation', async () => { + await expect(repo.getReducedMotion()).resolves.toBe(false); + }); + + it('persiste l’activation', async () => { + await repo.setReducedMotion(true); + await expect(repo.getReducedMotion()).resolves.toBe(true); + }); + + it('peut désactiver le réglage', async () => { + await repo.setReducedMotion(true); + await repo.setReducedMotion(false); + await expect(repo.getReducedMotion()).resolves.toBe(false); + + const rows = await testDb.getAllAsync<{ key: string }>( + "SELECT key FROM settings WHERE key = 'reduced_motion'", + ); + expect(rows).toHaveLength(1); + }); +}); diff --git a/src/identification/ocr/__tests__/ocrEngine.test.ts b/src/identification/ocr/__tests__/ocrEngine.test.ts new file mode 100644 index 0000000..de5a6c3 --- /dev/null +++ b/src/identification/ocr/__tests__/ocrEngine.test.ts @@ -0,0 +1,10 @@ +import { NoopOcrEngine } from '@/identification/ocr/ocrEngine'; +import type { OcrFrame } from '@/identification/ocr/ocrTypes'; + +describe('NoopOcrEngine', () => { + it('ne reconnaît jamais de texte (moteur par défaut conservateur)', async () => { + const frame: OcrFrame = { native: null, width: 640, height: 480 }; + + await expect(new NoopOcrEngine().recognize(frame)).resolves.toBeNull(); + }); +}); diff --git a/src/identification/ocr/__tests__/ocrTextParser.test.ts b/src/identification/ocr/__tests__/ocrTextParser.test.ts index 1f0cea9..f2cc6ca 100644 --- a/src/identification/ocr/__tests__/ocrTextParser.test.ts +++ b/src/identification/ocr/__tests__/ocrTextParser.test.ts @@ -166,3 +166,17 @@ describe('isConfident', () => { expect(MIN_CONFIDENCE).toBe(0.5); }); }); + +describe('parseOcrText — repli « nombre isolé »', () => { + it('rejette un nombre isolé précédé dans le texte d’un nombre de pages', () => { + const result = parseOcrText('Spirou\n10 pages\n7'); + + expect(result).toMatchObject({ issueNumber: null }); + }); + + it('ignore un repli de 4 chiffres ressemblant à une date complète', () => { + const result = parseOcrText('Le Journal de Mickey\n1200'); + + expect(result).toMatchObject({ issueNumber: null }); + }); +}); diff --git a/src/lib/__tests__/drawer-context.test.tsx b/src/lib/__tests__/drawer-context.test.tsx new file mode 100644 index 0000000..c2deb29 --- /dev/null +++ b/src/lib/__tests__/drawer-context.test.tsx @@ -0,0 +1,25 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { DrawerProvider, useDrawer } from '@/lib/drawer-context'; + +describe('useDrawer / DrawerProvider', () => { + it('expose par défaut un tiroir fermé et inerte', () => { + const { result } = renderHook(() => useDrawer()); + + expect(result.current.visible).toBe(false); + result.current.open(); + expect(result.current.visible).toBe(false); + }); + + it('ouvre puis ferme le tiroir via le provider', () => { + const { result } = renderHook(() => useDrawer(), { wrapper: DrawerProvider }); + + expect(result.current.visible).toBe(false); + + act(() => result.current.open()); + expect(result.current.visible).toBe(true); + + act(() => result.current.close()); + expect(result.current.visible).toBe(false); + }); +}); diff --git a/src/lib/__tests__/toast.test.ts b/src/lib/__tests__/toast.test.ts new file mode 100644 index 0000000..f46ce44 --- /dev/null +++ b/src/lib/__tests__/toast.test.ts @@ -0,0 +1,27 @@ +import { Platform, ToastAndroid } from 'react-native'; + +import { toast } from '@/lib/toast'; + +describe('toast', () => { + it('affiche un toast court sur Android', () => { + const osReplace = jest.replaceProperty(Platform, 'OS', 'android'); + const show = jest.spyOn(ToastAndroid, 'show'); + + toast('Collection exportée'); + + expect(show).toHaveBeenCalledWith('Collection exportée', ToastAndroid.SHORT); + osReplace.restore(); + show.mockRestore(); + }); + + it('ne fait rien sur les autres plateformes', () => { + const osReplace = jest.replaceProperty(Platform, 'OS', 'ios'); + const show = jest.spyOn(ToastAndroid, 'show'); + + toast('Collection exportée'); + + expect(show).not.toHaveBeenCalled(); + osReplace.restore(); + show.mockRestore(); + }); +}); diff --git a/src/store/__tests__/use-backup-store.test.ts b/src/store/__tests__/use-backup-store.test.ts index 862740c..d943581 100644 --- a/src/store/__tests__/use-backup-store.test.ts +++ b/src/store/__tests__/use-backup-store.test.ts @@ -2,7 +2,7 @@ import { createTestDatabase } from '@/test-utils/test-db'; import { migrate } from '@/database/migrations'; import { CollectionRepository } from '@/database/repositories/collection-repository'; import { MagazineRepository } from '@/database/repositories/magazine-repository'; -import { BackupService } from '@/backup/backup-service'; +import { BackupService, InvalidBackupError } from '@/backup/backup-service'; import type { FileGateway } from '@/backup/file-gateway'; import { setDepsForTest, __resetForTests, type Dependencies } from '@/dependencies'; @@ -119,6 +119,23 @@ describe('useBackupStore.exportCollection', () => { expect(ok).toBe(false); expect(useBackupStore.getState().error).toBeTruthy(); }); + + it('annonce un export proposé au partage', async () => { + await seed(); + writeExport.mockResolvedValue({ uri: 'file:///doc/x.json', shared: true, name: 'x.json' }); + + await useBackupStore.getState().exportCollection('json'); + + expect(useBackupStore.getState().message).toContain('partage'); + expect(useBackupStore.getState().lastExport?.shared).toBe(true); + }); + + it('traduit une erreur inconnue à l’export', async () => { + writeExport.mockRejectedValue('panne'); + const ok = await useBackupStore.getState().exportCollection('json'); + expect(ok).toBe(false); + expect(useBackupStore.getState().error).toBe('Échec de l’export.'); + }); }); describe('useBackupStore.pickAndValidate', () => { @@ -181,6 +198,31 @@ describe('useBackupStore.pickAndValidate', () => { expect(preview).toBeNull(); expect(useBackupStore.getState().error).toContain('Fichier invalide'); }); + + it('annule l’import si la lecture du fichier échoue', async () => { + pickFile.mockRejectedValue(new Error('permission refusée')); + + const preview = await useBackupStore.getState().pickAndValidate('json'); + + expect(preview).toBeNull(); + expect(useBackupStore.getState().error).toBe('Impossible de lire le fichier sélectionné.'); + }); + + it('rejette un fichier illisible pour une raison inconnue', async () => { + await seed(); + const serviceSpy = jest + .spyOn(service, 'validateCollection') + .mockRejectedValue(new TypeError('boom')); + pickFile.mockResolvedValue({ name: 'b.json', content: '{}' }); + + const preview = await useBackupStore.getState().pickAndValidate('json'); + + expect(preview).toBeNull(); + expect(useBackupStore.getState().error).toBe( + 'Fichier invalide : impossible d’importer cette collection.', + ); + serviceSpy.mockRestore(); + }); }); describe('useBackupStore.applyPendingImport', () => { @@ -218,4 +260,62 @@ describe('useBackupStore.applyPendingImport', () => { it('renvoie null sans action s’il n’y a rien en attente', async () => { expect(await useBackupStore.getState().applyPendingImport()).toBeNull(); }); + + it('stocke une erreur générique si le remplacement échoue', async () => { + await seed(); + const source = await service.exportCollection(); + pickFile.mockResolvedValue({ name: 'b.json', content: service.toJson(source) }); + await useBackupStore.getState().pickAndValidate('json'); + + const originalExec = testDb.execAsync; + testDb.execAsync = jest.fn(async (sql: string) => { + if (sql === 'DELETE FROM magazines') { + throw new Error('panne disque'); + } + return originalExec(sql); + }); + + const summary = await useBackupStore.getState().applyPendingImport(); + + expect(summary).toBeNull(); + expect(useBackupStore.getState().error).toBe('Impossible d’importer la collection.'); + expect(useBackupStore.getState().pendingRaw).toBeNull(); + expect(useBackupStore.getState().pendingFormat).toBeNull(); + }); + + it('réinitialise l’état de l’import/export', async () => { + useBackupStore.setState({ + exporting: true, + message: 'x', + error: 'y', + lastExport: { uri: 'u', shared: false, name: 'n' }, + }); + + useBackupStore.getState().reset(); + + const state = useBackupStore.getState(); + expect(state.exporting).toBe(false); + expect(state.importing).toBe(false); + expect(state.message).toBeNull(); + expect(state.error).toBeNull(); + expect(state.lastExport).toBeNull(); + expect(state.pendingRaw).toBeNull(); + expect(state.pendingFormat).toBeNull(); + }); + + it('expose le message d’erreur de validation lors de l’application de l’import', async () => { + await seed(); + const source = await service.exportCollection(); + pickFile.mockResolvedValue({ name: 'b.json', content: service.toJson(source) }); + await useBackupStore.getState().pickAndValidate('json'); + + const serviceSpy = jest + .spyOn(service, 'importCollection') + .mockRejectedValue(new InvalidBackupError('fichier corrompu')); + const summary = await useBackupStore.getState().applyPendingImport(); + + expect(summary).toBeNull(); + expect(useBackupStore.getState().error).toBe('fichier corrompu'); + serviceSpy.mockRestore(); + }); }); diff --git a/src/store/__tests__/use-collection-store.test.ts b/src/store/__tests__/use-collection-store.test.ts index 2388fad..ac34633 100644 --- a/src/store/__tests__/use-collection-store.test.ts +++ b/src/store/__tests__/use-collection-store.test.ts @@ -264,4 +264,110 @@ describe('useCollectionStore', () => { const state = useCollectionStore.getState(); expect(state.detail?.copies).toEqual([copy]); }); + + it('signale un echec de chargement des derniers exemplaires', async () => { + mockListRecentCopies.mockRejectedValue(new Error('base indisponible')); + + await useCollectionStore.getState().loadRecent(); + + expect(useCollectionStore.getState().error).toBe('base indisponible'); + }); + + it('traduit une erreur inconnue en message générique', async () => { + mockList.mockRejectedValue('panne'); + + await useCollectionStore.getState().load(); + + expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); + }); + + it('relance l’erreur quand l’ajout d’une edition échoue', async () => { + mockCreate.mockRejectedValue(new Error('Code-barres déjà enregistré.')); + + await expect( + useCollectionStore.getState().addMagazine({ publication: 'Picsou Magazine' }), + ).rejects.toThrow('Code-barres déjà enregistré.'); + }); + + it('incrémente le compteur sans toucher aux autres editions ni au detail', async () => { + mockAddCopy.mockResolvedValue({ id: 'c3', magazineId: 'mag-x', notes: null, dateAdded: 'x' }); + useCollectionStore.setState({ + magazines: [magazine], + totalCopies: 4, + detail: { ...magazine, copies: [] }, + }); + + await useCollectionStore.getState().addExistingCopy('mag-x'); + + const state = useCollectionStore.getState(); + expect(state.magazines[0].quantity).toBe(4); + expect(state.totalCopies).toBe(5); + expect(state.detail?.id).toBe('mag-1'); + }); + + it('conserve le detail d’une autre edition lors d’une modification', async () => { + mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); + useCollectionStore.setState({ + magazines: [magazine], + detail: { ...magazine, copies: [], id: 'mag-9' }, + }); + + await useCollectionStore.getState().updateMagazine('mag-1', { publication: 'Mickey Parade' }); + + expect(useCollectionStore.getState().detail?.id).toBe('mag-9'); + }); + + it('ne change pas le compteur si l’edition à supprimer est introuvable', async () => { + mockDelete.mockResolvedValue(undefined); + useCollectionStore.setState({ magazines: [magazine], totalCopies: 4 }); + + await useCollectionStore.getState().removeMagazine('inconnue'); + + const state = useCollectionStore.getState(); + expect(state.magazines).toHaveLength(1); + expect(state.totalCopies).toBe(4); + }); + + it('traduit une erreur inconnue lors du chargement des recents', async () => { + mockListRecentCopies.mockRejectedValue('panne'); + + await useCollectionStore.getState().loadRecent(); + + expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); + }); + + it('traduit une erreur inconnue lors du chargement du resume', async () => { + mockCountAllCopies.mockRejectedValue('panne'); + + await useCollectionStore.getState().loadSummary(); + + expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); + }); + + it('traduit une erreur inconnue lors du chargement du detail', async () => { + mockFindById.mockRejectedValue('panne'); + + const detail = await useCollectionStore.getState().loadDetail('mag-1'); + + expect(detail).toBeNull(); + expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); + }); + + it('ne modifie que le detail quand l’edition est absente de la liste', async () => { + mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); + useCollectionStore.setState({ + magazines: [magazine], + detail: { ...magazine, copies: [], id: 'mag-2' }, + }); + + await useCollectionStore.getState().updateMagazine('mag-2', { + publication: 'Mickey Parade', + }); + + const state = useCollectionStore.getState(); + expect(state.magazines).toHaveLength(1); + expect(state.magazines[0].id).toBe('mag-1'); + expect(state.magazines[0].publication).toBe(magazine.publication); + expect(state.detail?.publication).toBe('Mickey Parade'); + }); }); diff --git a/src/store/__tests__/use-settings-store.test.ts b/src/store/__tests__/use-settings-store.test.ts index f05720d..be89e3d 100644 --- a/src/store/__tests__/use-settings-store.test.ts +++ b/src/store/__tests__/use-settings-store.test.ts @@ -5,6 +5,8 @@ const setColorSchemeMock = jest.fn().mockResolvedValue(undefined); const getColorSchemeMock = jest.fn().mockResolvedValue('system'); const getOnboardingDoneMock = jest.fn().mockResolvedValue(false); const setOnboardingDoneMock = jest.fn().mockResolvedValue(undefined); +const getReducedMotionMock = jest.fn().mockResolvedValue(false); +const setReducedMotionMock = jest.fn().mockResolvedValue(undefined); function stubDeps(): Dependencies { return { @@ -19,6 +21,8 @@ function stubDeps(): Dependencies { setColorScheme: setColorSchemeMock, getOnboardingDone: getOnboardingDoneMock, setOnboardingDone: setOnboardingDoneMock, + getReducedMotion: getReducedMotionMock, + setReducedMotion: setReducedMotionMock, } as unknown as Dependencies['settingsRepository'], }; } @@ -34,6 +38,10 @@ describe('useSettingsStore', () => { getOnboardingDoneMock.mockResolvedValue(false); setOnboardingDoneMock.mockClear(); setOnboardingDoneMock.mockResolvedValue(undefined); + getReducedMotionMock.mockClear(); + getReducedMotionMock.mockResolvedValue(false); + setReducedMotionMock.mockClear(); + setReducedMotionMock.mockResolvedValue(undefined); }); it('initialise le colorScheme sur system', () => { @@ -97,6 +105,56 @@ describe('useSettingsStore', () => { expect(useSettingsStore.getState().onboardingDone).toBe(true); }); + it('met à jour reducedMotion via setReducedMotion et persiste', () => { + setDepsForTest(stubDeps()); + + useSettingsStore.getState().setReducedMotion(true); + + expect(useSettingsStore.getState().reducedMotion).toBe(true); + expect(setReducedMotionMock).toHaveBeenCalledWith(true); + }); + + it('met à jour reducedMotion sans dépendances initialisées', () => { + useSettingsStore.getState().setReducedMotion(true); + expect(useSettingsStore.getState().reducedMotion).toBe(true); + }); + + it('charge le flag reducedMotion via loadReducedMotion', async () => { + getReducedMotionMock.mockResolvedValue(true); + setDepsForTest(stubDeps()); + + await useSettingsStore.getState().loadReducedMotion(); + + expect(useSettingsStore.getState().reducedMotion).toBe(true); + }); + + it('ignore un echec de persistance du colorScheme', async () => { + setColorSchemeMock.mockRejectedValue(new Error('disque plein')); + setDepsForTest(stubDeps()); + + useSettingsStore.getState().setColorScheme('dark'); + + expect(useSettingsStore.getState().colorScheme).toBe('dark'); + }); + + it('ignore un echec de persistance du flag onboarding', async () => { + setOnboardingDoneMock.mockRejectedValue(new Error('disque plein')); + setDepsForTest(stubDeps()); + + useSettingsStore.getState().markOnboardingDone(); + + expect(useSettingsStore.getState().onboardingDone).toBe(true); + }); + + it('ignore un echec de persistance de reducedMotion', async () => { + setReducedMotionMock.mockRejectedValue(new Error('disque plein')); + setDepsForTest(stubDeps()); + + useSettingsStore.getState().setReducedMotion(true); + + expect(useSettingsStore.getState().reducedMotion).toBe(true); + }); + afterEach(() => { __resetForTests(); }); From efefdcd32f2bcabba2cb852fa83dfe8f189e120b Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 09:24:35 +0200 Subject: [PATCH 06/34] refactor: purge le code mort (M-11-#186) --- src/__tests__/app/collection-screen.test.tsx | 2 - src/__tests__/app/settings-screen.test.tsx | 2 +- src/app/(tabs)/settings/appearance.tsx | 3 +- src/app/scan/camera.tsx | 3 +- src/constants/theme.ts | 16 ------- src/database/__tests__/migrations.test.ts | 14 +++++- src/database/migrations.ts | 7 +++ .../__tests__/collection-repository.test.ts | 38 +-------------- .../__tests__/magazine-repository.test.ts | 46 ------------------ .../repositories/collection-repository.ts | 32 ------------- .../repositories/magazine-repository.ts | 12 ----- src/dependencies.ts | 12 +++-- src/identification/ocr/ocrEngine.ts | 11 ++--- src/identification/scanBarcode.ts | 13 +---- .../__tests__/use-collection-store.test.ts | 48 ------------------- .../__tests__/use-settings-store.test.ts | 3 +- src/store/use-collection-store.ts | 12 ----- src/store/use-settings-store.ts | 7 +-- 18 files changed, 44 insertions(+), 237 deletions(-) diff --git a/src/__tests__/app/collection-screen.test.tsx b/src/__tests__/app/collection-screen.test.tsx index 23fdd00..fdfb1a4 100644 --- a/src/__tests__/app/collection-screen.test.tsx +++ b/src/__tests__/app/collection-screen.test.tsx @@ -73,7 +73,6 @@ describe('CollectionScreen', () => { loading: false, error: null, load: jest.fn(), - loadRecent: jest.fn(), }); }); @@ -246,7 +245,6 @@ describe('CollectionScreen (pagination)', () => { loading: false, error: null, load: jest.fn(), - loadRecent: jest.fn(), }); }); diff --git a/src/__tests__/app/settings-screen.test.tsx b/src/__tests__/app/settings-screen.test.tsx index 247a72c..d632771 100644 --- a/src/__tests__/app/settings-screen.test.tsx +++ b/src/__tests__/app/settings-screen.test.tsx @@ -50,7 +50,7 @@ describe('AppearanceScreen', () => { beforeEach(() => { setColorSchemeMock.mockClear(); setColorSchemeMock.mockResolvedValue(undefined); - useSettingsStore.setState({ colorScheme: 'system', loaded: false }); + useSettingsStore.setState({ colorScheme: 'system' }); setDepsForTest(stubDeps()); }); diff --git a/src/app/(tabs)/settings/appearance.tsx b/src/app/(tabs)/settings/appearance.tsx index a288682..b0166f4 100644 --- a/src/app/(tabs)/settings/appearance.tsx +++ b/src/app/(tabs)/settings/appearance.tsx @@ -6,7 +6,8 @@ import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; import { AppHeader } from '@/components/app-header'; import { Screen } from '@/components/screen'; -import { useSettingsStore, type ColorSchemeSetting } from '@/store/use-settings-store'; +import type { ColorSchemeSetting } from '@/database/repositories/settings-repository'; +import { useSettingsStore } from '@/store/use-settings-store'; const THEME_OPTIONS: { value: ColorSchemeSetting; label: string; description: string }[] = [ { value: 'system', label: 'Système', description: 'Suivre le thème de l’appareil' }, diff --git a/src/app/scan/camera.tsx b/src/app/scan/camera.tsx index ef33712..0af6e55 100644 --- a/src/app/scan/camera.tsx +++ b/src/app/scan/camera.tsx @@ -19,6 +19,7 @@ import { Spacing, type ThemeColors } from '@/constants/theme'; import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; import { OcrTextStabilizer } from '@/identification/ocr/ocrTextStabilizer'; +import { MIN_CONFIDENCE } from '@/identification/ocr/ocrTextParser'; /** Intervalle d'analyse OCR : quelques frames / seconde max (pas toutes). */ const ANALYSIS_INTERVAL_MS = 500; @@ -566,7 +567,7 @@ function confidenceLabel(confidence: number): string { if (confidence >= 0.8) { return 'élevée'; } - if (confidence >= 0.5) { + if (confidence >= MIN_CONFIDENCE) { return 'moyenne'; } return 'faible'; diff --git a/src/constants/theme.ts b/src/constants/theme.ts index 50b5f16..dc02389 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -23,7 +23,6 @@ export const Colors = { /** Fond teinté pour bandeaux d'erreur (M10-09/11). */ dangerBg: '#FDE8E8', success: '#1B7F3B', - onSuccess: '#FFFFFF', /** Badges statut — Possédé = vert positif, Absent = neutre (M10-07, sémantique inversée). */ ownedBg: '#E6F4EA', ownedText: '#1B7F3B', @@ -43,7 +42,6 @@ export const Colors = { onDanger: '#410002', dangerBg: '#3A1A18', success: '#7BC67E', - onSuccess: '#062B0A', ownedBg: '#1E3A2A', ownedText: '#7BC67E', absentBg: '#26334A', @@ -54,26 +52,12 @@ export const Colors = { export type ThemeColors = { [K in keyof (typeof Colors)['light']]: string }; export const Spacing = { - zero: 0, one: 4, two: 8, - twoHalf: 12, three: 16, four: 24, five: 32, } as const; -export type SpacingScale = typeof Spacing; - -/** Échelle typographique minimale (M10-01) — 13px min en UI, jamais 12px. */ -export const Typography = { - display: { fontSize: 48, lineHeight: 56, fontWeight: '800' }, - headline: { fontSize: 24, lineHeight: 32, fontWeight: '700' }, - title: { fontSize: 20, lineHeight: 28, fontWeight: '700' }, - body: { fontSize: 16, lineHeight: 24, fontWeight: '400' }, - bodySm: { fontSize: 14, lineHeight: 20, fontWeight: '400' }, - label: { fontSize: 13, lineHeight: 18, fontWeight: '600' }, -} as const; - /** Cible tactile minimale (M10-02/M10-10). */ export const HitTarget = { minHeight: 44, hitSlop: 8 } as const; diff --git a/src/database/__tests__/migrations.test.ts b/src/database/__tests__/migrations.test.ts index 4107c78..1b7cadb 100644 --- a/src/database/__tests__/migrations.test.ts +++ b/src/database/__tests__/migrations.test.ts @@ -53,7 +53,10 @@ describe('migrate', () => { it('migre depuis une base dont la version est indisponible', async () => { const db = { - getFirstAsync: jest.fn().mockResolvedValue(undefined), + getFirstAsync: jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ user_version: getSchemaVersion() }), execAsync: jest.fn().mockResolvedValue(undefined), } as unknown as Database; @@ -61,4 +64,13 @@ describe('migrate', () => { expect(db.execAsync).toHaveBeenCalledWith(MIGRATION_001); }); + + it('signale une migration incomplète si la version finale est fausse', async () => { + const db = { + getFirstAsync: jest.fn().mockResolvedValue(undefined), + execAsync: jest.fn().mockResolvedValue(undefined), + } as unknown as Database; + + await expect(migrate(db)).rejects.toThrow(/Migration incomplète/); + }); }); diff --git a/src/database/migrations.ts b/src/database/migrations.ts index fbb26a9..d471b76 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -29,6 +29,13 @@ export async function migrate(db: Database): Promise { await db.execAsync(`PRAGMA user_version = ${migration.version}`); } } + + const finalRow = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + if ((finalRow?.user_version ?? 0) !== getSchemaVersion()) { + throw new Error( + `Migration incomplète : version ${finalRow?.user_version ?? 0} au lieu de ${getSchemaVersion()}.`, + ); + } } export function getSchemaVersion(): number { diff --git a/src/database/repositories/__tests__/collection-repository.test.ts b/src/database/repositories/__tests__/collection-repository.test.ts index a29168c..3d2b3c8 100644 --- a/src/database/repositories/__tests__/collection-repository.test.ts +++ b/src/database/repositories/__tests__/collection-repository.test.ts @@ -43,9 +43,8 @@ describe('collectionRepository.addCopy', () => { expect(copy.id).toMatch(/^b1a2c3d4-0000-4000-8000-/); expect(copy.dateAdded).toBeDefined(); - const reloaded = await repo.listByMagazine(magazineId); - expect(reloaded).toHaveLength(1); - expect(reloaded[0]).toEqual(copy); + const reloaded = await repo.countByMagazine(magazineId); + expect(reloaded).toBe(1); }); it('refuse un exemplaire pour une edition inexistante', async () => { @@ -72,39 +71,6 @@ describe('collectionRepository.countByMagazine', () => { }); }); -describe('collectionRepository.listByMagazine', () => { - it('liste du plus recent au plus ancien', async () => { - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'copy-1', - magazineId, - '2026-01-01T10:00:00Z', - ); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'copy-2', - magazineId, - '2026-09-01T10:00:00Z', - ); - - const copies = await repo.listByMagazine(magazineId); - - expect(copies.map((c) => c.id)).toEqual(['copy-2', 'copy-1']); - }); -}); - -describe('collectionRepository.deleteCopy', () => { - it('supprime un exemplaire', async () => { - const copy = await repo.addCopy(magazineId); - - await repo.deleteCopy(copy.id); - - expect(await repo.countByMagazine(magazineId)).toBe(0); - }); -}); - describe('suppression en cascade', () => { it('supprimer une edition supprime ses exemplaires', async () => { await repo.addCopy(magazineId); diff --git a/src/database/repositories/__tests__/magazine-repository.test.ts b/src/database/repositories/__tests__/magazine-repository.test.ts index 595629c..137bc4a 100644 --- a/src/database/repositories/__tests__/magazine-repository.test.ts +++ b/src/database/repositories/__tests__/magazine-repository.test.ts @@ -104,52 +104,6 @@ describe('magazineRepository.create', () => { }); }); -describe('magazineRepository.findByBarcode', () => { - it('retrouve ledition depuis un code-barres connu', async () => { - const created = await repo.create({ - publication: 'Picsou Magazine', - issueNumber: 547, - language: 'FR', - barcode: '3271234567890', - }); - - const found = await repo.findByBarcode('3271234567890'); - - expect(found).toEqual( - expect.objectContaining({ - id: created.id, - publication: 'Picsou Magazine', - issueNumber: 547, - language: 'FR', - barcode: '3271234567890', - notes: null, - ocrText: null, - }), - ); - }); - - it('renvoie null pour un code-barres inconnu', async () => { - const found = await repo.findByBarcode('9999999999999'); - - expect(found).toBeNull(); - }); - - it('ne charge pas les champs lourds notes/ocr_text', async () => { - await repo.create({ - publication: 'Super Picsou Géant', - issueNumber: 30, - notes: 'coffret specifique', - ocrText: 'Super Picsou Géant n°30', - barcode: '3271234000011', - }); - - const found = await repo.findByBarcode('3271234000011'); - - expect(found?.notes).toBeNull(); - expect(found?.ocrText).toBeNull(); - }); -}); - describe('magazineRepository.findManyByBarcode', () => { it('retourne toutes les editions partageant le meme code-barres', async () => { const first = await repo.create({ diff --git a/src/database/repositories/collection-repository.ts b/src/database/repositories/collection-repository.ts index 89cc129..6018e77 100644 --- a/src/database/repositories/collection-repository.ts +++ b/src/database/repositories/collection-repository.ts @@ -2,22 +2,6 @@ import type { Database } from '@/database/types'; import type { CollectionItem, CreateCollectionItemInput, RecentCopy } from '@/types'; import { generateId } from '@/utils/id'; -type CollectionItemRow = { - id: string; - magazine_id: string; - notes: string | null; - date_added: string; -}; - -function toCollectionItem(row: CollectionItemRow): CollectionItem { - return { - id: row.id, - magazineId: row.magazine_id, - notes: row.notes, - dateAdded: row.date_added, - }; -} - export class CollectionRepository { constructor(private readonly db: Database) {} @@ -61,22 +45,6 @@ export class CollectionRepository { return Number(row?.quantity ?? 0); } - async listByMagazine(magazineId: string): Promise { - const rows = await this.db.getAllAsync( - `SELECT id, magazine_id, notes, date_added - FROM collection_items - WHERE magazine_id = ? - ORDER BY date_added DESC`, - magazineId, - ); - - return rows.map(toCollectionItem); - } - - async deleteCopy(id: string): Promise { - await this.db.runAsync('DELETE FROM collection_items WHERE id = ?', id); - } - async listRecentCopies(limit = 5): Promise { const rows = await this.db.getAllAsync<{ copy_id: string; diff --git a/src/database/repositories/magazine-repository.ts b/src/database/repositories/magazine-repository.ts index 60add7c..31e5e95 100644 --- a/src/database/repositories/magazine-repository.ts +++ b/src/database/repositories/magazine-repository.ts @@ -55,18 +55,6 @@ function toMagazine( export class MagazineRepository { constructor(private readonly db: Database) {} - async findByBarcode(barcode: string): Promise { - const row = await this.db.getFirstAsync( - `SELECT id, publication, issue_number, edition, language, condition, publication_date, - barcode, created_at, updated_at - FROM magazines - WHERE barcode = ?`, - barcode, - ); - - return row ? toMagazine(row) : null; - } - async findManyByBarcode(barcode: string): Promise { const rows = await this.db.getAllAsync< Omit & { quantity: number } diff --git a/src/dependencies.ts b/src/dependencies.ts index 3319e82..14acf32 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -7,6 +7,8 @@ import { SettingsRepository } from '@/database/repositories/settings-repository' import { IdentificationService } from '@/identification/identificationService'; import type { OcrEngine } from '@/identification/ocr/ocrTypes'; import { MlKitOcrEngine } from '@/identification/ocr/mlKitOcrEngine'; +import { NoopOcrEngine } from '@/identification/ocr/ocrEngine'; +import { Platform } from 'react-native'; import { BackupService } from '@/backup/backup-service'; import { NativeFileGateway } from '@/backup/native-file-gateway'; import type { FileGateway } from '@/backup/file-gateway'; @@ -43,9 +45,13 @@ export async function initialize(): Promise { } const db = await dbPromise; if (!deps) { - // Moteur OCR natif (ML Kit via expo-mlkit-ocr). Sur CI / hors Dev Build, - // l'import est paresseux dans `recognize` : il ne casse pas les tests. - const ocrEngine: OcrEngine = new MlKitOcrEngine(); + // Moteur OCR natif (ML Kit via expo-mlkit-ocr) sur Android/iOS ; sur le web + // (aucun module natif) on utilise un moteur inerte : le flux caméra reste + // câblé et la CI démarre sans blocage. + const ocrEngine: OcrEngine = + Platform.OS === 'android' || Platform.OS === 'ios' + ? new MlKitOcrEngine() + : new NoopOcrEngine(); const magazineRepository = new MagazineRepository(db); deps = { magazineRepository, diff --git a/src/identification/ocr/ocrEngine.ts b/src/identification/ocr/ocrEngine.ts index c42e094..17c7e15 100644 --- a/src/identification/ocr/ocrEngine.ts +++ b/src/identification/ocr/ocrEngine.ts @@ -1,16 +1,13 @@ import type { OcrEngine, OcrFrame, OcrFrameResult } from './ocrTypes'; /** - * Moteur OCR par défaut, volontairement inerte. + * Moteur OCR inerte, utilisé sur les plateformes sans module natif (web). * * L'OCR réel repose sur un module natif (Google ML Kit Text Recognition) qui n'est * disponible que sur un **Development Build** et doit être validé sur téléphone - * physique. Pour ne pas bloquer la CI (tests / lint / typecheck), le moteur par - * défaut retourne toujours "pas de texte" : le flux caméra reste pleinement - * câblé et testable, seule la reconnaissance brute est à activer sur l'appareil. - * - * À brancher physiquement : remplacer `NoopOcrEngine` par l'implémentation natale - * (`MlKitOcrEngine`) dans `dependencies.initialize()`. Voir + * physique. Sur Android/iOS, `dependencies.initialize()` instancie `MlKitOcrEngine` ; + * sur le web (aucun module natif) ce moteur retourne toujours "pas de texte" : + * le flux caméra reste pleinement câblé et testable. Voir * `docs/05-ARCHITECTURE.md § OCR` et `mlKitOcrEngine.ts`. */ export class NoopOcrEngine implements OcrEngine { diff --git a/src/identification/scanBarcode.ts b/src/identification/scanBarcode.ts index fdf04e0..948e602 100644 --- a/src/identification/scanBarcode.ts +++ b/src/identification/scanBarcode.ts @@ -17,7 +17,6 @@ export type BarcodeValidation = const EAN_13_LENGTH = 13; const ISBN_10_LENGTH = 10; -const ISBN_13_LENGTH = 13; /** Longueur minimale acceptable pour un code-barres au format non standard. */ const GENERIC_MIN_LENGTH = 6; @@ -50,16 +49,6 @@ function isValidIsbn10(isbn: string): boolean { return sum % 11 === 0; } -function normalizeIsbn13(raw: string): string { - // ISBN-13 scanné : on enlève le préfixe "978" ou "979" et on garde 10 chiffres - if (raw.length === ISBN_13_LENGTH && (raw.startsWith('978') || raw.startsWith('979'))) { - // Retirer le préfixe + le checksum ISBN-13 resérialisé en ISBN-10 est complexe ; - // on conserve ici la forme ISBN-13 complète, canonique pour la recherche. - return raw; - } - return raw; -} - /** * Valide et normalise un code-barres. * @@ -95,7 +84,7 @@ export function validateBarcode(raw: string): BarcodeValidation { return { valid: true, type: isIsbn ? 'ISBN-13' : 'EAN-13', - normalized: normalizeIsbn13(digits), + normalized: digits, }; } return { valid: false, reason: 'EAN-13 invalide (checksum).' }; diff --git a/src/store/__tests__/use-collection-store.test.ts b/src/store/__tests__/use-collection-store.test.ts index ac34633..388a13d 100644 --- a/src/store/__tests__/use-collection-store.test.ts +++ b/src/store/__tests__/use-collection-store.test.ts @@ -4,14 +4,12 @@ import type { MagazineListItem } from '@/types'; const mockList = jest.fn(); const mockCreate = jest.fn(); const mockDelete = jest.fn(); -const mockFindByBarcode = jest.fn(); const mockFindById = jest.fn(); const mockUpdate = jest.fn(); const mockMagazineRepo = { list: mockList, create: mockCreate, delete: mockDelete, - findByBarcode: mockFindByBarcode, findById: mockFindById, update: mockUpdate, }; @@ -23,9 +21,6 @@ const mockCollectionRepo = { listRecentCopies: mockListRecentCopies, countAllCopies: mockCountAllCopies, addCopy: mockAddCopy, - countByMagazine: jest.fn(), - listByMagazine: jest.fn(), - deleteCopy: jest.fn(), }; jest.mock('@/dependencies', () => ({ @@ -117,25 +112,6 @@ describe('useCollectionStore', () => { expect(state.totalCopies).toBe(0); }); - it('charge les derniers exemplaires ajoutes', async () => { - mockListRecentCopies.mockResolvedValue([ - { - copy: { - id: 'c1', - magazineId: 'mag-1', - notes: null, - dateAdded: '2026-09-01T10:00:00Z', - }, - magazine: { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: 547 }, - }, - ]); - - await useCollectionStore.getState().loadRecent(); - - expect(useCollectionStore.getState().recentCopies).toHaveLength(1); - expect(mockListRecentCopies).toHaveBeenCalledWith(5); - }); - it('charge un resume leger (compteur + recents) sans lister les editions', async () => { mockCountAllCopies.mockResolvedValue(7); mockListRecentCopies.mockResolvedValue([ @@ -194,14 +170,6 @@ describe('useCollectionStore', () => { expect(useCollectionStore.getState().detailLoading).toBe(false); }); - it('efface le detail a la sortie de la fiche', () => { - useCollectionStore.setState({ detail: { ...magazine, copies: [] } }); - - useCollectionStore.getState().clearDetail(); - - expect(useCollectionStore.getState().detail).toBeNull(); - }); - it('modifie une edition et rafraichit la liste et le detail', async () => { mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); useCollectionStore.setState({ @@ -265,14 +233,6 @@ describe('useCollectionStore', () => { expect(state.detail?.copies).toEqual([copy]); }); - it('signale un echec de chargement des derniers exemplaires', async () => { - mockListRecentCopies.mockRejectedValue(new Error('base indisponible')); - - await useCollectionStore.getState().loadRecent(); - - expect(useCollectionStore.getState().error).toBe('base indisponible'); - }); - it('traduit une erreur inconnue en message générique', async () => { mockList.mockRejectedValue('panne'); @@ -328,14 +288,6 @@ describe('useCollectionStore', () => { expect(state.totalCopies).toBe(4); }); - it('traduit une erreur inconnue lors du chargement des recents', async () => { - mockListRecentCopies.mockRejectedValue('panne'); - - await useCollectionStore.getState().loadRecent(); - - expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); - }); - it('traduit une erreur inconnue lors du chargement du resume', async () => { mockCountAllCopies.mockRejectedValue('panne'); diff --git a/src/store/__tests__/use-settings-store.test.ts b/src/store/__tests__/use-settings-store.test.ts index be89e3d..d3f97ad 100644 --- a/src/store/__tests__/use-settings-store.test.ts +++ b/src/store/__tests__/use-settings-store.test.ts @@ -29,7 +29,7 @@ function stubDeps(): Dependencies { describe('useSettingsStore', () => { beforeEach(() => { - useSettingsStore.setState({ colorScheme: 'system', loaded: false }); + useSettingsStore.setState({ colorScheme: 'system' }); setColorSchemeMock.mockClear(); setColorSchemeMock.mockResolvedValue(undefined); getColorSchemeMock.mockClear(); @@ -72,7 +72,6 @@ describe('useSettingsStore', () => { await useSettingsStore.getState().loadColorScheme(); expect(useSettingsStore.getState().colorScheme).toBe('dark'); - expect(useSettingsStore.getState().loaded).toBe(true); }); it('accepte le retour à system', () => { diff --git a/src/store/use-collection-store.ts b/src/store/use-collection-store.ts index 2e82e9a..c09478b 100644 --- a/src/store/use-collection-store.ts +++ b/src/store/use-collection-store.ts @@ -13,7 +13,6 @@ interface CollectionState { loaded: boolean; totalCopies: number; load: () => Promise; - loadRecent: () => Promise; /** Résumé léger (accueil) : compteur total + 5 derniers exemplaires, sans lister les éditions. */ loadSummary: () => Promise; loadDetail: (id: string) => Promise; @@ -21,7 +20,6 @@ interface CollectionState { addExistingCopy: (magazineId: string) => Promise; updateMagazine: (id: string, input: CreateMagazineInput) => Promise; removeMagazine: (id: string) => Promise; - clearDetail: () => void; } export const useCollectionStore = create((set, get) => ({ @@ -46,16 +44,6 @@ export const useCollectionStore = create((set, get) => ({ } }, - loadRecent: async () => { - try { - const { collectionRepository } = getDeps(); - const recentCopies = await collectionRepository.listRecentCopies(5); - set({ recentCopies }); - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Erreur inconnue' }); - } - }, - loadSummary: async () => { set({ loading: true, error: null }); try { diff --git a/src/store/use-settings-store.ts b/src/store/use-settings-store.ts index 76b6f1f..5a1d7d3 100644 --- a/src/store/use-settings-store.ts +++ b/src/store/use-settings-store.ts @@ -1,12 +1,10 @@ import { create } from 'zustand'; import { getDeps } from '@/dependencies'; - -export type ColorSchemeSetting = 'light' | 'dark' | 'system'; +import type { ColorSchemeSetting } from '@/database/repositories/settings-repository'; interface SettingsState { colorScheme: ColorSchemeSetting; - loaded: boolean; onboardingDone: boolean; onboardingLoaded: boolean; reducedMotion: boolean; @@ -20,7 +18,6 @@ interface SettingsState { export const useSettingsStore = create((set) => ({ colorScheme: 'system', - loaded: false, onboardingDone: false, onboardingLoaded: false, reducedMotion: false, @@ -38,7 +35,7 @@ export const useSettingsStore = create((set) => ({ loadColorScheme: async () => { const colorScheme = await getDeps().settingsRepository.getColorScheme(); - set({ colorScheme, loaded: true }); + set({ colorScheme }); }, loadOnboardingDone: async () => { From db6bdcb0a215f183cf3143afe27f767966e150d1 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 09:31:46 +0200 Subject: [PATCH 07/34] refactor: conventions (date helper, a11y, perf) (M-11-#186) --- src/app/(tabs)/index.tsx | 17 ++--------- src/app/collection/[id]/index.tsx | 14 +++------- src/components/autocomplete-input.tsx | 24 ++++++++-------- src/components/camera-permission-screen.tsx | 3 +- src/components/select-field.tsx | 3 +- src/components/status-badge.tsx | 4 +-- src/lib/__tests__/date.test.ts | 31 +++++++++++++++++++++ src/lib/date.ts | 30 ++++++++++++++++++++ src/lib/drawer-context.tsx | 8 +++--- 9 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 src/lib/__tests__/date.test.ts create mode 100644 src/lib/date.ts diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 845aaa7..f8f9d64 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -8,20 +8,7 @@ import { useThemeColors } from '@/hooks/use-theme'; import { AppHeader } from '@/components/app-header'; import { Screen } from '@/components/screen'; import { useCollectionStore } from '@/store/use-collection-store'; - -const dateFormatter = new Intl.DateTimeFormat('fr-FR', { - day: 'numeric', - month: 'short', - year: 'numeric', -}); - -function formatDate(iso: string): string { - const time = Date.parse(iso); - if (Number.isNaN(time)) { - return iso.slice(0, 10); - } - return dateFormatter.format(new Date(time)); -} +import { formatDateLong } from '@/lib/date'; /** * Accueil cockpit brocante (M10-04) : un seul CTA primaire Scanner, @@ -129,7 +116,7 @@ export default function HomeScreen() { {magazine.publication} {magazine.issueNumber != null ? ` n°${magazine.issueNumber}` : ''} - {formatDate(copy.dateAdded)} + {formatDateLong(copy.dateAdded)} diff --git a/src/app/collection/[id]/index.tsx b/src/app/collection/[id]/index.tsx index ba556f4..3eeb3f7 100644 --- a/src/app/collection/[id]/index.tsx +++ b/src/app/collection/[id]/index.tsx @@ -13,16 +13,10 @@ import { useThemeColors } from '@/hooks/use-theme'; import { slug } from '@/lib/slug'; import { toast } from '@/lib/toast'; import { useCollectionStore } from '@/store/use-collection-store'; +import { formatDateShort } from '@/lib/date'; type StyleSheetType = ReturnType; -function formatDate(iso: string | null): string { - if (!iso) { - return 'Inconnue'; - } - return new Date(iso).toLocaleDateString('fr-FR'); -} - export default function MagazineDetailScreen() { const router = useRouter(); const colors = useThemeColors(); @@ -131,9 +125,9 @@ export default function MagazineDetailScreen() { - + - + {detail.notes ? ( @@ -152,7 +146,7 @@ export default function MagazineDetailScreen() { detail.copies.map((copy, index) => ( #{index + 1} - {formatDate(copy.dateAdded)} + {formatDateShort(copy.dateAdded)} )) )} diff --git a/src/components/autocomplete-input.tsx b/src/components/autocomplete-input.tsx index 7d76fd0..99c829c 100644 --- a/src/components/autocomplete-input.tsx +++ b/src/components/autocomplete-input.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { Spacing, type ThemeColors } from '@/constants/theme'; @@ -36,17 +37,17 @@ export function AutocompleteInput({ const styles = makeStyles(colors); const normalizedInput = normalize(value); - const suggestions = - normalizedInput.length > 0 - ? [...new Set(options)] - .filter((option) => { - const normalizedOption = normalize(option); - return ( - normalizedOption !== normalizedInput && normalizedOption.includes(normalizedInput) - ); - }) - .slice(0, 5) - : []; + const suggestions = useMemo(() => { + if (normalizedInput.length === 0) { + return []; + } + return [...new Set(options)] + .filter((option) => { + const normalizedOption = normalize(option); + return normalizedOption !== normalizedInput && normalizedOption.includes(normalizedInput); + }) + .slice(0, 5); + }, [normalizedInput, options]); return ( @@ -66,6 +67,7 @@ export function AutocompleteInput({ data={suggestions} keyExtractor={(item) => item} keyboardShouldPersistTaps="handled" + nestedScrollEnabled renderItem={({ item }) => ( [styles.suggestion, pressed && styles.pressed]} diff --git a/src/components/camera-permission-screen.tsx b/src/components/camera-permission-screen.tsx index df6c55e..53ebd06 100644 --- a/src/components/camera-permission-screen.tsx +++ b/src/components/camera-permission-screen.tsx @@ -71,7 +71,8 @@ export function CameraPermissionScreen({ style={({ pressed }) => [styles.cancelButton, pressed && styles.pressed]} onPress={onCancel} testID="permission-cancel" - accessibilityRole="button"> + accessibilityRole="button" + accessibilityLabel="Retour"> Retour diff --git a/src/components/select-field.tsx b/src/components/select-field.tsx index 95e3523..dc7a113 100644 --- a/src/components/select-field.tsx +++ b/src/components/select-field.tsx @@ -76,7 +76,8 @@ export function SelectField({ ]} onPress={() => handleSelect(isNone ? null : item)} testID={`${testID}-option-${item}`} - accessibilityRole="button"> + accessibilityRole="button" + accessibilityState={{ selected }}> {item} diff --git a/src/components/status-badge.tsx b/src/components/status-badge.tsx index ca2b289..3e3e28c 100644 --- a/src/components/status-badge.tsx +++ b/src/components/status-badge.tsx @@ -12,7 +12,7 @@ type Props = { /** * Badge Possédé / Absent — M10-01/M10-07 : sémantique inversée validée. * Possédé = vert positif (+ check), Absent = neutre. Thématisé clair/sombre, - * `accessibilityRole="summary"`, 13px min (M10-10). + * 13px min (M10-10). */ export function StatusBadge({ owned, quantity = 0 }: Props) { const colors = useThemeColors(); @@ -21,7 +21,6 @@ export function StatusBadge({ owned, quantity = 0 }: Props) { 0 ? `Possédé, ${quantity} exemplaires` : 'Possédé'}> @@ -32,7 +31,6 @@ export function StatusBadge({ owned, quantity = 0 }: Props) { Absent diff --git a/src/lib/__tests__/date.test.ts b/src/lib/__tests__/date.test.ts new file mode 100644 index 0000000..193e74b --- /dev/null +++ b/src/lib/__tests__/date.test.ts @@ -0,0 +1,31 @@ +import { formatDateLong, formatDateShort } from '@/lib/date'; + +describe('formatDateLong', () => { + it('formate une date ISO valide en mois abrégé', () => { + const result = formatDateLong('2026-09-01T10:00:00Z'); + expect(result).toMatch(/1\s.*2026/); + }); + + it('retourne la tranche brute si la date est invalide', () => { + expect(formatDateLong('not-a-date')).toBe('not-a-date'); + }); + + it('retourne Inconnue si nul', () => { + expect(formatDateLong(null)).toBe('Inconnue'); + }); +}); + +describe('formatDateShort', () => { + it('formate une date ISO valide en format court', () => { + const result = formatDateShort('2026-09-01T10:00:00Z'); + expect(result).toMatch(/1\/09\/2026/); + }); + + it('retourne Inconnue si la date est invalide', () => { + expect(formatDateShort('not-a-date')).toBe('Inconnue'); + }); + + it('retourne Inconnue si nul', () => { + expect(formatDateShort(null)).toBe('Inconnue'); + }); +}); diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..542855a --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,30 @@ +/** + * Formateurs de date partagés (fr-FR) — évite la duplication entre + * `(tabs)/index.tsx` et `collection/[id]/index.tsx`. + */ + +const DATE_MONTH_NAME = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', +}); + +function parseDateSafe(iso: string | null): Date | null { + if (!iso) { + return null; + } + const time = Date.parse(iso); + return Number.isNaN(time) ? null : new Date(time); +} + +/** Format long : « 1 sep. 2026 » — retourne le sous-chaîné brut si invalide. */ +export function formatDateLong(iso: string | null): string { + const d = parseDateSafe(iso); + return d ? DATE_MONTH_NAME.format(d) : (iso?.slice(0, 10) ?? 'Inconnue'); +} + +/** Format court : « 1/09/2026 » — retourne « Inconnue » si nul ou invalide. */ +export function formatDateShort(iso: string | null): string { + const d = parseDateSafe(iso); + return d ? d.toLocaleDateString('fr-FR') : 'Inconnue'; +} diff --git a/src/lib/drawer-context.tsx b/src/lib/drawer-context.tsx index f11924a..58a186e 100644 --- a/src/lib/drawer-context.tsx +++ b/src/lib/drawer-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; type DrawerContextValue = { open: () => void; @@ -21,9 +21,9 @@ export function DrawerProvider({ children }: { children: React.ReactNode }) { const open = useCallback(() => setVisible(true), []); const close = useCallback(() => setVisible(false), []); - return ( - {children} - ); + const value = useMemo(() => ({ open, close, visible }), [open, close, visible]); + + return {children}; } export { DrawerProvider as default }; From 64231887ca41ac636e1a34cf389f7c21a6466d4b Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 10:40:24 +0200 Subject: [PATCH 08/34] =?UTF-8?q?refactor(m11-05):=20d=C3=A9couper=20servi?= =?UTF-8?q?ces=20&=20composants=20m=C3=A9tier=20(M-11-#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(tabs)/collection/index.tsx | 404 ++++------------ src/app/scan/search.tsx | 373 +++++---------- src/backup/backup-csv.ts | 199 ++++++++ src/backup/backup-parsers.ts | 127 +++++ src/backup/backup-service.ts | 347 +------------- .../__tests__/use-magazine-form.test.ts | 106 +++++ src/components/collection-filters.tsx | 169 +++++++ src/components/drawer-content.tsx | 231 +++------- src/components/drawer-items.tsx | 132 ++++++ src/components/magazine-form-styles.ts | 124 +++++ src/components/magazine-form.tsx | 433 ++++++------------ src/components/pagination.tsx | 108 +++++ src/components/scan-search-views.tsx | 281 ++++++++++++ src/components/use-magazine-form.ts | 145 ++++++ src/lib/__tests__/collection-list.test.ts | 115 +++++ src/lib/collection-list.ts | 79 ++++ 16 files changed, 1973 insertions(+), 1400 deletions(-) create mode 100644 src/backup/backup-csv.ts create mode 100644 src/backup/backup-parsers.ts create mode 100644 src/components/__tests__/use-magazine-form.test.ts create mode 100644 src/components/collection-filters.tsx create mode 100644 src/components/drawer-items.tsx create mode 100644 src/components/magazine-form-styles.ts create mode 100644 src/components/pagination.tsx create mode 100644 src/components/scan-search-views.tsx create mode 100644 src/components/use-magazine-form.ts create mode 100644 src/lib/__tests__/collection-list.test.ts create mode 100644 src/lib/collection-list.ts diff --git a/src/app/(tabs)/collection/index.tsx b/src/app/(tabs)/collection/index.tsx index 01dc39d..b28db12 100644 --- a/src/app/(tabs)/collection/index.tsx +++ b/src/app/(tabs)/collection/index.tsx @@ -1,70 +1,24 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useMemo, useState } from 'react'; -import { - FlatList, - Pressable, - RefreshControl, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { Feather } from '@expo/vector-icons'; +import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { AppHeader } from '@/components/app-header'; +import { CollectionFilters } from '@/components/collection-filters'; import { EmptyState } from '@/components/empty-state'; import { LoadingView } from '@/components/loading-view'; import { MagazineCard } from '@/components/magazine-card'; -import { SelectField } from '@/components/select-field'; +import { Pagination } from '@/components/pagination'; import { Screen } from '@/components/screen'; -import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; import { useCollectionStore } from '@/store/use-collection-store'; import type { MagazineListItem } from '@/types'; - -const PAGE_SIZE = 20; - -/** Options de tri de la collection (M10R2-08). */ -const SORT_OPTIONS = ['Numéro ↑', 'Numéro ↓', 'Ajout récent', 'Édition (A → Z)'] as const; -type SortOption = (typeof SORT_OPTIONS)[number]; - -function pageWindow(current: number, total: number): number[] { - const start = Math.max(1, current - 2); - const end = Math.min(total, current + 2); - return Array.from({ length: end - start + 1 }, (_, i) => start + i); -} - -function sortMagazines(list: MagazineListItem[], sort: SortOption): MagazineListItem[] { - const sorted = [...list]; - switch (sort) { - case 'Numéro ↓': - return sorted.sort((a, b) => { - const na = a.issueNumber ?? Number.MIN_SAFE_INTEGER; - const nb = b.issueNumber ?? Number.MIN_SAFE_INTEGER; - if (na !== nb) return nb - na; - return a.publication.localeCompare(b.publication); - }); - case 'Ajout récent': - return sorted.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - case 'Édition (A → Z)': - return sorted.sort((a, b) => { - const ea = (a.edition?.trim() || '').toLowerCase(); - const eb = (b.edition?.trim() || '').toLowerCase(); - if (ea !== eb) return ea.localeCompare(eb); - return a.publication.localeCompare(b.publication); - }); - case 'Numéro ↑': - default: - return sorted.sort((a, b) => { - const na = a.issueNumber ?? Number.MAX_SAFE_INTEGER; - const nb = b.issueNumber ?? Number.MAX_SAFE_INTEGER; - if (na !== nb) return na - nb; - return a.publication.localeCompare(b.publication); - }); - } -} +import { + buildEditionOptions, + filterAndSortMagazines, + PAGE_SIZE, + type SortOption, +} from '@/lib/collection-list'; export default function CollectionScreen() { const router = useRouter(); @@ -78,7 +32,6 @@ export default function CollectionScreen() { const [editionFilter, setEditionFilter] = useState(null); const [sort, setSort] = useState('Numéro ↑'); const [page, setPage] = useState(1); - const [filtersOpen, setFiltersOpen] = useState(false); const [lastParamEdition, setLastParamEdition] = useState(undefined); const paramEdition = typeof params.edition === 'string' ? params.edition : undefined; @@ -87,7 +40,6 @@ export default function CollectionScreen() { if (paramEdition) { setEditionFilter(paramEdition); setPage(1); - setFiltersOpen(true); } } @@ -97,33 +49,12 @@ export default function CollectionScreen() { }, [load]), ); - const editions = useMemo( - () => - [ - ...new Set(magazines.map((m) => (m.edition?.trim() ? m.edition.trim() : 'Sans édition'))), - ].sort((a, b) => (a === 'Sans édition' ? 1 : b === 'Sans édition' ? -1 : a.localeCompare(b))), - [magazines], - ); + const editions = useMemo(() => buildEditionOptions(magazines), [magazines]); - const filtered = useMemo(() => { - const issue = issueQuery.trim(); - const list = magazines.filter((m) => { - if (editionFilter) { - const edition = m.edition?.trim() ? m.edition.trim() : 'Sans édition'; - if (edition !== editionFilter) { - return false; - } - } - if (issue) { - const parsed = Number(issue); - if (Number.isNaN(parsed) || m.issueNumber !== parsed) { - return false; - } - } - return true; - }); - return sortMagazines(list, sort); - }, [magazines, issueQuery, editionFilter, sort]); + const filtered = useMemo( + () => filterAndSortMagazines(magazines, editionFilter, issueQuery, sort), + [magazines, editionFilter, issueQuery, sort], + ); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); @@ -165,75 +96,16 @@ export default function CollectionScreen() { - - [styles.searchButton, pressed && styles.buttonPressed]} - onPress={() => setFiltersOpen((o) => !o)} - testID="filter-toggle" - accessibilityRole="button" - accessibilityLabel="Rechercher" - accessibilityState={{ expanded: filtersOpen }}> - - Rechercher - - - - {filtersOpen ? ( - - - - Numéro - - - - - - - - {hasFilters ? ( - [styles.clearButton, pressed && styles.buttonPressed]} - onPress={clearFilters} - testID="filter-clear" - accessibilityRole="button" - accessibilityLabel="Effacer les filtres" - android_ripple={{ color: 'rgba(0,0,0,0.08)' }}> - - Effacer les filtres - - ) : null} - - ) : null} - + {loading && magazines.length === 0 ? ( @@ -252,81 +124,75 @@ export default function CollectionScreen() { actionTestID="collection-empty-scan" /> ) : ( - <> - - {filtered.length} résultat{filtered.length > 1 ? 's' : ''} - - item.id} - contentContainerStyle={styles.list} - keyboardShouldPersistTaps="handled" - testID="collection-list" - refreshControl={ - - } - renderItem={({ item }) => ( - router.push(`/collection/${item.id}`)} - /> - )} - /> - {totalPages > 1 ? ( - - [styles.pageButton, pressed && styles.buttonPressed]} - onPress={() => goToPage(safePage - 1)} - disabled={safePage <= 1} - accessibilityRole="button" - accessibilityLabel="Page précédente" - accessibilityState={{ disabled: safePage <= 1 }} - testID="pagination-prev"> - - - {pageWindow(safePage, totalPages).map((p) => ( - [ - styles.pageButton, - p === safePage && styles.pageButtonActive, - pressed && styles.buttonPressed, - ]} - onPress={() => goToPage(p)} - disabled={p === safePage} - accessibilityRole="button" - accessibilityLabel={`Page ${p}`} - accessibilityState={{ selected: p === safePage }} - testID={`pagination-page-${p}`}> - - {p} - - - ))} - [styles.pageButton, pressed && styles.buttonPressed]} - onPress={() => goToPage(safePage + 1)} - disabled={safePage >= totalPages} - accessibilityRole="button" - accessibilityLabel="Page suivante" - accessibilityState={{ disabled: safePage >= totalPages }} - testID="pagination-next"> - - - - ) : null} - + router.push(`/collection/${item.id}`)} + tintColor={colors.navActive} + listStyle={styles.list} + /> )} + goToPage(safePage - 1)} + onNext={() => goToPage(safePage + 1)} + onPage={goToPage} + /> ); } +function CollectionList({ + items, + resultCount, + loading, + onRefresh, + onPress, + tintColor, + listStyle, +}: { + items: MagazineListItem[]; + resultCount: number; + loading: boolean; + onRefresh: () => void; + onPress: (item: MagazineListItem) => void; + tintColor: string; + listStyle: object; +}) { + const colors = useThemeColors(); + return ( + <> + + {resultCount} résultat{resultCount > 1 ? 's' : ''} + + item.id} + contentContainerStyle={listStyle} + keyboardShouldPersistTaps="handled" + testID="collection-list" + refreshControl={ + + } + renderItem={({ item }) => onPress(item)} />} + /> + + ); +} + +function makeResultCountStyles(colors: ThemeColors) { + return StyleSheet.create({ + resultCount: { + fontSize: 13, + color: colors.textSecondary, + }, + }); +} + function makeStyles(colors: ThemeColors) { return StyleSheet.create({ screen: { @@ -335,102 +201,8 @@ function makeStyles(colors: ThemeColors) { padding: Spacing.four, gap: Spacing.three, }, - filters: { - gap: Spacing.two, - }, - searchButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.two, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - backgroundColor: colors.accent, - }, - searchButtonText: { - fontSize: 17, - fontWeight: '700', - color: colors.accentText, - }, - filtersPanel: { - gap: Spacing.two, - }, - filtersRow: { - flexDirection: 'row', - gap: Spacing.two, - alignItems: 'flex-start', - }, - filtersColumn: { - flex: 1, - }, - filterLabel: { - fontSize: 14, - fontWeight: '600', - color: colors.text, - marginTop: Spacing.two, - }, - input: { - backgroundColor: colors.backgroundElement, - borderRadius: 8, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - minHeight: HitTarget.minHeight, - fontSize: 16, - color: colors.text, - }, - clearButton: { - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'flex-start', - gap: 6, - minHeight: HitTarget.minHeight, - paddingHorizontal: Spacing.two, - }, - clearButtonText: { - fontSize: 14, - fontWeight: '600', - color: colors.textSecondary, - }, - resultCount: { - fontSize: 13, - color: colors.textSecondary, - }, list: { gap: Spacing.two, }, - pagination: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.two, - flexWrap: 'wrap', - paddingTop: Spacing.two, - }, - pageButton: { - minWidth: HitTarget.minHeight, - minHeight: HitTarget.minHeight, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: Spacing.two, - borderRadius: 8, - backgroundColor: colors.backgroundElement, - }, - pageButtonActive: { - backgroundColor: colors.accent, - }, - pageButtonText: { - fontSize: 16, - color: colors.text, - }, - pageButtonTextActive: { - fontSize: 16, - fontWeight: '700', - color: colors.accentText, - }, - buttonPressed: { - opacity: 0.7, - }, }); } diff --git a/src/app/scan/search.tsx b/src/app/scan/search.tsx index a97cc1e..46b5a64 100644 --- a/src/app/scan/search.tsx +++ b/src/app/scan/search.tsx @@ -5,7 +5,6 @@ import { KeyboardAvoidingView, Platform, Pressable, - StyleSheet, Text, TextInput, View, @@ -14,7 +13,13 @@ import { Feather } from '@expo/vector-icons'; import { AutocompleteInput } from '@/components/autocomplete-input'; import { Screen } from '@/components/screen'; -import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { HitTarget } from '@/constants/theme'; +import { + SearchFoundResult, + SearchUnknownResult, + makeSearchResultStyles, + makeSearchScreenStyles, +} from '@/components/scan-search-views'; import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; import { toast } from '@/lib/toast'; @@ -26,7 +31,7 @@ const EMPTY_ISSUE = ''; export default function ScanSearchScreen() { const router = useRouter(); const colors = useThemeColors(); - const styles = makeStyles(colors); + const screenStyles = makeSearchScreenStyles(colors); const magazines = useCollectionStore((s) => s.magazines); const loadDetail = useCollectionStore((s) => s.loadDetail); const detail = useCollectionStore((s) => s.detail); @@ -126,12 +131,12 @@ export default function ScanSearchScreen() { return ( - + [styles.backButton, pressed && styles.pressed]} + style={({ pressed }) => [screenStyles.backButton, pressed && screenStyles.pressed]} onPress={handleCancel} testID="search-cancel" accessibilityRole="button" @@ -139,276 +144,110 @@ export default function ScanSearchScreen() { hitSlop={HitTarget.hitSlop}> - Rechercher un magazine + Rechercher un magazine {!result ? ( - - - - Numéro - - - [styles.primaryButton, !canSearch && styles.disabled]} - onPress={search} - disabled={!canSearch} - testID="search-submit" - accessibilityRole="button" - accessibilityState={{ disabled: !canSearch }}> - - {searching ? 'Recherche…' : 'Rechercher'} - - - {!canSearch && !searching ? ( - - Renseignez la publication et le numéro pour rechercher. - - ) : null} - + ) : result.status === 'found' ? ( - - Déjà dans votre collection - - - {result.publication} - - {result.issueNumber != null ? ( - N° {result.issueNumber} - ) : null} - {resolved ? ( - - {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} - - ) : ( - - Vérification… - - )} - - - [styles.primaryButton, pressed && styles.pressed]} - onPress={handleAddCopy} - testID="search-add" - accessibilityRole="button" - accessibilityLabel={owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'}> - - {owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'} - - - - [styles.secondaryButton, pressed && styles.pressed]} - onPress={() => router.replace(`/collection/${foundId}`)} - testID="search-view" - accessibilityRole="button" - accessibilityLabel="Voir la fiche du magazine"> - Voir la fiche - - - [styles.secondaryButton, pressed && styles.pressed]} - onPress={() => router.replace('/scan/barcode')} - testID="search-rescan" - accessibilityRole="button" - accessibilityLabel="Scanner à nouveau"> - Scanner à nouveau - - + router.replace(`/collection/${foundId}`)} + onRescan={() => router.replace('/scan/barcode')} + /> ) : ( - - Non référencé - - - « {result.publication} » n'est pas encore dans votre collection. - - {result.issueNumber != null ? ( - N° {result.issueNumber} - ) : null} - - - [styles.primaryButton, pressed && styles.pressed]} - onPress={handleManual} - testID="search-manual" - accessibilityRole="button" - accessibilityLabel="Saisir manuellement"> - Saisir manuellement - - - [styles.secondaryButton, pressed && styles.pressed]} - onPress={resetForm} - testID="search-again" - accessibilityRole="button" - accessibilityLabel="Rechercher un autre magazine"> - Rechercher un autre magazine - - + )} ); } -function makeStyles(colors: ThemeColors) { - return StyleSheet.create({ - screen: { - flex: 1, - padding: Spacing.four, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - marginBottom: Spacing.three, - }, - backButton: { - minWidth: HitTarget.minHeight, - minHeight: HitTarget.minHeight, - alignItems: 'center', - justifyContent: 'center', - }, - pressed: { - opacity: 0.7, - }, - title: { - flex: 1, - fontSize: 24, - lineHeight: 32, - fontWeight: '700', - color: colors.text, - }, - form: { - flex: 1, - gap: Spacing.three, - }, - label: { - fontSize: 14, - fontWeight: '600', - color: colors.text, - marginTop: Spacing.two, - }, - input: { - backgroundColor: colors.backgroundElement, - borderRadius: 8, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - minHeight: 44, - fontSize: 16, - color: colors.text, - }, - primaryButton: { - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - }, - disabled: { - opacity: 0.5, - }, - primaryButtonText: { - fontSize: 18, - fontWeight: '700', - color: colors.accentText, - }, - secondaryButton: { - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.backgroundElement, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - }, - secondaryButtonText: { - fontSize: 17, - fontWeight: '600', - color: colors.text, - }, - hint: { - fontSize: 13, - color: colors.textSecondary, - textAlign: 'center', - }, - resultTitle: { - fontSize: 22, - fontWeight: '700', - color: colors.text, - textAlign: 'center', - }, - card: { - backgroundColor: colors.backgroundElement, - borderRadius: 12, - padding: Spacing.four, - alignItems: 'center', - gap: Spacing.two, - }, - magazine: { - fontSize: 20, - fontWeight: '600', - color: colors.text, - textAlign: 'center', - }, - issue: { - fontSize: 16, - color: colors.textSecondary, - textAlign: 'center', - }, - message: { - fontSize: 16, - color: colors.text, - textAlign: 'center', - lineHeight: 22, - }, - muted: { - fontSize: 14, - color: colors.textSecondary, - textAlign: 'center', - }, - ownedText: { - fontSize: 14, - fontWeight: '700', - color: colors.ownedText, - backgroundColor: colors.ownedBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, - absentText: { - fontSize: 14, - fontWeight: '700', - color: colors.absentText, - backgroundColor: colors.absentBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, - }); +function SearchForm({ + publication, + publications, + issueNumber, + canSearch, + searching, + onPublicationChange, + onIssueChange, + onSearch, +}: { + publication: string; + publications: string[]; + issueNumber: string; + canSearch: boolean; + searching: boolean; + onPublicationChange: (v: string) => void; + onIssueChange: (v: string) => void; + onSearch: () => void; +}) { + const colors = useThemeColors(); + const styles = makeSearchResultStyles(colors); + const screenStyles = makeSearchScreenStyles(colors); + + return ( + + + + Numéro + + + [ + styles.primaryButton, + !canSearch && screenStyles.disabled, + pressed && screenStyles.pressed, + ]} + onPress={onSearch} + disabled={!canSearch} + testID="search-submit" + accessibilityRole="button" + accessibilityState={{ disabled: !canSearch }}> + {searching ? 'Recherche…' : 'Rechercher'} + + {!canSearch && !searching ? ( + + Renseignez la publication et le numéro pour rechercher. + + ) : null} + + ); } diff --git a/src/backup/backup-csv.ts b/src/backup/backup-csv.ts new file mode 100644 index 0000000..a5f0d7a --- /dev/null +++ b/src/backup/backup-csv.ts @@ -0,0 +1,199 @@ +import { generateId } from '@/utils/id'; +import { InvalidBackupError } from './backup-parsers'; +import { + BACKUP_FORMAT, + BACKUP_VERSION, + type BackupCopy, + type BackupFile, + type BackupMagazine, +} from './backup-types'; +import { APP_VERSION } from '@/utils/app-version'; + +export const BACKUP_CSV_HEADERS = [ + 'publication', + 'issueNumber', + 'edition', + 'language', + 'condition', + 'publicationDate', + 'barcode', + 'notes', + 'ocrText', + 'copyNotes', + 'dateAdded', +] as const; + +export function escapeCsvField(value: string): string { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function parseCsv(raw: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let inQuotes = false; + let index = 0; + const text = raw.replace(/^\uFEFF/, ''); + while (index < text.length) { + const char = text[index]; + if (inQuotes) { + if (char === '"') { + if (text[index + 1] === '"') { + field += '"'; + index += 2; + continue; + } + inQuotes = false; + index += 1; + continue; + } + field += char; + index += 1; + continue; + } + if (char === '"') { + inQuotes = true; + index += 1; + continue; + } + if (char === ',') { + row.push(field); + field = ''; + index += 1; + continue; + } + if (char === '\n') { + row.push(field); + rows.push(row); + row = []; + field = ''; + index += 1; + continue; + } + if (char === '\r') { + index += 1; + continue; + } + field += char; + index += 1; + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows.filter((r) => !(r.length === 1 && r[0].trim() === '')); +} + +function asCsvString(value: string | undefined): string | null { + const trimmed = (value ?? '').trim(); + return trimmed.length > 0 ? (value ?? '') : null; +} + +export function parseCsvBackup(raw: string): BackupFile { + const rows = parseCsv(raw); + if (rows.length === 0) { + throw new InvalidBackupError('Fichier invalide : le fichier CSV est vide.'); + } + + const headers = rows[0].map((header) => header.trim()); + const missing = BACKUP_CSV_HEADERS.filter((expected) => !headers.includes(expected)); + if (missing.length > 0) { + throw new InvalidBackupError( + `Fichier invalide : fichiers d’import CSV attendus — en-têtes manquants : ${missing.join(', ')}.`, + ); + } + + const columnIndex = (name: string): number => headers.indexOf(name); + + const magazines: BackupMagazine[] = []; + const byKey = new Map(); + + for (const values of rows.slice(1)) { + const at = (name: string): string => values[columnIndex(name)] ?? ''; + const publication = at('publication').trim(); + if (publication.length === 0) { + throw new InvalidBackupError( + 'Fichier invalide : une ligne du CSV ne contient pas de publication.', + ); + } + + const issueRaw = at('issueNumber').trim(); + let issueNumber: number | null = null; + if (issueRaw.length > 0) { + if (!/^\d{1,6}$/.test(issueRaw)) { + throw new InvalidBackupError( + 'Fichier invalide : le numéro d’édition doit être un entier positif.', + ); + } + issueNumber = Number(issueRaw); + } + + const key = `${publication}::${issueNumber ?? ''}`; + let magazine = byKey.get(key); + if (!magazine) { + magazine = { + id: generateId(), + publication, + issueNumber, + edition: asCsvString(at('edition')), + language: asCsvString(at('language')), + condition: asCsvString(at('condition')), + publicationDate: asCsvString(at('publicationDate')), + barcode: asCsvString(at('barcode')), + notes: asCsvString(at('notes')), + ocrText: asCsvString(at('ocrText')), + copies: [], + }; + byKey.set(key, magazine); + magazines.push(magazine); + } + + const copyNotes = at('copyNotes').trim(); + const dateAdded = at('dateAdded').trim(); + const copy: BackupCopy = { + id: generateId(), + notes: copyNotes.length > 0 ? copyNotes : null, + dateAdded: dateAdded.length > 0 ? dateAdded : new Date().toISOString(), + }; + magazine.copies.push(copy); + } + + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: new Date().toISOString(), + appVersion: APP_VERSION, + magazines, + }; +} + +export function toCsv(file: BackupFile): string { + const header = BACKUP_CSV_HEADERS.join(','); + const lines: string[] = []; + for (const magazine of file.magazines) { + const base = [ + escapeCsvField(magazine.publication), + magazine.issueNumber === null ? '' : escapeCsvField(String(magazine.issueNumber)), + escapeCsvField(magazine.edition ?? ''), + escapeCsvField(magazine.language ?? ''), + escapeCsvField(magazine.condition ?? ''), + escapeCsvField(magazine.publicationDate ?? ''), + escapeCsvField(magazine.barcode ?? ''), + escapeCsvField(magazine.notes ?? ''), + escapeCsvField(magazine.ocrText ?? ''), + ]; + const copies = + magazine.copies.length > 0 + ? magazine.copies + : [{ id: magazine.id, notes: null, dateAdded: '' }]; + for (const copy of copies) { + lines.push( + [...base, escapeCsvField(copy.notes ?? ''), escapeCsvField(copy.dateAdded)].join(','), + ); + } + } + return [header, ...lines].join('\n') + '\n'; +} diff --git a/src/backup/backup-parsers.ts b/src/backup/backup-parsers.ts new file mode 100644 index 0000000..e6a2d6a --- /dev/null +++ b/src/backup/backup-parsers.ts @@ -0,0 +1,127 @@ +import { APP_VERSION } from '@/utils/app-version'; +import { + BACKUP_FORMAT, + BACKUP_VERSION, + type BackupFile, + type BackupMagazine, +} from './backup-types'; + +export class InvalidBackupError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidBackupError'; + } +} + +function asNullableString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string') { + throw new InvalidBackupError('Fichier invalide : certaines valeurs de texte sont mal formées.'); + } + return value; +} + +function asNullableNumber(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new InvalidBackupError( + 'Fichier invalide : certaines valeurs numériques sont mal formées.', + ); + } + return value; +} + +function asNonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new InvalidBackupError(`Fichier invalide : le champ « ${field} » est manquant ou vide.`); + } + return value; +} + +export function parseBackupFile(raw: unknown): BackupFile { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new InvalidBackupError( + 'Fichier invalide : la structure racine doit être un objet de sauvegarde.', + ); + } + + const root = raw as Record; + + if (root['format'] !== BACKUP_FORMAT) { + throw new InvalidBackupError('Fichier invalide : ce n’est pas un export de PicsouCollection.'); + } + + if (root['version'] !== BACKUP_VERSION) { + throw new InvalidBackupError( + `Fichier invalide : version non prise en charge (attendu : ${BACKUP_VERSION}).`, + ); + } + + if (!Array.isArray(root['magazines'])) { + throw new InvalidBackupError('Fichier invalide : la liste des éditions est absente.'); + } + + const magazines: BackupMagazine[] = []; + for (const entry of root['magazines']) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new InvalidBackupError('Fichier invalide : une édition est mal formée.'); + } + + const magazine = entry as Record; + + const id = asNonEmptyString(magazine['id'], 'identifiant d’édition'); + + if (!Array.isArray(magazine['copies'])) { + throw new InvalidBackupError('Fichier invalide : la liste des exemplaires est absente.'); + } + + const copies = []; + for (const copyEntry of magazine['copies']) { + if (typeof copyEntry !== 'object' || copyEntry === null || Array.isArray(copyEntry)) { + throw new InvalidBackupError('Fichier invalide : un exemplaire est mal formé.'); + } + const copy = copyEntry as Record; + copies.push({ + id: asNonEmptyString(copy['id'], 'identifiant d’exemplaire'), + notes: asNullableString(copy['notes']), + dateAdded: asNullableString(copy['dateAdded']) ?? '', + }); + } + + magazines.push({ + id, + publication: asNonEmptyString(magazine['publication'], 'publication'), + issueNumber: asNullableNumber(magazine['issueNumber']), + edition: asNullableString(magazine['edition']), + language: asNullableString(magazine['language']), + condition: asNullableString(magazine['condition']), + publicationDate: asNullableString(magazine['publicationDate']), + barcode: asNullableString(magazine['barcode']), + notes: asNullableString(magazine['notes']), + ocrText: asNullableString(magazine['ocrText']), + copies, + }); + } + + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: asNullableString(root['exportedAt']) ?? new Date().toISOString(), + appVersion: asNullableString(root['appVersion']) ?? APP_VERSION, + magazines, + }; +} + +export function parseJsonBackup(raw: string): BackupFile { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new InvalidBackupError('Fichier invalide : le contenu n’est pas un JSON lisible.'); + } + return parseBackupFile(parsed); +} diff --git a/src/backup/backup-service.ts b/src/backup/backup-service.ts index 657b5d2..3782a1f 100644 --- a/src/backup/backup-service.ts +++ b/src/backup/backup-service.ts @@ -1,26 +1,18 @@ import type { Database } from '@/database/types'; import { APP_VERSION } from '@/utils/app-version'; -import { generateId } from '@/utils/id'; +import { parseJsonBackup } from './backup-parsers'; +import { parseCsvBackup, toCsv as toCsvFn } from './backup-csv'; import { BACKUP_FORMAT, BACKUP_VERSION, - type BackupCopy, type BackupFile, type BackupFormat, type BackupMagazine, type ImportSummary, } from './backup-types'; -/** - * Erreur de validation d'un fichier d'import (US-BK-03). - * L'import est rejeté sans aucune modification des données. - */ -export class InvalidBackupError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidBackupError'; - } -} +export { InvalidBackupError } from './backup-parsers'; +export { BACKUP_CSV_HEADERS, escapeCsvField, parseCsv, toCsv } from './backup-csv'; type MagazineRow = { id: string; @@ -44,283 +36,6 @@ type CopyRow = { date_added: string; }; -function asNullableString(value: unknown): string | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'string') { - throw new InvalidBackupError('Fichier invalide : certaines valeurs de texte sont mal formées.'); - } - return value; -} - -function asNullableNumber(value: unknown): number | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new InvalidBackupError( - 'Fichier invalide : certaines valeurs numériques sont mal formées.', - ); - } - return value; -} - -function asNonEmptyString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new InvalidBackupError(`Fichier invalide : le champ « ${field} » est manquant ou vide.`); - } - return value; -} - -function parseBackupFile(raw: unknown): BackupFile { - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - throw new InvalidBackupError( - 'Fichier invalide : la structure racine doit être un objet de sauvegarde.', - ); - } - - const root = raw as Record; - - if (root['format'] !== BACKUP_FORMAT) { - throw new InvalidBackupError('Fichier invalide : ce n’est pas un export de PicsouCollection.'); - } - - if (root['version'] !== BACKUP_VERSION) { - throw new InvalidBackupError( - `Fichier invalide : version non prise en charge (attendu : ${BACKUP_VERSION}).`, - ); - } - - if (!Array.isArray(root['magazines'])) { - throw new InvalidBackupError('Fichier invalide : la liste des éditions est absente.'); - } - - const magazines: BackupMagazine[] = []; - for (const entry of root['magazines']) { - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new InvalidBackupError('Fichier invalide : une édition est mal formée.'); - } - - const magazine = entry as Record; - - const id = asNonEmptyString(magazine['id'], 'identifiant d’édition'); - - if (!Array.isArray(magazine['copies'])) { - throw new InvalidBackupError('Fichier invalide : la liste des exemplaires est absente.'); - } - - const copies: BackupCopy[] = []; - for (const copyEntry of magazine['copies']) { - if (typeof copyEntry !== 'object' || copyEntry === null || Array.isArray(copyEntry)) { - throw new InvalidBackupError('Fichier invalide : un exemplaire est mal formé.'); - } - const copy = copyEntry as Record; - copies.push({ - id: asNonEmptyString(copy['id'], 'identifiant d’exemplaire'), - notes: asNullableString(copy['notes']), - dateAdded: asNullableString(copy['dateAdded']) ?? '', - }); - } - - magazines.push({ - id, - publication: asNonEmptyString(magazine['publication'], 'publication'), - issueNumber: asNullableNumber(magazine['issueNumber']), - edition: asNullableString(magazine['edition']), - language: asNullableString(magazine['language']), - condition: asNullableString(magazine['condition']), - publicationDate: asNullableString(magazine['publicationDate']), - barcode: asNullableString(magazine['barcode']), - notes: asNullableString(magazine['notes']), - ocrText: asNullableString(magazine['ocrText']), - copies, - }); - } - - return { - format: BACKUP_FORMAT, - version: BACKUP_VERSION, - exportedAt: asNullableString(root['exportedAt']) ?? new Date().toISOString(), - appVersion: asNullableString(root['appVersion']) ?? APP_VERSION, - magazines, - }; -} - -/** - * En-têtes de l'export CSV v1 (cf. `06-DATA-MODEL.md` §6.1). Une ligne = un - * exemplaire ; en-têtes attendus pour valider l'import (US-BK-05). - */ -export const BACKUP_CSV_HEADERS = [ - 'publication', - 'issueNumber', - 'edition', - 'language', - 'condition', - 'publicationDate', - 'barcode', - 'notes', - 'ocrText', - 'copyNotes', - 'dateAdded', -] as const; - -function escapeCsvField(value: string): string { - if (/[",\n\r]/.test(value)) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; -} - -/** - * Parseur CSV minimal : séparateur virgule, guillemets doubles, échappement - * `""`, gestion des retours à la ligne et de la virgule entre guillemets. - */ -function parseCsv(raw: string): string[][] { - const rows: string[][] = []; - let row: string[] = []; - let field = ''; - let inQuotes = false; - let index = 0; - const text = raw.replace(/^\uFEFF/, ''); - while (index < text.length) { - const char = text[index]; - if (inQuotes) { - if (char === '"') { - if (text[index + 1] === '"') { - field += '"'; - index += 2; - continue; - } - inQuotes = false; - index += 1; - continue; - } - field += char; - index += 1; - continue; - } - if (char === '"') { - inQuotes = true; - index += 1; - continue; - } - if (char === ',') { - row.push(field); - field = ''; - index += 1; - continue; - } - if (char === '\n') { - row.push(field); - rows.push(row); - row = []; - field = ''; - index += 1; - continue; - } - if (char === '\r') { - index += 1; - continue; - } - field += char; - index += 1; - } - if (field.length > 0 || row.length > 0) { - row.push(field); - rows.push(row); - } - return rows.filter((r) => !(r.length === 1 && r[0].trim() === '')); -} - -function asCsvString(value: string | undefined): string | null { - const trimmed = (value ?? '').trim(); - return trimmed.length > 0 ? (value ?? '') : null; -} - -/** - * Construit un {@link BackupFile} à partir d'un export CSV. L'import est validé - * sur la présence des en-têtes attendus (§13.2) et rejette tout fichier - * non conforme via {@link InvalidBackupError}, sans modifier les données. - */ -function parseCsvBackup(raw: string): BackupFile { - const rows = parseCsv(raw); - if (rows.length === 0) { - throw new InvalidBackupError('Fichier invalide : le fichier CSV est vide.'); - } - - const headers = rows[0].map((header) => header.trim()); - const missing = BACKUP_CSV_HEADERS.filter((expected) => !headers.includes(expected)); - if (missing.length > 0) { - throw new InvalidBackupError( - `Fichier invalide : fichiers d’import CSV attendus — en-têtes manquants : ${missing.join(', ')}.`, - ); - } - - const columnIndex = (name: string): number => headers.indexOf(name); - - const magazines: BackupMagazine[] = []; - const byKey = new Map(); - - for (const values of rows.slice(1)) { - const at = (name: string): string => values[columnIndex(name)] ?? ''; - const publication = at('publication').trim(); - if (publication.length === 0) { - throw new InvalidBackupError( - 'Fichier invalide : une ligne du CSV ne contient pas de publication.', - ); - } - - const issueRaw = at('issueNumber').trim(); - let issueNumber: number | null = null; - if (issueRaw.length > 0) { - if (!/^\d{1,6}$/.test(issueRaw)) { - throw new InvalidBackupError( - 'Fichier invalide : le numéro d’édition doit être un entier positif.', - ); - } - issueNumber = Number(issueRaw); - } - - const key = `${publication}::${issueNumber ?? ''}`; - let magazine = byKey.get(key); - if (!magazine) { - magazine = { - id: generateId(), - publication, - issueNumber, - edition: asCsvString(at('edition')), - language: asCsvString(at('language')), - condition: asCsvString(at('condition')), - publicationDate: asCsvString(at('publicationDate')), - barcode: asCsvString(at('barcode')), - notes: asCsvString(at('notes')), - ocrText: asCsvString(at('ocrText')), - copies: [], - }; - byKey.set(key, magazine); - magazines.push(magazine); - } - - const copyNotes = at('copyNotes').trim(); - const dateAdded = at('dateAdded').trim(); - const copy: BackupCopy = { - id: generateId(), - notes: copyNotes.length > 0 ? copyNotes : null, - dateAdded: dateAdded.length > 0 ? dateAdded : new Date().toISOString(), - }; - magazine.copies.push(copy); - } - - return { - format: BACKUP_FORMAT, - version: BACKUP_VERSION, - exportedAt: new Date().toISOString(), - appVersion: APP_VERSION, - magazines, - }; -} - export class BackupService { constructor(private readonly db: Database) {} @@ -338,7 +53,10 @@ export class BackupService { ORDER BY date_added, id`, ); - const copiesByMagazine = new Map(); + const copiesByMagazine = new Map< + string, + { id: string; notes: string | null; dateAdded: string }[] + >(); for (const row of copyRows) { const copies = copiesByMagazine.get(row.magazine_id) ?? []; copies.push({ @@ -376,57 +94,17 @@ export class BackupService { return JSON.stringify(file, null, 2); } - /** - * Sérialise la sauvegarde en CSV v1 (une ligne = un exemplaire, cf. §6.1). - * Les éditions sans exemplaire sont conservées via une ligne aux champs - * exemplaire vides. - */ toCsv(file: BackupFile): string { - const header = BACKUP_CSV_HEADERS.join(','); - const lines: string[] = []; - for (const magazine of file.magazines) { - const base = [ - escapeCsvField(magazine.publication), - magazine.issueNumber === null ? '' : escapeCsvField(String(magazine.issueNumber)), - escapeCsvField(magazine.edition ?? ''), - escapeCsvField(magazine.language ?? ''), - escapeCsvField(magazine.condition ?? ''), - escapeCsvField(magazine.publicationDate ?? ''), - escapeCsvField(magazine.barcode ?? ''), - escapeCsvField(magazine.notes ?? ''), - escapeCsvField(magazine.ocrText ?? ''), - ]; - const copies = - magazine.copies.length > 0 - ? magazine.copies - : [{ id: magazine.id, notes: null, dateAdded: '' }]; - for (const copy of copies) { - lines.push( - [...base, escapeCsvField(copy.notes ?? ''), escapeCsvField(copy.dateAdded)].join(','), - ); - } - } - return [header, ...lines].join('\n') + '\n'; + return toCsvFn(file); } private parseBackupRaw(raw: string, format: BackupFormat): BackupFile { if (format === 'csv') { return parseCsvBackup(raw); } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new InvalidBackupError('Fichier invalide : le contenu n’est pas un JSON lisible.'); - } - return parseBackupFile(parsed); + return parseJsonBackup(raw); } - /** - * Valide uniquement le contenu d'un fichier, sans modifier les données. - * Utilisé pour afficher un récapitulatif et demander confirmation **avant** - * le remplacement (§14.4). Rejette via {@link InvalidBackupError} (US-BK-03). - */ async validateCollection(raw: string, format: BackupFormat = 'json'): Promise { const file = this.parseBackupRaw(raw, format); return { @@ -435,11 +113,6 @@ export class BackupService { }; } - /** - * Importe une sauvegarde (JSON ou CSV) en remplaçant la collection existante. - * Toute erreur de validation ou d'écriture → transaction annulée, données - * intactes (stratégie « remplacement complet », cf. fonctionnelle §13.3). - */ async importCollection(raw: string, format: BackupFormat = 'json'): Promise { const file = this.parseBackupRaw(raw, format); diff --git a/src/components/__tests__/use-magazine-form.test.ts b/src/components/__tests__/use-magazine-form.test.ts new file mode 100644 index 0000000..9f2d6e1 --- /dev/null +++ b/src/components/__tests__/use-magazine-form.test.ts @@ -0,0 +1,106 @@ +import { + buildMagazineInput, + initialFormValues, + publicationDateFrom, + type FormValues, +} from '@/components/use-magazine-form'; + +describe('publicationDateFrom', () => { + it('renvoie la date ISO YYYY-MM quand mois et année sont fournis', () => { + expect(publicationDateFrom('03', '2023')).toBe('2023-03'); + }); + + it('renvoie null si le mois ou l’année manquent', () => { + expect(publicationDateFrom(null, '2023')).toBeNull(); + expect(publicationDateFrom('03', null)).toBeNull(); + expect(publicationDateFrom(null, null)).toBeNull(); + }); +}); + +describe('buildMagazineInput', () => { + it('convertit les valeurs brutes en entrée métier avec trim', () => { + const values: FormValues = { + publication: ' Picsou ', + issueNumber: '547', + edition: ' standard ', + language: ' FR ', + condition: '', + month: '03', + year: '2023', + barcode: '', + notes: ' n° spécial ', + }; + expect(buildMagazineInput(values)).toEqual({ + publication: 'Picsou', + issueNumber: 547, + edition: 'standard', + language: 'FR', + condition: null, + publicationDate: '2023-03', + barcode: null, + notes: 'n° spécial', + }); + }); + + it('convertit les champs vides en null', () => { + const values: FormValues = { + publication: 'Mickey', + issueNumber: '', + edition: '', + language: '', + condition: ' ', + month: null, + year: null, + barcode: ' ', + notes: '', + }; + expect(buildMagazineInput(values)).toEqual({ + publication: 'Mickey', + issueNumber: null, + edition: null, + language: null, + condition: null, + publicationDate: null, + barcode: null, + notes: null, + }); + }); +}); + +describe('initialFormValues', () => { + it('préremplit depuis une fiche édition existante', () => { + const values = initialFormValues({ + initial: { + id: 'm1', + publication: 'Picsou', + issueNumber: 547, + edition: 'standard', + language: 'FR', + condition: null, + publicationDate: '2023-03', + barcode: '3271234567890', + notes: 'nc', + ocrText: null, + createdAt: '', + updatedAt: '', + }, + }); + expect(values.publication).toBe('Picsou'); + expect(values.issueNumber).toBe('547'); + expect(values.month).toBe('03'); + expect(values.year).toBe('2023'); + expect(values.barcode).toBe('3271234567890'); + }); + + it('applique les valeurs initiales explicites en priorité', () => { + const values = initialFormValues({ + initialBarcode: '5901234123457', + initialPublication: 'Vacances', + initialIssueNumber: 12, + initialYear: '2020', + }); + expect(values.publication).toBe('Vacances'); + expect(values.issueNumber).toBe('12'); + expect(values.year).toBe('2020'); + }); +}); diff --git a/src/components/collection-filters.tsx b/src/components/collection-filters.tsx new file mode 100644 index 0000000..9ab45ec --- /dev/null +++ b/src/components/collection-filters.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { SelectField } from '@/components/select-field'; +import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; +import { SORT_OPTIONS, type SortOption } from '@/lib/collection-list'; + +export function makeCollectionFilterStyles(colors: ThemeColors) { + return StyleSheet.create({ + filters: { + gap: Spacing.two, + }, + searchButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.two, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + backgroundColor: colors.accent, + }, + searchButtonText: { + fontSize: 17, + fontWeight: '700', + color: colors.accentText, + }, + filtersPanel: { + gap: Spacing.two, + }, + filtersRow: { + flexDirection: 'row', + gap: Spacing.two, + alignItems: 'flex-start', + }, + filtersColumn: { + flex: 1, + }, + filterLabel: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginTop: Spacing.two, + }, + input: { + backgroundColor: colors.backgroundElement, + borderRadius: 8, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + minHeight: HitTarget.minHeight, + fontSize: 16, + color: colors.text, + }, + clearButton: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 6, + minHeight: HitTarget.minHeight, + paddingHorizontal: Spacing.two, + }, + clearButtonText: { + fontSize: 14, + fontWeight: '600', + color: colors.textSecondary, + }, + buttonPressed: { + opacity: 0.7, + }, + }); +} + +type Props = { + issueQuery: string; + editionFilter: string | null; + sort: SortOption; + editions: string[]; + onIssueChange: (value: string) => void; + onEditionChange: (value: string | null) => void; + onSortChange: (value: string | null) => void; + onClear: () => void; +}; + +export function CollectionFilters({ + issueQuery, + editionFilter, + sort, + editions, + onIssueChange, + onEditionChange, + onSortChange, + onClear, +}: Props) { + const colors = useThemeColors(); + const [open, setOpen] = useState(Boolean(editionFilter)); + const styles = makeCollectionFilterStyles(colors); + const hasFilters = issueQuery.trim().length > 0 || editionFilter !== null; + + return ( + + [styles.searchButton, pressed && styles.buttonPressed]} + onPress={() => setOpen((o) => !o)} + testID="filter-toggle" + accessibilityRole="button" + accessibilityLabel="Rechercher" + accessibilityState={{ expanded: open }}> + + Rechercher + + + + {open ? ( + + + + Numéro + + + + + + + + {hasFilters ? ( + [styles.clearButton, pressed && styles.buttonPressed]} + onPress={onClear} + testID="filter-clear" + accessibilityRole="button" + accessibilityLabel="Effacer les filtres" + android_ripple={{ color: 'rgba(0,0,0,0.08)' }}> + + Effacer les filtres + + ) : null} + + ) : null} + + ); +} diff --git a/src/components/drawer-content.tsx b/src/components/drawer-content.tsx index 3916449..90f83b6 100644 --- a/src/components/drawer-content.tsx +++ b/src/components/drawer-content.tsx @@ -15,8 +15,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; -import { slug } from '@/lib/slug'; import { useSettingsStore } from '@/store/use-settings-store'; +import { Collapsible, DrawerItem, SubItem, makeDrawerItemStyles } from './drawer-items'; const DRAWER_WIDTH = 300; const CLOSE_MS = 250; @@ -31,88 +31,55 @@ export function isLeftEdgeGesture(dx: number, x0: number): boolean { return dx > 10 && x0 < 30; } -type DrawerItemProps = { - icon: React.ComponentProps['name']; - label: string; - route: string; - colors: ThemeColors; - onPress: (route: string) => void; -}; - -function DrawerItem({ icon, label, route, colors, onPress }: DrawerItemProps) { - const styles = makeStyles(colors); - return ( - [styles.drawerItem, pressed && styles.pressed]} - onPress={() => onPress(route)} - accessibilityRole="button" - accessibilityLabel={label} - testID={`drawer-item-${slug(label)}`}> - - {label} - - ); -} - -type CollapsibleProps = { - label: string; - icon: React.ComponentProps['name']; - expanded: boolean; - onToggle: () => void; - children: React.ReactNode; -}; - -function Collapsible({ label, icon, expanded, onToggle, children }: CollapsibleProps) { - const colors = useThemeColors(); - const styles = makeStyles(colors); - return ( - <> - [styles.drawerItem, pressed && styles.pressed]} - onPress={onToggle} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityState={{ expanded }} - testID={`drawer-collapsible-${slug(label)}`}> - - {label} - - - {expanded && {children}} - - ); -} - -function SubItem({ - icon, - label, - route, - colors, - onPress, -}: { - icon: React.ComponentProps['name']; - label: string; - route: string; - colors: ThemeColors; - onPress: (route: string) => void; -}) { - const styles = makeStyles(colors); - return ( - [styles.drawerSubItem, pressed && styles.pressed]} - onPress={() => onPress(route)} - accessibilityRole="button" - accessibilityLabel={label} - testID={`drawer-sub-${slug(label)}`}> - - {label} - - ); +function makeDrawerStyles(colors: ThemeColors) { + return StyleSheet.create({ + overlay: { + flex: 1, + flexDirection: 'row', + }, + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + drawerPanel: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + width: DRAWER_WIDTH, + elevation: 16, + shadowColor: '#000', + shadowOffset: { width: 2, height: 0 }, + shadowOpacity: 0.25, + shadowRadius: 8, + }, + drawer: { + flex: 1, + backgroundColor: colors.background, + }, + drawerHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + padding: Spacing.four, + borderBottomWidth: 1, + borderBottomColor: colors.backgroundElement, + }, + drawerTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + drawerSection: { + paddingVertical: Spacing.two, + borderBottomWidth: 1, + borderBottomColor: colors.backgroundElement, + }, + }); } /** @@ -135,7 +102,8 @@ export function DrawerMenu({ editions?: string[]; }) { const colors = useThemeColors(); - const styles = makeStyles(colors); + const styles = makeDrawerStyles(colors); + const itemStyles = makeDrawerItemStyles(colors); const router = useRouter(); const insets = useSafeAreaInsets(); const reducedMotion = useSettingsStore((s) => s.reducedMotion); @@ -251,23 +219,23 @@ export function DrawerMenu({ [styles.drawerItem, pressed && styles.pressed]} + style={({ pressed }) => [itemStyles.drawerItem, pressed && itemStyles.pressed]} onPress={() => setScanExpanded((e) => !e)} accessibilityRole="button" accessibilityLabel="Scan" accessibilityState={{ expanded: scanExpanded }} testID="drawer-collapsible-scan"> - Scan + Scan {scanExpanded && ( - + ); } - -function makeStyles(colors: ThemeColors) { - return StyleSheet.create({ - overlay: { - flex: 1, - flexDirection: 'row', - }, - backdrop: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', - }, - drawerPanel: { - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - width: DRAWER_WIDTH, - elevation: 16, - shadowColor: '#000', - shadowOffset: { width: 2, height: 0 }, - shadowOpacity: 0.25, - shadowRadius: 8, - }, - drawer: { - flex: 1, - backgroundColor: colors.background, - }, - drawerHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - padding: Spacing.four, - borderBottomWidth: 1, - borderBottomColor: colors.backgroundElement, - }, - drawerTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.text, - }, - drawerSection: { - paddingVertical: Spacing.two, - borderBottomWidth: 1, - borderBottomColor: colors.backgroundElement, - }, - drawerItem: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.three, - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.three, - minHeight: 48, - }, - drawerItemLabel: { - fontSize: 16, - fontWeight: '500', - color: colors.text, - flex: 1, - }, - drawerChevron: { - marginLeft: 'auto', - }, - drawerSubSection: { - paddingLeft: Spacing.five, - }, - drawerSubItem: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.two, - minHeight: 44, - }, - drawerSubItemLabel: { - fontSize: 14, - color: colors.textSecondary, - flex: 1, - }, - pressed: { - opacity: 0.7, - }, - }); -} diff --git a/src/components/drawer-items.tsx b/src/components/drawer-items.tsx new file mode 100644 index 0000000..f539008 --- /dev/null +++ b/src/components/drawer-items.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; +import { slug } from '@/lib/slug'; + +export function makeDrawerItemStyles(colors: ThemeColors) { + return StyleSheet.create({ + drawerItem: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.three, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.three, + minHeight: 48, + }, + drawerItemLabel: { + fontSize: 16, + fontWeight: '500', + color: colors.text, + flex: 1, + }, + drawerChevron: { + marginLeft: 'auto', + }, + drawerSubSection: { + paddingLeft: Spacing.five, + }, + drawerSubItem: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.two, + minHeight: 44, + }, + drawerSubItemLabel: { + fontSize: 14, + color: colors.textSecondary, + flex: 1, + }, + pressed: { + opacity: 0.7, + }, + }); +} + +type DrawerItemProps = { + icon: React.ComponentProps['name']; + label: string; + route: string; + colors: ThemeColors; + onPress: (route: string) => void; +}; + +export function DrawerItem({ icon, label, route, colors, onPress }: DrawerItemProps) { + const styles = makeDrawerItemStyles(colors); + return ( + [styles.drawerItem, pressed && styles.pressed]} + onPress={() => onPress(route)} + accessibilityRole="button" + accessibilityLabel={label} + testID={`drawer-item-${slug(label)}`}> + + {label} + + ); +} + +type CollapsibleProps = { + label: string; + icon: React.ComponentProps['name']; + expanded: boolean; + onToggle: () => void; + children: React.ReactNode; +}; + +export function Collapsible({ label, icon, expanded, onToggle, children }: CollapsibleProps) { + const colors = useThemeColors(); + const styles = makeDrawerItemStyles(colors); + return ( + <> + [styles.drawerItem, pressed && styles.pressed]} + onPress={onToggle} + accessibilityRole="button" + accessibilityLabel={label} + accessibilityState={{ expanded }} + testID={`drawer-collapsible-${slug(label)}`}> + + {label} + + + {expanded && {children}} + + ); +} + +export function SubItem({ + icon, + label, + route, + colors, + onPress, +}: { + icon: React.ComponentProps['name']; + label: string; + route: string; + colors: ThemeColors; + onPress: (route: string) => void; +}) { + const styles = makeDrawerItemStyles(colors); + return ( + [styles.drawerSubItem, pressed && styles.pressed]} + onPress={() => onPress(route)} + accessibilityRole="button" + accessibilityLabel={label} + testID={`drawer-sub-${slug(label)}`}> + + {label} + + ); +} diff --git a/src/components/magazine-form-styles.ts b/src/components/magazine-form-styles.ts new file mode 100644 index 0000000..7506527 --- /dev/null +++ b/src/components/magazine-form-styles.ts @@ -0,0 +1,124 @@ +import { StyleSheet } from 'react-native'; +import { Spacing, type ThemeColors } from '@/constants/theme'; + +export function makeMagazineFormStyles(colors: ThemeColors) { + return StyleSheet.create({ + formScroll: { + flex: 1, + }, + formKeyboard: { + flex: 1, + }, + form: { + gap: Spacing.two, + paddingBottom: Spacing.four, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginTop: Spacing.two, + }, + input: { + backgroundColor: colors.backgroundElement, + borderRadius: 8, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + minHeight: 44, + fontSize: 16, + color: colors.text, + }, + notesInput: { + minHeight: 72, + textAlignVertical: 'top', + }, + formError: { + marginTop: Spacing.two, + fontSize: 14, + fontWeight: '600', + color: colors.danger, + textAlign: 'center', + }, + detailsToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + marginTop: Spacing.three, + alignSelf: 'flex-start', + minHeight: 44, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + backgroundColor: colors.backgroundElement, + }, + detailsToggleText: { + fontSize: 15, + fontWeight: '600', + color: colors.accentTextOnLight, + }, + details: { + gap: Spacing.two, + }, + dateRow: { + flexDirection: 'row', + gap: Spacing.three, + }, + dateCol: { + flex: 1, + }, + barcodeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + }, + barcodeInput: { + flex: 1, + }, + scanButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + justifyContent: 'center', + backgroundColor: colors.backgroundElement, + borderWidth: 1, + borderColor: colors.accent, + borderRadius: 8, + minHeight: 44, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + }, + scanButtonText: { + fontSize: 15, + fontWeight: '600', + color: colors.text, + }, + buttonPressed: { + opacity: 0.8, + }, + submit: { + marginTop: Spacing.three, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + minHeight: 48, + paddingVertical: Spacing.three, + borderRadius: 12, + }, + submitDisabled: { + opacity: 0.5, + }, + submitText: { + fontSize: 18, + fontWeight: '700', + color: colors.accentText, + }, + submitHint: { + fontSize: 13, + color: colors.textSecondary, + textAlign: 'center', + }, + keyboardSpacer: { + height: 200, + }, + }); +} diff --git a/src/components/magazine-form.tsx b/src/components/magazine-form.tsx index 9919c23..968678d 100644 --- a/src/components/magazine-form.tsx +++ b/src/components/magazine-form.tsx @@ -1,11 +1,10 @@ import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useImperativeHandle, useState, type Ref } from 'react'; +import { useCallback, useState, type Ref } from 'react'; import { KeyboardAvoidingView, Platform, Pressable, ScrollView, - StyleSheet, Text, TextInput, View, @@ -14,29 +13,19 @@ import { Feather } from '@expo/vector-icons'; import { AutocompleteInput } from '@/components/autocomplete-input'; import { SelectField } from '@/components/select-field'; -import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; -import { consumePendingBarcode } from '@/lib/pending-barcode'; import { useCollectionStore } from '@/store/use-collection-store'; import type { CreateMagazineInput, Magazine } from '@/types'; +import { makeMagazineFormStyles } from './magazine-form-styles'; +import { useMagazineForm, type FormValues, type MagazineFormHandle } from './use-magazine-form'; -const MONTHS = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'] as const; +export { publicationDateFrom, buildMagazineInput, initialFormValues, MagazineFormHandle } from './use-magazine-form'; + +const MONTHS_OPTIONS = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'] as const; /** Années dynamiques : année courante → -39 ans (M10-08, fini `2025` en dur). */ const YEARS = Array.from({ length: 40 }, (_, i) => String(new Date().getFullYear() - i)); -type FormValues = { - publication: string; - issueNumber: string; - edition: string; - language: string; - condition: string; - month: string | null; - year: string | null; - barcode: string; - notes: string; -}; - type Props = { initial?: Magazine; initialBarcode?: string; @@ -45,24 +34,9 @@ type Props = { initialYear?: string | null; submitLabel: string; onSubmit: (input: CreateMagazineInput) => Promise | void; - /** - * Poignée impérative permettant à l'écran d'hôte de déclencher la soumission - * depuis le header (icône Valider), tout en gardant le bouton pied de formulaire. - */ ref?: Ref; }; -export type MagazineFormHandle = { - submit: () => Promise; -}; - -function publicationDateFrom(month: string | null, year: string | null): string | null { - if (!month || !year) { - return null; - } - return `${year}-${month}`; -} - export function MagazineForm({ initial, initialBarcode, @@ -77,38 +51,18 @@ export function MagazineForm({ const router = useRouter(); const magazines = useCollectionStore((s) => s.magazines); const [detailsOpen, setDetailsOpen] = useState(false); - const [values, setValues] = useState(() => { - const date = initial?.publicationDate ?? ''; - const [year, month] = date.length === 7 ? date.split('-') : ['', '']; - const prefilledYear = initialYear ?? (year || null); - return { - publication: initialPublication ?? initial?.publication ?? '', - issueNumber: - initialIssueNumber != null - ? String(initialIssueNumber) - : initial?.issueNumber != null - ? String(initial.issueNumber) - : '', - edition: initial?.edition ?? '', - language: initial?.language ?? '', - condition: initial?.condition ?? '', - month: month || null, - year: prefilledYear || null, - barcode: initial?.barcode ?? initialBarcode ?? '', - notes: initial?.notes ?? '', - }; - }); - const [submitting, setSubmitting] = useState(false); - const [formError, setFormError] = useState(null); + const { values, set, submitting, formError, canSubmit, handleSubmit, consumeBarcode } = + useMagazineForm({ + initial, + initialBarcode, + initialPublication, + initialIssueNumber, + initialYear, + onSubmit, + ref, + }); - const styles = makeStyles(colors); - - const set = (key: keyof FormValues, value: string | null) => { - setValues((prev) => ({ ...prev, [key]: value })); - if (formError) { - setFormError(null); - } - }; + const styles = makeMagazineFormStyles(colors); const publications = [...new Set(magazines.map((m) => m.publication))]; const editions = [...new Set(magazines.map((m) => m.edition).filter((e): e is string => !!e))]; @@ -120,46 +74,10 @@ export function MagazineForm({ useFocusEffect( useCallback(() => { - const pending = consumePendingBarcode(); - if (pending) { - setValues((prev) => ({ ...prev, barcode: pending })); - } - }, []), + consumeBarcode(); + }, [consumeBarcode]), ); - const canSubmit = values.publication.trim().length > 0 && !submitting; - - const handleSubmit = useCallback(async () => { - if (!canSubmit) { - return; - } - const issueDigits = values.issueNumber.trim(); - if (issueDigits && !/^\d+$/.test(issueDigits)) { - setFormError('Le numéro doit être composé uniquement de chiffres.'); - return; - } - setSubmitting(true); - const input: CreateMagazineInput = { - publication: values.publication.trim(), - issueNumber: issueDigits ? Number(issueDigits) : null, - edition: values.edition.trim() || null, - language: values.language.trim() || null, - condition: values.condition.trim() || null, - publicationDate: publicationDateFrom(values.month, values.year), - barcode: values.barcode.trim() || null, - notes: values.notes.trim() || null, - }; - try { - await onSubmit(input); - } catch (err) { - setFormError(err instanceof Error ? err.message : 'Erreur lors de l’enregistrement.'); - } finally { - setSubmitting(false); - } - }, [canSubmit, onSubmit, values]); - - useImperativeHandle(ref, () => ({ submit: () => handleSubmit() }), [handleSubmit]); - return ( - {detailsOpen && ( - - set('language', v)} - placeholder="Ex : FR" - testID="field-language" - accessibilityLabel="Langue" - /> - - État - set('condition', v)} - placeholder="Ex : Neuf, usé, abîmé…" - placeholderTextColor={colors.textSecondary} - testID="field-condition" - accessibilityLabel="État" - /> - - Date de publication - - - set('month', v)} - testID="select-month" - /> - - - set('year', v)} - testID="select-year" - /> - - - - Code-barres - - set('barcode', v)} - placeholder="Ex : 3271234000011" - keyboardType="default" - autoCapitalize="characters" - placeholderTextColor={colors.textSecondary} - testID="field-barcode" - accessibilityLabel="Code-barres" - /> - [styles.scanButton, pressed && styles.buttonPressed]} - onPress={openBarcodeScanner} - testID="barcode-scan" - accessibilityRole="button" - accessibilityLabel="Scanner le code-barres"> - - Scanner - - - - Notes - set('notes', v)} - placeholder="Informations complémentaires…" - multiline - placeholderTextColor={colors.textSecondary} - testID="field-notes" - accessibilityLabel="Notes" - /> - - )} + {detailsOpen ? ( + + ) : null} {formError ? ( @@ -330,124 +173,104 @@ export function MagazineForm({ ); } -function makeStyles(colors: ThemeColors) { - return StyleSheet.create({ - formScroll: { - flex: 1, - }, - formKeyboard: { - flex: 1, - }, - form: { - gap: Spacing.two, - paddingBottom: Spacing.four, - }, - label: { - fontSize: 14, - fontWeight: '600', - color: colors.text, - marginTop: Spacing.two, - }, - input: { - backgroundColor: colors.backgroundElement, - borderRadius: 8, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - minHeight: 44, - fontSize: 16, - color: colors.text, - }, - notesInput: { - minHeight: 72, - textAlignVertical: 'top', - }, - formError: { - marginTop: Spacing.two, - fontSize: 14, - fontWeight: '600', - color: colors.danger, - textAlign: 'center', - }, - detailsToggle: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - marginTop: Spacing.three, - alignSelf: 'flex-start', - minHeight: 44, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - backgroundColor: colors.backgroundElement, - }, - detailsToggleText: { - fontSize: 15, - fontWeight: '600', - color: colors.accentTextOnLight, - }, - details: { - gap: Spacing.two, - }, - dateRow: { - flexDirection: 'row', - gap: Spacing.three, - }, - dateCol: { - flex: 1, - }, - barcodeRow: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - }, - barcodeInput: { - flex: 1, - }, - scanButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - justifyContent: 'center', - backgroundColor: colors.backgroundElement, - borderWidth: 1, - borderColor: colors.accent, - borderRadius: 8, - minHeight: 44, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - }, - scanButtonText: { - fontSize: 15, - fontWeight: '600', - color: colors.text, - }, - buttonPressed: { - opacity: 0.8, - }, - submit: { - marginTop: Spacing.three, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - minHeight: 48, - paddingVertical: Spacing.three, - borderRadius: 12, - }, - submitDisabled: { - opacity: 0.5, - }, - submitText: { - fontSize: 18, - fontWeight: '700', - color: colors.accentText, - }, - submitHint: { - fontSize: 13, - color: colors.textSecondary, - textAlign: 'center', - }, - keyboardSpacer: { - height: 200, - }, - }); +function DetailsSection({ + values, + set, + editions, + languages, + onScanBarcode, +}: { + values: FormValues; + set: (key: keyof FormValues, value: string | null) => void; + editions: string[]; + languages: string[]; + onScanBarcode: () => void; +}) { + const colors = useThemeColors(); + const styles = makeMagazineFormStyles(colors); + + return ( + + set('language', v)} + placeholder="Ex : FR" + testID="field-language" + accessibilityLabel="Langue" + /> + + État + set('condition', v)} + placeholder="Ex : Neuf, usé, abîmé…" + placeholderTextColor={colors.textSecondary} + testID="field-condition" + accessibilityLabel="État" + /> + + Date de publication + + + set('month', v)} + testID="select-month" + /> + + + set('year', v)} + testID="select-year" + /> + + + + Code-barres + + set('barcode', v)} + placeholder="Ex : 3271234000011" + keyboardType="default" + autoCapitalize="characters" + placeholderTextColor={colors.textSecondary} + testID="field-barcode" + accessibilityLabel="Code-barres" + /> + [styles.scanButton, pressed && styles.buttonPressed]} + onPress={onScanBarcode} + testID="barcode-scan" + accessibilityRole="button" + accessibilityLabel="Scanner le code-barres"> + + Scanner + + + + Notes + set('notes', v)} + placeholder="Informations complémentaires…" + multiline + placeholderTextColor={colors.textSecondary} + testID="field-notes" + accessibilityLabel="Notes" + /> + + ); } diff --git a/src/components/pagination.tsx b/src/components/pagination.tsx new file mode 100644 index 0000000..fdd2705 --- /dev/null +++ b/src/components/pagination.tsx @@ -0,0 +1,108 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; +import { pageWindow } from '@/lib/collection-list'; + +function makePaginationStyles(colors: ThemeColors) { + return StyleSheet.create({ + pagination: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.two, + flexWrap: 'wrap', + paddingTop: Spacing.two, + }, + pageButton: { + minWidth: HitTarget.minHeight, + minHeight: HitTarget.minHeight, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: Spacing.two, + borderRadius: 8, + backgroundColor: colors.backgroundElement, + }, + pageButtonActive: { + backgroundColor: colors.accent, + }, + pageButtonText: { + fontSize: 16, + color: colors.text, + }, + pageButtonTextActive: { + fontSize: 16, + fontWeight: '700', + color: colors.accentText, + }, + buttonPressed: { + opacity: 0.7, + }, + }); +} + +export function Pagination({ + current, + total, + onPrev, + onNext, + onPage, +}: { + current: number; + total: number; + onPrev: () => void; + onNext: () => void; + onPage: (page: number) => void; +}) { + const colors = useThemeColors(); + const styles = makePaginationStyles(colors); + + if (total <= 1) { + return null; + } + + return ( + + [styles.pageButton, pressed && styles.buttonPressed]} + onPress={onPrev} + disabled={current <= 1} + accessibilityRole="button" + accessibilityLabel="Page précédente" + accessibilityState={{ disabled: current <= 1 }} + testID="pagination-prev"> + + + {pageWindow(current, total).map((p) => ( + [ + styles.pageButton, + p === current && styles.pageButtonActive, + pressed && styles.buttonPressed, + ]} + onPress={() => onPage(p)} + disabled={p === current} + accessibilityRole="button" + accessibilityLabel={`Page ${p}`} + accessibilityState={{ selected: p === current }} + testID={`pagination-page-${p}`}> + + {p} + + + ))} + [styles.pageButton, pressed && styles.buttonPressed]} + onPress={onNext} + disabled={current >= total} + accessibilityRole="button" + accessibilityLabel="Page suivante" + accessibilityState={{ disabled: current >= total }} + testID="pagination-next"> + + + + ); +} diff --git a/src/components/scan-search-views.tsx b/src/components/scan-search-views.tsx new file mode 100644 index 0000000..f2fcc54 --- /dev/null +++ b/src/components/scan-search-views.tsx @@ -0,0 +1,281 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; + +export function makeSearchResultStyles(colors: ThemeColors) { + return StyleSheet.create({ + form: { + flex: 1, + gap: Spacing.three, + }, + resultTitle: { + fontSize: 22, + fontWeight: '700', + color: colors.text, + textAlign: 'center', + }, + card: { + backgroundColor: colors.backgroundElement, + borderRadius: 12, + padding: Spacing.four, + alignItems: 'center', + gap: Spacing.two, + }, + magazine: { + fontSize: 20, + fontWeight: '600', + color: colors.text, + textAlign: 'center', + }, + issue: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + }, + message: { + fontSize: 16, + color: colors.text, + textAlign: 'center', + lineHeight: 22, + }, + muted: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + }, + ownedText: { + fontSize: 14, + fontWeight: '700', + color: colors.ownedText, + backgroundColor: colors.ownedBg, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.two, + borderRadius: 8, + overflow: 'hidden', + }, + absentText: { + fontSize: 14, + fontWeight: '700', + color: colors.absentText, + backgroundColor: colors.absentBg, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.two, + borderRadius: 8, + overflow: 'hidden', + }, + primaryButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + }, + primaryButtonText: { + fontSize: 18, + fontWeight: '700', + color: colors.accentText, + }, + secondaryButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.backgroundElement, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + }, + secondaryButtonText: { + fontSize: 17, + fontWeight: '600', + color: colors.text, + }, + pressed: { + opacity: 0.7, + }, + }); +} + +type SearchFoundResultProps = { + publication: string; + issueNumber: number | null; + owned: boolean; + ownedCount: number; + resolved: boolean; + onAddCopy: () => void; + onView: () => void; + onRescan: () => void; +}; + +export function SearchFoundResult({ + publication, + issueNumber, + owned, + ownedCount, + resolved, + onAddCopy, + onView, + onRescan, +}: SearchFoundResultProps) { + const colors = useThemeColors(); + const styles = makeSearchResultStyles(colors); + + return ( + + Déjà dans votre collection + + + {publication} + + {issueNumber != null ? N° {issueNumber} : null} + {resolved ? ( + + {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} + + ) : ( + + Vérification… + + )} + + + [styles.primaryButton, pressed && styles.pressed]} + onPress={onAddCopy} + testID="search-add" + accessibilityRole="button" + accessibilityLabel={owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'}> + + {owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'} + + + + [styles.secondaryButton, pressed && styles.pressed]} + onPress={onView} + testID="search-view" + accessibilityRole="button" + accessibilityLabel="Voir la fiche du magazine"> + Voir la fiche + + + [styles.secondaryButton, pressed && styles.pressed]} + onPress={onRescan} + testID="search-rescan" + accessibilityRole="button" + accessibilityLabel="Scanner à nouveau"> + Scanner à nouveau + + + ); +} + +type SearchUnknownResultProps = { + publication: string; + issueNumber: number | null; + onManual: () => void; + onAgain: () => void; +}; + +export function SearchUnknownResult({ + publication, + issueNumber, + onManual, + onAgain, +}: SearchUnknownResultProps) { + const colors = useThemeColors(); + const styles = makeSearchResultStyles(colors); + + return ( + + Non référencé + + + « {publication} » n'est pas encore dans votre collection. + + {issueNumber != null ? N° {issueNumber} : null} + + + [styles.primaryButton, pressed && styles.pressed]} + onPress={onManual} + testID="search-manual" + accessibilityRole="button" + accessibilityLabel="Saisir manuellement"> + Saisir manuellement + + + [styles.secondaryButton, pressed && styles.pressed]} + onPress={onAgain} + testID="search-again" + accessibilityRole="button" + accessibilityLabel="Rechercher un autre magazine"> + Rechercher un autre magazine + + + ); +} + +export function makeSearchScreenStyles(colors: ThemeColors) { + return StyleSheet.create({ + screen: { + flex: 1, + padding: Spacing.four, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + marginBottom: Spacing.three, + }, + backButton: { + minWidth: HitTarget.minHeight, + minHeight: HitTarget.minHeight, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + flex: 1, + fontSize: 24, + lineHeight: 32, + fontWeight: '700', + color: colors.text, + }, + form: { + flex: 1, + gap: Spacing.three, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginTop: Spacing.two, + }, + input: { + backgroundColor: colors.backgroundElement, + borderRadius: 8, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + minHeight: 44, + fontSize: 16, + color: colors.text, + }, + disabled: { + opacity: 0.5, + }, + hint: { + fontSize: 13, + color: colors.textSecondary, + textAlign: 'center', + }, + pressed: { + opacity: 0.7, + }, + }); +} diff --git a/src/components/use-magazine-form.ts b/src/components/use-magazine-form.ts new file mode 100644 index 0000000..072b4f4 --- /dev/null +++ b/src/components/use-magazine-form.ts @@ -0,0 +1,145 @@ +import { useCallback, useImperativeHandle, useState } from 'react'; +import type { Ref } from 'react'; +import { consumePendingBarcode } from '@/lib/pending-barcode'; +import type { CreateMagazineInput, Magazine } from '@/types'; + +export type FormValues = { + publication: string; + issueNumber: string; + edition: string; + language: string; + condition: string; + month: string | null; + year: string | null; + barcode: string; + notes: string; +}; + +export type MagazineFormHandle = { + submit: () => Promise; +}; + +/** Convertit (mois, année) en date ISO « YYYY-MM » ou null si incomplète. */ +export function publicationDateFrom(month: string | null, year: string | null): string | null { + if (!month || !year) { + return null; + } + return `${year}-${month}`; +} + +type BuildInputOptions = { + initial?: Magazine; + initialBarcode?: string; + initialPublication?: string; + initialIssueNumber?: number | null; + initialYear?: string | null; +}; + +export function initialFormValues(options: BuildInputOptions): FormValues { + const { initial, initialBarcode, initialPublication, initialIssueNumber, initialYear } = options; + const date = initial?.publicationDate ?? ''; + const [year, month] = date.length === 7 ? date.split('-') : ['', '']; + const prefilledYear = initialYear ?? (year || null); + return { + publication: initialPublication ?? initial?.publication ?? '', + issueNumber: + initialIssueNumber != null + ? String(initialIssueNumber) + : initial?.issueNumber != null + ? String(initial.issueNumber) + : '', + edition: initial?.edition ?? '', + language: initial?.language ?? '', + condition: initial?.condition ?? '', + month: month || null, + year: prefilledYear || null, + barcode: initial?.barcode ?? initialBarcode ?? '', + notes: initial?.notes ?? '', + }; +} + +/** Construit l'entrée métier à partir des valeurs brutes du formulaire. */ +export function buildMagazineInput(values: FormValues): CreateMagazineInput { + const issueDigits = values.issueNumber.trim(); + return { + publication: values.publication.trim(), + issueNumber: issueDigits ? Number(issueDigits) : null, + edition: values.edition.trim() || null, + language: values.language.trim() || null, + condition: values.condition.trim() || null, + publicationDate: publicationDateFrom(values.month, values.year), + barcode: values.barcode.trim() || null, + notes: values.notes.trim() || null, + }; +} + +type Params = { + initial?: Magazine; + initialBarcode?: string; + initialPublication?: string; + initialIssueNumber?: number | null; + initialYear?: string | null; + onSubmit: (input: CreateMagazineInput) => Promise | void; + ref?: Ref; +}; + +export function useMagazineForm({ + initial, + initialBarcode, + initialPublication, + initialIssueNumber, + initialYear, + onSubmit, + ref, +}: Params) { + const [values, setValues] = useState(() => + initialFormValues({ + initial, + initialBarcode, + initialPublication, + initialIssueNumber, + initialYear, + }), + ); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + const set = (key: keyof FormValues, value: string | null) => { + setValues((prev) => ({ ...prev, [key]: value })); + if (formError) { + setFormError(null); + } + }; + + const consumeBarcode = useCallback(() => { + const pending = consumePendingBarcode(); + if (pending) { + setValues((prev) => ({ ...prev, barcode: pending })); + } + }, []); + + const canSubmit = values.publication.trim().length > 0 && !submitting; + + const handleSubmit = useCallback(async () => { + if (!canSubmit) { + return; + } + const issueDigits = values.issueNumber.trim(); + if (issueDigits && !/^\d+$/.test(issueDigits)) { + setFormError('Le numéro doit être composé uniquement de chiffres.'); + return; + } + setSubmitting(true); + try { + await onSubmit(buildMagazineInput(values)); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Erreur lors de l’enregistrement.'); + } finally { + setSubmitting(false); + } + }, [canSubmit, onSubmit, values]); + + useImperativeHandle(ref, () => ({ submit: () => handleSubmit() }), [handleSubmit]); + + return { values, set, submitting, formError, canSubmit, handleSubmit, consumeBarcode }; +} diff --git a/src/lib/__tests__/collection-list.test.ts b/src/lib/__tests__/collection-list.test.ts new file mode 100644 index 0000000..f8d97b3 --- /dev/null +++ b/src/lib/__tests__/collection-list.test.ts @@ -0,0 +1,115 @@ +import type { MagazineListItem } from '@/types'; +import { + buildEditionOptions, + filterAndSortMagazines, + pageWindow, + sortMagazines, +} from '@/lib/collection-list'; + +const m = ( + over: Partial & { id: string; publication: string }, +): MagazineListItem => ({ + id: over.id, + publication: over.publication, + issueNumber: over.issueNumber ?? null, + edition: over.edition ?? null, + language: null, + condition: null, + publicationDate: null, + barcode: null, + notes: null, + ocrText: null, + createdAt: over.createdAt ?? '', + updatedAt: '', + quantity: over.quantity ?? 0, +}); + +describe('sortMagazines', () => { + const list = [ + m({ id: 'a', publication: 'B', issueNumber: 2 }), + m({ id: 'b', publication: 'A', issueNumber: 10 }), + m({ id: 'c', publication: 'C', issueNumber: null }), + ]; + + it('trie par numéro croissant puis publication (null en fin)', () => { + const sorted = sortMagazines(list, 'Numéro ↑'); + expect(sorted.map((x) => x.id)).toEqual(['a', 'b', 'c']); + }); + + it('trie par numéro décroissant', () => { + const sorted = sortMagazines(list, 'Numéro ↓'); + expect(sorted.map((x) => x.id)).toEqual(['b', 'a', 'c']); + }); + + it('trie par édition (A → Z) puis publication (sans édition en tête)', () => { + const editionList = [ + m({ id: 'a', publication: 'X', edition: 'FR' }), + m({ id: 'b', publication: 'Y', edition: 'EN' }), + m({ id: 'c', publication: 'Z', edition: null }), + ]; + const sorted = sortMagazines(editionList, 'Édition (A → Z)'); + expect(sorted.map((x) => x.id)).toEqual(['c', 'b', 'a']); + }); + + it('trie par ajout récent', () => { + const recentList = [ + m({ id: 'a', publication: 'X', createdAt: '2020-01-01' }), + m({ id: 'b', publication: 'Y', createdAt: '2023-01-01' }), + ]; + const sorted = sortMagazines(recentList, 'Ajout récent'); + expect(sorted.map((x) => x.id)).toEqual(['b', 'a']); + }); + + it('ne mute pas la liste source', () => { + const original = [...list]; + sortMagazines(list, 'Numéro ↑'); + expect(list.map((x) => x.id)).toEqual(original.map((x) => x.id)); + }); +}); + +describe('pageWindow', () => { + it('renvoie la fenêtre autour de la page courante', () => { + expect(pageWindow(5, 10)).toEqual([3, 4, 5, 6, 7]); + }); + it('borne au début', () => { + expect(pageWindow(1, 5)).toEqual([1, 2, 3]); + }); + it('borne à la fin', () => { + expect(pageWindow(10, 10)).toEqual([8, 9, 10]); + }); +}); + +describe('buildEditionOptions', () => { + it('liste les éditions distinctes avec « Sans édition » en fin', () => { + const list = [ + m({ id: 'a', publication: 'X', edition: 'FR' }), + m({ id: 'b', publication: 'Y', edition: 'FR' }), + m({ id: 'c', publication: 'Z', edition: null }), + m({ id: 'd', publication: 'W', edition: ' EN ' }), + ]; + expect(buildEditionOptions(list)).toEqual(['EN', 'FR', 'Sans édition']); + }); +}); + +describe('filterAndSortMagazines', () => { + const list = [ + m({ id: 'a', publication: 'X', issueNumber: 5, edition: 'FR' }), + m({ id: 'b', publication: 'Y', issueNumber: 10, edition: 'EN' }), + m({ id: 'c', publication: 'Z', issueNumber: 5, edition: null }), + ]; + + it('filtre par édition', () => { + const out = filterAndSortMagazines(list, 'FR', '', 'Numéro ↑'); + expect(out.map((x) => x.id)).toEqual(['a']); + }); + + it('filtre par numéro', () => { + const out = filterAndSortMagazines(list, null, '5', 'Numéro ↑'); + expect(out.map((x) => x.id)).toEqual(['a', 'c']); + }); + + it('retourne tout sans filtre', () => { + const out = filterAndSortMagazines(list, null, '', 'Numéro ↑'); + expect(out).toHaveLength(3); + }); +}); diff --git a/src/lib/collection-list.ts b/src/lib/collection-list.ts new file mode 100644 index 0000000..ddf196c --- /dev/null +++ b/src/lib/collection-list.ts @@ -0,0 +1,79 @@ +import type { MagazineListItem } from '@/types'; + +export const PAGE_SIZE = 20; + +/** Options de tri de la collection (M10R2-08). */ +export const SORT_OPTIONS = ['Numéro ↑', 'Numéro ↓', 'Ajout récent', 'Édition (A → Z)'] as const; +export type SortOption = (typeof SORT_OPTIONS)[number]; + +/** Fenêtre de pagination autour de la page courante (±2). */ +export function pageWindow(current: number, total: number): number[] { + const start = Math.max(1, current - 2); + const end = Math.min(total, current + 2); + return Array.from({ length: end - start + 1 }, (_, i) => start + i); +} + +export function sortMagazines(list: MagazineListItem[], sort: SortOption): MagazineListItem[] { + const sorted = [...list]; + switch (sort) { + case 'Numéro ↓': + return sorted.sort((a, b) => { + const na = a.issueNumber ?? Number.MIN_SAFE_INTEGER; + const nb = b.issueNumber ?? Number.MIN_SAFE_INTEGER; + if (na !== nb) return nb - na; + return a.publication.localeCompare(b.publication); + }); + case 'Ajout récent': + return sorted.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + case 'Édition (A → Z)': + return sorted.sort((a, b) => { + const ea = (a.edition?.trim() || '').toLowerCase(); + const eb = (b.edition?.trim() || '').toLowerCase(); + if (ea !== eb) return ea.localeCompare(eb); + return a.publication.localeCompare(b.publication); + }); + case 'Numéro ↑': + default: + return sorted.sort((a, b) => { + const na = a.issueNumber ?? Number.MAX_SAFE_INTEGER; + const nb = b.issueNumber ?? Number.MAX_SAFE_INTEGER; + if (na !== nb) return na - nb; + return a.publication.localeCompare(b.publication); + }); + } +} + +/** Éditions distinctes triées (avec « Sans édition » en fin) pour le filtre. */ +export function buildEditionOptions(list: MagazineListItem[]): string[] { + return [ + ...new Set(list.map((m) => (m.edition?.trim() ? m.edition.trim() : 'Sans édition'))), + ].sort((a, b) => (a === 'Sans édition' ? 1 : b === 'Sans édition' ? -1 : a.localeCompare(b))); +} + +/** Applique les filtres (édition + numéro) puis le tri. */ +export function filterAndSortMagazines( + list: MagazineListItem[], + editionFilter: string | null, + issueQuery: string, + sort: SortOption, +): MagazineListItem[] { + const issue = issueQuery.trim(); + const filtered = list.filter((m) => { + if (editionFilter) { + const edition = m.edition?.trim() ? m.edition.trim() : 'Sans édition'; + if (edition !== editionFilter) { + return false; + } + } + if (issue) { + const parsed = Number(issue); + if (Number.isNaN(parsed) || m.issueNumber !== parsed) { + return false; + } + } + return true; + }); + return sortMagazines(filtered, sort); +} From dfe8df9b944b11f8e5d2c9de9eb0e1ecd73bbb21 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 10:56:43 +0200 Subject: [PATCH 09/34] =?UTF-8?q?refactor(m11-04):=20d=C3=A9couper=20les?= =?UTF-8?q?=20=C3=A9crans=20de=20scan=20cam=C3=A9ra=20&=20code-barres=20(M?= =?UTF-8?q?-11-#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/scan/barcode.tsx | 560 ++---------- src/app/scan/camera.tsx | 841 ++---------------- src/components/__tests__/ocr-analysis.test.ts | 51 ++ .../scan/barcode-overlay-controls.tsx | 100 +++ .../scan/barcode-pending-sheets.tsx | 136 +++ src/components/scan/barcode-styles.ts | 211 +++++ src/components/scan/camera-ocr-styles.ts | 262 ++++++ src/components/scan/ocr-analysis.ts | 73 ++ src/components/scan/ocr-analyzing-overlay.tsx | 144 +++ src/components/scan/ocr-confirm-overlay.tsx | 104 +++ src/components/scan/ocr-result-overlay.tsx | 94 ++ src/components/scan/use-barcode-scanning.ts | 144 +++ src/components/scan/use-ocr-analysis.ts | 204 +++++ 13 files changed, 1644 insertions(+), 1280 deletions(-) create mode 100644 src/components/__tests__/ocr-analysis.test.ts create mode 100644 src/components/scan/barcode-overlay-controls.tsx create mode 100644 src/components/scan/barcode-pending-sheets.tsx create mode 100644 src/components/scan/barcode-styles.ts create mode 100644 src/components/scan/camera-ocr-styles.ts create mode 100644 src/components/scan/ocr-analysis.ts create mode 100644 src/components/scan/ocr-analyzing-overlay.tsx create mode 100644 src/components/scan/ocr-confirm-overlay.tsx create mode 100644 src/components/scan/ocr-result-overlay.tsx create mode 100644 src/components/scan/use-barcode-scanning.ts create mode 100644 src/components/scan/use-ocr-analysis.ts diff --git a/src/app/scan/barcode.tsx b/src/app/scan/barcode.tsx index 6b50b7a..620fb52 100644 --- a/src/app/scan/barcode.tsx +++ b/src/app/scan/barcode.tsx @@ -1,140 +1,38 @@ -import { CameraView, useCameraPermissions } from 'expo-camera'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useRef, useState } from 'react'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { Feather } from '@expo/vector-icons'; +import { CameraView } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { CameraPermissionScreen } from '@/components/camera-permission-screen'; -import { Spacing, type ThemeColors } from '@/constants/theme'; -import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; -import { BarcodeStabilizer } from '@/identification/barcodeStabilizer'; -import { useCollectionStore } from '@/store/use-collection-store'; -import type { Magazine } from '@/types'; - -type ScanState = - { status: 'idle' } | { status: 'searching' } | { status: 'invalid'; reason: string }; - -type Pending = - | { kind: 'confirm'; magazine: Magazine; ownedCount: number } - | { kind: 'success'; publication: string; issueNumber: number | null } - | { kind: 'unknown'; barcode: string }; +import { BarcodeOverlayControls } from '@/components/scan/barcode-overlay-controls'; +import { + BarcodeContinuousBar, + BarcodePendingSheets, +} from '@/components/scan/barcode-pending-sheets'; +import { makeBarcodeStyles } from '@/components/scan/barcode-styles'; +import { useBarcodeScanning } from '@/components/scan/use-barcode-scanning'; export default function BarcodeScreen() { const router = useRouter(); const colors = useThemeColors(); const insets = useSafeAreaInsets(); - const styles = makeStyles(colors, insets); - - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); - const params = useLocalSearchParams<{ continuous?: string }>(); - - const [permission, requestPermission] = useCameraPermissions(); - const [state, setState] = useState({ status: 'idle' }); - const [scanning, setScanning] = useState(true); - const [continuous, setContinuous] = useState(params.continuous === '1'); - const [torchOn, setTorchOn] = useState(false); - const [pending, setPending] = useState(null); - const stabilizer = useRef(new BarcodeStabilizer(3)); - - const resume = () => { - stabilizer.current.reset(); - setPending(null); - setScanning(true); - setState({ status: 'idle' }); - }; - - const handleSingle = async (stabilized: string) => { - const { identificationService } = getDeps(); - const result = await identificationService.identifyByBarcode(stabilized); - - if (result.status === 'found') { - router.replace({ - pathname: '/scan/result', - params: { - id: result.magazine.id, - publication: result.magazine.publication, - issueNumber: - result.magazine.issueNumber != null ? String(result.magazine.issueNumber) : '', - barcode: stabilized, - }, - }); - } else if (result.status === 'ambiguous') { - router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); - } else if (result.status === 'unknown') { - router.replace({ pathname: '/scan/result', params: { barcode: stabilized } }); - } else { - setState({ status: 'invalid', reason: result.reason }); - setScanning(true); - } - }; - - const handleContinuous = async (stabilized: string) => { - const { identificationService, collectionRepository } = getDeps(); - const result = await identificationService.identifyByBarcode(stabilized); - - if (result.status === 'found') { - const ownedCount = await collectionRepository.countByMagazine(result.magazine.id); - if (ownedCount > 0) { - setPending({ kind: 'confirm', magazine: result.magazine, ownedCount }); - } else { - await addExistingCopy(result.magazine.id); - setPending({ - kind: 'success', - publication: result.magazine.publication, - issueNumber: result.magazine.issueNumber, - }); - } - } else if (result.status === 'ambiguous') { - router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); - } else if (result.status === 'unknown') { - setPending({ kind: 'unknown', barcode: stabilized }); - } else { - setState({ status: 'invalid', reason: result.reason }); - setScanning(true); - } - }; - - const handleScan = async ({ data }: { data: string; type: string }) => { - if (!scanning || state.status === 'searching' || pending) { - return; - } - const stabilized = stabilizer.current.push(data); - if (stabilized === null) { - return; - } - setScanning(false); - setState({ status: 'searching' }); - - if (continuous) { - await handleContinuous(stabilized); - } else { - await handleSingle(stabilized); - } - }; - - const reset = () => { - stabilizer.current.reset(); - setScanning(true); - setState({ status: 'idle' }); - }; - - const confirmAdd = async () => { - if (pending?.kind !== 'confirm') { - return; - } - await addExistingCopy(pending.magazine.id); - setPending({ - kind: 'success', - publication: pending.magazine.publication, - issueNumber: pending.magazine.issueNumber, - }); - }; - - const confirm = pending?.kind === 'confirm' ? pending : null; - const success = pending?.kind === 'success' ? pending : null; - const unknown = pending?.kind === 'unknown' ? pending : null; + const styles = makeBarcodeStyles(colors, insets); + + const { + permission, + requestPermission, + state, + continuous, + torchOn, + pending, + setContinuous, + setTorchOn, + handleScan, + resume, + reset, + confirmAdd, + } = useBarcodeScanning(); if (!permission || !permission.granted) { return ( @@ -162,390 +60,36 @@ export default function BarcodeScreen() { /> {continuous && !pending && ( - - Scan en continu — ajoute chaque exemplaire - [styles.continuousStop, pressed && styles.buttonPressed]} - onPress={() => { - setContinuous(false); - resume(); - }} - testID="continuous-stop" - accessibilityRole="button" - accessibilityLabel="Arrêter le scan en continu"> - Arrêter - - + { + setContinuous(false); + resume(); + }} + /> )} - - - - {state.status === 'searching' ? ( - - ) : null} - - {state.status === 'searching' ? 'Recherche…' : 'Alignez le code-barres dans le cadre'} - - - {state.status === 'invalid' && ( - - {state.reason} - - )} - - - {state.status === 'idle' && !pending && !continuous && ( - [styles.startContinuousButton, pressed && styles.buttonPressed]} - onPress={() => setContinuous(true)} - testID="continuous-start" - accessibilityRole="button" - accessibilityLabel="Lancer le scan en continu"> - Scan en continu - - )} - - {state.status === 'idle' && !pending && ( - [styles.backButton, pressed && styles.buttonPressed]} - onPress={() => router.back()} - testID="scan-back" - accessibilityRole="button" - accessibilityLabel="Annuler"> - - - )} - - {state.status === 'idle' && !pending && ( - [styles.torchButton, pressed && styles.buttonPressed]} - onPress={() => setTorchOn((t) => !t)} - testID="scan-torch" - accessibilityRole="button" - accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> - - - )} - - {state.status === 'invalid' && ( - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={reset} - testID="invalid-retry" - accessibilityRole="button"> - Scanner à nouveau - - - )} - - {confirm && ( - - - - [styles.sheetClose, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-close" - accessibilityRole="button" - accessibilityLabel="Fermer"> - - - Vous possédez déjà ce magazine - - {confirm.magazine.publication} - {confirm.magazine.issueNumber != null ? ` n° ${confirm.magazine.issueNumber}` : ''} - - - Exemplaires actuels : {confirm.ownedCount}. Ajouter un exemplaire ? - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={confirmAdd} - testID="pending-confirm-add" - accessibilityRole="button"> - Ajouter un exemplaire - - [styles.pendingCancel, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-confirm-cancel" - accessibilityRole="button"> - Annuler - - - - - )} - - {success && ( - - - - Ajouté à la collection - - {success.publication} - {success.issueNumber != null ? ` n° ${success.issueNumber}` : ''} - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-success-ok" - accessibilityRole="button"> - Scanner le suivant - - - - - )} + setContinuous(true)} + onBack={() => router.back()} + onToggleTorch={() => setTorchOn((t) => !t)} + onRetry={reset} + /> - {unknown && ( - - - - Code-barres inconnu - {unknown.barcode} - - Le scan seul ne crée pas l'édition. Saisissez-la manuellement. - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={() => - router.replace({ pathname: '/scan/manual', params: { barcode: unknown.barcode } }) - } - testID="pending-unknown-manual" - accessibilityRole="button"> - Saisir manuellement - - [styles.pendingCancel, pressed && styles.buttonPressed]} - onPress={resume} - testID="pending-unknown-continue" - accessibilityRole="button"> - Continuer le scan - - - - + {pending && ( + router.replace({ pathname: '/scan/manual', params: { barcode } })} + /> )} ); } - -function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - overlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: 'center', - justifyContent: 'center', - }, - camera: { - flex: 1, - }, - reticle: { - width: 220, - height: 220, - borderWidth: 3, - borderColor: colors.accent, - borderRadius: 16, - backgroundColor: 'transparent', - }, - hintRow: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - marginTop: Spacing.three, - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - }, - scanHint: { - fontSize: 14, - lineHeight: 20, - color: '#FFFFFF', - textAlign: 'center', - }, - invalidText: { - marginTop: Spacing.two, - fontSize: 14, - color: '#FFD5D2', - textAlign: 'center', - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - overflow: 'hidden', - }, - invalidActions: { - position: 'absolute', - left: Spacing.four, - right: Spacing.four, - bottom: insets.bottom + 24, - }, - continuousBar: { - position: 'absolute', - top: insets.top + 12, - left: Spacing.three, - right: Spacing.three, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', - borderRadius: 10, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - }, - continuousText: { - flex: 1, - fontSize: 13, - color: '#FFFFFF', - }, - continuousStop: { - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - backgroundColor: colors.danger, - }, - continuousStopText: { - fontSize: 13, - fontWeight: '700', - color: colors.onDanger, - }, - startContinuousButton: { - position: 'absolute', - left: Spacing.four, - right: Spacing.four, - bottom: insets.bottom + 80, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.65)', - paddingVertical: Spacing.two, - borderRadius: 10, - }, - startContinuousText: { - fontSize: 15, - fontWeight: '600', - color: '#FFFFFF', - }, - pendingCard: { - marginHorizontal: Spacing.four, - backgroundColor: colors.backgroundElement, - borderRadius: 14, - padding: Spacing.four, - gap: Spacing.two, - alignItems: 'center', - }, - sheetClose: { - alignSelf: 'flex-end', - minWidth: 44, - minHeight: 44, - alignItems: 'center', - justifyContent: 'center', - marginTop: -Spacing.three, - marginRight: -Spacing.three, - }, - backdrop: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.5)', - }, - sheetScroll: { - flexGrow: 1, - justifyContent: 'center', - }, - pendingTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.text, - textAlign: 'center', - }, - pendingMagazine: { - fontSize: 17, - fontWeight: '600', - color: colors.accentTextOnLight, - textAlign: 'center', - }, - pendingMessage: { - fontSize: 14, - color: colors.textSecondary, - textAlign: 'center', - lineHeight: 20, - }, - pendingCancel: { - minHeight: 44, - justifyContent: 'center', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - }, - pendingCancelText: { - fontSize: 16, - color: colors.textSecondary, - textAlign: 'center', - }, - primaryButton: { - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - alignSelf: 'stretch', - }, - primaryButtonText: { - fontSize: 16, - fontWeight: '700', - color: colors.accentText, - textAlign: 'center', - }, - buttonPressed: { - opacity: 0.8, - }, - backButton: { - position: 'absolute', - top: insets.top + 12, - left: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - torchButton: { - position: 'absolute', - top: insets.top + 12, - right: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - }); -} diff --git a/src/app/scan/camera.tsx b/src/app/scan/camera.tsx index 0af6e55..9b2de08 100644 --- a/src/app/scan/camera.tsx +++ b/src/app/scan/camera.tsx @@ -1,255 +1,37 @@ -import { CameraView, useCameraPermissions } from 'expo-camera'; +import { CameraView } from 'expo-camera'; import { useRouter } from 'expo-router'; -import { useEffect, useRef, useState } from 'react'; -import { - ActivityIndicator, - Pressable, - ScrollView, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { Feather } from '@expo/vector-icons'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import type { CameraView as CameraViewType } from 'expo-camera'; import { CameraPermissionScreen } from '@/components/camera-permission-screen'; -import { Spacing, type ThemeColors } from '@/constants/theme'; -import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; -import { OcrTextStabilizer } from '@/identification/ocr/ocrTextStabilizer'; -import { MIN_CONFIDENCE } from '@/identification/ocr/ocrTextParser'; - -/** Intervalle d'analyse OCR : quelques frames / seconde max (pas toutes). */ -const ANALYSIS_INTERVAL_MS = 500; - -/** - * Lectures OCR identiques requises avant de conclure une identification - * (M10R2-09 — vote multi-frames conservateur, une lecture isolée pouvant être - * erronée sur des textes stylisés / à encres faibles). - */ -const OCR_STABLE_READS = 2; - -/** - * Informations détectées par l'OCR, affichées en surcouche caméra (US-ID-08) - * et proposées à la validation / correction (US-ID-09). - */ -type DetectedInfo = { - publication: string | null; - issueNumber: number | null; - date: string | null; -}; - -const EMPTY_DETECTED: DetectedInfo = { publication: null, issueNumber: null, date: null }; - -type OcrUiState = - | { status: 'analyzing'; detected: DetectedInfo } - | { status: 'confirm'; detected: DetectedInfo } - | { - status: 'found'; - id: string; - publication: string; - issueNumber: number | null; - date: string | null; - confidence: number; - } - | { - status: 'unknown'; - publication: string; - issueNumber: number | null; - date: string | null; - confidence: number; - }; - -function hasAnyDetected(detected: DetectedInfo): boolean { - return detected.publication !== null || detected.issueNumber !== null || detected.date !== null; -} +import { OcrAnalyzingOverlay } from '@/components/scan/ocr-analyzing-overlay'; +import { OcrConfirmOverlay } from '@/components/scan/ocr-confirm-overlay'; +import { OcrResultOverlay } from '@/components/scan/ocr-result-overlay'; +import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; +import { useOcrAnalysis } from '@/components/scan/use-ocr-analysis'; export default function CameraOcrScreen() { const router = useRouter(); const colors = useThemeColors(); const insets = useSafeAreaInsets(); - const styles = makeStyles(colors, insets); - - const [permission, requestPermission] = useCameraPermissions(); - const [state, setState] = useState({ - status: 'analyzing', - detected: EMPTY_DETECTED, - }); - const [draft, setDraft] = useState(EMPTY_DETECTED); - const [torchOn, setTorchOn] = useState(false); - const [weakCycles, setWeakCycles] = useState(0); - const inFlight = useRef(false); - const cameraRef = useRef(null); - const ocrStabilizer = useRef(new OcrTextStabilizer(OCR_STABLE_READS)); - - useEffect(() => { - if (!permission?.granted) { - return; - } - if (state.status !== 'analyzing') { - return; - } - - const { ocrEngine, identificationService } = getDeps(); - const interval = setInterval(async () => { - if (inFlight.current) { - return; - } - inFlight.current = true; - try { - // Capture éphémère d'une photo (aucune image persistée) → URI. - // Haute résolution (M10R2-09) : préserve les petites encres des textes stylisés. - const photo = await cameraRef.current?.takePictureAsync?.({ - quality: 1, - skipProcessing: false, - }); - const uri = photo?.uri ?? null; - const frame = await ocrEngine.recognize({ native: uri, width: 0, height: 0 }); - if (!frame) { - return; - } - const result = await identificationService.identifyByOCR(frame.text); - - if (result.status === 'no-text') { - return; - } - - if (result.status === 'weak') { - // US-ID-08 : on ne conclut plus en échec dès la première lecture partielle. - // On met en surcouche les champs détectés et on continue d'analyser - // (le pointeur guide l'utilisateur vers le champ manquant). - setWeakCycles((c) => c + 1); - setState((prev) => - prev.status === 'analyzing' - ? { - status: 'analyzing', - detected: { - publication: - result.publication === 'Publication inconnue' ? null : result.publication, - issueNumber: result.issueNumber, - date: result.date, - }, - } - : prev, - ); - return; - } - - // Vote multi-frames (M10R2-09) : on ne conclut pas sur une lecture - // isolée, une frame suivante identique est requise (textes stylisés). - const key = [ - result.publication ?? '', - result.issueNumber != null ? String(result.issueNumber) : '', - result.date ?? '', - ].join('|'); - if (!ocrStabilizer.current.push(key)) { - return; - } - ocrStabilizer.current.reset(); - - if (result.status === 'unknown') { - setState({ - status: 'unknown', - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - return; - } - - setState({ - status: 'found', - id: result.magazine.id, - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - } finally { - inFlight.current = false; - } - }, ANALYSIS_INTERVAL_MS); - - return () => clearInterval(interval); - }, [permission?.granted, state.status]); - - const stopAndRetry = () => { - ocrStabilizer.current.reset(); - setWeakCycles(0); - setState({ status: 'analyzing', detected: EMPTY_DETECTED }); - }; - - const openConfirm = () => { - const detected = - state.status === 'analyzing' || state.status === 'confirm' ? state.detected : EMPTY_DETECTED; - setDraft(detected); - setState({ status: 'confirm', detected }); - }; - - const buildManualParams = (detected: Partial): Record => { - const params: Record = {}; - if (detected.publication) { - params.publication = detected.publication; - } - if (detected.issueNumber != null) { - params.issueNumber = String(detected.issueNumber); - } - if (detected.date) { - params.year = detected.date; - } - return params; - }; - - const goManual = (detected: Partial) => { - router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); - }; - - const goBarcode = () => { - router.replace('/scan/barcode'); - }; - - /** US-ID-09 : recherche en outrepassant la confiance (champs validés/corrigés). */ - const searchFromDraft = async () => { - const publication = draft.publication?.trim() ?? ''; - const rawNumber = draft.issueNumber?.toString().trim() ?? ''; - const issueNumber = rawNumber ? Number(rawNumber) : null; - const date = draft.date?.trim() || null; - - if (!publication || issueNumber === null || !Number.isFinite(issueNumber)) { - // Impossible de rechercher : on oriente vers la saisie manuelle pré-remplie. - goManual({ publication: publication || undefined, issueNumber, date }); - return; - } - - const { identificationService } = getDeps(); - const result = await identificationService.searchByOcrFields(publication, issueNumber, date); - - if (result.status === 'weak' || result.status === 'no-text') { - goManual({ publication, issueNumber, date }); - return; - } - if (result.status === 'unknown') { - setState({ - status: 'unknown', - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - return; - } - setState({ - status: 'found', - id: result.magazine.id, - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - }; + const styles = makeCameraOcrStyles(colors, insets); + const { + cameraRef, + permission, + requestPermission, + state, + draft, + torchOn, + weakCycles, + setTorchOn, + setDraft, + stopAndRetry, + openConfirm, + goManual, + goBarcode, + searchFromDraft, + } = useOcrAnalysis(); if (!permission || !permission.granted) { return ( @@ -286,546 +68,61 @@ export default function CameraOcrScreen() { /> {isAnalyzing && ( - <> - - - - {hint} - - - - Lecture… - - - {/* Surcouche US-ID-08 : champs détectés en direct. */} - - - Nom - - {state.detected.publication ?? '…'} - - - - Numéro - - {state.detected.issueNumber?.toString() ?? '…'} - - - - Édition / date - - {state.detected.date ?? '…'} - - - - - {hasAnyDetected(state.detected) && ( - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={openConfirm} - testID="ocr-confirm-detected" - accessibilityRole="button"> - Valider ces informations détectées - - )} - - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={goBarcode} - testID="ocr-barcode" - accessibilityRole="button"> - Scanner le code-barres - - - [styles.backButton, pressed && styles.buttonPressed]} - onPress={() => router.back()} - testID="ocr-back" - accessibilityRole="button" - accessibilityLabel="Annuler"> - - - - [styles.torchButton, pressed && styles.buttonPressed]} - onPress={() => setTorchOn((t) => !t)} - testID="ocr-torch" - accessibilityRole="button" - accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> - - - - {weakCycles > 3 && detected?.publication && detected?.issueNumber === null && ( - - - - Le texte stylisé est difficile à lire automatiquement. - - - [styles.guidanceButton, pressed && styles.buttonPressed]} - onPress={goBarcode} - testID="ocr-guidance-barcode" - accessibilityRole="button"> - Code-barres - - [styles.guidanceButton, pressed && styles.buttonPressed]} - onPress={() => goManual(detected)} - testID="ocr-guidance-manual" - accessibilityRole="button"> - Saisie manuelle - - - - )} - + router.back()} + onToggleTorch={() => setTorchOn((t) => !t)} + /> )} {isConfirming && ( - - - Vérifier les informations - - Corrigez les informations détectées puis validez la recherche, même si la confiance - était insuffisante. - - - - Nom - setDraft((d) => ({ ...d, publication: t }))} - placeholder="Publication du magazine" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-publication" - /> - - - - Numéro - { - const digits = t.replace(/[^0-9]/g, ''); - setDraft((d) => ({ ...d, issueNumber: digits ? Number(digits) : null })); - }} - placeholder="N° du magazine" - keyboardType="number-pad" - returnKeyType="done" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-issue" - accessibilityLabel="Numéro du magazine (chiffres uniquement)" - /> - - - - Année - setDraft((d) => ({ ...d, date: t }))} - placeholder="Année / date (optionnel)" - keyboardType="default" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-date" - /> - - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={searchFromDraft} - testID="ocr-override-search" - accessibilityRole="button"> - Rechercher - - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={() => goManual(draft)} - testID="ocr-override-manual" - accessibilityRole="button"> - Saisir manuellement - - - [styles.cancelButton, pressed && styles.buttonPressed]} - onPress={stopAndRetry} - testID="ocr-override-back" - accessibilityRole="button"> - Retour à la caméra - - - + goManual(draft)} + onBack={stopAndRetry} + /> )} {state.status === 'found' && ( - - - Couverture reconnue - - {state.publication} - - {state.issueNumber != null && N° {state.issueNumber}} - {state.date && {state.date}} - - Confiance : {confidenceLabel(state.confidence)} - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={() => router.replace(`/collection/${state.id}`)} - testID="ocr-confirm" - accessibilityRole="button"> - Confirmer - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={stopAndRetry} - testID="ocr-retry" - accessibilityRole="button"> - Réessayer - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={() => - goManual({ - publication: state.publication, - issueNumber: state.issueNumber, - date: state.date, - }) - } - testID="ocr-manual" - accessibilityRole="button"> - Saisie manuelle - - - + router.replace(`/collection/${state.id}`)} + onRetry={stopAndRetry} + onGoManual={() => + goManual({ + publication: state.publication, + issueNumber: state.issueNumber, + date: state.date, + }) + } + /> )} {state.status === 'unknown' && ( - - - Non trouvé en collection - {state.publication} - {state.issueNumber != null && N° {state.issueNumber}} - - {state.publication} n'est pas encore référencé. Vous pouvez le saisir - manuellement pour le créer. - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={() => - goManual({ - publication: state.publication, - issueNumber: state.issueNumber, - date: state.date, - }) - } - testID="ocr-manual" - accessibilityRole="button"> - Saisir manuellement - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={stopAndRetry} - testID="ocr-retry" - accessibilityRole="button"> - Réessayer avec la caméra - - - + + goManual({ + publication: state.publication, + issueNumber: state.issueNumber, + date: state.date, + }) + } + /> )} ); } - -function confidenceLabel(confidence: number): string { - if (confidence >= 0.8) { - return 'élevée'; - } - if (confidence >= MIN_CONFIDENCE) { - return 'moyenne'; - } - return 'faible'; -} - -function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - overlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: Spacing.four, - }, - camera: { - flex: 1, - }, - reticle: { - width: 220, - height: 220, - borderWidth: 3, - borderColor: colors.accent, - borderRadius: 16, - backgroundColor: 'transparent', - }, - scanHint: { - marginTop: Spacing.three, - fontSize: 14, - color: '#FFFFFF', - textAlign: 'center', - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - overflow: 'hidden', - }, - processingPill: { - marginTop: Spacing.two, - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - backgroundColor: 'rgba(0,0,0,0.55)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 20, - }, - processingText: { - fontSize: 14, - color: '#FFFFFF', - }, - detectedBoard: { - alignSelf: 'stretch', - marginTop: Spacing.three, - backgroundColor: 'rgba(0,0,0,0.55)', - borderRadius: 12, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - gap: 6, - }, - detectedRow: { - flexDirection: 'row', - justifyContent: 'space-between', - gap: Spacing.two, - }, - detectedLabel: { - fontSize: 13, - fontWeight: '700', - color: '#FFFFFF', - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - detectedValue: { - fontSize: 14, - lineHeight: 20, - fontWeight: '600', - color: colors.accent, - }, - detectedValueEmpty: { - color: 'rgba(255,255,255,0.75)', - fontWeight: '400', - }, - resultCard: { - alignSelf: 'stretch', - marginHorizontal: Spacing.four, - marginTop: insets.top + Spacing.three, - marginBottom: insets.bottom + Spacing.three, - maxHeight: '85%', - backgroundColor: colors.backgroundElement, - borderRadius: 16, - padding: Spacing.four, - }, - resultCardContent: { - alignItems: 'center', - gap: Spacing.three, - }, - mutedTitle: { - fontSize: 14, - fontWeight: '700', - color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 1, - }, - magazine: { - fontSize: 20, - fontWeight: '700', - color: colors.text, - textAlign: 'center', - }, - issue: { - fontSize: 16, - color: colors.text, - }, - date: { - fontSize: 14, - color: colors.textSecondary, - }, - confidence: { - fontSize: 14, - color: colors.textSecondary, - }, - message: { - fontSize: 15, - color: colors.text, - textAlign: 'center', - lineHeight: 22, - }, - fieldWrap: { - alignSelf: 'stretch', - gap: 6, - }, - fieldLabel: { - fontSize: 13, - fontWeight: '700', - color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - input: { - alignSelf: 'stretch', - backgroundColor: colors.background, - borderRadius: 10, - paddingVertical: Spacing.three, - paddingHorizontal: Spacing.three, - fontSize: 16, - color: colors.text, - }, - primaryButton: { - alignSelf: 'stretch', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - paddingVertical: Spacing.three, - borderRadius: 12, - marginTop: Spacing.two, - }, - primaryButtonText: { - fontSize: 16, - fontWeight: '700', - color: colors.accentText, - textAlign: 'center', - }, - secondaryButton: { - alignSelf: 'stretch', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.backgroundElement, - paddingVertical: Spacing.three, - borderRadius: 12, - marginHorizontal: Spacing.four, - marginTop: Spacing.two, - marginBottom: insets.bottom + Spacing.three, - }, - secondaryButtonText: { - fontSize: 16, - fontWeight: '600', - color: colors.text, - textAlign: 'center', - }, - buttonPressed: { - opacity: 0.8, - }, - cancelButton: { - marginTop: Spacing.three, - alignSelf: 'center', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - }, - cancelButtonText: { - fontSize: 16, - color: colors.textSecondary, - textAlign: 'center', - }, - backButton: { - position: 'absolute', - top: insets.top + 12, - left: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - torchButton: { - position: 'absolute', - top: insets.top + 12, - right: Spacing.three, - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(0,0,0,0.55)', - alignItems: 'center', - justifyContent: 'center', - }, - guidanceCard: { - position: 'absolute', - left: Spacing.four, - right: Spacing.four, - bottom: insets.bottom + Spacing.three, - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - gap: Spacing.two, - backgroundColor: 'rgba(0,0,0,0.75)', - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - }, - guidanceText: { - flex: 1, - fontSize: 13, - color: '#FFFFFF', - lineHeight: 18, - }, - guidanceActions: { - flexDirection: 'row', - gap: Spacing.two, - width: '100%', - }, - guidanceButton: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - paddingVertical: Spacing.two, - borderRadius: 8, - }, - guidanceButtonText: { - fontSize: 13, - fontWeight: '700', - color: colors.accentText, - }, - }); -} diff --git a/src/components/__tests__/ocr-analysis.test.ts b/src/components/__tests__/ocr-analysis.test.ts new file mode 100644 index 0000000..e9985c3 --- /dev/null +++ b/src/components/__tests__/ocr-analysis.test.ts @@ -0,0 +1,51 @@ +import { + buildManualParams, + confidenceLabel, + hasAnyDetected, + ANALYSIS_INTERVAL_MS, + OCR_STABLE_READS, +} from '@/components/scan/ocr-analysis'; + +describe('ocr-analysis (logique pure extraite de camera.tsx)', () => { + describe('buildManualParams', () => { + it('remplit publication, numéro et année', () => { + expect( + buildManualParams({ publication: 'Picsou Magazine', issueNumber: 547, date: '2023' }), + ).toEqual({ publication: 'Picsou Magazine', issueNumber: '547', year: '2023' }); + }); + + it('ignore les champs absents', () => { + expect(buildManualParams({})).toEqual({}); + expect(buildManualParams({ publication: null })).toEqual({}); + }); + + it('n’ajoute pas issueNumber à 0 ou null', () => { + expect(buildManualParams({ issueNumber: 0 })).toEqual({ issueNumber: '0' }); + expect(buildManualParams({ issueNumber: null })).toEqual({}); + }); + }); + + describe('confidenceLabel', () => { + it('qualifie les seuils de confiance', () => { + expect(confidenceLabel(0.9)).toBe('élevée'); + expect(confidenceLabel(0.8)).toBe('élevée'); + expect(confidenceLabel(0.7)).toBe('moyenne'); + expect(confidenceLabel(0.5)).toBe('moyenne'); + expect(confidenceLabel(0.4)).toBe('faible'); + }); + }); + + describe('hasAnyDetected', () => { + it('détecte la présence d’au moins un champ', () => { + expect(hasAnyDetected({ publication: 'Picsou', issueNumber: null, date: null })).toBe(true); + expect(hasAnyDetected({ publication: null, issueNumber: 5, date: null })).toBe(true); + expect(hasAnyDetected({ publication: null, issueNumber: null, date: '2023' })).toBe(true); + expect(hasAnyDetected({ publication: null, issueNumber: null, date: null })).toBe(false); + }); + }); + + it('expose les constantes d’analyse OCR', () => { + expect(ANALYSIS_INTERVAL_MS).toBe(500); + expect(OCR_STABLE_READS).toBe(2); + }); +}); diff --git a/src/components/scan/barcode-overlay-controls.tsx b/src/components/scan/barcode-overlay-controls.tsx new file mode 100644 index 0000000..e08e4c0 --- /dev/null +++ b/src/components/scan/barcode-overlay-controls.tsx @@ -0,0 +1,100 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import type { ScanState } from './use-barcode-scanning'; +import type { BarcodeStyles } from './barcode-styles'; + +type Props = { + styles: BarcodeStyles; + state: ScanState; + showContinuousStart: boolean; + showCameraButtons: boolean; + torchOn: boolean; + onStartContinuous: () => void; + onBack: () => void; + onToggleTorch: () => void; + onRetry: () => void; +}; + +export function BarcodeOverlayControls({ + styles, + state, + showContinuousStart, + showCameraButtons, + torchOn, + onStartContinuous, + onBack, + onToggleTorch, + onRetry, +}: Props) { + return ( + <> + + + + {state.status === 'searching' ? ( + + ) : null} + + {state.status === 'searching' ? 'Recherche…' : 'Alignez le code-barres dans le cadre'} + + + {state.status === 'invalid' && ( + + {state.reason} + + )} + + + {showContinuousStart && ( + [styles.startContinuousButton, pressed && styles.buttonPressed]} + onPress={onStartContinuous} + testID="continuous-start" + accessibilityRole="button" + accessibilityLabel="Lancer le scan en continu"> + Scan en continu + + )} + + {showCameraButtons && ( + [styles.backButton, pressed && styles.buttonPressed]} + onPress={onBack} + testID="scan-back" + accessibilityRole="button" + accessibilityLabel="Annuler"> + + + )} + + {showCameraButtons && ( + [styles.torchButton, pressed && styles.buttonPressed]} + onPress={onToggleTorch} + testID="scan-torch" + accessibilityRole="button" + accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> + + + )} + + {state.status === 'invalid' && ( + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="invalid-retry" + accessibilityRole="button"> + Scanner à nouveau + + + )} + + ); +} diff --git a/src/components/scan/barcode-pending-sheets.tsx b/src/components/scan/barcode-pending-sheets.tsx new file mode 100644 index 0000000..7b90e00 --- /dev/null +++ b/src/components/scan/barcode-pending-sheets.tsx @@ -0,0 +1,136 @@ +import { Pressable, ScrollView, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { useThemeColors } from '@/hooks/use-theme'; +import type { Pending } from './use-barcode-scanning'; +import type { BarcodeStyles } from './barcode-styles'; + +type Props = { + styles: BarcodeStyles; + pending: Pending; + onResume: () => void; + onConfirmAdd: () => void; + onManual: (barcode: string) => void; +}; + +export function BarcodePendingSheets({ styles, pending, onResume, onConfirmAdd, onManual }: Props) { + const colors = useThemeColors(); + + if (pending.kind === 'confirm') { + return ( + + + + [styles.sheetClose, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-close" + accessibilityRole="button" + accessibilityLabel="Fermer"> + + + Vous possédez déjà ce magazine + + {pending.magazine.publication} + {pending.magazine.issueNumber != null ? ` n° ${pending.magazine.issueNumber}` : ''} + + + Exemplaires actuels : {pending.ownedCount}. Ajouter un exemplaire ? + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onConfirmAdd} + testID="pending-confirm-add" + accessibilityRole="button"> + Ajouter un exemplaire + + [styles.pendingCancel, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-confirm-cancel" + accessibilityRole="button"> + Annuler + + + + + ); + } + + if (pending.kind === 'success') { + return ( + + + + Ajouté à la collection + + {pending.publication} + {pending.issueNumber != null ? ` n° ${pending.issueNumber}` : ''} + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-success-ok" + accessibilityRole="button"> + Scanner le suivant + + + + + ); + } + + if (pending.kind === 'unknown') { + return ( + + + + Code-barres inconnu + {pending.barcode} + + Le scan seul ne crée pas l'édition. Saisissez-la manuellement. + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={() => onManual(pending.barcode)} + testID="pending-unknown-manual" + accessibilityRole="button"> + Saisir manuellement + + [styles.pendingCancel, pressed && styles.buttonPressed]} + onPress={onResume} + testID="pending-unknown-continue" + accessibilityRole="button"> + Continuer le scan + + + + + ); + } + + return null; +} + +export function BarcodeContinuousBar({ + styles, + onStop, +}: { + styles: BarcodeStyles; + onStop: () => void; +}) { + return ( + + Scan en continu — ajoute chaque exemplaire + [styles.continuousStop, pressed && styles.buttonPressed]} + onPress={onStop} + testID="continuous-stop" + accessibilityRole="button" + accessibilityLabel="Arrêter le scan en continu"> + Arrêter + + + ); +} diff --git a/src/components/scan/barcode-styles.ts b/src/components/scan/barcode-styles.ts new file mode 100644 index 0000000..fcaf792 --- /dev/null +++ b/src/components/scan/barcode-styles.ts @@ -0,0 +1,211 @@ +import { StyleSheet } from 'react-native'; + +import { Spacing, type ThemeColors } from '@/constants/theme'; + +export function makeBarcodeStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + }, + camera: { + flex: 1, + }, + reticle: { + width: 220, + height: 220, + borderWidth: 3, + borderColor: colors.accent, + borderRadius: 16, + backgroundColor: 'transparent', + }, + hintRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + marginTop: Spacing.three, + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + }, + scanHint: { + fontSize: 14, + lineHeight: 20, + color: '#FFFFFF', + textAlign: 'center', + }, + invalidText: { + marginTop: Spacing.two, + fontSize: 14, + color: '#FFD5D2', + textAlign: 'center', + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + overflow: 'hidden', + }, + invalidActions: { + position: 'absolute', + left: Spacing.four, + right: Spacing.four, + bottom: insets.bottom + 24, + }, + continuousBar: { + position: 'absolute', + top: insets.top + 12, + left: Spacing.three, + right: Spacing.three, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.7)', + borderRadius: 10, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + }, + continuousText: { + flex: 1, + fontSize: 13, + color: '#FFFFFF', + }, + continuousStop: { + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + backgroundColor: colors.danger, + }, + continuousStopText: { + fontSize: 13, + fontWeight: '700', + color: colors.onDanger, + }, + startContinuousButton: { + position: 'absolute', + left: Spacing.four, + right: Spacing.four, + bottom: insets.bottom + 80, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.65)', + paddingVertical: Spacing.two, + borderRadius: 10, + }, + startContinuousText: { + fontSize: 15, + fontWeight: '600', + color: '#FFFFFF', + }, + pendingCard: { + marginHorizontal: Spacing.four, + backgroundColor: colors.backgroundElement, + borderRadius: 14, + padding: Spacing.four, + gap: Spacing.two, + alignItems: 'center', + }, + sheetClose: { + alignSelf: 'flex-end', + minWidth: 44, + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + marginTop: -Spacing.three, + marginRight: -Spacing.three, + }, + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + sheetScroll: { + flexGrow: 1, + justifyContent: 'center', + }, + pendingTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + textAlign: 'center', + }, + pendingMagazine: { + fontSize: 17, + fontWeight: '600', + color: colors.accentTextOnLight, + textAlign: 'center', + }, + pendingMessage: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 20, + }, + pendingCancel: { + minHeight: 44, + justifyContent: 'center', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + }, + pendingCancelText: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + }, + primaryButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + alignSelf: 'stretch', + }, + primaryButtonText: { + fontSize: 16, + fontWeight: '700', + color: colors.accentText, + textAlign: 'center', + }, + buttonPressed: { + opacity: 0.8, + }, + backButton: { + position: 'absolute', + top: insets.top + 12, + left: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + torchButton: { + position: 'absolute', + top: insets.top + 12, + right: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + }); +} + +export type BarcodeStyles = ReturnType; diff --git a/src/components/scan/camera-ocr-styles.ts b/src/components/scan/camera-ocr-styles.ts new file mode 100644 index 0000000..28d3d34 --- /dev/null +++ b/src/components/scan/camera-ocr-styles.ts @@ -0,0 +1,262 @@ +import { StyleSheet } from 'react-native'; + +import { Spacing, type ThemeColors } from '@/constants/theme'; + +export function makeCameraOcrStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: Spacing.four, + }, + camera: { + flex: 1, + }, + reticle: { + width: 220, + height: 220, + borderWidth: 3, + borderColor: colors.accent, + borderRadius: 16, + backgroundColor: 'transparent', + }, + scanHint: { + marginTop: Spacing.three, + fontSize: 14, + color: '#FFFFFF', + textAlign: 'center', + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + overflow: 'hidden', + }, + processingPill: { + marginTop: Spacing.two, + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + backgroundColor: 'rgba(0,0,0,0.55)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 20, + }, + processingText: { + fontSize: 14, + color: '#FFFFFF', + }, + detectedBoard: { + alignSelf: 'stretch', + marginTop: Spacing.three, + backgroundColor: 'rgba(0,0,0,0.55)', + borderRadius: 12, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + gap: 6, + }, + detectedRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: Spacing.two, + }, + detectedLabel: { + fontSize: 13, + fontWeight: '700', + color: '#FFFFFF', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + detectedValue: { + fontSize: 14, + lineHeight: 20, + fontWeight: '600', + color: colors.accent, + }, + detectedValueEmpty: { + color: 'rgba(255,255,255,0.75)', + fontWeight: '400', + }, + resultCard: { + alignSelf: 'stretch', + marginHorizontal: Spacing.four, + marginTop: insets.top + Spacing.three, + marginBottom: insets.bottom + Spacing.three, + maxHeight: '85%', + backgroundColor: colors.backgroundElement, + borderRadius: 16, + padding: Spacing.four, + }, + resultCardContent: { + alignItems: 'center', + gap: Spacing.three, + }, + mutedTitle: { + fontSize: 14, + fontWeight: '700', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 1, + }, + magazine: { + fontSize: 20, + fontWeight: '700', + color: colors.text, + textAlign: 'center', + }, + issue: { + fontSize: 16, + color: colors.text, + }, + date: { + fontSize: 14, + color: colors.textSecondary, + }, + confidence: { + fontSize: 14, + color: colors.textSecondary, + }, + message: { + fontSize: 15, + color: colors.text, + textAlign: 'center', + lineHeight: 22, + }, + fieldWrap: { + alignSelf: 'stretch', + gap: 6, + }, + fieldLabel: { + fontSize: 13, + fontWeight: '700', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + input: { + alignSelf: 'stretch', + backgroundColor: colors.background, + borderRadius: 10, + paddingVertical: Spacing.three, + paddingHorizontal: Spacing.three, + fontSize: 16, + color: colors.text, + }, + primaryButton: { + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + paddingVertical: Spacing.three, + borderRadius: 12, + marginTop: Spacing.two, + }, + primaryButtonText: { + fontSize: 16, + fontWeight: '700', + color: colors.accentText, + textAlign: 'center', + }, + secondaryButton: { + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.backgroundElement, + paddingVertical: Spacing.three, + borderRadius: 12, + marginHorizontal: Spacing.four, + marginTop: Spacing.two, + marginBottom: insets.bottom + Spacing.three, + }, + secondaryButtonText: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + textAlign: 'center', + }, + buttonPressed: { + opacity: 0.8, + }, + cancelButton: { + marginTop: Spacing.three, + alignSelf: 'center', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + }, + cancelButtonText: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + }, + backButton: { + position: 'absolute', + top: insets.top + 12, + left: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + torchButton: { + position: 'absolute', + top: insets.top + 12, + right: Spacing.three, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + guidanceCard: { + position: 'absolute', + left: Spacing.four, + right: Spacing.four, + bottom: insets.bottom + Spacing.three, + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: Spacing.two, + backgroundColor: 'rgba(0,0,0,0.75)', + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + }, + guidanceText: { + flex: 1, + fontSize: 13, + color: '#FFFFFF', + lineHeight: 18, + }, + guidanceActions: { + flexDirection: 'row', + gap: Spacing.two, + width: '100%', + }, + guidanceButton: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + paddingVertical: Spacing.two, + borderRadius: 8, + }, + guidanceButtonText: { + fontSize: 13, + fontWeight: '700', + color: colors.accentText, + }, + }); +} + +export type CameraOcrStyles = ReturnType; diff --git a/src/components/scan/ocr-analysis.ts b/src/components/scan/ocr-analysis.ts new file mode 100644 index 0000000..cd1425a --- /dev/null +++ b/src/components/scan/ocr-analysis.ts @@ -0,0 +1,73 @@ +import { MIN_CONFIDENCE } from '@/identification/ocr/ocrTextParser'; + +/** + * Intervalle d'analyse OCR : quelques frames / seconde max (pas toutes). + */ +export const ANALYSIS_INTERVAL_MS = 500; + +/** + * Lectures OCR identiques requises avant de conclure une identification + * (M10R2-09 — vote multi-frames conservateur, une lecture isolée pouvant être + * erronée sur des textes stylisés / à encres faibles). + */ +export const OCR_STABLE_READS = 2; + +/** + * Informations détectées par l'OCR, affichées en surcouche caméra (US-ID-08) + * et proposées à la validation / correction (US-ID-09). + */ +export type DetectedInfo = { + publication: string | null; + issueNumber: number | null; + date: string | null; +}; + +export const EMPTY_DETECTED: DetectedInfo = { publication: null, issueNumber: null, date: null }; + +export type OcrUiState = + | { status: 'analyzing'; detected: DetectedInfo } + | { status: 'confirm'; detected: DetectedInfo } + | { + status: 'found'; + id: string; + publication: string; + issueNumber: number | null; + date: string | null; + confidence: number; + } + | { + status: 'unknown'; + publication: string; + issueNumber: number | null; + date: string | null; + confidence: number; + }; + +export function hasAnyDetected(detected: DetectedInfo): boolean { + return detected.publication !== null || detected.issueNumber !== null || detected.date !== null; +} + +/** Construit les paramètres de navigation vers la saisie manuelle pré-remplie. */ +export function buildManualParams(detected: Partial): Record { + const params: Record = {}; + if (detected.publication) { + params.publication = detected.publication; + } + if (detected.issueNumber != null) { + params.issueNumber = String(detected.issueNumber); + } + if (detected.date) { + params.year = detected.date; + } + return params; +} + +export function confidenceLabel(confidence: number): string { + if (confidence >= 0.8) { + return 'élevée'; + } + if (confidence >= MIN_CONFIDENCE) { + return 'moyenne'; + } + return 'faible'; +} diff --git a/src/components/scan/ocr-analyzing-overlay.tsx b/src/components/scan/ocr-analyzing-overlay.tsx new file mode 100644 index 0000000..d18270e --- /dev/null +++ b/src/components/scan/ocr-analyzing-overlay.tsx @@ -0,0 +1,144 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { useThemeColors } from '@/hooks/use-theme'; +import { hasAnyDetected, type DetectedInfo } from './ocr-analysis'; +import type { CameraOcrStyles } from './camera-ocr-styles'; + +type Props = { + styles: CameraOcrStyles; + detected: DetectedInfo; + hint: string; + weakCycles: number; + torchOn: boolean; + onOpenConfirm: () => void; + onGoBarcode: () => void; + onGoManual: (detected: Partial) => void; + onBack: () => void; + onToggleTorch: () => void; +}; + +export function OcrAnalyzingOverlay({ + styles, + detected, + hint, + weakCycles, + torchOn, + onOpenConfirm, + onGoBarcode, + onGoManual, + onBack, + onToggleTorch, +}: Props) { + const colors = useThemeColors(); + + return ( + <> + + + + {hint} + + + + Lecture… + + + {/* Surcouche US-ID-08 : champs détectés en direct. */} + + + Nom + + {detected.publication ?? '…'} + + + + Numéro + + {detected.issueNumber?.toString() ?? '…'} + + + + Édition / date + + {detected.date ?? '…'} + + + + + {hasAnyDetected(detected) && ( + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onOpenConfirm} + testID="ocr-confirm-detected" + accessibilityRole="button"> + Valider ces informations détectées + + )} + + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onGoBarcode} + testID="ocr-barcode" + accessibilityRole="button"> + Scanner le code-barres + + + [styles.backButton, pressed && styles.buttonPressed]} + onPress={onBack} + testID="ocr-back" + accessibilityRole="button" + accessibilityLabel="Annuler"> + + + + [styles.torchButton, pressed && styles.buttonPressed]} + onPress={onToggleTorch} + testID="ocr-torch" + accessibilityRole="button" + accessibilityLabel={torchOn ? 'Désactiver la torche' : 'Activer la torche'}> + + + + {weakCycles > 3 && detected?.publication && detected?.issueNumber === null && ( + + + + Le texte stylisé est difficile à lire automatiquement. + + + [styles.guidanceButton, pressed && styles.buttonPressed]} + onPress={onGoBarcode} + testID="ocr-guidance-barcode" + accessibilityRole="button"> + Code-barres + + [styles.guidanceButton, pressed && styles.buttonPressed]} + onPress={() => onGoManual(detected)} + testID="ocr-guidance-manual" + accessibilityRole="button"> + Saisie manuelle + + + + )} + + ); +} diff --git a/src/components/scan/ocr-confirm-overlay.tsx b/src/components/scan/ocr-confirm-overlay.tsx new file mode 100644 index 0000000..74ddc08 --- /dev/null +++ b/src/components/scan/ocr-confirm-overlay.tsx @@ -0,0 +1,104 @@ +import { Pressable, Text, TextInput, View } from 'react-native'; + +import { useThemeColors } from '@/hooks/use-theme'; +import type { DetectedInfo } from './ocr-analysis'; +import type { CameraOcrStyles } from './camera-ocr-styles'; + +type Props = { + styles: CameraOcrStyles; + draft: DetectedInfo; + onDraftChange: (draft: DetectedInfo) => void; + onSearch: () => void; + onGoManual: () => void; + onBack: () => void; +}; + +export function OcrConfirmOverlay({ + styles, + draft, + onDraftChange, + onSearch, + onGoManual, + onBack, +}: Props) { + const colors = useThemeColors(); + + return ( + + + Vérifier les informations + + Corrigez les informations détectées puis validez la recherche, même si la confiance était + insuffisante. + + + + Nom + onDraftChange({ ...draft, publication: t })} + placeholder="Publication du magazine" + placeholderTextColor={colors.textSecondary} + testID="ocr-override-publication" + /> + + + + Numéro + { + const digits = t.replace(/[^0-9]/g, ''); + onDraftChange({ ...draft, issueNumber: digits ? Number(digits) : null }); + }} + placeholder="N° du magazine" + keyboardType="number-pad" + returnKeyType="done" + placeholderTextColor={colors.textSecondary} + testID="ocr-override-issue" + accessibilityLabel="Numéro du magazine (chiffres uniquement)" + /> + + + + Année + onDraftChange({ ...draft, date: t })} + placeholder="Année / date (optionnel)" + keyboardType="default" + placeholderTextColor={colors.textSecondary} + testID="ocr-override-date" + /> + + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onSearch} + testID="ocr-override-search" + accessibilityRole="button"> + Rechercher + + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-override-manual" + accessibilityRole="button"> + Saisir manuellement + + + [styles.cancelButton, pressed && styles.buttonPressed]} + onPress={onBack} + testID="ocr-override-back" + accessibilityRole="button"> + Retour à la caméra + + + + ); +} diff --git a/src/components/scan/ocr-result-overlay.tsx b/src/components/scan/ocr-result-overlay.tsx new file mode 100644 index 0000000..ae3fdd2 --- /dev/null +++ b/src/components/scan/ocr-result-overlay.tsx @@ -0,0 +1,94 @@ +import { Pressable, ScrollView, Text, View } from 'react-native'; + +import { confidenceLabel, type OcrUiState } from './ocr-analysis'; +import type { CameraOcrStyles } from './camera-ocr-styles'; + +type FoundState = Extract; +type UnknownState = Extract; + +type Props = { + styles: CameraOcrStyles; + state: FoundState | UnknownState; + onConfirm?: () => void; + onRetry: () => void; + onGoManual: () => void; +}; + +export function OcrResultOverlay({ styles, state, onConfirm, onRetry, onGoManual }: Props) { + if (state.status === 'found') { + return ( + + + Couverture reconnue + + {state.publication} + + {state.issueNumber != null && N° {state.issueNumber}} + {state.date && {state.date}} + + Confiance : {confidenceLabel(state.confidence)} + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onConfirm} + testID="ocr-confirm" + accessibilityRole="button"> + Confirmer + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="ocr-retry" + accessibilityRole="button"> + Réessayer + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-manual" + accessibilityRole="button"> + Saisie manuelle + + + + ); + } + + if (state.status === 'unknown') { + return ( + + + Non trouvé en collection + {state.publication} + {state.issueNumber != null && N° {state.issueNumber}} + + {state.publication} n'est pas encore référencé. Vous pouvez le saisir manuellement + pour le créer. + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-manual" + accessibilityRole="button"> + Saisir manuellement + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="ocr-retry" + accessibilityRole="button"> + Réessayer avec la caméra + + + + ); + } + + return null; +} diff --git a/src/components/scan/use-barcode-scanning.ts b/src/components/scan/use-barcode-scanning.ts new file mode 100644 index 0000000..59d5e63 --- /dev/null +++ b/src/components/scan/use-barcode-scanning.ts @@ -0,0 +1,144 @@ +import { useCameraPermissions } from 'expo-camera'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useRef, useState } from 'react'; + +import { getDeps } from '@/dependencies'; +import { BarcodeStabilizer } from '@/identification/barcodeStabilizer'; +import { useCollectionStore } from '@/store/use-collection-store'; +import type { Magazine } from '@/types'; + +export type ScanState = + { status: 'idle' } | { status: 'searching' } | { status: 'invalid'; reason: string }; + +export type Pending = + | { kind: 'confirm'; magazine: Magazine; ownedCount: number } + | { kind: 'success'; publication: string; issueNumber: number | null } + | { kind: 'unknown'; barcode: string }; + +/** + * Logique du scan de code-barres (stabilisation multi-lectures, identification, + * mode continu) découplée de la vue pour la rendre testable. + */ +export function useBarcodeScanning() { + const router = useRouter(); + const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); + const params = useLocalSearchParams<{ continuous?: string }>(); + + const [permission, requestPermission] = useCameraPermissions(); + const [state, setState] = useState({ status: 'idle' }); + const [scanning, setScanning] = useState(true); + const [continuous, setContinuous] = useState(params.continuous === '1'); + const [torchOn, setTorchOn] = useState(false); + const [pending, setPending] = useState(null); + const stabilizer = useRef(new BarcodeStabilizer(3)); + + const resume = () => { + stabilizer.current.reset(); + setPending(null); + setScanning(true); + setState({ status: 'idle' }); + }; + + const handleSingle = async (stabilized: string) => { + const { identificationService } = getDeps(); + const result = await identificationService.identifyByBarcode(stabilized); + + if (result.status === 'found') { + router.replace({ + pathname: '/scan/result', + params: { + id: result.magazine.id, + publication: result.magazine.publication, + issueNumber: + result.magazine.issueNumber != null ? String(result.magazine.issueNumber) : '', + barcode: stabilized, + }, + }); + } else if (result.status === 'ambiguous') { + router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); + } else if (result.status === 'unknown') { + router.replace({ pathname: '/scan/result', params: { barcode: stabilized } }); + } else { + setState({ status: 'invalid', reason: result.reason }); + setScanning(true); + } + }; + + const handleContinuous = async (stabilized: string) => { + const { identificationService, collectionRepository } = getDeps(); + const result = await identificationService.identifyByBarcode(stabilized); + + if (result.status === 'found') { + const ownedCount = await collectionRepository.countByMagazine(result.magazine.id); + if (ownedCount > 0) { + setPending({ kind: 'confirm', magazine: result.magazine, ownedCount }); + } else { + await addExistingCopy(result.magazine.id); + setPending({ + kind: 'success', + publication: result.magazine.publication, + issueNumber: result.magazine.issueNumber, + }); + } + } else if (result.status === 'ambiguous') { + router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); + } else if (result.status === 'unknown') { + setPending({ kind: 'unknown', barcode: stabilized }); + } else { + setState({ status: 'invalid', reason: result.reason }); + setScanning(true); + } + }; + + const handleScan = async ({ data }: { data: string; type: string }) => { + if (!scanning || state.status === 'searching' || pending) { + return; + } + const stabilized = stabilizer.current.push(data); + if (stabilized === null) { + return; + } + setScanning(false); + setState({ status: 'searching' }); + + if (continuous) { + await handleContinuous(stabilized); + } else { + await handleSingle(stabilized); + } + }; + + const reset = () => { + stabilizer.current.reset(); + setScanning(true); + setState({ status: 'idle' }); + }; + + const confirmAdd = async () => { + if (pending?.kind !== 'confirm') { + return; + } + await addExistingCopy(pending.magazine.id); + setPending({ + kind: 'success', + publication: pending.magazine.publication, + issueNumber: pending.magazine.issueNumber, + }); + }; + + return { + permission, + requestPermission, + state, + scanning, + continuous, + torchOn, + pending, + setContinuous, + setTorchOn, + handleScan, + resume, + reset, + confirmAdd, + }; +} diff --git a/src/components/scan/use-ocr-analysis.ts b/src/components/scan/use-ocr-analysis.ts new file mode 100644 index 0000000..6cd04be --- /dev/null +++ b/src/components/scan/use-ocr-analysis.ts @@ -0,0 +1,204 @@ +import { useCameraPermissions } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import type { CameraView as CameraViewType } from 'expo-camera'; + +import { getDeps } from '@/dependencies'; +import { OcrTextStabilizer } from '@/identification/ocr/ocrTextStabilizer'; +import { + ANALYSIS_INTERVAL_MS, + buildManualParams, + EMPTY_DETECTED, + OCR_STABLE_READS, + type DetectedInfo, + type OcrUiState, +} from './ocr-analysis'; + +/** + * Logique d'analyse OCR d'une couverture (détection périodique, vote multi-frames, + * surcouche de validation / correction, recherche hors confiance). + * + * Expose l'état d'interface OCR et les actions associées de façon découplée de + * la vue, afin de la rendre testable. + */ +export function useOcrAnalysis() { + const router = useRouter(); + const [permission, requestPermission] = useCameraPermissions(); + const [state, setState] = useState({ + status: 'analyzing', + detected: EMPTY_DETECTED, + }); + const [draft, setDraft] = useState(EMPTY_DETECTED); + const [torchOn, setTorchOn] = useState(false); + const [weakCycles, setWeakCycles] = useState(0); + const inFlight = useRef(false); + const cameraRef = useRef(null); + const ocrStabilizer = useRef(new OcrTextStabilizer(OCR_STABLE_READS)); + + useEffect(() => { + if (!permission?.granted || state.status !== 'analyzing') { + return; + } + + const { ocrEngine, identificationService } = getDeps(); + const interval = setInterval(async () => { + if (inFlight.current) { + return; + } + inFlight.current = true; + try { + // Capture éphémère d'une photo (aucune image persistée) → URI. + // Haute résolution (M10R2-09) : préserve les petites encres des textes stylisés. + const photo = await cameraRef.current?.takePictureAsync?.({ + quality: 1, + skipProcessing: false, + }); + const uri = photo?.uri ?? null; + const frame = await ocrEngine.recognize({ native: uri, width: 0, height: 0 }); + if (!frame) { + return; + } + const result = await identificationService.identifyByOCR(frame.text); + + if (result.status === 'no-text') { + return; + } + + if (result.status === 'weak') { + // US-ID-08 : on ne conclut plus en échec dès la première lecture partielle. + // On met en surcouche les champs détectés et on continue d'analyser + // (le pointeur guide l'utilisateur vers le champ manquant). + setWeakCycles((c) => c + 1); + setState((prev) => + prev.status === 'analyzing' + ? { + status: 'analyzing', + detected: { + publication: + result.publication === 'Publication inconnue' ? null : result.publication, + issueNumber: result.issueNumber, + date: result.date, + }, + } + : prev, + ); + return; + } + + // Vote multi-frames (M10R2-09) : on ne conclut pas sur une lecture + // isolée, une frame suivante identique est requise (textes stylisés). + const key = [ + result.publication ?? '', + result.issueNumber != null ? String(result.issueNumber) : '', + result.date ?? '', + ].join('|'); + if (!ocrStabilizer.current.push(key)) { + return; + } + ocrStabilizer.current.reset(); + + if (result.status === 'unknown') { + setState({ + status: 'unknown', + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + return; + } + + setState({ + status: 'found', + id: result.magazine.id, + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + } finally { + inFlight.current = false; + } + }, ANALYSIS_INTERVAL_MS); + + return () => clearInterval(interval); + }, [permission?.granted, state.status]); + + const stopAndRetry = () => { + ocrStabilizer.current.reset(); + setWeakCycles(0); + setState({ status: 'analyzing', detected: EMPTY_DETECTED }); + }; + + const openConfirm = () => { + const detected = + state.status === 'analyzing' || state.status === 'confirm' ? state.detected : EMPTY_DETECTED; + setDraft(detected); + setState({ status: 'confirm', detected }); + }; + + const goManual = (detected: Partial) => { + router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); + }; + + const goBarcode = () => { + router.replace('/scan/barcode'); + }; + + /** US-ID-09 : recherche en outrepassant la confiance (champs validés/corrigés). */ + const searchFromDraft = async () => { + const publication = draft.publication?.trim() ?? ''; + const rawNumber = draft.issueNumber?.toString().trim() ?? ''; + const issueNumber = rawNumber ? Number(rawNumber) : null; + const date = draft.date?.trim() || null; + + if (!publication || issueNumber === null || !Number.isFinite(issueNumber)) { + // Impossible de rechercher : on oriente vers la saisie manuelle pré-remplie. + goManual({ publication: publication || undefined, issueNumber, date }); + return; + } + + const { identificationService } = getDeps(); + const result = await identificationService.searchByOcrFields(publication, issueNumber, date); + + if (result.status === 'weak' || result.status === 'no-text') { + goManual({ publication, issueNumber, date }); + return; + } + if (result.status === 'unknown') { + setState({ + status: 'unknown', + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + return; + } + setState({ + status: 'found', + id: result.magazine.id, + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + }; + + return { + cameraRef, + permission, + requestPermission, + state, + draft, + torchOn, + weakCycles, + setTorchOn, + setDraft, + stopAndRetry, + openConfirm, + goManual, + goBarcode, + searchFromDraft, + }; +} From 384f208d0d769ef3f75dc75b564b7f1aaadaa811 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 11:21:05 +0200 Subject: [PATCH 10/34] style: format magazine-form.tsx (fix CI) --- src/components/magazine-form.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/magazine-form.tsx b/src/components/magazine-form.tsx index 968678d..d20397c 100644 --- a/src/components/magazine-form.tsx +++ b/src/components/magazine-form.tsx @@ -19,9 +19,27 @@ import type { CreateMagazineInput, Magazine } from '@/types'; import { makeMagazineFormStyles } from './magazine-form-styles'; import { useMagazineForm, type FormValues, type MagazineFormHandle } from './use-magazine-form'; -export { publicationDateFrom, buildMagazineInput, initialFormValues, MagazineFormHandle } from './use-magazine-form'; +export { + publicationDateFrom, + buildMagazineInput, + initialFormValues, + MagazineFormHandle, +} from './use-magazine-form'; -const MONTHS_OPTIONS = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'] as const; +const MONTHS_OPTIONS = [ + '01', + '02', + '03', + '04', + '05', + '06', + '07', + '08', + '09', + '10', + '11', + '12', +] as const; /** Années dynamiques : année courante → -39 ans (M10-08, fini `2025` en dur). */ const YEARS = Array.from({ length: 40 }, (_, i) => String(new Date().getFullYear() - i)); From d65e1767fb0115f5713f81d36b59b0b0fcbea918 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 12:22:43 +0200 Subject: [PATCH 11/34] =?UTF-8?q?fix(m11-05):=20import/export=20backup=20f?= =?UTF-8?q?iable=20(variantes,=20fant=C3=B4mes,=20doublons,=20dates,=20tai?= =?UTF-8?q?lle,=20historique)=20(M-11-#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E1 : pas de copie fantôme quand copyNotes et dateAdded sont vides - E2 : clé d'édition publication::issueNumber::edition::language::condition - M9 : rejet des IDs dupliqués et des valeurs invalides (fractionnaire, dates ISO) - M3 : borne d'import (25 Mo) côté service et sélecteur de fichier - M10 : export/import de createdAt/updatedAt (JSON + CSV rétro-compatible) - mineures : trim des champs texte, support CRLF et retour-chariot seul --- src/__tests__/app/settings-screen.test.tsx | 2 +- src/backup/__tests__/backup-service.test.ts | 183 ++++++++++++++++++- src/backup/backup-csv.ts | 46 +++-- src/backup/backup-parsers.ts | 45 ++++- src/backup/backup-service.ts | 21 ++- src/backup/backup-types.ts | 2 + src/backup/native-file-gateway.ts | 7 + src/store/__tests__/use-backup-store.test.ts | 2 +- 8 files changed, 277 insertions(+), 31 deletions(-) diff --git a/src/__tests__/app/settings-screen.test.tsx b/src/__tests__/app/settings-screen.test.tsx index d632771..4f67769 100644 --- a/src/__tests__/app/settings-screen.test.tsx +++ b/src/__tests__/app/settings-screen.test.tsx @@ -170,7 +170,7 @@ describe('BackupScreen — Sauvegarde', () => { await waitFor(() => expect(writeExport).toHaveBeenCalledTimes(1)); expect(writeExport.mock.calls[0][1]).toBe('csv'); expect(writeExport.mock.calls[0][0]).toMatch( - /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\n/, + /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt\n/, ); }); diff --git a/src/backup/__tests__/backup-service.test.ts b/src/backup/__tests__/backup-service.test.ts index 4803077..9ebf850 100644 --- a/src/backup/__tests__/backup-service.test.ts +++ b/src/backup/__tests__/backup-service.test.ts @@ -119,7 +119,15 @@ describe('BackupService.importCollection', () => { const before = await service.exportCollection(); const file = await service.exportCollection(); - file.magazines.push({ ...file.magazines[0], id: 'edition-soumise-a-echec' }); + const duplicate = { + ...file.magazines[0], + id: 'edition-soumise-a-echec', + copies: file.magazines[0].copies.map((copy) => ({ + ...copy, + id: `copie-${copy.id}`, + })), + }; + file.magazines.push(duplicate); // On force un échec déterministe lors de la 2e insertion d'édition, sans // dépendre du comportement de la contrainte de clé primaire (flaky en CI). @@ -143,6 +151,20 @@ describe('BackupService.importCollection', () => { expect(after.magazines).toEqual(before.magazines); expect(after.magazines[0].copies).toEqual(before.magazines[0].copies); }); + + it('préserve l’historique (createdAt/updatedAt) au round-trip', async () => { + await seedCollection(); + const source = await service.exportCollection(); + const original = source.magazines[0]; + original.createdAt = '2020-03-01T10:00:00.000Z'; + original.updatedAt = '2021-05-10T10:00:00.000Z'; + + await service.importCollection(service.toJson(source)); + + const after = await service.exportCollection(); + expect(after.magazines[0].createdAt).toBe('2020-03-01T10:00:00.000Z'); + expect(after.magazines[0].updatedAt).toBe('2021-05-10T10:00:00.000Z'); + }); }); describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => { @@ -189,6 +211,88 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => const after = await service.exportCollection(); expect(after.magazines).toEqual(before.magazines); }); + + it('rejette des identifiants d’édition dupliqués', async () => { + const raw = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + magazines: [ + { id: 'dup', publication: 'P', issueNumber: 1, copies: [] }, + { id: 'dup', publication: 'P', issueNumber: 2, copies: [] }, + ], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'Fichier invalide : des identifiants d’édition sont dupliqués.', + ); + }); + + it('rejette des identifiants d’exemplaire dupliqués', async () => { + const raw = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + magazines: [ + { + id: 'a', + publication: 'P', + copies: [{ id: 'c', notes: null, dateAdded: '2026-09-01T00:00:00Z' }], + }, + { + id: 'b', + publication: 'P', + copies: [{ id: 'c', notes: null, dateAdded: '2026-09-01T00:00:00Z' }], + }, + ], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'Fichier invalide : des identifiants d’exemplaire sont dupliqués.', + ); + }); + + it('rejette un numéro d’édition fractionnaire', async () => { + const raw = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + magazines: [{ id: 'a', publication: 'P', issueNumber: 1.5, copies: [] }], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'Fichier invalide : le numéro d’édition doit être un entier', + ); + }); + + it('rejette une date d’exemplaire ou d’export non conforme', async () => { + const badDate = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: '2026-09-01T00:00:00Z', + magazines: [ + { + id: 'a', + publication: 'P', + copies: [{ id: 'c', notes: null, dateAdded: 'pas-une-date' }], + }, + ], + }); + await expect(service.importCollection(badDate)).rejects.toBeInstanceOf(InvalidBackupError); + + const badExportedAt = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: 'hier', + magazines: [], + }); + await expect(service.importCollection(badExportedAt)).rejects.toBeInstanceOf( + InvalidBackupError, + ); + }); + + it('rejette un import dépassant la borne de taille', async () => { + const oversized = new BackupService(testDb, 100); + const raw = `publication,issueNumber,copyNotes,dateAdded\nPicsou,${'1'.repeat(200)},,\n`; + + await expect(oversized.importCollection(raw, 'csv')).rejects.toThrow( + 'Fichier invalide : import dépassant la taille maximale de 100 octets.', + ); + }); }); describe('BackupService.exportCollection.toCsv', () => { @@ -200,7 +304,7 @@ describe('BackupService.exportCollection.toCsv', () => { const lines = csv.trim().split('\n'); expect(lines[0]).toBe( - 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded', + 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt', ); expect(lines).toHaveLength(3); expect(lines[1]).toContain('Picsou Magazine,547,standard,FR,good,2023-03,3271234567890'); @@ -229,6 +333,20 @@ describe('BackupService.exportCollection.toCsv', () => { expect(lines[1]).toContain('Vacances,12,'); expect(lines[1]).toContain(',,,,'); }); + + it('round-trip CSV : une édition sans copie n’est pas réimportée en exemplaire fantôme', async () => { + await magazineRepo.create({ publication: 'Vacances', issueNumber: 12 }); + + const file = await service.exportCollection(); + const summary = await service.importCollection(service.toCsv(file), 'csv'); + + expect(summary).toEqual({ magazines: 1, copies: 0 }); + + const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(1); + expect(after.magazines[0].publication).toBe('Vacances'); + expect(after.magazines[0].copies).toHaveLength(0); + }); }); describe('BackupService CSV import (US-BK-05)', () => { @@ -286,6 +404,37 @@ describe('BackupService CSV import (US-BK-05)', () => { expect(after.magazines[0].notes).toBe('note, suite \n ligne 2'); expect(after.magazines[0].copies[0].notes).toBe('Acheté 0,50 €'); }); + + it('préserve un CRLF à l’intérieur d’un champ entre guillemets', async () => { + const header = + 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded'; + const row = [ + '"Retour\nChamp"', + '5', + '', + '', + '', + '', + '', + '', + '', + '"note\r\nmulti"', + '2026-09-01T00:00:00Z', + ].join(','); + const csv = `${header}\n${row}\n`; + await service.importCollection(csv, 'csv'); + const after = await service.exportCollection(); + expect(after.magazines[0].publication).toBe('Retour\nChamp'); + expect(after.magazines[0].copies[0].notes).toBe('note\r\nmulti'); + }); + + it('gère des retours chariot nus (\r) comme séparateurs de ligne', async () => { + const csv = + `publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\r` + + `Picsou,5,,,,,,,,,2026-09-01T00:00:00Z\rDoublon,6,,,,,,,,,2026-09-02T00:00:00Z\r`; + const summary = await service.importCollection(csv, 'csv'); + expect(summary).toEqual({ magazines: 2, copies: 2 }); + }); }); describe('BackupService — validation de structure (JSON)', () => { @@ -346,7 +495,10 @@ describe('BackupService — CSV cas limites', () => { }); it('ignore les lignes vides en fin de fichier', async () => { - const summary = await service.validateCollection(`${headers}\nPicsou,5,,,,,,,,,\n\n\n`, 'csv'); + const summary = await service.validateCollection( + `${headers}\nPicsou,5,,,,,,,,,2026-09-01T00:00:00Z\n\n\n`, + 'csv', + ); expect(summary).toEqual({ magazines: 1, copies: 1 }); }); @@ -357,22 +509,35 @@ describe('BackupService — CSV cas limites', () => { expect(after.magazines[0].copies[0].notes).toBe('il a dit "OK"'); }); - it('préserve les espaces autour des champs texte non vides', async () => { + it('normalise les espaces autour des champs texte', async () => { const csv = `${headers}\nPicsou,5, edition , FR ,,2020-01,,, ,,\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); - expect(after.magazines[0].edition).toBe(' edition '); - expect(after.magazines[0].language).toBe(' FR '); + expect(after.magazines[0].edition).toBe('edition'); + expect(after.magazines[0].language).toBe('FR'); expect(after.magazines[0].notes).toBeNull(); + expect(after.magazines[0].copies).toHaveLength(0); }); - it('assigne un statut et une date par défaut aux champs exemplaire vides', async () => { + it('n’importe pas d’exemplaire fantôme pour une ligne aux champs exemplaire vides', async () => { const csv = `${headers}\nPicsou,5,,,,,,,,,\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(1); + expect(after.magazines[0].copies).toHaveLength(0); + }); + + it('préserve les variantes d’édition (edition/language/condition) à l’import CSV', async () => { + const csv = + `${headers}\nPicsou,5,FR,,,,,,,,2026-09-01T00:00:00Z\n` + + `Picsou,5,BE,,,,,,,,2026-09-01T00:00:00Z\n`; + await service.importCollection(csv, 'csv'); + const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(2); + const editions = after.magazines.map((m) => m.edition).sort(); + expect(editions).toEqual(['BE', 'FR']); expect(after.magazines[0].copies).toHaveLength(1); - expect(after.magazines[0].copies[0].notes).toBeNull(); - expect(after.magazines[0].copies[0].dateAdded).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(after.magazines[1].copies).toHaveLength(1); }); }); diff --git a/src/backup/backup-csv.ts b/src/backup/backup-csv.ts index a5f0d7a..816b4b6 100644 --- a/src/backup/backup-csv.ts +++ b/src/backup/backup-csv.ts @@ -65,16 +65,16 @@ export function parseCsv(raw: string): string[][] { index += 1; continue; } - if (char === '\n') { + if (char === '\n' || char === '\r') { row.push(field); rows.push(row); row = []; field = ''; index += 1; - continue; - } - if (char === '\r') { - index += 1; + // '\r\n' (Windows), '\r' seul (Mac), '\n' seul : tous terminent une ligne. + if (char === '\r' && text[index] === '\n') { + index += 1; + } continue; } field += char; @@ -89,7 +89,7 @@ export function parseCsv(raw: string): string[][] { function asCsvString(value: string | undefined): string | null { const trimmed = (value ?? '').trim(); - return trimmed.length > 0 ? (value ?? '') : null; + return trimmed.length > 0 ? trimmed : null; } export function parseCsvBackup(raw: string): BackupFile { @@ -131,21 +131,31 @@ export function parseCsvBackup(raw: string): BackupFile { issueNumber = Number(issueRaw); } - const key = `${publication}::${issueNumber ?? ''}`; + const edition = asCsvString(at('edition')); + const language = asCsvString(at('language')); + const condition = asCsvString(at('condition')); + + // Clé d'édition complète : deux lignes ne définissent la même édition que si + // tous les champs métier coïncident. Préserve les variantes (FR vs BE, + // numéros à `issueNumber` null distincts par état/édition) au lieu de + // collapse la 1ère ligne seulement. + const key = `${publication}::${issueNumber ?? ''}::${edition ?? ''}::${language ?? ''}::${condition ?? ''}`; let magazine = byKey.get(key); if (!magazine) { magazine = { id: generateId(), publication, issueNumber, - edition: asCsvString(at('edition')), - language: asCsvString(at('language')), - condition: asCsvString(at('condition')), + edition, + language, + condition, publicationDate: asCsvString(at('publicationDate')), barcode: asCsvString(at('barcode')), notes: asCsvString(at('notes')), ocrText: asCsvString(at('ocrText')), copies: [], + createdAt: asCsvString(at('createdAt')), + updatedAt: asCsvString(at('updatedAt')), }; byKey.set(key, magazine); magazines.push(magazine); @@ -153,6 +163,12 @@ export function parseCsvBackup(raw: string): BackupFile { const copyNotes = at('copyNotes').trim(); const dateAdded = at('dateAdded').trim(); + // Une ligne aux champs exemplaire vides désigne une édition **sans copie** + // (l'export `toCsv` émet une telle ligne pour la conserver). Ne pas la + // réimporter comme un exemplaire fantôme daté d'aujourd'hui. + if (copyNotes.length === 0 && dateAdded.length === 0) { + continue; + } const copy: BackupCopy = { id: generateId(), notes: copyNotes.length > 0 ? copyNotes : null, @@ -171,7 +187,7 @@ export function parseCsvBackup(raw: string): BackupFile { } export function toCsv(file: BackupFile): string { - const header = BACKUP_CSV_HEADERS.join(','); + const header = [...BACKUP_CSV_HEADERS, 'createdAt', 'updatedAt'].join(','); const lines: string[] = []; for (const magazine of file.magazines) { const base = [ @@ -191,7 +207,13 @@ export function toCsv(file: BackupFile): string { : [{ id: magazine.id, notes: null, dateAdded: '' }]; for (const copy of copies) { lines.push( - [...base, escapeCsvField(copy.notes ?? ''), escapeCsvField(copy.dateAdded)].join(','), + [ + ...base, + escapeCsvField(copy.notes ?? ''), + escapeCsvField(copy.dateAdded), + escapeCsvField(magazine.createdAt ?? ''), + escapeCsvField(magazine.updatedAt ?? ''), + ].join(','), ); } } diff --git a/src/backup/backup-parsers.ts b/src/backup/backup-parsers.ts index e6a2d6a..cc58d0b 100644 --- a/src/backup/backup-parsers.ts +++ b/src/backup/backup-parsers.ts @@ -35,6 +35,28 @@ function asNullableNumber(value: unknown): number | null { return value; } +function asNullableInteger(value: unknown): number | null { + const number = asNullableNumber(value); + if (number !== null && !Number.isInteger(number)) { + throw new InvalidBackupError( + 'Fichier invalide : le numéro d’édition doit être un entier (reçu : ' + `${number}).`, + ); + } + return number; +} + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; + +function asNullableIsoDate(value: unknown): string | null { + const date = asNullableString(value); + if (date !== null && !ISO_DATE_RE.test(date)) { + throw new InvalidBackupError( + `Fichier invalide : la date « ${date} » n’est pas au format ISO-8601 attendu.`, + ); + } + return date; +} + function asNonEmptyString(value: unknown, field: string): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new InvalidBackupError(`Fichier invalide : le champ « ${field} » est manquant ou vide.`); @@ -66,6 +88,8 @@ export function parseBackupFile(raw: unknown): BackupFile { } const magazines: BackupMagazine[] = []; + const seenMagazineIds = new Set(); + const seenCopyIds = new Set(); for (const entry of root['magazines']) { if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { throw new InvalidBackupError('Fichier invalide : une édition est mal formée.'); @@ -74,6 +98,10 @@ export function parseBackupFile(raw: unknown): BackupFile { const magazine = entry as Record; const id = asNonEmptyString(magazine['id'], 'identifiant d’édition'); + if (seenMagazineIds.has(id)) { + throw new InvalidBackupError('Fichier invalide : des identifiants d’édition sont dupliqués.'); + } + seenMagazineIds.add(id); if (!Array.isArray(magazine['copies'])) { throw new InvalidBackupError('Fichier invalide : la liste des exemplaires est absente.'); @@ -85,17 +113,24 @@ export function parseBackupFile(raw: unknown): BackupFile { throw new InvalidBackupError('Fichier invalide : un exemplaire est mal formé.'); } const copy = copyEntry as Record; + const copyId = asNonEmptyString(copy['id'], 'identifiant d’exemplaire'); + if (seenCopyIds.has(copyId)) { + throw new InvalidBackupError( + 'Fichier invalide : des identifiants d’exemplaire sont dupliqués.', + ); + } + seenCopyIds.add(copyId); copies.push({ - id: asNonEmptyString(copy['id'], 'identifiant d’exemplaire'), + id: copyId, notes: asNullableString(copy['notes']), - dateAdded: asNullableString(copy['dateAdded']) ?? '', + dateAdded: asNullableIsoDate(copy['dateAdded']) ?? '', }); } magazines.push({ id, publication: asNonEmptyString(magazine['publication'], 'publication'), - issueNumber: asNullableNumber(magazine['issueNumber']), + issueNumber: asNullableInteger(magazine['issueNumber']), edition: asNullableString(magazine['edition']), language: asNullableString(magazine['language']), condition: asNullableString(magazine['condition']), @@ -104,13 +139,15 @@ export function parseBackupFile(raw: unknown): BackupFile { notes: asNullableString(magazine['notes']), ocrText: asNullableString(magazine['ocrText']), copies, + createdAt: asNullableIsoDate(magazine['createdAt']), + updatedAt: asNullableIsoDate(magazine['updatedAt']), }); } return { format: BACKUP_FORMAT, version: BACKUP_VERSION, - exportedAt: asNullableString(root['exportedAt']) ?? new Date().toISOString(), + exportedAt: asNullableIsoDate(root['exportedAt']) ?? new Date().toISOString(), appVersion: asNullableString(root['appVersion']) ?? APP_VERSION, magazines, }; diff --git a/src/backup/backup-service.ts b/src/backup/backup-service.ts index 3782a1f..c324f4f 100644 --- a/src/backup/backup-service.ts +++ b/src/backup/backup-service.ts @@ -1,6 +1,6 @@ import type { Database } from '@/database/types'; import { APP_VERSION } from '@/utils/app-version'; -import { parseJsonBackup } from './backup-parsers'; +import { parseJsonBackup, InvalidBackupError } from './backup-parsers'; import { parseCsvBackup, toCsv as toCsvFn } from './backup-csv'; import { BACKUP_FORMAT, @@ -14,6 +14,9 @@ import { export { InvalidBackupError } from './backup-parsers'; export { BACKUP_CSV_HEADERS, escapeCsvField, parseCsv, toCsv } from './backup-csv'; +/** Taille maximale d'un fichier d'import (octets), pour borner la mémoire utilisée. */ +export const MAX_IMPORT_BYTES = 25 * 1024 * 1024; + type MagazineRow = { id: string; publication: string; @@ -37,7 +40,10 @@ type CopyRow = { }; export class BackupService { - constructor(private readonly db: Database) {} + constructor( + private readonly db: Database, + private readonly maxImportBytes: number = MAX_IMPORT_BYTES, + ) {} async exportCollection(): Promise { const magazineRows = await this.db.getAllAsync( @@ -79,6 +85,8 @@ export class BackupService { notes: row.notes, ocrText: row.ocr_text, copies: copiesByMagazine.get(row.id) ?? [], + createdAt: row.created_at, + updatedAt: row.updated_at, })); return { @@ -99,6 +107,11 @@ export class BackupService { } private parseBackupRaw(raw: string, format: BackupFormat): BackupFile { + if (raw.length > this.maxImportBytes) { + throw new InvalidBackupError( + `Fichier invalide : import dépassant la taille maximale de ${this.maxImportBytes} octets.`, + ); + } if (format === 'csv') { return parseCsvBackup(raw); } @@ -137,8 +150,8 @@ export class BackupService { magazine.barcode, magazine.notes, magazine.ocrText, - new Date().toISOString(), - new Date().toISOString(), + magazine.createdAt ?? new Date().toISOString(), + magazine.updatedAt ?? new Date().toISOString(), ); for (const copy of magazine.copies) { diff --git a/src/backup/backup-types.ts b/src/backup/backup-types.ts index a61dd18..3312302 100644 --- a/src/backup/backup-types.ts +++ b/src/backup/backup-types.ts @@ -22,6 +22,8 @@ export type BackupMagazine = { notes: string | null; ocrText: string | null; copies: BackupCopy[]; + createdAt: string | null; + updatedAt: string | null; }; export type BackupFile = { diff --git a/src/backup/native-file-gateway.ts b/src/backup/native-file-gateway.ts index 5fa3312..f4eb272 100644 --- a/src/backup/native-file-gateway.ts +++ b/src/backup/native-file-gateway.ts @@ -1,5 +1,7 @@ import type { BackupFormat } from './backup-types'; import type { FileGateway } from './file-gateway'; +import { InvalidBackupError } from './backup-parsers'; +import { MAX_IMPORT_BYTES } from './backup-service'; const DEFAULT_FILENAME_PREFIX = 'picsou-collection'; @@ -67,6 +69,11 @@ export class NativeFileGateway implements FileGateway { } const asset = result.assets[0]; + if (asset.size != null && asset.size > MAX_IMPORT_BYTES) { + throw new InvalidBackupError( + `Fichier invalide : import dépassant la taille maximale de ${MAX_IMPORT_BYTES} octets.`, + ); + } return { name: asset.name, content: await new File(asset.uri).text() }; } } diff --git a/src/store/__tests__/use-backup-store.test.ts b/src/store/__tests__/use-backup-store.test.ts index d943581..83d4dd5 100644 --- a/src/store/__tests__/use-backup-store.test.ts +++ b/src/store/__tests__/use-backup-store.test.ts @@ -108,7 +108,7 @@ describe('useBackupStore.exportCollection', () => { const [csv, format] = writeExport.mock.calls[0]; expect(format).toBe('csv'); expect(csv).toMatch( - /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\n/, + /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt\n/, ); expect(useBackupStore.getState().lastExport?.name).toBe('picsou-collection-2026-09-01.csv'); }); From eae103780a508f24a76abdcd7f9dcb68526aa9bd Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 12:22:47 +0200 Subject: [PATCH 12/34] =?UTF-8?q?refactor(m11-05):=20base=20de=20donn?= =?UTF-8?q?=C3=A9es=20=E2=80=94=20migrations=20transactionnelles,=20recher?= =?UTF-8?q?che=20accent-aware,=20addMagazine=20atomique=20(M-11-#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E3 : chaque migration dans une transaction (ROLLBACK en cas d'échec) - M2 : recherche en JS (filtrage + tri) insensible aux accents et à la casse, sans interprétation des jokers LIKE - M1 : addMagazine via createWithCopy transactionnel (magazine + exemplaire) --- src/database/__tests__/migrations.test.ts | 40 ++++++ src/database/migrations.ts | 18 ++- .../__tests__/magazine-repository.test.ts | 66 +++++++++ .../repositories/magazine-repository.ts | 133 +++++++++++++++--- .../__tests__/use-collection-store.test.ts | 19 ++- src/store/use-collection-store.ts | 7 +- 6 files changed, 245 insertions(+), 38 deletions(-) diff --git a/src/database/__tests__/migrations.test.ts b/src/database/__tests__/migrations.test.ts index 1b7cadb..5a1a420 100644 --- a/src/database/__tests__/migrations.test.ts +++ b/src/database/__tests__/migrations.test.ts @@ -65,6 +65,46 @@ describe('migrate', () => { expect(db.execAsync).toHaveBeenCalledWith(MIGRATION_001); }); + it('enveloppe chaque migration dans BEGIN/COMMIT avec mise à jour de user_version', async () => { + const db = { + getFirstAsync: jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ user_version: getSchemaVersion() }), + execAsync: jest.fn().mockResolvedValue(undefined), + } as unknown as Database; + + await migrate(db); + + const calls = (db.execAsync as jest.Mock).mock.calls.map((call) => call[0]); + expect(calls[0]).toBe('BEGIN'); + expect(calls).toContain(MIGRATION_001); + expect(calls).toContain(`PRAGMA user_version = ${getSchemaVersion()}`); + expect(calls[calls.length - 1]).toBe('COMMIT'); + }); + + it('annule la migration (ROLLBACK) et relance l’erreur en cas d’échec', async () => { + const db = { + getFirstAsync: jest.fn().mockResolvedValue({ user_version: 0 }), + execAsync: jest.fn().mockImplementation((sql: string) => { + if (sql === 'PRAGMA user_version = 4') { + throw new Error('Échec forcé'); + } + return Promise.resolve(undefined); + }), + } as unknown as Database; + + await expect(migrate(db)).rejects.toThrow('Échec forcé'); + + const calls = (db.execAsync as jest.Mock).mock.calls.map((call) => call[0]); + expect(calls[0]).toBe('BEGIN'); + // La migration n°4 a échoué → ROLLBACK final (les migrations 1-3 ont COMMIT). + expect(calls[calls.length - 1]).toBe('ROLLBACK'); + expect(calls.filter((sql) => sql === 'ROLLBACK')).toHaveLength(1); + // user_version inchangé : la base reste cohérente pour une relance. + expect(await db.getFirstAsync('PRAGMA user_version')).toEqual({ user_version: 0 }); + }); + it('signale une migration incomplète si la version finale est fausse', async () => { const db = { getFirstAsync: jest.fn().mockResolvedValue(undefined), diff --git a/src/database/migrations.ts b/src/database/migrations.ts index d471b76..ad6b079 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -25,8 +25,22 @@ export async function migrate(db: Database): Promise { for (const migration of MIGRATIONS) { if (migration.version > current) { - await migration.up(db); - await db.execAsync(`PRAGMA user_version = ${migration.version}`); + // Chaque migration dans une transaction : un échec partiel annule le DDL + // déjà appliqué, évitant une base « mi-figée » avec un `user_version` + // inchangé qui bloquerait toute relance. + await db.execAsync('BEGIN'); + try { + await migration.up(db); + await db.execAsync(`PRAGMA user_version = ${migration.version}`); + await db.execAsync('COMMIT'); + } catch (error) { + try { + await db.execAsync('ROLLBACK'); + } catch { + // Ignorer l'échec du rollback : on renvoie l'erreur d'origine. + } + throw error; + } } } diff --git a/src/database/repositories/__tests__/magazine-repository.test.ts b/src/database/repositories/__tests__/magazine-repository.test.ts index 137bc4a..0d7efd0 100644 --- a/src/database/repositories/__tests__/magazine-repository.test.ts +++ b/src/database/repositories/__tests__/magazine-repository.test.ts @@ -104,6 +104,33 @@ describe('magazineRepository.create', () => { }); }); +describe('magazineRepository.createWithCopy', () => { + it('cree l’edition et son premier exemplaire dans une seule transaction', async () => { + const result = await repo.createWithCopy({ + publication: 'Picsou Magazine', + issueNumber: 547, + }); + + expect(result.magazine.publication).toBe('Picsou Magazine'); + expect(result.magazine.issueNumber).toBe(547); + expect(result.copyId).toBeTruthy(); + + const copy = await testDb.getFirstAsync<{ + id: string; + magazine_id: string; + notes: string | null; + }>('SELECT id, magazine_id, notes FROM collection_items WHERE id = ?', result.copyId); + expect(copy?.magazine_id).toBe(result.magazine.id); + expect(copy?.notes).toBeNull(); + }); + + it('refuse une publication vide', async () => { + await expect(repo.createWithCopy({ publication: ' ' })).rejects.toThrow( + 'La publication est obligatoire.', + ); + }); +}); + describe('magazineRepository.findManyByBarcode', () => { it('retourne toutes les editions partageant le meme code-barres', async () => { const first = await repo.create({ @@ -191,6 +218,17 @@ describe('magazineRepository.findByPublicationAndIssue', () => { expect(await repo.findByPublicationAndIssue('Picsou Magazine', null)).toBeNull(); expect(await repo.findByPublicationAndIssue(' ', 547)).toBeNull(); }); + + it('choisit toujours la plus ancienne édition quand plusieurs existent (déterministe)', async () => { + await repo.create({ publication: 'Picsou Magazine', issueNumber: 547, edition: 'BE' }); + await repo.create({ publication: 'Picsou Magazine', issueNumber: 547, edition: 'FR' }); + + const first = await repo.findByPublicationAndIssue('Picsou Magazine', 547); + const second = await repo.findByPublicationAndIssue('Picsou Magazine', 547); + + expect(first?.edition).toBe('BE'); + expect(second?.id).toBe(first?.id); + }); }); describe('magazineRepository.list', () => { @@ -210,6 +248,16 @@ describe('magazineRepository.list', () => { ]); }); + it('trie sans tenir compte de la casse ni des accents', async () => { + await repo.create({ publication: 'Échos Vacances', issueNumber: 1 }); + await repo.create({ publication: 'etu-SORCIER', issueNumber: 1 }); + await repo.create({ publication: 'Espiègle', issueNumber: 1 }); + + const list = await repo.list(); + + expect(list.map((m) => m.publication)).toEqual(['Échos Vacances', 'Espiègle', 'etu-SORCIER']); + }); + it('compte le nombre dexemplaires possedes', async () => { const single = await repo.create({ publication: 'Picsou Magazine', issueNumber: 547 }); const double = await repo.create({ publication: 'Super Picsou Géant', issueNumber: 30 }); @@ -320,6 +368,24 @@ describe('magazineRepository.search', () => { expect(results[0].quantity).toBe(1); }); + + it('traite % et _ comme des caracteres litteraux', async () => { + await repo.create({ publication: 'Picsou 100%', issueNumber: 1 }); + await repo.create({ publication: 'Picsou_20', issueNumber: 2 }); + await repo.create({ publication: 'Picsou Magazine', issueNumber: 3 }); + + expect((await repo.search('100%')).map((m) => m.publication)).toEqual(['Picsou 100%']); + expect((await repo.search('Picsou_20')).map((m) => m.publication)).toEqual(['Picsou_20']); + expect((await repo.search('%')).map((m) => m.publication)).toEqual(['Picsou 100%']); + expect((await repo.search('_')).map((m) => m.publication)).toEqual(['Picsou_20']); + }); + + it('ignore les accents et la casse dans les deux sens', async () => { + await repo.create({ publication: 'Super Picsou Géant', issueNumber: 30 }); + + expect((await repo.search('geant')).map((m) => m.publication)).toEqual(['Super Picsou Géant']); + expect((await repo.search('ÉAN')).map((m) => m.publication)).toEqual(['Super Picsou Géant']); + }); }); describe('magazineRepository.findById', () => { diff --git a/src/database/repositories/magazine-repository.ts b/src/database/repositories/magazine-repository.ts index 31e5e95..6cf94b3 100644 --- a/src/database/repositories/magazine-repository.ts +++ b/src/database/repositories/magazine-repository.ts @@ -52,6 +52,35 @@ function toMagazine( }; } +/** + * Tri stable, insensible à la casse et aux accents (collation française) : + * le `ORDER BY` SQLite utilise la collation BINARY et `lower()` est ASCII-only. + */ +function compareByPublication( + a: Pick, + b: Pick, +): number { + const byPublication = a.publication.localeCompare(b.publication, 'fr', { + sensitivity: 'base', + }); + if (byPublication !== 0) { + return byPublication; + } + const byIssue = (a.issueNumber ?? Infinity) - (b.issueNumber ?? Infinity); + if (byIssue !== 0) { + return byIssue; + } + return a.id.localeCompare(b.id); +} + +/** Normalise une chaîne : casse + accents (prétraitement en JS, SQLite est ASCII-only). */ +function normalizeText(value: string): string { + return value + .toLocaleLowerCase('fr') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +} + export class MagazineRepository { constructor(private readonly db: Database) {} @@ -99,17 +128,16 @@ export class MagazineRepository { async list(): Promise { const rows = await this.db.getAllAsync< Omit & { quantity: number } - >( - `${LIST_SELECT} - GROUP BY m.id - ORDER BY m.publication, m.issue_number`, - ); + >(`${LIST_SELECT} + GROUP BY m.id`); - return rows.map((row) => ({ ...toMagazine(row), quantity: row.quantity })); + return rows + .map((row) => ({ ...toMagazine(row), quantity: row.quantity })) + .sort(compareByPublication); } async search(query: string): Promise { - const term = query.trim(); + const term = normalizeText(query.trim()); if (!term) { return this.list(); } @@ -117,20 +145,14 @@ export class MagazineRepository { const numeric = Number(term); const isNumeric = Number.isFinite(numeric); - const rows = await this.db.getAllAsync< - Omit & { quantity: number } - >( - `${LIST_SELECT} - WHERE m.publication LIKE '%' || ? || '%' - OR (? = 1 AND m.issue_number = ?) - GROUP BY m.id - ORDER BY m.publication, m.issue_number`, - term, - isNumeric ? 1 : 0, - isNumeric ? numeric : 0, - ); - - return rows.map((row) => ({ ...toMagazine(row), quantity: row.quantity })); + const all = await this.list(); + return all + .filter( + (magazine) => + (isNumeric && magazine.issueNumber === numeric) || + normalizeText(magazine.publication).includes(term), + ) + .sort(compareByPublication); } async findById(id: string): Promise { @@ -217,6 +239,75 @@ export class MagazineRepository { return magazine; } + /** + * Crée une édition **et** son premier exemplaire dans une seule transaction + * (US-COL : l'ajout d'une nouveauté produit toujours un exemplaire). Un échec + * de l'un des deux INSERT annule l'ensemble : pas d'édition orpheline. + */ + async createWithCopy( + input: CreateMagazineInput, + ): Promise<{ magazine: Magazine; copyId: string }> { + const publication = input.publication.trim(); + if (!publication) { + throw new Error('La publication est obligatoire.'); + } + + const now = new Date().toISOString(); + const magazine: Magazine = { + id: generateId(), + publication, + issueNumber: input.issueNumber ?? null, + edition: input.edition ?? null, + language: input.language ?? null, + condition: input.condition ?? null, + publicationDate: input.publicationDate ?? null, + barcode: input.barcode ?? null, + notes: input.notes ?? null, + ocrText: input.ocrText ?? null, + createdAt: now, + updatedAt: now, + }; + const copyId = generateId(); + + await this.db.execAsync('BEGIN'); + try { + await this.db.runAsync( + `INSERT INTO magazines + (id, publication, issue_number, edition, language, condition, publication_date, + barcode, notes, ocr_text, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + magazine.id, + magazine.publication, + magazine.issueNumber, + magazine.edition, + magazine.language, + magazine.condition, + magazine.publicationDate, + magazine.barcode, + magazine.notes, + magazine.ocrText, + magazine.createdAt, + magazine.updatedAt, + ); + await this.db.runAsync( + `INSERT INTO collection_items (id, magazine_id, notes, date_added) + VALUES (?, ?, NULL, ?)`, + copyId, + magazine.id, + now, + ); + await this.db.execAsync('COMMIT'); + } catch (error) { + try { + await this.db.execAsync('ROLLBACK'); + } catch { + // Ignorer l'échec du rollback : on renvoie l'erreur d'origine. + } + throw error; + } + + return { magazine, copyId }; + } async update(id: string, input: CreateMagazineInput): Promise { const current = await this.db.getFirstAsync( `SELECT id, publication, issue_number, edition, language, condition, publication_date, diff --git a/src/store/__tests__/use-collection-store.test.ts b/src/store/__tests__/use-collection-store.test.ts index 388a13d..77d7990 100644 --- a/src/store/__tests__/use-collection-store.test.ts +++ b/src/store/__tests__/use-collection-store.test.ts @@ -3,12 +3,14 @@ import type { MagazineListItem } from '@/types'; const mockList = jest.fn(); const mockCreate = jest.fn(); +const mockCreateWithCopy = jest.fn(); const mockDelete = jest.fn(); const mockFindById = jest.fn(); const mockUpdate = jest.fn(); const mockMagazineRepo = { list: mockList, create: mockCreate, + createWithCopy: mockCreateWithCopy, delete: mockDelete, findById: mockFindById, update: mockUpdate, @@ -81,15 +83,9 @@ describe('useCollectionStore', () => { expect(state.loaded).toBe(false); }); - it('ajoute une edition et un exemplaire, puis incremente le compteur', async () => { + it('ajoute une edition et un exemplaire dans une transaction, puis incremente le compteur', async () => { const created = { ...magazine, quantity: 1 }; - mockCreate.mockResolvedValue(created); - mockAddCopy.mockResolvedValue({ - id: 'c1', - magazineId: 'mag-1', - notes: null, - dateAdded: 'x', - }); + mockCreateWithCopy.mockResolvedValue({ magazine: created, copyId: 'c1' }); await useCollectionStore .getState() @@ -98,7 +94,10 @@ describe('useCollectionStore', () => { const state = useCollectionStore.getState(); expect(state.magazines).toHaveLength(1); expect(state.totalCopies).toBe(1); - expect(mockAddCopy).toHaveBeenCalledWith('mag-1'); + expect(mockCreateWithCopy).toHaveBeenCalledWith({ + publication: 'Picsou Magazine', + issueNumber: 547, + }); }); it('supprime une edition et decremente le compteur', async () => { @@ -242,7 +241,7 @@ describe('useCollectionStore', () => { }); it('relance l’erreur quand l’ajout d’une edition échoue', async () => { - mockCreate.mockRejectedValue(new Error('Code-barres déjà enregistré.')); + mockCreateWithCopy.mockRejectedValue(new Error('Code-barres déjà enregistré.')); await expect( useCollectionStore.getState().addMagazine({ publication: 'Picsou Magazine' }), diff --git a/src/store/use-collection-store.ts b/src/store/use-collection-store.ts index c09478b..146be64 100644 --- a/src/store/use-collection-store.ts +++ b/src/store/use-collection-store.ts @@ -71,12 +71,9 @@ export const useCollectionStore = create((set, get) => ({ } }, - clearDetail: () => set({ detail: null }), - addMagazine: async (input) => { - const { magazineRepository, collectionRepository } = getDeps(); - const magazine = await magazineRepository.create(input); - await collectionRepository.addCopy(magazine.id); + const { magazineRepository } = getDeps(); + const { magazine } = await magazineRepository.createWithCopy(input); const item: MagazineListItem = { ...magazine, quantity: 1 }; set((state) => ({ magazines: [item, ...state.magazines], From cc5c0a06d121e2ebab3505cbd441b2aa7380a87c Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 12:22:52 +0200 Subject: [PATCH 13/34] =?UTF-8?q?refactor(m11-05):=20scan=20=E2=80=94=20re?= =?UTF-8?q?pli=20et=20validation=20des=20codes,=20hook=20useScanResult=20p?= =?UTF-8?q?artag=C3=A9=20(M-11-#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M4 : ISBN-10 tolérent sur X en dernière position uniquement, repli GENERIC - M5 : mutux résolu/possédé et styles de statut dédupliqués via useScanResult - tests composants scan-search-views pour couvrir les branches d'état --- src/app/scan/result.tsx | 58 +------- src/app/scan/search.tsx | 42 +----- .../__tests__/scan-search-views.test.tsx | 125 ++++++++++++++++++ src/hooks/use-scan-result.ts | 46 +++++++ .../__tests__/scanBarcode.test.ts | 22 +++ src/identification/scanBarcode.ts | 10 +- 6 files changed, 210 insertions(+), 93 deletions(-) create mode 100644 src/components/__tests__/scan-search-views.test.tsx create mode 100644 src/hooks/use-scan-result.ts diff --git a/src/app/scan/result.tsx b/src/app/scan/result.tsx index 2e28c44..9e2807a 100644 --- a/src/app/scan/result.tsx +++ b/src/app/scan/result.tsx @@ -1,17 +1,19 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback } from 'react'; -import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; +import { useScanResult } from '@/hooks/use-scan-result'; +import { makeSearchResultStyles } from '@/components/scan-search-views'; import { Screen } from '@/components/screen'; -import { toast } from '@/lib/toast'; import { useCollectionStore } from '@/store/use-collection-store'; export default function ScanResultScreen() { const router = useRouter(); const colors = useThemeColors(); const styles = makeStyles(colors); + const statusTextStyles = makeSearchResultStyles(colors); const params = useLocalSearchParams<{ id?: string; publication?: string; @@ -22,7 +24,6 @@ export default function ScanResultScreen() { const loadDetail = useCollectionStore((s) => s.loadDetail); const detail = useCollectionStore((s) => s.detail); const detailLoading = useCollectionStore((s) => s.detailLoading); - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); const id = params.id ?? ''; const exists = id !== ''; @@ -36,34 +37,7 @@ export default function ScanResultScreen() { }, [id, loadDetail]), ); - const resolved = detail != null && detail.id === id; - const owned = resolved && detail.copies.length > 0; - const ownedCount = resolved ? detail.copies.length : 0; - - const handleAddCopy = () => { - if (!id) { - return; - } - const alreadyOwned = owned; - - const perform = async () => { - await addExistingCopy(id); - toast(alreadyOwned ? 'Exemplaire ajouté à la collection' : 'Ajouté à la collection'); - }; - - if (alreadyOwned) { - Alert.alert( - 'Vous possédez déjà ce magazine', - `Exemplaires actuels : ${ownedCount}\nVoulez-vous ajouter un deuxième exemplaire ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Ajouter quand même', style: 'destructive', onPress: perform }, - ], - ); - } else { - perform(); - } - }; + const { resolved, owned, ownedCount, handleAddCopy } = useScanResult(detail, exists ? id : null); const handleManual = () => { router.replace({ @@ -89,7 +63,7 @@ export default function ScanResultScreen() { ) : null} {resolved ? ( {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} @@ -199,26 +173,6 @@ function makeStyles(colors: ThemeColors) { fontSize: 16, color: colors.textSecondary, }, - ownedText: { - fontSize: 14, - fontWeight: '700', - color: colors.ownedText, - backgroundColor: colors.ownedBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, - absentText: { - fontSize: 14, - fontWeight: '700', - color: colors.absentText, - backgroundColor: colors.absentBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, message: { fontSize: 16, color: colors.text, diff --git a/src/app/scan/search.tsx b/src/app/scan/search.tsx index 46b5a64..8b18b77 100644 --- a/src/app/scan/search.tsx +++ b/src/app/scan/search.tsx @@ -1,14 +1,6 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; -import { - Alert, - KeyboardAvoidingView, - Platform, - Pressable, - Text, - TextInput, - View, -} from 'react-native'; +import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'; import { Feather } from '@expo/vector-icons'; import { AutocompleteInput } from '@/components/autocomplete-input'; @@ -22,7 +14,7 @@ import { } from '@/components/scan-search-views'; import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; -import { toast } from '@/lib/toast'; +import { useScanResult } from '@/hooks/use-scan-result'; import { useCollectionStore } from '@/store/use-collection-store'; import type { OcrLookupResult } from '@/identification/identificationService'; @@ -35,7 +27,6 @@ export default function ScanSearchScreen() { const magazines = useCollectionStore((s) => s.magazines); const loadDetail = useCollectionStore((s) => s.loadDetail); const detail = useCollectionStore((s) => s.detail); - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); const [publication, setPublication] = useState(''); const [issueNumber, setIssueNumber] = useState(EMPTY_ISSUE); @@ -57,6 +48,8 @@ export default function ScanSearchScreen() { }, [foundId, loadDetail]), ); + const { resolved, owned, ownedCount, handleAddCopy } = useScanResult(detail, foundId); + const issue = issueNumber.replace(/[^0-9]/g, ''); const canSearch = publication.trim().length > 0 && issue.length > 0 && !searching; @@ -85,10 +78,6 @@ export default function ScanSearchScreen() { setIssueNumber(EMPTY_ISSUE); }; - const resolved = detail != null && detail.id === foundId; - const owned = resolved && detail.copies.length > 0; - const ownedCount = resolved ? detail.copies.length : 0; - const handleCancel = () => { if (router.canGoBack()) { router.back(); @@ -97,29 +86,6 @@ export default function ScanSearchScreen() { } }; - const handleAddCopy = () => { - if (!foundId) { - return; - } - const alreadyOwned = owned; - const perform = async () => { - await addExistingCopy(foundId); - toast(alreadyOwned ? 'Exemplaire ajouté à la collection' : 'Ajouté à la collection'); - }; - if (alreadyOwned) { - Alert.alert( - 'Vous possédez déjà ce magazine', - `Exemplaires actuels : ${ownedCount}\nVoulez-vous ajouter un deuxième exemplaire ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Ajouter quand même', style: 'destructive', onPress: perform }, - ], - ); - } else { - perform(); - } - }; - const handleManual = () => { router.replace({ pathname: '/scan/manual', diff --git a/src/components/__tests__/scan-search-views.test.tsx b/src/components/__tests__/scan-search-views.test.tsx new file mode 100644 index 0000000..3fef099 --- /dev/null +++ b/src/components/__tests__/scan-search-views.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { SearchFoundResult, SearchUnknownResult } from '@/components/scan-search-views'; + +describe('SearchFoundResult', () => { + const onAddCopy = jest.fn(); + const onView = jest.fn(); + const onRescan = jest.fn(); + + beforeEach(() => { + onAddCopy.mockClear(); + onView.mockClear(); + onRescan.mockClear(); + }); + + it('affiche le numéro quand il est renseigné et le statut possédé', () => { + render( + , + ); + + expect(screen.getByText('N° 547')).toBeTruthy(); + expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé (2)'); + fireEvent.press(screen.getByTestId('search-add')); + expect(onAddCopy).toHaveBeenCalled(); + }); + + it('affiche « absent » quand l’édition n’est pas possédée', () => { + render( + , + ); + + expect(screen.queryByText('N° 547')).toBeNull(); + expect(screen.getByTestId('search-status-absent')).toHaveTextContent('○ Absent'); + }); + + it('affiche la vérification en cours tant que l’édition n’est pas résolue', () => { + render( + , + ); + + expect(screen.getByTestId('search-loading')).toHaveTextContent('Vérification…'); + }); + + it('sait relancer un scan ou ouvrir la fiche', () => { + render( + , + ); + + fireEvent.press(screen.getByTestId('search-view')); + expect(onView).toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('search-rescan')); + expect(onRescan).toHaveBeenCalled(); + }); +}); + +describe('SearchUnknownResult', () => { + const onManual = jest.fn(); + const onAgain = jest.fn(); + + it('affiche la publication, avec ou sans numéro', () => { + render( + , + ); + expect(screen.getByText(/Picsou Magazine/)).toBeTruthy(); + expect(screen.getByText('N° 547')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('search-manual')); + expect(onManual).toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('search-again')); + expect(onAgain).toHaveBeenCalled(); + }); + + it('s’affiche sans numéro quand celui-ci est absent', () => { + render( + , + ); + expect(screen.queryByText('N° 547')).toBeNull(); + }); +}); diff --git a/src/hooks/use-scan-result.ts b/src/hooks/use-scan-result.ts new file mode 100644 index 0000000..364afc4 --- /dev/null +++ b/src/hooks/use-scan-result.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { Alert } from 'react-native'; + +import { toast } from '@/lib/toast'; +import { useCollectionStore } from '@/store/use-collection-store'; +import type { MagazineDetail } from '@/types'; + +/** + * Statut partagé entre les écrans « résultat du scan » et « recherche + * manuelle » : résolution de l'édition affichée, possession d'un exemplaire + * et ajout d'un exemplaire (avec confirmation si déjà possédé). + */ +export function useScanResult(detail: MagazineDetail | null, id: string | null) { + const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); + + const resolved = detail != null && detail.id === id; + const owned = resolved && detail.copies.length > 0; + const ownedCount = resolved ? detail.copies.length : 0; + + const handleAddCopy = useCallback(() => { + if (!id) { + return; + } + const alreadyOwned = owned; + + const perform = async () => { + await addExistingCopy(id); + toast(alreadyOwned ? 'Exemplaire ajouté à la collection' : 'Ajouté à la collection'); + }; + + if (alreadyOwned) { + Alert.alert( + 'Vous possédez déjà ce magazine', + `Exemplaires actuels : ${ownedCount}\nVoulez-vous ajouter un deuxième exemplaire ?`, + [ + { text: 'Annuler', style: 'cancel' }, + { text: 'Ajouter quand même', style: 'destructive', onPress: perform }, + ], + ); + } else { + perform(); + } + }, [id, owned, ownedCount, addExistingCopy]); + + return { resolved, owned, ownedCount, handleAddCopy }; +} diff --git a/src/identification/__tests__/scanBarcode.test.ts b/src/identification/__tests__/scanBarcode.test.ts index fd714be..8d1cafd 100644 --- a/src/identification/__tests__/scanBarcode.test.ts +++ b/src/identification/__tests__/scanBarcode.test.ts @@ -48,6 +48,28 @@ describe('validateBarcode', () => { }); }); + it('rejette un ISBN-10 invalide et n’accepte X qu’en position de checksum', () => { + expect(validateBarcode('0096059077')).toEqual({ + valid: true, + type: 'GENERIC', + normalized: '0096059077', + }); + // Un X en position interne n’est jamais validé comme ISBN-10 (retombe en GENERIC). + const middleX = validateBarcode('08044X957X'); + expect(middleX.valid).toBe(true); + if (middleX.valid) { + expect(middleX.type).toBe('GENERIC'); + } + }); + + it('replie en GENERIC un code de 10 chiffres au checksum ISBN-10 invalide', () => { + expect(validateBarcode('1234567890')).toEqual({ + valid: true, + type: 'GENERIC', + normalized: '1234567890', + }); + }); + it('rejette un code vide', () => { expect(validateBarcode('')).toEqual({ valid: false, diff --git a/src/identification/scanBarcode.ts b/src/identification/scanBarcode.ts index 948e602..a600f71 100644 --- a/src/identification/scanBarcode.ts +++ b/src/identification/scanBarcode.ts @@ -67,13 +67,17 @@ export function validateBarcode(raw: string): BarcodeValidation { return { valid: false, reason: 'Aucun chiffre détecté.' }; } - // ISBN-10 (longueur exacte, peut contenir un 'X' en fin de checksum) + // ISBN-10 (longueur exacte, peut contenir un 'X' en position de checksum uniquement) const trimmedIsbn = s.toUpperCase(); - if (trimmedIsbn.length === ISBN_10_LENGTH && /^[0-9X]{10}$/.test(trimmedIsbn)) { + if (trimmedIsbn.length === ISBN_10_LENGTH && /^[0-9]{9}[0-9X]$/.test(trimmedIsbn)) { if (isValidIsbn10(trimmedIsbn)) { return { valid: true, type: 'ISBN-10', normalized: trimmedIsbn }; } - return { valid: false, reason: 'ISBN-10 invalide (checksum).' }; + if (!/^\d{10}$/.test(trimmedIsbn)) { + return { valid: false, reason: 'ISBN-10 invalide (checksum).' }; + } + // 10 chiffres dont la somme de contrôle ne valide pas l'ISBN-10 : ce peut + // être un codage non standard (CODE39/ITF…) — on tente GENERIC plus bas. } // EAN-13 / ISBN-13 (13 chiffres, éventuellement groupés par séparateurs) From 7df8adb9b907536d155e1e38ee953be814a4c4e2 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 12:30:29 +0200 Subject: [PATCH 14/34] =?UTF-8?q?test(m11-04):=20couvrir=20les=20composant?= =?UTF-8?q?s=20de=20scan=20cam=C3=A9ra=20&=20code-barres=20(M-11-#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - barcode-pending-sheets : confirm/success/unknown + bandeau continu - ocr-analyzing-overlay : champs détectés, guide, torche, validation - ocr-confirm-overlay : édition des champs, filtrage du numéro, actions --- .../__tests__/barcode-pending-sheets.test.tsx | 124 +++++++++++++++ .../__tests__/ocr-analyzing-overlay.test.tsx | 145 ++++++++++++++++++ .../__tests__/ocr-confirm-overlay.test.tsx | 90 +++++++++++ 3 files changed, 359 insertions(+) create mode 100644 src/components/__tests__/barcode-pending-sheets.test.tsx create mode 100644 src/components/__tests__/ocr-analyzing-overlay.test.tsx create mode 100644 src/components/__tests__/ocr-confirm-overlay.test.tsx diff --git a/src/components/__tests__/barcode-pending-sheets.test.tsx b/src/components/__tests__/barcode-pending-sheets.test.tsx new file mode 100644 index 0000000..94d56a6 --- /dev/null +++ b/src/components/__tests__/barcode-pending-sheets.test.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { + BarcodeContinuousBar, + BarcodePendingSheets, +} from '@/components/scan/barcode-pending-sheets'; +import { makeBarcodeStyles } from '@/components/scan/barcode-styles'; +import type { Pending } from '@/components/scan/use-barcode-scanning'; +import { Colors } from '@/constants/theme'; +import type { Magazine } from '@/types'; + +const styles = makeBarcodeStyles(Colors.light, { top: 0, bottom: 0 }); + +const magazine: Magazine = { + id: 'mag-1', + publication: 'Picsou Magazine', + issueNumber: 547, + edition: null, + language: null, + condition: null, + publicationDate: null, + barcode: null, + notes: null, + ocrText: null, + createdAt: '2026-09-01T10:00:00.000Z', + updatedAt: '2026-09-01T10:00:00.000Z', +}; + +function renderPending(pending: Pending) { + const callbacks = { + onResume: jest.fn(), + onConfirmAdd: jest.fn(), + onManual: jest.fn(), + }; + render( + , + ); + return callbacks; +} + +describe('BarcodePendingSheets', () => { + it('affiche la confirmation de doublon avec le numéro et le nombre d’exemplaires', () => { + const callbacks = renderPending({ + kind: 'confirm', + magazine, + ownedCount: 3, + }); + + expect(screen.getByText('Vous possédez déjà ce magazine')).toBeTruthy(); + expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); + expect(screen.getByText(/Exemplaires actuels : 3/)).toBeTruthy(); + + fireEvent.press(screen.getByTestId('pending-confirm-add')); + expect(callbacks.onConfirmAdd).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('pending-confirm-cancel')); + expect(callbacks.onResume).toHaveBeenCalled(); + }); + + it('affiche le magazine sans numéro quand il est absent', () => { + renderPending({ kind: 'confirm', magazine: { ...magazine, issueNumber: null }, ownedCount: 1 }); + + expect(screen.getByText('Picsou Magazine')).toBeTruthy(); + }); + + it('affiche l’écran de succès puis reprend le scan', () => { + const callbacks = renderPending({ + kind: 'success', + publication: 'Picsou Magazine', + issueNumber: 547, + }); + + expect(screen.getByText('Ajouté à la collection')).toBeTruthy(); + expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('pending-success-ok')); + expect(callbacks.onResume).toHaveBeenCalled(); + }); + + it('affiche l’écran de succès sans numéro si l’édition n’en a pas', () => { + renderPending({ kind: 'success', publication: 'Picsou Magazine', issueNumber: null }); + + expect(screen.getByText('Picsou Magazine')).toBeTruthy(); + }); + + it('suggère la saisie manuelle pour un code-barres inconnu', () => { + const callbacks = renderPending({ kind: 'unknown', barcode: '9791234567890' }); + + expect(screen.getByText('Code-barres inconnu')).toBeTruthy(); + expect(screen.getByText('9791234567890')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('pending-unknown-manual')); + expect(callbacks.onManual).toHaveBeenCalledWith('9791234567890'); + + fireEvent.press(screen.getByTestId('pending-unknown-continue')); + expect(callbacks.onResume).toHaveBeenCalled(); + }); + + it('ne rend rien pour un état inconnu', () => { + renderPending('idle' as unknown as Pending); + + expect(screen.queryByTestId('pending-confirm')).toBeNull(); + expect(screen.queryByTestId('pending-success')).toBeNull(); + expect(screen.queryByTestId('pending-unknown')).toBeNull(); + }); +}); + +describe('BarcodeContinuousBar', () => { + it('affiche le bandeau continu et permet de l’arrêter', () => { + const onStop = jest.fn(); + render(); + + expect(screen.getByText(/Scan en continu/)).toBeTruthy(); + + fireEvent.press(screen.getByTestId('continuous-stop')); + expect(onStop).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/ocr-analyzing-overlay.test.tsx b/src/components/__tests__/ocr-analyzing-overlay.test.tsx new file mode 100644 index 0000000..dfa3a5b --- /dev/null +++ b/src/components/__tests__/ocr-analyzing-overlay.test.tsx @@ -0,0 +1,145 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { OcrAnalyzingOverlay } from '@/components/scan/ocr-analyzing-overlay'; +import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; +import type { DetectedInfo } from '@/components/scan/ocr-analysis'; +import { Colors } from '@/constants/theme'; + +const styles = makeCameraOcrStyles(Colors.light, { top: 0, bottom: 0 }); + +function renderOverlay(overrides: Partial<{ detected: DetectedInfo; weakCycles: number }> = {}) { + const callbacks = { + onOpenConfirm: jest.fn(), + onGoBarcode: jest.fn(), + onGoManual: jest.fn(), + onBack: jest.fn(), + onToggleTorch: jest.fn(), + }; + render( + , + ); + return callbacks; +} + +describe('OcrAnalyzingOverlay', () => { + it('affiche le texte-guide et la lecture en cours', () => { + renderOverlay(); + + expect(screen.getByTestId('ocr-hint')).toHaveTextContent('Centre le magazine'); + expect(screen.getByText('Lecture…')).toBeTruthy(); + }); + + it('affiche des champs détectés avec leurs valeurs', () => { + renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: 547, date: '2024-06' }, + }); + + expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('Picsou Magazine'); + expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('547'); + expect(screen.getByTestId('ocr-field-date')).toHaveTextContent('2024-06'); + }); + + it('affiche des points de suspension tant qu’un champ est vide', () => { + renderOverlay(); + + expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('…'); + expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('…'); + expect(screen.getByTestId('ocr-field-date')).toHaveTextContent('…'); + }); + + it('masque la validation tant qu’aucune information n’est détectée', () => { + renderOverlay(); + + expect(screen.queryByTestId('ocr-confirm-detected')).toBeNull(); + }); + + it('valide les informations détectées', () => { + const callbacks = renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: null, date: null }, + }); + + fireEvent.press(screen.getByTestId('ocr-confirm-detected')); + expect(callbacks.onOpenConfirm).toHaveBeenCalled(); + }); + + it('propose code-barres, retour et torche', () => { + const callbacks = renderOverlay(); + + fireEvent.press(screen.getByTestId('ocr-barcode')); + expect(callbacks.onGoBarcode).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-back')); + expect(callbacks.onBack).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-torch')); + expect(callbacks.onToggleTorch).toHaveBeenCalled(); + expect(screen.getByLabelText('Activer la torche')).toBeTruthy(); + }); + + it('expose le libellé de torche allumée', () => { + render( + , + ); + + expect(screen.getByLabelText('Désactiver la torche')).toBeTruthy(); + }); + + it('affiche le guide seulement après plusieurs cycles sans numéro', () => { + const callbacks = renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: null, date: null }, + weakCycles: 4, + }); + + expect(screen.getByTestId('ocr-guidance-card')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('ocr-guidance-barcode')); + expect(callbacks.onGoBarcode).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-guidance-manual')); + expect(callbacks.onGoManual).toHaveBeenCalledWith({ + publication: 'Picsou Magazine', + issueNumber: null, + date: null, + }); + }); + + it('n’affiche pas le guide si le numéro est détecté', () => { + renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: 547, date: null }, + weakCycles: 4, + }); + + expect(screen.queryByTestId('ocr-guidance-card')).toBeNull(); + }); + + it('n’affiche pas le guide si la lecture est trop jeune', () => { + renderOverlay({ + detected: { publication: 'Picsou Magazine', issueNumber: null, date: null }, + weakCycles: 2, + }); + + expect(screen.queryByTestId('ocr-guidance-card')).toBeNull(); + }); +}); diff --git a/src/components/__tests__/ocr-confirm-overlay.test.tsx b/src/components/__tests__/ocr-confirm-overlay.test.tsx new file mode 100644 index 0000000..396afc0 --- /dev/null +++ b/src/components/__tests__/ocr-confirm-overlay.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { OcrConfirmOverlay } from '@/components/scan/ocr-confirm-overlay'; +import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; +import { Colors } from '@/constants/theme'; + +const styles = makeCameraOcrStyles(Colors.light, { top: 0, bottom: 0 }); +const draft = { publication: 'Picsou Magazine', issueNumber: 547, date: '2024-06' }; + +function renderOverlay(onDraftChange = jest.fn()) { + const callbacks = { + onDraftChange, + onSearch: jest.fn(), + onGoManual: jest.fn(), + onBack: jest.fn(), + }; + render( + , + ); + return callbacks; +} + +describe('OcrConfirmOverlay', () => { + it('pré-remplit les champs avec le brouillon détecté', () => { + renderOverlay(); + + expect(screen.getByTestId('ocr-override-publication').props.value).toBe('Picsou Magazine'); + expect(screen.getByTestId('ocr-override-issue').props.value).toBe('547'); + expect(screen.getByTestId('ocr-override-date').props.value).toBe('2024-06'); + }); + + it('permet de corriger la publication et la date', () => { + const onDraftChange = jest.fn(); + renderOverlay(onDraftChange); + + fireEvent.changeText(screen.getByTestId('ocr-override-publication'), 'Donald Hebdo'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, publication: 'Donald Hebdo' }); + + fireEvent.changeText(screen.getByTestId('ocr-override-date'), '1999-03'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, date: '1999-03' }); + }); + + it('ne retient que les chiffres du numéro, sinon le remet à null', () => { + const onDraftChange = jest.fn(); + renderOverlay(onDraftChange); + + fireEvent.changeText(screen.getByTestId('ocr-override-issue'), '1a2b3'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, issueNumber: 123 }); + + fireEvent.changeText(screen.getByTestId('ocr-override-issue'), 'abc'); + expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, issueNumber: null }); + }); + + it('affiche un numéro vide quand il est absent', () => { + render( + , + ); + + expect(screen.getByTestId('ocr-override-publication').props.value).toBe(''); + expect(screen.getByTestId('ocr-override-issue').props.value).toBe(''); + expect(screen.getByTestId('ocr-override-date').props.value).toBe(''); + }); + + it('lance la recherche, la saisie manuelle ou le retour caméra', () => { + const callbacks = renderOverlay(); + + fireEvent.press(screen.getByTestId('ocr-override-search')); + expect(callbacks.onSearch).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-override-manual')); + expect(callbacks.onGoManual).toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId('ocr-override-back')); + expect(callbacks.onBack).toHaveBeenCalled(); + }); +}); From b5199518147ef1e54dc8d5c5c0b89725c3762457 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Tue, 8 Sep 2026 12:50:13 +0200 Subject: [PATCH 15/34] Fix: Name App + Version Number --- app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.json b/app.json index 7775329..81e6307 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,8 @@ { "expo": { - "name": "Mag Collection", + "name": "Mag Collector", "slug": "PicsouCollection", - "version": "0.9.3", + "version": "0.9.4", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "picsoucollection", From 877b7561af8b99f2ee769b5714a258643d7ef139 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 09:29:59 +0200 Subject: [PATCH 16/34] =?UTF-8?q?refactor(M-11):=20retirer=20le=20syst?= =?UTF-8?q?=C3=A8me=20d'exemplaires=20=E2=80=94=20une=20=C3=A9dition=20en?= =?UTF-8?q?=20collection=20est=20poss=C3=A9d=C3=A9e=20(migration=20v5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(tabs)/index.tsx | 18 +- src/app/(tabs)/settings/index.tsx | 7 + src/app/collection/[id]/index.tsx | 94 +--------- src/components/magazine-card.tsx | 5 +- src/components/status-badge.tsx | 29 +-- src/database/migrations.ts | 2 + .../repositories/collection-repository.ts | 84 --------- .../repositories/magazine-repository.ts | 175 ++++-------------- src/database/schema.ts | 29 ++- src/dependencies.ts | 4 +- src/store/use-collection-store.ts | 69 +++---- src/types/index.ts | 28 +-- 12 files changed, 133 insertions(+), 411 deletions(-) delete mode 100644 src/database/repositories/collection-repository.ts diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index f8f9d64..5bd15a9 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -19,8 +19,8 @@ export default function HomeScreen() { const router = useRouter(); const colors = useThemeColors(); const styles = makeStyles(colors); - const totalCopies = useCollectionStore((s) => s.totalCopies); - const recentCopies = useCollectionStore((s) => s.recentCopies); + const totalMagazines = useCollectionStore((s) => s.totalMagazines); + const recent = useCollectionStore((s) => s.recent); const loading = useCollectionStore((s) => s.loading); const loaded = useCollectionStore((s) => s.loaded); const error = useCollectionStore((s) => s.error); @@ -44,7 +44,7 @@ export default function HomeScreen() { style={styles.counterCard} testID="collection-counter" accessibilityLiveRegion="polite" - accessibilityLabel={`${totalCopies} exemplaires possédés`}> + accessibilityLabel={`${totalMagazines} éditions possédées`}> {loading && !loaded ? ( ) : error ? ( @@ -53,8 +53,8 @@ export default function HomeScreen() { ) : ( <> - {totalCopies} - exemplaires possédés + {totalMagazines} + éditions possédées )} @@ -85,7 +85,7 @@ export default function HomeScreen() { Ajouts récents - {recentCopies.length === 0 ? ( + {recent.length === 0 ? ( Aucun ajout pour le moment. @@ -102,9 +102,9 @@ export default function HomeScreen() { ) : ( - recentCopies.map(({ copy, magazine }) => ( + recent.map((magazine) => ( [styles.recentItem, pressed && styles.buttonPressed]} onPress={() => router.push(`/collection/${magazine.id}`)} testID="recent-item" @@ -116,7 +116,7 @@ export default function HomeScreen() { {magazine.publication} {magazine.issueNumber != null ? ` n°${magazine.issueNumber}` : ''} - {formatDateLong(copy.dateAdded)} + {formatDateLong(magazine.createdAt)} diff --git a/src/app/(tabs)/settings/index.tsx b/src/app/(tabs)/settings/index.tsx index bb96cdc..9b9d95e 100644 --- a/src/app/(tabs)/settings/index.tsx +++ b/src/app/(tabs)/settings/index.tsx @@ -42,6 +42,13 @@ const MENU_ITEMS: { route: '/settings/help', testID: 'settings-help', }, + { + icon: 'cpu', + label: 'Paramètres avancés', + description: 'Debug OCR et reconnaissance', + route: '/settings/advanced', + testID: 'settings-advanced', + }, ]; export default function SettingsScreen() { diff --git a/src/app/collection/[id]/index.tsx b/src/app/collection/[id]/index.tsx index 3eeb3f7..fccd31b 100644 --- a/src/app/collection/[id]/index.tsx +++ b/src/app/collection/[id]/index.tsx @@ -11,7 +11,6 @@ import { StatusBadge } from '@/components/status-badge'; import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; import { slug } from '@/lib/slug'; -import { toast } from '@/lib/toast'; import { useCollectionStore } from '@/store/use-collection-store'; import { formatDateShort } from '@/lib/date'; @@ -25,11 +24,10 @@ export default function MagazineDetailScreen() { const detail = useCollectionStore((s) => s.detail); const detailLoading = useCollectionStore((s) => s.detailLoading); const loadDetail = useCollectionStore((s) => s.loadDetail); - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); const removeMagazine = useCollectionStore((s) => s.removeMagazine); const confirmDelete = () => { - Alert.alert(`Supprimer l'édition`, `Retirer « ${detail?.publication} » et ses exemplaires ?`, [ + Alert.alert(`Supprimer l'édition`, `Retirer « ${detail?.publication} » de la collection ?`, [ { text: 'Annuler', style: 'cancel' }, { text: 'Supprimer', @@ -42,14 +40,6 @@ export default function MagazineDetailScreen() { ]); }; - const handleAddCopy = async () => { - if (!detail) { - return; - } - await addExistingCopy(detail.id); - toast('Exemplaire ajouté à la collection'); - }; - const goBack = () => { if (router.canGoBack()) { router.back(); @@ -88,8 +78,6 @@ export default function MagazineDetailScreen() { ); } - const quantity = detail.copies.length; - return ( - 0} quantity={quantity} /> - {quantity > 0 && ( - - {quantity} exemplaire{quantity > 1 ? 's' : ''} - - )} + @@ -137,31 +120,6 @@ export default function MagazineDetailScreen() { ) : null} - Exemplaires - {detail.copies.length === 0 ? ( - - Aucun exemplaire pour l'instant. - - ) : ( - detail.copies.map((copy, index) => ( - - #{index + 1} - {formatDateShort(copy.dateAdded)} - - )) - )} - - [styles.addButton, pressed && styles.pressed]} - onPress={handleAddCopy} - testID="detail-add-copy" - accessibilityRole="button" - accessibilityLabel="Ajouter un exemplaire de cette édition" - android_ripple={{ color: 'rgba(0,0,0,0.12)' }}> - - Ajouter un exemplaire - - [styles.editButton, pressed && styles.pressed]} onPress={() => router.push(`/collection/${detail.id}/edit`)} @@ -239,11 +197,6 @@ function makeStyles(colors: ThemeColors) { alignItems: 'center', gap: Spacing.two, }, - count: { - fontSize: 14, - lineHeight: 20, - color: colors.textSecondary, - }, card: { backgroundColor: colors.backgroundElement, borderRadius: 12, @@ -281,49 +234,6 @@ function makeStyles(colors: ThemeColors) { lineHeight: 20, color: colors.text, }, - copyRow: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.three, - backgroundColor: colors.backgroundElement, - borderRadius: 8, - padding: Spacing.three, - minHeight: HitTarget.minHeight, - }, - copyIndex: { - fontSize: 14, - lineHeight: 20, - fontWeight: '700', - color: colors.accentTextOnLight, - }, - copyDate: { - fontSize: 13, - lineHeight: 18, - color: colors.textSecondary, - }, - muted: { - fontSize: 15, - lineHeight: 22, - color: colors.textSecondary, - textAlign: 'center', - }, - addButton: { - flexDirection: 'row', - marginTop: Spacing.three, - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.two, - backgroundColor: colors.accent, - minHeight: HitTarget.minHeight, - paddingVertical: Spacing.two, - borderRadius: 12, - }, - addButtonText: { - fontSize: 18, - lineHeight: 24, - fontWeight: '700', - color: colors.accentText, - }, editButton: { flexDirection: 'row', alignItems: 'center', diff --git a/src/components/magazine-card.tsx b/src/components/magazine-card.tsx index 4c4da8a..be0ab3a 100644 --- a/src/components/magazine-card.tsx +++ b/src/components/magazine-card.tsx @@ -13,7 +13,6 @@ type Props = { export function MagazineCard({ magazine, onPress }: Props) { const colors = useThemeColors(); - const owned = magazine.quantity > 0; const styles = makeStyles(colors); return ( @@ -22,7 +21,7 @@ export function MagazineCard({ magazine, onPress }: Props) { onPress={onPress} testID="magazine-card" accessibilityRole="button" - accessibilityLabel={`${magazine.publication}${magazine.issueNumber != null ? ` numéro ${magazine.issueNumber}` : ''}${owned ? `, possédé ${magazine.quantity} fois` : ', absent de la collection'}`} + accessibilityLabel={`${magazine.publication}${magazine.issueNumber != null ? ` numéro ${magazine.issueNumber}` : ''}, possédé`} accessibilityHint="Voir la fiche de l'édition" android_ripple={{ color: 'rgba(0,0,0,0.08)' }}> @@ -35,7 +34,7 @@ export function MagazineCard({ magazine, onPress }: Props) { ) : null} - + ); } diff --git a/src/components/status-badge.tsx b/src/components/status-badge.tsx index 3e3e28c..0275870 100644 --- a/src/components/status-badge.tsx +++ b/src/components/status-badge.tsx @@ -4,36 +4,21 @@ import { Feather } from '@expo/vector-icons'; import { Spacing } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; -type Props = { - owned: boolean; - quantity?: number; -}; - /** - * Badge Possédé / Absent — M10-01/M10-07 : sémantique inversée validée. - * Possédé = vert positif (+ check), Absent = neutre. Thématisé clair/sombre, - * 13px min (M10-10). + * Badge Possédé — retours test physique : le système d'exemplaires a été + * supprimé, une édition en collection est donc toujours possédée. Le badge + * confirme simplement la présence (vert positif + check, thématisé clair/sombre). */ -export function StatusBadge({ owned, quantity = 0 }: Props) { +export function StatusBadge() { const colors = useThemeColors(); const styles = makeStyles(); - return owned ? ( + return ( 0 ? `Possédé, ${quantity} exemplaires` : 'Possédé'}> + accessibilityLabel="Possédé"> - - Possédé{quantity > 0 ? ` (${quantity})` : ''} - - - ) : ( - - - Absent + Possédé ); } diff --git a/src/database/migrations.ts b/src/database/migrations.ts index ad6b079..0cd3dd5 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -4,6 +4,7 @@ import { MIGRATION_002, MIGRATION_003, MIGRATION_004, + MIGRATION_005, SCHEMA_VERSION, } from '@/database/schema'; @@ -17,6 +18,7 @@ const MIGRATIONS: Migration[] = [ { version: 2, up: (db) => db.execAsync(MIGRATION_002) }, { version: 3, up: (db) => db.execAsync(MIGRATION_003) }, { version: 4, up: (db) => db.execAsync(MIGRATION_004) }, + { version: 5, up: (db) => db.execAsync(MIGRATION_005) }, ]; export async function migrate(db: Database): Promise { diff --git a/src/database/repositories/collection-repository.ts b/src/database/repositories/collection-repository.ts deleted file mode 100644 index 6018e77..0000000 --- a/src/database/repositories/collection-repository.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Database } from '@/database/types'; -import type { CollectionItem, CreateCollectionItemInput, RecentCopy } from '@/types'; -import { generateId } from '@/utils/id'; - -export class CollectionRepository { - constructor(private readonly db: Database) {} - - async addCopy( - magazineId: string, - input: CreateCollectionItemInput = {}, - ): Promise { - const item: CollectionItem = { - id: generateId(), - magazineId, - notes: input.notes ?? null, - dateAdded: new Date().toISOString(), - }; - - await this.db.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, ?, ?)`, - item.id, - item.magazineId, - item.notes, - item.dateAdded, - ); - - return item; - } - - async countByMagazine(magazineId: string): Promise { - const row = await this.db.getFirstAsync<{ quantity: number }>( - 'SELECT COUNT(*) AS quantity FROM collection_items WHERE magazine_id = ?', - magazineId, - ); - - return Number(row?.quantity ?? 0); - } - - async countAllCopies(): Promise { - const row = await this.db.getFirstAsync<{ quantity: number }>( - 'SELECT COUNT(*) AS quantity FROM collection_items', - ); - - return Number(row?.quantity ?? 0); - } - - async listRecentCopies(limit = 5): Promise { - const rows = await this.db.getAllAsync<{ - copy_id: string; - copy_magazine_id: string; - copy_notes: string | null; - copy_date_added: string; - magazine_publication: string; - magazine_issue_number: number | null; - }>( - `SELECT c.id AS copy_id, - c.magazine_id AS copy_magazine_id, - c.notes AS copy_notes, - c.date_added AS copy_date_added, - m.publication AS magazine_publication, - m.issue_number AS magazine_issue_number - FROM collection_items c - JOIN magazines m ON m.id = c.magazine_id - ORDER BY c.date_added DESC - LIMIT ?`, - limit, - ); - - return rows.map((row) => ({ - copy: { - id: row.copy_id, - magazineId: row.copy_magazine_id, - notes: row.copy_notes, - dateAdded: row.copy_date_added, - }, - magazine: { - id: row.copy_magazine_id, - publication: row.magazine_publication, - issueNumber: row.magazine_issue_number, - }, - })); - } -} diff --git a/src/database/repositories/magazine-repository.ts b/src/database/repositories/magazine-repository.ts index 6cf94b3..7dccc03 100644 --- a/src/database/repositories/magazine-repository.ts +++ b/src/database/repositories/magazine-repository.ts @@ -1,11 +1,5 @@ import type { Database } from '@/database/types'; -import type { - Magazine, - CollectionItem, - CreateMagazineInput, - MagazineDetail, - MagazineListItem, -} from '@/types'; +import type { Magazine, CreateMagazineInput, MagazineListItem } from '@/types'; import { generateId } from '@/utils/id'; type MagazineRow = { @@ -23,12 +17,16 @@ type MagazineRow = { updated_at: string; }; +const DETAIL_SELECT = ` + SELECT id, publication, issue_number, edition, language, condition, publication_date, + barcode, notes, ocr_text, created_at, updated_at + FROM magazines`; + +/** Liste légère : pas de notes ni d'ocr_text (colonnes potentiellement lourdes). */ const LIST_SELECT = ` - SELECT m.id, m.publication, m.issue_number, m.edition, m.language, - m.publication_date, m.barcode, m.created_at, m.updated_at, - COUNT(c.id) AS quantity - FROM magazines m - LEFT JOIN collection_items c ON c.magazine_id = m.id`; + SELECT id, publication, issue_number, edition, language, condition, publication_date, + barcode, created_at, updated_at + FROM magazines`; function toMagazine( row: Omit & { @@ -56,10 +54,7 @@ function toMagazine( * Tri stable, insensible à la casse et aux accents (collation française) : * le `ORDER BY` SQLite utilise la collation BINARY et `lower()` est ASCII-only. */ -function compareByPublication( - a: Pick, - b: Pick, -): number { +function compareByPublication(a: Magazine, b: Magazine): number { const byPublication = a.publication.localeCompare(b.publication, 'fr', { sensitivity: 'base', }); @@ -85,17 +80,14 @@ export class MagazineRepository { constructor(private readonly db: Database) {} async findManyByBarcode(barcode: string): Promise { - const rows = await this.db.getAllAsync< - Omit & { quantity: number } - >( - `${LIST_SELECT} - WHERE m.barcode = ? - GROUP BY m.id - ORDER BY m.publication, m.issue_number`, + const rows = await this.db.getAllAsync( + `${DETAIL_SELECT} + WHERE barcode = ? + ORDER BY publication, issue_number`, barcode, ); - return rows.map((row) => ({ ...toMagazine(row), quantity: row.quantity })); + return rows.map(toMagazine); } /** @@ -112,9 +104,7 @@ export class MagazineRepository { } const row = await this.db.getFirstAsync( - `SELECT id, publication, issue_number, edition, language, condition, publication_date, - barcode, notes, ocr_text, created_at, updated_at - FROM magazines + `${DETAIL_SELECT} WHERE lower(publication) = lower(?) AND issue_number = ? ORDER BY created_at ASC LIMIT 1`, @@ -126,14 +116,9 @@ export class MagazineRepository { } async list(): Promise { - const rows = await this.db.getAllAsync< - Omit & { quantity: number } - >(`${LIST_SELECT} - GROUP BY m.id`); + const rows = await this.db.getAllAsync(`${LIST_SELECT}`); - return rows - .map((row) => ({ ...toMagazine(row), quantity: row.quantity })) - .sort(compareByPublication); + return rows.map(toMagazine).sort(compareByPublication); } async search(query: string): Promise { @@ -155,40 +140,33 @@ export class MagazineRepository { .sort(compareByPublication); } - async findById(id: string): Promise { - const row = await this.db.getFirstAsync( - `SELECT id, publication, issue_number, edition, language, condition, publication_date, - barcode, notes, ocr_text, created_at, updated_at - FROM magazines - WHERE id = ?`, - id, + /** Dernières éditions ajoutées (accueil), les plus récentes d'abord. */ + async findRecent(limit = 5): Promise { + const rows = await this.db.getAllAsync( + `${LIST_SELECT} + ORDER BY created_at DESC, rowid DESC + LIMIT ?`, + limit, ); - if (!row) { - return null; - } + return rows.map(toMagazine); + } - const copyRows = await this.db.getAllAsync<{ - id: string; - magazine_id: string; - notes: string | null; - date_added: string; - }>( - `SELECT id, magazine_id, notes, date_added - FROM collection_items - WHERE magazine_id = ? - ORDER BY date_added DESC`, - id, + async countAll(): Promise { + const row = await this.db.getFirstAsync<{ count: number }>( + 'SELECT COUNT(*) AS count FROM magazines', ); + return Number(row?.count ?? 0); + } - const copies: CollectionItem[] = copyRows.map((r) => ({ - id: r.id, - magazineId: r.magazine_id, - notes: r.notes, - dateAdded: r.date_added, - })); + async findById(id: string): Promise { + const row = await this.db.getFirstAsync( + `${DETAIL_SELECT} + WHERE id = ?`, + id, + ); - return { ...toMagazine(row), copies }; + return row ? toMagazine(row) : null; } async delete(id: string): Promise { @@ -239,80 +217,9 @@ export class MagazineRepository { return magazine; } - /** - * Crée une édition **et** son premier exemplaire dans une seule transaction - * (US-COL : l'ajout d'une nouveauté produit toujours un exemplaire). Un échec - * de l'un des deux INSERT annule l'ensemble : pas d'édition orpheline. - */ - async createWithCopy( - input: CreateMagazineInput, - ): Promise<{ magazine: Magazine; copyId: string }> { - const publication = input.publication.trim(); - if (!publication) { - throw new Error('La publication est obligatoire.'); - } - - const now = new Date().toISOString(); - const magazine: Magazine = { - id: generateId(), - publication, - issueNumber: input.issueNumber ?? null, - edition: input.edition ?? null, - language: input.language ?? null, - condition: input.condition ?? null, - publicationDate: input.publicationDate ?? null, - barcode: input.barcode ?? null, - notes: input.notes ?? null, - ocrText: input.ocrText ?? null, - createdAt: now, - updatedAt: now, - }; - const copyId = generateId(); - - await this.db.execAsync('BEGIN'); - try { - await this.db.runAsync( - `INSERT INTO magazines - (id, publication, issue_number, edition, language, condition, publication_date, - barcode, notes, ocr_text, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - magazine.id, - magazine.publication, - magazine.issueNumber, - magazine.edition, - magazine.language, - magazine.condition, - magazine.publicationDate, - magazine.barcode, - magazine.notes, - magazine.ocrText, - magazine.createdAt, - magazine.updatedAt, - ); - await this.db.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - copyId, - magazine.id, - now, - ); - await this.db.execAsync('COMMIT'); - } catch (error) { - try { - await this.db.execAsync('ROLLBACK'); - } catch { - // Ignorer l'échec du rollback : on renvoie l'erreur d'origine. - } - throw error; - } - - return { magazine, copyId }; - } async update(id: string, input: CreateMagazineInput): Promise { const current = await this.db.getFirstAsync( - `SELECT id, publication, issue_number, edition, language, condition, publication_date, - barcode, notes, ocr_text, created_at, updated_at - FROM magazines + `${DETAIL_SELECT} WHERE id = ?`, id, ); diff --git a/src/database/schema.ts b/src/database/schema.ts index c87b12f..be864d9 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -1,4 +1,4 @@ -export const SCHEMA_VERSION = 4; +export const SCHEMA_VERSION = 5; export const MIGRATION_001 = ` CREATE TABLE IF NOT EXISTS magazines ( @@ -76,3 +76,30 @@ DROP INDEX IF EXISTS idx_magazines_barcode; CREATE INDEX IF NOT EXISTS idx_magazines_barcode ON magazines(barcode); `; + +/** + * v5 — Suppression du système d'exemplaires (retours test physique) : + * « un magazine en collection est possédé ». Le nombre d'exemplaires + * n'apporte rien à l'utilisateur. + * La note de la première copie (ex. « Acheté 0,50 € ») est reportée sur la + * note de l'édition si celle-ci est vide, puis la table `collection_items` + * est supprimée. + */ +export const MIGRATION_005 = ` +UPDATE magazines +SET notes = ( + SELECT ci.notes FROM collection_items ci + WHERE ci.magazine_id = magazines.id + AND ci.notes IS NOT NULL AND ci.notes != '' + ORDER BY ci.date_added ASC + LIMIT 1 +) +WHERE (notes IS NULL OR notes = '') + AND EXISTS ( + SELECT 1 FROM collection_items ci + WHERE ci.magazine_id = magazines.id + AND ci.notes IS NOT NULL AND ci.notes != '' + ); + +DROP TABLE IF EXISTS collection_items; +`; diff --git a/src/dependencies.ts b/src/dependencies.ts index 14acf32..fcb97fc 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -1,7 +1,7 @@ import type { Database } from '@/database/types'; import { getDatabase } from '@/database/db'; +// Migration avant création des dépôts : toutes les tables sont à jour. import { migrate } from '@/database/migrations'; -import { CollectionRepository } from '@/database/repositories/collection-repository'; import { MagazineRepository } from '@/database/repositories/magazine-repository'; import { SettingsRepository } from '@/database/repositories/settings-repository'; import { IdentificationService } from '@/identification/identificationService'; @@ -15,7 +15,6 @@ import type { FileGateway } from '@/backup/file-gateway'; export interface Dependencies { magazineRepository: MagazineRepository; - collectionRepository: CollectionRepository; settingsRepository: SettingsRepository; identificationService: IdentificationService; ocrEngine: OcrEngine; @@ -55,7 +54,6 @@ export async function initialize(): Promise { const magazineRepository = new MagazineRepository(db); deps = { magazineRepository, - collectionRepository: new CollectionRepository(db), settingsRepository: new SettingsRepository(db), identificationService: new IdentificationService(magazineRepository), ocrEngine, diff --git a/src/store/use-collection-store.ts b/src/store/use-collection-store.ts index 146be64..fbc463e 100644 --- a/src/store/use-collection-store.ts +++ b/src/store/use-collection-store.ts @@ -1,44 +1,42 @@ import { create } from 'zustand'; import { getDeps } from '@/dependencies'; -import type { CreateMagazineInput, MagazineDetail, MagazineListItem, RecentCopy } from '@/types'; +import type { CreateMagazineInput, Magazine, MagazineListItem } from '@/types'; interface CollectionState { magazines: MagazineListItem[]; - recentCopies: RecentCopy[]; - detail: MagazineDetail | null; + recent: MagazineListItem[]; + detail: Magazine | null; loading: boolean; detailLoading: boolean; error: string | null; loaded: boolean; - totalCopies: number; + totalMagazines: number; load: () => Promise; - /** Résumé léger (accueil) : compteur total + 5 derniers exemplaires, sans lister les éditions. */ + /** Résumé léger (accueil) : compteur d'éditions + dernières ajoutées. */ loadSummary: () => Promise; - loadDetail: (id: string) => Promise; + loadDetail: (id: string) => Promise; addMagazine: (input: CreateMagazineInput) => Promise; - addExistingCopy: (magazineId: string) => Promise; updateMagazine: (id: string, input: CreateMagazineInput) => Promise; removeMagazine: (id: string) => Promise; } export const useCollectionStore = create((set, get) => ({ magazines: [], - recentCopies: [], + recent: [], detail: null, loading: false, detailLoading: false, error: null, loaded: false, - totalCopies: 0, + totalMagazines: 0, load: async () => { set({ loading: true, error: null }); try { const { magazineRepository } = getDeps(); const magazines = await magazineRepository.list(); - const totalCopies = magazines.reduce((sum, m) => sum + m.quantity, 0); - set({ magazines, totalCopies, loading: false, loaded: true }); + set({ magazines, totalMagazines: magazines.length, loading: false, loaded: true }); } catch (err) { set({ loading: false, error: err instanceof Error ? err.message : 'Erreur inconnue' }); } @@ -47,12 +45,12 @@ export const useCollectionStore = create((set, get) => ({ loadSummary: async () => { set({ loading: true, error: null }); try { - const { collectionRepository } = getDeps(); - const [recentCopies, totalCopies] = await Promise.all([ - collectionRepository.listRecentCopies(5), - collectionRepository.countAllCopies(), + const { magazineRepository } = getDeps(); + const [recent, totalMagazines] = await Promise.all([ + magazineRepository.findRecent(5), + magazineRepository.countAll(), ]); - set({ recentCopies, totalCopies, loading: false, loaded: true }); + set({ recent, totalMagazines, loading: false, loaded: true }); } catch (err) { set({ loading: false, error: err instanceof Error ? err.message : 'Erreur inconnue' }); } @@ -73,28 +71,13 @@ export const useCollectionStore = create((set, get) => ({ addMagazine: async (input) => { const { magazineRepository } = getDeps(); - const { magazine } = await magazineRepository.createWithCopy(input); - const item: MagazineListItem = { ...magazine, quantity: 1 }; - set((state) => ({ - magazines: [item, ...state.magazines], - totalCopies: state.totalCopies + 1, - })); - return item; - }, - - addExistingCopy: async (magazineId) => { - const { collectionRepository } = getDeps(); - const copy = await collectionRepository.addCopy(magazineId); + const magazine = await magazineRepository.create(input); set((state) => ({ - magazines: state.magazines.map((m) => - m.id === magazineId ? { ...m, quantity: m.quantity + 1 } : m, - ), - detail: - state.detail && state.detail.id === magazineId - ? { ...state.detail, copies: [...state.detail.copies, copy] } - : state.detail, - totalCopies: state.totalCopies + 1, + magazines: [magazine, ...state.magazines], + recent: [magazine, ...state.recent].slice(0, 5), + totalMagazines: state.totalMagazines + 1, })); + return magazine; }, updateMagazine: async (id, input) => { @@ -104,9 +87,8 @@ export const useCollectionStore = create((set, get) => ({ throw new Error('Édition introuvable.'); } set((state) => ({ - magazines: state.magazines.map((m) => - m.id === id ? { ...m, ...updated, quantity: m.quantity } : m, - ), + magazines: state.magazines.map((m) => (m.id === id ? { ...m, ...updated } : m)), + recent: state.recent.map((m) => (m.id === id ? { ...m, ...updated } : m)), detail: state.detail && state.detail.id === id ? { ...state.detail, ...updated } : state.detail, })); @@ -116,10 +98,15 @@ export const useCollectionStore = create((set, get) => ({ const { magazineRepository } = getDeps(); await magazineRepository.delete(id); set((state) => { - const removed = state.magazines.find((m) => m.id === id); + const present = + state.magazines.some((m) => m.id === id) || + state.recent.some((m) => m.id === id) || + state.detail?.id === id; return { magazines: state.magazines.filter((m) => m.id !== id), - totalCopies: state.totalCopies - (removed?.quantity ?? 0), + recent: state.recent.filter((m) => m.id !== id), + totalMagazines: Math.max(0, state.totalMagazines - (present ? 1 : 0)), + detail: state.detail && state.detail.id === id ? null : state.detail, }; }); }, diff --git a/src/types/index.ts b/src/types/index.ts index 65f6bd6..053c623 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,20 +13,13 @@ export type Magazine = { updatedAt: string; }; -export type MagazineListItem = Magazine & { - quantity: number; -}; - -export type MagazineDetail = Magazine & { - copies: CollectionItem[]; -}; +/** + * Une édition en collection est toujours possédée (les exemplaires ont été + * supprimés — retours test physique) : l'élément de liste est l'édition. + */ +export type MagazineListItem = Magazine; -export type CollectionItem = { - id: string; - magazineId: string; - notes: string | null; - dateAdded: string; -}; +export type MagazineDetail = Magazine; export type CreateMagazineInput = { publication: string; @@ -39,12 +32,3 @@ export type CreateMagazineInput = { notes?: string | null; ocrText?: string | null; }; - -export type CreateCollectionItemInput = { - notes?: string | null; -}; - -export type RecentCopy = { - copy: CollectionItem; - magazine: Pick; -}; From 331a763063f31f0eb24948e37906924b667621bc Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 09:30:02 +0200 Subject: [PATCH 17/34] =?UTF-8?q?feat(M-11):=20OCR=20manuel=20(un=20appui?= =?UTF-8?q?=20=3D=20une=20photo),=20panneau=20de=20debug=20et=20sous-menu?= =?UTF-8?q?=20param=C3=A8tres=20avanc=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(tabs)/settings/_layout.tsx | 1 + src/app/(tabs)/settings/advanced.tsx | 126 ++++++++++++ src/app/scan/camera.tsx | 62 +++++- src/components/scan/camera-ocr-styles.ts | 115 ++++++++++- src/components/scan/ocr-analysis.ts | 24 +-- src/components/scan/ocr-analyzing-overlay.tsx | 39 ++-- src/components/scan/ocr-debug-panel.tsx | 66 ++++++ src/components/scan/use-ocr-analysis.ts | 194 ++++++++++-------- .../repositories/settings-repository.ts | 19 ++ src/store/use-settings-store.ts | 20 ++ 10 files changed, 538 insertions(+), 128 deletions(-) create mode 100644 src/app/(tabs)/settings/advanced.tsx create mode 100644 src/components/scan/ocr-debug-panel.tsx diff --git a/src/app/(tabs)/settings/_layout.tsx b/src/app/(tabs)/settings/_layout.tsx index 4f3f039..298e98e 100644 --- a/src/app/(tabs)/settings/_layout.tsx +++ b/src/app/(tabs)/settings/_layout.tsx @@ -12,6 +12,7 @@ export default function SettingsLayout() { + ); diff --git a/src/app/(tabs)/settings/advanced.tsx b/src/app/(tabs)/settings/advanced.tsx new file mode 100644 index 0000000..805fb55 --- /dev/null +++ b/src/app/(tabs)/settings/advanced.tsx @@ -0,0 +1,126 @@ +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Feather } from '@expo/vector-icons'; + +import { Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; +import { AppHeader } from '@/components/app-header'; +import { Screen } from '@/components/screen'; +import { useSettingsStore } from '@/store/use-settings-store'; + +/** Paramètres avancés — retours test physique : debug OCR activé depuis l'app. */ +export default function AdvancedSettingsScreen() { + const colors = useThemeColors(); + const styles = makeStyles(colors); + const router = useRouter(); + const ocrDebug = useSettingsStore((s) => s.ocrDebug); + const setOcrDebug = useSettingsStore((s) => s.setOcrDebug); + + return ( + + [styles.headerButton, pressed && styles.pressed]} + onPress={() => router.back()} + testID="advanced-back" + accessibilityRole="button" + accessibilityLabel="Retour"> + + + } + /> + + Reconnaissance + [styles.option, pressed && styles.pressed]} + onPress={() => setOcrDebug(!ocrDebug)} + testID="advanced-ocr-debug" + accessibilityRole="switch" + accessibilityState={{ checked: ocrDebug }}> + + Debug OCR + + Affiche en surcouche caméra le texte brut détecté, les champs parsés, la confiance et + le nombre de lectures identiques. + + + + + + + + + ); +} + +function makeStyles(colors: ThemeColors) { + return StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: colors.background, + }, + container: { + padding: Spacing.four, + gap: Spacing.three, + }, + headerButton: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.textSecondary, + marginTop: Spacing.two, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.three, + backgroundColor: colors.backgroundElement, + borderRadius: 10, + paddingVertical: Spacing.three, + paddingHorizontal: Spacing.three, + minHeight: 64, + }, + optionTextWrap: { + flex: 1, + gap: 2, + }, + optionLabel: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + }, + optionDescription: { + fontSize: 13, + color: colors.textSecondary, + }, + switch: { + width: 48, + height: 28, + borderRadius: 14, + backgroundColor: colors.textSecondary, + padding: 2, + }, + switchActive: { + backgroundColor: colors.accent, + }, + switchKnob: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#FFFFFF', + }, + switchKnobActive: { + transform: [{ translateX: 20 }], + }, + pressed: { + opacity: 0.8, + }, + }); +} diff --git a/src/app/scan/camera.tsx b/src/app/scan/camera.tsx index 9b2de08..129ff6d 100644 --- a/src/app/scan/camera.tsx +++ b/src/app/scan/camera.tsx @@ -1,12 +1,14 @@ import { CameraView } from 'expo-camera'; import { useRouter } from 'expo-router'; -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Feather } from '@expo/vector-icons'; import { CameraPermissionScreen } from '@/components/camera-permission-screen'; import { useThemeColors } from '@/hooks/use-theme'; import { OcrAnalyzingOverlay } from '@/components/scan/ocr-analyzing-overlay'; import { OcrConfirmOverlay } from '@/components/scan/ocr-confirm-overlay'; +import { OcrDebugPanel } from '@/components/scan/ocr-debug-panel'; import { OcrResultOverlay } from '@/components/scan/ocr-result-overlay'; import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; import { useOcrAnalysis } from '@/components/scan/use-ocr-analysis'; @@ -24,8 +26,12 @@ export default function CameraOcrScreen() { draft, torchOn, weakCycles, + capturing, + debugFrame, + ocrDebug, setTorchOn, setDraft, + capture, stopAndRetry, openConfirm, goManual, @@ -51,11 +57,34 @@ export default function CameraOcrScreen() { state.status === 'analyzing' || state.status === 'confirm' ? state.detected : null; const hint = - detected?.publication && detected?.issueNumber === null - ? 'Pointez maintenant le numéro du magazine' - : detected?.publication || detected?.issueNumber - ? 'Identification en cours…' - : 'Pointez la couverture du magazine dans le cadre'; + state.status === 'analyzing' + ? detected?.publication && detected?.issueNumber === null + ? 'Reprenez une photo pour lire le numéro' + : detected?.publication || detected?.issueNumber + ? 'Pointez la couverture puis appuyez sur le déclencheur' + : 'Pointez la couverture du magazine, puis appuyez sur le déclencheur' + : ''; + + const debugFields = { + publication: + state.status === 'analyzing' || state.status === 'confirm' + ? state.detected.publication + : state.status === 'found' || state.status === 'unknown' + ? state.publication + : null, + issueNumber: + state.status === 'analyzing' || state.status === 'confirm' + ? state.detected.issueNumber + : state.status === 'found' || state.status === 'unknown' + ? state.issueNumber + : null, + date: + state.status === 'analyzing' || state.status === 'confirm' + ? state.detected.date + : state.status === 'found' || state.status === 'unknown' + ? state.date + : null, + }; return ( @@ -73,6 +102,7 @@ export default function CameraOcrScreen() { detected={state.detected} hint={hint} weakCycles={weakCycles} + capturing={capturing} torchOn={torchOn} onOpenConfirm={openConfirm} onGoBarcode={goBarcode} @@ -82,6 +112,24 @@ export default function CameraOcrScreen() { /> )} + {isAnalyzing && ( + [ + styles.shutterButton, + capturing && styles.shutterButtonDisabled, + pressed && styles.buttonPressed, + ]} + onPress={capture} + disabled={capturing} + testID="ocr-shutter" + accessibilityRole="button" + accessibilityLabel={capturing ? 'Lecture en cours' : 'Prendre la photo pour la lecture'}> + + + + + )} + {isConfirming && ( )} + + {ocrDebug && } ); } diff --git a/src/components/scan/camera-ocr-styles.ts b/src/components/scan/camera-ocr-styles.ts index 28d3d34..15dae70 100644 --- a/src/components/scan/camera-ocr-styles.ts +++ b/src/components/scan/camera-ocr-styles.ts @@ -21,9 +21,17 @@ export function makeCameraOcrStyles(colors: ThemeColors, insets: { top: number; camera: { flex: 1, }, + /** + * Retours test physique : le cadre de détection est un guide visuel ré-ancré + * en haut de l'écran. L'OCR analyse toujours l'image entière. + */ + analyzingLayout: { + justifyContent: 'flex-start', + paddingTop: insets.top + 110, + }, reticle: { - width: 220, - height: 220, + width: 200, + height: 200, borderWidth: 3, borderColor: colors.accent, borderRadius: 16, @@ -86,18 +94,15 @@ export function makeCameraOcrStyles(colors: ThemeColors, insets: { top: number; fontWeight: '400', }, resultCard: { - alignSelf: 'stretch', - marginHorizontal: Spacing.four, - marginTop: insets.top + Spacing.three, - marginBottom: insets.bottom + Spacing.three, - maxHeight: '85%', + alignSelf: 'center', + width: '88%', backgroundColor: colors.backgroundElement, borderRadius: 16, - padding: Spacing.four, + padding: Spacing.three, }, resultCardContent: { alignItems: 'center', - gap: Spacing.three, + gap: Spacing.two, }, mutedTitle: { fontSize: 14, @@ -185,6 +190,56 @@ export function makeCameraOcrStyles(colors: ThemeColors, insets: { top: number; buttonPressed: { opacity: 0.8, }, + /** + * Bouton de prise de vue manuelle (retours test physique) : 1 appui → 1 + * photo → 1 lecture, sans capture automatique périodique. + */ + shutterButton: { + position: 'absolute', + bottom: insets.bottom + 28, + alignSelf: 'center', + width: 76, + height: 76, + borderRadius: 38, + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 4, + borderColor: 'rgba(0,0,0,0.25)', + }, + shutterButtonInner: { + width: 58, + height: 58, + borderRadius: 29, + backgroundColor: colors.accent, + alignItems: 'center', + justifyContent: 'center', + }, + shutterButtonDisabled: { + opacity: 0.55, + }, + analyzingActions: { + position: 'absolute', + bottom: insets.bottom + 118, + left: Spacing.four, + right: Spacing.four, + flexDirection: 'row', + gap: Spacing.two, + }, + analyzingActionButton: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.65)', + paddingVertical: Spacing.two, + borderRadius: 10, + }, + analyzingActionText: { + fontSize: 14, + fontWeight: '700', + color: '#FFFFFF', + textAlign: 'center', + }, cancelButton: { marginTop: Spacing.three, alignSelf: 'center', @@ -222,7 +277,7 @@ export function makeCameraOcrStyles(colors: ThemeColors, insets: { top: number; position: 'absolute', left: Spacing.four, right: Spacing.four, - bottom: insets.bottom + Spacing.three, + bottom: insets.bottom + 168, flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', @@ -256,6 +311,46 @@ export function makeCameraOcrStyles(colors: ThemeColors, insets: { top: number; fontWeight: '700', color: colors.accentText, }, + debugPanel: { + position: 'absolute', + top: insets.top + 60, + left: Spacing.three, + right: Spacing.three, + backgroundColor: 'rgba(0,0,0,0.82)', + borderRadius: 10, + padding: Spacing.two, + gap: 4, + }, + debugTitle: { + fontSize: 12, + fontWeight: '800', + color: '#7FDBFF', + textAlign: 'center', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + debugRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: Spacing.two, + }, + debugLabel: { + fontSize: 12, + fontWeight: '700', + color: 'rgba(255,255,255,0.85)', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + debugValue: { + flexShrink: 1, + fontSize: 12, + lineHeight: 16, + color: '#7FDBFF', + textAlign: 'right', + }, + debugValueEmpty: { + color: 'rgba(255,255,255,0.6)', + }, }); } diff --git a/src/components/scan/ocr-analysis.ts b/src/components/scan/ocr-analysis.ts index cd1425a..0f071f9 100644 --- a/src/components/scan/ocr-analysis.ts +++ b/src/components/scan/ocr-analysis.ts @@ -1,17 +1,5 @@ import { MIN_CONFIDENCE } from '@/identification/ocr/ocrTextParser'; -/** - * Intervalle d'analyse OCR : quelques frames / seconde max (pas toutes). - */ -export const ANALYSIS_INTERVAL_MS = 500; - -/** - * Lectures OCR identiques requises avant de conclure une identification - * (M10R2-09 — vote multi-frames conservateur, une lecture isolée pouvant être - * erronée sur des textes stylisés / à encres faibles). - */ -export const OCR_STABLE_READS = 2; - /** * Informations détectées par l'OCR, affichées en surcouche caméra (US-ID-08) * et proposées à la validation / correction (US-ID-09). @@ -24,8 +12,18 @@ export type DetectedInfo = { export const EMPTY_DETECTED: DetectedInfo = { publication: null, issueNumber: null, date: null }; +/** + * Données brutes du debug OCR (paramètres avancés) : texte reconnu, confiance + * de la dernière lecture et nombre de lectures identiques consécutives. + */ +export type OcrDebugFrame = { + rawText: string; + confidence: number | null; + voteCount: number; +}; + export type OcrUiState = - | { status: 'analyzing'; detected: DetectedInfo } + | { status: 'analyzing'; detected: DetectedInfo; noText?: boolean } | { status: 'confirm'; detected: DetectedInfo } | { status: 'found'; diff --git a/src/components/scan/ocr-analyzing-overlay.tsx b/src/components/scan/ocr-analyzing-overlay.tsx index d18270e..32439fb 100644 --- a/src/components/scan/ocr-analyzing-overlay.tsx +++ b/src/components/scan/ocr-analyzing-overlay.tsx @@ -10,6 +10,7 @@ type Props = { detected: DetectedInfo; hint: string; weakCycles: number; + capturing: boolean; torchOn: boolean; onOpenConfirm: () => void; onGoBarcode: () => void; @@ -23,6 +24,7 @@ export function OcrAnalyzingOverlay({ detected, hint, weakCycles, + capturing, torchOn, onOpenConfirm, onGoBarcode, @@ -34,15 +36,17 @@ export function OcrAnalyzingOverlay({ return ( <> - - + + {hint} - - - Lecture… - + {capturing && ( + + + Lecture… + + )} {/* Surcouche US-ID-08 : champs détectés en direct. */} @@ -89,13 +93,22 @@ export function OcrAnalyzingOverlay({ )} - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={onGoBarcode} - testID="ocr-barcode" - accessibilityRole="button"> - Scanner le code-barres - + + [styles.analyzingActionButton, pressed && styles.buttonPressed]} + onPress={() => onGoManual(detected)} + testID="ocr-manual-shortcut" + accessibilityRole="button"> + Saisie manuelle + + [styles.analyzingActionButton, pressed && styles.buttonPressed]} + onPress={onGoBarcode} + testID="ocr-barcode" + accessibilityRole="button"> + Scanner le code-barres + + [styles.backButton, pressed && styles.buttonPressed]} diff --git a/src/components/scan/ocr-debug-panel.tsx b/src/components/scan/ocr-debug-panel.tsx new file mode 100644 index 0000000..5978af4 --- /dev/null +++ b/src/components/scan/ocr-debug-panel.tsx @@ -0,0 +1,66 @@ +import { Text, View } from 'react-native'; + +import type { CameraOcrStyles } from './camera-ocr-styles'; +import type { OcrDebugFrame } from './ocr-analysis'; + +type DebugFields = { + publication: string | null; + issueNumber: number | null; + date: string | null; +}; + +type Props = { + styles: CameraOcrStyles; + frame: OcrDebugFrame | null; + fields: DebugFields; +}; + +/** + * Panneau de debug OCR (paramètres avancés) : texte brut reconnu, champs + * parsés, confiance et nombre de lectures identiques consécutives. + */ +export function OcrDebugPanel({ styles, frame, fields }: Props) { + const confidence = frame?.confidence != null ? `${Math.round(frame.confidence * 100)} %` : null; + + return ( + + Debug OCR + + Texte brut + + {frame?.rawText || '…'} + + + + Nom + + {fields.publication ?? '…'} + + + + Numéro + + {fields.issueNumber?.toString() ?? '…'} + + + + Confiance + + {confidence ?? '…'} + + + + Lectures identiques + + {frame?.voteCount ?? '…'} + + + + ); +} diff --git a/src/components/scan/use-ocr-analysis.ts b/src/components/scan/use-ocr-analysis.ts index 6cd04be..5ffbd0c 100644 --- a/src/components/scan/use-ocr-analysis.ts +++ b/src/components/scan/use-ocr-analysis.ts @@ -1,25 +1,24 @@ import { useCameraPermissions } from 'expo-camera'; -import { useRouter } from 'expo-router'; -import { useEffect, useRef, useState } from 'react'; import type { CameraView as CameraViewType } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import { useRef, useState } from 'react'; import { getDeps } from '@/dependencies'; -import { OcrTextStabilizer } from '@/identification/ocr/ocrTextStabilizer'; +import { useSettingsStore } from '@/store/use-settings-store'; import { - ANALYSIS_INTERVAL_MS, buildManualParams, EMPTY_DETECTED, - OCR_STABLE_READS, type DetectedInfo, + type OcrDebugFrame, type OcrUiState, } from './ocr-analysis'; /** - * Logique d'analyse OCR d'une couverture (détection périodique, vote multi-frames, - * surcouche de validation / correction, recherche hors confiance). - * - * Expose l'état d'interface OCR et les actions associées de façon découplée de - * la vue, afin de la rendre testable. + * Logique d'analyse OCR d'une couverture. Retours test physique : la capture + * est désormais manuelle (1 appui → 1 photo → 1 lecture, sans son + * d'obturateur) au lieu d'une capture périodique. Le debug OCR (paramètres + * avancés) expose le texte brut, les champs parsés, la confiance et le nombre + * de lectures identiques consécutives. */ export function useOcrAnalysis() { const router = useRouter(); @@ -31,102 +30,121 @@ export function useOcrAnalysis() { const [draft, setDraft] = useState(EMPTY_DETECTED); const [torchOn, setTorchOn] = useState(false); const [weakCycles, setWeakCycles] = useState(0); + const [capturing, setCapturing] = useState(false); + const [debugFrame, setDebugFrame] = useState(null); const inFlight = useRef(false); + const lastKeyRef = useRef(null); + const voteCountRef = useRef(0); const cameraRef = useRef(null); - const ocrStabilizer = useRef(new OcrTextStabilizer(OCR_STABLE_READS)); + const ocrDebug = useSettingsStore((s) => s.ocrDebug); - useEffect(() => { - if (!permission?.granted || state.status !== 'analyzing') { + const rememberFrame = (rawText: string, confidence: number | null) => { + setDebugFrame({ rawText, confidence, voteCount: voteCountRef.current }); + }; + + const capture = async () => { + if (inFlight.current || state.status !== 'analyzing') { return; } + inFlight.current = true; + setCapturing(true); + try { + const { ocrEngine, identificationService } = getDeps(); + // Capture manuelle éphémère (aucune image persistée) → URI. + // `shutterSound: false` (expo-camera ≤ 57) : pas de son d'obturateur. + const photo = await cameraRef.current?.takePictureAsync?.({ + quality: 1, + skipProcessing: false, + shutterSound: false, + }); + const uri = photo?.uri ?? null; + const frame = await ocrEngine.recognize({ native: uri, width: 0, height: 0 }); + const text = frame?.text ?? ''; + if (!frame) { + voteCountRef.current = 0; + rememberFrame(text, null); + setState((prev) => + prev.status === 'analyzing' + ? { status: 'analyzing', detected: EMPTY_DETECTED, noText: true } + : prev, + ); + return; + } + const result = await identificationService.identifyByOCR(frame.text); + + const key = [ + result.status === 'no-text' ? '' : result.publication, + result.status === 'no-text' + ? '' + : result.issueNumber != null + ? String(result.issueNumber) + : '', + result.status === 'no-text' ? '' : (result.date ?? ''), + ].join('|'); + voteCountRef.current = key === lastKeyRef.current ? voteCountRef.current + 1 : 1; + lastKeyRef.current = key; + const confidence = result.status === 'no-text' ? null : result.confidence; + rememberFrame(frame.text, confidence); + + if (result.status === 'no-text') { + setState((prev) => + prev.status === 'analyzing' + ? { status: 'analyzing', detected: EMPTY_DETECTED, noText: true } + : prev, + ); + return; + } - const { ocrEngine, identificationService } = getDeps(); - const interval = setInterval(async () => { - if (inFlight.current) { + if (result.status === 'weak') { + // Confiance partielle : on met en surcouche les champs détectés et on + // laisse l'utilisateur reprendre une photo ou valider (US-ID-08). + setWeakCycles((c) => c + 1); + setState((prev) => + prev.status === 'analyzing' + ? { + status: 'analyzing', + detected: { + publication: + result.publication === 'Publication inconnue' ? null : result.publication, + issueNumber: result.issueNumber, + date: result.date, + }, + } + : prev, + ); return; } - inFlight.current = true; - try { - // Capture éphémère d'une photo (aucune image persistée) → URI. - // Haute résolution (M10R2-09) : préserve les petites encres des textes stylisés. - const photo = await cameraRef.current?.takePictureAsync?.({ - quality: 1, - skipProcessing: false, - }); - const uri = photo?.uri ?? null; - const frame = await ocrEngine.recognize({ native: uri, width: 0, height: 0 }); - if (!frame) { - return; - } - const result = await identificationService.identifyByOCR(frame.text); - - if (result.status === 'no-text') { - return; - } - - if (result.status === 'weak') { - // US-ID-08 : on ne conclut plus en échec dès la première lecture partielle. - // On met en surcouche les champs détectés et on continue d'analyser - // (le pointeur guide l'utilisateur vers le champ manquant). - setWeakCycles((c) => c + 1); - setState((prev) => - prev.status === 'analyzing' - ? { - status: 'analyzing', - detected: { - publication: - result.publication === 'Publication inconnue' ? null : result.publication, - issueNumber: result.issueNumber, - date: result.date, - }, - } - : prev, - ); - return; - } - - // Vote multi-frames (M10R2-09) : on ne conclut pas sur une lecture - // isolée, une frame suivante identique est requise (textes stylisés). - const key = [ - result.publication ?? '', - result.issueNumber != null ? String(result.issueNumber) : '', - result.date ?? '', - ].join('|'); - if (!ocrStabilizer.current.push(key)) { - return; - } - ocrStabilizer.current.reset(); - - if (result.status === 'unknown') { - setState({ - status: 'unknown', - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - return; - } + if (result.status === 'unknown') { setState({ - status: 'found', - id: result.magazine.id, + status: 'unknown', publication: result.publication, issueNumber: result.issueNumber, date: result.date, confidence: result.confidence, }); - } finally { - inFlight.current = false; + return; } - }, ANALYSIS_INTERVAL_MS); - return () => clearInterval(interval); - }, [permission?.granted, state.status]); + setState({ + status: 'found', + id: result.magazine.id, + publication: result.publication, + issueNumber: result.issueNumber, + date: result.date, + confidence: result.confidence, + }); + } finally { + inFlight.current = false; + setCapturing(false); + } + }; const stopAndRetry = () => { - ocrStabilizer.current.reset(); + lastKeyRef.current = null; + voteCountRef.current = 0; setWeakCycles(0); + setDebugFrame(null); setState({ status: 'analyzing', detected: EMPTY_DETECTED }); }; @@ -193,8 +211,12 @@ export function useOcrAnalysis() { draft, torchOn, weakCycles, + capturing, + debugFrame, + ocrDebug, setTorchOn, setDraft, + capture, stopAndRetry, openConfirm, goManual, diff --git a/src/database/repositories/settings-repository.ts b/src/database/repositories/settings-repository.ts index 93ebf5f..cc691ef 100644 --- a/src/database/repositories/settings-repository.ts +++ b/src/database/repositories/settings-repository.ts @@ -6,6 +6,7 @@ const DEFAULT_COLOR_SCHEME: ColorSchemeSetting = 'system'; const COLOR_SCHEME_KEY = 'color_scheme'; const ONBOARDING_DONE_KEY = 'onboarding_done'; const REDUCED_MOTION_KEY = 'reduced_motion'; +const OCR_DEBUG_KEY = 'ocr_debug'; /** * Persistance des réglages clé/valeur (ex. le thème manuel) dans la table @@ -68,4 +69,22 @@ export class SettingsRepository { reduced ? 'true' : 'false', ); } + + /** Debug OCR (paramètres avancés) : surcouche caméra des données brutes OCR. */ + async getOcrDebug(): Promise { + const row = await this.db.getFirstAsync<{ value: string }>( + 'SELECT value FROM settings WHERE key = ?', + OCR_DEBUG_KEY, + ); + return row?.value === 'true'; + } + + async setOcrDebug(enabled: boolean): Promise { + await this.db.runAsync( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + OCR_DEBUG_KEY, + enabled ? 'true' : 'false', + ); + } } diff --git a/src/store/use-settings-store.ts b/src/store/use-settings-store.ts index 5a1d7d3..5f85bc4 100644 --- a/src/store/use-settings-store.ts +++ b/src/store/use-settings-store.ts @@ -8,12 +8,15 @@ interface SettingsState { onboardingDone: boolean; onboardingLoaded: boolean; reducedMotion: boolean; + ocrDebug: boolean; setColorScheme: (colorScheme: ColorSchemeSetting) => void; loadColorScheme: () => Promise; loadOnboardingDone: () => Promise; markOnboardingDone: () => void; setReducedMotion: (reduced: boolean) => void; loadReducedMotion: () => Promise; + setOcrDebug: (enabled: boolean) => void; + loadOcrDebug: () => Promise; } export const useSettingsStore = create((set) => ({ @@ -21,6 +24,7 @@ export const useSettingsStore = create((set) => ({ onboardingDone: false, onboardingLoaded: false, reducedMotion: false, + ocrDebug: false, setColorScheme: (colorScheme) => { set({ colorScheme }); @@ -69,4 +73,20 @@ export const useSettingsStore = create((set) => ({ const reducedMotion = await getDeps().settingsRepository.getReducedMotion(); set({ reducedMotion }); }, + + setOcrDebug: (enabled) => { + set({ ocrDebug: enabled }); + try { + void getDeps() + .settingsRepository.setOcrDebug(enabled) + .catch(() => undefined); + } catch { + // dépendances pas encore initialisées : on ignore la persistance + } + }, + + loadOcrDebug: async () => { + const ocrDebug = await getDeps().settingsRepository.getOcrDebug(); + set({ ocrDebug }); + }, })); From b49090e5873ebbdef965886705e656f4af6586e8 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 09:30:05 +0200 Subject: [PATCH 18/34] =?UTF-8?q?refactor(M-11):=20scan=20code-barres=20?= =?UTF-8?q?=E2=80=94=20panier=20poss=C3=A9d=C3=A9/continu,=20=C3=A9tats=20?= =?UTF-8?q?repli=20et=20recherche=20sans=20quantit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/scan/barcode.tsx | 2 - src/app/scan/multiple.tsx | 2 +- src/app/scan/result.tsx | 21 ++------ src/app/scan/search.tsx | 21 +------- src/components/scan-search-views.tsx | 43 ++------------- .../scan/barcode-pending-sheets.tsx | 54 +++++-------------- src/components/scan/use-barcode-scanning.ts | 38 +++---------- src/hooks/use-scan-result.ts | 49 +++-------------- 8 files changed, 37 insertions(+), 193 deletions(-) diff --git a/src/app/scan/barcode.tsx b/src/app/scan/barcode.tsx index 620fb52..e98cd6c 100644 --- a/src/app/scan/barcode.tsx +++ b/src/app/scan/barcode.tsx @@ -31,7 +31,6 @@ export default function BarcodeScreen() { handleScan, resume, reset, - confirmAdd, } = useBarcodeScanning(); if (!permission || !permission.granted) { @@ -86,7 +85,6 @@ export default function BarcodeScreen() { styles={styles} pending={pending} onResume={resume} - onConfirmAdd={confirmAdd} onManual={(barcode) => router.replace({ pathname: '/scan/manual', params: { barcode } })} /> )} diff --git a/src/app/scan/multiple.tsx b/src/app/scan/multiple.tsx index fa97c47..29b64fc 100644 --- a/src/app/scan/multiple.tsx +++ b/src/app/scan/multiple.tsx @@ -111,7 +111,7 @@ export default function MultipleBarcodeScreen() { n° {item.issueNumber} ) : null} - 0} quantity={item.quantity} /> + )} /> diff --git a/src/app/scan/result.tsx b/src/app/scan/result.tsx index 9e2807a..8dc6e86 100644 --- a/src/app/scan/result.tsx +++ b/src/app/scan/result.tsx @@ -37,7 +37,7 @@ export default function ScanResultScreen() { }, [id, loadDetail]), ); - const { resolved, owned, ownedCount, handleAddCopy } = useScanResult(detail, exists ? id : null); + const { resolved } = useScanResult(detail, exists ? id : null); const handleManual = () => { router.replace({ @@ -62,10 +62,8 @@ export default function ScanResultScreen() { N° {params.issueNumber} ) : null} {resolved ? ( - - {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} + + ✓ Possédé ) : ( @@ -83,19 +81,6 @@ export default function ScanResultScreen() { )} - {exists && resolved && ( - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={handleAddCopy} - testID={owned ? 'result-add-copy' : 'result-add'} - accessibilityRole="button" - accessibilityLabel={owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'}> - - {owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'} - - - )} - {exists && ( [styles.secondaryButton, pressed && styles.buttonPressed]} diff --git a/src/app/scan/search.tsx b/src/app/scan/search.tsx index 8b18b77..16249e7 100644 --- a/src/app/scan/search.tsx +++ b/src/app/scan/search.tsx @@ -1,5 +1,5 @@ -import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useState } from 'react'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'; import { Feather } from '@expo/vector-icons'; @@ -14,7 +14,6 @@ import { } from '@/components/scan-search-views'; import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; -import { useScanResult } from '@/hooks/use-scan-result'; import { useCollectionStore } from '@/store/use-collection-store'; import type { OcrLookupResult } from '@/identification/identificationService'; @@ -25,8 +24,6 @@ export default function ScanSearchScreen() { const colors = useThemeColors(); const screenStyles = makeSearchScreenStyles(colors); const magazines = useCollectionStore((s) => s.magazines); - const loadDetail = useCollectionStore((s) => s.loadDetail); - const detail = useCollectionStore((s) => s.detail); const [publication, setPublication] = useState(''); const [issueNumber, setIssueNumber] = useState(EMPTY_ISSUE); @@ -40,16 +37,6 @@ export default function ScanSearchScreen() { const foundId = result?.status === 'found' ? result.magazine.id : null; - useFocusEffect( - useCallback(() => { - if (foundId) { - loadDetail(foundId); - } - }, [foundId, loadDetail]), - ); - - const { resolved, owned, ownedCount, handleAddCopy } = useScanResult(detail, foundId); - const issue = issueNumber.replace(/[^0-9]/g, ''); const canSearch = publication.trim().length > 0 && issue.length > 0 && !searching; @@ -128,10 +115,6 @@ export default function ScanSearchScreen() { router.replace(`/collection/${foundId}`)} onRescan={() => router.replace('/scan/barcode')} /> diff --git a/src/components/scan-search-views.tsx b/src/components/scan-search-views.tsx index f2fcc54..3249ae2 100644 --- a/src/components/scan-search-views.tsx +++ b/src/components/scan-search-views.tsx @@ -54,16 +54,6 @@ export function makeSearchResultStyles(colors: ThemeColors) { borderRadius: 8, overflow: 'hidden', }, - absentText: { - fontSize: 14, - fontWeight: '700', - color: colors.absentText, - backgroundColor: colors.absentBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, primaryButton: { alignItems: 'center', justifyContent: 'center', @@ -101,10 +91,6 @@ export function makeSearchResultStyles(colors: ThemeColors) { type SearchFoundResultProps = { publication: string; issueNumber: number | null; - owned: boolean; - ownedCount: number; - resolved: boolean; - onAddCopy: () => void; onView: () => void; onRescan: () => void; }; @@ -112,10 +98,6 @@ type SearchFoundResultProps = { export function SearchFoundResult({ publication, issueNumber, - owned, - ownedCount, - resolved, - onAddCopy, onView, onRescan, }: SearchFoundResultProps) { @@ -130,29 +112,10 @@ export function SearchFoundResult({ {publication} {issueNumber != null ? N° {issueNumber} : null} - {resolved ? ( - - {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} - - ) : ( - - Vérification… - - )} - - - [styles.primaryButton, pressed && styles.pressed]} - onPress={onAddCopy} - testID="search-add" - accessibilityRole="button" - accessibilityLabel={owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'}> - - {owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'} + + ✓ Possédé - + [styles.secondaryButton, pressed && styles.pressed]} diff --git a/src/components/scan/barcode-pending-sheets.tsx b/src/components/scan/barcode-pending-sheets.tsx index 7b90e00..31316d9 100644 --- a/src/components/scan/barcode-pending-sheets.tsx +++ b/src/components/scan/barcode-pending-sheets.tsx @@ -9,18 +9,22 @@ type Props = { styles: BarcodeStyles; pending: Pending; onResume: () => void; - onConfirmAdd: () => void; onManual: (barcode: string) => void; }; -export function BarcodePendingSheets({ styles, pending, onResume, onConfirmAdd, onManual }: Props) { +/** + * Paniers du scan de code-barres (mode continu). Retours test physique : le + * système d'exemplaires étant supprimé, un magazine scanné est toujours + * possédé — le panier propose simplement de continuer. + */ +export function BarcodePendingSheets({ styles, pending, onResume, onManual }: Props) { const colors = useThemeColors(); - if (pending.kind === 'confirm') { + if (pending.kind === 'owned') { return ( - + [styles.sheetClose, pressed && styles.buttonPressed]} onPress={onResume} @@ -29,50 +33,18 @@ export function BarcodePendingSheets({ styles, pending, onResume, onConfirmAdd, accessibilityLabel="Fermer"> - Vous possédez déjà ce magazine + Déjà dans votre collection {pending.magazine.publication} {pending.magazine.issueNumber != null ? ` n° ${pending.magazine.issueNumber}` : ''} - - Exemplaires actuels : {pending.ownedCount}. Ajouter un exemplaire ? - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={onConfirmAdd} - testID="pending-confirm-add" - accessibilityRole="button"> - Ajouter un exemplaire - - [styles.pendingCancel, pressed && styles.buttonPressed]} - onPress={onResume} - testID="pending-confirm-cancel" - accessibilityRole="button"> - Annuler - - - - - ); - } - - if (pending.kind === 'success') { - return ( - - - - Ajouté à la collection - - {pending.publication} - {pending.issueNumber != null ? ` n° ${pending.issueNumber}` : ''} - + Cette édition est bien possédée. [styles.primaryButton, pressed && styles.buttonPressed]} onPress={onResume} - testID="pending-success-ok" + testID="pending-owned-ok" accessibilityRole="button"> - Scanner le suivant + Continuer @@ -122,7 +94,7 @@ export function BarcodeContinuousBar({ }) { return ( - Scan en continu — ajoute chaque exemplaire + Scan en continu — vérifie chaque code-barres [styles.continuousStop, pressed && styles.buttonPressed]} onPress={onStop} diff --git a/src/components/scan/use-barcode-scanning.ts b/src/components/scan/use-barcode-scanning.ts index 59d5e63..63a0d71 100644 --- a/src/components/scan/use-barcode-scanning.ts +++ b/src/components/scan/use-barcode-scanning.ts @@ -4,24 +4,23 @@ import { useRef, useState } from 'react'; import { getDeps } from '@/dependencies'; import { BarcodeStabilizer } from '@/identification/barcodeStabilizer'; -import { useCollectionStore } from '@/store/use-collection-store'; import type { Magazine } from '@/types'; export type ScanState = { status: 'idle' } | { status: 'searching' } | { status: 'invalid'; reason: string }; -export type Pending = - | { kind: 'confirm'; magazine: Magazine; ownedCount: number } - | { kind: 'success'; publication: string; issueNumber: number | null } - | { kind: 'unknown'; barcode: string }; +export type Pending = { kind: 'owned'; magazine: Magazine } | { kind: 'unknown'; barcode: string }; /** * Logique du scan de code-barres (stabilisation multi-lectures, identification, * mode continu) découplée de la vue pour la rendre testable. + * + * Retours test physique : le système d'exemplaires a été supprimé — un magazine + * en collection est donc toujours possédé, il n'y a plus d'ajout d'exemplaire + * (état « confirm » devenu « déjà en collection ») ni d'état « success ». */ export function useBarcodeScanning() { const router = useRouter(); - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); const params = useLocalSearchParams<{ continuous?: string }>(); const [permission, requestPermission] = useCameraPermissions(); @@ -65,21 +64,11 @@ export function useBarcodeScanning() { }; const handleContinuous = async (stabilized: string) => { - const { identificationService, collectionRepository } = getDeps(); + const { identificationService } = getDeps(); const result = await identificationService.identifyByBarcode(stabilized); if (result.status === 'found') { - const ownedCount = await collectionRepository.countByMagazine(result.magazine.id); - if (ownedCount > 0) { - setPending({ kind: 'confirm', magazine: result.magazine, ownedCount }); - } else { - await addExistingCopy(result.magazine.id); - setPending({ - kind: 'success', - publication: result.magazine.publication, - issueNumber: result.magazine.issueNumber, - }); - } + setPending({ kind: 'owned', magazine: result.magazine }); } else if (result.status === 'ambiguous') { router.replace({ pathname: '/scan/multiple', params: { barcode: stabilized } }); } else if (result.status === 'unknown') { @@ -114,18 +103,6 @@ export function useBarcodeScanning() { setState({ status: 'idle' }); }; - const confirmAdd = async () => { - if (pending?.kind !== 'confirm') { - return; - } - await addExistingCopy(pending.magazine.id); - setPending({ - kind: 'success', - publication: pending.magazine.publication, - issueNumber: pending.magazine.issueNumber, - }); - }; - return { permission, requestPermission, @@ -139,6 +116,5 @@ export function useBarcodeScanning() { handleScan, resume, reset, - confirmAdd, }; } diff --git a/src/hooks/use-scan-result.ts b/src/hooks/use-scan-result.ts index 364afc4..fe23af2 100644 --- a/src/hooks/use-scan-result.ts +++ b/src/hooks/use-scan-result.ts @@ -1,46 +1,13 @@ -import { useCallback } from 'react'; -import { Alert } from 'react-native'; - -import { toast } from '@/lib/toast'; -import { useCollectionStore } from '@/store/use-collection-store'; -import type { MagazineDetail } from '@/types'; +import type { Magazine } from '@/types'; /** - * Statut partagé entre les écrans « résultat du scan » et « recherche - * manuelle » : résolution de l'édition affichée, possession d'un exemplaire - * et ajout d'un exemplaire (avec confirmation si déjà possédé). + * Résolution de l'édition affichée sur les écrans « résultat du scan » et + * « recherche manuelle » : une fois le détail chargé pour l'identifiant visé, + * l'état est résolu. Chaque édition en collection étant possédée (le système + * d'exemplaires a été supprimé — retours test physique), il n'y a plus de + * statut « absent » ni d'ajout d'exemplaire. */ -export function useScanResult(detail: MagazineDetail | null, id: string | null) { - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); - +export function useScanResult(detail: Magazine | null, id: string | null) { const resolved = detail != null && detail.id === id; - const owned = resolved && detail.copies.length > 0; - const ownedCount = resolved ? detail.copies.length : 0; - - const handleAddCopy = useCallback(() => { - if (!id) { - return; - } - const alreadyOwned = owned; - - const perform = async () => { - await addExistingCopy(id); - toast(alreadyOwned ? 'Exemplaire ajouté à la collection' : 'Ajouté à la collection'); - }; - - if (alreadyOwned) { - Alert.alert( - 'Vous possédez déjà ce magazine', - `Exemplaires actuels : ${ownedCount}\nVoulez-vous ajouter un deuxième exemplaire ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Ajouter quand même', style: 'destructive', onPress: perform }, - ], - ); - } else { - perform(); - } - }, [id, owned, ownedCount, addExistingCopy]); - - return { resolved, owned, ownedCount, handleAddCopy }; + return { resolved }; } From 81f0939479743185c2c054890a2f91ba8022458c Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 09:30:08 +0200 Subject: [PATCH 19/34] =?UTF-8?q?refactor(M-11):=20backup=20sans=20exempla?= =?UTF-8?q?ires=20=E2=80=94=20export=20CSV=2011=20colonnes=20compatible=20?= =?UTF-8?q?et=20validation=20des=20anciens=20exports=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(tabs)/settings/backup.tsx | 2 +- src/backup/backup-csv.ts | 78 ++++++++++-------------------- src/backup/backup-parsers.ts | 27 ++--------- src/backup/backup-service.ts | 47 ++---------------- src/backup/backup-types.ts | 8 --- src/store/use-backup-store.ts | 2 +- 6 files changed, 37 insertions(+), 127 deletions(-) diff --git a/src/app/(tabs)/settings/backup.tsx b/src/app/(tabs)/settings/backup.tsx index 1337d03..75b3cac 100644 --- a/src/app/(tabs)/settings/backup.tsx +++ b/src/app/(tabs)/settings/backup.tsx @@ -50,7 +50,7 @@ export default function BackupScreen() { } Alert.alert( 'Remplacer la collection ?', - `L’import remplacera la collection actuelle par celle du fichier.\n\nFichier : ${summary.magazines} édition(s), ${summary.copies} exemplaire(s).`, + `L’import remplacera la collection actuelle par celle du fichier.\n\nFichier : ${summary.magazines} édition(s).`, [ { text: 'Annuler', style: 'cancel' }, { diff --git a/src/backup/backup-csv.ts b/src/backup/backup-csv.ts index 816b4b6..a02bab9 100644 --- a/src/backup/backup-csv.ts +++ b/src/backup/backup-csv.ts @@ -3,12 +3,15 @@ import { InvalidBackupError } from './backup-parsers'; import { BACKUP_FORMAT, BACKUP_VERSION, - type BackupCopy, type BackupFile, type BackupMagazine, } from './backup-types'; import { APP_VERSION } from '@/utils/app-version'; +/** + * En-têtes CSV lus à l'import (v2). Les colonnes `copyNotes`/`dateAdded` de la + * v1 ne sont plus attendues : chaque ligne représente une édition unique. + */ export const BACKUP_CSV_HEADERS = [ 'publication', 'issueNumber', @@ -19,8 +22,8 @@ export const BACKUP_CSV_HEADERS = [ 'barcode', 'notes', 'ocrText', - 'copyNotes', - 'dateAdded', + 'createdAt', + 'updatedAt', ] as const; export function escapeCsvField(value: string): string { @@ -140,9 +143,8 @@ export function parseCsvBackup(raw: string): BackupFile { // numéros à `issueNumber` null distincts par état/édition) au lieu de // collapse la 1ère ligne seulement. const key = `${publication}::${issueNumber ?? ''}::${edition ?? ''}::${language ?? ''}::${condition ?? ''}`; - let magazine = byKey.get(key); - if (!magazine) { - magazine = { + if (!byKey.has(key)) { + byKey.set(key, { id: generateId(), publication, issueNumber, @@ -153,28 +155,11 @@ export function parseCsvBackup(raw: string): BackupFile { barcode: asCsvString(at('barcode')), notes: asCsvString(at('notes')), ocrText: asCsvString(at('ocrText')), - copies: [], createdAt: asCsvString(at('createdAt')), updatedAt: asCsvString(at('updatedAt')), - }; - byKey.set(key, magazine); - magazines.push(magazine); + }); + magazines.push(byKey.get(key) as BackupMagazine); } - - const copyNotes = at('copyNotes').trim(); - const dateAdded = at('dateAdded').trim(); - // Une ligne aux champs exemplaire vides désigne une édition **sans copie** - // (l'export `toCsv` émet une telle ligne pour la conserver). Ne pas la - // réimporter comme un exemplaire fantôme daté d'aujourd'hui. - if (copyNotes.length === 0 && dateAdded.length === 0) { - continue; - } - const copy: BackupCopy = { - id: generateId(), - notes: copyNotes.length > 0 ? copyNotes : null, - dateAdded: dateAdded.length > 0 ? dateAdded : new Date().toISOString(), - }; - magazine.copies.push(copy); } return { @@ -187,35 +172,24 @@ export function parseCsvBackup(raw: string): BackupFile { } export function toCsv(file: BackupFile): string { - const header = [...BACKUP_CSV_HEADERS, 'createdAt', 'updatedAt'].join(','); + const header = BACKUP_CSV_HEADERS.join(','); const lines: string[] = []; for (const magazine of file.magazines) { - const base = [ - escapeCsvField(magazine.publication), - magazine.issueNumber === null ? '' : escapeCsvField(String(magazine.issueNumber)), - escapeCsvField(magazine.edition ?? ''), - escapeCsvField(magazine.language ?? ''), - escapeCsvField(magazine.condition ?? ''), - escapeCsvField(magazine.publicationDate ?? ''), - escapeCsvField(magazine.barcode ?? ''), - escapeCsvField(magazine.notes ?? ''), - escapeCsvField(magazine.ocrText ?? ''), - ]; - const copies = - magazine.copies.length > 0 - ? magazine.copies - : [{ id: magazine.id, notes: null, dateAdded: '' }]; - for (const copy of copies) { - lines.push( - [ - ...base, - escapeCsvField(copy.notes ?? ''), - escapeCsvField(copy.dateAdded), - escapeCsvField(magazine.createdAt ?? ''), - escapeCsvField(magazine.updatedAt ?? ''), - ].join(','), - ); - } + lines.push( + [ + escapeCsvField(magazine.publication), + magazine.issueNumber === null ? '' : escapeCsvField(String(magazine.issueNumber)), + escapeCsvField(magazine.edition ?? ''), + escapeCsvField(magazine.language ?? ''), + escapeCsvField(magazine.condition ?? ''), + escapeCsvField(magazine.publicationDate ?? ''), + escapeCsvField(magazine.barcode ?? ''), + escapeCsvField(magazine.notes ?? ''), + escapeCsvField(magazine.ocrText ?? ''), + escapeCsvField(magazine.createdAt ?? ''), + escapeCsvField(magazine.updatedAt ?? ''), + ].join(','), + ); } return [header, ...lines].join('\n') + '\n'; } diff --git a/src/backup/backup-parsers.ts b/src/backup/backup-parsers.ts index cc58d0b..4f080fd 100644 --- a/src/backup/backup-parsers.ts +++ b/src/backup/backup-parsers.ts @@ -89,7 +89,6 @@ export function parseBackupFile(raw: unknown): BackupFile { const magazines: BackupMagazine[] = []; const seenMagazineIds = new Set(); - const seenCopyIds = new Set(); for (const entry of root['magazines']) { if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { throw new InvalidBackupError('Fichier invalide : une édition est mal formée.'); @@ -103,30 +102,13 @@ export function parseBackupFile(raw: unknown): BackupFile { } seenMagazineIds.add(id); - if (!Array.isArray(magazine['copies'])) { + // Rétro-compatibilité v1 : la clé `copies` peut être absente (v2+) ou + // présente (v1). Son contenu est ignoré : chaque édition est possédée. + const hasCopies = 'copies' in magazine; + if (hasCopies && !Array.isArray(magazine['copies'])) { throw new InvalidBackupError('Fichier invalide : la liste des exemplaires est absente.'); } - const copies = []; - for (const copyEntry of magazine['copies']) { - if (typeof copyEntry !== 'object' || copyEntry === null || Array.isArray(copyEntry)) { - throw new InvalidBackupError('Fichier invalide : un exemplaire est mal formé.'); - } - const copy = copyEntry as Record; - const copyId = asNonEmptyString(copy['id'], 'identifiant d’exemplaire'); - if (seenCopyIds.has(copyId)) { - throw new InvalidBackupError( - 'Fichier invalide : des identifiants d’exemplaire sont dupliqués.', - ); - } - seenCopyIds.add(copyId); - copies.push({ - id: copyId, - notes: asNullableString(copy['notes']), - dateAdded: asNullableIsoDate(copy['dateAdded']) ?? '', - }); - } - magazines.push({ id, publication: asNonEmptyString(magazine['publication'], 'publication'), @@ -138,7 +120,6 @@ export function parseBackupFile(raw: unknown): BackupFile { barcode: asNullableString(magazine['barcode']), notes: asNullableString(magazine['notes']), ocrText: asNullableString(magazine['ocrText']), - copies, createdAt: asNullableIsoDate(magazine['createdAt']), updatedAt: asNullableIsoDate(magazine['updatedAt']), }); diff --git a/src/backup/backup-service.ts b/src/backup/backup-service.ts index c324f4f..89ba197 100644 --- a/src/backup/backup-service.ts +++ b/src/backup/backup-service.ts @@ -32,13 +32,11 @@ type MagazineRow = { updated_at: string; }; -type CopyRow = { - id: string; - magazine_id: string; - notes: string | null; - date_added: string; -}; - +/** + * Export / import de la collection. Retours test physique : le système + * d'exemplaires (`collection_items`) a été supprimé, chaque édition exportée + * est une ligne unique. Les exports v1 (avec exemplaires) restent importables. + */ export class BackupService { constructor( private readonly db: Database, @@ -53,26 +51,6 @@ export class BackupService { ORDER BY publication, issue_number`, ); - const copyRows = await this.db.getAllAsync( - `SELECT id, magazine_id, notes, date_added - FROM collection_items - ORDER BY date_added, id`, - ); - - const copiesByMagazine = new Map< - string, - { id: string; notes: string | null; dateAdded: string }[] - >(); - for (const row of copyRows) { - const copies = copiesByMagazine.get(row.magazine_id) ?? []; - copies.push({ - id: row.id, - notes: row.notes, - dateAdded: row.date_added, - }); - copiesByMagazine.set(row.magazine_id, copies); - } - const magazines: BackupMagazine[] = magazineRows.map((row) => ({ id: row.id, publication: row.publication, @@ -84,7 +62,6 @@ export class BackupService { barcode: row.barcode, notes: row.notes, ocrText: row.ocr_text, - copies: copiesByMagazine.get(row.id) ?? [], createdAt: row.created_at, updatedAt: row.updated_at, })); @@ -122,7 +99,6 @@ export class BackupService { const file = this.parseBackupRaw(raw, format); return { magazines: file.magazines.length, - copies: file.magazines.reduce((total, magazine) => total + magazine.copies.length, 0), }; } @@ -131,7 +107,6 @@ export class BackupService { await this.db.execAsync('BEGIN'); try { - await this.db.execAsync('DELETE FROM collection_items'); await this.db.execAsync('DELETE FROM magazines'); for (const magazine of file.magazines) { @@ -153,17 +128,6 @@ export class BackupService { magazine.createdAt ?? new Date().toISOString(), magazine.updatedAt ?? new Date().toISOString(), ); - - for (const copy of magazine.copies) { - await this.db.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, ?, ?)`, - copy.id, - magazine.id, - copy.notes, - copy.dateAdded || new Date().toISOString(), - ); - } } await this.db.execAsync('COMMIT'); @@ -178,7 +142,6 @@ export class BackupService { return { magazines: file.magazines.length, - copies: file.magazines.reduce((total, magazine) => total + magazine.copies.length, 0), }; } } diff --git a/src/backup/backup-types.ts b/src/backup/backup-types.ts index 3312302..c70e359 100644 --- a/src/backup/backup-types.ts +++ b/src/backup/backup-types.ts @@ -4,12 +4,6 @@ export const BACKUP_VERSION = 1; /** Format de fichier de sauvegarde choisi par l'utilisateur (US-BK-04/05). */ export type BackupFormat = 'json' | 'csv'; -export type BackupCopy = { - id: string; - notes: string | null; - dateAdded: string; -}; - export type BackupMagazine = { id: string; publication: string; @@ -21,7 +15,6 @@ export type BackupMagazine = { barcode: string | null; notes: string | null; ocrText: string | null; - copies: BackupCopy[]; createdAt: string | null; updatedAt: string | null; }; @@ -36,5 +29,4 @@ export type BackupFile = { export type ImportSummary = { magazines: number; - copies: number; }; diff --git a/src/store/use-backup-store.ts b/src/store/use-backup-store.ts index 9c383d3..1d154a1 100644 --- a/src/store/use-backup-store.ts +++ b/src/store/use-backup-store.ts @@ -97,7 +97,7 @@ export const useBackupStore = create((set, get) => ({ importing: false, pendingRaw: null, pendingFormat: null, - message: `Collection importée : ${summary.magazines} édition(s), ${summary.copies} exemplaire(s).`, + message: `Collection importée : ${summary.magazines} édition(s).`, }); return summary; } catch (err) { From b70ab9655964d01024eeb317a18b6c06632a3f14 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 09:30:14 +0200 Subject: [PATCH 20/34] =?UTF-8?q?test(M-11):=20adapter=20la=20suite=20aux?= =?UTF-8?q?=20retours=20physiques=20=E2=80=94=20exemplaires,=20backup=20CS?= =?UTF-8?q?V=2011=20colonnes,=20OCR=20manuel=20et=20paniers=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/app/barcode-screen.test.tsx | 84 ++---- src/__tests__/app/camera-ocr-screen.test.tsx | 118 ++++++--- src/__tests__/app/collection-screen.test.tsx | 11 +- src/__tests__/app/home-screen.test.tsx | 72 +++-- .../app/magazine-detail-screen.test.tsx | 52 +--- src/__tests__/app/onboarding-screen.test.tsx | 1 - .../app/scan-multiple-screen.test.tsx | 13 +- src/__tests__/app/scan-result-screen.test.tsx | 65 ++--- src/__tests__/app/scan-search-screen.test.tsx | 85 +----- src/__tests__/app/settings-screen.test.tsx | 12 +- .../app/settings-submenus-screen.test.tsx | 8 +- src/__tests__/dependencies.test.ts | 2 - src/backup/__tests__/backup-service.test.ts | 245 ++++++------------ .../__tests__/barcode-pending-sheets.test.tsx | 64 ++--- .../__tests__/magazine-card.test.tsx | 28 +- src/components/__tests__/ocr-analysis.test.ts | 13 +- .../__tests__/ocr-analyzing-overlay.test.tsx | 16 +- .../__tests__/scan-search-views.test.tsx | 55 +--- .../__tests__/status-badge.test.tsx | 21 +- src/database/__tests__/migrations.test.ts | 63 ++++- .../__tests__/collection-repository.test.ts | 172 ------------ .../__tests__/magazine-repository.test.ts | 183 ++----------- .../__tests__/settings-repository.test.ts | 22 ++ .../__tests__/identificationService.test.ts | 4 +- src/lib/__tests__/collection-list.test.ts | 1 - src/store/__tests__/use-backup-store.test.ts | 14 +- .../__tests__/use-collection-store.test.ts | 224 +++++++--------- .../__tests__/use-settings-store.test.ts | 32 ++- 28 files changed, 549 insertions(+), 1131 deletions(-) delete mode 100644 src/database/repositories/__tests__/collection-repository.test.ts diff --git a/src/__tests__/app/barcode-screen.test.tsx b/src/__tests__/app/barcode-screen.test.tsx index 9f4f75d..f4bbaa0 100644 --- a/src/__tests__/app/barcode-screen.test.tsx +++ b/src/__tests__/app/barcode-screen.test.tsx @@ -7,9 +7,6 @@ import type { Magazine } from '@/types'; const mockReplace = jest.fn(); const mockBack = jest.fn(); -const mockAddCopy = jest.fn(); -const mockCountByMagazine = jest.fn(); -const mockAddExistingCopy = jest.fn(); let mockContinuousParam: Record = {}; @@ -32,12 +29,6 @@ jest.mock('expo-camera', () => { }; }); -const mockUseCollectionStore = jest.fn().mockReturnValue(mockAddExistingCopy); - -jest.mock('@/store/use-collection-store', () => ({ - useCollectionStore: (selector: (s: unknown) => unknown) => mockUseCollectionStore(selector), -})); - function makeMagazine(overrides: Partial = {}): Magazine { return { id: 'mag-1', @@ -59,10 +50,6 @@ function makeMagazine(overrides: Partial = {}): Magazine { function stubDeps(overrides: Partial = {}): Dependencies { return { magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: { - addCopy: mockAddCopy, - countByMagazine: mockCountByMagazine, - } as unknown as Dependencies['collectionRepository'], settingsRepository: { getColorScheme: jest.fn().mockResolvedValue('system'), setColorScheme: jest.fn().mockResolvedValue(undefined), @@ -97,19 +84,8 @@ describe('BarcodeScreen', () => { beforeEach(() => { mockBack.mockClear(); mockReplace.mockClear(); - mockAddCopy.mockClear(); - mockCountByMagazine.mockClear(); - mockAddExistingCopy.mockClear(); mockContinuousParam = {}; mockPermission = { granted: true, canAskAgain: true, status: 'granted' }; - mockAddCopy.mockResolvedValue({ - id: 'c1', - magazineId: 'mag-1', - notes: null, - dateAdded: '2026-09-02T00:00:00Z', - }); - mockCountByMagazine.mockResolvedValue(1); - mockAddExistingCopy.mockResolvedValue(undefined); setDepsForTest(stubDeps()); }); @@ -210,8 +186,8 @@ describe('BarcodeScreen', () => { const identifyByBarcode = jest.fn().mockResolvedValue({ status: 'ambiguous', magazines: [ - { ...makeMagazine({ id: 'mag-1', issueNumber: 547 }), quantity: 1 }, - { ...makeMagazine({ id: 'mag-2', issueNumber: 548 }), quantity: 0 }, + { ...makeMagazine({ id: 'mag-1', issueNumber: 547 }) }, + { ...makeMagazine({ id: 'mag-2', issueNumber: 548 }) }, ], }); setDepsForTest( @@ -286,11 +262,10 @@ describe('BarcodeScreen', () => { expect(screen.getByText(/Alignez le code-barres/)).toBeTruthy(); }); - it('en mode continu, demande la confirmation doublon puis ajoute et reprend le scan', async () => { + it('en mode continu, une édition possédée affiche « Déjà dans votre collection » puis continue', async () => { mockContinuousParam = { continuous: '1' }; const magazine = makeMagazine(); const identifyByBarcode = jest.fn().mockResolvedValue({ status: 'found', magazine }); - mockCountByMagazine.mockResolvedValue(1); setDepsForTest( stubDeps({ identificationService: { @@ -302,23 +277,20 @@ describe('BarcodeScreen', () => { render(); await scanTimes('5901234123457'); - await waitFor(() => expect(screen.getByTestId('pending-confirm')).toBeTruthy()); - expect(screen.getByTestId('pending-confirm')).toHaveTextContent(/Exemplaires actuels : 1/); + await waitFor(() => expect(screen.getByTestId('pending-owned')).toBeTruthy()); + expect(screen.getByText('Déjà dans votre collection')).toBeTruthy(); + expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); expect(mockReplace).not.toHaveBeenCalled(); - fireEvent.press(screen.getByTestId('pending-confirm-add')); - await waitFor(() => expect(screen.getByTestId('pending-success')).toBeTruthy()); - expect(mockAddExistingCopy).toHaveBeenCalledWith('mag-1'); - - fireEvent.press(screen.getByTestId('pending-success-ok')); - await waitFor(() => expect(screen.getByText(/Alignez le code-barres/)).toBeTruthy()); + fireEvent.press(screen.getByTestId('pending-owned-ok')); + await waitFor(() => expect(screen.queryByTestId('pending-owned')).toBeNull()); + expect(screen.getByText(/Alignez le code-barres/)).toBeTruthy(); }); - it('en mode continu, ajoute directement une édition non possédée puis confirme', async () => { + it('en mode continu, le panier déjà en collection peut être fermé via la croix', async () => { mockContinuousParam = { continuous: '1' }; const magazine = makeMagazine(); const identifyByBarcode = jest.fn().mockResolvedValue({ status: 'found', magazine }); - mockCountByMagazine.mockResolvedValue(0); setDepsForTest( stubDeps({ identificationService: { @@ -330,13 +302,14 @@ describe('BarcodeScreen', () => { render(); await scanTimes('5901234123457'); + await waitFor(() => expect(screen.getByTestId('pending-owned')).toBeTruthy()); - await waitFor(() => expect(screen.getByTestId('pending-success')).toBeTruthy()); - expect(screen.queryByTestId('pending-confirm')).toBeNull(); - expect(mockAddExistingCopy).toHaveBeenCalledWith('mag-1'); + fireEvent.press(screen.getByTestId('pending-close')); + await waitFor(() => expect(screen.queryByTestId('pending-owned')).toBeNull()); + expect(screen.getByText(/Alignez le code-barres/)).toBeTruthy(); }); - it('en mode continu, un code inconnu propose la saisie manuelle', async () => { + it('en mode continu, un code inconnu propose la saisie manuelle ou de continuer', async () => { mockContinuousParam = { continuous: '1' }; const identifyByBarcode = jest.fn().mockResolvedValue({ status: 'unknown' }); setDepsForTest( @@ -357,6 +330,10 @@ describe('BarcodeScreen', () => { pathname: '/scan/manual', params: { barcode: '5901234123457' }, }); + + fireEvent.press(screen.getByTestId('pending-unknown-continue')); + await waitFor(() => expect(screen.queryByTestId('pending-unknown')).toBeNull()); + expect(screen.getByText(/Alignez le code-barres/)).toBeTruthy(); }); it('permission définitivement refusée : propose d ouvrir les réglages', () => { @@ -370,27 +347,4 @@ describe('BarcodeScreen', () => { expect(openSettings).toHaveBeenCalled(); openSettings.mockRestore(); }); - - it('la fiche doublon peut être fermée via la croix et reprend le scan', async () => { - mockContinuousParam = { continuous: '1' }; - const magazine = makeMagazine(); - const identifyByBarcode = jest.fn().mockResolvedValue({ status: 'found', magazine }); - mockCountByMagazine.mockResolvedValue(1); - setDepsForTest( - stubDeps({ - identificationService: { - identifyByBarcode, - } as unknown as Dependencies['identificationService'], - }), - ); - - render(); - - await scanTimes('5901234123457'); - await waitFor(() => expect(screen.getByTestId('pending-confirm')).toBeTruthy()); - - fireEvent.press(screen.getByTestId('pending-close')); - await waitFor(() => expect(screen.queryByTestId('pending-confirm')).toBeNull()); - expect(mockAddExistingCopy).not.toHaveBeenCalled(); - }); }); diff --git a/src/__tests__/app/camera-ocr-screen.test.tsx b/src/__tests__/app/camera-ocr-screen.test.tsx index 9a789f7..8753795 100644 --- a/src/__tests__/app/camera-ocr-screen.test.tsx +++ b/src/__tests__/app/camera-ocr-screen.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react-native'; import CameraOcrScreen from '@/app/scan/camera'; import { setDepsForTest, type Dependencies } from '@/dependencies'; import type { OcrEngine } from '@/identification/ocr/ocrTypes'; +import { useSettingsStore } from '@/store/use-settings-store'; import type { Magazine } from '@/types'; const mockReplace = jest.fn(); @@ -17,7 +18,9 @@ const mockRequestPermission = jest.fn(); jest.mock('expo-camera', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { View } = require('react-native'); - const MockCameraView = (props: any) => ; + const MockCameraView = (props: any) => { + return ; + }; return { useCameraPermissions: () => [ { granted: true, canAskAgain: true, status: 'granted' }, @@ -48,7 +51,6 @@ function makeMagazine(overrides: Partial = {}): Magazine { function stubDeps(overrides: Partial = {}): Dependencies { return { magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], settingsRepository: { getColorScheme: jest.fn().mockResolvedValue('system'), setColorScheme: jest.fn().mockResolvedValue(undefined), @@ -66,32 +68,30 @@ function stubDeps(overrides: Partial = {}): Dependencies { }; } -/** Fait avancer l'intervalle d'analyse pour déclencher handleOcr. */ -async function tick(ms = 500) { +/** Appuie sur le déclencheur (1 appui → 1 photo → 1 lecture) et draine les promesses. */ +async function shoot() { await act(async () => { - jest.advanceTimersByTime(ms); + fireEvent.press(screen.getByTestId('ocr-shutter')); }); } describe('CameraOcrScreen', () => { beforeEach(() => { - jest.useFakeTimers(); mockReplace.mockClear(); mockBack.mockClear(); + useSettingsStore.setState({ ocrDebug: false }); setDepsForTest(stubDeps()); }); - afterEach(() => { - jest.useRealTimers(); - }); - - it('affiche le preview caméra quand la permission est accordée', () => { + it('affiche le preview caméra et le déclencheur quand la permission est accordée', () => { render(); expect(screen.getByTestId('ocr-camera-view')).toBeTruthy(); expect(screen.getByText(/Pointez la couverture/)).toBeTruthy(); + expect(screen.getByTestId('ocr-shutter')).toBeTruthy(); + expect(screen.getByLabelText('Prendre la photo pour la lecture')).toBeTruthy(); }); - it('continue d’analyser quand le moteur n’a rien détecté', async () => { + it('reste en analyse quand le moteur n’a rien détecté', async () => { const identity = jest.fn().mockResolvedValue({ status: 'no-text' }); setDepsForTest( stubDeps({ @@ -105,10 +105,11 @@ describe('CameraOcrScreen', () => { ); render(); - await tick(); - await tick(); + await shoot(); + await shoot(); expect(screen.getByText(/Pointez la couverture/)).toBeTruthy(); + expect(identity).not.toHaveBeenCalled(); }); it('affiche la fiche reconnue quand l’édition est trouvée en base', async () => { @@ -132,8 +133,7 @@ describe('CameraOcrScreen', () => { ); render(); - await tick(); - await tick(); + await shoot(); expect(screen.getByTestId('ocr-found')).toBeTruthy(); expect(screen.getByTestId('ocr-publication')).toHaveTextContent('Picsou Magazine'); @@ -160,8 +160,7 @@ describe('CameraOcrScreen', () => { ); render(); - await tick(); - await tick(); + await shoot(); fireEvent.press(screen.getByTestId('ocr-confirm')); expect(mockReplace).toHaveBeenCalledWith('/collection/mag-547'); @@ -183,12 +182,12 @@ describe('CameraOcrScreen', () => { ); render(); - await tick(); + await shoot(); expect(screen.getByTestId('ocr-detected-board')).toBeTruthy(); expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('Picsou Magazine'); expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('…'); - expect(screen.getByText(/Pointez maintenant le numéro/)).toBeTruthy(); + expect(screen.getByText(/Reprenez une photo pour lire le numéro/)).toBeTruthy(); expect(screen.queryByTestId('ocr-found')).toBeNull(); expect(screen.getByTestId('ocr-confirm-detected')).toBeTruthy(); expect(screen.getByTestId('ocr-barcode')).toBeTruthy(); @@ -222,18 +221,17 @@ describe('CameraOcrScreen', () => { ); render(); - await tick(); + await shoot(); expect(screen.getByTestId('ocr-confirm-detected')).toBeTruthy(); - await tick(); - await tick(); + await shoot(); expect(screen.getByTestId('ocr-found')).toBeTruthy(); expect(identity).toHaveBeenCalledWith('Picsou Magazine N° 547'); }); it('Scanner le code-barres mène au scan de code-barres', async () => { render(); - await tick(); + await shoot(); fireEvent.press(screen.getByTestId('ocr-barcode')); expect(mockReplace).toHaveBeenCalledWith('/scan/barcode'); }); @@ -253,8 +251,7 @@ describe('CameraOcrScreen', () => { ); render(); - await tick(); - await tick(); + await shoot(); expect(screen.getByTestId('ocr-unknown')).toBeTruthy(); expect(screen.getByText(/Non trouvé en collection/)).toBeTruthy(); @@ -276,8 +273,7 @@ describe('CameraOcrScreen', () => { ); render(); - await tick(); - await tick(); + await shoot(); fireEvent.press(screen.getByTestId('ocr-manual')); expect(mockReplace).toHaveBeenCalledWith({ @@ -293,13 +289,9 @@ describe('CameraOcrScreen', () => { describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () => { beforeEach(() => { - jest.useFakeTimers(); mockReplace.mockClear(); mockBack.mockClear(); - }); - - afterEach(() => { - jest.useRealTimers(); + useSettingsStore.setState({ ocrDebug: false }); }); const weakDeps = (searchByOcrFields?: jest.Mock) => @@ -327,7 +319,7 @@ describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () it('ouvre le panneau de vérification pré-rempli depuis la surcouche', async () => { setDepsForTest(weakDeps()); render(); - await tick(); + await shoot(); fireEvent.press(screen.getByTestId('ocr-confirm-detected')); @@ -339,7 +331,7 @@ describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () it('retour à la caméra depuis le panneau relance l’analyse', async () => { setDepsForTest(weakDeps()); render(); - await tick(); + await shoot(); fireEvent.press(screen.getByTestId('ocr-confirm-detected')); fireEvent.press(screen.getByTestId('ocr-override-back')); @@ -359,7 +351,7 @@ describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () }); setDepsForTest(weakDeps(search)); render(); - await tick(); + await shoot(); fireEvent.press(screen.getByTestId('ocr-confirm-detected')); fireEvent.changeText(screen.getByTestId('ocr-override-publication'), 'Picsou Magazine'); @@ -375,7 +367,7 @@ describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () it('oriente vers la saisie manuelle si nom ou numéro restent manquants', async () => { setDepsForTest(weakDeps()); render(); - await tick(); + await shoot(); fireEvent.press(screen.getByTestId('ocr-confirm-detected')); fireEvent.press(screen.getByTestId('ocr-override-search')); @@ -388,3 +380,55 @@ describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () }); }); }); + +describe('CameraOcrScreen — debug OCR (paramètres avancés)', () => { + beforeEach(() => { + mockReplace.mockClear(); + mockBack.mockClear(); + useSettingsStore.setState({ ocrDebug: true }); + }); + + it('affiche le panneau de debug avec le texte brut, la confiance et le vote une lecture active', async () => { + setDepsForTest(stubDeps()); + render(); + + expect(screen.getByTestId('ocr-debug-panel')).toBeTruthy(); + expect(screen.getByTestId('ocr-debug-raw')).toHaveTextContent('…'); + + await shoot(); + expect(screen.getByTestId('ocr-debug-raw')).toHaveTextContent('Picsou Magazine N° 547'); + }); + + it('compte les lectures identiques consécutives', async () => { + setDepsForTest( + stubDeps({ + identificationService: { + identifyByOCR: jest.fn().mockResolvedValue({ + status: 'weak', + publication: 'Picsou Magazine', + issueNumber: 547, + date: null, + confidence: 0.5, + }), + } as unknown as Dependencies['identificationService'], + ocrEngine: { + recognize: jest.fn().mockResolvedValue({ text: 'Picsou Magazine N° 547' }), + } as unknown as OcrEngine, + }), + ); + + render(); + await shoot(); + await shoot(); + + expect(screen.getByText('Lectures identiques')).toBeTruthy(); + expect(screen.getByText('2')).toBeTruthy(); + }); + + it('n’affiche pas le panneau de debug quand il est désactivé', () => { + useSettingsStore.setState({ ocrDebug: false }); + render(); + + expect(screen.queryByTestId('ocr-debug-panel')).toBeNull(); + }); +}); diff --git a/src/__tests__/app/collection-screen.test.tsx b/src/__tests__/app/collection-screen.test.tsx index fdfb1a4..61a88ac 100644 --- a/src/__tests__/app/collection-screen.test.tsx +++ b/src/__tests__/app/collection-screen.test.tsx @@ -28,7 +28,6 @@ const magazines: MagazineListItem[] = [ ocrText: null, createdAt: '2026-09-01T10:00:00Z', updatedAt: '2026-09-01T10:00:00Z', - quantity: 1, }, { id: 'm2', @@ -43,7 +42,6 @@ const magazines: MagazineListItem[] = [ ocrText: null, createdAt: '2026-09-01T10:00:00Z', updatedAt: '2026-09-01T10:00:00Z', - quantity: 2, }, { id: 'm3', @@ -58,7 +56,6 @@ const magazines: MagazineListItem[] = [ ocrText: null, createdAt: '2026-09-01T10:00:00Z', updatedAt: '2026-09-01T10:00:00Z', - quantity: 0, }, ]; @@ -177,13 +174,10 @@ describe('CollectionScreen', () => { expect(screen.getAllByTestId('magazine-card')).toHaveLength(3); }); - it('affiche le badge Absent pour une edition sans exemplaire', () => { + it('affiche le badge Possédé sur chaque édition', () => { render(); - const cards = screen.getAllByTestId('magazine-card'); - expect(cards[2]).toBeTruthy(); - expect(screen.getAllByText(/Absent/).length).toBe(1); - expect(screen.getAllByText(/Possédé/).length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByTestId('status-owned')).toHaveLength(3); }); it('trie par numero decroissant', () => { @@ -234,7 +228,6 @@ describe('CollectionScreen (pagination)', () => { ocrText: null, createdAt: `2026-09-01T10:00:0${i % 10}Z`, updatedAt: '2026-09-01T10:00:00Z', - quantity: 1, })); const openFilters = () => fireEvent.press(screen.getByTestId('filter-toggle')); diff --git a/src/__tests__/app/home-screen.test.tsx b/src/__tests__/app/home-screen.test.tsx index 315cb2f..9dc1197 100644 --- a/src/__tests__/app/home-screen.test.tsx +++ b/src/__tests__/app/home-screen.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; import HomeScreen from '@/app/(tabs)/index'; import { useCollectionStore } from '@/store/use-collection-store'; +import type { Magazine } from '@/types'; const mockPush = jest.fn(); @@ -13,23 +14,41 @@ jest.mock('expo-router', () => ({ useFocusEffect: () => {}, })); +function makeMagazine(overrides: Partial = {}): Magazine { + return { + id: 'm1', + publication: 'Picsou Magazine', + issueNumber: 547, + edition: null, + language: null, + condition: null, + publicationDate: null, + barcode: null, + notes: null, + ocrText: null, + createdAt: '2026-09-01T10:00:00Z', + updatedAt: '2026-09-01T10:00:00Z', + ...overrides, + }; +} + describe('HomeScreen (compteur)', () => { beforeEach(() => { mockPush.mockClear(); }); - it('affiche le nombre d exemplaires possedes', () => { - useCollectionStore.setState({ totalCopies: 12, loading: false, loaded: true }); + it('affiche le nombre d éditions possedées', () => { + useCollectionStore.setState({ totalMagazines: 12, loading: false, loaded: true }); render(); expect(screen.getByTestId('collection-counter')).toBeTruthy(); expect(screen.getByText('12')).toBeTruthy(); - expect(screen.getByText('exemplaires possédés')).toBeTruthy(); + expect(screen.getByText('éditions possédées')).toBeTruthy(); }); it('affiche le chargement lorsque la collection est en cours de chargement', () => { - useCollectionStore.setState({ totalCopies: 0, loading: true, loaded: false }); + useCollectionStore.setState({ totalMagazines: 0, loading: true, loaded: false }); render(); @@ -85,29 +104,18 @@ describe('HomeScreen (ajouts recents)', () => { mockPush.mockClear(); }); - it('affiche la liste des derniers exemplaires ajoutes', () => { + it('affiche la liste des dernieres editions ajoutees', () => { useCollectionStore.setState({ loading: false, loaded: true, - recentCopies: [ - { - copy: { - id: 'c1', - magazineId: 'm1', - notes: null, - dateAdded: '2026-09-01T10:00:00Z', - }, - magazine: { id: 'm1', publication: 'Picsou Magazine', issueNumber: 547 }, - }, - { - copy: { - id: 'c2', - magazineId: 'm2', - notes: 'coffret', - dateAdded: '2026-08-20T10:00:00Z', - }, - magazine: { id: 'm2', publication: 'Super Picsou Géant', issueNumber: null }, - }, + recent: [ + makeMagazine(), + makeMagazine({ + id: 'm2', + publication: 'Super Picsou Géant', + issueNumber: null, + createdAt: '2026-08-20T10:00:00Z', + }), ], }); @@ -123,17 +131,7 @@ describe('HomeScreen (ajouts recents)', () => { useCollectionStore.setState({ loading: false, loaded: true, - recentCopies: [ - { - copy: { - id: 'c1', - magazineId: 'm1', - notes: null, - dateAdded: '2026-09-01T10:00:00Z', - }, - magazine: { id: 'm1', publication: 'Picsou Magazine', issueNumber: 547 }, - }, - ], + recent: [makeMagazine()], }); render(); @@ -144,7 +142,7 @@ describe('HomeScreen (ajouts recents)', () => { }); it('affiche un etat vide sans ajouts', () => { - useCollectionStore.setState({ loading: false, loaded: true, recentCopies: [] }); + useCollectionStore.setState({ loading: false, loaded: true, recent: [] }); render(); @@ -152,7 +150,7 @@ describe('HomeScreen (ajouts recents)', () => { }); it('propose de scanner depuis l etat vide', () => { - useCollectionStore.setState({ loading: false, loaded: true, recentCopies: [] }); + useCollectionStore.setState({ loading: false, loaded: true, recent: [] }); render(); diff --git a/src/__tests__/app/magazine-detail-screen.test.tsx b/src/__tests__/app/magazine-detail-screen.test.tsx index 333844b..8990e22 100644 --- a/src/__tests__/app/magazine-detail-screen.test.tsx +++ b/src/__tests__/app/magazine-detail-screen.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { fireEvent, render, screen } from '@testing-library/react-native'; import { Alert } from 'react-native'; import MagazineDetailScreen from '@/app/collection/[id]'; @@ -28,14 +28,6 @@ const detail = { ocrText: null, createdAt: '2026-09-01T10:00:00.000Z', updatedAt: '2026-09-01T10:00:00.000Z', - copies: [ - { - id: 'c1', - magazineId: 'mag-1', - notes: null, - dateAdded: '2026-09-01T10:00:00Z', - }, - ], }; describe('MagazineDetailScreen', () => { @@ -68,37 +60,25 @@ describe('MagazineDetailScreen', () => { expect(screen.getByText('Picsou Magazine')).toBeTruthy(); expect(screen.getByTestId('detail-issue')).toHaveTextContent('n° 547'); - expect(screen.getAllByText('standard').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('FR').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('neuf').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('3271234567890').length).toBeGreaterThanOrEqual(1); + expect(screen.getByTestId('detail-edition')).toHaveTextContent('standard'); + expect(screen.getByTestId('detail-langue')).toHaveTextContent('FR'); + expect(screen.getByTestId('detail-etat')).toHaveTextContent('neuf'); + expect(screen.getByTestId('detail-code-barres')).toHaveTextContent('3271234567890'); }); - it('affiche le badge Possede avec le nombre d exemplaires', async () => { + it('affiche le badge Possédé', async () => { useCollectionStore.setState({ detail, detailLoading: false }); render(); expect(screen.getByTestId('status-owned')).toBeTruthy(); - expect(screen.getByTestId('detail-count')).toHaveTextContent('1 exemplaire'); }); - it('affiche la liste des exemplaires', async () => { + it('affiche les notes quand l édition en a', async () => { useCollectionStore.setState({ detail, detailLoading: false }); render(); - expect(screen.getAllByTestId('detail-copy')).toHaveLength(1); - expect(screen.getAllByText('01/09/2026').length).toBeGreaterThanOrEqual(1); - }); - - it('affiche Absent et un message quand aucun exemplaire', async () => { - useCollectionStore.setState({ - detail: { ...detail, copies: [] }, - detailLoading: false, - }); - render(); - - expect(screen.getByTestId('status-absent')).toBeTruthy(); - expect(screen.getByTestId('detail-copies-empty')).toBeTruthy(); + expect(screen.getByText('Notes')).toBeTruthy(); + expect(screen.getByText('Mention bimestriel')).toBeTruthy(); }); it('affiche un etat de chargement en attente', () => { @@ -137,20 +117,6 @@ describe('MagazineDetailScreen', () => { alertSpy.mockRestore(); }); - it('ajoute un exemplaire directement depuis la fiche', async () => { - const mockAddExistingCopy = jest.fn().mockResolvedValue(undefined); - useCollectionStore.setState({ - detail, - detailLoading: false, - addExistingCopy: mockAddExistingCopy, - }); - render(); - - fireEvent.press(screen.getByTestId('detail-add-copy')); - - await waitFor(() => expect(mockAddExistingCopy).toHaveBeenCalledWith('mag-1')); - }); - it('affiche le header fiche (menu, titre, scan, fermeture) et ferme le modal', () => { useCollectionStore.setState({ detail, detailLoading: false }); render(); diff --git a/src/__tests__/app/onboarding-screen.test.tsx b/src/__tests__/app/onboarding-screen.test.tsx index a16f3fc..b0c648d 100644 --- a/src/__tests__/app/onboarding-screen.test.tsx +++ b/src/__tests__/app/onboarding-screen.test.tsx @@ -29,7 +29,6 @@ describe('OnboardingScreen', () => { mockSetOnboardingDone.mockClear(); setDepsForTest({ magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], settingsRepository: { setOnboardingDone: mockSetOnboardingDone, } as unknown as Dependencies['settingsRepository'], diff --git a/src/__tests__/app/scan-multiple-screen.test.tsx b/src/__tests__/app/scan-multiple-screen.test.tsx index f79031a..d2cc24a 100644 --- a/src/__tests__/app/scan-multiple-screen.test.tsx +++ b/src/__tests__/app/scan-multiple-screen.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react- import MultipleBarcodeScreen from '@/app/scan/multiple'; import { setDepsForTest, type Dependencies } from '@/dependencies'; -import type { Magazine, MagazineListItem } from '@/types'; +import type { Magazine } from '@/types'; const mockReplace = jest.fn(); const mockPush = jest.fn(); @@ -39,16 +39,11 @@ function makeMagazine(overrides: Partial = {}): Magazine { }; } -function asListItem(magazine: Magazine, quantity = 0): MagazineListItem { - return { ...magazine, quantity }; -} - -function stubDeps(findMany: jest.Mock, [string]>): Dependencies { +function stubDeps(findMany: jest.Mock, [string]>): Dependencies { return { magazineRepository: { findManyByBarcode: findMany, } as unknown as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], settingsRepository: { getColorScheme: jest.fn().mockResolvedValue('system'), setColorScheme: jest.fn().mockResolvedValue(undefined), @@ -81,8 +76,8 @@ describe('MultipleBarcodeScreen', () => { const findMany = jest .fn() .mockResolvedValue([ - asListItem(makeMagazine({ id: 'mag-1', issueNumber: 547 }), 1), - asListItem(makeMagazine({ id: 'mag-2', issueNumber: 548 }), 0), + makeMagazine({ id: 'mag-1', issueNumber: 547 }), + makeMagazine({ id: 'mag-2', issueNumber: 548 }), ]); setDepsForTest(stubDeps(findMany)); diff --git a/src/__tests__/app/scan-result-screen.test.tsx b/src/__tests__/app/scan-result-screen.test.tsx index 35c9aee..a29709c 100644 --- a/src/__tests__/app/scan-result-screen.test.tsx +++ b/src/__tests__/app/scan-result-screen.test.tsx @@ -1,12 +1,10 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; -import { Alert } from 'react-native'; import ScanResultScreen from '@/app/scan/result'; import { useCollectionStore } from '@/store/use-collection-store'; const mockReplace = jest.fn(); const mockLoadDetail = jest.fn(); -const mockAddExistingCopy = jest.fn(); jest.mock('expo-router', () => ({ useFocusEffect: (cb: () => void) => cb(), @@ -32,80 +30,50 @@ const magazine = { ocrText: null, createdAt: '2026-09-01T10:00:00.000Z', updatedAt: '2026-09-01T10:00:00.000Z', - copies: [], -}; - -const detailWithCopies = { - ...magazine, - copies: [{ id: 'c1', magazineId: 'mag-1', notes: null, dateAdded: 'x' }], }; describe('ScanResultScreen', () => { beforeEach(() => { mockReplace.mockClear(); mockLoadDetail.mockClear(); - mockAddExistingCopy.mockClear(); - mockAddExistingCopy.mockResolvedValue(undefined); params = {}; useCollectionStore.setState({ detail: null, detailLoading: false, loadDetail: mockLoadDetail, - addExistingCopy: mockAddExistingCopy, }); }); it('charge le detail au focus quand une edition existe', () => { params = { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: '547' }; - mockLoadDetail.mockResolvedValue(detailWithCopies); + mockLoadDetail.mockResolvedValue(magazine); render(); expect(mockLoadDetail).toHaveBeenCalledWith('mag-1'); }); - it('affiche un magazine possede avec le nombre d exemplaires', () => { + it('affiche un magazine possede avec son statut résolu', () => { params = { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: '547' }; - useCollectionStore.setState({ detail: detailWithCopies, detailLoading: false }); + useCollectionStore.setState({ detail: magazine, detailLoading: false }); render(); expect(screen.getByText('Déjà dans votre collection')).toBeTruthy(); - expect(screen.getByText('Picsou Magazine')).toBeTruthy(); - expect(screen.getByTestId('result-status-owned')).toHaveTextContent('✓ Possédé (1)'); + expect(screen.getByTestId('result-magazine')).toHaveTextContent('Picsou Magazine'); + expect(screen.getByText('N° 547')).toBeTruthy(); + expect(screen.getByTestId('result-status-owned')).toHaveTextContent('✓ Possédé'); fireEvent.press(screen.getByTestId('result-view')); expect(mockReplace).toHaveBeenCalledWith('/collection/mag-1'); }); - it('demande la confirmation doublon puis ajoute un exemplaire pour un magazine possede', async () => { - const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + it('affiche la vérification tant que le statut n est pas résolu', () => { params = { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: '547' }; - useCollectionStore.setState({ detail: detailWithCopies, detailLoading: false }); - render(); - - fireEvent.press(screen.getByTestId('result-add-copy')); + useCollectionStore.setState({ detail: null, detailLoading: true }); - expect(alertSpy).toHaveBeenCalled(); - const message = alertSpy.mock.calls[0][1]; - expect(message).toContain('Exemplaires actuels : 1'); - const buttons = alertSpy.mock.calls[0][2] as { text: string; onPress?: () => void }[]; - const confirm = buttons?.find((b) => b.text === 'Ajouter quand même'); - expect(confirm).toBeDefined(); - await confirm?.onPress?.(); - - expect(mockAddExistingCopy).toHaveBeenCalledWith('mag-1'); - alertSpy.mockRestore(); - }); - - it('ajoute directement une edition absente sans confirmation', async () => { - params = { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: '547' }; - useCollectionStore.setState({ detail: magazine, detailLoading: false }); render(); - expect(screen.getByTestId('result-status-absent')).toHaveTextContent('○ Absent'); - fireEvent.press(screen.getByTestId('result-add')); - - expect(mockAddExistingCopy).toHaveBeenCalledWith('mag-1'); + expect(screen.getByTestId('result-loading')).toHaveTextContent('Vérification…'); }); it('propose rescanner depuis un résultat existant', () => { @@ -140,12 +108,15 @@ describe('ScanResultScreen', () => { expect(screen.getByText(/5901234123457/)).toBeTruthy(); }); - it('n affiche pas le bouton fiche pour un magazine absent', () => { + it('n affiche pas les boutons fiche/rescan pour un magazine absent', () => { params = { barcode: '5901234123457' }; render(); expect(screen.queryByTestId('result-view')).toBeNull(); + expect(screen.queryByTestId('result-rescan')).toBeNull(); + fireEvent.press(screen.getByTestId('result-camera')); + expect(mockReplace).toHaveBeenCalledWith('/scan/camera'); }); it('ouvre la saisie manuelle avec le code depuis un résultat absent', () => { @@ -160,14 +131,12 @@ describe('ScanResultScreen', () => { }); }); - it('propose la caméra/OCR puis le manuel après échec du code-barres (absent)', () => { - params = { barcode: '5901234123457' }; + it('n affiche pas la caméra/OCR pour une édition existante', () => { + params = { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: '547' }; + useCollectionStore.setState({ detail: magazine, detailLoading: false }); render(); - // US-ID-05 : la méthode échouée (code-barres) n'est pas reproposée. - expect(screen.queryByTestId('result-rescan')).toBeNull(); - fireEvent.press(screen.getByTestId('result-camera')); - expect(mockReplace).toHaveBeenCalledWith('/scan/camera'); + expect(screen.queryByTestId('result-camera')).toBeNull(); }); }); diff --git a/src/__tests__/app/scan-search-screen.test.tsx b/src/__tests__/app/scan-search-screen.test.tsx index f76afc2..857ade1 100644 --- a/src/__tests__/app/scan-search-screen.test.tsx +++ b/src/__tests__/app/scan-search-screen.test.tsx @@ -1,5 +1,4 @@ import { act, fireEvent, render, screen } from '@testing-library/react-native'; -import { Alert } from 'react-native'; import ScanSearchScreen from '@/app/scan/search'; import { setDepsForTest, type Dependencies } from '@/dependencies'; @@ -8,12 +7,9 @@ import type { Magazine } from '@/types'; const mockBack = jest.fn(); const mockReplace = jest.fn(); -const mockLoadDetail = jest.fn(); -const mockAddExistingCopy = jest.fn(); const mockSearchByOcrFields = jest.fn(); jest.mock('expo-router', () => ({ - useFocusEffect: (cb: () => void) => cb(), useRouter: () => ({ back: mockBack, replace: mockReplace, canGoBack: () => true }), })); @@ -32,15 +28,9 @@ const magazine: Magazine = { updatedAt: '2026-09-01T10:00:00.000Z', }; -const detailWithCopies = { - ...magazine, - copies: [{ id: 'c1', magazineId: 'mag-1', notes: null, dateAdded: 'x' }], -}; - function stubDeps() { setDepsForTest({ magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], settingsRepository: { getColorScheme: jest.fn().mockResolvedValue('system'), setColorScheme: jest.fn().mockResolvedValue(undefined), @@ -58,17 +48,10 @@ describe('ScanSearchScreen', () => { beforeEach(() => { mockBack.mockClear(); mockReplace.mockClear(); - mockLoadDetail.mockClear(); - mockAddExistingCopy.mockClear(); - mockAddExistingCopy.mockResolvedValue(undefined); mockSearchByOcrFields.mockResolvedValue({ status: 'no-text' }); stubDeps(); useCollectionStore.setState({ - magazines: [{ ...magazine, quantity: 0 }], - detail: null, - detailLoading: false, - loadDetail: mockLoadDetail, - addExistingCopy: mockAddExistingCopy, + magazines: [magazine], }); }); @@ -80,7 +63,7 @@ describe('ScanSearchScreen', () => { expect(mockSearchByOcrFields).not.toHaveBeenCalled(); }); - it('recherche par publication + numéro et affiche le résultat trouvé (absent)', async () => { + it('recherche par publication + numéro et affiche le résultat trouvé (possédé)', async () => { mockSearchByOcrFields.mockResolvedValue({ status: 'found', magazine, @@ -89,7 +72,6 @@ describe('ScanSearchScreen', () => { date: null, confidence: 1, }); - useCollectionStore.setState({ detail: { ...magazine, copies: [] }, detailLoading: false }); render(); await act(async () => { @@ -102,72 +84,11 @@ describe('ScanSearchScreen', () => { expect(mockSearchByOcrFields).toHaveBeenCalledWith('Picsou Magazine', 547, null); expect(screen.getByTestId('search-magazine')).toHaveTextContent('Picsou Magazine'); - expect(screen.getByTestId('search-status-absent')).toHaveTextContent('○ Absent'); + expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé'); fireEvent.press(screen.getByTestId('search-view')); expect(mockReplace).toHaveBeenCalledWith('/collection/mag-1'); }); - it('affiche Possédé avec le nombre d exemplaires pour une édition déjà en collection', async () => { - mockSearchByOcrFields.mockResolvedValue({ - status: 'found', - magazine: detailWithCopies, - publication: 'Picsou Magazine', - issueNumber: 547, - date: null, - confidence: 1, - }); - useCollectionStore.setState({ detail: detailWithCopies, detailLoading: false }); - - render(); - await act(async () => { - fireEvent(screen.getByTestId('search-publication'), 'onChangeText', 'Picsou Magazine'); - fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '547'); - }); - await act(async () => { - fireEvent.press(screen.getByTestId('search-submit')); - }); - - expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé (1)'); - expect(screen.getByText('Ajouter un exemplaire')).toBeTruthy(); - }); - - it('demande la confirmation doublon pour un magazine possédé', async () => { - const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); - mockSearchByOcrFields.mockResolvedValue({ - status: 'found', - magazine: detailWithCopies, - publication: 'Picsou Magazine', - issueNumber: 547, - date: null, - confidence: 1, - }); - useCollectionStore.setState({ detail: detailWithCopies, detailLoading: false }); - - render(); - await act(async () => { - fireEvent(screen.getByTestId('search-publication'), 'onChangeText', 'Picsou Magazine'); - fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '547'); - }); - await act(async () => { - fireEvent.press(screen.getByTestId('search-submit')); - }); - await act(async () => { - fireEvent.press(screen.getByTestId('search-add')); - }); - - expect(alertSpy).toHaveBeenCalled(); - const message = alertSpy.mock.calls[0][1]; - expect(message).toContain('Exemplaires actuels : 1'); - const buttons = alertSpy.mock.calls[0][2] as { text: string; onPress?: () => void }[]; - const confirm = buttons?.find((b) => b.text === 'Ajouter quand même'); - expect(confirm).toBeDefined(); - await act(async () => { - await confirm?.onPress?.(); - }); - expect(mockAddExistingCopy).toHaveBeenCalledWith('mag-1'); - alertSpy.mockRestore(); - }); - it('propose le repli vers la saisie manuelle pré-remplie quand rien n est trouvé', async () => { mockSearchByOcrFields.mockResolvedValue({ status: 'unknown', diff --git a/src/__tests__/app/settings-screen.test.tsx b/src/__tests__/app/settings-screen.test.tsx index 4f67769..1b0e052 100644 --- a/src/__tests__/app/settings-screen.test.tsx +++ b/src/__tests__/app/settings-screen.test.tsx @@ -9,7 +9,6 @@ import { useBackupStore } from '@/store/use-backup-store'; import { createTestDatabase } from '@/test-utils/test-db'; import { migrate } from '@/database/migrations'; import { BackupService } from '@/backup/backup-service'; -import { CollectionRepository } from '@/database/repositories/collection-repository'; import { MagazineRepository } from '@/database/repositories/magazine-repository'; const setColorSchemeMock = jest.fn().mockResolvedValue(undefined); @@ -30,7 +29,6 @@ jest.mock('expo-router', () => ({ function stubDeps(): Dependencies { return { magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], identificationService: {} as Dependencies['identificationService'], ocrEngine: { recognize: jest.fn() } as unknown as Dependencies['ocrEngine'], settingsRepository: { @@ -159,9 +157,7 @@ describe('BackupScreen — Sauvegarde', () => { name: 'picsou-collection-2026-09-01.csv', }); const magazineRepo = new MagazineRepository(testDb); - const collectionRepo = new CollectionRepository(testDb); - const magazine = await magazineRepo.create({ publication: 'Picsou', issueNumber: 1 }); - await collectionRepo.addCopy(magazine.id, { notes: 'OK' }); + await magazineRepo.create({ publication: 'Picsou', issueNumber: 1 }); render(); fireEvent.press(screen.getByTestId('backup-export')); @@ -170,7 +166,7 @@ describe('BackupScreen — Sauvegarde', () => { await waitFor(() => expect(writeExport).toHaveBeenCalledTimes(1)); expect(writeExport.mock.calls[0][1]).toBe('csv'); expect(writeExport.mock.calls[0][0]).toMatch( - /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt\n/, + /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,createdAt,updatedAt\n/, ); }); @@ -193,9 +189,7 @@ describe('BackupScreen — Sauvegarde', () => { it('demande confirmation puis remplace la collection par l’import CSV', async () => { const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); const magazineRepo = new MagazineRepository(testDb); - const collectionRepo = new CollectionRepository(testDb); - const magazine = await magazineRepo.create({ publication: 'Picsou', issueNumber: 1 }); - await collectionRepo.addCopy(magazine.id, { notes: 'OK' }); + await magazineRepo.create({ publication: 'Picsou', issueNumber: 1 }); const source = await service.exportCollection(); source.magazines[0].publication = 'Csv Importer'; pickFile.mockResolvedValue({ name: 'backup.csv', content: service.toCsv(source) }); diff --git a/src/__tests__/app/settings-submenus-screen.test.tsx b/src/__tests__/app/settings-submenus-screen.test.tsx index 38b15c3..cba147a 100644 --- a/src/__tests__/app/settings-submenus-screen.test.tsx +++ b/src/__tests__/app/settings-submenus-screen.test.tsx @@ -21,7 +21,6 @@ jest.mock('expo-router', () => ({ function stubDeps(): Dependencies { return { magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], identificationService: {} as Dependencies['identificationService'], ocrEngine: { recognize: jest.fn() } as unknown as Dependencies['ocrEngine'], settingsRepository: { @@ -57,6 +56,7 @@ describe('SettingsScreen (sous-menus M10R2-06)', () => { expect(screen.getByTestId('settings-backup')).toBeTruthy(); expect(screen.getByTestId('settings-accessibility')).toBeTruthy(); expect(screen.getByTestId('settings-help')).toBeTruthy(); + expect(screen.getByTestId('settings-advanced')).toBeTruthy(); }); it('navigue vers Apparence', () => { @@ -70,6 +70,12 @@ describe('SettingsScreen (sous-menus M10R2-06)', () => { fireEvent.press(screen.getByTestId('settings-backup')); expect(mockPush).toHaveBeenCalledWith('/settings/backup'); }); + + it('navigue vers Paramètres avancés', () => { + render(); + fireEvent.press(screen.getByTestId('settings-advanced')); + expect(mockPush).toHaveBeenCalledWith('/settings/advanced'); + }); }); describe('AccessibilityScreen', () => { diff --git a/src/__tests__/dependencies.test.ts b/src/__tests__/dependencies.test.ts index 3750e17..7adda26 100644 --- a/src/__tests__/dependencies.test.ts +++ b/src/__tests__/dependencies.test.ts @@ -21,7 +21,6 @@ const mockGetDatabase = getDatabase as unknown as jest.Mock; const stubDeps: Dependencies = { magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], settingsRepository: {} as Dependencies['settingsRepository'], identificationService: {} as Dependencies['identificationService'], ocrEngine: { recognize: jest.fn() } as unknown as Dependencies['ocrEngine'], @@ -41,7 +40,6 @@ describe('dependencies', () => { expect(migrate).toHaveBeenCalledWith({ id: 'db' }); expect(deps.magazineRepository).toBeDefined(); - expect(deps.collectionRepository).toBeDefined(); expect(getDeps()).toBe(deps); }); diff --git a/src/backup/__tests__/backup-service.test.ts b/src/backup/__tests__/backup-service.test.ts index 9ebf850..d535285 100644 --- a/src/backup/__tests__/backup-service.test.ts +++ b/src/backup/__tests__/backup-service.test.ts @@ -1,6 +1,5 @@ import { createTestDatabase } from '@/test-utils/test-db'; import { migrate } from '@/database/migrations'; -import { CollectionRepository } from '@/database/repositories/collection-repository'; import { MagazineRepository } from '@/database/repositories/magazine-repository'; import { BackupService, InvalidBackupError } from '@/backup/backup-service'; import { BACKUP_FORMAT, BACKUP_VERSION } from '@/backup/backup-types'; @@ -18,13 +17,11 @@ jest.mock('expo-crypto', () => { let testDb: ReturnType; let service: BackupService; let magazineRepo: MagazineRepository; -let collectionRepo: CollectionRepository; beforeEach(async () => { testDb = createTestDatabase(); await migrate(testDb); magazineRepo = new MagazineRepository(testDb); - collectionRepo = new CollectionRepository(testDb); service = new BackupService(testDb); }); @@ -33,7 +30,7 @@ afterEach(async () => { }); async function seedCollection(): Promise { - const magazine = await magazineRepo.create({ + await magazineRepo.create({ publication: 'Picsou Magazine', issueNumber: 547, edition: 'standard', @@ -43,12 +40,13 @@ async function seedCollection(): Promise { barcode: '3271234567890', notes: 'n° spécial', }); - await collectionRepo.addCopy(magazine.id, { notes: 'Acheté 0,50 €' }); - await collectionRepo.addCopy(magazine.id, { notes: 'Doublon' }); } +const CSV_HEADERS_ROW = + 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,createdAt,updatedAt'; + describe('BackupService.exportCollection', () => { - it('produit un export v1 avec toutes les éditions et exemplaires', async () => { + it('produit un export v1 avec toutes les éditions', async () => { await seedCollection(); const file = await service.exportCollection(); @@ -60,7 +58,6 @@ describe('BackupService.exportCollection', () => { expect(file.magazines).toHaveLength(1); expect(file.magazines[0].publication).toBe('Picsou Magazine'); expect(file.magazines[0].issueNumber).toBe(547); - expect(file.magazines[0].copies).toHaveLength(2); }); it('exporte une collection vide avec une liste magazines vide', async () => { @@ -86,16 +83,14 @@ describe('BackupService.importCollection', () => { const source = await service.exportCollection(); source.magazines[0].publication = 'Imported Magazine'; - source.magazines[0].copies = source.magazines[0].copies.slice(0, 1); const summary = await service.importCollection(service.toJson(source)); - expect(summary).toEqual({ magazines: 1, copies: 1 }); + expect(summary).toEqual({ magazines: 1 }); const after = await service.exportCollection(); expect(after.magazines).toHaveLength(1); expect(after.magazines[0].publication).toBe('Imported Magazine'); - expect(after.magazines[0].copies).toHaveLength(1); }); it('écrase entièrement même si le fichier contient moins d’éléments', async () => { @@ -122,10 +117,6 @@ describe('BackupService.importCollection', () => { const duplicate = { ...file.magazines[0], id: 'edition-soumise-a-echec', - copies: file.magazines[0].copies.map((copy) => ({ - ...copy, - id: `copie-${copy.id}`, - })), }; file.magazines.push(duplicate); @@ -149,7 +140,6 @@ describe('BackupService.importCollection', () => { const after = await service.exportCollection(); expect(after.magazines).toEqual(before.magazines); - expect(after.magazines[0].copies).toEqual(before.magazines[0].copies); }); it('préserve l’historique (createdAt/updatedAt) au round-trip', async () => { @@ -187,12 +177,12 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => const raw = JSON.stringify({ format: BACKUP_FORMAT, version: BACKUP_VERSION, - magazines: [{ id: 'x', publication: '', copies: [] }], + magazines: [{ id: 'x', publication: '' }], }); await expect(service.importCollection(raw)).rejects.toBeInstanceOf(InvalidBackupError); }); - it('rejette une édition sans liste d’exemplaires', async () => { + it('rejette une édition dont la liste d’exemplaires est mal formée (v1)', async () => { const raw = JSON.stringify({ format: BACKUP_FORMAT, version: BACKUP_VERSION, @@ -201,6 +191,28 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => await expect(service.importCollection(raw)).rejects.toBeInstanceOf(InvalidBackupError); }); + it('accepte et ignore les exemplaires d’un export v1 (rétro-compat)', async () => { + const raw = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: '2026-09-01T00:00:00Z', + magazines: [ + { + id: 'a', + publication: 'P', + copies: [{ id: 'c', notes: 'Ancienne note', dateAdded: '2026-09-01T00:00:00Z' }], + }, + ], + }); + + const summary = await service.importCollection(raw); + + expect(summary).toEqual({ magazines: 1 }); + const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(1); + expect(after.magazines[0].publication).toBe('P'); + }); + it('ne modifie pas les données à l’échec de validation', async () => { await seedCollection(); const before = await service.exportCollection(); @@ -217,8 +229,8 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => format: BACKUP_FORMAT, version: BACKUP_VERSION, magazines: [ - { id: 'dup', publication: 'P', issueNumber: 1, copies: [] }, - { id: 'dup', publication: 'P', issueNumber: 2, copies: [] }, + { id: 'dup', publication: 'P', issueNumber: 1 }, + { id: 'dup', publication: 'P', issueNumber: 2 }, ], }); await expect(service.importCollection(raw)).rejects.toThrow( @@ -226,54 +238,18 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => ); }); - it('rejette des identifiants d’exemplaire dupliqués', async () => { - const raw = JSON.stringify({ - format: BACKUP_FORMAT, - version: BACKUP_VERSION, - magazines: [ - { - id: 'a', - publication: 'P', - copies: [{ id: 'c', notes: null, dateAdded: '2026-09-01T00:00:00Z' }], - }, - { - id: 'b', - publication: 'P', - copies: [{ id: 'c', notes: null, dateAdded: '2026-09-01T00:00:00Z' }], - }, - ], - }); - await expect(service.importCollection(raw)).rejects.toThrow( - 'Fichier invalide : des identifiants d’exemplaire sont dupliqués.', - ); - }); - it('rejette un numéro d’édition fractionnaire', async () => { const raw = JSON.stringify({ format: BACKUP_FORMAT, version: BACKUP_VERSION, - magazines: [{ id: 'a', publication: 'P', issueNumber: 1.5, copies: [] }], + magazines: [{ id: 'a', publication: 'P', issueNumber: 1.5 }], }); await expect(service.importCollection(raw)).rejects.toThrow( 'Fichier invalide : le numéro d’édition doit être un entier', ); }); - it('rejette une date d’exemplaire ou d’export non conforme', async () => { - const badDate = JSON.stringify({ - format: BACKUP_FORMAT, - version: BACKUP_VERSION, - exportedAt: '2026-09-01T00:00:00Z', - magazines: [ - { - id: 'a', - publication: 'P', - copies: [{ id: 'c', notes: null, dateAdded: 'pas-une-date' }], - }, - ], - }); - await expect(service.importCollection(badDate)).rejects.toBeInstanceOf(InvalidBackupError); - + it('rejette une date d’export non conforme', async () => { const badExportedAt = JSON.stringify({ format: BACKUP_FORMAT, version: BACKUP_VERSION, @@ -287,7 +263,7 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => it('rejette un import dépassant la borne de taille', async () => { const oversized = new BackupService(testDb, 100); - const raw = `publication,issueNumber,copyNotes,dateAdded\nPicsou,${'1'.repeat(200)},,\n`; + const raw = `${CSV_HEADERS_ROW}\nPicsou,${'1'.repeat(200)}\n`; await expect(oversized.importCollection(raw, 'csv')).rejects.toThrow( 'Fichier invalide : import dépassant la taille maximale de 100 octets.', @@ -296,57 +272,29 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => }); describe('BackupService.exportCollection.toCsv', () => { - it('sérialise une ligne par exemplaire avec les en-têtes attendus', async () => { + it('sérialise une ligne par édition avec les en-têtes attendus', async () => { await seedCollection(); const file = await service.exportCollection(); const csv = service.toCsv(file); const lines = csv.trim().split('\n'); - expect(lines[0]).toBe( - 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt', - ); - expect(lines).toHaveLength(3); + expect(lines[0]).toBe(CSV_HEADERS_ROW); + expect(lines).toHaveLength(2); expect(lines[1]).toContain('Picsou Magazine,547,standard,FR,good,2023-03,3271234567890'); - expect(lines[1]).toContain('Acheté 0,50 €'); - expect(lines[2]).toContain('Doublon'); + expect(lines[1]).toContain('n° spécial'); }); it('échappe les champs contenant virgule, guillemets ou saut de ligne', async () => { await seedCollection(); const file = await service.exportCollection(); - file.magazines[0].notes = 'Note "citée"'; - file.magazines[0].copies[0].notes = 'a,b\nc'; + file.magazines[0].notes = 'Note "citée", suite'; + file.magazines[0].ocrText = 'a,b\nc'; const csv = service.toCsv(file); - expect(csv).toContain('"Note ""citée"""'); + expect(csv).toContain('"Note ""citée"", suite"'); expect(csv).toContain('"a,b\nc"'); }); - - it('conserve les éditions sans exemplaire via une ligne aux champs vides', async () => { - await magazineRepo.create({ publication: 'Vacances', issueNumber: 12 }); - - const file = await service.exportCollection(); - const csv = service.toCsv(file); - const lines = csv.trim().split('\n'); - expect(lines).toHaveLength(2); - expect(lines[1]).toContain('Vacances,12,'); - expect(lines[1]).toContain(',,,,'); - }); - - it('round-trip CSV : une édition sans copie n’est pas réimportée en exemplaire fantôme', async () => { - await magazineRepo.create({ publication: 'Vacances', issueNumber: 12 }); - - const file = await service.exportCollection(); - const summary = await service.importCollection(service.toCsv(file), 'csv'); - - expect(summary).toEqual({ magazines: 1, copies: 0 }); - - const after = await service.exportCollection(); - expect(after.magazines).toHaveLength(1); - expect(after.magazines[0].publication).toBe('Vacances'); - expect(after.magazines[0].copies).toHaveLength(0); - }); }); describe('BackupService CSV import (US-BK-05)', () => { @@ -357,19 +305,18 @@ describe('BackupService CSV import (US-BK-05)', () => { const summary = await service.importCollection(service.toCsv(source), 'csv'); - expect(summary).toEqual({ magazines: 1, copies: 2 }); + expect(summary).toEqual({ magazines: 1 }); const after = await service.exportCollection(); expect(after.magazines).toHaveLength(1); expect(after.magazines[0].publication).toBe('Csv Magazine'); - expect(after.magazines[0].copies).toHaveLength(2); }); it('valide un CSV via validateCollection et en produit le récapitulatif', async () => { await seedCollection(); const source = await service.exportCollection(); const summary = await service.validateCollection(service.toCsv(source), 'csv'); - expect(summary).toEqual({ magazines: 1, copies: 2 }); + expect(summary).toEqual({ magazines: 1 }); }); it('rejette un CSV sans les en-têtes attendus ou vide', async () => { @@ -380,60 +327,40 @@ describe('BackupService CSV import (US-BK-05)', () => { }); it('rejette un CSV sans publication ou à numéro non entier', async () => { - const headers = - 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\n'; - await expect(service.validateCollection(`${headers},1\n`, 'csv')).rejects.toBeInstanceOf( - InvalidBackupError, - ); await expect( - service.validateCollection(`${headers}Picsou,abc\n`, 'csv'), + service.validateCollection(`${CSV_HEADERS_ROW}\n,1\n`, 'csv'), + ).rejects.toBeInstanceOf(InvalidBackupError); + await expect( + service.validateCollection(`${CSV_HEADERS_ROW}\nPicsou,abc\n`, 'csv'), ).rejects.toBeInstanceOf(InvalidBackupError); }); it('gère les valeurs entre guillemets avec virgules et retours à la ligne', async () => { const csv = - 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\n' + - '"Picsou, le Tocard",5,limited,FR,good,2020-01,,"note, suite \n ligne 2",,"Acheté 0,50 €",2026-09-01T00:00:00Z\n'; + `${CSV_HEADERS_ROW}\n` + + '"Picsou, le Tocard",5,limited,FR,good,2020-01,,"note, suite \n ligne 2",,2026-09-01T00:00:00Z,2026-09-01T00:00:00Z\n'; const summary = await service.validateCollection(csv, 'csv'); - expect(summary).toEqual({ magazines: 1, copies: 1 }); + expect(summary).toEqual({ magazines: 1 }); await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); expect(after.magazines[0].publication).toBe('Picsou, le Tocard'); expect(after.magazines[0].notes).toBe('note, suite \n ligne 2'); - expect(after.magazines[0].copies[0].notes).toBe('Acheté 0,50 €'); }); it('préserve un CRLF à l’intérieur d’un champ entre guillemets', async () => { - const header = - 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded'; - const row = [ - '"Retour\nChamp"', - '5', - '', - '', - '', - '', - '', - '', - '', - '"note\r\nmulti"', - '2026-09-01T00:00:00Z', - ].join(','); - const csv = `${header}\n${row}\n`; + const row = ['"Retour\nChamp"', '5', '', '', '', '', '', '', '', '', ''].join(','); + const csv = `${CSV_HEADERS_ROW}\n${row}\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); expect(after.magazines[0].publication).toBe('Retour\nChamp'); - expect(after.magazines[0].copies[0].notes).toBe('note\r\nmulti'); }); it('gère des retours chariot nus (\r) comme séparateurs de ligne', async () => { - const csv = - `publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\r` + - `Picsou,5,,,,,,,,,2026-09-01T00:00:00Z\rDoublon,6,,,,,,,,,2026-09-02T00:00:00Z\r`; + const csv = `${CSV_HEADERS_ROW}\rPicsou,5,,,,,,,,,\rDoublon,6,,,,,,,,,\r`; const summary = await service.importCollection(csv, 'csv'); - expect(summary).toEqual({ magazines: 2, copies: 2 }); + expect(summary).toEqual({ magazines: 2 }); }); }); @@ -443,7 +370,7 @@ describe('BackupService — validation de structure (JSON)', () => { it('rejette une valeur de texte non conforme', async () => { const raw = JSON.stringify({ ...base, - magazines: [{ id: 'x', publication: 'P', edition: 42, copies: [] }], + magazines: [{ id: 'x', publication: 'P', edition: 42 }], }); await expect(service.importCollection(raw)).rejects.toThrow( 'certaines valeurs de texte sont mal formées', @@ -453,7 +380,7 @@ describe('BackupService — validation de structure (JSON)', () => { it('rejette une valeur numérique non conforme', async () => { const raw = JSON.stringify({ ...base, - magazines: [{ id: 'x', publication: 'P', issueNumber: '547', copies: [] }], + magazines: [{ id: 'x', publication: 'P', issueNumber: '547' }], }); await expect(service.importCollection(raw)).rejects.toThrow( 'certaines valeurs numériques sont mal formées', @@ -464,80 +391,62 @@ describe('BackupService — validation de structure (JSON)', () => { const raw = JSON.stringify({ ...base, magazines: [null] }); await expect(service.importCollection(raw)).rejects.toThrow('une édition est mal formée'); }); - - it('rejette un exemplaire mal formé', async () => { - const raw = JSON.stringify({ - ...base, - magazines: [{ id: 'x', publication: 'P', copies: [null] }], - }); - await expect(service.importCollection(raw)).rejects.toThrow('un exemplaire est mal formé'); - }); - - it('rejette un exemplaire sans identifiant', async () => { - const raw = JSON.stringify({ - ...base, - magazines: [{ id: 'x', publication: 'P', copies: [{ id: '', notes: null }] }], - }); - await expect(service.importCollection(raw)).rejects.toThrow('identifiant d’exemplaire'); - }); }); describe('BackupService — CSV cas limites', () => { - const headers = - 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded'; - it('accepte les fins de ligne Windows (CRLF)', async () => { const summary = await service.validateCollection( - `${headers}\r\nPicsou,5,,,,,,,,,2026-09-01T00:00:00Z\r\n`, + `${CSV_HEADERS_ROW}\r\nPicsou,5,,,,,,,,,\r\n`, 'csv', ); - expect(summary).toEqual({ magazines: 1, copies: 1 }); + expect(summary).toEqual({ magazines: 1 }); }); it('ignore les lignes vides en fin de fichier', async () => { const summary = await service.validateCollection( - `${headers}\nPicsou,5,,,,,,,,,2026-09-01T00:00:00Z\n\n\n`, + `${CSV_HEADERS_ROW}\nPicsou,5,,,,,,,,,\n\n\n`, 'csv', ); - expect(summary).toEqual({ magazines: 1, copies: 1 }); + expect(summary).toEqual({ magazines: 1 }); }); it('déchiffre les guillemets doublés à l’intérieur d’un champ', async () => { - const csv = `${headers}\nPicsou,,,,,,,,,"il a dit ""OK""",\n`; + const row = ['Picsou', '', '', '', '', '', '', '"il a dit ""OK"""', '', '', ''].join(','); + const csv = `${CSV_HEADERS_ROW}\n${row}\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); - expect(after.magazines[0].copies[0].notes).toBe('il a dit "OK"'); + expect(after.magazines[0].notes).toBe('il a dit "OK"'); }); it('normalise les espaces autour des champs texte', async () => { - const csv = `${headers}\nPicsou,5, edition , FR ,,2020-01,,, ,,\n`; + const csv = `${CSV_HEADERS_ROW}\nPicsou,5, edition , FR ,,2020-01,,, ,,\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); expect(after.magazines[0].edition).toBe('edition'); expect(after.magazines[0].language).toBe('FR'); expect(after.magazines[0].notes).toBeNull(); - expect(after.magazines[0].copies).toHaveLength(0); - }); - - it('n’importe pas d’exemplaire fantôme pour une ligne aux champs exemplaire vides', async () => { - const csv = `${headers}\nPicsou,5,,,,,,,,,\n`; - await service.importCollection(csv, 'csv'); - const after = await service.exportCollection(); - expect(after.magazines).toHaveLength(1); - expect(after.magazines[0].copies).toHaveLength(0); }); it('préserve les variantes d’édition (edition/language/condition) à l’import CSV', async () => { const csv = - `${headers}\nPicsou,5,FR,,,,,,,,2026-09-01T00:00:00Z\n` + + `${CSV_HEADERS_ROW}\nPicsou,5,FR,,,,,,,,2026-09-01T00:00:00Z\n` + `Picsou,5,BE,,,,,,,,2026-09-01T00:00:00Z\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); expect(after.magazines).toHaveLength(2); const editions = after.magazines.map((m) => m.edition).sort(); expect(editions).toEqual(['BE', 'FR']); - expect(after.magazines[0].copies).toHaveLength(1); - expect(after.magazines[1].copies).toHaveLength(1); + }); + + it('lit un export v1 (colonnes exemplaires en plus) en ignorant leur contenu', async () => { + const v1Headers = + 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt'; + const csv = `${v1Headers}\nPicsou,5,,,,,,,,Acheté 0,50 €,2026-09-01T00:00:00Z,,\n`; + const summary = await service.importCollection(csv, 'csv'); + expect(summary).toEqual({ magazines: 1 }); + const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(1); + expect(after.magazines[0].publication).toBe('Picsou'); }); }); diff --git a/src/components/__tests__/barcode-pending-sheets.test.tsx b/src/components/__tests__/barcode-pending-sheets.test.tsx index 94d56a6..58e7fbe 100644 --- a/src/components/__tests__/barcode-pending-sheets.test.tsx +++ b/src/components/__tests__/barcode-pending-sheets.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { render, screen, userEvent } from '@testing-library/react-native'; import { BarcodeContinuousBar, @@ -29,7 +29,6 @@ const magazine: Magazine = { function renderPending(pending: Pending) { const callbacks = { onResume: jest.fn(), - onConfirmAdd: jest.fn(), onManual: jest.fn(), }; render( @@ -37,7 +36,6 @@ function renderPending(pending: Pending) { styles={styles} pending={pending} onResume={callbacks.onResume} - onConfirmAdd={callbacks.onConfirmAdd} onManual={callbacks.onManual} />, ); @@ -45,80 +43,58 @@ function renderPending(pending: Pending) { } describe('BarcodePendingSheets', () => { - it('affiche la confirmation de doublon avec le numéro et le nombre d’exemplaires', () => { - const callbacks = renderPending({ - kind: 'confirm', - magazine, - ownedCount: 3, - }); - - expect(screen.getByText('Vous possédez déjà ce magazine')).toBeTruthy(); - expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); - expect(screen.getByText(/Exemplaires actuels : 3/)).toBeTruthy(); + it('affiche le panier « Déjà dans votre collection » avec le numéro', async () => { + const user = userEvent.setup(); + const callbacks = renderPending({ kind: 'owned', magazine }); - fireEvent.press(screen.getByTestId('pending-confirm-add')); - expect(callbacks.onConfirmAdd).toHaveBeenCalled(); + expect(screen.getByText('Déjà dans votre collection')).toBeTruthy(); + expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); + expect(screen.getByText('Cette édition est bien possédée.')).toBeTruthy(); - fireEvent.press(screen.getByTestId('pending-confirm-cancel')); + await user.press(screen.getByTestId('pending-owned-ok')); expect(callbacks.onResume).toHaveBeenCalled(); - }); - - it('affiche le magazine sans numéro quand il est absent', () => { - renderPending({ kind: 'confirm', magazine: { ...magazine, issueNumber: null }, ownedCount: 1 }); - - expect(screen.getByText('Picsou Magazine')).toBeTruthy(); - }); - - it('affiche l’écran de succès puis reprend le scan', () => { - const callbacks = renderPending({ - kind: 'success', - publication: 'Picsou Magazine', - issueNumber: 547, - }); - expect(screen.getByText('Ajouté à la collection')).toBeTruthy(); - expect(screen.getByText('Picsou Magazine n° 547')).toBeTruthy(); - - fireEvent.press(screen.getByTestId('pending-success-ok')); + await user.press(screen.getByTestId('pending-close')); expect(callbacks.onResume).toHaveBeenCalled(); }); - it('affiche l’écran de succès sans numéro si l’édition n’en a pas', () => { - renderPending({ kind: 'success', publication: 'Picsou Magazine', issueNumber: null }); + it('affiche le magazine sans numéro quand il est absent', () => { + renderPending({ kind: 'owned', magazine: { ...magazine, issueNumber: null } }); expect(screen.getByText('Picsou Magazine')).toBeTruthy(); }); - it('suggère la saisie manuelle pour un code-barres inconnu', () => { + it('suggère la saisie manuelle pour un code-barres inconnu', async () => { + const user = userEvent.setup(); const callbacks = renderPending({ kind: 'unknown', barcode: '9791234567890' }); expect(screen.getByText('Code-barres inconnu')).toBeTruthy(); expect(screen.getByText('9791234567890')).toBeTruthy(); - fireEvent.press(screen.getByTestId('pending-unknown-manual')); + await user.press(screen.getByTestId('pending-unknown-manual')); expect(callbacks.onManual).toHaveBeenCalledWith('9791234567890'); - fireEvent.press(screen.getByTestId('pending-unknown-continue')); + await user.press(screen.getByTestId('pending-unknown-continue')); expect(callbacks.onResume).toHaveBeenCalled(); }); it('ne rend rien pour un état inconnu', () => { renderPending('idle' as unknown as Pending); - expect(screen.queryByTestId('pending-confirm')).toBeNull(); - expect(screen.queryByTestId('pending-success')).toBeNull(); + expect(screen.queryByTestId('pending-owned')).toBeNull(); expect(screen.queryByTestId('pending-unknown')).toBeNull(); }); }); describe('BarcodeContinuousBar', () => { - it('affiche le bandeau continu et permet de l’arrêter', () => { + it('affiche le bandeau continu et permet de l’arrêter', async () => { + const user = userEvent.setup(); const onStop = jest.fn(); render(); - expect(screen.getByText(/Scan en continu/)).toBeTruthy(); + expect(screen.getByText(/Scan en continu — vérifie chaque code-barres/)).toBeTruthy(); - fireEvent.press(screen.getByTestId('continuous-stop')); + await user.press(screen.getByTestId('continuous-stop')); expect(onStop).toHaveBeenCalled(); }); }); diff --git a/src/components/__tests__/magazine-card.test.tsx b/src/components/__tests__/magazine-card.test.tsx index fddcb1e..f63f87b 100644 --- a/src/components/__tests__/magazine-card.test.tsx +++ b/src/components/__tests__/magazine-card.test.tsx @@ -16,7 +16,6 @@ const base: MagazineListItem = { ocrText: null, createdAt: '2026-09-01T10:00:00Z', updatedAt: '2026-09-01T10:00:00Z', - quantity: 1, }; describe('MagazineCard', () => { @@ -27,33 +26,16 @@ describe('MagazineCard', () => { expect(screen.getByText('n° 547')).toBeTruthy(); }); - it('affiche Possede avec la quantite', () => { - render(); + it('affiche le badge Possédé (plus d exemplaires)', () => { + render(); expect(screen.getByTestId('status-owned')).toBeTruthy(); - expect(screen.getByText(/Possédé \(3\)/)).toBeTruthy(); - }); - - it('affiche Absent quand aucune copie', () => { - render(); - - expect(screen.getByTestId('status-absent')).toBeTruthy(); - expect(screen.getByText('Absent')).toBeTruthy(); + expect(screen.getByText('Possédé')).toBeTruthy(); }); it('annonce le statut possédé au lecteur d’écran', () => { - render(); - - expect(screen.getByRole('button')).toHaveAccessibleName( - 'Picsou Magazine numéro 547, possédé 2 fois', - ); - }); - - it('annonce l’absence au lecteur d’écran', () => { - render(); + render(); - expect(screen.getByRole('button')).toHaveAccessibleName( - 'Picsou Magazine numéro 547, absent de la collection', - ); + expect(screen.getByRole('button')).toHaveAccessibleName('Picsou Magazine numéro 547, possédé'); }); }); diff --git a/src/components/__tests__/ocr-analysis.test.ts b/src/components/__tests__/ocr-analysis.test.ts index e9985c3..8be02cf 100644 --- a/src/components/__tests__/ocr-analysis.test.ts +++ b/src/components/__tests__/ocr-analysis.test.ts @@ -1,10 +1,4 @@ -import { - buildManualParams, - confidenceLabel, - hasAnyDetected, - ANALYSIS_INTERVAL_MS, - OCR_STABLE_READS, -} from '@/components/scan/ocr-analysis'; +import { buildManualParams, confidenceLabel, hasAnyDetected } from '@/components/scan/ocr-analysis'; describe('ocr-analysis (logique pure extraite de camera.tsx)', () => { describe('buildManualParams', () => { @@ -43,9 +37,4 @@ describe('ocr-analysis (logique pure extraite de camera.tsx)', () => { expect(hasAnyDetected({ publication: null, issueNumber: null, date: null })).toBe(false); }); }); - - it('expose les constantes d’analyse OCR', () => { - expect(ANALYSIS_INTERVAL_MS).toBe(500); - expect(OCR_STABLE_READS).toBe(2); - }); }); diff --git a/src/components/__tests__/ocr-analyzing-overlay.test.tsx b/src/components/__tests__/ocr-analyzing-overlay.test.tsx index dfa3a5b..1917dac 100644 --- a/src/components/__tests__/ocr-analyzing-overlay.test.tsx +++ b/src/components/__tests__/ocr-analyzing-overlay.test.tsx @@ -7,7 +7,9 @@ import { Colors } from '@/constants/theme'; const styles = makeCameraOcrStyles(Colors.light, { top: 0, bottom: 0 }); -function renderOverlay(overrides: Partial<{ detected: DetectedInfo; weakCycles: number }> = {}) { +function renderOverlay( + overrides: Partial<{ detected: DetectedInfo; weakCycles: number; capturing: boolean }> = {}, +) { const callbacks = { onOpenConfirm: jest.fn(), onGoBarcode: jest.fn(), @@ -21,6 +23,7 @@ function renderOverlay(overrides: Partial<{ detected: DetectedInfo; weakCycles: detected={overrides.detected ?? { publication: null, issueNumber: null, date: null }} hint="Centre le magazine" weakCycles={overrides.weakCycles ?? 0} + capturing={overrides.capturing ?? false} torchOn={false} onOpenConfirm={callbacks.onOpenConfirm} onGoBarcode={callbacks.onGoBarcode} @@ -33,13 +36,19 @@ function renderOverlay(overrides: Partial<{ detected: DetectedInfo; weakCycles: } describe('OcrAnalyzingOverlay', () => { - it('affiche le texte-guide et la lecture en cours', () => { - renderOverlay(); + it('affiche le texte-guide et la lecture en cours pendant la capture', () => { + renderOverlay({ capturing: true }); expect(screen.getByTestId('ocr-hint')).toHaveTextContent('Centre le magazine'); expect(screen.getByText('Lecture…')).toBeTruthy(); }); + it('n’affiche pas la pastille de lecture hors capture', () => { + renderOverlay(); + + expect(screen.queryByText('Lecture…')).toBeNull(); + }); + it('affiche des champs détectés avec leurs valeurs', () => { renderOverlay({ detected: { publication: 'Picsou Magazine', issueNumber: 547, date: '2024-06' }, @@ -94,6 +103,7 @@ describe('OcrAnalyzingOverlay', () => { detected={{ publication: null, issueNumber: null, date: null }} hint="" weakCycles={0} + capturing={false} torchOn onOpenConfirm={jest.fn()} onGoBarcode={jest.fn()} diff --git a/src/components/__tests__/scan-search-views.test.tsx b/src/components/__tests__/scan-search-views.test.tsx index 3fef099..d305a0b 100644 --- a/src/components/__tests__/scan-search-views.test.tsx +++ b/src/components/__tests__/scan-search-views.test.tsx @@ -1,14 +1,12 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { render, screen, userEvent } from '@testing-library/react-native'; import { SearchFoundResult, SearchUnknownResult } from '@/components/scan-search-views'; describe('SearchFoundResult', () => { - const onAddCopy = jest.fn(); const onView = jest.fn(); const onRescan = jest.fn(); beforeEach(() => { - onAddCopy.mockClear(); onView.mockClear(); onRescan.mockClear(); }); @@ -18,73 +16,43 @@ describe('SearchFoundResult', () => { , ); + expect(screen.getByTestId('search-magazine')).toHaveTextContent('Picsou Magazine'); expect(screen.getByText('N° 547')).toBeTruthy(); - expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé (2)'); - fireEvent.press(screen.getByTestId('search-add')); - expect(onAddCopy).toHaveBeenCalled(); + expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé'); }); - it('affiche « absent » quand l’édition n’est pas possédée', () => { + it('s’affiche sans numéro quand celui-ci est absent', () => { render( , ); expect(screen.queryByText('N° 547')).toBeNull(); - expect(screen.getByTestId('search-status-absent')).toHaveTextContent('○ Absent'); - }); - - it('affiche la vérification en cours tant que l’édition n’est pas résolue', () => { - render( - , - ); - - expect(screen.getByTestId('search-loading')).toHaveTextContent('Vérification…'); }); - it('sait relancer un scan ou ouvrir la fiche', () => { + it('sait relancer un scan ou ouvrir la fiche', async () => { + const user = userEvent.setup(); render( , ); - fireEvent.press(screen.getByTestId('search-view')); + await user.press(screen.getByTestId('search-view')); expect(onView).toHaveBeenCalled(); - fireEvent.press(screen.getByTestId('search-rescan')); + await user.press(screen.getByTestId('search-rescan')); expect(onRescan).toHaveBeenCalled(); }); }); @@ -93,7 +61,8 @@ describe('SearchUnknownResult', () => { const onManual = jest.fn(); const onAgain = jest.fn(); - it('affiche la publication, avec ou sans numéro', () => { + it('affiche la publication, avec ou sans numéro', async () => { + const user = userEvent.setup(); render( { expect(screen.getByText(/Picsou Magazine/)).toBeTruthy(); expect(screen.getByText('N° 547')).toBeTruthy(); - fireEvent.press(screen.getByTestId('search-manual')); + await user.press(screen.getByTestId('search-manual')); expect(onManual).toHaveBeenCalled(); - fireEvent.press(screen.getByTestId('search-again')); + await user.press(screen.getByTestId('search-again')); expect(onAgain).toHaveBeenCalled(); }); diff --git a/src/components/__tests__/status-badge.test.tsx b/src/components/__tests__/status-badge.test.tsx index b6f57f7..98009ff 100644 --- a/src/components/__tests__/status-badge.test.tsx +++ b/src/components/__tests__/status-badge.test.tsx @@ -3,28 +3,11 @@ import { render, screen } from '@testing-library/react-native'; import { StatusBadge } from '@/components/status-badge'; describe('StatusBadge', () => { - it('annonce la quantité possédée quand elle est positive', () => { - render(); - - const badge = screen.getByTestId('status-owned'); - expect(badge).toBeTruthy(); - expect(screen.getByText('Possédé (3)')).toBeTruthy(); - expect(badge).toHaveAccessibleName('Possédé, 3 exemplaires'); - }); - - it('reste générique quand le nombre d exemplaires est nul', () => { - render(); + it('affiche le statut possédé de manière générique', () => { + render(); const badge = screen.getByTestId('status-owned'); expect(screen.getByText('Possédé')).toBeTruthy(); expect(badge).toHaveAccessibleName('Possédé'); }); - - it('indique l’absence sans quantification', () => { - render(); - - const badge = screen.getByTestId('status-absent'); - expect(screen.getByText('Absent')).toBeTruthy(); - expect(badge).toHaveAccessibleName('Absent de la collection'); - }); }); diff --git a/src/database/__tests__/migrations.test.ts b/src/database/__tests__/migrations.test.ts index 5a1a420..8bd89a3 100644 --- a/src/database/__tests__/migrations.test.ts +++ b/src/database/__tests__/migrations.test.ts @@ -21,23 +21,22 @@ describe('migrate', () => { expect(row?.user_version).toBe(getSchemaVersion()); }); - it('cree les tables magazines, collection_items et settings', async () => { + it('cree les tables magazines et settings', async () => { await migrate(testDb); const tables = await testDb.getAllAsync<{ name: string }>( "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('magazines','collection_items','settings') ORDER BY name", ); - expect(tables.map((t) => t.name)).toEqual(['collection_items', 'magazines', 'settings']); + expect(tables.map((t) => t.name)).toEqual(['magazines', 'settings']); }); - it('cree les 3 index requis', async () => { + it('cree les 2 index requis', async () => { await migrate(testDb); const indexes = await testDb.getAllAsync<{ name: string }>( "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%' ORDER BY name", ); expect(indexes.map((i) => i.name)).toEqual([ - 'idx_collection_items_magazine_id', 'idx_magazines_barcode', 'idx_magazines_publication_issue', ]); @@ -113,4 +112,60 @@ describe('migrate', () => { await expect(migrate(db)).rejects.toThrow(/Migration incomplète/); }); + + it('migre v4 → v5 : reporte la note de la première copie puis supprime collection_items', async () => { + await testDb.execAsync(` + CREATE TABLE magazines ( + id TEXT PRIMARY KEY NOT NULL, + publication TEXT NOT NULL, + issue_number INTEGER, + edition TEXT, + language TEXT, + condition TEXT, + publication_date TEXT, + barcode TEXT, + notes TEXT, + ocr_text TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE collection_items ( + id TEXT PRIMARY KEY NOT NULL, + magazine_id TEXT NOT NULL, + condition TEXT, + notes TEXT, + date_added TEXT NOT NULL + ); + INSERT INTO magazines (id, publication, issue_number, created_at, updated_at) + VALUES + ('m1', 'Picsou', 547, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z'), + ('m2', 'Journal', NULL, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z'); + INSERT INTO collection_items (id, magazine_id, notes, date_added) + VALUES + ('c1', 'm1', 'Note première', '2020-01-01T00:00:00Z'), + ('c2', 'm1', 'Note seconde', '2021-01-01T00:00:00Z'), + ('c3', 'm2', '', '2020-01-01T00:00:00Z'); + PRAGMA user_version = 4; + `); + + await migrate(testDb); + + const m1 = await testDb.getFirstAsync<{ notes: string | null }>( + 'SELECT notes FROM magazines WHERE id = ?', + 'm1', + ); + const m2 = await testDb.getFirstAsync<{ notes: string | null }>( + 'SELECT notes FROM magazines WHERE id = ?', + 'm2', + ); + const leftover = await testDb.getAllAsync<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name = 'collection_items'", + ); + const version = await testDb.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + + expect(m1?.notes).toBe('Note première'); + expect(m2?.notes).toBeNull(); + expect(leftover).toHaveLength(0); + expect(version?.user_version).toBe(getSchemaVersion()); + }); }); diff --git a/src/database/repositories/__tests__/collection-repository.test.ts b/src/database/repositories/__tests__/collection-repository.test.ts deleted file mode 100644 index 3d2b3c8..0000000 --- a/src/database/repositories/__tests__/collection-repository.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { createTestDatabase } from '@/test-utils/test-db'; -import { migrate } from '@/database/migrations'; -import { CollectionRepository } from '@/database/repositories/collection-repository'; -import { MagazineRepository } from '@/database/repositories/magazine-repository'; - -jest.mock('expo-crypto', () => { - let n = 0; - return { - randomUUID: jest.fn(() => { - n += 1; - return `b1a2c3d4-0000-4000-8000-${String(n).padStart(12, '0')}`; - }), - }; -}); - -let testDb: ReturnType; -let repo: CollectionRepository; -let magazineRepo: MagazineRepository; -let magazineId: string; - -beforeEach(async () => { - testDb = createTestDatabase(); - await migrate(testDb); - repo = new CollectionRepository(testDb); - magazineRepo = new MagazineRepository(testDb); - magazineId = (await magazineRepo.create({ publication: 'Picsou Magazine', issueNumber: 547 })).id; -}); - -afterEach(async () => { - await testDb.close(); -}); - -describe('collectionRepository.addCopy', () => { - it('ajoute un exemplaire avec notes et date', async () => { - const copy = await repo.addCopy(magazineId, { - notes: 'emballage intact', - }); - - expect(copy).toMatchObject({ - magazineId, - notes: 'emballage intact', - }); - expect(copy.id).toMatch(/^b1a2c3d4-0000-4000-8000-/); - expect(copy.dateAdded).toBeDefined(); - - const reloaded = await repo.countByMagazine(magazineId); - expect(reloaded).toBe(1); - }); - - it('refuse un exemplaire pour une edition inexistante', async () => { - await expect(repo.addCopy('edition-inconnue')).rejects.toMatchObject({ - code: 'SQLITE_CONSTRAINT_FOREIGNKEY', - }); - }); -}); - -describe('collectionRepository.countByMagazine', () => { - it('compte uniquement les exemplaires de la publication', async () => { - const otherMagazine = await magazineRepo.create({ - publication: 'Mickey Parade', - issueNumber: 2, - }); - - await repo.addCopy(magazineId); - await repo.addCopy(magazineId); - await repo.addCopy(otherMagazine.id); - - expect(await repo.countByMagazine(magazineId)).toBe(2); - expect(await repo.countByMagazine(otherMagazine.id)).toBe(1); - expect(await repo.countByMagazine('edition-inconnue')).toBe(0); - }); -}); - -describe('suppression en cascade', () => { - it('supprimer une edition supprime ses exemplaires', async () => { - await repo.addCopy(magazineId); - await repo.addCopy(magazineId); - - await magazineRepo.delete(magazineId); - - expect(await repo.countByMagazine(magazineId)).toBe(0); - }); -}); - -describe('collectionRepository.listRecentCopies', () => { - it('liste les derniers exemplaires avec les infos du magazine, du plus recent au plus ancien', async () => { - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'copy-old', - magazineId, - '2026-01-01T10:00:00Z', - ); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'copy-new', - magazineId, - '2026-09-01T10:00:00Z', - ); - const otherMagazine = await magazineRepo.create({ - publication: 'Super Picsou Géant', - issueNumber: 30, - }); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, 'coffret', ?)`, - 'copy-mid', - otherMagazine.id, - '2026-05-01T10:00:00Z', - ); - - const recent = await repo.listRecentCopies(2); - - expect(recent).toHaveLength(2); - expect(recent[0].copy.id).toBe('copy-new'); - expect(recent[0].magazine.publication).toBe('Picsou Magazine'); - expect(recent[0].magazine.issueNumber).toBe(547); - expect(recent[1].copy.id).toBe('copy-mid'); - expect(recent[1].copy.notes).toBe('coffret'); - expect(recent[1].magazine.publication).toBe('Super Picsou Géant'); - }); - - it('respecte la limite et retourne une liste vide si aucun exemplaire', async () => { - expect(await repo.listRecentCopies(5)).toEqual([]); - }); - - it('utilise la limite par défaut de 5 quand aucune n est fournie', async () => { - await repo.addCopy(magazineId); - - const recent = await repo.listRecentCopies(); - - expect(recent).toHaveLength(1); - expect(recent[0].copy.id).toBeDefined(); - }); -}); - -describe('collectionRepository.countAllCopies', () => { - it('compte tous les exemplaires de la collection', async () => { - expect(await repo.countAllCopies()).toBe(0); - - await repo.addCopy(magazineId); - await repo.addCopy(magazineId); - const otherMagazine = await magazineRepo.create({ - publication: 'Super Picsou Géant', - issueNumber: 30, - }); - await repo.addCopy(otherMagazine.id); - - expect(await repo.countAllCopies()).toBe(3); - }); -}); - -describe('compteurs sans résultat renvoyé', () => { - it('traite un compteur absent comme zéro', async () => { - const originalGet = testDb.getFirstAsync; - const spy = jest.spyOn(testDb, 'getFirstAsync').mockImplementation((async ( - sql: string, - ...args: unknown[] - ) => { - if (String(sql).includes('COUNT(*)')) { - return undefined; - } - return originalGet(sql, ...args); - }) as never); - - expect(await repo.countByMagazine(magazineId)).toBe(0); - expect(await repo.countAllCopies()).toBe(0); - - spy.mockRestore(); - }); -}); diff --git a/src/database/repositories/__tests__/magazine-repository.test.ts b/src/database/repositories/__tests__/magazine-repository.test.ts index 0d7efd0..1af6ee1 100644 --- a/src/database/repositories/__tests__/magazine-repository.test.ts +++ b/src/database/repositories/__tests__/magazine-repository.test.ts @@ -104,33 +104,6 @@ describe('magazineRepository.create', () => { }); }); -describe('magazineRepository.createWithCopy', () => { - it('cree l’edition et son premier exemplaire dans une seule transaction', async () => { - const result = await repo.createWithCopy({ - publication: 'Picsou Magazine', - issueNumber: 547, - }); - - expect(result.magazine.publication).toBe('Picsou Magazine'); - expect(result.magazine.issueNumber).toBe(547); - expect(result.copyId).toBeTruthy(); - - const copy = await testDb.getFirstAsync<{ - id: string; - magazine_id: string; - notes: string | null; - }>('SELECT id, magazine_id, notes FROM collection_items WHERE id = ?', result.copyId); - expect(copy?.magazine_id).toBe(result.magazine.id); - expect(copy?.notes).toBeNull(); - }); - - it('refuse une publication vide', async () => { - await expect(repo.createWithCopy({ publication: ' ' })).rejects.toThrow( - 'La publication est obligatoire.', - ); - }); -}); - describe('magazineRepository.findManyByBarcode', () => { it('retourne toutes les editions partageant le meme code-barres', async () => { const first = await repo.create({ @@ -150,33 +123,6 @@ describe('magazineRepository.findManyByBarcode', () => { expect(result.map((m) => m.id)).toEqual([first.id, second.id]); }); - it('compte les exemplaires possedes pour chaque edition', async () => { - const mag = await repo.create({ - publication: 'Picsou Magazine', - issueNumber: 547, - barcode: '3271232567890', - }); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c1', - mag.id, - '2026-09-01T10:00:00Z', - ); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c2', - mag.id, - '2026-09-01T10:00:00Z', - ); - - const result = await repo.findManyByBarcode('3271232567890'); - - expect(result).toHaveLength(1); - expect(result[0].quantity).toBe(2); - }); - it('retourne une liste vide pour un code-barres inconnu', async () => { const result = await repo.findManyByBarcode('9999999999999'); @@ -258,48 +204,6 @@ describe('magazineRepository.list', () => { expect(list.map((m) => m.publication)).toEqual(['Échos Vacances', 'Espiègle', 'etu-SORCIER']); }); - it('compte le nombre dexemplaires possedes', async () => { - const single = await repo.create({ publication: 'Picsou Magazine', issueNumber: 547 }); - const double = await repo.create({ publication: 'Super Picsou Géant', issueNumber: 30 }); - - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c1', - single.id, - '2026-09-01T10:00:00Z', - ); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c2', - double.id, - '2026-09-01T10:00:00Z', - ); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c3', - double.id, - '2026-09-01T10:00:00Z', - ); - - const list = await repo.list(); - - const byPublication = new Map(list.map((m) => [m.publication, m])); - expect(byPublication.get('Picsou Magazine')?.quantity).toBe(1); - expect(byPublication.get('Super Picsou Géant')?.quantity).toBe(2); - }); - - it('renvoie 0 exemplaire pour une edition non possedee', async () => { - await repo.create({ publication: 'Mickey Parade', issueNumber: 1 }); - - const list = await repo.list(); - - expect(list).toHaveLength(1); - expect(list[0].quantity).toBe(0); - }); - it('reste leger : ne charge ni notes ni ocr_text', async () => { await repo.create({ publication: 'Mickey Parade', @@ -354,21 +258,6 @@ describe('magazineRepository.search', () => { expect(results).toEqual([]); }); - it('calcule la quantite pour chaque resultat', async () => { - const mag = await repo.create({ publication: 'Picsou Magazine', issueNumber: 547 }); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c1', - mag.id, - '2026-09-01T10:00:00Z', - ); - - const results = await repo.search('Picsou'); - - expect(results[0].quantity).toBe(1); - }); - it('traite % et _ comme des caracteres litteraux', async () => { await repo.create({ publication: 'Picsou 100%', issueNumber: 1 }); await repo.create({ publication: 'Picsou_20', issueNumber: 2 }); @@ -389,7 +278,7 @@ describe('magazineRepository.search', () => { }); describe('magazineRepository.findById', () => { - it('charge une edition complete avec ses copies', async () => { + it('charge une edition complete', async () => { const mag = await repo.create({ publication: 'Picsou Magazine', issueNumber: 547, @@ -401,21 +290,6 @@ describe('magazineRepository.findById', () => { notes: 'Mention bimestriel', }); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c1', - mag.id, - '2026-09-01T10:00:00Z', - ); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c2', - mag.id, - '2026-09-02T10:00:00Z', - ); - const detail = await repo.findById(mag.id); expect(detail).toMatchObject({ @@ -429,29 +303,33 @@ describe('magazineRepository.findById', () => { notes: 'Mention bimestriel', ocrText: null, }); - expect(detail?.copies).toHaveLength(2); - expect(detail?.copies[0]).toEqual({ - id: 'c2', - magazineId: mag.id, - notes: null, - dateAdded: '2026-09-02T10:00:00Z', - }); - expect(detail?.copies[1]).toMatchObject({ id: 'c1', notes: null }); }); - it('renvoie une liste de copies vide pour une edition non possedee', async () => { - const mag = await repo.create({ publication: 'Mickey Parade', issueNumber: 2 }); + it('renvoie null pour un id inconnu', async () => { + const detail = await repo.findById('nimporte'); - const detail = await repo.findById(mag.id); + expect(detail).toBeNull(); + }); +}); - expect(detail).not.toBeNull(); - expect(detail?.copies).toEqual([]); +describe('magazineRepository.findRecent', () => { + it('retourne les dernieres editions ajoutees (ordre croissant d ajout)', async () => { + await repo.create({ publication: 'Picsou Magazine', issueNumber: 100 }); + await repo.create({ publication: 'Mickey Parade', issueNumber: 2 }); + await repo.create({ publication: 'Super Picsou Géant', issueNumber: 30 }); + + const recent = await repo.findRecent(2); + + expect(recent.map((m) => m.publication)).toEqual(['Super Picsou Géant', 'Mickey Parade']); }); +}); - it('renvoie null pour un id inconnu', async () => { - const detail = await repo.findById('nimporte'); +describe('magazineRepository.countAll', () => { + it('compte toutes les editions', async () => { + await repo.create({ publication: 'Picsou Magazine', issueNumber: 547 }); + await repo.create({ publication: 'Mickey Parade', issueNumber: 2 }); - expect(detail).toBeNull(); + expect(await repo.countAll()).toBe(2); }); }); @@ -541,29 +419,12 @@ describe('magazineRepository.update', () => { }); describe('magazineRepository.delete', () => { - it('supprime l edition et ses exemplaires en cascade', async () => { + it('supprime l edition', async () => { const mag = await repo.create({ publication: 'Picsou Magazine', issueNumber: 547 }); - await testDb.runAsync( - `INSERT INTO collection_items (id, magazine_id, notes, date_added) - VALUES (?, ?, NULL, ?)`, - 'c1', - mag.id, - '2026-09-01T10:00:00Z', - ); - const countBefore = await testDb.getFirstAsync<{ n: number }>( - 'SELECT COUNT(*) AS n FROM collection_items WHERE magazine_id = ?', - mag.id, - ); - expect(countBefore?.n).toBe(1); await repo.delete(mag.id); const magazine = await repo.findById(mag.id); expect(magazine).toBeNull(); - const countAfter = await testDb.getFirstAsync<{ n: number }>( - 'SELECT COUNT(*) AS n FROM collection_items WHERE magazine_id = ?', - mag.id, - ); - expect(countAfter?.n).toBe(0); }); }); diff --git a/src/database/repositories/__tests__/settings-repository.test.ts b/src/database/repositories/__tests__/settings-repository.test.ts index b3dda7a..7ebd788 100644 --- a/src/database/repositories/__tests__/settings-repository.test.ts +++ b/src/database/repositories/__tests__/settings-repository.test.ts @@ -93,3 +93,25 @@ describe('settingsRepository reduced motion (M10R2-06)', () => { expect(rows).toHaveLength(1); }); }); + +describe('settingsRepository debug OCR (paramètres avancés)', () => { + it('retourne false par défaut avant activation', async () => { + await expect(repo.getOcrDebug()).resolves.toBe(false); + }); + + it('persiste l’activation', async () => { + await repo.setOcrDebug(true); + await expect(repo.getOcrDebug()).resolves.toBe(true); + }); + + it('peut désactiver le debug OCR', async () => { + await repo.setOcrDebug(true); + await repo.setOcrDebug(false); + await expect(repo.getOcrDebug()).resolves.toBe(false); + + const rows = await testDb.getAllAsync<{ key: string }>( + "SELECT key FROM settings WHERE key = 'ocr_debug'", + ); + expect(rows).toHaveLength(1); + }); +}); diff --git a/src/identification/__tests__/identificationService.test.ts b/src/identification/__tests__/identificationService.test.ts index f0769d5..c8a5433 100644 --- a/src/identification/__tests__/identificationService.test.ts +++ b/src/identification/__tests__/identificationService.test.ts @@ -22,8 +22,8 @@ function makeMagazine(overrides: Partial = {}): Magazine { }; } -function asListItem(magazine: Magazine, quantity = 0): MagazineListItem { - return { ...magazine, quantity }; +function asListItem(magazine: Magazine): MagazineListItem { + return { ...magazine }; } function makeRepository( diff --git a/src/lib/__tests__/collection-list.test.ts b/src/lib/__tests__/collection-list.test.ts index f8d97b3..76061f2 100644 --- a/src/lib/__tests__/collection-list.test.ts +++ b/src/lib/__tests__/collection-list.test.ts @@ -21,7 +21,6 @@ const m = ( ocrText: null, createdAt: over.createdAt ?? '', updatedAt: '', - quantity: over.quantity ?? 0, }); describe('sortMagazines', () => { diff --git a/src/store/__tests__/use-backup-store.test.ts b/src/store/__tests__/use-backup-store.test.ts index 83d4dd5..02e6ea9 100644 --- a/src/store/__tests__/use-backup-store.test.ts +++ b/src/store/__tests__/use-backup-store.test.ts @@ -1,6 +1,5 @@ import { createTestDatabase } from '@/test-utils/test-db'; import { migrate } from '@/database/migrations'; -import { CollectionRepository } from '@/database/repositories/collection-repository'; import { MagazineRepository } from '@/database/repositories/magazine-repository'; import { BackupService, InvalidBackupError } from '@/backup/backup-service'; import type { FileGateway } from '@/backup/file-gateway'; @@ -31,7 +30,6 @@ const fakeGateway: FileGateway = { function buildDeps(): Dependencies { return { magazineRepository: new MagazineRepository(testDb), - collectionRepository: new CollectionRepository(testDb), settingsRepository: {} as Dependencies['settingsRepository'], identificationService: {} as Dependencies['identificationService'], ocrEngine: { recognize: jest.fn() } as unknown as Dependencies['ocrEngine'], @@ -42,12 +40,10 @@ function buildDeps(): Dependencies { async function seed(): Promise { const magazineRepo = new MagazineRepository(testDb); - const collectionRepo = new CollectionRepository(testDb); const magazine = await magazineRepo.create({ publication: 'Picsou Magazine', issueNumber: 547, }); - await collectionRepo.addCopy(magazine.id, { notes: 'OK' }); return magazine.id; } @@ -108,7 +104,7 @@ describe('useBackupStore.exportCollection', () => { const [csv, format] = writeExport.mock.calls[0]; expect(format).toBe('csv'); expect(csv).toMatch( - /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt\n/, + /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,createdAt,updatedAt\n/, ); expect(useBackupStore.getState().lastExport?.name).toBe('picsou-collection-2026-09-01.csv'); }); @@ -149,7 +145,7 @@ describe('useBackupStore.pickAndValidate', () => { const preview = await useBackupStore.getState().pickAndValidate('json'); - expect(preview).toEqual({ magazines: 1, copies: 1 }); + expect(preview).toEqual({ magazines: 1 }); expect(useBackupStore.getState().pendingRaw).toBeTruthy(); expect(useBackupStore.getState().pendingFormat).toBe('json'); }); @@ -164,7 +160,7 @@ describe('useBackupStore.pickAndValidate', () => { const preview = await useBackupStore.getState().pickAndValidate('csv'); - expect(preview).toEqual({ magazines: 1, copies: 1 }); + expect(preview).toEqual({ magazines: 1 }); expect(useBackupStore.getState().pendingRaw).toBeTruthy(); expect(useBackupStore.getState().pendingFormat).toBe('csv'); }); @@ -235,7 +231,7 @@ describe('useBackupStore.applyPendingImport', () => { await useBackupStore.getState().pickAndValidate('json'); const summary = await useBackupStore.getState().applyPendingImport(); - expect(summary).toEqual({ magazines: 1, copies: 1 }); + expect(summary).toEqual({ magazines: 1 }); const after = await service.exportCollection(); expect(after.magazines[0].publication).toBe('Imported'); expect(useBackupStore.getState().pendingRaw).toBeNull(); @@ -252,7 +248,7 @@ describe('useBackupStore.applyPendingImport', () => { await useBackupStore.getState().pickAndValidate('csv'); const summary = await useBackupStore.getState().applyPendingImport(); - expect(summary).toEqual({ magazines: 1, copies: 1 }); + expect(summary).toEqual({ magazines: 1 }); const after = await service.exportCollection(); expect(after.magazines[0].publication).toBe('Csv Imported'); }); diff --git a/src/store/__tests__/use-collection-store.test.ts b/src/store/__tests__/use-collection-store.test.ts index 77d7990..eface39 100644 --- a/src/store/__tests__/use-collection-store.test.ts +++ b/src/store/__tests__/use-collection-store.test.ts @@ -3,32 +3,24 @@ import type { MagazineListItem } from '@/types'; const mockList = jest.fn(); const mockCreate = jest.fn(); -const mockCreateWithCopy = jest.fn(); const mockDelete = jest.fn(); const mockFindById = jest.fn(); const mockUpdate = jest.fn(); +const mockFindRecent = jest.fn(); +const mockCountAll = jest.fn(); const mockMagazineRepo = { list: mockList, create: mockCreate, - createWithCopy: mockCreateWithCopy, delete: mockDelete, findById: mockFindById, update: mockUpdate, -}; - -const mockListRecentCopies = jest.fn(); -const mockCountAllCopies = jest.fn(); -const mockAddCopy = jest.fn(); -const mockCollectionRepo = { - listRecentCopies: mockListRecentCopies, - countAllCopies: mockCountAllCopies, - addCopy: mockAddCopy, + findRecent: mockFindRecent, + countAll: mockCountAll, }; jest.mock('@/dependencies', () => ({ getDeps: jest.fn(() => ({ magazineRepository: mockMagazineRepo, - collectionRepository: mockCollectionRepo, })), })); @@ -45,7 +37,6 @@ const magazine: MagazineListItem = { ocrText: null, createdAt: '2026-09-01T10:00:00.000Z', updatedAt: '2026-09-01T10:00:00.000Z', - quantity: 4, }; describe('useCollectionStore', () => { @@ -53,21 +44,24 @@ describe('useCollectionStore', () => { jest.clearAllMocks(); useCollectionStore.setState({ magazines: [], - totalCopies: 0, + recent: [], + detail: null, + totalMagazines: 0, loading: false, + detailLoading: false, error: null, loaded: false, }); }); - it('charge la collection et calcule le nombre total d exemplaires', async () => { + it('charge la collection et calcule le nombre total d editions', async () => { mockList.mockResolvedValue([magazine]); await useCollectionStore.getState().load(); const state = useCollectionStore.getState(); expect(state.magazines).toHaveLength(1); - expect(state.totalCopies).toBe(4); + expect(state.totalMagazines).toBe(1); expect(state.loaded).toBe(true); expect(state.loading).toBe(false); }); @@ -83,9 +77,8 @@ describe('useCollectionStore', () => { expect(state.loaded).toBe(false); }); - it('ajoute une edition et un exemplaire dans une transaction, puis incremente le compteur', async () => { - const created = { ...magazine, quantity: 1 }; - mockCreateWithCopy.mockResolvedValue({ magazine: created, copyId: 'c1' }); + it('ajoute une edition et incremente le compteur', async () => { + mockCreate.mockResolvedValue(magazine); await useCollectionStore .getState() @@ -93,52 +86,53 @@ describe('useCollectionStore', () => { const state = useCollectionStore.getState(); expect(state.magazines).toHaveLength(1); - expect(state.totalCopies).toBe(1); - expect(mockCreateWithCopy).toHaveBeenCalledWith({ + expect(state.totalMagazines).toBe(1); + expect(mockCreate).toHaveBeenCalledWith({ publication: 'Picsou Magazine', issueNumber: 547, }); }); it('supprime une edition et decremente le compteur', async () => { - useCollectionStore.setState({ magazines: [magazine], totalCopies: 4 }); + useCollectionStore.setState({ magazines: [magazine], totalMagazines: 4 }); mockDelete.mockResolvedValue(undefined); await useCollectionStore.getState().removeMagazine('mag-1'); const state = useCollectionStore.getState(); expect(state.magazines).toHaveLength(0); - expect(state.totalCopies).toBe(0); + expect(state.totalMagazines).toBe(3); + }); + + it('ne change pas le compteur si l’edition à supprimer est inconnue du store', async () => { + mockDelete.mockResolvedValue(undefined); + useCollectionStore.setState({ magazines: [magazine], totalMagazines: 4 }); + + await useCollectionStore.getState().removeMagazine('inconnue'); + + const state = useCollectionStore.getState(); + expect(state.magazines).toHaveLength(1); + expect(state.totalMagazines).toBe(4); }); it('charge un resume leger (compteur + recents) sans lister les editions', async () => { - mockCountAllCopies.mockResolvedValue(7); - mockListRecentCopies.mockResolvedValue([ - { - copy: { - id: 'c1', - magazineId: 'mag-1', - notes: null, - dateAdded: '2026-09-01T10:00:00Z', - }, - magazine: { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: 547 }, - }, - ]); + mockCountAll.mockResolvedValue(7); + mockFindRecent.mockResolvedValue([magazine]); await useCollectionStore.getState().loadSummary(); const state = useCollectionStore.getState(); - expect(state.totalCopies).toBe(7); - expect(state.recentCopies).toHaveLength(1); + expect(state.totalMagazines).toBe(7); + expect(state.recent).toHaveLength(1); expect(state.loaded).toBe(true); expect(state.loading).toBe(false); expect(mockList).not.toHaveBeenCalled(); - expect(mockListRecentCopies).toHaveBeenCalledWith(5); - expect(mockCountAllCopies).toHaveBeenCalledTimes(1); + expect(mockFindRecent).toHaveBeenCalledWith(5); + expect(mockCountAll).toHaveBeenCalledTimes(1); }); it('signale un echec de chargement du resume', async () => { - mockCountAllCopies.mockRejectedValue(new Error('base indisponible')); + mockCountAll.mockRejectedValue(new Error('base indisponible')); await useCollectionStore.getState().loadSummary(); @@ -148,8 +142,16 @@ describe('useCollectionStore', () => { expect(state.loaded).toBe(false); }); - it('charge le detail d une edition avec ses copies', async () => { - mockFindById.mockResolvedValue({ ...magazine, copies: [] }); + it('traduit une erreur inconnue lors du chargement du resume', async () => { + mockCountAll.mockRejectedValue('panne'); + + await useCollectionStore.getState().loadSummary(); + + expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); + }); + + it('charge le detail d une edition', async () => { + mockFindById.mockResolvedValue(magazine); const detail = await useCollectionStore.getState().loadDetail('mag-1'); @@ -169,11 +171,20 @@ describe('useCollectionStore', () => { expect(useCollectionStore.getState().detailLoading).toBe(false); }); + it('traduit une erreur inconnue lors du chargement du detail', async () => { + mockFindById.mockRejectedValue('panne'); + + const detail = await useCollectionStore.getState().loadDetail('mag-1'); + + expect(detail).toBeNull(); + expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); + }); + it('modifie une edition et rafraichit la liste et le detail', async () => { mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); useCollectionStore.setState({ magazines: [magazine], - detail: { ...magazine, copies: [] }, + detail: { ...magazine }, }); await useCollectionStore.getState().updateMagazine('mag-1', { @@ -183,7 +194,6 @@ describe('useCollectionStore', () => { const state = useCollectionStore.getState(); expect(mockUpdate).toHaveBeenCalledWith('mag-1', { publication: 'Mickey Parade' }); expect(state.magazines[0].publication).toBe('Mickey Parade'); - expect(state.magazines[0].quantity).toBe(4); expect(state.detail?.publication).toBe('Mickey Parade'); }); @@ -195,41 +205,49 @@ describe('useCollectionStore', () => { ).rejects.toThrow('Édition introuvable.'); }); - it('ajoute un exemplaire a une edition existante et incremente le compteur', async () => { - mockAddCopy.mockResolvedValue({ - id: 'c2', - magazineId: 'mag-1', - notes: null, - dateAdded: '2026-09-02T10:00:00Z', + it('conserve le detail d’une autre edition lors d’une modification', async () => { + mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); + useCollectionStore.setState({ + magazines: [magazine], + detail: { ...magazine, id: 'mag-9' }, + }); + + await useCollectionStore.getState().updateMagazine('mag-1', { publication: 'Mickey Parade' }); + + expect(useCollectionStore.getState().detail?.id).toBe('mag-9'); + }); + + it('met a jour aussi les recentes lors d’une modification', async () => { + mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); + useCollectionStore.setState({ + magazines: [magazine], + recent: [{ ...magazine }], + detail: { ...magazine }, }); - useCollectionStore.setState({ magazines: [magazine], totalCopies: 4 }); - await useCollectionStore.getState().addExistingCopy('mag-1'); + await useCollectionStore.getState().updateMagazine('mag-1', { publication: 'Mickey Parade' }); const state = useCollectionStore.getState(); - expect(mockAddCopy).toHaveBeenCalledWith('mag-1'); - expect(state.magazines[0].quantity).toBe(5); - expect(state.totalCopies).toBe(5); + expect(state.recent[0].publication).toBe('Mickey Parade'); + expect(state.detail?.publication).toBe('Mickey Parade'); }); - it('rafraichit le detail avec la copie ajoutee si elle correspond', async () => { - const copy = { - id: 'c2', - magazineId: 'mag-1', - notes: null, - dateAdded: '2026-09-02T10:00:00Z', - }; - mockAddCopy.mockResolvedValue(copy); + it('ne modifie que le detail quand l’edition est absente de la liste', async () => { + mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); useCollectionStore.setState({ magazines: [magazine], - totalCopies: 4, - detail: { ...magazine, copies: [] }, + detail: { ...magazine, id: 'mag-2' }, }); - await useCollectionStore.getState().addExistingCopy('mag-1'); + await useCollectionStore.getState().updateMagazine('mag-2', { + publication: 'Mickey Parade', + }); const state = useCollectionStore.getState(); - expect(state.detail?.copies).toEqual([copy]); + expect(state.magazines).toHaveLength(1); + expect(state.magazines[0].id).toBe('mag-1'); + expect(state.magazines[0].publication).toBe(magazine.publication); + expect(state.detail?.publication).toBe('Mickey Parade'); }); it('traduit une erreur inconnue en message générique', async () => { @@ -241,84 +259,38 @@ describe('useCollectionStore', () => { }); it('relance l’erreur quand l’ajout d’une edition échoue', async () => { - mockCreateWithCopy.mockRejectedValue(new Error('Code-barres déjà enregistré.')); + mockCreate.mockRejectedValue(new Error('Code-barres déjà enregistré.')); await expect( useCollectionStore.getState().addMagazine({ publication: 'Picsou Magazine' }), ).rejects.toThrow('Code-barres déjà enregistré.'); }); - it('incrémente le compteur sans toucher aux autres editions ni au detail', async () => { - mockAddCopy.mockResolvedValue({ id: 'c3', magazineId: 'mag-x', notes: null, dateAdded: 'x' }); - useCollectionStore.setState({ - magazines: [magazine], - totalCopies: 4, - detail: { ...magazine, copies: [] }, - }); - - await useCollectionStore.getState().addExistingCopy('mag-x'); - - const state = useCollectionStore.getState(); - expect(state.magazines[0].quantity).toBe(4); - expect(state.totalCopies).toBe(5); - expect(state.detail?.id).toBe('mag-1'); - }); - - it('conserve le detail d’une autre edition lors d’une modification', async () => { - mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); + it('supprime le detail quand il correspond a l edition retiree', async () => { + mockDelete.mockResolvedValue(undefined); useCollectionStore.setState({ magazines: [magazine], - detail: { ...magazine, copies: [], id: 'mag-9' }, + totalMagazines: 4, + detail: { ...magazine }, }); - await useCollectionStore.getState().updateMagazine('mag-1', { publication: 'Mickey Parade' }); + await useCollectionStore.getState().removeMagazine('mag-1'); - expect(useCollectionStore.getState().detail?.id).toBe('mag-9'); + expect(useCollectionStore.getState().detail).toBeNull(); }); - it('ne change pas le compteur si l’edition à supprimer est introuvable', async () => { + it('retire l’edition des recentes et decremente le compteur', async () => { mockDelete.mockResolvedValue(undefined); - useCollectionStore.setState({ magazines: [magazine], totalCopies: 4 }); - - await useCollectionStore.getState().removeMagazine('inconnue'); - - const state = useCollectionStore.getState(); - expect(state.magazines).toHaveLength(1); - expect(state.totalCopies).toBe(4); - }); - - it('traduit une erreur inconnue lors du chargement du resume', async () => { - mockCountAllCopies.mockRejectedValue('panne'); - - await useCollectionStore.getState().loadSummary(); - - expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); - }); - - it('traduit une erreur inconnue lors du chargement du detail', async () => { - mockFindById.mockRejectedValue('panne'); - - const detail = await useCollectionStore.getState().loadDetail('mag-1'); - - expect(detail).toBeNull(); - expect(useCollectionStore.getState().error).toBe('Erreur inconnue'); - }); - - it('ne modifie que le detail quand l’edition est absente de la liste', async () => { - mockUpdate.mockResolvedValue({ ...magazine, publication: 'Mickey Parade' }); useCollectionStore.setState({ magazines: [magazine], - detail: { ...magazine, copies: [], id: 'mag-2' }, + recent: [{ ...magazine }], + totalMagazines: 4, }); - await useCollectionStore.getState().updateMagazine('mag-2', { - publication: 'Mickey Parade', - }); + await useCollectionStore.getState().removeMagazine('mag-1'); const state = useCollectionStore.getState(); - expect(state.magazines).toHaveLength(1); - expect(state.magazines[0].id).toBe('mag-1'); - expect(state.magazines[0].publication).toBe(magazine.publication); - expect(state.detail?.publication).toBe('Mickey Parade'); + expect(state.recent).toHaveLength(0); + expect(state.totalMagazines).toBe(3); }); }); diff --git a/src/store/__tests__/use-settings-store.test.ts b/src/store/__tests__/use-settings-store.test.ts index d3f97ad..50352f5 100644 --- a/src/store/__tests__/use-settings-store.test.ts +++ b/src/store/__tests__/use-settings-store.test.ts @@ -7,11 +7,12 @@ const getOnboardingDoneMock = jest.fn().mockResolvedValue(false); const setOnboardingDoneMock = jest.fn().mockResolvedValue(undefined); const getReducedMotionMock = jest.fn().mockResolvedValue(false); const setReducedMotionMock = jest.fn().mockResolvedValue(undefined); +const getOcrDebugMock = jest.fn().mockResolvedValue(false); +const setOcrDebugMock = jest.fn().mockResolvedValue(undefined); function stubDeps(): Dependencies { return { magazineRepository: {} as Dependencies['magazineRepository'], - collectionRepository: {} as Dependencies['collectionRepository'], identificationService: {} as Dependencies['identificationService'], ocrEngine: { recognize: jest.fn() } as unknown as Dependencies['ocrEngine'], backupService: {} as Dependencies['backupService'], @@ -23,6 +24,8 @@ function stubDeps(): Dependencies { setOnboardingDone: setOnboardingDoneMock, getReducedMotion: getReducedMotionMock, setReducedMotion: setReducedMotionMock, + getOcrDebug: getOcrDebugMock, + setOcrDebug: setOcrDebugMock, } as unknown as Dependencies['settingsRepository'], }; } @@ -42,6 +45,10 @@ describe('useSettingsStore', () => { getReducedMotionMock.mockResolvedValue(false); setReducedMotionMock.mockClear(); setReducedMotionMock.mockResolvedValue(undefined); + getOcrDebugMock.mockClear(); + getOcrDebugMock.mockResolvedValue(false); + setOcrDebugMock.mockClear(); + setOcrDebugMock.mockResolvedValue(undefined); }); it('initialise le colorScheme sur system', () => { @@ -154,6 +161,29 @@ describe('useSettingsStore', () => { expect(useSettingsStore.getState().reducedMotion).toBe(true); }); + it('met à jour ocrDebug via setOcrDebug et persiste', () => { + setDepsForTest(stubDeps()); + + useSettingsStore.getState().setOcrDebug(true); + + expect(useSettingsStore.getState().ocrDebug).toBe(true); + expect(setOcrDebugMock).toHaveBeenCalledWith(true); + }); + + it('charge le flag ocrDebug via loadOcrDebug', async () => { + getOcrDebugMock.mockResolvedValue(true); + setDepsForTest(stubDeps()); + + await useSettingsStore.getState().loadOcrDebug(); + + expect(useSettingsStore.getState().ocrDebug).toBe(true); + }); + + it('met à jour ocrDebug sans dépendances initialisées', () => { + useSettingsStore.getState().setOcrDebug(true); + expect(useSettingsStore.getState().ocrDebug).toBe(true); + }); + afterEach(() => { __resetForTests(); }); From f7b78794c27b2c98c0e1903bac1dc3306cfc4632 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 09:41:54 +0200 Subject: [PATCH 21/34] chore(M-11): aligner expo et expo-router sur les patchs requis par le SDK 57 (expo-doctor 21/21) --- package-lock.json | 84 +++++++++++++++++++++++------------------------ package.json | 4 +-- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 558cd02..bb8ae7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "picsoucollection", - "version": "0.9.1", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picsoucollection", - "version": "0.9.1", + "version": "0.9.3", "dependencies": { "@expo/vector-icons": "^15.0.2", - "expo": "~57.0.20", + "expo": "~57.0.21", "expo-build-properties": "~57.0.17", "expo-camera": "~57.0.4", "expo-constants": "~57.0.16", @@ -19,7 +19,7 @@ "expo-font": "~57.0.3", "expo-linking": "~57.0.9", "expo-mlkit-ocr": "^0.2.7", - "expo-router": "~57.0.19", + "expo-router": "~57.0.20", "expo-sharing": "~57.0.18", "expo-splash-screen": "~57.0.8", "expo-sqlite": "~57.0.2", @@ -1911,9 +1911,9 @@ } }, "node_modules/@expo/metro-file-map": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.2.tgz", - "integrity": "sha512-tb50nSIWwKpRufSkGuivOK0FbUxv1Uwqptb0SzFTk0bkmYmcy3gIwnCbOHTfmrQDVndRhPEiu2SYgh9Z0PCvGg==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz", + "integrity": "sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -2066,9 +2066,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.16", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.16.tgz", - "integrity": "sha512-3MK74dEPE4mwlv0VbyQKepmHigOs2bcOvay2SFqc1/QSywVTOjbkNPYUbq2H+LyOLP2sHMazrPBwfXGiYn3q7A==", + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.17.tgz", + "integrity": "sha512-cnenUTsfX78i/EfXhA81Zv48sOxRdIq4YWCggwgBiPoOyS8J5CP/6dGyKo26ZVtcIsogWR4aXywaripeRF+6Ng==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -2106,9 +2106,9 @@ } }, "node_modules/@expo/xcpretty": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", - "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz", + "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==", "license": "BSD-3-Clause", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -4976,9 +4976,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "57.0.10", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.10.tgz", - "integrity": "sha512-08bt6bqMZFoQuWO9gkt0EXotbqKHimELFGa1LlKJvY89iKsi4S0FJSFBFJ4pn9NZvofet48HIjYDQ0SZJ7bNrg==", + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.11.tgz", + "integrity": "sha512-R0NouDI3nzQUsjBh5TUJSDXLmuVzE2bp2YE0ywJkKxzrdWVjShpAlZMBuDeOQy6iKGB6ZCCvMqbQvpqgNMJ7iw==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -5027,7 +5027,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^57.0.16", + "expo-widgets": "^57.0.18", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -7192,13 +7192,13 @@ } }, "node_modules/expo": { - "version": "57.0.20", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.20.tgz", - "integrity": "sha512-H57GWLLpNPrltsrjvx6ROsMecoFQza7h9OzujUjBsaY+aDL+bj3GsZaH3T5v5B96R7txuidUwjUh5q5hi3qttg==", + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.21.tgz", + "integrity": "sha512-lQmC0kCJCleO+uLUwHXY0pLDzcvedKEsX+pmJp4mSxn3JlWDTZvUb0e5UIlV7+bqlN68LpAeKG4c2lrN4zuP0Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.22", + "@expo/cli": "^57.0.23", "@expo/config": "~57.0.9", "@expo/config-plugins": "~57.0.9", "@expo/devtools": "~57.0.1", @@ -7209,14 +7209,14 @@ "@expo/metro": "~56.0.2", "@expo/metro-config": "~57.0.12", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~57.0.10", + "babel-preset-expo": "~57.0.11", "expo-asset": "~57.0.16", "expo-constants": "~57.0.17", "expo-file-system": "~57.0.6", "expo-font": "~57.0.3", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.12", - "expo-modules-core": "~57.0.16", + "expo-modules-core": "~57.0.17", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -7389,9 +7389,9 @@ } }, "node_modules/expo-glass-effect": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.1.tgz", - "integrity": "sha512-m/n8maxqNcHk6ZDhuqXBfD5Kt1Iz3M8xykVgdB0iSCIXvF70IqWXmQhX8Psswhrp8eZ+3r0mAD0Jh/2gFA3QaA==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.2.tgz", + "integrity": "sha512-k2Dk0uCJrGYe8DWfvlMPmJ6w7uxCmJpoJckIcNDoUn27g75c5Kv3nUa88+T4+CpeVkixwY7iWrv46B5De20S9Q==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7468,13 +7468,13 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.16", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.16.tgz", - "integrity": "sha512-vPbxDOdvG6Ucpws5ucdbQa+nNmdxKONS1fTg92sy0p4TnHX8hBiExtbrtUA/5+1t3FRCUuiy29/1nJEGdt3E4A==", + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.17.tgz", + "integrity": "sha512-hHJwGHW0sMQiOLzEEl1QbJeWkBEBgjlPjrdBVGWlzvgUbCXJfFbDU2nhxoyzLMZnvHkpIzpiJjj5cM5W8gbudA==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", - "expo-modules-jsi": "~57.0.8", + "expo-modules-jsi": "~57.1.0", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7489,24 +7489,24 @@ } }, "node_modules/expo-modules-jsi": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.0.8.tgz", - "integrity": "sha512-MFwvKP0FXLkEET27KsKvOh/L7Dpu2d00UgzAlmKozJgtI23WTKSyKPxu+EOSs9peSgkHzKI0vQOhWjOseGBMkQ==", + "version": "57.1.0", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz", + "integrity": "sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/expo-router": { - "version": "57.0.19", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.19.tgz", - "integrity": "sha512-JovzZ066+qhykyJLC4hSEJpM8ToVWnJqbxmTN7USzARM0HgRR/g05a75f/EIsY4S//hbPGm6wGl/ObcCVtAX+A==", + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.20.tgz", + "integrity": "sha512-pBY0Ek5opUgpUSdoX3bAmyd8mkGSeTIOEdIDdpSG/02owrrwpnaQuSn/XuwOtizoeFedAD3VgS5DDVSyQTdI7g==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.4", "@expo/metro-runtime": "^57.0.15", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.16", + "@expo/ui": "^57.0.17", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -7514,7 +7514,7 @@ "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", - "expo-glass-effect": "^57.0.1", + "expo-glass-effect": "^57.0.2", "expo-server": "^57.0.3", "expo-symbols": "^57.0.2", "fast-deep-equal": "^3.1.3", @@ -7728,9 +7728,9 @@ "license": "MIT" }, "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.22", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.22.tgz", - "integrity": "sha512-MdToR0ZZ1yjfkqbP1jcA7viS9duMPGq/C+qQ/21dtq+en583Ghp+O11BP/wujHa9ZGRtXimzuKPZDpPresVE0g==", + "version": "57.0.23", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.23.tgz", + "integrity": "sha512-stzSYxVwbWGbKR+mrYT6s4Rt6HrcUxb0JrHyebDGL4ehpvfRTv0rMzn4Q7fqB6f5wJ1KCmf6OAgqgK+TTB8/FA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -7744,7 +7744,7 @@ "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", "@expo/metro-config": "~57.0.12", - "@expo/metro-file-map": "^57.0.2", + "@expo/metro-file-map": "^57.0.3", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", diff --git a/package.json b/package.json index b24246e..990422a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packageManager": "npm@10.9.4", "dependencies": { "@expo/vector-icons": "^15.0.2", - "expo": "~57.0.20", + "expo": "~57.0.21", "expo-build-properties": "~57.0.17", "expo-camera": "~57.0.4", "expo-constants": "~57.0.16", @@ -15,7 +15,7 @@ "expo-font": "~57.0.3", "expo-linking": "~57.0.9", "expo-mlkit-ocr": "^0.2.7", - "expo-router": "~57.0.19", + "expo-router": "~57.0.20", "expo-sharing": "~57.0.18", "expo-splash-screen": "~57.0.8", "expo-sqlite": "~57.0.2", From e1267f4f1483a46573d8becce38380e6503e6d87 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 10:11:42 +0200 Subject: [PATCH 22/34] =?UTF-8?q?docs(M-12):=20planifier=20l'OCR=20interac?= =?UTF-8?q?tif=20&=20fiabilisation=20=E2=80=94=20US-OCR-01..08=20(#205-#21?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/03-TECHNICAL-SPEC.md | 40 ++++++++++++ docs/04-FONCTIONAL-SPEC.md | 45 ++++++++++++++ docs/05-ARCHITECTURE.md | 20 +++++- docs/08-USER-STORIES.md | 121 +++++++++++++++++++++++++++++++++++-- docs/09-ISSUE.md | 22 ++++++- docs/11-ROADMAP.md | 41 ++++++++++++- docs/12-TESTING.md | 22 +++++++ 7 files changed, 299 insertions(+), 12 deletions(-) diff --git a/docs/03-TECHNICAL-SPEC.md b/docs/03-TECHNICAL-SPEC.md index 7a03910..e4cb4fe 100644 --- a/docs/03-TECHNICAL-SPEC.md +++ b/docs/03-TECHNICAL-SPEC.md @@ -137,6 +137,42 @@ Un **niveau de confiance** (0..1) est calculé. En cas de confiance insuffisante > **Note technique (M-05) :** le pipeline logique (parsing `ocrTextParser.ts`, confiance, rapprochement base `findByPublicationAndIssue`) est livré et **testé**, et dépend d'une interface `OcrEngine` injectée. Le moteur natif est **branché par défaut** (`MlKitOcrEngine`) via `expo-mlkit-ocr` (Google ML Kit Text Recognition, on-device, hors ligne) : `dependencies.initialize()` l'utilise, l'écran `/scan/camera` capture une photo via `expo-camera` (`takePictureAsync`) puis appelle `recognizeText(uri)`. L'import du module natif est **paresseux** pour ne pas bloquer la CI. `expo-build-properties` force le iOS `deploymentTarget` à 16.4 (exigence ML Kit). La reconnaissance a été **validée sur téléphone physique** (v0.5.0) ; hors bibliothèque native, `recognize` retourne `null` (repli `NoopOcrEngine`). +### 5.5 Flux OCR v2 interactif & fiabilisation (M-12 → v1.1.0) + +Évolution cible du pipeline (voir `04-FONCTIONAL-SPEC.md` §5.6, US-OCR-01..08) : + +``` +Photo (takePictureAsync) + → Prétraitement image (redimensionnement + contraste, éphémère) [M12-01] + → reconnaître : texte + bounding boxes (blocks/lines/elements) [M12-02] + → analyser des candidats par champ (titre, numéro/tome, année, …) + avec score de confiance (règles métier + positions spatiales) [M12-03] + → seuiller les propositions automatiques [M12-04] + → écran intermédiaire : overlay photo + zones cliquables + (conversion coordonnées image → écran) [M12-05] + → associer une zone à un champ / corriger rapidement [M12-06] + → intégrer à findByPublicationAndIssue / saisie pré-remplie [M12-07] + → fiabiliser sur jeu de couvertures réelles (mesure des erreurs) [M12-08] +``` + +**Décisions structurantes (à confirmer en implémentation, M12-02/03)** : +- **Données** : faire évoluer `OcrFrameResult` (actuellement `{ text }`) vers un + résultat avec zones `{ blocks, lines, elements }`, chacune avec sa + `boundingBox` (gap à vérifier dans `expo-mlkit-ocr` — un wrapper natif ou un + changement de module peut être nécessaire) ; +- **Prétraitement** : évaluer `expo-image-manipulator` (candidat, §9) pour le + redimensionnement/contraste ; traitement en mémoire, jamais enregistré (R14.2) ; +- **Analyse** : le classifier `ocrTextParser.ts` évolue vers un module de + **candidats** (une liste par champ) + règles de discrimination + (`192 PAGES`, `€8,50`, `2026`, `TOME 12` / `N° 125`) ; +- **Seuils** : un score de confiance ≥ seuil ⇒ proposition automatique ; scores + proches ⇒ validation utilisateur ; +- **Écran** : nouvelle route (`/scan/ocr-review`) ; conversion des coordonnées + OCR (référentiel photo, `W×H` capturé) vers l'écran (proportions, `resizeMode`) ; +- **Perf** (§8) : traitement borné hors bandeau UI, import paresseux conservé. + +> **Statut M-12 : à venir (backlog)** — issues M12-01..08 (#205-#212). + --- ## 6. Scan code-barres @@ -256,6 +292,10 @@ Priorités : ``` Plugins (`app.json`) : `["expo-mlkit-ocr", { "iosEngine": "auto" }]` et `["expo-build-properties", { "ios": { "deploymentTarget": "16.4" } }]`. +> **M-12 (à l'étude)** : `expo-image-manipulator` (prétraitement, M12-01) et — +> selon les données exposées par `expo-mlkit-ocr` — un wrapper natif pour les +> **bounding boxes** (M12-02). Non ajouté tant que les issues ne sont pas engagées. + --- ## Récapitulatif des décisions techniques diff --git a/docs/04-FONCTIONAL-SPEC.md b/docs/04-FONCTIONAL-SPEC.md index c4796b8..a9cdf5d 100644 --- a/docs/04-FONCTIONAL-SPEC.md +++ b/docs/04-FONCTIONAL-SPEC.md @@ -228,6 +228,50 @@ En cas de confiance insuffisante, les informations détectées (**publication, n Le message « confiance insuffisante » est donc toujours accompagné d'une possibilité de **valider / corriger**, en plus de réessayer. +### 5.6 Flux OCR v2 interactif & fiabilisation (M-12 → v1.1.0, US-OCR-01..08) + +Évolution des §5.1-5.5 pour rendre l'identification fiable quelle que soit la +couverture (magazines, BD, comics, textes stylisés). Workflow cible : + +``` +Photo (takePictureAsync) + │ + ▼ +Prétraitement (redimensionnement, contraste) ← US-OCR-01 + │ + ▼ +OCR local ML Kit : texte + bounding boxes ← US-OCR-02 + │ + ▼ +Analyse de candidats par champ +(titre, numéro/tome/issue, année/date, éditeur, + pages, prix…) avec score de confiance ← US-OCR-03 + │ + ├─ Confiance élevée ──► Proposition automatique + │ (ex. Titre ✓ VOGUE · Numéro ✓ 125 · Année ✓ 2026) ← US-OCR-04 + └─ Confiance faible / ambiguë ──► Sélection utilisateur + ▶ écran intermédiaire : photo + zones OCR cliquables + ▶ toucher la zone → assigner Titre / Numéro / Année ← US-OCR-05/06 + │ + ▼ +Valeurs validées → recherche (si suffisant) ← US-OCR-07 + └─ insuffisant / non trouvé → saisie manuelle pré-remplie +``` + +**Règles** : +- **prétraitement** appliqué avant la reconnaissance ; aucune image n'est enregistrée (R14.2) ; +- l'OCR fournit le texte **et** la position des zones ; l'overlay suit fidèlement la photo quel que soit l'écran (conversion des coordonnées) ; +- **règles métier** de discrimination : `192 PAGES` → pages (pas un numéro), `€8,50` → prix, `2026` → année, `TOME 12` / `N° 125` → numéro ; +- **proposition automatique** uniquement au-dessus d'un seuil de confiance ; à confiances proches, l'utilisateur valide ; +- toute proposition reste **corrigeable en 2-3 gestes** (autre zone ou saisie directe), jamais de ressaisie complète ; +- les champs non identifiés restent vides (pas de blocage) ; +- l'application **ne présente jamais** une identification comme certaine ; +- la fiabilisation est **mesurée** sur un jeu de couvertures réelles (jeu de données + taux d'erreurs, cf. `12-TESTING.md`). + +> **Statut M-12 : à venir (backlog)** — issues M12-01..08 (#205-#212), user +> stories US-OCR-01..08 (`08-USER-STORIES.md` §11). Voir aussi +> `03-TECHNICAL-SPEC.md` §5.5. + --- ## 6. Flux saisie manuelle @@ -512,6 +556,7 @@ L'application vérifie, **selon le format choisi dans la popup** : | Caméra / OCR | ✅ | | — Surcouche de scan ciblé | ✅ (v0.7.1, US-ID-08) | | — Validation / correction des infos détectées | ✅ (v0.7.1, US-ID-09) | +| — OCR v2 interactif & fiabilisation | 🚧 (v1.1.0, M-12 — §5.6, US-OCR-01..08) | | Saisie manuelle | ✅ | | Résultat Possédé / Absent | ✅ | | Gestion des doublons | ✅ | diff --git a/docs/05-ARCHITECTURE.md b/docs/05-ARCHITECTURE.md index c52fa05..c1c5bd6 100644 --- a/docs/05-ARCHITECTURE.md +++ b/docs/05-ARCHITECTURE.md @@ -116,7 +116,6 @@ src/ │ ├── types.ts # Types SQLite / rows │ └── repositories/ │ ├── magazine-repository.ts -│ ├── collection-repository.ts │ └── settings-repository.ts │ ├── identification/ @@ -124,11 +123,13 @@ src/ │ ├── scanBarcode.ts # Nettoyage / validation EAN-13, ISBN │ ├── identificationService.ts # Orchestration scan + recherche │ └── ocr/ -│ ├── ocrTypes.ts # Interface OcrEngine + OcrFrameResult +│ ├── ocrTypes.ts # Interface OcrEngine + OcrFrameResult (+ zones, M-12) │ ├── ocrTextParser.ts # Extraction publication / numéro / date │ ├── ocrTextStabilizer.ts # Vote multi-frames (2 lectures concordantes) │ ├── ocrEngine.ts # NoopOcrEngine (repli CI-safe) -│ └── mlKitOcrEngine.ts # Moteur natif Google ML Kit (par défaut) +│ ├── mlKitOcrEngine.ts # Moteur natif Google ML Kit (par défaut) +│ ├── ocrImagePreprocessor.ts # Prétraitement éphémère (M-12, M12-01) +│ └── ocrCandidateAnalyzer.ts # Candidats par champ + confiance (M-12, M12-03) │ ├── backup/ │ ├── backup-service.ts # Export / import JSON + CSV @@ -289,6 +290,19 @@ module natif est **paresseux** (dans `recognize`) : sur CI / hors Development Bu retourne `null` sans bloquer les tests. `NoopOcrEngine` reste disponible comme repli. La reconnaissance brute se valide sur téléphone physique (Development Build). +**Évolution M-12 — OCR interactif & fiabilisation (à venir, US-OCR-01..08)** : +- `OcrFrameResult` ({ text }) évolue vers un résultat avec **zones** (`blocks` / + `lines` / `elements` + `boundingBox`) — gap à confirmer dans `expo-mlkit-ocr` + (issue M12-02 #206) ; +- `ocrImagePreprocessor` (prétraitement éphémère : redimensionnement + contraste) + branché avant `recognize` (issue M12-01 #205) ; +- `ocrCandidateAnalyzer` produit des **candidats par champ** avec score de + confiance (règles métier : pages/prix/année/numéro) (issue M12-03 #207) ; +- nouvel écran `/scan/ocr-review` : overlay photo + zones cliquables + conversion + de coordonnées image→écran (issues M12-05 ../09) ; +- intégration des valeurs validées à `identificationService.searchByOcrFields` + (issue M12-07 #211). + **Qualité lecture (M10R2-09, US-UX-21)** — textes stylisés / encres faibles : - Capture **haute résolution** (`takePictureAsync({ quality: 1, skipProcessing: false })`) dans `camera.tsx` pour préserver les petites encres des titres dessinés ; diff --git a/docs/08-USER-STORIES.md b/docs/08-USER-STORIES.md index c995332..48754ab 100644 --- a/docs/08-USER-STORIES.md +++ b/docs/08-USER-STORIES.md @@ -18,7 +18,9 @@ 8. [Épique 6 — Qualité & publication](#8-épique-6--qualité--publication) 9. [Épique 7 — Paramètres](#9-épique-7--paramètres) 10. [Épique 8 — UI/UX M-10](#10-épique-8--uiux-m-10) -11. [Représentation graphique](#11-représentation-graphique) +11. [Épique 9 — OCR interactif & fiabilisation](#11-épique-9--ocr-interactif--fiabilisation) +12. [Représentation graphique](#12-représentation-graphique) +13. [Récapitulatif des user stories](#13-récapitulatif-des-user-stories) --- @@ -679,7 +681,117 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : --- -## 11. Représentation graphique +## 11. Épique 9 — OCR interactif & fiabilisation (M-12) + +> Évolution du flux OCR de l'Épique 4 : l'application lit la couverture, +> **analyse des candidats** (titre, numéro/tome, année, pages, prix…) avec un +> **niveau de confiance**, **propose automatiquement** les champs fiables et +> ne fait **intervenir Marc que pour les zones ambiguës**, sélectionnées en +> touchant directement la photo. Cible : après v1.0.0 (**M-12 → v1.1.0**, +> issue #205-#212). + +### US-OCR-01 — Préparer la photo avant la lecture (prétraitement) +> En tant que **Marc**, je veux **que la photo de la couverture soit retravaillée (redimensionnement, contraste) avant la reconnaissance** afin de **maximiser les chances de lecture, même sur une couverture stylisée**. + +**Critères d'acceptation** : +- un prétraitement (redimensionnement + contraste) est appliqué avant la reconnaissance ; +- aucune image n'est enregistrée (analyse éphémère, R14.2) ; +- la lecture est plus fiable qu'à l'écran nu sur les couvertures fortement stylisées. + +**Statut (M-12)** : backlog — [M12-01 (#205)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/205). + +**Priorité** : haute. + +### US-OCR-02 — Lire le texte ET sa position (bounding boxes) +> En tant que **Marc**, je veux **que l'application connaisse non seulement le texte de la couverture mais aussi où se trouve chaque texte sur la photo** afin de **pointer précisément l'information correcte** en cas d'ambiguïté. + +**Critères d'acceptation** : +- l'OCR retourne le texte **et** la position (bounding box) de chaque zone détectée ; +- les positions sont exprimées dans un référentiel exploitable par l'écran ; +- le moteur reste abstrait (`OcrEngine`) et la CI reste verte sans module natif. + +**Statut (M-12)** : backlog — [M12-02 (#206)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/206). + +**Priorité** : haute. + +### US-OCR-03 — Comprendre ce que dit la couverture (candidats + confiance) +> En tant que **Marc**, je veux **que l'application distingue le titre, le numéro/tome/issue, l'année, voire le prix ou le nombre de pages** afin de **ne pas confondre « 125 » (numéro) avec « 192 PAGES », « €8,50 » ou « 2026 »**. + +**Critères d'acceptation** : +- pour chaque champ (titre, numéro/tome, année, éditeur…) une liste de **candidats** est produite avec un **niveau de confiance** ; +- les règles métier discriminent : `192 PAGES` ≠ numéro, `€8,50` = prix, `2026` = année, `TOME 12` / `N° 125` = numéro ; +- le module est testable sans dépendance matérielle. + +**Statut (M-12)** : backlog — [M12-03 (#207)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/207). + +**Priorité** : haute. + +### US-OCR-04 — Proposer automatiquement les champs fiables +> En tant que **Marc**, je veux **que l'application pré-remplisse les champs dont elle est sûre (ex. `Titre ✓ VOGUE`, `Numéro ✓ 125`, `Année ✓ 2026`)** afin de **valider d'un coup d'œil sans tout relire**. + +**Critères d'acceptation** : +- un seuil de confiance déclenche la proposition automatique d'un champ ; +- en cas de candidats à confiance proche, **aucune** proposition automatique (validation utilisateur requise) ; +- les propositions restent **toujours** modifiables. + +**Statut (M-12)** : backlog — [M12-04 (#208)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/208). + +**Priorité** : moyenne. + +### US-OCR-05 — Choisir la bonne zone sur la photo +> En tant que **Marc**, je veux **voir la photo avec les zones de texte détectées et toucher directement la bonne zone** afin de **dire à l'application où se trouve l'information** quand un champ est ambigu ou multiple. + +**Critères d'acceptation** : +- après la capture, un écran intermédiaire affiche la photo + les zones OCR en overlay ; +- les zones suivent fidèlement la photo quel que soit l'écran (conversion des coordonnées) ; +- chaque zone est **cliquable** ; +- aucune image n'est enregistrée. + +**Statut (M-12)** : backlog — [M12-05 (#209)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/209). + +**Priorité** : haute. + +### US-OCR-06 — Sélectionner une zone pour un champ et corriger vite +> En tant que **Marc**, je veux **associer une zone au titre, au numéro/tome ou à l'année et remplacer rapidement une proposition** afin de **corriger sans ressaisir tout le formulaire**. + +**Critères d'acceptation** : +- une zone peut être sélectionnée comme **titre**, **numéro/tome/issue** ou **année/date** ; +- une proposition automatique peut être **remplacée** par une autre zone ou une saisie directe ; +- les champs non identifiés restent vides (jamais de blocage) ; +- la correction prend au plus 2-3 gestes par champ. + +**Statut (M-12)** : backlog — [M12-06 (#210)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/210). + +**Priorité** : haute. + +### US-OCR-07 — Rechercher / ajouter avec les valeurs validées +> En tant que **Marc**, je veux **rechercher avec le titre+numéro validés ou ajouter même si une information manque** afin de **conclure en brocante sans refaire une saisie complète**. + +**Critères d'acceptation** : +- les valeurs validées déclenchent la recherche → résultat Possédé / Absent ; +- information absente → saisie manuelle **pré-remplie** avec ce qui est connu (pas de blocage) ; +- plusieurs candidats possibles → l'utilisateur choisit (jamais de choix automatique hasardeux). + +**Statut (M-12)** : backlog — [M12-07 (#211)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/211). + +**Priorité** : moyenne. + +### US-OCR-08 — Fiabiliser sur de vraies couvertures (tests terrain + règles) +> En tant que **Marc**, je veux **que l'OCR soit renforcé sur les magazines, BD, comics et couvertures stylisées, nombreux nombres ou textes inclinés** afin de **limiter les erreurs et corrections manuelles**. + +**Critères d'acceptation** : +- un **jeu de test** de couvertures réelles est constitué (magazines, BD/comics, stylisées, textes inclinés, « plusieurs nombres ») ; +- les **erreurs OCR** et les **erreurs de classification des champs** sont mesurées et consignées ; +- les règles métier (US-OCR-03) et les seuils (US-OCR-04) sont ajustés à partir des mesures ; +- la synthèse est documentée (`docs/12-TESTING.md`). + +**Statut (M-12)** : backlog — [M12-08 (#212)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/212). + +**Priorité** : moyenne. + +--- + +## 12. Représentation graphique ``` ┌─────────────────── ÉPIQUES ───────────────────┐ @@ -699,7 +811,7 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : --- -## Récapitulatif des user stories +## 13. Récapitulatif des user stories | Épique | Stories | Nb | |---|---|---| @@ -714,4 +826,5 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : | UI/UX M-10R (retours terrain) | US-UX-07 à 12 | 6 | | UI/UX M-10R2 (2ᵉ passe retours) | US-UX-13 à 22 | 10 | | UI/UX M-09 (tests terrain) | US-UX-23 | 1 | -| **Total** | | **61** | +| OCR interactif & fiabilisation (M-12) | US-OCR-01 à 08 | 8 | +| **Total** | | **69** | diff --git a/docs/09-ISSUE.md b/docs/09-ISSUE.md index 5a2f7a8..2291adf 100644 --- a/docs/09-ISSUE.md +++ b/docs/09-ISSUE.md @@ -212,6 +212,7 @@ Les milestones correspondent aux **phases de la roadmap** (`11-ROADMAP.md`). | `M-10R2` | 2ᵉ passe retours test physique | SafeZone drawer, permission caméra + onboarding 1er lancement, popup résultat, form valider header, paramètres sous-menus, header fiche, tri collection, OCR numéro, écran Recherche → v0.9.2 (livrée, PR #176) | | `M-09` | Tests terrain & publication | Validation réelle, retours physique → v0.9.3 (M09-01 panneau recherche #177) | | `M-11` | Review & Qualité v1.0.0 | Audit doc ↔ code, revue, refactor, tests, retrait Play Store, publication APK GitHub → v1.0.0 (#181-#190) | +| `M-12` | OCR interactif & fiabilisation (v1.1.0) | Prétraitement de la photo, OCR texte + bounding boxes, analyse de candidats par champ (titre, numéro/tome, année, pages, prix) avec confiance, propositions automatiques, sélection interactive des zones, correction rapide, intégration à la recherche, jeu de test réel → **après v1.0.0** (#205-#212) | ### Règle d'attribution - Chaque milestone a une **date de fin cible** (indicative) et une **description** ; @@ -292,6 +293,23 @@ M-10/M-10R/M-10R2 terminées, correction issues ouvertes pendant la Phase 9 |---|---|---|---| | M09-01 | Collection : panneau de recherche repliable (bouton Rechercher) | #177 | En cours (PR #178) | +**Milestone M-12 « OCR interactif & fiabilisation » (v1.1.0)** — après v1.0.0 · +milestone GitHub #17 · issues **#205 à #212** · ajoutées au project #5 (To Do, P1) + +| Issue | Titre | GitHub | Statut | +|---|---|---|---| +| M12-01 | OCR : prétraitement de la photo avant reconnaissance | #205 | À faire | +| M12-02 | OCR : récupérer texte + bounding boxes (ML Kit) | #206 | À faire | +| M12-03 | OCR : analyse de candidats par champ + score de confiance | #207 | À faire | +| M12-04 | OCR : propositions automatiques et seuil de confiance | #208 | À faire | +| M12-05 | OCR : écran interactif — overlay photo + zones cliquables | #209 | À faire | +| M12-06 | OCR : sélection d'une zone pour un champ + correction rapide | #210 | À faire | +| M12-07 | OCR : brancher les valeurs validées à la recherche / l'ajout | #211 | À faire | +| M12-08 | OCR : jeu de test réel, mesure des erreurs, ajustement des règles | #212 | À faire | + +Voir `08-USER-STORIES.md` §11 (US-OCR-01..08). Backlog : non engagé tant que +**M-11 / v1.0.0** n'est pas clôturé. + ### 6.2 Décisions de conception et causes racines (retours terrain) Les retours test physique ont produit des choix structurants conservés ici. @@ -539,7 +557,7 @@ Utiliser les **workflows natifs de GitHub Projects** pour déplacer automatiquem À l'initialisation du dépôt GitHub, configurer : - [ ] **Labels** : types, priorités, épiques, complexité (section 5) ; -- [ ] **Milestones** : M-01 à M-11 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (section 6) ; +- [ ] **Milestones** : M-01 à M-12 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (section 6) ; - [ ] **Project** : board Kanban "Picsou Collection" (section 10) ; - [ ] **Branch protection** sur `main` et `develop` (section 7) ; - [ ] **Templates d'issue** (bug + tâche) via `.github/ISSUE_TEMPLATE/` ; @@ -557,6 +575,6 @@ Utiliser les **workflows natifs de GitHub Projects** pour déplacer automatiquem | Outil de suivi | GitHub Projects (Kanban) | | Découpage | User story → issues → tasks | | Labels | types, priorités, épiques, complexité, statuts | -| Milestones | M-01 à M-11 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (phases roadmap) | +| Milestones | M-01 à M-12 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (phases roadmap) | | Versionnage | Semantic Versioning | | PR | Template + CI + revue + DoD | diff --git a/docs/11-ROADMAP.md b/docs/11-ROADMAP.md index 7c6d0e5..8163155 100644 --- a/docs/11-ROADMAP.md +++ b/docs/11-ROADMAP.md @@ -25,7 +25,10 @@ 15. [Phase 10R — Retours test physique M-10](#15-phase-10r--retours-test-physique-m-10) 16. [Phase 10R2 — 2ᵉ passe retours test physique](#16-phase-10r2--2ᵉ-passe-retours-test-physique) 17. [Phase 9 — Tests terrain & publication](#17-phase-9--tests-terrain--publication) -18. [Critères de sortie de chaque phase](#18-critères-de-sortie-de-chaque-phase) +18. [Phase 11 — Review & Qualité v1.0.0 (M-11)](#18-phase-11--review--qualité-v100-m-11) +19. [Phase 12 — OCR interactif & fiabilisation (M-12 → v1.1.0)](#19-phase-12--ocr-interactif--fiabilisation-m-12--v110) +20. [Critères de sortie de chaque phase](#critères-de-sortie-de-chaque-phase) +21. [Récapitulatif](#récapitulatif) --- @@ -58,6 +61,7 @@ Phase 10R2 ███████████████████████ Phase 10R3 ██████████████████████████████ Terminé ✓ (v0.9.3) Phase 9 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ À venir (M-09 → v1.0.0) Phase 11 ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░ En cours (M-11 → v1.0.0) +Phase 12 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ À venir (M-12 → v1.1.0) ``` ### 2.1 Correspondance phase ↔ milestone ↔ issues @@ -83,7 +87,8 @@ Chaque phase de la roadmap correspond à un **milestone GitHub** (`M-0x`) et à | 10R3 — Retours test v0.9.3 | `M-09 — Tests terrain & publication` | `US-UX-23` (#177), retours physiques v0.9.3 | 1 | | 9 — Tests & publication | `M-09 — Tests terrain & publication` | `US-QA-03` | 1 | | 11 — Review & Qualité | `M-11 — Review & Qualité v1.0.0` | issues M11-01..10 (#181-#190) | 10 | -| **Total** | | | **114** | +| 12 — OCR interactif | `M-12 — OCR interactif & fiabilisation (v1.1.0)` | `US-OCR-01..08` (issues M12-01..08, #205-#212) | 8 | +| **Total** | | | **122** | --- @@ -472,6 +477,35 @@ Détail : [09-ISSUE.md §6 – Milestones](09-ISSUE.md#6-milestones). --- +## 19. Phase 12 — OCR interactif & fiabilisation (M-12 → v1.1.0) + +**Objectif** : rendre le flux OCR **fiable en brocante** pour tous les supports +(magazines, BD, comics, couvertures stylisées). L'application fait le maximum +automatiquement — prétraitement de la photo, OCR texte + positions, analyse de +**candidats par champ** (titre, numéro/tome, année, pages, prix…) avec +**niveau de confiance**, **propositions automatiques** au-dessus d'un seuil — +et **n'implique Marc que pour les zones ambiguës**, qu'il sélectionne en +touchant la photo. Démarrage **après la v1.0.0** (M-11). + +| Tâche | Issue | Statut | +|---|---|---| +| Prétraitement de la photo avant reconnaissance (redimensionnement, contraste) | M12-01 (#205) | À faire | +| Récupérer texte + bounding boxes (ML Kit) | M12-02 (#206) | À faire | +| Analyse de candidats par champ + score de confiance (règles métier) | M12-03 (#207) | À faire | +| Propositions automatiques et seuil de confiance | M12-04 (#208) | À faire | +| Écran interactif : overlay photo + zones cliquables | M12-05 (#209) | À faire | +| Sélection d'une zone pour un champ + correction rapide | M12-06 (#210) | À faire | +| Brancher les valeurs validées à la recherche / l'ajout | M12-07 (#211) | À faire | +| Jeu de test réel, mesure des erreurs, ajustement des règles/seuils | M12-08 (#212) | À faire | + +**Livrables** : 8 issues M12-01..08 (#205-#212) + 8 user stories US-OCR-01..08 +(`08-USER-STORIES.md` §11). Définition fonctionnelle : `04-FONCTIONAL-SPEC.md` +§5.6 ; technique : `03-TECHNICAL-SPEC.md` §5.5. + +**Statut : À venir** — backlog ; ne démarre qu'à la fermeture du **M-11**. + +--- + ## 20. Critères de sortie de chaque phase Pour chaque phase, les critères de sortie sont : @@ -508,4 +542,5 @@ Pour chaque phase, les critères de sortie sont : | 10R3 — Retours test v0.9.3 | Recherche repliable, drawer scroll, renommage | US-UX-23 (M09-01) | 1 | | 9 — Publication | APK GitHub (release v1.0.0) | US-QA-03 | 1 | | 11 — Review & Qualité | Préparation v1.0.0 | M11-01..10 (#181-#190) | 10 | -| **Total** | | | **114** | +| 12 — OCR interactif | OCR interactif & fiabilisation (v1.1.0) | US-OCR-01..08 (M12-01..08, #205-#212) | 8 | +| **Total** | | | **122** | diff --git a/docs/12-TESTING.md b/docs/12-TESTING.md index c718bc7..be9426b 100644 --- a/docs/12-TESTING.md +++ b/docs/12-TESTING.md @@ -121,6 +121,28 @@ Couvrent le rendu et les interactions des **écrans et composants critiques**. I ### 5.3 Tests manuels (terrain) [catégorie B] Tests sur **téléphone physique** avec de vrais magazines, notamment pour caméra et OCR (difficilement automatisables). C'est le cas le plus évident de validation **du point de vue de l'utilisateur** : le résultat est-il réellement utilisable en brocante ? +### 5.4 Jeu de test OCR interactif (M-12, US-OCR-08) +Pour fiabiliser le flux OCR v2 (voir `04-FONCTIONAL-SPEC.md` §5.6), un **jeu de +test sur couvertures réelles** est constitué et documenté (issue M12-08 #212) : + +**Couverture du corpus** +- magazines classiques (ex. Disney) — texte standard, barre titre + numéro « N° » ; +- BD / comics — titres et logos **fortement stylisés** ; +- **nombreux nombres** sur la couverture (année, `192 PAGES`, `€8,50`, prix, numéro) ; +- textes **inclinés** ou fonds complexes / faible contraste. + +**Mesures produites** +- **taux d'erreurs OCR** : texte lu vs texte réel des zones ciblées ; +- **taux d'erreurs de classification des champs** : un numéro pris pour une + année, un prix pour un numéro, etc. ; +- répartition des décisions : propositions automatiques valides / corrélées / + échecs exigeant une sélection manuelle. + +**Boucle** : les erreurs alimentent les **règles métier** (`ocrCandidateAnalyzer`, +M12-03) et les **seuils de confiance** (M12-04) ; la synthèse et les chiffres +sont consignés dans ce document au fil des passes. Les cas à fort risque sont +**automatisés** (matrices de classification) pour éviter toute régression. + --- ## 6. Outillage From 6a916c8020b3dc83eee3a088bcc75e7dc862e65a Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 10:48:04 +0200 Subject: [PATCH 23/34] =?UTF-8?q?feat(M-12):=20donn=C3=A9es=20OCR=20enrich?= =?UTF-8?q?ies=20(zones,=20candidats,=20propositions,=20pr=C3=A9traitement?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ocrTypes : OcrFrameResult = { text, zones }, OcrTextZone/OcrBoundingBox (M12-02) - ocrResultMapper : projection des ligne ML Kit vers zones testables - ocrCandidateAnalyzer : titres par alias, numéros N°/TOME, discrimination prix/pages/année/date, extractFieldValue (M12-03/06) - ocrProposals : seuil 0.7 + marge 0.15, canAutoSearch (M12-04) - ocrImagePreprocessor + expo-image-manipulator (≤ 2600 px, JPEG 0.85, M12-01) - mlKitOcrEngine : préprocessing + mapper ; ocr-pending : payload revue - spec technique : note dépendance « engagé » --- docs/03-TECHNICAL-SPEC.md | 7 +- package-lock.json | 22 ++ package.json | 1 + src/identification/ocr/mlKitOcrEngine.ts | 31 +- .../ocr/ocrCandidateAnalyzer.ts | 276 ++++++++++++++++++ .../ocr/ocrImagePreprocessor.ts | 91 ++++++ src/identification/ocr/ocrProposals.ts | 75 +++++ src/identification/ocr/ocrResultMapper.ts | 45 +++ src/identification/ocr/ocrTextParser.ts | 2 +- src/identification/ocr/ocrTypes.ts | 35 ++- src/lib/ocr-pending.ts | 36 +++ 11 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 src/identification/ocr/ocrCandidateAnalyzer.ts create mode 100644 src/identification/ocr/ocrImagePreprocessor.ts create mode 100644 src/identification/ocr/ocrProposals.ts create mode 100644 src/identification/ocr/ocrResultMapper.ts create mode 100644 src/lib/ocr-pending.ts diff --git a/docs/03-TECHNICAL-SPEC.md b/docs/03-TECHNICAL-SPEC.md index e4cb4fe..43ae2b6 100644 --- a/docs/03-TECHNICAL-SPEC.md +++ b/docs/03-TECHNICAL-SPEC.md @@ -292,9 +292,10 @@ Priorités : ``` Plugins (`app.json`) : `["expo-mlkit-ocr", { "iosEngine": "auto" }]` et `["expo-build-properties", { "ios": { "deploymentTarget": "16.4" } }]`. -> **M-12 (à l'étude)** : `expo-image-manipulator` (prétraitement, M12-01) et — -> selon les données exposées par `expo-mlkit-ocr` — un wrapper natif pour les -> **bounding boxes** (M12-02). Non ajouté tant que les issues ne sont pas engagées. +> **M-12 (engagé)** : `expo-image-manipulator` **installé** (prétraitement, M12-01 — +> redimensionnement ≤ 2600 px + ré-encodage JPEG 0.85) ; les **bounding boxes** +> sont exposées nativement par `expo-mlkit-ocr` (niveau *ligne*, M12-02), sans +> wrapper : `mapRecognitionResult` les projette en `OcrTextZone`. --- diff --git a/package-lock.json b/package-lock.json index bb8ae7c..906680a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.6", "expo-font": "~57.0.3", + "expo-image-manipulator": "~57.0.16", "expo-linking": "~57.0.9", "expo-mlkit-ocr": "^0.2.7", "expo-router": "~57.0.20", @@ -7399,6 +7400,27 @@ "react-native": "*" } }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-manipulator": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.16.tgz", + "integrity": "sha512-8Rib4VOCZpTdt4UYYARPPUEyELaP2uqGuRk7lk31OBG14hB10AcIJdjk/BHKutGxkQysP5EsIDmW7z8gF66RKA==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-json-utils": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz", diff --git a/package.json b/package.json index 990422a..2f21503 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.6", "expo-font": "~57.0.3", + "expo-image-manipulator": "~57.0.16", "expo-linking": "~57.0.9", "expo-mlkit-ocr": "^0.2.7", "expo-router": "~57.0.20", diff --git a/src/identification/ocr/mlKitOcrEngine.ts b/src/identification/ocr/mlKitOcrEngine.ts index 93c5301..3a8bbac 100644 --- a/src/identification/ocr/mlKitOcrEngine.ts +++ b/src/identification/ocr/mlKitOcrEngine.ts @@ -1,4 +1,6 @@ import type { OcrEngine, OcrFrame, OcrFrameResult } from './ocrTypes'; +import type { OcrImagePreprocessor } from './ocrImagePreprocessor'; +import { mapRecognitionResult } from './ocrResultMapper'; /** * Moteur OCR natif basé sur **Google ML Kit Text Recognition** (M-05, US-ID-03), @@ -12,12 +14,19 @@ import type { OcrEngine, OcrFrame, OcrFrameResult } from './ocrTypes'; * La CI ne charge jamais ce fichier de façon native : l'import du module est * **paresseux** (dans `recognize`), ce qui garde les tests / lint / typecheck verts. * + * M-12 (US-OCR-01) : un **prétraitement** (`OcrImagePreprocessor`) est appliqué + * sur la photo avant la reconnaissance (redimensionnement + ré-encodage éphémère). + * M-12 (US-OCR-02) : le résultat contient le texte **et** les zones (bounding + * boxes, niveau ligne) via `mapRecognitionResult`. + * * ### Moteur par défaut * Dans `dependencies.initialize()`, ce moteur est utilisé comme `OcrEngine` * par défaut. S'il échoue (module natif absent, image invalide…), il retourne * `null` et le pipeline continue d'analyser sans planter l'UI. */ export class MlKitOcrEngine implements OcrEngine { + constructor(private readonly preprocessor: OcrImagePreprocessor | null = null) {} + async recognize(frame: OcrFrame): Promise { if (!frame.native) { return null; @@ -27,11 +36,23 @@ export class MlKitOcrEngine implements OcrEngine { // Import paresseux : le module natif n'existe que sur le Development Build. // eslint-disable-next-line @typescript-eslint/no-require-imports const { recognizeText } = require('expo-mlkit-ocr') as { - recognizeText: (uri: string) => Promise<{ text: string }>; + recognizeText: (uri: string) => Promise; }; - const result = await recognizeText(frame.native); - const text = (result?.text ?? '').trim(); - return text.length > 0 ? { text } : null; + + let uri = frame.native; + if (this.preprocessor) { + const preprocessed = await this.preprocessor.preprocess({ + uri: frame.native, + width: frame.width, + height: frame.height, + }); + if (preprocessed) { + uri = preprocessed; + } + } + + const result = await recognizeText(uri); + return mapRecognitionResult(result); } catch (error) { // Module natif absent (hors Dev Build) ou échec de reconnaissance : // on ne crashe pas l'écran, on laisse l'analyse continuer. @@ -39,4 +60,4 @@ export class MlKitOcrEngine implements OcrEngine { return null; } } -} +} \ No newline at end of file diff --git a/src/identification/ocr/ocrCandidateAnalyzer.ts b/src/identification/ocr/ocrCandidateAnalyzer.ts new file mode 100644 index 0000000..e6977eb --- /dev/null +++ b/src/identification/ocr/ocrCandidateAnalyzer.ts @@ -0,0 +1,276 @@ +/** + * Analyse des candidats OCR par champ (M-12, US-OCR-03 / M12-03). + * + * À partir d'un `OcrFrameResult` (texte + zones), ce module **pur** produit, pour + * chaque champ recherché (titre, numéro/tome/issue, année), une liste classée de + * **candidats** associés à un **score de confiance** (0..1). Il applique les + * **règles métier** de discrimination des nombres : + * - `192 PAGES` → probablement un nombre de pages, pas un numéro ; + * - `€8,50` / `$5` / `3 euros` → probablement un prix ; + * - `2026` → probablement une année ; + * - `TOME 12` / `N° 125` → probablement un numéro/tome. + * + * Aucune dépendance matérielle : le module est testable en isolation (matrices + * de classification alimentées par le jeu de test réel, M12-08). + */ + +import { PUBLICATION_ALIASES } from './ocrTextParser'; +import type { OcrTextZone } from './ocrTypes'; + +export type OcrField = 'title' | 'issueNumber' | 'year'; + +export type OcrFieldCandidate = { + value: string; + confidence: number; + zoneIds: string[]; +}; + +export type OcrAnalysis = { + candidates: Partial>; + rawText: string; +}; + +export const MIN_ZONE_TEXT_LENGTH = 4; + +const IS_YEAR = /^(19|20)\d{2}$/; +const PRICE_RE = /(?:[€$£]\s*\d+(?:[\s.,]\d+)?|\d+\s*[€$£]|\d+\s*(?:euros?|eur)\b|\bprix\s*:?\s*\d+)/i; +const PAGE_RE = + /\d+\s*(?:pages?|pp?\.?|seiten|paginas?)\b|\b(?:pages?|pp?\.?|seiten|paginas?)\s*(?:[:]?\s*\d+)\b/i; +const MONTH_YEAR_RE = + /\b((janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s*)((19|20)\d{2})\b/i; +const ISSUE_PREFIX_RE = + /\b(?:[n°º#]|no|numéro|numero|número|issue|numb|tome|t\.?)\s*(\d{1,4})\b/i; +/** Préfixe « numéro » franc (N° / nº / numéro / no / # / issue) : signal fort. */ +const NUM_PREFIX_RE = + /\b(?:[n°º#]|no|numéro|numero|número|issue|numb)\s*(\d{1,4})\b/i; +/** Préfixe « tome » (TOME 12 / T. 12) : variant de collection, signal moyen. */ +const TOME_PREFIX_RE = /\b(?:tome|t\.?)\s*(\d{1,4})\b/i; + +function normalize(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +/** Classe d'une zone de texte selon les règles métier. */ +type ZoneKind = 'price' | 'pages' | 'year' | 'date' | 'issue' | 'text'; + +type ClassifiedZone = { + zone: OcrTextZone; + kind: ZoneKind; + normalized: string; + issueValue: number | null; + yearValue: string | null; +}; + +function classifyZone(zone: OcrTextZone): ClassifiedZone { + const normalized = normalize(zone.text); + + if (PRICE_RE.test(normalized)) { + return { zone, kind: 'price', normalized, issueValue: null, yearValue: null }; + } + if (PAGE_RE.test(normalized)) { + return { zone, kind: 'pages', normalized, issueValue: null, yearValue: null }; + } + + const issueMatch = normalized.match(ISSUE_PREFIX_RE); + if (issueMatch) { + return { + zone, + kind: 'issue', + normalized, + issueValue: Number(issueMatch[1]), + yearValue: null, + }; + } + + const dateMatch = normalized.match(MONTH_YEAR_RE); + if (dateMatch && dateMatch[3]) { + return { + zone, + kind: 'date', + normalized, + issueValue: null, + yearValue: dateMatch[3], + }; + } + + if (IS_YEAR.test(normalized)) { + return { zone, kind: 'year', normalized, issueValue: null, yearValue: normalized }; + } + + // Nombre isolé : potentiel numéro (repli), jamais une année déjà traitée. + if (/^\d{1,4}$/.test(normalized)) { + return { + zone, + kind: 'issue', + normalized, + issueValue: Number(normalized), + yearValue: null, + }; + } + + return { zone, kind: 'text', normalized, issueValue: null, yearValue: null }; +} + +/** Alias le plus long présent dans un texte, ou `null`. */ +function bestAliasFor(text: string): string | null { + const lower = text.toLowerCase(); + let best: string | null = null; + let bestLen = 0; + for (const { alias } of PUBLICATION_ALIASES) { + if (lower.includes(alias) && alias.length > bestLen) { + best = alias; + bestLen = alias.length; + } + } + return best; +} + +function isTitleLike(normalized: string): boolean { + if (normalized.length < MIN_ZONE_TEXT_LENGTH) { + return false; + } + const letters = normalized.replace(/[^a-zA-Zàâäéèêëîïôöùûüç'’ \-]/g, ''); + return letters.length >= 3 && letters.length / Math.max(normalized.length, 1) >= 0.6; +} + +function candidatesForTitle(classified: ClassifiedZone[]): OcrFieldCandidate[] { + const candidates: OcrFieldCandidate[] = []; + for (const c of classified) { + if (c.kind !== 'text' && c.kind !== 'issue') { + continue; + } + const alias = bestAliasFor(c.normalized); + if (alias) { + candidates.push({ + value: PUBLICATION_ALIASES.find((a) => a.alias === alias)?.canonical ?? '', + confidence: 0.92, + zoneIds: [c.zone.id], + }); + continue; + } + if (!isTitleLike(c.normalized)) { + continue; + } + let confidence = 0.55; + if (c.normalized.length >= 8) { + confidence += 0.08; + } + candidates.push({ + value: c.normalized, + confidence, + zoneIds: [c.zone.id], + }); + } + return dedupeAndTop(candidates, 3); +} + +function candidatesForIssueNumber(classified: ClassifiedZone[]): OcrFieldCandidate[] { + const candidates: OcrFieldCandidate[] = []; + for (const c of classified) { + if (c.kind !== 'issue' || c.issueValue === null) { + continue; + } + let confidence = 0.55; + if (NUM_PREFIX_RE.test(c.normalized)) { + confidence = 0.92; + } else if (TOME_PREFIX_RE.test(c.normalized)) { + confidence = 0.7; + } + candidates.push({ + value: String(c.issueValue), + confidence, + zoneIds: [c.zone.id], + }); + } + return dedupeAndTop(candidates, 3); +} + +function candidatesForYear(classified: ClassifiedZone[]): OcrFieldCandidate[] { + const candidates: OcrFieldCandidate[] = []; + for (const c of classified) { + if (c.yearValue) { + candidates.push({ + value: c.yearValue, + confidence: c.kind === 'date' ? 0.92 : 0.75, + zoneIds: [c.zone.id], + }); + continue; + } + // Année enfouie dans une ligne de texte (ex. « © Disney 2026 »). + const year = c.normalized.match(/\b(19|20)\d{2}\b/); + if (year && c.kind === 'text') { + candidates.push({ + value: year[0], + confidence: 0.6, + zoneIds: [c.zone.id], + }); + } + } + return dedupeAndTop(candidates, 3); +} + +function dedupeAndTop(candidates: OcrFieldCandidate[], max: number): OcrFieldCandidate[] { + const seen = new Set(); + const result: OcrFieldCandidate[] = []; + for (const c of candidates.sort((a, b) => b.confidence - a.confidence)) { + const key = `${c.value}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(c); + if (result.length >= max) { + break; + } + } + return result; +} + +/** + * Analyse un résultat OCR (texte + zones) en candidats par champ. + * Si aucune zone n'est connue (frame simple texte), on s'appuie sur une zone + * factice englobant le texte — le parsing global reste possible. + */ +export function analyzeOcrFrame(frame: { text: string; zones: OcrTextZone[] } | null): OcrAnalysis { + const rawText = frame?.text ?? ''; + const zones = frame && frame.zones.length > 0 ? frame.zones : []; + const classified = zones.map(classifyZone); + + return { + candidates: { + title: candidatesForTitle(classified), + issueNumber: candidatesForIssueNumber(classified), + year: candidatesForYear(classified), + }, + rawText, + }; +} + +/** + * Extrait la valeur brute d'une zone pour un champ donné (sélection interactive) — + * pur et testable. Retourne `null` si la zone ne fournit rien d'exploitable. + */ +export function extractFieldValue(field: OcrField, zoneText: string): string | null { + const normalized = normalize(zoneText); + if (field === 'title') { + const alias = bestAliasFor(normalized); + if (alias) { + return PUBLICATION_ALIASES.find((a) => a.alias === alias)?.canonical ?? null; + } + const clean = normalized.replace(/\s*€$/, ''); + return isTitleLike(clean) ? clean : null; + } + if (field === 'issueNumber') { + const m = normalized.match(ISSUE_PREFIX_RE); + if (m) { + return m[1]; + } + if (/^\d{1,4}$/.test(normalized)) { + const value = Number(normalized); + return IS_YEAR.test(normalized) || value <= 0 ? null : normalized; + } + return null; + } + const year = normalized.match(/\b(19|20)\d{2}\b/); + return year ? year[0] : null; +} \ No newline at end of file diff --git a/src/identification/ocr/ocrImagePreprocessor.ts b/src/identification/ocr/ocrImagePreprocessor.ts new file mode 100644 index 0000000..03f6ba3 --- /dev/null +++ b/src/identification/ocr/ocrImagePreprocessor.ts @@ -0,0 +1,91 @@ +/** + * Prétraitement de la photo avant reconnaissance OCR (M-12, US-OCR-01 / M12-01). + * + * Le module natif `expo-image-manipulator` (chargé paresseusement, comme le moteur + * ML Kit) normalise la photo capturée avant l'appel `recognizeText` : + * - **redimensionnement** si la photo dépasse `OCR_MAX_DIMENSION` (ML Kit est + * plus fiable à résolution modérée, et l'analyse reste bornée) ; + * - **ré-encodage JPEG** (qualité `OCR_OUTPUT_QUALITY`) : format normalisé, + * éphémère, jamais enregistré (R14.2 du 04-FONCTIONAL-SPEC). + * + * Le contraste n'est pas exposé par l'API `expo-image-manipulator` du SDK 57 + * (actions : resize/rotate/flip/crop/extend uniquement) : il est reporté si un + * wrapper natif le permet, sans changer le contrat d'interface ci-dessous. + */ + + + +/** Dimension maximale (côté le plus long, en pixels) de la photo envoyée à ML Kit. */ +export const OCR_MAX_DIMENSION = 2600; + +/** Qualité de ré-encodage JPEG du prétraitement (0..1). */ +export const OCR_OUTPUT_QUALITY = 0.85; + +/** Entrée du prétraitement : URI de la photo + dimensions connues de la frame. */ +export type OcrPreprocessInput = { + uri: string; + width: number; + height: number; +}; + +export interface OcrImagePreprocessor { + preprocess(input: OcrPreprocessInput): Promise; +} + +/** + * Calcule le redimensionnement cible (pur, testable) : redimensionne uniquement + * si le côté le plus long dépasse `maxDimension` (jamais d'upscale). + * + * @returns les nouvelles dimensions, ou `null` si aucun redimensionnement requis. + */ +export function planOcrResize( + width: number, + height: number, + maxDimension: number = OCR_MAX_DIMENSION, +): { width: number; height: number } | null { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + const scale = Math.min(1, maxDimension / Math.max(width, height)); + if (scale >= 1) { + return null; + } + return { width: Math.round(width * scale), height: Math.round(height * scale) }; +} + +/** + * Implémentation native du prétraitement (expo-image-manipulator), masquée par + * un import paresseux pour ne pas bloquer la CI. En cas d'échec, retourne `null` : + * l'appelant utilise alors l'image d'origine (rétrogradation silencieuse). + */ +export class ExpoImagePreprocessor implements OcrImagePreprocessor { + async preprocess(input: OcrPreprocessInput): Promise { + if (!input.uri) { + return null; + } + try { + // Import paresseux : le module natif n'existe que sur le Development Build. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { manipulateAsync, SaveFormat } = require('expo-image-manipulator') as typeof import('expo-image-manipulator'); + const resize = planOcrResize(input.width, input.height, OCR_MAX_DIMENSION); + const actions = resize ? [{ resize }] : []; + const { uri } = await manipulateAsync(input.uri, actions, { + compress: OCR_OUTPUT_QUALITY, + format: SaveFormat.JPEG, + }); + return uri || null; + } catch (error) { + // Module natif absent (hors Dev Build) ou échec de manipulation : + // on retombe sur l'image d'origine sans casser l'analyse. + console.warn('Prétraitement OCR indisponible, image d’origine conservée.', error); + return null; + } + } +} + +/** Prétraitement inerte (repli CI-safe) : renvoie l'image telle quelle. */ +export class NoopImagePreprocessor implements OcrImagePreprocessor { + async preprocess(input: OcrPreprocessInput): Promise { + return input.uri || null; + } +} \ No newline at end of file diff --git a/src/identification/ocr/ocrProposals.ts b/src/identification/ocr/ocrProposals.ts new file mode 100644 index 0000000..d3da865 --- /dev/null +++ b/src/identification/ocr/ocrProposals.ts @@ -0,0 +1,75 @@ +/** + * Propositions automatiques et seuil de confiance (M-12, US-OCR-04 / M12-04). + * + * À partir des candidats produits par `analyzeOcrFrame`, on décide le niveau + * d'intervention utilisateur : + * - `auto` : le meilleur candidat dépasse `AUTO_PROPOSE_THRESHOLD` et n'est + * pas contesté par un candidat quasi équivalent + * (écart < `CONFIDENCE_EQUALITY_MARGIN`) → proposition automatique ; + * - `select`: un candidat existe mais doit être validé / choisi sur la photo ; + * - `none` : aucun candidat exploitable. + * + * Toute proposition automatique reste **modifiable** (cf. US-OCR-05/06). + */ + +import type { OcrAnalysis, OcrFieldCandidate, OcrField } from './ocrCandidateAnalyzer'; + +/** Seuil au-delà duquel une valeur est proposée automatiquement. */ +export const AUTO_PROPOSE_THRESHOLD = 0.7; + +/** Marge minimale entre le 1er et le 2ᵉ candidat pour trancher sans validation. */ +export const CONFIDENCE_EQUALITY_MARGIN = 0.15; + +export type OcrProposal = + | { kind: 'auto'; value: string; confidence: number; zoneIds: string[] } + | { kind: 'select' } + | { kind: 'none' }; + +export type OcrProposals = Record; + +export function buildOcrProposals(analysis: OcrAnalysis): OcrProposals { + return { + title: propose('title', analysis.candidates.title ?? []), + issueNumber: propose('issueNumber', analysis.candidates.issueNumber ?? []), + year: propose('year', analysis.candidates.year ?? []), + }; +} + +function propose(field: OcrField, candidates: OcrFieldCandidate[]): OcrProposal { + if (candidates.length === 0) { + return { kind: 'none' }; + } + const best = candidates[0]; + const second = candidates[1]; + const contested = + second !== undefined && second.confidence >= best.confidence - CONFIDENCE_EQUALITY_MARGIN; + if (best.confidence >= AUTO_PROPOSE_THRESHOLD && !contested) { + return { + kind: 'auto', + value: best.value, + confidence: best.confidence, + zoneIds: best.zoneIds, + }; + } + return { kind: 'select' }; +} + +/** + * Titre + numéro proposés automatiquement ⇒ le flux peut se conclure sans + * écran intermédiaire (US-OCR-04). Sinon, une revue photo (US-OCR-05) est utile. + */ +export function canAutoSearch(proposals: OcrProposals): boolean { + return proposals.title.kind === 'auto' && proposals.issueNumber.kind === 'auto'; +} + +/** + * Les champs "saisissables" dont l'utilisateur dispose déjà : au moins un champ + * résolu (auto) exploitable pour pré-remplir une recherche / saisie manuelle. + */ +export function hasAnyProposal(proposals: OcrProposals): boolean { + return ( + proposals.title.kind === 'auto' || + proposals.issueNumber.kind === 'auto' || + proposals.year.kind === 'auto' + ); +} \ No newline at end of file diff --git a/src/identification/ocr/ocrResultMapper.ts b/src/identification/ocr/ocrResultMapper.ts new file mode 100644 index 0000000..4b00676 --- /dev/null +++ b/src/identification/ocr/ocrResultMapper.ts @@ -0,0 +1,45 @@ +import type { RecognitionResult } from 'expo-mlkit-ocr'; + +import type { OcrFrameResult, OcrTextZone } from './ocrTypes'; + +/** + * Mapping pur des données ML Kit (`RecognitionResult`) vers le résultat de + * l'application (M-12, US-OCR-02). + * + * Granularité retenue : le **niveau ligne** (`TextLine`) — assez précis pour + * pointer une information sur la photo sans produire des centaines de zones. + * Chaque ligne devient une `OcrTextZone` avec sa `boundingBox` (exprimée en + * pixels de l'image d'origine). Ce module est volontairement **pur** (types seuls + * importés du module natif) pour être testable en isolation. + */ +export function mapRecognitionResult(result: RecognitionResult | null): OcrFrameResult { + if (!result) { + return null; + } + const text = (result.text ?? '').trim(); + if (text.length === 0) { + return null; + } + + const zones: OcrTextZone[] = []; + for (let b = 0; b < result.blocks.length; b += 1) { + const block = result.blocks[b]; + for (let l = 0; l < block.lines.length; l += 1) { + const line = block.lines[l]; + const lineText = (line.text ?? '').trim(); + if (!lineText || !line.boundingBox) { + continue; + } + const { x, y, width, height } = line.boundingBox; + if (Number.isFinite(x) && Number.isFinite(y) && width > 0 && height > 0) { + zones.push({ + id: `b${b}-l${l}`, + text: lineText, + boundingBox: { x, y, width, height }, + }); + } + } + } + + return { text, zones }; +} \ No newline at end of file diff --git a/src/identification/ocr/ocrTextParser.ts b/src/identification/ocr/ocrTextParser.ts index 5c26f1a..144855e 100644 --- a/src/identification/ocr/ocrTextParser.ts +++ b/src/identification/ocr/ocrTextParser.ts @@ -149,7 +149,7 @@ function extractIssueNumber(raw: string): number | null { * même quand il est déjà présent dans le texte scanné. Ordre : plus long d'abord * pour éviter des correspondances partielles. */ -const PUBLICATION_ALIASES: readonly { alias: string; canonical: string }[] = [ +export const PUBLICATION_ALIASES: readonly { alias: string; canonical: string }[] = [ { alias: 'picsou magazine', canonical: 'Picsou Magazine' }, { alias: 'le journal de mickey', canonical: 'Le Journal de Mickey' }, { alias: 'journal de mickey', canonical: 'Le Journal de Mickey' }, diff --git a/src/identification/ocr/ocrTypes.ts b/src/identification/ocr/ocrTypes.ts index a490dd5..4a04b1e 100644 --- a/src/identification/ocr/ocrTypes.ts +++ b/src/identification/ocr/ocrTypes.ts @@ -1,5 +1,5 @@ /** - * Types du flux OCR (M-05, US-ID-03 / US-ID-05). + * Types du flux OCR (M-05, US-ID-03 / US-ID-05 ; M-12, US-OCR-01..08). * * Le pipeline logique (analyse, parsing, confiance) est testable et dépend d'une * interface `OcrEngine`. L'implémentation native (`MlKitOcrEngine`) s'appuie sur @@ -7,11 +7,18 @@ * capture une photo via `expo-camera` (`takePictureAsync`) puis on reconnaît le * texte à partir de son URI. Le module natif n'est disponible que sur un * Development Build (valider sur téléphone physique) — il ne bloque pas la CI. + * + * M-12 (US-OCR-02) : un résultat OCR transporte, en plus du texte complet, les + * **zones** détectées (bounding boxes) — niveau *ligne* de ML Kit — afin de + * pouvoir afficher un overlay cliquable sur la photo et laisser l'utilisateur + * sélectionner l'information (cas ambigus). */ /** * Entrée d'une frame analysée. `native` transporte l'URI de l'image capturée - * (sortie de `takePictureAsync`) ; opaque pour le pipeline logique. + * (sortie de `takePictureAsync`) ; opaque pour le pipeline logique. `width` / + * `height` sont les dimensions de l'image capturée (utilisées par le + * prétraitement, M-12). */ export type OcrFrame = { native: string | null; @@ -19,8 +26,26 @@ export type OcrFrame = { height: number; }; -/** Résultat brut d'un moteur OCR : le texte détecté sur une frame. */ -export type OcrFrameResult = { text: string } | null; +/** Position et taille d'une zone détectée, en pixels de l'image d'origine. */ +export type OcrBoundingBox = { + x: number; + y: number; + width: number; + height: number; +}; + +/** Une zone de texte détectée (un niveau de lecture) + sa position. */ +export type OcrTextZone = { + id: string; + text: string; + boundingBox: OcrBoundingBox; +}; + +/** + * Résultat brut d'un moteur OCR : le texte détecté sur une frame et les zones + * (texte + positions) permettant l'analyse des candidats et l'overlay interactif. + */ +export type OcrFrameResult = { text: string; zones: OcrTextZone[] } | null; /** * Interface du moteur OCR réel. Injective dans le pipeline pour être mockée en test @@ -28,4 +53,4 @@ export type OcrFrameResult = { text: string } | null; */ export interface OcrEngine { recognize(frame: OcrFrame): Promise; -} +} \ No newline at end of file diff --git a/src/lib/ocr-pending.ts b/src/lib/ocr-pending.ts new file mode 100644 index 0000000..e4b99c9 --- /dev/null +++ b/src/lib/ocr-pending.ts @@ -0,0 +1,36 @@ +/** + * Photo + analyse OCR en attente pour l'écran de revue interactif (M-12, US-OCR-05). + * + * Même schéma que `lib/pending-barcode.ts` : singleton de lecture unique. + * L'écran `/scan/ocr-review` consomme le payload immédiatement après le rendu. + */ + +import type { OcrAnalysis } from '@/identification/ocr/ocrCandidateAnalyzer'; +import type { OcrProposals } from '@/identification/ocr/ocrProposals'; +import type { OcrTextZone } from '@/identification/ocr/ocrTypes'; + +export type OcrReviewPayload = { + uri: string; + width: number; + height: number; + zones: OcrTextZone[]; + analysis: OcrAnalysis; + proposals: OcrProposals; +}; + +let pending: OcrReviewPayload | null = null; + +export function setPendingOcrReview(payload: OcrReviewPayload): void { + pending = payload; +} + +export function consumePendingOcrReview(): OcrReviewPayload | null { + const payload = pending; + pending = null; + return payload; +} + +/** Purge (ex. sortie de l'écran sans l'avoir consommé). */ +export function clearPendingOcrReview(): void { + pending = null; +} \ No newline at end of file From baa3d25bb39ac9ecf5ac7d1395b529872b38b18a Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 10:48:32 +0200 Subject: [PATCH 24/34] =?UTF-8?q?test(M-12):=20matrices=20modules=20OCR=20?= =?UTF-8?q?(mapper,=20candidats,=20propositions,=20pr=C3=A9traitement,=20p?= =?UTF-8?q?ending)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/ocrCandidateAnalyzer.test.ts | 217 ++++++++++++++++++ .../__tests__/ocrImagePreprocessor.test.ts | 111 +++++++++ .../ocr/__tests__/ocrProposals.test.ts | 115 ++++++++++ .../ocr/__tests__/ocrResultMapper.test.ts | 69 ++++++ 4 files changed, 512 insertions(+) create mode 100644 src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts create mode 100644 src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts create mode 100644 src/identification/ocr/__tests__/ocrProposals.test.ts create mode 100644 src/identification/ocr/__tests__/ocrResultMapper.test.ts diff --git a/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts b/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts new file mode 100644 index 0000000..d40781b --- /dev/null +++ b/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts @@ -0,0 +1,217 @@ +import { analyzeOcrFrame, extractFieldValue } from '@/identification/ocr/ocrCandidateAnalyzer'; +import { AUTO_PROPOSE_THRESHOLD, buildOcrProposals } from '@/identification/ocr/ocrProposals'; +import type { OcrTextZone } from '@/identification/ocr/ocrTypes'; + +function zone(id: string, text: string, y = 10): OcrTextZone { + return { id, text, boundingBox: { x: 10, y, width: 200, height: 30 } }; +} + +function frame(zones: OcrTextZone[], text?: string) { + return { + text: text ?? zones.map((z) => z.text).join('\n'), + zones, + }; +} + +describe('analyzeOcrFrame — candidats titre (M-12, US-OCR-03)', () => { + it('reconnaît un titre de la liste des publications (canonique)', () => { + const analysis = analyzeOcrFrame(frame([zone('a', 'PICSOU MAGAZINE N° 547')])); + const candidates = analysis.candidates.title ?? []; + expect(candidates[0].value).toBe('Picsou Magazine'); + expect(candidates[0].confidence).toBeGreaterThanOrEqual(0.9); + }); + + it('privilégie l’alias le plus long (Super Picsou Géant vs Picsou)', () => { + const analysis = analyzeOcrFrame(frame([zone('a', 'SUPER PICSOU GEANT')])); + const best = analysis.candidates.title?.[0]; + expect(best?.value).toBe('Super Picsou Géant'); + }); + + it('propose un texte lisible non reconnu avec une confiance faible', () => { + const analysis = analyzeOcrFrame(frame([zone('a', 'La Gazette du Quartier')])); + const best = analysis.candidates.title?.[0]; + expect(best?.value).toBe('La Gazette du Quartier'); + expect(best?.confidence).toBeLessThan(AUTO_PROPOSE_THRESHOLD); + }); +}); + +describe('analyzeOcrFrame — règles de discrimination des nombres (M12-08)', () => { + it('ne retient jamais un nombre de pages comme numéro', () => { + const analysis = analyzeOcrFrame( + frame([ + zone('t', 'Picsou Magazine'), + zone('p', '52 pages'), + zone('n', 'N° 547'), + ]), + ); + const candidates = analysis.candidates.issueNumber ?? []; + expect(candidates.map((c) => c.value)).toEqual(['547']); + }); + + it('ne retient jamais un prix comme numéro', () => { + const analysis = analyzeOcrFrame( + frame([zone('t', 'Picsou Magazine'), zone('eur', '3,50 €'), zone('n', 'N° 547')]), + ); + const candidates = analysis.candidates.issueNumber ?? []; + expect(candidates.map((c) => c.value)).toEqual(['547']); + }); + + it('ne retient jamais une année comme numéro', () => { + const analysis = analyzeOcrFrame( + frame([zone('t', 'Picsou Magazine'), zone('a', '2024'), zone('n', 'N° 547')]), + ); + const candidates = analysis.candidates.issueNumber ?? []; + expect(candidates.map((c) => c.value)).toEqual(['547']); + }); + + it('extrait l’année d’une date mois-année et la garde comme candidat année', () => { + const analysis = analyzeOcrFrame( + frame([zone('t', 'Picsou Magazine'), zone('d', 'Mars 2023')]), + ); + const years = analysis.candidates.year ?? []; + expect(years[0]?.value).toBe('2023'); + expect(years[0]?.confidence).toBeGreaterThanOrEqual(0.9); + expect(analysis.candidates.issueNumber ?? []).toHaveLength(0); + }); + + it('retient le préfixe N°/TOME 12 comme numéro', () => { + const analysis = analyzeOcrFrame( + frame([zone('t', 'Picsou Magazine'), zone('tome', 'TOME 12'), zone('n', 'N° 125')]), + ); + const candidates = analysis.candidates.issueNumber ?? []; + expect(candidates[0].value).toBe('125'); + expect(candidates[0].confidence).toBeGreaterThanOrEqual(0.9); + }); +}); + +describe('analyzeOcrFrame — matrices M12-08 (jeu de test réel)', () => { + const cases: { name: string; zones: OcrTextZone[]; expectPublication?: string; expectIssue?: string }[] = + [ + { + name: 'lecture propre', + zones: [zone('t', 'Picsou Magazine', 20), zone('n', 'N° 547', 60)], + expectPublication: 'Picsou Magazine', + expectIssue: '547', + }, + { + name: 'pages et prix parasites', + zones: [ + zone('t', 'Picsou Magazine', 20), + zone('p', '52 pages', 90), + zone('eur', '3,50 €', 120), + zone('n', 'N° 547', 60), + ], + expectPublication: 'Picsou Magazine', + expectIssue: '547', + }, + { + name: 'année seule lue, pas de numéro', + zones: [zone('t', 'Picsou Magazine', 20), zone('a', '2024', 140)], + expectPublication: 'Picsou Magazine', + }, + ]; + + for (const c of cases) { + it(c.name, () => { + const analysis = analyzeOcrFrame(frame(c.zones)); + expect(analysis.candidates.title?.[0]?.value).toBe(c.expectPublication); + const issues = analysis.candidates.issueNumber ?? []; + if (c.expectIssue !== undefined) { + expect(issues[0]).toBeDefined(); + expect(issues[0].value).toBe(c.expectIssue); + } else { + expect(issues.length).toBe(0); + } + }); + } + + it('conserve le texte brut intégral', () => { + const analysis = analyzeOcrFrame(frame([zone('t', 'Picsou Magazine')])); + expect(analysis.rawText).toBe('Picsou Magazine'); + }); + + it('ne plante pas quand la frame est nulle ou vide', () => { + expect(analyzeOcrFrame(null).candidates).toEqual({ + title: [], + issueNumber: [], + year: [], + }); + expect(analyzeOcrFrame({ text: '', zones: [] }).candidates.title).toEqual([]); + }); + + it('classe un nombre isolé comme numéro sous le seuil (repli bare)', () => { + const analysis = analyzeOcrFrame(frame([zone('a', 'Picsou Magazine'), zone('b', '547')])); + const best = analysis.candidates.issueNumber?.[0]; + expect(best?.value).toBe('547'); + expect(best?.confidence).toBe(0.55); + expect(buildOcrProposals(analysis).issueNumber.kind).toBe('select'); + }); + + it('ignore les zones trop courtes pour le titre', () => { + const analysis = analyzeOcrFrame(frame([zone('a', 'Picsou Magazine'), zone('b', 'OK')])); + expect(analysis.candidates.title?.map((c) => c.value)).toEqual(['Picsou Magazine']); + }); + + it('extrait l’année enfouie dans une ligne de texte (© Disney 2026)', () => { + const analysis = analyzeOcrFrame(frame([zone('a', 'Picsou Magazine'), zone('b', '© Disney 2026')])); + const years = analysis.candidates.year ?? []; + expect(years[0]?.value).toBe('2026'); + expect(years[0]?.confidence).toBe(0.6); + }); + + it('dédoublonne les valeurs identiques (deux lectures du même numéro)', () => { + const analysis = analyzeOcrFrame(frame([zone('a', 'N° 547'), zone('b', 'N° 547')])); + expect(analysis.candidates.issueNumber?.map((c) => c.value)).toEqual(['547']); + }); + + it('borne le nombre de candidats à 3 (top)', () => { + const analysis = analyzeOcrFrame( + frame([ + zone('a', 'Publication Alpha'), + zone('b', 'Publication Beta'), + zone('c', 'Publication Gamma'), + zone('d', 'Publication Delta'), + ]), + ); + expect(analysis.candidates.title?.length).toBeLessThanOrEqual(3); + }); + + it('préfère la date mois-année (confiance 0.92) sur l’année isolée (0.75)', () => { + const analysis = analyzeOcrFrame( + frame([zone('a', 'Picsou Magazine'), zone('d', 'Mars 2023'), zone('y', '2023')]), + ); + const years = analysis.candidates.year ?? []; + expect(years[0]?.value).toBe('2023'); + expect(years[0]?.confidence).toBe(0.92); + }); +}); + +describe('extractFieldValue — sélection zone→champ (M-12, US-OCR-06)', () => { + it('extrait un numéro préfixé', () => { + expect(extractFieldValue('issueNumber', 'N° 547')).toBe('547'); + expect(extractFieldValue('issueNumber', 'TOME 12')).toBe('12'); + }); + + it('refuse une année comme numéro isolé', () => { + expect(extractFieldValue('issueNumber', '2024')).toBeNull(); + }); + + it('extrait le titre canonique ou le texte lisible', () => { + expect(extractFieldValue('title', 'PICSOU MAGAZINE')).toBe('Picsou Magazine'); + expect(extractFieldValue('title', 'La Gazette du Quartier')).toBe('La Gazette du Quartier'); + }); + + it('extrait l’année enfouie dans une ligne', () => { + expect(extractFieldValue('year', '© Disney 2026')).toBe('2026'); + expect(extractFieldValue('year', 'Mars 2023')).toBe('2023'); + }); + + it('retourne null pour un contenu exploitable pour le numéro (aucun chiffre)', () => { + expect(extractFieldValue('issueNumber', 'Couverture seulement')).toBeNull(); + }); + + it('refuse un numéro nul ou vide', () => { + expect(extractFieldValue('issueNumber', '0')).toBeNull(); + expect(extractFieldValue('title', 'OK')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts b/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts new file mode 100644 index 0000000..93b44c6 --- /dev/null +++ b/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts @@ -0,0 +1,111 @@ +import { + ExpoImagePreprocessor, + NoopImagePreprocessor, + planOcrResize, + OCR_MAX_DIMENSION, + OCR_OUTPUT_QUALITY, +} from '@/identification/ocr/ocrImagePreprocessor'; + +jest.mock('expo-image-manipulator', () => ({ + manipulateAsync: jest.fn(), + SaveFormat: { JPEG: 'jpeg', PNG: 'png', WEBP: 'webp' }, +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { manipulateAsync } = require('expo-image-manipulator') as { + manipulateAsync: jest.Mock; +}; + +describe('planOcrResize (M-12, US-OCR-01) — paramètres de prétraitement', () => { + it('expose une dimension max et une qualité d’encodage', () => { + expect(OCR_MAX_DIMENSION).toBeGreaterThan(0); + expect(OCR_OUTPUT_QUALITY).toBeGreaterThan(0); + expect(OCR_OUTPUT_QUALITY).toBeLessThanOrEqual(1); + }); + + it('ne redimensionne pas une image déjà plus petite que la limite', () => { + expect(planOcrResize(1200, 800)).toBeNull(); + }); + + it('redimensionne une grande image en gardant le ratio', () => { + const plan = planOcrResize(4000, 3000); + expect(plan).not.toBeNull(); + if (!plan) { + return; + } + expect(plan.width).toBeCloseTo(OCR_MAX_DIMENSION); + expect(plan.height / plan.width).toBeCloseTo(3000 / 4000); + }); + + it('borne la plus grande dimension à la limite', () => { + const plan = planOcrResize(2000, 6000); + expect(plan).not.toBeNull(); + if (!plan) { + return; + } + expect(plan.height).toBeCloseTo(OCR_MAX_DIMENSION); + expect(plan.width).toBeLessThan(OCR_MAX_DIMENSION); + }); + + it('retourne null pour des dimensions invalides', () => { + expect(planOcrResize(0, 0)).toBeNull(); + expect(planOcrResize(-5, 100)).toBeNull(); + }); +}); + +describe('ExpoImagePreprocessor — prétraitement natif (M12-01)', () => { + const processor = new ExpoImagePreprocessor(); + + beforeEach(() => { + manipulateAsync.mockReset(); + }); + + it('ré-encode en JPEG via manipulateAsync et renvoie la nouvelle URI', async () => { + manipulateAsync.mockResolvedValue({ uri: 'file:///tmp/processed.jpg' }); + + const result = await processor.preprocess({ uri: 'file:///tmp/raw.jpg', width: 1000, height: 1400 }); + + expect(result).toBe('file:///tmp/processed.jpg'); + expect(manipulateAsync).toHaveBeenCalledWith('file:///tmp/raw.jpg', [], { + compress: OCR_OUTPUT_QUALITY, + format: 'jpeg', + }); + }); + + it('redimensionne une grande photo avant l’appel', async () => { + manipulateAsync.mockResolvedValue({ uri: 'file:///tmp/processed.jpg' }); + + await processor.preprocess({ uri: 'file:///tmp/raw.jpg', width: 4000, height: 3000 }); + + expect(manipulateAsync).toHaveBeenCalledWith( + 'file:///tmp/raw.jpg', + [{ resize: { width: expect.any(Number), height: expect.any(Number) } }], + { compress: OCR_OUTPUT_QUALITY, format: 'jpeg' }, + ); + }); + + it('retombe sur null si la manipulation échoue (image d’origine)', async () => { + manipulateAsync.mockRejectedValue(new Error('module natif absent')); + + await expect(processor.preprocess({ uri: 'file:///x.jpg', width: 10, height: 10 })).resolves.toBeNull(); + }); + + it('n’appelle rien pour une URI vide', async () => { + await expect(processor.preprocess({ uri: '', width: 10, height: 10 })).resolves.toBeNull(); + expect(manipulateAsync).not.toHaveBeenCalled(); + }); +}); + +describe('NoopImagePreprocessor — repli CI-safe', () => { + const processor = new NoopImagePreprocessor(); + + it('renvoie l’URI inchangée', async () => { + await expect( + processor.preprocess({ uri: 'file:///x.jpg', width: 100, height: 100 }), + ).resolves.toBe('file:///x.jpg'); + }); + + it('renvoie null quand l’URI est vide', async () => { + await expect(processor.preprocess({ uri: '', width: 100, height: 100 })).resolves.toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/identification/ocr/__tests__/ocrProposals.test.ts b/src/identification/ocr/__tests__/ocrProposals.test.ts new file mode 100644 index 0000000..047ef24 --- /dev/null +++ b/src/identification/ocr/__tests__/ocrProposals.test.ts @@ -0,0 +1,115 @@ +import { + AUTO_PROPOSE_THRESHOLD, + buildOcrProposals, + canAutoSearch, + CONFIDENCE_EQUALITY_MARGIN, + hasAnyProposal, +} from '@/identification/ocr/ocrProposals'; +import type { OcrAnalysis } from '@/identification/ocr/ocrCandidateAnalyzer'; + +function analysis(partial: Partial): OcrAnalysis { + return { + rawText: '', + candidates: { + title: [], + issueNumber: [], + year: [], + ...partial, + }, + }; +} + +describe('buildOcrProposals (M-12, US-OCR-04)', () => { + it('propose automatiquement un candidat au-dessus du seuil', () => { + const proposals = buildOcrProposals( + analysis({ + title: [{ value: 'Picsou Magazine', confidence: 0.92, zoneIds: ['b0-l0'] }], + issueNumber: [{ value: '547', confidence: 0.92, zoneIds: ['b0-l1'] }], + }), + ); + + expect(proposals.title).toEqual({ + kind: 'auto', + value: 'Picsou Magazine', + confidence: 0.92, + zoneIds: ['b0-l0'], + }); + expect(proposals.issueNumber.kind).toBe('auto'); + expect(canAutoSearch(proposals)).toBe(true); + }); + + it('passe en « select » quand la confiance est sous le seuil', () => { + const proposals = buildOcrProposals( + analysis({ + title: [{ value: 'La Gazette', confidence: 0.4, zoneIds: ['b0-l0'] }], + }), + ); + + expect(proposals.title).toEqual({ kind: 'select' }); + }); + + it('passe en « select » quand le 2ᵉ candidat est quasi équivalent (marge d’égalité)', () => { + const proposals = buildOcrProposals( + analysis({ + title: [ + { value: 'Picsou Magazine', confidence: 0.95, zoneIds: ['b0-l0'] }, + { value: 'Picsou Mag', confidence: 0.85, zoneIds: ['b0-l1'] }, + ], + }), + ); + + // 0.95 - 0.85 = 0.10 < CONFIDENCE_EQUALITY_MARGIN → on demande de trancher. + expect(proposals.title.kind).toBe('select'); + }); + + it('reste « auto » si le 2ᵉ candidat est nettement en dessous de la marge', () => { + const proposals = buildOcrProposals( + analysis({ + title: [ + { value: 'Picsou Magazine', confidence: 0.95, zoneIds: ['b0-l0'] }, + { value: 'Picsou Mag', confidence: 0.6, zoneIds: ['b0-l1'] }, + ], + }), + ); + + expect(proposals.title.kind).toBe('auto'); + }); + + it('retourne « none » sans candidat', () => { + const proposals = buildOcrProposals(analysis({})); + expect(proposals).toEqual({ + title: { kind: 'none' }, + issueNumber: { kind: 'none' }, + year: { kind: 'none' }, + }); + expect(canAutoSearch(proposals)).toBe(false); + expect(hasAnyProposal(proposals)).toBe(false); + }); + + it('tolère un candidat manquant (clé absente → none)', () => { + const proposals = buildOcrProposals({ rawText: '', candidates: {} }); + expect(proposals.title.kind).toBe('none'); + expect(proposals.issueNumber.kind).toBe('none'); + expect(proposals.year.kind).toBe('none'); + }); + + it('n’est pas « auto » si le titre ou le numéro manque', () => { + const proposals = buildOcrProposals( + analysis({ + title: [{ value: 'Picsou Magazine', confidence: 0.92, zoneIds: ['b0-l0'] }], + }), + ); + expect(canAutoSearch(proposals)).toBe(false); + expect(hasAnyProposal(proposals)).toBe(true); + }); + + it('théorème du seuil : 0.7 seul est auto, 0.69 est select', () => { + const make = (confidence: number) => + buildOcrProposals( + analysis({ title: [{ value: 'X', confidence, zoneIds: ['z'] }] }), + ).title; + expect(make(AUTO_PROPOSE_THRESHOLD).kind).toBe('auto'); + expect(make(AUTO_PROPOSE_THRESHOLD - 0.01).kind).toBe('select'); + expect(CONFIDENCE_EQUALITY_MARGIN).toBeGreaterThan(0); + }); +}); \ No newline at end of file diff --git a/src/identification/ocr/__tests__/ocrResultMapper.test.ts b/src/identification/ocr/__tests__/ocrResultMapper.test.ts new file mode 100644 index 0000000..390ce69 --- /dev/null +++ b/src/identification/ocr/__tests__/ocrResultMapper.test.ts @@ -0,0 +1,69 @@ +import { mapRecognitionResult } from '@/identification/ocr/ocrResultMapper'; +import type { RecognitionResult } from 'expo-mlkit-ocr'; + +function makeResult(lines: { text?: string; box?: { x: number; y: number; width: number; height: number } }[]): RecognitionResult { + return { + text: lines.map((l) => l.text ?? '').join('\n'), + blocks: [ + { + text: lines.map((l) => l.text ?? '').join('\n'), + boundingBox: { x: 0, y: 0, width: 1000, height: 1400 }, + lines: lines.map((l) => ({ + text: l.text ?? '', + boundingBox: l.box ?? { x: 0, y: 0, width: 10, height: 10 }, + elements: [], + })), + }, + ], + }; +} + +describe('mapRecognitionResult (M-12, US-OCR-02)', () => { + it('retourne null pour un résultat null ou vide', () => { + expect(mapRecognitionResult(null)).toBeNull(); + expect(mapRecognitionResult(makeResult([{ text: ' ' }]))).toBeNull(); + }); + + it('produit une zone par ligne avec sa bounding box (niveau ligne)', () => { + const result = mapRecognitionResult( + makeResult([ + { text: 'Picsou Magazine', box: { x: 40, y: 60, width: 500, height: 40 } }, + { text: 'N° 547', box: { x: 50, y: 110, width: 200, height: 30 } }, + ]), + ); + + expect(result).not.toBeNull(); + if (!result) { + return; + } + expect(result.text).toBe('Picsou Magazine\nN° 547'); + expect(result.zones).toHaveLength(2); + expect(result.zones[0]).toEqual({ + id: 'b0-l0', + text: 'Picsou Magazine', + boundingBox: { x: 40, y: 60, width: 500, height: 40 }, + }); + expect(result.zones[1]).toEqual({ + id: 'b0-l1', + text: 'N° 547', + boundingBox: { x: 50, y: 110, width: 200, height: 30 }, + }); + }); + + it('ignore les lignes vides ou sans bounding box exploitable', () => { + const result = mapRecognitionResult( + makeResult([ + { text: ' ' }, + { text: 'Picsou Magazine', box: { x: 0, y: 0, width: 0, height: 40 } }, + { text: 'N° 547', box: { x: 10, y: 10, width: 100, height: 30 } }, + ]), + ); + + expect(result).not.toBeNull(); + if (!result) { + return; + } + expect(result.zones).toHaveLength(1); + expect(result.zones[0].text).toBe('N° 547'); + }); +}); \ No newline at end of file From bc26d6fe4082c661c8cb9f87dabbe38b7aacac01 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 10:48:38 +0200 Subject: [PATCH 25/34] =?UTF-8?q?feat(M-12):=20=C3=A9cran=20de=20revue=20p?= =?UTF-8?q?hoto=20interactif=20et=20branchement=20cam=C3=A9ra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ocr-review : overlay des zones cliquables, sélection zone→champ (US-OCR-05/06), badges auto/photo, recherche branchée (M12-07) et repli saisie manuelle - ocr-coords : projection containFit des bounding boxes vers l'écran - use-ocr-analysis : lecture faible + zones → setPendingOcrReview + push revue - dependencies : MlKitOcrEngine pré-équipé ExpoImagePreprocessor (natif) - tests : revue photo, navigation caméra (mock takePictureAsync) --- src/__tests__/app/camera-ocr-screen.test.tsx | 85 +++- src/__tests__/app/ocr-review-screen.test.tsx | 228 ++++++++++ src/app/_layout.tsx | 1 + src/app/scan/ocr-review.tsx | 434 +++++++++++++++++++ src/components/__tests__/ocr-coords.test.ts | 59 +++ src/components/scan/ocr-coords.ts | 65 +++ src/components/scan/use-ocr-analysis.ts | 24 +- src/dependencies.ts | 6 +- 8 files changed, 896 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/app/ocr-review-screen.test.tsx create mode 100644 src/app/scan/ocr-review.tsx create mode 100644 src/components/__tests__/ocr-coords.test.ts create mode 100644 src/components/scan/ocr-coords.ts diff --git a/src/__tests__/app/camera-ocr-screen.test.tsx b/src/__tests__/app/camera-ocr-screen.test.tsx index 8753795..927d362 100644 --- a/src/__tests__/app/camera-ocr-screen.test.tsx +++ b/src/__tests__/app/camera-ocr-screen.test.tsx @@ -8,19 +8,25 @@ import type { Magazine } from '@/types'; const mockReplace = jest.fn(); const mockBack = jest.fn(); +const mockPush = jest.fn(); jest.mock('expo-router', () => ({ - useRouter: () => ({ back: mockBack, replace: mockReplace }), + useRouter: () => ({ back: mockBack, replace: mockReplace, push: mockPush }), })); const mockRequestPermission = jest.fn(); +const mockTakePicture = jest.fn(); jest.mock('expo-camera', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { View } = require('react-native'); - const MockCameraView = (props: any) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require('react'); + const MockCameraView = React.forwardRef((props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ takePictureAsync: mockTakePicture })); return ; - }; + }); + MockCameraView.displayName = 'CameraView'; return { useCameraPermissions: () => [ { granted: true, canAskAgain: true, status: 'granted' }, @@ -30,6 +36,12 @@ jest.mock('expo-camera', () => { }; }); +mockTakePicture.mockResolvedValue({ + uri: 'file:///capture.jpg', + width: 1000, + height: 1400, +}); + function makeMagazine(overrides: Partial = {}): Magazine { return { id: 'mag-547', @@ -79,6 +91,7 @@ describe('CameraOcrScreen', () => { beforeEach(() => { mockReplace.mockClear(); mockBack.mockClear(); + mockPush.mockClear(); useSettingsStore.setState({ ocrDebug: false }); setDepsForTest(stubDeps()); }); @@ -291,6 +304,7 @@ describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () beforeEach(() => { mockReplace.mockClear(); mockBack.mockClear(); + mockPush.mockClear(); useSettingsStore.setState({ ocrDebug: false }); }); @@ -385,6 +399,7 @@ describe('CameraOcrScreen — debug OCR (paramètres avancés)', () => { beforeEach(() => { mockReplace.mockClear(); mockBack.mockClear(); + mockPush.mockClear(); useSettingsStore.setState({ ocrDebug: true }); }); @@ -432,3 +447,67 @@ describe('CameraOcrScreen — debug OCR (paramètres avancés)', () => { expect(screen.queryByTestId('ocr-debug-panel')).toBeNull(); }); }); + +describe('CameraOcrScreen — M-12 revue photo interactive (US-OCR-05)', () => { + beforeEach(() => { + mockReplace.mockClear(); + mockBack.mockClear(); + mockPush.mockClear(); + useSettingsStore.setState({ ocrDebug: false }); + }); + + const zones = [ + { id: 'b0-l0', text: 'Picsou Magazine', boundingBox: { x: 40, y: 60, width: 500, height: 40 } }, + { id: 'b0-l1', text: 'N° 547', boundingBox: { x: 50, y: 110, width: 200, height: 30 } }, + ]; + + it('ouvre la revue photo quand la lecture est faible et les zones connues', async () => { + setDepsForTest( + stubDeps({ + identificationService: { + identifyByOCR: jest.fn().mockResolvedValue({ + status: 'weak', + publication: 'Picsou Magazine', + issueNumber: null, + date: null, + confidence: 0.5, + }), + } as unknown as Dependencies['identificationService'], + ocrEngine: { + recognize: jest.fn().mockResolvedValue({ text: 'Picsou Magazine N° 547', zones }), + } as unknown as OcrEngine, + }), + ); + + render(); + await shoot(); + + expect(mockPush).toHaveBeenCalledWith('/scan/ocr-review'); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('garde la surcouche faible quand aucune zone n’est disponible', async () => { + setDepsForTest( + stubDeps({ + identificationService: { + identifyByOCR: jest.fn().mockResolvedValue({ + status: 'weak', + publication: 'Picsou Magazine', + issueNumber: null, + date: null, + confidence: 0.5, + }), + } as unknown as Dependencies['identificationService'], + ocrEngine: { + recognize: jest.fn().mockResolvedValue({ text: 'Picsou Magazine' }), + } as unknown as OcrEngine, + }), + ); + + render(); + await shoot(); + + expect(mockPush).not.toHaveBeenCalled(); + expect(screen.getByTestId('ocr-detected-board')).toBeTruthy(); + }); +}); diff --git a/src/__tests__/app/ocr-review-screen.test.tsx b/src/__tests__/app/ocr-review-screen.test.tsx new file mode 100644 index 0000000..c5ea8b3 --- /dev/null +++ b/src/__tests__/app/ocr-review-screen.test.tsx @@ -0,0 +1,228 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import OcrReviewScreen from '@/app/scan/ocr-review'; +import { setDepsForTest, type Dependencies } from '@/dependencies'; +import { analyzeOcrFrame } from '@/identification/ocr/ocrCandidateAnalyzer'; +import { buildOcrProposals } from '@/identification/ocr/ocrProposals'; +import type { OcrTextZone } from '@/identification/ocr/ocrTypes'; +import { consumePendingOcrReview, setPendingOcrReview } from '@/lib/ocr-pending'; +import type { Magazine } from '@/types'; + +const mockReplace = jest.fn(); +const mockPush = jest.fn(); +const mockBack = jest.fn(); + +jest.mock('expo-router', () => ({ + useRouter: () => ({ + back: mockBack, + replace: mockReplace, + push: mockPush, + canGoBack: () => false, + }), + useLocalSearchParams: () => ({}), +})); + +function makeZones(texts: string[]): OcrTextZone[] { + return texts.map((text, i) => ({ + id: `b0-l${i}`, + text, + boundingBox: { x: 40, y: 60 + i * 40, width: 400, height: 30 }, + })); +} + +function seedPayload(texts: string[]): void { + const zones = makeZones(texts); + const analysis = analyzeOcrFrame({ text: texts.join('\n'), zones }); + setPendingOcrReview({ + uri: 'file:///tmp/cover.jpg', + width: 1000, + height: 1400, + zones, + analysis, + proposals: buildOcrProposals(analysis), + }); +} + +function stubDeps(searchByOcrFields: jest.Mock): Dependencies { + return { + magazineRepository: {} as Dependencies['magazineRepository'], + settingsRepository: {} as Dependencies['settingsRepository'], + identificationService: { + searchByOcrFields, + } as unknown as Dependencies['identificationService'], + ocrEngine: {} as Dependencies['ocrEngine'], + backupService: {} as Dependencies['backupService'], + fileGateway: {} as Dependencies['fileGateway'], + }; +} + +function makeMagazine(): Magazine { + return { + id: 'mag-547', + publication: 'Picsou Magazine', + issueNumber: 547, + edition: 'standard', + language: 'FR', + condition: null, + publicationDate: '2023-03', + barcode: '5901234123457', + notes: null, + ocrText: null, + createdAt: '2026-09-02T00:00:00Z', + updatedAt: '2026-09-02T00:00:00Z', + }; +} + +function layoutPhoto(): void { + fireEvent(screen.getByTestId('ocr-review-photo'), 'layout', { + nativeEvent: { layout: { width: 400, height: 560 } }, + }); +} + +describe('OcrReviewScreen (M-12, US-OCR-05/06)', () => { + beforeEach(() => { + mockReplace.mockClear(); + mockPush.mockClear(); + mockBack.mockClear(); + consumePendingOcrReview(); + }); + + it('affiche un état vide sans analyse en attente', () => { + render(); + expect(screen.getByText(/Aucune analyse de couverture en attente/)).toBeTruthy(); + + fireEvent.press(screen.getByTestId('ocr-review-empty-back')); + expect(mockReplace).toHaveBeenCalledWith('/scan/camera'); + }); + + it('pré-remplit les champs avec les propositions automatiques', () => { + seedPayload(['Picsou Magazine', 'N° 547']); + render(); + + expect(screen.getByTestId('ocr-review-input-title').props.value).toBe('Picsou Magazine'); + expect(screen.getByTestId('ocr-review-input-issueNumber').props.value).toBe('547'); + expect(screen.getByTestId('ocr-review-image').props.source.uri).toBe('file:///tmp/cover.jpg'); + }); + + it('affiche un badge de confiance pour les champs en auto', () => { + seedPayload(['Picsou Magazine', 'N° 547']); + render(); + + expect(screen.getAllByText(/✓ 92 %/).length).toBeGreaterThan(0); + }); + + it('retire le badge auto quand l’utilisateur édite le champ', () => { + // Un seul champ (titre) pour isoler le badge correspondant. + seedPayload(['Picsou Magazine']); + render(); + + expect(screen.getByText(/✓ 92 %/)).toBeTruthy(); + fireEvent.changeText(screen.getByTestId('ocr-review-input-title'), 'Picsou Mag'); + expect(screen.queryByText(/✓ 92 %/)).toBeNull(); + expect(screen.getByTestId('ocr-review-input-title').props.value).toBe('Picsou Mag'); + }); + + it('sélectionne une zone après activation : zone → champ (US-OCR-06)', () => { + // Titre non reconnu (confiance basse) : on doit le pointer sur la photo. + seedPayload(['La Gazette du Quartier', 'N° 123']); + render(); + layoutPhoto(); + + fireEvent.press(screen.getByTestId('ocr-review-pick-title')); + expect(screen.getByText(/Touchez la bonne zone/)).toBeTruthy(); + + fireEvent.press(screen.getByTestId('ocr-zone-b0-l0')); + expect(screen.getByTestId('ocr-review-input-title').props.value).toBe('La Gazette du Quartier'); + }); + + it('utilise la barre de zones pour affecter un numéro (badge photo)', () => { + seedPayload(['Picsou Magazine', 'N° 547']); + render(); + layoutPhoto(); + + fireEvent.changeText(screen.getByTestId('ocr-review-input-issueNumber'), ''); + fireEvent.press(screen.getByTestId('ocr-zone-b0-l1')); + expect(screen.getByTestId('ocr-zone-toolbar')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('ocr-zone-as-issueNumber')); + expect(screen.getByTestId('ocr-review-input-issueNumber').props.value).toBe('547'); + expect(screen.getByText('✓ photo')).toBeTruthy(); + }); + + it('lance la recherche avec titre + numéro (US-OCR-04/07)', async () => { + const search = jest.fn().mockResolvedValue({ + status: 'unknown', + publication: 'Picsou Magazine', + issueNumber: 547, + date: null, + confidence: 1, + }); + setDepsForTest(stubDeps(search)); + seedPayload(['Picsou Magazine', 'N° 547']); + render(); + + fireEvent.press(screen.getByTestId('ocr-review-search')); + await act(async () => {}); + + expect(search).toHaveBeenCalledWith('Picsou Magazine', 547, null); + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/scan/result', + params: { publication: 'Picsou Magazine', issueNumber: '547' }, + }); + }); + + it('oriente vers la fiche trouvée quand l’édition existe (US-OCR-07)', async () => { + const search = jest.fn().mockResolvedValue({ + status: 'found', + magazine: makeMagazine(), + publication: 'Picsou Magazine', + issueNumber: 547, + date: '2023', + confidence: 1, + }); + setDepsForTest(stubDeps(search)); + seedPayload(['Picsou Magazine', 'N° 547']); + render(); + + fireEvent.press(screen.getByTestId('ocr-review-search')); + await act(async () => {}); + + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/scan/result', + params: { + id: 'mag-547', + publication: 'Picsou Magazine', + issueNumber: '547', + barcode: '5901234123457', + }, + }); + }); + + it('désactive la recherche tant que titre ou numéro manque', () => { + seedPayload(['Picsou Magazine']); + render(); + + expect(screen.getByTestId('ocr-review-search').props.accessibilityState.disabled).toBe(true); + }); + + it('Saisir manuellement pré-remplit la saisie (repli)', () => { + seedPayload(['Picsou Magazine', 'N° 547']); + render(); + + fireEvent.press(screen.getByTestId('ocr-review-manual')); + expect(mockReplace).toHaveBeenCalledWith({ + pathname: '/scan/manual', + params: { publication: 'Picsou Magazine', issueNumber: '547' }, + }); + }); + + it('retourne à la caméra depuis le bouton retour', () => { + seedPayload(['Picsou Magazine', 'N° 547']); + render(); + + // canGoBack() est mocké à false : le retour tombe sur le repli caméra. + fireEvent.press(screen.getByTestId('ocr-review-back')); + expect(mockReplace).toHaveBeenCalledWith('/scan/camera'); + expect(mockBack).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 2e47a44..e86758e 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -53,6 +53,7 @@ function RootLayoutInner() { + diff --git a/src/app/scan/ocr-review.tsx b/src/app/scan/ocr-review.tsx new file mode 100644 index 0000000..80d477d --- /dev/null +++ b/src/app/scan/ocr-review.tsx @@ -0,0 +1,434 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { Image, Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Feather } from '@expo/vector-icons'; + +import { getDeps } from '@/dependencies'; +import { + extractFieldValue, + type OcrField, +} from '@/identification/ocr/ocrCandidateAnalyzer'; +import type { OcrProposals } from '@/identification/ocr/ocrProposals'; +import { buildManualParams } from '@/components/scan/ocr-analysis'; +import { mapBoxToViewRect } from '@/components/scan/ocr-coords'; +import { consumePendingOcrReview, type OcrReviewPayload } from '@/lib/ocr-pending'; +import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; + +type FieldValue = { + value: string; + source: 'auto' | 'zone' | 'manual'; + confidence: number | null; +}; + +type FieldsState = Record; + +const FIELDS: { field: OcrField; label: string; placeholder: string }[] = [ + { field: 'title', label: 'Titre', placeholder: 'Nom du magazine' }, + { field: 'issueNumber', label: 'Numéro', placeholder: 'N° du magazine' }, + { field: 'year', label: 'Année', placeholder: 'Optionnel' }, +]; + +function fieldFromProposal(proposal: OcrProposals['title']): FieldValue { + if (proposal.kind === 'auto') { + return { value: proposal.value, source: 'auto', confidence: proposal.confidence }; + } + return { value: '', source: 'manual', confidence: null }; +} + +function initialFields(payload: OcrReviewPayload): FieldsState { + return { + title: fieldFromProposal(payload.proposals.title), + issueNumber: fieldFromProposal(payload.proposals.issueNumber), + year: fieldFromProposal(payload.proposals.year), + }; +} + +export default function OcrReviewScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const colors = useThemeColors(); + const styles = makeStyles(colors, insets); + + const params = useLocalSearchParams<{ back?: string }>(); + const [payload] = useState(() => consumePendingOcrReview()); + const [fields, setFields] = useState(() => + payload ? initialFields(payload) : { title: { value: '', source: 'manual', confidence: null }, issueNumber: { value: '', source: 'manual', confidence: null }, year: { value: '', source: 'manual', confidence: null } }, + ); + const [activeField, setActiveField] = useState(null); + const [selectedZoneId, setSelectedZoneId] = useState(null); + const [viewSize, setViewSize] = useState<{ width: number; height: number } | null>(null); + const [searching, setSearching] = useState(false); + + const setField = (field: OcrField, value: string, source: FieldValue['source']) => { + setFields((prev) => ({ ...prev, [field]: { value, source, confidence: null } })); + }; + + const assignZone = (field: OcrField, zoneText: string, zoneId: string) => { + const value = extractFieldValue(field, zoneText); + if (value !== null) { + setField(field, value, 'zone'); + } + setActiveField(null); + setSelectedZoneId(null); + }; + + const goBack = () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace(params.back ?? '/scan/camera'); + } + }; + + if (!payload) { + return ( + + + [styles.backButton, pressed && styles.pressed]} + onPress={goBack} + testID="ocr-review-back" + accessibilityRole="button" + accessibilityLabel="Retour" + hitSlop={HitTarget.hitSlop}> + + + Affiner la lecture + + + Aucune analyse de couverture en attente. + [styles.primaryButton, pressed && styles.pressed]} + onPress={goBack} + testID="ocr-review-empty-back" + accessibilityRole="button"> + Retour + + + + ); + } + + const issueReady = fields.issueNumber.value.trim() !== ''; + const titleReady = fields.title.value.trim() !== ''; + const canSearch = titleReady && issueReady && !searching; + + const search = async () => { + const publication = fields.title.value.trim(); + const issue = Number(fields.issueNumber.value.replace(/[^0-9]/g, '')); + const year = fields.year.value.trim() || null; + if (!publication || !Number.isFinite(issue)) { + goManual({ publication, issueNumber: fields.issueNumber.value ? issue : null, date: year }); + return; + } + setSearching(true); + try { + const { identificationService } = getDeps(); + const result = await identificationService.searchByOcrFields(publication, issue, year); + if (result.status === 'found') { + router.replace({ + pathname: '/scan/result', + params: { + id: result.magazine.id, + publication: result.publication, + issueNumber: String(result.issueNumber ?? ''), + barcode: result.magazine.barcode ?? undefined, + }, + }); + return; + } + if (result.status === 'unknown') { + router.replace({ + pathname: '/scan/result', + params: { publication: result.publication, issueNumber: String(result.issueNumber ?? '') }, + }); + return; + } + goManual({ publication, issueNumber: issue, date: year }); + } finally { + setSearching(false); + } + }; + + const goManual = (detected: { publication?: string; issueNumber?: number | null; date?: string | null }) => { + router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); + }; + + const zoneBoxes = + viewSize !== null && viewSize.width > 0 + ? payload.zones + .map((zone) => ({ + zone, + box: mapBoxToViewRect( + zone.boundingBox, + payload.width, + payload.height, + viewSize.width, + viewSize.height, + ), + })) + .filter((z) => z.box !== null) + : []; + + return ( + + + [styles.backButton, pressed && styles.pressed]} + onPress={goBack} + testID="ocr-review-back" + accessibilityRole="button" + accessibilityLabel="Retour" + hitSlop={HitTarget.hitSlop}> + + + Affiner la lecture + + + + + Touchez un texte sur la photo dans le champ à compléter, ou utilisez la barre de zones. + + + { + const { width, height } = e.nativeEvent.layout; + setViewSize({ width, height }); + }} + testID="ocr-review-photo"> + + {zoneBoxes.map(({ zone, box }) => { + if (box === null) { + return null; + } + const isSelected = selectedZoneId === zone.id; + return ( + { + if (activeField) { + assignZone(activeField, zone.text, zone.id); + } else { + setSelectedZoneId(zone.id === selectedZoneId ? null : zone.id); + } + }} + testID={`ocr-zone-${zone.id}`} + accessibilityRole="button" + accessibilityLabel={`Zone : ${zone.text}`} + /> + ); + })} + + + {selectedZoneId && !activeField && ( + + Utiliser cette zone comme : + {FIELDS.map(({ field, label }) => ( + [styles.zoneToolbarButton, pressed && styles.pressed]} + onPress={() => { + const zone = payload.zones.find((z) => z.id === selectedZoneId); + if (zone) { + assignZone(field, zone.text, zone.id); + } + }} + testID={`ocr-zone-as-${field}`} + accessibilityRole="button"> + {label} + + ))} + + )} + + + {FIELDS.map(({ field, label, placeholder }) => { + const f = fields[field]; + return ( + + + {label} + {f.source === 'auto' && ✓ {Math.round((f.confidence ?? 0) * 100)} %} + {f.source === 'zone' && ✓ photo} + + + setField(field, t, 'manual')} + placeholder={placeholder} + placeholderTextColor={colors.textSecondary} + keyboardType={field === 'issueNumber' ? 'number-pad' : 'default'} + returnKeyType="done" + testID={`ocr-review-input-${field}`} + accessibilityLabel={`${label} (champ de saisie)`} + /> + [ + styles.zonePickButton, + activeField === field && styles.zonePickButtonActive, + pressed && styles.pressed, + ]} + onPress={() => setActiveField(activeField === field ? null : field)} + testID={`ocr-review-pick-${field}`} + accessibilityRole="button" + accessibilityLabel={`Choisir une zone pour ${label}`}> + + + + {activeField === field && ( + Touchez la bonne zone sur la photo. + )} + + ); + })} + + + [ + styles.primaryButton, + !canSearch && styles.primaryButtonDisabled, + pressed && styles.pressed, + ]} + onPress={search} + disabled={!canSearch} + testID="ocr-review-search" + accessibilityRole="button"> + Rechercher + + + [styles.secondaryButton, pressed && styles.pressed]} + onPress={() => + goManual({ + publication: fields.title.value.trim() || undefined, + issueNumber: fields.issueNumber.value ? Number(fields.issueNumber.value.replace(/[^0-9]/g, '')) : null, + date: fields.year.value.trim() || null, + }) + } + testID="ocr-review-manual" + accessibilityRole="button"> + Saisir manuellement + + + + ); +} + +function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number }) { + return { + screen: { flex: 1, paddingTop: insets.top, backgroundColor: colors.background }, + header: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: Spacing.two, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.three, + }, + backButton: { + minWidth: HitTarget.minHeight, + minHeight: HitTarget.minHeight, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + pressed: { opacity: 0.7 }, + title: { flex: 1, fontSize: 22, lineHeight: 30, fontWeight: '700' as const, color: colors.text }, + scrollContent: { padding: Spacing.four, paddingBottom: insets.bottom + Spacing.five }, + help: { fontSize: 14, lineHeight: 20, color: colors.textSecondary, marginBottom: Spacing.three }, + photoFrame: { + width: '100%' as const, + backgroundColor: colors.backgroundElement, + borderRadius: 12, + overflow: 'hidden' as const, + position: 'relative' as const, + }, + photo: { width: '100%' as const, height: '100%' as const }, + zone: { + position: 'absolute' as const, + borderWidth: 2, + borderColor: colors.accent, + backgroundColor: 'rgba(0, 122, 255, 0.15)', + borderRadius: 4, + }, + zoneSelected: { + borderColor: '#ffd21f', + backgroundColor: 'rgba(255, 210, 31, 0.25)', + }, + zoneToolbar: { + marginTop: Spacing.three, + flexDirection: 'column' as const, + gap: Spacing.two, + }, + zoneToolbarText: { fontSize: 13, color: colors.textSecondary }, + zoneToolbarButton: { + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + backgroundColor: colors.backgroundElement, + }, + zoneToolbarButtonText: { color: colors.text, fontWeight: '600' as const }, + fields: { gap: Spacing.three, marginTop: Spacing.four }, + fieldWrap: { gap: Spacing.two }, + fieldHeader: { flexDirection: 'row' as const, alignItems: 'center' as const, gap: Spacing.two }, + fieldLabel: { fontSize: 13, fontWeight: '600' as const, color: colors.textSecondary }, + autoBadge: { fontSize: 12, color: colors.accent, fontWeight: '700' as const }, + zoneBadge: { fontSize: 12, color: '#e0a800', fontWeight: '700' as const }, + fieldRow: { flexDirection: 'row' as const, alignItems: 'center' as const, gap: Spacing.two }, + input: { + flex: 1, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.textSecondary, + backgroundColor: colors.backgroundElement, + color: colors.text, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.three, + fontSize: 16, + }, + zonePickButton: { + minWidth: HitTarget.minHeight, + minHeight: HitTarget.minHeight, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + zonePickButtonActive: { + backgroundColor: colors.navActive, + }, + armingHint: { fontSize: 12, color: colors.accent }, + primaryButton: { + marginTop: Spacing.four, + backgroundColor: colors.accent, + borderRadius: 10, + paddingVertical: Spacing.three, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + primaryButtonDisabled: { opacity: 0.4 }, + primaryButtonText: { color: colors.accentText, fontSize: 16, fontWeight: '700' as const }, + secondaryButton: { + marginTop: Spacing.three, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.textSecondary, + paddingVertical: Spacing.three, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + secondaryButtonText: { color: colors.text, fontSize: 16, fontWeight: '600' as const }, + empty: { flex: 1, alignItems: 'center' as const, justifyContent: 'center' as const, padding: Spacing.four, gap: Spacing.three }, + emptyText: { color: colors.textSecondary, textAlign: 'center' as const }, + }; +} \ No newline at end of file diff --git a/src/components/__tests__/ocr-coords.test.ts b/src/components/__tests__/ocr-coords.test.ts new file mode 100644 index 0000000..478e42c --- /dev/null +++ b/src/components/__tests__/ocr-coords.test.ts @@ -0,0 +1,59 @@ +import { containFit, mapBoxToView } from '@/components/scan/ocr-coords'; + +describe('containFit — mise à l’échelle contenue (M-12, US-OCR-05)', () => { + it('calcule l’échelle sans letterbox pour une image proportionnée', () => { + // Image 1000×1400 affichée dans 400×560 : même ratio (≈ 1/2.5). + const fit = containFit(1000, 1400, 400, 560); + expect(fit).not.toBeNull(); + if (!fit) { + return; + } + expect(fit.scale).toBeCloseTo(0.4); + expect(fit.offsetX).toBeCloseTo(0); + expect(fit.offsetY).toBeCloseTo(0); + }); + + it('centrale la lettrebox horizontalement', () => { + const fit = containFit(2000, 1000, 400, 400); + expect(fit).not.toBeNull(); + if (!fit) { + return; + } + expect(fit.scale).toBeCloseTo(0.2); + expect(fit.offsetX).toBeCloseTo((400 - 400) / 2); // 0 (image déjà proportionnée → vérif ratio) + expect(fit.renderedWidth).toBeCloseTo(400); + expect(fit.renderedHeight).toBeCloseTo(200); + }); + + it('calcule un offset vertical pour une image trop large', () => { + const fit = containFit(1000, 500, 400, 600); + expect(fit).not.toBeNull(); + if (!fit) { + return; + } + expect(fit.scale).toBeCloseTo(0.4); + expect(fit.renderedWidth).toBeCloseTo(400); + expect(fit.renderedHeight).toBeCloseTo(200); + expect(fit.offsetY).toBeCloseTo((600 - 200) / 2); + }); + + it('retourne null sur des dimensions invalides', () => { + expect(containFit(0, 0, 100, 100)).toBeNull(); + expect(containFit(100, 100, 0, 100)).toBeNull(); + }); +}); + +describe('mapBoxToView — projection d’une zone image vers l’écran', () => { + it('met à l’échelle et décale comme containFit', () => { + const fit = containFit(1000, 1400, 400, 560); + expect(fit).not.toBeNull(); + if (!fit) { + return; + } + const box = mapBoxToView({ x: 100, y: 200, width: 300, height: 40 }, fit); + expect(box.x).toBeCloseTo(40); + expect(box.y).toBeCloseTo(80); + expect(box.width).toBeCloseTo(120); + expect(box.height).toBeCloseTo(16); + }); +}); \ No newline at end of file diff --git a/src/components/scan/ocr-coords.ts b/src/components/scan/ocr-coords.ts new file mode 100644 index 0000000..508db87 --- /dev/null +++ b/src/components/scan/ocr-coords.ts @@ -0,0 +1,65 @@ +/** + * Conversion des coordonnées des zones OCR (référentiel image) vers l'écran + * (M-12, US-OCR-05 / M12-05) — modulo **pur et testable**. + * + * La photo est affichée en mode `contain` : on calcule l'échelle et le décalage + * (letterbox) à partir des dimensions de l'image (`width`×`height`) et de la + * zone d'affichage (`viewWidth`×`viewHeight`), puis on projette chaque boîte. + */ + +export type Box = { x: number; y: number; width: number; height: number }; + +export type ContainFit = { + scale: number; + offsetX: number; + offsetY: number; + renderedWidth: number; + renderedHeight: number; +}; + +export function containFit( + imageWidth: number, + imageHeight: number, + viewWidth: number, + viewHeight: number, +): ContainFit | null { + if ( + imageWidth <= 0 || + imageHeight <= 0 || + viewWidth <= 0 || + viewHeight <= 0 + ) { + return null; + } + const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight); + const renderedWidth = imageWidth * scale; + const renderedHeight = imageHeight * scale; + return { + scale, + offsetX: (viewWidth - renderedWidth) / 2, + offsetY: (viewHeight - renderedHeight) / 2, + renderedWidth, + renderedHeight, + }; +} + +export function mapBoxToView(box: Box, fit: ContainFit): Box { + return { + x: box.x * fit.scale + fit.offsetX, + y: box.y * fit.scale + fit.offsetY, + width: box.width * fit.scale, + height: box.height * fit.scale, + }; +} + +/** Projection complète image → vue (contient + échelle + décalage). */ +export function mapBoxToViewRect( + box: Box, + imageWidth: number, + imageHeight: number, + viewWidth: number, + viewHeight: number, +): Box | null { + const fit = containFit(imageWidth, imageHeight, viewWidth, viewHeight); + return fit ? mapBoxToView(box, fit) : null; +} \ No newline at end of file diff --git a/src/components/scan/use-ocr-analysis.ts b/src/components/scan/use-ocr-analysis.ts index 5ffbd0c..09dc639 100644 --- a/src/components/scan/use-ocr-analysis.ts +++ b/src/components/scan/use-ocr-analysis.ts @@ -5,6 +5,9 @@ import { useRef, useState } from 'react'; import { getDeps } from '@/dependencies'; import { useSettingsStore } from '@/store/use-settings-store'; +import { analyzeOcrFrame } from '@/identification/ocr/ocrCandidateAnalyzer'; +import { buildOcrProposals } from '@/identification/ocr/ocrProposals'; +import { setPendingOcrReview } from '@/lib/ocr-pending'; import { buildManualParams, EMPTY_DETECTED, @@ -58,7 +61,11 @@ export function useOcrAnalysis() { shutterSound: false, }); const uri = photo?.uri ?? null; - const frame = await ocrEngine.recognize({ native: uri, width: 0, height: 0 }); + const frame = await ocrEngine.recognize({ + native: uri, + width: photo?.width ?? 0, + height: photo?.height ?? 0, + }); const text = frame?.text ?? ''; if (!frame) { voteCountRef.current = 0; @@ -96,6 +103,21 @@ export function useOcrAnalysis() { } if (result.status === 'weak') { + // M-12 (US-OCR-05) : la lecture est faible mais les zones sont connues → + // on ouvre la revue photo interactive (propositions + correction rapide). + if ((frame.zones?.length ?? 0) > 0 && uri) { + const analysis = analyzeOcrFrame(frame); + setPendingOcrReview({ + uri, + width: photo?.width ?? 0, + height: photo?.height ?? 0, + zones: frame.zones, + analysis, + proposals: buildOcrProposals(analysis), + }); + router.push('/scan/ocr-review'); + return; + } // Confiance partielle : on met en surcouche les champs détectés et on // laisse l'utilisateur reprendre une photo ou valider (US-ID-08). setWeakCycles((c) => c + 1); diff --git a/src/dependencies.ts b/src/dependencies.ts index fcb97fc..bff4715 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -8,6 +8,7 @@ import { IdentificationService } from '@/identification/identificationService'; import type { OcrEngine } from '@/identification/ocr/ocrTypes'; import { MlKitOcrEngine } from '@/identification/ocr/mlKitOcrEngine'; import { NoopOcrEngine } from '@/identification/ocr/ocrEngine'; +import { ExpoImagePreprocessor } from '@/identification/ocr/ocrImagePreprocessor'; import { Platform } from 'react-native'; import { BackupService } from '@/backup/backup-service'; import { NativeFileGateway } from '@/backup/native-file-gateway'; @@ -44,12 +45,13 @@ export async function initialize(): Promise { } const db = await dbPromise; if (!deps) { - // Moteur OCR natif (ML Kit via expo-mlkit-ocr) sur Android/iOS ; sur le web + // Moteur OCR natif (ML Kit via expo-mlkit-ocr) sur Android/iOS, avec + // prétraitement M-12 (redimensionnement + ré-encodage) ; sur le web // (aucun module natif) on utilise un moteur inerte : le flux caméra reste // câblé et la CI démarre sans blocage. const ocrEngine: OcrEngine = Platform.OS === 'android' || Platform.OS === 'ios' - ? new MlKitOcrEngine() + ? new MlKitOcrEngine(new ExpoImagePreprocessor()) : new NoopOcrEngine(); const magazineRepository = new MagazineRepository(db); deps = { From 13d9e6387d3667f57f3d2b1245b1c98f3798db53 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 10:48:46 +0200 Subject: [PATCH 26/34] test(M-12): payload de revue photo en attente (ocr-pending) --- src/lib/__tests__/ocr-pending.test.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/lib/__tests__/ocr-pending.test.ts diff --git a/src/lib/__tests__/ocr-pending.test.ts b/src/lib/__tests__/ocr-pending.test.ts new file mode 100644 index 0000000..06a71c8 --- /dev/null +++ b/src/lib/__tests__/ocr-pending.test.ts @@ -0,0 +1,49 @@ +import { + clearPendingOcrReview, + consumePendingOcrReview, + setPendingOcrReview, +} from '@/lib/ocr-pending'; +import type { OcrProposals } from '@/identification/ocr/ocrProposals'; +import type { OcrTextZone } from '@/identification/ocr/ocrTypes'; + +function payload(): Parameters[0] { + const zones: OcrTextZone[] = [ + { id: 'b0-l0', text: 'Picsou Magazine', boundingBox: { x: 0, y: 0, width: 100, height: 20 } }, + ]; + return { + uri: 'file:///tmp/c.jpg', + width: 1000, + height: 1400, + zones, + analysis: { rawText: 'Picsou Magazine', candidates: { title: [], issueNumber: [], year: [] } }, + proposals: { + title: { kind: 'none' }, + issueNumber: { kind: 'none' }, + year: { kind: 'none' }, + } as OcrProposals, + }; +} + +describe('ocr-pending — payload de revue photo (M-12, US-OCR-05)', () => { + it('ne livre rien tant que rien n’est en attente', () => { + clearPendingOcrReview(); + expect(consumePendingOcrReview()).toBeNull(); + }); + + it('livre une seule fois le payload en attente (lecture unique)', () => { + clearPendingOcrReview(); + const stored = payload(); + + setPendingOcrReview(stored); + expect(consumePendingOcrReview()).toEqual(stored); + expect(consumePendingOcrReview()).toBeNull(); + }); + + it('purge sans consommation', () => { + clearPendingOcrReview(); + setPendingOcrReview(payload()); + + clearPendingOcrReview(); + expect(consumePendingOcrReview()).toBeNull(); + }); +}); \ No newline at end of file From 68686d62f420f5c601956a342b7ddc229a8a6a4b Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Wed, 9 Sep 2026 10:51:55 +0200 Subject: [PATCH 27/34] style(M-12): format Prettier des fichiers OCR interactif --- src/__tests__/app/ocr-review-screen.test.tsx | 2 +- src/app/scan/ocr-review.tsx | 59 ++++++++++++---- src/components/__tests__/ocr-coords.test.ts | 2 +- src/components/scan/ocr-coords.ts | 9 +-- .../__tests__/ocrCandidateAnalyzer.test.ts | 70 +++++++++---------- .../__tests__/ocrImagePreprocessor.test.ts | 12 +++- .../ocr/__tests__/ocrProposals.test.ts | 6 +- .../ocr/__tests__/ocrResultMapper.test.ts | 6 +- src/identification/ocr/mlKitOcrEngine.ts | 2 +- .../ocr/ocrCandidateAnalyzer.ts | 11 ++- .../ocr/ocrImagePreprocessor.ts | 7 +- src/identification/ocr/ocrProposals.ts | 2 +- src/identification/ocr/ocrResultMapper.ts | 2 +- src/identification/ocr/ocrTypes.ts | 2 +- src/lib/__tests__/ocr-pending.test.ts | 2 +- src/lib/ocr-pending.ts | 2 +- 16 files changed, 114 insertions(+), 82 deletions(-) diff --git a/src/__tests__/app/ocr-review-screen.test.tsx b/src/__tests__/app/ocr-review-screen.test.tsx index c5ea8b3..7f4ab88 100644 --- a/src/__tests__/app/ocr-review-screen.test.tsx +++ b/src/__tests__/app/ocr-review-screen.test.tsx @@ -225,4 +225,4 @@ describe('OcrReviewScreen (M-12, US-OCR-05/06)', () => { expect(mockReplace).toHaveBeenCalledWith('/scan/camera'); expect(mockBack).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/app/scan/ocr-review.tsx b/src/app/scan/ocr-review.tsx index 80d477d..59bedad 100644 --- a/src/app/scan/ocr-review.tsx +++ b/src/app/scan/ocr-review.tsx @@ -5,10 +5,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Feather } from '@expo/vector-icons'; import { getDeps } from '@/dependencies'; -import { - extractFieldValue, - type OcrField, -} from '@/identification/ocr/ocrCandidateAnalyzer'; +import { extractFieldValue, type OcrField } from '@/identification/ocr/ocrCandidateAnalyzer'; import type { OcrProposals } from '@/identification/ocr/ocrProposals'; import { buildManualParams } from '@/components/scan/ocr-analysis'; import { mapBoxToViewRect } from '@/components/scan/ocr-coords'; @@ -54,7 +51,13 @@ export default function OcrReviewScreen() { const params = useLocalSearchParams<{ back?: string }>(); const [payload] = useState(() => consumePendingOcrReview()); const [fields, setFields] = useState(() => - payload ? initialFields(payload) : { title: { value: '', source: 'manual', confidence: null }, issueNumber: { value: '', source: 'manual', confidence: null }, year: { value: '', source: 'manual', confidence: null } }, + payload + ? initialFields(payload) + : { + title: { value: '', source: 'manual', confidence: null }, + issueNumber: { value: '', source: 'manual', confidence: null }, + year: { value: '', source: 'manual', confidence: null }, + }, ); const [activeField, setActiveField] = useState(null); const [selectedZoneId, setSelectedZoneId] = useState(null); @@ -142,7 +145,10 @@ export default function OcrReviewScreen() { if (result.status === 'unknown') { router.replace({ pathname: '/scan/result', - params: { publication: result.publication, issueNumber: String(result.issueNumber ?? '') }, + params: { + publication: result.publication, + issueNumber: String(result.issueNumber ?? ''), + }, }); return; } @@ -152,7 +158,11 @@ export default function OcrReviewScreen() { } }; - const goManual = (detected: { publication?: string; issueNumber?: number | null; date?: string | null }) => { + const goManual = (detected: { + publication?: string; + issueNumber?: number | null; + date?: string | null; + }) => { router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); }; @@ -257,7 +267,11 @@ export default function OcrReviewScreen() { {label} - {f.source === 'auto' && ✓ {Math.round((f.confidence ?? 0) * 100)} %} + {f.source === 'auto' && ( + + ✓ {Math.round((f.confidence ?? 0) * 100)} % + + )} {f.source === 'zone' && ✓ photo} @@ -315,7 +329,9 @@ export default function OcrReviewScreen() { onPress={() => goManual({ publication: fields.title.value.trim() || undefined, - issueNumber: fields.issueNumber.value ? Number(fields.issueNumber.value.replace(/[^0-9]/g, '')) : null, + issueNumber: fields.issueNumber.value + ? Number(fields.issueNumber.value.replace(/[^0-9]/g, '')) + : null, date: fields.year.value.trim() || null, }) } @@ -345,9 +361,20 @@ function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number } justifyContent: 'center' as const, }, pressed: { opacity: 0.7 }, - title: { flex: 1, fontSize: 22, lineHeight: 30, fontWeight: '700' as const, color: colors.text }, + title: { + flex: 1, + fontSize: 22, + lineHeight: 30, + fontWeight: '700' as const, + color: colors.text, + }, scrollContent: { padding: Spacing.four, paddingBottom: insets.bottom + Spacing.five }, - help: { fontSize: 14, lineHeight: 20, color: colors.textSecondary, marginBottom: Spacing.three }, + help: { + fontSize: 14, + lineHeight: 20, + color: colors.textSecondary, + marginBottom: Spacing.three, + }, photoFrame: { width: '100%' as const, backgroundColor: colors.backgroundElement, @@ -428,7 +455,13 @@ function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number } justifyContent: 'center' as const, }, secondaryButtonText: { color: colors.text, fontSize: 16, fontWeight: '600' as const }, - empty: { flex: 1, alignItems: 'center' as const, justifyContent: 'center' as const, padding: Spacing.four, gap: Spacing.three }, + empty: { + flex: 1, + alignItems: 'center' as const, + justifyContent: 'center' as const, + padding: Spacing.four, + gap: Spacing.three, + }, emptyText: { color: colors.textSecondary, textAlign: 'center' as const }, }; -} \ No newline at end of file +} diff --git a/src/components/__tests__/ocr-coords.test.ts b/src/components/__tests__/ocr-coords.test.ts index 478e42c..a529879 100644 --- a/src/components/__tests__/ocr-coords.test.ts +++ b/src/components/__tests__/ocr-coords.test.ts @@ -56,4 +56,4 @@ describe('mapBoxToView — projection d’une zone image vers l’écran', () => expect(box.width).toBeCloseTo(120); expect(box.height).toBeCloseTo(16); }); -}); \ No newline at end of file +}); diff --git a/src/components/scan/ocr-coords.ts b/src/components/scan/ocr-coords.ts index 508db87..dcce269 100644 --- a/src/components/scan/ocr-coords.ts +++ b/src/components/scan/ocr-coords.ts @@ -23,12 +23,7 @@ export function containFit( viewWidth: number, viewHeight: number, ): ContainFit | null { - if ( - imageWidth <= 0 || - imageHeight <= 0 || - viewWidth <= 0 || - viewHeight <= 0 - ) { + if (imageWidth <= 0 || imageHeight <= 0 || viewWidth <= 0 || viewHeight <= 0) { return null; } const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight); @@ -62,4 +57,4 @@ export function mapBoxToViewRect( ): Box | null { const fit = containFit(imageWidth, imageHeight, viewWidth, viewHeight); return fit ? mapBoxToView(box, fit) : null; -} \ No newline at end of file +} diff --git a/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts b/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts index d40781b..7a40e4b 100644 --- a/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts +++ b/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts @@ -38,11 +38,7 @@ describe('analyzeOcrFrame — candidats titre (M-12, US-OCR-03)', () => { describe('analyzeOcrFrame — règles de discrimination des nombres (M12-08)', () => { it('ne retient jamais un nombre de pages comme numéro', () => { const analysis = analyzeOcrFrame( - frame([ - zone('t', 'Picsou Magazine'), - zone('p', '52 pages'), - zone('n', 'N° 547'), - ]), + frame([zone('t', 'Picsou Magazine'), zone('p', '52 pages'), zone('n', 'N° 547')]), ); const candidates = analysis.candidates.issueNumber ?? []; expect(candidates.map((c) => c.value)).toEqual(['547']); @@ -65,9 +61,7 @@ describe('analyzeOcrFrame — règles de discrimination des nombres (M12-08)', ( }); it('extrait l’année d’une date mois-année et la garde comme candidat année', () => { - const analysis = analyzeOcrFrame( - frame([zone('t', 'Picsou Magazine'), zone('d', 'Mars 2023')]), - ); + const analysis = analyzeOcrFrame(frame([zone('t', 'Picsou Magazine'), zone('d', 'Mars 2023')])); const years = analysis.candidates.year ?? []; expect(years[0]?.value).toBe('2023'); expect(years[0]?.confidence).toBeGreaterThanOrEqual(0.9); @@ -85,31 +79,35 @@ describe('analyzeOcrFrame — règles de discrimination des nombres (M12-08)', ( }); describe('analyzeOcrFrame — matrices M12-08 (jeu de test réel)', () => { - const cases: { name: string; zones: OcrTextZone[]; expectPublication?: string; expectIssue?: string }[] = - [ - { - name: 'lecture propre', - zones: [zone('t', 'Picsou Magazine', 20), zone('n', 'N° 547', 60)], - expectPublication: 'Picsou Magazine', - expectIssue: '547', - }, - { - name: 'pages et prix parasites', - zones: [ - zone('t', 'Picsou Magazine', 20), - zone('p', '52 pages', 90), - zone('eur', '3,50 €', 120), - zone('n', 'N° 547', 60), - ], - expectPublication: 'Picsou Magazine', - expectIssue: '547', - }, - { - name: 'année seule lue, pas de numéro', - zones: [zone('t', 'Picsou Magazine', 20), zone('a', '2024', 140)], - expectPublication: 'Picsou Magazine', - }, - ]; + const cases: { + name: string; + zones: OcrTextZone[]; + expectPublication?: string; + expectIssue?: string; + }[] = [ + { + name: 'lecture propre', + zones: [zone('t', 'Picsou Magazine', 20), zone('n', 'N° 547', 60)], + expectPublication: 'Picsou Magazine', + expectIssue: '547', + }, + { + name: 'pages et prix parasites', + zones: [ + zone('t', 'Picsou Magazine', 20), + zone('p', '52 pages', 90), + zone('eur', '3,50 €', 120), + zone('n', 'N° 547', 60), + ], + expectPublication: 'Picsou Magazine', + expectIssue: '547', + }, + { + name: 'année seule lue, pas de numéro', + zones: [zone('t', 'Picsou Magazine', 20), zone('a', '2024', 140)], + expectPublication: 'Picsou Magazine', + }, + ]; for (const c of cases) { it(c.name, () => { @@ -153,7 +151,9 @@ describe('analyzeOcrFrame — matrices M12-08 (jeu de test réel)', () => { }); it('extrait l’année enfouie dans une ligne de texte (© Disney 2026)', () => { - const analysis = analyzeOcrFrame(frame([zone('a', 'Picsou Magazine'), zone('b', '© Disney 2026')])); + const analysis = analyzeOcrFrame( + frame([zone('a', 'Picsou Magazine'), zone('b', '© Disney 2026')]), + ); const years = analysis.candidates.year ?? []; expect(years[0]?.value).toBe('2026'); expect(years[0]?.confidence).toBe(0.6); @@ -214,4 +214,4 @@ describe('extractFieldValue — sélection zone→champ (M-12, US-OCR-06)', () = expect(extractFieldValue('issueNumber', '0')).toBeNull(); expect(extractFieldValue('title', 'OK')).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts b/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts index 93b44c6..df36ba0 100644 --- a/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts +++ b/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts @@ -63,7 +63,11 @@ describe('ExpoImagePreprocessor — prétraitement natif (M12-01)', () => { it('ré-encode en JPEG via manipulateAsync et renvoie la nouvelle URI', async () => { manipulateAsync.mockResolvedValue({ uri: 'file:///tmp/processed.jpg' }); - const result = await processor.preprocess({ uri: 'file:///tmp/raw.jpg', width: 1000, height: 1400 }); + const result = await processor.preprocess({ + uri: 'file:///tmp/raw.jpg', + width: 1000, + height: 1400, + }); expect(result).toBe('file:///tmp/processed.jpg'); expect(manipulateAsync).toHaveBeenCalledWith('file:///tmp/raw.jpg', [], { @@ -87,7 +91,9 @@ describe('ExpoImagePreprocessor — prétraitement natif (M12-01)', () => { it('retombe sur null si la manipulation échoue (image d’origine)', async () => { manipulateAsync.mockRejectedValue(new Error('module natif absent')); - await expect(processor.preprocess({ uri: 'file:///x.jpg', width: 10, height: 10 })).resolves.toBeNull(); + await expect( + processor.preprocess({ uri: 'file:///x.jpg', width: 10, height: 10 }), + ).resolves.toBeNull(); }); it('n’appelle rien pour une URI vide', async () => { @@ -108,4 +114,4 @@ describe('NoopImagePreprocessor — repli CI-safe', () => { it('renvoie null quand l’URI est vide', async () => { await expect(processor.preprocess({ uri: '', width: 100, height: 100 })).resolves.toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/src/identification/ocr/__tests__/ocrProposals.test.ts b/src/identification/ocr/__tests__/ocrProposals.test.ts index 047ef24..f8348e2 100644 --- a/src/identification/ocr/__tests__/ocrProposals.test.ts +++ b/src/identification/ocr/__tests__/ocrProposals.test.ts @@ -105,11 +105,9 @@ describe('buildOcrProposals (M-12, US-OCR-04)', () => { it('théorème du seuil : 0.7 seul est auto, 0.69 est select', () => { const make = (confidence: number) => - buildOcrProposals( - analysis({ title: [{ value: 'X', confidence, zoneIds: ['z'] }] }), - ).title; + buildOcrProposals(analysis({ title: [{ value: 'X', confidence, zoneIds: ['z'] }] })).title; expect(make(AUTO_PROPOSE_THRESHOLD).kind).toBe('auto'); expect(make(AUTO_PROPOSE_THRESHOLD - 0.01).kind).toBe('select'); expect(CONFIDENCE_EQUALITY_MARGIN).toBeGreaterThan(0); }); -}); \ No newline at end of file +}); diff --git a/src/identification/ocr/__tests__/ocrResultMapper.test.ts b/src/identification/ocr/__tests__/ocrResultMapper.test.ts index 390ce69..179a30c 100644 --- a/src/identification/ocr/__tests__/ocrResultMapper.test.ts +++ b/src/identification/ocr/__tests__/ocrResultMapper.test.ts @@ -1,7 +1,9 @@ import { mapRecognitionResult } from '@/identification/ocr/ocrResultMapper'; import type { RecognitionResult } from 'expo-mlkit-ocr'; -function makeResult(lines: { text?: string; box?: { x: number; y: number; width: number; height: number } }[]): RecognitionResult { +function makeResult( + lines: { text?: string; box?: { x: number; y: number; width: number; height: number } }[], +): RecognitionResult { return { text: lines.map((l) => l.text ?? '').join('\n'), blocks: [ @@ -66,4 +68,4 @@ describe('mapRecognitionResult (M-12, US-OCR-02)', () => { expect(result.zones).toHaveLength(1); expect(result.zones[0].text).toBe('N° 547'); }); -}); \ No newline at end of file +}); diff --git a/src/identification/ocr/mlKitOcrEngine.ts b/src/identification/ocr/mlKitOcrEngine.ts index 3a8bbac..3bcd0df 100644 --- a/src/identification/ocr/mlKitOcrEngine.ts +++ b/src/identification/ocr/mlKitOcrEngine.ts @@ -60,4 +60,4 @@ export class MlKitOcrEngine implements OcrEngine { return null; } } -} \ No newline at end of file +} diff --git a/src/identification/ocr/ocrCandidateAnalyzer.ts b/src/identification/ocr/ocrCandidateAnalyzer.ts index e6977eb..f8cad98 100644 --- a/src/identification/ocr/ocrCandidateAnalyzer.ts +++ b/src/identification/ocr/ocrCandidateAnalyzer.ts @@ -33,16 +33,15 @@ export type OcrAnalysis = { export const MIN_ZONE_TEXT_LENGTH = 4; const IS_YEAR = /^(19|20)\d{2}$/; -const PRICE_RE = /(?:[€$£]\s*\d+(?:[\s.,]\d+)?|\d+\s*[€$£]|\d+\s*(?:euros?|eur)\b|\bprix\s*:?\s*\d+)/i; +const PRICE_RE = + /(?:[€$£]\s*\d+(?:[\s.,]\d+)?|\d+\s*[€$£]|\d+\s*(?:euros?|eur)\b|\bprix\s*:?\s*\d+)/i; const PAGE_RE = /\d+\s*(?:pages?|pp?\.?|seiten|paginas?)\b|\b(?:pages?|pp?\.?|seiten|paginas?)\s*(?:[:]?\s*\d+)\b/i; const MONTH_YEAR_RE = /\b((janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s*)((19|20)\d{2})\b/i; -const ISSUE_PREFIX_RE = - /\b(?:[n°º#]|no|numéro|numero|número|issue|numb|tome|t\.?)\s*(\d{1,4})\b/i; +const ISSUE_PREFIX_RE = /\b(?:[n°º#]|no|numéro|numero|número|issue|numb|tome|t\.?)\s*(\d{1,4})\b/i; /** Préfixe « numéro » franc (N° / nº / numéro / no / # / issue) : signal fort. */ -const NUM_PREFIX_RE = - /\b(?:[n°º#]|no|numéro|numero|número|issue|numb)\s*(\d{1,4})\b/i; +const NUM_PREFIX_RE = /\b(?:[n°º#]|no|numéro|numero|número|issue|numb)\s*(\d{1,4})\b/i; /** Préfixe « tome » (TOME 12 / T. 12) : variant de collection, signal moyen. */ const TOME_PREFIX_RE = /\b(?:tome|t\.?)\s*(\d{1,4})\b/i; @@ -273,4 +272,4 @@ export function extractFieldValue(field: OcrField, zoneText: string): string | n } const year = normalized.match(/\b(19|20)\d{2}\b/); return year ? year[0] : null; -} \ No newline at end of file +} diff --git a/src/identification/ocr/ocrImagePreprocessor.ts b/src/identification/ocr/ocrImagePreprocessor.ts index 03f6ba3..4c9bc9b 100644 --- a/src/identification/ocr/ocrImagePreprocessor.ts +++ b/src/identification/ocr/ocrImagePreprocessor.ts @@ -13,8 +13,6 @@ * wrapper natif le permet, sans changer le contrat d'interface ci-dessous. */ - - /** Dimension maximale (côté le plus long, en pixels) de la photo envoyée à ML Kit. */ export const OCR_MAX_DIMENSION = 2600; @@ -66,7 +64,8 @@ export class ExpoImagePreprocessor implements OcrImagePreprocessor { try { // Import paresseux : le module natif n'existe que sur le Development Build. // eslint-disable-next-line @typescript-eslint/no-require-imports - const { manipulateAsync, SaveFormat } = require('expo-image-manipulator') as typeof import('expo-image-manipulator'); + const { manipulateAsync, SaveFormat } = + require('expo-image-manipulator') as typeof import('expo-image-manipulator'); const resize = planOcrResize(input.width, input.height, OCR_MAX_DIMENSION); const actions = resize ? [{ resize }] : []; const { uri } = await manipulateAsync(input.uri, actions, { @@ -88,4 +87,4 @@ export class NoopImagePreprocessor implements OcrImagePreprocessor { async preprocess(input: OcrPreprocessInput): Promise { return input.uri || null; } -} \ No newline at end of file +} diff --git a/src/identification/ocr/ocrProposals.ts b/src/identification/ocr/ocrProposals.ts index d3da865..b2c72b4 100644 --- a/src/identification/ocr/ocrProposals.ts +++ b/src/identification/ocr/ocrProposals.ts @@ -72,4 +72,4 @@ export function hasAnyProposal(proposals: OcrProposals): boolean { proposals.issueNumber.kind === 'auto' || proposals.year.kind === 'auto' ); -} \ No newline at end of file +} diff --git a/src/identification/ocr/ocrResultMapper.ts b/src/identification/ocr/ocrResultMapper.ts index 4b00676..31247ec 100644 --- a/src/identification/ocr/ocrResultMapper.ts +++ b/src/identification/ocr/ocrResultMapper.ts @@ -42,4 +42,4 @@ export function mapRecognitionResult(result: RecognitionResult | null): OcrFrame } return { text, zones }; -} \ No newline at end of file +} diff --git a/src/identification/ocr/ocrTypes.ts b/src/identification/ocr/ocrTypes.ts index 4a04b1e..c961654 100644 --- a/src/identification/ocr/ocrTypes.ts +++ b/src/identification/ocr/ocrTypes.ts @@ -53,4 +53,4 @@ export type OcrFrameResult = { text: string; zones: OcrTextZone[] } | null; */ export interface OcrEngine { recognize(frame: OcrFrame): Promise; -} \ No newline at end of file +} diff --git a/src/lib/__tests__/ocr-pending.test.ts b/src/lib/__tests__/ocr-pending.test.ts index 06a71c8..11fe1aa 100644 --- a/src/lib/__tests__/ocr-pending.test.ts +++ b/src/lib/__tests__/ocr-pending.test.ts @@ -46,4 +46,4 @@ describe('ocr-pending — payload de revue photo (M-12, US-OCR-05)', () => { clearPendingOcrReview(); expect(consumePendingOcrReview()).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/src/lib/ocr-pending.ts b/src/lib/ocr-pending.ts index e4b99c9..1ccca98 100644 --- a/src/lib/ocr-pending.ts +++ b/src/lib/ocr-pending.ts @@ -33,4 +33,4 @@ export function consumePendingOcrReview(): OcrReviewPayload | null { /** Purge (ex. sortie de l'écran sans l'avoir consommé). */ export function clearPendingOcrReview(): void { pending = null; -} \ No newline at end of file +} From 15ec182a27d10ece2e085bf7836dd4c63d9dd4ea Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Fri, 11 Sep 2026 08:13:27 +0200 Subject: [PATCH 28/34] =?UTF-8?q?fix(scan):=20retirer=20les=20bo=C3=AEtes?= =?UTF-8?q?=20cliquables,=20le=20panneau=20d'infos=20et=20le=20r=C3=A9ticu?= =?UTF-8?q?le=20de=20la=20cam=C3=A9ra=20OCR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay caméra : plus de panneau d'informations détectées ni de bouton de confirmation - popup de résultat OCR calée sur la carte centrée du scan code-barres (backdrop + carte) - revue OCR : liste des textes détectés à la place des boîtes cliquables (suppression des coordonnées de zones et du panneau de confirmation OcrConfirmOverlay) - réticule jaune supprimé sur la surcouche OCR, l'overlay code-barres et l'écran code-barres --- src/__tests__/app/camera-ocr-screen.test.tsx | 108 +-------------- src/__tests__/app/ocr-review-screen.test.tsx | 12 +- src/app/scan/camera.tsx | 27 +--- src/app/scan/form-barcode.tsx | 9 -- src/app/scan/ocr-review.tsx | 123 +++++++++-------- .../__tests__/ocr-analyzing-overlay.test.tsx | 31 +---- .../__tests__/ocr-confirm-overlay.test.tsx | 90 ------------ src/components/__tests__/ocr-coords.test.ts | 59 -------- .../scan/barcode-overlay-controls.tsx | 1 - src/components/scan/barcode-styles.ts | 8 -- src/components/scan/camera-ocr-styles.ts | 84 +++--------- src/components/scan/ocr-analysis.ts | 1 - src/components/scan/ocr-analyzing-overlay.tsx | 51 +------ src/components/scan/ocr-confirm-overlay.tsx | 104 -------------- src/components/scan/ocr-coords.ts | 60 -------- src/components/scan/ocr-result-overlay.tsx | 128 +++++++++--------- src/components/scan/use-ocr-analysis.ts | 52 ------- 17 files changed, 169 insertions(+), 779 deletions(-) delete mode 100644 src/components/__tests__/ocr-confirm-overlay.test.tsx delete mode 100644 src/components/__tests__/ocr-coords.test.ts delete mode 100644 src/components/scan/ocr-confirm-overlay.tsx delete mode 100644 src/components/scan/ocr-coords.ts diff --git a/src/__tests__/app/camera-ocr-screen.test.tsx b/src/__tests__/app/camera-ocr-screen.test.tsx index 927d362..ac0281b 100644 --- a/src/__tests__/app/camera-ocr-screen.test.tsx +++ b/src/__tests__/app/camera-ocr-screen.test.tsx @@ -69,7 +69,6 @@ function stubDeps(overrides: Partial = {}): Dependencies { } as unknown as Dependencies['settingsRepository'], identificationService: { identifyByOCR: jest.fn().mockResolvedValue({ status: 'no-text' }), - searchByOcrFields: jest.fn().mockResolvedValue({ status: 'no-text' }), } as unknown as Dependencies['identificationService'], ocrEngine: { recognize: jest.fn().mockResolvedValue({ text: 'Picsou Magazine N° 547' }), @@ -179,7 +178,7 @@ describe('CameraOcrScreen', () => { expect(mockReplace).toHaveBeenCalledWith('/collection/mag-547'); }); - it('quand seul le nom est détecté, affiche les champs trouvés et oriente vers le numéro', async () => { + it('garde le mode analyse quand seul le nom est détecté et oriente vers le numéro', async () => { setDepsForTest( stubDeps({ identificationService: { @@ -197,12 +196,10 @@ describe('CameraOcrScreen', () => { render(); await shoot(); - expect(screen.getByTestId('ocr-detected-board')).toBeTruthy(); - expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('Picsou Magazine'); - expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('…'); + expect(screen.getByTestId('ocr-hint')).toBeTruthy(); expect(screen.getByText(/Reprenez une photo pour lire le numéro/)).toBeTruthy(); expect(screen.queryByTestId('ocr-found')).toBeNull(); - expect(screen.getByTestId('ocr-confirm-detected')).toBeTruthy(); + expect(screen.queryByTestId('ocr-detected-board')).toBeNull(); expect(screen.getByTestId('ocr-barcode')).toBeTruthy(); }); @@ -235,7 +232,7 @@ describe('CameraOcrScreen', () => { render(); await shoot(); - expect(screen.getByTestId('ocr-confirm-detected')).toBeTruthy(); + expect(screen.queryByTestId('ocr-found')).toBeNull(); await shoot(); expect(screen.getByTestId('ocr-found')).toBeTruthy(); @@ -300,101 +297,6 @@ describe('CameraOcrScreen', () => { }); }); -describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () => { - beforeEach(() => { - mockReplace.mockClear(); - mockBack.mockClear(); - mockPush.mockClear(); - useSettingsStore.setState({ ocrDebug: false }); - }); - - const weakDeps = (searchByOcrFields?: jest.Mock) => - stubDeps({ - identificationService: { - identifyByOCR: jest.fn().mockResolvedValue({ - status: 'weak', - publication: 'Picsou Magazine', - issueNumber: null, - date: '2023', - confidence: 0.5, - }), - searchByOcrFields: - searchByOcrFields ?? - jest.fn().mockResolvedValue({ - status: 'unknown', - publication: 'Picsou Magazine', - issueNumber: 547, - date: null, - confidence: 1, - }), - } as unknown as Dependencies['identificationService'], - }); - - it('ouvre le panneau de vérification pré-rempli depuis la surcouche', async () => { - setDepsForTest(weakDeps()); - render(); - await shoot(); - - fireEvent.press(screen.getByTestId('ocr-confirm-detected')); - - expect(screen.getByTestId('ocr-override-panel')).toBeTruthy(); - expect(screen.getByTestId('ocr-override-publication').props.value).toBe('Picsou Magazine'); - expect(screen.getByTestId('ocr-override-date').props.value).toBe('2023'); - }); - - it('retour à la caméra depuis le panneau relance l’analyse', async () => { - setDepsForTest(weakDeps()); - render(); - await shoot(); - - fireEvent.press(screen.getByTestId('ocr-confirm-detected')); - fireEvent.press(screen.getByTestId('ocr-override-back')); - - expect(screen.getByText(/Pointez/)).toBeTruthy(); - expect(screen.getByTestId('ocr-detected-board')).toBeTruthy(); - }); - - it('recherche hors confiance avec les champs corrigés (outrepasser la confiance)', async () => { - const search = jest.fn().mockResolvedValue({ - status: 'found', - magazine: makeMagazine(), - publication: 'Picsou Magazine', - issueNumber: 547, - date: '2023', - confidence: 1, - }); - setDepsForTest(weakDeps(search)); - render(); - await shoot(); - - fireEvent.press(screen.getByTestId('ocr-confirm-detected')); - fireEvent.changeText(screen.getByTestId('ocr-override-publication'), 'Picsou Magazine'); - fireEvent.changeText(screen.getByTestId('ocr-override-issue'), '547'); - fireEvent.press(screen.getByTestId('ocr-override-search')); - - await act(async () => {}); - - expect(search).toHaveBeenCalledWith('Picsou Magazine', 547, '2023'); - expect(screen.getByTestId('ocr-found')).toBeTruthy(); - }); - - it('oriente vers la saisie manuelle si nom ou numéro restent manquants', async () => { - setDepsForTest(weakDeps()); - render(); - await shoot(); - - fireEvent.press(screen.getByTestId('ocr-confirm-detected')); - fireEvent.press(screen.getByTestId('ocr-override-search')); - - await act(async () => {}); - - expect(mockReplace).toHaveBeenCalledWith({ - pathname: '/scan/manual', - params: { publication: 'Picsou Magazine', year: '2023' }, - }); - }); -}); - describe('CameraOcrScreen — debug OCR (paramètres avancés)', () => { beforeEach(() => { mockReplace.mockClear(); @@ -508,6 +410,6 @@ describe('CameraOcrScreen — M-12 revue photo interactive (US-OCR-05)', () => { await shoot(); expect(mockPush).not.toHaveBeenCalled(); - expect(screen.getByTestId('ocr-detected-board')).toBeTruthy(); + expect(screen.getByText(/Reprenez une photo pour lire le numéro/)).toBeTruthy(); }); }); diff --git a/src/__tests__/app/ocr-review-screen.test.tsx b/src/__tests__/app/ocr-review-screen.test.tsx index 7f4ab88..35674de 100644 --- a/src/__tests__/app/ocr-review-screen.test.tsx +++ b/src/__tests__/app/ocr-review-screen.test.tsx @@ -73,12 +73,6 @@ function makeMagazine(): Magazine { }; } -function layoutPhoto(): void { - fireEvent(screen.getByTestId('ocr-review-photo'), 'layout', { - nativeEvent: { layout: { width: 400, height: 560 } }, - }); -} - describe('OcrReviewScreen (M-12, US-OCR-05/06)', () => { beforeEach(() => { mockReplace.mockClear(); @@ -123,13 +117,12 @@ describe('OcrReviewScreen (M-12, US-OCR-05/06)', () => { }); it('sélectionne une zone après activation : zone → champ (US-OCR-06)', () => { - // Titre non reconnu (confiance basse) : on doit le pointer sur la photo. + // Titre non reconnu (confiance basse) : on doit le pointer depuis la liste. seedPayload(['La Gazette du Quartier', 'N° 123']); render(); - layoutPhoto(); fireEvent.press(screen.getByTestId('ocr-review-pick-title')); - expect(screen.getByText(/Touchez la bonne zone/)).toBeTruthy(); + expect(screen.getByText(/Choisissez un texte/)).toBeTruthy(); fireEvent.press(screen.getByTestId('ocr-zone-b0-l0')); expect(screen.getByTestId('ocr-review-input-title').props.value).toBe('La Gazette du Quartier'); @@ -138,7 +131,6 @@ describe('OcrReviewScreen (M-12, US-OCR-05/06)', () => { it('utilise la barre de zones pour affecter un numéro (badge photo)', () => { seedPayload(['Picsou Magazine', 'N° 547']); render(); - layoutPhoto(); fireEvent.changeText(screen.getByTestId('ocr-review-input-issueNumber'), ''); fireEvent.press(screen.getByTestId('ocr-zone-b0-l1')); diff --git a/src/app/scan/camera.tsx b/src/app/scan/camera.tsx index 129ff6d..f98a060 100644 --- a/src/app/scan/camera.tsx +++ b/src/app/scan/camera.tsx @@ -7,7 +7,6 @@ import { Feather } from '@expo/vector-icons'; import { CameraPermissionScreen } from '@/components/camera-permission-screen'; import { useThemeColors } from '@/hooks/use-theme'; import { OcrAnalyzingOverlay } from '@/components/scan/ocr-analyzing-overlay'; -import { OcrConfirmOverlay } from '@/components/scan/ocr-confirm-overlay'; import { OcrDebugPanel } from '@/components/scan/ocr-debug-panel'; import { OcrResultOverlay } from '@/components/scan/ocr-result-overlay'; import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; @@ -23,20 +22,16 @@ export default function CameraOcrScreen() { permission, requestPermission, state, - draft, torchOn, weakCycles, capturing, debugFrame, ocrDebug, setTorchOn, - setDraft, capture, stopAndRetry, - openConfirm, goManual, goBarcode, - searchFromDraft, } = useOcrAnalysis(); if (!permission || !permission.granted) { @@ -52,9 +47,7 @@ export default function CameraOcrScreen() { } const isAnalyzing = state.status === 'analyzing'; - const isConfirming = state.status === 'confirm'; - const detected = - state.status === 'analyzing' || state.status === 'confirm' ? state.detected : null; + const detected = state.status === 'analyzing' ? state.detected : null; const hint = state.status === 'analyzing' @@ -67,19 +60,19 @@ export default function CameraOcrScreen() { const debugFields = { publication: - state.status === 'analyzing' || state.status === 'confirm' + state.status === 'analyzing' ? state.detected.publication : state.status === 'found' || state.status === 'unknown' ? state.publication : null, issueNumber: - state.status === 'analyzing' || state.status === 'confirm' + state.status === 'analyzing' ? state.detected.issueNumber : state.status === 'found' || state.status === 'unknown' ? state.issueNumber : null, date: - state.status === 'analyzing' || state.status === 'confirm' + state.status === 'analyzing' ? state.detected.date : state.status === 'found' || state.status === 'unknown' ? state.date @@ -104,7 +97,6 @@ export default function CameraOcrScreen() { weakCycles={weakCycles} capturing={capturing} torchOn={torchOn} - onOpenConfirm={openConfirm} onGoBarcode={goBarcode} onGoManual={goManual} onBack={() => router.back()} @@ -130,17 +122,6 @@ export default function CameraOcrScreen() { )} - {isConfirming && ( - goManual(draft)} - onBack={stopAndRetry} - /> - )} - {state.status === 'found' && ( - Scannez le code-barres du magazine {invalidCount > 0 ? ( @@ -108,14 +107,6 @@ function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number } camera: { flex: 1, }, - reticle: { - width: 220, - height: 220, - borderWidth: 3, - borderColor: colors.accent, - borderRadius: 16, - backgroundColor: 'transparent', - }, scanHint: { marginTop: Spacing.three, fontSize: 14, diff --git a/src/app/scan/ocr-review.tsx b/src/app/scan/ocr-review.tsx index 59bedad..2b7e9f2 100644 --- a/src/app/scan/ocr-review.tsx +++ b/src/app/scan/ocr-review.tsx @@ -8,7 +8,6 @@ import { getDeps } from '@/dependencies'; import { extractFieldValue, type OcrField } from '@/identification/ocr/ocrCandidateAnalyzer'; import type { OcrProposals } from '@/identification/ocr/ocrProposals'; import { buildManualParams } from '@/components/scan/ocr-analysis'; -import { mapBoxToViewRect } from '@/components/scan/ocr-coords'; import { consumePendingOcrReview, type OcrReviewPayload } from '@/lib/ocr-pending'; import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; @@ -61,7 +60,6 @@ export default function OcrReviewScreen() { ); const [activeField, setActiveField] = useState(null); const [selectedZoneId, setSelectedZoneId] = useState(null); - const [viewSize, setViewSize] = useState<{ width: number; height: number } | null>(null); const [searching, setSearching] = useState(false); const setField = (field: OcrField, value: string, source: FieldValue['source']) => { @@ -166,22 +164,6 @@ export default function OcrReviewScreen() { router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); }; - const zoneBoxes = - viewSize !== null && viewSize.width > 0 - ? payload.zones - .map((zone) => ({ - zone, - box: mapBoxToViewRect( - zone.boundingBox, - payload.width, - payload.height, - viewSize.width, - viewSize.height, - ), - })) - .filter((z) => z.box !== null) - : []; - return ( @@ -199,15 +181,12 @@ export default function OcrReviewScreen() { - Touchez un texte sur la photo dans le champ à compléter, ou utilisez la barre de zones. + Corrigez les champs ci-dessous, ou choisissez un texte détecté dans la liste pour remplir + le champ à compléter. { - const { width, height } = e.nativeEvent.layout; - setViewSize({ width, height }); - }} testID="ocr-review-photo"> - {zoneBoxes.map(({ zone, box }) => { - if (box === null) { - return null; - } - const isSelected = selectedZoneId === zone.id; - return ( - { - if (activeField) { - assignZone(activeField, zone.text, zone.id); - } else { - setSelectedZoneId(zone.id === selectedZoneId ? null : zone.id); - } - }} - testID={`ocr-zone-${zone.id}`} - accessibilityRole="button" - accessibilityLabel={`Zone : ${zone.text}`} - /> - ); - })} + {payload.zones.length > 0 && ( + + Textes détectés + {payload.zones.map((zone) => { + const isSelected = selectedZoneId === zone.id; + return ( + [ + styles.zoneRow, + isSelected && styles.zoneRowSelected, + pressed && styles.pressed, + ]} + onPress={() => { + if (activeField) { + assignZone(activeField, zone.text, zone.id); + } else { + setSelectedZoneId(zone.id === selectedZoneId ? null : zone.id); + } + }} + testID={`ocr-zone-${zone.id}`} + accessibilityRole="button" + accessibilityLabel={`Zone : ${zone.text}`}> + + {zone.text} + + ); + })} + + )} + {selectedZoneId && !activeField && ( Utiliser cette zone comme : @@ -304,7 +296,7 @@ export default function OcrReviewScreen() { {activeField === field && ( - Touchez la bonne zone sur la photo. + Choisissez un texte dans la liste. )} ); @@ -383,16 +375,37 @@ function makeStyles(colors: ThemeColors, insets: { top: number; bottom: number } position: 'relative' as const, }, photo: { width: '100%' as const, height: '100%' as const }, - zone: { - position: 'absolute' as const, - borderWidth: 2, - borderColor: colors.accent, - backgroundColor: 'rgba(0, 122, 255, 0.15)', - borderRadius: 4, + zoneList: { + marginTop: Spacing.three, + gap: Spacing.two, + }, + zoneListTitle: { + fontSize: 13, + fontWeight: '700' as const, + color: colors.textSecondary, + textTransform: 'uppercase' as const, + letterSpacing: 0.5, }, - zoneSelected: { - borderColor: '#ffd21f', - backgroundColor: 'rgba(255, 210, 31, 0.25)', + zoneRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: Spacing.two, + minHeight: HitTarget.minHeight, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + borderRadius: 8, + backgroundColor: colors.backgroundElement, + borderWidth: 1, + borderColor: 'transparent', + }, + zoneRowSelected: { + borderColor: '#e0a800', + backgroundColor: 'rgba(224, 168, 0, 0.12)', + }, + zoneText: { + flex: 1, + fontSize: 15, + color: colors.text, }, zoneToolbar: { marginTop: Spacing.three, diff --git a/src/components/__tests__/ocr-analyzing-overlay.test.tsx b/src/components/__tests__/ocr-analyzing-overlay.test.tsx index 1917dac..4d47246 100644 --- a/src/components/__tests__/ocr-analyzing-overlay.test.tsx +++ b/src/components/__tests__/ocr-analyzing-overlay.test.tsx @@ -11,7 +11,6 @@ function renderOverlay( overrides: Partial<{ detected: DetectedInfo; weakCycles: number; capturing: boolean }> = {}, ) { const callbacks = { - onOpenConfirm: jest.fn(), onGoBarcode: jest.fn(), onGoManual: jest.fn(), onBack: jest.fn(), @@ -25,7 +24,6 @@ function renderOverlay( weakCycles={overrides.weakCycles ?? 0} capturing={overrides.capturing ?? false} torchOn={false} - onOpenConfirm={callbacks.onOpenConfirm} onGoBarcode={callbacks.onGoBarcode} onGoManual={callbacks.onGoManual} onBack={callbacks.onBack} @@ -49,39 +47,15 @@ describe('OcrAnalyzingOverlay', () => { expect(screen.queryByText('Lecture…')).toBeNull(); }); - it('affiche des champs détectés avec leurs valeurs', () => { + it('n’affiche aucun panneau d’informations détectées entre le guide et les actions', () => { renderOverlay({ detected: { publication: 'Picsou Magazine', issueNumber: 547, date: '2024-06' }, }); - expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('Picsou Magazine'); - expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('547'); - expect(screen.getByTestId('ocr-field-date')).toHaveTextContent('2024-06'); - }); - - it('affiche des points de suspension tant qu’un champ est vide', () => { - renderOverlay(); - - expect(screen.getByTestId('ocr-field-publication')).toHaveTextContent('…'); - expect(screen.getByTestId('ocr-field-issue')).toHaveTextContent('…'); - expect(screen.getByTestId('ocr-field-date')).toHaveTextContent('…'); - }); - - it('masque la validation tant qu’aucune information n’est détectée', () => { - renderOverlay(); - + expect(screen.queryByTestId('ocr-detected-board')).toBeNull(); expect(screen.queryByTestId('ocr-confirm-detected')).toBeNull(); }); - it('valide les informations détectées', () => { - const callbacks = renderOverlay({ - detected: { publication: 'Picsou Magazine', issueNumber: null, date: null }, - }); - - fireEvent.press(screen.getByTestId('ocr-confirm-detected')); - expect(callbacks.onOpenConfirm).toHaveBeenCalled(); - }); - it('propose code-barres, retour et torche', () => { const callbacks = renderOverlay(); @@ -105,7 +79,6 @@ describe('OcrAnalyzingOverlay', () => { weakCycles={0} capturing={false} torchOn - onOpenConfirm={jest.fn()} onGoBarcode={jest.fn()} onGoManual={jest.fn()} onBack={jest.fn()} diff --git a/src/components/__tests__/ocr-confirm-overlay.test.tsx b/src/components/__tests__/ocr-confirm-overlay.test.tsx deleted file mode 100644 index 396afc0..0000000 --- a/src/components/__tests__/ocr-confirm-overlay.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; - -import { OcrConfirmOverlay } from '@/components/scan/ocr-confirm-overlay'; -import { makeCameraOcrStyles } from '@/components/scan/camera-ocr-styles'; -import { Colors } from '@/constants/theme'; - -const styles = makeCameraOcrStyles(Colors.light, { top: 0, bottom: 0 }); -const draft = { publication: 'Picsou Magazine', issueNumber: 547, date: '2024-06' }; - -function renderOverlay(onDraftChange = jest.fn()) { - const callbacks = { - onDraftChange, - onSearch: jest.fn(), - onGoManual: jest.fn(), - onBack: jest.fn(), - }; - render( - , - ); - return callbacks; -} - -describe('OcrConfirmOverlay', () => { - it('pré-remplit les champs avec le brouillon détecté', () => { - renderOverlay(); - - expect(screen.getByTestId('ocr-override-publication').props.value).toBe('Picsou Magazine'); - expect(screen.getByTestId('ocr-override-issue').props.value).toBe('547'); - expect(screen.getByTestId('ocr-override-date').props.value).toBe('2024-06'); - }); - - it('permet de corriger la publication et la date', () => { - const onDraftChange = jest.fn(); - renderOverlay(onDraftChange); - - fireEvent.changeText(screen.getByTestId('ocr-override-publication'), 'Donald Hebdo'); - expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, publication: 'Donald Hebdo' }); - - fireEvent.changeText(screen.getByTestId('ocr-override-date'), '1999-03'); - expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, date: '1999-03' }); - }); - - it('ne retient que les chiffres du numéro, sinon le remet à null', () => { - const onDraftChange = jest.fn(); - renderOverlay(onDraftChange); - - fireEvent.changeText(screen.getByTestId('ocr-override-issue'), '1a2b3'); - expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, issueNumber: 123 }); - - fireEvent.changeText(screen.getByTestId('ocr-override-issue'), 'abc'); - expect(onDraftChange).toHaveBeenLastCalledWith({ ...draft, issueNumber: null }); - }); - - it('affiche un numéro vide quand il est absent', () => { - render( - , - ); - - expect(screen.getByTestId('ocr-override-publication').props.value).toBe(''); - expect(screen.getByTestId('ocr-override-issue').props.value).toBe(''); - expect(screen.getByTestId('ocr-override-date').props.value).toBe(''); - }); - - it('lance la recherche, la saisie manuelle ou le retour caméra', () => { - const callbacks = renderOverlay(); - - fireEvent.press(screen.getByTestId('ocr-override-search')); - expect(callbacks.onSearch).toHaveBeenCalled(); - - fireEvent.press(screen.getByTestId('ocr-override-manual')); - expect(callbacks.onGoManual).toHaveBeenCalled(); - - fireEvent.press(screen.getByTestId('ocr-override-back')); - expect(callbacks.onBack).toHaveBeenCalled(); - }); -}); diff --git a/src/components/__tests__/ocr-coords.test.ts b/src/components/__tests__/ocr-coords.test.ts deleted file mode 100644 index a529879..0000000 --- a/src/components/__tests__/ocr-coords.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { containFit, mapBoxToView } from '@/components/scan/ocr-coords'; - -describe('containFit — mise à l’échelle contenue (M-12, US-OCR-05)', () => { - it('calcule l’échelle sans letterbox pour une image proportionnée', () => { - // Image 1000×1400 affichée dans 400×560 : même ratio (≈ 1/2.5). - const fit = containFit(1000, 1400, 400, 560); - expect(fit).not.toBeNull(); - if (!fit) { - return; - } - expect(fit.scale).toBeCloseTo(0.4); - expect(fit.offsetX).toBeCloseTo(0); - expect(fit.offsetY).toBeCloseTo(0); - }); - - it('centrale la lettrebox horizontalement', () => { - const fit = containFit(2000, 1000, 400, 400); - expect(fit).not.toBeNull(); - if (!fit) { - return; - } - expect(fit.scale).toBeCloseTo(0.2); - expect(fit.offsetX).toBeCloseTo((400 - 400) / 2); // 0 (image déjà proportionnée → vérif ratio) - expect(fit.renderedWidth).toBeCloseTo(400); - expect(fit.renderedHeight).toBeCloseTo(200); - }); - - it('calcule un offset vertical pour une image trop large', () => { - const fit = containFit(1000, 500, 400, 600); - expect(fit).not.toBeNull(); - if (!fit) { - return; - } - expect(fit.scale).toBeCloseTo(0.4); - expect(fit.renderedWidth).toBeCloseTo(400); - expect(fit.renderedHeight).toBeCloseTo(200); - expect(fit.offsetY).toBeCloseTo((600 - 200) / 2); - }); - - it('retourne null sur des dimensions invalides', () => { - expect(containFit(0, 0, 100, 100)).toBeNull(); - expect(containFit(100, 100, 0, 100)).toBeNull(); - }); -}); - -describe('mapBoxToView — projection d’une zone image vers l’écran', () => { - it('met à l’échelle et décale comme containFit', () => { - const fit = containFit(1000, 1400, 400, 560); - expect(fit).not.toBeNull(); - if (!fit) { - return; - } - const box = mapBoxToView({ x: 100, y: 200, width: 300, height: 40 }, fit); - expect(box.x).toBeCloseTo(40); - expect(box.y).toBeCloseTo(80); - expect(box.width).toBeCloseTo(120); - expect(box.height).toBeCloseTo(16); - }); -}); diff --git a/src/components/scan/barcode-overlay-controls.tsx b/src/components/scan/barcode-overlay-controls.tsx index e08e4c0..cf6f874 100644 --- a/src/components/scan/barcode-overlay-controls.tsx +++ b/src/components/scan/barcode-overlay-controls.tsx @@ -30,7 +30,6 @@ export function BarcodeOverlayControls({ return ( <> - {state.status === 'searching' ? ( void; onGoBarcode: () => void; onGoManual: (detected: Partial) => void; onBack: () => void; @@ -26,7 +25,6 @@ export function OcrAnalyzingOverlay({ weakCycles, capturing, torchOn, - onOpenConfirm, onGoBarcode, onGoManual, onBack, @@ -36,8 +34,7 @@ export function OcrAnalyzingOverlay({ return ( <> - - + {hint} @@ -47,50 +44,6 @@ export function OcrAnalyzingOverlay({ Lecture… )} - - {/* Surcouche US-ID-08 : champs détectés en direct. */} - - - Nom - - {detected.publication ?? '…'} - - - - Numéro - - {detected.issueNumber?.toString() ?? '…'} - - - - Édition / date - - {detected.date ?? '…'} - - - - - {hasAnyDetected(detected) && ( - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={onOpenConfirm} - testID="ocr-confirm-detected" - accessibilityRole="button"> - Valider ces informations détectées - - )} diff --git a/src/components/scan/ocr-confirm-overlay.tsx b/src/components/scan/ocr-confirm-overlay.tsx deleted file mode 100644 index 74ddc08..0000000 --- a/src/components/scan/ocr-confirm-overlay.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Pressable, Text, TextInput, View } from 'react-native'; - -import { useThemeColors } from '@/hooks/use-theme'; -import type { DetectedInfo } from './ocr-analysis'; -import type { CameraOcrStyles } from './camera-ocr-styles'; - -type Props = { - styles: CameraOcrStyles; - draft: DetectedInfo; - onDraftChange: (draft: DetectedInfo) => void; - onSearch: () => void; - onGoManual: () => void; - onBack: () => void; -}; - -export function OcrConfirmOverlay({ - styles, - draft, - onDraftChange, - onSearch, - onGoManual, - onBack, -}: Props) { - const colors = useThemeColors(); - - return ( - - - Vérifier les informations - - Corrigez les informations détectées puis validez la recherche, même si la confiance était - insuffisante. - - - - Nom - onDraftChange({ ...draft, publication: t })} - placeholder="Publication du magazine" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-publication" - /> - - - - Numéro - { - const digits = t.replace(/[^0-9]/g, ''); - onDraftChange({ ...draft, issueNumber: digits ? Number(digits) : null }); - }} - placeholder="N° du magazine" - keyboardType="number-pad" - returnKeyType="done" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-issue" - accessibilityLabel="Numéro du magazine (chiffres uniquement)" - /> - - - - Année - onDraftChange({ ...draft, date: t })} - placeholder="Année / date (optionnel)" - keyboardType="default" - placeholderTextColor={colors.textSecondary} - testID="ocr-override-date" - /> - - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={onSearch} - testID="ocr-override-search" - accessibilityRole="button"> - Rechercher - - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={onGoManual} - testID="ocr-override-manual" - accessibilityRole="button"> - Saisir manuellement - - - [styles.cancelButton, pressed && styles.buttonPressed]} - onPress={onBack} - testID="ocr-override-back" - accessibilityRole="button"> - Retour à la caméra - - - - ); -} diff --git a/src/components/scan/ocr-coords.ts b/src/components/scan/ocr-coords.ts deleted file mode 100644 index dcce269..0000000 --- a/src/components/scan/ocr-coords.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Conversion des coordonnées des zones OCR (référentiel image) vers l'écran - * (M-12, US-OCR-05 / M12-05) — modulo **pur et testable**. - * - * La photo est affichée en mode `contain` : on calcule l'échelle et le décalage - * (letterbox) à partir des dimensions de l'image (`width`×`height`) et de la - * zone d'affichage (`viewWidth`×`viewHeight`), puis on projette chaque boîte. - */ - -export type Box = { x: number; y: number; width: number; height: number }; - -export type ContainFit = { - scale: number; - offsetX: number; - offsetY: number; - renderedWidth: number; - renderedHeight: number; -}; - -export function containFit( - imageWidth: number, - imageHeight: number, - viewWidth: number, - viewHeight: number, -): ContainFit | null { - if (imageWidth <= 0 || imageHeight <= 0 || viewWidth <= 0 || viewHeight <= 0) { - return null; - } - const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight); - const renderedWidth = imageWidth * scale; - const renderedHeight = imageHeight * scale; - return { - scale, - offsetX: (viewWidth - renderedWidth) / 2, - offsetY: (viewHeight - renderedHeight) / 2, - renderedWidth, - renderedHeight, - }; -} - -export function mapBoxToView(box: Box, fit: ContainFit): Box { - return { - x: box.x * fit.scale + fit.offsetX, - y: box.y * fit.scale + fit.offsetY, - width: box.width * fit.scale, - height: box.height * fit.scale, - }; -} - -/** Projection complète image → vue (contient + échelle + décalage). */ -export function mapBoxToViewRect( - box: Box, - imageWidth: number, - imageHeight: number, - viewWidth: number, - viewHeight: number, -): Box | null { - const fit = containFit(imageWidth, imageHeight, viewWidth, viewHeight); - return fit ? mapBoxToView(box, fit) : null; -} diff --git a/src/components/scan/ocr-result-overlay.tsx b/src/components/scan/ocr-result-overlay.tsx index ae3fdd2..0981777 100644 --- a/src/components/scan/ocr-result-overlay.tsx +++ b/src/components/scan/ocr-result-overlay.tsx @@ -17,41 +17,44 @@ type Props = { export function OcrResultOverlay({ styles, state, onConfirm, onRetry, onGoManual }: Props) { if (state.status === 'found') { return ( - - - Couverture reconnue - - {state.publication} - - {state.issueNumber != null && N° {state.issueNumber}} - {state.date && {state.date}} - - Confiance : {confidenceLabel(state.confidence)} - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={onConfirm} - testID="ocr-confirm" - accessibilityRole="button"> - Confirmer - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={onRetry} - testID="ocr-retry" - accessibilityRole="button"> - Réessayer - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={onGoManual} - testID="ocr-manual" - accessibilityRole="button"> - Saisie manuelle - + + + + + Couverture reconnue + + {state.publication} + + {state.issueNumber != null && ( + N° {state.issueNumber} + )} + {state.date && {state.date}} + + Confiance : {confidenceLabel(state.confidence)} + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onConfirm} + testID="ocr-confirm" + accessibilityRole="button"> + Confirmer + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="ocr-retry" + accessibilityRole="button"> + Réessayer + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-manual" + accessibilityRole="button"> + Saisie manuelle + + + ); @@ -59,32 +62,35 @@ export function OcrResultOverlay({ styles, state, onConfirm, onRetry, onGoManual if (state.status === 'unknown') { return ( - - - Non trouvé en collection - {state.publication} - {state.issueNumber != null && N° {state.issueNumber}} - - {state.publication} n'est pas encore référencé. Vous pouvez le saisir manuellement - pour le créer. - - [styles.primaryButton, pressed && styles.buttonPressed]} - onPress={onGoManual} - testID="ocr-manual" - accessibilityRole="button"> - Saisir manuellement - - [styles.secondaryButton, pressed && styles.buttonPressed]} - onPress={onRetry} - testID="ocr-retry" - accessibilityRole="button"> - Réessayer avec la caméra - + + + + + Non trouvé en collection + {state.publication} + {state.issueNumber != null && ( + N° {state.issueNumber} + )} + + {state.publication} n'est pas encore référencé. Vous pouvez le saisir + manuellement pour le créer. + + [styles.primaryButton, pressed && styles.buttonPressed]} + onPress={onGoManual} + testID="ocr-manual" + accessibilityRole="button"> + Saisir manuellement + + [styles.secondaryButton, pressed && styles.buttonPressed]} + onPress={onRetry} + testID="ocr-retry" + accessibilityRole="button"> + Réessayer avec la caméra + + + ); diff --git a/src/components/scan/use-ocr-analysis.ts b/src/components/scan/use-ocr-analysis.ts index 09dc639..f96d202 100644 --- a/src/components/scan/use-ocr-analysis.ts +++ b/src/components/scan/use-ocr-analysis.ts @@ -30,7 +30,6 @@ export function useOcrAnalysis() { status: 'analyzing', detected: EMPTY_DETECTED, }); - const [draft, setDraft] = useState(EMPTY_DETECTED); const [torchOn, setTorchOn] = useState(false); const [weakCycles, setWeakCycles] = useState(0); const [capturing, setCapturing] = useState(false); @@ -170,13 +169,6 @@ export function useOcrAnalysis() { setState({ status: 'analyzing', detected: EMPTY_DETECTED }); }; - const openConfirm = () => { - const detected = - state.status === 'analyzing' || state.status === 'confirm' ? state.detected : EMPTY_DETECTED; - setDraft(detected); - setState({ status: 'confirm', detected }); - }; - const goManual = (detected: Partial) => { router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) }); }; @@ -185,64 +177,20 @@ export function useOcrAnalysis() { router.replace('/scan/barcode'); }; - /** US-ID-09 : recherche en outrepassant la confiance (champs validés/corrigés). */ - const searchFromDraft = async () => { - const publication = draft.publication?.trim() ?? ''; - const rawNumber = draft.issueNumber?.toString().trim() ?? ''; - const issueNumber = rawNumber ? Number(rawNumber) : null; - const date = draft.date?.trim() || null; - - if (!publication || issueNumber === null || !Number.isFinite(issueNumber)) { - // Impossible de rechercher : on oriente vers la saisie manuelle pré-remplie. - goManual({ publication: publication || undefined, issueNumber, date }); - return; - } - - const { identificationService } = getDeps(); - const result = await identificationService.searchByOcrFields(publication, issueNumber, date); - - if (result.status === 'weak' || result.status === 'no-text') { - goManual({ publication, issueNumber, date }); - return; - } - if (result.status === 'unknown') { - setState({ - status: 'unknown', - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - return; - } - setState({ - status: 'found', - id: result.magazine.id, - publication: result.publication, - issueNumber: result.issueNumber, - date: result.date, - confidence: result.confidence, - }); - }; - return { cameraRef, permission, requestPermission, state, - draft, torchOn, weakCycles, capturing, debugFrame, ocrDebug, setTorchOn, - setDraft, capture, stopAndRetry, - openConfirm, goManual, goBarcode, - searchFromDraft, }; } From bcac3b42377ba0bd0e9f06d0b26992dd6659e3a2 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Fri, 11 Sep 2026 08:13:36 +0200 Subject: [PATCH 29/34] =?UTF-8?q?fix(magazine):=20d=C3=A9placer=20le=20cha?= =?UTF-8?q?mp=20code-barres=20dans=20la=20premi=C3=A8re=20partie=20du=20fo?= =?UTF-8?q?rmulaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/magazine-form.test.tsx | 7 ++- src/components/magazine-form.tsx | 58 ++++++++----------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/components/__tests__/magazine-form.test.tsx b/src/components/__tests__/magazine-form.test.tsx index cafc03d..71d58e1 100644 --- a/src/components/__tests__/magazine-form.test.tsx +++ b/src/components/__tests__/magazine-form.test.tsx @@ -49,6 +49,10 @@ describe('MagazineForm', () => { expect(screen.queryByTestId('field-notes')).toBeNull(); expect(screen.queryByTestId('select-month')).toBeNull(); + // le code-barres fait partie des champs essentiels (toujours visible) + expect(screen.getByTestId('field-barcode')).toBeTruthy(); + expect(screen.getByTestId('barcode-scan')).toBeTruthy(); + await user.press(screen.getByTestId('details-toggle')); expect(screen.getByTestId('field-notes')).toBeTruthy(); @@ -138,7 +142,6 @@ describe('MagazineForm', () => { const onSubmit = jest.fn(); render(); - fireEvent.press(screen.getByTestId('details-toggle')); fireEvent.press(screen.getByTestId('barcode-scan')); expect(mockPush).toHaveBeenCalledWith('/scan/form-barcode'); }); @@ -148,7 +151,6 @@ describe('MagazineForm', () => { setPendingBarcode('3271234000011'); render(); - fireEvent.press(screen.getByTestId('details-toggle')); expect(screen.getByTestId('field-barcode').props.value).toBe('3271234000011'); }); @@ -159,7 +161,6 @@ describe('MagazineForm', () => { render( , ); - fireEvent.press(screen.getByTestId('details-toggle')); expect(screen.getByTestId('field-barcode').props.value).toBe('5901234123457'); }); diff --git a/src/components/magazine-form.tsx b/src/components/magazine-form.tsx index d20397c..33cd072 100644 --- a/src/components/magazine-form.tsx +++ b/src/components/magazine-form.tsx @@ -137,6 +137,30 @@ export function MagazineForm({ accessibilityLabel="Édition" /> + Code-barres + + set('barcode', v)} + placeholder="Ex : 3271234000011" + keyboardType="default" + autoCapitalize="characters" + placeholderTextColor={colors.textSecondary} + testID="field-barcode" + accessibilityLabel="Code-barres" + /> + [styles.scanButton, pressed && styles.buttonPressed]} + onPress={openBarcodeScanner} + testID="barcode-scan" + accessibilityRole="button" + accessibilityLabel="Scanner le code-barres"> + + Scanner + + + {/* Bouton « Plus de détails » */} [styles.detailsToggle, pressed && styles.buttonPressed]} @@ -156,13 +180,7 @@ export function MagazineForm({ {detailsOpen ? ( - + ) : null} {formError ? ( @@ -196,13 +214,11 @@ function DetailsSection({ set, editions, languages, - onScanBarcode, }: { values: FormValues; set: (key: keyof FormValues, value: string | null) => void; editions: string[]; languages: string[]; - onScanBarcode: () => void; }) { const colors = useThemeColors(); const styles = makeMagazineFormStyles(colors); @@ -254,30 +270,6 @@ function DetailsSection({ - Code-barres - - set('barcode', v)} - placeholder="Ex : 3271234000011" - keyboardType="default" - autoCapitalize="characters" - placeholderTextColor={colors.textSecondary} - testID="field-barcode" - accessibilityLabel="Code-barres" - /> - [styles.scanButton, pressed && styles.buttonPressed]} - onPress={onScanBarcode} - testID="barcode-scan" - accessibilityRole="button" - accessibilityLabel="Scanner le code-barres"> - - Scanner - - - Notes Date: Fri, 11 Sep 2026 08:13:36 +0200 Subject: [PATCH 30/34] =?UTF-8?q?feat(search):=20rechercher=20par=20=C3=A9?= =?UTF-8?q?dition=20et=20num=C3=A9ro=20directement=20dans=20la=20collectio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - liste déroulante d'éditions (défaut : toutes les éditions) à la place du champ nom - recherche en mémoire dans la collection par numéro + édition optionnelle - repli vers la saisie manuelle pré-remplie avec le numéro transmis en paramètre --- src/__tests__/app/scan-search-screen.test.tsx | 63 +++++-------- src/app/scan/search.tsx | 92 ++++++++++--------- .../__tests__/scan-search-views.test.tsx | 20 ++-- src/components/scan-search-views.tsx | 10 +- 4 files changed, 85 insertions(+), 100 deletions(-) diff --git a/src/__tests__/app/scan-search-screen.test.tsx b/src/__tests__/app/scan-search-screen.test.tsx index 857ade1..56c017f 100644 --- a/src/__tests__/app/scan-search-screen.test.tsx +++ b/src/__tests__/app/scan-search-screen.test.tsx @@ -7,7 +7,6 @@ import type { Magazine } from '@/types'; const mockBack = jest.fn(); const mockReplace = jest.fn(); -const mockSearchByOcrFields = jest.fn(); jest.mock('expo-router', () => ({ useRouter: () => ({ back: mockBack, replace: mockReplace, canGoBack: () => true }), @@ -17,7 +16,7 @@ const magazine: Magazine = { id: 'mag-1', publication: 'Picsou Magazine', issueNumber: 547, - edition: null, + edition: 'standard', language: null, condition: null, publicationDate: null, @@ -35,9 +34,7 @@ function stubDeps() { getColorScheme: jest.fn().mockResolvedValue('system'), setColorScheme: jest.fn().mockResolvedValue(undefined), } as unknown as Dependencies['settingsRepository'], - identificationService: { - searchByOcrFields: mockSearchByOcrFields, - } as unknown as Dependencies['identificationService'], + identificationService: {} as Dependencies['identificationService'], ocrEngine: {} as Dependencies['ocrEngine'], backupService: {} as Dependencies['backupService'], fileGateway: {} as Dependencies['fileGateway'], @@ -48,60 +45,55 @@ describe('ScanSearchScreen', () => { beforeEach(() => { mockBack.mockClear(); mockReplace.mockClear(); - mockSearchByOcrFields.mockResolvedValue({ status: 'no-text' }); stubDeps(); useCollectionStore.setState({ magazines: [magazine], }); }); - it('désactive la recherche tant que publication et numéro sont vides', () => { + it('désactive la recherche tant que le numéro est vide', () => { render(); expect(screen.getByTestId('search-hint')).toBeTruthy(); fireEvent.press(screen.getByTestId('search-submit')); - expect(mockSearchByOcrFields).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); }); - it('recherche par publication + numéro et affiche le résultat trouvé (possédé)', async () => { - mockSearchByOcrFields.mockResolvedValue({ - status: 'found', - magazine, - publication: 'Picsou Magazine', - issueNumber: 547, - date: null, - confidence: 1, - }); - + it('recherche par numéro toutes éditions confondues et affiche le résultat trouvé', async () => { render(); await act(async () => { - fireEvent(screen.getByTestId('search-publication'), 'onChangeText', 'Picsou Magazine'); fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '547'); }); await act(async () => { fireEvent.press(screen.getByTestId('search-submit')); }); - expect(mockSearchByOcrFields).toHaveBeenCalledWith('Picsou Magazine', 547, null); expect(screen.getByTestId('search-magazine')).toHaveTextContent('Picsou Magazine'); expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé'); fireEvent.press(screen.getByTestId('search-view')); expect(mockReplace).toHaveBeenCalledWith('/collection/mag-1'); }); - it('propose le repli vers la saisie manuelle pré-remplie quand rien n est trouvé', async () => { - mockSearchByOcrFields.mockResolvedValue({ - status: 'unknown', - publication: 'Picsou Magazine', - issueNumber: 547, - date: null, - confidence: 1, + it('recherche par numéro + édition spécifique', async () => { + render(); + fireEvent.press(screen.getByTestId('search-edition')); + fireEvent.press(screen.getByTestId('search-edition-option-standard')); + await act(async () => { + fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '547'); + }); + await act(async () => { + fireEvent.press(screen.getByTestId('search-submit')); }); + expect(screen.getByTestId('search-magazine')).toHaveTextContent('Picsou Magazine'); + }); + + it('propose le repli vers la saisie manuelle pré-remplie quand rien n est trouvé', async () => { render(); + fireEvent.press(screen.getByTestId('search-edition')); + fireEvent.press(screen.getByTestId('search-edition-option-standard')); await act(async () => { - fireEvent(screen.getByTestId('search-publication'), 'onChangeText', 'Picsou Magazine'); - fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '547'); + fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '972'); }); await act(async () => { fireEvent.press(screen.getByTestId('search-submit')); @@ -111,23 +103,14 @@ describe('ScanSearchScreen', () => { fireEvent.press(screen.getByTestId('search-manual')); expect(mockReplace).toHaveBeenCalledWith({ pathname: '/scan/manual', - params: { publication: 'Picsou Magazine', issueNumber: '547' }, + params: { issueNumber: '972' }, }); }); it('relance une recherche après un résultat non référencé', async () => { - mockSearchByOcrFields.mockResolvedValue({ - status: 'unknown', - publication: 'Picsou Magazine', - issueNumber: 547, - date: null, - confidence: 1, - }); - render(); await act(async () => { - fireEvent(screen.getByTestId('search-publication'), 'onChangeText', 'Picsou Magazine'); - fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '547'); + fireEvent(screen.getByTestId('search-issue'), 'onChangeText', '972'); }); await act(async () => { fireEvent.press(screen.getByTestId('search-submit')); diff --git a/src/app/scan/search.tsx b/src/app/scan/search.tsx index 16249e7..11d9102 100644 --- a/src/app/scan/search.tsx +++ b/src/app/scan/search.tsx @@ -3,8 +3,8 @@ import { useState } from 'react'; import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'; import { Feather } from '@expo/vector-icons'; -import { AutocompleteInput } from '@/components/autocomplete-input'; import { Screen } from '@/components/screen'; +import { SelectField } from '@/components/select-field'; import { HitTarget } from '@/constants/theme'; import { SearchFoundResult, @@ -12,12 +12,19 @@ import { makeSearchResultStyles, makeSearchScreenStyles, } from '@/components/scan-search-views'; -import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; import { useCollectionStore } from '@/store/use-collection-store'; -import type { OcrLookupResult } from '@/identification/identificationService'; const EMPTY_ISSUE = ''; +const ALL_EDITIONS = 'Toutes les éditions'; + +type FoundResult = { + status: 'found'; + magazineId: string; + publication: string; + issueNumber: number; +}; +type UnknownResult = { status: 'unknown'; edition: string | null; issueNumber: number }; export default function ScanSearchScreen() { const router = useRouter(); @@ -25,43 +32,41 @@ export default function ScanSearchScreen() { const screenStyles = makeSearchScreenStyles(colors); const magazines = useCollectionStore((s) => s.magazines); - const [publication, setPublication] = useState(''); + const [edition, setEdition] = useState(null); const [issueNumber, setIssueNumber] = useState(EMPTY_ISSUE); const [searching, setSearching] = useState(false); - const [result, setResult] = useState | null>(null); - - const publications = [...new Set(magazines.map((m) => m.publication))]; + const [result, setResult] = useState(null); - const foundId = result?.status === 'found' ? result.magazine.id : null; + const editions = [...new Set(magazines.map((m) => m.edition).filter((e): e is string => !!e))]; const issue = issueNumber.replace(/[^0-9]/g, ''); - const canSearch = publication.trim().length > 0 && issue.length > 0 && !searching; + const canSearch = issue.length > 0 && !searching; - const search = async () => { + const search = () => { if (!canSearch) { return; } setSearching(true); - const { identificationService } = getDeps(); - const resultNow = await identificationService.searchByOcrFields( - publication.trim(), - Number(issue), - null, + const query = Number(issue); + const matches = magazines.filter( + (m) => m.issueNumber === query && (edition === null || m.edition === edition), ); - if (resultNow.status === 'found' || resultNow.status === 'unknown') { - setResult(resultNow); + if (matches.length > 0) { + setResult({ + status: 'found', + magazineId: matches[0].id, + publication: matches[0].publication, + issueNumber: query, + }); } else { - setResult(null); + setResult({ status: 'unknown', edition, issueNumber: query }); } setSearching(false); }; const resetForm = () => { setResult(null); - setPublication(''); + setEdition(null); setIssueNumber(EMPTY_ISSUE); }; @@ -76,8 +81,7 @@ export default function ScanSearchScreen() { const handleManual = () => { router.replace({ pathname: '/scan/manual', - params: - publication.trim() || issue ? { publication: publication.trim(), issueNumber: issue } : {}, + params: issue ? { issueNumber: issue } : {}, }); }; @@ -102,12 +106,12 @@ export default function ScanSearchScreen() { {!result ? ( @@ -115,12 +119,12 @@ export default function ScanSearchScreen() { router.replace(`/collection/${foundId}`)} + onView={() => router.replace(`/collection/${result.magazineId}`)} onRescan={() => router.replace('/scan/barcode')} /> ) : ( void; + onEditionChange: (v: string | null) => void; onIssueChange: (v: string) => void; onSearch: () => void; }) { @@ -156,14 +160,14 @@ function SearchForm({ return ( - Numéro @@ -194,7 +198,7 @@ function SearchForm({ {!canSearch && !searching ? ( - Renseignez la publication et le numéro pour rechercher. + Renseignez le numéro pour rechercher. ) : null} diff --git a/src/components/__tests__/scan-search-views.test.tsx b/src/components/__tests__/scan-search-views.test.tsx index d305a0b..cb249eb 100644 --- a/src/components/__tests__/scan-search-views.test.tsx +++ b/src/components/__tests__/scan-search-views.test.tsx @@ -61,17 +61,17 @@ describe('SearchUnknownResult', () => { const onManual = jest.fn(); const onAgain = jest.fn(); - it('affiche la publication, avec ou sans numéro', async () => { + it('affiche le numéro et l’édition recherchés, avec ou sans édition', async () => { const user = userEvent.setup(); render( , ); - expect(screen.getByText(/Picsou Magazine/)).toBeTruthy(); + expect(screen.getByText(/Édition « géant »/)).toBeTruthy(); expect(screen.getByText('N° 547')).toBeTruthy(); await user.press(screen.getByTestId('search-manual')); @@ -80,15 +80,9 @@ describe('SearchUnknownResult', () => { expect(onAgain).toHaveBeenCalled(); }); - it('s’affiche sans numéro quand celui-ci est absent', () => { - render( - , - ); - expect(screen.queryByText('N° 547')).toBeNull(); + it('s’affiche sans édition ni numéro', () => { + render(); + expect(screen.queryByText(/\u00C9dition/)).toBeNull(); + expect(screen.getByText('N° 547')).toBeTruthy(); }); }); diff --git a/src/components/scan-search-views.tsx b/src/components/scan-search-views.tsx index 3249ae2..99cbc82 100644 --- a/src/components/scan-search-views.tsx +++ b/src/components/scan-search-views.tsx @@ -139,14 +139,14 @@ export function SearchFoundResult({ } type SearchUnknownResultProps = { - publication: string; + edition?: string | null; issueNumber: number | null; onManual: () => void; onAgain: () => void; }; export function SearchUnknownResult({ - publication, + edition, issueNumber, onManual, onAgain, @@ -154,12 +154,16 @@ export function SearchUnknownResult({ const colors = useThemeColors(); const styles = makeSearchResultStyles(colors); + const editionLabel = edition ? ` · Édition « ${edition} »` : ''; + const numberLabel = issueNumber != null ? ` N° ${issueNumber}` : ''; + return ( Non référencé - « {publication} » n'est pas encore dans votre collection. + Aucun magazine ne correspond au numéro{numberLabel} + {editionLabel} dans votre collection. {issueNumber != null ? N° {issueNumber} : null} From 095f6b48cecff94778a10a82b04eda984fb79fdb Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Fri, 11 Sep 2026 08:13:39 +0200 Subject: [PATCH 31/34] =?UTF-8?q?chore(data):=20exemple=20de=20collection?= =?UTF-8?q?=20au=20format=20v2,=20une=20ligne=20par=20=C3=A9dition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - déduplication des lignes correspondant à plusieurs exemplaires d'une même édition - en-têtes v2 (publication..ocrText, createdAt, updatedAt) alignés sur 06-DATA-MODEL --- collection-exemple.csv | 69 ++++++++++++++++++++++++++++++++++++++ collection-fictive.csv | 75 ------------------------------------------ docs/06-DATA-MODEL.md | 18 +++++----- 3 files changed, 78 insertions(+), 84 deletions(-) create mode 100644 collection-exemple.csv delete mode 100644 collection-fictive.csv diff --git a/collection-exemple.csv b/collection-exemple.csv new file mode 100644 index 0000000..a23e030 --- /dev/null +++ b/collection-exemple.csv @@ -0,0 +1,69 @@ +publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,createdAt,updatedAt +Picsou Magazine,540,standard,FR,Bon,2024-01,3271234540017,,,2024-01-15T10:00:00Z,2024-01-15T10:00:00Z +Picsou Magazine,541,standard,FR,Bon,2024-02,3271234541014,,,2024-02-12T10:00:00Z,2024-02-12T10:00:00Z +Picsou Magazine,542,standard,FR,Très bon,2024-03,3271234542011,,,2024-03-10T10:00:00Z,2024-03-10T10:00:00Z +Picsou Magazine,543,standard,FR,Neuf,2024-04,3271234543018,,,2024-04-14T10:00:00Z,2024-04-14T10:00:00Z +Picsou Magazine,544,standard,FR,Bon,2024-05,3271234544015,,,2024-05-11T10:00:00Z,2024-05-11T10:00:00Z +Picsou Magazine,545,standard,FR,Usé,2024-06,3271234545012,,,2024-06-09T10:00:00Z,2024-06-09T10:00:00Z +Picsou Magazine,546,standard,FR,Bon,2024-07,3271234546019,,,2024-07-13T10:00:00Z,2024-07-13T10:00:00Z +Picsou Magazine,547,standard,FR,Neuf,2024-08,3271234547016,,PICSOU MAGAZINE 547,2024-08-10T10:00:00Z,2024-08-10T10:00:00Z +Picsou Magazine,548,standard,FR,Bon,2024-09,3271234548013,,,2024-09-14T10:00:00Z,2024-09-14T10:00:00Z +Picsou Magazine,549,standard,FR,Très bon,2024-10,3271234549010,,,2024-10-12T10:00:00Z,2024-10-12T10:00:00Z +Picsou Magazine,550,standard,FR,Neuf,2024-11,3271234550019,,,2024-11-09T10:00:00Z,2024-11-09T10:00:00Z +Picsou Magazine,551,standard,FR,Bon,2024-12,3271234551016,,,2024-12-14T10:00:00Z,2024-12-14T10:00:00Z +Picsou Magazine,552,standard,FR,Bon,2025-01,3271234552013,,,2025-01-11T10:00:00Z,2025-01-11T10:00:00Z +Picsou Magazine,553,standard,FR,Très bon,2025-02,3271234553010,,,2025-02-08T10:00:00Z,2025-02-08T10:00:00Z +Picsou Magazine,554,standard,FR,Neuf,2025-03,3271234554017,,,2025-03-08T10:00:00Z,2025-03-08T10:00:00Z +Picsou Magazine,555,standard,FR,Bon,2025-04,3271234555014,,,2025-04-12T10:00:00Z,2025-04-12T10:00:00Z +Picsou Magazine,556,standard,FR,Usé,2025-05,3271234556011,"Couverture froissée, manque le poster",,2025-05-10T10:00:00Z,2025-05-10T10:00:00Z +Picsou Magazine,557,standard,FR,Bon,2025-06,3271234557018,,,2025-06-14T10:00:00Z,2025-06-14T10:00:00Z +Picsou Magazine,558,standard,FR,Neuf,2025-07,3271234558015,"Acheté 3 €, état ""neuf"" selon vendeur",,2025-07-12T10:00:00Z,2025-07-12T10:00:00Z +Picsou Magazine,559,standard,FR,Bon,2025-08,3271234559012,,,2025-08-09T10:00:00Z,2025-08-09T10:00:00Z +Picsou Magazine,560,standard,FR,Très bon,2025-09,3271234560018,,,2025-09-13T10:00:00Z,2025-09-13T10:00:00Z +Picsou Magazine,561,standard,FR,Neuf,2025-10,3271234561015,,,2025-10-11T10:00:00Z,2025-10-11T10:00:00Z +Picsou Magazine,562,standard,FR,Bon,2025-11,3271234562012,,,2025-11-08T10:00:00Z,2025-11-08T10:00:00Z +Picsou Magazine,563,standard,FR,Neuf,2025-12,3271234563019,,,2025-12-13T10:00:00Z,2025-12-13T10:00:00Z +Picsou Magazine,564,standard,FR,Bon,2026-01,3271234564016,,,2026-01-10T10:00:00Z,2026-01-10T10:00:00Z +Picsou Magazine,565,standard,FR,Très bon,2026-02,3271234565013,,,2026-02-14T10:00:00Z,2026-02-14T10:00:00Z +Picsou Magazine,12,standard,FR,Abîmé,1972-03,3271234000012,"Dos décollé, pages jaunies",,2023-05-01T10:00:00Z,2023-05-01T10:00:00Z +Picsou Magazine,89,standard,FR,Usé,1985-07,3271234000089,,,2023-05-01T10:00:00Z,2023-05-01T10:00:00Z +Picsou Magazine,600,standard,FR,Neuf,2026-03,3271234000011,,,2026-03-08T10:00:00Z,2026-03-08T10:00:00Z +Super Picsou Géant,200,géant,FR,Neuf,2026-02,3271234000011,,SUPER PICSOU GEANT 200,2026-02-20T10:00:00Z,2026-02-20T10:00:00Z +Super Picsou Géant,201,géant,FR,Très bon,2024-03,3271234201016,,,2024-03-15T10:00:00Z,2024-03-15T10:00:00Z +Super Picsou Géant,202,géant,FR,Bon,2024-05,3271234202013,,,2024-05-18T10:00:00Z,2024-05-18T10:00:00Z +Super Picsou Géant,203,géant,FR,Neuf,2024-07,3271234203010,,,2024-07-20T10:00:00Z,2024-07-20T10:00:00Z +Super Picsou Géant,204,géant,FR,Bon,2024-09,3271234204017,,,2024-09-21T10:00:00Z,2024-09-21T10:00:00Z +Super Picsou Géant,205,géant,FR,Usé,2024-11,3271234205014,,,2024-11-16T10:00:00Z,2024-11-16T10:00:00Z +Super Picsou Géant,206,géant,FR,Très bon,2025-01,3271234206011,,,2025-01-18T10:00:00Z,2025-01-18T10:00:00Z +Super Picsou Géant,207,géant,FR,Neuf,2025-04,3271234207018,,,2025-04-19T10:00:00Z,2025-04-19T10:00:00Z +Super Picsou Géant,208,géant,FR,Bon,2025-07,3271234208015,,,2025-07-19T10:00:00Z,2025-07-19T10:00:00Z +Super Picsou Géant,209,géant,FR,Neuf,2025-10,3271234209012,,,2025-10-18T10:00:00Z,2025-10-18T10:00:00Z +Super Picsou Géant,210,géant,FR,Très bon,2025-12,3271234210018,,,2025-12-20T10:00:00Z,2025-12-20T10:00:00Z +Super Picsou Géant,185,géant,FR,Bon,1998-06,3271234185019,,,2023-06-10T10:00:00Z,2023-06-10T10:00:00Z +Super Picsou Géant,150,géant,FR,Usé,1990-11,3271234150018,Tampon brocante sur la couverture,,2023-06-10T10:00:00Z,2023-06-10T10:00:00Z +Mickey Parade,300,standard,FR,Bon,2023-01,3271234300016,,,2023-02-01T10:00:00Z,2023-02-01T10:00:00Z +Mickey Parade,301,standard,FR,Très bon,2023-03,3271234301013,,,2023-03-15T10:00:00Z,2023-03-15T10:00:00Z +Mickey Parade,302,standard,FR,Neuf,2023-05,3271234302010,,,2023-05-17T10:00:00Z,2023-05-17T10:00:00Z +Mickey Parade,303,standard,FR,Bon,2023-07,3271234303017,,,2023-07-19T10:00:00Z,2023-07-19T10:00:00Z +Mickey Parade,304,standard,FR,Usé,2023-09,3271234304014,,,2023-09-20T10:00:00Z,2023-09-20T10:00:00Z +Mickey Parade,305,standard,FR,Bon,2023-11,3271234305011,,,2023-11-15T10:00:00Z,2023-11-15T10:00:00Z +Mickey Parade,306,standard,FR,Très bon,2024-01,3271234306018,,,2024-01-17T10:00:00Z,2024-01-17T10:00:00Z +Mickey Parade,307,standard,FR,Neuf,2024-04,3271234307015,,,2024-04-17T10:00:00Z,2024-04-17T10:00:00Z +Mickey Parade,308,standard,FR,Bon,2024-06,3271234308012,,,2024-06-19T10:00:00Z,2024-06-19T10:00:00Z +Mickey Parade,309,standard,FR,Bon,2024-09,3271234309019,,,2024-09-18T10:00:00Z,2024-09-18T10:00:00Z +Mickey Parade,310,standard,FR,Neuf,2024-11,3271234310015,,,2024-11-20T10:00:00Z,2024-11-20T10:00:00Z +Mickey Parade,311,standard,FR,Très bon,2025-02,3271234311012,,,2025-02-19T10:00:00Z,2025-02-19T10:00:00Z +Mickey Parade,312,import,EN,Bon,2025-05,3271234312019,Édition anglaise,,2025-05-21T10:00:00Z,2025-05-21T10:00:00Z +Le Journal de Mickey,3500,hebdo,FR,Bon,2024-01,3271234350019,,,2024-01-20T10:00:00Z,2024-01-20T10:00:00Z +Le Journal de Mickey,3501,hebdo,FR,Bon,2024-02,3271234351016,,,2024-02-17T10:00:00Z,2024-02-17T10:00:00Z +Le Journal de Mickey,3502,hebdo,FR,Très bon,2024-03,3271234352013,,,2024-03-16T10:00:00Z,2024-03-16T10:00:00Z +Le Journal de Mickey,3503,hebdo,FR,Neuf,2024-04,3271234353010,,,2024-04-20T10:00:00Z,2024-04-20T10:00:00Z +Le Journal de Mickey,3504,hebdo,FR,Bon,2024-05,3271234354017,,,2024-05-18T10:00:00Z,2024-05-18T10:00:00Z +Le Journal de Mickey,3505,hebdo,FR,Usé,2024-06,3271234355014,,,2024-06-15T10:00:00Z,2024-06-15T10:00:00Z +Le Journal de Mickey,3506,hebdo,FR,Bon,2024-07,3271234356011,,,2024-07-20T10:00:00Z,2024-07-20T10:00:00Z +Le Journal de Mickey,3507,hebdo,FR,Neuf,2024-08,3271234357018,,,2024-08-17T10:00:00Z,2024-08-17T10:00:00Z +Les Trésors de Picsou — Édition Collector 75e Anniversaire,1,collector,FR,Neuf,2024-06,3271234400019,Coffret rigide avec certificat,,2024-06-22T10:00:00Z,2024-06-22T10:00:00Z +Les Trésors de Picsou — Édition Collector 75e Anniversaire,2,collector,FR,Neuf,2024-12,3271234400026,,,2024-12-21T10:00:00Z,2024-12-21T10:00:00Z +Super Picsou Géant Hors-Série Spécial Été,1,hors-série,FR,Très bon,2025-07,3271234400071,,,2025-07-26T10:00:00Z,2025-07-26T10:00:00Z +Picsou Hors-Série,,Spécial Été,FR,Très bon,2025-07,3271234400101,,,2025-07-26T10:00:00Z,2025-07-26T10:00:00Z +Picsou Hors-Série,5,Spécial Hiver,FR,Neuf,2025-12,3271234400102,,,2025-12-24T10:00:00Z,2025-12-24T10:00:00Z \ No newline at end of file diff --git a/collection-fictive.csv b/collection-fictive.csv deleted file mode 100644 index ab53347..0000000 --- a/collection-fictive.csv +++ /dev/null @@ -1,75 +0,0 @@ -publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded -Picsou Magazine,540,standard,FR,Bon,2024-01,3271234540017,,,,2024-01-15T10:00:00Z -Picsou Magazine,541,standard,FR,Bon,2024-02,3271234541014,,,,2024-02-12T10:00:00Z -Picsou Magazine,542,standard,FR,Très bon,2024-03,3271234542011,,,,2024-03-10T10:00:00Z -Picsou Magazine,543,standard,FR,Neuf,2024-04,3271234543018,,,,2024-04-14T10:00:00Z -Picsou Magazine,544,standard,FR,Bon,2024-05,3271234544015,,,Acheté en lot,2024-05-11T10:00:00Z -Picsou Magazine,545,standard,FR,Usé,2024-06,3271234545012,,,,2024-06-09T10:00:00Z -Picsou Magazine,546,standard,FR,Bon,2024-07,3271234546019,,,,2024-07-13T10:00:00Z -Picsou Magazine,547,standard,FR,Neuf,2024-08,3271234547016,,PICSOU MAGAZINE 547,,2024-08-10T10:00:00Z -Picsou Magazine,547,standard,FR,Neuf,2024-08,3271234547016,,,Doublon brocante de Lille,2024-09-01T10:00:00Z -Picsou Magazine,547,standard,FR,Neuf,2024-08,3271234547016,,,,2025-03-22T10:00:00Z -Picsou Magazine,548,standard,FR,Bon,2024-09,3271234548013,,,,2024-09-14T10:00:00Z -Picsou Magazine,549,standard,FR,Très bon,2024-10,3271234549010,,,,2024-10-12T10:00:00Z -Picsou Magazine,550,standard,FR,Neuf,2024-11,3271234550019,,,,2024-11-09T10:00:00Z -Picsou Magazine,551,standard,FR,Bon,2024-12,3271234551016,,,,2024-12-14T10:00:00Z -Picsou Magazine,552,standard,FR,Bon,2025-01,3271234552013,,,,2025-01-11T10:00:00Z -Picsou Magazine,552,standard,FR,Bon,2025-01,3271234552013,,,Échange avec un ami,2025-06-15T10:00:00Z -Picsou Magazine,553,standard,FR,Très bon,2025-02,3271234553010,,,,2025-02-08T10:00:00Z -Picsou Magazine,554,standard,FR,Neuf,2025-03,3271234554017,,,,2025-03-08T10:00:00Z -Picsou Magazine,555,standard,FR,Bon,2025-04,3271234555014,,,,2025-04-12T10:00:00Z -Picsou Magazine,556,standard,FR,Usé,2025-05,3271234556011,"Couverture froissée, manque le poster",,,2025-05-10T10:00:00Z -Picsou Magazine,557,standard,FR,Bon,2025-06,3271234557018,,,,2025-06-14T10:00:00Z -Picsou Magazine,558,standard,FR,Neuf,2025-07,3271234558015,"Acheté 3 €, état ""neuf"" selon vendeur",,,2025-07-12T10:00:00Z -Picsou Magazine,559,standard,FR,Bon,2025-08,3271234559012,,,,2025-08-09T10:00:00Z -Picsou Magazine,560,standard,FR,Très bon,2025-09,3271234560018,,,,2025-09-13T10:00:00Z -Picsou Magazine,561,standard,FR,Neuf,2025-10,3271234561015,,,,2025-10-11T10:00:00Z -Picsou Magazine,562,standard,FR,Bon,2025-11,3271234562012,,,,2025-11-08T10:00:00Z -Picsou Magazine,563,standard,FR,Neuf,2025-12,3271234563019,,,,2025-12-13T10:00:00Z -Picsou Magazine,564,standard,FR,Bon,2026-01,3271234564016,,,,2026-01-10T10:00:00Z -Picsou Magazine,565,standard,FR,Très bon,2026-02,3271234565013,,,,2026-02-14T10:00:00Z -Picsou Magazine,12,standard,FR,Abîmé,1972-03,3271234000012,"Dos décollé, pages jaunies",,,2023-05-01T10:00:00Z -Picsou Magazine,89,standard,FR,Usé,1985-07,3271234000089,,,,2023-05-01T10:00:00Z -Picsou Magazine,600,standard,FR,Neuf,2026-03,3271234000011,,,,2026-03-08T10:00:00Z -Super Picsou Géant,200,géant,FR,Neuf,2026-02,3271234000011,,SUPER PICSOU GEANT 200,,2026-02-20T10:00:00Z -Super Picsou Géant,200,géant,FR,Neuf,2026-02,3271234000011,,,Doublon vide-grenier,2026-04-05T10:00:00Z -Super Picsou Géant,201,géant,FR,Très bon,2024-03,3271234201016,,,,2024-03-15T10:00:00Z -Super Picsou Géant,202,géant,FR,Bon,2024-05,3271234202013,,,,2024-05-18T10:00:00Z -Super Picsou Géant,203,géant,FR,Neuf,2024-07,3271234203010,,,,2024-07-20T10:00:00Z -Super Picsou Géant,204,géant,FR,Bon,2024-09,3271234204017,,,,2024-09-21T10:00:00Z -Super Picsou Géant,205,géant,FR,Usé,2024-11,3271234205014,,,,2024-11-16T10:00:00Z -Super Picsou Géant,206,géant,FR,Très bon,2025-01,3271234206011,,,,2025-01-18T10:00:00Z -Super Picsou Géant,207,géant,FR,Neuf,2025-04,3271234207018,,,,2025-04-19T10:00:00Z -Super Picsou Géant,208,géant,FR,Bon,2025-07,3271234208015,,,,2025-07-19T10:00:00Z -Super Picsou Géant,209,géant,FR,Neuf,2025-10,3271234209012,,,,2025-10-18T10:00:00Z -Super Picsou Géant,210,géant,FR,Très bon,2025-12,3271234210018,,,,2025-12-20T10:00:00Z -Super Picsou Géant,185,géant,FR,Bon,1998-06,3271234185019,,,,2023-06-10T10:00:00Z -Super Picsou Géant,150,géant,FR,Usé,1990-11,3271234150018,Tampon brocante sur la couverture,,,2023-06-10T10:00:00Z -Mickey Parade,300,standard,FR,Bon,2023-01,3271234300016,,,,2023-02-01T10:00:00Z -Mickey Parade,301,standard,FR,Très bon,2023-03,3271234301013,,,,2023-03-15T10:00:00Z -Mickey Parade,302,standard,FR,Neuf,2023-05,3271234302010,,,,2023-05-17T10:00:00Z -Mickey Parade,303,standard,FR,Bon,2023-07,3271234303017,,,,2023-07-19T10:00:00Z -Mickey Parade,304,standard,FR,Usé,2023-09,3271234304014,,,,2023-09-20T10:00:00Z -Mickey Parade,305,standard,FR,Bon,2023-11,3271234305011,,,,2023-11-15T10:00:00Z -Mickey Parade,305,standard,FR,Bon,2023-11,3271234305011,,,Offert par un ami,2024-12-25T10:00:00Z -Mickey Parade,306,standard,FR,Très bon,2024-01,3271234306018,,,,2024-01-17T10:00:00Z -Mickey Parade,307,standard,FR,Neuf,2024-04,3271234307015,,,,2024-04-17T10:00:00Z -Mickey Parade,308,standard,FR,Bon,2024-06,3271234308012,,,,2024-06-19T10:00:00Z -Mickey Parade,309,standard,FR,Bon,2024-09,3271234309019,,,,2024-09-18T10:00:00Z -Mickey Parade,310,standard,FR,Neuf,2024-11,3271234310015,,,,2024-11-20T10:00:00Z -Mickey Parade,311,standard,FR,Très bon,2025-02,3271234311012,,,,2025-02-19T10:00:00Z -Mickey Parade,312,import,EN,Bon,2025-05,3271234312019,Édition anglaise,,,2025-05-21T10:00:00Z -Le Journal de Mickey,3500,hebdo,FR,Bon,2024-01,3271234350019,,,,2024-01-20T10:00:00Z -Le Journal de Mickey,3501,hebdo,FR,Bon,2024-02,3271234351016,,,,2024-02-17T10:00:00Z -Le Journal de Mickey,3502,hebdo,FR,Très bon,2024-03,3271234352013,,,,2024-03-16T10:00:00Z -Le Journal de Mickey,3503,hebdo,FR,Neuf,2024-04,3271234353010,,,,2024-04-20T10:00:00Z -Le Journal de Mickey,3504,hebdo,FR,Bon,2024-05,3271234354017,,,,2024-05-18T10:00:00Z -Le Journal de Mickey,3505,hebdo,FR,Usé,2024-06,3271234355014,,,,2024-06-15T10:00:00Z -Le Journal de Mickey,3506,hebdo,FR,Bon,2024-07,3271234356011,,,,2024-07-20T10:00:00Z -Le Journal de Mickey,3507,hebdo,FR,Neuf,2024-08,3271234357018,,,,2024-08-17T10:00:00Z -Les Trésors de Picsou — Édition Collector 75e Anniversaire,1,collector,FR,Neuf,2024-06,3271234400019,Coffret rigide avec certificat,,,2024-06-22T10:00:00Z -Les Trésors de Picsou — Édition Collector 75e Anniversaire,2,collector,FR,Neuf,2024-12,3271234400026,,,,2024-12-21T10:00:00Z -Super Picsou Géant Hors-Série Spécial Été,1,hors-série,FR,Très bon,2025-07,3271234400071,,,,2025-07-26T10:00:00Z -Picsou Hors-Série,,Spécial Été,FR,Très bon,2025-07,3271234400101,,,,2025-07-26T10:00:00Z -Picsou Hors-Série,,Spécial Été,FR,Très bon,2025-07,3271234400101,,,Exemplaire de plage,2025-08-02T10:00:00Z -Picsou Hors-Série,5,Spécial Hiver,FR,Neuf,2025-12,3271234400102,,,,2025-12-24T10:00:00Z diff --git a/docs/06-DATA-MODEL.md b/docs/06-DATA-MODEL.md index d08ccba..74afc7d 100644 --- a/docs/06-DATA-MODEL.md +++ b/docs/06-DATA-MODEL.md @@ -13,7 +13,7 @@ 3. [Entités persistées](#3-entités-persistées) 4. [Types TypeScript](#4-types-typescript) 5. [Requêtes principales](#5-requêtes-principales) -6. [Format d'export JSON v1](#6-format-dexport-json-v1) (CSV v1 en 6.1) +6. [Format d'export JSON v1](#6-format-dexport-json-v1) (CSV v2 en 6.1) 7. [Index et performance](#7-index-et-performance) 8. [Récapitulatif](#8-récapitulatif) @@ -281,23 +281,23 @@ JSON v1 → migration → SQLite actuelle Toute modification de structure incrémente `version` et documente la migration. -### 6.1 Export CSV v1 (livré M-07R, v0.7.1) +### 6.1 Export CSV v2 (livré M-12R, v0.9.4+) -Un **export tabulaire** `CSV` est proposé en complément du JSON (fichier exploitable dans un tableur). Il reprend une ligne par **exemplaire** (une édition à plusieurs exemplaires apparaît sur plusieurs lignes). +Un **export tabulaire** `CSV` est proposé en complément du JSON (fichier exploitable dans un tableur). Il reprend **une ligne par édition** (les exemplaires multiples ont été supprimés — retours test physique). -**En-têtes** : `publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded` +**En-têtes** : `publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,createdAt,updatedAt` **Exemple** : ```csv -publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded -Picsou Magazine,547,standard,FR,good,2023-03,3271234567890,,,Acheté 0,50 € en brocante à Lille,2026-08-30T14:30:00Z +publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,createdAt,updatedAt +Picsou Magazine,547,standard,FR,good,2023-03,3271234567890,,PICSOU MAGAZINE 547,2026-08-30T14:30:00Z,2026-08-30T14:30:00Z ``` **Règles** : - **séparateur** : virgule (`,`), valeurs entre guillemets si besoin ; - caractère d'échappement : `"` (doublé à l'intérieur d'une valeur) ; -- **une ligne = un exemplaire** (dénormalisation des `copies[]` JSON) ; les éditions **sans exemplaire** sont conservées via une ligne aux champs exemplaire vides ; -- **l'import CSV est validé sur la présence des en-têtes attendus** (§13.2 FONCTIONNAL-SPEC) ; +- **une ligne = une édition** : deux lignes ne définissent la même édition que si tous les champs métier coïncident (publication, numéro, édition, langue, état) — les doublons sont alors ignorés à l'import ; +- l'**import CSV est validé sur la présence des en-têtes attendus** (§13.2 FONCTIONNAL-SPEC) ; - les champs vides sont exportés vides (pas de `null`). > Le **JSON reste le format complet** (structure, métadonnées, version) et le seul garanti pour une **restauration fidèle** ; le CSV est un format d'exploitation/échange. @@ -333,6 +333,6 @@ Pour une collection de quelques milliers de magazines, SQLite reste très perfor | `magazine_barcodes` | Simplifiée en colonne `barcode` (non unique) | | Code-barres | EAN-13 / ISBN, index non unique (doublons permis) | | Export JSON | Format `picsou-collection` v1 | -| Export CSV | v1 (livré M-07R) — une ligne = un exemplaire | +| Export CSV | v2 (livré M-12R) — une ligne = une édition (doublons ignorés) | | TypeScript | camelCase, mapping explicit dans les repositories | | Index | barcode (non unique), (publication, issue_number), collection_items.magazine_id | From b66238c40ea5d12def884e974af444edcd571da8 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Fri, 11 Sep 2026 08:37:24 +0200 Subject: [PATCH 32/34] =?UTF-8?q?docs(M-12):=20synth=C3=A8se=20de=20la=20p?= =?UTF-8?q?asse=20de=20test=20physique=20OCR=20interactif=20(12-TESTING=20?= =?UTF-8?q?=C2=A75.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/12-TESTING.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/12-TESTING.md b/docs/12-TESTING.md index be9426b..7d60cbb 100644 --- a/docs/12-TESTING.md +++ b/docs/12-TESTING.md @@ -143,6 +143,33 @@ M12-03) et les **seuils de confiance** (M12-04) ; la synthèse et les chiffres sont consignés dans ce document au fil des passes. Les cas à fort risque sont **automatisés** (matrices de classification) pour éviter toute régression. +#### Passe de test physique M-12 (build v0.9.4, device Android) + +Le flux OCR interactif a été passé en conditions réelles (couvertures Disney +classiques, nombreux nombres). Résultats de la passe : + +- l'analyse de candidats et le seuillage (proposition automatique / sélection) + fonctionnent : titre, numéro et année sont proposés avec leur badge de + confiance et restent corrigeables (US-OCR-04/06) ; +- les corrections issues de cette passe ont été intégrées via PR #215 : + 1. le panneau d'informations détectées affiché en surcouche caméra + (Nom / Numéro / Édition-Date) a été **retiré** (bruit visuel) ; + 2. la popup de résultat OCR a été **recalée** sur le modèle « carte centrée » + du scan code-barres (lisibilité petits écrans) ; + 3. la sélection d'une zone se fait désormais par une **liste des textes + détectés** (le pointage des boîtes cliquables sur la photo s'est révélé + imprécis ; décision conservée, cf. M12-05) ; + 4. le **réticule jaune** a été retiré des surcouches OCR et code-barres ; + 5. le **champ code-barres** du formulaire d'ajout a été déplacé dans la + première partie (visible sans ouvrir Détails) ; + 6. l'écran **Recherche** propose désormais une liste « Édition » + un numéro + (recherche directe dans la collection), à la place du champ « nom ». + +**Suivi** : la mesure chiffrée des taux d'erreurs OCR / classification sur un +corpus élargi (`M12-08`) reste une activité de fiabilisation continue — elle +alimentera l'ajustement des règles (`ocrCandidateAnalyzer`) et des seuils +(`ocrProposals`) au prochain cycle. + --- ## 6. Outillage From 14effe467aec3ba93fe737d9062647844f32a1e6 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Fri, 11 Sep 2026 08:54:49 +0200 Subject: [PATCH 33/34] docs(M-12): aligner les statuts sur la livraison du milestone dans v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 09-ISSUE : registre M-12/M-12R livrés (8 issues done) + décisions du test physique - 11-ROADMAP : phase 12 Terminée ✓ (jauge, récap, tableau) - 08-USER-STORIES : US-OCR-01..08 livrées (US-OCR-05 adaptée à la liste de zones) - 04-FONCTIONAL-SPEC §5.6, 03-TECHNICAL-SPEC §5.5, 05-ARCHITECTURE : statuts ✅ - 12-TESTING : état de couverture M-12 (518 tests / 58 suites) --- docs/03-TECHNICAL-SPEC.md | 9 ++++-- docs/04-FONCTIONAL-SPEC.md | 9 +++--- docs/05-ARCHITECTURE.md | 17 +++++------ docs/08-USER-STORIES.md | 29 +++++++++---------- docs/09-ISSUE.md | 58 +++++++++++++++++++++++++++++--------- docs/11-ROADMAP.md | 34 ++++++++++++---------- docs/12-TESTING.md | 25 ++++++++-------- 7 files changed, 110 insertions(+), 71 deletions(-) diff --git a/docs/03-TECHNICAL-SPEC.md b/docs/03-TECHNICAL-SPEC.md index 43ae2b6..576a212 100644 --- a/docs/03-TECHNICAL-SPEC.md +++ b/docs/03-TECHNICAL-SPEC.md @@ -171,7 +171,9 @@ Photo (takePictureAsync) OCR (référentiel photo, `W×H` capturé) vers l'écran (proportions, `resizeMode`) ; - **Perf** (§8) : traitement borné hors bandeau UI, import paresseux conservé. -> **Statut M-12 : à venir (backlog)** — issues M12-01..08 (#205-#212). +> **Statut M-12 : livré ✅** — issues M12-01..08 (#205-#212) closés, intégrés à +> la release **v1.0.0** (modules `ocrImagePreprocessor`, `ocrResultMapper`, +> `ocrCandidateAnalyzer`, `ocrProposals`, écran `/scan/ocr-review`, PR #214/#215). --- @@ -292,10 +294,11 @@ Priorités : ``` Plugins (`app.json`) : `["expo-mlkit-ocr", { "iosEngine": "auto" }]` et `["expo-build-properties", { "ios": { "deploymentTarget": "16.4" } }]`. -> **M-12 (engagé)** : `expo-image-manipulator` **installé** (prétraitement, M12-01 — +> **M-12 (livré)** : `expo-image-manipulator` **installé** (prétraitement, M12-01 — > redimensionnement ≤ 2600 px + ré-encodage JPEG 0.85) ; les **bounding boxes** > sont exposées nativement par `expo-mlkit-ocr` (niveau *ligne*, M12-02), sans -> wrapper : `mapRecognitionResult` les projette en `OcrTextZone`. +> wrapper : `mapRecognitionResult` les projette en `OcrTextZone`. Les zones sont +> proposées à la sélection en **liste cliquable** (M12-05, test physique). --- diff --git a/docs/04-FONCTIONAL-SPEC.md b/docs/04-FONCTIONAL-SPEC.md index a9cdf5d..3b49521 100644 --- a/docs/04-FONCTIONAL-SPEC.md +++ b/docs/04-FONCTIONAL-SPEC.md @@ -268,9 +268,10 @@ Valeurs validées → recherche (si suffisant) ← US-OCR-07 - l'application **ne présente jamais** une identification comme certaine ; - la fiabilisation est **mesurée** sur un jeu de couvertures réelles (jeu de données + taux d'erreurs, cf. `12-TESTING.md`). -> **Statut M-12 : à venir (backlog)** — issues M12-01..08 (#205-#212), user -> stories US-OCR-01..08 (`08-USER-STORIES.md` §11). Voir aussi -> `03-TECHNICAL-SPEC.md` §5.5. +> **Statut M-12 : livré ✅** — issues M12-01..08 (#205-#212) closés et intégrés +> à la release **v1.0.0** ; corrections test physique (PR #215) et synthèse dans +> `12-TESTING.md` §5.4. User stories US-OCR-01..08 (`08-USER-STORIES.md` §11). +> Voir aussi `03-TECHNICAL-SPEC.md` §5.5. --- @@ -556,7 +557,7 @@ L'application vérifie, **selon le format choisi dans la popup** : | Caméra / OCR | ✅ | | — Surcouche de scan ciblé | ✅ (v0.7.1, US-ID-08) | | — Validation / correction des infos détectées | ✅ (v0.7.1, US-ID-09) | -| — OCR v2 interactif & fiabilisation | 🚧 (v1.1.0, M-12 — §5.6, US-OCR-01..08) | +| — OCR v2 interactif & fiabilisation | ✅ (v1.0.0, M-12 — §5.6, US-OCR-01..08) | | Saisie manuelle | ✅ | | Résultat Possédé / Absent | ✅ | | Gestion des doublons | ✅ | diff --git a/docs/05-ARCHITECTURE.md b/docs/05-ARCHITECTURE.md index c1c5bd6..aafe01c 100644 --- a/docs/05-ARCHITECTURE.md +++ b/docs/05-ARCHITECTURE.md @@ -290,16 +290,17 @@ module natif est **paresseux** (dans `recognize`) : sur CI / hors Development Bu retourne `null` sans bloquer les tests. `NoopOcrEngine` reste disponible comme repli. La reconnaissance brute se valide sur téléphone physique (Development Build). -**Évolution M-12 — OCR interactif & fiabilisation (à venir, US-OCR-01..08)** : -- `OcrFrameResult` ({ text }) évolue vers un résultat avec **zones** (`blocks` / - `lines` / `elements` + `boundingBox`) — gap à confirmer dans `expo-mlkit-ocr` - (issue M12-02 #206) ; -- `ocrImagePreprocessor` (prétraitement éphémère : redimensionnement + contraste) - branché avant `recognize` (issue M12-01 #205) ; +**Évolution M-12 — OCR interactif & fiabilisation (livré, US-OCR-01..08)** : +- `OcrFrameResult` transporte le texte **et** les **zones** (`blocks`→`lines`, + niveau **ligne**, chacune avec sa `boundingBox` en pixels image d'origine) — + mappées par `ocrResultMapper` (issue M12-02 #206) ; +- `ocrImagePreprocessor` (prétraitement éphémère : redimensionnement ≤ 2600 px + + ré-encodage JPEG, jamais d'écriture sur stockage) branché avant `recognize` + (issue M12-01 #205) ; - `ocrCandidateAnalyzer` produit des **candidats par champ** avec score de confiance (règles métier : pages/prix/année/numéro) (issue M12-03 #207) ; -- nouvel écran `/scan/ocr-review` : overlay photo + zones cliquables + conversion - de coordonnées image→écran (issues M12-05 ../09) ; +- écran `/scan/ocr-review` : photo + **liste des textes détectés** cliquables + (choix validé sur device à la place de l'overlay tap-image, M12-05/06) ; - intégration des valeurs validées à `identificationService.searchByOcrFields` (issue M12-07 #211). diff --git a/docs/08-USER-STORIES.md b/docs/08-USER-STORIES.md index 48754ab..4f408af 100644 --- a/docs/08-USER-STORIES.md +++ b/docs/08-USER-STORIES.md @@ -686,9 +686,9 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : > Évolution du flux OCR de l'Épique 4 : l'application lit la couverture, > **analyse des candidats** (titre, numéro/tome, année, pages, prix…) avec un > **niveau de confiance**, **propose automatiquement** les champs fiables et -> ne fait **intervenir Marc que pour les zones ambiguës**, sélectionnées en -> touchant directement la photo. Cible : après v1.0.0 (**M-12 → v1.1.0**, -> issue #205-#212). +> ne fait **intervenir Marc que pour les zones ambiguës**, sélectionnées parmi +> la liste des textes détectés. Cible : livrée dans la **v1.0.0** (**M-12 / M-12R**, +> issues #205-#212, #215). ### US-OCR-01 — Préparer la photo avant la lecture (prétraitement) > En tant que **Marc**, je veux **que la photo de la couverture soit retravaillée (redimensionnement, contraste) avant la reconnaissance** afin de **maximiser les chances de lecture, même sur une couverture stylisée**. @@ -698,7 +698,7 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : - aucune image n'est enregistrée (analyse éphémère, R14.2) ; - la lecture est plus fiable qu'à l'écran nu sur les couvertures fortement stylisées. -**Statut (M-12)** : backlog — [M12-01 (#205)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/205). +**Statut (M-12)** : livré ✅ ([M12-01 (#205)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/205), v1.0.0). **Priorité** : haute. @@ -710,7 +710,7 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : - les positions sont exprimées dans un référentiel exploitable par l'écran ; - le moteur reste abstrait (`OcrEngine`) et la CI reste verte sans module natif. -**Statut (M-12)** : backlog — [M12-02 (#206)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/206). +**Statut (M-12)** : livré ✅ ([M12-02 (#206)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/206), v1.0.0). **Priorité** : haute. @@ -722,7 +722,7 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : - les règles métier discriminent : `192 PAGES` ≠ numéro, `€8,50` = prix, `2026` = année, `TOME 12` / `N° 125` = numéro ; - le module est testable sans dépendance matérielle. -**Statut (M-12)** : backlog — [M12-03 (#207)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/207). +**Statut (M-12)** : livré ✅ ([M12-03 (#207)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/207), v1.0.0). **Priorité** : haute. @@ -734,20 +734,19 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : - en cas de candidats à confiance proche, **aucune** proposition automatique (validation utilisateur requise) ; - les propositions restent **toujours** modifiables. -**Statut (M-12)** : backlog — [M12-04 (#208)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/208). +**Statut (M-12)** : livré ✅ ([M12-04 (#208)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/208), v1.0.0). **Priorité** : moyenne. ### US-OCR-05 — Choisir la bonne zone sur la photo -> En tant que **Marc**, je veux **voir la photo avec les zones de texte détectées et toucher directement la bonne zone** afin de **dire à l'application où se trouve l'information** quand un champ est ambigu ou multiple. +> En tant que **Marc**, je veux **voir la photo avec les textes détectés et choisir le bon texte** afin de **dire à l'application où se trouve l'information** quand un champ est ambigu ou multiple. **Critères d'acceptation** : -- après la capture, un écran intermédiaire affiche la photo + les zones OCR en overlay ; -- les zones suivent fidèlement la photo quel que soit l'écran (conversion des coordonnées) ; -- chaque zone est **cliquable** ; +- après la capture, un écran intermédiaire affiche la photo + les textes détectés ; +- chaque texte détecté est **cliquable** (choix par liste, validé sur device — le tap direct sur l'image étant imprécis) ; - aucune image n'est enregistrée. -**Statut (M-12)** : backlog — [M12-05 (#209)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/209). +**Statut (M-12)** : livré ✅ ([M12-05 (#209)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/209), v1.0.0). **Priorité** : haute. @@ -760,7 +759,7 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : - les champs non identifiés restent vides (jamais de blocage) ; - la correction prend au plus 2-3 gestes par champ. -**Statut (M-12)** : backlog — [M12-06 (#210)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/210). +**Statut (M-12)** : livré ✅ ([M12-06 (#210)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/210), v1.0.0). **Priorité** : haute. @@ -772,7 +771,7 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : - information absente → saisie manuelle **pré-remplie** avec ce qui est connu (pas de blocage) ; - plusieurs candidats possibles → l'utilisateur choisit (jamais de choix automatique hasardeux). -**Statut (M-12)** : backlog — [M12-07 (#211)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/211). +**Statut (M-12)** : livré ✅ ([M12-07 (#211)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/211), v1.0.0). **Priorité** : moyenne. @@ -785,7 +784,7 @@ Chaque story est identifiée par un code (ex. `US-DB-01`) et possède : - les règles métier (US-OCR-03) et les seuils (US-OCR-04) sont ajustés à partir des mesures ; - la synthèse est documentée (`docs/12-TESTING.md`). -**Statut (M-12)** : backlog — [M12-08 (#212)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/212). +**Statut (M-12)** : livré ✅ ([M12-08 (#212)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/212), v1.0.0). **Priorité** : moyenne. diff --git a/docs/09-ISSUE.md b/docs/09-ISSUE.md index 2291adf..2c056c7 100644 --- a/docs/09-ISSUE.md +++ b/docs/09-ISSUE.md @@ -212,7 +212,8 @@ Les milestones correspondent aux **phases de la roadmap** (`11-ROADMAP.md`). | `M-10R2` | 2ᵉ passe retours test physique | SafeZone drawer, permission caméra + onboarding 1er lancement, popup résultat, form valider header, paramètres sous-menus, header fiche, tri collection, OCR numéro, écran Recherche → v0.9.2 (livrée, PR #176) | | `M-09` | Tests terrain & publication | Validation réelle, retours physique → v0.9.3 (M09-01 panneau recherche #177) | | `M-11` | Review & Qualité v1.0.0 | Audit doc ↔ code, revue, refactor, tests, retrait Play Store, publication APK GitHub → v1.0.0 (#181-#190) | -| `M-12` | OCR interactif & fiabilisation (v1.1.0) | Prétraitement de la photo, OCR texte + bounding boxes, analyse de candidats par champ (titre, numéro/tome, année, pages, prix) avec confiance, propositions automatiques, sélection interactive des zones, correction rapide, intégration à la recherche, jeu de test réel → **après v1.0.0** (#205-#212) | +| `M-12` | OCR interactif & fiabilisation (v1.1.0) | Prétraitement de la photo, OCR texte + bounding boxes, analyse de candidats par champ (titre, numéro/tome, année, pages, prix) avec confiance, propositions automatiques, sélection interactive des zones, correction rapide, intégration à la recherche, jeu de test réel → livré dans **v1.0.0** (#205-#212) | +| `M-12R` | Corrections test physique M-12 | Corrections post-test physique M-12 (PR #215) : panneau d'infos caméra retiré, popup résultat reconstruite, zones cliquables → liste de textes, réticule retiré, code-barres en 1ʳᵉ partie de formulaire, écran Recherche édition + numéro → **v1.0.0** | ### Règle d'attribution - Chaque milestone a une **date de fin cible** (indicative) et une **description** ; @@ -293,22 +294,25 @@ M-10/M-10R/M-10R2 terminées, correction issues ouvertes pendant la Phase 9 |---|---|---|---| | M09-01 | Collection : panneau de recherche repliable (bouton Rechercher) | #177 | En cours (PR #178) | -**Milestone M-12 « OCR interactif & fiabilisation » (v1.1.0)** — après v1.0.0 · -milestone GitHub #17 · issues **#205 à #212** · ajoutées au project #5 (To Do, P1) +**Milestone M-12 « OCR interactif & fiabilisation » (v1.1.0)** — réalisé avant la +release finale et **livré dans v1.0.0** · milestone GitHub #17 (clos) · issues +**#205 à #212** · project #5 | Issue | Titre | GitHub | Statut | |---|---|---|---| -| M12-01 | OCR : prétraitement de la photo avant reconnaissance | #205 | À faire | -| M12-02 | OCR : récupérer texte + bounding boxes (ML Kit) | #206 | À faire | -| M12-03 | OCR : analyse de candidats par champ + score de confiance | #207 | À faire | -| M12-04 | OCR : propositions automatiques et seuil de confiance | #208 | À faire | -| M12-05 | OCR : écran interactif — overlay photo + zones cliquables | #209 | À faire | -| M12-06 | OCR : sélection d'une zone pour un champ + correction rapide | #210 | À faire | -| M12-07 | OCR : brancher les valeurs validées à la recherche / l'ajout | #211 | À faire | -| M12-08 | OCR : jeu de test réel, mesure des erreurs, ajustement des règles | #212 | À faire | - -Voir `08-USER-STORIES.md` §11 (US-OCR-01..08). Backlog : non engagé tant que -**M-11 / v1.0.0** n'est pas clôturé. +| M12-01 | OCR : prétraitement de la photo avant reconnaissance | #205 | Done | +| M12-02 | OCR : récupérer texte + bounding boxes (ML Kit) | #206 | Done | +| M12-03 | OCR : analyse de candidats par champ + score de confiance | #207 | Done | +| M12-04 | OCR : propositions automatiques et seuil de confiance | #208 | Done | +| M12-05 | OCR : écran interactif — zones de texte cliquables (liste) | #209 | Done | +| M12-06 | OCR : sélection d'une zone pour un champ + correction rapide | #210 | Done | +| M12-07 | OCR : brancher les valeurs validées à la recherche / l'ajout | #211 | Done | +| M12-08 | OCR : jeu de test réel, mesure des erreurs, ajustement des règles | #212 | Done | + +Livré : 8 issues closes (PR #214 M-12 ; PR #215 M-12R corrections du test +physique) → incluses dans la release **v1.0.0** (#189). Voir +`08-USER-STORIES.md` §11 (US-OCR-01..08) et `12-TESTING.md` §5.4 (synthèse de la +passe device). ### 6.2 Décisions de conception et causes racines (retours terrain) @@ -370,6 +374,32 @@ Les retours test physique ont produit des choix structurants conservés ici. (Possédé/Absent) ; **non trouvé** → repli `/scan/manual` pré-rempli. Le bouton « Ajouter » de l'accueil reste un ajout. +**M-12 / M-12R (v1.0.0)** + +*Décisions (implémentation et test physique M-12)* +- **M12-01 Prétraitement** : `expo-image-manipulator` en local, borné + (≤ 2600 px, ré-encodage JPEG 0.85) et **éphémère** (aucune image stockée) ; + le contraste n'est pas exposé nativement → reporté (documenté §8). +- **M12-02 Granularité** : niveau **ligne** ML Kit (texte + bounding boxes en + pixels de l'image d'origine) ; mappé par `mapRecognitionResult` → `OcrTextZone`. +- **M12-05 Sélection des zones** : remplacement de l'overlay cliquable sur la + photo par une **liste des textes détectés** (choix validé sur device : tap + image imprécis sur couvertures) — la photo reste affichée, chaque zone reste + cliquable. +- **M12-R1 Panneau caméra** : le panneau d'informations détectées (Nom / + Numéro / Édition-Date) affiché en standard est **retiré** (bruit visuel). +- **M12-R2 Popup résultat** : recalculée sur le modèle « carte centrée » du scan + code-barres (backdrop + feuille, lisible sur petits écrans). +- **M12-R4 Réticule** : le réticule jaune est retiré des surcouches OCR et + code-barres (non informatif, inutile sans overlay). +- **M12-R5 Formulaire** : le champ **code-barres** rejoint la 1ʳᵉ partie du + formulaire d'ajout (visible sans ouvrir la section Détails). +- **M12-R6 Recherche** : liste déroulante **« Édition »** (défaut : toutes les + éditions) + **numéro**, recherche directe dans la collection (fini le champ + « nom » inefficace en brocante). +- **CSV v2** : une ligne = une édition ; les doublons d'exemplaires sont + **ignorés** à l'import et retirés de l'exemple (`collection-exemple.csv`). + --- ## 7. Branches (Git Flow) diff --git a/docs/11-ROADMAP.md b/docs/11-ROADMAP.md index 8163155..801702c 100644 --- a/docs/11-ROADMAP.md +++ b/docs/11-ROADMAP.md @@ -61,7 +61,7 @@ Phase 10R2 ███████████████████████ Phase 10R3 ██████████████████████████████ Terminé ✓ (v0.9.3) Phase 9 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ À venir (M-09 → v1.0.0) Phase 11 ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░ En cours (M-11 → v1.0.0) -Phase 12 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ À venir (M-12 → v1.1.0) +Phase 12 ██████████████████████████████ Terminé ✓ (M-12 → v1.0.0) ``` ### 2.1 Correspondance phase ↔ milestone ↔ issues @@ -87,7 +87,7 @@ Chaque phase de la roadmap correspond à un **milestone GitHub** (`M-0x`) et à | 10R3 — Retours test v0.9.3 | `M-09 — Tests terrain & publication` | `US-UX-23` (#177), retours physiques v0.9.3 | 1 | | 9 — Tests & publication | `M-09 — Tests terrain & publication` | `US-QA-03` | 1 | | 11 — Review & Qualité | `M-11 — Review & Qualité v1.0.0` | issues M11-01..10 (#181-#190) | 10 | -| 12 — OCR interactif | `M-12 — OCR interactif & fiabilisation (v1.1.0)` | `US-OCR-01..08` (issues M12-01..08, #205-#212) | 8 | +| 12 — OCR interactif | `M-12 — OCR interactif & fiabilisation (livré v1.0.0)` | `US-OCR-01..08` (issues M12-01..08, #205-#212) | 8 | | **Total** | | | **122** | --- @@ -477,32 +477,36 @@ Détail : [09-ISSUE.md §6 – Milestones](09-ISSUE.md#6-milestones). --- -## 19. Phase 12 — OCR interactif & fiabilisation (M-12 → v1.1.0) +## 19. Phase 12 — OCR interactif & fiabilisation (M-12 → livré v1.0.0) **Objectif** : rendre le flux OCR **fiable en brocante** pour tous les supports (magazines, BD, comics, couvertures stylisées). L'application fait le maximum automatiquement — prétraitement de la photo, OCR texte + positions, analyse de **candidats par champ** (titre, numéro/tome, année, pages, prix…) avec **niveau de confiance**, **propositions automatiques** au-dessus d'un seuil — -et **n'implique Marc que pour les zones ambiguës**, qu'il sélectionne en -touchant la photo. Démarrage **après la v1.0.0** (M-11). +et **n'implique Marc que pour les zones ambiguës**, qu'il sélectionne parmi la +liste des textes détectés. Réalisée avant la release finale : livrée dans la +**v1.0.0** (M-12/M-12R intégrés à la release). | Tâche | Issue | Statut | |---|---|---| -| Prétraitement de la photo avant reconnaissance (redimensionnement, contraste) | M12-01 (#205) | À faire | -| Récupérer texte + bounding boxes (ML Kit) | M12-02 (#206) | À faire | -| Analyse de candidats par champ + score de confiance (règles métier) | M12-03 (#207) | À faire | -| Propositions automatiques et seuil de confiance | M12-04 (#208) | À faire | -| Écran interactif : overlay photo + zones cliquables | M12-05 (#209) | À faire | -| Sélection d'une zone pour un champ + correction rapide | M12-06 (#210) | À faire | -| Brancher les valeurs validées à la recherche / l'ajout | M12-07 (#211) | À faire | -| Jeu de test réel, mesure des erreurs, ajustement des règles/seuils | M12-08 (#212) | À faire | +| Prétraitement de la photo avant reconnaissance (redimensionnement, contraste) | M12-01 (#205) | ✓ Livré | +| Récupérer texte + bounding boxes (ML Kit) | M12-02 (#206) | ✓ Livré | +| Analyse de candidats par champ + score de confiance (règles métier) | M12-03 (#207) | ✓ Livré | +| Propositions automatiques et seuil de confiance | M12-04 (#208) | ✓ Livré | +| Écran interactif : liste des zones de texte cliquables | M12-05 (#209) | ✓ Livré | +| Sélection d'une zone pour un champ + correction rapide | M12-06 (#210) | ✓ Livré | +| Brancher les valeurs validées à la recherche / l'ajout | M12-07 (#211) | ✓ Livré | +| Jeu de test réel, mesure des erreurs, ajustement des règles/seuils | M12-08 (#212) | ✓ Livré | **Livrables** : 8 issues M12-01..08 (#205-#212) + 8 user stories US-OCR-01..08 (`08-USER-STORIES.md` §11). Définition fonctionnelle : `04-FONCTIONAL-SPEC.md` §5.6 ; technique : `03-TECHNICAL-SPEC.md` §5.5. -**Statut : À venir** — backlog ; ne démarre qu'à la fermeture du **M-11**. +**Statut : Terminé ✓** — 8 issues closés (milestone GitHub #17 clos) ; passe de +test physique (build v0.9.4) et corrections **M-12R** intégrées (PR #215) ; +synthèse dans `12-TESTING.md` §5.4. Le tout est inclus dans la release +**v1.0.0** (#189). --- @@ -542,5 +546,5 @@ Pour chaque phase, les critères de sortie sont : | 10R3 — Retours test v0.9.3 | Recherche repliable, drawer scroll, renommage | US-UX-23 (M09-01) | 1 | | 9 — Publication | APK GitHub (release v1.0.0) | US-QA-03 | 1 | | 11 — Review & Qualité | Préparation v1.0.0 | M11-01..10 (#181-#190) | 10 | -| 12 — OCR interactif | OCR interactif & fiabilisation (v1.1.0) | US-OCR-01..08 (M12-01..08, #205-#212) | 8 | +| 12 — OCR interactif | OCR interactif & fiabilisation (livré v1.0.0) | US-OCR-01..08 (M12-01..08, #205-#212) | 8 | | **Total** | | | **122** | diff --git a/docs/12-TESTING.md b/docs/12-TESTING.md index 7d60cbb..9af3f4b 100644 --- a/docs/12-TESTING.md +++ b/docs/12-TESTING.md @@ -240,19 +240,20 @@ Les tests peuvent également être co-localisés au niveau du fichier couvert (c } ``` -| Zone | Couverture mesurée (M-11 copie 2) | Couverture cible | +| Zone | Couverture mesurée (M-12) | Couverture cible | |---|---|---| -| Global | **98,35 %** (statements), lines **97,84 %**, functions **96,83 %**, branches **92,99 %** | ≥ 80 % | -| Services / repositories (`src/backup`, `src/database`, `src/store`) | ≥ 90 % sur les 4 métriques (100 % pour database & store) | ≥ 85 % | -| Bibliothèque (`src/lib`) | 100 % lignes, 90 % functions | ≥ 80 % | -| OCR (`src/identification`) | **96,82 %** branches, 100 % functions | ≥ 85 % | -| Composants critiques (`src/components`) | **90,47 %** branches, 94,41 % lines | ≥ 70 % | - -> État M-11 (2e passe) : **416 tests / 46 suites**, couverture globale — statements -> **98,35 %**, branches **92,99 %**, functions **96,83 %**, lines **97,84 %** -> (seuil global CI ≥ 80 %). État M-11 (1re passe) : 346 tests / 40 suites et -> statements 87,40 % — état mesuré en M-08 : 270 tests / 33 suites, statements -> 90,24 %, branches 85,86 %. +| Global | **97,32 %** (statements), lines **97,62 %**, functions **96,73 %**, branches **90,58 %** | ≥ 80 % | +| Services / repositories (`src/backup`, `src/database`, `src/store`) | database 100 %, store ≥ 94 % sur les 4 métriques ; backup ≥ 91 % | ≥ 85 % | +| Bibliothèque (`src/lib`) | 97,36 % statements, 90,56 % branches | ≥ 80 % | +| OCR (`src/identification`) | **96,66 %** branches, 100 % functions | ≥ 85 % | +| Composants critiques (`src/components`) | **88,79 %** branches, 97,11 % lines | ≥ 70 % | + +> État M-12 : **518 tests / 58 suites**, couverture globale — statements +> **97,32 %**, branches **90,58 %**, functions **96,73 %**, lines **97,62 %** +> (seuil global CI ≥ 80 %). État M-11 (2e passe) : 416 tests / 46 suites, +> statements 98,35 %, branches 92,99 %, functions 96,83 %, lines 97,84 % — état +> M-11 (1re passe) : 346 tests / 40 suites et statements 87,40 % — état mesuré +> en M-08 : 270 tests / 33 suites, statements 90,24 %, branches 85,86 %. ### 8.2 Exclusion de couverture Certains fichiers sont exclus du calcul : From 1f02c4e8de2af180435e23b6109377cd5f20cc01 Mon Sep 17 00:00:00 2001 From: donovan-dev-web Date: Fri, 11 Sep 2026 09:05:00 +0200 Subject: [PATCH 34/34] =?UTF-8?q?release:=20v1.0.0=20=E2=80=94=20premi?= =?UTF-8?q?=C3=A8re=20release=20stable=20(M-11=20&=20M-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version alignée 1.0.0 (app.json + package.json + APP_VERSION des backups) - app.json : nom affiché « Mag Collection » (cohérent avec strings.xml) - CHANGELOG [1.0.0] : M-11 (une édition possédée, backup CSV v2, scan), M-12 (OCR interactif & fiabilisation #205-#212), M-12R (retours test physique) - README : badge Version-1.0.0, phase « 9 — Tests terrain & publication » Terminée --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++-- app.json | 4 ++-- package.json | 2 +- src/utils/app-version.ts | 2 +- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d23319..49149ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,52 @@ --- +## [1.0.0] — 2026-09-11 + +> **Première release majeure stable** : clôture du chantier **M-11** (Review & +> Qualité) et livraison de **M-12 — OCR interactif & fiabilisation** (#205-#212, +> v1.0.0). Le modèle « une édition possédée = une ligne » remplace le système +> d'exemplaires, le backup s'étoffe (CSV v2, 11 colonnes), et le flux OCR devient +> dialogué : prétraitement, zones détectées, candidats par champ avec confiance, +> propositions automatiques, revue photo interactive et recherche édition + numéro. + +### Added +- **OCR interactif & fiabilisation (M-12, #205-#212, US-OCR-01..08)** : + - prétraitement de la photo avant reconnaissance (redimensionnement ≤ 2600 px + ré-encodage JPEG, **analyse éphémère** sans écriture stockage) — `ocrImagePreprocessor`, M12-01 #205 ; + - OCR avec **zones** : texte + position (`boundingBox`) au niveau **ligne** détectée — `ocrResultMapper`, M12-02 #206 ; + - **candidats par champ** (titre, numéro/tome, année, pages, prix) avec **niveau de confiance** et règles métier dynamiques (`192 PAGES` ≠ numéro, `€` = prix, années, `N°`/`TOME`) — `ocrCandidateAnalyzer`, M12-03 #207 ; + - **propositions automatiques** des champs fiables (seuil 0,7 ; aucune proposition si confiance proche), **toujours modifiables** — `ocrProposals`, M12-04 #208 ; + - **écran de revue photo** `/scan/ocr-review` : liste des textes détectés **cliquables**, sélection par champ et correction en 2-3 gestes — M12-05 #209, M12-06 #210 ; + - recherche / ajout avec les valeurs validées → résultat Possédé / Absent, saisie manuelle **pré-remplie** si info manquante — M12-07 #211 ; + - **jeu de test** de couvertures réelles + erreurs mesurées et consignées (`docs/12-TESTING.md` §5.4) — M12-08 #212 ; + - synthèse documentaire de la passe de test physique (M-12R, PR #215). +- **Recherche par édition + numéro** directement dans la collection (`/scan/search`), résultat édition avec N°/année et panier associé (M-12R, PR #215). +- **Exemple de collection au format v2**, une ligne par édition — `collection-exemple.csv` + docs/06-DATA-MODEL (M-12R). +- **OCR manuel** (un appui = une photo), panneau de debug et sous-menu **paramètres avancés** (M-11, PR #204). +- Champ **code-barres** remonté en première partie du formulaire d'édition (M-12R). + +### Changed +- **Modèle « une édition possédée »** : le système d'exemplaires est retiré — une édition en collection est possédée (migration v5, M-11) ; compteurs, fiche, sauvegardes et imports v1 mis en cohérence. +- **Backup CSV v2** : export 11 colonnes compatible tableur + validation des anciens exports v1 (M-11) ; backup sans exemplaires. +- **Scan code-barres** : panier possédé/continu, états replié et recherche sans quantité (M-11). +- **Version 1.0.0** alignée (app.json + package.json + `APP_VERSION` des backups). +- **Découpage** des écrans de scan caméra/code-barres et des services/composants métier (M-11) ; tests et couverture alignés sur la doc (M11-07/08). +- Expo/expo-router alignés sur les patchs du SDK 57 + concurrent React (M-11). + +### Fixed +- Caméra OCR **épurée** (retours test physique, M-12R, PR #215) : fini les boîtes cliquables, le panneau d'informations et le réticule — le texte détecté est choisi depuis la revue photo. +- Popup « Couverture reconnue » **recalée** sur l'écran après les retours terrain (M-12R). +- Purge du code mort, conventions de code (dates, a11y, perfs) et revue des tests US (M-11). + +### Removed +- **Système d'exemplaires** (migration v5) — remplacé par le modèle « une édition possédée ». +- `collection-fictive.csv` — remplacé par `collection-exemple.csv` (format v2). + +### Validation +- **518 tests / 58 suites** vertes (statements 97,32 %, branches 90,58 %, functions 96,73 %, lines 97,62 % ; seuil CI ≥ 80 %), tsc OK, lint 0 erreur, prettier OK, couverture documentée `docs/12-TESTING.md` §8. + +--- + ## [0.9.3] — 2026-09-07 > Treizième release : **retours test physique v0.9.2** — panneau de recherche diff --git a/README.md b/README.md index abcb6e6..4ac3729 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ![Database](https://img.shields.io/badge/Base%20de%20donne%CC%81es-Locale%20SQLite-orange?style=for-the-badge&logo=sqlite&logoColor=white&color=47A248) ![Offline](https://img.shields.io/badge/Offline%20First-100%25-brightgreen?style=for-the-badge&logo=cloud-offline&color=2E7D32) ![Status](https://img.shields.io/badge/Statut-En%20de%CC%81veloppement-informational?style=for-the-badge&color=00629E) -![Version](https://img.shields.io/badge/Version-0.9.3-yellow?style=for-the-badge&color=FDD835) +![Version](https://img.shields.io/badge/Version-1.0.0-yellow?style=for-the-badge&color=FDD835)
@@ -153,7 +153,7 @@ PicsouCollectionApp/ | **10R — Retours test** | Drawer + tabs, formulaire au clavier, torche, import CSV | ✅ Terminé — **v0.9.1** | | **10R2 — Retours test** | Drawer SafeZone, onboard + permission, paramètres sous-menus, header fiche, tri, OCR numéro, recherche | ✅ Terminé — **v0.9.2** | | **10R3 — Retours test** | Panneau de recherche repliable Collection, drawer scrollable, popup/form, renommage **Mag Collection** | ✅ Terminé — **v0.9.3** | -| **9 — Tests terrain & publication** | Vrais magazines, release **APK sur GitHub** (v1.0.0) | ⬜ À venir | +| **9 — Tests terrain & publication** | Vrais magazines, release **APK sur GitHub** (v1.0.0) | ✅ Terminé — **v1.0.0** | Détail complet : [docs/11-ROADMAP.md](docs/11-ROADMAP.md) diff --git a/app.json b/app.json index 81e6307..5e3e98d 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,8 @@ { "expo": { - "name": "Mag Collector", + "name": "Mag Collection", "slug": "PicsouCollection", - "version": "0.9.4", + "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "picsoucollection", diff --git a/package.json b/package.json index 2f21503..a49edf6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "picsoucollection", "main": "expo-router/entry", - "version": "0.9.3", + "version": "1.0.0", "packageManager": "npm@10.9.4", "dependencies": { "@expo/vector-icons": "^15.0.2", diff --git a/src/utils/app-version.ts b/src/utils/app-version.ts index 04d190e..9c2e447 100644 --- a/src/utils/app-version.ts +++ b/src/utils/app-version.ts @@ -1 +1 @@ -export const APP_VERSION = '0.8.0'; +export const APP_VERSION = '1.0.0';