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");
});