import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { CATALOG_NAME_KEY, CATALOG_SUMMARY_KEY } from '@lobbyforge/plugin-sdk'; import { SHIPPED_LOCALES } from '../locales.generated'; /** * Hushle's translation tables, checked against the panel that renders * them and against English. * * The languages are read from `locales/`, not listed here, so a new * `locales/.json` (scaffolded by `pnpm i18n:add`) is held to these * rules the moment it exists. A file marked `"$status": "partial"` may * have blanks — they fall back to English at runtime — but it may never * carry a key English lacks or change a placeholder. * * A missing translation is SILENT by design (the lookup falls back), so * only a test notices one. That is the whole reason this file exists. */ const ROOT = join(__dirname, '..', '..'); type Table = Record; const load = (code: string): Table => JSON.parse(readFileSync(join(ROOT, 'locales', `${code}.json`), 'utf8')) as Table; /** `$`-prefixed entries are file metadata (`$status`), not strings. */ const strings = (table: Table): Table => Object.fromEntries(Object.entries(table).filter(([key]) => !key.startsWith('$'))); const placeholders = (text: string) => (text.match(/\{(\w+)\}/g) ?? []).sort().join(); const onDisk = readdirSync(join(ROOT, 'locales')) .filter((file) => file.endsWith('.json')) .map((file) => file.slice(0, -'.json'.length)) .sort(); const english = strings(load('en')); /** The panel: its root component and the phase views it renders from `src/ui/`. */ const panelSources = [ join(ROOT, 'src', 'renderClient.tsx'), ...readdirSync(join(ROOT, 'src', 'ui')) .filter((file) => /\.tsx?$/.test(file)) .map((file) => join(ROOT, 'src', 'ui', file)), ]; /** Every `t('hushle.…')` key the panel renders. */ const used = [ ...new Set( panelSources.flatMap((path) => [...readFileSync(path, 'utf8').matchAll(/\bt\(\s*'(hushle\.[^']+)'/g)].map((match) => match[1]!) ) ), ].sort(); describe('Hushle locales', () => { it('finds the keys the panel uses', () => { expect(used.length).toBeGreaterThan(10); expect(used).toContain('hushle.lobby.packLabel'); }); it('ships every language on disk, English first', () => { // The generated index (`pnpm i18n:sync`) is what the manifest and the // loader read; it must list exactly the files that exist. expect(SHIPPED_LOCALES).toEqual(['en', ...onDisk.filter((code) => code !== 'en')]); }); it('translates its catalogue summary, which the host shows', () => { expect(english[CATALOG_SUMMARY_KEY]?.trim()).toBeTruthy(); expect(english[CATALOG_NAME_KEY]?.trim()).toBeTruthy(); }); it('has English for every key the panel renders', () => { expect(used.filter((key) => !english[key]?.trim())).toEqual([]); }); it('ships no key the panel never renders', () => { // A key nobody renders is a translation cost with no payoff. // `catalog.summary` and `catalog.name` are rendered by the host (activity picker, admin list). expect(Object.keys(english).filter((key) => !used.includes(key) && key !== CATALOG_SUMMARY_KEY && key !== CATALOG_NAME_KEY)).toEqual([]); }); describe.each(onDisk.filter((code) => code !== 'en'))('%s', (code) => { const table = load(code); const messages = strings(table); it('defines no key that English does not have', () => { expect(Object.keys(messages).filter((key) => !(key in english))).toEqual([]); }); it('uses exactly the placeholders English uses', () => { const mismatched = Object.entries(messages) .filter(([key, value]) => value.trim() && key in english) .filter(([key, value]) => placeholders(value) !== placeholders(english[key]!)) .map(([key]) => key); expect(mismatched).toEqual([]); }); if (table.$status !== 'partial') { it('translates every key, because it is not marked partial', () => { expect(Object.keys(english).filter((key) => !messages[key]?.trim())).toEqual([]); }); } }); });