diff --git a/scripts/workers-harness/entry.jsx b/scripts/workers-harness/entry.jsx index c74cfd2..4a91c4a 100644 --- a/scripts/workers-harness/entry.jsx +++ b/scripts/workers-harness/entry.jsx @@ -6,7 +6,8 @@ // 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 -// (die Skript-API liefert dort eine leere Liste: gebaut wird der Reiter, gefüllt wird er im Prüfstand nicht). +// 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. @@ -25,7 +26,13 @@ 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 }) +const scripted = createScriptedApi({ + now: () => performance.now(), + base: Date.now(), + halted: params.get('halted') === '1', + pult: pageMode ? (params.get('p4') === '1' ? 'p4' : true) : false, + botleadsEmpty: params.get('botleads') === 'leer', +}) const stage = createRef() const fault = params.get('fault') const control = { fault: fault && fault !== 'none' ? fault : null, calls: [], jwts: [] } @@ -44,7 +51,8 @@ window.__wk = { stage, api: scripted, control } holder.api = api const tab = params.get('tab') -const entries = [pageMode && tab ? `/hustler?tab=${encodeURIComponent(tab)}` : '/hustler'] +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 c12a77f..fe301b9 100644 --- a/scripts/workers-harness/scripted-api.js +++ b/scripts/workers-harness/scripted-api.js @@ -11,6 +11,8 @@ // paste.js (Prüfreihenfolge, 400/404/409). Eigener Satz Daten mit `pult: 'p4'` (p4Fixtures); die P3-Daten bleiben unberührt. Die Schalter // `faults.reply`, `.approve-preview`, `.start-build` und `.paste` lassen eine Aktion mit { status, message } scheitern (409 mit deutschem // Satz, 413 „Anfrage zu groß.“, 400), `pultDelayMs` hält jede Pult-Aktion so lange auf (Knöpfe sind währenddessen gesperrt). +// Bot-Leads (P6): botleads({ room, q, page, id }) antwortet mit 60 festen Einträgen in der Form von GET /botleads (workers-dev-p6/src/api/botleads.js: +// items, total, pageSize 50, Telefon nie bei Heilberufen, gekürzte Stubs nur mit id); Schalter und Fixtures siehe „Bot-Leads (P6)“ weiter unten. import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js' import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js' @@ -468,14 +470,186 @@ function defaultSettings(halted) { } } +// ---------- Bot-Leads (P6) ---------- +// GET /botleads in der Form des Dienstes (workers-dev-p6/src/api/botleads.js, listBotLeads): { items, total, pageSize }, neueste zuerst, 50 je Seite. +// Der Speicher unten hält die Dokumente (Interessenten der Etage „Ohne Website“, status 'botlead') mit allem, was der Dienst kennt; nach außen geht wie +// im Dienst nur botleadEntry(): feste Auswahl der Felder, Telefon nur mit Quelle und nie bei einem Heilberuf (auch nicht aus einem Altbestand, in dem die +// Nummer noch steht; leere Zeichenkette statt null, wie im Dienst), Zeit mit „Z“. Gekürzte Stubs ('[gekürzt]', purgedAt) fehlen in der Liste und in +// total und kommen nur mit id (Ereignislink). 60 sichtbare Einträge (je 15 in den vier Räumen: zwei Seiten) und 3 gekürzte Stubs mit festen Kennungen. +// Besonderheiten: Raum aerzte und eine Physiotherapie im Raum selbst sind Heilberufe mit Altbestand-Telefon im Speicher (darf nie erscheinen); ein +// Eintrag hat eine http-Quelle, zwei haben eine feindliche Quelle (javascript:, Zugangsdaten in der Adresse), die der Dienst nie liefern würde und +// die Oberfläche trotzdem nicht als Link zeigen darf; ein langer Name und ein langer Grund ohne Umbruchstellen prüfen den Umbruch am Handy. +export const BOTLEAD_PAGE_SIZE = 50 +export const BOTLEAD_ROOM_KEYS = Object.freeze(['aerzte', 'handwerk', 'selbst', 'laden']) +export const BOTLEAD_IDS = Object.freeze({ + health: 'bl-aerzte-01', // Heilberuf mit Altbestand-Telefon im Speicher, Seite 1 + physio: 'bl-selbst-01', // Heilberuf außerhalb des Raums aerzte + http: 'bl-handwerk-02', // Quelle mit http + javascript: 'bl-laden-04', // Quelle javascript: + credentials: 'bl-selbst-03', // Quelle mit Zugangsdaten in der Adresse + long: 'bl-handwerk-01', // langer Name und langer Grund ohne Umbruchstellen + cut: 'bl-gekuerzt-01', // gekürzter Stub (nur mit id sichtbar) + cuts: Object.freeze(['bl-gekuerzt-01', 'bl-gekuerzt-02', 'bl-gekuerzt-03']), +}) +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 +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.', +]) +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' +// [Name, PLZ, Ort] je Raum, 15 Einträge; Fund i gehört zum Raum i % 4 und dort zum Eintrag i / 4 (neueste zuerst, Raum für Raum abwechselnd) +const BOTLEAD_TABLE = Object.freeze({ + aerzte: [ + ['Praxis Dr. Lindner', '67655', 'Kaiserslautern'], ['Zahnarztpraxis Brandt & Kollegen', '66849', 'Landstuhl'], ['Kieferorthopädie Dr. Yilmaz', '67433', 'Neustadt an der Weinstraße'], + ['Augenarztpraxis am Markt', '76829', 'Landau in der Pfalz'], ['Frauenarztpraxis Dr. Schaal', '67098', 'Bad Dürkheim'], ['Kinderarzt Dr. Weigand', '66953', 'Pirmasens'], + ['HNO-Praxis Dr. Reuter', '67227', 'Frankenthal'], ['Orthopädie Zentrum Westpfalz', '67697', 'Otterberg'], ['Hautarztpraxis Dr. Kessler', '66482', 'Zweibrücken'], + ['Heilpraktikerin Sabine Roth', '67547', 'Worms'], ['Tierarztpraxis Dr. Keller', '67346', 'Speyer'], ['Psychotherapie Fuchs', '67269', 'Grünstadt'], + ['Hausarztpraxis Dr. Baumann', '66869', 'Kusel'], ['Zahnärzte am Dom', '67346', 'Speyer'], ['Praxis für Allgemeinmedizin Dr. Heinz', '55411', 'Bingen'], + ], + handwerk: [ + [BOTLEAD_LONG_NAME, '67655', 'Kaiserslautern'], ['Elektro Hartmann GmbH', '67677', 'Enkenbach-Alsenborn'], ['Sanitär Weber', '66877', 'Ramstein-Miesenbach'], + ['Malerbetrieb Schneider', '67806', 'Rockenhausen'], ['Schreinerei Ott', '67722', 'Winnweiler'], ['Dachdeckerei Lang & Söhne', '66849', 'Landstuhl'], + ['Fliesen Becker', '67433', 'Neustadt an der Weinstraße'], ['Zimmerei Jung', '76829', 'Landau in der Pfalz'], ['Gartenbau Grünfinger', '66953', 'Pirmasens'], + ['Metallbau Schmitt', '66482', 'Zweibrücken'], ['Kfz-Werkstatt Mayer', '67227', 'Frankenthal'], ['Autolackiererei Roth', '67547', 'Worms'], + ['Bauunternehmen Kraus GmbH & Co. KG', '67547', 'Worms'], ['Heizungsbau Albrecht', '67098', 'Bad Dürkheim'], ['Glaserei Peters', '55543', 'Bad Kreuznach'], + ], + selbst: [ + ['Physiotherapie Moser', '67655', 'Kaiserslautern'], ['Steuerberatung Klein', '67433', 'Neustadt an der Weinstraße'], ['Fotografie Lichtblick', '66849', 'Landstuhl'], + ['Coaching Anja Berger', '67098', 'Bad Dürkheim'], ['Grafikdesign Pixelwerk', '66953', 'Pirmasens'], ['Architekturbüro Vogel', '67227', 'Frankenthal'], + ['Hundeschule Pfotenglück', '66869', 'Kusel'], ['Nachhilfe Lernwerk', '76829', 'Landau in der Pfalz'], ['Fahrschule Schreiber', '67547', 'Worms'], + ['Versicherungsmakler Böhm', '67346', 'Speyer'], ['Immobilien Kaufmann', '67269', 'Grünstadt'], ['Übersetzungsbüro Lingua', '55116', 'Mainz'], + ['Steuerberater Dr. Franke', '67677', 'Enkenbach-Alsenborn'], ['Hundepension Waldblick', '66482', 'Zweibrücken'], ['Yogastudio Atemraum', '67697', 'Otterberg'], + ], + laden: [ + ['Bäckerei Sommer', '67655', 'Kaiserslautern'], ['Metzgerei Kunz', '66849', 'Landstuhl'], ['Café Eselsburg', '67697', 'Otterberg'], + ['Restaurant Zum Hirschen', '67433', 'Neustadt an der Weinstraße'], ['Blumenladen Rosengarten', '76829', 'Landau in der Pfalz'], ['Friseursalon Haarscharf', '67098', 'Bad Dürkheim'], + ['Kosmetikstudio Belle', '66953', 'Pirmasens'], ['Boutique Mode & Mehr', '67227', 'Frankenthal'], ['Buchhandlung Seitenweise', '66482', 'Zweibrücken'], + ['Eisdiele Dolce Vita', '67547', 'Worms'], ['Geschenkeladen Wunderbar', '67346', 'Speyer'], ['Fahrradladen Kettenreaktion', '67269', 'Grünstadt'], + ['Weinstube Pfälzer Eck', '67433', 'Neustadt an der Weinstraße'], ['Pizzeria Da Marco', '67655', 'Kaiserslautern'], ['Kiosk am Bahnhof', '67806', 'Rockenhausen'], + ], +}) + +function botleadDocs() { + const docs = [] + const pad = (n) => String(n).padStart(2, '0') + for (let i = 0; i < 60; i += 1) { + const room = BOTLEAD_ROOM_KEYS[i % 4] + const k = Math.floor(i / 4) + const [name, postalCode, city] = BOTLEAD_TABLE[room][k] + const id = `bl-${room}-${pad(k + 1)}` + const health = room === 'aerzte' || id === BOTLEAD_IDS.physio + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) + const source = room === 'selbst' ? 'impressum' : room === 'laden' ? 'osm' : 'places' + let sourceUrl = room === 'selbst' ? `https://www.${slug}.example/impressum` : room === 'laden' ? `https://www.openstreetmap.org/node/${4710000 + i * 37}` : `https://www.11880.com/branchenbuch/${slug}` + if (id === BOTLEAD_IDS.http) sourceUrl = 'http://www.elektro-hartmann-alt.example/kontakt' + if (id === BOTLEAD_IDS.javascript) sourceUrl = 'javascript:alert(document.domain)' + if (id === BOTLEAD_IDS.credentials) sourceUrl = 'https://fotografie-lichtblick.example@evil.example/impressum' + let reason = BOTLEAD_REASONS[(i * 3 + k) % BOTLEAD_REASONS.length] + if (id === BOTLEAD_IDS.long) reason = BOTLEAD_LONG_REASON + // Telefon: die meisten mit Quelle, einer nur mit Nummer (ohne Quelle gilt sie als nicht belegt), einer ganz ohne; Heilberufe tragen im Speicher eine Altbestand-Nummer samt Quelle + const phoneStyle = health ? 'alt' : k % 5 === 4 ? 'ohneQuelle' : k % 6 === 5 ? 'keine' : 'mitQuelle' + docs.push({ + $id: id, + entrance: 'ohne_website', + status: 'botlead', + room, + name, + street: '', + postalCode, + city, + website: reason === BOTLEAD_REASONS[2] ? `https://www.${slug}.example/` : '', + score: 35 + ((i * 13) % 60), + botLeadReason: reason, + source, + sourceUrl, + foundAt: isoOf(BOTLEAD_ANCHOR_MS - i * BOTLEAD_STEP_MS - (i % 4) * 11 * 60 * 1000), + isHealth: health, + purgedAt: '', + phone: phoneStyle === 'keine' ? '' : `0${631 + (i % 5)} 555 ${String(100 + i * 7).padStart(4, '0')}`, + phoneSource: phoneStyle === 'mitQuelle' || phoneStyle === 'alt' ? (source === 'osm' ? 'osm' : 'impressum') : '', + }) + } + // Gekürzte Stubs wie nach archive/kuerzen.js: Name '[gekürzt]', Ort, Raum, Punkte und Grund bleiben, alles Persönliche ist leer + const cuts = [['handwerk', 'Kaiserslautern', 51], ['laden', 'Landau in der Pfalz', 44], ['aerzte', 'Worms', 62]] + cuts.forEach(([room, city, score], n) => { + docs.push({ + $id: BOTLEAD_IDS.cuts[n], entrance: 'ohne_website', status: 'botlead', room, name: '[gekürzt]', street: '', postalCode: '', city, website: '', score, + botLeadReason: BOTLEAD_REASONS[n], source: '', sourceUrl: '', foundAt: isoOf(BOTLEAD_ANCHOR_MS - (120 + n * 9) * 24 * 60 * 60 * 1000), isHealth: room === 'aerzte', + purgedAt: isoOf(BOTLEAD_ANCHOR_MS - (2 + n) * 24 * 60 * 60 * 1000), phone: '', phoneSource: '', + }) + }) + return docs +} + +// Ein Eintrag der Antwort: feste Auswahl der Felder (wie eintrag() im Dienst; nie E-Mail, Quelle der Adresse, Ansprechpartner, Kennung des Orts, Anfragetext) +function botleadEntry(doc) { + const health = Boolean(doc.isHealth) || doc.room === 'aerzte' + const withNumber = !health && doc.phoneSource !== '' + const found = Date.parse(doc.foundAt) + return { + id: doc.$id, + room: doc.room, + roomLabel: BOTLEAD_ROOM_LABEL[doc.room] ?? doc.room, + name: doc.name, + street: doc.street, + postalCode: doc.postalCode, + city: doc.city, + website: doc.website, + score: Number.isFinite(doc.score) ? doc.score : 0, + reason: doc.botLeadReason, + source: doc.source, + sourceUrl: doc.sourceUrl, + foundAt: Number.isNaN(found) ? doc.foundAt : new Date(found).toISOString(), + isHealth: health, + gekuerzt: Boolean(doc.purgedAt), + phone: withNumber ? doc.phone : '', + phoneSource: withNumber ? doc.phoneSource : '', + } +} + +// Suchtext und Bestand auf dieselbe Form bringen: klein, Umlaute als ae/oe/ue, Satzzeichen weg (wie normalizeName des Dienstes, ohne Rechtsformen) +const foldSearch = (value) => String(value ?? '').toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]+/g, ' ').trim() +const BOTLEAD_ID_FORM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +/** listBotLeads des Dienstes auf dem Speicher docs: { items, total, pageSize }. Ein unbekannter Raum wirft 400 „Unbekannter Raum.“. */ +export function listBotleads(docs, { room = '', q = '', page = 1, id = '' } = {}) { + const raum = room ?? '' + if (raum !== '' && !BOTLEAD_ROOM_KEYS.includes(raum)) throw Object.assign(new Error('Unbekannter Raum.'), { status: 400 }) + const kennung = typeof id === 'string' ? id : '' + if (kennung !== '') { + const doc = BOTLEAD_ID_FORM.test(kennung) ? docs.find((d) => d.$id === kennung) : undefined + const items = doc ? [botleadEntry(doc)] : [] + return { items, total: items.length, pageSize: BOTLEAD_PAGE_SIZE } + } + const words = foldSearch(String(q ?? '').slice(0, 100)).split(' ').filter(Boolean) + const hit = docs + .filter((d) => !d.purgedAt && (raum === '' || d.room === raum)) + .filter((d) => { const hay = foldSearch([d.name, d.city, d.botLeadReason, BOTLEAD_ROOM_LABEL[d.room]].join(' ')); return words.every((w) => hay.includes(w)) }) + .sort((x, y) => Date.parse(y.foundAt) - Date.parse(x.foundAt) || y.score - x.score || (x.$id < y.$id ? -1 : 1)) + const seite = Number.isInteger(page) && page >= 1 ? page : 1 + const from = (seite - 1) * BOTLEAD_PAGE_SIZE + return { items: hit.slice(from, from + BOTLEAD_PAGE_SIZE).map(botleadEntry), total: hit.length, pageSize: BOTLEAD_PAGE_SIZE } +} + /** * Skript-API für den Prüfstand. now() = Uhr in Millisekunden (nur die Differenz zum Aufruf von createScriptedApi zählt), * base = Zeitpunkt (ms) des Starts für die Zeitstempel der Ereignisse, halted = Bounce-Notbremse ist von Anfang an gesetzt (H4), * pult = das Freigabe-Pult und die Interessenten (P3) mit den festen Daten füllen (sonst ist das Pult leer); 'p4' nimmt statt dessen den - * Satz für die Gespräche und die Werkstatt (p4Fixtures). + * Satz für die Gespräche und die Werkstatt (p4Fixtures). botleadsEmpty = die Bot-Lead-Liste ist von Anfang an leer (Leerzustand ohne Filter). + * Bot-Leads (P6): botleads({ room, q, page, id }) antwortet wie GET /botleads des Dienstes (listBotleads oben). Schalter: `faults.botleads` ({ status, message }), + * `botleadsDelayMs` (Zahl oder Funktion (Abfrage) => ms), `botleadsEmpty`; `botleadCalls` hält die Abfragen fest, `botleadsHealthPhones` die Nummern, die im + * Speicher bei Heilberufen stehen und nie ausgeliefert werden dürfen. * Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam. */ -export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pult = false }) { +export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pult = false, botleadsEmpty = false }) { const start = now() const elapsed = () => Math.max(0, now() - start) let paused = false @@ -485,11 +659,13 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu const fx = pult === 'p4' ? p4Fixtures(base) : pult ? pultFixtures(base) : { items: [], prospects: {}, threads: {}, messages: {}, previews: {} } const pultItems = fx.items // je { status, message } oder null; die vier Schlüssel mit Bindestrich oder Namen der P4-Aktionen stehen ebenfalls hier - const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null, reply: null, 'approve-preview': null, 'start-build': null, paste: null } + const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null, reply: null, 'approve-preview': null, 'start-build': null, paste: null, botleads: null } const pastedBefore = new Set() // Interessent + Text: dieselbe eingefügte Antwort ist beim zweiten Mal „doppelt“ const pultCalls = [] const prospectCalls = [] const pauseCalls = [] + const botleadStore = botleadDocs() + const botleadCalls = [] // Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige const all = SCRIPT.map((s, i) => ({ @@ -621,6 +797,10 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu pultCalls, prospectCalls, pauseCalls, + botleadCalls, + botleadsHealthPhones: botleadStore.filter((d) => d.isHealth && d.phone !== '').map((d) => d.phone), + botleadsEmpty, + botleadsDelayMs: 0, // > 0 oder Funktion (Abfrage) => ms: die Antwort der Bot-Lead-Liste kommt erst nach so vielen Millisekunden pultEmpty: false, // true: das Pult ist leer (Leerzustand) pultDelayMs: 0, // > 0: jede Pult-Aktion antwortet erst nach so vielen Millisekunden (Knöpfe sind in der Zwischenzeit gesperrt) @@ -728,9 +908,14 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu return clone({ item }) }, - // Bot-Leads (P6): leere Liste; der Reiter wird im Prüfstand gebaut und gezeigt, aber nicht mit Daten gefüllt (workersHarness.test.mjs pinnt die leere Antwort) - async botleads() { - return { items: [], total: 0 } + // GET /botleads?room=&q=&page=&id=: Antwort { items, total, pageSize } wie der Dienst (listBotleads); leer bei botleadsEmpty. Abfragen landen in botleadCalls. + async botleads(query = {}) { + const { room = '', q = '', page = 1, id = '' } = query ?? {} + botleadCalls.push(clone({ room, q, page, id })) + const wait = typeof api.botleadsDelayMs === 'function' ? api.botleadsDelayMs({ room, q, page, id }) : api.botleadsDelayMs + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)) + if (faults.botleads) throw failure(faults.botleads) + return clone(listBotleads(api.botleadsEmpty ? [] : botleadStore, { room, q, page, id })) }, // GET /prospect/:id: { prospect (nur Felder der Whitelist), thread, messages, preview, reply }; Faden und Vorschau fehlen als null diff --git a/scripts/workers-harness/scripted-fetch.js b/scripts/workers-harness/scripted-fetch.js index f0940b9..9830969 100644 --- a/scripts/workers-harness/scripted-fetch.js +++ b/scripts/workers-harness/scripted-fetch.js @@ -6,6 +6,7 @@ // Satz) kommen als { error } mit diesem Status an, wie vom Dienst (auch 413 „Anfrage zu groß.“). Seit P4 laufen reply, approve-preview, // start-build und paste über dieselbe Route POST /pult/:id/. Ein Fehler mit status 0 aus der Skript-API bricht die Verbindung ab // (fetch wirft), damit die Meldung „Kann gesendet sein – Pult neu laden.“ (Zeitüberschreitung, D14) sichtbar wird. +// Seit P6 auch Bot-Leads (GET /botleads?room=&q=&page=&id=): die Route liest die Angaben wie der Dienst (fehlend = leer, page als Zahl, unlesbar = NaN). // control.calls sammelt jede Anfrage ({ method, path, jwt }), damit der Prüfstand zählen kann. const reply = (status, body) => ({ @@ -42,6 +43,10 @@ export function createScriptedFetch(api, control) { if (method === 'GET' && path === '/pult') return reply(200, await api.pult(parsed.searchParams.get('status') ?? undefined)) const action = /^\/pult\/([^/]+)\/([^/]+)$/.exec(path) if (method === 'POST' && action) return reply(200, await api.pultAction(decodeURIComponent(action[1]), action[2], body)) + if (method === 'GET' && path === '/botleads') { + const query = parsed.searchParams + return reply(200, await api.botleads({ room: query.get('room') ?? '', q: query.get('q') ?? '', page: Number(query.get('page') ?? ''), id: query.get('id') ?? '' })) + } const prospect = /^\/prospect\/([^/]+)$/.exec(path) if (method === 'GET' && prospect) return reply(200, await api.prospect(decodeURIComponent(prospect[1]))) return reply(404, { error: 'Unbekannter Pfad im Prüfstand.' }) diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs index 89f14c7..4626c2e 100644 --- a/scripts/workers-harness/shot.cjs +++ b/scripts/workers-harness/shot.cjs @@ -7,7 +7,8 @@ // 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-). Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der +// 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). 'use strict' @@ -1684,10 +1685,490 @@ async function handyP4(browser, base) { await context.close() } +// ---------- P6: Bot-Leads (Reiter „Bot-Leads“: Desktop 1280 x 900 und Handy 390 x 844) ---------- +// Daten der Skript-API (scripted-api.js, „Bot-Leads (P6)“): 60 sichtbare Einträge (je 15 in den vier Räumen; Seite 1 hat 50, Seite 2 hat 10), drei gekürzte +// Stubs (nur mit ?bot= zu sehen), Heilberufe mit Altbestand-Telefon im Speicher, eine http-Quelle und zwei feindliche Quellen (javascript:, Zugangsdaten). +// Die Seite läuft über den echten API-Client und die Fetch-Weiche (fault=none): Query, Fehlertexte und Zeitgrenzen sind die des Betriebs. Was die Oberfläche +// angefragt hat, steht in window.__wk.api.botleadCalls. Die Kennungen unten sind die aus BOTLEAD_IDS in scripted-api.js. +const BL_URL = '/index.html?page=1&fault=none&tab=botleads' +const BL_IDS = { health: 'bl-aerzte-01', cut: 'bl-gekuerzt-01', cutHealth: 'bl-gekuerzt-03' } // Heilberuf (Seite 1), gekürzter Handwerk-Stub, gekürzter Heilberuf-Stub +const BL_NAMES = { health: 'Praxis Dr. Lindner', physio: 'Physiotherapie Moser', http: 'Elektro Hartmann GmbH', javascript: 'Restaurant Zum Hirschen', credentials: 'Fotografie Lichtblick' } +const BL_HEADERS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden'] +const BL_ROOM_LABELS = ['Alle Räume', 'Ärzte & Praxen', 'Handwerk', 'Selbstständige', 'Läden & Gastro'] +const BL_HEALTH_PHONE = 'bewusst nicht gespeichert' +const BL_NO_PHONE = 'nicht verfügbar' +const BL_HINT = 'Reine Liste: keine Anrufe, kein Mailversand.' +const BL_SESSION = 'Anmeldung abgelaufen. Bitte neu anmelden.' +const BL_LABEL = (page, pages, count) => `Seite ${page} von ${pages} · ${count} ${count === 1 ? 'Eintrag' : 'Einträge'}` + +const blRows = (page) => page.locator('.wk-bl-table tbody tr') +const blCalls = (page) => page.evaluate(() => window.__wk.api.botleadCalls) +const blFootLabel = (page) => page.evaluate(() => { const el = document.querySelector('.wk-bl .table-foot > span'); return el ? el.textContent : null }) +const blWaitLabel = (page, label) => page.waitForFunction((want) => { const el = document.querySelector('.wk-bl .table-foot > span'); return Boolean(el) && el.textContent === want }, label) +// Fertig geladen: kein „Wird geladen …“ mehr und Tabelle, Leerzustand oder Fehlerzeile da +const blSettled = (page) => page.waitForFunction(() => { + const root = document.querySelector('.wk-bl') + return Boolean(root) && !root.querySelector('.loading') && Boolean(root.querySelector('table, .empty, .msg.err')) +}) +const blRead = (page) => page.evaluate(() => Array.from(document.querySelectorAll('.wk-bl-table tbody tr')).map((tr) => { + const c = tr.children + const title = c[0].querySelector('.cell-title') + const a = c[5].querySelector('a') + return { + title: title.childNodes[0] ? title.childNodes[0].textContent : '', + tag: title.querySelector('.wk-bl-tag') ? title.querySelector('.wk-bl-tag').textContent : null, + place: c[0].querySelector('.cell-sub') ? c[0].querySelector('.cell-sub').textContent : '', + room: c[1].textContent.trim(), + score: c[2].textContent.trim(), + reason: c[3].textContent.trim(), + phone: c[4].textContent.trim(), + source: c[5].textContent.trim(), + found: c[6].textContent.trim(), + link: a ? { href: a.getAttribute('href'), rel: a.getAttribute('rel'), target: a.getAttribute('target'), label: a.getAttribute('aria-label') } : null, + cut: tr.classList.contains('wk-bl-cut'), + current: tr.getAttribute('aria-current'), + } +})) +const blFocus = (page) => page.evaluate(() => { + const el = document.activeElement + return { tag: el ? el.tagName : null, text: el ? (el.textContent || '').trim().slice(0, 30) : null, inPager: Boolean(el && el.closest && el.closest('.wk-bl-pager')), onBody: el === document.body } +}) +const blDay = (text) => { const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text); return m ? Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : NaN } +const blTitle = (rows, title) => rows.find((r) => r.title === title) +const blSelectRoom = async (page, value) => { await page.getByLabel('Raum', { exact: true }).selectOption(value); await blSettled(page) } + +// Kontrast aller Texte im Bereich scope: Vordergrund und der wirksame Hintergrund (Ebenen der Vorfahren übereinander, auf Weiß) aus den berechneten Stilen, +// Deckkraft des Elements und seiner Vorfahren im Vordergrund; Verhältnis nach WCAG 2.x. Läuft im Browser. +function contrastProbe(scope) { + const parse = (css) => { + let m = /^rgba?\(([^)]+)\)$/.exec(css) + let scale = 1 + if (!m) { m = /^color\(srgb ([^)]+)\)$/.exec(css); scale = 255 } + if (!m) return null + const p = m[1].split(/[\s,/]+/).filter(Boolean).map((v) => (v.endsWith('%') ? parseFloat(v) / 100 : parseFloat(v))) + return { r: p[0] * scale, g: p[1] * scale, b: p[2] * scale, a: p.length > 3 ? p[3] : 1 } + } + const over = (top, bottom) => ({ r: top.r * top.a + bottom.r * (1 - top.a), g: top.g * top.a + bottom.g * (1 - top.a), b: top.b * top.a + bottom.b * (1 - top.a), a: 1 }) + const lum = (c) => { + const lin = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 } + return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b) + } + const out = [] + const root = document.querySelector(scope) + if (!root) return { error: 'kein Bereich ' + scope, items: out } + for (const el of [root, ...root.querySelectorAll('*')]) { + const own = Array.from(el.childNodes).filter((n) => n.nodeType === 3 && n.textContent.trim() !== '').map((n) => n.textContent.trim()).join(' ') + if (own === '') continue + const layers = [] + let opacity = 1 + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const cs = getComputedStyle(n) + const bg = parse(cs.backgroundColor) + if (bg === null) return { error: 'Hintergrund nicht lesbar: ' + cs.backgroundColor, items: out } + if (bg.a > 0) layers.push(bg) + opacity *= Number(cs.opacity) + } + let back = { r: 255, g: 255, b: 255, a: 1 } + for (const layer of layers.reverse()) back = over(layer, back) + const fgRaw = parse(getComputedStyle(el).color) + if (fgRaw === null) return { error: 'Farbe nicht lesbar: ' + getComputedStyle(el).color, items: out } + const fg = over({ ...fgRaw, a: fgRaw.a * opacity }, back) + const l1 = lum(fg) + const l2 = lum(back) + out.push({ text: own.slice(0, 40), tag: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(/\s+/)[0] : ''), ratio: Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100, color: getComputedStyle(el).color, background: 'rgb(' + [back.r, back.g, back.b].map(Math.round).join(', ') + ')' }) + } + return { error: null, items: out } +} +const blContrast = async (page, scope) => { + const report = await page.evaluate(contrastProbe, scope) + const worst = [...report.items].sort((a, b) => a.ratio - b.ratio) + return { error: report.error, count: report.items.length, min: worst.length ? worst[0].ratio : null, low: report.items.filter((i) => i.ratio < 4.5).map((i) => `${i.tag} „${i.text}“ ${i.ratio}:1 (${i.color} auf ${i.background})`) } +} + +// Wörter, die ein Zeilenumbruch mitten im Wort trennt (z. B. „Kaiserslauter“ + „n“): Wörter ohne Bindestrich, Schrägstrich, Punkt, Komma und Klammern, die der +// Browser über zwei Zeilen setzt (ein Bereich um das Wort hat dann mehr als ein Rechteck). Läuft im Browser über alle Textknoten im Bereich scope. +const blBrokenWords = (page, scope) => page.evaluate((selector) => { + const root = document.querySelector(selector) + const out = [] + if (!root) return { error: 'kein Bereich ' + selector, words: out, count: 0 } + let count = 0 + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent + for (const match of text.matchAll(/\S+/g)) { + const word = match[0] + if (word.length < 3 || /[-–/@.,;:()[\]]/.test(word)) continue + count += 1 + const range = document.createRange() + range.setStart(node, match.index) + range.setEnd(node, match.index + word.length) + if (range.getClientRects().length > 1) out.push(word) + } + } + return { error: null, words: out, count } +}, scope) + +// ---------- Szenario Q: Bot-Leads (Desktop 1280 x 900) ---------- + +async function botleads(browser, base) { + const { context, page } = await newPage(browser, 'botleads', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + + // 1. Der Reiter: sichtbar, nicht gewählt, mit Klick gewählt + await page.goto(base + '/index.html?page=1&fault=none') + await page.getByRole('tab').first().waitFor() + await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) + const tab = page.getByRole('tab', { name: 'Bot-Leads', exact: true }) + check('botleads: Reiter „Bot-Leads“ ist sichtbar und vor dem Klick nicht gewählt', (await tab.isVisible()) && (await tab.getAttribute('aria-selected')) === 'false') + await tab.click() + await blSettled(page) + await page.evaluate(() => document.fonts.ready) + check('botleads: nach dem Klick ist „Bot-Leads“ gewählt, das Hochhaus ist weg und die Liste da', (await tab.getAttribute('aria-selected')) === 'true' && (await page.locator('[data-wk-worker]').count()) === 0 && (await blRows(page).count()) > 0) + + // 2. Liste, Seite 1 + const heads = (await page.locator('.wk-bl-table thead th').allTextContents()).map((t) => t.trim()) + check('botleads: Spalten Betrieb, Raum, Punkte, Grund, Telefon, Quelle, Gefunden', same(heads, BL_HEADERS), heads) + const page1 = await blRead(page) + check('botleads: Seite 1 zeigt 50 Zeilen, die Seitenzeile „Seite 1 von 2 · 60 Einträge“', page1.length === 50 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60), { rows: page1.length, label: await blFootLabel(page) }) + check('botleads: Hinweis „Reine Liste: keine Anrufe, kein Mailversand.“ steht über der Liste', (await textOf(page.locator('.wk-bl > .wk-hint'))) === BL_HINT) + const pagerState = async () => ({ + prev: await page.getByRole('button', { name: 'Zurück', exact: true }).isDisabled(), + next: await page.getByRole('button', { name: 'Weiter', exact: true }).isDisabled(), + }) + check('botleads: auf Seite 1 ist „Zurück“ gesperrt und „Weiter“ frei', same(await pagerState(), { prev: true, next: false }), await pagerState()) + const days1 = page1.map((r) => blDay(r.found)) + check('botleads: neueste zuerst (Datum TT.MM.JJJJ, nie aufsteigend), erste Zeile ist der jüngste Fund', days1.every((d, i) => Number.isFinite(d) && (i === 0 || days1[i - 1] >= d)) && page1[0].title === BL_NAMES.health, { first: page1[0] }) + const bad = page1.filter((r) => /undefined|NaN|\[object|null/.test(Object.values(r).filter((v) => typeof v === 'string').join(' '))) + check('botleads: kein „undefined“, „NaN“ oder „null“ in den Zeilen', bad.length === 0, bad.slice(0, 2)) + check('botleads: gekürzte Einträge fehlen in der Liste (kein „[gekürzt]“, kein Kürzel, nicht in der Zählung)', page1.every((r) => r.title !== '[gekürzt]' && r.tag === null && !r.cut)) + const options = await page.getByLabel('Raum', { exact: true }).locator('option').allTextContents() + check('botleads: Raumfilter bietet alle Räume und die vier Räume', same(options, BL_ROOM_LABELS), options) + const settledCalls = (await blCalls(page)).length + await sleep(1300) + check('botleads: kein Polling (nach 1,3 s keine weitere Abfrage)', (await blCalls(page)).length === settledCalls, settledCalls) + const callFirst = (await blCalls(page))[0] + check('botleads: die erste Abfrage ist Raum leer, Suche leer, Seite 1', same(callFirst, { room: '', q: '', page: 1, id: '' }), callFirst) + const w0 = await page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth })) + check('botleads: Liste ohne seitliches Scrollen am Desktop', w0.scroll <= w0.inner, w0) + const broken0 = await blBrokenWords(page, '.wk-bl-table tbody') + check('botleads: am Desktop wird kein Wort mitten im Wort umgebrochen', broken0.error === null && broken0.count > 100 && broken0.words.length === 0, broken0) + await topOfPage(page) + await shoot(page, 'botleads-liste.png') + await shoot(page, 'botleads-liste-ganz.png', { fullPage: true }) + + // 3. Telefon: Heilberufe nie, sonst nur mit Quelle + const healthRows = page1.filter((r) => r.room === 'Ärzte & Praxen') + check('botleads: jede Zeile im Raum „Ärzte & Praxen“ zeigt „bewusst nicht gespeichert“ statt einer Nummer', healthRows.length >= 10 && healthRows.every((r) => r.phone === BL_HEALTH_PHONE), healthRows.map((r) => r.phone)) + const physio = blTitle(page1, BL_NAMES.physio) + check('botleads: auch die Physiotherapie im Raum „Selbstständige“ (Heilberuf) zeigt keine Nummer', Boolean(physio) && physio.phone === BL_HEALTH_PHONE, physio) + const storedHealthPhones = await page.evaluate(() => window.__wk.api.botleadsHealthPhones) + const bodyDigits = await page.evaluate(() => document.body.textContent.replace(/\s+/g, ' ')) + check('botleads: keine der im Altbestand gespeicherten Heilberuf-Nummern steht irgendwo auf der Seite', storedHealthPhones.length >= 5 && storedHealthPhones.every((n) => !bodyDigits.includes(n) && !bodyDigits.replace(/\s/g, '').includes(n.replace(/\s/g, ''))), storedHealthPhones) + const others = page1.filter((r) => r.room !== 'Ärzte & Praxen' && r.title !== BL_NAMES.physio) + check('botleads: Betriebe außerhalb der Heilberufe zeigen ihre Nummer, wenn eine Quelle da ist, sonst „nicht verfügbar“', others.some((r) => PHONE_NUMBER.test(r.phone)) && others.some((r) => r.phone === BL_NO_PHONE) && others.every((r) => PHONE_NUMBER.test(r.phone) || r.phone === BL_NO_PHONE), others.map((r) => r.phone)) + const healthRow = blRows(page).filter({ has: page.locator('.cell-title', { hasText: BL_NAMES.health }) }) + await healthRow.screenshot({ path: path.join(out, 'botleads-heilberuf-ohne-telefon.png') }) + shots.push(path.join(out, 'botleads-heilberuf-ohne-telefon.png')) + + // 4. Quellen-Links + const links = page1.filter((r) => r.link) + const linkProblems = links.filter((r) => { + let url + try { url = new URL(r.link.href) } catch { return true } + const tokens = (r.link.rel || '').split(/\s+/) + return !(url.protocol === 'https:' || url.protocol === 'http:') || url.username !== '' || url.password !== '' || r.link.target !== '_blank' || !tokens.includes('noopener') || !tokens.includes('noreferrer') || !(r.link.label || '').startsWith('Quelle öffnen: ') || r.source !== 'Quelle öffnen' + }) + check('botleads: jeder Quellen-Link ist http(s) ohne Zugangsdaten, öffnet im neuen Tab mit rel „noopener noreferrer“ und hat einen eigenen Namen', links.length >= 40 && linkProblems.length === 0, linkProblems.slice(0, 3)) + const httpRow = blTitle(page1, BL_NAMES.http) + check('botleads: die http-Quelle bleibt ein Link mit rel noopener (die Ansicht lässt http und https zu)', Boolean(httpRow && httpRow.link) && httpRow.link.href.startsWith('http://') && (httpRow.link.rel || '').includes('noopener'), httpRow && httpRow.link) + const hostile = [blTitle(page1, BL_NAMES.javascript), blTitle(page1, BL_NAMES.credentials)] + check('botleads: feindliche Quellen (javascript:, Zugangsdaten in der Adresse) werden kein Link, die Zelle zeigt „–“', hostile.every((r) => r && r.link === null && r.source === '–'), hostile) + check('botleads: auf der Seite steht kein Link mit javascript: oder data:', (await page.locator('a[href^="javascript:" i], a[href^="data:" i]').count()) === 0) + + // 5. Raumfilter + await blSelectRoom(page, 'handwerk') + const hw = await blRead(page) + check('botleads: Raum „Handwerk“ zeigt genau die 15 Handwerk-Zeilen, die Seitenzeile ohne Blättern', hw.length === 15 && hw.every((r) => r.room === 'Handwerk') && (await blFootLabel(page)) === BL_LABEL(1, 1, 15) && (await page.locator('.wk-bl-pager').count()) === 0, { rows: hw.length, label: await blFootLabel(page) }) + const callHw = (await blCalls(page)).at(-1) + check('botleads: der Filter fragt den Dienst mit room=handwerk, Seite 1', callHw.room === 'handwerk' && callHw.page === 1 && callHw.q === '', callHw) + const longRow = blTitle(hw, 'Elektroinstallation-und-Haustechnik-Hoffmann-Sonnenschein-Wittgensteiner GmbH') + check('botleads: der lange Name und der lange Grund erscheinen vollständig', Boolean(longRow) && longRow.reason.startsWith('Impressum nennt nur eine Postfachadresse') && longRow.reason.endsWith('gehört einem Dritten.'), longRow) + await shoot(page, 'botleads-raum-handwerk.png') + await blSelectRoom(page, 'aerzte') + const ae = await blRead(page) + check('botleads: Raum „Ärzte & Praxen“ zeigt 15 Praxen, alle ohne Nummer', ae.length === 15 && ae.every((r) => r.room === 'Ärzte & Praxen' && r.phone === BL_HEALTH_PHONE), ae.map((r) => r.phone)) + await shoot(page, 'botleads-raum-aerzte.png') + await blSelectRoom(page, '') + check('botleads: „Alle Räume“ bringt die 50 Zeilen von Seite 1 zurück', (await blRows(page).count()) === 50 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60)) + + // 6. Blättern mit der Tastatur: der Fokus bleibt auf der Seitenzeile + const next = page.getByRole('button', { name: 'Weiter', exact: true }) + const prev = page.getByRole('button', { name: 'Zurück', exact: true }) + await next.focus() + check('botleads: „Weiter“ lässt sich per Tastatur fokussieren', (await blFocus(page)).inPager) + await page.keyboard.press('Enter') + await blWaitLabel(page, BL_LABEL(2, 2, 60)) + await blSettled(page) + const page2 = await blRead(page) + const focusNext = await blFocus(page) + check('botleads: nach „Weiter“ (Enter) bleibt der Tastaturfokus auf der Seitenzeile (Knopf in .wk-bl-pager)', focusNext.inPager, focusNext) + check('botleads: Seite 2 zeigt die letzten 10 Einträge, „Zurück“ frei und „Weiter“ gesperrt', page2.length === 10 && same(await pagerState(), { prev: false, next: true }), { rows: page2.length, state: await pagerState() }) + const allTitles = [...page1, ...page2].map((r) => r.title) + check('botleads: beide Seiten zusammen sind 60 verschiedene Betriebe, Seite 2 knüpft zeitlich an Seite 1 an', new Set(allTitles).size === 60 && blDay(page2[0].found) <= blDay(page1.at(-1).found), { unique: new Set(allTitles).size }) + const call2 = (await blCalls(page)).at(-1) + check('botleads: „Weiter“ fragt Seite 2 mit demselben Filter', call2.page === 2 && call2.room === '' && call2.q === '', call2) + await topOfPage(page) + await shoot(page, 'botleads-seite-2.png') + await prev.focus() + await page.keyboard.press('Enter') + await blWaitLabel(page, BL_LABEL(1, 2, 60)) + await blSettled(page) + const focusPrev = await blFocus(page) + check('botleads: nach „Zurück“ (Enter) bleibt der Tastaturfokus auf der Seitenzeile', focusPrev.inPager, focusPrev) + check('botleads: „Zurück“ bringt Seite 1 mit 50 Zeilen', (await blRows(page).count()) === 50 && same((await blRead(page)).map((r) => r.title), page1.map((r) => r.title))) + metrics.botleadsFocusAfterPaging = { forward: focusNext, back: focusPrev } + + // 7. Wechsel des Raums auf Seite 2 springt auf Seite 1; Ladezustand und späte Antworten + await next.click() + await blWaitLabel(page, BL_LABEL(2, 2, 60)) + await blSelectRoom(page, 'laden') + const callLaden = (await blCalls(page)).at(-1) + check('botleads: Raumwechsel auf Seite 2 fragt wieder Seite 1', callLaden.room === 'laden' && callLaden.page === 1, callLaden) + await blSelectRoom(page, '') + await page.evaluate(() => { window.__wk.api.botleadsDelayMs = (query) => (query.room === 'handwerk' ? 1000 : 0) }) + await page.getByLabel('Raum', { exact: true }).selectOption('handwerk') + const loadingText = await textOf(page.locator('.wk-bl .loading')) + check('botleads: während der Antwort steht „Wird geladen …“ (role=status), keine alte Tabelle', loadingText === 'Wird geladen …' && (await page.locator('.wk-bl .loading').getAttribute('role')) === 'status' && (await page.locator('.wk-bl-table').count()) === 0, loadingText) + await shoot(page, 'botleads-laedt.png') + await page.getByLabel('Raum', { exact: true }).selectOption('laden') + await blSettled(page) + const lad = await blRead(page) + await sleep(1500) + const ladLater = await blRead(page) + check('botleads: die späte Antwort des früheren Filters (Handwerk) überschreibt die neue Liste (Läden) nicht', lad.length === 15 && same(ladLater, lad) && ladLater.every((r) => r.room === 'Läden & Gastro') && (await page.getByLabel('Raum', { exact: true }).inputValue()) === 'laden', { rows: ladLater.length }) + await page.evaluate(() => { window.__wk.api.botleadsDelayMs = 0 }) + await blSelectRoom(page, '') + + // 8. Suche und Leerzustand mit Filter + const search = page.getByLabel('Betriebe suchen', { exact: true }) + await search.fill('Hartmann') + await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Hartmann')) + await blSettled(page) + const hartmann = await blRead(page) + check('botleads: Suche „Hartmann“ findet genau einen Betrieb, Seitenzeile „1 Eintrag“', hartmann.length === 1 && hartmann[0].title === BL_NAMES.http && (await blFootLabel(page)) === BL_LABEL(1, 1, 1), { rows: hartmann.length, label: await blFootLabel(page) }) + await search.fill('Qwertz') + await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Qwertz')) + await blSettled(page) + const emptyTitle = await textOf(page.locator('.wk-bl .empty strong')) + const emptyHint = await textOf(page.locator('.wk-bl .empty')) + check('botleads: Suche ohne Treffer zeigt „Keine Treffer.“ mit Hinweis, ohne Tabelle und ohne Seitenzeile', emptyTitle === 'Keine Treffer.' && emptyHint.includes('Für diesen Raum und diese Suche gibt es keine Bot-Leads.') && (await page.locator('.wk-bl-table').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0, { emptyTitle, emptyHint }) + check('botleads: im Leerzustand bleiben Raumfilter und Suchfeld bedienbar', (await page.getByLabel('Raum', { exact: true }).isEnabled()) && (await search.isEnabled()) && (await search.inputValue()) === 'Qwertz') + await shoot(page, 'botleads-leer-filter.png') + await blSelectRoom(page, 'handwerk') + check('botleads: Raum plus Suche ohne Treffer bleibt „Keine Treffer.“', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Keine Treffer.') + await search.fill('') + await page.waitForFunction(() => window.__wk.api.botleadCalls.at(-1).q === '' && window.__wk.api.botleadCalls.at(-1).room === 'handwerk') + await blSettled(page) + check('botleads: Suche leeren bringt die 15 Handwerk-Zeilen zurück', (await blRows(page).count()) === 15) + await blSelectRoom(page, '') + + // 9. Fehlerzustand: deutscher Satz, Filter bleibt bedienbar, Erholung beim nächsten Versuch + await page.evaluate(() => { window.__wk.control.fault = 'down' }) + await page.getByLabel('Raum', { exact: true }).selectOption('selbst') + await blSettled(page) + const err = page.locator('.wk-bl .msg.err') + const errText = await textOf(err) + check('botleads: Dienst nicht erreichbar zeigt den deutschen Satz „Der Hustler-Dienst ist gerade nicht erreichbar.“ (role=alert)', errText === PULT_DOWN && (await err.getAttribute('role')) === 'alert', errText) + check('botleads: die Fehlermeldung ist ein ganzer deutscher Satz ohne Technik (kein HTTP, fetch, Error, undefined)', /^[A-ZÄÖÜ][^]*[.!]$/.test(errText) && !/http|fetch|error|undefined|unbekannter fehler|\d{3}/i.test(errText), errText) + check('botleads: im Fehlerzustand keine Tabelle, keine Seitenzeile, Filter bedienbar', (await page.locator('.wk-bl-table').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).isEnabled())) + await shoot(page, 'botleads-fehler.png') + await page.evaluate(() => { window.__wk.control.fault = 'session' }) + await page.getByLabel('Raum', { exact: true }).selectOption('laden') + await blSettled(page) + check('botleads: abgelaufene Anmeldung zeigt „Anmeldung abgelaufen. Bitte neu anmelden.“', (await textOf(page.locator('.wk-bl .msg.err'))) === BL_SESSION, await textOf(page.locator('.wk-bl .msg.err'))) + await shoot(page, 'botleads-fehler-anmeldung.png') + await page.evaluate(() => { window.__wk.control.fault = null }) + await page.getByLabel('Raum', { exact: true }).selectOption('selbst') + await blSettled(page) + check('botleads: nach dem nächsten Versuch ist der Fehler weg und die 15 Selbstständigen stehen da', (await page.locator('.wk-bl .msg.err').count()) === 0 && (await blRows(page).count()) === 15) + + // 10. Deep-Link ?tab=botleads&bot=: genau ein Eintrag, „Alle anzeigen“ führt zur Liste + await page.goto(base + BL_URL + '&bot=' + BL_IDS.health) + await blSettled(page) + const single = await blRead(page) + check('botleads: Deep-Link zeigt genau den einen Eintrag, hervorgehoben (aria-current)', single.length === 1 && single[0].title === BL_NAMES.health && single[0].current === 'true', single) + check('botleads: Deep-Link öffnet den Reiter „Bot-Leads“', (await page.getByRole('tab', { name: 'Bot-Leads', exact: true }).getAttribute('aria-selected')) === 'true') + check('botleads: Einzelansicht: Hinweis und „Alle anzeigen“, kein Raumfilter, keine Suche, keine Seitenzeile', (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1 && (await textOf(page.locator('.wk-bl-single'))).includes('Einzelner Eintrag aus dem Ereignisstrom') && (await page.locator('.wk-bl select, .wk-bl input').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0) + check('botleads: Einzelansicht des Heilberufs ohne Telefon', single[0].phone === BL_HEALTH_PHONE, single[0].phone) + const callSingle = (await blCalls(page)).at(-1) + check('botleads: Deep-Link fragt den Dienst mit id=', callSingle.id === BL_IDS.health, callSingle) + await shoot(page, 'botleads-einzeln.png') + await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).click() + await blSettled(page) + check('botleads: „Alle anzeigen“ führt zur Liste (50 Zeilen, Raumfilter und Seitenzeile, kein Einzelhinweis)', (await blRows(page).count()) === 50 && (await page.locator('.wk-bl-single').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).count()) === 1 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60) && (await page.locator('tr[aria-current]').count()) === 0) + metrics.botleadsFocusAfterAlleAnzeigen = await blFocus(page) + await topOfPage(page) + await shoot(page, 'botleads-nach-alle-anzeigen.png') + + // gekürzter Eintrag: nur über den Deep-Link, grau, ohne Quelle + await page.goto(base + BL_URL + '&bot=' + BL_IDS.cut) + await blSettled(page) + const cut = await blRead(page) + check('botleads: gekürzter Eintrag erscheint über den Deep-Link, mit Kürzel „gekürzt“, grauer Zeile, ohne Quelle', cut.length === 1 && cut[0].title === '[gekürzt]' && cut[0].tag === 'gekürzt' && cut[0].cut && cut[0].source === '–' && cut[0].link === null && cut[0].phone === BL_NO_PHONE, cut) + await shoot(page, 'botleads-einzeln-gekuerzt.png') + await page.goto(base + BL_URL + '&bot=' + BL_IDS.cutHealth) + await blSettled(page) + const cutHealth = await blRead(page) + check('botleads: gekürzter Heilberuf zeigt ebenfalls keine Nummer', cutHealth.length === 1 && cutHealth[0].phone === BL_HEALTH_PHONE, cutHealth) + await page.goto(base + BL_URL + '&bot=gibt-es-nicht') + await blSettled(page) + check('botleads: unbekannte Kennung zeigt „Dieser Eintrag ist nicht (mehr) in der Liste.“ und „Alle anzeigen“', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Dieser Eintrag ist nicht (mehr) in der Liste.' && (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1) + await shoot(page, 'botleads-einzeln-unbekannt.png') + await page.goto(base + BL_URL + '&bot=' + BL_IDS.health) + await blSettled(page) + await page.getByRole('tab', { name: 'Freigabe', exact: true }).click() + await page.getByRole('tab', { name: 'Bot-Leads', exact: true }).click() + await blSettled(page) + check('botleads: Reiterwechsel lässt die Einzelansicht fallen (wieder die ganze Liste)', (await blRows(page).count()) === 50) + + // 11. Leerzustand ohne Filter + await page.goto(base + BL_URL + '&botleads=leer') + await blSettled(page) + check('botleads: leere Liste ohne Filter zeigt „Noch keine Bot-Leads.“ ohne Tabelle und ohne Seitenzeile', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Noch keine Bot-Leads.' && (await page.locator('.wk-bl-table').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0) + await shoot(page, 'botleads-leer.png') + await context.close() +} + +// ---------- Szenario R: Bot-Leads am Handy (390 x 844) ---------- + +async function handyBotleads(browser, base) { + const { context, page } = await newPage(browser, 'handy-botleads', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) + const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth })) + const heightOf = (name) => page.getByRole('button', { name, exact: true }).evaluate((el) => { + el.scrollIntoView({ block: 'center' }) + const r = el.getBoundingClientRect() + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) + return { height: Math.round(r.height * 10) / 10, width: Math.round(r.width), left: Math.round(r.left), right: Math.round(r.right), covered: !(hit && (hit === el || el.contains(hit))) } + }) + + // 1. Reiter am Handy: im Fenster, wählbar + await page.goto(base + '/index.html?page=1&fault=none') + await page.getByRole('tab').first().waitFor() + await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) + const tab = page.getByRole('tab', { name: 'Bot-Leads', exact: true }) + const tabBox = await tab.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), inner: window.innerWidth } }) + check('handy-botleads: Reiter „Bot-Leads“ liegt im Fenster (links >= 0, rechts <= 390) und ist nicht gewählt', tabBox.left >= 0 && tabBox.right <= tabBox.inner && (await tab.getAttribute('aria-selected')) === 'false', tabBox) + await tab.click() + await blSettled(page) + await page.evaluate(() => document.fonts.ready) + check('handy-botleads: nach dem Antippen ist „Bot-Leads“ gewählt und die Liste da', (await tab.getAttribute('aria-selected')) === 'true' && (await blRows(page).count()) === 50) + + // 2. Kein seitliches Scrollen der Seite; die Tabelle scrollt in ihrem Rahmen + const w0 = await width() + metrics.phoneBotleads = w0 + check('handy-botleads: Liste ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0) + const frame = await page.locator('.wk-bl-scroll').evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), clientWidth: el.clientWidth, scrollWidth: el.scrollWidth } }) + check('handy-botleads: der Rahmen der Tabelle liegt im Fenster und scrollt waagerecht (Tabelle breiter als der Rahmen)', frame.left >= 0 && frame.right <= 390 && frame.scrollWidth > frame.clientWidth, frame) + const bar = await page.evaluate(() => { + const out = {} + for (const [name, sel] of [['tabs', '.tabs'], ['raum', '.wk-bl select'], ['suche', '.wk-bl input[type="search"]']]) { const r = document.querySelector(sel).getBoundingClientRect(); out[name] = { left: Math.round(r.left), right: Math.round(r.right) } } + return out + }) + check('handy-botleads: Reiterleiste, Raumfilter und Suchfeld liegen im Fenster', Object.values(bar).every((b) => b.left >= 0 && b.right <= 390), bar) + const longCells = await page.evaluate(() => Array.from(document.querySelectorAll('.wk-bl-table tbody tr')).filter((tr) => tr.children[0].textContent.startsWith('Elektroinstallation-und-Haustechnik')).map((tr) => Array.from(tr.children).map((td) => td.scrollWidth - td.clientWidth))) + check('handy-botleads: der lange Name und der lange Grund laufen nicht aus ihren Zellen', longCells.length === 1 && longCells[0].every((d) => d <= 1), longCells) + const broken1 = await blBrokenWords(page, '.wk-bl-table tbody') + check('handy-botleads: Liste ohne mitten im Wort umgebrochene Wörter (Name, Ort, Raum, Grund)', broken1.error === null && broken1.count > 100 && broken1.words.length === 0, { count: broken1.count, words: broken1.words.slice(0, 8) }) + await topOfPage(page) + await shoot(page, 'handy-botleads-liste.png') + await shoot(page, 'handy-botleads-liste-ganz.png', { fullPage: true }) + await page.locator('.wk-bl-scroll').evaluate((el) => { el.scrollLeft = el.scrollWidth }) + await page.locator('.wk-bl-scroll').scrollIntoViewIfNeeded() + await sleep(150) + await shoot(page, 'handy-botleads-tabelle-gescrollt.png') + await page.locator('.wk-bl-scroll').evaluate((el) => { el.scrollLeft = 0 }) + + // 3. Blätterknöpfe: im Fenster, nicht verdeckt, mindestens 40 px hoch + const prevBox = await heightOf('Zurück') + const nextBox = await heightOf('Weiter') + metrics.phoneBotleadsPager = { prev: prevBox, next: nextBox } + check('handy-botleads: Blätterknöpfe „Zurück“ und „Weiter“ sind im Fenster und nicht verdeckt', [prevBox, nextBox].every((b) => b.left >= 0 && b.right <= 390 && !b.covered), { prevBox, nextBox }) + check('handy-botleads: Blätterknöpfe sind mindestens 40 px hoch (Tippfläche)', prevBox.height >= 40 && nextBox.height >= 40, { prev: prevBox.height, next: nextBox.height }) + await shoot(page, 'handy-botleads-blaettern.png') + await page.getByRole('button', { name: 'Weiter', exact: true }).click() + await blWaitLabel(page, BL_LABEL(2, 2, 60)) + await blSettled(page) + const w1 = await width() + check('handy-botleads: Seite 2 (10 Zeilen) ohne seitliches Scrollen, „Zurück“ erreichbar', (await blRows(page).count()) === 10 && w1.scroll <= w1.inner && !(await heightOf('Zurück')).covered, w1) + await shoot(page, 'handy-botleads-seite-2.png') + + // 4. Gekürzter Eintrag: Kontrast der abgeblendeten Zeile + await page.goto(base + BL_URL + '&bot=' + BL_IDS.cut) + await blSettled(page) + const cutRows = await blRead(page) + check('handy-botleads: gekürzter Eintrag ist als graue Zeile da (Kürzel „gekürzt“)', cutRows.length === 1 && cutRows[0].cut && cutRows[0].tag === 'gekürzt', cutRows) + const w2 = await width() + check('handy-botleads: Einzelansicht ohne seitliches Scrollen', w2.scroll <= w2.inner, w2) + const broken2 = await blBrokenWords(page, '.wk-bl-table tbody') + check('handy-botleads: Einzelansicht ohne mitten im Wort umgebrochene Wörter (z. B. „Kaiserslauter-n“ im Betrieb-Feld)', broken2.error === null && broken2.count >= 5 && broken2.words.length === 0, { count: broken2.count, words: broken2.words }) + const cutContrast = await blContrast(page, '.wk-bl-table tbody tr.wk-bl-cut') + metrics.phoneBotleadsContrastCut = cutContrast + check('handy-botleads: Kontrast der abgeblendeten (gekürzten) Zeile >= 4,5:1 für jeden Text (aus den berechneten Stilen)', cutContrast.error === null && cutContrast.count >= 6 && cutContrast.low.length === 0, cutContrast) + const alle = await heightOf('Alle anzeigen') + check('handy-botleads: „Alle anzeigen“ ist im Fenster und mindestens 40 px hoch (Tippfläche)', alle.left >= 0 && alle.right <= 390 && !alle.covered && alle.height >= 40, alle) + await topOfPage(page) + await shoot(page, 'handy-botleads-gekuerzt.png') + + // 5. Kontrast der normalen Zeilen (Seite 1), Leerzustand und Fehler am Handy + await page.goto(base + BL_URL) + await blSettled(page) + const normalContrast = await blContrast(page, '.wk-bl-table tbody') + metrics.phoneBotleadsContrastRows = { count: normalContrast.count, min: normalContrast.min } + check('handy-botleads: Kontrast der normalen Zeilen >= 4,5:1 für jeden Text', normalContrast.error === null && normalContrast.count > 100 && normalContrast.low.length === 0, { count: normalContrast.count, min: normalContrast.min, low: normalContrast.low.slice(0, 3) }) + const search = page.getByLabel('Betriebe suchen', { exact: true }) + await search.fill('Qwertz') + await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Qwertz')) + await blSettled(page) + const w3 = await width() + check('handy-botleads: Leerzustand mit Filter („Keine Treffer.“) ohne seitliches Scrollen', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Keine Treffer.' && w3.scroll <= w3.inner, w3) + await topOfPage(page) + await shoot(page, 'handy-botleads-leer-filter.png') + await search.fill('') + await page.waitForFunction(() => window.__wk.api.botleadCalls.at(-1).q === '') + await blSettled(page) + await page.evaluate(() => { window.__wk.control.fault = 'down' }) + await page.getByLabel('Raum', { exact: true }).selectOption('laden') + await blSettled(page) + const w4 = await width() + check('handy-botleads: Fehlerzustand zeigt den deutschen Satz und läuft nicht über den Rand', (await textOf(page.locator('.wk-bl .msg.err'))) === PULT_DOWN && w4.scroll <= w4.inner, w4) + await topOfPage(page) + await shoot(page, 'handy-botleads-fehler.png') + await page.evaluate(() => { window.__wk.control.fault = null }) + + // 6. Deep-Link und leere Liste am Handy + await page.goto(base + BL_URL + '&bot=' + BL_IDS.health) + await blSettled(page) + const one = await blRead(page) + const w5 = await width() + check('handy-botleads: Deep-Link zeigt genau einen Eintrag (hervorgehoben) mit „Alle anzeigen“, ohne seitliches Scrollen', one.length === 1 && one[0].current === 'true' && one[0].phone === BL_HEALTH_PHONE && (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1 && w5.scroll <= w5.inner, { rows: one.length, w5 }) + const broken3 = await blBrokenWords(page, '.wk-bl-table tbody') + check('handy-botleads: Deep-Link-Einzelansicht des Heilberufs ohne mitten im Wort umgebrochene Wörter (z. B. „Kaiserslau-tern“)', broken3.error === null && broken3.count >= 5 && broken3.words.length === 0, { count: broken3.count, words: broken3.words }) + await shoot(page, 'handy-botleads-einzeln.png') + await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).click() + await blSettled(page) + check('handy-botleads: „Alle anzeigen“ führt am Handy zur Liste (50 Zeilen, Raumfilter da)', (await blRows(page).count()) === 50 && (await page.getByLabel('Raum', { exact: true }).count()) === 1) + await page.goto(base + BL_URL + '&botleads=leer') + await blSettled(page) + const w6 = await width() + check('handy-botleads: leere Liste („Noch keine Bot-Leads.“) ohne seitliches Scrollen', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Noch keine Bot-Leads.' && w6.scroll <= w6.inner, w6) + await shoot(page, 'handy-botleads-leer.png') + await context.close() +} + const SCENARIOS = [ ['desktop', desktop], ['blasen', bubbles], ['handy', phone], ['reduziert', reduced], ['notbremse', halted], ['ausfall', outage], ['freigabe', freigabe], ['interessent', interessent], ['anhalten', anhalten], ['handy-freigabe', handyFreigabe], ['antworten', antworten], ['vorschau', vorschau], ['einfuegen', einfuegen], ['bauen', bauen], ['fokus', fokus], ['handy-p4', handyP4], + ['botleads', botleads], ['handy-botleads', handyBotleads], ]; (async () => { diff --git a/tests/unit/workersHarness.test.mjs b/tests/unit/workersHarness.test.mjs index 311af91..6a28989 100644 --- a/tests/unit/workersHarness.test.mjs +++ b/tests/unit/workersHarness.test.mjs @@ -116,7 +116,26 @@ test('scripted api: Spur mit 12 Ereignissen (unsicherer und sicherer Link), Tage assert.ok(s1.workers.find((w) => w.workerId === 'otto').task !== s0.workers.find((w) => w.workerId === 'otto').task) for (const key of ['newAbosMonth', 'mrrNewEur', 'dealsMonth', 'openThreads', 'pultOpen', 'botLeads', 'apiTodayUsd', 'apiMonthUsd', 'dailyBudgetUsd', 'monthlyBudgetUsd']) assert.ok(Number.isFinite(s1.kpis[key]), key) assert.deepEqual(await api.pult('offen'), { items: [] }) - assert.deepEqual(await api.botleads({}), { items: [], total: 0 }) + // Bot-Leads (P6): wie GET /botleads des Dienstes { items, total, pageSize }: 60 sichtbare Einträge in vier Räumen auf zwei Seiten, nie Telefon bei Heilberufen, + // gekürzte Stubs weder in der Liste noch in total, aber mit id erreichbar; 400 bei unbekanntem Raum + const bl = await api.botleads({}) + assert.deepEqual(Object.keys(bl).sort(), ['items', 'pageSize', 'total']) + assert.deepEqual([bl.total, bl.pageSize, bl.items.length], [60, 50, 50]) + assert.deepEqual(Object.keys(bl.items[0]).sort(), ['city', 'foundAt', 'gekuerzt', 'id', 'isHealth', 'name', 'phone', 'phoneSource', 'postalCode', 'reason', 'room', 'roomLabel', 'score', 'source', 'sourceUrl', 'street', 'website']) + const blTwo = await api.botleads({ page: 2 }) + assert.equal(blTwo.items.length, 10) + assert.equal(new Set([...bl.items, ...blTwo.items].map((e) => e.id)).size, 60) + assert.ok([...bl.items, ...blTwo.items].every((e, i, all) => i === 0 || Date.parse(all[i - 1].foundAt) >= Date.parse(e.foundAt))) + assert.deepEqual(new Set([...bl.items, ...blTwo.items].map((e) => e.room)), new Set(['aerzte', 'handwerk', 'selbst', 'laden'])) + assert.ok([...bl.items, ...blTwo.items].every((e) => e.gekuerzt === false && e.name !== '[gekürzt]' && e.foundAt.endsWith('Z'))) + assert.ok([...bl.items, ...blTwo.items].filter((e) => e.isHealth).every((e) => e.phone === '' && e.phoneSource === '')) + assert.ok([...bl.items, ...blTwo.items].some((e) => !e.isHealth && e.phone !== '' && e.phoneSource !== '')) + assert.ok(api.botleadsHealthPhones.length > 0 && !JSON.stringify([bl, blTwo]).includes(api.botleadsHealthPhones[0])) + assert.equal((await api.botleads({ room: 'handwerk' })).total, 15) + assert.equal((await api.botleads({ q: 'Qwertz' })).total, 0) + assert.deepEqual((await api.botleads({ id: 'bl-gekuerzt-01' })).items.map((e) => [e.name, e.gekuerzt]), [['[gekürzt]', true]]) + assert.deepEqual(await api.botleads({ id: 'gibt-es-nicht' }), { items: [], total: 0, pageSize: 50 }) + await assert.rejects(api.botleads({ room: 'kaffee' }), (err) => err.status === 400 && err.message === 'Unbekannter Raum.') }) // ---- Endprüfung P2: Fetch-Weiche des Prüfstands (echter API-Client gegen die Skript-API, mit Ausfall-Schalter) ----