Files
tickte-system/tests/unit/workersPult.test.mjs
T
ClaudeandClaude Sonnet 5.5 4ec3022df4 Workers P3-Endprüfung W5: Reiter Erledigt zeigt Ergebnis, Person und Zeitpunkt
Abgeschlossene Zeilen tragen jetzt ein Badge (Gesendet grün, Verworfen/Erledigt neutral) und die
Zeile "Gesendet von Anna · vor 2 Std." aus doneBy/doneAt statt des Alters ab Anlage. Menschen
(mensch:<id>) werden über die Mitarbeiterliste aufgelöst, ein Worker wie max über das
Rollen-Register als "Max (automatisch)" und nie als "Kollege". Offene Zeilen bleiben unverändert.

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

280 lines
21 KiB
JavaScript
Raw 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, staleView, ageLabel, safeExternalUrl, mailtoHref, employeeName, rowModel, copyAndOpen } =
await loadModule('src/lib/workersPult.js')
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 ['vorschau_freigabe', '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)
}
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)
})