From 3d283089da0506cc6d7d382b079709ff3ccd3d52 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 22 Jan 2023 11:33:25 +0100 Subject: [PATCH 1/2] feat(tests): Added more testcases for translation functions Signed-off-by: Ferdinand Thiessen --- tests/index.test.js | 142 ---------------------- tests/translation.test.ts | 244 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 142 deletions(-) delete mode 100644 tests/index.test.js create mode 100644 tests/translation.test.ts diff --git a/tests/index.test.js b/tests/index.test.js deleted file mode 100644 index 110e5749..00000000 --- a/tests/index.test.js +++ /dev/null @@ -1,142 +0,0 @@ -import { - getCanonicalLocale, - translate, - translatePlural, - register, - _unregister, -} from '../lib/index' - -const setLocale = (locale) => document.documentElement.setAttribute('data-locale', locale) - -describe('translate', () => { - const mockWindowDE = () => { - window._oc_l10n_registry_translations = { - core: { - 'Hello world!': 'Hallo Welt!', - 'Hello {name}': 'Hallo {name}', - '_download %n file_::_download %n files_': [ - 'Lade %n Datei herunter', - 'Lade %n Dateien herunter', - ], - }, - } - window._oc_l10n_registry_plural_functions = { - core: (t) => t === 1 ? 0 : 1, - } - setLocale('de') - } - - beforeAll(mockWindowDE) - - it('singular', () => { - const text = 'Hello world!' - const translation = translate('core', text) - expect(translation).toBe('Hallo Welt!') - }) - - it('with variable', () => { - const text = 'Hello {name}' - const translation = translate('core', text, { name: 'J. Doe' }) - expect(translation).toBe('Hallo J. Doe') - }) - - it('plural', () => { - const text = ['download %n file', 'download %n files'] - - expect(translatePlural('core', ...text, 1)).toBe('Lade 1 Datei herunter') - - expect(translatePlural('core', ...text, 2)).toBe('Lade 2 Dateien herunter') - }) - - it('missing text', () => { - const text = 'Good bye!' - const translation = translate('core', text) - expect(translation).toBe('Good bye!') - }) - - it('missing application', () => { - const text = 'Good bye!' - const translation = translate('unavailable', text) - expect(translation).toBe('Good bye!') - }) -}) - -describe('getCanonicalLocale', () => { - afterEach(() => { - setLocale('') - }) - - it('Returns primary locales as is', () => { - setLocale('de') - expect(getCanonicalLocale()).toEqual('de') - setLocale('zu') - expect(getCanonicalLocale()).toEqual('zu') - }) - - it('Returns extended locales with hyphens', () => { - setLocale('az_Cyrl_AZ') - expect(getCanonicalLocale()).toEqual('az-Cyrl-AZ') - setLocale('de_DE') - expect(getCanonicalLocale()).toEqual('de-DE') - }) -}) - -describe('register', () => { - beforeEach(() => { - setLocale('de_DE') - window._oc_l10n_registry_translations = undefined - window._oc_l10n_registry_plural_functions = undefined - }) - - it('initial', () => { - register('app', { - Application: 'Anwendung', - '_%n guest_::_%n guests_': ['%n Gast', '%n Gäste'], - }) - expect(translate('app', 'Application')).toBe('Anwendung') - expect(translatePlural('app', '%n guest', '%n guests', 1)).toBe('1 Gast') - expect(translatePlural('app', '%n guest', '%n guests', 2)).toBe('2 Gäste') - }) - - it('extend', () => { - window._oc_l10n_registry_translations = { - app: { - Application: 'Anwendung', - }, - } - window._oc_l10n_registry_plural_functions = { - app: (t) => t === 1 ? 0 : 1, - } - register('app', { - Translation: 'Übersetzung', - }) - expect(translate('app', 'Application')).toBe('Anwendung') - expect(translate('app', 'Translation')).toBe('Übersetzung') - }) - - it('with another app', () => { - window._oc_l10n_registry_translations = { - core: { - 'Hello world!': 'Hallo Welt!', - }, - } - window._oc_l10n_registry_plural_functions = { - core: (t) => t === 1 ? 0 : 1, - } - register('app', { - Application: 'Anwendung', - }) - expect(translate('core', 'Hello world!')).toBe('Hallo Welt!') - expect(translate('app', 'Application')).toBe('Anwendung') - }) - - it('unregister', () => { - window._oc_l10n_registry_translations = {} - window._oc_l10n_registry_plural_functions = {} - register('app', { - Application: 'Anwendung', - }) - _unregister('app') - expect(translate('app', 'Application')).toBe('Application') - }) -}) diff --git a/tests/translation.test.ts b/tests/translation.test.ts new file mode 100644 index 00000000..cfd29b88 --- /dev/null +++ b/tests/translation.test.ts @@ -0,0 +1,244 @@ +import type { NextcloudWindowWithRegistry } from '../lib/registry' +import { + getCanonicalLocale, + getLanguage, + getLocale, + getPlural, + loadTranslations, + register, + translate, + translatePlural, + _unregister, +} from '../lib/translation' + +declare const window: NextcloudWindowWithRegistry + +const setLocale = (locale: string) => document.documentElement.setAttribute('data-locale', locale) +const setLanguage = (lang: string) => document.documentElement.setAttribute('lang', lang) + +describe('getCanonicalLocale', () => { + afterEach(() => { + setLocale('') + }) + + it('Returns primary locales as is', () => { + setLocale('de') + expect(getCanonicalLocale()).toEqual('de') + setLocale('zu') + expect(getCanonicalLocale()).toEqual('zu') + }) + + it('Returns extended locales with hyphens', () => { + setLocale('az_Cyrl_AZ') + expect(getCanonicalLocale()).toEqual('az-Cyrl-AZ') + setLocale('de_DE') + expect(getCanonicalLocale()).toEqual('de-DE') + }) +}) + +test('getLanguage', () => { + document.documentElement.removeAttribute('lang') + // Expect fallback + expect(getLanguage()).toBe('en') + setLanguage('') + expect(getLanguage()).toBe('en') + + // Expect value + setLanguage('zu') + expect(getLanguage()).toBe('zu') +}) + +test('getLocale', () => { + document.documentElement.removeAttribute('data-locale') + // Expect fallback + expect(getLocale()).toBe('en') + setLocale('') + expect(getLocale()).toBe('en') + + // Expect value + setLocale('de_DE') + expect(getLocale()).toBe('de_DE') +}) + +describe('translate', () => { + const mockWindowDE = () => { + window._oc_l10n_registry_translations = { + core: { + 'Hello world!': 'Hallo Welt!', + 'Hello {name}': 'Hallo {name}', + '_download %n file_::_download %n files_': [ + 'Lade %n Datei herunter', + 'Lade %n Dateien herunter', + ], + '_download %n file from {url}_::_download %n files from {url}_': [ + 'Lade %n Datei von {url} herunter', + 'Lade %n Dateien von {url} herunter', + ], + }, + } + window._oc_l10n_registry_plural_functions = { + core: (t) => t === 1 ? 0 : 1, + } + setLocale('de') + } + + beforeAll(mockWindowDE) + + it('without placeholder HTML escaping', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: 'Name' }, undefined, { escape: false }) + expect(translation).toBe('Hallo Name') + }) + + it('with placeholder HTML escaping', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: 'Name' }) + expect(translation).toBe('Hallo <del>Name</del>') + }) + + it('without placeholder XSS sanitizing', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: '' }, undefined, { sanitize: false, escape: false }) + expect(translation).toBe('Hallo ') + }) + + it('with placeholder XSS sanitizing', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: '' }, undefined, { escape: false }) + expect(translation).toBe('Hallo ') + }) + + it('singular', () => { + const text = 'Hello world!' + const translation = translate('core', text) + expect(translation).toBe('Hallo Welt!') + }) + + it('singular with variable', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: 'J. Doe' }) + expect(translation).toBe('Hallo J. Doe') + }) + + it('singular with missing variable', () => { + const text = 'Hello {name}' + const translation = translate('core', text, {}) + expect(translation).toBe('Hallo {name}') + }) + + it('singular with invalid variable', () => { + const text = 'Hello {name}' + // We need to disable ts here as users might use it from JS were no type checking is available + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const translation = translate('core', text, { name: true }) // invalid as only `string` or `number` are allowed + expect(translation).toBe('Hallo {name}') + }) + + it('singular with missing translation', () => { + const text = 'Good bye!' + const translation = translate('core', text) + expect(translation).toBe('Good bye!') + }) + + it('singular with missing application', () => { + const text = 'Good bye!' + const translation = translate('unavailable', text) + expect(translation).toBe('Good bye!') + }) + + it('singular with plural translation', () => { + const text = '_download %n file_::_download %n files_' + const translation = translate('core', text) + expect(translation).toBe('Lade %n Datei herunter') + }) + + it('plural', () => { + const text = ['download %n file', 'download %n files'] as const + + expect(translatePlural('core', ...text, 1)).toBe('Lade 1 Datei herunter') + + expect(translatePlural('core', ...text, 2)).toBe('Lade 2 Dateien herunter') + }) + + it('plural with missing translation', () => { + const text = ['%n translation does not exist', '%n translations do not exist'] as const + + expect(translatePlural('core', ...text, 1)).toBe('1 translation does not exist') + expect(translatePlural('core', ...text, 2)).toBe('2 translations do not exist') + }) + + it('plural with variable', () => { + const text = ['download %n file from {url}', 'download %n files from {url}'] as const + + expect(translatePlural('core', ...text, 1, { url: 'nextcloud.com' })).toBe('Lade 1 Datei von nextcloud.com herunter') + expect(translatePlural('core', ...text, 2, { url: 'nextcloud.com' })).toBe('Lade 2 Dateien von nextcloud.com herunter') + }) + + it('plural with missing variable', () => { + const text = ['download %n file from {url}', 'download %n files from {url}'] as const + + expect(translatePlural('core', ...text, 1)).toBe('Lade 1 Datei von {url} herunter') + expect(translatePlural('core', ...text, 2)).toBe('Lade 2 Dateien von {url} herunter') + }) +}) + +describe('register', () => { + beforeEach(() => { + setLocale('de_DE') + window._oc_l10n_registry_translations = undefined + window._oc_l10n_registry_plural_functions = undefined + }) + + it('with blank registry', () => { + register('app', { + Application: 'Anwendung', + '_%n guest_::_%n guests_': ['%n Gast', '%n Gäste'], + }) + expect(translate('app', 'Application')).toBe('Anwendung') + expect(translatePlural('app', '%n guest', '%n guests', 1)).toBe('1 Gast') + expect(translatePlural('app', '%n guest', '%n guests', 2)).toBe('2 Gäste') + }) + + it('extend registered translations', () => { + window._oc_l10n_registry_translations = { + app: { + Application: 'Anwendung', + }, + } + window._oc_l10n_registry_plural_functions = { + app: (t) => t === 1 ? 0 : 1, + } + register('app', { + Translation: 'Übersetzung', + }) + expect(translate('app', 'Application')).toBe('Anwendung') + expect(translate('app', 'Translation')).toBe('Übersetzung') + }) + + it('extend with another new app', () => { + window._oc_l10n_registry_translations = { + core: { + 'Hello world!': 'Hallo Welt!', + }, + } + window._oc_l10n_registry_plural_functions = { + core: (t) => t === 1 ? 0 : 1, + } + register('app', { + Application: 'Anwendung', + }) + expect(translate('core', 'Hello world!')).toBe('Hallo Welt!') + expect(translate('app', 'Application')).toBe('Anwendung') + }) + + it('unregister', () => { + window._oc_l10n_registry_translations = {} + window._oc_l10n_registry_plural_functions = {} + register('app', { + Application: 'Anwendung', + }) + _unregister('app') + expect(translate('app', 'Application')).toBe('Application') + }) +}) From 84fa49689d2434f3c2bc781c167204e09707c01e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 25 Jan 2023 15:24:50 +0100 Subject: [PATCH 2/2] fix(translation): Fix singular translation in edge cases where plural strings are provided Signed-off-by: Ferdinand Thiessen --- lib/translation.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/translation.ts b/lib/translation.ts index 43fdb753..16caec77 100644 --- a/lib/translation.ts +++ b/lib/translation.ts @@ -86,11 +86,12 @@ export function translate( } const bundle = getAppTranslations(app) - const translation = bundle.translations[text] || text + let translation = bundle.translations[text] || text + translation = Array.isArray(translation) ? translation[0] : translation if (typeof vars === 'object' || number !== undefined) { return optSanitize(_build( - typeof translation === 'string' ? translation : translation[0], + translation, vars, number ))