Merge kn/auftraege-pilot (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
5f4e158660
10 files changed
+544
-36
No files matched your search
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const NEU = path.join(WURZEL, 'public', 'neu')
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
|
||||
// Seit P7 sprechen auch die vier Bestellseiten „du“ (Fassung 6, C14); einzige Ausnahme ist der Rechtstext TAKEOVER_CONDITIONS (hash-gepinnt)
|
||||
const WORTLAUT = []
|
||||
const BESTELLSEITEN = ['ansicht-bestellen', 'ansicht-wechsel', 'ansicht-speicher', 'ansicht-gutschrift', 'bestell-bausteine']
|
||||
|
||||
@@ -6,7 +6,7 @@ import { K } from '../public/neu/ansicht-hilfen.js'
|
||||
const css = (f) => readFileSync(new URL(`../public/neu/${f}`, import.meta.url), 'utf8')
|
||||
const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
|
||||
const A = css('ansichten.css'); const O = css('oeffentlich/oeffentlich.css'); const C = css('chat-composer.css'); const BASIS = css('basis.css'); const TOKENS = css('tokens.css')
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
|
||||
const existiert = (n) => existsSync(new URL(`../public/neu/${n}.js`, import.meta.url))
|
||||
const lies = (n) => css(`${n}.js`).replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const VERBOTEN = /linear-gradient|radial-gradient|conic-gradient|backdrop-filter|prefers-color-scheme:\s*dark|font-weight:\s*800|#9d3dfe|rgba?\(\s*157\s*,\s*61/i
|
||||
|
||||
@@ -32,6 +32,22 @@ test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GE
|
||||
const w = await zeige(fx)
|
||||
assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]'))
|
||||
}))
|
||||
const ZEIT_AUFTRAEGE = [
|
||||
{ number: '100207', topic: 'Öffnungszeiten aktualisieren', createdAt: '2026-10-02T10:00:00.000Z', status: { label: 'Erledigt', tone: 'done', step: 4 }, entries: [{ kind: 'online', label: 'Online erledigt', date: '2026-10-04', minutes: 50, person: 'Lena Vogel', text: 'Neue Öffnungszeiten eingetragen.' }] },
|
||||
{ number: '100231', topic: 'Neue Fotos für die Startseite', createdAt: '2026-10-13T10:00:00.000Z', status: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 }, entries: [] },
|
||||
]
|
||||
test('Website: „Was wir zuletzt erledigt haben“ ist eine Zeitleiste, laufende Aufträge zuerst, Rückfrage mit Link in den Chat', mit({ ...HAUPT, 'GET /api/activity': { tickets: ZEIT_AUFTRAEGE } }, async (fx) => {
|
||||
const w = await zeige(fx)
|
||||
const abschnitt = fx.finde(w, '[data-abschnitt="aktivitaet"]')
|
||||
assert.equal(fx.finde(abschnitt, 'h2').textContent, 'Was wir zuletzt erledigt haben')
|
||||
assert.deepEqual(fx.alle(abschnitt, '[data-auftrag]').map((b) => b.getAttribute('data-auftrag')), ['100231', '100207'])
|
||||
assert.equal(fx.finde(abschnitt, '.kn-az-info').textContent, 'Wir brauchen eine Info von dir · Im Chat antworten')
|
||||
assert.equal(fx.finde(abschnitt, '.kn-az-info a').getAttribute('href'), '#chat')
|
||||
assert.match(fx.finde(abschnitt, '[data-auftrag="100207"]').textContent, /04\.10\.Neue Öffnungszeiten eingetragen\./)
|
||||
}))
|
||||
test('Website: ohne Aufträge steht der ruhige Satz', mit(HAUPT, async (fx) => {
|
||||
assert.match(fx.text(fx.finde(await zeige(fx), '[data-abschnitt="aktivitaet"]')), /Es wurden noch keine Arbeiten dokumentiert\./)
|
||||
}))
|
||||
test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) }))
|
||||
test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => {
|
||||
const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, baueAktivitaet, ladeProjektDetails, baueProjektDetails,
|
||||
AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, ladeProjektDetails, baueProjektDetails,
|
||||
} from '../public/neu/website-details.js'
|
||||
import { mit, mitDom } from './support/knDaten.js'
|
||||
|
||||
@@ -45,15 +45,7 @@ test('Auftrag: Nummer, Betreff, Status, Fortschritt, Hinweis bei Rückfrage', ()
|
||||
assert.match(schlicht.textContent, /Dein Auftrag/); assert.equal(fx.alle(schlicht, 'li').length, 0); assert.doesNotMatch(schlicht.textContent, /Wir brauchen/)
|
||||
}))
|
||||
|
||||
test('Aktivität: neueste zuerst, drei sichtbar, „Alle anzeigen (n)“ klappt auf; fremde Inhalte bleiben Text', () => mitDom((fx) => {
|
||||
const t = (n, createdAt, topic) => ({ number: String(n), topic, createdAt, status: { label: 'Offen', tone: 'active', step: 2 }, entries: [] })
|
||||
const el = baueAktivitaet([t(1, '2026-10-01T00:00:00Z', 'Eins'), t(4, '2026-10-04T00:00:00Z', '<img src=x onerror=alert(1)>'), t(2, '2026-10-02T00:00:00Z', 'Zwei'), t(3, '2026-10-03T00:00:00Z', 'Drei')], new Date('2026-10-16T09:00:00Z'))
|
||||
assert.equal(fx.alle(el, 'article').length, 3); assert.match(el.textContent, /Auftrag 4/); assert.doesNotMatch(el.textContent, /Auftrag 1/)
|
||||
assert.equal(fx.finde(el, 'img'), null)
|
||||
const alle = fx.finde(el, (e) => e.textContent === 'Alle anzeigen (4)'); assert.ok(alle)
|
||||
fx.klick(alle); assert.equal(fx.alle(el, 'article').length, 4); assert.ok(fx.finde(el, (e) => e.tagName === 'BUTTON' && e.textContent === 'Weniger anzeigen'))
|
||||
assert.equal(baueAktivitaet([], new Date()).textContent, 'Es wurden noch keine Arbeiten dokumentiert.')
|
||||
}))
|
||||
// Die Aktivität der Website-Seite (Zeitleiste) prüfen knAuftragsZeitleiste.test.js und knAuftragsZeitleisteDom.test.js.
|
||||
|
||||
test('Arbeiten: Unterstützung, Änderungsliste und Text; Zeitangaben', () => mitDom((fx) => {
|
||||
const el = baueArbeiten([{ date: '2026-09-20', kind: 'update', label: 'Aktualisierung', person: 'Kenso', minutes: 90, changes: ['Menü angepasst'], supports: [{ name: 'Justin', task: 'Bilder', minutes: 15 }] },
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.): reine Logik (Fortschritt, Reihenfolge, Gruppierung, Kürzen).
|
||||
// Antwortform von GET /api/activity laut server/routes/customerData.js und services/customerProjectView.js. Der DOM-Bau steht in knAuftragsZeitleisteDom.test.js.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
MAX_AUFTRAEGE, MAX_ZEILEN, STUFEN, fortschritt, braucheInfo, letzteAktivitaet, sortiereAuftraege, gruppiereAuftraege, kuerze,
|
||||
eintragsKurztext, datumKurz, zeitleistenZeilen,
|
||||
} from '../public/neu/auftrags-zeitleiste.js'
|
||||
import { AUFTRAG_SCHRITTE } from '../public/neu/website-details.js'
|
||||
|
||||
const JETZT = new Date('2026-10-16T09:00:00Z')
|
||||
const ST = {
|
||||
neu: { label: 'Eingegangen', tone: 'new', step: 1 },
|
||||
arbeit: { label: 'In Arbeit', tone: 'active', step: 2 },
|
||||
wartet: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 },
|
||||
pruefung: { label: 'Wird geprüft', tone: 'review', step: 3 },
|
||||
fertig: { label: 'Erledigt', tone: 'done', step: 4 },
|
||||
pause: { label: 'Pausiert', tone: 'paused', step: 0 },
|
||||
storniert: { label: 'Storniert', tone: 'stopped', step: 0 },
|
||||
}
|
||||
const auftrag = (nr, st, createdAt, entries = [], topic = `Thema ${nr}`) => ({ number: String(nr), topic, status: ST[st], createdAt, entries })
|
||||
const eintrag = (date, text, over = {}) => ({ kind: 'online', label: 'Online erledigt', date, minutes: 15, person: 'Lena Vogel', text, ...over })
|
||||
const nummern = (liste) => liste.map((a) => a.number)
|
||||
|
||||
/* ------------------------------------------------------------------ reine Logik --- */
|
||||
|
||||
test('Stufen: dieselben vier Namen wie der Fortschritt der Projektdetails', () => {
|
||||
assert.deepEqual(STUFEN, ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt'])
|
||||
assert.deepEqual(STUFEN, AUFTRAG_SCHRITTE)
|
||||
})
|
||||
|
||||
test('Fortschritt: erreichte Abschnitte bis zum aktuellen, Stufe 0 ist leer', () => {
|
||||
const stand = (step) => fortschritt({ step }).map((s) => [s.erreicht, s.aktuell])
|
||||
assert.deepEqual(stand(1), [[true, true], [false, false], [false, false], [false, false]])
|
||||
assert.deepEqual(stand(2), [[true, false], [true, true], [false, false], [false, false]])
|
||||
assert.deepEqual(stand(3), [[true, false], [true, false], [true, true], [false, false]])
|
||||
assert.deepEqual(stand(4), [[true, false], [true, false], [true, false], [true, true]])
|
||||
assert.deepEqual(fortschritt({ step: 4 }).map((s) => s.name), STUFEN)
|
||||
for (const leer of [0, undefined, null, 'x', -1, 9]) assert.deepEqual(fortschritt({ step: leer }).map((s) => s.erreicht), [false, false, false, false], `step ${String(leer)}`)
|
||||
assert.deepEqual(fortschritt(null).map((s) => s.erreicht), [false, false, false, false])
|
||||
})
|
||||
|
||||
test('Info vom Kunden nötig: genau der Ton „waiting“ (Status „awaiting“ des Servers)', () => {
|
||||
assert.equal(braucheInfo(auftrag(1, 'wartet', '2026-10-01T00:00:00Z')), true)
|
||||
for (const st of ['neu', 'arbeit', 'pruefung', 'fertig', 'pause', 'storniert']) assert.equal(braucheInfo(auftrag(1, st, '2026-10-01T00:00:00Z')), false, st)
|
||||
assert.equal(braucheInfo({}), false); assert.equal(braucheInfo(null), false)
|
||||
})
|
||||
|
||||
test('Letzte Aktivität: der jüngste Eintrag oder die Anlage des Auftrags, je nachdem, was später ist', () => {
|
||||
const a = auftrag(1, 'arbeit', '2026-10-01T10:00:00Z', [eintrag('2026-10-05', 'x'), eintrag('2026-10-09', 'y'), eintrag('', 'ohne Datum')])
|
||||
assert.equal(letzteAktivitaet(a), Date.parse('2026-10-09T00:00:00Z'))
|
||||
assert.equal(letzteAktivitaet(auftrag(2, 'arbeit', '2026-10-12T10:00:00Z', [eintrag('2026-10-05', 'x')])), Date.parse('2026-10-12T10:00:00Z'))
|
||||
assert.equal(letzteAktivitaet(auftrag(3, 'arbeit', '', [])), 0)
|
||||
assert.equal(letzteAktivitaet({ entries: 'kaputt', createdAt: 'nie' }), 0)
|
||||
})
|
||||
|
||||
test('Reihenfolge: laufende zuerst (letzte Aktivität), dann erledigte (neueste zuerst), Storniertes ganz hinten', () => {
|
||||
const liste = [
|
||||
auftrag(1, 'fertig', '2026-09-01T00:00:00Z', [eintrag('2026-10-14', 'fertig, aber jüngster Eintrag')]),
|
||||
auftrag(2, 'arbeit', '2026-10-01T00:00:00Z', [eintrag('2026-10-03', 'alt')]),
|
||||
auftrag(3, 'storniert', '2026-10-15T00:00:00Z'),
|
||||
auftrag(4, 'wartet', '2026-10-02T00:00:00Z', [eintrag('2026-10-13', 'neuer')]),
|
||||
auftrag(5, 'fertig', '2026-09-20T00:00:00Z', [eintrag('2026-09-25', 'älter')]),
|
||||
auftrag(6, 'pause', '2026-10-04T00:00:00Z'),
|
||||
auftrag(7, 'pruefung', '2026-10-05T00:00:00Z'),
|
||||
auftrag(8, 'neu', '2026-10-12T00:00:00Z'),
|
||||
]
|
||||
assert.deepEqual(nummern(sortiereAuftraege(liste)), ['4', '8', '7', '6', '2', '1', '5', '3'])
|
||||
assert.deepEqual(nummern(liste), ['1', '2', '3', '4', '5', '6', '7', '8'], 'die Eingabe bleibt unverändert')
|
||||
})
|
||||
|
||||
test('Reihenfolge: bei gleicher Aktivität bleibt die Reihenfolge des Servers', () => {
|
||||
const liste = ['9', '8', '7'].map((n) => auftrag(n, 'arbeit', '2026-10-10T00:00:00Z'))
|
||||
assert.deepEqual(nummern(sortiereAuftraege(liste)), ['9', '8', '7'])
|
||||
})
|
||||
|
||||
test('Gruppierung: höchstens fünf Aufträge sichtbar, der Rest ist „älter“', () => {
|
||||
assert.equal(MAX_AUFTRAEGE, 5)
|
||||
const liste = Array.from({ length: 7 }, (_, i) => auftrag(100 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`))
|
||||
const g = gruppiereAuftraege(liste)
|
||||
assert.deepEqual(nummern(g.sichtbar), ['106', '105', '104', '103', '102']); assert.deepEqual(nummern(g.aeltere), ['101', '100'])
|
||||
const fuenf = gruppiereAuftraege(liste.slice(0, 5)); assert.equal(fuenf.sichtbar.length, 5); assert.deepEqual(fuenf.aeltere, [])
|
||||
assert.deepEqual(gruppiereAuftraege([]), { sichtbar: [], aeltere: [] }); assert.deepEqual(gruppiereAuftraege(undefined), { sichtbar: [], aeltere: [] })
|
||||
assert.equal(gruppiereAuftraege(liste, 2).aeltere.length, 5)
|
||||
})
|
||||
|
||||
test('Gruppierung: ein erledigter Auftrag rutscht hinter laufende, auch wenn er neuer ist', () => {
|
||||
const liste = [auftrag(1, 'fertig', '2026-10-15T00:00:00Z'), ...Array.from({ length: 5 }, (_, i) => auftrag(10 + i, 'arbeit', `2026-09-0${i + 1}T00:00:00Z`))]
|
||||
const g = gruppiereAuftraege(liste)
|
||||
assert.deepEqual(nummern(g.aeltere), ['1'])
|
||||
})
|
||||
|
||||
test('Kürzen: die ersten n sichtbar, der Rest getrennt; Eingabe bleibt', () => {
|
||||
assert.equal(MAX_ZEILEN, 3)
|
||||
const a = [1, 2, 3, 4, 5]
|
||||
assert.deepEqual(kuerze(a, 3), { sichtbar: [1, 2, 3], rest: [4, 5] })
|
||||
assert.deepEqual(kuerze([1, 2], 3), { sichtbar: [1, 2], rest: [] })
|
||||
assert.deepEqual(kuerze(undefined, 3), { sichtbar: [], rest: [] })
|
||||
assert.deepEqual(a, [1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test('Datum: TT.MM., mit Jahr nur wenn es nicht das laufende ist', () => {
|
||||
assert.equal(datumKurz('2026-10-02', JETZT), '02.10.')
|
||||
assert.equal(datumKurz('2025-12-24', JETZT), '24.12.25')
|
||||
assert.equal(datumKurz('2026-10-02T08:00:00.000Z', JETZT), '02.10.')
|
||||
for (const kaputt of ['', null, undefined, 'gestern', '2026-13']) assert.equal(datumKurz(kaputt, JETZT), '', String(kaputt))
|
||||
})
|
||||
|
||||
test('Kurztext: Änderungen, freier Text oder Art der Arbeit; nie lang', () => {
|
||||
assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst'] }), 'Menü angepasst')
|
||||
assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst', 'Bilder getauscht'] }), 'Menü angepasst und 1 weitere Änderung')
|
||||
assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['A', 'B', 'C', 'D'] }), 'A und 3 weitere Änderungen')
|
||||
assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Neue Öffnungszeiten eingetragen.' }), 'Neue Öffnungszeiten eingetragen.')
|
||||
assert.equal(eintragsKurztext({ kind: 'onsite', label: 'Vor Ort bei dir', text: '' }), 'Vor Ort bei dir')
|
||||
assert.equal(eintragsKurztext({ kind: 'work', label: '', text: '' }), 'Arbeit am Projekt')
|
||||
assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Erste Zeile\n\nZweite Zeile mit Lücken' }), 'Erste Zeile')
|
||||
assert.equal(eintragsKurztext({ kind: 'online', label: 'x', text: ' viel Leerraum ' }), 'viel Leerraum')
|
||||
const lang = eintragsKurztext({ kind: 'online', label: 'x', text: 'Wort '.repeat(60) })
|
||||
assert.ok(lang.length <= 91 && lang.endsWith('…') && !lang.includes(' '), lang)
|
||||
assert.equal(eintragsKurztext(null), 'Arbeit am Projekt')
|
||||
})
|
||||
|
||||
test('Zeitleiste: neueste zuerst, Datum TT.MM., ohne Datum ganz unten, keine Namen und Minuten', () => {
|
||||
const a = auftrag(1, 'arbeit', '2026-10-01T00:00:00Z', [
|
||||
eintrag('2026-10-03', 'Alt'), eintrag('2026-10-12', 'Neu', { person: 'Kenso', minutes: 90 }), eintrag('', 'Ohne Datum'), eintrag('2026-10-07', 'Mitte'),
|
||||
])
|
||||
const zeilen = zeitleistenZeilen(a, JETZT)
|
||||
assert.deepEqual(zeilen.map((z) => [z.datum, z.text]), [['12.10.', 'Neu'], ['07.10.', 'Mitte'], ['03.10.', 'Alt'], ['', 'Ohne Datum']])
|
||||
assert.deepEqual(Object.keys(zeilen[0]).sort(), ['datum', 'iso', 'text'])
|
||||
assert.equal(zeilen[0].iso, '2026-10-12')
|
||||
assert.deepEqual(zeitleistenZeilen({ entries: null }, JETZT), []); assert.deepEqual(zeitleistenZeilen(null, JETZT), [])
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): der DOM-Bau mit dem Fake-DOM. Die reine Logik prüft knAuftragsZeitleiste.test.js.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { baueAktivitaet } from '../public/neu/auftrags-zeitleiste.js'
|
||||
import { mitDom } from './support/knDaten.js'
|
||||
import { customerOrder, customerWorkEntries } from '../server/services/customerProjectView.js'
|
||||
|
||||
const JETZT = new Date('2026-10-16T09:00:00Z')
|
||||
const ST = {
|
||||
neu: { label: 'Eingegangen', tone: 'new', step: 1 },
|
||||
arbeit: { label: 'In Arbeit', tone: 'active', step: 2 },
|
||||
wartet: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 },
|
||||
pruefung: { label: 'Wird geprüft', tone: 'review', step: 3 },
|
||||
fertig: { label: 'Erledigt', tone: 'done', step: 4 },
|
||||
pause: { label: 'Pausiert', tone: 'paused', step: 0 },
|
||||
storniert: { label: 'Storniert', tone: 'stopped', step: 0 },
|
||||
}
|
||||
const auftrag = (nr, st, createdAt, entries = [], topic = `Thema ${nr}`) => ({ number: String(nr), topic, status: ST[st], createdAt, entries })
|
||||
const eintrag = (date, text, over = {}) => ({ kind: 'online', label: 'Online erledigt', date, minutes: 15, person: 'Lena Vogel', text, ...over })
|
||||
|
||||
/* ---------------------------------------------------------------------- DOM --- */
|
||||
|
||||
const dreiAuftraege = () => [
|
||||
auftrag(100207, 'fertig', '2026-10-02T10:00:00Z', [eintrag('2026-10-04', 'Neue Öffnungszeiten eingetragen.')], 'Öffnungszeiten aktualisieren'),
|
||||
auftrag(100231, 'wartet', '2026-10-13T10:00:00Z', [eintrag('2026-10-15', 'Fotos erhalten.'), eintrag('2026-10-13', 'Bildauswahl vorbereitet.')], 'Neue Fotos für die Startseite'),
|
||||
auftrag(100224, 'pruefung', '2026-10-06T10:00:00Z', [
|
||||
eintrag('2026-10-14', 'Speisekarte gesetzt.'), eintrag('2026-10-13', 'Layout geprüft.'), eintrag('2026-10-12', 'Entwurf erstellt.'), eintrag('2026-10-09', 'Texte gesammelt.'),
|
||||
], 'Speisekarte als PDF'),
|
||||
]
|
||||
|
||||
test('Aktivität: ein Block je Auftrag, Titel als Überschrift, Pille in Farbe des Status, Reihenfolge laufend vor erledigt', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet(dreiAuftraege(), JETZT)
|
||||
const bloecke = fx.alle(el, '[data-auftrag]')
|
||||
assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['100231', '100224', '100207'])
|
||||
const kopf = (b) => ({ titel: fx.finde(b, 'h3').textContent, pille: fx.finde(b, '.kn-pille').textContent, klasse: fx.finde(b, '.kn-pille').className })
|
||||
assert.deepEqual(kopf(bloecke[0]), { titel: 'Neue Fotos für die Startseite', pille: 'Wartet auf deine Rückmeldung', klasse: 'kn-pille kn-pille-orange' })
|
||||
assert.deepEqual(kopf(bloecke[1]), { titel: 'Speisekarte als PDF', pille: 'Wird geprüft', klasse: 'kn-pille kn-pille-violett' })
|
||||
assert.deepEqual(kopf(bloecke[2]), { titel: 'Öffnungszeiten aktualisieren', pille: 'Erledigt', klasse: 'kn-pille kn-pille-gruen' })
|
||||
assert.equal(fx.finde(bloecke[0], 'h3').className, 'kn-az-titel')
|
||||
}))
|
||||
|
||||
test('Aktivität: In Arbeit ist blau, Eingegangen und Pausiert grau, Storniert rot', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z'), auftrag(2, 'neu', '2026-10-04T00:00:00Z'), auftrag(3, 'pause', '2026-10-03T00:00:00Z'), auftrag(4, 'storniert', '2026-10-02T00:00:00Z')], JETZT)
|
||||
assert.deepEqual(fx.alle(el, '.kn-pille').map((p) => p.className.split(' ')[1]), ['kn-pille-blau', 'kn-pille-grau', 'kn-pille-grau', 'kn-pille-rot'])
|
||||
}))
|
||||
|
||||
test('Aktivität: schmaler Balken mit vier Abschnitten, Stufennamen als Text für Screenreader', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet(dreiAuftraege(), JETZT)
|
||||
const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]')
|
||||
const stand = (b) => fx.alle(fx.finde(b, '.kn-az-balken'), 'li').map((li) => [li.classList.contains('is-done'), li.getAttribute('aria-current')])
|
||||
assert.deepEqual(stand(wartet), [[true, null], [true, 'step'], [false, null], [false, null]])
|
||||
assert.deepEqual(stand(pruefung), [[true, null], [true, null], [true, 'step'], [false, null]])
|
||||
assert.deepEqual(stand(fertig), [[true, null], [true, null], [true, null], [true, 'step']])
|
||||
const balken = fx.finde(wartet, '.kn-az-balken'); assert.equal(balken.tagName, 'OL'); assert.equal(balken.getAttribute('aria-label'), 'Fortschritt des Auftrags')
|
||||
assert.deepEqual(fx.alle(balken, '.kn-nur-lesen').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, aktueller Stand', 'Prüfung, noch offen', 'Erledigt, noch offen'])
|
||||
assert.deepEqual(fx.alle(fertig, '.kn-nur-lesen').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, erreicht', 'Prüfung, erreicht', 'Erledigt, aktueller Stand'])
|
||||
}))
|
||||
|
||||
test('Aktivität: Stufe 0 (pausiert, storniert) zeigt keinen Balken, nur die Pille', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet([auftrag(1, 'pause', '2026-10-05T00:00:00Z'), auftrag(2, 'storniert', '2026-10-04T00:00:00Z')], JETZT)
|
||||
assert.equal(fx.alle(el, '[data-auftrag]').length, 2); assert.equal(fx.finde(el, '.kn-az-balken'), null)
|
||||
}))
|
||||
|
||||
test('Aktivität: Rückfrage steht unter der Überschrift mit Link in den Chat, sonst keine Zeile', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet(dreiAuftraege(), JETZT)
|
||||
const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]')
|
||||
const info = fx.finde(wartet, '.kn-az-info')
|
||||
assert.equal(info.textContent, 'Wir brauchen eine Info von dir · Im Chat antworten')
|
||||
// Die ganze Zeile ist der Link in den Chat (eine große Tippfläche am Handy); nur „Im Chat antworten“ ist unterstrichen
|
||||
const chat = fx.finde(info, 'a'); assert.deepEqual([chat.textContent, chat.getAttribute('href'), chat.getAttribute('data-view')], ['Wir brauchen eine Info von dir · Im Chat antworten', '#chat', 'chat'])
|
||||
assert.equal(fx.finde(chat, '.kn-az-info-link').textContent, 'Im Chat antworten'); assert.equal(fx.alle(info, 'a').length, 1)
|
||||
assert.equal(fx.alle(el, '.kn-az-info').length, 1); assert.equal(fx.finde(pruefung, '.kn-az-info'), null); assert.equal(fx.finde(fertig, '.kn-az-info'), null)
|
||||
// Reihenfolge im Block: Kopf, Info, Balken, Zeitleiste
|
||||
const klassen = [...wartet.children].map((k) => k.className.split(' ')[0]); assert.deepEqual(klassen, ['kn-az-kopf', 'kn-az-info', 'kn-az-balken', 'kn-az-zeitleiste'])
|
||||
}))
|
||||
|
||||
test('Aktivität: Auftragsnummer erscheint nicht als Text (ruhiger), bleibt aber als Datenattribut', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet(dreiAuftraege(), JETZT)
|
||||
assert.doesNotMatch(fx.text(el), /1002\d\d|Auftrag \d/)
|
||||
}))
|
||||
|
||||
test('Aktivität: Zeitleiste mit Datum und Kurztext, drei sichtbar, „mehr anzeigen“ klappt den Rest auf', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet(dreiAuftraege(), JETZT)
|
||||
const pruefung = fx.alle(el, '[data-auftrag]')[1]
|
||||
const zeilen = fx.alle(pruefung, '.kn-az-zeitleiste li')
|
||||
assert.deepEqual(zeilen.map((z) => fx.finde(z, 'time').textContent), ['14.10.', '13.10.', '12.10.', '09.10.'])
|
||||
assert.deepEqual(zeilen.map((z) => fx.finde(z, '.kn-az-text').textContent), ['Speisekarte gesetzt.', 'Layout geprüft.', 'Entwurf erstellt.', 'Texte gesammelt.'])
|
||||
assert.equal(fx.finde(zeilen[0], 'time').getAttribute('datetime'), '2026-10-14')
|
||||
assert.deepEqual(zeilen.map((z) => z.hasAttribute('data-extra')), [false, false, false, true])
|
||||
const wrapper = fx.finde(pruefung, '[data-einklappbar]'); assert.ok(wrapper); assert.ok(!wrapper.classList.contains('is-open'))
|
||||
const mehr = fx.finde(pruefung, '[data-mehr]'); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false')
|
||||
fx.klick(mehr)
|
||||
assert.ok(wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'weniger anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'true')
|
||||
fx.klick(mehr)
|
||||
assert.ok(!wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false')
|
||||
// genau drei Zeilen: kein Knopf; weniger: kein Knopf
|
||||
assert.equal(fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]'), null)
|
||||
assert.equal(fx.alle(el, '[data-mehr]').length, 1)
|
||||
}))
|
||||
|
||||
test('Aktivität: Auftrag ohne Einträge zeigt einen ruhigen Satz statt einer leeren Zeitleiste', () => mitDom((fx) => {
|
||||
const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [])], JETZT)
|
||||
assert.equal(fx.finde(el, '.kn-az-zeitleiste'), null); assert.match(fx.text(el), /Noch keine Arbeiten dokumentiert\./)
|
||||
}))
|
||||
|
||||
test('Aktivität: fünf Aufträge sichtbar, „Ältere Aufträge anzeigen“ klappt den Rest auf', () => mitDom((fx) => {
|
||||
const liste = Array.from({ length: 8 }, (_, i) => auftrag(200 + i, i === 7 ? 'fertig' : 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, [eintrag(`2026-10-0${i + 1}`, `Eintrag ${i}`)]))
|
||||
const el = baueAktivitaet(liste, JETZT)
|
||||
const bloecke = fx.alle(el, '[data-auftrag]')
|
||||
assert.equal(bloecke.length, 8)
|
||||
assert.deepEqual(bloecke.map((b) => b.hasAttribute('data-extra')), [false, false, false, false, false, true, true, true])
|
||||
assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['206', '205', '204', '203', '202', '201', '200', '207'])
|
||||
const alt = fx.finde(el, '[data-aeltere]'); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'false')
|
||||
const huelle = alt.closest('[data-einklappbar]'); assert.ok(!huelle.classList.contains('is-open'))
|
||||
fx.klick(alt); assert.ok(huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Weniger anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'true')
|
||||
fx.klick(alt); assert.ok(!huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen')
|
||||
assert.equal(fx.finde(baueAktivitaet(liste.slice(0, 5), JETZT), '[data-aeltere]'), null)
|
||||
}))
|
||||
|
||||
test('Aktivität: Zeilen-Knopf und Aufträge-Knopf sind getrennt (mehr anzeigen öffnet nicht die älteren Aufträge)', () => mitDom((fx) => {
|
||||
const viele = Array.from({ length: 6 }, (_, i) => auftrag(300 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t))))
|
||||
const el = baueAktivitaet(viele, JETZT)
|
||||
const huelle = fx.finde(el, '[data-aeltere]').closest('[data-einklappbar]')
|
||||
const ersterMehr = fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]')
|
||||
fx.klick(ersterMehr); assert.ok(!huelle.classList.contains('is-open')); assert.ok(ersterMehr.closest('[data-einklappbar]').classList.contains('is-open'))
|
||||
}))
|
||||
|
||||
test('Aktivität: fremde Inhalte bleiben Text; leer und kaputte Elemente', () => mitDom((fx) => {
|
||||
const boese = '<img src=x onerror=alert(1)>'
|
||||
const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', boese), eintrag('2026-10-05', 'x', { changes: [boese] })], boese)], JETZT)
|
||||
assert.equal(fx.finde(el, 'img'), null); assert.ok(fx.text(el).includes(boese))
|
||||
assert.equal(baueAktivitaet([], JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.')
|
||||
assert.equal(baueAktivitaet(undefined, JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.')
|
||||
const roh = baueAktivitaet([{ number: '9' }, { topic: 'Nur Titel', status: null, entries: null }], JETZT)
|
||||
assert.equal(fx.alle(roh, '.kn-az').length, 2); assert.match(fx.text(roh), /Dein Auftrag/); assert.doesNotMatch(fx.text(roh), /null|undefined/)
|
||||
}))
|
||||
|
||||
// Vertrag mit dem Server: GET /api/activity baut jeden Auftrag aus customerOrder() und customerWorkEntries() (server/routes/customerData.js).
|
||||
// Hier läuft die echte Server-Aufbereitung in die Zeitleiste, damit eine geänderte Antwortform den Test rot macht.
|
||||
test('Vertrag: Aufträge in der Form von /api/activity (customerOrder, customerWorkEntries) erscheinen mit Datum und Kurztext', () => mitDom((fx) => {
|
||||
const ticket = (woid, status, createdAt, worksheets) => ({ woid, topic: `Thema ${woid}`, status, createdAt, worksheets })
|
||||
const antwort = [
|
||||
ticket('100231', 'awaiting', '2026-10-13T10:00:00.000Z', [
|
||||
{ serviceType: 'REMOTE', date: '14.10.2026', totalTime: 50, employeeName: 'Lena Vogel', details: 'Fotos erhalten und gesichtet.\nWeitere Zeile.', supporters: '' },
|
||||
{ serviceType: 'GIT', date: '2026-10-12', totalTime: 0, isComment: true, details: '- [abcdef1](https://git.example/x/commit/abcdef1): feat: Menü angepasst (Kenso)\n- [abcdef2](https://git.example/x/commit/abcdef2): Bilder getauscht (Kenso)' },
|
||||
]),
|
||||
ticket('100207', 'closed', '2026-10-02T10:00:00.000Z', [{ serviceType: 'ON SITE', date: '04.10.2026', totalTime: 60, employeeName: 'Kenso', details: '' }]),
|
||||
].map((t) => ({ ...customerOrder(t), createdAt: t.createdAt, entries: customerWorkEntries(t.worksheets) }))
|
||||
const el = baueAktivitaet(antwort, JETZT)
|
||||
const [wartet, fertig] = fx.alle(el, '[data-auftrag]')
|
||||
assert.deepEqual([wartet.getAttribute('data-auftrag'), fertig.getAttribute('data-auftrag')], ['100231', '100207'])
|
||||
assert.equal(fx.finde(wartet, '.kn-pille').textContent, 'Wartet auf deine Rückmeldung'); assert.ok(fx.finde(wartet, '.kn-az-info'))
|
||||
assert.deepEqual(fx.alle(wartet, '.kn-az-zeilen li').map((z) => z.textContent), ['14.10.Fotos erhalten und gesichtet.', '12.10.Menü angepasst und 1 weitere Änderung'])
|
||||
assert.equal(fx.finde(fertig, '.kn-pille').textContent, 'Erledigt'); assert.deepEqual(fx.alle(fertig, '.kn-az-zeilen li').map((z) => z.textContent), ['04.10.Vor Ort bei dir'])
|
||||
}))
|
||||
|
||||
// Regression (Browserlauf 17.10.): Die allgemeine Regel `.kn-einklappbar:not(.is-open) [data-extra]` trifft jeden Nachfahren. Läge sie an den verschachtelten Wrappern
|
||||
// (Aufträge, darin die Zeitleisten), verbärge ein zugeklappter äußerer Wrapper auch die Zeilen einer aufgeklappten Zeitleiste. Darum tragen beide Wrapper eigene Klassen
|
||||
// und eigene Regeln nur für direkte Kinder.
|
||||
test('Einklappen: die Wrapper verschachteln sich nicht über .kn-einklappbar, die Regeln gelten nur für direkte Kinder', () => mitDom((fx) => {
|
||||
const viele = Array.from({ length: 6 }, (_, i) => auftrag(400 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t))))
|
||||
const el = baueAktivitaet(viele, JETZT)
|
||||
assert.deepEqual(fx.alle(el, '[data-einklappbar]').map((w) => w.classList.contains('kn-einklappbar')), new Array(7).fill(false))
|
||||
assert.equal(el.classList.contains('kn-az-liste'), true); assert.equal(fx.finde(el, '[data-einklappbar] [data-einklappbar]').classList.contains('kn-az-zeitleiste'), true)
|
||||
const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8')
|
||||
assert.match(css, /\.kn-az-liste:not\(\.is-open\) > \.kn-az-auftraege > \[data-extra\] \{ display: none; \}/)
|
||||
assert.match(css, /\.kn-az-zeitleiste:not\(\.is-open\) > \.kn-az-zeilen > \[data-extra\] \{ display: none; \}/)
|
||||
}))
|
||||
|
||||
// Browserlauf 17.10.: Ein display:flex am Link zerlegte den Satz in zwei Spalten (Text | „Im Chat antworten“). Der Link ist ein Block; die Tippfläche kommt aus dem Innenabstand.
|
||||
test('Rückfrage-Link: am Handy ein Block (kein flex) mit Innenabstand für 44 px Tippfläche', () => {
|
||||
const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8')
|
||||
assert.match(css, /\n \.kn-az-info a \{ display: block; padding: 12px 0; \}/)
|
||||
assert.doesNotMatch(css, /\.kn-az-info a \{[^}]*display: *(inline-)?flex/)
|
||||
})
|
||||
|
||||
test('Zeitleiste: trägt ein Eintrag das Jahr (Vorjahr), richten sich alle Datumsspalten des Auftrags danach', () => mitDom((fx) => {
|
||||
const mitJahr = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu'), eintrag('2025-12-24', 'alt')])], JETZT)
|
||||
assert.deepEqual(fx.alle(mitJahr, 'time').map((t) => t.textContent), ['06.10.', '24.12.25']); assert.ok(fx.finde(mitJahr, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr'))
|
||||
const ohne = baueAktivitaet([auftrag(2, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu')])], JETZT)
|
||||
assert.ok(!fx.finde(ohne, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr'))
|
||||
}))
|
||||
Reference in new issue
Block a user