Files
echo-du-huit/evenements.test.ts
Pierre MartinandClaude Opus 4.8 1878ba08ec feat: parse iCalendar feed into Event[] (T1)
Add a pure parser parserEvenements(flux, aujourdhui) backed by vendored
ical.js v2.2.1 (ESM, no build). Returns events at the frozen Event contract
{ uid, titre, description, lieu, debut, fin, categories, journeeEntiere }:

- register embedded VTIMEZONE before toJSDate (correct offset, e.g. Africa/Lagos)
- dedupe by UID, keep the master (RECURRENCE-ID exceptions ignored)
- filter to today and future (local start of day)
- coerce missing text fields to ""

Edge cases deferred to T3 (RRULE expansion, all-day hour) are documented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:18:29 +02:00

244 lines
7.4 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { parserEvenements } from "./extension/evenements.js";
// Fixtures ICS inline et minimalistes (jamais l'export réel : données perso).
// `aujourdhui` est injecté pour des tests déterministes.
const AUJOURDHUI = new Date("2026-06-30T12:00:00Z");
function vcalendar(...lignes: string[]): string {
return ["BEGIN:VCALENDAR", "VERSION:2.0", ...lignes, "END:VCALENDAR"].join(
"\r\n",
) + "\r\n";
}
function vevent(...lignes: string[]): string {
return ["BEGIN:VEVENT", ...lignes, "END:VEVENT"].join("\r\n");
}
const VTIMEZONE_PARIS = [
"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",
];
const VTIMEZONE_LAGOS = [
"BEGIN:VTIMEZONE",
"TZID:Africa/Lagos",
"BEGIN:STANDARD",
"TZOFFSETFROM:+0100",
"TZOFFSETTO:+0100",
"TZNAME:WAT",
"DTSTART:19700101T000000",
"END:STANDARD",
"END:VTIMEZONE",
];
describe("parserEvenements", () => {
test("VEVENT complet → tous les champs mappés", () => {
const flux = vcalendar(
...VTIMEZONE_PARIS,
vevent(
"UID:complet@8",
"SUMMARY:Atelier lecture",
"DESCRIPTION:Venez nombreux",
"LOCATION:Café du Huit",
"CATEGORIES:Culture",
"DTSTART;TZID=Europe/Paris:20260710T180000",
"DTEND;TZID=Europe/Paris:20260710T200000",
),
);
const [event] = parserEvenements(flux, AUJOURDHUI);
expect(event.uid).toBe("complet@8");
expect(event.titre).toBe("Atelier lecture");
expect(event.description).toBe("Venez nombreux");
expect(event.lieu).toBe("Café du Huit");
expect(event.categories).toEqual(["Culture"]);
expect(event.journeeEntiere).toBe(false);
expect(event.debut.toISOString()).toBe("2026-07-10T16:00:00.000Z");
expect(event.fin.toISOString()).toBe("2026-07-10T18:00:00.000Z");
});
test("SUMMARY / DESCRIPTION / LOCATION absents → chaînes vides", () => {
const flux = vcalendar(
vevent("UID:vide@8", "DTSTART:20260710T180000"),
);
const [event] = parserEvenements(flux, AUJOURDHUI);
expect(event.titre).toBe("");
expect(event.description).toBe("");
expect(event.lieu).toBe("");
});
describe("CATEGORIES", () => {
test("une valeur → tableau à un élément", () => {
const flux = vcalendar(
vevent("UID:cat1@8", "DTSTART:20260710T180000", "CATEGORIES:Culture"),
);
expect(parserEvenements(flux, AUJOURDHUI)[0].categories).toEqual([
"Culture",
]);
});
test("plusieurs valeurs séparées par virgule → tableau", () => {
const flux = vcalendar(
vevent("UID:cat2@8", "DTSTART:20260710T180000", "CATEGORIES:A,B"),
);
expect(parserEvenements(flux, AUJOURDHUI)[0].categories).toEqual([
"A",
"B",
]);
});
test("absent → tableau vide", () => {
const flux = vcalendar(
vevent("UID:cat0@8", "DTSTART:20260710T180000"),
);
expect(parserEvenements(flux, AUJOURDHUI)[0].categories).toEqual([]);
});
});
test("dédoublonnage : maître + RECURRENCE-ID même UID → 1 Event (le maître)", () => {
const flux = vcalendar(
vevent(
"UID:recur@8",
"SUMMARY:Maître",
"DTSTART:20260710T180000",
"RRULE:FREQ=WEEKLY",
),
vevent(
"UID:recur@8",
"SUMMARY:Exception",
"RECURRENCE-ID:20260717T180000",
"DTSTART:20260717T190000",
),
);
const events = parserEvenements(flux, AUJOURDHUI);
expect(events).toHaveLength(1);
expect(events[0].titre).toBe("Maître");
});
describe("filtre futur (aujourd'hui inclus)", () => {
test("hier → exclu", () => {
const flux = vcalendar(
vevent("UID:hier@8", "DTSTART:20260629T180000"),
);
expect(parserEvenements(flux, AUJOURDHUI)).toHaveLength(0);
});
test("aujourd'hui → inclus", () => {
const flux = vcalendar(
vevent("UID:auj@8", "DTSTART:20260630T080000"),
);
expect(parserEvenements(flux, AUJOURDHUI)).toHaveLength(1);
});
test("demain → inclus", () => {
const flux = vcalendar(
vevent("UID:demain@8", "DTSTART:20260701T180000"),
);
expect(parserEvenements(flux, AUJOURDHUI)).toHaveLength(1);
});
});
describe("journée entière", () => {
test("DTSTART;VALUE=DATE → journeeEntiere true", () => {
const flux = vcalendar(
vevent("UID:jour@8", "DTSTART;VALUE=DATE:20260701"),
);
expect(parserEvenements(flux, AUJOURDHUI)[0].journeeEntiere).toBe(true);
});
test("événement horodaté → journeeEntiere false", () => {
const flux = vcalendar(
vevent("UID:heure@8", "DTSTART:20260701T180000"),
);
expect(parserEvenements(flux, AUJOURDHUI)[0].journeeEntiere).toBe(false);
});
});
test("fuseau Europe/Paris (VTIMEZONE) → instant absolu correct", () => {
const flux = vcalendar(
...VTIMEZONE_PARIS,
vevent("UID:paris@8", "DTSTART;TZID=Europe/Paris:20260710T120000"),
);
// Été : Paris = UTC+2 → 12:00 local = 10:00 UTC.
expect(parserEvenements(flux, AUJOURDHUI)[0].debut.toISOString()).toBe(
"2026-07-10T10:00:00.000Z",
);
});
test("fuseau Africa/Lagos (VTIMEZONE) → offset distinct de Paris", () => {
const flux = vcalendar(
...VTIMEZONE_LAGOS,
vevent("UID:lagos@8", "DTSTART;TZID=Africa/Lagos:20260710T120000"),
);
// Lagos = UTC+1 toute l'année → 12:00 local = 11:00 UTC (≠ Paris).
expect(parserEvenements(flux, AUJOURDHUI)[0].debut.toISOString()).toBe(
"2026-07-10T11:00:00.000Z",
);
});
test("line-folding + échappements (\\, \\n) → décodés", () => {
const flux = vcalendar(
vevent(
"UID:fold@8",
"DTSTART:20260710T180000",
// Ligne repliée (continuation par CRLF + espace) + échappements.
"SUMMARY:Café\\, lecture et tr\r\n ès longue suite\\nsur deux lignes",
),
);
expect(parserEvenements(flux, AUJOURDHUI)[0].titre).toBe(
"Café, lecture et très longue suite\nsur deux lignes",
);
});
test("DTEND absent → fin dérivée sans planter (fin >= debut)", () => {
const flux = vcalendar(
vevent("UID:nodtend@8", "DTSTART:20260710T180000"),
);
const [event] = parserEvenements(flux, AUJOURDHUI);
expect(event.fin.getTime()).toBeGreaterThanOrEqual(event.debut.getTime());
});
describe("récurrent (RRULE) — comportement T1 sans expansion", () => {
test("maître futur → présent une seule fois", () => {
const flux = vcalendar(
vevent(
"UID:rfutur@8",
"DTSTART:20260710T180000",
"RRULE:FREQ=WEEKLY",
),
);
expect(parserEvenements(flux, AUJOURDHUI)).toHaveLength(1);
});
test("maître passé → absent (trou T1 assumé)", () => {
const flux = vcalendar(
vevent(
"UID:rpasse@8",
"DTSTART:20260101T180000",
"RRULE:FREQ=WEEKLY",
),
);
expect(parserEvenements(flux, AUJOURDHUI)).toHaveLength(0);
});
});
test("flux sans VEVENT / VCALENDAR vide → []", () => {
expect(parserEvenements(vcalendar(), AUJOURDHUI)).toEqual([]);
});
});