feat: read and write the mairie status carried by CATEGORIES
Two pure modules: statut-mairie.js (string[] -> string[] status logic) and ics-categories.js, which rewrites CATEGORIES in an iCalendar resource behind a structural guard: nothing is written if anything but CATEGORIES changed.
This commit is contained in:
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
# Dépendances de dev (Bun)
|
# Dépendances de dev (Bun)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Config locale (organisateur, email)
|
# Config locale : organisateur/email (config.local.json) ET outillage de session
|
||||||
config.local.json
|
# (.claude/settings.local.json, régénéré par tâche). Motif générique : la
|
||||||
|
# convention *.local.json vaut pour tout ce qui ne concerne que cette machine.
|
||||||
|
*.local.json
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Réécriture de CATEGORIES dans une ressource iCalendar, avec garde-fou de
|
||||||
|
// non-régression. PUR (ical.js tourne aussi dans Bun) : c'est ici que se joue
|
||||||
|
// l'intégrité de l'agenda d'une association, on le teste à fond.
|
||||||
|
//
|
||||||
|
// Règle du rayon d'explosion : si le round-trip ical.js a touché autre chose que
|
||||||
|
// CATEGORIES, on n'écrit PAS. Un tag non posé se repose ; un événement abîmé se
|
||||||
|
// répare à la main.
|
||||||
|
|
||||||
|
import ICAL from "./vendor/ical.js";
|
||||||
|
|
||||||
|
// `transformer` (obligatoire) : string[] -> string[]. Il est appliqué aux
|
||||||
|
// catégories FRAÎCHEMENT lues dans la ressource, jamais à celles de la liste
|
||||||
|
// (potentiellement périmées).
|
||||||
|
export function appliquerAuxCategories(ics, uid, transformer) {
|
||||||
|
const racine = parser(ics);
|
||||||
|
if (!racine) return { ok: false, erreur: "ics-illisible" };
|
||||||
|
|
||||||
|
// Le VEVENT maître = celui SANS RECURRENCE-ID, cohérent avec evenements.js
|
||||||
|
// qui ignore les exceptions d'occurrence.
|
||||||
|
const maitre = racine
|
||||||
|
.getAllSubcomponents("vevent")
|
||||||
|
.find((v) => v.getFirstPropertyValue("uid") === uid && !v.hasProperty("recurrence-id"));
|
||||||
|
if (!maitre) return { ok: false, erreur: "vevent-introuvable" };
|
||||||
|
|
||||||
|
const categories = transformer(lireCategories(maitre));
|
||||||
|
|
||||||
|
// Une seule propriété multi-valeurs : les formes répétées sont fusionnées.
|
||||||
|
maitre.removeAllProperties("categories");
|
||||||
|
if (categories.length > 0) {
|
||||||
|
const propriete = new ICAL.Property("categories");
|
||||||
|
propriete.setValues(categories);
|
||||||
|
maitre.addProperty(propriete);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apres = racine.toString();
|
||||||
|
if (!seulesLesCategoriesOntChange(ics, apres)) {
|
||||||
|
return { ok: false, erreur: "reecriture-risquee" };
|
||||||
|
}
|
||||||
|
return { ok: true, ics: apres, categories };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde-fou E3 : comparaison STRUCTURELLE (jCal), pas textuelle. Le repliement
|
||||||
|
// de ligne et l'espacement peuvent changer librement ; une valeur, non.
|
||||||
|
export function seulesLesCategoriesOntChange(icsAvant, icsApres) {
|
||||||
|
const avant = parser(icsAvant);
|
||||||
|
const apres = parser(icsApres);
|
||||||
|
if (!avant || !apres) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
JSON.stringify(sansCategories(avant.jCal)) ===
|
||||||
|
JSON.stringify(sansCategories(apres.jCal))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parser(ics) {
|
||||||
|
try {
|
||||||
|
const racine = new ICAL.Component(ICAL.parse(ics));
|
||||||
|
// `ICAL.parse("")` ne LÈVE PAS : il rend `[]`, et le composant vide qui en
|
||||||
|
// sort explose au premier accès. Le try/catch ne suffit donc pas — on exige
|
||||||
|
// un VCALENDAR reconnaissable avant de rendre la main.
|
||||||
|
return racine.name === "vcalendar" ? racine : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jCal : [nom, propriétés, sous-composants]. On retire `categories` PARTOUT,
|
||||||
|
// y compris dans les exceptions d'occurrence.
|
||||||
|
function sansCategories([nom, proprietes, sousComposants]) {
|
||||||
|
return [
|
||||||
|
nom,
|
||||||
|
proprietes.filter(([nomPropriete]) => nomPropriete !== "categories"),
|
||||||
|
sousComposants.map(sansCategories),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// CATEGORIES peut prendre deux formes (virgules et/ou propriétés répétées),
|
||||||
|
// comme dans evenements.js.
|
||||||
|
function lireCategories(vevent) {
|
||||||
|
return vevent.getAllProperties("categories").flatMap((propriete) => propriete.getValues());
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Statut mairie porté par les CATEGORIES de l'événement Nextcloud : `string[]`
|
||||||
|
// → `string[]`. Fonctions PURES (aucun DOM, aucun réseau, aucun ICS) : c'est le
|
||||||
|
// cœur logique de la persistance partagée, testé à fond.
|
||||||
|
//
|
||||||
|
// `mairie:` est NOTRE espace de noms : les autres catégories appartiennent aux
|
||||||
|
// bénévoles et gardent leur ordre et leur casse.
|
||||||
|
|
||||||
|
const PREFIXE = "mairie:";
|
||||||
|
// NFC explicite : « é » s'écrit de deux façons en Unicode (précomposée ou
|
||||||
|
// « e » + accent combinant) qui ne sont PAS `===`. Sans normalisation, un tag
|
||||||
|
// écrit par un autre client CalDAV serait relu comme « aucun » — panne
|
||||||
|
// silencieuse. On normalise ce qu'on écrit ET ce qu'on lit.
|
||||||
|
const IGNORE = `${PREFIXE}ignoré`.normalize("NFC");
|
||||||
|
const SOUMIS = `${PREFIXE}soumis`;
|
||||||
|
|
||||||
|
const JOUR = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Europe/Paris",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Écriture concurrente (deux bénévoles, deux clients) : plusieurs tags peuvent
|
||||||
|
// cohabiter. La DERNIÈRE valeur reconnue l'emporte, comme indexerCartes.
|
||||||
|
// Un tag `mairie:` inconnu (posé par une version future) est traité comme
|
||||||
|
// « aucun » : il ne doit jamais casser la liste d'aujourd'hui.
|
||||||
|
export function lireStatut(categories) {
|
||||||
|
let statut = { type: "aucun" };
|
||||||
|
for (const categorie of categories) {
|
||||||
|
const reconnu = reconnaitre(categorie.normalize("NFC"));
|
||||||
|
if (reconnu) statut = reconnu;
|
||||||
|
}
|
||||||
|
return statut;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnaitre(categorie) {
|
||||||
|
if (categorie === IGNORE) return { type: "ignoré" };
|
||||||
|
if (categorie === SOUMIS) return { type: "soumis", jour: null };
|
||||||
|
if (categorie.startsWith(`${SOUMIS}:`)) {
|
||||||
|
// L'intention prime sur sa décoration : une date illisible n'annule pas
|
||||||
|
// l'envoi, on affichera « Soumis » sans date.
|
||||||
|
const jour = categorie.slice(SOUMIS.length + 1);
|
||||||
|
return { type: "soumis", jour: JOUR.test(jour) ? jour : null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poserIgnore(categories) {
|
||||||
|
return [...retirerStatut(categories), IGNORE];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poserSoumis(categories, jour) {
|
||||||
|
return [...retirerStatut(categories), `${SOUMIS}:${jour}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retirerStatut(categories) {
|
||||||
|
return categories.filter((categorie) => !categorie.startsWith(PREFIXE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// « AAAA-MM-JJ » à Cugnaux : en-CA rend directement cette forme (cf.
|
||||||
|
// publication.js). `maintenant` injecté (obligatoire) pour des tests
|
||||||
|
// déterministes.
|
||||||
|
export function jourParis(maintenant) {
|
||||||
|
return FORMAT_JOUR_PARIS.format(maintenant);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import ICAL from "./extension/vendor/ical.js";
|
||||||
|
import {
|
||||||
|
appliquerAuxCategories,
|
||||||
|
seulesLesCategoriesOntChange,
|
||||||
|
} from "./extension/ics-categories.js";
|
||||||
|
import { poserIgnore } from "./extension/statut-mairie.js";
|
||||||
|
|
||||||
|
const UID = "db2d146b-8e18-4df6-929f-a651d04245df";
|
||||||
|
|
||||||
|
function ressource(corpsVevent: string, extra = "") {
|
||||||
|
return [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//IDN nextcloud.com//Calendar app 5.2.2//EN",
|
||||||
|
extra,
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
`UID:${UID}`,
|
||||||
|
"DTSTAMP:20250828T095756Z",
|
||||||
|
"DTSTART;TZID=Europe/Paris:20260907T090000",
|
||||||
|
"DTEND;TZID=Europe/Paris:20260907T130000",
|
||||||
|
"SUMMARY:Forum des associations",
|
||||||
|
corpsVevent,
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
.filter((ligne) => ligne !== "")
|
||||||
|
.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const VTIMEZONE = [
|
||||||
|
"BEGIN:VTIMEZONE",
|
||||||
|
"TZID:Europe/Paris",
|
||||||
|
"BEGIN:DAYLIGHT",
|
||||||
|
"TZOFFSETFROM:+0100",
|
||||||
|
"TZOFFSETTO:+0200",
|
||||||
|
"TZNAME:CEST",
|
||||||
|
"DTSTART:19700329T020000",
|
||||||
|
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
|
||||||
|
"END:DAYLIGHT",
|
||||||
|
"BEGIN:STANDARD",
|
||||||
|
"TZOFFSETFROM:+0200",
|
||||||
|
"TZOFFSETTO:+0100",
|
||||||
|
"TZNAME:CET",
|
||||||
|
"DTSTART:19701025T030000",
|
||||||
|
"RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
|
||||||
|
"END:STANDARD",
|
||||||
|
"END:VTIMEZONE",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
// Relit la ressource produite : on vérifie des VALEURS, jamais du texte brut.
|
||||||
|
function relire(ics: string) {
|
||||||
|
const racine = new ICAL.Component(ICAL.parse(ics));
|
||||||
|
const vevents = racine.getAllSubcomponents("vevent");
|
||||||
|
const maitre = vevents.find((v) => !v.hasProperty("recurrence-id"));
|
||||||
|
if (!maitre) throw new Error("VEVENT maître introuvable dans la ressource produite");
|
||||||
|
return {
|
||||||
|
racine,
|
||||||
|
maitre,
|
||||||
|
exception: vevents.find((v) => v.hasProperty("recurrence-id")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoriesDe(vevent: any) {
|
||||||
|
return vevent.getAllProperties("categories").flatMap((p) => p.getValues());
|
||||||
|
}
|
||||||
|
|
||||||
|
const ajouterTag = (categories: string[]) => poserIgnore(categories);
|
||||||
|
|
||||||
|
describe("appliquerAuxCategories", () => {
|
||||||
|
test("CATEGORIES absent → ajouté, le reste de l'événement intact", () => {
|
||||||
|
const avant = ressource("DESCRIPTION:Venez nombreux\r\nRRULE:FREQ=WEEKLY;COUNT=3");
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, ajouterTag);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const { maitre } = relire(res.ics);
|
||||||
|
expect(categoriesDe(maitre)).toEqual(["mairie:ignoré"]);
|
||||||
|
expect(maitre.getFirstPropertyValue("summary")).toBe("Forum des associations");
|
||||||
|
expect(maitre.getFirstPropertyValue("description")).toBe("Venez nombreux");
|
||||||
|
expect(maitre.getFirstPropertyValue("dtstart").toString()).toBe("2026-09-07T09:00:00");
|
||||||
|
expect(maitre.getFirstProperty("dtstart").getParameter("tzid")).toBe("Europe/Paris");
|
||||||
|
expect(maitre.getFirstPropertyValue("rrule").toString()).toBe("FREQ=WEEKLY;COUNT=3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CATEGORIES existant → catégories des bénévoles préservées, tag remplacé", () => {
|
||||||
|
const avant = ressource("CATEGORIES:Concert,mairie:soumis:2026-08-01");
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, ajouterTag);
|
||||||
|
|
||||||
|
expect(res.categories).toEqual(["Concert", "mairie:ignoré"]);
|
||||||
|
expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Concert", "mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deux propriétés CATEGORIES répétées → fusionnées, aucune valeur perdue", () => {
|
||||||
|
const avant = ressource("CATEGORIES:Concert\r\nCATEGORIES:Jazz");
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, ajouterTag);
|
||||||
|
|
||||||
|
const { maitre } = relire(res.ics);
|
||||||
|
expect(maitre.getAllProperties("categories")).toHaveLength(1);
|
||||||
|
expect(categoriesDe(maitre)).toEqual(["Concert", "Jazz", "mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valeur contenant une virgule → échappement correct au round-trip", () => {
|
||||||
|
const avant = ressource("CATEGORIES:Concert\\, jazz");
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, ajouterTag);
|
||||||
|
|
||||||
|
expect(res.categories).toEqual(["Concert, jazz", "mairie:ignoré"]);
|
||||||
|
expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Concert, jazz", "mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accents fidèles au round-trip", () => {
|
||||||
|
const avant = ressource("CATEGORIES:Fête de quartier");
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, ajouterTag);
|
||||||
|
|
||||||
|
expect(res.ics).toContain("mairie:ignoré");
|
||||||
|
expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Fête de quartier", "mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ligne repliée (> 75 octets) → contenu identique après round-trip", () => {
|
||||||
|
const longue = "Une description très longue ".repeat(8).trim();
|
||||||
|
const avant = ressource(`DESCRIPTION:${longue}`);
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, ajouterTag);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(relire(res.ics).maitre.getFirstPropertyValue("description")).toBe(longue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("VTIMEZONE + maître + exception → seul le maître change", () => {
|
||||||
|
const avant = ressource(
|
||||||
|
[
|
||||||
|
"END:VEVENT",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
`UID:${UID}`,
|
||||||
|
"RECURRENCE-ID;TZID=Europe/Paris:20260914T090000",
|
||||||
|
"DTSTAMP:20250828T095756Z",
|
||||||
|
"DTSTART;TZID=Europe/Paris:20260914T100000",
|
||||||
|
"SUMMARY:Forum (reporté)",
|
||||||
|
"CATEGORIES:Exception",
|
||||||
|
].join("\r\n"),
|
||||||
|
VTIMEZONE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, ajouterTag);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const { racine, maitre, exception } = relire(res.ics);
|
||||||
|
expect(categoriesDe(maitre)).toEqual(["mairie:ignoré"]);
|
||||||
|
expect(categoriesDe(exception!)).toEqual(["Exception"]);
|
||||||
|
expect(exception!.getFirstPropertyValue("summary")).toBe("Forum (reporté)");
|
||||||
|
expect(racine.getAllSubcomponents("vtimezone")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transformer rendant [] → propriété CATEGORIES supprimée", () => {
|
||||||
|
const avant = ressource("CATEGORIES:mairie:ignoré");
|
||||||
|
|
||||||
|
const res = appliquerAuxCategories(avant, UID, () => []);
|
||||||
|
|
||||||
|
expect(res.categories).toEqual([]);
|
||||||
|
expect(res.ics).not.toContain("CATEGORIES");
|
||||||
|
expect(relire(res.ics).maitre.getAllProperties("categories")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uid absent de la ressource → vevent-introuvable", () => {
|
||||||
|
const res = appliquerAuxCategories(ressource("SEQUENCE:1"), "uid-inconnu", ajouterTag);
|
||||||
|
expect(res).toEqual({ ok: false, erreur: "vevent-introuvable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ICS illisible → ics-illisible, jamais d'exception", () => {
|
||||||
|
const res = appliquerAuxCategories("BEGIN:VCALENDAR\r\nVERSION:2.0", UID, ajouterTag);
|
||||||
|
expect(res).toEqual({ ok: false, erreur: "ics-illisible" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ICAL.parse("") ne lève pas : il rend [] et le composant vide explose plus
|
||||||
|
// tard. Ces deux cas sont atteignables (calendar-data vide côté serveur).
|
||||||
|
for (const [nom, ics] of [
|
||||||
|
["chaîne vide", ""],
|
||||||
|
["blancs seuls", " \r\n "],
|
||||||
|
["texte quelconque", "pas du tout un calendrier"],
|
||||||
|
] as [string, string][]) {
|
||||||
|
test(`${nom} → ics-illisible, jamais d'exception`, () => {
|
||||||
|
expect(appliquerAuxCategories(ics, UID, ajouterTag)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
erreur: "ics-illisible",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formes exotiques rencontrées dans de vrais calendriers : elles doivent
|
||||||
|
// passer le garde-fou, sinon il bloquerait l'écriture pour rien.
|
||||||
|
const formesExotiques: [string, string][] = [
|
||||||
|
["paramètre quoté à virgule", 'ATTENDEE;CN="Doe, John";PARTSTAT=ACCEPTED:mailto:a@b.c'],
|
||||||
|
["propriété inconnue", "X-BIDULE;FOO=bar:valeur;avec;points-virgules"],
|
||||||
|
["pièce jointe binaire", "ATTACH;VALUE=BINARY;ENCODING=BASE64:aGVsbG8="],
|
||||||
|
["EXDATE multiple", "EXDATE;TZID=Europe/Paris:20260914T090000,20260921T090000"],
|
||||||
|
["accent sur la frontière de repliement", `DESCRIPTION:${"a".repeat(74)}é${"b".repeat(20)}`],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [nom, ligne] of formesExotiques) {
|
||||||
|
test(`${nom} → le garde-fou laisse passer`, () => {
|
||||||
|
const res = appliquerAuxCategories(ressource(ligne, VTIMEZONE), UID, ajouterTag);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seulesLesCategoriesOntChange", () => {
|
||||||
|
const base = ressource("DESCRIPTION:Venez nombreux\r\nCATEGORIES:Concert", VTIMEZONE);
|
||||||
|
|
||||||
|
test("seul CATEGORIES diffère → true", () => {
|
||||||
|
const autre = ressource("DESCRIPTION:Venez nombreux\r\nCATEGORIES:Jazz", VTIMEZONE);
|
||||||
|
expect(seulesLesCategoriesOntChange(base, autre)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CATEGORIES supprimé → true", () => {
|
||||||
|
const autre = ressource("DESCRIPTION:Venez nombreux", VTIMEZONE);
|
||||||
|
expect(seulesLesCategoriesOntChange(base, autre)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repliement et espacement différents → true (comparaison structurelle)", () => {
|
||||||
|
const replie = base.replace(
|
||||||
|
"DESCRIPTION:Venez nombreux",
|
||||||
|
"DESCRIPTION:Venez\r\n nombreux",
|
||||||
|
);
|
||||||
|
expect(seulesLesCategoriesOntChange(base, replie)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DESCRIPTION différente → false", () => {
|
||||||
|
const autre = ressource("DESCRIPTION:Annulé\r\nCATEGORIES:Concert", VTIMEZONE);
|
||||||
|
expect(seulesLesCategoriesOntChange(base, autre)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TZID différent → false", () => {
|
||||||
|
expect(
|
||||||
|
seulesLesCategoriesOntChange(base, base.replace("TZID=Europe/Paris:20260907", "TZID=Europe/Lisbon:20260907")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RRULE ajoutée → false", () => {
|
||||||
|
const autre = ressource(
|
||||||
|
"DESCRIPTION:Venez nombreux\r\nCATEGORIES:Concert\r\nRRULE:FREQ=WEEKLY",
|
||||||
|
VTIMEZONE,
|
||||||
|
);
|
||||||
|
expect(seulesLesCategoriesOntChange(base, autre)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("VTIMEZONE modifié → false", () => {
|
||||||
|
expect(
|
||||||
|
seulesLesCategoriesOntChange(base, base.replace("TZNAME:CEST", "TZNAME:XXXX")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ICS illisible d'un côté → false (on n'écrit pas dans le doute)", () => {
|
||||||
|
expect(seulesLesCategoriesOntChange(base, "n'importe quoi")).toBe(false);
|
||||||
|
expect(seulesLesCategoriesOntChange(base, "")).toBe(false);
|
||||||
|
expect(seulesLesCategoriesOntChange("", "")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
jourParis,
|
||||||
|
lireStatut,
|
||||||
|
poserIgnore,
|
||||||
|
poserSoumis,
|
||||||
|
retirerStatut,
|
||||||
|
} from "./extension/statut-mairie.js";
|
||||||
|
|
||||||
|
describe("lireStatut", () => {
|
||||||
|
test("aucune catégorie → aucun", () => {
|
||||||
|
expect(lireStatut([])).toEqual({ type: "aucun" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tag ignoré", () => {
|
||||||
|
expect(lireStatut(["mairie:ignoré"])).toEqual({ type: "ignoré" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tag soumis avec date", () => {
|
||||||
|
expect(lireStatut(["mairie:soumis:2026-08-13"])).toEqual({
|
||||||
|
type: "soumis",
|
||||||
|
jour: "2026-08-13",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les catégories des bénévoles n'interfèrent pas", () => {
|
||||||
|
expect(lireStatut(["Concert", "mairie:ignoré"])).toEqual({ type: "ignoré" });
|
||||||
|
expect(lireStatut(["Concert", "Jazz"])).toEqual({ type: "aucun" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tag mairie: inconnu → aucun, sans exception", () => {
|
||||||
|
expect(lireStatut(["mairie:bidule"])).toEqual({ type: "aucun" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plusieurs tags reconnus → le dernier l'emporte", () => {
|
||||||
|
expect(lireStatut(["mairie:ignoré", "mairie:soumis:2026-08-13"])).toEqual({
|
||||||
|
type: "soumis",
|
||||||
|
jour: "2026-08-13",
|
||||||
|
});
|
||||||
|
expect(lireStatut(["mairie:soumis:2026-08-13", "mairie:ignoré"])).toEqual({
|
||||||
|
type: "ignoré",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tag écrit en Unicode décomposé (NFD) → reconnu quand même", () => {
|
||||||
|
const decompose = "mairie:ignoré".normalize("NFD");
|
||||||
|
expect(decompose).not.toBe("mairie:ignoré");
|
||||||
|
expect(lireStatut([decompose])).toEqual({ type: "ignoré" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le tag posé est en forme normalisée (NFC)", () => {
|
||||||
|
expect(poserIgnore([])[0]).toBe("mairie:ignoré".normalize("NFC"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("soumis sans date ou date malformée → jour null", () => {
|
||||||
|
expect(lireStatut(["mairie:soumis"])).toEqual({ type: "soumis", jour: null });
|
||||||
|
expect(lireStatut(["mairie:soumis:hier"])).toEqual({ type: "soumis", jour: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("poser / retirer", () => {
|
||||||
|
test("poserSoumis remplace le tag existant, préserve ordre et casse des autres", () => {
|
||||||
|
expect(poserSoumis(["Concert", "mairie:ignoré"], "2026-08-13")).toEqual([
|
||||||
|
"Concert",
|
||||||
|
"mairie:soumis:2026-08-13",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("poserIgnore deux fois → pas de doublon", () => {
|
||||||
|
expect(poserIgnore(poserIgnore([]))).toEqual(["mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("poserIgnore sur une liste vide", () => {
|
||||||
|
expect(poserIgnore([])).toEqual(["mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("poser* écrase même un tag mairie: inconnu", () => {
|
||||||
|
expect(poserIgnore(["Concert", "mairie:bidule"])).toEqual(["Concert", "mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retirerStatut enlève le tag et laisse le reste", () => {
|
||||||
|
expect(retirerStatut(["Concert", "mairie:soumis:2026-08-13"])).toEqual(["Concert"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retirerStatut sur une liste sans tag → inchangée", () => {
|
||||||
|
expect(retirerStatut(["Concert", "Jazz"])).toEqual(["Concert", "Jazz"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les entrées ne sont jamais mutées", () => {
|
||||||
|
const categories = ["Concert", "mairie:ignoré"];
|
||||||
|
poserSoumis(categories, "2026-08-13");
|
||||||
|
poserIgnore(categories);
|
||||||
|
retirerStatut(categories);
|
||||||
|
expect(categories).toEqual(["Concert", "mairie:ignoré"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("jourParis", () => {
|
||||||
|
test("23h30 UTC le 31/12 → déjà le 1er janvier à Paris (+01:00)", () => {
|
||||||
|
expect(jourParis(new Date("2025-12-31T23:30:00Z"))).toBe("2026-01-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("22h30 UTC en août → déjà le lendemain à Paris (+02:00)", () => {
|
||||||
|
expect(jourParis(new Date("2026-08-13T22:30:00Z"))).toBe("2026-08-14");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user