From 78153557ddc3340c90639a986abfd0645f92d9f7 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 13:07:17 +0200 Subject: [PATCH] feat: compute the mairie form values from a pending event Pure, testable core of the prefill: payload + config -> flat values ready to lay down. What the machine does not know stays empty and is listed in aCompleter, never invented (no fake 00:00, no made-up coordinates). DTEND is exclusive (RFC 5545): a festival on the 18th-20th carries DTEND=21, so the end date must step back one day, in local components for all-day events. Without it, a wrong date reaches a moderated form. Co-Authored-By: Claude Opus 4.8 --- extension/formulaire-valeurs.js | 133 +++++++++++++++ formulaire-valeurs.test.ts | 283 ++++++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 extension/formulaire-valeurs.js create mode 100644 formulaire-valeurs.test.ts diff --git a/extension/formulaire-valeurs.js b/extension/formulaire-valeurs.js new file mode 100644 index 0000000..e7ed1fe --- /dev/null +++ b/extension/formulaire-valeurs.js @@ -0,0 +1,133 @@ +// Cœur métier du pré-remplissage : traduit le payload déposé par la liste en +// valeurs prêtes à poser dans le formulaire mairie. Fonctions PURES, aucun DOM +// (c'est cette partie-là qui est testée ; le poseur, lui, est fin et impur). +// +// ⚠️ Ni `import` ni `export`, comme config.js (cf. DECISIONS.md 2026-07-17). + +const LOCALE = "fr-FR"; +const FUSEAU = "Europe/Paris"; + +// Dupliquée depuis transfert.js (ESM, inatteignable depuis un content script +// classique). Un test de dérive garantit l'égalité — cf. DECISIONS.md 2026-07-17. +const CLE_EVENEMENT_EN_ATTENTE = "evenement-en-attente"; + +// Un événement horodaté porte un vrai instant : la mairie attend l'heure de +// Cugnaux, quel que soit le fuseau de la machine du bénévole. +const FORMAT_DATE_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, + day: "2-digit", + month: "2-digit", + year: "numeric", +}); +const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + +globalThis.EchoFormulaire = { + CLE_EVENEMENT_EN_ATTENTE, + + // payload (transfert.js) + config (config.js) → objet plat, prêt à poser. + // Ce qu'on ignore reste vide et part dans `aCompleter` : jamais d'invention. + calculerValeurs(payload, config) { + const debut = new Date(payload.debut); + const fin = new Date(payload.fin); + + const valeurs = { + organisateur: config.organisateur, + email: config.email, + titre: payload.titre, + description: payload.description, + theme: config.themeDefaut, + tarifs: config.tarifs, + ...(payload.journeeEntiere ? datesFlottantes(debut, fin) : datesHorodatees(debut, fin)), + ...lieuEtCoordonnees(payload.lieu, config.cafe), + }; + + return { ...valeurs, aCompleter: listerACompleter(valeurs) }; + }, +}; + +// Journée entière = date FLOTTANTE (RFC 5545) : composantes locales +// uniquement, jamais l'heure, jamais un fuseau — sinon la date glisse d'un jour +// hors de +02:00 (cf. DECISIONS.md 2026-07-14). +function datesFlottantes(debut, fin) { + const dateDebut = formaterDateLocale(debut); + const dernierJour = veilleDe(fin); + + return { + dateDebut, + // Un seul jour, ou DTEND absent (le parseur pose alors fin === debut). + dateFin: + dernierJour.getTime() <= debut.getTime() ? dateDebut : formaterDateLocale(dernierJour), + // Poser « 00:00 » serait exactement le mensonge que la liste refuse déjà. + heureDebut: null, + heureFin: null, + }; +} + +function datesHorodatees(debut, fin) { + const dateDebut = FORMAT_DATE_PARIS.format(debut); + const heureDebut = heureDe(debut); + + // DTEND absent : le parseur pose fin === debut. Pas d'heure de fin à + // soumettre — mais l'événement a bien lieu ce jour-là. + if (fin.getTime() <= debut.getTime()) { + return { dateDebut, dateFin: dateDebut, heureDebut, heureFin: null }; + } + + // Jour de fin calculé à Paris : un 22:00 → 02:00 change de jour. + return { + dateDebut, + dateFin: FORMAT_DATE_PARIS.format(fin), + heureDebut, + heureFin: heureDe(fin), + }; +} + +// Le formulaire attend deux selects [HH, MM], zéro-paddés. +function heureDe(date) { + return FORMAT_HEURE_PARIS.format(date).split(":"); +} + +function formaterDateLocale(date) { + const jour = String(date.getDate()).padStart(2, "0"); + const mois = String(date.getMonth() + 1).padStart(2, "0"); + return `${jour}/${mois}/${date.getFullYear()}`; +} + +// DTEND est EXCLUSIF (RFC 5545) : un événement du 18 porte DTEND=19, un festival +// du 18 au 20 porte DTEND=21. Sans ce recul d'un jour, on soumet à une mairie +// qui modère une date de fin FAUSSE. Composantes locales, jamais « -24h ». +function veilleDe(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1); +} + +// La majorité des événements n'a pas de LOCATION : ils se tiennent au café. +function lieuEtCoordonnees(lieu, cafe) { + const saisi = lieu.trim(); + if (!saisi) { + return { adresse: cafe.adresse, coordonnees: `${cafe.coords}|${cafe.adresse}` }; + } + + // Lieu ailleurs : on ne sait pas géocoder. Inventer un lat|lng serait pire + // qu'un champ vide → l'humain place le point sur la carte. + return { adresse: saisi, coordonnees: "" }; +} + +// Toujours honnête : ce que la machine ne sait pas est dit, pas masqué. +function listerACompleter({ heureDebut, heureFin, coordonnees, organisateur, email, titre }) { + const aCompleter = [ + "l'image (obligatoire)", // un script ne peut pas remplir un + "le thème", // le calendrier n'en a pas : le défaut est à confirmer + ]; + + if (heureDebut === null || heureFin === null) aCompleter.push("l'heure"); + if (coordonnees === "") aCompleter.push("le lieu exact sur la carte"); + if (!organisateur || !email) aCompleter.push("l'organisateur et l'email (config.local.json)"); + if (titre === "") aCompleter.push("le titre"); + + return aCompleter; +} diff --git a/formulaire-valeurs.test.ts b/formulaire-valeurs.test.ts new file mode 100644 index 0000000..db61620 --- /dev/null +++ b/formulaire-valeurs.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from "bun:test"; +import "./extension/config.js"; +import "./extension/formulaire-valeurs.js"; +import { CLE_EVENEMENT_EN_ATTENTE, serialiserPourFormulaire } from "./extension/transfert.js"; + +const { DEFAUTS } = (globalThis as any).EchoConfig; +const { calculerValeurs } = (globalThis as any).EchoFormulaire; + +// Payload = ce que dépose transfert.js (debut/fin en ISO), pas un Event. +function payload(surcharges: Record = {}) { + return { + uid: "uid-1", + titre: "Atelier", + description: "Venez nombreux", + lieu: "", + debut: new Date("2026-07-18T08:30:00Z").toISOString(), + fin: new Date("2026-07-18T10:30:00Z").toISOString(), + categories: [], + journeeEntiere: false, + ...surcharges, + }; +} + +// Une journée entière est une date FLOTTANTE : fixture en composantes locales, +// jamais un instant absolu (cf. DECISIONS.md 2026-07-14). +function payloadJourneeEntiere(debut: Date, fin: Date, surcharges = {}) { + return payload({ + debut: debut.toISOString(), + fin: fin.toISOString(), + journeeEntiere: true, + ...surcharges, + }); +} + +describe("journée entière", () => { + test("un seul jour (DTEND = J+1) → dateFin === dateDebut", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("10/07/2026"); + expect(valeurs.dateFin).toBe("10/07/2026"); + }); + + test("lit les composantes locales, pas un instant absolu", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("10/07/2026"); + }); + + // Festival 18→21 exclusif : il se termine réellement le 20. Sans ce recul, + // une date FAUSSE part à une mairie qui modère. + test("plusieurs jours (18 → DTEND 21) → dateFin = 20/07/2026", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 18), new Date(2026, 6, 21)), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.dateFin).toBe("20/07/2026"); + }); + + test("DTEND absent (fin === debut) → dateFin === dateDebut", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 10)), + DEFAUTS, + ); + + expect(valeurs.dateFin).toBe("10/07/2026"); + }); + + // Poser « 00:00 » serait le mensonge que presentation.js refuse déjà. + test("aucune heure : poser 00:00 serait un mensonge", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.heureDebut).toBeNull(); + expect(valeurs.heureFin).toBeNull(); + }); + + test("l'heure manquante est signalée", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.aCompleter).toContain("l'heure"); + }); +}); + +describe("événement horodaté", () => { + test("heures à Paris, zéro-paddées", () => { + const valeurs = calculerValeurs( + payload({ + debut: new Date("2026-07-18T07:05:00Z").toISOString(), // 09:05 à Paris + fin: new Date("2026-07-18T19:00:00Z").toISOString(), // 21:00 à Paris + }), + DEFAUTS, + ); + + expect(valeurs.heureDebut).toEqual(["09", "05"]); + expect(valeurs.heureFin).toEqual(["21", "00"]); + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.dateFin).toBe("18/07/2026"); + }); + + test("DTEND absent (fin === debut) → pas d'heure de fin, mais le jour reste vrai", () => { + const debut = new Date("2026-07-18T08:30:00Z").toISOString(); + const valeurs = calculerValeurs(payload({ debut, fin: debut }), DEFAUTS); + + expect(valeurs.heureDebut).toEqual(["10", "30"]); + expect(valeurs.heureFin).toBeNull(); + expect(valeurs.dateFin).toBe("18/07/2026"); + expect(valeurs.aCompleter).toContain("l'heure"); + }); + + // 22:00 → 02:00 : annoncer la fin le même jour serait un mensonge. + test("franchit minuit → dateFin est le lendemain", () => { + const valeurs = calculerValeurs( + payload({ + debut: new Date("2026-07-18T20:00:00Z").toISOString(), // 22:00 à Paris + fin: new Date("2026-07-19T00:00:00Z").toISOString(), // 02:00 le 19 à Paris + }), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.heureDebut).toEqual(["22", "00"]); + expect(valeurs.dateFin).toBe("19/07/2026"); + expect(valeurs.heureFin).toEqual(["02", "00"]); + }); + + test("heures complètes → l'heure n'est pas dans aCompleter", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.aCompleter).not.toContain("l'heure"); + }); +}); + +describe("lieu", () => { + test("lieu vide (cas majoritaire) → adresse et coordonnées du café", () => { + const valeurs = calculerValeurs(payload({ lieu: "" }), DEFAUTS); + + expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux"); + expect(valeurs.coordonnees).toBe( + "43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux", + ); + expect(valeurs.aCompleter).not.toContain("le lieu exact sur la carte"); + }); + + test("lieu en blancs → café", () => { + const valeurs = calculerValeurs(payload({ lieu: " " }), DEFAUTS); + + expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux"); + }); + + // On ne sait pas géocoder : inventer un lat|lng serait pire que le laisser vide. + test("lieu renseigné → pas de coordonnées inventées, manque signalé", () => { + const valeurs = calculerValeurs(payload({ lieu: "Salle des fêtes" }), DEFAUTS); + + expect(valeurs.adresse).toBe("Salle des fêtes"); + expect(valeurs.coordonnees).toBe(""); + expect(valeurs.aCompleter).toContain("le lieu exact sur la carte"); + }); +}); + +describe("champs de config", () => { + test("thème et tarifs viennent de la config", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.theme).toBe("Culture"); + expect(valeurs.tarifs).toBe("Gratuit"); + }); + + test("organisateur et email surchargés sont posés et non signalés", () => { + const config = { ...DEFAUTS, organisateur: "L'Atelier du Huit", email: "a@b.fr" }; + + const valeurs = calculerValeurs(payload(), config); + + expect(valeurs.organisateur).toBe("L'Atelier du Huit"); + expect(valeurs.email).toBe("a@b.fr"); + expect(valeurs.aCompleter).not.toContain("l'organisateur et l'email (config.local.json)"); + }); + + test("sans config.local.json → organisateur/email vides et signalés", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.organisateur).toBe(""); + expect(valeurs.email).toBe(""); + expect(valeurs.aCompleter).toContain("l'organisateur et l'email (config.local.json)"); + }); +}); + +describe("aCompleter", () => { + test("signale toujours l'image et le thème", () => { + const config = { ...DEFAUTS, organisateur: "X", email: "a@b.fr" }; + + const valeurs = calculerValeurs(payload({ lieu: "" }), config); + + expect(valeurs.aCompleter).toContain("l'image (obligatoire)"); + expect(valeurs.aCompleter).toContain("le thème"); + }); + + test("titre vide → signalé", () => { + expect(calculerValeurs(payload({ titre: "" }), DEFAUTS).aCompleter).toContain("le titre"); + }); + + test("titre renseigné → non signalé", () => { + expect(calculerValeurs(payload(), DEFAUTS).aCompleter).not.toContain("le titre"); + }); +}); + +describe("texte", () => { + test("titre et description sont repris tels quels", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.titre).toBe("Atelier"); + expect(valeurs.description).toBe("Venez nombreux"); + }); + + // Cas majoritaire (142/473 ont une DESCRIPTION) : le poseur n'écrira pas "". + test("description vide reste vide", () => { + expect(calculerValeurs(payload({ description: "" }), DEFAUTS).description).toBe(""); + }); +}); + +// La constante est dupliquée dans formulaire-valeurs.js (un content script +// classique ne peut pas importer transfert.js, qui est en ESM). Ce test est le +// garde-fou contre la dérive. +describe("couture avec transfert.js", () => { + test("la clé dupliquée est identique à l'originale", () => { + expect((globalThis as any).EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE).toBe( + CLE_EVENEMENT_EN_ATTENTE, + ); + }); + + // Le contrat de couture vérifié bout en bout, pas supposé. + test("aller-retour réel : serialiserPourFormulaire → calculerValeurs", () => { + const evenement = { + uid: "uid-1", + titre: "Concert", + description: "Au café", + lieu: "", + debut: new Date("2026-07-18T18:00:00Z"), // 20:00 à Paris + fin: new Date("2026-07-18T20:00:00Z"), // 22:00 à Paris + categories: [], + journeeEntiere: false, + }; + + const valeurs = calculerValeurs(serialiserPourFormulaire(evenement), DEFAUTS); + + expect(valeurs.titre).toBe("Concert"); + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.heureDebut).toEqual(["20", "00"]); + expect(valeurs.heureFin).toEqual(["22", "00"]); + expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux"); + }); + + test("aller-retour réel d'une journée entière (date flottante)", () => { + const evenement = { + uid: "uid-2", + titre: "Festival", + description: "", + lieu: "", + debut: new Date(2026, 6, 18), + fin: new Date(2026, 6, 21), + categories: [], + journeeEntiere: true, + }; + + const valeurs = calculerValeurs(serialiserPourFormulaire(evenement), DEFAUTS); + + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.dateFin).toBe("20/07/2026"); + expect(valeurs.heureDebut).toBeNull(); + }); +});