waybetter/packages/runtime/tests/i18n.test.ts
CRT dc449de9c2 shell: the Store view and install screen as framework React components, strings in the framework catalogs
Moved from wayBetter for X so every wayBetter app gets the Store UI; the app passes its native
bridge, theme adapter, translator and (optionally) its own controls and button classes.

- storeModel.ts: wire types of the native store commands (InstallPreview, StoreCatalogView,
  LinkEvent, StoreItem with `apps`), permission badges in the site's words, categories, item state
  (install/installed/update/changed locally/built in), search, free theme ids, versions, previews.
- installController.ts: the install screen's state machine (links incl. those queued before the
  shell listened, dropped files, store items, cancel, confirm), generic over the app's theme format
  (`ThemeAdapter`: parse, ids, facts, save) and its bridge (`StoreBackend`: invoke/listen).
- InstallDialog.tsx (`InstallHost`): the screen (Install enabled after 1 s, Enter never installs,
  tabs hidden via setOverlay) and the window-wide drop target. StoreView.tsx: search, kinds,
  categories, cards, permission badges, install/update/remove; default controls or the app's.
- store.css: both, themed with framework `--wb-*` tokens (documented; dark defaults).
- storeMessages.ts + every framework catalog: 120 `wb.store.*`, `wb.install.*`, `wb.permission.*`
  messages moved from the app catalogs with all existing translations; the six that named X take
  `{site}`; generic words the components need copied with their translations.
- npm package: exports for the new modules and store.css, optional react/react-dom peers; @types
  for the typecheck. Tests: models, controller flows with a fake bridge, every looked-up key exists.
2026-09-29 11:08:04 +07:00

225 lines
13 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
argumentShapes, createI18n, direction, fallbackChain, formatMessage, matchLocale, normalizeLocale,
parseMessage, placeholders, pluralCategories, relativeParts, type Catalog,
} from '../src/i18n.ts';
import { checkCatalog, checkMessageSafety, checkShape, checkSource, coverage, generateKeyTypes, type CatalogIssue } from '../src/i18nCheck.ts';
import { createPluginI18n, pluginNamespace } from '../src/pluginI18n.ts';
import { KIT_MESSAGES } from '../src/ui.ts';
import { SHELL_MESSAGES, shellText } from '../../shell/src/messages.ts';
import { STORE_MESSAGES } from '../../shell/src/storeMessages.ts';
const here = dirname(fileURLToPath(import.meta.url));
test('locale tags are normalized, including X and legacy codes', () => {
assert.equal(normalizeLocale('en_gb'), 'en-GB');
assert.equal(normalizeLocale('zh-cn'), 'zh-Hans');
assert.equal(normalizeLocale('zh-TW'), 'zh-Hant');
assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant');
assert.equal(normalizeLocale('zh'), 'zh-Hans');
assert.equal(normalizeLocale('msa'), 'ms');
assert.equal(normalizeLocale('no'), 'nb');
assert.equal(normalizeLocale('iw'), 'he');
assert.equal(normalizeLocale('ar-x-fm'), 'ar-x-fm');
assert.equal(normalizeLocale('pt-br'), 'pt-BR');
for (const bad of ['', 'x', 'english!', '<script>', 'a'.repeat(40), null, 42]) assert.equal(normalizeLocale(bad), null, String(bad));
});
test('fallback chains end in the source and never cross scripts', () => {
assert.deepEqual(fallbackChain('pt-BR'), ['pt-BR', 'pt', 'en']);
assert.deepEqual(fallbackChain('zh-Hant'), ['zh-Hant', 'en']);
assert.deepEqual(fallbackChain('ar-x-fm'), ['ar-x-fm', 'ar', 'en']);
assert.deepEqual(fallbackChain('en-GB', 'en', { 'en-GB': 'en' }), ['en-GB', 'en']);
assert.equal(matchLocale(['pt-PT', 'en'], ['en', 'pt', 'tr']), 'pt');
assert.equal(matchLocale(['xx', 'tr-TR'], ['en', 'tr']), 'tr');
assert.equal(matchLocale(['ja'], ['en', 'tr']), 'en');
});
test('direction follows the script', () => {
for (const rtl of ['ar', 'ar-x-fm', 'fa', 'he', 'ur']) assert.equal(direction(rtl), 'rtl', rtl);
for (const ltr of ['en', 'tr', 'ja', 'zh-Hant', 'ha']) assert.equal(direction(ltr), 'ltr', ltr);
});
test('ICU-lite: placeholders, plurals with exact forms, select, quoting', () => {
assert.equal(formatMessage('Hello, {name}!', { name: '<b>x</b>' }), 'Hello, <b>x</b>!', 'values are text');
const drafts = '{count, plural, =0 {No drafts} one {# draft} other {# drafts}}';
assert.equal(formatMessage(drafts, { count: 0 }), 'No drafts');
assert.equal(formatMessage(drafts, { count: 1 }), '1 draft');
assert.equal(formatMessage(drafts, { count: 1234 }), '1,234 drafts');
assert.equal(formatMessage(drafts, { count: 1234 }, 'de'), '1.234 drafts');
// Arabic has six forms; "few" for 3.
assert.equal(formatMessage('{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}', { n: 3 }, 'ar'), 'f');
assert.equal(formatMessage('{kind, select, post {Post} reply {Reply} other {Item}}', { kind: 'reply' }), 'Reply');
assert.equal(formatMessage('{kind, select, post {Post} other {Item}}', { kind: 'nope' }), 'Item');
assert.equal(formatMessage("Use '{braces}' and it''s fine"), "Use {braces} and it's fine");
assert.equal(formatMessage('{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}', { n: 22 }), '22nd');
assert.equal(formatMessage('{n, number, percent}', { n: 0.5 }), '50%');
assert.equal(formatMessage('Missing {x}'), 'Missing {x}');
assert.equal(formatMessage('Broken {x'), 'Broken {x', 'malformed messages render raw');
assert.throws(() => parseMessage('{n, plural, one {x}}'), /other/);
assert.throws(() => parseMessage('{a-b}'), /placeholder/);
assert.deepEqual(placeholders('{a} {n, plural, one {# {b}} other {{c}}}'), ['a', 'b', 'c', 'n']);
assert.deepEqual(argumentShapes(drafts), { count: { kind: 'plural', options: ['=0', 'one', 'other'] } });
assert.deepEqual(pluralCategories('tr').sort(), ['one', 'other']);
assert.deepEqual(pluralCategories('ja'), ['other']);
});
test('createI18n: lazy catalogs, fallback, missing reports, resolved subsets', async () => {
const missing: string[] = [];
const loads: string[] = [];
const i18n = createI18n({
source: { 'a.hello': 'Hello, {name}', 'a.only': 'English only', 'x.nav': 'Nav' },
available: ['en', 'pt', 'pt-BR', 'tr'],
load: tag => {
loads.push(tag);
return ({ pt: { 'a.hello': 'Olá, {name}' }, 'pt-BR': { 'x.nav': 'Navegação' }, tr: { 'a.hello': 'Merhaba {name}' } } as Record<string, Record<string, string>>)[tag];
},
onMissing: (key, locale, reason) => missing.push(`${reason}:${locale}:${key}`),
});
const seen: string[] = [];
i18n.onChange(locale => seen.push(locale));
assert.equal(await i18n.setLocale('pt-BR'), 'pt-BR');
assert.deepEqual(i18n.chain, ['pt-BR', 'pt', 'en']);
assert.deepEqual(loads.sort(), ['pt', 'pt-BR']);
assert.equal(i18n.t('a.hello', { name: 'Ana' }), 'Olá, Ana');
assert.equal(i18n.t('x.nav'), 'Navegação');
assert.equal(i18n.t('a.only'), 'English only');
assert.equal(i18n.t('nope'), 'nope');
assert.ok(missing.includes('key:pt-BR:a.only') && missing.includes('key:pt-BR:nope'));
assert.deepEqual(i18n.resolved(['x.']), { 'x.nav': 'Navegação' });
assert.equal(await i18n.setLocale('fr-FR'), 'en', 'no catalog: the source');
assert.equal(await i18n.setLocale('tr-TR'), 'tr');
assert.deepEqual(seen, ['pt-BR', 'en', 'tr']);
assert.equal(i18n.number(1234.5), '1.234,5');
assert.equal(i18n.dir, 'ltr');
i18n.add('tr', { 'a.only': 'Sadece' });
assert.equal(i18n.t('a.only'), 'Sadece');
assert.equal(i18n.tx('dynamic.key', {}, 'fallback'), 'fallback');
assert.equal(typeof i18n.relative(-3, 'minute'), 'string');
assert.deepEqual(relativeParts(Date.now() - 5 * 60_000), { value: -5, unit: 'minute' });
});
test('catalog checks: placeholders, plurals, markup, extra keys, lengths', () => {
const source = {
'a.hello': 'Hello, {name}',
'a.count': '{n, plural, one {# item} other {# items}}',
'a.kind': '{k, select, post {Post} other {Other}}',
'a.short': 'Save',
};
const catalog: Catalog = {
locale: 'ar', name: 'العربية', englishName: 'Arabic', dir: 'rtl', status: 'machine',
messages: {
'a.hello': 'مرحبا {nom}',
'a.count': '{n, plural, one {عنصر} other {# عناصر}}',
'a.kind': '{k, select, other {آخر}}',
'a.short': 'x'.repeat(80),
'a.gone': 'قديم',
},
};
const issues = checkCatalog(catalog, source);
const text = issues.map(issue => `${issue.level}:${issue.key}:${issue.message}`).join('\n');
assert.match(text, /error:a\.hello:missing placeholder\(s\) \{name\}/);
assert.match(text, /error:a\.hello:unknown placeholder\(s\) \{nom\}/);
assert.match(text, /warning:a\.count:.*no "zero" form \(ar uses it/);
assert.match(text, /error:a\.kind:.*lacks the "post" option/);
assert.match(text, /warning:a\.short:translation is 80 characters/);
assert.match(text, /error:a\.gone:key is not in the source/);
assert.equal(checkMessageSafety('tr', 'k', 'Merhaba <img src=x onerror=alert(1)>')[0]?.level, 'error');
assert.equal(checkMessageSafety('tr', 'k', 'a‮b')[0]?.level, 'error');
assert.equal(checkMessageSafety('tr', 'k', 'Boyut < 8 MB').length, 0);
assert.equal(checkShape({ locale: 'tr', name: 'Türkçe', englishName: 'Turkish', status: 'reviewed', messages: {} }).length, 1, 'reviewed needs reviewers');
assert.equal(checkShape({ locale: 'ar', name: 'x', englishName: 'y', status: 'machine', dir: 'ltr', messages: {} }).length, 1, 'wrong dir');
assert.equal(checkShape({ locale: 'zh-cn', name: 'x', englishName: 'y', status: 'machine', messages: {} }).length, 1, 'non-canonical tag');
assert.equal(checkSource({ locale: 'en', name: 'English', englishName: 'English', status: 'source', messages: { a: 'x' } }, { b: 'desc' }).length, 1);
assert.equal(coverage(catalog, source), 1);
assert.match(generateKeyTypes(source), /"a\.hello": \{ name: string \| number \| Date \}/);
});
test('apostrophes are literal unless they quote { } # or another apostrophe', () => {
assert.equal(formatMessage("Don't lose the developer's {name}", { name: 'draft' }), "Don't lose the developer's draft");
assert.equal(formatMessage("It's {n, plural, one {# post} other {# posts}}", { n: 2 }), "It's 2 posts");
assert.equal(formatMessage("'#'1 and '{'x'}' and it''s"), '#1 and {x} and it\'s');
assert.equal(formatMessage("{n, plural, other {'#' is #}}", { n: 3 }), '# is 3');
assert.deepEqual(placeholders("don't {a} 'n' {b}"), ['a', 'b']);
assert.deepEqual(checkMessageSafety('en', 'k', "Don't show the developer's notes"), []);
});
test('catalog checks: plural-only arguments may be dropped, other placeholders may not', () => {
const source = {
'a.fonts': 'Download {count, plural, one {it} other {them}} now?',
'a.saved': 'Download {fonts} to see {count, plural, one {it} other {them}}.',
'a.drafts': '{count, plural, one {# draft} other {# drafts}}',
'a.kind': 'Open {kind, select, post {the post} other {it}}',
'a.frames': 'New {ext}{frames, plural, =0 {} =1 {} other { · # frames}}',
'a.before': 'In',
};
const catalog: Catalog = {
locale: 'ms', name: 'Bahasa Melayu', englishName: 'Malay', status: 'machine',
messages: {
'a.fonts': 'Muat turun sekarang?',
'a.saved': 'Muat turun {fonts} untuk melihatnya.',
'a.drafts': 'draf',
'a.kind': 'Buka',
'a.frames': '{ext} baharu{frames, plural, other { · # bingkai}}',
'a.before': '',
},
};
const errors = checkCatalog(catalog, source, { 'a.before': { description: 'fragment', allowEmpty: true } }).filter(issue => issue.level === 'error');
assert.deepEqual(errors.map(issue => `${issue.key}:${issue.message}`), [
'a.drafts:missing placeholder(s) {count}',
'a.kind:missing placeholder(s) {kind}',
]);
assert.equal(checkCatalog({ ...catalog, messages: { 'a.before': '' } }, source).filter(issue => issue.level === 'error').length, 1, 'empty needs allowEmpty');
// Only `other` where the language has only `other` (ms, vi, th, yo): no warning either.
for (const locale of ['ms', 'vi', 'th', 'yo']) {
const only = checkCatalog({ ...catalog, locale, messages: { 'a.drafts': '{count, plural, other {# x}}' } }, source);
assert.deepEqual(only.filter(issue => issue.key === 'a.drafts'), [], locale);
}
const sk = checkCatalog({ ...catalog, locale: 'sk', messages: { 'a.frames': 'Nový {ext}{frames, plural, =0 {} =1 {} other { · # sn.}}' } }, source);
assert.ok(sk.every(issue => issue.level === 'warning'), 'missing plural forms are warnings');
});
test('plugin strings: own translations, then the app catalog, then own English', () => {
let locale = 'tr';
const listeners = new Set<() => void>();
const host = { 'plugin.reading-time.label': '{minutes} dk okuma', 'kit.ok': 'Tamam' } as Record<string, string>;
const source = { locale: () => locale, message: (key: string) => host[key], onChange: (fn: () => void) => { listeners.add(fn); return () => listeners.delete(fn); } };
const root = createPluginI18n(source);
const bundled = root.namespace(pluginNamespace('reading-time.plugin.js'));
bundled.register({ en: { label: '{minutes} min read', other: 'Other' } });
assert.equal(bundled.t('label', { minutes: 3 }), '3 dk okuma');
assert.equal(bundled.t('other'), 'Other');
const third = root.namespace('notes');
third.register({ en: { saved: 'Saved' }, 'tr-TR': { saved: 'Kaydedildi' } });
assert.equal(third.t('saved'), 'Saved', 'tr-TR does not serve tr');
third.register({ tr: { saved: 'Kaydedildi' } });
assert.equal(third.t('saved'), 'Kaydedildi');
let heard = '';
third.onChange(next => { heard = next; });
locale = 'en';
for (const fn of listeners) fn();
assert.equal(heard, 'en');
assert.equal(third.t('saved'), 'Saved');
assert.equal(third.t('missing'), 'missing');
assert.throws(() => root.namespace('../x'), /invalid/);
});
test('framework catalogs: kit words for every locale pass the checks', () => {
const dir = join(here, '../../waybetter/locales');
const en = JSON.parse(readFileSync(join(dir, 'en.json'), 'utf8')) as Catalog;
assert.deepEqual(en.messages, { ...KIT_MESSAGES, ...SHELL_MESSAGES, ...STORE_MESSAGES }, 'en.json is the kit, shell-model and store source');
assert.equal(shellText('wb.account.signedOut', { handle: '@a' }), '@a · signed out');
assert.equal(shellText('wb.account.signedOut', { handle: '@a' }, () => '{handle} · oturum kapalı'), '@a · oturum kapalı');
const files = readdirSync(dir).filter(file => file.endsWith('.json'));
assert.ok(files.length >= 50, `${files.length} locales`);
for (const file of files) {
const catalog = JSON.parse(readFileSync(join(dir, file), 'utf8')) as Catalog;
assert.equal(`${catalog.locale}.json`, file);
const errors: CatalogIssue[] = [...checkShape(catalog, file), ...(file === 'en.json' ? checkSource(catalog) : checkCatalog(catalog, en.messages))].filter(issue => issue.level === 'error');
assert.deepEqual(errors, [], file);
}
});