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>
115 lines
6.6 KiB
JavaScript
115 lines
6.6 KiB
JavaScript
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',
|
||
]);
|
||
});
|