Files
Webklar-Kundenbereich/test/termineView.test.js
T
ClaudeandClaude Sonnet 5.5 9c07f6a8c5 Terminplaner: Portal-Ansicht – Nachbesserungen (1/2: Module getrennt)
public/termine.js in Huelle und Module zerlegt, Verhalten unveraendert (R48):
termine-dom.js (Helfer ohne Zustand), termine-tab-termine.js (Liste, Kopf, Zeilen),
termine-aktionen.js (Aktionen mit Rueckfrage), termine-formulare.js (Uhrzeiten,
Termin eintragen, Zeit sperren). Reiter bekommen Zustand und aktuell() nur ueber
ctx; die Huelle haengt sie ueber die Registry REITER ein. Statiktest prueft alle
public/termine*.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

261 lines
16 KiB
JavaScript
Raw 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 { readFileSync, readdirSync } 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<b>'), '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, /<section class="kb-view" id="view-termine">[\s\S]*<h1>Terminplaner<\/h1>[\s\S]*<div id="termine-body">/)
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">/)
// 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('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort()
assert.deepEqual(dateien, ['termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-tab-termine.js', 'termine-view.js', 'termine.js'])
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const quellen = dateien.map((f) => [f, ohneKommentare(lies(`public/${f}`))])
for (const [datei, quelle] of quellen) {
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
}
// Links nur auf die eigene Export-Schnittstelle (exportUrl), nie auf Kundentext
const hrefs = quellen.flatMap(([datei, quelle]) => (quelle.match(/href: [^,}]+|setAttribute\('href'/g) || []).map((m) => `${datei}: ${m}`))
assert.deepEqual(hrefs, ['termine.js: href: exportUrl(projectId)'])
const dom = quellen.find(([f]) => f === 'termine-dom.js')[1]
assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID')
})
test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => {
const huelle = lies('public/termine.js')
assert.match(huelle, /import \{ render as renderTermine \} from '\.\/termine-tab-termine\.js'/)
assert.match(huelle, /const REITER = \[/)
assert.match(lies('public/termine-tab-termine.js'), /export function render\(panel, ctx\)/)
// Reiter lesen Zustand nur aus ctx, nicht aus der Huelle
for (const datei of ['termine-tab-termine.js', 'termine-aktionen.js', 'termine-formulare.js']) {
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
}
})