import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RELATIVE_UNITS, formatDate, formatDateTime, formatNumber, formatRuntime, formatTotalHours, relativeTime, relativeFromMs, } from "./format"; import en from "../i18n/locales/en/time.json"; import hu from "../i18n/locales/hu/time.json"; // i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it. // `language` is mutable because relativeFromMs reads it per call to pick the Intl locale — these // cases assert both languages by flipping it. `t` echoes the key: the only string still coming // from the locale files is justNow. const i18nStub = vi.hoisted(() => ({ language: "en", t: (key: string) => key })); vi.mock("../i18n", () => ({ default: i18nStub })); const MIN = 60; const HOUR = 3600; const DAY = 86400; const WEEK = 604800; const MONTH = 2592000; const YEAR = 31536000; // [age in seconds, expected tier, expected en, expected hu]. Every tier is sampled at its exact // opening second and inside it — an interior-only suite still passes if the range comparison // flips to `<=`. The wording comes from Intl/CLDR rather than from us, so a failure here after a // runtime upgrade means the platform's copy moved, not that tier selection broke; the tier column // is what pins our own logic. const CASES: [number, string, string, string][] = [ [MIN - 1, "justNow", "time.justNow", "time.justNow"], [MIN, "minute", "1m ago", "1 perce"], [90, "minute", "1m ago", "1 perce"], [HOUR - 1, "minute", "59m ago", "59 perce"], [HOUR, "hour", "1h ago", "1 órája"], [2 * HOUR, "hour", "2h ago", "2 órája"], [DAY - 1, "hour", "23h ago", "23 órája"], [DAY, "day", "1d ago", "1 napja"], [25 * HOUR, "day", "1d ago", "1 napja"], [WEEK - 1, "day", "6d ago", "6 napja"], [WEEK, "week", "1w ago", "1 hete"], [8 * DAY, "week", "1w ago", "1 hete"], [MONTH - 1, "week", "4w ago", "4 hete"], [MONTH, "month", "1mo ago", "1 hónapja"], [40 * DAY, "month", "1mo ago", "1 hónapja"], [YEAR - 1, "month", "12mo ago", "12 hónapja"], [YEAR, "year", "1y ago", "1 éve"], [400 * DAY, "year", "1y ago", "1 éve"], [10 * YEAR, "year", "10y ago", "10 éve"], ]; // Fixed clock: relativeFromMs reads Date.now(), so the age under test has to be exact. const NOW = Date.UTC(2026, 6, 20, 12, 0, 0); describe("relativeFromMs", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); i18nStub.language = "en"; }); afterEach(() => { vi.useRealTimers(); }); for (const [secs, tier, expectedEn, expectedHu] of CASES) { it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => { expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn); i18nStub.language = "hu"; expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu); }); } it("follows a language switch at runtime", () => { const twoHoursAgo = NOW - 2 * HOUR * 1000; expect(relativeFromMs(twoHoursAgo)).toBe("2h ago"); i18nStub.language = "hu"; expect(relativeFromMs(twoHoursAgo)).toBe("2 órája"); i18nStub.language = "en"; expect(relativeFromMs(twoHoursAgo)).toBe("2h ago"); }); it("clamps a future timestamp instead of counting up", () => { expect(relativeFromMs(NOW + 5 * DAY * 1000)).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(""); }); // Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering // it here fails rather than slipping through unnoticed. it("keeps the unit table sorted ascending", () => { const divisors = RELATIVE_UNITS.map(([secs]) => secs); expect(divisors, "row 0 doubles as the just-now threshold").toEqual( [...divisors].sort((a, b) => a - b), ); expect(new Set(divisors).size, "duplicate divisors would make a tier unreachable").toBe( divisors.length, ); }); it("exercises every tier in the unit table", () => { const covered = new Set(CASES.map(([, tier]) => tier)); for (const [, unit] of RELATIVE_UNITS) { expect(covered, `no case covers ${unit}`).toContain(unit); } }); it("still has the one string Intl cannot produce, in both languages", () => { expect(en).toHaveProperty("justNow"); expect(hu).toHaveProperty("justNow"); }); }); // formatRuntime went from "one Plex movie's length" to "a playlist's total playtime" (E9 12D), and a // TOTAL is the only input that can legitimately be 1-59 seconds. That tier had no coverage and no // implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail // beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be // dropped again as "dead code" — it is the one branch a whole-playlist figure reaches. // A bare `n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a Hungarian browser // printed "1 234 567" (and a Hungarian UI in an English browser "1,234,567"). The case below asserts // BOTH languages and that the two differ, rather than comparing against the runtime's own default: // the default is whatever locale the machine running the suite has (Hungarian on the dev box, English // on a CI image), so a one-language assertion passes under the old bug on half the machines. describe("formatNumber", () => { afterEach(() => { i18nStub.language = "en"; }); it("groups in the UI language, not in the runtime's", () => { const asEnglish = formatNumber(1234567); i18nStub.language = "hu"; const asHungarian = formatNumber(1234567); 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. expect(asEnglish).not.toBe(asHungarian); }); 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)); i18nStub.language = "hu"; expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000)); i18nStub.language = "en"; expect(formatNumber(1000)).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 }); 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)} h`); }); }); describe("formatRuntime", () => { const CASES: [number | null | undefined, string][] = [ [null, ""], [undefined, ""], [0, ""], // nothing to show — the call sites suppress their separator on the empty string [1, "1s"], [45, "45s"], // one Short: the regression case [45.7, "45s"], // truncates, so the seconds tier cannot overstate either [59, "59s"], // last second before the minute tier [60, "1m"], [90, "1m"], // floors, never rounds up — a total must not overstate [90.9, "1m"], [3599, "59m"], [3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation [5025, "1h 23m"], [360000, "100h 0m"], // a large aggregate keeps counting hours rather than rolling to days ]; it.each(CASES)("formats %o as %o", (sec, expected) => { expect(formatRuntime(sec)).toBe(expected); }); it("never reports a non-zero duration as zero", () => { for (let sec = 1; sec < 3600; sec++) { expect(formatRuntime(sec), `${sec}s`).not.toBe("0m"); } }); }); // Dates had the same bug as numbers (a bare `toLocaleString()`), and the same trap for a test: the // runtime's own locale and time zone are the dev box's, so both languages — and a zone west of UTC — // are pinned explicitly. describe("formatDateTime / formatDate", () => { const savedTz = process.env.TZ; afterEach(() => { i18nStub.language = "en"; if (savedTz === undefined) delete process.env.TZ; else process.env.TZ = savedTz; }); it("formats a timestamp in the UI language, not in the runtime's", () => { const iso = "2026-09-17T08:30:14Z"; const asEnglish = formatDateTime(iso); i18nStub.language = "hu"; const asHungarian = formatDateTime(iso); expect(asEnglish).toBe(new Date(iso).toLocaleString("en")); expect(asHungarian).toBe(new Date(iso).toLocaleString("hu")); expect(asEnglish).not.toBe(asHungarian); expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu")); }); it("shows a date-only value as that calendar day in every time zone", () => { process.env.TZ = "America/New_York"; expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026"); process.env.TZ = "Pacific/Kiritimati"; expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026"); }); });