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

+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)