Picarones / tests /test_reproducibility_ops.py
Claude
feat(sprint-H.3)!: renommage reports_v2/ → reports/
9011070 unverified
raw
history blame
11.2 kB
"""Tests Sprint A8 — reproductibilité opérationnelle.
Items M-1, M-2, M-12, M-18, m-11, m-13, m-14 de l'audit
``institutional-readiness-2026-05.md``.
Valide la chaîne **lock file + Docker digest + snapshots** comme
contrat opérationnel pour la reproductibilité institutionnelle.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# M-1 — Lock files
# ---------------------------------------------------------------------------
def test_runtime_lock_exists() -> None:
"""``requirements.lock`` doit exister à la racine et être un lock file
valide (généré par uv pip compile)."""
lock = REPO_ROOT / "requirements.lock"
assert lock.exists(), "requirements.lock manquant — sortie de `uv pip compile`"
text = lock.read_text(encoding="utf-8")
assert "uv pip compile" in text or "==" in text, (
"Le lock file ne semble pas avoir été généré par uv "
"(en-tête `uv pip compile` absente)."
)
def test_dev_lock_exists() -> None:
"""``requirements-dev.lock`` doit exister (extras dev + web)."""
lock = REPO_ROOT / "requirements-dev.lock"
assert lock.exists(), "requirements-dev.lock manquant"
text = lock.read_text(encoding="utf-8")
# Doit contenir au moins pytest et fastapi (extras dev + web).
assert "pytest" in text
assert "fastapi" in text
def test_runtime_lock_pins_versions() -> None:
"""Tout package du lock runtime doit être épinglé à une version
exacte (``==``)."""
lock = REPO_ROOT / "requirements.lock"
lines = [
line.strip()
for line in lock.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.startswith("#") and not line.startswith(" ")
]
for line in lines:
# Tolère ``-r`` et ``-c`` (références à d'autres lock files)
if line.startswith(("-r", "-c", "-e")):
continue
# Format attendu : ``package==1.2.3`` ou ``package==1.2.3 ; marker``
# (pas de ``>=``, ``<=``, ``~=`` qui sont des bornes non-épinglées).
assert "==" in line, (
f"Lock runtime non-épinglé : `{line}`. "
"Régénérer avec `uv pip compile pyproject.toml -o requirements.lock`."
)
# ---------------------------------------------------------------------------
# M-2 — Dockerfile pinning
# ---------------------------------------------------------------------------
def test_dockerfile_pins_python_patch() -> None:
"""Le Dockerfile doit utiliser un patch précis (3.11.x) plutôt
que le stream ``3.11-slim``."""
dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8")
# Cherche ARG PYTHON_BASE_IMAGE qui pointe vers une version patch
m = re.search(
r"ARG\s+PYTHON_BASE_IMAGE\s*=\s*python:(\d+\.\d+\.\d+)",
dockerfile,
)
assert m, (
"Dockerfile ne déclare pas ARG PYTHON_BASE_IMAGE=python:X.Y.Z-slim "
"— rétrocompat avec ``python:3.11-slim`` (stream) introduit "
"des builds non reproductibles."
)
def test_dockerfile_uses_arg_in_from() -> None:
"""Les directives ``FROM`` doivent référencer ``${PYTHON_BASE_IMAGE}``."""
dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8")
from_lines = [
line for line in dockerfile.splitlines() if line.startswith("FROM")
]
assert from_lines, "Aucune directive FROM trouvée dans le Dockerfile"
for line in from_lines:
assert "${PYTHON_BASE_IMAGE}" in line, (
f"FROM non aligné sur ARG : `{line}`"
)
# ---------------------------------------------------------------------------
# M-18 — .dockerignore + .env.example
# ---------------------------------------------------------------------------
def test_dockerignore_exists_and_excludes_git() -> None:
"""``.dockerignore`` doit exister et exclure au moins ``.git``,
``tests/``, ``__pycache__``."""
f = REPO_ROOT / ".dockerignore"
assert f.exists(), ".dockerignore manquant à la racine"
content = f.read_text(encoding="utf-8")
for required in [".git", "tests", "__pycache__", ".venv", ".pytest_cache"]:
assert required in content, (
f".dockerignore n'exclut pas `{required}` — build context inutilement gros."
)
def test_env_example_exists_and_documents_keys() -> None:
"""``.env.example`` doit exister et lister les variables clés."""
f = REPO_ROOT / ".env.example"
assert f.exists(), ".env.example manquant"
content = f.read_text(encoding="utf-8")
required_vars = [
"PICARONES_PUBLIC_MODE",
"PICARONES_CSRF_REQUIRED",
"PICARONES_CSRF_SECRET",
"PICARONES_BROWSE_ROOTS",
"PICARONES_MAX_UPLOAD_MB",
"PICARONES_PORT",
]
missing = [v for v in required_vars if v not in content]
assert not missing, (
f"Variables manquantes dans .env.example : {missing}"
)
# ---------------------------------------------------------------------------
# M-12 — Doc snapshots
# ---------------------------------------------------------------------------
def test_reproducibility_snapshots_doc_exists() -> None:
"""``docs/reference/reproducibility-snapshots.md`` doit exister et
documenter la procédure end-to-end (S60 — Diataxis)."""
f = REPO_ROOT / "docs" / "reference" / "reproducibility-snapshots.md"
assert f.exists()
text = f.read_text(encoding="utf-8")
# Sections clés attendues.
for section in [
"Pourquoi des snapshots",
"Ce qu'un snapshot contient",
"Comment rejouer un benchmark",
"Limites assumées",
]:
assert section in text, f"Section manquante : {section!r}"
# ---------------------------------------------------------------------------
# m-11 — Versionnement testdata
# ---------------------------------------------------------------------------
def test_testdata_version_yaml_exists() -> None:
"""``tests/.testdata/VERSION.yaml`` doit exister et lister les
fixtures versionnables."""
f = REPO_ROOT / "tests" / ".testdata" / "VERSION.yaml"
assert f.exists(), "VERSION.yaml manquant pour le versionnement testdata"
data = yaml.safe_load(f.read_text(encoding="utf-8"))
assert data is not None
assert "version" in data
assert "corpus_de_reference" in data, (
"Le corpus de référence Sprint A5 doit être listé."
)
def test_testdata_paths_exist() -> None:
"""Toute fixture listée dans VERSION.yaml doit pointer vers un
fichier existant."""
f = REPO_ROOT / "tests" / ".testdata" / "VERSION.yaml"
if not f.exists():
pytest.skip("VERSION.yaml absent")
data = yaml.safe_load(f.read_text(encoding="utf-8"))
missing: list[str] = []
for entry in data.get("corpus_de_reference") or []:
path = REPO_ROOT / entry["path"]
if not path.exists():
missing.append(entry["path"])
assert not missing, (
f"Fixtures listées mais absentes du repo : {missing}"
)
# ---------------------------------------------------------------------------
# m-13 — requirements.txt aligné
# ---------------------------------------------------------------------------
def test_requirements_txt_points_to_lock() -> None:
"""``requirements.txt`` ne doit plus contenir de bornes ``>=``
individuelles — c'est un alias vers ``requirements.lock``."""
f = REPO_ROOT / "requirements.txt"
assert f.exists()
content = f.read_text(encoding="utf-8")
# Doit contenir la directive ``-r requirements.lock``
assert "-r requirements.lock" in content, (
"requirements.txt doit pointer vers requirements.lock via `-r`."
)
# Tolérance : autoriser quelques commentaires explicatifs mais pas
# de spec ``package>=version`` indépendante du lock.
spec_lines = [
line.strip()
for line in content.splitlines()
if line.strip()
and not line.startswith("#")
and not line.startswith("-")
]
assert not spec_lines, (
f"Lignes de spec non-locked dans requirements.txt : {spec_lines}"
)
# ---------------------------------------------------------------------------
# m-14 — Pricing staleness
# ---------------------------------------------------------------------------
def test_pricing_yaml_has_valid_until() -> None:
"""``picarones/data/pricing.yaml`` doit déclarer ``meta.valid_until``."""
f = REPO_ROOT / "picarones" / "data" / "pricing.yaml"
data = yaml.safe_load(f.read_text(encoding="utf-8"))
meta = data.get("meta") or {}
assert "valid_until" in meta, (
"pricing.yaml doit déclarer `meta.valid_until` (Sprint A8 m-14)"
)
# Doit être au format ISO date YYYY-MM-DD
assert re.match(r"\d{4}-\d{2}-\d{2}", str(meta["valid_until"])), (
f"valid_until mal formé : {meta['valid_until']!r}"
)
def test_pricing_staleness_detector_registered() -> None:
"""Le détecteur ``detect_pricing_staleness`` doit être enregistré."""
# Trigger registration
import picarones.evaluation.metrics # noqa: F401
from picarones.domain.facts import FactType
from picarones.reports.narrative.detectors import DETECTORS_BY_TYPE
assert FactType.PRICING_STALENESS_WARNING in DETECTORS_BY_TYPE
def test_pricing_staleness_detector_silent_when_valid() -> None:
"""Le détecteur reste silencieux si ``today <= valid_until``."""
from datetime import date, timedelta
from picarones.reports.narrative.detectors.pareto import (
detect_pricing_staleness,
)
future = (date.today() + timedelta(days=180)).isoformat()
benchmark_data = {
"snapshots": {"pricing": {"meta": {"valid_until": future}}}
}
facts = detect_pricing_staleness(benchmark_data)
assert facts == []
def test_pricing_staleness_detector_fires_when_expired() -> None:
"""Le détecteur émet un Fact si la date est dépassée."""
from datetime import date, timedelta
from picarones.domain.facts import FactType
from picarones.reports.narrative.detectors.pareto import (
detect_pricing_staleness,
)
past = (date.today() - timedelta(days=30)).isoformat()
benchmark_data = {
"snapshots": {"pricing": {"meta": {"valid_until": past}}}
}
facts = detect_pricing_staleness(benchmark_data)
assert len(facts) == 1
assert facts[0].type == FactType.PRICING_STALENESS_WARNING
assert facts[0].payload["days_overdue"] == 30
def test_pricing_staleness_detector_silent_on_missing_data() -> None:
"""Le détecteur ne crashe pas si la clé est absente."""
from picarones.reports.narrative.detectors.pareto import (
detect_pricing_staleness,
)
# Pas de snapshots
assert detect_pricing_staleness({}) == []
# Snapshots vides
assert detect_pricing_staleness({"snapshots": {}}) == []
# valid_until mal formé
bad = {"snapshots": {"pricing": {"meta": {"valid_until": "not-a-date"}}}}
assert detect_pricing_staleness(bad) == []