Files
Webklar-Kundenbereich/test/knWasserLeiste.test.js
T
ClaudeandClaude Opus 5.5 1f350ca432 Kundenansicht neu: Wasser-Leiste breiter mit Wörtern, Unterseiten im Vollbild
Wunsch vom 29.10. nach dem ersten Blick am iPhone:
- Die Leiste nutzt die Breite (12 px Rand, höchstens 480 px), Symbol über
  dem Wort, und steht ganz über dem Wisch-Balken des iPhones
  (Safe-Area plus 6 px statt minus 10 px).
- Unterseiten öffnen sich ganz und werden mit Unschärfe eingeblendet statt
  hochzufahren. Oben links die Überschrift, oben rechts „Zurück“ als
  Glasknopf, der beim Scrollen stehen bleibt (neuer Platz
  #kn-blatt-zurueck).
- Reiter einer Funktion stehen unten in derselben Wasser-Leiste (Art
  'reiter', Kurzwörter, kleinere Schrift wenn es eng wird), ohne Zurück.
  Ohne Reiter bleibt unten nichts. Die alte Leiste gibt beim Neuaufbau
  ihren WebGL-Kontext frei.

Glas und Unschärfe stehen weiter allein in wasser-leiste.css; huelle.css
bleibt ohne Glas. Desktop unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:09:42 +00:00

133 lines
10 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { PAD, WACH_MS, SZENEN, SYMBOLE, neuerZustand, setzeZiel, schritt, istBeschaeftigt, shaderDaten, tropfenMasse, baueWasserLeiste } from '../public/neu/wasser-leiste.js'
import { WOERTER } from '../public/neu/leiste.js'
import { lies, handyTeil, HANDY } from './support/knHuelle.js'
// Pille 194 px breit (vier Knöpfe zu 44 px, 2 px Abstand, 6 px Rand), Knöpfe 42 px hoch
const knopf = (i) => ({ links: 6 + i * 46, oben: 6, breite: 44, hoehe: 42 })
const zufall = () => 0.5
const laufe = (s, sekunden, start = 0) => { let t = start; for (let n = 0; n < sekunden * 60; n++) { t += 1000 / 60; schritt(s, 1 / 60, t, zufall) } return t }
const bereit = (i = 0) => { const s = neuerZustand(); s.w = 194; s.h = 54; s.leer = false; setzeZiel(s, knopf(i), 0); return s }
test('setzeZiel: die erste Messung setzt den Tropfen sofort unter den Knopf, eine spätere startet die Fahrt', () => {
const s = bereit(1)
assert.deepEqual([s.a.x, s.b.x, s.t.x, s.t.cy, s.t.r, s.t.q, s.moving], [PAD + 52 + 22, PAD + 74, PAD + 74, PAD + 27, 21, 1, false])
setzeZiel(s, knopf(1), 10); assert.equal(s.moving, false) // gleiche Stelle: nichts zu tun
setzeZiel(s, knopf(3), 20)
assert.deepEqual([s.t.x, s.moving, s.lagFrom, s.a.x], [PAD + 144 + 22, true, 90, PAD + 74])
})
test('schritt: der Tropfen fließt ans Ziel, kommt zur Ruhe und schlägt dort eine Welle', () => {
const s = bereit(0); setzeZiel(s, knopf(1), 0); laufe(s, 2)
assert.ok(Math.abs(s.a.x - s.t.x) < 0.5 && Math.abs(s.b.x - s.t.x) < 0.5, `${s.a.x} ${s.b.x} ${s.t.x}`)
assert.deepEqual([s.moving, s.split, Math.abs(s.sy.s - 1) < 0.01], [false, false, true])
assert.equal(s.rips.length, 1); assert.equal(s.rips[0].amp, 0.7); assert.equal(s.rips[0].x, s.t.x)
laufe(s, 3); assert.equal(s.rips.length, 0) // Wellen verebben nach 2,4 s
})
test('schritt: ein langer Sprung reißt den Tropfen in Tröpfchen, sie fließen am Ziel wieder zusammen (kräftigere Welle)', () => {
const s = bereit(0); setzeZiel(s, knopf(3), 0)
let t = 0; let maxTroepfchen = 0; let starkeWelle = false
for (let n = 0; n < 240; n++) { t += 1000 / 60; schritt(s, 1 / 60, t, zufall); maxTroepfchen = Math.max(maxTroepfchen, s.sats.length); starkeWelle ||= s.rips.some((r) => r.amp === 1.2) }
assert.equal(maxTroepfchen, 3); assert.equal(s.split, true)
assert.deepEqual([s.sats.length, s.moving, starkeWelle], [0, false, true])
for (const k of [s.a, s.b]) assert.ok(k.x >= PAD + 6 + s.q + s.t.r - 3 - 1e-9 && k.x <= PAD + s.w - 6 - s.q - s.t.r + 3 + 1e-9) // nie durch die Wand
})
test('schritt mit „Bewegung reduzieren“: springt sofort, ohne Tröpfchen, Welle und Eigenbewegung', () => {
const s = bereit(0); s.reduce = true; s.wachBis = 1e9; setzeZiel(s, knopf(3), 0)
schritt(s, 1 / 60, 16, zufall)
assert.deepEqual([s.a.x, s.b.x, s.sats.length, s.rips.length, s.moving, s.amb], [s.t.x, s.t.x, 0, 0, false, 0])
assert.equal(istBeschaeftigt(s, 16), false)
})
test('Eigenbewegung: blendet nach dem Wecken ein und nach WACH_MS weich aus, dann ruht die Schleife', () => {
const s = bereit(0); s.wachBis = WACH_MS
assert.equal(istBeschaeftigt(s, 0), true)
let t = laufe(s, 2); assert.ok(s.amb > 0.9, String(s.amb))
t = laufe(s, (WACH_MS - t) / 1000 + 6, t); assert.equal(s.amb, 0)
assert.equal(istBeschaeftigt(s, t), false)
})
test('shaderDaten: ohne aktiven Bereich kein Tropfen; mit Bereich führt der Kopf, Tröpfchen ab Platz 3, höchstens 6 Wellen', () => {
const s = bereit(1); s.leer = true
assert.deepEqual([...shaderDaten(s).blobs].slice(0, 8), [0, 0, 0, 0, 0, 0, 0, 0])
s.leer = false; s.lead = 1; s.b.x = 150; s.sats = [{ x: 90, y: 40, r: 6 }]; s.rips = Array.from({ length: 8 }, (_, i) => ({ x: i, y: 0, age: 0.1, amp: 1 }))
const d = shaderDaten(s)
assert.equal(d.blobs[0], 150); assert.equal(d.blobs[4], s.a.x); assert.deepEqual([...d.blobs.slice(8, 12)], [90, 40, 0, 6])
assert.deepEqual([d.rips[0], d.rips[20]], [2, 7]); assert.deepEqual([d.sy[0], d.sy[2]], [1, 1])
})
test('tropfenMasse: in Ruhe genau so breit wie der aktive Knopf und an seiner Stelle (CSS-Tropfen ohne WebGL)', () => {
const s = bereit(2)
const m = tropfenMasse(s)
assert.ok(Math.abs(m.links - 98) < 1e-9 && Math.abs(m.breite - 44) < 1e-9 && m.skalaY === 1, JSON.stringify(m))
})
test('Bauen: vier Reiter mit Wort, Symbol-Platz und Szene, Auswahl, Zähler am Symbol, kein WebGL ohne Browser', async () => {
const fx = installFakeDom()
try {
const gew = []
const w = baueWasserLeiste({ eintraege: WOERTER, aktiv: '', bei: (k) => gew.push(k) })
const tabs = fx.alle(w, '[role=tab]')
assert.deepEqual([w.getAttribute('role'), w.getAttribute('aria-label'), w.getAttribute('data-gl'), w.hasAttribute('data-leer')], ['tablist', 'Bereiche', 'off', true])
assert.deepEqual(tabs.map((t) => [t.textContent, t.getAttribute('aria-label'), t.getAttribute('tabindex')]), [['Website', 'Website', '0'], ['Funktionen', 'Funktionen', '-1'], ['Chat', 'Chat', '-1'], ['Verträge', 'Verträge', '-1']])
assert.equal(fx.alle(w, '.kn-wl-ico').length, 4)
w.setzeAktiv('chat')
assert.deepEqual(tabs.map((t) => [t.getAttribute('aria-selected'), t.getAttribute('aria-current')]), [['false', null], ['false', null], ['true', 'page'], ['false', null]])
assert.equal(w.hasAttribute('data-leer'), false)
await fx.klick(tabs[3]); assert.deepEqual(gew, ['vertraege']); assert.equal(fx.finde(tabs[3], '.kn-wl-ico').getAttribute('data-fx'), 'drop')
await fx.klick(tabs[3]); assert.deepEqual(gew, ['vertraege', 'vertraege']) // erneut auf den aktiven Bereich: die Hülle scrollt nach oben
await fx.klick(tabs[0]); assert.deepEqual([fx.finde(tabs[0], '.kn-wl-ico').getAttribute('data-fx'), fx.finde(tabs[3], '.kn-wl-ico').hasAttribute('data-fx')], ['spin', false])
await fx.sende(tabs[0], 'keydown', { key: 'ArrowLeft' }); assert.equal(gew.at(-1), 'vertraege') // ringsum
w.setzeAnzeige('chat', { zahl: 3 }); w.setzeAnzeige('vertraege', { zahl: 2, ausruf: true })
assert.deepEqual([fx.finde(fx.finde(tabs[2], '.kn-wl-ico'), '.kn-zahl').textContent, tabs[2].getAttribute('aria-label'), tabs[3].getAttribute('aria-label')], ['3', 'Chat, 3 ungelesen', 'Verträge, Zahlung fehlgeschlagen'])
w.setzeAnzeige('chat', { zahl: 0 }); assert.equal(tabs[2].getAttribute('aria-label'), 'Chat'); assert.ok(fx.finde(tabs[2], '.kn-zahl').classList.contains('kn-zahl--weg'))
w.setzeAktiv('gibtesnicht'); assert.deepEqual([fx.alle(w, '[aria-selected=true]').length, w.hasAttribute('data-leer'), tabs[0].getAttribute('tabindex')], [0, true, '0'])
w.beende()
} finally { fx.restore() }
})
test('Art reiter: nur Wörter (Kurzwort sichtbar, volles Wort vorgelesen), keine Szene, kein Zähler; beende räumt auf', async () => {
const fx = installFakeDom()
try {
const gew = []
const w = baueWasserLeiste({ eintraege: [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }], aktiv: 'termine', art: 'reiter', ariaLabel: 'Reiter', bei: (k) => gew.push(k) })
const tabs = fx.alle(w, '[role=tab]')
assert.deepEqual([w.getAttribute('data-art'), w.getAttribute('aria-label'), fx.alle(w, '.kn-wl-ico').length], ['reiter', 'Reiter', 0])
assert.deepEqual(tabs.map((t) => [t.textContent, t.getAttribute('aria-label')]), [['Termine', 'Termine'], ['Team', 'Team & Plätze']])
await fx.klick(tabs[1]); assert.deepEqual([gew, tabs[1].getAttribute('aria-selected')], [['team'], 'true'])
w.setzeAnzeige('team', { zahl: 2 }); assert.equal(fx.finde(w, '.kn-zahl'), null)
w.beende(); w.beende() // zweimal ist kein Fehler
} finally { fx.restore() }
})
test('Symbole und Szenen: für jeden Bereich der Leiste ein Symbol und eine eigene Szene', () => {
for (const { key } of WOERTER) { assert.ok(SYMBOLE[key]?.length > 0, key); assert.ok(SZENEN[key], key) }
assert.equal(new Set(WOERTER.map((w) => SZENEN[w.key])).size, WOERTER.length)
assert.ok(SYMBOLE.funktionen.some(([, a]) => a.class === 'kn-wl-tuer')) // die Kachel, die bei 'glow' aufleuchtet
})
test('wasser-leiste.css: Pille wie die Vorlage, am Handy schwebend über dem Inhalt, Abstände über --kn-wl-zone, Etage als Glas-Pille, Bewegung reduziert', () => {
const css = lies('wasser-leiste.css'); const handy = handyTeil(css)
assert.match(css, /\.kn-wl-glas\s*\{[^}]*backdrop-filter:\s*blur\(10px\) saturate\(1\.2\) brightness\(1\.08\)/s)
assert.match(css, /\.kn-wl\[data-gl="off"\] \.kn-wl-tropfen\s*\{\s*display:\s*block/)
assert.doesNotMatch(css, /max-width: 520px/) // die Wörter stehen immer da (Wunsch 29.10.)
assert.match(css, /\.kn-wl\[data-art="bereiche"\] \.kn-wl-item\s*\{[^}]*flex:\s*1 1 0;[^}]*flex-direction:\s*column/) // Symbol über dem Wort, gleich breit
assert.match(css, /\.kn-wl\[data-art="reiter"\]\[data-eng\] \.kn-wl-item\s*\{[^}]*font-size:\s*12px/)
for (const fx of ['pop', 'plane', 'drop', 'spin', 'glow', 'wave']) assert.match(css, new RegExp(`\\.kn-wl-ico\\[data-fx="${fx}"\\] svg\\s*\\{[^}]*animation:`))
assert.ok(handy.startsWith(HANDY))
assert.match(handy, /\.kn\s*\{[^}]*--kn-wl-luft:\s*max\(14px, calc\(env\(safe-area-inset-bottom\) \+ 6px\)\)[^}]*--kn-unten:\s*var\(--kn-wl-zone\)/s) // ganz über dem Wisch-Balken des iPhones
assert.match(handy, /\.kn\.kn-hat-streifen\s*\{\s*--kn-unten:\s*calc\(var\(--kn-wl-zone\) \+ 44px\)/)
assert.ok(handy.indexOf('.kn.kn-tastatur') > handy.indexOf('.kn.kn-hat-streifen')) // Tastatur schlägt den Streifen
assert.match(handy, /\.kn-leiste\s*\{[^}]*background:\s*none[^}]*pointer-events:\s*none/s)
assert.match(handy, /\.kn-leiste \.kn-rw\s*\{[^}]*right:\s*12px;[^}]*left:\s*12px;[^}]*max-width:\s*480px/s) // breit: Rand 12 px, höchstens 480 px
assert.match(handy, /\.kn-leiste \.kn-wl\s*\{\s*display:\s*flex;\s*width:\s*100%/)
assert.match(handy, /\.kn-leiste \.kn-rw\s*\{[^}]*pointer-events:\s*none/s); assert.match(handy, /\.kn-leiste \.kn-rw > \*\s*\{\s*pointer-events:\s*auto/)
assert.match(css, /@media \(prefers-reduced-motion: reduce\)\s*\{[^@]*animation:\s*none !important/s)
// huelle.css bleibt unverändert die Grundlage (Desktop und Etage-Logik)
assert.match(lies('huelle.css'), /\.kn-leiste\s*\{[^}]*border-top:\s*1px solid var\(--kn-line\)/)
})