File size: 7,656 Bytes
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e45d507
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
563a0f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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}"
            )