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:
@@ -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 }) {
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
85
src/test/hooks/dump/useDumpCardLink.test.tsx
Normal file
85
src/test/hooks/dump/useDumpCardLink.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
110
src/test/hooks/dump/useDumpListSync.test.tsx
Normal file
110
src/test/hooks/dump/useDumpListSync.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
134
src/test/hooks/dump/useUserDumpFeed.test.tsx
Normal file
134
src/test/hooks/dump/useUserDumpFeed.test.tsx
Normal 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"]);
|
||||
});
|
||||
});
|
||||
89
src/test/hooks/editor/useEmojiTrigger.test.tsx
Normal file
89
src/test/hooks/editor/useEmojiTrigger.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
15
src/test/hooks/feed/useDefaultFeedTab.test.tsx
Normal file
15
src/test/hooks/feed/useDefaultFeedTab.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
55
src/test/hooks/feed/useFading.test.tsx
Normal file
55
src/test/hooks/feed/useFading.test.tsx
Normal 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({});
|
||||
});
|
||||
});
|
||||
97
src/test/hooks/feed/useFeedCache.test.tsx
Normal file
97
src/test/hooks/feed/useFeedCache.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
115
src/test/hooks/feed/useInfiniteScroll.test.tsx
Normal file
115
src/test/hooks/feed/useInfiniteScroll.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
91
src/test/hooks/feed/usePositionAwareSync.test.tsx
Normal file
91
src/test/hooks/feed/usePositionAwareSync.test.tsx
Normal 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"]);
|
||||
});
|
||||
});
|
||||
52
src/test/hooks/feed/useScrollSave.test.tsx
Normal file
52
src/test/hooks/feed/useScrollSave.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
112
src/test/hooks/player/usePlayRichContent.test.tsx
Normal file
112
src/test/hooks/player/usePlayRichContent.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
139
src/test/hooks/player/useYouTubePlaylist.test.tsx
Normal file
139
src/test/hooks/player/useYouTubePlaylist.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
165
src/test/hooks/playlist/usePlaylistListSync.test.tsx
Normal file
165
src/test/hooks/playlist/usePlaylistListSync.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
65
src/test/hooks/shared/useAnchoredFloat.test.tsx
Normal file
65
src/test/hooks/shared/useAnchoredFloat.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
33
src/test/hooks/shared/useDocumentTitle.test.tsx
Normal file
33
src/test/hooks/shared/useDocumentTitle.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
29
src/test/hooks/shared/useHeightTransition.test.tsx
Normal file
29
src/test/hooks/shared/useHeightTransition.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
20
src/test/hooks/shared/useScrollLock.test.tsx
Normal file
20
src/test/hooks/shared/useScrollLock.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
13
src/test/hooks/shared/useSoundsEnabled.test.tsx
Normal file
13
src/test/hooks/shared/useSoundsEnabled.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
61
src/test/hooks/shared/useTabParam.test.tsx
Normal file
61
src/test/hooks/shared/useTabParam.test.tsx
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user