import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DYNAMIC_ALLOWED, MAX_IMPLICIT_MATCHES, countMatches, evaluate, firstArgument, interpolationResults, readEnKeys, scanSource, scanTree, templatePattern, } from '../../../scripts/i18n-keys.mjs'; // FE-I18N-KEYS-001 to FE-I18N-KEYS-015: the client key check (scripts/i18n-keys.mjs). const EN = new Set([ 'budget.title', 'places.count', 'places.count.one', 'trips.total.other', 'trips.total.one', 'costs.filter.all', ]); describe('i18n key check', () => { it('FE-I18N-KEYS-001: finds literal keys in t, tHtml, tr, TransHtml, translateApiError and *Key props', () => { const { literal } = scanSource( [ "t('budget.title')", 'props.t("places.count", { count })', "tHtml('a.html')", "tr('pdf.mapTitle')", '', "translateApiError(t, err, 'files.uploadError')", "const tabs = [{ labelKey: 'tabs.one' }]", '', ].join('\n') ); expect(literal.map((l) => l.key)).toEqual([ 'budget.title', 'places.count', 'a.html', 'pdf.mapTitle', 'journey.text', 'files.uploadError', 'tabs.one', 'hint.title', ]); }); it('FE-I18N-KEYS-002: checks both sides of a conditional key and ignores later arguments', () => { const { literal } = scanSource("t(open ? 'a.open' : 'a.closed', { name: 'x.y' })"); expect(literal.map((l) => l.key)).toEqual(['a.open', 'a.closed']); expect(firstArgument("t(fn(a, b) ? 'x.y' : 'z.w', 1)", 1)).toBe("fn(a, b) ? 'x.y' : 'z.w'"); }); it('FE-I18N-KEYS-003: reads templates, appended prefixes and key builders as patterns', () => { const { literal, dynamic } = scanSource( [ 't(`costs.filter.${f}`)', "t('costs.filter.' + f)", 't(`plain.key`)', 'export const guideKey = (id: string): string =>\n `help.guide.${id}.title`', 'const cacheKey = (id: number): string => `${id} x`', "const other = notATranslation('x.y')", ].join('\n') ); expect(literal.map((l) => l.key)).toEqual(['plain.key']); expect(dynamic.map((d) => d.template)).toEqual([ 'costs.filter.${f}', 'costs.filter.${…}', 'help.guide.${id}.title', ]); expect(templatePattern('costs.filter.${f}')!.test('costs.filter.all')).toBe(true); expect(templatePattern('${opt.label}Hint')).toBeNull(); // An interpolation is one key segment: costs.filter.${f} does not reach a nested costs.filter.x.y. expect(templatePattern('costs.filter.${f}')!.test('costs.filter.x.y')).toBe(false); }); it('FE-I18N-KEYS-004: fails a literal key en lacks and accepts a plural group by its general form', () => { const scan = scanSource("t('budget.addCategory'); t('places.count', { count }); t('trips.total', { count })"); const { missing } = evaluate(scan, EN, []); expect(missing.map((m) => m.key)).toEqual(['budget.addCategory']); }); it('FE-I18N-KEYS-005: fails a template no en key matches unless it is allowed with the keys it resolves to', () => { const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`); t(`${opt.label}Hint`)'); expect(evaluate(scan, EN, []).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}', '${opt.label}Hint']); const allowed = [{ file: '', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }]; const result = evaluate(scan, EN, allowed); expect(result.unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']); expect(result.stale).toEqual([]); }); it('FE-I18N-KEYS-006: refuses an allow-list entry nothing uses or whose keys en lacks', () => { const scan = scanSource('t(`${opt.label}Hint`)'); const unused = [{ file: '', template: '${gone}', because: 'test', resolves: ['budget.title'] }]; expect(evaluate(scan, EN, unused).stale).toEqual(unused); const wrong = [{ file: '', template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }]; expect(evaluate(scan, EN, wrong).stale).toEqual(wrong); const elsewhere = [ { file: 'other.tsx', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }, ]; expect(evaluate(scan, EN, elsewhere).stale).toEqual(elsewhere); expect(evaluate(scan, EN, elsewhere).unmatched.map((u) => u.template)).toEqual(['${opt.label}Hint']); }); it('FE-I18N-KEYS-007: reports en keys neither a literal nor a pattern reaches', () => { const scan = scanSource("t('budget.title'); t('places.count', { count }); t(`costs.filter.${f}`)"); expect(evaluate(scan, EN, []).unused).toEqual(['trips.total.other', 'trips.total.one']); }); it('FE-I18N-KEYS-008: every allow-list entry names its file and a reason, and a prefix-less one its keys', () => { for (const entry of DYNAMIC_ALLOWED) { expect(entry.file).toMatch(/\.tsx?$/); expect(entry.because.length).toBeGreaterThan(20); if (templatePattern(entry.template) === null) expect('resolves' in entry && entry.resolves.length).toBeGreaterThan(0); } }); it('FE-I18N-KEYS-009: fails closed on a missing source tree or one without any key', () => { expect(() => scanTree(join(tmpdir(), 'trek-i18n-keys-does-not-exist'))).toThrow(/does not exist/); const empty = mkdtempSync(join(tmpdir(), 'trek-i18n-keys-')); try { expect(() => scanTree(empty)).toThrow(/scanner is broken/); } finally { rmSync(empty, { recursive: true, force: true }); } }); it('FE-I18N-KEYS-010: the client source names no key en lacks today', async () => { const enKeys = await readEnKeys(); const { missing, unmatched, broad, stale } = evaluate(scanTree(), enKeys); expect(missing).toEqual([]); expect(unmatched).toEqual([]); expect(broad).toEqual([]); expect(stale).toEqual([]); }); // A table of en keys wider than the implicit bound: wide.k0 to wide.k. const WIDE = new Set([...EN, ...Array.from({ length: MAX_IMPLICIT_MATCHES + 1 }, (_, i) => `wide.k${i}`)]); it('FE-I18N-KEYS-011: counts a plural group once when measuring what a template reaches', () => { expect(countMatches(templatePattern('trips.${x}')!, EN)).toBe(1); expect(countMatches(templatePattern('wide.${x}')!, WIDE)).toBe(MAX_IMPLICIT_MATCHES + 1); }); it('FE-I18N-KEYS-012: fails a template wider than the bound unless its file has an entry for it', () => { const scan = scanSource('t(`wide.${x}`); t(`costs.filter.${f}`)', 'a.tsx'); const { broad, unmatched } = evaluate(scan, WIDE, []); expect(broad.map((b) => [b.template, b.matches])).toEqual([['wide.${x}', MAX_IMPLICIT_MATCHES + 1]]); expect(unmatched).toEqual([]); const otherFile = [{ file: 'b.tsx', template: 'wide.${x}', because: 'test' }]; expect(evaluate(scan, WIDE, otherFile).broad).toHaveLength(1); const ownFile = [{ file: 'a.tsx', template: 'wide.${x}', because: 'test' }]; expect(evaluate(scan, WIDE, ownFile)).toMatchObject({ broad: [], stale: [] }); }); it('FE-I18N-KEYS-013: refuses an entry for a narrow template, and an allowed template that matches nothing', () => { const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`)', 'a.tsx'); const narrow = [{ file: 'a.tsx', template: 'costs.filter.${f}', because: 'test' }]; expect(evaluate(scan, EN, narrow).stale).toEqual(narrow); const gone = [{ file: 'a.tsx', template: 'gone.prefix.${x}', because: 'test' }]; expect(evaluate(scan, EN, gone).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']); }); it('FE-I18N-KEYS-014: checks each literal branch of a template as a key, so a typo in one fails', () => { const scan = scanSource("t(`costs.filter.${on ? 'all' : 'alll'}`)", 'a.tsx'); expect(scan.dynamic).toEqual([]); expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.all', 'costs.filter.alll']); expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.alll']); // Nested ternaries, parentheses and optional chaining in the condition; a literal in a comparison is no result. expect(interpolationResults("(a?.b === 'x') ? 'p' : c ? ('q') : 'r'")).toEqual({ literals: ['p', 'q', 'r'], complete: true, }); expect(interpolationResults("fn('a.b')")).toEqual({ literals: [], complete: false }); }); it('FE-I18N-KEYS-015: keeps a template with data in it a pattern and still checks its literal fallback', () => { const scan = scanSource("t(`costs.filter.${role ?? 'nope'}`); t(`budget.${p === 'x' ? 'title' : p}`)", 'a.tsx'); expect(scan.dynamic.map((d) => d.template)).toEqual([ "costs.filter.${role ?? 'nope'}", "budget.${p === 'x' ? 'title' : p}", ]); expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.nope', 'budget.title']); expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.nope']); // A derived pattern is judged under its source template's allow-list entry. const two = scanSource("t(`wide.${a ?? 'k0'}.${b}`)", 'a.tsx'); expect(two.dynamic.map((d) => d.site)).toEqual([undefined, "wide.${a ?? 'k0'}.${b}"]); }); });