diff --git a/README.md b/README.md index a7caec3..24ebda5 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,37 @@ runtime à installer) : > Pense aussi à **fermer les anciens onglets de la liste** avant de recharger : > 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 ```sh diff --git a/config.test.ts b/config.test.ts new file mode 100644 index 0000000..e08fd84 --- /dev/null +++ b/config.test.ts @@ -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(""); + }); +}); diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a68cf6e..1d0c67c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 **aussi** les coordonnées (`input_18`). Écrire l'adresse dans le payload 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. diff --git a/docs/RECHERCHE.md b/docs/RECHERCHE.md index b41652d..3b6fd39 100644 --- a/docs/RECHERCHE.md +++ b/docs/RECHERCHE.md @@ -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) -| id DOM | name | Contenu | Source | -|---|---|---|---| -| `#input_6_42` | `input_42` | Organisateur | config (« L'Atelier du Huit ») | -| `#input_6_41` | `input_41` | Email | config | -| `#input_6_1` | `input_1` | Titre | SUMMARY | -| `#input_6_3` | `input_3` | Description (textarea) | DESCRIPTION | -| `#input_6_20` | `input_20` | Thème (select) | mapping (voir §3) | -| `#input_6_31` | `input_31` | Date début `jj/mm/aaaa` | DTSTART | -| `#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_39_1` / `#input_6_39_2` | `input_39[]` | Heure fin [HH, MM] | DTEND | -| `#input_6_35` (`_1`) | `input_35.1` | Accessibilité (case) | fixe | -| `#input_6_36` | `input_36` | Public ciblé | optionnel | -| `#input_6_40` | `input_40` | Tarifs | défaut « Gratuit » | -| `#nova_address` | `nova_address` | Adresse texte | défaut café | -| `#input_6_18` | `input_18` | `lat\|lng\|adresse` (hidden) | défaut café | -| `#input_6_34` | `input_34` | **Image (file)** | ⚠️ non remplissable par script (sécurité) → manuel | -| `#input_6_23`, `#input_6_43` | | Honeypot | laisser vide | +> ⚠️ **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 +> sont des ``. Le code l'a suivi et les 4 champs d'heures +> ont échoué (`champ.options` undefined → TypeError). **Ne pas déduire le type +> d'un champ de son intitulé : le HTML est la seule source de vérité.** Types +> ci-dessous vérifiés sur la page réelle le 2026-07-17. + +| id DOM | name | Balise réelle | Contenu | Source | +|---|---|---|---|---| +| `#input_6_42` | `input_42` | `input[type=text]` | Organisateur | config (« L'Atelier du Huit ») | +| `#input_6_41` | `input_41` | `input[type=email]` | Email | config | +| `#input_6_1` | `input_1` | `input[type=text]` | Titre | SUMMARY | +| `#input_6_3` | `input_3` | `textarea` | Description | DESCRIPTION | +| `#input_6_20` | `input_20` | **`select`** | Thème | mapping (voir §3) | +| `#input_6_31` | `input_31` | `input[type=text]` (datepicker) | Date début `jj/mm/aaaa` | DTSTART | +| `#input_6_32` | `input_32` | `input[type=text]` (datepicker) | Date fin `jj/mm/aaaa` | DTEND | +| `#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_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` `input_18` = `43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux` diff --git a/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md b/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md new file mode 100644 index 0000000..67a23a4 --- /dev/null +++ b/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md @@ -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 ``. `poserSelect` + faisait `[...champ.options]` sur un `` → `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] diff --git a/extension.test.ts b/extension.test.ts index 3b38d74..a9174f0 100644 --- a/extension.test.ts +++ b/extension.test.ts @@ -14,6 +14,12 @@ describe("manifest.json", () => { 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", () => { expect(manifest.host_permissions).toEqual([ "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", () => { test("background.js existe", () => { 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", () => { expect(existsSync(join(extensionDir, "liste.html"))).toBe(true); }); diff --git a/extension/background.js b/extension/background.js index 2431475..0bc415c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -3,3 +3,27 @@ browser.action.onClicked.addListener(() => { 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); diff --git a/extension/config.js b/extension/config.js new file mode 100644 index 0000000..bb03a75 --- /dev/null +++ b/extension/config.js @@ -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 }; + }, +}; diff --git a/extension/config.local.json.example b/extension/config.local.json.example new file mode 100644 index 0000000..dc767b5 --- /dev/null +++ b/extension/config.local.json.example @@ -0,0 +1,4 @@ +{ + "organisateur": "L'Atelier du Huit", + "email": "contact@atelier-huit.fr" +} diff --git a/extension/formulaire-mairie.css b/extension/formulaire-mairie.css new file mode 100644 index 0000000..f832969 --- /dev/null +++ b/extension/formulaire-mairie.css @@ -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; +} diff --git a/extension/formulaire-mairie.js b/extension/formulaire-mairie.js new file mode 100644 index 0000000..056360e --- /dev/null +++ b/extension/formulaire-mairie.js @@ -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 → `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 (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; +} diff --git a/extension/formulaire-valeurs.js b/extension/formulaire-valeurs.js new file mode 100644 index 0000000..e7ed1fe --- /dev/null +++ b/extension/formulaire-valeurs.js @@ -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 + "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; +} diff --git a/extension/manifest.json b/extension/manifest.json index b5c32e7..fecb728 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -10,8 +10,18 @@ } }, "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": { "default_title": "Ouvrir la liste de l'Écho du Huit", "default_icon": "icons/icon.svg" diff --git a/extension/presentation.js b/extension/presentation.js index b357d53..0f107f8 100644 --- a/extension/presentation.js +++ b/extension/presentation.js @@ -2,11 +2,16 @@ // Fonctions pures, sans DOM (testables). Locale et fuseau sont des constantes : // 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 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"; +// Source unique : le formulaire mairie a besoin de la même adresse (et de ses +// 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 // Cugnaux, quel que soit le fuseau de la machine. diff --git a/formulaire-valeurs.test.ts b/formulaire-valeurs.test.ts new file mode 100644 index 0000000..db61620 --- /dev/null +++ b/formulaire-valeurs.test.ts @@ -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 = {}) { + 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(); + }); +});