Kundenansicht neu P2: Ansicht Chat mit dem Composer und lilafreier CSS-Kopie

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:59:28 +00:00
1 parent a3831040ba
commit 8c8d29657d
6 files changed
+475 -9

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { zeigeChat, CHAT_TEXTE, nachrichtMeta } from '../public/neu/ansicht-chat.js'
import { setzeChatVorgabe } from '../public/neu/ansicht-hilfen.js'
import { mit, me, nachricht, neueWurzel } from './support/knDaten.js'
// Attrappe des klassischen Skripts chat-composer.js
function stub() {
const log = { create: null, destroyed: 0 }
globalThis.ChatComposer = {
create(opts) {
log.create = opts
opts.root.append(document.createElement('textarea'))
return { destroy() { log.destroyed += 1 }, focus() {} }
},
renderMessage(msg, { meta }) {
const bubble = document.createElement('div'); bubble.className = 'chat-bubble'; bubble.setAttribute('data-id', msg.id); bubble.append(`${meta} ${msg.text}`)
return bubble
},
trackScroll() {},
}
return log
}
const NACHRICHTEN = { messages: [nachricht({ id: 'm1', sender: 'admin', senderName: 'Kenso', text: 'Hallo', createdAt: '2026-10-14T08:00:00.000Z' }), nachricht({ id: 'm2', sender: 'customer', text: 'Danke', createdAt: '2026-10-14T08:05:00.000Z' })], unread: 1 }
test('Chat: Blasen, Composer-Optionen, genau eine Gelesen-Markierung', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': { updated: 1 } }, async (fx) => {
const log = stub(); const events = []; fx.document.dispatchEvent = (e) => events.push(`${e.type}:${e.detail.was}`)
const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe()
assert.deepEqual(fx.alle(w, '.chat-bubble').map((e) => e.textContent), ['Kenso · 14.10.26, 10:00 Hallo', 'Du · 14.10.26, 10:05 Danke'])
assert.deepEqual([log.create.uploadUrl, log.create.placeholder, log.create.disabled], ['/api/chat/attachments', 'Nachricht an das WEBklar-Team…', false])
assert.equal(fx.fetchLog.filter((a) => a.method === 'POST' && a.url === '/api/chat/read').length, 1); assert.ok(events.includes('kn:zaehler-neu:chat'))
}))
test('Chat im Ansichtsmodus: Composer gesperrt, kein schreibender Aufruf', mit({ 'GET /api/chat/messages': NACHRICHTEN }, async (fx) => {
const log = stub(); await zeigeChat(neueWurzel(fx), { route: { bereich: 'chat', query: '' }, me: me({ viewAs: true }), nav: 'tab' }); await fx.ruhe()
assert.equal(log.create.disabled, true); assert.equal(log.create.disabledText, 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.')
assert.deepEqual(fx.fetchLog.filter((a) => a.method !== 'GET'), [])
}))
test('Chat: senden, Poll mit since, Dopplungen, zweiter Aufruf räumt auf', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': {}, 'POST /api/chat/messages': { message: { id: 'm3' } } }, async (fx) => {
mock.timers.enable({ apis: ['setInterval'] })
try {
const log = stub(); const w = neueWurzel(fx); const args = { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }
await zeigeChat(w, args); await fx.ruhe()
assert.deepEqual(await log.create.send({ text: 'Hi', attachmentIds: ['a1'] }), { id: 'm3' })
assert.deepEqual(fx.fetchLog.at(-1).body, { text: 'Hi', attachmentIds: ['a1'] })
mock.timers.tick(10000); await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).url, '/api/chat/messages?since=2026-10-14T08%3A05%3A00.000Z'); assert.equal(fx.alle(w, '.chat-bubble').length, 2)
await zeigeChat(w, args); await fx.ruhe(); assert.equal(log.destroyed, 1)
const vorher = fx.fetchLog.length; mock.timers.tick(10000); await fx.ruhe(); assert.equal(fx.fetchLog.filter((a, i) => i >= vorher && a.url.includes('since=')).length, 1)
} finally { mock.timers.reset() }
}))
test('Chat: Vorgabe aus „Postfach anfragen“ landet im Eingabefeld, leerer Verlauf sagt es', mit({ 'GET /api/chat/messages': { messages: [], unread: 0 }, 'POST /api/chat/read': {} }, async (fx) => {
stub(); setzeChatVorgabe('Bitte lege ein neues Postfach an: '); const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe()
assert.equal(fx.finde(w, 'textarea').value, 'Bitte lege ein neues Postfach an: '); assert.match(fx.text(w), /Noch keine Nachrichten\./)
}))
test('dashboard.html lädt den Composer klassisch und die bereinigte CSS-Kopie zwischen huelle.css und ansichten.css, nie das alte /chat-composer.css', () => {
const html = readFileSync(new URL('../public/neu/dashboard.html', import.meta.url), 'utf8')
assert.match(html, /<script src="\/kundenansicht\.js"><\/script>\s*<script src="\/chat-composer\.js"><\/script>[\s\S]*<script type="module" src="\/neu\/huelle\.js">/)
const i = (s) => html.indexOf(s); assert.ok(i('href="/neu/huelle.css"') < i('href="/neu/chat-composer.css"') && i('href="/neu/chat-composer.css"') < i('href="/neu/ansichten.css"'))
assert.doesNotMatch(html, /href="\/chat-composer\.css"/)
})
const lesePublic = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
const geruest = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\{[^{}]*\}/g, '{}').replace(/\s+/g, ' ').trim() // Selektoren und Verschachtelung ohne Deklarationen
test('public/neu/chat-composer.css: kein Lila, kein Gewicht 800, kein Glas, helle Variablen', () => {
const kopie = lesePublic('neu/chat-composer.css')
assert.doesNotMatch(kopie, /#9d3dfe|rgba?\(\s*157\s*,\s*61\s*,\s*254|font-weight:\s*800|backdrop-filter/i)
const wurzel = kopie.match(/:root\s*\{([^}]*)\}/)[1]
for (const v of ['accent', 'accent-hover', 'accent-soft', 'accent-line', 'accent-text', 'on-accent', 'line', 'muted', 'surface']) assert.match(wurzel, new RegExp(`--chat-${v}:`), v)
assert.match(wurzel, /--chat-accent:\s*#111111/); assert.match(wurzel, /--chat-surface:\s*#ffffff/)
})
test('public/neu/chat-composer.css: gleiche Selektoren in gleicher Reihenfolge wie das Original (Kopie driftet nicht)', { skip: !existsSync(new URL('../public/chat-composer.css', import.meta.url)) }, () => {
assert.equal(geruest(lesePublic('neu/chat-composer.css')), geruest(lesePublic('chat-composer.css')))
})
test('Chat: Texte und Meta der Nachricht', () => {
assert.deepEqual(CHAT_TEXTE, { platzhalter: 'Nachricht an das WEBklar-Team…', kurz: 'Nachricht…', gesperrt: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.', leer: 'Noch keine Nachrichten.' })
assert.equal(nachrichtMeta(nachricht({ sender: 'customer', createdAt: '2026-10-14T08:05:00.000Z' })), 'Du · 14.10.26, 10:05')
assert.equal(nachrichtMeta(nachricht({ sender: 'admin', senderName: '', createdAt: '2026-10-14T08:05:00.000Z' })), 'Webklar-Team · 14.10.26, 10:05')
})
+1 -1
View File
@@ -10,7 +10,7 @@ test('dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge, klassisches
assert.match(html, /apple-mobile-web-app-status-bar-style" content="default"/); assert.match(html, /<link rel="manifest" href="\/neu\/manifest\.webmanifest">/)
assert.match(html, /viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"/)
const css = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map((m) => m[1]); const ORDNUNG = ['/neu/tokens.css', '/neu/basis.css', '/neu/huelle.css', '/neu/ansichten.css', '/neu/funktionen.css', '/neu/module.css']
assert.deepEqual(css.filter((f) => f !== '/chat-composer.css'), ORDNUNG); assert.ok(css.indexOf('/chat-composer.css') === -1 || css.indexOf('/chat-composer.css') > css.indexOf('/neu/huelle.css'))
assert.deepEqual(css.filter((f) => f !== '/neu/chat-composer.css'), ORDNUNG); assert.ok(css.indexOf('/neu/chat-composer.css') === -1 || css.indexOf('/neu/chat-composer.css') > css.indexOf('/neu/huelle.css'))
assert.match(html, /<body class="kn">/)
const skripte = [...html.matchAll(/<script([^>]*)>/g)].map((m) => m[1].trim())
assert.deepEqual([skripte[0], skripte.at(-1)], ['src="/kundenansicht.js"', 'type="module" src="/neu/huelle.js"']); assert.ok(skripte.slice(1, -1).every((s) => s === 'src="/chat-composer.js"'))
+8 -4
View File
@@ -37,12 +37,16 @@ function ereignisseFuerDokument(document) {
}
function mitAttrappen(fx) {
const vorher = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent')
Object.defineProperty(globalThis, 'CustomEvent', { value: EreignisAttrappe, writable: true, configurable: true, enumerable: true })
// Event und CustomEvent: die echten Klassen haben nur lesbare Felder (target), fakeDom schreibt sie
const namen = ['CustomEvent', 'Event']
const vorher = namen.map((n) => [n, Object.getOwnPropertyDescriptor(globalThis, n)])
for (const n of namen) Object.defineProperty(globalThis, n, { value: EreignisAttrappe, writable: true, configurable: true, enumerable: true })
ereignisseFuerDokument(fx.document)
return () => {
if (vorher) Object.defineProperty(globalThis, 'CustomEvent', vorher)
else delete globalThis.CustomEvent
for (const [n, d] of vorher) {
if (d) Object.defineProperty(globalThis, n, d)
else delete globalThis[n]
}
}
}