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 @@ ![Database](https://img.shields.io/badge/Base%20de%20donne%CC%81es-Locale%20SQLite-orange?style=for-the-badge&logo=sqlite&logoColor=white&color=47A248) ![Offline](https://img.shields.io/badge/Offline%20First-100%25-brightgreen?style=for-the-badge&logo=cloud-offline&color=2E7D32) ![Status](https://img.shields.io/badge/Statut-En%20de%CC%81veloppement-informational?style=for-the-badge&color=00629E) -![Version](https://img.shields.io/badge/Version-0.9.3-yellow?style=for-the-badge&color=FDD835) +![Version](https://img.shields.io/badge/Version-1.0.0-yellow?style=for-the-badge&color=FDD835)
@@ -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';