import test from 'node:test' import assert from 'node:assert/strict' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { createHash } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' // Prüft die vom Import-Skript erzeugten Dateien (src/styles/hustler, src/assets/hustler, public/fonts). // Die Prüfung der Ergebnisse ist bewusst unabhängig vom Umformer im Skript geschrieben. const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const ASSETS = path.join(REPO, 'src/assets/hustler') const BILDSCHIRME = [ { name: 'hochhaus', wurzel: 'hk-hochhaus', kf: 'hk-h-', datei: path.join(REPO, 'src/styles/hustler/hochhaus.css') }, { name: 'keller', wurzel: 'hk-keller', kf: 'hk-k-', datei: path.join(REPO, 'src/styles/hustler/keller.css') }, { name: 'pult', wurzel: 'hk-pult', kf: 'hk-p-', datei: path.join(REPO, 'src/styles/hustler/pult.css') }, ] const ANIMATIONS_WOERTER = new Set(['none', 'infinite', 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end', 'normal', 'reverse', 'alternate', 'alternate-reverse', 'forwards', 'backwards', 'both', 'running', 'paused', 'initial', 'inherit', 'unset', 'revert']) const lies = (datei) => readFileSync(datei, 'utf8') const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, ' ') // → { regeln: [{ selektor, koerper, kontext }], keyframes: [name] } function zerlege(css) { const text = ohneKommentare(css) const regeln = [] const keyframes = [] let i = 0 function liste(kontext) { while (i < text.length) { while (i < text.length && /\s/.test(text[i])) i += 1 if (i >= text.length) return if (text[i] === '}') { i += 1; return } const start = i while (i < text.length && !'{;}'.includes(text[i])) i += 1 const kopf = text.slice(start, i).trim() assert.equal(text[i], '{', `Regel ohne Block: ${kopf.slice(0, 60)}`) i += 1 if (kopf.startsWith('@keyframes')) { keyframes.push(kopf.slice('@keyframes'.length).trim()) let tiefe = 1 while (tiefe > 0 && i < text.length) { if (text[i] === '{') tiefe += 1 else if (text[i] === '}') tiefe -= 1 i += 1 } continue } if (kopf.startsWith('@')) { liste(kopf); continue } const b = i while (i < text.length && text[i] !== '}') i += 1 regeln.push({ selektor: kopf, koerper: text.slice(b, i), kontext }) i += 1 } } liste('') return { regeln, keyframes } } // Teilt an Kommas/Leerzeichen auf oberster Ebene (außerhalb von Klammern und Zeichenketten) function teile(s, trenner) { const aus = [] let tiefe = 0 let quote = null let aktuell = '' for (const c of s) { if (quote) { aktuell += c; if (c === quote) quote = null; continue } if (c === '"' || c === "'") { quote = c; aktuell += c; continue } if (c === '(' || c === '[') tiefe += 1 if (c === ')' || c === ']') tiefe -= 1 if (tiefe === 0 && trenner.test(c)) { if (aktuell) aus.push(aktuell); aktuell = ''; continue } aktuell += c } if (aktuell) aus.push(aktuell) return aus } function dateienUnter(ordner) { const liste = [] for (const name of readdirSync(ordner)) { const voll = path.join(ordner, name) if (statSync(voll).isDirectory()) liste.push(...dateienUnter(voll)) else liste.push(voll) } return liste } const css = Object.fromEntries(BILDSCHIRME.map((b) => [b.name, lies(b.datei)])) const zerlegt = Object.fromEntries(BILDSCHIRME.map((b) => [b.name, zerlege(css[b.name])])) test('Kopfkommentar: jede CSS-Datei sagt, dass sie erzeugt ist', () => { for (const b of BILDSCHIRME) { assert.match(css[b.name].split('\n')[0], /^\/\* Erzeugt von scripts\/hustler-import\.mjs aus .+, nicht von Hand ändern\. \*\/$/, b.name) } }) test('kein /_blob/, kein googleapis, keine @font-face/@import in den erzeugten CSS-Dateien', () => { for (const b of BILDSCHIRME) { const t = ohneKommentare(css[b.name]) assert.ok(!t.includes('/_blob/'), `${b.name}: /_blob/`) assert.ok(!/googleapis/i.test(t), `${b.name}: googleapis`) assert.ok(!/@font-face/i.test(t), `${b.name}: @font-face`) assert.ok(!/@import/i.test(t), `${b.name}: @import`) assert.ok(zerlegt[b.name].regeln.length > 50, `${b.name}: zu wenige Regeln gelesen`) } }) test('jede url(...) zeigt auf eine vorhandene Datei im Asset-Ordner (relativer Pfad)', () => { for (const b of BILDSCHIRME) { const urls = [...ohneKommentare(css[b.name]).matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((m) => m[1].replace(/^["']|["']$/g, '')) assert.ok(urls.length > 10, `${b.name}: zu wenige url(...)`) for (const u of urls) { assert.match(u, /^\.\.\/\.\.\/assets\/hustler\/[\w./-]+\.png$/, `${b.name}: ${u}`) const ziel = path.resolve(path.dirname(b.datei), u) assert.ok(ziel.startsWith(ASSETS + path.sep), `${b.name}: ${u} liegt außerhalb von src/assets/hustler`) assert.ok(existsSync(ziel) && statSync(ziel).isFile(), `${b.name}: ${u} fehlt`) } } }) test('jede Datei unter src/assets/hustler wird von mindestens einer CSS-Datei benutzt', () => { const benutzt = new Set() for (const b of BILDSCHIRME) { for (const m of ohneKommentare(css[b.name]).matchAll(/url\(\s*([^)]*?)\s*\)/g)) { benutzt.add(path.resolve(path.dirname(b.datei), m[1].replace(/^["']|["']$/g, ''))) } } const alle = dateienUnter(ASSETS) assert.ok(alle.length > 100, `nur ${alle.length} Dateien`) for (const d of alle) { assert.ok(d.endsWith('.png'), `keine PNG: ${d}`) assert.ok(benutzt.has(d), `unbenutzt: ${path.relative(REPO, d)}`) } }) test('jede Klasse in einem Selektor beginnt mit hk-', () => { for (const b of BILDSCHIRME) { for (const r of zerlegt[b.name].regeln) { const ohneStrings = r.selektor.replace(/"[^"]*"|'[^']*'/g, '""').replace(/\[[^\]]*\]/g, '[]') const klassen = [...ohneStrings.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1]) for (const k of klassen) { assert.ok(k.startsWith('hk-'), `${b.name}: .${k} in „${r.selektor.slice(0, 80)}“`) assert.ok(!k.startsWith('hk-hk-'), `${b.name}: doppelt vorangestellt: .${k}`) } } } }) test('jede Regel liegt unter ihrer Wurzelklasse', () => { for (const b of BILDSCHIRME) { for (const r of zerlegt[b.name].regeln) { for (const teil of teile(r.selektor, /,/)) { const t = teil.trim() assert.ok(t.startsWith(`.${b.wurzel}`), `${b.name}: „${t.slice(0, 80)}“ beginnt nicht mit .${b.wurzel}`) const danach = t[b.wurzel.length + 1] assert.ok(danach === undefined || ' .:[>+~'.includes(danach), `${b.name}: „${t.slice(0, 80)}“ nennt eine andere Klasse mit gleichem Anfang`) assert.ok(!/(^|[\s>+~(,])(?:html|body|:root)(?![\w-])/i.test(t), `${b.name}: html/body/:root übrig in „${t.slice(0, 80)}“`) } } assert.ok(zerlegt[b.name].regeln.some((r) => r.selektor === `.${b.wurzel}`), `${b.name}: keine Regel für die Wurzel selbst (früher body)`) } }) test('jeder Keyframe-Name trägt den Vorsatz seines Bildschirms und ist nur einmal definiert', () => { const alle = new Set() for (const b of BILDSCHIRME) { const { keyframes } = zerlegt[b.name] assert.ok(keyframes.length > 10, `${b.name}: nur ${keyframes.length} Keyframes`) assert.equal(new Set(keyframes).size, keyframes.length, `${b.name}: Keyframe doppelt`) for (const k of keyframes) { assert.ok(k.startsWith(b.kf), `${b.name}: ${k} ohne ${b.kf}`) assert.ok(!alle.has(k), `${k} kommt in zwei Bildschirmen vor`) alle.add(k) } } }) test('jede in animation/animation-name genannte Bezeichnung ist ein definierter Keyframe oder ein Schlüsselwort', () => { let geprueft = 0 for (const b of BILDSCHIRME) { const definiert = new Set(zerlegt[b.name].keyframes) for (const r of zerlegt[b.name].regeln) { for (const deklaration of teile(r.koerper, /;/)) { const k = deklaration.indexOf(':') if (k < 0) continue const eigenschaft = deklaration.slice(0, k).trim().toLowerCase() if (!/^(?:-webkit-)?animation(?:-name)?$/.test(eigenschaft)) continue const wert = deklaration.slice(k + 1).replace(/!important/i, ' ') for (const eintrag of teile(wert, /,/)) { for (const wort of teile(eintrag, /\s/)) { if (wort.includes('(') || /^[+-]?[\d.]+(?:ms|s)?$/.test(wort)) continue if (ANIMATIONS_WOERTER.has(wort)) continue assert.ok(definiert.has(wort), `${b.name}: animation nennt „${wort}“, aber es gibt keinen Keyframe ${wort}`) geprueft += 1 } } } } } assert.ok(geprueft > 80, `nur ${geprueft} Animationsnamen gefunden`) }) test('schrift.css bindet Press Start 2P selbst gehostet ein (Datei vorhanden, OFL-Text)', () => { const schrift = ohneKommentare(lies(path.join(REPO, 'src/styles/hustler/schrift.css'))) assert.match(schrift, /@font-face/) assert.match(schrift, /font-family:\s*["']Press Start 2P["']/) assert.match(schrift, /font-display:\s*swap/) const m = schrift.match(/url\(\s*["']?(\/fonts\/[\w.-]+\.woff2)["']?\s*\)/) assert.ok(m, 'keine woff2-Adresse unter /fonts/') assert.equal(m[1], '/fonts/PressStart2P-Regular.woff2') const datei = path.join(REPO, 'public', m[1]) assert.ok(existsSync(datei), `${m[1]} fehlt in public/`) assert.equal(readFileSync(datei).subarray(0, 4).toString('latin1'), 'wOF2') // lateinischer Schnitt aus @fontsource/press-start-2p@5.2.7 (files/press-start-2p-latin-400-normal.woff2): Ziffern, Umlaute, ß, € (kein U+2212) assert.equal(createHash('sha256').update(readFileSync(datei)).digest('hex'), 'afec86997fdaf54af1f59358fa2c1e2a0f1d04146edad18e5cd141d0384a7548') assert.ok(!/googleapis|gstatic/.test(schrift)) assert.ok(!/Pixelify/i.test(schrift), 'Pixelify Sans ist schon in workers.css eingebunden, nicht doppelt') const lizenz = lies(path.join(REPO, 'public/fonts/OFL-PressStart2P.txt')) assert.match(lizenz, /^Copyright 2012 The Press Start 2P Project Authors \(cody@zone38\.net\), with Reserved Font Name "Press Start 2P"\.?$/m) assert.match(lizenz, /SIL OPEN FONT LICENSE Version 1\.1/) })