Merge branch 'formater-les-donnees-pour-le-formulaire-mairi-oebs'
This commit is contained in:
@@ -50,6 +50,37 @@ runtime à installer) :
|
|||||||
> Pense aussi à **fermer les anciens onglets de la liste** avant de recharger :
|
> Pense aussi à **fermer les anciens onglets de la liste** avant de recharger :
|
||||||
> une page orpheline rend les clics inertes.
|
> une page orpheline rend les clics inertes.
|
||||||
|
|
||||||
|
### Configuration (optionnelle)
|
||||||
|
|
||||||
|
Le formulaire de la mairie demande un **organisateur** et un **email** : ils sont
|
||||||
|
personnels, donc absents du dépôt. Pour qu'ils soient pré-remplis :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp extension/config.local.json.example extension/config.local.json
|
||||||
|
```
|
||||||
|
|
||||||
|
puis édite le fichier (il est gitignoré, il ne partira jamais sur le dépôt) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"organisateur": "L'Atelier du Huit",
|
||||||
|
"email": "contact@atelier-huit.fr"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce fichier est **facultatif**. Sans lui, tout le reste est pré-rempli (titre,
|
||||||
|
dates, heures, lieu, thème, tarifs) : seuls l'organisateur et l'email restent
|
||||||
|
vides, et le bandeau du formulaire te le dit.
|
||||||
|
|
||||||
|
> ⚠️ Ce fichier ne surcharge que `organisateur` et `email`. Les autres valeurs
|
||||||
|
> par défaut (thème « Culture », tarifs « Gratuit », adresse **et coordonnées**
|
||||||
|
> du café) vont ensemble et vivent dans `extension/config.js` : c'est là qu'on
|
||||||
|
> les change. Y mettre un `cafe` partiel produirait des coordonnées invalides.
|
||||||
|
|
||||||
|
> **Après avoir créé ou modifié `config.local.json`** : recharge le module dans
|
||||||
|
> `about:debugging`. L'extension lit le fichier au réveil de son arrière-plan,
|
||||||
|
> pas à chaque ouverture du formulaire.
|
||||||
|
|
||||||
### Vérifier l'extension
|
### Vérifier l'extension
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
// config.js n'exporte RIEN (chargeable en script classique ET en module) :
|
||||||
|
// on l'importe pour son effet de bord et on lit le namespace global.
|
||||||
|
import "./extension/config.js";
|
||||||
|
|
||||||
|
const { DEFAUTS, fusionner, CLE_CONFIG_LOCALE } = (globalThis as any).EchoConfig;
|
||||||
|
|
||||||
|
describe("DEFAUTS", () => {
|
||||||
|
test("themeDefaut est une valeur exacte du dropdown mairie (RECHERCHE §3)", () => {
|
||||||
|
expect(DEFAUTS.themeDefaut).toBe("Culture");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("organisateur et email sont vides : jamais inventés, signalés à l'utilisateur", () => {
|
||||||
|
expect(DEFAUTS.organisateur).toBe("");
|
||||||
|
expect(DEFAUTS.email).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le café porte l'adresse et les coordonnées de RECHERCHE §2", () => {
|
||||||
|
expect(DEFAUTS.cafe.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux");
|
||||||
|
expect(DEFAUTS.cafe.coords).toBe("43.53753132806989|1.342981890597508");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tarifs par défaut : Gratuit", () => {
|
||||||
|
expect(DEFAUTS.tarifs).toBe("Gratuit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CLE_CONFIG_LOCALE", () => {
|
||||||
|
test("est la clé de storage partagée background → content script", () => {
|
||||||
|
expect(CLE_CONFIG_LOCALE).toBe("config-locale");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fusionner", () => {
|
||||||
|
// Un clone frais n'a pas de config.local.json : le repli doit être total,
|
||||||
|
// jamais une exception.
|
||||||
|
test.each([
|
||||||
|
["absente", undefined],
|
||||||
|
["null", null],
|
||||||
|
["une chaîne", "n'importe quoi"],
|
||||||
|
["un tableau vide", []],
|
||||||
|
])("locale %s → défauts nus", (_libelle, locale) => {
|
||||||
|
expect(fusionner(DEFAUTS, locale)).toEqual(DEFAUTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("organisateur et email surchargent, le reste survit", () => {
|
||||||
|
const fusion = fusionner(DEFAUTS, {
|
||||||
|
organisateur: "L'Atelier du Huit",
|
||||||
|
email: "contact@atelier-huit.fr",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fusion.organisateur).toBe("L'Atelier du Huit");
|
||||||
|
expect(fusion.email).toBe("contact@atelier-huit.fr");
|
||||||
|
expect(fusion.themeDefaut).toBe("Culture");
|
||||||
|
expect(fusion.cafe).toEqual(DEFAUTS.cafe);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ne mute pas DEFAUTS (le module est partagé par tout le monde)", () => {
|
||||||
|
fusionner(DEFAUTS, { organisateur: "Autre" });
|
||||||
|
|
||||||
|
expect(DEFAUTS.organisateur).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,3 +126,58 @@ deux avals du parseur ont chacun le leur : la liste affiche l'adresse pour ne pa
|
|||||||
montrer une ligne vide (cas majoritaire), le content script décide s'il pose
|
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
|
**aussi** les coordonnées (`input_18`). Écrire l'adresse dans le payload
|
||||||
détruirait le signal « pas de lieu » dont il a besoin.
|
détruirait le signal « pas de lieu » dont il a besoin.
|
||||||
|
|
||||||
|
## 2026-07-17 — [T1] Pré-remplissage : content scripts classiques + `globalThis`
|
||||||
|
|
||||||
|
Le formulaire mairie est pré-rempli par deux content scripts (`config.js`,
|
||||||
|
`formulaire-valeurs.js`, `formulaire-mairie.js`), **classiques — pas des modules
|
||||||
|
ESM** — qui communiquent par des namespaces `globalThis` (`EchoConfig`,
|
||||||
|
`EchoFormulaire`).
|
||||||
|
|
||||||
|
**Pourquoi pas ESM :** un content script n'est pas un module (`import` y lève une
|
||||||
|
`SyntaxError`), et le `import()` dynamique y est cassé côté Firefox (bugs ouverts
|
||||||
|
1803950 / 1536094). Restait à convertir la couture `transfert.js`, figée le
|
||||||
|
2026-07-14 : non.
|
||||||
|
|
||||||
|
⚠️ **`config.js` et `formulaire-valeurs.js` n'ont NI `import` NI `export`, et
|
||||||
|
c'est délibéré — ne pas « corriger ».** Un fichier sans les deux est valide **à la
|
||||||
|
fois** comme script classique (content script, background) et comme module ESM
|
||||||
|
(`import "./config.js"` depuis `presentation.js` et depuis `bun test`). C'est ce
|
||||||
|
qui permet à `presentation.js` de partager l'adresse du café **sans build**. Le
|
||||||
|
jour où quelqu'un y ajoute un `export`, les content scripts cassent
|
||||||
|
silencieusement.
|
||||||
|
|
||||||
|
**Corollaire : l'ordre des tableaux `js` du manifest EST la dépendance**
|
||||||
|
(`config` → `valeurs` → `mairie`), testé dans `extension.test.ts`.
|
||||||
|
|
||||||
|
**`CLE_EVENEMENT_EN_ATTENTE` est dupliquée** dans `formulaire-valeurs.js`
|
||||||
|
(l'original vit dans `transfert.js`, en ESM, inatteignable depuis un content
|
||||||
|
script classique). Un **test de dérive** garantit l'égalité des deux.
|
||||||
|
|
||||||
|
**Config (a) : défauts committés + `config.local.json` optionnel.** Les défauts
|
||||||
|
(thème, tarifs, café) sont dans `config.js` ; seuls organisateur et email sont
|
||||||
|
personnels et vivent dans `config.local.json` (gitignoré). Le fichier n'est
|
||||||
|
**pas requis** : un clone frais doit marcher, il laisserait sinon l'extension
|
||||||
|
cassée par défaut. Absent ou invalide → défauts nus, manque **signalé** dans le
|
||||||
|
bandeau, jamais d'exception.
|
||||||
|
|
||||||
|
**C'est le background qui lit `config.local.json`**, puis le passe par le
|
||||||
|
storage. Un content script ne pourrait le `fetch(runtime.getURL(...))` que si le
|
||||||
|
fichier était en `web_accessible_resources` — ce qui **exposerait l'email à la
|
||||||
|
page de la mairie**. Le fichier étant optionnel, il ne peut pas non plus être
|
||||||
|
listé dans un tableau `js` (Firefox refuse d'installer si un fichier déclaré
|
||||||
|
manque) : `fetch` + repli est la seule voie.
|
||||||
|
|
||||||
|
**On ne remplit que ce qu'on sait ; le reste est dit, jamais inventé.** Pas
|
||||||
|
d'heure « 00:00 » pour une journée entière, pas de `lat|lng` deviné pour un lieu
|
||||||
|
hors café, pas d'organisateur par défaut. Ce qui manque part dans le bandeau
|
||||||
|
(`aCompleter`). En particulier, **`input_6_35` (accessibilité) est laissé
|
||||||
|
intact** : `RECHERCHE.md` le dit « fixe », mais la machine ne sait pas si le lieu
|
||||||
|
est accessible — cocher au hasard sur un formulaire **modéré** serait exactement
|
||||||
|
le « champ rempli faux » qu'on veut éviter.
|
||||||
|
|
||||||
|
**Séparation calcul / DOM** : `formulaire-valeurs.js` est pur et testé (les dates
|
||||||
|
sont le vrai risque, invisible sous `TZ=Europe/Paris`) ; `formulaire-mairie.js`
|
||||||
|
est fin, impur et **non testé unitairement** — aucun harnais DOM dans le repo, et
|
||||||
|
happy-dom n'émulerait de toute façon pas Gravity Forms. C'est le test humain qui
|
||||||
|
tranche.
|
||||||
|
|||||||
+28
-18
@@ -45,24 +45,34 @@ Content script qui **remplit le DOM par id**, puis **l'utilisateur soumet à la
|
|||||||
|
|
||||||
### Mapping des champs (ids confirmés présents dans le HTML)
|
### Mapping des champs (ids confirmés présents dans le HTML)
|
||||||
|
|
||||||
| id DOM | name | Contenu | Source |
|
> ⚠️ **La colonne « balise réelle » a été payée par un bug** (2026-07-17). Le
|
||||||
|---|---|---|---|
|
> corps de la tâche T1 affirmait « Heures = 2 **selects** » : c'est **faux**, ce
|
||||||
| `#input_6_42` | `input_42` | Organisateur | config (« L'Atelier du Huit ») |
|
> sont des `<input type="number">`. Le code l'a suivi et les 4 champs d'heures
|
||||||
| `#input_6_41` | `input_41` | Email | config |
|
> ont échoué (`champ.options` undefined → TypeError). **Ne pas déduire le type
|
||||||
| `#input_6_1` | `input_1` | Titre | SUMMARY |
|
> d'un champ de son intitulé : le HTML est la seule source de vérité.** Types
|
||||||
| `#input_6_3` | `input_3` | Description (textarea) | DESCRIPTION |
|
> ci-dessous vérifiés sur la page réelle le 2026-07-17.
|
||||||
| `#input_6_20` | `input_20` | Thème (select) | mapping (voir §3) |
|
|
||||||
| `#input_6_31` | `input_31` | Date début `jj/mm/aaaa` | DTSTART |
|
| id DOM | name | Balise réelle | Contenu | Source |
|
||||||
| `#input_6_32` | `input_32` | Date fin `jj/mm/aaaa` | DTEND |
|
|---|---|---|---|---|
|
||||||
| `#input_6_38_1` / `#input_6_38_2` | `input_38[]` | Heure début [HH, MM] | DTSTART |
|
| `#input_6_42` | `input_42` | `input[type=text]` | Organisateur | config (« L'Atelier du Huit ») |
|
||||||
| `#input_6_39_1` / `#input_6_39_2` | `input_39[]` | Heure fin [HH, MM] | DTEND |
|
| `#input_6_41` | `input_41` | `input[type=email]` | Email | config |
|
||||||
| `#input_6_35` (`_1`) | `input_35.1` | Accessibilité (case) | fixe |
|
| `#input_6_1` | `input_1` | `input[type=text]` | Titre | SUMMARY |
|
||||||
| `#input_6_36` | `input_36` | Public ciblé | optionnel |
|
| `#input_6_3` | `input_3` | `textarea` | Description | DESCRIPTION |
|
||||||
| `#input_6_40` | `input_40` | Tarifs | défaut « Gratuit » |
|
| `#input_6_20` | `input_20` | **`select`** | Thème | mapping (voir §3) |
|
||||||
| `#nova_address` | `nova_address` | Adresse texte | défaut café |
|
| `#input_6_31` | `input_31` | `input[type=text]` (datepicker) | Date début `jj/mm/aaaa` | DTSTART |
|
||||||
| `#input_6_18` | `input_18` | `lat\|lng\|adresse` (hidden) | défaut café |
|
| `#input_6_32` | `input_32` | `input[type=text]` (datepicker) | Date fin `jj/mm/aaaa` | DTEND |
|
||||||
| `#input_6_34` | `input_34` | **Image (file)** | ⚠️ non remplissable par script (sécurité) → manuel |
|
| `#input_6_38_1` / `#input_6_38_2` | `input_38[]` | **`input[type=number]`** (`min/max`, placeholder `HH`/`MM`) | Heure début [HH, MM] | DTSTART |
|
||||||
| `#input_6_23`, `#input_6_43` | | Honeypot | laisser vide |
|
| `#input_6_39_1` / `#input_6_39_2` | `input_39[]` | **`input[type=number]`** | Heure fin [HH, MM] | DTEND |
|
||||||
|
| `#input_6_35` (`_1`) | `input_35.1` | `input[type=checkbox]` | Accessibilité | ⚠️ laissé **intact** : la machine ne sait pas si le lieu est accessible |
|
||||||
|
| `#input_6_36` | `input_36` | | Public ciblé | optionnel |
|
||||||
|
| `#input_6_40` | `input_40` | **`textarea`** | Tarifs | défaut « Gratuit » |
|
||||||
|
| `#nova_address` | `nova_address` | `input[type=text]` | Adresse texte | défaut café |
|
||||||
|
| `#input_6_18` | `input_18` | `input[type=hidden]` | `lat\|lng\|adresse` | défaut café |
|
||||||
|
| `#input_6_34` | `input_34` | `input[type=file]` | **Image** | ⚠️ non remplissable par script (sécurité) → manuel |
|
||||||
|
| `#input_6_23`, `#input_6_43` | | | Honeypot | laisser vide |
|
||||||
|
|
||||||
|
Les 4 champs d'heures acceptent le **zéro-padding** (`"09"`, `"00"`) : une suite
|
||||||
|
de chiffres est un *valid floating-point number* au sens HTML.
|
||||||
|
|
||||||
**Défaut café** : `8 Rue du Pré Vicinal 31270 Cugnaux`
|
**Défaut café** : `8 Rue du Pré Vicinal 31270 Cugnaux`
|
||||||
`input_18` = `43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux`
|
`input_18` = `43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux`
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
# Solution : [T1] Content script : pré-remplir le formulaire mairie (+ config)
|
||||||
|
|
||||||
|
## Problème résolu
|
||||||
|
|
||||||
|
Au clic sur « Créer sur le site mairie », l'extension déposait l'événement dans
|
||||||
|
`storage.local` puis ouvrait un **formulaire vide** : le bénévole devait recopier
|
||||||
|
à la main titre, dates, heures, adresse, organisateur, email — exactement le
|
||||||
|
geste que l'extension existe pour supprimer. La boucle n'était pas fermée.
|
||||||
|
|
||||||
|
Objectif tenu : le formulaire s'ouvre **déjà rempli** de ce que la machine sait,
|
||||||
|
**laisse vide** ce qu'elle ignore (jamais de valeur inventée), et **dit** dans un
|
||||||
|
bandeau ce qui reste à compléter. Le bénévole relit, complète (image, thème,
|
||||||
|
enrichissement) et envoie lui-même — **pas d'auto-submit**.
|
||||||
|
|
||||||
|
Ce ticket a aussi **introduit la config** de l'extension.
|
||||||
|
|
||||||
|
## Approche choisie
|
||||||
|
|
||||||
|
**Deux content scripts classiques partageant un scope + namespace `globalThis`**
|
||||||
|
(approche 3 du brainstorm), **+ config à défauts committés et surcharge locale
|
||||||
|
optionnelle** (option (a)).
|
||||||
|
|
||||||
|
Le vrai risque du ticket n'était pas « poser des valeurs dans un DOM » : c'était
|
||||||
|
le **calcul des dates** (DTEND exclusif, dates flottantes de journée entière,
|
||||||
|
franchissement de minuit) — invisible sous `TZ=Europe/Paris`, la famille de bugs
|
||||||
|
qui avait déjà coûté deux passes au ticket précédent. Il fallait donc que ce
|
||||||
|
calcul reste **sous `bun test`**.
|
||||||
|
|
||||||
|
Trois alternatives écartées, et pourquoi :
|
||||||
|
- **Content script monolithique** : logique de dates intestable (rien à importer
|
||||||
|
en `bun test`) → on coderait à l'aveugle les deux bugs les plus chers.
|
||||||
|
- **Module ESM en import dynamique** (`import(runtime.getURL(...))` +
|
||||||
|
`web_accessible_resources`) : s'appuie sur un **bug ouvert MV3 de Firefox**
|
||||||
|
([bugzilla 1803950](https://bugzilla.mozilla.org/show_bug.cgi?id=1803950)) —
|
||||||
|
parier le risque sur le point le plus fragile de la plateforme.
|
||||||
|
- **Formater en amont, content script bête** : réviserait une **couture figée
|
||||||
|
trois jours plus tôt** et coupleraient la page liste au formulaire mairie.
|
||||||
|
|
||||||
|
L'approche retenue garde le calcul testable **sans** parier sur un bug plateforme
|
||||||
|
**ni** rouvrir une couture fraîche.
|
||||||
|
|
||||||
|
## Décisions clés
|
||||||
|
|
||||||
|
- **`config.js` sans `import` ni `export`, communique par `globalThis`.** Un
|
||||||
|
fichier sans `import`/`export` est valide **à la fois** comme script classique
|
||||||
|
(content script, background) **et** comme module ESM (`import "./config.js"`
|
||||||
|
depuis `presentation.js` et `bun test`). C'est ce qui permet d'absorber
|
||||||
|
`LIEU_PAR_DEFAUT` (fin d'une duplication) sans build. ⚠️ Le jour où quelqu'un
|
||||||
|
ajoute un `export`, tous les content scripts cassent → acté dans `DECISIONS.md`,
|
||||||
|
avec un commentaire en tête de fichier.
|
||||||
|
|
||||||
|
- **Le background lit `config.local.json`, pas le content script.** Un content
|
||||||
|
script ne peut `fetch(runtime.getURL(...))` que si la ressource est en
|
||||||
|
`web_accessible_resources` — ce qui **exposerait l'email à la page de la
|
||||||
|
mairie**. Le background lit sans WAR et transmet via `storage.local`.
|
||||||
|
|
||||||
|
- **Config optionnelle, dégradation propre.** Fichier absent (clone frais), HTTP
|
||||||
|
non-ok ou JSON invalide → `{}` → organisateur/email vides **signalés dans le
|
||||||
|
bandeau**, tout le reste rempli. Un `config.local.json` **requis** (énoncé
|
||||||
|
littéral) aurait cassé tout clone frais : rejeté. Le fichier n'étant pas
|
||||||
|
garanti présent, il **ne peut pas** être listé dans le manifest (Firefox refuse
|
||||||
|
d'installer si un fichier `js` manque) : `fetch` + repli est la seule voie.
|
||||||
|
|
||||||
|
- **`CLE_EVENEMENT_EN_ATTENTE` dupliquée + test de dérive.** La constante vit dans
|
||||||
|
`transfert.js` (ESM), inatteignable depuis un content script classique.
|
||||||
|
Dupliquée volontairement, avec un test qui garantit l'égalité avec l'originale
|
||||||
|
(plutôt que rouvrir la couture pour convertir `transfert.js`).
|
||||||
|
|
||||||
|
- **Honnêteté machine/humain.** Jamais de « 00:00 » sur une journée entière
|
||||||
|
(heures laissées vides), jamais de `lat|lng` inventé sur un lieu hors café,
|
||||||
|
`input_6_35` (accessibilité) laissé intact, honeypots intacts. Tout manque
|
||||||
|
remonte dans `aCompleter` (bandeau).
|
||||||
|
|
||||||
|
- **`config.local.json.example` dans `extension/`, pas à la racine.** Le
|
||||||
|
background résout `runtime.getURL("config.local.json")` **dans `extension/`** :
|
||||||
|
l'exemple doit être à côté de sa cible de copie.
|
||||||
|
|
||||||
|
## Patterns à réutiliser
|
||||||
|
|
||||||
|
- **Fichier « bilingue » ESM/classique** : ne poser `import`/`export` nulle part,
|
||||||
|
publier l'API sur `globalThis.MonNamespace`. Chargeable partout dans une
|
||||||
|
extension MV3 sans build. Le prix : une convention à documenter pour qu'un futur
|
||||||
|
agent ne la « corrige » pas en `export`.
|
||||||
|
|
||||||
|
- **Séparer calcul pur (testé) / effet de bord (fin, non testé).**
|
||||||
|
`calculerValeurs(payload, config)` est pur et renvoie un objet **plat, prêt à
|
||||||
|
poser** (`dateDebut: "18/07/2026"`, `heureDebut: ["19","30"] | null`, …) ;
|
||||||
|
`formulaire-mairie.js` ne fait que lire le storage et écrire le DOM. Le bug des
|
||||||
|
heures (voir Pièges) a **validé** ce découpage : confiné au poseur, diagnostiqué
|
||||||
|
en une pile d'appels, corrigé en quelques lignes, cœur testé jamais touché.
|
||||||
|
|
||||||
|
- **Merge tolérant pour config éditée à la main** : `fusionner(defauts, locale)`
|
||||||
|
traite `null` / non-objet / tableau comme « pas de surcharge » et ne mute pas
|
||||||
|
les défauts. Une config optionnelle ne doit **jamais** lever.
|
||||||
|
|
||||||
|
- **Dates de calendrier sans fuseau.** Journée entière = date flottante
|
||||||
|
(RFC 5545) → lire **uniquement** les composantes locales
|
||||||
|
(`getFullYear/getMonth/getDate`), jamais l'heure, jamais un fuseau. DTEND est
|
||||||
|
**exclusif** → reculer la date de fin d'un jour (en composantes locales, jamais
|
||||||
|
« −24h »). Événement horodaté → tout à `Europe/Paris` via `Intl.DateTimeFormat`,
|
||||||
|
heures zéro-paddées. **Épingler `TZ=Europe/Paris` dans les tests ET rejouer sous
|
||||||
|
plusieurs fuseaux** (le bug de fuseau est invisible sous le fuseau de dev).
|
||||||
|
|
||||||
|
- **Poser une valeur dans un champ piloté par un framework** : après `.value`,
|
||||||
|
dispatcher `new Event("input", {bubbles:true})` **puis**
|
||||||
|
`new Event("change", {bubbles:true})` (Gravity Forms écoute `change`). Le
|
||||||
|
dispatch est gratuit — ne pas supposer que `.value` suffit.
|
||||||
|
|
||||||
|
- **Isoler chaque effet de bord dans son propre `try/catch`.** Un `try` global
|
||||||
|
autour du remplissage laissait la première exception emporter **tous** les
|
||||||
|
champs suivants. Un `try` **par champ** → le champ fautif part dans `manquants`,
|
||||||
|
les autres sont posés. Ferme la classe entière de panne, pas le cas vu.
|
||||||
|
|
||||||
|
- **Rendre tout geste automatique visible.** Un bandeau injecté dit ce qui a été
|
||||||
|
fait et ce qui reste ; storage vide (visite directe) → **rien**. Un
|
||||||
|
préremplissage silencieusement partiel soumis à une mairie qui modère est le
|
||||||
|
pire échec possible.
|
||||||
|
|
||||||
|
## Pièges à éviter
|
||||||
|
|
||||||
|
- 🔴 **Ne jamais déduire le type d'un champ de son intitulé — le HTML est la seule
|
||||||
|
source de vérité.** L'énoncé du ticket affirmait « Heures = 2 **selects**
|
||||||
|
input_6_38_1/_2 ». Le HTML réel dit `<input type="number">`. `poserSelect`
|
||||||
|
faisait `[...champ.options]` sur un `<input>` → `champ.options` undefined →
|
||||||
|
`TypeError`, qui (avant l'isolation par champ) vidait aussi Tarifs, adresse et
|
||||||
|
coordonnées. Corrigé : heures via `poserTexte` (le zéro-padding `"09"`/`"00"`
|
||||||
|
reste un *valid floating-point number* au sens HTML). `RECHERCHE.md` §2 a été
|
||||||
|
corrigée à la racine (colonne « balise réelle » + avertissement) pour qu'un
|
||||||
|
futur agent ne repaie pas ce bug. C'est la cause racine du seul bug du ticket,
|
||||||
|
et il venait d'un **doc/énoncé faux cru sur parole**.
|
||||||
|
|
||||||
|
- **Toucher au `manifest.json` impose Décharger + Charger**, pas « Recharger » ni
|
||||||
|
F5. Piège déjà payé plusieurs fois. Fermer aussi les anciens onglets de la liste
|
||||||
|
(page orpheline = clics inertes).
|
||||||
|
|
||||||
|
- **DTEND exclusif** : sans recul d'un jour, la date de fin d'un événement
|
||||||
|
multi-jours est **fausse** — et soumise à une mairie qui modère (personne ne
|
||||||
|
relit une date qui *a l'air* juste). Le bug le plus cher du ticket.
|
||||||
|
|
||||||
|
- **Course background ↔ content script** (P3 ouvert, non corrigé) :
|
||||||
|
`chargerConfigLocale()` est async ; au tout premier chargement post-install, si
|
||||||
|
le formulaire s'ouvrait avant la fin du `fetch`, organisateur/email seraient
|
||||||
|
vides à tort. Improbable (la valeur persiste ensuite), noté pour mémoire.
|
||||||
|
|
||||||
|
- **`chargerConfigLocale()` s'exécute à chaque réveil de l'event page** et écrit
|
||||||
|
`{}` si le fichier est absent. Toute future page d'options qui écrirait dans
|
||||||
|
`storage.local` verrait ses valeurs **écrasées par `{}`** au prochain réveil.
|
||||||
|
Trancher « qui fait autorité, fichier ou UI » fait partie du futur ticket — ce
|
||||||
|
n'est pas « juste ajouter un écran ».
|
||||||
|
|
||||||
|
- **Le poseur DOM n'est pas testé unitairement** (aucun harnais DOM dans le repo ;
|
||||||
|
happy-dom n'émule pas le JS de Gravity Forms / Leaflet). C'est le **test humain**
|
||||||
|
qui tranche le rendu réel (datepicker, marqueur carte, bandeau non écrasé par le
|
||||||
|
CSS de la mairie). Le bug des heures l'a prouvé : seul le DOM réel tranche.
|
||||||
|
|
||||||
|
- **Config `cafe` partielle interdite** : `fusionner` est shallow → une locale
|
||||||
|
avec un `cafe` incomplet produirait `"undefined|adresse"`. Le README précise que
|
||||||
|
seuls `organisateur` et `email` sont surchargeables.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
tags: [firefox-extension, manifest-v3, content-script, globalthis, esm, gravity-forms, dates, timezone, rfc5545, config, bun-test, tdd, vanilla-js]
|
||||||
@@ -14,6 +14,12 @@ describe("manifest.json", () => {
|
|||||||
expect(manifest.background.scripts).toContain("background.js");
|
expect(manifest.background.scripts).toContain("background.js");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scripts classiques : pas d'import, l'ordre du tableau EST la dépendance.
|
||||||
|
// config.js pose globalThis.EchoConfig, que background.js lit.
|
||||||
|
test("charge config.js avant background.js", () => {
|
||||||
|
expect(manifest.background.scripts).toEqual(["config.js", "background.js"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("demande les host_permissions des deux sources", () => {
|
test("demande les host_permissions des deux sources", () => {
|
||||||
expect(manifest.host_permissions).toEqual([
|
expect(manifest.host_permissions).toEqual([
|
||||||
"https://atelier-huit.frama.space/*",
|
"https://atelier-huit.frama.space/*",
|
||||||
@@ -34,11 +40,47 @@ describe("manifest.json", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("content_scripts (pré-remplissage du formulaire mairie)", () => {
|
||||||
|
const contentScript = manifest.content_scripts?.[0];
|
||||||
|
|
||||||
|
// URL exacte du formulaire, pas tout ville-cugnaux.fr : on n'injecte pas de
|
||||||
|
// code sur les pages d'un tiers sans raison.
|
||||||
|
test("ne s'injecte que sur le formulaire de la mairie", () => {
|
||||||
|
expect(contentScript.matches).toEqual([
|
||||||
|
"https://www.ville-cugnaux.fr/mes-loisirs/associations/proposer-un-evenement-dans-lagenda/*",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("charge config, valeurs puis poseur (l'ordre EST la dépendance)", () => {
|
||||||
|
expect(contentScript.js).toEqual([
|
||||||
|
"config.js",
|
||||||
|
"formulaire-valeurs.js",
|
||||||
|
"formulaire-mairie.js",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("porte le style du bandeau", () => {
|
||||||
|
expect(contentScript.css).toEqual(["formulaire-mairie.css"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chaque fichier déclaré existe sur le disque", () => {
|
||||||
|
for (const fichier of [...contentScript.js, ...contentScript.css]) {
|
||||||
|
expect(existsSync(join(extensionDir, fichier))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ressources référencées", () => {
|
describe("ressources référencées", () => {
|
||||||
test("background.js existe", () => {
|
test("background.js existe", () => {
|
||||||
expect(existsSync(join(extensionDir, "background.js"))).toBe(true);
|
expect(existsSync(join(extensionDir, "background.js"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("chaque script d'arrière-plan existe", () => {
|
||||||
|
for (const fichier of manifest.background.scripts) {
|
||||||
|
expect(existsSync(join(extensionDir, fichier))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("liste.html existe", () => {
|
test("liste.html existe", () => {
|
||||||
expect(existsSync(join(extensionDir, "liste.html"))).toBe(true);
|
expect(existsSync(join(extensionDir, "liste.html"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,3 +3,27 @@
|
|||||||
browser.action.onClicked.addListener(() => {
|
browser.action.onClicked.addListener(() => {
|
||||||
browser.tabs.create({ url: browser.runtime.getURL("liste.html") });
|
browser.tabs.create({ url: browser.runtime.getURL("liste.html") });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// C'est le background qui lit config.local.json, pas le content script : ce
|
||||||
|
// dernier ne pourrait le faire que via web_accessible_resources, ce qui
|
||||||
|
// exposerait l'email à la page de la mairie. Il la reçoit par le storage.
|
||||||
|
//
|
||||||
|
// Le fichier est OPTIONNEL (absent d'un clone frais), donc impossible à
|
||||||
|
// déclarer dans un tableau `js` du manifest — Firefox refuserait d'installer
|
||||||
|
// l'extension. D'où fetch + repli : absent ou JSON invalide → {} → défauts nus,
|
||||||
|
// jamais d'exception.
|
||||||
|
async function chargerConfigLocale() {
|
||||||
|
let locale = {};
|
||||||
|
try {
|
||||||
|
const reponse = await fetch(browser.runtime.getURL("config.local.json"));
|
||||||
|
if (reponse.ok) locale = await reponse.json();
|
||||||
|
} catch {
|
||||||
|
console.warn("Écho du Huit : config.local.json illisible, défauts utilisés.");
|
||||||
|
}
|
||||||
|
await browser.storage.local.set({ [globalThis.EchoConfig.CLE_CONFIG_LOCALE]: locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
// À chaque réveil de l'event page, et pas seulement à l'installation : le
|
||||||
|
// fichier peut avoir changé depuis, le storage doit refléter le disque.
|
||||||
|
chargerConfigLocale();
|
||||||
|
browser.runtime.onInstalled.addListener(chargerConfigLocale);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Défauts committés de l'extension + fusion avec la surcharge locale.
|
||||||
|
//
|
||||||
|
// ⚠️ Ce fichier n'a NI `import` NI `export`, DÉLIBÉRÉMENT (ne pas « corriger ») :
|
||||||
|
// c'est ce qui le rend chargeable à la fois comme content script / script de
|
||||||
|
// background (scripts classiques : un `export` y lève une SyntaxError) et comme
|
||||||
|
// module ESM (`import "./config.js"` depuis presentation.js et bun test).
|
||||||
|
// Il communique donc par `globalThis`. Cf. DECISIONS.md 2026-07-17.
|
||||||
|
|
||||||
|
globalThis.EchoConfig = {
|
||||||
|
// Clé de storage : le background écrit, le content script lit.
|
||||||
|
CLE_CONFIG_LOCALE: "config-locale",
|
||||||
|
|
||||||
|
DEFAUTS: {
|
||||||
|
// Vides par défaut : sans config.local.json, on préfère un champ vide
|
||||||
|
// signalé dans le bandeau à un organisateur inventé.
|
||||||
|
organisateur: "",
|
||||||
|
email: "",
|
||||||
|
|
||||||
|
// RECHERCHE.md §3 : valeur exacte du dropdown #input_6_20.
|
||||||
|
themeDefaut: "Culture",
|
||||||
|
tarifs: "Gratuit",
|
||||||
|
|
||||||
|
// RECHERCHE.md §2. `input_18` se compose `${coords}|${adresse}`.
|
||||||
|
cafe: {
|
||||||
|
adresse: "8 Rue du Pré Vicinal 31270 Cugnaux",
|
||||||
|
coords: "43.53753132806989|1.342981890597508",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fusion de surface : la locale ne porte que des champs plats
|
||||||
|
// (organisateur, email…). Tolérante par nécessité — `config.local.json` est
|
||||||
|
// optionnel et éditable à la main : absent, `null` ou invalide → défauts nus,
|
||||||
|
// jamais d'exception.
|
||||||
|
fusionner(defauts, locale) {
|
||||||
|
const surcharge =
|
||||||
|
locale && typeof locale === "object" && !Array.isArray(locale) ? locale : {};
|
||||||
|
return { ...defauts, ...surcharge };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"organisateur": "L'Atelier du Huit",
|
||||||
|
"email": "contact@atelier-huit.fr"
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/* Bandeau injecté dans la page de la mairie. Propriétés posées explicitement :
|
||||||
|
le CSS du site est hors de notre contrôle et pourrait tout hériter. */
|
||||||
|
|
||||||
|
.echo-bandeau {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid #1a73e8;
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f2f7fe;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echo-bandeau p {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echo-bandeau-titre {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echo-bandeau-liste {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echo-bandeau-liste li {
|
||||||
|
margin: 0;
|
||||||
|
color: inherit;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echo-bandeau-consigne {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echo-bandeau-alerte {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Poseur : lit le storage, écrit le DOM du formulaire mairie, injecte le
|
||||||
|
// bandeau. Volontairement FIN et sans logique métier — tout le calcul est dans
|
||||||
|
// formulaire-valeurs.js, sous tests. Ici, c'est le test humain qui tranche.
|
||||||
|
//
|
||||||
|
// Content script classique : pas d'import, les dépendances arrivent par
|
||||||
|
// globalThis (ordre du tableau `js` du manifest — cf. DECISIONS.md 2026-07-17).
|
||||||
|
//
|
||||||
|
// PAS d'auto-submit : on pré-remplit, l'humain relit et envoie.
|
||||||
|
|
||||||
|
// RECHERCHE.md §2 (form id = 6). Non touchés délibérément : input_6_34 (image,
|
||||||
|
// impossible par script), input_6_23 / input_6_43 (honeypot), les tokens CSRF,
|
||||||
|
// et input_6_35 (accessibilité : la machine ne sait pas si le lieu est
|
||||||
|
// accessible — cocher au hasard serait un champ rempli faux).
|
||||||
|
const CHAMP_ORGANISATEUR = "input_6_42";
|
||||||
|
const CHAMP_EMAIL = "input_6_41";
|
||||||
|
const CHAMP_TITRE = "input_6_1";
|
||||||
|
const CHAMP_DESCRIPTION = "input_6_3";
|
||||||
|
const CHAMP_THEME = "input_6_20";
|
||||||
|
const CHAMP_DATE_DEBUT = "input_6_31";
|
||||||
|
const CHAMP_DATE_FIN = "input_6_32";
|
||||||
|
const CHAMP_HEURE_DEBUT = "input_6_38";
|
||||||
|
const CHAMP_HEURE_FIN = "input_6_39";
|
||||||
|
const CHAMP_TARIFS = "input_6_40";
|
||||||
|
const CHAMP_ADRESSE = "nova_address";
|
||||||
|
const CHAMP_COORDONNEES = "input_6_18";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { EchoConfig, EchoFormulaire } = globalThis;
|
||||||
|
|
||||||
|
// Storage illisible ou visite directe du formulaire (sans passer par la
|
||||||
|
// liste) : on ne sait pas s'il y a un travail à faire, donc on ne touche à
|
||||||
|
// rien et on n'affiche aucun bandeau.
|
||||||
|
const stock = await lireStock();
|
||||||
|
if (!stock) return;
|
||||||
|
|
||||||
|
const payload = stock[EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE];
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// On n'efface PAS le storage à la lecture : le pré-remplissage survit à un
|
||||||
|
// F5 (couture actée — DECISIONS.md 2026-07-14).
|
||||||
|
const config = EchoConfig.fusionner(
|
||||||
|
EchoConfig.DEFAUTS,
|
||||||
|
stock[EchoConfig.CLE_CONFIG_LOCALE],
|
||||||
|
);
|
||||||
|
const valeurs = EchoFormulaire.calculerValeurs(payload, config);
|
||||||
|
|
||||||
|
injecterBandeau(valeurs.aCompleter, remplir(valeurs));
|
||||||
|
} catch (erreur) {
|
||||||
|
// Filet : cette fonction est `async`, une exception y deviendrait une
|
||||||
|
// rejection non gérée — ni remplissage, ni bandeau, ni message. Le bénévole
|
||||||
|
// ne verrait rien du tout. Ce poseur n'étant pas testé unitairement (choix
|
||||||
|
// acté), le filet est sa seule ligne de défense.
|
||||||
|
// Cas réel : la mairie remplace un <select> par un <input> → `champ.options`
|
||||||
|
// est undefined → TypeError au milieu du remplissage.
|
||||||
|
console.error("Écho du Huit : pré-remplissage interrompu.", erreur);
|
||||||
|
injecterBandeauDegrade();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function lireStock() {
|
||||||
|
const { EchoConfig, EchoFormulaire } = globalThis;
|
||||||
|
try {
|
||||||
|
return await browser.storage.local.get([
|
||||||
|
EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE,
|
||||||
|
EchoConfig.CLE_CONFIG_LOCALE,
|
||||||
|
]);
|
||||||
|
} catch (erreur) {
|
||||||
|
console.error("Écho du Huit : storage illisible.", erreur);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renvoie les ids qu'on n'a PAS pu remplir (formulaire de la mairie changé ?).
|
||||||
|
function remplir(valeurs) {
|
||||||
|
const manquants = [];
|
||||||
|
|
||||||
|
// Un champ inattendu (type changé côté mairie) ne doit pas emporter tous les
|
||||||
|
// champs suivants : on le compte comme manquant et on continue. Sans ça, une
|
||||||
|
// seule exception vide silencieusement toute la fin du formulaire.
|
||||||
|
const poser = (poseur, id, valeur) => {
|
||||||
|
try {
|
||||||
|
if (!poseur(id, valeur)) manquants.push(id);
|
||||||
|
} catch (erreur) {
|
||||||
|
console.warn(`Écho du Huit : échec sur #${id}.`, erreur);
|
||||||
|
manquants.push(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const texte = (id, valeur) => poser(poserTexte, id, valeur);
|
||||||
|
const select = (id, valeur) => poser(poserSelect, id, valeur);
|
||||||
|
|
||||||
|
// heure === null (journée entière, DTEND absent) : ne rien poser est le
|
||||||
|
// comportement voulu, pas un échec.
|
||||||
|
// Les 4 champs d'heures sont des <input type="number"> (vérifié sur le HTML
|
||||||
|
// réel), PAS des selects — contrairement à ce qu'annonçait l'énoncé du
|
||||||
|
// ticket. Ils acceptent « 09 » et « 00 » (chiffres = valid floating-point
|
||||||
|
// number au sens HTML), donc le zéro-padding du calcul convient tel quel.
|
||||||
|
const heure = (prefixe, valeur) => {
|
||||||
|
if (valeur === null) return;
|
||||||
|
texte(`${prefixe}_1`, valeur[0]);
|
||||||
|
texte(`${prefixe}_2`, valeur[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
texte(CHAMP_ORGANISATEUR, valeurs.organisateur);
|
||||||
|
texte(CHAMP_EMAIL, valeurs.email);
|
||||||
|
texte(CHAMP_TITRE, valeurs.titre);
|
||||||
|
texte(CHAMP_DESCRIPTION, valeurs.description);
|
||||||
|
select(CHAMP_THEME, valeurs.theme);
|
||||||
|
texte(CHAMP_DATE_DEBUT, valeurs.dateDebut);
|
||||||
|
texte(CHAMP_DATE_FIN, valeurs.dateFin);
|
||||||
|
heure(CHAMP_HEURE_DEBUT, valeurs.heureDebut);
|
||||||
|
heure(CHAMP_HEURE_FIN, valeurs.heureFin);
|
||||||
|
texte(CHAMP_TARIFS, valeurs.tarifs);
|
||||||
|
texte(CHAMP_ADRESSE, valeurs.adresse);
|
||||||
|
texte(CHAMP_COORDONNEES, valeurs.coordonnees);
|
||||||
|
|
||||||
|
return manquants;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poserTexte(id, valeur) {
|
||||||
|
// Une valeur vide se laisse telle quelle : un champ intact est honnête, et
|
||||||
|
// le manque est déjà annoncé dans le bandeau.
|
||||||
|
if (!valeur) return true;
|
||||||
|
|
||||||
|
const champ = document.getElementById(id);
|
||||||
|
if (!champ) return absent(id);
|
||||||
|
|
||||||
|
champ.value = valeur;
|
||||||
|
notifier(champ);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poserSelect(id, valeur) {
|
||||||
|
if (!valeur) return true;
|
||||||
|
|
||||||
|
const champ = document.getElementById(id);
|
||||||
|
if (!champ) return absent(id);
|
||||||
|
|
||||||
|
// Match exact : les valeurs du thème sont reprises telles quelles de
|
||||||
|
// RECHERCHE.md §3. Poser une valeur sans option correspondante VIDERAIT le
|
||||||
|
// champ en silence.
|
||||||
|
const option = [...champ.options].find((option) => option.value === valeur);
|
||||||
|
if (!option) {
|
||||||
|
console.warn(`Écho du Huit : aucune option « ${valeur} » dans #${id}.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
champ.value = option.value;
|
||||||
|
notifier(champ);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravity Forms écoute `change` : poser `.value` seul peut rester invisible du
|
||||||
|
// formulaire. Le dispatch est gratuit et sans risque, on ne le suppose pas.
|
||||||
|
function notifier(champ) {
|
||||||
|
champ.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
champ.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function absent(id) {
|
||||||
|
console.warn(`Écho du Huit : champ #${id} introuvable.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un geste sans retour visible est indiagnosticable : le bandeau dit ce qui a
|
||||||
|
// été fait ET ce qui reste à faire.
|
||||||
|
function injecterBandeau(aCompleter, manquants) {
|
||||||
|
const bandeau = creerBandeau();
|
||||||
|
bandeau.append(
|
||||||
|
paragraphe("echo-bandeau-titre", "Écho du Huit a prérempli ce formulaire."),
|
||||||
|
paragraphe("echo-bandeau-intro", "À compléter :"),
|
||||||
|
listeDe(aCompleter),
|
||||||
|
paragraphe("echo-bandeau-consigne", "Relis puis envoie toi-même."),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (manquants.length > 0) {
|
||||||
|
bandeau.append(
|
||||||
|
paragraphe(
|
||||||
|
"echo-bandeau-alerte",
|
||||||
|
`⚠️ Certains champs n'ont pas pu être remplis (le formulaire de la mairie a peut-être changé) : ${manquants.join(", ")}.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
injecter(bandeau);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le pré-remplissage a échoué en cours de route : le dire, plutôt que de
|
||||||
|
// laisser le bénévole devant un formulaire à moitié rempli sans explication.
|
||||||
|
function injecterBandeauDegrade() {
|
||||||
|
const bandeau = creerBandeau();
|
||||||
|
bandeau.append(
|
||||||
|
paragraphe("echo-bandeau-titre", "Écho du Huit n'a pas pu préremplir ce formulaire."),
|
||||||
|
paragraphe(
|
||||||
|
"echo-bandeau-alerte",
|
||||||
|
"⚠️ Le formulaire de la mairie a peut-être changé. Vérifie chaque champ et remplis-le à la main ; le détail de l'erreur est dans la console (F12).",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
injecter(bandeau);
|
||||||
|
}
|
||||||
|
|
||||||
|
function creerBandeau() {
|
||||||
|
const bandeau = document.createElement("aside");
|
||||||
|
bandeau.className = "echo-bandeau";
|
||||||
|
return bandeau;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injecter(bandeau) {
|
||||||
|
const ancre =
|
||||||
|
document.getElementById("gform_wrapper_6") ?? document.getElementById("gform_6");
|
||||||
|
if (!ancre) {
|
||||||
|
console.warn("Écho du Huit : formulaire introuvable, bandeau non injecté.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ancre.before(bandeau);
|
||||||
|
}
|
||||||
|
|
||||||
|
// textContent uniquement : jamais d'innerHTML sur une page tierce.
|
||||||
|
function paragraphe(classe, texte) {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = classe;
|
||||||
|
p.textContent = texte;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeDe(elements) {
|
||||||
|
const liste = document.createElement("ul");
|
||||||
|
liste.className = "echo-bandeau-liste";
|
||||||
|
for (const element of elements) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.textContent = element;
|
||||||
|
liste.append(item);
|
||||||
|
}
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Cœur métier du pré-remplissage : traduit le payload déposé par la liste en
|
||||||
|
// valeurs prêtes à poser dans le formulaire mairie. Fonctions PURES, aucun DOM
|
||||||
|
// (c'est cette partie-là qui est testée ; le poseur, lui, est fin et impur).
|
||||||
|
//
|
||||||
|
// ⚠️ Ni `import` ni `export`, comme config.js (cf. DECISIONS.md 2026-07-17).
|
||||||
|
|
||||||
|
const LOCALE = "fr-FR";
|
||||||
|
const FUSEAU = "Europe/Paris";
|
||||||
|
|
||||||
|
// Dupliquée depuis transfert.js (ESM, inatteignable depuis un content script
|
||||||
|
// classique). Un test de dérive garantit l'égalité — cf. DECISIONS.md 2026-07-17.
|
||||||
|
const CLE_EVENEMENT_EN_ATTENTE = "evenement-en-attente";
|
||||||
|
|
||||||
|
// Un événement horodaté porte un vrai instant : la mairie attend l'heure de
|
||||||
|
// Cugnaux, quel que soit le fuseau de la machine du bénévole.
|
||||||
|
const FORMAT_DATE_PARIS = new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: FUSEAU,
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: FUSEAU,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.EchoFormulaire = {
|
||||||
|
CLE_EVENEMENT_EN_ATTENTE,
|
||||||
|
|
||||||
|
// payload (transfert.js) + config (config.js) → objet plat, prêt à poser.
|
||||||
|
// Ce qu'on ignore reste vide et part dans `aCompleter` : jamais d'invention.
|
||||||
|
calculerValeurs(payload, config) {
|
||||||
|
const debut = new Date(payload.debut);
|
||||||
|
const fin = new Date(payload.fin);
|
||||||
|
|
||||||
|
const valeurs = {
|
||||||
|
organisateur: config.organisateur,
|
||||||
|
email: config.email,
|
||||||
|
titre: payload.titre,
|
||||||
|
description: payload.description,
|
||||||
|
theme: config.themeDefaut,
|
||||||
|
tarifs: config.tarifs,
|
||||||
|
...(payload.journeeEntiere ? datesFlottantes(debut, fin) : datesHorodatees(debut, fin)),
|
||||||
|
...lieuEtCoordonnees(payload.lieu, config.cafe),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...valeurs, aCompleter: listerACompleter(valeurs) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Journée entière = date FLOTTANTE (RFC 5545) : composantes locales
|
||||||
|
// uniquement, jamais l'heure, jamais un fuseau — sinon la date glisse d'un jour
|
||||||
|
// hors de +02:00 (cf. DECISIONS.md 2026-07-14).
|
||||||
|
function datesFlottantes(debut, fin) {
|
||||||
|
const dateDebut = formaterDateLocale(debut);
|
||||||
|
const dernierJour = veilleDe(fin);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateDebut,
|
||||||
|
// Un seul jour, ou DTEND absent (le parseur pose alors fin === debut).
|
||||||
|
dateFin:
|
||||||
|
dernierJour.getTime() <= debut.getTime() ? dateDebut : formaterDateLocale(dernierJour),
|
||||||
|
// Poser « 00:00 » serait exactement le mensonge que la liste refuse déjà.
|
||||||
|
heureDebut: null,
|
||||||
|
heureFin: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function datesHorodatees(debut, fin) {
|
||||||
|
const dateDebut = FORMAT_DATE_PARIS.format(debut);
|
||||||
|
const heureDebut = heureDe(debut);
|
||||||
|
|
||||||
|
// DTEND absent : le parseur pose fin === debut. Pas d'heure de fin à
|
||||||
|
// soumettre — mais l'événement a bien lieu ce jour-là.
|
||||||
|
if (fin.getTime() <= debut.getTime()) {
|
||||||
|
return { dateDebut, dateFin: dateDebut, heureDebut, heureFin: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jour de fin calculé à Paris : un 22:00 → 02:00 change de jour.
|
||||||
|
return {
|
||||||
|
dateDebut,
|
||||||
|
dateFin: FORMAT_DATE_PARIS.format(fin),
|
||||||
|
heureDebut,
|
||||||
|
heureFin: heureDe(fin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le formulaire attend deux selects [HH, MM], zéro-paddés.
|
||||||
|
function heureDe(date) {
|
||||||
|
return FORMAT_HEURE_PARIS.format(date).split(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formaterDateLocale(date) {
|
||||||
|
const jour = String(date.getDate()).padStart(2, "0");
|
||||||
|
const mois = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${jour}/${mois}/${date.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTEND est EXCLUSIF (RFC 5545) : un événement du 18 porte DTEND=19, un festival
|
||||||
|
// du 18 au 20 porte DTEND=21. Sans ce recul d'un jour, on soumet à une mairie
|
||||||
|
// qui modère une date de fin FAUSSE. Composantes locales, jamais « -24h ».
|
||||||
|
function veilleDe(date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La majorité des événements n'a pas de LOCATION : ils se tiennent au café.
|
||||||
|
function lieuEtCoordonnees(lieu, cafe) {
|
||||||
|
const saisi = lieu.trim();
|
||||||
|
if (!saisi) {
|
||||||
|
return { adresse: cafe.adresse, coordonnees: `${cafe.coords}|${cafe.adresse}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lieu ailleurs : on ne sait pas géocoder. Inventer un lat|lng serait pire
|
||||||
|
// qu'un champ vide → l'humain place le point sur la carte.
|
||||||
|
return { adresse: saisi, coordonnees: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toujours honnête : ce que la machine ne sait pas est dit, pas masqué.
|
||||||
|
function listerACompleter({ heureDebut, heureFin, coordonnees, organisateur, email, titre }) {
|
||||||
|
const aCompleter = [
|
||||||
|
"l'image (obligatoire)", // un script ne peut pas remplir un <input type=file>
|
||||||
|
"le thème", // le calendrier n'en a pas : le défaut est à confirmer
|
||||||
|
];
|
||||||
|
|
||||||
|
if (heureDebut === null || heureFin === null) aCompleter.push("l'heure");
|
||||||
|
if (coordonnees === "") aCompleter.push("le lieu exact sur la carte");
|
||||||
|
if (!organisateur || !email) aCompleter.push("l'organisateur et l'email (config.local.json)");
|
||||||
|
if (titre === "") aCompleter.push("le titre");
|
||||||
|
|
||||||
|
return aCompleter;
|
||||||
|
}
|
||||||
+11
-1
@@ -10,8 +10,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background.js"]
|
"scripts": ["config.js", "background.js"]
|
||||||
},
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://www.ville-cugnaux.fr/mes-loisirs/associations/proposer-un-evenement-dans-lagenda/*"
|
||||||
|
],
|
||||||
|
"js": ["config.js", "formulaire-valeurs.js", "formulaire-mairie.js"],
|
||||||
|
"css": ["formulaire-mairie.css"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
"action": {
|
"action": {
|
||||||
"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"
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
// Fonctions pures, sans DOM (testables). Locale et fuseau sont des constantes :
|
// Fonctions pures, sans DOM (testables). Locale et fuseau sont des constantes :
|
||||||
// l'appli est mono-locale (bénévoles de Cugnaux).
|
// l'appli est mono-locale (bénévoles de Cugnaux).
|
||||||
|
|
||||||
|
// config.js n'exporte rien : il pose globalThis.EchoConfig (cf. DECISIONS.md
|
||||||
|
// 2026-07-17). L'import ne sert qu'à son effet de bord.
|
||||||
|
import "./config.js";
|
||||||
|
|
||||||
const LOCALE = "fr-FR";
|
const LOCALE = "fr-FR";
|
||||||
const FUSEAU = "Europe/Paris";
|
const FUSEAU = "Europe/Paris";
|
||||||
|
|
||||||
// RECHERCHE.md §2 : adresse du café de l'Atelier du Huit.
|
// Source unique : le formulaire mairie a besoin de la même adresse (et de ses
|
||||||
export const LIEU_PAR_DEFAUT = "8 Rue du Pré Vicinal 31270 Cugnaux";
|
// coordonnées) que la liste.
|
||||||
|
export const LIEU_PAR_DEFAUT = globalThis.EchoConfig.DEFAUTS.cafe.adresse;
|
||||||
|
|
||||||
// Un événement horodaté porte un vrai instant : on l'affiche à l'heure de
|
// Un événement horodaté porte un vrai instant : on l'affiche à l'heure de
|
||||||
// Cugnaux, quel que soit le fuseau de la machine.
|
// Cugnaux, quel que soit le fuseau de la machine.
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import "./extension/config.js";
|
||||||
|
import "./extension/formulaire-valeurs.js";
|
||||||
|
import { CLE_EVENEMENT_EN_ATTENTE, serialiserPourFormulaire } from "./extension/transfert.js";
|
||||||
|
|
||||||
|
const { DEFAUTS } = (globalThis as any).EchoConfig;
|
||||||
|
const { calculerValeurs } = (globalThis as any).EchoFormulaire;
|
||||||
|
|
||||||
|
// Payload = ce que dépose transfert.js (debut/fin en ISO), pas un Event.
|
||||||
|
function payload(surcharges: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
uid: "uid-1",
|
||||||
|
titre: "Atelier",
|
||||||
|
description: "Venez nombreux",
|
||||||
|
lieu: "",
|
||||||
|
debut: new Date("2026-07-18T08:30:00Z").toISOString(),
|
||||||
|
fin: new Date("2026-07-18T10:30:00Z").toISOString(),
|
||||||
|
categories: [],
|
||||||
|
journeeEntiere: false,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une journée entière est une date FLOTTANTE : fixture en composantes locales,
|
||||||
|
// jamais un instant absolu (cf. DECISIONS.md 2026-07-14).
|
||||||
|
function payloadJourneeEntiere(debut: Date, fin: Date, surcharges = {}) {
|
||||||
|
return payload({
|
||||||
|
debut: debut.toISOString(),
|
||||||
|
fin: fin.toISOString(),
|
||||||
|
journeeEntiere: true,
|
||||||
|
...surcharges,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("journée entière", () => {
|
||||||
|
test("un seul jour (DTEND = J+1) → dateFin === dateDebut", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.dateDebut).toBe("10/07/2026");
|
||||||
|
expect(valeurs.dateFin).toBe("10/07/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lit les composantes locales, pas un instant absolu", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.dateDebut).toBe("10/07/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Festival 18→21 exclusif : il se termine réellement le 20. Sans ce recul,
|
||||||
|
// une date FAUSSE part à une mairie qui modère.
|
||||||
|
test("plusieurs jours (18 → DTEND 21) → dateFin = 20/07/2026", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payloadJourneeEntiere(new Date(2026, 6, 18), new Date(2026, 6, 21)),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.dateDebut).toBe("18/07/2026");
|
||||||
|
expect(valeurs.dateFin).toBe("20/07/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DTEND absent (fin === debut) → dateFin === dateDebut", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 10)),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.dateFin).toBe("10/07/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poser « 00:00 » serait le mensonge que presentation.js refuse déjà.
|
||||||
|
test("aucune heure : poser 00:00 serait un mensonge", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.heureDebut).toBeNull();
|
||||||
|
expect(valeurs.heureFin).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'heure manquante est signalée", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.aCompleter).toContain("l'heure");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("événement horodaté", () => {
|
||||||
|
test("heures à Paris, zéro-paddées", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payload({
|
||||||
|
debut: new Date("2026-07-18T07:05:00Z").toISOString(), // 09:05 à Paris
|
||||||
|
fin: new Date("2026-07-18T19:00:00Z").toISOString(), // 21:00 à Paris
|
||||||
|
}),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.heureDebut).toEqual(["09", "05"]);
|
||||||
|
expect(valeurs.heureFin).toEqual(["21", "00"]);
|
||||||
|
expect(valeurs.dateDebut).toBe("18/07/2026");
|
||||||
|
expect(valeurs.dateFin).toBe("18/07/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DTEND absent (fin === debut) → pas d'heure de fin, mais le jour reste vrai", () => {
|
||||||
|
const debut = new Date("2026-07-18T08:30:00Z").toISOString();
|
||||||
|
const valeurs = calculerValeurs(payload({ debut, fin: debut }), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.heureDebut).toEqual(["10", "30"]);
|
||||||
|
expect(valeurs.heureFin).toBeNull();
|
||||||
|
expect(valeurs.dateFin).toBe("18/07/2026");
|
||||||
|
expect(valeurs.aCompleter).toContain("l'heure");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 22:00 → 02:00 : annoncer la fin le même jour serait un mensonge.
|
||||||
|
test("franchit minuit → dateFin est le lendemain", () => {
|
||||||
|
const valeurs = calculerValeurs(
|
||||||
|
payload({
|
||||||
|
debut: new Date("2026-07-18T20:00:00Z").toISOString(), // 22:00 à Paris
|
||||||
|
fin: new Date("2026-07-19T00:00:00Z").toISOString(), // 02:00 le 19 à Paris
|
||||||
|
}),
|
||||||
|
DEFAUTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valeurs.dateDebut).toBe("18/07/2026");
|
||||||
|
expect(valeurs.heureDebut).toEqual(["22", "00"]);
|
||||||
|
expect(valeurs.dateFin).toBe("19/07/2026");
|
||||||
|
expect(valeurs.heureFin).toEqual(["02", "00"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heures complètes → l'heure n'est pas dans aCompleter", () => {
|
||||||
|
const valeurs = calculerValeurs(payload(), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.aCompleter).not.toContain("l'heure");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lieu", () => {
|
||||||
|
test("lieu vide (cas majoritaire) → adresse et coordonnées du café", () => {
|
||||||
|
const valeurs = calculerValeurs(payload({ lieu: "" }), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux");
|
||||||
|
expect(valeurs.coordonnees).toBe(
|
||||||
|
"43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux",
|
||||||
|
);
|
||||||
|
expect(valeurs.aCompleter).not.toContain("le lieu exact sur la carte");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lieu en blancs → café", () => {
|
||||||
|
const valeurs = calculerValeurs(payload({ lieu: " " }), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux");
|
||||||
|
});
|
||||||
|
|
||||||
|
// On ne sait pas géocoder : inventer un lat|lng serait pire que le laisser vide.
|
||||||
|
test("lieu renseigné → pas de coordonnées inventées, manque signalé", () => {
|
||||||
|
const valeurs = calculerValeurs(payload({ lieu: "Salle des fêtes" }), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.adresse).toBe("Salle des fêtes");
|
||||||
|
expect(valeurs.coordonnees).toBe("");
|
||||||
|
expect(valeurs.aCompleter).toContain("le lieu exact sur la carte");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("champs de config", () => {
|
||||||
|
test("thème et tarifs viennent de la config", () => {
|
||||||
|
const valeurs = calculerValeurs(payload(), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.theme).toBe("Culture");
|
||||||
|
expect(valeurs.tarifs).toBe("Gratuit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("organisateur et email surchargés sont posés et non signalés", () => {
|
||||||
|
const config = { ...DEFAUTS, organisateur: "L'Atelier du Huit", email: "a@b.fr" };
|
||||||
|
|
||||||
|
const valeurs = calculerValeurs(payload(), config);
|
||||||
|
|
||||||
|
expect(valeurs.organisateur).toBe("L'Atelier du Huit");
|
||||||
|
expect(valeurs.email).toBe("a@b.fr");
|
||||||
|
expect(valeurs.aCompleter).not.toContain("l'organisateur et l'email (config.local.json)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sans config.local.json → organisateur/email vides et signalés", () => {
|
||||||
|
const valeurs = calculerValeurs(payload(), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.organisateur).toBe("");
|
||||||
|
expect(valeurs.email).toBe("");
|
||||||
|
expect(valeurs.aCompleter).toContain("l'organisateur et l'email (config.local.json)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aCompleter", () => {
|
||||||
|
test("signale toujours l'image et le thème", () => {
|
||||||
|
const config = { ...DEFAUTS, organisateur: "X", email: "a@b.fr" };
|
||||||
|
|
||||||
|
const valeurs = calculerValeurs(payload({ lieu: "" }), config);
|
||||||
|
|
||||||
|
expect(valeurs.aCompleter).toContain("l'image (obligatoire)");
|
||||||
|
expect(valeurs.aCompleter).toContain("le thème");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("titre vide → signalé", () => {
|
||||||
|
expect(calculerValeurs(payload({ titre: "" }), DEFAUTS).aCompleter).toContain("le titre");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("titre renseigné → non signalé", () => {
|
||||||
|
expect(calculerValeurs(payload(), DEFAUTS).aCompleter).not.toContain("le titre");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("texte", () => {
|
||||||
|
test("titre et description sont repris tels quels", () => {
|
||||||
|
const valeurs = calculerValeurs(payload(), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.titre).toBe("Atelier");
|
||||||
|
expect(valeurs.description).toBe("Venez nombreux");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cas majoritaire (142/473 ont une DESCRIPTION) : le poseur n'écrira pas "".
|
||||||
|
test("description vide reste vide", () => {
|
||||||
|
expect(calculerValeurs(payload({ description: "" }), DEFAUTS).description).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// La constante est dupliquée dans formulaire-valeurs.js (un content script
|
||||||
|
// classique ne peut pas importer transfert.js, qui est en ESM). Ce test est le
|
||||||
|
// garde-fou contre la dérive.
|
||||||
|
describe("couture avec transfert.js", () => {
|
||||||
|
test("la clé dupliquée est identique à l'originale", () => {
|
||||||
|
expect((globalThis as any).EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE).toBe(
|
||||||
|
CLE_EVENEMENT_EN_ATTENTE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le contrat de couture vérifié bout en bout, pas supposé.
|
||||||
|
test("aller-retour réel : serialiserPourFormulaire → calculerValeurs", () => {
|
||||||
|
const evenement = {
|
||||||
|
uid: "uid-1",
|
||||||
|
titre: "Concert",
|
||||||
|
description: "Au café",
|
||||||
|
lieu: "",
|
||||||
|
debut: new Date("2026-07-18T18:00:00Z"), // 20:00 à Paris
|
||||||
|
fin: new Date("2026-07-18T20:00:00Z"), // 22:00 à Paris
|
||||||
|
categories: [],
|
||||||
|
journeeEntiere: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const valeurs = calculerValeurs(serialiserPourFormulaire(evenement), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.titre).toBe("Concert");
|
||||||
|
expect(valeurs.dateDebut).toBe("18/07/2026");
|
||||||
|
expect(valeurs.heureDebut).toEqual(["20", "00"]);
|
||||||
|
expect(valeurs.heureFin).toEqual(["22", "00"]);
|
||||||
|
expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aller-retour réel d'une journée entière (date flottante)", () => {
|
||||||
|
const evenement = {
|
||||||
|
uid: "uid-2",
|
||||||
|
titre: "Festival",
|
||||||
|
description: "",
|
||||||
|
lieu: "",
|
||||||
|
debut: new Date(2026, 6, 18),
|
||||||
|
fin: new Date(2026, 6, 21),
|
||||||
|
categories: [],
|
||||||
|
journeeEntiere: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const valeurs = calculerValeurs(serialiserPourFormulaire(evenement), DEFAUTS);
|
||||||
|
|
||||||
|
expect(valeurs.dateDebut).toBe("18/07/2026");
|
||||||
|
expect(valeurs.dateFin).toBe("20/07/2026");
|
||||||
|
expect(valeurs.heureDebut).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user