Spaces:
Sleeping
Sleeping
| """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" | |
| ) | |
| 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 "<script>" in html or "&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) | |
| 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}" | |