Merge branch 'cli-de-selection-des-evenements-oear'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user