Files
Rotatory/test/ical.test.js
T
ferdi2goandClaude Fable 5.1 95756778d8 Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben
Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 06:39:37 +00:00

115 lines
6.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { unescapeText, unfold, parseLine, parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences, toLocalDate } from '../server/ical.js';
test('unfold und parseLine', () => {
const lines = unfold('SUMMARY:Ein sehr\r\n langer Titel\r\nDTSTART;TZID=Europe/Berlin:20261005T093000\r\n');
assert.deepEqual(lines, ['SUMMARY:Ein sehr langer Titel', 'DTSTART;TZID=Europe/Berlin:20261005T093000']);
const p = parseLine('ATTENDEE;CN="Max, Mustermann";ROLE=REQ-PARTICIPANT:mailto:max@example.de');
assert.equal(p.name, 'ATTENDEE');
assert.equal(p.params.CN, 'Max, Mustermann');
assert.equal(p.value, 'mailto:max@example.de');
});
test('unescapeText löst \\; \\, \\n und \\\\ auf', () => {
assert.equal(unescapeText('a\\;b\\,c\\nd\\\\e'), 'a;b,c\nd\\e');
assert.equal(unescapeText('x\\\\ny'), 'x\\ny', 'maskierter Backslash gefolgt von n ist kein Zeilenumbruch');
});
test('toLocalDate: Datum, UTC nach Berlin, Wandzeit', () => {
assert.deepEqual(toLocalDate('20261005', { VALUE: 'DATE' }).date, '2026-10-05');
assert.equal(toLocalDate('20261005T220000Z').date, '2026-10-06'); // 22:00 UTC = 00:00 MESZ am Folgetag
assert.equal(toLocalDate('20261205T230000Z').date, '2026-12-06'); // Winterzeit
assert.equal(toLocalDate('20261005T093000', { TZID: 'Europe/Berlin' }).date, '2026-10-05');
assert.equal(toLocalDate('kaputt'), null);
});
const ICS = (body) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
test('ganztägige Urlaubswoche: DTEND exklusiv', () => {
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:a\r\nSUMMARY:Stefan Frühmorgen (Urlaub)\r\nDTSTART;VALUE=DATE:20260914\r\nDTEND;VALUE=DATE:20260919\r\nEND:VEVENT'));
assert.equal(ev.from, '2026-09-14');
assert.equal(ev.to, '2026-09-18');
assert.equal(ev.allDay, true);
assert.equal(ev.summary, 'Stefan Frühmorgen (Urlaub)');
});
test('Termin mit Uhrzeit, Termin bis Mitternacht, DURATION, mehrere VEVENTs', () => {
const evs = parseEvents(ICS([
'BEGIN:VEVENT\r\nUID:b\r\nSUMMARY:Libor Herudek (Urlaub)\r\nDTSTART;TZID=Europe/Berlin:20260918T120000\r\nDTEND;TZID=Europe/Berlin:20260918T160000\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:c\r\nSUMMARY:X (krank)\r\nDTSTART:20260917T220000Z\r\nDTEND:20260918T220000Z\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:d\r\nSUMMARY:Y (frei)\r\nDTSTART;VALUE=DATE:20260921\r\nDURATION:P2D\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:e\r\nSUMMARY:Z\r\nDTSTART;VALUE=DATE:20260922\r\nEND:VEVENT',
].join('\r\n')));
assert.equal(evs.length, 4);
assert.deepEqual([evs[0].from, evs[0].to], ['2026-09-18', '2026-09-18']);
assert.deepEqual([evs[1].from, evs[1].to], ['2026-09-18', '2026-09-18']); // 00:00–24:00 MESZ
assert.deepEqual([evs[2].from, evs[2].to], ['2026-09-21', '2026-09-22']);
assert.deepEqual([evs[3].from, evs[3].to], ['2026-09-22', '2026-09-22']);
});
test('RRULE und RECURRENCE-ID werden durchgereicht, Alarme stören nicht', () => {
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:r\r\nSUMMARY:A (frei)\r\nDTSTART;VALUE=DATE:20260914\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\nEND:VALARM\r\nEND:VEVENT'));
assert.equal(ev.rrule, 'FREQ=WEEKLY;BYDAY=MO');
assert.equal(ev.description, '');
});
test('vCard 3.0 mit Base64-Foto und vCard 4.0 mit data-URI', () => {
const v3 = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ferdinand\r\nFN:Ferdinand Urban\r\nN:Urban;Ferdinand;;;\r\nEMAIL;TYPE=INTERNET:urban@team-tradeo.de\r\nPHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\n SkZJRg==\r\nCLOUD:ferdinand@cld2.tradeo.de\r\nEND:VCARD\r\n';
const [c] = parseVCards(v3);
assert.equal(c.uid, 'ferdinand');
assert.equal(c.firstName, 'Ferdinand');
assert.equal(c.lastName, 'Urban');
assert.equal(c.email, 'urban@team-tradeo.de');
assert.deepEqual(c.photo, { kind: 'inline', mime: 'image/jpeg', base64: '/9j/4AAQSkZJRg==' });
const v4 = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:Anna Maria Ciesla\r\nPHOTO:data:image/png;base64,iVBORw0KGgo=\r\nEND:VCARD\r\n';
const [d] = parseVCards(v4);
assert.equal(d.firstName, 'Anna Maria');
assert.equal(d.lastName, 'Ciesla');
assert.equal(d.photo.mime, 'image/png');
assert.equal(d.photo.base64, 'iVBORw0KGgo=');
const v4url = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:y\r\nFN:Solo\r\nPHOTO;VALUE=URI:https://cloud.example.de/avatar.png\r\nEND:VCARD\r\n';
const [e] = parseVCards(v4url);
assert.equal(e.lastName, 'Solo');
assert.equal(e.firstName, '');
assert.deepEqual(e.photo, { kind: 'url', url: 'https://cloud.example.de/avatar.png' });
});
test('Abwesenheitstitel deuten', () => {
assert.deepEqual(parseAbsenceSummary('Stefan Frühmorgen (Urlaub)'), { name: 'Stefan Frühmorgen', kind: 'urlaub', raw: 'Urlaub' });
assert.equal(parseAbsenceSummary('Carmine Mastromarino (krank)').kind, 'krankheit');
assert.equal(parseAbsenceSummary('Alex Koop (frei)').kind, 'frei');
assert.equal(parseAbsenceSummary('Hans Holzmann [Home Office]').kind, 'frei');
assert.deepEqual(parseAbsenceSummary('Nur Name'), { name: 'Nur Name', kind: 'sonstiges', raw: '' });
});
test('Namen zuordnen: Vor+Nach, Nach+Vor, Umlaute, eindeutiger Nachname', () => {
const persons = [
{ id: '1', firstName: 'Stefan', lastName: 'Frühmorgen' },
{ id: '2', firstName: 'Michael', lastName: 'Lotter' },
{ id: '3', firstName: 'Michael', lastName: 'Schuster' },
];
assert.equal(matchPerson('Stefan Frühmorgen', persons).id, '1');
assert.equal(matchPerson('Fruehmorgen, Stefan', persons).id, '1');
assert.equal(matchPerson('FRÜHMORGEN STEFAN', persons).id, '1');
assert.equal(matchPerson('Stefan Fru\u0308hmorgen', persons).id, '1', 'NFD-Umlaut (u + Trema)');
assert.equal(matchPerson('Lotter', persons).id, '2');
assert.equal(matchPerson('Michael', persons), null);
assert.equal(matchPerson('Unbekannt Person', persons), null);
assert.equal(matchPerson('', persons), null);
});
test('Abwesenheiten verschmelzen: angrenzend und überlappend, getrennt nach Art', () => {
const merged = mergeAbsences([
{ personId: 'a', type: 'urlaub', from: '2026-10-05', to: '2026-10-06' },
{ personId: 'a', type: 'urlaub', from: '2026-10-07', to: '2026-10-09' },
{ personId: 'a', type: 'urlaub', from: '2026-10-08', to: '2026-10-08' },
{ personId: 'a', type: 'frei', from: '2026-10-05', to: '2026-10-05' },
{ personId: 'a', type: 'urlaub', from: '2026-10-19', to: '2026-10-19' },
{ personId: 'b', type: 'urlaub', from: '2026-10-05', to: '2026-10-05' },
]);
assert.deepEqual(merged.map((m) => `${m.personId}/${m.type}/${m.from}/${m.to}`), [
'a/urlaub/2026-10-05/2026-10-09', 'a/frei/2026-10-05/2026-10-05', 'b/urlaub/2026-10-05/2026-10-05', 'a/urlaub/2026-10-19/2026-10-19',
]);
});