Files
Webklar-Kundenbereich/test/termineView.test.js
T

597 lines
41 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, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
} from '../public/termine-view.js'
import {
MAX, BUNDESLAENDER, ERINNERUNG_STUNDEN, FENSTER_FEHLER, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, RASTER_MIN, WOCHENTAGE_PLAN, ZAHLARTEN,
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, ordneNachIds, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
} from '../public/termine-view.js'
import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js'
import { exportUrl, request } from '../public/termine-dom.js'
import { beschrifteFehler } from '../public/termine-speichern.js'
import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js'
import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js'
import { VORLAGEN, ausVorlage } from '../server/services/termine/vorlagen.js'
const JETZT_SERVER = new Date('2026-10-05T08:00:00.000Z') // feste Uhr für Aufrufe der Server-Prüfung
// 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' })
// Ruling R50: Offene Anfragen immer ab heute 365 Tage (so lange kann eine Anfrage nicht in der Zukunft liegen), unabhängig vom Buchungszeitraum
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2027-10-05' }, 'ab heute 365 Tage')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 30), { von: '2026-10-05', bis: '2027-10-05' }, 'ein kurzer Buchungszeitraum begrenzt die Liste nicht')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2028-02-28'), { von: '2028-02-28', bis: '2029-02-27' }, 'Schaltjahr: 365 Kalendertage')
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('fensterAufteilen: lückenlose Abfragefenster von höchstens 62 Tagen nach dem Beginn (Grenze der Schnittstelle)', () => {
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-10-05'), [{ von: '2026-10-05', bis: '2026-10-05' }])
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-04'), [{ von: '2026-10-05', bis: '2026-12-04' }], '60 Tage: ein Aufruf')
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-06'), [{ von: '2026-10-05', bis: '2026-12-06' }], 'genau 62 Tage nach dem Beginn: noch ein Aufruf')
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-07'), [{ von: '2026-10-05', bis: '2026-12-06' }, { von: '2026-12-07', bis: '2026-12-07' }])
const jahr = fensterAufteilen('2026-10-05', '2027-10-05')
assert.equal(jahr.length, 6, '365 Tage: höchstens 6 Aufrufe')
assert.equal(jahr[0].von, '2026-10-05')
assert.equal(jahr.at(-1).bis, '2027-10-05')
for (let i = 0; i < jahr.length; i += 1) {
assert.ok(jahr[i].bis <= tagPlus(jahr[i].von, 62), `Fenster ${i} nicht länger als 62 Tage`)
if (i > 0) assert.equal(jahr[i].von, tagPlus(jahr[i - 1].bis, 1), `Fenster ${i} schließt ohne Lücke und Überlappung an`)
}
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-10-01'), [], 'Ende vor Beginn')
})
test('istTag: nur echte Kalendertage aus einem vernünftigen Jahr (Datumsfeld mit 5-stelligem Jahr darf nichts auslösen)', () => {
for (const gut of ['2026-10-05', '2028-02-29', '2000-01-01', '2100-12-31']) assert.equal(istTag(gut), true, gut)
for (const schlecht of ['12345-01-01', '+12345-01-01', '2026-02-30', '2026-13-01', '2026-00-10', '2027-02-29', '0001-01-01', '1999-12-31', '2101-01-01', '2026-1-5', '', 'abc', null, undefined, 20261005]) {
assert.equal(istTag(schlecht), false, String(schlecht))
}
})
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' }), '24:00', 'Ende um Mitternacht ist 24:00, nicht 00:00 (22:00–24:00)')
assert.equal(endeZeit({ endsAt: '2026-10-05T22:05:00.000Z' }), '00:05', 'nach Mitternacht bleibt es 00:05')
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-gaestetexte.js', 'termine-speichern.js', 'termine-tab-einstellungen.js',
'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-termine.js', 'termine-team-ausnahmen.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}`))])
// Nur termine-gaestetexte.js schreibt für Gäste (Mustertext, Auswahl der Anrede) und darf deshalb die Sie-Form enthalten
const GAESTETEXTE = 'termine-gaestetexte.js'
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`)
if (datei !== GAESTETEXTE) 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`)
}
assert.match(quellen.find(([f]) => f === GAESTETEXTE)[1], /\bSie\b/, 'die Ausnahme hat einen Grund')
// 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-dom.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')
// Speichern der Einstellungen gibt es genau einmal (termine-speichern.js), nicht in jedem Reiter
assert.deepEqual(quellen.filter(([, quelle]) => /'PATCH'/.test(quelle)).map(([datei]) => datei), ['termine-speichern.js'])
// Editoren klappen ueber einen einzigen Baustein auf (editorUmschalter in termine-dom.js), nicht jeder mit eigenem Code
assert.deepEqual(quellen.filter(([, quelle]) => /'aria-expanded', 'true'|setAttribute\('aria-expanded', 'true'\)/.test(quelle)).map(([datei]) => datei), ['termine-dom.js'])
// Editoren merken sich keine Stelle in der Liste (sie kann veraltet sein), sondern suchen den Eintrag per ID
for (const [datei, quelle] of quellen.filter(([f]) => /^termine-(tab-(leistungen|team|einstellungen)|team-ausnahmen)\.js$/.test(f))) {
assert.doesNotMatch(quelle, /entwurf\.\w+\[(index|i)\]/, `${datei}: Eintrag per ID suchen, nicht per Stelle`)
}
})
test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => {
const huelle = lies('public/termine.js')
for (const tab of ['termine', 'leistungen', 'team', 'einstellungen']) {
assert.match(huelle, new RegExp(`import \\{ render as render${tab[0].toUpperCase()}${tab.slice(1)} \\} from '\\./termine-tab-${tab}\\.js'`))
assert.match(lies(`public/termine-tab-${tab}.js`), /export function render\(panel, ctx\)/)
}
assert.match(huelle, /const REITER = \[/)
assert.deepEqual([...huelle.matchAll(/\{ key: '(\w+)', label: '([^']+)', render: (\w+) \}/g)].map((m) => [m[1], m[2], m[3]]), [
['termine', 'Termine', 'renderTermine'], ['leistungen', 'Leistungen', 'renderLeistungen'], ['team', 'Team & Plätze', 'renderTeam'], ['einstellungen', 'Einstellungen', 'renderEinstellungen'],
])
// Reiter lesen Zustand nur aus ctx, nicht aus der Huelle
for (const datei of ['termine-tab-termine.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-einstellungen.js', 'termine-team-ausnahmen.js', 'termine-speichern.js', 'termine-aktionen.js', 'termine-formulare.js']) {
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
}
})
test('beschrifteFehler: Fehler ohne Platz am Feld nennen den Eintrag', () => {
const quellen = [{
leistungen: [{ id: 's_aaaaaaaa', name: 'Herrenschnitt' }, { id: 's_bbbbbbbb', name: 'Färben' }],
ressourcen: [{ id: 'r_aaaaaaaa', name: 'Anna', typ: 'person' }, { id: 'r_bbbbbbbb', name: 'Kabine 1', typ: 'platz' }],
abwesenheiten: [{ id: 'a_aaaaaaaa', titel: 'Betriebsurlaub' }],
}]
assert.equal(beschrifteFehler('leistungen.1.ressourcen', 'Eine aktive Leistung braucht …', quellen), 'Leistung „Färben“: Eine aktive Leistung braucht …', 'nach Stelle')
assert.equal(beschrifteFehler('leistungen.s_aaaaaaaa', 'Für diese Leistung gibt es noch 1 künftigen Termin.', quellen), 'Leistung „Herrenschnitt“: Für diese Leistung gibt es noch 1 künftigen Termin.', 'nach ID')
assert.equal(beschrifteFehler('ressourcen.r_bbbbbbbb', 'Für diesen Platz …', quellen), 'Platz „Kabine 1“: Für diesen Platz …')
assert.equal(beschrifteFehler('ressourcen.0.plan.3', 'Zeitfenster überlappen sich.', quellen), 'Person „Anna“: Zeitfenster überlappen sich.')
assert.equal(beschrifteFehler('abwesenheiten.0.bis', 'Das Ende …', quellen), 'Abwesenheit „Betriebsurlaub“: Das Ende …')
assert.equal(beschrifteFehler('leistungen.7.name', 'Bitte gib einen Namen an.', quellen), 'Bitte gib einen Namen an.', 'unbekannter Eintrag: Text unverändert')
assert.equal(beschrifteFehler('regeln.raster', 'Das Raster …', quellen), 'Das Raster …')
assert.equal(beschrifteFehler('betrieb.adresse', 'Bitte gib die Anschrift an.', quellen), 'Bitte gib die Anschrift an. Das änderst du im Reiter „Einstellungen“.', 'Betriebsangaben stehen woanders')
assert.equal(beschrifteFehler('_', 'Zu umfangreich.', quellen), 'Zu umfangreich.')
// gesucht wird in allen Quellen (der alte Stand kennt Einträge, die der Entwurf nicht mehr hat)
assert.equal(beschrifteFehler('leistungen.s_cccccccc', 'x', [{ leistungen: [] }, { leistungen: [{ id: 's_cccccccc', name: 'Alt' }] }]), 'Leistung „Alt“: x')
})
test('request: Netzwerkfehler auf Deutsch, Fehler der Schnittstelle mit Feldern und Code', async () => {
const vorher = globalThis.fetch
try {
globalThis.fetch = async () => { throw new TypeError('Failed to fetch') }
await assert.rejects(request('/api/termine/buchungen'), (err) => err.message === 'Keine Verbindung. Bitte versuch es gleich noch einmal.' && err.status === 0 && !/fetch/i.test(err.message))
globalThis.fetch = async () => ({ ok: false, status: 400, json: async () => ({ error: 'Bitte einen Namen angeben.', fehler: { name: 'Bitte einen Namen angeben.' }, code: 'x' }) })
await assert.rejects(request('/api/termine/buchungen'), (err) => err.status === 400 && err.fehler.name === 'Bitte einen Namen angeben.' && err.code === 'x')
globalThis.fetch = async () => ({ ok: false, status: 502, json: async () => { throw new Error('kein JSON') } })
await assert.rejects(request('/x'), /Server nicht erreichbar/)
} finally {
globalThis.fetch = vorher
}
})
test('exportUrl: Projekt-ID wird codiert', () => {
assert.equal(exportUrl('p1'), '/api/termine/export.csv?projectId=p1')
assert.equal(exportUrl('a&b=c d'), '/api/termine/export.csv?projectId=a%26b%3Dc%20d')
})
// ---- Einstellungen bearbeiten (Aufgabe 21b): Wochenplan, IDs, Preise, Spiegel der Server-Grenzen
test('Wochenplan: Text je Wochentag ⇔ Plan der Einstellung', () => {
const plan = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] }
assert.deepEqual(WOCHENTAGE_PLAN.map(([nr, name]) => `${nr}${name}`), ['1Montag', '2Dienstag', '3Mittwoch', '4Donnerstag', '5Freitag', '6Samstag', '7Sonntag'])
assert.deepEqual(planTexte(plan), { 1: '09:00–12:30, 13:30–18:00', 2: '', 3: '', 4: '', 5: '', 6: '09:00–13:00', 7: '' })
assert.deepEqual(planTexte(undefined), { 1: '', 2: '', 3: '', 4: '', 5: '', 6: '', 7: '' })
assert.deepEqual(planAusTexten(planTexte(plan)), { plan }, 'Umkehrung')
assert.deepEqual(planAusTexten({ 1: ' ', 2: '8:00-12:00 und 14:00 bis 24:00' }), { plan: { 2: [['08:00', '12:00'], ['14:00', '24:00']] } }, 'leer = geschlossen, Schreibweisen werden vereinheitlicht')
assert.deepEqual(planAusTexten({}), { plan: {} })
assert.deepEqual(planAusTexten(undefined), { plan: {} })
assert.deepEqual(planAusTexten({ 3: '9-12', 5: '09:00–09:00', 1: '09:00–10:00' }), { fehler: { 3: FENSTER_FEHLER, 5: FENSTER_FEHLER } }, 'Fehler je Tag, kein Teilplan')
assert.match(FENSTER_FEHLER, /09:00–12:30, 13:30–18:00/)
})
test('Wochenplan aus dem Formular nimmt der Server an (5-Minuten-Raster, aufsteigend, bis 24:00)', () => {
const e = ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '1', adresse: 'Str. 1' }, JETZT_SERVER)
e.ressourcen[0].plan = planAusTexten({ 1: '07:05–12:00, 13:00–24:00', 7: '10:00–11:00' }).plan
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
assert.deepEqual(erg.einstellung.ressourcen[0].plan, { 1: [['07:05', '12:00'], ['13:00', '24:00']], 7: [['10:00', '11:00']] })
})
test('neueId: Präfix + 8 Zeichen a–z0–9, mit crypto.getRandomValues (Ruling R8)', () => {
for (const p of ['r', 's', 'a']) for (let i = 0; i < 50; i += 1) assert.match(neueId(p), new RegExp(`^${p}_[a-z0-9]{8}$`))
assert.equal(new Set(Array.from({ length: 2000 }, () => neueId('s'))).size, 2000, 'Zufall, keine Wiederholung')
assert.throws(() => neueId('x'), /Präfix/)
// Bytes ab 252 werden verworfen (252 = 7 · 36: sonst wären die ersten Zeichen häufiger); der Rest wird modulo 36 abgebildet
assert.equal(neueId('r', () => Uint8Array.from([255, 252, 253, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])), 'r_abcdefgh')
assert.equal(neueId('s', () => Uint8Array.from([35, 36, 71, 251, 250, 249, 248, 247, 0, 0, 0, 0, 0, 0, 0, 0])), 's_9a998765')
// zu wenige brauchbare Bytes ⇒ es wird nachgezogen
let aufrufe = 0
const nurSpaeterBrauchbar = () => {
aufrufe += 1
return Uint8Array.from({ length: 16 }, (_, i) => (aufrufe === 1 ? 255 : i))
}
assert.equal(neueId('a', nurSpaeterBrauchbar), 'a_abcdefgh')
assert.equal(aufrufe, 2)
})
test('eindeutigeId: nie eine ID, die es in der Liste schon gibt', () => {
const folgen = [Array.from({ length: 16 }, (_, i) => i), Array.from({ length: 16 }, (_, i) => i + 8)]
let k = 0
const bytes = () => Uint8Array.from(folgen[k++])
assert.equal(eindeutigeId('r', ['r_abcdefgh'], bytes), 'r_ijklmnop')
assert.equal(k, 2)
assert.match(eindeutigeId('s', []), /^s_[a-z0-9]{8}$/)
})
test('Preis: Euro-Text ⇔ Cent (deutsche Schreibweise), Anzeige wie beim Server', () => {
const gut = { '45': 4500, '45,00': 4500, '45,5': 4550, '45.50': 4550, '1.234,50': 123450, '1.234': 123400, '1234,5': 123450, ' 19,99 € ': 1999, '0': 0, '0,05': 5, '10000': 1000000, '10000,01': 1000001, '12.5': 1250 }
for (const [text, cent] of Object.entries(gut)) assert.equal(euroZuCent(text), cent, text)
for (const schlecht of ['', ' ', 'abc', ',50', '12,345', '1,2,3', '-5', '1.2.3', '1e3', '99999999999', '4 5', null, undefined, 45]) assert.equal(euroZuCent(schlecht), null, String(schlecht))
assert.equal(centZuEuro(4500), '45,00')
assert.equal(centZuEuro(5), '0,05')
assert.equal(centZuEuro(123450), '1234,50')
assert.equal(centZuEuro(0), '0,00')
assert.equal(centZuEuro(-1), '')
assert.equal(centZuEuro(1.5), '')
assert.equal(centZuEuro(undefined), '')
assert.equal(euroZuCent(centZuEuro(123456)), 123456, 'Hin und zurück')
for (const l of [{ preisArt: 'fest', preisCent: 4500 }, { preisArt: 'ab', preisCent: 4500 }, { preisArt: 'fest', preisCent: 123450 }, { preisArt: 'ab', preisCent: 5 }]) {
assert.equal(preisAnzeige(l), preisText(l), `wie preisText des Servers: ${JSON.stringify(l)}`)
}
assert.equal(preisAnzeige({ preisArt: 'keiner', preisCent: 0 }), 'ohne Preis')
assert.equal(preisAnzeige({ preisArt: 'fest', preisCent: 0 }), 'ohne Preis')
assert.equal(preisAnzeige(undefined), 'ohne Preis')
})
test('ganzzahl: nur ganze Zahlen aus Feldern, sonst null', () => {
assert.equal(ganzzahl('45'), 45)
assert.equal(ganzzahl(' 45 '), 45)
assert.equal(ganzzahl('0'), 0)
assert.equal(ganzzahl('-5'), -5)
for (const schlecht of ['', ' ', '4.5', '4,5', 'abc', '1e3', '9999999999', null, undefined, 5]) assert.equal(ganzzahl(schlecht), null, String(schlecht))
})
test('zusatzSchluessel: Schlüssel aus der Beschriftung, eindeutig, vom Server erlaubt', () => {
assert.equal(zusatzSchluessel('Wunschfarbe', []), 'wunschfarbe')
assert.equal(zusatzSchluessel('Größe in cm', []), 'groesse_in_cm')
assert.equal(zusatzSchluessel(' Haar-Länge (Straße) ', []), 'haar_laenge_strasse')
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge']), 'haarlaenge_2')
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge', 'haarlaenge_2']), 'haarlaenge_3')
assert.equal(zusatzSchluessel('!!!', []), 'feld')
assert.equal(zusatzSchluessel('', ['feld']), 'feld_2')
const lang = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [])
assert.match(lang, /^[a-z0-9_]{1,20}$/)
const langZwei = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [lang])
assert.match(langZwei, /^[a-z0-9_]{1,20}$/)
assert.notEqual(langZwei, lang)
for (const reserviert of ['constructor', 'prototype', '__proto__']) {
const key = zusatzSchluessel(reserviert, [])
assert.match(key, /^[a-z0-9_]{1,20}$/)
assert.ok(!['__proto__', 'constructor', 'prototype'].includes(key), `${reserviert} ⇒ ${key}`)
}
})
test('Auswahlmöglichkeiten: eine je Zeile', () => {
assert.deepEqual(optionenAusText('Rot\n Blau \r\n\nGrün\n'), ['Rot', 'Blau', 'Grün'])
assert.deepEqual(optionenAusText(''), [])
assert.deepEqual(optionenAusText(undefined), [])
assert.equal(optionenText(['Rot', 'Blau']), 'Rot\nBlau')
assert.equal(optionenText(undefined), '')
})
test('verschiebeEintrag: Reihenfolge ändern, ohne die Liste zu verändern', () => {
const liste = ['a', 'b', 'c']
assert.deepEqual(verschiebeEintrag(liste, 1, -1), ['b', 'a', 'c'])
assert.deepEqual(verschiebeEintrag(liste, 0, 1), ['b', 'a', 'c'])
assert.deepEqual(verschiebeEintrag(liste, 2, 1), ['a', 'b', 'c'], 'am Ende nach unten: unverändert')
assert.deepEqual(verschiebeEintrag(liste, 0, -1), ['a', 'b', 'c'], 'am Anfang nach oben: unverändert')
assert.deepEqual(verschiebeEintrag(liste, 7, 1), ['a', 'b', 'c'], 'unbekannte Stelle')
assert.deepEqual(liste, ['a', 'b', 'c'])
assert.notEqual(verschiebeEintrag(liste, 0, -1), liste, 'immer eine Kopie')
})
test('ordneNachIds: gemerkte Reihenfolge (IDs) auf den aktuellen Stand der Liste anwenden', () => {
const liste = [{ id: 'a', n: 1 }, { id: 'b', n: 2 }, { id: 'c', n: 3 }, { id: 'd', n: 4 }]
assert.deepEqual(ordneNachIds(liste, ['c', 'a', 'b', 'd']).map((x) => x.id), ['c', 'a', 'b', 'd'])
assert.deepEqual(ordneNachIds(liste, ['b', 'a']).map((x) => x.id), ['b', 'a', 'c', 'd'], 'nicht genannte Eintraege folgen in alter Reihenfolge')
assert.deepEqual(ordneNachIds(liste, ['x', 'd', 'y', 'a']).map((x) => x.id), ['d', 'a', 'b', 'c'], 'unbekannte IDs werden uebergangen')
assert.deepEqual(ordneNachIds(liste, []).map((x) => x.id), ['a', 'b', 'c', 'd'])
assert.deepEqual(ordneNachIds(liste, undefined).map((x) => x.id), ['a', 'b', 'c', 'd'])
assert.deepEqual(ordneNachIds(undefined, ['a']), [])
assert.equal(ordneNachIds(liste, ['d'])[0], liste[3], 'dieselben Objekte, keine Kopien der Eintraege')
assert.deepEqual(liste.map((x) => x.id), ['a', 'b', 'c', 'd'], 'Liste bleibt unveraendert')
assert.notEqual(ordneNachIds(liste, ['a', 'b', 'c', 'd']), liste, 'immer eine neue Liste')
// Zusammenspiel mit verschiebeEintrag: IDs verschieben, dann auf den Stand anwenden
assert.deepEqual(ordneNachIds(liste, verschiebeEintrag(liste.map((x) => x.id), 3, -1)).map((x) => x.id), ['a', 'b', 'd', 'c'])
})
test('aktivSeitText: Datum nach Berliner Kalender', () => {
assert.equal(aktivSeitText('2026-10-01T09:00:00.000Z'), 'Aktiv seit 01.10.2026')
assert.equal(aktivSeitText('2026-09-30T22:30:00.000Z'), 'Aktiv seit 01.10.2026', 'nach Mitternacht in Berlin')
assert.equal(aktivSeitText('2026-09-30T21:30:00.000Z'), 'Aktiv seit 30.09.2026')
for (const schlecht of ['', 'gestern', null, undefined, 0]) assert.equal(aktivSeitText(schlecht), '', String(schlecht))
})
test('Hinweis zu Gesundheitsangaben: Wortlaut wie bei den Gästen', () => {
assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.')
})
// ---- Spiegel der Auswahl und Grenzen des Servers (einstellungen.js): driftet eine Seite, wird dieser Test rot
const basisEinstellung = () => ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '06301 1', adresse: 'Hauptstr. 1' }, JETZT_SERVER)
/** Fehler des Servers für eine geänderte Einstellung ({} = gültig). */
const serverFehler = (aendere) => {
const e = basisEinstellung()
aendere(e)
return normalisiereEinstellung(e, { now: JETZT_SERVER }).fehler || {}
}
test('Spiegel: Auswahllisten sind die des Servers', () => {
assert.deepEqual(RASTER_MIN, [5, 10, 15, 20, 30, 60])
for (const r of RASTER_MIN) assert.equal(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], undefined, `Raster ${r}`)
for (const r of [0, 7, 45, 90]) assert.ok(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], `Raster ${r} gibt es nicht`)
assert.deepEqual(ERINNERUNG_STUNDEN, [2, 12, 24, 48])
for (const n of ERINNERUNG_STUNDEN) assert.equal(serverFehler((e) => { e.erinnerung.stundenVorher = n })['erinnerung.stundenVorher'], undefined, `${n} Stunden`)
assert.ok(serverFehler((e) => { e.erinnerung.stundenVorher = 3 })['erinnerung.stundenVorher'])
assert.deepEqual(GAST_FELD_WAHL.map(([key]) => key), ['aus', 'optional', 'pflicht'])
for (const [key] of GAST_FELD_WAHL) {
assert.deepEqual(serverFehler((e) => { e.gastFelder.telefon = key; e.gastFelder.nachricht = key }), {}, key)
}
assert.deepEqual(new Set(BUNDESLAENDER.map(([key]) => key)), new Set(SERVER_BUNDESLAENDER), 'alle 16 Bundesländer, keines zu viel')
assert.equal(BUNDESLAENDER.length, 16)
assert.deepEqual(BUNDESLAENDER.map(([, name]) => name), [...BUNDESLAENDER.map(([, name]) => name)].sort((a, b) => a.localeCompare(b, 'de')), 'nach Namen sortiert')
for (const [key] of BUNDESLAENDER) assert.deepEqual(serverFehler((e) => { e.bundesland = key }), {}, key)
assert.deepEqual(ANREDE_WAHL.map(([key]) => key), ['sie', 'du'])
for (const [key] of ANREDE_WAHL) assert.deepEqual(serverFehler((e) => { e.anrede = key }), {}, key)
})
test('Spiegel: Zahlarten und ihre Namen sind die des Servers', () => {
const keys = ZAHLARTEN.map(([key]) => key)
assert.deepEqual(keys, ['bar', 'ec', 'kreditkarte', 'ueberweisung', 'paypal'])
const e = basisEinstellung()
e.zahlung.vorOrtArten = keys
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
assert.equal(erg.fehler, undefined)
assert.deepEqual(erg.einstellung.zahlung.vorOrtArten, keys, 'Reihenfolge wie beim Server')
assert.equal(zahlungText(erg.einstellung), `Bezahlung vor Ort: ${ZAHLARTEN.map(([, name]) => name).join(', ')}`)
assert.ok(serverFehler((x) => { x.zahlung.vorOrtArten = ['bitcoin'] })['zahlung.vorOrtArten'])
})
test('Spiegel: Obergrenzen der Listen sind die des Servers', () => {
assert.deepEqual(LIMITS, { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10 })
const tag = (i) => new Date(Date.UTC(2027, 0, 1 + i)).toISOString().slice(0, 10)
const faelle = {
ressourcen: [LIMITS.ressourcen, 'ressourcen', (e, n) => { e.leistungen = []; e.abwesenheiten = []; e.ressourcen = Array.from({ length: n }, (_, i) => ({ id: neueId('r'), typ: 'person', name: `P${i}`, aktiv: true, plan: {} })) }],
leistungen: [LIMITS.leistungen, 'leistungen', (e, n) => { e.leistungen = Array.from({ length: n }, (_, i) => ({ ...e.leistungen[0], id: neueId('s'), name: `L${i}` })) }],
abwesenheiten: [LIMITS.abwesenheiten, 'abwesenheiten', (e, n) => { e.abwesenheiten = Array.from({ length: n }, (_, i) => ({ id: neueId('a'), ressourceId: null, von: tag(i), bis: tag(i), titel: '' })) }],
sonderoeffnungen: [LIMITS.sonderoeffnungen, 'sonderoeffnungen', (e, n) => { e.sonderoeffnungen = Object.fromEntries(Array.from({ length: n }, (_, i) => [tag(i), []])) }],
zusatzfelder: [LIMITS.zusatzfelder, 'gastFelder.zusatz', (e, n) => { e.gastFelder.zusatz = Array.from({ length: n }, (_, i) => ({ key: `f${i}`, label: `F${i}`, typ: 'text', optionen: [], pflicht: false })) }],
optionen: [LIMITS.optionen, 'gastFelder.zusatz.0.optionen', (e, n) => { e.gastFelder.zusatz = [{ key: 'wahl', label: 'Wahl', typ: 'auswahl', optionen: Array.from({ length: n }, (_, i) => `O${i}`), pflicht: false }] }],
}
for (const [name, [grenze, pfad, baue]] of Object.entries(faelle)) {
assert.equal(serverFehler((e) => baue(e, grenze))[pfad], undefined, `${name}: ${grenze} sind erlaubt`)
assert.ok(serverFehler((e) => baue(e, grenze + 1))[pfad], `${name}: ${grenze + 1} sind zu viele`)
}
})
test('Spiegel: Längen der Eingabefelder (maxlength) kürzt der Server nicht mehr', () => {
const lang = (n) => 'x'.repeat(n)
const lies = (e, was) => was(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung)
const faelle = [
['betriebName', (e, t) => { e.betrieb.name = t }, (x) => x.betrieb.name],
['telefon', (e, t) => { e.betrieb.telefon = t }, (x) => x.betrieb.telefon],
['adresse', (e, t) => { e.betrieb.adresse = t }, (x) => x.betrieb.adresse],
['leistungName', (e, t) => { e.leistungen[0].name = t }, (x) => x.leistungen[0].name],
['beschreibung', (e, t) => { e.leistungen[0].beschreibung = t }, (x) => x.leistungen[0].beschreibung],
['ressourcenName', (e, t) => { e.ressourcen[0].name = t }, (x) => x.ressourcen[0].name],
['abwesenheitTitel', (e, t) => { e.abwesenheiten = [{ id: 'a_abcd1234', ressourceId: null, von: '2027-01-01', bis: '2027-01-02', titel: t }] }, (x) => x.abwesenheiten[0].titel],
['zusatzLabel', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: t, typ: 'text', optionen: [], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].label],
['option', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: 'K', typ: 'auswahl', optionen: [t], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].optionen[0]],
['storno', (e, t) => { e.storno = { text: t } }, (x) => x.storno.text],
]
for (const [name, setze, lese] of faelle) {
const grenze = MAX[name === 'telefon' ? 'telefon' : name]
assert.ok(Number.isInteger(grenze), `MAX.${name}`)
const e = basisEinstellung()
setze(e, lang(grenze))
assert.equal(lies(e, lese).length, grenze, `${name}: ${grenze} Zeichen bleiben ganz`)
const zuLang = basisEinstellung()
setze(zuLang, lang(grenze + 1))
assert.equal(lies(zuLang, lese).length, grenze, `${name}: mehr als ${grenze} würden gekürzt`)
}
assert.equal(MAX.url, 300)
const url = (n) => `https://example.org/${'a'.repeat(n - 'https://example.org/'.length)}`
assert.equal(serverFehler((e) => { e.betrieb.impressumUrl = url(300) })['betrieb.impressumUrl'], undefined)
assert.ok(serverFehler((e) => { e.betrieb.impressumUrl = url(301) })['betrieb.impressumUrl'])
})
test('Mustertext Storno (Gästetext): wie die Vorlagen des Servers, Frist einsetzbar', () => {
assert.equal(stornoMuster(24), VORLAGEN.salon.storno, 'derselbe Wortlaut wie in den Vorlagen')
assert.ok(stornoMuster(48).includes('bis 48 Stunden vorher'))
assert.ok(stornoMuster(48).includes('50 % des Preises'))
for (const unbrauchbar of [0, -1, 1.5, NaN, 'x', null, undefined]) assert.equal(stornoMuster(unbrauchbar), stornoMuster(24), String(unbrauchbar))
const e = basisEinstellung()
e.storno = { text: stornoMuster(36) }
assert.equal(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung.storno.text, stornoMuster(36), 'der Server ändert den Text nicht')
assert.deepEqual(ANREDE_WAHL.map(([, name]) => name), ['Sie (förmlich)', 'Du (persönlich)'])
})