// Testhilfen fuer die Funktionsmodule im Abo-Modell (P4 „Funktionsmodule“): Fake-DOM mit CustomEvent, Antworten nach Adresse, Attrappe fuer den // Einschalt-Ablauf (zeigeEinschalten aus public/neu/funktion-einschalten.js), Wurzeln fuer die innerHTML-Module (News, Analyse). import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { installFakeDom } from './fakeDom.js' /** Wörter, die im Abo-Modell auf keiner Oberfläche der Funktionen stehen dürfen (Spec: keine Preise für Funktionen). */ export const KEIN_PREIS = /€|einmalig|Preis|Zahlungspflichtig|Bestellseite|Behalten/ /** CustomEvent fuer fakeDom: type, detail, bubbles, target, ergebnisse, preventDefault, stopPropagation. */ class FakeCustomEvent { constructor(type, init = {}) { this.type = type this.detail = init.detail ?? null this.bubbles = init.bubbles === true this.target = null this.currentTarget = null this.defaultPrevented = false this.stopped = false this.ergebnisse = [] } preventDefault() { this.defaultPrevented = true } stopPropagation() { this.stopped = true } } /** Testrumpf: Fake-DOM an, CustomEvent der Fake-Klasse, nach dem Test alles zurück. fn(fx). */ export const mitDom = (fn) => async () => { const fx = installFakeDom() const davor = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent') Object.defineProperty(globalThis, 'CustomEvent', { value: FakeCustomEvent, writable: true, configurable: true, enumerable: true }) try { await fn(fx) await fx.ruhe() } finally { fx.restore() if (davor) Object.defineProperty(globalThis, 'CustomEvent', davor) else delete globalThis.CustomEvent } } /** Lauscht am document.body auf kn:reiter. => { reiter: [detail, …] } (in der Reihenfolge des Eintreffens). */ export function hoereAufKn(fx) { const k = { reiter: [] } fx.document.body.addEventListener('kn:reiter', (ev) => { k.reiter.push(ev.detail) }) return k } /** * Ersetzt fetch: Antworten nach 'METHODE /pfad?query'. Wert: { status?, body } | Array (der Reihe nach, der letzte gilt weiter) | * (aufruf) => Wert. Unbekannte Adressen: 404 und Eintrag in fx.unbekannt. Jeder Aufruf steht in fx.fetchLog. */ export function routen(fx, tabelle) { const felder = new Map(Object.entries(tabelle).map(([schluessel, wert]) => [schluessel, Array.isArray(wert) ? [...wert] : wert])) fx.unbekannt = [] globalThis.fetch = async (url, optionen = {}) => { const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined } fx.fetchLog.push(aufruf) const schluessel = `${aufruf.method} ${aufruf.url}` let eintrag = felder.get(schluessel) if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0] if (typeof eintrag === 'function') eintrag = eintrag(aufruf) if (eintrag === undefined) { fx.unbekannt.push(schluessel) eintrag = { status: 404, body: { error: `nicht vorgesehen: ${schluessel}` } } } const { status = 200, body = {} } = eintrag return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) } } } /** * Attrappe fuer zeigeEinschalten. fn(platz, { key, projectId, viewAs, onFertig }) zeichnet nichts und merkt den Aufruf. * fertig(i, ergebnis) ruft onFertig des i-ten Aufrufs. */ export function einschaltenAttrappe() { const aufrufe = [] const fn = (wurzel, o = {}) => { aufrufe.push({ wurzel, key: o.key, projectId: o.projectId, viewAs: o.viewAs, onFertig: o.onFertig }) return Promise.resolve() } const fertig = (i = 0, ergebnis = { feature: { state: 'setup' }, ticketWoid: null }) => aufrufe[i].onFertig(ergebnis) return { fn, aufrufe, fertig } } function attrappe() { const handlers = {} return { innerHTML: '', textContent: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(typ, fn) { handlers[typ] = fn }, querySelector: () => null, querySelectorAll: () => [], } } /** * Wurzel fuer die innerHTML-Module (News, Analyse): { root, stage, kn }. root.querySelector(stageSelektor) liefert stage, '.an' die Attrappe `an`. * stage.els['#id'] entsteht beim ersten querySelector('#id'). root.dispatchEvent legt die Meldungen 'kn:reiter' (ev.detail) in kn.reiter ab. */ export function htmlWurzel(stageSelektor) { const els = {} const attribute = {} const stage = { els, attribute, innerHTML: '', querySelector: (selektor) => (els[selektor] ||= attrappe()), querySelectorAll: () => [], setAttribute: (name, wert) => { attribute[name] = String(wert) }, getAttribute: (name) => (Object.hasOwn(attribute, name) ? attribute[name] : null), } const an = attrappe() const kn = { reiter: [] } const root = { innerHTML: '', dataset: {}, querySelector: (selektor) => (selektor === stageSelektor ? stage : selektor === '.an' ? an : null), dispatchEvent(ev) { if (ev?.type === 'kn:reiter') kn.reiter.push(ev.detail) return true }, } return { root, stage, kn } } /** * public/funktionen.js laden, alle statischen Importe (absolut /x.js oder relativ ./x.js) auf die Dateien in public/ umbiegen * (Muster funktionenZeitenTile.test.js). => das Modul. */ export async function ladeFunktionenModul() { const dir = mkdtempSync(join(tmpdir(), 'fn-modul-abo-')) const dateiUrl = (name) => pathToFileURL(new URL(`../../public/${name}`, import.meta.url).pathname).href const quelle = readFileSync(new URL('../../public/funktionen.js', import.meta.url), 'utf8') const src = quelle.replace(/(\bfrom\s+)'(?:\/|\.\/)([^']+)'/g, (_, von, name) => `${von}'${dateiUrl(name)}'`) const datei = join(dir, 'funktionen.mjs') writeFileSync(datei, src) return import(pathToFileURL(datei).href) }