diff --git a/src/components/workers/hochhaus/useKamera.js b/src/components/workers/hochhaus/useKamera.js
index 126c7a2..bd31d0c 100644
--- a/src/components/workers/hochhaus/useKamera.js
+++ b/src/components/workers/hochhaus/useKamera.js
@@ -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,
}
}
diff --git a/src/lib/hustlerSicht.js b/src/lib/hustlerSicht.js
index 2b7c2ae..45a7da2 100644
--- a/src/lib/hustlerSicht.js
+++ b/src/lib/hustlerSicht.js
@@ -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),
+ }
+}
diff --git a/src/pages/WorkersPage.jsx b/src/pages/WorkersPage.jsx
index 9ca00a6..de83ffb 100644
--- a/src/pages/WorkersPage.jsx
+++ b/src/pages/WorkersPage.jsx
@@ -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 (
@@ -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' })}
/>
)}
diff --git a/src/styles/hustler/hustler-detail.css b/src/styles/hustler/hustler-detail.css
index 2f4e418..892222c 100644
--- a/src/styles/hustler/hustler-detail.css
+++ b/src/styles/hustler/hustler-detail.css
@@ -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))) {
diff --git a/src/styles/hustler/hustler-ui.css b/src/styles/hustler/hustler-ui.css
index 00c7ca4..a3354e2 100644
--- a/src/styles/hustler/hustler-ui.css
+++ b/src/styles/hustler/hustler-ui.css
@@ -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; }
}
diff --git a/src/styles/hustler/hustler-welt.css b/src/styles/hustler/hustler-welt.css
index 7cdeabb..f5fca89 100644
--- a/src/styles/hustler/hustler-welt.css
+++ b/src/styles/hustler/hustler-welt.css
@@ -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; }
diff --git a/tests/unit/hustlerDialog.test.mjs b/tests/unit/hustlerDialog.test.mjs
index 4f5cd21..38ea1d1 100644
--- a/tests/unit/hustlerDialog.test.mjs
+++ b/tests/unit/hustlerDialog.test.mjs
@@ -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
diff --git a/tests/unit/hustlerHochhausWelt.test.mjs b/tests/unit/hustlerHochhausWelt.test.mjs
index 1e6c7bb..8b8ddc1 100644
--- a/tests/unit/hustlerHochhausWelt.test.mjs
+++ b/tests/unit/hustlerHochhausWelt.test.mjs
@@ -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}`)))
diff --git a/tests/unit/hustlerSeite.test.mjs b/tests/unit/hustlerSeite.test.mjs
index 257521d..9ed693c 100644
--- a/tests/unit/hustlerSeite.test.mjs
+++ b/tests/unit/hustlerSeite.test.mjs
@@ -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() }
+})
diff --git a/tests/unit/hustlerSicht.test.mjs b/tests/unit/hustlerSicht.test.mjs
index 0b53528..dbe46b1 100644
--- a/tests/unit/hustlerSicht.test.mjs
+++ b/tests/unit/hustlerSicht.test.mjs
@@ -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
diff --git a/tests/unit/workersRoute.test.mjs b/tests/unit/workersRoute.test.mjs
index 2c09aff..e9b54e0 100644
--- a/tests/unit/workersRoute.test.mjs
+++ b/tests/unit/workersRoute.test.mjs
@@ -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, /]*regieRef=\{regieRef\}/)
assert.match(s, /feed=\{\{ state, error, loaded, refresh \}\}/); assert.match(s, /]*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, /]*\bbot=\{bot\}/); assert.match(s, /]*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)', () => {