From 2470f05acbf5c54c9343d460d273fa0c4d5c65f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 09:32:33 +0000 Subject: [PATCH] =?UTF-8?q?Hustler-Pixelwelt=20T12:=20Pr=C3=BCfstand=20auf?= =?UTF-8?q?=20die=20Pixelwelt=20umgestellt=20(Szenarien=20A=E2=80=93M),=20?= =?UTF-8?q?Handy-Kopfzeile=20zweireihig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prüfstand: entry.jsx zeigt nur noch die echte Seite WorkersPage (BrowserRouter, Adresse mit ?tab= lesbar); shot.cjs neu mit den Szenarien A–M (Hochhaus, Regie, Handy, weniger Bewegung, Notbremse, Ausfall, Detail, Etagen, Pause, Pult P3, Pult P4, Keller, Handy Pult/Keller), harte Prüfungen und Bildern mit Szenario-Vorsilbe; Befunde an der App stehen getrennt von den Verstößen. Skript-API: Gründe der Bot-Leads wörtlich wie im Dienst. Kopfzeile am Handy: zwei Reihen (Geld + Pause/Zahnrad, dann die fünf Kennzahlen), schmaler Panel-Rand, 40-px-Knöpfe, Querformat unter 500 px Höhe in einer Reihe; hustlerKopf.test um die Zusicherungen dazu ergänzt. Co-Authored-By: Claude Opus 5.5 --- scripts/workers-harness/build.mjs | 2 +- scripts/workers-harness/entry.jsx | 59 +- scripts/workers-harness/scripted-api.js | 23 +- scripts/workers-harness/shot.cjs | 3359 +++++++++-------------- src/styles/hustler/hustler-kopf.css | 34 +- tests/unit/hustlerKopf.test.mjs | 7 + 6 files changed, 1431 insertions(+), 2053 deletions(-) diff --git a/scripts/workers-harness/build.mjs b/scripts/workers-harness/build.mjs index 268f910..7ed7ff5 100644 --- a/scripts/workers-harness/build.mjs +++ b/scripts/workers-harness/build.mjs @@ -3,7 +3,7 @@ // outDir muss ein neuer oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo sein. // Die Schrift /fonts/* bleibt außen vor: der Server von shot.cjs liefert sie aus public/fonts. // Drei Module der App hängen an Appwrite (Anmeldung, Mitarbeiterliste, Sitzungs-JWT) und werden nur im Prüfstand ausgetauscht, damit die -// echte Seite WorkersPage (Reiter Hochhaus, Freigabe und Bot-Leads) samt PultList, ProspectPanel und BotLeadList ohne Netz läuft: AuthContext -> stub-auth.js, +// 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. import esbuild from 'esbuild' import fs from 'node:fs' diff --git a/scripts/workers-harness/entry.jsx b/scripts/workers-harness/entry.jsx index 4a91c4a..91ca601 100644 --- a/scripts/workers-harness/entry.jsx +++ b/scripts/workers-harness/entry.jsx @@ -1,23 +1,23 @@ -// Einstiegspunkt des Prüfstands: das Hochhaus-Board der Hustler-Seite mit der Skript-API, ohne Anmeldung und ohne Appwrite. -// Drumherum steht dieselbe Hülle wie in der App (Menüleiste links, Seitenrand), damit die Bühne genauso breit wird wie im echten Betrieb. -// ?halted=1 startet mit gesetzter Bounce-Notbremse (H4). window.__wk gibt es nur hier: die Bühne und die Skript-API für shot.cjs. -// ?fault=none|down|session|hang schaltet zwischen Board und Skript-API den echten API-Client (createWorkersApi) mit einer Fetch-Weiche -// (scripted-fetch.js): window.__wk.control.fault lässt sich zur Laufzeit auf null | 'down' | 'session' | 'hang' setzen (Ausfall-Szenario). -// Ohne den Parameter bekommt das Board die Skript-API direkt (Szenarien A bis E). -// ?page=1 zeigt statt des Boards die echte Seite WorkersPage (Reiter Hochhaus, Freigabe und Bot-Leads; PultList, ProspectPanel, BotLeadList); AuthContext, -// useEmployees und workersClient sind dort durch stub-*.js ersetzt (build.mjs). ?tab=freigabe öffnet den Reiter gleich (Deep-Link), ebenso ?tab=botleads -// mit ?bot= (Einzelansicht aus dem Ereignisstrom) und ?botleads=leer (die Liste ist leer: Leerzustand ohne Filter). Die Skript-API liefert 60 feste -// Bot-Leads samt drei gekürzten Stubs (scripted-api.js, „Bot-Leads (P6)“). -// Mit ?page=1&fault=none läuft die Seite über den echten API-Client und die Fetch-Weiche (Pult, Aktionen, Interessent). -// ?p4=1 (nur mit ?page=1) nimmt den Datensatz für die Gespräche und die Werkstatt (P4: als Mensch antworten, Vorschau freigeben und bauen, -// Antwort des Kunden einfügen) statt der P3-Einträge. -import { StrictMode, createRef } from 'react' +// Einstiegspunkt des Prüfstands: die echte Seite WorkersPage der Pixelwelt (Hochhaus, Pult, Keller) mit der Skript-API, ohne Anmeldung und ohne Appwrite. +// Drumherum steht dieselbe Hülle wie in der App (Menüleiste links, Seitenrand), damit die Welt genauso groß wird wie im echten Betrieb. +// Der Router ist ein BrowserRouter: ?tab=… und ?bot=… stehen in der echten Adresse des Browsers, ein Wechsel des Bildschirms (Etage Freigabe, „◂ Hochhaus“) +// ändert sie wie in der App; die Adresse lässt sich also lesen und prüfen. Die Parameter des Prüfstands selbst werden nur beim Laden gelesen. +// AuthContext, useEmployees und workersClient sind durch stub-*.js ersetzt (build.mjs); die Seite bekommt ihre API über harness-bridge.js. +// Parameter (alle optional): +// ?tab=hochhaus|freigabe|botleads öffnet den Bildschirm gleich (Deep-Link wie aus dem Ereignisstrom); mit &bot= den Einzelfall im Keller +// ?halted=1 startet mit gesetzter Bounce-Notbremse (Banner „Versand gestoppt“) +// ?fault=none|down|session|hang die Seite läuft über den echten API-Client (createWorkersApi) und die Fetch-Weiche scripted-fetch.js; +// window.__wk.control.fault lässt sich zur Laufzeit auf null | 'down' | 'session' | 'hang' setzen (Ausfall). +// Ohne den Parameter bekommt die Seite die Skript-API direkt. +// ?p4=1 nimmt für das Pult den Datensatz der Gespräche und der Werkstatt (als Mensch antworten, Vorschau freigeben und +// bauen, Antwort des Kunden einfügen) statt der P3-Einträge +// ?botleads=leer die Bot-Lead-Liste ist leer (Leerzustand ohne Filter) +// window.__wk = { api, control }: die Skript-API (Schalter faults, pultEmpty, pultCalls, prospectCalls, pauseCalls, botleadCalls …) und die Steuerung der +// Fetch-Weiche (fault, calls, jwts) für shot.cjs. +import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { MemoryRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router-dom' import '../../src/styles/global.css' -import '../../src/styles/workers.css' -import PageHeader from '../../src/components/ui/PageHeader' -import WorkersBoard from '../../src/components/workers/WorkersBoard' import WorkersPage from '../../src/pages/WorkersPage' import { createWorkersApi } from '../../src/lib/workersApi.js' import { createScriptedApi } from './scripted-api.js' @@ -25,15 +25,13 @@ import { createScriptedFetch } from './scripted-fetch.js' import { holder } from './harness-bridge.js' const params = new URLSearchParams(window.location.search) -const pageMode = params.get('page') === '1' const scripted = createScriptedApi({ now: () => performance.now(), base: Date.now(), halted: params.get('halted') === '1', - pult: pageMode ? (params.get('p4') === '1' ? 'p4' : true) : false, + pult: params.get('p4') === '1' ? 'p4' : true, botleadsEmpty: params.get('botleads') === 'leer', }) -const stage = createRef() const fault = params.get('fault') const control = { fault: fault && fault !== 'none' ? fault : null, calls: [], jwts: [] } // Zeitgrenze im Prüfstand kurz (1,5 s statt 20 s), damit der Ausfall „Antwort kommt nie“ in Sekunden sichtbar wird @@ -47,31 +45,20 @@ const api = fault === null fetchImpl: createScriptedFetch(scripted, control), timeoutMs: 1500, }) -window.__wk = { stage, api: scripted, control } +window.__wk = { api: scripted, control } holder.api = api -const tab = params.get('tab') -const bot = params.get('bot') -const entries = [pageMode && tab ? `/hustler?tab=${encodeURIComponent(tab)}${bot ? `&bot=${encodeURIComponent(bot)}` : ''}` : '/hustler'] - createRoot(document.getElementById('root')).render( - +
- + , ) diff --git a/scripts/workers-harness/scripted-api.js b/scripts/workers-harness/scripted-api.js index fe301b9..8283d3e 100644 --- a/scripts/workers-harness/scripted-api.js +++ b/scripts/workers-harness/scripted-api.js @@ -494,14 +494,23 @@ export const BOTLEAD_IDS = Object.freeze({ const BOTLEAD_ROOM_LABEL = Object.freeze({ aerzte: 'Ärzte & Praxen', handwerk: 'Handwerk', selbst: 'Selbstständige', laden: 'Läden & Gastro' }) const BOTLEAD_ANCHOR_MS = Date.parse('2026-10-17T20:00:00.000Z') const BOTLEAD_STEP_MS = 14.5 * 60 * 60 * 1000 +// Die 14 festen Grundtexte des Dienstes (workers-dev-p6/src/scoring/business.js, Konstante GRUND; wörtlich wie tests/fixtures/botlead-gruende.json): die Akte im Keller +// erkennt sie am Wortlaut und zeigt Symbol und Erklärung dazu. Die Stelle 2 („Website in gutem Zustand“) bestimmt unten, welche Einträge eine Website haben. const BOTLEAD_REASONS = Object.freeze([ - 'Keine veröffentlichte E-Mail-Adresse gefunden (nur ein Kontaktformular).', - 'Gehört zu einer Kette oder einem Filialverbund, wird nicht angeschrieben.', - 'Website ist in gutem Zustand (modern, mobil nutzbar), kein Bedarf erkennbar.', - 'Kein Mail-Platz mehr in dieser Nacht (Tageslimit erreicht).', - 'Mail nur über eine Sammeladresse des Verbunds, nicht eindeutig dem Betrieb zuzuordnen.', - 'Lesen der Seite fehlgeschlagen (Zeitüberschreitung).', - 'Name fehlt in Impressum und Karte.', + 'keine veröffentlichte Mail', + 'Kette oder Franchise', + 'Website in gutem Zustand', + 'Punktzahl unter 55', + 'Adresse nicht zustellbar', + 'veraltet (nicht ausgewählt)', + 'schon bekannt (E-Mail oder Telefon)', + 'schon bekannt (Name und PLZ)', + 'Sperrliste', + 'Lesen fehlgeschlagen', + 'Impressum passt nicht zum Betrieb', + 'Lauf unterbrochen', + 'Bewertung ausgefallen', + 'Name fehlt', ]) const BOTLEAD_LONG_REASON = 'Impressum nennt nur eine Postfachadresse und ein Kontaktformular; die einzige Mail-Adresse (verwaltung@sehr-langer-domainname-der-nicht-umbricht-handwerksbetrieb-westpfalz-gruppe.example) gehört einem Dritten.' const BOTLEAD_LONG_NAME = 'Elektroinstallation-und-Haustechnik-Hoffmann-Sonnenschein-Wittgensteiner GmbH' diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs index 2b45426..0cc3c86 100644 --- a/scripts/workers-harness/shot.cjs +++ b/scripts/workers-harness/shot.cjs @@ -1,16 +1,25 @@ -// Manuelle Abnahme der Hustler-Seite im echten Browser (Chromium, headless shell) gegen den Prüfstand. -// Aufruf (immer unter Speicherdeckel, serielle Läufe): -// systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 300 node --max-old-space-size=400 \ -// scripts/workers-harness/shot.cjs +// Abnahme der Hustler-Pixelwelt im echten Browser (Chromium, headless shell) gegen den Prüfstand (entry.jsx, die echte Seite WorkersPage). +// Aufruf (immer unter Speicherdeckel, seriell, nie zwei Browser gleichzeitig und nie parallel zur Test-Suite): +// 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 // = Ausgabe von build.mjs, = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo. // 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: A Desktop, B Blasen, C Handy, D weniger Bewegung, E Notbremse, F Ausfall (P2); G Freigabe, H Interessenten-Panel, I Anhalten -// mit Rückfrage, J Handy mit Freigabe und Panel (P3); K Antworten als Mensch, L Vorschau freigeben, M Antwort des Kunden einfügen, N Vorschau -// bauen, O Fokus, P Handy für alle Felder (P4, eigener Datensatz ?p4=1, Bilder mit Vorsilbe antworten-, vorschau-, einfuegen-, bauen-, fokus-, -// handy-p4-); Q Bot-Leads am Desktop und R Bot-Leads am Handy (P6, eigene Daten der Skript-API, Bilder mit Vorsilbe botleads-, handy-botleads-). -// Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der -// Abbruch steht als Verstoß in der Liste. HARNESS_ONLY=freigabe,interessent (Namen siehe `SCENARIOS` unten) lässt nur diese laufen (für -// die Arbeit am Prüfstand; die Abnahme läuft immer vollständig). +// 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 +// B regie (b-) Regie: innerhalb von 15 s erscheint eine Sprechblase, höchstens vier Effekte gleichzeitig +// C handy (c-) Handy 390 x 844 (hochkant) und 844 x 390 (quer): Kopfzeile höchstens zwei Reihen, Wischen verschiebt, Doppeltipp zeigt das ganze Haus, Detail als Vollbild +// D bewegung (d-) „Weniger Bewegung“: Schnee und Flackern stehen, kein Gleiten, nur Sprechblasen +// E notbremse (e-) ?halted=1: Banner mit Grund, Einstellungen mit dem Notbremse-Block +// F ausfall (f-) ?fault=down: Leerzustand „Der Hustler-Dienst ist noch nicht erreichbar“, nach „Neu laden“ das Haus, Verbindungsfehler-Banner +// G detail (g-) Detailansicht: Dialog, Blättern mit ←/→, Esc mit Fokus zurück auf die Figur, lange Spur mit ◂ n / 19 ▸, ein Bild je Status +// H etagen (h-) Etage zoomen (mit Gleiten), Tasten +/−/0, Etage „Freigabe“ → Pult und zurück, Keller-Etage → Bot-Leads und zurück +// I pause (i-) Pause mit Pixel-Rückfrage, Animationen stehen (hk-steht), Fortsetzen ohne Rückfrage +// J pult (j-) Pult mit den P3-Daten: Reiter, Umschlag/Brief, Ich kümmere mich, Kopieren & öffnen, Als gesendet markieren, Verwerfen mit Rückfrage, Interessent (WOMS-Aussehen), Fokus +// K pult-p4 (k-) Pult mit den P4-Daten (?p4=1): als Mensch antworten, Vorschau freigeben, Antwort des Kunden einfügen, Vorschau bauen +// L keller (l-) Keller: Reiter, Suche, Blättern, Akte (Heilberuf ohne Nummer, Quelle als Link), Einzelansicht (?bot=), leer (?botleads=leer), Arne +// M handy-pult-keller (m-) Pult und Keller am Handy 390 x 844: volle Breite, Brief und Akte als Vollbild, nichts läuft über den Rand +// Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der Abbruch steht als Verstoß in der Liste. HARNESS_ONLY=detail,pult +// (Namen siehe `SCENARIOS` unten) lässt nur diese laufen (für die Arbeit am Prüfstand; die Abnahme läuft immer vollständig). 'use strict' const { createRequire } = require('module') const path = require('path') @@ -33,12 +42,12 @@ if (fs.existsSync(out) && fs.readdirSync(out).length > 0) { console.error('refus if (!fs.existsSync(path.join(dist, 'index.html'))) { console.error('refusing: no index.html in ' + dist); process.exit(2) } fs.mkdirSync(out, { recursive: true }) -// Namen und Modelle aus der gemeinsamen Roster-Fixture (CommonJS kann den ESM-Quelltext nicht laden) -const roster = JSON.parse(fs.readFileSync(path.join(repo, 'tests/fixtures/workers-roster.json'), 'utf8')).roles -const MODEL = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' } -const FONT_URL = '/fonts/PixelifySans-Variable.woff2' const CHROME = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell' +const FONT_FILES = ['/fonts/PixelifySans-Variable.woff2', '/fonts/PressStart2P-Regular.woff2'] const HALT_LINE = 'Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.' +const PAUSED_LINE = 'Angehalten: Es wird nichts gesendet und nichts gebaut.' +const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.' +const NO_SERVICE = 'Der Hustler-Dienst ist noch nicht erreichbar' const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' } const fontLog = [] @@ -63,19 +72,42 @@ function serve() { } const problems = [] +const findings = [] // Befunde: Abweichungen, die an der App liegen (nicht am Prüfstand); sie lassen den Lauf nicht scheitern, stehen aber im Bericht const errors = [] const checks = {} const metrics = {} const shots = [] +const perScenario = {} // Name -> { checks, failed } +let current = '' // das laufende Szenario (für die Zählung) +let lastPage = null // die zuletzt geöffnete Seite: bricht ein Szenario ab, wird von hier aus der Zustand im Bild festgehalten (abbruch-.png) +let openContexts = [] // Kontexte des laufenden Szenarios (bei einem Abbruch schließt der Läufer sie) function check(name, ok, detail) { checks[name] = ok ? 'ok' : 'FEHLER' + (detail === undefined ? '' : ' ' + JSON.stringify(detail)) - if (!ok) problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))) + const entry = perScenario[current] || (perScenario[current] = { checks: 0, failed: 0 }) + entry.checks += 1 + if (!ok) { + entry.failed += 1 + problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))) + } +} + +// Wie check, aber für einen Befund an der App: bei einem Verstoß kein Fehlschlag des Laufs, sondern ein Eintrag unter „befunde“ (JSON-Zeile und Standardfehlerausgabe). +// Wird der Befund in der App behoben, schlägt der Aufruf nie an; dann kann aus befund wieder check werden. +function befund(name, ok, detail) { + checks[name] = ok ? 'ok' : 'BEFUND' + (detail === undefined ? '' : ' ' + JSON.stringify(detail)) + const entry = perScenario[current] || (perScenario[current] = { checks: 0, failed: 0 }) + entry.checks += 1 + if (!ok) { + entry.befunde = (entry.befunde || 0) + 1 + findings.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))) + } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) -let lastPage = null // die zuletzt geöffnete Seite: bricht ein Szenario ab, wird von hier aus der Zustand im Bild festgehalten (abbruch-.png) const textOf = async (loc) => ((await loc.textContent()) || '').replace(/\s+/g, ' ').trim() +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') async function shoot(page, name, options = {}) { const file = path.join(out, name) @@ -83,450 +115,25 @@ async function shoot(page, name, options = {}) { shots.push(file) } -// Neuer Kontext mit Fehlersammlung; rAF wird mitgezählt (eine Schleife = höchstens ein wartender Rückruf) +// Neuer Kontext mit Fehlersammlung (pageerror, console.error, fehlgeschlagene und 4xx/5xx-Anfragen) async function newPage(browser, label, contextOptions) { - const context = await browser.newContext(contextOptions) - await context.addInitScript(() => { - const raf = window.requestAnimationFrame.bind(window) - const caf = window.cancelAnimationFrame.bind(window) - const waiting = new Set() - window.__raf = { waiting, calls: 0 } - window.requestAnimationFrame = (cb) => { - window.__raf.calls += 1 - const id = raf((t) => { waiting.delete(id); cb(t) }) - waiting.add(id) - return id - } - window.cancelAnimationFrame = (id) => { waiting.delete(id); caf(id) } - }) + const context = await browser.newContext({ deviceScaleFactor: 1, ...contextOptions }) + openContexts.push(context) const page = await context.newPage() lastPage = page page.setDefaultTimeout(20000) page.on('pageerror', (e) => errors.push(label + ' pageerror: ' + String(e))) page.on('console', (m) => { if (m.type() === 'error') errors.push(label + ' console.error: ' + m.text()) }) page.on('requestfailed', (r) => errors.push(label + ' requestfailed: ' + r.url())) - page.on('response', (r) => { - if (r.url().endsWith(FONT_URL)) metrics.fontStatus = r.status() - if (r.status() >= 400) errors.push(label + ' http ' + r.status() + ': ' + r.url()) - }) + page.on('response', (r) => { if (r.status() >= 400) errors.push(label + ' http ' + r.status() + ': ' + r.url()) }) return { context, page } } -async function ready(page, url) { - await page.goto(url) - await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) - await page.evaluate(() => document.fonts.load('600 12px "Pixelify Sans"')) - await page.waitForFunction(() => window.__wk && window.__wk.stage.current && window.__wk.stage.current.stats().frames > 0) - await page.evaluate(() => document.fonts.ready) -} - -const stats = (page) => page.evaluate(() => window.__wk.stage.current.stats()) -const topOfPage = (page) => page.evaluate(() => window.scrollTo(0, 0)) - -async function pickFromList(page, id, name) { - await page.locator(`[data-wk-worker="${id}"]`).click() - await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, name) -} - -// ---------- Szenario A: Desktop 1280 x 900 ---------- - -async function desktop(browser, base) { - const { context, page } = await newPage(browser, 'desktop', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) - const t0 = Date.now() - await ready(page, base + '/index.html') - await sleep(500) - await topOfPage(page) - - // 1. Schrift: geladen, Status 200, document.fonts.check - check('schrift geladen', await page.evaluate(() => document.fonts.check('600 12px "Pixelify Sans"'))) - check('schrift mit 200 geliefert', metrics.fontStatus === 200, metrics.fontStatus) - check('keine Warnzeile ohne ?halted=1', (await page.locator('.msg.err[role="alert"]').count()) === 0) - - // a) erstes Bild: Hochhaus komplett (Bühne mindestens 560 px breit, Zoom 1) - const box = await page.locator('.wk-canvas').boundingBox() - const first = await stats(page) - metrics.desktopStage = { width: Math.round(box.width), height: Math.round(box.height), top: Math.round(box.y), zoom: first.zoom } - check('desktop: ganzes Hochhaus im Bild (Zoom 1, Bühne >= 560 px, Unterkante im Fenster)', first.zoom === 1 && box.width >= 560 && box.y + box.height <= 900, metrics.desktopStage) - await shoot(page, 'desktop-start.png') - - // 2. 19 Knöpfe; Klick auf jeden zeigt den Namen aus ROLES und den vollen Modellnamen - check('19 Knöpfe', (await page.locator('[data-wk-worker]').count()) === 19) - const wrong = [] - for (const r of roster) { - await pickFromList(page, r.workerId, r.name) - const name = await textOf(page.locator('.wk-name')) - const chip = await textOf(page.locator('.wk-chip')) - if (name !== r.name || chip !== MODEL[r.model]) wrong.push({ id: r.workerId, name, chip }) - } - check('Klick auf jeden Knopf: Name und Modell-Chip stimmen', wrong.length === 0, wrong) - - // 3. sechs Figuren auf der Leinwand anklicken - await page.locator('button[aria-label="Karte schließen"]').click() - const hits = [] - for (const id of ['otto', 'kim', 'rita', 'bea', 'bob', 'arne']) { - await topOfPage(page) - const pos = await page.evaluate((who) => window.__wk.stage.current.locate(who), id) - const cb = await page.locator('.wk-canvas').boundingBox() - if (!pos) { hits.push({ id, locate: null }); continue } - await page.mouse.click(cb.x + pos.x, cb.y + pos.y) - const want = roster.find((r) => r.workerId === id).name - let got = '' - try { - await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, want, { timeout: 3000 }) - got = want - } catch { - got = await textOf(page.locator('.wk-name').first()).catch(() => '') - } - if (got !== want) hits.push({ id, want, got }) - } - check('Klick auf sechs Figuren trifft die Figur (locate)', hits.length === 0, hits) - - // 4. Spur von kim: javascript:-Zeile nur Text, https-Zeile ein sicherer Link - await pickFromList(page, 'kim', 'Kim') - await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12) - check('Spur hat 12 Zeilen', (await page.locator('.wk-trace li').count()) === 12) - check('kein Link mit javascript:', (await page.locator('.wk-trace a[href^="javascript:" i]').count()) === 0) - const fund = page.locator('.wk-trace a[href="https://example.org/fund"]') - check('https-Zeile ist ein Link', (await fund.count()) === 1) - if ((await fund.count()) === 1) { - check('https-Link: rel noopener noreferrer, target _blank', (await fund.getAttribute('rel')) === 'noopener noreferrer' && (await fund.getAttribute('target')) === '_blank') - } - const unsafeRow = page.locator('.wk-trace li', { hasText: 'Link mit Skript abgelehnt' }) - check('javascript:-Zeile steht als Text', (await unsafeRow.count()) === 1 && (await unsafeRow.locator('a').count()) === 0) - - // Karte offen (Bea: Sonnet-Chip, Spur) für das Bild - await pickFromList(page, 'bea', 'Bea') - await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12) - await topOfPage(page) - await shoot(page, 'desktop-karte.png') - await page.locator('button[aria-label="Karte schließen"]').click() - - // 5. Pausenknopf und Einstellungen - const pauseBtn = page.getByRole('button', { name: 'Alles anhalten', exact: true }) - await pauseBtn.click() - // Anhalten betrifft alle Mitarbeiter und fragt nach (P2-T21): erst „Anhalten“ im Dialog „Alles anhalten?“ pausiert - await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).getByRole('button', { name: 'Anhalten', exact: true }).click() - await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor() - check('Pausenknopf: „Fortsetzen“ nach dem Anhalten', true) - await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click() - await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor() - check('Pausenknopf: zurück auf „Alles anhalten“', true) - - await page.getByRole('button', { name: 'Einstellungen', exact: true }).click() - const dialog = page.getByRole('dialog', { name: 'Hustler-Einstellungen' }) - await dialog.waitFor() - await dialog.getByLabel('Tagesgrenze (USD)').waitFor() - check('Einstellungen: Dialog „Hustler-Einstellungen“ mit Feld „Tagesgrenze (USD)“', true) - check('Einstellungen ohne Notbremse: kein Block „Versand gestoppt“', (await page.locator('.wk-halt').count()) === 0) - await sleep(600) // der Dialog blendet ein - await shoot(page, 'desktop-einstellungen.png') - await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click() - await page.getByRole('dialog').waitFor({ state: 'detached' }) - check('Einstellungen: „Abbrechen“ schließt den Dialog', true) - - // 6. nach 20 s Skriptlauf: Bild, Zeichenzeit, Bilder pro Sekunde, eine einzige Schleife - await topOfPage(page) - const wait = 20000 - (Date.now() - t0) - if (wait > 0) await sleep(wait) - await shoot(page, 'desktop-nach-20s.png') - const timed = () => page.evaluate(() => ({ ...window.__wk.stage.current.stats(), at: performance.now() })) - // Messfenster von zwei Sekunden: Bilder je Sekunde und wartende rAF-Rückrufe (eine Schleife = höchstens einer) - const sample = async () => { - const a = await timed() - const raf = [] - for (let i = 0; i < 20; i += 1) { - raf.push(await page.evaluate(() => window.__raf.waiting.size)) - await sleep(100) - } - const b = await timed() - const seconds = (b.at - a.at) / 1000 - return { frames: b.frames - a.frames, seconds, fps: (b.frames - a.frames) / seconds, avgDrawMs: b.avgDrawMs, rafWaitingMax: Math.max(...raf), raf } - } - const busy = await sample() - const dbg = await page.evaluate(() => window.__wk.stage.current.debug()) - metrics.fpsDuringScript = Math.round(busy.fps * 10) / 10 - metrics.avgDrawMs = Math.round(busy.avgDrawMs * 100) / 100 - metrics.rafWaitingMax = busy.rafWaitingMax - metrics.pending = dbg.pending - if (!(busy.avgDrawMs <= 25)) console.warn('WARNUNG: avgDrawMs ' + busy.avgDrawMs.toFixed(2) + ' > 25 (software-gerendert)') - check('Schleife läuft (Bilder wachsen)', busy.frames > 0, busy.frames) - check('StrictMode: genau eine Schleife (höchstens ein wartender rAF-Rückruf)', busy.rafWaitingMax <= 1, busy.raf) - check('debug().pending >= 0', dbg.pending >= 0, dbg.pending) - // Nach dem Skript (Ende bei 45,8 s, letzter Zustandsabruf bei 45 s) kommen keine Daten mehr: dann zählt nur die Schleife. - // Beim Eintreffen von Zustand oder Ereignissen zeichnet die Szene sofort ein Bild extra, das ist keine Schleife. - const rest = 49000 - (Date.now() - t0) - if (rest > 0) await sleep(rest) - const idle = await sample() - metrics.fpsIdle = Math.round(idle.fps * 10) / 10 - metrics.fpsWindowSeconds = Math.round(idle.seconds * 100) / 100 - // FRAME_MS 33 ergibt höchstens 30,3 Bilder je Sekunde; ein Bild Messkante kommt dazu - check('Bilder pro Sekunde höchstens 30 (FRAME_MS 33, Ruhe nach dem Skript)', idle.fps <= 1000 / 33 + 1 / idle.seconds, { fps: metrics.fpsIdle, window: metrics.fpsWindowSeconds }) - check('Schleife läuft auch in Ruhe mit etwa 30 Bildern je Sekunde', idle.fps >= 20, idle.fps) - await context.close() -} - -// ---------- Szenario B: Blasen, Zeichen und Symbole bei doppelter Auflösung ---------- - -async function bubbles(browser, base) { - const { context, page } = await newPage(browser, 'blasen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }) - await ready(page, base + '/index.html') - await sleep(400) - await topOfPage(page) - const cb = await page.locator('.wk-canvas').boundingBox() - const clip = { x: cb.x, y: cb.y, width: cb.width, height: cb.height } - await shoot(page, 'desktop-hochhaus-2x.png', { clip }) - // Echte Engine, selbst gebaute Ereignisse: ’ ü % und die längste erlaubte Blase (20 Zeichen) gleichzeitig - const at = Date.now() - const make = (n, workerId, kind, text, extra) => ({ id: 'probe-' + n, ts: new Date(at + n).toISOString(), workerId, kind, text, link: null, to: null, prospectId: null, costUsd: 0, meta: null, ...extra }) - await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [ - make(1, 'otto', 'budget_warnung', 'Tagesbudget zu 80 % erreicht'), - make(2, 'bob', 'bau_start', 'Vorschau wird gebaut'), - make(3, 'theo', 'gelesen', 'Posteingang gelesen', { meta: { count: 123456789012 } }), - ]) - await sleep(700) - await shoot(page, 'desktop-blasen-2x.png', { clip }) - await sleep(2600) - await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [ - make(4, 'paul', 'pruefung_zurueck', 'Prüfung: Runde 1 zurück', { meta: { round: 1 } }), - make(5, 'lea', 'gelesen', 'Läden gelesen', { meta: { count: 123456789012 } }), - ]) - await sleep(700) - await shoot(page, 'desktop-blasen-2x-b.png', { clip }) - await context.close() -} - -// ---------- Szenario C: Handy 390 x 844 ---------- - -async function phone(browser, base) { - const { context, page } = await newPage(browser, 'handy', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) - await ready(page, base + '/index.html') - await sleep(500) - const width = await page.evaluate(() => document.documentElement.scrollWidth) - metrics.phoneScrollWidth = width - check('handy: kein seitliches Scrollen (scrollWidth <= 390)', width <= 390, width) - await page.locator('.wk-stage').scrollIntoViewIfNeeded() - await sleep(200) - const zoomBtn = page.getByRole('button', { name: 'Vergrößern', exact: true }) - check('handy: Zoom-Knöpfe sichtbar', (await zoomBtn.isVisible()) && (await page.getByRole('button', { name: 'Verkleinern', exact: true }).isVisible())) - await shoot(page, 'handy.png') - const z0 = (await stats(page)).zoom - await zoomBtn.click() - await sleep(300) - const z1 = (await stats(page)).zoom - metrics.phoneZoom = [Math.round(z0 * 100) / 100, Math.round(z1 * 100) / 100] - check('handy: „+“ erhöht den Zoom', z1 > z0, [z0, z1]) - await shoot(page, 'handy-zoom.png') - await shoot(page, 'handy-ganz.png', { fullPage: true }) - - // Auswahl aus der Liste (die Liste ist auf dem Handy die Alternative zur Leinwand): die Karte kommt ins Bild und nimmt den Fokus, - // „Karte schließen“ gibt den Fokus an den Listeneintrag zurück (Endprüfung P2) - const bea = page.locator('[data-wk-worker="bea"]') - await bea.scrollIntoViewIfNeeded() - await bea.click() - await page.waitForFunction(() => document.activeElement && document.activeElement.classList.contains('wk-name') && document.activeElement.textContent === 'Bea') - check('handy: nach der Auswahl in der Liste hat der Name in der Karte den Fokus', true) - await sleep(300) - const cardBox = await page.locator('.wk-card').boundingBox() - const view = await page.evaluate(() => window.innerHeight) - metrics.phoneCardTop = Math.round(cardBox.y) - check('handy: nach der Auswahl in der Liste liegt der Kartenanfang im Fenster', cardBox.y >= 0 && cardBox.y <= view - 120, { top: cardBox.y, view }) - await shoot(page, 'handy-karte.png') - await page.locator('button[aria-label="Karte schließen"]').click() - const back = await page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-wk-worker') : null)) - check('handy: „Karte schließen“ gibt den Fokus an den Listeneintrag zurück', back === 'bea', back) - await context.close() -} - -// ---------- Szenario D: weniger Bewegung ---------- - -async function reduced(browser, base) { - const { context, page } = await newPage(browser, 'reduziert', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) - await page.goto(base + '/index.html') - await page.emulateMedia({ reducedMotion: 'reduce' }) - await page.reload() - await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) - await sleep(2000) - const f2 = (await stats(page)).frames - await sleep(2300) - const f4 = (await stats(page)).frames - metrics.reducedFrames = [f2, f4] - check('weniger Bewegung: nach 2 s höchstens 5 Bilder', f2 <= 5, f2) - check('weniger Bewegung: die Schleife steht (Bildzahl bleibt)', f4 === f2, [f2, f4]) - await context.close() -} - -// ---------- Szenario E: Notbremse (H4) ---------- - -async function halted(browser, base) { - const { context, page } = await newPage(browser, 'notbremse', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) - await ready(page, base + '/index.html?halted=1') - await sleep(500) - await topOfPage(page) - const alerts = page.locator('.msg.err[role="alert"]') - await alerts.first().waitFor() - check('notbremse: genau eine rote Zeile', (await alerts.count()) === 1, await alerts.count()) - const line = await textOf(alerts.first()) - check('notbremse: Text der Warnzeile (Punkt des Grundes nicht doppelt)', line === HALT_LINE, line) - await shoot(page, 'desktop-gestoppt.png') - - await page.getByRole('button', { name: 'Einstellungen', exact: true }).click() - const dialog = page.getByRole('dialog', { name: 'Hustler-Einstellungen' }) - await dialog.waitFor() - const block = page.locator('.wk-halt') - await block.waitFor() - check('notbremse: Block „Versand gestoppt“ im Dialog', (await textOf(block.locator('.wk-set-title'))) === 'Versand gestoppt') - const release = block.getByRole('button', { name: 'Versand wieder freigeben', exact: true }) - check('notbremse: Knopf „Versand wieder freigeben“', (await release.count()) === 1) - await release.click() - await page.waitForFunction(() => document.querySelectorAll('.wk-halt').length === 0 && document.querySelectorAll('.msg.err[role="alert"]').length === 0, null, { timeout: 2000 }) - check('notbremse: binnen 2 s sind Block und Warnzeile weg', true) - const saved = await page.evaluate(() => JSON.stringify(window.__wk.api.saved)) - check('notbremse: gesendet wurde genau [{ sendingHalted: false }]', saved === '[{"sendingHalted":false}]', saved) - await context.close() -} - -// ---------- Szenario F: Ausfall, Wiederkehr, versteckter Tab, 401-Folge (Endprüfung P2) ---------- -// Das Board bekommt hier den echten API-Client (createWorkersApi) hinter einer Fetch-Weiche mit Ausfall-Schalter (entry.jsx, ?fault=). - -const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.' -const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.' -const callCount = (page) => page.evaluate(() => window.__wk.control.calls.length) -const freshMints = (page) => page.evaluate(() => window.__wk.control.jwts.filter(Boolean).length) -const setFault = (page, fault) => page.evaluate((f) => { window.__wk.control.fault = f }, fault) -// Tab verstecken oder zurückholen: Headless-Chromium kennt keinen versteckten Tab, darum werden die beiden Eigenschaften überschrieben -const setHidden = (page, hidden) => page.evaluate((h) => { - Object.defineProperty(document, 'hidden', { configurable: true, get: () => h }) - Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (h ? 'hidden' : 'visible') }) - document.dispatchEvent(new Event('visibilitychange')) -}, hidden) - -async function outage(browser, base) { - const { context, page } = await newPage(browser, 'ausfall', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) - - // 1. Dienst beim Öffnen nicht erreichbar (503 ohne JSON): Hinweis mit „Neu laden“ statt leerer Seite - await page.goto(base + '/index.html?fault=down') - const empty = page.locator('.empty') - await empty.waitFor() - const emptyText = await textOf(empty) - check('ausfall: 503 beim Öffnen: „Der Hustler-Dienst ist noch nicht erreichbar“ mit der Dienst-Meldung', emptyText.includes('Der Hustler-Dienst ist noch nicht erreichbar') && emptyText.includes(DOWN_TEXT), emptyText) - check('ausfall: im Ausfall kein Hochhaus', (await page.locator('.wk-canvas').count()) === 0) - await shoot(page, 'desktop-ausfall.png') - const asked = await callCount(page) - await page.getByRole('button', { name: 'Neu laden', exact: true }).click() - await page.waitForFunction((n) => window.__wk.control.calls.length > n, asked) - await sleep(300) - check('ausfall: „Neu laden“ fragt den Dienst erneut, der Hinweis bleibt', (await page.locator('.empty').count()) === 1) - - // 2. Dienst kehrt zurück: „Neu laden“ zeigt das Hochhaus - await setFault(page, null) - await page.getByRole('button', { name: 'Neu laden', exact: true }).click() - await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19 && window.__wk.stage.current.stats().frames > 0, null, { timeout: 8000 }) - check('ausfall: nach „Neu laden“ ist das Hochhaus mit 19 Figuren da', true) - check('ausfall: ohne Fehler keine Warnzeile', (await page.locator('.msg.warn').count()) === 0) - - // 3. Ausfall mitten im Betrieb: Warnzeile, die Daten bleiben stehen - await setFault(page, 'down') - const warn = page.locator('.msg.warn[role="status"]') - await warn.waitFor({ timeout: 9000 }) - const warnText = await textOf(warn) - check('ausfall: 503 im Betrieb: Warnzeile „Verbindung gestört: …“, das Hochhaus bleibt', warnText === 'Verbindung gestört: ' + DOWN_TEXT + ' Es wird weiter versucht.' && (await page.locator('[data-wk-worker]').count()) === 19, warnText) - await topOfPage(page) - await shoot(page, 'desktop-verbindung-gestoert.png') - - // 4. Versteckter Tab: keine Abrufe, keine Bilder, auch wenn der Dienst inzwischen wieder da ist; beim Zurückkehren sofort beides - await setHidden(page, true) - await setFault(page, null) - await sleep(400) - const hiddenCalls = await callCount(page) - const hiddenFrames = (await stats(page)).frames - await sleep(6500) // länger als der Takt der Ereignisse (5 s) - const stillCalls = await callCount(page) - const stillFrames = (await stats(page)).frames - metrics.hidden = { calls: [hiddenCalls, stillCalls], frames: [hiddenFrames, stillFrames] } - check('ausfall: versteckter Tab: keine Abrufe in 6,5 s', stillCalls === hiddenCalls, [hiddenCalls, stillCalls]) - check('ausfall: versteckter Tab: die Szene zeichnet nicht (höchstens 1 Bild Nachlauf)', stillFrames - hiddenFrames <= 1, [hiddenFrames, stillFrames]) - check('ausfall: versteckter Tab: die Warnzeile bleibt bis zur Rückkehr stehen', (await page.locator('.msg.warn').count()) === 1) - await setHidden(page, false) - await page.waitForFunction((n) => window.__wk.control.calls.length > n, stillCalls, { timeout: 1500 }) - check('ausfall: Rückkehr in den Tab fragt sofort ab (binnen 1,5 s, ohne den Takt abzuwarten)', true) - await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 3000 }) - check('ausfall: nach der Rückkehr verschwindet die Warnzeile', true) - const resumed = (await stats(page)).frames - await sleep(600) - check('ausfall: nach der Rückkehr zeichnet die Szene wieder', (await stats(page)).frames > resumed) - - // 5. Dauerhaftes 401 (Dienst lehnt das JWT ab): weiter abfragen, aber höchstens ein frisches JWT, Meldung „Anmeldung abgelaufen“ - await pickFromList(page, 'kim', 'Kim') // die Spur der Karte fragt ebenfalls ab - const before = await callCount(page) - const mintsBefore = await freshMints(page) - await setFault(page, 'session') - await page.waitForFunction((n) => window.__wk.control.calls.length >= n + 4, before, { timeout: 30000 }) - const mints = (await freshMints(page)) - mintsBefore - const sessionWarn = await textOf(page.locator('.msg.warn[role="status"]')) - metrics.session401 = { calls: (await callCount(page)) - before, freshMints: mints } - check('ausfall: dauerhaftes 401: höchstens ein frisches JWT trotz mehrerer Abrufe', mints <= 1, mints) - check('ausfall: dauerhaftes 401: Warnzeile nennt die abgelaufene Anmeldung', sessionWarn === 'Verbindung gestört: ' + SESSION_TEXT + ' Es wird weiter versucht.', sessionWarn) - await setFault(page, null) - await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 20000 }) // der Zustand wird nur alle 15 s geholt - check('ausfall: nimmt der Dienst das JWT wieder an, verschwindet die Warnzeile ohne neues Anmelden', true) - await context.close() - - // 6. Antwort kommt nie (tote Verbindung): nach der Zeitgrenze (im Prüfstand 1,5 s) erscheint der Hinweis, nicht ewiges „Wird geladen …“ - const hang = await newPage(browser, 'ausfall-haengt', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) - const t0 = Date.now() - await hang.page.goto(base + '/index.html?fault=hang') - await hang.page.locator('.loading').waitFor() - await hang.page.locator('.empty').waitFor({ timeout: 6000 }) - metrics.hangSeconds = Math.round((Date.now() - t0) / 100) / 10 - check('ausfall: hängende Verbindung endet nach der Zeitgrenze mit der Dienst-Meldung', (await textOf(hang.page.locator('.empty'))).includes(DOWN_TEXT), metrics.hangSeconds) - await hang.context.close() -} - -// ---------- P3: Freigabe-Pult, Interessenten-Panel, Anhalten mit Rückfrage ---------- -// Die Seite läuft als echte WorkersPage (?page=1) über den echten API-Client und die Fetch-Weiche (&fault=none); Anmeldung, Mitarbeiterliste -// und Sitzungs-JWT sind ersetzt (build.mjs). Zwischenablage und window.open sind ersetzt und halten fest, was die Oberfläche tut -// (window.__clip, window.__opened). Die Schalter der Skript-API (window.__wk.api.faults, .pultEmpty, .pultCalls) steuern 409, 400, Ausfall und Leerzustand. - -const T_MAIL = 'Anfrage: Vereinswebsite für den SV Namborn' -const T_PLAT = 'Kleinanzeigen: Website für Friseursalon gesucht' -const T_KOLL = 'Kleinanzeigen: Homepage für Handwerksbetrieb' -const T_UNS = 'Unsichere Antwort: bitte prüfen' -const T_ALT = 'freelancermap: WordPress-Relaunch' -const T_GES = 'Kleinanzeigen: Fotograf sucht Portfolio-Seite' -const T_ERL = 'Beschwerde über eine Vorschau' -const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1' -const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1' -const SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein' -const PULT_URL = '/index.html?page=1&fault=none' -const PULT_DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.' -const CONFLICT = 'Wird schon von einem Kollegen bearbeitet.' -const TOO_LONG = 'Der Text ist zu lang (höchstens 8000 Zeichen).' -const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.' -// Telefon: das Wort oder eine Nummer (+49 / Vorwahl mit 0, Trennzeichen, weitere Ziffern); Postleitzahlen und Beträge passen nicht darauf -const PHONE_WORD = /telefon|phone|\btel\.|handynummer|mobilnummer/i -const PHONE_NUMBER = /\+49|\b0\d{2,5}[\s/-]\d{3,}/ - -const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) -const rowOf = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: title }) }) -const rowTitles = (page) => page.locator('.wk-pult-title').allTextContents() -const finishedLine = (page) => page.locator('.wk-pult-filters ~ .msg') -const setPultFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value]) -const pultCalls = (page) => page.evaluate(() => window.__wk.api.pultCalls) -const resetStubs = (page) => page.evaluate(() => { window.__clip.length = 0; window.__opened.length = 0; window.__openMeta.length = 0 }) -const stubState = (page) => page.evaluate(() => ({ clip: window.__clip.slice(), opened: window.__opened.map((a) => Array.from(a)), meta: window.__openMeta.slice() })) -const waitTitles = (page, titles, timeout = 5000) => page.waitForFunction( - (want) => JSON.stringify(Array.from(document.querySelectorAll('.wk-pult-title')).map((el) => el.textContent)) === JSON.stringify(want), - titles, - { timeout }, -) -const activeProbe = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-probe') : null)) -const activeTag = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.tagName : null)) - -// Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden der Seite) -async function newPultPage(browser, label, contextOptions) { +// Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden): halten fest, was die Oberfläche tut (window.__clip, __opened, __openMeta). +// Die Zwischenablage antwortet erst nach 300 ms (wie ein Browser, der nach der Erlaubnis fragt): wer vor dem Öffnen auf das Kopieren wartet, öffnet später +// als 300 ms nach dem Klick und verliert die Nutzer-Geste. +async function newStubPage(browser, label, contextOptions) { const made = await newPage(browser, label, contextOptions) - // Die Zwischenablage antwortet erst nach 300 ms (wie ein Browser, der nach der Erlaubnis fragt): wer vor dem Öffnen auf das Kopieren - // wartet, öffnet später als 300 ms nach dem Klick und verliert die Nutzer-Geste. window.open hält Zeit und Geste fest. await made.context.addInitScript(() => { window.__clip = [] window.__opened = [] @@ -546,461 +153,721 @@ async function newPultPage(browser, label, contextOptions) { return made } -// Reiter „Freigabe“ per Deep-Link öffnen und warten, bis die vier wartenden Einträge und die Mitarbeiternamen da sind -async function openPult(page, base) { - await page.goto(base + PULT_URL + '&tab=freigabe') - await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4) - await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin')) - await page.evaluate(() => document.fonts.ready) +const settleFonts = (page) => page.evaluate(() => Promise.all([ + document.fonts.load('600 12px "Pixelify Sans"'), + document.fonts.load('12px "Press Start 2P"'), +]).then(() => document.fonts.ready)) + +// Seite laden und warten, bis das Haus mit allen 19 Figuren steht +async function ready(page, url) { + await page.goto(url) + await page.waitForFunction(() => document.querySelectorAll('[data-hk-hustler]').length === 19) + await settleFonts(page) } -// ---------- Szenario G: Freigabe (Desktop 1280 x 900) ---------- +const apiOf = (page, key) => page.evaluate((k) => JSON.parse(JSON.stringify(window.__wk.api[k])), key) +const setFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value]) +const widthOf = (page) => page.evaluate(() => ({ + scroll: document.scrollingElement.scrollWidth, + inner: window.innerWidth, + body: document.body.scrollWidth, + root: (() => { const el = document.querySelector('.hk-hochhaus, .hk-pult, .hk-keller'); return el ? { scroll: el.scrollWidth, client: el.clientWidth } : null })(), +})) +const noSideScroll = (w) => w.scroll <= w.inner && w.body <= w.inner && (w.root === null || w.root.scroll <= w.root.client) +const rectOf = (loc) => loc.evaluate((el) => { const b = el.getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height), r: Math.round(b.right), b: Math.round(b.bottom) } }) +// Kamera der Hochhaus-Welt aus dem Stil der Bühne: transform: translate(x px, y px) scale(s) +const cameraOf = (page) => page.evaluate(() => { + const el = document.querySelector('.hk-buehne') + const m = el ? /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/.exec(el.style.transform) : null + return m ? { x: Number(m[1]), y: Number(m[2]), s: Number(m[3]) } : null +}) +const focusInfo = (page) => page.evaluate(() => { + const el = document.activeElement + if (!el) return { tag: null } + return { + tag: el.tagName, + hustler: el.getAttribute('data-hk-hustler'), + zeile: el.getAttribute('data-hk-zeile'), + label: el.getAttribute('aria-label'), + pause: el.hasAttribute('data-hk-pause'), + role: el.getAttribute('role'), + cls: String(el.className || '').slice(0, 60), + text: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60), + inDialog: Boolean(el.closest('[role="dialog"]')), + } +}) -async function freigabe(browser, base) { - const { context, page } = await newPultPage(browser, 'freigabe', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) +// ---------- Szenario A: Hochhaus am Desktop (1280 x 720 und 1920 x 1080) ---------- - // 1. Reiterleiste: erst ab zwei Reitern, Start auf „Hochhaus“, Wechsel auf „Freigabe“ - await page.goto(base + PULT_URL) - await page.getByRole('tab').first().waitFor() - await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) - const tabNames = (await page.getByRole('tab').allTextContents()).map((t) => t.trim()) - check('freigabe: Reiterleiste mit „Hochhaus“, „Freigabe“ und „Bot-Leads“', same(tabNames, ['Hochhaus', 'Freigabe', 'Bot-Leads']), tabNames) - check('freigabe: Start auf „Hochhaus“ (Hochhaus mit 19 Figuren, Reiter gewählt)', (await page.getByRole('tab', { name: 'Hochhaus', exact: true }).getAttribute('aria-selected')) === 'true') - await page.getByRole('tab', { name: 'Freigabe', exact: true }).click() - await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4) - await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin')) - await page.evaluate(() => document.fonts.ready) - check('freigabe: nach dem Klick ist „Freigabe“ gewählt und das Hochhaus weg', (await page.getByRole('tab', { name: 'Freigabe', exact: true }).getAttribute('aria-selected')) === 'true' && (await page.locator('[data-wk-worker]').count()) === 0) +async function hochhaus(browser, base) { + for (const [w, h] of [[1280, 720], [1920, 1080]]) { + const tag = `${w}x${h}` + const { context, page } = await newPage(browser, 'hochhaus-' + tag, { viewport: { width: w, height: h } }) + await ready(page, base + '/index.html') + await sleep(700) + const m = await page.evaluate(() => { + const box = (el) => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, r: b.right, b: b.bottom, w: b.width, h: b.height } } + const sicht = box(document.querySelector('.hk-sicht')) + const inside = (b) => b.x >= sicht.x - 2 && b.y >= sicht.y - 2 && b.r <= sicht.r + 2 && b.b <= sicht.b + 2 + const figuren = [...document.querySelectorAll('[data-hk-hustler]')].map((el) => { + const b = box(el) + const sprite = el.firstElementChild + return { id: el.getAttribute('data-hk-hustler'), tag: el.tagName, type: el.getAttribute('type'), label: el.getAttribute('aria-label') || '', w: Math.round(b.w), h: Math.round(b.h), inside: inside(b), sprite: sprite ? sprite.className : '' } + }) + const text = (sel) => [...document.querySelectorAll(sel)].map((el) => el.textContent.replace(/\s+/g, ' ').trim()) + return { + sicht, + haus: inside(box(document.querySelector('.hk-haus-bild'))), + kopf: { ...box(document.querySelector('.hk-kopf')), betraege: text('.hk-kopf-betrag'), zahlen: [...document.querySelectorAll('.hk-kopf-zahl')].map((z) => z.lastElementChild.textContent.trim()) }, + figuren, + grauRaeume: [...document.querySelectorAll('[class*="hk-raum-grau-"]')].map((el) => el.className.replace('hk-raum-grau-', '')).sort(), + zettel: text('.hk-zettel'), + briefkasten: text('.hk-briefkasten'), + tresor: text('.hk-schildtext-tresor'), + keller: text('.hk-schildtext-keller'), + meldungen: document.querySelectorAll('.hk-meldung').length, + dialoge: document.querySelectorAll('[role="dialog"]').length, + fonts: { pixelify: document.fonts.check('600 12px "Pixelify Sans"'), pressStart: document.fonts.check('12px "Press Start 2P"') }, + } + }) + const camera = await cameraOf(page) + metrics['hochhaus ' + tag] = { sicht: { w: Math.round(m.sicht.w), h: Math.round(m.sicht.h) }, zoom: camera && Number(camera.s.toFixed(3)), kopf: { h: Math.round(m.kopf.h) } } - // 2. Filter und Liste - const filterButtons = page.locator('.wk-pult-filters .seg button') - check('freigabe: Filter „Wartet“, „Veraltet“, „Erledigt“, „Wartet“ gedrückt', same(await filterButtons.allTextContents(), ['Wartet', 'Veraltet', 'Erledigt']) && same(await filterButtons.evaluateAll((els) => els.map((el) => el.getAttribute('aria-pressed'))), ['true', 'false', 'false'])) - const first = await rowTitles(page) - check('freigabe: „Wartet“ zeigt die vier Einträge, neueste zuerst', same(first, [T_MAIL, T_PLAT, T_KOLL, T_UNS]), first) - - const mail = rowOf(page, T_MAIL) - const plat = rowOf(page, T_PLAT) - const koll = rowOf(page, T_KOLL) - const uns = rowOf(page, T_UNS) - const buttonNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim())) - - const platMeta = await textOf(plat.locator('.wk-pult-meta')) - check('freigabe: Plattform-Zeile: Alter, „läuft in 20 Std. ab“, Ort und Score', platMeta.includes('vor 5 Std.') && platMeta.includes('läuft in 20 Std. ab') && platMeta.includes('67655 Kaiserslautern · Score 72'), platMeta) - check('freigabe: Plattform-Zeile: Art-Badge „Antwort auf Plattform-Anfrage“', (await textOf(plat.locator('.wk-pult-head'))).includes('Antwort auf Plattform-Anfrage')) - const link = plat.getByRole('link', { name: `Anzeige öffnen: ${T_PLAT}`, exact: true }) - check('freigabe: „Anzeige öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await link.getAttribute('href')) === HAIR_URL && (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer') - check('freigabe: Plattform-Zeile (offen): Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen (P4), Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(plat)) - const mailTo = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href') - check('freigabe: Mail-Zeile: Link „E-Mail schreiben“ mit mailto-Adresse, Betreff und Text', typeof mailTo === 'string' && mailTo.startsWith('mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=Guten%20Tag%20Herr%20Beispiel'), mailTo) - check('freigabe: Mail-Zeile: kein „Kopieren & öffnen“ (Antwort geht per Mailprogramm)', (await mail.getByRole('button', { name: 'Kopieren & öffnen' }).count()) === 0) - await mail.getByLabel('Entwurf der Antwort').fill('Neuer Text für die Mail.') - const mailEdited = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href') - check('freigabe: Mail-Zeile: der Mail-Link trägt den bearbeiteten Text aus dem Feld', mailEdited === 'mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=' + encodeURIComponent('Neuer Text für die Mail.'), mailEdited) - check('freigabe: Kollegen-Zeile: „übernommen von Justin“, kein „Ich kümmere mich“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin') && (await koll.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0) - check('freigabe: Zeile ohne Antwort („unsicher“): nur Ich kümmere mich, Erledigt, Verwerfen, kein Textfeld', same(await buttonNames(uns), ['Ich kümmere mich', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await buttonNames(uns)) - // Wiederkehrende Bedienelemente tragen den Titel der Zeile im Namen (Screenreader-Liste), das sichtbare Wort steht vorn - const repeated = await page.evaluate(() => ({ - details: [...document.querySelectorAll('.wk-pult-meta button.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]), - sources: [...document.querySelectorAll('.wk-pult-meta a.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]), - drafts: [...document.querySelectorAll('textarea.wk-pult-draft')].map((el) => el.getAttribute('aria-label')), - })) - const unique = (list) => new Set(list).size === list.length - check('freigabe: „Details“, „Anzeige öffnen“ und Entwurfsfeld haben je Zeile einen eigenen Namen (sichtbares Wort vorn)', - repeated.details.length > 1 && repeated.details.every(([text, name]) => text === 'Details' && typeof name === 'string' && name.startsWith('Details zu ')) && unique(repeated.details.map((d) => d[1])) - && repeated.sources.length > 1 && repeated.sources.every(([text, name]) => text === 'Anzeige öffnen' && typeof name === 'string' && name.startsWith('Anzeige öffnen: ')) && unique(repeated.sources.map((d) => d[1])) - && repeated.drafts.length > 1 && repeated.drafts.every((name) => typeof name === 'string' && name.startsWith('Entwurf der Antwort: ')) && unique(repeated.drafts), repeated) - await topOfPage(page) - await shoot(page, 'desktop-freigabe-wartet.png') - await shoot(page, 'desktop-freigabe-wartet-ganz.png', { fullPage: true }) - - // 3. Kollege hat schon übernommen: Kopieren & öffnen kopiert und öffnet, der Dienst antwortet 409, die Zeile zeigt den Namen - await koll.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click() - const kollNote = koll.locator('.msg') - await kollNote.waitFor() - const kollText = await textOf(kollNote) - check('freigabe: 409 beim Übernehmen: Hinweis „Wird schon von Justin bearbeitet.“ (zusammen mit „Kopiert und geöffnet.“)', kollText === 'Kopiert und geöffnet. Wird schon von Justin bearbeitet.', kollText) - check('freigabe: 409-Hinweis ist eine rote Zeile mit role=alert', (await kollNote.getAttribute('class')).includes('err') && (await kollNote.getAttribute('role')) === 'alert') - const kollStub = await stubState(page) - check('freigabe: Kollegen-Zeile: Text kopiert und Anzeige geöffnet (trotz 409)', kollStub.clip.length === 1 && kollStub.clip[0].startsWith('Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage') && same(kollStub.opened, [[WEBER_URL, '_blank', 'noopener,noreferrer']]), kollStub) - check('freigabe: Kollegen-Zeile bleibt „übernommen von Justin“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin')) - await shoot(page, 'desktop-freigabe-409-kollege.png') - await resetStubs(page) - - // 4. Kopieren & öffnen mit bearbeitetem Text: kopiert den Text aus dem Feld, öffnet die Anzeige, übernimmt den Eintrag für mich - const area = plat.getByLabel('Entwurf der Antwort') - const draft = await area.inputValue() - const edited = draft + '\nPS: Bearbeitet im Prüfstand.' - await area.fill(edited) - await plat.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click() - const platNote = plat.locator('.msg') - await platNote.waitFor() - check('freigabe: Kopieren & öffnen: Meldung „Kopiert und geöffnet.“ (grün, role=status)', (await textOf(platNote)) === 'Kopiert und geöffnet.' && (await platNote.getAttribute('class')).includes('ok') && (await platNote.getAttribute('role')) === 'status', await textOf(platNote)) - const platStub = await stubState(page) - check('freigabe: Kopieren & öffnen kopiert den bearbeiteten Text aus dem Feld', same(platStub.clip, [edited]), platStub.clip) - check('freigabe: Kopieren & öffnen öffnet genau die Anzeige im neuen Tab (noopener,noreferrer)', same(platStub.opened, [[HAIR_URL, '_blank', 'noopener,noreferrer']]), platStub.opened) - check('freigabe: Kopieren & öffnen öffnet im Zug des Klicks (binnen 150 ms, Nutzer-Geste aktiv), ohne die Zwischenablage abzuwarten', platStub.meta.length === 1 && platStub.meta[0].sinceClick < 150 && platStub.meta[0].active === true, platStub.meta) - await page.waitForFunction((t) => { - const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t)) - return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso') - }, T_PLAT) - const platCalls = (await pultCalls(page)).filter((c) => c.id === 'pult-plat') - check('freigabe: Kopieren & öffnen übernimmt den Eintrag (claim) und er steht „übernommen von Kenso“', platCalls.length >= 1 && platCalls[0].action === 'claim' && (await plat.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, platCalls) - check('freigabe: der bearbeitete Text bleibt nach dem Neuladen der Liste im Feld', (await area.inputValue()) === edited) - await shoot(page, 'desktop-freigabe-kopiert.png') - - // 5. Übernehmen: 409 vom Dienst (Schalter) zeigt den Satz, danach klappt es - await setPultFault(page, 'claim', { status: 409, message: CONFLICT }) - await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() - const mailNote = mail.locator('.msg') - await mailNote.waitFor() - check('freigabe: Übernehmen mit 409: der Satz des Dienstes steht in der Zeile', (await textOf(mailNote)) === CONFLICT && (await mailNote.getAttribute('role')) === 'alert', await textOf(mailNote)) - check('freigabe: nach dem 409 ist der Eintrag nicht übernommen (Knopf bleibt, kein „übernommen von“)', (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 1 && !(await textOf(mail.locator('.wk-pult-meta'))).includes('übernommen von')) - await shoot(page, 'desktop-freigabe-409-uebernehmen.png') - await setPultFault(page, 'claim', null) - await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() - await page.waitForFunction((t) => { - const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t)) - return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso') - }, T_MAIL) - check('freigabe: Übernehmen: „in Arbeit“ von mir (übernommen von Kenso), Meldung „Du kümmerst dich darum.“, Knopf weg', (await textOf(mail.locator('.msg'))) === 'Du kümmerst dich darum.' && (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, await textOf(mail.locator('.msg'))) - await shoot(page, 'desktop-freigabe-uebernommen.png') - - // 6. Als gesendet markieren: Fehler 400 mit deutschem Satz, dann der Erfolg mit dem bearbeiteten Text - await setPultFault(page, 'sent', { status: 400, message: TOO_LONG }) - await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click() - await plat.locator('.msg.err').waitFor() - check('freigabe: Fehler 400: der deutsche Satz des Dienstes steht in der Zeile (role=alert), die Zeile bleibt', (await textOf(plat.locator('.msg.err'))) === TOO_LONG && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await rowTitles(page)).includes(T_PLAT), await textOf(plat.locator('.msg.err'))) - await shoot(page, 'desktop-freigabe-400.png') - await setPultFault(page, 'sent', null) - await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click() - await finishedLine(page).first().waitFor() - check('freigabe: gesendet: Meldung oben „…“ als gesendet markiert. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T_PLAT}“ als gesendet markiert.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && !(await rowTitles(page)).includes(T_PLAT), await textOf(finishedLine(page).first())) - const sentCall = (await pultCalls(page)).filter((c) => c.id === 'pult-plat' && c.action === 'sent').pop() - check('freigabe: gesendet schickt den bearbeiteten Text (body.text)', sentCall !== undefined && sentCall.body && sentCall.body.text === edited, sentCall) - await shoot(page, 'desktop-freigabe-gesendet.png') - - // 7. Erledigt (Art ohne Antwort) - await uns.getByRole('button', { name: 'Erledigt', exact: true }).click() - await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ erledigt.`, T_UNS) - check('freigabe: Erledigt: Meldung oben, die Zeile ist weg', !(await rowTitles(page)).includes(T_UNS), await rowTitles(page)) - - // 8. Verwerfen: erster Klick fragt nach, die Frage verfällt nach 4 s, zwei Klicks verwerfen; die Oberfläche schickt keinen Grund - const discardBtn = mail.locator('.wk-pult-actions').getByRole('button', { name: /^(Verwerfen|Wirklich verwerfen\?)$/ }) - await discardBtn.click() - check('freigabe: Verwerfen: erster Klick fragt „Wirklich verwerfen?“, noch keine Aktion am Dienst', (await textOf(discardBtn)) === 'Wirklich verwerfen?' && (await pultCalls(page)).filter((c) => c.action === 'discard').length === 0) - await shoot(page, 'desktop-freigabe-verwerfen-frage.png') - await sleep(4400) - check('freigabe: Verwerfen: die Rückfrage verfällt nach 4 s (wieder „Verwerfen“)', (await textOf(discardBtn)) === 'Verwerfen') - await discardBtn.click() - await discardBtn.click() - await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ verworfen.`, T_MAIL) - const discards = (await pultCalls(page)).filter((c) => c.action === 'discard') - check('freigabe: Verwerfen: genau ein Aufruf, Meldung oben „…“ verworfen., Zeile weg', discards.length === 1 && discards[0].id === 'pult-mail' && !(await rowTitles(page)).includes(T_MAIL), discards) - check('freigabe: Verwerfen schickt keinen Grund (Body {}); die Oberfläche hat kein Feld dafür', same(discards[0] && discards[0].body, {}), discards[0]) - const afterAll = await rowTitles(page) - check('freigabe: „Wartet“ zeigt am Ende nur den Eintrag des Kollegen', same(afterAll, [T_KOLL]), afterAll) - - // 9. Filter „Veraltet“, „Erledigt“ - await filterButtons.nth(1).click() - await waitTitles(page, [T_ALT]) - check('freigabe: „Veraltet“ zeigt den veralteten Eintrag mit Badge „veraltet“', (await textOf(rowOf(page, T_ALT).locator('.wk-pult-head'))).includes('veraltet') && (await filterButtons.nth(1).getAttribute('aria-pressed')) === 'true') - check('freigabe: Filterwechsel räumt die Meldung oben weg', (await finishedLine(page).count()) === 0) - check('freigabe: veralteter Eintrag lässt sich weiter bearbeiten (Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(rowOf(page, T_ALT))) - await shoot(page, 'desktop-freigabe-veraltet.png') - await rowOf(page, T_ALT).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() - await page.locator('.empty').waitFor() - const emptyOld = await textOf(page.locator('.empty')) - check('freigabe: Leerzustand „Veraltet“: „Nichts ist veraltet“ mit Hinweis', emptyOld.startsWith('Nichts ist veraltet') && emptyOld.includes('Einträge, die zu lange liegen'), emptyOld) - await shoot(page, 'desktop-freigabe-leer-veraltet.png') - - await filterButtons.nth(2).click() - await waitTitles(page, [T_MAIL, T_PLAT, T_UNS, T_GES, T_ERL]) - // Seit P4 nimmt eine gesendete Plattform-Antwort auch danach noch die Antwort des Kunden an: dort steht der eine Knopf „Antwort des Kunden einfügen“; - // verworfene und erledigte Zeilen sowie die gesendete Mail-Antwort haben keine Aktionen mehr - const doneActions = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({ - title: row.querySelector('.wk-pult-title').textContent, - actions: [...row.querySelectorAll('.wk-pult-actions > *')].map((el) => el.textContent.trim()), - }))) - check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), Aktion nur „Antwort des Kunden einfügen“ an den gesendeten Plattform-Antworten', - doneActions.length === 5 && doneActions.every((r) => same(r.actions, [T_PLAT, T_GES].includes(r.title) ? ['Antwort des Kunden einfügen'] : [])), doneActions) - const doneMeta = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({ - badge: [...row.querySelectorAll('.wk-pult-head .badge')].map((b) => b.textContent.trim()).filter((t) => ['Gesendet', 'Verworfen', 'Erledigt'].includes(t)), - meta: row.querySelector('.wk-pult-meta').textContent.trim(), - }))) - check('freigabe: „Erledigt“: jede Zeile nennt Ergebnis (Badge), Person und Zeitpunkt der Erledigung, nicht das Alter ab Anlage', - doneMeta.length === 5 && doneMeta.every((r) => r.badge.length === 1 && new RegExp(`^${r.badge[0]} von (Kenso|Justin) · (gerade eben|vor \\d+ (Min\\.|Std\\.|Tag|Tagen))`).test(r.meta)), doneMeta) - check('freigabe: „Erledigt“: Foto-Zeile „Gesendet von Kenso · vor 1 Tag“, Beschwerde „Erledigt von Justin · vor 3 Tagen“', - (await textOf(rowOf(page, T_GES).locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso · vor 1 Tag') && (await textOf(rowOf(page, T_ERL).locator('.wk-pult-meta'))).startsWith('Erledigt von Justin · vor 3 Tagen')) - await shoot(page, 'desktop-freigabe-erledigt.png') - - // 10. Leerzustand „Wartet“ und Ausfall des Pults mit „Neu laden“ - await page.evaluate(() => { window.__wk.api.pultEmpty = true }) - await filterButtons.nth(0).click() - await page.locator('.empty').waitFor() - const emptyWait = await textOf(page.locator('.empty')) - check('freigabe: Leerzustand „Wartet“: „Nichts wartet auf dich“ mit Hinweis', emptyWait.startsWith('Nichts wartet auf dich') && emptyWait.includes('Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'), emptyWait) - await shoot(page, 'desktop-freigabe-leer.png') - await setPultFault(page, 'pult', { status: 503, message: PULT_DOWN }) - await filterButtons.nth(1).click() - await page.getByRole('button', { name: 'Neu laden', exact: true }).waitFor() - const emptyDown = await textOf(page.locator('.empty')) - check('freigabe: Pult nicht erreichbar: Hinweis mit dem Satz des Dienstes und „Neu laden“', emptyDown.includes('Das Pult ist gerade nicht erreichbar') && emptyDown.includes(PULT_DOWN), emptyDown) - await shoot(page, 'desktop-freigabe-ausfall.png') - await setPultFault(page, 'pult', null) - await page.getByRole('button', { name: 'Neu laden', exact: true }).click() - await page.waitForFunction(() => (document.querySelector('.empty') || {}).textContent?.startsWith('Nichts ist veraltet')) - check('freigabe: „Neu laden“ nach dem Ausfall fragt erneut, der Leerzustand kehrt zurück', true) - await page.evaluate(() => { window.__wk.api.pultEmpty = false }) - await filterButtons.nth(0).click() - await waitTitles(page, [T_KOLL, T_ALT]) - check('freigabe: nach dem Ausfall und dem Leerzustand ist die Liste wieder da (Kollege und der eben übernommene veraltete Eintrag)', true) - await context.close() + const wide = await widthOf(page) + check(`hochhaus ${tag}: kein waagrechtes Scrollen`, noSideScroll(wide), wide) + check(`hochhaus ${tag}: das ganze Haus steht im Bild`, m.haus, m.sicht) + check(`hochhaus ${tag}: 19 Figuren-Knöpfe (echte