import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { EN } from './en'; const SRC = join(import.meta.dirname, '..'); /** Alle Quelldateien (ohne Tests) unter src/ als [Pfad relativ zu src, Text]. */ function sources(dir = SRC): Array<[string, string]> { const out: Array<[string, string]> = []; for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) out.push(...sources(path)); else if (/\.ts$/.test(name) && !/\.test\.ts$/.test(name)) out.push([relative(SRC, path), readFileSync(path, 'utf8')]); } return out; } interface Found { /** Die deutschen Texte aus `t('…')` und `msg('…')` (Literale in '…', "…" oder `…` ohne ${…}). */ texts: string[]; /** Aufrufe mit einem Template-Literal samt ${…}: der Text steht nicht fest und lässt sich nicht nachschlagen. */ dynamic: string[]; } /** * Sammelt die Texte aller freistehenden Aufrufe `t(…)` und `msg(…)`. Gelesen wird der Syntaxbaum, daher zählen * Kommentare, Zeichenketten und Methodenaufrufe wie `x.t(…)` nicht. Ein Argument, das kein Literal ist * (etwa eine Konstante `t(TITEL)`), wird nicht gesammelt: seinen Text trägt das `msg('…')` der Konstante. */ function findTexts(code: string): Found { const found: Found = { texts: [], dynamic: [] }; const file = ts.createSourceFile('quelle.ts', code, ts.ScriptTarget.Latest); const visit = (node: ts.Node): void => { const isTranslation = ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ['t', 'msg'].includes(node.expression.text); if (isTranslation) { const arg = node.arguments[0]; if (arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) found.texts.push(arg.text); else if (arg && ts.isTemplateExpression(arg)) found.dynamic.push(arg.getText(file)); } ts.forEachChild(node, visit); }; visit(file); return found; } /** Die Platzhalter `{name}` eines Textes, ohne Doppelte und sortiert. */ const placeholders = (text: string): string[] => [...new Set(text.match(/\{\w+\}/g) ?? [])].sort(); // Text → Dateien, in denen er steht; dazu alle dynamischen Aufrufe als „Datei: Quelltext“. const used = new Map(); const dynamic: string[] = []; for (const [path, code] of sources()) { const found = findTexts(code); for (const text of found.texts) used.set(text, [...(used.get(text) ?? []), path]); dynamic.push(...found.dynamic.map((call) => `${path}: ${call}`)); } describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => { it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => { expect(used.size).toBeGreaterThan(10); expect(used.has('Gestalten')).toBe(true); }); it('jeder Text aus t()/msg() hat eine (nicht leere) englische Übersetzung', () => { const translated = (text: string) => Object.hasOwn(EN, text) && EN[text].trim() !== ''; const missing = [...used].filter(([text]) => !translated(text)).map(([text, files]) => `${files[0]}: ${text}`); expect(missing).toEqual([]); }); it('jeder Eintrag in en.ts wird in t()/msg() benutzt', () => { expect(Object.keys(EN).filter((text) => !used.has(text))).toEqual([]); }); it('Platzhalter {…} stehen in Text und Übersetzung gleich', () => { const mismatched = Object.entries(EN) .filter(([text, english]) => placeholders(text).join() !== placeholders(english).join()) .map(([text]) => text); expect(mismatched).toEqual([]); }); it('kein t()/msg() mit Template-Literal und ${…}', () => { expect(dynamic).toEqual([]); }); }); describe('Scanner für t() und msg()', () => { it('findet Literale in \'…\', "…" und `…` ohne ${…}, jeweils das erste Argument', () => { const code = [ "const a = t('Eins');", 'const b = msg("Zwei");', 'const c = t(`Drei`);', "const d = t('Vier {n}', { n: 4 });", "const e = t('Mit \\'Escape\\'');", ].join('\n'); expect(findTexts(code)).toEqual({ texts: ['Eins', 'Zwei', 'Drei', 'Vier {n}', "Mit 'Escape'"], dynamic: [] }); }); it('meldet Template-Literale mit ${…} als dynamisch', () => { const found = findTexts('const a = t(`Hallo ${name}`); const b = msg(`x${1}`);'); expect(found.texts).toEqual([]); expect(found.dynamic).toEqual(['`Hallo ${name}`', '`x${1}`']); }); it('ignoriert Kommentare, Zeichenketten, Methodenaufrufe, ähnliche Namen und Konstanten', () => { const code = [ "// t('Kommentar')", "/* msg('Block') */", "const s = \"t('in Zeichenkette')\";", "const m = obj.t('Methode');", "const f = format('Name') + git('x') + t_('y') + $t('z');", 'const k = t(TITEL) + t(LABELS[key]);', 'const leer = t();', ].join('\n'); expect(findTexts(code)).toEqual({ texts: [], dynamic: [] }); }); it('findet verschachtelte Aufrufe', () => { const code = "el('a', { title: t('Innen') }, t('Außen'), cond ? t('Wenn') : msg('Sonst'));"; expect(findTexts(code).texts).toEqual(['Innen', 'Außen', 'Wenn', 'Sonst']); }); });