| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js"; |
| import { |
| formatDurationCompact, |
| formatDurationHuman, |
| formatDurationPrecise, |
| formatDurationSeconds, |
| } from "./format-duration.js"; |
| import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js"; |
|
|
| const invalidDurationInputs = [null, undefined, -100] as const; |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe("format-duration", () => { |
| describe("formatDurationCompact", () => { |
| it("returns undefined for null/undefined/non-positive", () => { |
| expect(formatDurationCompact(null)).toBeUndefined(); |
| expect(formatDurationCompact(undefined)).toBeUndefined(); |
| expect(formatDurationCompact(0)).toBeUndefined(); |
| expect(formatDurationCompact(-100)).toBeUndefined(); |
| }); |
|
|
| it("formats compact units and omits trailing zero components", () => { |
| const cases = [ |
| [500, "500ms"], |
| [999, "999ms"], |
| [1000, "1s"], |
| [45000, "45s"], |
| [59000, "59s"], |
| [60000, "1m"], |
| [65000, "1m5s"], |
| [90000, "1m30s"], |
| [3600000, "1h"], |
| [3660000, "1h1m"], |
| [5400000, "1h30m"], |
| [86400000, "1d"], |
| [90000000, "1d1h"], |
| [172800000, "2d"], |
| ] as const; |
| for (const [input, expected] of cases) { |
| expect(formatDurationCompact(input), String(input)).toBe(expected); |
| } |
| }); |
|
|
| it("supports spaced option", () => { |
| expect(formatDurationCompact(65000, { spaced: true })).toBe("1m 5s"); |
| expect(formatDurationCompact(3660000, { spaced: true })).toBe("1h 1m"); |
| expect(formatDurationCompact(90000000, { spaced: true })).toBe("1d 1h"); |
| }); |
|
|
| it("rounds at boundaries", () => { |
| |
| expect(formatDurationCompact(59500)).toBe("1m"); |
| |
| expect(formatDurationCompact(59400)).toBe("59s"); |
| }); |
| }); |
|
|
| describe("formatDurationHuman", () => { |
| it("returns fallback for invalid duration input", () => { |
| for (const value of invalidDurationInputs) { |
| expect(formatDurationHuman(value)).toBe("n/a"); |
| } |
| expect(formatDurationHuman(null, "unknown")).toBe("unknown"); |
| }); |
|
|
| it("formats single-unit outputs and day threshold behavior", () => { |
| const cases = [ |
| [500, "500ms"], |
| [5000, "5s"], |
| [180000, "3m"], |
| [7200000, "2h"], |
| [23 * 3600000, "23h"], |
| [24 * 3600000, "1d"], |
| [25 * 3600000, "1d"], |
| [172800000, "2d"], |
| ] as const; |
| for (const [input, expected] of cases) { |
| expect(formatDurationHuman(input), String(input)).toBe(expected); |
| } |
| }); |
| }); |
|
|
| describe("formatDurationPrecise", () => { |
| it("shows milliseconds for sub-second", () => { |
| expect(formatDurationPrecise(500)).toBe("500ms"); |
| expect(formatDurationPrecise(999)).toBe("999ms"); |
| }); |
|
|
| it("clamps negative and fractional sub-second values to non-negative milliseconds", () => { |
| expect(formatDurationPrecise(-1)).toBe("0ms"); |
| expect(formatDurationPrecise(-500)).toBe("0ms"); |
| expect(formatDurationPrecise(999.6)).toBe("1000ms"); |
| }); |
|
|
| it("shows decimal seconds for >=1s", () => { |
| expect(formatDurationPrecise(1000)).toBe("1s"); |
| expect(formatDurationPrecise(1500)).toBe("1.5s"); |
| expect(formatDurationPrecise(1234)).toBe("1.23s"); |
| }); |
|
|
| it("returns unknown for non-finite", () => { |
| expect(formatDurationPrecise(NaN)).toBe("unknown"); |
| expect(formatDurationPrecise(Infinity)).toBe("unknown"); |
| }); |
| }); |
|
|
| describe("formatDurationSeconds", () => { |
| it("formats with configurable decimals", () => { |
| expect(formatDurationSeconds(1500, { decimals: 1 })).toBe("1.5s"); |
| expect(formatDurationSeconds(1234, { decimals: 2 })).toBe("1.23s"); |
| expect(formatDurationSeconds(1000, { decimals: 0 })).toBe("1s"); |
| }); |
|
|
| it("supports seconds unit", () => { |
| expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds"); |
| }); |
|
|
| it("clamps negative values and rejects non-finite input", () => { |
| expect(formatDurationSeconds(-1500, { decimals: 1 })).toBe("0s"); |
| expect(formatDurationSeconds(NaN)).toBe("unknown"); |
| expect(formatDurationSeconds(Infinity)).toBe("unknown"); |
| }); |
| }); |
| }); |
|
|
| describe("format-datetime", () => { |
| describe("resolveTimezone", () => { |
| it.each([ |
| { input: "America/New_York", expected: "America/New_York" }, |
| { input: "Europe/London", expected: "Europe/London" }, |
| { input: "UTC", expected: "UTC" }, |
| { input: "Invalid/Timezone", expected: undefined }, |
| { input: "garbage", expected: undefined }, |
| { input: "", expected: undefined }, |
| ] as const)("resolves $input", ({ input, expected }) => { |
| expect(resolveTimezone(input)).toBe(expected); |
| }); |
| }); |
|
|
| describe("formatUtcTimestamp", () => { |
| it.each([ |
| { displaySeconds: false, expected: "2024-01-15T14:30Z" }, |
| { displaySeconds: true, expected: "2024-01-15T14:30:45Z" }, |
| ])("formats UTC timestamp (displaySeconds=$displaySeconds)", ({ displaySeconds, expected }) => { |
| const date = new Date("2024-01-15T14:30:45.000Z"); |
| const result = displaySeconds |
| ? formatUtcTimestamp(date, { displaySeconds: true }) |
| : formatUtcTimestamp(date); |
| expect(result).toBe(expected); |
| }); |
| }); |
|
|
| describe("formatZonedTimestamp", () => { |
| it.each([ |
| { |
| date: new Date("2024-01-15T14:30:00.000Z"), |
| options: { timeZone: "UTC" }, |
| expected: /2024-01-15 14:30/, |
| }, |
| { |
| date: new Date("2024-01-15T14:30:45.000Z"), |
| options: { timeZone: "UTC", displaySeconds: true }, |
| expected: /2024-01-15 14:30:45/, |
| }, |
| ] as const)("formats zoned timestamp", ({ date, options, expected }) => { |
| const result = formatZonedTimestamp(date, options); |
| expect(result).toMatch(expected); |
| }); |
|
|
| it("returns undefined when required Intl parts are missing", () => { |
| function MissingPartsDateTimeFormat() { |
| return { |
| formatToParts: () => [ |
| { type: "month", value: "01" }, |
| { type: "day", value: "15" }, |
| { type: "hour", value: "14" }, |
| { type: "minute", value: "30" }, |
| ], |
| } as Intl.DateTimeFormat; |
| } |
|
|
| vi.spyOn(Intl, "DateTimeFormat").mockImplementation( |
| MissingPartsDateTimeFormat as unknown as typeof Intl.DateTimeFormat, |
| ); |
|
|
| expect(formatZonedTimestamp(new Date("2024-01-15T14:30:00.000Z"), { timeZone: "UTC" })).toBe( |
| undefined, |
| ); |
| }); |
|
|
| it("returns undefined when Intl formatting throws", () => { |
| function ThrowingDateTimeFormat() { |
| return { |
| formatToParts: () => { |
| throw new Error("boom"); |
| }, |
| } as unknown as Intl.DateTimeFormat; |
| } |
|
|
| vi.spyOn(Intl, "DateTimeFormat").mockImplementation( |
| ThrowingDateTimeFormat as unknown as typeof Intl.DateTimeFormat, |
| ); |
|
|
| expect(formatZonedTimestamp(new Date("2024-01-15T14:30:00.000Z"), { timeZone: "UTC" })).toBe( |
| undefined, |
| ); |
| }); |
| }); |
| }); |
|
|
| describe("format-relative", () => { |
| describe("formatTimeAgo", () => { |
| it("returns fallback for invalid elapsed input", () => { |
| for (const value of invalidDurationInputs) { |
| expect(formatTimeAgo(value)).toBe("unknown"); |
| } |
| expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a"); |
| }); |
|
|
| it("formats relative age around key unit boundaries", () => { |
| const cases = [ |
| [0, "just now"], |
| [29000, "just now"], |
| [30000, "1m ago"], |
| [300000, "5m ago"], |
| [7200000, "2h ago"], |
| [47 * 3600000, "47h ago"], |
| [48 * 3600000, "2d ago"], |
| [172800000, "2d ago"], |
| ] as const; |
| for (const [input, expected] of cases) { |
| expect(formatTimeAgo(input), String(input)).toBe(expected); |
| } |
| }); |
|
|
| it("omits suffix when suffix: false", () => { |
| expect(formatTimeAgo(0, { suffix: false })).toBe("0s"); |
| expect(formatTimeAgo(300000, { suffix: false })).toBe("5m"); |
| expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h"); |
| }); |
| }); |
|
|
| describe("formatRelativeTimestamp", () => { |
| beforeEach(() => { |
| vi.useFakeTimers(); |
| vi.setSystemTime(new Date("2024-02-10T12:00:00.000Z")); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| }); |
|
|
| it("returns fallback for invalid timestamp input", () => { |
| for (const value of [null, undefined]) { |
| expect(formatRelativeTimestamp(value)).toBe("n/a"); |
| } |
| expect(formatRelativeTimestamp(null, { fallback: "unknown" })).toBe("unknown"); |
| }); |
|
|
| it.each([ |
| { offsetMs: -10000, expected: "just now" }, |
| { offsetMs: -30000, expected: "just now" }, |
| { offsetMs: -300000, expected: "5m ago" }, |
| { offsetMs: -7200000, expected: "2h ago" }, |
| { offsetMs: -(47 * 3600000), expected: "47h ago" }, |
| { offsetMs: -(48 * 3600000), expected: "2d ago" }, |
| { offsetMs: 30000, expected: "in <1m" }, |
| { offsetMs: 300000, expected: "in 5m" }, |
| { offsetMs: 7200000, expected: "in 2h" }, |
| ])("formats relative timestamp for offset $offsetMs", ({ offsetMs, expected }) => { |
| expect(formatRelativeTimestamp(Date.now() + offsetMs)).toBe(expected); |
| }); |
|
|
| it.each([ |
| { |
| name: "keeps 7-day-old timestamps relative", |
| offsetMs: -7 * 24 * 3600000, |
| options: { dateFallback: true, timezone: "UTC" }, |
| expected: "7d ago", |
| }, |
| { |
| name: "falls back to a short date once the timestamp is older than 7 days", |
| offsetMs: -8 * 24 * 3600000, |
| options: { dateFallback: true, timezone: "UTC" }, |
| expected: "Feb 2", |
| }, |
| { |
| name: "keeps relative output when date fallback is disabled", |
| offsetMs: -8 * 24 * 3600000, |
| options: { timezone: "UTC" }, |
| expected: "8d ago", |
| }, |
| ])("$name", ({ offsetMs, options, expected }) => { |
| expect(formatRelativeTimestamp(Date.now() + offsetMs, options)).toBe(expected); |
| }); |
|
|
| it("falls back to relative days when date formatting throws", () => { |
| expect( |
| formatRelativeTimestamp(Date.now() - 8 * 24 * 3600000, { |
| dateFallback: true, |
| timezone: "Invalid/Timezone", |
| }), |
| ).toBe("8d ago"); |
| }); |
| }); |
| }); |
|
|