Spaces:
Sleeping
Sleeping
Claude
feat(sprint-A6): WCAG niveau A bloquant — skip-link, canvas a11y, scope=col
43d25a5 unverified | """Tests Sprint A6 — accessibilité WCAG niveau A bloquant. | |
| Items B-9, B-10, m-3, m-4 de l'audit institutional-readiness-2026-05. | |
| Ce fichier valide le **socle a11y bloquant** pour une déclaration de | |
| conformité RGAA / WCAG 2.1 niveau A : | |
| - WCAG 2.4.1 (Bypass Blocks) — skip-to-content link (B-10) | |
| - WCAG 1.1.1 (Non-text Content) — Canvas charts → aria-label + table | |
| jumelle accessible aux AT (B-9) | |
| - WCAG 1.3.1 (Info and Relationships) — ``scope="col"`` sur les | |
| ``<th>`` (m-4) | |
| - Pas de chaîne hardcodée FR/EN dans la nav (m-3) | |
| Ces tests se contentent de vérifier la présence des marqueurs HTML | |
| attendus dans le rapport généré. L'audit sémantique complet (NVDA / | |
| JAWS / VoiceOver) reste manuel et tracé dans | |
| ``docs/audits/external-audits-2026/`` (Sprint A15). | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import pytest | |
| from picarones.fixtures import generate_sample_benchmark | |
| from picarones.report.generator import ReportGenerator | |
| def demo_html(tmp_path_factory) -> str: | |
| """Rapport démo (FR) généré une fois pour tous les tests du module.""" | |
| out = tmp_path_factory.mktemp("a11y") / "report.html" | |
| bench = generate_sample_benchmark(n_docs=4) | |
| ReportGenerator(bench, lang="fr").generate(out) | |
| return out.read_text(encoding="utf-8") | |
| def demo_html_en(tmp_path_factory) -> str: | |
| """Rapport démo (EN) — pour vérifier que les libellés a11y sont | |
| bilingues.""" | |
| out = tmp_path_factory.mktemp("a11y_en") / "report_en.html" | |
| bench = generate_sample_benchmark(n_docs=4) | |
| ReportGenerator(bench, lang="en").generate(out) | |
| return out.read_text(encoding="utf-8") | |
| # --------------------------------------------------------------------------- | |
| # B-10 — Skip-to-content (WCAG 2.4.1) | |
| # --------------------------------------------------------------------------- | |
| def test_skip_link_present(demo_html: str) -> None: | |
| """Un lien ``href="#main"`` avec class ``skip-link`` doit exister.""" | |
| assert 'class="skip-link"' in demo_html | |
| assert 'href="#main"' in demo_html | |
| def test_skip_link_first_focusable_in_body(demo_html: str) -> None: | |
| """Le skip-link doit être le **premier** élément focusable du body | |
| (sinon Tab depuis l'URL bar atteint d'abord la nav, ce qui défait | |
| le but).""" | |
| body_start = demo_html.find("<body>") | |
| assert body_start > 0 | |
| body_part = demo_html[body_start : body_start + 1500] | |
| skip_pos = body_part.find('class="skip-link"') | |
| nav_pos = body_part.find("<nav") | |
| assert skip_pos > 0 and nav_pos > 0 | |
| assert skip_pos < nav_pos, ( | |
| "Le skip-link doit précéder le <nav> dans le DOM." | |
| ) | |
| def test_main_has_id_main(demo_html: str) -> None: | |
| """Le ``<main>`` doit avoir ``id="main"`` pour que le skip-link | |
| pointe vers une cible existante.""" | |
| assert re.search(r'<main[^>]*\bid="main"', demo_html), ( | |
| '<main id="main"> attendu pour la cible du skip-link.' | |
| ) | |
| def test_skip_link_label_is_i18n(demo_html: str, demo_html_en: str) -> None: | |
| """Le libellé du skip-link doit être en français en mode FR et en | |
| anglais en mode EN (pas de chaîne hardcodée).""" | |
| # FR : "Aller au contenu" | |
| assert "Aller au contenu" in demo_html | |
| # EN : "Skip to content" | |
| assert "Skip to content" in demo_html_en | |
| # --------------------------------------------------------------------------- | |
| # B-9 — Canvas charts accessibles (WCAG 1.1.1) | |
| # --------------------------------------------------------------------------- | |
| def test_all_canvases_have_aria_label(demo_html: str) -> None: | |
| """Tout ``<canvas>`` Chart.js (avec ``id="chart-..."`` ou | |
| ``pareto-chart``) doit avoir ``aria-label`` non vide. | |
| Tolérance : un ``<canvas>`` créé dynamiquement côté JS sans id | |
| pré-déclaré reste possible (Chart.js peut en générer pour des | |
| sub-charts). Le test ne valide que les canvas que les templates | |
| Jinja2 produisent — pas ceux du DOM dynamique.""" | |
| html = _strip_inline_scripts(demo_html) | |
| canvases = re.findall(r"<canvas[^>]*>", html) | |
| chart_canvases = [ | |
| c for c in canvases | |
| if 'id="chart-' in c or 'id="pareto-chart"' in c | |
| ] | |
| canvases_no_label = [ | |
| c for c in chart_canvases | |
| if 'aria-label="' not in c and "data-a11y-label" not in c | |
| ] | |
| assert not canvases_no_label, ( | |
| f"Canvas Chart.js sans aria-label : {canvases_no_label}" | |
| ) | |
| def test_canvases_have_role_img(demo_html: str) -> None: | |
| """``role="img"`` doit être posé sur les canvas pour les annoncer | |
| comme images aux AT.""" | |
| canvases = re.findall(r"<canvas[^>]*>", demo_html) | |
| chart_canvases = [c for c in canvases if "chart-" in c] | |
| if not chart_canvases: | |
| pytest.skip("Aucun canvas Chart.js dans le rapport démo") | |
| canvases_no_role = [c for c in chart_canvases if 'role="img"' not in c] | |
| assert not canvases_no_role, ( | |
| f"Canvas Chart.js sans role=img : {canvases_no_role[:3]}" | |
| ) | |
| def test_data_table_helpers_present(demo_html: str) -> None: | |
| """La fonction ``attachChartA11y`` qui génère les tables jumelles | |
| doit être incluse dans le JS embarqué.""" | |
| assert "attachChartA11y" in demo_html | |
| assert "_populateChartDataTable" in demo_html | |
| def test_view_data_button_label_localized( | |
| demo_html: str, demo_html_en: str | |
| ) -> None: | |
| """Les libellés du bouton « Voir les données » doivent être dans | |
| l'objet I18N côté JS (pas hardcodés en français).""" | |
| assert "Voir les données" in demo_html | |
| assert "View data" in demo_html_en | |
| # --------------------------------------------------------------------------- | |
| # m-4 — scope="col" sur les <th> | |
| # --------------------------------------------------------------------------- | |
| def _strip_inline_scripts(html: str) -> str: | |
| """Retire les blocs ``<script>...</script>`` et ``<style>...</style>`` | |
| avant d'analyser les balises HTML. | |
| Nécessaire car Chart.js minifié contient des chaînes comme | |
| ``<this._cachedMeta`` qui matchent le regex ``<th[\\s>]`` faussement | |
| (sequence ``<t`` + word boundary). On limite l'analyse au HTML rendu | |
| par les templates Jinja2, pas au JS embarqué. | |
| """ | |
| cleaned = re.sub(r"<script\b[^>]*>.*?</script>", "", html, flags=re.DOTALL) | |
| cleaned = re.sub(r"<style\b[^>]*>.*?</style>", "", cleaned, flags=re.DOTALL) | |
| return cleaned | |
| def test_table_headers_have_scope(demo_html: str) -> None: | |
| """Tout ``<th>`` rendu par les templates doit avoir ``scope="col"`` | |
| ou ``scope="row"``.""" | |
| html = _strip_inline_scripts(demo_html) | |
| # Regex strict : <th suivi d'un espace ou >, qui n'a PAS d'attribut scope= | |
| th_no_scope = re.findall( | |
| r"<th(?:\s+(?![^>]*\bscope=)[^>]*)?>", | |
| html, | |
| ) | |
| # On filtre faux positifs : <thead, <tbody, <tfoot etc. ne doivent pas matcher. | |
| th_no_scope = [t for t in th_no_scope if re.match(r"<th(\s|>)", t)] | |
| total_th = len(re.findall(r"<th(\s|>)", html)) | |
| if total_th == 0: | |
| pytest.skip("Pas de <th> dans le rapport démo") | |
| assert not th_no_scope, ( | |
| f"{len(th_no_scope)}/{total_th} <th> sans scope= " | |
| f"dans le HTML rendu (hors <script>/<style>). " | |
| f"Premiers : {th_no_scope[:3]}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # m-3 — Bouton Reset i18n | |
| # --------------------------------------------------------------------------- | |
| def test_reset_button_uses_i18n_key(demo_html: str) -> None: | |
| """Le bouton « Réinitialiser » du bandeau d'exclusion doit avoir | |
| ``data-i18n="reset_all"`` (pas de chaîne FR hardcodée sans | |
| mécanisme i18n).""" | |
| # Le bouton apparaît avec data-i18n="reset_all" | |
| assert 'data-i18n="reset_all"' in demo_html | |
| def test_reset_label_in_i18n_dicts(demo_html: str, demo_html_en: str) -> None: | |
| """Les clés ``reset_all`` doivent exister dans les deux | |
| dictionnaires i18n embarqués.""" | |
| # Le JSON I18N est embarqué inline dans le HTML. | |
| # On cherche un fragment JSON ``"reset_all":"..."`` | |
| assert re.search(r'"reset_all"\s*:\s*"R[ée]initialiser"', demo_html) | |
| assert re.search(r'"reset_all"\s*:\s*"Reset"', demo_html_en) | |
| # --------------------------------------------------------------------------- | |
| # Synthèse | |
| # --------------------------------------------------------------------------- | |
| def test_html_has_lang_attribute(demo_html: str, demo_html_en: str) -> None: | |
| """``<html lang="...">`` doit être posé pour les AT (déjà cas mais | |
| on renforce).""" | |
| assert 'lang="fr"' in demo_html | |
| assert 'lang="en"' in demo_html_en | |
| def test_global_a11y_smoke(demo_html: str) -> None: | |
| """Méta-test : tous les marqueurs a11y de niveau A sont présents | |
| dans un rapport démo standard.""" | |
| markers = [ | |
| 'class="skip-link"', | |
| 'href="#main"', | |
| 'id="main"', | |
| 'role="img"', | |
| "attachChartA11y", | |
| 'scope="col"', | |
| 'data-i18n="reset_all"', | |
| ] | |
| missing = [m for m in markers if m not in demo_html] | |
| assert not missing, f"Marqueurs WCAG niveau A manquants : {missing}" | |