Claude commited on
Commit
71f166b
·
unverified ·
1 Parent(s): 5c1dfb1

feat(migration): Phase 0 du retrait legacy — foundation

Browse files

Plan complet de retrait du legacy en 11 phases livré dans
docs/migration/legacy-retirement-plan.md. Phase 0 = poser les
garde-fous qui rendent les phases 1-11 vérifiables sans introduire
de régression invisible.

Livrables Phase 0
=================

P0.1 — Test architectural anti-imports legacy.
tests/architecture/test_no_legacy_imports_in_rewrite.py scanne
via AST tous les fichiers des paquets rewrite (domain, formats,
evaluation, pipeline, adapters, app, reports_v2, interfaces) et
rejette tout import depuis un paquet legacy (core, measurements,
engines, llm, pipelines, report, web, cli, extras, modules).
État initial : VERT — le rewrite est déjà clean.

P0.2 — Doc des tolérances de régression.
docs/migration/regression-tolerances.md définit les ε par
métrique : CER 0, Wilcoxon p-value 1e-9, HTML diff sémantique,
narrative facts égalité ensembliste, JSON sort_keys. Politique
d'aléatoire (seed=42). Stratégie cloud (fixtures figées).
Procédure d'exception pour régressions intentionnelles.

P0.3 — Harness regression legacy ↔ rewrite.
tests/regression/legacy_vs_rewrite/ avec conftest fixtures
corpus synthétique (3 docs / 30 docs, gitignore corpora) +
helpers golden (assert_golden_match avec flag --regen-golden) +
comparateurs sémantiques (floats, sets, JSON). Marker regression
enregistré et exclu de addopts (opt-in via pytest -m regression).
16 smoke tests valident le harness lui-même.

P0.4 — Tracker statue Phase 0 done.

Validation : pytest archi (3 passed), regression (16 passed), suite
défaut (5018 passed), ruff clean.

Phase 1 (foundation conceptuelle, core/results → domain/run_result)
peut démarrer sans risque.

CLAUDE.md CHANGED
@@ -101,7 +101,7 @@ picarones/
101
 
102
  ## État des tests et bugs historiques
103
 
104
- `pytest tests/` → **5040 passed, 12 skipped, 8 deselected, 0 failed**
105
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
106
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
107
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
@@ -242,7 +242,7 @@ détecte, arbitre, rend.
242
  ## Contexte développement
243
 
244
  - **Environnement** : GitHub Codespaces, Python 3.11+
245
- - **Tests** : `pytest tests/ -q` → ~5040 passed, 2 skipped, 0 failed.
246
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
247
  - **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
248
  - **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
 
101
 
102
  ## État des tests et bugs historiques
103
 
104
+ `pytest tests/` → **5050 passed, 12 skipped, 8 deselected, 0 failed**
105
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
106
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
107
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
 
242
  ## Contexte développement
243
 
244
  - **Environnement** : GitHub Codespaces, Python 3.11+
245
+ - **Tests** : `pytest tests/ -q` → ~5050 passed, 2 skipped, 0 failed.
246
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
247
  - **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
248
  - **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
README.md CHANGED
@@ -396,7 +396,7 @@ ruff check picarones/ tests/
396
  python -m mypy picarones/core/
397
  ```
398
 
399
- **Test suite**: ~5040 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
396
  python -m mypy picarones/core/
397
  ```
398
 
399
+ **Test suite**: ~5050 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
docs/migration/legacy-retirement-plan.md CHANGED
@@ -38,7 +38,7 @@ remplis :
38
 
39
  ## Phases
40
 
41
- ### Phase 0 — Foundation (en cours)
42
 
43
  **Objectif** : poser les garde-fous qui rendent les 11 phases
44
  suivantes **vérifiables** sans introduire de régression invisible.
@@ -47,20 +47,29 @@ suivantes **vérifiables** sans introduire de régression invisible.
47
 
48
  - [x] `docs/migration/legacy-retirement-plan.md` (ce document) —
49
  inventaire complet, phases, acceptance criteria.
50
- - [ ] `tests/regression/legacy_vs_rewrite/` — harness qui exécute
51
- legacy + rewrite sur 3 corpus de référence et compare bit-for-bit
52
- (avec ε explicite par métrique).
53
- - [ ] `docs/migration/regression-tolerances.md` — table des
54
- tolérances acceptables par métrique (ex : CER ε = 0, narrative
55
- templates ε = 0 mais ordre des facts non-significatif, etc.).
56
- - [ ] Test architectural `test_no_legacy_imports_in_rewrite.py` qui
57
- garantit qu'un module rewrite ne réintroduit jamais d'import
58
- legacy.
59
-
60
- **Critère de fin** : harness vert sur 3 corpus de référence pour
61
- les fonctionnalités déjà migrées (5 OCR, 4 LLM, 4 VLM, vues
62
- canoniques). Toute migration future doit ajouter son corpus de
63
- régression.
 
 
 
 
 
 
 
 
 
64
 
65
  ### Phase 1 — Foundation conceptuelle (`core/`, `domain/`)
66
 
@@ -224,8 +233,8 @@ over_normalization.detect_over_normalization`.
224
  **Module** : `modules/alto_text_to_mono_region.TextToAltoMonoRegion`
225
  (310 LOC) — baseline TEXT → ALTO.
226
 
227
- **Cible** : `picarones/formats/alto/baseline_reconstruction.py` ou
228
- `picarones/evaluation/projectors/text_to_alto.py` (selon où la
229
  sémantique colle le mieux).
230
 
231
  **Effort** : 1 jour.
@@ -347,7 +356,8 @@ mais le CER a glissé de 0,002 par doc »*.
347
 
348
  | Phase | Statut |
349
  |-------|--------|
350
- | 0 | 🟡 En cours |
351
- | 1-11 | ⚪ À démarrer |
 
352
 
353
- **Dernière mise à jour** : 2026-05.
 
38
 
39
  ## Phases
40
 
41
+ ### Phase 0 — Foundation terminée
42
 
43
  **Objectif** : poser les garde-fous qui rendent les 11 phases
44
  suivantes **vérifiables** sans introduire de régression invisible.
 
47
 
48
  - [x] `docs/migration/legacy-retirement-plan.md` (ce document) —
49
  inventaire complet, phases, acceptance criteria.
50
+ - [x] `docs/migration/regression-tolerances.md` — table des
51
+ tolérances acceptables par métrique et type d'output (CER ε=0,
52
+ Wilcoxon ε=1e-9, HTML diff sémantique, narrative facts égalité
53
+ ensembliste, etc.).
54
+ - [x] `tests/regression/legacy_vs_rewrite/` harness scaffolding :
55
+ fixtures de corpus synthétique (small=3 docs, medium=30 docs,
56
+ large laissé pour ajout opportuniste) + gestion golden snapshot
57
+ avec flag `--regen-golden` + comparateurs sémantiques (floats,
58
+ sets, JSON). Marker `regression` enregistré et exclu de
59
+ ``addopts`` par défaut (opt-in via `pytest -m regression`).
60
+ Smoke test couvre les 16 invariants du harness lui-même.
61
+ - [x] `tests/architecture/test_no_legacy_imports_in_rewrite.py`
62
+ garantit qu'aucun fichier des paquets `domain/`, `formats/`,
63
+ `evaluation/`, `pipeline/`, `adapters/`, `app/`, `reports_v2/`,
64
+ `interfaces/` n'importe depuis un paquet legacy. AST-based,
65
+ pas regex syntaxique. État initial : **vert** — le rewrite est
66
+ déjà clean.
67
+
68
+ **Acceptance** : ✅ remplie. Le harness est prêt à recevoir les
69
+ tests de régression de chaque phase suivante (`test_phase1_*.py`,
70
+ `test_phase2_*.py`, etc.). Toute fonctionnalité migrée DOIT
71
+ avoir son test de régression ajouté ici en même temps que le
72
+ code.
73
 
74
  ### Phase 1 — Foundation conceptuelle (`core/`, `domain/`)
75
 
 
233
  **Module** : `modules/alto_text_to_mono_region.TextToAltoMonoRegion`
234
  (310 LOC) — baseline TEXT → ALTO.
235
 
236
+ **Cible** : `picarones.formats.alto.baseline_reconstruction` ou
237
+ `picarones.evaluation.projectors.text_to_alto` (selon où la
238
  sémantique colle le mieux).
239
 
240
  **Effort** : 1 jour.
 
356
 
357
  | Phase | Statut |
358
  |-------|--------|
359
+ | 0 | Terminée |
360
+ | 1 | ⚪ À démarrer |
361
+ | 2-11 | ⚪ À démarrer |
362
 
363
+ **Dernière mise à jour** : 2026-05 (Phase 0 livrée).
docs/migration/regression-tolerances.md ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tolérances de régression — legacy ↔ rewrite
2
+
3
+ > **Audience** : développeur qui migre une fonctionnalité legacy
4
+ > vers le rewrite, reviewer qui relit la PR.
5
+ >
6
+ > **Référence** : [`legacy-retirement-plan.md`](legacy-retirement-plan.md).
7
+ >
8
+ > **Contrat** : le harness `tests/regression/legacy_vs_rewrite/`
9
+ > exécute legacy + rewrite sur les mêmes corpus de référence et
10
+ > compare leurs sorties. Toute divergence au-delà de la tolérance
11
+ > ε définie ici est une **régression à corriger avant merge**.
12
+ >
13
+ > Une régression peut être :
14
+ >
15
+ > - **Intentionnelle** : la phase de migration corrige un bug
16
+ > historique → la tolérance est temporairement relâchée AVEC
17
+ > commentaire pointant vers l'issue.
18
+ > - **Inattendue** : c'est ce que ce document est censé empêcher.
19
+
20
+ ## Principe général
21
+
22
+ Pour une fonctionnalité donnée, la sortie du rewrite **doit être
23
+ égale** à celle du legacy à la tolérance ε près. L'égalité est :
24
+
25
+ - **Bit-for-bit** quand l'output est déterministe (texte, hash, JSON).
26
+ - **Sémantique** quand l'output structurel a des libertés (ordre des
27
+ éléments d'un set, indentation HTML, ordre des facts narratifs
28
+ équivalents).
29
+
30
+ ## Table des tolérances par type d'output
31
+
32
+ ### Métriques numériques
33
+
34
+ | Métrique | ε | Justification |
35
+ |----------|---|---------------|
36
+ | `cer_raw`, `cer_nfc`, `cer_caseless`, `cer_diplomatic` | **0** (bit-for-bit) | jiwer est déterministe ; toute différence = changement de pré/post-processing |
37
+ | `wer`, `mer`, `wil` | **0** | idem |
38
+ | `bleu`, `chrf` | **1e-9** | flottants — réordonnancements internes acceptables |
39
+ | `precision`, `recall`, `f1` (NER) | **1e-9** | flottants |
40
+ | `mufi_coverage`, `abbreviation_expansion_score` | **0** | comptage entier sur ensembles fermés |
41
+ | `roman_numerals_accuracy` | **0** | parsing déterministe |
42
+ | `unicode_blocks_accuracy` | **0** | tables Unicode déterministes |
43
+ | `reading_order_f1` (ICDAR 2015) | **1e-9** | algorithme déterministe, flottants |
44
+ | `layout_f1` | **1e-9** | flottants |
45
+ | `confusion_matrix.entries` | **0** | comptage entier |
46
+ | `taxonomy.error_class_*` | **0** | classification déterministe sur règles |
47
+
48
+ ### Tests statistiques
49
+
50
+ | Test | ε | Justification |
51
+ |------|---|---------------|
52
+ | Wilcoxon `p_value` | **1e-9** | scipy `wilcoxon` est déterministe à entrée constante |
53
+ | Friedman `chi2`, `p_value` | **1e-9** | idem |
54
+ | Nemenyi (matrice p-values) | **1e-9** | dérivé de Friedman |
55
+ | Bootstrap CI 95 % | **1e-3** | random seed FIXÉ explicitement (cf. `bootstrap.py` du legacy : `seed=42`) ; la tolérance laisse une marge minuscule pour les ré-implémentations qui itéreraient dans un ordre différent à seed identique |
56
+ | Pareto front (set d'engines dominants) | **0** (bit-for-bit en tant qu'ensemble) | dominance Pareto stable sur entrées identiques |
57
+ | CDD (Critical Difference Diagram) coordonnées SVG | **1e-3** sur les positions (px) | rendu Matplotlib peut varier sur des sub-pixels selon backend |
58
+ | Clustering (labels) | **0** sur l'**ensemble** des classes (l'étiquetage interne 0/1/2 peut différer mais la partition doit être identique) | un test custom compare les partitions, pas les labels |
59
+ | Corrélation Spearman / Pearson | **1e-9** | flottants |
60
+
61
+ ### Calibration
62
+
63
+ | Output | ε | Justification |
64
+ |--------|---|---------------|
65
+ | ECE, MCE | **1e-9** | flottants, pas d'aléatoire |
66
+ | Reliability diagram (bins, freq, conf) | **0** sur les bins, **1e-9** sur les valeurs | binning déterministe |
67
+
68
+ ### Confidences sidecar (S50 sur Tesseract)
69
+
70
+ | Output | ε |
71
+ |--------|---|
72
+ | `tokens[].text` | **0** (string identique) |
73
+ | `tokens[].confidence` | **0** | Tesseract retourne un entier 0-100 ; division exacte par 100 → flottant binairement identique en IEEE-754 |
74
+ | `extractor`, `model_version` | **0** |
75
+
76
+ ### HTML (rapport `reports_v2/html/render.py`)
77
+
78
+ Le diff HTML est **structurel**, pas lexical :
79
+
80
+ - Mêmes éléments DOM avec mêmes attributs sémantiques (`data-*`,
81
+ `aria-*`, `id`, `class`).
82
+ - Mêmes valeurs textuelles dans les nœuds de texte.
83
+ - L'**ordre** des sections doit être identique.
84
+ - L'indentation et le whitespace inter-éléments sont **ignorés**.
85
+ - Le contenu d'un `<script>` est comparé après normalisation
86
+ d'espace blanc.
87
+
88
+ Implémenté via une fonction `assert_html_semantically_equal(a, b)`
89
+ qui parse les deux HTML avec `lxml` (ou `html.parser` fallback) et
90
+ compare l'arbre.
91
+
92
+ ### CSV (`reports_v2/csv/render.py`)
93
+
94
+ | Output | ε |
95
+ |--------|---|
96
+ | Header row | **0** (identique exact) |
97
+ | Data rows (set non ordonné) | **0** sur l'ensemble |
98
+ | Ordre des lignes | autorisé à différer | les renderers triaient parfois différemment ; seule l'égalité ensembliste est exigée |
99
+ | Format des nombres | **0** (le rewrite formate à 6 décimales `f"{v:.6f}"`) | déterministe |
100
+
101
+ ### JSON (`reports_v2/json/render.py`)
102
+
103
+ | Output | ε |
104
+ |--------|---|
105
+ | Bit-for-bit identique | **0** | le rewrite utilise `model_dump(mode="json")` Pydantic + `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` ; le legacy doit être amené au même contrat dans la phase concernée |
106
+
107
+ ### Narrative facts (Phase 3)
108
+
109
+ | Aspect | ε |
110
+ |--------|---|
111
+ | Ensemble des `Fact` produits (par `FactType`) | **0** sur l'ensemble | l'arbitre peut réordonner mais pas inventer ni rater un fact |
112
+ | Payload de chaque fact (les valeurs numériques citées) | **0** (bit-for-bit) | garde-fou anti-hallucination |
113
+ | Templates rendus FR + EN | **0** sur le texte | déterministe par `str.format_map` |
114
+ | Ordre final des facts dans la synthèse | **autorisé à différer** | l'arbitre du rewrite peut choisir un ordre différent si la priorité est respectée — un test custom valide « les facts HIGH apparaissent avant les MEDIUM » plutôt que l'ordre exact |
115
+
116
+ ### Rapport HTML — sections legacy spécifiques (Phase 5)
117
+
118
+ Pour chaque renderer migré (calibration, NER, Pareto, narrative,
119
+ philological, etc.), un cas-test de régression dédié vit dans
120
+ `tests/regression/legacy_vs_rewrite/test_phase5_<renderer>.py`.
121
+ Le snapshot legacy est figé en début de phase.
122
+
123
+ ## Aléatoire — politique
124
+
125
+ Tout module qui utilise `random` doit :
126
+
127
+ 1. Accepter un argument `seed: int` ou utiliser une seed fixée
128
+ explicitement.
129
+ 2. Documenter la seed dans son docstring.
130
+ 3. Le harness de régression utilise toujours **seed=42**.
131
+
132
+ Modules concernés au legacy :
133
+
134
+ - `measurements/statistics/bootstrap.py` (seed=42)
135
+ - `measurements/runner/workers.py` (pas d'aléatoire — confirmé)
136
+ - `core/results.py` (pas d'aléatoire — confirmé)
137
+
138
+ ## Adaptateurs cloud (Mistral, OpenAI, Anthropic, Google, Azure)
139
+
140
+ Les appels réseau ne sont **pas** rejoués pendant la régression —
141
+ le test serait non-déterministe et coûteux. Stratégie :
142
+
143
+ 1. Le harness utilise des **fixtures de réponses figées** (JSON
144
+ capturé en local lors de la création du corpus de référence).
145
+ 2. Le legacy et le rewrite reçoivent **la même fixture** ; le test
146
+ vérifie que tous deux produisent le même output structurel.
147
+ 3. Si une dépendance SDK change la sérialisation (rare), le test
148
+ pète bruyamment et la PR doit re-frigorifier la fixture.
149
+
150
+ Aucune tolérance non triviale n'est nécessaire — l'égalité
151
+ bit-for-bit est tenable parce que l'aléatoire vient du cloud, pas
152
+ du parser.
153
+
154
+ ## Procédure d'exception (régression intentionnelle)
155
+
156
+ Quand une migration corrige un bug historique légitime :
157
+
158
+ 1. Ouvrir une issue GitHub avec le label `regression-intentional`.
159
+ 2. Référencer le numéro d'issue dans le commit qui modifie la
160
+ tolérance.
161
+ 3. Ajouter une entrée dans la section *« Régressions intentionnelles
162
+ acceptées »* ci-dessous, **avant** le merge.
163
+ 4. La tolérance peut être relâchée temporairement ; au merge, soit
164
+ le snapshot legacy est mis à jour pour refléter le nouveau
165
+ comportement (correct), soit la tolérance reste serrée pour les
166
+ prochaines migrations.
167
+
168
+ ## Régressions intentionnelles acceptées
169
+
170
+ | Date | Issue | Phase | Module | Description |
171
+ |------|-------|-------|--------|-------------|
172
+ | (aucune à ce jour) | | | | |
173
+
174
+ ## Révisions
175
+
176
+ | Version | Date | Changements |
177
+ |---------|------|-------------|
178
+ | 1.0 | 2026-05 | Création initiale (Phase 0 du plan de retrait legacy) |
pyproject.toml CHANGED
@@ -165,11 +165,14 @@ testpaths = ["tests"]
165
  # Windows) — utilisé par les tests CLI E2E qui résolvent leurs mock
166
  # adapters via dotted path (``importlib.import_module("tests.fixtures.…")``).
167
  pythonpath = ["."]
168
- # Exclusion par défaut : markers ``network`` et ``live`` non
169
- # sélectionnés. Override en local via ``pytest -m network`` ou
170
- # ``pytest -m live`` (avec env vars / binaires correctement
171
- # configurés). ``-m ""`` pour tout exécuter.
172
- addopts = "-v --tb=short -m 'not network and not live'"
 
 
 
173
  # Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
174
  # Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
175
  # incompatible avec le timeout en mode "signal" sur certaines plateformes.
@@ -188,6 +191,7 @@ markers = [
188
  "slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
189
  "network: tests qui hit le réseau réel ; exclus par défaut",
190
  "live: tests d'intégration contre vraie API/binaire (Tesseract, Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in en local via 'pytest -m live'",
 
191
  ]
192
 
193
  # ──────────────────────────────────────────────────────────────────
 
165
  # Windows) — utilisé par les tests CLI E2E qui résolvent leurs mock
166
  # adapters via dotted path (``importlib.import_module("tests.fixtures.…")``).
167
  pythonpath = ["."]
168
+ # Exclusion par défaut : markers ``network``, ``live`` et
169
+ # ``regression`` non sélectionnés. Override en local via
170
+ # ``pytest -m network`` ou ``pytest -m live`` (avec env vars /
171
+ # binaires correctement configurés). Le marker ``regression``
172
+ # (harness legacy rewrite) est lent ; opt-in via
173
+ # ``pytest -m regression`` ou run dédié en CI. ``-m ""`` pour
174
+ # tout exécuter.
175
+ addopts = "-v --tb=short -m 'not network and not live and not regression'"
176
  # Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
177
  # Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
178
  # incompatible avec le timeout en mode "signal" sur certaines plateformes.
 
191
  "slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
192
  "network: tests qui hit le réseau réel ; exclus par défaut",
193
  "live: tests d'intégration contre vraie API/binaire (Tesseract, Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in en local via 'pytest -m live'",
194
+ "regression: harness de régression legacy ↔ rewrite (tests/regression/legacy_vs_rewrite/) ; exclus par défaut, opt-in via 'pytest -m regression' ou job CI dédié",
195
  ]
196
 
197
  # ──────────────────────────────────────────────────────────────────
tests/architecture/test_no_legacy_imports_in_rewrite.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou : aucun module du rewrite n'importe depuis le legacy.
2
+
3
+ L'arborescence post-rewrite (``domain → formats → evaluation →
4
+ pipeline → adapters → app → reports_v2 → interfaces``) doit être
5
+ **autonome**. Le legacy peut s'appuyer sur le rewrite (re-exports),
6
+ mais l'inverse romprait l'invariant — chaque retrait de paquet
7
+ legacy au cours des phases 1-11 ferait planter le rewrite.
8
+
9
+ Ce test scanne tous les fichiers Python des paquets rewrite et
10
+ rejette toute déclaration d'import qui pointe vers un paquet
11
+ legacy.
12
+
13
+ Listes de référence
14
+ -------------------
15
+
16
+ Les paquets sont déclarés ici de manière explicite — un nouveau
17
+ paquet rewrite ou legacy doit être inscrit consciemment, pas
18
+ auto-détecté. Cela évite qu'une erreur de structure (un paquet
19
+ posé au mauvais endroit) ne soit silencieusement classée par
20
+ heuristique.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ import re
27
+ from pathlib import Path
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parents[2]
30
+
31
+ #: Paquets de l'arborescence rewrite (cible 2.0). Ne doivent
32
+ #: jamais importer depuis :data:`LEGACY_PACKAGES`.
33
+ REWRITE_PACKAGES: tuple[str, ...] = (
34
+ "domain",
35
+ "formats",
36
+ "evaluation",
37
+ "pipeline",
38
+ "adapters",
39
+ "app",
40
+ "reports_v2",
41
+ "interfaces",
42
+ )
43
+
44
+ #: Paquets legacy. Importables uniquement depuis l'intérieur du
45
+ #: legacy lui-même (ou depuis les tests, qui valident la migration
46
+ #: en cours).
47
+ LEGACY_PACKAGES: tuple[str, ...] = (
48
+ "core",
49
+ "measurements",
50
+ "engines",
51
+ "llm",
52
+ "pipelines",
53
+ "report",
54
+ "web",
55
+ "cli",
56
+ "extras",
57
+ "modules",
58
+ )
59
+
60
+ #: Pattern qui matche un import déclaré dans le code source.
61
+ #:
62
+ #: Couvre :
63
+ #:
64
+ #: - ``from picarones.X import ...``
65
+ #: - ``import picarones.X``
66
+ #: - ``import picarones.X as Y``
67
+ #:
68
+ #: Ne couvre PAS les imports différés via ``importlib.import_module``
69
+ #: ou ``__import__`` — le test architectural cible la déclaration
70
+ #: statique, pas la résolution dynamique.
71
+ _IMPORT_RE = re.compile(
72
+ r"^\s*(?:from|import)\s+picarones\.([a-z_][a-z_0-9]*)",
73
+ re.MULTILINE,
74
+ )
75
+
76
+
77
+ def _rewrite_modules() -> list[Path]:
78
+ """Liste tous les fichiers ``.py`` des paquets rewrite."""
79
+ out: list[Path] = []
80
+ for pkg in REWRITE_PACKAGES:
81
+ root = REPO_ROOT / "picarones" / pkg
82
+ if not root.exists():
83
+ continue
84
+ out.extend(p for p in root.rglob("*.py") if "__pycache__" not in p.parts)
85
+ return sorted(out)
86
+
87
+
88
+ def _scan_legacy_imports(path: Path) -> list[tuple[int, str]]:
89
+ """Retourne la liste des ``(numéro_de_ligne, import_legacy)``
90
+ trouvés dans ``path``.
91
+
92
+ Utilise l'AST pour capturer les imports indentés (à l'intérieur
93
+ de fonctions, ``TYPE_CHECKING``, etc.) — un grep simple raterait
94
+ ces cas.
95
+ """
96
+ try:
97
+ text = path.read_text(encoding="utf-8")
98
+ except (OSError, UnicodeDecodeError):
99
+ return []
100
+
101
+ offenders: list[tuple[int, str]] = []
102
+ try:
103
+ tree = ast.parse(text, filename=str(path))
104
+ except SyntaxError:
105
+ # On laisse les autres tests d'archi attraper les fichiers
106
+ # cassés.
107
+ return []
108
+ legacy_set = set(LEGACY_PACKAGES)
109
+ for node in ast.walk(tree):
110
+ if isinstance(node, ast.ImportFrom):
111
+ mod = node.module or ""
112
+ parts = mod.split(".")
113
+ if len(parts) >= 2 and parts[0] == "picarones" and parts[1] in legacy_set:
114
+ offenders.append((node.lineno, f"from {mod} import ..."))
115
+ elif isinstance(node, ast.Import):
116
+ for alias in node.names:
117
+ parts = alias.name.split(".")
118
+ if (
119
+ len(parts) >= 2
120
+ and parts[0] == "picarones"
121
+ and parts[1] in legacy_set
122
+ ):
123
+ offenders.append((node.lineno, f"import {alias.name}"))
124
+ return offenders
125
+
126
+
127
+ def test_rewrite_modules_dont_import_from_legacy() -> None:
128
+ """Aucun fichier des paquets rewrite n'a d'import legacy.
129
+
130
+ Si ce test échoue, le rewrite a une dépendance qui empêchera
131
+ le retrait du paquet legacy concerné. Deux fixes possibles :
132
+
133
+ 1. Le code legacy importé existe en équivalent dans le rewrite
134
+ → migrer l'import.
135
+ 2. Il n'existe pas encore → la fonctionnalité doit être inscrite
136
+ au plan ``docs/migration/legacy-retirement-plan.md`` comme
137
+ bloquante avant le retrait du paquet legacy concerné.
138
+ """
139
+ offenders: list[tuple[str, int, str]] = []
140
+ for path in _rewrite_modules():
141
+ rel = path.relative_to(REPO_ROOT).as_posix()
142
+ for lineno, import_text in _scan_legacy_imports(path):
143
+ offenders.append((rel, lineno, import_text))
144
+
145
+ if offenders:
146
+ sample = "\n".join(
147
+ f" {p}:{n} → {s}" for p, n, s in offenders[:30]
148
+ )
149
+ more = (
150
+ f"\n ... ({len(offenders) - 30} de plus)"
151
+ if len(offenders) > 30
152
+ else ""
153
+ )
154
+ raise AssertionError(
155
+ f"\n{len(offenders)} import(s) legacy détecté(s) dans le "
156
+ "rewrite. Le retrait du legacy en sera bloqué.\n\n"
157
+ f"{sample}{more}\n\n"
158
+ "Soit migrer l'import vers l'équivalent rewrite, soit "
159
+ "inscrire la fonctionnalité manquante dans "
160
+ "``docs/migration/legacy-retirement-plan.md`` comme "
161
+ "bloquante.",
162
+ )
163
+
164
+
165
+ def test_legacy_packages_match_directory_structure() -> None:
166
+ """Cohérence : les noms déclarés dans :data:`LEGACY_PACKAGES`
167
+ correspondent à des dossiers réels.
168
+
169
+ Quand un paquet legacy est supprimé (au fil des phases), il faut
170
+ le retirer aussi de cette liste — sinon le test ci-dessus ne
171
+ refusera plus les imports vers ce paquet désormais inexistant
172
+ (ce serait quand même un import cassé, pris par d'autres tests,
173
+ mais incohérent).
174
+ """
175
+ missing = []
176
+ for pkg in LEGACY_PACKAGES:
177
+ if not (REPO_ROOT / "picarones" / pkg).is_dir():
178
+ missing.append(pkg)
179
+ assert not missing, (
180
+ f"Paquet(s) déclaré(s) dans LEGACY_PACKAGES mais sans "
181
+ f"dossier correspondant : {missing}. Si ces paquets ont été "
182
+ "retirés au cours d'une phase de migration, mettre à jour "
183
+ "LEGACY_PACKAGES ici."
184
+ )
185
+
186
+
187
+ def test_rewrite_packages_match_directory_structure() -> None:
188
+ """Cohérence : les paquets cibles existent."""
189
+ missing = []
190
+ for pkg in REWRITE_PACKAGES:
191
+ if not (REPO_ROOT / "picarones" / pkg).is_dir():
192
+ missing.append(pkg)
193
+ assert not missing, (
194
+ f"Paquet(s) du rewrite déclaré(s) mais absent(s) du "
195
+ f"filesystem : {missing}."
196
+ )
tests/regression/__init__.py ADDED
File without changes
tests/regression/legacy_vs_rewrite/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Harness de régression legacy ↔ rewrite.
2
+
3
+ Ce package est l'**invariant** qui rend le retrait du legacy
4
+ vérifiable. À chaque phase du plan de retrait
5
+ (`docs/migration/legacy-retirement-plan.md`), un fichier
6
+ ``test_phase<N>_<module>.py`` est ajouté ici qui :
7
+
8
+ 1. Exécute le legacy sur un corpus de référence et capture la sortie
9
+ (la première fois — snapshot golden).
10
+ 2. Exécute le rewrite sur le même corpus.
11
+ 3. Compare la sortie rewrite à la golden, à la tolérance ε définie
12
+ dans ``docs/migration/regression-tolerances.md``.
13
+
14
+ Le harness est **autonome** : pas de dépendance réseau, pas de
15
+ binaire système non installable via pip. Les corpus de référence
16
+ vivent dans ``corpora/`` et sont versionnés (synthétiques pour
17
+ les small/medium, échantillons figés du domaine public pour large
18
+ si jamais ajouté).
19
+ """
tests/regression/legacy_vs_rewrite/conftest.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fixtures partagées du harness de régression.
2
+
3
+ Trois axes :
4
+
5
+ 1. **Corpus de référence** : 3 tailles (small / medium / large) ;
6
+ les images sont générées synthétiquement à la première
7
+ utilisation pour rester reproductibles cross-OS sans déposer de
8
+ blob binaire dans git.
9
+ 2. **Golden snapshots** : sortie capturée du legacy, mise en cache
10
+ sous ``golden/<phase>/<corpus>/<module>.<ext>``. Régénérée à
11
+ l'usage avec ``pytest --regen-golden``.
12
+ 3. **Comparateurs** : helpers d'égalité bit-for-bit, sémantique
13
+ HTML, ensemble de Facts. Vivent dans ``_helpers/``.
14
+
15
+ Le harness est exclu du run pytest par défaut via le marker
16
+ ``regression`` (cf. ``pyproject.toml``) — il s'exécute en CI
17
+ dédié pour ne pas ralentir la boucle de dev locale.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from pathlib import Path
24
+ from typing import Any, Iterable
25
+
26
+ import pytest
27
+
28
+ HARNESS_ROOT = Path(__file__).resolve().parent
29
+ CORPORA_DIR = HARNESS_ROOT / "corpora"
30
+ GOLDEN_DIR = HARNESS_ROOT / "golden"
31
+
32
+
33
+ def pytest_addoption(parser: pytest.Parser) -> None:
34
+ """Ajoute ``--regen-golden`` pour régénérer les snapshots."""
35
+ parser.addoption(
36
+ "--regen-golden",
37
+ action="store_true",
38
+ default=False,
39
+ help=(
40
+ "Régénère les golden snapshots du harness de régression "
41
+ "depuis l'état legacy actuel. À utiliser quand on accepte "
42
+ "explicitement une régression intentionnelle (cf. "
43
+ "docs/migration/regression-tolerances.md)."
44
+ ),
45
+ )
46
+
47
+
48
+ def pytest_configure(config: pytest.Config) -> None:
49
+ """Enregistre le marker ``regression``."""
50
+ config.addinivalue_line(
51
+ "markers",
52
+ "regression: tests de régression legacy ↔ rewrite ; exclus "
53
+ "par défaut, opt-in via ``pytest -m regression``.",
54
+ )
55
+
56
+
57
+ # ──────────────────────────────────────────────────────────────────
58
+ # Corpus
59
+ # ──────────────────────────────────────────────────────────────────
60
+
61
+
62
+ @pytest.fixture(scope="session")
63
+ def small_corpus_dir() -> Path:
64
+ """Corpus *small* : 3 documents synthétiques.
65
+
66
+ Génération unique à la première utilisation par session. Les
67
+ images sont des PNG noir-sur-blanc avec une chaîne lisible
68
+ figée par document, ce qui garantit la reproductibilité de
69
+ Tesseract cross-OS (à version de binaire constante, le rendu
70
+ PIL est identique).
71
+ """
72
+ out = CORPORA_DIR / "small"
73
+ out.mkdir(parents=True, exist_ok=True)
74
+ _generate_synthetic_corpus(
75
+ out,
76
+ documents=[
77
+ ("doc01", "BENEDICTUS DEUS"),
78
+ ("doc02", "Anno Domini MCMXVII"),
79
+ ("doc03", "Folio 23 recto"),
80
+ ],
81
+ )
82
+ return out
83
+
84
+
85
+ @pytest.fixture(scope="session")
86
+ def medium_corpus_dir() -> Path:
87
+ """Corpus *medium* : 30 documents synthétiques.
88
+
89
+ Mêmes contraintes que ``small_corpus_dir`` ; le contenu varie
90
+ pour exercer les statistiques sur un échantillon plus large.
91
+ """
92
+ out = CORPORA_DIR / "medium"
93
+ out.mkdir(parents=True, exist_ok=True)
94
+ docs = [
95
+ (f"doc{i:03d}", f"Sample text number {i:03d}")
96
+ for i in range(1, 31)
97
+ ]
98
+ _generate_synthetic_corpus(out, documents=docs)
99
+ return out
100
+
101
+
102
+ # ──────────────────────────────────────────────────────────────────
103
+ # Golden snapshots
104
+ # ──────────────────────────────────────────────────────────────────
105
+
106
+
107
+ @pytest.fixture
108
+ def golden_path(request: pytest.FixtureRequest):
109
+ """Factory de chemins de snapshot.
110
+
111
+ Usage ::
112
+
113
+ def test_phaseN_xxx(golden_path):
114
+ path = golden_path("phase1", "small", "tesseract.txt")
115
+ # path est garanti dans GOLDEN_DIR ; le caller doit
116
+ # l'écrire (au régen) ou le lire (en assertion).
117
+
118
+ Le chemin retourné est ``golden/<phase>/<corpus>/<filename>``.
119
+ Le répertoire parent est créé si nécessaire.
120
+ """
121
+
122
+ def _make(phase: str, corpus: str, filename: str) -> Path:
123
+ path = GOLDEN_DIR / phase / corpus / filename
124
+ path.parent.mkdir(parents=True, exist_ok=True)
125
+ return path
126
+
127
+ return _make
128
+
129
+
130
+ @pytest.fixture
131
+ def regen_golden(request: pytest.FixtureRequest) -> bool:
132
+ """``True`` si l'utilisateur a passé ``--regen-golden``."""
133
+ return bool(request.config.getoption("--regen-golden"))
134
+
135
+
136
+ def assert_golden_match(
137
+ actual: str | bytes,
138
+ golden_path: Path,
139
+ *,
140
+ regen: bool,
141
+ encoding: str = "utf-8",
142
+ ) -> None:
143
+ """Compare ``actual`` au contenu de ``golden_path``.
144
+
145
+ Si ``regen=True`` ou si le fichier golden n'existe pas, écrit
146
+ ``actual`` au lieu de comparer. Échoue sinon en cas de
147
+ divergence.
148
+ """
149
+ if isinstance(actual, str):
150
+ if regen or not golden_path.exists():
151
+ golden_path.write_text(actual, encoding=encoding)
152
+ return
153
+ expected = golden_path.read_text(encoding=encoding)
154
+ assert actual == expected, (
155
+ f"Golden mismatch sur {golden_path}.\n"
156
+ f"--- expected ---\n{expected[:500]}\n"
157
+ f"--- actual ---\n{actual[:500]}\n"
158
+ f"\nRégénérer avec ``pytest --regen-golden`` si la "
159
+ "régression est intentionnelle (cf. "
160
+ "regression-tolerances.md)."
161
+ )
162
+ else:
163
+ if regen or not golden_path.exists():
164
+ golden_path.write_bytes(actual)
165
+ return
166
+ expected_b = golden_path.read_bytes()
167
+ assert actual == expected_b, (
168
+ f"Golden mismatch (bytes) sur {golden_path}.\n"
169
+ "Régénérer avec ``pytest --regen-golden`` si "
170
+ "intentionnel."
171
+ )
172
+
173
+
174
+ # ──────────────────────────────────────────────────────────────────
175
+ # Comparateurs sémantiques
176
+ # ──────────────────────────────────────────────────────────────────
177
+
178
+
179
+ def assert_floats_equal(
180
+ actual: float,
181
+ expected: float,
182
+ *,
183
+ eps: float = 1e-9,
184
+ label: str = "value",
185
+ ) -> None:
186
+ """Égalité flottante au ε près (cf. regression-tolerances.md)."""
187
+ assert abs(actual - expected) <= eps, (
188
+ f"{label}: actual={actual!r} expected={expected!r} "
189
+ f"diff={abs(actual - expected):.3e} > eps={eps:.0e}"
190
+ )
191
+
192
+
193
+ def assert_set_equal(
194
+ actual: Iterable[Any],
195
+ expected: Iterable[Any],
196
+ *,
197
+ label: str = "set",
198
+ ) -> None:
199
+ """Égalité ensembliste (ordre ignoré).
200
+
201
+ Utilisé typiquement pour les `Pareto front`, l'ensemble des
202
+ Facts narratifs, l'ensemble des lignes CSV.
203
+ """
204
+ a = set(actual)
205
+ e = set(expected)
206
+ missing = e - a
207
+ extra = a - e
208
+ assert not (missing or extra), (
209
+ f"{label}: ensembles différents.\n"
210
+ f" manquants ({len(missing)}): {sorted(missing)[:10]}\n"
211
+ f" en trop ({len(extra)}): {sorted(extra)[:10]}"
212
+ )
213
+
214
+
215
+ def assert_json_semantic_equal(
216
+ actual: dict | list,
217
+ expected: dict | list,
218
+ *,
219
+ label: str = "json",
220
+ ) -> None:
221
+ """Égalité JSON : sérialisation déterministe puis diff.
222
+
223
+ Les deux structures sont sérialisées via
224
+ ``json.dumps(sort_keys=True, ensure_ascii=False, indent=2)``
225
+ avant comparaison — l'ordre des clés ne compte pas, le
226
+ whitespace non plus.
227
+ """
228
+ a = json.dumps(actual, sort_keys=True, ensure_ascii=False, indent=2)
229
+ e = json.dumps(expected, sort_keys=True, ensure_ascii=False, indent=2)
230
+ assert a == e, (
231
+ f"{label}: JSON différents.\n--- expected ---\n{e[:500]}\n"
232
+ f"--- actual ---\n{a[:500]}"
233
+ )
234
+
235
+
236
+ # ──────────────────────────────────────────────────────────────────
237
+ # Corpus generation (synthetic)
238
+ # ──────────────────────────────────────────────────────────────────
239
+
240
+
241
+ def _generate_synthetic_corpus(
242
+ out_dir: Path,
243
+ *,
244
+ documents: list[tuple[str, str]],
245
+ ) -> None:
246
+ """Génère un corpus synthétique : pour chaque ``(doc_id, text)``,
247
+ écrit ``out_dir/<doc_id>.png`` (image avec le texte rendu) et
248
+ ``out_dir/<doc_id>.gt.txt`` (la GT).
249
+
250
+ Idempotent : si tous les fichiers existent, ne fait rien.
251
+ """
252
+ pytest.importorskip("PIL")
253
+ # Pillow expose ``Image``, ``ImageDraw``, ``ImageFont`` comme
254
+ # **sous-modules**, pas comme attributs du package ``PIL`` ;
255
+ # ``import PIL`` seul ne les attache pas. Imports explicites
256
+ # ici (Pillow est une dep optionnelle du harness — d'où le
257
+ # ``importorskip`` et le déport en local).
258
+ from PIL import Image, ImageDraw, ImageFont
259
+
260
+ for doc_id, text in documents:
261
+ png = out_dir / f"{doc_id}.png"
262
+ gt = out_dir / f"{doc_id}.gt.txt"
263
+ if png.exists() and gt.exists():
264
+ continue
265
+ img = Image.new("RGB", (600, 100), color="white")
266
+ draw = ImageDraw.Draw(img)
267
+ try:
268
+ font = ImageFont.truetype("DejaVuSans-Bold.ttf", size=32)
269
+ except OSError:
270
+ font = ImageFont.load_default()
271
+ draw.text((20, 30), text, fill="black", font=font)
272
+ img.save(png)
273
+ gt.write_text(text, encoding="utf-8")
tests/regression/legacy_vs_rewrite/corpora/.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Corpus synthétiques générés par le harness à chaque run.
2
+ # Reproductibles : même contenu, même rendu PIL → mêmes octets.
3
+ # On ne versionne pas pour garder le repo léger ; le test
4
+ # ``test_corpus_generation_is_idempotent`` garantit qu'on ne
5
+ # régénère pas si les fichiers existent déjà (utile pour les
6
+ # runs CI avec cache).
7
+ *.png
8
+ *.gt.txt
tests/regression/legacy_vs_rewrite/test_phase0_harness_smoke.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests du harness lui-même.
2
+
3
+ Phase 0 : avant que la moindre comparaison legacy ↔ rewrite ne soit
4
+ faite, il faut prouver que le harness :
5
+
6
+ 1. Génère des corpus de référence reproductibles cross-OS.
7
+ 2. Sait écrire et relire un golden snapshot.
8
+ 3. Ses comparateurs sémantiques rejettent les vraies différences et
9
+ acceptent les non-significatives.
10
+
11
+ Ces tests sont marqués ``regression`` mais ne font pas de
12
+ comparaison legacy ↔ rewrite — ils valident l'infrastructure
13
+ elle-même.
14
+
15
+ Aux phases suivantes, des fichiers ``test_phaseN_<module>.py``
16
+ viendront s'ajouter à côté de celui-ci pour vérifier chaque
17
+ fonctionnalité migrée.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ import pytest
25
+
26
+ from tests.regression.legacy_vs_rewrite.conftest import (
27
+ assert_floats_equal,
28
+ assert_golden_match,
29
+ assert_json_semantic_equal,
30
+ assert_set_equal,
31
+ )
32
+
33
+
34
+ pytestmark = pytest.mark.regression
35
+
36
+
37
+ # ──────────────────────────────────────────────────────────────────
38
+ # Corpus
39
+ # ──────────────────────────────────────────────────────────────────
40
+
41
+
42
+ def test_small_corpus_has_three_documents(small_corpus_dir: Path) -> None:
43
+ """``small_corpus_dir`` produit 3 paires (image + GT)."""
44
+ pngs = sorted(small_corpus_dir.glob("*.png"))
45
+ gts = sorted(small_corpus_dir.glob("*.gt.txt"))
46
+ assert len(pngs) == 3, f"3 PNG attendus, {len(pngs)} trouvés."
47
+ assert len(gts) == 3, f"3 GT attendues, {len(gts)} trouvées."
48
+ for png in pngs:
49
+ gt = png.with_suffix("").with_suffix(".gt.txt")
50
+ assert gt.exists(), f"GT manquante pour {png.name}."
51
+
52
+
53
+ def test_medium_corpus_has_thirty_documents(medium_corpus_dir: Path) -> None:
54
+ """``medium_corpus_dir`` produit 30 paires."""
55
+ pngs = sorted(medium_corpus_dir.glob("*.png"))
56
+ assert len(pngs) == 30
57
+
58
+
59
+ def test_corpus_generation_is_idempotent(small_corpus_dir: Path) -> None:
60
+ """Re-générer le corpus ne réécrit pas les fichiers existants."""
61
+ pngs_before = {p: p.stat().st_mtime for p in small_corpus_dir.glob("*.png")}
62
+ # Re-déclencher la génération en réimportant la fixture (ici on
63
+ # appelle directement la primitive — le test n'est pas sale, c'est
64
+ # le contrat d'idempotence qui est vérifié).
65
+ from tests.regression.legacy_vs_rewrite.conftest import (
66
+ _generate_synthetic_corpus,
67
+ )
68
+ _generate_synthetic_corpus(
69
+ small_corpus_dir,
70
+ documents=[
71
+ ("doc01", "BENEDICTUS DEUS"),
72
+ ("doc02", "Anno Domini MCMXVII"),
73
+ ("doc03", "Folio 23 recto"),
74
+ ],
75
+ )
76
+ pngs_after = {p: p.stat().st_mtime for p in small_corpus_dir.glob("*.png")}
77
+ for path, mtime_before in pngs_before.items():
78
+ assert pngs_after[path] == mtime_before, (
79
+ f"{path.name} a été ré-écrit alors qu'il existait déjà."
80
+ )
81
+
82
+
83
+ # ──────────────────────────────────────────────────────────────────
84
+ # Golden snapshots
85
+ # ──────────────────────────────────────────────────────────────────
86
+
87
+
88
+ def test_golden_path_creates_directories(golden_path, tmp_path) -> None:
89
+ """``golden_path('phase', 'corpus', 'file')`` crée le dossier."""
90
+ p = golden_path("phase0", "smoke", "tmp.txt")
91
+ assert p.parent.exists()
92
+ # Cleanup pour ne pas polluer.
93
+ if p.exists():
94
+ p.unlink()
95
+
96
+
97
+ def test_golden_match_writes_on_first_run(
98
+ tmp_path: Path,
99
+ regen_golden: bool,
100
+ ) -> None:
101
+ """Quand le fichier golden n'existe pas, on l'écrit (premier run)."""
102
+ target = tmp_path / "first.txt"
103
+ assert_golden_match("hello", target, regen=False) # écrit
104
+ assert target.read_text() == "hello"
105
+
106
+
107
+ def test_golden_match_passes_when_identical(tmp_path: Path) -> None:
108
+ """Quand actual == golden, le test passe silencieusement."""
109
+ target = tmp_path / "id.txt"
110
+ target.write_text("identical content")
111
+ assert_golden_match("identical content", target, regen=False)
112
+
113
+
114
+ def test_golden_match_fails_when_different(tmp_path: Path) -> None:
115
+ """Quand actual != golden, AssertionError."""
116
+ target = tmp_path / "diff.txt"
117
+ target.write_text("expected text")
118
+ with pytest.raises(AssertionError, match="Golden mismatch"):
119
+ assert_golden_match("actual text", target, regen=False)
120
+
121
+
122
+ def test_golden_match_regen_overwrites(tmp_path: Path) -> None:
123
+ """En mode regen, le fichier est ré-écrit même si différent."""
124
+ target = tmp_path / "regen.txt"
125
+ target.write_text("old")
126
+ assert_golden_match("new", target, regen=True)
127
+ assert target.read_text() == "new"
128
+
129
+
130
+ # ──────────────────────────────────────────────────────────────────
131
+ # Comparateurs sémantiques
132
+ # ──────────────────────────────────────────────────────────────────
133
+
134
+
135
+ def test_assert_floats_equal_within_eps() -> None:
136
+ assert_floats_equal(1.0000000001, 1.0, eps=1e-9)
137
+
138
+
139
+ def test_assert_floats_equal_rejects_outside_eps() -> None:
140
+ with pytest.raises(AssertionError, match="diff="):
141
+ assert_floats_equal(1.001, 1.0, eps=1e-9)
142
+
143
+
144
+ def test_assert_set_equal_accepts_reorder() -> None:
145
+ assert_set_equal([3, 1, 2], [1, 2, 3])
146
+
147
+
148
+ def test_assert_set_equal_rejects_missing() -> None:
149
+ with pytest.raises(AssertionError, match="manquants"):
150
+ assert_set_equal([1, 2], [1, 2, 3])
151
+
152
+
153
+ def test_assert_set_equal_rejects_extra() -> None:
154
+ with pytest.raises(AssertionError, match="en trop"):
155
+ assert_set_equal([1, 2, 3, 4], [1, 2, 3])
156
+
157
+
158
+ def test_assert_json_semantic_ignores_key_order() -> None:
159
+ a = {"b": 2, "a": 1}
160
+ e = {"a": 1, "b": 2}
161
+ assert_json_semantic_equal(a, e)
162
+
163
+
164
+ def test_assert_json_semantic_detects_real_diff() -> None:
165
+ with pytest.raises(AssertionError, match="JSON différents"):
166
+ assert_json_semantic_equal({"a": 1}, {"a": 2})
167
+
168
+
169
+ def test_assert_json_semantic_handles_lists() -> None:
170
+ """Les listes gardent l'ordre — c'est le contrat JSON."""
171
+ with pytest.raises(AssertionError):
172
+ assert_json_semantic_equal([1, 2], [2, 1])