Hustler-Pixelwelt T9: Dein Pult (Szene, Klemmbrett mit Umschlägen, Brief mit den bisherigen Aktionen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:54:39 +00:00
1 parent 1b3fdf5d97
commit 5b5bb740c2
10 files changed
+2098 -653

No files matched your search

+115 -449
View File
@@ -1,439 +1,87 @@
// „Dein Pult“ (Hustler-Pixelwelt T9, Spec E11/E13/E14): der Bildschirm Freigabe. Die Szene (pult/PultSzene.jsx) füllt den Raum, davor liegt das Klemmbrett
// (pult/Klemmbrett.jsx) mit den Umschlägen der Einträge, die ein Mensch absendet, übernimmt oder abschließt; ein Klick auf einen Umschlag öffnet den Brief
// (pult/Brief.jsx) mit den bisherigen Aktionen. Nichts hier versendet selbst etwas: „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die
// Anzeige, abgeschickt wird dort von Hand. Aufbau und Klassen wie Freigabe.dc.html (Zeichenfläche) mit hk- davor; Wurzel .hk-pult.
// Die Datei bleibt PultList.jsx (Name und Default-Export wie bisher), sie hält Zustand und Fluss: Reiter, bearbeitete Texte und Feldtexte je Eintrag (sie überleben
// 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-
// Schleife), onBack() („◂ Hochhaus“). workers (Hustler-Tabelle) reicht die Seite durch, das Pult braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler.
// 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 Badge from '../ui/Badge'
import Button from '../ui/Button'
import EmptyState from '../ui/EmptyState'
import { copyTextChecked } from '../CopyableCredential'
import { useEmployees } from '../../hooks/useEmployees' import { useEmployees } from '../../hooks/useEmployees'
import { usePultFeed } from '../../hooks/usePultFeed' import { usePultFeed } from '../../hooks/usePultFeed'
import { reiterZahl, wartetZahl, botenLaufen } from '../../lib/hustlerPult'
import { activeControl, restoreFocus } from '../../lib/workersFocus' import { activeControl, restoreFocus } from '../../lib/workersFocus'
import { import { PULT_FILTERS, rowModel } from '../../lib/workersPult'
ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText, import Brief from './pult/Brief'
} from '../../lib/workersPult' import Klemmbrett from './pult/Klemmbrett'
import PultSzene from './pult/PultSzene'
import ProspectPanel from './ProspectPanel' import ProspectPanel from './ProspectPanel'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder 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)
// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
// Leerzustand je Filter export default function PultList({ api, me, kpis, onBack }) {
const EMPTY = {
offen: { title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' },
veraltet: { title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' },
erledigt: { title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' },
}
// Neuer Tab ohne Rückbezug auf diese Seite
const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was die Zeile anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
function copyNote(result, hasUrl) {
if (!hasUrl) {
return result.copied
? { kind: 'ok', text: 'Kopiert.' }
: { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
}
if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
}
// Zugänglicher Name für ein Bedienelement, das in jeder Zeile wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
// Namen“), dahinter der Titel der Zeile, damit eine Liste aller „Details“-Knöpfe im Screenreader unterscheidbar ist. Ohne Titel
// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
function rowName(label, title, joiner = ': ') {
const t = typeof title === 'string' ? title.trim() : ''
return t === '' ? label : `${label}${joiner}${t}`
}
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (.wk-pult-reply) und „Antwort des Kunden einfügen“ (.wk-pult-paste).
// Textfeld, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und zwei Knöpfe; die Prüfung macht die Zeile beim Absenden.
// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
function TextPanel({ className, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
const count = charCount(value)
// preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
const onKeyDown = (e) => {
if (e.key !== 'Escape' || busy) return
e.preventDefault()
e.stopPropagation()
onClose()
}
return (
<div className={className} onKeyDown={onKeyDown}>
{hint && <p className="wk-hint">{hint}</p>}
<textarea className="textarea" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} autoFocus />
<div className="wk-pult-panel-foot">
<span className={count > REPLY_TEXT_MAX ? 'wk-pult-count over' : 'wk-pult-count'}>{count} / {REPLY_TEXT_MAX}</span>
<div className="wk-pult-panel-btns">
<Button type="button" onClick={onClose} disabled={busy}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={onSubmit} disabled={busy}>{submitLabel}</Button>
</div>
</div>
</div>
)
}
// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der
// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und
// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFinished, onDetails }) {
const [busy, setBusy] = useState(false)
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 [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
const aliveRef = useRef(true)
const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
const rowRef = useRef(null) // die Zeile selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
const wasBusy = useRef(false)
useEffect(() => {
aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
// Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
useEffect(() => {
if (confirming === null) return undefined
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
return () => window.clearTimeout(timer)
}, [confirming])
// Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an <body>. Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
// frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an die Zeile.
// Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Eine Zeile, die die Liste verlassen hat, gibt ihn nicht mehr zurück: das tut die
// Meldung oben (PultList).
useEffect(() => {
if (busy) {
wasBusy.current = true
return
}
if (!wasBusy.current) return
wasBusy.current = false
const memory = returnTo.current
returnTo.current = null
if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
const row = rowRef.current
restoreFocus(browserDocument(), [
memory.control,
row?.querySelector(PANEL_FIELD),
memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
row,
])
}, [busy])
const begin = () => {
if (busyRef.current) return false
busyRef.current = true
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
setBusy(true)
setConfirming(null)
setNote(null)
return true
}
const end = () => {
busyRef.current = false
if (aliveRef.current) setBusy(false)
}
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile.
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
// approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „Kann gesendet sein – Pult neu laden.“, D14).
// Ergebnis: ob es geklappt hat.
const run = async (call, done, finished, action) => {
if (!begin()) return false
let ok = false
try {
const result = await call()
const message = typeof done === 'function' ? done(result) : done
if (finished) onFinished(row.id, `„${row.title}“ ${message}`, returnTo.current?.control != null)
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
ok = true
} catch (err) {
if (aliveRef.current) setNote({ kind: 'err', text: actionErrorText(err, action) })
}
await refresh()
end()
return ok
}
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
// Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet die Zeile das ehrlich,
// statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
// Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
const copyOpen = async () => {
if (!begin()) return
const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
let next = copyNote(result, row.sourceHref !== null)
if (!row.claimedByMe) {
try {
await api.pultAction(row.id, 'claim')
} catch (err) {
const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
next = { kind: 'err', text: `${next.text} ${hint}` }
}
await refresh()
}
if (aliveRef.current) setNote(next)
end()
}
const discard = () => {
if (confirming !== 'discard') {
setConfirming('discard')
return
}
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
}
// 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).
const approve = () => {
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') {
setConfirming('approve')
return
}
run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
}
// 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.
const startBuild = () => {
if (confirming !== 'build') {
setConfirming('build')
return
}
run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
}
// Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
// vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text einen Wechsel des Filters überlebt.
const replyValue = fields.reply ?? text
const pasteValue = fields.paste ?? ''
const toggle = (which) => 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.
// Der Knopf trägt seine Kennung in data-opens (Button reicht sie weiter; ein ref ginge an Button vorbei).
const close = (which) => {
setOpen(null)
actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
}
const sendReply = () => {
const check = validateReplyText(replyValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
}
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
const sendPaste = () => {
const check = validateReplyText(pasteValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(async () => {
const result = await api.pultAction(row.id, 'paste', { text: check.text })
onField(row.id, 'paste', '')
if (aliveRef.current) setOpen(null)
return result
}, pasteDoneText, false, 'paste')
}
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
const { prospect } = row
const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
// Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
const mail = row.kind === 'mail_antwort' && prospect && prospect.email
? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
: ''
return (
<li className="wk-pult-row" ref={rowRef} tabIndex={-1}>
<div className="wk-pult-head">
<Badge tone="blue">{row.kindLabel}</Badge>
<strong className="wk-pult-title">{row.title}</strong>
{row.stale.stale && <Badge tone="orange">{row.stale.label}</Badge>}
{row.statusLabel && <Badge tone={row.statusTone}>{row.statusLabel}</Badge>}
</div>
<div className="wk-pult-meta">
{row.stale.soon && <span>{row.stale.label}</span>}
{row.claimedByLabel && <span>übernommen von {row.claimedByLabel}</span>}
{/* Abgeschlossen: was daraus wurde, wer es tat und wann (das Alter ab Anlage führte dort in die Irre) */}
{row.doneLine ? <span>{row.doneLine}</span> : row.ageLabel && <span>{row.ageLabel}</span>}
{info && <span>{info}</span>}
{row.sourceHref && (
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Anzeige öffnen', row.title)}>Anzeige öffnen</a>
)}
{prospect && (
<button type="button" className="link" onClick={() => onDetails(prospect.$id)} aria-label={rowName('Details', row.title, ' zu ')}>Details</button>
)}
</div>
{item.originalText && (
<details className="wk-pult-original">
<summary>Originalanfrage</summary>
<p className="wk-pult-original-text">{item.originalText}</p>
</details>
)}
{row.canSend ? (
<textarea
className="textarea wk-pult-draft"
value={text}
onChange={(e) => onText(row.id, e.target.value)}
aria-label={rowName('Entwurf der Antwort', row.title)}
/>
) : (
item.draft && (
<details className="wk-pult-original">
<summary>Entwurf</summary>
<p className="wk-pult-original-text">{item.draft}</p>
</details>
)
)}
{hasActions && (
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)} ref={actionsRef}>
{row.canSend && row.kind === 'mail_antwort' && (
mail
? <a className="btn btn-primary" href={mail}>E-Mail schreiben</a>
: <span className="wk-hint">Keine gültige E-Mail-Adresse hinterlegt.</span>
)}
{row.canSend && row.kind !== 'mail_antwort' && (
<Button type="button" variant="primary" onClick={copyOpen} disabled={busy}>
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
</Button>
)}
{row.canClaim && (
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}>
Ich kümmere mich
</Button>
)}
{row.canSend && (
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)} disabled={busy || text.trim() === ''}>
Als gesendet markieren
</Button>
)}
{row.canReply && (
<Button type="button" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="reply" disabled={busy}>
Als Mensch antworten
</Button>
)}
{row.canApprovePreview && (
<>
{row.previewHref
? <a className="btn" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}>Vorschau ansehen</a>
: <span className="wk-hint">Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.</span>}
<Button type="button" variant="primary" onClick={approve} disabled={busy || !row.previewHref}>
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
</Button>
</>
)}
{row.canStartBuild && (
<>
<Button type="button" onClick={startBuild} disabled={busy} title={BUILD_HINT}>
{confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'}
</Button>
{confirming === 'build' && <span className="wk-hint">{BUILD_HINT}</span>}
</>
)}
{row.canPaste && (
<Button type="button" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={busy}>
Antwort des Kunden einfügen
</Button>
)}
{row.canDone && (
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
Erledigt
</Button>
)}
{row.canDiscard && (
<>
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Button>
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
{row.kind === 'vorschau_freigabe' && <span className="wk-hint">„Verwerfen“ sendet die Vorschau nicht.</span>}
</>
)}
</div>
)}
{open === 'reply' && row.canReply && (
<TextPanel
className="wk-pult-reply"
label={rowName('Text der Antwort', row.title)}
value={replyValue}
onChange={(value) => onField(row.id, 'reply', value)}
submitLabel="Jetzt senden"
onSubmit={sendReply}
onClose={() => close('reply')}
busy={busy}
/>
)}
{open === 'paste' && row.canPaste && (
<TextPanel
className="wk-pult-paste"
label={rowName('Antwort des Kunden', row.title)}
hint="Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf."
value={pasteValue}
onChange={(value) => onField(row.id, 'paste', value)}
submitLabel="Einfügen"
onSubmit={sendPaste}
onClose={() => close('paste')}
busy={busy}
/>
)}
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
</li>
)
}
// Reiter „Freigabe“: die Einträge des Pults, die ein Mensch absendet, übernimmt oder abschließt. Nichts hier versendet selbst etwas:
// „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die Anzeige, abgeschickt wird dort von Hand.
// me = { id, name } des Angemeldeten.
export default function PultList({ api, me }) {
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
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg) const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (sein Umschlag ist dann schon weg)
const [spaet, setSpaet] = useState(null) // { kind, text }: Ergebnis einer Aktion, die erst nach dem Schließen ihres Briefs antwortete
const [focusAsk, setFocusAsk] = useState(0) // zählt die Abschlüsse, bei denen die Person den Fokus auf einem Knopf hatte: auch eine gleiche Meldung zweimal nacheinander holt ihn const [focusAsk, setFocusAsk] = useState(0) // zählt die Abschlüsse, bei denen die Person den Fokus auf einem Knopf hatte: auch eine gleiche Meldung zweimal nacheinander holt ihn
const finishedRef = useRef(null) const finishedRef = useRef(null)
const [briefId, setBriefId] = useState(null) // Eintrag, dessen Brief offen ist (null = keiner)
const openerRef = useRef(null) // der Umschlag, der den Brief geöffnet hat: dorthin geht der Fokus beim Schließen
const backFocus = useRef(false) // true: die Person hat den Brief geschlossen, der Fokus soll an den Umschlag zurück
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 listeRef = useRef(null)
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()
// Verlässt eine Zeile die Liste, hängt ihr Knopf mit dem Fokus ab. Die Meldung oben 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(() => {
if (focusAsk === 0) return if (focusAsk === 0) return
restoreFocus(browserDocument(), [finishedRef.current]) restoreFocus(browserDocument(), [finishedRef.current])
}, [focusAsk]) }, [focusAsk])
// Schließt die Person den Brief selbst (Schließen, Esc, Hintergrund), geht der Fokus an den Umschlag zurück (sonst an den ersten Umschlag der Liste)
useEffect(() => {
if (briefId !== null || !backFocus.current) return
backFocus.current = false
const opener = openerRef.current
openerRef.current = null
const target = opener && opener.isConnected !== false && typeof opener.focus === 'function'
? opener
: listeRef.current?.querySelector?.('.hk-umschlag')
if (target && typeof target.focus === 'function') target.focus({ preventScroll: true })
}, [briefId])
const chooseFilter = (id) => { const chooseFilter = (id) => {
setFilterId(id) setFilterId(id)
setFinished('') setFinished('')
setSpaet(null)
} }
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), []) const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), []) const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
const oeffne = (id, event) => {
openerRef.current = event?.currentTarget ?? activeControl(browserDocument())
setSpaet(null)
setBriefId(id)
}
const schliesse = () => {
backFocus.current = true
setBriefId(null)
}
// Der Eintrag verlässt die Liste: sein Brief schließt (war inzwischen ein anderer offen, bleibt der), die Meldung steht oben und holt sich den Fokus
const onFinished = useCallback((id, message, hadFocus) => { const onFinished = useCallback((id, message, hadFocus) => {
backFocus.current = false
setBriefId((current) => (current === id ? null : current))
setFinished(message) setFinished(message)
if (hadFocus) setFocusAsk((n) => n + 1) if (hadFocus) setFocusAsk((n) => n + 1)
setDrafts((d) => { setDrafts((d) => {
@@ -445,66 +93,84 @@ export default function PultList({ api, me }) {
return rest return rest
}) })
}, []) }, [])
const onSpaet = useCallback((note) => setSpaet(note), [])
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
// Ein Brief, dessen Eintrag nicht mehr in der Liste steht (ein Kollege war schneller), schließt sich: auf einem alten Stand wird nichts mehr gesendet
useEffect(() => {
if (briefId !== null && !loading && offen === null) {
backFocus.current = true
setBriefId(null)
}
})
let body // Zahlen: nur der offene Reiter kennt die Länge seiner Liste; „Wartet“ sonst kpis.pultOpen (ein Abruf für drei Reiter wäre zu viel, Spec E11)
if (loading) { const geladen = loading || (error && items.length === 0) ? null : items.length
body = <div className="loading">Wird geladen …</div> const zahlen = Object.fromEntries(PULT_FILTERS.map((f) => [f.id, reiterZahl(f.id, { aktivId: filter.id, kpis, geladen })]))
} else if (rows.length === 0 && error) { const kopfZahl = wartetZahl(kpis, filter.id, geladen)
body = (
<EmptyState return (
title="Das Pult ist gerade nicht erreichbar" <div className="hk-pult">
hint={<p>{error}</p>} <PultSzene laeuft={botenLaufen(kpis)} />
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
<div className="hk-pult-ui">
<div className="hk-pult-kopf">
<button type="button" className="hk-knopf hk-pult-knopf" onClick={() => { if (typeof onBack === 'function') onBack() }}>
<span className="hk-ic hk-ic-zurueck" aria-hidden="true" />Hochhaus
</button>
<div className="hk-messing hk-pult-schild">
<span className="hk-ic hk-ic-briefkasten" aria-hidden="true" />
<h1 className="hk-pult-h1">Freigabe</h1>
{kopfZahl !== null && (
<span className="hk-pult-zahl"><span className="hk-nur-sr">Wartet auf dich:</span>{kopfZahl}</span>
)}
</div>
<button type="button" className="hk-knopf hk-pult-knopf hk-pult-zuklappen" onClick={() => setZu((z) => !z)}>
<span className="hk-ic hk-ic-art-mail" aria-hidden="true" />{zu ? 'Pult aufklappen' : 'Pult zuklappen'}
</button>
</div>
{!zu && (
<div className="hk-pult-unten" ref={listeRef}>
<Klemmbrett
filterId={filter.id}
onFilter={chooseFilter}
zahlen={zahlen}
laden={loading}
fehler={error}
rows={rows}
onNeuLaden={refresh}
meldung={finished}
meldungRef={finishedRef}
spaet={spaet}
onOeffne={oeffne}
/> />
) </div>
} else if (rows.length === 0) { )}
const empty = EMPTY[filter.id] ?? EMPTY.offen </div>
body = <EmptyState title={empty.title} hint={<p>{empty.hint}</p>} />
} else { {offen && (
body = ( <Brief
<ul className="wk-pult-list"> key={offen.row.id}
{rows.map(({ item, row }) => (
<PultRow
key={row.id}
api={api} api={api}
item={item} item={offen.item}
row={row} row={offen.row}
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText} text={Object.hasOwn(drafts, offen.row.id) ? drafts[offen.row.id] : offen.row.copyText}
fields={Object.hasOwn(fields, row.id) ? fields[row.id] : NO_FIELDS} fields={Object.hasOwn(fields, offen.row.id) ? fields[offen.row.id] : NO_FIELDS}
onText={setText} onText={setText}
onField={setField} onField={setField}
refresh={refresh} refresh={refresh}
onFinished={onFinished} onFinished={onFinished}
onClose={schliesse}
onDetails={setDetailId} onDetails={setDetailId}
onSpaet={onSpaet}
/> />
))} )}
</ul> {detailId && (
) <div className="hk-woms">
} <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />
return (
<>
<div className="wk-pult-filters">
<div className="seg" role="group" aria-label="Filter">
{PULT_FILTERS.map((f) => (
<button
key={f.id}
type="button"
className={f.id === filter.id ? 'on' : undefined}
aria-pressed={f.id === filter.id}
onClick={() => chooseFilter(f.id)}
>
{f.label}
</button>
))}
</div> </div>
)}
</div> </div>
{finished && <div className="msg ok" role="status" tabIndex={-1} ref={finishedRef}>{finished}</div>}
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
{body}
{detailId && <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />}
</>
) )
} }
+504
View File
@@ -0,0 +1,504 @@
// Der Brief des Pults (Hustler-Pixelwelt T9, Spec E11/E13/E14): Dialog über dem Klemmbrett mit dem Inhalt des bisherigen Zeilenbausteins PultRow – alle Zustände
// (busy, confirming, open, note), alle Aktionen über api.pultAction, alle Texte, Rückfragen (4 s), Kopieren & öffnen, Mail-Link, Antworten-/Einfügen-Feld mit Zähler
// und 8000-Grenze, Original und Entwurf – nur mit neuem Markup (Brief-Papier, Stempel-Knöpfe, Klassen der Vorlage Freigabe.dc.html mit hk-). Welche Knöpfe erscheinen,
// entscheidet allein rowModel (src/lib/workersPult.js): canClaim, canSend, canReply, canPaste, canApprovePreview, canStartBuild, canDone, canDiscard,
// sourceHref, previewHref, mailtoHref, claimedByOther. Der Brief hält nur, was ihm gehört (läuft gerade etwas, Rückfrage, offenes Feld, Meldung); der bearbeitete
// Text und die Feldtexte liegen in PultList, damit sie Schließen, Filterwechsel und Neuladen überleben. api.pultAction führt aus, der Dienst entscheidet über Rechte
// und Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
//
// 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;
// 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).
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
import { useEffect, useRef, useState } from 'react'
import { copyTextChecked } from '../../CopyableCredential'
import { umschlagModell } from '../../../lib/hustlerPult'
import { activeControl, restoreFocus } from '../../../lib/workersFocus'
import {
ACTION_FAILED, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, validateReplyText,
} from '../../../lib/workersPult'
import Marken from './Marken'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
const browserDocument = () => (typeof document === 'undefined' ? null : document)
// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
const PANEL_FIELD = '.hk-feld-panel textarea'
// Neuer Tab ohne Rückbezug auf diese Seite
const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was der Brief anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
function copyNote(result, hasUrl) {
if (!hasUrl) {
return result.copied
? { kind: 'ok', text: 'Kopiert.' }
: { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
}
if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
}
// Zugänglicher Name für ein Bedienelement, das in jedem Brief wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
// Namen“), dahinter der Titel des Eintrags, damit eine Liste aller „Anzeige öffnen“-Links im Screenreader unterscheidbar ist. Ohne Titel
// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
function rowName(label, title, joiner = ': ') {
const t = typeof title === 'string' ? title.trim() : ''
return t === '' ? label : `${label}${joiner}${t}`
}
// Stempel-Knopf der Vorlage (stempel + Art: haupt | normal | gruen | rot, stempel-frage während der Rückfrage, Icon ic-…). Alles Weitere (onClick, disabled, title,
// aria-expanded, data-opens) geht unverändert an den Knopf.
function Stempel({ art, icon, frage = false, aus = false, children, ...rest }) {
const klasse = `hk-stempel hk-stempel-${art}${frage ? ' hk-stempel-frage' : ''}${aus ? ' hk-stempel-aus' : ''}`
return (
<button type="button" className={klasse} {...rest}>
{icon && <span className={`hk-ic hk-ic-${icon}`} aria-hidden="true" />}
{children}
</button>
)
}
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (hk-feld-antwort) und „Antwort des Kunden einfügen“ (hk-feld-einfuegen).
// Beschriftung, Textfeld, Hinweis, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und zwei Knöpfe; die Prüfung macht der Brief beim Absenden.
// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
function TextPanel({ className, caption, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
const count = charCount(value)
const fieldRef = useRef(null)
const panelRef = useRef(null)
// Fokus ins Textfeld beim Aufklappen, ohne dass die Seite springt; danach rollt das ganze Feld (mit Zähler und Knöpfen) nur bei Bedarf ins Bild
useEffect(() => {
const el = fieldRef.current
if (!el || typeof el.focus !== 'function') return
el.focus({ preventScroll: true })
const panel = panelRef.current
if (panel && typeof panel.scrollIntoView === 'function') panel.scrollIntoView({ block: 'nearest' })
}, [])
// preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
const onKeyDown = (e) => {
if (e.key !== 'Escape' || busy) return
e.preventDefault()
e.stopPropagation()
onClose()
}
return (
<div className={`hk-feld-panel ${className}`} onKeyDown={onKeyDown} ref={panelRef}>
<label className="hk-feld-label">
<span className="hk-feld-titel">{caption}</span>
<textarea className="hk-feld" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} ref={fieldRef} />
</label>
{hint && <p className="hk-brief-hinweis">{hint}</p>}
<div className="hk-feld-fuss">
<span className={count > REPLY_TEXT_MAX ? 'hk-zaehler hk-zaehler-ueber' : 'hk-zaehler'}>{count} / {REPLY_TEXT_MAX}</span>
<div className="hk-feld-knoepfe">
<Stempel art="normal" onClick={onClose} disabled={busy}>Abbrechen</Stempel>
<Stempel art="haupt" icon="gesendet" onClick={onSubmit} disabled={busy}>{submitLabel}</Stempel>
</div>
</div>
</div>
)
}
// 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,
// 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 }) {
const [busy, setBusy] = useState(false)
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 [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
const aliveRef = useRef(true)
const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
const briefRef = useRef(null) // der Brief selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
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 wasBusy = useRef(false)
useEffect(() => {
aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
// Beim Öffnen: Fokus auf den ersten Knopf (Spec E14)
useEffect(() => {
const erster = ersterRef.current
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
}, [])
// Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
useEffect(() => {
if (confirming === null) return undefined
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
return () => window.clearTimeout(timer)
}, [confirming])
// Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an <body>. Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
// frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an den Brief.
// Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Ein Brief, der nach einem Abschluss geschlossen hat, gibt ihn nicht mehr zurück:
// das tut die Meldung in der Liste (PultList).
useEffect(() => {
if (busy) {
wasBusy.current = true
return
}
if (!wasBusy.current) return
wasBusy.current = false
const memory = returnTo.current
returnTo.current = null
if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
const brief = briefRef.current
restoreFocus(browserDocument(), [
memory.control,
brief?.querySelector(PANEL_FIELD),
memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
brief,
])
}, [busy])
const begin = () => {
if (busyRef.current) return false
busyRef.current = true
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
setBusy(true)
setConfirming(null)
setNote(null)
return true
}
const end = () => {
busyRef.current = false
if (aliveRef.current) setBusy(false)
}
// Meldung des Briefs; ist er schon zu, geht sie an die Liste
const report = (next) => {
if (aliveRef.current) setNote(next)
else if (typeof onSpaet === 'function') onSpaet(next)
}
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb bei der Liste und nicht im Brief.
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
// approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „Kann gesendet sein – Pult neu laden.“, D14).
// Ergebnis: ob es geklappt hat.
const run = async (call, done, finished, action) => {
if (!begin()) return false
let ok = false
try {
const result = await call()
const message = typeof done === 'function' ? done(result) : done
if (finished) onFinished(row.id, `„${row.title}“ ${message}`, aliveRef.current && returnTo.current?.control != null)
else report({ kind: 'ok', text: message })
ok = true
} catch (err) {
report({ kind: 'err', text: actionErrorText(err, action) })
}
await refresh()
end()
return ok
}
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
// Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet der Brief das ehrlich,
// statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
// Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
const copyOpen = async () => {
if (!begin()) return
const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
let next = copyNote(result, row.sourceHref !== null)
if (!row.claimedByMe) {
try {
await api.pultAction(row.id, 'claim')
} catch (err) {
const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
next = { kind: 'err', text: `${next.text} ${hint}` }
}
await refresh()
}
report(next)
end()
}
const discard = () => {
if (confirming !== 'discard') {
setConfirming('discard')
return
}
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
}
// 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).
const approve = () => {
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') {
setConfirming('approve')
return
}
run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
}
// 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.
const startBuild = () => {
if (confirming !== 'build') {
setConfirming('build')
return
}
run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
}
// Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
// 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 pasteValue = fields.paste ?? ''
const toggle = (which) => 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.
// Der Knopf trägt seine Kennung in data-opens.
const close = (which) => {
setOpen(null)
actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
}
const sendReply = () => {
const check = validateReplyText(replyValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
}
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
const sendPaste = () => {
const check = validateReplyText(pasteValue)
if (!check.ok) {
setNote({ kind: 'err', text: check.error })
return
}
run(async () => {
const result = await api.pultAction(row.id, 'paste', { text: check.text })
onField(row.id, 'paste', '')
if (aliveRef.current) setOpen(null)
return result
}, pasteDoneText, false, 'paste')
}
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
const { prospect } = row
const m = umschlagModell(row)
// Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
const mail = row.kind === 'mail_antwort' && prospect && prospect.email
? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
: ''
const original = typeof item.originalText === 'string' ? item.originalText : ''
const draft = typeof item.draft === 'string' ? item.draft : ''
// Tasten am Brief: Esc schließt erst ein offenes Feld (das Textfeld schließt sich selbst, hier gilt es für den Rest des Briefs), dann den Brief;
// Tab wandert im Kreis durch die Bedienelemente des Briefs
const taste = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
if (open !== null) {
if (!busy) close(open)
return
}
onClose()
return
}
if (e.key !== 'Tab') return
const brief = briefRef.current
if (!brief || typeof brief.querySelectorAll !== 'function') return
const items = [...brief.querySelectorAll('button, textarea, a[href]')].filter((el) => !el.disabled)
if (items.length === 0) return
const doc = browserDocument()
const aktiv = doc ? doc.activeElement : null
if (!e.shiftKey && aktiv === items[items.length - 1]) {
e.preventDefault()
items[0].focus()
} else if (e.shiftKey && (aktiv === items[0] || aktiv === brief)) {
e.preventDefault()
items[items.length - 1].focus()
}
}
return (
<div className="hk-brief-ueber">
<button type="button" tabIndex={-1} className="hk-brief-hinten" aria-label="Brief schließen" onClick={() => onClose()} />
<section
ref={briefRef}
role="dialog"
aria-modal="true"
aria-label={rowName(m.artLabel, row.title)}
tabIndex={-1}
className="hk-brief"
onKeyDown={taste}
>
<div className="hk-brief-inhalt">
<div className="hk-brief-kopf">
{/* „Schließen“ steht im Quelltext zuerst (erster Knopf: Fokus beim Öffnen, Anfang der Fokusfalle) und sitzt per CSS rechts oben */}
<button type="button" className="hk-knopf hk-brief-zu" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
<span className="hk-ic hk-ic-schliessen" aria-hidden="true" />
</button>
<div className="hk-brief-titelblock">
<div className="hk-brief-art"><span className={`hk-ic hk-ic-${m.icon}`} aria-hidden="true" />{m.artLabel}</div>
<h2 className="hk-brief-titel">{m.titel}</h2>
<div className="hk-brief-meta">
{m.info && <span>{m.info}</span>}
{m.alter && <span>{m.alter}</span>}
<Marken m={m} />
{row.sourceHref && (
<a className="hk-link" href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Anzeige öffnen', row.title)}>Anzeige öffnen</a>
)}
{prospect && (
<button type="button" className="hk-link" onClick={() => onDetails(prospect.$id)} aria-label={rowName('Interessent', row.title, ' zu ')}>Interessent</button>
)}
</div>
</div>
</div>
{original !== '' && (
<>
<h3 className="hk-brief-h">Originalanfrage</h3>
<blockquote className="hk-brief-zitat">{original}</blockquote>
</>
)}
{row.canSend ? (
<label className="hk-feld-label">
<span className="hk-feld-titel">Entwurf der Antwort</span>
<textarea
className="hk-feld hk-brief-entwurf"
value={text}
onChange={(e) => onText(row.id, e.target.value)}
aria-label={rowName('Entwurf der Antwort', row.title)}
/>
</label>
) : (
draft !== '' && (
<>
<h3 className="hk-brief-h">Entwurf</h3>
<p className="hk-brief-lesen">{draft}</p>
</>
)
)}
{hasActions ? (
<>
<h3 className="hk-brief-h hk-brief-h-aktionen">Aktionen</h3>
<div className="hk-aktionen" role="group" aria-label={rowName('Aktionen', row.title)} ref={actionsRef}>
{row.canSend && row.kind === 'mail_antwort' && (
mail
? <a className="hk-stempel hk-stempel-haupt" href={mail}><span className="hk-ic hk-ic-art-mail" aria-hidden="true" />E-Mail schreiben</a>
: <span className="hk-brief-hinweis">Keine gültige E-Mail-Adresse hinterlegt.</span>
)}
{row.canSend && row.kind !== 'mail_antwort' && (
<Stempel art="haupt" icon="kopieren" onClick={copyOpen} disabled={busy}>
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
</Stempel>
)}
{row.canApprovePreview && (
row.previewHref
? <a className="hk-stempel hk-stempel-normal" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}><span className="hk-ic hk-ic-ansehen" aria-hidden="true" />Vorschau ansehen</a>
: <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 && (
<Stempel art="normal" icon="uebernehmen" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}>
Ich kümmere mich
</Stempel>
)}
{row.canSend && (
<Stempel
art="gruen"
icon="gesendet"
aus={text.trim() === ''}
onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)}
disabled={busy || text.trim() === ''}
>
Als gesendet markieren
</Stempel>
)}
{row.canReply && (
<Stempel art="normal" icon="antworten" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="reply" disabled={busy}>
Als Mensch antworten
</Stempel>
)}
{row.canApprovePreview && (
<Stempel art="gruen" icon="gesendet" frage={confirming === 'approve'} onClick={approve} disabled={busy || !row.previewHref}>
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
</Stempel>
)}
{row.canStartBuild && (
<>
<Stempel art="normal" icon="bauen" frage={confirming === 'build'} onClick={startBuild} disabled={busy} title={BUILD_HINT}>
{confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'}
</Stempel>
{confirming === 'build' && <span className="hk-brief-hinweis hk-brief-hinweis-bau">{BUILD_HINT}</span>}
</>
)}
{row.canPaste && (
<Stempel art="normal" icon="einfuegen" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={busy}>
Antwort des Kunden einfügen
</Stempel>
)}
{row.canDone && (
<Stempel art="normal" icon="erledigt" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
Erledigt
</Stempel>
)}
{row.canDiscard && (
<>
<Stempel art="rot" icon="verwerfen" frage={confirming === 'discard'} onClick={discard} disabled={busy}>
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Stempel>
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
{row.kind === 'vorschau_freigabe' && <span className="hk-brief-hinweis">„Verwerfen“ sendet die Vorschau nicht.</span>}
</>
)}
</div>
</>
) : (
<p className="hk-brief-hinweis">Dieser Eintrag ist abgeschlossen.</p>
)}
{open === 'reply' && row.canReply && (
<TextPanel
className="hk-feld-antwort"
caption="Text der Antwort"
label={rowName('Text der Antwort', row.title)}
value={replyValue}
onChange={(value) => onField(row.id, 'reply', value)}
submitLabel="Jetzt senden"
onSubmit={sendReply}
onClose={() => close('reply')}
busy={busy}
/>
)}
{open === 'paste' && row.canPaste && (
<TextPanel
className="hk-feld-einfuegen"
caption="Antwort des Kunden"
label={rowName('Antwort des Kunden', row.title)}
hint="Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf."
value={pasteValue}
onChange={(value) => onField(row.id, 'paste', value)}
submitLabel="Einfügen"
onSubmit={sendPaste}
onClose={() => close('paste')}
busy={busy}
/>
)}
{note && <div className={`hk-hinweis ${note.kind === 'err' ? 'hk-hinweis-err' : 'hk-hinweis-ok'}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
</div>
</section>
</div>
)
}
@@ -0,0 +1,94 @@
// Das Klemmbrett des Pults (Hustler-Pixelwelt T9, Spec E11): Reiter Wartet / Veraltet / Erledigt (PULT_FILTERS) mit Zahl, das Brett mit Klammer, Untertitel,
// Meldungen, Laden, Fehler mit „Neu laden“, Leerzustände und die Umschläge. Ein Umschlag ist ein Knopf und öffnet den Brief (Brief.jsx); er zeigt Art-Icon,
// Titel, Art · Alter, Ort · Score und je nach Zustand Frist, „veraltet“, Halter oder den Ergebnis-Stempel. Reine Darstellung: Daten, Zustand und Klicks
// kommen von PultList. Aufbau und Klassen wie Freigabe.dc.html (Block „Klemmbrett“) mit hk- davor. Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
//
// „von <Hustler>“ der Vorlage fehlt mit Absicht: ein Pult-Eintrag des Dienstes (Übersicht §4.11) trägt kein Feld, das den bringenden Hustler nennt
// (addPult kennt workerId nur beim Anlegen, gespeichert wird es nicht); erfunden wird nichts.
import { PULT_FILTERS } from '../../../lib/workersPult'
import { LEER, REITER_KLASSE, UNTERTITEL, reiterName, umschlagModell } from '../../../lib/hustlerPult'
import Marken from './Marken'
function Umschlag({ row, onOeffne }) {
const m = umschlagModell(row)
return (
<li>
<button type="button" className="hk-umschlag" aria-haspopup="dialog" aria-label={m.label} onClick={(e) => onOeffne(row.id, e)}>
<span className={`hk-ic hk-ic-${m.icon} hk-umschlag-art`} aria-hidden="true" />
<span className="hk-zelle-text hk-umschlag-titel" title={m.titel}>{m.titel}</span>
<span className="hk-klein hk-umschlag-art-alter">{m.alter ? `${m.artLabel} · ${m.alter}` : m.artLabel}</span>
<span className="hk-umschlag-marken">
{m.info && <span className="hk-klein hk-klein-inline">{m.info}</span>}
<Marken m={m} />
</span>
</button>
</li>
)
}
// Props: filterId (Id des offenen Reiters), onFilter(id), zahlen ({ [filterId]: Zahl | null }), laden, fehler (Satz des Dienstes oder null), rows ([{ row }] als
// rowModel), onNeuLaden(), meldung (Text der Erfolgsmeldung eines Abschlusses, '' = keine) mit meldungRef (nimmt den Fokus), spaet ({ kind, text } | null:
// Ergebnis einer Aktion, deren Brief schon zu war), onOeffne(id, event)
export default function Klemmbrett({ filterId, onFilter, zahlen, laden, fehler, rows, onNeuLaden, meldung, meldungRef, spaet, onOeffne }) {
let inhalt
if (laden) {
inhalt = <div className="hk-laden" role="status">Wird geladen …</div>
} else if (rows.length === 0 && fehler) {
inhalt = (
<div className="hk-leer">
<p className="hk-leer-titel">Das Pult ist gerade nicht erreichbar</p>
<p className="hk-leer-text">{fehler}</p>
<button type="button" className="hk-stempel hk-stempel-normal hk-leer-knopf" onClick={() => onNeuLaden()}>Neu laden</button>
</div>
)
} else if (rows.length === 0) {
const leer = LEER[filterId] ?? LEER.offen
inhalt = (
<div className="hk-leer">
<p className="hk-leer-titel">{leer.title}</p>
<p className="hk-leer-text">{leer.hint}</p>
</div>
)
} else {
inhalt = (
<ol className="hk-liste">
{rows.map(({ row }) => <Umschlag key={row.id} row={row} onOeffne={onOeffne} />)}
</ol>
)
}
return (
<>
<div className="hk-reiter-leiste" role="group" aria-label="Einträge filtern">
{PULT_FILTERS.map((f) => {
const aktiv = f.id === filterId
const zahl = zahlen[f.id] ?? null
return (
<button
key={f.id}
type="button"
className={`hk-reiter ${REITER_KLASSE[f.id] ?? ''}${aktiv ? ' hk-reiter-an' : ''}`}
aria-pressed={aktiv}
aria-label={zahl === null ? undefined : reiterName(f.label, zahl)}
onClick={() => onFilter(f.id)}
>
{f.label}
{zahl !== null && <span className="hk-zahl">{zahl}</span>}
</button>
)
})}
</div>
<section className="hk-klemmbrett hk-brett" aria-label="Dein Pult">
<span className="hk-klammer" aria-hidden="true" />
<div className="hk-brett-kopf">
<h2 className="hk-brett-titel">Dein Pult</h2>
<p className="hk-brett-unter">{UNTERTITEL[filterId] ?? UNTERTITEL.offen}</p>
</div>
{meldung && <div className="hk-hinweis hk-hinweis-ok" role="status" tabIndex={-1} ref={meldungRef}>{meldung}</div>}
{spaet && <div className={`hk-hinweis ${spaet.kind === 'err' ? 'hk-hinweis-err' : 'hk-hinweis-ok'}`} role={spaet.kind === 'err' ? 'alert' : 'status'}>{spaet.text}</div>}
{fehler && rows.length > 0 && <div className="hk-hinweis hk-hinweis-warn" role="status">Verbindung gestört: {fehler} Es wird weiter versucht.</div>}
{inhalt}
</section>
</>
)
}
+21
View File
@@ -0,0 +1,21 @@
// Die kleinen Marken eines Pult-Eintrags (Frist, „veraltet“, Halter, Ergebnis-Stempel): im Umschlag der Liste und in der Kopfzeile des Briefs gleich.
// Klassen und Aufbau wie Freigabe.dc.html (marke, ic-klein, abdruck) mit hk- davor; die Farben stehen in styles/hustler/hustler-pult-ui.css.
// Ein Marken-Satz kommt aus umschlagModell (src/lib/hustlerPult.js). Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
// Props: m = umschlagModell(row)
export default function Marken({ m }) {
return (
<>
{m.frist && (
<span className="hk-marke hk-marke-frist"><span className="hk-ic hk-ic-klein hk-ic-sanduhr-k" aria-hidden="true" />{m.frist}</span>
)}
{m.gehalten && (
<span className="hk-marke hk-marke-halter"><span className="hk-ic hk-ic-klein hk-ic-person" aria-hidden="true" />{m.gehalten}</span>
)}
{m.veraltet && (
<span className="hk-marke hk-marke-veraltet"><span className="hk-ic hk-ic-klein hk-ic-veraltet" aria-hidden="true" />veraltet</span>
)}
{m.fertig && <span className={m.fertig.klasse}>{m.fertig.text}</span>}
</>
)
}
+72
View File
@@ -0,0 +1,72 @@
// Die Szene hinter dem Klemmbrett (Hustler-Pixelwelt T9, Spec E11/E13/E14): 1:1 aus dem Markup der Zeichenfläche (pult/entwurf/Freigabe.dc.html, Block „Szene“),
// Klassen mit hk- davor. Raum, Fensterschnee, Lichter (Flackern), Uhr mit Sekundenzeiger, Lichterkette, Aufzugtüren, Boten-Schleife (Clara, Nora), Briefkasten-Fähnchen
// und Monitor sind reine CSS-Animationen aus styles/hustler/pult.css. Die 640 x 360 große Bühne wird „cover“ in die Fläche eingepasst (ResizeObserver, wie bindeRoot/passe
// in logik.js). Die Boten-Schleife und das Fähnchen laufen nur, wenn `laeuft` gilt (kpis.pultOpen > 0); sonst steht die Ruhe-Klasse der Vorlage (hk-pult-ruhe) am
// Rahmen und friert sie im Ruhebild ein. „Weniger Bewegung“ regelt das CSS der Vorlage. Dekoration: aria-hidden, keine Bedienelemente.
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
import { useEffect, useRef, useState } from 'react'
import { SZENE_START, SZENE_TEILE as T, szenePassung } from '../../../lib/hustlerPult'
const lage = (teil) => ({ left: teil.left, top: teil.top })
const flaeche = (teil) => ({ left: teil.left, top: teil.top, width: teil.width, height: teil.height })
// Props: laeuft (Boten-Schleife an, Standard aus)
export default function PultSzene({ laeuft = false }) {
const rahmen = useRef(null)
const [pass, setPass] = useState(SZENE_START)
// Einpassen: beim Einhängen und bei jeder Größenänderung des Rahmens messen (ohne ResizeObserver nur einmal)
useEffect(() => {
const el = rahmen.current
if (!el || typeof el.getBoundingClientRect !== 'function') return undefined
const passe = () => {
const r = el.getBoundingClientRect()
const next = szenePassung(r.width, r.height)
if (next) setPass((prev) => (prev.transform === next.transform ? prev : next))
}
let beobachter = null
if (typeof ResizeObserver !== 'undefined') {
beobachter = new ResizeObserver(passe)
beobachter.observe(el)
}
passe()
return () => { if (beobachter) beobachter.disconnect() }
}, [])
return (
<div className={laeuft ? 'hk-pult-szene' : 'hk-pult-szene hk-pult-ruhe'} ref={rahmen} aria-hidden="true">
<div className="hk-pult-buehne" style={{ transform: pass.transform }}>
<div className="hk-sz-ebene hk-sz-bild" />
<div className="hk-pult-teil hk-pult-fenster" style={flaeche(T.fenster1)}>
<div className="hk-sz-schnee" />
<div className="hk-sz-sprossen1" />
</div>
<div className="hk-pult-teil hk-pult-fenster hk-sz-fenster2" style={flaeche(T.fenster2)}>
<div className="hk-sz-schnee" />
</div>
<div className="hk-sz-sprossen2" />
<span className="hk-spr hk-pult-teil hk-sz-auflicht" style={lage(T.auflicht)} />
<div className="hk-sz-innen" />
<div className="hk-boten-weg"><span className="hk-boten-bild" /></div>
<span className="hk-spr hk-pult-teil hk-sz-tuer" style={lage(T.tuer)} />
<span className="hk-spr hk-pult-teil hk-sz-ruftaste" style={lage(T.ruftaste)} />
<span className="hk-spr hk-pult-teil hk-sz-funke" style={lage(T.funke)} />
<span className="hk-spr hk-pult-teil hk-sz-ding" style={lage(T.ding)} />
<span className="hk-spr hk-pult-teil hk-sz-briefkasten" style={lage(T.briefkasten)} />
<span className="hk-spr hk-pult-teil hk-sz-monitor" style={lage(T.monitor)} />
<span className="hk-sz-sekunde" />
<span className="hk-spr hk-pult-teil hk-sz-dampf" style={lage(T.dampf)} />
<span className="hk-pult-teil hk-pult-antenne hk-sz-antenne" style={lage(T.antenne)} />
<div className="hk-sz-ebene hk-sz-kette0" />
<div className="hk-sz-ebene hk-sz-kette1" />
<div className="hk-sz-ebene hk-sz-kette2" />
<div className="hk-sz-ebene hk-sz-ltisch" />
<div className="hk-sz-ebene hk-sz-l55" />
<div className="hk-sz-ebene hk-sz-l232" />
<div className="hk-sz-ebene hk-sz-lrechts" />
<div className="hk-sz-ebene hk-sz-lmond" />
</div>
<div className="hk-pult-vignette" />
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
// Reine Ableitungen des Pults in der Hustler-Pixelwelt (Spec E11/E13/E14, Plan T9): Szene (Maße, Einpassen), Boten-Schleife, Reiter und ihre Zahlen,
// Umschlag (Art-Icon, Alter, Frist, Halter, Ergebnis-Stempel, Name für Screenreader). Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule).
// Die Klassen heißen genau wie in src/styles/hustler/pult.css (alle mit hk- davor). Fachlich bleibt alles bei workersPult.js (rowModel, Filter, Texte);
// hier steht nur, wie ein schon fertiges Zeilenmodell aussieht. Keine Beispieldaten: die Texte sind UI-Text.
import { PULT_KIND_LABELS } from './workersPult.js'
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const isCount = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0
// ---------------------------------------------------------------------------------------------------------------
// Szene (640 x 360 Bildpunkte, Vorlage Freigabe.dc.html): Lage der Einzelteile, soweit sie im Markup der Vorlage inline stehen
export const SZENE = Object.freeze({ w: 640, h: 360 })
/** Lage (left/top in Bildpunkten der Szene) und, wo die Vorlage sie nennt, Größe der Einzelteile. */
export const SZENE_TEILE = Object.freeze({
fenster1: Object.freeze({ left: 106, top: 46, width: 79, height: 55 }),
fenster2: Object.freeze({ left: 553, top: 61, width: 47, height: 100 }),
auflicht: Object.freeze({ left: 1, top: 320 }),
tuer: Object.freeze({ left: 11, top: 219 }),
ruftaste: Object.freeze({ left: 66, top: 262 }),
funke: Object.freeze({ left: 211, top: 274 }),
ding: Object.freeze({ left: 14, top: 197 }),
briefkasten: Object.freeze({ left: 229, top: 232 }),
monitor: Object.freeze({ left: 158, top: 243 }),
dampf: Object.freeze({ left: 107, top: 244 }),
antenne: Object.freeze({ left: 167, top: 52 }),
})
/** Anfangsmaß, bevor gemessen wurde (Vorlage: Zoom 2, keine Verschiebung). */
export const SZENE_START = Object.freeze({ s: 2, x: 0, y: 0, transform: 'translate(0.0px, 0.0px) scale(2.0000)' })
/**
* Einpassen „cover“ (bindeRoot/passe in logik.js): Zoom so, dass die Szene die Fläche füllt. Ist die Szene breiter als die Fläche, wird sie um 15 %
* des Überstands nach links geschoben (x = min(0, 0,15 · (Breite − 640 s))); senkrecht bleibt der Raum unten sichtbar
* (y = min(Überstand / 2, Überstand + 36 s), Überstand = Höhe − 360 s). Ergebnis { s, x, y, transform } oder null bei leerer oder unbrauchbarer Fläche.
*/
export function szenePassung(width, height) {
if (!(typeof width === 'number' && typeof height === 'number' && width > 0 && height > 0 && Number.isFinite(width) && Number.isFinite(height))) return null
const s = Math.max(width / SZENE.w, height / SZENE.h)
const ueber = height - SZENE.h * s
const x = Math.min(0, (width - SZENE.w * s) * 0.15)
const y = Math.min(ueber / 2, ueber + 36 * s)
return { s, x, y, transform: `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${s.toFixed(4)})` }
}
/** Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur, solange etwas wartet: kpis.pultOpen > 0 (Spec E11); sonst ruht der Raum. */
export function botenLaufen(kpis) {
return isRecord(kpis) && typeof kpis.pultOpen === 'number' && Number.isFinite(kpis.pultOpen) && kpis.pultOpen > 0
}
// ---------------------------------------------------------------------------------------------------------------
// Klemmbrett: Reiter, Untertitel, Leerzustände
/** Klasse des Reiters je Filter-Id (Farbe des Reiters in pult.css). */
export const REITER_KLASSE = Object.freeze({ offen: 'hk-reiter-wartet', veraltet: 'hk-reiter-veraltet', erledigt: 'hk-reiter-erledigt' })
/** Untertitel des Klemmbretts je Filter (Vorlage). */
export const UNTERTITEL = Object.freeze({
offen: 'Hier wartet alles, was ein Mensch freigeben muss.',
veraltet: 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.',
erledigt: 'Was du oder das Team schon abgeschlossen habt.',
})
/** Leerzustand je Filter (Titel und Hinweis, Vorlage = die bisherigen Texte der Liste). */
export const LEER = Object.freeze({
offen: Object.freeze({ title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' }),
veraltet: Object.freeze({ title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' }),
erledigt: Object.freeze({ title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' }),
})
/**
* „Wartet auf dich“ (Zahl im Kopf und am Reiter „Wartet“): kpis.pultOpen, wenn es eine Zahl ist; sonst, wenn der Reiter „Wartet“ offen und
* geladen ist, die Länge der Liste; sonst null (keine Zahl zeigen, kein „0“ erfinden).
*/
export function wartetZahl(kpis, aktivId, geladen) {
if (isRecord(kpis) && isCount(kpis.pultOpen)) return kpis.pultOpen
if (aktivId === 'offen' && isCount(geladen)) return geladen
return null
}
/**
* Zahl am Reiter (Spec E11): „Wartet“ zeigt kpis.pultOpen (dieselbe Zahl wie der Briefkasten im Haus und der Kopf des Pults; fehlt sie, am offenen Reiter die Länge
* der geladenen Liste), der offene Reiter „Veraltet“ oder „Erledigt“ die Länge seiner Liste (geladen = null, solange sie nicht da ist); die übrigen Reiter haben keine
* Zahl (sonst drei Abrufe alle 30 s). Ergebnis: Zahl oder null.
*/
export function reiterZahl(reiterId, { aktivId, kpis, geladen } = {}) {
if (reiterId === 'offen') return wartetZahl(kpis, aktivId, geladen)
if (reiterId === aktivId && isCount(geladen)) return geladen
return null
}
/** Name des Reiters für Screenreader: „Wartet, 3 Einträge“; ohne Zahl nur das Wort. */
export function reiterName(label, zahl) {
if (!isCount(zahl)) return label
return `${label}, ${zahl} ${zahl === 1 ? 'Eintrag' : 'Einträge'}`
}
// ---------------------------------------------------------------------------------------------------------------
// Umschlag
/** Icon (Teil der Klasse hk-ic-<icon>) je Pult-Art; unbekannte Arten bekommen das Fragezeichen-Icon. */
export const ART_ICONS = Object.freeze({
plattform_antwort: 'art-plattform',
mail_antwort: 'art-mail',
vorschau_freigabe: 'art-vorschau',
unsicher: 'art-unsicher',
beschwerde: 'art-beschwerde',
preisfrage: 'art-preisfrage',
mensch_gewuenscht: 'art-mensch',
bau_fehlgeschlagen: 'art-bau-kaputt',
})
const ART_UNBEKANNT = 'art-unsicher'
/** Name und Icon einer Pult-Art. */
export function artView(kind) {
return {
label: Object.hasOwn(PULT_KIND_LABELS, kind) ? PULT_KIND_LABELS[kind] : String(kind ?? ''),
icon: Object.hasOwn(ART_ICONS, kind) ? ART_ICONS[kind] : ART_UNBEKANNT,
}
}
export const DU_KUEMMERST = 'Du kümmerst dich darum.'
/** „66640 Namborn · Score 72“ aus der Kurzfassung des Interessenten (leer, wenn nichts da ist). */
export function prospectInfo(prospect) {
if (!isRecord(prospect)) return ''
const place = [prospect.postalCode, prospect.city].filter(Boolean).join(' ')
return [place, prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
}
/**
* Was ein Umschlag (und die Kopfzeile des Briefs) zeigt, aus dem fertigen Zeilenmodell (rowModel):
* art/icon, titel, alter (bei Abgeschlossenem leer: das Alter ab Anlage führte dort in die Irre, der Stempel nennt Ergebnis, Person und Zeit),
* info (Ort · Score), frist („läuft in N Std. ab“), veraltet, gehalten („Du kümmerst dich darum.“ / „übernommen von …“), fertig { text, klasse } und
* label (zugänglicher Name des Umschlags: Art: Titel, Alter, Frist, veraltet, Halter, Ergebnis).
*/
export function umschlagModell(row) {
const art = artView(row.kind)
const done = typeof row.doneLine === 'string' && row.doneLine !== ''
const alter = done ? '' : (row.ageLabel || '')
const frist = row.stale?.soon ? row.stale.label : ''
const veraltet = row.stale?.stale === true
let gehalten = ''
if (row.claimedByMe) gehalten = DU_KUEMMERST
else if (row.claimedByLabel) gehalten = `übernommen von ${row.claimedByLabel}`
const fertig = done ? { text: row.doneLine, klasse: row.statusTone === 'green' ? 'hk-abdruck hk-stempel-gruen' : 'hk-abdruck hk-stempel-grau' } : null
const titel = typeof row.title === 'string' ? row.title : ''
const teile = [titel.trim() === '' ? art.label : `${art.label}: ${titel}`]
if (alter) teile.push(alter)
if (frist) teile.push(frist)
if (veraltet) teile.push('veraltet')
if (gehalten) teile.push(row.claimedByMe ? 'du kümmerst dich darum' : gehalten)
if (fertig) teile.push(fertig.text)
return { id: row.id, artLabel: art.label, icon: art.icon, titel, alter, info: prospectInfo(row.prospect), frist, veraltet, gehalten, fertig, label: teile.join(', ') }
}
+138
View File
@@ -0,0 +1,138 @@
/* Handgeschriebene Regeln des Pults (PultList.jsx, pult/*.jsx; Hustler-Pixelwelt T9, Spec E11/E13/E14). Nicht erzeugt: Szene, Reiter, Klemmbrett, Umschlag, Brief,
Stempel, Feld, Icons und Keyframes kommen aus pult.css (scripts/hustler-import.mjs, nie von Hand ändern). Hier stehen die Inline-Stile der Zeichenfläche
(pixelwelt-v2/pult/entwurf/Freigabe.dc.html) als Klassen sowie das, was die Vorlage nicht zeigt (Meldungen, Laden, Leerzustand, Handy). Alle Klassen beginnen mit hk-
und stehen unter der Wurzel .hk-pult. Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS); diese Datei kommt nach pult.css. */
/* ---------- Wurzel: füllt den Inhaltsbereich (Vorlage: 100 % x 100vh, nichts scrollt außer der Liste und dem Brief) ---------- */
.hk-pult { position: absolute; inset: 0; overflow: hidden; color: #2b2230; font-size: 15px; line-height: 20px; }
/* ---------- Szene: Rahmen, Bühne (640 x 360, per transform eingepasst), Teile, Vignette ---------- */
.hk-pult .hk-pult-szene { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.hk-pult .hk-pult-buehne { position: absolute; left: 0; top: 0; width: 640px; height: 360px; transform-origin: 0 0; image-rendering: pixelated; pointer-events: none; }
.hk-pult .hk-pult-teil { position: absolute; }
.hk-pult .hk-pult-fenster { overflow: hidden; }
.hk-pult .hk-pult-antenne { width: 1px; height: 1px; background: #e2483d; }
.hk-pult .hk-pult-vignette {
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
}
/* ---------- Oberfläche vor der Szene: Kopfzeile, Reiter, Klemmbrett ---------- */
.hk-pult .hk-pult-ui {
position: absolute; top: 14px; right: 16px; bottom: 14px; width: min(716px, calc(100% - 32px));
display: flex; flex-direction: column;
}
.hk-pult .hk-pult-kopf { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 6px; }
.hk-pult .hk-pult-knopf { padding: 0 10px 4px 4px; }
.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-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-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; }
/* 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-pult-unten { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.hk-pult .hk-reiter-leiste { position: relative; z-index: 1; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; }
.hk-pult .hk-zahl { font-size: 13px; line-height: 14px; font-weight: 700; color: #5a4a3a; }
/* Klemmbrett: das Brett ist ein flexibler Streifen, die Liste scrollt darin */
.hk-pult .hk-brett { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
.hk-pult .hk-brett-kopf { padding: 6px 4px; border-bottom: 2px solid rgba(90, 60, 176, 0.3); }
.hk-pult .hk-brett-titel { margin: 0; font-size: 17px; line-height: 20px; font-weight: 700; color: #4a2fa0; }
.hk-pult .hk-brett-unter { margin: 2px 0 0; font-size: 13px; line-height: 16px; color: #5a4a3a; }
/* Meldungen auf dem Brett (Abschluss, später eintreffendes Ergebnis, Verbindung gestört) und im Brief */
.hk-pult .hk-hinweis {
margin: 0 4px; padding: 6px 10px; font-size: 14px; line-height: 18px; color: #2b2230; background: #fff7d6;
box-shadow: 0 0 0 2px #2b2230, 2px 2px 0 2px rgba(43, 34, 48, 0.22); overflow-wrap: anywhere;
}
.hk-pult .hk-hinweis:focus-visible { outline: 2px solid #1a1630; box-shadow: 0 0 0 4px #ffd25a; }
.hk-pult .hk-hinweis-ok { background: #e3f3d6; color: #1f5a2a; }
.hk-pult .hk-hinweis-err { background: #fbe3e0; color: #8e1f17; }
.hk-pult .hk-hinweis-warn { background: #fff3cf; color: #6b4a00; }
.hk-pult .hk-brief .hk-hinweis { margin: 12px 0 0; }
/* Laden und Leerzustand */
.hk-pult .hk-laden { margin: 8px 4px; color: #5a4a3a; }
.hk-pult .hk-leer { margin: 8px 4px; color: #5a4a3a; }
.hk-pult .hk-leer-titel { margin: 0; font-weight: 700; overflow-wrap: anywhere; }
.hk-pult .hk-leer-text { margin: 2px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.hk-pult .hk-leer-knopf { margin-top: 10px; }
/* ---------- Umschlag (Knopf in der Liste): Icon links über drei Zeilen, rechts Titel, Art · Alter, Marken ---------- */
.hk-pult .hk-liste .hk-umschlag { grid-template-columns: 32px minmax(0, 1fr); }
.hk-pult .hk-umschlag-art { grid-area: 1 / 1 / 4 / 2; }
.hk-pult .hk-umschlag-titel { grid-column: 2; grid-row: 1; font-size: 15px; line-height: 18px; font-weight: 700; }
.hk-pult .hk-umschlag-art-alter { grid-column: 2; grid-row: 2; }
.hk-pult .hk-umschlag-marken { grid-column: 2; grid-row: 3; display: flex; align-items: center; flex-wrap: wrap; gap: 2px 10px; min-height: 16px; }
.hk-pult .hk-klein-inline { display: inline; }
.hk-pult .hk-marke-frist { color: #9a4a10; }
.hk-pult .hk-marke-halter { color: #4a2fa0; }
.hk-pult .hk-marke-veraltet { color: #8a4a20; }
/* ---------- 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; }
.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;
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
}
.hk-pult .hk-brief { position: relative; width: min(640px, 100%); max-height: 100%; display: flex; flex-direction: column; color: #2b2230; outline: none; }
.hk-pult .hk-brief-inhalt { overflow: auto; min-height: 0; padding: 6px; scrollbar-width: thin; scrollbar-color: #5a3cb0 #e8dcc0; }
.hk-pult .hk-brief-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.hk-pult .hk-brief-titelblock { flex: 1 1 auto; min-width: 0; order: 1; }
.hk-pult .hk-brief-zu { order: 2; flex: none; }
.hk-pult .hk-brief-art { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #4a2fa0; }
.hk-pult .hk-brief-titel { margin: 6px 0 0; font-size: 19px; line-height: 24px; font-weight: 700; overflow-wrap: anywhere; }
.hk-pult .hk-brief-meta { margin-top: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: #5a4a3a; }
.hk-pult .hk-link {
display: inline-flex; align-items: center; min-height: 24px; margin: 0; padding: 0; border: 0; background: none;
font: inherit; color: #4a2fa0; text-decoration: underline; text-underline-offset: 3px; cursor: pointer;
}
.hk-pult .hk-link:hover { color: #2f1a80; }
.hk-pult .hk-link:focus-visible { outline: 2px solid #1a1630; outline-offset: 2px; box-shadow: 0 0 0 4px #ffd25a; }
.hk-pult .hk-brief-h { margin: 14px 0 4px; font-size: 13px; line-height: 16px; font-weight: 700; color: #4a2fa0; }
.hk-pult .hk-brief-h-aktionen { margin: 16px 0 6px; }
.hk-pult .hk-brief-zitat {
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;
}
.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-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 {
margin: 0; padding: 2px 10px 4px; white-space: pre-line; font-size: 15px; line-height: 20px; overflow-wrap: anywhere;
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-brief-hinweis { margin: 0; font-size: 13px; color: #5a4a3a; }
.hk-pult .hk-brief-hinweis-bau { color: #7a2e3a; }
.hk-pult .hk-stempel:disabled { opacity: 0.5; cursor: default; }
.hk-pult a.hk-stempel { text-decoration: none; }
.hk-pult .hk-feld-panel { margin-top: 14px; }
.hk-pult .hk-feld-antwort,
.hk-pult .hk-feld-einfuegen { padding-top: 6px; border-top: 2px dashed rgba(90, 60, 176, 0.25); }
.hk-pult .hk-feld-panel .hk-feld-label { margin-top: 0; }
.hk-pult .hk-feld-panel .hk-brief-hinweis { margin: 6px 0 0; }
.hk-pult .hk-feld-fuss { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.hk-pult .hk-zaehler { font-size: 13px; line-height: 16px; color: #5a4a3a; }
.hk-pult .hk-zaehler-ueber { color: #a8261d; font-weight: 700; }
.hk-pult .hk-feld-knoepfe { display: flex; gap: 10px; }
/* ---------- Handy (unter 720 px, Spec E13): Klemmbrett in voller Breite über der abgedunkelten Szene, Brief als Vollbild ---------- */
@media (max-width: 719px) {
.hk-pult .hk-pult-vignette {
background: linear-gradient(rgba(6, 7, 18, 0.55), rgba(6, 7, 18, 0.55)), radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
}
.hk-pult .hk-pult-ui { left: 8px; right: 8px; top: 8px; bottom: 8px; width: auto; }
.hk-pult .hk-reiter-leiste { padding-left: 8px; }
/* Kopfzeile in zwei Reihen: Zurück und Zuklappen nebeneinander, darunter das Messingschild; die Klammer entfällt (sie läge auf dem dritten Reiter) */
.hk-pult .hk-pult-kopf { gap: 4px 8px; margin-bottom: 2px; }
.hk-pult .hk-pult-knopf { order: 1; }
.hk-pult .hk-pult-schild { order: 2; flex: 1 1 100%; }
.hk-pult .hk-klammer { display: none; }
.hk-pult .hk-brief-ueber { padding: 0; }
.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-knoepfe { flex-direction: column-reverse; }
.hk-pult .hk-feld-knoepfe .hk-stempel { justify-content: center; }
}
+688
View File
@@ -0,0 +1,688 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { fakeReact } from './helpers/fakeReact.mjs'
import { loadModule } from './load.mjs'
// Dein Pult (Hustler-Pixelwelt T9, Spec E11/E13/E14): die reine Logik (src/lib/hustlerPult.js) und die Bauteile PultList, PultSzene, Klemmbrett, Brief im Mini-Läufer
// anstelle von React (helpers/fakeReact.mjs). Die Aktionen des Briefs (Antworten, Freigeben, Bauen, Einfügen, Fokus nach Aktionen) prüft workersPultUi.test.mjs;
// hier stehen Umschlag-Inhalt je Art und Status, Zahlen an den Reitern, Brief öffnen/schließen/Fokus, Boten nur bei wartenden Einträgen, „Pult zuklappen“, „◂ Hochhaus“,
// der Weg zum Interessenten-Panel, die Klassen gegen das CSS und die Regeln der Architektur (kein CSS-/Bild-Import, kein innerHTML, sichere Links).
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8')
const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
const settle = () => new Promise((resolve) => setImmediate(resolve))
const L = await loadModule('src/lib/hustlerPult.js')
const P = await loadModule('src/lib/workersPult.js')
const { PULT_KIND_LABELS, PULT_FILTERS, rowModel } = P
const NOW = Date.now() // die Bauteile rechnen mit der Uhr des Rechners: Alter und Fristen hängen deshalb an dieser Zeit
const iso = (ms) => new Date(ms).toISOString()
const ME = { id: 'u1', name: 'Justin' }
const EMPLOYEES = [{ userId: 'u2', displayName: 'Kenso' }]
const rowOf = (item) => rowModel(item, { me: ME, nowMs: NOW, employees: EMPLOYEES })
const entry = (extra = {}) => ({ $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(NOW - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72, email: 'x@verein.example' }, ...extra })
// ---------------------------------------------------------------------------------------------------------------
// Reine Logik
test('szenePassung: „cover“ wie bindeRoot/passe der Vorlage, unbrauchbare Flächen ergeben null', () => {
const at = (w, h) => { const p = L.szenePassung(w, h); return p && [p.s, p.x, p.y, p.transform] }
assert.deepEqual(at(1280, 720), [2, 0, 0, 'translate(0.0px, 0.0px) scale(2.0000)'])
assert.deepEqual(at(1920, 1080), [3, 0, 0, 'translate(0.0px, 0.0px) scale(3.0000)'])
// breiter als hoch: die Szene ist höher als die Fläche, unten bleibt der Raum sichtbar (y = Überstand + 36 s, hier der kleinere von Überstand / 2 und Überstand + 36 s)
assert.deepEqual(at(2560, 1080), [4, 0, -216, 'translate(0.0px, -216.0px) scale(4.0000)'])
// höher als breit: die Szene ist breiter als die Fläche und rutscht um 15 % des Überstands nach links
assert.deepEqual(at(800, 900), [2.5, -120, 0, 'translate(-120.0px, 0.0px) scale(2.5000)'])
for (const [w, h] of [[0, 100], [100, 0], [-1, 5], [NaN, 5], [5, Infinity], ['5', 5], [undefined, undefined]]) assert.equal(L.szenePassung(w, h), null, `${w} x ${h}`)
assert.deepEqual({ ...L.SZENE }, { w: 640, h: 360 })
assert.equal(L.SZENE_START.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
})
test('botenLaufen: nur bei kpis.pultOpen > 0 (Zahl), sonst ruht der Raum', () => {
assert.equal(L.botenLaufen({ pultOpen: 1 }), true)
assert.equal(L.botenLaufen({ pultOpen: 12 }), true)
for (const kpis of [undefined, null, {}, { pultOpen: 0 }, { pultOpen: -1 }, { pultOpen: '3' }, { pultOpen: NaN }, { pultOpen: null }, [], 'x']) assert.equal(L.botenLaufen(kpis), false, JSON.stringify(kpis))
})
test('Zahlen an den Reitern: „Wartet“ aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die übrigen Reiter ohne Zahl', () => {
const z = (id, o) => L.reiterZahl(id, o)
// Wartet zeigt kpis.pultOpen (auch am offenen Reiter); ohne Zahl vom Dienst am offenen Reiter die Länge der Liste
assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 2 }), 5)
assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: null }), 5)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 9 }), 5)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: {}, geladen: 9 }), null)
assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: 4 }), 4)
assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: null }), null)
// Veraltet, Erledigt: nur wenn offen
assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), 3)
assert.equal(z('erledigt', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), null)
assert.equal(z('veraltet', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 3 }), null)
assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: {}, geladen: null }), null)
// kaputte Zahlen sind keine Zahlen
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: '5' }, geladen: 3 }), null)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: -2 }, geladen: 3 }), null)
assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 0 }, geladen: 3 }), 0)
assert.equal(L.wartetZahl({ pultOpen: 3 }, 'erledigt', null), 3)
assert.equal(L.wartetZahl({}, 'offen', 7), 7)
assert.equal(L.wartetZahl({}, 'veraltet', 7), null)
assert.equal(L.reiterName('Wartet', 1), 'Wartet, 1 Eintrag')
assert.equal(L.reiterName('Wartet', 0), 'Wartet, 0 Einträge')
assert.equal(L.reiterName('Erledigt', 12), 'Erledigt, 12 Einträge')
assert.equal(L.reiterName('Veraltet', null), 'Veraltet')
assert.deepEqual(Object.keys(L.REITER_KLASSE), PULT_FILTERS.map((f) => f.id))
assert.deepEqual(Object.keys(L.UNTERTITEL), PULT_FILTERS.map((f) => f.id)); assert.deepEqual(Object.keys(L.LEER), PULT_FILTERS.map((f) => f.id))
})
test('artView: jede Pult-Art des Dienstes hat Name und Icon; Namen wie PULT_KIND_LABELS, unbekannte Art bekommt das Fragezeichen', () => {
assert.deepEqual(Object.keys(L.ART_ICONS).sort(), Object.keys(PULT_KIND_LABELS).sort())
for (const [kind, label] of Object.entries(PULT_KIND_LABELS)) assert.deepEqual({ ...L.artView(kind) }, { label, icon: L.ART_ICONS[kind] }, kind)
assert.deepEqual({ ...L.artView('neu_vom_dienst') }, { label: 'neu_vom_dienst', icon: 'art-unsicher' })
// jedes Icon gibt es im erzeugten CSS
const css = read('src/styles/hustler/pult.css')
for (const icon of Object.values(L.ART_ICONS)) assert.ok(css.includes(`.hk-ic-${icon}{`), icon)
})
test('umschlagModell: Inhalt je Art und Status – Titel, Alter, Ort · Score, Frist, veraltet, Halter, Ergebnis-Stempel, zugänglicher Name', () => {
const m = (extra) => L.umschlagModell(rowOf(entry(extra)))
// offene Plattform-Antwort: Art, Alter, Ort · Score, nichts sonst
let u = m()
assert.deepEqual({ ...u, fertig: u.fertig }, {
id: 'a', artLabel: 'Antwort auf Plattform-Anfrage', icon: 'art-plattform', titel: 'Suche Webdesigner', alter: 'vor 3 Std.', info: '66640 Namborn · Score 72',
frist: '', veraltet: false, gehalten: '', fertig: null, label: 'Antwort auf Plattform-Anfrage: Suche Webdesigner, vor 3 Std.',
})
// jede Art zeigt ihr Icon
for (const kind of Object.keys(PULT_KIND_LABELS)) assert.equal(m({ kind }).icon, L.ART_ICONS[kind], kind)
// läuft bald ab (< 24 h), veraltet (Status), veraltet durch staleAt
u = m({ staleAt: iso(NOW + 5 * 3600e3) })
assert.equal(u.frist, 'läuft in 5 Std. ab'); assert.equal(u.veraltet, false); assert.ok(u.label.endsWith('vor 3 Std., läuft in 5 Std. ab'))
u = m({ status: 'veraltet' })
assert.equal(u.veraltet, true); assert.equal(u.frist, ''); assert.ok(u.label.endsWith(', veraltet'))
assert.equal(m({ staleAt: iso(NOW - 1000) }).veraltet, true)
assert.equal(m({ staleAt: iso(NOW + 3 * 24 * 3600e3) }).frist, '')
// übernommen: von mir „Du kümmerst dich darum.“, von einem Kollegen mit Namen, von Max automatisch, unbekannt „Kollege“
u = m({ status: 'in_arbeit', claimedBy: 'mensch:u1' })
assert.equal(u.gehalten, 'Du kümmerst dich darum.'); assert.ok(u.label.endsWith(', du kümmerst dich darum'))
u = m({ status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.equal(u.gehalten, 'übernommen von Kenso'); assert.ok(u.label.endsWith(', übernommen von Kenso'))
assert.equal(m({ status: 'in_arbeit', claimedBy: 'max' }).gehalten, 'übernommen von Max (automatisch)')
assert.equal(m({ status: 'in_arbeit', claimedBy: 'mensch:u9' }).gehalten, 'übernommen von Kollege')
// bei veraltet hält niemand mehr, auch wenn der frühere Bearbeiter noch drinsteht
assert.equal(m({ status: 'veraltet', claimedBy: 'mensch:u2' }).gehalten, '')
// abgeschlossen: Ergebnis-Stempel statt Alter ab Anlage (gesendet grün, die übrigen grau)
const done = { createdAt: iso(NOW - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(NOW - 2 * 3600e3) }
u = m({ ...done, status: 'gesendet' })
assert.deepEqual({ ...u.fertig }, { text: 'Gesendet von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-gruen' }); assert.equal(u.alter, '')
assert.equal(u.label, 'Antwort auf Plattform-Anfrage: Suche Webdesigner, Gesendet von Kenso · vor 2 Std.')
assert.deepEqual({ ...m({ ...done, status: 'verworfen' }).fertig }, { text: 'Verworfen von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
assert.deepEqual({ ...m({ ...done, status: 'erledigt', doneBy: 'max' }).fertig }, { text: 'Erledigt von Max (automatisch) · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
assert.equal(m({ ...done, status: 'erledigt', doneBy: undefined }).fertig.text, 'Erledigt · vor 2 Std.')
// ohne Interessent keine Kurzinfo, ohne Titel nur die Art im Namen, ohne Alter (kaputte Zeit) kein Alter
assert.equal(m({ prospect: null }).info, '')
assert.equal(m({ title: ' ' }).label, 'Antwort auf Plattform-Anfrage, vor 3 Std.')
assert.equal(m({ createdAt: 'kaputt' }).alter, '')
assert.equal(L.prospectInfo({ city: 'Worms' }), 'Worms'); assert.equal(L.prospectInfo({ score: 0 }), 'Score 0'); assert.equal(L.prospectInfo(null), '')
})
// ---------------------------------------------------------------------------------------------------------------
// Bauteile im Mini-Läufer
// Was das Pult von außen holt: Feed (je Filter eine Antwort), Mitarbeiterliste, Zwischenablage, das Interessenten-Panel (hält seine Eigenschaften fest)
const STUBS = {
usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }',
useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
CopyableCredential: 'export async function copyTextChecked() {}',
ProspectPanel: "import { jsx } from 'react'\nexport default function ProspectPanel(props) { globalThis.__panel = props; return jsx('div', { 'data-panel': props.prospectId }) }",
}
const stubs = {
name: 'pult-stubs',
setup(build) {
build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
},
}
const loadUi = () => esbuild.build({
stdin: {
contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'\nexport { default as PultSzene } from './src/components/workers/pult/PultSzene.jsx'",
resolveDir: ROOT, loader: 'js',
},
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs],
}).then(importBundle)
const nodes = (node, test, out = []) => {
if (node === null || typeof node === 'string') return out
if (test(node)) out.push(node)
for (const child of node.children) nodes(child, test, out)
return out
}
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name)
const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name))
const btn = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
const classesIn = (tree, into = new Set()) => {
for (const n of nodes(tree, (x) => typeof x.tag === 'string')) for (const c of String(n.props.className ?? '').split(' ')) if (c) into.add(c)
return into
}
function fakeFocus() {
const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body }
const f = { doc, body, found: new Map(), made: new Map(), all: [] }
f.el = (name, extra = {}) => ({
name, isConnected: true, disabled: false, focusCalls: 0, focus() { this.focusCalls += 1; doc.activeElement = this },
querySelector: (selector) => f.found.get(selector) ?? null, querySelectorAll: () => f.all, ...extra,
})
f.ref = (key) => f.made.get(key)
const had = Object.hasOwn(globalThis, 'document')
const saved = globalThis.document
globalThis.document = doc
globalThis.__fakeRef = (tag, props) => {
const key = String(props.className ?? tag).split(' ')[0]
if (!f.made.has(key)) f.made.set(key, f.el(key))
return f.made.get(key)
}
f.restore = () => {
if (had) globalThis.document = saved
else delete globalThis.document
delete globalThis.__fakeRef
}
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
async function startPult(items, { api = { pultAction: async () => ({}) }, kpis, onBack, feed = {} } = {}) {
const { __mount, jsx, PultList } = await loadUi()
const timers = new Map()
let nextTimer = 1
const refreshes = []
const saved = { window: globalThis.window }
globalThis.window = {
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearTimeout: (id) => { timers.delete(id) },
open: () => {},
}
globalThis.__pultEmployees = EMPLOYEES
globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra })
delete globalThis.__panel
const mounted = __mount(jsx(PultList, { api, me: ME, kpis, onBack }))
const view = {
timers, refreshes,
tree: () => mounted.tree(),
brief: () => nodes(view.tree(), (n) => n.tag === 'section' && hasClass(n, 'hk-brief'))[0] ?? null,
envelopes: () => byClass(view.tree(), 'hk-umschlag'),
async open(index = 0, currentTarget) {
const e = view.envelopes()[index]
assert.ok(e, `Umschlag ${index}`)
e.props.onClick({ preventDefault() {}, currentTarget })
await settle()
},
async press(node) { node.props.onClick?.({ preventDefault() {} }); await settle() },
async briefKey(key, extra = {}) {
const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true }, ...extra }
view.brief().props.onKeyDown(event)
await settle()
return event
},
close: () => view.press(nodes(view.brief(), (n) => n.props['aria-label'] === 'Schließen')[0]),
tab: async (label) => { await view.press(nodes(byClass(view.tree(), 'hk-reiter-leiste')[0], (n) => n.tag === 'button' && textOf(n).startsWith(label))[0]) },
end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees; delete globalThis.__panel },
}
await settle()
return view
}
const ALLE_ARTEN = Object.keys(PULT_KIND_LABELS).map((kind, i) => entry({ $id: `k${i}`, kind, title: `Titel ${kind}`, draft: 'Entwurf', originalText: 'Anfrage des Kunden', sourceUrl: kind === 'vorschau_freigabe' ? 'https://entwurf.project.webklar.com/AAA/' : 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospectId: 'p1' }))
test('Pult: die Liste zeigt je Eintrag einen Umschlag mit Art-Icon, Titel, Art · Alter, Ort · Score und den Marken des Zustands', async () => {
const items = [
entry({ $id: 'a' }),
entry({ $id: 'b', kind: 'mail_antwort', title: 'Zweite', status: 'veraltet' }),
entry({ $id: 'c', kind: 'unsicher', title: 'Dritte', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 5 * 3600e3) }),
entry({ $id: 'd', kind: 'preisfrage', title: 'Vierte', status: 'in_arbeit', claimedBy: 'mensch:u1' }),
entry({ $id: 'e', kind: 'beschwerde', title: 'Fünfte', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 2 * 3600e3) }),
]
const ui = await startPult(items)
try {
const rows = ui.envelopes()
assert.equal(rows.length, 5)
const sig = (n) => ({ icon: byClass(n, 'hk-ic')[0].props.className, titel: textOf(byClass(n, 'hk-umschlag-titel')[0]), artAlter: textOf(byClass(n, 'hk-umschlag-art-alter')[0]), marken: byClass(n, 'hk-marke').map(textOf), stempel: byClass(n, 'hk-abdruck').map(textOf), info: byClass(n, 'hk-klein-inline').map(textOf) })
assert.deepEqual(sig(rows[0]), { icon: 'hk-ic hk-ic-art-plattform hk-umschlag-art', titel: 'Suche Webdesigner', artAlter: 'Antwort auf Plattform-Anfrage · vor 3 Std.', marken: [], stempel: [], info: ['66640 Namborn · Score 72'] })
assert.deepEqual([sig(rows[1]).icon, sig(rows[1]).marken], ['hk-ic hk-ic-art-mail hk-umschlag-art', ['veraltet']])
assert.deepEqual([sig(rows[2]).icon, sig(rows[2]).marken], ['hk-ic hk-ic-art-unsicher hk-umschlag-art', ['läuft in 5 Std. ab', 'übernommen von Kenso']])
assert.deepEqual([sig(rows[3]).icon, sig(rows[3]).marken], ['hk-ic hk-ic-art-preisfrage hk-umschlag-art', ['Du kümmerst dich darum.']])
assert.deepEqual([sig(rows[4]).icon, sig(rows[4]).marken, sig(rows[4]).stempel, sig(rows[4]).artAlter], ['hk-ic hk-ic-art-beschwerde hk-umschlag-art', [], ['Gesendet von Kenso · vor 2 Std.'], 'Beschwerde'])
// Knopf mit Namen, Fenster-Hinweis und ohne eigenen Fokus-Trick: ein Klick öffnet, sonst nichts
assert.ok(rows.every((r) => r.tag === 'button' && r.props.type === 'button' && r.props['aria-haspopup'] === 'dialog'))
assert.equal(rows[2].props['aria-label'], 'Unsicher – bitte prüfen: Dritte, vor 3 Std., läuft in 5 Std. ab, übernommen von Kenso')
assert.equal(ui.brief(), null)
// „von <Hustler>“ gibt es nicht: ein Pult-Eintrag nennt keinen Hustler
assert.ok(!ui.tree() || !textOf(ui.tree()).includes('Gebracht von'))
} finally { ui.end() }
})
test('Pult: Zahlen an den Reitern und im Kopf – Wartet aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die anderen ohne Zahl', async () => {
const wartet = [entry({ $id: 'a' }), entry({ $id: 'b' })]
const byStatus = { 'offen,in_arbeit': wartet, veraltet: [entry({ $id: 'v', status: 'veraltet' })], 'gesendet,verworfen,erledigt': [entry({ $id: 'x', status: 'gesendet' }), entry({ $id: 'y', status: 'gesendet' }), entry({ $id: 'z', status: 'gesendet' })] }
const ui = await startPult([], { kpis: { pultOpen: 5 }, feed: { byStatus } })
try {
const tabs = () => byClass(ui.tree(), 'hk-reiter').map((t) => ({ text: textOf(t), name: t.props['aria-label'], an: t.props['aria-pressed'] }))
const kopf = () => textOf(byClass(ui.tree(), 'hk-pult-zahl')[0] ?? { children: [] })
// Wartet ist offen: die Liste hat 2 Einträge, Reiter und Kopf zeigen die Zahl des Dienstes (wie der Briefkasten im Haus)
assert.deepEqual(tabs(), [
{ text: 'Wartet5', name: 'Wartet, 5 Einträge', an: true }, { text: 'Veraltet', name: undefined, an: false }, { text: 'Erledigt', name: undefined, an: false },
])
assert.equal(kopf(), 'Wartet auf dich:5')
await ui.tab('Veraltet')
assert.deepEqual(tabs(), [
{ text: 'Wartet5', name: 'Wartet, 5 Einträge', an: false }, { text: 'Veraltet1', name: 'Veraltet, 1 Eintrag', an: true }, { text: 'Erledigt', name: undefined, an: false },
])
await ui.tab('Erledigt')
assert.deepEqual(tabs().map((t) => t.text), ['Wartet5', 'Veraltet', 'Erledigt3'])
assert.equal(kopf(), 'Wartet auf dich:5')
} finally { ui.end() }
// ohne Zahl vom Dienst: nur der offene Reiter „Wartet“ kennt seine Zahl (die Länge der Liste), im Kopf ebenso; in anderen Reitern fehlt sie überall
const ohne = await startPult([], { kpis: undefined, feed: { byStatus } })
try {
assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet2', 'Veraltet', 'Erledigt'])
assert.equal(textOf(byClass(ohne.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:2')
await ohne.tab('Veraltet')
assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet1', 'Erledigt'])
assert.equal(byClass(ohne.tree(), 'hk-pult-zahl').length, 0)
} finally { ohne.end() }
// während des Ladens und bei einem Fehler ohne Einträge steht keine erfundene „0“
const laden = await startPult([], { kpis: {}, feed: { extra: { loading: true } } })
try {
assert.deepEqual(byClass(laden.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt']); assert.equal(byClass(laden.tree(), 'hk-pult-zahl').length, 0)
assert.ok(textOf(laden.tree()).includes('Wird geladen …'))
} finally { laden.end() }
const kaputt = await startPult([], { kpis: {}, feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
try {
assert.deepEqual(byClass(kaputt.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt'])
assert.ok(textOf(kaputt.tree()).includes('Das Pult ist gerade nicht erreichbar'))
assert.equal(btn(kaputt.tree(), 'Neu laden').length, 1)
await kaputt.press(btn(kaputt.tree(), 'Neu laden')[0]); assert.equal(kaputt.refreshes.length, 1)
} finally { kaputt.end() }
})
test('Pult: Untertitel und Leerzustand je Reiter wie in der Vorlage', async () => {
const ui = await startPult([], { kpis: { pultOpen: 0 } })
try {
const sub = () => textOf(byClass(ui.tree(), 'hk-brett-unter')[0])
const leer = () => byClass(ui.tree(), 'hk-leer').map(textOf)
assert.equal(sub(), 'Hier wartet alles, was ein Mensch freigeben muss.'); assert.deepEqual(leer(), ['Nichts wartet auf dichNeue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'])
await ui.tab('Veraltet')
assert.equal(sub(), 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.'); assert.deepEqual(leer(), ['Nichts ist veraltetEinträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.'])
await ui.tab('Erledigt')
assert.equal(sub(), 'Was du oder das Team schon abgeschlossen habt.'); assert.deepEqual(leer(), ['Noch nichts erledigtGesendete, verworfene und erledigte Einträge stehen hier.'])
// der Kopf zeigt bei pultOpen 0 die „0“ (eine Zahl vom Dienst), die Reiter-Leiste ist eine Gruppe mit Namen
assert.equal(textOf(byClass(ui.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:0')
assert.equal(byClass(ui.tree(), 'hk-reiter-leiste')[0].props.role, 'group')
} finally { ui.end() }
})
test('Pult: die Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur bei kpis.pultOpen > 0, sonst steht die Ruhe-Klasse der Vorlage am Rahmen', async () => {
const klasse = async (kpis) => {
const ui = await startPult([], { kpis })
try {
const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
assert.equal(szene.props['aria-hidden'], 'true')
return hasClass(szene, 'hk-pult-ruhe')
} finally { ui.end() }
}
assert.equal(await klasse({ pultOpen: 3 }), false)
assert.equal(await klasse({ pultOpen: 1 }), false)
for (const kpis of [undefined, {}, { pultOpen: 0 }, { pultOpen: '2' }]) assert.equal(await klasse(kpis), true, JSON.stringify(kpis))
// die Ruhe-Regeln der Vorlage hängen an .hk-pult-ruhe unterhalb der Wurzel .hk-pult, die Boten und der Aufzug darunter
const css = read('src/styles/hustler/pult.css')
assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-boten-weg')); assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-sz-briefkasten'))
// die Szene steckt innerhalb der Wurzel (PultList) und enthält Boten und Fähnchen
const ui = await startPult([], { kpis: { pultOpen: 1 } })
try {
assert.ok(hasClass(ui.tree(), 'hk-pult'))
const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
for (const c of ['hk-boten-weg', 'hk-boten-bild', 'hk-sz-tuer', 'hk-sz-briefkasten', 'hk-sz-sekunde', 'hk-sz-schnee', 'hk-sz-kette0', 'hk-sz-lmond', 'hk-sz-monitor']) assert.ok(byClass(szene, c).length >= 1, c)
assert.match(byClass(szene, 'hk-pult-buehne')[0].props.style.transform, /^translate\(.*\) scale\(.*\)$/)
} finally { ui.end() }
})
test('PultSzene: passt sich der Fläche an (ResizeObserver, „cover“) und räumt den Beobachter auf', async () => {
const { __mount, jsx, PultSzene } = await loadUi()
const size = { width: 1920, height: 1080 }
const observed = []
const disconnected = []
const callbacks = []
globalThis.ResizeObserver = class { constructor(cb) { callbacks.push(cb) } observe(el) { observed.push(el) } disconnect() { disconnected.push(true) } }
globalThis.__fakeRef = () => ({ getBoundingClientRect: () => ({ ...size }) })
try {
const mounted = __mount(jsx(PultSzene, { laeuft: true }))
await settle()
const stage = () => nodes(mounted.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0]
assert.equal(stage().props.style.transform, 'translate(0.0px, 0.0px) scale(3.0000)')
assert.equal(observed.length, 1)
size.width = 2560; size.height = 1080
callbacks[0](); await settle()
assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)')
size.width = 0; size.height = 0
callbacks[0](); await settle()
assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)') // keine Fläche: das letzte Maß bleibt
mounted.unmount()
assert.equal(disconnected.length, 1)
} finally { delete globalThis.ResizeObserver; delete globalThis.__fakeRef }
// ohne ResizeObserver wird einmal gemessen; ohne Rahmen (Server, Node) passiert nichts
const einmal = __mount(jsx(PultSzene, {}))
await settle()
assert.equal(nodes(einmal.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0].props.style.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
einmal.unmount()
})
test('Pult: „Pult zuklappen“ blendet Reiter und Klemmbrett aus (die Szene bleibt), „Pult aufklappen“ bringt sie zurück', async () => {
const ui = await startPult([entry()], { kpis: { pultOpen: 1 } })
try {
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 1)
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
await ui.press(btn(ui.tree(), 'Pult zuklappen')[0])
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 0); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 0); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 0)
assert.equal(byClass(ui.tree(), 'hk-pult-szene').length, 1) // die Szene bleibt ganz zu sehen
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 0); assert.equal(btn(ui.tree(), 'Pult aufklappen').length, 1)
assert.equal(byClass(ui.tree(), 'hk-pult-zahl').length, 1) // Kopf mit Zahl bleibt
await ui.press(btn(ui.tree(), 'Pult aufklappen')[0])
assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 1)
assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
} finally { ui.end() }
})
test('Pult: „◂ Hochhaus“ ruft onBack; ohne onBack passiert beim Klick nichts', async () => {
const calls = []
const ui = await startPult([], { onBack: () => calls.push('zurück') })
try {
const back = btn(ui.tree(), 'Hochhaus')
assert.equal(back.length, 1); assert.equal(back[0].tag, 'button'); assert.equal(back[0].props.type, 'button')
assert.ok(byClass(back[0], 'hk-ic-zurueck').length === 1 && byClass(back[0], 'hk-ic-zurueck')[0].props['aria-hidden'] === 'true')
await ui.press(back[0]); await ui.press(back[0])
assert.deepEqual(calls, ['zurück', 'zurück'])
} finally { ui.end() }
const stumm = await startPult([])
try { await stumm.press(btn(stumm.tree(), 'Hochhaus')[0]) } finally { stumm.end() }
})
// ---- Brief: öffnen, schließen, Fokus ----
test('Pult: ein Klick auf den Umschlag öffnet den Brief als Dialog (role, aria-modal, Name), der Fokus geht auf den ersten Knopf „Schließen“', async () => {
const f = fakeFocus()
const ui = await startPult([entry({ title: 'Suche Webdesigner' })])
try {
const umschlag = f.el('umschlag')
f.doc.activeElement = umschlag
await ui.open(0, umschlag)
const brief = ui.brief()
assert.ok(brief)
assert.deepEqual([brief.props.role, brief.props['aria-modal'], brief.props['aria-label'], brief.props.tabIndex], ['dialog', 'true', 'Antwort auf Plattform-Anfrage: Suche Webdesigner', -1])
// der erste Knopf im Brief ist „Schließen“ und hat den Fokus
const buttons = nodes(brief, (n) => n.tag === 'button' || n.tag === 'a')
assert.equal(buttons[0].props['aria-label'], 'Schließen')
assert.deepEqual([f.ref('hk-knopf').focusCalls, f.doc.activeElement], [1, f.ref('hk-knopf')])
// Hintergrund-Knopf (nicht in der Tab-Reihenfolge) schließt
const hinten = byClass(ui.tree(), 'hk-brief-hinten')[0]
assert.deepEqual([hinten.tag, hinten.props.tabIndex, hinten.props['aria-label']], ['button', -1, 'Brief schließen'])
// Überschrift, Art, Original und Entwurf stehen im Brief
assert.equal(textOf(byClass(brief, 'hk-brief-titel')[0]), 'Suche Webdesigner')
assert.equal(byClass(brief, 'hk-brief-titel')[0].tag, 'h2')
} finally { ui.end(); f.restore() }
})
test('Pult: der Brief schließt mit „Schließen“, Hintergrund und Esc; der Fokus geht an den Umschlag zurück', async () => {
for (const weg of ['knopf', 'hintergrund', 'esc']) {
const f = fakeFocus()
const ui = await startPult([entry()])
try {
const umschlag = f.el('umschlag')
await ui.open(0, umschlag)
assert.ok(ui.brief(), weg)
if (weg === 'knopf') await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
if (weg === 'hintergrund') await ui.press(byClass(ui.tree(), 'hk-brief-hinten')[0])
if (weg === 'esc') { const event = await ui.briefKey('Escape'); assert.ok(event.defaultPrevented && event.stopped) }
assert.equal(ui.brief(), null, weg)
assert.deepEqual([umschlag.focusCalls, f.doc.activeElement], [1, umschlag], weg)
// eine andere Taste schließt nicht
} finally { ui.end(); f.restore() }
}
const ui = await startPult([entry()])
try {
await ui.open(0)
await ui.briefKey('a'); await ui.briefKey('Enter')
assert.ok(ui.brief())
} finally { ui.end() }
})
test('Pult: Esc schließt erst ein offenes Feld, dann den Brief; Esc bei laufender Anfrage lässt das Feld stehen', async () => {
const f = fakeFocus()
let finish
const api = { pultAction: () => new Promise((resolve) => { finish = resolve }) }
const ui = await startPult([entry({ kind: 'unsicher', draft: 'Entwurf' })], { api })
try {
const umschlag = f.el('umschlag')
const opener = f.el('opener')
f.found.set('[data-opens="reply"]', opener)
await ui.open(0, umschlag)
await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1)
let event = await ui.briefKey('Escape') // der Fokus liegt außerhalb des Textfelds: der Brief schließt das Feld
assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 0); assert.ok(ui.brief()); assert.equal(opener.focusCalls, 1)
event = await ui.briefKey('Escape')
assert.equal(ui.brief(), null); assert.equal(umschlag.focusCalls, 1)
// während einer Anfrage: Feld bleibt (nur der Brief hat Esc behandelt)
await ui.open(0, umschlag)
await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
await ui.press(btn(ui.brief(), 'Jetzt senden')[0])
event = await ui.briefKey('Escape')
assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1); assert.ok(ui.brief())
finish({}); await settle()
} finally { ui.end(); f.restore() }
})
test('Pult: Tab bleibt im Brief (vom letzten Knopf zum ersten, Umschalt+Tab vom ersten oder vom Brief selbst zum letzten)', async () => {
const f = fakeFocus()
const ui = await startPult([entry()])
try {
const erster = f.el('erster'); const mitte = f.el('mitte'); const letzter = f.el('letzter'); const gesperrt = f.el('gesperrt', { disabled: true })
f.all = [erster, mitte, gesperrt, letzter]
await ui.open(0)
// Mitte: Tab geht seinen normalen Weg
f.doc.activeElement = mitte
let event = await ui.briefKey('Tab'); assert.equal(event.defaultPrevented, false)
event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
// Ende -> Anfang
f.doc.activeElement = letzter
event = await ui.briefKey('Tab'); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, erster)
// Anfang (und der Brief selbst) mit Umschalt -> Ende; gesperrte Knöpfe zählen nicht
event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
f.doc.activeElement = f.ref('hk-brief')
event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
// Umschalt+Tab vom Ende: normal
event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
} finally { ui.end(); f.restore() }
})
test('Pult: nach einem Abschluss schließt der Brief, die Meldung erscheint (role status) und nimmt den Fokus; der Umschlag bekommt ihn nicht', async () => {
const f = fakeFocus()
const ui = await startPult([entry({ kind: 'unsicher', title: 'Unklare Antwort', draft: '' })])
try {
const umschlag = f.el('umschlag')
await ui.open(0, umschlag)
const erledigt = f.el('erledigt-knopf', { disabled: true })
f.doc.activeElement = erledigt
await ui.press(btn(ui.brief(), 'Erledigt')[0])
assert.equal(ui.brief(), null)
const meldung = byClass(ui.tree(), 'hk-hinweis')[0]
assert.deepEqual([meldung.props.role, meldung.props.tabIndex, textOf(meldung)], ['status', -1, '„Unklare Antwort“ erledigt.'])
assert.deepEqual([f.ref('hk-hinweis').focusCalls, f.doc.activeElement, umschlag.focusCalls], [1, f.ref('hk-hinweis'), 0])
// ein Filterwechsel räumt die Meldung weg
await ui.tab('Veraltet')
assert.equal(byClass(ui.tree(), 'hk-hinweis').length, 0)
} finally { ui.end(); f.restore() }
})
test('Pult: verschwindet der Eintrag aus der Liste (ein Kollege war schneller), schließt sein offener Brief', async () => {
const f = fakeFocus()
const items = [entry({ $id: 'a', title: 'Eins' }), entry({ $id: 'b', title: 'Zwei' })]
const ui = await startPult(items)
try {
const umschlag = f.el('umschlag')
await ui.open(1, umschlag)
assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
items.splice(1, 1) // der nächste Abruf des Feeds liefert die kürzere Liste (hier: beim nächsten Zeichnen, ausgelöst durch einen Reiterwechsel)
await ui.tab('Veraltet')
assert.equal(ui.brief(), null)
assert.equal(ui.envelopes().length, 1)
} finally { ui.end(); f.restore() }
})
test('Pult: ein Ergebnis, das nach dem Schließen des Briefs eintrifft, erscheint in der Liste (Fehler als Alarm); ein Abschluss danach schließt keinen anderen Brief', async () => {
const f = fakeFocus()
let reject
let finish
const api = { pultAction: (id, action) => (action === 'discard' ? new Promise((resolve) => { finish = resolve }) : new Promise((_, no) => { reject = no })) }
const items = [entry({ $id: 'a', title: 'Eins', kind: 'unsicher', prospectId: 'p1' }), entry({ $id: 'b', title: 'Zwei', kind: 'unsicher', prospectId: 'p1' })]
const ui = await startPult(items, { api })
try {
// 1. Fehler nach dem Schließen: „Kann gesendet sein“ geht nicht verloren
await ui.open(0, f.el('u0'))
await ui.press(btn(ui.brief(), 'Vorschau bauen')[0]); await ui.press(btn(ui.brief(), 'Wirklich bauen?')[0])
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
assert.equal(ui.brief(), null)
reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
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.']])
// ein neuer Brief räumt die späte Meldung weg
await ui.open(1)
assert.equal(byClass(ui.tree(), 'hk-hinweis-err').length, 0)
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
// 2. Abschluss, nachdem ein anderer Brief offen ist: die Meldung kommt, der andere Brief bleibt, kein Fokus-Raub
await ui.open(0, f.el('u0b'))
await ui.press(btn(ui.brief(), 'Verwerfen')[0]); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0])
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
await ui.open(1, f.el('u1'))
const vorher = f.doc.activeElement
finish({}); await settle()
assert.ok(ui.brief()); assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-ok').map(textOf), ['„Eins“ verworfen.'])
assert.equal(f.doc.activeElement, vorher)
} finally { ui.end(); f.restore() }
})
test('Pult: „Interessent“ im Brief öffnet das Interessenten-Panel in der WOMS-Hülle, Schließen räumt es weg und gibt den Brief frei', async () => {
const ui = await startPult([entry({ prospect: { $id: 'p42', name: 'Verein' } })])
try {
await ui.open()
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
const knopf = btn(ui.brief(), 'Interessent')
assert.equal(knopf.length, 1); assert.equal(knopf[0].props['aria-label'], 'Interessent zu Suche Webdesigner')
await ui.press(knopf[0])
const huelle = byClass(ui.tree(), 'hk-woms')
assert.equal(huelle.length, 1)
assert.deepEqual(nodes(huelle[0], (n) => n.props['data-panel'] !== undefined).map((n) => n.props['data-panel']), ['p42'])
assert.equal(globalThis.__panel.prospectId, 'p42'); assert.ok(ui.brief()) // der Brief bleibt darunter offen
globalThis.__panel.onClose(); await settle()
assert.equal(byClass(ui.tree(), 'hk-woms').length, 0); assert.ok(ui.brief())
} finally { ui.end() }
// ohne Interessent gibt es den Knopf nicht
const ohne = await startPult([entry({ prospect: null })])
try { await ohne.open(); assert.equal(btn(ohne.brief(), 'Interessent').length, 0) } finally { ohne.end() }
})
// ---- Klassen gegen das CSS, Architektur-Regeln ----
const CSS_DATEIEN = ['pult.css', 'hustler-ui.css', 'hustler-pult-ui.css']
const definiert = () => {
const out = new Set()
for (const f of CSS_DATEIEN) for (const m of read('src/styles/hustler/' + f).matchAll(/\.(hk-[a-z0-9-]+)/g)) out.add(m[1])
return out
}
test('Pult: jede gerenderte hk-Klasse steht in pult.css, hustler-ui.css oder hustler-pult-ui.css (Liste, Szene, Brief in allen Arten, Felder, Rückfragen, Meldungen)', async () => {
const css = definiert()
const seen = new Set()
const jeder = [...ALLE_ARTEN,
entry({ $id: 'v', status: 'veraltet', staleAt: iso(NOW - 1000) }), entry({ $id: 'h', status: 'in_arbeit', claimedBy: 'mensch:u1' }), entry({ $id: 's', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 3600e3) }),
entry({ $id: 'g', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 3600e3) }), entry({ $id: 'x', status: 'verworfen', doneBy: 'max', doneAt: iso(Date.now() - 3600e3) }),
]
const api = { pultAction: async () => { throw Object.assign(new Error('Fehler des Dienstes.'), { status: 409 }) } }
const ui = await startPult(jeder, { api, kpis: { pultOpen: 3 }, onBack() {} })
try {
classesIn(ui.tree(), seen)
for (let i = 0; i < jeder.length; i += 1) {
await ui.open(i)
classesIn(ui.tree(), seen)
// jedes Feld und jede Rückfrage einmal aufklappen, dann Fehler und Erfolg zeigen
for (const label of ['Als Mensch antworten', 'Antwort des Kunden einfügen']) {
const k = btn(ui.brief(), label)[0]
if (k) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Abbrechen')[0]) }
}
for (const label of ['Verwerfen', 'Vorschau bauen', 'Freigeben & senden']) {
const k = btn(ui.brief(), label)[0]
if (k && !k.props.disabled) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0] ?? btn(ui.brief(), 'Wirklich bauen?')[0] ?? btn(ui.brief(), 'Wirklich freigeben?')[0]); classesIn(ui.tree(), seen) }
}
await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
}
// Meldung nach Abschluss, später Ergebnis, Panel
classesIn(ui.tree(), seen)
} finally { ui.end() }
for (const [name, extra] of [['laden', { loading: true }], ['fehler', { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' }]]) {
const z = await startPult([], { feed: { extra } })
try { classesIn(z.tree(), seen) } finally { z.end() }
assert.ok(seen.size > 0, name)
}
const mitFehler = await startPult(jeder.slice(0, 2), { feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
try { classesIn(mitFehler.tree(), seen) } finally { mitFehler.end() }
const ohneBoten = await startPult([], { kpis: { pultOpen: 0 } })
try { classesIn(ohneBoten.tree(), seen) } finally { ohneBoten.end() }
const zu = await startPult([entry({ prospect: { $id: 'p42' } })])
try {
await zu.open(); await zu.press(btn(zu.brief(), 'Interessent')[0]); classesIn(zu.tree(), seen)
await zu.press(btn(zu.tree(), 'Pult zuklappen')[0]); classesIn(zu.tree(), seen)
} finally { zu.end() }
const fehlt = [...seen].filter((c) => c.startsWith('hk-') && !css.has(c))
assert.deepEqual(fehlt, [])
// gesehen sind die wichtigen Klassen wirklich (der Test prüft nicht ins Leere)
for (const c of ['hk-pult', 'hk-pult-szene', 'hk-umschlag', 'hk-brief', 'hk-brief-ueber', 'hk-stempel', 'hk-stempel-frage', 'hk-stempel-gruen', 'hk-stempel-rot', 'hk-stempel-haupt', 'hk-feld-panel', 'hk-feld-antwort', 'hk-feld-einfuegen',
'hk-hinweis-err', 'hk-hinweis-warn', 'hk-leer', 'hk-laden', 'hk-reiter-an', 'hk-abdruck', 'hk-marke-frist', 'hk-marke-halter', 'hk-marke-veraltet', 'hk-woms', 'hk-brief-zitat', 'hk-brief-lesen', 'hk-link', 'hk-zaehler',
'hk-ic-art-plattform', 'hk-ic-art-mail', 'hk-ic-art-vorschau', 'hk-ic-art-unsicher', 'hk-ic-art-beschwerde', 'hk-ic-art-preisfrage', 'hk-ic-art-mensch', 'hk-ic-art-bau-kaputt', 'hk-ic-bauen', 'hk-ic-verwerfen']) {
assert.ok(seen.has(c), `nie gerendert: ${c}`)
}
})
test('Pult: Quelltext – kein CSS- oder Bild-Import, kein innerHTML, Links nur mit rel, jeder Knopf mit type, Bilder und Dekoration aria-hidden', () => {
const dateien = ['src/components/workers/PultList.jsx', 'src/components/workers/pult/Brief.jsx', 'src/components/workers/pult/Klemmbrett.jsx', 'src/components/workers/pult/Marken.jsx', 'src/components/workers/pult/PultSzene.jsx', 'src/lib/hustlerPult.js']
for (const f of dateien) {
const s = read(f)
assert.doesNotMatch(s, /import\s[^\n]*\.(css|png|svg|jpe?g|gif|webp|woff2?)['"]/, `${f}: Import von CSS oder Bild`)
assert.doesNotMatch(s, /import\s*['"][^'"]*\.(css|png|svg)['"]/, `${f}: Import von CSS oder Bild`)
assert.doesNotMatch(s, /innerHTML|dangerouslySetInnerHTML/, f)
assert.doesNotMatch(s, /\burl\(/, `${f}: Bild im Quelltext`)
assert.equal((s.match(/<button\b/g) ?? []).length, (s.match(/<button\b[^>]*\btype="button"/g) ?? []).length, `${f}: Knopf ohne type`)
// jeder externe Link öffnet ohne Rückbezug
for (const m of s.matchAll(/<a\b[^>]*\btarget="_blank"[^>]*>/g)) assert.match(m[0], /rel="noopener noreferrer"/, f)
}
// Links aus Dienst-Daten laufen nur über rowModel (sourceHref/previewHref/mailtoHref), nie über den rohen Eintrag
const brief = read('src/components/workers/pult/Brief.jsx')
assert.doesNotMatch(brief, /href=\{item\./); assert.doesNotMatch(brief, /sourceUrl/)
})
test('Pult: Handy unter 720 px – Klemmbrett in voller Breite über abgedunkelter Szene, Brief als Vollbild (CSS)', () => {
const css = read('src/styles/hustler/hustler-pult-ui.css')
const phone = css.slice(css.indexOf('@media (max-width: 719px)'))
assert.ok(phone.length > 0 && css.includes('@media (max-width: 719px)'))
assert.match(phone, /\.hk-pult-ui\s*\{[^}]*width:\s*auto/); assert.match(phone, /\.hk-pult-ui\s*\{[^}]*left:\s*8px/)
assert.match(phone, /\.hk-pult-vignette\s*\{[^}]*rgba\(6, 7, 18, 0\.55\)/)
assert.match(phone, /\.hk-brief-ueber\s*\{[^}]*padding:\s*0/); assert.match(phone, /\.hk-brief\s*\{[^}]*width:\s*100%[^}]*height:\s*100%/)
// am Desktop: Klemmbrett rechts, höchstens 716 px breit; Brief höchstens 640 px
assert.match(css, /\.hk-pult \.hk-pult-ui\s*\{[^}]*width:\s*min\(716px, calc\(100% - 32px\)\)/); assert.match(css, /\.hk-pult \.hk-brief\s*\{[^}]*width:\s*min\(640px, 100%\)/)
// „weniger Bewegung“ steht im erzeugten CSS der Vorlage
assert.ok(read('src/styles/hustler/pult.css').includes('@media (prefers-reduced-motion: reduce)'))
})
+5 -3
View File
@@ -415,12 +415,14 @@ test('ProspectPanel: Dialog mit Ladezustand und Fehler, lädt über api.prospect
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length) assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
}) })
test('PultList: "Details" öffnet das Interessenten-Panel, Schließen räumt die Auswahl weg', () => { test('PultList: der Knopf „Interessent“ im Brief öffnet das Interessenten-Panel (in der WOMS-Hülle), Schließen räumt die Auswahl weg', () => {
const s = read('src/components/workers/PultList.jsx') const s = read('src/components/workers/PultList.jsx')
assert.match(s, /import ProspectPanel from '\.\/ProspectPanel'/) assert.match(s, /import ProspectPanel from '\.\/ProspectPanel'/)
assert.match(s, /const \[detailId, setDetailId\] = useState\(null\)/) assert.match(s, /const \[detailId, setDetailId\] = useState\(null\)/)
assert.match(s, /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/) // der Brief (pult/Brief.jsx) öffnet mit dem Knopf „Interessent“ das Panel der Liste; es steht im Pult in der Hülle hk-woms (WOMS-Aussehen statt Pixel-Schrift)
assert.match(s, /\{detailId && <ProspectPanel api=\{api\} prospectId=\{detailId\} onClose=\{\(\) => setDetailId\(null\)\} \/>\}/) assert.match(read('src/components/workers/pult/Brief.jsx'), /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/)
assert.match(s, /onDetails=\{setDetailId\}/)
assert.match(s, /\{detailId && \(\s*<div className="hk-woms">\s*<ProspectPanel api=\{api\} prospectId=\{detailId\} onClose=\{\(\) => setDetailId\(null\)\} \/>\s*<\/div>\s*\)\}/)
}) })
test('CSS: wk-pult-text bricht Zeilen wie im Text (pre-wrap) und lange Wörter um; Panel-Klassen sind definiert', () => { test('CSS: wk-pult-text bricht Zeilen wie im Text (pre-wrap) und lange Wörter um; Panel-Klassen sind definiert', () => {
+304 -201
View File
@@ -7,39 +7,52 @@ import esbuild from 'esbuild'
import { fakeReact } from './helpers/fakeReact.mjs' import { fakeReact } from './helpers/fakeReact.mjs'
// Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik // Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik
// steckt in P3-T18. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (helpers/fakeReact.mjs), PultList zusätzlich als Text gerendert. // steckt in P3-T18. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (helpers/fakeReact.mjs), PultList und Brief zusätzlich als Text gerendert.
// Hustler-Pixelwelt T9: aus der Zeile (PultRow) wurde der Brief (pult/Brief.jsx), die Liste zeigt Umschläge (pult/Klemmbrett.jsx). Die fachlichen Zusicherungen gelten
// unverändert; wo früher in der Zeile geklickt wurde, wird jetzt erst der Umschlag geöffnet. Der Quelltext des Pults liegt in mehreren Dateien: pultSource() fügt sie zusammen.
const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8') const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
const PULT_DATEIEN = [
'src/components/workers/PultList.jsx', 'src/components/workers/pult/Brief.jsx', 'src/components/workers/pult/Klemmbrett.jsx',
'src/components/workers/pult/Marken.jsx', 'src/components/workers/pult/PultSzene.jsx', 'src/lib/hustlerPult.js',
]
const pultSource = () => PULT_DATEIEN.map(read).join('\n')
test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => { test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => {
const s = read('src/pages/WorkersPage.jsx') const s = read('src/pages/WorkersPage.jsx')
assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /<PultList[^>]*api=\{api\}/) assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /<PultList[^>]*api=\{api\}/)
}) })
test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => { test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => {
const s = read('src/components/workers/PultList.jsx') const s = pultSource()
assert.doesNotMatch(s, /dangerouslySetInnerHTML/) assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/) assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
assert.doesNotMatch(s, /readOnly[^>]*wk-pult-draft|wk-pult-draft[^>]*readOnly/) assert.doesNotMatch(s, /readOnly[^>]*hk-brief-entwurf|hk-brief-entwurf[^>]*readOnly/)
}) })
test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => { test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => {
const s = read('src/hooks/usePultFeed.js') const s = read('src/hooks/usePultFeed.js')
assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/) assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/)
}) })
test('CSS: wk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte wk-Klasse ist definiert', () => { test('CSS: hk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte hk-Klasse ist definiert (ProspectPanel bleibt bei den wk-Klassen von workers.css)', () => {
const css = read('src/styles/workers.css') const css = ['pult.css', 'hustler-ui.css', 'hustler-pult-ui.css'].map((f) => read('src/styles/hustler/' + f)).join('\n')
assert.match(css, /\.wk-pult-row/); assert.match(css, /@media \(max-width: 860px\)[\s\S]*\.wk-pult-row/) const own = read('src/styles/hustler/hustler-pult-ui.css')
for (const f of ['PultList', 'ProspectPanel']) { const p = 'src/components/workers/' + f + '.jsx'; if (fs.existsSync(new URL('../../' + p, import.meta.url))) for (const m of read(p).matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) } assert.match(own, /\.hk-pult \.hk-brief\b/); assert.match(own, /@media \(max-width: 719px\)[\s\S]*\.hk-brief\b/)
for (const f of PULT_DATEIEN) for (const m of read(f).matchAll(/(?<![\w-])hk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`)
const wk = read('src/styles/workers.css')
for (const m of read('src/components/workers/ProspectPanel.jsx').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(wk.includes('.' + m[0]), `ProspectPanel: ${m[0]}`)
// das Pult selbst benutzt keine wk-Klassen mehr
for (const f of PULT_DATEIEN) assert.doesNotMatch(read(f), /(?<![\w-])wk-[a-z0-9-]+/, f)
}) })
test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => { test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeiteten Text, Verwerfen fragt zuerst nach, Meldungen ok|err', () => {
const s = read('src/components/workers/PultList.jsx') const s = pultSource()
assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/) assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/)
assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/) assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/)
// Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug // Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug
assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/) assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/)
assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/) assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/)
assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/) assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/)
for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t) // „Details“ heißt im Brief „Interessent“ (Spec E11)
assert.match(s, /className=\{`msg \$\{/); assert.match(s, /'ok'/); assert.match(s, /'err'/) for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Interessent']) assert.ok(s.includes(t), t)
assert.match(s, /hk-hinweis-err/); assert.match(s, /hk-hinweis-ok/); assert.match(s, /'ok'/); assert.match(s, /'err'/)
// der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf // der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf
assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/) assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/)
// der Text erscheint nur als Textknoten // der Text erscheint nur als Textknoten
@@ -223,12 +236,24 @@ test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async
} finally { b.restore() } } finally { b.restore() }
}) })
// ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); Attrappen aus STUBS ---- // ---- PultList und Brief werden mit echtem React auf einen Text gerendert (renderToStaticMarkup); Attrappen aus STUBS ----
// Die Liste zeigt Umschläge; die Aktionen stehen im Brief. Der Brief wird für sich gerendert, mit derselben rowModel wie in der Seite.
async function loadList() { async function loadList() {
return importBundle(await esbuild.build({ return importBundle(await esbuild.build({
stdin: { stdin: {
contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))", contents: [
"import { createElement } from 'react'",
"import { renderToStaticMarkup } from 'react-dom/server'",
"import PultList from './src/components/workers/PultList.jsx'",
"import Brief from './src/components/workers/pult/Brief.jsx'",
"import { rowModel } from './src/lib/workersPult.js'",
'export const render = (props) => renderToStaticMarkup(createElement(PultList, props))',
'export const renderBrief = (item, { me, nowMs, employees, text, fields } = {}) => {',
' const row = rowModel(item, { me, nowMs, employees })',
' return renderToStaticMarkup(createElement(Brief, { api: {}, item, row, text: text ?? row.copyText, fields: fields ?? {}, onText() {}, onField() {}, refresh: async () => {}, onFinished() {}, onClose() {}, onDetails() {}, onSpaet() {} }))',
'}',
].join('\n'),
resolveDir: ROOT, loader: 'js', resolveDir: ROOT, loader: 'js',
}, },
bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs], bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs],
@@ -237,125 +262,140 @@ async function loadList() {
})) }))
} }
test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => { // Stempel-Knopf (Brief) mit diesem Text: <button type="button" class="hk-stempel …">[Icon]Text</button>
const { render } = await loadList() const stempel = (html, label) => new RegExp(`<button type="button" class="hk-stempel[^"]*"[^>]*>(?:<span[^>]*></span>)?${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</button>`).test(html)
test('Pult (gerendert): Aktionen je Art und Status im Brief, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
const { render, renderBrief } = await loadList()
const now = Date.now() const now = Date.now()
const iso = (ms) => new Date(ms).toISOString() const iso = (ms) => new Date(ms).toISOString()
const me = { id: 'u1', name: 'Justin' } const me = { id: 'u1', name: 'Justin' }
const employees = [{ userId: 'u2', displayName: 'Kenso' }]
const brief = (item) => renderBrief(item, { me, nowMs: now, employees })
const show = (items, extra = {}) => { const show = (items, extra = {}) => {
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {}, ...extra }) globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {}, ...extra })
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }] globalThis.__pultEmployees = employees
return render({ api: {}, me }) return render({ api: {}, me })
} }
const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } } const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } }
try { try {
// offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Details; das Textfeld trägt den Entwurf // offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Interessent; das Textfeld trägt den Entwurf
let html = show([{ ...base, originalText: 'Original <img src=x onerror=alert(1)>', draft: 'Hallo <script>alert(1)</script>', title: 'T <b>x</b>' }]) let html = brief({ ...base, originalText: 'Original <img src=x onerror=alert(1)>', draft: 'Hallo <script>alert(1)</script>', title: 'T <b>x</b>' })
for (const t of ['Kopieren &amp; öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Details', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t) for (const t of ['Kopieren &amp; öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Interessent', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t)
const erledigt = '<button class="btn" type="button">Erledigt</button>' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen assert.ok(!stempel(html, 'Erledigt'))
assert.ok(!html.includes(erledigt)); assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer" aria-label="Anzeige öffnen: T &lt;b&gt;x&lt;\/b&gt;">/) assert.match(html, /<a class="hk-link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer" aria-label="Anzeige öffnen: T &lt;b&gt;x&lt;\/b&gt;">/)
assert.match(html, /<textarea class="textarea wk-pult-draft"[^>]*>Hallo &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/textarea>/) assert.match(html, /<textarea class="hk-feld hk-brief-entwurf"[^>]*>Hallo &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/textarea>/)
// Fremdtext erscheint nie als Markup // Fremdtext erscheint nie als Markup
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original &lt;img src=x onerror=alert(1)&gt;')) assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>x</b>')); assert.ok(html.includes('Original &lt;img src=x onerror=alert(1)&gt;'))
// Kollege hat übernommen: Name statt „Ich kümmere mich“, der Rest bleibt bedienbar (der Dienst entscheidet mit 409); javascript:-Adresse gibt keinen Link, nur „Kopieren“ // Kollege hat übernommen: Name statt „Ich kümmere mich“, der Rest bleibt bedienbar (der Dienst entscheidet mit 409); javascript:-Adresse gibt keinen Link, nur „Kopieren“
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2', sourceUrl: 'javascript:alert(1)' }]) html = brief({ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2', sourceUrl: 'javascript:alert(1)' })
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren')) assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Ich kümmere mich')); assert.ok(html.includes('Als gesendet markieren'))
assert.ok(!html.includes('href="javascript:')); assert.ok(!html.includes('Anzeige öffnen')); assert.match(html, />Kopieren<\/button>/) assert.ok(!html.includes('href="javascript:')); assert.ok(!html.includes('Anzeige öffnen')); assert.ok(stempel(html, 'Kopieren'))
// veraltet und „läuft bald ab“ // veraltet und „läuft bald ab“ stehen als Marken am Umschlag
html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }]) html = show([{ ...base, status: 'veraltet' }, { ...base, $id: 'b', staleAt: iso(now + 5 * 3600e3) }])
assert.ok(html.includes('<span class="badge orange">veraltet</span>')); assert.ok(html.includes('läuft in 5 Std. ab')) assert.ok(html.includes('<span class="hk-marke hk-marke-veraltet"><span class="hk-ic hk-ic-klein hk-ic-veraltet" aria-hidden="true"></span>veraltet</span>'))
assert.ok(html.includes('<span class="hk-marke hk-marke-frist"><span class="hk-ic hk-ic-klein hk-ic-sanduhr-k" aria-hidden="true"></span>läuft in 5 Std. ab</span>'))
// nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion // nicht sendbare Art: Erledigt statt Senden, aber Verwerfen bleibt; abgeschlossene Einträge haben keine Aktion
html = show([{ ...base, kind: 'unsicher', draft: '' }]) html = brief({ ...base, kind: 'unsicher', draft: '' })
assert.ok(html.includes(erledigt)); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea')) assert.ok(stempel(html, 'Erledigt')); assert.ok(html.includes('Verwerfen')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('<textarea'))
// gesendet: nichts mehr zu senden oder zu verwerfen; ohne Interessent bleibt die Zeile ohne Aktionen, mit Interessent nur „Antwort des Kunden einfügen“ (P4-T32, D13) // gesendet: nichts mehr zu senden oder zu verwerfen; ohne Interessent bleibt der Brief ohne Aktionen, mit Interessent nur „Antwort des Kunden einfügen“ (P4-T32, D13)
html = show([{ ...base, status: 'gesendet', prospect: null }]) html = brief({ ...base, status: 'gesendet', prospect: null })
assert.ok(!html.includes('row-actions')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<summary>Entwurf</summary>')) assert.ok(!html.includes('hk-aktionen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')); assert.ok(html.includes('<h3 class="hk-brief-h">Entwurf</h3>'))
html = show([{ ...base, status: 'gesendet' }]) assert.ok(html.includes('Dieser Eintrag ist abgeschlossen.'))
assert.ok(html.includes('row-actions')); assert.ok(html.includes('Antwort des Kunden einfügen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen')) html = brief({ ...base, status: 'gesendet' })
assert.ok(html.includes('hk-aktionen')); assert.ok(html.includes('Antwort des Kunden einfügen')); assert.ok(!html.includes('<textarea')); assert.ok(!html.includes('Verwerfen'))
assert.ok(!html.includes('Kopieren')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('Ich kümmere mich')) assert.ok(!html.includes('Kopieren')); assert.ok(!html.includes('Als gesendet markieren')); assert.ok(!html.includes('Ich kümmere mich'))
// Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link // Mail-Antwort: Link mit genau einer Adresse und kodiertem Text; eine eingeschleuste zweite Adresse ergibt keinen Link
const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' } const mail = { ...base, kind: 'mail_antwort', draft: 'Guten Tag\nBis bald', sourceUrl: '' }
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } }]) html = brief({ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example' } })
assert.match(html, /<a class="btn btn-primary" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&amp;body=Guten%20Tag%0D%0ABis%20bald">E-Mail schreiben<\/a>/) assert.match(html, /<a class="hk-stempel hk-stempel-haupt" href="mailto:x@firma\.example\?subject=Re%3A%20Firma&amp;body=Guten%20Tag%0D%0ABis%20bald"><span class="hk-ic hk-ic-art-mail" aria-hidden="true"><\/span>E-Mail schreiben<\/a>/)
assert.ok(!html.includes('Kopieren')) assert.ok(!html.includes('Kopieren'))
html = show([{ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example?bcc=z@w.de' } }]) html = brief({ ...mail, prospect: { $id: 'p2', name: 'Firma', email: 'x@firma.example?bcc=z@w.de' } })
assert.ok(!html.includes('mailto:')); assert.ok(html.includes('Keine gültige E-Mail-Adresse hinterlegt.')) assert.ok(!html.includes('mailto:')); assert.ok(html.includes('Keine gültige E-Mail-Adresse hinterlegt.'))
// Zustände: laden, leer je Filter, Fehler ohne und mit Einträgen // Zustände der Liste: laden, leer je Filter, Fehler ohne und mit Einträgen
assert.ok(show([], { loading: true }).includes('Wird geladen')) assert.ok(show([], { loading: true }).includes('Wird geladen'))
assert.ok(show([]).includes('Nichts wartet auf dich')) assert.ok(show([]).includes('Nichts wartet auf dich'))
html = show([], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' }) html = show([], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
assert.ok(html.includes('Das Pult ist gerade nicht erreichbar')); assert.ok(html.includes('Der Hustler-Dienst ist gerade nicht erreichbar.')); assert.ok(html.includes('Neu laden')) assert.ok(html.includes('Das Pult ist gerade nicht erreichbar')); assert.ok(html.includes('Der Hustler-Dienst ist gerade nicht erreichbar.')); assert.ok(html.includes('Neu laden'))
html = show([base], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' }) html = show([base], { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
assert.ok(html.includes('class="msg warn"')); assert.ok(html.includes('Suche Webdesigner')) assert.ok(html.includes('hk-hinweis hk-hinweis-warn')); assert.ok(html.includes('Verbindung gestört: Der Hustler-Dienst ist gerade nicht erreichbar. Es wird weiter versucht.')); assert.ok(html.includes('Suche Webdesigner'))
// die Filter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt // die Reiter kommen aus PULT_FILTERS, „Wartet“ ist vorgewählt (am offenen Reiter steht die Länge der geladenen Liste, an den anderen keine Zahl)
html = show([]) html = show([])
assert.match(html, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/) assert.match(html, /<button type="button" class="hk-reiter hk-reiter-wartet hk-reiter-an" aria-pressed="true" aria-label="Wartet, 0 Einträge">Wartet<span class="hk-zahl">0<\/span><\/button><button type="button" class="hk-reiter hk-reiter-veraltet" aria-pressed="false">Veraltet<\/button><button type="button" class="hk-reiter hk-reiter-erledigt" aria-pressed="false">Erledigt<\/button>/)
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees } } finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
}) })
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann // Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => { test('Pult (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => {
const { render } = await loadList() const { render, renderBrief } = await loadList()
const now = Date.now() const now = Date.now()
const iso = (ms) => new Date(ms).toISOString() const iso = (ms) => new Date(ms).toISOString()
const employees = [{ userId: 'u2', displayName: 'Anna' }]
const show = (items) => { const show = (items) => {
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} }) globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Anna' }] globalThis.__pultEmployees = employees
return render({ api: {}, me: { id: 'u1', name: 'Justin' } }) return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
} }
const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) } const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) }
try { try {
const html = show([ const items = [
{ ...base, $id: 'a', status: 'gesendet' }, { ...base, $id: 'a', status: 'gesendet' },
{ ...base, $id: 'b', status: 'verworfen', title: 'Zweite' }, { ...base, $id: 'b', status: 'verworfen', title: 'Zweite' },
{ ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' }, { ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' },
]) ]
assert.ok(html.includes('<span class="badge green">Gesendet</span>')) for (const html of [show(items), ...items.map((item) => renderBrief(item, { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees }))]) {
assert.ok(html.includes('<span class="badge">Verworfen</span>')); assert.ok(html.includes('<span class="badge">Erledigt</span>'))
assert.ok(html.includes('<span>Gesendet von Anna · vor 2 Std.</span>')); assert.ok(html.includes('<span>Verworfen von Anna · vor 2 Std.</span>'))
assert.ok(html.includes('<span>Erledigt von Max (automatisch) · vor 2 Std.</span>'))
assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da
assert.ok(!html.includes('Kollege')) assert.ok(!html.includes('Kollege'))
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Badge }
const list = show(items)
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-gruen">Gesendet von Anna · vor 2 Std.</span>'))
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-grau">Verworfen von Anna · vor 2 Std.</span>'))
assert.ok(list.includes('<span class="hk-abdruck hk-stempel-grau">Erledigt von Max (automatisch) · vor 2 Std.</span>'))
assert.ok(renderBrief(items[0], { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees }).includes('<span class="hk-abdruck hk-stempel-gruen">Gesendet von Anna · vor 2 Std.</span>'))
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Stempel
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }]) const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
assert.ok(open.includes('<span>vor 4 Tagen</span>')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('badge green')) assert.ok(open.includes('Antwort auf Plattform-Anfrage · vor 4 Tagen')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('hk-abdruck'))
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees } } finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
}) })
// Endprüfung P3 (W8): „Details“, „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel der Zeile im zugänglichen Namen // Endprüfung P3 (W8): „Interessent“ (früher „Details“), „Anzeige öffnen“, Entwurfsfeld und Aktionsgruppe tragen den Titel des Eintrags im zugänglichen Namen
test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => { test('Pult (gerendert): wiederkehrende Bedienelemente haben je Eintrag einen eigenen zugänglichen Namen, sichtbares Wort vorn (W8)', async () => {
const { render } = await loadList() const { render, renderBrief } = await loadList()
const show = (items) => { const me = { id: 'u1', name: 'Justin' }
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} })
globalThis.__pultEmployees = []
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
}
const base = { kind: 'plattform_antwort', status: 'offen', draft: 'Hallo', createdAt: new Date().toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } } const base = { kind: 'plattform_antwort', status: 'offen', draft: 'Hallo', createdAt: new Date().toISOString(), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const brief = (item) => renderBrief(item, { me, nowMs: Date.now(), employees: [] })
try { try {
const html = show([{ ...base, $id: 'a', title: 'Suche Webdesigner' }, { ...base, $id: 'b', title: 'Homepage für Handwerker <b>x</b> & "Co"' }]) const html = brief({ ...base, $id: 'a', title: 'Suche Webdesigner' })
for (const name of ['Details zu Suche Webdesigner', 'Anzeige öffnen: Suche Webdesigner', 'Entwurf der Antwort: Suche Webdesigner', 'Aktionen: Suche Webdesigner']) { for (const name of ['Interessent zu Suche Webdesigner', 'Anzeige öffnen: Suche Webdesigner', 'Entwurf der Antwort: Suche Webdesigner', 'Aktionen: Suche Webdesigner']) {
assert.ok(html.includes(`aria-label="${name}"`), name) assert.ok(html.includes(`aria-label="${name}"`), name)
} }
// zwei Zeilen ⇒ zwei verschiedene Namen; Fremdtext im Namen ist als Attribut kodiert // Fremdtext im Namen ist als Attribut kodiert; zwei Einträge ⇒ zwei verschiedene Namen (Umschläge in der Liste, Brief je Eintrag)
const names = [...html.matchAll(/aria-label="(Details zu [^"]*)"/g)].map((m) => m[1]) const titles = ['Suche Webdesigner', 'Homepage für Handwerker <b>x</b> & "Co"']
const second = brief({ ...base, $id: 'b', title: titles[1] })
assert.ok(second.includes('aria-label="Interessent zu Homepage für Handwerker &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;"'))
assert.ok(second.includes('aria-label="Aktionen: Homepage für Handwerker &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;"'))
assert.ok(!second.includes('<b>x</b>'))
globalThis.__pultFeedFor = () => ({ items: [{ ...base, $id: 'a', title: titles[0] }, { ...base, $id: 'b', title: titles[1] }], loading: false, error: null, refresh: async () => {} })
globalThis.__pultEmployees = []
const list = render({ api: {}, me })
const names = [...list.matchAll(/class="hk-umschlag" aria-haspopup="dialog" aria-label="([^"]*)"/g)].map((m) => m[1])
assert.equal(names.length, 2); assert.equal(new Set(names).size, 2) assert.equal(names.length, 2); assert.equal(new Set(names).size, 2)
assert.ok(html.includes('aria-label="Details zu Homepage für Handwerker &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;"')) assert.ok(names[1].includes('Homepage für Handwerker &lt;b&gt;x&lt;/b&gt; &amp; &quot;Co&quot;')); assert.ok(!list.includes('<b>x</b>'))
assert.ok(!html.includes('<b>x</b>'))
// das sichtbare Wort bleibt (WCAG 2.5.3) und steht im Namen vorn // das sichtbare Wort bleibt (WCAG 2.5.3) und steht im Namen vorn
assert.match(html, /aria-label="Details zu Suche Webdesigner">Details<\/button>/) assert.match(html, /aria-label="Interessent zu Suche Webdesigner">Interessent<\/button>/)
assert.match(html, /aria-label="Anzeige öffnen: Suche Webdesigner">Anzeige öffnen<\/a>/) assert.match(html, /aria-label="Anzeige öffnen: Suche Webdesigner">Anzeige öffnen<\/a>/)
// leerer oder fehlender Titel: schlichter Name, nie „zu undefined“ // leerer oder fehlender Titel: schlichter Name, nie „zu undefined“
for (const title of ['', ' ', undefined]) { for (const title of ['', ' ', undefined]) {
const bare = show([{ ...base, $id: 'c', title }]) const bare = brief({ ...base, $id: 'c', title })
assert.ok(!bare.includes('undefined'), String(title)) assert.ok(!bare.includes('undefined'), String(title))
assert.ok(!bare.includes('Details zu'), String(title)); assert.ok(!bare.includes('Anzeige öffnen:'), String(title)) assert.ok(!bare.includes('Interessent zu'), String(title)); assert.ok(!bare.includes('Anzeige öffnen:'), String(title))
assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title)) assert.ok(bare.includes('aria-label="Entwurf der Antwort"'), String(title)); assert.ok(bare.includes('aria-label="Aktionen"'), String(title))
assert.match(bare, />Details<\/button>/) assert.match(bare, />Interessent<\/button>/)
} }
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees } } finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
}) })
@@ -363,12 +403,12 @@ test('PultList (gerendert): wiederkehrende Bedienelemente haben je Zeile einen e
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ---- // ---- P4-T32: als Mensch antworten, Vorschau freigeben, Vorschau bauen, Antwort des Kunden einfügen ----
test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit validateReplyText, Freigabe mit Rückfrage, sichere Vorschau-Adresse', () => { test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit validateReplyText, Freigabe mit Rückfrage, sichere Vorschau-Adresse', () => {
const s = read('src/components/workers/PultList.jsx') const s = pultSource()
for (const t of [ for (const t of [
"'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref', "'reply'", "'approve-preview'", "'paste'", "'start-build'", 'validateReplyText', 'canReply', 'canApprovePreview', 'canStartBuild', 'canPaste', 'previewHref',
'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"', 'actionErrorText', 'pasteDoneText', 'rel="noopener noreferrer"', 'target="_blank"',
'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?', 'Als Mensch antworten', 'Jetzt senden', 'Vorschau ansehen', 'Freigeben & senden', 'Wirklich freigeben?', 'Vorschau bauen', 'Wirklich bauen?',
'Antwort des Kunden einfügen', 'Einfügen', 'wk-pult-reply', 'wk-pult-paste', 'Abbrechen', 'data-opens', 'Escape', 'restoreFocus', 'activeControl', 'tabIndex={-1}', 'Antwort des Kunden einfügen', 'Einfügen', 'hk-feld-antwort', 'hk-feld-einfuegen', 'Abbrechen', 'data-opens', 'Escape', 'restoreFocus', 'activeControl', 'tabIndex={-1}',
'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.', 'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.',
]) assert.ok(s.includes(t), t) ]) assert.ok(s.includes(t), t)
assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/) assert.doesNotMatch(s, /dangerouslySetInnerHTML/); assert.doesNotMatch(s, /innerHTML/)
@@ -377,7 +417,7 @@ test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit v
assert.match(s, /api\.pultAction\(row\.id, 'approve-preview'\)/) assert.match(s, /api\.pultAction\(row\.id, 'approve-preview'\)/)
assert.match(s, /api\.pultAction\(row\.id, 'start-build'\)/) assert.match(s, /api\.pultAction\(row\.id, 'start-build'\)/)
assert.match(s, /api\.pultAction\(row\.id, 'paste', \{ text: [\w.]+ \}\)/) assert.match(s, /api\.pultAction\(row\.id, 'paste', \{ text: [\w.]+ \}\)/)
// beide Texte werden vor dem Senden mit validateReplyText geprüft, ein Fehler erscheint als Meldung in der Zeile // beide Texte werden vor dem Senden mit validateReplyText geprüft, ein Fehler erscheint als Meldung im Brief
assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2) assert.equal((s.match(/validateReplyText\(/g) ?? []).length, 2)
// die Vorschau-Adresse kommt aus rowModel (previewHref) und öffnet ohne Rückbezug in einem neuen Tab // die Vorschau-Adresse kommt aus rowModel (previewHref) und öffnet ohne Rückbezug in einem neuen Tab
assert.match(s, /href=\{row\.previewHref\}[^>]*target="_blank"[^>]*rel="noopener noreferrer"/) assert.match(s, /href=\{row\.previewHref\}[^>]*target="_blank"[^>]*rel="noopener noreferrer"/)
@@ -386,117 +426,110 @@ test('PultList (P4-T32): Quelltext – Aktionen über pultAction, Prüfung mit v
// „Als Mensch antworten“ ist mit dem Entwurf vorbelegt, „Einfügen“ nie: die Felder sind getrennt und das Einfüge-Feld beginnt leer // „Als Mensch antworten“ ist mit dem Entwurf vorbelegt, „Einfügen“ nie: die Felder sind getrennt und das Einfüge-Feld beginnt leer
assert.match(s, /fields\.reply \?\? text/); assert.match(s, /fields\.paste \?\? ''/) assert.match(s, /fields\.reply \?\? text/); assert.match(s, /fields\.paste \?\? ''/)
assert.doesNotMatch(s, /fields\.paste \?\? (text|row\.copyText)/); assert.doesNotMatch(s, /fields\.reply \?\? (fields\.paste|'')/) assert.doesNotMatch(s, /fields\.paste \?\? (text|row\.copyText)/); assert.doesNotMatch(s, /fields\.reply \?\? (fields\.paste|'')/)
// das Fehlerschema der Zeile bleibt: Meldung mit msg ok|err, Fehlertext über actionErrorText (D14: Zeitüberschreitung ⇒ „Kann gesendet sein“) // das Fehlerschema des Briefs bleibt: Meldung ok|err, Fehlertext über actionErrorText (D14: Zeitüberschreitung ⇒ „Kann gesendet sein“)
assert.match(s, /actionErrorText\(err, action\)/) assert.match(s, /actionErrorText\(err, action\)/)
}) })
test('PultList (P4-T32): Aktionsfeld erscheint auch bei gesendet, wenn eine der vier neuen Aktionen möglich ist (D13)', () => { test('PultList (P4-T32): Aktionsfeld erscheint auch bei gesendet, wenn eine der vier neuen Aktionen möglich ist (D13)', () => {
const s = read('src/components/workers/PultList.jsx') const s = pultSource()
assert.match(s, /row\.canSend \|\| row\.canClaim \|\| row\.canDone \|\| row\.canDiscard \|\| row\.canReply \|\| row\.canApprovePreview \|\| row\.canStartBuild \|\| row\.canPaste/) assert.match(s, /row\.canSend \|\| row\.canClaim \|\| row\.canDone \|\| row\.canDiscard \|\| row\.canReply \|\| row\.canApprovePreview \|\| row\.canStartBuild \|\| row\.canPaste/)
}) })
test('CSS (P4-T32): .wk-pult-reply und .wk-pult-paste mit Haarlinie, volle Breite, auf dem Handy untereinander', () => { test('CSS (P4-T32): Antwort- und Einfüge-Feld mit Zähler und zwei Knöpfen, auf dem Handy untereinander (Abbrechen unten, wie bisher)', () => {
const css = read('src/styles/workers.css') const css = read('src/styles/hustler/hustler-pult-ui.css').replace(/\/\*[\s\S]*?\*\//g, '')
assert.match(css, /\.wk-pult-reply/); assert.match(css, /\.wk-pult-paste/) assert.match(css, /\.hk-pult \.hk-feld-panel\s*\{/); assert.match(css, /\.hk-pult \.hk-zaehler-ueber\s*\{/)
const rule = css.match(/\.wk-pult-row \.wk-pult-reply[^{]*\{[^}]*\}/)?.[0] ?? '' assert.match(css, /\.hk-pult \.hk-feld-knoepfe\s*\{[^}]*display:\s*flex/)
assert.match(rule, /\.wk-pult-paste/); assert.match(rule, /border-top: 1px solid var\(--line\)/); assert.match(rule, /width: 100%/) // schmale Bildschirme (unter 720 px, Spec E13): die Fußzeile des Feldes (Zähler, Knöpfe) steht untereinander, „Abbrechen“ als letzter
// schmale Bildschirme: die Fußzeile des Feldes (Zähler, Knöpfe) steht untereinander const phone = css.slice(css.indexOf('@media (max-width: 719px)'))
const phone = css.slice(css.indexOf('@media (max-width: 860px)')) assert.match(phone, /\.hk-feld-fuss\s*\{[^}]*flex-direction:\s*column/); assert.match(phone, /\.hk-feld-knoepfe\s*\{[^}]*flex-direction:\s*column-reverse/)
assert.match(phone, /\.wk-pult-panel-foot/); assert.match(phone, /flex-direction: column/)
// Endprüfung W4: „Abbrechen“ und „Jetzt senden“ stehen nebeneinander (Gruppe .wk-pult-panel-btns), auf dem Handy untereinander über die volle Breite
assert.match(css, /\.wk-pult-panel-btns\s*\{[^}]*display: flex/); assert.match(phone, /\.wk-pult-panel-btns\s*\{[^}]*flex-direction: column-reverse/)
}) })
test('PultList (gerendert, P4-T32): Aktionen je Art – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => { test('Pult (gerendert, P4-T32): Aktionen je Art im Brief – Antworten, Freigeben, Bauen, Einfügen; Felder bleiben zu, Fremdtext nur als Text', async () => {
const { render } = await loadList() const { renderBrief } = await loadList()
const now = Date.now() const now = Date.now()
const iso = (ms) => new Date(ms).toISOString() const iso = (ms) => new Date(ms).toISOString()
const show = (items) => { const employees = [{ userId: 'u2', displayName: 'Kenso' }]
globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {} }) const show = (item) => renderBrief(item, { me: { id: 'u1', name: 'Justin' }, nowMs: now, employees })
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
}
const base = { $id: 'a', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: '', createdAt: iso(now - 3600e3), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } } const base = { $id: 'a', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: '', createdAt: iso(now - 3600e3), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
const buttonOf = (html, label) => new RegExp(`<button class="(?:btn|link-danger)[^"]*"[^>]*>${label}</button>`).test(html) // nur Knöpfe der Zeile (die Reiter „Wartet“ … „Erledigt“ haben keine Klasse btn) const buttonOf = stempel
try {
// unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu // unsicher mit Interessent und Adresse: antworten, bauen, erledigen, verwerfen; die Felder sind zu
let html = show([base]) let html = show(base)
for (const t of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.ok(buttonOf(html, t), t) for (const t of ['Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.ok(buttonOf(html, t), t)
for (const t of ['Antwort des Kunden einfügen', 'Freigeben', 'Jetzt senden', 'wk-pult-reply', 'wk-pult-paste', '<textarea']) assert.ok(!html.includes(t), t) for (const t of ['Antwort des Kunden einfügen', 'Freigeben', 'Jetzt senden', 'hk-feld-antwort', 'hk-feld-einfuegen', '<textarea']) assert.ok(!html.includes(t), t)
assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>Als Mensch antworten<\/button>/) assert.match(html, /<button[^>]*aria-expanded="false"[^>]*>(?:<span[^>]*><\/span>)?Als Mensch antworten<\/button>/)
// „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen // „Mail ohne Faden“: weder Interessent noch Adresse ⇒ weder Antworten noch Bauen
html = show([{ ...base, prospectId: undefined, prospect: null }]) html = show({ ...base, prospectId: undefined, prospect: null })
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt')) assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
// Interessent ohne Adresse (Plattform): kein Mail-Kanal ⇒ nicht antworten, aber bauen // Interessent ohne Adresse (Plattform): kein Mail-Kanal ⇒ nicht antworten, aber bauen
html = show([{ ...base, prospect: { $id: 'p1', name: 'Verein' } }]) html = show({ ...base, prospect: { $id: 'p1', name: 'Verein' } })
assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen')) assert.ok(!html.includes('Als Mensch antworten')); assert.ok(html.includes('Vorschau bauen'))
// Endprüfung P4 (W2): ein toter Interessent (gesperrt, verloren, Deal) bekommt keinen Knopf zum Bauen, ein lebender behält ihn // Endprüfung P4 (W2): ein toter Interessent (gesperrt, verloren, Deal) bekommt keinen Knopf zum Bauen, ein lebender behält ihn
for (const status of ['gesperrt', 'verloren', 'deal']) { for (const status of ['gesperrt', 'verloren', 'deal']) {
html = show([{ ...base, prospect: { ...base.prospect, status } }]) html = show({ ...base, prospect: { ...base.prospect, status } })
assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(buttonOf(html, 'Als Mensch antworten'), status); assert.ok(buttonOf(html, 'Erledigt'), status) assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(buttonOf(html, 'Als Mensch antworten'), status); assert.ok(buttonOf(html, 'Erledigt'), status)
} }
assert.ok(buttonOf(show([{ ...base, prospect: { ...base.prospect, status: 'gespraech' } }]), 'Vorschau bauen')) assert.ok(buttonOf(show({ ...base, prospect: { ...base.prospect, status: 'gespraech' } }), 'Vorschau bauen'))
// von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher // von einem Kollegen übernommen: weder antworten noch bauen, der Rest bleibt wie bisher
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' }]) html = show({ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt')) assert.ok(html.includes('übernommen von Kenso')); assert.ok(!html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')); assert.ok(buttonOf(html, 'Erledigt'))
// von Max übernommen: „Max (automatisch)“, nie „Kollege“ // von Max übernommen: „Max (automatisch)“, nie „Kollege“
html = show([{ ...base, status: 'in_arbeit', claimedBy: 'max' }]) html = show({ ...base, status: 'in_arbeit', claimedBy: 'max' })
assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege')) assert.ok(html.includes('übernommen von Max (automatisch)')); assert.ok(!html.includes('Kollege'))
// mail_antwort: Mail-Link und Antwort-Funktion nebeneinander // mail_antwort: Mail-Link und Antwort-Funktion nebeneinander
html = show([{ ...base, kind: 'mail_antwort', draft: 'Guten Tag' }]) html = show({ ...base, kind: 'mail_antwort', draft: 'Guten Tag' })
assert.ok(html.includes('E-Mail schreiben')); assert.ok(html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen')) assert.ok(html.includes('E-Mail schreiben')); assert.ok(html.includes('Als Mensch antworten')); assert.ok(!html.includes('Vorschau bauen'))
// preisfrage, mensch_gewuenscht: antworten // preisfrage, mensch_gewuenscht: antworten
for (const kind of ['preisfrage', 'mensch_gewuenscht']) assert.ok(show([{ ...base, kind }]).includes('Als Mensch antworten'), kind) for (const kind of ['preisfrage', 'mensch_gewuenscht']) assert.ok(show({ ...base, kind }).includes('Als Mensch antworten'), kind)
assert.ok(!show([{ ...base, kind: 'beschwerde' }]).includes('Als Mensch antworten')) assert.ok(!show({ ...base, kind: 'beschwerde' }).includes('Als Mensch antworten'))
// abgeschlossen: keine Antwort-Funktion mehr // abgeschlossen: keine Antwort-Funktion mehr
for (const status of ['gesendet', 'erledigt', 'verworfen']) { for (const status of ['gesendet', 'erledigt', 'verworfen']) {
html = show([{ ...base, status }]) html = show({ ...base, status })
assert.ok(!html.includes('Als Mensch antworten'), status); assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(!html.includes('row-actions'), status) assert.ok(!html.includes('Als Mensch antworten'), status); assert.ok(!html.includes('Vorschau bauen'), status); assert.ok(!html.includes('hk-aktionen'), status)
} }
// vorschau_freigabe: Vorschau ansehen (sicherer Link) + Freigeben & senden + Hinweis zu Verwerfen; kein Erledigt, keine Anzeige-Adresse, keine Antwort-Funktion // vorschau_freigabe: Vorschau ansehen (sicherer Link) + Freigeben & senden + Hinweis zu Verwerfen; kein Erledigt, keine Anzeige-Adresse, keine Antwort-Funktion
const freigabe = { ...base, kind: 'vorschau_freigabe', title: 'Vorschau <b>prüfen</b>', draft: 'Hallo, hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' } const freigabe = { ...base, kind: 'vorschau_freigabe', title: 'Vorschau <b>prüfen</b>', draft: 'Hallo, hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
html = show([freigabe]) html = show(freigabe)
// der Name trägt den Titel der Zeile (W8), Fremdtext darin ist kodiert // der Name trägt den Titel des Eintrags (W8), Fremdtext darin ist kodiert
assert.match(html, /<a class="btn" href="https:\/\/entwurf\.project\.webklar\.com\/AAA\/" target="_blank" rel="noopener noreferrer" aria-label="Vorschau ansehen: Vorschau &lt;b&gt;prüfen&lt;\/b&gt;">Vorschau ansehen<\/a>/) assert.match(html, /<a class="hk-stempel hk-stempel-normal" href="https:\/\/entwurf\.project\.webklar\.com\/AAA\/" target="_blank" rel="noopener noreferrer" aria-label="Vorschau ansehen: Vorschau &lt;b&gt;prüfen&lt;\/b&gt;"><span class="hk-ic hk-ic-ansehen" aria-hidden="true"><\/span>Vorschau ansehen<\/a>/)
assert.ok(html.includes('Freigeben &amp; senden')); assert.ok(buttonOf(html, 'Verwerfen')); assert.ok(html.includes('sendet die Vorschau nicht')) assert.ok(html.includes('Freigeben &amp; senden')); assert.ok(buttonOf(html, 'Verwerfen')); assert.ok(html.includes('sendet die Vorschau nicht'))
for (const t of ['Anzeige öffnen', 'Als Mensch antworten', 'Vorschau bauen', 'Wirklich freigeben?']) assert.ok(!html.includes(t), t) for (const t of ['Anzeige öffnen', 'Als Mensch antworten', 'Vorschau bauen', 'Wirklich freigeben?']) assert.ok(!html.includes(t), t)
assert.ok(!buttonOf(html, 'Erledigt')); assert.ok(!html.includes('<b>prüfen</b>')) assert.ok(!buttonOf(html, 'Erledigt')); assert.ok(!html.includes('<b>prüfen</b>'))
// gültige Adresse: der Knopf zum Freigeben ist bedienbar // gültige Adresse: der Knopf zum Freigeben ist bedienbar
assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>Freigeben &amp; senden<\/button>/) assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Freigeben &amp; senden<\/button>/)
// fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, und der Knopf zum Freigeben ist gesperrt (Endprüfung W5: der Mensch soll keine // fremde, unsichere oder fehlende Adresse: kein Link, Hinweis statt Link, und der Knopf zum Freigeben ist gesperrt (Endprüfung W5: der Mensch soll keine
// Vorschau freigeben, die er nicht ansehen kann); der Hinweis nennt den Grund und den Ausweg // Vorschau freigeben, die er nicht ansehen kann); der Hinweis nennt den Grund und den Ausweg
for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) { for (const sourceUrl of ['https://fremd.example/AAA/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', '']) {
html = show([{ ...freigabe, sourceUrl }]) html = show({ ...freigabe, sourceUrl })
assert.ok(!html.includes('Vorschau ansehen</a>'), sourceUrl); assert.ok(!html.includes('href="javascript:'), sourceUrl); assert.ok(!html.includes('fremd.example'), sourceUrl) assert.ok(!html.includes('Vorschau ansehen</a>'), sourceUrl); assert.ok(!html.includes('href="javascript:'), sourceUrl); assert.ok(!html.includes('fremd.example'), sourceUrl)
assert.ok(html.includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.'), sourceUrl) assert.ok(html.includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.'), sourceUrl)
assert.match(html, /<button[^>]*disabled[^>]*>Freigeben &amp; senden<\/button>/, sourceUrl) assert.match(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Freigeben &amp; senden<\/button>/, sourceUrl)
assert.ok(buttonOf(html, 'Verwerfen')); assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>Verwerfen<\/button>/, sourceUrl) // Verwerfen bleibt frei assert.ok(buttonOf(html, 'Verwerfen')); assert.doesNotMatch(html, /<button[^>]*disabled[^>]*>(?:<span[^>]*><\/span>)?Verwerfen<\/button>/, sourceUrl) // Verwerfen bleibt frei
} }
// freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben // freigegeben (erledigt) oder von einem Kollegen übernommen: nichts mehr freizugeben
assert.ok(!show([{ ...freigabe, status: 'erledigt' }]).includes('Freigeben')) assert.ok(!show({ ...freigabe, status: 'erledigt' }).includes('Freigeben'))
html = show([{ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' }]) html = show({ ...freigabe, status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.ok(!html.includes('Freigeben')); assert.ok(buttonOf(html, 'Verwerfen')) assert.ok(!html.includes('Freigeben')); assert.ok(buttonOf(html, 'Verwerfen'))
// plattform_antwort: „Antwort des Kunden einfügen“ in jedem Status außer verworfen, auch bei einem Kollegen; das Feld ist zu // plattform_antwort: „Antwort des Kunden einfügen“ in jedem Status außer verworfen, auch bei einem Kollegen; das Feld ist zu
const plattform = { ...base, kind: 'plattform_antwort', draft: 'Hallo', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } } const plattform = { ...base, kind: 'plattform_antwort', draft: 'Hallo', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
for (const status of ['offen', 'veraltet', 'gesendet', 'erledigt']) { for (const status of ['offen', 'veraltet', 'gesendet', 'erledigt']) {
html = show([{ ...plattform, status }]) html = show({ ...plattform, status })
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen'), status); assert.ok(!html.includes('wk-pult-paste'), status); assert.ok(!html.includes('Als Mensch antworten'), status) assert.ok(buttonOf(html, 'Antwort des Kunden einfügen'), status); assert.ok(!html.includes('hk-feld-einfuegen'), status); assert.ok(!html.includes('Als Mensch antworten'), status)
} }
html = show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }]) html = show({ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' })
assert.ok(buttonOf(html, 'Antwort des Kunden einfügen')); assert.ok(html.includes('übernommen von Kenso')) assert.ok(buttonOf(html, 'Antwort des Kunden einfügen')); assert.ok(html.includes('übernommen von Kenso'))
assert.ok(!show([{ ...plattform, status: 'verworfen' }]).includes('Antwort des Kunden einfügen')) assert.ok(!show({ ...plattform, status: 'verworfen' }).includes('Antwort des Kunden einfügen'))
assert.ok(!show([{ ...plattform, prospect: null }]).includes('Antwort des Kunden einfügen')) assert.ok(!show({ ...plattform, prospect: null }).includes('Antwort des Kunden einfügen'))
assert.match(show([{ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }]), /aria-expanded="false"[^>]*>Antwort des Kunden einfügen<\/button>/) assert.match(show({ ...plattform, status: 'in_arbeit', claimedBy: 'mensch:u2' }), /aria-expanded="false"[^>]*>(?:<span[^>]*><\/span>)?Antwort des Kunden einfügen<\/button>/)
// in den anderen Arten gibt es kein Einfügen // in den anderen Arten gibt es kein Einfügen
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show([{ ...base, kind }]).includes('Antwort des Kunden einfügen'), kind) for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe']) assert.ok(!show({ ...base, kind }).includes('Antwort des Kunden einfügen'), kind)
} finally { delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees }
}) })
// ---- PultList wird bedient (P4-T32): derselbe Mini-Läufer (helpers/fakeReact.mjs) statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ---- // ---- Das Pult wird bedient (P4-T32): derselbe Mini-Läufer (helpers/fakeReact.mjs) statt React und DOM. Die Komponenten laufen wirklich (Zustand, Effekte, Ereignisse), ----
// ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ---- // ---- gerendert wird in einen Baum aus { tag, props, children }; Klicks und Eingaben ruft der Test von Hand auf. Der Dienst ist eine Attrappe. ----
// ---- Seit T9 sitzen die Aktionen im Brief: ui.click(label) öffnet erst den Umschlag des Eintrags (ui.open), wenn dessen Brief noch nicht offen ist. ----
async function loadUi() { async function loadUi() {
return importBundle(await esbuild.build({ return importBundle(await esbuild.build({
@@ -515,14 +548,16 @@ const nodes = (node, test, out = []) => {
const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join('')) const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name) const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name)
const clickable = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label) const clickable = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
// Knöpfe der Zeilen (Liste) und der Reiter (Filter) getrennt: „Erledigt“ ist beides const EMPTY_TREE = { tag: 'fragment', props: {}, children: [] }
const inList = (tree) => nodes(tree, (n) => n.tag === 'ul' && hasClass(n, 'wk-pult-list'))[0] ?? { tag: 'fragment', props: {}, children: [] } // Der offene Brief (null, wenn keiner offen ist), die Umschläge der Liste und die Reiter
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'wk-pult-filters'))[0] const briefOf = (tree) => nodes(tree, (n) => n.tag === 'section' && hasClass(n, 'hk-brief'))[0] ?? null
const envelopes = (tree) => nodes(tree, (n) => n.tag === 'button' && hasClass(n, 'hk-umschlag'))
const inFilters = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'hk-reiter-leiste'))[0]
const fieldOf = (tree, labelStart) => nodes(tree, (n) => n.tag === 'textarea' && String(n.props['aria-label'] ?? '').startsWith(labelStart)) const fieldOf = (tree, labelStart) => nodes(tree, (n) => n.tag === 'textarea' && String(n.props['aria-label'] ?? '').startsWith(labelStart))
const messages = (tree) => nodes(tree, (n) => n.tag === 'div' && String(n.props.className ?? '').split(' ').includes('msg')).map((n) => ({ kind: n.props.className.split(' ')[1], text: textOf(n), role: n.props.role })) const messages = (tree) => nodes(tree, (n) => n.tag === 'div' && hasClass(n, 'hk-hinweis')).map((n) => ({ kind: String(n.props.className).split(' ').find((c) => c.startsWith('hk-hinweis-')).slice('hk-hinweis-'.length), text: textOf(n), role: n.props.role }))
// Ein Lauf: Liste mit den Einträgen des Reiters „Wartet“ (alle anderen Reiter sind leer), fester Dienst, Takt von Hand // Ein Lauf: Liste mit den Einträgen des Reiters „Wartet“ (alle anderen Reiter sind leer), fester Dienst, Takt von Hand
async function startList(items, api) { async function startList(items, api, props = {}) {
const { __mount, jsx, PultList } = await loadUi() const { __mount, jsx, PultList } = await loadUi()
const refreshes = [] const refreshes = []
const timers = new Map() const timers = new Map()
@@ -536,20 +571,50 @@ async function startList(items, api) {
} }
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }] globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } }) globalThis.__pultFeedFor = (statuses) => ({ items: statuses === 'offen,in_arbeit' ? items : [], loading: false, error: null, refresh: async () => { refreshes.push(statuses) } })
const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' } })) const mounted = __mount(jsx(PultList, { api, me: { id: 'u1', name: 'Justin' }, ...props }))
let current = null // Nummer des Umschlags, dessen Brief offen ist
const view = { const view = {
refreshes, timers, opened, refreshes, timers, opened,
tree: () => mounted.tree(), tree: () => mounted.tree(),
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung; ein gesperrter Knopf löst nichts aus (wie im Browser) brief: () => briefOf(view.tree()),
// row: Nummer der Zeile, wenn mehrere Zeilen denselben Knopf haben // Brief öffnen: Klick auf den Umschlag (Nummer in der Liste)
async open(index = 0) {
const found = envelopes(view.tree())[index]
assert.ok(found, `Umschlag ${index}`)
found.props.onClick({ preventDefault() {} })
current = index
await settle()
assert.ok(view.brief(), 'der Brief ist offen')
},
// Brief schließen: der Knopf „Schließen“
async close() {
const found = nodes(view.brief(), (n) => n.tag === 'button' && n.props['aria-label'] === 'Schließen')
assert.equal(found.length, 1)
found[0].props.onClick()
current = null
await settle()
},
// sorgt dafür, dass der Brief des Umschlags (Standard: der offene, sonst der erste) offen ist
async ensureOpen(row) {
if (view.brief() === null) current = null
if (row === undefined) {
if (view.brief() === null) await view.open(0)
return
}
if (view.brief() !== null && current === row) return
if (view.brief() !== null) await view.close()
await view.open(row)
},
// Klick auf den (einzigen) Knopf/Link mit dieser Beschriftung im Brief; ein gesperrter Knopf löst nichts aus (wie im Browser)
// row: Nummer des Umschlags, wenn mehrere Einträge denselben Knopf haben
async click(label, row) { async click(label, row) {
const scope = row === undefined ? inList(view.tree()) : nodes(inList(view.tree()), (n) => n.tag === 'li')[row] await view.ensureOpen(row)
const found = clickable(scope, label) const found = clickable(view.brief(), label)
assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`) assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`)
if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} }) if (!found[0].props.disabled) found[0].props.onClick?.({ preventDefault() {} })
await settle() await settle()
}, },
// Taste im Feld mit dieser Klasse (wk-pult-reply, wk-pult-paste); Ergebnis: das Ereignis, damit der Test preventDefault sehen kann // Taste im Feld mit dieser Klasse (hk-feld-antwort, hk-feld-einfuegen); Ergebnis: das Ereignis, damit der Test preventDefault sehen kann
async key(panelClass, key = 'Escape') { async key(panelClass, key = 'Escape') {
const panel = nodes(view.tree(), (n) => n.tag === 'div' && hasClass(n, panelClass))[0] const panel = nodes(view.tree(), (n) => n.tag === 'div' && hasClass(n, panelClass))[0]
assert.ok(panel, 'Feld ' + panelClass) assert.ok(panel, 'Feld ' + panelClass)
@@ -558,15 +623,22 @@ async function startList(items, api) {
await settle() await settle()
return event return event
}, },
// Taste am Brief selbst (Esc, Tab)
async briefKey(key, extra = {}) {
const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true }, ...extra }
view.brief().props.onKeyDown(event)
await settle()
return event
},
async tab(label) { async tab(label) {
const found = clickable(inFilters(view.tree()), label) const found = nodes(inFilters(view.tree()), (n) => n.tag === 'button' && textOf(n).startsWith(label))
assert.equal(found.length, 1, `Reiter „${label}“`) assert.equal(found.length, 1, `Reiter „${label}“`)
found[0].props.onClick() found[0].props.onClick()
await settle() await settle()
}, },
link: (label) => clickable(inList(view.tree()), label)[0], link: (label) => clickable(view.brief() ?? EMPTY_TREE, label)[0],
has: (label) => clickable(inList(view.tree()), label).length > 0, has: (label) => clickable(view.brief() ?? EMPTY_TREE, label).length > 0,
disabled: (label) => clickable(inList(view.tree()), label).map((n) => n.props.disabled === true), disabled: (label) => clickable(view.brief() ?? EMPTY_TREE, label).map((n) => n.props.disabled === true),
async type(labelStart, value) { async type(labelStart, value) {
const found = fieldOf(view.tree(), labelStart) const found = fieldOf(view.tree(), labelStart)
assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`) assert.equal(found.length, 1, `Textfeld „${labelStart}“ (gefunden: ${found.length})`)
@@ -598,7 +670,7 @@ function scriptedApi(...outcomes) {
const failure = (message, status) => Object.assign(new Error(message), { status }) const failure = (message, status) => Object.assign(new Error(message), { status })
// document-Attrappe für den Fokus (nur für die Dauer eines Tests): activeElement stellt der Test, focus() eines Elements setzt es. Elemente, die der Bauteil über // document-Attrappe für den Fokus (nur für die Dauer eines Tests): activeElement stellt der Test, focus() eines Elements setzt es. Elemente, die der Bauteil über
// ref anfasst (Zeile, Aktionsgruppe, Meldung), liefert __fakeRef je Klasse; querySelector antwortet aus f.found (Selektor -> Element), sonst null. // ref anfasst (Brief, Aktionsgruppe, Meldung, erster Knopf, Textfeld), liefert __fakeRef je Klasse; querySelector antwortet aus f.found (Selektor -> Element), sonst null.
function fakeFocus() { function fakeFocus() {
const body = { name: 'body' } const body = { name: 'body' }
const doc = { body, documentElement: { name: 'html' }, activeElement: body } const doc = { body, documentElement: { name: 'html' }, activeElement: body }
@@ -623,7 +695,7 @@ function fakeFocus() {
const unsicher = { $id: 'e1', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: 'Entwurf von Clara', createdAt: new Date().toISOString(), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } } const unsicher = { $id: 'e1', kind: 'unsicher', status: 'offen', title: 'Unklare Antwort', draft: 'Entwurf von Clara', createdAt: new Date().toISOString(), prospectId: 'p1', prospect: { $id: 'p1', name: 'Verein', email: 'x@verein.example' } }
test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit dem Entwurf vorbelegt, prüft den Text und zeigt die Sätze des Dienstes', async () => { test('Pult (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit dem Entwurf vorbelegt, prüft den Text und zeigt die Sätze des Dienstes', async () => {
const api = scriptedApi(failure('Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'e1' } })) const api = scriptedApi(failure('Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([unsicher], api) const ui = await startList([unsicher], api)
try { try {
@@ -633,15 +705,15 @@ test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit
assert.ok(ui.text().includes('17 / 8000')) assert.ok(ui.text().includes('17 / 8000'))
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], true) assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], true)
// leer ⇒ Hinweis in der Zeile, nichts geht hinaus // leer ⇒ Hinweis im Brief, nichts geht hinaus
await ui.type('Text der Antwort', ' ') await ui.type('Text der Antwort', ' ')
await ui.click('Jetzt senden') await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]) assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }])
assert.equal(api.calls.length, 0) assert.equal(api.calls.length, 0)
// zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus // zu lang ⇒ Zähler rot, Hinweis, nichts geht hinaus
await ui.type('Text der Antwort', 'a'.repeat(8001)) await ui.type('Text der Antwort', 'a'.repeat(8001))
assert.match(textOf(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0]), /8001 \/ 8000/) assert.match(textOf(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('hk-zaehler'))[0]), /8001 \/ 8000/)
assert.ok(String(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('wk-pult-count'))[0].props.className).includes('over')) assert.ok(String(nodes(ui.tree(), (n) => n.tag === 'span' && String(n.props.className).includes('hk-zaehler'))[0].props.className).includes('ueber'))
await ui.click('Jetzt senden') await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }]) assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Der Text ist zu lang (höchstens 8000 Zeichen).', role: 'alert' }])
assert.equal(api.calls.length, 0) assert.equal(api.calls.length, 0)
@@ -656,17 +728,20 @@ test('PultList (bedient, P4-T32): „Als Mensch antworten“ klappt auf, ist mit
await ui.click('Jetzt senden') await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }]) assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Kann gesendet sein – Pult neu laden.', role: 'alert' }])
assert.equal(ui.refreshes.length, 2) assert.equal(ui.refreshes.length, 2)
// Erfolg: der Eintrag verlässt die Liste, die Meldung steht oben, das Feld ist leer geräumt (zurück beim Entwurf), die Liste wird neu geholt // Erfolg: der Eintrag verlässt die Liste, der Brief schließt, die Meldung steht bei der Liste, die Liste wird neu geholt
await ui.click('Jetzt senden') await ui.click('Jetzt senden')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ gesendet.', role: 'status' }]) assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ gesendet.', role: 'status' }])
assert.equal(ui.refreshes.length, 3); assert.equal(api.calls.length, 3) assert.equal(ui.refreshes.length, 3); assert.equal(api.calls.length, 3)
assert.equal(ui.brief(), null)
// das Feld ist leer geräumt (beim erneuten Öffnen wieder beim Entwurf)
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara']) assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
} finally { ui.end() } } finally { ui.end() }
}) })
test('PultList (bedient, P4-T32): während einer Anfrage sind alle Knöpfe der Zeile gesperrt, ein zweiter Klick sendet nichts', async () => { test('Pult (bedient, P4-T32): während einer Anfrage sind alle Knöpfe des Briefs gesperrt, ein zweiter Klick sendet nichts', async () => {
let finish let reject
const pending = new Promise((resolve) => { finish = resolve }) const pending = new Promise((_, no) => { reject = no })
const api = scriptedApi(pending) const api = scriptedApi(pending)
const ui = await startList([unsicher], api) const ui = await startList([unsicher], api)
try { try {
@@ -676,12 +751,14 @@ test('PultList (bedient, P4-T32): während einer Anfrage sind alle Knöpfe der Z
for (const label of ['Jetzt senden', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [true], label) for (const label of ['Jetzt senden', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen', 'Ich kümmere mich']) assert.deepEqual(ui.disabled(label), [true], label)
await ui.click('Jetzt senden'); await ui.click('Vorschau bauen') await ui.click('Jetzt senden'); await ui.click('Vorschau bauen')
assert.equal(api.calls.length, 1) assert.equal(api.calls.length, 1)
finish({ item: { $id: 'e1' } }); await settle() // der Dienst antwortet mit einem Fehler: der Brief bleibt offen, alles ist wieder frei (bei einem Erfolg schließt der Brief)
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false]) assert.deepEqual(ui.disabled('Jetzt senden'), [false]); assert.deepEqual(ui.disabled('Vorschau bauen'), [false])
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }])
} finally { ui.end() } } finally { ui.end() }
}) })
test('PultList (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)
try { try {
@@ -693,7 +770,7 @@ test('PultList (bedient, P4-T32): ein „unsicher“-Eintrag ohne Entwurf öffne
// Endprüfung P4 (W4): die aufgeklappten Felder schließen mit Esc und mit „Abbrechen“; der Fokus geht an den Knopf zurück, der sie öffnete, der Text bleibt. // Endprüfung P4 (W4): die aufgeklappten Felder schließen mit Esc und mit „Abbrechen“; der Fokus geht an den Knopf zurück, der sie öffnete, der Text bleibt.
// Während einer Anfrage bleiben beide gesperrt (das Feld soll nicht mitten im Senden verschwinden). // Während einer Anfrage bleiben beide gesperrt (das Feld soll nicht mitten im Senden verschwinden).
test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch antworten“, geben den Fokus an den Knopf zurück und behalten den Text', async () => { test('Pult (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch antworten“, geben den Fokus an den Knopf zurück und behalten den Text', async () => {
const f = fakeFocus() const f = fakeFocus()
let finish let finish
const api = scriptedApi(new Promise((resolve) => { finish = resolve })) const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
@@ -701,16 +778,17 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch a
try { try {
const opener = f.el('opener') const opener = f.el('opener')
f.found.set('[data-opens="reply"]', opener) f.found.set('[data-opens="reply"]', opener)
assert.equal(clickable(inList(ui.tree()), 'Als Mensch antworten')[0].props['data-opens'], 'reply') // der Auslöser trägt seine Kennung await ui.open()
assert.equal(clickable(ui.brief(), 'Als Mensch antworten')[0].props['data-opens'], 'reply') // der Auslöser trägt seine Kennung
assert.ok(!ui.has('Abbrechen')) assert.ok(!ui.has('Abbrechen'))
await ui.click('Als Mensch antworten') await ui.click('Als Mensch antworten')
assert.ok(ui.has('Abbrechen')) assert.ok(ui.has('Abbrechen'))
await ui.type('Text der Antwort', 'Mein Text') await ui.type('Text der Antwort', 'Mein Text')
// eine andere Taste ändert nichts // eine andere Taste ändert nichts
let event = await ui.key('wk-pult-reply', 'a') let event = await ui.key('hk-feld-antwort', 'a')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false) assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false)
// Esc: Feld zu, Fokus auf dem Auslöser, das Ereignis ist behandelt (die Handy-Leiste schließt sonst mit) // Esc: Feld zu, Fokus auf dem Auslöser, das Ereignis ist behandelt (die Handy-Leiste schließt sonst mit)
event = await ui.key('wk-pult-reply') event = await ui.key('hk-feld-antwort')
assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 1); assert.equal(f.doc.activeElement, opener) assert.deepEqual(ui.value('Text der Antwort'), []); assert.equal(opener.focusCalls, 1); assert.equal(f.doc.activeElement, opener)
assert.ok(event.defaultPrevented); assert.ok(event.stopped) assert.ok(event.defaultPrevented); assert.ok(event.stopped)
assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], false) assert.equal(nodes(ui.tree(), (n) => n.tag === 'button' && textOf(n) === 'Als Mensch antworten')[0].props['aria-expanded'], false)
@@ -727,7 +805,7 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch a
await ui.click('Als Mensch antworten') await ui.click('Als Mensch antworten')
await ui.click('Jetzt senden') await ui.click('Jetzt senden')
assert.equal(api.calls.length, 1); assert.deepEqual(ui.disabled('Abbrechen'), [true]) assert.equal(api.calls.length, 1); assert.deepEqual(ui.disabled('Abbrechen'), [true])
event = await ui.key('wk-pult-reply') event = await ui.key('hk-feld-antwort')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false); assert.equal(opener.focusCalls, 2) assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']); assert.equal(event.defaultPrevented, false); assert.equal(opener.focusCalls, 2)
await ui.click('Abbrechen') await ui.click('Abbrechen')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text']) assert.deepEqual(ui.value('Text der Antwort'), ['Mein Text'])
@@ -735,7 +813,7 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen „Als Mensch a
} finally { ui.end(); f.restore() } } finally { ui.end(); f.restore() }
}) })
test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort des Kunden einfügen“ und kehren an dessen Knopf zurück', async () => { test('Pult (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort des Kunden einfügen“ und kehren an dessen Knopf zurück', async () => {
const f = fakeFocus() const f = fakeFocus()
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 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 api = scriptedApi() const api = scriptedApi()
@@ -743,10 +821,11 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort
try { try {
const opener = f.el('opener') const opener = f.el('opener')
f.found.set('[data-opens="paste"]', opener) f.found.set('[data-opens="paste"]', opener)
assert.equal(clickable(inList(ui.tree()), 'Antwort des Kunden einfügen')[0].props['data-opens'], 'paste') await ui.open()
assert.equal(clickable(ui.brief(), 'Antwort des Kunden einfügen')[0].props['data-opens'], 'paste')
await ui.click('Antwort des Kunden einfügen') await ui.click('Antwort des Kunden einfügen')
await ui.type('Antwort des Kunden', 'Noch nicht gesendet') await ui.type('Antwort des Kunden', 'Noch nicht gesendet')
await ui.key('wk-pult-paste') await ui.key('hk-feld-einfuegen')
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 1) assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 1)
await ui.click('Antwort des Kunden einfügen') await ui.click('Antwort des Kunden einfügen')
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch nicht gesendet']) // der Text bleibt assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch nicht gesendet']) // der Text bleibt
@@ -754,17 +833,18 @@ test('PultList (bedient, W4): Esc und „Abbrechen“ schließen auch „Antwort
assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0) assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(opener.focusCalls, 2); assert.equal(api.calls.length, 0)
// „Abbrechen“ liegt vor dem Absenden (Einfügen bleibt der Hauptknopf) // „Abbrechen“ liegt vor dem Absenden (Einfügen bleibt der Hauptknopf)
await ui.click('Antwort des Kunden einfügen') await ui.click('Antwort des Kunden einfügen')
const names = nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'wk-pult-panel-btns'))[0].children.map(textOf) const names = nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'hk-feld-knoepfe'))[0].children.map(textOf)
assert.deepEqual(names, ['Abbrechen', 'Einfügen']) assert.deepEqual(names, ['Abbrechen', 'Einfügen'])
} finally { ui.end(); f.restore() } } finally { ui.end(); f.restore() }
}) })
// Endprüfung P4 (W3): ein gesperrter Knopf verliert den Fokus an <body>. Nach der Anfrage gibt die Zeile ihn zurück (an den Knopf, sonst ans Feld, an den // Endprüfung P4 (W3): ein gesperrter Knopf verliert den Fokus an <body>. Nach der Anfrage gibt der Brief ihn zurück (an den Knopf, sonst ans Feld, an den
// Knopf, der das Feld öffnete, oder an die Zeile); verlässt eine Zeile die Liste, geht er an die Meldung oben. Wer den Fokus inzwischen selbst // Knopf, der das Feld öffnete, oder an den Brief); verlässt ein Eintrag die Liste, geht er an die Meldung oben. Wer den Fokus inzwischen selbst
// woanders hat, wird nicht gestört. Der Test spielt den Browser nach: activeElement stellt der Test (Klick: Knopf; Sperre: body). // woanders hat, wird nicht gestört. Der Test spielt den Browser nach: activeElement stellt der Test (Klick: Knopf; Sperre: body).
const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea' // Der Brief nimmt beim Öffnen selbst den Fokus (erster Knopf): die Tests setzen activeElement deshalb erst nach ui.open().
const PANEL_FIELD = '.hk-feld-panel textarea'
test('PultList (bedient, W3): nach einem Fehler beim Senden steht der Fokus wieder auf „Jetzt senden“; hat die Person inzwischen selbst gewählt, bleibt er dort', async () => { test('Pult (bedient, W3): nach einem Fehler beim Senden steht der Fokus wieder auf „Jetzt senden“; hat die Person inzwischen selbst gewählt, bleibt er dort', async () => {
for (const moved of [false, true]) { for (const moved of [false, true]) {
const f = fakeFocus() const f = fakeFocus()
let reject let reject
@@ -778,34 +858,34 @@ test('PultList (bedient, W3): nach einem Fehler beim Senden steht der Fokus wied
const other = f.el('other') const other = f.el('other')
f.doc.activeElement = moved ? other : f.body // der gesperrte Knopf verliert den Fokus (Chromium) – oder die Person ist schon woandershin gegangen f.doc.activeElement = moved ? other : f.body // der gesperrte Knopf verliert den Fokus (Chromium) – oder die Person ist schon woandershin gegangen
reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle() reject(failure('Wird schon von einem Kollegen bearbeitet.', 409)); await settle()
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }]) // die Zeile bleibt assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Wird schon von einem Kollegen bearbeitet.', role: 'alert' }]) // der Brief bleibt
if (moved) assert.deepEqual([f.doc.activeElement, submit.focusCalls], [other, 0]) if (moved) assert.deepEqual([f.doc.activeElement, submit.focusCalls], [other, 0])
else assert.deepEqual([f.doc.activeElement, submit.focusCalls], [submit, 1]) else assert.deepEqual([f.doc.activeElement, submit.focusCalls], [submit, 1])
} finally { ui.end(); f.restore() } } finally { ui.end(); f.restore() }
} }
}) })
test('PultList (bedient, W3): ist der Knopf nach der Aktion weg („Ich kümmere mich“), geht der Fokus an die Zeile; sie ist mit tabIndex -1 fokussierbar', async () => { test('Pult (bedient, W3): ist der Knopf nach der Aktion weg („Ich kümmere mich“), geht der Fokus an den Brief; er ist mit tabIndex -1 fokussierbar', async () => {
const f = fakeFocus() const f = fakeFocus()
let finish let finish
const api = scriptedApi(new Promise((resolve) => { finish = resolve })) const api = scriptedApi(new Promise((resolve) => { finish = resolve }))
const ui = await startList([unsicher], api) const ui = await startList([unsicher], api)
try { try {
const li = nodes(ui.tree(), (n) => n.tag === 'li')[0] await ui.open()
assert.equal(li.props.tabIndex, -1) assert.equal(ui.brief().props.tabIndex, -1)
const claim = f.el('claim') const claim = f.el('claim')
f.doc.activeElement = claim f.doc.activeElement = claim
await ui.click('Ich kümmere mich') await ui.click('Ich kümmere mich')
f.doc.activeElement = f.body f.doc.activeElement = f.body
claim.isConnected = false // nach „übernommen“ gibt es den Knopf nicht mehr claim.isConnected = false // nach „übernommen“ gibt es den Knopf nicht mehr
finish({ item: { $id: 'e1' } }); await settle() finish({ item: { $id: 'e1' } }); await settle()
const row = f.ref('wk-pult-row') const brief = f.ref('hk-brief')
assert.deepEqual([row.focusCalls, f.doc.activeElement], [1, row]) assert.deepEqual([brief.focusCalls, f.doc.activeElement], [1, brief])
assert.equal(claim.focusCalls, 0) assert.equal(claim.focusCalls, 0)
} finally { ui.end(); f.restore() } } finally { ui.end(); f.restore() }
}) })
test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus an „Antwort des Kunden einfügen“; nach einem Fehler bleibt er im Feld', async () => { test('Pult (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus an „Antwort des Kunden einfügen“; nach einem Fehler bleibt er im Feld', async () => {
const f = fakeFocus() const f = fakeFocus()
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 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' } }
let reject let reject
@@ -823,7 +903,7 @@ test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus
f.doc.activeElement = f.body f.doc.activeElement = f.body
reject(failure('Dieser Kontakt ist gesperrt.', 409)); await settle() reject(failure('Dieser Kontakt ist gesperrt.', 409)); await settle()
assert.deepEqual([f.doc.activeElement, insert.focusCalls], [insert, 1]) assert.deepEqual([f.doc.activeElement, insert.focusCalls], [insert, 1])
// Erfolg: das Feld klappt zu (Textfeld und Knopf „Einfügen“ sind weg), der Fokus geht an „Antwort des Kunden einfügen“ (vor der Zeile) // Erfolg: das Feld klappt zu (Textfeld und Knopf „Einfügen“ sind weg), der Fokus geht an „Antwort des Kunden einfügen“ (vor dem Brief)
f.found.delete(PANEL_FIELD) f.found.delete(PANEL_FIELD)
f.doc.activeElement = insert f.doc.activeElement = insert
await ui.click('Einfügen') await ui.click('Einfügen')
@@ -831,12 +911,12 @@ test('PultList (bedient, W3): nach dem Einfügen (Feld klappt zu) geht der Fokus
insert.isConnected = false insert.isConnected = false
finish({ status: 'neu' }); await settle() finish({ status: 'neu' }); await settle()
assert.deepEqual([opener.focusCalls, f.doc.activeElement], [1, opener]) assert.deepEqual([opener.focusCalls, f.doc.activeElement], [1, opener])
assert.equal(field.focusCalls, 0); assert.equal(f.ref('wk-pult-row').focusCalls, 0) assert.equal(field.focusCalls, 0); assert.equal(f.ref('hk-brief').focusCalls, 0)
// und bleibt das Feld offen, aber der Knopf ist weg, nimmt das Textfeld den Fokus (vor dem Auslöser) // und bleibt das Feld offen, aber der Knopf ist weg, nimmt das Textfeld den Fokus (vor dem Auslöser)
} finally { ui.end(); f.restore() } } finally { ui.end(); f.restore() }
}) })
test('PultList (bedient, W3): verlässt die Zeile die Liste, geht der Fokus an die Meldung oben (auch bei gleichem Text); sie ist mit tabIndex -1 fokussierbar', async () => { test('Pult (bedient, W3): verlässt ein Eintrag die Liste, geht der Fokus an die Meldung oben (auch bei gleichem Text); sie ist mit tabIndex -1 fokussierbar', async () => {
const f = fakeFocus() const f = fakeFocus()
const zweite = { ...unsicher, $id: 'e2' } // gleicher Titel: dieselbe Meldung zweimal const zweite = { ...unsicher, $id: 'e2' } // gleicher Titel: dieselbe Meldung zweimal
let finish let finish
@@ -844,27 +924,31 @@ test('PultList (bedient, W3): verlässt die Zeile die Liste, geht der Fokus an d
const ui = await startList([unsicher, zweite], api) const ui = await startList([unsicher, zweite], api)
try { try {
// der Knopf, den die Anfrage gerade sperrt (disabled): der Browser lässt ihn erst beim nächsten Takt los, dann liegt der Fokus auf <body> // der Knopf, den die Anfrage gerade sperrt (disabled): der Browser lässt ihn erst beim nächsten Takt los, dann liegt der Fokus auf <body>
await ui.open(0)
const done0 = f.el('done0', { disabled: true }); f.doc.activeElement = done0 const done0 = f.el('done0', { disabled: true }); f.doc.activeElement = done0
await ui.click('Erledigt', 0) await ui.click('Erledigt')
const status = f.ref('msg') const status = f.ref('hk-hinweis')
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ erledigt.', role: 'status' }]) assert.deepEqual(ui.messages(), [{ kind: 'ok', text: '„Unklare Antwort“ erledigt.', role: 'status' }])
assert.equal(nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'msg'))[0].props.tabIndex, -1) assert.equal(ui.brief(), null) // der Brief schließt, der Eintrag verlässt die Liste
assert.equal(nodes(ui.tree(), (n) => n.tag === 'div' && hasClass(n, 'hk-hinweis'))[0].props.tabIndex, -1)
assert.deepEqual([status.focusCalls, f.doc.activeElement], [1, status]) assert.deepEqual([status.focusCalls, f.doc.activeElement], [1, status])
assert.equal(done0.focusCalls, 0) assert.equal(done0.focusCalls, 0)
// zweite Zeile, gleiche Meldung: wieder auf die Meldung // zweiter Eintrag, gleiche Meldung: wieder auf die Meldung
await ui.open(1)
f.doc.activeElement = f.el('done1', { disabled: true }) f.doc.activeElement = f.el('done1', { disabled: true })
await ui.click('Erledigt', 1) await ui.click('Erledigt')
assert.equal(status.focusCalls, 2); assert.equal(f.doc.activeElement, status) assert.equal(status.focusCalls, 2); assert.equal(f.doc.activeElement, status)
// hat die Person den Fokus während der Anfrage selbst woanders hingesetzt, bleibt er dort // hat die Person den Fokus während der Anfrage selbst woanders hingesetzt, bleibt er dort
await ui.open(0)
f.doc.activeElement = f.el('done2', { disabled: true }) f.doc.activeElement = f.el('done2', { disabled: true })
await ui.click('Erledigt', 0) await ui.click('Erledigt')
const other = f.el('other'); f.doc.activeElement = other const other = f.el('other'); f.doc.activeElement = other
finish({}); await settle() finish({}); await settle()
assert.deepEqual([status.focusCalls, f.doc.activeElement], [2, other]) assert.deepEqual([status.focusCalls, f.doc.activeElement], [2, other])
} finally { ui.end(); f.restore() } } finally { ui.end(); f.restore() }
}) })
test('PultList (bedient, W3): ohne document (Server, Node) und ohne Fokus vor dem Klick passiert nichts', async () => { test('Pult (bedient, W3): ohne document (Server, Node) und ohne Fokus vor dem Klick passiert nichts', async () => {
const api = scriptedApi() const api = scriptedApi()
const ui = await startList([unsicher], api) // kein document, kein fakeRef: refs bleiben null const ui = await startList([unsicher], api) // kein document, kein fakeRef: refs bleiben null
try { try {
@@ -876,21 +960,24 @@ test('PultList (bedient, W3): ohne document (Server, Node) und ohne Fokus vor de
const f = fakeFocus() const f = fakeFocus()
const ui2 = await startList([unsicher], scriptedApi()) const ui2 = await startList([unsicher], scriptedApi())
try { try {
await ui2.open()
f.doc.activeElement = f.body // der Brief hat beim Öffnen den Fokus genommen; ein Klick in Safari ließe ihn bei <body>
await ui2.click('Ich kümmere mich') await ui2.click('Ich kümmere mich')
assert.deepEqual([f.ref('wk-pult-row').focusCalls, f.doc.activeElement], [0, f.body]) assert.deepEqual([f.ref('hk-brief').focusCalls, f.doc.activeElement], [0, f.body])
await ui2.click('Erledigt') await ui2.click('Erledigt')
assert.ok(ui2.text().includes('erledigt.')) assert.ok(ui2.text().includes('erledigt.'))
assert.deepEqual([f.ref('msg').focusCalls, f.doc.activeElement], [0, f.body]) assert.deepEqual([f.ref('hk-hinweis').focusCalls, f.doc.activeElement], [0, f.body])
} finally { ui2.end(); f.restore() } } finally { ui2.end(); f.restore() }
}) })
// Endprüfung P4 (W2): „Vorschau bauen“ startet einen Opus-Lauf und lässt sich in der Oberfläche nicht zurücknehmen: wie Freigeben und Verwerfen // Endprüfung P4 (W2): „Vorschau bauen“ startet einen Opus-Lauf und lässt sich in der Oberfläche nicht zurücknehmen: wie Freigeben und Verwerfen
// fragt der Knopf zuerst nach (4 s). Ein Fehlklick (Handy: der Knopf liegt in voller Breite zwischen anderen) startet keinen Bau mehr. // fragt der Knopf zuerst nach (4 s). Ein Fehlklick (Handy: der Knopf liegt in voller Breite zwischen anderen) startet keinen Bau mehr.
test('PultList (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (4 s, Hinweis), zweiter startet den Bau; Fehler und Erfolg wie bisher', async () => { test('Pult (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (4 s, Hinweis), zweiter startet den Bau; Fehler und Erfolg wie bisher', async () => {
const api = scriptedApi(failure('Für diesen Interessenten gibt es schon eine Vorschau.', 409), Promise.resolve({ item: { $id: 'e1' } })) const api = scriptedApi(failure('Für diesen Interessenten gibt es schon eine Vorschau.', 409), Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([{ ...unsicher, draft: undefined }], api) const ui = await startList([{ ...unsicher, draft: undefined }], api)
try { try {
// erster Klick: Rückfrage und Hinweis zu Kosten und Wirkung, nichts geht hinaus; die Rückfrage verfällt nach 4 s // erster Klick: Rückfrage und Hinweis zu Kosten und Wirkung, nichts geht hinaus; die Rückfrage verfällt nach 4 s
await ui.open()
assert.ok(!ui.text().includes('Opus')) assert.ok(!ui.text().includes('Opus'))
await ui.click('Vorschau bauen') await ui.click('Vorschau bauen')
assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich bauen?')); assert.ok(!ui.has('Vorschau bauen')) assert.equal(api.calls.length, 0); assert.ok(ui.has('Wirklich bauen?')); assert.ok(!ui.has('Vorschau bauen'))
@@ -911,7 +998,7 @@ test('PultList (bedient, W2): „Vorschau bauen“ – erster Klick fragt nach (
} finally { ui.end() } } finally { ui.end() }
}) })
test('PultList (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben lösen einander ab; Verwerfen sendet nichts ohne zweiten Klick', async () => { test('Pult (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben lösen einander ab; Verwerfen sendet nichts ohne zweiten Klick', async () => {
const api = scriptedApi() const api = scriptedApi()
const ui = await startList([unsicher], api) const ui = await startList([unsicher], api)
try { try {
@@ -924,12 +1011,13 @@ test('PultList (bedient, W2): die Rückfragen vor Bauen, Verwerfen und Freigeben
} finally { ui.end() } } finally { ui.end() }
}) })
test('PultList (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach (4 s), zweiter sendet; keine Antwort heißt „Kann gesendet sein“', async () => { test('Pult (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach (4 s), zweiter sendet; keine Antwort heißt „Kann gesendet sein“', async () => {
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' } const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.', sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }
const api = scriptedApi(failure('Die Vorschau ist nicht freigabebereit.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'f1' }, previewUrl: freigabe.sourceUrl })) const api = scriptedApi(failure('Die Vorschau ist nicht freigabebereit.', 409), failure('Der Hustler-Dienst ist gerade nicht erreichbar.', 0), Promise.resolve({ item: { $id: 'f1' }, previewUrl: freigabe.sourceUrl }))
const ui = await startList([freigabe], api) const ui = await startList([freigabe], api)
try { try {
// der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug // der Link zur Vorschau öffnet in einem neuen Tab ohne Rückbezug
await ui.open()
const link = ui.link('Vorschau ansehen') const link = ui.link('Vorschau ansehen')
assert.deepEqual([link.tag, link.props.href, link.props.target, link.props.rel], ['a', 'https://entwurf.project.webklar.com/AAA/', '_blank', 'noopener noreferrer']) assert.deepEqual([link.tag, link.props.href, link.props.target, link.props.rel], ['a', 'https://entwurf.project.webklar.com/AAA/', '_blank', 'noopener noreferrer'])
assert.ok(!ui.has('Anzeige öffnen')); assert.ok(!ui.has('Erledigt')) assert.ok(!ui.has('Anzeige öffnen')); assert.ok(!ui.has('Erledigt'))
@@ -958,12 +1046,13 @@ test('PultList (bedient, P4-T32): Vorschau freigeben – erster Klick fragt nach
}) })
// Endprüfung P4 (W5): ohne gültige Vorschau-Adresse lässt sich nicht freigeben (auch nicht über die Rückfrage), mit gültiger Adresse wie bisher // Endprüfung P4 (W5): ohne gültige Vorschau-Adresse lässt sich nicht freigeben (auch nicht über die Rückfrage), mit gültiger Adresse wie bisher
test('PultList (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorschau-Adresse gesperrt – kein Klick, keine Rückfrage, keine Aktion', async () => { test('Pult (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorschau-Adresse gesperrt – kein Klick, keine Rückfrage, keine Aktion', async () => {
const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.' } const freigabe = { ...unsicher, $id: 'f1', kind: 'vorschau_freigabe', title: 'Vorschau prüfen', draft: 'Hier ist Ihre Vorschau.' }
for (const sourceUrl of ['http://entwurf.project.webklar.com/AAA/', 'https://fremd.example/AAA/', '']) { for (const sourceUrl of ['http://entwurf.project.webklar.com/AAA/', 'https://fremd.example/AAA/', '']) {
const api = scriptedApi() const api = scriptedApi()
const ui = await startList([{ ...freigabe, sourceUrl }], api) const ui = await startList([{ ...freigabe, sourceUrl }], api)
try { try {
await ui.open()
assert.deepEqual(ui.disabled('Freigeben & senden'), [true], sourceUrl) assert.deepEqual(ui.disabled('Freigeben & senden'), [true], sourceUrl)
assert.ok(ui.text().includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich')); assert.ok(!ui.has('Vorschau ansehen')) assert.ok(ui.text().includes('Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich')); assert.ok(!ui.has('Vorschau ansehen'))
await ui.click('Freigeben & senden') await ui.click('Freigeben & senden')
@@ -977,13 +1066,14 @@ test('PultList (bedient, W5): „Freigeben & senden“ ist ohne gültige Vorscha
const api = scriptedApi() const api = scriptedApi()
const ui = await startList([{ ...freigabe, sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }], api) const ui = await startList([{ ...freigabe, sourceUrl: 'https://entwurf.project.webklar.com/AAA/' }], api)
try { try {
await ui.open()
assert.deepEqual(ui.disabled('Freigeben & senden'), [false]); assert.ok(!ui.text().includes('Keine gültige Vorschau-Adresse')) assert.deepEqual(ui.disabled('Freigeben & senden'), [false]); assert.ok(!ui.text().includes('Keine gültige Vorschau-Adresse'))
await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?')) await ui.click('Freigeben & senden'); assert.ok(ui.has('Wirklich freigeben?'))
await ui.click('Wirklich freigeben?'); assert.deepEqual(api.calls, [['f1', 'approve-preview']]) await ui.click('Wirklich freigeben?'); assert.deepEqual(api.calls, [['f1', 'approve-preview']])
} finally { ui.end() } } finally { ui.end() }
}) })
test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres Feld mit Hinweis, Prüfung, Einfügen, doppelt, Fehler des Dienstes, Text bleibt beim Filterwechsel', async () => { test('Pult (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres Feld mit Hinweis, Prüfung, Einfügen, doppelt, Fehler des Dienstes, Text bleibt beim Filterwechsel', async () => {
const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } } const plattform = { ...unsicher, $id: 'k1', kind: 'plattform_antwort', status: 'in_arbeit', claimedBy: 'mensch:u2', title: 'Suche Webdesigner', draft: 'Claras Entwurf', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein' } }
const api = scriptedApi(Promise.resolve({ status: 'neu', messageId: 'm1', threadId: 't1' }), Promise.resolve({ status: 'doppelt', messageId: 'm1', threadId: 't1' }), failure('Dieser Kontakt ist gesperrt.', 409)) const api = scriptedApi(Promise.resolve({ status: 'neu', messageId: 'm1', threadId: 't1' }), Promise.resolve({ status: 'doppelt', messageId: 'm1', threadId: 't1' }), failure('Dieser Kontakt ist gesperrt.', 409))
const ui = await startList([plattform], api) const ui = await startList([plattform], api)
@@ -996,7 +1086,7 @@ test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres
await ui.click('Einfügen') await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]); assert.equal(api.calls.length, 0) assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Bitte einen Text eingeben.', role: 'alert' }]); assert.equal(api.calls.length, 0)
// einfügen: getrimmter Text an die Id des Eintrags; danach ist das Feld geleert und zu, die Zeile meldet das Einsortieren // einfügen: getrimmter Text an die Id des Eintrags; danach ist das Feld geleert und zu, der Brief meldet das Einsortieren
await ui.type('Antwort des Kunden', ' Danke, das passt. ') await ui.type('Antwort des Kunden', ' Danke, das passt. ')
await ui.click('Einfügen') await ui.click('Einfügen')
assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]]) assert.deepEqual(api.calls, [['k1', 'paste', { text: 'Danke, das passt.' }]])
@@ -1010,13 +1100,14 @@ test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres
assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }]) assert.deepEqual(ui.messages(), [{ kind: 'ok', text: 'Diese Antwort war schon eingefügt.', role: 'status' }])
assert.deepEqual(ui.value('Antwort des Kunden'), []) assert.deepEqual(ui.value('Antwort des Kunden'), [])
// Dienstfehler: Satz als Fehler, der Text bleibt stehen (auch wenn der Reiter gewechselt wird und die Zeile verschwindet) // Dienstfehler: Satz als Fehler, der Text bleibt stehen (auch wenn der Brief schließt, der Reiter gewechselt wird und der Eintrag verschwindet)
await ui.click('Antwort des Kunden einfügen') await ui.click('Antwort des Kunden einfügen')
await ui.type('Antwort des Kunden', 'Noch eine Antwort') await ui.type('Antwort des Kunden', 'Noch eine Antwort')
await ui.click('Einfügen') await ui.click('Einfügen')
assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }]) assert.deepEqual(ui.messages(), [{ kind: 'err', text: 'Dieser Kontakt ist gesperrt.', role: 'alert' }])
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort']) assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), []) await ui.close()
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Antwort des Kunden'), []); assert.equal(envelopes(ui.tree()).length, 0)
await ui.tab('Wartet') await ui.tab('Wartet')
await ui.click('Antwort des Kunden einfügen') await ui.click('Antwort des Kunden einfügen')
assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort']) assert.deepEqual(ui.value('Antwort des Kunden'), ['Noch eine Antwort'])
@@ -1025,40 +1116,52 @@ test('PultList (bedient, P4-T32): „Antwort des Kunden einfügen“ – leeres
} finally { ui.end() } } finally { ui.end() }
}) })
test('PultList (bedient, P4-T32): ein Text von „Als Mensch antworten“ überlebt den Wechsel des Reiters; nach dem Absenden ist er weg', async () => { test('Pult (bedient, P4-T32): ein Text von „Als Mensch antworten“ überlebt Schließen und den Wechsel des Reiters; nach dem Absenden ist er weg', async () => {
const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } })) const api = scriptedApi(Promise.resolve({ item: { $id: 'e1' } }))
const ui = await startList([unsicher], api) const ui = await startList([unsicher], api)
try { try {
await ui.click('Als Mensch antworten') await ui.click('Als Mensch antworten')
await ui.type('Text der Antwort', 'Mein eigener Text') await ui.type('Text der Antwort', 'Mein eigener Text')
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Text der Antwort'), []) // die Zeile ist weg await ui.close()
await ui.tab('Veraltet'); assert.deepEqual(ui.value('Text der Antwort'), []) // der Eintrag ist weg
await ui.tab('Wartet') await ui.tab('Wartet')
await ui.click('Als Mensch antworten') await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text']) assert.deepEqual(ui.value('Text der Antwort'), ['Mein eigener Text'])
await ui.click('Jetzt senden') await ui.click('Jetzt senden')
assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]]) assert.deepEqual(api.calls, [['e1', 'reply', { text: 'Mein eigener Text' }]])
await ui.click('Als Mensch antworten')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara']) assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf von Clara'])
} finally { ui.end() } } finally { ui.end() }
}) })
test('PultList (bedient, P4-T32): mehrere Einträge desselben Interessenten – Felder, Texte und Aktionen hängen an der Eintrags-Id', async () => { test('Pult (bedient, P4-T32): mehrere Einträge desselben Interessenten – Felder, Texte und Aktionen hängen an der Eintrags-Id', async () => {
const zweite = { ...unsicher, $id: 'e3', title: 'Zweite Rückfrage', draft: 'Entwurf drei' } // gleicher Interessent p1 wie e1 const zweite = { ...unsicher, $id: 'e3', title: 'Zweite Rückfrage', draft: 'Entwurf drei' } // gleicher Interessent p1 wie e1
const nachfolge = { ...unsicher, $id: 'k9', kind: 'plattform_antwort', title: 'Folge-Antwort', draft: 'Claras Folge', prospect: { $id: 'p1', name: 'Verein' } } const nachfolge = { ...unsicher, $id: 'k9', kind: 'plattform_antwort', title: 'Folge-Antwort', draft: 'Claras Folge', prospect: { $id: 'p1', name: 'Verein' } }
const api = scriptedApi(Promise.resolve({ item: { $id: 'e3' } }), Promise.resolve({ status: 'neu' })) const api = scriptedApi(Promise.resolve({ item: { $id: 'e3' } }), Promise.resolve({ status: 'neu' }))
const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api) const ui = await startList([{ ...unsicher, draft: 'Entwurf eins' }, zweite, nachfolge], api)
try { try {
assert.equal(nodes(ui.tree(), (n) => n.tag === 'li').length, 3) assert.equal(envelopes(ui.tree()).length, 3)
await ui.click('Als Mensch antworten', 0) await ui.click('Als Mensch antworten', 0)
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur Zeile 1 ist aufgeklappt assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins']) // nur der Brief des ersten Eintrags ist offen
await ui.type('Text der Antwort', 'Text eins')
await ui.click('Als Mensch antworten', 1) await ui.click('Als Mensch antworten', 1)
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf drei']) // der Brief zeigt nur seinen Eintrag
await ui.type('Text der Antwort: Zweite Rückfrage', 'Text drei') await ui.type('Text der Antwort: Zweite Rückfrage', 'Text drei')
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Text drei']) // die Texte stören sich nicht // die Texte stören sich nicht: nach dem Wechsel zurück steht in jedem Eintrag sein eigener Text
// gesendet wird der Text der Zeile, deren Knopf gedrückt wurde, an die Id dieses Eintrags await ui.click('Als Mensch antworten', 0)
await ui.click('Jetzt senden', 1) assert.deepEqual(ui.value('Text der Antwort'), ['Text eins'])
await ui.click('Als Mensch antworten', 1)
assert.deepEqual(ui.value('Text der Antwort'), ['Text drei'])
// gesendet wird der Text des Eintrags, dessen Brief offen ist, an die Id dieses Eintrags
await ui.click('Jetzt senden')
assert.deepEqual(api.calls, [['e3', 'reply', { text: 'Text drei' }]]) assert.deepEqual(api.calls, [['e3', 'reply', { text: 'Text drei' }]])
assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf eins', 'Entwurf drei']) // e3 ist abgeschlossen (Text zurückgesetzt), e1 unberührt assert.equal(ui.brief(), null)
// Einfügen an der dritten Zeile: nur dort, mit der Id des Eintrags k9 await ui.click('Als Mensch antworten', 1)
await ui.click('Antwort des Kunden einfügen') assert.deepEqual(ui.value('Text der Antwort'), ['Entwurf drei']) // e3 ist abgeschlossen (Text zurückgesetzt)
await ui.click('Als Mensch antworten', 0)
assert.deepEqual(ui.value('Text der Antwort'), ['Text eins']) // e1 unberührt
// Einfügen am dritten Eintrag: nur dort, mit der Id des Eintrags k9
await ui.click('Antwort des Kunden einfügen', 2)
await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern') await ui.type('Antwort des Kunden: Folge-Antwort', 'Ja, gern')
await ui.click('Einfügen') await ui.click('Einfügen')
assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }]) assert.deepEqual(api.calls[1], ['k9', 'paste', { text: 'Ja, gern' }])