Workers P2: Prüfstand mit Skript-API und Playwright-Abnahme
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b78379b9b
commit
d67f8cc471
6 files changed
+896
No files matched your search
@@ -0,0 +1,301 @@
|
||||
// Skript-API des Prüfstands: dieselbe Form wie createWorkersApi (src/lib/workersApi.js), aber alles kommt aus einem festen Drehbuch.
|
||||
// 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 Entdopplung übernimmt der Feed), höchstens `limit` Stück, die ältesten zuerst; nichts aus der Zukunft.
|
||||
import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js'
|
||||
import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js'
|
||||
|
||||
const DEFAULT_BASE = Date.parse('2026-10-17T20:00:00.000Z')
|
||||
const OVERLAP_MS = 5000
|
||||
const EVENTS_MAX = 200
|
||||
const HALT_REASON = 'Zwei harte Bounces in den letzten 10 Mails.'
|
||||
|
||||
// Drehbuch: [{ at: Sekunden nach dem Start, event }]. Deckt alle 26 Ereignisarten und 16 Worker ab; das erste Ereignis liegt nicht vor 2 s,
|
||||
// damit die Bühne vorher ruhig steht. Die Zeiten sind so verteilt, dass ein Abruf (alle 5 s) höchstens fünf bis sechs Ereignisse bringt.
|
||||
const step = (at, workerId, kind, text, extra = {}) => ({ at, event: { workerId, kind, text, ...extra } })
|
||||
|
||||
export const SCRIPT = Object.freeze([
|
||||
step(2.0, 'otto', 'auftrag', 'Rita sucht Anfragen der Nacht', { to: 'rita' }),
|
||||
step(2.6, 'otto', 'auftrag', 'Ali sucht Praxen ohne Website', { to: 'ali' }),
|
||||
step(4.0, 'rita', 'gelesen', 'Netz-Radar: 12 Beiträge gelesen', { meta: { count: 12 } }),
|
||||
step(5.0, 'rita', 'fund', 'Neue Anfrage gefunden'),
|
||||
step(6.2, 'ali', 'gelesen', 'Verzeichnis: 7 Praxen gelesen', { meta: { count: 7 } }),
|
||||
step(7.2, 'ali', 'fund', 'Praxis ohne Website gefunden'),
|
||||
step(8.2, 'arne', 'doppelt', 'Eine Praxis war schon im Register'),
|
||||
step(9.4, 'rita', 'uebergabe', 'Anfrage an Bea übergeben', { to: 'bea' }),
|
||||
step(10.6, 'ali', 'uebergabe', 'Praxis an Bea übergeben', { to: 'bea' }),
|
||||
step(12.0, 'bea', 'bewertet', 'Anfrage mit 87 Punkten bewertet', { meta: { score: 87 } }),
|
||||
step(13.2, 'bea', 'top3', 'In die Top 3 aufgenommen'),
|
||||
step(14.0, 'arne', 'botlead', 'Kontakt in die Bot-Lead-Liste abgelegt'),
|
||||
step(15.0, 'lea', 'leerlauf', 'Wartet auf den nächsten Auftrag'),
|
||||
step(16.0, 'hanna', 'fund', 'Betrieb ohne Website gefunden'),
|
||||
step(17.0, 'bea', 'uebergabe', 'Top 3 an Clara übergeben', { to: 'clara' }),
|
||||
step(18.4, 'clara', 'entwurf', 'Antwort auf eine Anfrage entworfen'),
|
||||
step(19.4, 'clara', 'pult', 'Entwurf liegt im Pult'),
|
||||
step(20.6, 'max', 'entwurf', 'Erste Mail entworfen'),
|
||||
step(21.6, 'max', 'gesendet', 'Mail verschickt'),
|
||||
step(23.0, 'nora', 'eingang', 'Antwort eingegangen'),
|
||||
step(24.0, 'nora', 'sortiert', 'Antwort als „Ja“ eingeordnet', { meta: { classification: 'ja' } }),
|
||||
step(25.4, 'bob', 'bau_start', 'Vorschau wird gebaut'),
|
||||
step(27.0, 'bob', 'bau_fertig', 'Vorschau ist gebaut'),
|
||||
step(28.4, 'paul', 'pruefung_zurueck', 'Prüfung: Runde 1 zurück an Bob', { to: 'bob', meta: { round: 1 } }),
|
||||
step(30.0, 'bob', 'bau_fertig', 'Vorschau überarbeitet'),
|
||||
step(31.2, 'paul', 'pruefung_ok', 'Prüfung bestanden'),
|
||||
step(32.4, 'max', 'vorschau_link', 'Vorschau-Link verschickt', { link: 'https://example.org/vorschau' }),
|
||||
step(33.6, 'theo', 'angebot', 'Kauf-Link erzeugt'),
|
||||
step(34.8, 'system', 'deal', 'Bezahlt: Hosting-Abo', { meta: { offerId: 'offer-1', customerId: 'customer-1', productKey: 'hosting', amountCents: 2000, kundenart: 'privat' } }),
|
||||
step(36.0, 'system', 'gesperrt', 'Kontakt auf die Sperrliste gesetzt'),
|
||||
step(37.2, 'otto', 'budget_warnung', 'Tagesbudget zu 80 % erreicht'),
|
||||
step(38.4, 'otto', 'budget_stopp', 'Tagesbudget erreicht'),
|
||||
step(39.6, 'sven', 'fehler', 'Quelle antwortet nicht'),
|
||||
step(40.8, 'system', 'pause', 'Pause beendet', { meta: { paused: false } }),
|
||||
// 12 Ziffern plus „ gelesen“ ergeben genau die längste erlaubte Sprechblase (20 Zeichen)
|
||||
step(42.0, 'nora', 'gelesen', 'Posteingang gelesen', { meta: { count: 123456789012 } }),
|
||||
step(43.4, 'bianca', 'bau_start', 'Zweite Vorschau wird gebaut'),
|
||||
step(44.6, 'ben', 'leerlauf', 'Wartet auf einen Bauplan'),
|
||||
step(45.8, 'sven', 'leerlauf', 'Quelle wieder erreichbar'),
|
||||
])
|
||||
|
||||
// Zustand vor dem ersten Ereignis: gemischte Status; die drei nicht angeschlossenen Such-Worker (kim, pia, finn) sind grau
|
||||
const NOT_CONNECTED = Object.freeze(['kim', 'pia', 'finn'])
|
||||
const START = Object.freeze({
|
||||
otto: ['arbeitet', 'Plant die Nacht'],
|
||||
rita: ['arbeitet', 'Sucht frische Anfragen'],
|
||||
kim: ['grau', ''],
|
||||
pia: ['grau', ''],
|
||||
finn: ['grau', ''],
|
||||
ali: ['wartet', 'Wartet auf einen Auftrag'],
|
||||
hanna: ['leerlauf', ''],
|
||||
sven: ['leerlauf', ''],
|
||||
lea: ['arbeitet', 'Liest Läden und Gastro'],
|
||||
bea: ['wartet', 'Wartet auf Kandidaten'],
|
||||
max: ['leerlauf', ''],
|
||||
clara: ['arbeitet', 'Schreibt Antworten'],
|
||||
nora: ['arbeitet', 'Sortiert den Posteingang'],
|
||||
theo: ['leerlauf', ''],
|
||||
bob: ['arbeitet', 'Baut eine Vorschau'],
|
||||
bianca: ['wartet', 'Wartet auf einen Bauplan'],
|
||||
ben: ['leerlauf', ''],
|
||||
paul: ['leerlauf', ''],
|
||||
arne: ['leerlauf', ''],
|
||||
})
|
||||
|
||||
// Tageszähler je Worker (Schlüssel wie in counterLines)
|
||||
const SCOUT_COUNTERS = Object.freeze({ gelesen: 24, gefunden: 3, passend: 2 })
|
||||
const COUNTERS = Object.freeze({
|
||||
rita: SCOUT_COUNTERS,
|
||||
kim: Object.freeze({}),
|
||||
pia: Object.freeze({}),
|
||||
finn: Object.freeze({}),
|
||||
ali: SCOUT_COUNTERS,
|
||||
hanna: SCOUT_COUNTERS,
|
||||
sven: SCOUT_COUNTERS,
|
||||
lea: SCOUT_COUNTERS,
|
||||
bea: Object.freeze({ bewertet: 9 }),
|
||||
max: Object.freeze({ entwuerfe: 4, gesendet: 3 }),
|
||||
clara: Object.freeze({ entwuerfe: 2 }),
|
||||
theo: Object.freeze({ entwuerfe: 1 }),
|
||||
nora: Object.freeze({ gelesen: 5 }),
|
||||
bob: Object.freeze({ gebaut: 1 }),
|
||||
bianca: Object.freeze({ gebaut: 1 }),
|
||||
ben: Object.freeze({ gebaut: 0 }),
|
||||
paul: Object.freeze({ geprueft: 2 }),
|
||||
})
|
||||
|
||||
// Kennzahlen wie in den Test-Fixtures
|
||||
const KPIS = Object.freeze({
|
||||
newAbosMonth: 3,
|
||||
mrrNewEur: 60,
|
||||
dealsMonth: 3,
|
||||
openThreads: 7,
|
||||
pultOpen: 2,
|
||||
botLeads: 112,
|
||||
apiTodayUsd: 5.1,
|
||||
apiMonthUsd: 50,
|
||||
dailyBudgetUsd: 6,
|
||||
monthlyBudgetUsd: 150,
|
||||
})
|
||||
|
||||
// Spur: zwölf Einträge (neueste zuerst), darunter ein unsicherer Link (nur Text), ein sicherer externer und ein interner Link
|
||||
const TRACE = Object.freeze([
|
||||
['gelesen', 'Quelle geprüft', null],
|
||||
['fund', 'Fund notiert', 'https://example.org/fund'],
|
||||
['fehler', 'Link mit Skript abgelehnt', 'javascript:alert(1)'],
|
||||
['uebergabe', 'Zur Übersicht der Hustler', '/hustler?tab=hochhaus'],
|
||||
['gelesen', 'Beiträge der letzten Stunde gelesen', null],
|
||||
['fund', 'Zweiten Kandidaten gefunden', null],
|
||||
['leerlauf', 'Kurze Pause', null],
|
||||
['gelesen', 'Neue Quelle gelesen', null],
|
||||
['uebergabe', 'Dokument weitergereicht', null],
|
||||
['fund', 'Kandidat notiert', null],
|
||||
['gelesen', 'Postfach geleert', null],
|
||||
['auftrag', 'Auftrag von Otto angenommen', null],
|
||||
])
|
||||
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v))
|
||||
const isoOf = (ms) => new Date(ms).toISOString()
|
||||
|
||||
// Standardwerte der Einstellungen (Übersicht §4.1)
|
||||
function defaultSettings(halted) {
|
||||
return {
|
||||
paused: false,
|
||||
dailyBudgetUsd: 6,
|
||||
monthlyBudgetUsd: 150,
|
||||
warnPct: 80,
|
||||
mailColdPerDay: 3,
|
||||
mailReplyCapPerDay: 15,
|
||||
sendWindowStart: '09:00',
|
||||
sendWindowEnd: '16:00',
|
||||
sendWeekdaysOnly: true,
|
||||
runsPerNight: 1,
|
||||
candidatesPerRun: 10,
|
||||
activeRooms: [...SEARCH_ROOMS],
|
||||
connectedRooms: SEARCH_ROOMS.filter((room) => !NOT_CONNECTED.includes(ROLES.find((r) => r.room === room).workerId)),
|
||||
regions: ['Rheinland-Pfalz'],
|
||||
previewGate: 'erste_n',
|
||||
previewGateN: 10,
|
||||
previewsSent: 0,
|
||||
offerText: 'Wir bauen Ihnen kostenlos eine erste Vorschau Ihrer neuen Website. Gefällt sie Ihnen, betreuen wir sie für 20 € im Monat.',
|
||||
offerProductKey: 'hosting',
|
||||
pultStaleDays: 3,
|
||||
retentionDays: 90,
|
||||
followupDays: 7,
|
||||
previewReminderDays: 5,
|
||||
previewDeleteDays: 60,
|
||||
reportEmail: 'bericht@example.org',
|
||||
privacyInteressentenLive: false,
|
||||
sendingHalted: halted,
|
||||
sendingHaltedReason: halted ? HALT_REASON : '',
|
||||
updatedAt: null,
|
||||
updatedBy: 'system',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam.
|
||||
*/
|
||||
export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false }) {
|
||||
const start = now()
|
||||
const elapsed = () => Math.max(0, now() - start)
|
||||
let paused = false
|
||||
const settings = defaultSettings(halted === true)
|
||||
const saved = []
|
||||
|
||||
// Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige
|
||||
const all = SCRIPT.map((s, i) => ({
|
||||
id: `harness-${String(i + 1).padStart(3, '0')}`,
|
||||
ts: isoOf(base + Math.round(s.at * 1000)),
|
||||
workerId: s.event.workerId,
|
||||
kind: s.event.kind,
|
||||
text: s.event.text,
|
||||
link: s.event.link ?? null,
|
||||
to: s.event.to ?? null,
|
||||
prospectId: null,
|
||||
costUsd: 0,
|
||||
meta: s.event.meta ?? null,
|
||||
}))
|
||||
const dueEvents = () => all.filter((e) => Date.parse(e.ts) <= base + elapsed())
|
||||
|
||||
// Zustand: Startwerte, fortgeschrieben mit den fälligen Ereignissen (wie der Dienst)
|
||||
function buildState() {
|
||||
const startList = ROLES.map((r) => ({
|
||||
workerId: r.workerId,
|
||||
status: START[r.workerId][0],
|
||||
task: START[r.workerId][1],
|
||||
counters: { ...(COUNTERS[r.workerId] ?? {}) },
|
||||
connected: !NOT_CONNECTED.includes(r.workerId),
|
||||
}))
|
||||
const due = dueEvents()
|
||||
const table = reduceEvents(indexWorkers({ workers: startList, lastEventTs: isoOf(base) }), due)
|
||||
const count = (kind) => due.filter((e) => e.kind === kind).length
|
||||
return {
|
||||
paused,
|
||||
workers: startList.map((w) => ({ ...w, status: table[w.workerId].status, task: table[w.workerId].task })),
|
||||
kpis: {
|
||||
...KPIS,
|
||||
newAbosMonth: KPIS.newAbosMonth + count('deal'),
|
||||
mrrNewEur: KPIS.mrrNewEur + 20 * count('deal'),
|
||||
dealsMonth: KPIS.dealsMonth + count('deal'),
|
||||
pultOpen: KPIS.pultOpen + count('pult'),
|
||||
},
|
||||
sendingHalted: settings.sendingHalted === true,
|
||||
sendingHaltedReason: typeof settings.sendingHaltedReason === 'string' ? settings.sendingHaltedReason : '',
|
||||
lastEventTs: due.length ? due[due.length - 1].ts : isoOf(base),
|
||||
}
|
||||
}
|
||||
|
||||
const notFound = () => Object.assign(new Error('Diesen Hustler gibt es nicht.'), { status: 404 })
|
||||
|
||||
return {
|
||||
saved,
|
||||
|
||||
async state() {
|
||||
return clone(buildState())
|
||||
},
|
||||
|
||||
async events(after, limit = EVENTS_MAX) {
|
||||
const afterMs = after === null || after === undefined ? NaN : Date.parse(after)
|
||||
const from = Number.isNaN(afterMs) ? -Infinity : afterMs - OVERLAP_MS
|
||||
const n = Math.min(EVENTS_MAX, Math.max(1, Math.trunc(Number(limit)) || EVENTS_MAX))
|
||||
return { events: clone(dueEvents().filter((e) => Date.parse(e.ts) >= from).slice(0, n)) }
|
||||
},
|
||||
|
||||
async trace(workerId) {
|
||||
const role = roleOf(workerId)
|
||||
if (!role) throw notFound()
|
||||
const nowMs = base + elapsed()
|
||||
return clone({
|
||||
worker: { ...role, modelId: MODEL_IDS[role.model] },
|
||||
today: { counters: COUNTERS[workerId] ?? {}, tokens: { in: 18400, out: 2300 }, costUsd: 0.37 },
|
||||
events: TRACE.map(([kind, text, link], i) => ({
|
||||
id: `trace-${workerId}-${i + 1}`,
|
||||
ts: isoOf(nowMs - (i * 2 + 1) * 60000),
|
||||
workerId,
|
||||
kind,
|
||||
text,
|
||||
link,
|
||||
to: null,
|
||||
prospectId: null,
|
||||
costUsd: 0,
|
||||
meta: null,
|
||||
})),
|
||||
})
|
||||
},
|
||||
|
||||
async pause(next) {
|
||||
paused = Boolean(next)
|
||||
return { paused }
|
||||
},
|
||||
|
||||
async settings() {
|
||||
return clone({ ...settings, paused })
|
||||
},
|
||||
|
||||
// Der Patch wird gemerkt und übernommen; sendingHalted: false hebt die Notbremse auf und leert den Grund (H4)
|
||||
async saveSettings(patch) {
|
||||
const change = patch !== null && typeof patch === 'object' ? clone(patch) : {}
|
||||
saved.push(change)
|
||||
Object.assign(settings, clone(change))
|
||||
if (change.sendingHalted === false) settings.sendingHaltedReason = ''
|
||||
return clone({ ...settings, paused })
|
||||
},
|
||||
|
||||
async pult() {
|
||||
return { items: [] }
|
||||
},
|
||||
|
||||
async pultAction() {
|
||||
return {}
|
||||
},
|
||||
|
||||
async botleads() {
|
||||
return { items: [], total: 0 }
|
||||
},
|
||||
|
||||
async prospect() {
|
||||
return {}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user