- 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>
526 lines
21 KiB
JavaScript
526 lines
21 KiB
JavaScript
// 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]
|
||
}
|
||
},
|
||
}
|
||
}
|