BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. band_locations
impose UNIQUE (ma_id, step_order, location_raw) : le doublon était absorbé par
le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les compteurs et n'a
aucun sens métier — une étape ne se déroule pas deux fois au même endroit.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.
SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.
COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. Ce qui est testé, c'est la logique AUTOUR du réseau : quand réessayer,
quand abandonner, comment extraire les données d'une réponse enveloppée par
FlareSolverr.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.
PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
centroïdes. Invariants — jamais d'exception, structure toujours complète,
aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
sans effet de bord ni perte d'éléments, clés de coordonnées.
max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».
Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.
Co-Authored-By: Claude <noreply@anthropic.com>
195 lines
7.4 KiB
JavaScript
195 lines
7.4 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import fc from "fast-check";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import vm from "node:vm";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
/**
|
|
* Propriétés de la logique de filtrage du site public.
|
|
*
|
|
* Ces fonctions décident ce que l'utilisateur voit sur la carte. Un cas limite
|
|
* mal traité ne produit pas d'erreur : il fait simplement disparaître des
|
|
* groupes, ou en affiche qui auraient dû être filtrés. C'est invisible en
|
|
* production, d'où l'intérêt de chercher les contre-exemples plutôt que
|
|
* d'énumérer les cas auxquels on a pensé.
|
|
*/
|
|
const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../site");
|
|
const sandbox = { module: undefined };
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(fs.readFileSync(path.join(SITE, "pure.js"), "utf8"), sandbox);
|
|
const P = sandbox.BMPure;
|
|
|
|
const anyValue = fc.oneof(
|
|
fc.string(), fc.integer(), fc.double(), fc.boolean(),
|
|
fc.constant(null), fc.constant(undefined), fc.constant(""),
|
|
);
|
|
|
|
const band = fc.record({
|
|
ma_id: fc.integer({ min: 0 }),
|
|
name: fc.option(fc.string(), { nil: undefined }),
|
|
genre: fc.option(fc.string(), { nil: undefined }),
|
|
status: fc.option(fc.string(), { nil: undefined }),
|
|
country: fc.option(fc.string(), { nil: undefined }),
|
|
location_text: fc.option(fc.string(), { nil: undefined }),
|
|
themes: fc.option(fc.array(fc.string(), { maxLength: 5 }), { nil: undefined }),
|
|
formed_year: fc.option(fc.integer({ min: 1800, max: 2100 }), { nil: null }),
|
|
});
|
|
|
|
describe("échappement HTML", () => {
|
|
// Tout le rendu passe par innerHTML : un échappement incomplet est une
|
|
// faille XSS, pas un défaut cosmétique.
|
|
it("aucun caractère dangereux ne survit, quelle que soit l'entrée", () => {
|
|
fc.assert(fc.property(anyValue, (v) => {
|
|
const out = P.escapeHtml(v);
|
|
expect(out).not.toMatch(/[<>]/);
|
|
expect(out).not.toMatch(/["']/);
|
|
}), { numRuns: 600 });
|
|
});
|
|
|
|
it("est idempotent en sûreté : ré-échapper ne réintroduit rien", () => {
|
|
fc.assert(fc.property(fc.string(), (v) => {
|
|
expect(P.escapeHtml(P.escapeHtml(v))).not.toMatch(/[<>"']/);
|
|
}), { numRuns: 400 });
|
|
});
|
|
|
|
it("préserve le texte sans caractère spécial", () => {
|
|
fc.assert(fc.property(
|
|
fc.string({ unit: fc.constantFrom(..."abcdéèêöµ0123456789 -_.") }),
|
|
(v) => { expect(P.escapeHtml(v)).toBe(v); }
|
|
), { numRuns: 300 });
|
|
});
|
|
});
|
|
|
|
describe("normalisation", () => {
|
|
it("norm ne lève jamais et rend toujours une chaîne trimée", () => {
|
|
fc.assert(fc.property(anyValue, (v) => {
|
|
const r = P.norm(v);
|
|
expect(typeof r).toBe("string");
|
|
expect(r).toBe(r.trim());
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
it("parseYear rend null ou un nombre fini", () => {
|
|
fc.assert(fc.property(anyValue, (v) => {
|
|
const r = P.parseYear(v);
|
|
expect(r === null || Number.isFinite(r)).toBe(true);
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
it("splitThemes rend toujours un tableau de chaînes non vides", () => {
|
|
fc.assert(fc.property(
|
|
fc.oneof(fc.string(), fc.array(fc.string(), { maxLength: 8 }), fc.constant(null)),
|
|
(v) => {
|
|
const r = P.splitThemes(v);
|
|
expect(Array.isArray(r)).toBe(true);
|
|
expect(r.every((x) => typeof x === "string" && x.length > 0)).toBe(true);
|
|
}
|
|
), { numRuns: 500 });
|
|
});
|
|
});
|
|
|
|
describe("filtrage", () => {
|
|
it("matchesQuery ne lève jamais et rend un booléen", () => {
|
|
fc.assert(fc.property(band, fc.string(), (b, q) => {
|
|
expect(typeof P.matchesQuery(b, q)).toBe("boolean");
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
// Propriété structurante de la recherche : une requête vide n'exclut rien.
|
|
it("une requête vide laisse toujours passer", () => {
|
|
fc.assert(fc.property(band, (b) => {
|
|
expect(P.matchesQuery(b, "")).toBe(true);
|
|
}), { numRuns: 300 });
|
|
});
|
|
|
|
it("la recherche est insensible à la casse", () => {
|
|
fc.assert(fc.property(band, fc.string({ minLength: 1 }), (b, q) => {
|
|
expect(P.matchesQuery(b, q.toLowerCase()))
|
|
.toBe(P.matchesQuery({ ...b }, q.toLowerCase()));
|
|
}), { numRuns: 300 });
|
|
});
|
|
|
|
// Invariant tri-état : une facette ABSENTE de la Map ne doit jamais exclure.
|
|
// C'est ce qui évite de vider la carte quand le jeu de données change.
|
|
it("une facette inconnue laisse toujours passer", () => {
|
|
fc.assert(fc.property(fc.string(), fc.string(), (val, fallback) => {
|
|
expect(P.matchesFacet(val, new Map(), fallback)).toBe(true);
|
|
}), { numRuns: 400 });
|
|
});
|
|
|
|
it("matchesFacet suit exactement la valeur cochée", () => {
|
|
fc.assert(fc.property(
|
|
fc.string({ minLength: 1 }).filter((s) => s.trim() !== ""),
|
|
fc.boolean(),
|
|
(val, coche) => {
|
|
const map = new Map([[val.trim(), coche]]);
|
|
expect(P.matchesFacet(val, map, "??")).toBe(coche);
|
|
}
|
|
), { numRuns: 400 });
|
|
});
|
|
|
|
it("matchesYear ne lève jamais et rend un booléen", () => {
|
|
fc.assert(fc.property(
|
|
band,
|
|
fc.record({ min: fc.option(fc.integer(), { nil: null }), max: fc.option(fc.integer(), { nil: null }) }),
|
|
fc.record({ min: fc.option(fc.integer(), { nil: null }), max: fc.option(fc.integer(), { nil: null }) }),
|
|
(b, filtre, plage) => {
|
|
expect(typeof P.matchesYear(b, filtre, plage)).toBe("boolean");
|
|
}
|
|
), { numRuns: 500 });
|
|
});
|
|
|
|
// Sans cette propriété, ouvrir le site avec le curseur au maximum ferait
|
|
// disparaître tous les groupes sans année.
|
|
it("une plage non resserrée laisse tout passer, année ou pas", () => {
|
|
fc.assert(fc.property(band, fc.integer(), fc.integer(), (b, a, z) => {
|
|
const [min, max] = a <= z ? [a, z] : [z, a];
|
|
expect(P.matchesYear(b, { min, max }, { min, max })).toBe(true);
|
|
}), { numRuns: 400 });
|
|
});
|
|
});
|
|
|
|
describe("tri", () => {
|
|
it("préserve toujours le nombre d'éléments et n'altère pas l'entrée", () => {
|
|
fc.assert(fc.property(
|
|
fc.array(band, { maxLength: 30 }),
|
|
fc.constantFrom("az", "status", "genre", "country", "year", "inconnu"),
|
|
(bands, mode) => {
|
|
const copie = JSON.parse(JSON.stringify(bands));
|
|
const out = P.sortBands(bands, mode);
|
|
expect(out).toHaveLength(bands.length);
|
|
expect(bands).toEqual(copie); // pas d'effet de bord
|
|
expect(new Set(out)).toEqual(new Set(bands)); // pas de perte
|
|
}
|
|
), { numRuns: 300 });
|
|
});
|
|
|
|
it("le tri par année est décroissant, les groupes sans année en dernier", () => {
|
|
fc.assert(fc.property(fc.array(band, { maxLength: 25 }), (bands) => {
|
|
const out = P.sortBands(bands, "year");
|
|
const clefs = out.map((b) => (Number.isFinite(b.formed_year) ? b.formed_year : -1));
|
|
for (let i = 1; i < clefs.length; i++) {
|
|
expect(clefs[i - 1]).toBeGreaterThanOrEqual(clefs[i]);
|
|
}
|
|
}), { numRuns: 300 });
|
|
});
|
|
});
|
|
|
|
describe("clé de coordonnées", () => {
|
|
it("deux coordonnées égales donnent toujours la même clé", () => {
|
|
fc.assert(fc.property(fc.double({ noNaN: true }), fc.double({ noNaN: true }), (lat, lon) => {
|
|
expect(P.keyFromLatLon(lat, lon)).toBe(P.keyFromLatLon(String(lat), String(lon)));
|
|
}), { numRuns: 400 });
|
|
});
|
|
|
|
it("la clé a toujours la forme attendue", () => {
|
|
fc.assert(fc.property(
|
|
fc.double({ min: -90, max: 90, noNaN: true }),
|
|
fc.double({ min: -180, max: 180, noNaN: true }),
|
|
(lat, lon) => {
|
|
expect(P.keyFromLatLon(lat, lon)).toMatch(/^-?\d+\.\d{6},-?\d+\.\d{6}$/);
|
|
}
|
|
), { numRuns: 400 });
|
|
});
|
|
});
|