import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync } from 'node:fs' // Prüftest für public/neu/module.css (P4 Tasks 9 bis 13): jede Regel steht unter .kn, nur Farben der Palette, keine Verläufe, kein Glas, // kein Dunkelmodus, keine Animation; für jedes Präfix aus FERTIG ist jede Klasse der alten CSS-Datei unter .kn gestaltet. const PUBLIC = new URL('../public/', import.meta.url) const lies = (datei) => readFileSync(new URL(datei, PUBLIC), 'utf8') const neuDateien = () => readdirSync(new URL('neu/', PUBLIC)).filter((f) => f.endsWith('.css')).sort() const neuCss = () => neuDateien().map((f) => lies(`neu/${f}`)).join('\n') const MODUL = () => lies('neu/module.css') const jsModule = () => [ ...readdirSync(PUBLIC).filter((f) => f.endsWith('.js')).map((f) => lies(f)), ...readdirSync(new URL('neu/', PUBLIC)).filter((f) => f.endsWith('.js')).map((f) => lies(`neu/${f}`)), ].join('\n') const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '') /** [{ selektor, koerper }] – verschachtelte @media/@supports flach, @keyframes und @font-face ohne Schritte. */ function regeln(css) { const text = ohneKommentare(css) const lies2 = (quelle) => { const aus = [] let i = 0 while (i < quelle.length) { const auf = quelle.indexOf('{', i) if (auf === -1) break const kopf = quelle.slice(i, auf).trim() let tiefe = 1 let j = auf + 1 while (j < quelle.length && tiefe > 0) { if (quelle[j] === '{') tiefe += 1 else if (quelle[j] === '}') tiefe -= 1 j += 1 } const innen = quelle.slice(auf + 1, j - 1) if (/^@(media|supports|container|layer)\b/.test(kopf)) aus.push(...lies2(innen)) else if (!kopf.startsWith('@')) aus.push({ selektor: kopf, koerper: innen }) i = j } return aus } return lies2(text) } /** Selektoren einer Regelliste; Kommalisten aufgespalten, Klammer-bewusst (:is(), :not(), [attr="a,b"]). */ function selektoren(css) { const aus = [] for (const { selektor } of regeln(css)) { let tiefe = 0 let quelle = '' let anfuehrung = null for (const z of selektor) { if (anfuehrung) { quelle += z; if (z === anfuehrung) anfuehrung = null; continue } if (z === '"' || z === "'") { anfuehrung = z; quelle += z; continue } if (z === '(' || z === '[') tiefe += 1 if (z === ')' || z === ']') tiefe -= 1 if (z === ',' && tiefe === 0) { aus.push(quelle.trim()); quelle = ''; continue } quelle += z } if (quelle.trim()) aus.push(quelle.trim()) } return aus } /** Klassen mit Präfix aus den Selektoren (nur echte Klassen: ein Punkt direkt davor, nie `.kn-fn-…` für `fn`). */ function klassen(css, praefix) { const treffer = new Set() const muster = new RegExp(`\\.(${praefix}-[A-Za-z0-9_-]+)`, 'g') for (const s of selektoren(css)) for (const m of s.matchAll(muster)) treffer.add(m[1]) return treffer } const FERTIG = ['tm', 'sh', 'bk', 'zt', 'fn', 'nw', 'an', 'hs'] // Präfixe mit geprüfter Abdeckung (alle) const UNGENUTZT = new Set([]) // alte Klassen, die in keiner JS-Datei vorkommen; der Test prüft das const ALT = { tm: 'termine.css', sh: 'shop.css', bk: 'buchungen.css', zt: 'zeiten.css', an: 'analyse.css', hs: 'analyse.css', nw: 'funktionen.css' } const FN_NEWS = ['fn-project', 'fn-booking', 'fn-price', 'fn-estimate', 'fn-desc-link', 'fn-consent', 'fn-thanks', 'fn-status', 'fn-facts', 'fn-pill', 'fn-pill-setup'] const ALT_KLASSEN = ['kb-panel', 'kb-btn', 'kb-btn-primary', 'kb-btn-outline', 'kb-empty', 'kb-link', 'kb-checks', 'abo-actions', 'abo-btn', 'hidden', 'muted', 'error'] const PALETTE = new Set(['#fff', '#ffffff', '#1a1f36', '#5e6678', '#8a93a3', '#ebeef1', '#d8dde3', '#f6f8fa', '#f8f9fb', '#111', '#111111', '#414552', '#e0f2fe', '#075985', '#fcedb9', '#a82c00', '#efeafe', '#5b21b6', '#d7f7c2', '#006908', '#ffe7f2', '#b3093c', '#c0123c']) const ZETTEL = /#fff6d6|#ddeffb|#ffe1d9/i const RING = new Set(['60,66,87,.16', '60,66,87,0.16', '0,0,0,.06', '0,0,0,0.06']) const HANDY = '@media (max-width: 880px), (pointer: coarse) and (max-height: 500px)' test('Hilfen: Regeln, Selektoren und Klassen werden richtig gelesen', () => { const css = '/* x */ .a, .b:not(.c, .d) { color: #fff } @media (x) { .e { margin: 0 } } @keyframes k { from { opacity: 0 } } .f[data-x="a,b"] { top: 0 }' assert.deepEqual(regeln(css).map((r) => r.selektor), ['.a, .b:not(.c, .d)', '.e', '.f[data-x="a,b"]']) assert.deepEqual(selektoren(css), ['.a', '.b:not(.c, .d)', '.e', '.f[data-x="a,b"]']) assert.deepEqual([...klassen('.kn .tm-a, .kn .kn-tm-b, .tm-c-d:hover { }', 'tm')].sort(), ['tm-a', 'tm-c-d']) assert.deepEqual([...klassen('.kn-fn-x, .fn-y { }', 'fn')], ['fn-y']) }) test('module.css: jede Regel steht unter .kn und hängt nicht an der alten Seite', () => { const alle = selektoren(MODUL()) assert.ok(alle.length > 0, 'die Datei hat Regeln') for (const s of alle) assert.match(s, /^\.kn(\s|[.:#[>~+]|$)/, s) assert.doesNotMatch(MODUL(), /#view-|--kb-|\.kb-page/) }) test('module.css: Verbote (Lila, Teal, Verlauf, Glas, Dunkel, Gewicht 800, Animation)', () => { assert.doesNotMatch(MODUL(), /#9d3dfe|#d4b4ff|157,\s*61,\s*254|#2dd4bf|#5eead4|gradient|backdrop-filter|color-scheme:\s*dark|prefers-color-scheme|font-weight:\s*(800|900)|@keyframes/) }) test('module.css: Farben nur aus der Palette der Übersicht; Zettelfarben nur in .nw-paper-*; rgba nur der Ring-Schatten', () => { for (const { selektor, koerper } of regeln(MODUL())) { for (const [, hex] of koerper.matchAll(/(#[0-9a-fA-F]{3,8})\b/g)) assert.ok(PALETTE.has(hex.toLowerCase()) || (ZETTEL.test(hex) && /\.nw-paper-/.test(selektor)), `${selektor}: ${hex}`) for (const [, wert] of koerper.matchAll(/rgba?\(([^)]*)\)/g)) assert.ok(RING.has(wert.replace(/\s/g, '')), `${selektor}: rgba(${wert})`) } }) test('module.css: Fokus schwarz, Handy-Regeln wörtlich mit 44px, reduzierte Bewegung', () => { const css = MODUL() assert.match(css, /:focus-visible[^{]*\{[^}]*outline:\s*2px solid (#111|var\(--kn-[a-z-]+\))/) assert.ok(css.includes(HANDY) && /min-height:\s*44px/.test(css.slice(css.indexOf(HANDY)))) assert.match(css, /prefers-reduced-motion:\s*reduce/) }) test('module.css: Animationen höchstens als transition bis 150 ms', () => { for (const { selektor, koerper } of regeln(MODUL())) { assert.doesNotMatch(koerper, /\banimation(-name)?\s*:/, selektor) for (const [, wert] of koerper.matchAll(/transition(?:-duration)?:\s*([^;]*)/g)) { for (const [, zahl, einheit] of wert.matchAll(/(\d*\.?\d+)(ms|s)\b/g)) assert.ok((einheit === 's' ? Number(zahl) * 1000 : Number(zahl)) <= 150, `${selektor}: ${wert}`) } } }) test('module.css: jedes var(--kn-…) ist in public/neu/*.css definiert', () => { const definiert = new Set([...neuCss().matchAll(/(--kn-[a-z0-9-]+)\s*:/g)].map((m) => m[1])) for (const [, name] of MODUL().matchAll(/var\((--kn-[a-z0-9-]+)/g)) assert.ok(definiert.has(name), name) }) test('Alt-Klassen der Module sind unter .kn gestaltet (in einer Datei von public/neu)', () => { const sel = selektoren(neuCss()).filter((s) => s.startsWith('.kn')) for (const k of ALT_KLASSEN) assert.ok(sel.some((s) => new RegExp(`\\.${k}(?![a-z0-9-])`).test(s)), k) }) test('UNGENUTZT enthält nur Klassen, die kein JS nennt', () => { const js = jsModule() for (const k of UNGENUTZT) assert.ok(!js.includes(k), k) }) for (const praefix of FERTIG) { test(`Abdeckung ${praefix}: jede Klasse der alten CSS-Datei ist unter .kn gestaltet`, () => { const alt = praefix === 'fn' ? FN_NEWS : [...klassen(lies(ALT[praefix]), praefix)] const neu = klassen(MODUL(), praefix) assert.ok(alt.length > 0, 'es gibt alte Klassen') assert.deepEqual(alt.filter((k) => !neu.has(k) && !UNGENUTZT.has(k)).sort(), []) }) }