fix(walkthrough): offer German, and catch the next locale that is only half added
German was added to the interface but not to the walkthrough's own language list, and nothing failed: the picker offered Deutsch because it is built from the interface locales, the server resolved the tag to English, and a German reader paid for a walkthrough written in English while the picker still said Deutsch. The two lists cannot be one — the server cannot import from packages/ui — so a test reads i18n/runtime.ts and compares them, in both directions and through normalizeLanguage. Drift this quiet needs a test rather than vigilance.
This commit is contained in:
@@ -136,7 +136,12 @@ silently. The prompt says so explicitly.
|
||||
|
||||
`languages.js` owns the accepted tags; they match the UI's `Locale` union, and
|
||||
anything else — unknown, malformed, absent — resolves to English rather than
|
||||
failing the request. The default language adds no instruction at all, since the
|
||||
failing the request. The two lists cannot be one, because the server cannot
|
||||
import from `packages/ui`, so `languages.test.js` reads `i18n/runtime.ts` and
|
||||
compares them. That test exists because a locale added to the interface alone
|
||||
fails silently in the worst way: the picker offers the language, the tag
|
||||
resolves to English, and the reader pays for a walkthrough written in the wrong
|
||||
one while the picker still names theirs. The default language adds no instruction at all, since the
|
||||
system prompt is already English.
|
||||
|
||||
The language is part of the cache key. Without that, asking for a translation
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DEFAULT_LANGUAGE = 'en';
|
||||
// which every model handles more reliably than a switch mid-sentence.
|
||||
const LANGUAGE_NAMES = {
|
||||
en: 'English',
|
||||
de: 'German',
|
||||
fr: 'French',
|
||||
'zh-CN': 'Simplified Chinese',
|
||||
'zh-TW': 'Traditional Chinese',
|
||||
@@ -57,3 +58,9 @@ export function normalizeLanguage(value) {
|
||||
export function languageName(language) {
|
||||
return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE];
|
||||
}
|
||||
|
||||
// The tags this list must agree with live in `packages/ui/src/lib/i18n`, which
|
||||
// the server cannot import. `languages.test.js` compares the two by reading
|
||||
// that file, because a locale added on one side only fails silently: the picker
|
||||
// offers the language and the walkthrough comes back in English.
|
||||
export const __testing = { LANGUAGE_NAMES };
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeLanguage, __testing } from './languages.js';
|
||||
|
||||
// The languages a walkthrough may be written in have to agree with the locales
|
||||
// the interface ships, because the picker offers exactly those and the server
|
||||
// decides what the prompt asks for. The two lists cannot be one list — the
|
||||
// server cannot import from `packages/ui` — so they are compared here instead.
|
||||
//
|
||||
// This exists because German was added to the interface and not here. Nothing
|
||||
// broke loudly: the picker offered Deutsch, `normalizeLanguage` quietly resolved
|
||||
// it to English, and a German user paid for a walkthrough written in English
|
||||
// while the picker still said Deutsch. A drift this quiet needs a test, not
|
||||
// vigilance.
|
||||
const RUNTIME_TS = fileURLToPath(new URL('../../../../ui/src/lib/i18n/runtime.ts', import.meta.url));
|
||||
|
||||
const interfaceLocales = () => {
|
||||
const source = fs.readFileSync(RUNTIME_TS, 'utf8');
|
||||
const match = source.match(/export const LOCALES = \[([^\]]*)\]/);
|
||||
if (!match) throw new Error(`Could not find LOCALES in ${RUNTIME_TS}`);
|
||||
return match[1]
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
describe('supported languages', () => {
|
||||
it('covers every locale the interface offers', () => {
|
||||
const missing = interfaceLocales().filter((locale) => !Object.hasOwn(__testing.LANGUAGE_NAMES, locale));
|
||||
|
||||
expect(missing, `add these to LANGUAGE_NAMES in languages.js: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers nothing the interface cannot label', () => {
|
||||
const locales = new Set(interfaceLocales());
|
||||
const extra = Object.keys(__testing.LANGUAGE_NAMES).filter((tag) => !locales.has(tag));
|
||||
|
||||
// A language here that the interface does not know is not harmful, but it
|
||||
// is unreachable: the picker is built from the interface list.
|
||||
expect(extra, `unreachable from the picker: ${extra.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves every interface locale to itself rather than to the default', () => {
|
||||
for (const locale of interfaceLocales()) {
|
||||
expect(normalizeLanguage(locale)).toBe(locale);
|
||||
}
|
||||
});
|
||||
|
||||
it('names every supported language in English, for the prompt', () => {
|
||||
for (const [tag, name] of Object.entries(__testing.LANGUAGE_NAMES)) {
|
||||
expect(name, tag).toMatch(/^[A-Z][A-Za-z ]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user