Workers P3: Prüfstand um Freigabe, Interessenten-Panel, Anhalten-Rückfrage und Handy erweitert
Echte Seite WorkersPage im Prüfstand (Austausch-Module für Anmeldung, Mitarbeiterliste und Sitzungs-JWT), Skript-API mit Pult, Aktionen und Interessent samt Schaltern für 409, 400, Ausfall und Leerzustand, vier neue Szenarien (Freigabe, Interessent, Anhalten, Handy), jedes Szenario läuft für sich, und der Szenario Desktop bestätigt jetzt die Rückfrage vor dem Anhalten. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
686db61e1c
commit
2ed2201b22
9 files changed
+847
-20
No files matched your search
@@ -2,6 +2,9 @@
|
|||||||
// entry.jsx -> harness.js + harness.css (esbuild, Entwicklungsmodus, damit StrictMode wie in der Entwicklung zweimal einhängt), dazu index.html.
|
// entry.jsx -> harness.js + harness.css (esbuild, Entwicklungsmodus, damit StrictMode wie in der Entwicklung zweimal einhängt), dazu index.html.
|
||||||
// outDir muss ein neuer oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo sein.
|
// outDir muss ein neuer oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo sein.
|
||||||
// Die Schrift /fonts/* bleibt außen vor: der Server von shot.cjs liefert sie aus public/fonts.
|
// Die Schrift /fonts/* bleibt außen vor: der Server von shot.cjs liefert sie aus public/fonts.
|
||||||
|
// Drei Module der App hängen an Appwrite (Anmeldung, Mitarbeiterliste, Sitzungs-JWT) und werden nur im Prüfstand ausgetauscht, damit die
|
||||||
|
// echte Seite WorkersPage (Reiter Hochhaus und Freigabe) samt PultList und ProspectPanel ohne Netz läuft: AuthContext -> stub-auth.js,
|
||||||
|
// useEmployees -> stub-employees.js, workersClient -> stub-client.js. Am Quelltext der App ändert das nichts.
|
||||||
import esbuild from 'esbuild'
|
import esbuild from 'esbuild'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
@@ -29,6 +32,20 @@ if (fs.existsSync(out) && fs.readdirSync(out).length > 0) {
|
|||||||
}
|
}
|
||||||
fs.mkdirSync(out, { recursive: true })
|
fs.mkdirSync(out, { recursive: true })
|
||||||
|
|
||||||
|
const STUBS = [
|
||||||
|
{ filter: /(^|\/)context\/AuthContext(\.jsx?)?$/, file: 'stub-auth.js' },
|
||||||
|
{ filter: /(^|\/)hooks\/useEmployees(\.jsx?)?$/, file: 'stub-employees.js' },
|
||||||
|
{ filter: /(^|\/)lib\/workersClient(\.jsx?)?$/, file: 'stub-client.js' },
|
||||||
|
]
|
||||||
|
const stubPlugin = {
|
||||||
|
name: 'harness-stubs',
|
||||||
|
setup(build) {
|
||||||
|
for (const stub of STUBS) {
|
||||||
|
build.onResolve({ filter: stub.filter }, () => ({ path: path.join(here, stub.file) }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const result = await esbuild.build({
|
const result = await esbuild.build({
|
||||||
entryPoints: [path.join(here, 'entry.jsx')],
|
entryPoints: [path.join(here, 'entry.jsx')],
|
||||||
outfile: path.join(out, 'harness.js'),
|
outfile: path.join(out, 'harness.js'),
|
||||||
@@ -37,6 +54,7 @@ const result = await esbuild.build({
|
|||||||
platform: 'browser',
|
platform: 'browser',
|
||||||
jsx: 'automatic',
|
jsx: 'automatic',
|
||||||
external: ['/fonts/*'],
|
external: ['/fonts/*'],
|
||||||
|
plugins: [stubPlugin],
|
||||||
define: { 'process.env.NODE_ENV': '"development"' },
|
define: { 'process.env.NODE_ENV': '"development"' },
|
||||||
logLevel: 'warning',
|
logLevel: 'warning',
|
||||||
metafile: true,
|
metafile: true,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
// ?fault=none|down|session|hang schaltet zwischen Board und Skript-API den echten API-Client (createWorkersApi) mit einer Fetch-Weiche
|
// ?fault=none|down|session|hang schaltet zwischen Board und Skript-API den echten API-Client (createWorkersApi) mit einer Fetch-Weiche
|
||||||
// (scripted-fetch.js): window.__wk.control.fault lässt sich zur Laufzeit auf null | 'down' | 'session' | 'hang' setzen (Ausfall-Szenario).
|
// (scripted-fetch.js): window.__wk.control.fault lässt sich zur Laufzeit auf null | 'down' | 'session' | 'hang' setzen (Ausfall-Szenario).
|
||||||
// Ohne den Parameter bekommt das Board die Skript-API direkt (Szenarien A bis E).
|
// 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 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).
|
||||||
import { StrictMode, createRef } from 'react'
|
import { StrictMode, createRef } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
@@ -11,9 +14,11 @@ import '../../src/styles/global.css'
|
|||||||
import '../../src/styles/workers.css'
|
import '../../src/styles/workers.css'
|
||||||
import PageHeader from '../../src/components/ui/PageHeader'
|
import PageHeader from '../../src/components/ui/PageHeader'
|
||||||
import WorkersBoard from '../../src/components/workers/WorkersBoard'
|
import WorkersBoard from '../../src/components/workers/WorkersBoard'
|
||||||
|
import WorkersPage from '../../src/pages/WorkersPage'
|
||||||
import { createWorkersApi } from '../../src/lib/workersApi.js'
|
import { createWorkersApi } from '../../src/lib/workersApi.js'
|
||||||
import { createScriptedApi } from './scripted-api.js'
|
import { createScriptedApi } from './scripted-api.js'
|
||||||
import { createScriptedFetch } from './scripted-fetch.js'
|
import { createScriptedFetch } from './scripted-fetch.js'
|
||||||
|
import { holder } from './harness-bridge.js'
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const scripted = createScriptedApi({ now: () => performance.now(), base: Date.now(), halted: params.get('halted') === '1' })
|
const scripted = createScriptedApi({ now: () => performance.now(), base: Date.now(), halted: params.get('halted') === '1' })
|
||||||
@@ -32,18 +37,27 @@ const api = fault === null
|
|||||||
timeoutMs: 1500,
|
timeoutMs: 1500,
|
||||||
})
|
})
|
||||||
window.__wk = { stage, api: scripted, control }
|
window.__wk = { stage, api: scripted, control }
|
||||||
|
holder.api = api
|
||||||
|
|
||||||
|
const pageMode = params.get('page') === '1'
|
||||||
|
const tab = params.get('tab')
|
||||||
|
const entries = [pageMode && tab ? `/hustler?tab=${encodeURIComponent(tab)}` : '/hustler']
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<MemoryRouter>
|
<MemoryRouter initialEntries={entries}>
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<div className="sidebar" aria-hidden="true" />
|
<div className="sidebar" aria-hidden="true" />
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<main id="inhalt">
|
<main id="inhalt">
|
||||||
<div className="page wk-page">
|
{pageMode ? (
|
||||||
<PageHeader title="Hustler" subtitle="Die Nachtschicht deiner Claude-Armee" />
|
<WorkersPage />
|
||||||
<WorkersBoard api={api} isAdmin stageRef={stage} />
|
) : (
|
||||||
</div>
|
<div className="page wk-page">
|
||||||
|
<PageHeader title="Hustler" subtitle="Die Nachtschicht deiner Claude-Armee" />
|
||||||
|
<WorkersBoard api={api} isAdmin stageRef={stage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Brücke zwischen entry.jsx und den Austausch-Modulen (stub-*.js): entry.jsx legt die Skript-API (oder den echten API-Client über
|
||||||
|
// der Fetch-Weiche) hier ab, stub-client.js reicht Aufrufe der Seite WorkersPage daran weiter. Kein Netz, kein Appwrite.
|
||||||
|
export const holder = { api: null }
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
// Kein Netz, kein Appwrite, keine echte Zeit: die Uhr `now` (Millisekunden) wird von außen gegeben, die Basiszeit `base` ebenso.
|
// Kein Netz, kein Appwrite, keine echte Zeit: die Uhr `now` (Millisekunden) wird von außen gegeben, die Basiszeit `base` ebenso.
|
||||||
// Die Antworten verhalten sich wie der Dienst P1: events(after) liefert aufsteigend alle fälligen Einträge ab `after - 5 s` (Überlappung,
|
// Die Antworten verhalten sich wie der Dienst P1: events(after) liefert aufsteigend alle fälligen Einträge ab `after - 5 s` (Überlappung,
|
||||||
// die Entdopplung übernimmt der Feed), höchstens `limit` Stück, die ältesten zuerst; nichts aus der Zukunft.
|
// die Entdopplung übernimmt der Feed), höchstens `limit` Stück, die ältesten zuerst; nichts aus der Zukunft.
|
||||||
|
// Freigabe-Pult und Interessent (P3): pult, pultAction und prospect antworten mit festen Daten in der Form der Team-Routen des Dienstes
|
||||||
|
// (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.
|
||||||
import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js'
|
import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js'
|
||||||
import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js'
|
import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js'
|
||||||
|
|
||||||
@@ -132,6 +136,180 @@ const TRACE = Object.freeze([
|
|||||||
['auftrag', 'Auftrag von Otto angenommen', null],
|
['auftrag', 'Auftrag von Otto angenommen', null],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// ---------- Freigabe-Pult und Interessenten (P3) ----------
|
||||||
|
// Form wie die Team-Routen des Dienstes (workers-dev/src/api/anfragenRoutes.js): Pult-Einträge mit allen Feldern aus §4.11 (nur die gesetzten),
|
||||||
|
// dazu `prospect` als Kurzfassung (kein Telefon, kein Anfragetext); die Ansicht des Interessenten nur mit den Feldern der Whitelist.
|
||||||
|
// Telefonnummern gibt es in diesen Daten nirgends: die Prüfung "kein Telefon im Bild" ist damit eine Prüfung der Oberfläche, nicht der Daten.
|
||||||
|
const ME = 'mensch:u-me' // der angemeldete Prüfstand-Nutzer (stub-auth.js: Kennung u-me, Name Kenso)
|
||||||
|
const COLLEAGUE = 'mensch:u-kollege' // der Kollege, den stub-employees.js als „Justin“ kennt
|
||||||
|
const HOUR_MS = 3600 * 1000
|
||||||
|
const DAY_MS = 24 * HOUR_MS
|
||||||
|
const PULT_STATUS = ['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt']
|
||||||
|
const PULT_LIMIT = 50
|
||||||
|
const STATUS_ERROR = `Der Status ist ungültig. Erlaubt sind: ${PULT_STATUS.join(', ')}.`
|
||||||
|
const REPLY_KINDS = ['plattform_antwort', 'mail_antwort']
|
||||||
|
// Felder der Kurzfassung in der Liste (kurzSicht) und der Ansicht des Interessenten (interessentSicht): kein Telefon, keine Nachweise
|
||||||
|
const PROSPECT_SHORT = ['entrance', 'name', 'source', 'sourceUrl', 'city', 'postalCode', 'email', 'score', 'scoreReason', 'requestedAt', 'status']
|
||||||
|
const PROSPECT_VIEW = [
|
||||||
|
'entrance', 'room', 'foundBy', 'source', 'sourceUrl', 'foundAt', 'name', 'contactName', 'email', 'city', 'postalCode',
|
||||||
|
'requestText', 'requestedAt', 'score', 'scoreReason', 'status', 'threadId', 'previewId', 'offerId', 'customerId',
|
||||||
|
]
|
||||||
|
const pick = (record, keys) => Object.fromEntries(keys.filter((key) => record[key] !== undefined).map((key) => [key, record[key]]))
|
||||||
|
|
||||||
|
const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1'
|
||||||
|
const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1'
|
||||||
|
const NORD_URL = 'https://www.freelancermap.de/projekt/wordpress-relaunch-agentur'
|
||||||
|
const FOTO_URL = 'https://www.kleinanzeigen.de/s-anzeige/portfolio-seite-fotograf/2870000003-1-1'
|
||||||
|
const SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein'
|
||||||
|
|
||||||
|
// Lange Wörter ohne Leerzeichen: sie müssen am Handy umbrechen, statt die Seite zu verbreitern
|
||||||
|
const LONG_WORD = 'https://www.sv-namborn.example/vereinsleben/abteilungen/fussball/jugend/spielplan-und-trainingszeiten-saison-2026-2027-alle-mannschaften'
|
||||||
|
|
||||||
|
function pultFixtures(base) {
|
||||||
|
const at = (ms) => isoOf(base + ms)
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
$id: 'pult-mail',
|
||||||
|
kind: 'mail_antwort',
|
||||||
|
prospectId: 'p-mail',
|
||||||
|
threadId: 'thr-mail',
|
||||||
|
title: 'Anfrage: Vereinswebsite für den SV Namborn',
|
||||||
|
originalText: `Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht. Siehe auch ${LONG_WORD}\nBudget bis 1.500 Euro.`,
|
||||||
|
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(-2.5 * HOUR_MS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'pult-plat',
|
||||||
|
kind: 'plattform_antwort',
|
||||||
|
prospectId: 'p-plat',
|
||||||
|
title: 'Kleinanzeigen: Website für Friseursalon gesucht',
|
||||||
|
originalText: 'Suche jemanden für eine einfache Website mit Preisliste und Terminanfrage.\nBitte mit Referenzen melden.',
|
||||||
|
draft: 'Hallo, ich habe Ihre Anzeige gesehen. Wir bauen Ihnen kostenlos eine erste Vorschau, gern mit Preisliste und Terminanfrage.',
|
||||||
|
sourceUrl: HAIR_URL,
|
||||||
|
status: 'offen',
|
||||||
|
createdAt: at(-5 * HOUR_MS),
|
||||||
|
staleAt: at(20 * HOUR_MS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'pult-kollege',
|
||||||
|
kind: 'plattform_antwort',
|
||||||
|
prospectId: 'p-kollege',
|
||||||
|
title: 'Kleinanzeigen: Homepage für Handwerksbetrieb',
|
||||||
|
originalText: 'Brauche eine Homepage für meinen Meisterbetrieb.',
|
||||||
|
draft: 'Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage, ohne Kosten und ohne Verpflichtung.',
|
||||||
|
sourceUrl: WEBER_URL,
|
||||||
|
status: 'in_arbeit',
|
||||||
|
claimedBy: COLLEAGUE,
|
||||||
|
claimedAt: at(-1 * HOUR_MS),
|
||||||
|
createdAt: at(-8 * HOUR_MS),
|
||||||
|
staleAt: at(3 * DAY_MS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'pult-unsicher',
|
||||||
|
kind: 'unsicher',
|
||||||
|
title: 'Unsichere Antwort: bitte prüfen',
|
||||||
|
originalText: 'Nora war sich bei der Einordnung einer Antwort nicht sicher und bittet um einen Blick.',
|
||||||
|
status: 'offen',
|
||||||
|
createdAt: at(-26 * HOUR_MS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'pult-alt',
|
||||||
|
kind: 'plattform_antwort',
|
||||||
|
prospectId: 'p-alt',
|
||||||
|
title: 'freelancermap: WordPress-Relaunch',
|
||||||
|
originalText: 'Relaunch einer bestehenden WordPress-Seite.',
|
||||||
|
draft: 'Guten Tag, wir übernehmen den Relaunch gern und zeigen vorab eine Vorschau.',
|
||||||
|
sourceUrl: NORD_URL,
|
||||||
|
status: 'veraltet',
|
||||||
|
createdAt: at(-6 * DAY_MS),
|
||||||
|
staleAt: at(-3 * DAY_MS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'pult-gesendet',
|
||||||
|
kind: 'plattform_antwort',
|
||||||
|
prospectId: 'p-gesendet',
|
||||||
|
title: 'Kleinanzeigen: Fotograf sucht Portfolio-Seite',
|
||||||
|
draft: 'Hallo, wir bauen Ihnen gern eine Portfolio-Seite. Eine erste Vorschau gibt es kostenlos.',
|
||||||
|
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: 'pult-erledigt',
|
||||||
|
kind: 'beschwerde',
|
||||||
|
title: 'Beschwerde über eine Vorschau',
|
||||||
|
originalText: 'Der Kunde findet die Vorschau unvollständig.',
|
||||||
|
status: 'erledigt',
|
||||||
|
doneBy: COLLEAGUE,
|
||||||
|
doneAt: at(-3 * DAY_MS),
|
||||||
|
createdAt: at(-4 * DAY_MS),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Interessenten: Kurzfassung (kurzSicht) und Zusatzfelder der Ansicht (interessentSicht); nur Felder aus der Whitelist
|
||||||
|
const prospects = {
|
||||||
|
'p-mail': {
|
||||||
|
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', 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',
|
||||||
|
requestText: `Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht.\n\nBudget bis 1.500 Euro, Start im November. Mehr dazu: ${LONG_WORD}`,
|
||||||
|
requestedAt: at(-3 * HOUR_MS), score: 84, scoreReason: 'Klarer Auftrag, Budget genannt, Region passt.',
|
||||||
|
status: 'bewertet', threadId: 'thr-mail', previewId: 'prev-mail',
|
||||||
|
},
|
||||||
|
'p-plat': {
|
||||||
|
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: HAIR_URL,
|
||||||
|
foundAt: at(-5 * HOUR_MS), name: 'Friseursalon Haarmonie', city: 'Kaiserslautern', postalCode: '67655',
|
||||||
|
requestText: 'Suche jemanden für eine einfache Website mit Preisliste und Terminanfrage.\nBitte mit Referenzen melden.',
|
||||||
|
requestedAt: at(-5 * HOUR_MS), score: 72, scoreReason: 'Passt zum Angebot, Budget unklar.', status: 'bewertet',
|
||||||
|
},
|
||||||
|
'p-kollege': {
|
||||||
|
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: WEBER_URL,
|
||||||
|
foundAt: at(-8 * HOUR_MS), name: 'Meisterbetrieb Weber', city: 'Landstuhl', postalCode: '66849', status: 'gefunden',
|
||||||
|
},
|
||||||
|
'p-alt': {
|
||||||
|
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'freelancermap-alarm', sourceUrl: NORD_URL,
|
||||||
|
foundAt: at(-6 * DAY_MS), name: 'Agentur Nord', city: 'Hamburg', postalCode: '20095', score: 65, status: 'bewertet',
|
||||||
|
scoreReason: 'Relaunch, aber weit weg.',
|
||||||
|
},
|
||||||
|
'p-gesendet': {
|
||||||
|
entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: FOTO_URL,
|
||||||
|
foundAt: at(-2 * DAY_MS), name: 'Foto Lichtblick', city: 'Mainz', postalCode: '55116', score: 78, status: 'kontaktiert',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faden, Nachrichten und Vorschau gibt es nur für den Mail-Interessenten (zwei Nachrichten: eine eingegangene, eine von einem Kollegen)
|
||||||
|
const threads = {
|
||||||
|
'thr-mail': {
|
||||||
|
$id: 'thr-mail', prospectId: 'p-mail', channel: 'mail', toEmail: 'vorstand@sv-namborn.example', subject: 'Re: SV Namborn',
|
||||||
|
status: 'wartet_auf_uns', lastInboundAt: at(-1.5 * HOUR_MS), followupSent: false, reminderSent: false, createdAt: at(-3 * HOUR_MS),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const messages = {
|
||||||
|
'thr-mail': [
|
||||||
|
{
|
||||||
|
$id: 'msg-1', direction: 'in', author: 'kunde', subject: 'Anfrage Vereinswebsite', channel: 'mail', classification: 'frage',
|
||||||
|
body: `Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?\nDie Seite ist heute unter ${LONG_WORD} erreichbar.`,
|
||||||
|
sentAt: at(-3 * HOUR_MS), createdAt: at(-3 * HOUR_MS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: 'msg-2', direction: 'out', author: COLLEAGUE, subject: 'Re: SV Namborn', channel: 'mail',
|
||||||
|
body: 'Gern. Wir melden uns mit einer ersten Vorschau.',
|
||||||
|
sentAt: at(-2 * HOUR_MS), createdAt: at(-2 * HOUR_MS),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const previews = {
|
||||||
|
'prev-mail': { $id: 'prev-mail', prospectId: 'p-mail', builder: 'bob', status: 'fertig', round: 1, createdAt: at(-1 * HOUR_MS), deleteAfter: at(60 * DAY_MS) },
|
||||||
|
}
|
||||||
|
return { items, prospects, threads, messages, previews }
|
||||||
|
}
|
||||||
|
|
||||||
const clone = (v) => JSON.parse(JSON.stringify(v))
|
const clone = (v) => JSON.parse(JSON.stringify(v))
|
||||||
const isoOf = (ms) => new Date(ms).toISOString()
|
const isoOf = (ms) => new Date(ms).toISOString()
|
||||||
|
|
||||||
@@ -182,6 +360,12 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
|
|||||||
let paused = false
|
let paused = false
|
||||||
const settings = defaultSettings(halted === true)
|
const settings = defaultSettings(halted === true)
|
||||||
const saved = []
|
const saved = []
|
||||||
|
const fx = pultFixtures(base)
|
||||||
|
const pultItems = fx.items
|
||||||
|
const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null } // je { status, message } oder null
|
||||||
|
const pultCalls = []
|
||||||
|
const prospectCalls = []
|
||||||
|
const pauseCalls = []
|
||||||
|
|
||||||
// Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige
|
// Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige
|
||||||
const all = SCRIPT.map((s, i) => ({
|
const all = SCRIPT.map((s, i) => ({
|
||||||
@@ -227,9 +411,18 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notFound = () => Object.assign(new Error('Diesen Hustler gibt es nicht.'), { status: 404 })
|
const notFound = () => Object.assign(new Error('Diesen Hustler gibt es nicht.'), { status: 404 })
|
||||||
|
const httpError = (status, message) => Object.assign(new Error(message), { status })
|
||||||
|
const failure = (fault) => httpError(fault.status, fault.message)
|
||||||
|
const nowIsoString = () => isoOf(base + elapsed())
|
||||||
|
const shortOf = (id) => (Object.hasOwn(fx.prospects, id) ? { $id: id, ...pick(fx.prospects[id], PROSPECT_SHORT) } : null)
|
||||||
|
|
||||||
return {
|
const api = {
|
||||||
saved,
|
saved,
|
||||||
|
faults,
|
||||||
|
pultCalls,
|
||||||
|
prospectCalls,
|
||||||
|
pauseCalls,
|
||||||
|
pultEmpty: false, // true: das Pult ist leer (Leerzustand)
|
||||||
|
|
||||||
async state() {
|
async state() {
|
||||||
return clone(buildState())
|
return clone(buildState())
|
||||||
@@ -265,6 +458,7 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
|
|||||||
},
|
},
|
||||||
|
|
||||||
async pause(next) {
|
async pause(next) {
|
||||||
|
pauseCalls.push(Boolean(next))
|
||||||
paused = Boolean(next)
|
paused = Boolean(next)
|
||||||
return { paused }
|
return { paused }
|
||||||
},
|
},
|
||||||
@@ -282,20 +476,76 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false })
|
|||||||
return clone({ ...settings, paused })
|
return clone({ ...settings, paused })
|
||||||
},
|
},
|
||||||
|
|
||||||
async pult() {
|
// GET /pult?status=…: neueste zuerst, höchstens 50, mit der Kurzfassung des Interessenten (null ohne Interessent)
|
||||||
return { items: [] }
|
async pult(status = 'offen,in_arbeit') {
|
||||||
|
if (faults.pult) throw failure(faults.pult)
|
||||||
|
const list = [...new Set((Array.isArray(status) ? status : String(status).split(',')).map((entry) => String(entry).trim()))]
|
||||||
|
if (list.some((entry) => !PULT_STATUS.includes(entry))) throw httpError(400, STATUS_ERROR)
|
||||||
|
const rows = api.pultEmpty
|
||||||
|
? []
|
||||||
|
: pultItems.filter((item) => list.includes(item.status)).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, PULT_LIMIT)
|
||||||
|
return clone({ items: rows.map((item) => ({ ...item, prospect: shortOf(item.prospectId) })) })
|
||||||
},
|
},
|
||||||
|
|
||||||
async pultAction() {
|
// POST /pult/:id/<claim|sent|discard|done>: Regeln wie pult.js (claimPult, markSent, discard, donePult); Antwort { item }
|
||||||
return {}
|
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 (faults[action]) throw failure(faults[action])
|
||||||
|
const item = pultItems.find((entry) => entry.$id === id)
|
||||||
|
if (!item) throw httpError(404, 'Eintrag nicht gefunden.')
|
||||||
|
const editable = ['offen', 'in_arbeit', 'veraltet'].includes(item.status)
|
||||||
|
const prospect = Object.hasOwn(fx.prospects, item.prospectId) ? fx.prospects[item.prospectId] : null
|
||||||
|
const now = nowIsoString()
|
||||||
|
|
||||||
|
if (action === 'claim') {
|
||||||
|
if (item.status === 'in_arbeit') {
|
||||||
|
if (item.claimedBy !== ME) throw httpError(409, 'Wird schon von einem Kollegen bearbeitet.')
|
||||||
|
} else if (item.status === 'offen' || item.status === 'veraltet') {
|
||||||
|
Object.assign(item, { status: 'in_arbeit', claimedBy: ME, claimedAt: now })
|
||||||
|
} else {
|
||||||
|
throw httpError(409, 'Schon erledigt.')
|
||||||
|
}
|
||||||
|
} else if (action === 'sent') {
|
||||||
|
if (!REPLY_KINDS.includes(item.kind)) throw httpError(409, 'Dieser Eintrag ist keine Antwort zum Absenden.')
|
||||||
|
if (!editable) throw httpError(409, 'Schon erledigt.')
|
||||||
|
const text = body && typeof body.text === 'string' ? body.text.trim() : (item.draft ?? '').trim()
|
||||||
|
if (text === '') throw httpError(400, 'Der Text der Antwort fehlt.')
|
||||||
|
Object.assign(item, { status: 'gesendet', doneBy: ME, doneAt: now })
|
||||||
|
if (prospect && (prospect.status === 'gefunden' || prospect.status === 'bewertet')) prospect.status = 'kontaktiert'
|
||||||
|
} else if (action === 'discard') {
|
||||||
|
if (!editable) throw httpError(409, 'Schon erledigt.')
|
||||||
|
Object.assign(item, { status: 'verworfen', doneBy: ME, doneAt: now })
|
||||||
|
if (prospect && prospect.status === 'bewertet') prospect.status = 'verloren'
|
||||||
|
} else {
|
||||||
|
if (REPLY_KINDS.includes(item.kind)) throw httpError(409, 'Antworten schließt man mit „Als gesendet“ oder „Verwerfen“ ab.')
|
||||||
|
if (!editable) throw httpError(409, 'Schon erledigt.')
|
||||||
|
Object.assign(item, { status: 'erledigt', doneBy: ME, doneAt: now })
|
||||||
|
}
|
||||||
|
return clone({ item })
|
||||||
},
|
},
|
||||||
|
|
||||||
async botleads() {
|
async botleads() {
|
||||||
return { items: [], total: 0 }
|
return { items: [], total: 0 }
|
||||||
},
|
},
|
||||||
|
|
||||||
async prospect() {
|
// GET /prospect/:id: { prospect (nur Felder der Whitelist), thread, messages, preview, reply }; Faden und Vorschau fehlen als null
|
||||||
return {}
|
async prospect(id) {
|
||||||
|
prospectCalls.push(id)
|
||||||
|
if (faults.prospect) throw failure(faults.prospect)
|
||||||
|
if (!Object.hasOwn(fx.prospects, id)) throw httpError(404, 'Interessent nicht gefunden.')
|
||||||
|
const record = fx.prospects[id]
|
||||||
|
const thread = record.threadId && Object.hasOwn(fx.threads, record.threadId) ? fx.threads[record.threadId] : null
|
||||||
|
const preview = record.previewId && Object.hasOwn(fx.previews, record.previewId) ? fx.previews[record.previewId] : null
|
||||||
|
const reply = pultItems.find((item) => item.prospectId === id && REPLY_KINDS.includes(item.kind)) ?? null
|
||||||
|
return clone({
|
||||||
|
prospect: { $id: id, ...pick(record, PROSPECT_VIEW) },
|
||||||
|
thread,
|
||||||
|
messages: thread && Object.hasOwn(fx.messages, thread.$id) ? fx.messages[thread.$id] : [],
|
||||||
|
preview,
|
||||||
|
reply,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return api
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
// beantwortet sie mit der Skript-API. Damit läuft im Prüfstand dieselbe Kette wie im Betrieb (API-Client, Feed, Board), und die
|
// beantwortet sie mit der Skript-API. Damit läuft im Prüfstand dieselbe Kette wie im Betrieb (API-Client, Feed, Board), und die
|
||||||
// Ausfälle lassen sich schalten: control.fault = null (gesund) | 'down' (503 ohne JSON wie eine Fehlerseite des Proxys) |
|
// 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.
|
// '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/<Aktion>) und Interessent (GET /prospect/:id): Fehler der Skript-API (status + deutscher
|
||||||
|
// Satz) kommen als { error } mit diesem Status an, wie vom Dienst.
|
||||||
// control.calls sammelt jede Anfrage ({ method, path, jwt }), damit der Prüfstand zählen kann.
|
// control.calls sammelt jede Anfrage ({ method, path, jwt }), damit der Prüfstand zählen kann.
|
||||||
|
|
||||||
const reply = (status, body) => ({
|
const reply = (status, body) => ({
|
||||||
@@ -34,6 +36,12 @@ export function createScriptedFetch(api, control) {
|
|||||||
if (method === 'POST' && path === '/pause') return reply(200, await api.pause(body.paused))
|
if (method === 'POST' && path === '/pause') return reply(200, await api.pause(body.paused))
|
||||||
if (method === 'GET' && path === '/settings') return reply(200, await api.settings())
|
if (method === 'GET' && path === '/settings') return reply(200, await api.settings())
|
||||||
if (method === 'PUT' && path === '/settings') return reply(200, await api.saveSettings(body))
|
if (method === 'PUT' && path === '/settings') return reply(200, await api.saveSettings(body))
|
||||||
|
// Freigabe-Pult und Interessent (P3); ohne ?status= gilt wie im Dienst offen,in_arbeit
|
||||||
|
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))
|
||||||
|
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.' })
|
return reply(404, { error: 'Unbekannter Pfad im Prüfstand.' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply(err && err.status ? err.status : 500, { error: err && err.message ? err.message : 'Fehler im Prüfstand.' })
|
return reply(err && err.status ? err.status : 500, { error: err && err.message ? err.message : 'Fehler im Prüfstand.' })
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
// scripts/workers-harness/shot.cjs <distDir> <outDir>
|
// scripts/workers-harness/shot.cjs <distDir> <outDir>
|
||||||
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
|
// <distDir> = Ausgabe von build.mjs, <outDir> = 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.
|
// 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
|
||||||
|
// 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'
|
'use strict'
|
||||||
const { createRequire } = require('module')
|
const { createRequire } = require('module')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
@@ -195,6 +199,8 @@ async function desktop(browser, base) {
|
|||||||
// 5. Pausenknopf und Einstellungen
|
// 5. Pausenknopf und Einstellungen
|
||||||
const pauseBtn = page.getByRole('button', { name: 'Alles anhalten', exact: true })
|
const pauseBtn = page.getByRole('button', { name: 'Alles anhalten', exact: true })
|
||||||
await pauseBtn.click()
|
await pauseBtn.click()
|
||||||
|
// Anhalten betrifft alle Mitarbeiter und fragt nach (P2-T21): erst „Anhalten“ im Dialog „Alles anhalten?“ pausiert
|
||||||
|
await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).getByRole('button', { name: 'Anhalten', exact: true }).click()
|
||||||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
|
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
|
||||||
check('Pausenknopf: „Fortsetzen“ nach dem Anhalten', true)
|
check('Pausenknopf: „Fortsetzen“ nach dem Anhalten', true)
|
||||||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
|
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
|
||||||
@@ -471,19 +477,511 @@ async function outage(browser, base) {
|
|||||||
await hang.context.close()
|
await hang.context.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- P3: Freigabe-Pult, Interessenten-Panel, Anhalten mit Rückfrage ----------
|
||||||
|
// Die Seite läuft als echte WorkersPage (?page=1) über den echten API-Client und die Fetch-Weiche (&fault=none); Anmeldung, Mitarbeiterliste
|
||||||
|
// und Sitzungs-JWT sind ersetzt (build.mjs). Zwischenablage und window.open sind ersetzt und halten fest, was die Oberfläche tut
|
||||||
|
// (window.__clip, window.__opened). Die Schalter der Skript-API (window.__wk.api.faults, .pultEmpty, .pultCalls) steuern 409, 400, Ausfall und Leerzustand.
|
||||||
|
|
||||||
|
const T_MAIL = 'Anfrage: Vereinswebsite für den SV Namborn'
|
||||||
|
const T_PLAT = 'Kleinanzeigen: Website für Friseursalon gesucht'
|
||||||
|
const T_KOLL = 'Kleinanzeigen: Homepage für Handwerksbetrieb'
|
||||||
|
const T_UNS = 'Unsichere Antwort: bitte prüfen'
|
||||||
|
const T_ALT = 'freelancermap: WordPress-Relaunch'
|
||||||
|
const T_GES = 'Kleinanzeigen: Fotograf sucht Portfolio-Seite'
|
||||||
|
const T_ERL = 'Beschwerde über eine Vorschau'
|
||||||
|
const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1'
|
||||||
|
const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1'
|
||||||
|
const SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein'
|
||||||
|
const PULT_URL = '/index.html?page=1&fault=none'
|
||||||
|
const PULT_DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
|
||||||
|
const CONFLICT = 'Wird schon von einem Kollegen bearbeitet.'
|
||||||
|
const TOO_LONG = 'Der Text ist zu lang (höchstens 8000 Zeichen).'
|
||||||
|
const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.'
|
||||||
|
// Telefon: das Wort oder eine Nummer (+49 / Vorwahl mit 0, Trennzeichen, weitere Ziffern); Postleitzahlen und Beträge passen nicht darauf
|
||||||
|
const PHONE_WORD = /telefon|phone|\btel\.|handynummer|mobilnummer/i
|
||||||
|
const PHONE_NUMBER = /\+49|\b0\d{2,5}[\s/-]\d{3,}/
|
||||||
|
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
const rowOf = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: title }) })
|
||||||
|
const rowTitles = (page) => page.locator('.wk-pult-title').allTextContents()
|
||||||
|
const finishedLine = (page) => page.locator('.wk-pult-filters ~ .msg')
|
||||||
|
const setPultFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value])
|
||||||
|
const pultCalls = (page) => page.evaluate(() => window.__wk.api.pultCalls)
|
||||||
|
const resetStubs = (page) => page.evaluate(() => { window.__clip.length = 0; window.__opened.length = 0; window.__openMeta.length = 0 })
|
||||||
|
const stubState = (page) => page.evaluate(() => ({ clip: window.__clip.slice(), opened: window.__opened.map((a) => Array.from(a)), meta: window.__openMeta.slice() }))
|
||||||
|
const waitTitles = (page, titles, timeout = 5000) => page.waitForFunction(
|
||||||
|
(want) => JSON.stringify(Array.from(document.querySelectorAll('.wk-pult-title')).map((el) => el.textContent)) === JSON.stringify(want),
|
||||||
|
titles,
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
const activeProbe = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-probe') : null))
|
||||||
|
const activeTag = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.tagName : null))
|
||||||
|
|
||||||
|
// Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden der Seite)
|
||||||
|
async function newPultPage(browser, label, contextOptions) {
|
||||||
|
const made = await newPage(browser, label, contextOptions)
|
||||||
|
// Die Zwischenablage antwortet erst nach 300 ms (wie ein Browser, der nach der Erlaubnis fragt): wer vor dem Öffnen auf das Kopieren
|
||||||
|
// wartet, öffnet später als 300 ms nach dem Klick und verliert die Nutzer-Geste. window.open hält Zeit und Geste fest.
|
||||||
|
await made.context.addInitScript(() => {
|
||||||
|
window.__clip = []
|
||||||
|
window.__opened = []
|
||||||
|
window.__openMeta = []
|
||||||
|
window.__lastClickAt = 0
|
||||||
|
document.addEventListener('click', () => { window.__lastClickAt = performance.now() }, true)
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: (text) => { window.__clip.push(text); return new Promise((resolve) => setTimeout(resolve, 300)) } },
|
||||||
|
})
|
||||||
|
window.open = (...args) => {
|
||||||
|
window.__opened.push(args)
|
||||||
|
window.__openMeta.push({ sinceClick: performance.now() - window.__lastClickAt, active: navigator.userActivation ? navigator.userActivation.isActive : null })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return made
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reiter „Freigabe“ per Deep-Link öffnen und warten, bis die vier wartenden Einträge und die Mitarbeiternamen da sind
|
||||||
|
async function openPult(page, base) {
|
||||||
|
await page.goto(base + PULT_URL + '&tab=freigabe')
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4)
|
||||||
|
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
|
||||||
|
await page.evaluate(() => document.fonts.ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Szenario G: Freigabe (Desktop 1280 x 900) ----------
|
||||||
|
|
||||||
|
async function freigabe(browser, base) {
|
||||||
|
const { context, page } = await newPultPage(browser, 'freigabe', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||||||
|
|
||||||
|
// 1. Reiterleiste: erst ab zwei Reitern, Start auf „Hochhaus“, Wechsel auf „Freigabe“
|
||||||
|
await page.goto(base + PULT_URL)
|
||||||
|
await page.getByRole('tab').first().waitFor()
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
|
||||||
|
const tabNames = (await page.getByRole('tab').allTextContents()).map((t) => t.trim())
|
||||||
|
check('freigabe: Reiterleiste mit „Hochhaus“ und „Freigabe“', same(tabNames, ['Hochhaus', 'Freigabe']), tabNames)
|
||||||
|
check('freigabe: Start auf „Hochhaus“ (Hochhaus mit 19 Figuren, Reiter gewählt)', (await page.getByRole('tab', { name: 'Hochhaus', exact: true }).getAttribute('aria-selected')) === 'true')
|
||||||
|
await page.getByRole('tab', { name: 'Freigabe', exact: true }).click()
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4)
|
||||||
|
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
|
||||||
|
await page.evaluate(() => document.fonts.ready)
|
||||||
|
check('freigabe: nach dem Klick ist „Freigabe“ gewählt und das Hochhaus weg', (await page.getByRole('tab', { name: 'Freigabe', exact: true }).getAttribute('aria-selected')) === 'true' && (await page.locator('[data-wk-worker]').count()) === 0)
|
||||||
|
|
||||||
|
// 2. Filter und Liste
|
||||||
|
const filterButtons = page.locator('.wk-pult-filters .seg button')
|
||||||
|
check('freigabe: Filter „Wartet“, „Veraltet“, „Erledigt“, „Wartet“ gedrückt', same(await filterButtons.allTextContents(), ['Wartet', 'Veraltet', 'Erledigt']) && same(await filterButtons.evaluateAll((els) => els.map((el) => el.getAttribute('aria-pressed'))), ['true', 'false', 'false']))
|
||||||
|
const first = await rowTitles(page)
|
||||||
|
check('freigabe: „Wartet“ zeigt die vier Einträge, neueste zuerst', same(first, [T_MAIL, T_PLAT, T_KOLL, T_UNS]), first)
|
||||||
|
|
||||||
|
const mail = rowOf(page, T_MAIL)
|
||||||
|
const plat = rowOf(page, T_PLAT)
|
||||||
|
const koll = rowOf(page, T_KOLL)
|
||||||
|
const uns = rowOf(page, T_UNS)
|
||||||
|
const buttonNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim()))
|
||||||
|
|
||||||
|
const platMeta = await textOf(plat.locator('.wk-pult-meta'))
|
||||||
|
check('freigabe: Plattform-Zeile: Alter, „läuft in 20 Std. ab“, Ort und Score', platMeta.includes('vor 5 Std.') && platMeta.includes('läuft in 20 Std. ab') && platMeta.includes('67655 Kaiserslautern · Score 72'), platMeta)
|
||||||
|
check('freigabe: Plattform-Zeile: Art-Badge „Antwort auf Plattform-Anfrage“', (await textOf(plat.locator('.wk-pult-head'))).includes('Antwort auf Plattform-Anfrage'))
|
||||||
|
const link = plat.getByRole('link', { name: 'Anzeige öffnen', 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))
|
||||||
|
const mailTo = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href')
|
||||||
|
check('freigabe: Mail-Zeile: Link „E-Mail schreiben“ mit mailto-Adresse, Betreff und Text', typeof mailTo === 'string' && mailTo.startsWith('mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=Guten%20Tag%20Herr%20Beispiel'), mailTo)
|
||||||
|
check('freigabe: Mail-Zeile: kein „Kopieren & öffnen“ (Antwort geht per Mailprogramm)', (await mail.getByRole('button', { name: 'Kopieren & öffnen' }).count()) === 0)
|
||||||
|
await mail.getByLabel('Entwurf der Antwort').fill('Neuer Text für die Mail.')
|
||||||
|
const mailEdited = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href')
|
||||||
|
check('freigabe: Mail-Zeile: der Mail-Link trägt den bearbeiteten Text aus dem Feld', mailEdited === 'mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=' + encodeURIComponent('Neuer Text für die Mail.'), mailEdited)
|
||||||
|
check('freigabe: Kollegen-Zeile: „übernommen von Justin“, kein „Ich kümmere mich“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin') && (await koll.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0)
|
||||||
|
check('freigabe: Zeile ohne Antwort („unsicher“): nur Ich kümmere mich, Erledigt, Verwerfen, kein Textfeld', same(await buttonNames(uns), ['Ich kümmere mich', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await buttonNames(uns))
|
||||||
|
await topOfPage(page)
|
||||||
|
await shoot(page, 'desktop-freigabe-wartet.png')
|
||||||
|
await shoot(page, 'desktop-freigabe-wartet-ganz.png', { fullPage: true })
|
||||||
|
|
||||||
|
// 3. Kollege hat schon übernommen: Kopieren & öffnen kopiert und öffnet, der Dienst antwortet 409, die Zeile zeigt den Namen
|
||||||
|
await koll.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click()
|
||||||
|
const kollNote = koll.locator('.msg')
|
||||||
|
await kollNote.waitFor()
|
||||||
|
const kollText = await textOf(kollNote)
|
||||||
|
check('freigabe: 409 beim Übernehmen: Hinweis „Wird schon von Justin bearbeitet.“ (zusammen mit „Kopiert und geöffnet.“)', kollText === 'Kopiert und geöffnet. Wird schon von Justin bearbeitet.', kollText)
|
||||||
|
check('freigabe: 409-Hinweis ist eine rote Zeile mit role=alert', (await kollNote.getAttribute('class')).includes('err') && (await kollNote.getAttribute('role')) === 'alert')
|
||||||
|
const kollStub = await stubState(page)
|
||||||
|
check('freigabe: Kollegen-Zeile: Text kopiert und Anzeige geöffnet (trotz 409)', kollStub.clip.length === 1 && kollStub.clip[0].startsWith('Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage') && same(kollStub.opened, [[WEBER_URL, '_blank', 'noopener,noreferrer']]), kollStub)
|
||||||
|
check('freigabe: Kollegen-Zeile bleibt „übernommen von Justin“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin'))
|
||||||
|
await shoot(page, 'desktop-freigabe-409-kollege.png')
|
||||||
|
await resetStubs(page)
|
||||||
|
|
||||||
|
// 4. Kopieren & öffnen mit bearbeitetem Text: kopiert den Text aus dem Feld, öffnet die Anzeige, übernimmt den Eintrag für mich
|
||||||
|
const area = plat.getByLabel('Entwurf der Antwort')
|
||||||
|
const draft = await area.inputValue()
|
||||||
|
const edited = draft + '\nPS: Bearbeitet im Prüfstand.'
|
||||||
|
await area.fill(edited)
|
||||||
|
await plat.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click()
|
||||||
|
const platNote = plat.locator('.msg')
|
||||||
|
await platNote.waitFor()
|
||||||
|
check('freigabe: Kopieren & öffnen: Meldung „Kopiert und geöffnet.“ (grün, role=status)', (await textOf(platNote)) === 'Kopiert und geöffnet.' && (await platNote.getAttribute('class')).includes('ok') && (await platNote.getAttribute('role')) === 'status', await textOf(platNote))
|
||||||
|
const platStub = await stubState(page)
|
||||||
|
check('freigabe: Kopieren & öffnen kopiert den bearbeiteten Text aus dem Feld', same(platStub.clip, [edited]), platStub.clip)
|
||||||
|
check('freigabe: Kopieren & öffnen öffnet genau die Anzeige im neuen Tab (noopener,noreferrer)', same(platStub.opened, [[HAIR_URL, '_blank', 'noopener,noreferrer']]), platStub.opened)
|
||||||
|
check('freigabe: Kopieren & öffnen öffnet im Zug des Klicks (binnen 150 ms, Nutzer-Geste aktiv), ohne die Zwischenablage abzuwarten', platStub.meta.length === 1 && platStub.meta[0].sinceClick < 150 && platStub.meta[0].active === true, platStub.meta)
|
||||||
|
await page.waitForFunction((t) => {
|
||||||
|
const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t))
|
||||||
|
return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')
|
||||||
|
}, T_PLAT)
|
||||||
|
const platCalls = (await pultCalls(page)).filter((c) => c.id === 'pult-plat')
|
||||||
|
check('freigabe: Kopieren & öffnen übernimmt den Eintrag (claim) und er steht „übernommen von Kenso“', platCalls.length >= 1 && platCalls[0].action === 'claim' && (await plat.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, platCalls)
|
||||||
|
check('freigabe: der bearbeitete Text bleibt nach dem Neuladen der Liste im Feld', (await area.inputValue()) === edited)
|
||||||
|
await shoot(page, 'desktop-freigabe-kopiert.png')
|
||||||
|
|
||||||
|
// 5. Übernehmen: 409 vom Dienst (Schalter) zeigt den Satz, danach klappt es
|
||||||
|
await setPultFault(page, 'claim', { status: 409, message: CONFLICT })
|
||||||
|
await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||||||
|
const mailNote = mail.locator('.msg')
|
||||||
|
await mailNote.waitFor()
|
||||||
|
check('freigabe: Übernehmen mit 409: der Satz des Dienstes steht in der Zeile', (await textOf(mailNote)) === CONFLICT && (await mailNote.getAttribute('role')) === 'alert', await textOf(mailNote))
|
||||||
|
check('freigabe: nach dem 409 ist der Eintrag nicht übernommen (Knopf bleibt, kein „übernommen von“)', (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 1 && !(await textOf(mail.locator('.wk-pult-meta'))).includes('übernommen von'))
|
||||||
|
await shoot(page, 'desktop-freigabe-409-uebernehmen.png')
|
||||||
|
await setPultFault(page, 'claim', null)
|
||||||
|
await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||||||
|
await page.waitForFunction((t) => {
|
||||||
|
const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t))
|
||||||
|
return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')
|
||||||
|
}, T_MAIL)
|
||||||
|
check('freigabe: Übernehmen: „in Arbeit“ von mir (übernommen von Kenso), Meldung „Du kümmerst dich darum.“, Knopf weg', (await textOf(mail.locator('.msg'))) === 'Du kümmerst dich darum.' && (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, await textOf(mail.locator('.msg')))
|
||||||
|
await shoot(page, 'desktop-freigabe-uebernommen.png')
|
||||||
|
|
||||||
|
// 6. Als gesendet markieren: Fehler 400 mit deutschem Satz, dann der Erfolg mit dem bearbeiteten Text
|
||||||
|
await setPultFault(page, 'sent', { status: 400, message: TOO_LONG })
|
||||||
|
await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click()
|
||||||
|
await plat.locator('.msg.err').waitFor()
|
||||||
|
check('freigabe: Fehler 400: der deutsche Satz des Dienstes steht in der Zeile (role=alert), die Zeile bleibt', (await textOf(plat.locator('.msg.err'))) === TOO_LONG && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await rowTitles(page)).includes(T_PLAT), await textOf(plat.locator('.msg.err')))
|
||||||
|
await shoot(page, 'desktop-freigabe-400.png')
|
||||||
|
await setPultFault(page, 'sent', null)
|
||||||
|
await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click()
|
||||||
|
await finishedLine(page).first().waitFor()
|
||||||
|
check('freigabe: gesendet: Meldung oben „…“ als gesendet markiert. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T_PLAT}“ als gesendet markiert.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && !(await rowTitles(page)).includes(T_PLAT), await textOf(finishedLine(page).first()))
|
||||||
|
const sentCall = (await pultCalls(page)).filter((c) => c.id === 'pult-plat' && c.action === 'sent').pop()
|
||||||
|
check('freigabe: gesendet schickt den bearbeiteten Text (body.text)', sentCall !== undefined && sentCall.body && sentCall.body.text === edited, sentCall)
|
||||||
|
await shoot(page, 'desktop-freigabe-gesendet.png')
|
||||||
|
|
||||||
|
// 7. Erledigt (Art ohne Antwort)
|
||||||
|
await uns.getByRole('button', { name: 'Erledigt', exact: true }).click()
|
||||||
|
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ erledigt.`, T_UNS)
|
||||||
|
check('freigabe: Erledigt: Meldung oben, die Zeile ist weg', !(await rowTitles(page)).includes(T_UNS), await rowTitles(page))
|
||||||
|
|
||||||
|
// 8. Verwerfen: erster Klick fragt nach, die Frage verfällt nach 4 s, zwei Klicks verwerfen; die Oberfläche schickt keinen Grund
|
||||||
|
const discardBtn = mail.locator('.wk-pult-actions').getByRole('button', { name: /^(Verwerfen|Wirklich verwerfen\?)$/ })
|
||||||
|
await discardBtn.click()
|
||||||
|
check('freigabe: Verwerfen: erster Klick fragt „Wirklich verwerfen?“, noch keine Aktion am Dienst', (await textOf(discardBtn)) === 'Wirklich verwerfen?' && (await pultCalls(page)).filter((c) => c.action === 'discard').length === 0)
|
||||||
|
await shoot(page, 'desktop-freigabe-verwerfen-frage.png')
|
||||||
|
await sleep(4400)
|
||||||
|
check('freigabe: Verwerfen: die Rückfrage verfällt nach 4 s (wieder „Verwerfen“)', (await textOf(discardBtn)) === 'Verwerfen')
|
||||||
|
await discardBtn.click()
|
||||||
|
await discardBtn.click()
|
||||||
|
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ verworfen.`, T_MAIL)
|
||||||
|
const discards = (await pultCalls(page)).filter((c) => c.action === 'discard')
|
||||||
|
check('freigabe: Verwerfen: genau ein Aufruf, Meldung oben „…“ verworfen., Zeile weg', discards.length === 1 && discards[0].id === 'pult-mail' && !(await rowTitles(page)).includes(T_MAIL), discards)
|
||||||
|
check('freigabe: Verwerfen schickt keinen Grund (Body {}); die Oberfläche hat kein Feld dafür', same(discards[0] && discards[0].body, {}), discards[0])
|
||||||
|
const afterAll = await rowTitles(page)
|
||||||
|
check('freigabe: „Wartet“ zeigt am Ende nur den Eintrag des Kollegen', same(afterAll, [T_KOLL]), afterAll)
|
||||||
|
|
||||||
|
// 9. Filter „Veraltet“, „Erledigt“
|
||||||
|
await filterButtons.nth(1).click()
|
||||||
|
await waitTitles(page, [T_ALT])
|
||||||
|
check('freigabe: „Veraltet“ zeigt den veralteten Eintrag mit Badge „veraltet“', (await textOf(rowOf(page, T_ALT).locator('.wk-pult-head'))).includes('veraltet') && (await filterButtons.nth(1).getAttribute('aria-pressed')) === 'true')
|
||||||
|
check('freigabe: Filterwechsel räumt die Meldung oben weg', (await finishedLine(page).count()) === 0)
|
||||||
|
check('freigabe: veralteter Eintrag lässt sich weiter bearbeiten (Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen']), await buttonNames(rowOf(page, T_ALT)))
|
||||||
|
await shoot(page, 'desktop-freigabe-veraltet.png')
|
||||||
|
await rowOf(page, T_ALT).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||||||
|
await page.locator('.empty').waitFor()
|
||||||
|
const emptyOld = await textOf(page.locator('.empty'))
|
||||||
|
check('freigabe: Leerzustand „Veraltet“: „Nichts ist veraltet“ mit Hinweis', emptyOld.startsWith('Nichts ist veraltet') && emptyOld.includes('Einträge, die zu lange liegen'), emptyOld)
|
||||||
|
await shoot(page, 'desktop-freigabe-leer-veraltet.png')
|
||||||
|
|
||||||
|
await filterButtons.nth(2).click()
|
||||||
|
await waitTitles(page, [T_MAIL, T_PLAT, T_UNS, T_GES, T_ERL])
|
||||||
|
check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), ohne Aktionsknöpfe', (await page.locator('.wk-pult-actions').count()) === 0)
|
||||||
|
await shoot(page, 'desktop-freigabe-erledigt.png')
|
||||||
|
|
||||||
|
// 10. Leerzustand „Wartet“ und Ausfall des Pults mit „Neu laden“
|
||||||
|
await page.evaluate(() => { window.__wk.api.pultEmpty = true })
|
||||||
|
await filterButtons.nth(0).click()
|
||||||
|
await page.locator('.empty').waitFor()
|
||||||
|
const emptyWait = await textOf(page.locator('.empty'))
|
||||||
|
check('freigabe: Leerzustand „Wartet“: „Nichts wartet auf dich“ mit Hinweis', emptyWait.startsWith('Nichts wartet auf dich') && emptyWait.includes('Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'), emptyWait)
|
||||||
|
await shoot(page, 'desktop-freigabe-leer.png')
|
||||||
|
await setPultFault(page, 'pult', { status: 503, message: PULT_DOWN })
|
||||||
|
await filterButtons.nth(1).click()
|
||||||
|
await page.getByRole('button', { name: 'Neu laden', exact: true }).waitFor()
|
||||||
|
const emptyDown = await textOf(page.locator('.empty'))
|
||||||
|
check('freigabe: Pult nicht erreichbar: Hinweis mit dem Satz des Dienstes und „Neu laden“', emptyDown.includes('Das Pult ist gerade nicht erreichbar') && emptyDown.includes(PULT_DOWN), emptyDown)
|
||||||
|
await shoot(page, 'desktop-freigabe-ausfall.png')
|
||||||
|
await setPultFault(page, 'pult', null)
|
||||||
|
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||||||
|
await page.waitForFunction(() => (document.querySelector('.empty') || {}).textContent?.startsWith('Nichts ist veraltet'))
|
||||||
|
check('freigabe: „Neu laden“ nach dem Ausfall fragt erneut, der Leerzustand kehrt zurück', true)
|
||||||
|
await page.evaluate(() => { window.__wk.api.pultEmpty = false })
|
||||||
|
await filterButtons.nth(0).click()
|
||||||
|
await waitTitles(page, [T_KOLL, T_ALT])
|
||||||
|
check('freigabe: nach dem Ausfall und dem Leerzustand ist die Liste wieder da (Kollege und der eben übernommene veraltete Eintrag)', true)
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Szenario H: Interessenten-Panel (Desktop) ----------
|
||||||
|
|
||||||
|
async function interessent(browser, base) {
|
||||||
|
const { context, page } = await newPultPage(browser, 'interessent', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||||||
|
await openPult(page, base)
|
||||||
|
|
||||||
|
// 1. „Details“ öffnet das Panel als Dialog
|
||||||
|
const opener = rowOf(page, T_MAIL).getByRole('button', { name: 'Details', exact: true })
|
||||||
|
await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
|
||||||
|
await opener.click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
|
||||||
|
await dialog.waitFor()
|
||||||
|
await dialog.locator('.wk-prospect').waitFor()
|
||||||
|
await sleep(600) // der Dialog blendet ein
|
||||||
|
check('interessent: „Details“ öffnet einen Dialog (role=dialog, aria-modal) mit dem Namen als Titel', (await dialog.getAttribute('aria-modal')) === 'true')
|
||||||
|
// Im Entwicklungsmodus hängt StrictMode jeden Effekt zweimal ein: ein oder zwei Abfragen, aber immer nur dieser Interessent
|
||||||
|
const asked = await page.evaluate(() => window.__wk.api.prospectCalls)
|
||||||
|
check('interessent: das Panel fragt den Interessenten p-mail ab (und keinen anderen)', asked.length >= 1 && asked.every((id) => id === 'p-mail'), asked)
|
||||||
|
check('interessent: der Fokus liegt beim Öffnen im Dialog', await dialog.evaluate((el) => el.contains(document.activeElement)))
|
||||||
|
const scoreParts = await dialog.locator('.wk-prospect-score').evaluate((el) => Array.from(el.children).map((child) => child.textContent.trim()))
|
||||||
|
check('interessent: Score als Badge „Score 84 · hoch“ mit Begründung', same(scoreParts, ['Score 84 · hoch', 'Klarer Auftrag, Budget genannt, Region passt.']), scoreParts)
|
||||||
|
const facts = await dialog.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim()))
|
||||||
|
check('interessent: Fakten Quelle, Gefunden, Ort, Status (ohne Telefon)', same(facts, ['Quelle', 'freelancermap', 'Gefunden', 'vor 3 Std.', 'Ort', '66640 Namborn', 'Status', 'Bewertet']), facts)
|
||||||
|
const request = dialog.locator('.wk-prospect-section').first()
|
||||||
|
const requestText = request.locator('.wk-pult-text')
|
||||||
|
check('interessent: Anfragetext als Text mit erhaltenen Zeilenumbrüchen (pre-wrap)', (await requestText.textContent()).includes('Vereinsseite neu macht.\n\nBudget bis 1.500 Euro') && (await requestText.evaluate((el) => getComputedStyle(el).whiteSpace)) === 'pre-wrap')
|
||||||
|
const open = request.getByRole('link', { name: 'Anfrage öffnen', exact: true })
|
||||||
|
check('interessent: „Anfrage öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await open.getAttribute('href')) === SV_URL && (await open.getAttribute('target')) === '_blank' && (await open.getAttribute('rel')) === 'noopener noreferrer')
|
||||||
|
const msgs = await dialog.locator('li.wk-prospect-msg').evaluateAll((els) => els.map((el) => ({ who: el.querySelector('strong').textContent, when: el.querySelector('.wk-prospect-msg-head span').textContent, body: el.querySelector('p').textContent })))
|
||||||
|
check('interessent: zwei Nachrichten: „Interessent“ (eingegangen) und „Justin“ (Mitarbeiter, Name aus der Liste), mit Alter', msgs.length === 2 && msgs[0].who === 'Interessent' && msgs[1].who === 'Justin' && msgs[0].when === 'vor 3 Std.' && msgs[1].when === 'vor 2 Std.' && msgs[0].body.startsWith('Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?'), msgs)
|
||||||
|
const previewLine = await textOf(dialog.locator('.wk-prospect-section').last().locator('p.muted'))
|
||||||
|
check('interessent: Vorschau-Zeile „Vorschau: fertig · Runde 1 · erstellt vor 1 Std.“', previewLine === 'Vorschau: fertig · Runde 1 · erstellt vor 1 Std.', previewLine)
|
||||||
|
const html = await page.evaluate(() => document.documentElement.outerHTML)
|
||||||
|
const text = await page.evaluate(() => document.body.innerText)
|
||||||
|
check('interessent: nirgends im Bild ein Telefon (weder Wort noch Nummer, weder in Text noch im HTML)', !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text), { word: (html.match(PHONE_WORD) || [])[0], number: (html.match(PHONE_NUMBER) || [])[0] })
|
||||||
|
check('interessent: keine Mail-Rohdaten im Bild (messageId, inReplyTo, references)', !/messageId|inReplyTo|referencesJson/.test(html))
|
||||||
|
await shoot(page, 'desktop-interessent-panel.png')
|
||||||
|
|
||||||
|
// 2. Esc schließt, der Fokus kehrt auf „Details“ zurück
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
check('interessent: Esc schließt das Panel', true)
|
||||||
|
check('interessent: nach Esc steht der Fokus wieder auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
|
||||||
|
|
||||||
|
// 3. „Schließen“ im Fuß und das Kreuz schließen ebenfalls und geben den Fokus zurück
|
||||||
|
await opener.click()
|
||||||
|
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).last().click()
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
check('interessent: „Schließen“ im Fuß schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
|
||||||
|
await opener.click()
|
||||||
|
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).first().click()
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
check('interessent: das Kreuz oben schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
|
||||||
|
|
||||||
|
// 4. Interessent ohne Score, ohne Anfragetext, ohne Verlauf
|
||||||
|
await rowOf(page, T_KOLL).getByRole('button', { name: 'Details', exact: true }).click()
|
||||||
|
const dialog2 = page.getByRole('dialog', { name: 'Meisterbetrieb Weber', exact: true })
|
||||||
|
await dialog2.waitFor()
|
||||||
|
await dialog2.locator('.wk-prospect').waitFor()
|
||||||
|
const body2 = await textOf(dialog2.locator('.wk-prospect'))
|
||||||
|
check('interessent: ohne Score „Noch nicht bewertet“, ohne Anfragetext und Verlauf die Leerzeilen, keine Vorschau-Zeile', body2.includes('Noch nicht bewertet') && body2.includes('Kein Anfragetext gespeichert.') && body2.includes('Noch keine Nachrichten.') && !body2.includes('Vorschau'), body2)
|
||||||
|
const facts2 = await dialog2.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim()))
|
||||||
|
check('interessent: Fakten Kleinanzeigen / 66849 Landstuhl / Gefunden', same(facts2, ['Quelle', 'Kleinanzeigen', 'Gefunden', 'vor 8 Std.', 'Ort', '66849 Landstuhl', 'Status', 'Gefunden']), facts2)
|
||||||
|
await sleep(600)
|
||||||
|
await shoot(page, 'desktop-interessent-leer.png')
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
|
||||||
|
// 5. Dienst meldet einen Fehler: Satz im Panel, „Neu laden“ holt den Interessenten
|
||||||
|
await setPultFault(page, 'prospect', { status: 404, message: 'Interessent nicht gefunden.' })
|
||||||
|
await rowOf(page, T_PLAT).getByRole('button', { name: 'Details', exact: true }).click()
|
||||||
|
const dialog3 = page.getByRole('dialog', { name: 'Interessent', exact: true })
|
||||||
|
await dialog3.getByRole('alert').waitFor()
|
||||||
|
check('interessent: Fehler vom Dienst: der Satz steht im Panel (role=alert) mit „Neu laden“', (await textOf(dialog3.getByRole('alert'))) === 'Interessent nicht gefunden.' && (await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).count()) === 1, await textOf(dialog3))
|
||||||
|
await sleep(600)
|
||||||
|
await shoot(page, 'desktop-interessent-fehler.png')
|
||||||
|
await setPultFault(page, 'prospect', null)
|
||||||
|
await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||||||
|
const dialog4 = page.getByRole('dialog', { name: 'Friseursalon Haarmonie', exact: true })
|
||||||
|
await dialog4.locator('.wk-prospect').waitFor()
|
||||||
|
check('interessent: „Neu laden“ holt den Interessenten (Score 72 · mittel)', (await textOf(dialog4.locator('.wk-prospect-score'))).startsWith('Score 72 · mittel'), await textOf(dialog4.locator('.wk-prospect-score')))
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Szenario I: Alles anhalten mit Rückfrage (Desktop) ----------
|
||||||
|
|
||||||
|
async function anhalten(browser, base) {
|
||||||
|
const { context, page } = await newPage(browser, 'anhalten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||||||
|
await ready(page, base + '/index.html')
|
||||||
|
await sleep(400)
|
||||||
|
await topOfPage(page)
|
||||||
|
const status = () => textOf(page.locator('.wk-status'))
|
||||||
|
const calls = () => page.evaluate(() => window.__wk.api.pauseCalls)
|
||||||
|
const pauseBtn = page.locator('.wk-toolbar').getByRole('button', { name: 'Alles anhalten', exact: true })
|
||||||
|
|
||||||
|
// 1. Rückfrage statt sofortigem Anhalten
|
||||||
|
await pauseBtn.evaluate((el) => el.setAttribute('data-probe', 'pause-button'))
|
||||||
|
await pauseBtn.click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true })
|
||||||
|
await dialog.waitFor()
|
||||||
|
check('anhalten: „Alles anhalten“ öffnet den Dialog „Alles anhalten?“ mit dem Satz zu den Workern', (await textOf(dialog.locator('.dialog-body'))) === HALT_QUESTION, await textOf(dialog.locator('.dialog-body')))
|
||||||
|
check('anhalten: Dialog mit den Knöpfen „Abbrechen“ und „Anhalten“', same(await dialog.locator('.dialog-foot button').allTextContents(), ['Abbrechen', 'Anhalten']))
|
||||||
|
check('anhalten: vor der Bestätigung ist nichts angehalten (kein Aufruf, Status „Läuft“)', same(await calls(), []) && (await status()) === 'Läuft', { calls: await calls(), status: await status() })
|
||||||
|
await sleep(600)
|
||||||
|
await shoot(page, 'desktop-anhalten-frage.png')
|
||||||
|
|
||||||
|
// 2. Abbrechen tut nichts, der Fokus kehrt auf den Knopf zurück
|
||||||
|
await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click()
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
check('anhalten: „Abbrechen“ schließt den Dialog, nichts wird angehalten', same(await calls(), []) && (await status()) === 'Läuft' && (await pauseBtn.textContent()) === 'Alles anhalten')
|
||||||
|
check('anhalten: nach „Abbrechen“ steht der Fokus wieder auf „Alles anhalten“', (await activeProbe(page)) === 'pause-button', await activeTag(page))
|
||||||
|
|
||||||
|
// 3. Esc verhält sich wie Abbrechen
|
||||||
|
await pauseBtn.click()
|
||||||
|
await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).waitFor()
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
check('anhalten: Esc schließt den Dialog, nichts wird angehalten, Fokus zurück auf „Alles anhalten“', same(await calls(), []) && (await activeProbe(page)) === 'pause-button', { calls: await calls(), focus: await activeTag(page) })
|
||||||
|
|
||||||
|
// 4. Anhalten hält an
|
||||||
|
await pauseBtn.click()
|
||||||
|
const dialog2 = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true })
|
||||||
|
await dialog2.waitFor()
|
||||||
|
await dialog2.getByRole('button', { name: 'Anhalten', exact: true }).click()
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
|
||||||
|
await page.waitForFunction(() => document.querySelector('.wk-status') && document.querySelector('.wk-status').textContent.startsWith('Angehalten'))
|
||||||
|
check('anhalten: „Anhalten“ pausiert genau einmal (Aufruf pause(true)), Status „Angehalten: …“, Knopf „Fortsetzen“', same(await calls(), [true]) && (await status()) === 'Angehalten: Es wird nichts gesendet und nichts gebaut.', { calls: await calls(), status: await status() })
|
||||||
|
await sleep(500)
|
||||||
|
const focusTag = await activeTag(page)
|
||||||
|
const focusText = await page.evaluate(() => (document.activeElement ? (document.activeElement.textContent || '').trim().slice(0, 40) : null))
|
||||||
|
check('anhalten: nach „Anhalten“ steht der Fokus nicht auf <body> (Tastatur-Nutzer verlieren sonst ihre Stelle)', focusTag !== null && focusTag !== 'BODY', { tag: focusTag, text: focusText })
|
||||||
|
await topOfPage(page)
|
||||||
|
await shoot(page, 'desktop-anhalten-angehalten.png')
|
||||||
|
|
||||||
|
// 5. Fortsetzen geht ohne Rückfrage
|
||||||
|
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor()
|
||||||
|
check('anhalten: „Fortsetzen“ geht ohne Rückfrage (kein Dialog, pause(false), Status „Läuft“)', (await page.getByRole('dialog').count()) === 0 && same(await calls(), [true, false]) && (await status()) === 'Läuft', { calls: await calls(), status: await status() })
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Szenario J: Handy 390 x 844 für Freigabe und Panel ----------
|
||||||
|
|
||||||
|
async function handyFreigabe(browser, base) {
|
||||||
|
const { context, page } = await newPultPage(browser, 'handy-freigabe', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
|
||||||
|
await openPult(page, base)
|
||||||
|
await sleep(300)
|
||||||
|
const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth }))
|
||||||
|
const w0 = await width()
|
||||||
|
metrics.phoneFreigabe = w0
|
||||||
|
check('handy-freigabe: Freigabe ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0)
|
||||||
|
const bar = await page.evaluate(() => {
|
||||||
|
const out = {}
|
||||||
|
for (const [name, sel] of [['tabs', '.tabs'], ['filter', '.wk-pult-filters .seg']]) {
|
||||||
|
const r = document.querySelector(sel).getBoundingClientRect()
|
||||||
|
out[name] = { left: Math.round(r.left), right: Math.round(r.right) }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
check('handy-freigabe: Reiterleiste und Filter liegen im Fenster', bar.tabs.left >= 0 && bar.tabs.right <= 390 && bar.filter.left >= 0 && bar.filter.right <= 390, bar)
|
||||||
|
await topOfPage(page)
|
||||||
|
await shoot(page, 'handy-freigabe.png')
|
||||||
|
await shoot(page, 'handy-freigabe-ganz.png', { fullPage: true })
|
||||||
|
|
||||||
|
// Jede Schaltfläche und jeder Link der Aktionszeilen: im Fenster, nicht verdeckt, mindestens 40 px hoch (Tippfläche), volle Breite untereinander
|
||||||
|
const reach = await page.evaluate(() => {
|
||||||
|
const out = []
|
||||||
|
for (const el of document.querySelectorAll('.wk-pult-actions > *')) {
|
||||||
|
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.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
|
||||||
|
})
|
||||||
|
const bad = reach.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40)
|
||||||
|
check('handy-freigabe: alle Aktionsknöpfe und Links sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach.length >= 10 && bad.length === 0, { count: reach.length, bad })
|
||||||
|
const w1 = await width()
|
||||||
|
check('handy-freigabe: nach dem Durchblättern der Aktionen weiterhin kein seitliches Scrollen', w1.scroll <= w1.inner, w1)
|
||||||
|
|
||||||
|
// Die lange Adresse steckt in der Originalanfrage: aufgeklappt darf sie die Seite nicht verbreitern
|
||||||
|
await rowOf(page, T_MAIL).locator('summary', { hasText: 'Originalanfrage' }).click()
|
||||||
|
await sleep(150)
|
||||||
|
const w1b = await width()
|
||||||
|
check('handy-freigabe: aufgeklappte „Originalanfrage“ mit langer Adresse verbreitert die Seite nicht', w1b.scroll <= w1b.inner, w1b)
|
||||||
|
await rowOf(page, T_MAIL).scrollIntoViewIfNeeded()
|
||||||
|
await shoot(page, 'handy-freigabe-originalanfrage.png')
|
||||||
|
|
||||||
|
// Panel am Handy: Bottom-Sheet im Fenster, lange Wörter brechen um, Schließen erreichbar
|
||||||
|
const opener = rowOf(page, T_MAIL).getByRole('button', { name: 'Details', exact: true })
|
||||||
|
await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
|
||||||
|
await opener.scrollIntoViewIfNeeded()
|
||||||
|
await opener.click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
|
||||||
|
await dialog.waitFor()
|
||||||
|
await dialog.locator('.wk-prospect').waitFor()
|
||||||
|
await sleep(700)
|
||||||
|
const box = await dialog.evaluate((el) => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const body = el.querySelector('.dialog-body')
|
||||||
|
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, bodyOverflowX: body.scrollWidth - body.clientWidth, bodyScrolls: body.scrollHeight > body.clientHeight }
|
||||||
|
})
|
||||||
|
metrics.phonePanel = box
|
||||||
|
check('handy-freigabe: 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)
|
||||||
|
check('handy-freigabe: Panel ohne waagerechten Überlauf (lange Adressen brechen um)', box.bodyOverflowX <= 0, box)
|
||||||
|
const w2 = await width()
|
||||||
|
check('handy-freigabe: mit offenem Panel kein seitliches Scrollen', w2.scroll <= w2.inner, w2)
|
||||||
|
await shoot(page, 'handy-interessent-panel.png')
|
||||||
|
// ganz nach unten im Panel: Nachrichtenverlauf und Schließen-Knopf
|
||||||
|
await dialog.locator('.dialog-body').evaluate((el) => { el.scrollTop = el.scrollHeight })
|
||||||
|
await sleep(200)
|
||||||
|
await shoot(page, 'handy-interessent-panel-unten.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), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) }
|
||||||
|
})
|
||||||
|
check('handy-freigabe: „Schließen“ im Panel ist erreichbar (im Fenster, nicht verdeckt)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.top >= 0 && closeReach.bottom <= closeReach.inner && !closeReach.covered, closeReach)
|
||||||
|
const lastMsg = await dialog.locator('li.wk-prospect-msg').last().evaluate((el) => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const foot = el.closest('.dialog').querySelector('.dialog-foot').getBoundingClientRect()
|
||||||
|
return { bottom: Math.round(r.bottom), footTop: Math.round(foot.top) }
|
||||||
|
})
|
||||||
|
check('handy-freigabe: nach dem Blättern liegt die letzte Nachricht über dem Fuß (nichts abgeschnitten)', lastMsg.bottom <= lastMsg.footTop + 1, lastMsg)
|
||||||
|
await closeBtn.click()
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||||
|
check('handy-freigabe: „Schließen“ schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(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],
|
||||||
|
];
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const server = await serve()
|
const server = await serve()
|
||||||
const base = 'http://127.0.0.1:' + server.address().port
|
const base = 'http://127.0.0.1:' + server.address().port
|
||||||
const browser = await chromium.launch({ executablePath: CHROME, args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage'] })
|
const browser = await chromium.launch({ executablePath: CHROME, args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage'] })
|
||||||
try {
|
try {
|
||||||
await desktop(browser, base)
|
const only = (process.env.HARNESS_ONLY || '').split(',').map((name) => name.trim()).filter(Boolean)
|
||||||
await bubbles(browser, base)
|
for (const [name, run] of SCENARIOS) {
|
||||||
await phone(browser, base)
|
if (only.length > 0 && !only.includes(name)) continue
|
||||||
await reduced(browser, base)
|
try {
|
||||||
await halted(browser, base)
|
await run(browser, base)
|
||||||
await outage(browser, base)
|
} catch (e) {
|
||||||
} catch (e) {
|
problems.push('Abbruch in Szenario ' + name + ': ' + (e && e.stack ? e.stack : String(e)))
|
||||||
problems.push('Abbruch: ' + (e && e.stack ? e.stack : String(e)))
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close()
|
await browser.close()
|
||||||
server.close()
|
server.close()
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Ersatz für src/context/AuthContext.jsx im Prüfstand: ein angemeldeter Admin (Kenso, Kennung u-me), ohne Appwrite.
|
||||||
|
// Die Kennung passt zu `mensch:u-me` in den Pult-Einträgen der Skript-API ("von mir übernommen").
|
||||||
|
const auth = Object.freeze({ isAdmin: true, user: Object.freeze({ $id: 'u-me', name: 'Kenso' }) })
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return auth
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Ersatz für src/lib/workersClient.js im Prüfstand (build.mjs tauscht das Modul aus): die Seite WorkersPage bekommt ihre API von hier,
|
||||||
|
// ohne Appwrite-Sitzung. `workersApi` ist ein Stellvertreter mit fester Identität (die Hooks hängen an `api`), der jeden Aufruf an die
|
||||||
|
// Skript-API weiterreicht, die entry.jsx in harness-bridge.js ablegt.
|
||||||
|
import { holder } from './harness-bridge.js'
|
||||||
|
|
||||||
|
export const workersApi = new Proxy({}, {
|
||||||
|
get: (_target, name) => (typeof name === 'string' ? (...args) => holder.api[name](...args) : undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function resetWorkersJwt() {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Ersatz für src/hooks/useEmployees.js im Prüfstand: die Mitarbeiterliste ({ userId, displayName }) kommt nach kurzer Ladezeit
|
||||||
|
// (wie im Betrieb: erst leer, dann gefüllt), ohne Appwrite. Die Namen sind die, die der Prüfstand in den Zeilen erwartet.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export const EMPLOYEES = Object.freeze([
|
||||||
|
Object.freeze({ userId: 'u-me', displayName: 'Kenso' }),
|
||||||
|
Object.freeze({ userId: 'u-kollege', displayName: 'Justin' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const LOAD_MS = 60
|
||||||
|
|
||||||
|
export function useEmployees() {
|
||||||
|
const [state, setState] = useState({ employees: [], loading: true })
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setState({ employees: [...EMPLOYEES], loading: false }), LOAD_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
return { ...state, error: null, refresh: async () => {} }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user