Workers P4: Test des Pults – ein Mini-Läufer und eine Attrappen-Tabelle statt zwei
FAKE_REACT (usePultFeed) und FAKE_UI (PultList) waren dieselben Hooks doppelt, STUBS und UI_STUBS dieselben Attrappen doppelt. Jetzt gibt es einen Läufer (FAKE_REACT, mehrere Bauteile mit Zustand, Effekten und Abbau) und eine Tabelle (STUBS); usePultFeed läuft in einem Bauteil (mountHook), die gerenderten und die bedienten PultList-Tests teilen sich die Attrappen über globalThis.__pultFeedFor. Nur Test-Code, das Verhalten der Tests bleibt gleich (24 Tests grün). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
533091c333
commit
6ecfc922d4
1 file changed
+153
-220
+153
-220
@@ -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, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/)
|
||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||||
})
|
||||
|
||||
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
|
||||
@@ -368,7 +421,7 @@ test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und
|
||||
const now = Date.now()
|
||||
const iso = (ms) => new Date(ms).toISOString()
|
||||
const show = (items) => {
|
||||
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
|
||||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
||||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Anna' }]
|
||||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||||
}
|
||||
@@ -388,14 +441,14 @@ test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und
|
||||
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Badge
|
||||
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
|
||||
assert.ok(open.includes('<span>vor 4 Tagen</span>')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('badge green'))
|
||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||||
})
|
||||
|
||||
// Endprüfung P3 (W8): „Details“, „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel der Zeile im zugänglichen Namen
|
||||
test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => {
|
||||
const { render } = await loadList()
|
||||
const show = (items) => {
|
||||
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
|
||||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
||||
globalThis.__pultEmployees = []
|
||||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||||
}
|
||||
@@ -421,7 +474,7 @@ test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen e
|
||||
assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title))
|
||||
assert.match(bare, />Details<\/button>/)
|
||||
}
|
||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||||
})
|
||||
|
||||
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ----
|
||||
@@ -474,7 +527,7 @@ test('PultList (gerendert, P4-T32): Aktionen je Art – Antworten, Freigeben, Ba
|
||||
const now = Date.now()
|
||||
const iso = (ms) => new Date(ms).toISOString()
|
||||
const show = (items) => {
|
||||
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
|
||||
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
|
||||
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
|
||||
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||||
}
|
||||
@@ -543,137 +596,17 @@ test('PultList (gerendert, P4-T32): Aktionen je Art – Antworten, Freigeben, Ba
|
||||
assert.match(show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }]), /aria-expanded="false"[^>]*>Antwort des Kunden einfügen<\/button>/)
|
||||
// in den anderen Arten gibt es kein Einfügen
|
||||
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show([{ ...base, kind }]).includes('Antwort des Kunden einfügen'), kind)
|
||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
|
||||
})
|
||||
|
||||
// ---- PultList wird bedient (P4-T32): Mini-Läufer statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
|
||||
// ---- PultList wird bedient (P4-T32): derselbe Mini-Läufer (FAKE_REACT) statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
|
||||
// ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ----
|
||||
|
||||
const FAKE_UI = `
|
||||
export const Fragment = Symbol('fragment')
|
||||
export function jsx(type, props, key) { return { type, props: props ?? {}, key } }
|
||||
export const jsxs = jsx
|
||||
|
||||
let current = null
|
||||
let hookIndex = 0
|
||||
let effects = []
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function __mount(element) {
|
||||
const instances = new Map()
|
||||
let tree = null
|
||||
let rendering = false
|
||||
let dirty = false
|
||||
|
||||
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, rerender: render }; 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()
|
||||
effects = []
|
||||
tree = expand(element, 'r', visited)
|
||||
for (const [path, inst] of [...instances]) {
|
||||
if (visited.has(path)) continue
|
||||
inst.alive = false
|
||||
for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
|
||||
instances.delete(path)
|
||||
}
|
||||
for (const run of effects.splice(0)) run()
|
||||
} while (dirty)
|
||||
rendering = false
|
||||
}
|
||||
render()
|
||||
return { tree: () => tree, unmount: () => { for (const inst of instances.values()) { inst.alive = false; for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup() } instances.clear() } }
|
||||
}
|
||||
|
||||
export function useState(initial) {
|
||||
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 (!inst.alive) return
|
||||
const value = typeof next === 'function' ? next(slot.value) : next
|
||||
if (Object.is(value, slot.value)) return
|
||||
slot.value = value
|
||||
inst.rerender()
|
||||
}]
|
||||
}
|
||||
export function useRef(initial) {
|
||||
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 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
|
||||
inst.hooks[i] = { fn, deps }
|
||||
return fn
|
||||
}
|
||||
export function useEffect(fn, deps) {
|
||||
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
|
||||
effects.push(() => {
|
||||
if (typeof slot.cleanup === 'function') slot.cleanup()
|
||||
slot.deps = deps
|
||||
slot.ran = true
|
||||
slot.cleanup = fn()
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
const UI_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 (Dialog) gehört nicht zu diesen Tests
|
||||
}
|
||||
|
||||
async function loadUi() {
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const plugin = {
|
||||
name: 'pult-ui',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-ui' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'fake-ui' }, () => ({ contents: FAKE_UI, loader: 'js' }))
|
||||
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-ui-stub' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'pult-ui-stub' }, (a) => ({ contents: UI_STUBS[a.path], loader: 'js' }))
|
||||
},
|
||||
}
|
||||
const out = await esbuild.build({
|
||||
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'", resolveDir: root, loader: 'js' },
|
||||
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [plugin],
|
||||
})
|
||||
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||||
return importBundle(await esbuild.build({
|
||||
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'", resolveDir: ROOT, loader: 'js' },
|
||||
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs],
|
||||
}))
|
||||
}
|
||||
|
||||
// Baum durchsuchen und bedienen
|
||||
|
||||
Reference in new issue
Block a user