Files
TREK/client/tests/unit/i18n/i18nKeysScript.test.ts
T
Maurice 33f004ed04 fix(client): a wide template key needs a reviewed allow-list entry
lint:i18n-keys let any template key through as long as its fixed parts
matched one en key, and each interpolation could stand for any run of
characters, dots included. `settings.${mode}` therefore reached all 574
settings keys and `help.guide.${id}.${part}` nearly 2000, so a wrong
suffix behind such a prefix could never fail, and none of the 109
templates had been looked at.

An interpolation now stands for one key segment, and a template passes
by itself only when it reaches between 1 and MAX_IMPLICIT_MATCHES (30)
en keys, the size of an enum such as the docsync error codes. A wider
one fails unless DYNAMIC_ALLOWED has an entry for that file and template
saying what bounds the value. The twelve wide templates in use today
get one each: the help key builders (checked by the registry test),
literal tuples beside the call, typed unions, the generated plugin
permissions and the seeded photo provider fields. An entry whose file no
longer uses the template, or whose template has become narrow enough to
pass alone, fails, so the list stays short and current.

Listing all 109 templates instead was considered and left out: the
narrow ones would only restate their own prefix, and the bound already
keeps a mistake inside one small namespace.
2026-10-08 13:04:42 +02:00

146 lines
7.3 KiB
TypeScript

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,
readEnKeys,
scanSource,
scanTree,
templatePattern,
} from '../../../scripts/i18n-keys.mjs'
// FE-I18N-KEYS-001 to FE-I18N-KEYS-013: 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')",
'<TransHtml html="journey.text" />',
"translateApiError(t, err, 'files.uploadError')",
"const tabs = [{ labelKey: 'tabs.one' }]",
'<Hint titleKey="hint.title" />',
].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: '<source>', 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: '<source>', template: '${gone}', because: 'test', resolves: ['budget.title'] }]
expect(evaluate(scan, EN, unused).stale).toEqual(unused)
const wrong = [{ file: '<source>', 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<MAX>.
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}'])
})
})