Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3

- public/termine-fragebogen.js: renderFragebogen(root, ctx) mit Fortschrittsleiste
  (ol, aria-current, Handy: Schritt n von 8), Start beim ersten offenen Schritt
  (alles bestaetigt: Bestellseite), freier Navigation, einem Formular je Schritt,
  Weiter = ein PATCH ueber neuerSpeicher, danach PUT /fragebogen; Fokus auf die
  Ueberschrift; Kundenansicht ohne PUT
- Schritt 1 Branche und Betrieb (Gesundheitsbereich: Erklaertext statt Weiter, keine
  Anfrage; Branche wechseln mit Rueckfrage), Schritt 2 Wer wird gebucht,
  Schritt 3 Leistungen mit Haken Preise geprueft; Schritt 7 als Beispiel eines
  Schritts mit Feldern, Schritte 4, 5, 6 und 8 folgen in C9b
- termine.css: Fortschrittsleiste und Handy-Ansicht
- Tests: termineView (Dateiliste, PUT nur im Fragebogen, nur ueber termine.js geladen),
  termineFragebogenView (Verhalten mit Fake-DOM)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 21:55:51 +00:00
1 parent 20583424a3
commit 9bf5ec09f8
4 files changed
+1352 -3

No files matched your search

+502
View File
@@ -0,0 +1,502 @@
/* Terminplaner im Kundenbereich: Fragebogen vor der Bestellung (TP2). renderFragebogen(root, ctx) zeigt in der Huelle (termine.js) die acht Schritte
und danach die Bestellseite (funktion-bestellen.js). ctx ist der Kontext der Reiter (baueCtx in termine.js, hinweise() bleibt leer); ctx.daten ist die
Antwort von GET /api/termine/einstellungen im Zustand off (einstellung, version, fragebogen, branchen, betriebVorschlag, kundenansicht, offen, bestellung).
Aufbau: Fortschrittsleiste (<ol>, aria-current="step"; am Handy dazu „Schritt n von 8“), darunter der Schritt. Der Start ist der erste offene
Schritt (alles bestaetigt: die Bestellseite); die Leiste erlaubt freie Navigation, nur ohne Entwurf fuehrt sie allein in die Branchenwahl.
Jeder Schritt steht in BAU: key ⇒ (t) ⇒ Element mit t = { ctx, key, nr, titel, gehe(key), neuZeichnen(), weiter(), zurueck }. Ein Schritt mit
Feldern ist ein <form>; „Weiter“ (verdrahte) macht genau EINEN PATCH ueber den neuerSpeicher des Schritts (termine-speichern.js, der einzige
PATCH-Weg; nur wenn der Schritt Felder hat) und danach PUT /fragebogen mit dem Schritt in bestaetigt (bestaetige, der einzige PUT). Fehler stehen am
Feld (setzeFehler); beim Wechsel bekommt die Ueberschrift des Schritts den Fokus. Kundenansicht (daten.kundenansicht): PATCH bleibt moeglich, es gibt
keinen PUT, denn Schritte bestaetigt nur der Kunde selbst. Die Schritte 4, 5, 6 und 8 traegt C9b in BAU nach. Texte der Kunden kommen nur ueber
value/textContent in die Seite (h aus termine-dom.js), nie als HTML. */
import { FRAGEBOGEN_SCHRITTE, LIMITS, MAX, ersterOffenerSchritt, preisAnzeige, ressourcenAusNamen, schrittStatus } from './termine-view.js'
import { allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler, zeigeFehler } from './termine-dom.js'
import { neuerSpeicher } from './termine-speichern.js'
import { render as renderLeistungen } from './termine-tab-leistungen.js'
import { betrieb, darstellung, erinnerung } from './termine-tab-einstellungen.js'
import { renderBestellen } from './funktion-bestellen.js'
const BESTELLEN = 'bestellen'
const TITEL = new Map([...FRAGEBOGEN_SCHRITTE, [BESTELLEN, 'Prüfen und bestellen']])
const AUFBEWAHRUNG = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.'
const KUNDENANSICHT = 'Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und bestellen kann nur der Kunde selbst.'
const GESUNDHEIT_TEXT = 'Für den Gesundheitsbereich ist der Terminplaner nicht verfügbar. Schon der Name einer Leistung oder ein Terminwunsch kann etwas über die Gesundheit deiner Gäste verraten, '
+ 'und solche Angaben sind besonders geschützt. Gehört dein Angebot nicht zum Gesundheitsbereich, wähle die Branche, die am besten passt. Fragen dazu beantworten wir dir im Chat.'
const WECHSEL_TEXT = 'Das ersetzt deine Leistungen, Zeiten und Regeln durch die Vorschläge der neuen Branche. Alle Bestätigungen gehen zurück, du bestätigst die Schritte danach noch einmal. '
+ 'Deine Angaben zum Betrieb bleiben erhalten.'
const NACH_VORLAGE_FEHLT = 'Deine Branche ist angelegt, aber nicht alle Angaben konnten gespeichert werden. Bitte prüfe sie und klick auf „Weiter“.'
const NACH_VORLAGE_LADEN = 'Deine Branche ist angelegt. Wir konnten den Stand aber nicht laden. Bitte lade die Seite neu.'
// Betriebsangaben von Schritt 1, ohne Entwurf und mit Entwurf
const BETRIEB_NUR = ['name', 'adresse', 'kleinunternehmer']
// POST vorlage uebernimmt aus betrieb nur diese Felder (server/routes/termine.js) ...
const KONTAKT_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl']
// ... diese bleiben dabei auf der Strecke; der Client traegt sie mit einem PATCH nach (betriebNachtrag)
const NACHTRAG_FELDER = ['kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl']
const WER_ARTEN = [
['einzel', 'Einzelperson', 'Eine Person wird gebucht, zum Beispiel du selbst.'],
['team', 'Team', 'Mehrere Personen können gebucht werden, jede mit eigenen Zeiten.'],
['plaetze', 'Plätze', 'Gebucht werden Plätze oder Räume, zum Beispiel Stühle oder Zimmer.'],
]
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/* ---------------------------------------------------------------- Gerüst --- */
export function renderFragebogen(root, ctx) {
const stand = h('p', { class: 'tm-schritt-stand' })
const liste = h('ol', { class: 'tm-schritte' })
const inhalt = h('div', { class: 'tm-fb-inhalt' })
root.replaceChildren(h('div', { class: 'tm-fragebogen' },
ctx.daten.kundenansicht === true ? h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, KUNDENANSICHT)) : null,
h('nav', { class: 'tm-fortschritt', 'aria-label': 'Fortschritt im Fragebogen' }, stand, liste),
inhalt,
h('p', { class: 'tm-hinweis tm-fb-aufbewahrung' }, AUFBEWAHRUNG)))
let angezeigt = null // Schluessel des Schritts, der gerade steht
// ohne Entwurf gibt es nur die Branchenwahl; alle anderen Schritte brauchen eine Einstellung
const darfHin = (key) => key === 'branche' || Boolean(ctx.daten.einstellung)
function zeichneLeiste() {
const eintraege = [...schrittStatus(ctx.daten.fragebogen), { key: BESTELLEN, titel: TITEL.get(BESTELLEN), erledigt: false }]
liste.replaceChildren(...eintraege.map(({ key, titel, erledigt }, i) => h('li', {
class: 'tm-schritt', 'data-schritt': key, 'data-erledigt': erledigt ? 'true' : null, 'aria-current': key === angezeigt ? 'step' : null,
}, h('button', { type: 'button', class: 'tm-schritt-knopf', disabled: !darfHin(key), onclick: () => { if (key !== angezeigt) zeige(key) } },
h('span', { class: 'tm-schritt-nr', 'aria-hidden': 'true' }, erledigt ? '✓' : String(i + 1)),
h('span', { class: 'tm-schritt-titel' }, titel),
erledigt ? h('span', { class: 'tm-sr' }, ' (erledigt)') : null))))
const nr = FRAGEBOGEN_SCHRITTE.findIndex(([key]) => key === angezeigt)
stand.textContent = nr < 0 ? TITEL.get(BESTELLEN) : `Schritt ${nr + 1} von ${FRAGEBOGEN_SCHRITTE.length}`
}
function bestellseite() {
const seite = h('div', { class: 'tm-fb-bestellen' })
renderBestellen(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key) })
return seite
}
/** Schritt zeigen (=> sein Element). fokus: die Ueberschrift bekommt den Fokus, so wie bei jedem Wechsel; der erste Aufruf nimmt keinen. */
function zeige(key, { fokus = true } = {}) {
const ziel = darfHin(key) ? key : 'branche'
angezeigt = ziel
zeichneLeiste()
const el = ziel === BESTELLEN ? bestellseite() : (BAU[ziel] ?? schrittOhneInhalt)(baueT(ziel))
inhalt.replaceChildren(el)
const kopf = fokus ? el.querySelector('h3') : null
if (kopf) {
kopf.setAttribute('tabindex', '-1')
kopf.focus()
}
return el
}
function baueT(key) {
const nr = FRAGEBOGEN_SCHRITTE.findIndex(([k]) => k === key)
return {
ctx,
key,
nr,
titel: TITEL.get(key),
gehe: (ziel) => zeige(ziel),
// nur solange der Schritt noch steht: eine spaete Antwort holt den Kunden nicht aus einem anderen Schritt zurueck
neuZeichnen: () => (angezeigt === key ? zeige(key, { fokus: false }) : null),
weiter: () => zeige(FRAGEBOGEN_SCHRITTE[nr + 1]?.[0] ?? BESTELLEN),
zurueck: nr > 0 ? () => zeige(FRAGEBOGEN_SCHRITTE[nr - 1][0]) : null,
}
}
zeige(ersterOffenerSchritt(ctx.daten.fragebogen), { fokus: false })
}
/* ------------------------------------------------------------ Bausteine --- */
/** Rahmen eines Schritts: Ueberschrift (Fokusziel beim Wechsel) und ein erklaerender Satz; der Schritt haengt seinen Inhalt an. */
function rahmen(t, intro) {
return h('section', { class: 'tm-fb-schritt', 'data-schritt': t.key },
h('h3', { id: `tm-fb-${t.key}`, tabindex: '-1' }, t.titel),
intro ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, intro) : null)
}
const schrittForm = (t, ...kinder) => h('form', { class: 'tm-fb-form', novalidate: true, 'aria-labelledby': `tm-fb-${t.key}` }, ...kinder)
/** Zurueck (ohne zu speichern) und Weiter (sendet das Formular). */
function knoepfe(t, { mitWeiter = true } = {}) {
return h('div', { class: 'tm-frage-knoepfe' },
t.zurueck ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => t.zurueck() }, 'Zurück') : null,
mitWeiter ? h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'weiter' }, 'Weiter') : null)
}
/**
* PUT /fragebogen: der Schritt kommt in bestaetigt, aenderung(fragebogen) setzt weitere Angaben (z. B. preiseGeprueft). Gesendet wird der ganze Fragebogen
* (PUT ersetzt ihn) auf dem Stand von ctx.daten; die Antwort gilt danach in ctx.daten (fragebogen und die offenen Punkte der Bestellung).
* In der Kundenansicht passiert nichts: Schritte bestaetigt nur der Kunde selbst.
*/
async function bestaetige(t, aenderung) {
const { ctx } = t
if (ctx.daten.kundenansicht === true) return
const fragebogen = structuredClone(istObjekt(ctx.daten.fragebogen) ? ctx.daten.fragebogen : {})
aenderung?.(fragebogen)
fragebogen.bestaetigt = [...new Set([...(Array.isArray(fragebogen.bestaetigt) ? fragebogen.bestaetigt : []), t.key])]
const antwort = await ctx.api.send('PUT', '/fragebogen', { fragebogen })
if (!ctx.aktuell()) return
if (istObjekt(antwort?.fragebogen)) ctx.daten.fragebogen = antwort.fragebogen
if (antwort?.offen !== undefined) ctx.daten.offen = antwort.offen
}
/**
* „Weiter“ eines Schritts: pruefe() (Fehler ⇒ { feld: text } am Feld, nichts wird gesendet), aendere(entwurf) (ein PATCH ueber den Speicher des
* Schritts; false = der Schritt hat selbst Fehler gezeigt; fehlt aendere, gibt es keinen PATCH), dann PUT mit dem Schritt (fragebogen(f) setzt weitere
* Angaben) und der naechste Schritt. Nur ein Durchlauf zur Zeit; verlaesst der Kunde den Schritt oder die Ansicht, fasst nichts mehr die Seite an.
*/
function verdrahte(t, form, sp, { pruefe, aendere, fragebogen } = {}) {
const weiter = form.querySelector('[data-aktion="weiter"]')
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (laeuft) return
laeuft = true
const hatteFokus = document.activeElement === weiter
weiter.disabled = true
try {
setzeFehler(form, {})
const fehler = pruefe?.()
if (fehler) {
zeigeFehler(form, { fehler })
return
}
if (aendere && !(await sp.speichere(aendere, { container: form }))) return
if (!sp.nochHier()) return
try {
await bestaetige(t, fragebogen)
} catch (err) {
if (sp.nochHier()) zeigeFehler(form, err)
return
}
if (sp.nochHier()) t.weiter()
} finally {
laeuft = false
weiter.disabled = false
// ein gesperrter Knopf verliert im Browser den Fokus: bleibt der Schritt stehen und hat ihn niemand sonst (ein Feld mit Fehler), kehrt er zurueck
const jetzt = document.activeElement
if (hatteFokus && weiter.isConnected && (!jetzt || jetzt === document.body || jetzt === weiter)) weiter.focus()
}
})
}
/** Schritt neu zeichnen und im neuen Formular einen allgemeinen Fehler zeigen (nach einem Teilerfolg, den die alte Ansicht nicht kennt). */
function zeigeNeu(t, text) {
const form = t.neuZeichnen()?.querySelector('form')
if (form) setzeFehler(form, { _: text })
}
/* ------------------------------------------------- Schritt 1: Branche und Betrieb --- */
/**
* Radiogruppe der Branchen aus daten.branchen. Der Gesundheitsbereich zeigt den Erklaertext und KEINEN Knopf (es geht nie eine Anfrage raus), jede andere
* Wahl den Knopf (type submit, data-aktion branche-starten). => { liste, hinweis, aktion, gewaehlt() }: der Aufrufer setzt die drei Elemente.
*/
function brancheWahl(daten, { name, knopf }) {
let gewaehlt = null
const hinweis = h('div', { class: 'tm-fb-branchenhinweis' })
const aktion = h('div', { class: 'tm-frage-knoepfe' })
const liste = h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' },
h('legend', { class: 'tm-sr' }, 'Branche'),
(daten.branchen || []).map((b) => {
const radio = h('input', { type: 'radio', name, value: b.key })
radio.addEventListener('change', () => { gewaehlt = b; zeichne() })
const beschreibung = (daten.vorlagen || []).find((v) => v.key === b.key)?.beschreibung
return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, b.name), beschreibung ? h('small', null, beschreibung) : null))
}))
function zeichne() {
if (gewaehlt?.gesundheit) {
hinweis.replaceChildren(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'gesundheit' },
h('span', null, GESUNDHEIT_TEXT),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen')))
aktion.replaceChildren()
return
}
hinweis.replaceChildren()
aktion.replaceChildren(h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'branche-starten', disabled: gewaehlt === null }, knopf))
}
zeichne()
return { liste, hinweis, aktion, gewaehlt: () => gewaehlt }
}
/** Nur die nichtleeren Texte der Felder, die POST vorlage aus betrieb uebernimmt. */
const kontaktVon = (angaben) => Object.fromEntries(KONTAKT_FELDER
.filter((name) => typeof angaben?.[name] === 'string' && angaben[name].trim() !== '')
.map((name) => [name, angaben[name]]))
/** Was der Kunde angegeben hatte und POST vorlage nicht uebernommen hat (Haken gesetzt, Adresse eingetragen), im Vergleich zum neuen Entwurf. */
function betriebNachtrag(gewuenscht, neu) {
const nachtrag = {}
for (const name of NACHTRAG_FELDER) {
const wunsch = gewuenscht?.[name]
if (wunsch && wunsch !== neu?.[name]) nachtrag[name] = wunsch
}
return nachtrag
}
/**
* Nach einem erfolgreichen POST vorlage: Daten neu laden (Einstellung, Version, Fragebogen) und nachtragen, was der Server aus betrieb nicht uebernimmt
* (ein PATCH, nur wenn etwas fehlt). => true, wenn der Entwurf danach vollstaendig steht.
*/
async function nachVorlage(t, sp, container, gewuenscht) {
const { ctx } = t
try {
await ctx.neuLaden()
} catch {
return false
}
if (!sp.nochHier() || !ctx.daten.einstellung) return false
const nachtrag = betriebNachtrag(gewuenscht, ctx.daten.einstellung.betrieb)
if (Object.keys(nachtrag).length === 0) return true
return sp.speichere((entwurf) => { entwurf.betrieb = { ...entwurf.betrieb, ...nachtrag }; return true }, { container })
}
function schrittBranche(t) {
return t.ctx.daten.einstellung ? brancheMitEntwurf(t) : brancheStart(t)
}
/** Ohne Entwurf: Branche waehlen und die Betriebsangaben pruefen; „Mit dieser Branche starten“ legt den Entwurf an und bestaetigt den Schritt. */
function brancheStart(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Such dir die Branche aus, die am besten zu deinem Betrieb passt. Daraus legen wir Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const angaben = betrieb(ctx, { betrieb: ctx.daten.betriebVorschlag ?? {} }, { nur: BETRIEB_NUR })
const wahl = brancheWahl(ctx.daten, { name: 'termine-branche', knopf: 'Mit dieser Branche starten' })
const form = schrittForm(t, h('div', { class: 'kb-panel' }, wahl.liste, wahl.hinweis), angaben.el, allgemeinerFehler(), wahl.aktion)
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const branche = wahl.gewaehlt()
if (laeuft || branche === null || branche.gesundheit) return // der Gesundheitsbereich sendet nie etwas
laeuft = true
const knopf = form.querySelector('[data-aktion="branche-starten"]')
if (knopf) knopf.disabled = true
try {
setzeFehler(form, {})
const gewuenscht = angaben.lese()
// die Website des Projekts kennt nur der Vorschlag (der Server nimmt Name, Anschrift, E-Mail und Telefon selbst aus dem Konto): sie geht mit
const website = typeof ctx.daten.betriebVorschlag?.websiteUrl === 'string' ? ctx.daten.betriebVorschlag.websiteUrl.trim() : ''
try {
await ctx.api.send('POST', '/einstellungen/vorlage', { branche: branche.key, betrieb: website ? { ...gewuenscht, websiteUrl: website } : gewuenscht })
} catch (err) {
if (sp.nochHier()) zeigeFehler(form, err)
return
}
// ab hier gibt es den Entwurf: was schiefgeht, zeigt den Schritt neu (mit Entwurf) statt noch einmal anzulegen
const vollstaendig = await nachVorlage(t, sp, form, gewuenscht)
if (!sp.nochHier()) return
if (!vollstaendig) {
if (ctx.daten.einstellung) zeigeNeu(t, NACH_VORLAGE_FEHLT)
else zeigeFehler(form, { fehler: { _: NACH_VORLAGE_LADEN } })
return
}
try {
await bestaetige(t)
} catch (err) {
if (sp.nochHier()) zeigeNeu(t, err.message)
return
}
if (!sp.nochHier()) return
ctx.meldung(`Die Branche „${branche.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
t.weiter()
} finally {
laeuft = false
if (knopf) knopf.disabled = false
}
})
wurzel.append(sp.konflikt, form)
return wurzel
}
/** Mit Entwurf: gewaehlte Branche (wechseln nur mit Rueckfrage), Betriebsangaben, „Weiter“ speichert sie und bestaetigt den Schritt. */
function brancheMitEntwurf(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const branche = (ctx.daten.branchen || []).find((b) => b.key === ctx.daten.fragebogen?.branche)
const wurzel = rahmen(t, 'Prüfe die Angaben zu deinem Betrieb. Die Branche bestimmt, welche Vorschläge für Leistungen, Zeiten und Regeln du siehst.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const angaben = betrieb(ctx, e, { nur: BETRIEB_NUR })
const wechsel = wechselFrage(t, sp, angaben, branche !== undefined)
const form = schrittForm(t, angaben.el, allgemeinerFehler(), knoepfe(t))
verdrahte(t, form, sp, {
pruefe: () => (branche ? null : { _: 'Bitte wähle zuerst deine Branche.' }),
aendere: (entwurf) => { angaben.lese(entwurf); return true },
})
wurzel.append(sp.konflikt,
h('div', { class: 'tm-fb-branche' }, h('p', null, 'Deine Branche: ', h('strong', null, branche?.name ?? 'noch nicht gewählt')), wechsel.knopf),
wechsel.box,
form)
return wurzel
}
/**
* „Branche wechseln“ mit Rueckfrage in der Seite (kein confirm()): ersetzt Leistungen, Zeiten und Regeln und setzt alle Bestaetigungen zurueck
* (POST vorlage mit ersetzen: true und der gelesenen version). Die Betriebsangaben gehen mit; was der Server dabei nicht uebernimmt, wird nachgetragen.
* Danach steht Schritt 1 neu da, ein PUT gibt es nicht. => { knopf, box }: der Aufrufer setzt beide NEBEN das Formular des Schritts.
*/
function wechselFrage(t, sp, angaben, hatBranche) {
const { ctx } = t
const box = h('div', { class: 'tm-frage' })
const knopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'branche-wechseln' }, hatBranche ? 'Branche wechseln' : 'Branche wählen')
const zu = () => { box.replaceChildren(); knopf.focus() }
knopf.addEventListener('click', () => {
const wahl = brancheWahl(ctx.daten, { name: 'termine-branche-neu', knopf: 'Ja, Branche wechseln' })
const abbrechen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: zu }, 'Abbrechen')
const rueckfrage = frage('Branche wechseln?', h('p', null, WECHSEL_TEXT), wahl.liste, wahl.hinweis, allgemeinerFehler(), h('div', { class: 'tm-frage-knoepfe' }, wahl.aktion, abbrechen))
let laeuft = false
rueckfrage.addEventListener('submit', async () => {
const neu = wahl.gewaehlt()
if (laeuft || neu === null || neu.gesundheit) return
laeuft = true
const ok = rueckfrage.querySelector('[data-aktion="branche-starten"]')
if (ok) ok.disabled = true
try {
setzeFehler(rueckfrage, {})
const gewuenscht = { ...ctx.daten.einstellung.betrieb, ...angaben.lese() }
try {
await ctx.api.send('POST', '/einstellungen/vorlage', { branche: neu.key, betrieb: kontaktVon(gewuenscht), ersetzen: true, version: ctx.daten.version })
} catch (err) {
if (!sp.nochHier()) return
if (err.code === 'EINSTELLUNG_VERALTET') {
box.replaceChildren()
sp.konflikt.hidden = false // „Neu laden“ des Speichers
sp.konflikt.scrollIntoView({ block: 'nearest' })
} else zeigeFehler(rueckfrage, err)
return
}
const vollstaendig = await nachVorlage(t, sp, rueckfrage, gewuenscht)
if (!sp.nochHier()) return
if (vollstaendig) t.gehe('branche')
else zeigeNeu(t, NACH_VORLAGE_FEHLT)
} finally {
laeuft = false
if (ok) ok.disabled = false
}
})
box.replaceChildren(rueckfrage)
rueckfrage.focus()
})
return { knopf, box }
}
/* ------------------------------------------------------ Schritt 2: Wer wird gebucht --- */
/** Vorbelegung der Art: nur Plaetze ⇒ Plaetze, mehr als ein Eintrag ⇒ Team, sonst Einzelperson. */
function vorgabeArt(ressourcen) {
if (ressourcen.length > 0 && ressourcen.every((r) => r.typ === 'platz')) return 'plaetze'
return ressourcen.length > 1 ? 'team' : 'einzel'
}
function schrittWer(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Trag ein, wer oder was bei dir gebucht werden kann. Neue Personen oder Plätze können zunächst alle Leistungen anbieten, das änderst du im nächsten Schritt.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const ressourcen = ctx.daten.einstellung.ressourcen || []
const vorgabe = vorgabeArt(ressourcen)
const radios = WER_ARTEN.map(([wert, name, beschreibung]) => h('label', { class: 'tm-vorlage' },
h('input', { type: 'radio', name: 'termine-wer-art', value: wert, checked: wert === vorgabe }),
h('span', null, h('strong', null, name), h('small', null, beschreibung))))
const namen = h('textarea', { rows: '4', maxlength: String(LIMITS.ressourcen * (MAX.ressourcenName + 1)), autocomplete: 'off' }, ressourcen.map((r) => r.name).join('\n'))
const form = schrittForm(t,
h('div', { class: 'kb-panel' },
h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' }, h('legend', { class: 'tm-sr' }, 'Wer wird gebucht'), radios),
feld('ressourcen', 'Namen (einer pro Zeile)', namen, `Höchstens ${LIMITS.ressourcen} Einträge mit je höchstens ${MAX.ressourcenName} Zeichen. Bei einer Einzelperson trägst du nur einen Namen ein.`)),
allgemeinerFehler(),
knoepfe(t))
let gelesen = null
const art = () => form.querySelector('input[name="termine-wer-art"]:checked')?.getAttribute('value') ?? vorgabe
verdrahte(t, form, sp, {
pruefe: () => {
const zeilen = namen.value.split(/\r?\n/)
const gefuellt = zeilen.filter((zeile) => zeile.trim() !== '')
let fehler = null
if (gefuellt.length === 0) fehler = 'Bitte trag mindestens einen Namen ein.'
else if (art() === 'einzel' && gefuellt.length > 1) fehler = 'Für eine Einzelperson trägst du nur einen Namen ein. Wähle „Team“, wenn mehrere Personen gebucht werden können.'
else if (gefuellt.length > LIMITS.ressourcen) fehler = `Es sind höchstens ${LIMITS.ressourcen} Einträge möglich.`
else if (gefuellt.some((zeile) => [...zeile.trim()].length > MAX.ressourcenName)) fehler = `Ein Name darf höchstens ${MAX.ressourcenName} Zeichen lang sein.`
if (fehler) return { ressourcen: fehler }
gelesen = ressourcenAusNamen({ bisher: ctx.daten.einstellung.ressourcen, typ: art() === 'plaetze' ? 'platz' : 'person', namen: zeilen })
return null
},
// neue Eintraege bieten alle Leistungen an (der Validator verlangt eine aktive Ressource), entfernte verschwinden aus Leistungen und Abwesenheiten
aendere: (entwurf) => {
const { ressourcen: neu, entfernt, neu: hinzu } = gelesen
entwurf.ressourcen = neu
for (const leistung of entwurf.leistungen || []) leistung.ressourcen = [...(leistung.ressourcen || []).filter((id) => !entfernt.includes(id)), ...hinzu]
entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => !entfernt.includes(a.ressourceId))
return true
},
})
wurzel.append(sp.konflikt, form)
return wurzel
}
/* ---------------------------------------------------------- Schritt 3: Leistungen --- */
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Person oder einem aktiven Platz (wie sichtbareLeistungen auf dem Server). */
function sichtbareLeistungen(einstellung) {
const aktiv = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiv.has(id)))
}
const hatPreis = (leistung) => preisAnzeige(leistung) !== 'ohne Preis'
function schrittLeistungen(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Prüfe die Leistungen, die deine Gäste buchen können, und ändere sie, wie du sie brauchst. Änderungen an einer Leistung werden sofort gespeichert.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
// der Reiter Leistungen speichert selbst; sein Platz ist ein eigenes Element, das Formular des Schritts steht daneben (kein verschachteltes Formular)
const leistungen = h('div', { class: 'tm-fb-leistungen' })
renderLeistungen(leistungen, ctx)
const haken = h('input', { type: 'checkbox', checked: ctx.daten.fragebogen?.preiseGeprueft === true })
const form = schrittForm(t,
hakenFeld('preiseGeprueft', haken, 'Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)'),
h('p', { class: 'tm-fehler', 'data-fehler': 'leistungen', role: 'alert' }),
allgemeinerFehler(),
knoepfe(t))
verdrahte(t, form, sp, {
pruefe: () => {
const sichtbar = sichtbareLeistungen(ctx.daten.einstellung)
if (sichtbar.length === 0) return { leistungen: 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.' }
if (sichtbar.some(hatPreis) && !haken.checked) return { preiseGeprueft: 'Bitte bestätige, dass du die Preise geprüft hast.' }
return null
},
fragebogen: (f) => { f.preiseGeprueft = haken.checked },
})
wurzel.append(sp.konflikt, leistungen, form)
return wurzel
}
/* ------------------------------------------------- Schritt 7: Benachrichtigung --- */
function schrittBenachrichtigung(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Wohin sollen die Nachrichten zu Terminen gehen, wie sprichst du deine Gäste an und bekommen Gäste eine Erinnerung?')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const e = ctx.daten.einstellung
const abschnitte = [betrieb(ctx, e, { nur: ['email'], titel: 'Deine E-Mail-Adresse', hinweis: null }), darstellung(e), erinnerung(e)]
const form = schrittForm(t, abschnitte.map((a) => a.el), allgemeinerFehler(), knoepfe(t))
verdrahte(t, form, sp, {
aendere: (entwurf) => {
const fehler = {}
for (const a of abschnitte) a.lese(entwurf, fehler)
if (Object.keys(fehler).length === 0) return true
zeigeFehler(form, { fehler })
return false
},
})
wurzel.append(sp.konflikt, form)
return wurzel
}
/* ------------------------------------------------------ Schritte ohne Inhalt --- */
/** Platz fuer die Schritte, die C9b nachtraegt (4, 5, 6, 8): Ueberschrift, Hinweis und Zurueck, aber nichts zu bestaetigen. */
function schrittOhneInhalt(t) {
const wurzel = rahmen(t, 'Dieser Schritt ist noch nicht verfügbar.')
wurzel.append(knoepfe(t, { mitWeiter: false }))
return wurzel
}
const BAU = { branche: schrittBranche, wer: schrittWer, leistungen: schrittLeistungen, benachrichtigung: schrittBenachrichtigung }
+37
View File
@@ -227,6 +227,38 @@
.tm-statuskarte a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; }
.tm-danke { padding: 12px 16px; border-radius: 12px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); font-weight: 600; }
/* ------------------------------------------ Fragebogen (termine-fragebogen.js) --- */
.tm-fragebogen > * + * { margin-top: 16px; }
.tm-fragebogen > .tm-box { margin-bottom: 0; }
.tm-fortschritt { padding-bottom: 14px; border-bottom: 1px solid var(--kb-border); }
.tm-schritt-stand { display: none; margin: 0 0 10px; font-size: 14px; font-weight: 700; }
.tm-schritte { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
.tm-schritt { margin: 0; }
.tm-schritt-knopf {
display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 8px; border-radius: 999px; border: 1px solid transparent;
background: transparent; color: var(--kb-muted); font: inherit; font-size: 14px; font-weight: 700; text-align: left; cursor: pointer;
}
.tm-schritt-knopf:hover:not(:disabled) { color: #fff; background: rgba(255, 255, 255, 0.06); }
.tm-schritt-knopf:disabled { opacity: 0.5; cursor: not-allowed; }
.tm-schritt[aria-current='step'] .tm-schritt-knopf { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.tm-schritt-nr {
display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--kb-border-strong);
font-size: 12px; font-variant-numeric: tabular-nums;
}
.tm-schritt[data-erledigt] .tm-schritt-nr { color: #86efac; background: rgba(74, 222, 128, 0.1); border-color: rgba(74, 222, 128, 0.4); }
.tm-schritt[aria-current='step'] .tm-schritt-nr { color: #fff; background: var(--kb-purple); border-color: var(--kb-purple); }
.tm-fb-schritt > * + * { margin-top: 16px; }
.tm-fb-schritt > h3 { margin: 0; }
.tm-fb-schritt > h3:focus { outline: none; }
.tm-fb-schritt > .tm-abschnitt-text { margin-bottom: 0; line-height: 1.55; }
.tm-fb-form > * + * { margin-top: 16px; }
.tm-fb-form > .tm-box, .tm-fb-form .tm-fb-branchenhinweis .tm-box { margin-bottom: 0; }
.tm-fb-branchenhinweis:empty { display: none; }
.tm-fb-branchen { margin-bottom: 0; }
.tm-fb-branche { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; }
.tm-fb-branche p { margin: 0; }
.tm-fb-aufbewahrung { margin-top: 18px; }
/* ----------------------------------------------------------------- Handy --- */
@media (max-width: 880px), (pointer: coarse) {
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
@@ -236,6 +268,11 @@
.tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
.tm-haken input { margin-top: 0; }
.tm-link-feld, .tm-code-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */
/* Fragebogen: „Schritt n von 8“ und eine Reihe nummerierter Punkte statt der Titel (der Titel steht als Ueberschrift darunter) */
.tm-schritt-stand { display: block; }
.tm-schritte { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; }
.tm-schritt-knopf { width: 100%; justify-content: center; padding: 0; }
.tm-schritt-titel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
}
@media (max-width: 600px) {
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+800
View File
@@ -16,6 +16,7 @@ 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 * as bestellen from '../public/funktion-bestellen.js'
import * as fragebogenAnsicht from '../public/termine-fragebogen.js'
import { FENSTER_FEHLER } from '../public/termine-view.js'
import { h, request } from '../public/termine-dom.js'
import { neuerSpeicher } from '../public/termine-speichern.js'
@@ -1522,3 +1523,802 @@ test('statusKarte: Texte vom Server (Ticketnummer, Datum, Einrichtung) nur als T
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.ok(fx.text(wurzel).includes(boese))
}))
// ------------------------------------------------------- Fragebogen-Oberfläche: Gerüst und Schritte 1 bis 3 (TP2, Task C9a)
// renderFragebogen(root, ctx) in public/termine-fragebogen.js. Fake-DOM und Fake-fetch wie oben; ctx kommt aus baueCtx der Hülle. Ohne
// ausdrückliches neuLaden-Protokoll ersetzt ein Stummel neuLaden (er liest nichts nach), damit fetchLog genau die Schreibzugriffe zeigt.
const FB_GET = '/api/termine/einstellungen?projectId=p1'
const FB_PATCH = ['PATCH', '/api/termine/einstellungen']
const FB_PUT = ['PUT', '/api/termine/fragebogen']
const FB_POST = ['POST', '/api/termine/einstellungen/vorlage']
const BETRIEB_KONTO = { name: 'Salon Muster', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', email: 'salon@example.invalid', telefon: '06301 12345', websiteUrl: '' }
/** Antwort von GET /api/termine/einstellungen im Zustand off mit Entwurf (alles, was der Fragebogen aus den Daten liest). */
const fbDaten = (aenderung = {}) => ({
state: 'off',
einstellung: salon(),
version: 3,
fragebogen: { ...leerFragebogen(), branche: 'salon' },
branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })),
vorlagen: [{ key: 'salon', name: 'Friseur und Salon', beschreibung: 'Haarschnitt, Färben und Bartpflege.' }],
kundenansicht: false,
fehlt: [],
abo: { ok: true },
offen: [],
bestellung: null,
...aenderung,
})
/** Daten ohne Entwurf: der Kunde sieht die Branchenwahl. */
const fbStart = (aenderung = {}) => fbDaten({ einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO, ...aenderung })
/** Fragebogen, dessen erste n Schritte bestätigt sind. */
const fbBis = (n, aenderung = {}) => ({ ...leerFragebogen(), branche: 'salon', bestaetigt: SCHRITTE.slice(0, n), ...aenderung })
const fbAntwort = (n, aenderung = {}) => ({ body: { fragebogen: fbBis(n, aenderung), offen: [] } })
const fbPatchAntwort = (version = 4, einstellung = salon()) => ({ body: { einstellung, version, fehlt: [] } })
/** Fragebogen in den Baum hängen. => { wurzel, ctx, s }; stumm: neuLaden liest nichts nach (Standard). */
function fbSeite(daten, { stumm = true } = {}) {
const wurzel = h('div')
document.body.append(wurzel)
const s = { daten, nurLesen: false, meldungEl: h('div', { tabindex: '-1' }) }
document.body.append(s.meldungEl)
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true })
if (stumm) ctx.neuLaden = async () => ctx.daten
fragebogenAnsicht.renderFragebogen(wurzel, ctx)
return { wurzel, ctx, s }
}
const fbSchritt = (fx, wurzel) => fx.finde(wurzel, 'li[aria-current="step"]')?.dataset.schritt
const fbWeiter = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="weiter"]')
const fbAufrufe = (fx) => fx.fetchLog.map((a) => [a.method, a.url])
const fbKnoepfe = (fx, wurzel) => fx.alle(wurzel, 'button').map((k) => fx.text(k))
const fbRadio = (fx, baum, wert) => fx.finde(baum, `input[type="radio"][value="${wert}"]`)
const fbGehe = (fx, wurzel, key) => fx.klick(fx.finde(wurzel, `li[data-schritt="${key}"] button`))
const fbFeld = (fx, baum, name) => fx.finde(baum, `[data-feld="${name}"]`)
const fbFehler = (fx, baum, name) => fx.text(fx.finde(baum, `[data-fehler="${name}"]`))
// ------------------------------------------------------- Gerüst
test('renderFragebogen: Fortschrittsleiste ist eine <ol> mit neun Punkten, genau einer trägt aria-current="step"', mitDom(undefined, (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
const liste = fx.finde(wurzel, 'ol.tm-schritte')
assert.ok(liste, 'geordnete Liste')
const punkte = fx.alle(liste, 'li.tm-schritt')
assert.deepEqual(punkte.map((li) => li.dataset.schritt), [...SCHRITTE, 'bestellen'])
assert.deepEqual(punkte.map((li) => fx.text(fx.finde(li, '.tm-schritt-titel'))), [...SCHRITTE.map((key) => SCHRITT_TITEL[key]), SCHRITT_TITEL.bestellen])
assert.deepEqual(fx.alle(wurzel, '[aria-current]').map((el) => [el.tagName, el.getAttribute('aria-current'), el.dataset.schritt]), [['LI', 'step', 'leistungen']])
assert.deepEqual(punkte.map((li) => li.hasAttribute('data-erledigt')), [true, true, false, false, false, false, false, false, false], 'erledigt: nur die bestätigten')
assert.match(fx.text(punkte[0]), /erledigt/, 'auch für den Screenreader')
}))
test('renderFragebogen: Handy-Zähler „Schritt n von 8“ und Titel des Schritts als Überschrift', mitDom(undefined, (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
assert.equal(fx.text(fx.finde(wurzel, '.tm-schritt-stand')), 'Schritt 3 von 8')
assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Leistungen')
const { wurzel: start } = fbSeite(fbStart())
assert.equal(fx.text(fx.finde(start, '.tm-schritt-stand')), 'Schritt 1 von 8')
}))
test('renderFragebogen: Start beim ersten offenen Schritt (die erste Lücke, nicht die Zahl der Haken)', mitDom(undefined, (fx) => {
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(0) })).wurzel), 'branche')
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(2) })).wurzel), 'leistungen')
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(6) })).wurzel), 'benachrichtigung')
const luecke = { ...fbBis(0), bestaetigt: ['branche', 'leistungen', 'zeiten'] }
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: luecke })).wurzel), 'wer', 'Lücke in der Mitte')
assert.equal(fbSchritt(fx, fbSeite(fbStart()).wurzel), 'branche', 'ohne Entwurf')
}))
test('renderFragebogen: sind alle Schritte bestätigt, steht die Bestellseite da', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = fbSeite(daten)
assert.equal(fbSchritt(fx, wurzel), 'bestellen')
assert.ok(fx.finde(wurzel, '.tm-bestellen'), 'renderBestellen hat die Seite gezeichnet')
assert.equal(fx.text(bestellKnopf(fx, wurzel)), daten.bestellung.consent.knopf)
assert.equal(fx.text(fx.finde(wurzel, '.tm-schritt-stand')), 'Prüfen und bestellen')
assert.equal(fx.alle(wurzel, '.tm-schritt[data-erledigt]').length, 8)
assert.deepEqual(fx.fetchLog, [], 'nichts nachgeladen')
}))
test('renderFragebogen: „Ändern“ und „Zum Schritt“ der Bestellseite springen in den Schritt', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ offen: [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }] })
const { wurzel } = fbSeite(daten)
await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"][data-schritt="zeiten"]'))
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
assert.equal(fx.finde(wurzel, '.tm-bestellen'), null, 'die Bestellseite ist weg')
assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Zeiten')
}))
test('renderFragebogen: freie Navigation über die Liste; der Fokus geht auf die Schrittüberschrift', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
await fx.klick(fx.finde(wurzel, 'li[data-schritt="wer"] button'))
assert.equal(fbSchritt(fx, wurzel), 'wer')
const kopf = fx.finde(wurzel, 'h3')
assert.equal(fx.text(kopf), 'Wer wird gebucht')
assert.equal(kopf.getAttribute('tabindex'), '-1')
assert.ok(document.activeElement === kopf)
assert.deepEqual(fx.fetchLog, [], 'Navigation allein ruft nichts auf')
}))
test('renderFragebogen: die Leiste führt auch zur Bestellseite; dort geht der Fokus auf die Überschrift', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(await bestellDaten({ fragebogen: fbBis(2) }))
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
await fx.klick(fx.finde(wurzel, 'li[data-schritt="bestellen"] button'))
assert.equal(fbSchritt(fx, wurzel), 'bestellen')
assert.ok(fx.finde(wurzel, '.tm-bestellen'))
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift der Bestellseite')
assert.deepEqual(fx.fetchLog, [])
}))
test('renderFragebogen: der erste Aufruf nimmt keinen Fokus, nur der Wechsel', mitDom(undefined, (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
assert.ok(document.activeElement !== fx.finde(wurzel, 'h3'))
}))
test('renderFragebogen: ohne Entwurf führt die Liste nur in die Branchenwahl (die anderen Schritte brauchen eine Einstellung)', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbStart())
const knoepfe = fx.alle(wurzel, '.tm-schritt button')
assert.deepEqual(knoepfe.map((k) => k.disabled), [false, true, true, true, true, true, true, true, true])
await fx.klick(knoepfe[3])
assert.equal(fbSchritt(fx, wurzel), 'branche')
}))
test('renderFragebogen: Hinweis zur Aufbewahrung des Entwurfs steht auf jedem Schritt', mitDom(undefined, async (fx) => {
const HINWEIS = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.'
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
assert.ok(fx.text(wurzel).includes(HINWEIS))
await fx.klick(fx.finde(wurzel, 'li[data-schritt="bestellen"] button'))
assert.ok(fx.text(wurzel).includes(HINWEIS), 'auch auf der Bestellseite')
assert.ok(fx.text(fbSeite(fbStart()).wurzel).includes(HINWEIS), 'auch bei der Branchenwahl')
}))
test('renderFragebogen: Kundenansicht zeigt den Hinweis, sonst nicht', mitDom(undefined, (fx) => {
const mit = fbSeite(fbDaten({ kundenansicht: true })).wurzel
assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/)
assert.equal(fx.finde(fbSeite(fbDaten()).wurzel, '.tm-box[role="note"]'), null)
}))
test('Schritte ohne Inhalt (4, 5, 6, 8 kommen mit C9b): Titel, Hinweis, Zurück, kein Weiter und kein Aufruf', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(3) }))
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Zeiten')
assert.equal(fbWeiter(fx, wurzel), null)
await fx.klick(fx.finde(wurzel, '[data-aktion="zurueck"]'))
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
assert.deepEqual(fx.fetchLog, [])
}))
// ------------------------------------------------------- Schritt 1: Branche und Betrieb, ohne Entwurf
test('Schritt 1 ohne Entwurf: Radiogruppe aus daten.branchen, Betrieb mit Vorschlag, Knopf „Mit dieser Branche starten“ gesperrt bis zur Wahl', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbStart())
const radios = fx.alle(wurzel, 'input[type="radio"]')
assert.deepEqual(radios.map((r) => r.getAttribute('value')), BRANCHEN.map((b) => b.key))
assert.equal(new Set(radios.map((r) => r.name)).size, 1, 'eine Gruppe')
assert.ok(radios.every((r) => !r.checked), 'keine Vorauswahl')
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /starten|Weiter/.test(t)), ['Mit dieser Branche starten'])
const start = fx.finde(wurzel, '[data-aktion="branche-starten"]')
assert.equal(start.disabled, true)
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.name', 'betrieb.adresse', 'betrieb.kleinunternehmer'], 'nur diese drei Betriebsfelder')
assert.equal(fbFeld(fx, wurzel, 'betrieb.name').value, BETRIEB_KONTO.name, 'betriebVorschlag')
assert.equal(fbFeld(fx, wurzel, 'betrieb.adresse').value, BETRIEB_KONTO.adresse)
assert.equal(fbFeld(fx, wurzel, 'betrieb.kleinunternehmer').checked, false)
await fx.klick(fbRadio(fx, wurzel, 'salon'))
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]').disabled, false)
assert.ok(fx.text(wurzel).includes('Friseur und Salon'))
assert.ok(fx.text(wurzel).includes('Haarschnitt, Färben und Bartpflege.'), 'Beschreibung der Vorlage')
}))
test('Schritt 1 (a): Gesundheitsbereich zeigt den Erklärtext statt „Weiter“, es gibt keinen Start-Knopf und es geht keine Anfrage raus', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbStart())
await fx.klick(fbRadio(fx, wurzel, 'salon'))
assert.ok(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
await fx.klick(fbRadio(fx, wurzel, 'gesundheit'))
const hinweis = fx.finde(wurzel, '[data-hinweis="gesundheit"]')
assert.ok(hinweis, 'der Erklärtext steht im Baum')
assert.match(fx.text(hinweis), /Gesundheitsbereich/)
assert.match(fx.text(hinweis), /nicht verfügbar/)
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]'), null)
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Mit dieser Branche starten|Weiter/.test(t)), [])
// auch ein Absenden des Formulars (Enter in einem Feld) ruft nichts auf
await fx.sende(fx.finde(wurzel, 'form'), 'submit')
await fx.klick(fx.finde(wurzel, 'li[data-schritt="branche"] button'))
assert.deepEqual(fx.fetchLog, [], 'kein einziger Aufruf')
// zurück zu einer anderen Branche: Knopf wieder da, Erklärtext weg
await fx.klick(fbRadio(fx, wurzel, 'kosmetik'))
assert.ok(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fx.finde(wurzel, '[data-hinweis="gesundheit"]'), null)
}))
test('Schritt 1 starten: POST vorlage { branche, betrieb }, danach Neuladen und PUT mit „branche“ in bestaetigt, weiter zu Schritt 2', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
fbAntwort(1),
],
}, async (fx) => {
const { wurzel, ctx } = fbSeite(fbStart(), { stumm: false })
await fx.setze(fbFeld(fx, wurzel, 'betrieb.name'), 'Salon Neu')
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PUT])
assert.deepEqual(fx.fetchLog[0].body, { projectId: 'p1', branche: 'salon', betrieb: { name: 'Salon Neu', adresse: BETRIEB_KONTO.adresse, kleinunternehmer: false } })
assert.equal(fx.fetchLog[2].body.projectId, 'p1')
assert.deepEqual(fx.fetchLog[2].body.fragebogen.bestaetigt, ['branche'])
assert.equal(fx.fetchLog[2].body.fragebogen.branche, 'salon')
assert.equal(fbSchritt(fx, wurzel), 'wer')
assert.deepEqual(ctx.daten.fragebogen.bestaetigt, ['branche'], 'die Antwort des PUT gilt')
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'))
}))
test('Schritt 1 starten: die Website des Projekts aus dem Vorschlag geht mit in betrieb (nur der Vorschlag kennt sie)', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
fbAntwort(1),
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart({ betriebVorschlag: { ...BETRIEB_KONTO, websiteUrl: 'https://salon.example.invalid' } }), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fx.fetchLog[0].body.betrieb.websiteUrl, 'https://salon.example.invalid')
assert.deepEqual(Object.keys(fx.fetchLog[0].body.betrieb).sort(), ['adresse', 'kleinunternehmer', 'name', 'websiteUrl'])
}))
test('Schritt 1 starten: übernimmt der Server „Kleinunternehmer“ nicht (er kennt nur fünf Betriebsfelder), geht es in einem Nachtrag-PATCH nach', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
fbPatchAntwort(2),
{ body: fbDaten({ version: 2, fragebogen: fbBis(0) }) },
fbAntwort(1),
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart(), { stumm: false })
await fx.klick(fbFeld(fx, wurzel, 'betrieb.kleinunternehmer'))
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PATCH, ['GET', FB_GET], FB_PUT])
assert.equal(fx.fetchLog[0].body.betrieb.kleinunternehmer, true)
assert.equal(fx.fetchLog[2].body.version, 1)
assert.equal(fx.fetchLog[2].body.einstellung.betrieb.kleinunternehmer, true)
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 1 starten: ein Fehler des Servers steht am Feld, danach kein Neuladen und kein PUT, der Knopf ist wieder frei', mitDom({
antworten: [{ status: 400, body: { error: 'Bitte gib den Namen an.', fehler: { 'betrieb.name': 'Bitte gib den Namen deines Betriebs an.' } } }],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart(), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST])
assert.equal(fbFehler(fx, wurzel, 'betrieb.name'), 'Bitte gib den Namen deines Betriebs an.')
assert.equal(fbFeld(fx, wurzel, 'betrieb.name').getAttribute('aria-invalid'), 'true')
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]').disabled, false)
assert.equal(fbSchritt(fx, wurzel), 'branche')
}))
test('Schritt 1 starten in der Kundenansicht: POST und Neuladen ja, PUT nein, weiter zu Schritt 2', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0), kundenansicht: true }) },
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart({ kundenansicht: true }), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET]])
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 1 starten: Fehlschlag des PUT nach angelegtem Entwurf zeichnet den Schritt mit Entwurf neu und meldet den Fehler', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
{ status: 500, body: { error: 'Fehler 500' } },
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart(), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fbSchritt(fx, wurzel), 'branche')
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]'), null, 'der Entwurf besteht: keine zweite Anlage')
assert.ok(fbWeiter(fx, wurzel), 'stattdessen „Weiter“')
assert.equal(fbFehler(fx, wurzel, '_'), 'Fehler 500')
}))
// ------------------------------------------------------- Schritt 1 mit Entwurf: Branche wechseln
test('Schritt 1 mit Entwurf: gewählte Branche, Betriebsfelder, „Weiter“ speichert in einem PATCH und bestätigt mit PUT', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(1)],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten())
assert.match(fx.text(fx.finde(wurzel, '.tm-fb-branche')), /Friseur und Salon/)
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.name', 'betrieb.adresse', 'betrieb.kleinunternehmer'])
assert.equal(fx.alle(wurzel, 'input[type="radio"]').length, 0, 'die Branche wechselt man nur über die Rückfrage')
await fx.setze(fbFeld(fx, wurzel, 'betrieb.adresse'), 'Neue Straße 2, 67655 Kaiserslautern')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
assert.equal(fx.fetchLog[0].body.einstellung.betrieb.adresse, 'Neue Straße 2, 67655 Kaiserslautern')
assert.equal(fx.fetchLog[0].body.version, 3)
assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, ['branche'])
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 1 mit Entwurf: ohne gespeicherte Branche kein Aufruf, der Hinweis steht allgemein', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: { ...leerFragebogen(), branche: null } }))
assert.equal(fx.text(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')), 'Branche wählen')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.match(fbFehler(fx, wurzel, '_'), /Branche/)
}))
test('Branche wechseln: Rückfrage in der Seite mit dem Wortlaut, Abbrechen ruft nichts auf', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(3) }))
await fbGehe(fx, wurzel, 'branche')
assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null)
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
assert.ok(frage, 'Rückfrage')
assert.match(fx.text(frage), /ersetzt deine Leistungen, Zeiten und Regeln/)
assert.match(fx.text(frage), /Bestätigungen gehen zurück/)
assert.ok(document.activeElement === frage)
assert.equal(frage.closest('form[aria-labelledby]'), null, 'nicht in das Formular des Schritts geschachtelt')
assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]').disabled, true, 'bis zur Wahl gesperrt')
await fx.klick(fx.alle(frage, 'button').find((k) => fx.text(k) === 'Abbrechen'))
assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null)
assert.deepEqual(fx.fetchLog, [])
assert.ok(document.activeElement === fx.finde(wurzel, '[data-aktion="branche-wechseln"]'), 'Fokus zurück auf den Knopf')
}))
test('Branche wechseln: „Ja, Branche wechseln“ sendet ersetzen: true mit version, übernimmt den Betrieb und zeigt Schritt 1 neu; kein PUT', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 6, fehlt: [], fragebogen: fbBis(0, { branche: 'kosmetik' }) } },
// der Server übernimmt Impressum/Datenschutz/AGB/Kleinunternehmer nicht: im neuen Entwurf fehlen sie
{ body: fbDaten({ version: 6, fragebogen: fbBis(0, { branche: 'kosmetik' }), einstellung: { ...salon(), betrieb: { ...salon().betrieb, impressumUrl: '', datenschutzUrl: '' } } }) },
fbPatchAntwort(7),
{ body: fbDaten({ version: 7, fragebogen: fbBis(0, { branche: 'kosmetik' }) }) },
],
}, async (fx) => {
const daten = fbDaten({ fragebogen: fbBis(3), version: 5 })
const { wurzel } = fbSeite(daten, { stumm: false })
await fbGehe(fx, wurzel, 'branche')
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'kosmetik'))
await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PATCH, ['GET', FB_GET]])
const post = fx.fetchLog[0].body
assert.equal(post.ersetzen, true)
assert.equal(post.version, 5)
assert.equal(post.branche, 'kosmetik')
assert.equal(post.projectId, 'p1')
assert.equal(post.betrieb.name, BETRIEB.name)
assert.equal(post.betrieb.telefon, BETRIEB.telefon)
assert.equal(post.betrieb.email, BETRIEB.email)
assert.equal(post.betrieb.adresse, BETRIEB.adresse)
assert.equal(fx.fetchLog[2].body.einstellung.betrieb.impressumUrl, BETRIEB.impressumUrl, 'Nachtrag: was der Server nicht übernimmt, kommt zurück')
assert.equal(fx.fetchLog[2].body.einstellung.betrieb.datenschutzUrl, BETRIEB.datenschutzUrl)
assert.equal(fx.fetchLog[2].body.version, 6)
assert.equal(fbSchritt(fx, wurzel), 'branche', 'Schritt 1 neu, alle Bestätigungen sind weg')
assert.equal(fx.alle(wurzel, '.tm-schritt[data-erledigt]').length, 0)
assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null)
}))
test('Branche wechseln: Gesundheitsbereich in der Rückfrage ⇒ Erklärtext, kein Bestätigen-Knopf, kein Aufruf', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten())
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'gesundheit'))
assert.ok(fx.finde(frage, '[data-hinweis="gesundheit"]'))
assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]'), null)
await fx.sende(frage, 'submit')
assert.deepEqual(fx.fetchLog, [])
}))
test('Branche wechseln: 409 EINSTELLUNG_VERALTET zeigt den Hinweis „Neu laden“, die Rückfrage bleibt ohne Folgeaufruf', mitDom({
antworten: [{ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert.', code: 'EINSTELLUNG_VERALTET' } }],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten(), { stumm: false })
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'kosmetik'))
await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST])
const konflikt = fx.finde(wurzel, '.tm-box-warn[role="alert"]')
assert.equal(konflikt.hidden, false)
assert.match(fx.text(konflikt), /Neu laden/)
}))
test('Branche wechseln: ein anderer Fehler steht in der Rückfrage', mitDom({
antworten: [{ status: 409, body: { error: 'Die Branche lässt sich jetzt nicht mehr wechseln.', code: 'nicht_ersetzbar' } }],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten(), { stumm: false })
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'kosmetik'))
await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]'))
assert.equal(fbFehler(fx, frage, '_'), 'Die Branche lässt sich jetzt nicht mehr wechseln.')
assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]').disabled, false)
}))
// ------------------------------------------------------- Schritt 2: Wer wird gebucht
const fbWerSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(1), ...aenderung }))
const fbNamen = (fx, wurzel) => fbFeld(fx, wurzel, 'ressourcen')
test('Schritt 2: Einzelperson / Team / Plätze, vorbelegt aus den Ressourcen; die Namen stehen je Zeile', mitDom(undefined, async (fx) => {
const { wurzel } = fbWerSeite()
assert.equal(fbSchritt(fx, wurzel), 'wer')
assert.deepEqual(fx.alle(wurzel, 'input[name="termine-wer-art"]').map((r) => r.getAttribute('value')), ['einzel', 'team', 'plaetze'])
assert.equal(fbRadio(fx, wurzel, 'einzel').checked, true, 'eine Person: Einzelperson')
assert.equal(fbNamen(fx, wurzel).value, 'Stylist/in')
const e = salon()
e.ressourcen = zweiRessourcen()
const team = fbWerSeite({ einstellung: e }).wurzel
assert.equal(fbRadio(fx, team, 'team').checked, true)
assert.equal(fbNamen(fx, team).value, 'Anna\nBen')
const plaetze = salon()
plaetze.ressourcen = zweiRessourcen().map((r) => ({ ...r, typ: 'platz' }))
assert.equal(fbRadio(fx, fbWerSeite({ einstellung: plaetze }).wurzel, 'plaetze').checked, true)
}))
test('Schritt 2 (f): die Namen „Anna“ und „Ben“ ⇒ der PATCH enthält beide Ressourcen und jede Leistung ist beiden zugeordnet', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(2)],
}, async (fx) => {
const { wurzel, ctx } = fbWerSeite()
const alt = ctx.daten.einstellung.ressourcen[0]
await fx.klick(fbRadio(fx, wurzel, 'team'))
await fx.setze(fbNamen(fx, wurzel), 'Anna\nBen')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
const einstellung = fx.fetchLog[0].body.einstellung
assert.deepEqual(einstellung.ressourcen.map((r) => [r.name, r.typ, r.aktiv]), [['Anna', 'person', true], ['Ben', 'person', true]])
assert.equal(einstellung.ressourcen[0].id, alt.id, 'der erste Eintrag wird umbenannt: ID bleibt')
assert.deepEqual(einstellung.ressourcen[1].plan, einstellung.ressourcen[0].plan, 'der neue Eintrag übernimmt den Plan')
const ids = einstellung.ressourcen.map((r) => r.id)
assert.equal(new Set(ids).size, 2)
assert.ok(einstellung.leistungen.length > 0)
for (const l of einstellung.leistungen) assert.deepEqual([...l.ressourcen].sort(), [...ids].sort(), `${l.name}: beiden zugeordnet`)
assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, ['branche', 'wer'])
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
}))
test('Schritt 2: entfernte Personen verschwinden aus den Leistungen und aus den Abwesenheiten; eine Abwesenheit für alle bleibt', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(2)],
}, async (fx) => {
const e = salon()
e.ressourcen = zweiRessourcen()
for (const l of e.leistungen) l.ressourcen = ['r_anna0001', 'r_ben00002']
e.abwesenheiten = [
{ id: 'a_test0001', ressourceId: 'r_ben00002', von: '2026-12-24', bis: '2026-12-26', titel: 'Urlaub Ben' },
{ id: 'a_test0002', ressourceId: 'r_anna0001', von: '2027-01-02', bis: '2027-01-03', titel: 'Urlaub Anna' },
{ id: 'a_test0003', ressourceId: null, von: '2027-01-04', bis: '2027-01-04', titel: 'Inventur' },
]
const { wurzel } = fbWerSeite({ einstellung: e })
await fx.setze(fbNamen(fx, wurzel), 'Anna')
await fx.klick(fbRadio(fx, wurzel, 'einzel'))
await fx.klick(fbWeiter(fx, wurzel))
const einstellung = fx.fetchLog[0].body.einstellung
assert.deepEqual(einstellung.ressourcen.map((r) => r.id), ['r_anna0001'])
for (const l of einstellung.leistungen) assert.deepEqual(l.ressourcen, ['r_anna0001'])
assert.deepEqual(einstellung.abwesenheiten.map((a) => a.id), ['a_test0002', 'a_test0003'])
}))
test('Schritt 2: Plätze ⇒ typ platz für alle Einträge', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(2)] }, async (fx) => {
const { wurzel } = fbWerSeite()
await fx.klick(fbRadio(fx, wurzel, 'plaetze'))
await fx.setze(fbNamen(fx, wurzel), 'Stuhl 1\nStuhl 2\n\n Stuhl 3 ')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog[0].body.einstellung.ressourcen.map((r) => [r.name, r.typ]), [['Stuhl 1', 'platz'], ['Stuhl 2', 'platz'], ['Stuhl 3', 'platz']])
}))
test('Schritt 2: mindestens ein Name, sonst Fehler am Feld und kein Aufruf', mitDom(undefined, async (fx) => {
const { wurzel } = fbWerSeite()
await fx.setze(fbNamen(fx, wurzel), ' \n \n')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.match(fbFehler(fx, wurzel, 'ressourcen'), /mindestens einen Namen/)
assert.equal(fbNamen(fx, wurzel).getAttribute('aria-invalid'), 'true')
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 2: Einzelperson mit zwei Namen, zu viele Einträge und zu lange Namen werden am Feld abgewiesen', mitDom(undefined, async (fx) => {
const { wurzel } = fbWerSeite()
await fx.setze(fbNamen(fx, wurzel), 'Anna\nBen')
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'ressourcen'), /Einzelperson/)
await fx.klick(fbRadio(fx, wurzel, 'team'))
await fx.setze(fbNamen(fx, wurzel), Array.from({ length: LIMITS.ressourcen + 1 }, (_, i) => `Person ${i + 1}`).join('\n'))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'ressourcen'), new RegExp(String(LIMITS.ressourcen)))
await fx.setze(fbNamen(fx, wurzel), 'x'.repeat(MAX.ressourcenName + 1))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'ressourcen'), new RegExp(String(MAX.ressourcenName)))
assert.deepEqual(fx.fetchLog, [], 'nichts davon geht raus')
}))
// ------------------------------------------------------- Schritt 3: Leistungen
const fbLeistungenSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(2), ...aenderung }))
test('Schritt 3: der Reiter Leistungen steckt unverändert im Schritt, dazu der Haken „Preise geprüft“', mitDom(undefined, (fx) => {
const { wurzel } = fbLeistungenSeite()
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
assert.ok(fx.finde(wurzel, '[data-werkzeug="leistung-neu"]'), '+ Leistung anlegen')
assert.equal(fx.alle(wurzel, 'li.tm-eintrag[data-id]').length, salon().leistungen.length, 'die Leistungen der Vorlage')
const haken = fbFeld(fx, wurzel, 'preiseGeprueft')
assert.equal(haken.type, 'checkbox')
assert.equal(haken.checked, false)
assert.equal(fx.text(haken.closest('label')), 'Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)')
assert.equal(fbSeite(fbDaten({ fragebogen: fbBis(2, { preiseGeprueft: true }) })).wurzel.querySelector('[data-feld="preiseGeprueft"]').checked, true, 'wie gespeichert')
const form = fbWeiter(fx, wurzel).closest('form')
assert.equal(form.querySelector('form'), null)
assert.equal(fx.alle(form, '.tm-eintrag').length, 0, 'die Leistungen stehen nicht im Formular des Schritts (kein verschachteltes Formular)')
}))
test('Schritt 3 (c): mit Preisen ohne den Haken meldet „Weiter“ den Fehler am Haken und ruft nichts auf', mitDom(undefined, async (fx) => {
const { wurzel } = fbLeistungenSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.match(fbFehler(fx, wurzel, 'preiseGeprueft'), /Preise/)
assert.equal(fbFeld(fx, wurzel, 'preiseGeprueft').getAttribute('aria-invalid'), 'true')
assert.ok(document.activeElement === fbFeld(fx, wurzel, 'preiseGeprueft'))
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
}))
test('Schritt 3 (c): mit dem Haken genau ein PUT (kein PATCH) mit preiseGeprueft: true und dem Schritt in bestaetigt', mitDom({
antworten: [fbAntwort(3, { preiseGeprueft: true })],
}, async (fx) => {
const { wurzel } = fbLeistungenSeite()
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
assert.equal(fx.fetchLog[0].body.projectId, 'p1')
assert.equal(fx.fetchLog[0].body.fragebogen.preiseGeprueft, true)
assert.deepEqual(fx.fetchLog[0].body.fragebogen.bestaetigt, ['branche', 'wer', 'leistungen'])
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
}))
test('Schritt 3: ohne Preise braucht „Weiter“ den Haken nicht', mitDom({ antworten: [fbAntwort(3)] }, async (fx) => {
const e = salon()
for (const l of e.leistungen) Object.assign(l, { preisArt: 'keiner', preisCent: 0 })
const { wurzel } = fbLeistungenSeite({ einstellung: e })
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
assert.equal(fx.fetchLog[0].body.fragebogen.preiseGeprueft, false)
}))
test('Schritt 3: ohne sichtbare Leistung (alle inaktiv oder ohne aktive Person) meldet „Weiter“ den Fehler und ruft nichts auf', mitDom(undefined, async (fx) => {
const keine = salon()
keine.leistungen = []
const inaktiv = salon()
for (const l of inaktiv.leistungen) l.aktiv = false
const ohnePerson = salon()
ohnePerson.ressourcen[0].aktiv = false
for (const e of [keine, inaktiv, ohnePerson]) {
const { wurzel } = fbLeistungenSeite({ einstellung: e })
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'leistungen'), /mindestens eine aktive Leistung/)
}
assert.deepEqual(fx.fetchLog, [])
}))
test('Schritt 3: Leistungen, die der Kunde im Schritt ändert, zählen sofort (Prüfung liest ctx.daten frisch)', mitDom({ antworten: [fbAntwort(3)] }, async (fx) => {
const { wurzel, ctx } = fbLeistungenSeite()
ctx.daten.einstellung.leistungen = []
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'leistungen'), /mindestens eine aktive Leistung/)
ctx.daten.einstellung.leistungen = salon().leistungen.map((l) => ({ ...l, ressourcen: ctx.daten.einstellung.ressourcen.map((r) => r.id), preisArt: 'keiner', preisCent: 0 }))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
}))
// ------------------------------------------------------- „Weiter“ allgemein (Schritt 7 als Schritt mit Feldern)
const fbSiebenSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(6), ...aenderung }))
test('Schritt 7: Betrieb nur mit E-Mail, Anrede und Farbe, Erinnerung; Zurück und Weiter', mitDom(undefined, (fx) => {
const { wurzel } = fbSiebenSeite()
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.email'])
assert.ok(fbFeld(fx, wurzel, 'anrede'))
assert.ok(fbFeld(fx, wurzel, 'erinnerung.anbieten'))
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter'])
}))
test('„Weiter“ (b): genau ein PATCH, danach ein PUT mit dem Schritt in bestaetigt; PATCH und PUT in dieser Reihenfolge', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(7)],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'neu@example.invalid')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [FB_PATCH, FB_PUT])
const [patch, put] = fx.fetchLog
assert.equal(patch.body.projectId, 'p1')
assert.equal(patch.body.version, 3)
assert.equal(patch.body.einstellung.betrieb.email, 'neu@example.invalid')
assert.equal(put.body.projectId, 'p1')
assert.deepEqual(put.body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 6), 'benachrichtigung'])
assert.equal(fbSchritt(fx, wurzel), 'website')
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift des nächsten Schritts')
}))
test('„Weiter“ (b): liest der Speicher nach dem PATCH neu (GET), steht der PUT danach und trägt den frischen Fragebogen', mitDom({
antworten: [fbPatchAntwort(4), { body: fbDaten({ version: 4, fragebogen: fbBis(6, { bundeslandGewaehlt: true }) }) }, fbAntwort(7, { bundeslandGewaehlt: true })],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(6) }), { stumm: false })
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, ['GET', FB_GET], FB_PUT])
assert.equal(fx.fetchLog[2].body.fragebogen.bundeslandGewaehlt, true, 'der PUT baut auf dem frisch geladenen Stand auf')
}))
test('„Weiter“ (b): antwortet der PATCH mit 400 und fehler, folgt kein PUT, der Fehler steht am Feld, der Schritt bleibt', mitDom({
antworten: [{ status: 400, body: { error: 'Bitte gib eine gültige E-Mail-Adresse an.', fehler: { 'betrieb.email': 'Bitte gib eine gültige E-Mail-Adresse an.' } } }],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'kaputt')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'kein PUT')
assert.equal(fbFehler(fx, wurzel, 'betrieb.email'), 'Bitte gib eine gültige E-Mail-Adresse an.')
assert.equal(fbFeld(fx, wurzel, 'betrieb.email').getAttribute('aria-invalid'), 'true')
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
assert.equal(fbWeiter(fx, wurzel).disabled, false, 'wieder frei')
}))
test('„Weiter“: 409 EINSTELLUNG_VERALTET zeigt den Hinweis „Neu laden“ ohne PUT', mitDom({
antworten: [{ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert.', code: 'EINSTELLUNG_VERALTET' } }],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fx.finde(wurzel, '.tm-box-warn[role="alert"]').hidden, false)
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
}))
test('„Weiter“: schlägt der PUT fehl, bleibt der Schritt stehen und der Fehler steht im Formular', mitDom({
antworten: [fbPatchAntwort(4), { status: 409, body: { error: 'Es gibt noch keine Einstellungen.', code: 'keine_einstellung' } }],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
assert.equal(fbFehler(fx, wurzel, '_'), 'Es gibt noch keine Einstellungen.')
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
assert.equal(fbWeiter(fx, wurzel).disabled, false)
}))
test('„Weiter“: ein PUT-Fehler mit Feldfehlern steht am Feld, einer ohne Platz im Formular allgemein', mitDom({
antworten: [
{ status: 400, body: { error: 'Bitte „ja“ oder „nein“ wählen.', fehler: { preiseGeprueft: 'Bitte „ja“ oder „nein“ wählen.' } } },
{ status: 400, body: { error: 'Der Fragebogen ist ungültig.', fehler: { 'einbau.modus': 'Bitte wähle den Modus.' } } },
],
}, async (fx) => {
const { wurzel } = fbLeistungenSeite()
const form = fbWeiter(fx, wurzel).closest('form')
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbFehler(fx, form, 'preiseGeprueft'), 'Bitte „ja“ oder „nein“ wählen.')
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbFehler(fx, form, '_'), 'Bitte wähle den Modus.', 'ohne Feld im Formular: allgemein')
assert.equal(fbFehler(fx, form, 'preiseGeprueft'), '', 'der alte Fehler ist weg')
}))
test('„Weiter“: bleibt der Schritt stehen, hat der Knopf danach wieder den Fokus, der ihn beim Sperren verlieren kann; ein Feld mit Fehler behält ihn', mitDom({
antworten: [{ status: 409, body: { error: 'Es gibt noch keine Einstellungen.', code: 'keine_einstellung' } }],
}, async (fx) => {
const { wurzel } = fbLeistungenSeite({ fragebogen: fbBis(2, { preiseGeprueft: true }) })
const knopf = fbWeiter(fx, wurzel)
knopf.focus()
await fx.klick(knopf)
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
assert.ok(document.activeElement === knopf, 'wieder auf „Weiter“')
// Fehler am Feld: der Fokus gehört dem Feld
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
knopf.focus()
await fx.klick(knopf)
assert.ok(document.activeElement === fbFeld(fx, wurzel, 'preiseGeprueft'))
}))
test('„Weiter“: zwei Absendungen gleichzeitig ergeben einen PATCH und einen PUT', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(7)] }, async (fx) => {
const { wurzel } = fbSiebenSeite()
const form = fbWeiter(fx, wurzel).closest('form')
await Promise.all([fx.sende(form, 'submit'), fx.sende(form, 'submit')])
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
}))
test('„Weiter“: während des Speicherns ist der Knopf gesperrt', mitDom(undefined, async (fx) => {
let freigeben
const warten = new Promise((r) => { freigeben = r })
const { wurzel } = fbSiebenSeite()
fx.antworten.push(fbPatchAntwort(4), fbAntwort(7))
// die Antwort des PATCH wird zurückgehalten: solange sie fehlt, ist der Knopf gesperrt
const original = globalThis.fetch
globalThis.fetch = async (url, optionen) => { await warten; return original(url, optionen) }
const knopf = fbWeiter(fx, wurzel)
const klick = fx.klick(knopf)
await fx.ruhe()
assert.equal(knopf.disabled, true)
assert.equal(fx.finde(wurzel, 'section[data-schritt]').getAttribute('aria-busy'), 'true', 'der Schritt ist aria-busy')
freigeben()
await klick
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
assert.equal(fbSchritt(fx, wurzel), 'website')
}))
test('„Weiter“ (d): in der Kundenansicht sendet Schritt 7 den PATCH, aber keinen PUT und wechselt trotzdem zum nächsten Schritt', mitDom({ antworten: [fbPatchAntwort(4)] }, async (fx) => {
const { wurzel } = fbSiebenSeite({ kundenansicht: true })
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbSchritt(fx, wurzel), 'website')
}))
test('„Weiter“ (d): in der Kundenansicht geht Schritt 3 ohne jeden Aufruf weiter', mitDom(undefined, async (fx) => {
const { wurzel } = fbLeistungenSeite({ kundenansicht: true })
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
}))
test('Kundenansicht: die Bestellseite bleibt gesperrt, auch wenn alles bestätigt und angehakt ist', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(await bestellDaten({ kundenansicht: true }))
await alleAnhaken(fx, wurzel)
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
assert.match(fx.text(wurzel), /In der Kundenansicht kann nicht bestellt werden/)
}))
test('„Weiter“: verlässt der Kunde den Schritt oder die Ansicht, während der PATCH läuft, wird nichts mehr bestätigt oder gezeichnet', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(7)] }, async (fx) => {
const wurzel = h('div')
document.body.append(wurzel)
const s = { daten: fbDaten({ fragebogen: fbBis(6) }), nurLesen: false, meldungEl: h('div') }
let aktuell = true
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => aktuell })
ctx.neuLaden = async () => ctx.daten
fragebogenAnsicht.renderFragebogen(wurzel, ctx)
const klick = fx.klick(fbWeiter(fx, wurzel))
aktuell = false
await klick
assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'kein PUT für eine verlassene Ansicht')
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung', 'und kein Wechsel')
}))
test('Zurück: wechselt ohne Aufruf zum vorigen Schritt, im ersten Schritt gibt es den Knopf nicht', mitDom(undefined, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.klick(fx.finde(wurzel, '[data-aktion="zurueck"]'))
assert.equal(fbSchritt(fx, wurzel), 'gastfelder')
assert.deepEqual(fx.fetchLog, [])
assert.equal(fx.finde(fbSeite(fbDaten()).wurzel, '[data-aktion="zurueck"]'), null)
}))
test('Texte der Kunden stehen nur als Text im Baum (Namen, Vorschlag)', mitDom(undefined, (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const e = salon()
e.ressourcen[0].name = boese
const { wurzel } = fbWerSeite({ einstellung: e })
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.equal(fbNamen(fx, wurzel).value, boese)
const start = fbSeite(fbStart({ betriebVorschlag: { ...BETRIEB_KONTO, name: boese } })).wurzel
assert.equal(fx.alle(start, 'img').length, 0)
assert.equal(fbFeld(fx, start, 'betrieb.name').value, boese)
}))
// ------------------------------------------------------- Quelltext und Styles
test('termine-fragebogen.js statisch: nur diese Importe, ein einziger PUT, kein PATCH, kein Browserspeicher, Du-Form', () => {
const code = ohneKommentare(fbQuelleText())
assert.ok(code.length > 3000, 'die Datei ist gefüllt')
assert.deepEqual(code.match(/from '[^']+'/g), [
"from './termine-view.js'", "from './termine-dom.js'", "from './termine-speichern.js'", "from './termine-tab-leistungen.js'", "from './termine-tab-einstellungen.js'", "from './funktion-bestellen.js'",
])
assert.equal(code.match(/'PUT'/g).length, 1, 'PUT /fragebogen an genau einer Stelle')
assert.doesNotMatch(code, /'PATCH'/, 'PATCH nur über neuerSpeicher')
assert.doesNotMatch(code, /\bfetch\(|XMLHttpRequest/, 'Aufrufe nur über ctx.api')
assert.doesNotMatch(code, /localStorage|sessionStorage|document\.cookie|innerHTML|insertAdjacentHTML|outerHTML/)
assert.doesNotMatch(code, /\bSie\b|\bIhr(e[nmrs]?)?\b/)
assert.doesNotMatch(code, /\bhref\b/, 'kein Link in diesem Modul')
assert.match(code, /export function renderFragebogen\(root, ctx\)/)
assert.doesNotMatch(code, /import\s*\(/)
})
const fbQuelleText = () => readFileSync(new URL('../public/termine-fragebogen.js', import.meta.url), 'utf8')
test('termine.css: Fortschrittsleiste (tm-schritte, tm-schritt) und die Handy-Ansicht mit „Schritt n von 8“', () => {
const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8')
for (const klasse of ['tm-fragebogen', 'tm-schritte', 'tm-schritt', 'tm-schritt-knopf', 'tm-schritt-nr', 'tm-schritt-stand', 'tm-fb-schritt']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
assert.match(css, /\.tm-schritt\[aria-current='step'\]/, 'der aktuelle Schritt ist hervorgehoben')
assert.match(css.match(/\.tm-schritt-stand\s*\{[^}]*\}/)?.[0] ?? '', /display:\s*none/, 'am Rechner ohne Zähler')
const handy = css.slice(css.indexOf('@media (max-width: 880px)'))
assert.match(handy, /\.tm-schritt-stand\s*\{[^}]*display:\s*block/, 'am Handy zeigt die Leiste den Zähler')
assert.match(handy, /\.tm-schritt-titel\s*\{[^}]*(clip|position:\s*absolute)/, 'am Handy bleiben nur die Nummern sichtbar')
})
+13 -3
View File
@@ -318,7 +318,7 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp
test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort()
assert.deepEqual(dateien, [
'termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-gaestetexte.js', 'termine-speichern.js', 'termine-tab-einstellungen.js',
'termine-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, '')
@@ -339,10 +339,12 @@ test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser
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)\.js$/.test(f))) {
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`)
}
})
@@ -358,11 +360,19 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn
['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']) {
for (const datei of ['termine-tab-termine.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-einstellungen.js', 'termine-team-ausnahmen.js', 'termine-speichern.js', 'termine-aktionen.js', 'termine-formulare.js', 'termine-fragebogen.js']) {
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
}
})
test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f))
const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`)))
assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`)
for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`)
assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/)
})
test('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)