"""Phase B6 — rendu HTML des ``BenchmarkResult.view_results``.
Vérifie que le renderer ``build_view_results_html`` :
1. Retourne ``""`` quand ``view_results`` est vide ou ``None`` (compat
ascendante : un BenchmarkResult issu de
``run_benchmark_via_service`` sans RunOrchestrator n'a pas de
``view_results``).
2. Génère une section par vue présente, avec titre + note
méthodologique + tableau engine × moyenne_par_metric.
3. Liste explicitement les pipelines OMIS de chaque vue (= ceux qui
n'ont pas produit d'artefact éligible).
4. Échappe le HTML correctement (résistance XSS via noms d'engine
custom).
5. S'intègre proprement dans le rapport HTML complet (test bout-en-bout
via ``ReportGenerator``).
"""
from __future__ import annotations
from picarones.evaluation.benchmark_result import BenchmarkResult, EngineReport
from picarones.evaluation.metric_result import MetricsResult
from picarones.reports.html.renderers.view_results import (
build_view_results_html,
)
# ──────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────
def _make_engine_report(name: str) -> EngineReport:
return EngineReport(
engine_name=name,
engine_version="test",
engine_config={},
document_results=[],
aggregated_metrics={},
)
# ──────────────────────────────────────────────────────────────────────
# Renderer adaptatif (cas vides)
# ──────────────────────────────────────────────────────────────────────
class TestEmptyViewResults:
def test_none_returns_empty_string(self) -> None:
assert build_view_results_html(None, all_engine_names=["t"]) == ""
def test_empty_dict_returns_empty_string(self) -> None:
assert build_view_results_html({}, all_engine_names=["t"]) == ""
# ──────────────────────────────────────────────────────────────────────
# Rendu d'une vue avec données
# ──────────────────────────────────────────────────────────────────────
class TestSingleViewRendering:
def _sample_view_results(
self,
) -> dict[str, dict[str, dict[str, dict[str, float]]]]:
return {
"text_final": {
"tesseract": {
"doc1": {"cer": 0.05, "wer": 0.10},
"doc2": {"cer": 0.03, "wer": 0.08},
},
},
}
def test_section_contains_view_title(self) -> None:
html = build_view_results_html(
self._sample_view_results(), all_engine_names=["tesseract"],
)
assert "TextView" in html
# Note méthodologique présente.
assert "projetées" in html.lower() or "projeté" in html.lower()
def test_section_contains_engine_metrics_table(self) -> None:
html = build_view_results_html(
self._sample_view_results(), all_engine_names=["tesseract"],
)
# Header + métrique + valeur.
assert "tesseract" in html
assert "cer" in html
assert "wer" in html
# Moyenne CER : (0.05 + 0.03) / 2 = 0.04 → 4.00%.
assert "4.00%" in html
# Moyenne WER : (0.10 + 0.08) / 2 = 0.09 → 9.00%.
assert "9.00%" in html
def test_no_omitted_when_all_eligible(self) -> None:
html = build_view_results_html(
self._sample_view_results(), all_engine_names=["tesseract"],
)
# "Tous les pipelines éligibles" affiché car aucun n'est omis.
assert "éligibles" in html or "eligible" in html.lower()
# ──────────────────────────────────────────────────────────────────────
# Pipelines omis (cas AltoView avec engine OCR pur)
# ──────────────────────────────────────────────────────────────────────
class TestOmittedPipelines:
def test_alto_view_omits_text_only_engine(self) -> None:
"""Cas typique : AltoView ne reçoit que des résultats du
pipeline qui produit ALTO. Un pipeline OCR seul est omis."""
view_results = {
"alto_documentary": {
"tesseract_alto": {
"doc1": {"alto_validity": 1.0},
},
# Pas de "tesseract_text_only" → omis de cette vue
},
}
html = build_view_results_html(
view_results,
all_engine_names=["tesseract_alto", "tesseract_text_only"],
)
assert "tesseract_alto" in html
# tesseract_text_only listé dans Pipelines omis.
assert "tesseract_text_only" in html
# Le label "Pipelines omis" est présent.
assert "omis" in html.lower() or "omitted" in html.lower()
# ──────────────────────────────────────────────────────────────────────
# Multi-vues (le cas typique de production)
# ──────────────────────────────────────────────────────────────────────
class TestMultipleViews:
def test_renders_three_canonical_views(self) -> None:
view_results = {
"text_final": {
"tesseract": {"doc1": {"cer": 0.1}},
},
"alto_documentary": {
"tesseract": {"doc1": {"alto_validity": 1.0}},
},
"searchability": {
"tesseract": {"doc1": {"searchability_recall": 0.95}},
},
}
html = build_view_results_html(
view_results, all_engine_names=["tesseract"],
)
assert "TextView" in html
assert "AltoView" in html
assert "SearchView" in html
# ──────────────────────────────────────────────────────────────────────
# Sécurité — XSS via noms d'engine custom
# ──────────────────────────────────────────────────────────────────────
class TestXssEscaping:
def test_engine_name_with_html_chars_is_escaped(self) -> None:
view_results = {
"text_final": {
"": {"doc1": {"cer": 0.1}},
},
}
html = build_view_results_html(
view_results, all_engine_names=[""],
)
# Le HTML brut ne doit pas apparaître non échappé.
assert "