Files
Webklar-Kundenbereich/test/termineView.test.js
T
ClaudeandClaude Opus 5.5 af63b69ec1 Merge origin/main in feature/terminplaner-tp2 (Analyse-Cockpit, Subdomain-Sperre, Projekte)
Konflikte: funktionen.js folgt der Aufteilung aus main (fn-util.js, analyse.js); der Link
'Zu Verträge' der Analyse-Seite kommt jetzt auch in analyse.js aus vertraege-link.js.
Checkout und Team-Ansicht übernehmen assertOwnProject(..., { forNewOrder: true }) und
includeArchived aus main. Test-Pins der absoluten Importe nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:11:58 +00:00

819 lines
59 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, 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, h, request } from '../public/termine-dom.js'
import { installFakeDom } from './support/fakeDom.js'
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
import { baueArchiv } from '../scripts/termine-texte-archiv.mjs'
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 (die Analyse-Oberfläche liegt in analyse.js, mit relativen Importen)
const fn = lies('public/funktionen.js')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"])
assert.doesNotMatch(fn, /from '\/termine/)
// dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b)
assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.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-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('Ansicht nach Zustand (TP2 C10b): termine.js lädt Fragebogen, Statuskarte und die Quelle des Links relativ, kennt keinen Abo-Text und liest die Rückkehr von Stripe einmal', () => {
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const huelle = ohneKommentare(lies('public/termine.js'))
assert.ok(huelle.includes("import { renderFragebogen } from './termine-fragebogen.js'"))
assert.ok(huelle.includes("import { renderBestellen, statusKarte } from './funktion-bestellen.js'"))
assert.ok(huelle.includes("import { ZU_VERTRAEGE } from './vertraege-link.js'"))
assert.doesNotMatch(huelle, /from '\/|import\s*\(/, 'nur relative, statische Importe')
// R111: der Abo-Hinweis kommt vom Server (GET /api/funktionen, termine.hinweis), nie als Text aus dem Browser; R112: die alten Texte sind weg
assert.doesNotMatch(huelle, /Wartungs-Abo|gekündigt|nicht aktiv|nicht geprüft|brauchst du ein laufendes/, 'keine Kopie der Abo-Hinweise')
assert.doesNotMatch(huelle, /noch nicht freigeschaltet|Wir schalten den Terminplaner für dich frei/, 'die Texte der Ansicht vor C10b')
assert.match(huelle, /s\.termine\??\.hinweis/, 'der Text kommt aus der Antwort von /api/funktionen')
assert.match(huelle, /ZU_VERTRAEGE\.view/, 'der Link nimmt Ziel, Text und Klasse aus der gemeinsamen Quelle')
assert.doesNotMatch(huelle, /'vertraege'|"vertraege"|Zu Verträge/, 'und schreibt sie nicht noch einmal hin')
// Rückkehr von Stripe: die Adresse wird an genau einer Stelle gelesen und bereinigt
assert.equal(huelle.match(/history\.replaceState/g).length, 1)
assert.equal(huelle.match(/location\.hash/g).length, 1)
assert.match(huelle, /bestellt'\) === '1'/, 'danke nur mit bestellt=1')
// die Quelle des Links ist reine Daten ohne Import
const link = lies('public/vertraege-link.js')
assert.doesNotMatch(ohneKommentare(link), /\bimport\b/)
assert.match(link, /export const ZU_VERTRAEGE = Object\.freeze\(\{ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' \}\)/)
})
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.deepEqual(ladende, ['termine.js'], `nur die Huelle darf den Fragebogen laden (C10b verdrahtet ihn): ${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)'])
})
// ---------------------------------------------------------------- Hinweistexte nach Recherche (TP2 F11)
const mitFakeDom = (fn) => async () => {
const fx = installFakeDom()
try {
await fn(fx)
} finally {
fx.restore()
}
}
const SIE_WOERTER = /\bSie\b|\bIhr(e[nmrs]?)?\b/
test('freizeit_hinweis_text: Hinweis zur Freizeitleistung nach der Recherche (Du-Form, keine Rechtsberatung, kein „in der Regel keine Freizeitleistung“)', mitFakeDom((fx) => {
const text = einstellungenTab.FREIZEIT_HINWEIS
assert.equal(text, 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. '
+ 'Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. '
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
+ 'Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.')
assert.ok(!text.includes('Weiterbildung'), 'Weiterbildung trägt die Recherche nicht')
assert.ok(!/keine Freizeitleistung/.test(text), 'keine Liste „keine Freizeitleistung“ mehr')
assert.ok(!/Massage[^.;]*keine Freizeit|Freizeit[^.;]*Massage/.test(text), 'Massage steht nicht als Beispiel für „keine Freizeitleistung“')
assert.ok(text.includes('keine Rechtsberatung'))
assert.ok(text.includes('bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht'))
assert.doesNotMatch(text, SIE_WOERTER, 'Portaltexte in der Du-Form')
assert.match(text, /\bdeine\b/)
// der Reiter zeigt genau diesen Text am Haken „Freizeitangebote“
const wurzel = einstellungenTab.rechtliches({ recht: { gaesteSind: 'verbraucher', freizeit: false } }).el
const klein = fx.finde(wurzel, 'label.tm-haken small')
assert.equal(fx.text(klein), text)
}))
test('widerruf_hinweis_wertersatz: nach dem dritten Satz steht der Satz zum Wertersatz (§ 357a Abs. 2 BGB), Rest unverändert', mitFakeDom((fx) => {
const text = einstellungenTab.WIDERRUF_HINWEIS
assert.equal(text, 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
+ 'Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). '
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.')
assert.doesNotMatch(text, SIE_WOERTER)
const wurzel = einstellungenTab.rechtliches({ recht: { gaesteSind: 'verbraucher', freizeit: false } }).el
assert.equal(fx.text(fx.finde(wurzel, '.tm-box-info')), text)
}))
test('storno_abschnitt_text: der Abschnitt „Stornobedingungen“ sagt, dass sie nur für Leistungen mit Preis gelten', mitFakeDom((fx) => {
const e = basisEinstellung()
const frist = h('input', { type: 'number', value: '24' })
const { el } = einstellungenTab.storno({ nurLesen: false }, e, frist)
const einleitung = fx.text(fx.finde(el, '.tm-abschnitt-text'))
assert.ok(einleitung.includes('Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.'), 'bisheriger Text bleibt')
assert.ok(einleitung.endsWith('Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.'))
assert.doesNotMatch(einleitung, SIE_WOERTER)
}))
test('storno_muster_gekennzeichnet: der Hinweis steht wörtlich und in der Du-Form neben dem Stornofeld, nicht in den Gästetexten und nicht im Nachweis', mitFakeDom((fx) => {
const hinweis = einstellungenTab.STORNO_MUSTER_HINWEIS
assert.equal(hinweis, 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.')
assert.doesNotMatch(hinweis, SIE_WOERTER)
const e = basisEinstellung()
const frist = h('input', { type: 'number', value: '24' })
const { el } = einstellungenTab.storno({ nurLesen: false }, e, frist)
const feldEl = fx.finde(el, '.tm-feld')
const textfeld = fx.finde(feldEl, 'textarea')
assert.equal(textfeld.dataset.feld, 'storno.text')
const gekennzeichnet = fx.alle(el, '[data-hinweis="storno-muster"]')
assert.equal(gekennzeichnet.length, 1)
assert.equal(fx.text(gekennzeichnet[0]), hinweis, 'wörtlich, ohne Zusatz')
assert.equal(gekennzeichnet[0].parentNode, feldEl, 'im Block des Stornofelds')
const kinder = [...feldEl.children]
assert.equal(kinder.indexOf(gekennzeichnet[0]), kinder.indexOf(textfeld) + 1, 'unmittelbar beim Textfeld')
// schreibgeschützt (abgeschaltete Funktion) bleibt der Hinweis stehen
assert.equal(fx.alle(einstellungenTab.storno({ nurLesen: true }, e, frist).el, '[data-hinweis="storno-muster"]').length, 1)
// der Hinweis ist ein Portaltext: er steht in keinem Gästetext, in keiner Vorlage und im Widget nicht
for (const gaestetext of [baueArchiv(), lies('server/services/termine/rechtstexte.js'), lies('public/termine-gaestetexte.js'), lies('public/embed/termine.js'), lies('server/services/termine/vorlagen.js')]) {
assert.ok(!gaestetext.includes(hinweis))
}
assert.ok(!stornoMuster(24).includes('ist ein Muster'))
// der Text im Feld und damit der Hash des Nachweises (sha256 von storno.text) bleiben vom Hinweis unberührt
assert.equal(textfeld.value, e.storno.text)
assert.ok(!fx.text(textfeld).includes(hinweis))
}))