"""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" )