Files
ClaudeandClaude Sonnet 5.5 8bb02f3949 Kundenansicht neu E-Fix M5: Chat hält die Eingabe über der Tastatur (revealChatComposer aus app.js)
Der Chat der neuen Ansicht übernahm nur das Ausblenden der Leiste, nicht das Nachschieben der Eingabe: auf kleinen Handys (iPhone SE, Android mit
hoher Tastatur) konnte die Eingabezeile unter der Tastatur stehen bleiben, und die letzte Nachricht verschwand beim Schrumpfen des Verlaufs.
Jetzt wie in app.js: 350 ms nach dem Fokus im Composer und bei jedem visualViewport-Resize wird die Eingabe in den sichtbaren Bereich geschoben
(composerVersatz, nur wenn verdeckt, 8 px Luft, nur am Handy), und ein ResizeObserver hält die neueste Nachricht sichtbar, wenn man unten war.
Hörer und Beobachter werden beim Neuaufbau abgebaut. Tests für die Rechnung und die Verdrahtung in knAnsichtChat.test.js.

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

107 lines
9.1 KiB
JavaScript

import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { zeigeChat, CHAT_TEXTE, nachrichtMeta, composerVersatz, verdrahteEingabe } 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')
})
// M5 (E-Fix): Eingabe über der Tastatur halten, wie revealChatComposer in app.js:2240-2260
test('M5: composerVersatz schiebt nur, wenn die Eingabe verdeckt ist (Tastatur unten) oder über dem sichtbaren Bereich liegt, mit 8 px Luft', () => {
const sicht = { visibleTop: 0, visibleBottom: 500 }
assert.equal(composerVersatz({ rect: { top: 400, bottom: 460 }, ...sicht }), 0, 'sichtbar: nichts tun')
assert.equal(composerVersatz({ rect: { top: 430, bottom: 492 }, ...sicht }), 0, 'knapp innerhalb der Luft: nichts tun')
assert.equal(composerVersatz({ rect: { top: 480, bottom: 540 }, ...sicht }), 48, 'unten verdeckt: um die Überdeckung plus Luft nach unten scrollen')
assert.equal(composerVersatz({ rect: { top: -30, bottom: 30 }, ...sicht }), -38, 'oben abgeschnitten: nach oben')
assert.equal(composerVersatz({ rect: { top: 400.5, bottom: 491.5 }, ...sicht }), 0, 'unter 1 px: nichts tun')
assert.equal(composerVersatz({ rect: { top: Number.NaN, bottom: Number.NaN }, ...sicht }), 0)
})
test('M5: verdrahteEingabe prüft 350 ms nach dem Fokus und bei jedem visualViewport-Resize, nur am Handy und nur mit Fokus im Composer; abbau entfernt die Hörer', () => {
const scrolled = []; const geplant = []; const vvHoerer = new Map(); const docHoerer = new Map()
const feld = { matches: (s) => s.includes('textarea') }
const wurzel = { contains: (el) => el === feld, getBoundingClientRect: () => ({ top: 480, bottom: 540 }) }
const doc = { activeElement: feld, addEventListener: (n, f) => docHoerer.set(n, f), removeEventListener: (n) => docHoerer.delete(n) }
const vv = { offsetTop: 0, height: 500, addEventListener: (n, f) => vvHoerer.set(n, f), removeEventListener: (n) => vvHoerer.delete(n) }
let handy = true
const win = { visualViewport: vv, matchMedia: () => ({ matches: handy }), scrollBy: (x, y) => scrolled.push([x, y]), requestAnimationFrame: (fn) => fn(), setTimeout: (fn, ms) => geplant.push([ms, fn]) }
const v = verdrahteEingabe({ win, doc, wurzel })
docHoerer.get('focusin')({ target: feld }); assert.deepEqual(geplant.map((g) => g[0]), [350]); geplant[0][1](); assert.deepEqual(scrolled, [[0, 48]])
vvHoerer.get('resize')(); assert.deepEqual(scrolled.at(-1), [0, 48], 'beim Resize der Tastatur wieder')
docHoerer.get('focusin')({ target: { matches: () => true } }); assert.equal(geplant.length, 1, 'Fokus außerhalb des Composers plant nichts')
handy = false; vvHoerer.get('resize')(); assert.equal(scrolled.length, 2, 'am Desktop nie')
handy = true; doc.activeElement = null; vvHoerer.get('resize')(); assert.equal(scrolled.length, 2, 'ohne Fokus im Composer nie')
v.abbau(); assert.deepEqual([docHoerer.size, vvHoerer.size], [0, 0])
})