Files
tickte-system/tests/unit/hustlerHochhausWelt.test.mjs
T
ClaudeandClaude Sonnet 5.5 32f4f1d941 Hustler-Stufe: Keller und Bot-Leads erst ab Stufe 6 (Seite, Keller-Etage, Spur-Links, Abruf von /botleads)
WorkersPage: ?tab=botleads (auch mit &bot=) und unbekannte Werte fallen unterhalb von Stufe 6 auf das Hochhaus zurück, BotLeadList wird nie eingehängt; die Weiterleitungen /leads und /workers bleiben.
HochhausWelt: die Keller-Etage ist dann ein Knopf wie jede andere (zoomt heran) statt eines Links; Arne bleibt klickbar, die Zahl Bot-Leads in der Kopfzeile bleibt Text.
HustlerDialog: Spur-Links in den Keller bleiben Text. workersApi.botleads ruft unterhalb von Stufe 6 nichts ab (kein JWT, keine Anfrage), sondern liefert 404 mit deutschem Satz.
Bestehende Keller-Tests laufen ausdrücklich auf Stufe 6; neue Tests prüfen Stufe 1, 3 und 5 (gesperrt) und 6 bis 9 (offen).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:36:52 +00:00

1275 lines
74 KiB
JavaScript

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.
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 // forwardRef und useImperativeHandle liefert inzwischen der gemeinsame Mini-Läufer
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, kameraZeige, kameraZoomAuf, tippRand } 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 }
// Stufen-Sperre (hustlerStufe.js): die Zusicherungen dieser Datei gelten für das volle Haus (Stufe 6, Keller offen, Verhalten wie vor der Sperre);
// stufe: 3 (Keller gesperrt) steht in den eigenen Tests am Ende des Abschnitts „Etagen“
let props = { state, workers: state ? indexWorkers(state) : {}, onZeige() {}, onOpen() {}, stufe: 6, ...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() }
})
// Stufen-Sperre: unterhalb der Keller-Stufe (6) gibt es keinen Weg in den Keller; die Etage verhält sich wie jede andere (zoomt heran), Arne bleibt klickbar
test('Etagen bei Stufe 3 und 5: nur Freigabe ist ein Link, die Keller-Etage ist ein Knopf wie die anderen (Beschriftung, Text, kein href)', async () => {
const { ETAGEN } = await laden()
for (const stufe of [1, 3, 5]) {
const ui = await start({ stufe })
try {
const etagen = byClass(ui.tree(), 'hk-etage')
assert.equal(etagen.length, 10, `Stufe ${stufe}`)
assert.deepEqual(etagen.map((n) => n.tag), ['button', 'button', 'button', 'button', 'button', 'button', 'button', 'a', 'button', 'button'], `Stufe ${stufe}`)
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 heranzoomen',
], `Stufe ${stufe}`)
assert.deepEqual(etagen.map((n) => textOf(n)).slice(7), ['Freigabe ▸', 'Tresor', 'Keller'], `Stufe ${stufe}`)
const keller = etagen[9]
assert.equal(keller.props.type, 'button'); assert.equal(keller.props.href, undefined); assert.equal(keller.props['data-hk-etage'], 'keller')
// dieselbe Lage wie vorher, kein Loch im Haus
const e = ETAGEN.find((x) => x.id === 'keller')
assert.deepEqual(keller.props.style, { left: 960, top: e.oben, width: 512, height: e.unten - e.oben })
// im ganzen Baum führt nichts mehr in den Keller: kein Link, kein Text „Bot-Leads ▸“, kein tab=botleads
assert.deepEqual(nodes(ui.tree(), (n) => n.tag === 'a').map((n) => n.props.href), ['/hustler?tab=freigabe'])
assert.ok(!textOf(ui.tree()).includes('Bot-Leads ▸'))
assert.ok(!JSON.stringify(nodes(ui.tree(), (n) => n.tag === 'a').map((n) => n.props)).includes('botleads'))
} finally { ui.end() }
}
})
test('Stufe 3: Klick auf die Keller-Etage zoomt heran und öffnet keinen Bildschirm; die Freigabe-Etage öffnet weiter das Pult', async () => {
const { kameraEtage, ETAGEN } = await laden()
const geoeffnet = []
const restore = fensterAttrappe()
const ui = await start({ stufe: 3, onOpen: (tab) => geoeffnet.push(tab) })
try {
ui.anhaengen()
const keller = ETAGEN.find((e) => e.id === 'keller')
ui.etage('Keller').props.onClick(ereignis())
nearKamera(ui.kamera(), kameraEtage(keller.oben, keller.unten, STANDARD_GROESSE), 'Keller')
assert.ok(hasClass(ui.buehne(), 'hk-gleitet'))
assert.deepEqual(geoeffnet, [])
await wait(650)
const e = ereignis(); ui.etage('Freigabe ▸').props.onClick(e)
assert.equal(e.defaultPrevented, true); assert.deepEqual(geoeffnet, ['freigabe'])
} finally { ui.end(); restore() }
})
test('Stufe 3: Arne bleibt im Haus sichtbar und klickbar (Detail-Dialog), auch die Keller-Zahl am Schild bleibt Text', async () => {
const gezeigt = []
const ui = await start({ stufe: 3, onZeige: (id) => gezeigt.push(id), state: makeState({ kpis: { pultOpen: 2, dealsMonth: 1, botLeads: 112 } }) })
try {
const arne = ui.hustler('arne')
assert.ok(arne, 'Arne steht im Haus')
assert.match(arne.props['aria-label'], /^Arne, /)
arne.props.onClick(ereignis())
assert.deepEqual(gezeigt, ['arne'])
const schild = byClass(ui.tree(), 'hk-schildtext-keller')
assert.equal(schild.length, 1); assert.equal(textOf(schild[0]), '112 Bot-Leads')
assert.equal(schild[0].tag === 'a' || schild[0].tag === 'button', false)
} finally { ui.end() }
})
test('Stufe 6 und höher: die Keller-Etage bleibt der Link auf die Bot-Leads (wie vor der Sperre)', async () => {
for (const stufe of [6, 7, 9]) {
const ui = await start({ stufe })
try {
const keller = ui.etage('Keller · Bot-Leads ▸')
assert.equal(keller.tag, 'a', `Stufe ${stufe}`); assert.equal(keller.props.href, '/hustler?tab=botleads')
assert.equal(keller.props['aria-label'], 'Keller · Bot-Leads: Bot-Leads öffnen')
} finally { ui.end() }
}
})
// ---------- 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() }
})
// ---------- Korrekturen der Endprüfung (T13): Fokus, Tastatur, Handy, Schnee, weniger Bewegung ----------
const lies = (datei) => readFileSync(path.join(ROOT, 'src/styles/hustler', datei), 'utf8')
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '')
// Block { ... } ab der Klammer an Position `ab` (geschachtelt)
function block(text, ab) {
let tiefe = 0
for (let i = ab; i < text.length; i += 1) {
if (text[i] === '{') tiefe += 1
else if (text[i] === '}') { tiefe -= 1; if (tiefe === 0) return text.slice(ab + 1, i) }
}
throw new Error('Klammer nicht geschlossen')
}
const mediaBlock = (text, kopf) => {
const i = text.indexOf(kopf)
assert.ok(i >= 0, kopf)
return block(text, text.indexOf('{', i))
}
const leucht = (hex) => {
const c = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
const kontrast = (a, b) => { const [x, y] = [leucht(a), leucht(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05) }
// Element-Attrappe für den Tastaturfokus: Lage in der Bühne (offset*), :focus-visible und Kind der Gruppe `.hk-etage, .hk-hustler`
function fokusZiel({ left, top, width, height }, { figur = true, tastatur = true } = {}) {
const el = {
offsetLeft: left, offsetTop: top, offsetWidth: width, offsetHeight: height,
closest: () => el,
matches: (sel) => (sel === ':focus-visible' ? tastatur : sel === '.hk-hustler' ? figur : false),
}
return el
}
const fokus = (ziel, sicht) => ({ target: ziel, currentTarget: sicht })
test('M1 Fokus per Tastatur: eine Figur außerhalb des Bildes holt die Kamera heran (Kopfzeile abgezogen), mit Gleiten; sichtbar, Maus und gesperrt bewegen nichts', async () => {
const { HUSTLER, kameraEtage, kameraZeige } = await laden()
const restore = fensterAttrappe()
const ui = await start()
try {
const sicht = ui.anhaengen(fakeSicht(390, 844))
ui.etage('Werkstatt').props.onClick(ereignis()) // Handy hochkant: Etage heranzoomen
await wait(650)
const vorher = ui.kamera()
nearKamera(vorher, kameraEtage(776, 832, { w: 390, h: 844 }), 'Werkstatt nah')
// Ben (Werkstatt, rechts) liegt am rechten Rand, außerhalb der 24 px Luft
const k = HUSTLER.ben.knopf
const rechts = vorher.x + (k.left + k.w) * vorher.s
assert.ok(rechts > 390 - 24, `Ausgangslage: Ben ragt in den Rand (${rechts})`)
ui.ruf('onFocus', fokus(fokusZiel({ left: k.left, top: k.top, width: k.w, height: k.h }), sicht))
const nachher = ui.kamera()
const erwartet = kameraZeige(vorher, { left: k.left, top: k.top - 34 / vorher.s, width: k.w, height: k.h + 34 / vorher.s }, { w: 390, h: 844 }, { oben: 84 })
nearKamera(nachher, erwartet, 'Ben sichtbar')
assert.equal(nachher.s, vorher.s, 'Zoom bleibt')
assert.ok(nachher.x + (k.left + k.w) * nachher.s <= 390 - 24 + 1e-6)
assert.ok(hasClass(ui.buehne(), 'hk-gleitet'), 'gleitet')
await wait(650)
// schon sichtbar: keine Bewegung
const still = ui.kamera()
ui.ruf('onFocus', fokus(fokusZiel({ left: k.left, top: k.top, width: k.w, height: k.h }), sicht))
assert.deepEqual(ui.kamera(), still); assert.ok(!hasClass(ui.buehne(), 'hk-gleitet'))
// Mausfokus (kein :focus-visible): kein Schwenk
const bob = HUSTLER.bob.knopf
ui.ruf('onFocus', fokus(fokusZiel({ left: bob.left - 600, top: bob.top, width: bob.w, height: bob.h }, { tastatur: false }), sicht))
assert.deepEqual(ui.kamera(), still)
// ohne passendes Ziel (Fokus auf das Sichtfenster selbst): nichts
ui.ruf('onFocus', { target: { closest: () => null, matches: () => true }, currentTarget: sicht })
ui.ruf('onFocus', { target: null, currentTarget: sicht })
assert.deepEqual(ui.kamera(), still)
// Detailansicht offen: nichts
ui.set({ gesperrt: true })
ui.ruf('onFocus', fokus(fokusZiel({ left: bob.left - 600, top: bob.top, width: bob.w, height: bob.h }), sicht))
assert.deepEqual(ui.kamera(), still)
} finally { ui.end(); restore() }
})
test('M1 Fokus per Tastatur: die Kopfzeile wird gemessen (Unterkante von .hk-kopf, sonst 84 px); bei „weniger Bewegung“ springt die Kamera statt zu gleiten', async () => {
const { kameraZeige } = await laden()
const g = { w: 1280, h: 720 }
const ruhig = fensterAttrappe({ reduziert: true })
const ui = await start()
try {
const sicht = ui.anhaengen(fakeSicht(g.w, g.h))
for (let i = 0; i < 3; i += 1) ui.ruf('onKeyDown', ereignis({ key: '+' }))
const k0 = ui.kamera()
assert.ok(k0.s > 1.5)
// Etage, deren Oberkante auf dem Bildschirm bei 130 px liegt: unter 84 + 24 = 108 (Standard), aber über 150 + 24 = 174 (gemessene Kopfzeile)
const rechteck = { left: 1000, top: (130 - k0.y) / k0.s, width: 512, height: 56 }
assert.equal(kameraZeige(k0, rechteck, g, { oben: 84 }), null, 'mit Standardhöhe nichts zu tun')
const erwartet = kameraZeige(k0, rechteck, g, { oben: 150 })
assert.notEqual(erwartet, null, 'mit gemessener Kopfzeile muss die Kamera schieben')
const ziel = fokusZiel(rechteck, { figur: false })
// ohne messbare Kopfzeile (kein Geschwister .hk-kopf): Standardhöhe, keine Bewegung
ui.ruf('onFocus', fokus(ziel, sicht))
nearKamera(ui.kamera(), k0, 'ohne Messung')
// mit Messung: Kopfzeile reicht bis 150 px -> die Kamera schiebt die Etage darunter
sicht.parentElement = { querySelector: (sel) => (sel === '.hk-kopf' ? { getBoundingClientRect: () => ({ bottom: 150 }) } : null) }
ui.ruf('onFocus', fokus(ziel, sicht))
nearKamera(ui.kamera(), erwartet, 'mit Messung')
assert.ok(ui.kamera().y + rechteck.top * ui.kamera().s >= 150 + 24 - 1e-6, 'Etage liegt unter Kopfzeile + Luft')
assert.equal(ui.kamera().s, k0.s)
assert.ok(!hasClass(ui.buehne(), 'hk-gleitet'), 'bei weniger Bewegung springt die Kamera')
// eine unsinnig hohe Kopfzeile wird auf die Hälfte des Fensters begrenzt (viele Meldungen)
sicht.parentElement = { querySelector: () => ({ getBoundingClientRect: () => ({ bottom: 9999 }) }) }
const k1 = ui.kamera()
ui.ruf('onFocus', fokus(fokusZiel({ left: 1000, top: (400 - k1.y) / k1.s, width: 24, height: 32 }), sicht))
assert.ok(ui.kamera().y + ((400 - k1.y) / k1.s) * ui.kamera().s >= 360 + 24 - 1e-6, 'Obergrenze 50 %')
} finally { ui.end(); ruhig() }
})
test('K1 Zeigerdruck in die Welt gibt dem Sichtfenster den Fokus (tabindex -1, preventScroll); auf Figur/Etage und bei gesperrter Kamera nicht', async () => {
const restore = fensterAttrappe()
const ui = await start()
try {
assert.equal(ui.tree().props.tabIndex, -1)
const sicht = ui.anhaengen()
sicht.focusCalls = []
sicht.focus = (optionen) => { sicht.focusCalls.push(optionen) }
ui.ruf('onPointerDown', zeiger({ clientX: 600, clientY: 400 }))
assert.deepEqual(sicht.focusCalls, [{ preventScroll: true }])
ui.ruf('onPointerUp', zeiger({ clientX: 600, clientY: 400 }))
// auf einem Knopf/Link der Welt fokussiert der Browser selbst: kein zweiter Fokus
ui.ruf('onPointerDown', zeiger({ clientX: 600, clientY: 400, target: aufBedienbarem }))
assert.equal(sicht.focusCalls.length, 1)
ui.ruf('onPointerUp', zeiger({ clientX: 600, clientY: 400 }))
// Finger zählt wie die Maus
ui.ruf('onPointerDown', finger({ clientX: 600, clientY: 400 }))
assert.equal(sicht.focusCalls.length, 2)
ui.ruf('onPointerUp', finger({ clientX: 600, clientY: 400 }))
// Detailansicht offen: die Welt nimmt dem Dialog den Fokus nicht
ui.set({ gesperrt: true })
ui.ruf('onPointerDown', zeiger({ clientX: 600, clientY: 400 }))
assert.equal(sicht.focusCalls.length, 2)
} finally { ui.end(); restore() }
// Sichtfenster ohne focus() (Attrappe, alter Browser): kein Absturz
const r2 = fensterAttrappe()
const ohne = await start()
try { ohne.anhaengen(); ohne.ruf('onPointerDown', zeiger({ clientX: 1, clientY: 1 })) } finally { ohne.end(); r2() }
})
test('K3 Figuren melden einen Dialog (aria-haspopup), das Neon „HUSTLER“ ist für Screenreader versteckt; Etagen tragen ihre Kennung für den Rückweg', async () => {
const { ETAGEN } = await laden()
const ui = await start()
try {
const knoepfe = nodes(ui.tree(), (n) => n.tag === 'button' && n.props['data-hk-hustler'] !== undefined)
assert.equal(knoepfe.length, 19)
for (const b of knoepfe) assert.equal(b.props['aria-haspopup'], 'dialog', b.props['data-hk-hustler'])
// Etagen (die zoomen) melden keinen Dialog
for (const e of byClass(ui.tree(), 'hk-etage')) assert.equal(e.props['aria-haspopup'], undefined)
assert.equal(byClass(ui.tree(), 'hk-neon')[0].props['aria-hidden'], 'true')
assert.deepEqual(byClass(ui.tree(), 'hk-etage').map((n) => n.props['data-hk-etage']), ETAGEN.map((e) => e.id))
} finally { ui.end() }
})
test('M8 Tippflächen: die Bühne setzt die Überstände aus tippRand(Zoom) (--hk-tipp-x/-oben/-unten); sie wachsen beim Herauszoomen, hustler-welt.css wertet sie am ::after aus', async () => {
const { tippRand } = await laden()
const restore = fensterAttrappe()
const ui = await start()
try {
ui.anhaengen(fakeSicht(390, 844))
const gleich = () => {
const t = tippRand(ui.kamera().s)
const stil = ui.buehne().props.style
assert.equal(stil['--hk-tipp-x'], `${t.x.toFixed(2)}px`); assert.equal(stil['--hk-tipp-oben'], `${t.oben.toFixed(2)}px`); assert.equal(stil['--hk-tipp-unten'], `${t.unten.toFixed(2)}px`)
return t
}
const ganz = gleich()
assert.ok(ganz.x > 12, 'ganzes Haus am Handy: breiter als die alten 12 px')
ui.etage('Werkstatt').props.onClick(ereignis())
gleich()
await wait(650)
for (let i = 0; i < 40; i += 1) ui.rad(ereignis({ clientX: 195, clientY: 400, deltaY: -400, deltaMode: 0, ctrlKey: false }))
assert.equal(ui.kamera().s, 5)
assert.deepEqual({ x: gleich().x, oben: gleich().oben, unten: gleich().unten }, { x: 12, oben: 8, unten: 6 })
} finally { ui.end(); restore() }
const css = ohneKommentare(lies('hustler-welt.css'))
const regel = css.match(/\.hk-hochhaus \.hk-buehne \.hk-hustler::after\s*\{([^}]*)\}/)
assert.ok(regel, '::after-Regel der Figuren')
assert.match(regel[1], /left:\s*calc\(-1 \* var\(--hk-tipp-x, 12px\)\)/); assert.match(regel[1], /right:\s*calc\(-1 \* var\(--hk-tipp-x, 12px\)\)/)
assert.match(regel[1], /top:\s*calc\(-1 \* var\(--hk-tipp-oben, 8px\)\)/); assert.match(regel[1], /bottom:\s*calc\(-1 \* var\(--hk-tipp-unten, 6px\)\)/)
// und die Vorgabe bleibt die alte Regel aus hochhaus.css (-12/-12/-8/-6), wenn die Bühne nichts setzt
assert.match(ohneKommentare(lies('hochhaus.css')), /\.hk-hustler::after\{content:""; position:absolute; left:-12px; right:-12px; top:-8px; bottom:-6px\}/)
})
test('Schnee (R1): Bewegung per transform statt background-position, gleiche Richtung, Strecke und Dauer wie die Vorlage; Ebene um genau diese Strecke größer; Container schneidet ab', () => {
const welt = ohneKommentare(lies('hustler-welt.css'))
const vorlage = ohneKommentare(lies('hochhaus.css'))
const dauer = (text, klasse, name) => {
const m = text.match(new RegExp(`${klasse}[^{]*\\{[^}]*animation:\\s*${name}\\s+(\\d+)s linear infinite`))
assert.ok(m, `${klasse} ${name}`)
return Number(m[1])
}
for (const [i, kachel] of [[1, 512], [2, 512], [3, 768]]) {
// Vorlage: background-position: dx dy (Bild wandert um genau diese Strecke)
const v = vorlage.match(new RegExp(`@keyframes hk-h-schnee${i}\\{to\\{background-position:(-?\\d+)px (-?\\d+)px\\}\\}`))
assert.ok(v, `Vorlage hk-h-schnee${i}`)
const [dx, dy] = [Number(v[1]), Number(v[2])]
// neu: dieselbe Strecke als transform
const k = welt.match(new RegExp(`@keyframes hk-welt-schnee${i}\\s*\\{\\s*to\\s*\\{\\s*transform:\\s*translate\\((-?\\d+)px,\\s*(-?\\d+)px\\);\\s*\\}\\s*\\}`))
assert.ok(k, `Keyframes hk-welt-schnee${i} mit transform`)
assert.deepEqual([Number(k[1]), Number(k[2])], [dx, dy], `Strecke ${i}`)
assert.ok(!/background-position/.test(welt.slice(welt.indexOf(`@keyframes hk-welt-schnee${i}`), welt.indexOf(`@keyframes hk-welt-schnee${i}`) + 120)), 'kein background-position')
// gleiche Dauer wie die Vorlage
assert.equal(dauer(welt, `\\.hk-schnee-${i}::before`, `hk-welt-schnee${i}`), dauer(vorlage, `\\.hk-schnee-${i}`, `hk-h-schnee${i}`), `Dauer ${i}`)
// Kachelgröße wie die Vorlage, die Strecke ist ein Vielfaches (nahtlos)
assert.match(vorlage, new RegExp(`\\.hk-schnee-${i}\\{background:url\\([^)]*\\) 0 0 / ${kachel}px ${kachel}px repeat`))
assert.equal(Math.abs(dx) % kachel, 0); assert.equal(Math.abs(dy) % kachel, 0)
// Ebene: um |dx| breiter, um |dy| höher als das Fenster; Anfangslage so, dass die Bewegung sie nie vom Fenster wegzieht
const regel = welt.match(new RegExp(`\\.hk-schnee-${i}::before\\s*\\{([^}]*)\\}`))[1]
assert.match(regel, new RegExp(`width:\\s*calc\\(100% \\+ ${Math.abs(dx)}px\\)`)); assert.match(regel, new RegExp(`height:\\s*calc\\(100% \\+ ${Math.abs(dy)}px\\)`))
assert.match(regel, new RegExp(`left:\\s*${dx > 0 ? `-${dx}px` : '0'}[;\\s]`)); assert.match(regel, new RegExp(`top:\\s*-${dy}px`))
assert.match(regel, new RegExp(`background-size:\\s*${kachel}px ${kachel}px`))
assert.match(regel, new RegExp(`url\\(\\.\\./\\.\\./assets/hustler/welt/schnee-${i}\\.png\\)`))
}
// der Container hat kein eigenes Bild und keine eigene Animation mehr und schneidet ab
const container = welt.match(/\.hk-hochhaus \.hk-sicht > \.hk-schnee\s*\{([^}]*)\}/)[1]
assert.match(container, /background:\s*none/); assert.match(container, /animation:\s*none/); assert.match(container, /overflow:\s*hidden/); assert.match(container, /overflow:\s*clip/)
// Detailansicht offen: Schnee weg; weniger Bewegung: keine Animation; Pause: auch Pseudo-Elemente stehen
assert.match(welt, /\.hk-sicht\.hk-verdeckt > \.hk-schnee\s*\{\s*display:\s*none;?\s*\}/)
assert.match(mediaBlock(welt, '@media (prefers-reduced-motion: reduce)'), /\.hk-schnee::before\s*\{\s*animation:\s*none/)
assert.match(mediaBlock(vorlage, '@media (prefers-reduced-motion: reduce)'), /\.hk-hochhaus \.hk-schnee\{display:none\}/)
const ui = ohneKommentare(lies('hustler-ui.css'))
assert.match(ui, /\.hk-seite \.hk-steht \*::before/); assert.match(ui, /\.hk-seite \.hk-steht \*::after/)
assert.match(ui.match(/\.hk-seite \.hk-steht,[^{]*\{([^}]*)\}/)[1], /animation-play-state:\s*paused !important/)
})
test('Schnee (R1): in der Welt hängen die drei Schnee-Ebenen im Sichtfenster; die Pause (hk-steht) und die offene Detailansicht (hk-verdeckt) stehen auf demselben Element', async () => {
const ui = await start({ state: makeState({ paused: true }), gesperrt: true })
try {
const wurzel = ui.tree()
assert.ok(hasClass(wurzel, 'hk-steht')); assert.ok(hasClass(wurzel, 'hk-verdeckt'))
assert.deepEqual(wurzel.children.filter((k) => hasClass(k, 'hk-schnee')).map((k) => classes(k)[1]), ['hk-schnee-1', 'hk-schnee-2', 'hk-schnee-3'])
ui.set({ gesperrt: false })
assert.ok(hasClass(ui.tree(), 'hk-steht')); assert.ok(!hasClass(ui.tree(), 'hk-verdeckt'))
} finally { ui.end() }
})
test('M4 weniger Bewegung: alle Sprite-Schleifen im Hochhaus (und in der Detailansicht) stehen auf dem ersten Bild; jedes animierte Element der Welt ist von einer Reduziert-Regel erfasst', async () => {
const ui = css => ohneKommentare(lies(css))
const vorlage = ui('hochhaus.css')
const regel = mediaBlock(ui('hustler-ui.css'), '@media (prefers-reduced-motion: reduce)')
assert.match(regel, /\.hk-seite \.hk-hochhaus \.hk-spr\s*\{\s*animation:\s*none;?\s*\}/)
// mit höherer Spezifität als die Sprite-Regeln der Vorlage (.hk-hochhaus .hk-fig-… = 0,2,0): .hk-seite .hk-hochhaus .hk-spr = 0,3,0
assert.match(vorlage, /\.hk-hochhaus \.hk-fig-muetze-arbeiten\{[^}]*animation:/)
// Klassen mit Animation in der Vorlage (außerhalb der Reduziert-Regeln)
const reduziert = mediaBlock(vorlage, '@media (prefers-reduced-motion: reduce)')
const ohneReduziert = vorlage.replace(reduziert, '')
const animiert = new Set([...ohneReduziert.matchAll(/\.hk-hochhaus (\.hk-[\w-]+)\{[^}]*animation:/g)].map((m) => m[1].slice(1)))
assert.ok(animiert.has('hk-fig-muetze-arbeiten') && animiert.has('hk-gr-muetze-arbeiten') && animiert.has('hk-dampf') && animiert.has('hk-bildschirm'))
// erfasst: Elemente mit hk-spr, oder eine Klasse aus den Reduziert-Regeln (Vorlage, hustler-ui, hustler-welt)
const reduzierteKlassen = new Set([...[reduziert, regel, mediaBlock(ui('hustler-welt.css'), '@media (prefers-reduced-motion: reduce)')].join('\n').matchAll(/\.hk-[\w-]+/g)].map((m) => m[0].slice(1)))
const zustaende = [
makeState({ statuses: { rita: 'arbeitet', pia: 'fehler', ali: 'wartet', hanna: 'leerlauf', otto: 'arbeitet' } }),
makeState({ statuses: { otto: 'wartet' }, connected: { kim: false, lea: false } }),
]
for (const state of zustaende) {
const lauf = await start({ state })
try {
const knoten = nodes(lauf.tree(), (n) => typeof n.tag === 'string' && classes(n).some((c) => animiert.has(c)))
assert.ok(knoten.length > 20)
for (const n of knoten) {
const erfasst = hasClass(n, 'hk-spr') || classes(n).some((c) => reduzierteKlassen.has(c))
assert.ok(erfasst, `nicht erfasst: ${classes(n).join(' ')}`)
}
} finally { lauf.end() }
}
})
test('K6 Weiß auf Rot: Briefkasten-Zähler und Fehler-Marker nutzen #c8372d (Kontrast zu Weiß mindestens 4,5:1; #e2483d reichte nicht)', () => {
assert.ok(kontrast('#e2483d', '#ffffff') < 4.5, 'Ausgangsfarbe war zu hell')
assert.ok(kontrast('#c8372d', '#ffffff') >= 4.5)
assert.ok(Math.abs(kontrast('#c8372d', '#ffffff') - 5.2) < 0.1)
const briefkasten = ohneKommentare(lies('hustler-welt.css')).match(/\.hk-hochhaus \.hk-briefkasten\s*\{([^}]*)\}/)[1]
const alarm = ohneKommentare(lies('hustler-ui.css')).match(/\.hk-seite \.hk-alarm\s*\{([^}]*)\}/)[1]
for (const [wo, regel] of [['Briefkasten', briefkasten], ['Fehler-Marker', alarm]]) {
const hg = regel.match(/background:\s*(#[0-9a-f]{6})/i)[1]; const vg = regel.match(/[^-]color:\s*(#[0-9a-f]{6})/i)[1]
assert.equal(hg.toLowerCase(), '#c8372d', wo)
assert.ok(kontrast(hg, vg) >= 4.5, `${wo}: ${kontrast(hg, vg).toFixed(2)}`)
}
})
test('R4 Überlagerungen: z-index der Detailansicht liegt unter Seitenleiste (20) und Abdunkler (15) der Handy-Navigation, aber über der Kopfzeile (5)', () => {
const global = readFileSync(path.join(ROOT, 'src/styles/global.css'), 'utf8')
const sidebar = Number(global.match(/\.sidebar \{[^}]*z-index:\s*(\d+)/)?.[1] ?? 20)
const scrim = Number(global.match(/\.shell\.menu-open \.scrim \{[^}]*z-index:\s*(\d+)/)[1])
assert.equal(scrim, 15); assert.equal(sidebar, 20)
const detail = ohneKommentare(lies('hustler-detail.css')).match(/\.hk-hochhaus \.hk-dialog-ueber\s*\{([^}]*)\}/)[1]
const z = Number(detail.match(/z-index:\s*(\d+)/)[1])
assert.ok(z <= 9 && z > 5, `z-index ${z}`)
const kopf = Number(ohneKommentare(lies('hustler-kopf.css')).match(/\.hk-hochhaus \.hk-kopf\s*\{[^}]*z-index:\s*(\d+)/)[1])
assert.ok(z > kopf)
})
test('R7 Hover in WOMS-Bausteinen: Links im WOMS-Dialog bleiben beim Überfahren in der WOMS-Akzentfarbe (nicht gelb wie .hk-hochhaus a:hover)', () => {
const ui = ohneKommentare(lies('hustler-ui.css'))
assert.match(ui, /\.hk-seite \.hk-woms a:hover\s*\{\s*color:\s*var\(--accent\);?\s*\}/)
assert.match(ohneKommentare(lies('hochhaus.css')), /\.hk-hochhaus a:hover\{color:#ffe9a0\}/)
// höhere Spezifität als die Vorlage: .hk-seite .hk-woms a:hover (0,3,1) gegen .hk-hochhaus a:hover (0,2,1)
})
// ---------- 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() }
}
// Detailansicht offen (gesperrt): die Welt trägt hk-verdeckt
const gesperrt = await start({ gesperrt: true })
try { for (const c of allClasses(gesperrt.tree())) gesehen.add(c) } finally { gesperrt.end() }
assert.ok(gesehen.has('hk-verdeckt'))
// 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)
})