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 <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-07-17 13:07:17 +02:00
co-authored by Claude Opus 4.8
parent b103ab78ec
commit 78153557dd
2 changed files with 416 additions and 0 deletions
+133
View File
@@ -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 <input type=file>
"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;
}
+283
View File
@@ -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<string, unknown> = {}) {
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();
});
});