- public/termine-fragebogen.js: Schritt 4 Zeiten (je Person/Platz wochenplanFeld, Bundesland ohne Vorauswahl mit leerer erster Option, "An Feiertagen geschlossen" im PATCH, danach PUT mit bundeslandGewaehlt; darunter abwesenheiten und sonderoeffnungen mit eigenem Container und eigenem zeichne, damit ungespeicherte Zeiten bleiben), Schritt 5 Regeln (Sammelknopf Alle sofort buchbar / Alle nur als Anfrage mit buchungsartFuerAlle und buchungsartLage, regeln, storno mit Muster nach Absagefrist, rechtliches, zahlung), Schritt 6 Gastfelder, Schritt 7 mit gemeinsamem leseAbschnitte, Schritt 8 Website (betrieb nur Telefon und vier Links, Einbauwuensche Modus/Knopftext/Stelle/Gestaltung/Bemerkung im PUT, Hinweis auf Mehrwuensche nach Stunden); danach renderBestellen. Platzhalter-Schritt entfernt. - termine.css: Klassen der Schritte 4 bis 8 - Tests: termineView (Importe, PATCH/PUT nur an erlaubten Stellen, Bundesland ohne Vorauswahl im Quelltext), termineFragebogenView (Verhalten mit Fake-DOM fuer die Schritte 4 bis 8, Schluesselmengen, Bestellseite nach Schritt 8) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
710 lines
50 KiB
JavaScript
710 lines
50 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync, readdirSync } from 'node:fs'
|
||
import {
|
||
FILTER, FRAGEBOGEN_SCHRITTE, ABSAGE_GRUENDE, abschaltText, 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'
|
||
import { SCHRITTE, SCHRITT_TITEL } from '../server/services/termine/fragebogen.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)
|
||
test('abschaltText: Text je Abschaltgrund, mit und ohne Löschdatum, Du-Form', () => {
|
||
const alt = 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und '
|
||
const loeschen = ' Ab dem 04.11.2026 löschen wir die Daten.'
|
||
// Abo beendet: Wartungs-Abo im Text, Export bleibt
|
||
assert.deepEqual(abschaltText({ grund: 'abo_ende', loeschungAm: '2026-11-04' }), {
|
||
davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und ',
|
||
danach: `.${loeschen}`,
|
||
mitExport: true,
|
||
})
|
||
assert.deepEqual(abschaltText({ grund: 'abo_ende', loeschungAm: null }), {
|
||
davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und ',
|
||
danach: '.',
|
||
mitExport: true,
|
||
})
|
||
// Abo endete vor der Einrichtung: nichts zu exportieren, also kein Export-Satz
|
||
const vor = abschaltText({ grund: 'abo_ende_vor_einrichtung', loeschungAm: '2026-11-04' })
|
||
assert.equal(vor.mitExport, false)
|
||
assert.equal(vor.davor + vor.danach, `Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.${loeschen}`)
|
||
const vorOhne = abschaltText({ grund: 'abo_ende_vor_einrichtung', loeschungAm: null })
|
||
assert.equal(vorOhne.mitExport, false)
|
||
assert.equal(vorOhne.davor + vorOhne.danach, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.')
|
||
// Team, gelöschtes Konto, kein Grund (Altbestand), unbekannter Grund, fehlende Angabe: der bisherige Satz, mit Datum ergänzt
|
||
for (const grund of ['team', 'kunde_geloescht', null, undefined, 'frist', 5]) {
|
||
assert.deepEqual(abschaltText({ grund, loeschungAm: '2026-11-04' }), { davor: alt, danach: `.${loeschen}`, mitExport: true }, String(grund))
|
||
assert.deepEqual(abschaltText({ grund, loeschungAm: null }), { davor: alt, danach: '.', mitExport: true }, String(grund))
|
||
}
|
||
assert.deepEqual(abschaltText(), { davor: alt, danach: '.', mitExport: true })
|
||
assert.deepEqual(abschaltText(null), { davor: alt, danach: '.', mitExport: true })
|
||
// mitExport ist nur bei vor_einrichtung falsch
|
||
for (const grund of ['abo_ende', 'team', 'kunde_geloescht', null]) assert.equal(abschaltText({ grund }).mitExport, true, String(grund))
|
||
})
|
||
|
||
test('abschaltText: ein Datum, das kein Berliner Kalendertag ist, wird nicht angezeigt; nie Sie/Ihr', () => {
|
||
for (const kaputt of ['04.11.2026', '2026-13-45', '', 'morgen', 20261104, {}]) {
|
||
const t = abschaltText({ grund: 'abo_ende', loeschungAm: kaputt })
|
||
assert.ok(!/löschen/.test(t.davor + t.danach), `kein Datum für ${JSON.stringify(kaputt)}`)
|
||
}
|
||
for (const grund of ['abo_ende', 'abo_ende_vor_einrichtung', 'team', 'kunde_geloescht', null]) {
|
||
const t = abschaltText({ grund, loeschungAm: '2026-11-04' })
|
||
assert.doesNotMatch(t.davor + t.danach, /\bSie\b|\bIhr(e[nmrs]?)?\b/, String(grund))
|
||
}
|
||
})
|
||
|
||
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||
|
||
test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminplaner-Module', () => {
|
||
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 keine Terminplaner-Module (die Kachel steht inline); seine absoluten Importe biegt
|
||
// test/funktionenTiles.test.js um – genau diese beiden (/geraete.js kam mit der Heatmap im Gerätrahmen auf main)
|
||
const fn = lies('public/funktionen.js')
|
||
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
|
||
assert.doesNotMatch(fn, /from '\/termine/)
|
||
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-fragebogen.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'])
|
||
// Den Fragebogen speichert genau eine Datei (termine-fragebogen.js, TP2): PUT /fragebogen gibt es nirgends sonst
|
||
assert.deepEqual(quellen.filter(([, quelle]) => /'PUT'/.test(quelle)).map(([datei]) => datei), ['termine-fragebogen.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|fragebogen)\.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', 'termine-fragebogen.js']) {
|
||
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
|
||
}
|
||
})
|
||
|
||
test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => {
|
||
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f))
|
||
const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`)))
|
||
assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`)
|
||
for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`)
|
||
assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/)
|
||
})
|
||
|
||
test('Fragebogen (TP2 C9b): Schritte 4 bis 8 nutzen die Bausteine der Reiter, PATCH und PUT nur an den erlaubten Stellen, Bundesland ohne Vorauswahl', () => {
|
||
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||
const quelle = ohneKommentare(lies('public/termine-fragebogen.js'))
|
||
const importAus = (datei) => [...quelle.matchAll(new RegExp(`import \\{([^}]*)\\} from '\\./${datei.replace('.', '\\.')}'`, 'g'))].flatMap((m) => m[1].split(',').map((x) => x.trim().split(/\s+as\s+/)[0]))
|
||
// Ausnahmen (Urlaub, Sonderöffnungen) und Wochenplan kommen aus den Reiter-Modulen, nicht aus einer zweiten Fassung im Fragebogen
|
||
assert.deepEqual(importAus('termine-team-ausnahmen.js').sort(), ['abwesenheiten', 'sonderoeffnungen'])
|
||
assert.deepEqual(importAus('termine-tab-team.js'), ['wochenplanFeld'])
|
||
for (const name of ['betrieb', 'darstellung', 'erinnerung', 'gastFelder', 'regeln', 'rechtliches', 'storno', 'zahlung']) {
|
||
assert.ok(importAus('termine-tab-einstellungen.js').includes(name), `${name} kommt aus termine-tab-einstellungen.js`)
|
||
}
|
||
assert.ok(importAus('termine-view.js').includes('buchungsartFuerAlle') && importAus('termine-view.js').includes('buchungsartLage'), 'der Sammelknopf nutzt die Helfer aus termine-view.js')
|
||
assert.ok(importAus('funktion-bestellen.js').includes('renderBestellen'))
|
||
assert.doesNotMatch(quelle, /\bfeiertage\b/, 'Bundesland und Feiertage stehen im Formular des Schritts, nicht im eigenen Speichern des Reiters')
|
||
// PATCH gibt es nur in termine-speichern.js, PUT nur in termine-fragebogen.js – und dort an genau einer Stelle
|
||
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^(termine.*|funktion-bestellen)\.js$/.test(f))
|
||
assert.deepEqual(dateien.filter((f) => /'PATCH'/.test(ohneKommentare(lies(`public/${f}`)))), ['termine-speichern.js'])
|
||
assert.deepEqual(dateien.filter((f) => /'PUT'/.test(ohneKommentare(lies(`public/${f}`)))), ['termine-fragebogen.js'])
|
||
assert.equal(quelle.match(/'PUT'/g).length, 1)
|
||
assert.doesNotMatch(quelle, /'PATCH'|\bfetch\(/)
|
||
// Bundesland: leere erste Option, danach die Länder; gewählt ist nur, was der Kunde schon ausdrücklich gewählt hatte (nicht der Serverstandard im Entwurf)
|
||
const wahl = quelle.slice(quelle.indexOf('BUNDESLAENDER.map') - 200, quelle.indexOf('BUNDESLAENDER.map') + 200)
|
||
assert.match(wahl, /h\('option', \{ value: '' \}[^)]*\),\s*BUNDESLAENDER\.map/, 'die erste Option ist leer und kommt vor den Ländern')
|
||
assert.doesNotMatch(quelle, /selected:\s*(true|e\.bundesland|einstellung\.bundesland)/, 'keine Vorauswahl aus dem Entwurf')
|
||
assert.match(quelle, /bundeslandGewaehlt/)
|
||
})
|
||
|
||
test('Reiter-Exporte für den Fragebogen (TP2 C7b): acht Abschnitte, wochenplanFeld, baueCtx, fehlt aus der Antwort', async () => {
|
||
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||
// Einstellungen: die acht Abschnitte sind Exporte; der Reiter baut sie weiter selbst (betrieb ohne nur = alle neun Felder, Verhalten in termineFragebogenView.test.js)
|
||
const einstellungen = await import('../public/termine-tab-einstellungen.js')
|
||
for (const name of ['betrieb', 'darstellung', 'regeln', 'gastFelder', 'erinnerung', 'storno', 'rechtliches', 'zahlung']) {
|
||
assert.equal(typeof einstellungen[name], 'function', `${name} ist exportiert`)
|
||
assert.match(lies('public/termine-tab-einstellungen.js'), new RegExp(`export function ${name}\\(`), name)
|
||
}
|
||
assert.match(lies('public/termine-tab-einstellungen.js'), /const abschnitte = \[betrieb\(ctx, e\), darstellung\(e\), regelnAbschnitt, gastFelder\(ctx, e\), erinnerung\(e\), storno\(ctx, e, regelnAbschnitt\.absage\), rechtliches\(e\), zahlung\(e\), aufbewahrung\(e\)\]/, 'der Reiter benutzt sie selbst')
|
||
// Team: wochenplanFeld ist ein Export, den der Editor einer Person selbst benutzt (die Tagesfelder gibt es nur dort)
|
||
const team = ohneKommentare(lies('public/termine-tab-team.js'))
|
||
assert.match(team, /export function wochenplanFeld\(r, pfad\)/)
|
||
assert.match(team, /= wochenplanFeld\(/, 'formular benutzt wochenplanFeld')
|
||
assert.equal(team.match(/WOCHENTAGE_PLAN\.map/g).length, 1, 'die Tagesfelder werden nur in wochenplanFeld gebaut')
|
||
assert.equal(team.match(/planAusTexten\(/g).length, 1, 'der Wochenplan wird nur in wochenplanFeld gelesen')
|
||
// Speichern: fehlt kommt aus der Antwort des Servers
|
||
const speichern = ohneKommentare(lies('public/termine-speichern.js'))
|
||
assert.match(speichern, /fehlt: antwort\.fehlt \?\? \[\]/)
|
||
assert.doesNotMatch(speichern, /fehlt: \[\]/)
|
||
// Huelle: der Kontext der Reiter wird in baueCtx gebaut (exportiert, auch der Fragebogen braucht ihn), zeigeReiter benutzt es
|
||
const huelle = ohneKommentare(lies('public/termine.js'))
|
||
assert.match(huelle, /export function baueCtx\(/)
|
||
assert.match(huelle, /const ctx = baueCtx\(/)
|
||
assert.equal(huelle.match(/api: \{/g).length, 1, 'die Schnittstelle des Kontexts steht nur in baueCtx')
|
||
})
|
||
|
||
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: Schritte des Fragebogens (Schlüssel, Reihenfolge, Titel) sind die des Servers', () => {
|
||
assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([key]) => key), [...SCHRITTE])
|
||
assert.deepEqual(Object.fromEntries(FRAGEBOGEN_SCHRITTE), Object.fromEntries(SCHRITTE.map((key) => [key, SCHRITT_TITEL[key]])), 'Titel wie in „Bitte bestätige den Schritt …“')
|
||
})
|
||
|
||
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)'])
|
||
})
|