Picarones / tests /reports /test_lazy_images.py
Claude
feat(sprint-S8): cohérence finale — renames test dirs, /metrics endpoint, SBOM workflow
43478ec unverified
Raw
History Blame
7.66 kB
"""Tests Sprint A5 — option ``lazy_images`` du ReportGenerator (M-16).
Vérifie que :
1. Par défaut (``lazy_images=False``), les images restent embarquées
en base64 (rétrocompat — rapport mono-fichier transportable).
2. Avec ``lazy_images=True``, les images sont externalisées dans
``<output_dir>/report-assets/`` et le HTML les référence par URL
relative.
3. Le HTML reste valide et lisible dans les deux modes.
4. La taille du HTML monolithique baisse drastiquement en mode lazy
sur un corpus de plusieurs documents.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from picarones.evaluation.synthetic import generate_sample_benchmark
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def demo_benchmark_with_images(tmp_path: Path):
"""Benchmark démo avec quelques images PNG synthétiques sur disque.
On utilise les fixtures officielles puis on remplace les
``image_path`` par des PNG réels créés à la volée pour que
``_externalize_images_to_dir`` ait de quoi travailler.
"""
from PIL import Image
bench = generate_sample_benchmark(n_docs=3)
# Crée 3 PNG synthétiques minuscules
for i, engine_report in enumerate(bench.engine_reports):
for j, dr in enumerate(engine_report.document_results):
img_path = tmp_path / f"img_{j}.png"
if not img_path.exists():
Image.new("RGB", (200, 100), color=(255, 240, 220)).save(img_path)
dr.image_path = str(img_path)
return bench
# ---------------------------------------------------------------------------
# Mode par défaut (rétrocompat) : images embarquées base64
# ---------------------------------------------------------------------------
def test_default_mode_inlines_images(demo_benchmark_with_images, tmp_path: Path) -> None:
"""``lazy_images=False`` (défaut) : les images vivent en base64
inline dans le HTML, aucun fichier d'asset n'est créé."""
from picarones.reports.html.generator import ReportGenerator
out = tmp_path / "report.html"
gen = ReportGenerator(demo_benchmark_with_images)
path = gen.generate(out)
assert path.exists()
html = path.read_text(encoding="utf-8")
# Rétrocompat : data-URI base64 présent
assert "data:image" in html or "image/png;base64" in html, (
"En mode par défaut, le HTML doit contenir des data-URI base64."
)
# Pas de dossier d'assets externes
assert not (tmp_path / "report-assets").exists(), (
"En mode inline, aucun fichier d'asset ne doit être créé."
)
# ---------------------------------------------------------------------------
# Mode lazy : images externalisées
# ---------------------------------------------------------------------------
def test_lazy_mode_creates_asset_directory(
demo_benchmark_with_images, tmp_path: Path
) -> None:
"""``lazy_images=True`` : ``report-assets/`` est créé à côté du HTML
et contient des fichiers image."""
from picarones.reports.html.generator import ReportGenerator
out = tmp_path / "report.html"
gen = ReportGenerator(demo_benchmark_with_images, lazy_images=True)
path = gen.generate(out)
assert path.exists()
assets_dir = tmp_path / "report-assets"
assert assets_dir.exists() and assets_dir.is_dir()
asset_files = list(assets_dir.iterdir())
assert len(asset_files) >= 1, (
f"Au moins une image doit être externalisée. "
f"Trouvé : {asset_files}"
)
def test_lazy_mode_html_references_relative_urls(
demo_benchmark_with_images, tmp_path: Path
) -> None:
"""En mode lazy, le HTML référence les images via URL relative
``report-assets/...`` plutôt qu'un data-URI."""
from picarones.reports.html.generator import ReportGenerator
out = tmp_path / "report.html"
gen = ReportGenerator(demo_benchmark_with_images, lazy_images=True)
path = gen.generate(out)
html = path.read_text(encoding="utf-8")
assert "report-assets/" in html, (
"Le HTML doit référencer les images via URL relative."
)
# ``loading="lazy"`` doit toujours être présent (le template le pose)
assert 'loading="lazy"' in html
def test_lazy_mode_significantly_reduces_html_size(
demo_benchmark_with_images, tmp_path: Path
) -> None:
"""Le HTML lazy doit être nettement plus petit que le HTML inline.
Sur le corpus démo (3 docs × 200×100 PNG), le ratio doit être
favorable au lazy. Test peu strict (ratio > 1.05) pour ne pas
être flaky en fonction du contenu vendor.
"""
from picarones.reports.html.generator import ReportGenerator
inline_out = tmp_path / "inline.html"
lazy_out = tmp_path / "lazy.html"
ReportGenerator(demo_benchmark_with_images, lazy_images=False).generate(inline_out)
ReportGenerator(demo_benchmark_with_images, lazy_images=True).generate(lazy_out)
inline_size = inline_out.stat().st_size
lazy_size = lazy_out.stat().st_size
assert inline_size > lazy_size, (
f"Le HTML lazy ({lazy_size} B) doit être < HTML inline "
f"({inline_size} B). Diff : {inline_size - lazy_size} B."
)
# ---------------------------------------------------------------------------
# Robustesse
# ---------------------------------------------------------------------------
def test_lazy_mode_with_missing_image_does_not_crash(tmp_path: Path) -> None:
"""Si l'image source n'existe pas, l'externalisation log un warning
et continue (rétrocompat avec ``_encode_image_b64`` qui retourne ''
silencieusement)."""
from picarones.reports.html.generator import ReportGenerator
bench = generate_sample_benchmark(n_docs=2)
# Pointe vers un chemin inexistant
for er in bench.engine_reports:
for dr in er.document_results:
dr.image_path = "/nonexistent/missing.png"
out = tmp_path / "report.html"
# Ne doit PAS lever
path = ReportGenerator(bench, lazy_images=True).generate(out)
assert path.exists()
def test_safe_filename_generation(tmp_path: Path) -> None:
"""Les doc_id contenant des caractères non-FS-safe doivent produire
des noms de fichiers normalisés (pas de path traversal possible)."""
from PIL import Image
from picarones.reports.html.generator import _externalize_images_to_dir
src = tmp_path / "src.png"
Image.new("RGB", (50, 50), color=(0, 0, 0)).save(src)
bench = generate_sample_benchmark(n_docs=1)
bad_id = "../../etc/passwd"
for er in bench.engine_reports:
for dr in er.document_results:
dr.doc_id = bad_id
dr.image_path = str(src)
out_dir = tmp_path / "out"
out_dir.mkdir()
mapping = _externalize_images_to_dir(bench, out_dir)
# Garde-fou de path traversal : aucun fichier ne doit être créé en
# dehors de out_dir/report-assets, **et** le chemin résolu de tout
# fichier d'asset doit rester *à l'intérieur* du dossier d'assets.
forbidden = out_dir.parent / "etc" / "passwd"
assert not forbidden.exists(), "Path traversal détecté !"
assets_dir = (out_dir / "report-assets").resolve()
if mapping:
for url in mapping.values():
assert url.startswith("report-assets/")
# Le chemin résolu doit être contenu dans assets_dir
resolved = (out_dir / url).resolve()
assert str(resolved).startswith(str(assets_dir)), (
f"Path traversal : {resolved} sort de {assets_dir}"
)