Files
ClaudeandClaude Sonnet 5.5 5e48fbf264 Hustler-Stufe: P4-Aktionen im Pult erst ab Stufe 4 (rowModel-Parameter stufe, PultList reicht ihn durch)
Unterhalb von Stufe 4 sind canReply, canPaste, canApprovePreview und canStartBuild immer false: Antwort des Kunden einfügen, Als Mensch antworten / Jetzt senden, Freigeben & senden
und Vorschau bauen verschwinden samt Feldern und Rückfragen, die Aktionsgruppe schließt sich ohne Lücke. P3-Aktionen (übernehmen, Kopieren & öffnen, als gesendet markieren, verwerfen,
erledigt, E-Mail schreiben) bleiben. Bestehende P4-Tests laufen ausdrücklich auf Stufe 6; neue Tests prüfen Stufe 3 gerendert und bedient (Reihenfolge der Knöpfe, kein Feld, Esc, Verwerfen mit Rückfrage).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:36:44 +00:00

540 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Reine Pult-Logik des Reiters „Freigabe“: Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen
const {
PULT_KIND_LABELS, SENDABLE_KINDS, PULT_FILTERS, REPLY_KINDS, PASTE_KINDS, REPLY_TEXT_MAX, UNCERTAIN_TEXT,
staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel: rowModelNachStufe, copyAndOpen, validateReplyText, charCount, actionErrorText, pasteDoneText,
} = await loadModule('src/lib/workersPult.js')
const { HUSTLER_STUFE } = await loadModule('src/lib/hustlerStufe.js')
// Stufen-Sperre (hustlerStufe.js): rowModel bietet die P4-Aktionen erst ab Stufe 4 an. Die Zusicherungen dieser Datei gelten für das volle Pult und laufen deshalb auf
// Stufe 6 (P4 und Keller offen, Verhalten wie vor der Sperre). Was unterhalb von Stufe 4 gilt, prüft hustlerStufe.test.mjs.
const rowModel = (item, ctx) => rowModelNachStufe(item, { stufe: 6, ...ctx })
const NOW = Date.parse('2026-10-19T09:00:00Z')
const MIN = 60 * 1000
const HOUR = 60 * MIN
const DAY = 24 * HOUR
const iso = (offsetMs) => new Date(NOW + offsetMs).toISOString()
test('Filter: drei Reiter mit den Status-Listen für die API', () => {
assert.deepEqual(PULT_FILTERS.map((f) => f.statuses), ['offen,in_arbeit', 'veraltet', 'gesendet,verworfen,erledigt'])
assert.deepEqual(PULT_FILTERS.map((f) => [f.id, f.label]), [['offen', 'Wartet'], ['veraltet', 'Veraltet'], ['erledigt', 'Erledigt']])
})
test('Arten: deutsche Bezeichnungen für alle acht Pult-Arten, sendbar sind nur die zwei Antwort-Arten', () => {
assert.deepEqual(PULT_KIND_LABELS, {
plattform_antwort: 'Antwort auf Plattform-Anfrage',
mail_antwort: 'Antwort per E-Mail',
vorschau_freigabe: 'Vorschau freigeben',
unsicher: 'Unsicher – bitte prüfen',
beschwerde: 'Beschwerde',
preisfrage: 'Preisfrage',
mensch_gewuenscht: 'Mensch gewünscht',
bau_fehlgeschlagen: 'Bau fehlgeschlagen',
})
assert.deepEqual(SENDABLE_KINDS, ['plattform_antwort', 'mail_antwort'])
})
test('Veraltet-Anzeige: Status veraltet, abgelaufenes staleAt, "läuft in 5 Std. ab" unter 24 h, sonst nichts', () => {
assert.deepEqual(staleView({ status: 'veraltet' }, NOW), { stale: true, soon: false, label: 'veraltet' })
assert.deepEqual(staleView({ status: 'offen', staleAt: '2026-10-19T08:59:00Z' }, NOW), { stale: true, soon: false, label: 'veraltet' })
assert.deepEqual(staleView({ status: 'offen', staleAt: '2026-10-19T14:00:00Z' }, NOW), { stale: false, soon: true, label: 'läuft in 5 Std. ab' })
assert.deepEqual(staleView({ status: 'offen', staleAt: '2026-10-22T09:00:00Z' }, NOW), { stale: false, soon: false, label: '' })
assert.deepEqual(staleView({ status: 'offen' }, NOW), { stale: false, soon: false, label: '' })
})
test('Veraltet-Anzeige: Grenzen (genau jetzt, aufrunden, genau 24 h), in_arbeit zählt mit, abgeschlossene und kaputte Zeiten nie', () => {
assert.equal(staleView({ status: 'offen', staleAt: iso(0) }, NOW).stale, true) // staleAt <= jetzt
assert.equal(staleView({ status: 'in_arbeit', staleAt: iso(-1) }, NOW).stale, true)
assert.deepEqual(staleView({ status: 'in_arbeit', staleAt: iso(30 * MIN) }, NOW), { stale: false, soon: true, label: 'läuft in 1 Std. ab' })
assert.equal(staleView({ status: 'offen', staleAt: iso(HOUR + 1) }, NOW).label, 'läuft in 2 Std. ab') // aufgerundet
assert.equal(staleView({ status: 'offen', staleAt: iso(DAY - 1) }, NOW).soon, true)
assert.equal(staleView({ status: 'offen', staleAt: iso(DAY) }, NOW).soon, false) // erst unter 24 h
for (const status of ['gesendet', 'verworfen', 'erledigt']) assert.deepEqual(staleView({ status, staleAt: iso(-DAY) }, NOW), { stale: false, soon: false, label: '' })
assert.deepEqual(staleView({ status: 'offen', staleAt: 'kein Datum' }, NOW), { stale: false, soon: false, label: '' })
assert.deepEqual(staleView({ status: 'offen', staleAt: null }, NOW), { stale: false, soon: false, label: '' })
})
test('ageLabel: gerade eben, Minuten, Stunden, Tage; kaputte Zeit leer', () => {
assert.equal(ageLabel(iso(-30 * 1000), NOW), 'gerade eben')
assert.equal(ageLabel(iso(-5 * MIN), NOW), 'vor 5 Min.')
assert.equal(ageLabel(iso(-3 * HOUR), NOW), 'vor 3 Std.')
assert.equal(ageLabel(iso(-2 * DAY), NOW), 'vor 2 Tagen')
assert.equal(ageLabel(iso(-MIN), NOW), 'vor 1 Min.')
assert.equal(ageLabel(iso(-(HOUR - 1)), NOW), 'vor 59 Min.')
assert.equal(ageLabel(iso(-HOUR), NOW), 'vor 1 Std.')
assert.equal(ageLabel(iso(-DAY), NOW), 'vor 1 Tag')
assert.equal(ageLabel(iso(5 * MIN), NOW), 'gerade eben') // Uhr des Rechners geht etwas nach
assert.equal(ageLabel('', NOW), ''); assert.equal(ageLabel(undefined, NOW), ''); assert.equal(ageLabel('gestern', NOW), '')
})
test('safeExternalUrl: nur https, alles andere (http, javascript:, leer) → null', () => {
assert.equal(safeExternalUrl('https://www.kleinanzeigen.de/s-anzeige/x/1-2-3'), 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3')
assert.equal(safeExternalUrl('javascript:alert(1)'), null); assert.equal(safeExternalUrl('http://example.org/x'), null); assert.equal(safeExternalUrl(''), null)
})
test('safeExternalUrl: weitere Schemata, relative und kaputte Werte, Zugangsdaten in der Adresse', () => {
for (const bad of ['data:text/html,<b>x</b>', 'file:///etc/passwd', 'ftp://x.example/', 'mailto:x@y.de', '//x.example/', '/pfad', 'https://', ' javascript:alert(1)', null, undefined, 42, {}]) assert.equal(safeExternalUrl(bad), null, String(bad))
assert.equal(safeExternalUrl('https://www.kleinanzeigen.de@evil.example/'), null) // sähe nach Kleinanzeigen aus, führt aber woandershin
assert.equal(safeExternalUrl('https://user:pw@x.example/'), null)
assert.equal(safeExternalUrl(' HTTPS://X.Example/a b\n'), 'https://x.example/a%20b') // vom Browser normalisiert: Groß-/Kleinschreibung, Leerzeichen, Zeilenumbruch
})
test('mailtoHref: kodiert, Betreff einzeilig, Text über 1800 Zeichen entfällt', () => {
assert.equal(
mailtoHref({ to: 'x@verein.example', subject: 'Re: Bäckerei & Café?', body: 'Hallo Welt\nZeile 2 & mehr?' }),
'mailto:x@verein.example?subject=Re%3A%20B%C3%A4ckerei%20%26%20Caf%C3%A9%3F&body=Hallo%20Welt%0D%0AZeile%202%20%26%20mehr%3F'
)
// Betreff einzeilig: kein Zeilenumbruch, der einen weiteren Mail-Kopf einschleusen könnte
const sub = mailtoHref({ to: 'x@y.de', subject: 'Hallo\r\nBcc: z@w.de\tTab', body: '' })
assert.equal(sub, 'mailto:x@y.de?subject=Hallo%20Bcc%3A%20z%40w.de%20Tab')
assert.doesNotMatch(sub, /%0[AD]/i)
// Betreff höchstens 78 Zeichen
assert.equal(decodeURIComponent(mailtoHref({ to: 'x@y.de', subject: 'a'.repeat(100), body: '' }).split('subject=')[1]), 'a'.repeat(78))
// Text: 1800 Zeichen gehen mit, 1801 entfallen (die Adresse und der Betreff bleiben)
assert.match(mailtoHref({ to: 'x@y.de', subject: 'S', body: 'b'.repeat(1800) }), /&body=b{1800}$/)
assert.equal(mailtoHref({ to: 'x@y.de', subject: 'S', body: 'b'.repeat(1801) }), 'mailto:x@y.de?subject=S')
// ohne Betreff und ohne Text bleibt nur die Adresse
assert.equal(mailtoHref({ to: 'x@y.de' }), 'mailto:x@y.de')
assert.equal(mailtoHref({ to: 'x@y.de', body: 'Nur Text' }), 'mailto:x@y.de?body=Nur%20Text')
})
test('mailtoHref (F15): genau eine gültige Adresse, sonst "" (kein Mail-Link)', () => {
assert.equal(mailtoHref({ to: 'x@y.de?bcc=z@w.de', subject: 'S', body: 'B' }), '') // eingeschleuster Empfänger
assert.equal(mailtoHref({ to: 'x@y.de\r\nBcc: z@w.de', subject: 'S', body: 'B' }), '') // CR/LF
for (const bad of [
'x@y.de\n', '\nx@y.de', 'x@y.de\r', 'x@y.de ', ' x@y.de', 'x @y.de', 'x@y.de\tz', 'x@y.de
z', // Leerraum jeder Art
'x@y.de&cc=z@w.de', 'x@y.de%0Abcc=z@w.de', 'x%40y.de', 'x@y.de,z@w.de', 'x@y.de;z@w.de', // Trenner und Kodierung
'mailto:x@y.de', 'MAILTO:x@y.de', 'x@y.de#a', 'x@y.de/a', 'x@y.de\\a', '<x@y.de>', '"x"@y.de', // Präfix und Zeichen mit Sonderbedeutung
'x@@y.de', 'x@y@z.de', 'xy.de', '@y.de', 'x@', '', '@', null, undefined, 42,
]) assert.equal(mailtoHref({ to: bad, subject: 'S', body: 'B' }), '', JSON.stringify(bad))
assert.equal(mailtoHref({ subject: 'S' }), ''); assert.equal(mailtoHref(), '')
assert.equal(mailtoHref({ to: 'max.muster+shop@müller-bäcker.example' }), 'mailto:max.muster+shop@müller-bäcker.example') // Umlaut-Domain und + sind gültig
})
test('rowModel: Rechte je Status/Art/Person', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'T', draft: 'D', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', createdAt: '2026-10-19T06:00:00Z' }
const me = { id: 'u1', name: 'Justin' }
let m = rowModel(base, { me, nowMs: NOW, employees: [] })
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.copyText, m.sourceHref], [true, true, true, false, 'D', base.sourceUrl])
m = rowModel({ ...base, status: 'in_arbeit', claimedBy: 'mensch:u2' }, { me, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Kenso' }] })
assert.deepEqual([m.canClaim, m.claimedByOther, m.claimedByLabel, m.canSend], [false, true, 'Kenso', true])
m = rowModel({ ...base, kind: 'unsicher' }, { me, nowMs: NOW, employees: [] })
assert.deepEqual([m.canSend, m.canDone], [false, true])
m = rowModel({ ...base, status: 'gesendet' }, { me, nowMs: NOW, employees: [] })
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone], [false, false, false, false])
m = rowModel({ ...base, kind: 'mail_antwort', sourceUrl: '', prospect: { email: 'x@verein.example', name: 'Verein' } }, { me, nowMs: NOW, employees: [] })
assert.match(m.mailtoHref, /^mailto:x@verein\.example\?subject=/)
})
test('rowModel: Zeilendaten (Id, Art, Titel, Alter, Veraltet, Interessent) kommen aus dem Eintrag', () => {
const prospect = { name: 'Verein', email: 'x@verein.example' }
const item = { $id: 'p9', kind: 'beschwerde', status: 'offen', title: 'Titel', draft: 'Entwurf', createdAt: iso(-3 * HOUR), staleAt: iso(5 * HOUR), prospect }
const m = rowModel(item, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [] })
assert.deepEqual(
[m.id, m.kind, m.kindLabel, m.title, m.ageLabel, m.stale, m.prospect, m.copyText],
['p9', 'beschwerde', 'Beschwerde', 'Titel', 'vor 3 Std.', { stale: false, soon: true, label: 'läuft in 5 Std. ab' }, prospect, 'Entwurf']
)
const bare = rowModel({ $id: 'p8', kind: 'neue_art', status: 'offen', title: 'X', createdAt: iso(-MIN) }, { me: null, nowMs: NOW, employees: [] })
assert.deepEqual([bare.kindLabel, bare.copyText, bare.prospect, bare.sourceHref, bare.mailtoHref], ['neue_art', '', null, null, ''])
})
test('rowModel: veraltet bleibt übernehmbar, senden und verwerfen; Abgeschlossenes hat keine Aktion; nur Antwort-Arten sind sendbar', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'veraltet', title: 'T', draft: 'D', createdAt: iso(-4 * DAY) }
const ctx = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [] }
let m = rowModel(base, ctx)
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.stale.stale], [true, true, true, false, true])
// Der Dienst lässt die Übernahme eines veralteten Eintrags zu, auch wenn früher jemand anderes ihn hatte
m = rowModel({ ...base, claimedBy: 'mensch:u2' }, ctx)
assert.deepEqual([m.canClaim, m.claimedByOther, m.claimedByLabel], [true, false, ''])
for (const status of ['gesendet', 'verworfen', 'erledigt']) {
m = rowModel({ ...base, status }, ctx)
assert.deepEqual([m.canClaim, m.canSend, m.canDiscard, m.canDone, m.stale.stale], [false, false, false, false, false], status)
}
for (const kind of ['unsicher', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
m = rowModel({ ...base, kind, status: 'offen' }, ctx)
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, true], kind)
}
// P4-T32 (D20): „Erledigt“ würde die Vorschau-Freigabe schließen, ohne dass der Link hinausgeht: bei vorschau_freigabe gibt es nur Freigeben oder Verwerfen
m = rowModel({ ...base, kind: 'vorschau_freigabe', status: 'offen' }, ctx)
assert.deepEqual([m.canSend, m.canDiscard, m.canDone], [false, true, false])
m = rowModel({ ...base, kind: 'mail_antwort', status: 'in_arbeit' }, ctx)
assert.deepEqual([m.canSend, m.canDone], [true, false])
})
test('rowModel: eigene Übernahme, Name des Kollegen, Quell-Link nur https', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'in_arbeit', title: 'T', draft: 'D', createdAt: iso(-HOUR), claimedBy: 'mensch:u1' }
const employees = [{ userId: 'u1', displayName: 'Justin Ö.' }, { userId: 'u2', displayName: 'Kenso' }]
let m = rowModel(base, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther, m.claimedByLabel, m.canClaim, m.canSend], [true, false, 'Justin Ö.', false, true])
m = rowModel(base, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [] }) // eigener Name, auch wenn die Mitarbeiterliste ihn nicht kennt
assert.deepEqual([m.claimedByMe, m.claimedByLabel], [true, 'Justin'])
m = rowModel({ ...base, claimedBy: 'mensch:u9' }, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther, m.claimedByLabel], [false, true, 'Kollege'])
m = rowModel({ ...base, status: 'offen', claimedBy: undefined }, { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther, m.claimedByLabel], [false, false, ''])
m = rowModel({ ...base, claimedBy: 'mensch:u1' }, { me: null, nowMs: NOW, employees })
assert.deepEqual([m.claimedByMe, m.claimedByOther], [false, true]) // ohne bekannte Person gehört der Eintrag jemand anderem
for (const sourceUrl of ['javascript:alert(1)', 'http://www.kleinanzeigen.de/x', 'https://www.kleinanzeigen.de@evil.example/', '']) {
assert.equal(rowModel({ ...base, sourceUrl }, { me: null, nowMs: NOW, employees }).sourceHref, null, sourceUrl)
}
})
test('rowModel: Mail-Link nur bei mail_antwort mit gültiger Adresse, Betreff "Re: Name", Text = Entwurf', () => {
const base = { $id: 'a', kind: 'mail_antwort', status: 'offen', title: 'T', draft: 'Guten Tag\nBis bald', createdAt: iso(-HOUR) }
const ctx = { me: null, nowMs: NOW, employees: [] }
assert.equal(rowModel({ ...base, prospect: { email: 'x@verein.example', name: 'Verein' } }, ctx).mailtoHref, 'mailto:x@verein.example?subject=Re%3A%20Verein&body=Guten%20Tag%0D%0ABis%20bald')
assert.equal(rowModel({ ...base, prospect: { email: 'x@verein.example' } }, ctx).mailtoHref, 'mailto:x@verein.example?body=Guten%20Tag%0D%0ABis%20bald') // ohne Namen kein Betreff
assert.equal(rowModel({ ...base, prospect: { email: 'x@y.de?bcc=z@w.de', name: 'Verein' } }, ctx).mailtoHref, '') // F15: kein Link
assert.equal(rowModel({ ...base, prospect: { name: 'Verein' } }, ctx).mailtoHref, '')
assert.equal(rowModel({ ...base, prospect: null }, ctx).mailtoHref, '')
assert.equal(rowModel(base, ctx).mailtoHref, '')
assert.equal(rowModel({ ...base, kind: 'plattform_antwort', prospect: { email: 'x@verein.example', name: 'Verein' } }, ctx).mailtoHref, '') // Plattform-Antwort geht über den Quell-Link
})
// Endprüfung P3 (W5): der Reiter „Erledigt“ zeigt, was aus einem Eintrag wurde, wer es tat und wann
test('rowModel: abgeschlossene Einträge zeigen Ergebnis, Person und Zeitpunkt; drei Status ergeben drei Bezeichnungen', () => {
const base = { $id: 'a', kind: 'plattform_antwort', title: 'T', draft: 'D', createdAt: iso(-4 * DAY), doneBy: 'mensch:u2', doneAt: iso(-2 * HOUR) }
const ctx = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Anna' }] }
const out = {}
for (const status of ['gesendet', 'verworfen', 'erledigt']) {
const m = rowModel({ ...base, status }, ctx)
out[status] = [m.statusLabel, m.statusTone, m.doneByLabel, m.doneAgeLabel, m.doneLine]
}
assert.deepEqual(out, {
gesendet: ['Gesendet', 'green', 'von Anna', 'vor 2 Std.', 'Gesendet von Anna · vor 2 Std.'],
verworfen: ['Verworfen', 'muted', 'von Anna', 'vor 2 Std.', 'Verworfen von Anna · vor 2 Std.'],
erledigt: ['Erledigt', 'muted', 'von Anna', 'vor 2 Std.', 'Erledigt von Anna · vor 2 Std.'],
})
assert.equal(new Set(Object.values(out).map((v) => v[0])).size, 3)
// das Alter ab Anlage bleibt im Modell (Alter-Feld unverändert), die Zeile zeigt aber die Abschlusszeit
assert.equal(rowModel({ ...base, status: 'gesendet' }, ctx).ageLabel, 'vor 4 Tagen')
})
test('rowModel: doneBy "mensch:<id>" über die Mitarbeiterliste, ein Worker wie "max" wird nie zu "Kollege"', () => {
const base = { $id: 'a', kind: 'mail_antwort', status: 'gesendet', title: 'T', createdAt: iso(-DAY), doneAt: iso(-5 * MIN) }
const ctx = { me: null, nowMs: NOW, employees: [{ userId: 'u1', displayName: 'Justin' }] }
assert.equal(rowModel({ ...base, doneBy: 'mensch:u1' }, ctx).doneByLabel, 'von Justin')
assert.equal(rowModel({ ...base, doneBy: 'mensch:u9' }, ctx).doneByLabel, 'von Kollege') // unbekannte Person
assert.equal(rowModel({ ...base, doneBy: 'mensch:u1' }, { ...ctx, employees: [] }).doneByLabel, 'von Kollege')
assert.equal(rowModel({ ...base, doneBy: 'max' }, ctx).doneByLabel, 'von Max (automatisch)') // P4: Max sendet automatisch
assert.equal(rowModel({ ...base, doneBy: 'clara' }, ctx).doneLine, 'Gesendet von Clara (automatisch) · vor 5 Min.')
assert.ok(!rowModel({ ...base, doneBy: 'max' }, ctx).doneLine.includes('Kollege'))
// unbekannte Kennungen (auch eine, die wie ein Objekt-Schlüssel aussieht) sind weder Mensch noch Worker
for (const by of ['system', 'constructor', '__proto__', 'u1']) assert.equal(rowModel({ ...base, doneBy: by }, ctx).doneByLabel, 'automatisch', by)
// ohne doneBy kein Satzteil
for (const by of [undefined, null, '', 7]) assert.deepEqual([rowModel({ ...base, doneBy: by }, ctx).doneByLabel, rowModel({ ...base, doneBy: by }, ctx).doneLine], ['', 'Gesendet · vor 5 Min.'], String(by))
})
test('rowModel: fehlendes oder kaputtes doneAt gibt keine Zeitangabe, offene Zeilen bleiben unverändert', () => {
const base = { $id: 'a', kind: 'plattform_antwort', status: 'verworfen', title: 'T', createdAt: iso(-DAY), doneBy: 'mensch:u1' }
const ctx = { me: null, nowMs: NOW, employees: [{ userId: 'u1', displayName: 'Justin' }] }
for (const doneAt of [undefined, null, '', 'gestern']) {
const m = rowModel({ ...base, doneAt }, ctx)
assert.deepEqual([m.doneAgeLabel, m.doneLine], ['', 'Verworfen von Justin'], String(doneAt))
}
// offene, übernommene und veraltete Einträge: keine Abschlussfelder, auch wenn doneBy/doneAt (aus einer Altlast) gesetzt wären
for (const status of ['offen', 'in_arbeit', 'veraltet']) {
const m = rowModel({ ...base, status, doneAt: iso(-HOUR), claimedBy: 'mensch:u1' }, ctx)
assert.deepEqual([m.statusLabel, m.statusTone, m.doneByLabel, m.doneAgeLabel, m.doneLine], ['', '', '', '', ''], status)
assert.equal(m.ageLabel, 'vor 1 Tag')
}
// unbekannter Status (neuer Dienst) und Objekt-Schlüssel als Status
for (const status of ['unbekannt', 'constructor', undefined]) assert.equal(rowModel({ ...base, status }, ctx).statusLabel, '', String(status))
})
test('employeeName: "mensch:"-Präfix weg, Nachschlagen über userId, sonst "Kollege"', () => {
const employees = [{ userId: 'u2', displayName: 'Kenso' }, { userId: 'u3', displayName: '' }, { userId: 'u4' }]
assert.equal(employeeName('mensch:u2', employees), 'Kenso')
assert.equal(employeeName('u2', employees), 'Kenso') // ohne Präfix
assert.equal(employeeName('mensch:u9', employees), 'Kollege') // unbekannt
assert.equal(employeeName('mensch:u3', employees), 'Kollege') // ohne Namen
assert.equal(employeeName('mensch:u4', employees), 'Kollege')
assert.equal(employeeName('system:u2', employees), 'Kollege') // fremde Kennung
assert.equal(employeeName('', employees), 'Kollege'); assert.equal(employeeName(undefined, employees), 'Kollege'); assert.equal(employeeName(null, undefined), 'Kollege')
assert.equal(employeeName('mensch:u2', null), 'Kollege'); assert.equal(employeeName('mensch:u2', []), 'Kollege')
})
test('copyAndOpen: Kopieren wird vor dem Öffnen angestoßen (ohne await dazwischen); Kopierfehler ändert das Öffnen nicht', async () => {
const order = []
const r = await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: (t) => { order.push('copy:' + t); return Promise.resolve() }, open: (u) => { order.push('open:' + u); return true } })
assert.deepEqual(order, ['copy:D', 'open:https://x.example/']); assert.deepEqual(r, { copied: true, opened: true })
const f = await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: () => Promise.reject(new Error('blockiert')), open: () => true })
assert.deepEqual(f, { copied: false, opened: true })
assert.equal((await copyAndOpen({ text: 'D', url: null, copy: async () => {}, open: () => true })).opened, false)
assert.equal((await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: async () => {}, open: () => null })).opened, true) // window.open mit noopener liefert immer null: zählt nicht
})
test('copyAndOpen: open läuft noch im selben Zug (Nutzer-Geste), auch wenn copy lange braucht; Fehler und unsichere Adressen', async () => {
// open wird aufgerufen, bevor copy fertig ist, und zwar synchron nach dem Aufruf (kein await davor)
const order = []
let finish
const pending = new Promise((resolve) => { finish = resolve })
const run = copyAndOpen({ text: 'D', url: 'https://x.example/', copy: () => pending.then(() => order.push('copy fertig')), open: () => { order.push('open'); return null } })
assert.deepEqual(order, ['open'])
finish()
assert.deepEqual(await run, { copied: true, opened: true })
assert.deepEqual(order, ['open', 'copy fertig'])
// copy wirft sofort (z. B. navigator.clipboard fehlt): kein Absturz, geöffnet wird trotzdem
assert.deepEqual(await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: () => { throw new TypeError('kein clipboard') }, open: () => null }), { copied: false, opened: true })
// open wirft (Popup-Sperre): kopiert ist trotzdem
assert.deepEqual(await copyAndOpen({ text: 'D', url: 'https://x.example/', copy: async () => {}, open: () => { throw new Error('gesperrt') } }), { copied: true, opened: false })
// unsichere Adresse: wird nie geöffnet, kopiert wird trotzdem
let opened = 0
const bad = await copyAndOpen({ text: 'D', url: 'javascript:alert(1)', copy: async () => {}, open: () => { opened += 1; return null } })
assert.deepEqual(bad, { copied: true, opened: false }); assert.equal(opened, 0)
})
// ---- P4-T32: als Mensch antworten, Vorschau freigeben, Bau starten, Antwort des Kunden einfügen ----
const T32_CTX = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Kenso' }] }
const PREVIEW_URL = 'https://entwurf.project.webklar.com/AAA/'
const entry = (over = {}) => ({ $id: 'e1', kind: 'unsicher', status: 'offen', title: 'T', draft: 'D', createdAt: iso(-HOUR), prospectId: 'p1', prospect: { name: 'X', email: 'x@verein.example' }, ...over })
const flags = (item, ctx = T32_CTX) => {
const m = rowModel(item, ctx)
return [m.canReply, m.canApprovePreview, m.canStartBuild, m.canPaste]
}
test('Arten: REPLY_KINDS und PASTE_KINDS sind eingefroren und liegen im Dienst genauso', () => {
assert.deepEqual(REPLY_KINDS, ['mail_antwort', 'unsicher', 'preisfrage', 'mensch_gewuenscht'])
assert.deepEqual(PASTE_KINDS, ['plattform_antwort'])
assert.ok(Object.isFrozen(REPLY_KINDS) && Object.isFrozen(PASTE_KINDS))
assert.equal(REPLY_TEXT_MAX, 8000)
})
test('rowModel canReply: Art, Status, Übernahme und Mail-Kanal (Interessent mit Adresse)', () => {
// Art unsicher, offen, Interessent mit Adresse: ja; vorschau_freigabe und beschwerde: nein
assert.deepEqual(flags(entry()), [true, false, true, false])
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ kind: 'beschwerde' }), T32_CTX).canApprovePreview, false)
for (const kind of REPLY_KINDS) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, true, kind)
for (const kind of ['plattform_antwort', 'vorschau_freigabe', 'beschwerde', 'bau_fehlgeschlagen', 'neue_art']) assert.equal(rowModel(entry({ kind }), T32_CTX).canReply, false, kind)
// „Mail ohne Faden“: kein Interessent ⇒ keine Antwort-Funktion; ohne Adresse (Plattform-Interessent) ebenso (Ruling D13)
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ prospect: undefined }), T32_CTX).canReply, false)
assert.equal(rowModel(entry({ prospect: { name: 'X' } }), T32_CTX).canReply, false)
for (const email of ['', ' ', null, 7]) assert.equal(rowModel(entry({ prospect: { name: 'X', email } }), T32_CTX).canReply, false, String(email))
// Status: offen, in_arbeit (nur eigene Übernahme) und veraltet; alles Abgeschlossene nicht
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, true, status)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canReply, true)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canReply, false) // Kollege hat übernommen
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canReply, false) // Max sendet gerade selbst
assert.equal(rowModel(entry({ status: 'veraltet', claimedBy: 'mensch:u2' }), T32_CTX).canReply, true) // veraltet hält nichts mehr
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canReply, false, status)
})
test('rowModel canApprovePreview und previewHref: nur Art vorschau_freigabe, gleiche Status-Regeln, nur die Entwurfs-Adresse', () => {
const freigabe = (over = {}) => entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL, ...over })
let m = rowModel(freigabe(), T32_CTX)
assert.deepEqual([m.canApprovePreview, m.canReply, m.canStartBuild, m.canPaste, m.previewHref], [true, false, false, false, PREVIEW_URL])
// dieselben Status-Regeln wie bei canReply
for (const status of ['offen', 'veraltet']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, true, status)
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canApprovePreview, true)
assert.equal(rowModel(freigabe({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canApprovePreview, false)
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(freigabe({ status }), T32_CTX).canApprovePreview, false, status)
// andere Arten geben nichts frei, auch mit Entwurfs-Adresse
for (const kind of ['unsicher', 'mail_antwort', 'plattform_antwort', 'beschwerde']) {
m = rowModel(freigabe({ kind }), T32_CTX)
assert.deepEqual([m.canApprovePreview, m.previewHref], [false, null], kind)
}
// previewHref: nur https://entwurf.project.webklar.com/…, alles andere null (der Link steht neben dem Knopf „Freigeben“)
for (const bad of ['https://fremd.example/', 'javascript:alert(1)', 'http://entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com.fremd.example/AAA/',
'https://entwurf.project.webklar.com@fremd.example/AAA/', 'https://user:pw@entwurf.project.webklar.com/AAA/', 'https://entwurf.project.webklar.com:8443/AAA/',
'https://www.project.webklar.com/AAA/', 'https://project.webklar.com/AAA/', '//entwurf.project.webklar.com/AAA/', '', null, undefined, 7]) {
m = rowModel(freigabe({ sourceUrl: bad }), T32_CTX)
assert.equal(m.previewHref, null, String(bad))
assert.equal(m.canApprovePreview, true, String(bad)) // das Modell bleibt bei true; die Zeile sperrt den Knopf ohne gültige Adresse (Endprüfung W5, workersPultUi.test.mjs)
}
assert.equal(rowModel(freigabe({ sourceUrl: 'HTTPS://ENTWURF.PROJECT.WEBKLAR.COM/AAA/' }), T32_CTX).previewHref, PREVIEW_URL) // vom Browser normalisiert
// abgeschlossen bleibt die Adresse im Modell (die Zeile braucht sie dort nicht)
assert.equal(rowModel(freigabe({ status: 'erledigt' }), T32_CTX).previewHref, PREVIEW_URL)
})
test('rowModel (D20): vorschau_freigabe zeigt keine „Anzeige öffnen“-Adresse (sourceUrl ist die Vorschau) und kein „Erledigt“', () => {
const m = rowModel(entry({ kind: 'vorschau_freigabe', sourceUrl: PREVIEW_URL }), T32_CTX)
assert.deepEqual([m.sourceHref, m.canDone, m.canDiscard, m.canSend, m.canClaim], [null, false, true, false, true])
// andere Arten behalten ihre Anzeige-Adresse
assert.equal(rowModel(entry({ kind: 'plattform_antwort', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3' }), T32_CTX).sourceHref, 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3')
})
test('rowModel canStartBuild (GB16): nur Art unsicher mit prospectId, bearbeitbar und nicht von einem anderen übernommen', () => {
assert.equal(rowModel(entry(), T32_CTX).canStartBuild, true)
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canStartBuild, true) // die Kurzfassung des Interessenten fehlt, die Id nicht: der Dienst prüft
for (const prospectId of [undefined, null, '', ' ', 7]) assert.equal(rowModel(entry({ prospectId }), T32_CTX).canStartBuild, false, String(prospectId))
for (const kind of ['mail_antwort', 'preisfrage', 'mensch_gewuenscht', 'beschwerde', 'vorschau_freigabe', 'plattform_antwort', 'bau_fehlgeschlagen']) {
assert.equal(rowModel(entry({ kind }), T32_CTX).canStartBuild, false, kind)
}
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), T32_CTX).canStartBuild, true)
assert.equal(rowModel(entry({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canStartBuild, false)
assert.equal(rowModel(entry({ status: 'veraltet' }), T32_CTX).canStartBuild, true)
for (const status of ['gesendet', 'erledigt', 'verworfen']) assert.equal(rowModel(entry({ status }), T32_CTX).canStartBuild, false, status)
})
// Endprüfung P4 (W2): ein toter Interessent bekommt keinen Knopf „Vorschau bauen“ (der Dienst lehnt ihn ohnehin mit 409 ab)
test('rowModel canStartBuild (W2): bei gesperrtem, verlorenem oder abgeschlossenem Interessenten (gesperrt, verloren, deal) kein Knopf, sonst bleibt er', () => {
for (const status of ['gesperrt', 'verloren', 'deal']) {
assert.equal(rowModel(entry({ prospect: { $id: 'p1', name: 'X', status } }), T32_CTX).canStartBuild, false, status)
}
// lebende Interessenten und Kurzfassungen ohne Status behalten den Knopf (der Dienst prüft das Übrige: doppelte Vorschau)
for (const status of ['gefunden', 'kontaktiert', 'gespraech', 'ja', 'vorschau_bau', undefined, null, 7]) {
assert.equal(rowModel(entry({ prospect: { $id: 'p1', name: 'X', status } }), T32_CTX).canStartBuild, true, String(status))
}
assert.equal(rowModel(entry({ prospect: null }), T32_CTX).canStartBuild, true) // keine Kurzfassung: der Dienst entscheidet
// alles andere zählt weiter: nur Art unsicher, bearbeitbar, nicht von einem anderen gehalten, mit prospectId
assert.equal(rowModel(entry({ prospect: { status: 'gesperrt' }, status: 'veraltet' }), T32_CTX).canStartBuild, false)
assert.equal(rowModel(entry({ prospect: { status: 'verloren' }, prospectId: '' }), T32_CTX).canStartBuild, false)
})
test('rowModel canPaste: Art plattform_antwort mit Interessent, in jedem Status außer verworfen, auch bei einem Kollegen', () => {
const plattform = (over = {}) => entry({ kind: 'plattform_antwort', prospect: { name: 'X' }, ...over })
for (const status of ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'erledigt']) assert.equal(rowModel(plattform({ status }), T32_CTX).canPaste, true, status)
// von einem Kollegen übernommen oder schon gesendet: bleibt einfügbar (die Rückmeldung kommt meist erst nach dem Senden)
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), T32_CTX).canPaste, true)
assert.equal(rowModel(plattform({ status: 'in_arbeit', claimedBy: 'max' }), T32_CTX).canPaste, true)
assert.equal(rowModel(plattform({ status: 'verworfen' }), T32_CTX).canPaste, false)
assert.equal(rowModel(plattform({ prospect: null }), T32_CTX).canPaste, false)
assert.equal(rowModel(plattform({ prospect: undefined }), T32_CTX).canPaste, false)
for (const kind of ['mail_antwort', 'unsicher', 'vorschau_freigabe', 'beschwerde', 'preisfrage', 'mensch_gewuenscht', 'bau_fehlgeschlagen']) {
assert.equal(rowModel(plattform({ kind }), T32_CTX).canPaste, false, kind)
}
// Einfügen und Antworten schließen sich aus: eine Plattform-Antwort geht über die Plattform, eine Mail-Art nie über das Einfügen
assert.deepEqual(flags(plattform()), [false, false, false, true])
})
test('rowModel: eine Übernahme durch einen Worker (max) heißt „Max (automatisch)“, nie „Kollege“ (wie bei doneBy)', () => {
const held = (claimedBy) => rowModel(entry({ kind: 'mail_antwort', status: 'in_arbeit', claimedBy }), T32_CTX)
let m = held('max')
assert.deepEqual([m.claimedByLabel, m.claimedByOther, m.claimedByMe], ['Max (automatisch)', true, false])
assert.equal(held('clara').claimedByLabel, 'Clara (automatisch)')
// Menschen wie bisher; unbekannte Kennungen (auch Objekt-Schlüssel) sind keine Worker
assert.equal(held('mensch:u2').claimedByLabel, 'Kenso')
assert.equal(held('mensch:u9').claimedByLabel, 'Kollege')
for (const by of ['constructor', '__proto__', 'system']) assert.equal(held(by).claimedByLabel, 'Kollege', by)
// ein Mensch, dessen Nutzer-Kennung zufällig wie ein Worker heißt, bleibt ein Mensch
assert.equal(held('mensch:max').claimedByLabel, 'Kollege')
})
test('validateReplyText: getrimmt, leer und zu lang mit den Sätzen des Dienstes, Grenze 8000 Zeichen (Code Points)', () => {
assert.deepEqual(validateReplyText(' '), { ok: false, error: 'Bitte einen Text eingeben.' })
for (const bad of ['', '\n\t ', undefined, null, 5, {}]) assert.deepEqual(validateReplyText(bad), { ok: false, error: 'Bitte einen Text eingeben.' }, String(bad))
assert.deepEqual(validateReplyText(' Hallo '), { ok: true, text: 'Hallo' })
assert.deepEqual(validateReplyText('Zeile 1\n\nZeile 2'), { ok: true, text: 'Zeile 1\n\nZeile 2' }) // innere Umbrüche bleiben
const tooLong = { ok: false, error: 'Der Text ist zu lang (höchstens 8000 Zeichen).' }
assert.deepEqual(validateReplyText('a'.repeat(8001)), tooLong)
assert.equal(validateReplyText('a'.repeat(8000)).ok, true)
assert.equal(validateReplyText(` ${'a'.repeat(8000)} `).ok, true) // gezählt wird der getrimmte Text
// Code Points wie im Dienst: 8000 Emoji sind 8000 Zeichen (16000 UTF-16-Einheiten), 8001 sind zu viel
assert.equal(validateReplyText('😀'.repeat(8000)).ok, true)
assert.deepEqual(validateReplyText('😀'.repeat(8001)), tooLong)
assert.equal(charCount(' 😀a '), 2); assert.equal(charCount(undefined), 0); assert.equal(charCount('a'.repeat(8001)), 8001)
})
test('actionErrorText (D14): bei reply, approve-preview und start-build heißt „keine Antwort“ nicht „nicht gesendet“', () => {
assert.equal(UNCERTAIN_TEXT, 'Kann gesendet sein – Pult neu laden.')
const down = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })
for (const action of ['reply', 'approve-preview', 'start-build']) assert.equal(actionErrorText(down, action), UNCERTAIN_TEXT, action)
// alle anderen Aktionen und alle echten Antworten des Dienstes behalten ihren Satz
for (const action of ['paste', 'claim', 'sent', 'discard', 'done', undefined]) assert.equal(actionErrorText(down, action), down.message, String(action))
// W1: auch die Fehlerseite des Proxys beim Warten (502/504, Meldung „nicht erreichbar“) lässt offen, ob der Dienst fertig wurde; 503 (kein Upstream) und der Rest nicht
for (const status of [502, 504]) {
const gateway = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status })
for (const action of ['reply', 'approve-preview', 'start-build']) assert.equal(actionErrorText(gateway, action), UNCERTAIN_TEXT, `${action} ${status}`)
for (const action of ['paste', 'claim', 'sent', 'discard', 'done', undefined]) assert.equal(actionErrorText(gateway, action), gateway.message, `${action} ${status}`)
// ein Satz des Dienstes unter demselben Status gehört dem Dienst (502 „Anmeldedienst nicht erreichbar.“ kommt vor der Aktion)
const sentence = Object.assign(new Error('Anmeldedienst nicht erreichbar.'), { status })
assert.equal(actionErrorText(sentence, 'reply'), 'Anmeldedienst nicht erreichbar.', String(status))
}
for (const status of [503, 429, 500, 401, 400, 409]) {
const other = Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status })
assert.equal(actionErrorText(other, 'reply'), other.message, String(status))
}
const conflict = Object.assign(new Error('Wird schon von einem Kollegen bearbeitet.'), { status: 409 })
assert.equal(actionErrorText(conflict, 'reply'), 'Wird schon von einem Kollegen bearbeitet.')
assert.equal(actionErrorText(Object.assign(new Error('Anfrage zu groß.'), { status: 413 }), 'reply'), 'Anfrage zu groß.')
assert.equal(actionErrorText(Object.assign(new Error('Anmeldung abgelaufen. Bitte neu anmelden.'), { status: 401 }), 'reply'), 'Anmeldung abgelaufen. Bitte neu anmelden.')
// kein Satz da: allgemeiner Hinweis
assert.equal(actionErrorText(new Error(''), 'reply'), 'Das hat nicht geklappt.')
assert.equal(actionErrorText(undefined, 'paste'), 'Das hat nicht geklappt.')
assert.equal(actionErrorText(null, 'reply'), 'Das hat nicht geklappt.')
})
test('pasteDoneText: neu und fortgesetzt melden das Einsortieren, doppelt meldet „schon eingefügt“', () => {
assert.equal(pasteDoneText({ status: 'neu', messageId: 'm1', threadId: 't1' }), 'Eingefügt. Nora sortiert die Antwort ein.')
assert.equal(pasteDoneText({ status: 'fortgesetzt' }), 'Eingefügt. Nora sortiert die Antwort ein.')
assert.equal(pasteDoneText({ status: 'doppelt' }), 'Diese Antwort war schon eingefügt.')
for (const odd of [undefined, null, {}, 'doppelt', { status: 7 }]) assert.equal(pasteDoneText(odd), 'Eingefügt. Nora sortiert die Antwort ein.', String(odd))
})
// ---------- Stufen-Sperre (hustlerStufe.js): rowModel auf Stufe 3 und Stufe 6 ----------
const ME = { id: 'u1', name: 'Justin' }
const PROSPEKT = { $id: 'p1', name: 'Verein', email: 'x@verein.example', status: 'neu' }
const eintrag = (extra) => ({ $id: 'e1', kind: 'plattform_antwort', status: 'offen', title: 'Titel', draft: 'Hallo', createdAt: '2026-10-19T08:00:00Z', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospectId: 'p1', prospect: PROSPEKT, ...extra })
const zeile = (item, s) => rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: s })
const P4 = ['canReply', 'canPaste', 'canApprovePreview', 'canStartBuild']
const P3 = ['canClaim', 'canSend', 'canDiscard', 'canDone']
const VORSCHAU = 'https://entwurf.project.webklar.com/verein'
test('rowModel auf Stufe 1 bis 3: canReply, canPaste, canApprovePreview und canStartBuild sind für jede Art und jeden Status false', () => {
const kinds = Object.keys(PULT_KIND_LABELS)
const states = ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'verworfen', 'erledigt']
for (const s of [1, 2, 3]) {
for (const kind of kinds) {
for (const status of states) {
for (const extra of [{}, { claimedBy: 'mensch:u1' }, { claimedBy: 'mensch:u2' }, { prospect: null }]) {
const item = eintrag({ kind, status, sourceUrl: kind === 'vorschau_freigabe' ? VORSCHAU : undefined, ...extra })
const m = zeile(item, s)
for (const key of P4) assert.equal(m[key], false, `Stufe ${s} ${kind}/${status} ${JSON.stringify(extra)}: ${key}`)
}
}
}
}
})
test('rowModel auf Stufe 4, 6 und 9: die P4-Aktionen erscheinen wie vor der Sperre', () => {
for (const s of [4, 6, 9]) {
assert.equal(zeile(eintrag({ kind: 'mail_antwort' }), s).canReply, true, `Stufe ${s}`)
assert.equal(zeile(eintrag({ kind: 'unsicher' }), s).canReply, true)
assert.equal(zeile(eintrag({ kind: 'plattform_antwort', status: 'gesendet' }), s).canPaste, true)
assert.equal(zeile(eintrag({ kind: 'plattform_antwort', status: 'verworfen' }), s).canPaste, false) // die Regeln selbst bleiben
assert.equal(zeile(eintrag({ kind: 'vorschau_freigabe', sourceUrl: VORSCHAU }), s).canApprovePreview, true)
assert.equal(zeile(eintrag({ kind: 'unsicher' }), s).canStartBuild, true)
assert.equal(zeile(eintrag({ kind: 'unsicher', prospect: { ...PROSPEKT, status: 'gesperrt' } }), s).canStartBuild, false)
assert.equal(zeile(eintrag({ kind: 'mail_antwort', claimedBy: 'mensch:u2', status: 'in_arbeit' }), s).canReply, false) // von einem Kollegen übernommen
}
})
test('rowModel: die P3-Aktionen sind auf Stufe 3 und 6 gleich; die Stufe ändert nur die vier P4-Felder', () => {
const fixtures = []
for (const kind of Object.keys(PULT_KIND_LABELS)) {
for (const status of ['offen', 'in_arbeit', 'veraltet', 'gesendet', 'verworfen', 'erledigt']) fixtures.push(eintrag({ kind, status, sourceUrl: kind === 'vorschau_freigabe' ? VORSCHAU : undefined }))
}
fixtures.push(eintrag({ status: 'in_arbeit', claimedBy: 'mensch:u1' }), eintrag({ status: 'in_arbeit', claimedBy: 'mensch:u2' }), eintrag({ kind: 'mail_antwort', sourceUrl: '' }))
for (const item of fixtures) {
const gesperrt = zeile(item, 3); const offen = zeile(item, 6)
for (const key of P3) assert.equal(gesperrt[key], offen[key], `${item.kind}/${item.status}: ${key}`)
const ohneP4 = (m) => Object.fromEntries(Object.entries(m).filter(([k]) => !P4.includes(k)))
assert.deepEqual(ohneP4(gesperrt), ohneP4(offen), `${item.kind}/${item.status}`)
}
// und konkret die P3-Aktionen auf Stufe 3
const plattform = zeile(eintrag({}), 3)
assert.deepEqual([plattform.canClaim, plattform.canSend, plattform.canDiscard, plattform.canDone], [true, true, true, false])
assert.equal(plattform.sourceHref, 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3'); assert.equal(plattform.copyText, 'Hallo')
const mail = zeile(eintrag({ kind: 'mail_antwort', sourceUrl: '' }), 3)
assert.match(mail.mailtoHref, /^mailto:x@verein\.example\?/); assert.equal(mail.canSend, true)
const unsicher = zeile(eintrag({ kind: 'unsicher' }), 3)
assert.deepEqual([unsicher.canDone, unsicher.canDiscard, unsicher.canClaim], [true, true, true])
const freigabe = zeile(eintrag({ kind: 'vorschau_freigabe', sourceUrl: VORSCHAU }), 3)
assert.deepEqual([freigabe.canClaim, freigabe.canDiscard, freigabe.canDone, freigabe.canSend], [true, true, false, false])
})
test('rowModel ohne Stufe gilt HUSTLER_STUFE (die Stufe der Produktion)', () => {
const item = eintrag({ kind: 'mail_antwort' })
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [] }).canReply, HUSTLER_STUFE >= 4)
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: undefined }).canReply, HUSTLER_STUFE >= 4)
assert.equal(rowModelNachStufe(item, { me: ME, nowMs: NOW, employees: [], stufe: 'x' }).canReply, HUSTLER_STUFE >= 4)
})