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