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 a5c5c3d..4ac3729 100755
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@



-
+
@@ -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 |
---
@@ -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) | ✅ Terminé — **v1.0.0** |
Détail complet : [docs/11-ROADMAP.md](docs/11-ROADMAP.md)
@@ -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/app.json b/app.json
index 7775329..5e3e98d 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Mag Collection",
"slug": "PicsouCollection",
- "version": "0.9.3",
+ "version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "picsoucollection",
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/00-GLOSSAIRE.md b/docs/00-GLOSSAIRE.md
index c0fb4e9..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,12 +115,12 @@ 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.
-**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/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 86cde24..576a212 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.
@@ -137,6 +137,44 @@ 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 : 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).
+
---
## 6. Scan code-barres
@@ -165,8 +203,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 +219,11 @@ Deux modes de build :
},
"preview": {
"distribution": "internal",
- "channel": "preview"
- },
- "production": {
- "channel": "production",
- "autoIncrement": true
+ "channel": "preview",
+ "android": {
+ "buildType": "apk"
+ }
}
- },
- "submit": {
- "production": {}
}
}
```
@@ -224,16 +258,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"
}
```
@@ -241,12 +275,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"
}
```
@@ -254,11 +289,17 @@ 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" } }]`.
+> **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`. Les zones sont
+> proposées à la sélection en **liste cliquable** (M12-05, test physique).
+
---
## Récapitulatif des décisions techniques
diff --git a/docs/04-FONCTIONAL-SPEC.md b/docs/04-FONCTIONAL-SPEC.md
index 3fa5eb3..3b49521 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 |
---
@@ -221,6 +228,51 @@ 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 : 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.
+
---
## 6. Flux saisie manuelle
@@ -505,6 +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.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 fa6d38e..aafe01c 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,131 @@ 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
+│ └── 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 (+ 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)
+│ ├── ocrImagePreprocessor.ts # Prétraitement éphémère (M-12, M12-01)
+│ └── ocrCandidateAnalyzer.ts # Candidats par champ + confiance (M-12, M12-03)
│
├── 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 +214,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 +228,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 +259,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)
@@ -229,6 +290,20 @@ 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 (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) ;
+- é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).
+
**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 ;
@@ -241,22 +316,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 +361,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 +374,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 +408,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..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)
@@ -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).
@@ -280,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.
@@ -332,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 |
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/08-USER-STORIES.md b/docs/08-USER-STORIES.md
index a4766a1..4f408af 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)
---
@@ -439,12 +441,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.
@@ -678,7 +681,116 @@ 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 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**.
+
+**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)** : livré ✅ ([M12-01 (#205)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/205), v1.0.0).
+
+**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)** : livré ✅ ([M12-02 (#206)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/206), v1.0.0).
+
+**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)** : livré ✅ ([M12-03 (#207)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/207), v1.0.0).
+
+**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)** : 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 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 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)** : livré ✅ ([M12-05 (#209)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/209), v1.0.0).
+
+**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)** : livré ✅ ([M12-06 (#210)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/210), v1.0.0).
+
+**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)** : livré ✅ ([M12-07 (#211)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/211), v1.0.0).
+
+**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)** : livré ✅ ([M12-08 (#212)](https://github.com/donovan-dev-web/PicsouCollectionApp/issues/212), v1.0.0).
+
+**Priorité** : moyenne.
+
+---
+
+## 12. Représentation graphique
```
┌─────────────────── ÉPIQUES ───────────────────┐
@@ -698,7 +810,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 |
|---|---|---|
@@ -713,4 +825,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 e5ef50e..2c056c7 100644
--- a/docs/09-ISSUE.md
+++ b/docs/09-ISSUE.md
@@ -210,7 +210,10 @@ 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) |
+| `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** ;
@@ -291,6 +294,26 @@ 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)** — 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 | 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)
Les retours test physique ont produit des choix structurants conservés ici.
@@ -351,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)
@@ -538,7 +587,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-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/` ;
@@ -556,6 +605,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-12 (+ `M-04R`, `M-07R`, `M-10R`, `M-10R2`) (phases roadmap) |
| Versionnage | Semantic Versioning |
| PR | Template + CI + revue + DoD |
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..801702c 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)
---
@@ -55,7 +58,10 @@ 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)
+Phase 12 ██████████████████████████████ Terminé ✓ (M-12 → v1.0.0)
```
### 2.1 Correspondance phase ↔ milestone ↔ issues
@@ -78,8 +84,11 @@ 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 |
+| 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** |
---
@@ -137,9 +146,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 +347,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.
---
@@ -397,30 +407,110 @@ 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
+## 17. Phase 10R3 — Retours test physique (M-09 → v0.9.3)
-**Objectif** : Validation réelle et publication Play Store.
+**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 |
+| 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.
+
+| 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 |
+| 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.
-**Livrables** : application validée et publiée.
+---
+
+## 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.
+
+---
+
+## 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 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) | ✓ 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 : 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).
---
-## 18. Critères de sortie de chaque phase
+## 20. Critères de sortie de chaque phase
Pour chaque phase, les critères de sortie sont :
@@ -449,9 +539,12 @@ 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 | Play Store | 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 |
+| 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 81bfe47..9af3f4b 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**.
@@ -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.
@@ -121,6 +121,55 @@ 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.
+
+#### 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
@@ -130,10 +179,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 +193,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`).
---
@@ -162,6 +218,11 @@ Alternativement, les tests peuvent être co-localisés `collectionService.test.t
### 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": {
@@ -169,23 +230,40 @@ Alternativement, les tests peuvent être co-localisés `collectionService.test.t
"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-12) | Couverture cible |
|---|---|---|
-| Global | **90,24 %** (seuil CI ≥ 80 %, M-08) | ≥ 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 %.
+| 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 :
- 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/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/package-lock.json b/package-lock.json
index 558cd02..906680a 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",
@@ -17,9 +17,10 @@
"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.19",
+ "expo-router": "~57.0.20",
"expo-sharing": "~57.0.18",
"expo-splash-screen": "~57.0.8",
"expo-sqlite": "~57.0.2",
@@ -1911,9 +1912,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 +2067,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 +2107,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 +4977,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 +5028,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 +7193,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 +7210,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 +7390,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": "*",
@@ -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",
@@ -7468,13 +7490,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 +7511,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 +7536,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 +7750,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 +7766,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 3f1c182..a49edf6 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
{
"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",
- "expo": "~57.0.20",
+ "expo": "~57.0.21",
"expo-build-properties": "~57.0.17",
"expo-camera": "~57.0.4",
"expo-constants": "~57.0.16",
@@ -13,9 +13,10 @@
"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.19",
+ "expo-router": "~57.0.20",
"expo-sharing": "~57.0.18",
"expo-splash-screen": "~57.0.8",
"expo-sqlite": "~57.0.2",
@@ -66,6 +67,7 @@
"jest": {
"preset": "jest-expo",
"clearMocks": true,
+ "testTimeout": 15000,
"setupFiles": [
"/jest.setup.js"
],
@@ -78,7 +80,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": {
@@ -86,6 +90,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/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"
)
diff --git a/src/__tests__/app/barcode-screen.test.tsx b/src/__tests__/app/barcode-screen.test.tsx
index bac54db..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());
});
@@ -119,7 +95,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({
@@ -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 74eb7c2..ac0281b 100644
--- a/src/__tests__/app/camera-ocr-screen.test.tsx
+++ b/src/__tests__/app/camera-ocr-screen.test.tsx
@@ -3,21 +3,30 @@ 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();
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' },
@@ -27,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',
@@ -48,14 +63,12 @@ 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),
} 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' }),
@@ -66,32 +79,31 @@ 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();
+ mockPush.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 +117,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 +145,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,14 +172,13 @@ describe('CameraOcrScreen', () => {
);
render();
- await tick();
- await tick();
+ await shoot();
fireEvent.press(screen.getByTestId('ocr-confirm'));
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('garde le mode analyse quand seul le nom est détecté et oriente vers le numéro', async () => {
setDepsForTest(
stubDeps({
identificationService: {
@@ -183,18 +194,16 @@ 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.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();
});
- 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()
@@ -222,18 +231,17 @@ describe('CameraOcrScreen', () => {
);
render();
- await tick();
- expect(screen.getByTestId('ocr-confirm-detected')).toBeTruthy();
+ await shoot();
+ expect(screen.queryByTestId('ocr-found')).toBeNull();
- 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 +261,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 +283,7 @@ describe('CameraOcrScreen', () => {
);
render();
- await tick();
- await tick();
+ await shoot();
fireEvent.press(screen.getByTestId('ocr-manual'));
expect(mockReplace).toHaveBeenCalledWith({
@@ -291,100 +297,119 @@ describe('CameraOcrScreen', () => {
});
});
-describe('CameraOcrScreen — US-ID-09 surcouche de validation / correction', () => {
+describe('CameraOcrScreen — debug OCR (paramètres avancés)', () => {
beforeEach(() => {
- jest.useFakeTimers();
mockReplace.mockClear();
mockBack.mockClear();
+ mockPush.mockClear();
+ useSettingsStore.setState({ ocrDebug: true });
});
- afterEach(() => {
- jest.useRealTimers();
+ 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');
});
- 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',
+ 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: 1,
+ confidence: 0.5,
}),
- } as unknown as Dependencies['identificationService'],
- });
+ } as unknown as Dependencies['identificationService'],
+ ocrEngine: {
+ recognize: jest.fn().mockResolvedValue({ text: 'Picsou Magazine N° 547' }),
+ } as unknown as OcrEngine,
+ }),
+ );
- it('ouvre le panneau de vérification pré-rempli depuis la surcouche', async () => {
- setDepsForTest(weakDeps());
render();
- await tick();
-
- fireEvent.press(screen.getByTestId('ocr-confirm-detected'));
+ await shoot();
+ await shoot();
- 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');
+ expect(screen.getByText('Lectures identiques')).toBeTruthy();
+ expect(screen.getByText('2')).toBeTruthy();
});
- it('retour à la caméra depuis le panneau relance l’analyse', async () => {
- setDepsForTest(weakDeps());
+ it('n’affiche pas le panneau de debug quand il est désactivé', () => {
+ useSettingsStore.setState({ ocrDebug: false });
render();
- await tick();
- fireEvent.press(screen.getByTestId('ocr-confirm-detected'));
- fireEvent.press(screen.getByTestId('ocr-override-back'));
+ expect(screen.queryByTestId('ocr-debug-panel')).toBeNull();
+ });
+});
- expect(screen.getByText(/Pointez/)).toBeTruthy();
- expect(screen.getByTestId('ocr-detected-board')).toBeTruthy();
+describe('CameraOcrScreen — M-12 revue photo interactive (US-OCR-05)', () => {
+ beforeEach(() => {
+ mockReplace.mockClear();
+ mockBack.mockClear();
+ mockPush.mockClear();
+ useSettingsStore.setState({ ocrDebug: false });
});
- 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 tick();
+ 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 } },
+ ];
- 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'));
+ 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,
+ }),
+ );
- await act(async () => {});
+ render();
+ await shoot();
- expect(search).toHaveBeenCalledWith('Picsou Magazine', 547, '2023');
- expect(screen.getByTestId('ocr-found')).toBeTruthy();
+ expect(mockPush).toHaveBeenCalledWith('/scan/ocr-review');
+ expect(mockReplace).not.toHaveBeenCalled();
});
- it('oriente vers la saisie manuelle si nom ou numéro restent manquants', async () => {
- setDepsForTest(weakDeps());
- render();
- await tick();
-
- fireEvent.press(screen.getByTestId('ocr-confirm-detected'));
- fireEvent.press(screen.getByTestId('ocr-override-search'));
+ 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,
+ }),
+ );
- await act(async () => {});
+ render();
+ await shoot();
- expect(mockReplace).toHaveBeenCalledWith({
- pathname: '/scan/manual',
- params: { publication: 'Picsou Magazine', year: '2023' },
- });
+ expect(mockPush).not.toHaveBeenCalled();
+ expect(screen.getByText(/Reprenez une photo pour lire le numéro/)).toBeTruthy();
});
});
diff --git a/src/__tests__/app/collection-screen.test.tsx b/src/__tests__/app/collection-screen.test.tsx
index 6967c11..61a88ac 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(),
}));
@@ -27,7 +28,6 @@ const magazines: MagazineListItem[] = [
ocrText: null,
createdAt: '2026-09-01T10:00:00Z',
updatedAt: '2026-09-01T10:00:00Z',
- quantity: 1,
},
{
id: 'm2',
@@ -42,7 +42,6 @@ const magazines: MagazineListItem[] = [
ocrText: null,
createdAt: '2026-09-01T10:00:00Z',
updatedAt: '2026-09-01T10:00:00Z',
- quantity: 2,
},
{
id: 'm3',
@@ -57,13 +56,13 @@ const magazines: MagazineListItem[] = [
ocrText: null,
createdAt: '2026-09-01T10:00:00Z',
updatedAt: '2026-09-01T10:00:00Z',
- quantity: 0,
},
];
describe('CollectionScreen', () => {
beforeEach(() => {
mockUseFocusEffect.mockClear();
+ mockPush.mockClear();
mockUseFocusEffect.mockImplementation((cb: () => void) => cb());
mockUseLocalSearchParams.mockReturnValue({});
useCollectionStore.setState({
@@ -71,7 +70,6 @@ describe('CollectionScreen', () => {
loading: false,
error: null,
load: jest.fn(),
- loadRecent: jest.fn(),
});
});
@@ -176,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', () => {
@@ -192,12 +187,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 +200,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');
});
});
@@ -229,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'));
@@ -240,7 +238,6 @@ describe('CollectionScreen (pagination)', () => {
loading: false,
error: null,
load: jest.fn(),
- loadRecent: jest.fn(),
});
});
diff --git a/src/__tests__/app/home-screen.test.tsx b/src/__tests__/app/home-screen.test.tsx
index a71ba1a..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();
@@ -161,7 +159,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 +167,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..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', () => {
@@ -56,7 +48,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');
@@ -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/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/ocr-review-screen.test.tsx b/src/__tests__/app/ocr-review-screen.test.tsx
new file mode 100644
index 0000000..35674de
--- /dev/null
+++ b/src/__tests__/app/ocr-review-screen.test.tsx
@@ -0,0 +1,220 @@
+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',
+ };
+}
+
+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 depuis la liste.
+ seedPayload(['La Gazette du Quartier', 'N° 123']);
+ render();
+
+ fireEvent.press(screen.getByTestId('ocr-review-pick-title'));
+ 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');
+ });
+
+ it('utilise la barre de zones pour affecter un numéro (badge photo)', () => {
+ seedPayload(['Picsou Magazine', 'N° 547']);
+ render();
+
+ 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();
+ });
+});
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-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/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..56c017f 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,8 @@ 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 }),
}));
@@ -21,7 +16,7 @@ const magazine: Magazine = {
id: 'mag-1',
publication: 'Picsou Magazine',
issueNumber: 547,
- edition: null,
+ edition: 'standard',
language: null,
condition: null,
publicationDate: null,
@@ -32,22 +27,14 @@ 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),
} 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'],
@@ -58,129 +45,55 @@ 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],
});
});
- 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é (absent)', async () => {
- mockSearchByOcrFields.mockResolvedValue({
- status: 'found',
- magazine,
- publication: 'Picsou Magazine',
- issueNumber: 547,
- date: null,
- confidence: 1,
- });
- useCollectionStore.setState({ detail: { ...magazine, copies: [] }, detailLoading: false });
-
+ 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-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 });
-
+ 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-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();
+ expect(screen.getByTestId('search-magazine')).toHaveTextContent('Picsou Magazine');
});
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,
- });
-
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'));
@@ -190,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/__tests__/app/settings-screen.test.tsx b/src/__tests__/app/settings-screen.test.tsx
index 9028428..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: {
@@ -50,7 +48,7 @@ describe('AppearanceScreen', () => {
beforeEach(() => {
setColorSchemeMock.mockClear();
setColorSchemeMock.mockResolvedValue(undefined);
- useSettingsStore.setState({ colorScheme: 'system', loaded: false });
+ useSettingsStore.setState({ colorScheme: 'system' });
setDepsForTest(stubDeps());
});
@@ -133,7 +131,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 +149,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',
@@ -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,11 +166,11 @@ 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,createdAt,updatedAt\n/,
);
});
- 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,12 +186,10 @@ 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);
- 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/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/(tabs)/index.tsx b/src/app/(tabs)/index.tsx
index 845aaa7..5bd15a9 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,
@@ -32,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);
@@ -57,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 ? (
@@ -66,8 +53,8 @@ export default function HomeScreen() {
) : (
<>
- {totalCopies}
- exemplaires possédés
+ {totalMagazines}
+ éditions possédées
>
)}
@@ -98,7 +85,7 @@ export default function HomeScreen() {
Ajouts récents
- {recentCopies.length === 0 ? (
+ {recent.length === 0 ? (
Aucun ajout pour le moment.
@@ -115,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"
@@ -129,7 +116,7 @@ export default function HomeScreen() {
{magazine.publication}
{magazine.issueNumber != null ? ` n°${magazine.issueNumber}` : ''}
- {formatDate(copy.dateAdded)}
+ {formatDateLong(magazine.createdAt)}
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/(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/(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/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/_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/collection/[id]/index.tsx b/src/app/collection/[id]/index.tsx
index e699fac..fccd31b 100644
--- a/src/app/collection/[id]/index.tsx
+++ b/src/app/collection/[id]/index.tsx
@@ -11,18 +11,11 @@ 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';
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();
@@ -31,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',
@@ -48,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();
@@ -94,8 +78,6 @@ export default function MagazineDetailScreen() {
);
}
- const quantity = detail.copies.length;
-
return (
- 0} quantity={quantity} />
- {quantity > 0 && (
-
- {quantity} exemplaire{quantity > 1 ? 's' : ''}
-
- )}
+
-
+
-
+
{detail.notes ? (
@@ -143,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}
- {formatDate(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`)}
@@ -229,12 +181,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,
@@ -251,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,
@@ -293,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/app/scan/barcode.tsx b/src/app/scan/barcode.tsx
index 6b50b7a..e98cd6c 100644
--- a/src/app/scan/barcode.tsx
+++ b/src/app/scan/barcode.tsx
@@ -1,140 +1,37 @@
-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,
+ } = useBarcodeScanning();
if (!permission || !permission.granted) {
return (
@@ -162,390 +59,35 @@ 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 ef33712..f98a060 100644
--- a/src/app/scan/camera.tsx
+++ b/src/app/scan/camera.tsx
@@ -1,254 +1,38 @@
-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 { Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import type { CameraView as CameraViewType } from 'expo-camera';
+import { Feather } from '@expo/vector-icons';
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';
-
-/** 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 { 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';
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,
+ torchOn,
+ weakCycles,
+ capturing,
+ debugFrame,
+ ocrDebug,
+ setTorchOn,
+ capture,
+ stopAndRetry,
+ goManual,
+ goBarcode,
+ } = useOcrAnalysis();
if (!permission || !permission.granted) {
return (
@@ -263,16 +47,37 @@ 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 =
- 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.detected.publication
+ : state.status === 'found' || state.status === 'unknown'
+ ? state.publication
+ : null,
+ issueNumber:
+ state.status === 'analyzing'
+ ? state.detected.issueNumber
+ : state.status === 'found' || state.status === 'unknown'
+ ? state.issueNumber
+ : null,
+ date:
+ state.status === 'analyzing'
+ ? state.detected.date
+ : state.status === 'found' || state.status === 'unknown'
+ ? state.date
+ : null,
+ };
return (
@@ -285,546 +90,70 @@ 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
-
+ {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'}>
+
+
-
+
)}
{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,
+ })
+ }
+ />
)}
+
+ {ocrDebug && }
);
}
-
-function confidenceLabel(confidence: number): string {
- if (confidence >= 0.8) {
- return 'élevée';
- }
- if (confidence >= 0.5) {
- 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/app/scan/form-barcode.tsx b/src/app/scan/form-barcode.tsx
index 4c0528a..ca7156a 100644
--- a/src/app/scan/form-barcode.tsx
+++ b/src/app/scan/form-barcode.tsx
@@ -69,7 +69,6 @@ export default function FormBarcodeScreen() {
/>
-
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/multiple.tsx b/src/app/scan/multiple.tsx
index 3f7b403..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} />
+
)}
/>
@@ -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/ocr-review.tsx b/src/app/scan/ocr-review.tsx
new file mode 100644
index 0000000..2b7e9f2
--- /dev/null
+++ b/src/app/scan/ocr-review.tsx
@@ -0,0 +1,480 @@
+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 { 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 [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) });
+ };
+
+ return (
+
+
+ [styles.backButton, pressed && styles.pressed]}
+ onPress={goBack}
+ testID="ocr-review-back"
+ accessibilityRole="button"
+ accessibilityLabel="Retour"
+ hitSlop={HitTarget.hitSlop}>
+
+
+ Affiner la lecture
+
+
+
+
+ Corrigez les champs ci-dessous, ou choisissez un texte détecté dans la liste pour remplir
+ le champ à compléter.
+
+
+
+
+
+
+ {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 :
+ {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 && (
+ Choisissez un texte dans la liste.
+ )}
+
+ );
+ })}
+
+
+ [
+ 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 },
+ zoneList: {
+ marginTop: Spacing.three,
+ gap: Spacing.two,
+ },
+ zoneListTitle: {
+ fontSize: 13,
+ fontWeight: '700' as const,
+ color: colors.textSecondary,
+ textTransform: 'uppercase' as const,
+ letterSpacing: 0.5,
+ },
+ 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,
+ 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 },
+ };
+}
diff --git a/src/app/scan/result.tsx b/src/app/scan/result.tsx
index b6c1ce1..8dc6e86 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 } = useScanResult(detail, exists ? id : null);
const handleManual = () => {
router.replace({
@@ -84,12 +58,12 @@ export default function ScanResultScreen() {
{params.publication ?? 'Magazine'}
- {params.issueNumber ? N° {params.issueNumber} : null}
+ {params.issueNumber != null && params.issueNumber !== '' ? (
+ N° {params.issueNumber}
+ ) : null}
{resolved ? (
-
- {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'}
+
+ ✓ Possédé
) : (
@@ -107,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]}
@@ -197,26 +158,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 a97cc1e..11d9102 100644
--- a/src/app/scan/search.tsx
+++ b/src/app/scan/search.tsx
@@ -1,89 +1,75 @@
-import { useFocusEffect, useRouter } from 'expo-router';
-import { useCallback, useState } from 'react';
-import {
- Alert,
- KeyboardAvoidingView,
- Platform,
- Pressable,
- StyleSheet,
- Text,
- TextInput,
- View,
-} from 'react-native';
+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';
-import { AutocompleteInput } from '@/components/autocomplete-input';
import { Screen } from '@/components/screen';
-import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme';
-import { getDeps } from '@/dependencies';
+import { SelectField } from '@/components/select-field';
+import { HitTarget } from '@/constants/theme';
+import {
+ SearchFoundResult,
+ SearchUnknownResult,
+ makeSearchResultStyles,
+ makeSearchScreenStyles,
+} from '@/components/scan-search-views';
import { useThemeColors } from '@/hooks/use-theme';
-import { toast } from '@/lib/toast';
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();
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);
- const addExistingCopy = useCollectionStore((s) => s.addExistingCopy);
- 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 [result, setResult] = useState(null);
- const publications = [...new Set(magazines.map((m) => m.publication))];
-
- const foundId = result?.status === 'found' ? result.magazine.id : null;
-
- useFocusEffect(
- useCallback(() => {
- if (foundId) {
- loadDetail(foundId);
- }
- }, [foundId, loadDetail]),
- );
+ 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);
};
- 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();
@@ -92,46 +78,22 @@ 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',
- params:
- publication.trim() || issue ? { publication: publication.trim(), issueNumber: issue } : {},
+ params: issue ? { issueNumber: issue } : {},
});
};
return (
-
+
[styles.backButton, pressed && styles.pressed]}
+ style={({ pressed }) => [screenStyles.backButton, pressed && screenStyles.pressed]}
onPress={handleCancel}
testID="search-cancel"
accessibilityRole="button"
@@ -139,276 +101,106 @@ 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/${result.magazineId}`)}
+ 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({
+ edition,
+ editions,
+ issueNumber,
+ canSearch,
+ searching,
+ onEditionChange,
+ onIssueChange,
+ onSearch,
+}: {
+ edition: string | null;
+ editions: string[];
+ issueNumber: string;
+ canSearch: boolean;
+ searching: boolean;
+ onEditionChange: (v: string | null) => 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 le numéro pour rechercher.
+
+ ) : null}
+
+ );
}
diff --git a/src/backup/__tests__/backup-service.test.ts b/src/backup/__tests__/backup-service.test.ts
index e964c62..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 () => {
@@ -119,7 +114,11 @@ 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',
+ };
+ 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).
@@ -141,7 +140,20 @@ 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 () => {
+ 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');
});
});
@@ -165,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,
@@ -179,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();
@@ -189,46 +223,78 @@ 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 },
+ { id: 'dup', publication: 'P', issueNumber: 2 },
+ ],
+ });
+ await expect(service.importCollection(raw)).rejects.toThrow(
+ 'Fichier invalide : des identifiants d’édition 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 }],
+ });
+ await expect(service.importCollection(raw)).rejects.toThrow(
+ 'Fichier invalide : le numéro d’édition doit être un entier',
+ );
+ });
+
+ it('rejette une date d’export non conforme', async () => {
+ 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 = `${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.',
+ );
+ });
});
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',
- );
- 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(',,,,');
- });
});
describe('BackupService CSV import (US-BK-05)', () => {
@@ -239,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 () => {
@@ -262,28 +327,146 @@ 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 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');
+ });
+
+ it('gère des retours chariot nus (\r) comme séparateurs de ligne', async () => {
+ const csv = `${CSV_HEADERS_ROW}\rPicsou,5,,,,,,,,,\rDoublon,6,,,,,,,,,\r`;
+ const summary = await service.importCollection(csv, 'csv');
+ expect(summary).toEqual({ magazines: 2 });
+ });
+});
+
+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 }],
+ });
+ 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' }],
+ });
+ 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');
+ });
+});
+
+describe('BackupService — CSV cas limites', () => {
+ it('accepte les fins de ligne Windows (CRLF)', async () => {
+ const summary = await service.validateCollection(
+ `${CSV_HEADERS_ROW}\r\nPicsou,5,,,,,,,,,\r\n`,
+ 'csv',
+ );
+ expect(summary).toEqual({ magazines: 1 });
+ });
+
+ it('ignore les lignes vides en fin de fichier', async () => {
+ const summary = await service.validateCollection(
+ `${CSV_HEADERS_ROW}\nPicsou,5,,,,,,,,,\n\n\n`,
+ 'csv',
+ );
+ expect(summary).toEqual({ magazines: 1 });
+ });
+
+ it('déchiffre les guillemets doublés à l’intérieur d’un champ', async () => {
+ 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].notes).toBe('il a dit "OK"');
+ });
+
+ it('normalise les espaces autour des champs texte', async () => {
+ 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();
+ });
+
+ it('préserve les variantes d’édition (edition/language/condition) à l’import CSV', async () => {
+ const csv =
+ `${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']);
+ });
+
+ 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');
+ });
+});
+
+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/backup/backup-csv.ts b/src/backup/backup-csv.ts
new file mode 100644
index 0000000..a02bab9
--- /dev/null
+++ b/src/backup/backup-csv.ts
@@ -0,0 +1,195 @@
+import { generateId } from '@/utils/id';
+import { InvalidBackupError } from './backup-parsers';
+import {
+ BACKUP_FORMAT,
+ BACKUP_VERSION,
+ 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',
+ 'edition',
+ 'language',
+ 'condition',
+ 'publicationDate',
+ 'barcode',
+ 'notes',
+ 'ocrText',
+ 'createdAt',
+ 'updatedAt',
+] 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' || char === '\r') {
+ row.push(field);
+ rows.push(row);
+ row = [];
+ field = '';
+ 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;
+ 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 ? trimmed : 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 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 ?? ''}`;
+ if (!byKey.has(key)) {
+ byKey.set(key, {
+ id: generateId(),
+ publication,
+ issueNumber,
+ edition,
+ language,
+ condition,
+ publicationDate: asCsvString(at('publicationDate')),
+ barcode: asCsvString(at('barcode')),
+ notes: asCsvString(at('notes')),
+ ocrText: asCsvString(at('ocrText')),
+ createdAt: asCsvString(at('createdAt')),
+ updatedAt: asCsvString(at('updatedAt')),
+ });
+ magazines.push(byKey.get(key) as BackupMagazine);
+ }
+ }
+
+ 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) {
+ 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
new file mode 100644
index 0000000..4f080fd
--- /dev/null
+++ b/src/backup/backup-parsers.ts
@@ -0,0 +1,145 @@
+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 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.`);
+ }
+ 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[] = [];
+ const seenMagazineIds = 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.');
+ }
+
+ 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);
+
+ // 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.');
+ }
+
+ magazines.push({
+ id,
+ publication: asNonEmptyString(magazine['publication'], 'publication'),
+ issueNumber: asNullableInteger(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']),
+ createdAt: asNullableIsoDate(magazine['createdAt']),
+ updatedAt: asNullableIsoDate(magazine['updatedAt']),
+ });
+ }
+
+ return {
+ format: BACKUP_FORMAT,
+ version: BACKUP_VERSION,
+ exportedAt: asNullableIsoDate(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..89ba197 100644
--- a/src/backup/backup-service.ts
+++ b/src/backup/backup-service.ts
@@ -1,26 +1,21 @@
import type { Database } from '@/database/types';
import { APP_VERSION } from '@/utils/app-version';
-import { generateId } from '@/utils/id';
+import { parseJsonBackup, InvalidBackupError } 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';
+
+/** 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;
@@ -37,292 +32,16 @@ type MagazineRow = {
updated_at: string;
};
-type CopyRow = {
- id: string;
- magazine_id: string;
- notes: string | null;
- 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.
+ * 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.
*/
-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) {}
+ constructor(
+ private readonly db: Database,
+ private readonly maxImportBytes: number = MAX_IMPORT_BYTES,
+ ) {}
async exportCollection(): Promise {
const magazineRows = await this.db.getAllAsync(
@@ -332,23 +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();
- 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,
@@ -360,7 +62,8 @@ 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,
}));
return {
@@ -376,76 +79,34 @@ 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 (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);
}
- 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 {
magazines: file.magazines.length,
- copies: file.magazines.reduce((total, magazine) => total + magazine.copies.length, 0),
};
}
- /**
- * 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);
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) {
@@ -464,20 +125,9 @@ 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) {
- 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');
@@ -492,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 a61dd18..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,8 @@ export type BackupMagazine = {
barcode: string | null;
notes: string | null;
ocrText: string | null;
- copies: BackupCopy[];
+ createdAt: string | null;
+ updatedAt: string | null;
};
export type BackupFile = {
@@ -34,5 +29,4 @@ export type BackupFile = {
export type ImportSummary = {
magazines: number;
- copies: number;
};
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/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__/barcode-pending-sheets.test.tsx b/src/components/__tests__/barcode-pending-sheets.test.tsx
new file mode 100644
index 0000000..58e7fbe
--- /dev/null
+++ b/src/components/__tests__/barcode-pending-sheets.test.tsx
@@ -0,0 +1,100 @@
+import { render, screen, userEvent } 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(),
+ onManual: jest.fn(),
+ };
+ render(
+ ,
+ );
+ return callbacks;
+}
+
+describe('BarcodePendingSheets', () => {
+ it('affiche le panier « Déjà dans votre collection » avec le numéro', async () => {
+ const user = userEvent.setup();
+ const callbacks = renderPending({ kind: 'owned', magazine });
+
+ 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();
+
+ await user.press(screen.getByTestId('pending-owned-ok'));
+ expect(callbacks.onResume).toHaveBeenCalled();
+
+ await user.press(screen.getByTestId('pending-close'));
+ expect(callbacks.onResume).toHaveBeenCalled();
+ });
+
+ 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', async () => {
+ const user = userEvent.setup();
+ const callbacks = renderPending({ kind: 'unknown', barcode: '9791234567890' });
+
+ expect(screen.getByText('Code-barres inconnu')).toBeTruthy();
+ expect(screen.getByText('9791234567890')).toBeTruthy();
+
+ await user.press(screen.getByTestId('pending-unknown-manual'));
+ expect(callbacks.onManual).toHaveBeenCalledWith('9791234567890');
+
+ 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-owned')).toBeNull();
+ expect(screen.queryByTestId('pending-unknown')).toBeNull();
+ });
+});
+
+describe('BarcodeContinuousBar', () => {
+ 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 — vérifie chaque code-barres/)).toBeTruthy();
+
+ await user.press(screen.getByTestId('continuous-stop'));
+ expect(onStop).toHaveBeenCalled();
+ });
+});
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__/magazine-card.test.tsx b/src/components/__tests__/magazine-card.test.tsx
index 126df07..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,17 +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();
+ expect(screen.getByText('Possédé')).toBeTruthy();
});
- it('affiche Absent quand aucune copie', () => {
- render();
+ it('annonce le statut possédé au lecteur d’écran', () => {
+ render();
- expect(screen.getByTestId('status-absent')).toBeTruthy();
- expect(screen.getByText('Absent')).toBeTruthy();
+ expect(screen.getByRole('button')).toHaveAccessibleName('Picsou Magazine numéro 547, possédé');
});
});
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/__tests__/ocr-analysis.test.ts b/src/components/__tests__/ocr-analysis.test.ts
new file mode 100644
index 0000000..8be02cf
--- /dev/null
+++ b/src/components/__tests__/ocr-analysis.test.ts
@@ -0,0 +1,40 @@
+import { buildManualParams, confidenceLabel, hasAnyDetected } 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);
+ });
+ });
+});
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..4d47246
--- /dev/null
+++ b/src/components/__tests__/ocr-analyzing-overlay.test.tsx
@@ -0,0 +1,128 @@
+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; capturing: boolean }> = {},
+) {
+ const callbacks = {
+ 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 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('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.queryByTestId('ocr-detected-board')).toBeNull();
+ expect(screen.queryByTestId('ocr-confirm-detected')).toBeNull();
+ });
+
+ 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__/scan-search-views.test.tsx b/src/components/__tests__/scan-search-views.test.tsx
new file mode 100644
index 0000000..cb249eb
--- /dev/null
+++ b/src/components/__tests__/scan-search-views.test.tsx
@@ -0,0 +1,88 @@
+import { render, screen, userEvent } from '@testing-library/react-native';
+
+import { SearchFoundResult, SearchUnknownResult } from '@/components/scan-search-views';
+
+describe('SearchFoundResult', () => {
+ const onView = jest.fn();
+ const onRescan = jest.fn();
+
+ beforeEach(() => {
+ onView.mockClear();
+ onRescan.mockClear();
+ });
+
+ it('affiche le numéro quand il est renseigné et le statut possédé', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('search-magazine')).toHaveTextContent('Picsou Magazine');
+ expect(screen.getByText('N° 547')).toBeTruthy();
+ expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé');
+ });
+
+ it('s’affiche sans numéro quand celui-ci est absent', () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText('N° 547')).toBeNull();
+ });
+
+ it('sait relancer un scan ou ouvrir la fiche', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.press(screen.getByTestId('search-view'));
+ expect(onView).toHaveBeenCalled();
+ await user.press(screen.getByTestId('search-rescan'));
+ expect(onRescan).toHaveBeenCalled();
+ });
+});
+
+describe('SearchUnknownResult', () => {
+ const onManual = jest.fn();
+ const onAgain = jest.fn();
+
+ it('affiche le numéro et l’édition recherchés, avec ou sans édition', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ expect(screen.getByText(/Édition « géant »/)).toBeTruthy();
+ expect(screen.getByText('N° 547')).toBeTruthy();
+
+ await user.press(screen.getByTestId('search-manual'));
+ expect(onManual).toHaveBeenCalled();
+ await user.press(screen.getByTestId('search-again'));
+ expect(onAgain).toHaveBeenCalled();
+ });
+
+ 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/__tests__/status-badge.test.tsx b/src/components/__tests__/status-badge.test.tsx
new file mode 100644
index 0000000..98009ff
--- /dev/null
+++ b/src/components/__tests__/status-badge.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@testing-library/react-native';
+
+import { StatusBadge } from '@/components/status-badge';
+
+describe('StatusBadge', () => {
+ 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é');
+ });
+});
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/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/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 8014667..90f83b6 100644
--- a/src/components/drawer-content.tsx
+++ b/src/components/drawer-content.tsx
@@ -15,94 +15,71 @@ 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;
-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}
-
- );
+/** Un glissement ouvre le panneau au-delà de 40 % de sa largeur. */
+export function isOpenGesture(dx: number): boolean {
+ return dx > DRAWER_WIDTH * 0.4;
}
-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}}
- >
- );
+/** 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;
}
-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,
+ },
+ });
}
/**
@@ -125,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);
@@ -177,14 +155,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();
@@ -241,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/empty-state.tsx b/src/components/empty-state.tsx
index bffaf23..4ab0dd3 100644
--- a/src/components/empty-state.tsx
+++ b/src/components/empty-state.tsx
@@ -30,7 +30,7 @@ export function EmptyState({
const colors = useThemeColors();
const styles = makeStyles(colors);
return (
-
+
{title}
{message ? {message} : null}
diff --git a/src/components/magazine-card.tsx b/src/components/magazine-card.tsx
index 5ee8bb3..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,11 +21,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}` : ''}, possédé`}
accessibilityHint="Voir la fiche de l'édition"
android_ripple={{ color: 'rgba(0,0,0,0.08)' }}>
@@ -39,7 +34,7 @@ export function MagazineCard({ magazine, onPress }: Props) {
) : null}
-
+
);
}
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..33cd072 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,37 @@ 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 +52,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 +69,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 +92,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 (
+ 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]}
@@ -219,90 +179,9 @@ export function MagazineForm({
- {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 +209,78 @@ 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,
+}: {
+ values: FormValues;
+ set: (key: keyof FormValues, value: string | null) => void;
+ editions: string[];
+ languages: string[];
+}) {
+ 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"
+ />
+
+
+
+ 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..99cbc82
--- /dev/null
+++ b/src/components/scan-search-views.tsx
@@ -0,0 +1,248 @@
+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',
+ },
+ 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;
+ onView: () => void;
+ onRescan: () => void;
+};
+
+export function SearchFoundResult({
+ publication,
+ issueNumber,
+ onView,
+ onRescan,
+}: SearchFoundResultProps) {
+ const colors = useThemeColors();
+ const styles = makeSearchResultStyles(colors);
+
+ return (
+
+ Déjà dans votre collection
+
+
+ {publication}
+
+ {issueNumber != null ? N° {issueNumber} : null}
+
+ ✓ Possédé
+
+
+
+ [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 = {
+ edition?: string | null;
+ issueNumber: number | null;
+ onManual: () => void;
+ onAgain: () => void;
+};
+
+export function SearchUnknownResult({
+ edition,
+ issueNumber,
+ onManual,
+ onAgain,
+}: SearchUnknownResultProps) {
+ const colors = useThemeColors();
+ const styles = makeSearchResultStyles(colors);
+
+ const editionLabel = edition ? ` · Édition « ${edition} »` : '';
+ const numberLabel = issueNumber != null ? ` N° ${issueNumber}` : '';
+
+ return (
+
+ Non référencé
+
+
+ Aucun magazine ne correspond au numéro{numberLabel}
+ {editionLabel} 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/scan/barcode-overlay-controls.tsx b/src/components/scan/barcode-overlay-controls.tsx
new file mode 100644
index 0000000..cf6f874
--- /dev/null
+++ b/src/components/scan/barcode-overlay-controls.tsx
@@ -0,0 +1,99 @@
+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..31316d9
--- /dev/null
+++ b/src/components/scan/barcode-pending-sheets.tsx
@@ -0,0 +1,108 @@
+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;
+ onManual: (barcode: string) => void;
+};
+
+/**
+ * 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 === 'owned') {
+ return (
+
+
+
+ [styles.sheetClose, pressed && styles.buttonPressed]}
+ onPress={onResume}
+ testID="pending-close"
+ accessibilityRole="button"
+ accessibilityLabel="Fermer">
+
+
+ Déjà dans votre collection
+
+ {pending.magazine.publication}
+ {pending.magazine.issueNumber != null ? ` n° ${pending.magazine.issueNumber}` : ''}
+
+ Cette édition est bien possédée.
+ [styles.primaryButton, pressed && styles.buttonPressed]}
+ onPress={onResume}
+ testID="pending-owned-ok"
+ accessibilityRole="button">
+ Continuer
+
+
+
+
+ );
+ }
+
+ 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 — vérifie chaque code-barres
+ [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..fdf62b4
--- /dev/null
+++ b/src/components/scan/barcode-styles.ts
@@ -0,0 +1,203 @@
+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,
+ },
+ 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..2e33615
--- /dev/null
+++ b/src/components/scan/camera-ocr-styles.ts
@@ -0,0 +1,311 @@
+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,
+ },
+ 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',
+ },
+ /**
+ * Popup de résultat calée sur le scan code-barres (retours test physique) :
+ * un voile plein écran (`backdrop`) avec une carte centrée, largeur et
+ * hauteur contraintes par des marges identiques horizontal / vertical.
+ */
+ 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',
+ },
+ resultCard: {
+ alignSelf: 'center',
+ marginHorizontal: Spacing.four,
+ marginVertical: Spacing.four,
+ maxWidth: 520,
+ width: '88%',
+ backgroundColor: colors.backgroundElement,
+ borderRadius: 16,
+ padding: Spacing.three,
+ },
+ resultCardContent: {
+ alignItems: 'center',
+ gap: Spacing.two,
+ },
+ 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,
+ },
+ 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,
+ },
+ /**
+ * 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',
+ 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 + 168,
+ 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,
+ },
+ 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)',
+ },
+ });
+}
+
+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..b195870
--- /dev/null
+++ b/src/components/scan/ocr-analysis.ts
@@ -0,0 +1,70 @@
+import { MIN_CONFIDENCE } from '@/identification/ocr/ocrTextParser';
+
+/**
+ * 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 };
+
+/**
+ * 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; noText?: boolean }
+ | {
+ 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..16fb38c
--- /dev/null
+++ b/src/components/scan/ocr-analyzing-overlay.tsx
@@ -0,0 +1,110 @@
+import { ActivityIndicator, Pressable, Text, View } from 'react-native';
+import { Feather } from '@expo/vector-icons';
+
+import { useThemeColors } from '@/hooks/use-theme';
+import type { DetectedInfo } from './ocr-analysis';
+import type { CameraOcrStyles } from './camera-ocr-styles';
+
+type Props = {
+ styles: CameraOcrStyles;
+ detected: DetectedInfo;
+ hint: string;
+ weakCycles: number;
+ capturing: boolean;
+ torchOn: boolean;
+ onGoBarcode: () => void;
+ onGoManual: (detected: Partial) => void;
+ onBack: () => void;
+ onToggleTorch: () => void;
+};
+
+export function OcrAnalyzingOverlay({
+ styles,
+ detected,
+ hint,
+ weakCycles,
+ capturing,
+ torchOn,
+ onGoBarcode,
+ onGoManual,
+ onBack,
+ onToggleTorch,
+}: Props) {
+ const colors = useThemeColors();
+
+ return (
+ <>
+
+
+ {hint}
+
+ {capturing && (
+
+
+ Lecture…
+
+ )}
+
+
+
+ [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]}
+ 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-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/ocr-result-overlay.tsx b/src/components/scan/ocr-result-overlay.tsx
new file mode 100644
index 0000000..0981777
--- /dev/null
+++ b/src/components/scan/ocr-result-overlay.tsx
@@ -0,0 +1,100 @@
+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..63a0d71
--- /dev/null
+++ b/src/components/scan/use-barcode-scanning.ts
@@ -0,0 +1,120 @@
+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 type { Magazine } from '@/types';
+
+export type ScanState =
+ { status: 'idle' } | { status: 'searching' } | { status: 'invalid'; reason: 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 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 } = getDeps();
+ const result = await identificationService.identifyByBarcode(stabilized);
+
+ if (result.status === 'found') {
+ setPending({ kind: 'owned', magazine: result.magazine });
+ } 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' });
+ };
+
+ return {
+ permission,
+ requestPermission,
+ state,
+ scanning,
+ continuous,
+ torchOn,
+ pending,
+ setContinuous,
+ setTorchOn,
+ handleScan,
+ resume,
+ reset,
+ };
+}
diff --git a/src/components/scan/use-ocr-analysis.ts b/src/components/scan/use-ocr-analysis.ts
new file mode 100644
index 0000000..f96d202
--- /dev/null
+++ b/src/components/scan/use-ocr-analysis.ts
@@ -0,0 +1,196 @@
+import { useCameraPermissions } from 'expo-camera';
+import type { CameraView as CameraViewType } from 'expo-camera';
+import { useRouter } from 'expo-router';
+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,
+ type DetectedInfo,
+ type OcrDebugFrame,
+ type OcrUiState,
+} from './ocr-analysis';
+
+/**
+ * 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();
+ const [permission, requestPermission] = useCameraPermissions();
+ const [state, setState] = useState({
+ status: 'analyzing',
+ detected: 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 ocrDebug = useSettingsStore((s) => s.ocrDebug);
+
+ 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: photo?.width ?? 0,
+ height: photo?.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;
+ }
+
+ 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);
+ setState((prev) =>
+ prev.status === 'analyzing'
+ ? {
+ status: 'analyzing',
+ detected: {
+ publication:
+ result.publication === 'Publication inconnue' ? null : result.publication,
+ issueNumber: result.issueNumber,
+ date: result.date,
+ },
+ }
+ : prev,
+ );
+ 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,
+ });
+ } finally {
+ inFlight.current = false;
+ setCapturing(false);
+ }
+ };
+
+ const stopAndRetry = () => {
+ lastKeyRef.current = null;
+ voteCountRef.current = 0;
+ setWeakCycles(0);
+ setDebugFrame(null);
+ setState({ status: 'analyzing', detected: EMPTY_DETECTED });
+ };
+
+ const goManual = (detected: Partial) => {
+ router.replace({ pathname: '/scan/manual', params: buildManualParams(detected) });
+ };
+
+ const goBarcode = () => {
+ router.replace('/scan/barcode');
+ };
+
+ return {
+ cameraRef,
+ permission,
+ requestPermission,
+ state,
+ torchOn,
+ weakCycles,
+ capturing,
+ debugFrame,
+ ocrDebug,
+ setTorchOn,
+ capture,
+ stopAndRetry,
+ goManual,
+ goBarcode,
+ };
+}
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 c9868ec..0275870 100644
--- a/src/components/status-badge.tsx
+++ b/src/components/status-badge.tsx
@@ -4,38 +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,
- * `accessibilityRole="status"`, 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/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/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 20c451a..8bd89a3 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;
@@ -19,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',
]);
@@ -48,4 +49,123 @@ 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()
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValue({ user_version: getSchemaVersion() }),
+ execAsync: jest.fn().mockResolvedValue(undefined),
+ } as unknown as Database;
+
+ await migrate(db);
+
+ 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),
+ execAsync: jest.fn().mockResolvedValue(undefined),
+ } as unknown as Database;
+
+ 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/migrations.ts b/src/database/migrations.ts
index fbb26a9..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 {
@@ -25,10 +27,31 @@ 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;
+ }
}
}
+
+ 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
deleted file mode 100644
index 8ea5c21..0000000
--- a/src/database/repositories/__tests__/collection-repository.test.ts
+++ /dev/null
@@ -1,177 +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.listByMagazine(magazineId);
- expect(reloaded).toHaveLength(1);
- expect(reloaded[0]).toEqual(copy);
- });
-
- 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('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);
- 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([]);
- });
-});
-
-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);
- });
-});
diff --git a/src/database/repositories/__tests__/magazine-repository.test.ts b/src/database/repositories/__tests__/magazine-repository.test.ts
index 761a975..1af6ee1 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({
@@ -169,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');
@@ -237,6 +164,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', () => {
@@ -256,46 +194,14 @@ describe('magazineRepository.list', () => {
]);
});
- 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 });
+ 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).toHaveLength(1);
- expect(list[0].quantity).toBe(0);
+ expect(list.map((m) => m.publication)).toEqual(['Échos Vacances', 'Espiègle', 'etu-SORCIER']);
});
it('reste leger : ne charge ni notes ni ocr_text', async () => {
@@ -352,24 +258,27 @@ 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',
- );
+ 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 });
- const results = await repo.search('Picsou');
+ 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']);
+ });
- expect(results[0].quantity).toBe(1);
+ 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', () => {
- 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,
@@ -381,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({
@@ -409,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();
+ });
+});
+
+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(detail).not.toBeNull();
- expect(detail?.copies).toEqual([]);
+ 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);
});
});
@@ -469,7 +367,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 +375,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');
});
@@ -518,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 814f148..7ebd788 100644
--- a/src/database/repositories/__tests__/settings-repository.test.ts
+++ b/src/database/repositories/__tests__/settings-repository.test.ts
@@ -71,3 +71,47 @@ 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);
+ });
+});
+
+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/database/repositories/collection-repository.ts b/src/database/repositories/collection-repository.ts
deleted file mode 100644
index 89cc129..0000000
--- a/src/database/repositories/collection-repository.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-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) {}
-
- 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 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;
- 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 719eb49..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 & {
@@ -52,33 +50,44 @@ function toMagazine(
};
}
-export class MagazineRepository {
- constructor(private readonly db: Database) {}
+/**
+ * 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: Magazine, b: Magazine): 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);
+}
- 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,
- );
+/** 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, '');
+}
- return row ? toMagazine(row) : null;
- }
+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);
}
/**
@@ -95,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`,
@@ -109,19 +116,13 @@ 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`,
- );
+ const rows = await this.db.getAllAsync(`${LIST_SELECT}`);
- return rows.map((row) => ({ ...toMagazine(row), quantity: row.quantity }));
+ return rows.map(toMagazine).sort(compareByPublication);
}
async search(query: string): Promise {
- const term = query.trim();
+ const term = normalizeText(query.trim());
if (!term) {
return this.list();
}
@@ -129,56 +130,43 @@ export class MagazineRepository {
const numeric = Number(term);
const isNumeric = Number.isFinite(numeric);
- const rows = await this.db.getAllAsync<
- Omit & { quantity: number }
- >(
+ const all = await this.list();
+ return all
+ .filter(
+ (magazine) =>
+ (isNumeric && magazine.issueNumber === numeric) ||
+ normalizeText(magazine.publication).includes(term),
+ )
+ .sort(compareByPublication);
+ }
+
+ /** 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}
- 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,
+ ORDER BY created_at DESC, rowid DESC
+ LIMIT ?`,
+ limit,
);
- return rows.map((row) => ({ ...toMagazine(row), quantity: row.quantity }));
+ return rows.map(toMagazine);
}
- 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,
+ async countAll(): Promise {
+ const row = await this.db.getFirstAsync<{ count: number }>(
+ 'SELECT COUNT(*) AS count FROM magazines',
);
+ return Number(row?.count ?? 0);
+ }
- if (!row) {
- return null;
- }
-
- 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`,
+ async findById(id: string): Promise {
+ const row = await this.db.getFirstAsync(
+ `${DETAIL_SELECT}
+ WHERE id = ?`,
id,
);
- const copies: CollectionItem[] = copyRows.map((r) => ({
- id: r.id,
- magazineId: r.magazine_id,
- notes: r.notes,
- dateAdded: r.date_added,
- }));
-
- return { ...toMagazine(row), copies };
+ return row ? toMagazine(row) : null;
}
async delete(id: string): Promise {
@@ -231,9 +219,7 @@ export class MagazineRepository {
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,
);
@@ -251,7 +237,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 +247,8 @@ export class MagazineRepository {
input.condition ?? null,
input.publicationDate ?? null,
input.barcode ?? null,
+ input.notes ?? null,
+ input.ocrText ?? null,
updatedAt,
id,
);
@@ -273,6 +262,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/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/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 ce2738e..bff4715 100644
--- a/src/dependencies.ts
+++ b/src/dependencies.ts
@@ -1,19 +1,21 @@
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';
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';
import type { FileGateway } from '@/backup/file-gateway';
export interface Dependencies {
magazineRepository: MagazineRepository;
- collectionRepository: CollectionRepository;
settingsRepository: SettingsRepository;
identificationService: IdentificationService;
ocrEngine: OcrEngine;
@@ -43,14 +45,19 @@ 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, 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 ExpoImagePreprocessor())
+ : new NoopOcrEngine();
+ const magazineRepository = new MagazineRepository(db);
deps = {
- magazineRepository: new MagazineRepository(db),
- collectionRepository: new CollectionRepository(db),
+ magazineRepository,
settingsRepository: new SettingsRepository(db),
- identificationService: new IdentificationService(new MagazineRepository(db)),
+ identificationService: new IdentificationService(magazineRepository),
ocrEngine,
backupService: new BackupService(db),
fileGateway: new NativeFileGateway(),
diff --git a/src/hooks/use-scan-result.ts b/src/hooks/use-scan-result.ts
new file mode 100644
index 0000000..fe23af2
--- /dev/null
+++ b/src/hooks/use-scan-result.ts
@@ -0,0 +1,13 @@
+import type { Magazine } from '@/types';
+
+/**
+ * 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: Magazine | null, id: string | null) {
+ const resolved = detail != null && detail.id === id;
+ return { resolved };
+}
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/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/ocr/__tests__/ocrCandidateAnalyzer.test.ts b/src/identification/ocr/__tests__/ocrCandidateAnalyzer.test.ts
new file mode 100644
index 0000000..7a40e4b
--- /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();
+ });
+});
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__/ocrImagePreprocessor.test.ts b/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts
new file mode 100644
index 0000000..df36ba0
--- /dev/null
+++ b/src/identification/ocr/__tests__/ocrImagePreprocessor.test.ts
@@ -0,0 +1,117 @@
+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();
+ });
+});
diff --git a/src/identification/ocr/__tests__/ocrProposals.test.ts b/src/identification/ocr/__tests__/ocrProposals.test.ts
new file mode 100644
index 0000000..f8348e2
--- /dev/null
+++ b/src/identification/ocr/__tests__/ocrProposals.test.ts
@@ -0,0 +1,113 @@
+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);
+ });
+});
diff --git a/src/identification/ocr/__tests__/ocrResultMapper.test.ts b/src/identification/ocr/__tests__/ocrResultMapper.test.ts
new file mode 100644
index 0000000..179a30c
--- /dev/null
+++ b/src/identification/ocr/__tests__/ocrResultMapper.test.ts
@@ -0,0 +1,71 @@
+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');
+ });
+});
diff --git a/src/identification/ocr/__tests__/ocrTextParser.test.ts b/src/identification/ocr/__tests__/ocrTextParser.test.ts
index 26272d5..f2cc6ca 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');
@@ -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/identification/ocr/mlKitOcrEngine.ts b/src/identification/ocr/mlKitOcrEngine.ts
index 93c5301..3bcd0df 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.
diff --git a/src/identification/ocr/ocrCandidateAnalyzer.ts b/src/identification/ocr/ocrCandidateAnalyzer.ts
new file mode 100644
index 0000000..f8cad98
--- /dev/null
+++ b/src/identification/ocr/ocrCandidateAnalyzer.ts
@@ -0,0 +1,275 @@
+/**
+ * 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;
+}
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/ocr/ocrImagePreprocessor.ts b/src/identification/ocr/ocrImagePreprocessor.ts
new file mode 100644
index 0000000..4c9bc9b
--- /dev/null
+++ b/src/identification/ocr/ocrImagePreprocessor.ts
@@ -0,0 +1,90 @@
+/**
+ * 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;
+ }
+}
diff --git a/src/identification/ocr/ocrProposals.ts b/src/identification/ocr/ocrProposals.ts
new file mode 100644
index 0000000..b2c72b4
--- /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'
+ );
+}
diff --git a/src/identification/ocr/ocrResultMapper.ts b/src/identification/ocr/ocrResultMapper.ts
new file mode 100644
index 0000000..31247ec
--- /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 };
+}
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..c961654 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
diff --git a/src/identification/scanBarcode.ts b/src/identification/scanBarcode.ts
index fdf04e0..a600f71 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.
*
@@ -78,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)
@@ -95,7 +88,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/lib/__tests__/collection-list.test.ts b/src/lib/__tests__/collection-list.test.ts
new file mode 100644
index 0000000..76061f2
--- /dev/null
+++ b/src/lib/__tests__/collection-list.test.ts
@@ -0,0 +1,114 @@
+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: '',
+});
+
+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/__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/__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__/ocr-pending.test.ts b/src/lib/__tests__/ocr-pending.test.ts
new file mode 100644
index 0000000..11fe1aa
--- /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();
+ });
+});
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/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);
+}
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 };
diff --git a/src/lib/ocr-pending.ts b/src/lib/ocr-pending.ts
new file mode 100644
index 0000000..1ccca98
--- /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;
+}
diff --git a/src/store/__tests__/use-backup-store.test.ts b/src/store/__tests__/use-backup-store.test.ts
index 862740c..02e6ea9 100644
--- a/src/store/__tests__/use-backup-store.test.ts
+++ b/src/store/__tests__/use-backup-store.test.ts
@@ -1,8 +1,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';
@@ -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\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');
});
@@ -119,6 +115,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', () => {
@@ -132,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');
});
@@ -147,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');
});
@@ -181,6 +194,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', () => {
@@ -193,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();
@@ -210,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');
});
@@ -218,4 +256,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..eface39 100644
--- a/src/store/__tests__/use-collection-store.test.ts
+++ b/src/store/__tests__/use-collection-store.test.ts
@@ -4,34 +4,23 @@ 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 mockFindRecent = jest.fn();
+const mockCountAll = jest.fn();
const mockMagazineRepo = {
list: mockList,
create: mockCreate,
delete: mockDelete,
- findByBarcode: mockFindByBarcode,
findById: mockFindById,
update: mockUpdate,
-};
-
-const mockListRecentCopies = jest.fn();
-const mockCountAllCopies = jest.fn();
-const mockAddCopy = jest.fn();
-const mockCollectionRepo = {
- listRecentCopies: mockListRecentCopies,
- countAllCopies: mockCountAllCopies,
- addCopy: mockAddCopy,
- countByMagazine: jest.fn(),
- listByMagazine: jest.fn(),
- deleteCopy: jest.fn(),
+ findRecent: mockFindRecent,
+ countAll: mockCountAll,
};
jest.mock('@/dependencies', () => ({
getDeps: jest.fn(() => ({
magazineRepository: mockMagazineRepo,
- collectionRepository: mockCollectionRepo,
})),
}));
@@ -48,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', () => {
@@ -56,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);
});
@@ -86,15 +77,8 @@ describe('useCollectionStore', () => {
expect(state.loaded).toBe(false);
});
- it('ajoute une edition et un exemplaire, puis incremente le compteur', async () => {
- const created = { ...magazine, quantity: 1 };
- mockCreate.mockResolvedValue(created);
- mockAddCopy.mockResolvedValue({
- id: 'c1',
- magazineId: 'mag-1',
- notes: null,
- dateAdded: 'x',
- });
+ it('ajoute une edition et incremente le compteur', async () => {
+ mockCreate.mockResolvedValue(magazine);
await useCollectionStore
.getState()
@@ -102,68 +86,53 @@ describe('useCollectionStore', () => {
const state = useCollectionStore.getState();
expect(state.magazines).toHaveLength(1);
- expect(state.totalCopies).toBe(1);
- expect(mockAddCopy).toHaveBeenCalledWith('mag-1');
+ 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('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('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();
@@ -173,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');
@@ -194,19 +171,20 @@ describe('useCollectionStore', () => {
expect(useCollectionStore.getState().detailLoading).toBe(false);
});
- it('efface le detail a la sortie de la fiche', () => {
- useCollectionStore.setState({ detail: { ...magazine, copies: [] } });
+ it('traduit une erreur inconnue lors du chargement du detail', async () => {
+ mockFindById.mockRejectedValue('panne');
- useCollectionStore.getState().clearDetail();
+ const detail = await useCollectionStore.getState().loadDetail('mag-1');
- expect(useCollectionStore.getState().detail).toBeNull();
+ 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', {
@@ -216,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');
});
@@ -228,40 +205,92 @@ 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.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 () => {
+ 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('supprime le detail quand il correspond a l edition retiree', async () => {
+ mockDelete.mockResolvedValue(undefined);
+ useCollectionStore.setState({
+ magazines: [magazine],
+ totalMagazines: 4,
+ detail: { ...magazine },
+ });
+
+ await useCollectionStore.getState().removeMagazine('mag-1');
+
+ expect(useCollectionStore.getState().detail).toBeNull();
+ });
+
+ it('retire l’edition des recentes et decremente le compteur', async () => {
+ mockDelete.mockResolvedValue(undefined);
+ useCollectionStore.setState({
+ magazines: [magazine],
+ recent: [{ ...magazine }],
+ totalMagazines: 4,
+ });
+
+ await useCollectionStore.getState().removeMagazine('mag-1');
const state = useCollectionStore.getState();
- expect(state.detail?.copies).toEqual([copy]);
+ 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 f05720d..50352f5 100644
--- a/src/store/__tests__/use-settings-store.test.ts
+++ b/src/store/__tests__/use-settings-store.test.ts
@@ -5,11 +5,14 @@ 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);
+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'],
@@ -19,13 +22,17 @@ function stubDeps(): Dependencies {
setColorScheme: setColorSchemeMock,
getOnboardingDone: getOnboardingDoneMock,
setOnboardingDone: setOnboardingDoneMock,
+ getReducedMotion: getReducedMotionMock,
+ setReducedMotion: setReducedMotionMock,
+ getOcrDebug: getOcrDebugMock,
+ setOcrDebug: setOcrDebugMock,
} as unknown as Dependencies['settingsRepository'],
};
}
describe('useSettingsStore', () => {
beforeEach(() => {
- useSettingsStore.setState({ colorScheme: 'system', loaded: false });
+ useSettingsStore.setState({ colorScheme: 'system' });
setColorSchemeMock.mockClear();
setColorSchemeMock.mockResolvedValue(undefined);
getColorSchemeMock.mockClear();
@@ -34,6 +41,14 @@ describe('useSettingsStore', () => {
getOnboardingDoneMock.mockResolvedValue(false);
setOnboardingDoneMock.mockClear();
setOnboardingDoneMock.mockResolvedValue(undefined);
+ getReducedMotionMock.mockClear();
+ getReducedMotionMock.mockResolvedValue(false);
+ setReducedMotionMock.mockClear();
+ setReducedMotionMock.mockResolvedValue(undefined);
+ getOcrDebugMock.mockClear();
+ getOcrDebugMock.mockResolvedValue(false);
+ setOcrDebugMock.mockClear();
+ setOcrDebugMock.mockResolvedValue(undefined);
});
it('initialise le colorScheme sur system', () => {
@@ -64,7 +79,6 @@ describe('useSettingsStore', () => {
await useSettingsStore.getState().loadColorScheme();
expect(useSettingsStore.getState().colorScheme).toBe('dark');
- expect(useSettingsStore.getState().loaded).toBe(true);
});
it('accepte le retour à system', () => {
@@ -97,6 +111,79 @@ 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);
+ });
+
+ 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();
});
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) {
diff --git a/src/store/use-collection-store.ts b/src/store/use-collection-store.ts
index 2e82e9a..fbc463e 100644
--- a/src/store/use-collection-store.ts
+++ b/src/store/use-collection-store.ts
@@ -1,70 +1,56 @@
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;
- loadRecent: () => 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;
- clearDetail: () => void;
}
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' });
}
},
- 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 {
- 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' });
}
@@ -83,33 +69,15 @@ export const useCollectionStore = create((set, get) => ({
}
},
- clearDetail: () => set({ detail: null }),
-
addMagazine: async (input) => {
- const { magazineRepository, collectionRepository } = getDeps();
+ const { magazineRepository } = getDeps();
const magazine = await magazineRepository.create(input);
- await collectionRepository.addCopy(magazine.id);
- 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);
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) => {
@@ -119,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,
}));
@@ -131,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/store/use-settings-store.ts b/src/store/use-settings-store.ts
index 76b6f1f..5f85bc4 100644
--- a/src/store/use-settings-store.ts
+++ b/src/store/use-settings-store.ts
@@ -1,29 +1,30 @@
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;
+ 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) => ({
colorScheme: 'system',
- loaded: false,
onboardingDone: false,
onboardingLoaded: false,
reducedMotion: false,
+ ocrDebug: false,
setColorScheme: (colorScheme) => {
set({ colorScheme });
@@ -38,7 +39,7 @@ export const useSettingsStore = create((set) => ({
loadColorScheme: async () => {
const colorScheme = await getDeps().settingsRepository.getColorScheme();
- set({ colorScheme, loaded: true });
+ set({ colorScheme });
},
loadOnboardingDone: async () => {
@@ -72,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 });
+ },
}));
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;
-};
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';