test(format): cover the relative-time tier boundaries

The repo had no unit-test runner, so this adds the minimum: vitest (3.x, as
4.x needs Vite 6), a node-env config deliberately separate from vite.config.ts
so the production build never loads it, npm test / test:watch, and a knip
entry so the config and specs are not reported unused. Browser flows stay in
e2e/ under Playwright.

The suite samples every tier at its exact opening second as well as inside it,
and stubs i18n so each assertion lands on the key rather than the copy. An
interior-only suite still passes when the range comparison flips to <=, which
is how the tier shift went unnoticed for so long.
This commit is contained in:
2026-07-20 03:06:50 +02:00
parent c61bc4ff74
commit da728f0c5e
5 changed files with 568 additions and 3 deletions
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { 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 to
// echo `key:count`. That is exactly what these cases need to lock: which LABEL a range is paired
// with. The bug this suite exists for had every label one tier too small — right number, wrong
// word ("4 min ago" rendered as "4s ago").
vi.mock("../i18n", () => ({
default: {
t: (key: string, opts?: { count?: number }) => (opts ? `${key}:${opts.count}` : key),
},
}));
const MIN = 60;
const HOUR = 3600;
const DAY = 86400;
const WEEK = 604800;
const MONTH = 2592000;
const YEAR = 31536000;
// [name, age in seconds, expected label]. Every tier is sampled both at its exact opening second
// and somewhere inside it — an interior-only suite still passes if the comparison flips to `<=`.
const CASES: [string, number, string][] = [
["just under a minute", MIN - 1, "time.justNow"],
["exactly a minute", MIN, "time.minutesAgo:1"],
["a minute and a half", 90, "time.minutesAgo:1"],
["the last second before an hour", HOUR - 1, "time.minutesAgo:59"],
["exactly an hour", HOUR, "time.hoursAgo:1"],
["two hours", 2 * HOUR, "time.hoursAgo:2"],
["the last second before a day", DAY - 1, "time.hoursAgo:23"],
["exactly a day", DAY, "time.daysAgo:1"],
["just past a day", 25 * HOUR, "time.daysAgo:1"],
["the last second before a week", WEEK - 1, "time.daysAgo:6"],
["exactly a week", WEEK, "time.weeksAgo:1"],
["eight days", 8 * DAY, "time.weeksAgo:1"],
["the last second before a month", MONTH - 1, "time.weeksAgo:4"],
["exactly a month", MONTH, "time.monthsAgo:1"],
["forty days", 40 * DAY, "time.monthsAgo:1"],
["the last second before a year", YEAR - 1, "time.monthsAgo:12"],
["exactly a year", YEAR, "time.yearsAgo:1"],
["four hundred days", 400 * DAY, "time.yearsAgo:1"],
["a decade", 10 * YEAR, "time.yearsAgo:10"],
];
// 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);
});
afterEach(() => {
vi.useRealTimers();
});
for (const [name, secs, expected] of CASES) {
it(`${name} → ${expected}`, () => {
expect(relativeFromMs(NOW - secs * 1000)).toBe(expected);
});
}
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("");
});
it("translates every label these cases expect, in both languages", () => {
const keys = [...new Set(CASES.map(([, , label]) => label.split(":")[0].replace("time.", "")))];
for (const key of keys) {
expect(en, `en/time.json is missing ${key}`).toHaveProperty(key);
expect(hu, `hu/time.json is missing ${key}`).toHaveProperty(key);
}
});
});