- gelöscht: components/workers/engine/* (10 Dateien), WorkersStage, WorkersBoard, WorkerCard, WorkerList, WorkersKpis, die neun Engine-Tests samt workersSmoke (211 Tests) und die Helfer engineVm.mjs und fakeCtx.mjs (nur die Engine-Tests brauchten sie) - workers.css (310 -> 60 Zeilen) behält nur Einstellungen-Dialog und Interessenten-Panel; @font-face für Pixelify Sans steht jetzt in hustler/schrift.css - workersComponents: kein innerHTML und kein Kanvas rekursiv unter components/workers/**, jeder Knopf mit type, Links nur aus geprüften Feldern (externe mit noopener), keine Anmeldung in den Bildschirmen, alte Dateien weg und nirgends mehr importiert - workersCss: jede wk-Klasse im Code hat eine Regel, keine Regel ohne Nutzer; workersFont: Pixelify Sans genau einmal in schrift.css - hustlerAssets: Pixelify Sans in schrift.css erlaubt; hustlerDialog: Link-Ziele nur aus safeLink, Knöpfe mit type; hustlerSeite: Laden/Fehler/bereit am selben Einhängepunkt Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
223 lines
10 KiB
JavaScript
223 lines
10 KiB
JavaScript
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))
|
|
// Seit T11 steht auch Pixelify Sans hier (nicht mehr in workers.css): genau eine Einbindung; Datei und Lizenz prüft workersFont.test.mjs
|
|
assert.equal((schrift.match(/font-family:\s*["']Pixelify Sans["']/g) ?? []).length, 1, 'Pixelify Sans genau einmal')
|
|
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/)
|
|
})
|