Files
tickte-system/tests/unit/hustlerImportCss.test.mjs
T

299 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
// Umformer und Planer aus scripts/hustler-import.mjs: CSS-Zerlegung (Kommentare, Zeichenketten, verschachtelte
// @media, @keyframes), Selektoren, Animationsnamen, Blob-Adressen, Fehlermeldungen und Wiederholbarkeit.
const { wandleCss, wandleSelektor, baue, pruefsumme, schreibe, ImportFehler, main } = await import('../../scripts/hustler-import.mjs')
const V2 = process.env.HUSTLER_V2 || '/root/reports/workers-2026-10-16/pixelwelt-v2'
const hatV2 = existsSync(path.join(V2, 'entwurf/Main.dc.html'))
// Löscht nur Ordner, die dieser Test selbst unter os.tmpdir() mit dem Vorsatz hustler- angelegt hat
function loesche(ordner) {
assert.equal(path.dirname(ordner), os.tmpdir(), 'nur Ordner direkt unter dem Temp-Verzeichnis')
assert.ok(path.basename(ordner).startsWith('hustler-'), 'nur selbst angelegte Ordner')
rmSync(ordner, { recursive: true, force: true })
}
const BLOB = (id) => `../../assets/hustler/${id}.png`
const umformen = (css, extra = {}) => wandleCss(css, { wurzel: 'hk-t', kfVorsatz: 'hk-x-', blobPfad: BLOB, ...extra })
const nur = (css, extra) => umformen(css, extra).css
test('Wurzel: body, html und :root werden zur Wurzelklasse, doppelte fallen zusammen', () => {
assert.equal(nur('body{margin:0}'), '.hk-t{margin:0}')
assert.equal(nur('html,body{margin:0}'), '.hk-t{margin:0}')
assert.equal(nur(':root{--a:1}'), '.hk-t{--a:1}')
assert.equal(nur('html body{margin:0}'), '.hk-t{margin:0}')
assert.equal(nur('html > body{margin:0}'), '.hk-t{margin:0}')
assert.equal(nur('body.dunkel{color:#fff}'), '.hk-t.hk-dunkel{color:#fff}')
assert.equal(nur('body > .a, html .b{x:1}'), '.hk-t > .hk-a,.hk-t .hk-b{x:1}')
assert.equal(nur('body::selection{x:1}'), '.hk-t::selection{x:1}')
})
test('andere Selektoren stehen unter der Wurzel, auch Element-Selektoren', () => {
assert.equal(nur('a{color:red}'), '.hk-t a{color:red}')
assert.equal(nur('a:hover{color:red}'), '.hk-t a:hover{color:red}')
assert.equal(nur('a, button{x:1}'), '.hk-t a,.hk-t button{x:1}')
assert.equal(nur('*{box-sizing:border-box}'), '.hk-t *{box-sizing:border-box}')
assert.equal(nur('button:focus-visible{outline:0}'), '.hk-t button:focus-visible{outline:0}')
assert.equal(nur('.suche::-webkit-search-cancel-button{display:none}'), '.hk-t .hk-suche::-webkit-search-cancel-button{display:none}')
assert.equal(nur('li:nth-child(2n+1){x:1}'), '.hk-t li:nth-child(2n+1){x:1}')
})
test('jede Klasse im Selektor bekommt hk- (auch in :not und nach Kombinatoren), Deklarationen bleiben', () => {
assert.equal(nur('.panel{width:.5em}'), '.hk-t .hk-panel{width:.5em}')
assert.equal(nur('a:not(.knopf):hover{color:#7a2e3a}'), '.hk-t a:not(.hk-knopf):hover{color:#7a2e3a}')
assert.equal(nur('.a.b > .c + .d ~ .e .f{x:1}'), '.hk-t .hk-a.hk-b > .hk-c + .hk-d ~ .hk-e .hk-f{x:1}')
assert.equal(nur('.stempel:not(.stempel-frage):hover{background:#fff}'), '.hk-t .hk-stempel:not(.hk-stempel-frage):hover{background:#fff}')
assert.equal(nur('.a{transition:opacity .12s steps(2); margin:0 .5em; font-size:.9rem}'), '.hk-t .hk-a{transition:opacity .12s steps(2); margin:0 .5em; font-size:.9rem}')
assert.equal(nur('.a{content:".b .c"}'), '.hk-t .hk-a{content:".b .c"}')
})
test('Attributselektoren und Zeichenketten im Selektor bleiben unberührt, Klassen danach werden trotzdem ergänzt', () => {
assert.equal(nur('a[href^=".x"] .c{x:1}'), '.hk-t a[href^=".x"] .hk-c{x:1}')
assert.equal(nur('[data-a="b.c"].d{x:1}'), '.hk-t [data-a="b.c"].hk-d{x:1}')
assert.equal(nur('input[type=text]:focus .z{x:1}'), '.hk-t input[type=text]:focus .hk-z{x:1}')
})
test('html/body/:root mitten im Selektor werden nicht geraten, sondern gemeldet', () => {
assert.throws(() => nur('.dunkel body{x:1}'), (e) => e instanceof ImportFehler && /mitten im Selektor/.test(e.message))
assert.throws(() => wandleSelektor('a:not(body)', 'hk-t'), ImportFehler)
assert.throws(() => wandleSelektor(' ', 'hk-t'), /leerer Selektor/)
})
test('@keyframes: Namen bekommen den Vorsatz, Animationen und animation-name ziehen mit (nur ganze, bekannte Namen)', () => {
const { css, keyframes } = umformen(`
@keyframes funkeln{0%,100%{opacity:.25}50%{opacity:1}}
@keyframes k24x4{to{background-position:-96px 0}}
.a{animation:funkeln 3.2s steps(4) infinite}
.b{animation:k24x4 600ms steps(4) infinite, funkeln 1s linear -0.5s infinite alternate}
.c{animation-name:funkeln,k24x4; animation-duration:.5s}
.d{animation:none!important}
.e{animation:funkeln2 1s linear infinite}
.f{-webkit-animation:funkeln 1s}
`)
assert.deepEqual(keyframes, ['hk-x-funkeln', 'hk-x-k24x4'])
const zeilen = css.split('\n')
assert.equal(zeilen[0], '@keyframes hk-x-funkeln{0%,100%{opacity:.25}50%{opacity:1}}')
assert.equal(zeilen[1], '@keyframes hk-x-k24x4{to{background-position:-96px 0}}')
assert.equal(zeilen[2], '.hk-t .hk-a{animation:hk-x-funkeln 3.2s steps(4) infinite}')
assert.equal(zeilen[3], '.hk-t .hk-b{animation:hk-x-k24x4 600ms steps(4) infinite, hk-x-funkeln 1s linear -0.5s infinite alternate}')
assert.equal(zeilen[4], '.hk-t .hk-c{animation-name:hk-x-funkeln,hk-x-k24x4; animation-duration:.5s}')
assert.equal(zeilen[5], '.hk-t .hk-d{animation:none!important}')
assert.equal(zeilen[6], '.hk-t .hk-e{animation:funkeln2 1s linear infinite}', 'unbekannte Namen (nur Teilstring eines Keyframes) bleiben')
assert.equal(zeilen[7], '.hk-t .hk-f{-webkit-animation:hk-x-funkeln 1s}')
})
test('Animationen: Schlüsselwörter, steps(1,end) und Zahlen wie .5s oder -3s bleiben, Namen nach einer Funktion werden trotzdem umbenannt', () => {
const { css } = umformen('@keyframes dampfzeit{0%{opacity:1}} .a{animation:k10 1000ms steps(4) infinite, dampfzeit 3200ms steps(1,end) infinite -.5s}')
assert.match(css, /\.hk-t \.hk-a\{animation:k10 1000ms steps\(4\) infinite, hk-x-dampfzeit 3200ms steps\(1,end\) infinite -\.5s\}/)
const { css: c2 } = umformen('@keyframes a{to{x:1}} .b{animation:cubic-bezier(.2,.7,.2,1) a 2s ease-in-out infinite}')
assert.match(c2, /animation:cubic-bezier\(\.2,\.7,\.2,1\) hk-x-a 2s ease-in-out infinite/)
})
test('verschachtelte @media/@supports: Regeln darin werden wie oben umgeformt, die Bedingung bleibt', () => {
const { css } = umformen(`
@media (prefers-reduced-motion: reduce){.a,.b{animation:none} body{margin:1px} @supports (display:grid){.c{display:grid}}}
@media (min-width:720px){a:hover{color:red}}
`)
assert.equal(css, [
'@media (prefers-reduced-motion: reduce){',
'.hk-t .hk-a,.hk-t .hk-b{animation:none}',
'.hk-t{margin:1px}',
'@supports (display:grid){',
'.hk-t .hk-c{display:grid}',
'}',
'}',
'@media (min-width:720px){',
'.hk-t a:hover{color:red}',
'}',
].join('\n'))
})
test('Kommentare und Zeichenketten: Klammern und Semikola in Texten stören nicht', () => {
const { css } = umformen(`
/* Kopf { mit Klammer } */
.a{content:"}{;"; /* mitten { drin */ color:red}
/* .b{x:1} */
.c::after{content:'a\\'b}'}
.d{font-family:'Pixelify Sans',monospace}
`)
assert.equal(css, [
'.hk-t .hk-a{content:"}{;"; color:red}',
".hk-t .hk-c::after{content:'a\\'b}'}",
".hk-t .hk-d{font-family:'Pixelify Sans',monospace}",
].join('\n'))
assert.throws(() => umformen('.a{content:"offen}'), /Zeichenkette ohne Ende/)
assert.throws(() => umformen('.a{color:red} /* offen'), /Kommentar ohne Ende/)
})
test('@font-face und @import entfallen samt Inhalt (deren Blob-Adressen werden gar nicht erst aufgelöst)', () => {
const aufgeloest = []
const { css } = umformen('@import url("x.css");\n@font-face{font-family:"X";src:url(/_blob/zzz)}\n.a{color:red}\n@import "y.css";', { blobPfad: (id) => { aufgeloest.push(id); return BLOB(id) } })
assert.equal(css, '.hk-t .hk-a{color:red}')
assert.deepEqual(aufgeloest, [])
})
test('Blob-Adressen: url(…) in allen Schreibweisen und in border-image/mask werden zum Asset-Pfad', () => {
const { css } = umformen(`
.a{background:url(/_blob/abc1) 0 0 / 10px repeat, linear-gradient(red, blue)}
.b{background-image:url("/_blob/abc2")}
.c{background-image:url( '/_blob/abc3' )}
.d{border-image:url(/_blob/abc4) 8 fill stretch}
.e{-webkit-mask-image:url(/_blob/abc5); mask-image:url(/_blob/abc5)}
.f{background:URL(/_blob/abc6)}
.g{background:url(data:image/png;base64,AAAA)}
`)
assert.match(css, /\.hk-t \.hk-a\{background:url\(\.\.\/\.\.\/assets\/hustler\/abc1\.png\) 0 0 \/ 10px repeat, linear-gradient\(red, blue\)\}/)
assert.match(css, /\.hk-b\{background-image:url\("\.\.\/\.\.\/assets\/hustler\/abc2\.png"\)\}/)
assert.match(css, /\.hk-c\{background-image:url\( '\.\.\/\.\.\/assets\/hustler\/abc3\.png' \)\}|\.hk-c\{background-image:url\('\.\.\/\.\.\/assets\/hustler\/abc3\.png'\)\}/)
assert.match(css, /\.hk-d\{border-image:url\(\.\.\/\.\.\/assets\/hustler\/abc4\.png\) 8 fill stretch\}/)
assert.match(css, /\.hk-e\{-webkit-mask-image:url\(\.\.\/\.\.\/assets\/hustler\/abc5\.png\); mask-image:url\(\.\.\/\.\.\/assets\/hustler\/abc5\.png\)\}/)
assert.match(css, /\.hk-f\{background:url\(\.\.\/\.\.\/assets\/hustler\/abc6\.png\)\}/)
assert.match(css, /\.hk-g\{background:url\(data:image\/png;base64,AAAA\)\}/, 'data:-Adressen mit ; bleiben ganz')
assert.ok(!css.includes('/_blob/'))
})
test('Blob-Adressen: unbekannte Id und unerwartete Schreibweise brechen mit klarer Meldung ab', () => {
const streng = (id) => { throw new ImportFehler(`Im CSS steht /_blob/${id}, aber blobs.json kennt diese Adresse nicht.`) }
assert.throws(() => umformen('.a{background:url(/_blob/unbekannt)}', { blobPfad: streng }), /kennt diese Adresse nicht/)
assert.throws(() => umformen('.a{background:url(/_blob/ab/cd)}'), /unerwartete Blob-Adresse/)
})
test('nicht unterstützte Konstrukte werden gemeldet statt falsch umgeformt', () => {
assert.throws(() => umformen('.a{.b{x:1}}'), /verschachtelte Regel/)
assert.throws(() => umformen('@page{margin:0}'), /nicht unterstützte At-Regel/)
assert.throws(() => umformen('.a{color:red'), ImportFehler)
assert.throws(() => umformen('.a{color}'), /Deklaration ohne/)
assert.throws(() => umformen('}'), /unerwartete schließende Klammer/)
assert.throws(() => umformen('@namespace svg url(x);'), /nicht unterstützte Anweisung/)
})
test('Keyframe-Namen aus @keyframes in @media und mit Vendor-Präfix werden ebenfalls erfasst', () => {
const { css, keyframes } = umformen('@media (min-width:1px){@keyframes w{to{x:1}}} @-webkit-keyframes w{to{x:1}} .a{animation:w 1s}')
assert.deepEqual(keyframes, ['hk-x-w'])
assert.match(css, /\.hk-t \.hk-a\{animation:hk-x-w 1s\}/)
assert.match(css, /@-webkit-keyframes hk-x-w\{to\{x:1\}\}/)
})
// ───────────────────────── Planer gegen die echte Zeichenfläche (übersprungen, wenn sie fehlt) ─────────────────────────
const opt = { skip: hatV2 ? false : `pixelwelt-v2 nicht gefunden (${V2}); HUSTLER_V2 setzen` }
test('baue: zwei Läufe liefern byte-gleiche Ausgaben (gleiche Prüfsumme); je Schlüssel der Tabelle eine PNG, dazu 3 CSS + 1 JS', opt, async () => {
const quellen = JSON.parse(readFileSync(new URL('../../scripts/hustler-import/quellen.json', import.meta.url), 'utf8'))
const a = await baue({ v2: V2 })
const b = await baue({ v2: V2 })
assert.equal(pruefsumme(a.dateien), pruefsumme(b.dateien))
const namen = [...a.dateien.keys()]
const verschieden = new Set(Object.values(quellen).flatMap((t) => Object.values(t)))
assert.equal(namen.filter((n) => n.endsWith('.png')).length, verschieden.size)
assert.deepEqual(namen.filter((n) => !n.endsWith('.png')).sort(), ['src/lib/hustlerWelt.js', 'src/styles/hustler/hochhaus.css', 'src/styles/hustler/keller.css', 'src/styles/hustler/pult.css'])
for (const name of ['hochhaus', 'keller', 'pult']) {
assert.equal(a.bericht.bildschirme[name].dateien, Object.keys(quellen[name]).length, `${name}: jedes Bild der Tabelle wird im CSS benutzt`)
}
// gemeinsam benutzte Bilder liegen einmal, die Zielpfade spiegeln die Ordner ohne „entwurf/“
assert.ok(a.dateien.has('src/assets/hustler/ui/knopf.png'))
assert.ok(a.dateien.has('src/assets/hustler/keller/ui/robo.png'))
assert.ok(a.dateien.has('src/assets/hustler/ui/robo.png'))
assert.ok(a.dateien.has('src/assets/hustler/pult/eigen/lichterkette-0.png'))
assert.ok(![...a.dateien.keys()].some((n) => n.includes('/entwurf/')))
})
test('baue: die Dateien im Repo entsprechen dem, was das Skript aus pixelwelt-v2 erzeugt (außer wenn mit pixelwelt-woms gebaut wurde)', opt, async () => {
const plan = await baue({ v2: V2 })
const repo = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..')
const cssDatei = readFileSync(path.join(repo, 'src/styles/hustler/keller.css'), 'utf8')
assert.equal(cssDatei, plan.dateien.get('src/styles/hustler/keller.css'))
const pultDatei = readFileSync(path.join(repo, 'src/styles/hustler/pult.css'), 'utf8')
assert.equal(pultDatei, plan.dateien.get('src/styles/hustler/pult.css'))
})
test('baue: fehlende Schlüssel, Dateien und falsche Schlüssel brechen mit deutscher Meldung ab', opt, async () => {
const quellen = JSON.parse(readFileSync(new URL('../../scripts/hustler-import/quellen.json', import.meta.url), 'utf8'))
const ohneKey = structuredClone(quellen)
delete ohneKey.pult.klammer
await assert.rejects(baue({ v2: V2, quellen: ohneKey }), (e) => e instanceof ImportFehler && /Schlüssel „klammer“.*fehlt in scripts\/hustler-import\/quellen\.json/.test(e.message))
const ohneDatei = structuredClone(quellen)
ohneDatei.keller.keller = 'keller/szene/gibt-es-nicht.png'
await assert.rejects(baue({ v2: V2, quellen: ohneDatei }), (e) => e instanceof ImportFehler && /Quelldatei fehlt: keller\/szene\/gibt-es-nicht\.png/.test(e.message))
const falscherKey = structuredClone(quellen)
falscherKey.hochhaus['gibt-es-nicht'] = 'haus/haus.png'
await assert.rejects(baue({ v2: V2, quellen: falscherKey }), /Schlüssel „gibt-es-nicht“ steht nicht in/)
const unbekannterBildschirm = { ...structuredClone(quellen), handy: {} }
await assert.rejects(baue({ v2: V2, quellen: unbekannterBildschirm }), /unbekannter Bildschirm „handy“/)
const gefaehrlich = structuredClone(quellen)
gefaehrlich.pult.pult = '../../etc/passwd.png'
await assert.rejects(baue({ v2: V2, quellen: gefaehrlich }), /ungültiger Quellpfad/)
await assert.rejects(baue({ v2: path.join(V2, 'gibt-es-nicht') }), /pixelwelt-v2 existiert nicht/)
})
test('baue: eine veränderte Datei fällt über hochgeladen.sha256 auf; ein veralteter Blob im CSS ebenso', opt, async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'hustler-v2-'))
try {
cpSync(V2, tmp, { recursive: true })
const datei = path.join(tmp, 'detail/dampf.png')
const bytes = readFileSync(datei)
bytes[bytes.length - 1] ^= 0xff
writeFileSync(datei, bytes)
await assert.rejects(baue({ v2: tmp }), (e) => e instanceof ImportFehler && /Prüfsumme stimmt nicht: detail\/dampf\.png/.test(e.message))
// Blob-Adresse im CSS, die blobs.json nicht kennt
writeFileSync(datei, readFileSync(path.join(V2, 'detail/dampf.png')))
const html = path.join(tmp, 'keller/entwurf/BotLeads.dc.html')
writeFileSync(html, readFileSync(html, 'utf8').replace('</style>', '.neu{background:url(/_blob/ffffffffffffffffffffffffffffffff)}</style>'))
await assert.rejects(baue({ v2: tmp }), (e) => e instanceof ImportFehler && /BotLeads\.dc\.html steht \/_blob\/ffffffffffffffffffffffffffffffff, aber .*blobs\.json kennt diese Adresse nicht/.test(e.message))
} finally {
loesche(tmp)
}
})
test('baue mit pixelwelt-woms: gleichnamige Haus-Dateien ersetzen die aus v2, graue Räume werden Klassen und Daten, alte Ebenen verschwinden wieder', opt, async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'hustler-woms-'))
const wurzel = mkdtempSync(path.join(os.tmpdir(), 'hustler-wurzel-'))
try {
mkdirSync(path.join(tmp, 'haus'))
cpSync(path.join(V2, 'haus/schild-a.png'), path.join(tmp, 'haus/haus.png'))
cpSync(path.join(V2, 'ui/panel.png'), path.join(tmp, 'haus/raum-grau-netz.png'))
cpSync(path.join(V2, 'ui/knopf.png'), path.join(tmp, 'haus/raum-grau-kleinanzeigen.png'))
writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({ graueRaeume: { netz: { x: 1040, y: 558, w: 24, h: 24 }, kleinanzeigen: { file: 'raum-grau-kleinanzeigen.png', x: 1140, y: 558 } } }))
const ohne = await baue({ v2: V2 })
const mit = await baue({ v2: V2, woms: tmp })
assert.notEqual(pruefsumme(ohne.dateien), pruefsumme(mit.dateien))
assert.ok(mit.dateien.get('src/assets/hustler/haus/haus.png').equals(readFileSync(path.join(V2, 'haus/schild-a.png'))), 'haus.png stammt aus pixelwelt-woms')
assert.ok(mit.dateien.get('src/assets/hustler/haus/haus-fenster-a.png').equals(ohne.dateien.get('src/assets/hustler/haus/haus-fenster-a.png')), 'andere Dateien bleiben aus v2')
assert.deepEqual(mit.bericht.graueRaeume, ['netz', 'kleinanzeigen'])
const css = mit.dateien.get('src/styles/hustler/hochhaus.css')
assert.match(css, /\.hk-hochhaus \.hk-raum-grau-netz\{position:absolute; left:1040px; top:558px; width:24px; height:24px; background-image:url\(\.\.\/\.\.\/assets\/hustler\/haus\/raum-grau-netz\.png\); image-rendering:pixelated; pointer-events:none\}/)
assert.match(css, /\.hk-hochhaus \.hk-raum-grau-kleinanzeigen\{position:absolute; left:1140px; top:558px; width:24px; height:24px;/)
const welt = mit.dateien.get('src/lib/hustlerWelt.js')
assert.match(welt, /export const GRAUE_RAEUME = Object\.freeze\(\{\s*netz: Object\.freeze\(\{ left: 1040, top: 558, width: 24, height: 24 \}\),\s*kleinanzeigen:/)
// schreiben, nochmal schreiben (nichts mehr zu tun), dann ohne woms: die grauen Ebenen werden entfernt
const z1 = schreibe(mit, wurzel)
assert.equal(z1.geschrieben, mit.dateien.size)
assert.equal(schreibe(mit, wurzel).geschrieben, 0)
assert.ok(existsSync(path.join(wurzel, 'src/assets/hustler/haus/raum-grau-netz.png')))
const z2 = schreibe(ohne, wurzel)
assert.deepEqual(z2.entfernt.sort(), ['raum-grau-kleinanzeigen.png', 'raum-grau-netz.png'])
assert.ok(!existsSync(path.join(wurzel, 'src/assets/hustler/haus/raum-grau-netz.png')))
// Fehler: kein Such-Raum, falsche Größe
writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({ graueRaeume: { bruecke: { x: 1, y: 1 } } }))
await assert.rejects(baue({ v2: V2, woms: tmp }), /„bruecke“ ist kein Such-Raum/)
writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({ graueRaeume: { netz: { x: 1040, y: 558, w: 99, h: 24 } } }))
await assert.rejects(baue({ v2: V2, woms: tmp }), /99×24 im Manifest, aber raum-grau-netz\.png ist 24×24/)
// ein Ordner ohne graueRaeume ändert an den Räumen nichts
writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({}))
assert.deepEqual((await baue({ v2: V2, woms: tmp })).bericht.graueRaeume, [])
} finally {
loesche(tmp)
loesche(wurzel)
}
})
test('Import ohne pixelwelt-woms bricht ab, solange im Repo graue Raum-Ebenen liegen (Endprüfung R6) – nichts wird geschrieben oder gelöscht', opt, async () => {
await assert.rejects(() => main([V2]), (e) => e instanceof ImportFehler && /--ohne-woms/.test(e.message))
})