/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import fetchMock from "fetch-mock-jest";
import { Translation } from "matrix-web-i18n";
import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api";
import SdkConfig from "../src/SdkConfig";
import {
_t,
_tDom,
CustomTranslationOptions,
getAllLanguagesWithLabels,
registerCustomTranslations,
setLanguage,
setMissingEntryGenerator,
substitute,
TranslatedString,
UserFriendlyError,
TranslationKey,
} from "../src/languageHandler";
import { stubClient } from "./test-utils";
import { setupLanguageMock } from "./setup/setupLanguage";
async function setupTranslationOverridesForTests(overrides: TranslationStringsObject) {
const lookupUrl = "/translations.json";
const fn = (url: string): TranslationStringsObject => {
expect(url).toEqual(lookupUrl);
return overrides;
};
SdkConfig.add({
custom_translations_url: lookupUrl,
});
CustomTranslationOptions.lookupFn = fn;
await registerCustomTranslations({
testOnlyIgnoreCustomTranslationsCache: true,
});
}
describe("languageHandler", () => {
beforeEach(async () => {
await setLanguage("en");
});
afterEach(() => {
SdkConfig.reset();
CustomTranslationOptions.lookupFn = undefined;
});
it("should support overriding translations", async () => {
const str: TranslationKey = "power_level|default";
const enOverride: Translation = "Visitor";
const deOverride: Translation = "Besucher";
// First test that overrides aren't being used
await setLanguage("en");
expect(_t(str)).toMatchInlineSnapshot(`"Default"`);
await setLanguage("de");
expect(_t(str)).toMatchInlineSnapshot(`"Standard"`);
await setupTranslationOverridesForTests({
[str]: {
en: enOverride,
de: deOverride,
},
});
// Now test that they *are* being used
await setLanguage("en");
expect(_t(str)).toEqual(enOverride);
await setLanguage("de");
expect(_t(str)).toEqual(deOverride);
});
it("should support overriding plural translations", async () => {
const str: TranslationKey = "voip|n_people_joined";
const enOverride: Translation = {
other: "%(count)s people in the call",
one: "%(count)s person in the call",
};
const deOverride: Translation = {
other: "%(count)s Personen im Anruf",
one: "%(count)s Person im Anruf",
};
// First test that overrides aren't being used
await setLanguage("en");
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person joined"`);
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people joined"`);
await setLanguage("de");
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person beigetreten"`);
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen beigetreten"`);
await setupTranslationOverridesForTests({
[str]: {
en: enOverride,
de: deOverride,
},
});
// Now test that they *are* being used
await setLanguage("en");
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person in the call"`);
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people in the call"`);
await setLanguage("de");
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person im Anruf"`);
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen im Anruf"`);
});
describe("UserFriendlyError", () => {
const testErrorMessage = "This email address is already in use (%(email)s)" as TranslationKey;
beforeEach(async () => {
// Setup some strings with variable substituations that we can use in the tests.
const deOverride = "Diese E-Mail-Adresse wird bereits verwendet (%(email)s)";
await setupTranslationOverridesForTests({
[testErrorMessage]: {
en: testErrorMessage,
de: deOverride,
},
});
});
it("includes English message and localized translated message", async () => {
await setLanguage("de");
const friendlyError = new UserFriendlyError(testErrorMessage, {
email: "test@example.com",
cause: undefined,
});
// Ensure message is in English so it's readable in the logs
expect(friendlyError.message).toStrictEqual("This email address is already in use (test@example.com)");
// Ensure the translated message is localized appropriately
expect(friendlyError.translatedMessage).toStrictEqual(
"Diese E-Mail-Adresse wird bereits verwendet (test@example.com)",
);
});
it("includes underlying cause error", async () => {
await setLanguage("de");
const underlyingError = new Error("Fake underlying error");
const friendlyError = new UserFriendlyError(testErrorMessage, {
email: "test@example.com",
cause: underlyingError,
});
expect(friendlyError.cause).toStrictEqual(underlyingError);
});
it("ok to omit the substitution variables and cause object, there just won't be any cause", async () => {
const friendlyError = new UserFriendlyError("foo error" as TranslationKey);
expect(friendlyError.cause).toBeUndefined();
});
});
describe("getAllLanguagesWithLabels", () => {
it("should handle unknown language sanely", async () => {
fetchMock.getOnce(
"/i18n/languages.json",
{
en: "en_EN.json",
de: "de_DE.json",
qq: "qq.json",
},
{ overwriteRoutes: true },
);
await expect(getAllLanguagesWithLabels()).resolves.toMatchInlineSnapshot(`
[
{
"label": "English",
"labelInTargetLanguage": "English",
"value": "en",
},
{
"label": "German",
"labelInTargetLanguage": "Deutsch",
"value": "de",
},
{
"label": "qq",
"labelInTargetLanguage": "qq",
"value": "qq",
},
]
`);
setupLanguageMock(); // restore language mock
});
});
});
describe("languageHandler JSX", function () {
// See setupLanguage.ts for how we are stubbing out translations to provide fixture data for these tests
const basicString = "common|rooms";
const selfClosingTagSub = "Accept to continue:" as TranslationKey;
const textInTagSub = "Upgrade to your own domain" as TranslationKey;
const plurals = "common|and_n_others";
const variableSub = "slash_command|ignore_dialog_description";
type TestCase = [
string,
TranslationKey,
Record,
Record | undefined,
TranslatedString,
];
const testCasesEn: TestCase[] = [
// description of the test case, translationString, variables, tags, expected result
["translates a basic string", basicString, {}, undefined, "Rooms"],
["handles plurals when count is 0", plurals, { count: 0 }, undefined, "and 0 others..."],
["handles plurals when count is 1", plurals, { count: 1 }, undefined, "and one other..."],
["handles plurals when count is not 1", plurals, { count: 2 }, undefined, "and 2 others..."],
["handles simple variable substitution", variableSub, { userId: "foo" }, undefined, "You are now ignoring foo"],
[
"handles simple tag substitution",
selfClosingTagSub,
{},
{ policyLink: () => "foo" },
"Accept foo to continue:",
],
["handles text in tags", textInTagSub, {}, { a: (sub: string) => `x${sub}x` }, "xUpgradex to your own domain"],
[
"handles variable substitution with React function component",
variableSub,
{ userId: () => foo },
undefined,
// eslint-disable-next-line react/jsx-key
You are now ignoring foo
,
],
[
"handles variable substitution with react node",
variableSub,
{ userId: foo },
undefined,
// eslint-disable-next-line react/jsx-key
You are now ignoring foo
,
],
[
"handles tag substitution with React function component",
selfClosingTagSub,
{},
{ policyLink: () => foo },
// eslint-disable-next-line react/jsx-key
Accept foo to continue:
,
],
];
let oldNodeEnv: string | undefined;
beforeAll(() => {
oldNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "test";
});
afterAll(() => {
process.env.NODE_ENV = oldNodeEnv;
});
describe("when translations exist in language", () => {
beforeEach(function () {
stubClient();
setLanguage("en");
setMissingEntryGenerator((key) => key.split("|", 2)[1]);
});
it("translates a string to german", async () => {
await setLanguage("de");
const translated = _t(basicString);
expect(translated).toBe("Räume");
});
it.each(testCasesEn)("%s", (_d, translationString, variables, tags, result) => {
expect(_t(translationString, variables, tags!)).toEqual(result);
});
it("replacements in the wrong order", function () {
const text = "%(var1)s %(var2)s" as TranslationKey;
expect(_t(text, { var2: "val2", var1: "val1" })).toBe("val1 val2");
});
it("multiple replacements of the same variable", function () {
const text = "%(var1)s %(var1)s";
expect(substitute(text, { var1: "val1" })).toBe("val1 val1");
});
it("multiple replacements of the same tag", function () {
const text = "Click here to join the discussion! or here";
expect(substitute(text, {}, { a: (sub) => `x${sub}x` })).toBe(
"xClick herex to join the discussion! xor herex",
);
});
});
describe("for a non-en language", () => {
beforeEach(() => {
stubClient();
setLanguage("lv");
// counterpart doesnt expose any way to restore default config
// missingEntryGenerator is mocked in the root setup file
// reset to default here
const counterpartDefaultMissingEntryGen = function (key: string) {
return "missing translation: " + key;
};
setMissingEntryGenerator(counterpartDefaultMissingEntryGen);
});
// mocked lv has only `"Uploading %(filename)s and %(count)s others|one"`
const lvExistingPlural = "room|upload|uploading_multiple_file";
const lvNonExistingPlural = "%(spaceName)s and %(count)s others";
describe("pluralization", () => {
const pluralCases = [
[
"falls back when plural string exists but not for for count",
lvExistingPlural,
{ count: 2, filename: "test.txt" },
undefined,
"Uploading test.txt and 2 others",
],
[
"falls back when plural string does not exists at all",
lvNonExistingPlural,
{ count: 2, spaceName: "test" },
undefined,
"test and 2 others",
],
] as TestCase[];
describe("_t", () => {
it("translated correctly when plural string exists for count", () => {
expect(_t(lvExistingPlural, { count: 1, filename: "test.txt" })).toEqual(
"Качване на test.txt и 1 друг",
);
});
it.each(pluralCases)("%s", (_d, translationString, variables, tags, result) => {
expect(_t(translationString, variables, tags!)).toEqual(result);
});
});
describe("_tDom()", () => {
it("translated correctly when plural string exists for count", () => {
expect(_tDom(lvExistingPlural, { count: 1, filename: "test.txt" })).toEqual(
"Качване на test.txt и 1 друг",
);
});
it.each(pluralCases)(
"%s and translates with fallback locale, attributes fallback locale",
(_d, translationString, variables, tags, result) => {
expect(_tDom(translationString, variables, tags!)).toEqual({result});
},
);
});
});
describe("when a translation string does not exist in active language", () => {
describe("_t", () => {
it.each(testCasesEn)(
"%s and translates with fallback locale",
(_d, translationString, variables, tags, result) => {
expect(_t(translationString, variables, tags!)).toEqual(result);
},
);
});
describe("_tDom()", () => {
it.each(testCasesEn)(
"%s and translates with fallback locale, attributes fallback locale",
(_d, translationString, variables, tags, result) => {
expect(_tDom(translationString, variables, tags!)).toEqual({result});
},
);
});
});
});
describe("when languages dont load", () => {
it("_t", () => {
const STRING_NOT_IN_THE_DICTIONARY = "a string that isn't in the translations dictionary" as TranslationKey;
expect(_t(STRING_NOT_IN_THE_DICTIONARY, {})).toEqual(STRING_NOT_IN_THE_DICTIONARY);
});
it("_tDom", () => {
const STRING_NOT_IN_THE_DICTIONARY = "a string that isn't in the translations dictionary" as TranslationKey;
expect(_tDom(STRING_NOT_IN_THE_DICTIONARY, {})).toEqual(
{STRING_NOT_IN_THE_DICTIONARY},
);
});
});
});