Files
logbuch/tests/design/rules.test.ts
T
Matthias G a123e0bca2 Write text blocks with formatting
Tiptap replaces the plain textarea: bold, italic, subheading, lists and
links. Stored as Markdown so API clients keep reading and writing the
same field, rendered with react-markdown.
2026-08-01 12:46:34 +02:00

160 lines
5.1 KiB
TypeScript
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 { 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 => /<html[^>]*\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<string, unknown>).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([])
})
})