Round 2 mutation-checked round 1's own fix and found the one line it left uncovered: every table row was an integer, so `Math.floor(sec)` in the new seconds tier could be deleted or swapped for round/ceil with the suite still green — exactly the "later cleanup removes it as redundant" failure the fix's comment claims to prevent. Two fractional rows kill all three mutants.
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { RELATIVE_UNITS, formatRuntime, 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.
|
|
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");
|
|
}
|
|
});
|
|
});
|