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
|
> ⚠️ Un module temporaire **disparaît au redémarrage de Firefox** : il faut le
|
||||||
> recharger via `about:debugging` à chaque session (comportement de dev attendu).
|
> 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
|
### Vérifier l'extension
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -70,3 +70,59 @@ ici.
|
|||||||
Gravity Forms n'accepte `?input_X=` que si chaque champ est configuré
|
Gravity Forms n'accepte `?input_X=` que si chaque champ est configuré
|
||||||
« population dynamique » côté mairie (improbable). On remplit donc le **DOM par
|
« population dynamique » côté mairie (improbable). On remplit donc le **DOM par
|
||||||
id** (ids confirmés dans `RECHERCHE.md`).
|
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)", () => {
|
test("ne définit PAS default_popup (sinon onClicked devient muet)", () => {
|
||||||
expect(manifest.action.default_popup).toBeUndefined();
|
expect(manifest.action.default_popup).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ h1 {
|
|||||||
font-style: italic;
|
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 {
|
#etat {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-8
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import { recupererFluxCalendrier } from "./nextcloud.js";
|
||||||
compterEvenements,
|
import { parserEvenements } from "./evenements.js";
|
||||||
recupererFluxCalendrier,
|
import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js";
|
||||||
} from "./nextcloud.js";
|
import { deposerEvenement } from "./transfert.js";
|
||||||
|
|
||||||
const URL_NEXTCLOUD = "https://atelier-huit.frama.space/";
|
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.
|
// Mapping type d'erreur → message FR non technique et actionnable.
|
||||||
const MESSAGES_ERREUR = {
|
const MESSAGES_ERREUR = {
|
||||||
"non-connecte":
|
"non-connecte":
|
||||||
@@ -16,6 +19,7 @@ const MESSAGES_ERREUR = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const etat = document.querySelector("#etat");
|
const etat = document.querySelector("#etat");
|
||||||
|
const liste = document.querySelector("#liste");
|
||||||
|
|
||||||
function rendreNeutre(texte) {
|
function rendreNeutre(texte) {
|
||||||
etat.replaceChildren();
|
etat.replaceChildren();
|
||||||
@@ -25,9 +29,80 @@ function rendreNeutre(texte) {
|
|||||||
etat.append(p);
|
etat.append(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rendreSucces(flux) {
|
function rendreEvenements(evenements) {
|
||||||
const nombre = compterEvenements(flux);
|
// La liste est sa propre preuve de vie : plus de compteur dans #etat.
|
||||||
rendreNeutre(`${nombre} événement${nombre > 1 ? "s" : ""} lu${nombre > 1 ? "s" : ""}`);
|
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) {
|
function rendreErreur(typeErreur) {
|
||||||
@@ -60,7 +135,7 @@ async function charger() {
|
|||||||
// « Illegal invocation » dans le navigateur.
|
// « Illegal invocation » dans le navigateur.
|
||||||
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
|
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
rendreSucces(res.flux);
|
rendreEvenements(trierParDebut(parserEvenements(res.flux, new Date())));
|
||||||
} else {
|
} else {
|
||||||
rendreErreur(res.erreur);
|
rendreErreur(res.erreur);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"default_title": "Ouvrir la liste de l'Écho du Huit",
|
"default_title": "Ouvrir la liste de l'Écho du Huit",
|
||||||
"default_icon": "icons/icon.svg"
|
"default_icon": "icons/icon.svg"
|
||||||
},
|
},
|
||||||
|
"permissions": ["storage"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://atelier-huit.frama.space/*",
|
"https://atelier-huit.frama.space/*",
|
||||||
"https://www.ville-cugnaux.fr/*"
|
"https://www.ville-cugnaux.fr/*"
|
||||||
|
|||||||
@@ -30,9 +30,3 @@ export async function recupererFluxCalendrier(fetchImpl) {
|
|||||||
}
|
}
|
||||||
return { ok: true, flux };
|
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 { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
URL_CALENDRIER,
|
URL_CALENDRIER,
|
||||||
compterEvenements,
|
|
||||||
recupererFluxCalendrier,
|
recupererFluxCalendrier,
|
||||||
} from "./extension/nextcloud.js";
|
} from "./extension/nextcloud.js";
|
||||||
|
|
||||||
@@ -65,21 +64,3 @@ describe("recupererFluxCalendrier", () => {
|
|||||||
expect(optionsVues).toEqual({ credentials: "include" });
|
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",
|
"type": "module",
|
||||||
"description": "Outillage de dev (Bun) pour l'extension. L'extension elle-même est du JS vanilla sans build.",
|
"description": "Outillage de dev (Bun) pour l'extension. L'extension elle-même est du JS vanilla sans build.",
|
||||||
"scripts": {
|
"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