Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 21:06:42 +00:00
1 parent 0090332c70
commit 747be432c5
7 files changed
+1032 -59

No files matched your search

+3 -2
View File
@@ -112,8 +112,9 @@ export function neuerSpeicher(ctx, wurzel, neuZeichnen) {
try {
const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf })
if (!ctx.aktuell()) return false
// Der Server hat streng geprueft: Telefon und Anschrift fehlen nach einem Erfolg nie mehr
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: [] })
// fehlt (Pflichtangaben fuer Gaeste) nennt der Server in der Antwort: in aktiver Funktion ist es nach einem Erfolg leer, im Entwurf
// des Fragebogens koennen Telefon und Anschrift noch fehlen
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: antwort.fehlt ?? [] })
ctx.hinweise()
try {
await ctx.neuLaden() // neue Personen/Plaetze bekommen vom Server ihre Kalender-Links
+68 -37
View File
@@ -69,48 +69,79 @@ export function render(panel, ctx) {
}
/* ----------------------------------------------------------------- Betrieb --- */
function betrieb(ctx, e) {
const b = e.betrieb || {}
const text = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const url = (wert) => text(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
const f = {
name: text(b.name, MAX.betriebName), email: h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }), telefon: text(b.telefon, MAX.telefon),
adresse: text(b.adresse, MAX.adresse), impressumUrl: url(b.impressumUrl), datenschutzUrl: url(b.datenschutzUrl), agbUrl: url(b.agbUrl), websiteUrl: url(b.websiteUrl),
kleinunternehmer: h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
const textFeld = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const urlFeld = (wert) => textFeld(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
/** Die Angaben zum Betrieb in der Reihenfolge der Seite: Name, Mailadresse des Betriebs, Telefon, Anschrift, Kleinunternehmer, vier Links. */
const BETRIEB_FELDER = {
name: { eingabe: (b) => textFeld(b.name, MAX.betriebName), zeige: (i) => feld('betrieb.name', 'Name des Betriebs', i) },
email: {
eingabe: (b) => h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }),
zeige: (i) => feld('betrieb.email', 'E-Mail des Betriebs', i, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.'),
},
telefon: {
eingabe: (b) => textFeld(b.telefon, MAX.telefon),
zeige: (i) => feld('betrieb.telefon', 'Telefon (Pflicht)', i, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
},
adresse: {
eingabe: (b) => textFeld(b.adresse, MAX.adresse),
zeige: (i) => feld('betrieb.adresse', 'Anschrift', i, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.'),
},
kleinunternehmer: {
eingabe: (b) => h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
zeige: (i) => hakenFeld('betrieb.kleinunternehmer', i, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
},
impressumUrl: { eingabe: (b) => urlFeld(b.impressumUrl), zeige: (i) => feld('betrieb.impressumUrl', 'Link zum Impressum', i, 'Vollständige Adresse mit https://') },
datenschutzUrl: { eingabe: (b) => urlFeld(b.datenschutzUrl), zeige: (i) => feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', i, 'Vollständige Adresse mit https://') },
agbUrl: { eingabe: (b) => urlFeld(b.agbUrl), zeige: (i) => feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', i, 'Vollständige Adresse mit https://') },
websiteUrl: { eingabe: (b) => urlFeld(b.websiteUrl), zeige: (i) => feld('betrieb.websiteUrl', 'Link zu deiner Website', i, 'Vollständige Adresse mit https://') },
}
const fehlt = ctx.daten.fehlt || []
/** Wie die Felder auf der Seite zusammenstehen: Zeilen im Raster, das Ankreuzfeld steht allein. */
const BETRIEB_ZEILEN = [
{ felder: ['name', 'email'], raster: true },
{ felder: ['telefon', 'adresse'], raster: true },
{ felder: ['kleinunternehmer'], raster: false },
{ felder: ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl'], raster: true },
]
const BETRIEB_TITEL = 'Dein Betrieb'
const BETRIEB_HINWEIS = 'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.'
/**
* Abschnitt „Dein Betrieb“. Ohne Optionen alle neun Felder wie bisher. Der Fragebogen zeigt in einem Schritt nur einen Teil:
* nur = Teilmenge der Felder (name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl); es entstehen nur
* diese Felder (in der Reihenfolge der Seite) und lese schreibt nur diese in den Entwurf. titel und hinweis ersetzen Ueberschrift und Erklaersatz
* (hinweis: null laesst ihn weg). lese(entwurf) schreibt in entwurf.betrieb und gibt die gelesenen Felder zurueck (nur die gezeigten).
* Der Hinweis auf fehlende Pflichtangaben nennt mit nur nur Angaben, die dabei zu sehen sind.
*/
export function betrieb(ctx, e, { nur, titel = BETRIEB_TITEL, hinweis = BETRIEB_HINWEIS } = {}) {
const b = e.betrieb || {}
for (const name of nur || []) if (!Object.hasOwn(BETRIEB_FELDER, name)) throw new Error(`Unbekanntes Feld im Betrieb: ${name}`)
const gezeigt = Object.keys(BETRIEB_FELDER).filter((name) => !nur || nur.includes(name))
const f = Object.fromEntries(gezeigt.map((name) => [name, BETRIEB_FELDER[name].eingabe(b)]))
const fehlt = (ctx.daten.fehlt || []).filter((pfad) => !nur || gezeigt.includes(pfad.replace(/^betrieb\./, '')))
const warnung = fehlt.length
? h('div', { class: 'tm-box tm-box-warn', role: 'note' }, `Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: ${fehltText(fehlt) || 'eine Angabe zu deinem Betrieb'}.`)
: null
const el = abschnitt('Dein Betrieb',
'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.',
warnung,
h('div', { class: 'tm-raster' },
feld('betrieb.name', 'Name des Betriebs', f.name),
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
h('div', { class: 'tm-raster' },
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.')),
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
h('div', { class: 'tm-raster' },
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', f.datenschutzUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', f.agbUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.websiteUrl', 'Link zu deiner Website', f.websiteUrl, 'Vollständige Adresse mit https://')))
const zeilen = BETRIEB_ZEILEN
.map(({ felder, raster }) => {
const els = felder.filter((name) => f[name]).map((name) => BETRIEB_FELDER[name].zeige(f[name]))
if (!els.length) return null
return raster ? h('div', { class: 'tm-raster' }, els) : els
})
.filter(Boolean)
const el = abschnitt(titel, hinweis, warnung, ...zeilen)
return {
el,
lese(entwurf) {
entwurf.betrieb = {
...entwurf.betrieb,
name: f.name.value, email: f.email.value, telefon: f.telefon.value, adresse: f.adresse.value, kleinunternehmer: f.kleinunternehmer.checked,
impressumUrl: f.impressumUrl.value, datenschutzUrl: f.datenschutzUrl.value, agbUrl: f.agbUrl.value, websiteUrl: f.websiteUrl.value,
}
lese(entwurf = {}) {
const gelesen = Object.fromEntries(gezeigt.map((name) => [name, name === 'kleinunternehmer' ? f[name].checked : f[name].value]))
entwurf.betrieb = { ...entwurf.betrieb, ...gelesen }
return gelesen
},
}
}
/* -------------------------------------------------------- Anrede und Farbe --- */
function darstellung(e) {
export function darstellung(e) {
const anrede = h('select', null, wahl(ANREDE_WAHL, e.anrede ?? 'sie'))
const farbe = h('input', { type: 'color', value: /^#[0-9a-f]{6}$/.test(e.farbe) ? e.farbe : '#7c3aed' })
const el = abschnitt('Anrede und Farbe', 'So treten die Buchungsseite und die E-Mails an deine Gäste auf.',
@@ -121,7 +152,7 @@ function darstellung(e) {
}
/* ------------------------------------------------------------------ Regeln --- */
function regeln(e) {
export function regeln(e) {
const r = e.regeln || {}
const raster = h('select', null, wahl(RASTER_MIN.map((n) => [n, `${n} Minuten`]), r.raster ?? 15))
const vorlauf = zahlenFeld('regeln.vorlaufMin', 'Mindestvorlauf (Minuten)', r.vorlaufMin, { min: 0, max: 20160, hinweis: 'Wie kurzfristig darf gebucht werden? 120 sind 2 Stunden, höchstens 20160 (14 Tage).' })
@@ -146,7 +177,7 @@ function regeln(e) {
}
/* ------------------------------------------------- Angaben von Gaesten --- */
function gastFelder(ctx, e) {
export function gastFelder(ctx, e) {
const g = e.gastFelder || {}
const telefon = h('select', null, wahl(GAST_FELD_WAHL, g.telefon ?? 'optional'))
const nachricht = h('select', null, wahl(GAST_FELD_WAHL, g.nachricht ?? 'aus'))
@@ -210,7 +241,7 @@ function gastFelder(ctx, e) {
}
/* -------------------------------------------------------------- Erinnerung --- */
function erinnerung(e) {
export function erinnerung(e) {
const r = e.erinnerung || {}
const anbieten = h('input', { type: 'checkbox', checked: r.anbieten !== false })
const stunden = h('select', null, wahl(ERINNERUNG_STUNDEN.map((n) => [n, `${n} Stunden vorher`]), r.stundenVorher ?? 24))
@@ -221,7 +252,7 @@ function erinnerung(e) {
}
/* --------------------------------------------------------- Stornobedingungen --- */
function storno(ctx, e, fristFeld) {
export function storno(ctx, e, fristFeld) {
const s = e.storno || {}
const textfeld = h('textarea', { rows: '6', maxlength: String(MAX.storno) }, s.text ?? '')
// das Muster setzt die Frist ein, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
@@ -243,7 +274,7 @@ function storno(ctx, e, fristFeld) {
}
/* ------------------------------------------------------------- Rechtliches --- */
function rechtliches(e) {
export function rechtliches(e) {
const r = e.recht || {}
const gaeste = h('select', null, wahl([['verbraucher', 'Privatpersonen'], ['unternehmer', 'Unternehmen'], ['gemischt', 'Beides']], r.gaesteSind ?? 'verbraucher'))
const freizeit = h('input', { type: 'checkbox', checked: r.freizeit === true })
@@ -255,7 +286,7 @@ function rechtliches(e) {
}
/* ----------------------------------------------------------------- Bezahlung --- */
function zahlung(e) {
export function zahlung(e) {
const z = e.zahlung || {}
const arten = ZAHLARTEN.map(([key, name]) => ({ key, name, input: h('input', { type: 'checkbox', checked: (z.vorOrtArten || []).includes(key) }) }))
const el = abschnitt('Bezahlung', 'Bezahlt wird bei dir vor Ort. Welche Zahlarten nimmst du an? Gäste sehen das vor der Buchung.',
+32 -12
View File
@@ -60,6 +60,34 @@ function personen(t) {
: h('p', { class: 'tm-leer' }, 'Noch keine Person und kein Platz. Ohne sie kann niemand buchen.'))
}
/**
* Wochenplan einer Person/eines Platzes: je Wochentag ein Textfeld mit den Zeitfenstern (Text wie „09:00–12:30, 13:30–18:00“, leer = geschlossen),
* vorbelegt aus r.plan (r darf fehlen). pfad ist der Pfad der Person in der Einstellung ('ressourcen.1'); die Felder heissen `${pfad}.plan.${nr}`,
* so finden die Fehler des Servers (ressourcen.1.plan.3) ihr Feld. Der Editor einer Person und der Fragebogen (Schritt „Zeiten“) bauen ihn hiermit.
* => { el, lese }: el ist die Gruppe (Beschriftung, Hinweis, sieben Felder), lese() liefert { plan } (geschlossene Tage fehlen im Plan) oder, bei
* einem Fenster, das nicht zu lesen ist, { fehler } (Feldname ⇒ Text; dann gibt es keinen Teilplan). Pruefen im Einzelnen bleibt Sache des Servers.
*/
export function wochenplanFeld(r, pfad) {
const texte = planTexte(r?.plan)
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
nr, name: `${pfad}.plan.${nr}`,
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
tag,
}))
const el = h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
h('legend', { class: 'tm-label' }, 'Wochenplan'),
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
h('div', { class: 'tm-raster' }, tage.map(({ name, input, tag }) => feld(name, tag, input))))
return {
el,
lese() {
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
if (gelesen.fehler) return { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${pfad}.plan.${nr}`, text])) }
return { plan: gelesen.plan }
},
}
}
/**
* Editor einer neuen (r null) oder bestehenden Person/eines Platzes: Formular, darunter (nur bestehend) Kalender-Link und die Rueckfrage zum
* Entfernen. Die Rueckfragen stehen neben dem Formular, nicht darin: ein submit der Rueckfrage steigt sonst ins aeussere Formular auf.
@@ -78,12 +106,7 @@ function formular(t, { r, schliessen }) {
const name = h('input', { type: 'text', maxlength: MAX.ressourcenName, autocomplete: 'off', value: alt.name })
const typ = h('select', null, TYPEN.map(([wert, text]) => h('option', { value: wert, selected: alt.typ === wert }, text)))
const aktiv = h('input', { type: 'checkbox', checked: alt.aktiv })
const texte = planTexte(alt.plan)
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
nr, name: `${p}.plan.${nr}`,
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
tag,
}))
const wochenplan = wochenplanFeld(alt, p)
const zuordnung = leistungen.map((l, k) => ({ id: l.id, input: h('input', { type: 'checkbox', checked: (l.ressourcen || []).includes(alt.id) }), name: `leistungen.${k}.ressourcen`, label: `${l.name}${l.aktiv ? '' : ' (inaktiv)'}` }))
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Hinzufügen' : 'Speichern')
@@ -105,10 +128,7 @@ function formular(t, { r, schliessen }) {
feld(`${p}.name`, 'Name', name, 'So steht es beim Buchen, wenn Gäste eine Person wählen dürfen.'),
feld(`${p}.typ`, 'Art', typ)),
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Personen und Plätze können gebucht werden. Termine, die schon gebucht sind, bleiben bei „Inaktiv“ bestehen.'),
h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
h('legend', { class: 'tm-label' }, 'Wochenplan'),
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
h('div', { class: 'tm-raster' }, tage.map(({ name: feldName, input, tag }) => feld(feldName, tag, input)))),
wochenplan.el,
h('fieldset', { class: 'tm-gruppe-felder', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Diese Leistungen bietet sie an'),
zuordnung.length
@@ -124,9 +144,9 @@ function formular(t, { r, schliessen }) {
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
const gelesen = wochenplan.lese()
if (gelesen.fehler) {
zeigeFehler(form, { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${p}.plan.${nr}`, text])) })
zeigeFehler(form, { fehler: gelesen.fehler })
return false
}
const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan }
+19 -8
View File
@@ -2,7 +2,7 @@
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern.
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js,
termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js).
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden } (baueCtx): Zustand und Pruefung "bin ich noch
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen
(die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen,
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
@@ -164,15 +164,18 @@ function zeigeMeldung(s, text, art = 'ok') {
el.scrollIntoView({ block: 'nearest' })
}
function zeigeReiter(s, { projekt, aktuell }) {
/**
* Kontext fuer die Module, die in der Huelle laufen (die Reiter, der Fragebogen): Zustand und die Pruefung "bin ich noch aktuell?" kommen nur hierher.
* hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (der Fragebogen hat keine), zumReiter(key) wechselt den Reiter; beide duerfen fehlen.
* nurLesen und daten sind die der Huelle im Moment des Aufrufs; neuLaden() holt die Daten neu und ersetzt sie in s und im Kontext.
*/
export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = () => {} }) {
const projectId = projekt.projectId
const nurLesen = s.nurLesen
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const ctx = {
s,
projectId,
daten: s.daten,
nurLesen,
nurLesen: s.nurLesen,
auswahl,
aktuell,
api: {
@@ -183,18 +186,26 @@ function zeigeReiter(s, { projekt, aktuell }) {
},
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
hinweise: () => { if (aktuell()) zeichneHinweise() },
zumReiter: (key) => waehleReiter(key),
hinweise: () => { if (aktuell()) hinweise() },
zumReiter: (key) => zumReiter(key),
neuLaden: async () => {
const daten = await ladeDaten(projectId)
if (aktuell()) {
s.daten = daten
ctx.daten = daten
zeichneHinweise()
hinweise()
}
return daten
},
}
return ctx
}
function zeigeReiter(s, { projekt, aktuell }) {
const projectId = projekt.projectId
const nurLesen = s.nurLesen
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter })
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
const hinweisBox = h('div', { class: 'tm-hinweise' })
+525
View File
@@ -0,0 +1,525 @@
// Minimal-DOM und Fake-fetch fuer Tests des Portal-Clients (public/termine*.js, public/funktion-bestellen.js) ohne Browser. Nicht Teil von npm test.
//
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
//
// installFakeDom stellt `document`, `window` (nur location und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
//
// Elemente: createElement, setAttribute/getAttribute/hasAttribute/removeAttribute, append/prepend/appendChild/insertBefore/replaceChildren/remove,
// children/childNodes/parentNode/parentElement/firstChild/lastChild/firstElementChild/lastElementChild, className/classList/id/dataset/hidden/
// disabled/tabIndex/type/name, value/checked/selected (Eigenschaft ueberdeckt das Attribut wie im Browser; select.value ist die gewaehlte
// Option, ohne Wahl die erste), textContent, isConnected, closest/matches/querySelector/querySelectorAll, focus (document.activeElement)/blur/
// select/scrollIntoView, addEventListener/dispatchEvent (Ereignisse steigen zu den Eltern auf, preventDefault/stopPropagation).
// Selektoren: Tag, *, #id, .klasse, [attr], [attr="wert"], :not(…), :disabled, :enabled, :checked, Komma, Leerzeichen und >.
//
// Der Fake-fetch schreibt jeden Aufruf als { method, url, body } (body = geparstes JSON oder undefined) in fetchLog und antwortet mit dem naechsten
// Eintrag aus `antworten` ({ status, body } oder eine Funktion (aufruf) => { status, body }); ohne Eintrag 200 mit {}.
// klick, setze, sende und ruhe liefern ein Promise: die Wirkung der Ereignisse (auch von async-Handlern) ist erst danach da, also `await fx.klick(…)`.
// klick wirkt wie ein Klick im Browser (Haken und Radio schalten, „submit“-Knopf sendet das Formular, gesperrte Elemente tun nichts); ein Klick auf ein
// <label> schaltet seinen Haken NICHT: der Test klickt den Haken selbst. sende(el, typ, init) schickt ein beliebiges Ereignis (z. B. 'keydown', { key }).
import { setImmediate as naechsterTakt } from 'node:timers/promises'
/* --------------------------------------------------------------- CSS.escape --- */
/** CSS.escape nach der Spezifikation (die Client-Dateien bauen damit Attribut-Selektoren aus Feldnamen wie 'leistungen.2.dauer'). */
export function cssEscape(wert) {
const s = String(wert)
let aus = ''
for (let i = 0; i < s.length; i += 1) {
const c = s[i]
const code = s.charCodeAt(i)
if (code === 0) aus += '\uFFFD'
else if ((code >= 1 && code <= 31) || code === 127 || (i === 0 && c >= '0' && c <= '9') || (i === 1 && c >= '0' && c <= '9' && s[0] === '-')) aus += `\\${code.toString(16)} `
else if (i === 0 && c === '-' && s.length === 1) aus += `\\${c}`
else if (code >= 128 || c === '-' || c === '_' || /[0-9A-Za-z]/.test(c)) aus += c
else aus += `\\${c}`
}
return aus
}
const entescape = (text) => text.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_, hex, zeichen) => (hex ? String.fromCodePoint(parseInt(hex, 16)) : zeichen))
/* ---------------------------------------------------------------- Selektoren --- */
const STEUERUNGEN = new Set(['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'FIELDSET', 'OPTION'])
/** :disabled – eigenes Attribut oder ein gesperrtes fieldset darueber (wie im Browser). */
function istGesperrt(el) {
if (!STEUERUNGEN.has(el.tagName)) return false
if (el.hasAttribute('disabled')) return true
for (let p = el.parentNode; p && p.nodeType === 1; p = p.parentNode) if (p.tagName === 'FIELDSET' && p.hasAttribute('disabled')) return true
return false
}
const TEIL_QUELLE = /(\*|[A-Za-z][\w-]*)|#((?:\\.|[\w-])+)|\.((?:\\.|[\w-])+)|\[\s*([\w:-]+)\s*(?:(=)\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\]\s]*)))?\s*\]|:not\(([^)]*)\)|:([a-z-]+)/
/** Ein einfacher Selektor (ohne Leerzeichen/Komma) als Pruefung el => boolean. */
function einfach(text) {
const tests = []
const teil = new RegExp(TEIL_QUELLE.source, 'y') // je Aufruf eigener Zustand: :not(…) ruft einfach() verschachtelt auf
while (teil.lastIndex < text.length) {
const m = teil.exec(text)
if (!m) throw new Error(`fakeDom: Selektor nicht unterstuetzt: ${text}`)
const [, tag, id, klasse, attr, op, doppelt, einzeln, blank, nicht, pseudo] = m
if (tag) tests.push(tag === '*' ? () => true : (el) => el.tagName === tag.toUpperCase())
else if (id) tests.push((el) => el.id === entescape(id))
else if (klasse) tests.push((el) => el.classList.contains(entescape(klasse)))
else if (attr) {
const soll = op ? entescape(doppelt ?? einzeln ?? blank ?? '') : null
tests.push((el) => (op ? el.getAttribute(attr) === soll : el.hasAttribute(attr)))
} else if (nicht !== undefined) {
const liste = parse(nicht)
tests.push((el) => !liste.some((komplex) => passt(el, komplex)))
} else if (pseudo === 'disabled') tests.push((el) => istGesperrt(el))
else if (pseudo === 'enabled') tests.push((el) => STEUERUNGEN.has(el.tagName) && !istGesperrt(el))
else if (pseudo === 'checked') tests.push((el) => (el.tagName === 'OPTION' ? el.selected : el.tagName === 'INPUT' && el.checked))
else throw new Error(`fakeDom: Pseudo-Klasse nicht unterstuetzt: :${pseudo}`)
}
return (el) => tests.every((test) => test(el))
}
/** Selektorliste ⇒ Array von Ketten [{ komb, test }] (komb = Verbindung zum Vorgaenger: ' ' Nachfahre, '>' Kind). */
function parse(quelle) {
const liste = []
let kette = []
let stueck = ''
let komb = ' '
let tiefe = 0
let quote = null
const schliesse = () => {
if (!stueck) return
kette.push({ komb, test: einfach(stueck) })
stueck = ''
komb = ' '
}
for (let i = 0; i < quelle.length; i += 1) {
const zeichen = quelle[i]
if (zeichen === '\\') { stueck += zeichen + (quelle[i + 1] ?? ''); i += 1; continue }
if (quote) { stueck += zeichen; if (zeichen === quote) quote = null; continue }
if (zeichen === '"' || zeichen === "'") { quote = zeichen; stueck += zeichen; continue }
if (zeichen === '[' || zeichen === '(') tiefe += 1
if (zeichen === ']' || zeichen === ')') tiefe -= 1
if (tiefe === 0 && zeichen === ',') { schliesse(); if (kette.length) liste.push(kette); kette = []; continue }
if (tiefe === 0 && /\s/.test(zeichen)) { schliesse(); continue }
if (tiefe === 0 && zeichen === '>') { schliesse(); komb = '>'; continue }
stueck += zeichen
}
schliesse()
if (kette.length) liste.push(kette)
if (!liste.length) throw new Error(`fakeDom: leerer Selektor: "${quelle}"`)
return liste
}
function passt(el, kette, i = kette.length - 1) {
if (!kette[i].test(el)) return false
if (i === 0) return true
if (kette[i].komb === '>') return el.parentNode?.nodeType === 1 && passt(el.parentNode, kette, i - 1)
for (let p = el.parentNode; p && p.nodeType === 1; p = p.parentNode) if (passt(p, kette, i - 1)) return true
return false
}
const trifft = (el, selektor) => parse(selektor).some((kette) => passt(el, kette))
/* -------------------------------------------------------------------- Knoten --- */
class FakeText {
constructor(text) {
this.nodeType = 3
this.data = String(text)
this.parentNode = null
}
get textContent() { return this.data }
set textContent(wert) { this.data = String(wert) }
}
const kebab = (name) => `data-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`
const alsKnoten = (doc, wert) => (typeof wert === 'object' && wert !== null ? wert : new FakeText(wert))
class FakeElement {
constructor(doc, tag) {
this.ownerDocument = doc
this.nodeType = 1
this.tagName = String(tag).toUpperCase()
this.parentNode = null
this.childNodes = []
this._attrs = new Map()
this._listeners = new Map()
this._value = undefined
this._checked = undefined
this._selected = undefined
const el = this
this.dataset = new Proxy({}, {
get: (_, name) => (typeof name === 'string' ? el._attrs.get(kebab(name)) : undefined),
set: (_, name, wert) => { el.setAttribute(kebab(name), wert); return true },
has: (_, name) => el._attrs.has(kebab(name)),
deleteProperty: (_, name) => { el._attrs.delete(kebab(name)); return true },
})
const liste = () => (el._attrs.get('class') ?? '').split(/\s+/).filter(Boolean)
this.classList = {
contains: (name) => liste().includes(name),
add: (...namen) => el.setAttribute('class', [...new Set([...liste(), ...namen])].join(' ')),
remove: (...namen) => el.setAttribute('class', liste().filter((n) => !namen.includes(n)).join(' ')),
toggle: (name, an) => {
const soll = an ?? !liste().includes(name)
if (soll) this.classList.add(name)
else this.classList.remove(name)
return soll
},
}
}
/* Attribute */
setAttribute(name, wert) { this._attrs.set(name, String(wert)) }
getAttribute(name) { return this._attrs.has(name) ? this._attrs.get(name) : null }
hasAttribute(name) { return this._attrs.has(name) }
removeAttribute(name) { this._attrs.delete(name) }
get id() { return this._attrs.get('id') ?? '' }
set id(wert) { this.setAttribute('id', wert) }
get className() { return this._attrs.get('class') ?? '' }
set className(wert) { this.setAttribute('class', wert) }
get hidden() { return this._attrs.has('hidden') }
set hidden(an) { if (an) this.setAttribute('hidden', ''); else this.removeAttribute('hidden') }
get disabled() { return this._attrs.has('disabled') }
set disabled(an) { if (an) this.setAttribute('disabled', ''); else this.removeAttribute('disabled') }
get readOnly() { return this._attrs.has('readonly') }
set readOnly(an) { if (an) this.setAttribute('readonly', ''); else this.removeAttribute('readonly') }
get tabIndex() { return this._attrs.has('tabindex') ? Number(this._attrs.get('tabindex')) : -1 }
set tabIndex(wert) { this.setAttribute('tabindex', wert) }
get type() { return this._attrs.get('type') ?? (this.tagName === 'BUTTON' ? 'submit' : 'text') }
set type(wert) { this.setAttribute('type', wert) }
get name() { return this._attrs.get('name') ?? '' }
set name(wert) { this.setAttribute('name', wert) }
/* value, checked, selected: die Eigenschaft ueberdeckt das Attribut, sobald sie gesetzt ist */
get value() {
if (this.tagName === 'SELECT') {
const optionen = this.querySelectorAll('option')
return (optionen.find((o) => o.selected) ?? optionen.find((o) => !o.disabled))?.value ?? ''
}
if (this.tagName === 'OPTION') return this._attrs.get('value') ?? this.textContent
if (this.tagName === 'TEXTAREA') return this._value ?? this.textContent
return this._value ?? this._attrs.get('value') ?? (this.type === 'checkbox' || this.type === 'radio' ? 'on' : '')
}
set value(wert) {
if (this.tagName === 'SELECT') {
for (const o of this.querySelectorAll('option')) o._selected = o.value === String(wert)
} else if (this.tagName === 'OPTION') this.setAttribute('value', wert)
else this._value = String(wert)
}
get checked() { return this._checked ?? this._attrs.has('checked') }
set checked(an) {
this._checked = Boolean(an)
if (an && this.type === 'radio' && this.name) {
let wurzel = this
while (wurzel.parentNode) wurzel = wurzel.parentNode
for (const r of wurzel.querySelectorAll('input')) if (r !== this && r.type === 'radio' && r.name === this.name) r._checked = false
}
}
get selected() { return this._selected ?? this._attrs.has('selected') }
set selected(an) {
this._selected = Boolean(an)
if (an && this.parentNode?.tagName === 'SELECT') for (const o of this.parentNode.querySelectorAll('option')) if (o !== this) o._selected = false
}
/* Baum */
get children() { return this.childNodes.filter((n) => n.nodeType === 1) }
get parentElement() { return this.parentNode?.nodeType === 1 ? this.parentNode : null }
get firstChild() { return this.childNodes[0] ?? null }
get lastChild() { return this.childNodes.at(-1) ?? null }
get firstElementChild() { return this.children[0] ?? null }
get lastElementChild() { return this.children.at(-1) ?? null }
get isConnected() {
let wurzel = this
while (wurzel.parentNode) wurzel = wurzel.parentNode
return wurzel === this.ownerDocument.documentElement
}
get textContent() { return this.childNodes.map((n) => n.textContent).join('') }
set textContent(wert) { this.replaceChildren(...(wert === '' || wert === null || wert === undefined ? [] : [String(wert)])) }
_loese(knoten) {
if (knoten.parentNode) knoten.parentNode.removeChild(knoten)
return knoten
}
append(...knoten) {
for (const wert of knoten) {
const k = this._loese(alsKnoten(this.ownerDocument, wert))
k.parentNode = this
this.childNodes.push(k)
}
}
prepend(...knoten) {
const neu = knoten.map((wert) => this._loese(alsKnoten(this.ownerDocument, wert)))
for (const k of neu) k.parentNode = this
this.childNodes.unshift(...neu)
}
appendChild(knoten) { this.append(knoten); return knoten }
insertBefore(knoten, vor) {
if (!vor) return this.appendChild(knoten)
this._loese(knoten)
knoten.parentNode = this
this.childNodes.splice(this.childNodes.indexOf(vor), 0, knoten)
return knoten
}
removeChild(knoten) {
const i = this.childNodes.indexOf(knoten)
if (i < 0) throw new Error('fakeDom: removeChild: kein Kind dieses Elements')
this.childNodes.splice(i, 1)
knoten.parentNode = null
return knoten
}
replaceChildren(...knoten) {
for (const k of this.childNodes) k.parentNode = null
this.childNodes = []
this.append(...knoten)
}
remove() { this.parentNode?.removeChild(this) }
contains(knoten) {
for (let k = knoten; k; k = k.parentNode) if (k === this) return true
return false
}
/* Suche */
matches(selektor) { return trifft(this, selektor) }
closest(selektor) {
for (let el = this; el && el.nodeType === 1; el = el.parentNode) if (trifft(el, selektor)) return el
return null
}
querySelectorAll(selektor) {
const liste = parse(selektor)
const treffer = []
const durchlauf = (el) => {
for (const kind of el.children) {
if (liste.some((kette) => passt(kind, kette))) treffer.push(kind)
durchlauf(kind)
}
}
durchlauf(this)
return treffer
}
querySelector(selektor) { return this.querySelectorAll(selektor)[0] ?? null }
/* Fokus */
focus() {
if (!this.isConnected || istGesperrt(this)) return
const doc = this.ownerDocument
const davor = doc._aktiv
doc._aktiv = this
if (davor !== this) feuere(this, neuesEreignis('focus', { bubbles: false }))
}
blur() { if (this.ownerDocument._aktiv === this) this.ownerDocument._aktiv = null }
select() {}
scrollIntoView() {}
click() { return klickEl(this) }
/* Ereignisse */
addEventListener(typ, fn) {
if (!this._listeners.has(typ)) this._listeners.set(typ, [])
this._listeners.get(typ).push(fn)
}
removeEventListener(typ, fn) {
const liste = this._listeners.get(typ)
if (liste?.includes(fn)) liste.splice(liste.indexOf(fn), 1)
}
dispatchEvent(ev) {
feuere(this, ev)
return !ev.defaultPrevented
}
}
/* ----------------------------------------------------------------- Ereignisse --- */
function neuesEreignis(typ, init = {}) {
return {
type: typ,
target: null,
currentTarget: null,
bubbles: true,
defaultPrevented: false,
stopped: false,
ergebnisse: [],
preventDefault() { this.defaultPrevented = true },
stopPropagation() { this.stopped = true },
...init,
}
}
/** Ereignis an el senden und zu den Eltern aufsteigen lassen; Rueckgaben der Handler (async-Handler: Promises) landen in ev.ergebnisse. */
function feuere(el, ev) {
ev.target = el
for (let knoten = el; knoten && knoten.nodeType === 1; knoten = knoten.parentNode) {
ev.currentTarget = knoten
for (const fn of [...(knoten._listeners.get(ev.type) ?? [])]) ev.ergebnisse.push(fn.call(knoten, ev))
if (ev.stopped || !ev.bubbles) break
}
}
/** Alle Handler eines Ereignisses abwarten und danach einen Takt, in dem Microtask-Ketten (fetch, await) durchlaufen. */
async function abwarten(ergebnisse) {
await Promise.all(ergebnisse)
await naechsterTakt()
}
/** Klick wie im Browser: Haken setzen/umschalten, click, input/change, bei „submit“-Knopf in einem Formular submit. */
function klickEl(el) {
if (istGesperrt(el)) return abwarten([])
const ergebnisse = []
const art = el.tagName === 'INPUT' ? el.type : null
if (art === 'checkbox') el.checked = !el.checked
else if (art === 'radio') el.checked = true
const klick = neuesEreignis('click')
feuere(el, klick)
ergebnisse.push(...klick.ergebnisse)
if (!klick.defaultPrevented) {
if (art === 'checkbox' || art === 'radio') {
for (const typ of ['input', 'change']) {
const ev = neuesEreignis(typ)
feuere(el, ev)
ergebnisse.push(...ev.ergebnisse)
}
} else if ((el.tagName === 'BUTTON' && el.type === 'submit') || (art === 'submit')) {
const form = el.closest('form')
if (form) {
const ev = neuesEreignis('submit', { submitter: el })
feuere(form, ev)
ergebnisse.push(...ev.ergebnisse)
}
}
}
return abwarten(ergebnisse)
}
/** Wert eingeben wie ein Nutzer: Text/Auswahl als String, Haken/Radio als Boolean; danach input und change. */
function setzeEl(el, wert) {
if (istGesperrt(el)) return abwarten([])
if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) el.checked = Boolean(wert)
else el.value = wert
const ergebnisse = []
for (const typ of ['input', 'change']) {
const ev = neuesEreignis(typ)
feuere(el, ev)
ergebnisse.push(...ev.ergebnisse)
}
return abwarten(ergebnisse)
}
/* -------------------------------------------------------------------- Einbau --- */
function neuesDokument() {
const doc = { nodeType: 9, _aktiv: null }
doc.createElement = (tag) => new FakeElement(doc, tag)
doc.createTextNode = (text) => new FakeText(text)
doc.documentElement = doc.createElement('html')
doc.head = doc.createElement('head')
doc.body = doc.createElement('body')
doc.documentElement.append(doc.head, doc.body)
Object.defineProperty(doc, 'activeElement', { get: () => (doc._aktiv?.isConnected ? doc._aktiv : doc.body) })
doc.querySelector = (selektor) => (trifft(doc.documentElement, selektor) ? doc.documentElement : doc.documentElement.querySelector(selektor))
doc.querySelectorAll = (selektor) => doc.documentElement.querySelectorAll(selektor)
doc.getElementById = (id) => doc.documentElement.querySelector(`[id="${id}"]`)
return doc
}
const GLOBALE = ['document', 'window', 'CSS', 'fetch']
/**
* Fake-DOM und Fake-fetch auf globalThis stellen.
* => { document, window, fetchLog, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
* finde(baum, vergleich) / alle(baum, vergleich): erstes / alle Elemente im Baum (baum selbst eingeschlossen, Dokumentreihenfolge), fuer die
* vergleich(el) wahr ist (Funktion) oder die zum Selektor passen (String).
* text(baum): Text des Baums (textContent). klick(el), setze(el, wert), ruhe(): siehe Kopf der Datei.
*/
export function installFakeDom({ antworten = [] } = {}) {
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
const document = neuesDokument()
const location = { href: 'http://localhost/', hash: '' }
const window = {
document,
location,
history: { replaceState: (_zustand, _titel, url) => { location.hash = String(url ?? '').includes('#') ? `#${String(url).split('#')[1]}` : '' } },
}
const fetchLog = []
const warteschlange = [...antworten]
const fakeFetch = async (url, optionen = {}) => {
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
fetchLog.push(aufruf)
const eintrag = warteschlange.shift()
const { status = 200, body = {} } = (typeof eintrag === 'function' ? eintrag(aufruf) : eintrag) || {}
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
const neu = { document, window, CSS: { escape: cssEscape }, fetch: fakeFetch }
for (const [name, wert] of Object.entries(neu)) Object.defineProperty(globalThis, name, { value: wert, writable: true, configurable: true, enumerable: true })
const knoten = (baum) => [baum, ...(baum?.querySelectorAll?.('*') ?? [])].filter((n) => n?.nodeType === 1)
const pruefung = (vergleich) => (typeof vergleich === 'string' ? (el) => trifft(el, vergleich) : vergleich)
return {
document,
window,
fetchLog,
antworten: warteschlange,
finde: (baum, vergleich) => knoten(baum).find(pruefung(vergleich)) ?? null,
alle: (baum, vergleich) => knoten(baum).filter(pruefung(vergleich)),
text: (baum) => baum?.textContent ?? '',
klick: klickEl,
setze: setzeEl,
sende: (el, typ, init) => {
const ev = neuesEreignis(typ, init)
feuere(el, ev)
return abwarten(ev.ergebnisse)
},
ruhe: () => abwarten([]),
restore() {
for (const name of GLOBALE) {
if (vorher[name]) Object.defineProperty(globalThis, name, vorher[name])
else delete globalThis[name]
}
},
}
}
+359
View File
@@ -8,6 +8,14 @@ import {
import { BRANCHEN, SCHRITTE, SCHRITT_TITEL, leerFragebogen } from '../server/services/termine/fragebogen.js'
import { normalisiereEinstellung } from '../server/services/termine/einstellungen.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
import { installFakeDom } from './support/fakeDom.js'
// Namensraeume statt benannter Importe: fehlt ein Export, scheitert nur der Test, der ihn braucht (nicht die ganze Datei)
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
import * as teamTab from '../public/termine-tab-team.js'
import * as huelle from '../public/termine.js'
import { FENSTER_FEHLER } from '../public/termine-view.js'
import { h, request } from '../public/termine-dom.js'
import { neuerSpeicher } from '../public/termine-speichern.js'
// Reine Helfer des Fragebogens in public/termine-view.js (TP2, Task C7a): Schrittstand, Namen der Personen/Plaetze, sofort/Anfrage fuer alle
// und die Zusammenfassung auf der Bestellseite. Kein DOM; der Spiegel zum Server (SCHRITTE) steht in test/termineView.test.js.
@@ -478,3 +486,354 @@ test('termine-view.js bleibt rein: kein DOM, kein Netz, nur der Import der Anred
assert.doesNotMatch(quelle, /\b(document|window|navigator|fetch|XMLHttpRequest|localStorage|sessionStorage)\b/)
assert.deepEqual(quelle.match(/^import .*$/gm), ["import { ANREDE_WAHL } from './termine-gaestetexte.js'"])
})
// ------------------------------------------------------- Fake-DOM (Testhilfe, TP2 C7b)
// test/support/fakeDom.js tragen auch C8, C9a, C9b und C10b: die Tests hier halten fest, worauf sie sich verlassen.
/** Fake-DOM fuer die Dauer von fn aufbauen (und immer abbauen). */
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} finally {
fx.restore()
}
}
/** Namen (data-feld) aller Formularfelder im Baum, in Dokumentreihenfolge. */
const felderVon = (fx, baum) => fx.alle(baum, (n) => typeof n.dataset.feld === 'string').map((n) => n.dataset.feld)
test('Fake-DOM: globale Objekte nur zwischen installFakeDom und restore', () => {
const vorher = { document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }
const fx = installFakeDom()
assert.equal(globalThis.document, fx.document)
assert.notEqual(globalThis.fetch, vorher.fetch)
assert.equal(globalThis.CSS.escape('leistungen.2.dauer'), 'leistungen\\.2\\.dauer')
fx.restore()
assert.deepEqual({ document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }, vorher)
})
test('Fake-DOM: Baum, Attribute, Eigenschaften und isConnected', mitDom(undefined, (fx) => {
const knopf = h('button', { type: 'button', class: 'kb-btn tm-x', 'data-aktion': 'weiter', hidden: true }, 'Weiter ', h('b', null, 'jetzt'), 5)
assert.equal(knopf.tagName, 'BUTTON')
assert.equal(knopf.className, 'kb-btn tm-x')
assert.equal(knopf.dataset.aktion, 'weiter')
assert.equal(knopf.getAttribute('data-aktion'), 'weiter')
assert.equal(knopf.hidden, true)
assert.equal(fx.text(knopf), 'Weiter jetzt5')
assert.equal(knopf.children.length, 1, 'children: nur Elemente')
assert.equal(knopf.childNodes.length, 3)
const wurzel = h('div', null, knopf)
assert.equal(knopf.isConnected, false)
document.body.append(wurzel)
assert.equal(knopf.isConnected, true)
wurzel.replaceChildren('neu')
assert.equal(knopf.isConnected, false)
assert.equal(knopf.parentNode, null)
wurzel.textContent = ''
assert.equal(wurzel.childNodes.length, 0)
}))
test('Fake-DOM: Selektoren wie die Client-Dateien sie brauchen', mitDom(undefined, (fx) => {
const zeile = h('li', { class: 'tm-eintrag', 'data-id': 'r_abc' }, h('button', { 'data-aktion': 'bearbeiten' }, 'Bearbeiten'), h('button', { disabled: true }, 'Aus'))
const feld = h('p', { 'data-fehler': 'leistungen.2.dauer' })
const sperre = h('fieldset', { disabled: true }, h('input', { type: 'text' }))
const tafel = h('div', { role: 'tabpanel' }, h('ul', null, zeile), feld, sperre)
document.body.append(tafel)
assert.equal(tafel.querySelector('li[data-id="r_abc"] [data-aktion="bearbeiten"]').textContent, 'Bearbeiten')
assert.equal(tafel.querySelector(`[data-fehler="${CSS.escape('leistungen.2.dauer')}"]`), feld)
assert.deepEqual(zeile.querySelectorAll('button:not(:disabled)').map((n) => n.textContent), ['Bearbeiten'])
assert.equal(zeile.querySelectorAll('button:disabled').length, 1)
assert.equal(sperre.querySelector('input:not(:disabled)'), null, 'fieldset disabled sperrt die Felder darin')
assert.equal(zeile.closest('[role="tabpanel"]'), tafel)
assert.equal(zeile.closest('form'), null)
assert.deepEqual(fx.alle(tafel, 'li, p').map((n) => n.tagName), ['LI', 'P'])
assert.equal(fx.finde(tafel, (n) => n.tagName === 'FIELDSET'), sperre)
assert.equal(tafel.querySelector('ul > li'), zeile)
assert.throws(() => tafel.querySelector('a:hover'), /nicht unterstuetzt/)
}))
test('Fake-DOM: value, checked und selected wie im Browser', mitDom(undefined, async (fx) => {
const auswahl = h('select', null, h('option', { value: '' }, '– bitte wählen –'), h('option', { value: 'RP' }, 'Rheinland-Pfalz'), h('option', { value: 'SL', selected: true }, 'Saarland'))
assert.equal(auswahl.value, 'SL', 'selected-Attribut gilt')
const ohneWahl = h('select', null, h('option', { value: '' }, '–'), h('option', { value: 'RP' }, 'RP'))
assert.equal(ohneWahl.value, '', 'ohne selected zeigt der Browser die erste Option')
await fx.setze(ohneWahl, 'RP')
assert.equal(ohneWahl.value, 'RP')
const text = h('input', { type: 'text', value: 'alt' })
assert.equal(text.value, 'alt')
await fx.setze(text, 'neu')
assert.equal(text.value, 'neu')
assert.equal(text.getAttribute('value'), 'alt', 'die Eigenschaft ändert das Attribut nicht')
const haken = h('input', { type: 'checkbox', checked: true })
assert.equal(haken.checked, true)
await fx.klick(haken)
assert.equal(haken.checked, false)
const area = h('textarea', null, 'Vorgabe')
assert.equal(area.value, 'Vorgabe')
const a = h('input', { type: 'radio', name: 'x', checked: true })
const b = h('input', { type: 'radio', name: 'x' })
h('div', null, a, b)
await fx.klick(b)
assert.deepEqual([a.checked, b.checked], [false, true], 'Radios einer Gruppe schließen sich aus')
}))
test('Fake-DOM: Ereignisse steigen auf, klick auf Absenden-Knopf sendet das Formular, gesperrte Knöpfe tun nichts, async-Handler werden abgewartet', mitDom(undefined, async (fx) => {
const log = []
const knopf = h('button', { type: 'submit' }, 'Weiter')
const aus = h('button', { type: 'submit', disabled: true }, 'Aus')
const lauf = h('button', { type: 'button' }, 'Lauf')
const form = h('form', null, knopf, aus, lauf)
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
log.push('submit')
await Promise.resolve()
await new Promise((r) => setImmediate(r))
log.push('submit fertig')
})
form.addEventListener('click', (ev) => log.push(`click ${ev.target.textContent} ${ev.currentTarget.tagName}`))
lauf.addEventListener('click', async () => { await new Promise((r) => setImmediate(r)); log.push('lauf fertig') })
document.body.append(form)
await fx.klick(aus)
assert.deepEqual(log, [], 'gesperrt: kein Ereignis')
await fx.klick(knopf)
assert.deepEqual(log, ['click Weiter FORM', 'submit', 'submit fertig'])
log.length = 0
await fx.klick(lauf)
assert.deepEqual(log, ['click Lauf FORM', 'lauf fertig'], 'kein submit bei type=button')
log.length = 0
form.addEventListener('keydown', (ev) => log.push(`${ev.type} ${ev.key}`))
await fx.sende(lauf, 'keydown', { key: 'ArrowRight' })
assert.deepEqual(log, ['keydown ArrowRight'])
}))
test('Fake-DOM: fetch schreibt { method, url, body } ins Protokoll und liefert die Antworten der Reihe nach', mitDom({ antworten: [{ status: 400, body: { error: 'Nein', fehler: { 'betrieb.telefon': 'fehlt' }, code: 'X' } }, { body: { ok: 1 } }] }, async (fx) => {
await assert.rejects(request('/api/termine/einstellungen', { method: 'PATCH', body: { projectId: 'p1', version: 3 } }), (err) => err.status === 400 && err.code === 'X' && err.fehler['betrieb.telefon'] === 'fehlt')
assert.deepEqual(await request('/api/termine/x?projectId=p1'), { ok: 1 })
assert.deepEqual(await request('/api/termine/y'), {}, 'ohne Eintrag: 200 mit {}')
assert.deepEqual(fx.fetchLog, [
{ method: 'PATCH', url: '/api/termine/einstellungen', body: { projectId: 'p1', version: 3 } },
{ method: 'GET', url: '/api/termine/x?projectId=p1', body: undefined },
{ method: 'GET', url: '/api/termine/y', body: undefined },
])
}))
// ------------------------------------------------------- betrieb(ctx, e, { nur, titel, hinweis })
const BETRIEB_FELDER = ['name', 'email', 'telefon', 'adresse', 'kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']
const betriebCtx = (fehlt = []) => ({ daten: { fehlt }, nurLesen: false })
test('betrieb mit nur: [email] baut nur das Feld email, lese() liefert nur { email }', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
assert.deepEqual(felderVon(fx, el), ['betrieb.email'])
assert.equal(fx.alle(el, 'input').length, 1)
const eingabe = fx.finde(el, '[data-feld="betrieb.email"]')
assert.equal(eingabe.value, BETRIEB.email, 'vorbelegt aus der Einstellung')
await fx.setze(eingabe, 'neu@example.invalid')
const gelesen = lese()
assert.deepEqual(Object.keys(gelesen), ['email'], 'Schlüsselmenge')
assert.deepEqual(gelesen, { email: 'neu@example.invalid' })
// in den Entwurf geschrieben ändert sich nur email, alles andere bleibt
const entwurf = structuredClone(e)
lese(entwurf)
assert.deepEqual(entwurf.betrieb, { ...e.betrieb, email: 'neu@example.invalid' })
}))
test('betrieb mit nur: Teilmenge in der Reihenfolge der Seite, Haken und Adressen gelesen, Unbekanntes wirft', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['websiteUrl', 'name', 'kleinunternehmer'] })
assert.deepEqual(felderVon(fx, el), ['betrieb.name', 'betrieb.kleinunternehmer', 'betrieb.websiteUrl'], 'Reihenfolge der Seite, nicht der Aufzählung')
await fx.klick(fx.finde(el, '[data-feld="betrieb.kleinunternehmer"]'))
await fx.setze(fx.finde(el, '[data-feld="betrieb.websiteUrl"]'), 'https://salon.example.invalid')
const gelesen = lese()
assert.deepEqual(Object.keys(gelesen).sort(), ['kleinunternehmer', 'name', 'websiteUrl'])
assert.equal(gelesen.kleinunternehmer, true)
assert.equal(gelesen.websiteUrl, 'https://salon.example.invalid')
assert.equal(gelesen.name, BETRIEB.name)
assert.throws(() => einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email', 'steuernummer'] }), /steuernummer/)
}))
test('betrieb ohne nur baut weiter alle neun Felder und schreibt alle neun in den Entwurf', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e)
assert.deepEqual(felderVon(fx, el).sort(), BETRIEB_FELDER.map((n) => `betrieb.${n}`).sort())
assert.equal(fx.alle(el, 'input').length, 9)
assert.equal(fx.text(fx.finde(el, 'h3')), 'Dein Betrieb')
const entwurf = { betrieb: {} }
lese(entwurf)
assert.deepEqual(Object.keys(entwurf.betrieb).sort(), [...BETRIEB_FELDER].sort())
assert.equal(entwurf.betrieb.telefon, BETRIEB.telefon)
assert.equal(entwurf.betrieb.kleinunternehmer, false)
}))
test('betrieb: titel und hinweis ersetzen Überschrift und Erklärsatz', mitDom(undefined, (fx) => {
const e = salon()
const eigen = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], titel: 'Wohin sollen die Nachrichten?', hinweis: 'Eine Adresse für alles.' })
assert.equal(fx.text(fx.finde(eigen.el, 'h3')), 'Wohin sollen die Nachrichten?')
assert.equal(fx.text(fx.finde(eigen.el, '.tm-abschnitt-text')), 'Eine Adresse für alles.')
const ohneHinweis = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], hinweis: null })
assert.equal(fx.finde(ohneHinweis.el, '.tm-abschnitt-text'), null)
const standard = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
assert.equal(fx.text(fx.finde(standard.el, 'h3')), 'Dein Betrieb')
assert.match(fx.text(fx.finde(standard.el, '.tm-abschnitt-text')), /Diese Angaben sehen deine Gäste/)
}))
test('betrieb: der Hinweis auf fehlende Angaben nennt mit nur nur die Felder, die zu sehen sind', mitDom(undefined, (fx) => {
const e = salon()
const fehlt = ['betrieb.telefon', 'betrieb.adresse']
const alles = einstellungenTab.betrieb(betriebCtx(fehlt), e)
assert.match(fx.text(fx.finde(alles.el, '.tm-box-warn')), /deine Telefonnummer und deine Anschrift/, 'ohne nur wie bisher')
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['email'] }).el, '.tm-box-warn'), null)
const nurTelefon = fx.text(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['telefon'] }).el, '.tm-box-warn'))
assert.match(nurTelefon, /Es fehlt noch: deine Telefonnummer\.$/, 'die Anschrift steht nicht in der Liste dessen, was fehlt')
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx([]), e).el, '.tm-box-warn'), null)
}))
// ------------------------------------------------------- wochenplanFeld(r, pfad)
test('wochenplanFeld: sieben Tagesfelder mit Namen aus dem Pfad, gefüllt aus dem Plan der Person', mitDom(undefined, (fx) => {
const r = { id: 'r_anna0001', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] } }
const { el } = teamTab.wochenplanFeld(r, 'ressourcen.2')
assert.deepEqual(felderVon(fx, el), [1, 2, 3, 4, 5, 6, 7].map((nr) => `ressourcen.2.plan.${nr}`))
assert.deepEqual(fx.alle(el, 'label').map((l) => fx.text(l)), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
const eingaben = fx.alle(el, 'input')
assert.deepEqual(eingaben.map((i) => i.value), ['09:00–12:30, 13:30–18:00', '', '', '', '', '09:00–13:00', ''])
assert.ok(eingaben.every((i) => i.getAttribute('placeholder') === 'geschlossen'))
assert.equal(fx.text(fx.finde(el, 'legend')), 'Wochenplan')
assert.equal(fx.alle(el, '[data-fehler]').length, 7, 'je Tag ein Platz für den Fehler')
}))
test('wochenplanFeld: gültige Fenster ⇒ { plan } mit genau diesen Fenstern, ein leerer Tag ist geschlossen', mitDom(undefined, async (fx) => {
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.0')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.1"]'), '09:00–12:30, 13:30–18:00')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.3"]'), '8:00-12:00')
const gelesen = lese()
assert.deepEqual(gelesen, { plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 3: [['08:00', '12:00']] } })
assert.equal(gelesen.fehler, undefined)
assert.ok(!(2 in gelesen.plan) && !(7 in gelesen.plan), 'leerer Tag ⇒ geschlossen (kein Eintrag)')
// ein vorher offener Tag, der geleert wird, ist danach geschlossen
const offen = teamTab.wochenplanFeld({ id: 'r1', plan: { 1: [['09:00', '12:00']], 2: [['10:00', '11:00']] } }, 'ressourcen.0')
await fx.setze(fx.finde(offen.el, '[data-feld="ressourcen.0.plan.1"]'), '')
assert.deepEqual(offen.lese(), { plan: { 2: [['10:00', '11:00']] } })
assert.deepEqual(teamTab.wochenplanFeld(null, 'ressourcen.0').lese(), { plan: {} }, 'ohne Person: alles geschlossen')
}))
test('wochenplanFeld: ein ungültiges Fenster ⇒ { fehler } mit Text am Feld des Tages, kein Teilplan', mitDom(undefined, async (fx) => {
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.1')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.1"]'), '09:00–12:00')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.2"]'), 'abc')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.5"]'), '09:00–09:00')
const gelesen = lese()
assert.deepEqual(gelesen, { fehler: { 'ressourcen.1.plan.2': FENSTER_FEHLER, 'ressourcen.1.plan.5': FENSTER_FEHLER } })
assert.equal(gelesen.plan, undefined)
assert.ok(typeof gelesen.fehler['ressourcen.1.plan.2'] === 'string' && gelesen.fehler['ressourcen.1.plan.2'].length > 0)
}))
test('Reiter Team & Plätze: der Editor einer Person speichert den Wochenplan aus wochenplanFeld und zeigt Fehler am Tag', mitDom(undefined, async (fx) => {
const einstellung = salon()
const anna = einstellung.ressourcen[0]
const ctx = {
s: {}, projectId: 'p1', daten: { einstellung, version: 4, feeds: {}, fehlt: [] }, nurLesen: false, auswahl: {}, aktuell: () => true,
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
meldung() {}, hinweise() {}, zumReiter() {}, neuLaden: async () => {},
}
const tafel = h('div', { role: 'tabpanel' })
document.body.append(tafel)
teamTab.render(tafel, ctx)
await fx.ruhe()
await fx.klick(fx.finde(tafel, `li[data-id="${anna.id}"] [data-aktion="bearbeiten"]`))
const form = fx.finde(tafel, (n) => n.tagName === 'FORM' && n.getAttribute('aria-label') === `Bearbeiten: ${anna.name}`)
assert.ok(form, 'Editor ist offen')
const speichern = fx.finde(form, (n) => n.tagName === 'BUTTON' && n.type === 'submit')
const montag = fx.finde(form, '[data-feld="ressourcen.0.plan.1"]')
// Fehler: nichts wird gesendet, der Text steht am Tag
await fx.setze(montag, 'abc')
await fx.klick(speichern)
assert.deepEqual(fx.fetchLog, [])
assert.equal(fx.text(fx.finde(form, '[data-fehler="ressourcen.0.plan.1"]')), FENSTER_FEHLER)
assert.equal(montag.getAttribute('aria-invalid'), 'true')
// gültig: PATCH mit dem Plan (alle anderen Tage unverändert)
await fx.setze(montag, '10:00-13:00')
fx.antworten.push({ body: { einstellung, version: 5, fehlt: [] } })
await fx.klick(speichern)
assert.equal(fx.fetchLog.length, 1)
const [aufruf] = fx.fetchLog
assert.deepEqual([aufruf.method, aufruf.url], ['PATCH', '/api/termine/einstellungen'])
assert.equal(aufruf.body.version, 4)
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[1], [['10:00', '13:00']])
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[2], anna.plan[2])
}))
// ------------------------------------------------------- termine-speichern.js: fehlt aus der Antwort
const speicherCtx = (einstellung, fehlt) => ({
aktuell: () => true,
daten: { einstellung, version: 3, fehlt },
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
hinweise() {}, meldung() {}, neuLaden: async () => {},
})
test('Speichern: antwortet der PATCH mit fehlt, steht es danach in den Daten des Reiters', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4, fehlt: ['betrieb.telefon'] } }] }, async (fx) => {
const ctx = speicherCtx(salon(), ['betrieb.telefon', 'betrieb.adresse'])
const wurzel = h('div')
document.body.append(wurzel)
const sp = neuerSpeicher(ctx, wurzel, () => {})
assert.equal(await sp.speichere((entwurf) => { entwurf.betrieb.adresse = 'Hauptstraße 1' }, { container: wurzel }), true)
assert.deepEqual(ctx.daten.fehlt, ['betrieb.telefon'])
assert.equal(ctx.daten.version, 4)
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['PATCH', '/api/termine/einstellungen']])
}))
test('Speichern: ohne fehlt in der Antwort ist es leer', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4 } }] }, async () => {
const ctx = speicherCtx(salon(), ['betrieb.telefon'])
const wurzel = h('div')
document.body.append(wurzel)
const sp = neuerSpeicher(ctx, wurzel, () => {})
assert.equal(await sp.speichere(() => true, { container: wurzel }), true)
assert.deepEqual(ctx.daten.fehlt, [])
}))
// ------------------------------------------------------- termine.js: baueCtx
test('baueCtx: Schnittstelle, Daten und Meldungen wie der Reiter-Kontext', mitDom({ antworten: [{}, {}, {}, { body: { version: 9, einstellung: null } }, { body: { version: 11 } }] }, async (fx) => {
const s = { daten: { version: 3 }, nurLesen: false, meldungEl: h('div', { class: 'tm-meldung', tabindex: '-1' }) }
document.body.append(s.meldungEl)
let aktuell = true
let gezeichnet = 0
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p 1' }, aktuell: () => aktuell, hinweise: () => { gezeichnet += 1 } })
assert.equal(ctx.s, s)
assert.equal(ctx.projectId, 'p 1')
assert.equal(ctx.daten, s.daten)
assert.equal(ctx.nurLesen, false)
assert.equal(typeof ctx.auswahl, 'object')
assert.equal(ctx.aktuell(), true)
assert.equal(typeof ctx.zumReiter, 'function')
// Schnittstelle: GET und DELETE mit projectId in der Adresse, PATCH/POST/PUT mit projectId im Body
await ctx.api.get('/buchungen', { von: '2026-10-05' })
await ctx.api.send('DELETE', '/abwesenheiten/a1')
await ctx.api.send('PATCH', '/einstellungen', { version: 3 })
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url, a.body]), [
['GET', '/api/termine/buchungen?projectId=p+1&von=2026-10-05', undefined],
['DELETE', '/api/termine/abwesenheiten/a1?projectId=p+1', undefined],
['PATCH', '/api/termine/einstellungen', { projectId: 'p 1', version: 3 }],
])
// Meldung nur solange die Ansicht aktuell ist; hinweise() zeichnet nur dann
ctx.meldung('Gespeichert.')
assert.equal(s.meldungEl.textContent, 'Gespeichert.')
ctx.hinweise()
assert.equal(gezeichnet, 1)
// neu laden: Daten und ctx.daten werden ersetzt und die Hinweise neu gezeichnet
const daten = await ctx.neuLaden()
assert.deepEqual(daten, { version: 9, einstellung: null })
assert.equal(s.daten, daten)
assert.equal(ctx.daten, daten)
assert.equal(gezeichnet, 2)
assert.equal(fx.fetchLog.at(-1).url, '/api/termine/einstellungen?projectId=p%201')
aktuell = false
ctx.meldung('Zu spät.')
ctx.hinweise()
await ctx.neuLaden()
assert.equal(s.meldungEl.textContent, 'Gespeichert.', 'eine veraltete Ansicht bekommt keine Meldung')
assert.equal(gezeichnet, 2)
assert.equal(ctx.daten, daten, 'und keine neuen Daten')
// ohne hinweise/zumReiter ist der Kontext trotzdem benutzbar (Fragebogen)
const schlicht = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true })
schlicht.hinweise()
schlicht.zumReiter('team')
}))
+26
View File
@@ -363,6 +363,32 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn
}
})
test('Reiter-Exporte für den Fragebogen (TP2 C7b): acht Abschnitte, wochenplanFeld, baueCtx, fehlt aus der Antwort', async () => {
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
// Einstellungen: die acht Abschnitte sind Exporte; der Reiter baut sie weiter selbst (betrieb ohne nur = alle neun Felder, Verhalten in termineFragebogenView.test.js)
const einstellungen = await import('../public/termine-tab-einstellungen.js')
for (const name of ['betrieb', 'darstellung', 'regeln', 'gastFelder', 'erinnerung', 'storno', 'rechtliches', 'zahlung']) {
assert.equal(typeof einstellungen[name], 'function', `${name} ist exportiert`)
assert.match(lies('public/termine-tab-einstellungen.js'), new RegExp(`export function ${name}\\(`), name)
}
assert.match(lies('public/termine-tab-einstellungen.js'), /const abschnitte = \[betrieb\(ctx, e\), darstellung\(e\), regelnAbschnitt, gastFelder\(ctx, e\), erinnerung\(e\), storno\(ctx, e, regelnAbschnitt\.absage\), rechtliches\(e\), zahlung\(e\), aufbewahrung\(e\)\]/, 'der Reiter benutzt sie selbst')
// Team: wochenplanFeld ist ein Export, den der Editor einer Person selbst benutzt (die Tagesfelder gibt es nur dort)
const team = ohneKommentare(lies('public/termine-tab-team.js'))
assert.match(team, /export function wochenplanFeld\(r, pfad\)/)
assert.match(team, /= wochenplanFeld\(/, 'formular benutzt wochenplanFeld')
assert.equal(team.match(/WOCHENTAGE_PLAN\.map/g).length, 1, 'die Tagesfelder werden nur in wochenplanFeld gebaut')
assert.equal(team.match(/planAusTexten\(/g).length, 1, 'der Wochenplan wird nur in wochenplanFeld gelesen')
// Speichern: fehlt kommt aus der Antwort des Servers
const speichern = ohneKommentare(lies('public/termine-speichern.js'))
assert.match(speichern, /fehlt: antwort\.fehlt \?\? \[\]/)
assert.doesNotMatch(speichern, /fehlt: \[\]/)
// Huelle: der Kontext der Reiter wird in baueCtx gebaut (exportiert, auch der Fragebogen braucht ihn), zeigeReiter benutzt es
const huelle = ohneKommentare(lies('public/termine.js'))
assert.match(huelle, /export function baueCtx\(/)
assert.match(huelle, /const ctx = baueCtx\(/)
assert.equal(huelle.match(/api: \{/g).length, 1, 'die Schnittstelle des Kontexts steht nur in baueCtx')
})
test('beschrifteFehler: Fehler ohne Platz am Feld nennen den Eintrag', () => {
const quellen = [{
leistungen: [{ id: 's_aaaaaaaa', name: 'Herrenschnitt' }, { id: 's_bbbbbbbb', name: 'Färben' }],