Files
logbuch/tests/design/rules.test.ts
T
Matthias Giesselmann b90ff252d1 Add Logbuch: project update blog with admin, media and API
Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
2026-07-31 21:33:42 +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(150)
})
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([])
})
})