diff --git a/tests/unit/workersPultUi.test.mjs b/tests/unit/workersPultUi.test.mjs index 344529f..175f587 100644 --- a/tests/unit/workersPultUi.test.mjs +++ b/tests/unit/workersPultUi.test.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' // Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik -// steckt in P3-T18. Nur usePultFeed läuft wirklich, mit einem kleinen Hook-Läufer anstelle von React (siehe unten). +// steckt in P3-T18. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (siehe unten), PultList zusätzlich als Text gerendert. const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8') test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => { @@ -45,91 +45,159 @@ test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeitete assert.doesNotMatch(s, /innerHTML/) }) -// ---- usePultFeed läuft wirklich: Mini-Läufer für Hooks statt React, falsche document/window mit von Hand weitergestelltem Takt ---- +// ---- gemeinsame Bausteine der Läufer: EIN Mini-Läufer anstelle von React (Hooks, Zustand, Effekte, Baum aus { tag, props, children }) ---- +// ---- und EINE Tabelle mit Attrappen für alles, was PultList von außen holt. usePultFeed und PultList laufen im selben Läufer. ---- + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) const FAKE_REACT = ` -let active = null -export function __mount(hook, getProps) { - const run = { hooks: [], index: 0, effects: [], dirty: false, rendering: false, unmounted: false, late: 0, result: undefined } - run.render = () => { - if (run.rendering) { run.dirty = true; return } - run.rendering = true - do { - run.dirty = false - run.index = 0 - active = run - run.result = hook(getProps()) - active = null - for (const effect of run.effects.splice(0)) effect() - } while (run.dirty) - run.rendering = false +export const Fragment = Symbol('fragment') +export function jsx(type, props, key) { return { type, props: props ?? {}, key } } +export const jsxs = jsx + +let current = null // Instanz des Bauteils, dessen Render gerade läuft +let hookIndex = 0 + +const flat = (children, out = []) => { + if (Array.isArray(children)) children.forEach((c) => flat(c, out)) + else if (children !== null && children !== undefined && children !== false && children !== true) out.push(children) + return out +} + +// Hängt ein Element (Bauteile mit Hooks, darunter Tags) ein und rendert es; jedes Bauteil behält seinen Zustand unter seinem Pfad im Baum. +export function __mount(element) { + const owner = { render, effects: [], late: 0 } // late: Zustandsänderungen, die nach dem Abbau noch ankamen + const instances = new Map() + let tree = null + let rendering = false + let dirty = false + + const dispose = (inst) => { + inst.alive = false + for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup() } - run.render() + + const expand = (node, path, visited) => { + if (typeof node === 'string' || typeof node === 'number') return String(node) + const { type, props } = node + if (typeof type === 'function') { + visited.add(path) + let inst = instances.get(path) + if (!inst) { inst = { hooks: [], alive: true, owner }; instances.set(path, inst) } + current = inst + hookIndex = 0 + const out = type(props) + current = null + return out === null || out === undefined || out === false ? null : expand(out, path + '/c', visited) + } + const children = flat(props.children).map((c, i) => expand(c, path + '/' + (c && c.key != null ? 'k:' + c.key : i), visited)) + return { tag: type === Fragment ? 'fragment' : type, props, children } + } + + function render() { + if (rendering) { dirty = true; return } + rendering = true + do { + dirty = false + const visited = new Set() + tree = expand(element, 'r', visited) + for (const [path, inst] of [...instances]) { + if (visited.has(path)) continue + dispose(inst) + instances.delete(path) + } + for (const run of owner.effects.splice(0)) run() + } while (dirty) + rendering = false + } + render() return { - result: () => run.result, - rerender: () => run.render(), - lateUpdates: () => run.late, - unmount: () => { - run.unmounted = true - for (const h of run.hooks) if (h && typeof h.cleanup === 'function') h.cleanup() - }, + tree: () => tree, + rerender: render, + lateUpdates: () => owner.late, + unmount: () => { for (const inst of instances.values()) dispose(inst); instances.clear() }, } } + export function useState(initial) { - const run = active - const i = run.index++ - if (!run.hooks[i]) run.hooks[i] = { value: typeof initial === 'function' ? initial() : initial } - const slot = run.hooks[i] + const inst = current + const i = hookIndex++ + if (!inst.hooks[i]) inst.hooks[i] = { value: typeof initial === 'function' ? initial() : initial } + const slot = inst.hooks[i] return [slot.value, (next) => { - if (run.unmounted) { run.late += 1; return } + if (!inst.alive) { inst.owner.late += 1; return } const value = typeof next === 'function' ? next(slot.value) : next if (Object.is(value, slot.value)) return slot.value = value - run.render() + inst.owner.render() }] } export function useRef(initial) { - const run = active - const i = run.index++ - if (!run.hooks[i]) run.hooks[i] = { current: initial } - return run.hooks[i] + const inst = current + const i = hookIndex++ + if (!inst.hooks[i]) inst.hooks[i] = { current: initial } + return inst.hooks[i] } export function useCallback(fn, deps) { - const run = active - const i = run.index++ - const slot = run.hooks[i] + const inst = current + const i = hookIndex++ + const slot = inst.hooks[i] if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn - run.hooks[i] = { fn, deps } + inst.hooks[i] = { fn, deps } return fn } export function useEffect(fn, deps) { - const run = active - const i = run.index++ - if (!run.hooks[i]) run.hooks[i] = { deps: undefined, cleanup: undefined } - const slot = run.hooks[i] - if (slot.deps && deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return - run.effects.push(() => { + const inst = current + const i = hookIndex++ + if (!inst.hooks[i]) inst.hooks[i] = { deps: undefined, cleanup: undefined, ran: false } + const slot = inst.hooks[i] + if (slot.ran && deps && slot.deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return + inst.owner.effects.push(() => { if (typeof slot.cleanup === 'function') slot.cleanup() slot.deps = deps + slot.ran = true slot.cleanup = fn() }) } ` +const fakeReact = { + name: 'fake-react', + setup(build) { + build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-react' })) + build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' })) + }, +} + +// Was PultList von außen holt: der Feed (je Filter eine Antwort aus globalThis.__pultFeedFor), die Mitarbeiterliste, die Zwischenablage, das Panel (Dialog) +const STUBS = { + usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }', + useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }', + CopyableCredential: 'export async function copyTextChecked() {}', + ProspectPanel: 'export default function ProspectPanel() { return null }', // das Panel gehört nicht zu diesen Tests (workersProspect.test.mjs) +} +const stubs = { + name: 'pult-stubs', + setup(build) { + build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' })) + build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' })) + }, +} + +// ---- usePultFeed läuft wirklich: im Mini-Läufer, falsche document/window mit von Hand weitergestelltem Takt ---- async function loadFeed() { - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') - const fakeReact = { - name: 'fake-react', - setup(build) { - build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'fake-react' })) - build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' })) - }, - } - const out = await esbuild.build({ - stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount } from 'react'", resolveDir: root, loader: 'js' }, + const { usePultFeed, __mount, jsx } = await importBundle(await esbuild.build({ + stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount, jsx } from 'react'", resolveDir: ROOT, loader: 'js' }, bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact], - }) - return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) + })) + // Der Hook läuft in einem Bauteil, das nichts zeichnet und sein Ergebnis festhält + const mountHook = (hook) => { + let result + const mounted = __mount(jsx(() => { result = hook(); return null }, {})) + return { result: () => result, rerender: mounted.rerender, lateUpdates: mounted.lateUpdates, unmount: mounted.unmount } + } + return { usePultFeed, mountHook } } // document und window nur für die Dauer eines Tests: Takt (setInterval) und visibilitychange werden von Hand ausgelöst @@ -171,11 +239,11 @@ const settle = () => new Promise((resolve) => setImmediate(resolve)) const itemsOf = (...ids) => ({ items: ids.map((id) => ({ $id: id })) }) test('usePultFeed: lädt beim Start, zeigt die Einträge, deutscher Fehler bleibt neben den letzten Einträgen stehen', async () => { - const { usePultFeed, __mount } = await loadFeed() + const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() - const hook = __mount(() => usePultFeed({ api, statuses: 'offen,in_arbeit' }), () => ({})) + const hook = mountHook(() => usePultFeed({ api, statuses: 'offen,in_arbeit' })) assert.deepEqual(api.calls.map((c) => c.status), ['offen,in_arbeit']) assert.deepEqual([hook.result().loading, hook.result().items, hook.result().error], [true, [], null]) @@ -195,11 +263,11 @@ test('usePultFeed: lädt beim Start, zeigt die Einträge, deutscher Fehler bleib }) test('usePultFeed: Takt und Neuladen nur bei sichtbarem Tab; zurück im Vordergrund wird sofort geladen', async () => { - const { usePultFeed, __mount } = await loadFeed() + const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() - const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({})) + const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' })) api.calls[0].resolve(itemsOf('a')); await settle() b.doc.visibilityState = 'hidden' b.tick(); b.fire('visibilitychange') // versteckt: weder Takt noch Wechsel-Ereignis rufen den Dienst @@ -214,11 +282,11 @@ test('usePultFeed: Takt und Neuladen nur bei sichtbarem Tab; zurück im Vordergr }) test('usePultFeed: nur die letzte Anfrage zählt (späte alte Antwort wird verworfen); refresh() holt sofort neu', async () => { - const { usePultFeed, __mount } = await loadFeed() + const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() - const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({})) + const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' })) const first = api.calls[0] const refreshed = hook.result().refresh() // zweite Anfrage, während die erste noch läuft assert.equal(api.calls.length, 2) @@ -235,12 +303,12 @@ test('usePultFeed: nur die letzte Anfrage zählt (späte alte Antwort wird verwo }) test('usePultFeed: Wechsel des Filters zeigt nie die Einträge des alten Filters und verwirft dessen späte Antwort', async () => { - const { usePultFeed, __mount } = await loadFeed() + const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() let statuses = 'offen,in_arbeit' - const hook = __mount(() => usePultFeed({ api, statuses }), () => ({})) + const hook = mountHook(() => usePultFeed({ api, statuses })) api.calls[0].resolve(itemsOf('a')); await settle() statuses = 'veraltet'; hook.rerender() assert.equal(api.calls.at(-1).status, 'veraltet') @@ -255,50 +323,35 @@ test('usePultFeed: Wechsel des Filters zeigt nie die Einträge des alten Filters }) test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async () => { - const { usePultFeed, __mount } = await loadFeed() + const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() - const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({})) + const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' })) hook.unmount() api.calls[0].resolve(itemsOf('a')); await settle() assert.equal(hook.lateUpdates(), 0) // auch ein später Fehler setzt nichts const second = fakeApi() - const again = __mount(() => usePultFeed({ api: second, statuses: 'offen' }), () => ({})) + const again = mountHook(() => usePultFeed({ api: second, statuses: 'offen' })) again.unmount() second.calls[0].reject(new Error('zu spät')); await settle() assert.equal(again.lateUpdates(), 0) } finally { b.restore() } }) -// ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); der Feed, die Mitarbeiterliste und die Zwischenablage sind Attrappen ---- - -const STUBS = { - usePultFeed: 'export function usePultFeed() { return globalThis.__pultFeed }', - useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }', - CopyableCredential: 'export async function copyTextChecked() {}', -} +// ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); Attrappen aus STUBS ---- async function loadList() { - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') - const stubs = { - name: 'pult-stubs', - setup(build) { - build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' })) - build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' })) - }, - } - const out = await esbuild.build({ + return importBundle(await esbuild.build({ stdin: { contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))", - resolveDir: root, loader: 'js', + resolveDir: ROOT, loader: 'js', }, bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs], // react-dom/server ist CommonJS und holt sich Node-Module über require: im Data-URL-Modul gibt es keins banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" }, - }) - return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) + })) } test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => { @@ -307,7 +360,7 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, const iso = (ms) => new Date(ms).toISOString() const me = { id: 'u1', name: 'Justin' } const show = (items, extra = {}) => { - globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {}, ...extra } + globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {}, ...extra }) globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }] return render({ api: {}, me }) } @@ -359,7 +412,7 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, // die Filter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt html = show([]) assert.match(html, /