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