Le viewport est élargi de 20 % pour précharger les bords, sans aucun bornage. Au-delà d'un certain dézoom, la bbox sortait des limites géographiques : zoom 2 (monde) -> -164,-88.4,144,110.4 /api/clusters rejette en 400 toute coordonnée hors [-90,90] / [-180,180]. Vérifié contre la vraie validation : les zooms 3 et 4 passent, les zooms 1 et 2 renvoient « Coordinates out of range ». L'utilisateur qui dézoomait ne voyait donc plus aucun point, sans le moindre message. Leaflet renvoie par ailleurs des longitudes au-delà de ±180 quand la vue chevauche plusieurs copies du monde ; le bornage seul produirait alors un intervalle vide, que l'API rejette aussi. On retombe dans ce cas sur la bande complète, que la vue couvre de toute façon. Le calcul part dans pure.js, conformément à la convention du fichier, avec des cas limites et une propriété fast-check : aucune vue Leaflet imaginable ne doit produire une bbox que l'API refuse. Vérifié que cette propriété échoue bien sur l'ancienne implémentation (contre-exemple : east = 180.00000000000003). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
8.8 KiB
JavaScript
230 lines
8.8 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 });
|
|
});
|
|
});
|
|
|
|
describe("viewportBbox — propriétés", () => {
|
|
// Les contraintes exactes de /api/clusters. Toute vue Leaflet imaginable doit
|
|
// produire une bbox que l'API accepte : sinon la carte se vide sans message.
|
|
const acceptable = ({ minLon, minLat, maxLon, maxLat }) =>
|
|
minLat >= -90 && maxLat <= 90 &&
|
|
minLon >= -180 && maxLon <= 180 &&
|
|
minLon < maxLon && minLat < maxLat;
|
|
|
|
it("aucune vue ne produit une bbox rejetée par l'API", () => {
|
|
fc.assert(
|
|
fc.property(
|
|
// Latitudes dans la plage Web Mercator, longitudes volontairement
|
|
// au-delà de ±180 : Leaflet les renvoie ainsi quand la vue chevauche
|
|
// plusieurs copies du monde.
|
|
fc.double({ min: -85, max: 85, noNaN: true }),
|
|
fc.double({ min: -85, max: 85, noNaN: true }),
|
|
fc.double({ min: -540, max: 540, noNaN: true }),
|
|
fc.double({ min: -540, max: 540, noNaN: true }),
|
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
|
(a, b, c, d, f) => {
|
|
const south = Math.min(a, b);
|
|
const north = Math.max(a, b);
|
|
const west = Math.min(c, d);
|
|
const east = Math.max(c, d);
|
|
// Une vue dégénérée (hauteur ou largeur nulle) n'existe pas sur une
|
|
// carte réellement affichée.
|
|
fc.pre(north > south && east > west);
|
|
return acceptable(P.viewportBbox({ south, west, north, east }, f));
|
|
}
|
|
),
|
|
{ numRuns: 500 }
|
|
);
|
|
});
|
|
});
|