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('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.match(css, /@media \(max-width: 520px\)\s*\{[^@]*\.kn-wl-label\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/s) // schmal nur Symbole, Wort bleibt für Screenreader 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\(10px, calc\(env\(safe-area-inset-bottom\) - 10px\)\)[^}]*--kn-unten:\s*var\(--kn-wl-zone\)/s) 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-blatt\s*\{\s*bottom:\s*0;\s*padding-bottom:\s*calc\(var\(--kn-unten\) \+ 24px\)/) assert.match(handy, /#kn-zeile-etage\s*\{[^}]*border-radius:\s*999px[^}]*backdrop-filter/s) 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\)/) })