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