"""Tests Sprint 17 — refactor du générateur HTML en templates Jinja2.
Objectif : garantir que le découpage de ``_HTML_TEMPLATE`` (3100 lignes
monolithiques) en templates séparés (``base.html.j2`` + 9 partials) n'a pas
altéré la sortie du rapport. Après ce sprint, toute modification future doit
conserver ces invariants.
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
import pytest
from picarones import fixtures
from picarones.report.generator import (
ReportGenerator,
_build_jinja_env,
_TEMPLATES_DIR,
)
# ---------------------------------------------------------------------------
# Structure des fichiers attendus
# ---------------------------------------------------------------------------
EXPECTED_TEMPLATE_FILES = {
"base.html.j2",
"_header.html",
"_footer.html",
"_styles.css",
"_app.js",
"view_ranking.html",
"view_gallery.html",
"view_document.html",
"view_analyses.html",
"view_characters.html",
}
class TestTemplateStructure:
def test_all_expected_template_files_exist(self):
present = {p.name for p in _TEMPLATES_DIR.iterdir() if p.is_file()}
missing = EXPECTED_TEMPLATE_FILES - present
assert not missing, f"Templates manquants : {missing}"
def test_jinja_env_can_load_base_template(self):
env = _build_jinja_env()
tpl = env.get_template("base.html.j2")
assert tpl is not None
def test_no_dangling_format_placeholders_in_templates(self):
"""Aucun {placeholder} style .format() ne doit traîner — tout doit être
en syntaxe Jinja2 {{ variable }}."""
suspicious_pattern = re.compile(r"(? 10_000 # Chart.js inline à lui seul
def test_report_contains_expected_markers(self, benchmark_result, tmp_path):
out = tmp_path / "rapport.html"
ReportGenerator(benchmark_result).generate(out)
html = out.read_text(encoding="utf-8")
# Structure HTML attendue
assert "" in html
assert "" in html
assert "Picarones" in html
# Les 5 vues doivent être présentes
for view in ("view-ranking", "view-gallery", "view-document",
"view-analyses", "view-characters"):
assert f'id="{view}"' in html, f"Vue '{view}' absente du rapport"
# Données embarquées
assert "const DATA =" in html
assert "const I18N =" in html
# Chart.js inline
assert "Chart.js" in html
def test_report_has_no_nested_script_tags(self, benchmark_result, tmp_path):
"""Un bug classique du refactor : les `")
assert opens == closes, f"Script tags déséquilibrés : {opens} ouvertures vs {closes} fermetures"
def test_report_deterministic_given_same_data(self, benchmark_result, tmp_path):
"""Deux générations sur le MÊME benchmark produisent du HTML identique
(garde-fou pour le moteur narratif Sprint 4 qui doit être déterministe)."""
out1 = tmp_path / "r1.html"
out2 = tmp_path / "r2.html"
ReportGenerator(benchmark_result).generate(out1)
ReportGenerator(benchmark_result).generate(out2)
h1 = hashlib.sha256(out1.read_bytes()).hexdigest()
h2 = hashlib.sha256(out2.read_bytes()).hexdigest()
assert h1 == h2, "La génération du rapport doit être déterministe"
def test_english_locale_renders(self, benchmark_result, tmp_path):
out = tmp_path / "report_en.html"
ReportGenerator(benchmark_result, lang="en").generate(out)
html = out.read_text(encoding="utf-8")
assert '' in html
# ---------------------------------------------------------------------------
# Chargement i18n depuis JSON
# ---------------------------------------------------------------------------
class TestI18nFromJSON:
def test_i18n_directory_exists_and_has_json(self):
i18n_dir = Path(__file__).parent.parent / "picarones" / "report" / "i18n"
assert i18n_dir.is_dir()
files = {p.name for p in i18n_dir.glob("*.json")}
assert "fr.json" in files
assert "en.json" in files
def test_all_i18n_files_parse_as_json(self):
i18n_dir = Path(__file__).parent.parent / "picarones" / "report" / "i18n"
for f in i18n_dir.glob("*.json"):
data = json.loads(f.read_text(encoding="utf-8"))
assert isinstance(data, dict)
assert len(data) > 50 # raisonnable : on a 101 clés
def test_fr_and_en_have_same_keys(self):
"""Garde-fou contre les traductions manquantes."""
from picarones.i18n import TRANSLATIONS
fr_keys = set(TRANSLATIONS.get("fr", {}).keys())
en_keys = set(TRANSLATIONS.get("en", {}).keys())
missing_in_en = fr_keys - en_keys
missing_in_fr = en_keys - fr_keys
assert not missing_in_en, f"Clés manquantes en anglais : {missing_in_en}"
assert not missing_in_fr, f"Clés manquantes en français : {missing_in_fr}"
def test_translations_load_via_public_api(self):
from picarones.i18n import get_labels, SUPPORTED_LANGS
assert "fr" in SUPPORTED_LANGS
assert "en" in SUPPORTED_LANGS
fr = get_labels("fr")
en = get_labels("en")
assert fr["html_lang"] == "fr"
assert en["html_lang"] == "en"
# Fallback sur fr si langue inconnue
assert get_labels("xx") == fr
# ---------------------------------------------------------------------------
# Validation du contenu extrait (pas de régression sur le HTML rendu)
# ---------------------------------------------------------------------------
class TestTemplateContent:
def test_css_file_contains_expected_rules(self):
css = (_TEMPLATES_DIR / "_styles.css").read_text(encoding="utf-8")
# Quelques règles canoniques du rapport qui doivent rester
for marker in ("nav", ".cer-badge", ".gallery-card", ".tab-btn"):
assert marker in css, f"Règle CSS '{marker}' manquante"
def test_app_js_starts_with_use_strict(self):
js = (_TEMPLATES_DIR / "_app.js").read_text(encoding="utf-8")
first_nonblank = next((l for l in js.splitlines() if l.strip()), "")
assert "'use strict'" in first_nonblank
def test_app_js_has_no_residual_script_tag(self):
"""Garde-fou contre un futur refactor qui ré-inclurait par erreur."""
js = (_TEMPLATES_DIR / "_app.js").read_text(encoding="utf-8")
assert "