Picarones / tests /reports /test_documents_view.py
Claude
test(report): Phase 17 commit F โ€” [2.1] + [2.2] tests theater retissรฉs
7afb1c1 unverified
Raw
History Blame Contribute Delete
27 kB
"""Tests Vue 03 ยท Par document โ€” galerie + drill-in + worst_lines.
Couvre :
1. **Hero stats** : 3 chiffres clรฉs (documents / strates normalisรฉes /
moteurs) cohรฉrents avec les chips de filtre.
2. **Galerie** : grid de cards avec aperรงu de strate synthรฉtique CSS,
badges moteurs Aโ†’E, et CER par moteur.
3. **Toolbar chips** : ยซ Toutes ยป + 1 chip par strate dรฉtectรฉe,
``data-strate`` sur chaque chip pour pilotage JS.
4. **Drill-in** : ``data-doc-id`` exposรฉ, ``_openLegacyDocument()``
dรฉfini dans ``_documents.js`` (dรฉlรฉgation au lieu d'``onclick``
inline pour parer XSS via doc_id contenant une apostrophe).
5. **Worst lines globales** : branchรฉes en bas de la galerie via
``_build_documents_worst_lines_html`` du gรฉnรฉrateur.
6. **HTML escape** sur doc_id et labels (anti-XSS).
7. **Empty state** : message clair quand le corpus est vide.
8. **Template** : plus de placeholder ยซ squelette S5B ยป.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
DOC_TEMPLATE = (
REPO_ROOT
/ "picarones" / "reports" / "html" / "templates" / "views" / "documents.html"
)
@pytest.fixture(scope="module")
def demo_html(tmp_path_factory) -> str:
out = tmp_path_factory.mktemp("documents") / "demo.html"
result = subprocess.run(
[sys.executable, "-m", "picarones", "demo", "--output", str(out)],
capture_output=True, text=True, timeout=120,
)
assert result.returncode == 0, (
f"`picarones demo` a รฉchouรฉ :\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
return out.read_text(encoding="utf-8")
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 1. Hero stats
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDocumentsHeroStats:
def test_three_hero_stats_rendered(self, demo_html: str) -> None:
# Le container hero-stats existe et contient bien 3 stats.
idx = demo_html.find('id="documents-hero-stats"')
assert idx > 0
# 3 .hero-stat dans le bloc Documents
end = demo_html.find('</header>', idx)
snippet = demo_html[idx:end]
assert snippet.count('class="hero-stat"') == 3
def test_hero_stat_count_matches_chip_total(self, demo_html: str) -> None:
"""Le nombre de strates affichรฉ dans le hero stat doit รชtre
รฉgal au nombre de chips moins la chip ``Toutes`` (cohรฉrence
UX)."""
import re
m = re.search(
r'id="documents-hero-stats">.*?<span class="v">(\d+)</span>'
r'.*?<span class="v">(\d+)</span>'
r'.*?<span class="v">(\d+)</span>',
demo_html, re.S,
)
assert m is not None
n_docs, n_strata, n_engines = map(int, m.groups())
assert n_docs > 0
assert n_strata >= 1
assert n_engines >= 1
# Chips rรฉels (hors Toutes)
chips = re.findall(
r'<button[^>]*class="doc-chip[^"]*"[^>]*data-strate="([^"]+)"',
demo_html,
)
chips_other = [c for c in chips if c != "*"]
assert len(chips_other) == n_strata, (
f"hero stats ({n_strata}) โ‰  chips ({len(chips_other)})"
)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 2. Contrat des renderers
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDocumentsGalleryRendererContract:
def test_module_exposes_builders(self) -> None:
from picarones.reports.html.renderers import documents_gallery
assert hasattr(documents_gallery, "build_documents_hero_stats_html")
assert hasattr(documents_gallery, "build_documents_gallery_html")
def test_empty_corpus_returns_empty_gallery(self) -> None:
from picarones.reports.html.renderers.documents_gallery import (
build_documents_gallery_html,
)
assert build_documents_gallery_html({"documents": []}, {}) == ""
def test_hero_stats_count_normalized_strata(self) -> None:
"""Les strates comptรฉes doivent รชtre normalisรฉes (raw
``script_type`` regroupรฉs en buckets presse/imprime/manuscrit/
defaut) โ€” cohรฉrent avec les chips."""
from picarones.reports.html.renderers.documents_gallery import (
build_documents_hero_stats_html,
)
report_data = {
"documents": [
{"script_type": "presse-quotidien"},
{"script_type": "imprime-livre"},
{"script_type": "imprime-pamphlet"},
{"script_type": "manuscrit-cursif"},
],
"engines": [{"name": "a"}, {"name": "b"}],
}
html = build_documents_hero_stats_html(report_data, {})
# 4 raw types โ†’ 3 buckets normalisรฉs (presse + imprime + manuscrit)
assert ">3<" in html or ">3</span>" in html
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 3. Toolbar et chips
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDocumentsToolbar:
def test_toolbar_present_with_role(self, demo_html: str) -> None:
assert 'class="documents-toolbar"' in demo_html
# role=toolbar pour a11y
assert 'role="toolbar"' in demo_html
def test_toutes_chip_active_by_default(self, demo_html: str) -> None:
assert 'class="doc-chip active" data-strate="*"' in demo_html
def test_chip_count_includes_all_strata(self, demo_html: str) -> None:
"""Une chip par strate normalisรฉe prรฉsente dans le corpus +
la chip ``Toutes``."""
import re
chips = re.findall(r'data-strate="([^"]+)"', demo_html)
# Au moins 1 chip "Toutes" + 1 chip strate
assert "*" in chips
# Hors "*", au moins une strate
assert any(c != "*" for c in chips)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 4. Cards documents
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDocumentCards:
def test_grid_present(self, demo_html: str) -> None:
assert 'id="documents-grid"' in demo_html
assert 'class="documents-grid"' in demo_html
def test_cards_have_data_doc_id_attribute(self, demo_html: str) -> None:
"""Chaque card doit exposer ``data-doc-id`` pour le drill-in
via dรฉlรฉgation JS (pas d'onclick inline qui ouvrirait un
vecteur XSS via doc_id avec apostrophe)."""
import re
cards = re.findall(r'class="doc-card[^"]*"[^>]*data-doc-id', demo_html)
assert len(cards) > 0
def test_no_inline_onclick_on_cards(self, demo_html: str) -> None:
"""La dรฉlรฉgation au lieu d'onclick inline รฉlimine la surface
d'attaque XSS via doc_id."""
import re
bad = re.findall(
r'<a[^>]*class="doc-card[^"]*"[^>]*onclick=',
demo_html,
)
assert bad == [], f"onclick inline dรฉtectรฉ sur doc-card: {bad}"
def test_cards_have_strate_class_for_css_preview(self, demo_html: str) -> None:
"""Chaque card porte une classe ``doc-card-strate-{presse|
imprime|manuscrit|defaut}`` qui active l'aperรงu CSS
synthรฉtique."""
import re
strate_classes = set(
re.findall(r'doc-card doc-card-strate-(\w+)', demo_html)
)
assert strate_classes # au moins une
assert strate_classes <= {"presse", "imprime", "manuscrit", "defaut"}
def test_engine_badges_present_on_cards(self, demo_html: str) -> None:
"""Cards de documents : badges moteurs Aโ†’E avec mรชmes accents
que dans engines_table.py."""
assert "engine-badge-fern" in demo_html or "engine-badge-slate" in demo_html
assert 'class="doc-engine-result"' in demo_html
assert 'class="doc-engine-cer mono"' in demo_html
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 5. Drill-in โ€” branchement JS
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDrillIn:
def test_documents_js_included(self, demo_html: str) -> None:
# Le routeur Documents (chip filter + drill-in) est inclus
assert "_openLegacyDocument" in demo_html
# La dรฉlรฉgation utilise data-doc-id
assert "card.dataset.docId" in demo_html
def test_view_document_still_in_dom_for_drill_in(self, demo_html: str) -> None:
"""La vue ``view-document`` doit rester dans le DOM (sibling
cachรฉ dans ``legacy-views-container``) pour que ``openDocument``
puisse l'activer. Le wrapper ``<details>`` visible a รฉtรฉ
retirรฉ en S11b, mais le contenu reste accessible."""
assert 'id="view-document"' in demo_html
assert 'id="legacy-views-container"' in demo_html
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 6. Worst lines branchรฉes en bas
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestWorstLinesBranching:
def test_worst_lines_section_exists_after_gallery(self, demo_html: str) -> None:
"""La section worst_lines doit apparaรฎtre DANS la vue
documents (entre l'ouverture de #view-documents et son
</section> fermante)."""
idx_view = demo_html.find('id="view-documents"')
assert idx_view > 0
end_view = demo_html.find('</section>', idx_view)
snippet = demo_html[idx_view:end_view]
# Le helper renvoie la table worst_lines si benchmark a des
# line_results. Demo fixture en a โ†’ on s'attend ร  la trouver.
assert "documents-worst-lines" in snippet or "worst-lines" in snippet, (
"Section worst_lines absente de la vue Documents"
)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 7. HTML escape
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestHtmlEscape:
def test_doc_id_escaped(self) -> None:
"""Un doc_id contenant ``<script>`` doit รชtre รฉchappรฉ."""
from picarones.reports.html.renderers.documents_gallery import (
build_documents_gallery_html,
)
report_data = {
"documents": [
{
"doc_id": '<script>alert("xss")</script>',
"script_type": "presse",
"engine_results": [],
},
],
"ranking": [],
}
html = build_documents_gallery_html(report_data, {})
assert "<script>alert" not in html
assert "&lt;script&gt;" in html or "&amp;lt;" in html
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 8. Empty state
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestEmptyState:
def test_empty_state_message_in_template(self) -> None:
"""La vue documents doit prรฉvoir un fallback ``{% else %}`` qui
affiche un message clair en l'absence de documents."""
text = DOC_TEMPLATE.read_text(encoding="utf-8")
assert "documents-empty-state" in text
assert "documents_empty" in text
def test_empty_state_pedagogic_with_bullets(self) -> None:
"""Phase 4 (D.1) : l'empty state Documents doit lister les
prรฉrequis comme Stability/Diagnostics โ€” pas juste un message
sec ยซ aucun document ยป."""
text = DOC_TEMPLATE.read_text(encoding="utf-8")
# Au moins 3 bullets de prรฉrequis (corpus, ground truth, images)
assert text.count('<li ') >= 3 or text.count('<li>') >= 3
# Clรฉs i18n des bullets prรฉsentes
assert "documents_empty_b1" in text
assert "documents_empty_b2" in text
assert "documents_empty_b3" in text
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 8-bis. Badge difficultรฉ (Phase 4 A.1)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDifficultyBadge:
"""Vรฉrifie que le score de difficultรฉ intrinsรจque calculรฉ par
:func:`annotate_documents_with_difficulty` apparaรฎt bien sur les
cartes โ€” avant ce cรขblage, le champ รฉtait prรฉsent dans le dict
mais jamais rendu (l'utilisateur ne voyait que le CER moyen)."""
def test_difficulty_badge_present_on_demo_cards(self, demo_html: str) -> None:
"""Demo fixture : 12 docs avec difficultรฉ calculรฉe โ†’ au moins
une carte affiche un badge difficultรฉ.
Phase 17 [2.1] โ€” durcissement anti-theater : l'ancien test
cherchait ``"doc-card-difficulty"`` mais cette chaรฎne apparaรฎt
AUSSI dans la dรฉfinition CSS ``.doc-card-difficulty {`` qui
est inlinรฉe dans le HTML rendu via ``{% include '_styles.css' %}``.
Le test passait mรชme si ``_difficulty_badge_html`` retournait
``""`` pour tous les docs.
Le pattern strict ``<span class="doc-card-difficulty doc-card-difficulty-``
ne peut apparaรฎtre QUE dans le rendu effectif d'un badge
(slug concatรฉnรฉ ร  la classe parent) โ€” la CSS รฉcrit les
sรฉlecteurs sรฉparรฉs (``.doc-card-difficulty {`` puis
``.diff-badge-easy {``), pas ce pattern combinรฉ en attribut.
"""
marker = '<span class="doc-card-difficulty doc-card-difficulty-'
assert marker in demo_html, (
f"Aucun badge difficultรฉ rendu (pattern {marker!r} absent) โ€” "
f"cรขblage cassรฉ OU annotate_documents_with_difficulty n'a "
f"pas peuplรฉ difficulty_slug"
)
def test_difficulty_badge_uses_color_inline(self, demo_html: str) -> None:
"""Badge porte un style inline avec couleur (rouge/jaune/vert)
pour rester lisible mรชme sans CSS chargรฉ."""
idx = demo_html.find('class="doc-card-difficulty')
assert idx > 0
end = demo_html.find('>', idx)
chunk = demo_html[idx:end]
# Au moins un attribut style avec background
assert 'style="background:' in chunk
def test_difficulty_badge_has_localized_label(self) -> None:
"""Les 4 labels (easy/moderate/hard/very_hard) sont
i18n-aware."""
from picarones.reports.html.renderers.documents_gallery import (
_difficulty_badge_html,
)
# FR par dรฉfaut
html_easy = _difficulty_badge_html({"difficulty_score": 0.10}, {})
assert "Facile" in html_easy
html_hard = _difficulty_badge_html({"difficulty_score": 0.80}, {})
assert "Trรจs difficile" in html_hard
# Override via labels
html_custom = _difficulty_badge_html(
{"difficulty_score": 0.10},
{"documents_difficulty_easy": "Easy"},
)
assert "Easy" in html_custom
def test_difficulty_badge_empty_when_score_missing(self) -> None:
"""Pas de badge si la difficultรฉ n'a pas pu รชtre calculรฉe โ€”
ne pas fabriquer un faux 0,50 par dรฉfaut visible."""
from picarones.reports.html.renderers.documents_gallery import (
_difficulty_badge_html,
)
assert _difficulty_badge_html({}, {}) == ""
assert _difficulty_badge_html({"difficulty_score": None}, {}) == ""
assert _difficulty_badge_html({"difficulty_score": "bad"}, {}) == ""
def test_difficulty_badge_uses_shared_bucketing(self) -> None:
"""Phase 16 [5.1] : ``_difficulty_badge_html`` consomme
:func:`difficulty_bucket` (evaluation/metrics/difficulty.py)
comme source unique des seuils, pas un bucketing dupliquรฉ.
Preuve : pour 8 scores qui couvrent les 4 buckets + leurs
bordures, le slug encodรฉ dans la classe CSS
``doc-card-difficulty-{slug}`` doit correspondre EXACTEMENT
au retour de ``difficulty_bucket(score)``. Sans cette
synchronisation, un seuil modifiรฉ ร  un seul endroit ferait
diverger le rendu (couleur) du label backend.
"""
from picarones.evaluation.metrics.difficulty import difficulty_bucket
from picarones.reports.html.renderers.documents_gallery import (
_difficulty_badge_html,
)
test_scores = [0.0, 0.24, 0.25, 0.49, 0.50, 0.74, 0.75, 1.0]
for score in test_scores:
expected_slug = difficulty_bucket(score)
html = _difficulty_badge_html({"difficulty_score": score}, {})
expected_class = f"doc-card-difficulty-{expected_slug}"
assert expected_class in html, (
f"score={score}: bucket attendu {expected_slug!r} "
f"absent du HTML rendu (rendu : {html[:200]})"
)
def test_difficulty_label_matches_evaluation_module(self) -> None:
"""Phase 16 [5.1] corollaire : le label par dรฉfaut du badge
(sans override i18n) doit รชtre EXACTEMENT celui produit par
:func:`difficulty_label` cรดtรฉ backend. Sans cette garantie,
changer une รฉtiquette FR ร  un endroit ferait diverger
rapport HTML et logs."""
from picarones.evaluation.metrics.difficulty import difficulty_label
from picarones.reports.html.renderers.documents_gallery import (
_difficulty_badge_html,
)
for score in [0.1, 0.3, 0.6, 0.9]:
expected_label = difficulty_label(score)
html = _difficulty_badge_html({"difficulty_score": score}, {})
assert f">{expected_label}<" in html, (
f"score={score}: label backend {expected_label!r} "
f"absent du badge HTML โ€” bucketing dupliquรฉ a divergรฉ"
)
def test_app_js_has_no_inline_difficulty_bucketing(self) -> None:
"""Phase 16-bis [5.1] : ``_app.js`` ne doit plus contenir de
bucketing inline ``dScore < 0.25 ? ... : ...`` qui dupliquerait
les seuils Python. Le JS doit lire ``doc.difficulty_slug``
annotรฉ par Python comme source unique.
Le re-audit Phase 16 avait flaggรฉ que mon premier fix [5.1]
ne migrait que le Python โ€” 3 copies du bucketing restaient
dans le JS embarquรฉ (galerie inline + dรฉtail document), avec
leurs propres hex hardcodรฉs (``#16a34a``, ``#ca8a04``, etc.).
Drift silencieuse : un changement de seuil Python ne
propageait pas au navigateur.
"""
import re
from pathlib import Path
app_js = Path(__file__).resolve().parents[2] / (
"picarones/reports/html/templates/_app.js"
)
src = app_js.read_text(encoding="utf-8")
# Patterns interdits : tout test ternaire ``dScore < X`` ou
# ``difficulty < X`` qui rรฉ-implรฉmente le bucketing
forbidden = [
re.compile(r"dScore\s*<\s*0\."),
re.compile(r"difficulty[_a-zA-Z]*\s*<\s*0\.\d+\s*\?"),
]
for pattern in forbidden:
match = pattern.search(src)
assert match is None, (
f"_app.js contient un bucketing difficultรฉ inline "
f"qui duplique les seuils Python โ€” drift silencieuse "
f"possible. Pattern : {pattern.pattern}. "
f"Match : {src[max(0, match.start()-30):match.end()+30]!r}"
)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 8-ter. Image preview : CLS + onerror (Phase 9 #3)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestImagePreviewAttributes:
"""Vรฉrouille les attributs ``width/height`` + ``onerror`` ajoutรฉs
aux ``<img>`` des cartes documents (Phase 9 #3) :
- sans ``width``/``height``, le navigateur ne peut pas rรฉserver
l'espace de l'image avant son chargement โ†’ dรฉcalage de mise
en page (CLS) ร  chaque image qui apparaรฎt ;
- sans ``onerror``, une URL base64 malformรฉe ou un 404 sur l'image
affiche l'icรดne ยซ image broken ยป du navigateur โ€” visuellement
cassรฉ."""
def test_img_has_width_height(self, demo_html: str) -> None:
"""Les ``<img>`` de la galerie doivent avoir ``width`` et
``height`` pour rรฉserver l'aspect 3/4 avant chargement."""
# On scope au panneau Documents
idx = demo_html.find('id="view-documents"')
end = demo_html.find('id="view-crosses"', idx)
block = demo_html[idx:end] if idx > 0 and end > idx else demo_html
# Au moins une <img> dans le bloc Documents
assert '<img' in block, "aucune balise <img> dans la vue Documents"
# Toutes les <img> dans la vue doivent avoir width et height
import re
for img_tag in re.findall(r'<img[^>]*>', block):
assert 'width=' in img_tag, f"<img sans width : {img_tag}"
assert 'height=' in img_tag, f"<img sans height : {img_tag}"
def test_img_has_onerror_fallback(self, demo_html: str) -> None:
"""``onerror`` doit masquer l'รฉlรฉment cassรฉ si l'image
n'est pas chargeable."""
idx = demo_html.find('id="view-documents"')
end = demo_html.find('id="view-crosses"', idx)
block = demo_html[idx:end] if idx > 0 and end > idx else demo_html
assert 'onerror=' in block, (
"onerror absent des <img> Documents โ€” image cassรฉe afficherait "
"l'icรดne broken du navigateur"
)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 9. Template โ€” plus de squelette
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDocumentsTemplate:
def test_squelette_removed(self) -> None:
text = DOC_TEMPLATE.read_text(encoding="utf-8")
assert "SQUELETTE S5B" not in text
assert "xer-view-skeleton" not in text
def test_template_consumes_gallery_html(self) -> None:
text = DOC_TEMPLATE.read_text(encoding="utf-8")
assert "documents_gallery_html" in text
assert "documents_hero_stats_html" in text
assert "documents_worst_lines_html" in text
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 10. i18n FR + EN paritรฉ
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDocumentsI18n:
REQUIRED_KEYS = (
"documents_filter_all",
"documents_open_link",
"documents_stat_count",
"documents_stat_engines",
"documents_stat_strata",
"documents_strate_defaut",
"documents_strate_imprime",
"documents_strate_manuscrit",
"documents_strate_presse",
"documents_title",
"documents_toolbar_label",
"documents_empty",
"documents_desc",
)
def _labels(self, lang: str) -> dict:
from picarones.reports.i18n import get_labels
return get_labels(lang)
@pytest.mark.parametrize("lang", ["fr", "en"])
def test_required_keys_present(self, lang: str) -> None:
labels = self._labels(lang)
for key in self.REQUIRED_KEYS:
assert key in labels, f"clรฉ {key!r} manquante en {lang}"
assert labels[key], f"clรฉ {key!r} vide en {lang}"