import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' const root = process.cwd() function walk(dir: string, found: string[] = []): string[] { for (const name of readdirSync(dir)) { const path = join(dir, name) if (statSync(path).isDirectory()) { walk(path, found) continue } if (name.endsWith('.tsx') || name.endsWith('.ts')) { found.push(path) } } return found } const sources = walk(join(root, 'src')) function read(path: string): string { return readFileSync(path, 'utf8') } function offenders(test: (text: string, path: string) => boolean): string[] { return sources.filter(path => test(read(path), path)).map(path => path.replace(`${root}/`, '')) } const css = read(join(root, 'src/app/globals.css')) describe('Farbmodus', () => { it('setzt data-theme nicht fest ins Server-HTML, sonst überschreibt React die gespeicherte Wahl', () => { expect(offenders(text => /]*\sdata-theme=/u.test(text))).toEqual([]) }) it('definiert jede Farbe für beide Modi', () => { const block = (start: string): string => { const from = css.indexOf(start) return from === -1 ? '' : css.slice(from, css.indexOf('\n}', from)) } const names = (text: string): string[] => [...text.matchAll(/--color-([a-z0-9-]+):/gu)] .map(match => match[1]) .filter((name): name is string => name !== undefined) const light = names(block('@theme {')) const dark = new Set(names(block(':root[data-theme="dark"] {'))) const missing = light.filter(name => !dark.has(name) && !name.startsWith('shot')) expect(missing).toEqual([]) }) it('mischt Schatten nicht aus der Tintenfarbe, weil die im Dunkeln hell ist', () => { expect(offenders(text => /shadow-ink|shadow-\[.*var\(--color-ink/u.test(text))).toEqual([]) }) it('führt Schatten über ein Token, das den Modus kennt', () => { expect(css).toContain('--shadow-lift') expect(css.match(/--shadow-lift/gu)?.length ?? 0).toBeGreaterThanOrEqual(2) }) }) describe('Aufbau', () => { it('hält den Fuß unten, auch wenn die Seite kurz ist', () => { expect(read(join(root, 'src/components/layout/SiteFooter.tsx'))).toContain('mt-auto') expect(read(join(root, 'src/components/layout/AppShell.tsx'))).toContain('min-h-full') }) it('benutzt für alle Bereiche dieselbe Hülle', () => { const site = read(join(root, 'src/app/(site)/layout.tsx')) const admin = read(join(root, 'src/app/admin/layout.tsx')) expect(site).toContain('AppShell') expect(admin).toContain('AppShell') }) it('kennt genau zwei Breiten und keine erfundenen', () => { expect(css).toContain('--container-page') expect(css).toContain('--container-read') expect(offenders(text => /max-w-\[\d/u.test(text))).toEqual([]) }) }) describe('Schreibweisen', () => { it('benutzt keine Pixelangaben in Klassen', () => { expect(offenders(text => /(?:w|h|p|m|gap|text|top|left|right|bottom)-\[\d+px\]/u.test(text))).toEqual([]) }) it('lässt keine Kommentare im Quelltext', () => { expect(offenders((text, path) => !path.endsWith('.d.ts') && /^\s*\/\//mu.test(text))).toEqual([]) }) it('benutzt keine Gedankenstriche', () => { const files = [...sources, join(root, 'messages/de.json'), join(root, 'messages/en.json')] const hits = files.filter(path => /[–—]/u.test(read(path))).map(path => path.replace(`${root}/`, '')) expect(hits).toEqual([]) }) it('schreibt Umlaute aus', () => { const de = read(join(root, 'messages/de.json')) expect(de).not.toMatch(/\b(ueber|fuer|koennen|muessen|groesse|waehlen)\b/u) }) it('benutzt keine Emojis', () => { const files = [...sources, join(root, 'messages/de.json'), join(root, 'messages/en.json')] const hits = files.filter(path => /\p{Extended_Pictographic}/u.test(read(path))).map(path => path.replace(`${root}/`, '')) expect(hits).toEqual([]) }) }) describe('Bedienbarkeit', () => { it('führt jeden scrollbaren Bereich über den Scroller statt nativ', () => { expect(offenders(text => /overflow-(?:y|x)-(?:auto|scroll)/u.test(text))).toEqual([]) }) it('hält das globale Stylesheet klein', () => { expect(css.split('\n').length).toBeLessThanOrEqual(200) }) it('macht den Tastaturfokus sichtbar', () => { expect(css).toMatch(/:focus-visible/u) }) it('nimmt Rücksicht auf abgeschaltete Bewegung', () => { const moving = offenders(text => /animate-rise|transition-/u.test(text)) const careless = moving.filter(path => !/motion-reduce/u.test(read(join(root, path)))) expect(careless).toEqual([]) }) }) describe('Sprache der Oberfläche', () => { it('hält beide Sprachdateien auf denselben Schlüsseln', () => { const keys = (value: unknown, prefix = ''): string[] => { if (typeof value !== 'object' || value === null) { return [prefix] } return Object.entries(value as Record).flatMap(([key, inner]) => keys(inner, prefix === '' ? key : `${prefix}.${key}`)) } const de = keys(JSON.parse(read(join(root, 'messages/de.json')))).sort() const en = keys(JSON.parse(read(join(root, 'messages/en.json')))).sort() expect(de.filter(key => !en.includes(key))).toEqual([]) expect(en.filter(key => !de.includes(key))).toEqual([]) }) })