import { MantineProvider } from "@mantine/core"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; import { formatCardAge, formatCardTime, formatWorkedDuration } from "../time.js"; import { ElapsedTime, RelativeTime, RelativeTimeProvider } from "./RelativeTime"; afterEach(() => { cleanup(); vi.useRealTimers(); }); const local = (year: number, month: number, day: number, hour = 0, minute = 0) => new Date(year, month - 1, day, hour, minute).toISOString(); test("saved work durations use hours and minutes without inventing missing data", () => { expect(formatWorkedDuration(54300000)).toBe("15h 5m"); expect(formatWorkedDuration(59999)).toBe("<1m"); expect(formatWorkedDuration(60000)).toBe("1m"); for (const value of [null, undefined, -1, NaN]) expect(formatWorkedDuration(value)).toBe("—"); }); test("elapsed time ticks from the turn start, resets for a new turn, and cleans up", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-09T07:12:34Z")); const { rerender, unmount } = render(); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveTextContent("12m"); expect(screen.getByLabelText(/^Elapsed time:/).querySelector("svg")).not.toBeNull(); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveAccessibleName(/Started/); act(() => { vi.advanceTimersByTime(1000); }); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveTextContent(/^12m$/); act(() => { vi.advanceTimersByTime(25000); }); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveTextContent(/^13m$/); rerender(); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveTextContent("1h 13m"); rerender(); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveTextContent("<1m"); unmount(); expect(vi.getTimerCount()).toBe(0); }); test("elapsed time does not invent a start time and clamps clock skew", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-09T07:00:00Z")); const { rerender } = render(); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveTextContent("—"); expect(vi.getTimerCount()).toBe(0); rerender(); expect(screen.getByLabelText(/^Elapsed time:/)).toHaveTextContent("<1m"); }); test("card dates use local calendar days across midnight and year boundaries", () => { const now = new Date(2026, 9, 8, 14, 35).getTime(); expect(formatCardTime(local(2026, 10, 8, 14, 30), { now })).toBe("2:30PM"); expect(formatCardTime(local(2026, 10, 7, 23, 59), { now })).toBe("Yesterday"); expect(formatCardTime(local(2026, 7, 7), { now })).toBe("Jul 7th"); expect(formatCardTime(local(2025, 7, 7), { now })).toBe("Jul 7th, 2025"); expect(formatCardTime(local(2025, 12, 31), { now: new Date(2026, 0, 1, 0, 1).getTime() })).toBe("Yesterday"); expect(formatCardTime(local(2026, 10, 9), { now })).toBe("Oct 9th"); expect(formatCardTime(null, { now })).toBe("—"); }); test("ordinal date labels handle teens and month endings", () => { const now = new Date(2026, 9, 8).getTime(); for (const [day, suffix] of [[1, "st"], [2, "nd"], [3, "rd"], [11, "th"], [12, "th"], [13, "th"], [21, "st"], [31, "st"]] as const) { expect(formatCardTime(local(2026, 7, day), { now })).toBe(`Jul ${day}${suffix}`); } }); test("card time has no clock and preserves exact-time access", () => { render( ); const button = screen.getByRole("button", { name: "Updated time: 2:30PM. Show exact time" }); expect(button.querySelector("svg")).toBeNull(); fireEvent.click(button); expect(button).toHaveAccessibleName(/Show card date/); expect(button).toHaveTextContent("2026"); fireEvent.click(button); expect(button).toHaveTextContent("2:30PM"); }); test("compact card ages use minutes, hours, days, and months at their boundaries", () => { const now = Date.parse("2026-10-10T12:00:00Z"); for (const [age, expected] of [[0, "<1m"], [59999, "<1m"], [60000, "1m"], [3600000, "1h"], [7200000, "2h"], [5 * 86400000, "5d"], [29 * 86400000, "29d"], [30 * 86400000, "1mo"], [75 * 86400000, "2mo"], [-60000, "<1m"]] as const) { expect(formatCardAge(new Date(now - age).toISOString(), { now })).toBe(expected); } expect(formatCardAge("invalid", { now })).toBe("—"); }); test("card format defaults to compact age and changes with the shared setting", () => { const now = new Date(2026, 9, 8, 15).getTime(); const view = (showCalendarDates = false) => ; const { rerender } = render(view()); expect(screen.getByRole("button", { name: /^Updated time:/ })).toHaveTextContent("30m"); fireEvent.click(screen.getByRole("button", { name: /^Updated time:/ })); expect(screen.getByRole("button", { name: /^Updated time:/ })).toHaveAccessibleName(/Show relative time/); fireEvent.click(screen.getByRole("button", { name: /^Updated time:/ })); rerender(view(true)); expect(screen.getByRole("button", { name: /^Updated time:/ })).toHaveTextContent("2:30PM"); rerender(view()); expect(screen.getByRole("button", { name: /^Updated time:/ })).toHaveTextContent("30m"); });