Hustler-Pixelwelt T13: Korrekturen Hochhaus (Fokus, Tastatur, weniger Bewegung, Handy-Tippflächen, Schnee per transform, Pause überall, Seite ohne Leerlauf-Blitz)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 09:47:57 +00:00
1 parent 0924a8d2ae
commit 5e6698f5e0
14 files changed
+994 -52

No files matched your search

+39 -14
View File
@@ -1,21 +1,43 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import HochhausKopf from './HochhausKopf'
import HochhausWelt from './HochhausWelt'
import HustlerDialog from './HustlerDialog'
import RegieEbene from './RegieEbene'
// Von welchem Bildschirm man zurückkommt -> Etage, über die man gegangen war (Freigabe-Pult über die Etage „freigabe“, Keller über „keller“)
const ETAGE_NACH_TAB = Object.freeze({ freigabe: 'freigabe', botleads: 'keller' })
// Überschrift der Seite, nur für Screenreader (Pult und Keller haben ihre eigene sichtbare Überschrift)
function Ueberschrift() {
return <h1 className="hk-nur-sr">Hustler – Hochhaus</h1>
}
// Bildschirm „Hochhaus“: Spielwelt mit Regie-Ebene (T5, T8), Kopfzeile (T6) und Detailansicht eines Hustlers (T7). Zustände wie bisher im WorkersBoard.
// Reine Darstellung ohne Auth-Bezug: isAdmin, der Feed (feed = { state, error, loaded, refresh }), die Hustler-Tabelle (workers), die Regie-Anbindung
// (regieRef, die Seite ruft regieRef.current.push(events) bei neuen Ereignissen) und der Bildschirmwechsel (onOpen(tab)) kommen von der Seite.
// (regieRef, die Seite ruft regieRef.current.push(events) bei neuen Ereignissen), der Bildschirmwechsel (onOpen(tab)) und vonTab (der Bildschirm, von dem man
// zurückkommt: 'freigabe' | 'botleads' | null) kommen von der Seite. Beim Zurückkommen bekommt die Etage, über die man gegangen war, den Fokus (M2).
// Die Kopfzeile steht im DOM vor der Welt, damit die Tab-Reihenfolge oben beginnt (M7); über der Welt liegt sie per z-index.
// Kein CSS-Import und keine Bilder: die Stile hängt WorkersPage ein (Regeln dieser Datei in styles/hustler/hustler-ui.css).
export default function Hochhaus({ api, isAdmin, feed, workers, regieRef, onOpen }) {
export default function Hochhaus({ api, isAdmin, feed, workers, regieRef, onOpen, vonTab = null }) {
const wurzelRef = useRef(null)
const weltRef = useRef(null)
const [auswahl, setAuswahl] = useState(null) // workerId der offenen Detailansicht (null = keine)
const ready = feed.loaded && Boolean(feed.state)
// Rückweg aus Pult oder Keller: Fokus auf die Etage, über die man ging (erst wenn die Welt steht; die Kamera holt sie bei Tastaturfokus ins Bild)
useEffect(() => {
if (!ready || !vonTab || !Object.hasOwn(ETAGE_NACH_TAB, vonTab)) return
const wurzel = wurzelRef.current
if (!wurzel || typeof wurzel.querySelector !== 'function') return
const etage = wurzel.querySelector(`[data-hk-etage="${ETAGE_NACH_TAB[vonTab]}"]`)
if (etage && typeof etage.focus === 'function') etage.focus({ preventScroll: true })
}, [ready, vonTab])
// Dienst noch nie erreicht: Pixel-Leerzustand mit Fehlertext und „Neu laden“
if (!ready && feed.error) {
return (
<div className="hk-hochhaus">
<div className="hk-hochhaus" ref={wurzelRef}>
<Ueberschrift />
<div className="hk-leer">
<strong className="hk-leer-titel">Der Hustler-Dienst ist noch nicht erreichbar</strong>
<p className="hk-leer-text">{feed.error}</p>
@@ -26,7 +48,8 @@ export default function Hochhaus({ api, isAdmin, feed, workers, regieRef, onOpen
}
if (!ready) {
return (
<div className="hk-hochhaus">
<div className="hk-hochhaus" ref={wurzelRef}>
<Ueberschrift />
<div className="hk-laden" role="status">Wird geladen …</div>
</div>
)
@@ -35,7 +58,16 @@ export default function Hochhaus({ api, isAdmin, feed, workers, regieRef, onOpen
// Die Regie-Ebene liegt in der skalierten Bühne (Weltkoordinaten); ihr Handle ist regieRef der Seite, die neue Ereignisse hineinreicht.
// Solange die Detailansicht offen ist, ruhen Rad, Ziehen und Tasten der Kamera (gesperrt).
return (
<div className="hk-hochhaus">
<div className="hk-hochhaus" ref={wurzelRef}>
<Ueberschrift />
<HochhausKopf
api={api}
isAdmin={isAdmin}
state={feed.state}
error={feed.error}
refresh={feed.refresh}
onGanz={() => { if (weltRef.current) weltRef.current.ganz() }}
/>
<HochhausWelt
ref={weltRef}
state={feed.state}
@@ -46,19 +78,12 @@ export default function Hochhaus({ api, isAdmin, feed, workers, regieRef, onOpen
>
<RegieEbene ref={regieRef} />
</HochhausWelt>
<HochhausKopf
api={api}
isAdmin={isAdmin}
state={feed.state}
error={feed.error}
refresh={feed.refresh}
onGanz={() => { if (weltRef.current) weltRef.current.ganz() }}
/>
{auswahl !== null && (
<HustlerDialog
api={api}
workerId={auswahl}
workers={workers}
paused={feed.state?.paused === true}
onClose={() => setAuswahl(null)}
onWechsel={setAuswahl}
/>
@@ -3,9 +3,12 @@
// 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.
// Bedienung: Tastaturfokus (:focus-visible) auf Etage oder Figur außerhalb des Bildes holt sie per Kamera herein (beiFokus, kein Schwenk bei Mausklick); das Sichtfenster ist
// per tabindex -1 fokussierbar (ein Klick in die Welt gibt ihm den Fokus, damit + - 0 Esc wirken); die Tippflächen der Figuren wachsen mit dem Zoom (--hk-tipp-*, tippRand);
// bei offener Detailansicht (gesperrt) trägt das Sichtfenster hk-verdeckt (Schnee aus).
// 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 { anzeigeName, figurZustand, schilder, tippRand } 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'
@@ -20,6 +23,11 @@ const HAUS_RECHTECK = Object.freeze({ left: HAUS_BILD.x, top: HAUS_BILD.y, width
const PX = (n) => `${n}px`
// Elemente, die Fokus per Tastatur bekommen können und beim Fokus ins Bild geholt werden (M1)
const FOKUS_ZIEL = '.hk-etage, .hk-hustler'
// Namensschild einer Figur: so viele Bildschirm-Pixel über dem Knopf sollen beim Heranholen mit sichtbar sein
const SCHILD_HOEHE = 34
// 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`
@@ -49,6 +57,7 @@ const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige,
const grau = new Set(figuren.filter((x) => x.f.raumGrau).map((x) => x.f.raum))
const { s, x, y } = kam
const tipp = tippRand(s)
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 = {
@@ -56,6 +65,10 @@ const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige,
imageRendering: s * geraeteDichte() < 1 ? 'auto' : 'pixelated',
'--hk-etikett': `${etikett}px`,
'--hk-name': `${namePx}px`,
// Tippfläche der Figuren: wächst mit dem Zoom auf 40 Bildschirm-Pixel (M8, hustler-welt.css)
'--hk-tipp-x': `${tipp.x.toFixed(2)}px`,
'--hk-tipp-oben': `${tipp.oben.toFixed(2)}px`,
'--hk-tipp-unten': `${tipp.unten.toFixed(2)}px`,
}
const zeige = (id) => () => {
@@ -70,10 +83,29 @@ const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige,
if (typeof onOpen === 'function') onOpen(tab)
}
// Fokus per Tastatur (:focus-visible) auf Etage oder Figur außerhalb des sichtbaren Bereichs: Kamera holt sie ins Bild (M1). Kein Schwenk bei Mausklick.
const beiFokus = (e) => {
if (gesperrt) return
const knoten = e && e.target
if (!knoten || typeof knoten.closest !== 'function') return
const ziel = knoten.closest(FOKUS_ZIEL)
if (!ziel) return
let tastatur = false
try { tastatur = ziel.matches(':focus-visible') } catch { tastatur = false }
if (!tastatur) return
// Der Browser darf das Sichtfenster nicht selbst verschieben (alte Browser ohne overflow: clip scrollen beim Fokus)
const sicht = e.currentTarget
if (sicht && (sicht.scrollLeft || sicht.scrollTop)) { sicht.scrollLeft = 0; sicht.scrollTop = 0 }
const figur = typeof ziel.matches === 'function' && ziel.matches('.hk-hustler')
kam.zeige({ left: ziel.offsetLeft, top: ziel.offsetTop, width: ziel.offsetWidth, height: ziel.offsetHeight }, figur ? SCHILD_HOEHE : 0)
}
return (
<div
ref={kam.view}
className={`hk-sicht${kam.ziehen ? ' hk-zieht' : ''}${pausiert ? ' hk-steht' : ''}`}
className={`hk-sicht${kam.ziehen ? ' hk-zieht' : ''}${pausiert ? ' hk-steht' : ''}${gesperrt ? ' hk-verdeckt' : ''}`}
tabIndex={-1}
onFocus={beiFokus}
onPointerDown={kam.handlers.onPointerDown}
onPointerMove={kam.handlers.onPointerMove}
onPointerUp={kam.handlers.onPointerUp}
@@ -100,6 +132,7 @@ const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige,
<div className="hk-haus hk-schild-b" aria-hidden="true" style={HAUS_RECHTECK} />
<div
className="hk-neon"
aria-hidden="true"
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
@@ -186,13 +219,13 @@ const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige,
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}>
<a key={e.id} className="hk-etage" data-hk-etage={e.id} 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}>
<button key={e.id} type="button" className="hk-etage" data-hk-etage={e.id} 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>
)
@@ -207,6 +240,7 @@ const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige,
type="button"
className="hk-hustler"
data-hk-hustler={id}
aria-haspopup="dialog"
onClick={zeige(id)}
aria-label={f.label}
style={{ left: k.left, top: k.top, width: k.w, height: k.h }}
@@ -8,7 +8,8 @@
// Esc schließt, ←/→ blättern in Roster-Reihenfolge, ein Klick auf den Hintergrund schließt erst 400 ms nach dem Öffnen (Geister-Klick nach
// dem Antippen der Figur). Beim Schließen geht der Fokus zurück auf das Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument
// steht), sonst auf die Figur des zuletzt gezeigten Hustlers ([data-hk-hustler="<id>"], die setzt die Welt).
import { useEffect, useRef } from 'react'
// Blättern wird angesagt (aria-live polite: Name, Status, Nummer), nicht aber das Öffnen. Angehalten (paused): die Animationen im Dialog stehen (hk-steht).
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useWorkerTrace } from '../../../hooks/useWorkersFeed'
import { blaettern, detailModell } from '../../../lib/hustlerSicht'
@@ -39,9 +40,12 @@ function figurStelle(szene) {
return DETAIL.figur.buero
}
// Ansage nach dem Blättern: „Lea, Arbeitet, 9 von 19“
const ansageVon = (m) => `${m.name}, ${m.statusText}, ${m.nummer.replace(' / ', ' von ')}`
// Props: api (Dienst-API für die Spur), workerId (null = nichts anzeigen), workers (Hustler-Tabelle der Seite, Zustandseinträge nach workerId),
// onClose(), onWechsel(nextId) (Blättern; die Seite setzt die neue Id)
export default function HustlerDialog({ api, workerId, workers, onClose, onWechsel }) {
// paused (Dienst angehalten: Animationen stehen), onClose(), onWechsel(nextId) (Blättern; die Seite setzt die neue Id)
export default function HustlerDialog({ api, workerId, workers, paused = false, onClose, onWechsel }) {
const { trace, loading, error } = useWorkerTrace(api, workerId)
const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle)
const letzterRef = useRef(null) // Knopf „Nächster Hustler“ (Ende der Fokusfalle)
@@ -50,6 +54,7 @@ export default function HustlerDialog({ api, workerId, workers, onClose, onWechs
const geoeffnetRef = useRef(0)
const openerRef = useRef(null)
const idRef = useRef(workerId)
const [ansage, setAnsage] = useState('') // Text der Live-Region: leer beim Öffnen, nach dem Blättern der neue Hustler
const offen = Boolean(workerId)
if (workerId) idRef.current = workerId
@@ -59,6 +64,7 @@ export default function HustlerDialog({ api, workerId, workers, onClose, onWechs
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
openerRef.current = aktiv && aktiv !== document.body ? aktiv : null
geoeffnetRef.current = Date.now()
setAnsage('')
const erster = ersterRef.current
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
return () => {
@@ -78,7 +84,13 @@ export default function HustlerDialog({ api, workerId, workers, onClose, onWechs
const m = detailModell(workerId, eintrag, trace, Date.now())
const stelle = figurStelle(m.szene)
const o = m.overlays
const wechsel = (schritt) => onWechsel(blaettern(workerId, schritt))
const wechsel = (schritt) => {
const naechster = blaettern(workerId, schritt)
// Ansage des Ziels (Name, Status, Nummer hängen nicht an der Spur) als Momentaufnahme: spätere Statuswechsel melden sich nicht von selbst
const ziel = isRecord(workers) && Object.hasOwn(workers, naechster) ? workers[naechster] : null
setAnsage(ansageVon(detailModell(naechster, ziel, null, Date.now())))
onWechsel(naechster)
}
// Hintergrund: schließt erst nach der Sperrzeit
const hintergrund = () => {
@@ -115,7 +127,7 @@ export default function HustlerDialog({ api, workerId, workers, onClose, onWechs
}
return (
<div className="hk-dialog-ueber" onKeyDown={taste}>
<div className={paused === true ? 'hk-dialog-ueber hk-steht' : 'hk-dialog-ueber'} onKeyDown={taste}>
<button type="button" tabIndex={-1} className="hk-dialog-hinten" aria-label="Detailansicht schließen" ref={hintenRef} onClick={hintergrund} />
<section role="dialog" aria-modal="true" aria-label={`Hustler ${m.name}`} tabIndex={-1} className="hk-panel hk-dialog" ref={sectionRef}>
<div className={m.szene} aria-hidden="true">
@@ -132,6 +144,7 @@ export default function HustlerDialog({ api, workerId, workers, onClose, onWechs
</span>
)}
</div>
<div className="hk-nur-sr" aria-live="polite" aria-atomic="true">{ansage}</div>
<div className="hk-dialog-daten">
<div className="hk-dialog-kopf">
<div>
+49 -4
View File
@@ -1,10 +1,12 @@
// 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()).
// Tasten +/=/-/0/Esc, Etagen heranzoomen, ein Element ins Bild holen (zeige, für den Tastaturfokus). Gleiten 0,55 s (aus bei „weniger Bewegung“: dann springt die Kamera).
// Nach einem Zug kein versehentlicher Klick (nachZug()). Ein Zeigerdruck in die Welt gibt dem Sichtfenster den Fokus.
// 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'
import { SICHT_STANDARD, kameraEtage, kameraGrenzen, kameraPassend, kameraZeige, kameraZoomAuf } from '../../../lib/hustlerSicht.js'
import { WELT } from '../../../lib/hustlerWelt.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
@@ -14,6 +16,10 @@ 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'
// Ein Zeigerdruck auf diesen Elementen fokussiert sich selbst; überall sonst bekommt das Sichtfenster den Fokus (damit + - 0 Esc wirken)
const SELBST_FOKUS = 'button, a[href], input, select, textarea, [tabindex]'
// Obergrenze für die von der Kopfzeile verdeckte Höhe (Anteil der Fensterhöhe), falls viele Meldungen darunter stehen
const KOPF_MAX_ANTEIL = 0.5
const jetzt = () => Date.now()
@@ -92,8 +98,17 @@ export function baueKamera(melde) {
if (m.st.ziehen) setze({ ziehen: false })
}
// Ein Druck in die Welt gibt dem Sichtfenster den Fokus (tabindex -1), sonst erreichen + - 0 Esc die Kamera nach einem Klick nicht mehr
const fokusAufSicht = (e) => {
const ziel = e && e.target
if (ziel && typeof ziel.closest === 'function' && ziel.closest(SELBST_FOKUS)) return
if (m.el && typeof m.el.focus === 'function') m.el.focus({ preventScroll: true })
}
const unten = (e) => {
if (m.gesperrt || m.st.gleiten || !m.st.bereit) return
if (m.gesperrt) return
fokusAufSicht(e)
if (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 })
@@ -186,6 +201,34 @@ export function baueKamera(melde) {
gleite(kameraEtage(oben, untenRand, groesse()))
}
// Höhe, die die Kopfzeile oben im Sichtfenster verdeckt: Unterkante von .hk-kopf (Geschwister des Sichtfensters), sonst die feste Höhe der Welt-Daten
const kopfHoehe = () => {
const g = groesse()
let h = WELT.hud
try {
const wurzel = m.el && m.el.parentElement
const kopf = wurzel && typeof wurzel.querySelector === 'function' ? wurzel.querySelector('.hk-kopf') : null
if (kopf) {
const unterkante = kopf.getBoundingClientRect().bottom - m.el.getBoundingClientRect().top
if (Number.isFinite(unterkante)) h = unterkante
}
} catch { /* Messung nicht möglich: feste Höhe */ }
return Math.min(g.h * KOPF_MAX_ANTEIL, Math.max(0, h))
}
/**
* Holt ein Rechteck der Welt (left, top, width, height) ins Bild, wenn es nicht ganz sichtbar ist (Tastaturfokus, M1): Kamera gleitet, bei „weniger Bewegung“
* springt sie. `obenExtra` = Bildschirm-Pixel über dem Rechteck, die mit sichtbar sein sollen (Namensschild). Gesperrt: nichts.
*/
const zeige = (rechteck, obenExtra = 0) => {
if (m.gesperrt || !m.st.bereit || !rechteck) return
const extra = Number.isFinite(obenExtra) && obenExtra > 0 && m.st.s > 0 ? obenExtra / m.st.s : 0
const ziel = kameraZeige(m.st, { ...rechteck, top: rechteck.top - extra, height: rechteck.height + extra }, groesse(), { oben: kopfHoehe() })
if (!ziel) return
m.selbst = true
gleite(ziel)
}
// Mausrad (passive: false, sonst scrollt die Seite mit) und Größenänderung am Sichtfenster
const verbinden = () => {
if (!m.el || m.verbunden) return
@@ -229,6 +272,7 @@ export function baueKamera(melde) {
loesen,
ganz,
etage,
zeige,
nachZug,
handlers: {
onPointerDown: unten,
@@ -243,7 +287,7 @@ export function baueKamera(melde) {
/**
* 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() }.
* Liefert { view (Ref-Callback für das Sichtfenster), s, x, y, bereit, gleiten, ziehen, handlers, ganz(), etage(oben, unten), zeige(rechteck, obenExtra), nachZug() }.
* s/x/y: Welt-Pixel mal s plus Verschiebung x/y (transform: translate(x, y) scale(s)).
*/
export default function useKamera({ gesperrt = false } = {}) {
@@ -269,6 +313,7 @@ export default function useKamera({ gesperrt = false } = {}) {
handlers: kam.handlers,
ganz: kam.ganz,
etage: kam.etage,
zeige: kam.zeige,
nachZug: kam.nachZug,
}
}
+61 -2
View File
@@ -258,6 +258,12 @@ const ETAGE_ZOOM_MAX = 3
const ETAGE_HOEHE = 3.4 // Etagenhöhe mal dieser Faktor passt in die Höhe unter der Kopfzeile
const ETAGE_HOEHE_RAND = 40
const OBEN_FREI = 0.35 // so viel der Höhe darf oben über der Welt frei bleiben (Himmel)
const ETAGE_MIN_FAKTOR = 1.6 // „Etage heranzoomen“ vergrößert mindestens so viel mehr als „ganzes Haus“
const ZEIGE_RAND = 24 // Luft um ein per Tastatur fokussiertes Element, das ins Bild geholt wird
const TIPP_PX = 40 // so groß soll die Tippfläche einer Figur am Bildschirm mindestens sein
const TIPP_FIGUR = Object.freeze({ w: 24, h: 32 }) // kleinster Figuren-Knopf in Weltpixeln
const TIPP_BASIS = Object.freeze({ x: 12, oben: 8, unten: 6 }) // Überstand der Tippfläche bei starkem Zoom (wie bisher in hochhaus.css)
const TIPP_MAX = Object.freeze({ x: 36, y: 12 }) // Überstand, ab dem sich Nachbarn überdecken würden
function sicht(g) {
return isRecord(g) && isNum(g.w) && g.w > 0 && isNum(g.h) && g.h > 0 ? g : SICHT_STANDARD
@@ -303,11 +309,64 @@ export function kameraZoomAuf(alt, mx, my, neuS, g) {
return kameraGrenzen({ s: n.s, x: px - (px - alt.x) * f, y: py - (py - alt.y) * f }, v)
}
/** Eine Etage (Klickbereich oben..unten in Weltpixeln) mittig heranzoomen, höchstens 3-fach. */
/**
* Eine Etage (Klickbereich oben..unten in Weltpixeln) mittig heranzoomen: höchstens 3-fach, aber nie weniger als das 1,6-fache von „ganzes Haus“
* (am Handy hochkant begrenzt die Breite sonst auf „cover“, und „heranzoomen“ ändert gar nichts) und nie über den größten Zoom.
*/
export function kameraEtage(oben, unten, g) {
const v = sicht(g)
const hoehe = isNum(oben) && isNum(unten) && unten > oben ? unten - oben : 1
const mitte = isNum(oben) && isNum(unten) ? (oben + unten) / 2 : 0
const s = Math.max(kameraMinS(v), Math.min(ETAGE_ZOOM_MAX, (v.w - ETAGE_SEITE) / ETAGE_BREITE, (v.h - WELT.hud - ETAGE_HOEHE_RAND) / (hoehe * ETAGE_HOEHE)))
const mindestens = Math.min(ZOOM_MAX, kameraPassend(v).s * ETAGE_MIN_FAKTOR)
const s = Math.max(kameraMinS(v), mindestens, Math.min(ETAGE_ZOOM_MAX, (v.w - ETAGE_SEITE) / ETAGE_BREITE, (v.h - WELT.hud - ETAGE_HOEHE_RAND) / (hoehe * ETAGE_HOEHE)))
return kameraGrenzen({ s, x: v.w / 2 - (ETAGEN_SPALTE.x + ETAGEN_SPALTE.w / 2) * s, y: WELT.hud / 2 + v.h / 2 - mitte * s }, v)
}
/**
* Kamera so verschieben, dass ein Rechteck der Welt (left, top, width, height in Weltpixeln, z. B. eine Etage oder Figur mit Fokus) im sichtbaren
* Bereich liegt: das Fenster ohne die Kopfzeile oben (`oben` px, Standard WELT.hud) und mit `rand` px Luft ringsum. Der Zoom bleibt. Liegt das Rechteck
* schon ganz im sichtbaren Bereich (oder lässt die Welt keine Verschiebung zu), kommt null; ist es größer als der Bereich, richtet sich seine linke
* bzw. obere Kante am Rand aus. Ergebnis { s, x, y } innerhalb der Grenzen.
*/
export function kameraZeige(kamera, rechteck, g, { oben = WELT.hud, rand = ZEIGE_RAND } = {}) {
const v = sicht(g)
if (!isRecord(kamera) || !isNum(kamera.s) || kamera.s <= 0 || !isNum(kamera.x) || !isNum(kamera.y)) return null
const r = isRecord(rechteck) ? rechteck : {}
if (!isNum(r.left) || !isNum(r.top) || !isNum(r.width) || !isNum(r.height)) return null
const kopf = isNum(oben) && oben >= 0 ? oben : WELT.hud
const luft = isNum(rand) && rand >= 0 ? rand : ZEIGE_RAND
const links = kamera.x + r.left * kamera.s
const rechts = links + r.width * kamera.s
const obenR = kamera.y + r.top * kamera.s
const untenR = obenR + r.height * kamera.s
const achse = (a, b, min, max) => {
if (b - a > max - min) return min - a // größer als der Bereich: Anfang zeigen
if (a < min) return min - a
if (b > max) return max - b
return 0
}
const dx = achse(links, rechts, luft, v.w - luft)
const dy = achse(obenR, untenR, kopf + luft, v.h - luft)
if (dx === 0 && dy === 0) return null
const neu = kameraGrenzen({ s: kamera.s, x: kamera.x + dx, y: kamera.y + dy }, v)
return Math.abs(neu.x - kamera.x) < 0.5 && Math.abs(neu.y - kamera.y) < 0.5 ? null : neu
}
/**
* Tippfläche der Figuren (M8): der Knopf ist 24 x 32 Weltpixel, am Handy also nur ~17 x 23 px groß. Die Tippfläche (::after des Knopfs) wächst über den
* Knopf hinaus, so dass sie am Bildschirm mindestens 40 px misst: Ergebnis { x, oben, unten } = Überstand in Weltpixeln (CSS: --hk-tipp-x, -oben,
* -unten an der Bühne). Nach unten gedeckelt, damit sich Nachbarn nicht überdecken: seitlich 36 (Figuren stehen mindestens 100 px auseinander, 24 + 2 x 36 = 96),
* senkrecht 12 (Figuren benachbarter Etagen stehen 56 px untereinander, 32 + 2 x 12 = 56). Mindestens die bisherigen 12/8/6.
*/
export function tippRand(zoom) {
const s = isNum(zoom) && zoom > 0 ? zoom : 1
const welt = TIPP_PX / s // 40 Bildschirm-Pixel in Weltpixeln
const klemme = (wert, min, max) => Math.min(max, Math.max(min, wert))
const seitlich = (welt - TIPP_FIGUR.w) / 2
const senkrecht = (welt - TIPP_FIGUR.h) / 2
return {
x: klemme(seitlich, TIPP_BASIS.x, TIPP_MAX.x),
oben: klemme(senkrecht, TIPP_BASIS.oben, TIPP_MAX.y),
unten: klemme(senkrecht, TIPP_BASIS.unten, TIPP_MAX.y),
}
}
+18 -5
View File
@@ -36,12 +36,16 @@ const api = workersApi
// Auth und Dienst-API kommen von hier. Der Feed (Zustand alle 15 s, Ereignisse alle 5 s) läuft genau einmal pro Seite, egal welcher
// Bildschirm offen ist; die Hustler-Tabelle (indexWorkers beim Schnappschuss, reduceEvents bei Ereignissen) gehört ebenfalls hierher.
// Ereignisse gehen zusätzlich an die Regie, die nur das Hochhaus über regieRef einhängt.
// Die Tabelle liegt nie einen Render hinter dem Schnappschuss zurück (sonst stünden alle Figuren kurz auf „Leerlauf“): sie merkt sich, aus welchem Schnappschuss sie
// entstand, und wird im Render neu aus `state` gebaut, sobald der Schnappschuss ein anderer ist. Pause (state.paused) geht an alle drei Bildschirme.
// Das Hochhaus bekommt vonTab (der Bildschirm, von dem man zurückkommt) und fokussiert dann die Etage, über die man gegangen war.
export default function WorkersPage() {
const { isAdmin, user } = useAuth()
const [params, setParams] = useSearchParams()
const active = pickTab(WORKERS_TABS, params.get('tab'))
const bot = params.get('bot') ?? ''
const [workers, setWorkers] = useState({})
const [tabelle, setTabelle] = useState({ von: null, workers: {} }) // Hustler-Tabelle samt dem Schnappschuss, aus dem sie entstand
const [verlauf, setVerlauf] = useState({ aktuell: active.id, vorher: null }) // aktueller und vorheriger Bildschirm
const regieRef = useRef(null)
// Tab-Titel nur solange die Seite offen ist; beim Verlassen kommt der vorige Titel zurück
@@ -53,14 +57,20 @@ export default function WorkersPage() {
// Neue Ereignisse: Tabelle fortschreiben und der Regie vorspielen (die Regie kennt den Rückstau selbst)
const onEvents = useCallback((events) => {
setWorkers((w) => reduceEvents(w, events))
setTabelle((t) => ({ von: t.von, workers: reduceEvents(t.workers, events) }))
if (regieRef.current) regieRef.current.push(events)
}, [])
const { state, error, loaded, refresh } = useWorkersFeed(api, { onEvents })
useEffect(() => {
if (state) setWorkers(indexWorkers(state))
}, [state])
// Neuer Schnappschuss: Tabelle sofort neu bauen (nicht erst nach einem Effekt) und merken
const veraltet = Boolean(state) && tabelle.von !== state
const workers = veraltet ? indexWorkers(state) : tabelle.workers
if (veraltet) setTabelle({ von: state, workers })
// Bildschirmwechsel: der bisherige wird zum vorherigen
const gewechselt = verlauf.aktuell !== active.id
const vonTab = gewechselt ? verlauf.aktuell : verlauf.vorher
if (gewechselt) setVerlauf({ aktuell: active.id, vorher: vonTab })
const paused = state?.paused === true
return (
<div className="hk-seite" data-tab={active.id}>
@@ -71,6 +81,7 @@ export default function WorkersPage() {
feed={{ state, error, loaded, refresh }}
workers={workers}
regieRef={regieRef}
vonTab={vonTab}
onOpen={(tab) => setParams({ tab })}
/>
)}
@@ -80,6 +91,7 @@ export default function WorkersPage() {
me={{ id: user?.$id, name: user?.name }}
kpis={state?.kpis}
workers={workers}
paused={paused}
onBack={() => setParams({ tab: 'hochhaus' })}
/>
)}
@@ -90,6 +102,7 @@ export default function WorkersPage() {
onClearBot={() => setParams({ tab: 'botleads' })}
kpis={state?.kpis}
workers={workers}
paused={paused}
onBack={() => setParams({ tab: 'hochhaus' })}
/>
)}
+5 -5
View File
@@ -6,7 +6,7 @@
/* ---------- Überlagerung: dunkler Hintergrund-Knopf und das Panel in der Mitte ---------- */
.hk-hochhaus .hk-dialog-ueber {
position: absolute; inset: 0; z-index: 20;
position: absolute; inset: 0; z-index: 9; /* unter Seitenleiste (20) und Abdunklung (15) der Handy-Navigation */
display: flex; align-items: center; justify-content: center;
box-sizing: border-box; padding: 16px;
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; color: #e6ebff;
@@ -64,8 +64,8 @@
.hk-hochhaus .hk-dialog-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; position: sticky; bottom: 0; padding: 6px 0 2px; background: #0e1430; }
.hk-hochhaus .hk-dialog-nummer { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 11px; line-height: 14px; color: #9aa6d6; }
/* ---------- Handy (unter 720 px, Spec E13): Vollbild, Szene oben (auf die Breite verkleinert), Daten darunter scrollbar ---------- */
@media (max-width: 719px) {
/* ---------- Handy (unter 720 px breit oder höchstens 500 px hoch, also auch quer, Spec E13): Vollbild, Szene oben (auf die Breite verkleinert), Daten darunter scrollbar ---------- */
@media (max-width: 719px), (max-height: 500px) {
.hk-hochhaus .hk-dialog-ueber { padding: 0; }
.hk-hochhaus .hk-dialog {
/* Container für die Szenen-Skalierung (cqw = Innenbreite des Panels) */
@@ -87,8 +87,8 @@
.hk-hochhaus .hk-dialog-fuss { position: sticky; bottom: 0; margin-top: 4px; padding: 6px 0 2px; background: #0e1430; }
}
/* Handy quer (niedrig): Szene links (45 % der Breite), Daten rechts daneben und scrollbar */
@media (max-width: 719px) and (max-height: 520px) {
/* Handy quer (niedrig, breiter als hoch): Szene links (45 % der Breite), Daten rechts daneben und scrollbar */
@media (max-height: 500px) and (orientation: landscape) {
.hk-hochhaus .hk-dialog { flex-direction: row; align-items: flex-start; }
.hk-hochhaus .hk-dialog .hk-sz { zoom: 0.4; }
@supports (zoom: tan(atan2(1px, 1px))) {
+11 -3
View File
@@ -28,6 +28,11 @@
/* WOMS-Bausteine (Einstellungen-Dialog, ProspectPanel) stehen in der Pixelwelt, sollen aber wie im übrigen WOMS aussehen */
.hk-seite .hk-woms { font-family: var(--font); font-size: 14px; line-height: 20px; color: var(--text); letter-spacing: normal; image-rendering: auto; }
.hk-seite .hk-woms a { color: var(--accent, #3b5bdb); }
/* .hk-hochhaus a:hover (hochhaus.css, gelb) darf im WOMS-Dialog nicht gewinnen: Hover bleibt in der Akzentfarbe des WOMS */
.hk-seite .hk-woms a:hover { color: var(--accent); }
/* Nur für Screenreader (Seitenüberschrift des Hochhauses, Ansage beim Blättern in der Detailansicht): unsichtbar, aber im Lesefluss */
.hk-seite .hk-nur-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Overseer außerhalb von „arbeitet“: Kristall bleibt sichtbar, nur matt */
.hk-seite .hk-gedimmt { filter: saturate(0.35) brightness(0.72); }
@@ -35,16 +40,19 @@
/* Fehler-Marker: rotes Pixel-Schild mit „!“ über dem Kopf (klein im Haus, groß in der Detailansicht) */
.hk-seite .hk-alarm {
position: absolute; left: 15px; top: -11px; width: 7px; height: 9px; box-sizing: border-box;
background: #e2483d; box-shadow: 0 0 0 1px #1a1630; color: #ffffff;
background: #c8372d; box-shadow: 0 0 0 1px #1a1630; color: #ffffff; /* #c8372d: weiß darauf 5,2:1 (mit #e2483d nur 4,0:1) */
font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 6px; line-height: 9px; text-align: center;
pointer-events: none; animation: hk-ui-blinken 1s steps(1) infinite;
}
.hk-seite .hk-alarm-gross { left: auto; right: 8px; top: 8px; width: 28px; height: 36px; font-size: 22px; line-height: 36px; box-shadow: 0 0 0 3px #1a1630; }
@keyframes hk-ui-blinken { 50% { opacity: 0.35; } }
/* Angehalten (state.paused): alles in der Welt steht still */
.hk-seite .hk-steht, .hk-seite .hk-steht * { animation-play-state: paused !important; }
/* Angehalten (state.paused): alles in der Welt steht still (Bühne, Schnee, Detailansicht); auch Pseudo-Elemente (der Schnee bewegt ::before) */
.hk-seite .hk-steht, .hk-seite .hk-steht *, .hk-seite .hk-steht *::before, .hk-seite .hk-steht *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
.hk-seite .hk-alarm { animation: none; }
/* Alle Sprite-Schleifen im Hochhaus und in der Detailansicht stehen auf dem ersten Bild; Pose und Zusätze (Punkte, Zzz, Bildschirm, Dampf …) bleiben sichtbar.
Höhere Spezifität als die Regeln mit animation: in hochhaus.css (.hk-hochhaus .hk-fig-…), deshalb mit .hk-seite davor. */
.hk-seite .hk-hochhaus .hk-spr { animation: none; }
}
+41 -1
View File
@@ -13,6 +13,8 @@
font-size: 15px; line-height: 20px;
}
.hk-hochhaus .hk-sicht.hk-zieht { cursor: grabbing; }
/* Das Sichtfenster ist per tabindex="-1" nur mit der Maus/Programm fokussierbar (ein Klick in die Welt gibt ihm den Fokus, damit + - 0 Esc wirken): kein Ring */
.hk-hochhaus .hk-sicht:focus { outline: none; }
.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; }
@@ -22,6 +24,8 @@
.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; }
/* Schnee: hochhaus.css blendet ihn aus; falls je eingeblendet, steht er */
.hk-hochhaus .hk-sicht > .hk-schnee::before { animation: none; }
}
/* Schriftgrößen der Etagen-Namen und Namensschilder: wachsen beim Herauszoomen, damit sie lesbar bleiben (Werte setzt die Bühne) */
@@ -49,7 +53,7 @@
.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;
background: #c8372d; color: #ffffff; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; /* #c8372d: weiß darauf 5,2:1 (mit #e2483d nur 4,0:1) */
box-shadow: 0 0 0 1px #3a1216, inset 0 1px 0 0 #ff7d70; pointer-events: none;
}
@@ -57,3 +61,39 @@
.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; }
/* ---------- Tippflächen der Figuren (M8): wachsen mit dem Zoom auf 40 Bildschirm-Pixel ----------
Der Knopf ist 24 x 32 Weltpixel; am Handy (ganzes Haus ~0,7-fach) wäre er nur ~17 x 23 px groß. Die Überstände (--hk-tipp-x/-oben/-unten) setzt die Bühne
aus tippRand(zoom) (lib/hustlerSicht.js), gedeckelt, damit sich Nachbarn nicht überdecken. Ersetzt left/right/top/bottom aus hochhaus.css (-12/-12/-8/-6 px). */
.hk-hochhaus .hk-buehne .hk-hustler::after {
left: calc(-1 * var(--hk-tipp-x, 12px)); right: calc(-1 * var(--hk-tipp-x, 12px));
top: calc(-1 * var(--hk-tipp-oben, 8px)); bottom: calc(-1 * var(--hk-tipp-unten, 6px));
}
/* ---------- Schnee (R1): Bewegung per transform statt background-position ----------
hochhaus.css (erzeugt) animiert background-position der drei Schnee-Ebenen: das malt 60 mal pro Sekunde drei Fensterflächen neu. Hier läuft stattdessen ein
::before, das um genau eine Kachel-Wiederholung größer ist als das Fenster und per transform (Compositor, kein Neu-Malen) um dieselbe Strecke in derselben
Richtung in derselben Zeit wandert wie die Vorlage (hk-h-schnee1: +512/+2048 in 72 s, -2: -512/+2048 in 48 s, -3: +768/+6144 in 64 s). Die Kachelgrößen
(512/512/768) teilen die Strecke, die Bildfolge ist also identisch. Der Container .hk-schnee schneidet ab (kein Überlauf). */
.hk-hochhaus .hk-sicht > .hk-schnee { background: none; animation: none; overflow: hidden; overflow: clip; }
.hk-hochhaus .hk-sicht > .hk-schnee::before { content: ''; position: absolute; background-repeat: repeat; image-rendering: pixelated; }
.hk-hochhaus .hk-sicht > .hk-schnee-1::before {
left: -512px; top: -2048px; width: calc(100% + 512px); height: calc(100% + 2048px);
background-image: url(../../assets/hustler/welt/schnee-1.png); background-size: 512px 512px;
animation: hk-welt-schnee1 72s linear infinite;
}
.hk-hochhaus .hk-sicht > .hk-schnee-2::before {
left: 0; top: -2048px; width: calc(100% + 512px); height: calc(100% + 2048px);
background-image: url(../../assets/hustler/welt/schnee-2.png); background-size: 512px 512px;
animation: hk-welt-schnee2 48s linear infinite;
}
.hk-hochhaus .hk-sicht > .hk-schnee-3::before {
left: -768px; top: -6144px; width: calc(100% + 768px); height: calc(100% + 6144px);
background-image: url(../../assets/hustler/welt/schnee-3.png); background-size: 768px 768px;
animation: hk-welt-schnee3 64s linear infinite;
}
@keyframes hk-welt-schnee1 { to { transform: translate(512px, 2048px); } }
@keyframes hk-welt-schnee2 { to { transform: translate(-512px, 2048px); } }
@keyframes hk-welt-schnee3 { to { transform: translate(768px, 6144px); } }
/* Detailansicht offen: Schnee weg (die Welt liegt unter dem Abdunkler, die Ebenen würden umsonst laufen) */
.hk-hochhaus .hk-sicht.hk-verdeckt > .hk-schnee { display: none; }
+88
View File
@@ -609,6 +609,94 @@ test('Fokus-Rückgabe: Kennungen mit Sonderzeichen gehen nie in einen Selektor',
// ---------------------------------------------------------------------------------------------------------------------------------------------
// Quelltext und Stil
// ---------------------------------------------------------------------------------------------------------------------------------------------
// Korrekturen der Endprüfung (T13): Ansage beim Blättern, Pause, Handy quer, Überlagerung, weniger Bewegung
test('M6 Blättern wird angesagt: Live-Region (polite) ist beim Öffnen leer, nach ◂ ▸ und ←/→ steht Name, Status und Nummer darin; Öffnen sagt nichts an', async () => {
const workers = { lea: EINTRAG('arbeitet'), bea: EINTRAG('wartet'), sven: EINTRAG('fehler'), otto: EINTRAG('leerlauf'), arne: EINTRAG('grau', { connected: false }) }
await lauf({ props: { workerId: 'lea', workers } }, (ui) => {
const region = () => ui.one((n) => n.props['aria-live'] === 'polite', 'Live-Region')
assert.equal(region().props['aria-atomic'], 'true'); assert.ok(hasClass(region(), 'hk-nur-sr'))
assert.equal(textOf(region()), '', 'beim Öffnen nichts ansagen')
ui.knopf('Nächster Hustler').props.onClick({})
assert.equal(textOf(region()), 'Bea, Wartet auf Auftrag, 10 von 19')
assert.deepEqual(ui.wechsel, ['bea'])
ui.setProps({ workerId: 'bea' })
assert.equal(textOf(region()), 'Bea, Wartet auf Auftrag, 10 von 19', 'bleibt stehen, bis neu geblättert wird')
ui.taste('ArrowLeft')
assert.equal(textOf(region()), 'Lea, Arbeitet, 9 von 19')
ui.setProps({ workerId: 'lea' })
ui.taste('ArrowLeft')
assert.equal(textOf(region()), 'Sven, Fehler, 8 von 19')
ui.setProps({ workerId: 'otto' })
ui.knopf('Vorheriger Hustler').props.onClick({})
assert.equal(textOf(region()), 'Arne, Noch nicht angeschlossen, 19 von 19', 'Umlauf')
// die Ansage ist eine Momentaufnahme: ein späterer Statuswechsel meldet sich nicht von selbst
ui.setProps({ workerId: 'arne', workers: { ...workers, arne: EINTRAG('arbeitet') } })
assert.equal(textOf(region()), 'Arne, Noch nicht angeschlossen, 19 von 19')
// der Dialog selbst kennt den neuen Namen
assert.equal(ui.dialog().props['aria-label'], 'Hustler Arne')
// ein Hustler ohne Eintrag in der Tabelle: Leerlauf
ui.setProps({ workerId: 'lea', workers: {} })
ui.knopf('Nächster Hustler').props.onClick({})
assert.equal(textOf(region()), 'Bea, Leerlauf, 10 von 19')
// Dialog zu und neu auf: wieder leer
ui.setProps({ workerId: null })
ui.setProps({ workerId: 'kim', workers })
assert.equal(textOf(region()), '')
})
})
test('R3 Pause: bei paused trägt die Überlagerung hk-steht (alle Animationen im Dialog stehen), sonst nicht', async () => {
await lauf({ props: { workerId: 'lea', workers: { lea: EINTRAG('arbeitet') }, paused: false } }, (ui) => {
assert.ok(!hasClass(ui.rahmen(), 'hk-steht'))
ui.setProps({ paused: true })
assert.ok(hasClass(ui.rahmen(), 'hk-steht')); assert.ok(hasClass(ui.rahmen(), 'hk-dialog-ueber'))
ui.setProps({ paused: 'ja' })
assert.ok(!hasClass(ui.rahmen(), 'hk-steht'), 'nur echtes true')
ui.setProps({ paused: undefined })
assert.ok(!hasClass(ui.rahmen(), 'hk-steht'))
})
// die globale Regel hält Animationen unter .hk-steht an, und die Detailansicht liegt unter der Seiten-Wurzel
const regel = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-ui.css'), 'utf8')
assert.match(regel, /\.hk-seite \.hk-steht \*[^{]*\{\s*animation-play-state:\s*paused !important/)
})
test('M5 Handy quer: Vollbild auch bei niedriger Höhe (max-width 719 ODER max-height 500), die Zeilen-Anordnung gilt nur quer; Überlagerung unter der Handy-Navigation', () => {
const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
// Vollbild-Block: beide Bedingungen, durch Komma verbunden
const vollbild = css.match(/@media \(max-width: 719px\), \(max-height: 500px\) \{([\s\S]*?)\n\}/)
assert.ok(vollbild, 'Media-Abfrage für Vollbild')
assert.match(vollbild[1], /\.hk-hochhaus \.hk-dialog-ueber \{ padding: 0; \}/)
assert.match(vollbild[1], /width: 100%; max-width: 100%; height: 100%; max-height: 100%/)
assert.match(vollbild[1], /container-type: inline-size/)
// Quer: Szene links, Daten rechts, nur im Querformat und niedrig
const quer = css.match(/@media \(max-height: 500px\) and \(orientation: landscape\) \{([\s\S]*?)\n\}/)
assert.ok(quer, 'Media-Abfrage für quer')
assert.match(quer[1], /flex-direction: row/); assert.match(quer[1], /45cqw/)
// die alte Bedingung (nur schmale Handys) ist weg
assert.doesNotMatch(css, /@media \(max-width: 719px\) and \(max-height: 520px\)/)
// R4: z-index unter Seitenleiste (20) und Abdunkler (15)
assert.match(css, /\.hk-hochhaus \.hk-dialog-ueber \{[^}]*z-index: 9;/)
assert.doesNotMatch(css, /z-index:\s*(1[0-9]|[2-9][0-9])/)
})
test('M4 weniger Bewegung: alle Sprites der Detailansicht (Figur, Monitor, Dampf, Zzz, Wirbel) tragen hk-spr und stehen damit auf dem ersten Bild; der Fehler-Marker hat seine eigene Regel', async () => {
const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-ui.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const reduziert = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)'))
assert.match(reduziert, /\.hk-seite \.hk-hochhaus \.hk-spr \{ animation: none; \}/)
assert.match(reduziert, /\.hk-seite \.hk-alarm \{ animation: none; \}/)
const vorlage = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hochhaus.css'), 'utf8')
const animiert = new Set([...vorlage.replace(/@media \(prefers-reduced-motion: reduce\)\{[\s\S]*?\n\}\n/, '').matchAll(/\.hk-hochhaus (\.hk-[\w-]+)\{[^}]*animation:/g)].map((m) => m[1].slice(1)))
for (const [id, status] of [['lea', 'arbeitet'], ['hanna', 'wartet'], ['bea', 'leerlauf'], ['kim', 'grau'], ['otto', 'wartet'], ['pia', 'fehler']]) {
await lauf({ props: { workerId: id, workers: { [id]: EINTRAG(status, { connected: status !== 'grau' }) } } }, (ui) => {
const bewegt = ui.find((n) => typeof n.tag === 'string' && classes(n).some((c) => animiert.has(c)))
assert.ok(bewegt.length >= 1, `${id}: mindestens die Figur`)
for (const n of bewegt) assert.ok(classes(n).includes('hk-spr') || classes(n).includes('hk-alarm'), `${id}: ${classes(n).join(' ')} nicht von einer Reduziert-Regel erfasst`)
})
}
})
test('Komponente: kein innerHTML, kein CSS- oder Bild-Import, Links nur über safeLink', () => {
const quelle = fs.readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HustlerDialog.jsx'), 'utf8')
const src = quelle.replace(/(^|\s)\/\/.*$/gm, '$1') // Zeilenkommentare dürfen das Wort erwähnen
+305 -1
View File
@@ -34,7 +34,7 @@ const laden = () => (bundle ??= esbuild.build({
"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 { 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',
@@ -865,6 +865,306 @@ test('Kamera: beim Abbau fallen Rad-Zuhörer, Beobachter und Gleit-Timer weg; ei
} 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 () => {
@@ -888,6 +1188,10 @@ test('Aufbau: kein CSS-Import, keine PNG-Importe, kein innerHTML; jede gerendert
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}`)))
+178 -3
View File
@@ -215,9 +215,10 @@ const STUBS = {
return hk.feed
}
`,
hochhaus: "import { jsx } from 'react'\nexport default function Hochhaus(props) { globalThis.__hk.props.hochhaus = props; return jsx('div', { 'data-screen': 'hochhaus' }) }",
pult: "import { jsx } from 'react'\nexport default function PultList(props) { globalThis.__hk.props.pult = props; return jsx('div', { 'data-screen': 'pult' }) }",
keller: "import { jsx } from 'react'\nexport default function BotLeadList(props) { globalThis.__hk.props.keller = props; return jsx('div', { 'data-screen': 'keller' }) }",
// renders: jede Darstellung eines Bildschirms (nicht nur die letzte), damit der Test auch Zwischenstände sieht (R5)
hochhaus: "import { jsx } from 'react'\nexport default function Hochhaus(props) { globalThis.__hk.props.hochhaus = props; globalThis.__hk.renders?.hochhaus.push(props); return jsx('div', { 'data-screen': 'hochhaus' }) }",
pult: "import { jsx } from 'react'\nexport default function PultList(props) { globalThis.__hk.props.pult = props; globalThis.__hk.renders?.pult.push(props); return jsx('div', { 'data-screen': 'pult' }) }",
keller: "import { jsx } from 'react'\nexport default function BotLeadList(props) { globalThis.__hk.props.keller = props; globalThis.__hk.renders?.keller.push(props); return jsx('div', { 'data-screen': 'keller' }) }",
}
const stubPlugin = {
name: 'seite-attrappen',
@@ -255,6 +256,7 @@ async function seite(query, { admin = false, feed } = {}) {
api: globalThis.__hkApi,
feed: feed ?? { state: null, error: null, loaded: false, refresh: () => {} },
feedCalls: [], feedMounts: 0, feedUnmounts: 0, props: {}, onEvents: null,
renders: { hochhaus: [], pult: [], keller: [] },
}
const savedHk = globalThis.__hk
const hadDocument = Object.hasOwn(globalThis, 'document')
@@ -454,3 +456,176 @@ test('Seite: ohne angemeldeten Nutzer bleibt me ohne Id und Name (kein Absturz)'
assert.deepEqual(ui.hk.props.pult.me, { id: undefined, name: undefined })
} finally { ui.end() }
})
// ---------- Korrekturen der Endprüfung (T13) ----------
test('R5 Seite: die Hustler-Tabelle liegt nie einen Render hinter dem Schnappschuss (kein „Leerlauf“-Blitz der Figuren, in keinem Bildschirm)', async () => {
const state = makeState({ statuses: { lea: 'wartet', kim: 'fehler' }, connected: { pia: false } })
const ui = await seite('tab=hochhaus', { feed: { state, error: null, loaded: true, refresh() {} } })
try {
// schon die allererste Darstellung mit Zustand hat die Tabelle daraus
const renders = ui.hk.renders.hochhaus
assert.ok(renders.length >= 1)
for (const p of renders) {
assert.equal(Object.keys(p.workers).length, 19)
assert.equal(p.workers.lea.status, 'wartet'); assert.equal(p.workers.kim.status, 'fehler'); assert.equal(p.workers.pia.status, 'grau')
}
// neuer Schnappschuss mitten im Lauf: jede weitere Darstellung zeigt schon die neue Lage (nie die alte Tabelle zum neuen Zustand)
renders.length = 0
const zweiter = makeState({ statuses: { lea: 'leerlauf' } })
ui.setFeed({ state: zweiter })
assert.ok(renders.length >= 1)
for (const p of renders) { assert.equal(p.feed.state, zweiter); assert.equal(p.workers.lea.status, 'leerlauf'); assert.equal(p.workers.kim.status, 'arbeitet') }
// dasselbe für Pult und Keller (sie bekommen dieselbe Tabelle)
ui.go('tab=freigabe')
ui.hk.renders.pult.length = 0
const dritter = makeState({ statuses: { lea: 'wartet' } })
ui.setFeed({ state: dritter })
for (const p of ui.hk.renders.pult) assert.equal(p.workers.lea.status, 'wartet')
ui.go('tab=botleads')
ui.hk.renders.keller.length = 0
ui.setFeed({ state: makeState({ statuses: { lea: 'fehler' } }) })
for (const p of ui.hk.renders.keller) assert.equal(p.workers.lea.status, 'fehler')
assert.ok(ui.hk.renders.keller.length >= 1)
// Ereignisse wirken weiter auf die Tabelle und überleben das Neu-Rendern desselben Schnappschusses
ui.hk.onEvents([ev('leerlauf', 'lea', { text: 'Pause' })])
assert.equal(ui.hk.props.keller.workers.lea.status, 'leerlauf')
ui.setFeed({})
assert.equal(ui.hk.props.keller.workers.lea.status, 'leerlauf')
} finally { ui.end() }
// Erster Schnappschuss kommt erst später: vorher leer, danach nie eine Darstellung mit Zustand und leerer Tabelle
const spaet = await seite('tab=hochhaus')
try {
assert.deepEqual(spaet.hk.props.hochhaus.workers, {})
spaet.hk.renders.hochhaus.length = 0
spaet.setFeed({ state: makeState({ statuses: { lea: 'wartet' } }), loaded: true })
for (const p of spaet.hk.renders.hochhaus) { assert.ok(p.feed.state); assert.equal(Object.keys(p.workers).length, 19) }
assert.ok(spaet.hk.renders.hochhaus.length >= 1)
} finally { spaet.end() }
})
test('R3 Seite: Pult und Keller bekommen paused aus state.paused (nur ein echtes true), Pause schaltet mit dem Zustand um', async () => {
const ui = await seite('tab=freigabe', { feed: { state: makeState({ paused: false }), error: null, loaded: true, refresh() {} } })
try {
assert.equal(ui.hk.props.pult.paused, false)
ui.setFeed({ state: makeState({ paused: true }) })
assert.equal(ui.hk.props.pult.paused, true)
ui.go('tab=botleads')
assert.equal(ui.hk.props.keller.paused, true)
ui.setFeed({ state: makeState({ paused: false }) })
assert.equal(ui.hk.props.keller.paused, false)
ui.setFeed({ state: { ...makeState(), paused: 'ja' } })
assert.equal(ui.hk.props.keller.paused, false)
// vor dem ersten Schnappschuss: nicht angehalten
ui.setFeed({ state: null, loaded: false })
assert.equal(ui.hk.props.keller.paused, false)
} finally { ui.end() }
})
test('M2 Seite: das Hochhaus bekommt vonTab (der Bildschirm, von dem man zurückkommt); beim ersten Besuch null', async () => {
const ui = await seite('tab=hochhaus', { feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try {
assert.equal(ui.hk.props.hochhaus.vonTab, null)
ui.go('tab=freigabe'); assert.deepEqual(ui.screen(), ['pult'])
ui.go('tab=hochhaus'); assert.equal(ui.hk.props.hochhaus.vonTab, 'freigabe')
// schon der erste Render nach dem Wechsel kennt vonTab (das Hochhaus fokussiert beim Einhängen)
assert.ok(ui.hk.renders.hochhaus.every((p, i) => i === 0 || p.vonTab === null || p.vonTab === 'freigabe'))
ui.go('tab=botleads'); assert.deepEqual(ui.screen(), ['keller'])
ui.go('tab=hochhaus'); assert.equal(ui.hk.props.hochhaus.vonTab, 'botleads')
// Neu-Rendern im Hochhaus ändert nichts
ui.setFeed({}); assert.equal(ui.hk.props.hochhaus.vonTab, 'botleads')
} finally { ui.end() }
// direkt im Pult gestartet, dann zurück
const direkt = await seite('tab=freigabe', { feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try {
direkt.go('tab=hochhaus')
assert.equal(direkt.hk.props.hochhaus.vonTab, 'freigabe')
} finally { direkt.end() }
})
// Hochhaus mit Wurzel-Attrappe (querySelector, focus) für den Rückweg und die Überschrift
function wurzelAttrappe() {
const gefragt = []
const etagen = new Map()
const wurzel = {
querySelector(sel) {
gefragt.push(sel)
const m = /^\[data-hk-etage="([\w-]+)"\]$/.exec(sel)
if (!m) return null
if (!etagen.has(m[1])) etagen.set(m[1], { id: m[1], focusCalls: [], focus(o) { this.focusCalls.push(o) } })
return etagen.get(m[1])
},
}
const gehabt = Object.hasOwn(globalThis, '__fakeRef')
const alt = globalThis.__fakeRef
globalThis.__fakeRef = (tag, props) => (tag === 'div' && props.className === 'hk-hochhaus' ? wurzel : null)
return { gefragt, etagen, restore() { if (gehabt) globalThis.__fakeRef = alt; else delete globalThis.__fakeRef } }
}
test('M2 Hochhaus: nach dem Rückweg aus dem Pult fokussiert die Etage „freigabe“, aus dem Keller die Etage „keller“ (ohne Scrollen), erst wenn die Welt steht; sonst nichts', async () => {
const ready = { state: makeState(), error: null, loaded: true, refresh() {} }
for (const [von, etage] of [['freigabe', 'freigabe'], ['botleads', 'keller']]) {
const w = wurzelAttrappe()
const view = await hochhaus(ready, { vonTab: von })
try {
assert.deepEqual(w.gefragt, [`[data-hk-etage="${etage}"]`])
assert.deepEqual(w.etagen.get(etage).focusCalls, [{ preventScroll: true }])
// Neu-Rendern (Feed-Aktualisierung) holt den Fokus nicht noch einmal
view.setFeed({ state: makeState() })
assert.equal(w.etagen.get(etage).focusCalls.length, 1)
} finally { view.end(); w.restore() }
}
// kein vonTab / unbekannter Wert: kein Fokus
for (const von of [null, undefined, 'hochhaus', 'toString', '__proto__']) {
const w = wurzelAttrappe()
const view = await hochhaus(ready, { vonTab: von })
try { assert.deepEqual(w.gefragt, [], String(von)) } finally { view.end(); w.restore() }
}
// noch nicht geladen: erst wenn der Zustand da ist
const w = wurzelAttrappe()
const view = await hochhaus({ state: null, error: null, loaded: false, refresh() {} }, { vonTab: 'freigabe' })
try {
assert.deepEqual(w.gefragt, [])
view.setFeed({ state: makeState(), loaded: true })
assert.deepEqual(w.etagen.get('freigabe').focusCalls, [{ preventScroll: true }])
} finally { view.end(); w.restore() }
})
test('M2/M7 Hochhaus: Überschrift „Hustler – Hochhaus“ (h1, nur für Screenreader) in jedem Zustand; die Kopfzeile steht im DOM vor der Welt', async () => {
const ready = { state: makeState(), error: null, loaded: true, refresh() {} }
const faelle = [
['geladen', ready],
['lädt', { state: null, error: null, loaded: false, refresh() {} }],
['Dienst nicht erreichbar', { state: null, error: 'weg', loaded: false, refresh() {} }],
]
for (const [wo, feed] of faelle) {
const view = await hochhaus(feed)
try {
const h1 = nodes(view.tree(), (n) => n.tag === 'h1')
assert.equal(h1.length, 1, wo)
assert.equal(textOf(h1[0]), 'Hustler – Hochhaus', wo); assert.equal(h1[0].props.className, 'hk-nur-sr', wo)
assert.equal(view.tree().children[0], h1[0], `${wo}: die Überschrift steht zuerst`)
} finally { view.end() }
}
const view = await hochhaus(ready)
try {
const reihenfolge = view.tree().children.filter((k) => k && k.props && k.props['data-teil']).map((k) => k.props['data-teil'])
assert.deepEqual(reihenfolge, ['kopf', 'welt'], 'Tab-Reihenfolge: Kopfzeile vor der Welt')
view.hh.welt.onZeige('lea')
await settle()
assert.deepEqual(view.tree().children.filter((k) => k && k.props && k.props['data-teil']).map((k) => k.props['data-teil']), ['kopf', 'welt', 'dialog'])
} finally { view.end() }
})
test('R3 Hochhaus: paused des Dienstes geht an die Detailansicht (nur ein echtes true)', async () => {
const view = await hochhaus({ state: makeState({ paused: false }), error: null, loaded: true, refresh() {} })
try {
view.hh.welt.onZeige('lea')
await settle()
assert.equal(view.hh.dialog.paused, false)
view.setFeed({ state: makeState({ paused: true }) })
assert.equal(view.hh.dialog.paused, true)
view.setFeed({ state: { ...makeState(), paused: 'ja' } })
assert.equal(view.hh.dialog.paused, false)
} finally { view.end() }
})
+140 -3
View File
@@ -12,11 +12,11 @@ const W = await loadModule('src/lib/hustlerWelt.js')
const {
HUT, STATUS_ANZEIGE, POSE_NACH_STATUS, AUFGABEN, ZOOM_MAX, SICHT_STANDARD,
anzeigeName, modellKurz, modellName, statusVon, figurZustand, detailModell, blaettern, hudWerte, schilder,
kameraMinS, kameraGrenzen, kameraPassend, kameraZoomAuf, kameraEtage,
kameraMinS, kameraGrenzen, kameraPassend, kameraZoomAuf, kameraEtage, kameraZeige, tippRand,
} = S
const { ROLES, SEARCH_ROOMS, FLOOR_TITLES, ROOM_TITLES } = R
const { formatEur, formatUsd, formatCount, kpiTiles, traceRows } = V
const { ETAGEN } = W
const { ETAGEN, HUSTLER } = W
const NBSP = String.fromCharCode(160)
const STATUS = ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler']
@@ -691,8 +691,18 @@ const VL = (() => {
const FENSTER = [
{ w: 1280, h: 720 }, { w: 1920, h: 1080 }, { w: 390, h: 700 }, { w: 390, h: 844 }, { w: 2560, h: 1440 },
{ w: 800, h: 400 }, { w: 3840, h: 2160 }, { w: 2400, h: 1200 }, { w: 1024, h: 1366 }, { w: 1500, h: 500 },
{ w: 844, h: 390 }, { w: 360, h: 740 }, { w: 768, h: 1024 },
]
// Einzige gewollte Abweichung von der Vorlage (M8): „Etage heranzoomen“ vergrößert mindestens das 1,6-fache von „ganzes Haus“ (höchstens 5-fach).
// Wo die Vorlage ohnehin mehr zoomt (Desktop, Handy quer), bleibt es bei ihrer Zahl; sonst gilt der Boden, gerechnet mit den Grenzen der Vorlage.
function etageErwartet(oben, unten, g) {
const vorlage = VL.etage(oben, unten, g)
const boden = Math.min(5, VL.passend(g).s * 1.6)
if (vorlage.s >= boden) return vorlage
return VL.grenzen({ s: boden, x: g.w / 2 - 1216 * boden, y: 42 + g.h / 2 - ((oben + unten) / 2) * boden }, g)
}
// Gleichmäßige Zufallszahlen ohne Math.random, damit der Test immer dasselbe prüft
function zufall(seed) {
let z = seed >>> 0
@@ -704,7 +714,7 @@ test('Kamera: gleiche Zahlen wie die Formeln der Zeichenfläche', () => {
for (const g of FENSTER) {
assert.equal(kameraMinS(g), VL.minS(g), `minS ${g.w}x${g.h}`)
assert.deepEqual(kameraPassend(g), VL.passend(g), `passend ${g.w}x${g.h}`)
for (const e of ETAGEN) assert.deepEqual(kameraEtage(e.oben, e.unten, g), VL.etage(e.oben, e.unten, g), `etage ${e.id} ${g.w}x${g.h}`)
for (const e of ETAGEN) assert.deepEqual(kameraEtage(e.oben, e.unten, g), etageErwartet(e.oben, e.unten, g), `etage ${e.id} ${g.w}x${g.h}`)
for (let i = 0; i < 40; i += 1) {
const st = { s: rnd() * 8, x: (rnd() - 0.7) * 6000, y: (rnd() - 0.6) * 3000 }
assert.deepEqual(kameraGrenzen(st, g), VL.grenzen(st, g), `grenzen ${JSON.stringify(st)}`)
@@ -846,6 +856,133 @@ test('kameraEtage: die Etage liegt mittig im Bild, höchstens 3-fach, nie kleine
assert.equal(kameraEtage(720, 776, { w: 1920, h: 1080 }).s, 3)
})
test('kameraEtage (M8): mindestens 1,6 x „ganzes Haus“ (höchstens ZOOM_MAX); Handy hochkant zoomt jetzt sichtbar, Desktop und Handy quer bleiben wie die Vorlage', () => {
for (const g of FENSTER) {
const ganz = kameraPassend(g).s
for (const e of ETAGEN) {
const k = kameraEtage(e.oben, e.unten, g)
const wo = `${e.id} ${g.w}x${g.h} ${JSON.stringify(k)}`
assert.ok(k.s >= Math.min(ZOOM_MAX, ganz * 1.6) - 1e-9, `mindestens 1,6 x ganz: ${wo}`)
assert.ok(k.s <= ZOOM_MAX, `höchstens ZOOM_MAX: ${wo}`)
}
}
const nahe = (a, b, wo) => assert.ok(Math.abs(a - b) < 1e-9, `${wo}: ${a} ≈ ${b}`)
// 390 x 844: ganzes Haus = cover = 844 / 1200; vorher änderte „heranzoomen“ den Zoom gar nicht (Breite begrenzte auf unter cover), jetzt 1,6 x
const handy = { w: 390, h: 844 }
nahe(kameraPassend(handy).s, 844 / 1200, 'ganz 390x844')
nahe(kameraEtage(552, 608, handy).s, (844 / 1200) * 1.6, 'Etage 390x844')
assert.ok(kameraEtage(552, 608, handy).s > kameraPassend(handy).s * 1.5)
// Desktop: die Breite begrenzt, das ist schon mehr als 1,6 x
nahe(kameraEtage(720, 776, { w: 1280, h: 720 }).s, (1280 - 96) / 560, 'Etage 1280x720')
assert.equal(kameraEtage(720, 776, { w: 1920, h: 1080 }).s, 3)
// Handy quer 844 x 390: 1,336 (Breite) bleibt, ganz = 0,41
nahe(kameraEtage(776, 832, { w: 844, h: 390 }).s, (844 - 96) / 560, 'Etage 844x390')
// Tablet hochkant: vorher kleiner als „ganzes Haus“ (1,2 gegen 1,26)
assert.ok(kameraEtage(776, 832, { w: 768, h: 1024 }).s > kameraPassend({ w: 768, h: 1024 }).s * 1.59)
// Die Etage bleibt mittig und die Kamera in den Grenzen
for (const e of ETAGEN) {
const k = kameraEtage(e.oben, e.unten, handy)
assert.deepEqual(kameraGrenzen(k, handy), k, e.id)
}
})
test('kameraZeige (M1): holt ein Rechteck der Welt ins Bild (unter der Kopfzeile, mit Rand), Zoom bleibt; schon Sichtbares und Unmögliches geben null', () => {
const g = { w: 1280, h: 720 }
const k = { s: 1, x: -600, y: -300 } // sichtbar: Welt x 600..1880, y 300+kopf..1020
const figur = { left: 1000, top: 500, width: 24, height: 32 }
assert.equal(kameraZeige(k, figur, g), null)
// rechts außerhalb: rechter Rand der Figur kommt auf Breite - 24
const rechts = kameraZeige(k, { ...figur, left: 2000 }, g)
assert.deepEqual(rechts, { s: 1, x: 1280 - 24 - (2000 + 24), y: -300 })
// links außerhalb: linker Rand auf 24
const links = kameraZeige(k, { ...figur, left: 400 }, g)
assert.deepEqual(links, { s: 1, x: 24 - 400, y: -300 })
// oben: unter der Kopfzeile (Standard 84 px) plus Rand 24
const oben = kameraZeige({ s: 1, x: -600, y: -300 }, { ...figur, top: 340 }, g)
assert.deepEqual(oben, { s: 1, x: -600, y: 84 + 24 - 340 })
// eine höhere Kopfzeile (2 Reihen am Handy) verschiebt die Grenze
assert.deepEqual(kameraZeige({ s: 1, x: -600, y: -300 }, { ...figur, top: 340 }, g, { oben: 150 }), { s: 1, x: -600, y: 150 + 24 - 340 })
// ein Rechteck dicht unter der Kopfzeile, das die Standard-Zeile nicht stört, aber eine hohe: wird weggeholt
assert.equal(kameraZeige({ s: 1, x: -600, y: -300 }, { ...figur, top: 420 }, g), null)
assert.notEqual(kameraZeige({ s: 1, x: -600, y: -300 }, { ...figur, top: 420 }, g, { oben: 150 }), null)
// unten
assert.deepEqual(kameraZeige(k, { ...figur, top: 1100 }, g), { s: 1, x: -600, y: 720 - 24 - (1100 + 32) })
// Zoom wirkt: bei s = 2 zählt das Rechteck doppelt
assert.deepEqual(kameraZeige({ s: 2, x: 0, y: 0 }, { left: 700, top: 200, width: 24, height: 32 }, g), { s: 2, x: 1280 - 24 - (700 + 24) * 2, y: 0 })
// größer als der sichtbare Bereich: die Anfangskante kommt an den Rand
const breit = kameraZeige({ s: 3, x: -2000, y: -300 }, { left: 960, top: 888, width: 512, height: 56 }, g)
assert.equal(breit.x, 24 - 960 * 3 - 0) // 960*3 - 2000 = 880 > 24 (sichtbar), Rechteck 1536 breit > 1232 -> linke Kante auf 24
// Ergebnis liegt in den Grenzen (die Welt endet links bei 0, rechts bei Breite - Welt)
const ganzRechts = kameraZeige({ s: 1, x: 0, y: 0 }, { left: 2390, top: 500, width: 24, height: 32 }, g)
assert.deepEqual(kameraGrenzen(ganzRechts, g), ganzRechts)
assert.equal(ganzRechts.x, 1280 - 2400)
// Eingabe unverändert, kaputte Eingaben geben null
const eingabe = Object.freeze({ s: 1, x: -600, y: -300 })
kameraZeige(eingabe, { ...figur, left: 2000 }, g)
assert.deepEqual({ ...eingabe }, { s: 1, x: -600, y: -300 })
for (const kaputt of [null, undefined, {}, { left: 1 }, { left: NaN, top: 0, width: 1, height: 1 }, 'x']) assert.equal(kameraZeige(k, kaputt, g), null)
assert.equal(kameraZeige(null, figur, g), null)
assert.equal(kameraZeige({ s: 0, x: 0, y: 0 }, figur, g), null)
// kaputtes Fenster fällt auf die Entwurfsgröße zurück
assert.deepEqual(kameraZeige(k, { ...figur, left: 2000 }, null), kameraZeige(k, { ...figur, left: 2000 }, SICHT_STANDARD))
})
test('tippRand (M8): Tippfläche der Figuren wächst mit dem Zoom auf 40 Bildschirm-Pixel, gedeckelt gegen Überlappung; bei starkem Zoom die alten 12/8/6', () => {
const nahe = (a, b, wo) => assert.ok(Math.abs(a - b) < 1e-9, `${wo}: ${a} ≈ ${b}`)
// starker Zoom: bisherige Überstände von hochhaus.css (-12/-12/-8/-6)
for (const z of [1.7, 2, 3, 5]) assert.deepEqual(tippRand(z), { x: 12, oben: 8, unten: 6 }, `Zoom ${z}`)
// Handy hochkant, ganzes Haus (0,7033): 40 px = 56,9 Weltpixel -> 24 + 2 x 16,4; senkrecht auf 12 gedeckelt (32 + 2 x 12 = 56 = Etagenabstand)
const z = 844 / 1200
const t = tippRand(z)
nahe(t.x, (40 / z - 24) / 2, 'x 0,7033')
assert.equal(t.oben, 12); assert.equal(t.unten, 12)
nahe((24 + 2 * t.x) * z, 40, 'Breite am Bildschirm')
nahe((32 + t.oben + t.unten) * z, 56 * z, 'Höhe am Bildschirm')
assert.ok((32 + t.oben + t.unten) * z > 39 && (32 + t.oben + t.unten) * z < 40)
// sehr klein (Handy quer, ganzes Haus 0,41; cover 0,35): seitlich auf 36 gedeckelt (24 + 72 = 96 < 100 Weltpixel Figurenabstand)
assert.equal(tippRand(0.41).x, 36); assert.equal(tippRand(0.3517).x, 36)
assert.ok((24 + 2 * 36) * 0.3517 >= 32, 'auch beim kleinsten Zoom mindestens 32 px breit')
// nie kleiner als die bisherigen Werte, immer endlich, auch bei kaputtem Zoom
for (const kaputt of [0, -1, NaN, Infinity, undefined, null, 'x']) {
const k = tippRand(kaputt)
for (const v of [k.x, k.oben, k.unten]) assert.ok(Number.isFinite(v) && v > 0, String(kaputt))
}
for (let zoom = 0.3; zoom <= 5; zoom += 0.05) {
const k = tippRand(zoom)
assert.ok(k.x >= 12 && k.oben >= 8 && k.unten >= 6 && k.x <= 36 && k.oben <= 12 && k.unten <= 12, `Zoom ${zoom}`)
}
})
test('tippRand (M8): die gewachsenen Tippflächen überdecken sich bei keinem Zoom (alle 19 Figuren, echte Lage), und am Handy sind sie breit genug', () => {
const figuren = Object.entries(HUSTLER).map(([id, h]) => ({ id, ...h.knopf }))
assert.equal(figuren.length, 19)
for (let zoom = 0.35; zoom <= 5; zoom += 0.01) {
const t = tippRand(zoom)
const flaechen = figuren.map((f) => ({ id: f.id, l: f.left - t.x, r: f.left + f.w + t.x, o: f.top - t.oben, u: f.top + f.h + t.unten }))
for (let i = 0; i < flaechen.length; i += 1) {
for (let j = i + 1; j < flaechen.length; j += 1) {
const a = flaechen[i]; const b = flaechen[j]
const ueberlappt = a.l < b.r - 1e-9 && b.l < a.r - 1e-9 && a.o < b.u - 1e-9 && b.o < a.u - 1e-9
assert.ok(!ueberlappt, `${a.id} und ${b.id} überdecken sich bei Zoom ${zoom.toFixed(2)}`)
}
}
}
// 390 x 844 (ganzes Haus 0,7033): jede Figur ≥ 40 px breit, ≥ 32 px hoch (senkrecht begrenzt der Etagenabstand 56 Weltpixel)
const z = kameraPassend({ w: 390, h: 844 }).s
const t = tippRand(z)
for (const f of figuren) {
assert.ok((f.w + 2 * t.x) * z >= 40 - 1e-9, `${f.id} Breite`)
assert.ok((f.h + t.oben + t.unten) * z >= 32, `${f.id} Höhe`)
}
// nach „Etage heranzoomen“ (1,125): ≥ 40 in beide Richtungen
const nah = kameraEtage(552, 608, { w: 390, h: 844 }).s
const tn = tippRand(nah)
for (const f of figuren) {
assert.ok((f.w + 2 * tn.x) * nah >= 40 - 1e-9, `${f.id} Breite nah`)
assert.ok((f.h + tn.oben + tn.unten) * nah >= 40 - 1e-9, `${f.id} Höhe nah`)
}
})
// ---------------------------------------------------------------------------------------------------------------
// Aufbau des Moduls
+3 -2
View File
@@ -29,7 +29,7 @@ test('WorkersPage: der Feed läuft einmal pro Seite und füllt die Hustler-Tabel
const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
assert.equal(s.match(/useWorkersFeed\(/g).length, 1)
assert.match(s, /const \{ state, error, loaded, refresh \} = useWorkersFeed\(api, \{ onEvents \}\)/)
assert.match(s, /indexWorkers\(state\)/); assert.match(s, /reduceEvents\(w, events\)/)
assert.match(s, /indexWorkers\(state\)/); assert.match(s, /reduceEvents\(t\.workers, events\)/) // die Tabelle merkt sich ihren Schnappschuss (R5) und wird bei Ereignissen fortgeschrieben
assert.match(s, /const regieRef = useRef\(null\)/); assert.match(s, /regieRef\.current\.push\(events\)/); assert.match(s, /<Hochhaus[^>]*regieRef=\{regieRef\}/)
assert.match(s, /feed=\{\{ state, error, loaded, refresh \}\}/); assert.match(s, /<Hochhaus[^>]*workers=\{workers\}/)
for (const f of ['Hochhaus', 'PultList', 'BotLeadList']) assert.equal(readFileSync(new URL(`../../src/components/workers/${f === 'Hochhaus' ? 'hochhaus/' : ''}${f}.jsx`, import.meta.url), 'utf8').includes('useWorkersFeed('), false, `${f} startet keinen eigenen Feed`)
@@ -42,7 +42,8 @@ test('WorkersPage: freigabe zeigt die PultList, botleads die BotLeadList (api, b
assert.match(s, /params\.get\('bot'\)/); assert.match(s, /<BotLeadList[^>]*\bbot=\{bot\}/); assert.match(s, /<BotLeadList[^>]*onClearBot=\{\(\) => setParams\(\{ tab: 'botleads' \}\)\}/)
assert.match(s, /onOpen=\{\(tab\) => setParams\(\{ tab \}\)\}/) // setParams ersetzt alle Parameter: bot fällt beim Wechsel weg
assert.equal(s.match(/onBack=\{\(\) => setParams\(\{ tab: 'hochhaus' \}\)\}/g).length, 2) // „◂ Hochhaus“ in Pult und Keller
for (const name of ['PultList', 'BotLeadList']) assert.match(s, new RegExp(`<${name}[\\s\\S]*?kpis=\\{state\\?\\.kpis\\}[\\s\\S]*?workers=\\{workers\\}[\\s\\S]*?/>`))
for (const name of ['PultList', 'BotLeadList']) assert.match(s, new RegExp(`<${name}[\\s\\S]*?kpis=\\{state\\?\\.kpis\\}[\\s\\S]*?workers=\\{workers\\}[\\s\\S]*?paused=\\{paused\\}[\\s\\S]*?/>`)) // Pause (R3) geht an Pult und Keller
assert.match(s, /const paused = state\?\.paused === true/)
assert.doesNotMatch(s, /Später kommt botleads/)
})
test('CSS-Importe der Hustler-Oberfläche stehen nur in WorkersPage, nicht in den Komponenten (sonst nicht unter Node mit fakeReact ladbar)', () => {