Files
2026-09-21 23:18:37 +01:00

339 lines
12 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
formatDate,
formatNumber,
formatRelative,
formatRelativeTime,
formatSidebarElapsedTime,
} from "./formats";
import { activateLocale } from "./index";
const FORMAT_DATE = "2026-07-27T00:00:00Z";
const FORMAT_NOW = "2026-07-27T12:00:00Z";
beforeAll(async () => {
// Activate en so the relative-time buckets resolve from the real catalog,
// matching the former timeAgo behavior byte-for-byte.
await activateLocale("en");
});
describe("formatRelative (en, timeAgo-compatible)", () => {
const now = new Date(FORMAT_NOW).getTime();
const ago = (ms: number) => new Date(now - ms).toISOString();
it("returns empty string for empty or invalid input", () => {
expect(formatRelative("", now)).toBe("");
expect(formatRelative("not-a-date", now)).toBe("");
});
it("returns 'just now' under a minute", () => {
expect(formatRelative(ago(30_000), now)).toBe("just now");
});
it("formats minutes, hours, and days", () => {
expect(formatRelative(ago(5 * 60_000), now)).toBe("5m ago");
expect(formatRelative(ago(3 * 3_600_000), now)).toBe("3h ago");
expect(formatRelative(ago(2 * 86_400_000), now)).toBe("2d ago");
});
});
describe("formatRelative (ja)", () => {
const now = new Date(FORMAT_NOW).getTime();
const ago = (ms: number) => new Date(now - ms).toISOString();
afterAll(async () => {
await activateLocale("en");
});
it("uses Japanese catalog buckets", async () => {
await activateLocale("ja");
expect(formatRelative(ago(30_000), now)).toBe("たった今");
expect(formatRelative(ago(5 * 60_000), now)).toBe("5分前");
expect(formatRelative(ago(3 * 3_600_000), now)).toBe("3時間前");
expect(formatRelative(ago(2 * 86_400_000), now)).toBe("2日前");
});
});
describe("formatSidebarElapsedTime", () => {
const now = new Date(FORMAT_NOW).getTime();
const ago = (seconds: number) => new Date(now - seconds * 1000).toISOString();
it("returns an empty string for empty or invalid input", () => {
expect(formatSidebarElapsedTime("", now)).toBe("");
expect(formatSidebarElapsedTime("not-a-date", now)).toBe("");
});
it("clamps future timestamps to zero seconds", () => {
expect(formatSidebarElapsedTime(new Date(now + 60_000).toISOString(), now)).toBe("0s");
});
it("uses the compact elapsed-time bucket boundaries", () => {
expect(formatSidebarElapsedTime(ago(59), now)).toBe("59s");
expect(formatSidebarElapsedTime(ago(60), now)).toBe("1m");
expect(formatSidebarElapsedTime(ago(59 * 60 + 59), now)).toBe("59m");
expect(formatSidebarElapsedTime(ago(60 * 60), now)).toBe("1h");
expect(formatSidebarElapsedTime(ago(24 * 60 * 60 - 1), now)).toBe("23h");
expect(formatSidebarElapsedTime(ago(24 * 60 * 60), now)).toBe("1d");
expect(formatSidebarElapsedTime(ago(7 * 24 * 60 * 60 - 1), now)).toBe("6d");
expect(formatSidebarElapsedTime(ago(7 * 24 * 60 * 60), now)).toBe("1w");
expect(formatSidebarElapsedTime(ago(365 * 24 * 60 * 60 - 1), now)).toBe("52w");
expect(formatSidebarElapsedTime(ago(365 * 24 * 60 * 60), now)).toBe("1y");
});
it("bounds the displayed years at 99+", () => {
expect(formatSidebarElapsedTime(ago(99 * 365 * 24 * 60 * 60), now)).toBe("99y");
expect(formatSidebarElapsedTime(ago(100 * 365 * 24 * 60 * 60), now)).toBe("99+y");
});
it.each([
["en", "3w"],
["pt-pt", "3sem"],
["zh-cn", "3周"],
["zh-hk", "3週"],
["zh-tw", "3週"],
["ja", "3週間"],
["pseudo", "3ŵ"],
] as const)("uses the %s compact unit catalog", async (locale, expected) => {
await activateLocale(locale);
expect(formatSidebarElapsedTime(ago(3 * 7 * 24 * 60 * 60), now)).toBe(expected);
});
afterAll(async () => {
await activateLocale("en");
});
});
describe("locale-aware Intl wrappers", () => {
it("formats numbers using the active locale", async () => {
await activateLocale("en");
expect(formatNumber(1234567.89)).toBe("1,234,567.89");
});
it("maps the pseudo locale to en for Intl", async () => {
await activateLocale("pseudo");
// pseudo has no CLDR data; wrappers fall back to en formatting.
expect(formatNumber(1000)).toBe("1,000");
expect(formatDate(FORMAT_DATE, { year: "numeric" })).toBe("2026");
await activateLocale("en");
});
it("passes zh-cn to number and date formatters", async () => {
await activateLocale("zh-cn");
const numberOptions: Intl.NumberFormatOptions = {
style: "currency",
currency: "CNY",
currencyDisplay: "name",
};
const dateOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
};
const date = FORMAT_DATE;
expect(formatNumber(1234.5, numberOptions)).toBe(
new Intl.NumberFormat("zh-cn", numberOptions).format(1234.5),
);
expect(formatDate(date, dateOptions)).toBe(
new Intl.DateTimeFormat("zh-cn", dateOptions).format(new Date(date)),
);
await activateLocale("en");
});
it.each([
["zh-tw", "TWD"],
["zh-hk", "HKD"],
] as const)("passes %s to number and date formatters", async (locale, currency) => {
await activateLocale(locale);
const numberOptions: Intl.NumberFormatOptions = {
style: "currency",
currency,
currencyDisplay: "code",
};
const dateOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
};
const date = FORMAT_DATE;
expect(formatNumber(1234.5, numberOptions)).toBe(
new Intl.NumberFormat(locale, numberOptions).format(1234.5),
);
expect(formatDate(date, dateOptions)).toBe(
new Intl.DateTimeFormat(locale, dateOptions).format(new Date(date)),
);
await activateLocale("en");
});
it("passes ja to number and date formatters", async () => {
await activateLocale("ja");
const numberOptions: Intl.NumberFormatOptions = {
style: "currency",
currency: "JPY",
currencyDisplay: "code",
};
const dateOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
};
const date = FORMAT_DATE;
expect(formatNumber(1234.5, numberOptions)).toBe(
new Intl.NumberFormat("ja", numberOptions).format(1234.5),
);
expect(formatDate(date, dateOptions)).toBe(
new Intl.DateTimeFormat("ja", dateOptions).format(new Date(date)),
);
await activateLocale("en");
});
it("passes pt-pt to number and date formatters", async () => {
await activateLocale("pt-pt");
const dateOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
};
const date = FORMAT_DATE;
expect(formatNumber(1234.5)).toBe(new Intl.NumberFormat("pt-pt").format(1234.5));
expect(formatDate(date, dateOptions)).toBe(
new Intl.DateTimeFormat("pt-pt", dateOptions).format(new Date(date)),
);
await activateLocale("en");
});
});
/**
* `host.utils.formatRelativeTime` (docs/plans/plugins/PLUGIN-API.md). Unlike
* `formatRelative` above, this one goes through `Intl.RelativeTimeFormat`
* rather than the message catalog, so it covers the full range up to years
* and localizes without any keys — which is the point: three published
* plugins ship hand-rolled English-only versions of this.
*/
const THREE_HOURS_AGO = "3 hours ago";
describe("formatRelativeTime (en)", () => {
const now = new Date(FORMAT_NOW).getTime();
const secondsAgo = (n: number) => new Date(now - n * 1000);
const secondsAhead = (n: number) => new Date(now + n * 1000);
it("returns an empty string for unparseable input", () => {
expect(formatRelativeTime("not-a-date", now)).toBe("");
expect(formatRelativeTime("", now)).toBe("");
});
it("collapses anything within ten seconds to 'now'", () => {
expect(formatRelativeTime(secondsAgo(0), now)).toBe("now");
expect(formatRelativeTime(secondsAgo(9), now)).toBe("now");
expect(formatRelativeTime(secondsAhead(9), now)).toBe("now");
});
// The threshold is tested against raw milliseconds, not the rounded second
// count. Rounding first moved the real cutoff to 9.5s and made it
// asymmetric — Math.round(-9.5) is -9 but Math.round(9.5) is 10 — so 9.5s
// ago read "now" while 9.5s ahead read "in 10 seconds". Reported by
// @yattdev on #2410.
it("uses a symmetric 10s 'now' cutoff that sub-second input cannot shift", () => {
const ms = (n: number) => new Date(now - n);
expect(formatRelativeTime(ms(9_400), now)).toBe("now");
expect(formatRelativeTime(ms(9_600), now)).toBe("now");
expect(formatRelativeTime(ms(9_900), now)).toBe("now");
expect(formatRelativeTime(ms(9_999), now)).toBe("now");
expect(formatRelativeTime(ms(10_000), now)).toBe("10 seconds ago");
expect(formatRelativeTime(ms(10_100), now)).toBe("10 seconds ago");
});
it("applies the same cutoff to future timestamps as to past ones", () => {
const ahead = (n: number) => new Date(now + n);
expect(formatRelativeTime(ahead(9_500), now)).toBe("now");
expect(formatRelativeTime(ahead(9_900), now)).toBe("now");
expect(formatRelativeTime(ahead(10_000), now)).toBe("in 10 seconds");
});
// The other half of the report — 59.6s rendering as "1 minute ago" — is
// intended. Unit selection rounds to the nearest unit, which describes
// 59.6s better than "59 seconds ago" would.
it("selects the nearest unit rather than truncating toward zero", () => {
expect(formatRelativeTime(new Date(now - 59_600), now)).toBe("1 minute ago");
expect(formatRelativeTime(new Date(now - 59_400), now)).toBe("59 seconds ago");
});
it("reports whole seconds below a minute", () => {
expect(formatRelativeTime(secondsAgo(10), now)).toBe("10 seconds ago");
expect(formatRelativeTime(secondsAgo(59), now)).toBe("59 seconds ago");
});
it("switches to minutes, hours, days and weeks at each boundary", () => {
expect(formatRelativeTime(secondsAgo(60), now)).toBe("1 minute ago");
expect(formatRelativeTime(secondsAgo(90), now)).toBe("1 minute ago");
expect(formatRelativeTime(secondsAgo(60 * 60), now)).toBe("1 hour ago");
expect(formatRelativeTime(secondsAgo(3 * 60 * 60), now)).toBe(THREE_HOURS_AGO);
expect(formatRelativeTime(secondsAgo(2 * 24 * 60 * 60), now)).toBe("2 days ago");
expect(formatRelativeTime(secondsAgo(3 * 7 * 24 * 60 * 60), now)).toBe("3 weeks ago");
expect(formatRelativeTime(secondsAgo(800 * 24 * 60 * 60), now)).toBe("2 years ago");
});
// numeric: "auto" is deliberate — CLDR phrases the small magnitudes
// idiomatically, which no interpolated number can do across locales.
it("uses idiomatic phrasing for a single unit either side", () => {
expect(formatRelativeTime(secondsAgo(24 * 60 * 60), now)).toBe("yesterday");
expect(formatRelativeTime(secondsAhead(24 * 60 * 60), now)).toBe("tomorrow");
expect(formatRelativeTime(secondsAgo(400 * 24 * 60 * 60), now)).toBe("last year");
});
it("phrases future timestamps as future", () => {
expect(formatRelativeTime(secondsAhead(3 * 60 * 60), now)).toBe("in 3 hours");
expect(formatRelativeTime(secondsAhead(5 * 24 * 60 * 60), now)).toBe("in 5 days");
});
it("accepts a Date, an epoch number, and an ISO string alike", () => {
const date = secondsAgo(3 * 60 * 60);
expect(formatRelativeTime(date, now)).toBe(THREE_HOURS_AGO);
expect(formatRelativeTime(date.getTime(), now)).toBe(THREE_HOURS_AGO);
expect(formatRelativeTime(date.toISOString(), now)).toBe(THREE_HOURS_AGO);
});
});
/**
* The reason this lives in the host rather than in each plugin. A hand-rolled
* "3 hours ago" ladder is English-only by construction; Intl reads the active
* locale, so the same call renders Portuguese for a pt-PT user.
*/
describe("formatRelativeTime (locale awareness)", () => {
const now = new Date(FORMAT_NOW).getTime();
const threeHoursAgo = new Date(now - 3 * 60 * 60 * 1000);
afterAll(async () => {
await activateLocale("en");
});
it("renders in the active locale, not English", async () => {
const english = formatRelativeTime(threeHoursAgo, now);
await activateLocale("pt-pt");
const portuguese = formatRelativeTime(threeHoursAgo, now);
expect(portuguese).not.toBe(english);
expect(portuguese).toContain("3");
expect(portuguese).toMatch(/horas/i);
});
it("renders Japanese relative time once ja is active", async () => {
await activateLocale("ja");
const japanese = formatRelativeTime(threeHoursAgo, now);
expect(japanese).toContain("3");
expect(japanese).toMatch(/時間/);
});
});