The chat cut removed the only WebSocket and CI went before it, but comments still leaned on both: `?account=` was justified by the socket, job polling by an in-process WS registry, the e2e flows as "proxy-safe (none use the WebSocket)", and pyrightconfig named a CI container the engine contract test never checked. Each now says what holds today (the wallet gate, no push channel, the two contract test halves, the image build's `npm ci`, the gate).
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
RELATIVE_UNITS,
|
|
formatBytes,
|
|
formatCompact,
|
|
formatCountOrDash,
|
|
formatDecimal,
|
|
formatMoney,
|
|
formatDate,
|
|
formatDateTime,
|
|
formatDateTimeSec,
|
|
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, i18nStub.language)).toBe(expectedEn);
|
|
i18nStub.language = "hu";
|
|
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, i18nStub.language)).toBe("2h ago");
|
|
i18nStub.language = "hu";
|
|
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája");
|
|
i18nStub.language = "en";
|
|
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
|
|
});
|
|
|
|
it("clamps a future timestamp instead of counting up", () => {
|
|
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", 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
|
|
// 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", 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
|
|
// in the test 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);
|
|
i18nStub.language = "hu";
|
|
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.
|
|
expect(asEnglish).not.toBe(asHungarian);
|
|
});
|
|
|
|
it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
|
|
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
|
|
i18nStub.language = "hu";
|
|
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("hu").format(1000));
|
|
i18nStub.language = "en";
|
|
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, 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, i18nStub.language)).toBe(
|
|
`${new Intl.NumberFormat("hu").format(12345)} ó`,
|
|
);
|
|
});
|
|
});
|
|
|
|
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, "1 sec"],
|
|
[45, "45 sec"], // one Short: the regression case
|
|
[45.7, "45 sec"], // truncates, so the seconds tier cannot overstate either
|
|
[59, "59 sec"], // last second before the minute tier
|
|
[60, "1 min"],
|
|
[90, "1 min"], // floors, never rounds up — a total must not overstate
|
|
[90.9, "1 min"],
|
|
[3599, "59 min"],
|
|
[3600, "1 hr 0 min"], // the zero minutes stay: "1 hr" alone reads as an approximation
|
|
[5025, "1 hr 23 min"],
|
|
[360000, "100 hr 0 min"], // a large aggregate keeps counting hours rather than rolling to days
|
|
];
|
|
|
|
it.each(CASES)("formats %o as %o", (sec, 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, 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, i18nStub.language), `${sec}s`).not.toBe("0 min");
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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", () => {
|
|
// Node re-reads TZ when it is ASSIGNED and not when it is deleted, so the zone has to be put back
|
|
// by name — and `process.env.TZ` is unset here, so the name has to come from the resolved zone.
|
|
// Vitest's per-file process would contain a leak today, but only until someone adds a test below.
|
|
const savedTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
afterEach(() => {
|
|
i18nStub.language = "en";
|
|
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);
|
|
i18nStub.language = "hu";
|
|
const asHungarian = formatDateTime(iso, i18nStub.language);
|
|
expect(asEnglish).not.toBe(asHungarian);
|
|
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu"));
|
|
});
|
|
|
|
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, 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, i18nStub.language)).toBe("2026. szept. 17. 08:30");
|
|
expect(formatDate(iso, "hu")).toBe("2026. szept. 17.");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
// 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"));
|
|
});
|
|
});
|