Merge branch 'cli-de-selection-des-evenements-oear'

This commit is contained in:
Pierre Martin
2026-07-17 12:47:11 +02:00
14 changed files with 813 additions and 34 deletions
+12
View File
@@ -38,6 +38,18 @@ runtime à installer) :
> ⚠️ Un module temporaire **disparaît au redémarrage de Firefox** : il faut le
> recharger via `about:debugging` à chaque session (comportement de dev attendu).
> 🔴 **Après toute modification de `manifest.json` (permission ajoutée…) :
> *Décharger* puis *Charger* le module. « Recharger » et F5 ne suffisent pas.**
> Firefox relit les **scripts** de la page à chaque ouverture, mais le
> **manifest** seulement **à l'installation** : on se retrouve sinon avec le code
> le plus récent et les **permissions figées de l'ancien**.
> Symptôme : `browser.<api> is undefined` alors que le manifest déclare bien la
> permission. Piège de **dev uniquement** (en module signé, les permissions
> déclarées sont accordées d'office) — mais il a déjà coûté deux passes de test.
>
> Pense aussi à **fermer les anciens onglets de la liste** avant de recharger :
> une page orpheline rend les clics inertes.
### Vérifier l'extension
```sh
+56
View File
@@ -70,3 +70,59 @@ ici.
Gravity Forms n'accepte `?input_X=` que si chaque champ est configuré
« population dynamique » côté mairie (improbable). On remplit donc le **DOM par
id** (ids confirmés dans `RECHERCHE.md`).
## 2026-07-14 — [T1] Liste : couture page → content script
La liste dépose l'événement choisi dans `storage.local` **puis** ouvre le
formulaire mairie (`extension/transfert.js`). Ce que la tâche sœur
(« formater les données pour le formulaire mairie ») doit lire :
**Clé unique `evenement-en-attente`, PAS `evenement:<uid>`** (révise la couture
« clé = uid » annoncée par la tâche sœur, qui avait un trou). Le content script
s'exécute sur le site de la mairie : il n'a **aucun canal** pour apprendre un
`uid`, le fragment d'URL ayant été écarté (pas de pollution d'une URL tierce).
Une clé connue d'avance le rend lisible sans transporter quoi que ce soit.
- Pas de résidus : un seul slot, écrasé au clic suivant (le dernier clic gagne —
limite assumée, le geste réel est séquentiel et le formulaire est modéré).
- **On n'efface pas à la lecture** : le formulaire survit à un rechargement.
- Le dépôt est `await` **avant** `tabs.create` : sinon le content script peut
lire avant l'écriture.
**`debut`/`fin` sérialisés en ISO 8601** (le payload n'est pas un `Event`). On ne
parie pas sur le structured clone : le contrat reste vrai quel que soit le
moteur, le content script réhydrate avec `new Date(iso)`.
⚠️ **Si `journeeEntiere` est vrai, l'instant ISO n'est PAS significatif.** Une
`DTSTART;VALUE=DATE` est une date *flottante* (RFC 5545) : elle n'a pas de
fuseau. ical.js la matérialise à **minuit local**, donc l'ISO produit dépend de
la machine (`2026-07-10` devient `...T15:00:00Z` depuis Tokyo). Le consommateur
doit **tester `journeeEntiere` d'abord** et n'en lire que les composantes
**locales** (`getFullYear`/`getMonth`/`getDate`) — jamais l'heure, jamais une
conversion de fuseau. C'est pour la même raison que la liste formate les
journées entières **sans** `timeZone` (`presentation.js`) : forcer `Europe/Paris`
faisait glisser la date au jour précédent depuis Tokyo ou Auckland.
⚠️ **`fin` est un DTEND EXCLUSIF (RFC 5545) — ne pas l'afficher/soumettre tel quel.**
Le payload porte la valeur brute du parseur. Pour une **journée entière**, un
événement du 18 porte `fin` = le **19**, et un festival du 18 au 20 porte `fin` =
le **21** : le dernier jour réel est **`fin` moins un jour** (composantes locales,
jamais « -24h »). Concerne directement la tâche sœur, qui remplit `#input_6_32`
(Date fin) **depuis DTEND** : sans ce recul d'un jour, elle soumettra une date
**fausse** à la mairie. La liste applique déjà la règle (`presentation.js`,
`veilleDe`). Autre cas mesuré : **`DTEND` absent → le parseur pose `fin === debut`**
(il n'y a alors pas de plage à afficher, ni de fin à soumettre).
**`TZ=Europe/Paris` sur `bun test` est PORTEUR — ne pas le retirer.** Le runner
de Bun force **`TZ=UTC`** quand `TZ` est absente (vérifié : `bun -e` lit le
fuseau système, le runner non). Sans le pin, les tests ne tournent donc pas dans
le fuseau des bénévoles. Corollaire pour l'écriture des tests : un fixture de
date flottante (journée entière, `DTSTART` sans `Z` ni `TZID`) se construit en
**composantes locales** (`new Date(2026, 6, 10)`), jamais en instant absolu
(`new Date("2026-07-10T00:00:00+02:00")`) — ce dernier n'est juste que sur une
machine à +02:00 et ment partout ailleurs.
**Défaut café = affichage seulement ; `lieu` stocké brut** (`""` si absent). Les
deux avals du parseur ont chacun le leur : la liste affiche l'adresse pour ne pas
montrer une ligne vide (cas majoritaire), le content script décide s'il pose
**aussi** les coordonnées (`input_18`). Écrire l'adresse dans le payload
détruirait le signal « pas de lieu » dont il a besoin.
@@ -0,0 +1,207 @@
# Solution : [T1] Page liste : afficher les événements + bouton Créer
## Problème résolu
Câbler le parseur (`evenements.js`, figé en T1 précédente mais **branché nulle
part**) à la page : afficher les événements futurs triés, et poser la **couture**
page → content script pour le pré-remplissage du formulaire mairie (tâche sœur).
Sous un ticket d'apparence UI se cachaient deux décisions structurantes :
1. **Où vivent le fetch et le parsing ?** L'énoncé disait « passage background
(CalDAV+parsing) → page », mais le code réel fetchait **déjà dans la page**.
La question n'était donc pas *comment transporter*, mais **faut-il introduire
ce transport**.
2. **Que transmet le bouton « Créer » ?** Le remplissage appartient à la tâche
sœur ; T1 devait poser la couture, pas le remplissage.
Le coût d'une erreur ici n'était pas dans T1 mais dans T2 (statuts CalDAV) et
dans la tâche content script, qui héritent du chemin de données choisi.
## Approche choisie
**A + H1** : fetch et parsing **restent dans la page** ; `storage.local` sous une
**clé unique** pour la seule frontière irréductible (page → content script).
Pourquoi pas les alternatives :
- **Background fetch+parse par message (B)** : introduit une **frontière de
sérialisation gratuite**. Firefox préserve les `Date` (structured clone),
Chrome les JSON-ise : faire reposer le contrat `Event` fraîchement figé sur un
détail du moteur = dette silencieuse. Zéro besoin actuel — `background.js`
garde son unique rôle (ouvrir l'onglet) et **n'a pas été modifié**.
- **Background → `storage.session` (C)** : le storage JSON-ise → `debut`/`fin`
deviennent des chaînes, réhydratation partout, contrat entamé. Plus
invalidation/fraîcheur à gérer. Sur-ingénierie pour une liste ouverte à la demande.
- **Clé `evenement:<uid>` (couture annoncée par la tâche sœur)** : **inutilisable
en l'état** — le content script s'exécute sur le site mairie et n'a **aucun
canal** pour apprendre un `uid` (le fragment d'URL a été écarté). La couture
nommée par la tâche sœur avait un trou ; T1 l'a révisée (voir `DECISIONS.md`).
- **`tabs.sendMessage` au chargement** : course page/content script à arbitrer,
et un F5 sur le formulaire (modéré, on y revient) perd le contexte.
Le principe : **n'introduire une frontière que quand elle sert quelqu'un**. Le
background reste disponible ; quand T2 aura un vrai besoin d'orchestration, il
sera introduit **pour une raison**, pas par anticipation.
## Décisions clés
Actées en détail dans `docs/DECISIONS.md` (2026-07-14) — **à lire avant la tâche
sœur ou T2**. En résumé :
- **Modules purs extraits** (`presentation.js`, `transfert.js`) plutôt que tout
dans `liste.js` : ce dernier touche `document` dès l'import, donc l'importer
dans Bun plante — y enterrer tri/formatage/défaut aurait rendu de la **logique
métier réelle** intestable. Chaque module existe parce qu'il rend testable une
décision métier (l'ordre, le mensonge « 00:00 », le contrat de couture).
- **Clé unique `evenement-en-attente`**, pas indexée par uid ; **on n'efface pas
à la lecture** (le formulaire survit à un F5) ; le dernier clic gagne (assumé :
le geste réel est séquentiel).
- **Sérialisation ISO explicite** : on ne parie pas sur le structured clone.
- **Défaut café = affichage seulement**, `lieu` stocké **brut** (`""` si absent) :
écrire l'adresse dans le payload **détruirait le signal « pas de lieu »** dont
le content script a besoin pour décider s'il pose aussi les coordonnées.
- **`await` le dépôt AVANT `tabs.create`** : sinon course — c'est précisément ce
que la couture achète (« la donnée attend le content script »).
- **Permission `storage` seule** ; `"tabs"` correctement écartée (`tabs.create`
n'en a pas besoin — confirmé en Firefox réel).
## Patterns à réutiliser
**Injecter la dépendance impure, garder le module pur** (convention du repo :
`fetchImpl`, `aujourdhui`, et maintenant `storage`) — paramètre **obligatoire**,
testable sans navigateur :
```js
export async function deposerEvenement(storage, evenement) {
await storage.set({ [CLE_EVENEMENT_EN_ATTENTE]: serialiserPourFormulaire(evenement) });
}
// appel réel : deposerEvenement(browser.storage.local, evenement)
```
**Le point d'entrée injecte le « maintenant »** — la pureté du parseur est
préservée jusqu'au dernier moment :
```js
rendreEvenements(trierParDebut(parserEvenements(res.flux, new Date())));
```
**Tout handler `async` de clic doit attraper** — sinon la promesse rejette en
silence et le bouton paraît **mort** (voir Pièges) :
```js
creer.addEventListener("click", async () => {
try { await creerSurSiteMairie(evenement); }
catch (erreur) { rendreEchecCreation(erreur); } // console.error + message NOMMANT l'erreur
});
```
Closure sur l'événement de la carte : ni `dataset`, ni re-lookup par uid.
**Formater une date flottante SANS `timeZone`, un instant AVEC** — les deux
formatteurs coexistent délibérément dans `presentation.js` :
```js
const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat("fr-FR", { timeZone: "Europe/Paris", dateStyle: "full" });
// Sans timeZone, DÉLIBÉRÉMENT : une journée entière est une date *flottante*
// (RFC 5545). ical.js la matérialise à minuit local ; la relire en local
// restitue le bon jour partout, alors que la convertir vers Paris la ferait
// glisser au jour précédent depuis Tokyo.
const FORMAT_JOUR_FLOTTANT = new Intl.DateTimeFormat("fr-FR", { dateStyle: "full" });
```
**Reculer d'un jour un DTEND de journée entière, en composantes locales** :
```js
// DTEND est EXCLUSIF (RFC 5545) : un événement du 18 porte DTEND=19.
// Jamais « -24h » : la date est flottante, un changement d'heure ferait basculer le jour.
const veilleDe = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1);
```
**Tester ce que le fuseau épinglé cache — en sous-processus.** `TZ=Europe/Paris`
est justement le seul fuseau où une journée entière mal formatée reste invisible.
`Intl` fige le fuseau à l'import : seul un autre processus peut le changer.
```ts
describe.each(["Asia/Tokyo", "Pacific/Auckland", "America/New_York", "Europe/Paris"])(
"sous TZ=%s", (fuseau) => {
test("journée entière → la date ne glisse pas d'un jour", async () => {
expect(await formaterDansLeFuseau(fuseau)).toContain(JOUR_ATTENDU);
});
});
// Bun.spawn(["bun", "-e", …], { env: { ...process.env, TZ: fuseau } })
```
**Le fixture traverse la frontière de processus en code source** (une constante
unique interpolée : `JOURNEE_ENTIERE_DEBUT.join(", ")`) → les deux tests ne
peuvent plus diverger **par construction**. C'est ce qui a corrigé la *cause* du
faux vert, pas son symptôme.
**Assertions tolérantes sur les sorties `Intl`** (`toContain` / `not.toMatch`,
jamais l'égalité stricte) : la ponctuation d'ICU varie entre versions de Bun. On
teste l'intention (bon jour, heure présente/absente, bon fuseau), pas le typographe.
**Rendu DOM sûr** : `textContent` exclusivement (jamais `innerHTML` — les titres
viennent d'une source externe), `replaceChildren()` pour rester rejouable au
Réessayer, `<li class="liste-vide">` **dans** le `<ul>` (un état vide est un
contenu, pas une erreur — et un `<ul>` ne peut contenir que des `<li>`).
## Pièges à éviter
- **🔴 Date flottante vs instant absolu : la famille de bugs de ce ticket.** Une
`DTSTART;VALUE=DATE` n'a **pas** de fuseau. Cette confusion a produit **trois**
défauts distincts, chacun invisible sous `TZ=Europe/Paris` :
1. `formaterDate` forçait `Europe/Paris` sur les journées entières → la date
**glissait d'un jour** depuis Tokyo (trou du **plan**, pas de l'exécution) ;
2. un fixture de test déclarait un instant absolu (`"...T00:00+02:00"`) pour
décrire une date flottante → **faux vert** ;
3. *(préexistant, hors T1)* `evenements.test.ts:141` mêle un `AUJOURDHUI`
absolu à un `DTSTART` flottant → rouge sous Auckland.
**Règle** : un fixture de date flottante se construit en **composantes locales**
(`new Date(2026, 6, 10)`), jamais en instant absolu — ce dernier n'est juste
que sur une machine à +02:00 et **ment partout ailleurs**. Côté consommateur :
tester `journeeEntiere` **d'abord**, ne lire que les composantes locales.
- **🔴 Un handler `async` non attrapé = un bouton silencieusement mort.**
`() => creerSurSiteMairie(evenement)` : la promesse n'était ni attendue ni
attrapée → **toute** défaillance devenait un rejet silencieux. Aucun onglet,
aucun message, **rien à diagnostiquer**. Le plan avait différé l'UI d'erreur
(« non demandé en T1 ») : le terrain a démontré que ce report rendait
l'anomalie **invisible ET indiagnosticable**, et a coûté **deux passes de
test**. Afficher le détail technique est **volontaire** : c'est ce qui a fait
passer la panne d'« indiagnosticable » à « nommée en 3 secondes ».
**Leçon générale : la remontée d'erreur d'un geste utilisateur n'est pas du
confort différable — c'est l'instrument qui rend le reste diagnosticable.**
- **🔴 Ajouter une clé au `manifest.json` impose Décharger + Charger.** Firefox
relit les **scripts** de la page à chaque ouverture, mais le **manifest**
seulement **à l'installation**. On se retrouve avec le code le plus récent et
les **permissions figées de l'ancien**. Symptôme : `browser.<api> is undefined`
alors que le manifest déclare la permission. **F5 et « Recharger » ne réparent
rien.** Ce piège a coûté deux passes ; **T2 (écriture CalDAV) y retombera** en
ajoutant sa permission. Documenté dans `README.md` § Installation (dev).
- **`bun test` nu force `TZ=UTC`** quand `TZ` est absente (le runner, pas `bun -e`
qui lit le fuseau système). Sans le pin `TZ=Europe/Paris` de `package.json`,
les tests ne tournent **pas** dans le fuseau des bénévoles. **Le pin est
PORTEUR — ne pas le retirer.** Corollaire : un test qui ne passe **que**
grâce au pin cache un bug de fuseau (cf. le sous-processus ci-dessus).
- **Un fuseau épinglé masque autant qu'il stabilise.** `Europe/Paris` est
précisément le fuseau où les bugs de date flottante sont invisibles. Épingler
pour le déterminisme **et** tester ailleurs pour la vérité.
- **`DTEND` exclusif** : sans le recul d'un jour, on annonce une date **FAUSSE**
(pas juste laide). ⚠️ **La tâche sœur doit l'appliquer avant de remplir
`#input_6_32`**, sinon elle soumettra une date fausse à la mairie.
Autre cas : **`DTEND` absent → le parseur pose `fin === debut`** (pas de plage
à afficher : « de 10:30 à 10:30 » serait absurde).
- **Ne pas coder « `debut` → `fin` » naïvement** : c'était faux **4 fois sur 6**
(DTEND absent, franchit minuit, journée entière, festival). Mesurer les formes
réelles **sur le vrai parseur** avant de coder.
- **La liste est incomplète par construction** : les récurrents au DTSTART maître
passé (atelier hebdo, permanence) n'y figurent pas — trou hérité, comblé par
[T3]. **Aucun avertissement UI** : un avertissement permanent que personne ne
peut lever devient du bruit.
- **Le bouton ouvre un formulaire VIDE** tant que la tâche sœur n'est pas faite.
État intermédiaire **honnête et démontrable** (`about:debugging` → Storage →
`evenement-en-attente`), pas un bug. Ne pas « finir le travail » en douce :
cela casserait un découpage déjà tranché.
## Tags
tags: [firefox-extension, manifest-v3, webextension, vanilla-js, storage-local,
content-script, seam, icalendar, rfc5545, floating-date, dtend-exclusive,
timezone, intl, date-formatting, pure-function, dependency-injection, bun-test,
cross-timezone-testing, test-fixtures, false-green, async-error-handling,
silent-failure, dom-security, textcontent, serialization, contract-design,
yagni, low-tech]
+4
View File
@@ -21,6 +21,10 @@ describe("manifest.json", () => {
]);
});
test("demande la permission storage (couture liste → formulaire)", () => {
expect(manifest.permissions).toContain("storage");
});
test("ne définit PAS default_popup (sinon onClicked devient muet)", () => {
expect(manifest.action.default_popup).toBeUndefined();
});
+31
View File
@@ -28,6 +28,37 @@ h1 {
font-style: italic;
}
.evenement {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 0;
border-top: 1px solid #8884;
}
.evenement-infos > * {
margin: 0;
}
.evenement-titre {
font-size: 1rem;
}
.evenement-date {
margin-top: 0.25rem;
}
.evenement-lieu {
color: #666;
}
.evenement-creer {
flex-shrink: 0;
font: inherit;
cursor: pointer;
}
#etat {
margin-bottom: 1.5rem;
}
+83 -8
View File
@@ -1,10 +1,13 @@
import {
compterEvenements,
recupererFluxCalendrier,
} from "./nextcloud.js";
import { recupererFluxCalendrier } from "./nextcloud.js";
import { parserEvenements } from "./evenements.js";
import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js";
import { deposerEvenement } from "./transfert.js";
const URL_NEXTCLOUD = "https://atelier-huit.frama.space/";
const URL_FORMULAIRE_MAIRIE =
"https://www.ville-cugnaux.fr/mes-loisirs/associations/proposer-un-evenement-dans-lagenda/";
// Mapping type d'erreur → message FR non technique et actionnable.
const MESSAGES_ERREUR = {
"non-connecte":
@@ -16,6 +19,7 @@ const MESSAGES_ERREUR = {
};
const etat = document.querySelector("#etat");
const liste = document.querySelector("#liste");
function rendreNeutre(texte) {
etat.replaceChildren();
@@ -25,9 +29,80 @@ function rendreNeutre(texte) {
etat.append(p);
}
function rendreSucces(flux) {
const nombre = compterEvenements(flux);
rendreNeutre(`${nombre} événement${nombre > 1 ? "s" : ""} lu${nombre > 1 ? "s" : ""}`);
function rendreEvenements(evenements) {
// La liste est sa propre preuve de vie : plus de compteur dans #etat.
etat.replaceChildren();
liste.replaceChildren();
if (evenements.length === 0) {
const vide = document.createElement("li");
vide.className = "liste-vide";
vide.textContent = "Aucun événement à venir";
liste.append(vide);
return;
}
liste.append(...evenements.map(rendreEvenement));
}
// textContent exclusivement : les titres viennent d'une source externe.
function rendreEvenement(evenement) {
const item = document.createElement("li");
item.className = "evenement";
const infos = document.createElement("div");
infos.className = "evenement-infos";
const titre = document.createElement("h2");
titre.className = "evenement-titre";
titre.textContent = evenement.titre;
const date = document.createElement("p");
date.className = "evenement-date";
date.textContent = formaterDate(evenement);
const lieu = document.createElement("p");
lieu.className = "evenement-lieu";
lieu.textContent = formaterLieu(evenement);
infos.append(titre, date, lieu);
const creer = document.createElement("button");
creer.type = "button";
creer.className = "evenement-creer";
creer.textContent = "Créer sur le site mairie";
creer.addEventListener("click", async () => {
try {
await creerSurSiteMairie(evenement);
} catch (erreur) {
rendreEchecCreation(erreur);
}
});
item.append(infos, creer);
return item;
}
async function creerSurSiteMairie(evenement) {
// Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant
// l'écriture.
await deposerEvenement(browser.storage.local, evenement);
await browser.tabs.create({ url: URL_FORMULAIRE_MAIRIE });
}
// Sans ce retour, la promesse du clic échouait en silence : le bouton paraissait
// mort et la panne était indiagnosticable, y compris pour nous. Le détail
// technique est affiché volontairement — c'est ce qui permet de nommer la cause
// sans ouvrir la console. Le F5 couvre le cas de la page orpheline (extension
// rechargée dans about:debugging alors que la liste restait ouverte).
function rendreEchecCreation(erreur) {
console.error("Échec de l'ouverture du formulaire mairie", erreur);
etat.replaceChildren();
const message = document.createElement("p");
message.className = "etat-erreur";
message.textContent = `Impossible d'ouvrir le formulaire mairie : ${erreur.message}. Recharge la page (F5), puis réessaie.`;
etat.append(message);
}
function rendreErreur(typeErreur) {
@@ -60,7 +135,7 @@ async function charger() {
// « Illegal invocation » dans le navigateur.
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
if (res.ok) {
rendreSucces(res.flux);
rendreEvenements(trierParDebut(parserEvenements(res.flux, new Date())));
} else {
rendreErreur(res.erreur);
}
+1
View File
@@ -16,6 +16,7 @@
"default_title": "Ouvrir la liste de l'Écho du Huit",
"default_icon": "icons/icon.svg"
},
"permissions": ["storage"],
"host_permissions": [
"https://atelier-huit.frama.space/*",
"https://www.ville-cugnaux.fr/*"
-6
View File
@@ -30,9 +30,3 @@ export async function recupererFluxCalendrier(fetchImpl) {
}
return { ok: true, flux };
}
// Preuve de vie de T1 : compte les vrais débuts de VEVENT (ancrage ligne, pas
// une chaîne « BEGIN:VEVENT » apparaissant dans une DESCRIPTION).
export function compterEvenements(flux) {
return (flux.match(/^BEGIN:VEVENT\s*$/gm) ?? []).length;
}
+82
View File
@@ -0,0 +1,82 @@
// Présentation des Event pour la liste : ordre, formatage date, défaut lieu.
// Fonctions pures, sans DOM (testables). Locale et fuseau sont des constantes :
// l'appli est mono-locale (bénévoles de Cugnaux).
const LOCALE = "fr-FR";
const FUSEAU = "Europe/Paris";
// RECHERCHE.md §2 : adresse du café de l'Atelier du Huit.
export const LIEU_PAR_DEFAUT = "8 Rue du Pré Vicinal 31270 Cugnaux";
// Un événement horodaté porte un vrai instant : on l'affiche à l'heure de
// Cugnaux, quel que soit le fuseau de la machine.
const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat(LOCALE, {
timeZone: FUSEAU,
dateStyle: "full",
});
const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, {
timeZone: FUSEAU,
timeStyle: "short",
});
// Sans timeZone, DÉLIBÉRÉMENT (ne pas « rétablir » FUSEAU ici) : une journée
// entière est une date *flottante* (RFC 5545), sans fuseau. ical.js la
// matérialise à minuit local ; la relire en local restitue le bon jour partout,
// alors que la convertir vers Paris la ferait glisser au 9 depuis Tokyo.
const FORMAT_JOUR_FLOTTANT = new Intl.DateTimeFormat(LOCALE, {
dateStyle: "full",
});
// Le parseur rend l'ordre du flux : l'ordre chronologique se décide ici.
export function trierParDebut(evenements) {
return [...evenements].sort((a, b) => a.debut.getTime() - b.debut.getTime());
}
// Une journée entière n'a pas d'heure : afficher « 00:00 » serait un mensonge.
export function formaterDate(evenement) {
return evenement.journeeEntiere
? formaterJourneeEntiere(evenement)
: formaterHorodate(evenement);
}
function formaterJourneeEntiere({ debut, fin }) {
const premierJour = FORMAT_JOUR_FLOTTANT.format(debut);
const dernierJour = veilleDe(fin);
// Un seul jour, ou pas de DTEND exploitable (le parseur pose alors fin === debut).
if (dernierJour.getTime() <= debut.getTime()) return premierJour;
return `du ${premierJour} au ${FORMAT_JOUR_FLOTTANT.format(dernierJour)}`;
}
// 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 annoncerait un jour de
// trop — une date FAUSSE, pas seulement laide.
// Composantes locales (jamais « -24h ») : la date est flottante, et un
// changement d'heure ferait basculer le jour.
function veilleDe(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1);
}
function formaterHorodate({ debut, fin }) {
const jourDebut = FORMAT_JOUR_PARIS.format(debut);
const heureDebut = FORMAT_HEURE_PARIS.format(debut);
// DTEND absent : le parseur pose fin === debut. « de 10:30 à 10:30 » serait absurde.
if (fin.getTime() <= debut.getTime()) return `${jourDebut} à ${heureDebut}`;
const jourFin = FORMAT_JOUR_PARIS.format(fin);
const heureFin = FORMAT_HEURE_PARIS.format(fin);
// Comparaison des jours tels qu'AFFICHÉS (donc à Paris) : un événement de
// 22:00 à 02:00 change de jour, l'annoncer sur un seul serait un mensonge.
if (jourFin === jourDebut) return `${jourDebut} de ${heureDebut} à ${heureFin}`;
return `du ${jourDebut} à ${heureDebut} au ${jourFin} à ${heureFin}`;
}
// La plupart des événements se tiennent au café et n'ont pas de LOCATION :
// afficher l'adresse par défaut plutôt qu'une ligne vide.
export function formaterLieu(evenement) {
return evenement.lieu.trim() || LIEU_PAR_DEFAUT;
}
+35
View File
@@ -0,0 +1,35 @@
// Couture liste → content script du formulaire mairie (contrat lu par la tâche
// sœur « formater les données pour le formulaire mairie »).
//
// Clé UNIQUE, pas indexée par uid : le content script s'exécute sur le site de
// la mairie et n'a aucun moyen d'apprendre un uid (le fragment d'URL a été
// écarté). Une clé connue d'avance lui suffit donc à lire « l'événement à
// créer ». Corollaire assumé : le dernier clic gagne, et on n'efface pas à la
// lecture pour que le formulaire survive à un rechargement.
//
// `debut`/`fin` sont sérialisés en ISO 8601 : le contrat reste explicite quel
// que soit le moteur, et le content script réhydrate avec `new Date(iso)`.
export const CLE_EVENEMENT_EN_ATTENTE = "evenement-en-attente";
// `lieu` est laissé brut ("" si absent) : c'est le signal dont le content
// script a besoin pour décider s'il pose aussi les coordonnées du café.
export function serialiserPourFormulaire(evenement) {
return {
uid: evenement.uid,
titre: evenement.titre,
description: evenement.description,
lieu: evenement.lieu,
debut: evenement.debut.toISOString(),
fin: evenement.fin.toISOString(),
categories: evenement.categories,
journeeEntiere: evenement.journeeEntiere,
};
}
// `storage` est injecté (obligatoire) : testable sans navigateur.
export async function deposerEvenement(storage, evenement) {
await storage.set({
[CLE_EVENEMENT_EN_ATTENTE]: serialiserPourFormulaire(evenement),
});
}
-19
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
URL_CALENDRIER,
compterEvenements,
recupererFluxCalendrier,
} from "./extension/nextcloud.js";
@@ -65,21 +64,3 @@ describe("recupererFluxCalendrier", () => {
expect(optionsVues).toEqual({ credentials: "include" });
});
});
describe("compterEvenements", () => {
test("compte 2 VEVENT", () => {
const flux =
"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
expect(compterEvenements(flux)).toBe(2);
});
test("compte 0 quand aucun VEVENT", () => {
expect(compterEvenements("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")).toBe(0);
});
test("ignore un BEGIN:VEVENT au milieu d'une DESCRIPTION", () => {
const flux =
"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDESCRIPTION:texte BEGIN:VEVENT piège\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
expect(compterEvenements(flux)).toBe(1);
});
});
+1 -1
View File
@@ -4,6 +4,6 @@
"type": "module",
"description": "Outillage de dev (Bun) pour l'extension. L'extension elle-même est du JS vanilla sans build.",
"scripts": {
"test": "bun test"
"test": "TZ=Europe/Paris bun test"
}
}
+215
View File
@@ -0,0 +1,215 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import {
LIEU_PAR_DEFAUT,
formaterDate,
formaterLieu,
trierParDebut,
} from "./extension/presentation.js";
// Fixtures = Event littéraux : le parsing est testé dans evenements.test.ts.
function evenement(surcharges: Record<string, unknown> = {}) {
return {
uid: "uid-1",
titre: "Atelier",
description: "",
lieu: "",
debut: new Date("2026-07-10T16:00:00Z"),
fin: new Date("2026-07-10T18:00:00Z"),
categories: [],
journeeEntiere: false,
...surcharges,
};
}
describe("trierParDebut", () => {
test("ordre mêlé → croissant sur debut", () => {
const tardif = evenement({ uid: "tardif", debut: new Date("2026-08-01T10:00:00Z") });
const tot = evenement({ uid: "tot", debut: new Date("2026-07-01T10:00:00Z") });
const median = evenement({ uid: "median", debut: new Date("2026-07-15T10:00:00Z") });
const tries = trierParDebut([tardif, tot, median]);
expect(tries.map((e) => e.uid)).toEqual(["tot", "median", "tardif"]);
});
test("ne mute pas le tableau d'entrée", () => {
const tardif = evenement({ uid: "tardif", debut: new Date("2026-08-01T10:00:00Z") });
const tot = evenement({ uid: "tot", debut: new Date("2026-07-01T10:00:00Z") });
const entree = [tardif, tot];
trierParDebut(entree);
expect(entree.map((e) => e.uid)).toEqual(["tardif", "tot"]);
});
test("[] → []", () => {
expect(trierParDebut([])).toEqual([]);
});
test("deux debut identiques → 2 éléments, pas de plantage", () => {
const meme = new Date("2026-07-10T16:00:00Z");
const a = evenement({ uid: "a", debut: meme });
const b = evenement({ uid: "b", debut: meme });
expect(trierParDebut([a, b])).toHaveLength(2);
});
});
// Fixture UNIQUE de journée entière, partagée par le test en processus et ceux
// en sous-processus : ils doivent décrire le même Event, sinon l'un des deux
// ment. Composantes LOCALES, jamais un instant absolu ("...T00:00+02:00") : une
// date flottante (VALUE=DATE) n'a pas de fuseau et ical.js la matérialise à
// minuit local, donc un instant figé ne serait juste qu'à +02:00.
const JOURNEE_ENTIERE_DEBUT = [2026, 6, 10] as const;
// DTEND est EXCLUSIF (RFC 5545) : le 11 signifie « dernier jour réel = le 10 ».
const JOURNEE_ENTIERE_FIN = [2026, 6, 11] as const;
const JOUR_ATTENDU = "10 juillet 2026";
function evenementJourneeEntiere() {
return evenement({
debut: new Date(...JOURNEE_ENTIERE_DEBUT),
fin: new Date(...JOURNEE_ENTIERE_FIN),
journeeEntiere: true,
});
}
describe("formaterDate", () => {
test("événement horodaté → date et heure", () => {
const rendu = formaterDate(evenement({ debut: new Date("2026-07-10T16:00:00Z") }));
expect(rendu).toContain("10 juillet 2026");
expect(rendu).toContain("18:00");
});
test("journée entière → date sans aucune heure", () => {
const rendu = formaterDate(evenementJourneeEntiere());
expect(rendu).toContain(JOUR_ATTENDU);
expect(rendu).not.toMatch(/\d{1,2}:\d{2}/);
});
test("instant à 11:00 UTC → affiché à l'heure de Paris (13:00)", () => {
const rendu = formaterDate(evenement({ debut: new Date("2026-07-10T11:00:00Z") }));
expect(rendu).toContain("13:00");
});
// Les 6 formes de fin mesurées sur le vrai parseur (cf. rapport de test).
// Cas de Pierre : 10:30 → 12:30 le 18/07 (10:30 Paris = 08:30Z).
describe("plage horaire", () => {
test("début et fin le même jour → « de 10:30 à 12:30 »", () => {
const rendu = formaterDate(
evenement({
debut: new Date("2026-07-18T08:30:00Z"),
fin: new Date("2026-07-18T10:30:00Z"),
}),
);
expect(rendu).toContain("18 juillet 2026");
expect(rendu).toContain("10:30");
expect(rendu).toContain("12:30");
});
// DTEND absent : le parseur pose fin === debut. « de 10:30 à 10:30 » serait absurde.
test("sans fin (fin === debut) → une seule heure affichée", () => {
const debut = new Date("2026-07-18T08:30:00Z");
const rendu = formaterDate(evenement({ debut, fin: new Date(debut) }));
expect(rendu).toContain("10:30");
expect(rendu.match(/\d{1,2}:\d{2}/g)).toHaveLength(1);
});
// 22:00 → 02:00 : « samedi 18 de 22:00 à 02:00 » serait un mensonge.
test("franchit minuit → la date de fin est affichée", () => {
const rendu = formaterDate(
evenement({
debut: new Date("2026-07-18T20:00:00Z"),
fin: new Date("2026-07-19T00:00:00Z"),
}),
);
expect(rendu).toContain("18 juillet 2026");
expect(rendu).toContain("22:00");
expect(rendu).toContain("19 juillet 2026");
expect(rendu).toContain("02:00");
});
test("journée entière d'un jour (DTEND exclusif) → le seul jour réel", () => {
const rendu = formaterDate(evenementJourneeEntiere());
expect(rendu).toContain(JOUR_ATTENDU);
expect(rendu).not.toContain("11 juillet"); // le DTEND exclusif n'est pas un jour de l'événement
expect(rendu).not.toMatch(/\d{1,2}:\d{2}/);
});
// Festival 18→21 exclusif : il se termine réellement le 20.
test("journée entière sur plusieurs jours → dernier jour réel = DTEND - 1", () => {
const rendu = formaterDate(
evenement({
debut: new Date(2026, 6, 18),
fin: new Date(2026, 6, 21),
journeeEntiere: true,
}),
);
expect(rendu).toContain("18 juillet 2026");
expect(rendu).toContain("20 juillet 2026");
expect(rendu).not.toContain("21 juillet");
expect(rendu).not.toMatch(/\d{1,2}:\d{2}/);
});
});
// Le fuseau des tests est épinglé à Europe/Paris : c'est justement le seul où
// une journée entière mal formatée reste invisible. On sous-traite donc à des
// processus réglés ailleurs.
describe.each(["Asia/Tokyo", "Pacific/Auckland", "America/New_York", "Europe/Paris"])(
"sous TZ=%s",
(fuseau) => {
test("journée entière → la date ne glisse pas d'un jour", async () => {
expect(await formaterDansLeFuseau(fuseau)).toContain(JOUR_ATTENDU);
});
},
);
});
// Le fixture traverse la frontière de processus en code source : c'est la même
// constante JOURNEE_ENTIERE, donc les deux tests ne peuvent pas diverger.
async function formaterDansLeFuseau(fuseau: string): Promise<string> {
const module = JSON.stringify(join(import.meta.dir, "extension/presentation.js"));
const processus = Bun.spawn(
[
"bun",
"-e",
`import { formaterDate } from ${module};
console.log(formaterDate({
debut: new Date(${JOURNEE_ENTIERE_DEBUT.join(", ")}),
fin: new Date(${JOURNEE_ENTIERE_FIN.join(", ")}),
journeeEntiere: true,
}));`,
],
{ env: { ...process.env, TZ: fuseau }, stdout: "pipe", stderr: "pipe" },
);
const [sortie, erreur, code] = await Promise.all([
new Response(processus.stdout).text(),
new Response(processus.stderr).text(),
processus.exited,
]);
if (code !== 0) throw new Error(`bun -e a échoué (${code}) : ${erreur}`);
return sortie;
}
describe("formaterLieu", () => {
test("lieu renseigné → rendu tel quel", () => {
expect(formaterLieu(evenement({ lieu: "Salle des fêtes" }))).toBe("Salle des fêtes");
});
test("lieu vide → adresse du café", () => {
expect(formaterLieu(evenement({ lieu: "" }))).toBe(LIEU_PAR_DEFAUT);
});
test("lieu en blancs → adresse du café", () => {
expect(formaterLieu(evenement({ lieu: " " }))).toBe(LIEU_PAR_DEFAUT);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import {
CLE_EVENEMENT_EN_ATTENTE,
deposerEvenement,
serialiserPourFormulaire,
} from "./extension/transfert.js";
function evenement(surcharges: Record<string, unknown> = {}) {
return {
uid: "uid-1",
titre: "Atelier",
description: "Venez nombreux",
lieu: "Salle des fêtes",
debut: new Date("2026-07-10T16:00:00Z"),
fin: new Date("2026-07-10T18:00:00Z"),
categories: ["soumis"],
journeeEntiere: false,
...surcharges,
};
}
// Storage factice (Chicago) : capture ce qui est écrit, pas de mock lourd.
function storageFactice() {
const ecrit: Record<string, unknown> = {};
return {
ecrit,
set: async (objet: Record<string, unknown>) => Object.assign(ecrit, objet),
};
}
describe("serialiserPourFormulaire", () => {
test("debut et fin deviennent des chaînes ISO", () => {
const payload = serialiserPourFormulaire(evenement());
expect(payload.debut).toBe("2026-07-10T16:00:00.000Z");
expect(payload.fin).toBe("2026-07-10T18:00:00.000Z");
});
test("un lieu vide reste vide (pas de défaut café — D4)", () => {
const payload = serialiserPourFormulaire(evenement({ lieu: "" }));
expect(payload.lieu).toBe("");
});
test("porte tous les champs du contrat Event", () => {
const payload = serialiserPourFormulaire(evenement());
expect(payload).toEqual({
uid: "uid-1",
titre: "Atelier",
description: "Venez nombreux",
lieu: "Salle des fêtes",
debut: "2026-07-10T16:00:00.000Z",
fin: "2026-07-10T18:00:00.000Z",
categories: ["soumis"],
journeeEntiere: false,
});
});
test("aller-retour : le content script peut réhydrater debut", () => {
const ev = evenement();
const payload = serialiserPourFormulaire(ev);
expect(new Date(payload.debut).getTime()).toBe(ev.debut.getTime());
});
});
describe("deposerEvenement", () => {
test("écrit sous la clé evenement-en-attente", async () => {
const storage = storageFactice();
await deposerEvenement(storage, evenement());
expect(storage.ecrit[CLE_EVENEMENT_EN_ATTENTE]).toMatchObject({ uid: "uid-1" });
});
test("un second dépôt écrase le premier (slot unique — D2)", async () => {
const storage = storageFactice();
await deposerEvenement(storage, evenement({ uid: "premier" }));
await deposerEvenement(storage, evenement({ uid: "second" }));
expect(Object.keys(storage.ecrit)).toEqual([CLE_EVENEMENT_EN_ATTENTE]);
expect(storage.ecrit[CLE_EVENEMENT_EN_ATTENTE]).toMatchObject({ uid: "second" });
});
});