import { afterEach, describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n";
const de: Catalog = {
strings: {
"Archive": "Archivieren",
"Move {n} to {folder}": "{n} nach {folder} verschieben",
// German puts the parts in a different order, which is the whole reason
// the element is a named hole rather than a split sentence.
"Open {scheme} links here": "{scheme}-Links hier öffnen",
},
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
};
/* Russian is the reason plural() does not take (one, other): it needs three
forms, and which one applies is not a question about the number 1. */
const ru: Catalog = {
strings: {},
plurals: { "{n} messages": { one: "{n} сообщение", few: "{n} сообщения", many: "{n} сообщений", other: "{n} сообщения" } },
};
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("t", () => {
it("returns the English it was given when nothing is loaded", () => {
// The whole point of English-as-key: a missing translation degrades to
// readable English rather than to a symbolic name leaking into the UI.
expect(t("Archive")).toBe("Archive");
expect(currentLanguage()).toBe("en");
});
it("translates once a catalogue is in force", () => {
setCatalog("de", de);
expect(t("Archive")).toBe("Archivieren");
});
it("falls back per string, not per catalogue", () => {
setCatalog("de", de);
expect(t("Report spam")).toBe("Report spam");
});
});
describe("interpolation", () => {
it("fills named placeholders", () => {
expect(interpolate("Move {n} to {folder}", { n: 3, folder: "Archive" })).toBe("Move 3 to Archive");
});
it("survives a translator reordering the sentence", () => {
// Positional arguments would not: German moves the parts around and means
// the same thing.
setCatalog("de", de);
expect(t("Move {n} to {folder}", { n: 3, folder: "Archiv" })).toBe("3 nach Archiv verschieben");
});
it("leaves an unknown placeholder alone rather than printing undefined", () => {
expect(interpolate("Hello {who}", {})).toBe("Hello {who}");
});
});
describe("plural", () => {
const FORMS = { one: "{n} message", other: "{n} messages" };
it("picks the English form without a catalogue", () => {
expect(plural(1, FORMS)).toBe("1 message");
expect(plural(0, FORMS)).toBe("0 messages");
expect(plural(5, FORMS)).toBe("5 messages");
});
it("uses the target language's own rule, not English's", () => {
setCatalog("ru", ru);
expect(plural(1, FORMS)).toBe("1 сообщение"); // one
expect(plural(3, FORMS)).toBe("3 сообщения"); // few
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
});
it("falls back to `other` when the catalogue lacks the category", () => {
setCatalog("de", de);
// German has no "few"; asking for 3 must not render undefined.
expect(plural(3, FORMS)).toBe("3 Nachrichten");
});
it("takes extra variables alongside the count", () => {
expect(plural(2, { one: "{n} message in {folder}", other: "{n} messages in {folder}" }, { folder: "Inbox" }))
.toBe("2 messages in Inbox");
});
});
describe("tNode", () => {
const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}>);
it("keeps an element inside the sentence", () => {
expect(render(tNode("Open {scheme} links here", { scheme: mailto: })))
.toBe("Open mailto: links here");
});
it("lets a translator move the element", () => {
// Splitting the sentence into two t() calls could not do this: the
// fragments would render in the English order whatever the catalogue said.
setCatalog("de", de);
expect(render(tNode("Open {scheme} links here", { scheme: mailto: })))
.toBe("mailto:-Links hier öffnen");
});
it("leaves a placeholder alone when nothing is supplied for it", () => {
expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here");
});
it("takes plain variables alongside elements", () => {
expect(render(tNode("{count} of {scheme}", { scheme: x }, { count: 3 }))).toBe("3 of x");
});
});
describe("setCatalog", () => {
it("does not announce a change that did not happen", () => {
/*
* The root keys its tree on the language version, so every publish
* remounts the app -- which re-runs the effect that loads the account's
* settings, which calls applyLang, which lands back in setCatalog with the
* same language. Publishing that non-change looped for ever, and from the
* outside it looked like the message list refreshing without end.
*/
const seen: number[] = [];
const stop = subscribeForTest(() => seen.push(1));
const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
setCatalog("de", cat);
setCatalog("de", cat);
setCatalog("de", cat);
expect(seen.length).toBe(1);
setCatalog("en", { strings: {}, plurals: {} });
expect(seen.length).toBe(2);
stop();
});
});
describe("tc", () => {
it("tells apart an English word doing two jobs", () => {
// "Archive" is the button and the folder; German wants a different word
// for each, and one key cannot hold both.
setCatalog("de", {
strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
plurals: {},
});
expect(t("Archive")).toBe("Archivieren");
expect(tc("folder", "Archive")).toBe("Archiv");
});
it("falls back to the plain translation, then to English", () => {
setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
});
});