Spaces:
Sleeping
feat(migration): Phase 0 du retrait legacy — foundation
Browse filesPlan 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 +2 -2
- README.md +1 -1
- docs/migration/legacy-retirement-plan.md +30 -20
- docs/migration/regression-tolerances.md +178 -0
- pyproject.toml +9 -5
- tests/architecture/test_no_legacy_imports_in_rewrite.py +196 -0
- tests/regression/__init__.py +0 -0
- tests/regression/legacy_vs_rewrite/__init__.py +19 -0
- tests/regression/legacy_vs_rewrite/conftest.py +273 -0
- tests/regression/legacy_vs_rewrite/corpora/.gitignore +8 -0
- tests/regression/legacy_vs_rewrite/test_phase0_harness_smoke.py +172 -0
|
@@ -101,7 +101,7 @@ picarones/
|
|
| 101 |
|
| 102 |
## État des tests et bugs historiques
|
| 103 |
|
| 104 |
-
`pytest tests/` → **
|
| 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` → ~
|
| 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).
|
|
@@ -396,7 +396,7 @@ ruff check picarones/ tests/
|
|
| 396 |
python -m mypy picarones/core/
|
| 397 |
```
|
| 398 |
|
| 399 |
-
**Test suite**: ~
|
| 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
|
|
@@ -38,7 +38,7 @@ remplis :
|
|
| 38 |
|
| 39 |
## Phases
|
| 40 |
|
| 41 |
-
### Phase 0 — Foundation
|
| 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 |
-
- [
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 228 |
-
`picarones
|
| 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 |
|
| 351 |
-
| 1
|
|
|
|
| 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).
|
|
@@ -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) |
|
|
@@ -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``
|
| 169 |
-
# sélectionnés. Override en local via
|
| 170 |
-
# ``pytest -m live`` (avec env vars /
|
| 171 |
-
# configurés). ``
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
# ──────────────────────────────────────────────────────────────────
|
|
@@ -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 |
+
)
|
|
File without changes
|
|
@@ -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 |
+
"""
|
|
@@ -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")
|
|
@@ -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
|
|
@@ -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])
|