"""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) == []