Files
Webklar-Kundenbereich/test/termineFragebogenView.test.js
T
ClaudeandClaude Sonnet 5.5 747be432c5 Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen
- termine-tab-einstellungen.js: die acht Abschnitte (betrieb, darstellung, regeln,
  gastFelder, erinnerung, storno, rechtliches, zahlung) sind Exporte; betrieb nimmt
  { nur, titel, hinweis } (nur die gewaehlten Felder, lese schreibt nur diese)
- termine-tab-team.js: wochenplanFeld(r, pfad) => { el, lese() => { plan } | { fehler } },
  der Editor einer Person benutzt es selbst
- termine-speichern.js: fehlt kommt aus der Antwort des Servers
- termine.js: baueCtx aus zeigeReiter herausgeloest (Verhalten gleich)
- test/support/fakeDom.js: Minimal-DOM und Fake-fetch fuer die Client-Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:06:42 +00:00

840 lines
51 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 } from 'node:fs'
import * as ansicht from '../public/termine-view.js'
import {
FRAGEBOGEN_SCHRITTE, LIMITS, MAX, buchungsartFuerAlle, buchungsartLage, ersterOffenerSchritt, ressourcenAusNamen, schrittStatus, zusammenfassung,
} from '../public/termine-view.js'
import { BRANCHEN, SCHRITTE, SCHRITT_TITEL, leerFragebogen } from '../server/services/termine/fragebogen.js'
import { normalisiereEinstellung } from '../server/services/termine/einstellungen.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
import { installFakeDom } from './support/fakeDom.js'
// Namensraeume statt benannter Importe: fehlt ein Export, scheitert nur der Test, der ihn braucht (nicht die ganze Datei)
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
import * as teamTab from '../public/termine-tab-team.js'
import * as huelle from '../public/termine.js'
import { FENSTER_FEHLER } from '../public/termine-view.js'
import { h, request } from '../public/termine-dom.js'
import { neuerSpeicher } from '../public/termine-speichern.js'
// Reine Helfer des Fragebogens in public/termine-view.js (TP2, Task C7a): Schrittstand, Namen der Personen/Plaetze, sofort/Anfrage fuer alle
// und die Zusammenfassung auf der Bestellseite. Kein DOM; der Spiegel zum Server (SCHRITTE) steht in test/termineView.test.js.
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const BETRIEB = {
name: 'Salon Muster',
email: 'salon@example.invalid',
telefon: '06301 12345',
adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
impressumUrl: 'https://salon.example.invalid/impressum',
datenschutzUrl: 'https://salon.example.invalid/datenschutz',
bundesland: 'RP',
}
const salon = () => ausVorlage('salon', BETRIEB, JETZT)
/** Zaehlende neueId: r_test0001, r_test0002 … (bekommt die belegten IDs, braucht sie hier nicht). */
const zaehler = () => {
let n = 0
return () => `r_test${String(++n).padStart(4, '0')}`
}
/** Zwei Ressourcen mit verschiedenen Plaenen, wie sie ein Entwurf haben kann. */
const zweiRessourcen = () => [
{ id: 'r_anna0001', typ: 'person', name: 'Anna', aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: 'a'.repeat(24) },
{ id: 'r_ben00002', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['13:00', '17:00']] }, feedToken: 'b'.repeat(24) },
]
const namenVon = (ressourcen) => ressourcen.map((r) => r.name)
const idsVon = (ressourcen) => ressourcen.map((r) => r.id)
// ---------------------------------------------------------------- Schritte
test('FRAGEBOGEN_SCHRITTE: acht Paare [Schlüssel, Titel], Schlüssel und Titel wie beim Server', () => {
assert.equal(FRAGEBOGEN_SCHRITTE.length, 8)
for (const eintrag of FRAGEBOGEN_SCHRITTE) {
assert.equal(eintrag.length, 2)
assert.equal(typeof eintrag[0], 'string')
assert.equal(typeof eintrag[1], 'string')
assert.ok(eintrag[1].length > 0)
}
assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([key]) => key), [...SCHRITTE])
assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([, titel]) => titel), SCHRITTE.map((key) => SCHRITT_TITEL[key]))
})
test('schrittStatus: leerer Fragebogen ⇒ nichts erledigt, Reihenfolge und Titel wie FRAGEBOGEN_SCHRITTE', () => {
const erwartet = FRAGEBOGEN_SCHRITTE.map(([key, titel]) => ({ key, titel, erledigt: false }))
assert.deepEqual(schrittStatus(leerFragebogen()), erwartet)
assert.deepEqual(schrittStatus(null), erwartet, 'noch kein Fragebogen gespeichert')
assert.deepEqual(schrittStatus(undefined), erwartet)
assert.deepEqual(schrittStatus({ bestaetigt: 'wer' }), erwartet, 'kein Array ⇒ nichts erledigt')
assert.deepEqual(schrittStatus({}), erwartet)
})
test('schrittStatus: teilweise bestätigt, Fremdes zählt nicht', () => {
const status = schrittStatus({ bestaetigt: ['zeiten', 'branche', 'wer', 'bestellen', 'unbekannt'] })
assert.deepEqual(status.map((s) => s.key), [...SCHRITTE])
assert.deepEqual(status.filter((s) => s.erledigt).map((s) => s.key), ['branche', 'wer', 'zeiten'])
assert.equal(status.length, 8, '„bestellen“ ist kein Schritt des Fragebogens')
})
test('schrittStatus: vollständig bestätigt ⇒ alles erledigt', () => {
assert.ok(schrittStatus({ bestaetigt: [...SCHRITTE] }).every((s) => s.erledigt === true))
})
test('ersterOffenerSchritt: leer ⇒ branche, teilweise ⇒ der erste Schritt ohne Haken, vollständig ⇒ bestellen', () => {
assert.equal(ersterOffenerSchritt(leerFragebogen()), 'branche')
assert.equal(ersterOffenerSchritt(null), 'branche')
assert.equal(ersterOffenerSchritt(undefined), 'branche')
assert.equal(ersterOffenerSchritt({ bestaetigt: ['branche', 'wer'] }), 'leistungen')
assert.equal(ersterOffenerSchritt({ bestaetigt: ['branche', 'leistungen'] }), 'wer', 'die erste Lücke zählt, nicht die Zahl der Haken')
assert.equal(ersterOffenerSchritt({ bestaetigt: SCHRITTE.slice(0, 7) }), 'website')
assert.equal(ersterOffenerSchritt({ bestaetigt: [...SCHRITTE] }), 'bestellen')
})
// ---------------------------------------------------------------- ressourcenAusNamen
test('ressourcenAusNamen: „Stylist/in“ → „Anna“ behält ID und Plan, der zweite Name bekommt neue ID und den Plan von Anna', () => {
const bisher = salon().ressourcen
assert.equal(bisher.length, 1)
assert.equal(bisher[0].name, 'Stylist/in')
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben'], neueId: zaehler() })
assert.deepEqual(namenVon(erg.ressourcen), ['Anna', 'Ben'])
const [anna, ben] = erg.ressourcen
assert.equal(anna.id, bisher[0].id, 'Anna behält die ID')
assert.deepEqual(anna.plan, bisher[0].plan, 'und den Plan')
assert.equal(ben.id, 'r_test0001')
assert.deepEqual(ben.plan, anna.plan, 'Ben bekommt den Plan von Anna …')
assert.notEqual(ben.plan, anna.plan, '… als Kopie')
const laenge = anna.plan[2].length
ben.plan[2].push(['20:00', '21:00'])
assert.equal(anna.plan[2].length, laenge, 'Änderungen an Ben verändern Anna nicht')
assert.equal(bisher[0].plan[2].length, laenge, 'und nicht den alten Eintrag')
assert.deepEqual(erg.neu, ['r_test0001'])
assert.deepEqual(erg.entfernt, [])
assert.ok(erg.ressourcen.every((r) => r.aktiv === true && r.typ === 'person'))
})
test('ressourcenAusNamen: gleiche Namen behalten ID und Plan, auch in anderer Reihenfolge und Schreibweise', () => {
const bisher = zweiRessourcen()
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['ben', 'Anna'], neueId: zaehler() })
assert.deepEqual(idsVon(erg.ressourcen), ['r_ben00002', 'r_anna0001'], 'Reihenfolge der Namen, IDs bleiben')
assert.deepEqual(namenVon(erg.ressourcen), ['ben', 'Anna'], 'die eingegebene Schreibweise gilt')
assert.deepEqual(erg.ressourcen[0].plan, bisher[1].plan)
assert.deepEqual(erg.ressourcen[1].plan, bisher[0].plan)
assert.deepEqual(erg.neu, [])
assert.deepEqual(erg.entfernt, [])
})
test('ressourcenAusNamen: Entfernen liefert die IDs der weggefallenen Einträge', () => {
const bisher = [...zweiRessourcen(), { id: 'r_cem00003', typ: 'person', name: 'Cem', aktiv: true, plan: {} }]
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben'], neueId: zaehler() })
assert.deepEqual(idsVon(erg.ressourcen), ['r_ben00002'])
assert.deepEqual(erg.entfernt, ['r_anna0001', 'r_cem00003'])
assert.deepEqual(erg.neu, [])
const alle = ressourcenAusNamen({ bisher, typ: 'person', namen: [], neueId: zaehler() })
assert.deepEqual(alle.ressourcen, [])
assert.deepEqual(alle.entfernt, ['r_anna0001', 'r_ben00002', 'r_cem00003'])
})
test('ressourcenAusNamen: übrige alte Einträge werden der Reihe nach umbenannt', () => {
const bisher = zweiRessourcen()
const umbenannt = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Xaver', 'Yvonne'], neueId: zaehler() })
assert.deepEqual(idsVon(umbenannt.ressourcen), ['r_anna0001', 'r_ben00002'])
assert.deepEqual(umbenannt.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[1].plan])
assert.deepEqual([umbenannt.neu, umbenannt.entfernt], [[], []])
// Ben bleibt, Dora übernimmt Annas Platz in der Liste, Cem fällt weg
const mitCem = [...bisher, { id: 'r_cem00003', typ: 'person', name: 'Cem', aktiv: true, plan: { 3: [['08:00', '09:00']] } }]
const gemischt = ressourcenAusNamen({ bisher: mitCem, typ: 'person', namen: ['Ben', 'Dora'], neueId: zaehler() })
assert.deepEqual(namenVon(gemischt.ressourcen), ['Ben', 'Dora'])
assert.deepEqual(idsVon(gemischt.ressourcen), ['r_ben00002', 'r_anna0001'])
assert.deepEqual(gemischt.ressourcen[1].plan, bisher[0].plan)
assert.deepEqual(gemischt.entfernt, ['r_cem00003'])
assert.deepEqual(gemischt.neu, [])
})
test('ressourcenAusNamen: ein neuer Name vor einem behaltenen bekommt trotzdem dessen Plan (Vorlage unabhängig von der Reihenfolge)', () => {
const bisher = [{ id: 'r_anna0001', typ: 'person', name: 'Anna', aktiv: true, plan: { 1: [['09:00', '12:00']] } }]
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Anna'], neueId: zaehler() })
assert.deepEqual(namenVon(erg.ressourcen), ['Ben', 'Anna'])
const [ben, anna] = erg.ressourcen
assert.equal(anna.id, 'r_anna0001', 'Anna behält ihre ID')
assert.deepEqual(anna.plan, { 1: [['09:00', '12:00']] }, 'und ihren Plan')
assert.equal(ben.id, 'r_test0001')
assert.deepEqual(ben.plan, { 1: [['09:00', '12:00']] }, 'Ben bekommt Annas Plan, nicht einen leeren')
assert.notEqual(ben.plan, anna.plan, 'als Kopie')
ben.plan[1].push(['14:00', '16:00'])
assert.equal(anna.plan[1].length, 1, 'Änderungen an Ben verändern Anna nicht')
assert.equal(bisher[0].plan[1].length, 1, 'und nicht den alten Eintrag')
assert.deepEqual(erg.neu, ['r_test0001'])
assert.deepEqual(erg.entfernt, [])
// mehrere neue Namen vor dem behaltenen, und ein umbenannter alter Eintrag liefert die Vorlage ebenso
const drei = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Cem', 'Anna'], neueId: zaehler() })
assert.deepEqual(drei.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[0].plan, bisher[0].plan])
const umbenannt = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Cem'], neueId: zaehler() })
assert.deepEqual(idsVon(umbenannt.ressourcen), ['r_anna0001', 'r_test0001'], 'Anna wird zu Ben, Cem ist neu')
assert.deepEqual(umbenannt.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[0].plan])
// ohne bisherigen Eintrag gibt es keine Vorlage: leerer Plan
assert.deepEqual(ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['Ben', 'Cem'], neueId: zaehler() }).ressourcen.map((r) => r.plan), [{}, {}])
})
test('ressourcenAusNamen: trimmt, entfernt Leere und Doppelte ohne Rücksicht auf Groß-/Kleinschreibung', () => {
const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen: [' Anna ', '', ' ', 'anna', 'ANNA', 'Ben', ' Ben', null, undefined, 5], neueId: zaehler() })
assert.deepEqual(namenVon(erg.ressourcen), ['Anna', 'Ben'], 'die erste Schreibweise gilt')
assert.equal(idsVon(erg.ressourcen).length, 2)
assert.deepEqual(namenVon(ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['Anna Müller', 'anna müller'] }).ressourcen), ['Anna Müller'], 'mehrfache Leerzeichen zählen als eines (wie beim Server)')
})
test('ressourcenAusNamen: höchstens LIMITS.ressourcen Einträge', () => {
const namen = Array.from({ length: LIMITS.ressourcen + 5 }, (_, i) => `Person ${i + 1}`)
const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen, neueId: zaehler() })
assert.equal(LIMITS.ressourcen, 20)
assert.equal(erg.ressourcen.length, 20)
assert.deepEqual(namenVon(erg.ressourcen), namen.slice(0, 20), 'die ersten 20 bleiben')
assert.equal(erg.neu.length, 20)
// Leere und Doppelte zählen nicht mit
const mitLuecken = ['', ...namen.slice(0, 20).flatMap((n) => [n, n.toUpperCase()]), 'Überzählig']
assert.deepEqual(namenVon(ressourcenAusNamen({ bisher: [], typ: 'person', namen: mitLuecken }).ressourcen), namen.slice(0, 20))
// genau LIMITS.ressourcen bleibt ganz
assert.equal(ressourcenAusNamen({ bisher: [], typ: 'person', namen: namen.slice(0, 20) }).ressourcen.length, 20)
})
test('ressourcenAusNamen: Namen werden auf MAX.ressourcenName Zeichen gekürzt', () => {
const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['x'.repeat(MAX.ressourcenName + 20)] })
assert.equal([...erg.ressourcen[0].name].length, MAX.ressourcenName)
})
test('ressourcenAusNamen: typ gilt für alle, auch für behaltene Einträge', () => {
const bisher = zweiRessourcen()
const erg = ressourcenAusNamen({ bisher, typ: 'platz', namen: ['Anna', 'Platz 2', 'Platz 3'], neueId: zaehler() })
assert.ok(erg.ressourcen.every((r) => r.typ === 'platz'))
assert.equal(erg.ressourcen[0].id, 'r_anna0001')
assert.deepEqual(bisher.map((r) => r.typ), ['person', 'person'], 'bisher bleibt unverändert')
assert.ok(ressourcenAusNamen({ bisher, typ: 'person', namen: ['A'] }).ressourcen.every((r) => r.typ === 'person'))
assert.equal(ressourcenAusNamen({ bisher: [], typ: 'unsinn', namen: ['A'] }).ressourcen[0].typ, 'person', 'unbekannter Typ ⇒ Person')
})
test('ressourcenAusNamen: ändert die Eingaben nicht, liefert nur Felder, die der Server kennt (kein feedToken)', () => {
const bisher = zweiRessourcen()
const vorher = JSON.stringify(bisher)
const namen = ['Ben', ' Anna ', 'Cem']
const namenVorher = [...namen]
const erg = ressourcenAusNamen({ bisher, typ: 'platz', namen, neueId: zaehler() })
assert.equal(JSON.stringify(bisher), vorher)
assert.deepEqual(namen, namenVorher)
for (const r of erg.ressourcen) assert.deepEqual(Object.keys(r).sort(), ['aktiv', 'id', 'name', 'plan', 'typ'])
assert.ok(!JSON.stringify(erg).includes('feedToken'))
assert.deepEqual(ressourcenAusNamen({ bisher: null, typ: 'person', namen: ['A'], neueId: zaehler() }).entfernt, [], 'ohne bisher: nichts zu entfernen')
assert.deepEqual(ressourcenAusNamen({ bisher, typ: 'person', namen: 'Anna', neueId: zaehler() }).ressourcen, [], 'keine Liste ⇒ keine Namen')
})
test('ressourcenAusNamen: neue IDs sind gültig und kollidieren nicht (Vorgabe ohne neueId, Zähler ohne Treffer)', () => {
const bisher = zweiRessourcen()
const namen = Array.from({ length: 15 }, (_, i) => `Neu ${i}`)
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen })
const ids = idsVon(erg.ressourcen)
assert.equal(new Set(ids).size, ids.length)
assert.ok(ids.every((id) => /^r_[a-z0-9]{8}$/.test(id)))
assert.equal(erg.neu.length, 13, 'zwei der Namen übernehmen die alten Einträge')
assert.ok(erg.neu.every((id) => ids.includes(id) && !bisher.some((r) => r.id === id)))
// neueId bekommt die belegten IDs (Set) und darf nur unbelegte liefern
const gesehen = []
ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben', 'Cem', 'Dora'], neueId: (belegt) => { gesehen.push([...belegt]); return `r_zzzz000${gesehen.length}` } })
assert.equal(gesehen.length, 2)
assert.ok(gesehen[0].includes('r_anna0001') && gesehen[0].includes('r_ben00002'), 'behaltene IDs sind belegt')
assert.ok(gesehen[1].includes('r_zzzz0001'), 'und die schon vergebenen neuen')
// liefert neueId eine belegte ID, ist das ein Fehler (der Server würde doppelte IDs ablehnen)
assert.throws(() => ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben', 'Cem'], neueId: () => 'r_anna0001' }), /ID/)
})
test('ressourcenAusNamen: das Ergebnis besteht normalisiereEinstellung (Leistungen allen zugeordnet), für Personen und Plätze', () => {
for (const [typ, namen] of [['person', ['Anna', 'Ben', 'Cem']], ['platz', ['Platz 1', 'Platz 2']], ['person', ['Nur Ich']]]) {
const e = salon()
const erg = ressourcenAusNamen({ bisher: e.ressourcen, typ, namen })
e.ressourcen = erg.ressourcen
e.leistungen = e.leistungen.map((l) => ({ ...l, ressourcen: idsVon(erg.ressourcen) }))
const geprueft = normalisiereEinstellung(e, { now: JETZT })
assert.equal(geprueft.fehler, undefined, `${typ}: ${JSON.stringify(geprueft.fehler)}`)
assert.deepEqual(namenVon(geprueft.einstellung.ressourcen), namen)
assert.ok(geprueft.einstellung.ressourcen.every((r) => r.typ === typ && r.aktiv === true))
assert.ok(geprueft.einstellung.ressourcen.every((r) => typeof r.feedToken === 'string' && r.feedToken.length > 0), 'der Server vergibt die Feed-Token')
}
})
// ---------------------------------------------------------------- Buchungsart
test('buchungsartFuerAlle: ändert nur buchungsart, an einer Kopie', () => {
const leistungen = salon().leistungen
const vorher = JSON.stringify(leistungen)
const anfrage = buchungsartFuerAlle(leistungen, 'anfrage')
assert.equal(JSON.stringify(leistungen), vorher, 'Eingabe unverändert')
assert.equal(anfrage.length, leistungen.length)
assert.ok(anfrage.every((l) => l.buchungsart === 'anfrage'))
assert.deepEqual(anfrage.map(({ buchungsart, ...rest }) => rest), leistungen.map(({ buchungsart, ...rest }) => rest), 'alles andere bleibt')
assert.notEqual(anfrage[0], leistungen[0])
const zurueck = buchungsartFuerAlle(anfrage, 'sofort')
assert.deepEqual(zurueck, leistungen)
})
test('buchungsartFuerAlle: inaktive Leistungen auch, unbekannte Art und Nicht-Liste ändern nichts', () => {
const leistungen = [{ id: 's_aaaaaaaa', name: 'A', aktiv: false, buchungsart: 'sofort' }, { id: 's_bbbbbbbb', name: 'B', aktiv: true, buchungsart: 'anfrage' }]
assert.deepEqual(buchungsartFuerAlle(leistungen, 'anfrage').map((l) => l.buchungsart), ['anfrage', 'anfrage'])
assert.deepEqual(buchungsartFuerAlle(leistungen, 'bar'), leistungen)
assert.deepEqual(buchungsartFuerAlle(leistungen, undefined), leistungen)
assert.deepEqual(buchungsartFuerAlle(null, 'sofort'), [])
assert.deepEqual(buchungsartFuerAlle(undefined, 'sofort'), [])
})
test('buchungsartLage: sofort, anfrage oder gemischt', () => {
const l = (...arten) => arten.map((buchungsart, i) => ({ id: `s_${i}`, buchungsart }))
assert.equal(buchungsartLage(l('sofort', 'sofort')), 'sofort')
assert.equal(buchungsartLage(l('anfrage', 'anfrage', 'anfrage')), 'anfrage')
assert.equal(buchungsartLage(l('sofort', 'anfrage')), 'gemischt')
assert.equal(buchungsartLage(l('anfrage')), 'anfrage')
assert.equal(buchungsartLage([]), 'sofort', 'ohne Leistungen gilt die Vorgabe des Servers')
assert.equal(buchungsartLage(null), 'sofort')
assert.equal(buchungsartLage([{ name: 'ohne Angabe' }, { buchungsart: 'anfrage' }]), 'gemischt', 'fehlende Angabe = sofort (Vorgabe des Servers)')
assert.equal(buchungsartLage(ausVorlage('handwerk', BETRIEB, JETZT).leistungen), 'anfrage', 'Vorlage mit Anfragen')
assert.equal(buchungsartLage(salon().leistungen), 'sofort')
})
test('buchungsartLage nach buchungsartFuerAlle: zeigt genau die gesetzte Art', () => {
const leistungen = [{ buchungsart: 'sofort' }, { buchungsart: 'anfrage' }]
assert.equal(buchungsartLage(leistungen), 'gemischt')
assert.equal(buchungsartLage(buchungsartFuerAlle(leistungen, 'anfrage')), 'anfrage')
assert.equal(buchungsartLage(buchungsartFuerAlle(leistungen, 'sofort')), 'sofort')
})
// ---------------------------------------------------------------- zusammenfassung
const stand = (aendere = () => {}) => {
const einstellung = salon()
const fragebogen = { ...leerFragebogen(), branche: 'salon', bestaetigt: [...SCHRITTE], bundeslandGewaehlt: true, preiseGeprueft: true, einbau: { ...leerFragebogen().einbau, modus: 'inline' } }
aendere(einstellung, fragebogen)
return { einstellung, fragebogen }
}
const gruppe = (ergebnis, schritt) => ergebnis.find((g) => g.schritt === schritt)
test('zusammenfassung: acht Gruppen in der Reihenfolge der Schritte, nur Text', () => {
const { einstellung, fragebogen } = stand()
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
assert.deepEqual(erg.map((g) => g.schritt), [...SCHRITTE])
assert.deepEqual(erg.map((g) => g.titel), FRAGEBOGEN_SCHRITTE.map(([, titel]) => titel))
for (const g of erg) {
assert.deepEqual(Object.keys(g).sort(), ['schritt', 'titel', 'zeilen'])
assert.ok(Array.isArray(g.zeilen) && g.zeilen.length > 0, `${g.schritt}: Zeilen`)
assert.ok(g.zeilen.every((z) => typeof z === 'string' && z.trim() !== ''), `${g.schritt}: nur nicht-leere Texte`)
}
})
test('zusammenfassung (Vorlage salon): Name, Branche und Anschrift', () => {
const { einstellung, fragebogen } = stand()
const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'branche').zeilen
assert.ok(zeilen.includes('Branche: Friseur und Salon'))
assert.ok(zeilen.includes('Betrieb: Salon Muster'))
assert.ok(zeilen.includes('Anschrift: Hauptstraße 1, 67655 Kaiserslautern'))
})
test('zusammenfassung (Vorlage salon): Leistungen mit Preis', () => {
const { einstellung, fragebogen } = stand()
const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'leistungen').zeilen
assert.ok(zeilen.includes('Herrenschnitt: 30 Min., 28,00 €'), zeilen.join(' | '))
assert.ok(zeilen.includes('Damenschnitt: 45 Min., ab 45,00 €'))
assert.ok(zeilen.includes('Kinderschnitt: 30 Min., 18,00 €'))
assert.ok(zeilen.includes('Färben: 120 Min., ab 75,00 €'))
assert.ok(zeilen.includes('Preise geprüft: ja'))
const offen = stand((e, f) => { f.preiseGeprueft = false })
assert.ok(gruppe(zusammenfassung(offen.einstellung, offen.fragebogen, BRANCHEN), 'leistungen').zeilen.includes('Preise geprüft: nein'))
const ohnePreis = stand((e) => { e.leistungen[0] = { ...e.leistungen[0], preisArt: 'keiner', preisCent: 0, aktiv: false } })
const z = gruppe(zusammenfassung(ohnePreis.einstellung, ohnePreis.fragebogen, BRANCHEN), 'leistungen').zeilen
assert.ok(z.includes('Herrenschnitt: 30 Min., ohne Preis (nicht aktiv)'), z.join(' | '))
})
test('zusammenfassung (Vorlage salon): Wochenplan, Tage mit gleichen Zeiten zusammengefasst, Ruhetage', () => {
const { einstellung, fragebogen } = stand()
const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'zeiten').zeilen
assert.ok(zeilen.includes('Dienstag–Freitag: 09:00–12:30, 13:30–18:00'), zeilen.join(' | '))
assert.ok(zeilen.includes('Samstag: 09:00–13:00'))
assert.ok(zeilen.includes('Geschlossen: Montag, Sonntag'))
const mehrere = stand((e) => { e.ressourcen = [{ ...e.ressourcen[0], name: 'Anna' }, { ...e.ressourcen[0], id: 'r_zweite01', name: 'Ben', plan: { 1: [['10:00', '11:00']] } }] })
const z = gruppe(zusammenfassung(mehrere.einstellung, mehrere.fragebogen, BRANCHEN), 'zeiten').zeilen
assert.ok(z.includes('Anna: Dienstag–Freitag: 09:00–12:30, 13:30–18:00'), z.join(' | '))
assert.ok(z.includes('Ben: Montag: 10:00–11:00'))
assert.ok(z.includes('Ben: Geschlossen: Dienstag–Sonntag'))
})
test('zusammenfassung: wer wird gebucht, Zeitraum-Angaben (Feiertage, Urlaub, besondere Tage)', () => {
const { einstellung, fragebogen } = stand((e) => {
e.ressourcen = [{ id: 'r_aaaaaaaa', typ: 'person', name: 'Anna', aktiv: true, plan: {} }, { id: 'r_bbbbbbbb', typ: 'person', name: 'Ben', aktiv: false, plan: {} }]
e.abwesenheiten = [{ id: 'a_aaaaaaaa', ressourceId: null, von: '2027-01-01', bis: '2027-01-02', titel: '' }]
e.sonderoeffnungen = { '2026-12-24': [['09:00', '12:00']], '2026-12-31': [] }
e.feiertageGeschlossen = true
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
assert.deepEqual(gruppe(erg, 'wer').zeilen, ['Personen: Anna, Ben (nicht aktiv)'])
const zeiten = gruppe(erg, 'zeiten').zeilen
assert.ok(zeiten.includes('Feiertage: geschlossen'))
assert.ok(zeiten.includes('Abwesenheiten: 1 eingetragen'))
assert.ok(zeiten.includes('Besondere Öffnungstage: 2'))
const plaetze = stand((e) => { e.ressourcen = [{ id: 'r_aaaaaaaa', typ: 'platz', name: 'Platz 1', aktiv: true, plan: {} }, { id: 'r_cccccccc', typ: 'person', name: 'Cem', aktiv: true, plan: {} }] })
assert.deepEqual(gruppe(zusammenfassung(plaetze.einstellung, plaetze.fragebogen, BRANCHEN), 'wer').zeilen, ['Plätze: Platz 1', 'Personen: Cem'])
})
test('zusammenfassung: Bundesland nur, wenn der Kunde es ausdrücklich gewählt hat (der Serverstandard RP zählt nicht)', () => {
const nichtGewaehlt = stand((e, f) => { f.bundeslandGewaehlt = false })
assert.equal(nichtGewaehlt.einstellung.bundesland, 'RP', 'Serverstandard')
const ohne = JSON.stringify(zusammenfassung(nichtGewaehlt.einstellung, nichtGewaehlt.fragebogen, BRANCHEN))
assert.ok(!ohne.includes('Rheinland-Pfalz') && !ohne.includes('Bundesland'), ohne)
const gewaehlt = stand((e) => { e.bundesland = 'HE' })
const zeilen = gruppe(zusammenfassung(gewaehlt.einstellung, gewaehlt.fragebogen, BRANCHEN), 'zeiten').zeilen
assert.ok(zeilen.includes('Bundesland: Hessen'), zeilen.join(' | '))
const rp = stand()
assert.ok(gruppe(zusammenfassung(rp.einstellung, rp.fragebogen, BRANCHEN), 'zeiten').zeilen.includes('Bundesland: Rheinland-Pfalz'), 'RP ausdrücklich gewählt')
})
test('zusammenfassung: Regeln, Gastfelder, Benachrichtigung und Website', () => {
const { einstellung, fragebogen } = stand((e, f) => {
e.gastFelder = { telefon: 'pflicht', nachricht: 'optional', zusatz: [{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', optionen: [], pflicht: true }, { key: 'wahl', label: 'Wunsch', typ: 'auswahl', optionen: ['a'], pflicht: false }] }
e.anrede = 'du'
e.erinnerung = { anbieten: true, stundenVorher: 12 }
e.betrieb.websiteUrl = 'https://salon.example.invalid'
f.einbau = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Unter dem Text auf der Startseite', gestaltung: 'Dunkelblau', bemerkung: 'Bitte vor dem Wochenende' }
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
const regeln = gruppe(erg, 'regeln').zeilen
assert.ok(regeln.includes('Buchungsart: sofort buchbar'), regeln.join(' | '))
assert.ok(regeln.includes('Raster: 15 Minuten'))
assert.ok(regeln.includes('Vorlaufzeit: 2 Stunden'))
assert.ok(regeln.includes('Buchungszeitraum: 60 Tage im Voraus'))
assert.ok(regeln.includes('Absage durch Gäste: bis 24 Stunden vorher'))
assert.ok(regeln.includes('Deine Gäste sind: Privatpersonen'))
assert.ok(regeln.includes('Zahlung: vor Ort (bar, EC-Karte, Kreditkarte)'))
const gast = gruppe(erg, 'gastfelder').zeilen
assert.ok(gast.includes('Telefonnummer des Gastes: Pflicht') && gast.includes('Nachricht des Gastes: Optional'), gast.join(' | '))
assert.ok(gast.includes('Zusatzfeld „Kennzeichen“: Pflicht') && gast.includes('Zusatzfeld „Wunsch“: Optional (Auswahl)'))
const benachrichtigung = gruppe(erg, 'benachrichtigung').zeilen
assert.ok(benachrichtigung.includes('Benachrichtigungen an: salon@example.invalid'), benachrichtigung.join(' | '))
assert.ok(benachrichtigung.includes('Anrede der Gäste: Du (persönlich)'))
assert.ok(benachrichtigung.includes('E-Mail-Erinnerung für Gäste: angeboten, 12 Stunden vorher'))
const website = gruppe(erg, 'website').zeilen
assert.ok(website.includes('Telefon: 06301 12345'), website.join(' | '))
assert.ok(website.includes('Impressum: https://salon.example.invalid/impressum'))
assert.ok(website.includes('Datenschutzerklärung: https://salon.example.invalid/datenschutz'))
assert.ok(website.includes('Website: https://salon.example.invalid'))
assert.ok(website.includes('Darstellung: hinter einem Knopf „Termin buchen“'))
assert.ok(website.includes('Stelle: Unter dem Text auf der Startseite'))
assert.ok(website.includes('Gestaltungswunsch: Dunkelblau'))
assert.ok(website.includes('Bemerkung: Bitte vor dem Wochenende'))
const inline = stand()
assert.ok(gruppe(zusammenfassung(inline.einstellung, inline.fragebogen, BRANCHEN), 'website').zeilen.includes('Darstellung: direkt auf deiner Seite'))
})
test('zusammenfassung: Anfrage, gemischte Gäste, Freizeitangebote, keine Erinnerung', () => {
const { einstellung, fragebogen } = stand((e) => {
e.leistungen = e.leistungen.map((l, i) => ({ ...l, buchungsart: i === 0 ? 'anfrage' : 'sofort' }))
e.recht = { gaesteSind: 'gemischt', freizeit: true }
e.erinnerung = { anbieten: false, stundenVorher: 24 }
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
const regeln = gruppe(erg, 'regeln').zeilen
assert.ok(regeln.includes('Buchungsart: gemischt, je Leistung'), regeln.join(' | '))
assert.ok(regeln.includes('Deine Gäste sind: Privatpersonen und Unternehmen'))
assert.ok(regeln.includes('Leistungen sind Freizeitangebote mit festem Termin'))
assert.ok(gruppe(erg, 'benachrichtigung').zeilen.includes('E-Mail-Erinnerung für Gäste: aus'))
const anfrage = stand((e) => { e.leistungen = ausVorlage('handwerk', BETRIEB, JETZT).leistungen })
assert.ok(gruppe(zusammenfassung(anfrage.einstellung, anfrage.fragebogen, BRANCHEN), 'regeln').zeilen.includes('Buchungsart: auf Anfrage'))
})
test('zusammenfassung: nie feedToken, nie Zeichen aus der Einstellung außerhalb der genannten Felder', () => {
const { einstellung, fragebogen } = stand()
assert.ok(einstellung.ressourcen.every((r) => typeof r.feedToken === 'string' && r.feedToken.length > 0), 'die Einstellung trägt Token')
const text = JSON.stringify(zusammenfassung(einstellung, fragebogen, BRANCHEN))
assert.ok(!text.includes('feedToken'))
for (const r of einstellung.ressourcen) assert.ok(!text.includes(r.feedToken))
assert.ok(!text.includes(einstellung.ressourcen[0].id), 'auch keine IDs')
})
test('zusammenfassung: Kundentext bleibt Text (nichts entschärft, nichts verändert), lange Wünsche werden gekürzt', () => {
const frech = '<img src=x onerror=alert(1)> & "Anführung"'
const { einstellung, fragebogen } = stand((e, f) => {
e.betrieb.name = frech
f.einbau = { modus: 'inline', knopfText: '', stelle: '', gestaltung: 'g'.repeat(1000), bemerkung: '' }
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
assert.ok(gruppe(erg, 'branche').zeilen.includes(`Betrieb: ${frech}`))
const wunsch = gruppe(erg, 'website').zeilen.find((z) => z.startsWith('Gestaltungswunsch: '))
assert.ok(wunsch.length <= 'Gestaltungswunsch: '.length + 200, 'gekürzt')
assert.ok(wunsch.endsWith('…'))
})
test('zusammenfassung: ohne Einstellung und Fragebogen weiterhin acht Gruppen, ohne Fehler und ohne erfundene Angaben', () => {
for (const [e, f, b] of [[null, null, null], [undefined, undefined, undefined], [{}, {}, []], ['x', 5, 'y']]) {
const erg = zusammenfassung(e, f, b)
assert.deepEqual(erg.map((g) => g.schritt), [...SCHRITTE])
assert.ok(erg.every((g) => Array.isArray(g.zeilen) && g.zeilen.every((z) => typeof z === 'string')))
const text = JSON.stringify(erg)
assert.ok(!/undefined|null|NaN|Rheinland/.test(text), text)
}
const unbekannt = zusammenfassung(salon(), { ...leerFragebogen(), branche: 'unbekannt' }, BRANCHEN)
assert.ok(!gruppe(unbekannt, 'branche').zeilen.some((z) => z.startsWith('Branche:')), 'unbekannte Branche ⇒ keine Zeile')
})
test('Löschdatum und Abschalt-Texte gehören nicht in die Helfer des Fragebogens (E11: abschaltText)', () => {
assert.equal(ansicht.loeschDatum, undefined)
assert.equal(typeof ansicht.abschaltText, 'function')
})
test('termine-view.js bleibt rein: kein DOM, kein Netz, nur der Import der Anrede-Auswahl', () => {
const quelle = readFileSync(new URL('../public/termine-view.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
assert.doesNotMatch(quelle, /\b(document|window|navigator|fetch|XMLHttpRequest|localStorage|sessionStorage)\b/)
assert.deepEqual(quelle.match(/^import .*$/gm), ["import { ANREDE_WAHL } from './termine-gaestetexte.js'"])
})
// ------------------------------------------------------- Fake-DOM (Testhilfe, TP2 C7b)
// test/support/fakeDom.js tragen auch C8, C9a, C9b und C10b: die Tests hier halten fest, worauf sie sich verlassen.
/** Fake-DOM fuer die Dauer von fn aufbauen (und immer abbauen). */
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} finally {
fx.restore()
}
}
/** Namen (data-feld) aller Formularfelder im Baum, in Dokumentreihenfolge. */
const felderVon = (fx, baum) => fx.alle(baum, (n) => typeof n.dataset.feld === 'string').map((n) => n.dataset.feld)
test('Fake-DOM: globale Objekte nur zwischen installFakeDom und restore', () => {
const vorher = { document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }
const fx = installFakeDom()
assert.equal(globalThis.document, fx.document)
assert.notEqual(globalThis.fetch, vorher.fetch)
assert.equal(globalThis.CSS.escape('leistungen.2.dauer'), 'leistungen\\.2\\.dauer')
fx.restore()
assert.deepEqual({ document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }, vorher)
})
test('Fake-DOM: Baum, Attribute, Eigenschaften und isConnected', mitDom(undefined, (fx) => {
const knopf = h('button', { type: 'button', class: 'kb-btn tm-x', 'data-aktion': 'weiter', hidden: true }, 'Weiter ', h('b', null, 'jetzt'), 5)
assert.equal(knopf.tagName, 'BUTTON')
assert.equal(knopf.className, 'kb-btn tm-x')
assert.equal(knopf.dataset.aktion, 'weiter')
assert.equal(knopf.getAttribute('data-aktion'), 'weiter')
assert.equal(knopf.hidden, true)
assert.equal(fx.text(knopf), 'Weiter jetzt5')
assert.equal(knopf.children.length, 1, 'children: nur Elemente')
assert.equal(knopf.childNodes.length, 3)
const wurzel = h('div', null, knopf)
assert.equal(knopf.isConnected, false)
document.body.append(wurzel)
assert.equal(knopf.isConnected, true)
wurzel.replaceChildren('neu')
assert.equal(knopf.isConnected, false)
assert.equal(knopf.parentNode, null)
wurzel.textContent = ''
assert.equal(wurzel.childNodes.length, 0)
}))
test('Fake-DOM: Selektoren wie die Client-Dateien sie brauchen', mitDom(undefined, (fx) => {
const zeile = h('li', { class: 'tm-eintrag', 'data-id': 'r_abc' }, h('button', { 'data-aktion': 'bearbeiten' }, 'Bearbeiten'), h('button', { disabled: true }, 'Aus'))
const feld = h('p', { 'data-fehler': 'leistungen.2.dauer' })
const sperre = h('fieldset', { disabled: true }, h('input', { type: 'text' }))
const tafel = h('div', { role: 'tabpanel' }, h('ul', null, zeile), feld, sperre)
document.body.append(tafel)
assert.equal(tafel.querySelector('li[data-id="r_abc"] [data-aktion="bearbeiten"]').textContent, 'Bearbeiten')
assert.equal(tafel.querySelector(`[data-fehler="${CSS.escape('leistungen.2.dauer')}"]`), feld)
assert.deepEqual(zeile.querySelectorAll('button:not(:disabled)').map((n) => n.textContent), ['Bearbeiten'])
assert.equal(zeile.querySelectorAll('button:disabled').length, 1)
assert.equal(sperre.querySelector('input:not(:disabled)'), null, 'fieldset disabled sperrt die Felder darin')
assert.equal(zeile.closest('[role="tabpanel"]'), tafel)
assert.equal(zeile.closest('form'), null)
assert.deepEqual(fx.alle(tafel, 'li, p').map((n) => n.tagName), ['LI', 'P'])
assert.equal(fx.finde(tafel, (n) => n.tagName === 'FIELDSET'), sperre)
assert.equal(tafel.querySelector('ul > li'), zeile)
assert.throws(() => tafel.querySelector('a:hover'), /nicht unterstuetzt/)
}))
test('Fake-DOM: value, checked und selected wie im Browser', mitDom(undefined, async (fx) => {
const auswahl = h('select', null, h('option', { value: '' }, '– bitte wählen –'), h('option', { value: 'RP' }, 'Rheinland-Pfalz'), h('option', { value: 'SL', selected: true }, 'Saarland'))
assert.equal(auswahl.value, 'SL', 'selected-Attribut gilt')
const ohneWahl = h('select', null, h('option', { value: '' }, '–'), h('option', { value: 'RP' }, 'RP'))
assert.equal(ohneWahl.value, '', 'ohne selected zeigt der Browser die erste Option')
await fx.setze(ohneWahl, 'RP')
assert.equal(ohneWahl.value, 'RP')
const text = h('input', { type: 'text', value: 'alt' })
assert.equal(text.value, 'alt')
await fx.setze(text, 'neu')
assert.equal(text.value, 'neu')
assert.equal(text.getAttribute('value'), 'alt', 'die Eigenschaft ändert das Attribut nicht')
const haken = h('input', { type: 'checkbox', checked: true })
assert.equal(haken.checked, true)
await fx.klick(haken)
assert.equal(haken.checked, false)
const area = h('textarea', null, 'Vorgabe')
assert.equal(area.value, 'Vorgabe')
const a = h('input', { type: 'radio', name: 'x', checked: true })
const b = h('input', { type: 'radio', name: 'x' })
h('div', null, a, b)
await fx.klick(b)
assert.deepEqual([a.checked, b.checked], [false, true], 'Radios einer Gruppe schließen sich aus')
}))
test('Fake-DOM: Ereignisse steigen auf, klick auf Absenden-Knopf sendet das Formular, gesperrte Knöpfe tun nichts, async-Handler werden abgewartet', mitDom(undefined, async (fx) => {
const log = []
const knopf = h('button', { type: 'submit' }, 'Weiter')
const aus = h('button', { type: 'submit', disabled: true }, 'Aus')
const lauf = h('button', { type: 'button' }, 'Lauf')
const form = h('form', null, knopf, aus, lauf)
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
log.push('submit')
await Promise.resolve()
await new Promise((r) => setImmediate(r))
log.push('submit fertig')
})
form.addEventListener('click', (ev) => log.push(`click ${ev.target.textContent} ${ev.currentTarget.tagName}`))
lauf.addEventListener('click', async () => { await new Promise((r) => setImmediate(r)); log.push('lauf fertig') })
document.body.append(form)
await fx.klick(aus)
assert.deepEqual(log, [], 'gesperrt: kein Ereignis')
await fx.klick(knopf)
assert.deepEqual(log, ['click Weiter FORM', 'submit', 'submit fertig'])
log.length = 0
await fx.klick(lauf)
assert.deepEqual(log, ['click Lauf FORM', 'lauf fertig'], 'kein submit bei type=button')
log.length = 0
form.addEventListener('keydown', (ev) => log.push(`${ev.type} ${ev.key}`))
await fx.sende(lauf, 'keydown', { key: 'ArrowRight' })
assert.deepEqual(log, ['keydown ArrowRight'])
}))
test('Fake-DOM: fetch schreibt { method, url, body } ins Protokoll und liefert die Antworten der Reihe nach', mitDom({ antworten: [{ status: 400, body: { error: 'Nein', fehler: { 'betrieb.telefon': 'fehlt' }, code: 'X' } }, { body: { ok: 1 } }] }, async (fx) => {
await assert.rejects(request('/api/termine/einstellungen', { method: 'PATCH', body: { projectId: 'p1', version: 3 } }), (err) => err.status === 400 && err.code === 'X' && err.fehler['betrieb.telefon'] === 'fehlt')
assert.deepEqual(await request('/api/termine/x?projectId=p1'), { ok: 1 })
assert.deepEqual(await request('/api/termine/y'), {}, 'ohne Eintrag: 200 mit {}')
assert.deepEqual(fx.fetchLog, [
{ method: 'PATCH', url: '/api/termine/einstellungen', body: { projectId: 'p1', version: 3 } },
{ method: 'GET', url: '/api/termine/x?projectId=p1', body: undefined },
{ method: 'GET', url: '/api/termine/y', body: undefined },
])
}))
// ------------------------------------------------------- betrieb(ctx, e, { nur, titel, hinweis })
const BETRIEB_FELDER = ['name', 'email', 'telefon', 'adresse', 'kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']
const betriebCtx = (fehlt = []) => ({ daten: { fehlt }, nurLesen: false })
test('betrieb mit nur: [email] baut nur das Feld email, lese() liefert nur { email }', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
assert.deepEqual(felderVon(fx, el), ['betrieb.email'])
assert.equal(fx.alle(el, 'input').length, 1)
const eingabe = fx.finde(el, '[data-feld="betrieb.email"]')
assert.equal(eingabe.value, BETRIEB.email, 'vorbelegt aus der Einstellung')
await fx.setze(eingabe, 'neu@example.invalid')
const gelesen = lese()
assert.deepEqual(Object.keys(gelesen), ['email'], 'Schlüsselmenge')
assert.deepEqual(gelesen, { email: 'neu@example.invalid' })
// in den Entwurf geschrieben ändert sich nur email, alles andere bleibt
const entwurf = structuredClone(e)
lese(entwurf)
assert.deepEqual(entwurf.betrieb, { ...e.betrieb, email: 'neu@example.invalid' })
}))
test('betrieb mit nur: Teilmenge in der Reihenfolge der Seite, Haken und Adressen gelesen, Unbekanntes wirft', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['websiteUrl', 'name', 'kleinunternehmer'] })
assert.deepEqual(felderVon(fx, el), ['betrieb.name', 'betrieb.kleinunternehmer', 'betrieb.websiteUrl'], 'Reihenfolge der Seite, nicht der Aufzählung')
await fx.klick(fx.finde(el, '[data-feld="betrieb.kleinunternehmer"]'))
await fx.setze(fx.finde(el, '[data-feld="betrieb.websiteUrl"]'), 'https://salon.example.invalid')
const gelesen = lese()
assert.deepEqual(Object.keys(gelesen).sort(), ['kleinunternehmer', 'name', 'websiteUrl'])
assert.equal(gelesen.kleinunternehmer, true)
assert.equal(gelesen.websiteUrl, 'https://salon.example.invalid')
assert.equal(gelesen.name, BETRIEB.name)
assert.throws(() => einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email', 'steuernummer'] }), /steuernummer/)
}))
test('betrieb ohne nur baut weiter alle neun Felder und schreibt alle neun in den Entwurf', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e)
assert.deepEqual(felderVon(fx, el).sort(), BETRIEB_FELDER.map((n) => `betrieb.${n}`).sort())
assert.equal(fx.alle(el, 'input').length, 9)
assert.equal(fx.text(fx.finde(el, 'h3')), 'Dein Betrieb')
const entwurf = { betrieb: {} }
lese(entwurf)
assert.deepEqual(Object.keys(entwurf.betrieb).sort(), [...BETRIEB_FELDER].sort())
assert.equal(entwurf.betrieb.telefon, BETRIEB.telefon)
assert.equal(entwurf.betrieb.kleinunternehmer, false)
}))
test('betrieb: titel und hinweis ersetzen Überschrift und Erklärsatz', mitDom(undefined, (fx) => {
const e = salon()
const eigen = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], titel: 'Wohin sollen die Nachrichten?', hinweis: 'Eine Adresse für alles.' })
assert.equal(fx.text(fx.finde(eigen.el, 'h3')), 'Wohin sollen die Nachrichten?')
assert.equal(fx.text(fx.finde(eigen.el, '.tm-abschnitt-text')), 'Eine Adresse für alles.')
const ohneHinweis = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], hinweis: null })
assert.equal(fx.finde(ohneHinweis.el, '.tm-abschnitt-text'), null)
const standard = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
assert.equal(fx.text(fx.finde(standard.el, 'h3')), 'Dein Betrieb')
assert.match(fx.text(fx.finde(standard.el, '.tm-abschnitt-text')), /Diese Angaben sehen deine Gäste/)
}))
test('betrieb: der Hinweis auf fehlende Angaben nennt mit nur nur die Felder, die zu sehen sind', mitDom(undefined, (fx) => {
const e = salon()
const fehlt = ['betrieb.telefon', 'betrieb.adresse']
const alles = einstellungenTab.betrieb(betriebCtx(fehlt), e)
assert.match(fx.text(fx.finde(alles.el, '.tm-box-warn')), /deine Telefonnummer und deine Anschrift/, 'ohne nur wie bisher')
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['email'] }).el, '.tm-box-warn'), null)
const nurTelefon = fx.text(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['telefon'] }).el, '.tm-box-warn'))
assert.match(nurTelefon, /Es fehlt noch: deine Telefonnummer\.$/, 'die Anschrift steht nicht in der Liste dessen, was fehlt')
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx([]), e).el, '.tm-box-warn'), null)
}))
// ------------------------------------------------------- wochenplanFeld(r, pfad)
test('wochenplanFeld: sieben Tagesfelder mit Namen aus dem Pfad, gefüllt aus dem Plan der Person', mitDom(undefined, (fx) => {
const r = { id: 'r_anna0001', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] } }
const { el } = teamTab.wochenplanFeld(r, 'ressourcen.2')
assert.deepEqual(felderVon(fx, el), [1, 2, 3, 4, 5, 6, 7].map((nr) => `ressourcen.2.plan.${nr}`))
assert.deepEqual(fx.alle(el, 'label').map((l) => fx.text(l)), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
const eingaben = fx.alle(el, 'input')
assert.deepEqual(eingaben.map((i) => i.value), ['09:00–12:30, 13:30–18:00', '', '', '', '', '09:00–13:00', ''])
assert.ok(eingaben.every((i) => i.getAttribute('placeholder') === 'geschlossen'))
assert.equal(fx.text(fx.finde(el, 'legend')), 'Wochenplan')
assert.equal(fx.alle(el, '[data-fehler]').length, 7, 'je Tag ein Platz für den Fehler')
}))
test('wochenplanFeld: gültige Fenster ⇒ { plan } mit genau diesen Fenstern, ein leerer Tag ist geschlossen', mitDom(undefined, async (fx) => {
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.0')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.1"]'), '09:00–12:30, 13:30–18:00')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.3"]'), '8:00-12:00')
const gelesen = lese()
assert.deepEqual(gelesen, { plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 3: [['08:00', '12:00']] } })
assert.equal(gelesen.fehler, undefined)
assert.ok(!(2 in gelesen.plan) && !(7 in gelesen.plan), 'leerer Tag ⇒ geschlossen (kein Eintrag)')
// ein vorher offener Tag, der geleert wird, ist danach geschlossen
const offen = teamTab.wochenplanFeld({ id: 'r1', plan: { 1: [['09:00', '12:00']], 2: [['10:00', '11:00']] } }, 'ressourcen.0')
await fx.setze(fx.finde(offen.el, '[data-feld="ressourcen.0.plan.1"]'), '')
assert.deepEqual(offen.lese(), { plan: { 2: [['10:00', '11:00']] } })
assert.deepEqual(teamTab.wochenplanFeld(null, 'ressourcen.0').lese(), { plan: {} }, 'ohne Person: alles geschlossen')
}))
test('wochenplanFeld: ein ungültiges Fenster ⇒ { fehler } mit Text am Feld des Tages, kein Teilplan', mitDom(undefined, async (fx) => {
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.1')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.1"]'), '09:00–12:00')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.2"]'), 'abc')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.5"]'), '09:00–09:00')
const gelesen = lese()
assert.deepEqual(gelesen, { fehler: { 'ressourcen.1.plan.2': FENSTER_FEHLER, 'ressourcen.1.plan.5': FENSTER_FEHLER } })
assert.equal(gelesen.plan, undefined)
assert.ok(typeof gelesen.fehler['ressourcen.1.plan.2'] === 'string' && gelesen.fehler['ressourcen.1.plan.2'].length > 0)
}))
test('Reiter Team & Plätze: der Editor einer Person speichert den Wochenplan aus wochenplanFeld und zeigt Fehler am Tag', mitDom(undefined, async (fx) => {
const einstellung = salon()
const anna = einstellung.ressourcen[0]
const ctx = {
s: {}, projectId: 'p1', daten: { einstellung, version: 4, feeds: {}, fehlt: [] }, nurLesen: false, auswahl: {}, aktuell: () => true,
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
meldung() {}, hinweise() {}, zumReiter() {}, neuLaden: async () => {},
}
const tafel = h('div', { role: 'tabpanel' })
document.body.append(tafel)
teamTab.render(tafel, ctx)
await fx.ruhe()
await fx.klick(fx.finde(tafel, `li[data-id="${anna.id}"] [data-aktion="bearbeiten"]`))
const form = fx.finde(tafel, (n) => n.tagName === 'FORM' && n.getAttribute('aria-label') === `Bearbeiten: ${anna.name}`)
assert.ok(form, 'Editor ist offen')
const speichern = fx.finde(form, (n) => n.tagName === 'BUTTON' && n.type === 'submit')
const montag = fx.finde(form, '[data-feld="ressourcen.0.plan.1"]')
// Fehler: nichts wird gesendet, der Text steht am Tag
await fx.setze(montag, 'abc')
await fx.klick(speichern)
assert.deepEqual(fx.fetchLog, [])
assert.equal(fx.text(fx.finde(form, '[data-fehler="ressourcen.0.plan.1"]')), FENSTER_FEHLER)
assert.equal(montag.getAttribute('aria-invalid'), 'true')
// gültig: PATCH mit dem Plan (alle anderen Tage unverändert)
await fx.setze(montag, '10:00-13:00')
fx.antworten.push({ body: { einstellung, version: 5, fehlt: [] } })
await fx.klick(speichern)
assert.equal(fx.fetchLog.length, 1)
const [aufruf] = fx.fetchLog
assert.deepEqual([aufruf.method, aufruf.url], ['PATCH', '/api/termine/einstellungen'])
assert.equal(aufruf.body.version, 4)
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[1], [['10:00', '13:00']])
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[2], anna.plan[2])
}))
// ------------------------------------------------------- termine-speichern.js: fehlt aus der Antwort
const speicherCtx = (einstellung, fehlt) => ({
aktuell: () => true,
daten: { einstellung, version: 3, fehlt },
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
hinweise() {}, meldung() {}, neuLaden: async () => {},
})
test('Speichern: antwortet der PATCH mit fehlt, steht es danach in den Daten des Reiters', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4, fehlt: ['betrieb.telefon'] } }] }, async (fx) => {
const ctx = speicherCtx(salon(), ['betrieb.telefon', 'betrieb.adresse'])
const wurzel = h('div')
document.body.append(wurzel)
const sp = neuerSpeicher(ctx, wurzel, () => {})
assert.equal(await sp.speichere((entwurf) => { entwurf.betrieb.adresse = 'Hauptstraße 1' }, { container: wurzel }), true)
assert.deepEqual(ctx.daten.fehlt, ['betrieb.telefon'])
assert.equal(ctx.daten.version, 4)
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['PATCH', '/api/termine/einstellungen']])
}))
test('Speichern: ohne fehlt in der Antwort ist es leer', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4 } }] }, async () => {
const ctx = speicherCtx(salon(), ['betrieb.telefon'])
const wurzel = h('div')
document.body.append(wurzel)
const sp = neuerSpeicher(ctx, wurzel, () => {})
assert.equal(await sp.speichere(() => true, { container: wurzel }), true)
assert.deepEqual(ctx.daten.fehlt, [])
}))
// ------------------------------------------------------- termine.js: baueCtx
test('baueCtx: Schnittstelle, Daten und Meldungen wie der Reiter-Kontext', mitDom({ antworten: [{}, {}, {}, { body: { version: 9, einstellung: null } }, { body: { version: 11 } }] }, async (fx) => {
const s = { daten: { version: 3 }, nurLesen: false, meldungEl: h('div', { class: 'tm-meldung', tabindex: '-1' }) }
document.body.append(s.meldungEl)
let aktuell = true
let gezeichnet = 0
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p 1' }, aktuell: () => aktuell, hinweise: () => { gezeichnet += 1 } })
assert.equal(ctx.s, s)
assert.equal(ctx.projectId, 'p 1')
assert.equal(ctx.daten, s.daten)
assert.equal(ctx.nurLesen, false)
assert.equal(typeof ctx.auswahl, 'object')
assert.equal(ctx.aktuell(), true)
assert.equal(typeof ctx.zumReiter, 'function')
// Schnittstelle: GET und DELETE mit projectId in der Adresse, PATCH/POST/PUT mit projectId im Body
await ctx.api.get('/buchungen', { von: '2026-10-05' })
await ctx.api.send('DELETE', '/abwesenheiten/a1')
await ctx.api.send('PATCH', '/einstellungen', { version: 3 })
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url, a.body]), [
['GET', '/api/termine/buchungen?projectId=p+1&von=2026-10-05', undefined],
['DELETE', '/api/termine/abwesenheiten/a1?projectId=p+1', undefined],
['PATCH', '/api/termine/einstellungen', { projectId: 'p 1', version: 3 }],
])
// Meldung nur solange die Ansicht aktuell ist; hinweise() zeichnet nur dann
ctx.meldung('Gespeichert.')
assert.equal(s.meldungEl.textContent, 'Gespeichert.')
ctx.hinweise()
assert.equal(gezeichnet, 1)
// neu laden: Daten und ctx.daten werden ersetzt und die Hinweise neu gezeichnet
const daten = await ctx.neuLaden()
assert.deepEqual(daten, { version: 9, einstellung: null })
assert.equal(s.daten, daten)
assert.equal(ctx.daten, daten)
assert.equal(gezeichnet, 2)
assert.equal(fx.fetchLog.at(-1).url, '/api/termine/einstellungen?projectId=p%201')
aktuell = false
ctx.meldung('Zu spät.')
ctx.hinweise()
await ctx.neuLaden()
assert.equal(s.meldungEl.textContent, 'Gespeichert.', 'eine veraltete Ansicht bekommt keine Meldung')
assert.equal(gezeichnet, 2)
assert.equal(ctx.daten, daten, 'und keine neuen Daten')
// ohne hinweise/zumReiter ist der Kontext trotzdem benutzbar (Fragebogen)
const schlicht = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true })
schlicht.hinweise()
schlicht.zumReiter('team')
}))