Hustler-Pixelwelt T6: Kopfzeile mit Münzen und Icons, Meldungen, Pause mit Pixel-Rückfrage, Einstellungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3027f03dbe
commit
6f4350495f
3 files changed
+1110
No files matched your search
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { shouldCloseOnKey } from '../../../lib/dialogKeys'
|
||||
import { hudWerte } from '../../../lib/hustlerSicht'
|
||||
import { pauseLabel } from '../../../lib/workersView'
|
||||
import WorkersSettingsDialog from '../WorkersSettingsDialog'
|
||||
|
||||
const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.'
|
||||
const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.'
|
||||
const NO_REASON_TEXT = 'Grund nicht angegeben'
|
||||
|
||||
// Grund der Notbremse für den Satz „Versand gestoppt: <Grund>. …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“)
|
||||
function haltReasonOf(reason) {
|
||||
const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : ''
|
||||
return text || NO_REASON_TEXT
|
||||
}
|
||||
|
||||
// Rückfrage „Alles anhalten?“ als Pixel-Dialog (Panel-Stil der Zeichenfläche). Fokus beim Öffnen auf „Abbrechen“ (die harmlose Wahl),
|
||||
// Tab bleibt im Dialog, Esc schließt, beim Schließen geht der Fokus zurück: auf das Element, das ihn vorher hatte, sonst auf den
|
||||
// Pause-Knopf (fallbackRef; Safari fokussiert Knöpfe beim Klicken nicht). Ein Klick auf den Hintergrund tut nichts und nimmt dem Dialog
|
||||
// den Fokus nicht.
|
||||
function Rueckfrage({ onCancel, onConfirm, fallbackRef }) {
|
||||
const boxRef = useRef(null)
|
||||
const cancelRef = useRef(null)
|
||||
const confirmRef = useRef(null)
|
||||
const openerRef = useRef(undefined)
|
||||
if (openerRef.current === undefined) openerRef.current = document.activeElement
|
||||
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus()
|
||||
return () => {
|
||||
const opener = openerRef.current
|
||||
const usable = opener && opener !== document.body && opener.isConnected !== false && typeof opener.focus === 'function'
|
||||
const target = usable ? opener : fallbackRef.current
|
||||
if (target && target.isConnected !== false && typeof target.focus === 'function') target.focus()
|
||||
}
|
||||
}, [fallbackRef])
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (shouldCloseOnKey(e, false)) {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const items = [cancelRef.current, confirmRef.current].filter(Boolean)
|
||||
if (items.length === 0) return
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
const inside = items.includes(active)
|
||||
if (e.shiftKey && (active === first || active === boxRef.current || !inside)) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && (active === last || !inside)) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hk-rueckfrage-hintergrund"
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) e.preventDefault() }}
|
||||
>
|
||||
<section className="hk-panel hk-rueckfrage" role="dialog" aria-modal="true" aria-labelledby="hk-rueckfrage-titel" tabIndex={-1} ref={boxRef}>
|
||||
<h2 className="hk-rueckfrage-titel" id="hk-rueckfrage-titel">Alles anhalten?</h2>
|
||||
<p className="hk-rueckfrage-text">Die Worker hören sofort auf, bis jemand fortsetzt.</p>
|
||||
<div className="hk-rueckfrage-knoepfe">
|
||||
<button type="button" className="hk-knopf" ref={cancelRef} onClick={() => onCancel()}>Abbrechen</button>
|
||||
<button type="button" className="hk-knopf" ref={confirmRef} onClick={() => onConfirm()}>Anhalten</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Kopfzeile des Hochhauses (Spec E7): Münzen (Einnahmen, API-Kosten), fünf Kennzahlen mit Icon, rechts „Ganzes Haus zeigen“ (nur Tastatur),
|
||||
// Pause/Fortsetzen und das Zahnrad (nur Admins), darunter die Meldungen als Pixel-Banner. Die Pause-Logik ist die des früheren WorkersBoard:
|
||||
// der Dienst bekommt den Zielwert (true = anhalten, nie aus dem Zustand abgeleitet), danach wird der Zustand sofort neu geholt; nur das
|
||||
// Anhalten fragt vorher nach. Ohne Auth-Bezug: isAdmin kommt von der Seite. state ist der Dienst-Zustand (bereits geladen), error der
|
||||
// Verbindungsfehler des Feeds (leer, solange alles gut geht), refresh holt den Zustand neu, onGanz zeigt das ganze Haus.
|
||||
export default function HochhausKopf({ api, isAdmin, state, error, refresh, onGanz }) {
|
||||
const aliveRef = useRef(true)
|
||||
const pauseRef = useRef(null)
|
||||
const refocusPauseRef = useRef(false) // nach dem Umschalten bekommt der Pause-Knopf den Fokus zurück, falls er ihn verlor
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [pausing, setPausing] = useState(false)
|
||||
const [pauseError, setPauseError] = useState('')
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => { aliveRef.current = false }
|
||||
}, [])
|
||||
|
||||
// Fokus nach dem Umschalten: Während pausing gilt, ist der Knopf gesperrt. Schließt die Rückfrage im selben Takt, in dem pausing beginnt,
|
||||
// kann sie den Fokus nicht auf den gesperrten Knopf zurückgeben, und er bliebe für immer auf <body>. Deshalb wird er erst hier gesetzt,
|
||||
// wenn pausing wieder endet (auch nach einem Fehler) und der Knopf wieder bedienbar ist. Hat der Mensch inzwischen woanders hin gewechselt
|
||||
// (Fokus nicht auf <body>), bleibt es dabei.
|
||||
useEffect(() => {
|
||||
if (pausing || !refocusPauseRef.current) return
|
||||
refocusPauseRef.current = false
|
||||
const active = document.activeElement
|
||||
if (active && active !== document.body) return
|
||||
pauseRef.current?.focus()
|
||||
}, [pausing])
|
||||
|
||||
// Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen
|
||||
const haltedByState = Boolean(state?.paused)
|
||||
useEffect(() => {
|
||||
if (haltedByState) setConfirmOpen(false)
|
||||
}, [haltedByState])
|
||||
|
||||
const paused = haltedByState
|
||||
const hud = hudWerte(state?.kpis)
|
||||
|
||||
// Pause für alle Mitarbeiter: Zielwert übergeben, danach den Zustand sofort neu holen. Ein offener Dialog darf nie fortsetzen, falls
|
||||
// inzwischen jemand anderes angehalten hat (darum der feste Zielwert).
|
||||
const setPause = async (target) => {
|
||||
if (pausing) return
|
||||
refocusPauseRef.current = true
|
||||
setPausing(true)
|
||||
setPauseError('')
|
||||
try {
|
||||
await api.pause(target)
|
||||
refresh()
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT)
|
||||
}
|
||||
if (aliveRef.current) setPausing(false)
|
||||
}
|
||||
|
||||
// Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage
|
||||
const requestPause = () => {
|
||||
if (paused) setPause(false)
|
||||
else setConfirmOpen(true)
|
||||
}
|
||||
const confirmHalt = () => {
|
||||
setConfirmOpen(false)
|
||||
setPause(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hk-kopf">
|
||||
<div className="hk-kopf-zeile">
|
||||
<section className="hk-panel hk-kopf-geld" aria-label="Geld">
|
||||
<div className="hk-kopf-wert" title={hud.einnahmen.titel}>
|
||||
<span className="hk-ic hk-muenze-gruen" aria-hidden="true" />
|
||||
<span className="hk-kopf-betrag hk-kopf-gruen">
|
||||
<span className="hk-kopf-sr">{hud.einnahmen.sr}</span>{hud.einnahmen.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hk-kopf-wert" title={hud.api.titel}>
|
||||
<span className="hk-ic hk-muenze-rot hk-kopf-v-rot" aria-hidden="true" />
|
||||
<span className={`hk-kopf-betrag hk-kopf-rot${hud.api.stufe ? ` hk-api-${hud.api.stufe}` : ''}`}>
|
||||
<span className="hk-kopf-sr">{hud.api.sr}</span>{hud.api.text}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hk-panel hk-kopf-zahlen" aria-label="Kennzahlen">
|
||||
{hud.zahlen.map((z) => (
|
||||
<div className="hk-kopf-zahl" key={z.key} title={z.titel} style={{ color: z.farbe }}>
|
||||
<span className={`${z.icon} hk-kopf-v-${z.key}`} aria-hidden="true" />
|
||||
<span className="hk-kopf-sr">{z.titel}:</span>
|
||||
<span>{z.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<span className="hk-kopf-abstand" />
|
||||
|
||||
<div className="hk-panel hk-kopf-rechts">
|
||||
<button type="button" className="hk-nur-tastatur" onClick={() => onGanz?.()}>Ganzes Haus zeigen</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hk-hud-knopf"
|
||||
aria-label={pauseLabel(paused)}
|
||||
title={pauseLabel(paused)}
|
||||
disabled={pausing}
|
||||
onClick={requestPause}
|
||||
ref={pauseRef}
|
||||
data-hk-pause
|
||||
>
|
||||
<span className={paused ? 'hk-ic hk-pfeil-rechts' : 'hk-ic hk-pause'} aria-hidden="true" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" className="hk-hud-knopf" aria-label="Einstellungen" title="Einstellungen" onClick={() => setSettingsOpen(true)}>
|
||||
<span className="hk-ic hk-zahnrad hk-kopf-v-zahnrad" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hk-meldungen">
|
||||
{paused && <div className="hk-meldung hk-meldung-angehalten" role="status">{PAUSED_TEXT}</div>}
|
||||
{pauseError !== '' && <div className="hk-meldung hk-meldung-umschalten" role="alert">{pauseError}</div>}
|
||||
{state?.sendingHalted === true && (
|
||||
<div className="hk-meldung hk-meldung-notbremse" role="alert">
|
||||
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
|
||||
</div>
|
||||
)}
|
||||
{Boolean(error) && <div className="hk-meldung hk-meldung-verbindung" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && settingsOpen && (
|
||||
<div className="hk-woms">
|
||||
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmOpen && !paused && (
|
||||
<Rueckfrage onCancel={() => setConfirmOpen(false)} onConfirm={confirmHalt} fallbackRef={pauseRef} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* Kopfzeile des Hochhauses (HochhausKopf.jsx): Lage, Münzen und Kennzahlen, Pause/Zahnrad, Pixel-Banner für Meldungen, Rückfrage „Alles anhalten?“.
|
||||
Handgeschrieben (nicht erzeugt). Die Inline-Stile der Zeichenfläche (Main.dc.html, erster Block) stehen hier als Klassen; Panel, Knopf, Icons und
|
||||
hk-nur-tastatur kommen aus hochhaus.css. Alle Klassen beginnen mit hk- und stehen unter .hk-hochhaus; Keyframes heißen hk-kopf-… .
|
||||
Wird wie hustler-ui.css in WorkersPage.jsx eingehängt (Komponenten importieren kein CSS). */
|
||||
|
||||
/* ---------- Lage: oben über der Welt, Abstand 12/10 px; nur die Panels fangen Zeigerereignisse ab (die Welt darunter bleibt bedienbar) ---------- */
|
||||
.hk-hochhaus .hk-kopf {
|
||||
position: absolute; left: 12px; top: 10px; right: 12px; z-index: 5;
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 10px;
|
||||
pointer-events: none;
|
||||
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; color: #e6ebff;
|
||||
}
|
||||
.hk-hochhaus .hk-kopf-zeile { align-self: stretch; display: flex; align-items: flex-start; flex-wrap: wrap; gap: 10px; pointer-events: none; }
|
||||
.hk-hochhaus .hk-kopf-abstand { flex: 1; min-width: 0; }
|
||||
|
||||
/* ---------- Panel „Geld“ ---------- */
|
||||
.hk-hochhaus .hk-kopf-geld { display: flex; align-items: center; gap: 20px; padding: 0 6px 0 2px; pointer-events: auto; }
|
||||
.hk-hochhaus .hk-kopf-wert { display: flex; align-items: center; gap: 8px; }
|
||||
.hk-hochhaus .hk-kopf-betrag { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 15px; line-height: 20px; }
|
||||
.hk-hochhaus .hk-kopf-gruen { color: #4ade5a; }
|
||||
.hk-hochhaus .hk-kopf-rot { color: #ff6b6b; }
|
||||
|
||||
/* Budget fast (80 %) oder ganz verbraucht: der rote Betrag blinkt bzw. leuchtet kräftiger */
|
||||
.hk-hochhaus .hk-api-warn { animation: hk-kopf-blinken 1s steps(1, end) infinite; }
|
||||
.hk-hochhaus .hk-api-neg { color: #ff2d2d; text-shadow: 0 0 0 #ff2d2d, 1px 0 0 #ff2d2d; }
|
||||
@keyframes hk-kopf-blinken { 0% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
/* ---------- Panel „Kennzahlen“ ---------- */
|
||||
.hk-hochhaus .hk-kopf-zahlen {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; max-width: 100%; padding: 0 6px 0 2px; pointer-events: auto;
|
||||
font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 13px; line-height: 18px;
|
||||
}
|
||||
.hk-hochhaus .hk-kopf-zahl { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* Die Icons laufen gegeneinander versetzt (wie in der Zeichenfläche), sonst blinzeln alle im selben Takt */
|
||||
.hk-hochhaus .hk-kopf-v-rot { animation-delay: -1.3s; }
|
||||
.hk-hochhaus .hk-kopf-v-abos { animation-delay: -0.4s; }
|
||||
.hk-hochhaus .hk-kopf-v-deals { animation-delay: -1.1s; }
|
||||
.hk-hochhaus .hk-kopf-v-threads { animation-delay: -1.8s; }
|
||||
.hk-hochhaus .hk-kopf-v-pult { animation-delay: -2.5s; }
|
||||
.hk-hochhaus .hk-kopf-v-botleads { animation-delay: -3.2s; }
|
||||
.hk-hochhaus .hk-kopf-v-zahnrad { animation-delay: -0.9s; }
|
||||
|
||||
/* Nur für Screenreader (Beschriftung vor der Zahl) */
|
||||
.hk-hochhaus .hk-kopf-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* ---------- Panel rechts: Ganzes Haus (nur Tastatur), Pause/Fortsetzen, Zahnrad ---------- */
|
||||
.hk-hochhaus .hk-kopf-rechts { display: flex; align-items: center; gap: 2px; padding: 0; pointer-events: auto; }
|
||||
.hk-hochhaus .hk-kopf .hk-nur-tastatur { font: inherit; }
|
||||
.hk-hochhaus .hk-hud-knopf {
|
||||
display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; margin: -6px 0; padding: 0;
|
||||
border: 0; background: none; cursor: pointer;
|
||||
}
|
||||
.hk-hochhaus .hk-hud-knopf:disabled { opacity: 0.5; cursor: progress; }
|
||||
|
||||
/* ---------- Meldungen: Pixel-Banner unter der Kopfzeile, links, in der Breite begrenzt, mehrere untereinander ---------- */
|
||||
.hk-hochhaus .hk-meldungen { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; max-width: min(560px, 100%); pointer-events: none; }
|
||||
.hk-hochhaus .hk-meldungen:empty { display: none; }
|
||||
.hk-hochhaus .hk-meldung {
|
||||
--hk-ton: #5b6bc0;
|
||||
position: relative; box-sizing: border-box; margin: 0; padding: 8px 12px 8px 44px;
|
||||
border: 4px solid var(--hk-ton); background: #0e1430; color: #e6ebff; overflow-wrap: anywhere;
|
||||
/* abgetreppte Ecken statt runder: Pixel-Optik ohne Bild */
|
||||
clip-path: polygon(0 4px, 4px 4px, 4px 0, calc(100% - 4px) 0, calc(100% - 4px) 4px, 100% 4px, 100% calc(100% - 4px), calc(100% - 4px) calc(100% - 4px), calc(100% - 4px) 100%, 4px 100%, 4px calc(100% - 4px), 0 calc(100% - 4px));
|
||||
}
|
||||
.hk-hochhaus .hk-meldung::before {
|
||||
content: '!'; position: absolute; left: 10px; top: 50%; width: 22px; height: 22px; margin-top: -11px;
|
||||
background: var(--hk-ton); color: #1a1630; text-align: center;
|
||||
font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 12px; line-height: 22px;
|
||||
}
|
||||
.hk-hochhaus .hk-meldung-angehalten { --hk-ton: #ffd25a; }
|
||||
.hk-hochhaus .hk-meldung-angehalten::before { content: 'II'; font-size: 10px; }
|
||||
.hk-hochhaus .hk-meldung-umschalten,
|
||||
.hk-hochhaus .hk-meldung-notbremse { --hk-ton: #e2483d; }
|
||||
.hk-hochhaus .hk-meldung-umschalten::before,
|
||||
.hk-hochhaus .hk-meldung-notbremse::before { color: #ffffff; }
|
||||
.hk-hochhaus .hk-meldung-verbindung { --hk-ton: #ff9f43; }
|
||||
|
||||
/* ---------- Rückfrage „Alles anhalten?“: Pixel-Dialog über der ganzen Welt ---------- */
|
||||
.hk-hochhaus .hk-rueckfrage-hintergrund {
|
||||
position: absolute; inset: 0; z-index: 30; box-sizing: border-box;
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
background: rgba(5, 8, 26, 0.72); color: #e6ebff;
|
||||
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px;
|
||||
}
|
||||
.hk-hochhaus .hk-rueckfrage { width: 100%; max-width: 420px; padding: 4px 8px 8px; outline: none; }
|
||||
.hk-hochhaus .hk-rueckfrage-titel { margin: 0 0 12px; color: #ffe9a0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 15px; line-height: 22px; font-weight: 400; }
|
||||
.hk-hochhaus .hk-rueckfrage-text { margin: 0; color: #b8c2f0; }
|
||||
.hk-hochhaus .hk-rueckfrage-knoepfe { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
|
||||
/* ---------- Handy: die Kopfzeile bricht um, Schrift etwas kleiner ---------- */
|
||||
@media (max-width: 719px) {
|
||||
.hk-hochhaus .hk-kopf { left: 8px; right: 8px; top: 8px; gap: 8px; font-size: 14px; line-height: 18px; }
|
||||
.hk-hochhaus .hk-kopf-zeile { gap: 6px 8px; }
|
||||
.hk-hochhaus .hk-kopf-abstand { display: none; }
|
||||
.hk-hochhaus .hk-kopf-geld { order: 1; gap: 12px; }
|
||||
.hk-hochhaus .hk-kopf-betrag { font-size: 12px; line-height: 16px; }
|
||||
.hk-hochhaus .hk-kopf-rechts { order: 2; margin-left: auto; }
|
||||
.hk-hochhaus .hk-kopf-zahlen { order: 3; flex: 1 1 100%; gap: 6px 10px; font-size: 11px; line-height: 16px; }
|
||||
.hk-hochhaus .hk-meldung { padding: 6px 10px 6px 40px; }
|
||||
.hk-hochhaus .hk-rueckfrage-hintergrund { padding: 12px; }
|
||||
.hk-hochhaus .hk-rueckfrage-titel { font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ---------- Weniger Bewegung: kein Blinken (der Betrag bleibt auch ohne Blinken erkennbar) ---------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hk-hochhaus .hk-api-warn { animation: none; color: #ffb86b; }
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import esbuild from 'esbuild'
|
||||
import { fakeReact } from './helpers/fakeReact.mjs'
|
||||
import { makeState } from './helpers/workersFixtures.mjs'
|
||||
|
||||
// Die Kopfzeile des Hochhauses (T6) läuft wirklich: im Mini-Läufer anstelle von React (Muster hustlerSeite.test.mjs, Baustein helpers/fakeReact.mjs).
|
||||
// WorkersSettingsDialog ist per esbuild-Plug-in durch eine Attrappe ersetzt (sie legt ihre Props unter globalThis.__kopf ab). Elemente mit ref
|
||||
// bekommen kleine Attrappen (focus() setzt document.activeElement, gesperrte Knöpfe nehmen den Fokus nicht), document ist eine Attrappe.
|
||||
// Geprüft werden die fachlichen Zusicherungen des früheren WorkersBoard (Pause mit Zielwert, Rückfrage nur beim Anhalten, Notbremse, Verbindungshinweis,
|
||||
// Einstellungen nur für Admins) und die Ausgabe der Kopfzeile (Werte, Striche, Budget-Stufen).
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||||
const NBSP = String.fromCharCode(160)
|
||||
|
||||
const nodes = (node, match, out = []) => {
|
||||
if (node === null || typeof node === 'string') return out
|
||||
if (match(node)) out.push(node)
|
||||
for (const child of node.children) nodes(child, match, out)
|
||||
return out
|
||||
}
|
||||
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
|
||||
const classes = (n) => String(n.props.className ?? '').split(' ').filter(Boolean)
|
||||
const hasClass = (n, name) => classes(n).includes(name)
|
||||
const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name))
|
||||
const byRole = (tree, role) => nodes(tree, (n) => typeof n.tag === 'string' && n.props.role === role)
|
||||
const buttons = (tree) => nodes(tree, (n) => n.tag === 'button')
|
||||
const buttonByText = (tree, text) => buttons(tree).find((b) => textOf(b) === text)
|
||||
const pauseButton = (tree) => buttons(tree).find((b) => b.props['data-hk-pause'] !== undefined)
|
||||
const settingsButton = (tree) => buttons(tree).find((b) => b.props['aria-label'] === 'Einstellungen')
|
||||
const hausButton = (tree) => buttons(tree).find((b) => textOf(b) === 'Ganzes Haus zeigen')
|
||||
const event = (extra = {}) => ({ defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, ...extra })
|
||||
// Klick wie im Browser: gesperrte Knöpfe lösen nichts aus
|
||||
const press = (node) => {
|
||||
if (node.props.disabled) return false
|
||||
node.props.onClick(event())
|
||||
return true
|
||||
}
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
// ---------- Bündel mit Attrappe für den Einstellungen-Dialog ----------
|
||||
|
||||
const ATTRAPPE = "import { jsx } from 'react'\nexport default function WorkersSettingsDialog(props) { globalThis.__kopf.settings.push(props); return jsx('div', { 'data-attrappe': 'einstellungen' }) }"
|
||||
const attrappe = {
|
||||
name: 'einstellungen-attrappe',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^\.\.\/WorkersSettingsDialog$/ }, () => ({ path: 'WorkersSettingsDialog', namespace: 'kopf-attrappe' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'kopf-attrappe' }, () => ({ contents: ATTRAPPE, loader: 'js' }))
|
||||
},
|
||||
}
|
||||
|
||||
let bundle
|
||||
const loadKopf = () => (bundle ??= esbuild.build({
|
||||
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as HochhausKopf } from './src/components/workers/hochhaus/HochhausKopf.jsx'", resolveDir: ROOT, loader: 'js' },
|
||||
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, attrappe],
|
||||
}).then(importBundle))
|
||||
|
||||
// Ein Lauf der Kopfzeile. props werden zwischendurch geändert (set) und neu gerendert. log hält die Aufrufe in ihrer Reihenfolge.
|
||||
async function kopf(overrides = {}) {
|
||||
const { __mount, jsx, HochhausKopf } = await loadKopf()
|
||||
const log = []
|
||||
const kb = { settings: [] }
|
||||
const body = { tag: 'body', isConnected: true, focus() { globalThis.document.activeElement = body } }
|
||||
const saved = { document: globalThis.document, hadDocument: Object.hasOwn(globalThis, 'document'), kopf: globalThis.__kopf, fakeRef: globalThis.__fakeRef }
|
||||
globalThis.document = { activeElement: body, body }
|
||||
globalThis.__kopf = kb
|
||||
// pro ref-Objekt bleibt dasselbe Element erhalten (wie im Browser), disabled folgt den Props des letzten Renders
|
||||
const elements = new WeakMap()
|
||||
globalThis.__fakeRef = (type, props) => {
|
||||
let el = elements.get(props.ref)
|
||||
if (!el) {
|
||||
el = { tag: type, isConnected: true, disabled: false, focus() { if (!this.disabled) globalThis.document.activeElement = this } }
|
||||
elements.set(props.ref, el)
|
||||
}
|
||||
el.disabled = Boolean(props.disabled)
|
||||
return el
|
||||
}
|
||||
const holder = {
|
||||
props: {
|
||||
api: { pause: async (target) => { log.push(`pause:${target}`) } },
|
||||
isAdmin: false,
|
||||
state: makeState(),
|
||||
error: null,
|
||||
refresh: () => { log.push('refresh') },
|
||||
onGanz: () => { log.push('ganz') },
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
const mounted = __mount(jsx(() => jsx(HochhausKopf, holder.props), {}))
|
||||
await settle()
|
||||
return {
|
||||
log, kb, holder,
|
||||
document: () => globalThis.document,
|
||||
tree: () => mounted.tree(),
|
||||
set(next) { holder.props = { ...holder.props, ...next }; mounted.rerender() },
|
||||
late: () => mounted.lateUpdates(),
|
||||
unmount: () => mounted.unmount(),
|
||||
end() {
|
||||
mounted.unmount()
|
||||
if (saved.hadDocument) globalThis.document = saved.document; else delete globalThis.document
|
||||
if (saved.kopf === undefined) delete globalThis.__kopf; else globalThis.__kopf = saved.kopf
|
||||
if (saved.fakeRef === undefined) delete globalThis.__fakeRef; else globalThis.__fakeRef = saved.fakeRef
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const VOLLE_KPIS = { mrrNewEur: 60, apiTodayUsd: 5.1, dailyBudgetUsd: 6, newAbosMonth: 3, dealsMonth: 3, openThreads: 7, pultOpen: 5, botLeads: 112 }
|
||||
|
||||
// Wert-Knoten eines Kopfzeilen-Eintrags: [Screenreader-Text, sichtbarer Text]
|
||||
const wert = (knoten) => {
|
||||
const sr = byClass(knoten, 'hk-kopf-sr')[0]
|
||||
return { sr: textOf(sr), text: knoten.children.filter((c) => typeof c === 'string').join('') }
|
||||
}
|
||||
|
||||
// ---------- Werte ----------
|
||||
|
||||
test('Kopfzeile: Panel „Geld“ mit grüner und roter Münze, Beträgen, Screenreader-Texten und Titeln', async () => {
|
||||
const ui = await kopf({ state: makeState({ kpis: VOLLE_KPIS }) })
|
||||
try {
|
||||
const geld = nodes(ui.tree(), (n) => n.tag === 'section' && n.props['aria-label'] === 'Geld')
|
||||
assert.equal(geld.length, 1); assert.ok(hasClass(geld[0], 'hk-panel'))
|
||||
const werte = byClass(geld[0], 'hk-kopf-wert')
|
||||
assert.equal(werte.length, 2)
|
||||
assert.equal(werte[0].props.title, 'Eingenommen pro Monat')
|
||||
assert.equal(werte[1].props.title, `API-Kosten heute: 5,10${NBSP}$ von 6,00${NBSP}$`)
|
||||
// Münzen
|
||||
assert.deepEqual(classes(byClass(werte[0], 'hk-ic')[0]).slice(0, 2), ['hk-ic', 'hk-muenze-gruen'])
|
||||
assert.deepEqual(classes(byClass(werte[1], 'hk-ic')[0]).slice(0, 2), ['hk-ic', 'hk-muenze-rot'])
|
||||
for (const ic of byClass(geld[0], 'hk-ic')) assert.equal(ic.props['aria-hidden'], 'true')
|
||||
// Beträge: Pixelschrift-Betrag mit ASCII-Minus, davor der Text nur für Screenreader
|
||||
const gruen = wert(byClass(werte[0], 'hk-kopf-betrag')[0])
|
||||
const rot = wert(byClass(werte[1], 'hk-kopf-betrag')[0])
|
||||
assert.deepEqual(gruen, { sr: 'Eingenommen:', text: `+60${NBSP}€/m` })
|
||||
assert.deepEqual(rot, { sr: 'API-Kosten heute:', text: `-5,10${NBSP}$` })
|
||||
assert.ok(hasClass(byClass(werte[0], 'hk-kopf-betrag')[0], 'hk-kopf-gruen')); assert.ok(hasClass(byClass(werte[1], 'hk-kopf-betrag')[0], 'hk-kopf-rot'))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Kopfzeile: Panel „Kennzahlen“ mit fünf Icons in Farbe, Titel, Screenreader-Text „Titel:“ und Zahl', async () => {
|
||||
const ui = await kopf({ state: makeState({ kpis: VOLLE_KPIS }) })
|
||||
try {
|
||||
const panel = nodes(ui.tree(), (n) => n.tag === 'section' && n.props['aria-label'] === 'Kennzahlen')
|
||||
assert.equal(panel.length, 1); assert.ok(hasClass(panel[0], 'hk-panel'))
|
||||
const zahlen = byClass(panel[0], 'hk-kopf-zahl')
|
||||
const erwartet = [
|
||||
['hk-stern', 'Neue Abos (Monat)', '3', '#ffd25a'],
|
||||
['hk-pokal', 'Deals (Monat)', '3', '#ff9f43'],
|
||||
['hk-blase', 'Gespräche offen', '7', '#4fd1e8'],
|
||||
['hk-glocke', 'Wartet auf dich', '5', '#ff6fae'],
|
||||
['hk-robo', 'Bot-Leads gesamt', '112', '#a78bfa'],
|
||||
]
|
||||
assert.equal(zahlen.length, 5)
|
||||
zahlen.forEach((z, i) => {
|
||||
const [icon, titel, text, farbe] = erwartet[i]
|
||||
assert.equal(z.props.title, titel); assert.equal(z.props.style.color, farbe)
|
||||
const ic = nodes(z, (n) => n.tag === 'span' && hasClass(n, 'hk-ic'))[0]
|
||||
assert.deepEqual(classes(ic).slice(0, 2), ['hk-ic', icon]); assert.equal(ic.props['aria-hidden'], 'true')
|
||||
assert.equal(textOf(byClass(z, 'hk-kopf-sr')[0]), `${titel}:`)
|
||||
assert.equal(textOf(z), `${titel}:${text}`)
|
||||
})
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Kopfzeile: fehlende, leere und kaputte Werte ergeben Striche, nie NaN oder undefined', async () => {
|
||||
const faelle = [
|
||||
{ kpis: {} },
|
||||
{ kpis: undefined },
|
||||
{ kpis: null },
|
||||
{ kpis: { mrrNewEur: 'viel', apiTodayUsd: NaN, dailyBudgetUsd: 'x', newAbosMonth: null, dealsMonth: Infinity, openThreads: undefined, pultOpen: '5', botLeads: {} } },
|
||||
{ kpis: { mrrNewEur: -1, apiTodayUsd: -2 } },
|
||||
]
|
||||
for (const { kpis } of faelle) {
|
||||
const state = { ...makeState(), kpis }
|
||||
const ui = await kopf({ state })
|
||||
try {
|
||||
const text = textOf(ui.tree())
|
||||
assert.ok(!/NaN|undefined|null|Infinity/.test(text), JSON.stringify(kpis))
|
||||
const betraege = byClass(ui.tree(), 'hk-kopf-betrag').map((b) => wert(b).text)
|
||||
assert.deepEqual(betraege, ['–', '–'], JSON.stringify(kpis))
|
||||
const zahlen = byClass(ui.tree(), 'hk-kopf-zahl').map((z) => textOf(z.children[2]))
|
||||
assert.deepEqual(zahlen, ['–', '–', '–', '–', '–'], JSON.stringify(kpis))
|
||||
// ohne Betrag steht auch im Titel der Strich, nie ein erfundener Wert
|
||||
assert.ok(byClass(ui.tree(), 'hk-kopf-wert')[1].props.title.includes('–'))
|
||||
} finally { ui.end() }
|
||||
}
|
||||
})
|
||||
|
||||
test('Kopfzeile: Null ist ein Wert (0 Deals, 0,00 $), kein Strich; große Zahlen mit Tausenderpunkt', async () => {
|
||||
const ui = await kopf({ state: makeState({ kpis: { mrrNewEur: 0, apiTodayUsd: 0, dailyBudgetUsd: 6, newAbosMonth: 0, dealsMonth: 0, openThreads: 1234, pultOpen: 0, botLeads: 20000 } }) })
|
||||
try {
|
||||
const betraege = byClass(ui.tree(), 'hk-kopf-betrag').map((b) => wert(b).text)
|
||||
assert.deepEqual(betraege, [`+0${NBSP}€/m`, `0,00${NBSP}$`])
|
||||
assert.deepEqual(byClass(ui.tree(), 'hk-kopf-zahl').map((z) => textOf(z.children[2])), ['0', '0', '1.234', '0', '20.000'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Kopfzeile: Budget-Stufen am roten Betrag – unter 80 % nichts, ab 80 % hk-api-warn, ab 100 % hk-api-neg; ohne Budget nichts', async () => {
|
||||
const stufe = async (kpis) => {
|
||||
const ui = await kopf({ state: makeState({ kpis }) })
|
||||
try {
|
||||
const rot = byClass(ui.tree(), 'hk-kopf-betrag')[1]
|
||||
return { warn: hasClass(rot, 'hk-api-warn'), neg: hasClass(rot, 'hk-api-neg'), gruenHat: classes(byClass(ui.tree(), 'hk-kopf-betrag')[0]).some((k) => k.startsWith('hk-api-')) }
|
||||
} finally { ui.end() }
|
||||
}
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 2, dailyBudgetUsd: 6 }), { warn: false, neg: false, gruenHat: false })
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 4.79, dailyBudgetUsd: 6 }), { warn: false, neg: false, gruenHat: false })
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 4.8, dailyBudgetUsd: 6 }), { warn: true, neg: false, gruenHat: false })
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 5.1, dailyBudgetUsd: 6 }), { warn: true, neg: false, gruenHat: false })
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 6, dailyBudgetUsd: 6 }), { warn: false, neg: true, gruenHat: false })
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: 6 }), { warn: false, neg: true, gruenHat: false })
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: undefined }), { warn: false, neg: false, gruenHat: false })
|
||||
assert.deepEqual(await stufe({ apiTodayUsd: 9, dailyBudgetUsd: 0 }), { warn: false, neg: false, gruenHat: false })
|
||||
})
|
||||
|
||||
// ---------- Pause ----------
|
||||
|
||||
test('Pause-Knopf: läuft -> „Alles anhalten“ mit Pause-Icon; angehalten -> „Fortsetzen“ mit Pfeil; type=button, data-hk-pause', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
let b = pauseButton(ui.tree())
|
||||
assert.equal(b.props.type, 'button'); assert.equal(b.props['aria-label'], 'Alles anhalten'); assert.equal(b.props.title, 'Alles anhalten')
|
||||
assert.ok(hasClass(b, 'hk-hud-knopf')); assert.equal(b.props.disabled, false)
|
||||
assert.deepEqual(classes(b.children[0]), ['hk-ic', 'hk-pause']); assert.equal(b.children[0].props['aria-hidden'], 'true')
|
||||
ui.set({ state: makeState({ paused: true }) })
|
||||
b = pauseButton(ui.tree())
|
||||
assert.equal(b.props['aria-label'], 'Fortsetzen'); assert.equal(b.props.title, 'Fortsetzen')
|
||||
assert.deepEqual(classes(b.children[0]), ['hk-ic', 'hk-pfeil-rechts'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: läuft -> „Alles anhalten“ fragt erst nach; „Anhalten“ ruft api.pause(true) und danach refresh, die Rückfrage verschwindet', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
|
||||
press(pauseButton(ui.tree()))
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
|
||||
assert.deepEqual(ui.log, []) // noch nichts passiert
|
||||
press(buttonByText(ui.tree(), 'Anhalten'))
|
||||
await settle()
|
||||
assert.deepEqual(ui.log, ['pause:true', 'refresh'])
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
|
||||
assert.equal(pauseButton(ui.tree()).props.disabled, false) // wieder bedienbar
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: der Dienst bekommt genau einen Zielwert und sonst nichts', async () => {
|
||||
const aufrufe = []
|
||||
const ui = await kopf({ api: { pause: async (...args) => { aufrufe.push(args) } } })
|
||||
try {
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
|
||||
assert.deepEqual(aufrufe, [[true]])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: „Abbrechen“ ruft den Dienst nicht, schließt die Rückfrage und gibt den Fokus an den Pause-Knopf zurück', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
const pauseEl = pauseButton(ui.tree()).props.ref.current
|
||||
pauseEl.focus()
|
||||
press(pauseButton(ui.tree()))
|
||||
assert.notEqual(ui.document().activeElement, pauseEl) // der Fokus liegt im Dialog
|
||||
press(buttonByText(ui.tree(), 'Abbrechen'))
|
||||
await settle()
|
||||
assert.deepEqual(ui.log, [])
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
|
||||
assert.equal(ui.document().activeElement, pauseEl)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: hatte der Pause-Knopf keinen Fokus (Safari fokussiert Knöpfe beim Klick nicht), geht er beim Schließen trotzdem an ihn', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
const pauseEl = pauseButton(ui.tree()).props.ref.current
|
||||
assert.equal(ui.document().activeElement, ui.document().body)
|
||||
press(pauseButton(ui.tree()))
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
|
||||
press(buttonByText(ui.tree(), 'Abbrechen'))
|
||||
assert.equal(ui.document().activeElement, pauseEl)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: angehalten -> „Fortsetzen“ geht ohne Rückfrage sofort mit api.pause(false), danach refresh', async () => {
|
||||
const ui = await kopf({ state: makeState({ paused: true }) })
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 0) // keine Rückfrage
|
||||
await settle()
|
||||
assert.deepEqual(ui.log, ['pause:false', 'refresh'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: die Rückfrage verschwindet, wenn inzwischen jemand anderes angehalten hat, und taucht nach dem Fortsetzen nicht von selbst wieder auf', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
|
||||
ui.set({ state: makeState({ paused: true }) })
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
|
||||
ui.set({ state: makeState({ paused: false }) })
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
|
||||
assert.deepEqual(ui.log, [])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: ein veralteter Dialog setzt nie fort – der Zielwert steht fest (true), egal was der Zustand inzwischen sagt', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
const anhalten = buttonByText(ui.tree(), 'Anhalten')
|
||||
// der Dienst meldet erst „angehalten“, dann wieder „läuft“, während die Rückfrage noch als Knoten vorliegt
|
||||
ui.set({ state: makeState({ paused: true }) })
|
||||
ui.set({ state: makeState({ paused: false }) })
|
||||
anhalten.props.onClick(event())
|
||||
await settle()
|
||||
assert.deepEqual(ui.log.filter((l) => l.startsWith('pause')), ['pause:true'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: Fehler beim Umschalten zeigt die Fehlermeldung des Dienstes bzw. „Umschalten fehlgeschlagen.“, ohne refresh; der nächste Versuch löscht ihn', async () => {
|
||||
// Antworten der Reihe nach: Error mit Text, Error ohne Text, ein geworfener Text; ist die Liste leer, gelingt der Aufruf
|
||||
const antworten = [new Error('Dienst nicht erreichbar.'), new Error(''), 'text']
|
||||
const ui = await kopf({ api: { pause: async (t) => { ui.log.push(`pause:${t}`); if (antworten.length > 0) throw antworten.shift() } } })
|
||||
try {
|
||||
// 1. Fehler mit Meldung
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
|
||||
let alarm = byRole(ui.tree(), 'alert')
|
||||
assert.equal(alarm.length, 1); assert.equal(textOf(alarm[0]), 'Dienst nicht erreichbar.'); assert.ok(hasClass(alarm[0], 'hk-meldung'))
|
||||
assert.ok(!ui.log.includes('refresh'))
|
||||
assert.equal(pauseButton(ui.tree()).props.disabled, false) // nach dem Fehler wieder bedienbar
|
||||
// 2. Fehler ohne Meldung -> Standardtext (der alte verschwindet beim Start des neuen Versuchs)
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
|
||||
alarm = byRole(ui.tree(), 'alert')
|
||||
assert.equal(alarm.length, 1); assert.equal(textOf(alarm[0]), 'Umschalten fehlgeschlagen.')
|
||||
// 3. Fehler, der kein Error ist (Text)
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
|
||||
assert.equal(textOf(byRole(ui.tree(), 'alert')[0]), 'Umschalten fehlgeschlagen.')
|
||||
// 4. Erfolg löscht die Meldung
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
|
||||
assert.equal(byRole(ui.tree(), 'alert').length, 0)
|
||||
assert.deepEqual(ui.log.filter((l) => l === 'refresh').length, 1)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: Fehlertext des Dienstes bleibt ein Textknoten (kein HTML)', async () => {
|
||||
const ui = await kopf({ api: { pause: async () => { throw new Error('<img src=x onerror=alert(1)>') } } })
|
||||
try {
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten')); await settle()
|
||||
const alarm = byRole(ui.tree(), 'alert')[0]
|
||||
assert.deepEqual(alarm.children, ['<img src=x onerror=alert(1)>'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: der Knopf ist während des Umschaltens gesperrt, ein zweiter Klick tut nichts; danach wieder frei', async () => {
|
||||
const warten = deferred()
|
||||
const ui = await kopf({ api: { pause: (t) => { ui.log.push(`pause:${t}`); return warten.promise } } })
|
||||
try {
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten'))
|
||||
assert.equal(pauseButton(ui.tree()).props.disabled, true)
|
||||
assert.equal(press(pauseButton(ui.tree())), false)
|
||||
// auch ein Klick, der trotzdem ankäme, startet nichts Zweites
|
||||
pauseButton(ui.tree()).props.onClick(event())
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 1) // der Versuch öffnet höchstens die Frage …
|
||||
press(buttonByText(ui.tree(), 'Anhalten')) // … deren Bestätigung aber nicht zum zweiten Aufruf führt
|
||||
assert.deepEqual(ui.log, ['pause:true'])
|
||||
warten.resolve()
|
||||
await settle()
|
||||
assert.equal(pauseButton(ui.tree()).props.disabled, false)
|
||||
assert.deepEqual(ui.log, ['pause:true', 'refresh'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: nach dem Umschalten bekommt der Pause-Knopf den Fokus zurück, wenn er ihn verlor (Fokus auf body); wer woanders ist, wird nicht gestört', async () => {
|
||||
for (const woanders of [false, true]) {
|
||||
const warten = deferred()
|
||||
const ui = await kopf({ api: { pause: (t) => { ui.log.push(`pause:${t}`); return warten.promise } } })
|
||||
try {
|
||||
const pauseEl = pauseButton(ui.tree()).props.ref.current
|
||||
pauseEl.focus()
|
||||
press(pauseButton(ui.tree())); press(buttonByText(ui.tree(), 'Anhalten'))
|
||||
assert.equal(pauseEl.disabled, true)
|
||||
// der gesperrte Knopf verliert den Fokus (Browser), die Rückfrage ist zu
|
||||
const andere = { tag: 'input', isConnected: true, focus() { ui.document().activeElement = this } }
|
||||
if (woanders) andere.focus(); else ui.document().body.focus()
|
||||
warten.resolve(); await settle()
|
||||
assert.equal(pauseEl.disabled, false)
|
||||
assert.equal(ui.document().activeElement, woanders ? andere : pauseEl)
|
||||
} finally { ui.end() }
|
||||
}
|
||||
})
|
||||
|
||||
test('Pause: nach einem Fehler bekommt der Pause-Knopf den Fokus ebenfalls zurück', async () => {
|
||||
const warten = deferred()
|
||||
const ui = await kopf({ state: makeState({ paused: true }), api: { pause: () => warten.promise } })
|
||||
try {
|
||||
const pauseEl = pauseButton(ui.tree()).props.ref.current
|
||||
pauseEl.focus()
|
||||
press(pauseButton(ui.tree())) // Fortsetzen
|
||||
ui.document().body.focus()
|
||||
warten.reject(new Error('weg')); await settle()
|
||||
assert.equal(ui.document().activeElement, pauseEl)
|
||||
assert.equal(textOf(byRole(ui.tree(), 'alert')[0]), 'weg')
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Pause: kommt die Antwort erst nach dem Abbau der Kopfzeile, ändert sie keinen Zustand mehr', async () => {
|
||||
const warten = deferred()
|
||||
const ui = await kopf({ state: makeState({ paused: true }), api: { pause: () => warten.promise } })
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
ui.unmount()
|
||||
warten.reject(new Error('zu spät')); await settle()
|
||||
assert.equal(ui.late(), 0)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
// ---------- Rückfrage als Pixel-Dialog ----------
|
||||
|
||||
test('Rückfrage: Pixel-Dialog (hk-panel) mit role=dialog, aria-modal, beschrifteter Überschrift, Text und den Knöpfen „Abbrechen“ und „Anhalten“', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
const dialog = byRole(ui.tree(), 'dialog')
|
||||
assert.equal(dialog.length, 1)
|
||||
const d = dialog[0]
|
||||
assert.ok(hasClass(d, 'hk-panel')); assert.equal(d.props['aria-modal'], 'true'); assert.equal(d.props.tabIndex, -1)
|
||||
const titel = byClass(d, 'hk-rueckfrage-titel')[0]
|
||||
assert.equal(textOf(titel), 'Alles anhalten?'); assert.equal(d.props['aria-labelledby'], titel.props.id)
|
||||
assert.equal(textOf(byClass(d, 'hk-rueckfrage-text')[0]), 'Die Worker hören sofort auf, bis jemand fortsetzt.')
|
||||
const knoepfe = buttons(d)
|
||||
assert.deepEqual(knoepfe.map(textOf), ['Abbrechen', 'Anhalten'])
|
||||
for (const k of knoepfe) { assert.equal(k.props.type, 'button'); assert.ok(hasClass(k, 'hk-knopf')) }
|
||||
// der Hintergrund liegt um den Dialog
|
||||
assert.equal(byClass(ui.tree(), 'hk-rueckfrage-hintergrund').length, 1)
|
||||
assert.ok(nodes(byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0], (n) => n === d).length === 1)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Rückfrage: der Fokus springt beim Öffnen auf „Abbrechen“ (die harmlose Wahl)', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
const abbrechen = buttonByText(ui.tree(), 'Abbrechen').props.ref.current
|
||||
assert.ok(abbrechen)
|
||||
assert.equal(ui.document().activeElement, abbrechen)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Rückfrage: Esc schließt (und verhindert den Standard, damit nichts darunter mitschließt); ein schon behandeltes Esc tut nichts; andere Tasten auch nicht', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
const hintergrund = () => byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0]
|
||||
hintergrund().props.onKeyDown(event({ key: 'Enter' }))
|
||||
hintergrund().props.onKeyDown(event({ key: 'a' }))
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
|
||||
hintergrund().props.onKeyDown(event({ key: 'Escape', defaultPrevented: true }))
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
|
||||
const e = event({ key: 'Escape' })
|
||||
hintergrund().props.onKeyDown(e)
|
||||
assert.equal(e.defaultPrevented, true)
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 0)
|
||||
assert.deepEqual(ui.log, [])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Rückfrage: Tab bleibt im Dialog – vom letzten Knopf nach vorn zum ersten, mit Umschalt vom ersten zum letzten, von draußen hinein', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
const abbrechen = buttonByText(ui.tree(), 'Abbrechen').props.ref.current
|
||||
const anhalten = buttonByText(ui.tree(), 'Anhalten').props.ref.current
|
||||
const hintergrund = () => byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0]
|
||||
const tab = (shift) => { const e = event({ key: 'Tab', shiftKey: shift }); hintergrund().props.onKeyDown(e); return e }
|
||||
// Abbrechen -> Tab: normaler Weg zum nächsten Knopf (nicht abgefangen)
|
||||
abbrechen.focus()
|
||||
assert.equal(tab(false).defaultPrevented, false)
|
||||
// Anhalten ist der letzte: Tab springt zurück zu Abbrechen
|
||||
anhalten.focus()
|
||||
assert.equal(tab(false).defaultPrevented, true); assert.equal(ui.document().activeElement, abbrechen)
|
||||
// Abbrechen ist der erste: Umschalt+Tab springt zu Anhalten
|
||||
assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten)
|
||||
// Umschalt+Tab von Anhalten: normaler Weg
|
||||
assert.equal(tab(true).defaultPrevented, false)
|
||||
// Fokus außerhalb (body): Tab holt ihn in den Dialog, Umschalt+Tab auch
|
||||
ui.document().body.focus()
|
||||
assert.equal(tab(false).defaultPrevented, true); assert.equal(ui.document().activeElement, abbrechen)
|
||||
ui.document().body.focus()
|
||||
assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten)
|
||||
// Fokus auf dem Dialog selbst (tabIndex -1): Umschalt+Tab geht zum letzten Knopf
|
||||
byRole(ui.tree(), 'dialog')[0].props.ref.current.focus()
|
||||
assert.equal(tab(true).defaultPrevented, true); assert.equal(ui.document().activeElement, anhalten)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Rückfrage: ein Klick auf den Hintergrund nimmt dem Dialog den Fokus nicht und schließt nichts; ein Klick im Dialog wird nicht abgefangen', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
press(pauseButton(ui.tree()))
|
||||
const hintergrund = byClass(ui.tree(), 'hk-rueckfrage-hintergrund')[0]
|
||||
const aufHintergrund = event({ target: 'h', currentTarget: 'h' })
|
||||
hintergrund.props.onMouseDown(aufHintergrund)
|
||||
assert.equal(aufHintergrund.defaultPrevented, true)
|
||||
const imDialog = event({ target: 'd', currentTarget: 'h' })
|
||||
hintergrund.props.onMouseDown(imDialog)
|
||||
assert.equal(imDialog.defaultPrevented, false)
|
||||
assert.equal(byRole(ui.tree(), 'dialog').length, 1)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
// ---------- Einstellungen ----------
|
||||
|
||||
test('Zahnrad: nur für Admins; mit aria-label „Einstellungen“, Zahnrad-Icon und type=button', async () => {
|
||||
const kein = await kopf({ isAdmin: false })
|
||||
try {
|
||||
assert.equal(settingsButton(kein.tree()), undefined)
|
||||
assert.equal(byClass(kein.tree(), 'hk-zahnrad').length, 0)
|
||||
} finally { kein.end() }
|
||||
const admin = await kopf({ isAdmin: true })
|
||||
try {
|
||||
const b = settingsButton(admin.tree())
|
||||
assert.equal(b.props.type, 'button'); assert.equal(b.props.title, 'Einstellungen'); assert.ok(hasClass(b, 'hk-hud-knopf'))
|
||||
assert.deepEqual(classes(b.children[0]).slice(0, 2), ['hk-ic', 'hk-zahnrad'])
|
||||
} finally { admin.end() }
|
||||
})
|
||||
|
||||
test('Einstellungen: das Zahnrad öffnet den bestehenden Dialog in einer hk-woms-Hülle mit api, onClose und onSaved (-> refresh)', async () => {
|
||||
const api = { pause: async () => {}, name: 'api-attrappe' }
|
||||
const ui = await kopf({ isAdmin: true, api })
|
||||
try {
|
||||
assert.equal(ui.kb.settings.length, 0)
|
||||
press(settingsButton(ui.tree()))
|
||||
const huelle = byClass(ui.tree(), 'hk-woms')
|
||||
assert.equal(huelle.length, 1)
|
||||
assert.equal(nodes(huelle[0], (n) => n.props && n.props['data-attrappe'] === 'einstellungen').length, 1)
|
||||
const props = ui.kb.settings.at(-1)
|
||||
assert.equal(props.api, api)
|
||||
// Speichern holt den Zustand neu
|
||||
assert.equal(ui.log.filter((l) => l === 'refresh').length, 0)
|
||||
props.onSaved()
|
||||
assert.equal(ui.log.filter((l) => l === 'refresh').length, 1)
|
||||
// Schließen
|
||||
props.onClose()
|
||||
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
|
||||
// und wieder öffnen
|
||||
press(settingsButton(ui.tree()))
|
||||
assert.equal(byClass(ui.tree(), 'hk-woms').length, 1)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Einstellungen: verliert der Mensch die Admin-Rechte, verschwindet der offene Dialog', async () => {
|
||||
const ui = await kopf({ isAdmin: true })
|
||||
try {
|
||||
press(settingsButton(ui.tree()))
|
||||
assert.equal(byClass(ui.tree(), 'hk-woms').length, 1)
|
||||
ui.set({ isAdmin: false })
|
||||
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
|
||||
assert.equal(settingsButton(ui.tree()), undefined)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
// ---------- Meldungen ----------
|
||||
|
||||
test('Meldung „Angehalten“: nur wenn der Dienst angehalten ist, role=status, mit dem Satz aus dem Board', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0)
|
||||
ui.set({ state: makeState({ paused: true }) })
|
||||
const m = byClass(ui.tree(), 'hk-meldung')
|
||||
assert.equal(m.length, 1); assert.equal(m[0].props.role, 'status'); assert.ok(hasClass(m[0], 'hk-meldung-angehalten'))
|
||||
assert.equal(textOf(m[0]), 'Angehalten: Es wird nichts gesendet und nichts gebaut.')
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Meldung Notbremse: für alle sichtbar (auch ohne Admin), role=alert, Satz mit Grund, Grund als Textknoten', async () => {
|
||||
const ui = await kopf({ isAdmin: false, state: makeState({ sendingHalted: true, sendingHaltedReason: 'Tageslimit erreicht' }) })
|
||||
try {
|
||||
const m = byRole(ui.tree(), 'alert')
|
||||
assert.equal(m.length, 1); assert.ok(hasClass(m[0], 'hk-meldung-notbremse'))
|
||||
assert.equal(textOf(m[0]), 'Versand gestoppt: Tageslimit erreicht. Ein Admin kann ihn in den Einstellungen freigeben.')
|
||||
// der Grund steht als eigener Textknoten, kein Element darin
|
||||
assert.ok(m[0].children.every((c) => typeof c === 'string'))
|
||||
assert.ok(m[0].children.includes('Tageslimit erreicht'))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Meldung Notbremse: Grund getrimmt, ohne abschließende Punkte (kein „..“); ohne Grund „Grund nicht angegeben“', async () => {
|
||||
const faelle = [
|
||||
['Limit erreicht.', 'Limit erreicht'],
|
||||
['Limit erreicht...', 'Limit erreicht'],
|
||||
[' Limit erreicht. . ', 'Limit erreicht'],
|
||||
['Zu viele Fehler (3).', 'Zu viele Fehler (3)'],
|
||||
['', 'Grund nicht angegeben'],
|
||||
[' ', 'Grund nicht angegeben'],
|
||||
['...', 'Grund nicht angegeben'],
|
||||
[undefined, 'Grund nicht angegeben'],
|
||||
[null, 'Grund nicht angegeben'],
|
||||
[42, 'Grund nicht angegeben'],
|
||||
[{ text: 'x' }, 'Grund nicht angegeben'],
|
||||
]
|
||||
for (const [grund, anzeige] of faelle) {
|
||||
const state = makeState({ sendingHalted: true })
|
||||
state.sendingHaltedReason = grund
|
||||
const ui = await kopf({ state })
|
||||
try {
|
||||
const satz = textOf(byRole(ui.tree(), 'alert')[0])
|
||||
assert.equal(satz, `Versand gestoppt: ${anzeige}. Ein Admin kann ihn in den Einstellungen freigeben.`, JSON.stringify(grund))
|
||||
assert.ok(!satz.includes('..'), JSON.stringify(grund))
|
||||
} finally { ui.end() }
|
||||
}
|
||||
// fehlendes Feld ganz
|
||||
const state = makeState({ sendingHalted: true })
|
||||
delete state.sendingHaltedReason
|
||||
const ui = await kopf({ state })
|
||||
try { assert.match(textOf(byRole(ui.tree(), 'alert')[0]), /^Versand gestoppt: Grund nicht angegeben\. Ein Admin/) } finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Meldung Notbremse: ein HTML-Grund bleibt Text; nur sendingHalted === true löst sie aus', async () => {
|
||||
const ui = await kopf({ state: makeState({ sendingHalted: true, sendingHaltedReason: '<b>fett</b>' }) })
|
||||
try {
|
||||
assert.ok(byRole(ui.tree(), 'alert')[0].children.includes('<b>fett</b>'))
|
||||
assert.equal(nodes(byRole(ui.tree(), 'alert')[0], (n) => typeof n.tag === 'string' && n.tag !== 'div').length, 0)
|
||||
} finally { ui.end() }
|
||||
for (const wert of [false, 'true', 1, null, undefined]) {
|
||||
const state = makeState({ sendingHaltedReason: 'egal' })
|
||||
state.sendingHalted = wert
|
||||
const u = await kopf({ state })
|
||||
try { assert.equal(byRole(u.tree(), 'alert').length, 0, String(wert)) } finally { u.end() }
|
||||
}
|
||||
})
|
||||
|
||||
test('Meldung „Verbindung gestört“: role=status mit dem Fehlertext als Textknoten, nur solange ein Fehler anliegt', async () => {
|
||||
const ui = await kopf({ error: null })
|
||||
try {
|
||||
assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0)
|
||||
ui.set({ error: 'Zeitüberschreitung.' })
|
||||
const m = byClass(ui.tree(), 'hk-meldung-verbindung')
|
||||
assert.equal(m.length, 1); assert.equal(m[0].props.role, 'status')
|
||||
assert.equal(textOf(m[0]), 'Verbindung gestört: Zeitüberschreitung. Es wird weiter versucht.')
|
||||
ui.set({ error: '<i>x</i>' })
|
||||
assert.ok(byClass(ui.tree(), 'hk-meldung-verbindung')[0].children.includes('<i>x</i>'))
|
||||
ui.set({ error: '' })
|
||||
assert.equal(byClass(ui.tree(), 'hk-meldung').length, 0)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Meldungen: mehrere stehen untereinander im selben Behälter unter der Kopfzeile, in fester Reihenfolge', async () => {
|
||||
const ui = await kopf({ state: makeState({ paused: true, sendingHalted: true, sendingHaltedReason: 'Limit' }), error: 'weg' })
|
||||
try {
|
||||
const behaelter = byClass(ui.tree(), 'hk-meldungen')
|
||||
assert.equal(behaelter.length, 1)
|
||||
const meldungen = behaelter[0].children.filter((c) => typeof c !== 'string')
|
||||
assert.equal(meldungen.length, behaelter[0].children.length) // keine Leertexte dazwischen
|
||||
assert.deepEqual(meldungen.map((m) => classes(m)[1]), ['hk-meldung-angehalten', 'hk-meldung-notbremse', 'hk-meldung-verbindung'])
|
||||
assert.deepEqual(meldungen.map((m) => m.props.role), ['status', 'alert', 'status'])
|
||||
// Behälter steht nach der Kopfzeile (darunter), beide in der Hülle hk-kopf
|
||||
const huelle = byClass(ui.tree(), 'hk-kopf')[0]
|
||||
assert.deepEqual(huelle.children.map((c) => classes(c)[0]), ['hk-kopf-zeile', 'hk-meldungen'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
// ---------- Rechts: Ganzes Haus ----------
|
||||
|
||||
test('„Ganzes Haus zeigen“: Knopf nur für die Tastatur (hk-nur-tastatur), ruft onGanz; ohne onGanz kein Absturz', async () => {
|
||||
const ui = await kopf()
|
||||
try {
|
||||
const b = hausButton(ui.tree())
|
||||
assert.equal(b.props.type, 'button'); assert.ok(hasClass(b, 'hk-nur-tastatur'))
|
||||
press(b)
|
||||
assert.deepEqual(ui.log, ['ganz'])
|
||||
press(b)
|
||||
assert.deepEqual(ui.log, ['ganz', 'ganz'])
|
||||
ui.set({ onGanz: undefined })
|
||||
assert.doesNotThrow(() => press(hausButton(ui.tree())))
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Aufbau: Geld, Kennzahlen, Abstandhalter, Panel rechts in dieser Reihenfolge; Pause steht nach „Ganzes Haus zeigen“, das Zahnrad zuletzt', async () => {
|
||||
const ui = await kopf({ isAdmin: true })
|
||||
try {
|
||||
const zeile = byClass(ui.tree(), 'hk-kopf-zeile')[0]
|
||||
assert.deepEqual(zeile.children.map((c) => classes(c)[1] ?? classes(c)[0]), ['hk-kopf-geld', 'hk-kopf-zahlen', 'hk-kopf-abstand', 'hk-kopf-rechts'])
|
||||
const rechts = byClass(zeile, 'hk-kopf-rechts')[0]
|
||||
assert.ok(hasClass(rechts, 'hk-panel'))
|
||||
assert.deepEqual(buttons(rechts).map((b) => b.props['aria-label'] ?? textOf(b)), ['Ganzes Haus zeigen', 'Alles anhalten', 'Einstellungen'])
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
// ---------- Allgemeine Zusicherungen ----------
|
||||
|
||||
test('Alle Knöpfe sind type="button" – in jedem Zustand (Rückfrage offen, Einstellungen offen, Admin)', async () => {
|
||||
const ui = await kopf({ isAdmin: true, state: makeState({ kpis: VOLLE_KPIS, sendingHalted: true, sendingHaltedReason: 'x' }), error: 'weg' })
|
||||
try {
|
||||
const pruefe = () => { for (const b of buttons(ui.tree())) assert.equal(b.props.type, 'button', textOf(b) || b.props['aria-label']) }
|
||||
pruefe()
|
||||
press(pauseButton(ui.tree())); pruefe()
|
||||
assert.equal(buttons(ui.tree()).length, 5) // Ganzes Haus, Pause, Zahnrad, Abbrechen, Anhalten
|
||||
press(buttonByText(ui.tree(), 'Abbrechen')); pruefe()
|
||||
press(settingsButton(ui.tree())); pruefe()
|
||||
ui.set({ state: makeState({ paused: true }) }); pruefe()
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('Quelltext: kein innerHTML, kein CSS-/Bildimport, kein window.confirm, keine Links aus Dienst-Daten, Einstellungen unverändert eingebunden', () => {
|
||||
const s = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HochhausKopf.jsx'), 'utf8')
|
||||
assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
|
||||
assert.doesNotMatch(s, /^\s*import\s+(?:[^'"\n]*\sfrom\s+)?['"][^'"]+\.(css|png|svg|jpe?g|webp|gif)['"]/m)
|
||||
assert.doesNotMatch(s, /window\.confirm|\bconfirm\(/)
|
||||
assert.doesNotMatch(s, /href=|<a[\s>]|<Link[\s>]/)
|
||||
assert.doesNotMatch(s, /useAuth|appwrite/)
|
||||
assert.match(s, /<div className="hk-woms">\s*<WorkersSettingsDialog api=\{api\} onClose=\{[^}]*\} onSaved=\{\(\) => refresh\(\)\} \/>\s*<\/div>/)
|
||||
assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/)
|
||||
// jedes <button> im Quelltext hat type="button"
|
||||
assert.equal((s.match(/<button\b/g) || []).length, (s.match(/<button\b[^>]*\btype="button"/g) || []).length)
|
||||
})
|
||||
|
||||
// ---------- CSS ----------
|
||||
|
||||
const cssText = (name) => readFileSync(path.join(ROOT, 'src/styles/hustler', name), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const klassenIn = (css) => new Set((css.replace(/url\([^)]*\)/g, '').match(/\.hk-[A-Za-z0-9_-]+/g) ?? []).map((k) => k.slice(1)))
|
||||
|
||||
test('CSS: jede Klasse, die die Kopfzeile in allen Zuständen ausgibt, steht in hochhaus.css, hustler-kopf.css oder hustler-ui.css', async () => {
|
||||
const bekannt = new Set([...klassenIn(cssText('hochhaus.css')), ...klassenIn(cssText('hustler-kopf.css')), ...klassenIn(cssText('hustler-ui.css'))])
|
||||
const ui = await kopf({ isAdmin: true, state: makeState({ kpis: { ...VOLLE_KPIS, apiTodayUsd: 5.1 }, paused: false, sendingHalted: true, sendingHaltedReason: 'x' }), error: 'weg' })
|
||||
try {
|
||||
const gesehen = new Set()
|
||||
const sammle = () => nodes(ui.tree(), (n) => typeof n.tag === 'string').forEach((n) => classes(n).forEach((k) => gesehen.add(k)))
|
||||
sammle()
|
||||
press(pauseButton(ui.tree())); sammle()
|
||||
press(buttonByText(ui.tree(), 'Abbrechen'))
|
||||
press(settingsButton(ui.tree())); sammle()
|
||||
ui.set({ state: makeState({ kpis: { ...VOLLE_KPIS, apiTodayUsd: 7 }, paused: true }) }); sammle()
|
||||
ui.set({ state: makeState({ kpis: {}, paused: true }) })
|
||||
// Fehlermeldung des Umschaltens
|
||||
ui.set({ api: { pause: async () => { throw new Error('x') } } })
|
||||
press(pauseButton(ui.tree())); await settle(); sammle()
|
||||
for (const k of gesehen) assert.ok(k.startsWith('hk-'), `fremde Klasse ${k}`)
|
||||
for (const k of gesehen) assert.ok(bekannt.has(k), `Klasse ${k} fehlt im CSS`)
|
||||
for (const k of ['hk-api-warn', 'hk-api-neg', 'hk-meldung-angehalten', 'hk-meldung-umschalten', 'hk-meldung-notbremse', 'hk-meldung-verbindung', 'hk-rueckfrage', 'hk-pfeil-rechts', 'hk-pause', 'hk-zahnrad']) assert.ok(gesehen.has(k), k)
|
||||
} finally { ui.end() }
|
||||
})
|
||||
|
||||
test('CSS hustler-kopf.css: nur hk-Klassen unter .hk-hochhaus, Keyframes hk-kopf-…, keine Bilder/Importe, Handy-Regeln unter 720 px, weniger Bewegung', () => {
|
||||
const css = cssText('hustler-kopf.css')
|
||||
assert.ok(klassenIn(css).size > 20)
|
||||
assert.ok(!/\.(?!hk-)[A-Za-z_-][\w-]*/.test(css.replace(/url\([^)]*\)/g, '').replace(/\d+\.\d+/g, '').replace(/['"][^'"]*['"]/g, '')), 'Klasse ohne hk-')
|
||||
assert.doesNotMatch(css, /url\(|@import|@font-face|\/_blob\//)
|
||||
// Selektoren: jede Regel (außer in @keyframes) beginnt mit .hk-hochhaus
|
||||
const vorspann = []
|
||||
let tiefe = 0
|
||||
const stapel = []
|
||||
let aktuell = ''
|
||||
for (const c of css) {
|
||||
if (c === '{') {
|
||||
const p = aktuell.trim(); aktuell = ''
|
||||
stapel.push(p)
|
||||
if (!stapel.slice(0, -1).some((x) => x.startsWith('@keyframes'))) vorspann.push(p)
|
||||
tiefe += 1
|
||||
} else if (c === '}') { stapel.pop(); tiefe -= 1; aktuell = '' } else if (c === ';') aktuell = ''
|
||||
else aktuell += c
|
||||
}
|
||||
assert.equal(tiefe, 0)
|
||||
const regeln = vorspann.filter((p) => !p.startsWith('@'))
|
||||
assert.ok(regeln.length > 30)
|
||||
for (const p of regeln) for (const teil of p.split(',')) assert.match(teil.trim(), /^\.hk-hochhaus\s/, `Selektor: ${teil.trim()}`)
|
||||
const keyframes = [...css.matchAll(/@keyframes\s+([\w-]+)/g)].map((m) => m[1])
|
||||
assert.ok(keyframes.length >= 1)
|
||||
for (const k of keyframes) assert.match(k, /^hk-kopf-/)
|
||||
for (const m of css.matchAll(/animation:\s*([\w-]+)/g)) assert.ok(keyframes.includes(m[1]) || m[1] === 'none', m[1])
|
||||
assert.match(css, /@media \(max-width: 719px\)/)
|
||||
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.hk-api-warn\s*\{\s*animation:\s*none/)
|
||||
// Meldungen sind nur Text (Dekor per ::before), die Hülle fängt keine Zeigerereignisse ab
|
||||
assert.match(css, /\.hk-hochhaus \.hk-kopf \{[^}]*pointer-events: none/)
|
||||
assert.match(css, /\.hk-hochhaus \.hk-kopf-geld \{[^}]*pointer-events: auto/)
|
||||
})
|
||||
Reference in new issue
Block a user