fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RELATIVE_UNITS,
|
||||
formatBytes,
|
||||
formatCompact,
|
||||
formatCountOrDash,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDateTimeSec,
|
||||
@@ -69,29 +74,29 @@ describe("relativeFromMs", () => {
|
||||
|
||||
for (const [secs, tier, expectedEn, expectedHu] of CASES) {
|
||||
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
|
||||
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn);
|
||||
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn);
|
||||
i18nStub.language = "hu";
|
||||
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu);
|
||||
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu);
|
||||
});
|
||||
}
|
||||
|
||||
it("follows a language switch at runtime", () => {
|
||||
const twoHoursAgo = NOW - 2 * HOUR * 1000;
|
||||
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
|
||||
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
|
||||
i18nStub.language = "hu";
|
||||
expect(relativeFromMs(twoHoursAgo)).toBe("2 órája");
|
||||
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája");
|
||||
i18nStub.language = "en";
|
||||
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
|
||||
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
|
||||
});
|
||||
|
||||
it("clamps a future timestamp instead of counting up", () => {
|
||||
expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow");
|
||||
expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow");
|
||||
});
|
||||
|
||||
it("renders nothing for a timestamp it cannot read", () => {
|
||||
expect(relativeTime("not-a-date")).toBe("");
|
||||
expect(relativeTime(null)).toBe("");
|
||||
expect(relativeFromMs(NaN)).toBe("");
|
||||
expect(relativeTime("not-a-date", i18nStub.language)).toBe("");
|
||||
expect(relativeTime(null, i18nStub.language)).toBe("");
|
||||
expect(relativeFromMs(NaN, i18nStub.language)).toBe("");
|
||||
});
|
||||
|
||||
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
|
||||
@@ -135,9 +140,9 @@ describe("formatNumber", () => {
|
||||
});
|
||||
|
||||
it("groups in the UI language, not in the runtime's", () => {
|
||||
const asEnglish = formatNumber(1234567);
|
||||
const asEnglish = formatNumber(1234567, i18nStub.language);
|
||||
i18nStub.language = "hu";
|
||||
const asHungarian = formatNumber(1234567);
|
||||
const asHungarian = formatNumber(1234567, i18nStub.language);
|
||||
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
|
||||
expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567));
|
||||
// The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale.
|
||||
@@ -145,24 +150,26 @@ describe("formatNumber", () => {
|
||||
});
|
||||
|
||||
it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
|
||||
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
i18nStub.language = "hu";
|
||||
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000));
|
||||
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("hu").format(1000));
|
||||
i18nStub.language = "en";
|
||||
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
});
|
||||
|
||||
it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
|
||||
expect(formatNumber(null)).toBe("");
|
||||
expect(formatNumber(undefined)).toBe("");
|
||||
expect(formatNumber(NaN)).toBe("");
|
||||
expect(formatNumber(Infinity)).toBe("");
|
||||
expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one
|
||||
expect(formatNumber(null, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(undefined, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(NaN, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(Infinity, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(0, i18nStub.language)).toBe("0"); // a real zero is a value, not a missing one
|
||||
});
|
||||
|
||||
it("carries the UI language into the channel total-hours label", () => {
|
||||
i18nStub.language = "hu";
|
||||
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} ó`);
|
||||
expect(formatTotalHours(12345 * 3600, i18nStub.language)).toBe(
|
||||
`${new Intl.NumberFormat("hu").format(12345)} ó`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,18 +192,18 @@ describe("formatRuntime", () => {
|
||||
];
|
||||
|
||||
it.each(CASES)("formats %o as %o", (sec, expected) => {
|
||||
expect(formatRuntime(sec)).toBe(expected);
|
||||
expect(formatRuntime(sec, i18nStub.language)).toBe(expected);
|
||||
});
|
||||
|
||||
it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => {
|
||||
i18nStub.language = "hu";
|
||||
expect(formatRuntime(5025)).toBe("1 ó 23 p");
|
||||
expect(formatRuntime(5025, i18nStub.language)).toBe("1 ó 23 p");
|
||||
i18nStub.language = "en";
|
||||
});
|
||||
|
||||
it("never reports a non-zero duration as zero", () => {
|
||||
for (let sec = 1; sec < 3600; sec++) {
|
||||
expect(formatRuntime(sec), `${sec}s`).not.toBe("0 min");
|
||||
expect(formatRuntime(sec, i18nStub.language), `${sec}s`).not.toBe("0 min");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -216,9 +223,9 @@ describe("formatDateTime / formatDate", () => {
|
||||
|
||||
it("formats a timestamp in the UI language, not in the runtime's", () => {
|
||||
const iso = "2026-09-17T08:30:14Z";
|
||||
const asEnglish = formatDateTime(iso);
|
||||
const asEnglish = formatDateTime(iso, i18nStub.language);
|
||||
i18nStub.language = "hu";
|
||||
const asHungarian = formatDateTime(iso);
|
||||
const asHungarian = formatDateTime(iso, i18nStub.language);
|
||||
expect(asEnglish).not.toBe(asHungarian);
|
||||
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu"));
|
||||
});
|
||||
@@ -226,10 +233,10 @@ describe("formatDateTime / formatDate", () => {
|
||||
it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => {
|
||||
process.env.TZ = "UTC";
|
||||
const iso = "2026-09-17T08:30:14Z";
|
||||
expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM");
|
||||
expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM");
|
||||
expect(formatDateTime(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30 AM");
|
||||
expect(formatDateTimeSec(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30:14 AM");
|
||||
i18nStub.language = "hu";
|
||||
expect(formatDateTime(iso)).toBe("2026. szept. 17. 08:30");
|
||||
expect(formatDateTime(iso, i18nStub.language)).toBe("2026. szept. 17. 08:30");
|
||||
expect(formatDate(iso, "hu")).toBe("2026. szept. 17.");
|
||||
});
|
||||
|
||||
@@ -240,3 +247,39 @@ describe("formatDateTime / formatDate", () => {
|
||||
expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026");
|
||||
});
|
||||
});
|
||||
|
||||
// E30 S3: every number a reader sees is in the UI language — the decimal mark, the grouping and the
|
||||
// compact suffix. `toFixed` and hand-built "1.2K" wrote English in both languages. Both languages are
|
||||
// asserted and differ, so an implementation that ignores the language cannot pass.
|
||||
describe("numbers in the UI language", () => {
|
||||
it("writes a rating with the language's decimal mark", () => {
|
||||
expect(formatDecimal(8.25, "en")).toBe("8.3");
|
||||
expect(formatDecimal(8.25, "hu")).toBe("8,3");
|
||||
expect(formatDecimal(8, "hu")).toBe("8,0"); // a rating keeps its decimal
|
||||
expect(formatDecimal(5, "hu", 1, 0)).toBe("5"); // unless asked to drop a zero one
|
||||
});
|
||||
|
||||
it("writes a big count compact, with the language's suffix", () => {
|
||||
// Intl keeps the number and its suffix together with a no-break space
|
||||
const hu = (n: number) => formatCompact(n, "hu").replace(/\u00a0/g, " ");
|
||||
expect(formatCompact(1234, "en")).toBe("1.2K");
|
||||
expect(hu(1234)).toBe("1,2 E");
|
||||
expect(formatCompact(3_400_000, "en")).toBe("3.4M");
|
||||
expect(hu(3_400_000)).toBe("3,4 M");
|
||||
expect(formatCompact(999, "hu")).toBe("999");
|
||||
expect(formatCompact(null, "hu")).toBe("");
|
||||
expect(formatCountOrDash(null, "hu")).toBe("—");
|
||||
});
|
||||
|
||||
it("writes a file size with the language's decimal mark", () => {
|
||||
expect(formatBytes(5 * 1024 ** 3, "en")).toBe("5.0 GB");
|
||||
expect(formatBytes(5 * 1024 ** 3, "hu")).toBe("5,0 GB");
|
||||
expect(formatBytes(501747, "hu")).toBe("490 KB");
|
||||
expect(formatBytes(0, "hu")).toBe("0 B");
|
||||
});
|
||||
|
||||
it("writes a dollar sum compact, never padded", () => {
|
||||
expect(formatMoney(160_000_000, "en")).toBe("$160M");
|
||||
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user