File size: 16,531 Bytes
48aae80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979f3c3
48aae80
 
 
979f3c3
48aae80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d109222
48aae80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""Tests Sprint 68 โ€” vue HTML de comparaison de N pipelines.

Couvre :

1. ``RankingSpec`` : ``display_label`` auto / explicite.
2. ``build_pipeline_ranking_table_html`` :
   - tableau rang / pipeline / valeur, ordre cohรฉrent
   - pipelines sans valeur en queue avec tirets
   - cellule de rang colorรฉe (gradient vertโ†’rouge)
   - vide si la comparaison ne contient aucune pipeline
3. ``build_pipeline_gain_table_html`` :
   - tableau pipeline / valeur / absolute / relative
   - baseline marquรฉe explicitement
   - couleur cellule favorable / dรฉfavorable selon
     ``higher_is_better``
   - baseline inconnue โ†’ chaรฎne vide
4. ``build_pipeline_comparison_summary_html`` : corpus, n_docs,
   n_pipelines, durรฉe, mini-rรฉsumรฉ par pipeline.
5. ``build_pipeline_comparison_report_html`` :
   - document HTML autonome (doctype, head, body, styles)
   - titre, lang attribute FR/EN
   - rankings affichรฉs si ranking_specs fourni
   - gain table affichรฉ uniquement si baseline_pipeline fourni
6. Anti-injection : pipeline name, corpus, labels.
7. Complรฉtude i18n : nouvelles clรฉs ``pipeline_*`` prรฉsentes
   en FR et EN.
"""

from __future__ import annotations

import json
from pathlib import Path

from picarones.core.modules import ArtifactType
from picarones.measurements.pipeline_benchmark import (
    PipelineBenchmarkResult,
    StepAggregate,
)
from picarones.measurements.pipeline_comparison import PipelineComparisonResult
from picarones.report.pipeline_render import (
    RankingSpec,
    build_pipeline_comparison_report_html,
    build_pipeline_comparison_summary_html,
    build_pipeline_gain_table_html,
    build_pipeline_ranking_table_html,
)


def _make_bench(name: str, cer_mean: float, n: int = 10) -> PipelineBenchmarkResult:
    bench = PipelineBenchmarkResult(
        pipeline_name=name, corpus_name="demo",
        n_docs=n, total_duration_seconds=1.0,
    )

    class _PR:
        def __init__(self, ok): self._ok = ok
        @property
        def succeeded(self): return self._ok
    bench.per_doc_results = [_PR(True)] * n
    bench.per_step_aggregates = [
        StepAggregate(
            step_name="ocr", n_docs=n, n_succeeded=n, n_failed=0,
            duration_seconds_total=0.5, duration_seconds_mean=0.05,
            duration_seconds_median=0.05,
            junction_metrics={
                "text": {
                    "cer": {"mean": cer_mean, "median": cer_mean, "n": n},
                },
            },
        ),
    ]
    return bench


def _make_comparison(
    pipelines: list[tuple[str, float]],
) -> PipelineComparisonResult:
    """Crรฉe une comparaison avec pipelines = [(name, cer_mean), ...]."""
    comparison = PipelineComparisonResult(
        corpus_name="demo",
        n_docs=10,
        total_duration_seconds=3.0,
    )
    for name, cer in pipelines:
        comparison.per_pipeline[name] = _make_bench(name, cer)
    return comparison


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 1. RankingSpec
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestRankingSpec:
    def test_display_label_default(self) -> None:
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        assert spec.display_label == "text.cer"

    def test_display_label_explicit(self) -> None:
        spec = RankingSpec(ArtifactType.TEXT, "cer", label="CER global")
        assert spec.display_label == "CER global"

    def test_higher_is_better_default_false(self) -> None:
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        assert spec.higher_is_better is False


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 2. ranking table
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestRankingTable:
    def test_orders_by_metric_ascending(self) -> None:
        comparison = _make_comparison([
            ("alpha", 0.20),
            ("beta", 0.05),
            ("gamma", 0.10),
        ])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_ranking_table_html(comparison, spec)
        # beta (CER 0.05) doit apparaรฎtre avant alpha (CER 0.20)
        idx_beta = html.find("beta")
        idx_alpha = html.find("alpha")
        idx_gamma = html.find("gamma")
        assert 0 < idx_beta < idx_gamma < idx_alpha

    def test_higher_is_better_reverses(self) -> None:
        comparison = _make_comparison([
            ("alpha", 0.20),
            ("beta", 0.80),
        ])
        spec = RankingSpec(
            ArtifactType.TEXT, "cer", higher_is_better=True,
        )
        html = build_pipeline_ranking_table_html(comparison, spec)
        # beta (0.80) en premier puisqu'on inverse
        idx_beta = html.find("beta")
        idx_alpha = html.find("alpha")
        assert idx_beta < idx_alpha

    def test_pipelines_without_metric_in_queue(self) -> None:
        # Pipeline "bad" sans CER (aucun step n'a tournรฉ)
        comparison = _make_comparison([("alpha", 0.10)])
        comparison.per_pipeline["bad"] = PipelineBenchmarkResult(
            pipeline_name="bad", corpus_name="demo",
        )
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_ranking_table_html(comparison, spec)
        idx_bad = html.find("bad")
        idx_alpha = html.find("alpha")
        assert 0 < idx_alpha < idx_bad
        # Le pipeline sans valeur affiche un tiret
        assert "โ€”" in html

    def test_rank_cell_colored(self) -> None:
        comparison = _make_comparison([
            ("a", 0.1), ("b", 0.2), ("c", 0.3),
        ])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_ranking_table_html(comparison, spec)
        assert "background:#" in html

    def test_empty_comparison_returns_empty(self) -> None:
        comparison = PipelineComparisonResult(corpus_name="empty")
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        assert build_pipeline_ranking_table_html(comparison, spec) == ""

    def test_uses_display_label_in_title(self) -> None:
        comparison = _make_comparison([("alpha", 0.1)])
        spec = RankingSpec(
            ArtifactType.TEXT, "cer", label="Mon Label",
        )
        html = build_pipeline_ranking_table_html(comparison, spec)
        assert "Mon Label" in html


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 3. gain table
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestGainTable:
    def test_baseline_marked(self) -> None:
        comparison = _make_comparison([
            ("baseline", 0.20), ("better", 0.10),
        ])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_gain_table_html(
            comparison, spec, baseline_pipeline="baseline",
        )
        assert "(rรฉfรฉrence)" in html
        # Les deux pipelines apparaissent
        assert "baseline" in html
        assert "better" in html

    def test_gain_absolute_and_relative(self) -> None:
        comparison = _make_comparison([
            ("baseline", 0.20), ("better", 0.10),
        ])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_gain_table_html(
            comparison, spec, baseline_pipeline="baseline",
        )
        # better : -0.1000 absolute, -50% relative
        assert "-0.1000" in html
        assert "-50.0%" in html

    def test_color_favorable_when_lower_better(self) -> None:
        # CER baisse โ†’ favorable โ†’ cellule verte (#cfe8cf)
        comparison = _make_comparison([
            ("baseline", 0.20), ("better", 0.05),
        ])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_gain_table_html(
            comparison, spec, baseline_pipeline="baseline",
        )
        assert "#cfe8cf" in html

    def test_color_unfavorable_when_lower_better(self) -> None:
        # CER monte โ†’ dรฉfavorable โ†’ cellule rouge (#f4cfcf)
        comparison = _make_comparison([
            ("baseline", 0.10), ("worse", 0.30),
        ])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_gain_table_html(
            comparison, spec, baseline_pipeline="baseline",
        )
        assert "#f4cfcf" in html

    def test_unknown_baseline_returns_empty(self) -> None:
        comparison = _make_comparison([("alpha", 0.1)])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_gain_table_html(
            comparison, spec, baseline_pipeline="nonexistent",
        )
        assert html == ""


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 4. comparison summary
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestComparisonSummary:
    def test_renders_corpus_and_counts(self) -> None:
        comparison = _make_comparison([
            ("a", 0.1), ("b", 0.2),
        ])
        html = build_pipeline_comparison_summary_html(comparison)
        assert "demo" in html
        assert "10" in html  # n_docs
        # 2 pipelines
        assert ">2<" in html

    def test_per_pipeline_mini_summary(self) -> None:
        comparison = _make_comparison([
            ("a", 0.1), ("b", 0.2),
        ])
        html = build_pipeline_comparison_summary_html(comparison)
        # Mini-rรฉsumรฉ : nom (n_succeeded/n_docs)
        assert "a" in html
        assert "b" in html
        assert "10/10" in html


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 5. document autonome
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestComparisonReport:
    def test_doctype_and_structure(self) -> None:
        comparison = _make_comparison([("a", 0.1)])
        html = build_pipeline_comparison_report_html(comparison)
        assert html.startswith("<!doctype html>")
        assert "<html" in html
        assert "<head>" in html
        assert "<body>" in html
        assert "</html>" in html

    def test_lang_attribute(self) -> None:
        comparison = _make_comparison([("a", 0.1)])
        html_fr = build_pipeline_comparison_report_html(
            comparison, lang="fr",
        )
        html_en = build_pipeline_comparison_report_html(
            comparison, lang="en",
        )
        assert 'lang="fr"' in html_fr
        assert 'lang="en"' in html_en

    def test_rankings_displayed_when_specs_provided(self) -> None:
        comparison = _make_comparison([
            ("a", 0.20), ("b", 0.05),
        ])
        specs = [RankingSpec(ArtifactType.TEXT, "cer", label="CER")]
        html = build_pipeline_comparison_report_html(
            comparison, ranking_specs=specs,
        )
        assert "Classement par CER" in html

    def test_no_rankings_without_specs(self) -> None:
        comparison = _make_comparison([("a", 0.1)])
        html = build_pipeline_comparison_report_html(comparison)
        # Pas de tableau de classement sans ranking_specs
        assert "Classement par" not in html

    def test_gain_table_only_with_baseline(self) -> None:
        comparison = _make_comparison([
            ("baseline", 0.20), ("better", 0.10),
        ])
        specs = [RankingSpec(ArtifactType.TEXT, "cer")]
        # Sans baseline : pas de gain table
        html_no_baseline = build_pipeline_comparison_report_html(
            comparison, ranking_specs=specs,
        )
        assert "Gain vs" not in html_no_baseline
        # Avec baseline : gain table prรฉsent
        html_with_baseline = build_pipeline_comparison_report_html(
            comparison, ranking_specs=specs,
            baseline_pipeline="baseline",
        )
        assert "Gain vs" in html_with_baseline


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 6. Anti-injection
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestAntiInjection:
    def test_pipeline_name_escaped_in_ranking(self) -> None:
        comparison = _make_comparison([
            ("<script>alert(1)</script>", 0.1),
        ])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        html = build_pipeline_ranking_table_html(comparison, spec)
        assert "<script>alert" not in html
        assert "&lt;script&gt;" in html

    def test_corpus_name_escaped_in_summary(self) -> None:
        comparison = PipelineComparisonResult(
            corpus_name="<img src=x onerror=alert(1)>",
        )
        html = build_pipeline_comparison_summary_html(comparison)
        assert "<img src=x" not in html
        assert "&lt;img" in html

    def test_label_via_i18n_escaped(self) -> None:
        comparison = _make_comparison([("a", 0.1)])
        spec = RankingSpec(ArtifactType.TEXT, "cer")
        labels = {"pipeline_ranking_title": "<b>Hack</b>"}
        html = build_pipeline_ranking_table_html(
            comparison, spec, labels=labels,
        )
        assert "<b>Hack</b>" not in html
        assert "&lt;b&gt;Hack&lt;/b&gt;" in html


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 7. Complรฉtude i18n
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestI18nCompleteness:
    def _load(self, lang: str) -> dict:
        path = (
            Path(__file__).parent.parent.parent
            / "picarones" / "report" / "i18n" / f"{lang}.json"
        )
        return json.loads(path.read_text(encoding="utf-8"))

    def test_new_keys_present_fr(self) -> None:
        d = self._load("fr")
        required = (
            "pipeline_comparison_report_title",
            "pipeline_comparison_report_note",
            "pipeline_comparison_summary_title",
            "pipeline_n_pipelines_label",
            "pipeline_n_pipelines_short",
            "pipeline_per_pipeline_label",
            "pipeline_ranking_title", "pipeline_rank_label",
            "pipeline_value_label",
            "pipeline_gain_title",
            "pipeline_gain_absolute_label",
            "pipeline_gain_relative_label",
            "pipeline_baseline_marker",
        )
        for key in required:
            assert key in d, f"manque clรฉ FR : {key}"

    def test_new_keys_present_en(self) -> None:
        d_fr = self._load("fr")
        d_en = self._load("en")
        for key in d_fr:
            if key.startswith("pipeline_"):
                assert key in d_en, f"manque clรฉ EN : {key}"