import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, fehltText, heuteBerlin, istSperre, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel, } from '../public/termine-view.js' // Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird // nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit. test('fensterParsen: Zeitfenster aus Text, normalisiert', () => { assert.deepEqual(fensterParsen('09:00–12:30, 13:30–18:00'), [['09:00', '12:30'], ['13:30', '18:00']]) assert.deepEqual(fensterParsen('9:00 - 12:30; 13:30 bis 18:00'), [['09:00', '12:30'], ['13:30', '18:00']]) assert.deepEqual(fensterParsen('9.00—24:00'), [['09:00', '24:00']]) assert.deepEqual(fensterParsen('08:00-12:00 und 14:00-18:00'), [['08:00', '12:00'], ['14:00', '18:00']]) assert.deepEqual(fensterParsen(' '), [], 'leer = an diesem Tag geschlossen') assert.deepEqual(fensterParsen(''), []) }) test('fensterParsen: ungültige Eingaben ⇒ null', () => { for (const schlecht of [ 'abc', '09:00', '09:00–', '–12:00', '12:00–09:00', '09:00–09:00', '09:07–12:00', '25:00–26:00', '24:00–24:00', '09:00–12:00, 11:00–13:00', '13:00–14:00, 09:00–10:00', '09:00–12:00,', '09:00–12:00 13:00–14:00', null, undefined, 5, ]) { assert.equal(fensterParsen(schlecht), null, String(schlecht)) } }) test('fensterText ist die Umkehrung von fensterParsen', () => { assert.equal(fensterText([['09:00', '12:30'], ['13:30', '18:00']]), '09:00–12:30, 13:30–18:00') assert.equal(fensterText([]), '') assert.equal(fensterText(undefined), '') assert.deepEqual(fensterParsen(fensterText([['08:00', '24:00']])), [['08:00', '24:00']]) }) test('zeitNormal: HH:MM, Ende darf 24:00 sein', () => { assert.equal(zeitNormal('9:05'), '09:05') assert.equal(zeitNormal(' 09:00 '), '09:00') assert.equal(zeitNormal('17.30'), '17:30') assert.equal(zeitNormal('24:00'), null) assert.equal(zeitNormal('24:00', { ende: true }), '24:00') assert.equal(zeitNormal('24:05', { ende: true }), null) assert.equal(zeitNormal('12:07'), '12:07', 'Zeit ohne 5er-Raster wird nur normalisiert, der Server prüft das Raster') assert.equal(zeitNormal('12:60'), null) assert.equal(zeitNormal('abc'), null) assert.equal(zeitNormal(''), null) assert.equal(zeitNormal(undefined), null) }) test('statusLabel: alle acht Status deutsch, Unbekanntes nie roh', () => { assert.deepEqual( ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen'].map(statusLabel), ['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen'], ) assert.equal(statusLabel('geheim'), 'Unbekannt') assert.equal(statusLabel(undefined), 'Unbekannt') for (const s of ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) { assert.match(statusTon(s), /^(ok|wait|off|bad)$/, s) } assert.equal(statusTon('bestaetigt'), 'ok') assert.equal(statusTon('angefragt'), 'wait') }) test('Kalender: wocheVon (Montag bis Sonntag), monatVon, tagPlus über Monats- und Jahresgrenzen', () => { assert.deepEqual(wocheVon('2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' }, 'Montag') assert.deepEqual(wocheVon('2026-10-11'), { von: '2026-10-05', bis: '2026-10-11' }, 'Sonntag gehört zur Woche davor') assert.deepEqual(wocheVon('2026-10-07'), { von: '2026-10-05', bis: '2026-10-11' }) assert.deepEqual(wocheVon('2026-12-31'), { von: '2026-12-28', bis: '2027-01-03' }, 'Jahreswechsel') assert.deepEqual(monatVon('2026-10-15'), { von: '2026-10-01', bis: '2026-10-31' }) assert.deepEqual(monatVon('2028-02-10'), { von: '2028-02-01', bis: '2028-02-29' }, 'Schaltjahr') assert.deepEqual(monatVon('2026-12-01'), { von: '2026-12-01', bis: '2026-12-31' }) assert.equal(tagPlus('2026-10-31', 1), '2026-11-01') assert.equal(tagPlus('2026-01-01', -1), '2025-12-31') assert.equal(tagPlus('2026-03-29', 1), '2026-03-30', 'Zeitumstellung spielt beim Kalender keine Rolle') assert.equal(tagPlus('2026-10-25', 1), '2026-10-26') }) test('bereichFuer und verschiebeTag je Ansicht', () => { assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' }) assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' }) assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' }) assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-06' }, 'ab heute, 62 Tage (Grenze der Schnittstelle)') assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01') assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30') assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12') assert.equal(verschiebeTag('woche', '2026-10-05', -1), '2026-09-28') assert.equal(verschiebeTag('monat', '2026-10-17', 1), '2026-11-01') assert.equal(verschiebeTag('monat', '2026-01-17', -1), '2025-12-01') assert.equal(verschiebeTag('monat', '2026-12-31', 1), '2027-01-01') }) test('Daten: datumLang, datumKurz, tagUeberschrift, zeitraumTitel', () => { assert.equal(datumLang('2026-10-05'), 'Montag, 5. Oktober 2026') assert.equal(datumLang('2026-03-01'), 'Sonntag, 1. März 2026') assert.equal(datumKurz('2026-10-05'), '05.10.2026') assert.equal(tagUeberschrift('2026-10-05', '2026-10-05'), 'Heute · Montag, 5. Oktober') assert.equal(tagUeberschrift('2026-10-06', '2026-10-05'), 'Morgen · Dienstag, 6. Oktober') assert.equal(tagUeberschrift('2026-10-04', '2026-10-05'), 'Gestern · Sonntag, 4. Oktober') assert.equal(tagUeberschrift('2026-10-12', '2026-10-05'), 'Montag, 12. Oktober') assert.equal(tagUeberschrift('2027-01-04', '2026-10-05'), 'Montag, 4. Januar 2027', 'anderes Jahr nennt das Jahr') assert.equal(zeitraumTitel('tag', '2026-10-05'), 'Montag, 5. Oktober 2026') assert.equal(zeitraumTitel('woche', '2026-10-07'), '5.–11. Oktober 2026') assert.equal(zeitraumTitel('woche', '2026-10-28'), '26. Oktober – 1. November 2026') assert.equal(zeitraumTitel('woche', '2026-12-30'), '28. Dezember 2026 – 3. Januar 2027') assert.equal(zeitraumTitel('monat', '2026-10-17'), 'Oktober 2026') assert.equal(zeitraumTitel('anfragen', '2026-10-17'), 'Offene Anfragen') }) test('heuteBerlin: der Berliner Kalendertag, nicht der des Browsers', () => { assert.equal(heuteBerlin(new Date('2026-10-04T22:30:00Z')), '2026-10-05', '00:30 Berlin (MESZ) ist schon Montag') assert.equal(heuteBerlin(new Date('2026-10-05T21:59:00Z')), '2026-10-05') assert.equal(heuteBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01', 'MEZ') assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/) }) test('endeZeit: Ende des Termins in Berliner Wanduhr aus endsAt', () => { assert.equal(endeZeit({ endsAt: '2026-10-05T08:45:00.000Z' }), '10:45') assert.equal(endeZeit({ endsAt: '2026-12-01T08:45:00.000Z' }), '09:45', 'Winterzeit') assert.equal(endeZeit({ endsAt: '2026-10-05T22:00:00.000Z' }), '00:00') assert.equal(endeZeit({ endsAt: 'kaputt' }), '') assert.equal(endeZeit({}), '') }) const B = (extra = {}) => ({ id: 'b1', status: 'bestaetigt', quelle: 'online', startsAt: '2026-10-05T08:00:00.000Z', endsAt: '2026-10-05T08:45:00.000Z', datum: '2026-10-05', zeit: '10:00', gastName: 'Max', gastEmail: 'max@example.com', gastTelefon: '', gastNachricht: '', ...extra, }) test('gruppiereNachTag: nach Berliner Tag, Tage und Termine aufsteigend, Termine der Liste bleiben vollständig', () => { const liste = [ B({ id: 'c', datum: '2026-10-06', zeit: '09:00', startsAt: '2026-10-06T07:00:00.000Z' }), B({ id: 'b', datum: '2026-10-05', zeit: '11:00', startsAt: '2026-10-05T09:00:00.000Z' }), B({ id: 'a', datum: '2026-10-05', zeit: '10:00', startsAt: '2026-10-05T08:00:00.000Z' }), ] const gruppen = gruppiereNachTag(liste) assert.deepEqual(gruppen.map((g) => g.tag), ['2026-10-05', '2026-10-06']) assert.deepEqual(gruppen[0].buchungen.map((b) => b.id), ['a', 'b']) assert.deepEqual(gruppen[1].buchungen.map((b) => b.id), ['c']) assert.deepEqual(gruppiereNachTag([]), []) assert.deepEqual(gruppiereNachTag(undefined), []) assert.deepEqual(liste.map((b) => b.id), ['c', 'b', 'a'], 'Eingabe bleibt unverändert') }) test('istSperre: nur quelle sperre', () => { assert.equal(istSperre({ quelle: 'sperre' }), true) assert.equal(istSperre({ quelle: 'manuell' }), false) assert.equal(istSperre({ quelle: 'online' }), false) assert.equal(istSperre({}), false) }) test('moeglicheAktionen: je Status und Zeit; Sperren nie mit Gast-Aktionen', () => { const vorher = Date.parse('2026-10-05T07:00:00Z') // vor Beginn const mitten = Date.parse('2026-10-05T08:10:00Z') // nach Beginn const danach = Date.parse('2026-10-05T09:00:00Z') // nach Ende assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), vorher), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), mitten), ['ablehnen', 'anonymisieren'], 'begonnene Anfrage lässt sich nicht mehr annehmen') assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), vorher), ['verschieben', 'absagen', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), mitten), ['nichterschienen', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), danach), ['nichterschienen', 'anonymisieren']) assert.deepEqual(moeglicheAktionen(B({ status: 'erledigt' }), danach), ['nichterschienen', 'anonymisieren']) for (const s of ['nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) assert.deepEqual(moeglicheAktionen(B({ status: s }), danach), ['anonymisieren'], s) assert.deepEqual(moeglicheAktionen(B({ status: 'gehalten' }), vorher), [], 'Reservierung wartet auf den Gast') // Gastdaten schon gelöscht oder nie erfasst: nichts zu löschen assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: '' }), danach), []) assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: '', gastEmail: '' }), danach), []) assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: 'x@y.de' }), danach), ['anonymisieren']) // Sperre: nur aufheben, solange sie noch nicht vorbei ist assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), vorher), ['entsperren']) assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), mitten), ['entsperren']) assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'erledigt', gastName: '' }), danach), []) }) test('Filter: Statuslisten für die Schnittstelle', () => { assert.deepEqual(FILTER.map((f) => f.key), ['alle', 'offen', 'angefragt', 'bestaetigt', 'abgesagt']) assert.equal(filterStatus('alle'), '') assert.equal(filterStatus('offen'), 'angefragt,bestaetigt') assert.equal(filterStatus('angefragt'), 'angefragt') assert.equal(filterStatus('bestaetigt'), 'bestaetigt') assert.equal(filterStatus('abgesagt'), 'abgesagt,abgelehnt,verfallen') assert.equal(filterStatus('gibtsnicht'), '') }) test('anfragenText: Einzahl und Mehrzahl', () => { assert.equal(anfragenText(0), '') assert.equal(anfragenText(1), '1 Anfrage wartet') assert.equal(anfragenText(2), '2 Anfragen warten') assert.equal(anfragenText(17), '17 Anfragen warten') assert.equal(anfragenText(undefined), '') }) test('Absagegründe: Auswahl mit kurzen Texten (Server: höchstens 100 Zeichen)', () => { assert.ok(ABSAGE_GRUENDE.length >= 4) for (const g of ABSAGE_GRUENDE) assert.ok(g.length > 0 && [...g].length <= 100, g) assert.equal(new Set(ABSAGE_GRUENDE).size, ABSAGE_GRUENDE.length) }) test('ressourcenWort: Person, Platz oder beides', () => { assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'person' }]), 'Person') assert.equal(ressourcenWort([{ typ: 'platz' }]), 'Platz') assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'platz' }]), 'Person/Platz') assert.equal(ressourcenWort([]), 'Person/Platz') assert.equal(ressourcenWort(undefined), 'Person/Platz') }) test('fehltText: Pflichtangaben für Gäste in Worten', () => { assert.equal(fehltText(['betrieb.telefon']), 'deine Telefonnummer') assert.equal(fehltText(['betrieb.telefon', 'betrieb.adresse']), 'deine Telefonnummer und deine Anschrift') assert.equal(fehltText(['betrieb.adresse']), 'deine Anschrift') assert.equal(fehltText([]), '') assert.equal(fehltText(['unbekannt']), '', 'Unbekanntes nie roh') assert.equal(fehltText(undefined), '') }) // ---- Verdrahtung (Portal-Ansicht #termine) const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen einen absoluten Import', () => { const app = lies('public/app.js') assert.match(app, /VIEW_HASHES = \{[^}]*termine: 'termine'/) assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen'/) assert.match(app, /FEATURE_LABELS = \{[^}]*termine: 'Terminplaner'/) assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/) const html = lies('public/dashboard.html') assert.match(html, /
[\s\S]*

Terminplaner<\/h1>[\s\S]*
/) assert.match(html, //) // R4: funktionen.js lädt nichts Neues (die Kachel steht inline); test/funktionenTiles.test.js biegt genau diesen Import um const fn = lies('public/funktionen.js') assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'"]) assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import') }) test('termine.js: Fremdtext nur als Text, keine Browser-Dialoge, Du-Form', () => { const quelle = lies('public/termine.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') // ohne Kommentare assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML') assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite') assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form') // Links nur auf die eigene Export-Schnittstelle, nie auf Kundentext const hrefs = quelle.match(/href: `[^`]*`/g) || [] assert.deepEqual(hrefs, ['href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`']) assert.doesNotMatch(quelle, /href: [^`]|setAttribute\('href'/, 'sonst kein href') })