import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { FAKE_REACT } from './helpers/fakeReact.mjs' import { makeState, IDS } from './helpers/workersFixtures.mjs' // Hochhaus-Welt (T5): HochhausWelt.jsx und useKamera.js laufen wirklich im Mini-Läufer anstelle von React (Muster hustlerSeite.test.mjs / botleadList.test.mjs). // 1. Figuren nach Status (E5), graue Räume samt Zettel (E4), Schilder, Etagen (E8), Pause, Beschriftungen (E14). // 2. Kamera mit nachgestellten Zeigerereignissen: Rad um den Mauspunkt, Ziehen und unterdrückter Klick danach, Pinch, Doppelklick/-tipp, Tasten, Gleiten, Größenänderung, gesperrt. // Der Mini-Läufer ruft nur Objekt-Refs (globalThis.__fakeRef); die Kamera hängt über einen ref-Callback am Sichtfenster, den der Test wie React beim Einhängen selbst mit // einer Element-Attrappe ruft. forwardRef und useImperativeHandle fehlen im gemeinsamen Mini-Läufer: dieser Test ergänzt sie lokal. 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 settle = () => new Promise((resolve) => setImmediate(resolve)) const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const FAKE_REACT_PLUS = `${FAKE_REACT} export function forwardRef(render) { return (props) => render(props, props.ref ?? null) } export function useImperativeHandle(ref, create) { if (ref && typeof ref === 'object') ref.current = create() } ` const fakeReactPlus = { 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_PLUS, loader: 'js' })) }, } let bundle const laden = () => (bundle ??= esbuild.build({ stdin: { contents: [ "export { __mount, jsx } from 'react'", "export { default as HochhausWelt } from './src/components/workers/hochhaus/HochhausWelt.jsx'", "export { indexWorkers } from './src/lib/workersEvents.js'", "export { AUFZUG, ETAGEN, GRAUE_RAEUME, HUSTLER, RAEUME, SCHILDER } from './src/lib/hustlerWelt.js'", "export { figurZustand, kameraEtage, kameraGrenzen, kameraMinS, kameraPassend, kameraZoomAuf } from './src/lib/hustlerSicht.js'", "export { FLOOR_TITLES, ROLES, ROOM_TITLES, SEARCH_ROOMS } from './src/lib/workersRoster.js'", ].join('\n'), resolveDir: ROOT, loader: 'js', }, bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReactPlus], }).then(importBundle)) // ---------- Baum durchsuchen ---------- const nodes = (node, match, out = []) => { if (node === null || typeof node === 'string') return out if (match(node)) out.push(node) for (const child of node.children) nodes(child, match, out) return out } const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join('')) const classes = (n) => String(n.props.className ?? '').split(' ').filter(Boolean) const hasClass = (n, name) => classes(n).includes(name) const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name)) const allClasses = (tree) => new Set(nodes(tree, (n) => typeof n.tag === 'string').flatMap(classes)) // ---------- Attrappen ---------- // Sichtfenster: Maße, Zuhörer (Rad), Zeiger-Fang function fakeSicht(w = 1280, h = 720) { const el = { rect: { left: 0, top: 0, width: w, height: h }, listeners: [], captured: [], getBoundingClientRect() { return { ...this.rect, right: this.rect.left + this.rect.width, bottom: this.rect.top + this.rect.height } }, addEventListener(type, fn, options) { this.listeners.push({ type, fn, options }) }, removeEventListener(type, fn) { this.listeners = this.listeners.filter((l) => !(l.type === type && l.fn === fn)) }, setPointerCapture(id) { this.captured.push(id) }, releasePointerCapture(id) { this.captured = this.captured.filter((c) => c !== id) }, } return el } const ereignis = (extra = {}) => ({ defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.gestoppt = true }, ...extra, }) // Zeiger: Maus mit linker Taste oder Finger; target.closest antwortet für `.hk-etage, .hk-hustler` (frei = null) const zeiger = (extra = {}) => ereignis({ pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1, ctrlKey: false, clientX: 0, clientY: 0, target: { closest: () => null }, ...extra }) const finger = (extra = {}) => zeiger({ pointerType: 'touch', button: 0, buttons: 0, ...extra }) const aufBedienbarem = { closest: () => ({}) } // Uhr: Date.now von Hand, damit „nach einem Zug“ (250 ms) prüfbar ist function uhr(start = 1_000_000) { const echt = Date.now let t = start Date.now = () => t return { advance(ms) { t += ms }, restore() { Date.now = echt } } } // window: matchMedia („weniger Bewegung“) und devicePixelRatio function fensterAttrappe({ reduziert = false, dpr = 1 } = {}) { const gehabt = Object.hasOwn(globalThis, 'window') const alt = globalThis.window globalThis.window = { matchMedia: () => ({ matches: reduziert }), devicePixelRatio: dpr } return () => { if (gehabt) globalThis.window = alt; else delete globalThis.window } } // Beobachter für die Größe des Sichtfensters function beobachterAttrappe() { const alt = globalThis.ResizeObserver const liste = [] globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; this.beobachtet = []; this.getrennt = false; liste.push(this) } observe(el) { this.beobachtet.push(el) } disconnect() { this.getrennt = true } } return { liste, restore() { if (alt === undefined) delete globalThis.ResizeObserver; else globalThis.ResizeObserver = alt } } } // ---------- Lauf ---------- const STANDARD_GROESSE = { w: 1280, h: 720 } async function start(initial = {}) { const { __mount, jsx, HochhausWelt, indexWorkers } = await laden() const state = initial.state === undefined ? makeState() : initial.state const handle = { current: null } let props = { state, workers: state ? indexWorkers(state) : {}, onZeige() {}, onOpen() {}, ...initial } const mounted = __mount(jsx(() => jsx(HochhausWelt, { ...props, ref: handle }), {})) await settle() const view = { handle, tree: () => mounted.tree(), set(next) { props = { ...props, ...next }; mounted.rerender() }, // wie React beim Einhängen: der ref-Callback bekommt das Element anhaengen(el = fakeSicht()) { view.sicht = el; mounted.tree().props.ref(el); return el }, abhaengen() { mounted.tree().props.ref(null) }, ruf(name, e) { return mounted.tree().props[name](e) }, buehne: () => byClass(mounted.tree(), 'hk-buehne')[0], kamera() { const m = /translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(view.buehne().props.style.transform) assert.ok(m, view.buehne().props.style.transform) return { x: Number(m[1]), y: Number(m[2]), s: Number(m[3]) } }, hustler: (id) => nodes(mounted.tree(), (n) => n.tag === 'button' && n.props['data-hk-hustler'] === id)[0], etage: (name) => nodes(mounted.tree(), (n) => hasClass(n, 'hk-etage') && textOf(n).trim() === name)[0], // Zeiger-Folgen rad(e) { for (const l of view.sicht.listeners.filter((x) => x.type === 'wheel')) l.fn(e) }, end() { mounted.unmount() }, } return view } const near = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b} (±${eps})`) const nearKamera = (k, z, msg) => { near(k.s, z.s, 0.0006, `${msg} s`); near(k.x, z.x, 0.11, `${msg} x`); near(k.y, z.y, 0.11, `${msg} y`) } const welt = (k, mx, my) => ({ x: (mx - k.x) / k.s, y: (my - k.y) / k.s }) // ---------- Figuren nach Status ---------- test('Figuren: je Status Klasse, Zusätze und Beschriftung an der richtigen Figur', async () => { const state = makeState({ statuses: { rita: 'arbeitet', pia: 'fehler', ali: 'wartet', hanna: 'leerlauf', lea: 'arbeitet', otto: 'arbeitet' }, connected: { kim: false }, }) const ui = await start({ state }) try { const sprite = (id) => byClass(ui.hustler(id), 'hk-spr')[0] const extras = (id) => ({ punkte: byClass(ui.hustler(id), 'hk-punkte').length, zzz: byClass(ui.hustler(id), 'hk-zzz').length, alarm: byClass(ui.hustler(id), 'hk-alarm').length, }) // arbeitet: farbig, tippt, kein Zusatz assert.equal(ui.hustler('rita').props['aria-label'], 'Rita, Haiku, arbeitet') assert.equal(sprite('rita').props.className, 'hk-spr hk-fig-muetze-arbeiten') assert.deepEqual(extras('rita'), { punkte: 0, zzz: 0, alarm: 0 }) // wartet: Kaffeetasse und „…“-Punkte assert.equal(ui.hustler('ali').props['aria-label'], 'Ali, Haiku, wartet auf Auftrag') assert.equal(sprite('ali').props.className, 'hk-spr hk-fig-muetze-warten') assert.deepEqual(extras('ali'), { punkte: 1, zzz: 0, alarm: 0 }) // leerlauf: schläft, Zzz assert.equal(ui.hustler('hanna').props['aria-label'], 'Hanna, Haiku, Leerlauf') assert.equal(sprite('hanna').props.className, 'hk-spr hk-fig-muetze-schlafen') assert.deepEqual(extras('hanna'), { punkte: 0, zzz: 1, alarm: 0 }) // grau (nicht angeschlossen): steht, kein Zusatz assert.equal(ui.hustler('kim').props['aria-label'], 'Kim, Haiku, noch nicht angeschlossen') assert.equal(sprite('kim').props.className, 'hk-spr hk-fig-muetze-nicht') assert.deepEqual(extras('kim'), { punkte: 0, zzz: 0, alarm: 0 }) // fehler: steht und der rote „!“-Marker (aria-hidden) assert.equal(ui.hustler('pia').props['aria-label'], 'Pia, Haiku, Fehler') assert.equal(sprite('pia').props.className, 'hk-spr hk-fig-muetze-nicht') assert.deepEqual(extras('pia'), { punkte: 0, zzz: 0, alarm: 1 }) const marker = byClass(ui.hustler('pia'), 'hk-alarm')[0] assert.equal(textOf(marker), '!'); assert.equal(marker.props['aria-hidden'], 'true') // Hut nach Modell: Sonnet Kappe, Opus Krone assert.equal(sprite('bea').props.className, 'hk-spr hk-fig-kappe-arbeiten') assert.equal(sprite('bob').props.className, 'hk-spr hk-fig-krone-arbeiten') // kein Zusatz an Figuren, die nichts davon brauchen; Marker nur an pia assert.equal(byClass(ui.tree(), 'hk-alarm').length, 1) assert.equal(byClass(ui.tree(), 'hk-punkte').length, 1) assert.equal(byClass(ui.tree(), 'hk-zzz').length, 1) } finally { ui.end() } }) test('Figuren: Overseer ist ein Kristall ohne Punkte und Zzz, außerhalb von „arbeitet“ gedimmt, bei „arbeitet“ voll', async () => { for (const [status, gedimmt] of [['arbeitet', false], ['wartet', true], ['leerlauf', true], ['fehler', true]]) { const ui = await start({ state: makeState({ statuses: { otto: status } }) }) try { const k = byClass(ui.hustler('otto'), 'hk-spr')[0] assert.ok(hasClass(k, 'hk-ovs-klein'), status) assert.equal(hasClass(k, 'hk-gedimmt'), gedimmt, status) assert.equal(byClass(ui.hustler('otto'), 'hk-punkte').length + byClass(ui.hustler('otto'), 'hk-zzz').length, 0, status) assert.equal(byClass(ui.hustler('otto'), 'hk-alarm').length, status === 'fehler' ? 1 : 0, status) assert.equal(textOf(byClass(ui.hustler('otto'), 'hk-namensschild')[0]), 'Overseer') assert.match(ui.hustler('otto').props['aria-label'], /^Overseer, Opus, /) } finally { ui.end() } } }) test('Figuren: alle 19 Hustler sind echte Knöpfe mit deutschem aria-label, Lage und Namensschild', async () => { const { figurZustand, HUSTLER, ROLES } = await laden() const state = makeState({ statuses: { kim: 'leerlauf', pia: 'wartet' }, connected: { finn: false } }) const ui = await start({ state }) try { const knoepfe = nodes(ui.tree(), (n) => n.tag === 'button' && n.props['data-hk-hustler'] !== undefined) assert.equal(knoepfe.length, 19) assert.deepEqual(knoepfe.map((b) => b.props['data-hk-hustler']), ROLES.map((r) => r.workerId)) const workers = (await laden()).indexWorkers(state) const labels = new Set() for (const b of knoepfe) { const id = b.props['data-hk-hustler'] assert.equal(b.props.type, 'button'); assert.ok(hasClass(b, 'hk-hustler')) assert.equal(b.props['aria-label'], figurZustand(id, workers[id], {}).label) assert.match(b.props['aria-label'], /^[^,]+(, [^,]+){1,2}$/) labels.add(b.props['aria-label']) const k = HUSTLER[id].knopf assert.deepEqual(b.props.style, { left: k.left, top: k.top, width: k.w, height: k.h }) assert.equal(textOf(byClass(b, 'hk-namensschild')[0]), id === 'otto' ? 'Overseer' : ROLES.find((r) => r.workerId === id).name) // Sprite und Zusätze sind Bilder: aria-hidden for (const bild of nodes(b, (n) => n.tag === 'span' && hasClass(n, 'hk-spr'))) assert.equal(bild.props['aria-hidden'], 'true') } assert.equal(labels.size, 19) assert.ok(labels.has('Finn, Haiku, noch nicht angeschlossen')) assert.ok(labels.has('Overseer, Opus, arbeitet')) } finally { ui.end() } }) test('Figuren: feste, je Figur verschiedene animation-delay (wiederholbar); Bildschirm nur bei „arbeitet“, Radar nur bei Rita und arbeitet', async () => { const ui = await start() try { const delays = nodes(ui.tree(), (n) => n.tag === 'button' && n.props['data-hk-hustler'] !== undefined) .map((b) => byClass(b, 'hk-spr')[0].props.style.animationDelay) assert.equal(new Set(delays).size, 19) for (const d of delays) assert.match(d, /^-\d\.\ds$/) const nochmal = await start() try { assert.deepEqual(nodes(nochmal.tree(), (n) => n.tag === 'button' && n.props['data-hk-hustler'] !== undefined).map((b) => byClass(b, 'hk-spr')[0].props.style.animationDelay), delays) } finally { nochmal.end() } } finally { ui.end() } const { HUSTLER } = await laden() // alle arbeiten: 18 Bildschirme (ohne Overseer) und ein Radar bei Rita const alle = await start() try { const schirme = byClass(alle.tree(), 'hk-bildschirm') assert.equal(schirme.length, 18) for (const s of schirme) assert.equal(s.props['aria-hidden'], 'true') assert.ok(schirme.some((s) => s.props.style.left === HUSTLER.rita.bildschirm.left && s.props.style.top === HUSTLER.rita.bildschirm.top)) assert.ok(!schirme.some((s) => s.props.style.left === undefined)) const radar = byClass(alle.tree(), 'hk-radar') assert.equal(radar.length, 1); assert.equal(radar[0].props.style.left, HUSTLER.rita.radar.left); assert.equal(radar[0].props.style.top, HUSTLER.rita.radar.top) } finally { alle.end() } // Rita wartet, Lea grau: weder Radar noch deren Bildschirm const manche = await start({ state: makeState({ statuses: { rita: 'wartet' }, connected: { lea: false } }) }) try { assert.equal(byClass(manche.tree(), 'hk-radar').length, 0) const lagen = byClass(manche.tree(), 'hk-bildschirm').map((s) => `${s.props.style.left}/${s.props.style.top}`) assert.equal(lagen.length, 16) assert.ok(!lagen.includes(`${HUSTLER.rita.bildschirm.left}/${HUSTLER.rita.bildschirm.top}`)) assert.ok(!lagen.includes(`${HUSTLER.lea.bildschirm.left}/${HUSTLER.lea.bildschirm.top}`)) } finally { manche.end() } }) // ---------- Graue Räume ---------- test('Graue Räume: Ebene und Zettel nur für nicht angeschlossene Such-Räume, an derselben relativen Lage; sonst Raumschild', async () => { const { RAEUME, ROOM_TITLES, SEARCH_ROOMS } = await laden() const alle = Object.keys(RAEUME) // ohne graue Räume: keine Ebene, kein Zettel, ein Raumschild je Raum const hell = await start() try { assert.equal(nodes(hell.tree(), (n) => classes(n).some((c) => c.startsWith('hk-raum-grau-'))).length, 0) assert.equal(byClass(hell.tree(), 'hk-zettel').length, 0) assert.deepEqual(byClass(hell.tree(), 'hk-raumschild').map((n) => textOf(n)), alle.map((r) => ROOM_TITLES[r])) } finally { hell.end() } // alle acht Such-Räume grau const connected = Object.fromEntries(['rita', 'kim', 'pia', 'finn', 'ali', 'hanna', 'sven', 'lea'].map((id) => [id, false])) const dunkel = await start({ state: makeState({ connected }) }) try { const ebenen = nodes(dunkel.tree(), (n) => classes(n).some((c) => c.startsWith('hk-raum-grau-'))) assert.deepEqual(ebenen.map((n) => classes(n)[0]).sort(), SEARCH_ROOMS.map((r) => `hk-raum-grau-${r}`).sort()) for (const e of ebenen) { assert.equal(e.tag, 'div'); assert.equal(e.props['aria-hidden'], 'true') } const zettel = byClass(dunkel.tree(), 'hk-zettel') assert.equal(zettel.length, 8) for (const raum of SEARCH_ROOMS) { const z = zettel.find((n) => textOf(n) === `${ROOM_TITLES[raum]}nicht angeschlossen`) assert.ok(z, raum) assert.equal(z.props['aria-hidden'], 'true') assert.deepEqual(z.props.style, { left: RAEUME[raum].x0 + 16, top: RAEUME[raum].y0 + 1, width: 76 }, raum) assert.equal(nodes(z, (n) => n.tag === 'br').length, 1) } // die drei Räume der Zeichenfläche liegen genau dort, wo das Entwurfsbild den Zettel hatte const lage = (titel) => zettel.find((n) => textOf(n).startsWith(titel)).props.style assert.deepEqual([lage('Kleinanzeigen').left, lage('Kleinanzeigen').top], [1156, 559]) assert.deepEqual([lage('Portale').left, lage('Portale').top], [1256, 559]) assert.deepEqual([lage('Foren & Fragen').left, lage('Foren & Fragen').top], [1356, 559]) // grauer Raum: kein normales Raumschild mehr, die übrigen 13 bleiben const schilder = byClass(dunkel.tree(), 'hk-raumschild').map((n) => textOf(n)) assert.equal(schilder.length, alle.length - 8) for (const raum of SEARCH_ROOMS) assert.ok(!schilder.includes(ROOM_TITLES[raum]), raum) assert.ok(schilder.includes('Brücke')); assert.ok(schilder.includes('Waage')); assert.ok(schilder.includes('Dein Pult')) // Reihenfolge in der Bühne: graue Ebene vor den Schildern und vor den Figuren const kinder = dunkel.buehne().children const pos = (match) => kinder.findIndex((k) => k !== null && typeof k !== 'string' && match(k)) const ersteEbene = pos((k) => classes(k).some((c) => c.startsWith('hk-raum-grau-'))) assert.ok(ersteEbene > pos((k) => hasClass(k, 'hk-aufzug'))) assert.ok(ersteEbene < pos((k) => hasClass(k, 'hk-raumschild') || hasClass(k, 'hk-zettel'))) assert.ok(ersteEbene < pos((k) => hasClass(k, 'hk-hustler'))) } finally { dunkel.end() } }) test('Graue Räume: ein einzelner grauer Raum; Fehler macht den Raum nicht grau; nicht angeschlossen ist nur Zustand, nicht Dauer', async () => { const einzeln = await start({ state: makeState({ connected: { pia: false } }) }) try { assert.deepEqual(nodes(einzeln.tree(), (n) => classes(n).some((c) => c.startsWith('hk-raum-grau-'))).map((n) => classes(n)[0]), ['hk-raum-grau-portale']) assert.deepEqual(byClass(einzeln.tree(), 'hk-zettel').map((n) => textOf(n)), ['Portalenicht angeschlossen']) // der Dienst meldet Pia wieder angeschlossen: Ebene und Zettel verschwinden, das Raumschild kommt zurück einzeln.set({ state: makeState(), workers: (await laden()).indexWorkers(makeState()) }) assert.equal(nodes(einzeln.tree(), (n) => classes(n).some((c) => c.startsWith('hk-raum-grau-'))).length, 0) assert.equal(byClass(einzeln.tree(), 'hk-zettel').length, 0) assert.ok(byClass(einzeln.tree(), 'hk-raumschild').some((n) => textOf(n) === 'Portale')) } finally { einzeln.end() } const fehler = await start({ state: makeState({ statuses: { kim: 'fehler' } }) }) try { assert.equal(nodes(fehler.tree(), (n) => classes(n).some((c) => c.startsWith('hk-raum-grau-'))).length, 0) assert.equal(byClass(fehler.tree(), 'hk-zettel').length, 0) } finally { fehler.end() } }) // ---------- Schilder ---------- test('Schilder: Briefkasten-Zähler (0 oder fehlend -> weg), Tresor- und Keller-Text nur mit Wert', async () => { const { SCHILDER } = await laden() const gut = await start({ state: makeState({ kpis: { pultOpen: 5, dealsMonth: 3, botLeads: 112 } }) }) try { const zaehler = byClass(gut.tree(), 'hk-briefkasten') assert.equal(zaehler.length, 1); assert.equal(textOf(zaehler[0]), '5'); assert.ok(hasClass(zaehler[0], 'hk-huepf')) assert.equal(zaehler[0].props['aria-hidden'], 'true') assert.equal(zaehler[0].props.style.left, SCHILDER.briefkasten.left); assert.equal(zaehler[0].props.style.top, SCHILDER.briefkasten.top) const tresor = byClass(gut.tree(), 'hk-schildtext-tresor'); const keller = byClass(gut.tree(), 'hk-schildtext-keller') assert.equal(textOf(tresor[0]), '3 Deals'); assert.equal(textOf(keller[0]), '112 Bot-Leads') assert.equal(tresor[0].props.style.left, SCHILDER.tresor.left); assert.equal(tresor[0].props.style.top, SCHILDER.tresor.top) assert.equal(keller[0].props.style.left, SCHILDER.keller.left); assert.equal(keller[0].props.style.top, SCHILDER.keller.top) } finally { gut.end() } const leer = await start({ state: makeState({ kpis: { pultOpen: 0, dealsMonth: 1, botLeads: 0 } }) }) try { assert.equal(byClass(leer.tree(), 'hk-briefkasten').length, 0) assert.equal(textOf(byClass(leer.tree(), 'hk-schildtext-tresor')[0]), '1 Deal') assert.equal(textOf(byClass(leer.tree(), 'hk-schildtext-keller')[0]), '0 Bot-Leads') } finally { leer.end() } const fehlt = await start({ state: makeState({ kpis: { pultOpen: null, dealsMonth: undefined, botLeads: 'x' } }) }) try { assert.equal(byClass(fehlt.tree(), 'hk-briefkasten').length, 0) assert.equal(byClass(fehlt.tree(), 'hk-schildtext-tresor').length, 0) assert.equal(byClass(fehlt.tree(), 'hk-schildtext-keller').length, 0) } finally { fehlt.end() } const ohneKennzahlen = await start({ state: { ...makeState(), kpis: undefined } }) try { assert.equal(byClass(ohneKennzahlen.tree(), 'hk-briefkasten').length + byClass(ohneKennzahlen.tree(), 'hk-schildtext').length, 0) } finally { ohneKennzahlen.end() } }) test('Welt: Ebenen und Zubehör wie die Zeichenfläche (Himmel, Funkeln, Mond, Welt, Lichter, Haus, Fenster, Neon, Antenne, Aufzug, Schnee)', async () => { const { AUFZUG, SCHILDER } = await laden() const ui = await start() try { const root = ui.tree() assert.equal(root.tag, 'div'); assert.ok(hasClass(root, 'hk-sicht')) const direkt = root.children.map((k) => classes(k).join(' ')) assert.deepEqual(direkt, ['hk-himmel', 'hk-funkeln', 'hk-mond', 'hk-buehne', 'hk-schnee hk-schnee-1', 'hk-schnee hk-schnee-2', 'hk-schnee hk-schnee-3']) for (const k of root.children.filter((n) => !hasClass(n, 'hk-buehne'))) assert.equal(k.props['aria-hidden'], 'true') const b = ui.buehne() const ebenen = b.children.slice(0, 12).map((k) => classes(k).join(' ')) assert.deepEqual(ebenen, [ 'hk-lage hk-welt-bild', 'hk-lage hk-licht-1', 'hk-lage hk-licht-2', 'hk-lage hk-licht-3', 'hk-lage hk-licht-4', 'hk-lage hk-laternen', 'hk-haus hk-haus-bild', 'hk-haus hk-fenster-a', 'hk-haus hk-fenster-b', 'hk-haus hk-fenster-c', 'hk-haus hk-schild-a', 'hk-haus hk-schild-b', ]) for (const k of b.children.slice(6, 12)) assert.deepEqual(k.props.style, { left: 930, top: 370, width: 570, height: 700 }) const neon = byClass(b, 'hk-neon')[0] assert.equal(textOf(neon), 'HUSTLER') assert.equal(neon.props.style.left, SCHILDER.neon.left); assert.equal(neon.props.style.fontSize, '22px') assert.equal(byClass(b, 'hk-antenne')[0].props['aria-hidden'], 'true') assert.deepEqual(byClass(b, 'hk-seil')[0].props.style, { left: AUFZUG.seil.left, top: AUFZUG.seil.top, width: AUFZUG.seil.w, height: AUFZUG.seil.h }) assert.deepEqual(byClass(b, 'hk-aufzug')[0].props.style, { left: AUFZUG.kabine.left, top: AUFZUG.kabine.top, width: AUFZUG.kabine.w, height: AUFZUG.kabine.h }) } finally { ui.end() } }) test('Welt: children (Regie-Ebene) liegt als letztes in der Bühne, über den Figuren', async () => { const { jsx } = await laden() const ui = await start({ children: jsx('div', { className: 'regie-test' }) }) try { const kinder = ui.buehne().children const letztes = kinder[kinder.length - 1] assert.ok(hasClass(letztes, 'regie-test')) assert.ok(kinder.findIndex((k) => k === letztes) > kinder.findLastIndex((k) => k !== null && typeof k !== 'string' && hasClass(k, 'hk-hustler'))) assert.equal(byClass(ui.tree(), 'regie-test').length, 1) // und ohne children bleibt die Bühne gültig const ohne = await start() try { assert.ok(!byClass(ohne.tree(), 'regie-test').length) } finally { ohne.end() } } finally { ui.end() } }) // ---------- Etagen ---------- test('Etagen: acht Knöpfe zoomen heran, Freigabe und Keller sind Links; Lage lückenlos aus den Daten, Beschriftungen wie in der Zeichenfläche', async () => { const { ETAGEN } = await laden() const ui = await start() try { const etagen = byClass(ui.tree(), 'hk-etage') assert.equal(etagen.length, 10) assert.deepEqual(etagen.map((n) => n.tag), ['button', 'button', 'button', 'button', 'button', 'button', 'button', 'a', 'button', 'a']) assert.deepEqual(etagen.map((n) => n.props['aria-label']), [ 'Kommandobrücke heranzoomen', 'Anfragen heranzoomen', 'Ohne Website heranzoomen', 'Bewertung heranzoomen', 'Gespräche heranzoomen', 'Werkstatt heranzoomen', 'Prüfung heranzoomen', 'Freigabe: Freigabe-Pult öffnen', 'Tresor heranzoomen', 'Keller · Bot-Leads: Bot-Leads öffnen', ]) assert.deepEqual(etagen.map((n) => textOf(n)), [ 'Kommandobrücke', 'Anfragen', 'Ohne Website', 'Bewertung', 'Gespräche', 'Werkstatt', 'Prüfung', 'Freigabe ▸', 'Tresor', 'Keller · Bot-Leads ▸', ]) etagen.forEach((n, i) => { assert.deepEqual(n.props.style, { left: 960, top: ETAGEN[i].oben, width: 512, height: ETAGEN[i].unten - ETAGEN[i].oben }) if (n.tag === 'button') assert.equal(n.props.type, 'button') assert.equal(byClass(n, 'hk-etage-name').length, 1) }) // lückenlos: jede Etage beginnt dort, wo die obere endet for (let i = 1; i < etagen.length; i += 1) assert.equal(etagen[i].props.style.top, etagen[i - 1].props.style.top + etagen[i - 1].props.style.height) const links = etagen.filter((n) => n.tag === 'a') assert.deepEqual(links.map((n) => n.props.href), ['/hustler?tab=freigabe', '/hustler?tab=botleads']) for (const l of links) assert.equal(l.props.draggable, false) } finally { ui.end() } }) test('Etagen: Klick auf Freigabe/Keller ruft onOpen mit dem Tab (kein Seitenwechsel durch den Link); Strg, Cmd, Umschalt und Mitteltaste lassen den Link in Ruhe', async () => { const geoeffnet = [] const ui = await start({ onOpen: (tab) => geoeffnet.push(tab) }) try { const freigabe = ui.etage('Freigabe ▸'); const keller = ui.etage('Keller · Bot-Leads ▸') const e1 = ereignis(); freigabe.props.onClick(e1) assert.equal(e1.defaultPrevented, true) const e2 = ereignis(); keller.props.onClick(e2) assert.equal(e2.defaultPrevented, true) assert.deepEqual(geoeffnet, ['freigabe', 'botleads']) for (const zusatz of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { button: 1 }]) { const e = ereignis(zusatz); freigabe.props.onClick(e); keller.props.onClick(ereignis(zusatz)) assert.equal(e.defaultPrevented, false, JSON.stringify(zusatz)) } assert.deepEqual(geoeffnet, ['freigabe', 'botleads']) } finally { ui.end() } }) test('Etagen: ein Etagen-Knopf zoomt heran (Kamera wie kameraEtage), ohne onOpen', async () => { const { kameraEtage, ETAGEN } = await laden() const geoeffnet = [] const restore = fensterAttrappe() const ui = await start({ onOpen: (tab) => geoeffnet.push(tab) }) try { ui.anhaengen() const werkstatt = ETAGEN.find((e) => e.id === 'werkstatt') ui.etage('Werkstatt').props.onClick(ereignis()) nearKamera(ui.kamera(), kameraEtage(werkstatt.oben, werkstatt.unten, STANDARD_GROESSE), 'Werkstatt') assert.ok(hasClass(ui.buehne(), 'hk-gleitet')) assert.deepEqual(geoeffnet, []) await wait(650) assert.ok(!hasClass(ui.buehne(), 'hk-gleitet')) } finally { ui.end(); restore() } }) // ---------- Klicks, Pause, gesperrt ---------- test('Figur-Klick ruft onZeige(id), jeder Hustler mit seiner Kennung', async () => { const gezeigt = [] const ui = await start({ onZeige: (id) => gezeigt.push(id) }) try { for (const id of IDS) ui.hustler(id).props.onClick(ereignis()) assert.deepEqual(gezeigt, IDS) assert.equal(IDS.length, 19) } finally { ui.end() } // ohne Rückruf kein Absturz const stumm = await start({ onZeige: undefined, onOpen: undefined }) try { stumm.hustler('lea').props.onClick(ereignis()); stumm.etage('Freigabe ▸').props.onClick(ereignis()) } finally { stumm.end() } }) test('Pause: nur bei state.paused trägt die Bühne hk-steht (Animationen stehen), sonst nicht', async () => { const lauf = await start({ state: makeState({ paused: false }) }) try { assert.ok(!hasClass(lauf.buehne(), 'hk-steht')); assert.equal(byClass(lauf.tree(), 'hk-steht').length, 0) lauf.set({ state: makeState({ paused: true }) }) assert.ok(hasClass(lauf.buehne(), 'hk-steht')) assert.ok(hasClass(lauf.buehne(), 'hk-buehne')) // auch Himmel und Schnee außerhalb der Bühne stehen: das Sichtfenster trägt hk-steht ebenfalls assert.ok(hasClass(lauf.tree(), 'hk-steht')) lauf.set({ state: makeState({ paused: false }) }) assert.ok(!hasClass(lauf.buehne(), 'hk-steht')) } finally { lauf.end() } const ungueltig = await start({ state: { ...makeState(), paused: 'ja' } }) try { assert.ok(!hasClass(ungueltig.buehne(), 'hk-steht')) } finally { ungueltig.end() } }) test('Beschriftungsgröße: Etikett und Namensschild folgen dem Zoom (Grenzen 8-18 px und 4-14 px) über --hk-etikett und --hk-name', async () => { const restore = fensterAttrappe() const ui = await start() try { ui.anhaengen() const s = ui.kamera().s const stil = ui.buehne().props.style assert.equal(stil['--hk-etikett'], `${Math.min(18, Math.max(8, 15 / s)).toFixed(2)}px`) assert.equal(stil['--hk-name'], `${Math.min(14, Math.max(4, 12 / s)).toFixed(2)}px`) // stark hineingezoomt: Etikett klein (8 px), Name klein (4 px bei s = 5) for (let i = 0; i < 40; i += 1) ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: -400, deltaMode: 0, ctrlKey: false })) assert.equal(ui.kamera().s, 5) assert.equal(ui.buehne().props.style['--hk-etikett'], '8.00px'); assert.equal(ui.buehne().props.style['--hk-name'], '4.00px') // weit heraus: Etikett wächst, aber höchstens 18 px, Name höchstens 14 px for (let i = 0; i < 80; i += 1) ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: 400, deltaMode: 0, ctrlKey: false })) assert.equal(ui.buehne().props.style['--hk-etikett'], `${Math.min(18, 15 / ui.kamera().s).toFixed(2)}px`) assert.ok(Number.parseFloat(ui.buehne().props.style['--hk-name']) <= 14) } finally { ui.end(); restore() } }) test('Bildschärfe: pixelated ab Zoom x Geräte-Dichte >= 1, darunter auto', async () => { // ganzes Haus bei 1280 x 720 (Zoom ca. 0,88): bei Dichte 1 weich, bei Dichte 2 scharf; hineingezoomt (Zoom 1,2) auch bei Dichte 1 scharf for (const [dpr, hinein, erwartet] of [[1, false, 'auto'], [2, false, 'pixelated'], [1, true, 'pixelated'], [0.5, true, 'auto']]) { const restore = fensterAttrappe({ dpr }) const ui = await start() try { ui.anhaengen() if (hinein) ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: -200, deltaMode: 0, ctrlKey: false })) const s = ui.kamera().s assert.equal(ui.buehne().props.style.imageRendering, s * dpr < 1 ? 'auto' : 'pixelated') assert.equal(ui.buehne().props.style.imageRendering, erwartet, `Dichte ${dpr} Zoom ${s}`) } finally { ui.end(); restore() } } }) // ---------- Kamera ---------- test('Kamera: vor dem Einhängen steht das ganze Haus für 1280 x 720, danach passt es zum Sichtfenster; Rad-Zuhörer mit passive: false', async () => { const { kameraPassend } = await laden() const restore = fensterAttrappe() const ui = await start() try { nearKamera(ui.kamera(), kameraPassend(STANDARD_GROESSE), 'vorher') const el = ui.anhaengen(fakeSicht(1000, 600)) nearKamera(ui.kamera(), kameraPassend({ w: 1000, h: 600 }), 'passend') const rad = el.listeners.filter((l) => l.type === 'wheel') assert.equal(rad.length, 1); assert.deepEqual(rad[0].options, { passive: false }) // erneutes Einhängen desselben Elements ändert nichts ui.anhaengen(el) assert.equal(el.listeners.filter((l) => l.type === 'wheel').length, 1) // das Sichtfenster trägt die Zeiger-Handler, touch-action steht im CSS for (const name of ['onPointerDown', 'onPointerMove', 'onPointerUp', 'onPointerCancel', 'onDoubleClick', 'onKeyDown']) assert.equal(typeof ui.tree().props[name], 'function', name) } finally { ui.end(); restore() } }) test('Kamera: Mausrad zoomt um den Mauspunkt (der Weltpunkt darunter bleibt), nimmt dem Browser das Rad und bleibt zwischen cover und 5', async () => { const { kameraMinS } = await laden() const restore = fensterAttrappe() const ui = await start() try { ui.anhaengen() const vorher = ui.kamera() const punkt = welt(vorher, 640, 360) const e = ereignis({ clientX: 640, clientY: 360, deltaY: -200, deltaMode: 0, ctrlKey: false }) ui.rad(e) assert.equal(e.defaultPrevented, true) const nachher = ui.kamera() near(nachher.s / vorher.s, Math.exp(200 * 0.0016), 0.002, 'Zoomfaktor') near(welt(nachher, 640, 360).x, punkt.x, 0.5, 'Weltpunkt x'); near(welt(nachher, 640, 360).y, punkt.y, 0.5, 'Weltpunkt y') // Zeilen-Modus und Strg (Pinch-Geste am Touchpad) zoomen stärker const z0 = ui.kamera().s ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: -2, deltaMode: 1, ctrlKey: false })) near(ui.kamera().s / z0, Math.exp(2 * 0.05), 0.002, 'deltaMode 1') const z1 = ui.kamera().s ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: -2, deltaMode: 0, ctrlKey: true })) near(ui.kamera().s / z1, Math.exp(2 * 0.016), 0.002, 'Strg') // Grenzen for (let i = 0; i < 60; i += 1) ui.rad(ereignis({ clientX: 100, clientY: 500, deltaY: -500, deltaMode: 0, ctrlKey: false })) assert.equal(ui.kamera().s, 5) for (let i = 0; i < 120; i += 1) ui.rad(ereignis({ clientX: 100, clientY: 500, deltaY: 500, deltaMode: 0, ctrlKey: false })) near(ui.kamera().s, kameraMinS(STANDARD_GROESSE), 0.0006, 'kleinster Zoom') const k = ui.kamera() assert.ok(k.x <= 0.05 && k.x >= STANDARD_GROESSE.w - 2400 * k.s - 0.05, 'links/rechts kein leerer Rand') assert.ok(k.y <= STANDARD_GROESSE.h * 0.35 + 0.05 && k.y >= STANDARD_GROESSE.h - 1200 * k.s - 0.05, 'oben/unten in den Grenzen') } finally { ui.end(); restore() } }) test('Kamera: Ziehen verschiebt (Schwelle 5 px, Zeiger-Fang, hk-zieht) und unterdrückt danach Figur- und Link-Klick für 250 ms', async () => { const gezeigt = []; const geoeffnet = [] const restore = fensterAttrappe(); const t = uhr() const ui = await start({ onZeige: (id) => gezeigt.push(id), onOpen: (tab) => geoeffnet.push(tab) }) try { const el = ui.anhaengen() const start0 = ui.kamera() // unter der Schwelle: kein Zug, der Klick geht durch ui.ruf('onPointerDown', zeiger({ clientX: 600, clientY: 400 })) ui.ruf('onPointerMove', zeiger({ clientX: 603, clientY: 402 })) assert.deepEqual(ui.kamera(), start0); assert.ok(!hasClass(ui.tree(), 'hk-zieht')); assert.deepEqual(el.captured, []) ui.ruf('onPointerUp', zeiger({ clientX: 603, clientY: 402 })) ui.hustler('lea').props.onClick(ereignis()) assert.deepEqual(gezeigt, ['lea']) // über der Schwelle: Zug ui.ruf('onPointerDown', zeiger({ clientX: 600, clientY: 400 })) ui.ruf('onPointerMove', zeiger({ clientX: 640, clientY: 420 })) assert.ok(hasClass(ui.tree(), 'hk-zieht')); assert.deepEqual(el.captured, [1]) const mitte = ui.kamera() near(mitte.x, start0.x + 40, 0.11, 'x +40'); near(mitte.y, start0.y + 20, 0.11, 'y +20'); assert.equal(mitte.s, start0.s) ui.ruf('onPointerMove', zeiger({ clientX: 660, clientY: 410 })) near(ui.kamera().x, start0.x + 60, 0.11, 'x +60'); near(ui.kamera().y, start0.y + 10, 0.11, 'y +10') ui.ruf('onPointerUp', zeiger({ clientX: 660, clientY: 410 })) assert.ok(!hasClass(ui.tree(), 'hk-zieht')); assert.deepEqual(el.captured, []) // direkt danach: kein Figur-Klick, kein Link (preventDefault), kein Etagen-Zoom const vorKlick = ui.kamera() ui.hustler('rita').props.onClick(ereignis()) const link = ereignis(); ui.etage('Freigabe ▸').props.onClick(link) ui.etage('Prüfung').props.onClick(ereignis()) assert.deepEqual(gezeigt, ['lea']); assert.deepEqual(geoeffnet, []) assert.equal(link.defaultPrevented, true); assert.equal(link.gestoppt, true) assert.deepEqual(ui.kamera(), vorKlick) ui.ruf('onDoubleClick', ereignis({ target: { closest: () => null } })) assert.deepEqual(ui.kamera(), vorKlick, 'Doppelklick direkt nach dem Zug zählt nicht') // nach 250 ms wieder normal t.advance(300) ui.hustler('rita').props.onClick(ereignis()) ui.etage('Freigabe ▸').props.onClick(ereignis()) assert.deepEqual(gezeigt, ['lea', 'rita']); assert.deepEqual(geoeffnet, ['freigabe']) } finally { ui.end(); t.restore(); restore() } }) test('Kamera: Ziehen bleibt in den Grenzen (nie Himmel über dem Rand); die Maus ohne gedrückte Taste beendet den Zug; rechte Taste und Strg ziehen nicht', async () => { const restore = fensterAttrappe(); const t = uhr() const ui = await start() try { const el = ui.anhaengen() ui.ruf('onPointerDown', zeiger({ clientX: 100, clientY: 100 })) ui.ruf('onPointerMove', zeiger({ clientX: 1200, clientY: 700 })) let k = ui.kamera() assert.ok(k.x <= 0.05 && k.y <= STANDARD_GROESSE.h * 0.35 + 0.05, `rechts/oben begrenzt ${JSON.stringify(k)}`) // Taste ist losgelassen, ohne dass pointerup beim Fenster ankam ui.ruf('onPointerMove', zeiger({ clientX: 1100, clientY: 650, buttons: 0 })) assert.ok(!hasClass(ui.tree(), 'hk-zieht')); assert.deepEqual(el.captured, []) t.advance(300) k = ui.kamera() ui.ruf('onPointerDown', zeiger({ button: 2, buttons: 2, clientX: 300, clientY: 300 })) ui.ruf('onPointerMove', zeiger({ button: 2, buttons: 2, clientX: 400, clientY: 300 })) ui.ruf('onPointerDown', zeiger({ ctrlKey: true, clientX: 300, clientY: 300, pointerId: 5 })) ui.ruf('onPointerMove', zeiger({ ctrlKey: true, clientX: 400, clientY: 300, pointerId: 5 })) assert.deepEqual(ui.kamera(), k) } finally { ui.end(); t.restore(); restore() } }) test('Kamera: Pinch mit zwei Fingern zoomt um die Mitte (der Weltpunkt unter den Fingern wandert mit), danach kurz kein Klick', async () => { const gezeigt = [] const restore = fensterAttrappe(); const t = uhr() const ui = await start({ onZeige: (id) => gezeigt.push(id) }) try { const el = ui.anhaengen() const k0 = ui.kamera() const unter = welt(k0, 600, 300) ui.ruf('onPointerDown', finger({ pointerId: 1, clientX: 500, clientY: 300 })) ui.ruf('onPointerDown', finger({ pointerId: 2, clientX: 700, clientY: 300 })) ui.ruf('onPointerMove', finger({ pointerId: 2, clientX: 900, clientY: 300 })) const k1 = ui.kamera() near(k1.s / k0.s, 2, 0.01, 'Abstand verdoppelt -> Zoom verdoppelt') near(welt(k1, 700, 300).x, unter.x, 0.6, 'Weltpunkt x'); near(welt(k1, 700, 300).y, unter.y, 0.6, 'Weltpunkt y') assert.deepEqual(el.captured, [], 'beim Pinch kein Zeiger-Fang') // Finger weg: der Rest ist kein Zug; Klicks sind kurz gesperrt ui.ruf('onPointerUp', finger({ pointerId: 2, clientX: 900, clientY: 300 })) ui.ruf('onPointerMove', finger({ pointerId: 1, clientX: 560, clientY: 340 })) assert.deepEqual(ui.kamera(), k1) ui.ruf('onPointerUp', finger({ pointerId: 1, clientX: 560, clientY: 340 })) ui.hustler('bob').props.onClick(ereignis()) assert.deepEqual(gezeigt, []) t.advance(500) ui.hustler('bob').props.onClick(ereignis()) assert.deepEqual(gezeigt, ['bob']) // Zoom bleibt zwischen cover und 5: sehr großer Abstand ui.ruf('onPointerDown', finger({ pointerId: 3, clientX: 600, clientY: 360 })) ui.ruf('onPointerDown', finger({ pointerId: 4, clientX: 610, clientY: 360 })) ui.ruf('onPointerMove', finger({ pointerId: 4, clientX: 1200, clientY: 360 })) assert.equal(ui.kamera().s, 5) } finally { ui.end(); t.restore(); restore() } }) test('Kamera: Doppelklick und Doppeltipp (freie Fläche) zeigen das ganze Haus; auf Etage oder Figur nicht', async () => { const { kameraPassend } = await laden() const restore = fensterAttrappe(); const t = uhr() const ui = await start() try { ui.anhaengen() const ganzHaus = kameraPassend(STANDARD_GROESSE) const hineinzoomen = () => ui.ruf('onKeyDown', ereignis({ key: '+' })) // Doppelklick hineinzoomen(); hineinzoomen() assert.ok(ui.kamera().s > ganzHaus.s) ui.ruf('onDoubleClick', ereignis({ target: aufBedienbarem })) assert.ok(ui.kamera().s > ganzHaus.s, 'auf Figur/Etage nichts') ui.ruf('onDoubleClick', ereignis({ target: { closest: () => null } })) nearKamera(ui.kamera(), ganzHaus, 'Doppelklick') await wait(650) // Doppeltipp: zweimal kurz hintereinander auf freier Fläche hineinzoomen(); hineinzoomen() const tipp = () => { ui.ruf('onPointerDown', finger({ pointerId: 7, clientX: 300, clientY: 300 })); ui.ruf('onPointerUp', finger({ pointerId: 7, clientX: 300, clientY: 300 })) } tipp(); t.advance(100) assert.ok(ui.kamera().s > ganzHaus.s) tipp() nearKamera(ui.kamera(), ganzHaus, 'Doppeltipp') await wait(650) // zu langsam (400 ms): kein Doppeltipp hineinzoomen(); hineinzoomen() const gezoomt = ui.kamera() tipp(); t.advance(400); tipp() assert.deepEqual(ui.kamera(), gezoomt) // Doppeltipp auf einer Figur: nichts t.advance(500) const aufFigur = () => { ui.ruf('onPointerDown', finger({ pointerId: 8, clientX: 300, clientY: 300 })); ui.ruf('onPointerUp', finger({ pointerId: 8, clientX: 300, clientY: 300, target: aufBedienbarem })) } aufFigur(); t.advance(100); aufFigur() assert.deepEqual(ui.kamera(), gezoomt) } finally { ui.end(); t.restore(); restore() } }) test('Kamera: Tasten + = - zoomen um die Mitte (Faktor 1,25), 0 und Esc zeigen das ganze Haus; Strg/Cmd/Alt und andere Tasten bleiben dem Browser', async () => { const { kameraPassend } = await laden() const restore = fensterAttrappe() const ui = await start() try { ui.anhaengen() const k0 = ui.kamera() const mitte = welt(k0, 640, 360) const taste = (key, extra = {}) => { const e = ereignis({ key, ...extra }); ui.ruf('onKeyDown', e); return e } let e = taste('+') assert.equal(e.defaultPrevented, true); near(ui.kamera().s, k0.s * 1.25, 0.0006, '+') near(welt(ui.kamera(), 640, 360).x, mitte.x, 0.6, 'Mitte x'); near(welt(ui.kamera(), 640, 360).y, mitte.y, 0.6, 'Mitte y') e = taste('='); assert.equal(e.defaultPrevented, true); near(ui.kamera().s, k0.s * 1.25 * 1.25, 0.001, '=') e = taste('-'); assert.equal(e.defaultPrevented, true); near(ui.kamera().s, k0.s * 1.25, 0.001, '-') const vorher = ui.kamera() for (const [key, extra] of [['+', { ctrlKey: true }], ['-', { metaKey: true }], ['0', { altKey: true }], ['a', {}], ['Tab', {}], ['Enter', {}], ['ArrowLeft', {}]]) { e = taste(key, extra); assert.equal(e.defaultPrevented, false, key); assert.deepEqual(ui.kamera(), vorher, key) } e = taste('0'); assert.equal(e.defaultPrevented, true); nearKamera(ui.kamera(), kameraPassend(STANDARD_GROESSE), '0') await wait(650) taste('+'); taste('+') e = taste('Escape'); assert.equal(e.defaultPrevented, true); nearKamera(ui.kamera(), kameraPassend(STANDARD_GROESSE), 'Esc') } finally { ui.end(); restore() } }) test('Kamera: gesperrt (Detailansicht offen) blockiert Rad, Ziehen, Pinch, Tasten und Doppelklick; entsperrt wirkt alles wieder', async () => { const restore = fensterAttrappe(); const t = uhr() const ui = await start({ gesperrt: true }) try { ui.anhaengen() const k0 = ui.kamera() const rad = ereignis({ clientX: 640, clientY: 360, deltaY: -300, deltaMode: 0, ctrlKey: false }) ui.rad(rad) ui.ruf('onPointerDown', zeiger({ clientX: 600, clientY: 400 })) ui.ruf('onPointerMove', zeiger({ clientX: 700, clientY: 450 })) ui.ruf('onPointerUp', zeiger({ clientX: 700, clientY: 450 })) ui.ruf('onPointerDown', finger({ pointerId: 1, clientX: 500, clientY: 300 })) ui.ruf('onPointerDown', finger({ pointerId: 2, clientX: 700, clientY: 300 })) ui.ruf('onPointerMove', finger({ pointerId: 2, clientX: 900, clientY: 300 })) ui.ruf('onPointerUp', finger({ pointerId: 2 })); ui.ruf('onPointerUp', finger({ pointerId: 1 })) const taste = ereignis({ key: '+' }); ui.ruf('onKeyDown', taste) ui.ruf('onKeyDown', ereignis({ key: '0' })) ui.ruf('onDoubleClick', ereignis({ target: { closest: () => null } })) assert.deepEqual(ui.kamera(), k0) assert.equal(rad.defaultPrevented, false); assert.equal(taste.defaultPrevented, false) // Detailansicht zu: alles wirkt wieder ui.set({ gesperrt: false }) ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: -300, deltaMode: 0, ctrlKey: false })) assert.ok(ui.kamera().s > k0.s) t.advance(1000) } finally { ui.end(); t.restore(); restore() } }) test('Kamera: Gleiten nur für „ganzes Haus“ und Etage (Klasse hk-gleitet, endet nach 0,6 s), nie beim Rad/Ziehen; bei „weniger Bewegung“ nie', async () => { const restore = fensterAttrappe() const ui = await start() try { ui.anhaengen() ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: -300, deltaMode: 0, ctrlKey: false })) assert.ok(!hasClass(ui.buehne(), 'hk-gleitet')) ui.ruf('onKeyDown', ereignis({ key: '0' })) assert.ok(hasClass(ui.buehne(), 'hk-gleitet')) // während des Gleitens ignoriert die Kamera Rad und Zeiger-Start const mitten = ui.kamera() ui.rad(ereignis({ clientX: 640, clientY: 360, deltaY: -300, deltaMode: 0, ctrlKey: false })) ui.ruf('onPointerDown', zeiger({ clientX: 600, clientY: 400 })); ui.ruf('onPointerMove', zeiger({ clientX: 700, clientY: 450 })) assert.deepEqual(ui.kamera(), mitten) await wait(650) assert.ok(!hasClass(ui.buehne(), 'hk-gleitet')) } finally { ui.end(); restore() } const ruhig = fensterAttrappe({ reduziert: true }) const still = await start() try { still.anhaengen() still.ruf('onKeyDown', ereignis({ key: '+' })); still.ruf('onKeyDown', ereignis({ key: '0' })) assert.ok(!hasClass(still.buehne(), 'hk-gleitet')) still.etage('Prüfung').props.onClick(ereignis()) assert.ok(!hasClass(still.buehne(), 'hk-gleitet')) } finally { still.end(); ruhig() } }) test('Kamera: ganz() über das Handle der Komponente; Größenänderung passt neu ein, nach eigenem Zoom nur die Grenzen', async () => { const { kameraPassend, kameraGrenzen } = await laden() const restore = fensterAttrappe(); const beob = beobachterAttrappe() const ui = await start() try { const el = ui.anhaengen(fakeSicht(1280, 720)) assert.equal(typeof ui.handle.current.ganz, 'function') assert.deepEqual(Object.keys(ui.handle.current), ['ganz']) assert.equal(beob.liste.length, 1); assert.deepEqual(beob.liste[0].beobachtet, [el]) // ohne eigenen Zoom: Größenänderung passt neu ein el.rect.width = 1000; el.rect.height = 600 beob.liste[0].cb() nearKamera(ui.kamera(), kameraPassend({ w: 1000, h: 600 }), 'neu eingepasst') // nach eigenem Zoom bleibt der Zoom, nur die Grenzen werden nachgezogen ui.ruf('onKeyDown', ereignis({ key: '+' })); ui.ruf('onKeyDown', ereignis({ key: '+' })) const gezoomt = ui.kamera() el.rect.width = 700; el.rect.height = 500 beob.liste[0].cb() nearKamera(ui.kamera(), kameraGrenzen(gezoomt, { w: 700, h: 500 }), 'nur Grenzen') // das Handle stellt das ganze Haus wieder her und gilt wieder als „nicht selbst gezoomt“ ui.handle.current.ganz() nearKamera(ui.kamera(), kameraPassend({ w: 700, h: 500 }), 'ganz()') await wait(650) el.rect.width = 1280; el.rect.height = 720 beob.liste[0].cb() nearKamera(ui.kamera(), kameraPassend(STANDARD_GROESSE), 'wieder eingepasst') } finally { ui.end(); beob.restore(); restore() } }) test('Kamera: beim Abbau fallen Rad-Zuhörer, Beobachter und Gleit-Timer weg; ein neues Element wird neu verbunden', async () => { const restore = fensterAttrappe(); const beob = beobachterAttrappe() const ui = await start() try { const el = ui.anhaengen() ui.ruf('onKeyDown', ereignis({ key: '0' })) // Gleit-Timer läuft assert.equal(el.listeners.length, 1) ui.end() assert.equal(el.listeners.length, 0, 'Rad-Zuhörer entfernt') assert.equal(beob.liste[0].getrennt, true, 'Beobachter getrennt') // Austausch des Elements (React ruft den Callback erst mit null, dann mit dem neuen Element) } finally { beob.restore(); restore() } const wechsel = await start() const r2 = fensterAttrappe(); const b2 = beobachterAttrappe() try { const a = wechsel.anhaengen(fakeSicht()) wechsel.abhaengen() assert.equal(a.listeners.length, 0); assert.equal(b2.liste[0].getrennt, true) const b = wechsel.anhaengen(fakeSicht(800, 600)) assert.equal(b.listeners.length, 1); assert.equal(b2.liste.length, 2) } finally { wechsel.end(); b2.restore(); r2() } }) // ---------- Aufbau ---------- test('Aufbau: kein CSS-Import, keine PNG-Importe, kein innerHTML; jede gerenderte Klasse steht in einer der Hustler-Stilregeln', async () => { for (const datei of ['src/components/workers/hochhaus/HochhausWelt.jsx', 'src/components/workers/hochhaus/useKamera.js']) { const quelle = readFileSync(path.join(ROOT, datei), 'utf8') assert.ok(!/\.css['"]/.test(quelle), `${datei}: kein CSS-Import`) assert.ok(!/\.png/.test(quelle), `${datei}: keine Bilder`) assert.ok(!/dangerouslySetInnerHTML|\.innerHTML|\.outerHTML|insertAdjacentHTML/.test(quelle), `${datei}: kein innerHTML`) assert.ok(!/^import .*from ['"][^'"]*\.(css|png)['"]/m.test(quelle)) } const css = ['hochhaus.css', 'hustler-ui.css', 'hustler-welt.css'].map((d) => readFileSync(path.join(ROOT, 'src/styles/hustler', d), 'utf8')).join('\n') const { SEARCH_ROOMS } = await laden() const gesehen = new Set() const zustaende = [ makeState({ statuses: { rita: 'arbeitet', pia: 'fehler', ali: 'wartet', hanna: 'leerlauf', otto: 'wartet' }, paused: true }), makeState({ connected: Object.fromEntries(['rita', 'kim', 'pia', 'finn', 'ali', 'hanna', 'sven', 'lea'].map((id) => [id, false])) }), ] for (const state of zustaende) { const ui = await start({ state }) try { for (const c of allClasses(ui.tree())) gesehen.add(c) } finally { ui.end() } } // Klassen, die nur im Lauf entstehen: Ziehen und Gleiten gesehen.add('hk-zieht'); gesehen.add('hk-gleitet') assert.ok(SEARCH_ROOMS.every((r) => gesehen.has(`hk-raum-grau-${r}`))) const fehlend = [...gesehen].filter((c) => !new RegExp(`\\.${c}(?![\\w-])`).test(css)) assert.deepEqual(fehlend, []) for (const c of gesehen) assert.ok(c.startsWith('hk-'), c) })