import { describe, expect, it } from 'vitest'; import de from './de.json'; import en from './en.json'; import { UMLAUT_ALLOWLIST, UMLAUT_REPLACEMENTS } from './umlaut-dictionary'; /** * umlaut-guard — regression guard for the de.json umlaut correction. * * This spec reads exclusively the parsed JSON content of `de.json` / * `en.json` and never greps repo-wide over source files. Grepping * source files would trigger on `umlaut-dictionary.ts` itself, since it * necessarily contains the wrong substitute spellings as its map keys * (e.g. `'fuer'`, `'loeschen'`). * * The second test below is the actual regression guard: it fails on any * NEWLY introduced substitute spelling (`ae`/`oe`/`ue`/`ss` token not on * the allowlist) without pinning today's wording — it pins vocabulary, * not phrasing. Fixing a failure is a one-line addition to either * `UMLAUT_REPLACEMENTS` (if it is a substitute spelling) or * `UMLAUT_ALLOWLIST` (if it is correct German), as instructed by the * assertion message itself. */ const UMLAUT_TOKEN_RE = /[A-Za-zÄÖÜäöüß]+/g; // Case-insensitive, sonst rutscht jede grossgeschriebene Ersatzschreibung durch: // "Aenderungen" beginnt mit "Ae", nicht mit "ae". Genau daran sind beim ersten // Durchgang "Aenderung", "Aenderungen" und "Oeffnen" vorbeigekommen — darunter // zwei gut sichtbare Schaltflaechen. const SUSPECT_RE = /(ae|oe|ue|ss)/i; /** Recursively flattens a nested message object into dot-joined leaf key paths. */ function flattenKeys(obj: unknown, prefix = ''): string[] { if (obj === null || typeof obj !== 'object') { return [prefix]; } return Object.entries(obj as Record).flatMap(([key, value]) => flattenKeys(value, prefix ? `${prefix}.${key}` : key), ); } /** * Walks every leaf string value in `obj`, skipping values that contain * `@` (e.g. the `testToPlaceholder` example email address), and invokes * `visit` with each whole-word token found plus its dot-joined key path. */ function walkTokens(obj: unknown, visit: (token: string, path: string) => void, prefix = ''): void { if (obj === null || typeof obj !== 'object') { return; } for (const [key, value] of Object.entries(obj as Record)) { const path = prefix ? `${prefix}.${key}` : key; if (typeof value === 'string') { if (value.includes('@')) { continue; } for (const token of value.match(UMLAUT_TOKEN_RE) ?? []) { visit(token, path); } } else if (value && typeof value === 'object') { walkTokens(value, visit, path); } } } describe('de.json umlaut regression guard', () => { it('contains no token from UMLAUT_REPLACEMENTS (known substitute spelling)', () => { const hits: string[] = []; walkTokens(de, (token, path) => { if (Object.prototype.hasOwnProperty.call(UMLAUT_REPLACEMENTS, token)) { hits.push(`${path}: "${token}" should be "${UMLAUT_REPLACEMENTS[token]}"`); } }); expect(hits, `Substitute spellings found in de.json:\n${hits.join('\n')}`).toEqual([]); }); it('flags any new ae/oe/ue/ss token not on UMLAUT_ALLOWLIST', () => { const allowlist = new Set(UMLAUT_ALLOWLIST); const hits: string[] = []; walkTokens(de, (token, path) => { if (SUSPECT_RE.test(token) && !allowlist.has(token)) { hits.push( `${path}: "${token}" is a new word not on UMLAUT_ALLOWLIST. ` + `Add it to UMLAUT_REPLACEMENTS in umlaut-dictionary.ts if it is a substitute ` + `spelling, or to UMLAUT_ALLOWLIST if it is already correct German.`, ); } }); expect(hits, `New ae/oe/ue/ss words found in de.json:\n${hits.join('\n')}`).toEqual([]); }); it('has an identical (recursively-flattened) key set in de and en', () => { const deKeys = flattenKeys(de).sort(); const enKeys = flattenKeys(en).sort(); const missingInEn = deKeys.filter((key) => !enKeys.includes(key)); const missingInDe = enKeys.filter((key) => !deKeys.includes(key)); expect(missingInEn, `Keys present in de.json but missing in en.json: ${missingInEn.join(', ')}`).toEqual([]); expect(missingInDe, `Keys present in en.json but missing in de.json: ${missingInDe.join(', ')}`).toEqual([]); expect(deKeys).toEqual(enKeys); }); });