"""Tests Sprint 41 — section NER dans le rapport HTML. Couvre : 1. ``build_ner_summary_html`` rend le tableau résumé (F1/P/R + totaux). 2. ``build_ner_per_category_html`` rend la heatmap moteur × catégorie. 3. **Masquage adaptatif** : les deux retournent ``""`` si aucun moteur n'a de ``aggregated_ner``, ou si le sous-champ ``per_category`` est absent (pour le second). 4. **Anti-injection** : un nom de moteur ou une catégorie contenant des balises HTML est échappé. 5. **Intégration template** : le rapport HTML inclut la section quand au moins un moteur a un ``aggregated_ner``, et l'omet sinon. 6. **i18n** : les clés FR/EN existent et sont utilisées. """ from __future__ import annotations import json from pathlib import Path import pytest from picarones.fixtures import generate_sample_benchmark from picarones.report.generator import ReportGenerator from picarones.report.ner_render import ( build_ner_per_category_html, build_ner_summary_html, ) # ────────────────────────────────────────────────────────────────────────── # Fixtures # ────────────────────────────────────────────────────────────────────────── def _engine_with_ner(name: str = "tess") -> dict: return { "name": name, "aggregated_ner": { "global": { "precision": 0.85, "recall": 0.78, "f1": 0.81, "support": 50, }, "per_category": { "PER": {"precision": 0.9, "recall": 0.8, "f1": 0.85, "support": 30}, "LOC": {"precision": 0.7, "recall": 0.6, "f1": 0.65, "support": 15}, "DATE": {"precision": 1.0, "recall": 0.9, "f1": 0.95, "support": 5}, }, "doc_count": 50, "hallucinated_total": 8, "missed_total": 11, }, } def _engine_without_ner(name: str = "no_ner") -> dict: return {"name": name, "aggregated_ner": None} # ────────────────────────────────────────────────────────────────────────── # 1. build_ner_summary_html # ────────────────────────────────────────────────────────────────────────── class TestNerSummaryHtml: def test_renders_table_with_engine_row(self) -> None: html = build_ner_summary_html([_engine_with_ner("tess")]) assert "ner-summary" in html assert "tess" in html assert "81.0 %" in html # F1 assert "85.0 %" in html # Precision assert "78.0 %" in html # Recall assert "50" in html # doc_count assert "8" in html # hallucinations assert "11" in html # missed def test_multiple_engines(self) -> None: engines = [_engine_with_ner("a"), _engine_with_ner("b")] html = build_ner_summary_html(engines) assert "" in html # Deux moteurs → trois minimum (header + 2 lignes) assert html.count("") >= 3 # ────────────────────────────────────────────────────────────────────────── # 2. build_ner_per_category_html # ────────────────────────────────────────────────────────────────────────── class TestNerPerCategoryHtml: def test_renders_heatmap_with_categories(self) -> None: html = build_ner_per_category_html([_engine_with_ner("tess")]) assert "ner-per-category" in html # Trois catégories en en-têtes for cat in ("PER", "LOC", "DATE"): assert cat in html # Le F1 est rendu en pourcentage assert "85.0 %" in html # PER assert "65.0 %" in html # LOC assert "95.0 %" in html # DATE def test_categories_union_across_engines(self) -> None: e1 = _engine_with_ner("a") e2 = { "name": "b", "aggregated_ner": { "global": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 5}, "per_category": { "ORG": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 5}, }, "doc_count": 5, "hallucinated_total": 0, "missed_total": 0, }, } html = build_ner_per_category_html([e1, e2]) for cat in ("PER", "LOC", "DATE", "ORG"): assert cat in html def test_no_data_marker_for_missing_category(self) -> None: e1 = _engine_with_ner("a") # b n'a que ORG, donc ses cellules PER/LOC/DATE doivent afficher "—" e2 = { "name": "b", "aggregated_ner": { "global": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 5}, "per_category": { "ORG": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 5}, }, "doc_count": 5, "hallucinated_total": 0, "missed_total": 0, }, } html = build_ner_per_category_html([e1, e2]) # "—" apparaît dans les cellules vides assert "—" in html # ────────────────────────────────────────────────────────────────────────── # 3. Masquage adaptatif # ────────────────────────────────────────────────────────────────────────── class TestAdaptiveMasking: def test_summary_empty_when_no_engine_has_ner(self) -> None: assert build_ner_summary_html([]) == "" assert build_ner_summary_html([_engine_without_ner()]) == "" def test_per_category_empty_when_no_engine_has_ner(self) -> None: assert build_ner_per_category_html([]) == "" assert build_ner_per_category_html([_engine_without_ner()]) == "" def test_per_category_empty_when_no_categories(self) -> None: engine = { "name": "x", "aggregated_ner": { "global": {"precision": 0.0, "recall": 0.0, "f1": 0.0}, "per_category": {}, # vide "doc_count": 0, "hallucinated_total": 0, "missed_total": 0, }, } assert build_ner_per_category_html([engine]) == "" # ────────────────────────────────────────────────────────────────────────── # 4. Anti-injection # ────────────────────────────────────────────────────────────────────────── class TestAntiInjection: def test_engine_name_with_html_chars_escaped(self) -> None: engine = _engine_with_ner("") html = build_ner_summary_html([engine]) assert "