a123e0bca2
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.
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
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([])
|
||
})
|
||
})
|