Hustler-Pixelwelt T13: Korrekturen Pult und Keller (Sperre laufender Aktionen, Fokus, Kontrast, Handy-Tippflächen, Pause)

Pult: laufende Anfragen je Eintrags-Id in PultList (laufRef, Brief meldet Beginn/Ende): Brief schließen und denselben Eintrag
wieder öffnen sperrt alle Aktionsknöpfe, kein zweites reply/paste/start-build (P1); spätes Ergebnis nennt den Titel (P1b);
Original und Entwurf pre-wrap (P3); Überschrift nimmt beim Einhängen den Fokus (M2); „Pult zuklappen“ mit aria-expanded (K4);
paused -> hk-steht an der Szene (R3); Brief z-index 9 (R4); Textlinks am Handy 40 px (K7).
Keller: Überschrift-Fokus (M2), Reiter-Fokusring innen (M3), Seitenzeile/Punkte/Reiterzahl in Pixelify Sans 13 px (K5), Suchfeld und
Akte-Link 40 px am Handy (K7), Ortszeile mit title (W2), paused -> hk-steht (R3), Akte z-index 9 (R4); Arne ohne Eintrag neutral (R5).
Tests: Sperre laufender Aktionen, Titel im späten Ergebnis, Fokus, aria-expanded, paused, pre-wrap, z-index, Tippflächen, Reiter-Fokus,
Schrift, Kontrast (WCAG) von .hk-zeile-alt/.hk-kl-tag auf Papier und Hover, Arne neutral (alte „Arne schläft ohne Eintrag“-Prüfung ersetzt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-22 09:40:06 +00:00
1 parent ea1485c27c
commit 0924a8d2ae
13 files changed
+586 -59

No files matched your search

+12 -4
View File
@@ -14,14 +14,15 @@ const browserDocument = () => (typeof document === 'undefined' ? null : document
// Bildschirm „Keller“ (Hustler-Pixelwelt T10, Spec E12/E13; Dateiname bleibt BotLeadList): die Archiv-Szene mit Arne (keller/KellerSzene), davor Arnes Register-Buch // Bildschirm „Keller“ (Hustler-Pixelwelt T10, Spec E12/E13; Dateiname bleibt BotLeadList): die Archiv-Szene mit Arne (keller/KellerSzene), davor Arnes Register-Buch
// (keller/RegisterBuch) mit den Betrieben, die nicht angeschrieben werden, samt Grund, und die Akte zu einem Eintrag (keller/Akte). Reine Liste, nichts wird gesendet // (keller/RegisterBuch) mit den Betrieben, die nicht angeschrieben werden, samt Grund, und die Akte zu einem Eintrag (keller/Akte). Reine Liste, nichts wird gesendet
// oder angerufen. Props: api, bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status), onBack (◂ Hochhaus). // oder angerufen. Props: api, bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status), paused (Hustler angehalten:
// die Szene steht still, Klasse hk-steht), onBack (◂ Hochhaus). Beim Einhängen nimmt die Überschrift „Bot-Leads“ (tabIndex -1) den Fokus, ohne dass die Seite springt (T13 M2).
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte // Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“, // Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“,
// das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin. // das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin.
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse). // bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten), und zwar // Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten), und zwar
// nur in der Akte. Kein CSS-Import (WorkersPage hängt die Stile ein), keine Bilder, kein HTML-Einschub. // nur in der Akte. Kein CSS-Import (WorkersPage hängt die Stile ein), keine Bilder, kein HTML-Einschub.
export default function BotLeadList({ api, bot, onClearBot, kpis, workers, onBack }) { export default function BotLeadList({ api, bot, onClearBot, kpis, workers, paused = false, onBack }) {
const botId = typeof bot === 'string' ? bot : '' const botId = typeof bot === 'string' ? bot : ''
const single = botId !== '' const single = botId !== ''
const [view, setView] = useState(START_VIEW) const [view, setView] = useState(START_VIEW)
@@ -32,6 +33,7 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, onBac
const [akte, setAkte] = useState(null) // Nr. des Eintrags in der gezeigten Seite, dessen Akte offen ist const [akte, setAkte] = useState(null) // Nr. des Eintrags in der gezeigten Seite, dessen Akte offen ist
const timerRef = useRef(null) const timerRef = useRef(null)
const seqRef = useRef(0) const seqRef = useRef(0)
const h1Ref = useRef(null) // Überschrift „Bot-Leads“: nimmt beim Einhängen den Fokus (Bildschirmwechsel)
// Tastaturfokus: Wer „Alle anzeigen“ oder ◂/▸ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder // Tastaturfokus: Wer „Alle anzeigen“ oder ◂/▸ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
// hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat. // hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat.
const reiterRef = useRef(null) // aktiver Raum-Reiter: Ziel nach „Alle anzeigen“ const reiterRef = useRef(null) // aktiver Raum-Reiter: Ziel nach „Alle anzeigen“
@@ -72,6 +74,12 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, onBac
useEffect(() => () => window.clearTimeout(timerRef.current), []) useEffect(() => () => window.clearTimeout(timerRef.current), [])
// Bildschirmwechsel (M2): Tastatur und Screenreader beginnen in diesem Bildschirm, nicht auf <body> oder dem ausgehängten „Hochhaus“-Knopf des alten
useEffect(() => {
const h1 = h1Ref.current
if (h1 && typeof h1.focus === 'function') h1.focus({ preventScroll: true })
}, [])
const onRetry = () => setAttempt((n) => n + 1) const onRetry = () => setAttempt((n) => n + 1)
const onClear = () => { const onClear = () => {
clearedRef.current = true clearedRef.current = true
@@ -126,7 +134,7 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, onBac
return ( return (
<div className="hk-keller"> <div className="hk-keller">
<KellerSzene workers={workers} /> <KellerSzene workers={workers} steht={paused === true} />
<div className="hk-kl-vignette" aria-hidden="true" /> <div className="hk-kl-vignette" aria-hidden="true" />
<div className="hk-kl-dunkel" aria-hidden="true" /> <div className="hk-kl-dunkel" aria-hidden="true" />
<div className="hk-kl-ui"> <div className="hk-kl-ui">
@@ -136,7 +144,7 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, onBac
</button> </button>
<div className="hk-messing hk-kl-schild"> <div className="hk-messing hk-kl-schild">
<span className="hk-ic hk-ic-robo" aria-hidden="true" /> <span className="hk-ic hk-ic-robo" aria-hidden="true" />
<h1 className="hk-kl-titel">Bot-Leads</h1> <h1 className="hk-kl-titel" tabIndex={-1} ref={h1Ref}>Bot-Leads</h1>
{plakette ? <span className="hk-kl-zahl">{plakette}</span> : null} {plakette ? <span className="hk-kl-zahl">{plakette}</span> : null}
</div> </div>
<button type="button" className="hk-knopf hk-kl-knopf-klein hk-kl-zuklappen" aria-expanded={!zu} aria-label={zu ? 'Register aufklappen' : 'Register zuklappen'} onClick={() => setZu((z) => !z)}> <button type="button" className="hk-knopf hk-kl-knopf-klein hk-kl-zuklappen" aria-expanded={!zu} aria-label={zu ? 'Register aufklappen' : 'Register zuklappen'} onClick={() => setZu((z) => !z)}>
+35 -5
View File
@@ -6,7 +6,12 @@
// Schließen des Briefs, Filterwechsel und Neuladen), die Meldung nach einem Abschluss samt Fokus, den offenen Brief, das Interessenten-Panel, „Pult zuklappen“. // Schließen des Briefs, Filterwechsel und Neuladen), die Meldung nach einem Abschluss samt Fokus, den offenen Brief, das Interessenten-Panel, „Pult zuklappen“.
// //
// Props: api (Dienst-API), me = { id, name } des Angemeldeten, kpis (Kennzahlen aus dem Zustand: pultOpen steuert die Zahl am Reiter „Wartet“, im Kopf und die Boten- // Props: api (Dienst-API), me = { id, name } des Angemeldeten, kpis (Kennzahlen aus dem Zustand: pultOpen steuert die Zahl am Reiter „Wartet“, im Kopf und die Boten-
// Schleife), onBack() („◂ Hochhaus“). workers (Hustler-Tabelle) reicht die Seite durch, das Pult braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler. // Schleife), paused (Hustler angehalten: die Szene steht still, Klasse hk-steht), onBack() („◂ Hochhaus“). workers (Hustler-Tabelle) reicht die Seite durch, das Pult
// braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler.
// Laufende Aktionen (T13 P1): welche Einträge gerade eine Anfrage an den Dienst laufen haben, merkt PultList (laufRef, nach Eintrags-Id), nicht der Brief: wer den Brief
// während der Anfrage schließt und denselben Eintrag wieder öffnet, findet alle Aktionsknöpfe gesperrt und kann keine zweite Anfrage auslösen (sonst fügte „Antwort
// einfügen“ doppelt ein); sobald die Anfrage endet, sind sie frei.
// Fokus (T13 M2): beim Einhängen nimmt die Überschrift „Freigabe“ (tabIndex -1) den Fokus, ohne dass die Seite springt.
// Kein CSS-Import und keine Bilder (die Stile hängt WorkersPage ein), Fremdtext nur als Textknoten. // Kein CSS-Import und keine Bilder (die Stile hängt WorkersPage ein), Fremdtext nur als Textknoten.
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useEmployees } from '../../hooks/useEmployees' import { useEmployees } from '../../hooks/useEmployees'
@@ -24,7 +29,7 @@ const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet // Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
const browserDocument = () => (typeof document === 'undefined' ? null : document) const browserDocument = () => (typeof document === 'undefined' ? null : document)
export default function PultList({ api, me, kpis, onBack }) { export default function PultList({ api, me, kpis, paused = false, onBack }) {
const [filterId, setFilterId] = useState('offen') const [filterId, setFilterId] = useState('offen')
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
@@ -38,11 +43,21 @@ export default function PultList({ api, me, kpis, onBack }) {
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins) const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
const [zu, setZu] = useState(false) // „Pult zuklappen“: nur die Szene bleibt const [zu, setZu] = useState(false) // „Pult zuklappen“: nur die Szene bleibt
const listeRef = useRef(null) const listeRef = useRef(null)
const h1Ref = useRef(null) // Überschrift „Freigabe“: nimmt beim Einhängen den Fokus (Bildschirmwechsel)
const laufRef = useRef(new Set()) // Ids der Einträge, für die gerade eine Anfrage an den Dienst läuft (überlebt das Schließen des Briefs)
const [, setLaufTakt] = useState(0) // zählt das Ende einer Anfrage: ein offener Brief dieses Eintrags zeichnet sich neu (laufRef allein löst kein Neuzeichnen aus)
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0] const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses }) const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
const { employees } = useEmployees() const { employees } = useEmployees()
const nowMs = Date.now() const nowMs = Date.now()
// Bildschirmwechsel (M2): die Überschrift nimmt den Fokus, damit Tastatur und Screenreader im neuen Bildschirm beginnen und nicht auf <body> oder dem
// ausgehängten „Hochhaus“-Knopf des alten stehen. preventScroll: die Szene soll nicht springen.
useEffect(() => {
const h1 = h1Ref.current
if (h1 && typeof h1.focus === 'function') h1.focus({ preventScroll: true })
}, [])
// Verlässt ein Eintrag die Liste, schließt sein Brief und die Knöpfe mit dem Fokus hängen ab. Die Meldung nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt: // Verlässt ein Eintrag die Liste, schließt sein Brief und die Knöpfe mit dem Fokus hängen ab. Die Meldung nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
// Tastatur- und Screenreader-Nutzer bleiben in der Nähe der Liste und hören die Bestätigung (Endprüfung W3). // Tastatur- und Screenreader-Nutzer bleiben in der Nähe der Liste und hören die Bestätigung (Endprüfung W3).
useEffect(() => { useEffect(() => {
@@ -94,6 +109,19 @@ export default function PultList({ api, me, kpis, onBack }) {
}) })
}, []) }, [])
const onSpaet = useCallback((note) => setSpaet(note), []) const onSpaet = useCallback((note) => setSpaet(note), [])
// Anfrage zu einem Eintrag beginnt (an = true) oder endet. Beginn: true, wenn sie beginnen darf, false, wenn für diesen Eintrag schon eine läuft (die zweite wird
// abgewiesen). Das Ende zeichnet neu: ein inzwischen wieder geöffneter Brief des Eintrags gibt seine Knöpfe frei. Der Beginn braucht kein Neuzeichnen: der Brief, der die
// Anfrage startet, sperrt sich selbst, und ein später geöffneter liest laufRef beim Zeichnen.
const onLauf = useCallback((id, an) => {
const laufend = laufRef.current
if (an) {
if (laufend.has(id)) return false
laufend.add(id)
return true
}
if (laufend.delete(id)) setLaufTakt((n) => n + 1)
return true
}, [])
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) })) const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
const offen = briefId === null ? null : rows.find(({ row }) => row.id === briefId) ?? null const offen = briefId === null ? null : rows.find(({ row }) => row.id === briefId) ?? null
@@ -112,7 +140,7 @@ export default function PultList({ api, me, kpis, onBack }) {
return ( return (
<div className="hk-pult"> <div className="hk-pult">
<PultSzene laeuft={botenLaufen(kpis)} /> <PultSzene laeuft={botenLaufen(kpis)} steht={paused === true} />
<div className="hk-pult-ui"> <div className="hk-pult-ui">
<div className="hk-pult-kopf"> <div className="hk-pult-kopf">
@@ -121,12 +149,12 @@ export default function PultList({ api, me, kpis, onBack }) {
</button> </button>
<div className="hk-messing hk-pult-schild"> <div className="hk-messing hk-pult-schild">
<span className="hk-ic hk-ic-briefkasten" aria-hidden="true" /> <span className="hk-ic hk-ic-briefkasten" aria-hidden="true" />
<h1 className="hk-pult-h1">Freigabe</h1> <h1 className="hk-pult-h1" tabIndex={-1} ref={h1Ref}>Freigabe</h1>
{kopfZahl !== null && ( {kopfZahl !== null && (
<span className="hk-pult-zahl"><span className="hk-nur-sr">Wartet auf dich:</span>{kopfZahl}</span> <span className="hk-pult-zahl"><span className="hk-nur-sr">Wartet auf dich:</span>{kopfZahl}</span>
)} )}
</div> </div>
<button type="button" className="hk-knopf hk-pult-knopf hk-pult-zuklappen" onClick={() => setZu((z) => !z)}> <button type="button" className="hk-knopf hk-pult-knopf hk-pult-zuklappen" aria-expanded={!zu} onClick={() => setZu((z) => !z)}>
<span className="hk-ic hk-ic-art-mail" aria-hidden="true" />{zu ? 'Pult aufklappen' : 'Pult zuklappen'} <span className="hk-ic hk-ic-art-mail" aria-hidden="true" />{zu ? 'Pult aufklappen' : 'Pult zuklappen'}
</button> </button>
</div> </div>
@@ -164,6 +192,8 @@ export default function PultList({ api, me, kpis, onBack }) {
onClose={schliesse} onClose={schliesse}
onDetails={setDetailId} onDetails={setDetailId}
onSpaet={onSpaet} onSpaet={onSpaet}
laeuft={laufRef.current.has(offen.row.id)}
onLauf={onLauf}
/> />
)} )}
{detailId && ( {detailId && (
@@ -7,8 +7,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ARNE_RUHE, BUCH_STEMPEL, DAMPF, FENSTER, LEDS, MAUS_STREIFEN, ROHRPOST, SCHUBLADE, arneAnsicht, kellerEinpassen } from '../../../lib/hustlerKeller' import { ARNE_RUHE, BUCH_STEMPEL, DAMPF, FENSTER, LEDS, MAUS_STREIFEN, ROHRPOST, SCHUBLADE, arneAnsicht, kellerEinpassen } from '../../../lib/hustlerKeller'
// Props: workers = Hustler-Tabelle der Seite (Zustandseinträge nach workerId, aus indexWorkers/reduceEvents) // Props: workers = Hustler-Tabelle der Seite (Zustandseinträge nach workerId, aus indexWorkers/reduceEvents), steht (Hustler angehalten: alles in der Szene steht still,
export default function KellerSzene({ workers }) { // Klasse hk-steht aus hustler-ui.css, auch Arne und die Animationen der Dinge)
export default function KellerSzene({ workers, steht = false }) {
const rahmenRef = useRef(null) const rahmenRef = useRef(null)
const [fit, setFit] = useState(() => kellerEinpassen(0, 0)) const [fit, setFit] = useState(() => kellerEinpassen(0, 0))
const arne = arneAnsicht(workers) const arne = arneAnsicht(workers)
@@ -39,7 +40,7 @@ export default function KellerSzene({ workers }) {
const ruht = arne.arbeitet ? '' : ' hk-kl-ruht' const ruht = arne.arbeitet ? '' : ' hk-kl-ruht'
return ( return (
<div className="hk-kl-szene" aria-hidden="true" ref={rahmenRef} data-hk-arne={arne.status}> <div className={steht === true ? 'hk-kl-szene hk-steht' : 'hk-kl-szene'} aria-hidden="true" ref={rahmenRef} data-hk-arne={arne.status}>
<div className="hk-kl-buehne" style={{ transform: `translate(${fit.x.toFixed(1)}px, ${fit.y.toFixed(1)}px) scale(${fit.s.toFixed(4)})` }}> <div className="hk-kl-buehne" style={{ transform: `translate(${fit.x.toFixed(1)}px, ${fit.y.toFixed(1)}px) scale(${fit.s.toFixed(4)})` }}>
<div className="hk-sz-ebene hk-sz-bild" /> <div className="hk-sz-ebene hk-sz-bild" />
<div style={FENSTER}> <div style={FENSTER}>
@@ -10,6 +10,7 @@ const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
// Eine Zeile des Buchs: ein Knopf, der die Akte öffnet. Spalten: Raum-Symbol, Name (+ „gekürzt“) mit Ort und Funddatum, Punkte, Grund mit Symbol, Telefon. // Eine Zeile des Buchs: ein Knopf, der die Akte öffnet. Spalten: Raum-Symbol, Name (+ „gekürzt“) mit Ort und Funddatum, Punkte, Grund mit Symbol, Telefon.
function Zeile({ z, nr, aktuell, onOpen }) { function Zeile({ z, nr, aktuell, onOpen }) {
const ort = [z.place, z.found].filter((t) => t !== '').join(' · ') // die Zeile kürzt ihn mit „…“: title nennt den vollen Ort samt Datum (W2)
return ( return (
<li> <li>
<button <button
@@ -26,7 +27,7 @@ function Zeile({ z, nr, aktuell, onOpen }) {
<span className="hk-zelle-text hk-kl-name" title={z.title}>{z.title}</span> <span className="hk-zelle-text hk-kl-name" title={z.title}>{z.title}</span>
{z.gekuerzt ? <span className="hk-kl-tag"><span className="hk-ic hk-ic-schere hk-kl-tag-icon" aria-hidden="true" />gekürzt</span> : null} {z.gekuerzt ? <span className="hk-kl-tag"><span className="hk-ic hk-ic-schere hk-kl-tag-icon" aria-hidden="true" />gekürzt</span> : null}
</span> </span>
<span className="hk-klein">{[z.place, z.found].filter((t) => t !== '').join(' · ')}</span> <span className="hk-klein" title={ort === '' ? undefined : ort}>{ort}</span>
</span> </span>
<span className="hk-zelle hk-kl-spalte-punkte"> <span className="hk-zelle hk-kl-spalte-punkte">
<span className="hk-kl-balken" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${z.punkte.farbe}`} style={{ width: z.punkte.breite }} /></span> <span className="hk-kl-balken" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${z.punkte.farbe}`} style={{ width: z.punkte.breite }} /></span>
+47 -21
View File
@@ -9,7 +9,9 @@
// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf den ersten Knopf („Schließen“), Tab bleibt im Brief, Esc schließt erst ein offenes Feld, dann den Brief // Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf den ersten Knopf („Schließen“), Tab bleibt im Brief, Esc schließt erst ein offenes Feld, dann den Brief
// (während einer Anfrage bleibt ein offenes Feld stehen). Schließt der Brief nach einem Abschluss (onFinished), steht die Meldung in der Liste und nimmt den Fokus; // (während einer Anfrage bleibt ein offenes Feld stehen). Schließt der Brief nach einem Abschluss (onFinished), steht die Meldung in der Liste und nimmt den Fokus;
// schließt die Person ihn selbst (onClose), gibt PultList den Fokus an den Umschlag zurück. Eine Aktion, die nach dem Schließen noch antwortet, meldet ihr Ergebnis // schließt die Person ihn selbst (onClose), gibt PultList den Fokus an den Umschlag zurück. Eine Aktion, die nach dem Schließen noch antwortet, meldet ihr Ergebnis
// über onSpaet an die Liste (sonst ginge ein Fehler wie „Kann gesendet sein – Pult neu laden.“ verloren). // über onSpaet an die Liste (sonst ginge ein Fehler wie „Kann gesendet sein – Pult neu laden.“ verloren); der Satz nennt dann den Eintrag („„Titel“: …“), denn der Brief
// ist ja zu. Laufende Anfragen (T13 P1): der Brief meldet Beginn und Ende je Eintrags-Id an PultList (onLauf) und bekommt von dort zurück, ob für seinen Eintrag gerade
// eine läuft (laeuft): auch ein später wieder geöffneter Brief sperrt dann alle Aktionsknöpfe und löst keine zweite Anfrage aus.
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten. // Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { copyTextChecked } from '../../CopyableCredential' import { copyTextChecked } from '../../CopyableCredential'
@@ -108,8 +110,9 @@ function TextPanel({ className, caption, label, hint, value, onChange, submitLab
// Props: api (pultAction), item (Eintrag des Dienstes: Original, Entwurf), row (rowModel), text (Entwurf oder bearbeiteter Text), fields ({ reply, paste }), // Props: api (pultAction), item (Eintrag des Dienstes: Original, Entwurf), row (rowModel), text (Entwurf oder bearbeiteter Text), fields ({ reply, paste }),
// onText(id, value), onField(id, which, value), refresh() (Liste neu holen), onFinished(id, message, hadFocus) (der Eintrag verlässt die Liste: PultList schließt den Brief, // onText(id, value), onField(id, which, value), refresh() (Liste neu holen), onFinished(id, message, hadFocus) (der Eintrag verlässt die Liste: PultList schließt den Brief,
// zeigt die Meldung und nimmt den Fokus), onClose() (die Person schließt), onDetails(prospectId), onSpaet(note) (Ergebnis, das nach dem Schließen eintrifft) // zeigt die Meldung und nimmt den Fokus), onClose() (die Person schließt), onDetails(prospectId), onSpaet(note) (Ergebnis, das nach dem Schließen eintrifft),
export default function Brief({ api, item, row, text, fields, onText, onField, refresh, onFinished, onClose, onDetails, onSpaet }) { // laeuft (für diesen Eintrag läuft gerade eine Anfrage, auch eine aus einem früher geschlossenen Brief), onLauf(id, an) (meldet Beginn und Ende; false = läuft schon)
export default function Brief({ api, item, row, text, fields, onText, onField, refresh, onFinished, onClose, onDetails, onSpaet, laeuft = false, onLauf }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
@@ -121,6 +124,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
const ersterRef = useRef(null) // „Schließen“: Fokus beim Öffnen const ersterRef = useRef(null) // „Schließen“: Fokus beim Öffnen
const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
const wasBusy = useRef(false) const wasBusy = useRef(false)
const gesperrt = busy || laeuft === true // alle Aktionsknöpfe: eigene Anfrage oder eine, die der vorige Brief dieses Eintrags noch laufen hat
useEffect(() => { useEffect(() => {
aliveRef.current = true aliveRef.current = true
@@ -165,6 +169,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
const begin = () => { const begin = () => {
if (busyRef.current) return false if (busyRef.current) return false
if (typeof onLauf === 'function' && onLauf(row.id, true) === false) return false // für diesen Eintrag läuft schon eine Anfrage (aus einem früher geschlossenen Brief)
busyRef.current = true busyRef.current = true
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
setBusy(true) setBusy(true)
@@ -174,12 +179,18 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
} }
const end = () => { const end = () => {
busyRef.current = false busyRef.current = false
if (typeof onLauf === 'function') onLauf(row.id, false) // auch bei geschlossenem Brief: erst jetzt ist der Eintrag wieder frei
if (aliveRef.current) setBusy(false) if (aliveRef.current) setBusy(false)
} }
// Meldung des Briefs; ist er schon zu, geht sie an die Liste // Meldung des Briefs; ist er schon zu, geht sie an die Liste und nennt den Eintrag (der Leser sieht nicht mehr, zu welchem Brief sie gehört)
const report = (next) => { const report = (next) => {
if (aliveRef.current) setNote(next) if (aliveRef.current) {
else if (typeof onSpaet === 'function') onSpaet(next) setNote(next)
return
}
if (typeof onSpaet !== 'function') return
const titel = typeof row.title === 'string' ? row.title.trim() : ''
onSpaet(titel === '' ? next : { ...next, text: `„${titel}“: ${next.text}` })
} }
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb bei der Liste und nicht im Brief. // Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb bei der Liste und nicht im Brief.
@@ -198,8 +209,11 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
} catch (err) { } catch (err) {
report({ kind: 'err', text: actionErrorText(err, action) }) report({ kind: 'err', text: actionErrorText(err, action) })
} }
try {
await refresh() await refresh()
end() } finally {
end() // auch wenn das Neuladen scheitert: die Sperre des Eintrags darf nicht hängen bleiben
}
return ok return ok
} }
@@ -209,6 +223,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
// Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis. // Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
const copyOpen = async () => { const copyOpen = async () => {
if (!begin()) return if (!begin()) return
try {
const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab }) const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
let next = copyNote(result, row.sourceHref !== null) let next = copyNote(result, row.sourceHref !== null)
if (!row.claimedByMe) { if (!row.claimedByMe) {
@@ -221,10 +236,13 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
await refresh() await refresh()
} }
report(next) report(next)
end() } finally {
end() // die Sperre des Eintrags darf nie hängen bleiben
}
} }
const discard = () => { const discard = () => {
if (gesperrt) return
if (confirming !== 'discard') { if (confirming !== 'discard') {
setConfirming('discard') setConfirming('discard')
return return
@@ -235,6 +253,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster). // Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster).
// Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5). // Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5).
const approve = () => { const approve = () => {
if (gesperrt) return
if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab
if (confirming !== 'approve') { if (confirming !== 'approve') {
setConfirming('approve') setConfirming('approve')
@@ -246,6 +265,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
// Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben // Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben
// fragt der erste Klick nach, der zweite binnen 4 s startet den Bau. // fragt der erste Klick nach, der zweite binnen 4 s startet den Bau.
const startBuild = () => { const startBuild = () => {
if (gesperrt) return
if (confirming !== 'build') { if (confirming !== 'build') {
setConfirming('build') setConfirming('build')
return return
@@ -257,7 +277,9 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
// vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text Schließen und einen Wechsel des Filters überlebt. // vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text Schließen und einen Wechsel des Filters überlebt.
const replyValue = fields.reply ?? text const replyValue = fields.reply ?? text
const pasteValue = fields.paste ?? '' const pasteValue = fields.paste ?? ''
const toggle = (which) => setOpen((current) => (current === which ? null : which)) const toggle = (which) => {
if (!gesperrt) setOpen((current) => (current === which ? null : which))
}
// Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin. // Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin.
// Der Knopf trägt seine Kennung in data-opens. // Der Knopf trägt seine Kennung in data-opens.
const close = (which) => { const close = (which) => {
@@ -307,7 +329,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (open !== null) { if (open !== null) {
if (!busy) close(open) if (!gesperrt) close(open)
return return
} }
onClose() onClose()
@@ -390,6 +412,10 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
) )
)} )}
{laeuft === true && !busy && (
<p className="hk-brief-hinweis hk-brief-laeuft" role="status">Für diesen Eintrag läuft noch eine Anfrage. Die Aktionen sind frei, sobald sie fertig ist.</p>
)}
{hasActions ? ( {hasActions ? (
<> <>
<h3 className="hk-brief-h hk-brief-h-aktionen">Aktionen</h3> <h3 className="hk-brief-h hk-brief-h-aktionen">Aktionen</h3>
@@ -400,7 +426,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
: <span className="hk-brief-hinweis">Keine gültige E-Mail-Adresse hinterlegt.</span> : <span className="hk-brief-hinweis">Keine gültige E-Mail-Adresse hinterlegt.</span>
)} )}
{row.canSend && row.kind !== 'mail_antwort' && ( {row.canSend && row.kind !== 'mail_antwort' && (
<Stempel art="haupt" icon="kopieren" onClick={copyOpen} disabled={busy}> <Stempel art="haupt" icon="kopieren" onClick={copyOpen} disabled={gesperrt}>
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'} {row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
</Stempel> </Stempel>
)} )}
@@ -410,7 +436,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
: <span className="hk-brief-hinweis">Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.</span> : <span className="hk-brief-hinweis">Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.</span>
)} )}
{row.canClaim && ( {row.canClaim && (
<Stempel art="normal" icon="uebernehmen" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}> <Stempel art="normal" icon="uebernehmen" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={gesperrt}>
Ich kümmere mich Ich kümmere mich
</Stempel> </Stempel>
)} )}
@@ -420,42 +446,42 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
icon="gesendet" icon="gesendet"
aus={text.trim() === ''} aus={text.trim() === ''}
onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)} onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)}
disabled={busy || text.trim() === ''} disabled={gesperrt || text.trim() === ''}
> >
Als gesendet markieren Als gesendet markieren
</Stempel> </Stempel>
)} )}
{row.canReply && ( {row.canReply && (
<Stempel art="normal" icon="antworten" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="reply" disabled={busy}> <Stempel art="normal" icon="antworten" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="reply" disabled={gesperrt}>
Als Mensch antworten Als Mensch antworten
</Stempel> </Stempel>
)} )}
{row.canApprovePreview && ( {row.canApprovePreview && (
<Stempel art="gruen" icon="gesendet" frage={confirming === 'approve'} onClick={approve} disabled={busy || !row.previewHref}> <Stempel art="gruen" icon="gesendet" frage={confirming === 'approve'} onClick={approve} disabled={gesperrt || !row.previewHref}>
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'} {confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
</Stempel> </Stempel>
)} )}
{row.canStartBuild && ( {row.canStartBuild && (
<> <>
<Stempel art="normal" icon="bauen" frage={confirming === 'build'} onClick={startBuild} disabled={busy} title={BUILD_HINT}> <Stempel art="normal" icon="bauen" frage={confirming === 'build'} onClick={startBuild} disabled={gesperrt} title={BUILD_HINT}>
{confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'} {confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'}
</Stempel> </Stempel>
{confirming === 'build' && <span className="hk-brief-hinweis hk-brief-hinweis-bau">{BUILD_HINT}</span>} {confirming === 'build' && <span className="hk-brief-hinweis hk-brief-hinweis-bau">{BUILD_HINT}</span>}
</> </>
)} )}
{row.canPaste && ( {row.canPaste && (
<Stempel art="normal" icon="einfuegen" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={busy}> <Stempel art="normal" icon="einfuegen" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={gesperrt}>
Antwort des Kunden einfügen Antwort des Kunden einfügen
</Stempel> </Stempel>
)} )}
{row.canDone && ( {row.canDone && (
<Stempel art="normal" icon="erledigt" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}> <Stempel art="normal" icon="erledigt" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={gesperrt}>
Erledigt Erledigt
</Stempel> </Stempel>
)} )}
{row.canDiscard && ( {row.canDiscard && (
<> <>
<Stempel art="rot" icon="verwerfen" frage={confirming === 'discard'} onClick={discard} disabled={busy}> <Stempel art="rot" icon="verwerfen" frage={confirming === 'discard'} onClick={discard} disabled={gesperrt}>
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'} {confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Stempel> </Stempel>
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */} {/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
@@ -478,7 +504,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
submitLabel="Jetzt senden" submitLabel="Jetzt senden"
onSubmit={sendReply} onSubmit={sendReply}
onClose={() => close('reply')} onClose={() => close('reply')}
busy={busy} busy={gesperrt}
/> />
)} )}
{open === 'paste' && row.canPaste && ( {open === 'paste' && row.canPaste && (
@@ -492,7 +518,7 @@ export default function Brief({ api, item, row, text, fields, onText, onField, r
submitLabel="Einfügen" submitLabel="Einfügen"
onSubmit={sendPaste} onSubmit={sendPaste}
onClose={() => close('paste')} onClose={() => close('paste')}
busy={busy} busy={gesperrt}
/> />
)} )}
+3 -3
View File
@@ -10,8 +10,8 @@ import { SZENE_START, SZENE_TEILE as T, szenePassung } from '../../../lib/hustle
const lage = (teil) => ({ left: teil.left, top: teil.top }) const lage = (teil) => ({ left: teil.left, top: teil.top })
const flaeche = (teil) => ({ left: teil.left, top: teil.top, width: teil.width, height: teil.height }) const flaeche = (teil) => ({ left: teil.left, top: teil.top, width: teil.width, height: teil.height })
// Props: laeuft (Boten-Schleife an, Standard aus) // Props: laeuft (Boten-Schleife an, Standard aus), steht (Hustler angehalten: alles in der Szene steht still, Klasse hk-steht aus hustler-ui.css, auch Boten und Fähnchen)
export default function PultSzene({ laeuft = false }) { export default function PultSzene({ laeuft = false, steht = false }) {
const rahmen = useRef(null) const rahmen = useRef(null)
const [pass, setPass] = useState(SZENE_START) const [pass, setPass] = useState(SZENE_START)
@@ -34,7 +34,7 @@ export default function PultSzene({ laeuft = false }) {
}, []) }, [])
return ( return (
<div className={laeuft ? 'hk-pult-szene' : 'hk-pult-szene hk-pult-ruhe'} ref={rahmen} aria-hidden="true"> <div className={`hk-pult-szene${laeuft ? '' : ' hk-pult-ruhe'}${steht === true ? ' hk-steht' : ''}`} ref={rahmen} aria-hidden="true">
<div className="hk-pult-buehne" style={{ transform: pass.transform }}> <div className="hk-pult-buehne" style={{ transform: pass.transform }}>
<div className="hk-sz-ebene hk-sz-bild" /> <div className="hk-sz-ebene hk-sz-bild" />
<div className="hk-pult-teil hk-pult-fenster" style={flaeche(T.fenster1)}> <div className="hk-pult-teil hk-pult-fenster" style={flaeche(T.fenster1)}>
+9 -2
View File
@@ -3,7 +3,7 @@
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Beispieldaten gibt es hier nicht; alle Werte kommen aus dem Dienst. // Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Beispieldaten gibt es hier nicht; alle Werte kommen aus dem Dienst.
import { ROOM_FILTERS, botleadRow } from './botleadsView.js' import { ROOM_FILTERS, botleadRow } from './botleadsView.js'
import { grundInfo } from './botleadGruende.js' import { grundInfo } from './botleadGruende.js'
import { figurZustand } from './hustlerSicht.js' import { anzeigeName, figurZustand, modellKurz } from './hustlerSicht.js'
import { ROLES } from './workersRoster.js' import { ROLES } from './workersRoster.js'
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
@@ -61,9 +61,16 @@ export const LEDS = Object.freeze([
/** /**
* Was die Szene von Arne zeigt (Spec E12): bei „arbeitet“ die Ablauf-Schleife mit Rohrpost, Stempel und Schublade, sonst steht oder sitzt er als Haus-Figur der * Was die Szene von Arne zeigt (Spec E12): bei „arbeitet“ die Ablauf-Schleife mit Rohrpost, Stempel und Schublade, sonst steht oder sitzt er als Haus-Figur der
* passenden Pose (warten, schlafen, nicht) am Pult und die drei ruhen; bei „fehler“ trägt er den roten Marker. workers = Hustler-Tabelle der Seite. * passenden Pose (warten, schlafen, nicht) am Pult und die drei ruhen; bei „fehler“ trägt er den roten Marker. workers = Hustler-Tabelle der Seite.
* Gibt es für Arne noch keinen Eintrag (Tabelle leer, Seite hat den ersten Zustand noch nicht): er steht neutral am Pult (status 'unbekannt', Pose „warten“ ohne
* Kaffeepunkte), ohne Zzz, ohne Marker und ohne „nicht angeschlossen“; wir wissen nichts über ihn und behaupten nichts (T13 R5). Ein Eintrag mit unbrauchbarem Status
* gilt weiter als Leerlauf (figurZustand).
*/ */
export function arneAnsicht(workers) { export function arneAnsicht(workers) {
const f = figurZustand('arne', workers && typeof workers === 'object' ? workers.arne : undefined) const eintrag = workers && typeof workers === 'object' ? workers.arne : undefined
if (eintrag === null || typeof eintrag !== 'object' || Array.isArray(eintrag)) {
return { status: 'unbekannt', arbeitet: false, pose: 'warten', alarm: false, punkte: false, zzz: false, label: `${anzeigeName('arne')}, ${modellKurz('arne')}` }
}
const f = figurZustand('arne', eintrag)
return { return {
status: f.status, status: f.status,
arbeitet: f.status === 'arbeitet', arbeitet: f.status === 'arbeitet',
+19 -4
View File
@@ -43,11 +43,21 @@
.hk-keller .hk-knopf:disabled { opacity: 0.45; cursor: default; } .hk-keller .hk-knopf:disabled { opacity: 0.45; cursor: default; }
.hk-keller .hk-knopf.hk-kl-zuklappen { margin-left: auto; } .hk-keller .hk-knopf.hk-kl-zuklappen { margin-left: auto; }
.hk-keller .hk-kl-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; } .hk-keller .hk-kl-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; }
.hk-keller .hk-kl-titel:focus-visible { outline: 2px solid #1a1630; outline-offset: 4px; }
.hk-keller .hk-kl-titel, .hk-keller .hk-kl-zahl { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; } .hk-keller .hk-kl-titel, .hk-keller .hk-kl-zahl { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; }
/* Raum-Reiter: sitzen oberhalb des Buchs, die inaktiven ragen 8 px hinter das Buch */ /* Raum-Reiter: sitzen oberhalb des Buchs, die inaktiven ragen 8 px hinter das Buch */
.hk-keller .hk-kl-reiter { position: relative; z-index: 1; flex: none; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; } .hk-keller .hk-kl-reiter { position: relative; z-index: 1; flex: none; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; }
.hk-keller .hk-kl-reiter-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 8px; color: #5a4a3a; } .hk-keller .hk-kl-reiter-zahl { font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 13px; line-height: 16px; color: #5a4a3a; }
/* Fokus am Raum-Reiter innen (T13 M3): außen klebte der Ring am Buch und wurde am Handy vom wischbaren Reiterstreifen abgeschnitten. Der Kasten-Schatten des Reiters
(sein „Rand“) bleibt, der Ring liegt als gelbe Innenkante darüber. */
.hk-keller .hk-kl-reiter .hk-reiter:focus-visible {
outline: 2px solid #1a1630; outline-offset: -2px;
box-shadow: 0 -2px 0 0 #2b2230, -2px 0 0 0 #2b2230, 2px 0 0 0 #2b2230, inset 0 0 0 4px #ffd25a;
}
.hk-keller .hk-kl-reiter .hk-reiter-an:focus-visible {
box-shadow: 0 -2px 0 0 #7a2e3a, -2px 0 0 0 #7a2e3a, 2px 0 0 0 #7a2e3a, inset 0 0 0 4px #ffd25a;
}
/* Buch: Kopf mit Titel, Hinweis und Suche, Kopfzeile, Liste, Fuß mit Seitenzeile */ /* Buch: Kopf mit Titel, Hinweis und Suche, Kopfzeile, Liste, Fuß mit Seitenzeile */
.hk-keller .hk-kl-buch { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 6px; } .hk-keller .hk-kl-buch { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
@@ -74,7 +84,7 @@
.hk-keller .hk-kl-farbe-mittel { background: #c9962a; } .hk-keller .hk-kl-farbe-mittel { background: #c9962a; }
.hk-keller .hk-kl-farbe-schlecht { background: #b8452f; } .hk-keller .hk-kl-farbe-schlecht { background: #b8452f; }
.hk-keller .hk-kl-farbe-leer { background: #9a8f84; } .hk-keller .hk-kl-farbe-leer { background: #9a8f84; }
.hk-keller .hk-kl-punkte-text { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 8px; } .hk-keller .hk-kl-punkte-text { font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 13px; line-height: 16px; }
/* Laden, Leer, Fehler */ /* Laden, Leer, Fehler */
.hk-keller .hk-kl-laden, .hk-keller .hk-kl-leer, .hk-keller .hk-kl-fehlerbox { margin: 8px; color: #5a4a3a; } .hk-keller .hk-kl-laden, .hk-keller .hk-kl-leer, .hk-keller .hk-kl-fehlerbox { margin: 8px; color: #5a4a3a; }
@@ -85,13 +95,14 @@
/* Fuß: ◂ Seitenzeile ▸ */ /* Fuß: ◂ Seitenzeile ▸ */
.hk-keller .hk-kl-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 2px; } .hk-keller .hk-kl-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 2px; }
.hk-keller .hk-kl-seiten { flex: 1 1 auto; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 8px; line-height: 12px; color: #5a4a3a; text-align: center; } .hk-keller .hk-kl-seiten { flex: 1 1 auto; font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 13px; line-height: 16px; color: #5a4a3a; text-align: center; }
/* Nur für Screenreader */ /* Nur für Screenreader */
.hk-keller .hk-kl-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .hk-keller .hk-kl-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* ---------- Akte: Dialog auf der Manila-Karte ---------- */ /* ---------- Akte: Dialog auf der Manila-Karte ---------- */
.hk-keller .hk-kl-akte-ueber { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; } /* z-index 9: über Szene und Buch (bis 2), aber unter der Handy-Seitenleiste (20) und ihrem Hintergrund (15) der Seite (T13 R4) */
.hk-keller .hk-kl-akte-ueber { position: absolute; inset: 0; z-index: 9; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; }
.hk-keller .hk-kl-akte-hinten { .hk-keller .hk-kl-akte-hinten {
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0; position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0;
background: rgba(6, 7, 18, 0.62); cursor: default; background: rgba(6, 7, 18, 0.62); cursor: default;
@@ -132,6 +143,10 @@
.hk-keller .hk-kl-buch .hk-zeile, .hk-keller .hk-kl-buch .hk-kopfzeile { grid-template-columns: 32px minmax(0, 1fr) minmax(0, 38%); } .hk-keller .hk-kl-buch .hk-zeile, .hk-keller .hk-kl-buch .hk-kopfzeile { grid-template-columns: 32px minmax(0, 1fr) minmax(0, 38%); }
.hk-keller .hk-kl-buch .hk-kl-spalte-punkte, .hk-keller .hk-kl-buch .hk-kl-spalte-telefon { display: none; } .hk-keller .hk-kl-buch .hk-kl-spalte-punkte, .hk-keller .hk-kl-buch .hk-kl-spalte-telefon { display: none; }
.hk-keller .hk-kl-akte-name { font-size: 12px; line-height: 18px; } .hk-keller .hk-kl-akte-name { font-size: 12px; line-height: 18px; }
/* Tippflächen (T13 K7): Suchfeld und Textlink der Akte mindestens 40 px hoch; die Lupe bleibt im Feld mittig */
.hk-keller input.hk-suche { min-height: 40px; }
.hk-keller .hk-kl-lupe { top: 4px; }
.hk-keller .hk-akte-link { min-height: 40px; }
.hk-keller .hk-kl-akte-ueber { padding: 0; } .hk-keller .hk-kl-akte-ueber { padding: 0; }
.hk-keller .hk-akte.hk-kl-akte { width: 100%; height: 100%; max-height: none; } .hk-keller .hk-akte.hk-kl-akte { width: 100%; height: 100%; max-height: none; }
} }
+8 -3
View File
@@ -27,6 +27,7 @@
.hk-pult .hk-pult-zuklappen { margin-left: auto; } .hk-pult .hk-pult-zuklappen { margin-left: auto; }
.hk-pult .hk-pult-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; } .hk-pult .hk-pult-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; }
.hk-pult .hk-pult-h1 { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; } .hk-pult .hk-pult-h1 { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; }
.hk-pult .hk-pult-h1:focus-visible { outline: 2px solid #1a1630; outline-offset: 4px; }
.hk-pult .hk-pult-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; } .hk-pult .hk-pult-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; }
/* Nur für Screenreader (Beschriftung vor der Zahl) */ /* Nur für Screenreader (Beschriftung vor der Zahl) */
.hk-pult .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; } .hk-pult .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; }
@@ -71,7 +72,8 @@
.hk-pult .hk-marke-veraltet { color: #8a4a20; } .hk-pult .hk-marke-veraltet { color: #8a4a20; }
/* ---------- Brief: Dunkelfläche mit Hintergrund-Knopf, Papier in der Mitte, Inhalt scrollt ---------- */ /* ---------- Brief: Dunkelfläche mit Hintergrund-Knopf, Papier in der Mitte, Inhalt scrollt ---------- */
.hk-pult .hk-brief-ueber { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; } /* z-index 9: über der Szene und dem Klemmbrett (bis 2), aber unter der Handy-Seitenleiste (20) und ihrem Hintergrund (15) der Seite (T13 R4) */
.hk-pult .hk-brief-ueber { position: absolute; inset: 0; z-index: 9; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; }
.hk-pult .hk-brief-hinten { .hk-pult .hk-brief-hinten {
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0; background: rgba(6, 7, 18, 0.6); cursor: default; position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0; background: rgba(6, 7, 18, 0.6); cursor: default;
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent; -webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
@@ -94,18 +96,19 @@
.hk-pult .hk-brief-h-aktionen { margin: 16px 0 6px; } .hk-pult .hk-brief-h-aktionen { margin: 16px 0 6px; }
.hk-pult .hk-brief-zitat { .hk-pult .hk-brief-zitat {
margin: 0; padding: 8px 10px; background: #f1ead6; box-shadow: inset 4px 0 0 0 #c9a25c; margin: 0; padding: 8px 10px; background: #f1ead6; box-shadow: inset 4px 0 0 0 #c9a25c;
white-space: pre-line; font-size: 14px; line-height: 18px; overflow-wrap: anywhere; white-space: pre-wrap; font-size: 14px; line-height: 18px; overflow-wrap: anywhere;
} }
.hk-pult .hk-feld-label { display: block; margin-top: 14px; } .hk-pult .hk-feld-label { display: block; margin-top: 14px; }
.hk-pult .hk-feld-titel { display: block; margin-bottom: 4px; font-size: 13px; line-height: 16px; color: #4a2fa0; } .hk-pult .hk-feld-titel { display: block; margin-bottom: 4px; font-size: 13px; line-height: 16px; color: #4a2fa0; }
.hk-pult .hk-brief-entwurf { min-height: 96px; background: repeating-linear-gradient(#fffdf6 0 17px, rgba(90, 60, 176, 0.16) 17px 18px); } .hk-pult .hk-brief-entwurf { min-height: 96px; background: repeating-linear-gradient(#fffdf6 0 17px, rgba(90, 60, 176, 0.16) 17px 18px); }
.hk-pult .hk-brief-lesen { .hk-pult .hk-brief-lesen {
margin: 0; padding: 2px 10px 4px; white-space: pre-line; font-size: 15px; line-height: 20px; overflow-wrap: anywhere; margin: 0; padding: 2px 10px 4px; white-space: pre-wrap; font-size: 15px; line-height: 20px; overflow-wrap: anywhere;
background: repeating-linear-gradient(transparent 0 19px, rgba(90, 60, 176, 0.18) 19px 20px); background: repeating-linear-gradient(transparent 0 19px, rgba(90, 60, 176, 0.18) 19px 20px);
} }
.hk-pult .hk-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; } .hk-pult .hk-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
.hk-pult .hk-brief-hinweis { margin: 0; font-size: 13px; color: #5a4a3a; } .hk-pult .hk-brief-hinweis { margin: 0; font-size: 13px; color: #5a4a3a; }
.hk-pult .hk-brief-hinweis-bau { color: #7a2e3a; } .hk-pult .hk-brief-hinweis-bau { color: #7a2e3a; }
.hk-pult .hk-brief-laeuft { margin-top: 12px; }
.hk-pult .hk-stempel:disabled { opacity: 0.5; cursor: default; } .hk-pult .hk-stempel:disabled { opacity: 0.5; cursor: default; }
.hk-pult a.hk-stempel { text-decoration: none; } .hk-pult a.hk-stempel { text-decoration: none; }
.hk-pult .hk-feld-panel { margin-top: 14px; } .hk-pult .hk-feld-panel { margin-top: 14px; }
@@ -131,6 +134,8 @@
.hk-pult .hk-pult-schild { order: 2; flex: 1 1 100%; } .hk-pult .hk-pult-schild { order: 2; flex: 1 1 100%; }
.hk-pult .hk-klammer { display: none; } .hk-pult .hk-klammer { display: none; }
.hk-pult .hk-brief-ueber { padding: 0; } .hk-pult .hk-brief-ueber { padding: 0; }
/* Textlinks im Kopf des Briefs („Anzeige öffnen“, „Interessent“): mindestens 40 px hoch zum Antippen (T13 K7) */
.hk-pult .hk-link { min-height: 40px; }
.hk-pult .hk-brief { width: 100%; height: 100%; max-height: 100%; } .hk-pult .hk-brief { width: 100%; height: 100%; max-height: 100%; }
.hk-pult .hk-feld-fuss { flex-direction: column; align-items: stretch; } .hk-pult .hk-feld-fuss { flex-direction: column; align-items: stretch; }
.hk-pult .hk-feld-knoepfe { flex-direction: column-reverse; } .hk-pult .hk-feld-knoepfe { flex-direction: column-reverse; }
+212 -5
View File
@@ -1,6 +1,7 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { existsSync, readFileSync, statSync } from 'node:fs' import { existsSync, readFileSync, statSync } from 'node:fs'
import { inflateSync as zlibSync } from 'node:zlib'
import path from 'node:path' import path from 'node:path'
import { loadModule } from './load.mjs' import { loadModule } from './load.mjs'
import { import {
@@ -101,19 +102,104 @@ test('Arne arbeitet nicht: Haus-Figur in der Pose des Status am Pult, Rohrpost,
} }
}) })
test('Arne ohne Zustand (Tabelle noch leer, kein Eintrag): er schläft; der Status wechselt mit den Daten der Seite', async () => { // T13 R5 (ersetzt „er schläft“): solange es keinen Eintrag für Arne gibt, weiß die Seite nichts über ihn; er steht neutral am Pult – ohne Zzz (schläft), ohne Kaffeepunkte
// (wartet auf Auftrag), ohne Marker und ohne die „nicht“-Pose (nicht angeschlossen). Mit den ersten Daten wechselt er in den echten Zustand.
test('Arne ohne Zustand (Tabelle noch leer, kein Eintrag): er steht neutral am Pult, ohne Zzz, Punkte, Marker und „nicht angeschlossen“; der Status wechselt mit den Daten der Seite', async () => {
const neutral = (ui, wo) => {
assert.equal(sz(ui).props['data-hk-arne'], 'unbekannt', wo)
assert.deepEqual(byClass(sz(ui), 'hk-kl-fig').map(classesOf), [['hk-spr', 'hk-kl-fig', 'hk-kl-fig-warten']], wo) // ruhige Haltung, keine Schlaf- oder „nicht“-Pose
for (const c of ['hk-kl-zzz', 'hk-kl-punkte', 'hk-alarm', 'hk-kl-fig-schlafen', 'hk-kl-fig-nicht', 'hk-arne-weg', 'hk-arne-bild']) assert.equal(has(ui, c), false, `${wo}: ${c}`)
for (const c of RUHENDE) assert.ok(classesOf(byClass(sz(ui), c)[0]).includes('hk-kl-ruht'), `${wo}: ${c} läuft`) // Rohrpost, Stempel und Schublade ruhen
}
const { ui } = await szene(undefined) const { ui } = await szene(undefined)
try { try {
assert.equal(sz(ui).props['data-hk-arne'], 'leerlauf') neutral(ui, 'ohne Tabelle')
assert.ok(has(ui, 'hk-kl-fig-schlafen') && has(ui, 'hk-kl-zzz'))
ui.setProps({ workers: { arne: eintrag('arbeitet') } }) ui.setProps({ workers: { arne: eintrag('arbeitet') } })
assert.ok(has(ui, 'hk-arne-weg') && !has(ui, 'hk-kl-fig')) assert.ok(has(ui, 'hk-arne-weg') && !has(ui, 'hk-kl-fig'))
ui.setProps({ workers: { arne: eintrag('fehler') } }) ui.setProps({ workers: { arne: eintrag('fehler') } })
assert.ok(!has(ui, 'hk-arne-weg') && has(ui, 'hk-kl-fig-nicht') && has(ui, 'hk-alarm')) assert.ok(!has(ui, 'hk-arne-weg') && has(ui, 'hk-kl-fig-nicht') && has(ui, 'hk-alarm'))
ui.setProps({ workers: { arne: eintrag('wartet') } }) ui.setProps({ workers: { arne: eintrag('wartet') } })
assert.ok(has(ui, 'hk-kl-fig-warten') && has(ui, 'hk-kl-punkte') && !has(ui, 'hk-alarm')) assert.ok(has(ui, 'hk-kl-fig-warten') && has(ui, 'hk-kl-punkte') && !has(ui, 'hk-alarm'))
ui.setProps({ workers: {} }) ui.setProps({ workers: { arne: eintrag('leerlauf') } })
assert.ok(has(ui, 'hk-kl-fig-schlafen')) assert.ok(has(ui, 'hk-kl-fig-schlafen') && has(ui, 'hk-kl-zzz') && !has(ui, 'hk-kl-punkte')) // ein echter Leerlauf schläft weiterhin
ui.setProps({ workers: { arne: eintrag('grau') } })
assert.ok(has(ui, 'hk-kl-fig-nicht') && !has(ui, 'hk-kl-zzz') && !has(ui, 'hk-alarm'))
// Tabelle leer ({}), Eintrag für Arne fehlt (nur andere Hustler), Eintrag ist keine Tabellenzeile: neutral
for (const workers of [{}, { otto: eintrag('arbeitet') }, { arne: null }, { arne: 'arbeitet' }, undefined]) {
ui.setProps({ workers })
neutral(ui, JSON.stringify(workers))
}
} finally { ui.end() }
})
// T13 R3: Prop paused (Hustler angehalten) → hk-steht an der Szene; die Regel der Seite (hustler-ui.css) hält darunter alle Animationen an (Arne, Rohrpost, Maus, Licht)
test('Szene (T13 R3): paused setzt hk-steht an der Keller-Szene (auch bei arbeitendem Arne), sonst nicht; unbrauchbare Werte gelten als „läuft“', async () => {
const klasse = async (initial) => {
const { ui } = await szene(undefined)
ui.end()
const lauf = await start(fakeApi(), initial)
try { return classesOf(sz(lauf)).join(' ') } finally { lauf.end() }
}
assert.equal(await klasse({ paused: true }), 'hk-kl-szene hk-steht')
assert.equal(await klasse({ paused: true, workers: { arne: eintrag('arbeitet') } }), 'hk-kl-szene hk-steht')
assert.equal(await klasse({ paused: false }), 'hk-kl-szene')
assert.equal(await klasse({}), 'hk-kl-szene')
for (const unsinn of ['ja', 1, null, {}]) assert.equal(await klasse({ paused: unsinn }), 'hk-kl-szene', String(unsinn))
// die Szene ist die Wurzel dieser Animationen und steckt unter .hk-seite (WorkersPage), wo die Regel gilt
assert.match(read('src/styles/hustler/hustler-ui.css'), /\.hk-seite \.hk-steht \*[^{]*\{[^}]*animation-play-state:\s*paused\s*!important/)
// der Wechsel von „läuft“ zu „steht“ und zurück gilt sofort
const lauf = await start(fakeApi(), {})
try {
assert.equal(classesOf(sz(lauf)).includes('hk-steht'), false)
lauf.setProps({ paused: true }); assert.equal(classesOf(sz(lauf)).includes('hk-steht'), true)
lauf.setProps({ paused: false }); assert.equal(classesOf(sz(lauf)).includes('hk-steht'), false)
} finally { lauf.end() }
})
// T13 M2: Bildschirmwechsel – die Überschrift „Bot-Leads“ nimmt beim Einhängen den Fokus
test('Keller (T13 M2): beim Einhängen nimmt die Überschrift „Bot-Leads“ den Fokus (tabIndex -1, preventScroll), nur einmal; ohne Element bricht nichts', async () => {
const aufrufe = []
const h1 = { focus: (options) => aufrufe.push(options) }
globalThis.__fakeRef = (tag) => (tag === 'h1' ? h1 : null)
const api = fakeApi()
const ui = await start(api, {})
try {
const titel = nodes(ui.tree(), (n) => n.tag === 'h1')
assert.equal(titel.length, 1); assert.equal(titel[0].props.tabIndex, -1); assert.equal(textOf(titel[0]), 'Bot-Leads')
assert.deepEqual(aufrufe, [{ preventScroll: true }])
// Laden, Raumwechsel, Suche und Akte holen den Fokus nicht wieder an die Überschrift
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
await ui.pick(1); await ui.pick(0)
await ui.type('Elektro'); ui.fireTimers(); await settle()
assert.equal(aufrufe.length, 1)
} finally { ui.end(); delete globalThis.__fakeRef }
const ohne = await start(fakeApi(), {}) // kein ref: nichts zu fokussieren
try { assert.equal(nodes(ohne.tree(), (n) => n.tag === 'h1').length, 1) } finally { ohne.end() }
assert.match(cssOhne('src/styles/hustler/hustler-keller-ui.css'), /\.hk-keller \.hk-kl-titel:focus-visible\s*\{[^}]*outline:\s*2px solid/)
})
// T13 K4 (Keller): „Register zuklappen“ sagt, ob das Register offen ist – die Prüfung war schon da, hier wird sie festgehalten
test('Keller (T13 K4): „Register zuklappen“ trägt aria-expanded und folgt dem Zustand', async () => {
const ui = await start(fakeApi(), {})
try {
assert.equal(buttons(ui.tree(), 'Register zuklappen')[0].props['aria-expanded'], true)
await ui.click('Register zuklappen')
assert.equal(buttons(ui.tree(), 'Register aufklappen')[0].props['aria-expanded'], false)
} finally { ui.end() }
})
// T13 W2: die Ortszeile kürzt mit „…“, der Browser-Tooltip nennt den vollen Ort samt Datum
test('Zeile (T13 W2): die Ortszeile trägt title mit vollem Ort und Funddatum; ohne Ort und Datum kein leeres title', async () => {
const ui = await start(fakeApi(), {})
try {
await ui.answer(0, { items: [{ ...ITEM, city: 'Kaiserslautern-Hohenecken-Am-Lauterstrom', postalCode: '67655' }, { ...ITEM, id: 'p9', city: '', postalCode: '', foundAt: '' }], total: 2 })
const zeilen = ui.rows()
const klein = (r) => byClass(r, 'hk-klein')[0]
assert.equal(klein(zeilen[0]).props.title, '67655 Kaiserslautern-Hohenecken-Am-Lauterstrom · 19.10.2026')
assert.equal(klein(zeilen[0]).props.title, textOf(klein(zeilen[0]))) // title = sichtbarer Text, nur ungekürzt
// ohne Ort und Datum: botleadRow liefert Platzhalter oder nichts; ein title ist nie leer
assert.ok(klein(zeilen[1]).props.title === undefined || klein(zeilen[1]).props.title !== '')
assert.equal(klein(zeilen[1]).props.title, textOf(klein(zeilen[1])) === '' ? undefined : textOf(klein(zeilen[1])))
} finally { ui.end() } } finally { ui.end() }
}) })
@@ -740,3 +826,124 @@ test('hustler-keller-ui.css: Überschreibungen erzeugter Regeln haben höhere Sp
} }
} }
}) })
// ---------------------------------------------------------------------------------------------------------------
// T13 (Endprüfung): Schrift, Tippflächen, Fokusring der Reiter, Stapelreihenfolge, Kontrast
const eigeneRegeln = (selektor, kontext = '') => ui.regeln.filter((r) => r.kontext === kontext && r.selektor.split(',').map((t) => t.trim()).includes(selektor))
const handyRegeln = (selektor) => ui.regeln.filter((r) => /max-width:\s*719px/.test(r.kontext) && r.selektor.split(',').map((t) => t.trim()).includes(selektor))
const eigenschaft = (regel, name) => new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`).exec(regel.koerper)?.[1].trim()
const pixel = (wert) => Number.parseFloat(wert)
test('hustler-keller-ui.css (T13 K5): Seitenzeile, Punkte-Zahl und Reiterzahl stehen in Pixelify Sans ab 13 px, nicht in Press Start 2P mit 8 px', () => {
for (const klasse of ['hk-kl-seiten', 'hk-kl-punkte-text', 'hk-kl-reiter-zahl']) {
const regeln = ui.regeln.filter((r) => r.selektor.split(',').some((t) => t.trim().endsWith(`.${klasse}`)))
assert.ok(regeln.length >= 1, klasse)
for (const r of regeln) {
const schrift = eigenschaft(r, 'font-family'); const groesse = eigenschaft(r, 'font-size')
if (r.kontext === '') { assert.ok(schrift && groesse, `${klasse}: Schrift und Größe stehen in der Grundregel`) }
if (schrift) { assert.match(schrift, /^'Pixelify Sans'/, klasse); assert.doesNotMatch(schrift, /Press Start 2P/, klasse) }
if (groesse) {
assert.ok(pixel(groesse) >= 13, `${klasse}: ${groesse}`)
const zeile = eigenschaft(r, 'line-height'); if (zeile) assert.ok(pixel(zeile) >= pixel(groesse), `${klasse}: Zeilenhöhe ${zeile}`)
}
}
}
})
test('hustler-keller-ui.css (T13 K7): am Handy sind Suchfeld und der Textlink der Akte mindestens 40 px hoch (Tippfläche), die Lupe bleibt im Feld mittig', () => {
const suche = handyRegeln('.hk-keller input.hk-suche')
assert.equal(suche.length, 1); assert.ok(pixel(eigenschaft(suche[0], 'min-height')) >= 40)
const link = handyRegeln('.hk-keller .hk-akte-link')
assert.equal(link.length, 1); assert.ok(pixel(eigenschaft(link[0], 'min-height')) >= 40)
// das Suchfeld ist am Desktop 36 px hoch (erzeugt); min-height gewinnt dort, wo es nötig ist; die Spezifität der Handy-Regel schlägt die erzeugte (input.hk-suche, 2 Klassen + Element)
assert.match(cssOhne('src/styles/hustler/keller.css'), /\.hk-keller \.hk-suche\{[^}]*height:36px/)
const lupe = handyRegeln('.hk-keller .hk-kl-lupe')
assert.equal(lupe.length, 1); assert.equal(pixel(eigenschaft(lupe[0], 'top')), 4) // 40 px Feld: 4 px oben, das 32-px-Symbol sitzt mittig (vorher 2 px bei 36 px)
// der Link steht in der Zeile des Textes und zentriert ihn (inline-flex), die 40 px sind also Fläche des Links; „Quelle öffnen“ trägt genau diese Klasse
assert.match(cssOhne('src/styles/hustler/keller.css'), /\.hk-keller \.hk-akte-link\{[^}]*display:inline-flex[^}]*align-items:center/)
assert.match(read('src/components/workers/keller/Akte.jsx'), /<a className="hk-akte-link" href=\{e\.sourceHref\}/)
// am Desktop ändert sich nichts: die Mindesthöhe steht nur im Handy-Block
for (const sel of ['.hk-keller input.hk-suche', '.hk-keller .hk-akte-link']) assert.equal(eigeneRegeln(sel).filter((r) => /min-height/.test(r.koerper)).length, 0, sel)
})
// Spezifität grob: Zahl der Klassen und Pseudoklassen (Elementnamen zählen weniger und sind hier nicht nötig)
const spezifitaet = (selektor) => (selektor.match(/\.[\w-]+/g) ?? []).length + (selektor.match(/:[\w-]+/g) ?? []).length
test('hustler-keller-ui.css (T13 M3): Raum-Reiter zeigen den Fokus innen (Umriss nach innen, gelbe Innenkante), auch der aktive; die Regel schlägt die erzeugte', () => {
const erzeugt = zerlege(cssOhne('src/styles/hustler/keller.css')).regeln.find((r) => r.selektor === '.hk-keller .hk-reiter:focus-visible')
assert.ok(erzeugt)
assert.match(erzeugt.koerper, /outline-offset:\s*2px/) // das war der Fehler: außen, am Handy vom wischbaren Reiterstreifen abgeschnitten
const normal = eigeneRegeln('.hk-keller .hk-kl-reiter .hk-reiter:focus-visible')
const aktiv = eigeneRegeln('.hk-keller .hk-kl-reiter .hk-reiter-an:focus-visible')
assert.equal(normal.length, 1); assert.equal(aktiv.length, 1)
assert.equal(eigenschaft(normal[0], 'outline-offset'), '-2px'); assert.match(eigenschaft(normal[0], 'outline'), /^2px solid #1a1630$/)
// der Ring ist innen (inset) und gelb; der Kasten-Schatten, der den Rand des Reiters bildet, bleibt erhalten (box-shadow ersetzt, nicht ergänzt)
assert.match(eigenschaft(normal[0], 'box-shadow'), /inset 0 0 0 4px #ffd25a$/); assert.match(eigenschaft(normal[0], 'box-shadow'), /^0 -2px 0 0 #2b2230, -2px 0 0 0 #2b2230, 2px 0 0 0 #2b2230/)
assert.match(eigenschaft(aktiv[0], 'box-shadow'), /inset 0 0 0 4px #ffd25a$/); assert.match(eigenschaft(aktiv[0], 'box-shadow'), /^0 -2px 0 0 #7a2e3a, -2px 0 0 0 #7a2e3a, 2px 0 0 0 #7a2e3a/)
// höhere Spezifität als die erzeugten Regeln (auch .hk-reiter-an:hover bleibt unberührt)
assert.ok(spezifitaet('.hk-keller .hk-kl-reiter .hk-reiter:focus-visible') > spezifitaet(erzeugt.selektor))
assert.ok(spezifitaet('.hk-keller .hk-kl-reiter .hk-reiter-an:focus-visible') > spezifitaet('.hk-keller .hk-reiter-an,.hk-keller .hk-reiter-an:hover'.split(',')[1]))
// der Reiter steckt wirklich im Streifen .hk-kl-reiter (RegisterBuch)
assert.match(read('src/components/workers/keller/RegisterBuch.jsx'), /className="hk-kl-reiter"[\s\S]*REITER\.map/)
})
test('hustler-keller-ui.css (T13 R4): die Akte liegt unter der Handy-Seitenleiste (z-index 20) und ihrem Hintergrund (15), aber über Szene und Buch', () => {
const akte = eigeneRegeln('.hk-keller .hk-kl-akte-ueber')[0]
const z = Number(eigenschaft(akte, 'z-index'))
assert.ok(z <= 9, `Akte z-index ${z}`)
const buch = Number(eigenschaft(eigeneRegeln('.hk-keller .hk-kl-buch')[0], 'z-index'))
assert.ok(z > buch, 'über dem Buch')
for (const r of ui.regeln) { const w = eigenschaft(r, 'z-index'); if (w) assert.ok(Number(w) <= 9, `${r.selektor}: z-index ${w}`) }
})
// Kontrast nach WCAG 2.x: relative Leuchtdichte, (hell + 0,05) / (dunkel + 0,05)
const kanal = (c) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }
const leuchtdichte = ([r, g, b]) => 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b)
const kontrast = (a, b) => { const [x, y] = [leuchtdichte(a), leuchtdichte(b)]; return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
const hex = (t) => { const m = /#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})\b/i.exec(t); assert.ok(m, t); return [1, 2, 3].map((i) => Number.parseInt(m[i], 16)) }
const mische = (vorn, alpha, hinten) => vorn.map((v, i) => v * alpha + hinten[i] * (1 - alpha)) // Farbe mit Deckkraft über einem Grund
// RGBA-Pixel einer 8-Bit-PNG (nicht verzahnt; Filter 0–4): das Papier des Buchs liegt als Bild vor (buch.png, „fill“ in der Mitte)
function pngPixel(datei, x, y) {
const d = readFileSync(path.join(ROOT, datei))
const breite = d.readUInt32BE(16); const farbtyp = d[25]; const kanaele = { 2: 3, 6: 4 }[farbtyp]
assert.ok(kanaele && d[24] === 8 && d[28] === 0, 'PNG: 8 Bit, RGB oder RGBA, nicht verzahnt')
const teile = []
for (let i = 8; i < d.length;) { const l = d.readUInt32BE(i); if (d.toString('latin1', i + 4, i + 8) === 'IDAT') teile.push(d.subarray(i + 8, i + 8 + l)); i += 12 + l }
const roh = zlibSync(Buffer.concat(teile))
const zeile = breite * kanaele
let davor = Buffer.alloc(zeile)
for (let row = 0; row <= y; row += 1) {
const filter = roh[row * (zeile + 1)]; const aktuell = Buffer.from(roh.subarray(row * (zeile + 1) + 1, (row + 1) * (zeile + 1)))
for (let i = 0; i < zeile; i += 1) {
const a = i >= kanaele ? aktuell[i - kanaele] : 0; const b = davor[i]; const c = i >= kanaele ? davor[i - kanaele] : 0
const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c)
aktuell[i] = (aktuell[i] + [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][filter]) & 255
}
davor = aktuell
}
return [...davor.subarray(x * kanaele, x * kanaele + 3)]
}
test('Kontrast (T13 C5): grau gestellte Einträge („gekürzt“: Zeilentext und Marke) haben auf dem Papier des Buchs und auf der Hover-Tönung mindestens 4,5 : 1', () => {
const erzeugt = zerlege(cssOhne('src/styles/hustler/keller.css')).regeln
const alt = erzeugt.find((r) => r.selektor === '.hk-keller .hk-zeile-alt')
const marke = eigeneRegeln('.hk-keller .hk-kl-tag')[0]
const hover = erzeugt.find((r) => r.selektor === '.hk-keller .hk-zeile:hover')
assert.ok(alt && marke && hover)
const vordergruende = { 'hk-zeile-alt': hex(eigenschaft(alt, 'color')), 'hk-kl-tag': hex(eigenschaft(marke, 'color')) }
// Papier: Mitte des Buchs (buch.png, 48 x 48); dieselbe Farbe steht als Papierton im CSS (Reiter „Alle“, großer Punkte-Balken)
const papier = pngPixel('src/assets/hustler/keller/ui/buch.png', 24, 24)
assert.deepEqual(papier, hex(eigenschaft(erzeugt.find((r) => r.selektor === '.hk-keller .hk-reiter-alle'), 'background')))
const m = /rgba\(\s*(\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\s*\)/.exec(eigenschaft(hover, 'background'))
assert.ok(m, 'Hover-Tönung ist rgba(…)')
const hoverGrund = mische([Number(m[1]), Number(m[2]), Number(m[3])], Number(m[4]), papier)
for (const [name, farbe] of Object.entries(vordergruende)) {
const aufPapier = kontrast(farbe, papier); const aufHover = kontrast(farbe, hoverGrund)
assert.ok(aufPapier >= 4.5, `${name} auf Papier: ${aufPapier.toFixed(2)} : 1`)
assert.ok(aufHover >= 4.5, `${name} auf Hover: ${aufHover.toFixed(2)} : 1`)
}
// die Rechnung selbst stimmt (Gegenprobe mit bekannten Werten: Schwarz auf Weiß 21 : 1, gleiche Farben 1 : 1)
assert.ok(Math.abs(kontrast([0, 0, 0], [255, 255, 255]) - 21) < 1e-9); assert.equal(kontrast([10, 20, 30], [10, 20, 30]), 1)
})
+14 -4
View File
@@ -111,8 +111,8 @@ test('arneAnsicht: arbeitet ⇒ Ablauf-Schleife; sonst Haus-Figur nach Status (w
assert.deepEqual(arneAnsicht({ arne: eintrag('fehler') }), { status: 'fehler', arbeitet: false, pose: 'nicht', alarm: true, punkte: false, zzz: false, label: 'Arne, Haiku, Fehler' }) assert.deepEqual(arneAnsicht({ arne: eintrag('fehler') }), { status: 'fehler', arbeitet: false, pose: 'nicht', alarm: true, punkte: false, zzz: false, label: 'Arne, Haiku, Fehler' })
}) })
test('arneAnsicht: nur „arbeitet“ arbeitet; ohne Tabelle, ohne Eintrag oder mit Unsinn schläft Arne (Leerlauf)', () => { test('arneAnsicht: nur „arbeitet“ arbeitet; ein Eintrag mit unbrauchbarem Status schläft (Leerlauf)', () => {
for (const workers of [undefined, null, {}, [], 'x', 5, { otto: eintrag('arbeitet') }, { arne: null }, { arne: 'arbeitet' }, { arne: { status: 'irgendwas' } }, { arne: {} }]) { for (const workers of [{ arne: { status: 'irgendwas' } }, { arne: {} }]) {
const a = arneAnsicht(workers) const a = arneAnsicht(workers)
assert.equal(a.arbeitet, false, JSON.stringify(workers)) assert.equal(a.arbeitet, false, JSON.stringify(workers))
assert.equal(a.status, 'leerlauf') assert.equal(a.status, 'leerlauf')
@@ -125,8 +125,18 @@ test('arneAnsicht: nur „arbeitet“ arbeitet; ohne Tabelle, ohne Eintrag oder
assert.equal(arneAnsicht({ arne: { ...eintrag('arbeitet'), connected: false } }).status, 'grau') assert.equal(arneAnsicht({ arne: { ...eintrag('arbeitet'), connected: false } }).status, 'grau')
}) })
// --------------------------------------------------------------------------------------------------------------- // T13 R5: ohne Eintrag für Arne (Tabelle leer, die Seite hat den ersten Zustand noch nicht) ist nichts über ihn bekannt: neutral, kein Schlaf, keine „nicht angeschlossen“-Pose
// Register-Buch test('arneAnsicht (T13 R5): ohne Tabelle oder ohne Eintrag für Arne steht er neutral da – nicht arbeitend, ohne Zzz, ohne Punkte, ohne Marker, nicht „nicht angeschlossen“', () => {
const neutral = { status: 'unbekannt', arbeitet: false, pose: 'warten', alarm: false, punkte: false, zzz: false, label: 'Arne, Haiku' }
for (const workers of [undefined, null, {}, [], 'x', 5, { otto: eintrag('arbeitet') }, { ali: eintrag('fehler') }, { arne: null }, { arne: undefined }, { arne: 'arbeitet' }, { arne: 7 }, { arne: [] }]) {
assert.deepEqual(arneAnsicht(workers), neutral, JSON.stringify(workers))
}
// neutral ist weder einer der Status des Dienstes noch „grau“
for (const status of ['arbeitet', 'wartet', 'leerlauf', 'fehler', 'grau']) assert.notEqual(arneAnsicht({ arne: eintrag(status) }).status, 'unbekannt', status)
assert.equal(arneAnsicht({ arne: eintrag('grau') }).status, 'grau')
// die Pose „warten“ gibt es als Sprite und als Klasse im CSS (sonst stünde Arne unsichtbar da)
assert.ok(CSS.has('hk-kl-fig-warten'))
})
test('REITER: Reihenfolge, Werte und Beschriftungen von ROOM_FILTERS, kurze sichtbare Namen, Symbol und Farbe je Reiter aus keller.css', () => { test('REITER: Reihenfolge, Werte und Beschriftungen von ROOM_FILTERS, kurze sichtbare Namen, Symbol und Farbe je Reiter aus keller.css', () => {
assert.deepEqual(REITER.map((r) => r.value), ROOM_FILTERS.map((f) => f.value)) assert.deepEqual(REITER.map((r) => r.value), ROOM_FILTERS.map((f) => f.value))
+119 -4
View File
@@ -194,8 +194,8 @@ function fakeFocus() {
return f return f
} }
// Ein Lauf des Pults: items gelten für jeden Filter (der Test steuert über die Liste, was sichtbar ist); opts: kpis, onBack, api // Ein Lauf des Pults: items gelten für jeden Filter (der Test steuert über die Liste, was sichtbar ist); opts: kpis, onBack, api, paused
async function startPult(items, { api = { pultAction: async () => ({}) }, kpis, onBack, feed = {} } = {}) { async function startPult(items, { api = { pultAction: async () => ({}) }, kpis, onBack, feed = {}, paused } = {}) {
const { __mount, jsx, PultList } = await loadUi() const { __mount, jsx, PultList } = await loadUi()
const timers = new Map() const timers = new Map()
let nextTimer = 1 let nextTimer = 1
@@ -209,7 +209,7 @@ async function startPult(items, { api = { pultAction: async () => ({}) }, kpis,
globalThis.__pultEmployees = EMPLOYEES globalThis.__pultEmployees = EMPLOYEES
globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra }) globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra })
delete globalThis.__panel delete globalThis.__panel
const mounted = __mount(jsx(PultList, { api, me: ME, kpis, onBack })) const mounted = __mount(jsx(PultList, { api, me: ME, kpis, onBack, paused }))
const view = { const view = {
timers, refreshes, timers, refreshes,
tree: () => mounted.tree(), tree: () => mounted.tree(),
@@ -396,6 +396,120 @@ test('Pult: „Pult zuklappen“ blendet Reiter und Klemmbrett aus (die Szene bl
} finally { ui.end() } } finally { ui.end() }
}) })
test('Pult (T13 P1b): ein spätes Ergebnis nennt den Eintrag („Titel“: …), auch ein später Erfolg; ohne Titel bleibt der Satz wie er ist', async () => {
const f = fakeFocus()
const wartend = []
const api = { pultAction: () => new Promise((resolve, reject) => { wartend.push({ resolve, reject }) }) }
const items = [entry({ $id: 'a', title: 'Suche Webdesigner', kind: 'unsicher', prospectId: 'p1' }), entry({ $id: 'b', title: ' ', kind: 'unsicher', prospectId: 'p1' })]
const ui = await startPult(items, { api })
try {
const zu = () => ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
// Fehler: Titel vorn
await ui.open(0, f.el('u0'))
await ui.press(btn(ui.brief(), 'Ich kümmere mich')[0]); await zu()
wartend[0].reject(Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })); await settle()
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-err').map(textOf), ['„Suche Webdesigner“: Wird schon von einem Kollegen bearbeitet.'])
// Erfolg (Antwort kommt nach dem Schließen): „Du kümmerst dich darum.“ gehört zu diesem Eintrag
await ui.open(0, f.el('u0b'))
await ui.press(btn(ui.brief(), 'Ich kümmere mich')[0]); await zu()
wartend[1].resolve({ item: { $id: 'a' } }); await settle()
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-ok').map(textOf), ['„Suche Webdesigner“: Du kümmerst dich darum.'])
// leerer Titel: kein „„“: …“ vorn
await ui.open(1, f.el('u1'))
await ui.press(btn(ui.brief(), 'Ich kümmere mich')[0]); await zu()
wartend[2].reject(Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })); await settle()
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-err').map(textOf), ['Wird schon von einem Kollegen bearbeitet.'])
} finally { ui.end(); f.restore() }
})
// ---- T13 M2: Bildschirmwechsel – die Überschrift nimmt beim Einhängen den Fokus ----
test('Pult (T13 M2): beim Einhängen nimmt die Überschrift „Freigabe“ den Fokus (tabIndex -1, preventScroll); ohne document oder Element passiert nichts', async () => {
const aufrufe = []
const h1 = { focus: (options) => aufrufe.push(options) }
globalThis.__fakeRef = (tag) => (tag === 'h1' ? h1 : null)
const ui = await startPult([entry()])
try {
const titel = byClass(ui.tree(), 'hk-pult-h1')
assert.equal(titel.length, 1); assert.equal(titel[0].tag, 'h1'); assert.equal(titel[0].props.tabIndex, -1); assert.equal(textOf(titel[0]), 'Freigabe')
assert.deepEqual(aufrufe, [{ preventScroll: true }]) // genau einmal, ohne dass die Seite springt
// ein Filterwechsel oder ein Brief holt den Fokus nicht noch einmal an die Überschrift
await ui.tab('Veraltet'); await ui.tab('Wartet')
await ui.open(0)
assert.equal(aufrufe.length, 1)
} finally { ui.end(); delete globalThis.__fakeRef }
// ohne Element (Server, Node: kein ref gesetzt) bricht nichts
const ohne = await startPult([entry()])
try { assert.equal(byClass(ohne.tree(), 'hk-pult-h1').length, 1) } finally { ohne.end() }
// die Überschrift zeigt den Fokus (sichtbarer Ring) wie die anderen Bedienelemente
assert.match(read('src/styles/hustler/hustler-pult-ui.css'), /\.hk-pult \.hk-pult-h1:focus-visible\s*\{[^}]*outline:\s*2px solid/)
})
// ---- T13 K4: „Pult zuklappen“ sagt, ob das Pult offen ist ----
test('Pult (T13 K4): „Pult zuklappen“ trägt aria-expanded (offen = true, zugeklappt = false)', async () => {
const ui = await startPult([entry()], { kpis: { pultOpen: 1 } })
try {
assert.equal(btn(ui.tree(), 'Pult zuklappen')[0].props['aria-expanded'], true)
await ui.press(btn(ui.tree(), 'Pult zuklappen')[0])
assert.equal(btn(ui.tree(), 'Pult aufklappen')[0].props['aria-expanded'], false)
await ui.press(btn(ui.tree(), 'Pult aufklappen')[0])
assert.equal(btn(ui.tree(), 'Pult zuklappen')[0].props['aria-expanded'], true)
} finally { ui.end() }
})
// ---- T13 R3: paused erreicht die Szene (Boten, Aufzug, Fähnchen, Uhr stehen still) ----
test('Pult (T13 R3): paused setzt hk-steht an der Szene (mit und ohne Boten-Schleife); sonst nicht; das CSS der Seite hält alles darunter an', async () => {
const klassen = async (opts) => {
const ui = await startPult([entry()], opts)
try { return String(byClass(ui.tree(), 'hk-pult-szene')[0].props.className) } finally { ui.end() }
}
assert.equal(await klassen({ kpis: { pultOpen: 3 }, paused: true }), 'hk-pult-szene hk-steht')
assert.equal(await klassen({ kpis: { pultOpen: 0 }, paused: true }), 'hk-pult-szene hk-pult-ruhe hk-steht')
assert.equal(await klassen({ kpis: { pultOpen: 3 }, paused: false }), 'hk-pult-szene')
assert.equal(await klassen({ kpis: { pultOpen: 3 } }), 'hk-pult-szene') // ohne die Prop gilt „nicht angehalten“
for (const unsinn of ['ja', 1, null, {}]) assert.equal(await klassen({ kpis: { pultOpen: 3 }, paused: unsinn }), 'hk-pult-szene', String(unsinn))
// die Regel, die Animationen unter hk-steht anhält, steht in hustler-ui.css unter der Seite (das Pult hängt darin)
assert.match(read('src/styles/hustler/hustler-ui.css'), /\.hk-seite \.hk-steht \*[^{]*\{[^}]*animation-play-state:\s*paused\s*!important/)
// PultSzene allein (ohne Seite) kennt die Prop ebenfalls
const { __mount, jsx, PultSzene } = await loadUi()
const szene = __mount(jsx(PultSzene, { laeuft: true, steht: true }))
assert.ok(hasClass(szene.tree(), 'hk-steht')); szene.unmount()
})
// ---- T13 P3, R4, K7: CSS des Pults ----
test('hustler-pult-ui.css (T13 P3): Originalanfrage und Entwurf zum Lesen behalten ihre Zeilenumbrüche und Leerzeichen (pre-wrap, nicht pre-line)', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
for (const klasse of ['hk-brief-zitat', 'hk-brief-lesen']) {
const regel = new RegExp(`\\.hk-pult \\.${klasse}\\s*\\{([^}]*)\\}`).exec(css)
assert.ok(regel, klasse)
assert.match(regel[1], /white-space:\s*pre-wrap\s*;/, klasse); assert.doesNotMatch(regel[1], /pre-line/, klasse)
}
assert.doesNotMatch(css, /white-space:\s*pre-line/)
})
test('hustler-pult-ui.css (T13 R4): der Brief liegt unter der Handy-Seitenleiste (z-index 20) und ihrem Hintergrund (15), aber über Szene und Klemmbrett', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
const z = (klasse) => Number(new RegExp(`\\.hk-pult \\.${klasse}\\s*\\{[^}]*z-index:\\s*(\\d+)`).exec(css)?.[1])
assert.ok(z('hk-brief-ueber') <= 9, `Brief z-index ${z('hk-brief-ueber')}`)
assert.ok(z('hk-brief-ueber') > z('hk-brett'), 'über dem Klemmbrett'); assert.ok(z('hk-brief-ueber') > z('hk-reiter-leiste'), 'über den Reitern')
// keine Regel dieser Datei legt etwas über die Seitenleiste
for (const m of css.matchAll(/z-index:\s*(\d+)/g)) assert.ok(Number(m[1]) <= 9, `z-index ${m[1]}`)
})
test('hustler-pult-ui.css (T13 K7): am Handy sind die Textlinks im Brief („Anzeige öffnen“, „Interessent“) mindestens 40 px hoch', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
const handy = css.slice(css.indexOf('@media (max-width: 719px)'))
assert.match(handy, /\.hk-pult \.hk-link\s*\{[^}]*min-height:\s*40px/)
// die Link-Regel selbst zentriert den Text in dieser Höhe (inline-flex), die Fläche ist also wirklich antippbar
assert.match(css, /\.hk-pult \.hk-link\s*\{[^}]*display:\s*inline-flex[^}]*align-items:\s*center/)
// „Anzeige öffnen“ und „Interessent“ tragen genau diese Klasse
const brief = read('src/components/workers/pult/Brief.jsx')
assert.match(brief, /<a className="hk-link" href=\{row\.sourceHref\}/); assert.match(brief, /<button type="button" className="hk-link" onClick=\{\(\) => onDetails/)
})
test('Pult: „◂ Hochhaus“ ruft onBack; ohne onBack passiert beim Klick nichts', async () => { test('Pult: „◂ Hochhaus“ ruft onBack; ohne onBack passiert beim Klick nichts', async () => {
const calls = [] const calls = []
const ui = await startPult([], { onBack: () => calls.push('zurück') }) const ui = await startPult([], { onBack: () => calls.push('zurück') })
@@ -557,7 +671,8 @@ test('Pult: ein Ergebnis, das nach dem Schließen des Briefs eintrifft, erschein
assert.equal(ui.brief(), null) assert.equal(ui.brief(), null)
reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle() reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
const alarm = byClass(ui.tree(), 'hk-hinweis-err') const alarm = byClass(ui.tree(), 'hk-hinweis-err')
assert.deepEqual(alarm.map((n) => [n.props.role, textOf(n)]), [['alert', 'Kann gesendet sein – Pult neu laden.']]) // T13 P1b: der Satz nennt den Eintrag (sein Brief ist ja zu), sonst weiß der Leser nicht, wozu er gehört
assert.deepEqual(alarm.map((n) => [n.props.role, textOf(n)]), [['alert', '„Eins“: Kann gesendet sein – Pult neu laden.']])
// ein neuer Brief räumt die späte Meldung weg // ein neuer Brief räumt die späte Meldung weg
await ui.open(1) await ui.open(1)
assert.equal(byClass(ui.tree(), 'hk-hinweis-err').length, 0) assert.equal(byClass(ui.tree(), 'hk-hinweis-err').length, 0)
+102
View File
@@ -758,6 +758,108 @@ test('Pult (bedient, P4-T32): während einer Anfrage sind alle Knöpfe des Brief
} finally { ui.end() } } finally { ui.end() }
}) })
// T13 P1: die Sperre gehört dem Eintrag, nicht dem Brief. Wer den Brief während einer Anfrage schließt und denselben Eintrag wieder öffnet, darf nichts zweimal auslösen
// (sonst fügte „Antwort einfügen“ den Text doppelt ein). Aufruf der Knopf-Funktionen von Hand: ein gesperrter Knopf löst im Browser nichts aus, aber die Funktion dahinter
// darf auch auf anderem Weg (alter Stand, Hilfstechnik) nichts senden.
test('Pult (bedient, T13 P1): Brief schließen, während eine Anfrage läuft → denselben Eintrag öffnen → Knöpfe gesperrt, kein zweiter Aufruf → Anfrage endet → frei bzw. Brief/Meldung wie vorgesehen', async () => {
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const zweite = { ...unsicher, $id: 'e2', title: 'Zweite Rückfrage' }
const faelle = [
{ name: 'start-build', item: unsicher, aktion: 'start-build', schliesst: true, ok: 'wird gebaut.',
start: async (ui) => { await ui.click('Vorschau bauen'); await ui.click('Wirklich bauen?') },
gesperrt: ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich'] },
{ name: 'reply', item: unsicher, aktion: 'reply', schliesst: true, ok: 'gesendet.',
start: async (ui) => { await ui.click('Als Mensch antworten'); await ui.click('Jetzt senden') },
gesperrt: ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich'] },
{ name: 'paste', item: plattform, aktion: 'paste', schliesst: false, ok: 'Eingefügt. Nora sortiert die Antwort ein.',
start: async (ui) => { await ui.click('Antwort des Kunden einfügen'); await ui.type('Antwort des Kunden', 'Ja, gern'); await ui.click('Einfügen') },
gesperrt: ['Antwort des Kunden einfügen', 'Kopieren & öffnen', 'Als gesendet markieren', 'Verwerfen', 'Ich kümmere mich'] },
]
for (const fall of faelle) {
for (const ende of ['fehler', 'erfolg']) {
const wo = `${fall.name} / ${ende}`
let finish
let reject
const api = scriptedApi(new Promise((resolve, no) => { finish = resolve; reject = no }))
const ui = await startList([fall.item, zweite], api)
const stale = (label) => clickable(ui.brief(), label)[0].props.onClick?.({ preventDefault() {} }) // wie ein Klick auf einen Knopf, den der Browser nicht auslöst
try {
// 1. die Anfrage läuft, der Brief wird geschlossen
await ui.open(0)
await fall.start(ui)
assert.equal(api.calls.length, 1, wo); assert.equal(api.calls[0][1], fall.aktion, wo)
await ui.close()
assert.equal(ui.brief(), null, wo)
// 2. derselbe Eintrag, neuer Brief: alles gesperrt, ein Hinweis nennt den Grund
await ui.open(0)
for (const label of fall.gesperrt) assert.deepEqual(ui.disabled(label), [true], `${wo}: ${label}`)
assert.ok(ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'), wo)
// 3. auch die Funktionen hinter den Knöpfen senden nichts (kein zweites reply, paste oder start-build, keine Rückfrage, kein Feld, kein Öffnen der Anzeige)
for (const label of fall.gesperrt) { stale(label); stale(label); await settle() }
assert.equal(api.calls.length, 1, `${wo}: zweiter Aufruf`)
assert.ok(!ui.has('Wirklich bauen?') && !ui.has('Wirklich verwerfen?') && !ui.has('Wirklich freigeben?'), `${wo}: Rückfrage`)
assert.deepEqual([ui.value('Text der Antwort'), ui.value('Antwort des Kunden')], [[], []], `${wo}: Feld`)
assert.equal(ui.opened.length, 0, `${wo}: Anzeige geöffnet`)
// 4. andere Einträge sind nicht betroffen
await ui.close(); await ui.open(1)
assert.deepEqual(ui.disabled('Erledigt'), [false], wo); assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'), wo)
await ui.close(); await ui.open(0)
assert.deepEqual(ui.disabled(fall.gesperrt[0]), [true], wo)
// 5. die Anfrage endet
if (ende === 'fehler') {
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
// der Brief bleibt offen, alles ist frei, der Hinweis ist weg; der späte Fehler steht bei der Liste und nennt den Eintrag
assert.ok(ui.brief(), wo)
for (const label of fall.gesperrt) assert.deepEqual(ui.disabled(label), [false], `${wo}: ${label}`)
assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'), wo)
assert.deepEqual(ui.messages(), [{ kind: 'err', text: `„${fall.item.title}“: Wird schon von einem Kollegen bearbeitet.`, role: 'alert' }], wo)
assert.equal(api.calls.length, 1, wo)
// wieder frei: die nächste Aktion geht hinaus
await ui.click('Ich kümmere mich')
assert.deepEqual(api.calls[1], [fall.item.$id, 'claim'], wo)
} else {
finish({ status: 'neu', item: { $id: fall.item.$id } }); await settle()
assert.equal(api.calls.length, 1, wo)
if (fall.schliesst) {
// der Eintrag hat die Liste verlassen: der wieder geöffnete Brief schließt, die Meldung nennt den Titel
assert.equal(ui.brief(), null, wo)
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: `„${fall.item.title}“ ${fall.ok}`, role: 'status' }], wo)
} else {
// der Eintrag bleibt: der Brief ist offen und frei, das späte Ergebnis steht mit dem Titel in der Liste
assert.ok(ui.brief(), wo)
for (const label of fall.gesperrt) assert.deepEqual(ui.disabled(label), [false], `${wo}: ${label}`)
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: `„${fall.item.title}“: ${fall.ok}`, role: 'status' }], wo)
}
}
} finally { ui.end() }
}
}
})
test('Pult (bedient, T13 P1): ohne Anfrage ist nichts gesperrt; die Sperre hält nur während der Anfrage und gilt je Eintrag (Brief zu, Brief auf, andere Einträge frei)', async () => {
const zweite = { ...unsicher, $id: 'e2', title: 'Zweite Rückfrage' }
let finish
const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
const ui = await startList([unsicher, zweite], api)
try {
await ui.open(0)
for (const label of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [false], label)
assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'))
await ui.click('Ich kümmere mich') // Anfrage zu e1 läuft
assert.equal(api.calls.length, 1)
// der erste Brief sperrt sich selbst (wie bisher), der Hinweis erscheint dort nicht: die Sperre ist die eigene
assert.deepEqual(ui.disabled('Erledigt'), [true]); assert.ok(!ui.text().includes('Für diesen Eintrag läuft noch eine Anfrage.'))
// ein zweiter Eintrag kann währenddessen bearbeitet werden (eigene Id)
await ui.close(); await ui.open(1)
await ui.click('Ich kümmere mich')
assert.deepEqual(api.calls.map((c) => c[0]), ['e1', 'e2'])
finish({ item: { $id: 'e1' } }); await settle()
await ui.close(); await ui.open(0)
assert.deepEqual(ui.disabled('Erledigt'), [false]) // Anfrage zu e1 ist zu Ende
} finally { ui.end() }
})
test('Pult (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld', async () => { test('Pult (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffnet „Als Mensch antworten“ mit leerem Feld', async () => {
const api = scriptedApi() const api = scriptedApi()
const ui = await startList([{ ...unsicher, draft: undefined }], api) const ui = await startList([{ ...unsicher, draft: undefined }], api)