metalfrom.eu/apps/web/site/pure.js
Nicolas Fryder 60074fb015
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(qualité): outillage de test complet, CI locale, annulation réelle des runs
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>
2026-08-18 10:05:40 +02:00

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[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;