"""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('', 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">.*?(\d+)' r'.*?(\d+)' r'.*?(\d+)', 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']*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" 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']*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 ``
`` 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 fermante).""" idx_view = demo_html.find('id="view-documents"') assert idx_view > 0 end_view = demo_html.find('', 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_type": "presse", "engine_results": [], }, ], "ranking": [], } html = build_documents_gallery_html(report_data, {}) assert "