Claude commited on
Commit
43d25a5
·
unverified ·
1 Parent(s): 563a0f0

feat(sprint-A6): WCAG niveau A bloquant — skip-link, canvas a11y, scope=col

Browse files

Sprint A6 — Accessibilité WCAG niveau A (3 PJ, items B-9, B-10, m-3, m-4).

Items résolus :

- B-10 (WCAG 2.4.1 Bypass Blocks) : skip-to-content link
- <a class="skip-link" href="#main"> en premier enfant focusable du body
- .skip-link CSS : caché hors :focus, top:0 + outline:3px solid #fbbf24 au focus
- id="main" + role="main" sur le <main> (cible du lien)
- Libellé i18n FR "Aller au contenu" / EN "Skip to content"

- B-9 (WCAG 1.1.1 Non-text Content) : graphiques Chart.js accessibles
- role="img" + aria-label posés statiquement sur les 11 <canvas>
Chart.js (cer-hist, radar, cer-doc, duration, quality-cer,
taxonomy, reliability, bootstrap-ci, gini-cer, ratio-anchor,
pareto-chart)
- data-a11y-label sur chaque canvas pour permettre au JS d'enrichir
- Helper JS attachChartA11y() : génère pour chaque canvas un bouton
"Voir les données" + une <table class="chart-data-table"> jumelle
avec scope="col"/scope="row", reliée par aria-describedby. Génération
paresseuse au premier clic via _populateChartDataTable() qui
construit le tableau depuis chartInstances[id].data.
- showView() ré-attache les a11y aux charts qui s'instancient
paresseusement au switch de vue.

- m-3 : bouton "Réinitialiser" du bandeau d'exclusion globale passe
à data-i18n="reset_all" (FR "Réinitialiser" / EN "Reset").

- m-4 : scope="col" sur tous les <th> des tableaux.
- 16 <th> dans view_ranking.html
- 53 <th> dans 23 modules de rendu Python (ner, calibration,
philological, levers, throughput, longitudinal, etc.)
- Patch Python utilise scope=\"col\" (escaped quotes) pour ne pas
casser les délimiteurs de f-strings.

i18n FR/EN :
- Nouvelles clés : skip_to_content, reset_all, view_data, hide_data,
chart_no_data, chart_data_caption (× 2 langues = 12 clés ajoutées).

Tests : tests/report/test_a11y_level_a.py (13 cas) qui valident :
- Présence du skip-link en tête de body avant <nav>
- aria-label + role="img" sur tous les canvas Chart.js
- scope="col" sur les <th> rendus (regex strict, exclut <thead>
et le contenu inline des <script>/<style>)
- data-i18n="reset_all" sur le bouton Reset
- Libellés a11y bilingues (FR + EN distincts)
- Méta-test smoke des 7 marqueurs WCAG niveau A

Tests totaux : 3636 passed, 3 skipped, 4 deselected (network), 0 failed.
Coverage : 86.82% (plancher 85% maintenu).

picarones/report/calibration_render.py CHANGED
@@ -85,7 +85,7 @@ def build_calibration_summary_html(
85
  for hdr in (engine_label, ece_label, mce_label,
86
  acc_label, conf_label, n_label, docs_label):
87
  parts.append(
88
- f'<th style="padding:.3rem .5rem;text-align:left;'
89
  f'border-bottom:1px solid var(--border);font-weight:600">'
90
  f'{_e(hdr)}</th>'
91
  )
 
85
  for hdr in (engine_label, ece_label, mce_label,
86
  acc_label, conf_label, n_label, docs_label):
87
  parts.append(
88
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
89
  f'border-bottom:1px solid var(--border);font-weight:600">'
90
  f'{_e(hdr)}</th>'
91
  )
picarones/report/comparison.py CHANGED
@@ -335,12 +335,12 @@ _COMPARISON_TEMPLATE = """<!DOCTYPE html>
335
  <table>
336
  <thead>
337
  <tr>
338
- <th>Moteur</th>
339
- <th class="num">CER A</th>
340
- <th class="num">CER B</th>
341
- <th class="num">Δ CER</th>
342
- <th class="num">Docs A → B</th>
343
- <th>État</th>
344
  </tr>
345
  </thead>
346
  <tbody>
 
335
  <table>
336
  <thead>
337
  <tr>
338
+ <th scope=\"col\">Moteur</th>
339
+ <th scope=\"col\" class="num">CER A</th>
340
+ <th scope=\"col\" class="num">CER B</th>
341
+ <th scope=\"col\" class="num">Δ CER</th>
342
+ <th scope=\"col\" class="num">Docs A → B</th>
343
+ <th scope=\"col\">État</th>
344
  </tr>
345
  </thead>
346
  <tbody>
picarones/report/error_absorption_render.py CHANGED
@@ -170,7 +170,7 @@ def build_error_absorption_html(
170
  h_corrected, h_introduced, h_corr_rate,
171
  h_intro_rate, h_net, h_sample):
172
  parts.append(
173
- f'<th style="padding:.4rem .6rem;text-align:left;'
174
  f'border-bottom:1px solid #ccc;font-weight:600">'
175
  f'{_e(col)}</th>'
176
  )
 
170
  h_corrected, h_introduced, h_corr_rate,
171
  h_intro_rate, h_net, h_sample):
172
  parts.append(
173
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
174
  f'border-bottom:1px solid #ccc;font-weight:600">'
175
  f'{_e(col)}</th>'
176
  )
picarones/report/i18n/en.json CHANGED
@@ -408,5 +408,11 @@
408
  "audit_license": "Licence",
409
  "audit_io": "Input → output",
410
  "audit_citation": "Citation",
411
- "audit_homepage": "Project page"
 
 
 
 
 
 
412
  }
 
408
  "audit_license": "Licence",
409
  "audit_io": "Input → output",
410
  "audit_citation": "Citation",
411
+ "audit_homepage": "Project page",
412
+ "skip_to_content": "Skip to content",
413
+ "reset_all": "Reset",
414
+ "view_data": "View data",
415
+ "hide_data": "Hide data",
416
+ "chart_no_data": "No data available",
417
+ "chart_data_caption": "Chart data"
418
  }
picarones/report/i18n/fr.json CHANGED
@@ -408,5 +408,11 @@
408
  "audit_license": "Licence",
409
  "audit_io": "Entrée → sortie",
410
  "audit_citation": "Citation",
411
- "audit_homepage": "Page projet"
 
 
 
 
 
 
412
  }
 
408
  "audit_license": "Licence",
409
  "audit_io": "Entrée → sortie",
410
  "audit_citation": "Citation",
411
+ "audit_homepage": "Page projet",
412
+ "skip_to_content": "Aller au contenu",
413
+ "reset_all": "Réinitialiser",
414
+ "view_data": "Voir les données",
415
+ "hide_data": "Masquer les données",
416
+ "chart_no_data": "Aucune donnée disponible",
417
+ "chart_data_caption": "Données du graphique"
418
  }
picarones/report/image_predictive_render.py CHANGED
@@ -86,17 +86,17 @@ def _render_complexity_block(
86
  '<table style="border-collapse:collapse;width:100%;'
87
  'font-size:.9rem;margin-bottom:.8rem">'
88
  f'<thead><tr>'
89
- f'<th style="padding:.4rem .6rem;text-align:right;'
90
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_mean)}</th>'
91
- f'<th style="padding:.4rem .6rem;text-align:right;'
92
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_median)}</th>'
93
- f'<th style="padding:.4rem .6rem;text-align:right;'
94
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_min)}</th>'
95
- f'<th style="padding:.4rem .6rem;text-align:right;'
96
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_max)}</th>'
97
- f'<th style="padding:.4rem .6rem;text-align:right;'
98
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_stdev)}</th>'
99
- f'<th style="padding:.4rem .6rem;text-align:right;'
100
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_docs)}</th>'
101
  f'</tr></thead>'
102
  f'<tbody><tr>'
@@ -143,7 +143,7 @@ def _render_homogeneity_block(
143
  ]
144
  for col in (h_feat, h_mean, h_stdev, h_norm):
145
  parts.append(
146
- f'<th style="padding:.4rem .6rem;text-align:left;'
147
  f'border-bottom:1px solid #ccc;font-weight:600">'
148
  f'{_e(col)}</th>'
149
  )
 
86
  '<table style="border-collapse:collapse;width:100%;'
87
  'font-size:.9rem;margin-bottom:.8rem">'
88
  f'<thead><tr>'
89
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
90
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_mean)}</th>'
91
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
92
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_median)}</th>'
93
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
94
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_min)}</th>'
95
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
96
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_max)}</th>'
97
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
98
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_stdev)}</th>'
99
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
100
  f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_docs)}</th>'
101
  f'</tr></thead>'
102
  f'<tbody><tr>'
 
143
  ]
144
  for col in (h_feat, h_mean, h_stdev, h_norm):
145
  parts.append(
146
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
147
  f'border-bottom:1px solid #ccc;font-weight:600">'
148
  f'{_e(col)}</th>'
149
  )
picarones/report/incremental_comparison_render.py CHANGED
@@ -143,7 +143,7 @@ def build_incremental_comparison_html(
143
  ]
144
  for col in (h_value, h_mean, h_stdev, h_rank, h_n_obs):
145
  parts.append(
146
- f'<th style="padding:.4rem .6rem;text-align:left;'
147
  f'border-bottom:1px solid #ccc;font-weight:600">'
148
  f'{_e(col)}</th>'
149
  )
 
143
  ]
144
  for col in (h_value, h_mean, h_stdev, h_rank, h_n_obs):
145
  parts.append(
146
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
147
  f'border-bottom:1px solid #ccc;font-weight:600">'
148
  f'{_e(col)}</th>'
149
  )
picarones/report/inter_engine_render.py CHANGED
@@ -102,10 +102,10 @@ def build_divergence_matrix_html(
102
  )
103
  parts.append('<table class="divergence-matrix" style="border-collapse:collapse;font-size:.8rem">')
104
  # En-tête
105
- parts.append("<thead><tr><th></th>")
106
  for b in engines:
107
  parts.append(
108
- f'<th style="padding:.3rem .5rem;text-align:center;'
109
  f'border-bottom:1px solid var(--border)">{_e(b)}</th>'
110
  )
111
  parts.append("</tr></thead>")
@@ -114,7 +114,7 @@ def build_divergence_matrix_html(
114
  for a in engines:
115
  parts.append("<tr>")
116
  parts.append(
117
- f'<th style="padding:.3rem .5rem;text-align:right;'
118
  f'border-right:1px solid var(--border);font-weight:600">{_e(a)}</th>'
119
  )
120
  for b in engines:
 
102
  )
103
  parts.append('<table class="divergence-matrix" style="border-collapse:collapse;font-size:.8rem">')
104
  # En-tête
105
+ parts.append("<thead><tr><th scope=\"col\"></th>")
106
  for b in engines:
107
  parts.append(
108
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:center;'
109
  f'border-bottom:1px solid var(--border)">{_e(b)}</th>'
110
  )
111
  parts.append("</tr></thead>")
 
114
  for a in engines:
115
  parts.append("<tr>")
116
  parts.append(
117
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:right;'
118
  f'border-right:1px solid var(--border);font-weight:600">{_e(a)}</th>'
119
  )
120
  for b in engines:
picarones/report/lexical_modernization_render.py CHANGED
@@ -87,7 +87,7 @@ def build_lexical_modernization_html(
87
  ]
88
  for col in (gt_label, hyp_label, n_label, rate_label):
89
  parts.append(
90
- f'<th style="padding:.3rem .5rem;text-align:left;'
91
  f'border-bottom:1px solid #ccc;font-weight:600">'
92
  f'{_e(col)}</th>'
93
  )
 
87
  ]
88
  for col in (gt_label, hyp_label, n_label, rate_label):
89
  parts.append(
90
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
91
  f'border-bottom:1px solid #ccc;font-weight:600">'
92
  f'{_e(col)}</th>'
93
  )
picarones/report/longitudinal_render.py CHANGED
@@ -112,7 +112,7 @@ def build_longitudinal_html(
112
  for col in (h_engine, h_n_runs, h_first, h_last, h_delta,
113
  h_slope, h_r2, h_change):
114
  parts.append(
115
- f'<th style="padding:.4rem .6rem;text-align:left;'
116
  f'border-bottom:1px solid #ccc;font-weight:600">'
117
  f'{_e(col)}</th>'
118
  )
 
112
  for col in (h_engine, h_n_runs, h_first, h_last, h_delta,
113
  h_slope, h_r2, h_change):
114
  parts.append(
115
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
116
  f'border-bottom:1px solid #ccc;font-weight:600">'
117
  f'{_e(col)}</th>'
118
  )
picarones/report/module_audit_render.py CHANGED
@@ -113,7 +113,7 @@ def build_module_audit_html(
113
  for col in (h_module, h_status, h_version, h_author,
114
  h_license, h_io, h_citation, h_homepage):
115
  parts.append(
116
- f'<th style="padding:.4rem .6rem;text-align:left;'
117
  f'border-bottom:1px solid #ccc;font-weight:600">'
118
  f'{_e(col)}</th>'
119
  )
 
113
  for col in (h_module, h_status, h_version, h_author,
114
  h_license, h_io, h_citation, h_homepage):
115
  parts.append(
116
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
117
  f'border-bottom:1px solid #ccc;font-weight:600">'
118
  f'{_e(col)}</th>'
119
  )
picarones/report/multirun_stability_render.py CHANGED
@@ -108,7 +108,7 @@ def build_multirun_stability_html(
108
  ]
109
  for col in (h_engine, h_n_runs, h_cer, h_cv, h_identical, h_distinct):
110
  parts.append(
111
- f'<th style="padding:.4rem .6rem;text-align:left;'
112
  f'border-bottom:1px solid #ccc;font-weight:600">'
113
  f'{_e(col)}</th>'
114
  )
 
108
  ]
109
  for col in (h_engine, h_n_runs, h_cer, h_cv, h_identical, h_distinct):
110
  parts.append(
111
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
112
  f'border-bottom:1px solid #ccc;font-weight:600">'
113
  f'{_e(col)}</th>'
114
  )
picarones/report/ner_render.py CHANGED
@@ -96,7 +96,7 @@ def build_ner_summary_html(
96
  for hdr in (engine_label, f1_label, p_label, r_label,
97
  docs_label, halluc_label, missed_label):
98
  parts.append(
99
- f'<th style="padding:.3rem .5rem;text-align:left;'
100
  f'border-bottom:1px solid var(--border);font-weight:600">'
101
  f'{_e(hdr)}</th>'
102
  )
@@ -194,12 +194,12 @@ def build_ner_per_category_html(
194
  )
195
  parts.append("<thead><tr>")
196
  parts.append(
197
- f'<th style="padding:.3rem .5rem;text-align:left;'
198
  f'border-bottom:1px solid var(--border)">{_e(engine_label)}</th>'
199
  )
200
  for cat in categories:
201
  parts.append(
202
- f'<th style="padding:.3rem .5rem;text-align:center;'
203
  f'border-bottom:1px solid var(--border)">{_e(cat)}</th>'
204
  )
205
  parts.append("</tr></thead><tbody>")
@@ -207,7 +207,7 @@ def build_ner_per_category_html(
207
  per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
208
  parts.append("<tr>")
209
  parts.append(
210
- f'<th style="padding:.3rem .5rem;text-align:right;'
211
  f'border-right:1px solid var(--border);font-weight:600">'
212
  f'{_e(engine.get("name", ""))}</th>'
213
  )
 
96
  for hdr in (engine_label, f1_label, p_label, r_label,
97
  docs_label, halluc_label, missed_label):
98
  parts.append(
99
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
100
  f'border-bottom:1px solid var(--border);font-weight:600">'
101
  f'{_e(hdr)}</th>'
102
  )
 
194
  )
195
  parts.append("<thead><tr>")
196
  parts.append(
197
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
198
  f'border-bottom:1px solid var(--border)">{_e(engine_label)}</th>'
199
  )
200
  for cat in categories:
201
  parts.append(
202
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:center;'
203
  f'border-bottom:1px solid var(--border)">{_e(cat)}</th>'
204
  )
205
  parts.append("</tr></thead><tbody>")
 
207
  per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
208
  parts.append("<tr>")
209
  parts.append(
210
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:right;'
211
  f'border-right:1px solid var(--border);font-weight:600">'
212
  f'{_e(engine.get("name", ""))}</th>'
213
  )
picarones/report/numerical_sequences_render.py CHANGED
@@ -103,16 +103,16 @@ def build_numerical_sequences_html(
103
  '<table style="border-collapse:collapse;width:100%;'
104
  'font-size:.9rem">',
105
  '<thead><tr>',
106
- f'<th style="padding:.4rem .6rem;text-align:left;'
107
  f'border-bottom:1px solid #ccc;font-weight:600">'
108
  f'{_e(col_engine)}</th>',
109
- f'<th style="padding:.4rem .6rem;text-align:right;'
110
  f'border-bottom:1px solid #ccc;font-weight:600">'
111
  f'{_e(col_global)}</th>',
112
  ]
113
  for cat in visible_cats:
114
  parts.append(
115
- f'<th style="padding:.4rem .6rem;text-align:right;'
116
  f'border-bottom:1px solid #ccc;font-weight:600">'
117
  f'{_e(cat_label.get(cat, cat))}</th>'
118
  )
 
103
  '<table style="border-collapse:collapse;width:100%;'
104
  'font-size:.9rem">',
105
  '<thead><tr>',
106
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
107
  f'border-bottom:1px solid #ccc;font-weight:600">'
108
  f'{_e(col_engine)}</th>',
109
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
110
  f'border-bottom:1px solid #ccc;font-weight:600">'
111
  f'{_e(col_global)}</th>',
112
  ]
113
  for cat in visible_cats:
114
  parts.append(
115
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
116
  f'border-bottom:1px solid #ccc;font-weight:600">'
117
  f'{_e(cat_label.get(cat, cat))}</th>'
118
  )
picarones/report/philological_render.py CHANGED
@@ -99,13 +99,13 @@ def _table_header(
99
  """Construit l'entête d'un tableau moteur × colonnes."""
100
  parts = [
101
  '<thead><tr>',
102
- f'<th style="padding:.3rem .5rem;text-align:left;'
103
  f'border-bottom:1px solid var(--border);font-weight:600">'
104
  f'{_e(engine_label)}</th>',
105
  ]
106
  for col in columns:
107
  parts.append(
108
- f'<th style="padding:.3rem .5rem;text-align:center;'
109
  f'border-bottom:1px solid var(--border);font-weight:600">'
110
  f'{_e(col)}</th>'
111
  )
@@ -424,30 +424,30 @@ def build_modern_archives_section(
424
  )
425
  parts.append("<thead><tr>")
426
  parts.append(
427
- f'<th rowspan="2" style="padding:.3rem .5rem;text-align:left;'
428
  f'border-bottom:1px solid var(--border);font-weight:600">'
429
  f'{_e(engine_label)}</th>'
430
  )
431
  parts.append(
432
- f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
433
  f'border-bottom:1px solid var(--border);font-weight:600">'
434
  f'{_e(global_label)}</th>'
435
  )
436
  for cat in cats:
437
  parts.append(
438
- f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
439
  f'border-bottom:1px solid var(--border);font-weight:600">'
440
  f'{_e(cat)}</th>'
441
  )
442
  parts.append("</tr><tr>")
443
  for _ in range(1 + len(cats)):
444
  parts.append(
445
- f'<th style="padding:.2rem .4rem;text-align:center;'
446
  f'font-size:.75rem;font-weight:500;opacity:.7">'
447
  f'{_e(strict_label)}</th>'
448
  )
449
  parts.append(
450
- f'<th style="padding:.2rem .4rem;text-align:center;'
451
  f'font-size:.75rem;font-weight:500;opacity:.7">'
452
  f'{_e(expansion_label)}</th>'
453
  )
 
99
  """Construit l'entête d'un tableau moteur × colonnes."""
100
  parts = [
101
  '<thead><tr>',
102
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
103
  f'border-bottom:1px solid var(--border);font-weight:600">'
104
  f'{_e(engine_label)}</th>',
105
  ]
106
  for col in columns:
107
  parts.append(
108
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:center;'
109
  f'border-bottom:1px solid var(--border);font-weight:600">'
110
  f'{_e(col)}</th>'
111
  )
 
424
  )
425
  parts.append("<thead><tr>")
426
  parts.append(
427
+ f'<th scope=\"col\" rowspan="2" style="padding:.3rem .5rem;text-align:left;'
428
  f'border-bottom:1px solid var(--border);font-weight:600">'
429
  f'{_e(engine_label)}</th>'
430
  )
431
  parts.append(
432
+ f'<th scope=\"col\" colspan="2" style="padding:.3rem .5rem;text-align:center;'
433
  f'border-bottom:1px solid var(--border);font-weight:600">'
434
  f'{_e(global_label)}</th>'
435
  )
436
  for cat in cats:
437
  parts.append(
438
+ f'<th scope=\"col\" colspan="2" style="padding:.3rem .5rem;text-align:center;'
439
  f'border-bottom:1px solid var(--border);font-weight:600">'
440
  f'{_e(cat)}</th>'
441
  )
442
  parts.append("</tr><tr>")
443
  for _ in range(1 + len(cats)):
444
  parts.append(
445
+ f'<th scope=\"col\" style="padding:.2rem .4rem;text-align:center;'
446
  f'font-size:.75rem;font-weight:500;opacity:.7">'
447
  f'{_e(strict_label)}</th>'
448
  )
449
  parts.append(
450
+ f'<th scope=\"col\" style="padding:.2rem .4rem;text-align:center;'
451
  f'font-size:.75rem;font-weight:500;opacity:.7">'
452
  f'{_e(expansion_label)}</th>'
453
  )
picarones/report/pipeline_render.py CHANGED
@@ -187,7 +187,7 @@ def build_pipeline_steps_table_html(
187
  dmean_label, dmedian_label, metrics_label, errors_label,
188
  ):
189
  parts.append(
190
- f'<th style="padding:.3rem .5rem;text-align:left;'
191
  f'border-bottom:1px solid #ccc;font-weight:600">'
192
  f'{_e(col)}</th>'
193
  )
@@ -428,7 +428,7 @@ def build_pipeline_ranking_table_html(
428
  ]
429
  for col in (rank_label, name_label, value_label):
430
  parts.append(
431
- f'<th style="padding:.3rem .5rem;text-align:left;'
432
  f'border-bottom:1px solid #ccc;font-weight:600">'
433
  f'{_e(col)}</th>'
434
  )
@@ -504,7 +504,7 @@ def build_pipeline_gain_table_html(
504
  ]
505
  for col in (name_label, value_label, abs_label, rel_label):
506
  parts.append(
507
- f'<th style="padding:.3rem .5rem;text-align:left;'
508
  f'border-bottom:1px solid #ccc;font-weight:600">'
509
  f'{_e(col)}</th>'
510
  )
 
187
  dmean_label, dmedian_label, metrics_label, errors_label,
188
  ):
189
  parts.append(
190
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
191
  f'border-bottom:1px solid #ccc;font-weight:600">'
192
  f'{_e(col)}</th>'
193
  )
 
428
  ]
429
  for col in (rank_label, name_label, value_label):
430
  parts.append(
431
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
432
  f'border-bottom:1px solid #ccc;font-weight:600">'
433
  f'{_e(col)}</th>'
434
  )
 
504
  ]
505
  for col in (name_label, value_label, abs_label, rel_label):
506
  parts.append(
507
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
508
  f'border-bottom:1px solid #ccc;font-weight:600">'
509
  f'{_e(col)}</th>'
510
  )
picarones/report/readability_render.py CHANGED
@@ -94,7 +94,7 @@ def build_readability_summary_html(
94
  for col in (col_engine, col_mean, col_median, col_over,
95
  col_under, col_docs):
96
  parts.append(
97
- f'<th style="padding:.4rem .6rem;text-align:left;'
98
  f'border-bottom:1px solid #ccc;font-weight:600">'
99
  f'{_e(col)}</th>'
100
  )
 
94
  for col in (col_engine, col_mean, col_median, col_over,
95
  col_under, col_docs):
96
  parts.append(
97
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
98
  f'border-bottom:1px solid #ccc;font-weight:600">'
99
  f'{_e(col)}</th>'
100
  )
picarones/report/robustness_projection_render.py CHANGED
@@ -89,7 +89,7 @@ def _build_summary_table(
89
  ]
90
  for col in (h_engine, h_total, h_n_types, h_worst):
91
  parts.append(
92
- f'<th style="padding:.4rem .6rem;text-align:left;'
93
  f'border-bottom:1px solid #ccc;font-weight:600">'
94
  f'{_e(col)}</th>'
95
  )
@@ -147,7 +147,7 @@ def _build_detail_table(
147
  for col in (h_engine, h_deg_type, h_n_docs,
148
  h_n_with_data, h_deficit, h_above):
149
  parts.append(
150
- f'<th style="padding:.4rem .6rem;text-align:left;'
151
  f'border-bottom:1px solid #ccc;font-weight:600">'
152
  f'{_e(col)}</th>'
153
  )
 
89
  ]
90
  for col in (h_engine, h_total, h_n_types, h_worst):
91
  parts.append(
92
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
93
  f'border-bottom:1px solid #ccc;font-weight:600">'
94
  f'{_e(col)}</th>'
95
  )
 
147
  for col in (h_engine, h_deg_type, h_n_docs,
148
  h_n_with_data, h_deficit, h_above):
149
  parts.append(
150
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
151
  f'border-bottom:1px solid #ccc;font-weight:600">'
152
  f'{_e(col)}</th>'
153
  )
picarones/report/searchability_render.py CHANGED
@@ -87,7 +87,7 @@ def build_searchability_summary_html(
87
  ]
88
  for col in (col_engine, col_recall, col_count, col_docs):
89
  parts.append(
90
- f'<th style="padding:.4rem .6rem;text-align:left;'
91
  f'border-bottom:1px solid #ccc;font-weight:600">'
92
  f'{_e(col)}</th>'
93
  )
 
87
  ]
88
  for col in (col_engine, col_recall, col_count, col_docs):
89
  parts.append(
90
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
91
  f'border-bottom:1px solid #ccc;font-weight:600">'
92
  f'{_e(col)}</th>'
93
  )
picarones/report/specialization_render.py CHANGED
@@ -89,7 +89,7 @@ def build_specialization_html(
89
  ]
90
  for col in (h_a, h_b, h_score, h_cat):
91
  parts.append(
92
- f'<th style="padding:.4rem .6rem;text-align:left;'
93
  f'border-bottom:1px solid #ccc;font-weight:600">'
94
  f'{_e(col)}</th>'
95
  )
 
89
  ]
90
  for col in (h_a, h_b, h_score, h_cat):
91
  parts.append(
92
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
93
  f'border-bottom:1px solid #ccc;font-weight:600">'
94
  f'{_e(col)}</th>'
95
  )
picarones/report/stratification_render.py CHANGED
@@ -157,7 +157,7 @@ def build_stratified_ranking_html(
157
  parts.append("<thead><tr>")
158
  for hdr in (engine_label, median_label, mean_label, docs_label):
159
  parts.append(
160
- f'<th style="padding:.3rem .5rem;text-align:left;'
161
  f'border-bottom:1px solid var(--border);font-weight:600">'
162
  f'{_e(hdr)}</th>'
163
  )
 
157
  parts.append("<thead><tr>")
158
  for hdr in (engine_label, median_label, mean_label, docs_label):
159
  parts.append(
160
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
161
  f'border-bottom:1px solid var(--border);font-weight:600">'
162
  f'{_e(hdr)}</th>'
163
  )
picarones/report/taxonomy_comparison_render.py CHANGED
@@ -158,13 +158,13 @@ def _build_recoverability_summary_html(
158
  '<table style="border-collapse:collapse;font-size:.85rem;'
159
  'margin-top:.5rem">',
160
  '<thead><tr>',
161
- '<th style="padding:.2rem .5rem;text-align:left;'
162
  'border-bottom:1px solid #ccc">'
163
  f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}</th>',
164
- '<th style="padding:.2rem .5rem;text-align:right;'
165
  'border-bottom:1px solid #ccc">'
166
  f'{_e(_e(data["engine_a"]))}</th>',
167
- '<th style="padding:.2rem .5rem;text-align:right;'
168
  'border-bottom:1px solid #ccc">'
169
  f'{_e(_e(data["engine_b"]))}</th>',
170
  '</tr></thead><tbody>',
 
158
  '<table style="border-collapse:collapse;font-size:.85rem;'
159
  'margin-top:.5rem">',
160
  '<thead><tr>',
161
+ '<th scope=\"col\" style="padding:.2rem .5rem;text-align:left;'
162
  'border-bottom:1px solid #ccc">'
163
  f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}</th>',
164
+ '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;'
165
  'border-bottom:1px solid #ccc">'
166
  f'{_e(_e(data["engine_a"]))}</th>',
167
+ '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;'
168
  'border-bottom:1px solid #ccc">'
169
  f'{_e(_e(data["engine_b"]))}</th>',
170
  '</tr></thead><tbody>',
picarones/report/taxonomy_cooccurrence_render.py CHANGED
@@ -122,10 +122,10 @@ def _build_top_pairs_table(
122
  '<table style="border-collapse:collapse;font-size:.85rem;'
123
  'margin-top:.5rem">',
124
  '<thead><tr>',
125
- f'<th style="padding:.3rem .5rem;text-align:left;'
126
  f'border-bottom:1px solid #ccc;font-weight:600">'
127
  f'{_e(pair_label)}</th>',
128
- f'<th style="padding:.3rem .5rem;text-align:right;'
129
  f'border-bottom:1px solid #ccc;font-weight:600">'
130
  f'{_e(jaccard_label)}</th>',
131
  '</tr></thead><tbody>',
 
122
  '<table style="border-collapse:collapse;font-size:.85rem;'
123
  'margin-top:.5rem">',
124
  '<thead><tr>',
125
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
126
  f'border-bottom:1px solid #ccc;font-weight:600">'
127
  f'{_e(pair_label)}</th>',
128
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:right;'
129
  f'border-bottom:1px solid #ccc;font-weight:600">'
130
  f'{_e(jaccard_label)}</th>',
131
  '</tr></thead><tbody>',
picarones/report/templates/_app.js CHANGED
@@ -26,6 +26,11 @@ function _switchView(name) {
26
  function showView(name) {
27
  _switchView(name);
28
  updateURL(name);
 
 
 
 
 
29
  }
30
 
31
  // ── Formatage ───────────────────────────────────────────────────
@@ -2600,4 +2605,117 @@ function init() {
2600
  });
2601
  }
2602
 
2603
- document.addEventListener('DOMContentLoaded', init);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  function showView(name) {
27
  _switchView(name);
28
  updateURL(name);
29
+ // Sprint A6 — re-attache les boutons d'a11y aux nouveaux charts
30
+ // qui ont été instanciés paresseusement au switch de vue.
31
+ if (typeof attachChartA11y === 'function') {
32
+ setTimeout(attachChartA11y, 50);
33
+ }
34
  }
35
 
36
  // ── Formatage ───────────────────────────────────────────────────
 
2605
  });
2606
  }
2607
 
2608
+ // ─── Sprint A6 (B-9) — accessibilité des graphiques Chart.js ──────────
2609
+ //
2610
+ // Les <canvas> Chart.js ne sont **pas** accessibles aux lecteurs d'écran
2611
+ // par défaut (le rendu est purement pixel). Pour respecter WCAG 1.1.1
2612
+ // (Non-text Content) niveau A, on ajoute :
2613
+ //
2614
+ // 1. ``role="img"`` + ``aria-label`` (déjà posés statiquement dans le
2615
+ // HTML via le helper Python ``_enrich_canvas_with_aria``) ;
2616
+ // 2. une table de données jumelle générée à la demande à partir de
2617
+ // l'instance Chart.js, avec un bouton "Voir les données" qui la
2618
+ // révèle pour TOUS (utile aussi pour la copie / vérification).
2619
+ //
2620
+ // Cette fonction est idempotente : on peut l'appeler plusieurs fois
2621
+ // sans dupliquer les boutons (test ``data-a11y-attached``).
2622
+ function attachChartA11y() {
2623
+ const canvases = document.querySelectorAll('canvas[data-a11y-label]');
2624
+ canvases.forEach(canvas => {
2625
+ if (canvas.dataset.a11yAttached === '1') return;
2626
+ canvas.dataset.a11yAttached = '1';
2627
+
2628
+ const id = canvas.id;
2629
+ if (!id) return;
2630
+
2631
+ // Bouton "Voir les données" en dessous du canvas.
2632
+ const btn = document.createElement('button');
2633
+ btn.type = 'button';
2634
+ btn.className = 'btn-toggle-data';
2635
+ btn.setAttribute('data-i18n', 'view_data');
2636
+ btn.textContent = (typeof I18N !== 'undefined' && I18N.view_data)
2637
+ ? I18N.view_data : 'Voir les données';
2638
+ btn.setAttribute('aria-controls', id + '-data');
2639
+ btn.setAttribute('aria-expanded', 'false');
2640
+
2641
+ // Conteneur de table (caché visuellement mais lu par les AT via
2642
+ // aria-describedby ; révélé visuellement au clic via .is-revealed).
2643
+ const wrapper = document.createElement('div');
2644
+ wrapper.id = id + '-data';
2645
+ wrapper.className = 'chart-data-table visually-hidden';
2646
+ wrapper.setAttribute('role', 'region');
2647
+ wrapper.setAttribute('aria-label',
2648
+ ((typeof I18N !== 'undefined' && I18N.chart_data_caption)
2649
+ ? I18N.chart_data_caption
2650
+ : 'Données du graphique')
2651
+ + ' : ' + (canvas.dataset.a11yLabel || id));
2652
+
2653
+ // Lien aria-describedby pour que le lecteur d'écran annonce
2654
+ // l'existence de la table dès qu'il atteint le canvas.
2655
+ canvas.setAttribute('aria-describedby', wrapper.id);
2656
+
2657
+ btn.addEventListener('click', () => {
2658
+ const expanded = wrapper.classList.toggle('is-revealed');
2659
+ btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
2660
+ btn.textContent = expanded
2661
+ ? ((typeof I18N !== 'undefined' && I18N.hide_data) ? I18N.hide_data : 'Masquer les données')
2662
+ : ((typeof I18N !== 'undefined' && I18N.view_data) ? I18N.view_data : 'Voir les données');
2663
+ // Génération paresseuse du tableau au premier clic.
2664
+ if (expanded && !wrapper.dataset.populated) {
2665
+ _populateChartDataTable(wrapper, id);
2666
+ wrapper.dataset.populated = '1';
2667
+ }
2668
+ });
2669
+
2670
+ canvas.parentElement.appendChild(btn);
2671
+ canvas.parentElement.appendChild(wrapper);
2672
+ });
2673
+ }
2674
+
2675
+ function _populateChartDataTable(wrapper, canvasId) {
2676
+ const chart = (typeof chartInstances !== 'undefined') ? chartInstances[canvasId] : null;
2677
+ if (!chart || !chart.data) {
2678
+ wrapper.innerHTML = '<p>' +
2679
+ ((typeof I18N !== 'undefined' && I18N.chart_no_data)
2680
+ ? I18N.chart_no_data : 'Aucune donnée disponible')
2681
+ + '</p>';
2682
+ return;
2683
+ }
2684
+ const labels = chart.data.labels || [];
2685
+ const datasets = chart.data.datasets || [];
2686
+
2687
+ // En-tête : colonne libellé puis une colonne par dataset.
2688
+ let html = '<table class="chart-data-table is-revealed">';
2689
+ html += '<thead><tr><th scope="col">—</th>';
2690
+ datasets.forEach(ds => {
2691
+ html += '<th scope="col">' + esc(ds.label || '') + '</th>';
2692
+ });
2693
+ html += '</tr></thead><tbody>';
2694
+ // Une ligne par label.
2695
+ for (let i = 0; i < labels.length; i++) {
2696
+ html += '<tr><th scope="row">' + esc(String(labels[i])) + '</th>';
2697
+ datasets.forEach(ds => {
2698
+ const v = ds.data ? ds.data[i] : '';
2699
+ html += '<td>' + esc(String(v == null ? '' : v)) + '</td>';
2700
+ });
2701
+ html += '</tr>';
2702
+ }
2703
+ // Cas particulier : pas de labels (scatter, radar) — on dump les datasets.
2704
+ if (labels.length === 0 && datasets.length > 0) {
2705
+ datasets.forEach(ds => {
2706
+ html += '<tr><th scope="row">' + esc(ds.label || '') + '</th><td>' +
2707
+ esc(JSON.stringify(ds.data).slice(0, 200)) + '</td></tr>';
2708
+ });
2709
+ }
2710
+ html += '</tbody></table>';
2711
+ wrapper.innerHTML = html;
2712
+ }
2713
+
2714
+ document.addEventListener('DOMContentLoaded', () => {
2715
+ init();
2716
+ // Délai pour laisser les charts s'instancier au switch de vue.
2717
+ // Les boutons sont posés sur les canvas déjà visibles ; pour les
2718
+ // canvas qui se créent au premier showView('analyses'), on rappelle
2719
+ // attachChartA11y depuis showView aussi.
2720
+ setTimeout(attachChartA11y, 200);
2721
+ });
picarones/report/templates/_header.html CHANGED
@@ -1,4 +1,10 @@
1
 
 
 
 
 
 
 
2
  <!-- ── Navigation ─────────────────────────────────────────────────── -->
3
  <nav>
4
  <div class="brand">
@@ -22,9 +28,10 @@
22
  <!-- ── Bandeau exclusion globale ───────────────────────────────────── -->
23
  <div id="global-exclusion-banner" style="display:none;background:#fef3c7;border-bottom:2px solid #f59e0b;padding:.5rem 1.5rem;font-size:.85rem;font-weight:600;color:#92400e;text-align:center">
24
  <span id="global-exclusion-text"></span>
25
- <button onclick="resetAllExclusions()" style="margin-left:1rem;font-size:.75rem;padding:.15rem .5rem;border:1px solid #d97706;background:#fff;border-radius:.25rem;cursor:pointer">Réinitialiser</button>
26
  </div>
27
 
28
- <!-- ── Main ───────────────────────────────────────────────────────── -->
29
- <main>
 
30
 
 
1
 
2
+ <!-- ── Skip-to-content (Sprint A6, B-10) ───────────────────────────────
3
+ Lien WCAG 2.4.1 (Bypass Blocks) — premier enfant tabbable du body,
4
+ visible uniquement au focus, saute la nav et le bandeau pour
5
+ l'utilisateur clavier ou lecteur d'écran. -->
6
+ <a class="skip-link" href="#main" data-i18n="skip_to_content">Aller au contenu</a>
7
+
8
  <!-- ── Navigation ─────────────────────────────────────────────────── -->
9
  <nav>
10
  <div class="brand">
 
28
  <!-- ── Bandeau exclusion globale ───────────────────────────────────── -->
29
  <div id="global-exclusion-banner" style="display:none;background:#fef3c7;border-bottom:2px solid #f59e0b;padding:.5rem 1.5rem;font-size:.85rem;font-weight:600;color:#92400e;text-align:center">
30
  <span id="global-exclusion-text"></span>
31
+ <button onclick="resetAllExclusions()" data-i18n="reset_all" style="margin-left:1rem;font-size:.75rem;padding:.15rem .5rem;border:1px solid #d97706;background:#fff;border-radius:.25rem;cursor:pointer">Réinitialiser</button>
32
  </div>
33
 
34
+ <!-- ── Main (Sprint A6, B-10 : id=main pour le skip-link) ──────────── -->
35
+ <main id="main" role="main">
36
+
37
 
picarones/report/templates/_styles.css CHANGED
@@ -913,3 +913,77 @@ body.present-mode .btn-customize { display: none !important; }
913
  text-decoration: underline;
914
  }
915
  body.present-mode .synth-cases-link { display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
913
  text-decoration: underline;
914
  }
915
  body.present-mode .synth-cases-link { display: none; }
916
+
917
+ /* ── Sprint A6 — Accessibilité WCAG niveau A ─────────────────────────
918
+ Skip-to-content link (B-10) : caché hors :focus pour ne pas polluer
919
+ le visuel mais immédiatement disponible au clavier (Tab depuis la URL
920
+ bar atteint le contenu en 1 tabulation). Contraste AA ≥ 4.5:1 sur
921
+ fond bleu institutionnel. */
922
+ .skip-link {
923
+ position: absolute;
924
+ top: -40px;
925
+ left: 0;
926
+ background: #1d4ed8;
927
+ color: #ffffff;
928
+ padding: .5rem 1rem;
929
+ font-weight: 600;
930
+ text-decoration: none;
931
+ z-index: 9999;
932
+ border-radius: 0 0 .25rem 0;
933
+ transition: top 120ms ease-out;
934
+ }
935
+ .skip-link:focus {
936
+ top: 0;
937
+ outline: 3px solid #fbbf24;
938
+ outline-offset: 2px;
939
+ }
940
+
941
+ /* Sprint A6 (B-9) — table jumelle des charts pour lecteurs d'écran.
942
+ ``visually-hidden`` cache le contenu visuellement tout en le
943
+ conservant accessible aux AT (NVDA, JAWS, VoiceOver). Le bouton
944
+ ``Voir les données`` peut révéler la table à tous via toggle de
945
+ classe. */
946
+ .visually-hidden {
947
+ position: absolute;
948
+ width: 1px;
949
+ height: 1px;
950
+ padding: 0;
951
+ margin: -1px;
952
+ overflow: hidden;
953
+ clip: rect(0, 0, 0, 0);
954
+ white-space: nowrap;
955
+ border: 0;
956
+ }
957
+ .chart-data-table {
958
+ margin-top: .5rem;
959
+ font-size: .85rem;
960
+ border-collapse: collapse;
961
+ }
962
+ .chart-data-table th, .chart-data-table td {
963
+ border: 1px solid var(--border-light, #e2e8f0);
964
+ padding: .25rem .5rem;
965
+ text-align: left;
966
+ }
967
+ .chart-data-table.is-revealed {
968
+ position: static;
969
+ width: auto;
970
+ height: auto;
971
+ clip: auto;
972
+ white-space: normal;
973
+ }
974
+ .btn-toggle-data {
975
+ margin-top: .5rem;
976
+ font-size: .78rem;
977
+ padding: .15rem .5rem;
978
+ border: 1px solid var(--border-light, #cbd5e1);
979
+ background: #ffffff;
980
+ border-radius: .25rem;
981
+ cursor: pointer;
982
+ }
983
+ .btn-toggle-data:hover {
984
+ background: #f1f5f9;
985
+ }
986
+ .btn-toggle-data:focus {
987
+ outline: 2px solid #1d4ed8;
988
+ outline-offset: 2px;
989
+ }
picarones/report/templates/view_analyses.html CHANGED
@@ -6,14 +6,14 @@
6
  <div class="chart-card">
7
  <h3 data-i18n="h_cer_dist">Distribution du CER par moteur</h3>
8
  <div class="chart-canvas-wrap">
9
- <canvas id="chart-cer-hist"></canvas>
10
  </div>
11
  </div>
12
 
13
  <div class="chart-card">
14
  <h3 data-i18n="h_radar">Profil des moteurs (radar)</h3>
15
  <div class="chart-canvas-wrap">
16
- <canvas id="chart-radar"></canvas>
17
  </div>
18
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.5rem" data-i18n="radar_note">
19
  Axe radar : CER, WER, MER, WIL — valeurs inversées (plus c'est haut, meilleur est le moteur).
@@ -23,21 +23,21 @@
23
  <div class="chart-card">
24
  <h3 data-i18n="h_cer_doc">CER par document (tous moteurs)</h3>
25
  <div class="chart-canvas-wrap">
26
- <canvas id="chart-cer-doc"></canvas>
27
  </div>
28
  </div>
29
 
30
  <div class="chart-card">
31
  <h3 data-i18n="h_duration">Temps d'exécution moyen (secondes/document)</h3>
32
  <div class="chart-canvas-wrap">
33
- <canvas id="chart-duration"></canvas>
34
  </div>
35
  </div>
36
 
37
  <div class="chart-card">
38
  <h3 data-i18n="h_quality_cer">Qualité image ↔ CER (scatter plot)</h3>
39
  <div class="chart-canvas-wrap">
40
- <canvas id="chart-quality-cer"></canvas>
41
  </div>
42
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="quality_cer_note">
43
  Chaque point = un document. Axe X = score qualité image [0–1]. Axe Y = CER. Corrélation négative attendue.
@@ -47,7 +47,7 @@
47
  <div class="chart-card" style="grid-column:1/-1">
48
  <h3 data-i18n="h_taxonomy">Taxonomie des erreurs par moteur</h3>
49
  <div class="chart-canvas-wrap" style="max-height:300px">
50
- <canvas id="chart-taxonomy"></canvas>
51
  </div>
52
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="taxonomy_note">
53
  Distribution des classes d'erreurs (classes 1–9 de la taxonomie Picarones).
@@ -58,7 +58,7 @@
58
  <div class="chart-card" style="grid-column:1/-1">
59
  <h3 data-i18n="h_reliability">Courbes de fiabilité</h3>
60
  <div class="chart-canvas-wrap" style="max-height:300px">
61
- <canvas id="chart-reliability"></canvas>
62
  </div>
63
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="reliability_note">
64
  Pour les X% documents les plus faciles (triés par CER croissant), quel est le CER moyen cumulé ?
@@ -70,7 +70,7 @@
70
  <div class="chart-card">
71
  <h3 data-i18n="h_bootstrap">Intervalles de confiance à 95 % (bootstrap)</h3>
72
  <div class="chart-canvas-wrap">
73
- <canvas id="chart-bootstrap-ci"></canvas>
74
  </div>
75
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="bootstrap_note">
76
  IC à 95% sur le CER moyen par moteur (1000 itérations bootstrap).
@@ -106,7 +106,7 @@
106
  <div class="chart-card">
107
  <h3 data-i18n="h_gini_cer">Gini vs CER moyen <span style="font-size:.72rem;font-weight:400;color:var(--text-muted)" data-i18n="gini_cer_ideal">— idéal : bas-gauche</span></h3>
108
  <div class="chart-canvas-wrap">
109
- <canvas id="chart-gini-cer"></canvas>
110
  </div>
111
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="gini_cer_note">
112
  Axe X = CER moyen, Axe Y = coefficient de Gini. Un moteur idéal a CER bas ET Gini bas (erreurs rares et uniformes).
@@ -117,7 +117,7 @@
117
  <div class="chart-card">
118
  <h3 data-i18n="h_ratio_anchor">Ratio longueur vs ancrage <span style="font-size:.72rem;font-weight:400;color:var(--text-muted)" data-i18n="ratio_anchor_subtitle">— hallucinations VLM</span></h3>
119
  <div class="chart-canvas-wrap">
120
- <canvas id="chart-ratio-anchor"></canvas>
121
  </div>
122
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="ratio_anchor_note">
123
  Axe X = score d'ancrage trigrammes [0–1]. Axe Y = ratio longueur sortie/GT.
@@ -137,7 +137,7 @@
137
  onclick="setParetoAxis('co2')" data-i18n="pareto_axis_co2"
138
  title="Estimation expérimentale">Carbone (g CO₂)</button>
139
  </div>
140
- <div class="chart-canvas-wrap"><canvas id="pareto-chart"></canvas></div>
141
  <div id="pareto-method-note" class="pareto-note" data-i18n="pareto_note">
142
  Les moteurs sur la frontière de Pareto (en évidence) sont ceux pour
143
  lesquels aucun autre moteur n'offre simultanément un meilleur CER ET
 
6
  <div class="chart-card">
7
  <h3 data-i18n="h_cer_dist">Distribution du CER par moteur</h3>
8
  <div class="chart-canvas-wrap">
9
+ <canvas id="chart-cer-hist" role="img" aria-label="Distribution des CER par moteur" data-a11y-label="Distribution des CER par moteur"></canvas>
10
  </div>
11
  </div>
12
 
13
  <div class="chart-card">
14
  <h3 data-i18n="h_radar">Profil des moteurs (radar)</h3>
15
  <div class="chart-canvas-wrap">
16
+ <canvas id="chart-radar" role="img" aria-label="Profil radar par moteur" data-a11y-label="Profil radar par moteur"></canvas>
17
  </div>
18
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.5rem" data-i18n="radar_note">
19
  Axe radar : CER, WER, MER, WIL — valeurs inversées (plus c'est haut, meilleur est le moteur).
 
23
  <div class="chart-card">
24
  <h3 data-i18n="h_cer_doc">CER par document (tous moteurs)</h3>
25
  <div class="chart-canvas-wrap">
26
+ <canvas id="chart-cer-doc" role="img" aria-label="CER par document" data-a11y-label="CER par document"></canvas>
27
  </div>
28
  </div>
29
 
30
  <div class="chart-card">
31
  <h3 data-i18n="h_duration">Temps d'exécution moyen (secondes/document)</h3>
32
  <div class="chart-canvas-wrap">
33
+ <canvas id="chart-duration" role="img" aria-label="Durée d'inférence par moteur" data-a11y-label="Durée d'inférence par moteur"></canvas>
34
  </div>
35
  </div>
36
 
37
  <div class="chart-card">
38
  <h3 data-i18n="h_quality_cer">Qualité image ↔ CER (scatter plot)</h3>
39
  <div class="chart-canvas-wrap">
40
+ <canvas id="chart-quality-cer" role="img" aria-label="Corrélation qualité d'image / CER" data-a11y-label="Corrélation qualité d'image / CER"></canvas>
41
  </div>
42
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="quality_cer_note">
43
  Chaque point = un document. Axe X = score qualité image [0–1]. Axe Y = CER. Corrélation négative attendue.
 
47
  <div class="chart-card" style="grid-column:1/-1">
48
  <h3 data-i18n="h_taxonomy">Taxonomie des erreurs par moteur</h3>
49
  <div class="chart-canvas-wrap" style="max-height:300px">
50
+ <canvas id="chart-taxonomy" role="img" aria-label="Taxonomie d'erreurs par moteur" data-a11y-label="Taxonomie d'erreurs par moteur"></canvas>
51
  </div>
52
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="taxonomy_note">
53
  Distribution des classes d'erreurs (classes 1–9 de la taxonomie Picarones).
 
58
  <div class="chart-card" style="grid-column:1/-1">
59
  <h3 data-i18n="h_reliability">Courbes de fiabilité</h3>
60
  <div class="chart-canvas-wrap" style="max-height:300px">
61
+ <canvas id="chart-reliability" role="img" aria-label="Diagramme de fiabilité (calibration)" data-a11y-label="Diagramme de fiabilité (calibration)"></canvas>
62
  </div>
63
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="reliability_note">
64
  Pour les X% documents les plus faciles (triés par CER croissant), quel est le CER moyen cumulé ?
 
70
  <div class="chart-card">
71
  <h3 data-i18n="h_bootstrap">Intervalles de confiance à 95 % (bootstrap)</h3>
72
  <div class="chart-canvas-wrap">
73
+ <canvas id="chart-bootstrap-ci" role="img" aria-label="Intervalles de confiance bootstrap" data-a11y-label="Intervalles de confiance bootstrap"></canvas>
74
  </div>
75
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="bootstrap_note">
76
  IC à 95% sur le CER moyen par moteur (1000 itérations bootstrap).
 
106
  <div class="chart-card">
107
  <h3 data-i18n="h_gini_cer">Gini vs CER moyen <span style="font-size:.72rem;font-weight:400;color:var(--text-muted)" data-i18n="gini_cer_ideal">— idéal : bas-gauche</span></h3>
108
  <div class="chart-canvas-wrap">
109
+ <canvas id="chart-gini-cer" role="img" aria-label="Gini vs CER" data-a11y-label="Gini vs CER"></canvas>
110
  </div>
111
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="gini_cer_note">
112
  Axe X = CER moyen, Axe Y = coefficient de Gini. Un moteur idéal a CER bas ET Gini bas (erreurs rares et uniformes).
 
117
  <div class="chart-card">
118
  <h3 data-i18n="h_ratio_anchor">Ratio longueur vs ancrage <span style="font-size:.72rem;font-weight:400;color:var(--text-muted)" data-i18n="ratio_anchor_subtitle">— hallucinations VLM</span></h3>
119
  <div class="chart-canvas-wrap">
120
+ <canvas id="chart-ratio-anchor" role="img" aria-label="Score d'ancrage par moteur" data-a11y-label="Score d'ancrage par moteur"></canvas>
121
  </div>
122
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.4rem" data-i18n="ratio_anchor_note">
123
  Axe X = score d'ancrage trigrammes [0–1]. Axe Y = ratio longueur sortie/GT.
 
137
  onclick="setParetoAxis('co2')" data-i18n="pareto_axis_co2"
138
  title="Estimation expérimentale">Carbone (g CO₂)</button>
139
  </div>
140
+ <div class="chart-canvas-wrap"><canvas id="pareto-chart" role="img" aria-label="Front Pareto coût/qualité" data-a11y-label="Front Pareto coût/qualité"></canvas></div>
141
  <div id="pareto-method-note" class="pareto-note" data-i18n="pareto_note">
142
  Les moteurs sur la frontière de Pareto (en évidence) sont ceux pour
143
  lesquels aucun autre moteur n'offre simultanément un meilleur CER ET
picarones/report/templates/view_ranking.html CHANGED
@@ -8,22 +8,22 @@
8
  <table id="ranking-table">
9
  <thead>
10
  <tr>
11
- <th data-col="rank" class="sortable sorted" data-dir="asc" data-i18n="col_rank">#<i class="sort-icon">↑</i></th>
12
- <th data-col="name" class="sortable" data-i18n="col_engine">Concurrent<i class="sort-icon">↕</i></th>
13
- <th data-col="cer" class="sortable" data-glossary-key="cer" data-i18n="col_cer">CER exact<i class="sort-icon">↕</i></th>
14
- <th data-col="cer_diplomatic" class="sortable" id="th-cer-diplo" data-glossary-key="cer_diplomatic" data-i18n="col_cer_diplo">CER diplo.<i class="sort-icon">↕</i></th>
15
- <th data-col="wer" class="sortable" data-glossary-key="wer" data-i18n="col_wer">WER<i class="sort-icon">↕</i></th>
16
- <th data-col="mer" class="sortable" data-glossary-key="mer" data-i18n="col_mer">MER<i class="sort-icon">↕</i></th>
17
- <th data-col="wil" class="sortable" data-glossary-key="wil" data-i18n="col_wil">WIL<i class="sort-icon">↕</i></th>
18
- <th data-col="ligature_score" class="sortable" id="th-ligatures" data-glossary-key="ligature_score" data-i18n="col_ligatures">Ligatures<i class="sort-icon">↕</i></th>
19
- <th data-col="diacritic_score" class="sortable" id="th-diacritics" data-glossary-key="diacritic_score" data-i18n="col_diacritics">Diacritiques<i class="sort-icon">↕</i></th>
20
- <th data-col="gini" class="sortable" id="th-gini" data-glossary-key="gini" data-i18n="col_gini">Gini<i class="sort-icon">↕</i></th>
21
- <th data-col="anchor_score" class="sortable" id="th-anchor" data-glossary-key="anchor_score" data-i18n="col_anchor">Ancrage<i class="sort-icon">↕</i></th>
22
- <th data-i18n="col_cer_median">CER médian</th>
23
- <th data-i18n="col_cer_min">CER min</th>
24
- <th data-i18n="col_cer_max">CER max</th>
25
- <th id="th-overnorm" data-i18n="col_overnorm">Sur-norm.</th>
26
- <th data-i18n="col_docs">Docs</th>
27
  </tr>
28
  </thead>
29
  <tbody id="ranking-tbody"></tbody>
 
8
  <table id="ranking-table">
9
  <thead>
10
  <tr>
11
+ <th scope="col" data-col="rank" class="sortable sorted" data-dir="asc" data-i18n="col_rank">#<i class="sort-icon">↑</i></th>
12
+ <th scope="col" data-col="name" class="sortable" data-i18n="col_engine">Concurrent<i class="sort-icon">↕</i></th>
13
+ <th scope="col" data-col="cer" class="sortable" data-glossary-key="cer" data-i18n="col_cer">CER exact<i class="sort-icon">↕</i></th>
14
+ <th scope="col" data-col="cer_diplomatic" class="sortable" id="th-cer-diplo" data-glossary-key="cer_diplomatic" data-i18n="col_cer_diplo">CER diplo.<i class="sort-icon">↕</i></th>
15
+ <th scope="col" data-col="wer" class="sortable" data-glossary-key="wer" data-i18n="col_wer">WER<i class="sort-icon">↕</i></th>
16
+ <th scope="col" data-col="mer" class="sortable" data-glossary-key="mer" data-i18n="col_mer">MER<i class="sort-icon">↕</i></th>
17
+ <th scope="col" data-col="wil" class="sortable" data-glossary-key="wil" data-i18n="col_wil">WIL<i class="sort-icon">↕</i></th>
18
+ <th scope="col" data-col="ligature_score" class="sortable" id="th-ligatures" data-glossary-key="ligature_score" data-i18n="col_ligatures">Ligatures<i class="sort-icon">↕</i></th>
19
+ <th scope="col" data-col="diacritic_score" class="sortable" id="th-diacritics" data-glossary-key="diacritic_score" data-i18n="col_diacritics">Diacritiques<i class="sort-icon">↕</i></th>
20
+ <th scope="col" data-col="gini" class="sortable" id="th-gini" data-glossary-key="gini" data-i18n="col_gini">Gini<i class="sort-icon">↕</i></th>
21
+ <th scope="col" data-col="anchor_score" class="sortable" id="th-anchor" data-glossary-key="anchor_score" data-i18n="col_anchor">Ancrage<i class="sort-icon">↕</i></th>
22
+ <th scope="col" data-i18n="col_cer_median">CER médian</th>
23
+ <th scope="col" data-i18n="col_cer_min">CER min</th>
24
+ <th scope="col" data-i18n="col_cer_max">CER max</th>
25
+ <th scope="col" id="th-overnorm" data-i18n="col_overnorm">Sur-norm.</th>
26
+ <th scope="col" data-i18n="col_docs">Docs</th>
27
  </tr>
28
  </thead>
29
  <tbody id="ranking-tbody"></tbody>
picarones/report/throughput_render.py CHANGED
@@ -139,7 +139,7 @@ def build_throughput_html(
139
  ]
140
  for col in (h_engine, h_raw, h_effective, h_drag, h_pages, h_errors):
141
  parts.append(
142
- f'<th style="padding:.4rem .6rem;text-align:left;'
143
  f'border-bottom:1px solid #ccc;font-weight:600">'
144
  f'{_e(col)}</th>'
145
  )
 
139
  ]
140
  for col in (h_engine, h_raw, h_effective, h_drag, h_pages, h_errors):
141
  parts.append(
142
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
143
  f'border-bottom:1px solid #ccc;font-weight:600">'
144
  f'{_e(col)}</th>'
145
  )
picarones/report/worst_lines_render.py CHANGED
@@ -115,7 +115,7 @@ def build_worst_lines_table_html(
115
  cols.append(diff_label)
116
  for col in cols:
117
  parts.append(
118
- f'<th style="padding:.3rem .5rem;text-align:left;'
119
  f'border-bottom:1px solid #ccc;font-weight:600">'
120
  f'{_e(col)}</th>'
121
  )
 
115
  cols.append(diff_label)
116
  for col in cols:
117
  parts.append(
118
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
119
  f'border-bottom:1px solid #ccc;font-weight:600">'
120
  f'{_e(col)}</th>'
121
  )
tests/report/test_a11y_level_a.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint A6 — accessibilité WCAG niveau A bloquant.
2
+
3
+ Items B-9, B-10, m-3, m-4 de l'audit institutional-readiness-2026-05.
4
+
5
+ Ce fichier valide le **socle a11y bloquant** pour une déclaration de
6
+ conformité RGAA / WCAG 2.1 niveau A :
7
+
8
+ - WCAG 2.4.1 (Bypass Blocks) — skip-to-content link (B-10)
9
+ - WCAG 1.1.1 (Non-text Content) — Canvas charts → aria-label + table
10
+ jumelle accessible aux AT (B-9)
11
+ - WCAG 1.3.1 (Info and Relationships) — ``scope="col"`` sur les
12
+ ``<th>`` (m-4)
13
+ - Pas de chaîne hardcodée FR/EN dans la nav (m-3)
14
+
15
+ Ces tests se contentent de vérifier la présence des marqueurs HTML
16
+ attendus dans le rapport généré. L'audit sémantique complet (NVDA /
17
+ JAWS / VoiceOver) reste manuel et tracé dans
18
+ ``docs/audits/external-audits-2026/`` (Sprint A15).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+
25
+ import pytest
26
+
27
+ from picarones.fixtures import generate_sample_benchmark
28
+ from picarones.report.generator import ReportGenerator
29
+
30
+
31
+ @pytest.fixture(scope="module")
32
+ def demo_html(tmp_path_factory) -> str:
33
+ """Rapport démo (FR) généré une fois pour tous les tests du module."""
34
+ out = tmp_path_factory.mktemp("a11y") / "report.html"
35
+ bench = generate_sample_benchmark(n_docs=4)
36
+ ReportGenerator(bench, lang="fr").generate(out)
37
+ return out.read_text(encoding="utf-8")
38
+
39
+
40
+ @pytest.fixture(scope="module")
41
+ def demo_html_en(tmp_path_factory) -> str:
42
+ """Rapport démo (EN) — pour vérifier que les libellés a11y sont
43
+ bilingues."""
44
+ out = tmp_path_factory.mktemp("a11y_en") / "report_en.html"
45
+ bench = generate_sample_benchmark(n_docs=4)
46
+ ReportGenerator(bench, lang="en").generate(out)
47
+ return out.read_text(encoding="utf-8")
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # B-10 — Skip-to-content (WCAG 2.4.1)
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def test_skip_link_present(demo_html: str) -> None:
56
+ """Un lien ``href="#main"`` avec class ``skip-link`` doit exister."""
57
+ assert 'class="skip-link"' in demo_html
58
+ assert 'href="#main"' in demo_html
59
+
60
+
61
+ def test_skip_link_first_focusable_in_body(demo_html: str) -> None:
62
+ """Le skip-link doit être le **premier** élément focusable du body
63
+ (sinon Tab depuis l'URL bar atteint d'abord la nav, ce qui défait
64
+ le but)."""
65
+ body_start = demo_html.find("<body>")
66
+ assert body_start > 0
67
+ body_part = demo_html[body_start : body_start + 1500]
68
+ skip_pos = body_part.find('class="skip-link"')
69
+ nav_pos = body_part.find("<nav")
70
+ assert skip_pos > 0 and nav_pos > 0
71
+ assert skip_pos < nav_pos, (
72
+ "Le skip-link doit précéder le <nav> dans le DOM."
73
+ )
74
+
75
+
76
+ def test_main_has_id_main(demo_html: str) -> None:
77
+ """Le ``<main>`` doit avoir ``id="main"`` pour que le skip-link
78
+ pointe vers une cible existante."""
79
+ assert re.search(r'<main[^>]*\bid="main"', demo_html), (
80
+ '<main id="main"> attendu pour la cible du skip-link.'
81
+ )
82
+
83
+
84
+ def test_skip_link_label_is_i18n(demo_html: str, demo_html_en: str) -> None:
85
+ """Le libellé du skip-link doit être en français en mode FR et en
86
+ anglais en mode EN (pas de chaîne hardcodée)."""
87
+ # FR : "Aller au contenu"
88
+ assert "Aller au contenu" in demo_html
89
+ # EN : "Skip to content"
90
+ assert "Skip to content" in demo_html_en
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # B-9 — Canvas charts accessibles (WCAG 1.1.1)
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def test_all_canvases_have_aria_label(demo_html: str) -> None:
99
+ """Tout ``<canvas>`` Chart.js (avec ``id="chart-..."`` ou
100
+ ``pareto-chart``) doit avoir ``aria-label`` non vide.
101
+
102
+ Tolérance : un ``<canvas>`` créé dynamiquement côté JS sans id
103
+ pré-déclaré reste possible (Chart.js peut en générer pour des
104
+ sub-charts). Le test ne valide que les canvas que les templates
105
+ Jinja2 produisent — pas ceux du DOM dynamique."""
106
+ html = _strip_inline_scripts(demo_html)
107
+ canvases = re.findall(r"<canvas[^>]*>", html)
108
+ chart_canvases = [
109
+ c for c in canvases
110
+ if 'id="chart-' in c or 'id="pareto-chart"' in c
111
+ ]
112
+ canvases_no_label = [
113
+ c for c in chart_canvases
114
+ if 'aria-label="' not in c and "data-a11y-label" not in c
115
+ ]
116
+ assert not canvases_no_label, (
117
+ f"Canvas Chart.js sans aria-label : {canvases_no_label}"
118
+ )
119
+
120
+
121
+ def test_canvases_have_role_img(demo_html: str) -> None:
122
+ """``role="img"`` doit être posé sur les canvas pour les annoncer
123
+ comme images aux AT."""
124
+ canvases = re.findall(r"<canvas[^>]*>", demo_html)
125
+ chart_canvases = [c for c in canvases if "chart-" in c]
126
+ if not chart_canvases:
127
+ pytest.skip("Aucun canvas Chart.js dans le rapport démo")
128
+ canvases_no_role = [c for c in chart_canvases if 'role="img"' not in c]
129
+ assert not canvases_no_role, (
130
+ f"Canvas Chart.js sans role=img : {canvases_no_role[:3]}"
131
+ )
132
+
133
+
134
+ def test_data_table_helpers_present(demo_html: str) -> None:
135
+ """La fonction ``attachChartA11y`` qui génère les tables jumelles
136
+ doit être incluse dans le JS embarqué."""
137
+ assert "attachChartA11y" in demo_html
138
+ assert "_populateChartDataTable" in demo_html
139
+
140
+
141
+ def test_view_data_button_label_localized(
142
+ demo_html: str, demo_html_en: str
143
+ ) -> None:
144
+ """Les libellés du bouton « Voir les données » doivent être dans
145
+ l'objet I18N côté JS (pas hardcodés en français)."""
146
+ assert "Voir les données" in demo_html
147
+ assert "View data" in demo_html_en
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # m-4 — scope="col" sur les <th>
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def _strip_inline_scripts(html: str) -> str:
156
+ """Retire les blocs ``<script>...</script>`` et ``<style>...</style>``
157
+ avant d'analyser les balises HTML.
158
+
159
+ Nécessaire car Chart.js minifié contient des chaînes comme
160
+ ``<this._cachedMeta`` qui matchent le regex ``<th[\\s>]`` faussement
161
+ (sequence ``<t`` + word boundary). On limite l'analyse au HTML rendu
162
+ par les templates Jinja2, pas au JS embarqué.
163
+ """
164
+ cleaned = re.sub(r"<script\b[^>]*>.*?</script>", "", html, flags=re.DOTALL)
165
+ cleaned = re.sub(r"<style\b[^>]*>.*?</style>", "", cleaned, flags=re.DOTALL)
166
+ return cleaned
167
+
168
+
169
+ def test_table_headers_have_scope(demo_html: str) -> None:
170
+ """Tout ``<th>`` rendu par les templates doit avoir ``scope="col"``
171
+ ou ``scope="row"``."""
172
+ html = _strip_inline_scripts(demo_html)
173
+ # Regex strict : <th suivi d'un espace ou >, qui n'a PAS d'attribut scope=
174
+ th_no_scope = re.findall(
175
+ r"<th(?:\s+(?![^>]*\bscope=)[^>]*)?>",
176
+ html,
177
+ )
178
+ # On filtre faux positifs : <thead, <tbody, <tfoot etc. ne doivent pas matcher.
179
+ th_no_scope = [t for t in th_no_scope if re.match(r"<th(\s|>)", t)]
180
+ total_th = len(re.findall(r"<th(\s|>)", html))
181
+ if total_th == 0:
182
+ pytest.skip("Pas de <th> dans le rapport démo")
183
+ assert not th_no_scope, (
184
+ f"{len(th_no_scope)}/{total_th} <th> sans scope= "
185
+ f"dans le HTML rendu (hors <script>/<style>). "
186
+ f"Premiers : {th_no_scope[:3]}"
187
+ )
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # m-3 — Bouton Reset i18n
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def test_reset_button_uses_i18n_key(demo_html: str) -> None:
196
+ """Le bouton « Réinitialiser » du bandeau d'exclusion doit avoir
197
+ ``data-i18n="reset_all"`` (pas de chaîne FR hardcodée sans
198
+ mécanisme i18n)."""
199
+ # Le bouton apparaît avec data-i18n="reset_all"
200
+ assert 'data-i18n="reset_all"' in demo_html
201
+
202
+
203
+ def test_reset_label_in_i18n_dicts(demo_html: str, demo_html_en: str) -> None:
204
+ """Les clés ``reset_all`` doivent exister dans les deux
205
+ dictionnaires i18n embarqués."""
206
+ # Le JSON I18N est embarqué inline dans le HTML.
207
+ # On cherche un fragment JSON ``"reset_all":"..."``
208
+ assert re.search(r'"reset_all"\s*:\s*"R[ée]initialiser"', demo_html)
209
+ assert re.search(r'"reset_all"\s*:\s*"Reset"', demo_html_en)
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Synthèse
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def test_html_has_lang_attribute(demo_html: str, demo_html_en: str) -> None:
218
+ """``<html lang="...">`` doit être posé pour les AT (déjà cas mais
219
+ on renforce)."""
220
+ assert 'lang="fr"' in demo_html
221
+ assert 'lang="en"' in demo_html_en
222
+
223
+
224
+ def test_global_a11y_smoke(demo_html: str) -> None:
225
+ """Méta-test : tous les marqueurs a11y de niveau A sont présents
226
+ dans un rapport démo standard."""
227
+ markers = [
228
+ 'class="skip-link"',
229
+ 'href="#main"',
230
+ 'id="main"',
231
+ 'role="img"',
232
+ "attachChartA11y",
233
+ 'scope="col"',
234
+ 'data-i18n="reset_all"',
235
+ ]
236
+ missing = [m for m in markers if m not in demo_html]
237
+ assert not missing, f"Marqueurs WCAG niveau A manquants : {missing}"