diff --git a/public/termine-speichern.js b/public/termine-speichern.js index 4fdd0a7..6aa9133 100644 --- a/public/termine-speichern.js +++ b/public/termine-speichern.js @@ -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 diff --git a/public/termine-tab-einstellungen.js b/public/termine-tab-einstellungen.js index ab79aea..851a20b 100644 --- a/public/termine-tab-einstellungen.js +++ b/public/termine-tab-einstellungen.js @@ -69,48 +69,79 @@ export function render(panel, ctx) { } /* ----------------------------------------------------------------- Betrieb --- */ -function betrieb(ctx, e) { +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://') }, +} +/** 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 || {} - 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 fehlt = ctx.daten.fehlt || [] + 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.', diff --git a/public/termine-tab-team.js b/public/termine-tab-team.js index 437961a..0da12c6 100644 --- a/public/termine-tab-team.js +++ b/public/termine-tab-team.js @@ -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 } diff --git a/public/termine.js b/public/termine.js index 38b0ca2..4b1dadd 100644 --- a/public/termine.js +++ b/public/termine.js @@ -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' }) diff --git a/test/support/fakeDom.js b/test/support/fakeDom.js new file mode 100644 index 0000000..0d2c117 --- /dev/null +++ b/test/support/fakeDom.js @@ -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 +//