632d8f4bc7
* Update matrix-web-i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix custom translations for structured JSON nested fields Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix import Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix export Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update @matrix-org/react-sdk-module-api Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update matrix-web-i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update matrix-web-i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
/*
|
|
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 <policyLink /> to continue:" as TranslationKey;
|
|
const textInTagSub = "<a>Upgrade</a> to your own domain" as TranslationKey;
|
|
const plurals = "and %(count)s others...";
|
|
const variableSub = "slash_command|ignore_dialog_description";
|
|
|
|
type TestCase = [
|
|
string,
|
|
TranslationKey,
|
|
Record<string, unknown>,
|
|
Record<string, unknown> | 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: () => <i>foo</i> },
|
|
undefined,
|
|
// eslint-disable-next-line react/jsx-key
|
|
<span>
|
|
You are now ignoring <i>foo</i>
|
|
</span>,
|
|
],
|
|
[
|
|
"handles variable substitution with react node",
|
|
variableSub,
|
|
{ userId: <i>foo</i> },
|
|
undefined,
|
|
// eslint-disable-next-line react/jsx-key
|
|
<span>
|
|
You are now ignoring <i>foo</i>
|
|
</span>,
|
|
],
|
|
[
|
|
"handles tag substitution with React function component",
|
|
selfClosingTagSub,
|
|
{},
|
|
{ policyLink: () => <i>foo</i> },
|
|
// eslint-disable-next-line react/jsx-key
|
|
<span>
|
|
Accept <i>foo</i> to continue:
|
|
</span>,
|
|
],
|
|
];
|
|
|
|
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 = "<a>Click here</a> to join the discussion! <a>or here</a>";
|
|
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(<span lang="en">{result}</span>);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
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(<span lang="en">{result}</span>);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
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(
|
|
<span lang="en">{STRING_NOT_IN_THE_DICTIONARY}</span>,
|
|
);
|
|
});
|
|
});
|
|
});
|