feat: derive event publication status from the mairie agenda
Pure matching (date locale Cugnaux + titre normalisé) and paginated transport, both fully tested. HTML extraction via DOMParser stays impure and untested (absent from Bun), like formulaire-mairie.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a7b780876b
commit
6c4f4219f1
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
calculerPeriode,
|
||||
construireUrlPage,
|
||||
recupererAgendaPublie,
|
||||
} from "./extension/agenda-mairie.js";
|
||||
|
||||
function evenement(surcharges: Record<string, unknown> = {}) {
|
||||
return {
|
||||
uid: "uid-1",
|
||||
titre: "Atelier",
|
||||
debut: new Date("2026-07-10T16:00:00Z"),
|
||||
fin: new Date("2026-07-10T18:00:00Z"),
|
||||
journeeEntiere: false,
|
||||
...surcharges,
|
||||
};
|
||||
}
|
||||
|
||||
describe("calculerPeriode", () => {
|
||||
test("debut = mois d'aujourd'hui ; fin = mois du dernier début", () => {
|
||||
const evenements = [
|
||||
evenement({ debut: new Date("2026-07-20T10:00:00Z") }),
|
||||
evenement({ debut: new Date("2026-09-05T10:00:00Z") }),
|
||||
evenement({ debut: new Date("2026-08-01T10:00:00Z") }),
|
||||
];
|
||||
const aujourdhui = new Date("2026-07-17T09:00:00Z");
|
||||
|
||||
expect(calculerPeriode(evenements, aujourdhui)).toEqual({
|
||||
debut: "07/2026",
|
||||
fin: "09/2026",
|
||||
});
|
||||
});
|
||||
|
||||
test("liste vide → période bornée au mois courant", () => {
|
||||
const aujourdhui = new Date("2026-07-17T09:00:00Z");
|
||||
expect(calculerPeriode([], aujourdhui)).toEqual({ debut: "07/2026", fin: "07/2026" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("construireUrlPage", () => {
|
||||
const periode = { debut: "07/2026", fin: "09/2026" };
|
||||
|
||||
test("page 1 → URL de base sans segment /page/", () => {
|
||||
const url = construireUrlPage(1, periode);
|
||||
expect(url).toBe(
|
||||
"https://www.ville-cugnaux.fr/mes-loisirs/agenda/?f=1&theme=&date=periode&date_debut=07%2F2026&date_fin=09%2F2026&public=",
|
||||
);
|
||||
});
|
||||
|
||||
test("page 3 → segment /page/3/", () => {
|
||||
const url = construireUrlPage(3, periode);
|
||||
expect(url).toContain("/mes-loisirs/agenda/page/3/?f=1");
|
||||
expect(url).toContain("date_debut=07%2F2026");
|
||||
expect(url).toContain("date_fin=09%2F2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recupererAgendaPublie", () => {
|
||||
const periode = { debut: "07/2026", fin: "09/2026" };
|
||||
const reponseOk = (html: string) => Promise.resolve({ ok: true, text: () => Promise.resolve(html) });
|
||||
|
||||
test("3 pages non vides puis une vide → cartes agrégées, arrêt sur la vide", async () => {
|
||||
const pages = [
|
||||
[{ date: "2026-07-01", titre: "A", lien: "l1" }],
|
||||
[{ date: "2026-07-02", titre: "B", lien: "l2" }],
|
||||
[{ date: "2026-07-03", titre: "C", lien: "l3" }],
|
||||
[],
|
||||
];
|
||||
const fetchImpl = (url: string) => reponseOk(url);
|
||||
let appel = 0;
|
||||
const extraire = () => pages[appel++];
|
||||
|
||||
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: true,
|
||||
cartes: [
|
||||
{ date: "2026-07-01", titre: "A", lien: "l1" },
|
||||
{ date: "2026-07-02", titre: "B", lien: "l2" },
|
||||
{ date: "2026-07-03", titre: "C", lien: "l3" },
|
||||
],
|
||||
});
|
||||
expect(appel).toBe(4); // 3 pages pleines + la vide qui stoppe
|
||||
});
|
||||
|
||||
test("plafond de sécurité respecté (extracteur toujours non vide)", async () => {
|
||||
let appels = 0;
|
||||
const fetchImpl = (url: string) => {
|
||||
appels++;
|
||||
return reponseOk(url);
|
||||
};
|
||||
const extraire = () => [{ date: "2026-07-01", titre: "A", lien: "l" }];
|
||||
|
||||
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(appels).toBe(12); // plafond
|
||||
});
|
||||
|
||||
test("fetch qui throw → { ok:false } (jamais d'exception)", async () => {
|
||||
const fetchImpl = () => Promise.reject(new Error("réseau"));
|
||||
const extraire = () => [];
|
||||
|
||||
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||
|
||||
expect(res).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("réponse HTTP non-ok (500) → { ok:false }", async () => {
|
||||
const fetchImpl = () => Promise.resolve({ ok: false, text: () => Promise.resolve("") });
|
||||
const extraire = () => [{ date: "2026-07-01", titre: "A", lien: "l" }];
|
||||
|
||||
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||
|
||||
expect(res).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Transport + pagination + extraction DOM de l'agenda public de la mairie.
|
||||
// Séparation nette : les parties PURES (calculerPeriode, construireUrlPage) et
|
||||
// la boucle (recupererAgendaPublie, testée via impls injectées) sont testables ;
|
||||
// extraireCartes utilise DOMParser (absent de Bun) et n'est PAS testé
|
||||
// unitairement — validé par le test humain, comme formulaire-mairie.js.
|
||||
|
||||
const BASE = "https://www.ville-cugnaux.fr/mes-loisirs/agenda/";
|
||||
const FUSEAU = "Europe/Paris";
|
||||
|
||||
// Sécurité anti-boucle : l'agenda ne renverra jamais 12 pages pleines pour nos
|
||||
// événements, mais une structure HTML changée pourrait empêcher la détection de
|
||||
// page vide → on plafonne pour ne jamais boucler indéfiniment.
|
||||
const PLAFOND_PAGES = 12;
|
||||
|
||||
const FORMAT_MOIS = new Intl.DateTimeFormat("fr-FR", {
|
||||
timeZone: FUSEAU,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
});
|
||||
|
||||
// Fenêtre à interroger : du mois courant au mois du dernier événement à venir.
|
||||
// `aujourdhui` injecté (obligatoire) pour des tests déterministes.
|
||||
export function calculerPeriode(evenements, aujourdhui) {
|
||||
const dernier = evenements.reduce(
|
||||
(max, ev) => (ev.debut.getTime() > max.getTime() ? ev.debut : max),
|
||||
aujourdhui,
|
||||
);
|
||||
return { debut: moisAnnee(aujourdhui), fin: moisAnnee(dernier) };
|
||||
}
|
||||
|
||||
function moisAnnee(date) {
|
||||
const parts = FORMAT_MOIS.formatToParts(date);
|
||||
const mois = parts.find((p) => p.type === "month").value;
|
||||
const annee = parts.find((p) => p.type === "year").value;
|
||||
return `${mois}/${annee}`;
|
||||
}
|
||||
|
||||
// Page 1 = URL de base ; page N>1 = segment /page/N/. Le « MM/YYYY » est encodé
|
||||
// en « MM%2FYYYY » par encodeURIComponent.
|
||||
export function construireUrlPage(n, periode) {
|
||||
const segment = n > 1 ? `page/${n}/` : "";
|
||||
const requete =
|
||||
`?f=1&theme=&date=periode` +
|
||||
`&date_debut=${encodeURIComponent(periode.debut)}` +
|
||||
`&date_fin=${encodeURIComponent(periode.fin)}` +
|
||||
`&public=`;
|
||||
return `${BASE}${segment}${requete}`;
|
||||
}
|
||||
|
||||
// Parcourt les pages jusqu'à une page vide (ou le plafond). Toute erreur (throw,
|
||||
// réponse non-ok) → { ok:false } : la liste s'affiche alors sans badge
|
||||
// (dégradation gracieuse). `fetchImpl` et `extraireCartesImpl` injectés pour
|
||||
// tester la boucle sans DOM ni réseau.
|
||||
export async function recupererAgendaPublie(fetchImpl, extraireCartesImpl, periode) {
|
||||
const cartes = [];
|
||||
try {
|
||||
for (let n = 1; n <= PLAFOND_PAGES; n++) {
|
||||
const res = await fetchImpl(construireUrlPage(n, periode));
|
||||
if (!res.ok) {
|
||||
console.error(`Écho du Huit : agenda mairie a répondu ${res.status}.`);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const cartesPage = extraireCartesImpl(await res.text());
|
||||
if (cartesPage.length === 0) break;
|
||||
cartes.push(...cartesPage);
|
||||
}
|
||||
return { ok: true, cartes };
|
||||
} catch (erreur) {
|
||||
console.error("Écho du Huit : agenda mairie injoignable.", erreur);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Extraction DOM (IMPUR : DOMParser). Chaque carte incomplète (date, titre ou
|
||||
// lien manquant) est IGNORÉE — jamais d'exception. Le lien est LU dans la carte,
|
||||
// jamais reconstruit (le slug porte des suffixes WordPress, ex. « -2 »).
|
||||
export function extraireCartes(html) {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const cartes = [];
|
||||
// Sélecteur agnostique de balise : l'agenda sert des <article class="… card-event">,
|
||||
// pas des <div>. `div.card-event` ne matcherait rien (panne silencieuse).
|
||||
for (const carte of doc.querySelectorAll(".card-event")) {
|
||||
const date = carte.querySelector("time[datetime]")?.getAttribute("datetime");
|
||||
const ancre = carte.querySelector(".card-title a");
|
||||
const titre = ancre?.textContent?.trim();
|
||||
const lien = normaliserLien(ancre?.getAttribute("href"));
|
||||
if (!date || !titre || !lien) continue;
|
||||
cartes.push({ date, titre, lien });
|
||||
}
|
||||
return cartes;
|
||||
}
|
||||
|
||||
// L'agenda sert le href brut, parfois RELATIF (« /agenda/slug/ ») : résolu contre
|
||||
// la page d'extension (moz-extension://), ce serait un lien mort. On le résout
|
||||
// contre l'agenda et on rejette tout schéma autre que http(s) — le lien vient
|
||||
// d'une source externe et finit en `href`. Href absurde → carte ignorée.
|
||||
function normaliserLien(href) {
|
||||
if (!href) return null;
|
||||
try {
|
||||
const url = new URL(href, BASE);
|
||||
return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Matching événement Nextcloud ↔ carte de l'agenda mairie. Fonctions PURES,
|
||||
// sans DOM (testables) : c'est le cœur du risque produit (précision du match,
|
||||
// fuseau, normalisation du titre). Biais PRÉCISION v1 : dans le doute, on
|
||||
// n'affirme pas « publié » (faux négatif bénin, cf. plan / DECISIONS.md).
|
||||
|
||||
const FUSEAU = "Europe/Paris";
|
||||
|
||||
// en-CA rend directement "YYYY-MM-DD" : même forme que le <time datetime> de
|
||||
// l'agenda, comparable sans reformatage.
|
||||
const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: FUSEAU,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
// Forme stable d'un titre : ce qui doit matcher malgré les écarts cosmétiques
|
||||
// entre le calendrier et la mairie. On RETIRE la ponctuation (sans la remplacer
|
||||
// par une espace) pour que « Atelier B.D. » == « Atelier BD ».
|
||||
export function normaliserTitre(titre) {
|
||||
return titre
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/gu, "") // diacritiques isolés par NFD
|
||||
.replace(/[^\p{Letter}\p{Number}\s]/gu, "") // ponctuation ET emojis
|
||||
.toLowerCase()
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Le <time datetime> de l'agenda est une date NUE (pas d'heure) : on la compare
|
||||
// à la date locale de Cugnaux du début, jamais à une conversion de fuseau.
|
||||
export function dateLocaleCugnaux(evenement) {
|
||||
return evenement.journeeEntiere
|
||||
? formaterDateFlottante(evenement.debut)
|
||||
: FORMAT_JOUR_PARIS.format(evenement.debut);
|
||||
}
|
||||
|
||||
// Journée entière = date flottante (RFC 5545) : composantes LOCALES, jamais un
|
||||
// fuseau, sinon la date glisse d'un jour (cf. presentation.js / DECISIONS.md).
|
||||
function formaterDateFlottante(date) {
|
||||
const annee = date.getFullYear();
|
||||
const mois = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const jour = String(date.getDate()).padStart(2, "0");
|
||||
return `${annee}-${mois}-${jour}`;
|
||||
}
|
||||
|
||||
// Index des cartes par `date|titre normalisé` → lien. Deux cartes de même clé
|
||||
// (même jour + même titre normalisé, rare) : la DERNIÈRE indexée l'emporte.
|
||||
export function indexerCartes(cartes) {
|
||||
const index = new Map();
|
||||
for (const carte of cartes) {
|
||||
index.set(cle(carte.date, carte.titre), carte.lien);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// Match STRICT : même date locale ET titre normalisé identique. Rien d'autre ne
|
||||
// vaut « publié » en v1.
|
||||
export function trouverPublication(evenement, index) {
|
||||
const lien = index.get(cle(dateLocaleCugnaux(evenement), evenement.titre));
|
||||
return lien ? { publie: true, lien } : { publie: false };
|
||||
}
|
||||
|
||||
function cle(date, titre) {
|
||||
return `${date}|${normaliserTitre(titre)}`;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
dateLocaleCugnaux,
|
||||
indexerCartes,
|
||||
normaliserTitre,
|
||||
trouverPublication,
|
||||
} from "./extension/publication.js";
|
||||
|
||||
// Fixtures = Event littéraux (comme presentation.test.ts) : le parsing vit
|
||||
// ailleurs. Ici on éprouve le matching, vrai risque produit.
|
||||
function evenement(surcharges: Record<string, unknown> = {}) {
|
||||
return {
|
||||
uid: "uid-1",
|
||||
titre: "Atelier dessin",
|
||||
description: "",
|
||||
lieu: "",
|
||||
debut: new Date("2026-07-10T16:00:00Z"),
|
||||
fin: new Date("2026-07-10T18:00:00Z"),
|
||||
categories: [],
|
||||
journeeEntiere: false,
|
||||
...surcharges,
|
||||
};
|
||||
}
|
||||
|
||||
function carte(surcharges: Record<string, unknown> = {}) {
|
||||
return {
|
||||
date: "2026-07-10",
|
||||
titre: "Atelier dessin",
|
||||
lien: "https://www.ville-cugnaux.fr/agenda/atelier-dessin-2/",
|
||||
...surcharges,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normaliserTitre", () => {
|
||||
test("accents supprimés (NFD)", () => {
|
||||
expect(normaliserTitre("Soirée jazz")).toBe("soiree jazz");
|
||||
});
|
||||
|
||||
test("casse repliée", () => {
|
||||
expect(normaliserTitre("ATELIER Dessin")).toBe("atelier dessin");
|
||||
});
|
||||
|
||||
test("apostrophes droites et typographiques retirées", () => {
|
||||
expect(normaliserTitre("L'atelier")).toBe(normaliserTitre("L’atelier"));
|
||||
expect(normaliserTitre("L'atelier")).toBe("latelier");
|
||||
});
|
||||
|
||||
test("tirets simples et cadratins retirés", () => {
|
||||
expect(normaliserTitre("ciné-club")).toBe(normaliserTitre("ciné–club"));
|
||||
});
|
||||
|
||||
test("deux-points et ponctuation retirés sans insérer d'espace", () => {
|
||||
// « Atelier B.D. » == « Atelier BD » une fois la ponctuation retirée.
|
||||
expect(normaliserTitre("Atelier B.D.")).toBe(normaliserTitre("Atelier BD"));
|
||||
});
|
||||
|
||||
test("emojis retirés", () => {
|
||||
expect(normaliserTitre("Fête \u{1F389} du village")).toBe("fete du village");
|
||||
});
|
||||
|
||||
test("espaces multiples compressés et bords élagués", () => {
|
||||
expect(normaliserTitre(" Atelier dessin ")).toBe("atelier dessin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateLocaleCugnaux", () => {
|
||||
test("horodaté à 22:00 Paris (été) → même jour", () => {
|
||||
// 20:00 UTC = 22:00 Paris l'été.
|
||||
const ev = evenement({ debut: new Date("2026-07-10T20:00:00Z") });
|
||||
expect(dateLocaleCugnaux(ev)).toBe("2026-07-10");
|
||||
});
|
||||
|
||||
test("horodaté à 00:30 Paris = 22:30 UTC la veille → jour de Cugnaux, pas UTC", () => {
|
||||
const ev = evenement({ debut: new Date("2026-07-09T22:30:00Z") });
|
||||
expect(dateLocaleCugnaux(ev)).toBe("2026-07-10");
|
||||
});
|
||||
|
||||
test("journée entière → composantes locales, sans glissement de fuseau", () => {
|
||||
// Date flottante : composantes locales (cf. presentation.js / DECISIONS.md).
|
||||
const ev = evenement({ debut: new Date(2026, 6, 10), journeeEntiere: true });
|
||||
expect(dateLocaleCugnaux(ev)).toBe("2026-07-10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("trouverPublication", () => {
|
||||
test("même date + titre normalisé identique → publié avec le bon lien", () => {
|
||||
const index = indexerCartes([carte()]);
|
||||
expect(trouverPublication(evenement(), index)).toEqual({
|
||||
publie: true,
|
||||
lien: "https://www.ville-cugnaux.fr/agenda/atelier-dessin-2/",
|
||||
});
|
||||
});
|
||||
|
||||
test("même titre mais date différente → non publié", () => {
|
||||
const index = indexerCartes([carte({ date: "2026-07-11" })]);
|
||||
expect(trouverPublication(evenement(), index)).toEqual({ publie: false });
|
||||
});
|
||||
|
||||
test("même date mais titre différent → non publié", () => {
|
||||
const index = indexerCartes([carte({ titre: "Concert" })]);
|
||||
expect(trouverPublication(evenement(), index)).toEqual({ publie: false });
|
||||
});
|
||||
|
||||
test("titre ne différant que par accents/emoji/casse/ponctuation → match", () => {
|
||||
const index = indexerCartes([
|
||||
carte({ titre: "Atelier Dessin \u{1F3A8}", lien: "https://x/agenda/a/" }),
|
||||
]);
|
||||
expect(trouverPublication(evenement({ titre: "atelier dessin" }), index)).toEqual({
|
||||
publie: true,
|
||||
lien: "https://x/agenda/a/",
|
||||
});
|
||||
});
|
||||
|
||||
test("titre proche mais non strictement identique le même jour → non publié (biais précision v1)", () => {
|
||||
const index = indexerCartes([carte({ titre: "Atelier peinture libre" })]);
|
||||
expect(trouverPublication(evenement({ titre: "Atelier peinture" }), index)).toEqual({
|
||||
publie: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("index vide (agenda injoignable) → non publié", () => {
|
||||
expect(trouverPublication(evenement(), new Map())).toEqual({ publie: false });
|
||||
});
|
||||
|
||||
test("deux cartes le même jour, titres distincts → la bonne est choisie", () => {
|
||||
const index = indexerCartes([
|
||||
carte({ titre: "Concert", lien: "https://x/agenda/concert/" }),
|
||||
carte({ titre: "Atelier dessin", lien: "https://x/agenda/atelier-dessin/" }),
|
||||
]);
|
||||
expect(trouverPublication(evenement(), index)).toEqual({
|
||||
publie: true,
|
||||
lien: "https://x/agenda/atelier-dessin/",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user