5 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 3c44c42681 Test: Szene bei Stufe 3 prüft Arne wirklich nach seinem Status
Der Test "die Szene zeigt Arne nach seinem Status" prüfte bisher nur die Zahl im
Schild und die Abruf-Anzahl. Er prüft jetzt bei Stufe 3 data-hk-arne, hk-arne-weg,
die drei hk-kl-ruht, den Marker hk-alarm und hk-steht für arbeitet, leerlauf,
fehler und angehalten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:35:27 +00:00
ClaudeandClaude Sonnet 5.5 2c0553a6b2 Prüfstand: Kommentare nennen die getrennten Schwellen (Keller ab 3, Bot-Leads ab 6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:29:13 +00:00
ClaudeandClaude Sonnet 5.5 5c3248ae8f Hustler-Stufe: Doku trennt Keller begehbar (3) und Bot-Leads abrufbar (6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:24:21 +00:00
ClaudeandClaude Sonnet 5.5 1ab760be0c Hustler-Keller: unter Stufe 6 leeres Register ohne Abruf, mit ehrlichem Text
BotLeadList bekommt die Stufe (WorkersPage reicht sie durch). Unterhalb von BOTLEADS_AB
(Stufe 6) ruft sie weder api.botleads noch ein JWT, zeigt keine Ladeanzeige und keinen
Fehlerzustand: Szene mit Arne, Register-Buch, Reiter und Suche stehen da, das Register ist leer
("Noch keine Bot-Leads. Hier legt Arne Betriebe ohne Website ab, die Bea nicht fuer eine Mail
ausgewaehlt hat. Die Branchen-Raeume ... suchen noch nicht."), ohne Zahl am Reiter. Reiter,
Suche, Zuklappen und Alle anzeigen bleiben bedienbar und loesen nie einen Abruf aus. Ab Stufe 6
unveraendert (Live-Daten); der Test-Laeufer startet den Keller standardmaessig mit Stufe 6.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:23:03 +00:00
ClaudeandClaude Sonnet 5.5 6eb4dd3c51 Hustler-Stufe: Keller begehbar ab Stufe 3, Bot-Leads abrufbar erst ab Stufe 6
Die Stufen-Sperre trennt jetzt zwei Dinge: KELLER_BEGEHBAR_AB = 3 (Keller-Etage als Link,
?tab=botleads gueltig, Spur-Links in den Keller) und BOTLEADS_AB = 6 (Abruf von GET /botleads).
kellerOffen/STUFE_KELLER entfallen, dafuer kellerBegehbar() und botleadsAbrufbar().
workersApi.botleads bleibt unter Stufe 6 hart gesperrt, der Text heisst nun
BOTLEADS_GESPERRT_TEXT. Die Keller-Ansicht selbst (leeres Register ohne Abruf) folgt im
naechsten Commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:21:59 +00:00
19 changed files with 338 additions and 110 deletions

No files matched your search

+35 -9
View File
@@ -10,11 +10,23 @@ export const HUSTLER_STUFE = stufeAus(buildWert(), 3)
Die `3` in dieser Zeile ist die Stufe der Produktion. Mehr ist zum Hochsetzen nicht zu ändern.
Es gibt drei Schwellen (alle in `src/lib/hustlerStufe.js`, mit je einer Funktion, die Bausteine und Tests fragen):
| Konstante | Wert | Funktion | Was sie freigibt |
|---|---|---|---|
| `KELLER_BEGEHBAR_AB` | 3 | `kellerBegehbar(stufe)` | der Keller als Bildschirm: Keller-Etage als Link, `?tab=botleads` (auch mit `&bot=`), Spur-Links in den Keller |
| `STUFE_ANTWORTEN` | 4 | `antwortenOffen(stufe)` | die Antwort-Knöpfe im Pult (Dienst P4) |
| `BOTLEADS_AB` | 6 | `botleadsAbrufbar(stufe)` | der Abruf `GET /api/workers/botleads` (Dienst P6): nur dann holt der Keller Daten |
**Keller begehbar** und **Bot-Leads abrufbar** sind getrennt: Ab Stufe 3 öffnet der Keller seine Szene (Arne, Regale, Register-Buch, Reiter, Suche), auch wenn der Dienst die Bot-Leads noch nicht liefert.
Dann steht das Register leer da (siehe unten) und es geht keine Anfrage hinaus.
| Stufe | Dienst | Oberfläche |
|---|---|---|
| 1 bis 3 | P1 + P3 (state, events, settings, pause, trace, pult GET, claim, sent, discard, done, prospect) | Hochhaus, Detailansicht, Pult mit Übernehmen, Kopieren & öffnen, Als gesendet markieren, Verwerfen, Erledigt, E-Mail schreiben. **Kein Keller, keine Antwort-Knöpfe.** |
| 4 und 5 | zusätzlich P4: `POST /pult/:id/paste`, `/reply`, `/approve-preview`, `/start-build` | zusätzlich im Pult-Brief: Antwort des Kunden einfügen, Als Mensch antworten / Jetzt senden, Freigeben & senden, Vorschau bauen. Kein Keller. |
| 6 bis 9 | zusätzlich P6: `GET /botleads` | zusätzlich der Keller (Bot-Leads): Keller-Etage als Link, Bildschirm `?tab=botleads`, Deeplinks, Spur-Links. Entspricht dem vollen Stand von `75ed38e`. |
| 1 und 2 | vor P3 (kein Produktionswert, nur für Tests des geschlossenen Kellers) | wie Stufe 3, nur der Keller ist zu: Keller-Etage zoomt, `?tab=botleads` fällt aufs Hochhaus, Spur-Links in den Keller sind Text. Keine Antwort-Knöpfe. |
| 3 | P1 + P3 (zusätzlich pult GET, claim, sent, discard, done, prospect) | Hochhaus, Detailansicht, Pult mit Übernehmen, Kopieren & öffnen, Als gesendet markieren, Verwerfen, Erledigt, E-Mail schreiben. **Keller begehbar, aber leer** (kein Abruf der Bot-Leads). Keine Antwort-Knöpfe. Das ist die Produktion. |
| 4 und 5 | zusätzlich P4: `POST /pult/:id/paste`, `/reply`, `/approve-preview`, `/start-build` | zusätzlich im Pult-Brief: Antwort des Kunden einfügen, Als Mensch antworten / Jetzt senden, Freigeben & senden, Vorschau bauen. Keller wie bei Stufe 3 (leer). |
| 6 bis 9 | zusätzlich P6: `GET /botleads` | der Keller holt die Bot-Leads (Live-Daten): Register mit Zeilen, Suche, Raum-Reiter, Seiten, Akte. Entspricht dem vollen Stand von `75ed38e`. |
## Was unterhalb von Stufe 4 gesperrt ist (P4-Aktionen)
@@ -22,13 +34,27 @@ Die `3` in dieser Zeile ist die Stufe der Produktion. Mehr ist zum Hochsetzen ni
Brief und Liste lesen nur diese Felder, deshalb verschwinden Knöpfe, Eingabefelder, Rückfragen und Hinweise zusammen; die Aktionsgruppe schließt sich ohne Lücke.
- `PultList` reicht `stufe` (Standard `HUSTLER_STUFE`) an `rowModel` durch. P3-Aktionen bleiben unverändert.
## Was unterhalb von Stufe 6 gesperrt ist (Keller)
## Keller: begehbar ab Stufe 3, Bot-Leads erst ab Stufe 6
Unterhalb von Stufe 3 (`kellerBegehbar` false) ist der Keller zu:
- `WorkersPage`: `?tab=botleads` (auch mit `&bot=`) und jeder andere unbekannte Wert zeigen das Hochhaus (`pickTab` nimmt den ersten erlaubten Bildschirm). `BotLeadList` wird nie eingehängt.
Die Weiterleitungen `/leads` und `/workers` auf `/hustler` (Query bleibt) gelten weiter und enden dann im Hochhaus.
- `HochhausWelt`: die Keller-Etage ist kein Link auf `/hustler?tab=botleads`, sondern ein Knopf wie jede andere Etage (zoomt heran, „Keller heranzoomen“).
- `HustlerDialog`: ein Spur-Link in den Keller bleibt Text. Arne ist weiter sichtbar und klickbar (Detailansicht), die Zahl „Bot-Leads“ in der Kopfzeile und am Schild im Haus bleibt.
- `workersApi.botleads` ruft unterhalb von Stufe 6 gar nichts ab (kein JWT, keine Anfrage) und liefert den Fehler 404 „Der Keller ist noch nicht freigeschaltet.“
Ab Stufe 3 sind alle drei Punkte offen (Etage ist ein Link, `?tab=botleads` gilt, Spur-Links führen hinein). Die Kennzahl „Bot-Leads“ in der Kopfzeile ist Text und bleibt es (sie führt auch ab Stufe 6 nirgends hin).
Unterhalb von Stufe 6 (`botleadsAbrufbar` false) gibt es `GET /botleads` im Dienst noch nicht. Darum:
- `BotLeadList` (Prop `stufe`, Standard `HUSTLER_STUFE`, von `WorkersPage` durchgereicht) ruft `api.botleads` gar nicht erst auf: kein JWT, keine Anfrage, keine Ladeanzeige, kein Fehlerzustand, kein „Erneut versuchen“.
- Der Keller zeigt seine normale Szene (Arne nach seinem Status, Regale, Register-Buch, Raum-Reiter, Suche) im Leer-Zustand mit ehrlichem Text (`RegisterBuch`, Prop `ohneDienst`):
**„Noch keine Bot-Leads.“** und darunter „Hier legt Arne Betriebe ohne Website ab, die Bea nicht für eine Mail ausgewählt hat. Die Branchen-Räume (Ärzte, Handwerk, Selbstständige, Läden) suchen noch nicht.“
Dieser Text gilt auch mit gewähltem Raum, getippter Suche und in der Einzelansicht (`&bot=`); „Keine Treffer.“ und die Zahl am Reiter gibt es ohne Dienst nicht.
- Reiter, Suche, „Register zuklappen“ und „Alle anzeigen“ bleiben bedienbar und ändern nur die Anzeige; sie lösen nie einen Abruf aus und nehmen keinen Fokus weg.
- `workersApi.botleads` bleibt unterhalb von Stufe 6 zusätzlich hart gesperrt (zweite Sicherung, falls ein Aufrufer die Stufe nicht prüft): kein JWT, keine Anfrage, Fehler 404 „Die Bot-Leads sind noch nicht freigeschaltet.“ (`BOTLEADS_GESPERRT_TEXT`).
Ab Stufe 6 gilt das Verhalten mit Live-Daten unverändert (Abruf, Suche mit Verzögerung, Seiten, Akte, Leer-Text „Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund.“).
Es gibt keine Überschreibung zur Laufzeit (kein localStorage, keine Adresse, kein Schalter in der Oberfläche).
@@ -36,19 +62,19 @@ Es gibt keine Überschreibung zur Laufzeit (kein localStorage, keine Adresse, ke
1. Den Dienst-Stand live nehmen und prüfen, dass die Endpunkte antworten (P4: ein Pult-Eintrag lässt sich einfügen/beantworten; P6: `GET /api/workers/botleads` antwortet 200).
2. In `src/lib/hustlerStufe.js` die `3` in der Zeile `export const HUSTLER_STUFE = stufeAus(buildWert(), 3)` ändern: auf `4` beim Livegang von P4, auf `6` beim Livegang von P6
(die Kommentarzeile darüber nennt die Stufen).
(die Kommentarzeile darüber nennt die Stufen). Der Keller selbst ist schon mit `3` offen; die `6` schaltet nur seinen Datenabruf ein.
3. Tests und Bau wie gewohnt: `node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs` und `vite build`.
Kein Test hängt an der `3`: `hustlerStufe.test.mjs` liest die Stufe aus dem Quelltext, alle Stufen-Tests geben ihre Stufe (3 und 6) ausdrücklich an. Bricht nach dem Hochsetzen
trotzdem ein Test, hat er die Stufe stillschweigend aus dem Standard gelesen und gehört auf eine ausdrückliche Stufe umgestellt.
4. Committen, pushen, deployen wie jede andere Änderung. Danach im Browser prüfen: P4 zeigt im Brief „Antwort des Kunden einfügen“ bzw. „Als Mensch antworten“; P6 zeigt die Keller-Etage mit „Keller · Bot-Leads ▸“.
4. Committen, pushen, deployen wie jede andere Änderung. Danach im Browser prüfen: P4 zeigt im Brief „Antwort des Kunden einfügen“ bzw. „Als Mensch antworten“; P6 zeigt im Keller das Register mit den Bot-Leads (statt „Noch keine Bot-Leads. Hier legt Arne …“). Die Keller-Etage „Keller · Bot-Leads ▸“ ist schon ab `3` ein Link.
## Prüfstand (`scripts/workers-harness`)
Der Prüfstand braucht P4 und Keller für seine Szenarien K und L. `build.mjs` baut deshalb standardmäßig mit `VITE_HUSTLER_STUFE=6`:
Der Prüfstand braucht P4 und die Bot-Leads (Live-Daten im Keller) für seine Szenarien K und L. `build.mjs` baut deshalb standardmäßig mit `VITE_HUSTLER_STUFE=6`:
```
node scripts/workers-harness/build.mjs "$(mktemp -d)" # Stufe 6, wie bisher (alle Szenarien)
VITE_HUSTLER_STUFE=3 node scripts/workers-harness/build.mjs "$(mktemp -d)" # Stand der Produktion (Szenarien ohne P4 und Keller)
VITE_HUSTLER_STUFE=3 node scripts/workers-harness/build.mjs "$(mktemp -d)" # Stand der Produktion (Szenarien ohne P4; der Keller zeigt sein leeres Register)
```
`VITE_HUSTLER_STUFE` gilt nur zur Bauzeit und nur als Ziffer 1 bis 9; alles andere fällt auf die Stufe aus dem Quelltext zurück (Vite) bzw. bricht den Bau des Prüfstands ab.
+3 -3
View File
@@ -5,9 +5,9 @@
// Drei Module der App hängen an Appwrite (Anmeldung, Mitarbeiterliste, Sitzungs-JWT) und werden nur im Prüfstand ausgetauscht, damit die
// echte Seite WorkersPage (Bildschirme Hochhaus, Pult und Keller samt Brief, Akte, ProspectPanel und Einstellungen) ohne Netz läuft: AuthContext -> stub-auth.js,
// useEmployees -> stub-employees.js, workersClient -> stub-client.js. Am Quelltext der App ändert das nichts.
// Stufen-Sperre (src/lib/hustlerStufe.js): die App zeigt P4-Aktionen im Pult erst ab Stufe 4 und den Keller (Bot-Leads) erst ab Stufe 6. Die Szenarien des Prüfstands
// (K pult-p4, L keller, die Keller-Etage im Haus) brauchen beides, deshalb baut dieses Skript standardmäßig mit VITE_HUSTLER_STUFE=6. Eine andere Stufe 1..9 kommt aus der
// Umgebung (VITE_HUSTLER_STUFE=3 node scripts/workers-harness/build.mjs <outDir> baut den Stand der Produktion); ein ungültiger Wert bricht ab.
// Stufen-Sperre (src/lib/hustlerStufe.js): die App zeigt P4-Aktionen im Pult erst ab Stufe 4 und die Bot-Leads im Keller (Abruf von /botleads) erst ab Stufe 6; der Keller selbst ist
// schon ab Stufe 3 begehbar, dann leer. Die Szenarien des Prüfstands (K pult-p4, L keller mit Live-Daten, die Keller-Etage im Haus) brauchen P4 und die Bot-Leads, deshalb baut dieses Skript standardmäßig mit VITE_HUSTLER_STUFE=6. Eine andere Stufe 1..9 kommt aus der
// Umgebung (VITE_HUSTLER_STUFE=3 node scripts/workers-harness/build.mjs <outDir> baut den Stand der Produktion, Keller leer); ein ungültiger Wert bricht ab.
import esbuild from 'esbuild'
import fs from 'node:fs'
import os from 'node:os'
+1 -1
View File
@@ -3,7 +3,7 @@
// flock /run/lock/hustler-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 timeout 600 \
// node --max-old-space-size=600 scripts/workers-harness/shot.cjs <distDir> <outDir>
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
// Stufen-Sperre (src/lib/hustlerStufe.js): build.mjs baut standardmäßig mit VITE_HUSTLER_STUFE=6 (P4-Aktionen im Pult und Keller offen); alle Szenarien unten setzen das voraus.
// Stufen-Sperre (src/lib/hustlerStufe.js): build.mjs baut standardmäßig mit VITE_HUSTLER_STUFE=6 (P4-Aktionen im Pult offen, Keller mit Live-Daten); alle Szenarien unten setzen das voraus. Der Keller bei Stufe 3 (leeres Register) ist in botleadList.test.mjs geprüft.
// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1.
// Szenarien (Name für HARNESS_ONLY, Vorsilbe der Bilder):
// A hochhaus (a-) Hochhaus am Desktop 1280 x 720 und 1920 x 1080: keine Fehler, nichts läuft über den Rand, 19 Figuren mit aria-label, Kim/Pia/Finn grau, Kopfzeile, Briefkasten
+13 -5
View File
@@ -4,6 +4,7 @@ import KellerSzene from './keller/KellerSzene'
import RegisterBuch from './keller/RegisterBuch'
import { pageInfo } from '../../lib/botleadsView'
import { eintragModell } from '../../lib/hustlerKeller'
import { HUSTLER_STUFE, botleadsAbrufbar } from '../../lib/hustlerStufe'
import { formatCount } from '../../lib/workersView'
import { restoreFocus } from '../../lib/workersFocus'
@@ -14,15 +15,20 @@ const browserDocument = () => (typeof document === 'undefined' ? null : document
// Bildschirm „Keller“ (Hustler-Pixelwelt T10, Spec E12/E13; Dateiname bleibt BotLeadList): die Archiv-Szene mit Arne (keller/KellerSzene), davor Arnes Register-Buch
// (keller/RegisterBuch) mit den Betrieben, die nicht angeschrieben werden, samt Grund, und die Akte zu einem Eintrag (keller/Akte). Reine Liste, nichts wird gesendet
// oder angerufen. Props: api, bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status), paused (Hustler angehalten:
// die Szene steht still, Klasse hk-steht), onBack (◂ Hochhaus). Beim Einhängen nimmt die Überschrift „Bot-Leads“ (tabIndex -1) den Fokus, ohne dass die Seite springt (T13 M2).
// oder angerufen. Props: api, stufe (Standard HUSTLER_STUFE), bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status),
// paused (Hustler angehalten: die Szene steht still, Klasse hk-steht), onBack (◂ Hochhaus). Beim Einhängen nimmt die Überschrift „Bot-Leads“ (tabIndex -1) den Fokus,
// ohne dass die Seite springt (T13 M2).
// Stufen-Sperre (hustlerStufe.js): unterhalb von Stufe 6 (BOTLEADS_AB) gibt es GET /botleads im Dienst noch nicht. Der Keller steht dann trotzdem da (Arne, Regale, Register-Buch,
// Reiter, Suche), das Register ist leer und sagt ehrlich warum (RegisterBuch, ohneDienst); es gibt keinen Abruf (auch kein JWT), keine Ladeanzeige, keinen Fehler. Reiter und Suche
// bleiben bedienbar und ändern nur die Anzeige (es gibt nichts zu filtern); die Bedienung entspricht sonst dem Keller mit Daten, also ohne Fokusfalle.
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“,
// das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin.
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten), und zwar
// nur in der Akte. Kein CSS-Import (WorkersPage hängt die Stile ein), keine Bilder, kein HTML-Einschub.
export default function BotLeadList({ api, bot, onClearBot, kpis, workers, paused = false, onBack }) {
export default function BotLeadList({ api, stufe = HUSTLER_STUFE, bot, onClearBot, kpis, workers, paused = false, onBack }) {
const abruf = botleadsAbrufbar(stufe)
const botId = typeof bot === 'string' ? bot : ''
const single = botId !== ''
const [view, setView] = useState(START_VIEW)
@@ -45,6 +51,7 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, pause
const key = JSON.stringify(single ? [botId, attempt] : [view.room, view.q, view.page, attempt])
useEffect(() => {
if (!abruf) return undefined // Stufe unter 6: kein Abruf, das Register bleibt leer
seqRef.current += 1
const mine = seqRef.current
// Ein früheres Ergebnis zu genau diesem Schlüssel (hin und zurück gewechselt, bevor der andere Schlüssel antwortete) ist veraltet: erst die neue Antwort zählt
@@ -70,7 +77,7 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, pause
}
load()
return () => { seqRef.current += 1 } // eine noch offene Anfrage zählt nicht mehr
}, [api, key])
}, [api, key, abruf])
useEffect(() => () => window.clearTimeout(timerRef.current), [])
@@ -101,7 +108,7 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, pause
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
}
const loading = result.key !== key
const loading = abruf && result.key !== key // ohne Abruf gibt es nichts zu laden
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raum-Reiter ersetzt den gedrückten Knopf und bekommt den Fokus.
// Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist.
@@ -159,6 +166,7 @@ export default function BotLeadList({ api, bot, onClearBot, kpis, workers, pause
draft={draft}
filtered={filtered}
laden={loading}
ohneDienst={!abruf}
fehler={result.error}
zeilen={zeilen}
total={result.total}
+1 -1
View File
@@ -18,7 +18,7 @@ function Ueberschrift() {
// (regieRef, die Seite ruft regieRef.current.push(events) bei neuen Ereignissen), der Bildschirmwechsel (onOpen(tab)) und vonTab (der Bildschirm, von dem man
// zurückkommt: 'freigabe' | 'botleads' | null) kommen von der Seite. Beim Zurückkommen bekommt die Etage, über die man gegangen war, den Fokus (M2).
// Die Kopfzeile steht im DOM vor der Welt, damit die Tab-Reihenfolge oben beginnt (M7); über der Welt liegt sie per z-index.
// stufe (Standard HUSTLER_STUFE, hustlerStufe.js) geht an die Welt (Keller-Etage als Link nur ab Stufe 6) und an die Detailansicht (Spur-Links in den Keller nur ab Stufe 6).
// stufe (Standard HUSTLER_STUFE, hustlerStufe.js) geht an die Welt (Keller-Etage als Link nur ab Stufe 3) und an die Detailansicht (Spur-Links in den Keller nur ab Stufe 3).
// Kein CSS-Import und keine Bilder: die Stile hängt WorkersPage ein (Regeln dieser Datei in styles/hustler/hustler-ui.css).
export default function Hochhaus({ api, isAdmin, feed, workers, regieRef, onOpen, vonTab = null, stufe = HUSTLER_STUFE }) {
const wurzelRef = useRef(null)
@@ -2,7 +2,7 @@
// Himmel, Funkeln, Mond, die 2400 x 1200 große Bühne (per transform verschoben und gezoomt) mit Welt-, Licht-, Haus- und Fensterebenen, Neon, Antenne, Aufzug,
// Raumschilder, Bildschirme, Radar, Briefkasten-Zähler, Tresor-/Keller-Text, den Etagen (Knöpfe, Freigabe und Keller sind Links) und den 19 Hustlern; davor der Schnee.
// Alles hängt am Dienst-Zustand: Figur und Zusätze nach Status (figurZustand), graue Raum-Ebene samt Papierschild nur für nicht angeschlossene Such-Räume.
// Stufen-Sperre (hustlerStufe.js): unterhalb der Keller-Stufe (6) ist die Keller-Etage kein Link auf die Bot-Leads, sondern ein Knopf wie jede andere Etage (heranzoomen).
// Stufen-Sperre (hustlerStufe.js): unterhalb der Stufe, ab der der Keller begehbar ist (3), ist die Keller-Etage kein Link auf den Keller, sondern ein Knopf wie jede andere Etage (heranzoomen).
// Die Kamera (Rad, Ziehen, Pinch, Tasten) steckt in useKamera. `children` (die Regie-Ebene) liegt in der Bühne über den Figuren, in Weltkoordinaten.
// Bedienung: Tastaturfokus (:focus-visible) auf Etage oder Figur außerhalb des Bildes holt sie per Kamera herein (beiFokus, kein Schwenk bei Mausklick); das Sichtfenster ist
// per tabindex -1 fokussierbar (ein Klick in die Welt gibt ihm den Fokus, damit + - 0 Esc wirken); die Tippflächen der Figuren wachsen mit dem Zoom (--hk-tipp-*, tippRand);
@@ -10,7 +10,7 @@
// Kein CSS-Import (die Stile hängt WorkersPage ein: styles/hustler/hochhaus.css, hustler-ui.css, hustler-welt.css), keine Bilder, kein innerHTML.
import { forwardRef, useImperativeHandle } from 'react'
import { anzeigeName, figurZustand, schilder, tippRand } from '../../../lib/hustlerSicht.js'
import { HUSTLER_STUFE, kellerOffen } from '../../../lib/hustlerStufe.js'
import { HUSTLER_STUFE, kellerBegehbar } from '../../../lib/hustlerStufe.js'
import { AUFZUG, ETAGEN, ETAGEN_SPALTE, HAUS_BILD, HUSTLER, RAEUME, SCHILDER } from '../../../lib/hustlerWelt.js'
import { FLOOR_TITLES, ROLES, ROOM_TITLES } from '../../../lib/workersRoster.js'
import useKamera from './useKamera.js'
@@ -21,9 +21,9 @@ const LINK_ETAGEN = Object.freeze({
keller: Object.freeze({ tab: 'botleads', text: 'Keller · Bot-Leads ▸', label: 'Keller · Bot-Leads: Bot-Leads öffnen' }),
})
// Welche Etagen Links sind: der Keller nur, wenn es die Bot-Leads schon gibt; sonst zoomt er heran wie jede andere Etage
// Welche Etagen Links sind: der Keller nur, wenn er begehbar ist (ab Stufe 3, auch wenn sein Register noch leer ist); sonst zoomt er heran wie jede andere Etage
const OHNE_KELLER = Object.freeze({ freigabe: LINK_ETAGEN.freigabe })
const linkEtagen = (stufe) => (kellerOffen(stufe) ? LINK_ETAGEN : OHNE_KELLER)
const linkEtagen = (stufe) => (kellerBegehbar(stufe) ? LINK_ETAGEN : OHNE_KELLER)
const HAUS_RECHTECK = Object.freeze({ left: HAUS_BILD.x, top: HAUS_BILD.y, width: HAUS_BILD.w, height: HAUS_BILD.h })
@@ -220,7 +220,7 @@ const HochhausWelt = forwardRef(function HochhausWelt({ state, workers, onZeige,
</span>
) : null}
{/* Etagen: Knöpfe zoomen heran, Freigabe (und ab der Keller-Stufe der Keller) sind Links auf die anderen Bildschirme */}
{/* Etagen: Knöpfe zoomen heran, Freigabe (und ab der Stufe, ab der der Keller begehbar ist, der Keller) sind Links auf die anderen Bildschirme */}
{ETAGEN.map((e) => {
const stil = { left: ETAGEN_SPALTE.x, top: e.oben, width: ETAGEN_SPALTE.w, height: e.unten - e.oben }
const link = links[e.id]
@@ -27,7 +27,7 @@ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// Einziger Ort des Dialogs, an dem ein Ziel für einen Link entsteht (wie TraceText in WorkerCard): nur was safeLink durchlässt, wird ein Link
// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text. Stufen-Sperre: ein interner Link in den Keller (?tab=botleads) bleibt
// unterhalb der Keller-Stufe (hustlerStufe.js) Text.
// unterhalb der Stufe, ab der der Keller begehbar ist (hustlerStufe.js, Stufe 3), Text.
function SpurText({ link, stufe, children }) {
const safe = isRecord(link) ? safeLink(link.href) : null
if (!safe || !linkErlaubt(safe.href, stufe)) return <>{children}</>
+16 -7
View File
@@ -1,12 +1,18 @@
// Das Register-Buch des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): Raum-Reiter, das aufgeschlagene Buch mit Titel, Hinweis, Suchfeld (oder, bei einem einzelnen
// Eintrag, „Alle anzeigen“), Kopfzeile, den Einträgen der Dienst-Seite (im Buch scrollbar), Leer-, Lade- und Fehlerzustand und der Seitenzeile mit ◂ ▸.
// Aufbau und Klassen wie keller/entwurf/BotLeads.dc.html mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-keller-ui.css.
// ohneDienst (Stufe unter 6, der Dienst kennt GET /botleads noch nicht): das Register ist leer und sagt ehrlich warum; weder Fehler noch Ladeanzeige noch Zahl am Reiter.
// Rein darstellend: Abruf, Suche, Seiten und Fokus gehören BotLeadList. Alle Texte der Zeilen sind Textknoten; Links gibt es hier nicht (der Link zur Quelle steht in der
// Akte und kommt nur aus row.sourceHref). Kein CSS-Import, keine Bilder, kein HTML-Einschub.
import { REITER } from '../../../lib/hustlerKeller'
import { formatCount } from '../../../lib/workersView'
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
// Leer-Text, solange der Dienst keine Bot-Leads liefert (Stufe unter 6): Bot-Leads entstehen erst, wenn die Branchen-Räume (P6) suchen
const LEER_OHNE_DIENST = [
'Noch keine Bot-Leads.',
'Hier legt Arne Betriebe ohne Website ab, die Bea nicht für eine Mail ausgewählt hat. Die Branchen-Räume (Ärzte, Handwerk, Selbstständige, Läden) suchen noch nicht.',
]
// Eine Zeile des Buchs: ein Knopf, der die Akte öffnet. Spalten: Raum-Symbol, Name (+ „gekürzt“) mit Ort und Funddatum, Punkte, Grund mit Symbol, Telefon.
function Zeile({ z, nr, aktuell, onOpen }) {
@@ -42,11 +48,12 @@ function Zeile({ z, nr, aktuell, onOpen }) {
// Props:
// einzeln (Einzelansicht über ?bot=), botId, room (gewählter Raum-Schlüssel), draft (Inhalt des Suchfelds), filtered (Raum oder Suche gesetzt)
// ohneDienst (der Dienst liefert noch keine Bot-Leads: ehrlicher Leer-Text)
// laden, fehler (Satz des Dienstes oder null), zeilen (eintragModell je Eintrag der Seite), total, info (pageInfo)
// onRoom(value), onSearch(ev), onClear(), onPage('prev' | 'next'), onRetry(), onOpen(nr, ev)
// reiterRef (am aktiven Reiter), zurueckRef, weiterRef (Blätterknöpfe): Fokus-Ziele von BotLeadList
export default function RegisterBuch({ einzeln, botId, room, draft, filtered, laden, fehler, zeilen, total, info, onRoom, onSearch, onClear, onPage, onRetry, onOpen, reiterRef, zurueckRef, weiterRef }) {
const zahl = laden || fehler ? null : total // die Zahl steht nur am aktiven Reiter und erst, wenn die Antwort da ist
export default function RegisterBuch({ einzeln, botId, room, draft, filtered, ohneDienst = false, laden, fehler, zeilen, total, info, onRoom, onSearch, onClear, onPage, onRetry, onOpen, reiterRef, zurueckRef, weiterRef }) {
const zahl = laden || fehler || ohneDienst ? null : total // die Zahl steht nur am aktiven Reiter und erst, wenn die Antwort da ist (ohne Dienst gibt es keine)
let koerper
if (laden) {
@@ -59,11 +66,13 @@ export default function RegisterBuch({ einzeln, botId, room, draft, filtered, la
</div>
)
} else if (zeilen.length === 0) {
const leer = einzeln
? ['Dieser Eintrag ist nicht (mehr) in der Liste.', 'Er wurde gekürzt oder ist kein Bot-Lead.']
: filtered
? ['Keine Treffer.', 'Für diesen Raum und diese Suche gibt es keine Bot-Leads.']
: ['Noch keine Bot-Leads.', 'Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund.']
const leer = ohneDienst
? LEER_OHNE_DIENST
: einzeln
? ['Dieser Eintrag ist nicht (mehr) in der Liste.', 'Er wurde gekürzt oder ist kein Bot-Lead.']
: filtered
? ['Keine Treffer.', 'Für diesen Raum und diese Suche gibt es keine Bot-Leads.']
: ['Noch keine Bot-Leads.', 'Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund.']
koerper = (
<div className="hk-kl-leer">
<p className="hk-kl-leer-titel">{leer[0]}</p>
+18 -11
View File
@@ -1,14 +1,18 @@
// Stufen-Sperre der Hustler-Seite: EINE Quelle der Wahrheit dafür, was die Oberfläche schon zeigen darf. Der Dienst (workers-dienst) wächst Stufe für Stufe
// (P1, P3, P4, P6); die Oberfläche ist fertig und würde sonst Knöpfe und Bildschirme zeigen, deren Endpunkte noch 404 liefern.
// Stufe < 4: im Pult keine Antwort-Knöpfe (paste „Antwort des Kunden einfügen“, reply „Als Mensch antworten“, approve-preview, start-build).
// Stufe < 6: kein Keller mit den Bot-Leads (kein Bildschirm, kein Link, kein Abruf von /api/workers/botleads); die Keller-Etage zoomt wie jede andere.
// Stufe < 3: kein Keller (keine Etage als Link, kein Bildschirm, keine Spur-Links in den Keller); die Keller-Etage zoomt wie jede andere.
// Stufe >= 3: der Keller ist begehbar (Keller-Etage, ?tab=botleads, Spur-Links): Arne, Regale und Register-Buch stehen da, das Register aber leer, ehrlich beschriftet.
// Stufe < 6: der Keller ruft die Bot-Leads nicht ab (kein GET /api/workers/botleads, auch kein JWT); der Endpunkt gibt es im Dienst erst mit P6.
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Es gibt keine Überschreibung zur Laufzeit (kein localStorage, keine Adresse): die Stufe ist
// zur Bauzeit festgelegt. Nur der Prüfstand (scripts/workers-harness) baut mit VITE_HUSTLER_STUFE=6, damit seine P4- und Keller-Szenarien weiter laufen.
// zur Bauzeit festgelegt. Nur der Prüfstand (scripts/workers-harness) baut mit VITE_HUSTLER_STUFE=6, damit seine P4- und Keller-Szenarien (mit Live-Daten) weiter laufen.
/** Ab dieser Stufe erscheinen die Antwort-Knöpfe im Pult (Dienst P4: paste, reply, approve-preview, start-build). */
export const STUFE_ANTWORTEN = 4
/** Ab dieser Stufe erscheint der Keller mit den Bot-Leads (Dienst P6: GET /botleads). */
export const STUFE_KELLER = 6
/** Ab dieser Stufe ist der Keller begehbar: Keller-Etage als Link, Bildschirm ?tab=botleads, Spur-Links. Schon mit P3; ohne P6 bleibt das Register leer. */
export const KELLER_BEGEHBAR_AB = 3
/** Ab dieser Stufe ruft der Keller die Bot-Leads ab (Dienst P6: GET /botleads). Darunter keine Anfrage, auch kein JWT. */
export const BOTLEADS_AB = 6
const KLEINSTE = 1
const GROESSTE = 9
@@ -33,7 +37,7 @@ function buildWert() {
}
}
// 3 = Dienst P3 live; ab 4 (P4) erscheinen die Antwort-Knöpfe im Pult, ab 6 (P6) der Keller mit den Bot-Leads; beim jeweiligen Livegang hier hochsetzen
// 3 = Dienst P3 live (der Keller ist begehbar, aber leer); ab 4 (P4) erscheinen die Antwort-Knöpfe im Pult, ab 6 (P6) liefert der Keller die Bot-Leads; beim jeweiligen Livegang hier hochsetzen
export const HUSTLER_STUFE = stufeAus(buildWert(), 3)
// Eine übergebene Stufe (Parameter der Funktionen und Bausteine, vor allem für Tests); fehlt sie oder ist sie unbrauchbar, gilt HUSTLER_STUFE
@@ -42,21 +46,24 @@ const stufeOder = (stufe) => stufeAus(stufe, HUSTLER_STUFE)
/** Zeigt das Pult die Antwort-Knöpfe (paste, reply, approve-preview, start-build)? Ab Stufe 4. */
export const antwortenOffen = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= STUFE_ANTWORTEN
/** Gibt es den Keller (Bildschirm Bot-Leads, Abruf von /botleads)? Ab Stufe 6. */
export const kellerOffen = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= STUFE_KELLER
/** Ist der Keller begehbar (Keller-Etage als Link, Bildschirm Bot-Leads, Spur-Links)? Ab Stufe 3. Ob er Daten hat, sagt botleadsAbrufbar. */
export const kellerBegehbar = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= KELLER_BEGEHBAR_AB
/** Darf dieser Bildschirm (Wert von ?tab=) auf der Stufe gezeigt werden? Nur der Keller ('botleads') ist gesperrt, alles andere ist frei. */
export const tabErlaubt = (tabId, stufe = HUSTLER_STUFE) => tabId !== 'botleads' || kellerOffen(stufe)
/** Darf der Keller die Bot-Leads abrufen (GET /botleads, Dienst P6)? Ab Stufe 6. Darunter zeigt er sein leeres Register und fragt nichts an. */
export const botleadsAbrufbar = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= BOTLEADS_AB
/** Darf dieser Bildschirm (Wert von ?tab=) auf der Stufe gezeigt werden? Nur der Keller ('botleads') ist an die Stufe gebunden (ab 3), alles andere ist frei. */
export const tabErlaubt = (tabId, stufe = HUSTLER_STUFE) => tabId !== 'botleads' || kellerBegehbar(stufe)
// Adressen, unter denen die Hustler-Seite liegt: /hustler und die alten /workers und /leads (App.jsx leitet sie weiter, die Query bleibt)
const SEITEN_PFADE = ['/hustler', '/workers', '/leads']
/**
* Darf ein interner Link (aus der Spur im Dialog) auf der Stufe ein Link sein? Ein Link in den Keller (…/hustler?tab=botleads, auch über /workers und /leads)
* ist unterhalb der Keller-Stufe nur Text; alles andere bleibt ein Link. Externe Adressen berührt das nicht.
* ist unterhalb der Stufe, ab der der Keller begehbar ist (3), nur Text; alles andere bleibt ein Link. Externe Adressen berührt das nicht.
*/
export function linkErlaubt(href, stufe = HUSTLER_STUFE) {
if (kellerOffen(stufe)) return true
if (kellerBegehbar(stufe)) return true
if (typeof href !== 'string' || !/^\/(?![/\\])/.test(href)) return true // nur interne Pfade (ein einzelner / am Anfang), nie //host oder eine ganze Adresse
let ziel
try {
+5 -5
View File
@@ -2,15 +2,15 @@
// Rein: kein Appwrite-Import, getJwt und fetch werden injiziert (workersClient.js verdrahtet sie), damit die
// deutschen Fehlertexte unter Node testbar sind. Fehler sind Error mit deutscher `message` und `status`.
import { HUSTLER_STUFE, kellerOffen } from './hustlerStufe'
import { HUSTLER_STUFE, botleadsAbrufbar } from './hustlerStufe'
export const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
export const BAD_REPLY_TEXT = 'Der Hustler-Dienst antwortet nicht richtig.'
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.'
export const INVALID_ID_TEXT = 'Ungültige Kennung.'
// Der Keller (GET /botleads) gehört zu Stufe 6 (hustlerStufe.js); darunter gibt es den Endpunkt im Dienst noch nicht
export const KELLER_GESPERRT_TEXT = 'Der Keller ist noch nicht freigeschaltet.'
// Die Bot-Leads (GET /botleads) gehören zu Stufe 6 (hustlerStufe.js, BOTLEADS_AB); darunter gibt es den Endpunkt im Dienst noch nicht
export const BOTLEADS_GESPERRT_TEXT = 'Die Bot-Leads sind noch nicht freigeschaltet.'
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
@@ -198,9 +198,9 @@ export function createWorkersApi({
if (!PULT_ACTIONS.includes(action)) throw statusError(`Unbekannte Aktion: ${action}`, 400)
return request(`/pult/${pathId(id)}/${action}`, { method: 'POST', body, limitMs: SLOW_PULT_ACTIONS.includes(action) ? actionTimeoutMs : undefined })
},
// Unterhalb der Keller-Stufe (6) geht gar keine Anfrage hinaus: der Endpunkt fehlt im Dienst noch (die Oberfläche zeigt den Keller dort auch nicht)
// Unterhalb der Stufe 6 (BOTLEADS_AB) geht gar keine Anfrage hinaus: der Endpunkt fehlt im Dienst noch (der Keller ruft ihn dort auch nicht auf, er zeigt sein leeres Register)
botleads: async ({ room, q, page, id } = {}) => {
if (!kellerOffen(stufe)) throw statusError(KELLER_GESPERRT_TEXT, 404)
if (!botleadsAbrufbar(stufe)) throw statusError(BOTLEADS_GESPERRT_TEXT, 404)
return request(`/botleads${query([['room', room], ['q', q], ['page', page], ['id', id]])}`)
},
prospect: async (id) => request(`/prospect/${pathId(id)}`),
+4 -2
View File
@@ -24,8 +24,9 @@ import '../styles/hustler/hustler-regie.css'
// Bildschirme der Seite: das Hochhaus, das Pult (Freigabe von P3) und der Keller (Bot-Leads von P6). Eine Reiterleiste gibt es nicht mehr;
// gewechselt wird im Haus (Etage Freigabe, Etage Keller) und über „◂ Hochhaus“ in Pult und Keller.
// Stufen-Sperre (lib/hustlerStufe.js, HUSTLER_STUFE): der Keller ist erst ab Stufe 6 ein Bildschirm. Darunter fallen ?tab=botleads (auch mit &bot=) und jeder
// andere unbekannte Wert auf das Hochhaus zurück (pickTab nimmt den ersten Reiter), BotLeadList wird nie eingehängt und /api/workers/botleads nie abgerufen.
// Stufen-Sperre (lib/hustlerStufe.js, HUSTLER_STUFE): der Keller ist ab Stufe 3 ein Bildschirm (begehbar), die Bot-Leads ruft er aber erst ab Stufe 6 ab; darunter zeigt
// BotLeadList sein leeres Register und fragt /api/workers/botleads nie an. Unterhalb von Stufe 3 fallen ?tab=botleads (auch mit &bot=) und jeder andere unbekannte Wert
// auf das Hochhaus zurück (pickTab nimmt den ersten Reiter), BotLeadList wird dann nie eingehängt.
const WORKERS_TABS = [
{ id: 'hochhaus', label: 'Hochhaus' },
{ id: 'freigabe', label: 'Freigabe' },
@@ -104,6 +105,7 @@ export default function WorkersPage({ stufe = HUSTLER_STUFE }) {
{active.id === 'botleads' && (
<BotLeadList
api={api}
stufe={stufe}
bot={bot}
onClearBot={() => setParams({ tab: 'botleads' })}
kpis={state?.kpis}
+145
View File
@@ -555,3 +555,148 @@ test('aria-current (C5): nur die Zeile mit der Kennung aus bot, nie eine Zeile o
assert.deepEqual(one.rows().map((r) => r.props['aria-current']), [undefined, 'true', undefined])
} finally { one.end() }
})
// ---------- Stufen-Sperre (hustlerStufe.js): unterhalb von Stufe 6 ist der Keller begehbar, aber leer und ohne Abruf ----------
// Der Läufer baut sonst mit stufe: 6 (Live-Daten, alle Tests oben). Hier ausdrücklich Stufe 3 (Produktion: Dienst P3, GET /botleads gibt es noch nicht).
const OHNE_DIENST_TITEL = 'Noch keine Bot-Leads.'
const OHNE_DIENST_TEXT = 'Hier legt Arne Betriebe ohne Website ab, die Bea nicht für eine Mail ausgewählt hat. Die Branchen-Räume (Ärzte, Handwerk, Selbstständige, Läden) suchen noch nicht.'
test('Stufe 3: der Keller steht mit Szene, Register-Buch, Reitern und Suche da, zeigt den ehrlichen Leer-Text, fragt nichts ab und zeigt weder Laden noch Fehler', async () => {
for (const stufe of [3, 4, 5]) {
const api = fakeApi()
const ui = await start(api, { stufe })
try {
assert.equal(api.calls.length, 0, `Stufe ${stufe}: kein Abruf`)
assert.equal(byClass(ui.tree(), 'hk-kl-szene').length, 1, 'Szene mit Arne')
assert.equal(textOf(byClass(ui.tree(), 'hk-kl-titel')[0]), 'Bot-Leads')
assert.equal(textOf(byClass(ui.tree(), 'hk-kl-buchtitel')[0]), 'Nie-doppelt-Register')
assert.equal(ui.reiter().length, ROOM_VALUES.length); assert.equal(ui.activeIndex(), 0)
assert.ok(ui.search(), 'Suchfeld')
const leer = byClass(ui.tree(), 'hk-kl-leer')
assert.equal(leer.length, 1)
assert.equal(textOf(byClass(leer[0], 'hk-kl-leer-titel')[0]), OHNE_DIENST_TITEL)
assert.equal(textOf(byClass(leer[0], 'hk-kl-leer-text')[0]), OHNE_DIENST_TEXT)
// kein Fehlerzustand, keine Ladeanzeige, kein „Erneut versuchen“, keine Zeilen, keine Kopfzeile, keine Seitenzeile, keine Zahl am Reiter
assert.ok(!ui.text().includes('Wird geladen')); assert.equal(byClass(ui.tree(), 'hk-kl-laden').length, 0)
assert.equal(ui.errors().length, 0); assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
assert.equal(nodes(ui.tree(), (n) => n.props && (n.props.role === 'alert' || n.props.role === 'status')).length, 0)
assert.equal(ui.rows().length, 0); assert.equal(byClass(ui.tree(), 'hk-kopfzeile').length, 0); assert.equal(byClass(ui.tree(), 'hk-kl-fuss').length, 0)
assert.equal(byClass(ui.tree(), 'hk-kl-reiter-zahl').length, 0)
assert.ok(!ui.text().includes('Keine Treffer'))
} finally { ui.end() }
}
})
test('Stufe 3: ohne Stufen-Angabe gilt HUSTLER_STUFE (Produktion = 3): leeres Register, kein Abruf', async () => {
const { HUSTLER_STUFE, botleadsAbrufbar } = await loadModule('src/lib/hustlerStufe.js')
const api = fakeApi()
const ui = await start(api, { stufe: undefined })
try {
assert.equal(api.calls.length, botleadsAbrufbar(HUSTLER_STUFE) ? 1 : 0)
if (!botleadsAbrufbar(HUSTLER_STUFE)) assert.ok(ui.text().includes(OHNE_DIENST_TITEL) && ui.text().includes(OHNE_DIENST_TEXT))
} finally { ui.end() }
})
test('Stufe 3: Reiter und Suche bleiben bedienbar, ändern nur die Anzeige und lösen nie einen Abruf aus; die Leer-Auskunft bleibt dieselbe', async () => {
const api = fakeApi()
const dom = fakeFocus()
const ui = await start(api, { stufe: 3 })
dom.attach(ui)
try {
await ui.pick(idx('handwerk'))
assert.equal(ui.activeIndex(), idx('handwerk')); assert.equal(dom.focused.length, 0)
await ui.type('Elektro'); ui.fireTimers(); await settle()
assert.equal(ui.search().props.value, 'Elektro')
await ui.pick(idx('laden')); await ui.pick(idx(''))
assert.equal(ui.activeIndex(), 0)
assert.equal(api.calls.length, 0, 'weder Reiter noch Suche rufen etwas ab')
assert.equal(ui.timers.size, 0)
assert.ok(ui.text().includes(OHNE_DIENST_TITEL) && ui.text().includes(OHNE_DIENST_TEXT))
assert.ok(!ui.text().includes('Keine Treffer') && !ui.text().includes('Wird geladen'))
assert.equal(ui.errors().length, 0); assert.equal(byClass(ui.tree(), 'hk-kl-reiter-zahl').length, 0)
// keine Fokusfalle: nichts wird dem Nutzer weggenommen oder von der Komponente an sich gezogen (nur die Überschrift beim Einhängen, ohne Seitensprung)
assert.ok(dom.focused.every((k) => k !== 'reiter'))
} finally { dom.restore(); ui.end() }
})
test('Stufe 3: Register zuklappen und aufklappen funktionieren wie mit Daten; ◂ Hochhaus ruft onBack', async () => {
const api = fakeApi(); let zurueck = 0
const ui = await start(api, { stufe: 3, onBack: () => { zurueck += 1 } })
try {
await ui.click('Register zuklappen')
assert.equal(byClass(ui.tree(), 'hk-buch').length, 0); assert.equal(byClass(ui.tree(), 'hk-kl-leer').length, 0); assert.equal(byClass(ui.tree(), 'hk-kl-szene').length, 1)
await ui.click('Register aufklappen')
assert.equal(byClass(ui.tree(), 'hk-kl-leer').length, 1)
await ui.click('Zurück zum Hochhaus'); assert.equal(zurueck, 1)
assert.equal(api.calls.length, 0)
} finally { ui.end() }
})
test('Stufe 3: ?bot= (Einzelansicht) ruft nichts ab, zeigt dieselbe ehrliche Auskunft und lässt „Alle anzeigen“ zu', async () => {
const api = fakeApi(); let leer = 0
const ui = await start(api, { stufe: 3, bot: 'p1', onClearBot: () => { leer += 1 } })
try {
assert.equal(api.calls.length, 0)
assert.ok(ui.text().includes(OHNE_DIENST_TITEL) && ui.text().includes(OHNE_DIENST_TEXT))
assert.ok(!ui.text().includes('Wird geladen') && !ui.text().includes('nicht (mehr) in der Liste'))
assert.equal(ui.errors().length, 0); assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 0)
await ui.click('Alle anzeigen'); assert.equal(leer, 1)
ui.setProps({ bot: '' }); await settle()
assert.equal(api.calls.length, 0); assert.ok(ui.text().includes(OHNE_DIENST_TEXT))
} finally { ui.end() }
})
test('Stufe 3: die Zahl im Schild kommt weiter aus den Kennzahlen (kpis.botLeads); die Szene zeigt Arne nach seinem Status', async () => {
const api = fakeApi()
const arne = (status) => ({ arne: { status, task: '', lastTs: null, connected: true, counters: {} } })
const stand = (ui) => ({
attr: nodes(ui.tree(), (n) => n.props && n.props['data-hk-arne'] !== undefined).map((n) => n.props['data-hk-arne']),
weg: byClass(ui.tree(), 'hk-arne-weg').length, ruht: byClass(ui.tree(), 'hk-kl-ruht').length,
alarm: byClass(ui.tree(), 'hk-alarm').length, steht: byClass(ui.tree(), 'hk-steht').length,
})
const ui = await start(api, { stufe: 3, kpis: { botLeads: 0 }, workers: arne('arbeitet') })
try {
assert.equal(textOf(byClass(ui.tree(), 'hk-kl-zahl')[0]), '0')
// arbeitet: Arnes Ablauf-Schleife läuft, nichts ruht, kein Alarm
assert.deepEqual(stand(ui), { attr: ['arbeitet'], weg: 1, ruht: 0, alarm: 0, steht: 0 })
// leerlauf: Arne sitzt am Pult, die drei Dinge (Stempel, Rohrpost, Schublade) ruhen
ui.setProps({ workers: arne('leerlauf') }); await settle()
assert.deepEqual(stand(ui), { attr: ['leerlauf'], weg: 0, ruht: 3, alarm: 0, steht: 0 })
// fehler: roter Marker
ui.setProps({ workers: arne('fehler') }); await settle()
assert.deepEqual(stand(ui), { attr: ['fehler'], weg: 0, ruht: 3, alarm: 1, steht: 0 })
// Hustler angehalten: die ganze Szene steht still
ui.setProps({ paused: true }); await settle()
assert.equal(stand(ui).steht, 1)
assert.equal(api.calls.length, 0)
} finally { ui.end() }
})
test('Stufe 6 (und höher): Abruf wie bisher, der Live-Leer-Text bleibt der alte (nicht der der Stufe 3), Lade- und Fehlerzustand sind zurück', async () => {
for (const stufe of [6, 9]) {
const api = fakeApi()
const ui = await start(api, { stufe })
try {
assert.equal(api.calls.length, 1, `Stufe ${stufe}`); assert.ok(ui.text().includes('Wird geladen'))
await ui.answer(0, { items: [], total: 0 })
assert.ok(ui.text().includes('Noch keine Bot-Leads.')); assert.ok(ui.text().includes('Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund.'))
assert.ok(!ui.text().includes(OHNE_DIENST_TEXT)); assert.equal(byClass(ui.tree(), 'hk-kl-reiter-zahl').length, 1)
await ui.pick(idx('handwerk'))
assert.equal(api.calls.length, 2); await ui.fail(1, new Error('Der Dienst antwortet nicht.'))
assert.deepEqual(ui.errors().map((e) => e.text), ['Der Dienst antwortet nicht.']); assert.equal(buttons(ui.tree(), 'Erneut versuchen').length, 1)
} finally { ui.end() }
}
})
test('Ändert sich die Stufe von 3 auf 6 (im Betrieb nur über einen neuen Bau, hier als Prop): ab dann Abruf, das Register füllt sich', async () => {
const api = fakeApi()
const ui = await start(api, { stufe: 3 })
try {
assert.equal(api.calls.length, 0)
ui.setProps({ stufe: 6 }); await settle()
assert.equal(api.calls.length, 1); assert.ok(ui.text().includes('Wird geladen'))
await ui.answer(0, { items: [ITEM], total: 1 })
assert.equal(ui.rows().length, 1); assert.ok(!ui.text().includes(OHNE_DIENST_TEXT))
} finally { ui.end() }
})
+2 -1
View File
@@ -128,7 +128,8 @@ export async function start(api, initial = {}) {
setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
clearTimeout: (id) => { timers.delete(id) },
}
let props = { api, ...initial }
// stufe 6: Live-Daten (Stufen-Sperre, hustlerStufe.js: unterhalb von Stufe 6 ruft der Keller nichts ab und zeigt sein leeres Register); die Tests der Stufe 3 geben sie ausdrücklich an
let props = { api, stufe: 6, ...initial }
const mounted = __mount(jsx(() => jsx(BotLeadList, props), {}))
const view = {
timers,
+4 -4
View File
@@ -385,8 +385,8 @@ test('Spur-Links: unsichere (javascript:, //host, /\\host, data:) bleiben Text;
})
})
// Stufen-Sperre (hustlerStufe.js): unterhalb der Keller-Stufe (6) führt auch kein Spur-Link in den Keller; der Eintrag bleibt als Text stehen
test('Spur-Links in den Keller (?tab=botleads, auch über /workers und /leads) sind auf Stufe 3 Text und ab Stufe 6 Router-Links; alle anderen Links bleiben', async () => {
// Stufen-Sperre (hustlerStufe.js): unterhalb von Stufe 3 (KELLER_BEGEHBAR_AB) führt auch kein Spur-Link in den Keller; der Eintrag bleibt als Text stehen
test('Spur-Links in den Keller (?tab=botleads, auch über /workers und /leads) sind auf Stufe 1 und 2 Text und ab Stufe 3 Router-Links; alle anderen Links bleiben', async () => {
const events = [
{ id: '1', ts: '2026-10-22T09:00:00Z', text: 'Keller', link: '/hustler?tab=botleads&bot=abc123' },
{ id: '2', ts: '2026-10-22T08:59:00Z', text: 'Alt', link: '/workers?tab=botleads&bot=abc123' },
@@ -396,7 +396,7 @@ test('Spur-Links in den Keller (?tab=botleads, auch über /workers und /leads) s
{ id: '6', ts: '2026-10-22T08:55:00Z', text: 'Extern', link: 'https://example.org/hustler?tab=botleads' },
]
const ziele = (li) => nodes(li, (n) => n.tag === 'a' || n.tag === 'router-link')
for (const stufe of [1, 3, 5]) {
for (const stufe of [1, 2]) {
await lauf({ props: { workerId: 'lea', stufe }, trace: { trace: { events }, loading: false, error: null } }, (ui) => {
const zeilen = ui.spurZeilen(); const byText = (t) => zeilen.find((z) => z.text === t)
assert.equal(zeilen.length, 6)
@@ -407,7 +407,7 @@ test('Spur-Links in den Keller (?tab=botleads, auch über /workers und /leads) s
assert.ok(!JSON.stringify(ui.find((x) => x.tag === 'a' || x.tag === 'router-link').map((n) => n.props)).includes('botleads&bot'))
})
}
for (const stufe of [6, 9]) {
for (const stufe of [3, 5, 6, 9]) {
await lauf({ props: { workerId: 'lea', stufe }, trace: { trace: { events }, loading: false, error: null } }, (ui) => {
const zeilen = ui.spurZeilen(); const byText = (t) => zeilen.find((z) => z.text === t)
assert.deepEqual(ziele(byText('Keller').li).map((n) => n.props.to), ['/hustler?tab=botleads&bot=abc123'])
+13 -9
View File
@@ -121,7 +121,7 @@ async function start(initial = {}) {
const state = initial.state === undefined ? makeState() : initial.state
const handle = { current: null }
// Stufen-Sperre (hustlerStufe.js): die Zusicherungen dieser Datei gelten für das volle Haus (Stufe 6, Keller offen, Verhalten wie vor der Sperre);
// stufe: 3 (Keller gesperrt) steht in den eigenen Tests am Ende des Abschnitts „Etagen“
// stufe 1 und 2 (Keller zu) und 3 (Keller begehbar, leer) stehen in den eigenen Tests am Ende des Abschnitts „Etagen“
let props = { state, workers: state ? indexWorkers(state) : {}, onZeige() {}, onOpen() {}, stufe: 6, ...initial }
const mounted = __mount(jsx(() => jsx(HochhausWelt, { ...props, ref: handle }), {}))
await settle()
@@ -480,10 +480,10 @@ test('Etagen: ein Etagen-Knopf zoomt heran (Kamera wie kameraEtage), ohne onOpen
} finally { ui.end(); restore() }
})
// Stufen-Sperre: unterhalb der Keller-Stufe (6) gibt es keinen Weg in den Keller; die Etage verhält sich wie jede andere (zoomt heran), Arne bleibt klickbar
test('Etagen bei Stufe 3 und 5: nur Freigabe ist ein Link, die Keller-Etage ist ein Knopf wie die anderen (Beschriftung, Text, kein href)', async () => {
// Stufen-Sperre: unterhalb von Stufe 3 (KELLER_BEGEHBAR_AB) gibt es keinen Weg in den Keller; die Etage verhält sich wie jede andere (zoomt heran), Arne bleibt klickbar
test('Etagen bei Stufe 1 und 2: nur Freigabe ist ein Link, die Keller-Etage ist ein Knopf wie die anderen (Beschriftung, Text, kein href)', async () => {
const { ETAGEN } = await laden()
for (const stufe of [1, 3, 5]) {
for (const stufe of [1, 2]) {
const ui = await start({ stufe })
try {
const etagen = byClass(ui.tree(), 'hk-etage')
@@ -507,11 +507,11 @@ test('Etagen bei Stufe 3 und 5: nur Freigabe ist ein Link, die Keller-Etage ist
}
})
test('Stufe 3: Klick auf die Keller-Etage zoomt heran und öffnet keinen Bildschirm; die Freigabe-Etage öffnet weiter das Pult', async () => {
test('Stufe 1 und 2: Klick auf die Keller-Etage zoomt heran und öffnet keinen Bildschirm; die Freigabe-Etage öffnet weiter das Pult', async () => {
const { kameraEtage, ETAGEN } = await laden()
const geoeffnet = []
const restore = fensterAttrappe()
const ui = await start({ stufe: 3, onOpen: (tab) => geoeffnet.push(tab) })
const ui = await start({ stufe: 2, onOpen: (tab) => geoeffnet.push(tab) })
try {
ui.anhaengen()
const keller = ETAGEN.find((e) => e.id === 'keller')
@@ -540,13 +540,17 @@ test('Stufe 3: Arne bleibt im Haus sichtbar und klickbar (Detail-Dialog), auch d
} finally { ui.end() }
})
test('Stufe 6 und höher: die Keller-Etage bleibt der Link auf die Bot-Leads (wie vor der Sperre)', async () => {
for (const stufe of [6, 7, 9]) {
const ui = await start({ stufe })
// Ab Stufe 3 ist der Keller begehbar (auch ohne Bot-Leads): die Etage ist der Link, genau wie mit Live-Daten ab Stufe 6
test('Ab Stufe 3 (Produktion) und höher: die Keller-Etage ist der Link auf den Keller (wie vor der Sperre), Klick wechselt den Bildschirm', async () => {
for (const stufe of [3, 4, 5, 6, 7, 9]) {
const geoeffnet = []
const ui = await start({ stufe, onOpen: (tab) => geoeffnet.push(tab) })
try {
const keller = ui.etage('Keller · Bot-Leads ▸')
assert.equal(keller.tag, 'a', `Stufe ${stufe}`); assert.equal(keller.props.href, '/hustler?tab=botleads')
assert.equal(keller.props['aria-label'], 'Keller · Bot-Leads: Bot-Leads öffnen')
const e = ereignis(); keller.props.onClick(e)
assert.equal(e.defaultPrevented, true, `Stufe ${stufe}`); assert.deepEqual(geoeffnet, ['botleads'], `Stufe ${stufe}`)
} finally { ui.end() }
}
})
+36 -17
View File
@@ -269,7 +269,7 @@ const loadSeite = () => (seiteBundle ??= esbuild.build({
// Ein Lauf der Seite. query ist der Teil nach dem ?, feed wird vom Test zwischendurch geändert (setFeed) und die Seite neu gerendert.
// stufe (Stufen-Sperre, hustlerStufe.js): die Zusicherungen dieser Datei gelten für die volle Seite (Stufe 6, Keller offen, Verhalten wie vor der Sperre);
// die Tests „Stufe 3“ weiter unten bauen die Seite mit stufe: 3 (Stand der Produktion).
// die Tests „Stufe 1 und 2“ und „Stufe 3“ weiter unten bauen die Seite mit stufe: 1/2 (Keller zu) und 3 (Stand der Produktion: Keller begehbar, ohne Bot-Leads).
async function seite(query, { admin = false, feed, stufe = 6 } = {}) {
const { __mount, jsx, WorkersPage } = await loadSeite()
const setParamsCalls = []
@@ -330,13 +330,13 @@ test('Seite: ?tab=freigabe zeigt das Pult, ?tab=botleads den Keller, beide direk
} finally { keller.end() }
})
// ---------- Stufen-Sperre (Stufe 3 = Stand der Produktion: Dienst P3, ohne Keller) ----------
// ---------- Stufen-Sperre (Keller begehbar ab Stufe 3 = Stand der Produktion: Dienst P3, Keller ohne Bot-Leads) ----------
// Adressen, die auf Stufe 3 in den Keller führen würden: der Reiter, mit Einzelfall, mit Ballast, andere Schreibweisen und erfundene Parameter
// Adressen, die in den Keller führen würden: der Reiter, mit Einzelfall, mit Ballast, andere Schreibweisen und erfundene Parameter
const KELLER_ADRESSEN = ['tab=botleads', 'tab=botleads&bot=abc123', 'bot=abc123&tab=botleads', 'tab=botleads&bot=', 'tab=BotLeads', 'ansicht=keller', 'tab=keller', 'tab=botleads&ansicht=keller']
test('Stufe 3: jede Adresse in den Keller fällt auf das Hochhaus zurück; der Keller wird nie eingehängt, Hochhaus und Seite bleiben ganz', async () => {
for (const stufe of [1, 3, 5]) {
test('Stufe 1 und 2: jede Adresse in den Keller fällt auf das Hochhaus zurück; der Keller wird nie eingehängt, Hochhaus und Seite bleiben ganz', async () => {
for (const stufe of [1, 2]) {
for (const query of KELLER_ADRESSEN) {
const ui = await seite(query, { stufe, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try {
@@ -351,8 +351,8 @@ test('Stufe 3: jede Adresse in den Keller fällt auf das Hochhaus zurück; der K
}
})
test('Stufe 3: der Wechsel der Adresse zu ?tab=botleads zeigt das Hochhaus, Freigabe bleibt erreichbar; der Rückweg aus dem Pult funktioniert wie sonst', async () => {
const ui = await seite('tab=freigabe', { stufe: 3, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
test('Stufe 2: der Wechsel der Adresse zu ?tab=botleads zeigt das Hochhaus, Freigabe bleibt erreichbar; der Rückweg aus dem Pult funktioniert wie sonst', async () => {
const ui = await seite('tab=freigabe', { stufe: 2, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try {
assert.deepEqual(ui.screen(), ['pult'])
ui.go('tab=botleads&bot=x'); assert.deepEqual(ui.screen(), ['hochhaus']); assert.equal(ui.hk.props.hochhaus.vonTab, 'freigabe')
@@ -362,32 +362,51 @@ test('Stufe 3: der Wechsel der Adresse zu ?tab=botleads zeigt das Hochhaus, Frei
} finally { ui.end() }
})
test('Stufe 3: das Hochhaus öffnet nur noch die Freigabe (onOpen(\'botleads\') tut nichts); Hochhaus und Pult bekommen die Stufe', async () => {
const ui = await seite('tab=hochhaus', { stufe: 3, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
test('Stufe 2: das Hochhaus öffnet nur noch die Freigabe (onOpen(\'botleads\') tut nichts); Hochhaus und Pult bekommen die Stufe', async () => {
const ui = await seite('tab=hochhaus', { stufe: 2, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try {
const p = ui.hk.props.hochhaus
p.onOpen('botleads'); p.onOpen('freigabe')
assert.deepEqual(ui.setParamsCalls, [{ tab: 'freigabe' }])
ui.go('tab=freigabe')
assert.equal(ui.hk.props.pult.stufe, 3)
assert.equal(ui.hk.props.pult.stufe, 2)
ui.hk.props.pult.onBack()
assert.deepEqual(ui.setParamsCalls, [{ tab: 'freigabe' }, { tab: 'hochhaus' }])
} finally { ui.end() }
})
test('Stufe 6 und höher: ?tab=botleads zeigt den Keller samt Einzelfall (bot), onOpen(\'botleads\') wechselt; Stufe 4 und 5 haben den Keller noch nicht', async () => {
for (const stufe of [6, 8, 9]) {
// Stufe 3 (Produktion) und höher: der Keller ist ein Bildschirm; die Stufe geht an BotLeadList, die darunter von Stufe 6 entscheidet, ob sie Bot-Leads abruft (botleadList.test.mjs)
test('Stufe 3 bis 9: ?tab=botleads (auch alle Schreibweisen mit tab=botleads) zeigt den Keller samt Einzelfall (bot) und bekommt die Stufe; onOpen(\'botleads\') wechselt; Zurück ins Hochhaus', async () => {
for (const stufe of [3, 4, 5, 6, 8, 9]) {
const ui = await seite('tab=botleads&bot=abc123', { stufe, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try {
assert.deepEqual(ui.screen(), ['keller'], `Stufe ${stufe}`); assert.equal(ui.hk.props.keller.bot, 'abc123')
assert.deepEqual(ui.screen(), ['keller'], `Stufe ${stufe}`); assert.equal(ui.hk.props.keller.bot, 'abc123'); assert.equal(ui.hk.props.keller.stufe, stufe)
assert.equal(ui.root().props['data-tab'], 'botleads', `Stufe ${stufe}`)
ui.hk.props.keller.onBack(); ui.hk.props.keller.onClearBot()
assert.deepEqual(ui.setParamsCalls, [{ tab: 'hochhaus' }, { tab: 'botleads' }], `Stufe ${stufe}`)
ui.go('tab=hochhaus'); ui.hk.props.hochhaus.onOpen('botleads')
assert.deepEqual(ui.setParamsCalls, [{ tab: 'botleads' }], `Stufe ${stufe}`)
assert.deepEqual(ui.setParamsCalls, [{ tab: 'hochhaus' }, { tab: 'botleads' }, { tab: 'botleads' }], `Stufe ${stufe}`)
} finally { ui.end() }
}
for (const stufe of [4, 5]) {
const ui = await seite('tab=botleads', { stufe })
try { assert.deepEqual(ui.screen(), ['hochhaus'], `Stufe ${stufe}`) } finally { ui.end() }
for (const query of ['tab=botleads', 'bot=abc123&tab=botleads', 'tab=botleads&bot=']) {
const ui = await seite(query, { stufe: 3, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try { assert.deepEqual(ui.screen(), ['keller'], query) } finally { ui.end() }
}
// erfundene Werte bleiben beim Hochhaus, auch auf Stufe 3
for (const query of ['tab=BotLeads', 'ansicht=keller', 'tab=keller']) {
const ui = await seite(query, { stufe: 3, feed: { state: makeState(), error: null, loaded: true, refresh() {} } })
try { assert.deepEqual(ui.screen(), ['hochhaus'], query) } finally { ui.end() }
}
})
test('Stufe 3: Hochhaus und Keller bekommen die Stufe, der Keller zählt als Bildschirm der Seite (data-tab); Pause und Kennzahlen gehen an ihn', async () => {
const state = makeState({ kpis: { pultOpen: 2, dealsMonth: 1, botLeads: 0 } })
const ui = await seite('tab=botleads', { stufe: 3, feed: { state, error: null, loaded: true, refresh() {} } })
try {
assert.equal(ui.hk.props.keller.stufe, 3); assert.equal(ui.hk.props.keller.kpis, state.kpis); assert.equal(ui.hk.props.keller.paused, false)
ui.go('tab=hochhaus')
assert.equal(ui.hk.props.hochhaus.stufe, 3); assert.equal(ui.hk.props.hochhaus.vonTab, 'botleads')
} finally { ui.end() }
})
test('Seite: keine Seitenüberschrift, keine Reiterleiste', async () => {
+29 -21
View File
@@ -7,15 +7,16 @@ import esbuild from 'esbuild'
import { loadModule } from './load.mjs'
// Stufen-Sperre der Hustler-Seite (src/lib/hustlerStufe.js): eine Quelle der Wahrheit, was die Oberfläche schon zeigen darf.
// Stufe 3 = Dienst P3 live (Stand der Produktion): kein Keller (Bot-Leads, P6), keine Antwort-Knöpfe im Pult (P4). Ab Stufe 4 kommen die Antwort-Knöpfe, ab 6 der Keller.
// Diese Datei prüft das Modul selbst (Stufe aus dem Bau, Schwellen, Bildschirme, Links). Die Bausteine prüfen ihre Sperre in ihren eigenen Tests, jeweils mit Stufe 3 (gesperrt)
// und Stufe 6 (offen): rowModel in workersPult.test.mjs, der Client in workersApi.test.mjs, Pult in workersPultUi.test.mjs, Haus in hustlerHochhausWelt.test.mjs, Seite in
// hustlerSeite.test.mjs, Spur-Links in hustlerDialog.test.mjs, der Prüfstand in workersHarness.test.mjs.
// Stufe 3 = Dienst P3 live (Stand der Produktion): der Keller ist begehbar (Etage, Bildschirm, Spur-Links), aber leer und ohne Abruf der Bot-Leads (P6), und es gibt keine
// Antwort-Knöpfe im Pult (P4). Ab Stufe 4 kommen die Antwort-Knöpfe, ab 6 liefert der Keller die Bot-Leads.
// Diese Datei prüft das Modul selbst (Stufe aus dem Bau, Schwellen, Bildschirme, Links). Die Bausteine prüfen ihre Sperre in ihren eigenen Tests, jeweils mit Stufe 3 (Keller leer)
// und Stufe 6 (Live-Daten), dazu 1 (Keller zu): rowModel in workersPult.test.mjs, der Client in workersApi.test.mjs, Pult in workersPultUi.test.mjs, Haus in hustlerHochhausWelt.test.mjs,
// Seite in hustlerSeite.test.mjs, Keller in botleadList.test.mjs, Spur-Links in hustlerDialog.test.mjs, der Prüfstand in workersHarness.test.mjs.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8')
const stufe = await loadModule('src/lib/hustlerStufe.js')
const { HUSTLER_STUFE, STUFE_ANTWORTEN, STUFE_KELLER, stufeAus, antwortenOffen, kellerOffen, tabErlaubt, linkErlaubt } = stufe
const { HUSTLER_STUFE, STUFE_ANTWORTEN, KELLER_BEGEHBAR_AB, BOTLEADS_AB, stufeAus, antwortenOffen, kellerBegehbar, botleadsAbrufbar, tabErlaubt, linkErlaubt } = stufe
// ---------- Stufe aus dem Bau ----------
@@ -36,13 +37,13 @@ test('Standard: ohne Wert aus dem Bau gilt die Stufe aus dem Quelltext, einmal u
assert.equal(HUSTLER_STUFE, Number(treffer[0][1]))
assert.ok(Number.isInteger(HUSTLER_STUFE) && HUSTLER_STUFE >= 1 && HUSTLER_STUFE <= 9)
assert.ok(quelltext.includes('beim jeweiligen Livegang hier hochsetzen'))
assert.equal(STUFE_ANTWORTEN, 4); assert.equal(STUFE_KELLER, 6)
assert.equal(STUFE_ANTWORTEN, 4); assert.equal(KELLER_BEGEHBAR_AB, 3); assert.equal(BOTLEADS_AB, 6)
})
// Der Wert aus dem Bau (Vite: import.meta.env.VITE_HUSTLER_STUFE, Prüfstand: define von esbuild) wird beim Bauen eingesetzt. Hier derselbe Weg mit esbuild.
async function stufeNachBau(define) {
const out = await esbuild.build({
stdin: { contents: "export { HUSTLER_STUFE, antwortenOffen, kellerOffen } from './src/lib/hustlerStufe.js'", resolveDir: ROOT, loader: 'js' },
stdin: { contents: "export { HUSTLER_STUFE, antwortenOffen, kellerBegehbar, botleadsAbrufbar } from './src/lib/hustlerStufe.js'", resolveDir: ROOT, loader: 'js' },
bundle: true, format: 'esm', platform: 'neutral', write: false, define,
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
@@ -52,7 +53,7 @@ test('Bau: VITE_HUSTLER_STUFE überschreibt die Stufe zur Bauzeit (Prüfstand);
for (const [roh, erwartet] of [['6', 6], ['4', 4], ['9', 9], ['1', 1], [' 6 ', 6], ['3', 3], ['0', HUSTLER_STUFE], ['10', HUSTLER_STUFE], ['abc', HUSTLER_STUFE], ['', HUSTLER_STUFE], ['6.5', HUSTLER_STUFE], ['-2', HUSTLER_STUFE]]) {
const m = await stufeNachBau({ 'import.meta.env.VITE_HUSTLER_STUFE': JSON.stringify(roh) })
assert.equal(m.HUSTLER_STUFE, erwartet, `VITE_HUSTLER_STUFE=${JSON.stringify(roh)}`)
assert.equal(m.kellerOffen(), erwartet >= 6); assert.equal(m.antwortenOffen(), erwartet >= 4)
assert.equal(m.kellerBegehbar(), erwartet >= 3); assert.equal(m.botleadsAbrufbar(), erwartet >= 6); assert.equal(m.antwortenOffen(), erwartet >= 4)
}
// nicht gesetzt (Vite: undefined) und ohne import.meta.env (Node): der Standard
assert.equal((await stufeNachBau({ 'import.meta.env.VITE_HUSTLER_STUFE': 'undefined' })).HUSTLER_STUFE, HUSTLER_STUFE)
@@ -72,41 +73,48 @@ test('keine Überschreibung zur Laufzeit: kein localStorage, keine Adresse, kein
// ---------- Schwellen ----------
test('Schwellen: Antwort-Knöpfe ab Stufe 4, Keller ab Stufe 6; unbrauchbare Stufe gilt als HUSTLER_STUFE', () => {
test('Schwellen: Keller begehbar ab Stufe 3, Antwort-Knöpfe ab Stufe 4, Bot-Leads abrufbar ab Stufe 6; unbrauchbare Stufe gilt als HUSTLER_STUFE', () => {
for (let n = 1; n <= 9; n += 1) {
assert.equal(kellerBegehbar(n), n >= 3, `kellerBegehbar(${n})`)
assert.equal(antwortenOffen(n), n >= 4, `antwortenOffen(${n})`)
assert.equal(kellerOffen(n), n >= 6, `kellerOffen(${n})`)
assert.equal(botleadsAbrufbar(n), n >= 6, `botleadsAbrufbar(${n})`)
}
assert.deepEqual([antwortenOffen(3), kellerOffen(3)], [false, false])
assert.deepEqual([antwortenOffen(6), kellerOffen(6)], [true, true])
// Stufe 3 (Produktion): Keller begehbar, aber weder Antwort-Knöpfe noch Abruf der Bot-Leads; der Abruf setzt die Begehbarkeit voraus
assert.deepEqual([kellerBegehbar(3), antwortenOffen(3), botleadsAbrufbar(3)], [true, false, false])
assert.deepEqual([kellerBegehbar(2), antwortenOffen(2), botleadsAbrufbar(2)], [false, false, false])
assert.deepEqual([kellerBegehbar(6), antwortenOffen(6), botleadsAbrufbar(6)], [true, true, true])
assert.ok(KELLER_BEGEHBAR_AB <= BOTLEADS_AB)
for (const kaputt of [undefined, null, 'x', 0, 12, NaN]) {
assert.equal(antwortenOffen(kaputt), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerOffen(kaputt), kellerOffen(HUSTLER_STUFE))
assert.equal(antwortenOffen(kaputt), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerBegehbar(kaputt), kellerBegehbar(HUSTLER_STUFE)); assert.equal(botleadsAbrufbar(kaputt), botleadsAbrufbar(HUSTLER_STUFE))
}
assert.equal(antwortenOffen(), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerOffen(), kellerOffen(HUSTLER_STUFE))
assert.equal(antwortenOffen(), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerBegehbar(), kellerBegehbar(HUSTLER_STUFE)); assert.equal(botleadsAbrufbar(), botleadsAbrufbar(HUSTLER_STUFE))
})
// ---------- Bildschirme und Deeplinks ----------
test('tabErlaubt: nur der Keller (botleads) ist gesperrt, und nur unterhalb von Stufe 6', () => {
for (const s of [1, 3, 4, 5]) {
test('tabErlaubt: nur der Keller (botleads) hängt an der Stufe, und nur unterhalb von Stufe 3 ist er gesperrt (ab 3 begehbar, auch ohne Bot-Leads)', () => {
for (const s of [1, 2]) {
assert.equal(tabErlaubt('botleads', s), false, `Stufe ${s}`)
for (const frei of ['hochhaus', 'freigabe', '', 'unbekannt', undefined]) assert.equal(tabErlaubt(frei, s), true, `${frei} auf Stufe ${s}`)
}
for (const s of [6, 7, 9]) assert.equal(tabErlaubt('botleads', s), true, `Stufe ${s}`)
for (const s of [3, 4, 5, 6, 7, 9]) {
assert.equal(tabErlaubt('botleads', s), true, `Stufe ${s}`)
for (const frei of ['hochhaus', 'freigabe', '', 'unbekannt', undefined]) assert.equal(tabErlaubt(frei, s), true, `${frei} auf Stufe ${s}`)
}
})
test('linkErlaubt: Links in den Keller sind unterhalb von Stufe 6 kein Link, alles andere bleibt unberührt', () => {
test('linkErlaubt: Links in den Keller sind unterhalb von Stufe 3 kein Link und ab 3 einer, alles andere bleibt unberührt', () => {
const keller = [
'/hustler?tab=botleads', '/hustler?tab=botleads&bot=abc123', '/hustler/?tab=botleads', '/Hustler?tab=botleads', '/hustler?bot=x&tab=botleads',
'/workers?tab=botleads&bot=x', '/leads?tab=botleads', '/hustler?tab=botleads#oben',
]
for (const href of keller) {
assert.equal(linkErlaubt(href, 3), false, `Stufe 3: ${href}`); assert.equal(linkErlaubt(href, 5), false, `Stufe 5: ${href}`)
assert.equal(linkErlaubt(href, 6), true, `Stufe 6: ${href}`)
assert.equal(linkErlaubt(href, 1), false, `Stufe 1: ${href}`); assert.equal(linkErlaubt(href, 2), false, `Stufe 2: ${href}`)
for (const s of [3, 5, 6]) assert.equal(linkErlaubt(href, s), true, `Stufe ${s}: ${href}`)
}
const frei = [
'/hustler?tab=freigabe', '/hustler', '/hustler?tab=hochhaus', '/hustler?bot=abc123', '/tickets/100001', '/customers/abc', '/other?tab=botleads', '/hustler/anderes?tab=botleads',
'https://beispiel.de/hustler?tab=botleads', 'http://x.example/?tab=botleads', '//evil.example/hustler?tab=botleads', '/\\evil.example/hustler?tab=botleads', '', 'kein-pfad', null, undefined, 42,
]
for (const href of frei) { assert.equal(linkErlaubt(href, 3), true, `Stufe 3: ${String(href)}`); assert.equal(linkErlaubt(href, 6), true, `Stufe 6: ${String(href)}`) }
for (const href of frei) { for (const s of [1, 3, 6]) assert.equal(linkErlaubt(href, s), true, `Stufe ${s}: ${String(href)}`) }
})
+5 -6
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS, ACTION_TIMEOUT_MS, SLOW_PULT_ACTIONS, UNCERTAIN_STATUS, KELLER_GESPERRT_TEXT } = await loadModule('src/lib/workersApi.js')
const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS, ACTION_TIMEOUT_MS, SLOW_PULT_ACTIONS, UNCERTAIN_STATUS, BOTLEADS_GESPERRT_TEXT } = await loadModule('src/lib/workersApi.js')
const { actionErrorText, UNCERTAIN_TEXT } = await loadModule('src/lib/workersPult.js')
const { createJwtCache } = await loadModule('src/lib/serverpulsAuth.js')
@@ -707,15 +707,14 @@ test('createJWT: Übertragungsfehler beim frischen JWT nach einer 401 sperrt nic
assert.deepEqual(mints, [false, true, false, true]) // keine Sperre: weder für frische JWTs noch für createJWT überhaupt
})
// ---------- Stufen-Sperre (hustlerStufe.js): kein Abruf des Kellers unterhalb von Stufe 6 ----------
// ---------- Stufen-Sperre (hustlerStufe.js): kein Abruf der Bot-Leads unterhalb von Stufe 6 (BOTLEADS_AB; der Keller selbst ist schon ab Stufe 3 begehbar, dann leer) ----------
test('API: auf Stufe 3 geht kein Abruf von /api/workers/botleads hinaus (auch kein JWT), auf Stufe 6 wie bisher', async () => {
for (const s of [1, 3, 5]) {
for (const s of [1, 3, 4, 5]) {
const f = fakeFetch(okReplies(8)); let jwtGeholt = 0
const api = createWorkersApi({ getJwt: async () => { jwtGeholt += 1; return 'jwt' }, fetchImpl: f, stufe: s })
for (const params of [undefined, {}, { room: 'handwerk' }, { q: 'Bäcker', page: 2 }, { id: 'abc' }]) {
await assert.rejects(api.botleads(params), (err) => err.status === 404 && err.message === KELLER_GESPERRT_TEXT, `Stufe ${s}`)
await assert.rejects(api.botleads(params), (err) => err.status === 404 && err.message === BOTLEADS_GESPERRT_TEXT, `Stufe ${s}`)
}
assert.equal(f.calls.length, 0, `Stufe ${s}`); assert.equal(jwtGeholt, 0, `Stufe ${s}`)
// die übrigen Abrufe der Stufe 3 laufen
@@ -728,5 +727,5 @@ test('API: auf Stufe 3 geht kein Abruf von /api/workers/botleads hinaus (auch ke
await api.botleads({ room: 'handwerk', page: 2 })
assert.deepEqual(f.calls.map((c) => [c.init.method || 'GET', c.url]), [['GET', '/api/workers/botleads?room=handwerk&page=2']])
}
assert.match(KELLER_GESPERRT_TEXT, /Keller/)
assert.match(BOTLEADS_GESPERRT_TEXT, /Bot-Leads/)
})
+2 -2
View File
@@ -19,7 +19,7 @@ test('WorkersPage: Tab-Titel Hustler, drei Bildschirme in fester Reihenfolge, ke
assert.match(s, /id: 'hochhaus', label: 'Hochhaus'/); assert.match(s, /id: 'freigabe', label: 'Freigabe'/); assert.match(s, /id: 'botleads', label: 'Bot-Leads'/)
assert.ok(s.indexOf("id: 'hochhaus'") < s.indexOf("id: 'freigabe'") && s.indexOf("id: 'freigabe'") < s.indexOf("id: 'botleads'"))
assert.doesNotMatch(s, /title="Hustler"/); assert.doesNotMatch(s, /PageHeader/); assert.doesNotMatch(s, /<Tabs\b/); assert.doesNotMatch(s, /WORKERS_TABS\.length > 1/)
assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(WORKERS_TABS\.filter\(\(t\) => tabErlaubt\(t\.id, stufe\)\), params\.get\('tab'\)\)/); assert.match(s, /useAuth\(\)/) // Stufen-Sperre: der Keller ist nur ab Stufe 6 ein Bildschirm (hustlerStufe.test.mjs)
assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(WORKERS_TABS\.filter\(\(t\) => tabErlaubt\(t\.id, stufe\)\), params\.get\('tab'\)\)/); assert.match(s, /useAuth\(\)/) // Stufen-Sperre: der Keller ist ab Stufe 3 ein Bildschirm, die Bot-Leads ruft er erst ab Stufe 6 ab (hustlerStufe.test.mjs, botleadList.test.mjs)
assert.match(s, /<Hochhaus[^>]*\bapi=\{api\}/); assert.match(s, /<Hochhaus[^>]*isAdmin=\{isAdmin\}/)
assert.match(s, /<div className="hk-seite" data-tab=\{active\.id\}>/) // Vollhöhen-Rahmen, data-tab schaltet den Übergang für Pult und Keller
assert.match(s, /import Hochhaus from '\.\.\/components\/workers\/hochhaus\/Hochhaus'/)
@@ -39,7 +39,7 @@ test('WorkersPage: freigabe zeigt die PultList, botleads die BotLeadList (api, b
assert.match(s, /import PultList from '\.\.\/components\/workers\/PultList'/); assert.match(s, /import BotLeadList from '\.\.\/components\/workers\/BotLeadList'/)
assert.match(s, /active\.id === 'freigabe' && \([\s\S]*?<PultList[^>]*\bapi=\{api\}/); assert.match(s, /<PultList[^>]*me=\{\{ id: user\?\.\$id, name: user\?\.name \}\}/)
assert.match(s, /active\.id === 'botleads' && \([\s\S]*?<BotLeadList[^>]*\bapi=\{api\}/)
assert.match(s, /params\.get\('bot'\)/); assert.match(s, /<BotLeadList[^>]*\bbot=\{bot\}/); assert.match(s, /<BotLeadList[^>]*onClearBot=\{\(\) => setParams\(\{ tab: 'botleads' \}\)\}/)
assert.match(s, /params\.get\('bot'\)/); assert.match(s, /<BotLeadList[^>]*\bstufe=\{stufe\}/); assert.match(s, /<BotLeadList[^>]*\bbot=\{bot\}/); assert.match(s, /<BotLeadList[^>]*onClearBot=\{\(\) => setParams\(\{ tab: 'botleads' \}\)\}/)
assert.match(s, /onOpen=\{\(tab\) => \{ if \(tabErlaubt\(tab, stufe\)\) setParams\(\{ tab \}\) \}\}/) // setParams ersetzt alle Parameter: bot fällt beim Wechsel weg; ein gesperrter Bildschirm öffnet sich nie
assert.equal(s.match(/onBack=\{\(\) => setParams\(\{ tab: 'hochhaus' \}\)\}/g).length, 2) // „◂ Hochhaus“ in Pult und Keller
for (const name of ['PultList', 'BotLeadList']) assert.match(s, new RegExp(`<${name}[\\s\\S]*?kpis=\\{state\\?\\.kpis\\}[\\s\\S]*?workers=\\{workers\\}[\\s\\S]*?paused=\\{paused\\}[\\s\\S]*?/>`)) // Pause (R3) geht an Pult und Keller