jellyfin-tools/jellyfin-year-review/web/unit/i18n.test.js
jellyfin-year-review/web/unit/i18n.test.jsRaw
1import { test } from 'node:test';
2import assert from 'node:assert/strict';
3import translations from '../src/lib/translations.json' with { type: 'json' };
4import { t, message } from '../src/lib/i18n.js';
5
6// Exercise edits to the same catalog the app imports, restoring it after each test.
7function withTranslations(values, run) {
8 const catalog = translations.languages[translations.language];
9 const original = { ...catalog.strings };
10 Object.assign(catalog.strings, values);
11 try { run(); }
12 finally { catalog.strings = original; }
13}
14
15test('top-level language selects English or Catalan labels and formatting', async () => {
16 const original = translations.language;
17 try {
18 for (const [language, label, formatted] of [['en', 'Sign out', '1.5'], ['ca', 'Tanca la sessió', '1,5']]) {
19 translations.language = language;
20 const active = await import(`../src/lib/i18n.js?selection=${language}`);
21 assert.equal(active.t('Sign out'), label);
22 assert.equal(new Intl.NumberFormat(active.locale).format(1.5), formatted);
23 assert.equal(active.direction, translations.languages[language].direction);
24 }
25 } finally { translations.language = original; }
26});
27
28test('an unknown language reports a configuration error', async () => {
29 const original = translations.language;
30 translations.language = 'missing';
31 try {
32 await assert.rejects(import('../src/lib/i18n.js?selection=missing'), /Unknown translation language: missing/);
33 } finally { translations.language = original; }
34});
35
36test('catalog edits replace labels and allow reordered, repeated placeholders', () => {
37 withTranslations({
38 'Sign out': 'Sair',
39 'You: rank {rank} of {count}': 'Entre {count}, você está em {rank} de {count}'
40 }, () => {
41 assert.equal(t('Sign out'), 'Sair');
42 assert.equal(t('You: rank {rank} of {count}', { rank: 3, count: 6 }), 'Entre 6, você está em 3 de 6');
43 });
44});
45
46test('missing translations fall back to English and inserted data stays literal', () => {
47 assert.equal(t('Unlisted text for {name}', { name: '<b>{year}</b>', year: 2025 }), 'Unlisted text for <b>{year}</b>');
48 assert.equal(t('constructor'), 'constructor');
49 assert.equal(t('Missing {value}'), 'Missing {value}');
50 const catalog = translations.languages[translations.language];
51 const original = catalog.strings['Sign out'];
52 delete catalog.strings['Sign out'];
53 try { assert.equal(t('Sign out'), 'Sign out'); }
54 finally { catalog.strings['Sign out'] = original; }
55});
56
57test('review messages, profiles and counted warnings use translated strings', () => {
58 withTranslations({
59 'Night Owl': 'Coruja noturna',
60 'Unable to sign in with those credentials.': 'Credenciais inválidas.',
61 '{count} records excluded because media metadata was deleted or unavailable.': 'Registros excluídos: {count}.'
62 }, () => {
63 assert.equal(message('Night Owl'), 'Coruja noturna');
64 assert.equal(message('Unable to sign in with those credentials.'), 'Credenciais inválidas.');
65 assert.equal(message('12 records excluded because media metadata was deleted or unavailable.'), 'Registros excluídos: 12.');
66 assert.equal(message('Custom source description'), 'Custom source description');
67 assert.equal(message(undefined), '');
68 });
69});
70
71test('translations preserve all named placeholders', () => {
72 const placeholders = text => [...text.matchAll(/\{\w+\}/g)].map(match => match[0]).sort();
73 for (const [language, catalog] of Object.entries(translations.languages)) {
74 for (const [english, translated] of Object.entries(catalog.strings)) {
75 assert.deepEqual([...new Set(placeholders(translated))], [...new Set(placeholders(english))], `${language}: ${english}`);
76 }
77 }
78});