metalfrom.eu/apps/web/test/pure.property.test.js
Nicolas Fryder 83ea8030e7
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
test: propriétés généralisées, scraper HTML et couche réseau couverts
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>
2026-08-18 22:09:37 +02:00

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 });
});
});