Files
tickte-system/tests/unit/workersComponents.test.mjs
T
ClaudeandClaude Sonnet 5.5 ba007a325a 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>
2026-10-22 09:09:37 +00:00

167 lines
9.9 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Regeln für alle React-Teile der Hustler-Seite, geprüft am Quelltext (Muster adminRoute.test.mjs): src/components/workers/** mit hochhaus/, pult/ und keller/.
// Das Verhalten der einzelnen Bildschirme prüfen die Komponententests (hustlerKopf, hustlerDialog, hustlerHochhausWelt, hustlerSeite, hustlerPult, hustlerKeller ...).
// Seit T11 gibt es die alte helle Oberfläche und die Kanvas-Engine nicht mehr; ihre Zusicherungen sind in diese Tests gewandert (Tabelle im Übergabebericht von T11).
const COMPONENTS = fileURLToPath(new URL('../../src/components/workers/', import.meta.url))
const SRC = fileURLToPath(new URL('../../src/', import.meta.url))
const read = (p) => readFileSync(path.join(COMPONENTS, p), 'utf8')
const readSrc = (p) => readFileSync(path.join(SRC, p), 'utf8')
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
const komponenten = () => walk(COMPONENTS).filter((f) => /\.(jsx?|mjs)$/.test(f))
const rel = (f) => path.relative(COMPONENTS, f)
// Kommentare weg: Block- und Zeilenkommentare (// nur am Zeilenanfang oder nach Leerraum, damit https:// in Zeichenketten bleibt)
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
// Öffnende Tags `<name …>` samt Attributen. Klammern {…} und Zeichenketten werden übersprungen, damit das `=>` eines onClick das Tag nicht beendet.
function oeffnendeTags(src, name) {
const tags = []
const re = new RegExp(`<${name}(?=[\\s/>])`, 'g')
for (let m; (m = re.exec(src)) !== null;) {
let tiefe = 0
let quote = ''
let i = m.index + m[0].length
for (; i < src.length; i++) {
const c = src[i]
if (quote) {
if (c === '\\') i += 1
else if (c === quote) quote = ''
continue
}
if (c === '"' || c === "'" || c === '`') quote = c
else if (c === '{') tiefe += 1
else if (c === '}') tiefe -= 1
else if (c === '>' && tiefe === 0) break
}
tags.push(src.slice(m.index, i + 1))
}
return tags
}
// Wert eines Attributs im Tag: Zeichenkette (mit Anführungszeichen) oder der Ausdruck in {…} ohne Klammern; null, wenn es das Attribut nicht gibt
function attribut(tag, name) {
const m = new RegExp(`(?<![\\w-])${name}=`).exec(tag)
if (!m) return null
const start = m.index + m[0].length
if (tag[start] === '"') return tag.slice(start, tag.indexOf('"', start + 1) + 1)
if (tag[start] !== '{') return null
let tiefe = 0
for (let i = start; i < tag.length; i++) {
if (tag[i] === '{') tiefe += 1
else if (tag[i] === '}') {
tiefe -= 1
if (tiefe === 0) return tag.slice(start + 1, i).trim()
}
}
return null
}
test('kein innerHTML und keine Kanvas-Zeichnung in den Komponenten unter src/components/workers/** (rekursiv, Kommentare ignoriert)', () => {
const dateien = komponenten()
for (const dir of ['hochhaus', 'pult', 'keller']) assert.ok(dateien.some((f) => rel(f).startsWith(dir + path.sep)), `${dir}/ wird mitgelesen`)
assert.ok(dateien.length >= 15, `nur ${dateien.length} Dateien gelesen`)
for (const f of dateien) {
const code = ohneKommentare(readFileSync(f, 'utf8'))
assert.doesNotMatch(code, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write/, rel(f))
assert.doesNotMatch(code, /<canvas\b|getContext\s*\(/, `${rel(f)}: die Pixelwelt ist DOM + CSS, kein Kanvas`)
}
})
// Frühere Prüfung des Boards: Rechte und Nutzer kommen als Props von der Seite (isAdmin, me), die Bildschirme greifen nie selbst auf Anmeldung oder Appwrite zu.
test('Komponenten ohne Anmeldungsbezug: kein useAuth und kein appwrite unter src/components/workers/**', () => {
for (const f of komponenten()) assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), /useAuth|appwrite/i, rel(f))
})
test('die alte Oberfläche und die Kanvas-Engine sind weg, und nichts unter src/ verweist noch darauf (T11)', () => {
for (const alt of ['engine', 'WorkersStage.jsx', 'WorkersBoard.jsx', 'WorkerCard.jsx', 'WorkerList.jsx', 'WorkersKpis.jsx']) assert.equal(existsSync(path.join(COMPONENTS, alt)), false, alt)
const verweis = /['"][^'"\n]*(?:\/engine\/[\w.-]+|\/(?:WorkersStage|WorkersBoard|WorkerCard|WorkerList|WorkersKpis))(?:\.jsx?)?['"]/
let gelesen = 0
for (const f of walk(SRC).filter((x) => /\.jsx?$/.test(x))) {
gelesen += 1
assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), verweis, path.relative(SRC, f))
}
assert.ok(gelesen > 100, `nur ${gelesen} Dateien gelesen`)
})
// Frühere Prüfung der Bühne und des Boards (PF-08): der WOMS-Button hat keinen Standard-type, ein <button> ohne type schickt in einem Formular ab.
test('jeder Knopf (<button>, <Button>) hat ein ausdrückliches type="button"; „submit“ gibt es nur im Formular der Einstellungen', () => {
let anzahl = 0
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
const code = ohneKommentare(readFileSync(f, 'utf8'))
for (const name of ['button', 'Button']) {
const tags = oeffnendeTags(code, name)
assert.equal(tags.length, (code.match(new RegExp(`<${name}(?=[\\s/>])`, 'g')) || []).length, `${rel(f)}: ${name}-Tags vollständig gelesen`)
for (const tag of tags) {
anzahl += 1
const erlaubt = rel(f) === 'WorkersSettingsDialog.jsx' ? /\btype="(?:button|submit)"/ : /\btype="button"/
assert.match(tag, erlaubt, `${rel(f)}: ${tag.replace(/\s+/g, ' ').slice(0, 90)}`)
}
}
}
assert.ok(anzahl >= 35, `nur ${anzahl} Knöpfe gelesen`)
// „submit“ kommt genau einmal vor: „Speichern“ im Formular
assert.equal((ohneKommentare(read('WorkersSettingsDialog.jsx')).match(/type="submit"/g) || []).length, 1)
})
// Frühere Prüfung der Worker-Karte (Review Focus 5): Ziele von Links kommen nur aus geprüften Feldern, externe öffnen mit noopener.
test('Links: jedes Ziel (href, to) kommt aus einem geprüften Feld oder ist fest; externe Ziele öffnen im neuen Tab mit rel="noopener noreferrer"', () => {
// safeLink (Spur im Dialog) · sourceHref/previewHref (workersPult, botleadsView, workersProspect über safeExternalUrl/safeHref/previewUrl) · mailtoHref · feste Pfade der Etagen
const ERLAUBT = new Set(['safe.href', 'row.sourceHref', 'row.previewHref', 'view.sourceHref', 'e.sourceHref', 'mail', '`/hustler?tab=${link.tab}`'])
let links = 0
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
const code = ohneKommentare(readFileSync(f, 'utf8'))
for (const [name, ziel] of [['a', 'href'], ['Link', 'to']]) {
for (const tag of oeffnendeTags(code, name)) {
links += 1
const wert = attribut(tag, ziel)
assert.ok(wert !== null, `${rel(f)}: ${name} ohne ${ziel}: ${tag.slice(0, 80)}`)
assert.ok(wert.startsWith('"') || ERLAUBT.has(wert), `${rel(f)}: ${ziel}={${wert}} ist kein geprüftes Ziel`)
if (attribut(tag, 'target') === '"_blank"') assert.equal(attribut(tag, 'rel'), '"noopener noreferrer"', `${rel(f)}: ${tag.slice(0, 80)}`)
if (wert.startsWith('"')) assert.doesNotMatch(wert, /javascript:|data:/i)
}
}
}
assert.ok(links >= 8, `nur ${links} Links gelesen`)
// die Felder selbst entstehen in den Ansichten nur über die Prüfer
assert.match(readSrc('lib/workersPult.js'), /sourceHref: freigabe \? null : safeExternalUrl\(item\.sourceUrl\)/)
assert.match(readSrc('lib/workersPult.js'), /previewHref: freigabe \? previewUrl\(item\.sourceUrl\) : null/)
assert.match(readSrc('lib/workersProspect.js'), /sourceHref: safeExternalUrl\(prospect\.sourceUrl\)/)
assert.match(readSrc('lib/botleadsView.js'), /sourceHref: safeHref\(it\.sourceUrl\)/)
assert.match(readSrc('lib/workersPult.js'), /return `mailto:\$\{to\}/)
// die Etagen-Links des Hochhauses sind feste Pfade
assert.match(read('hochhaus/HochhausWelt.jsx'), /tab: 'freigabe'[\s\S]*tab: 'botleads'/)
})
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
const s = read('WorkersSettingsDialog.jsx')
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
assert.doesNotMatch(s, /paused/)
for (const t of ['Hustler-Einstellungen', 'Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t)
assert.match(s, /disabled=\{/)
// H4/A21: Block „Versand gestoppt“ mit Freigabe, außerhalb des Formular-Patches
assert.match(s, /sendingHalted/); assert.ok(s.includes('Versand gestoppt')); assert.ok(s.includes('Versand wieder freigeben'))
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
})
// ---- P3-T21: Abmelden setzt das JWT zurück ----
test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-Cache und Sperre zurück (P3-T21)', () => {
const auth = readSrc('context/AuthContext.jsx')
assert.match(auth, /import \{ resetWorkersJwt \} from '\.\.\/lib\/workersClient'/)
const start = auth.indexOf('async function logout()')
assert.ok(start > 0)
const logout = auth.slice(start, auth.indexOf('const isAdmin', start))
assert.match(logout, /resetWorkersJwt\(\)/)
// im finally: auch ein fehlgeschlagenes deleteSessions lässt das JWT des alten Nutzers nicht im Cache
assert.ok(logout.indexOf('finally') > 0 && logout.indexOf('resetWorkersJwt()') > logout.indexOf('finally'))
const client = readSrc('lib/workersClient.js')
assert.match(client, /export function resetWorkersJwt\(\)/)
const fn = client.slice(client.indexOf('export function resetWorkersJwt()'))
assert.match(fn, /jwtCache\.invalidate\(\)/); assert.match(fn, /workersApi\.resetAuth\(\)/)
})