Claude commited on
Commit
7e28f42
·
unverified ·
1 Parent(s): 652752d

fix(zero-debt): éliminer toute la dette technique actionnable identifiée

Browse files

Audit récursif à 2 tours sur l'état du repo après le commit 652752d
(découpage statistics.py). 3 agents Explore tour 1 + 2 agents tour 2
+ vérifications personnelles → 8 corrections.

## Tour 1 — corrections du périmètre actionnable

1. **Régénération `scripts/gen_readme_tables.py`** : `test_readme_dual_lang`
échouait depuis le début de la branche. Tables README régénérées,
test enfin vert (3842 → 3843).

2. **Correction massive CLAUDE.md (47 substitutions)** : 39 chemins
`picarones/core/X.py` → `picarones/measurements/X.py` pour les
modules réellement déplacés historiquement (mufi, abbreviations,
modern_archives, calibration, ner, etc.). Plus 5 `*_runner.py`
orphelins remplacés par leur vrai chemin (ex: `core/pipeline_runner.py`
→ `core/pipeline.py`).

3. **6 corrections dans docs vivants** : `docs/profiles.md`,
`docs/cli-workflows.md`, `docs/roadmap/evolution-2026.md`,
`docs/user/writing-a-pipeline-module.md`, `SPECS.md`. Plus de
chemins cassés dans aucun document vivant.

4. **`docs/developer/index.md`** : arbo réécrite avec `core/` et
`measurements/` corrects + mention du sous-package `statistics/`.

5. **`docs/architecture.md`** : référence `statistics.py` →
`statistics/` ; nouvelle section « Convention de découpage des
modules > 400 lignes » qui formalise la convention sous-package
utilisée dans `picarones/measurements/statistics/`.

6. **`picarones/measurements/statistics/clustering.py`** : import
paresseux `from picarones.core.diff_utils import compute_word_diff`
déplacé au top-level. Le commentaire historique « Sprint A3 (B-1) »
référençait une violation de cercle qui n'existe plus.

7. **`picarones/report/levers_render.py`** : `except Exception: continue`
ligne 226 remplacé par `except Exception as exc: logger.warning(...)`
avec contexte (lv_type + payload + exception). Plus de fail
silencieux sur formatter cassé.

8. **`tests/architecture/test_doc_paths.py`** : `BROKEN_PATHS_BASELINE`
122 → 72. Les 72 restants sont **TOUS** dans CHANGELOG.md (67) et
docs/audits/*.md (5) — historiques intouchables. Commentaire
actualisé.

## Tour 2 — corrections sur les corrections du tour 1

9. **CLAUDE.md ligne 195** (Sprint 18) : `core/statistics.py` →
`picarones/measurements/statistics/`. Cette référence n'avait pas
le préfixe `picarones/` et a échappé à la regex de
`test_doc_paths` mais reste factuellement fausse.

10. **CLAUDE.md ligne 197** (Sprint 20) : `core/pricing.py` →
`picarones/measurements/pricing.py` ; `statistics.py` →
`picarones/measurements/statistics/pareto.py` (la fonction
`compute_pareto_front` vit maintenant dans le sous-module
`pareto`).

11. **CLAUDE.md ligne 54** (arbo informelle) : `statistics.py` retiré
de la liste, sous-package `statistics/` ajouté avec son contenu.

12. **`tests/report/test_sprint82_levers.py`** : nouveau test
`test_formatter_exception_logs_warning_and_skips_lever` qui
couvre le `logger.warning` ajouté dans levers_render.py. Évite
qu'une régression future supprime le warning sans CI failure.

## Vérifications finales

- `ruff check picarones/ tests/` : All checks passed!
- `pytest tests/` : **3843 passed, 2 skipped, 4 deselected, 0 failed**.
Le `test_readme_dual_lang` qui échouait depuis le début de la
branche est désormais vert (parité totale).
- `tests/architecture/` : 35 invariants verts.
- `BROKEN_PATHS_BASELINE` : 119 → 122 → **72** sur la branche.

## Ce qui reste comme dette ASSUMÉE (pas actionnable)

- 67 chemins `core/X.py` cassés dans CHANGELOG.md : **historique
versionné**, ne doit jamais être modifié rétroactivement.
- 5 chemins cassés dans `docs/audits/*.md` : audits historiques,
même logique.
- 13 modules de `measurements/` test-only : décision produit,
documentés dans `TEST_ONLY_BASELINE` avec justification par sprint.
- `_normal_sf` couplage `wilcoxon → friedman_nemenyi` : 8 lignes math
partagées, refactor en `_math.py` créerait plus de dette que de
bénéfice.
- 28 `# type: ignore` : tous justifiés (imports optionnels scipy,
spacy, payloads dynamiques).
- 23 `# noqa: F401` : tous des ré-exports rétrocompat documentés.

CLAUDE.md CHANGED
The diff for this file is too large to render. See raw diff
 
README.md CHANGED
@@ -385,7 +385,7 @@ ruff check picarones/ tests/
385
  python -m mypy picarones/core/
386
  ```
387
 
388
- **Test suite**: ~3763 tests, ~3 min on a modern laptop. Coverage
389
  floor at 85% (currently ~87%). The `network` marker excludes tests
390
  requiring live HTTP.
391
 
 
385
  python -m mypy picarones/core/
386
  ```
387
 
388
+ **Test suite**: ~3849 tests, ~3 min on a modern laptop. Coverage
389
  floor at 85% (currently ~87%). The `network` marker excludes tests
390
  requiring live HTTP.
391
 
SPECS.md CHANGED
@@ -425,7 +425,7 @@ colonne) et `picarones/report/glossary/{fr,en}.yaml`.
425
 
426
  **Note de traçabilité** : les références primaires (Demšar 2006,
427
  Wilcoxon 1945, Efron 1979, etc.) sont citées dans les docstrings
428
- de chaque fonction de `picarones/measurements/statistics.py`.
429
  Le glossaire contextuel relie chaque métrique à sa publication
430
  canonique (champ `reference`).
431
 
 
425
 
426
  **Note de traçabilité** : les références primaires (Demšar 2006,
427
  Wilcoxon 1945, Efron 1979, etc.) sont citées dans les docstrings
428
+ de chaque fonction de `picarones/measurements/statistics/`.
429
  Le glossaire contextuel relie chaque métrique à sa publication
430
  canonique (champ `reference`).
431
 
docs/architecture.md CHANGED
@@ -41,7 +41,7 @@ Les implémentations distribuées par défaut dans le package `picarones`.
41
 
42
  | Catégorie | Modules |
43
  |---|---|
44
- | Coeur | `metrics.py`, `statistics.py`, `runner.py`, `builtin_hooks.py`, `builtin_metrics.py`, `normalization.py` |
45
  | Erreurs | `confusion.py`, `taxonomy.py`, `taxonomy_comparison.py`, `taxonomy_cooccurrence.py`, `taxonomy_intra_doc.py` |
46
  | Lignes/structure | `line_metrics.py`, `structure.py`, `worst_lines.py`, `char_scores.py` |
47
  | Calibration/fiabilité | `calibration.py`, `reliability.py`, `hallucination.py` |
@@ -141,3 +141,39 @@ Organisés par cercle : `tests/core/`, `tests/measurements/`,
141
 
142
  Un test du cercle N **n'importe pas** les implémentations des
143
  cercles > N (sauf `tests/integration/`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  | Catégorie | Modules |
43
  |---|---|
44
+ | Coeur | `metrics.py`, `statistics/` (sous-package), `runner.py`, `builtin_hooks.py`, `builtin_metrics.py`, `normalization.py` |
45
  | Erreurs | `confusion.py`, `taxonomy.py`, `taxonomy_comparison.py`, `taxonomy_cooccurrence.py`, `taxonomy_intra_doc.py` |
46
  | Lignes/structure | `line_metrics.py`, `structure.py`, `worst_lines.py`, `char_scores.py` |
47
  | Calibration/fiabilité | `calibration.py`, `reliability.py`, `hallucination.py` |
 
141
 
142
  Un test du cercle N **n'importe pas** les implémentations des
143
  cercles > N (sauf `tests/integration/`).
144
+
145
+ ## Convention de découpage des modules > 400 lignes
146
+
147
+ Quand un module Python dépasse 400 lignes ET contient plusieurs
148
+ responsabilités disjointes, le découper en **sous-package** plutôt
149
+ qu'en plusieurs modules à plat. Modèle de référence :
150
+ [`picarones/measurements/statistics/`](../picarones/measurements/statistics/)
151
+ issu du sprint « découpage de statistics.py » (mai 2026).
152
+
153
+ Convention :
154
+
155
+ 1. **Renommer** `X.py` en `X/__init__.py` via `git mv` (préserve
156
+ l'historique du fichier original).
157
+ 2. **Créer** dans `X/` un sous-module par famille fonctionnelle
158
+ (`bootstrap.py`, `wilcoxon.py`, `friedman_nemenyi.py`, etc.).
159
+ Chaque sous-module doit faire moins de ~400 lignes ; sinon
160
+ re-décomposer.
161
+ 3. **`X/__init__.py`** ne contient QUE des ré-exports rétrocompat —
162
+ tous les symboles publics de l'ancien `X.py` doivent rester
163
+ importables via `from picarones.X import …`. Les symboles privés
164
+ ré-exportés doivent être ceux **réellement** consommés par les
165
+ tests (vérifié par grep, pas par supposition).
166
+ 4. **`__all__`** explicite dans chaque sous-module et dans le
167
+ `__init__.py`.
168
+ 5. **Tests architecture** (`tests/architecture/test_*.py`) doivent
169
+ continuer à passer : si nécessaire, étendre `_measurements_modules()`
170
+ ou `_imports_target_*` pour reconnaître les sous-packages.
171
+ 6. **Préfixer les modules de rendu** par leur domaine
172
+ (`cdd_render.py` plutôt que `render_cdd.py`) pour cohérence avec
173
+ `picarones/report/*_render.py`.
174
+
175
+ **Quand NE PAS découper** : si les responsabilités sont fortement
176
+ couplées (ex: un orchestrateur qui appelle 12 sous-fonctions au
177
+ même endroit), le maintien dans un seul fichier > 400 lignes est
178
+ acceptable. Le budget par fichier (`tests/architecture/test_file_budgets.py`)
179
+ documente ces dérogations conscientes.
docs/cli-workflows.md CHANGED
@@ -133,7 +133,7 @@ picarones import iiif \
133
  Télécharge un manifeste IIIF v2/v3 (BnF Gallica, Bodleian, Vatican…) et
134
  crée un corpus local avec `.gt.txt` extraits de l'OCR ALTO si présent.
135
  Depuis le chantier 4, IIIF et Gallica utilisent les mêmes helpers HTTP
136
- factorisés ([`picarones/importers/_http.py`](../picarones/importers/_http.py))
137
  avec garde-fou `file://`/`ftp://`/`javascript://`.
138
 
139
  ## Outils utilitaires
 
133
  Télécharge un manifeste IIIF v2/v3 (BnF Gallica, Bodleian, Vatican…) et
134
  crée un corpus local avec `.gt.txt` extraits de l'OCR ALTO si présent.
135
  Depuis le chantier 4, IIIF et Gallica utilisent les mêmes helpers HTTP
136
+ factorisés ([`picarones/extras/importers/_http.py`](../picarones/extras/importers/_http.py))
137
  avec garde-fou `file://`/`ftp://`/`javascript://`.
138
 
139
  ## Outils utilitaires
docs/developer/index.md CHANGED
@@ -10,10 +10,18 @@ modules. En résumé :
10
 
11
  ```
12
  picarones/
13
- ├── core/ # cœur analytique pur Python
 
 
 
 
 
 
 
14
  │ ├── runner.py # orchestration ThreadPool/ProcessPool
15
  │ ├── metrics.py # CER/WER/MER/WIL via jiwer
16
- │ ├── statistics.py # Wilcoxon, Friedman, Nemenyi, Pareto
 
17
  │ ├── narrative/ # moteur de synthèse factuelle
18
  │ ├── pricing.py # modèle de coût pour la vue Pareto
19
  │ └── …
 
10
 
11
  ```
12
  picarones/
13
+ ├── core/ # cœur analytique pur Python (Cercle 1)
14
+ │ ├── pipeline.py # PipelineRunner pour pipelines composées
15
+ │ ├── corpus.py # Document, Corpus, GTLevel
16
+ │ ├── results.py # DocumentResult, EngineReport, BenchmarkResult
17
+ │ ├── modules.py # BaseModule, ArtifactType
18
+ │ ├── facts.py # Fact, FactType, registre narratif
19
+ │ └── …
20
+ ├── measurements/ # métriques officielles (Cercle 2)
21
  │ ├── runner.py # orchestration ThreadPool/ProcessPool
22
  │ ├── metrics.py # CER/WER/MER/WIL via jiwer
23
+ │ ├── statistics/ # Wilcoxon, Friedman, Nemenyi, Pareto
24
+ │ │ (sous-package depuis le sprint « découpage statistics.py »)
25
  │ ├── narrative/ # moteur de synthèse factuelle
26
  │ ├── pricing.py # modèle de coût pour la vue Pareto
27
  │ └── …
docs/profiles.md CHANGED
@@ -150,7 +150,7 @@ def my_hook(*, ground_truth, hypothesis, image_path, corpus_lang, ocr_result):
150
 
151
  - [`picarones/core/metric_hooks.py`](../picarones/core/metric_hooks.py)
152
  — registre, profils, `run_document_hooks()`, `run_corpus_aggregators()`.
153
- - [`picarones/core/builtin_hooks.py`](../picarones/core/builtin_hooks.py)
154
  — les 12 hooks doc + 12 agrégateurs natifs Picarones.
155
  - [`tests/test_metric_hooks.py`](../tests/test_metric_hooks.py)
156
  — tests unitaires + rétrocompat profil `standard`.
 
150
 
151
  - [`picarones/core/metric_hooks.py`](../picarones/core/metric_hooks.py)
152
  — registre, profils, `run_document_hooks()`, `run_corpus_aggregators()`.
153
+ - [`picarones/measurements/builtin_hooks.py`](../picarones/measurements/builtin_hooks.py)
154
  — les 12 hooks doc + 12 agrégateurs natifs Picarones.
155
  - [`tests/test_metric_hooks.py`](../tests/test_metric_hooks.py)
156
  — tests unitaires + rétrocompat profil `standard`.
docs/roadmap/evolution-2026.md CHANGED
@@ -442,7 +442,7 @@ nouvelle dans le rapport.
442
 
443
  **A.II.1.a — Précision sur entités nommées (NER).**
444
 
445
- Nouveau module `picarones/core/ner.py`. Backends : spaCy multilingue,
446
  Stanza, modèle HIPE pour les corpus historiques. Choix paramétré par
447
  profil (`fr_core_news_lg`, `xx_ent_wiki_sm`, `hipe2022`).
448
 
@@ -464,7 +464,7 @@ glossaire (entrée `ner_score`).
464
 
465
  **A.II.1.b — Score de calibration des moteurs.**
466
 
467
- Nouveau module `picarones/core/calibration.py`. Tous les moteurs cibles
468
  fournissent une confidence par token ou par ligne (Tesseract `tsv`
469
  output, Pero OCR via `PageLayout`, Mistral OCR via `confidence`, Google
470
  Vision via `Word.confidence`). Ajout d'un champ
 
442
 
443
  **A.II.1.a — Précision sur entités nommées (NER).**
444
 
445
+ Nouveau module `picarones/measurements/ner.py`. Backends : spaCy multilingue,
446
  Stanza, modèle HIPE pour les corpus historiques. Choix paramétré par
447
  profil (`fr_core_news_lg`, `xx_ent_wiki_sm`, `hipe2022`).
448
 
 
464
 
465
  **A.II.1.b — Score de calibration des moteurs.**
466
 
467
+ Nouveau module `picarones/measurements/calibration.py`. Tous les moteurs cibles
468
  fournissent une confidence par token ou par ligne (Tesseract `tsv`
469
  output, Pero OCR via `PageLayout`, Mistral OCR via `confidence`, Google
470
  Vision via `Word.confidence`). Ajout d'un champ
docs/user/writing-a-pipeline-module.md CHANGED
@@ -350,7 +350,7 @@ brancher dans la pipeline et de mesurer.
350
  ### 6.b « Et si je veux juste tester une pipeline OCR seule, sans étapes en aval ? »
351
 
352
  C'est exactement ce que fait le runner OCR historique
353
- (`run_benchmark` dans `picarones/core/runner.py`) — il est
354
  toujours là, n'a pas changé, et reste la voie recommandée pour
355
  les benchmarks d'OCR mono-étage.
356
 
 
350
  ### 6.b « Et si je veux juste tester une pipeline OCR seule, sans étapes en aval ? »
351
 
352
  C'est exactement ce que fait le runner OCR historique
353
+ (`run_benchmark` dans `picarones/measurements/runner.py`) — il est
354
  toujours là, n'a pas changé, et reste la voie recommandée pour
355
  les benchmarks d'OCR mono-étage.
356
 
picarones/measurements/statistics/clustering.py CHANGED
@@ -10,6 +10,8 @@ import re
10
  from collections import defaultdict
11
  from dataclasses import dataclass
12
 
 
 
13
  # Patterns d'erreurs fréquentes (OCR + HTR documents patrimoniaux)
14
  _ERROR_PATTERNS = [
15
  # (pattern_re, label)
@@ -27,9 +29,12 @@ _ERROR_PATTERNS = [
27
 
28
 
29
  def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]:
30
- """Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution."""
31
- # Sprint A3 (B-1) : import depuis Cercle 1, plus de violation Cercle 2→3.
32
- from picarones.core.diff_utils import compute_word_diff
 
 
 
33
  ops = compute_word_diff(gt, hyp)
34
  pairs = []
35
  for op in ops:
 
10
  from collections import defaultdict
11
  from dataclasses import dataclass
12
 
13
+ from picarones.core.diff_utils import compute_word_diff
14
+
15
  # Patterns d'erreurs fréquentes (OCR + HTR documents patrimoniaux)
16
  _ERROR_PATTERNS = [
17
  # (pattern_re, label)
 
29
 
30
 
31
  def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]:
32
+ """Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution.
33
+
34
+ L'import de ``compute_word_diff`` est au top-level du module
35
+ (cercle 1 → cercle 2, sens autorisé). Il était paresseux historiquement
36
+ pour contourner une violation de cercle (Sprint A3) qui n'existe plus.
37
+ """
38
  ops = compute_word_diff(gt, hyp)
39
  pairs = []
40
  for op in ops:
picarones/report/levers_render.py CHANGED
@@ -25,9 +25,12 @@ recommandation : la phrase est purement descriptive.
25
 
26
  from __future__ import annotations
27
 
 
28
  from html import escape as _e
29
  from typing import Iterable, Optional
30
 
 
 
31
 
32
  def _lever_label(lever_type: str, labels: dict[str, str]) -> str:
33
  return labels.get(f"levers_label_{lever_type}", lever_type)
@@ -223,7 +226,12 @@ def build_levers_section_html(
223
  continue
224
  try:
225
  sentence = formatter(payload, labels)
226
- except Exception:
 
 
 
 
 
227
  continue
228
  if not sentence:
229
  continue
 
25
 
26
  from __future__ import annotations
27
 
28
+ import logging
29
  from html import escape as _e
30
  from typing import Iterable, Optional
31
 
32
+ logger = logging.getLogger(__name__)
33
+
34
 
35
  def _lever_label(lever_type: str, labels: dict[str, str]) -> str:
36
  return labels.get(f"levers_label_{lever_type}", lever_type)
 
226
  continue
227
  try:
228
  sentence = formatter(payload, labels)
229
+ except Exception as exc: # noqa: BLE001 — un formatter cassé ne doit pas casser la section
230
+ logger.warning(
231
+ "[levers_render] formatter %r a échoué sur payload=%r : %s — "
232
+ "ce levier sera omis du rapport",
233
+ lv_type, payload, exc,
234
+ )
235
  continue
236
  if not sentence:
237
  continue
tests/architecture/test_doc_paths.py CHANGED
@@ -28,21 +28,25 @@ from pathlib import Path
28
 
29
  REPO_ROOT = Path(__file__).resolve().parents[2]
30
 
31
- #: Snapshot v1.0.0. Doit baisser, jamais monter.
32
  #:
33
  #: Historique :
34
  #: - 119 (initial v1.0.0, dette pré-existante CLAUDE.md/CHANGELOG.md
35
  #: qui décrivent des modules sous ``picarones/core/...`` alors qu'ils
36
  #: vivent dans ``picarones/measurements/...``).
37
  #: - 122 (sprint « découpage de statistics.py », 2026-05-02) : 3 audits
38
- #: historiques (``docs/audits/institutional-readiness-2026-05.md`` et
39
- #: ``docs/audits/remediation-plan-2026-05.md``) référencent
40
- #: ``picarones/measurements/statistics.py`` qui est maintenant un
41
- #: sous-package. On préfère relever la baseline plutôt que modifier
42
- #: ces documents : un audit historique décrit un état du code à un
43
- #: moment T et ne doit pas être -écrit pour refléter les états
44
- #: futurs.
45
- BROKEN_PATHS_BASELINE = 122
 
 
 
 
46
 
47
  #: Patrons de fichiers de documentation à scanner.
48
  DOC_GLOBS: tuple[str, ...] = (
 
28
 
29
  REPO_ROOT = Path(__file__).resolve().parents[2]
30
 
31
+ #: Snapshot. Doit baisser, jamais monter.
32
  #:
33
  #: Historique :
34
  #: - 119 (initial v1.0.0, dette pré-existante CLAUDE.md/CHANGELOG.md
35
  #: qui décrivent des modules sous ``picarones/core/...`` alors qu'ils
36
  #: vivent dans ``picarones/measurements/...``).
37
  #: - 122 (sprint « découpage de statistics.py », 2026-05-02) : 3 audits
38
+ #: historiques référencent ``picarones/measurements/statistics.py``
39
+ #: qui est maintenant un sous-package. Baseline relevée.
40
+ #: - 72 (sprint « zéro dette actionnable », 2026-05-02) : 50 chemins
41
+ #: massivement corrigés 44 dans CLAUDE.md (``core/X.py``
42
+ #: ``measurements/X.py`` pour les modules réellement déplacés
43
+ #: historiquement) + 6 dans docs vivants (profiles, cli-workflows,
44
+ #: evolution-2026, user/writing-a-pipeline-module, SPECS).
45
+ #:
46
+ #: Les 72 restants sont **TOUS** dans :
47
+ #: - ``CHANGELOG.md`` (67) : journal historique versionné, intouchable.
48
+ #: - ``docs/audits/*.md`` (5) : audits historiques, intouchables.
49
+ BROKEN_PATHS_BASELINE = 72
50
 
51
  #: Patrons de fichiers de documentation à scanner.
52
  DOC_GLOBS: tuple[str, ...] = (
tests/report/test_sprint82_levers.py CHANGED
@@ -423,6 +423,50 @@ class TestRender:
423
  html = build_levers_section_html([bad], _load_labels("fr"))
424
  assert html == ""
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  def test_accepts_dict_input(self) -> None:
427
  d = {
428
  "type": "complementarity_observation",
 
423
  html = build_levers_section_html([bad], _load_labels("fr"))
424
  assert html == ""
425
 
426
+ def test_formatter_exception_logs_warning_and_skips_lever(
427
+ self, caplog, monkeypatch,
428
+ ) -> None:
429
+ """Si un formatter lève une exception, le levier est omis et un
430
+ ``logger.warning`` est émis avec le contexte (type + payload + exc).
431
+
432
+ Garantit que :
433
+ 1. La section continue à se rendre malgré le formatter cassé.
434
+ 2. Un diagnostic est tracé en logs (pas un fail silencieux).
435
+ """
436
+ import logging
437
+
438
+ from picarones.report import levers_render
439
+
440
+ # Patche un des formatters pour qu'il lève une exception
441
+ original = levers_render._FORMATTERS.copy()
442
+
443
+ def broken_formatter(payload: dict, labels: dict) -> str:
444
+ raise ValueError("crash test")
445
+
446
+ monkeypatch.setattr(
447
+ levers_render, "_FORMATTERS",
448
+ {**original, "complementarity_observation": broken_formatter},
449
+ )
450
+
451
+ d = {
452
+ "type": "complementarity_observation",
453
+ "importance": 40,
454
+ "payload": {"foo": "bar"},
455
+ }
456
+ with caplog.at_level(logging.WARNING, logger="picarones.report.levers_render"):
457
+ html = build_levers_section_html([d], _load_labels("fr"))
458
+
459
+ # 1. Le levier cassé est omis (HTML ne le contient pas).
460
+ assert "complementarity_observation" not in html
461
+
462
+ # 2. Un warning a été émis avec le contexte attendu.
463
+ warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
464
+ assert any(
465
+ "complementarity_observation" in r.getMessage()
466
+ and "crash test" in r.getMessage()
467
+ for r in warnings
468
+ ), f"Expected warning with formatter context, got: {[r.getMessage() for r in warnings]}"
469
+
470
  def test_accepts_dict_input(self) -> None:
471
  d = {
472
  "type": "complementarity_observation",