From 5138efd2bf122f4e5aa819a1327540aa39c97522 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:39:52 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P4:=20Pr=C3=BCfstand=20f=C3=BCr=20das?= =?UTF-8?q?=20neue=20Pult=20=E2=80=93=20Antworten,=20Vorschau=20freigeben,?= =?UTF-8?q?=20Einf=C3=BCgen,=20Bauen,=20Fokus,=20Handy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Skript-API kennt jetzt reply, approve-preview, start-build und paste mit den Prüfreihenfolgen und Sätzen des Dienstes (pultMail.js, paste.js) und einen eigenen Datensatz (?p4=1: Mail-Antwort, zwei Vorschau-Freigaben, unsicher, Kollegen-Eintrag, zwei gesendete Plattform-Antworten desselben Interessenten). Schalter für 400/409/413, Verbindungsabbruch (status 0) und eine Wartezeit je Aktion. Sechs neue Szenarien in shot.cjs: antworten, vorschau, einfuegen, bauen, fokus, handy-p4 (109 Prüfpunkte, Bilder je Zustand). Drei veraltete P3-Prüfpunkte angepasst (seit P4 bietet eine Plattform-Zeile auch „Antwort des Kunden einfügen“). Bricht ein Szenario ab, hält abbruch-.png den Zustand fest. Der Lauf deckt Fehler der App auf (Fokus nach Aktionen, kein Esc/Abbrechen an den Feldern, 32-px-Schließen am Handy); die Prüfpunkte dazu bleiben rot, an der App ist nichts geändert. Co-Authored-By: Claude Sonnet 5.5 --- scripts/workers-harness/entry.jsx | 4 +- scripts/workers-harness/scripted-api.js | 210 ++++++- scripts/workers-harness/scripted-fetch.js | 6 +- scripts/workers-harness/shot.cjs | 699 +++++++++++++++++++++- 4 files changed, 909 insertions(+), 10 deletions(-) diff --git a/scripts/workers-harness/entry.jsx b/scripts/workers-harness/entry.jsx index eab170c..6a56472 100644 --- a/scripts/workers-harness/entry.jsx +++ b/scripts/workers-harness/entry.jsx @@ -7,6 +7,8 @@ // ?page=1 zeigt statt des Boards die echte Seite WorkersPage (Reiter Hochhaus und Freigabe, PultList, ProspectPanel); AuthContext, // useEmployees und workersClient sind dort durch stub-*.js ersetzt (build.mjs). ?tab=freigabe öffnet den Reiter gleich (Deep-Link). // Mit ?page=1&fault=none läuft die Seite über den echten API-Client und die Fetch-Weiche (Pult, Aktionen, Interessent). +// ?p4=1 (nur mit ?page=1) nimmt den Datensatz für die Gespräche und die Werkstatt (P4: als Mensch antworten, Vorschau freigeben und bauen, +// Antwort des Kunden einfügen) statt der P3-Einträge. import { StrictMode, createRef } from 'react' import { createRoot } from 'react-dom/client' import { MemoryRouter } from 'react-router-dom' @@ -22,7 +24,7 @@ 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 }) +const scripted = createScriptedApi({ now: () => performance.now(), base: Date.now(), halted: params.get('halted') === '1', pult: pageMode ? (params.get('p4') === '1' ? 'p4' : true) : false }) const stage = createRef() const fault = params.get('fault') const control = { fault: fault && fault !== 'none' ? fault : null, calls: [], jwts: [] } diff --git a/scripts/workers-harness/scripted-api.js b/scripts/workers-harness/scripted-api.js index 3c813eb..ac60dad 100644 --- a/scripts/workers-harness/scripted-api.js +++ b/scripts/workers-harness/scripted-api.js @@ -6,6 +6,11 @@ // (workers-dev/src/api/anfragenRoutes.js) und verhalten sich wie dessen Regeln (pult.js): übernehmen, als gesendet markieren, verwerfen, // erledigen, 409 mit deutschem Satz bei fremder Übernahme. Die Schalter `faults` lassen einzelne Aktionen mit einem Status und einem // deutschen Satz scheitern; `pultCalls`, `prospectCalls` und `pauseCalls` halten fest, was die Oberfläche geschickt hat. +// Gespräche und Werkstatt (P4): pultAction kennt auch reply (als Mensch antworten), approve-preview (Vorschau freigeben), start-build +// (Bau starten) und paste (Antwort des Kunden einfügen), mit den Regeln und Sätzen aus workers-dev/src/gespraeche/pultMail.js und +// 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). import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js' import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js' @@ -313,6 +318,120 @@ function pultFixtures(base) { const clone = (v) => JSON.parse(JSON.stringify(v)) const isoOf = (ms) => new Date(ms).toISOString() +// ---------- Gespräche und Werkstatt (P4) ---------- +// Eigener Satz Pult-Einträge für die Szenarien zu „Als Mensch antworten“, „Vorschau freigeben“, „Vorschau bauen“ und „Antwort des Kunden +// einfügen“. Nur Felder aus den Whitelists; kein Telefon. Wartet: fünf Einträge (Mail-Antwort, zwei Vorschau-Freigaben, unsicher, ein +// Eintrag, den ein Kollege hält). Erledigt: zwei gesendete Plattform-Antworten desselben Interessenten (Standard-Platz und Platz „angebot“), +// die Zeilen müssen unabhängig voneinander bleiben. +const PREVIEW_OK = 'https://entwurf.project.webklar.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/' +const PREVIEW_BAD = 'http://entwurf.project.webklar.com/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/' // kein https: darf kein Link werden +const REPLY_TEXT_MAX = 8000 +const REPLY_ACTIONS = ['reply', 'approve-preview', 'start-build', 'paste'] + +function p4Fixtures(base) { + const at = (ms) => isoOf(base + ms) + const items = [ + { + $id: 'p4-mail', + kind: 'mail_antwort', + prospectId: 'p4-mail', + threadId: 'thr-p4-mail', + title: 'Anfrage: Vereinswebsite für den SV Namborn', + originalText: 'Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht.', + draft: 'Guten Tag Herr Beispiel,\n\nvielen Dank für Ihre Anfrage. Wir bauen Ihnen kostenlos eine erste Vorschau Ihrer neuen Vereinsseite.\n\nBeste Grüße\nKenso', + status: 'offen', + createdAt: at(-1 * HOUR_MS), + }, + { + $id: 'p4-freigabe', + kind: 'vorschau_freigabe', + prospectId: 'p4-vorschau', + previewId: 'prev-p4', + title: 'Vorschau freigeben: Friseur Muster', + draft: 'Guten Tag Frau Muster,\n\nhier ist Ihre erste Vorschau.', + sourceUrl: PREVIEW_OK, + status: 'offen', + createdAt: at(-2 * HOUR_MS), + }, + { + $id: 'p4-freigabe-falsch', + kind: 'vorschau_freigabe', + prospectId: 'p4-vorschau2', + previewId: 'prev-p4-2', + title: 'Vorschau freigeben: Bäckerei Korn', + draft: 'Guten Tag, hier ist Ihre erste Vorschau.', + sourceUrl: PREVIEW_BAD, + status: 'offen', + createdAt: at(-3 * HOUR_MS), + }, + { + $id: 'p4-unsicher', + kind: 'unsicher', + prospectId: 'p4-unsicher', + title: 'Unsichere Antwort: Ist das ein Ja?', + originalText: 'Nora war sich bei dieser Antwort nicht sicher: „Ja, aber erst nach dem Urlaub, vielleicht.“', + status: 'offen', + createdAt: at(-4 * HOUR_MS), + }, + { + $id: 'p4-kollege', + kind: 'mail_antwort', + prospectId: 'p4-kollege', + title: 'Anfrage: Tischlerei Holz', + draft: 'Guten Tag, wir melden uns mit einer Vorschau.', + status: 'in_arbeit', + claimedBy: COLLEAGUE, + claimedAt: at(-1 * HOUR_MS), + createdAt: at(-5 * HOUR_MS), + }, + { + $id: 'p4-plat-gesendet', + kind: 'plattform_antwort', + prospectId: 'p4-plat', + title: 'Kleinanzeigen: Website für Fotostudio', + draft: 'Hallo, wir bauen Ihnen gern eine erste Vorschau.', + sourceUrl: FOTO_URL, + status: 'gesendet', + claimedBy: ME, + claimedAt: at(-2 * DAY_MS + HOUR_MS), + doneBy: ME, + doneAt: at(-1 * DAY_MS), + createdAt: at(-2 * DAY_MS), + }, + { + $id: 'p4-plat-angebot', + kind: 'plattform_antwort', + prospectId: 'p4-plat', + title: 'Kleinanzeigen: Website für Fotostudio (Angebot)', + draft: 'Hallo, hier ist das Angebot zu Ihrer Vorschau.', + sourceUrl: FOTO_URL, + status: 'gesendet', + claimedBy: ME, + claimedAt: at(-4 * HOUR_MS), + doneBy: ME, + doneAt: at(-3 * HOUR_MS), + createdAt: at(-1 * DAY_MS), + }, + ] + const common = { entrance: 'anfrage', room: 'anfragen', foundBy: 'rita' } + const prospects = { + 'p4-mail': { ...common, source: 'freelancermap-alarm', sourceUrl: SV_URL, foundAt: at(-3 * HOUR_MS), name: 'SV Namborn', contactName: 'Hans Beispiel', email: 'vorstand@sv-namborn.example', city: 'Namborn', postalCode: '66640', score: 84, status: 'bewertet', threadId: 'thr-p4-mail' }, + 'p4-vorschau': { ...common, source: 'freelancermap-alarm', foundAt: at(-1 * DAY_MS), name: 'Friseur Muster', email: 'info@friseur-muster.example', city: 'Kaiserslautern', postalCode: '67655', score: 80, status: 'vorschau_bau', previewId: 'prev-p4' }, + 'p4-vorschau2': { ...common, source: 'freelancermap-alarm', foundAt: at(-1 * DAY_MS), name: 'Bäckerei Korn', email: 'info@baeckerei-korn.example', city: 'Landstuhl', postalCode: '66849', score: 75, status: 'vorschau_bau', previewId: 'prev-p4-2' }, + 'p4-unsicher': { ...common, source: 'freelancermap-alarm', foundAt: at(-2 * DAY_MS), name: 'Gasthaus Sonne', email: 'wirt@gasthaus-sonne.example', city: 'Pirmasens', postalCode: '66953', score: 70, status: 'gespraech' }, + 'p4-kollege': { ...common, source: 'freelancermap-alarm', foundAt: at(-1 * DAY_MS), name: 'Tischlerei Holz', email: 'holz@tischlerei-holz.example', city: 'Zweibrücken', postalCode: '66482', status: 'gespraech' }, + 'p4-plat': { ...common, source: 'kleinanzeigen-alarm', sourceUrl: FOTO_URL, foundAt: at(-3 * DAY_MS), name: 'Foto Lichtblick', city: 'Mainz', postalCode: '55116', score: 78, status: 'kontaktiert' }, + } + const threads = { + 'thr-p4-mail': { $id: 'thr-p4-mail', prospectId: 'p4-mail', channel: 'mail', toEmail: 'vorstand@sv-namborn.example', subject: 'Re: SV Namborn', status: 'wartet_auf_uns', followupSent: false, reminderSent: false, createdAt: at(-3 * HOUR_MS) }, + } + const previews = { + 'prev-p4': { $id: 'prev-p4', prospectId: 'p4-vorschau', builder: 'bob', status: 'freigabe', round: 1, createdAt: at(-4 * HOUR_MS) }, + 'prev-p4-2': { $id: 'prev-p4-2', prospectId: 'p4-vorschau2', builder: 'bob', status: 'freigabe', round: 1, createdAt: at(-5 * HOUR_MS) }, + } + return { items, prospects, threads, messages: {}, previews } +} + // Standardwerte der Einstellungen (Übersicht §4.1) function defaultSettings(halted) { return { @@ -352,7 +471,8 @@ function defaultSettings(halted) { /** * 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). + * 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). * Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam. */ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pult = false }) { @@ -361,9 +481,12 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu let paused = false const settings = defaultSettings(halted === true) const saved = [] - const fx = pult ? pultFixtures(base) : { items: [], prospects: {}, threads: {}, messages: {}, previews: {} } // ohne `pult` ist das Pult leer wie bisher + // ohne `pult` ist das Pult leer wie bisher + const fx = pult === 'p4' ? p4Fixtures(base) : pult ? pultFixtures(base) : { items: [], prospects: {}, threads: {}, messages: {}, previews: {} } const pultItems = fx.items - const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null } // je { status, message } oder null + // 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 pastedBefore = new Set() // Interessent + Text: dieselbe eingefügte Antwort ist beim zweiten Mal „doppelt“ const pultCalls = [] const prospectCalls = [] const pauseCalls = [] @@ -417,6 +540,81 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu const nowIsoString = () => isoOf(base + elapsed()) const shortOf = (id) => (Object.hasOwn(fx.prospects, id) ? { $id: id, ...pick(fx.prospects[id], PROSPECT_SHORT) } : null) + // ---- P4: reply, approve-preview, start-build, paste (Regeln und Sätze wie pultMail.js und paste.js des Dienstes) ---- + const TOT = ['verloren', 'deal'] // gesperrt hat seinen eigenen Satz + const conflict = (message) => httpError(409, message) + const NO_PROSPECT = 'Zu diesem Eintrag gibt es keinen Interessenten.' + const checkEditable = (item, editable) => { + if (!editable) throw conflict('Schon erledigt.') + if (item.status === 'in_arbeit' && item.claimedBy !== ME) throw conflict('Wird schon von einem Kollegen bearbeitet.') + } + const prospectOf = (item) => (Object.hasOwn(fx.prospects, item.prospectId) ? fx.prospects[item.prospectId] : null) + const needProspect = (item) => { + const prospect = prospectOf(item) + if (prospect === null) throw conflict(NO_PROSPECT) + return prospect + } + const checkAlive = (prospect) => { + if (prospect.status === 'gesperrt') throw conflict('Dieser Kontakt ist gesperrt.') + if (TOT.includes(prospect.status)) throw conflict('Dieser Kontakt ist nicht mehr aktiv.') + } + const close = (item, status, now) => Object.assign(item, { status, doneBy: ME, doneAt: now }) + // Text einer Antwort: getrimmt, nicht leer, höchstens 8000 Zeichen (Code Points) + const replyText = (text, emptyMessage, wrongTypeMessage) => { + if (text !== undefined && text !== null && typeof text !== 'string' && wrongTypeMessage) throw httpError(400, wrongTypeMessage) + const value = typeof text === 'string' ? text.trim() : '' + if (value === '') throw httpError(400, emptyMessage) + if (Array.from(value).length > REPLY_TEXT_MAX) throw httpError(400, `Der Text ist zu lang (höchstens ${REPLY_TEXT_MAX} Zeichen).`) + return value + } + + function p4Action(item, action, body, editable, now) { + const input = body !== null && typeof body === 'object' ? body : {} + if (action === 'reply') { + if (!['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'].includes(item.kind)) throw conflict('Dazu gibt es keine Antwort-Funktion.') + if (!item.prospectId) throw conflict(NO_PROSPECT) + checkEditable(item, editable) + replyText(input.text, 'Der Text der Antwort fehlt.', 'Der Text der Antwort muss ein Text sein.') + const prospect = needProspect(item) + if (prospect.status === 'gesperrt') throw conflict('Dieser Kontakt ist gesperrt.') + const thread = prospect.threadId && Object.hasOwn(fx.threads, prospect.threadId) ? fx.threads[prospect.threadId] : null + if (thread !== null ? thread.channel !== 'mail' : !prospect.email) throw conflict('Nur für Mail-Fäden.') + return clone({ item: close(item, 'gesendet', now) }) + } + if (action === 'approve-preview') { + if (item.kind !== 'vorschau_freigabe') throw conflict('Dieser Eintrag ist keine Vorschau-Freigabe.') + if (!item.prospectId) throw conflict(NO_PROSPECT) + checkEditable(item, editable) + const preview = item.previewId && Object.hasOwn(fx.previews, item.previewId) ? fx.previews[item.previewId] : null + if (preview === null || preview.status !== 'freigabe' || preview.convertedProjectId) throw conflict('Die Vorschau ist nicht freigabebereit.') + checkAlive(needProspect(item)) + return clone({ item: close(item, 'erledigt', now), previewUrl: item.sourceUrl ?? null }) + } + if (action === 'start-build') { + if (item.kind !== 'unsicher') throw conflict('Dazu gibt es keine Bau-Funktion.') + if (!item.prospectId) throw conflict(NO_PROSPECT) + checkEditable(item, editable) + const prospect = needProspect(item) + checkAlive(prospect) + if (prospect.previewId) throw conflict('Für diesen Interessenten gibt es schon eine Vorschau.') + prospect.status = 'vorschau_bau' + return clone({ item: close(item, 'erledigt', now) }) + } + // paste: Text, Eintrag (daraus der Interessent), Interessent, Faden-Kanal, Sperre; Antwort { status, messageId, threadId } + const text = replyText(input.text, 'Bitte einen Text eingeben.', 'Bitte einen Text eingeben.') + if (!item.prospectId) throw conflict(NO_PROSPECT) + const prospect = prospectOf(item) + if (prospect === null) throw httpError(404, 'Interessent nicht gefunden.') + const thread = prospect.threadId && Object.hasOwn(fx.threads, prospect.threadId) ? fx.threads[prospect.threadId] : null + if (thread !== null && thread.channel !== 'plattform') throw conflict('Nur für Plattform-Fäden.') + if (thread === null && prospect.entrance !== 'anfrage') throw conflict('Nur für Plattform-Anfragen.') + if (prospect.status === 'gesperrt') throw conflict('Dieser Kontakt ist gesperrt.') + const key = `${item.prospectId}:${text.replace(/\s+/g, ' ')}` + const status = pastedBefore.has(key) ? 'doppelt' : 'neu' + pastedBefore.add(key) + return clone({ status, messageId: `msg-p4-${pastedBefore.size}`, threadId: `thr-p4-${item.prospectId}` }) + } + const api = { saved, faults, @@ -424,6 +622,7 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu prospectCalls, pauseCalls, 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) async state() { return clone(buildState()) @@ -491,7 +690,8 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu // POST /pult/:id/: Regeln wie pult.js (claimPult, markSent, discard, donePult); Antwort { item } async pultAction(id, action, body) { pultCalls.push({ id, action, body: body === undefined ? undefined : clone(body) }) - if (!['claim', 'sent', 'discard', 'done'].includes(action)) throw httpError(404, 'Unbekannte Aktion.') + if (!['claim', 'sent', 'discard', 'done', ...REPLY_ACTIONS].includes(action)) throw httpError(404, 'Unbekannte Aktion.') + if (api.pultDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, api.pultDelayMs)) if (faults[action]) throw failure(faults[action]) const item = pultItems.find((entry) => entry.$id === id) if (!item) throw httpError(404, 'Eintrag nicht gefunden.') @@ -499,6 +699,8 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu const prospect = Object.hasOwn(fx.prospects, item.prospectId) ? fx.prospects[item.prospectId] : null const now = nowIsoString() + if (REPLY_ACTIONS.includes(action)) return p4Action(item, action, body, editable, now) + if (action === 'claim') { if (item.status === 'in_arbeit') { if (item.claimedBy !== ME) throw httpError(409, 'Wird schon von einem Kollegen bearbeitet.') diff --git a/scripts/workers-harness/scripted-fetch.js b/scripts/workers-harness/scripted-fetch.js index 0a38640..f0940b9 100644 --- a/scripts/workers-harness/scripted-fetch.js +++ b/scripts/workers-harness/scripted-fetch.js @@ -3,7 +3,9 @@ // Ausfälle lassen sich schalten: control.fault = null (gesund) | 'down' (503 ohne JSON wie eine Fehlerseite des Proxys) | // 'session' (401) | 'hang' (die Antwort kommt nie, wie auf einer toten Verbindung). Kein Netz, kein Appwrite. // Seit P3 auch Pult (GET /pult, POST /pult/:id/) und Interessent (GET /prospect/:id): Fehler der Skript-API (status + deutscher -// Satz) kommen als { error } mit diesem Status an, wie vom Dienst. +// 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. // control.calls sammelt jede Anfrage ({ method, path, jwt }), damit der Prüfstand zählen kann. const reply = (status, body) => ({ @@ -44,6 +46,8 @@ export function createScriptedFetch(api, control) { if (method === 'GET' && prospect) return reply(200, await api.prospect(decodeURIComponent(prospect[1]))) return reply(404, { error: 'Unbekannter Pfad im Prüfstand.' }) } catch (err) { + // Status 0 aus der Skript-API = die Verbindung bricht ab, bevor eine Antwort da ist (fetch wirft wie bei einem Netzfehler) + if (err && err.status === 0) throw new TypeError('Failed to fetch') return reply(err && err.status ? err.status : 500, { error: err && err.message ? err.message : 'Fehler im Prüfstand.' }) } } diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs index 6703ddf..68b5ff0 100644 --- a/scripts/workers-harness/shot.cjs +++ b/scripts/workers-harness/shot.cjs @@ -5,7 +5,9 @@ // = Ausgabe von build.mjs, = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo. // Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1. // Szenarien: A Desktop, B Blasen, C Handy, D weniger Bewegung, E Notbremse, F Ausfall (P2); G Freigabe, H Interessenten-Panel, I Anhalten -// mit Rückfrage, J Handy mit Freigabe und Panel (P3). Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der +// 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 // 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' @@ -71,6 +73,7 @@ function check(name, ok, detail) { } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +let lastPage = null // die zuletzt geöffnete Seite: bricht ein Szenario ab, wird von hier aus der Zustand im Bild festgehalten (abbruch-.png) const textOf = async (loc) => ((await loc.textContent()) || '').replace(/\s+/g, ' ').trim() async function shoot(page, name, options = {}) { @@ -96,6 +99,7 @@ async function newPage(browser, label, contextOptions) { window.cancelAnimationFrame = (id) => { waiting.delete(id); caf(id) } }) const page = await context.newPage() + lastPage = page page.setDefaultTimeout(20000) page.on('pageerror', (e) => errors.push(label + ' pageerror: ' + String(e))) page.on('console', (m) => { if (m.type() === 'error') errors.push(label + ' console.error: ' + m.text()) }) @@ -584,7 +588,7 @@ async function freigabe(browser, base) { check('freigabe: Plattform-Zeile: Art-Badge „Antwort auf Plattform-Anfrage“', (await textOf(plat.locator('.wk-pult-head'))).includes('Antwort auf Plattform-Anfrage')) const link = plat.getByRole('link', { name: `Anzeige öffnen: ${T_PLAT}`, exact: true }) check('freigabe: „Anzeige öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await link.getAttribute('href')) === HAIR_URL && (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer') - check('freigabe: Plattform-Zeile (offen): Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen']), await buttonNames(plat)) + check('freigabe: Plattform-Zeile (offen): Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen (P4), Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(plat)) const mailTo = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href') check('freigabe: Mail-Zeile: Link „E-Mail schreiben“ mit mailto-Adresse, Betreff und Text', typeof mailTo === 'string' && mailTo.startsWith('mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=Guten%20Tag%20Herr%20Beispiel'), mailTo) check('freigabe: Mail-Zeile: kein „Kopieren & öffnen“ (Antwort geht per Mailprogramm)', (await mail.getByRole('button', { name: 'Kopieren & öffnen' }).count()) === 0) @@ -700,7 +704,7 @@ async function freigabe(browser, base) { await waitTitles(page, [T_ALT]) check('freigabe: „Veraltet“ zeigt den veralteten Eintrag mit Badge „veraltet“', (await textOf(rowOf(page, T_ALT).locator('.wk-pult-head'))).includes('veraltet') && (await filterButtons.nth(1).getAttribute('aria-pressed')) === 'true') check('freigabe: Filterwechsel räumt die Meldung oben weg', (await finishedLine(page).count()) === 0) - check('freigabe: veralteter Eintrag lässt sich weiter bearbeiten (Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen']), await buttonNames(rowOf(page, T_ALT))) + check('freigabe: veralteter Eintrag lässt sich weiter bearbeiten (Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(rowOf(page, T_ALT))) await shoot(page, 'desktop-freigabe-veraltet.png') await rowOf(page, T_ALT).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() await page.locator('.empty').waitFor() @@ -710,7 +714,14 @@ async function freigabe(browser, base) { await filterButtons.nth(2).click() await waitTitles(page, [T_MAIL, T_PLAT, T_UNS, T_GES, T_ERL]) - check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), ohne Aktionsknöpfe', (await page.locator('.wk-pult-actions').count()) === 0) + // Seit P4 nimmt eine gesendete Plattform-Antwort auch danach noch die Antwort des Kunden an: dort steht der eine Knopf „Antwort des Kunden einfügen“; + // verworfene und erledigte Zeilen sowie die gesendete Mail-Antwort haben keine Aktionen mehr + const doneActions = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({ + title: row.querySelector('.wk-pult-title').textContent, + actions: [...row.querySelectorAll('.wk-pult-actions > *')].map((el) => el.textContent.trim()), + }))) + check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), Aktion nur „Antwort des Kunden einfügen“ an den gesendeten Plattform-Antworten', + doneActions.length === 5 && doneActions.every((r) => same(r.actions, [T_PLAT, T_GES].includes(r.title) ? ['Antwort des Kunden einfügen'] : [])), doneActions) const doneMeta = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({ badge: [...row.querySelectorAll('.wk-pult-head .badge')].map((b) => b.textContent.trim()).filter((t) => ['Gesendet', 'Verworfen', 'Erledigt'].includes(t)), meta: row.querySelector('.wk-pult-meta').textContent.trim(), @@ -982,9 +993,688 @@ async function handyFreigabe(browser, base) { } +// ---------- P4: Gespräche und Werkstatt (Pult: als Mensch antworten, Vorschau freigeben und bauen, Antwort des Kunden einfügen) ---------- +// Eigener Datensatz (?p4=1, scripted-api.js p4Fixtures). Wartet: Mail-Antwort, zwei Vorschau-Freigaben (eine mit gültiger, eine mit falscher +// Vorschau-Adresse), „unsicher“ und ein Eintrag, den ein Kollege hält (fünf Zeilen). Erledigt: zwei gesendete Plattform-Antworten desselben +// Interessenten (Standard-Platz und Platz „angebot“). Die Schalter der Skript-API (faults.reply, .approve-preview, .start-build, .paste und +// pultDelayMs) steuern 400/409/413 und die Wartezeit; pultCalls hält fest, was die Oberfläche geschickt hat. + +const P4_URL = '/index.html?page=1&fault=none&p4=1' +const T4 = { + mail: 'Anfrage: Vereinswebsite für den SV Namborn', + frei: 'Vorschau freigeben: Friseur Muster', + falsch: 'Vorschau freigeben: Bäckerei Korn', + uns: 'Unsichere Antwort: Ist das ein Ja?', + koll: 'Anfrage: Tischlerei Holz', + plat: 'Kleinanzeigen: Website für Fotostudio', + ang: 'Kleinanzeigen: Website für Fotostudio (Angebot)', +} +const PREVIEW_URL = 'https://entwurf.project.webklar.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/' +const GONE = 'Dieser Kontakt ist nicht mehr aktiv.' +const TOO_BIG = 'Anfrage zu groß.' +const NEED_TEXT = 'Bitte einen Text eingeben.' +const TOO_LONG_8000 = 'Der Text ist zu lang (höchstens 8000 Zeichen).' +const PASTE_HINT = 'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.' +const PASTE_OK = 'Eingefügt. Nora sortiert die Antwort ein.' +const PASTE_TWICE = 'Diese Antwort war schon eingefügt.' +const NO_PREVIEW_URL = 'Keine gültige Vorschau-Adresse hinterlegt.' + +const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +// Zeile mit genau diesem Titel (rowOf sucht nur einen Teil des Titels: „… (Angebot)“ träfe sonst auch die Zeile davor) +const row4 = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: new RegExp('^' + escapeRe(title) + '$') }) }) +const actionNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim())) +const callsOf = async (page, action, id) => (await pultCalls(page)).filter((c) => c.action === action && (id === undefined || c.id === id)) +const setDelay = (page, ms) => page.evaluate((v) => { window.__wk.api.pultDelayMs = v }, ms) +const noteOf = (row) => row.locator('.msg') +const focusInfo = (page) => page.evaluate(() => { + const el = document.activeElement + if (!el) return { tag: null } + return { + tag: el.tagName, + label: el.getAttribute('aria-label'), + probe: el.getAttribute('data-probe'), + text: (el.textContent || '').trim().slice(0, 30), + inReply: Boolean(el.closest('.wk-pult-reply')), + inPaste: Boolean(el.closest('.wk-pult-paste')), + inDialog: Boolean(el.closest('[role="dialog"]')), + } +}) +const phoneFree = async (page) => { + const html = await page.evaluate(() => document.documentElement.outerHTML) + const text = await page.evaluate(() => document.body.innerText) + return !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text) +} +const widthOf = (page) => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth })) +// Schaltflächen und Links: im Fenster, nicht verdeckt, Höhe in px (Tippfläche) +const reachOf = (page, selector) => page.evaluate((sel) => { + const out = [] + for (const el of document.querySelectorAll(sel)) { + el.scrollIntoView({ block: 'center' }) + const r = el.getBoundingClientRect() + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) + out.push({ text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28), left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height), covered: !(hit && (hit === el || el.contains(hit))) }) + } + return out +}, selector) +const badReach = (list) => list.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40) + +// Reiter „Freigabe“ mit dem P4-Datensatz: fünf wartende Zeilen und die Mitarbeiternamen sind da +async function openP4(page, base) { + await page.goto(base + P4_URL + '&tab=freigabe') + await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 5) + await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin')) + await page.evaluate(() => document.fonts.ready) +} +const chooseFilter4 = (page, label) => page.locator('.wk-pult-filters .seg button', { hasText: label }).click() +async function openP4Done(page, base) { + await openP4(page, base) + await chooseFilter4(page, 'Erledigt') + await waitTitles(page, [T4.ang, T4.plat]) +} + +// ---------- Szenario K: „Als Mensch antworten“ (Desktop 1280 x 900) ---------- + +async function antworten(browser, base) { + const { context, page } = await newPultPage(browser, 'antworten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + await openP4(page, base) + const mail = row4(page, T4.mail) + const uns = row4(page, T4.uns) + const koll = row4(page, T4.koll) + const draft = (await page.evaluate(() => window.__wk.api.pult('offen,in_arbeit'))).items.find((i) => i.$id === 'p4-mail').draft + await topOfPage(page) + await shoot(page, 'antworten-wartet.png') + + check('antworten: wartende Zeilen neueste zuerst (Mail, Freigabe, Freigabe mit falscher Adresse, unsicher, Kollege)', same(await rowTitles(page), [T4.mail, T4.frei, T4.falsch, T4.uns, T4.koll]), await rowTitles(page)) + check('antworten: Mail-Zeile: E-Mail schreiben, Ich kümmere mich, Als gesendet markieren, Als Mensch antworten, Verwerfen', same(await actionNames(mail), ['E-Mail schreiben', 'Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen']), await actionNames(mail)) + check('antworten: Kollegen-Zeile (von Justin übernommen) bietet kein „Als Mensch antworten“', (await koll.getByRole('button', { name: 'Als Mensch antworten' }).count()) === 0, await actionNames(koll)) + + // 1. Aufklappen: vorbelegt mit dem Entwurf, Zähler, eigener Name des Felds + const opener = mail.getByRole('button', { name: 'Als Mensch antworten', exact: true }) + check('antworten: vor dem Öffnen kein Antwortfeld, Knopf mit aria-expanded=false', (await mail.locator('.wk-pult-reply').count()) === 0 && (await opener.getAttribute('aria-expanded')) === 'false') + await opener.click() + const panel = mail.locator('.wk-pult-reply') + await panel.waitFor() + const area = panel.locator('textarea') + const count = () => textOf(panel.locator('.wk-pult-count')) + const send = panel.getByRole('button', { name: 'Jetzt senden', exact: true }) + check('antworten: „Als Mensch antworten“ klappt das Feld auf (aria-expanded=true), Textfeld heißt „Text der Antwort: …“', (await opener.getAttribute('aria-expanded')) === 'true' && (await area.getAttribute('aria-label')) === `Text der Antwort: ${T4.mail}`, await area.getAttribute('aria-label')) + check('antworten: das Feld ist mit dem Entwurf vorbelegt', (await area.inputValue()) === draft) + check(`antworten: Zähler zeigt die Zeichen des Entwurfs („${Array.from(draft.trim()).length} / 8000“)`, (await count()) === `${Array.from(draft.trim()).length} / 8000`, await count()) + check('antworten: Knopf „Jetzt senden“ im Fuß des Felds', (await send.count()) === 1) + await shoot(page, 'antworten-offen-vorbelegt.png') + + // 2. Tippen: der Zähler zählt Zeichen (Code Points), der Entwurf in der Zeile bleibt unberührt + await area.fill('Hallo 😀') + check('antworten: Zähler zählt mit (ein Emoji ist ein Zeichen: „7 / 8000“)', (await count()) === '7 / 8000', await count()) + check('antworten: das Antwortfeld ändert den Entwurf der Zeile nicht (getrennte Felder)', (await mail.getByLabel(`Entwurf der Antwort: ${T4.mail}`, { exact: true }).inputValue()) === draft) + + // 3. Leerer Text: Fehler in der Oberfläche, kein Aufruf am Dienst + await area.fill('') + await send.click() + const err = mail.locator('.msg.err') + await err.waitFor() + check('antworten: leerer Text ⇒ „Bitte einen Text eingeben.“ (rot, role=alert), kein Aufruf am Dienst', (await textOf(err)) === NEED_TEXT && (await err.getAttribute('role')) === 'alert' && (await callsOf(page, 'reply')).length === 0, { text: await textOf(err), calls: (await callsOf(page, 'reply')).length }) + await shoot(page, 'antworten-leer-fehler.png') + await area.fill(' \n \t ') + await send.click() + check('antworten: nur Leerraum ⇒ derselbe Satz, weiterhin kein Aufruf', (await textOf(err)) === NEED_TEXT && (await callsOf(page, 'reply')).length === 0) + + // 4. Zu lang: 8001 Zeichen (Emoji zählen als eins) ⇒ Fehler, Zähler rot; genau 8000 Emoji sind erlaubt + await area.fill('😀'.repeat(8001)) + check('antworten: 8001 Zeichen: Zähler „8001 / 8000“ ist rot (Klasse over)', (await count()) === '8001 / 8000' && (await panel.locator('.wk-pult-count').getAttribute('class')).includes('over'), await count()) + await send.click() + await page.waitForFunction((t) => document.querySelector('li.wk-pult-row .msg.err') && document.querySelector('li.wk-pult-row .msg.err').textContent === t, TOO_LONG_8000) + check('antworten: 8001 Zeichen ⇒ „Der Text ist zu lang (höchstens 8000 Zeichen).“, kein Aufruf am Dienst', (await callsOf(page, 'reply')).length === 0) + await shoot(page, 'antworten-zu-lang.png') + await area.fill('😀'.repeat(8000)) + check('antworten: genau 8000 Emoji (16000 UTF-16-Einheiten): Zähler „8000 / 8000“ ohne Rot', (await count()) === '8000 / 8000' && !(await panel.locator('.wk-pult-count').getAttribute('class')).includes('over'), await count()) + + // 5. 409 vom Dienst: der Satz steht in der Zeile, der Text bleibt, die Zeile bleibt, der Knopf ist wieder frei + const answer = ' Guten Tag Herr Beispiel,\n\nanbei die Vorschau, wie besprochen.\n\nBeste Grüße\nKenso \n' + await area.fill(answer) + await setPultFault(page, 'reply', { status: 409, message: CONFLICT }) + await send.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), CONFLICT) + check('antworten: 409 ⇒ der Satz des Dienstes steht in der Zeile (rot, role=alert)', (await textOf(noteOf(mail))) === CONFLICT && (await noteOf(mail).getAttribute('role')) === 'alert', await textOf(noteOf(mail))) + check('antworten: nach dem 409 bleiben Zeile, Text und Feld erhalten, „Jetzt senden“ ist wieder frei', (await mail.count()) === 1 && (await area.inputValue()) === answer && (await send.isEnabled()) && (await mail.getByRole('button', { name: 'Verwerfen' }).isEnabled())) + await shoot(page, 'antworten-409.png') + await setPultFault(page, 'reply', { status: 409, message: GONE }) + await send.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE) + check('antworten: zweiter 409 ersetzt die Meldung („Dieser Kontakt ist nicht mehr aktiv.“), nicht angehäuft', (await noteOf(mail).count()) === 1 && (await textOf(noteOf(mail))) === GONE) + await setPultFault(page, 'reply', { status: 413, message: TOO_BIG }) + await send.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), TOO_BIG) + check('antworten: 413 ⇒ „Anfrage zu groß.“ in der Zeile, Text bleibt', (await textOf(noteOf(mail))) === TOO_BIG && (await area.inputValue()) === answer) + + // Verbindung bricht beim Senden ab: ob die Mail hinausging, ist offen (D14), die Meldung sagt das und schickt nicht zum zweiten Mal + await setPultFault(page, 'reply', { status: 0, message: 'Verbindung weg' }) + await send.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Kann gesendet sein – Pult neu laden.') + check('antworten: Verbindungsabbruch beim Senden ⇒ „Kann gesendet sein – Pult neu laden.“ (nicht „nicht gesendet“), Text bleibt', (await textOf(noteOf(mail))) === 'Kann gesendet sein – Pult neu laden.' && (await area.inputValue()) === answer, await textOf(noteOf(mail))) + + // 6. Senden: während die Anfrage läuft sind alle Knöpfe der Zeile gesperrt, ein zweiter Klick schickt nichts, danach Erfolgsmeldung oben + await setPultFault(page, 'reply', null) + await setDelay(page, 1500) + const before = (await callsOf(page, 'reply')).length + await send.click() + const lockedNames = ['Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen'] + const locked = [] + for (const name of lockedNames) locked.push(await mail.getByRole('button', { name, exact: true }).isDisabled()) + check('antworten: während die Anfrage läuft sind „Jetzt senden“ und alle Knöpfe der Zeile gesperrt', (await send.isDisabled()) && locked.every(Boolean), locked) + await send.evaluate((el) => el.click()) // ein zweiter Klick auf den gesperrten Knopf darf nichts auslösen + await shoot(page, 'antworten-pending.png') + await finishedLine(page).first().waitFor() + const done = await textOf(finishedLine(page).first()) + check('antworten: Erfolg ⇒ Meldung oben „…“ gesendet. (role=status), die Zeile ist weg', done === `„${T4.mail}“ gesendet.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && (await mail.count()) === 0, done) + const replyCalls = await callsOf(page, 'reply') + const last = replyCalls[replyCalls.length - 1] + check('antworten: Doppelklick während der Anfrage ergibt genau einen Aufruf; gesendet wird der getrimmte Text als { text }', replyCalls.length === before + 1 && last.id === 'p4-mail' && same(last.body, { text: answer.trim() }), { calls: replyCalls.length, before, last }) + check('antworten: die Antworten der Fehlerfälle gingen alle mit demselben getrimmten Text hinaus', replyCalls.slice(0, before).every((c) => c.body && c.body.text === answer.trim()), replyCalls.map((c) => c.body && c.body.text && c.body.text.length)) + await shoot(page, 'antworten-gesendet.png') + await setDelay(page, 0) + + // 7. Eintrag „unsicher“: Antwortfeld ohne Vorbelegung, Text überlebt Zuklappen und Filterwechsel + check('antworten: „unsicher“-Zeile: Ich kümmere mich, Als Mensch antworten, Vorschau bauen, Erledigt, Verwerfen (kein Mail-Link, kein Entwurf)', same(await actionNames(uns), ['Ich kümmere mich', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await actionNames(uns)) + const unsOpener = uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }) + await unsOpener.click() + const unsPanel = uns.locator('.wk-pult-reply') + await unsPanel.waitFor() + const unsArea = unsPanel.locator('textarea') + check('antworten: „unsicher“: Antwortfeld ist leer (kein Entwurf), Zähler „0 / 8000“', (await unsArea.inputValue()) === '' && (await textOf(unsPanel.locator('.wk-pult-count'))) === '0 / 8000') + await unsArea.fill('Zwischenstand der Antwort') + await unsOpener.click() + check('antworten: zweiter Klick auf „Als Mensch antworten“ klappt das Feld zu (aria-expanded=false)', (await uns.locator('.wk-pult-reply').count()) === 0 && (await unsOpener.getAttribute('aria-expanded')) === 'false') + await unsOpener.click() + check('antworten: wieder aufgeklappt steht der Zwischenstand noch im Feld', (await uns.locator('.wk-pult-reply textarea').inputValue()) === 'Zwischenstand der Antwort') + await chooseFilter4(page, 'Veraltet') + await page.locator('.empty').waitFor() + await chooseFilter4(page, 'Wartet') + await waitTitles(page, [T4.frei, T4.falsch, T4.uns, T4.koll]) + await unsOpener.click() + check('antworten: der Text überlebt einen Wechsel des Filters', (await uns.locator('.wk-pult-reply textarea').inputValue()) === 'Zwischenstand der Antwort') + await shoot(page, 'antworten-unsicher.png') + await setPultFault(page, 'reply', { status: 400, message: NEED_TEXT }) + await uns.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click() + await uns.locator('.msg.err').waitFor() + check('antworten: 400 vom Dienst („Bitte einen Text eingeben.“) erscheint in der Zeile, die Zeile bleibt', (await textOf(uns.locator('.msg.err'))) === NEED_TEXT && (await uns.count()) === 1) + await setPultFault(page, 'reply', null) + check('antworten: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page)) + await context.close() +} + +// ---------- Szenario L: Vorschau freigeben (Desktop) ---------- + +async function vorschau(browser, base) { + const { context, page } = await newPultPage(browser, 'vorschau', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + await openP4(page, base) + const frei = row4(page, T4.frei) + const falsch = row4(page, T4.falsch) + await topOfPage(page) + await frei.scrollIntoViewIfNeeded() + await shoot(page, 'vorschau-wartet.png') + + // 1. Zeile und Link + check('vorschau: Art-Badge „Vorschau freigeben“, Knöpfe Ich kümmere mich, Vorschau ansehen, Freigeben & senden, Verwerfen (kein Erledigt, kein Als Mensch antworten)', + (await textOf(frei.locator('.wk-pult-head .badge').first())) === 'Vorschau freigeben' && same(await actionNames(frei), ['Ich kümmere mich', 'Vorschau ansehen', 'Freigeben & senden', 'Verwerfen']), await actionNames(frei)) + const link = frei.getByRole('link', { name: `Vorschau ansehen: ${T4.frei}`, exact: true }) + check('vorschau: „Vorschau ansehen“ hat genau die https-Adresse der Vorschau', (await link.getAttribute('href')) === PREVIEW_URL, await link.getAttribute('href')) + check('vorschau: „Vorschau ansehen“ öffnet im neuen Tab (target=_blank, rel=noopener noreferrer)', (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer') + check('vorschau: das sichtbare Wort „Vorschau ansehen“ steht im Namen vorn', (await textOf(link)) === 'Vorschau ansehen' && (await link.getAttribute('aria-label')).startsWith('Vorschau ansehen')) + check('vorschau: kein Link „Anzeige öffnen“ in der Zeile (die Adresse ist die Vorschau, keine Anzeige)', (await frei.getByRole('link', { name: /Anzeige öffnen/ }).count()) === 0) + check('vorschau: Hinweis „Verwerfen“ sendet die Vorschau nicht.', (await textOf(frei.locator('.wk-pult-actions'))).includes('„Verwerfen“ sendet die Vorschau nicht.')) + + // 2. Vorschau-Adresse mit http statt https: kein Link, Hinweis + const fakeLinks = await falsch.locator('a').evaluateAll((els) => els.map((el) => el.getAttribute('href'))) + check('vorschau: Vorschau mit http-Adresse wird kein Link (Hinweis „Keine gültige Vorschau-Adresse hinterlegt.“)', !fakeLinks.some((href) => /entwurf\.project\.webklar\.com/.test(href || '')) && (await textOf(falsch.locator('.wk-pult-actions'))).includes(NO_PREVIEW_URL), fakeLinks) + const falschApprove = falsch.getByRole('button', { name: 'Freigeben & senden', exact: true }) + metrics.freigabeOhneVorschauLink = { knopfDa: (await falschApprove.count()) === 1, knopfFrei: (await falschApprove.count()) === 1 && (await falschApprove.isEnabled()) } + await falsch.scrollIntoViewIfNeeded() + await shoot(page, 'vorschau-falsche-adresse.png') + + // 3. Erster Klick fragt, nach 4 s verfällt die Frage + const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ }) + await frei.scrollIntoViewIfNeeded() + await approveBtn.click() + check('vorschau: erster Klick fragt „Wirklich freigeben?“, noch keine Aktion am Dienst', (await textOf(approveBtn)) === 'Wirklich freigeben?' && (await callsOf(page, 'approve-preview')).length === 0, await textOf(approveBtn)) + await shoot(page, 'vorschau-frage.png') + await sleep(4400) + check('vorschau: nach 4 s verfällt die Rückfrage (wieder „Freigeben & senden“), noch immer keine Aktion', (await textOf(approveBtn)) === 'Freigeben & senden' && (await callsOf(page, 'approve-preview')).length === 0, await textOf(approveBtn)) + + // 4. Fehler vom Dienst (409): Satz in der Zeile, Zeile bleibt, Frage ist wieder zurückgesetzt + await setPultFault(page, 'approve-preview', { status: 409, message: GONE }) + await approveBtn.click() + await approveBtn.click() + await frei.locator('.msg.err').waitFor() + check('vorschau: 409 ⇒ „Dieser Kontakt ist nicht mehr aktiv.“ in der Zeile (rot, role=alert), Zeile bleibt', (await textOf(frei.locator('.msg.err'))) === GONE && (await frei.locator('.msg.err').getAttribute('role')) === 'alert' && (await frei.count()) === 1, await textOf(frei.locator('.msg.err'))) + check('vorschau: nach dem Fehler fragt der Knopf wieder von vorn („Freigeben & senden“, frei)', (await textOf(approveBtn)) === 'Freigeben & senden' && (await approveBtn.isEnabled())) + await shoot(page, 'vorschau-409.png') + await setPultFault(page, 'approve-preview', { status: 409, message: CONFLICT }) + await approveBtn.click() + await approveBtn.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), CONFLICT) + check('vorschau: zweiter 409 („Wird schon von einem Kollegen bearbeitet.“) ersetzt die Meldung', (await frei.locator('.msg').count()) === 1 && (await textOf(frei.locator('.msg'))) === CONFLICT) + + await setPultFault(page, 'approve-preview', { status: 0, message: 'Verbindung weg' }) + await approveBtn.click() + await approveBtn.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Kann gesendet sein – Pult neu laden.') + check('vorschau: Verbindungsabbruch bei der Freigabe ⇒ „Kann gesendet sein – Pult neu laden.“, die Zeile bleibt', (await textOf(frei.locator('.msg'))) === 'Kann gesendet sein – Pult neu laden.' && (await frei.count()) === 1) + + // 5. Erfolg: zweiter Klick binnen 4 s sendet, währenddessen sind die Knöpfe gesperrt + await setPultFault(page, 'approve-preview', null) + await setDelay(page, 1500) + const before = (await callsOf(page, 'approve-preview')).length + await approveBtn.click() + await approveBtn.click() + const lockedNames = ['Ich kümmere mich', 'Freigeben & senden', 'Verwerfen'] + const locked = [] + for (const name of lockedNames) locked.push(await frei.getByRole('button', { name, exact: true }).isDisabled()) + check('vorschau: während die Freigabe läuft sind „Freigeben & senden“, „Ich kümmere mich“ und „Verwerfen“ gesperrt', locked.every(Boolean), locked) + await approveBtn.evaluate((el) => el.click()) // weiterer Klick auf den gesperrten Knopf + await shoot(page, 'vorschau-pending.png') + await finishedLine(page).first().waitFor() + const done = await textOf(finishedLine(page).first()) + check('vorschau: Erfolg ⇒ Meldung oben „…“ freigegeben. Max sendet den Link im nächsten Sendefenster. (role=status), Zeile weg', done === `„${T4.frei}“ freigegeben. Max sendet den Link im nächsten Sendefenster.` && (await frei.count()) === 0 && (await finishedLine(page).first().getAttribute('role')) === 'status', done) + const calls = await callsOf(page, 'approve-preview', 'p4-freigabe') + check('vorschau: genau ein erfolgreicher Aufruf ohne Body (drei Fehlversuche davor, der Doppelklick im Warten zählt nicht)', calls.length === before + 1 && calls.every((c) => c.body === undefined), calls) + await shoot(page, 'vorschau-freigegeben.png') + await setDelay(page, 0) + check('vorschau: die andere Freigabe (falsche Adresse) und die übrigen Zeilen bleiben unberührt', same(await rowTitles(page), [T4.mail, T4.falsch, T4.uns, T4.koll]), await rowTitles(page)) + await context.close() +} + +// ---------- Szenario M: Antwort des Kunden einfügen (Desktop) ---------- + +async function einfuegen(browser, base) { + const { context, page } = await newPultPage(browser, 'einfuegen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + await openP4Done(page, base) + const plat = row4(page, T4.plat) + const ang = row4(page, T4.ang) + await topOfPage(page) + await shoot(page, 'einfuegen-erledigt.png') + + // 1. Zwei Zeilen desselben Interessenten, beide gesendet, beide bieten das Einfügen + check('einfuegen: Erledigt zeigt zwei gesendete Plattform-Antworten desselben Interessenten (Standard-Platz und „angebot“)', same(await rowTitles(page), [T4.ang, T4.plat]), await rowTitles(page)) + check('einfuegen: beide Zeilen sind „Gesendet von Kenso“ und bieten genau „Antwort des Kunden einfügen“', same(await actionNames(plat), ['Antwort des Kunden einfügen']) && same(await actionNames(ang), ['Antwort des Kunden einfügen']) && (await textOf(plat.locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso') && (await textOf(ang.locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso'), { plat: await actionNames(plat), ang: await actionNames(ang) }) + const platOpen = plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }) + const angOpen = ang.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }) + + // 2. Aufklappen: Hinweis, leeres Feld, Zähler, Knopf + check('einfuegen: vor dem Öffnen kein Feld, Knopf mit aria-expanded=false', (await plat.locator('.wk-pult-paste').count()) === 0 && (await platOpen.getAttribute('aria-expanded')) === 'false') + await platOpen.click() + const platPanel = plat.locator('.wk-pult-paste') + await platPanel.waitFor() + const platArea = platPanel.locator('textarea') + const platSend = platPanel.getByRole('button', { name: 'Einfügen', exact: true }) + check('einfuegen: Hinweistext „Kopiere die Antwort aus der Plattform hierher. …“', (await textOf(platPanel.locator('.wk-hint'))) === PASTE_HINT, await textOf(platPanel.locator('.wk-hint'))) + check('einfuegen: Textfeld ist leer, heißt „Antwort des Kunden: …“, Zähler „0 / 8000“, Knopf „Einfügen“', (await platArea.inputValue()) === '' && (await platArea.getAttribute('aria-label')) === `Antwort des Kunden: ${T4.plat}` && (await textOf(platPanel.locator('.wk-pult-count'))) === '0 / 8000' && (await platSend.count()) === 1 && (await platOpen.getAttribute('aria-expanded')) === 'true') + check('einfuegen: erste Zeile offen, zweite Zeile bleibt zu', (await ang.locator('.wk-pult-paste').count()) === 0) + + // 3. Leerer Text: Fehler in der Oberfläche, kein Aufruf + await platSend.click() + await plat.locator('.msg.err').waitFor() + check('einfuegen: leerer Text ⇒ „Bitte einen Text eingeben.“ (rot, role=alert), kein Aufruf am Dienst', (await textOf(plat.locator('.msg.err'))) === NEED_TEXT && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await callsOf(page, 'paste')).length === 0, { text: await textOf(plat.locator('.msg.err')), calls: (await callsOf(page, 'paste')).length }) + await shoot(page, 'einfuegen-leer-fehler.png') + + // 4. Beide Zeilen unabhängig: eigene Felder, eigene Meldungen + await platArea.fill('Antwort Kunde A') + await angOpen.click() + const angPanel = ang.locator('.wk-pult-paste') + await angPanel.waitFor() + const angArea = angPanel.locator('textarea') + const angSend = angPanel.getByRole('button', { name: 'Einfügen', exact: true }) + check('einfuegen: zweite Zeile öffnet ein eigenes, leeres Feld (kein Text der ersten)', (await angArea.inputValue()) === '' && (await platArea.inputValue()) === 'Antwort Kunde A') + await angArea.fill('Antwort Kunde B') + check('einfuegen: beide Felder gleichzeitig offen, jedes mit eigenem Text und eigenem Zähler', (await platArea.inputValue()) === 'Antwort Kunde A' && (await angArea.inputValue()) === 'Antwort Kunde B' && (await textOf(platPanel.locator('.wk-pult-count'))) === '15 / 8000' && (await textOf(angPanel.locator('.wk-pult-count'))) === '15 / 8000') + await shoot(page, 'einfuegen-zwei-offen.png') + check('einfuegen: die Fehlermeldung der ersten Zeile steht nur dort', (await ang.locator('.msg').count()) === 0) + + // 5. Fehler vom Dienst: 413, 400 und 409 mit deutschem Satz; Text und Feld bleiben + await setPultFault(page, 'paste', { status: 413, message: TOO_BIG }) + await platSend.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), TOO_BIG) + check('einfuegen: 413 ⇒ „Anfrage zu groß.“ (rot, role=alert) in der ersten Zeile; Feld und Text bleiben, Knopf frei', (await textOf(plat.locator('.msg.err'))) === TOO_BIG && (await platArea.inputValue()) === 'Antwort Kunde A' && (await platSend.isEnabled()), await textOf(plat.locator('.msg.err'))) + await shoot(page, 'einfuegen-413.png') + await setPultFault(page, 'paste', { status: 400, message: NEED_TEXT }) + await platSend.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), NEED_TEXT) + check('einfuegen: 400 vom Dienst ⇒ „Bitte einen Text eingeben.“, Meldung ersetzt die vorige', (await plat.locator('.msg').count()) === 1 && (await textOf(plat.locator('.msg'))) === NEED_TEXT) + await shoot(page, 'einfuegen-400.png') + await setPultFault(page, 'paste', { status: 409, message: GONE }) + await platSend.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE) + check('einfuegen: 409 ⇒ deutscher Satz des Dienstes in der Zeile', (await textOf(plat.locator('.msg'))) === GONE) + check('einfuegen: die zweite Zeile zeigt von all dem nichts (keine Meldung, Text bleibt)', (await ang.locator('.msg').count()) === 0 && (await angArea.inputValue()) === 'Antwort Kunde B') + await setPultFault(page, 'paste', { status: 0, message: 'Verbindung weg' }) + await platSend.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Der Hustler-Dienst ist gerade nicht erreichbar.') + check('einfuegen: Verbindungsabbruch ⇒ „Der Hustler-Dienst ist gerade nicht erreichbar.“, Text bleibt im Feld', (await textOf(plat.locator('.msg'))) === 'Der Hustler-Dienst ist gerade nicht erreichbar.' && (await platArea.inputValue()) === 'Antwort Kunde A') + await setPultFault(page, 'paste', null) + + // 6. Erfolg der ersten Zeile: gesperrt beim Warten, Meldung in der Zeile, Feld leer und zu, Zeile bleibt, zweite Zeile unberührt. + // Die Wartezeit liegt unter der Zeitgrenze des Prüfstands (1,5 s, entry.jsx): „paste“ hat anders als reply, approve-preview und start-build + // keine lange Frist (SLOW_PULT_ACTIONS), eine längere Wartezeit endete hier als „nicht erreichbar“. + await setDelay(page, 900) + await platSend.click() + check('einfuegen: während die Anfrage läuft sind „Einfügen“ und „Antwort des Kunden einfügen“ der Zeile gesperrt', (await platSend.isDisabled()) && (await platOpen.isDisabled())) + check('einfuegen: die zweite Zeile bleibt währenddessen bedienbar', (await angSend.isEnabled()) && (await angOpen.isEnabled())) + await platSend.evaluate((el) => el.click()) + await shoot(page, 'einfuegen-pending.png') + await plat.locator('.msg.ok').waitFor() + await setDelay(page, 0) + check('einfuegen: Erfolg ⇒ „Eingefügt. Nora sortiert die Antwort ein.“ (grün, role=status) in der Zeile', (await textOf(plat.locator('.msg.ok'))) === PASTE_OK && (await plat.locator('.msg.ok').getAttribute('role')) === 'status', await textOf(plat.locator('.msg'))) + check('einfuegen: danach ist das Feld zu, die Zeile bleibt in der Liste, die zweite Zeile ist unberührt (Feld offen, Text, keine Meldung)', (await plat.locator('.wk-pult-paste').count()) === 0 && (await plat.count()) === 1 && (await ang.locator('.wk-pult-paste textarea').inputValue()) === 'Antwort Kunde B' && (await ang.locator('.msg').count()) === 0) + const pasteCalls = await callsOf(page, 'paste') + const okCall = pasteCalls[pasteCalls.length - 1] + check('einfuegen: der Aufruf geht an den Eintrag der ersten Zeile mit { text: „Antwort Kunde A“ }; Doppelklick im Warten zählt nicht', okCall.id === 'p4-plat-gesendet' && same(okCall.body, { text: 'Antwort Kunde A' }) && pasteCalls.filter((c) => c.id === 'p4-plat-gesendet').length === 5 && pasteCalls.every((c) => c.id !== 'p4-plat-angebot'), pasteCalls) + await shoot(page, 'einfuegen-erfolg.png') + + // 7. Dieselbe Antwort noch einmal: „doppelt“ + await platOpen.click() + const again = plat.locator('.wk-pult-paste textarea') + await again.waitFor() + check('einfuegen: wieder geöffnet ist das Feld leer (nach dem Erfolg geleert)', (await again.inputValue()) === '') + await again.fill('Antwort Kunde A') + await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg')).some((el) => el.textContent === t), PASTE_TWICE) + check('einfuegen: dieselbe Antwort ein zweites Mal ⇒ „Diese Antwort war schon eingefügt.“', (await textOf(plat.locator('.msg'))) === PASTE_TWICE, await textOf(plat.locator('.msg'))) + + // 8. Zweite Zeile sendet für sich + await angSend.click() + await ang.locator('.msg.ok').waitFor() + const angCall = (await callsOf(page, 'paste', 'p4-plat-angebot')).pop() + check('einfuegen: zweite Zeile schickt ihren eigenen Text an ihren eigenen Eintrag, ihre Meldung steht nur dort', (await textOf(ang.locator('.msg.ok'))) === PASTE_OK && angCall && same(angCall.body, { text: 'Antwort Kunde B' }) && (await textOf(plat.locator('.msg'))) === PASTE_TWICE) + await shoot(page, 'einfuegen-beide-fertig.png') + check('einfuegen: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page)) + await context.close() +} + +// ---------- Szenario N: Vorschau bauen (Desktop) ---------- + +async function bauen(browser, base) { + const { context, page } = await newPultPage(browser, 'bauen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + await openP4(page, base) + const uns = row4(page, T4.uns) + const build = uns.getByRole('button', { name: /^(Vorschau bauen|Wirklich bauen\?)$/ }) + await uns.scrollIntoViewIfNeeded() + await shoot(page, 'bauen-zeile.png') + check('bauen: Zeile „unsicher“ bietet „Vorschau bauen“ (neben Ich kümmere mich, Als Mensch antworten, Erledigt, Verwerfen)', same(await actionNames(uns), ['Ich kümmere mich', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen']), await actionNames(uns)) + check('bauen: die Kollegen-Zeile und die Mail-Zeile bieten kein „Vorschau bauen“', (await row4(page, T4.koll).getByRole('button', { name: 'Vorschau bauen' }).count()) === 0 && (await row4(page, T4.mail).getByRole('button', { name: 'Vorschau bauen' }).count()) === 0) + + // 1. Fehler vom Dienst + await setPultFault(page, 'start-build', { status: 409, message: 'Für diesen Interessenten gibt es schon eine Vorschau.' }) + await build.click() + await uns.locator('.msg.err').waitFor() + check('bauen: 409 ⇒ „Für diesen Interessenten gibt es schon eine Vorschau.“ in der Zeile (rot, role=alert), Zeile bleibt', (await textOf(uns.locator('.msg.err'))) === 'Für diesen Interessenten gibt es schon eine Vorschau.' && (await uns.locator('.msg.err').getAttribute('role')) === 'alert' && (await uns.count()) === 1, await textOf(uns.locator('.msg.err'))) + check('bauen: nach dem Fehler ist „Vorschau bauen“ wieder frei', await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).isEnabled()) + await shoot(page, 'bauen-409.png') + await setPultFault(page, 'start-build', { status: 409, message: GONE }) + await build.click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE) + check('bauen: zweiter 409 („Dieser Kontakt ist nicht mehr aktiv.“) ersetzt die Meldung', (await uns.locator('.msg').count()) === 1 && (await textOf(uns.locator('.msg'))) === GONE) + await setPultFault(page, 'start-build', null) + + // 2. Fragt „Vorschau bauen“ nach? Festgestellt wird nur (metrics.bauenFragtNach): ruft der erste Klick den Dienst auf, gibt es keine Rückfrage + // (anders als bei Freigeben und Verwerfen). Fragt der Knopf nach, bestätigt der zweite Klick, damit der Rest des Szenarios gleich läuft. + await setDelay(page, 1500) + const before = (await callsOf(page, 'start-build')).length + await build.click() + await sleep(150) + const afterFirst = (await callsOf(page, 'start-build')).length + metrics.bauenFragtNach = afterFirst === before + metrics.bauenErsterKlickRuftDienst = afterFirst === before + 1 + if (afterFirst === before) await build.click() + check('bauen: während der Bau angelegt wird sind alle Knöpfe der Zeile gesperrt', (await uns.getByRole('button', { name: 'Ich kümmere mich', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Erledigt', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Verwerfen', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).isDisabled())) + await shoot(page, 'bauen-pending.png') + await finishedLine(page).first().waitFor() + await setDelay(page, 0) + check('bauen: Erfolg ⇒ Meldung oben „…“ wird gebaut. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T4.uns}“ wird gebaut.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && (await uns.count()) === 0, await textOf(finishedLine(page).first())) + const calls = await callsOf(page, 'start-build', 'p4-unsicher') + check('bauen: genau ein erfolgreicher Aufruf ohne Body (nach den zwei Fehlversuchen)', calls.length === before + 1 && calls.every((c) => c.body === undefined), calls) + await shoot(page, 'bauen-gestartet.png') + await context.close() +} + +// ---------- Szenario O: Fokus der aufgeklappten Felder und nach den Aktionen (Desktop) ---------- + +async function fokus(browser, base) { + const { context, page } = await newPultPage(browser, 'fokus', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + await openP4(page, base) + const mail = row4(page, T4.mail) + const noBody = (info) => info.tag !== null && info.tag !== 'BODY' + + // Esc schließt das Feld; gibt es keinen Esc-Weg, zählt ein Knopf „Abbrechen“. Ergebnis, ob eines von beiden das Feld schließt und wo dann der Fokus ist. + async function leaveByKeyboard(row, panelSelector, probe) { + const detached = (ms) => row.locator(panelSelector).waitFor({ state: 'detached', timeout: ms }).then(() => true, () => false) + await page.keyboard.press('Escape') + let how = 'esc' + let closed = await detached(700) + if (!closed) { + const cancel = row.locator(panelSelector).getByRole('button', { name: 'Abbrechen', exact: true }) + if ((await cancel.count()) > 0) { + how = 'abbrechen' + await cancel.click() + closed = await detached(700) + } else { + how = 'weder Esc noch Abbrechen' + } + } + return { how, closed, focus: await focusInfo(page), onOpener: (await focusInfo(page)).probe === probe } + } + + // 1. „Als Mensch antworten“ + const replyOpener = mail.getByRole('button', { name: 'Als Mensch antworten', exact: true }) + await replyOpener.evaluate((el) => el.setAttribute('data-probe', 'reply-opener')) + await replyOpener.click() + await mail.locator('.wk-pult-reply').waitFor() + const f1 = await focusInfo(page) + check('fokus: „Als Mensch antworten“ öffnen ⇒ der Fokus liegt im Textfeld des Antwortfelds', f1.tag === 'TEXTAREA' && f1.inReply, f1) + await shoot(page, 'fokus-antwort-offen.png') + const l1 = await leaveByKeyboard(mail, '.wk-pult-reply', 'reply-opener') + check('fokus: Esc oder „Abbrechen“ schließt das Antwortfeld und gibt den Fokus an „Als Mensch antworten“ zurück', l1.closed && l1.onOpener, l1) + if (!l1.closed) { + await replyOpener.click() // Rückweg über den Knopf selbst, damit der Rest des Szenarios weiterläuft + await mail.locator('.wk-pult-reply').waitFor({ state: 'detached' }) + } + const f1b = await focusInfo(page) + check('fokus: zweiter Klick auf „Als Mensch antworten“ schließt das Feld, der Fokus bleibt auf dem Knopf (nicht auf )', (await mail.locator('.wk-pult-reply').count()) === 0 && f1b.probe === 'reply-opener', f1b) + + // 2. „Details“ (Dialog) + const details = mail.getByRole('button', { name: `Details zu ${T4.mail}`, exact: true }) + await details.evaluate((el) => el.setAttribute('data-probe', 'details-opener')) + await details.click() + await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor() + await sleep(500) + check('fokus: „Details“ öffnen ⇒ der Fokus liegt im Dialog', (await focusInfo(page)).inDialog, await focusInfo(page)) + await page.keyboard.press('Escape') + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('fokus: Esc schließt „Details“, der Fokus kehrt auf „Details“ zurück', (await focusInfo(page)).probe === 'details-opener', await focusInfo(page)) + + // 3. Fokus nach Fehler und nach Erfolg + await replyOpener.click() + const area = mail.locator('.wk-pult-reply textarea') + await area.waitFor() + await setPultFault(page, 'reply', { status: 409, message: CONFLICT }) + await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click() + await mail.locator('.msg.err').waitFor() + await sleep(200) + const fErr = await focusInfo(page) + check('fokus: nach einem Fehler (409) beim Senden steht der Fokus nicht auf (Knopf oder Feld)', noBody(fErr), fErr) + await setPultFault(page, 'reply', null) + await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click() + await finishedLine(page).first().waitFor() + await sleep(200) + const fReply = await focusInfo(page) + check('fokus: nach dem erfolgreichen Senden einer Antwort (Zeile verschwindet) steht der Fokus nicht auf ', noBody(fReply), fReply) + + const frei = row4(page, T4.frei) + const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ }) + await approveBtn.click() + await approveBtn.click() + await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'freigegeben') + await sleep(200) + const fApprove = await focusInfo(page) + check('fokus: nach „Freigeben & senden“ (Zeile verschwindet) steht der Fokus nicht auf ', noBody(fApprove), fApprove) + + const uns = row4(page, T4.uns) + await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).click() + await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'wird gebaut') + await sleep(200) + const fBuild = await focusInfo(page) + check('fokus: nach „Vorschau bauen“ (Zeile verschwindet) steht der Fokus nicht auf ', noBody(fBuild), fBuild) + + // 4. „Antwort des Kunden einfügen“ (Reiter Erledigt) + await chooseFilter4(page, 'Erledigt') + await waitTitles(page, [T4.mail, T4.frei, T4.uns, T4.ang, T4.plat]) // Mail, Freigabe und unsicher sind durch die Aktionen davor hierher gewandert + const plat = row4(page, T4.plat) + const pasteOpener = plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }) + await pasteOpener.evaluate((el) => el.setAttribute('data-probe', 'paste-opener')) + await pasteOpener.click() + await plat.locator('.wk-pult-paste').waitFor() + const f4 = await focusInfo(page) + check('fokus: „Antwort des Kunden einfügen“ öffnen ⇒ der Fokus liegt im Textfeld', f4.tag === 'TEXTAREA' && f4.inPaste, f4) + await shoot(page, 'fokus-einfuegen-offen.png') + const l4 = await leaveByKeyboard(plat, '.wk-pult-paste', 'paste-opener') + check('fokus: Esc oder „Abbrechen“ schließt das Einfüge-Feld und gibt den Fokus an „Antwort des Kunden einfügen“ zurück', l4.closed && l4.onOpener, l4) + if (!l4.closed) { + await pasteOpener.click() + await plat.locator('.wk-pult-paste').waitFor({ state: 'detached' }) + } + await pasteOpener.click() + await plat.locator('.wk-pult-paste textarea').fill('Danke, das passt.') + await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click() + await plat.locator('.msg.ok').waitFor() + await sleep(200) + const fPaste = await focusInfo(page) + check('fokus: nach dem erfolgreichen Einfügen (Feld klappt zu) steht der Fokus nicht auf ', noBody(fPaste), fPaste) + + // Zum Vergleich (nur festgehalten, kein Prüfpunkt): „Ich kümmere mich“ aus P3 behält die Zeile, der Knopf ist beim Warten gesperrt + await chooseFilter4(page, 'Wartet') + await waitTitles(page, [T4.falsch, T4.koll]) + await row4(page, T4.falsch).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() + await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row')).some((el) => el.textContent.includes(t) && el.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')), T4.falsch) + await sleep(200) + metrics.fokusNachIchKuemmereMich = (await focusInfo(page)).tag + await context.close() +} + +// ---------- Szenario P: Handy 390 x 844 für alle Felder und Zustände ---------- + +async function handyP4(browser, base) { + const { context, page } = await newPultPage(browser, 'handy-p4', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) + await openP4(page, base) + await sleep(300) + const mail = row4(page, T4.mail) + const frei = row4(page, T4.frei) + const falsch = row4(page, T4.falsch) + const uns = row4(page, T4.uns) + const noScroll = async (label, detail) => { + const w = await widthOf(page) + check(`handy-p4: ${label}: kein seitliches Scrollen (scrollWidth <= innerWidth)`, w.scroll <= w.inner, { ...w, ...(detail || {}) }) + } + const ACTIONS = '.wk-pult-actions > a, .wk-pult-actions > button' + + // 1. Liste + await noScroll('Liste Wartet') + await topOfPage(page) + await shoot(page, 'handy-p4-wartet.png') + await shoot(page, 'handy-p4-wartet-ganz.png', { fullPage: true }) + const reach1 = await reachOf(page, ACTIONS) + check('handy-p4: alle Aktionsknöpfe und Links der Liste sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach1.length >= 15 && badReach(reach1).length === 0, { count: reach1.length, bad: badReach(reach1) }) + + // 2. „Als Mensch antworten“ mit sehr langem Wort, Fehlermeldung + await mail.scrollIntoViewIfNeeded() + await mail.getByRole('button', { name: 'Als Mensch antworten', exact: true }).click() + const panel = mail.locator('.wk-pult-reply') + await panel.waitFor() + await sleep(200) + await noScroll('Antwortfeld offen') + const reach2 = await reachOf(page, '.wk-pult-reply textarea, .wk-pult-reply .wk-pult-panel-foot .btn') + const area = panel.locator('textarea') + const areaBox = await area.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height) } }) + check('handy-p4: Antwortfeld: Textfeld und „Jetzt senden“ im Fenster, nicht verdeckt, Knopf mindestens 40 px hoch, Feld mindestens 100 px hoch', reach2.length === 2 && badReach(reach2).length === 0 && areaBox.left >= 0 && areaBox.right <= 390 && areaBox.height >= 100, { reach2, areaBox }) + await panel.scrollIntoViewIfNeeded() + await shoot(page, 'handy-p4-antwort-offen.png') + await area.fill('x'.repeat(400) + ' https://www.sv-namborn.example/' + 'lang/'.repeat(30)) + await noScroll('Antwortfeld mit sehr langem Text') + await setPultFault(page, 'reply', { status: 409, message: 'Wird schon von einem Kollegen bearbeitet.' }) + await panel.getByRole('button', { name: 'Jetzt senden', exact: true }).click() + await mail.locator('.msg.err').waitFor() + await setPultFault(page, 'reply', null) + await noScroll('Antwortfeld mit Fehlermeldung') + await mail.locator('.msg.err').scrollIntoViewIfNeeded() + await shoot(page, 'handy-p4-antwort-409.png') + + // 3. Vorschau-Freigabe: Frage, Link, falsche Adresse + await frei.scrollIntoViewIfNeeded() + const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ }) + await approveBtn.click() + check('handy-p4: Freigabe-Zeile fragt „Wirklich freigeben?“', (await textOf(approveBtn)) === 'Wirklich freigeben?') + await noScroll('Freigabe-Zeile mit Rückfrage') + await shoot(page, 'handy-p4-freigabe-frage.png') + await falsch.scrollIntoViewIfNeeded() + await shoot(page, 'handy-p4-freigabe-falsche-adresse.png') + + // 4. „unsicher“: Antwortfeld leer, „Vorschau bauen“ + await uns.scrollIntoViewIfNeeded() + await uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }).click() + await uns.locator('.wk-pult-reply').waitFor() + await noScroll('Zeile unsicher mit Antwortfeld') + const reach4 = await reachOf(page, 'li.wk-pult-row .wk-pult-reply .btn, li.wk-pult-row .wk-pult-actions > button') + check('handy-p4: alle Knöpfe von Antwortfeld und Aktionen sind erreichbar (nicht verdeckt, im Fenster, mindestens 40 px hoch)', reach4.length >= 10 && badReach(reach4).length === 0, { count: reach4.length, bad: badReach(reach4) }) + await uns.scrollIntoViewIfNeeded() + await shoot(page, 'handy-p4-unsicher.png') + + // 5. „Details“ als Panel + const details = uns.getByRole('button', { name: `Details zu ${T4.uns}`, exact: true }) + await details.scrollIntoViewIfNeeded() + await details.click() + const dialog = page.getByRole('dialog', { name: 'Gasthaus Sonne', exact: true }) + await dialog.waitFor() + await dialog.locator('.wk-prospect').waitFor() + await sleep(700) + const box = await dialog.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight } }) + check('handy-p4: „Details“-Panel liegt im Fenster (links >= 0, rechts <= 390, unten <= Fensterhöhe)', box.left >= 0 && box.right <= 390 && box.top >= 0 && box.bottom <= box.inner, box) + await noScroll('Details-Panel offen') + await shoot(page, 'handy-p4-details.png') + const closeBtn = dialog.getByRole('button', { name: 'Schließen', exact: true }).last() + const closeReach = await closeBtn.evaluate((el) => { const r = el.getBoundingClientRect(); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), height: Math.round(r.height), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) } }) + check('handy-p4: „Schließen“ im Details-Panel ist erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.bottom <= closeReach.inner && !closeReach.covered && closeReach.height >= 40, closeReach) + await closeBtn.click() + await page.getByRole('dialog').waitFor({ state: 'detached' }) + + // 6. Erledigt: „Antwort des Kunden einfügen“ in beiden Zeilen, 413 + await chooseFilter4(page, 'Erledigt') + await waitTitles(page, [T4.ang, T4.plat]) + const plat = row4(page, T4.plat) + const ang = row4(page, T4.ang) + await noScroll('Liste Erledigt') + await topOfPage(page) + await shoot(page, 'handy-p4-erledigt.png') + await plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }).click() + await ang.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }).click() + await plat.locator('.wk-pult-paste').waitFor() + await ang.locator('.wk-pult-paste').waitFor() + await plat.locator('.wk-pult-paste textarea').fill('Antwort Kunde A') + await setPultFault(page, 'paste', { status: 413, message: TOO_BIG }) + await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click() + await plat.locator('.msg.err').waitFor() + await setPultFault(page, 'paste', null) + await noScroll('beide Einfüge-Felder offen, 413 sichtbar') + const reach6 = await reachOf(page, '.wk-pult-paste textarea, .wk-pult-paste .wk-pult-panel-foot .btn, li.wk-pult-row .wk-pult-actions > button') + check('handy-p4: Einfüge-Felder, „Einfügen“ und „Antwort des Kunden einfügen“ sind erreichbar (im Fenster, nicht verdeckt, Knöpfe mindestens 40 px hoch)', reach6.length === 6 && badReach(reach6).length === 0, { count: reach6.length, bad: badReach(reach6) }) + await plat.scrollIntoViewIfNeeded() + await shoot(page, 'handy-p4-einfuegen-413.png') + await shoot(page, 'handy-p4-einfuegen-ganz.png', { fullPage: true }) + check('handy-p4: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page)) + 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], ]; (async () => { @@ -999,6 +1689,7 @@ const SCENARIOS = [ await run(browser, base) } catch (e) { problems.push('Abbruch in Szenario ' + name + ': ' + (e && e.stack ? e.stack : String(e))) + if (lastPage) await shoot(lastPage, 'abbruch-' + name + '.png', { fullPage: true }).catch(() => {}) } } } finally {