La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que TOUS les filtres de /admin/api/bands étaient non
testés (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Ils pouvaient être inopérants sans
que rien ne le signale.
Bugs trouvés et corrigés
1. crawler_pending détruit silencieusement (backend)
Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les
seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET
supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait
ses conflits de genre, de pays et de localisation, sans trace.
Corrigé en suivant la convention de /resolve-conflict : retrait des clés
arbitrées (`crawler_pending - ARRAY[...]`).
2. Filtre genre non borné (backend)
q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif
ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer.
country et status sont bornés au passage, par cohérence.
3. Chips inutilisables après une alerte (frontend)
Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une
alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre
revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé
(replaceState, qui ne déclenche pas hashchange).
4. Minuteur d'auto-refresh orphelin (frontend)
Le minuteur était armé APRÈS le chargement des données. Quitter la vue
pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le
Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un
autre onglet, indéfiniment. Résolu par un jeton de rendu.
Commentaire dangereux corrigé
La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire
annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences
invisibles : /api/clusters ne retient que ('done','country_only') — le point
n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file
('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé
par un test.
Test instable supprimé
Les interceptions Playwright lisaient la réponse après une possible navigation
(« Response has been disposed ») : un échec sur trois exécutions, sans rapport
avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est
pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions.
Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27)
- bandFilters.test.js : les 3 drapeaux × 2 polarités × présence/absence,
bornes de q, tri, pagination, alignement des paramètres liés
- conflicts.test.js : effet du PATCH sur crawler_pending, casts par type,
allowlist, cohérence avec resolve-conflict
- tools.spec.js : tri, pagination, arbitrage de conflit, annulations,
filtres LLM, cycle de vie des vues
Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug
réintroduit les fait échouer.
Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non
en réduisant le périmètre muté.
Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
|
import { patchJson } from "./helpers.js";
|
|
|
|
/**
|
|
* Couverture de chaque OUTIL du dashboard, de bout en bout.
|
|
*
|
|
* admin.spec.js décrit les parcours principaux ; ce fichier complète les
|
|
* commandes qui n'y étaient pas exercées — tri, pagination, arbitrage de
|
|
* conflit, annulations, filtres LLM — pour qu'aucun bouton ne reste sans test.
|
|
*/
|
|
|
|
test.describe("Cycle de vie des vues", () => {
|
|
// Régression : le minuteur d'auto-refresh était armé APRÈS le chargement.
|
|
// Quitter la vue pendant celui-ci laissait le Pilotage interroger quatre
|
|
// endpoints toutes les 15 s depuis un autre onglet, indéfiniment.
|
|
test("quitter une vue pendant son chargement n'arme pas de minuteur orphelin", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.route("**/admin/api/live", async (route) => {
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
await route.continue();
|
|
});
|
|
|
|
await page.click('.nav a[data-view="pilotage"]');
|
|
await page.waitForTimeout(150); // le chargement est en cours
|
|
await page.click('.nav a[data-view="bands"]');
|
|
await page.waitForTimeout(1200); // le chargement du Pilotage se termine
|
|
|
|
await expect(page.locator(".view-head h1")).toHaveText("Groupes");
|
|
// La vue Groupes n'a pas d'auto-refresh : aucun minuteur ne doit subsister.
|
|
expect(await page.evaluate("state.timer")).toBeNull();
|
|
});
|
|
|
|
test("changer de vue arrête l'auto-refresh de la précédente", async ({ page }) => {
|
|
await page.goto("/#/activity");
|
|
await expect(page.locator(".view-head h1")).toHaveText("Journal");
|
|
expect(await page.evaluate("state.timer")).not.toBeNull();
|
|
|
|
await page.click('.nav a[data-view="bands"]');
|
|
await expect(page.locator(".view-head h1")).toHaveText("Groupes");
|
|
expect(await page.evaluate("state.timer")).toBeNull();
|
|
});
|
|
});
|
|
|
|
test.describe("Groupes — tri et pagination", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.click('.nav a[data-view="bands"]');
|
|
});
|
|
|
|
test("cliquer un en-tête trie, recliquer inverse le sens", async ({ page }) => {
|
|
const asc = page.waitForRequest((r) => r.url().includes("sort=name") && r.url().includes("dir=asc"));
|
|
await page.locator('.th-sort[data-sort="name"]').click();
|
|
await asc;
|
|
|
|
const desc = page.waitForRequest((r) => r.url().includes("sort=name") && r.url().includes("dir=desc"));
|
|
await page.locator('.th-sort[data-sort="name"]').click();
|
|
await desc;
|
|
});
|
|
|
|
test("changer de colonne repart en ordre croissant", async ({ page }) => {
|
|
await page.locator('.th-sort[data-sort="name"]').click();
|
|
await page.locator('.th-sort[data-sort="name"]').click(); // desc
|
|
const req = page.waitForRequest((r) => r.url().includes("sort=country") && r.url().includes("dir=asc"));
|
|
await page.locator('.th-sort[data-sort="country"]').click();
|
|
await req;
|
|
});
|
|
|
|
test("la pagination est désactivée quand il n'y a qu'une page", async ({ page }) => {
|
|
await expect(page.locator('#b-pager [data-page="prev"]')).toBeDisabled();
|
|
await expect(page.locator('#b-pager [data-page="next"]')).toBeDisabled();
|
|
});
|
|
|
|
test("la page suivante est demandée avec le bon numéro", async ({ page }) => {
|
|
await patchJson(page, "**/admin/api/bands?*", (body) => {
|
|
body.total = 500; // force plusieurs pages
|
|
});
|
|
await page.click('.nav a[data-view="pilotage"]');
|
|
await page.click('.nav a[data-view="bands"]');
|
|
|
|
const req = page.waitForRequest((r) => r.url().includes("page=2"));
|
|
await page.locator('#b-pager [data-page="next"]').click();
|
|
await req;
|
|
});
|
|
|
|
test("« Tout effacer » remet la recherche et les filtres à zéro", async ({ page }) => {
|
|
await page.fill("#b-q", "mayhem");
|
|
await page.locator('.chip[data-chip="has_lat"]').click();
|
|
await expect(page.locator('.chip[data-chip="has_lat"]')).toHaveAttribute("aria-pressed", "true");
|
|
|
|
await page.click("#b-clear");
|
|
await expect(page.locator("#b-q")).toHaveValue("");
|
|
await expect(page.locator('.chip[data-chip="has_lat"]')).toHaveAttribute("aria-pressed", "false");
|
|
});
|
|
|
|
test("les filtres avancés sont transmis à l'API", async ({ page }) => {
|
|
await page.click("#b-advanced-toggle");
|
|
await page.fill("#b-country", "NO");
|
|
await page.fill("#b-genre", "black");
|
|
const req = page.waitForRequest((r) => r.url().includes("country=NO") && r.url().includes("genre=black"));
|
|
await page.click("#b-apply");
|
|
await req;
|
|
});
|
|
});
|
|
|
|
test.describe("Groupes — arbitrage des conflits crawler", () => {
|
|
/** Un groupe dont le crawler propose d'autres valeurs. */
|
|
async function withConflicts(page) {
|
|
// Seul le GET est modifié : le PATCH de sauvegarde doit continuer à
|
|
// atteindre la vraie API.
|
|
await patchJson(page, "**/admin/api/bands/1", (body) => {
|
|
if (body.item) body.item.crawler_pending = { genre: "True Black Metal", country: "SE" };
|
|
}, (route) => route.request().method() === "GET");
|
|
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();
|
|
}
|
|
|
|
test("les conflits sont mis en avant avec les deux valeurs", async ({ page }) => {
|
|
await withConflicts(page);
|
|
const conflicts = page.locator(".modal-section.conflicts");
|
|
await expect(conflicts).toBeVisible();
|
|
await expect(conflicts).toContainText("genre");
|
|
await expect(conflicts).toContainText("Black Metal"); // valeur actuelle
|
|
await expect(conflicts).toContainText("True Black Metal"); // proposition MA
|
|
});
|
|
|
|
test("« Garder » envoie keep_mine pour le bon champ", async ({ page }) => {
|
|
await withConflicts(page);
|
|
const req = page.waitForRequest((r) => r.url().includes("resolve-conflict") && r.method() === "POST");
|
|
await page.locator('[data-conflict-action="keep_mine"][data-field="genre"]').click();
|
|
expect(JSON.parse((await req).postData())).toEqual({ field: "genre", action: "keep_mine" });
|
|
});
|
|
|
|
test("« Accepter MA » envoie accept_crawler", async ({ page }) => {
|
|
await withConflicts(page);
|
|
const req = page.waitForRequest((r) => r.url().includes("resolve-conflict") && r.method() === "POST");
|
|
await page.locator('[data-conflict-action="accept_crawler"][data-field="country"]').click();
|
|
expect(JSON.parse((await req).postData())).toEqual({ field: "country", action: "accept_crawler" });
|
|
});
|
|
|
|
test("un groupe sans conflit n'affiche pas la section", 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-section.conflicts")).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
test.describe("Pilotage — actions restantes", () => {
|
|
test("l'action d'une alerte appelle bien son endpoint", async ({ page }) => {
|
|
await page.goto("/#/pilotage");
|
|
const req = page.waitForRequest(
|
|
(r) => r.url().includes("/admin/api/locations/reset-errors") && r.method() === "POST");
|
|
await page.locator(".alert").filter({ hasText: "erreur de géocodage" })
|
|
.getByRole("button", { name: "Tout remettre en file" }).click();
|
|
await req;
|
|
});
|
|
|
|
test("l'alerte d'enrichissement déclenche le job correspondant", async ({ page }) => {
|
|
await page.goto("/#/pilotage");
|
|
const req = page.waitForRequest(
|
|
(r) => r.url().includes("/admin/api/job-triggers") && r.method() === "POST");
|
|
await page.locator(".alert").filter({ hasText: "jamais enrichis" })
|
|
.getByRole("button", { name: "Lancer l'enrichissement" }).click();
|
|
expect(JSON.parse((await req).postData())).toEqual({ job_type: "enrich" });
|
|
});
|
|
|
|
test("le crawl complet demande confirmation avant d'être lancé", async ({ page }) => {
|
|
await page.goto("/#/pilotage");
|
|
const dialog = new Promise((resolve) => page.once("dialog", (d) => { resolve(d.message()); d.dismiss(); }));
|
|
await page.click('[data-job="full_crawl"]');
|
|
expect(await dialog).toMatch(/plusieurs heures/i);
|
|
// Refus : aucune demande ne doit partir.
|
|
await expect(page.locator("#p-job-feedback")).toBeEmpty();
|
|
});
|
|
|
|
test("une demande en attente peut être retirée de la file", async ({ page }) => {
|
|
await patchJson(page, "**/admin/api/live", (body) => {
|
|
body.pending_jobs = [{ id: 7, job_type: "enrich", status: "pending",
|
|
requested_by: "nico", created_at: new Date().toISOString() }];
|
|
});
|
|
await page.goto("/#/pilotage");
|
|
await expect(page.locator("[data-cancel-job]")).toBeVisible();
|
|
|
|
page.once("dialog", (d) => d.accept());
|
|
const req = page.waitForRequest(
|
|
(r) => r.url().includes("/admin/api/job-triggers/7/cancel") && r.method() === "POST");
|
|
await page.click("[data-cancel-job]");
|
|
await req;
|
|
});
|
|
|
|
test("l'échec d'une action est affiché sans casser la vue", async ({ page }) => {
|
|
await page.goto("/#/pilotage");
|
|
await page.route("**/admin/api/job-triggers", (route) =>
|
|
route.fulfill({ status: 500, json: { ok: false, error: "Erreur création job" } }));
|
|
await page.click('[data-job="enrich"]');
|
|
await expect(page.locator("#p-job-feedback")).toContainText("Erreur création job");
|
|
await expect(page.locator("#p-job-feedback")).toHaveClass(/err/);
|
|
await expect(page.locator("#p-health")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Journal — annulation depuis le détail", () => {
|
|
test("un traitement en cours peut être arrêté depuis son détail", async ({ page }) => {
|
|
await patchJson(page, "**/admin/api/activity?*", (body) => {
|
|
body.items[0].status = "running";
|
|
});
|
|
await page.goto("/#/activity");
|
|
await page.locator("#a-table tbody tr").first().click();
|
|
|
|
const modal = page.locator(".modal-backdrop .modal");
|
|
await expect(modal.locator("#am-cancel")).toBeVisible();
|
|
|
|
page.once("dialog", (d) => d.accept());
|
|
const req = page.waitForRequest(
|
|
(r) => r.url().includes("/admin/api/crawl-runs/5/cancel") && r.method() === "POST");
|
|
await modal.locator("#am-cancel").click();
|
|
await req;
|
|
});
|
|
|
|
test("un traitement terminé n'offre pas d'annulation", async ({ page }) => {
|
|
await page.goto("/#/activity");
|
|
await page.locator("#a-table tbody tr").first().click();
|
|
await expect(page.locator("#am-cancel")).toHaveCount(0);
|
|
});
|
|
|
|
test("le filtre « Erreurs seules » est une bascule", async ({ page }) => {
|
|
await page.goto("/#/activity");
|
|
const req = page.waitForRequest((r) => r.url().includes("status=error"));
|
|
await page.locator('[data-astatus="error"]').click();
|
|
await req;
|
|
|
|
const off = page.waitForRequest(
|
|
(r) => r.url().includes("/admin/api/activity") && !r.url().includes("status=error"));
|
|
await page.locator('[data-astatus="error"]').click();
|
|
await off;
|
|
});
|
|
});
|
|
|
|
test.describe("LLM — filtres", () => {
|
|
test.beforeEach(async ({ page }) => { await page.goto("/#/llm"); });
|
|
|
|
test("le filtre par modèle est transmis", async ({ page }) => {
|
|
await page.selectOption("#llm-model", "llama-3.1-8b-instant");
|
|
const req = page.waitForRequest((r) => r.url().includes("model=llama-3.1-8b-instant"));
|
|
await page.click("#llm-apply");
|
|
await req;
|
|
});
|
|
|
|
test("« sans résultat uniquement » est transmis", async ({ page }) => {
|
|
await page.check("#llm-null");
|
|
const req = page.waitForRequest((r) => r.url().includes("only_null=1"));
|
|
await page.click("#llm-apply");
|
|
await req;
|
|
});
|
|
|
|
test("la recherche part sur Entrée", async ({ page }) => {
|
|
const req = page.waitForRequest((r) => r.url().includes("q=bayonne"));
|
|
await page.fill("#llm-q", "bayonne");
|
|
await page.press("#llm-q", "Enter");
|
|
await req;
|
|
});
|
|
|
|
test("cliquer un groupe depuis la liste LLM ouvre sa fiche", async ({ page }) => {
|
|
await page.locator("#llm-list [data-open-band]").first().click();
|
|
await expect(page.locator(".modal-backdrop .modal h3")).toContainText("Gojira");
|
|
});
|
|
});
|
|
|
|
test.describe("Localisations — pagination et recherche", () => {
|
|
test.beforeEach(async ({ page }) => { await page.goto("/#/locations"); });
|
|
|
|
test("la recherche combine texte et pays", async ({ page }) => {
|
|
await page.fill("#l-q", "kolbotn");
|
|
await page.fill("#l-country", "NO");
|
|
const req = page.waitForRequest((r) => r.url().includes("q=kolbotn") && r.url().includes("country=NO"));
|
|
await page.click("#l-search");
|
|
await req;
|
|
});
|
|
|
|
test("le filtre « Toutes » retire le paramètre de statut", async ({ page }) => {
|
|
const req = page.waitForRequest(
|
|
(r) => r.url().includes("/admin/api/locations") && !r.url().includes("status="));
|
|
await page.locator('.chip[data-status=""]').click();
|
|
await req;
|
|
});
|
|
|
|
test("une erreur de l'API est affichée dans le tableau", async ({ page }) => {
|
|
await page.route("**/admin/api/locations?*", (route) =>
|
|
route.fulfill({ status: 500, json: { ok: false, error: "Erreur liste localisations" } }));
|
|
await page.locator('.chip[data-status="manual"]').click();
|
|
await expect(page.locator("#l-table .err-box")).toContainText("Erreur liste localisations");
|
|
});
|
|
});
|