Merge branch 'verifier-la-publication-sur-l-agenda-mairie-l-e6h6'

This commit is contained in:
Pierre Martin
2026-07-17 20:12:20 +02:00
9 changed files with 667 additions and 3 deletions
+117
View File
@@ -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 });
});
});
+37
View File
@@ -181,3 +181,40 @@ sont le vrai risque, invisible sous `TZ=Europe/Paris`) ; `formulaire-mairie.js`
est fin, impur et **non testé unitairement** — aucun harnais DOM dans le repo, et
happy-dom n'émulerait de toute façon pas Gravity Forms. C'est le test humain qui
tranche.
## 2026-07-17 — [T2] Publication mairie : dérivée, paginée, seam pur/impur
Le statut *publié* est **dérivé** de l'agenda public de la mairie (pas de tag),
en retrouvant l'événement Nextcloud par **date locale de Cugnaux + titre
normalisé**. Il donne aussi le **lien direct** (lu dans la carte).
**Corrections factuelles vérifiées en phase plan :**
- **L'agenda est PAGINÉ** : `.../agenda/page/N/?f=1&…`. Un seul fetch « période »
ne suffit pas → on boucle jusqu'à une page vide (plafond de sécurité anti-boucle).
- **Le slug porte des suffixes WordPress** (« Atelier dessin » →
`/agenda/atelier-dessin-2/`) : on matche sur **date + titre**, jamais sur le
slug. Le lien est **lu** dans la carte, jamais reconstruit.
- `<time datetime="YYYY-MM-DD">` est une **date nue** (pas d'heure) → comparer à
la date locale de Cugnaux du `debut`, jamais à une conversion de fuseau.
Journée entière = date flottante → composantes locales (comme `presentation.js`).
**Seam extraction impure / matching pur** (même logique que
`formulaire-valeurs.js` vs `formulaire-mairie.js`) : `DOMParser` est **absent de
Bun**, donc `extraireCartes(html)` (`agenda-mairie.js`) est impur et **non testé
unitairement** (test humain sur l'agenda réel) ; le **matching** (`publication.js`)
et la boucle de pagination (via impls injectées) sont purs et **testés à fond** —
c'est le vrai risque produit.
**Biais PRÉCISION v1** : match STRICT (date identique + titre normalisé
identique). Titre proche mais non strictement identique le même jour → on
**n'affirme pas** « publié » (faux négatif bénin ; le formulaire modéré rattrape
un doublon éventuel). Dans le doute — agenda injoignable, HTTP ≠ 200, DOMParser
qui lève, structure HTML changée — la liste s'affiche **sans badge** (dégradation
gracieuse, jamais d'exception, aucun faux positif). Deux cartes de même clé
(même jour + même titre normalisé, rare) : la **dernière indexée** l'emporte.
**Périmètre v1 = badge « publié » + lien uniquement.** L'annotation est
**additive** : la liste est rendue immédiatement, l'agenda ne la bloque jamais.
Fetch de l'agenda **sans** `credentials` (public). Aucune modification de
`manifest.json` (`host_permissions` couvre déjà `ville-cugnaux.fr`).
+5 -2
View File
@@ -120,9 +120,12 @@ URL filtrée par mois :
### Structure HTML d'un résultat (confirmée)
Chaque événement publié = `<div class="card card-thumbnail card-event ...">` contenant :
Chaque événement publié = `<article class="card card-thumbnail card-event ...">`
contenant (balise `<article>`, PAS `<div>` : vérifié sur l'agenda réel le
2026-07-17 — matcher sur la classe `.card-event`, agnostique de balise) :
- **Date** : `<time datetime="YYYY-MM-DD">` (ancre de match la plus fiable)
- **Lien direct** : `<a href="https://www.ville-cugnaux.fr/agenda/<slug>/">` — c'est le lien à afficher dans l'extension
- **Lien direct** : `<a href="/agenda/<slug>/">` — parfois **relatif**, à
résoudre contre l'agenda avant affichage ; c'est le lien à afficher dans l'extension
- **Titre** : `.card-title > a`
- **Thème** : classe `event_theme-<slug>`
@@ -0,0 +1,121 @@
# Solution : [T2] Vérifier la publication sur l'agenda mairie (+ lien direct)
## Problème résolu
Un bénévole qui avait poussé un événement vers le formulaire mairie n'avait
**aucun retour** : le formulaire est modéré, la publication arrive plus tard, et
rien dans l'Écho du Huit ne disait « c'est en ligne ». On ne savait pas si un
événement était *à faire* ou *déjà publié*, et pour le retrouver sur le site il
fallait le chercher à la main — au risque de le re-soumettre.
Objectif tenu : l'extension **détecte** qu'un événement Nextcloud est réellement
publié sur l'agenda public de la mairie, affiche un badge **« Publié »** + le
**lien direct** vers la fiche, et **retire le bouton « Créer »** (anti-doublon).
Ce statut *publié* est **dérivé** de l'agenda à la volée (décision 2026-06-29),
jamais un tag écrit dans Nextcloud : c'est la mairie qui fait foi, pas notre
intention. Aucun stockage, aucun `credentials` (agenda public).
## Approche choisie
**Scraping HTML de l'agenda + match strict `date locale Cugnaux + titre
normalisé`, biais précision v1** (approche 1 du brainstorm, variante
conservatrice).
Il n'y a **pas d'API mairie** : la seule source est le HTML public. Le vrai
risque n'était donc pas « aller chercher la page » mais **la fiabilité du
match** — et il est gouverné par une **asymétrie de risque** structurante :
- *Faux positif* (marquer « publié » à tort) → le bénévole croit que c'est fait,
l'événement **ne sera jamais publié**. **Grave.**
- *Faux négatif* (rater un match réel) → au pire il revérifie, et le formulaire
**modéré** rattrape un doublon. **Bénin.**
→ On privilégie la **précision** : match strict (date identique + titre normalisé
identique), et dans le doute on **n'affirme pas** « publié ».
Alternatives écartées :
- **Match par slug dérivé** : la slugification WordPress de la mairie n'est pas
connue et ajoute des suffixes (`atelier-dessin-2`) → égalité stricte cassée,
faux négatifs. On matche sur `date + titre` ; le lien est **lu** dans la carte,
jamais reconstruit.
- **Score de confiance + confirmation humaine** des cas ambigus (approche 3) :
meilleur rappel mais 3ᵉ état d'affichage et un état à mémoriser — or *publié*
n'a pas de tag et l'état local est proscrit. Reporté hors v1 ; la porte reste
ouverte si les faux négatifs se voient à l'usage.
## Décisions clés
- **Seam extraction impure / matching pur.** `DOMParser` est **absent de Bun** :
impossible de tester l'extraction DOM en `bun test`. On isole donc
`extraireCartes(html)` (impur, DOMParser, non testé unitairement — validé par
le test humain) du **matching pur** `publication.js`, testé à fond car c'est le
vrai risque produit. Même logique que `formulaire-valeurs.js` (pur, testé) vs
`formulaire-mairie.js` (impur, DOM). La boucle de pagination reste testable en
**injectant** `fetchImpl`/`extraireCartesImpl`.
- **L'agenda est paginé** (`.../agenda/page/N/?f=1&…`). Un seul fetch « période »
ne suffit pas : on boucle jusqu'à une page vide, avec un **plafond
anti-boucle** (12) au cas où un changement HTML masquerait la page vide.
- **Fuseau : jamais de conversion.** Le `<time datetime="YYYY-MM-DD">` est une
date **nue**. On la compare à la date **locale de Cugnaux** du `debut` :
`Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Paris" })` pour l'horodaté,
**composantes locales** (date flottante, sans fuseau) pour la journée entière —
sinon la date glisse d'un jour (piège déjà documenté).
- **Enrichissement additif.** La liste est rendue **immédiatement** ; le badge est
posé *après*, en tâche de fond (`annoterPublications(...).catch(...)`,
fire-and-forget). Un agenda injoignable, un HTTP ≠ 200, un `DOMParser` qui lève
ou une structure changée → `{ ok:false }` → liste **intacte, sans badge**.
Dégradation gracieuse, jamais d'exception, **aucun faux positif**.
- **Périmètre v1 = badge « Publié » + lien seulement.** On ne pose pas encore la
colonne de statut soumis/ignoré (tâche sœur) ; l'ossature reste extensible.
- **Aucune modif de `manifest.json`** : `host_permissions` couvre déjà
`ville-cugnaux.fr`, et les nouveaux modules sont des ESM importés par la page
d'extension (`DOMParser` y est disponible), pas des content scripts.
## Patterns à réutiliser
- **Seam « impur en périphérie, pur au centre ».** Dès qu'une capacité est
indisponible sous Bun (DOMParser, DOM, Gravity Forms), extraire une fonction
fine impure non testée et pousser toute la logique décidante dans un module pur
testé. Injecter les impls (`fetchImpl`, `extraireCartesImpl`) pour tester
l'orchestration sans réseau ni DOM.
- **Normalisation de titre robuste, en une regex.**
`NFD` + suppression des diacritiques, puis `replace(/[^\p{Letter}\p{Number}\s]/gu, "")`
retire **ponctuation ET emojis d'un coup** (ni lettre ni chiffre), casse basse,
espaces compressés. La ponctuation est retirée **sans espace** pour que
« Atelier B.D. » == « Atelier BD ».
- **Enrichissement DOM additif via `dataset.uid`.** Poser `item.dataset.uid` au
rendu, puis retrouver le `<li>` en phase 2 par
`liste.querySelector([data-uid="${CSS.escape(uid)}"])`. La donnée lente ne
bloque jamais l'affichage.
- **Lien externe défensif.** Résoudre le `href` scrapé via `new URL(href, BASE)`
(gère relatif **et** absolu), **rejeter tout schéma non-http(s)**, injecter en
`textContent` + `target="_blank"` `rel="noopener"`. Href absurde → carte
ignorée.
- **`aujourdhui` / `fetchImpl` injectés obligatoires** pour des tests
déterministes (pas de `new Date()` ni de `fetch` caché dans la logique).
## Pièges à éviter
- **Ne pas présumer la balise du scraping.** L'agenda sert des
`<article class="… card-event">`, **pas** des `<div>`. Un sélecteur
`div.card-event` extrait **0 carte** alors que l'agenda répond **200** : panne
**totale et silencieuse** (pas rattrapée par la dégradation gracieuse, qui ne
se déclenche que sur erreur réseau/HTTP). Trouvée par le test humain sur
l'agenda réel (45 cartes attendues, 0 obtenue). **Leçon : sélecteur agnostique
de balise (`.card-event`), et une hypothèse de structure HTML n'est vérifiée
que confrontée au HTML réel.** C'est exactement la zone que le plan réservait
au test humain — le process a joué son rôle.
- **Ne pas reconstruire le lien depuis le slug** : suffixes WordPress imprévisibles
(`-2`). Le lien se **lit** dans la carte.
- **Ne pas convertir le fuseau** d'une date nue ou d'une journée entière → dérive
d'un jour. Invisible sous `TZ=Europe/Paris`, d'où la variable portée par le
script de test.
- **Ne pas laisser flotter la promesse d'annotation** : `.catch` explicite, sinon
une erreur d'indexation devient une unhandled rejection.
- **Le `console.error "agenda mairie injoignable"` en sortie de test est
attendu** (test de dégradation gracieuse), pas un échec.
## Tags
tags: [firefox-extension, mv3, javascript, web-scraping, dom-parsing, bun-test,
pure-impure-seam, timezone, string-normalization, graceful-degradation,
nextcloud, wordpress]
+106
View File
@@ -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;
}
}
+22
View File
@@ -59,6 +59,28 @@ h1 {
cursor: pointer;
}
.evenement-publication {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.evenement-publie {
align-self: flex-end;
padding: 0.15rem 0.5rem;
border-radius: 0.75rem;
background: #1a7f37;
color: #fff;
font-size: 0.8rem;
font-weight: 600;
}
.evenement-lien-mairie {
font-size: 0.85rem;
}
#etat {
margin-bottom: 1.5rem;
}
+58 -1
View File
@@ -2,6 +2,8 @@ import { recupererFluxCalendrier } from "./nextcloud.js";
import { parserEvenements } from "./evenements.js";
import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js";
import { deposerEvenement } from "./transfert.js";
import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js";
import { indexerCartes, trouverPublication } from "./publication.js";
const URL_NEXTCLOUD = "https://atelier-huit.frama.space/";
@@ -49,6 +51,8 @@ function rendreEvenements(evenements) {
function rendreEvenement(evenement) {
const item = document.createElement("li");
item.className = "evenement";
// Ancre pour retrouver le <li> lors de l'annotation « publié » (phase 2).
item.dataset.uid = evenement.uid;
const infos = document.createElement("div");
infos.className = "evenement-infos";
@@ -135,10 +139,63 @@ async function charger() {
// « Illegal invocation » dans le navigateur.
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
if (res.ok) {
rendreEvenements(trierParDebut(parserEvenements(res.flux, new Date())));
const evenements = trierParDebut(parserEvenements(res.flux, new Date()));
rendreEvenements(evenements);
// Enrichissement additif : la liste est déjà rendue, l'agenda ne la
// bloque jamais. Un échec de scraping laisse la liste intacte, sans badge.
// Fire-and-forget : le `.catch` garantit qu'aucune erreur imprévue
// (indexation, annotation) ne devienne une rejection non gérée.
annoterPublications(evenements).catch((erreur) =>
console.error("Écho du Huit : annotation des publications interrompue.", erreur),
);
} else {
rendreErreur(res.erreur);
}
}
// Phase 2 : marque « publié » les événements retrouvés sur l'agenda mairie et y
// pose leur lien direct. Dégradation gracieuse : agenda injoignable ou structure
// changée → on ne touche à rien (aucun faux positif, biais précision v1).
async function annoterPublications(evenements) {
if (evenements.length === 0) return;
const periode = calculerPeriode(evenements, new Date());
// Agenda public : fetch SANS credentials.
const res = await recupererAgendaPublie((u, o) => fetch(u, o), extraireCartes, periode);
if (!res.ok) return;
const index = indexerCartes(res.cartes);
for (const evenement of evenements) {
const publication = trouverPublication(evenement, index);
if (!publication.publie) continue;
const item = liste.querySelector(`[data-uid="${CSS.escape(evenement.uid)}"]`);
if (item) annoterItem(item, publication.lien);
}
}
// Badge « Publié » + lien direct ; le bouton « Créer » est retiré (anti-doublon :
// un événement déjà publié n'a pas à être re-soumis). textContent uniquement,
// le lien vient d'une source externe.
function annoterItem(item, lien) {
item.querySelector(".evenement-creer")?.remove();
const publication = document.createElement("div");
publication.className = "evenement-publication";
const badge = document.createElement("span");
badge.className = "evenement-publie";
badge.textContent = "Publié";
const voir = document.createElement("a");
voir.className = "evenement-lien-mairie";
voir.href = lien;
voir.target = "_blank";
voir.rel = "noopener";
voir.textContent = "Voir sur le site mairie";
publication.append(badge, voir);
item.append(publication);
}
charger();
+66
View File
@@ -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)}`;
}
+135
View File
@@ -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/",
});
});
});