metalfrom.eu/apps/web/test/pure.test.js
Nicolas FRYDER 582fdc5451 fix(web): la carte se vidait en silence dès qu'on dézoomait au niveau monde
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>
2026-08-22 13:55:54 +02:00

382 lines
14 KiB
JavaScript

import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
// pure.js est un script classique chargé par <script>, pas un module ES.
// On l'évalue exactement comme le fait le navigateur — ça teste au passage que
// le fichier livré expose bien la globale BMPure attendue par app.js.
const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../site");
const source = fs.readFileSync(path.join(SITE, "pure.js"), "utf8");
const sandbox = { module: undefined };
vm.createContext(sandbox);
vm.runInContext(source, sandbox, { filename: "pure.js" });
const P = sandbox.BMPure;
describe("chargement du script", () => {
it("expose la globale BMPure attendue par app.js", () => {
expect(P).toBeDefined();
});
it("expose toutes les fonctions auxquelles app.js délègue", () => {
const appJs = fs.readFileSync(path.join(SITE, "app.js"), "utf8");
const used = [...appJs.matchAll(/BMPure\.(\w+)/g)].map((m) => m[1]);
expect(used.length).toBeGreaterThan(0);
for (const fn of new Set(used)) {
expect(typeof P[fn], `BMPure.${fn} utilisé dans app.js`).toBe("function");
}
});
});
const band = (over = {}) => ({
ma_id: 1, name: "Mayhem", genre: "Black Metal", status: "Active",
country: "NO", location_text: "Oslo", themes: [], formed_year: 1984, ...over,
});
describe("escapeHtml", () => {
it("neutralise les caractères d'injection HTML", () => {
expect(P.escapeHtml('<script>alert("x")</script>'))
.toBe("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;");
});
it("échappe l'apostrophe (attribut délimité par des quotes simples)", () => {
expect(P.escapeHtml("l'enfer")).toBe("l&#039;enfer");
});
it("échappe l'esperluette en premier (pas de double échappement inversé)", () => {
expect(P.escapeHtml("&lt;")).toBe("&amp;lt;");
});
it("null et undefined donnent une chaîne vide, pas 'null'", () => {
expect(P.escapeHtml(null)).toBe("");
expect(P.escapeHtml(undefined)).toBe("");
});
it("laisse le texte normal intact", () => {
expect(P.escapeHtml("Mötley Crüe")).toBe("Mötley Crüe");
});
});
describe("norm", () => {
it.each([
[null, ""], [undefined, ""], ["", ""], [" Oslo ", "Oslo"], [42, "42"],
])("norm(%s) === %s", (input, expected) => {
expect(P.norm(input)).toBe(expected);
});
// 0 est une valeur légitime : ne doit pas être avalé comme falsy.
it("conserve le zéro", () => {
expect(P.norm(0)).toBe("0");
});
});
describe("parseYear", () => {
it("accepte un nombre", () => {
expect(P.parseYear(1991)).toBe(1991);
});
it("extrait une année d'un texte libre", () => {
expect(P.parseYear("formé en 1991")).toBe(1991);
expect(P.parseYear("1984-1993")).toBe(1984);
});
it("ne reconnaît que les siècles plausibles", () => {
expect(P.parseYear("an 1234")).toBeNull();
expect(P.parseYear("2015")).toBe(2015);
});
it("renvoie null pour null/undefined", () => {
expect(P.parseYear(null)).toBeNull();
expect(P.parseYear(undefined)).toBeNull();
});
it("renvoie null pour un texte sans année", () => {
expect(P.parseYear("inconnu")).toBeNull();
});
});
describe("splitThemes", () => {
it("découpe sur virgule, point-virgule et slash", () => {
expect(P.splitThemes("Death, Satan; War/Occult"))
.toEqual(["Death", "Satan", "War", "Occult"]);
});
it("accepte déjà un tableau", () => {
expect(P.splitThemes([" a ", "", "b"])).toEqual(["a", "b"]);
});
it("élimine les fragments vides", () => {
expect(P.splitThemes("a,,;/b")).toEqual(["a", "b"]);
});
it("valeur vide → tableau vide", () => {
expect(P.splitThemes("")).toEqual([]);
expect(P.splitThemes(null)).toEqual([]);
});
});
describe("matchesQuery", () => {
it("une requête vide laisse tout passer", () => {
expect(P.matchesQuery(band(), "")).toBe(true);
});
it.each([
["nom", "mayhem"], ["genre", "black"], ["statut", "active"],
["pays", "no"], ["lieu", "oslo"],
])("cherche dans le %s", (_champ, q) => {
expect(P.matchesQuery(band(), q)).toBe(true);
});
it("cherche dans les thèmes", () => {
expect(P.matchesQuery(band({ themes: ["Satanism", "Death"] }), "satan")).toBe(true);
});
it("ne matche pas ce qui est absent", () => {
expect(P.matchesQuery(band(), "burzum")).toBe(false);
});
it("est insensible à la casse", () => {
expect(P.matchesQuery(band({ name: "MAYHEM" }), "mayhem")).toBe(true);
});
it("ne plante pas sur des champs manquants", () => {
expect(P.matchesQuery({ name: "X" }, "x")).toBe(true);
expect(P.matchesQuery({}, "zzz")).toBe(false);
});
});
describe("matchesFacet — logique tri-état", () => {
it("une facette absente de la Map passe (facette inconnue ≠ décochée)", () => {
expect(P.matchesFacet("Active", new Map(), "Unknown")).toBe(true);
});
it("une facette cochée passe", () => {
expect(P.matchesFacet("Active", new Map([["Active", true]]), "Unknown")).toBe(true);
});
it("une facette décochée ne passe pas", () => {
expect(P.matchesFacet("Active", new Map([["Active", false]]), "Unknown")).toBe(false);
});
it("une valeur vide retombe sur la clé de repli", () => {
const map = new Map([["Unknown", false]]);
expect(P.matchesFacet("", map, "Unknown")).toBe(false);
expect(P.matchesFacet(null, map, "Unknown")).toBe(false);
expect(P.matchesFacet(" ", map, "Unknown")).toBe(false);
});
it("la clé de repli cochée laisse passer les valeurs vides", () => {
expect(P.matchesFacet(null, new Map([["Unknown", true]]), "Unknown")).toBe(true);
});
it("les espaces autour de la valeur sont ignorés", () => {
expect(P.matchesFacet(" Active ", new Map([["Active", false]]), "Unknown")).toBe(false);
});
});
describe("matchesMacroGenre", () => {
const MACROS = [
{ key: "Black", terms: ["black"] },
{ key: "Doom", terms: ["doom", "stoner"] },
];
it("aucun macro-genre actif → tout passe", () => {
expect(P.matchesMacroGenre(band(), MACROS, new Map(), 0)).toBe(true);
});
it("matche sur un terme du macro-genre actif", () => {
const enabled = new Map([["Black", true]]);
expect(P.matchesMacroGenre(band({ genre: "Black Metal" }), MACROS, enabled, 1)).toBe(true);
});
it("matche n'importe lequel des termes d'un macro-genre", () => {
const enabled = new Map([["Doom", true]]);
expect(P.matchesMacroGenre(band({ genre: "Stoner Rock" }), MACROS, enabled, 1)).toBe(true);
});
it("ne matche pas un macro-genre inactif", () => {
const enabled = new Map([["Doom", true], ["Black", false]]);
expect(P.matchesMacroGenre(band({ genre: "Black Metal" }), MACROS, enabled, 1)).toBe(false);
});
// Quand un filtre de genre est actif, un groupe sans genre ne peut pas y
// répondre : il doit disparaître, pas passer par défaut.
it("un groupe sans genre est exclu dès qu'un macro-genre est actif", () => {
const enabled = new Map([["Black", true]]);
expect(P.matchesMacroGenre(band({ genre: "" }), MACROS, enabled, 1)).toBe(false);
expect(P.matchesMacroGenre(band({ genre: null }), MACROS, enabled, 1)).toBe(false);
});
it("est insensible à la casse du genre", () => {
const enabled = new Map([["Black", true]]);
expect(P.matchesMacroGenre(band({ genre: "BLACK METAL" }), MACROS, enabled, 1)).toBe(true);
});
});
describe("matchesTheme", () => {
it("aucun thème connu → tout passe", () => {
expect(P.matchesTheme(band(), new Map())).toBe(true);
});
it("tous les thèmes cochés → tout passe, même sans thème", () => {
const all = new Map([["War", true], ["Death", true]]);
expect(P.matchesTheme(band({ themes: [] }), all)).toBe(true);
});
it("filtre actif : un groupe sans thème est exclu", () => {
const partial = new Map([["War", true], ["Death", false]]);
expect(P.matchesTheme(band({ themes: [] }), partial)).toBe(false);
});
it("filtre actif : garde les groupes ayant au moins un thème coché", () => {
const partial = new Map([["War", true], ["Death", false]]);
expect(P.matchesTheme(band({ themes: ["War"] }), partial)).toBe(true);
expect(P.matchesTheme(band({ themes: ["Death"] }), partial)).toBe(false);
});
});
describe("matchesYear", () => {
const range = { min: 1970, max: 2020 };
it("aucun filtre posé → tout passe", () => {
expect(P.matchesYear(band(), { min: null, max: null }, range)).toBe(true);
});
it("plage non initialisée → tout passe", () => {
expect(P.matchesYear(band(), { min: 1990, max: 2000 }, { min: null, max: null })).toBe(true);
});
// Plage complète = pas de filtrage : les groupes sans année restent visibles.
it("plage complète → tout passe, y compris sans année", () => {
expect(P.matchesYear(band({ formed_year: null }), { ...range }, range)).toBe(true);
});
it("plage resserrée : garde ce qui est dedans", () => {
const f = { min: 1980, max: 1990 };
expect(P.matchesYear(band({ formed_year: 1984 }), f, range)).toBe(true);
expect(P.matchesYear(band({ formed_year: 1980 }), f, range)).toBe(true);
expect(P.matchesYear(band({ formed_year: 1990 }), f, range)).toBe(true);
});
it("plage resserrée : exclut ce qui est dehors", () => {
const f = { min: 1980, max: 1990 };
expect(P.matchesYear(band({ formed_year: 1979 }), f, range)).toBe(false);
expect(P.matchesYear(band({ formed_year: 1991 }), f, range)).toBe(false);
});
it("plage resserrée : exclut les groupes sans année", () => {
const f = { min: 1980, max: 1990 };
expect(P.matchesYear(band({ formed_year: null }), f, range)).toBe(false);
expect(P.matchesYear(band({ formed_year: undefined }), f, range)).toBe(false);
});
});
describe("keyFromLatLon", () => {
it("produit la même clé pour des coordonnées identiques", () => {
expect(P.keyFromLatLon(59.9139, 10.7522)).toBe(P.keyFromLatLon("59.9139", "10.7522"));
});
it("distingue des coordonnées différentes", () => {
expect(P.keyFromLatLon(59.9139, 10.7522)).not.toBe(P.keyFromLatLon(59.9140, 10.7522));
});
it("normalise à 6 décimales", () => {
expect(P.keyFromLatLon(1, 2)).toBe("1.000000,2.000000");
expect(P.keyFromLatLon(1.00000049, 2)).toBe("1.000000,2.000000");
});
});
describe("sortBands", () => {
const bands = [
band({ name: "Zyklon", genre: "Death", country: "NO", status: "Split-up", formed_year: 1998 }),
band({ name: "abbath", genre: "Black", country: "DE", status: "Active", formed_year: 2005 }),
band({ name: "Mayhem", genre: "Black", country: "NO", status: "Active", formed_year: 1984 }),
];
it("ne modifie pas le tableau d'entrée", () => {
const copy = [...bands];
P.sortBands(bands, "az");
expect(bands).toEqual(copy);
});
it("tri alphabétique insensible à la casse et aux accents", () => {
expect(P.sortBands(bands, "az").map((b) => b.name)).toEqual(["abbath", "Mayhem", "Zyklon"]);
});
it("mode inconnu retombe sur le tri alphabétique", () => {
expect(P.sortBands(bands, "nimportequoi").map((b) => b.name))
.toEqual(["abbath", "Mayhem", "Zyklon"]);
});
it("tri par année, décroissant", () => {
expect(P.sortBands(bands, "year").map((b) => b.formed_year)).toEqual([2005, 1998, 1984]);
});
it("les groupes sans année finissent en dernier", () => {
const withNull = [...bands, band({ name: "Sans", formed_year: null })];
expect(P.sortBands(withNull, "year").at(-1).name).toBe("Sans");
});
it.each(["status", "genre", "country"])("tri par %s, départagé par le nom", (mode) => {
const sorted = P.sortBands(bands, mode);
for (let i = 1; i < sorted.length; i++) {
const prev = (sorted[i - 1][mode] || "").toLowerCase();
const cur = (sorted[i][mode] || "").toLowerCase();
expect(prev <= cur).toBe(true);
}
});
it("départage bien à valeur égale", () => {
// abbath et Mayhem sont tous deux Black/Active → départagés par nom
expect(P.sortBands(bands, "genre").map((b) => b.name)).toEqual(["abbath", "Mayhem", "Zyklon"]);
});
it("liste vide", () => {
expect(P.sortBands([], "az")).toEqual([]);
});
});
describe("viewportBbox", () => {
const vue = (south, west, north, east) => ({ south, west, north, east });
// Contraintes reprises telles quelles de /api/clusters (app.js) : toute
// violation y renvoie un 400, et la carte reste vide sans message.
const estAcceptableParLAPI = ({ minLon, minLat, maxLon, maxLat }) =>
minLat >= -90 && maxLat <= 90 && minLon >= -180 && maxLon <= 180 &&
minLon < maxLon && minLat < maxLat;
it("élargit la vue pour précharger les bords", () => {
const b = P.viewportBbox(vue(40, 0, 50, 10), 0.2);
expect(b).toEqual({ minLon: -2, minLat: 38, maxLon: 12, maxLat: 52 });
});
it("borne la latitude au niveau monde", () => {
// Sans bornage : maxLat = 110.4 -> 400 « Coordinates out of range ».
const b = P.viewportBbox(vue(-60, -120, 82, 100), 0.2);
expect(b.maxLat).toBe(90);
expect(b.minLat).toBeGreaterThanOrEqual(-90);
expect(estAcceptableParLAPI(b)).toBe(true);
});
it("retombe sur la bande complète quand la vue chevauche plusieurs mondes", () => {
const b = P.viewportBbox(vue(40, 340, 60, 400), 0.2);
expect(b.minLon).toBe(-180);
expect(b.maxLon).toBe(180);
expect(estAcceptableParLAPI(b)).toBe(true);
});
it("produit toujours une bbox que l'API accepte", () => {
const vues = [
vue(38, -10, 60.5, 30), // Europe, vue initiale
vue(25, -25, 70, 45), // dézoom intermédiaire
vue(-60, -120, 82, 100), // monde
vue(-85, -180, 85, 180), // dézoom maximal
vue(-85, -400, 85, 400), // plusieurs copies du monde
vue(0, 0, 0.001, 0.001), // zoom extrême
];
for (const v of vues) {
expect(estAcceptableParLAPI(P.viewportBbox(v, 0.2))).toBe(true);
}
});
});