Le panneau était découpé par table SQL, pas par question que se pose l'admin. Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen de voir ce qui coince réellement. Redécoupage — un onglet = une question - Pilotage : est-ce que ça tourne ? (fusionne l'ancien « Actions ») - Groupes : trouver et corriger - Localisations : qu'est-ce qui coince dans le géocodage ? ← NOUVEAU - Journal : que s'est-il passé ? - LLM & coûts : combien ça coûte ? Pilotage : chaque chiffre problématique porte son action - Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état - Alertes actionnables : « 12 localisations en erreur » + [Examiner] + [Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet - Déclencheurs de traitements inline, opérations destructives repliées - L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais consultés) est retiré Localisations : la vue qui manquait totalement L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des milliers d'appels Geoapify/Groq facturés. - GET /admin/api/locations liste filtrable (statut, lieu, groupe, pays) - POST /admin/api/locations/:id/requeue relance UNE ligne - PATCH /admin/api/locations/:id saisie manuelle des coordonnées - Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle Groupes : recherche d'abord Une barre de recherche et des filtres rapides en chips remplacent les 8 champs texte ; les filtres avancés sont repliés. Accessibilité - Lignes de tableau activables au clavier (role=button, tabindex, Entrée) - Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine - Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1). Les messages d'erreur étaient difficiles à lire. Ajout de --err-text / --ok-text (~6,5:1) pour les usages en couleur de texte. Détecté par les nouveaux tests axe sur les vues rendues. - Statuts affichés en français au lieu des valeurs brutes de la base Tests - 36 tests API sur les trois nouveaux endpoints (allowlist de statuts, bornes des coordonnées, audit, 404) - 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une topologie identique à la production (statique servi + /admin/* proxifié) - Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la coquille vide du dashboard, tout étant construit en JavaScript - e2e branché sur le hook pre-push, pas sur `check` : la boucle de développement reste à 6 s, le push coûte 28 s Correction trouvée par les tests Le routeur ne séparait pas la query string du nom de vue : « #/locations?status=error » ne correspondait à aucun alias et retombait sur Pilotage — les liens des alertes ne fonctionnaient pas. Co-Authored-By: Claude <noreply@anthropic.com>
89 lines
3.7 KiB
JavaScript
89 lines
3.7 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
|
import AxeBuilder from "@axe-core/playwright";
|
|
|
|
/**
|
|
* Accessibilité des vues RENDUES.
|
|
*
|
|
* Le test statique (apps/web/test/a11y.test.js) analyse le HTML livré : pour le
|
|
* dashboard, ce n'est qu'une coquille vide, puisque tout est construit en
|
|
* JavaScript. C'est donc ici, dans un vrai navigateur après rendu, que
|
|
* l'accessibilité du dashboard est réellement vérifiée — contraste des couleurs
|
|
* compris, ce que jsdom ne sait pas calculer.
|
|
*/
|
|
|
|
const VIEWS = [
|
|
["pilotage", "Pilotage"],
|
|
["bands", "Groupes"],
|
|
["locations", "Localisations"],
|
|
["activity", "Journal"],
|
|
["llm", "LLM & coûts"],
|
|
];
|
|
|
|
async function analyse(page) {
|
|
return new AxeBuilder({ page })
|
|
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
|
.analyze();
|
|
}
|
|
|
|
function describeViolations(results) {
|
|
return results.violations.map((v) =>
|
|
`[${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.map((n) => n.target.join(" ")).join("\n ")}`);
|
|
}
|
|
|
|
test.describe("Accessibilité des vues", () => {
|
|
for (const [view, title] of VIEWS) {
|
|
test(`${title} — aucune violation critique ou sérieuse`, async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.click(`.nav a[data-view="${view}"]`);
|
|
await expect(page.locator(".view-head h1")).toHaveText(title);
|
|
// Laisse les chargements asynchrones peindre leur contenu.
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const results = await analyse(page);
|
|
const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
|
|
expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
|
|
});
|
|
}
|
|
|
|
test("l'écran de connexion est accessible", async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
await page.goto("/");
|
|
await expect(page.locator("#login-screen")).toBeVisible();
|
|
const results = await analyse(page);
|
|
const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
|
|
expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
|
|
});
|
|
|
|
test("la fiche d'un groupe reste accessible une fois ouverte", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.click('.nav a[data-view="bands"]');
|
|
await page.locator('tr[data-ma-id="1"]').click();
|
|
await expect(page.locator(".modal-backdrop .modal")).toBeVisible();
|
|
|
|
const results = await analyse(page);
|
|
const blocking = results.violations.filter((v) => ["critical", "serious"].includes(v.impact));
|
|
expect(describeViolations({ violations: blocking }), describeViolations({ violations: blocking }).join("\n")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test.describe("Navigation au clavier", () => {
|
|
test("on atteint la navigation puis le contenu à la tabulation", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.keyboard.press("Tab");
|
|
const firstFocus = await page.evaluate(() => document.activeElement?.getAttribute("data-view"));
|
|
expect(firstFocus).toBe("pilotage");
|
|
});
|
|
|
|
test("le focus est piégé dans la fiche ouverte", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.click('.nav a[data-view="bands"]');
|
|
await page.locator('tr[data-ma-id="1"]').click();
|
|
await expect(page.locator(".modal-backdrop")).toBeVisible();
|
|
|
|
// Vingt tabulations doivent toutes rester à l'intérieur de la modale :
|
|
// sinon le focus repart derrière le voile, invisible pour l'utilisateur.
|
|
for (let i = 0; i < 20; i++) await page.keyboard.press("Tab");
|
|
const inside = await page.evaluate(() => !!document.activeElement?.closest(".modal-backdrop"));
|
|
expect(inside).toBe(true);
|
|
});
|
|
});
|