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}"