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

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

View File

@@ -0,0 +1,123 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { useAuth } from "../../../hooks/auth/useAuth.ts";
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
import { calls, deferred, ok, route } from "../../fetch.ts";
import { makeSession, makeToken, storeSession, wire } from "../../fixtures.ts";
const authWrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
describe("AuthProvider", () => {
test("starts from the stored session", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user?.username).toBe("ann");
});
test("a token the server rejects is dropped", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => new Response(null, { status: 401 }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user).toBeNull());
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
`Bearer ${session.token}`,
);
});
test("a role changed on the server is picked up", async () => {
const session = makeSession({ role: "user" });
storeSession(session);
route(
"GET /api/users/me",
() => wire({ ...session.user, role: "moderator" }),
);
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
"moderator",
);
});
test("offline doesn't sign anyone out", async () => {
storeSession(makeSession());
route("GET /api/users/me", () => new TypeError("offline"));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
expect(result.current.user).not.toBeNull();
});
test("revalidates when the tab regains focus", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
act(() => {
globalThis.dispatchEvent(new Event("focus"));
});
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
});
test("a session without a media token fetches one", async () => {
const session = { ...makeSession(), mediaToken: undefined };
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
"m-1",
);
});
test("follows sign-in and sign-out from other tabs", async () => {
route("GET /api/users/me", () => ok(null));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user).toBeNull();
const session = makeSession({ username: "elsewhere" });
storeSession(session);
act(() => {
globalThis.dispatchEvent(
new StorageEvent("storage", { key: AUTH_STORAGE_KEY }),
);
});
expect(result.current.user?.username).toBe("elsewhere");
localStorage.removeItem(AUTH_STORAGE_KEY);
act(() => {
globalThis.dispatchEvent(
new StorageEvent("storage", { key: "unrelated" }),
);
});
expect(result.current.user).not.toBeNull();
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: null }));
});
expect(result.current.user).toBeNull();
});
test("a 401 for an old token doesn't sign out a newer session", async () => {
const first = makeSession();
storeSession(first);
const pending = deferred();
route("GET /api/users/me", () => pending.promise, { once: true });
route("GET /api/users/me", () => wire(first.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
const second = { ...first, token: makeToken() };
act(() => result.current.login(second));
await act(async () => pending.resolve(new Response(null, { status: 401 })));
expect(result.current.token).toBe(second.token);
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
second.token,
);
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { CategoriesProvider } from "../../../contexts/category/CategoriesProvider.tsx";
import { useCategories } from "../../../hooks/category/useCategories.ts";
import { fail, route } from "../../fetch.ts";
import { makeCategory, wire } from "../../fixtures.ts";
describe("CategoriesProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<CategoriesProvider>{children}</CategoriesProvider>
);
test("loads and indexes categories", async () => {
const music = makeCategory({ id: "c1", slug: "music" });
route("GET /api/categories", () => wire([music]));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.byId.get("c1")?.slug).toBe("music");
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
});
test("refresh refetches", async () => {
let list = [makeCategory({ id: "a" })];
route("GET /api/categories", () => wire(list));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.categories).toHaveLength(1));
list = [...list, makeCategory({ id: "b" })];
await act(() => result.current.refresh());
expect(result.current.categories).toHaveLength(2);
});
test("a failure still ends loading", async () => {
route("GET /api/categories", () => fail("x", 500));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.categories).toEqual([]);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { ChatProvider } from "../../../contexts/chat/ChatProvider.tsx";
import { useChat } from "../../../hooks/chat/useChat.ts";
import { route } from "../../fetch.ts";
import { makeSession } from "../../fixtures.ts";
import { currentPath, renderApp } from "../../render.tsx";
describe("ChatProvider", () => {
test("opens only for signed-in users", async () => {
const guest = renderHook(useChat, {
wrapper: ({ children }) => (
<AuthProvider>
<MemoryRouter>
<ChatProvider>{children}</ChatProvider>
</MemoryRouter>
</AuthProvider>
),
});
act(() => guest.result.current.openChat());
expect(guest.result.current.isChatOpen).toBe(false);
});
test("?chat=open deep-links into it, and closing strips the param", async () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
expect(chat.isChatOpen).toBe(true);
await screen.findByRole("dialog", { name: "Chat" });
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
expect(currentPath()).toBe("/somewhere?x=1");
});
test("openChat / closeChat", () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session });
act(() => chat.openChat());
expect(chat.isChatOpen).toBe(true);
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
});
});

View File

@@ -1,17 +1,17 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, render, renderHook, waitFor } from "@testing-library/react";
import { memo, type ReactNode, useContext, useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
import { PlayerProvider } from "../../../contexts/player/PlayerProvider.tsx";
import {
PlayerContext,
type PlayerItem,
PlayerTimeContext,
} from "../../contexts/PlayerContext.ts";
import type { AuthResponse } from "../../model.ts";
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
} from "../../../contexts/player/PlayerContext.ts";
import type { AuthResponse } from "../../../model.ts";
import { STREAM_STALE_MS } from "../../../utils/player/streamSources.ts";
import { calls, deferred, fail, route } from "../../fetch.ts";
import { makeSession } from "../../fixtures.ts";
const embed = (n: number): PlayerItem => ({
kind: "embed",

View File

@@ -1,323 +0,0 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router";
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
import { FollowProvider } from "../../contexts/FollowProvider.tsx";
import { CategoriesProvider } from "../../contexts/CategoriesProvider.tsx";
import { ThemeProvider } from "../../contexts/ThemeProvider.tsx";
import { ChatProvider } from "../../contexts/ChatProvider.tsx";
import { useAuth } from "../../hooks/useAuth.ts";
import { useFollows } from "../../hooks/useFollows.ts";
import { useCategories } from "../../hooks/useCategories.ts";
import { useTheme } from "../../hooks/useTheme.ts";
import { useChat } from "../../hooks/useChat.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, deferred, fail, ok, route } from "../fetch.ts";
import {
makeCategory,
makeSession,
makeToken,
storeSession,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
const authWrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
describe("AuthProvider", () => {
test("starts from the stored session", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user?.username).toBe("ann");
});
test("a token the server rejects is dropped", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => new Response(null, { status: 401 }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user).toBeNull());
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
`Bearer ${session.token}`,
);
});
test("a role changed on the server is picked up", async () => {
const session = makeSession({ role: "user" });
storeSession(session);
route(
"GET /api/users/me",
() => wire({ ...session.user, role: "moderator" }),
);
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
"moderator",
);
});
test("offline doesn't sign anyone out", async () => {
storeSession(makeSession());
route("GET /api/users/me", () => new TypeError("offline"));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
expect(result.current.user).not.toBeNull();
});
test("revalidates when the tab regains focus", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
act(() => {
globalThis.dispatchEvent(new Event("focus"));
});
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
});
test("a session without a media token fetches one", async () => {
const session = { ...makeSession(), mediaToken: undefined };
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
"m-1",
);
});
test("follows sign-in and sign-out from other tabs", async () => {
route("GET /api/users/me", () => ok(null));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
expect(result.current.user).toBeNull();
const session = makeSession({ username: "elsewhere" });
storeSession(session);
act(() => {
globalThis.dispatchEvent(
new StorageEvent("storage", { key: AUTH_STORAGE_KEY }),
);
});
expect(result.current.user?.username).toBe("elsewhere");
localStorage.removeItem(AUTH_STORAGE_KEY);
act(() => {
globalThis.dispatchEvent(
new StorageEvent("storage", { key: "unrelated" }),
);
});
expect(result.current.user).not.toBeNull();
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: null }));
});
expect(result.current.user).toBeNull();
});
test("a 401 for an old token doesn't sign out a newer session", async () => {
const first = makeSession();
storeSession(first);
const pending = deferred();
route("GET /api/users/me", () => pending.promise, { once: true });
route("GET /api/users/me", () => wire(first.user));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
const second = { ...first, token: makeToken() };
act(() => result.current.login(second));
await act(async () => pending.resolve(new Response(null, { status: 401 })));
expect(result.current.token).toBe(second.token);
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
second.token,
);
});
});
describe("FollowProvider", () => {
function setup(
status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] },
) {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/follows/status", () => status);
return renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
}
test("loads what the user follows", async () => {
const { result } = setup();
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.followedUserIds.has("u1")).toBe(true);
expect(result.current.followedPlaylistIds.has("p1")).toBe(true);
});
test("guests follow nobody and never ask", async () => {
const { result } = renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
await act(async () => {});
expect(result.current.isLoaded).toBe(false);
expect(calls("GET /api/follows/status")).toHaveLength(0);
});
test("follow and unfollow are optimistic and roll back on failure", async () => {
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
route("POST /api/follows/users/:id", () => ({}));
await act(() => result.current.followUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("DELETE /api/follows/users/:id", () => fail("no", 500));
await act(() => result.current.unfollowUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
await act(() => result.current.followPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
route("POST /api/follows/playlists/:id", () => ({}));
await act(() => result.current.followPlaylist("p2"));
route("DELETE /api/follows/playlists/:id", () => ({}));
await act(() => result.current.unfollowPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
"/api/follows/playlists/p2",
);
});
});
describe("CategoriesProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<CategoriesProvider>{children}</CategoriesProvider>
);
test("loads and indexes categories", async () => {
const music = makeCategory({ id: "c1", slug: "music" });
route("GET /api/categories", () => wire([music]));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.byId.get("c1")?.slug).toBe("music");
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
});
test("refresh refetches", async () => {
let list = [makeCategory({ id: "a" })];
route("GET /api/categories", () => wire(list));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.categories).toHaveLength(1));
list = [...list, makeCategory({ id: "b" })];
await act(() => result.current.refresh());
expect(result.current.categories).toHaveLength(2);
});
test("a failure still ends loading", async () => {
route("GET /api/categories", () => fail("x", 500));
const { result } = renderHook(useCategories, { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.categories).toEqual([]);
});
});
describe("ThemeProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const root = document.documentElement;
test("smooth and auto by default, with no attributes", () => {
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("smooth");
expect(result.current.colorScheme).toBe("auto");
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("applies and persists a choice", () => {
const { result } = renderHook(useTheme, { wrapper });
act(() => result.current.setStyle("brutalist"));
act(() => result.current.setColorScheme("dark"));
expect(root.getAttribute("data-style")).toBe("brutalist");
expect(root.getAttribute("data-color-scheme")).toBe("dark");
expect(localStorage.getItem("style")).toBe("brutalist");
expect(localStorage.getItem("color-scheme")).toBe("dark");
act(() => result.current.setStyle("smooth"));
act(() => result.current.setColorScheme("auto"));
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("restores the stored theme, migrating the legacy name", () => {
localStorage.setItem("style", "nyt");
localStorage.setItem("color-scheme", "light");
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("nyt");
expect(result.current.colorScheme).toBe("light");
localStorage.setItem("style", "default");
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe(
"smooth",
);
});
});
describe("ChatProvider", () => {
test("opens only for signed-in users", async () => {
const guest = renderHook(useChat, {
wrapper: ({ children }) => (
<AuthProvider>
<MemoryRouter>
<ChatProvider>{children}</ChatProvider>
</MemoryRouter>
</AuthProvider>
),
});
act(() => guest.result.current.openChat());
expect(guest.result.current.isChatOpen).toBe(false);
});
test("?chat=open deep-links into it, and closing strips the param", async () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
expect(chat.isChatOpen).toBe(true);
await screen.findByRole("dialog", { name: "Chat" });
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
expect(currentPath()).toBe("/somewhere?x=1");
});
test("openChat / closeChat", () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType<typeof useChat>;
function Probe() {
chat = useChat();
return null;
}
renderApp(<Probe />, { session });
act(() => chat.openChat());
expect(chat.isChatOpen).toBe(true);
act(() => chat.closeChat());
expect(chat.isChatOpen).toBe(false);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { FollowProvider } from "../../../contexts/social/FollowProvider.tsx";
import { useFollows } from "../../../hooks/social/useFollows.ts";
import { calls, fail, route } from "../../fetch.ts";
import { makeSession, storeSession, wire } from "../../fixtures.ts";
describe("FollowProvider", () => {
function setup(
status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] },
) {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/follows/status", () => status);
return renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
}
test("loads what the user follows", async () => {
const { result } = setup();
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.followedUserIds.has("u1")).toBe(true);
expect(result.current.followedPlaylistIds.has("p1")).toBe(true);
});
test("guests follow nobody and never ask", async () => {
const { result } = renderHook(useFollows, {
wrapper: ({ children }) => (
<AuthProvider>
<FollowProvider>{children}</FollowProvider>
</AuthProvider>
),
});
await act(async () => {});
expect(result.current.isLoaded).toBe(false);
expect(calls("GET /api/follows/status")).toHaveLength(0);
});
test("follow and unfollow are optimistic and roll back on failure", async () => {
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
route("POST /api/follows/users/:id", () => ({}));
await act(() => result.current.followUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("DELETE /api/follows/users/:id", () => fail("no", 500));
await act(() => result.current.unfollowUser("u2"));
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
await act(() => result.current.followPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
route("POST /api/follows/playlists/:id", () => ({}));
await act(() => result.current.followPlaylist("p2"));
route("DELETE /api/follows/playlists/:id", () => ({}));
await act(() => result.current.unfollowPlaylist("p2"));
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
"/api/follows/playlists/p2",
);
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { ThemeProvider } from "../../../contexts/theme/ThemeProvider.tsx";
import { useTheme } from "../../../hooks/theme/useTheme.ts";
describe("ThemeProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const root = document.documentElement;
test("smooth and auto by default, with no attributes", () => {
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("smooth");
expect(result.current.colorScheme).toBe("auto");
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("applies and persists a choice", () => {
const { result } = renderHook(useTheme, { wrapper });
act(() => result.current.setStyle("brutalist"));
act(() => result.current.setColorScheme("dark"));
expect(root.getAttribute("data-style")).toBe("brutalist");
expect(root.getAttribute("data-color-scheme")).toBe("dark");
expect(localStorage.getItem("style")).toBe("brutalist");
expect(localStorage.getItem("color-scheme")).toBe("dark");
act(() => result.current.setStyle("smooth"));
act(() => result.current.setColorScheme("auto"));
expect(root.hasAttribute("data-style")).toBe(false);
expect(root.hasAttribute("data-color-scheme")).toBe(false);
});
test("restores the stored theme, migrating the legacy name", () => {
localStorage.setItem("style", "nyt");
localStorage.setItem("color-scheme", "light");
const { result } = renderHook(useTheme, { wrapper });
expect(result.current.style).toBe("nyt");
expect(result.current.colorScheme).toBe("light");
localStorage.setItem("style", "default");
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe(
"smooth",
);
});
});

View File

@@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { useContext } from "react";
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
import { WSProvider } from "../../contexts/WSProvider.tsx";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { useWS, useWSChat, useWSPresence } from "../../hooks/useWS.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { FakeWebSocket } from "../ws.ts";
import { defaultRoutes } from "../render.tsx";
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 { useAuth } from "../../../hooks/auth/useAuth.ts";
import { FakeWebSocket } from "../../ws.ts";
import { defaultRoutes } from "../../render.tsx";
import {
makeChatMessage,
makeComment,
@@ -19,7 +19,7 @@ import {
makeUser,
storeSession,
wire,
} from "../fixtures.ts";
} from "../../fixtures.ts";
const me = makeSession({ id: "user-me", username: "me" });