Hustler-Pixelwelt T11: alte helle Oberfläche und Kanvas-Engine entfernt, workers.css gekürzt, Zusicherungen in die neuen Tests übertragen

- 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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-22 09:09:37 +00:00
1 parent 4be9750f36
commit ba007a325a
35 files changed
+277 -6495

No files matched your search

+60 -76
View File
@@ -1,32 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
test('workers.css: nur wk-Klassen, @font-face, keine Hex-Farben, reduzierte Bewegung', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
for (const m of css.matchAll(/([^{}]+)\{/g)) { const pre = m[1].trim(); if (pre.startsWith('@')) continue; for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) } // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
assert.match(css, /@font-face\s*\{[^}]*font-family:\s*"Pixelify Sans"[^}]*PixelifySans-Variable\.woff2[^}]*font-display:\s*swap/s); assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
assert.match(css, /prefers-reduced-motion/); assert.match(css, /@media \(max-width: 1100px\)/); assert.match(css, /@media \(max-width: 860px\)/)
// workers.css ist seit T11 (Pixelwelt) auf die weißen Bausteine gekürzt, die noch gebraucht werden: Einstellungen-Dialog (WorkersSettingsDialog)
// und Interessenten-Panel (ProspectPanel). Hochhaus, Pult und Keller haben ihre Stile in src/styles/hustler/ (eigene Tests: hustler*.test.mjs).
const stylesDir = fileURLToPath(new URL('../../src/styles/', import.meta.url))
const srcDir = fileURLToPath(new URL('../../src/', import.meta.url))
const stripped = (file) => readFileSync(path.join(stylesDir, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
// Klassen der Regeln (jede Regel, auch in @media) und Klassen im Code (ohne Kommentare; `wk-set-${name}` ist eine Kennung, keine Klasse;
// das Attribut data-wk-worker hat ein Zeichen davor und zählt nicht)
const definierteKlassen = (css) => new Set([...css.matchAll(/\.(wk-[a-z0-9-]+)/g)].map((m) => m[1]))
const benutzteKlassen = () => {
const out = new Map()
for (const f of walk(srcDir).filter((x) => /\.jsx?$/.test(x))) {
for (const m of ohneKommentare(readFileSync(f, 'utf8')).matchAll(/(?<![\w-])wk-[a-z0-9-]*[a-z0-9](?![\w-])/g)) {
if (!out.has(m[0])) out.set(m[0], new Set())
out.get(m[0]).add(path.relative(srcDir, f))
}
}
return out
}
test('workers.css: nur wk-Klassen, keine Hex-Farben, keine Schrift, Bilder oder Importe, ein Handy-Block', () => {
const css = stripped('workers.css')
let regeln = 0
for (const m of css.matchAll(/([^{}]+)\{/g)) {
const pre = m[1].trim()
if (pre.startsWith('@')) continue
regeln += 1
for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
}
assert.ok(regeln >= 15, `nur ${regeln} Regeln gelesen`)
assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
assert.doesNotMatch(css, /@font-face|@import|url\(/) // die Schrift steht in hustler/schrift.css (workersFont.test.mjs)
assert.match(css, /@media \(max-width: 860px\)/)
assert.doesNotMatch(css, /@media \(max-width: 1100px\)/)
// keine Bewegung in dieser Datei, also braucht sie auch kein prefers-reduced-motion
assert.doesNotMatch(css, /\b(?:animation|transition)\s*:|@keyframes/)
})
test('jede im JSX verwendete wk-Klasse ist definiert', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8'); const dir = new URL('../../src/components/workers/', import.meta.url)
for (const f of readdirSync(dir).filter((x) => x.endsWith('.jsx'))) for (const m of readFileSync(new URL(f, dir), 'utf8').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) // Lookbehind: das Attribut data-wk-worker ist keine Klasse
})
test('workers.css: Zoomknöpfe unten rechts (oben läge die Reihe über dem Schild der Kommandobrücke), Meldungen halten Abstand zu den Kennzahlen', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
assert.match(rule('.wk-zoom'), /bottom:\s*8px/); assert.match(rule('.wk-zoom'), /right:\s*8px/); assert.doesNotMatch(rule('.wk-zoom'), /\btop:/)
assert.match(rule('.wk-toolbar ~ .msg'), /margin-bottom:\s*16px/)
})
test('workers.css: die Karte bleibt beim Anspringen unter der festen Leiste der Handy-Ansicht (scroll-margin-top)', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
assert.match(css, /\.wk-card\s*\{[^}]*scroll-margin-top:\s*12px/)
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
assert.match(phone, /\.wk-card\s*\{\s*scroll-margin-top:\s*64px/) // 52 px Leiste + Luft
test('jede wk-Klasse im Code hat eine Regel in workers.css, und keine Regel steht ohne Nutzer', () => {
const definiert = definierteKlassen(stripped('workers.css'))
const benutzt = benutzteKlassen()
assert.ok(definiert.size >= 10 && benutzt.size >= 10, `${definiert.size} Regel-Klassen, ${benutzt.size} benutzte Klassen`)
for (const [klasse, dateien] of benutzt) assert.ok(definiert.has(klasse), `${klasse} (${[...dateien].join(', ')}) hat keine Regel`)
for (const klasse of definiert) assert.ok(benutzt.has(klasse), `.${klasse} wird nirgends im Code benutzt`)
// die Nutzer sind genau die zwei weißen Dialoge
const nutzer = new Set([...benutzt.values()].flatMap((s) => [...s]))
assert.deepEqual([...nutzer].sort(), ['components/workers/ProspectPanel.jsx', 'components/workers/WorkersSettingsDialog.jsx'])
})
test('workers.css: die globale Regel .page section + section (36 px) verlängert den Abstand der Panel-Abschnitte nicht (W4)', () => {
const global = readFileSync(new URL('../../src/styles/global.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const global = stripped('global.css')
const css = stripped('workers.css')
assert.match(global, /\.page section \+ section\s*\{\s*margin-top:\s*36px/) // die Regel, gegen die das Panel sich behaupten muss: Spezifität (0,1,2)
const m = css.match(/(\.wk-prospect [^{}]*\.wk-prospect-section \+ \.wk-prospect-section)\s*\{([^}]*)\}/)
assert.ok(m, 'Regel für zwei benachbarte Abschnitte im Panel')
@@ -38,60 +69,13 @@ test('workers.css: die globale Regel .page section + section (36 px) verlängert
assert.doesNotMatch(css.match(/\.wk-prospect-section\s*\{([^}]*)\}/)[1], /margin-top/)
})
// ---- Bot-Leads (Endprüfung P6: C5, W1, W2) ----
const stripped = (file) => readFileSync(new URL('../../src/styles/' + file, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Rumpf der Regel mit genau diesem Selektor am Anfang der Regel (weder Teil eines längeren Selektors noch einer Gruppe mit Komma)
const body = (css, selector) => css.match(new RegExp(`(?:^|[{}])\\s*${escapeRe(selector)}\\s*\\{([^}]*)\\}`))?.[1] ?? null
const decl = (rule, prop) => rule?.match(new RegExp(`(?:^|[;\\s])${prop}\\s*:\\s*([^;]+)`))?.[1].trim() ?? null
// Spezifität (ids, Klassen/Attribute/Pseudoklassen, Elemente); Vergleich von links nach rechts
const specificity = (selector) => {
const s = selector.replace(/\[[^\]]*\]/g, '.a')
const ids = (s.match(/#[\w-]+/g) || []).length
const classes = (s.match(/\.[\w-]+|:(?!:)[\w-]+/g) || []).length
const elements = (s.replace(/\.[\w-]+|#[\w-]+|:+[\w-]+/g, ' ').match(/(?:^|[\s>+~])[a-z][\w-]*/gi) || []).length
return [ids, classes, elements]
}
const beats = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]; return false }
const luminance = (hex) => {
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
const contrast = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05) }
const token = (name) => stripped('global.css').match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`))?.[1]
test('workers.css: gekürzte Zeilen der Bot-Leads bleiben lesbar (Text >= 4,5:1 auf dem Seiten- und dem Zeilengrund), grau über --text-2 statt --text-3 (C5)', () => {
const css = stripped('workers.css')
const rule = body(css, '.wk-bl-cut td')
assert.ok(rule, 'Regel .wk-bl-cut td')
assert.equal(decl(rule, 'color'), 'var(--text-2)')
assert.doesNotMatch(css, /\.wk-bl-cut[^{]*\{[^}]*--text-3/) // keine Regel der gekürzten Zeile greift zu --text-3
for (const ground of ['--bg', '--bg-subtle']) {
const ratio = contrast(token('--text-2'), token(ground))
assert.ok(ratio >= 4.5, `${ground}: ${ratio.toFixed(2)}:1`)
}
})
test('workers.css: am Handy sind die Blätterknöpfe, „Alle anzeigen“ und „Erneut versuchen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => {
const css = stripped('workers.css')
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn', '.wk-bl-retry .btn']) {
assert.equal(decl(body(phone, selector), 'min-height'), '40px', selector)
// min-height schlägt height: 32px der globalen .btn-Regel, und die Spezifität (0,2,0) schlägt .btn (0,1,0): kein !important nötig
assert.ok(beats(specificity(selector), specificity('.btn')), selector)
assert.doesNotMatch(body(phone, selector), /!important/)
}
assert.equal(decl(body(stripped('global.css'), '.btn'), 'height'), '32px') // das, wogegen die Knöpfe sich behaupten
assert.equal(body(css, '.btn'), null) // keine globale Änderung aus der Hustler-Datei
})
test('workers.css: der Ort in der Bot-Leads-Tabelle bricht nicht mitten im Wort (break-word schlägt die globale Regel mit anywhere) (W2)', () => {
test('workers.css: Einstellungen-Dialog behält Trennlinien und den roten Block „Versand gestoppt“ (Tokens aus global.css)', () => {
const css = stripped('workers.css')
const global = stripped('global.css')
const wanted = '.wk-bl .wk-bl-table td .cell-sub'
const globalSelector = '.table td .cell-sub'
assert.equal(decl(body(global, globalSelector), 'overflow-wrap'), 'anywhere') // die globale Regel, die der Bot-Leads-Ort nicht erben soll
assert.equal(decl(body(css, wanted), 'overflow-wrap'), 'break-word')
// streng größer, nicht gleich: bei Gleichstand entschiede die Reihenfolge der Stylesheets im Bündel
assert.ok(beats(specificity(wanted), specificity(globalSelector)), `${wanted} ${specificity(wanted)} gegen ${specificity(globalSelector)}`)
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
assert.match(rule('.wk-set-group'), /border-top:\s*1px solid var\(--line\)/)
assert.match(rule('.wk-halt'), /border-top:\s*1px solid var\(--danger\)/); assert.match(rule('.wk-halt'), /border-bottom:\s*1px solid var\(--danger\)/)
assert.match(rule('.wk-halt .wk-set-title'), /color:\s*var\(--red-fg\)/)
// jedes verwendete Token gibt es in global.css
for (const m of css.matchAll(/var\((--[\w-]+)\)/g)) assert.match(global, new RegExp(`${m[1]}\\s*:`), m[1])
})