import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { act, fireEvent, render, renderHook, screen, } from "@testing-library/react"; import { type ReactNode, useRef } from "react"; import { MemoryRouter, Route, Routes, useLocation, useNavigate, } from "react-router"; import { useFading } from "../../hooks/useFading.ts"; import { useFeedCache } from "../../hooks/useFeedCache.ts"; import { useTabParam } from "../../hooks/useTabParam.ts"; import { setTitleBadge, SITE_NAME, useDocumentTitle, } from "../../hooks/useDocumentTitle.ts"; import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts"; import { useScrollLock } from "../../hooks/useScrollLock.ts"; import { useScrollSave } from "../../hooks/useScrollSave.ts"; import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts"; import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts"; import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts"; import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts"; import { useHeightTransition } from "../../hooks/useHeightTransition.ts"; import { isDumpVisited } from "../../utils/visited.ts"; import { FakeIntersectionObserver } from "../setup.ts"; afterEach(() => vi.useRealTimers()); describe("useFading", () => { beforeEach(() => vi.useFakeTimers()); test("cooldown for 2s, then dismissing for 350ms, then gone", () => { const { result } = renderHook(useFading); act(() => result.current.startFading("a")); expect(result.current.fading).toEqual({ a: "cooldown" }); act(() => vi.advanceTimersByTime(2000)); expect(result.current.fading).toEqual({ a: "dismissing" }); act(() => vi.advanceTimersByTime(350)); expect(result.current.fading).toEqual({}); }); test("starting twice is one animation", () => { const { result } = renderHook(useFading); act(() => result.current.startFading("a")); act(() => vi.advanceTimersByTime(1000)); act(() => result.current.startFading("a")); act(() => vi.advanceTimersByTime(1000)); expect(result.current.fading).toEqual({ a: "dismissing" }); }); test("cancel during cooldown and during dismissal", () => { const { result } = renderHook(useFading); act(() => result.current.startFading("a")); act(() => result.current.cancelFading("a")); expect(result.current.fading).toEqual({}); act(() => vi.advanceTimersByTime(5000)); expect(result.current.fading).toEqual({}); act(() => result.current.startFading("b")); act(() => vi.advanceTimersByTime(2100)); act(() => result.current.cancelFading("b")); expect(result.current.fading).toEqual({}); // Cancelled means it can start over. act(() => result.current.startFading("b")); expect(result.current.fading).toEqual({ b: "cooldown" }); }); test("cancelAll", () => { const { result } = renderHook(useFading); act(() => { result.current.startFading("a"); result.current.startFading("b"); }); act(() => result.current.cancelAll()); expect(result.current.fading).toEqual({}); }); }); describe("useFeedCache", () => { // A MemoryRouter's first entry is a POP, as after Back. const wrapper = () => ({ children }: { children: ReactNode }) => ( {children} ); test("restores a saved feed on a back/forward (POP) visit", () => { const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), { wrapper: wrapper(), }); act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300)); const second = renderHook( () => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })), { wrapper: wrapper() }, ); expect(second.result.current.cached).toMatchObject({ items: [{ n: 10 }], page: 2, hasMore: true, scrollY: 300, }); }); test("an expired entry is ignored", () => { vi.useFakeTimers(); const first = renderHook(() => useFeedCache("k", (x) => x), { wrapper: wrapper(), }); act(() => first.result.current.saveState([], 1, false, 0)); vi.advanceTimersByTime(11 * 60 * 1000); const second = renderHook(() => useFeedCache("k", (x) => x), { wrapper: wrapper(), }); expect(second.result.current.cached).toBeNull(); }); test("a fresh (PUSH) visit ignores the cache", () => { sessionStorage.setItem( "k", JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now(), }), ); function Probe() { const { cached } = useFeedCache("k", (x) => x); return

{cached ? "cached" : "fresh"}

; } function Home() { const navigate = useNavigate(); return ( ); } render( } /> } /> , ); fireEvent.click(screen.getByText("go")); expect(screen.getByText("fresh")).toBeInTheDocument(); }); test("corrupt storage reads as no cache; a full quota doesn't throw", () => { sessionStorage.setItem("k", "{"); const { result } = renderHook(() => useFeedCache("k", (x) => x), { wrapper: wrapper(), }); expect(result.current.cached).toBeNull(); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("QuotaExceededError"); }); expect(() => result.current.saveState([], 1, false, 0)).not.toThrow(); }); }); describe("useTabParam", () => { const TABS = ["dumps", "playlists", "votes"] as const; function setup(path: string, state?: unknown) { let loc!: ReturnType; const hook = renderHook(() => { loc = useLocation(); return useTabParam(TABS, "dumps"); }, { wrapper: ({ children }) => ( {children} ), }); return { hook, loc: () => loc }; } test("reads the tab after /~/", () => { expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe( "playlists", ); expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe( "playlists", ); expect(setup("/users/ann").hook.result.current[0]).toBe("dumps"); expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps"); }); test("the default tab collapses to the base path", () => { const { hook, loc } = setup("/users/ann/~/votes"); act(() => hook.result.current[1]("dumps")); expect(loc().pathname).toBe("/users/ann"); act(() => hook.result.current[1]("playlists")); expect(loc().pathname).toBe("/users/ann/~/playlists"); }); test("at the root the default stays explicit", () => { const { hook, loc } = setup("/~/votes"); act(() => hook.result.current[1]("dumps")); expect(loc().pathname).toBe("/~/dumps"); }); test("keeps the query string and the entry's state", () => { const { hook, loc } = setup("/search?q=cats", { back: "/x" }); act(() => hook.result.current[1]("votes")); expect(loc().pathname).toBe("/search/~/votes"); expect(loc().search).toBe("?q=cats"); expect(loc().state).toEqual({ back: "/x" }); }); }); describe("useDocumentTitle", () => { test("composes page, site name and unread badge", () => { const { rerender } = renderHook( ({ title }: { title: string | null | undefined }) => useDocumentTitle(title), { initialProps: { title: "Cats" as string | null | undefined } }, ); expect(document.title).toBe(`Cats · ${SITE_NAME}`); act(() => setTitleBadge(3)); expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`); act(() => setTitleBadge(150)); expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`); rerender({ title: null }); expect(document.title).toBe(`(99+) ${SITE_NAME}`); act(() => setTitleBadge(0)); expect(document.title).toBe(SITE_NAME); // undefined leaves it be rerender({ title: undefined }); expect(document.title).toBe(SITE_NAME); }); test("the site name defaults to gerbeur when unsubstituted", () => { expect(SITE_NAME).toBe("gerbeur"); }); }); describe("useInfiniteScroll", () => { function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) { const ref = useInfiniteScroll(onMore, enabled); return
; } const intersect = (isIntersecting: boolean) => { const obs = FakeIntersectionObserver.instances.at(-1)!; act(() => obs.report(isIntersecting)); }; test("loads more when the sentinel shows, only while enabled", () => { const onMore = vi.fn(); const { rerender } = render(); expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1); intersect(false); expect(onMore).not.toHaveBeenCalled(); intersect(true); expect(onMore).toHaveBeenCalledTimes(1); rerender(); intersect(true); expect(onMore).toHaveBeenCalledTimes(1); }); // A load that fails re-enables the feed with the sentinel still in view: a // browser's new observer reports it at once, and without a pause that's a // request loop as fast as the request can fail. test("a sentinel that stays in view waits longer each time", () => { vi.useFakeTimers(); const onMore = vi.fn(); render(); intersect(true); expect(onMore).toHaveBeenCalledTimes(1); // Reported again right after each load, as a new observer does: how long // until the next one? const delays: number[] = []; for (let i = 0; i < 8; i++) { const before = onMore.mock.calls.length; intersect(true); let waited = 0; while (onMore.mock.calls.length === before) { act(() => vi.advanceTimersByTime(100)); waited += 100; } delays.push(waited); } expect(delays).toEqual([ 1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000, ]); }); test("re-rendering doesn't restart the wait", () => { vi.useFakeTimers(); const onMore = vi.fn(); const { rerender } = render(); intersect(true); intersect(true); act(() => vi.advanceTimersByTime(600)); // A new onLoadMore (the feed's state changed) remakes the observer. rerender( onMore()} enabled />); intersect(true); act(() => vi.advanceTimersByTime(400)); expect(onMore).toHaveBeenCalledTimes(2); }); test("leaving the view, or new items pushing it down, asks at once again", () => { vi.useFakeTimers(); const onMore = vi.fn(); render(); const at = (top: number) => { const obs = FakeIntersectionObserver.instances.at(-1)!; act(() => obs.report(true, top)); }; at(500); at(500); expect(onMore).toHaveBeenCalledTimes(1); // waiting act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500)); at(500); expect(onMore).toHaveBeenCalledTimes(2); at(500); at(900); // the page grew: that load worked expect(onMore).toHaveBeenCalledTimes(3); }); test("a pending retry is dropped when the feed is disabled or unmounted", () => { vi.useFakeTimers(); const onMore = vi.fn(); const { rerender, unmount } = render(); intersect(true); intersect(true); rerender(); act(() => vi.advanceTimersByTime(60_000)); expect(onMore).toHaveBeenCalledTimes(1); rerender(); intersect(true); // long overdue: at once expect(onMore).toHaveBeenCalledTimes(2); intersect(true); unmount(); act(() => vi.advanceTimersByTime(60_000)); expect(onMore).toHaveBeenCalledTimes(2); }); }); describe("useScrollLock", () => { function Lock() { useScrollLock(); return null; } test("counts nested locks", () => { const root = document.documentElement; const a = render(); const b = render(); expect(root.style.overflowY).toBe("hidden"); a.unmount(); expect(root.style.overflowY).toBe("hidden"); b.unmount(); expect(root.style.overflowY).toBe(""); }); }); describe("useScrollSave", () => { const scrollTo = (y: number) => { Object.defineProperty(globalThis, "scrollY", { value: y, configurable: true, }); globalThis.dispatchEvent(new Event("scroll")); }; test("saves the last position when the page is left, not while scrolling", () => { const onSave = vi.fn(); const { unmount } = renderHook(() => useScrollSave(true, onSave)); scrollTo(100); scrollTo(250); expect(onSave).not.toHaveBeenCalled(); // The next page has already scrolled back up by the time this unmounts. Object.defineProperty(globalThis, "scrollY", { value: 0, configurable: true, }); unmount(); expect(onSave).toHaveBeenCalledExactlyOnceWith(250); }); test("saves on pagehide, with the latest callback", () => { const first = vi.fn(); const second = vi.fn(); const { rerender, unmount } = renderHook( ({ cb }) => useScrollSave(true, cb), { initialProps: { cb: first } }, ); scrollTo(40); rerender({ cb: second }); globalThis.dispatchEvent(new Event("pagehide")); expect(first).not.toHaveBeenCalled(); expect(second).toHaveBeenCalledExactlyOnceWith(40); unmount(); }); test("nothing is tracked or saved while disabled", () => { const onSave = vi.fn(); const { unmount } = renderHook(() => useScrollSave(false, onSave)); scrollTo(10); globalThis.dispatchEvent(new Event("pagehide")); unmount(); expect(onSave).not.toHaveBeenCalled(); }); }); describe("stored preferences", () => { test("useDefaultFeedTab validates and persists", () => { localStorage.setItem("default-feed-tab", "bogus"); const { result } = renderHook(useDefaultFeedTab); expect(result.current[0]).toBe("journal"); act(() => result.current[1]("new")); expect(result.current[0]).toBe("new"); expect(localStorage.getItem("default-feed-tab")).toBe("new"); expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new"); }); test("useSoundsEnabled", () => { const { result } = renderHook(useSoundsEnabled); expect(result.current[0]).toBe(true); act(() => result.current[1](false)); expect(result.current[0]).toBe(false); expect(localStorage.getItem("sounds")).toBe("off"); }); }); describe("useDumpCardLink", () => { function Card() { const link = useDumpCardLink({ id: "d1", slug: "hello" }); return (
inner link
); } function setup() { const open = vi.spyOn(globalThis, "open").mockImplementation(() => null); render( } /> dump page

} />
, ); return { open }; } test("a click navigates and marks visited", () => { setup(); fireEvent.click(screen.getByTestId("card")); expect(screen.getByText("dump page")).toBeInTheDocument(); expect(isDumpVisited("d1")).toBe(true); }); test("ctrl/cmd/shift-click opens a new tab", () => { const { open } = setup(); fireEvent.click(screen.getByTestId("card"), { ctrlKey: true }); expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener"); }); test("a middle click opens a new tab, except over a link", () => { const { open } = setup(); const mouseDown = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true, }); screen.getByTestId("card").dispatchEvent(mouseDown); expect(mouseDown.defaultPrevented).toBe(true); fireEvent( screen.getByTestId("card"), new MouseEvent("auxclick", { button: 1, bubbles: true }), ); expect(open).toHaveBeenCalledTimes(1); fireEvent( screen.getByText("inner link"), new MouseEvent("auxclick", { button: 1, bubbles: true }), ); expect(open).toHaveBeenCalledTimes(1); fireEvent( screen.getByTestId("card"), new MouseEvent("auxclick", { button: 2, bubbles: true }), ); expect(open).toHaveBeenCalledTimes(1); }); test("a modified click on the play button opens the dump instead of playing", () => { const { open } = setup(); const play = screen.getByText("play"); fireEvent.click(play, { metaKey: true }); expect(open).toHaveBeenCalledTimes(1); expect(play.dataset.played).toBeUndefined(); fireEvent.click(play); expect(play.dataset.played).toBe("1"); }); }); describe("useAnchoredFloat", () => { function Float({ open, top }: { open: boolean; top: number }) { const ref = useRef(null); const style = useAnchoredFloat(ref, open, { width: 300, chromeHeight: 40, minContentHeight: 100, maxContentHeight: 400, }); return ( <>
{ ref.current = el; if (el) { el.getBoundingClientRect = () => ({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20, }) as DOMRect; } }} /> {JSON.stringify(style)} ); } const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!); beforeEach(() => { vi.stubGlobal("innerWidth", 1024); vi.stubGlobal("innerHeight", 768); }); test("null while closed", () => { render(); expect(styleOf()).toBeNull(); }); test("opens below an anchor near the top, clamped into the viewport", () => { render(); const s = styleOf(); expect(s.position).toBe("fixed"); expect(s.top).toBe(124); expect(s.bottom).toBe("auto"); expect(s.left).toBe(1024 - 300 - 8); expect(s["--float-max-h"]).toBe("400px"); }); test("flips above an anchor near the bottom", () => { render(); const s = styleOf(); expect(s.bottom).toBe(768 - 700 + 4); expect(s.top).toBe("auto"); }); }); describe("useHeightTransition", () => { test("animates from the old height to the new one when watch changes", () => { const animate = vi.spyOn(Element.prototype, "animate"); let height = 100; vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation( () => ({ height }) as DOMRect, ); function Box({ open }: { open: boolean }) { const ref = useHeightTransition(open); return
; } const { rerender } = render(); height = 250; rerender(); expect(animate).toHaveBeenCalledTimes(1); expect(animate.mock.calls[0][0]).toEqual([ expect.objectContaining({ height: "100px" }), expect.objectContaining({ height: "250px" }), ]); // No change in height, no animation. rerender(); expect(animate).toHaveBeenCalledTimes(1); }); });