Claude commited on
Commit
41b7d0a
·
unverified ·
1 Parent(s): 890e849

sprint27: snapshots de reproductibilité dans le rapport HTML

Browse files

Avant Sprint 27
---------------
Le rapport HTML auto-contenu n'embarquait que
``pareto.pricing_meta.last_updated`` — une simple date qui ne disait
rien sur le contenu de la table de prix utilisée. Si quelqu'un
modifiait ``picarones/data/pricing.yaml`` après génération, il était
impossible de reconstituer ce qu'avait vu le lecteur du rapport.
Idem pour le glossaire et le profil de normalisation : aucune trace
dans le HTML produit.

Pour un outil scientifique qui se présente comme « factuel » (cf.
moteur narratif Sprint 19, garde-fou anti-hallucination Sprint 23),
c'est un trou méthodologique : on traçait les *nombres* sans tracer
les *paramètres* qui les produisaient.

Sprint 27 — apport
------------------
Nouveau module ``picarones/report/snapshot.py`` qui expose quatre
fonctions de snapshot pures + une API agrégée ``snapshot_all()``.
Le résultat est embarqué dans ``report_data["snapshots"]`` par
``ReportGenerator.generate()`` :

- **pricing** : YAML brut intégral de ``data/pricing.yaml`` +
dict parsé. Un lecteur peut extraire ``raw_yaml``
et reconstituer exactement la table utilisée.
- **glossary** : entrées du glossaire dans la langue du rapport,
triées par clé pour reproductibilité bit-à-bit.
Filtrage optionnel par ``used_keys``.
- **normalization** : profil sérialisé (``name``, ``nfc``, ``caseless``,
``diplomatic_table``, ``exclude_chars`` triés,
``description``).
- **environment** : version Picarones, version Python, plateforme,
commit git court (12 chars) si dispo, liste
figée des paquets installés (200 max), triée
case-insensitive et dédupliquée par nom.

``schema_version: 1`` ouvert dans le bloc pour les futures évolutions.

``ReportGenerator`` accepte un nouveau paramètre
``normalization_profile=...`` (fallback à
``benchmark.metadata["normalization_profile"]``) pour que le snapshot
soit fidèle au profil effectivement utilisé.

Garanties
---------
- Déterminisme strict sur les sections statiques (pricing, glossary,
normalization). Seul ``environment.git_commit`` peut varier selon
l'état du repo, et c'est documenté.
- Dégradé non bloquant : pricing.yaml absent, pyyaml absent, git
inaccessible → ``{"available": False, "reason": "..."}`` plutôt que
d'exception.
- Aucun effet de bord : lecture seule, aucun chemin écrit, aucun
cache global mutable.

Tests (+23, soit 1377 passing au total)
---------------------------------------

tests/test_sprint27_reproducibility_snapshots.py couvre :

- Snapshot pricing : YAML par défaut chargé, custom YAML round-trip,
fichier absent → unavailable, sections meta et engines exposées.
- Snapshot glossary : fr complet, filtre used_keys, langue inconnue,
entrées triées pour déterminisme.
- Snapshot normalization : profil built-in sérialisé, None →
unavailable, exclude_chars triés.
- Snapshot environment : version Picarones cohérente, python/platform
présents, paquets triés et uniques, git_commit str|None.
- snapshot_all : 4 blocs + schema_version, déterminisme inter-appels.
- Intégration ReportGenerator : bloc snapshots présent dans le HTML,
pricing YAML brut embarqué, environment présent, glossary présent,
nom du profil de normalisation présent, raw_yaml disponible pour
rejouer la table.

Out of scope (reporté)
----------------------
Le mode ``--external-images`` (rapport léger qui externalise les
images dans ``<output>_assets/`` au lieu du base64) n'est pas inclus
ici — il sera traité dans un sous-sprint dédié pour ne pas mélanger
deux préoccupations indépendantes (reproductibilité scientifique vs
poids du rapport).

https://claude.ai/code/session_01L4RGWMrAajn5ZEFgTKjA5P

picarones/report/generator.py CHANGED
@@ -19,7 +19,7 @@ import base64
19
  import io
20
  import json
21
  from pathlib import Path
22
- from typing import Optional
23
 
24
  # ---------------------------------------------------------------------------
25
  # Ressources vendor (embarquées dans le rapport HTML)
@@ -618,6 +618,7 @@ class ReportGenerator:
618
  benchmark: BenchmarkResult,
619
  images_b64: Optional[dict[str, str]] = None,
620
  lang: str = "fr",
 
621
  ) -> None:
622
  """
623
  Parameters
@@ -629,15 +630,25 @@ class ReportGenerator:
629
  Si None, le générateur cherche dans ``benchmark.metadata["_images_b64"]``.
630
  lang:
631
  Code langue du rapport : ``"fr"`` (défaut) ou ``"en"``.
 
 
 
 
 
632
  """
633
  self.benchmark = benchmark
634
  self.images_b64: dict[str, str] = images_b64 or {}
635
  self.lang = lang
 
636
 
637
  # Récupérer les images embarquées dans les metadata (fixtures)
638
  if not self.images_b64:
639
  self.images_b64 = benchmark.metadata.get("_images_b64", {}) # type: ignore[assignment]
640
 
 
 
 
 
641
  def generate(self, output_path: str | Path) -> Path:
642
  """Génère le fichier HTML et le sauvegarde sur disque.
643
 
@@ -663,6 +674,17 @@ class ReportGenerator:
663
 
664
  labels = get_labels(self.lang)
665
  report_data = _build_report_data(self.benchmark, images_b64)
 
 
 
 
 
 
 
 
 
 
 
666
  report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
667
  i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
668
  chartjs_js = _load_vendor_js("chart.umd.min.js")
 
19
  import io
20
  import json
21
  from pathlib import Path
22
+ from typing import Any, Optional
23
 
24
  # ---------------------------------------------------------------------------
25
  # Ressources vendor (embarquées dans le rapport HTML)
 
618
  benchmark: BenchmarkResult,
619
  images_b64: Optional[dict[str, str]] = None,
620
  lang: str = "fr",
621
+ normalization_profile: Any = None,
622
  ) -> None:
623
  """
624
  Parameters
 
630
  Si None, le générateur cherche dans ``benchmark.metadata["_images_b64"]``.
631
  lang:
632
  Code langue du rapport : ``"fr"`` (défaut) ou ``"en"``.
633
+ normalization_profile:
634
+ Profil de normalisation effectivement utilisé (Sprint 27 — pour
635
+ le snapshot de reproductibilité). ``None`` retombe sur le
636
+ profil mentionné dans ``benchmark.metadata["normalization_profile"]``
637
+ s'il est présent, sinon snapshot indisponible.
638
  """
639
  self.benchmark = benchmark
640
  self.images_b64: dict[str, str] = images_b64 or {}
641
  self.lang = lang
642
+ self.normalization_profile = normalization_profile
643
 
644
  # Récupérer les images embarquées dans les metadata (fixtures)
645
  if not self.images_b64:
646
  self.images_b64 = benchmark.metadata.get("_images_b64", {}) # type: ignore[assignment]
647
 
648
+ # Sprint 27 — fallback : profil de normalisation depuis les metadata
649
+ if self.normalization_profile is None:
650
+ self.normalization_profile = benchmark.metadata.get("normalization_profile")
651
+
652
  def generate(self, output_path: str | Path) -> Path:
653
  """Génère le fichier HTML et le sauvegarde sur disque.
654
 
 
674
 
675
  labels = get_labels(self.lang)
676
  report_data = _build_report_data(self.benchmark, images_b64)
677
+
678
+ # Sprint 27 — snapshots de reproductibilité (pricing, glossaire,
679
+ # profil de normalisation, environnement). Embarqués dans le JSON
680
+ # du rapport pour qu'un lecteur puisse régénérer la synthèse, le
681
+ # Pareto et le glossaire sans accès au code source.
682
+ from picarones.report.snapshot import snapshot_all
683
+ report_data["snapshots"] = snapshot_all(
684
+ lang=self.lang,
685
+ normalization_profile=self.normalization_profile,
686
+ )
687
+
688
  report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
689
  i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
690
  chartjs_js = _load_vendor_js("chart.umd.min.js")
picarones/report/snapshot.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Snapshots de reproductibilité pour le rapport HTML (Sprint 27).
2
+
3
+ Le rapport HTML auto-contenu doit pouvoir être *rejoué* sans avoir
4
+ accès au code source du moment où il a été généré : un lecteur en
5
+ 2026 doit pouvoir comprendre exactement quelle table de prix, quelle
6
+ définition de métrique, quel profil de normalisation, et quelle
7
+ version de Picarones ont produit les chiffres affichés.
8
+
9
+ Avant le Sprint 27, le rapport intégrait uniquement
10
+ ``pareto.pricing_meta.last_updated`` — une simple date de mise à jour
11
+ qui ne disait rien sur le contenu de la table. Si quelqu'un modifiait
12
+ ``picarones/data/pricing.yaml`` après génération, il était impossible
13
+ de reconstituer ce qu'avait vu le lecteur du rapport.
14
+
15
+ Quatre snapshots sont produits par ce module et embarqués dans
16
+ ``report_data.snapshots`` :
17
+
18
+ - ``pricing`` — YAML brut intégral de la table de prix.
19
+ - ``glossary`` — entrées du glossaire pour la langue du rapport.
20
+ - ``normalization`` — profil de normalisation effectivement appliqué.
21
+ - ``environment`` — version Picarones, Python, plateforme, commit git
22
+ si dispo, liste figée des dépendances installées.
23
+
24
+ Garanties
25
+ ---------
26
+ - **Déterminisme** : sur entrées identiques, ``snapshot_all()`` produit
27
+ un dict bit-à-bit identique. Les listes sont triées, les timestamps
28
+ sont absents.
29
+ - **Pas d'effet de bord** : le module ne modifie aucun état global ;
30
+ les chemins YAML sont uniquement lus, jamais écrits.
31
+ - **Dégradé non bloquant** : si pyyaml est absent, si ``pricing.yaml``
32
+ n'existe pas, si git n'est pas installé, le snapshot retourne un
33
+ dict ``{"available": False, "reason": "..."}`` plutôt que de lever.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import platform
40
+ import subprocess
41
+ import sys
42
+ from importlib.metadata import distributions
43
+ from pathlib import Path
44
+ from typing import Any, Optional
45
+
46
+ from picarones import __version__
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Pricing snapshot
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def pricing_snapshot(pricing_path: Optional[Path] = None) -> dict[str, Any]:
56
+ """Retourne le YAML brut + dict parsé de la table de prix utilisée.
57
+
58
+ Si ``pricing_path`` n'est pas fourni, utilise le chemin par défaut
59
+ de ``picarones.core.pricing._DEFAULT_PRICING_PATH``.
60
+ """
61
+ if pricing_path is None:
62
+ try:
63
+ from picarones.core.pricing import _DEFAULT_PRICING_PATH
64
+ pricing_path = _DEFAULT_PRICING_PATH
65
+ except ImportError:
66
+ return {"available": False, "reason": "module pricing introuvable"}
67
+
68
+ pricing_path = Path(pricing_path)
69
+ if not pricing_path.exists():
70
+ return {
71
+ "available": False,
72
+ "reason": f"pricing.yaml introuvable : {pricing_path}",
73
+ "expected_path": str(pricing_path),
74
+ }
75
+
76
+ try:
77
+ raw = pricing_path.read_text(encoding="utf-8")
78
+ except OSError as exc:
79
+ return {
80
+ "available": False,
81
+ "reason": f"lecture impossible : {exc}",
82
+ "expected_path": str(pricing_path),
83
+ }
84
+
85
+ try:
86
+ import yaml
87
+ data = yaml.safe_load(raw) or {}
88
+ except (ImportError, Exception) as exc:
89
+ # Pas de yaml ou parsing en échec — on garde le brut quand même.
90
+ logger.warning("[snapshot] parsing pricing.yaml échoué : %s", exc)
91
+ data = {}
92
+
93
+ return {
94
+ "available": True,
95
+ "source_path": str(pricing_path),
96
+ "filename": pricing_path.name,
97
+ "size_bytes": len(raw.encode("utf-8")),
98
+ "raw_yaml": raw,
99
+ "data": data,
100
+ }
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Glossary snapshot
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def glossary_snapshot(
108
+ lang: str = "fr",
109
+ used_keys: Optional[list[str] | set[str]] = None,
110
+ ) -> dict[str, Any]:
111
+ """Retourne les entrées du glossaire qui figurent dans le rapport.
112
+
113
+ ``used_keys`` permet de ne snapshotter que les termes effectivement
114
+ référencés (réduit la taille). ``None`` → toutes les entrées de la
115
+ langue (mode conservateur).
116
+ """
117
+ try:
118
+ from picarones.report.glossary import load_glossary, SUPPORTED_LANGS
119
+ except ImportError:
120
+ return {"available": False, "reason": "module glossary introuvable"}
121
+
122
+ full = load_glossary(lang) or {}
123
+ if not full:
124
+ return {
125
+ "available": False,
126
+ "reason": f"aucune entrée pour lang={lang!r}",
127
+ "supported_langs": SUPPORTED_LANGS,
128
+ }
129
+
130
+ if used_keys is not None:
131
+ keys = set(used_keys)
132
+ entries = {k: v for k, v in full.items() if k in keys}
133
+ else:
134
+ entries = dict(full)
135
+
136
+ # Tri pour reproductibilité bit-à-bit.
137
+ entries_sorted = {k: entries[k] for k in sorted(entries)}
138
+
139
+ return {
140
+ "available": True,
141
+ "lang": lang,
142
+ "entry_count": len(entries_sorted),
143
+ "entries": entries_sorted,
144
+ }
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Normalization profile snapshot
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def normalization_snapshot(profile: Any) -> dict[str, Any]:
152
+ """Sérialise un ``NormalizationProfile``.
153
+
154
+ Couvre les profils built-in (``medieval_french``, ``nfc``, …) et les
155
+ profils custom YAML chargés au runtime — l'objectif est qu'un
156
+ lecteur du rapport puisse régénérer exactement la même
157
+ normalisation à partir de ce snapshot.
158
+ """
159
+ if profile is None:
160
+ return {"available": False, "reason": "aucun profil fourni"}
161
+
162
+ # NormalizationProfile est un dataclass — on accède aux champs par
163
+ # nom plutôt que via ``asdict`` pour bien contrôler le format.
164
+ try:
165
+ return {
166
+ "available": True,
167
+ "name": getattr(profile, "name", "unknown"),
168
+ "nfc": bool(getattr(profile, "nfc", True)),
169
+ "caseless": bool(getattr(profile, "caseless", False)),
170
+ "diplomatic_table": dict(getattr(profile, "diplomatic_table", {}) or {}),
171
+ "exclude_chars": sorted(getattr(profile, "exclude_chars", set()) or set()),
172
+ "description": getattr(profile, "description", ""),
173
+ }
174
+ except Exception as exc:
175
+ return {"available": False, "reason": f"sérialisation échouée : {exc}"}
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Environment snapshot
180
+ # ---------------------------------------------------------------------------
181
+
182
+ def _git_commit(repo_path: Optional[Path] = None) -> Optional[str]:
183
+ """Retourne le commit git court (12 chars) si on est dans un repo, sinon None."""
184
+ cwd = repo_path or Path(__file__).resolve().parents[2]
185
+ try:
186
+ out = subprocess.check_output(
187
+ ["git", "rev-parse", "HEAD"],
188
+ cwd=str(cwd),
189
+ stderr=subprocess.DEVNULL,
190
+ text=True,
191
+ timeout=2,
192
+ ).strip()
193
+ return out[:12] if out else None
194
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
195
+ return None
196
+
197
+
198
+ def _installed_packages(limit: int = 200) -> list[str]:
199
+ """Liste figée des paquets installés au format ``name==version``.
200
+
201
+ Triée par nom (case-insensitive) pour reproductibilité. Cappée à
202
+ ``limit`` paquets pour ne pas exploser le poids du rapport.
203
+ """
204
+ try:
205
+ pkgs: list[str] = []
206
+ seen: set[str] = set()
207
+ for d in distributions():
208
+ try:
209
+ name = (d.metadata.get("Name") or "").strip()
210
+ version = (d.version or "").strip()
211
+ except Exception:
212
+ continue
213
+ if not name or name.lower() in seen:
214
+ continue
215
+ seen.add(name.lower())
216
+ pkgs.append(f"{name}=={version}")
217
+ pkgs.sort(key=str.lower)
218
+ return pkgs[:limit]
219
+ except Exception as exc: # pragma: no cover — défense en profondeur
220
+ logger.warning("[snapshot] enum dépendances échoué : %s", exc)
221
+ return []
222
+
223
+
224
+ def environment_snapshot(repo_path: Optional[Path] = None) -> dict[str, Any]:
225
+ """Retourne version Picarones, Python, plateforme, commit, deps figées."""
226
+ return {
227
+ "available": True,
228
+ "picarones_version": __version__,
229
+ "python_version": platform.python_version(),
230
+ "python_implementation": platform.python_implementation(),
231
+ "platform": platform.platform(),
232
+ "executable": sys.executable,
233
+ "git_commit": _git_commit(repo_path),
234
+ "installed_packages": _installed_packages(),
235
+ }
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # API agrégée
240
+ # ---------------------------------------------------------------------------
241
+
242
+ def snapshot_all(
243
+ *,
244
+ lang: str = "fr",
245
+ glossary_used_keys: Optional[list[str] | set[str]] = None,
246
+ pricing_path: Optional[Path] = None,
247
+ normalization_profile: Any = None,
248
+ repo_path: Optional[Path] = None,
249
+ ) -> dict[str, Any]:
250
+ """Construit le bloc ``snapshots`` à embarquer dans ``report_data``."""
251
+ return {
252
+ "pricing": pricing_snapshot(pricing_path=pricing_path),
253
+ "glossary": glossary_snapshot(lang=lang, used_keys=glossary_used_keys),
254
+ "normalization": normalization_snapshot(normalization_profile),
255
+ "environment": environment_snapshot(repo_path=repo_path),
256
+ "schema_version": 1,
257
+ }
258
+
259
+
260
+ __all__ = [
261
+ "pricing_snapshot",
262
+ "glossary_snapshot",
263
+ "normalization_snapshot",
264
+ "environment_snapshot",
265
+ "snapshot_all",
266
+ ]
tests/test_sprint27_reproducibility_snapshots.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 27 — snapshots de reproductibilité dans le rapport HTML.
2
+
3
+ Le Sprint 27 ajoute le bloc ``report_data["snapshots"]`` qui embarque
4
+ dans chaque rapport HTML auto-contenu :
5
+
6
+ - le YAML brut intégral de ``picarones/data/pricing.yaml`` ;
7
+ - les entrées du glossaire dans la langue du rapport ;
8
+ - le profil de normalisation effectivement utilisé ;
9
+ - la version Picarones, la version Python, la plateforme,
10
+ le commit git si dispo, et la liste figée des paquets installés.
11
+
12
+ Le but est qu'un lecteur du rapport puisse rejouer la synthèse, le
13
+ Pareto et le glossaire sans accès au code source du moment où le
14
+ rapport a été généré.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+
22
+ import pytest
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # 1. Fonctions snapshot unitaires
27
+ # ---------------------------------------------------------------------------
28
+
29
+ class TestPricingSnapshot:
30
+ def test_default_pricing_yaml_is_loaded(self):
31
+ from picarones.report.snapshot import pricing_snapshot
32
+ s = pricing_snapshot()
33
+ assert s["available"] is True
34
+ assert s["filename"] == "pricing.yaml"
35
+ assert s["size_bytes"] > 100, "pricing.yaml ne doit pas être quasi-vide"
36
+ # raw_yaml et data sont cohérents
37
+ assert isinstance(s["raw_yaml"], str)
38
+ assert isinstance(s["data"], dict)
39
+
40
+ def test_data_contains_meta_and_engines(self):
41
+ from picarones.report.snapshot import pricing_snapshot
42
+ s = pricing_snapshot()
43
+ assert "meta" in s["data"], "le snapshot doit exposer la section meta"
44
+ assert "engines" in s["data"], "le snapshot doit exposer engines"
45
+
46
+ def test_missing_path_returns_unavailable(self, tmp_path):
47
+ from picarones.report.snapshot import pricing_snapshot
48
+ s = pricing_snapshot(pricing_path=tmp_path / "ne-pas-exister.yaml")
49
+ assert s["available"] is False
50
+ assert "introuvable" in s["reason"].lower()
51
+
52
+ def test_custom_yaml_round_trips(self, tmp_path):
53
+ from picarones.report.snapshot import pricing_snapshot
54
+ custom = tmp_path / "custom.yaml"
55
+ custom.write_text(
56
+ "meta:\n currency: USD\n last_updated: 2026-01-01\nengines:\n fake: {type: local}\n",
57
+ encoding="utf-8",
58
+ )
59
+ s = pricing_snapshot(pricing_path=custom)
60
+ assert s["available"] is True
61
+ assert s["data"]["meta"]["currency"] == "USD"
62
+ assert "fake" in s["data"]["engines"]
63
+ # Le brut doit être identique au fichier source — preuve de fidélité.
64
+ assert s["raw_yaml"] == custom.read_text(encoding="utf-8")
65
+
66
+
67
+ class TestGlossarySnapshot:
68
+ def test_default_lang_returns_entries(self):
69
+ from picarones.report.snapshot import glossary_snapshot
70
+ s = glossary_snapshot(lang="fr")
71
+ assert s["available"] is True
72
+ assert s["entry_count"] > 10
73
+ # Quelques clés canoniques attendues
74
+ for k in ("cer", "wer"):
75
+ assert k in s["entries"]
76
+
77
+ def test_used_keys_filter(self):
78
+ from picarones.report.snapshot import glossary_snapshot
79
+ s = glossary_snapshot(lang="fr", used_keys=["cer"])
80
+ assert s["entry_count"] == 1
81
+ assert list(s["entries"]) == ["cer"]
82
+
83
+ def test_unknown_lang_falls_back(self):
84
+ # `load_glossary` retombe sur fr si la langue est absente — donc
85
+ # le snapshot doit être disponible avec lang='fr' ou la langue
86
+ # demandée selon ce qu'on retourne. On vérifie qu'on ne crashe pas.
87
+ from picarones.report.snapshot import glossary_snapshot
88
+ s = glossary_snapshot(lang="xx-pas-existante")
89
+ # Soit on retombe sur fr (available=True), soit on signale unavailable.
90
+ assert "available" in s
91
+
92
+ def test_entries_sorted_for_determinism(self):
93
+ from picarones.report.snapshot import glossary_snapshot
94
+ s = glossary_snapshot(lang="fr")
95
+ keys = list(s["entries"])
96
+ assert keys == sorted(keys), (
97
+ "Les entrées doivent être triées pour produire un snapshot "
98
+ "bit-à-bit reproductible."
99
+ )
100
+
101
+
102
+ class TestNormalizationSnapshot:
103
+ def test_builtin_profile_serializes(self):
104
+ from picarones.core.normalization import get_builtin_profile
105
+ from picarones.report.snapshot import normalization_snapshot
106
+ p = get_builtin_profile("medieval_french")
107
+ s = normalization_snapshot(p)
108
+ assert s["available"] is True
109
+ assert s["name"] == "medieval_french"
110
+ assert s["nfc"] is True
111
+ # La table contient des correspondances connues
112
+ assert s["diplomatic_table"].get("ſ") == "s"
113
+
114
+ def test_none_profile_returns_unavailable(self):
115
+ from picarones.report.snapshot import normalization_snapshot
116
+ s = normalization_snapshot(None)
117
+ assert s["available"] is False
118
+
119
+ def test_exclude_chars_sorted(self):
120
+ from picarones.core.normalization import get_builtin_profile
121
+ from picarones.report.snapshot import normalization_snapshot
122
+ p = get_builtin_profile("sans_ponctuation")
123
+ s = normalization_snapshot(p)
124
+ # Liste triée pour reproductibilité
125
+ assert s["exclude_chars"] == sorted(s["exclude_chars"])
126
+
127
+
128
+ class TestEnvironmentSnapshot:
129
+ def test_returns_picarones_version(self):
130
+ from picarones import __version__
131
+ from picarones.report.snapshot import environment_snapshot
132
+ s = environment_snapshot()
133
+ assert s["available"] is True
134
+ assert s["picarones_version"] == __version__
135
+
136
+ def test_python_and_platform_present(self):
137
+ from picarones.report.snapshot import environment_snapshot
138
+ s = environment_snapshot()
139
+ assert s["python_version"]
140
+ assert s["python_implementation"]
141
+ assert s["platform"]
142
+
143
+ def test_installed_packages_sorted_unique(self):
144
+ from picarones.report.snapshot import environment_snapshot
145
+ s = environment_snapshot()
146
+ pkgs = s["installed_packages"]
147
+ assert isinstance(pkgs, list)
148
+ # Triés case-insensitive
149
+ assert pkgs == sorted(pkgs, key=str.lower)
150
+ # Pas de doublons
151
+ names = [p.split("==", 1)[0].lower() for p in pkgs]
152
+ assert len(names) == len(set(names))
153
+
154
+ def test_git_commit_is_str_or_none(self):
155
+ from picarones.report.snapshot import environment_snapshot
156
+ s = environment_snapshot()
157
+ commit = s.get("git_commit")
158
+ assert commit is None or (isinstance(commit, str) and 0 < len(commit) <= 12)
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # 2. snapshot_all : l'API agrégée appelée par ReportGenerator
163
+ # ---------------------------------------------------------------------------
164
+
165
+ class TestSnapshotAll:
166
+ def test_contains_all_four_blocks(self):
167
+ from picarones.report.snapshot import snapshot_all
168
+ s = snapshot_all()
169
+ for k in ("pricing", "glossary", "normalization", "environment"):
170
+ assert k in s, f"snapshot_all doit exposer la clé '{k}'"
171
+ assert s["schema_version"] == 1
172
+
173
+ def test_deterministic_for_same_inputs(self):
174
+ from picarones.core.normalization import get_builtin_profile
175
+ from picarones.report.snapshot import snapshot_all
176
+ profile = get_builtin_profile("nfc")
177
+
178
+ a = snapshot_all(lang="fr", normalization_profile=profile)
179
+ b = snapshot_all(lang="fr", normalization_profile=profile)
180
+ # Les sections statiques (pricing, glossary, normalization) sont
181
+ # déterministes ; environment peut varier sur git_commit selon
182
+ # l'état du repo. On compare donc les trois sections clés.
183
+ for k in ("pricing", "glossary", "normalization"):
184
+ assert a[k] == b[k], f"Section '{k}' non déterministe"
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # 3. Intégration ReportGenerator : snapshots embarqués dans le HTML
189
+ # ---------------------------------------------------------------------------
190
+
191
+ @pytest.fixture(scope="module")
192
+ def generated_report_html(tmp_path_factory) -> str:
193
+ """Génère un rapport démo et retourne son contenu HTML."""
194
+ from picarones import fixtures
195
+ from picarones.core.normalization import get_builtin_profile
196
+ from picarones.report.generator import ReportGenerator
197
+
198
+ b = fixtures.generate_sample_benchmark(n_docs=6)
199
+ out_dir = tmp_path_factory.mktemp("rep27")
200
+ out = out_dir / "report.html"
201
+ gen = ReportGenerator(
202
+ b,
203
+ lang="fr",
204
+ normalization_profile=get_builtin_profile("medieval_french"),
205
+ )
206
+ gen.generate(out)
207
+ return out.read_text(encoding="utf-8")
208
+
209
+
210
+ def _extract_report_data(html: str) -> dict:
211
+ """Récupère le dict ``report_data`` injecté dans le HTML.
212
+
213
+ Le générateur sérialise ``report_data`` en JSON dans une balise
214
+ ``<script id="picarones-data" type="application/json">``. Cette
215
+ fonction parse le JSON pour permettre des assertions précises.
216
+ """
217
+ m = re.search(
218
+ r'<script[^>]*id="picarones-data"[^>]*>(.*?)</script>',
219
+ html,
220
+ re.DOTALL,
221
+ )
222
+ if not m:
223
+ # Fallback : chercher la première occurrence de ``"snapshots"``
224
+ # et ouvrir le JSON englobant.
225
+ idx = html.find('"snapshots"')
226
+ assert idx >= 0, "Aucun bloc 'snapshots' trouvé dans le rapport"
227
+ # On retourne un dict factice pour ne pas bloquer les tests qui
228
+ # ne dépendent pas du parse précis.
229
+ return {"snapshots": {"present_in_html": True}}
230
+ return json.loads(m.group(1))
231
+
232
+
233
+ class TestReportEmbedsSnapshots:
234
+ def test_html_contains_snapshots_block(self, generated_report_html):
235
+ assert '"snapshots"' in generated_report_html
236
+ assert '"schema_version":1' in generated_report_html
237
+
238
+ def test_pricing_yaml_embedded_raw(self, generated_report_html):
239
+ # Le YAML brut doit être présent (chercher une ligne caractéristique)
240
+ assert "engines:" in generated_report_html
241
+ # ``meta:`` apparaît aussi dans pricing.yaml
242
+ assert "meta:" in generated_report_html
243
+
244
+ def test_environment_block_embedded(self, generated_report_html):
245
+ assert '"picarones_version"' in generated_report_html
246
+ assert '"python_version"' in generated_report_html
247
+ assert '"installed_packages"' in generated_report_html
248
+
249
+ def test_glossary_block_embedded(self, generated_report_html):
250
+ # Quelques clés du glossaire doivent figurer dans le HTML — mais
251
+ # comme le glossaire est aussi rendu côté UI dans une autre var,
252
+ # on vérifie au moins la présence du JSON glossary dans snapshots.
253
+ assert '"entries"' in generated_report_html
254
+
255
+ def test_normalization_profile_embedded(self, generated_report_html):
256
+ # Le snapshot doit nommer le profil utilisé
257
+ assert "medieval_french" in generated_report_html
258
+
259
+
260
+ class TestReportSnapshotPersistsAcrossPricingChanges:
261
+ """Garantie de reproductibilité : un rapport généré aujourd'hui reste
262
+ cohérent avec le pricing au moment de la génération, même si
263
+ ``picarones/data/pricing.yaml`` change ensuite."""
264
+
265
+ def test_snapshot_carries_full_yaml_for_replay(self, generated_report_html):
266
+ # Si quelqu'un ouvre le HTML demain et veut rejouer la table de
267
+ # prix, il peut extraire le ``raw_yaml`` du bloc snapshots et le
268
+ # parser. On vérifie que le brut YAML est bien là tel quel.
269
+ assert "raw_yaml" in generated_report_html
270
+ # Les hypothèses détaillées (assumptions, notes, sources) sont
271
+ # dans le YAML — au moins une doit apparaître dans le HTML
272
+ # via le bloc raw_yaml.
273
+ assert ("assumptions" in generated_report_html
274
+ or "notes" in generated_report_html
275
+ or "sources" in generated_report_html), (
276
+ "Le YAML pricing brut doit embarquer assumptions/notes/sources"
277
+ )