Workers P6: Prüfstand für den Reiter Bot-Leads (Desktop und Handy)
Skript-API: botleads({ room, q, page, id }) antwortet wie GET /botleads des Dienstes
(items, total, pageSize 50; Telefon nie bei Heilberufen, gekürzte Stubs nur mit id).
60 sichtbare Einträge in vier Räumen (zwei Seiten), 3 gekürzte Stubs, Heilberufe mit
Altbestand-Telefon, eine http-Quelle, zwei feindliche Quellen, lange Namen und Gründe.
Schalter: faults.botleads, botleadsDelayMs, botleadsEmpty (?botleads=leer), botleadCalls.
Fetch-Weiche und entry.jsx (?bot=<Id>) um die Route und den Deep-Link ergänzt.
Szenarien: botleads (Reiter, Liste, Raumfilter, Blättern mit Fokus, Suche, Leerzustände,
Fehlertexte, Deep-Link, Quellen-Links, Telefon) und handy-botleads (390 x 844: kein
seitliches Scrollen, Knopfhöhe, Kontrast der gekürzten Zeile, Wortumbruch).
Der Pin in workersHarness.test.mjs prüft nun die neue Antwortform statt der leeren Liste.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cd532e0cf0
commit
78c8e3fbc2
5 files changed
+709
-11
No files matched your search
@@ -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=<Id> (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(
|
||||
<StrictMode>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<Aktion>. 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.' })
|
||||
|
||||
@@ -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=<Id> 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=<Id>: 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=<Kennung>', 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 () => {
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
Reference in new issue
Block a user