v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:44:14 +00:00
parent 07d0344be7
commit 2b6e68391b
242 changed files with 4390 additions and 4229 deletions

View File

@@ -2,12 +2,12 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import type { AuthResponse } from "../../model.ts";
import { useAuth, useRequiredAuth } from "../../hooks/useAuth.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, route } from "../fetch.ts";
import { makeSession, makeToken, storeSession } from "../fixtures.ts";
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
import type { AuthResponse } from "../../../model.ts";
import { useAuth, useRequiredAuth } from "../../../hooks/auth/useAuth.ts";
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
import { calls, route } from "../../fetch.ts";
import { makeSession, makeToken, storeSession } from "../../fixtures.ts";
function wrapperFor(initial: AuthResponse | null) {
return function Wrapper({ children }: { children: ReactNode }) {

View File

@@ -1,377 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useRef, useState } from "react";
import { MemoryRouter } from "react-router";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { useUserDumpFeed } from "../../hooks/useUserDumpFeed.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { FakeIntersectionObserver } from "../setup.ts";
afterEach(() => {
vi.unstubAllGlobals();
document.head.querySelectorAll("script").forEach((s) => s.remove());
});
describe("usePlayRichContent", () => {
// The Bandcamp mode is read from a <meta> tag once, at module load.
async function load(mode: "embed" | "native") {
document.head.innerHTML = `<meta name="bandcamp-player" content="${mode}">`;
vi.resetModules();
const hookMod = await import("../../hooks/usePlayRichContent.ts");
const ctxMod = await import("../../contexts/PlayerContext.ts");
const play = vi.fn();
const playQueue = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<ctxMod.PlayerContext.Provider
value={{ ...ctxMod.defaultPlayerValue, play, playQueue }}
>
{children}
</ctxMod.PlayerContext.Provider>
);
const hook = renderHook(hookMod.usePlayRichContent, { wrapper });
return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent };
}
afterEach(() => {
document.head.innerHTML = "";
});
const yt = {
type: "youtube",
url: "https://youtu.be/x",
title: "Vid",
embedUrl: "https://www.youtube.com/embed/x",
thumbnailUrl: "https://i.ytimg.com/x.jpg",
siteName: "YouTube",
};
const bc = {
type: "bandcamp",
url: "https://a.bandcamp.com/album/x",
title: "Album",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
};
test("embeds play in the iframe", async () => {
const { hook, play } = await load("embed");
let started!: boolean;
await act(async () => {
started = await hook.result.current.playRichContent(yt, "/dumps/1");
});
expect(started).toBe(true);
expect(play).toHaveBeenCalledWith({
kind: "embed",
embedUrl: yt.embedUrl,
title: "Vid",
type: "youtube",
dumpHref: "/dumps/1",
artworkUrl: yt.thumbnailUrl,
subtitle: "YouTube",
});
});
test("nothing to embed: not started", async () => {
const { hook, play, canPlay } = await load("embed");
const bare = { type: "link", url: "https://x" };
expect(canPlay(bare)).toBe(false);
let started = true;
await act(async () => {
started = await hook.result.current.playRichContent(bare);
});
expect(started).toBe(false);
expect(play).not.toHaveBeenCalled();
});
test("native Bandcamp plays a resolved queue", async () => {
route("GET /api/bandcamp/tracks", () => ({
sourceUrl: bc.url,
itemType: "album",
resolvedAt: 1,
tracks: [{
index: 0,
title: "T",
streamUrl: "https://s/0",
streamable: true,
capped: false,
}],
}));
const { hook, playQueue, canPlay } = await load("native");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).toHaveBeenCalledWith(
[expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })],
0,
);
expect(hook.result.current.pending).toBe(false);
});
test("native Bandcamp that can't resolve falls back to the embed", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
route("GET /api/bandcamp/tracks", () => fail("x", 500));
const { hook, play, playQueue } = await load("native");
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).not.toHaveBeenCalled();
expect(play).toHaveBeenCalledWith(
expect.objectContaining({ kind: "embed" }),
);
});
test("an unknown mode reads as embed", async () => {
const { canPlay } = await load("bogus" as "embed");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false);
});
});
describe("useUserDumpFeed", () => {
const ann = makeUser({ username: "ann" });
function setup(
opts: { session?: ReturnType<typeof makeSession> | null } = {},
) {
const session = opts.session ?? null;
function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(session);
return (
<MemoryRouter>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
</MemoryRouter>
);
}
return renderHook(
({ username }: { username?: string }) =>
useUserDumpFeed(username, "dumps", "cache-key"),
{
initialProps: { username: "ann" as string | undefined },
wrapper: Wrapper,
},
);
}
test("loads the profile and the first page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
const session = makeSession();
const { result } = setup({ session });
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.profileUser.username).toBe("ann");
expect(s.items[0].createdAt).toBeInstanceOf(Date);
expect(s.hasMore).toBe(true);
const [req] = calls("GET /api/users/:u/dumps");
expect(req.url.searchParams.get("page")).toBe("1");
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("an unknown user is an error", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("error"));
});
test("an offline load says so", async () => {
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() =>
expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
})
);
});
test("scrolling to the sentinel loads the next page", async () => {
route("GET /api/users/:u", () => wire(ann));
route(
"GET /api/users/:u/dumps",
(req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const sentinel = document.createElement("div");
act(() => {
result.current.sentinelRef(sentinel);
});
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.report(true));
await waitFor(() => {
const s = result.current.state;
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
"a",
"b",
]);
});
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.hasMore).toBe(false);
expect(s.page).toBe(2);
});
test("changing user resets to loading", async () => {
route(
"GET /api/users/:u",
(req) => wire(makeUser({ username: req.params.u })),
);
route("GET /api/users/:u/dumps", () => page([]));
const { result, rerender } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
rerender({ username: "bob" });
expect(result.current.state.status).toBe("loading");
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
});
test("setItems edits the loaded list", async () => {
route("GET /api/users/:u", () => wire(ann));
route(
"GET /api/users/:u/dumps",
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.setItems((prev) => prev.slice(1)));
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.items.map((d) => d.id)).toEqual(["b"]);
});
});
describe("useYouTubePlaylist", () => {
type Events = {
onReady?: () => void;
onStateChange?: (e: { data: number }) => void;
};
function installYT(ids = ["a", "b", "c"]) {
const player = {
events: {} as Events,
index: 0,
playVideo: vi.fn(),
playVideoAt: vi.fn(),
nextVideo: vi.fn(),
previousVideo: vi.fn(),
getPlaylist: () => ids,
getPlaylistIndex() {
return this.index;
},
destroy: vi.fn(),
};
// Forwards to `player`, which the tests inspect and steer.
class Player {
constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
player.events = opts.events;
}
playVideo = player.playVideo;
playVideoAt = player.playVideoAt;
nextVideo = player.nextVideo;
previousVideo = player.previousVideo;
getPlaylist = player.getPlaylist;
getPlaylistIndex = () => player.index;
destroy = player.destroy;
}
window.YT = { Player };
return player;
}
// The IFrame API loads once per document and is cached in the module.
async function setup(embedUrl: string | null) {
vi.resetModules();
const { useYouTubePlaylist } = await import(
"../../hooks/useYouTubePlaylist.ts"
);
return renderHook(({ url }) => {
const ref = useRef<HTMLIFrameElement | null>(
document.createElement("iframe"),
);
return useYouTubePlaylist(url, ref);
}, { initialProps: { url: embedUrl } });
}
test("titles from the server, order and position from the player", async () => {
// The API script loads once per document; later tests find YT ready.
const player = installYT();
route("GET /api/youtube/playlist", () => ({
tracks: [
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "c", title: "C", duration: 30 },
],
}));
const { result } = await setup(
"https://www.youtube.com/embed/videoseries?list=PL1",
);
await waitFor(() => expect(result.current.rows).toHaveLength(2));
expect(result.current.ready).toBe(false);
await waitFor(() => expect(player.events.onReady).toBeDefined());
player.index = 1;
act(() => player.events.onReady!());
expect(result.current.ready).toBe(true);
expect(result.current.index).toBe(1);
// The player's list wins, matched to the titles by id.
expect(result.current.rows).toEqual([
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "b", title: undefined, duration: undefined },
{ videoId: "c", title: "C", duration: 30 },
]);
act(() => result.current.playAt(2));
act(() => result.current.next());
act(() => result.current.previous());
expect(player.playVideoAt).toHaveBeenCalledWith(2);
expect(player.nextVideo).toHaveBeenCalled();
expect(player.previousVideo).toHaveBeenCalled();
});
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
const player = installYT();
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
await setup("https://www.youtube.com/embed/x?list=PL2");
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
document.dispatchEvent(new Event("visibilitychange"));
for (let i = 0; i < 5; i++) {
act(() => player.events.onStateChange!({ data: 2 }));
}
expect(player.playVideo).toHaveBeenCalledTimes(3);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
act(() => player.events.onStateChange!({ data: 2 }));
expect(player.playVideo).toHaveBeenCalledTimes(3);
});
test("no list in the URL: no rows, no requests", async () => {
const { result } = await setup("https://www.youtube.com/embed/x");
expect(result.current.rows).toEqual([]);
expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
});
test("a new embed drops the old list; unmount destroys the player", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const player = installYT();
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
const { result, rerender, unmount } = await setup(
"https://www.youtube.com/embed/x?list=PL3",
);
await waitFor(() => expect(result.current.rows).toHaveLength(1));
await waitFor(() => expect(player.events.onReady).toBeDefined());
route("GET /api/youtube/playlist", () => fail("x", 500));
rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
expect(result.current.rows).toEqual([]);
expect(result.current.index).toBe(-1);
unmount();
expect(player.destroy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,85 @@
import { describe, expect, test, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { useDumpCardLink } from "../../../hooks/dump/useDumpCardLink.ts";
import { isDumpVisited } from "../../../utils/shared/visited.ts";
describe("useDumpCardLink", () => {
function Card() {
const link = useDumpCardLink({ id: "d1", slug: "hello" });
return (
<div data-testid="card" {...link.cardProps}>
<a href="/elsewhere">inner link</a>
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
}}
>
play
</button>
</div>
);
}
function setup() {
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Card />} />
<Route path="/dumps/:id" element={<p>dump page</p>} />
</Routes>
</MemoryRouter>,
);
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");
});
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import {
defaultWSValue,
WSContext,
type WSContextValue,
} from "../../../contexts/ws/WSContext.ts";
import type { Dump } from "../../../model.ts";
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
import { makeDump } from "../../fixtures.ts";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultWSValue;
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return {
Wrapper,
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
};
}
function useList<T>(initial: T[]) {
const [items, setItems] = useState(initial);
return { items, setItems };
}
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("useDumpListSync", () => {
function setup(
initial: Dump[],
opts?: Parameters<typeof useDumpListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
useDumpListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("drops deleted dumps", () => {
const { hook, emit } = setup([
makeDump({ id: "a" }),
makeDump({ id: "b" }),
]);
emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]);
});
test("updates a dump in place", () => {
const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
expect(hook.result.current.items[0].title).toBe("new");
});
test("a dump gone private leaves others' lists but not the owner's", () => {
const a = makeDump({ id: "a" });
const viewer = setup([a]);
viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(viewer.hook.result.current.items).toEqual([]);
const owner = setup([a], { isOwner: true });
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
});
test("re-inserts only the owner's dumps, and only when asked", () => {
const mine = makeDump({ id: "m", userId: "me" });
const theirs = makeDump({ id: "t", userId: "them" });
const none = setup([]);
none.emit({ lastDumpEvent: mine });
expect(none.hook.result.current.items).toEqual([]);
const owned = setup([], { ownerId: "me" });
owned.emit({ lastDumpEvent: theirs });
expect(owned.hook.result.current.items).toEqual([]);
owned.emit({ lastDumpEvent: mine });
expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
const skip = setup([], { ownerId: "me", skipReinsert: true });
skip.emit({ lastDumpEvent: mine });
expect(skip.hook.result.current.items).toEqual([]);
const privateOne = setup([], { ownerId: "me" });
privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
expect(privateOne.hook.result.current.items).toEqual([]);
});
test("comment events move the count, never below zero; edits don't", () => {
const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(0);
emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
expect(hook.result.current.items).toHaveLength(1);
});
});

View File

@@ -0,0 +1,134 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import { MemoryRouter } from "react-router";
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
import { useUserDumpFeed } from "../../../hooks/dump/useUserDumpFeed.ts";
import { calls, fail, route } from "../../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../../fixtures.ts";
import { FakeIntersectionObserver } from "../../setup.ts";
describe("useUserDumpFeed", () => {
const ann = makeUser({ username: "ann" });
function setup(
opts: { session?: ReturnType<typeof makeSession> | null } = {},
) {
const session = opts.session ?? null;
function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(session);
return (
<MemoryRouter>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
</MemoryRouter>
);
}
return renderHook(
({ username }: { username?: string }) =>
useUserDumpFeed(username, "dumps", "cache-key"),
{
initialProps: { username: "ann" as string | undefined },
wrapper: Wrapper,
},
);
}
test("loads the profile and the first page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
const session = makeSession();
const { result } = setup({ session });
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.profileUser.username).toBe("ann");
expect(s.items[0].createdAt).toBeInstanceOf(Date);
expect(s.hasMore).toBe(true);
const [req] = calls("GET /api/users/:u/dumps");
expect(req.url.searchParams.get("page")).toBe("1");
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("an unknown user is an error", async () => {
route("GET /api/users/:u", () => fail("User not found", 404));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("error"));
});
test("an offline load says so", async () => {
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() =>
expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
})
);
});
test("scrolling to the sentinel loads the next page", async () => {
route("GET /api/users/:u", () => wire(ann));
route(
"GET /api/users/:u/dumps",
(req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const sentinel = document.createElement("div");
act(() => {
result.current.sentinelRef(sentinel);
});
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.report(true));
await waitFor(() => {
const s = result.current.state;
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
"a",
"b",
]);
});
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.hasMore).toBe(false);
expect(s.page).toBe(2);
});
test("changing user resets to loading", async () => {
route(
"GET /api/users/:u",
(req) => wire(makeUser({ username: req.params.u })),
);
route("GET /api/users/:u/dumps", () => page([]));
const { result, rerender } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
rerender({ username: "bob" });
expect(result.current.state.status).toBe("loading");
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
});
test("setItems edits the loaded list", async () => {
route("GET /api/users/:u", () => wire(ann));
route(
"GET /api/users/:u/dumps",
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.setItems((prev) => prev.slice(1)));
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.items.map((d) => d.id)).toEqual(["b"]);
});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useEmojiTrigger } from "../../../hooks/editor/useEmojiTrigger.ts";
describe("useEmojiTrigger", () => {
function setup() {
const replace = vi.fn();
const hook = renderHook(() => useEmojiTrigger(replace));
const type = (value: string, cursor = value.length) =>
act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
return { replace, hook, type };
}
test("opens inline on ':' and two letters", () => {
const { hook, type } = setup();
type("hello :s");
expect(hook.result.current.emojiMode).toBe("closed");
type("hello :sm");
expect(hook.result.current.emojiMode).toBe("inline");
expect(hook.result.current.emojiQuery).toBe("sm");
});
test("emoticons, times and labels don't trigger", () => {
const { hook, type } = setup();
for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
type(text);
expect(hook.result.current.emojiMode, text).toBe("closed");
}
});
test("Unicode names work", () => {
const { hook, type } = setup();
type(":cœur");
expect(hook.result.current.emojiQuery).toBe("cœur");
});
test("not inside code", () => {
const { hook, type } = setup();
type("`:smile");
expect(hook.result.current.emojiMode).toBe("closed");
});
test("closes when the caret leaves the query", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("a caret move alone never opens it", () => {
const { hook } = setup();
act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("an inline pick replaces the query and adds a space", () => {
const { hook, type, replace } = setup();
type("hi :smi");
act(() => hook.result.current.selectEmoji("😄"));
expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
expect(hook.result.current.emojiMode).toBe("closed");
});
test("browse mode inserts at the caret and ignores typing", () => {
const { hook, type, replace } = setup();
act(() => hook.result.current.openEmojiBrowser(2, 4));
type("ab");
expect(hook.result.current.emojiMode).toBe("browse");
act(() => hook.result.current.setEmojiQuery("cat"));
expect(hook.result.current.emojiQuery).toBe("cat");
act(() => hook.result.current.selectEmoji("🐱"));
expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
});
test("selecting or querying while closed does nothing", () => {
const { hook, replace } = setup();
act(() => hook.result.current.setEmojiQuery("x"));
act(() => hook.result.current.selectEmoji("😀"));
expect(replace).not.toHaveBeenCalled();
expect(hook.result.current.emojiQuery).toBe("");
});
test("closeEmoji", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.closeEmoji());
expect(hook.result.current.emojiMode).toBe("closed");
});
});

View File

@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
import {
type MentionKey,
useMentionAutocomplete,
} from "../../hooks/useMentionAutocomplete.ts";
import { calls, deferred, route } from "../fetch.ts";
} from "../../../hooks/editor/useMentionAutocomplete.ts";
import { calls, deferred, route } from "../../fetch.ts";
function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
return {
@@ -20,92 +19,6 @@ function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
};
}
describe("useEmojiTrigger", () => {
function setup() {
const replace = vi.fn();
const hook = renderHook(() => useEmojiTrigger(replace));
const type = (value: string, cursor = value.length) =>
act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
return { replace, hook, type };
}
test("opens inline on ':' and two letters", () => {
const { hook, type } = setup();
type("hello :s");
expect(hook.result.current.emojiMode).toBe("closed");
type("hello :sm");
expect(hook.result.current.emojiMode).toBe("inline");
expect(hook.result.current.emojiQuery).toBe("sm");
});
test("emoticons, times and labels don't trigger", () => {
const { hook, type } = setup();
for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
type(text);
expect(hook.result.current.emojiMode, text).toBe("closed");
}
});
test("Unicode names work", () => {
const { hook, type } = setup();
type(":cœur");
expect(hook.result.current.emojiQuery).toBe("cœur");
});
test("not inside code", () => {
const { hook, type } = setup();
type("`:smile");
expect(hook.result.current.emojiMode).toBe("closed");
});
test("closes when the caret leaves the query", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("a caret move alone never opens it", () => {
const { hook } = setup();
act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
expect(hook.result.current.emojiMode).toBe("closed");
});
test("an inline pick replaces the query and adds a space", () => {
const { hook, type, replace } = setup();
type("hi :smi");
act(() => hook.result.current.selectEmoji("😄"));
expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
expect(hook.result.current.emojiMode).toBe("closed");
});
test("browse mode inserts at the caret and ignores typing", () => {
const { hook, type, replace } = setup();
act(() => hook.result.current.openEmojiBrowser(2, 4));
type("ab");
expect(hook.result.current.emojiMode).toBe("browse");
act(() => hook.result.current.setEmojiQuery("cat"));
expect(hook.result.current.emojiQuery).toBe("cat");
act(() => hook.result.current.selectEmoji("🐱"));
expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
});
test("selecting or querying while closed does nothing", () => {
const { hook, replace } = setup();
act(() => hook.result.current.setEmojiQuery("x"));
act(() => hook.result.current.selectEmoji("😀"));
expect(replace).not.toHaveBeenCalled();
expect(hook.result.current.emojiQuery).toBe("");
});
test("closeEmoji", () => {
const { hook, type } = setup();
type(":smile");
act(() => hook.result.current.closeEmoji());
expect(hook.result.current.emojiMode).toBe("closed");
});
});
describe("useMentionAutocomplete", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

View File

@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useDefaultFeedTab } from "../../../hooks/feed/useDefaultFeedTab.ts";
describe("useDefaultFeedTab", () => {
test("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");
});
});

View File

@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useFading } from "../../../hooks/feed/useFading.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({});
});
});

View File

@@ -0,0 +1,97 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
act,
fireEvent,
render,
renderHook,
screen,
} from "@testing-library/react";
import { type ReactNode } from "react";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router";
import { useFeedCache } from "../../../hooks/feed/useFeedCache.ts";
afterEach(() => vi.useRealTimers());
describe("useFeedCache", () => {
// A MemoryRouter's first entry is a POP, as after Back.
const wrapper = () => ({ children }: { children: ReactNode }) => (
<MemoryRouter>{children}</MemoryRouter>
);
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 <p>{cached ? "cached" : "fresh"}</p>;
}
function Home() {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate("/feed")}>
go
</button>
);
}
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/feed" element={<Probe />} />
</Routes>
</MemoryRouter>,
);
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();
});
});

View File

@@ -0,0 +1,115 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { useInfiniteScroll } from "../../../hooks/feed/useInfiniteScroll.ts";
import { FakeIntersectionObserver } from "../../setup.ts";
afterEach(() => vi.useRealTimers());
describe("useInfiniteScroll", () => {
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
const ref = useInfiniteScroll(onMore, enabled);
return <div ref={ref} data-testid="sentinel" />;
}
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(<Feed onMore={onMore} enabled />);
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
intersect(false);
expect(onMore).not.toHaveBeenCalled();
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled={false} />);
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(<Feed onMore={onMore} enabled />);
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(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
act(() => vi.advanceTimersByTime(600));
// A new onLoadMore (the feed's state changed) remakes the observer.
rerender(<Feed onMore={() => 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(<Feed onMore={onMore} enabled />);
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(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
rerender(<Feed onMore={onMore} enabled={false} />);
act(() => vi.advanceTimersByTime(60_000));
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled />);
intersect(true); // long overdue: at once
expect(onMore).toHaveBeenCalledTimes(2);
intersect(true);
unmount();
act(() => vi.advanceTimersByTime(60_000));
expect(onMore).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import {
defaultWSValue,
WSContext,
type WSContextValue,
} from "../../../contexts/ws/WSContext.ts";
import type { Dump, Playlist } from "../../../model.ts";
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
import { usePositionAwareSync } from "../../../hooks/feed/usePositionAwareSync.ts";
import { makeDump, makePlaylist } from "../../fixtures.ts";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultWSValue;
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return {
Wrapper,
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
};
}
function useList<T>(initial: T[]) {
const [items, setItems] = useState(initial);
return { items, setItems };
}
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("usePositionAwareSync", () => {
test("a dump that goes private and back returns to its old place", () => {
const ws = liveWS();
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
// Wire the pair exactly as UserPublicProfile does.
const both = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Dump | null>(null);
usePositionAwareSync(
items,
setItems,
event,
(d) => d.isPrivate,
(d) => !d.isPrivate,
);
useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
return { items, setEvent };
}, { wrapper: ws.Wrapper });
const b = initial[1];
act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
});
test("a position is only used once, and clamps to the list end", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
const hook = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Playlist | null>(null);
usePositionAwareSync(
items,
setItems,
event,
(p) => !p.isPublic,
(p) => p.isPublic,
);
return { items, setItems, setEvent };
});
const c = initial[2];
act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
// Nothing saved any more: a second public event is left alone.
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() =>
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
);
expect(ids(hook.result.current.items)).toEqual(["a"]);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, test, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useScrollSave } from "../../../hooks/feed/useScrollSave.ts";
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();
});
});

View File

@@ -1,615 +0,0 @@
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 }) => (
<MemoryRouter>{children}</MemoryRouter>
);
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 <p>{cached ? "cached" : "fresh"}</p>;
}
function Home() {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate("/feed")}>
go
</button>
);
}
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/feed" element={<Probe />} />
</Routes>
</MemoryRouter>,
);
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<typeof useLocation>;
const hook = renderHook(() => {
loc = useLocation();
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter
initialEntries={[{
pathname: path.split("?")[0],
search: path.includes("?") ? "?" + path.split("?")[1] : "",
state,
}]}
>
{children}
</MemoryRouter>
),
});
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 <div ref={ref} data-testid="sentinel" />;
}
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(<Feed onMore={onMore} enabled />);
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
intersect(false);
expect(onMore).not.toHaveBeenCalled();
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled={false} />);
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(<Feed onMore={onMore} enabled />);
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(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
act(() => vi.advanceTimersByTime(600));
// A new onLoadMore (the feed's state changed) remakes the observer.
rerender(<Feed onMore={() => 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(<Feed onMore={onMore} enabled />);
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(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
rerender(<Feed onMore={onMore} enabled={false} />);
act(() => vi.advanceTimersByTime(60_000));
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled />);
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(<Lock />);
const b = render(<Lock />);
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 (
<div data-testid="card" {...link.cardProps}>
<a href="/elsewhere">inner link</a>
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
}}
>
play
</button>
</div>
);
}
function setup() {
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Card />} />
<Route path="/dumps/:id" element={<p>dump page</p>} />
</Routes>
</MemoryRouter>,
);
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<HTMLDivElement>(null);
const style = useAnchoredFloat(ref, open, {
width: 300,
chromeHeight: 40,
minContentHeight: 100,
maxContentHeight: 400,
});
return (
<>
<div
ref={(el) => {
ref.current = el;
if (el) {
el.getBoundingClientRect = () =>
({
top,
bottom: top + 20,
left: 900,
right: 1000,
width: 100,
height: 20,
}) as DOMRect;
}
}}
/>
<output data-testid="style">{JSON.stringify(style)}</output>
</>
);
}
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
beforeEach(() => {
vi.stubGlobal("innerWidth", 1024);
vi.stubGlobal("innerHeight", 768);
});
test("null while closed", () => {
render(<Float open={false} top={100} />);
expect(styleOf()).toBeNull();
});
test("opens below an anchor near the top, clamped into the viewport", () => {
render(<Float open top={100} />);
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(<Float open top={700} />);
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<HTMLDivElement>(open);
return <div ref={ref} />;
}
const { rerender } = render(<Box open={false} />);
height = 250;
rerender(<Box open />);
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(<Box open={false} />);
expect(animate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode } from "react";
import { fail, route } from "../../fetch.ts";
describe("usePlayRichContent", () => {
// The Bandcamp mode is read from a <meta> tag once, at module load.
async function load(mode: "embed" | "native") {
document.head.innerHTML = `<meta name="bandcamp-player" content="${mode}">`;
vi.resetModules();
const hookMod = await import("../../../hooks/player/usePlayRichContent.ts");
const ctxMod = await import("../../../contexts/player/PlayerContext.ts");
const play = vi.fn();
const playQueue = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<ctxMod.PlayerContext.Provider
value={{ ...ctxMod.defaultPlayerValue, play, playQueue }}
>
{children}
</ctxMod.PlayerContext.Provider>
);
const hook = renderHook(hookMod.usePlayRichContent, { wrapper });
return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent };
}
afterEach(() => {
document.head.innerHTML = "";
});
const yt = {
type: "youtube",
url: "https://youtu.be/x",
title: "Vid",
embedUrl: "https://www.youtube.com/embed/x",
thumbnailUrl: "https://i.ytimg.com/x.jpg",
siteName: "YouTube",
};
const bc = {
type: "bandcamp",
url: "https://a.bandcamp.com/album/x",
title: "Album",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
};
test("embeds play in the iframe", async () => {
const { hook, play } = await load("embed");
let started!: boolean;
await act(async () => {
started = await hook.result.current.playRichContent(yt, "/dumps/1");
});
expect(started).toBe(true);
expect(play).toHaveBeenCalledWith({
kind: "embed",
embedUrl: yt.embedUrl,
title: "Vid",
type: "youtube",
dumpHref: "/dumps/1",
artworkUrl: yt.thumbnailUrl,
subtitle: "YouTube",
});
});
test("nothing to embed: not started", async () => {
const { hook, play, canPlay } = await load("embed");
const bare = { type: "link", url: "https://x" };
expect(canPlay(bare)).toBe(false);
let started = true;
await act(async () => {
started = await hook.result.current.playRichContent(bare);
});
expect(started).toBe(false);
expect(play).not.toHaveBeenCalled();
});
test("native Bandcamp plays a resolved queue", async () => {
route("GET /api/bandcamp/tracks", () => ({
sourceUrl: bc.url,
itemType: "album",
resolvedAt: 1,
tracks: [{
index: 0,
title: "T",
streamUrl: "https://s/0",
streamable: true,
capped: false,
}],
}));
const { hook, playQueue, canPlay } = await load("native");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).toHaveBeenCalledWith(
[expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })],
0,
);
expect(hook.result.current.pending).toBe(false);
});
test("native Bandcamp that can't resolve falls back to the embed", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
route("GET /api/bandcamp/tracks", () => fail("x", 500));
const { hook, play, playQueue } = await load("native");
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).not.toHaveBeenCalled();
expect(play).toHaveBeenCalledWith(
expect.objectContaining({ kind: "embed" }),
);
});
test("an unknown mode reads as embed", async () => {
const { canPlay } = await load("bogus" as "embed");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false);
});
});

View File

@@ -0,0 +1,139 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useRef } from "react";
import { calls, fail, route } from "../../fetch.ts";
afterEach(() => {
vi.unstubAllGlobals();
document.head.querySelectorAll("script").forEach((s) => s.remove());
});
describe("useYouTubePlaylist", () => {
type Events = {
onReady?: () => void;
onStateChange?: (e: { data: number }) => void;
};
function installYT(ids = ["a", "b", "c"]) {
const player = {
events: {} as Events,
index: 0,
playVideo: vi.fn(),
playVideoAt: vi.fn(),
nextVideo: vi.fn(),
previousVideo: vi.fn(),
getPlaylist: () => ids,
getPlaylistIndex() {
return this.index;
},
destroy: vi.fn(),
};
// Forwards to `player`, which the tests inspect and steer.
class Player {
constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
player.events = opts.events;
}
playVideo = player.playVideo;
playVideoAt = player.playVideoAt;
nextVideo = player.nextVideo;
previousVideo = player.previousVideo;
getPlaylist = player.getPlaylist;
getPlaylistIndex = () => player.index;
destroy = player.destroy;
}
window.YT = { Player };
return player;
}
// The IFrame API loads once per document and is cached in the module.
async function setup(embedUrl: string | null) {
vi.resetModules();
const { useYouTubePlaylist } = await import(
"../../../hooks/player/useYouTubePlaylist.ts"
);
return renderHook(({ url }) => {
const ref = useRef<HTMLIFrameElement | null>(
document.createElement("iframe"),
);
return useYouTubePlaylist(url, ref);
}, { initialProps: { url: embedUrl } });
}
test("titles from the server, order and position from the player", async () => {
// The API script loads once per document; later tests find YT ready.
const player = installYT();
route("GET /api/youtube/playlist", () => ({
tracks: [
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "c", title: "C", duration: 30 },
],
}));
const { result } = await setup(
"https://www.youtube.com/embed/videoseries?list=PL1",
);
await waitFor(() => expect(result.current.rows).toHaveLength(2));
expect(result.current.ready).toBe(false);
await waitFor(() => expect(player.events.onReady).toBeDefined());
player.index = 1;
act(() => player.events.onReady!());
expect(result.current.ready).toBe(true);
expect(result.current.index).toBe(1);
// The player's list wins, matched to the titles by id.
expect(result.current.rows).toEqual([
{ videoId: "a", title: "A", duration: 10 },
{ videoId: "b", title: undefined, duration: undefined },
{ videoId: "c", title: "C", duration: 30 },
]);
act(() => result.current.playAt(2));
act(() => result.current.next());
act(() => result.current.previous());
expect(player.playVideoAt).toHaveBeenCalledWith(2);
expect(player.nextVideo).toHaveBeenCalled();
expect(player.previousVideo).toHaveBeenCalled();
});
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
const player = installYT();
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
await setup("https://www.youtube.com/embed/x?list=PL2");
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
document.dispatchEvent(new Event("visibilitychange"));
for (let i = 0; i < 5; i++) {
act(() => player.events.onStateChange!({ data: 2 }));
}
expect(player.playVideo).toHaveBeenCalledTimes(3);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
act(() => player.events.onStateChange!({ data: 2 }));
expect(player.playVideo).toHaveBeenCalledTimes(3);
});
test("no list in the URL: no rows, no requests", async () => {
const { result } = await setup("https://www.youtube.com/embed/x");
expect(result.current.rows).toEqual([]);
expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
});
test("a new embed drops the old list; unmount destroys the player", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const player = installYT();
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
const { result, rerender, unmount } = await setup(
"https://www.youtube.com/embed/x?list=PL3",
);
await waitFor(() => expect(result.current.rows).toHaveLength(1));
await waitFor(() => expect(player.events.onReady).toBeDefined());
route("GET /api/youtube/playlist", () => fail("x", 500));
rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
expect(result.current.rows).toEqual([]);
expect(result.current.index).toBe(-1);
unmount();
expect(player.destroy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,165 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import {
defaultWSValue,
WSContext,
type WSContextValue,
} from "../../../contexts/ws/WSContext.ts";
import type { Playlist } from "../../../model.ts";
import { usePlaylistListSync } from "../../../hooks/playlist/usePlaylistListSync.ts";
import { makePlaylist } from "../../fixtures.ts";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultWSValue;
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return {
Wrapper,
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
};
}
function useList<T>(initial: T[]) {
const [items, setItems] = useState(initial);
return { items, setItems };
}
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("usePlaylistListSync", () => {
function setup(
initial: Playlist[],
opts?: Parameters<typeof usePlaylistListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
usePlaylistListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const p = makePlaylist({ id: "b" });
emit({
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
});
emit({
lastPlaylistEvent: {
type: "created",
playlistId: "b",
playlist: { ...p },
},
});
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });
byOwner.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(byOwner.hook.result.current.items).toEqual([]);
const priv = setup([]);
priv.emit({
lastPlaylistEvent: {
type: "created",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(priv.hook.result.current.items).toEqual([]);
const followed = setup([], { noNewEntries: true });
followed.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(followed.hook.result.current.items).toEqual([]);
});
test("updated: in place, removed when private, re-added when public", () => {
const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]);
emit({
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "two" },
},
});
expect(hook.result.current.items[0].title).toBe("two");
emit({
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(hook.result.current.items).toEqual([]);
emit({
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "back" },
},
});
expect(hook.result.current.items[0].title).toBe("back");
});
test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
const p = makePlaylist({ id: "p" });
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
const { hook, emit } = setup([], opts);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
});
expect(hook.result.current.items).toEqual([]);
}
});
test("deleted and deletedPlaylistIds both remove", () => {
const { hook, emit } = setup([
makePlaylist({ id: "a" }),
makePlaylist({ id: "b" }),
makePlaylist({ id: "c" }),
]);
emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
emit({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(hook.result.current.items)).toEqual(["c"]);
});
test("dumps_updated sets the count", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit({
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y", "z"],
},
});
expect(hook.result.current.items[0].dumpCount).toBe(3);
});
test("dumps_updated counts the members this viewer can't see too", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
emit({
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y"],
dumpCount: 4,
},
});
expect(hook.result.current.items[0].dumpCount).toBe(4);
});
});

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { useRef } from "react";
import { useAnchoredFloat } from "../../../hooks/shared/useAnchoredFloat.ts";
describe("useAnchoredFloat", () => {
function Float({ open, top }: { open: boolean; top: number }) {
const ref = useRef<HTMLDivElement>(null);
const style = useAnchoredFloat(ref, open, {
width: 300,
chromeHeight: 40,
minContentHeight: 100,
maxContentHeight: 400,
});
return (
<>
<div
ref={(el) => {
ref.current = el;
if (el) {
el.getBoundingClientRect = () =>
({
top,
bottom: top + 20,
left: 900,
right: 1000,
width: 100,
height: 20,
}) as DOMRect;
}
}}
/>
<output data-testid="style">{JSON.stringify(style)}</output>
</>
);
}
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
beforeEach(() => {
vi.stubGlobal("innerWidth", 1024);
vi.stubGlobal("innerHeight", 768);
});
test("null while closed", () => {
render(<Float open={false} top={100} />);
expect(styleOf()).toBeNull();
});
test("opens below an anchor near the top, clamped into the viewport", () => {
render(<Float open top={100} />);
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(<Float open top={700} />);
const s = styleOf();
expect(s.bottom).toBe(768 - 700 + 4);
expect(s.top).toBe("auto");
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import {
setTitleBadge,
SITE_NAME,
useDocumentTitle,
} from "../../../hooks/shared/useDocumentTitle.ts";
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");
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, test, vi } from "vitest";
import { render } from "@testing-library/react";
import { useHeightTransition } from "../../../hooks/shared/useHeightTransition.ts";
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<HTMLDivElement>(open);
return <div ref={ref} />;
}
const { rerender } = render(<Box open={false} />);
height = 250;
rerender(<Box open />);
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(<Box open={false} />);
expect(animate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import { render } from "@testing-library/react";
import { useScrollLock } from "../../../hooks/shared/useScrollLock.ts";
describe("useScrollLock", () => {
function Lock() {
useScrollLock();
return null;
}
test("counts nested locks", () => {
const root = document.documentElement;
const a = render(<Lock />);
const b = render(<Lock />);
expect(root.style.overflowY).toBe("hidden");
a.unmount();
expect(root.style.overflowY).toBe("hidden");
b.unmount();
expect(root.style.overflowY).toBe("");
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useSoundsEnabled } from "../../../hooks/shared/useSoundsEnabled.ts";
describe("useSoundsEnabled", () => {
test("defaults on and persists", () => {
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");
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router";
import { useTabParam } from "../../../hooks/shared/useTabParam.ts";
describe("useTabParam", () => {
const TABS = ["dumps", "playlists", "votes"] as const;
function setup(path: string, state?: unknown) {
let loc!: ReturnType<typeof useLocation>;
const hook = renderHook(() => {
loc = useLocation();
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter
initialEntries={[{
pathname: path.split("?")[0],
search: path.includes("?") ? "?" + path.split("?")[1] : "",
state,
}]}
>
{children}
</MemoryRouter>
),
});
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" });
});
});

View File

@@ -1,301 +0,0 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import {
defaultWSValue,
WSContext,
type WSContextValue,
} from "../../contexts/WSContext.ts";
import type { Dump, Playlist } from "../../model.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultWSValue;
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return {
Wrapper,
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
};
}
function useList<T>(initial: T[]) {
const [items, setItems] = useState(initial);
return { items, setItems };
}
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("useDumpListSync", () => {
function setup(
initial: Dump[],
opts?: Parameters<typeof useDumpListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
useDumpListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("drops deleted dumps", () => {
const { hook, emit } = setup([
makeDump({ id: "a" }),
makeDump({ id: "b" }),
]);
emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]);
});
test("updates a dump in place", () => {
const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
expect(hook.result.current.items[0].title).toBe("new");
});
test("a dump gone private leaves others' lists but not the owner's", () => {
const a = makeDump({ id: "a" });
const viewer = setup([a]);
viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(viewer.hook.result.current.items).toEqual([]);
const owner = setup([a], { isOwner: true });
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
});
test("re-inserts only the owner's dumps, and only when asked", () => {
const mine = makeDump({ id: "m", userId: "me" });
const theirs = makeDump({ id: "t", userId: "them" });
const none = setup([]);
none.emit({ lastDumpEvent: mine });
expect(none.hook.result.current.items).toEqual([]);
const owned = setup([], { ownerId: "me" });
owned.emit({ lastDumpEvent: theirs });
expect(owned.hook.result.current.items).toEqual([]);
owned.emit({ lastDumpEvent: mine });
expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
const skip = setup([], { ownerId: "me", skipReinsert: true });
skip.emit({ lastDumpEvent: mine });
expect(skip.hook.result.current.items).toEqual([]);
const privateOne = setup([], { ownerId: "me" });
privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
expect(privateOne.hook.result.current.items).toEqual([]);
});
test("comment events move the count, never below zero; edits don't", () => {
const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(1);
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
expect(hook.result.current.items[0].commentCount).toBe(0);
emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
expect(hook.result.current.items).toHaveLength(1);
});
});
describe("usePlaylistListSync", () => {
function setup(
initial: Playlist[],
opts?: Parameters<typeof usePlaylistListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
usePlaylistListSync(list.setItems, opts);
return list;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
}
test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const p = makePlaylist({ id: "b" });
emit({
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
});
emit({
lastPlaylistEvent: {
type: "created",
playlistId: "b",
playlist: { ...p },
},
});
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });
byOwner.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(byOwner.hook.result.current.items).toEqual([]);
const priv = setup([]);
priv.emit({
lastPlaylistEvent: {
type: "created",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(priv.hook.result.current.items).toEqual([]);
const followed = setup([], { noNewEntries: true });
followed.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(followed.hook.result.current.items).toEqual([]);
});
test("updated: in place, removed when private, re-added when public", () => {
const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]);
emit({
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "two" },
},
});
expect(hook.result.current.items[0].title).toBe("two");
emit({
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(hook.result.current.items).toEqual([]);
emit({
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "back" },
},
});
expect(hook.result.current.items[0].title).toBe("back");
});
test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
const p = makePlaylist({ id: "p" });
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
const { hook, emit } = setup([], opts);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
});
expect(hook.result.current.items).toEqual([]);
}
});
test("deleted and deletedPlaylistIds both remove", () => {
const { hook, emit } = setup([
makePlaylist({ id: "a" }),
makePlaylist({ id: "b" }),
makePlaylist({ id: "c" }),
]);
emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
emit({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(hook.result.current.items)).toEqual(["c"]);
});
test("dumps_updated sets the count", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit({
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y", "z"],
},
});
expect(hook.result.current.items[0].dumpCount).toBe(3);
});
test("dumps_updated counts the members this viewer can't see too", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
emit({
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y"],
dumpCount: 4,
},
});
expect(hook.result.current.items[0].dumpCount).toBe(4);
});
});
describe("usePositionAwareSync", () => {
test("a dump that goes private and back returns to its old place", () => {
const ws = liveWS();
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
// Wire the pair exactly as UserPublicProfile does.
const both = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Dump | null>(null);
usePositionAwareSync(
items,
setItems,
event,
(d) => d.isPrivate,
(d) => !d.isPrivate,
);
useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
return { items, setEvent };
}, { wrapper: ws.Wrapper });
const b = initial[1];
act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
});
test("a position is only used once, and clamps to the list end", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
const hook = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Playlist | null>(null);
usePositionAwareSync(
items,
setItems,
event,
(p) => !p.isPublic,
(p) => p.isPublic,
);
return { items, setItems, setEvent };
});
const c = initial[2];
act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
// Nothing saved any more: a second public event is left alone.
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() =>
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
);
expect(ids(hook.result.current.items)).toEqual(["a"]);
});
});