Spaces:
Sleeping
Sleeping
File size: 11,957 Bytes
41b7d0a 979f3c3 41b7d0a 979f3c3 41b7d0a 979f3c3 41b7d0a 979f3c3 41b7d0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | """Tests Sprint 27 — snapshots de reproductibilité dans le rapport HTML.
Le Sprint 27 ajoute le bloc ``report_data["snapshots"]`` qui embarque
dans chaque rapport HTML auto-contenu :
- le YAML brut intégral de ``picarones/data/pricing.yaml`` ;
- les entrées du glossaire dans la langue du rapport ;
- le profil de normalisation effectivement utilisé ;
- la version Picarones, la version Python, la plateforme,
le commit git si dispo, et la liste figée des paquets installés.
Le but est qu'un lecteur du rapport puisse rejouer la synthèse, le
Pareto et le glossaire sans accès au code source du moment où le
rapport a été généré.
"""
from __future__ import annotations
import json
import re
import pytest
# ---------------------------------------------------------------------------
# 1. Fonctions snapshot unitaires
# ---------------------------------------------------------------------------
class TestPricingSnapshot:
def test_default_pricing_yaml_is_loaded(self):
from picarones.report.snapshot import pricing_snapshot
s = pricing_snapshot()
assert s["available"] is True
assert s["filename"] == "pricing.yaml"
assert s["size_bytes"] > 100, "pricing.yaml ne doit pas être quasi-vide"
# raw_yaml et data sont cohérents
assert isinstance(s["raw_yaml"], str)
assert isinstance(s["data"], dict)
def test_data_contains_meta_and_engines(self):
from picarones.report.snapshot import pricing_snapshot
s = pricing_snapshot()
assert "meta" in s["data"], "le snapshot doit exposer la section meta"
assert "engines" in s["data"], "le snapshot doit exposer engines"
def test_missing_path_returns_unavailable(self, tmp_path):
from picarones.report.snapshot import pricing_snapshot
s = pricing_snapshot(pricing_path=tmp_path / "ne-pas-exister.yaml")
assert s["available"] is False
assert "introuvable" in s["reason"].lower()
def test_custom_yaml_round_trips(self, tmp_path):
from picarones.report.snapshot import pricing_snapshot
custom = tmp_path / "custom.yaml"
custom.write_text(
"meta:\n currency: USD\n last_updated: 2026-01-01\nengines:\n fake: {type: local}\n",
encoding="utf-8",
)
s = pricing_snapshot(pricing_path=custom)
assert s["available"] is True
assert s["data"]["meta"]["currency"] == "USD"
assert "fake" in s["data"]["engines"]
# Le brut doit être identique au fichier source — preuve de fidélité.
assert s["raw_yaml"] == custom.read_text(encoding="utf-8")
class TestGlossarySnapshot:
def test_default_lang_returns_entries(self):
from picarones.report.snapshot import glossary_snapshot
s = glossary_snapshot(lang="fr")
assert s["available"] is True
assert s["entry_count"] > 10
# Quelques clés canoniques attendues
for k in ("cer", "wer"):
assert k in s["entries"]
def test_used_keys_filter(self):
from picarones.report.snapshot import glossary_snapshot
s = glossary_snapshot(lang="fr", used_keys=["cer"])
assert s["entry_count"] == 1
assert list(s["entries"]) == ["cer"]
def test_unknown_lang_falls_back(self):
# `load_glossary` retombe sur fr si la langue est absente — donc
# le snapshot doit être disponible avec lang='fr' ou la langue
# demandée selon ce qu'on retourne. On vérifie qu'on ne crashe pas.
from picarones.report.snapshot import glossary_snapshot
s = glossary_snapshot(lang="xx-pas-existante")
# Soit on retombe sur fr (available=True), soit on signale unavailable.
assert "available" in s
def test_entries_sorted_for_determinism(self):
from picarones.report.snapshot import glossary_snapshot
s = glossary_snapshot(lang="fr")
keys = list(s["entries"])
assert keys == sorted(keys), (
"Les entrées doivent être triées pour produire un snapshot "
"bit-à-bit reproductible."
)
class TestNormalizationSnapshot:
def test_builtin_profile_serializes(self):
from picarones.measurements.normalization import get_builtin_profile
from picarones.report.snapshot import normalization_snapshot
p = get_builtin_profile("medieval_french")
s = normalization_snapshot(p)
assert s["available"] is True
assert s["name"] == "medieval_french"
assert s["nfc"] is True
# La table contient des correspondances connues
assert s["diplomatic_table"].get("ſ") == "s"
def test_none_profile_returns_unavailable(self):
from picarones.report.snapshot import normalization_snapshot
s = normalization_snapshot(None)
assert s["available"] is False
def test_exclude_chars_sorted(self):
from picarones.measurements.normalization import get_builtin_profile
from picarones.report.snapshot import normalization_snapshot
p = get_builtin_profile("sans_ponctuation")
s = normalization_snapshot(p)
# Liste triée pour reproductibilité
assert s["exclude_chars"] == sorted(s["exclude_chars"])
class TestEnvironmentSnapshot:
def test_returns_picarones_version(self):
from picarones import __version__
from picarones.report.snapshot import environment_snapshot
s = environment_snapshot()
assert s["available"] is True
assert s["picarones_version"] == __version__
def test_python_and_platform_present(self):
from picarones.report.snapshot import environment_snapshot
s = environment_snapshot()
assert s["python_version"]
assert s["python_implementation"]
assert s["platform"]
def test_installed_packages_sorted_unique(self):
from picarones.report.snapshot import environment_snapshot
s = environment_snapshot()
pkgs = s["installed_packages"]
assert isinstance(pkgs, list)
# Triés case-insensitive
assert pkgs == sorted(pkgs, key=str.lower)
# Pas de doublons
names = [p.split("==", 1)[0].lower() for p in pkgs]
assert len(names) == len(set(names))
def test_git_commit_is_str_or_none(self):
from picarones.report.snapshot import environment_snapshot
s = environment_snapshot()
commit = s.get("git_commit")
assert commit is None or (isinstance(commit, str) and 0 < len(commit) <= 12)
# ---------------------------------------------------------------------------
# 2. snapshot_all : l'API agrégée appelée par ReportGenerator
# ---------------------------------------------------------------------------
class TestSnapshotAll:
def test_contains_all_four_blocks(self):
from picarones.report.snapshot import snapshot_all
s = snapshot_all()
for k in ("pricing", "glossary", "normalization", "environment"):
assert k in s, f"snapshot_all doit exposer la clé '{k}'"
assert s["schema_version"] == 1
def test_deterministic_for_same_inputs(self):
from picarones.measurements.normalization import get_builtin_profile
from picarones.report.snapshot import snapshot_all
profile = get_builtin_profile("nfc")
a = snapshot_all(lang="fr", normalization_profile=profile)
b = snapshot_all(lang="fr", normalization_profile=profile)
# Les sections statiques (pricing, glossary, normalization) sont
# déterministes ; environment peut varier sur git_commit selon
# l'état du repo. On compare donc les trois sections clés.
for k in ("pricing", "glossary", "normalization"):
assert a[k] == b[k], f"Section '{k}' non déterministe"
# ---------------------------------------------------------------------------
# 3. Intégration ReportGenerator : snapshots embarqués dans le HTML
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def generated_report_html(tmp_path_factory) -> str:
"""Génère un rapport démo et retourne son contenu HTML."""
from picarones import fixtures
from picarones.measurements.normalization import get_builtin_profile
from picarones.report.generator import ReportGenerator
b = fixtures.generate_sample_benchmark(n_docs=6)
out_dir = tmp_path_factory.mktemp("rep27")
out = out_dir / "report.html"
gen = ReportGenerator(
b,
lang="fr",
normalization_profile=get_builtin_profile("medieval_french"),
)
gen.generate(out)
return out.read_text(encoding="utf-8")
def _extract_report_data(html: str) -> dict:
"""Récupère le dict ``report_data`` injecté dans le HTML.
Le générateur sérialise ``report_data`` en JSON dans une balise
``<script id="picarones-data" type="application/json">``. Cette
fonction parse le JSON pour permettre des assertions précises.
"""
m = re.search(
r'<script[^>]*id="picarones-data"[^>]*>(.*?)</script>',
html,
re.DOTALL,
)
if not m:
# Fallback : chercher la première occurrence de ``"snapshots"``
# et ouvrir le JSON englobant.
idx = html.find('"snapshots"')
assert idx >= 0, "Aucun bloc 'snapshots' trouvé dans le rapport"
# On retourne un dict factice pour ne pas bloquer les tests qui
# ne dépendent pas du parse précis.
return {"snapshots": {"present_in_html": True}}
return json.loads(m.group(1))
class TestReportEmbedsSnapshots:
def test_html_contains_snapshots_block(self, generated_report_html):
assert '"snapshots"' in generated_report_html
assert '"schema_version":1' in generated_report_html
def test_pricing_yaml_embedded_raw(self, generated_report_html):
# Le YAML brut doit être présent (chercher une ligne caractéristique)
assert "engines:" in generated_report_html
# ``meta:`` apparaît aussi dans pricing.yaml
assert "meta:" in generated_report_html
def test_environment_block_embedded(self, generated_report_html):
assert '"picarones_version"' in generated_report_html
assert '"python_version"' in generated_report_html
assert '"installed_packages"' in generated_report_html
def test_glossary_block_embedded(self, generated_report_html):
# Quelques clés du glossaire doivent figurer dans le HTML — mais
# comme le glossaire est aussi rendu côté UI dans une autre var,
# on vérifie au moins la présence du JSON glossary dans snapshots.
assert '"entries"' in generated_report_html
def test_normalization_profile_embedded(self, generated_report_html):
# Le snapshot doit nommer le profil utilisé
assert "medieval_french" in generated_report_html
class TestReportSnapshotPersistsAcrossPricingChanges:
"""Garantie de reproductibilité : un rapport généré aujourd'hui reste
cohérent avec le pricing au moment de la génération, même si
``picarones/data/pricing.yaml`` change ensuite."""
def test_snapshot_carries_full_yaml_for_replay(self, generated_report_html):
# Si quelqu'un ouvre le HTML demain et veut rejouer la table de
# prix, il peut extraire le ``raw_yaml`` du bloc snapshots et le
# parser. On vérifie que le brut YAML est bien là tel quel.
assert "raw_yaml" in generated_report_html
# Les hypothèses détaillées (assumptions, notes, sources) sont
# dans le YAML — au moins une doit apparaître dans le HTML
# via le bloc raw_yaml.
assert ("assumptions" in generated_report_html
or "notes" in generated_report_html
or "sources" in generated_report_html), (
"Le YAML pricing brut doit embarquer assumptions/notes/sources"
)
|