Hustler-Pixelwelt T5: Hochhaus-Welt (Ebenen, Figuren nach Status, graue Räume, Etagen) und Kamera
HochhausWelt.jsx baut die Welt 1:1 aus dem Markup der Zeichenfläche (Himmel, Bühne 2400x1200, Haus-, Licht- und Fensterebenen, Neon, Aufzug, Raumschilder, Bildschirme, Radar, Schilder, Etagen, 19 Hustler, Schnee); Figuren, Zusätze, graue Raum-Ebenen samt Papier-Zettel, Briefkasten-Zähler und Etagen-Links folgen dem Dienst-Zustand. useKamera.js ist der Hook für Rad, Ziehen, Pinch, Doppeltipp, Tasten und Gleiten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f4350495f
commit
94b88462c7
4 files changed
+1466
No files matched your search
@@ -0,0 +1,233 @@
|
||||
// Die Spielwelt des Hochhauses (Hustler-Pixelwelt T5, Spec E1/E4/E5/E8/E14): 1:1 aus dem Markup der Zeichenfläche (entwurf/markup.html), Klassen mit hk- davor.
|
||||
// Himmel, Funkeln, Mond, die 2400 x 1200 große Bühne (per transform verschoben und gezoomt) mit Welt-, Licht-, Haus- und Fensterebenen, Neon, Antenne, Aufzug,
|
||||
// Raumschilder, Bildschirme, Radar, Briefkasten-Zähler, Tresor-/Keller-Text, den Etagen (Knöpfe, Freigabe und Keller sind Links) und den 19 Hustlern; davor der Schnee.
|
||||
// Alles hängt am Dienst-Zustand: Figur und Zusätze nach Status (figurZustand), graue Raum-Ebene samt Papierschild nur für nicht angeschlossene Such-Räume.
|
||||
// Die Kamera (Rad, Ziehen, Pinch, Tasten) steckt in useKamera. `children` (die Regie-Ebene) liegt in der Bühne über den Figuren, in Weltkoordinaten.
|
||||
// Kein CSS-Import (die Stile hängt WorkersPage ein: styles/hustler/hochhaus.css, hustler-ui.css, hustler-welt.css), keine Bilder, kein innerHTML.
|
||||
import { forwardRef, useImperativeHandle } from 'react'
|
||||
import { anzeigeName, figurZustand, schilder } from '../../../lib/hustlerSicht.js'
|
||||
import { AUFZUG, ETAGEN, ETAGEN_SPALTE, HAUS_BILD, HUSTLER, RAEUME, SCHILDER } from '../../../lib/hustlerWelt.js'
|
||||
import { FLOOR_TITLES, ROLES, ROOM_TITLES } from '../../../lib/workersRoster.js'
|
||||
import useKamera from './useKamera.js'
|
||||
|
||||
// Etagen, die ein Bildschirm sind (Link statt Zoom): Text und Label wörtlich aus der Zeichenfläche
|
||||
const LINK_ETAGEN = Object.freeze({
|
||||
freigabe: Object.freeze({ tab: 'freigabe', text: 'Freigabe ▸', label: 'Freigabe: Freigabe-Pult öffnen' }),
|
||||
keller: Object.freeze({ tab: 'botleads', text: 'Keller · Bot-Leads ▸', label: 'Keller · Bot-Leads: Bot-Leads öffnen' }),
|
||||
})
|
||||
|
||||
const HAUS_RECHTECK = Object.freeze({ left: HAUS_BILD.x, top: HAUS_BILD.y, width: HAUS_BILD.w, height: HAUS_BILD.h })
|
||||
|
||||
const PX = (n) => `${n}px`
|
||||
|
||||
// Feste, je Figur verschiedene Startverschiebung der Animation (deterministisch aus dem Index: 7 und 19 sind teilerfremd, 19 Figuren -> 19 Werte)
|
||||
const versatz = (index, schritt = 7, anzahl = 19) => `-${(((index * schritt) % anzahl) / 10).toFixed(1)}s`
|
||||
|
||||
function geraeteDichte() {
|
||||
try { return window.devicePixelRatio || 1 } catch { return 1 }
|
||||
}
|
||||
|
||||
// Lage des „nicht angeschlossen“-Zettels: die Daten kennen drei Räume, alle acht Such-Räume haben dieselbe Lage relativ zum Raum (x0 + 16, y0 + 1)
|
||||
function zettelLage(raum) {
|
||||
const bekannt = SCHILDER.nichtAngeschlossen.raeume[raum]
|
||||
if (bekannt) return bekannt
|
||||
const r = RAEUME[raum]
|
||||
return { left: r.x0 + 16, top: r.y0 + 1 }
|
||||
}
|
||||
|
||||
const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige, onOpen, gesperrt = false, children }, ref) {
|
||||
const kam = useKamera({ gesperrt })
|
||||
useImperativeHandle(ref, () => ({ ganz: kam.ganz }), [kam.ganz])
|
||||
|
||||
const pausiert = state?.paused === true
|
||||
const tafel = schilder(state?.kpis)
|
||||
const figuren = ROLES.map((rolle, index) => ({
|
||||
id: rolle.workerId,
|
||||
index,
|
||||
f: figurZustand(rolle.workerId, workers?.[rolle.workerId], { paused: pausiert }),
|
||||
}))
|
||||
const grau = new Set(figuren.filter((x) => x.f.raumGrau).map((x) => x.f.raum))
|
||||
|
||||
const { s, x, y } = kam
|
||||
const etikett = Math.min(18, Math.max(8, 15 / s)).toFixed(2)
|
||||
const namePx = Math.min(14, Math.max(4, 12 / s)).toFixed(2)
|
||||
const buehneStil = {
|
||||
transform: `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${s.toFixed(4)})`,
|
||||
imageRendering: s * geraeteDichte() < 1 ? 'auto' : 'pixelated',
|
||||
'--hk-etikett': `${etikett}px`,
|
||||
'--hk-name': `${namePx}px`,
|
||||
}
|
||||
|
||||
const zeige = (id) => () => {
|
||||
if (kam.nachZug()) return
|
||||
if (typeof onZeige === 'function') onZeige(id)
|
||||
}
|
||||
// Freigabe und Keller sind echte Links; ein einfacher Klick wechselt den Bildschirm, Strg/Cmd/Umschalt/Mitteltaste öffnen wie jeder Link
|
||||
const linkKlick = (tab) => (e) => {
|
||||
if (kam.nachZug()) { e.preventDefault(); e.stopPropagation(); return }
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return
|
||||
e.preventDefault()
|
||||
if (typeof onOpen === 'function') onOpen(tab)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={kam.view}
|
||||
className={`hk-sicht${kam.ziehen ? ' hk-zieht' : ''}${pausiert ? ' hk-steht' : ''}`}
|
||||
onPointerDown={kam.handlers.onPointerDown}
|
||||
onPointerMove={kam.handlers.onPointerMove}
|
||||
onPointerUp={kam.handlers.onPointerUp}
|
||||
onPointerCancel={kam.handlers.onPointerCancel}
|
||||
onDoubleClick={kam.handlers.onDoubleClick}
|
||||
onKeyDown={kam.handlers.onKeyDown}
|
||||
>
|
||||
<div className="hk-himmel" aria-hidden="true" />
|
||||
<div className="hk-funkeln" aria-hidden="true" />
|
||||
<div className="hk-mond" aria-hidden="true" />
|
||||
|
||||
<div className={`hk-buehne${kam.gleiten ? ' hk-gleitet' : ''}${pausiert ? ' hk-steht' : ''}`} style={buehneStil}>
|
||||
<div className="hk-lage hk-welt-bild" aria-hidden="true" />
|
||||
<div className="hk-lage hk-licht-1" aria-hidden="true" />
|
||||
<div className="hk-lage hk-licht-2" aria-hidden="true" />
|
||||
<div className="hk-lage hk-licht-3" aria-hidden="true" />
|
||||
<div className="hk-lage hk-licht-4" aria-hidden="true" />
|
||||
<div className="hk-lage hk-laternen" aria-hidden="true" />
|
||||
<div className="hk-haus hk-haus-bild" aria-hidden="true" style={HAUS_RECHTECK} />
|
||||
<div className="hk-haus hk-fenster-a" aria-hidden="true" style={HAUS_RECHTECK} />
|
||||
<div className="hk-haus hk-fenster-b" aria-hidden="true" style={HAUS_RECHTECK} />
|
||||
<div className="hk-haus hk-fenster-c" aria-hidden="true" style={HAUS_RECHTECK} />
|
||||
<div className="hk-haus hk-schild-a" aria-hidden="true" style={HAUS_RECHTECK} />
|
||||
<div className="hk-haus hk-schild-b" aria-hidden="true" style={HAUS_RECHTECK} />
|
||||
<div
|
||||
className="hk-neon"
|
||||
style={{ left: SCHILDER.neon.left, top: SCHILDER.neon.top, width: SCHILDER.neon.w, height: SCHILDER.neon.h, fontSize: PX(SCHILDER.neon.schrift), lineHeight: PX(SCHILDER.neon.schrift) }}
|
||||
>
|
||||
HUSTLER
|
||||
</div>
|
||||
<div
|
||||
className="hk-antenne"
|
||||
aria-hidden="true"
|
||||
style={{ left: SCHILDER.antenne.left, top: SCHILDER.antenne.top, width: SCHILDER.antenne.w, height: SCHILDER.antenne.h }}
|
||||
/>
|
||||
<div className="hk-haus hk-seil" aria-hidden="true" style={{ left: AUFZUG.seil.left, top: AUFZUG.seil.top, width: AUFZUG.seil.w, height: AUFZUG.seil.h }} />
|
||||
<div className="hk-haus hk-aufzug" aria-hidden="true" style={{ left: AUFZUG.kabine.left, top: AUFZUG.kabine.top, width: AUFZUG.kabine.w, height: AUFZUG.kabine.h }} />
|
||||
|
||||
{/* Nicht angeschlossene Such-Räume: abgedunkelte Ebene, unter Schildern und Figuren */}
|
||||
{Object.keys(RAEUME).filter((raum) => grau.has(raum)).map((raum) => (
|
||||
<div key={raum} className={`hk-raum-grau-${raum}`} aria-hidden="true" />
|
||||
))}
|
||||
|
||||
{/* Raumschilder; ein grauer Raum bekommt stattdessen den Papier-Zettel „nicht angeschlossen“ */}
|
||||
{Object.keys(RAEUME).map((raum) => {
|
||||
if (grau.has(raum)) {
|
||||
const lage = zettelLage(raum)
|
||||
return (
|
||||
<span key={raum} className="hk-zettel" aria-hidden="true" style={{ left: lage.left, top: lage.top, width: SCHILDER.nichtAngeschlossen.breite }}>
|
||||
{ROOM_TITLES[raum]}<br />nicht angeschlossen
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span key={raum} className="hk-raumschild" aria-hidden="true" style={{ left: RAEUME[raum].etikett.x, top: RAEUME[raum].etikett.y }}>
|
||||
{ROOM_TITLES[raum]}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Bildschirme (nur wer arbeitet) und Rita-Radar */}
|
||||
{figuren.filter((e) => e.f.bildschirm && HUSTLER[e.id].bildschirm).map((e) => (
|
||||
<span
|
||||
key={e.id}
|
||||
className="hk-spr hk-bildschirm"
|
||||
aria-hidden="true"
|
||||
style={{ left: HUSTLER[e.id].bildschirm.left, top: HUSTLER[e.id].bildschirm.top, animationDelay: versatz(e.index, 3, 8) }}
|
||||
/>
|
||||
))}
|
||||
{figuren.filter((e) => e.f.radar && HUSTLER[e.id].radar).map((e) => (
|
||||
<span key={e.id} className="hk-spr hk-radar" aria-hidden="true" style={{ left: HUSTLER[e.id].radar.left, top: HUSTLER[e.id].radar.top }} />
|
||||
))}
|
||||
|
||||
{tafel.tresor !== null ? (
|
||||
<span
|
||||
className="hk-schildtext hk-schildtext-tresor"
|
||||
style={{ left: SCHILDER.tresor.left, top: SCHILDER.tresor.top, fontSize: PX(SCHILDER.tresor.schrift), lineHeight: PX(SCHILDER.tresor.zeile) }}
|
||||
>
|
||||
{tafel.tresor}
|
||||
</span>
|
||||
) : null}
|
||||
{tafel.keller !== null ? (
|
||||
<span
|
||||
className="hk-schildtext hk-schildtext-keller"
|
||||
style={{ left: SCHILDER.keller.left, top: SCHILDER.keller.top, fontSize: PX(SCHILDER.keller.schrift), lineHeight: PX(SCHILDER.keller.zeile) }}
|
||||
>
|
||||
{tafel.keller}
|
||||
</span>
|
||||
) : null}
|
||||
{tafel.briefkasten !== null ? (
|
||||
<span
|
||||
className="hk-huepf hk-briefkasten"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
left: SCHILDER.briefkasten.left,
|
||||
top: SCHILDER.briefkasten.top,
|
||||
minWidth: SCHILDER.briefkasten.minW,
|
||||
height: SCHILDER.briefkasten.h,
|
||||
fontSize: PX(SCHILDER.briefkasten.schrift),
|
||||
lineHeight: PX(SCHILDER.briefkasten.schrift),
|
||||
}}
|
||||
>
|
||||
{tafel.briefkasten}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Etagen: Knöpfe zoomen heran, Freigabe und Keller sind Links auf die anderen Bildschirme */}
|
||||
{ETAGEN.map((e) => {
|
||||
const stil = { left: ETAGEN_SPALTE.x, top: e.oben, width: ETAGEN_SPALTE.w, height: e.unten - e.oben }
|
||||
const link = LINK_ETAGEN[e.id]
|
||||
if (link) {
|
||||
return (
|
||||
<a key={e.id} className="hk-etage" href={`/hustler?tab=${link.tab}`} draggable={false} onClick={linkKlick(link.tab)} aria-label={link.label} style={stil}>
|
||||
<span className="hk-etage-name">{link.text}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button key={e.id} type="button" className="hk-etage" onClick={() => kam.etage(e.oben, e.unten)} aria-label={`${FLOOR_TITLES[e.id]} heranzoomen`} style={stil}>
|
||||
<span className="hk-etage-name">{FLOOR_TITLES[e.id]}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Die 19 Hustler: Knopf mit Sprite nach Status, Zusätzen und Namensschild */}
|
||||
{figuren.map(({ id, index, f }) => {
|
||||
const k = HUSTLER[id].knopf
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="hk-hustler"
|
||||
data-hk-hustler={id}
|
||||
onClick={zeige(id)}
|
||||
aria-label={f.label}
|
||||
style={{ left: k.left, top: k.top, width: k.w, height: k.h }}
|
||||
>
|
||||
<span className={f.gedimmt ? `${f.klasse} hk-gedimmt` : f.klasse} aria-hidden="true" style={{ animationDelay: versatz(index) }} />
|
||||
{f.punkte ? <span className="hk-spr hk-punkte" aria-hidden="true" /> : null}
|
||||
{f.zzz ? <span className="hk-spr hk-zzz" aria-hidden="true" /> : null}
|
||||
{f.alarm ? <span className="hk-alarm" aria-hidden="true">!</span> : null}
|
||||
<span className="hk-namensschild">{anzeigeName(id)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="hk-schnee hk-schnee-1" aria-hidden="true" />
|
||||
<div className="hk-schnee hk-schnee-2" aria-hidden="true" />
|
||||
<div className="hk-schnee hk-schnee-3" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default HochhausWelt
|
||||
@@ -0,0 +1,274 @@
|
||||
// Kamera der Hochhaus-Welt (Hustler-Pixelwelt T5, Spec E1/E8/E14): Port von logik.js der Zeichenfläche als Hook.
|
||||
// Mausrad zoomt um den Mauspunkt, Ziehen verschiebt, zwei Finger zoomen (Pinch), Doppelklick/Doppeltipp zeigt das ganze Haus,
|
||||
// Tasten +/=/-/0/Esc, Etagen heranzoomen. Gleiten 0,55 s (aus bei „weniger Bewegung“). Nach einem Zug kein versehentlicher Klick (nachZug()).
|
||||
// Gerechnet wird nur mit den reinen Kamera-Funktionen aus lib/hustlerSicht.js; hier steht die Zeiger- und Zustandsverwaltung.
|
||||
// Die Kamera selbst (baueKamera) ist reines JS ohne React und bekommt nur die Meldung „Zustand hat sich geändert“.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { SICHT_STANDARD, kameraEtage, kameraGrenzen, kameraPassend, kameraZoomAuf } from '../../../lib/hustlerSicht.js'
|
||||
|
||||
// Gleiten: die CSS-Klasse hk-gleitet dauert 0,55 s, der Merker bleibt etwas länger (Ende der Bewegung abwarten)
|
||||
export const GLEITEN_MS = 600
|
||||
const DOPPELTIPP_MS = 350
|
||||
const TASTE_FAKTOR = 1.25
|
||||
const ZUG_SCHWELLE_MAUS = 5
|
||||
const ZUG_SCHWELLE_TOUCH = 10
|
||||
// Beschreibt Elemente, auf denen ein Doppelklick/-tipp nicht „ganzes Haus“ bedeutet
|
||||
const BEDIENBAR = '.hk-etage, .hk-hustler'
|
||||
|
||||
const jetzt = () => Date.now()
|
||||
|
||||
function ruhig() {
|
||||
try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) } catch { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Kamera. `melde(zustand)` wird nach jeder Änderung von { s, x, y, bereit, gleiten, ziehen } mit dem neuen Zustandsobjekt gerufen.
|
||||
* Alle Methoden sind feste Funktionen (stabile Bezüge für React).
|
||||
*/
|
||||
export function baueKamera(melde) {
|
||||
const m = {
|
||||
st: { ...kameraPassend(SICHT_STANDARD), bereit: false, gleiten: false, ziehen: false },
|
||||
el: null,
|
||||
verbunden: false,
|
||||
ro: null,
|
||||
timer: null,
|
||||
zeiger: new Map(),
|
||||
drag: null,
|
||||
pinch: null,
|
||||
gezogenBis: 0,
|
||||
letzterTipp: 0,
|
||||
selbst: false, // Nutzer hat selbst gezoomt oder gezogen: bei Größenänderung nur Grenzen nachziehen, nicht neu einpassen
|
||||
gesperrt: false,
|
||||
}
|
||||
|
||||
const setze = (patch) => {
|
||||
m.st = { ...m.st, ...patch }
|
||||
melde(m.st)
|
||||
}
|
||||
|
||||
const groesse = () => {
|
||||
if (m.el) {
|
||||
const r = m.el.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0) return { w: r.width, h: r.height }
|
||||
}
|
||||
return SICHT_STANDARD
|
||||
}
|
||||
|
||||
const gleite = (ziel) => {
|
||||
clearTimeout(m.timer)
|
||||
m.timer = null
|
||||
if (ruhig()) { setze({ ...ziel, gleiten: false, bereit: true }); return }
|
||||
setze({ ...ziel, gleiten: true, bereit: true })
|
||||
m.timer = setTimeout(() => { m.timer = null; setze({ gleiten: false }) }, GLEITEN_MS)
|
||||
}
|
||||
|
||||
const ganz = () => {
|
||||
m.selbst = false
|
||||
gleite(kameraPassend(groesse()))
|
||||
}
|
||||
|
||||
const nachZug = () => jetzt() < m.gezogenBis
|
||||
|
||||
const zoomAuf = (mx, my, neuS) => {
|
||||
if (!m.st.s) return
|
||||
const g = groesse()
|
||||
m.selbst = true
|
||||
setze({ ...kameraZoomAuf(m.st, mx, my, neuS, g), gleiten: false, bereit: true })
|
||||
}
|
||||
|
||||
const rad = (e) => {
|
||||
if (m.gesperrt) return
|
||||
e.preventDefault()
|
||||
if (!m.st.bereit || m.st.gleiten || !m.el) return
|
||||
const r = m.el.getBoundingClientRect()
|
||||
const k = (e.deltaMode === 1 ? 0.05 : e.deltaMode === 2 ? 1 : 0.0016) * (e.ctrlKey ? 10 : 1)
|
||||
zoomAuf(e.clientX - r.left, e.clientY - r.top, m.st.s * Math.exp(-e.deltaY * k))
|
||||
}
|
||||
|
||||
const abbruchZiehen = () => {
|
||||
const d = m.drag
|
||||
m.drag = null
|
||||
if (d && d.moved) { try { m.el.releasePointerCapture(d.id) } catch { /* Zeiger schon weg */ } }
|
||||
if (m.st.ziehen) setze({ ziehen: false })
|
||||
}
|
||||
|
||||
const unten = (e) => {
|
||||
if (m.gesperrt || m.st.gleiten || !m.st.bereit) return
|
||||
if (e.pointerType === 'mouse' && (e.button !== 0 || e.ctrlKey)) return
|
||||
if (e.pointerType === 'mouse') m.zeiger.clear() // es gibt nur eine Maus: ein verwaister Eintrag darf keinen Pinch auslösen
|
||||
m.zeiger.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
if (m.zeiger.size === 2) {
|
||||
const [p, q] = [...m.zeiger.values()]
|
||||
abbruchZiehen()
|
||||
m.pinch = { d0: Math.hypot(p.x - q.x, p.y - q.y) || 1, s0: m.st.s, x0: m.st.x, y0: m.st.y, mx: (p.x + q.x) / 2, my: (p.y + q.y) / 2 }
|
||||
m.gezogenBis = jetzt() + 400
|
||||
return
|
||||
}
|
||||
if (m.zeiger.size > 2) return
|
||||
m.drag = { id: e.pointerId, sx: e.clientX, sy: e.clientY, x0: m.st.x, y0: m.st.y, moved: false, touch: e.pointerType !== 'mouse' }
|
||||
}
|
||||
|
||||
const loslassen = (e) => {
|
||||
m.zeiger.delete(e.pointerId)
|
||||
if (m.pinch) {
|
||||
if (m.zeiger.size < 2) { m.pinch = null; m.drag = null; m.gezogenBis = jetzt() + 300 }
|
||||
return
|
||||
}
|
||||
const d = m.drag
|
||||
if (!d || e.pointerId !== d.id) return
|
||||
m.drag = null
|
||||
if (d.moved) {
|
||||
m.gezogenBis = jetzt() + 250
|
||||
try { m.el.releasePointerCapture(d.id) } catch { /* Zeiger schon weg */ }
|
||||
setze({ ziehen: false })
|
||||
return
|
||||
}
|
||||
if (d.touch) {
|
||||
const frei = !(e.target && e.target.closest && e.target.closest(BEDIENBAR))
|
||||
const t = jetzt()
|
||||
if (frei && t - m.letzterTipp < DOPPELTIPP_MS) { m.letzterTipp = 0; ganz() }
|
||||
else m.letzterTipp = frei ? t : 0
|
||||
}
|
||||
}
|
||||
|
||||
const bewegen = (e) => {
|
||||
if (!m.zeiger.has(e.pointerId)) return
|
||||
if (e.pointerType === 'mouse' && !(e.buttons & 1)) { loslassen(e); return }
|
||||
m.zeiger.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
const g = groesse()
|
||||
if (m.pinch) {
|
||||
if (m.zeiger.size < 2) return
|
||||
const [p, q] = [...m.zeiger.values()]
|
||||
const r = m.el.getBoundingClientRect()
|
||||
const P = m.pinch
|
||||
const d = Math.hypot(p.x - q.x, p.y - q.y) || 1
|
||||
const n = kameraGrenzen({ s: (P.s0 * d) / P.d0, x: 0, y: 0 }, g)
|
||||
const f = n.s / P.s0
|
||||
const mx = (p.x + q.x) / 2 - r.left
|
||||
const my = (p.y + q.y) / 2 - r.top
|
||||
m.selbst = true
|
||||
setze({ ...kameraGrenzen({ s: n.s, x: mx - (P.mx - r.left - P.x0) * f, y: my - (P.my - r.top - P.y0) * f }, g), gleiten: false })
|
||||
return
|
||||
}
|
||||
const d = m.drag
|
||||
if (!d || e.pointerId !== d.id) return
|
||||
const dx = e.clientX - d.sx
|
||||
const dy = e.clientY - d.sy
|
||||
if (!d.moved) {
|
||||
const sw = d.touch ? ZUG_SCHWELLE_TOUCH : ZUG_SCHWELLE_MAUS
|
||||
if (dx * dx + dy * dy < sw * sw) return
|
||||
d.moved = true
|
||||
try { m.el.setPointerCapture(d.id) } catch { /* Zeiger schon weg */ }
|
||||
setze({ ziehen: true, gleiten: false })
|
||||
}
|
||||
m.selbst = true
|
||||
setze(kameraGrenzen({ s: m.st.s, x: d.x0 + dx, y: d.y0 + dy }, g))
|
||||
}
|
||||
|
||||
const doppelklick = (e) => {
|
||||
if (m.gesperrt || nachZug()) return
|
||||
if (e && e.target && e.target.closest && e.target.closest(BEDIENBAR)) return
|
||||
ganz()
|
||||
}
|
||||
|
||||
const taste = (e) => {
|
||||
if (m.gesperrt) return
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return // Browser-Zoom (Strg +/-) und Kürzel nicht abfangen
|
||||
const g = groesse()
|
||||
if (e.key === 'Escape' || e.key === '0') { e.preventDefault(); ganz() }
|
||||
else if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomAuf(g.w / 2, g.h / 2, m.st.s * TASTE_FAKTOR) }
|
||||
else if (e.key === '-') { e.preventDefault(); zoomAuf(g.w / 2, g.h / 2, m.st.s / TASTE_FAKTOR) }
|
||||
}
|
||||
|
||||
const etage = (oben, untenRand) => {
|
||||
if (nachZug()) return
|
||||
m.selbst = true
|
||||
gleite(kameraEtage(oben, untenRand, groesse()))
|
||||
}
|
||||
|
||||
// Mausrad (passive: false, sonst scrollt die Seite mit) und Größenänderung am Sichtfenster
|
||||
const verbinden = () => {
|
||||
if (!m.el || m.verbunden) return
|
||||
m.verbunden = true
|
||||
m.el.addEventListener('wheel', rad, { passive: false })
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
m.ro = new ResizeObserver(() => {
|
||||
const g = groesse()
|
||||
setze(m.selbst ? kameraGrenzen(m.st, g) : { ...kameraPassend(g), bereit: true })
|
||||
})
|
||||
m.ro.observe(m.el)
|
||||
}
|
||||
}
|
||||
|
||||
const loesen = () => {
|
||||
if (m.el && m.verbunden) m.el.removeEventListener('wheel', rad)
|
||||
m.verbunden = false
|
||||
if (m.ro) { m.ro.disconnect(); m.ro = null }
|
||||
clearTimeout(m.timer)
|
||||
m.timer = null
|
||||
}
|
||||
|
||||
// Ref-Callback für das Sichtfenster: beim Einhängen einpassen und verbinden, beim Aushängen alles lösen
|
||||
const view = (el) => {
|
||||
if (m.el === el) return
|
||||
loesen()
|
||||
m.el = el
|
||||
m.zeiger.clear()
|
||||
m.drag = null
|
||||
m.pinch = null
|
||||
if (!el) return
|
||||
verbinden()
|
||||
setze({ ...kameraPassend(groesse()), bereit: true, gleiten: false, ziehen: false })
|
||||
}
|
||||
|
||||
return {
|
||||
get st() { return m.st },
|
||||
set gesperrt(wert) { m.gesperrt = wert === true },
|
||||
view,
|
||||
verbinden,
|
||||
loesen,
|
||||
ganz,
|
||||
etage,
|
||||
nachZug,
|
||||
handlers: {
|
||||
onPointerDown: unten,
|
||||
onPointerMove: bewegen,
|
||||
onPointerUp: loslassen,
|
||||
onPointerCancel: loslassen,
|
||||
onDoubleClick: doppelklick,
|
||||
onKeyDown: taste,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook für die Welt. `gesperrt` = Detailansicht offen: Rad, Ziehen und Tasten wirken dann nicht.
|
||||
* Liefert { view (Ref-Callback für das Sichtfenster), s, x, y, bereit, gleiten, ziehen, handlers, ganz(), etage(oben, unten), nachZug() }.
|
||||
* s/x/y: Welt-Pixel mal s plus Verschiebung x/y (transform: translate(x, y) scale(s)).
|
||||
*/
|
||||
export default function useKamera({ gesperrt = false } = {}) {
|
||||
const halter = useRef(null)
|
||||
const [stand, setStand] = useState(null)
|
||||
if (halter.current === null) halter.current = baueKamera((neu) => setStand(neu))
|
||||
const kam = halter.current
|
||||
kam.gesperrt = gesperrt
|
||||
// Aufräumen beim Abbau; im Entwicklungsmodus (Effekte laufen doppelt) hängt der zweite Lauf Rad und Beobachter wieder ein
|
||||
useEffect(() => {
|
||||
kam.verbinden()
|
||||
return () => kam.loesen()
|
||||
}, [kam])
|
||||
const st = stand ?? kam.st
|
||||
return {
|
||||
view: kam.view,
|
||||
s: st.s,
|
||||
x: st.x,
|
||||
y: st.y,
|
||||
bereit: st.bereit,
|
||||
gleiten: st.gleiten,
|
||||
ziehen: st.ziehen,
|
||||
handlers: kam.handlers,
|
||||
ganz: kam.ganz,
|
||||
etage: kam.etage,
|
||||
nachZug: kam.nachZug,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Handgeschriebene Regeln der Hochhaus-Welt (HochhausWelt.jsx, Hustler-Pixelwelt T5). Nicht erzeugt: hochhaus.css kommt von scripts/hustler-import.mjs und
|
||||
wird nie von Hand geändert. Hier steht nur, was die Zeichenfläche als Inline-Stile hatte (Sichtfenster, Bühne, Raumschilder, Zettel, Schildtexte,
|
||||
Briefkasten-Zähler, Neon, Antenne, Sprite-Lagen der Figuren) und deshalb nicht im erzeugten CSS ist. Inline bleibt im JSX nur Geometrie aus
|
||||
lib/hustlerWelt.js und alles, was vom Zoom abhängt (transform der Bühne, Schriftgrößen über --hk-etikett und --hk-name). Alle Klassen beginnen mit hk-
|
||||
und stehen unter der Wurzel .hk-hochhaus. */
|
||||
|
||||
/* ---------- Sichtfenster: füllt die Wurzel, Ziehen und Pinch gehören der Kamera (touch-action: none) ---------- */
|
||||
.hk-hochhaus .hk-sicht {
|
||||
position: absolute; inset: 0;
|
||||
overflow: hidden; overflow: clip;
|
||||
touch-action: none; -webkit-user-select: none; user-select: none;
|
||||
cursor: grab;
|
||||
font-size: 15px; line-height: 20px;
|
||||
}
|
||||
.hk-hochhaus .hk-sicht.hk-zieht { cursor: grabbing; }
|
||||
.hk-hochhaus .hk-sicht > .hk-himmel,
|
||||
.hk-hochhaus .hk-sicht > .hk-funkeln { position: absolute; inset: 0; }
|
||||
.hk-hochhaus .hk-sicht > .hk-mond { position: absolute; right: 13%; top: 15%; width: 80px; height: 80px; }
|
||||
|
||||
/* ---------- Bühne: 2400 x 1200 Weltpixel, transform setzt die Kamera (translate und scale, Ursprung links oben) ---------- */
|
||||
.hk-hochhaus .hk-buehne { position: absolute; left: 0; top: 0; width: 2400px; height: 1200px; transform-origin: 0 0; }
|
||||
.hk-hochhaus .hk-buehne.hk-gleitet { transition: transform 0.55s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hk-hochhaus .hk-buehne.hk-gleitet { transition: none; }
|
||||
}
|
||||
|
||||
/* Schriftgrößen der Etagen-Namen und Namensschilder: wachsen beim Herauszoomen, damit sie lesbar bleiben (Werte setzt die Bühne) */
|
||||
.hk-hochhaus .hk-buehne .hk-etage-name { font-size: var(--hk-etikett, 15px); }
|
||||
.hk-hochhaus .hk-buehne .hk-namensschild { font-size: var(--hk-name, 12px); }
|
||||
|
||||
/* ---------- Neon „HUSTLER“ und Antenne (Lage und Größe inline aus hustlerWelt.js; Blinken steht in hochhaus.css) ---------- */
|
||||
.hk-hochhaus .hk-neon {
|
||||
position: absolute; display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Press Start 2P', 'Pixelify Sans', monospace; letter-spacing: 2px;
|
||||
color: #ffe27a; text-shadow: 0 0 4px #ffb84d, 0 0 12px #ff8a3d; pointer-events: none;
|
||||
}
|
||||
.hk-hochhaus .hk-antenne { position: absolute; background: #ff4d4d; box-shadow: 0 0 6px 2px rgba(255, 77, 77, 0.6); pointer-events: none; }
|
||||
|
||||
/* ---------- Raumschilder, Papier-Zettel für nicht angeschlossene Räume, Schildtexte, Briefkasten-Zähler ---------- */
|
||||
.hk-hochhaus .hk-raumschild { position: absolute; font-size: 8px; line-height: 9px; color: #4a5266; white-space: nowrap; pointer-events: none; }
|
||||
.hk-hochhaus .hk-zettel {
|
||||
position: absolute; padding: 1px 0; background: #f1ede4; color: #2b2230;
|
||||
font-size: 6px; line-height: 7px; text-align: center; white-space: nowrap;
|
||||
box-shadow: 0 0 0 1px #3a2f3d; pointer-events: none;
|
||||
}
|
||||
.hk-hochhaus .hk-schildtext { position: absolute; white-space: nowrap; pointer-events: none; }
|
||||
.hk-hochhaus .hk-schildtext-tresor { color: #2b3a33; }
|
||||
.hk-hochhaus .hk-schildtext-keller { color: #2f3646; }
|
||||
.hk-hochhaus .hk-briefkasten {
|
||||
position: absolute; box-sizing: border-box; padding: 0 2px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #e2483d; color: #ffffff; font-family: 'Press Start 2P', 'Pixelify Sans', monospace;
|
||||
box-shadow: 0 0 0 1px #3a1216, inset 0 1px 0 0 #ff7d70; pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- Figuren im Knopf: Sprite oben links, Kaffeepunkte und Zzz über dem Kopf (der Fehler-Marker steht in hustler-ui.css) ---------- */
|
||||
.hk-hochhaus .hk-hustler > .hk-spr { left: 0; top: 0; }
|
||||
.hk-hochhaus .hk-hustler > .hk-punkte { left: 14px; top: -6px; }
|
||||
.hk-hochhaus .hk-hustler > .hk-zzz { left: 15px; top: -11px; }
|
||||
@@ -0,0 +1,900 @@
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user