|
| 1 | +import { act, fireEvent, render, screen } from "@testing-library/react"; |
| 2 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | + |
| 4 | +import { RefreshMeta } from "@/components/site/refresh-meta"; |
| 5 | +import { relativeTimeFromNow } from "@/lib/utils"; |
| 6 | + |
| 7 | +const MINUTE_MS = 60_000; |
| 8 | +const HOUR_MS = 60 * MINUTE_MS; |
| 9 | +const DAY_MS = 24 * HOUR_MS; |
| 10 | + |
| 11 | +describe("relativeTimeFromNow (#2219)", () => { |
| 12 | + const now = Date.UTC(2026, 6, 10, 12, 0, 0); |
| 13 | + |
| 14 | + it("labels each bucket at and around its boundary", () => { |
| 15 | + // seconds bucket, including the exact lower edge and the last second before a minute |
| 16 | + expect(relativeTimeFromNow(now, now)).toBe("just now"); |
| 17 | + expect(relativeTimeFromNow(now - 59_000, now)).toBe("just now"); |
| 18 | + // minutes bucket: 60s flips to 1m; 59m59s still reads 59m |
| 19 | + expect(relativeTimeFromNow(now - MINUTE_MS, now)).toBe("1m ago"); |
| 20 | + expect(relativeTimeFromNow(now - (HOUR_MS - 1000), now)).toBe("59m ago"); |
| 21 | + // hours bucket: 60m flips to 1h; 23h59m still reads 23h |
| 22 | + expect(relativeTimeFromNow(now - HOUR_MS, now)).toBe("1h ago"); |
| 23 | + expect(relativeTimeFromNow(now - (DAY_MS - MINUTE_MS), now)).toBe("23h ago"); |
| 24 | + // days bucket: 24h flips to 1d and keeps counting |
| 25 | + expect(relativeTimeFromNow(now - DAY_MS, now)).toBe("1d ago"); |
| 26 | + expect(relativeTimeFromNow(now - 3 * DAY_MS - 2 * HOUR_MS, now)).toBe("3d ago"); |
| 27 | + }); |
| 28 | + |
| 29 | + it("clamps a marginally-future timestamp to 'just now' instead of a negative age", () => { |
| 30 | + expect(relativeTimeFromNow(now + 5_000, now)).toBe("just now"); |
| 31 | + }); |
| 32 | +}); |
| 33 | + |
| 34 | +describe("RefreshMeta (#2219)", () => { |
| 35 | + beforeEach(() => { |
| 36 | + vi.useFakeTimers(); |
| 37 | + vi.setSystemTime(Date.UTC(2026, 6, 10, 12, 0, 0)); |
| 38 | + }); |
| 39 | + afterEach(() => { |
| 40 | + vi.useRealTimers(); |
| 41 | + }); |
| 42 | + |
| 43 | + it("renders nothing before the first successful load", () => { |
| 44 | + const { container } = render(<RefreshMeta loadedAt={null} onRefresh={() => {}} />); |
| 45 | + expect(container.firstChild).toBeNull(); |
| 46 | + }); |
| 47 | + |
| 48 | + it("shows the relative label for the loaded timestamp", () => { |
| 49 | + render(<RefreshMeta loadedAt={Date.now() - 3 * MINUTE_MS} onRefresh={() => {}} />); |
| 50 | + expect(screen.getByText("last refresh 3m ago")).toBeTruthy(); |
| 51 | + }); |
| 52 | + |
| 53 | + it("advances the label on the interval tick without a reload", () => { |
| 54 | + render(<RefreshMeta loadedAt={Date.now()} onRefresh={() => {}} />); |
| 55 | + expect(screen.getByText("last refresh just now")).toBeTruthy(); |
| 56 | + act(() => { |
| 57 | + vi.advanceTimersByTime(2 * MINUTE_MS); |
| 58 | + }); |
| 59 | + expect(screen.getByText("last refresh 2m ago")).toBeTruthy(); |
| 60 | + }); |
| 61 | + |
| 62 | + it("invokes onRefresh when the refresh button is clicked", () => { |
| 63 | + const onRefresh = vi.fn(); |
| 64 | + render(<RefreshMeta loadedAt={Date.now()} onRefresh={onRefresh} />); |
| 65 | + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); |
| 66 | + expect(onRefresh).toHaveBeenCalledTimes(1); |
| 67 | + }); |
| 68 | + |
| 69 | + it("disables the button while a refresh is in flight", () => { |
| 70 | + const onRefresh = vi.fn(); |
| 71 | + render(<RefreshMeta loadedAt={Date.now()} onRefresh={onRefresh} refreshing />); |
| 72 | + const button = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement; |
| 73 | + expect(button.disabled).toBe(true); |
| 74 | + fireEvent.click(button); |
| 75 | + expect(onRefresh).not.toHaveBeenCalled(); |
| 76 | + }); |
| 77 | + |
| 78 | + it("clears its interval on unmount", () => { |
| 79 | + const clearSpy = vi.spyOn(window, "clearInterval"); |
| 80 | + const { unmount } = render(<RefreshMeta loadedAt={Date.now()} onRefresh={() => {}} />); |
| 81 | + unmount(); |
| 82 | + expect(clearSpy).toHaveBeenCalled(); |
| 83 | + }); |
| 84 | +}); |
0 commit comments