"""Tests des 5 vues HTML thématiques (chantier 3 post-Sprint 97).
Couvre :
- Importation et signature des 5 vues.
- Adaptive masking : ``""`` quand aucune sous-section n'a de signal.
- Rendu HTML cohérent quand les données sont fournies.
- Anti-injection HTML sur les noms de moteurs et libellés.
- Composition correcte du shell ```` (premier ouvert,
autres fermés).
- Câblage générator → vues (les variables sont passées au template).
"""
from __future__ import annotations
import pytest
# ──────────────────────────────────────────────────────────────────────────
# 1. Imports + signatures
# ──────────────────────────────────────────────────────────────────────────
class TestViewsImport:
def test_all_views_import(self):
from picarones.reports.html.views import (
build_advanced_taxonomy_view_html,
build_diagnostics_view_html,
build_economics_view_html,
build_pipeline_view_html,
build_robustness_view_html,
)
assert callable(build_advanced_taxonomy_view_html)
assert callable(build_diagnostics_view_html)
assert callable(build_economics_view_html)
assert callable(build_pipeline_view_html)
assert callable(build_robustness_view_html)
# ──────────────────────────────────────────────────────────────────────────
# 2. Adaptive masking — vues vides retournent ""
# ──────────────────────────────────────────────────────────────────────────
@pytest.fixture
def empty_report_data() -> dict:
return {"engines": []}
class TestAdaptiveMasking:
def test_economics_empty_returns_empty(self, empty_report_data):
from picarones.reports.html.views import build_economics_view_html
assert build_economics_view_html(empty_report_data, {}) == ""
def test_advanced_taxonomy_empty_returns_empty(self, empty_report_data):
from picarones.reports.html.views import build_advanced_taxonomy_view_html
assert build_advanced_taxonomy_view_html(empty_report_data, {}) == ""
def test_diagnostics_empty_returns_empty(self, empty_report_data):
from picarones.reports.html.views import build_diagnostics_view_html
assert build_diagnostics_view_html(empty_report_data, {}) == ""
def test_pipeline_empty_returns_empty(self, empty_report_data):
from picarones.reports.html.views import build_pipeline_view_html
assert build_pipeline_view_html(empty_report_data, {}) == ""
def test_robustness_empty_returns_empty(self, empty_report_data):
from picarones.reports.html.views import build_robustness_view_html
assert build_robustness_view_html(empty_report_data, {}) == ""
def test_advanced_taxonomy_single_engine_returns_empty(self):
"""La comparaison nécessite ≥ 2 moteurs."""
from picarones.reports.html.views import build_advanced_taxonomy_view_html
single = {"engines": [{
"name": "tess",
"aggregated_taxonomy": {"class_distribution": {"x": 10}},
}]}
# Pas de comparison possible → vue masquée
assert build_advanced_taxonomy_view_html(single, {}) == ""
# ──────────────────────────────────────────────────────────────────────────
# 3. Rendu HTML quand données fournies
# ──────────────────────────────────────────────────────────────────────────
class _MockMetrics:
def __init__(self, *, cer=0.05, wer=0.1, reference_length=500):
self.cer = cer
self.wer = wer
self.reference_length = reference_length
self.error = None
class _MockDocResult:
def __init__(self, duration=1.0):
self.engine_error = None
self.duration_seconds = duration
self.metrics = _MockMetrics()
class _MockEngineReport:
def __init__(self, name, n_docs=10):
self.engine_name = name
self.document_results = [_MockDocResult() for _ in range(n_docs)]
class TestEconomicsView:
def test_throughput_with_realistic_engines(self):
from picarones.reports.html.views import build_economics_view_html
reports = [
_MockEngineReport("tesseract"),
_MockEngineReport("pero_ocr"),
]
html = build_economics_view_html(
{"engines": []}, {},
engine_reports=reports,
)
assert html != ""
# Les deux moteurs doivent apparaître dans le HTML
assert "tesseract" in html
assert "pero" in html
def test_extra_html_blocks_appended(self):
from picarones.reports.html.views import build_economics_view_html
extra = ['CUSTOM_BLOCK
']
html = build_economics_view_html(
{"engines": []},
{"economics_extra_title": "Coût projeté"},
engine_reports=[_MockEngineReport("tess")],
extra_html_blocks=extra,
)
assert "CUSTOM_BLOCK" in html
def test_zero_duration_excludes_engine(self):
"""Bench depuis cache (durations=0) ne génère pas de throughput."""
from picarones.reports.html.views import build_economics_view_html
report = _MockEngineReport("cached")
for dr in report.document_results:
dr.duration_seconds = 0.0
html = build_economics_view_html(
{"engines": []}, {}, engine_reports=[report],
)
# Aucun moteur n'a de durée → vue masquée
assert html == ""
class TestAdvancedTaxonomyView:
def test_two_engines_taxonomy_compared(self):
from picarones.reports.html.views import build_advanced_taxonomy_view_html
report_data = {
"engines": [
{
"name": "tess", "cer": 0.05,
"aggregated_taxonomy": {
"class_distribution": {
"case_error": 100, "ligature_error": 50,
"lacuna": 30,
},
},
},
{
"name": "pero", "cer": 0.07,
"aggregated_taxonomy": {
"class_distribution": {
"case_error": 30, "lacuna": 80,
"diacritic_error": 60,
},
},
},
],
}
html = build_advanced_taxonomy_view_html(report_data, {})
assert html != ""
# Le diagramme miroir doit nommer les 2 moteurs
assert "tess" in html
assert "pero" in html
def test_anti_injection_engine_name(self):
"""Un nom de moteur avec balises HTML doit être échappé."""
from picarones.reports.html.views import build_advanced_taxonomy_view_html
report_data = {
"engines": [
{
"name": "",
"cer": 0.05,
"aggregated_taxonomy": {
"class_distribution": {"case_error": 10},
},
},
{
"name": "pero",
"cer": 0.07,
"aggregated_taxonomy": {
"class_distribution": {"lacuna": 10},
},
},
],
}
html = build_advanced_taxonomy_view_html(report_data, {})
# Pas de balise script non échappée
assert "",
view_note="Note bold",
blocks=[("Block ", "content
")],
)
# Pas d'injection
assert "" not in html
# Mais visible sous forme échappée
assert "<script" in html
# ──────────────────────────────────────────────────────────────────────────
# 5. Câblage générator → vues
# ──────────────────────────────────────────────────────────────────────────
class TestGeneratorWiring:
def test_generator_imports_three_views(self):
"""Test runtime du câblage des 3 vues automatiques (economics,
advanced_taxonomy, diagnostics).
Vérifie que la méthode ``ReportGenerator._build_section_html``
retourne un dict contenant les 3 clés attendues, ce qui
garantit qu'elles seront splatées vers le template Jinja.
Cette version remplace l'ancien test qui scannait textuellement
``generator.py`` à la recherche de ``var=`` ou ``"var"`` —
approche fragile (passait sur n'importe quelle occurrence dans
une docstring) et trop liée à la forme du code.
"""
from picarones.evaluation.synthetic import generate_sample_benchmark
from picarones.reports.html.generator import ReportGenerator
bench = generate_sample_benchmark()
gen = ReportGenerator(bench, lang="fr")
from picarones.reports.i18n import get_labels
report_data = {
"engines": [],
"inter_engine_analysis": None,
"stratified_ranking": None,
"available_strata": [],
"corpus_homogeneity": None,
}
section_html = gen._build_section_html(report_data, get_labels("fr"))
for name in (
"economics_view_html",
"advanced_taxonomy_view_html",
"diagnostics_view_html",
):
assert name in section_html, (
f"clé {name!r} absente du dict retourné par "
"ReportGenerator._build_section_html — la vue ne sera "
"pas câblée vers le template."
)
# Adaptive : avec un report_data vide, chaque vue retourne ""
# (rapport adaptatif). On vérifie le type, pas le contenu.
assert isinstance(section_html[name], str), (
f"section {name!r} doit être une chaîne, "
f"pas {type(section_html[name]).__name__}"
)
def test_template_uses_three_views(self):
"""Les 3 vues thématiques sont câblées dans leur destination
XerOCR sémantique (Phase 21 — dispatch depuis view_analyses) :
- economics_view_html → Engines/Tableau (throughput rejoint
le tableau de référence par moteur).
- advanced_taxonomy_view_html → Engines/Diagnostics (extends the
basic taxonomy chart).
- diagnostics_view_html → Engines/Diagnostics (composite
thématique homonyme).
"""
from pathlib import Path
templates_dir = (
Path(__file__).parent.parent.parent
/ "picarones" / "reports" / "html" / "templates"
)
from tests._strip_helpers import strip_comments
engines_table_src = strip_comments(
(templates_dir / "views" / "engines_table.html").read_text(encoding="utf-8"),
"jinja2",
)
engines_diag_src = strip_comments(
(templates_dir / "views" / "engines_diagnostics.html").read_text(encoding="utf-8"),
"jinja2",
)
# Accepte les deux variantes Jinja2 whitespace control (``{% if %}``
# ou ``{%- if %}``) — le second utilisé en engines_diagnostics
# depuis Phase 22+23 pour éviter l'accumulation de lignes vides.
def _wired(src: str, var: str) -> bool:
return f"{{% if {var} %}}" in src or f"{{%- if {var} %}}" in src
assert _wired(engines_table_src, "economics_view_html"), (
"economics_view_html doit être câblée dans engines_table.html "
"(Phase 21 dispatch — throughput va avec le tableau moteurs)"
)
assert _wired(engines_diag_src, "advanced_taxonomy_view_html"), (
"advanced_taxonomy_view_html doit être câblée dans engines_diagnostics.html"
)
assert _wired(engines_diag_src, "diagnostics_view_html"), (
"diagnostics_view_html doit être câblée dans engines_diagnostics.html"
)