test(frontend): Wächter gegen deutschen Oberflächentext – und fünf Reste

Konsolidierung: Bestandsaufnahme auf den Stand vom 24.09. gebracht (Punkte 1,
2, 2b und die Tests für documents/tags sind erledigt; sharing, rescan, audit
und thumbnails bleiben offen; jsdom ist seit dem 18.09. drin).

Beim Nachsehen fielen fünf Übersetzungsreste auf: label="Ganztägig",
aria-label="Kalendermenü", label="Bearbeiten" und zweimal "Löschen". Alle in
**doppelt** gequoteten JSX-Attributen – die Ersetzungsliste hatte nur die
einfach gequoteten, und das damalige Prüfskript hielt Einzelwörter ohne
Leerzeichen für Bezeichner.

Die Prüfung steht deshalb jetzt als Test, nicht als Skript. Sie läuft bei
jedem npm test mit.

Der erste Wächter taugte nichts, und das kam nur durch die Mutation heraus: Er
prüfte allein auf Umlaute und liess "Papierkorb ist leer" durch. Wer einen
Wächter baut, muss ihn einmal angreifen. Jetzt Umlaute plus eine Wortliste mit
Wortgrenzen, nachgewiesen an vier Mutationen.

Kommentare werden vorher entfernt – mit einem kleinen Durchlauf statt
zeilenweise: Blockkommentare haben Fortsetzungszeilen ohne führenden Stern,
die beim ersten Versuch fünf Fehlalarme erzeugten.

Ehrlich dazu: Das ist ein Netz, kein Beweis. Ein deutscher Satz ohne Umlaut
und ohne gelistetes Wort käme durch. Steht so im Test.

@types/node kam dazu, weil der Test den Quelltext von der Platte liest.

Geprüft: tsc sauber, 48 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün. Gebautes Bündel gegengeprüft – deutsche Texte weg,
englische da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-24 18:31:18 +02:00
1 parent e1259dd55c
commit a7528d7365
8 files changed
+261 -15

No files matched your search

+18
View File
@@ -19,6 +19,7 @@
"@tailwindcss/vite": "^4.3.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.6.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"jsdom": "^26.1.0",
@@ -2914,6 +2915,16 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="
},
"node_modules/@types/node": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -6371,6 +6382,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+1
View File
@@ -22,6 +22,7 @@
"@tailwindcss/vite": "^4.3.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.6.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"jsdom": "^26.1.0",
@@ -221,7 +221,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
)}
<div style={{ marginBottom: 14 }}>
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="Ganztägig" />
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="All day" />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
+4 -4
View File
@@ -582,7 +582,7 @@ export default function CalendarPage() {
<ConfirmDialog
title="Delete calendar"
message={`Permanently delete "${deleteCalendar.name}" and every event in it?`}
confirmLabel="Löschen" danger
confirmLabel="Delete" danger
onConfirm={confirmDeleteCalendar}
onCancel={() => setDeleteCalendar(null)}
/>
@@ -642,7 +642,7 @@ function CalMenu({ onRename, onExport, onImport, onDelete, onShare, own, canWrit
}, [open])
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button onClick={() => setOpen(o => !o)} aria-label="Kalendermenü" style={{
<button onClick={() => setOpen(o => !o)} aria-label="Calendar menu" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: '0 2px',
}}>⋮</button>
{open && (
@@ -651,11 +651,11 @@ function CalMenu({ onRename, onExport, onImport, onDelete, onShare, own, canWrit
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden',
}}>
{own && <MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />}
{own && <MenuRow icon={<RenameIcon />} label="Edit" onClick={() => { setOpen(false); onRename() }} />}
{own && <MenuRow icon={<PeopleIcon />} label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />}
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
{canWrite && <MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />}
{own && <MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />}
{own && <MenuRow icon={<TrashIcon />} label="Delete" danger onClick={() => { setOpen(false); onDelete() }} />}
</div>
)}
</div>
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
/**
* Die Oberfläche spricht Englisch – dieser Test hält sie dabei.
*
* Am 18.09.2026 wurde der Kalender übersetzt, 80 Stellen. Sechs Tage später
* fielen beim Nachsehen **vier Reste** auf: `label="Ganztägig"`,
* `aria-label="Kalendermenü"`, zweimal `confirmLabel="Löschen"`. Alle in
* doppelt gequoteten JSX-Attributen – meine Ersetzungsliste hatte nur die
* einfach gequoteten, und mein damaliges Prüfskript hielt Einzelwörter ohne
* Leerzeichen für Bezeichner und übersprang sie.
*
* Genau deshalb steht die Prüfung jetzt hier statt in einem Skript, das man
* einmal laufen lässt: Sie läuft bei jedem `npm test` mit.
*
* **Kommentare bleiben deutsch** und werden übersprungen. Erkannt wird nur,
* was in Anführungszeichen oder zwischen Tags steht.
*/
const hier = dirname(fileURLToPath(import.meta.url))
/**
* Woran man Deutsch erkennt.
*
* **Umlaute allein genügen nicht.** Die erste Fassung dieses Tests prüfte nur
* darauf – und liess „Papierkorb ist leer" anstandslos durch. Aufgefallen ist
* das, weil der Test mutiert wurde: Wer einen Wächter baut, muss ihn einmal
* angreifen, sonst bewacht er nichts.
*
* Deshalb zusätzlich eine Liste häufiger deutscher Wörter, mit Wortgrenzen
* geprüft – sonst schlüge „ist" in „list" an. Bewusst **nicht** darin: Wörter,
* die es im Englischen genauso gibt (Name, Start, Status, Import, Server) und
* solche, die zu leicht danebengreifen (man, war).
*
* **Das ist ein Netz, kein Beweis.** Ein deutscher Satz ohne Umlaut und ohne
* eines dieser Wörter käme durch – „Datum bereits vergeben" etwa. Der Test
* fängt, was erfahrungsgemäß passiert (ein vergessenes Attribut beim
* Übersetzen), nicht jeden denkbaren Fall. Wer hier etwas ergänzt, weil es
* einmal durchgerutscht ist, tut genau das Richtige.
*/
const umlaute = /[äöüßÄÖÜ]/
const deutscheWoerter = new RegExp(
'\\b(' + [
// Funktionswörter
'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen', 'einem',
'und', 'oder', 'nicht', 'kein', 'keine', 'keinen', 'ist', 'sind', 'wird',
'werden', 'wurde', 'mit', 'vom', 'zum', 'zur', 'aus', 'bei', 'nach',
'durch', 'ohne', 'gegen', 'noch', 'schon', 'auch', 'nur', 'sehr', 'mehr',
'alle', 'alles', 'sich', 'wenn', 'dann', 'hier', 'wie',
// Wörter der Oberfläche
'Datei', 'Dateien', 'Ordner', 'Termin', 'Termine', 'Kalender',
'Papierkorb', 'Einstellungen', 'Freigabe', 'Etikett', 'Absender',
'Speichern', 'Abbrechen', 'Bearbeiten', 'Hochladen', 'Herunterladen',
'Suchen', 'Anmelden', 'Abmelden', 'Benutzer', 'Nutzer', 'Passwort',
'Fehler', 'Hinweis', 'Warnung', 'leer', 'neu',
// Wörter, an denen typische Meldungen hängen – auch in der
// umlautfreien Schreibweise, die beim Tippen gern entsteht.
'fehlgeschlagen', 'erfolgreich', 'gespeichert', 'geladen', 'konnte',
'bitte', 'entfernen', 'aendern', 'hinzufuegen', 'Loeschen', 'Schliessen',
'Groesse', 'Aendern',
].join('|') + ')\\b', 'i')
function istDeutsch(text: string): boolean {
return umlaute.test(text) || deutscheWoerter.test(text)
}
/** Einzelne Stellen, an denen Deutsch bleiben darf. Bisher keine. */
const erlaubt: string[] = []
function quellen(ordner: string): string[] {
return readdirSync(ordner).flatMap(name => {
const pfad = join(ordner, name)
if (statSync(pfad).isDirectory()) return quellen(pfad)
if (!/\.tsx?$/.test(name) || name.includes('.test.')) return []
return [pfad]
})
}
/**
* Kommentare unkenntlich machen, Zeilenumbrüche behalten.
*
* Zeilenweise „fängt die Zeile mit // an?" reicht nicht: Ein Blockkommentar
* hat Fortsetzungszeilen ohne führenden Stern, und genau die wurden als
* Zeichenketten gelesen – fünf Fehlalarme beim ersten Versuch. Also einmal
* richtig durchgehen und dabei mitzählen, ob man gerade in einem Kommentar
* oder in einer Zeichenkette steht.
*
* Ersetzt wird durch Leerzeichen statt zu löschen, damit Zeilennummern und
* Spalten stimmen bleiben.
*/
function ohneKommentare(quelle: string): string {
const out: string[] = []
let i = 0
let stringZeichen = ''
while (i < quelle.length) {
const c = quelle[i]
const naechste = quelle[i + 1]
if (stringZeichen) {
if (c === '\\') { out.push(c, naechste ?? ''); i += 2; continue }
if (c === stringZeichen) stringZeichen = ''
out.push(c); i++; continue
}
if (c === '/' && naechste === '/') {
while (i < quelle.length && quelle[i] !== '\n') { out.push(' '); i++ }
continue
}
if (c === '/' && naechste === '*') {
while (i < quelle.length && !(quelle[i] === '*' && quelle[i + 1] === '/')) {
out.push(quelle[i] === '\n' ? '\n' : ' '); i++
}
out.push(' ', ' '); i += 2; continue
}
if (c === "'" || c === '"' || c === '`') stringZeichen = c
out.push(c); i++
}
return out.join('')
}
function deutscheTexte(pfad: string): string[] {
const treffer: string[] = []
ohneKommentare(readFileSync(pfad, 'utf8')).split('\n').forEach((zeile, i) => {
for (const m of zeile.matchAll(/(['"])((?:(?!\1)[^\\\n])*)\1/g)) {
if (istDeutsch(m[2]) && !erlaubt.includes(m[2])) {
treffer.push(`${pfad}:${i + 1} ${m[0]}`)
}
}
for (const m of zeile.matchAll(/>\s*([^<>{}\n]*?)\s*</g)) {
if (m[1] && istDeutsch(m[1]) && !erlaubt.includes(m[1])) {
treffer.push(`${pfad}:${i + 1} >${m[1]}<`)
}
}
})
return treffer
}
describe('Sprache der Oberfläche', () => {
it('enthält keinen deutschen Text mehr', () => {
const gefunden = quellen(hier).flatMap(deutscheTexte)
expect(gefunden, `Deutscher Oberflächentext:\n${gefunden.join('\n')}`).toEqual([])
})
it('erkennt Deutsch überhaupt – auch in Attributen', () => {
// Gegenprobe: Ohne sie wäre der Test oben grün, wenn das Muster kaputt
// ist. Genau dieser Fehler ist am 18.09. passiert.
const probe = '/tmp/aether-sprachprobe.tsx'
const inhalt = [
'// Ein Kommentar mit Umlaut: Größe – darf nicht auffallen',
'const a = <Toggle label="Ganztägig" />',
"const b = 'Löschen'",
'const c = <p>Keine Termine für heute</p>',
].join('\n')
writeFileSync(probe, inhalt)
const gefunden = deutscheTexte(probe)
expect(gefunden).toHaveLength(3)
expect(gefunden.join(' ')).toContain('Ganztägig')
expect(gefunden.join(' ')).not.toContain('Kommentar')
})
})
+3 -1
View File
@@ -4,7 +4,9 @@
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
// Vites Zusaetze zum Import: ?url, ?raw und der Seiteneffekt-Import von CSS.
"types": ["vite/client"],
// "node" kam am 24.09.2026 dazu: sprache.test.ts liest den Quelltext von
// der Platte, um deutschen Oberflaechentext zu finden.
"types": ["vite/client", "node"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",