Spaces:
Sleeping
Sleeping
File size: 9,098 Bytes
43d25a5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | """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
@pytest.fixture(scope="module")
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")
@pytest.fixture(scope="module")
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}"
|