v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 07:34:49 +00:00
parent 1c5a449d84
commit 308a8299f3
53 changed files with 1941 additions and 1044 deletions

View File

@@ -4,7 +4,7 @@ import { userEvent } from "@testing-library/user-event";
import { NotificationBell } from "../../../components/layout/NotificationBell.tsx";
import { makeNotification } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";
import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts";
import { createWSEventBus } from "../../../contexts/ws/WSContext.ts";
describe("NotificationBell", () => {
test("the bell shows unread notifications and goes to them", async () => {
@@ -20,33 +20,28 @@ describe("NotificationBell", () => {
expect(currentPath()).toBe("/notifications");
});
test("the bell rings for a new notification, not for one already there", () => {
test("the bell rings for each new notification", () => {
vi.useFakeTimers();
try {
const n1 = makeNotification({ id: "n1" });
const withLast = (n: ReturnType<typeof makeNotification>) => (
<WSContext.Provider
value={{ ...defaultWSValue, lastNotification: n }}
>
<NotificationBell />
</WSContext.Provider>
);
const { rerender } = renderWithContexts(withLast(n1));
const events = createWSEventBus();
renderWithContexts(<NotificationBell />, { events });
const ring = (id: string) =>
act(() => events.emit("notification", makeNotification({ id })));
const bell = () => screen.getByRole("button", { name: "Notifications" });
act(() => vi.advanceTimersByTime(10));
expect(bell()).not.toHaveClass("notification-bell--ringing");
rerender(withLast(makeNotification({ id: "n2" })));
ring("n2");
act(() => vi.advanceTimersByTime(10));
expect(bell()).toHaveClass("notification-bell--ringing");
act(() => vi.advanceTimersByTime(700));
expect(bell()).not.toHaveClass("notification-bell--ringing");
// Another in the middle of a ring doesn't leave the bell rung.
rerender(withLast(makeNotification({ id: "n3" })));
ring("n3");
act(() => vi.advanceTimersByTime(100));
rerender(withLast(makeNotification({ id: "n4" })));
ring("n4");
act(() => vi.advanceTimersByTime(700));
expect(bell()).not.toHaveClass("notification-bell--ringing");
rerender(withLast(makeNotification({ id: "n5" })));
ring("n5");
act(() => vi.advanceTimersByTime(10));
expect(bell()).toHaveClass("notification-bell--ringing");
} finally {

View File

@@ -5,6 +5,7 @@ import { CategoriesProvider } from "../../../contexts/category/CategoriesProvide
import { useCategories } from "../../../hooks/category/useCategories.ts";
import { fail, route } from "../../fetch.ts";
import { makeCategory, wire } from "../../fixtures.ts";
import { liveWS } from "../../live.tsx";
describe("CategoriesProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -64,4 +65,21 @@ describe("CategoriesProvider", () => {
vi.useRealTimers();
}
});
test("a category change elsewhere refetches", async () => {
let list = [makeCategory({ id: "a" })];
route("GET /api/categories", () => wire(list));
const ws = liveWS();
const { result } = renderHook(() => useCategories("dump"), {
wrapper: ({ children }) => (
<ws.Wrapper>
<CategoriesProvider>{children}</CategoriesProvider>
</ws.Wrapper>
),
});
await waitFor(() => expect(result.current.categories).toHaveLength(1));
list = [...list, makeCategory({ id: "b" })];
ws.emit("categories", null);
await waitFor(() => expect(result.current.categories).toHaveLength(2));
});
});

View File

@@ -1,10 +1,16 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useContext } from "react";
import { type ReactNode, useContext, useState } from "react";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { WSProvider } from "../../../contexts/ws/WSProvider.tsx";
import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts";
import { useWS, useWSChat, useWSPresence } from "../../../hooks/ws/useWS.ts";
import {
useWS,
useWSChat,
useWSEvent,
useWSPresence,
} from "../../../hooks/ws/useWS.ts";
import type { WSEvents } from "../../../contexts/ws/WSContext.ts";
import { useAuth } from "../../../hooks/auth/useAuth.ts";
import { FakeWebSocket } from "../../ws.ts";
import { defaultRoutes } from "../../render.tsx";
@@ -30,6 +36,14 @@ function wrapper({ children }: { children: ReactNode }) {
);
}
/** Every event of `kind` the provider emitted, oldest first. */
function useEvents<K extends keyof WSEvents>(kind: K): WSEvents[K][] {
// One array for the hook's life, which the subscription fills.
const [received] = useState<WSEvents[K][]>(() => []);
useWSEvent(kind, (event) => received.push(event));
return received;
}
function setup({ signedIn = true } = {}) {
if (signedIn) storeSession(me);
defaultRoutes({ session: signedIn ? me : null });
@@ -38,11 +52,25 @@ function setup({ signedIn = true } = {}) {
ws: { ...useWS(), ...useWSChat(), ...useWSPresence() },
auth: useAuth(),
avatars: useContext(AvatarOverrideContext),
events: {
vote: useEvents("vote"),
dump: useEvents("dump"),
playlist: useEvents("playlist"),
comment: useEvents("comment"),
user: useEvents("user"),
notification: useEvents("notification"),
categories: useEvents("categories"),
},
}),
{ wrapper },
);
const socket = FakeWebSocket.last;
return { hook, socket, ws: () => hook.result.current.ws };
return {
hook,
socket,
ws: () => hook.result.current.ws,
events: () => hook.result.current.events,
};
}
beforeEach(() =>
@@ -227,7 +255,7 @@ describe("votes", () => {
});
test("votes_update from another tab of mine syncs myVotes", () => {
const { socket, ws } = setup();
const { socket, ws, events } = setup();
socket.welcome();
socket.receive({
type: "votes_update",
@@ -237,7 +265,7 @@ describe("votes", () => {
action: "cast",
});
expect(ws().myVotes.has("d9")).toBe(true);
expect(ws().lastVoteEvent).toEqual({
expect(events().vote.at(-1)).toEqual({
dumpId: "d9",
voterId: "user-me",
action: "cast",
@@ -397,7 +425,7 @@ describe("dumps, playlists, comments, users", () => {
});
test("an update refreshes a live entry but doesn't make it an arrival", () => {
const { socket, ws } = setup();
const { socket, ws, events } = setup();
socket.welcome();
socket.receive({
type: "dump_created",
@@ -415,7 +443,11 @@ describe("dumps, playlists, comments, users", () => {
"a",
"two",
]]);
expect(ws().lastDumpEvent?.id).toBe("old");
expect(events().dump.map((e) => [e.type, e.dump.id])).toEqual([
["created", "a"],
["updated", "a"],
["updated", "old"],
]);
});
test("deletion removes from the live buffer", () => {
@@ -435,23 +467,23 @@ describe("dumps, playlists, comments, users", () => {
});
test("playlist events", () => {
const { socket, ws } = setup();
const { socket, ws, events } = setup();
socket.welcome();
const p = makePlaylist({ id: "p" });
socket.receive({ type: "playlist_created", playlist: wire(p) });
expect(ws().lastPlaylistEvent).toMatchObject({
expect(events().playlist.at(-1)).toMatchObject({
type: "created",
playlistId: "p",
});
socket.receive({ type: "playlist_updated", playlist: wire(p) });
expect(ws().lastPlaylistEvent?.type).toBe("updated");
expect(events().playlist.at(-1)?.type).toBe("updated");
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p",
dumpIds: ["x"],
dumpCount: 2,
});
expect(ws().lastPlaylistEvent).toEqual({
expect(events().playlist.at(-1)).toEqual({
type: "dumps_updated",
playlistId: "p",
dumpIds: ["x"],
@@ -459,27 +491,50 @@ describe("dumps, playlists, comments, users", () => {
});
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
expect(ws().lastPlaylistEvent).toEqual({
expect(events().playlist.at(-1)).toEqual({
type: "deleted",
playlistId: "p",
userId: "u",
});
// Going public again, after going private, is an arrival.
socket.receive({ type: "playlist_created", playlist: wire(p) });
expect(ws().deletedPlaylistIds.has("p")).toBe(false);
});
test("events arriving together all reach their subscribers", () => {
const { socket, events } = setup();
socket.welcome();
// Two messages in one task, as from one network read: a single state
// slot would keep only the last until the next render.
act(() => {
for (const id of ["n1", "n2"]) {
socket.onmessage?.(
new MessageEvent("message", {
data: JSON.stringify({
type: "notification_created",
notification: wire(makeNotification({ id })),
}),
}),
);
}
});
expect(events().notification.map((n) => n.id)).toEqual(["n1", "n2"]);
});
test("comment events", () => {
const { socket, ws } = setup();
const { socket, events } = setup();
socket.welcome();
const c = makeComment({ id: "c", dumpId: "d" });
socket.receive({ type: "comment_created", comment: wire(c) });
expect(ws().lastCommentEvent).toMatchObject({
expect(events().comment.at(-1)).toMatchObject({
type: "created",
dumpId: "d",
});
expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date);
expect(events().comment.at(-1)?.comment?.createdAt).toBeInstanceOf(Date);
socket.receive({ type: "comment_updated", comment: wire(c) });
expect(ws().lastCommentEvent?.type).toBe("updated");
expect(events().comment.at(-1)?.type).toBe("updated");
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
expect(ws().lastCommentEvent).toEqual({
expect(events().comment.at(-1)).toEqual({
type: "deleted",
dumpId: "d",
commentId: "c",
@@ -487,7 +542,7 @@ describe("dumps, playlists, comments, users", () => {
});
test("user_updated overrides that user's avatar everywhere", () => {
const { socket, hook } = setup();
const { socket, hook, events } = setup();
socket.welcome();
const u = makeUser({
id: "u1",
@@ -499,7 +554,7 @@ describe("dumps, playlists, comments, users", () => {
hasAvatar: true,
version: 5000,
});
expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1");
expect(events().user.at(-1)?.user.id).toBe("u1");
});
test("presence updates", () => {
@@ -514,18 +569,32 @@ describe("dumps, playlists, comments, users", () => {
});
describe("notifications", () => {
test("a new one bumps the count and is remembered", () => {
const { socket, ws } = setup();
test("a new one bumps the count and is passed on", () => {
const { socket, ws, events } = setup();
socket.welcome({ unreadNotificationCount: 1 });
socket.receive({
type: "notification_created",
notification: wire(makeNotification({ id: "n" })),
});
expect(ws().unreadNotificationCount).toBe(2);
expect(ws().lastNotification?.id).toBe("n");
expect(events().notification.map((n) => n.id)).toEqual(["n"]);
act(() => ws().clearUnreadNotifications());
expect(ws().unreadNotificationCount).toBe(0);
});
test("reading them in another tab clears the count here", () => {
const { socket, ws } = setup();
socket.welcome({ unreadNotificationCount: 3 });
socket.receive({ type: "notifications_read" });
expect(ws().unreadNotificationCount).toBe(0);
});
test("a category change is passed on", () => {
const { socket, events } = setup();
socket.welcome();
socket.receive({ type: "categories_changed" });
expect(events().categories).toEqual([null]);
});
});
describe("chat", () => {

View File

@@ -1,93 +1,144 @@
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 { useState } from "react";
import type { Dump } from "../../../../shared/dump.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 };
}
import {
type DumpListSyncOptions,
useDumpListSync,
} from "../../../hooks/dump/useDumpListSync.ts";
import { makeDump, makeUser } from "../../fixtures.ts";
import { liveWS } from "../../live.tsx";
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes));
describe("useDumpListSync", () => {
function setup(
initial: Dump[],
opts?: Parameters<typeof useDumpListSync>[1],
) {
function setup(initial: Dump[], opts?: DumpListSyncOptions) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
useDumpListSync(list.setItems, opts);
return list;
const [items, setItems] = useState(initial);
useDumpListSync(items, setItems, opts);
return { items, setItems };
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
const items = () => hook.result.current.items;
const update = (dump: Dump) => ws.emit("dump", { type: "updated", dump });
const create = (dump: Dump) => ws.emit("dump", { type: "created", dump });
return { ...ws, hook, items, update, create };
}
test("drops deleted dumps", () => {
const { hook, emit } = setup([
const { set, items } = setup([
makeDump({ id: "a" }),
makeDump({ id: "b" }),
]);
emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]);
set({ deletedDumpIds: new Set(["a"]) });
expect(ids(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");
const { update, items } = setup([makeDump({ id: "a", title: "old" })]);
update(makeDump({ id: "a", title: "new" }));
expect(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([]);
viewer.update({ ...a, isPrivate: true });
expect(viewer.items()).toEqual([]);
const owner = setup([a], { isOwner: true });
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
owner.update({ ...a, isPrivate: true });
expect(owner.items()[0].isPrivate).toBe(true);
});
test("never adds a dump the list doesn't hold", () => {
const { hook, emit } = setup([]);
emit({ lastDumpEvent: makeDump({ id: "m", userId: "me" }) });
expect(hook.result.current.items).toEqual([]);
test("without `belongs`, never adds a dump the list doesn't hold", () => {
const { create, items } = setup([]);
create(makeDump({ id: "m", userId: "me" }));
expect(items()).toEqual([]);
});
test("every event of a burst applies, however many before a render", () => {
const { events, items } = setup([
makeDump({ id: "a", commentCount: 0 }),
makeDump({ id: "b", commentCount: 0 }),
]);
act(() => {
events.emit("comment", { type: "created", dumpId: "a" });
events.emit("comment", { type: "created", dumpId: "b" });
events.emit("comment", { type: "created", dumpId: "a" });
});
expect(items().map((d) => d.commentCount)).toEqual([2, 1]);
});
test("a dump that left comes back where it was, if it belongs", () => {
const initial = ["a", "b", "c"].map((id) => makeDump({ id }));
const { set, create, items } = setup(initial, {
belongs: (d) => !d.isPrivate,
});
// Gone private for others, as a deletion.
set({ deletedDumpIds: new Set(["b"]) });
expect(ids(items())).toEqual(["a", "c"]);
set({ deletedDumpIds: new Set() });
create(initial[1]);
expect(ids(items())).toEqual(["a", "b", "c"]);
});
test("a position clamps to the list's end, and is used once", () => {
const initial = ["a", "b", "c"].map((id) => makeDump({ id }));
const { hook, update, items } = setup(initial, { belongs: () => true });
act(() => hook.result.current.setItems((prev) => prev.slice(0, 2)));
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
update(initial[2]);
expect(ids(items())).toEqual(["a", "c"]);
// Back in the list, it is edited in place, not put back again.
update({ ...initial[2], title: "again" });
expect(ids(items())).toEqual(["a", "c"]);
expect(items()[1].title).toBe("again");
});
test("with `addNew`, a new dump goes where its date puts it", () => {
const { create, items } = setup(
[
makeDump({ id: "b", createdAt: at(20) }),
makeDump({ id: "d", createdAt: at(10) }),
],
{ belongs: (d) => d.userId === "me", addNew: true },
);
create(makeDump({ id: "a", userId: "me", createdAt: at(30) }));
create(makeDump({ id: "c", userId: "me", createdAt: at(15) }));
// Older than everything loaded: a later page has it.
create(makeDump({ id: "e", userId: "me", createdAt: at(5) }));
// Someone else's.
create(makeDump({ id: "x", userId: "them", createdAt: at(40) }));
expect(ids(items())).toEqual(["a", "b", "c", "d"]);
});
test("with `addNew`, an empty list takes the first dump", () => {
const { create, items } = setup([], { belongs: () => true, addNew: true });
create(makeDump({ id: "a" }));
expect(ids(items())).toEqual(["a"]);
});
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);
const { emit, items } = setup([makeDump({ id: "a", commentCount: 0 })]);
emit("comment", { type: "created", dumpId: "a" });
expect(items()[0].commentCount).toBe(1);
emit("comment", { type: "updated", dumpId: "a" });
expect(items()[0].commentCount).toBe(1);
emit("comment", { type: "deleted", dumpId: "a" });
emit("comment", { type: "deleted", dumpId: "a" });
expect(items()[0].commentCount).toBe(0);
emit("comment", { type: "created", dumpId: "zzz" });
expect(items()).toHaveLength(1);
});
test("a renamed user's dumps take the new name", () => {
const { emit, items } = setup([
makeDump({ id: "a", userId: "u1", ownerUsername: "old" }),
makeDump({ id: "b", userId: "u2", ownerUsername: "other" }),
]);
emit("user", { user: makeUser({ id: "u1", username: "new" }) });
expect(items().map((d) => d.ownerUsername)).toEqual(["new", "other"]);
});
});

View File

@@ -3,10 +3,14 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { usePagedList } from "../../../hooks/feed/usePagedList.ts";
import { deferred } from "../../fetch.ts";
/** Items with the ids the list needs, `item(1)` for `{ id: "1" }`. */
const item = (n: number) => ({ id: String(n) });
const items = (...ns: number[]) => ns.map(item);
describe("usePagedList", () => {
test("loads the first page, then appends the next ones", async () => {
const fetchPage = vi.fn((page: number) =>
Promise.resolve({ items: [page], hasMore: page < 2 })
Promise.resolve({ items: items(page), hasMore: page < 2 })
);
const { result } = renderHook(() => usePagedList("k", fetchPage));
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
@@ -14,7 +18,12 @@ describe("usePagedList", () => {
await waitFor(() =>
expect(result.current.state).toEqual({
status: "loaded",
data: { items: [1, 2], hasMore: false, page: 2, loadingMore: false },
data: {
items: items(1, 2),
hasMore: false,
page: 2,
loadingMore: false,
},
})
);
act(() => result.current.loadMore());
@@ -22,9 +31,11 @@ describe("usePagedList", () => {
});
test("loads one next page at a time, and a failed one can be tried again", async () => {
const next = deferred<{ items: number[]; hasMore: boolean }>();
const next = deferred<{ items: { id: string }[]; hasMore: boolean }>();
const fetchPage = vi.fn((page: number) =>
page === 1 ? Promise.resolve({ items: [1], hasMore: true }) : next.promise
page === 1
? Promise.resolve({ items: items(1), hasMore: true })
: next.promise
);
const { result } = renderHook(() => usePagedList("k", fetchPage));
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
@@ -36,29 +47,62 @@ describe("usePagedList", () => {
await next.promise.catch(() => {});
});
expect(result.current.state).toMatchObject({
data: { items: [1], page: 1, loadingMore: false },
data: { items: items(1), page: 1, loadingMore: false },
});
});
test("a cached list shows as it was, and carries on from its page", async () => {
const fetchPage = vi.fn((page: number) =>
Promise.resolve({ items: [page], hasMore: false })
Promise.resolve({ items: items(page), hasMore: false })
);
const { result } = renderHook(() =>
usePagedList("k", fetchPage, {
items: [1, 2],
items: items(1, 2),
page: 2,
hasMore: true,
scrollY: 0,
})
);
expect(result.current.state).toMatchObject({ data: { items: [1, 2] } });
expect(result.current.state).toMatchObject({
data: { items: items(1, 2) },
});
act(() => result.current.loadMore());
await waitFor(() =>
expect(result.current.state).toMatchObject({
data: { items: [1, 2, 3] },
data: { items: items(1, 2, 3) },
})
);
expect(fetchPage).toHaveBeenCalledTimes(1);
});
test("resync fetches every page loaded again, in the list's place", async () => {
let version = 0;
const fetchPage = vi.fn((page: number) =>
Promise.resolve({
// After the first load, an item arrives on top and pushes the others
// down a slot, across the pages: 2 shows on both.
items: version === 0
? items(page * 2 - 1, page * 2)
: page === 1
? items(0, 1)
: items(2, 3),
hasMore: true,
})
);
const { result } = renderHook(() => usePagedList("k", fetchPage));
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.loadMore());
await waitFor(() =>
expect(result.current.state).toMatchObject({ data: { page: 2 } })
);
version = 1;
fetchPage.mockClear();
act(() => result.current.resync());
await waitFor(() =>
expect(result.current.state).toMatchObject({
data: { items: items(0, 1, 2, 3), page: 2, hasMore: true },
})
);
expect(fetchPage.mock.calls.map(([page]) => page)).toEqual([1, 2]);
});
});

View File

@@ -1,97 +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/ws/WSContext.ts";
import type { Dump } from "../../../../shared/dump.ts";
import type { Playlist } from "../../../../shared/playlist.ts";
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
import { usePositionAwareSync } from "../../../hooks/feed/usePositionAwareSync.ts";
import { useWS } from "../../../hooks/ws/useWS.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);
useDumpListSync(setItems);
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 dump that goes private for others, as a deletion, comes back where it was", () => {
const ws = liveWS();
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
const both = renderHook(() => {
const { items, setItems } = useList(initial);
const { lastDumpEvent } = useWS();
usePositionAwareSync(items, setItems, lastDumpEvent, (d) => !d.isPrivate);
useDumpListSync(setItems);
return { items };
}, { wrapper: ws.Wrapper });
ws.emit({ deletedDumpIds: new Set(["b"]) });
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
ws.emit({ deletedDumpIds: new Set(), lastDumpEvent: initial[1] });
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);
return { items, setItems, setEvent };
});
const c = initial[2];
// It leaves, whatever takes it out, then the list shrinks further.
act(() => hook.result.current.setItems((prev) => prev.slice(0, 2)));
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"]);
// Used once: the same event again adds nothing.
act(() =>
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
);
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
});
});

View File

@@ -1,155 +1,120 @@
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 { renderHook } from "@testing-library/react";
import { useState } from "react";
import type { Playlist } from "../../../../shared/playlist.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 };
}
import {
type PlaylistListSyncOptions,
usePlaylistListSync,
} from "../../../hooks/playlist/usePlaylistListSync.ts";
import { makePlaylist, makeUser } from "../../fixtures.ts";
import { liveWS } from "../../live.tsx";
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("usePlaylistListSync", () => {
function setup(
initial: Playlist[],
opts?: Parameters<typeof usePlaylistListSync>[1],
) {
function setup(initial: Playlist[], opts?: PlaylistListSyncOptions) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
usePlaylistListSync(list.setItems, opts);
return list;
const [items, setItems] = useState(initial);
usePlaylistListSync(items, setItems, opts);
return items;
}, { wrapper: ws.Wrapper });
return { ...ws, hook };
const items = () => hook.result.current;
const send = (type: "created" | "updated", playlist: Playlist) =>
ws.emit("playlist", { type, playlistId: playlist.id, playlist });
return { ...ws, items, send };
}
test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const { send, items } = 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"]);
send("created", p);
send("created", { ...p });
expect(ids(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([]);
byOwner.send("created", p);
expect(byOwner.items()).toEqual([]);
const priv = setup([]);
priv.emit({
lastPlaylistEvent: {
type: "created",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(priv.hook.result.current.items).toEqual([]);
priv.send("created", { ...p, isPublic: false });
expect(priv.items()).toEqual([]);
const followed = setup([], { noNewEntries: true });
followed.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(followed.hook.result.current.items).toEqual([]);
followed.send("created", p);
expect(followed.items()).toEqual([]);
});
test("updated: in place, removed when private, never re-added by itself", () => {
test("updated: in place, removed when private, never added by itself", () => {
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" },
},
});
// usePositionAwareSync puts it back, where it was.
expect(hook.result.current.items).toEqual([]);
const other = makePlaylist({ id: "q" });
const { send, items } = setup([p]);
send("updated", { ...p, title: "two" });
expect(items()[0].title).toBe("two");
send("updated", { ...p, isPublic: false });
expect(items()).toEqual([]);
send("updated", other);
expect(items()).toEqual([]);
});
test("one that left comes back where it was, even to a followed list", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
const { set, send, items } = setup(initial, { noNewEntries: true });
// Gone private for others, as a deletion.
set({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(items())).toEqual(["a", "c"]);
set({ deletedPlaylistIds: new Set() });
send("created", initial[1]);
expect(ids(items())).toEqual(["a", "b", "c"]);
});
test("its owner's list keeps it private, in place", () => {
const p = makePlaylist({ id: "p", userId: "me" });
const { send, items } = setup([p], { isOwner: true, ownerId: "me" });
send("updated", { ...p, isPublic: false });
expect(items()[0].isPublic).toBe(false);
});
test("deleted and deletedPlaylistIds both remove", () => {
const { hook, emit } = setup([
const { emit, set, items } = 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"]);
emit("playlist", { type: "deleted", playlistId: "a" });
set({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(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"],
},
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit("playlist", {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y", "z"],
});
expect(hook.result.current.items[0].dumpCount).toBe(3);
expect(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,
},
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
emit("playlist", {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y"],
dumpCount: 4,
});
expect(hook.result.current.items[0].dumpCount).toBe(4);
expect(items()[0].dumpCount).toBe(4);
});
test("a renamed owner's playlists take the new name", () => {
const { emit, items } = setup([
makePlaylist({ id: "a", userId: "u1", ownerUsername: "old" }),
]);
emit("user", { user: makeUser({ id: "u1", username: "new" }) });
expect(items()[0].ownerUsername).toBe("new");
});
});

View File

@@ -93,4 +93,25 @@ describe("useFetchState", () => {
expect(result.current.state).toEqual({ status: "loaded", data: 2 })
);
});
test("reload replaces the data, and keeps it when the load fails", async () => {
let n = 0;
const load = vi.fn(() =>
++n === 3 ? Promise.reject(new Error("offline")) : Promise.resolve(n)
);
const { result } = renderHook(() => useFetchState("a", load));
await waitFor(() =>
expect(result.current.state).toEqual({ status: "loaded", data: 1 })
);
act(() => result.current.reload());
await waitFor(() =>
expect(result.current.state).toEqual({ status: "loaded", data: 2 })
);
await act(async () => {
result.current.reload();
await Promise.resolve();
});
expect(load).toHaveBeenCalledTimes(3);
expect(result.current.state).toEqual({ status: "loaded", data: 2 });
});
});

38
src/test/live.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { act } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import {
createWSEventBus,
defaultWSValue,
WSContext,
type WSContextValue,
type WSEvents,
WSEventsContext,
} from "../contexts/ws/WSContext.ts";
/**
* Stands in for `WSProvider` around a hook: `set` changes the live state, as
* a message does, and `emit` sends an event to the hook's subscriptions, or
* `events` several within one `act`.
*/
export function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const events = createWSEventBus();
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(defaultWSValue);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return (
<WSContext.Provider value={value}>
<WSEventsContext.Provider value={events}>
{children}
</WSEventsContext.Provider>
</WSContext.Provider>
);
}
return {
Wrapper,
events,
set: (patch: Partial<WSContextValue>) => act(() => push(patch)),
emit: <K extends keyof WSEvents>(kind: K, event: WSEvents[K]) =>
act(() => events.emit(kind, event)),
};
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { Dump } from "../../pages/Dump.tsx";
@@ -237,6 +237,69 @@ describe("Dump page", () => {
.toBeInTheDocument();
});
test("a dump deleted, or made private, while on screen gives way to a notice", async () => {
dumpRoutes();
const { socket } = openDump(other);
await screen.findByRole("heading", { name: "A find" });
socket.receive({ type: "dump_deleted", dumpId: "d1" });
expect(screen.getByText("This dump was deleted, or made private."))
.toBeInTheDocument();
// Made public again by its poster.
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "d1", slug: "a-find", title: "Back" })),
});
expect(screen.getByRole("heading", { name: "Back" })).toBeInTheDocument();
});
test("a reconnect fetches the dump again", async () => {
const d = dumpRoutes();
route("GET /api/dumps/:id", () => wire({ ...d, title: "Edited offline" }));
const { socket } = openDump(other);
await screen.findByRole("heading", { name: "A find" });
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
expect(await screen.findByRole("heading", { name: "Edited offline" }))
.toBeInTheDocument();
});
test("a reconnect finds a dump deleted meanwhile", async () => {
dumpRoutes();
route("GET /api/dumps/:id", () => fail("Dump not found", 404));
const { socket } = openDump(other);
await screen.findByRole("heading", { name: "A find" });
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
expect(
await screen.findByText("This dump was deleted, or made private."),
).toBeInTheDocument();
});
test("a renamed commenter's name follows", async () => {
dumpRoutes();
route(
"GET /api/dumps/:id/comments",
() =>
wire([
makeComment({
id: "c1",
dumpId: "d1",
userId: "u9",
authorUsername: "oldname",
}),
]),
);
const { socket } = openDump(other);
await screen.findByText("oldname");
socket.receive({
type: "user_updated",
user: wire(makeUser({ id: "u9", username: "newname" })),
});
expect(screen.getByText("newname")).toBeInTheDocument();
});
test("adding to a playlist", async () => {
dumpRoutes();
route("GET /api/me/playlists", () => [

View File

@@ -248,6 +248,22 @@ describe("Index feeds", () => {
expect(screen.getByText("Another song")).toBeInTheDocument();
});
test("a dump edited out of the category leaves its feed", async () => {
const song = makeDump({
id: "s",
title: "A song",
categoryIds: ["c-music"],
});
route("GET /api/dumps", () => page([song]));
const { socket } = setup("/music/new");
await screen.findByText("A song");
socket.receive({
type: "dump_updated",
dump: wire({ ...song, categoryIds: [] }),
});
expect(screen.queryByText("A song")).not.toBeInTheDocument();
});
test("an unknown category is a 404", async () => {
route("GET /api/dumps", () => fail("nope", 404));
setup("/nonsense/new");
@@ -306,6 +322,74 @@ describe("Followed feed", () => {
expect(currentPath()).toBe("/follow");
});
test("a followed user's new dump arrives live, others' don't", async () => {
route("GET /api/me/follow", () => ({
followedUserIds: ["friend"],
followedPlaylistIds: [],
}));
route(
"GET /api/dumps",
(req) =>
page(
req.url.searchParams.get("follow") === "users"
? [
makeDump({
title: "By a friend",
userId: "friend",
createdAt: new Date(Date.now() - 60_000),
}),
]
: [],
),
);
const { socket } = setup("/follow", { session: makeSession() });
await screen.findByText("By a friend");
// The follows load beside the feed: their answer must be in.
await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1));
await act(() => new Promise((r) => setTimeout(r, 0)));
socket.receive({
type: "dump_created",
dump: wire(makeDump({ title: "Fresh", userId: "friend" })),
});
socket.receive({
type: "dump_created",
dump: wire(makeDump({ title: "A stranger's", userId: "stranger" })),
});
expect(screen.getByText("Fresh")).toBeInTheDocument();
expect(screen.queryByText("A stranger's")).not.toBeInTheDocument();
});
test("a followed playlist's new dumps bring its view up to date", async () => {
route("GET /api/me/follow", () => ({
followedUserIds: [],
followedPlaylistIds: ["mix"],
}));
route("GET /api/dumps", () => page([]), { once: true });
route("GET /api/dumps", () => page([]), { once: true });
route(
"GET /api/dumps",
(req) =>
page(
req.url.searchParams.get("follow") === "playlists"
? [makeDump({ title: "Added to the mix" })]
: [],
),
);
const { socket } = setup("/follow/playlists", { session: makeSession() });
await screen.findByText(
"Follow some public playlists to see their dumps here.",
);
await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1));
await act(() => new Promise((r) => setTimeout(r, 0)));
socket.receive({
type: "playlist_dumps_updated",
playlistId: "mix",
dumpIds: ["x"],
dumpCount: 1,
});
expect(await screen.findByText("Added to the mix")).toBeInTheDocument();
});
test("its view is in the path, a category's too", async () => {
route("GET /api/dumps", () => page([]));
setup("/music/follow/playlists", { session: makeSession() });

View File

@@ -440,18 +440,47 @@ describe("PlaylistDetail", () => {
});
test("a dump that goes private leaves, and comes back at its place", async () => {
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "b", title: "Bravo" })),
);
const { socket } = open({ session: visitor });
await screen.findByText("Bravo");
// What the server sends others when it goes private, then public again.
socket.receive({ type: "dump_deleted", dumpId: "b" });
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["a", "c"],
dumpCount: 3,
});
expect(titles()).toEqual(["Alpha", "Charlie"]);
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "b", title: "Bravo" })),
});
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["a", "b", "c"],
dumpCount: 3,
});
expect(await screen.findByText("Bravo")).toBeInTheDocument();
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
});
test("a playlist made private by its owner sends a visitor home", async () => {
const { socket } = open({ session: visitor });
await screen.findByText("Alpha");
// What the server sends everyone but the owner.
socket.receive({
type: "playlist_deleted",
playlistId: "p1",
userId: "owner",
});
expect(await screen.findByText("home")).toBeInTheDocument();
});
test("deleting the playlist asks, then goes home", async () => {
route(
"DELETE /api/playlists/:id",

View File

@@ -13,6 +13,7 @@ import {
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeIntersectionObserver } from "../setup.ts";
import { FakeWebSocket } from "../ws.ts";
function results(
{
@@ -209,4 +210,32 @@ describe("Search", () => {
await screen.findByText("About two");
expect(screen.queryByText("About one")).not.toBeInTheDocument();
});
test("the socket keeps the results live, and adds none", async () => {
const found = makeDump({ id: "d1", title: "Cat video" });
route("GET /api/search", () =>
results({
dumps: [found, makeDump({ id: "d2", title: "Cat photo" })],
playlists: [makePlaylist({ id: "p1", title: "Cats" })],
}));
const user = open("/search?q=cat");
const socket = FakeWebSocket.last;
socket.welcome();
await screen.findByText("Cat video");
socket.receive({
type: "dump_updated",
dump: wire({ ...found, title: "Cat video, remastered" }),
});
socket.receive({ type: "dump_deleted", dumpId: "d2" });
socket.receive({
type: "dump_created",
dump: wire(makeDump({ title: "Cat news" })),
});
expect(screen.getByText("Cat video, remastered")).toBeInTheDocument();
expect(screen.queryByText("Cat photo")).not.toBeInTheDocument();
expect(screen.queryByText("Cat news")).not.toBeInTheDocument();
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "u" });
await user.click(screen.getByRole("button", { name: /Playlists/ }));
expect(screen.queryByText("Cats")).not.toBeInTheDocument();
});
});

View File

@@ -177,6 +177,38 @@ describe("UserPublicProfile", () => {
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
});
test("the dumps tab gains her new dumps live, and reads its pages again on a reconnect", async () => {
const old = makeDump({
id: "o",
title: "Older",
userId: "ann",
createdAt: new Date(Date.now() - 60_000),
});
const { socket } = open({ url: "/@ann/dumps", dumps: [old] });
await screen.findByText("Older");
socket.welcome();
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "n", title: "Fresh", userId: "ann" })),
});
// Someone else's goes to their own profile.
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "x", title: "Not hers", userId: "bob" })),
});
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title"))
.map((a) => a.textContent);
expect(titles()).toEqual(["Fresh", "Older"]);
// Deleted while the socket was down.
route("GET /api/users/:u/dumps", () => page([old]));
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await waitFor(() => expect(titles()).toEqual(["Older"]));
});
test("the dumps tab narrows to a category, in its URL, and drops a dump edited out of it; only the dump lists keep it", async () => {
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
const tagged = makeDump({

View File

@@ -10,6 +10,7 @@ import { DEFAULT_SETTINGS } from "../../shared/settings.ts";
import { AuthContext } from "../contexts/auth/AuthContext.ts";
import { AuthProvider } from "../contexts/auth/AuthProvider.tsx";
import {
createWSEventBus,
defaultWSChatValue,
defaultWSPresenceValue,
defaultWSValue,
@@ -17,6 +18,8 @@ import {
type WSChatContextValue,
WSContext,
type WSContextValue,
type WSEventBus,
WSEventsContext,
WSPresenceContext,
type WSPresenceContextValue,
} from "../contexts/ws/WSContext.ts";
@@ -97,6 +100,8 @@ export interface StubOptions extends RouterOptions {
session?: Session | null;
/** Any of the socket's three contexts' fields; each goes to its own. */
ws?: Partial<WSContextValue & WSChatContextValue & WSPresenceContextValue>;
/** The bus the socket's events come from: a test emits on it. */
events?: WSEventBus;
follows?: Partial<FollowContextValue>;
categories?: Category[] | Partial<CategoriesContextValue>;
player?: Partial<PlayerContextValue & PlayerTimeValue>;
@@ -145,6 +150,8 @@ export function renderWithContexts(
opts: StubOptions = {},
renderOptions?: RenderOptions,
) {
// One bus for every render, as `WSProvider` keeps one.
const events = opts.events ?? createWSEventBus();
// A wrapper rather than one tree, so `rerender(ui)` keeps the contexts.
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nProvider i18n={i18n}>
@@ -161,25 +168,29 @@ export function renderWithContexts(
}}
>
<WSContext.Provider value={pick(defaultWSValue, opts.ws)}>
<WSChatContext.Provider value={pick(defaultWSChatValue, opts.ws)}>
<WSPresenceContext.Provider
value={pick(defaultWSPresenceValue, opts.ws)}
<WSEventsContext.Provider value={events}>
<WSChatContext.Provider
value={pick(defaultWSChatValue, opts.ws)}
>
<FollowContext.Provider
value={{
...defaultFollowValue,
isLoaded: true,
...opts.follows,
}}
<WSPresenceContext.Provider
value={pick(defaultWSPresenceValue, opts.ws)}
>
<CategoriesContext.Provider
value={categoriesValue(opts.categories)}
<FollowContext.Provider
value={{
...defaultFollowValue,
isLoaded: true,
...opts.follows,
}}
>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>
</WSPresenceContext.Provider>
</WSChatContext.Provider>
<CategoriesContext.Provider
value={categoriesValue(opts.categories)}
>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>
</WSPresenceContext.Provider>
</WSChatContext.Provider>
</WSEventsContext.Provider>
</WSContext.Provider>
</PlayerTimeContext.Provider>
</PlayerContext.Provider>