Le dépôt n'avait aucun test, aucun linter, aucune vérification de types. Outillage - ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python - tsc --checkJs sur l'API (pas de TypeScript, juste la vérification) - Vitest : 401 tests JS ; pytest : 43 tests Python - Tests de mutation (Stryker), deux profils : logique pure et API complète - Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook, c'est donc la seule porte de qualité avant la mise en ligne - Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré) Sécurité - Injection SQL authentifiée dans resolve-conflict : `field` était interpolé dans le SET sans allowlist - timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401) - setErrorHandler écrasait tous les 4xx en 500 - .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part alors que leur absence casse toute connexion admin Annulation réelle des crawl_run (migration 014) - L'API posait status='error' sans que le crawler en sache rien : le process continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur) - Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler écrit lui-même status='cancelled' Cohérence géographique (migration 014) - Le trigger 013 supprimait les band_locations sans purger le point dénormalisé - L'édition admin de lat/lon n'atteignait jamais band_locations : la carte ignorait la correction. Override step_order = -1, dans une transaction Corrections - limit/offset NaN → 500 au lieu de 400 - OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie) - listen() sans catch, cast ::text en dur sur les colonnes numériques - /admin/api/logs ne renvoyait pas sa pagination - a11y : sélecteur de langue annoncé comme liste vide (role=option manquant) Nettoyage - apps/web/quizz-site supprimé (sans rapport avec le projet) - Code mort : openModal(), LANG_NAMES, double import, variables inutilisées - .dockerignore ajoutés ; node_modules racine n'était pas gitignoré Co-Authored-By: Claude <noreply@anthropic.com>
149 lines
5.2 KiB
JavaScript
149 lines
5.2 KiB
JavaScript
/**
|
|
* Logique métier pure du site public : normalisation, filtrage, tri.
|
|
*
|
|
* Extraite d'app.js pour être testable. Toutes les fonctions ici sont sans
|
|
* effet de bord et ne lisent AUCUN état global : ce qu'elles filtrent leur est
|
|
* passé en argument. app.js conserve l'état (les Map de cases cochées) et
|
|
* délègue la décision à ce module.
|
|
*
|
|
* Chargé par <script> avant app.js (donc via la globale `BMPure`), et importé
|
|
* en CommonJS par les tests — d'où le pied de fichier. Pas d'étape de build.
|
|
*/
|
|
// `var` volontaire (et non const) : dans un script classique, seul `var` crée
|
|
// une propriété sur l'objet global. app.js s'en sortirait avec `const`, mais
|
|
// pas le harnais de test, qui évalue ce fichier dans un contexte `vm` et lit
|
|
// `sandbox.BMPure` — exactement comme le navigateur lit `window.BMPure`.
|
|
// eslint-disable-next-line no-var
|
|
var BMPure = (function () {
|
|
"use strict";
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? "").replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
|
|
);
|
|
}
|
|
|
|
function norm(s) {
|
|
if (s == null || s === "") return "";
|
|
return String(s).trim();
|
|
}
|
|
|
|
/** Extrait une année d'un nombre ou d'un texte libre ("formé en 1991"). */
|
|
function parseYear(val) {
|
|
if (val == null) return null;
|
|
const n = Number(val);
|
|
if (Number.isFinite(n)) return n;
|
|
const m = String(val).match(/(19|20)\d{2}/);
|
|
return m ? Number(m[0]) : null;
|
|
}
|
|
|
|
function splitThemes(val) {
|
|
if (!val) return [];
|
|
if (Array.isArray(val)) return val.map(norm).filter(Boolean);
|
|
return String(val).split(/[,;/]/).map(norm).filter(Boolean);
|
|
}
|
|
|
|
function matchesQuery(b, q) {
|
|
if (!q) return true;
|
|
const themes = Array.isArray(b.themes) ? b.themes.join(" ") : "";
|
|
const hay = `${b.name} ${b.genre} ${b.status} ${b.country} ${b.location_text} ${themes}`.toLowerCase();
|
|
return hay.includes(q);
|
|
}
|
|
|
|
/**
|
|
* Logique tri-état partagée par les filtres statut / pays / genre.
|
|
*
|
|
* Une valeur ABSENTE de la Map passe (la facette n'existe pas dans le jeu de
|
|
* données courant : on ne veut pas masquer des groupes à cause d'une facette
|
|
* inconnue). Une valeur présente ne passe que si elle est à true.
|
|
* Les valeurs vides retombent sur une clé de repli ("Unknown", "??").
|
|
*/
|
|
function matchesFacet(rawValue, enabledMap, fallbackKey) {
|
|
const v = norm(rawValue);
|
|
const key = v || fallbackKey;
|
|
if (!enabledMap.has(key)) return true;
|
|
return enabledMap.get(key) === true;
|
|
}
|
|
|
|
function matchesMacroGenre(b, macroGenres, macroGenreEnabled, macroActiveCount) {
|
|
if (macroActiveCount === 0) return true;
|
|
const g = norm(b.genre).toLowerCase();
|
|
if (!g) return false;
|
|
return macroGenres.some(
|
|
(m) => macroGenreEnabled.get(m.key) === true && m.terms.some((t) => g.includes(t))
|
|
);
|
|
}
|
|
|
|
function matchesTheme(b, themeEnabled) {
|
|
if (!themeEnabled.size) return true;
|
|
const allOn = Array.from(themeEnabled.values()).every((v) => v === true);
|
|
if (allOn) return true;
|
|
const tokens = Array.isArray(b.themes) ? b.themes : [];
|
|
if (!tokens.length) return false;
|
|
return tokens.some((t) => themeEnabled.get(t) === true);
|
|
}
|
|
|
|
/**
|
|
* Un groupe sans année reste visible tant que l'utilisateur n'a pas resserré
|
|
* la plage ; dès qu'il la resserre, l'absence d'année devient un motif
|
|
* d'exclusion (sinon la sélection « 1990-1995 » ramènerait des groupes sans
|
|
* date, ce qui est incompréhensible).
|
|
*/
|
|
function matchesYear(b, yearFilter, yearRange) {
|
|
if (yearFilter.min == null || yearFilter.max == null) return true;
|
|
if (yearRange.min == null || yearRange.max == null) return true;
|
|
|
|
const isFullRange = yearFilter.min === yearRange.min && yearFilter.max === yearRange.max;
|
|
if (isFullRange) return true;
|
|
|
|
const y = b.formed_year;
|
|
if (!Number.isFinite(y)) return false;
|
|
return y >= yearFilter.min && y <= yearFilter.max;
|
|
}
|
|
|
|
/** Clé d'agrégation des groupes partageant exactement les mêmes coordonnées. */
|
|
function keyFromLatLon(lat, lon) {
|
|
return `${Number(lat).toFixed(6)},${Number(lon).toFixed(6)}`;
|
|
}
|
|
|
|
const byName = (x, y) =>
|
|
(x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" });
|
|
|
|
/** Tri stable, sans effet de bord : renvoie une nouvelle liste. */
|
|
function sortBands(arr, mode) {
|
|
const a = [...arr];
|
|
const byField = (f) => (x, y) =>
|
|
(x[f] || "").localeCompare(y[f] || "", "fr", { sensitivity: "base" }) || byName(x, y);
|
|
|
|
if (mode === "status") a.sort(byField("status"));
|
|
else if (mode === "genre") a.sort(byField("genre"));
|
|
else if (mode === "country") a.sort(byField("country"));
|
|
else if (mode === "year") {
|
|
// Les groupes sans année partent en fin de liste (-1).
|
|
a.sort(
|
|
(x, y) =>
|
|
(Number.isFinite(y.formed_year) ? y.formed_year : -1) -
|
|
(Number.isFinite(x.formed_year) ? x.formed_year : -1)
|
|
);
|
|
} else a.sort(byName);
|
|
|
|
return a;
|
|
}
|
|
|
|
return {
|
|
escapeHtml,
|
|
norm,
|
|
parseYear,
|
|
splitThemes,
|
|
matchesQuery,
|
|
matchesFacet,
|
|
matchesMacroGenre,
|
|
matchesTheme,
|
|
matchesYear,
|
|
keyFromLatLon,
|
|
sortBands,
|
|
};
|
|
})();
|
|
|
|
if (typeof module !== "undefined" && module.exports) module.exports = BMPure;
|