fix(i18n): every counted string is a plural pair, so one reads singular

"1 seasons", "Synced 1 subscriptions", "1 video(s) were skipped": 21 English
strings carried {{count}} in one plain key and read plural at one. Each is a
_one/_other pair now (Hungarian keeps one form for both), and so is every other
counted string, so the next wording change is a plural too. A contract test
holds the rule for every locale file.
This commit is contained in:
2026-10-02 02:13:17 +02:00
parent e4c80fb7bb
commit 98d0ca6eeb
31 changed files with 177 additions and 72 deletions
+37
View File
@@ -6,6 +6,20 @@ import { describe, expect, it } from "vitest";
import i18n from "./index";
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
string,
{ default: Record<string, unknown> }
>;
// Every leaf string as [dotted key, value].
function leaves(obj: Record<string, unknown>, prefix = ""): [string, string][] {
return Object.entries(obj).flatMap(([k, v]) => {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object") return leaves(v as Record<string, unknown>, key);
return typeof v === "string" ? [[key, v] as [string, string]] : [];
});
}
describe("counted copy", () => {
it("the share link's expiry is singular for one day", async () => {
await i18n.changeLanguage("en");
@@ -22,3 +36,26 @@ describe("counted copy", () => {
expect(i18n.t("downloads.share.views", { count: 0 })).toBe("0 views");
});
});
// E31: "1 seasons", "Synced 1 subscriptions" — a counted string written as one plain key reads plural at
// one. So the rule is held for every string, not key by key: a value that carries {{count}} is a plural
// pair (`_one` / `_other`), even where the two read the same today, so the next wording is a plural too.
describe("every counted string is a plural pair", () => {
it("no {{count}} in a key without a plural suffix, in any language", () => {
const bare = Object.entries(mods).flatMap(([path, mod]) =>
leaves(mod.default)
.filter(([k, v]) => v.includes("{{count}}") && !/_(zero|one|two|few|many|other)$/.test(k))
.map(([k]) => `${path.replace(/^.*locales\//, "")}: ${k}`),
);
expect(bare).toEqual([]);
});
it("reads singular at one", async () => {
await i18n.changeLanguage("en");
expect(i18n.t("plex.seasons", { count: 1 })).toBe("1 season");
expect(i18n.t("plex.seasons", { count: 3 })).toBe("3 seasons");
expect(i18n.t("channels.notify.synced", { count: 1 })).toBe("Synced 1 subscription");
expect(i18n.t("playlists.pushPartial", { count: 1 })).toBe("Synced, but 1 video was skipped");
expect(i18n.t("playlists.pushPartial", { count: 2 })).toBe("Synced, but 2 videos were skipped");
});
});