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

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

View File

@@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DumpCard } from "../../../components/dump/DumpCard.tsx";
import { markDumpVisited } from "../../../utils/visited.ts";
import { markDumpVisited } from "../../../utils/shared/visited.ts";
import { makeCategory, makeDump, makeSession } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";

View File

@@ -8,7 +8,7 @@ import {
dumpFileUrl,
dumpFileVersion,
dumpThumbnailUrl,
} from "../../../utils/urls.ts";
} from "../../../utils/shared/urls.ts";
describe("FilePreview", () => {
const session = makeSession();

View File

@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { JournalCard } from "../../../components/dump/JournalCard.tsx";
import { makeDump } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";
import { dumpThumbnailUrl } from "../../../utils/urls.ts";
import { dumpThumbnailUrl } from "../../../utils/shared/urls.ts";
describe("JournalCard", () => {
const base = {

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FeedTabBar } from "../../../pages/index/FeedTabBar.tsx";
import { FeedTabBar } from "../../../components/feed/FeedTabBar.tsx";
import type { Category } from "../../../model.ts";
import { makeCategory, makeSession } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";

View File

@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen } from "@testing-library/react";
import { PresenceRow } from "../../../pages/index/PresenceRow.tsx";
import { PresenceRow } from "../../../components/feed/PresenceRow.tsx";
import { renderWithContexts } from "../../render.tsx";
describe("PresenceRow", () => {

View File

@@ -6,8 +6,8 @@ import { i18n } from "@lingui/core";
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import { useState } from "react";
import { AppHeader } from "../../../components/layout/AppHeader.tsx";
import { AuthContext } from "../../../contexts/AuthContext.ts";
import { defaultWSValue, WSContext } from "../../../contexts/WSContext.ts";
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts";
import type { AuthResponse } from "../../../model.ts";
import { makeSession } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";

View File

@@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatButton } from "../../../components/layout/ChatButton.tsx";
import { ChatContext } from "../../../contexts/ChatContext.ts";
import { ChatContext } from "../../../contexts/chat/ChatContext.ts";
import { renderWithContexts } from "../../render.tsx";
describe("ChatButton", () => {

View File

@@ -1,44 +1,36 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen } from "@testing-library/react";
import { ChatFab } from "../../../components/layout/ChatFab.tsx";
import { DumpFab } from "../../../components/layout/DumpFab.tsx";
import { ChatContext } from "../../../contexts/ChatContext.ts";
import { ChatContext } from "../../../contexts/chat/ChatContext.ts";
import { makeSession } from "../../fixtures.ts";
import { renderWithContexts } from "../../render.tsx";
describe("floating buttons", () => {
function withChat(openChat = vi.fn()) {
return (ui: React.ReactNode) => (
<ChatContext.Provider
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
>
{ui}
</ChatContext.Provider>
);
}
function withChat(ui: React.ReactNode, openChat = vi.fn()) {
return (
<ChatContext.Provider
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
>
{ui}
</ChatContext.Provider>
);
}
test("guests get neither", () => {
renderWithContexts(
withChat()(
<>
<ChatFab />
<DumpFab />
</>,
),
);
describe("ChatFab", () => {
test("guests don't get it", () => {
renderWithContexts(withChat(<ChatFab />));
expect(screen.queryByRole("button", { hidden: true })).not
.toBeInTheDocument();
});
test("they show once the header scrolls away", () => {
test("shows once the header scrolls away, and opens the chat", () => {
const openChat = vi.fn();
renderWithContexts(
withChat(openChat)(
withChat(
<>
<header className="app-header" />
<ChatFab />
<DumpFab />
</>,
openChat,
),
{ session: makeSession(), ws: { unreadChatCount: 2 } },
);
@@ -52,7 +44,6 @@ describe("floating buttons", () => {
expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)");
expect(chat).toHaveAttribute("aria-hidden", "true");
expect(chat).toHaveAttribute("tabindex", "-1");
expect(document.body).toHaveClass("has-fab");
header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect;
act(() => {
@@ -62,8 +53,5 @@ describe("floating buttons", () => {
expect(chat).toHaveClass("chat-fab--visible");
act(() => chat.click());
expect(openChat).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "New dump" })).toHaveClass(
"dump-fab--visible",
);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "vitest";
import { act, screen } from "@testing-library/react";
import { DumpFab } from "../../../components/layout/DumpFab.tsx";
import { makeSession } from "../../fixtures.ts";
import { renderWithContexts } from "../../render.tsx";
describe("DumpFab", () => {
test("guests don't get it", () => {
renderWithContexts(<DumpFab />);
expect(screen.queryByRole("button", { hidden: true })).not
.toBeInTheDocument();
expect(document.body).not.toHaveClass("has-fab");
});
test("shows once the header scrolls away", () => {
renderWithContexts(
<>
<header className="app-header" />
<DumpFab />
</>,
{ session: makeSession() },
);
const header = document.querySelector(".app-header")!;
// jsdom lays nothing out: put the header on screen first.
header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
const button = document.querySelector<HTMLButtonElement>(".dump-fab")!;
expect(button).toHaveAttribute("aria-label", "New dump");
expect(button).toHaveAttribute("aria-hidden", "true");
expect(document.body).toHaveClass("has-fab");
header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
expect(button).toHaveAttribute("aria-hidden", "false");
expect(button).toHaveClass("dump-fab--visible");
});
});

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/WSContext.ts";
import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts";
describe("NotificationBell", () => {
test("the bell shows the unread count and goes to notifications", async () => {

View File

@@ -3,11 +3,11 @@ import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useContext } from "react";
import { GlobalPlayer } from "../../../components/player/GlobalPlayer.tsx";
import { PlayerProvider } from "../../../contexts/PlayerProvider.tsx";
import { PlayerProvider } from "../../../contexts/player/PlayerProvider.tsx";
import {
PlayerContext,
type PlayerItem,
} from "../../../contexts/PlayerContext.ts";
} from "../../../contexts/player/PlayerContext.ts";
import { renderWithContexts } from "../../render.tsx";
let player!: React.ContextType<typeof PlayerContext>;

View File

@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx";
import { makePlaylist } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";
import { markPlaylistVisited } from "../../../utils/visited.ts";
import { markPlaylistVisited } from "../../../utils/shared/visited.ts";
describe("PlaylistCard", () => {
test("title, badge, owner, count; clicking opens it and marks it seen", async () => {

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { Avatar } from "../../../components/ui/Avatar.tsx";
import { AvatarOverrideContext } from "../../../contexts/AvatarOverrideContext.ts";
import { Avatar } from "../../../components/shared/Avatar.tsx";
import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts";
import { renderWithContexts } from "../../render.tsx";
describe("Avatar", () => {

View File

@@ -4,8 +4,8 @@ import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { useState } from "react";
import { Modal } from "../../../components/ui/Modal.tsx";
import { ConfirmModal } from "../../../components/ui/ConfirmModal.tsx";
import { Modal } from "../../../components/shared/Modal.tsx";
import { ConfirmModal } from "../../../components/shared/ConfirmModal.tsx";
const wrap = (ui: React.ReactNode) =>
render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { CountedInput } from "../../../components/ui/CountedInput.tsx";
import { CountedInput } from "../../../components/shared/CountedInput.tsx";
import { renderWithContexts } from "../../render.tsx";
describe("CountedInput", () => {

View File

@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { Modal } from "../../../components/ui/Modal.tsx";
import { Modal } from "../../../components/shared/Modal.tsx";
const wrap = (ui: React.ReactNode) =>
render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabBar } from "../../../components/ui/TabBar.tsx";
import { TabBar } from "../../../components/shared/TabBar.tsx";
import { renderWithContexts } from "../../render.tsx";
describe("TabBar", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { Thumbnail } from "../../../components/ui/Thumbnail.tsx";
import { Thumbnail } from "../../../components/shared/Thumbnail.tsx";
import { renderWithContexts } from "../../render.tsx";
describe("Thumbnail", () => {

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { ThumbnailPlaceholder } from "../../../components/ui/ThumbnailPlaceholder.tsx";
import { hueFromString } from "../../../utils/thumbnailTint.ts";
import { ThumbnailPlaceholder } from "../../../components/shared/ThumbnailPlaceholder.tsx";
import { hueFromString } from "../../../utils/shared/thumbnailTint.ts";
import { renderWithContexts } from "../../render.tsx";
describe("ThumbnailPlaceholder", () => {

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Tooltip } from "../../../components/ui/Tooltip.tsx";
import { Tooltip } from "../../../components/shared/Tooltip.tsx";
import { renderWithContexts } from "../../render.tsx";
describe("Tooltip", () => {

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

View File

@@ -8,7 +8,7 @@ import type {
Playlist,
PublicUser,
} from "../model.ts";
import { AUTH_STORAGE_KEY } from "../utils/authStorage.ts";
import { AUTH_STORAGE_KEY } from "../utils/auth/storage.ts";
let seq = 0;
const nextId = (prefix: string) => `${prefix}-${++seq}`;

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
import { describe, expect, test, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { useDumpCardLink } from "../../../hooks/dump/useDumpCardLink.ts";
import { isDumpVisited } from "../../../utils/shared/visited.ts";
describe("useDumpCardLink", () => {
function Card() {
const link = useDumpCardLink({ id: "d1", slug: "hello" });
return (
<div data-testid="card" {...link.cardProps}>
<a href="/elsewhere">inner link</a>
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
}}
>
play
</button>
</div>
);
}
function setup() {
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Card />} />
<Route path="/dumps/:id" element={<p>dump page</p>} />
</Routes>
</MemoryRouter>,
);
return { open };
}
test("a click navigates and marks visited", () => {
setup();
fireEvent.click(screen.getByTestId("card"));
expect(screen.getByText("dump page")).toBeInTheDocument();
expect(isDumpVisited("d1")).toBe(true);
});
test("ctrl/cmd/shift-click opens a new tab", () => {
const { open } = setup();
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
});
test("a middle click opens a new tab, except over a link", () => {
const { open } = setup();
const mouseDown = new MouseEvent("mousedown", {
button: 1,
bubbles: true,
cancelable: true,
});
screen.getByTestId("card").dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
fireEvent(
screen.getByTestId("card"),
new MouseEvent("auxclick", { button: 1, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
fireEvent(
screen.getByText("inner link"),
new MouseEvent("auxclick", { button: 1, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
fireEvent(
screen.getByTestId("card"),
new MouseEvent("auxclick", { button: 2, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
});
test("a modified click on the play button opens the dump instead of playing", () => {
const { open } = setup();
const play = screen.getByText("play");
fireEvent.click(play, { metaKey: true });
expect(open).toHaveBeenCalledTimes(1);
expect(play.dataset.played).toBeUndefined();
fireEvent.click(play);
expect(play.dataset.played).toBe("1");
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useDefaultFeedTab } from "../../../hooks/feed/useDefaultFeedTab.ts";
describe("useDefaultFeedTab", () => {
test("validates and persists", () => {
localStorage.setItem("default-feed-tab", "bogus");
const { result } = renderHook(useDefaultFeedTab);
expect(result.current[0]).toBe("journal");
act(() => result.current[1]("new"));
expect(result.current[0]).toBe("new");
expect(localStorage.getItem("default-feed-tab")).toBe("new");
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
});
});

View File

@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useFading } from "../../../hooks/feed/useFading.ts";
afterEach(() => vi.useRealTimers());
describe("useFading", () => {
beforeEach(() => vi.useFakeTimers());
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
expect(result.current.fading).toEqual({ a: "cooldown" });
act(() => vi.advanceTimersByTime(2000));
expect(result.current.fading).toEqual({ a: "dismissing" });
act(() => vi.advanceTimersByTime(350));
expect(result.current.fading).toEqual({});
});
test("starting twice is one animation", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
expect(result.current.fading).toEqual({ a: "dismissing" });
});
test("cancel during cooldown and during dismissal", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => result.current.cancelFading("a"));
expect(result.current.fading).toEqual({});
act(() => vi.advanceTimersByTime(5000));
expect(result.current.fading).toEqual({});
act(() => result.current.startFading("b"));
act(() => vi.advanceTimersByTime(2100));
act(() => result.current.cancelFading("b"));
expect(result.current.fading).toEqual({});
// Cancelled means it can start over.
act(() => result.current.startFading("b"));
expect(result.current.fading).toEqual({ b: "cooldown" });
});
test("cancelAll", () => {
const { result } = renderHook(useFading);
act(() => {
result.current.startFading("a");
result.current.startFading("b");
});
act(() => result.current.cancelAll());
expect(result.current.fading).toEqual({});
});
});

View File

@@ -0,0 +1,97 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
act,
fireEvent,
render,
renderHook,
screen,
} from "@testing-library/react";
import { type ReactNode } from "react";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router";
import { useFeedCache } from "../../../hooks/feed/useFeedCache.ts";
afterEach(() => vi.useRealTimers());
describe("useFeedCache", () => {
// A MemoryRouter's first entry is a POP, as after Back.
const wrapper = () => ({ children }: { children: ReactNode }) => (
<MemoryRouter>{children}</MemoryRouter>
);
test("restores a saved feed on a back/forward (POP) visit", () => {
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
const second = renderHook(
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
{ wrapper: wrapper() },
);
expect(second.result.current.cached).toMatchObject({
items: [{ n: 10 }],
page: 2,
hasMore: true,
scrollY: 300,
});
});
test("an expired entry is ignored", () => {
vi.useFakeTimers();
const first = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([], 1, false, 0));
vi.advanceTimersByTime(11 * 60 * 1000);
const second = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(second.result.current.cached).toBeNull();
});
test("a fresh (PUSH) visit ignores the cache", () => {
sessionStorage.setItem(
"k",
JSON.stringify({
items: [1],
page: 1,
hasMore: false,
scrollY: 0,
savedAt: Date.now(),
}),
);
function Probe() {
const { cached } = useFeedCache("k", (x) => x);
return <p>{cached ? "cached" : "fresh"}</p>;
}
function Home() {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate("/feed")}>
go
</button>
);
}
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/feed" element={<Probe />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("go"));
expect(screen.getByText("fresh")).toBeInTheDocument();
});
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
sessionStorage.setItem("k", "{");
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(result.current.cached).toBeNull();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
});
});

View File

@@ -0,0 +1,115 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { useInfiniteScroll } from "../../../hooks/feed/useInfiniteScroll.ts";
import { FakeIntersectionObserver } from "../../setup.ts";
afterEach(() => vi.useRealTimers());
describe("useInfiniteScroll", () => {
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
const ref = useInfiniteScroll(onMore, enabled);
return <div ref={ref} data-testid="sentinel" />;
}
const intersect = (isIntersecting: boolean) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.report(isIntersecting));
};
test("loads more when the sentinel shows, only while enabled", () => {
const onMore = vi.fn();
const { rerender } = render(<Feed onMore={onMore} enabled />);
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
intersect(false);
expect(onMore).not.toHaveBeenCalled();
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled={false} />);
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
});
// A load that fails re-enables the feed with the sentinel still in view: a
// browser's new observer reports it at once, and without a pause that's a
// request loop as fast as the request can fail.
test("a sentinel that stays in view waits longer each time", () => {
vi.useFakeTimers();
const onMore = vi.fn();
render(<Feed onMore={onMore} enabled />);
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
// Reported again right after each load, as a new observer does: how long
// until the next one?
const delays: number[] = [];
for (let i = 0; i < 8; i++) {
const before = onMore.mock.calls.length;
intersect(true);
let waited = 0;
while (onMore.mock.calls.length === before) {
act(() => vi.advanceTimersByTime(100));
waited += 100;
}
delays.push(waited);
}
expect(delays).toEqual([
1000,
2000,
4000,
8000,
16000,
30000,
30000,
30000,
]);
});
test("re-rendering doesn't restart the wait", () => {
vi.useFakeTimers();
const onMore = vi.fn();
const { rerender } = render(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
act(() => vi.advanceTimersByTime(600));
// A new onLoadMore (the feed's state changed) remakes the observer.
rerender(<Feed onMore={() => onMore()} enabled />);
intersect(true);
act(() => vi.advanceTimersByTime(400));
expect(onMore).toHaveBeenCalledTimes(2);
});
test("leaving the view, or new items pushing it down, asks at once again", () => {
vi.useFakeTimers();
const onMore = vi.fn();
render(<Feed onMore={onMore} enabled />);
const at = (top: number) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.report(true, top));
};
at(500);
at(500);
expect(onMore).toHaveBeenCalledTimes(1); // waiting
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
at(500);
expect(onMore).toHaveBeenCalledTimes(2);
at(500);
at(900); // the page grew: that load worked
expect(onMore).toHaveBeenCalledTimes(3);
});
test("a pending retry is dropped when the feed is disabled or unmounted", () => {
vi.useFakeTimers();
const onMore = vi.fn();
const { rerender, unmount } = render(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
rerender(<Feed onMore={onMore} enabled={false} />);
act(() => vi.advanceTimersByTime(60_000));
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled />);
intersect(true); // long overdue: at once
expect(onMore).toHaveBeenCalledTimes(2);
intersect(true);
unmount();
act(() => vi.advanceTimersByTime(60_000));
expect(onMore).toHaveBeenCalledTimes(2);
});
});

View File

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

View File

@@ -0,0 +1,52 @@
import { describe, expect, test, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useScrollSave } from "../../../hooks/feed/useScrollSave.ts";
describe("useScrollSave", () => {
const scrollTo = (y: number) => {
Object.defineProperty(globalThis, "scrollY", {
value: y,
configurable: true,
});
globalThis.dispatchEvent(new Event("scroll"));
};
test("saves the last position when the page is left, not while scrolling", () => {
const onSave = vi.fn();
const { unmount } = renderHook(() => useScrollSave(true, onSave));
scrollTo(100);
scrollTo(250);
expect(onSave).not.toHaveBeenCalled();
// The next page has already scrolled back up by the time this unmounts.
Object.defineProperty(globalThis, "scrollY", {
value: 0,
configurable: true,
});
unmount();
expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
});
test("saves on pagehide, with the latest callback", () => {
const first = vi.fn();
const second = vi.fn();
const { rerender, unmount } = renderHook(
({ cb }) => useScrollSave(true, cb),
{ initialProps: { cb: first } },
);
scrollTo(40);
rerender({ cb: second });
globalThis.dispatchEvent(new Event("pagehide"));
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledExactlyOnceWith(40);
unmount();
});
test("nothing is tracked or saved while disabled", () => {
const onSave = vi.fn();
const { unmount } = renderHook(() => useScrollSave(false, onSave));
scrollTo(10);
globalThis.dispatchEvent(new Event("pagehide"));
unmount();
expect(onSave).not.toHaveBeenCalled();
});
});

View File

@@ -1,615 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
act,
fireEvent,
render,
renderHook,
screen,
} from "@testing-library/react";
import { type ReactNode, useRef } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { useFading } from "../../hooks/useFading.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useTabParam } from "../../hooks/useTabParam.ts";
import {
setTitleBadge,
SITE_NAME,
useDocumentTitle,
} from "../../hooks/useDocumentTitle.ts";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
import { useScrollLock } from "../../hooks/useScrollLock.ts";
import { useScrollSave } from "../../hooks/useScrollSave.ts";
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts";
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
import { isDumpVisited } from "../../utils/visited.ts";
import { FakeIntersectionObserver } from "../setup.ts";
afterEach(() => vi.useRealTimers());
describe("useFading", () => {
beforeEach(() => vi.useFakeTimers());
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
expect(result.current.fading).toEqual({ a: "cooldown" });
act(() => vi.advanceTimersByTime(2000));
expect(result.current.fading).toEqual({ a: "dismissing" });
act(() => vi.advanceTimersByTime(350));
expect(result.current.fading).toEqual({});
});
test("starting twice is one animation", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
act(() => result.current.startFading("a"));
act(() => vi.advanceTimersByTime(1000));
expect(result.current.fading).toEqual({ a: "dismissing" });
});
test("cancel during cooldown and during dismissal", () => {
const { result } = renderHook(useFading);
act(() => result.current.startFading("a"));
act(() => result.current.cancelFading("a"));
expect(result.current.fading).toEqual({});
act(() => vi.advanceTimersByTime(5000));
expect(result.current.fading).toEqual({});
act(() => result.current.startFading("b"));
act(() => vi.advanceTimersByTime(2100));
act(() => result.current.cancelFading("b"));
expect(result.current.fading).toEqual({});
// Cancelled means it can start over.
act(() => result.current.startFading("b"));
expect(result.current.fading).toEqual({ b: "cooldown" });
});
test("cancelAll", () => {
const { result } = renderHook(useFading);
act(() => {
result.current.startFading("a");
result.current.startFading("b");
});
act(() => result.current.cancelAll());
expect(result.current.fading).toEqual({});
});
});
describe("useFeedCache", () => {
// A MemoryRouter's first entry is a POP, as after Back.
const wrapper = () => ({ children }: { children: ReactNode }) => (
<MemoryRouter>{children}</MemoryRouter>
);
test("restores a saved feed on a back/forward (POP) visit", () => {
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
const second = renderHook(
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
{ wrapper: wrapper() },
);
expect(second.result.current.cached).toMatchObject({
items: [{ n: 10 }],
page: 2,
hasMore: true,
scrollY: 300,
});
});
test("an expired entry is ignored", () => {
vi.useFakeTimers();
const first = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
act(() => first.result.current.saveState([], 1, false, 0));
vi.advanceTimersByTime(11 * 60 * 1000);
const second = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(second.result.current.cached).toBeNull();
});
test("a fresh (PUSH) visit ignores the cache", () => {
sessionStorage.setItem(
"k",
JSON.stringify({
items: [1],
page: 1,
hasMore: false,
scrollY: 0,
savedAt: Date.now(),
}),
);
function Probe() {
const { cached } = useFeedCache("k", (x) => x);
return <p>{cached ? "cached" : "fresh"}</p>;
}
function Home() {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate("/feed")}>
go
</button>
);
}
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/feed" element={<Probe />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("go"));
expect(screen.getByText("fresh")).toBeInTheDocument();
});
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
sessionStorage.setItem("k", "{");
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(),
});
expect(result.current.cached).toBeNull();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
});
});
describe("useTabParam", () => {
const TABS = ["dumps", "playlists", "votes"] as const;
function setup(path: string, state?: unknown) {
let loc!: ReturnType<typeof useLocation>;
const hook = renderHook(() => {
loc = useLocation();
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter
initialEntries={[{
pathname: path.split("?")[0],
search: path.includes("?") ? "?" + path.split("?")[1] : "",
state,
}]}
>
{children}
</MemoryRouter>
),
});
return { hook, loc: () => loc };
}
test("reads the tab after /~/", () => {
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
"playlists",
);
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
"playlists",
);
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
});
test("the default tab collapses to the base path", () => {
const { hook, loc } = setup("/users/ann/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/users/ann");
act(() => hook.result.current[1]("playlists"));
expect(loc().pathname).toBe("/users/ann/~/playlists");
});
test("at the root the default stays explicit", () => {
const { hook, loc } = setup("/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/~/dumps");
});
test("keeps the query string and the entry's state", () => {
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
act(() => hook.result.current[1]("votes"));
expect(loc().pathname).toBe("/search/~/votes");
expect(loc().search).toBe("?q=cats");
expect(loc().state).toEqual({ back: "/x" });
});
});
describe("useDocumentTitle", () => {
test("composes page, site name and unread badge", () => {
const { rerender } = renderHook(
({ title }: { title: string | null | undefined }) =>
useDocumentTitle(title),
{ initialProps: { title: "Cats" as string | null | undefined } },
);
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
act(() => setTitleBadge(3));
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
act(() => setTitleBadge(150));
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
rerender({ title: null });
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
act(() => setTitleBadge(0));
expect(document.title).toBe(SITE_NAME);
// undefined leaves it be
rerender({ title: undefined });
expect(document.title).toBe(SITE_NAME);
});
test("the site name defaults to gerbeur when unsubstituted", () => {
expect(SITE_NAME).toBe("gerbeur");
});
});
describe("useInfiniteScroll", () => {
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
const ref = useInfiniteScroll(onMore, enabled);
return <div ref={ref} data-testid="sentinel" />;
}
const intersect = (isIntersecting: boolean) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.report(isIntersecting));
};
test("loads more when the sentinel shows, only while enabled", () => {
const onMore = vi.fn();
const { rerender } = render(<Feed onMore={onMore} enabled />);
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
intersect(false);
expect(onMore).not.toHaveBeenCalled();
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled={false} />);
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
});
// A load that fails re-enables the feed with the sentinel still in view: a
// browser's new observer reports it at once, and without a pause that's a
// request loop as fast as the request can fail.
test("a sentinel that stays in view waits longer each time", () => {
vi.useFakeTimers();
const onMore = vi.fn();
render(<Feed onMore={onMore} enabled />);
intersect(true);
expect(onMore).toHaveBeenCalledTimes(1);
// Reported again right after each load, as a new observer does: how long
// until the next one?
const delays: number[] = [];
for (let i = 0; i < 8; i++) {
const before = onMore.mock.calls.length;
intersect(true);
let waited = 0;
while (onMore.mock.calls.length === before) {
act(() => vi.advanceTimersByTime(100));
waited += 100;
}
delays.push(waited);
}
expect(delays).toEqual([
1000,
2000,
4000,
8000,
16000,
30000,
30000,
30000,
]);
});
test("re-rendering doesn't restart the wait", () => {
vi.useFakeTimers();
const onMore = vi.fn();
const { rerender } = render(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
act(() => vi.advanceTimersByTime(600));
// A new onLoadMore (the feed's state changed) remakes the observer.
rerender(<Feed onMore={() => onMore()} enabled />);
intersect(true);
act(() => vi.advanceTimersByTime(400));
expect(onMore).toHaveBeenCalledTimes(2);
});
test("leaving the view, or new items pushing it down, asks at once again", () => {
vi.useFakeTimers();
const onMore = vi.fn();
render(<Feed onMore={onMore} enabled />);
const at = (top: number) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.report(true, top));
};
at(500);
at(500);
expect(onMore).toHaveBeenCalledTimes(1); // waiting
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
at(500);
expect(onMore).toHaveBeenCalledTimes(2);
at(500);
at(900); // the page grew: that load worked
expect(onMore).toHaveBeenCalledTimes(3);
});
test("a pending retry is dropped when the feed is disabled or unmounted", () => {
vi.useFakeTimers();
const onMore = vi.fn();
const { rerender, unmount } = render(<Feed onMore={onMore} enabled />);
intersect(true);
intersect(true);
rerender(<Feed onMore={onMore} enabled={false} />);
act(() => vi.advanceTimersByTime(60_000));
expect(onMore).toHaveBeenCalledTimes(1);
rerender(<Feed onMore={onMore} enabled />);
intersect(true); // long overdue: at once
expect(onMore).toHaveBeenCalledTimes(2);
intersect(true);
unmount();
act(() => vi.advanceTimersByTime(60_000));
expect(onMore).toHaveBeenCalledTimes(2);
});
});
describe("useScrollLock", () => {
function Lock() {
useScrollLock();
return null;
}
test("counts nested locks", () => {
const root = document.documentElement;
const a = render(<Lock />);
const b = render(<Lock />);
expect(root.style.overflowY).toBe("hidden");
a.unmount();
expect(root.style.overflowY).toBe("hidden");
b.unmount();
expect(root.style.overflowY).toBe("");
});
});
describe("useScrollSave", () => {
const scrollTo = (y: number) => {
Object.defineProperty(globalThis, "scrollY", {
value: y,
configurable: true,
});
globalThis.dispatchEvent(new Event("scroll"));
};
test("saves the last position when the page is left, not while scrolling", () => {
const onSave = vi.fn();
const { unmount } = renderHook(() => useScrollSave(true, onSave));
scrollTo(100);
scrollTo(250);
expect(onSave).not.toHaveBeenCalled();
// The next page has already scrolled back up by the time this unmounts.
Object.defineProperty(globalThis, "scrollY", {
value: 0,
configurable: true,
});
unmount();
expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
});
test("saves on pagehide, with the latest callback", () => {
const first = vi.fn();
const second = vi.fn();
const { rerender, unmount } = renderHook(
({ cb }) => useScrollSave(true, cb),
{ initialProps: { cb: first } },
);
scrollTo(40);
rerender({ cb: second });
globalThis.dispatchEvent(new Event("pagehide"));
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledExactlyOnceWith(40);
unmount();
});
test("nothing is tracked or saved while disabled", () => {
const onSave = vi.fn();
const { unmount } = renderHook(() => useScrollSave(false, onSave));
scrollTo(10);
globalThis.dispatchEvent(new Event("pagehide"));
unmount();
expect(onSave).not.toHaveBeenCalled();
});
});
describe("stored preferences", () => {
test("useDefaultFeedTab validates and persists", () => {
localStorage.setItem("default-feed-tab", "bogus");
const { result } = renderHook(useDefaultFeedTab);
expect(result.current[0]).toBe("journal");
act(() => result.current[1]("new"));
expect(result.current[0]).toBe("new");
expect(localStorage.getItem("default-feed-tab")).toBe("new");
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
});
test("useSoundsEnabled", () => {
const { result } = renderHook(useSoundsEnabled);
expect(result.current[0]).toBe(true);
act(() => result.current[1](false));
expect(result.current[0]).toBe(false);
expect(localStorage.getItem("sounds")).toBe("off");
});
});
describe("useDumpCardLink", () => {
function Card() {
const link = useDumpCardLink({ id: "d1", slug: "hello" });
return (
<div data-testid="card" {...link.cardProps}>
<a href="/elsewhere">inner link</a>
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
}}
>
play
</button>
</div>
);
}
function setup() {
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
render(
<MemoryRouter>
<Routes>
<Route path="/" element={<Card />} />
<Route path="/dumps/:id" element={<p>dump page</p>} />
</Routes>
</MemoryRouter>,
);
return { open };
}
test("a click navigates and marks visited", () => {
setup();
fireEvent.click(screen.getByTestId("card"));
expect(screen.getByText("dump page")).toBeInTheDocument();
expect(isDumpVisited("d1")).toBe(true);
});
test("ctrl/cmd/shift-click opens a new tab", () => {
const { open } = setup();
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
});
test("a middle click opens a new tab, except over a link", () => {
const { open } = setup();
const mouseDown = new MouseEvent("mousedown", {
button: 1,
bubbles: true,
cancelable: true,
});
screen.getByTestId("card").dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
fireEvent(
screen.getByTestId("card"),
new MouseEvent("auxclick", { button: 1, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
fireEvent(
screen.getByText("inner link"),
new MouseEvent("auxclick", { button: 1, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
fireEvent(
screen.getByTestId("card"),
new MouseEvent("auxclick", { button: 2, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
});
test("a modified click on the play button opens the dump instead of playing", () => {
const { open } = setup();
const play = screen.getByText("play");
fireEvent.click(play, { metaKey: true });
expect(open).toHaveBeenCalledTimes(1);
expect(play.dataset.played).toBeUndefined();
fireEvent.click(play);
expect(play.dataset.played).toBe("1");
});
});
describe("useAnchoredFloat", () => {
function Float({ open, top }: { open: boolean; top: number }) {
const ref = useRef<HTMLDivElement>(null);
const style = useAnchoredFloat(ref, open, {
width: 300,
chromeHeight: 40,
minContentHeight: 100,
maxContentHeight: 400,
});
return (
<>
<div
ref={(el) => {
ref.current = el;
if (el) {
el.getBoundingClientRect = () =>
({
top,
bottom: top + 20,
left: 900,
right: 1000,
width: 100,
height: 20,
}) as DOMRect;
}
}}
/>
<output data-testid="style">{JSON.stringify(style)}</output>
</>
);
}
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
beforeEach(() => {
vi.stubGlobal("innerWidth", 1024);
vi.stubGlobal("innerHeight", 768);
});
test("null while closed", () => {
render(<Float open={false} top={100} />);
expect(styleOf()).toBeNull();
});
test("opens below an anchor near the top, clamped into the viewport", () => {
render(<Float open top={100} />);
const s = styleOf();
expect(s.position).toBe("fixed");
expect(s.top).toBe(124);
expect(s.bottom).toBe("auto");
expect(s.left).toBe(1024 - 300 - 8);
expect(s["--float-max-h"]).toBe("400px");
});
test("flips above an anchor near the bottom", () => {
render(<Float open top={700} />);
const s = styleOf();
expect(s.bottom).toBe(768 - 700 + 4);
expect(s.top).toBe("auto");
});
});
describe("useHeightTransition", () => {
test("animates from the old height to the new one when watch changes", () => {
const animate = vi.spyOn(Element.prototype, "animate");
let height = 100;
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
() => ({ height }) as DOMRect,
);
function Box({ open }: { open: boolean }) {
const ref = useHeightTransition<HTMLDivElement>(open);
return <div ref={ref} />;
}
const { rerender } = render(<Box open={false} />);
height = 250;
rerender(<Box open />);
expect(animate).toHaveBeenCalledTimes(1);
expect(animate.mock.calls[0][0]).toEqual([
expect.objectContaining({ height: "100px" }),
expect.objectContaining({ height: "250px" }),
]);
// No change in height, no animation.
rerender(<Box open={false} />);
expect(animate).toHaveBeenCalledTimes(1);
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import {
setTitleBadge,
SITE_NAME,
useDocumentTitle,
} from "../../../hooks/shared/useDocumentTitle.ts";
describe("useDocumentTitle", () => {
test("composes page, site name and unread badge", () => {
const { rerender } = renderHook(
({ title }: { title: string | null | undefined }) =>
useDocumentTitle(title),
{ initialProps: { title: "Cats" as string | null | undefined } },
);
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
act(() => setTitleBadge(3));
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
act(() => setTitleBadge(150));
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
rerender({ title: null });
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
act(() => setTitleBadge(0));
expect(document.title).toBe(SITE_NAME);
// undefined leaves it be
rerender({ title: undefined });
expect(document.title).toBe(SITE_NAME);
});
test("the site name defaults to gerbeur when unsubstituted", () => {
expect(SITE_NAME).toBe("gerbeur");
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, test, vi } from "vitest";
import { render } from "@testing-library/react";
import { useHeightTransition } from "../../../hooks/shared/useHeightTransition.ts";
describe("useHeightTransition", () => {
test("animates from the old height to the new one when watch changes", () => {
const animate = vi.spyOn(Element.prototype, "animate");
let height = 100;
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
() => ({ height }) as DOMRect,
);
function Box({ open }: { open: boolean }) {
const ref = useHeightTransition<HTMLDivElement>(open);
return <div ref={ref} />;
}
const { rerender } = render(<Box open={false} />);
height = 250;
rerender(<Box open />);
expect(animate).toHaveBeenCalledTimes(1);
expect(animate.mock.calls[0][0]).toEqual([
expect.objectContaining({ height: "100px" }),
expect.objectContaining({ height: "250px" }),
]);
// No change in height, no animation.
rerender(<Box open={false} />);
expect(animate).toHaveBeenCalledTimes(1);
});
});

View File

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

View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useSoundsEnabled } from "../../../hooks/shared/useSoundsEnabled.ts";
describe("useSoundsEnabled", () => {
test("defaults on and persists", () => {
const { result } = renderHook(useSoundsEnabled);
expect(result.current[0]).toBe(true);
act(() => result.current[1](false));
expect(result.current[0]).toBe(false);
expect(localStorage.getItem("sounds")).toBe("off");
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router";
import { useTabParam } from "../../../hooks/shared/useTabParam.ts";
describe("useTabParam", () => {
const TABS = ["dumps", "playlists", "votes"] as const;
function setup(path: string, state?: unknown) {
let loc!: ReturnType<typeof useLocation>;
const hook = renderHook(() => {
loc = useLocation();
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter
initialEntries={[{
pathname: path.split("?")[0],
search: path.includes("?") ? "?" + path.split("?")[1] : "",
state,
}]}
>
{children}
</MemoryRouter>
),
});
return { hook, loc: () => loc };
}
test("reads the tab after /~/", () => {
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
"playlists",
);
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
"playlists",
);
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
});
test("the default tab collapses to the base path", () => {
const { hook, loc } = setup("/users/ann/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/users/ann");
act(() => hook.result.current[1]("playlists"));
expect(loc().pathname).toBe("/users/ann/~/playlists");
});
test("at the root the default stays explicit", () => {
const { hook, loc } = setup("/~/votes");
act(() => hook.result.current[1]("dumps"));
expect(loc().pathname).toBe("/~/dumps");
});
test("keeps the query string and the entry's state", () => {
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
act(() => hook.result.current[1]("votes"));
expect(loc().pathname).toBe("/search/~/votes");
expect(loc().search).toBe("?q=cats");
expect(loc().state).toEqual({ back: "/x" });
});
});

View File

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

View File

@@ -8,7 +8,7 @@ import {
deserializePlaylistMembership,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../../model.ts";
} from "../model.ts";
describe("parseAPIResponse", () => {
test("accepts either envelope", () => {

View File

@@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { NotFound } from "../../pages/NotFound.tsx";
import { renderWithContexts } from "../render.tsx";
describe("NotFound", () => {
test("NotFound", async () => {
renderWithContexts(<NotFound />, { route: "/nope" });
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^Page not found/));
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
"href",
"/",
);
});
});

View File

@@ -0,0 +1,75 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { ResetPassword } from "../../pages/ResetPassword.tsx";
import { calls, fail, route } from "../fetch.ts";
import { renderWithContexts } from "../render.tsx";
describe("ResetPassword", () => {
function setup(search = "?token=rst") {
const user = userEvent.setup();
renderWithContexts(<ResetPassword />, {
route: `/reset-password${search}`,
path: "/reset-password",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
return user;
}
test("no token: an invalid link", () => {
setup("");
expect(screen.getByRole("heading", { name: "Invalid link" }))
.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute(
"href",
"/login",
);
});
test("the two passwords must match", async () => {
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"different",
);
expect(await screen.findByText("Passwords do not match"))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set new password" }));
expect(calls("POST /api/users/reset-password")).toHaveLength(0);
});
test("sets the password, then offers login", async () => {
route("POST /api/users/reset-password", () => ({}));
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}",
);
await screen.findByRole("heading", { name: "Password updated" });
expect(calls("POST /api/users/reset-password")[0].body).toEqual({
token: "rst",
newPassword: "longenough",
});
await user.click(screen.getByRole("button", { name: "Go to login" }));
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("an expired token is reported", async () => {
route(
"POST /api/users/reset-password",
() => fail("Reset link expired", 400),
);
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Reset link expired",
);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { Route } from "react-router";
import { RestrictedGuest } from "../../pages/RestrictedGuest.tsx";
import { makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
const home = <Route path="/" element={<p>home page</p>} />;
describe("RestrictedGuest", () => {
test("sends members home", () => {
renderWithContexts(
<RestrictedGuest>
<p>form</p>
</RestrictedGuest>,
{
session: makeSession(),
route: "/login",
path: "/login",
extraRoutes: home,
},
);
expect(screen.getByText("home page")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { Route } from "react-router";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
describe("RestrictedLoggedIn", () => {
test("sends guests to login", () => {
renderWithContexts(
<RestrictedLoggedIn>
<p>secret</p>
</RestrictedLoggedIn>,
{
route: "/notifications",
path: "/notifications",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
},
);
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("lets members through", () => {
renderWithContexts(
<RestrictedLoggedIn>
<p>secret</p>
</RestrictedLoggedIn>,
{
session: makeSession(),
},
);
expect(screen.getByText("secret")).toBeInTheDocument();
});
});

View File

@@ -1,8 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserDumps } from "../../pages/UserDumps.tsx";
import { UserUpvoted } from "../../pages/UserUpvoted.tsx";
import { fail, route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
@@ -89,81 +88,3 @@ describe("UserDumps", () => {
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
});
});
describe("UserUpvoted", () => {
const voted = () => [
makeDump({ id: "v1", title: "Liked one" }),
makeDump({ id: "v2", title: "Liked two" }),
];
function openVotes(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/votes", () => page(voted()));
const user = userEvent.setup();
renderApp(<UserUpvoted />, {
route: "/users/ann/upvoted",
path: "/users/:username/upvoted",
session,
});
return { user, socket: FakeWebSocket.last };
}
test("lists what they upvoted", async () => {
openVotes();
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Upvoted" }))
.toBeInTheDocument();
});
test("your own list shows even before the live connection says what you voted", async () => {
openVotes(annSession);
// The socket is never opened: no welcome, no myVotes.
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByText("Liked two")).toBeInTheDocument();
});
test("unvoting on your own list fades the dump out", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { socket } = openVotes(annSession);
await screen.findByText("Liked one");
socket.welcome({ myVotes: ["v1", "v2"] });
socket.receive({
type: "votes_update",
dumpId: "v1",
voteCount: 0,
voterId: "ann",
action: "remove",
});
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass(
"dump-card--fading",
);
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test("their new upvote appears at the top", async () => {
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "v3", title: "Fresh like" })),
);
const { socket } = openVotes();
await screen.findByText("Liked one");
socket.welcome();
socket.receive({
type: "votes_update",
dumpId: "v3",
voteCount: 1,
voterId: "ann",
action: "cast",
});
await waitFor(() =>
expect(document.querySelector(".dump-card-title")).toHaveTextContent(
"Fresh like",
)
);
});
});

View File

@@ -0,0 +1,123 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { UserLogin } from "../../pages/UserLogin.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
const home = <Route path="/" element={<p>home page</p>} />;
describe("UserLogin", () => {
function setup() {
const user = userEvent.setup();
renderWithContexts(<UserLogin />, {
route: "/login",
path: "/login",
extraRoutes: home,
});
return user;
}
test("logs in, stores the session and goes home", async () => {
const session = makeSession({ username: "ann" });
route("POST /api/users/login", () => wire(session));
const user = setup();
expect(document.title).toMatch(/^Log in · /);
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
await screen.findByText("home page");
expect(calls("POST /api/users/login")[0].body).toEqual({
username: "ann",
password: "hunter22",
});
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
.toBe("ann");
});
test("both fields are required", async () => {
const user = setup();
// The header has a "Log in" button too; this is the form's.
await user.click(
document.querySelector<HTMLButtonElement>(
".auth-card button[type=submit]",
)!,
);
expect(calls("POST /api/users/login")).toHaveLength(0);
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("shows the server's refusal", async () => {
route("POST /api/users/login", () => fail("Invalid credentials", 401));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Login failed");
expect(alert).toHaveTextContent("Invalid credentials");
expect(currentPath()).toBe("/login");
});
test("says when the server can't be reached", async () => {
route("POST /api/users/login", () => new TypeError("Failed to fetch"));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent(
/Could not connect/,
);
});
test("the button says so while logging in", async () => {
const pending = deferred();
route("POST /api/users/login", () => pending.promise);
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("button", { name: "Logging in…" }))
.toBeDisabled();
pending.resolve(
new Response(
JSON.stringify({ success: true, data: wire(makeSession()) }),
),
);
await screen.findByText("home page");
});
test("password reset request", async () => {
route("POST /api/users/request-password-reset", () => ({}));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(
screen.getByPlaceholderText("Your email address"),
" a@b.c {Enter}",
);
await screen.findByText(/you'll receive a reset link/);
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({
email: "a@b.c",
});
});
test("a failed reset request is reported, not claimed as sent", async () => {
route(
"POST /api/users/request-password-reset",
() => fail("Internal error", 500),
);
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(
screen.getByPlaceholderText("Your email address"),
"a@b.c{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Request failed",
);
expect(screen.queryByText(/you'll receive a reset link/)).not
.toBeInTheDocument();
});
});

View File

@@ -15,7 +15,7 @@ import {
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
const ann = makeUser({
id: "ann",

View File

@@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { UserRegister } from "../../pages/UserRegister.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
const home = <Route path="/" element={<p>home page</p>} />;
describe("UserRegister", () => {
function setup(search = "?token=inv-1") {
const user = userEvent.setup();
renderWithContexts(<UserRegister />, {
route: `/register${search}`,
path: "/register",
extraRoutes: home,
});
return user;
}
test("no token: an invalid invite, no request", () => {
setup("");
expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite");
expect(calls("GET /api/invites/:t")).toHaveLength(0);
});
test("an unknown token is invalid", async () => {
route("GET /api/invites/:t", () => fail("nope", 404));
setup();
expect(screen.getByText("Checking invite…")).toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("already used");
});
test("registers with a valid invite", async () => {
route("GET /api/invites/:t", () => ({}));
const session = makeSession({ username: "new_one" });
route("POST /api/users/register", () => wire(session));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "new_one");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"longenough{Enter}",
);
await screen.findByText("home page");
expect(calls("POST /api/users/register")[0].body).toEqual({
username: "new_one",
password: "longenough",
inviteToken: "inv-1",
email: "n@o.pe",
});
expect(calls("GET /api/invites/:t")[0].params.t).toBe("inv-1");
});
test("validates the username and password length", async () => {
route("GET /api/invites/:t", () => ({}));
const user = setup();
await user.type(
await screen.findByPlaceholderText("Username"),
"bad name!",
);
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"short{Enter}",
);
expect(await screen.findByText(/letters, numbers, or underscores/))
.toBeInTheDocument();
expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
expect(calls("POST /api/users/register")).toHaveLength(0);
});
test("shows a server refusal (username taken)", async () => {
route("GET /api/invites/:t", () => ({}));
route(
"POST /api/users/register",
() => fail("Username already taken", 409),
);
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "taken");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"longenough{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Username already taken",
);
});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserUpvoted } from "../../pages/UserUpvoted.tsx";
import { route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const ann = makeUser({ id: "ann", username: "ann" });
const annSession = makeSession({ id: "ann", username: "ann" });
describe("UserUpvoted", () => {
const voted = () => [
makeDump({ id: "v1", title: "Liked one" }),
makeDump({ id: "v2", title: "Liked two" }),
];
function openVotes(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/votes", () => page(voted()));
const user = userEvent.setup();
renderApp(<UserUpvoted />, {
route: "/users/ann/upvoted",
path: "/users/:username/upvoted",
session,
});
return { user, socket: FakeWebSocket.last };
}
test("lists what they upvoted", async () => {
openVotes();
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Upvoted" }))
.toBeInTheDocument();
});
test("your own list shows even before the live connection says what you voted", async () => {
openVotes(annSession);
// The socket is never opened: no welcome, no myVotes.
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByText("Liked two")).toBeInTheDocument();
});
test("unvoting on your own list fades the dump out", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { socket } = openVotes(annSession);
await screen.findByText("Liked one");
socket.welcome({ myVotes: ["v1", "v2"] });
socket.receive({
type: "votes_update",
dumpId: "v1",
voteCount: 0,
voterId: "ann",
action: "remove",
});
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass(
"dump-card--fading",
);
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test("their new upvote appears at the top", async () => {
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "v3", title: "Fresh like" })),
);
const { socket } = openVotes();
await screen.findByText("Liked one");
socket.welcome();
socket.receive({
type: "votes_update",
dumpId: "v3",
voteCount: 1,
voterId: "ann",
action: "cast",
});
await waitFor(() =>
expect(document.querySelector(".dump-card-title")).toHaveTextContent(
"Fresh like",
)
);
});
});

View File

@@ -1,331 +0,0 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { UserLogin } from "../../pages/UserLogin.tsx";
import { UserRegister } from "../../pages/UserRegister.tsx";
import { ResetPassword } from "../../pages/ResetPassword.tsx";
import { NotFound } from "../../pages/NotFound.tsx";
import { RestrictedGuest } from "../../pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
const home = <Route path="/" element={<p>home page</p>} />;
describe("UserLogin", () => {
function setup() {
const user = userEvent.setup();
renderWithContexts(<UserLogin />, {
route: "/login",
path: "/login",
extraRoutes: home,
});
return user;
}
test("logs in, stores the session and goes home", async () => {
const session = makeSession({ username: "ann" });
route("POST /api/users/login", () => wire(session));
const user = setup();
expect(document.title).toMatch(/^Log in · /);
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
await screen.findByText("home page");
expect(calls("POST /api/users/login")[0].body).toEqual({
username: "ann",
password: "hunter22",
});
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
.toBe("ann");
});
test("both fields are required", async () => {
const user = setup();
// The header has a "Log in" button too; this is the form's.
await user.click(
document.querySelector<HTMLButtonElement>(
".auth-card button[type=submit]",
)!,
);
expect(calls("POST /api/users/login")).toHaveLength(0);
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("shows the server's refusal", async () => {
route("POST /api/users/login", () => fail("Invalid credentials", 401));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Login failed");
expect(alert).toHaveTextContent("Invalid credentials");
expect(currentPath()).toBe("/login");
});
test("says when the server can't be reached", async () => {
route("POST /api/users/login", () => new TypeError("Failed to fetch"));
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent(
/Could not connect/,
);
});
test("the button says so while logging in", async () => {
const pending = deferred();
route("POST /api/users/login", () => pending.promise);
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("button", { name: "Logging in…" }))
.toBeDisabled();
pending.resolve(
new Response(
JSON.stringify({ success: true, data: wire(makeSession()) }),
),
);
await screen.findByText("home page");
});
test("password reset request", async () => {
route("POST /api/users/request-password-reset", () => ({}));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(
screen.getByPlaceholderText("Your email address"),
" a@b.c {Enter}",
);
await screen.findByText(/you'll receive a reset link/);
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({
email: "a@b.c",
});
});
test("a failed reset request is reported, not claimed as sent", async () => {
route(
"POST /api/users/request-password-reset",
() => fail("Internal error", 500),
);
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(
screen.getByPlaceholderText("Your email address"),
"a@b.c{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Request failed",
);
expect(screen.queryByText(/you'll receive a reset link/)).not
.toBeInTheDocument();
});
});
describe("UserRegister", () => {
function setup(search = "?token=inv-1") {
const user = userEvent.setup();
renderWithContexts(<UserRegister />, {
route: `/register${search}`,
path: "/register",
extraRoutes: home,
});
return user;
}
test("no token: an invalid invite, no request", () => {
setup("");
expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite");
expect(calls("GET /api/invites/:t")).toHaveLength(0);
});
test("an unknown token is invalid", async () => {
route("GET /api/invites/:t", () => fail("nope", 404));
setup();
expect(screen.getByText("Checking invite…")).toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("already used");
});
test("registers with a valid invite", async () => {
route("GET /api/invites/:t", () => ({}));
const session = makeSession({ username: "new_one" });
route("POST /api/users/register", () => wire(session));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "new_one");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"longenough{Enter}",
);
await screen.findByText("home page");
expect(calls("POST /api/users/register")[0].body).toEqual({
username: "new_one",
password: "longenough",
inviteToken: "inv-1",
email: "n@o.pe",
});
expect(calls("GET /api/invites/:t")[0].params.t).toBe("inv-1");
});
test("validates the username and password length", async () => {
route("GET /api/invites/:t", () => ({}));
const user = setup();
await user.type(
await screen.findByPlaceholderText("Username"),
"bad name!",
);
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"short{Enter}",
);
expect(await screen.findByText(/letters, numbers, or underscores/))
.toBeInTheDocument();
expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
expect(calls("POST /api/users/register")).toHaveLength(0);
});
test("shows a server refusal (username taken)", async () => {
route("GET /api/invites/:t", () => ({}));
route(
"POST /api/users/register",
() => fail("Username already taken", 409),
);
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "taken");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"longenough{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Username already taken",
);
});
});
describe("ResetPassword", () => {
function setup(search = "?token=rst") {
const user = userEvent.setup();
renderWithContexts(<ResetPassword />, {
route: `/reset-password${search}`,
path: "/reset-password",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
return user;
}
test("no token: an invalid link", () => {
setup("");
expect(screen.getByRole("heading", { name: "Invalid link" }))
.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute(
"href",
"/login",
);
});
test("the two passwords must match", async () => {
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"different",
);
expect(await screen.findByText("Passwords do not match"))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set new password" }));
expect(calls("POST /api/users/reset-password")).toHaveLength(0);
});
test("sets the password, then offers login", async () => {
route("POST /api/users/reset-password", () => ({}));
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}",
);
await screen.findByRole("heading", { name: "Password updated" });
expect(calls("POST /api/users/reset-password")[0].body).toEqual({
token: "rst",
newPassword: "longenough",
});
await user.click(screen.getByRole("button", { name: "Go to login" }));
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("an expired token is reported", async () => {
route(
"POST /api/users/reset-password",
() => fail("Reset link expired", 400),
);
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Reset link expired",
);
});
});
describe("route guards and 404", () => {
test("guest-only pages send members home", () => {
renderWithContexts(
<RestrictedGuest>
<p>form</p>
</RestrictedGuest>,
{
session: makeSession(),
route: "/login",
path: "/login",
extraRoutes: home,
},
);
expect(screen.getByText("home page")).toBeInTheDocument();
});
test("member-only pages send guests to login", () => {
renderWithContexts(
<RestrictedLoggedIn>
<p>secret</p>
</RestrictedLoggedIn>,
{
route: "/notifications",
path: "/notifications",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
},
);
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("each guard lets the right people through", () => {
renderWithContexts(
<RestrictedLoggedIn>
<p>secret</p>
</RestrictedLoggedIn>,
{
session: makeSession(),
},
);
expect(screen.getByText("secret")).toBeInTheDocument();
});
test("NotFound", async () => {
renderWithContexts(<NotFound />, { route: "/nope" });
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^Page not found/));
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
"href",
"/",
);
});
});

View File

@@ -5,8 +5,8 @@ import { i18n } from "@lingui/core";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import type { AuthResponse, Category } from "../model.ts";
import { AuthContext } from "../contexts/AuthContext.ts";
import { AuthProvider } from "../contexts/AuthProvider.tsx";
import { AuthContext } from "../contexts/auth/AuthContext.ts";
import { AuthProvider } from "../contexts/auth/AuthProvider.tsx";
import {
defaultWSChatValue,
defaultWSPresenceValue,
@@ -17,19 +17,19 @@ import {
type WSContextValue,
WSPresenceContext,
type WSPresenceContextValue,
} from "../contexts/WSContext.ts";
import { WSProvider } from "../contexts/WSProvider.tsx";
} from "../contexts/ws/WSContext.ts";
import { WSProvider } from "../contexts/ws/WSProvider.tsx";
import {
defaultFollowValue,
FollowContext,
type FollowContextValue,
} from "../contexts/FollowContext.ts";
import { FollowProvider } from "../contexts/FollowProvider.tsx";
} from "../contexts/social/FollowContext.ts";
import { FollowProvider } from "../contexts/social/FollowProvider.tsx";
import {
CategoriesContext,
type CategoriesContextValue,
} from "../contexts/CategoriesContext.ts";
import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
} from "../contexts/category/CategoriesContext.ts";
import { CategoriesProvider } from "../contexts/category/CategoriesProvider.tsx";
import {
defaultPlayerTimeValue,
defaultPlayerValue,
@@ -37,10 +37,10 @@ import {
type PlayerContextValue,
PlayerTimeContext,
type PlayerTimeValue,
} from "../contexts/PlayerContext.ts";
import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
import { ChatProvider } from "../contexts/ChatProvider.tsx";
} from "../contexts/player/PlayerContext.ts";
import { PlayerProvider } from "../contexts/player/PlayerProvider.tsx";
import { ThemeProvider } from "../contexts/theme/ThemeProvider.tsx";
import { ChatProvider } from "../contexts/chat/ChatProvider.tsx";
import { route } from "./fetch.ts";
import { storeSession } from "./fixtures.ts";

View File

@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { can } from "../../../utils/auth/permissions.ts";
describe("can", () => {
test("admins can do everything, moderators moderate, users nothing", () => {
expect(can({ role: "admin" }, "category:manage")).toBe(true);
expect(can({ role: "admin" }, "user:manage")).toBe(true);
expect(can({ role: "moderator" }, "dump:moderate")).toBe(true);
expect(can({ role: "moderator" }, "chat:moderate")).toBe(true);
expect(can({ role: "moderator" }, "category:manage")).toBe(false);
expect(can({ role: "user" }, "dump:moderate")).toBe(false);
});
test("no user, no permission", () => {
expect(can(null, "dump:moderate")).toBe(false);
expect(can(undefined, "dump:moderate")).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import {
AUTH_STORAGE_KEY,
forgetStoredSession,
isTokenExpired,
readStoredSession,
storeSession,
} from "../../../utils/auth/storage.ts";
import { makeSession, makeToken, wire } from "../../fixtures.ts";
describe("isTokenExpired", () => {
test("reads exp from the payload", () => {
expect(isTokenExpired(makeToken({ expiresIn: 60 }))).toBe(false);
expect(isTokenExpired(makeToken({ expiresIn: -60 }))).toBe(true);
});
test("a token it can't read is treated as expired", () => {
expect(isTokenExpired("garbage")).toBe(true);
expect(isTokenExpired("a.b.c")).toBe(true);
});
test("no exp means it doesn't expire", () => {
const token = `x.${btoa(JSON.stringify({ sub: "u" }))}.y`;
expect(isTokenExpired(token)).toBe(false);
});
});
describe("stored session", () => {
test("round-trips, with dates revived", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
const read = readStoredSession();
expect(read?.user.username).toBe("ann");
expect(read?.user.createdAt).toBeInstanceOf(Date);
});
test("nothing stored", () => {
expect(readStoredSession()).toBeNull();
});
test("unparseable, expired and roleless sessions are discarded", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{not json");
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const expired = makeSession();
expired.token = makeToken({ expiresIn: -1 });
storeSession(expired);
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const stored = wire(makeSession());
const { role: _role, ...roleless } = stored.user;
localStorage.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({ ...stored, user: roleless }),
);
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession only forgets the session holding that token", () => {
const session = makeSession();
storeSession(session);
forgetStoredSession("some-older-token");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).not.toBeNull();
forgetStoredSession(session.token);
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession clears an unreadable entry", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{bad");
forgetStoredSession("anything");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import { markdownToPlainText, mentionsUser } from "../../../utils/chat/text.ts";
describe("mentionsUser", () => {
test("case-insensitive whole-word match", () => {
expect(mentionsUser("hey @Alice!", "alice")).toBe(true);
expect(mentionsUser("hey @alicex", "alice")).toBe(false);
});
test("follows the server's rule: not in an email, a link or brackets", () => {
expect(mentionsUser("mail bob@alice", "alice")).toBe(false);
expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
expect(mentionsUser("(@alice)", "alice")).toBe(false);
});
test("any of several mentions", () => {
expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
});
});
describe("markdownToPlainText", () => {
test("links become their text, images their alt or a label", () => {
expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe(
"see this now",
);
expect(markdownToPlainText("![cat](a.png)", "Image")).toBe("cat");
expect(markdownToPlainText("![](a.png)", "Image")).toBe("Image");
});
test("markup characters and block markers go", () => {
expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe(
"Title quote bold code",
);
});
test("a body cut off mid-link doesn't show a half URL", () => {
expect(markdownToPlainText("look [here](https://exa", "img")).toBe(
"look here",
);
expect(markdownToPlainText("pic ![alt](https://exa", "Image")).toBe(
"pic alt",
);
expect(markdownToPlainText("pic ![](https://exa", "Image")).toBe(
"pic Image",
);
expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic");
});
test("whitespace collapses to one line", () => {
expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c");
});
});

View File

@@ -0,0 +1,78 @@
import { describe, expect, test, vi } from "vitest";
import {
clearDumpDraft,
EMPTY_DUMP_DRAFT,
isDumpDraftEmpty,
loadDumpDraft,
saveDumpDraft,
} from "../../../utils/dump/draft.ts";
describe("dump draft", () => {
const draft = {
...EMPTY_DUMP_DRAFT,
url: "https://a.b",
comment: "why",
};
test("an empty draft is empty; any content isn't", () => {
expect(isDumpDraftEmpty(EMPTY_DUMP_DRAFT)).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, url: " " })).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, isPublic: false })).toBe(
false,
);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, categoryIds: ["c"] })).toBe(
false,
);
});
test("a title from the preview isn't worth keeping; a typed one is", () => {
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, title: "Scraped" })).toBe(
true,
);
expect(
isDumpDraftEmpty({
...EMPTY_DUMP_DRAFT,
title: "Mine",
titleFromUser: true,
}),
).toBe(false);
});
test("save, load and clear", () => {
saveDumpDraft(draft);
expect(loadDumpDraft()).toEqual(draft);
clearDumpDraft();
expect(loadDumpDraft()).toBeNull();
});
test("saving an empty draft removes the stored one", () => {
saveDumpDraft(draft);
saveDumpDraft(EMPTY_DUMP_DRAFT);
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
});
test("a malformed stored draft is sanitised or ignored", () => {
localStorage.setItem(
"gerbeur.dumpDraft",
JSON.stringify({ url: 5, comment: "c", categoryIds: ["a", 3, null] }),
);
expect(loadDumpDraft()).toEqual({
...EMPTY_DUMP_DRAFT,
comment: "c",
categoryIds: ["a"],
});
localStorage.setItem("gerbeur.dumpDraft", "nope");
expect(loadDumpDraft()).toBeNull();
});
test("storage that throws never breaks posting", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceeded");
});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
expect(() => saveDumpDraft(draft)).not.toThrow();
expect(loadDumpDraft()).toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { insideCode, isEmojiOnly } from "../../../utils/editor/text.ts";
describe("insideCode", () => {
test("plain text is not code", () => {
expect(insideCode("hello @bob")).toBe(false);
});
test("an unclosed inline span is code", () => {
expect(insideCode("try `@bob")).toBe(true);
expect(insideCode("try `a` then `@bob")).toBe(true);
});
test("a closed inline span is not", () => {
expect(insideCode("try `a` @bob")).toBe(false);
});
test("an open fence is code, a closed one is not", () => {
expect(insideCode("```\n@bob")).toBe(true);
expect(insideCode("```js\nx\n```\n@bob")).toBe(false);
});
test("the fence line itself is not an inline span", () => {
expect(insideCode("text\n```\ncode\n```")).toBe(false);
});
test("backticks on an earlier line don't carry over", () => {
expect(insideCode("a ` b\n@bob")).toBe(false);
});
});
describe("isEmojiOnly", () => {
test("one to three emoji", () => {
expect(isEmojiOnly("🎉")).toBe(true);
expect(isEmojiOnly("👍👍")).toBe(true);
expect(isEmojiOnly(" 🎉 🎉 🎉 ")).toBe(true);
});
test("more than the max", () => {
expect(isEmojiOnly("🎉🎉🎉🎉")).toBe(false);
expect(isEmojiOnly("🎉🎉🎉🎉", 4)).toBe(true);
});
test("a ZWJ sequence, a skin tone and a flag count as one each", () => {
expect(isEmojiOnly("👨‍👩‍👧‍👦")).toBe(true);
expect(isEmojiOnly("👍🏽")).toBe(true);
expect(isEmojiOnly("🇫🇷🇫🇷🇫🇷")).toBe(true);
});
test("text, digits and symbols are not emoji", () => {
expect(isEmojiOnly("")).toBe(false);
expect(isEmojiOnly("hi 🎉")).toBe(false);
expect(isEmojiOnly("123")).toBe(false);
expect(isEmojiOnly("#")).toBe(false);
expect(isEmojiOnly("1🎉")).toBe(false);
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { hotScore } from "../../../utils/feed/hotScore.ts";
import { makeDump } from "../../fixtures.ts";
const HOUR = 3_600_000;
describe("hotScore", () => {
test("more votes is hotter, older is colder", () => {
const now = makeDump({ createdAt: new Date(), voteCount: 0 });
const voted = { ...now, voteCount: 5 };
const old = { ...now, createdAt: new Date(Date.now() - 48 * HOUR) };
expect(hotScore(voted)).toBeGreaterThan(hotScore(now));
expect(hotScore(old)).toBeLessThan(hotScore(now));
});
});

View File

@@ -4,10 +4,9 @@ import {
hasQuote,
hasThumbnail,
type JournalShape,
} from "../../utils/journalLayout.ts";
import { hotScore } from "../../utils/hotScore.ts";
import type { Dump } from "../../model.ts";
import { makeDump } from "../fixtures.ts";
} from "../../../utils/feed/journalLayout.ts";
import type { Dump } from "../../../model.ts";
import { makeDump } from "../../fixtures.ts";
const HOUR = 3_600_000;
const SPAN: Record<JournalShape, [number, number]> = {
@@ -51,16 +50,6 @@ function feed(n: number, withArt: (i: number) => boolean): Dump[] {
}));
}
describe("hotScore", () => {
test("more votes is hotter, older is colder", () => {
const now = makeDump({ createdAt: new Date(), voteCount: 0 });
const voted = { ...now, voteCount: 5 };
const old = { ...now, createdAt: new Date(Date.now() - 48 * HOUR) };
expect(hotScore(voted)).toBeGreaterThan(hotScore(now));
expect(hotScore(old)).toBeLessThan(hotScore(now));
});
});
describe("hasThumbnail / hasQuote", () => {
test("image and video files have art; other files don't", () => {
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "image/png" })))

View File

@@ -1,178 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { can } from "../../utils/permissions.ts";
import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
import { relativeTime } from "../../utils/relativeTime.ts";
import {
hueFromString,
initialsFor,
isSafeHex,
tintFor,
} from "../../utils/thumbnailTint.ts";
import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
normalizeUrl,
playlistUrl,
} from "../../utils/urls.ts";
import { API_URL } from "../../config/api.ts";
import { i18n } from "../../i18n.ts";
describe("can", () => {
test("admins can do everything, moderators moderate, users nothing", () => {
expect(can({ role: "admin" }, "category:manage")).toBe(true);
expect(can({ role: "admin" }, "user:manage")).toBe(true);
expect(can({ role: "moderator" }, "dump:moderate")).toBe(true);
expect(can({ role: "moderator" }, "chat:moderate")).toBe(true);
expect(can({ role: "moderator" }, "category:manage")).toBe(false);
expect(can({ role: "user" }, "dump:moderate")).toBe(false);
});
test("no user, no permission", () => {
expect(can(null, "dump:moderate")).toBe(false);
expect(can(undefined, "dump:moderate")).toBe(false);
});
});
describe("proxyImageUrl", () => {
test("http images go through the proxy; https ones don't", () => {
expect(proxyImageUrl("http://img.test/a.png")).toBe(
`${API_URL}/api/proxy-image?url=${
encodeURIComponent("http://img.test/a.png")
}`,
);
expect(proxyImageUrl("https://img.test/a.png")).toBe(
"https://img.test/a.png",
);
});
test("force proxies https too", () => {
expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
"/api/proxy-image?url=",
);
});
test("relative, local and non-http URLs are left alone", () => {
expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
"http://localhost:8000/a.png",
);
expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe(
"http://127.0.0.1/a.png",
);
expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
"data:image/png;base64,AA",
);
});
});
describe("canProxyImage", () => {
test("third-party http(s) only", () => {
expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
expect(canProxyImage("/relative.png")).toBe(false);
expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
expect(canProxyImage("http://[bad")).toBe(false);
});
});
describe("relativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
});
afterEach(() => vi.useRealTimers());
const ago = (s: number) => new Date(Date.now() - s * 1000);
test("picks the largest sensible unit", () => {
expect(relativeTime(ago(0))).toBe("now");
expect(relativeTime(ago(30))).toBe("30 seconds ago");
expect(relativeTime(ago(120))).toBe("2 minutes ago");
expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago");
expect(relativeTime(ago(86400))).toBe("yesterday");
expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago");
expect(relativeTime(ago(60 * 86400))).toBe("2 months ago");
expect(relativeTime(ago(800 * 86400))).toBe("2 years ago");
});
test("follows the active locale", () => {
try {
i18n.load("fr", {});
i18n.activate("fr");
expect(relativeTime(ago(120))).toBe("il y a 2 minutes");
} finally {
i18n.activate("en");
}
});
});
describe("thumbnail tint", () => {
test("isSafeHex accepts only canonical #rrggbb", () => {
expect(isSafeHex("#a1b2c3")).toBe(true);
expect(isSafeHex("#A1B2C3")).toBe(false);
expect(isSafeHex("#abc")).toBe(false);
expect(isSafeHex("red")).toBe(false);
expect(isSafeHex("#000000;background:url(x)")).toBe(false);
expect(isSafeHex(undefined)).toBe(false);
});
test("hueFromString is stable and in range", () => {
expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
test("tintFor prefers the page colour, then the hostname sans www", () => {
expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
"#112233",
);
expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
hueFromString("a.b"),
);
expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
});
test("initialsFor takes the first letter or digit", () => {
expect(initialsFor(" bandcamp", undefined)).toBe("B");
expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y");
expect(initialsFor("«Élan»", undefined)).toBe("É");
expect(initialsFor("", "")).toBe("?");
});
});
describe("urls", () => {
test("dumpFileUrl versions by size and last change, and carries the token", () => {
const createdAt = new Date(1000);
const updatedAt = new Date(2000);
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt })).toBe(
`${API_URL}/api/files/d1?v=42-1000`,
);
// An edit (a replaced file among them) moves the version on.
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt, updatedAt }))
.toBe(`${API_URL}/api/files/d1?v=42-2000`);
expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
`${API_URL}/api/files/d1?v=0-0&token=t%20o%2Bk`,
);
});
test("dumpThumbnailUrl", () => {
expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
`${API_URL}/api/thumbnails/d1?token=tk`,
);
expect(
dumpThumbnailUrl({ id: "d1", fileSize: 7, createdAt: new Date(5) }, "tk"),
).toBe(`${API_URL}/api/thumbnails/d1?v=7-5&token=tk`);
});
test("normalizeUrl adds a scheme when there's none", () => {
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a");
expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
expect(normalizeUrl(" ")).toBe("");
});
test("slug beats id in page URLs", () => {
expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, test } from "vitest";
import { resolveBandcampQueue } from "../../../utils/player/bandcamp.ts";
import { calls, fail, route } from "../../fetch.ts";
const album = {
sourceUrl: "https://artist.bandcamp.com/album/a",
itemType: "album",
title: "A",
artist: "Artist",
artworkUrl: "https://f4.bcbits.com/a.jpg",
resolvedAt: 1000,
tracks: [
{
index: 0,
trackNum: 1,
title: "One",
duration: 100,
streamUrl: "https://s/1",
streamable: true,
capped: false,
},
{
index: 1,
trackNum: 2,
title: "Two",
streamUrl: null,
streamable: false,
capped: true,
},
{
index: 2,
trackNum: 3,
title: "Three",
duration: 50,
streamUrl: "https://s/3",
streamable: true,
capped: false,
},
],
};
describe("resolveBandcampQueue", () => {
test("turns the streamable tracks into a queue", async () => {
route("GET /api/bandcamp/tracks", () => album);
const items = await resolveBandcampQueue(album.sourceUrl, {
dumpHref: "/dumps/x",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
title: "One",
subtitle: "Artist",
trackNum: 1,
duration: 100,
artworkUrl: album.artworkUrl,
dumpHref: "/dumps/x",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: 1000,
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items[1]).toMatchObject({ resolveIndex: 2 });
const [req] = calls("GET /api/bandcamp/tracks");
expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
expect(req.url.searchParams.has("force")).toBe(false);
});
test("force asks the server to skip its cache", async () => {
route("GET /api/bandcamp/tracks", () => album);
await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("falls back to the given artwork", async () => {
route(
"GET /api/bandcamp/tracks",
() => ({ ...album, artworkUrl: undefined }),
);
const [item] = await resolveBandcampQueue(album.sourceUrl, {
fallbackArtworkUrl: "https://mine/art.png",
});
expect(item.artworkUrl).toBe("https://mine/art.png");
});
test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
route("GET /api/bandcamp/tracks", () => ({
...album,
tracks: [album.tracks[1]],
}));
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/);
});
});

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { fmt } from "../../../utils/player/duration.ts";
describe("fmt", () => {
test("m:ss", () => {
expect(fmt(0)).toBe("0:00");
expect(fmt(65.9)).toBe("1:05");
expect(fmt(3600)).toBe("60:00");
});
test("non-finite reads as zero", () => {
expect(fmt(NaN)).toBe("0:00");
expect(fmt(Infinity)).toBe("0:00");
});
});

View File

@@ -0,0 +1,103 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
isStreamStale,
resolveStreamQueue,
STREAM_STALE_MS,
} from "../../../utils/player/streamSources.ts";
import { calls, route } from "../../fetch.ts";
import type { PlayerItem } from "../../../contexts/player/PlayerContext.ts";
import { playerItemKey } from "../../../contexts/player/PlayerContext.ts";
afterEach(() => vi.useRealTimers());
const album = {
sourceUrl: "https://artist.bandcamp.com/album/a",
itemType: "album",
title: "A",
artist: "Artist",
artworkUrl: "https://f4.bcbits.com/a.jpg",
resolvedAt: 1000,
tracks: [
{
index: 0,
trackNum: 1,
title: "One",
duration: 100,
streamUrl: "https://s/1",
streamable: true,
capped: false,
},
{
index: 1,
trackNum: 2,
title: "Two",
streamUrl: null,
streamable: false,
capped: true,
},
{
index: 2,
trackNum: 3,
title: "Three",
duration: 50,
streamUrl: "https://s/3",
streamable: true,
capped: false,
},
],
};
describe("stream sources", () => {
const stream: Extract<PlayerItem, { kind: "stream" }> = {
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: Date.now(),
artworkUrl: "https://old/art.png",
dumpHref: "/dumps/x",
};
test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
route(
"GET /api/bandcamp/tracks",
() => ({ ...album, artworkUrl: undefined }),
);
const items = await resolveStreamQueue(stream);
expect(items[0].artworkUrl).toBe("https://old/art.png");
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("an unknown stream type has no resolver", async () => {
await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects
.toThrow(/no resolver/);
});
test("isStreamStale after the window", () => {
vi.useFakeTimers();
vi.setSystemTime(10 * STREAM_STALE_MS);
expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(
false,
);
expect(
isStreamStale({
...stream,
resolvedAt: Date.now() - STREAM_STALE_MS - 1,
}),
).toBe(true);
});
test("playerItemKey ignores the signed URL", () => {
expect(playerItemKey(stream)).toBe(
playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
);
expect(
playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }),
)
.toBe("file:f");
expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
.toBe("embed:e");
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, test, vi } from "vitest";
import { extractPeaks } from "../../../utils/player/waveform.ts";
import { calls, fail, route } from "../../fetch.ts";
describe("extractPeaks", () => {
test("decodes once per URL and takes each block's peak", async () => {
const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
const decode = vi.fn(async () => ({ getChannelData: () => data }));
const close = vi.fn();
vi.stubGlobal(
"AudioContext",
class {
decodeAudioData = decode;
close = close;
},
);
route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
expect(close).toHaveBeenCalled();
await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect(decode).toHaveBeenCalledTimes(1);
expect(calls("GET /api/files/:id")).toHaveLength(1);
});
test("a failed fetch throws", async () => {
route("GET /api/files/:id", () => fail("gone", 404));
await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
.toThrow(/404/);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import {
embedListId,
fetchPlaylistTracks,
withJsApi,
} from "../../../utils/player/youtube.ts";
import { fail, route } from "../../fetch.ts";
describe("youtube", () => {
test("embedListId reads ?list= from either embed shape", () => {
expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
.toBe("PL1");
expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe(
"PL2",
);
expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
expect(embedListId("nonsense")).toBeNull();
});
test("withJsApi adds the API parameters and our origin", () => {
const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL"));
expect(url.searchParams.get("enablejsapi")).toBe("1");
expect(url.searchParams.get("origin")).toBe(location.origin);
expect(url.searchParams.get("list")).toBe("PL");
expect(withJsApi("bad url")).toBe("bad url");
});
test("fetchPlaylistTracks", async () => {
route(
"GET /api/youtube/playlist",
(req) =>
req.url.searchParams.get("list") === "PL"
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
: { tracks: [] },
);
await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
{ videoId: "a", title: "A", duration: 3 },
]);
await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
route("GET /api/youtube/playlist", () => fail("x", 500));
await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
});
});

View File

@@ -1,238 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { resolveBandcampQueue } from "../../utils/bandcamp.ts";
import {
isStreamStale,
resolveStreamQueue,
STREAM_STALE_MS,
} from "../../utils/streamSources.ts";
import {
embedListId,
fetchPlaylistTracks,
withJsApi,
} from "../../utils/youtube.ts";
import { extractPeaks } from "../../utils/waveform.ts";
import { calls, fail, route } from "../fetch.ts";
import type { PlayerItem } from "../../contexts/PlayerContext.ts";
import { playerItemKey } from "../../contexts/PlayerContext.ts";
afterEach(() => vi.useRealTimers());
const album = {
sourceUrl: "https://artist.bandcamp.com/album/a",
itemType: "album",
title: "A",
artist: "Artist",
artworkUrl: "https://f4.bcbits.com/a.jpg",
resolvedAt: 1000,
tracks: [
{
index: 0,
trackNum: 1,
title: "One",
duration: 100,
streamUrl: "https://s/1",
streamable: true,
capped: false,
},
{
index: 1,
trackNum: 2,
title: "Two",
streamUrl: null,
streamable: false,
capped: true,
},
{
index: 2,
trackNum: 3,
title: "Three",
duration: 50,
streamUrl: "https://s/3",
streamable: true,
capped: false,
},
],
};
describe("resolveBandcampQueue", () => {
test("turns the streamable tracks into a queue", async () => {
route("GET /api/bandcamp/tracks", () => album);
const items = await resolveBandcampQueue(album.sourceUrl, {
dumpHref: "/dumps/x",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
title: "One",
subtitle: "Artist",
trackNum: 1,
duration: 100,
artworkUrl: album.artworkUrl,
dumpHref: "/dumps/x",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: 1000,
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items[1]).toMatchObject({ resolveIndex: 2 });
const [req] = calls("GET /api/bandcamp/tracks");
expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
expect(req.url.searchParams.has("force")).toBe(false);
});
test("force asks the server to skip its cache", async () => {
route("GET /api/bandcamp/tracks", () => album);
await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("falls back to the given artwork", async () => {
route(
"GET /api/bandcamp/tracks",
() => ({ ...album, artworkUrl: undefined }),
);
const [item] = await resolveBandcampQueue(album.sourceUrl, {
fallbackArtworkUrl: "https://mine/art.png",
});
expect(item.artworkUrl).toBe("https://mine/art.png");
});
test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
route("GET /api/bandcamp/tracks", () => ({
...album,
tracks: [album.tracks[1]],
}));
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/);
});
});
describe("stream sources", () => {
const stream: Extract<PlayerItem, { kind: "stream" }> = {
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: Date.now(),
artworkUrl: "https://old/art.png",
dumpHref: "/dumps/x",
};
test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
route(
"GET /api/bandcamp/tracks",
() => ({ ...album, artworkUrl: undefined }),
);
const items = await resolveStreamQueue(stream);
expect(items[0].artworkUrl).toBe("https://old/art.png");
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("an unknown stream type has no resolver", async () => {
await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects
.toThrow(/no resolver/);
});
test("isStreamStale after the window", () => {
vi.useFakeTimers();
vi.setSystemTime(10 * STREAM_STALE_MS);
expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(
false,
);
expect(
isStreamStale({
...stream,
resolvedAt: Date.now() - STREAM_STALE_MS - 1,
}),
).toBe(true);
});
test("playerItemKey ignores the signed URL", () => {
expect(playerItemKey(stream)).toBe(
playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
);
expect(
playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }),
)
.toBe("file:f");
expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
.toBe("embed:e");
});
});
describe("youtube", () => {
test("embedListId reads ?list= from either embed shape", () => {
expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
.toBe("PL1");
expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe(
"PL2",
);
expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
expect(embedListId("nonsense")).toBeNull();
});
test("withJsApi adds the API parameters and our origin", () => {
const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL"));
expect(url.searchParams.get("enablejsapi")).toBe("1");
expect(url.searchParams.get("origin")).toBe(location.origin);
expect(url.searchParams.get("list")).toBe("PL");
expect(withJsApi("bad url")).toBe("bad url");
});
test("fetchPlaylistTracks", async () => {
route(
"GET /api/youtube/playlist",
(req) =>
req.url.searchParams.get("list") === "PL"
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
: { tracks: [] },
);
await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
{ videoId: "a", title: "A", duration: 3 },
]);
await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
route("GET /api/youtube/playlist", () => fail("x", 500));
await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
});
});
describe("extractPeaks", () => {
test("decodes once per URL and takes each block's peak", async () => {
const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
const decode = vi.fn(async () => ({ getChannelData: () => data }));
const close = vi.fn();
vi.stubGlobal(
"AudioContext",
class {
decodeAudioData = decode;
close = close;
},
);
route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
expect(close).toHaveBeenCalled();
await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect(decode).toHaveBeenCalledTimes(1);
expect(calls("GET /api/files/:id")).toHaveLength(1);
});
test("a failed fetch throws", async () => {
route("GET /api/files/:id", () => fail("gone", 404));
await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
.toThrow(/404/);
});
});

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { friendlyFetchError } from "../../../utils/shared/api.ts";
describe("friendlyFetchError", () => {
test("a TypeError is a connection problem", () => {
expect(friendlyFetchError(new TypeError("Failed to fetch"))).toMatch(
/Could not connect/,
);
});
test("other errors keep their message", () => {
expect(friendlyFetchError(new Error("Nope"))).toBe("Nope");
expect(friendlyFetchError("weird")).toBe("An unexpected error occurred.");
});
});

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { charCountClass, overLimit } from "../../../utils/shared/charCount.ts";
describe("character limits", () => {
test("charCountClass warns from 85% and flags the limit", () => {
expect(charCountClass(10, 100)).toBe("");
expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
});
test("overLimit is strictly past it", () => {
expect(overLimit("abc", 3)).toBe(false);
expect(overLimit("abcd", 3)).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from "vitest";
import {
formatBytes,
titleFromFileName,
} from "../../../utils/shared/format.ts";
describe("formatBytes", () => {
test("B, KB and MB", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
});
});
describe("titleFromFileName", () => {
test("drops the extension and turns underscores into spaces", () => {
expect(titleFromFileName("holiday_photo-01.final.jpg")).toBe(
"holiday photo-01.final",
);
});
test("a dotfile keeps its name", () => {
expect(titleFromFileName(".gitignore")).toBe(".gitignore");
});
test("a name that is only separators falls back to itself", () => {
expect(titleFromFileName("___.txt")).toBe("___.txt");
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import {
canProxyImage,
proxyImageUrl,
} from "../../../utils/shared/proxyImage.ts";
import { API_URL } from "../../../config/api.ts";
describe("proxyImageUrl", () => {
test("http images go through the proxy; https ones don't", () => {
expect(proxyImageUrl("http://img.test/a.png")).toBe(
`${API_URL}/api/proxy-image?url=${
encodeURIComponent("http://img.test/a.png")
}`,
);
expect(proxyImageUrl("https://img.test/a.png")).toBe(
"https://img.test/a.png",
);
});
test("force proxies https too", () => {
expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
"/api/proxy-image?url=",
);
});
test("relative, local and non-http URLs are left alone", () => {
expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
"http://localhost:8000/a.png",
);
expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe(
"http://127.0.0.1/a.png",
);
expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
"data:image/png;base64,AA",
);
});
});
describe("canProxyImage", () => {
test("third-party http(s) only", () => {
expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
expect(canProxyImage("/relative.png")).toBe(false);
expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
expect(canProxyImage("http://[bad")).toBe(false);
});
});

View File

@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { relativeTime } from "../../../utils/shared/relativeTime.ts";
import { i18n } from "../../../i18n.ts";
describe("relativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
});
afterEach(() => vi.useRealTimers());
const ago = (s: number) => new Date(Date.now() - s * 1000);
test("picks the largest sensible unit", () => {
expect(relativeTime(ago(0))).toBe("now");
expect(relativeTime(ago(30))).toBe("30 seconds ago");
expect(relativeTime(ago(120))).toBe("2 minutes ago");
expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago");
expect(relativeTime(ago(86400))).toBe("yesterday");
expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago");
expect(relativeTime(ago(60 * 86400))).toBe("2 months ago");
expect(relativeTime(ago(800 * 86400))).toBe("2 years ago");
});
test("follows the active locale", () => {
try {
i18n.load("fr", {});
i18n.activate("fr");
expect(relativeTime(ago(120))).toBe("il y a 2 minutes");
} finally {
i18n.activate("en");
}
});
});

View File

@@ -34,7 +34,7 @@ class FakeAudioContext {
async function load() {
vi.resetModules();
return await import("../../utils/sound.ts");
return await import("../../../utils/shared/sound.ts");
}
const notes = () =>

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import {
hueFromString,
initialsFor,
isSafeHex,
tintFor,
} from "../../../utils/shared/thumbnailTint.ts";
describe("thumbnail tint", () => {
test("isSafeHex accepts only canonical #rrggbb", () => {
expect(isSafeHex("#a1b2c3")).toBe(true);
expect(isSafeHex("#A1B2C3")).toBe(false);
expect(isSafeHex("#abc")).toBe(false);
expect(isSafeHex("red")).toBe(false);
expect(isSafeHex("#000000;background:url(x)")).toBe(false);
expect(isSafeHex(undefined)).toBe(false);
});
test("hueFromString is stable and in range", () => {
expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
test("tintFor prefers the page colour, then the hostname sans www", () => {
expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
"#112233",
);
expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
hueFromString("a.b"),
);
expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
});
test("initialsFor takes the first letter or digit", () => {
expect(initialsFor(" bandcamp", undefined)).toBe("B");
expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y");
expect(initialsFor("«Élan»", undefined)).toBe("É");
expect(initialsFor("", "")).toBe("?");
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
normalizeUrl,
playlistUrl,
} from "../../../utils/shared/urls.ts";
import { API_URL } from "../../../config/api.ts";
describe("urls", () => {
test("dumpFileUrl versions by size and last change, and carries the token", () => {
const createdAt = new Date(1000);
const updatedAt = new Date(2000);
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt })).toBe(
`${API_URL}/api/files/d1?v=42-1000`,
);
// An edit (a replaced file among them) moves the version on.
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt, updatedAt }))
.toBe(`${API_URL}/api/files/d1?v=42-2000`);
expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
`${API_URL}/api/files/d1?v=0-0&token=t%20o%2Bk`,
);
});
test("dumpThumbnailUrl", () => {
expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
`${API_URL}/api/thumbnails/d1?token=tk`,
);
expect(
dumpThumbnailUrl({ id: "d1", fileSize: 7, createdAt: new Date(5) }, "tk"),
).toBe(`${API_URL}/api/thumbnails/d1?v=7-5&token=tk`);
});
test("normalizeUrl adds a scheme when there's none", () => {
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a");
expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
expect(normalizeUrl(" ")).toBe("");
});
test("slug beats id in page URLs", () => {
expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import {
isDumpVisited,
isPlaylistVisited,
isRecent,
markDumpVisited,
markPlaylistVisited,
} from "../../../utils/shared/visited.ts";
describe("visited", () => {
test("dumps and playlists are tracked apart", () => {
markDumpVisited("a");
markPlaylistVisited("b");
expect(isDumpVisited("a")).toBe(true);
expect(isDumpVisited("b")).toBe(false);
expect(isPlaylistVisited("b")).toBe(true);
expect(isPlaylistVisited("a")).toBe(false);
});
test("a corrupt entry reads as nothing visited", () => {
localStorage.setItem("visited_dumps", "{");
expect(isDumpVisited("a")).toBe(false);
markDumpVisited("a");
expect(isDumpVisited("a")).toBe(true);
});
test("isRecent is a seven-day window", () => {
const day = 24 * 3600 * 1000;
expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
expect(isRecent(new Date(Date.now() - 8 * day))).toBe(false);
});
});

View File

@@ -1,184 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
AUTH_STORAGE_KEY,
forgetStoredSession,
isTokenExpired,
readStoredSession,
storeSession,
} from "../../utils/authStorage.ts";
import {
clearDumpDraft,
EMPTY_DUMP_DRAFT,
isDumpDraftEmpty,
loadDumpDraft,
saveDumpDraft,
} from "../../utils/dumpDraft.ts";
import {
isDumpVisited,
isPlaylistVisited,
isRecent,
markDumpVisited,
markPlaylistVisited,
} from "../../utils/visited.ts";
import { makeSession, makeToken, wire } from "../fixtures.ts";
afterEach(() => vi.useRealTimers());
describe("isTokenExpired", () => {
test("reads exp from the payload", () => {
expect(isTokenExpired(makeToken({ expiresIn: 60 }))).toBe(false);
expect(isTokenExpired(makeToken({ expiresIn: -60 }))).toBe(true);
});
test("a token it can't read is treated as expired", () => {
expect(isTokenExpired("garbage")).toBe(true);
expect(isTokenExpired("a.b.c")).toBe(true);
});
test("no exp means it doesn't expire", () => {
const token = `x.${btoa(JSON.stringify({ sub: "u" }))}.y`;
expect(isTokenExpired(token)).toBe(false);
});
});
describe("stored session", () => {
test("round-trips, with dates revived", () => {
const session = makeSession({ username: "ann" });
storeSession(session);
const read = readStoredSession();
expect(read?.user.username).toBe("ann");
expect(read?.user.createdAt).toBeInstanceOf(Date);
});
test("nothing stored", () => {
expect(readStoredSession()).toBeNull();
});
test("unparseable, expired and roleless sessions are discarded", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{not json");
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const expired = makeSession();
expired.token = makeToken({ expiresIn: -1 });
storeSession(expired);
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const stored = wire(makeSession());
const { role: _role, ...roleless } = stored.user;
localStorage.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({ ...stored, user: roleless }),
);
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession only forgets the session holding that token", () => {
const session = makeSession();
storeSession(session);
forgetStoredSession("some-older-token");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).not.toBeNull();
forgetStoredSession(session.token);
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
test("forgetStoredSession clears an unreadable entry", () => {
localStorage.setItem(AUTH_STORAGE_KEY, "{bad");
forgetStoredSession("anything");
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});
describe("dump draft", () => {
const draft = {
...EMPTY_DUMP_DRAFT,
url: "https://a.b",
comment: "why",
};
test("an empty draft is empty; any content isn't", () => {
expect(isDumpDraftEmpty(EMPTY_DUMP_DRAFT)).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, url: " " })).toBe(true);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, isPublic: false })).toBe(
false,
);
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, categoryIds: ["c"] })).toBe(
false,
);
});
test("a title from the preview isn't worth keeping; a typed one is", () => {
expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, title: "Scraped" })).toBe(
true,
);
expect(
isDumpDraftEmpty({
...EMPTY_DUMP_DRAFT,
title: "Mine",
titleFromUser: true,
}),
).toBe(false);
});
test("save, load and clear", () => {
saveDumpDraft(draft);
expect(loadDumpDraft()).toEqual(draft);
clearDumpDraft();
expect(loadDumpDraft()).toBeNull();
});
test("saving an empty draft removes the stored one", () => {
saveDumpDraft(draft);
saveDumpDraft(EMPTY_DUMP_DRAFT);
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
});
test("a malformed stored draft is sanitised or ignored", () => {
localStorage.setItem(
"gerbeur.dumpDraft",
JSON.stringify({ url: 5, comment: "c", categoryIds: ["a", 3, null] }),
);
expect(loadDumpDraft()).toEqual({
...EMPTY_DUMP_DRAFT,
comment: "c",
categoryIds: ["a"],
});
localStorage.setItem("gerbeur.dumpDraft", "nope");
expect(loadDumpDraft()).toBeNull();
});
test("storage that throws never breaks posting", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceeded");
});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
expect(() => saveDumpDraft(draft)).not.toThrow();
expect(loadDumpDraft()).toBeNull();
});
});
describe("visited", () => {
test("dumps and playlists are tracked apart", () => {
markDumpVisited("a");
markPlaylistVisited("b");
expect(isDumpVisited("a")).toBe(true);
expect(isDumpVisited("b")).toBe(false);
expect(isPlaylistVisited("b")).toBe(true);
expect(isPlaylistVisited("a")).toBe(false);
});
test("a corrupt entry reads as nothing visited", () => {
localStorage.setItem("visited_dumps", "{");
expect(isDumpVisited("a")).toBe(false);
markDumpVisited("a");
expect(isDumpVisited("a")).toBe(true);
});
test("isRecent is a seven-day window", () => {
const day = 24 * 3600 * 1000;
expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
expect(isRecent(new Date(Date.now() - 8 * day))).toBe(false);
});
});

View File

@@ -1,157 +0,0 @@
import { describe, expect, test } from "vitest";
import { insideCode, isEmojiOnly } from "../../utils/editorText.ts";
import { markdownToPlainText, mentionsUser } from "../../utils/chatText.ts";
import { charCountClass, overLimit } from "../../utils/charCount.ts";
import { formatBytes, titleFromFileName } from "../../utils/format.ts";
import { fmt } from "../../utils/duration.ts";
import { friendlyFetchError } from "../../utils/apiError.ts";
describe("insideCode", () => {
test("plain text is not code", () => {
expect(insideCode("hello @bob")).toBe(false);
});
test("an unclosed inline span is code", () => {
expect(insideCode("try `@bob")).toBe(true);
expect(insideCode("try `a` then `@bob")).toBe(true);
});
test("a closed inline span is not", () => {
expect(insideCode("try `a` @bob")).toBe(false);
});
test("an open fence is code, a closed one is not", () => {
expect(insideCode("```\n@bob")).toBe(true);
expect(insideCode("```js\nx\n```\n@bob")).toBe(false);
});
test("the fence line itself is not an inline span", () => {
expect(insideCode("text\n```\ncode\n```")).toBe(false);
});
test("backticks on an earlier line don't carry over", () => {
expect(insideCode("a ` b\n@bob")).toBe(false);
});
});
describe("isEmojiOnly", () => {
test("one to three emoji", () => {
expect(isEmojiOnly("🎉")).toBe(true);
expect(isEmojiOnly("👍👍")).toBe(true);
expect(isEmojiOnly(" 🎉 🎉 🎉 ")).toBe(true);
});
test("more than the max", () => {
expect(isEmojiOnly("🎉🎉🎉🎉")).toBe(false);
expect(isEmojiOnly("🎉🎉🎉🎉", 4)).toBe(true);
});
test("a ZWJ sequence, a skin tone and a flag count as one each", () => {
expect(isEmojiOnly("👨‍👩‍👧‍👦")).toBe(true);
expect(isEmojiOnly("👍🏽")).toBe(true);
expect(isEmojiOnly("🇫🇷🇫🇷🇫🇷")).toBe(true);
});
test("text, digits and symbols are not emoji", () => {
expect(isEmojiOnly("")).toBe(false);
expect(isEmojiOnly("hi 🎉")).toBe(false);
expect(isEmojiOnly("123")).toBe(false);
expect(isEmojiOnly("#")).toBe(false);
expect(isEmojiOnly("1🎉")).toBe(false);
});
});
describe("mentionsUser", () => {
test("case-insensitive whole-word match", () => {
expect(mentionsUser("hey @Alice!", "alice")).toBe(true);
expect(mentionsUser("hey @alicex", "alice")).toBe(false);
});
test("follows the server's rule: not in an email, a link or brackets", () => {
expect(mentionsUser("mail bob@alice", "alice")).toBe(false);
expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
expect(mentionsUser("(@alice)", "alice")).toBe(false);
});
test("any of several mentions", () => {
expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
});
});
describe("markdownToPlainText", () => {
test("links become their text, images their alt or a label", () => {
expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe(
"see this now",
);
expect(markdownToPlainText("![cat](a.png)", "Image")).toBe("cat");
expect(markdownToPlainText("![](a.png)", "Image")).toBe("Image");
});
test("markup characters and block markers go", () => {
expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe(
"Title quote bold code",
);
});
test("a body cut off mid-link doesn't show a half URL", () => {
expect(markdownToPlainText("look [here](https://exa", "img")).toBe(
"look here",
);
expect(markdownToPlainText("pic ![alt](https://exa", "Image")).toBe(
"pic alt",
);
expect(markdownToPlainText("pic ![](https://exa", "Image")).toBe(
"pic Image",
);
expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic");
});
test("whitespace collapses to one line", () => {
expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c");
});
});
describe("character limits", () => {
test("charCountClass warns from 85% and flags the limit", () => {
expect(charCountClass(10, 100)).toBe("");
expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
});
test("overLimit is strictly past it", () => {
expect(overLimit("abc", 3)).toBe(false);
expect(overLimit("abcd", 3)).toBe(true);
});
});
describe("formatBytes", () => {
test("B, KB and MB", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
});
});
describe("titleFromFileName", () => {
test("drops the extension and turns underscores into spaces", () => {
expect(titleFromFileName("holiday_photo-01.final.jpg")).toBe(
"holiday photo-01.final",
);
});
test("a dotfile keeps its name", () => {
expect(titleFromFileName(".gitignore")).toBe(".gitignore");
});
test("a name that is only separators falls back to itself", () => {
expect(titleFromFileName("___.txt")).toBe("___.txt");
});
});
describe("fmt", () => {
test("m:ss", () => {
expect(fmt(0)).toBe("0:00");
expect(fmt(65.9)).toBe("1:05");
expect(fmt(3600)).toBe("60:00");
});
test("non-finite reads as zero", () => {
expect(fmt(NaN)).toBe("0:00");
expect(fmt(Infinity)).toBe("0:00");
});
});
describe("friendlyFetchError", () => {
test("a TypeError is a connection problem", () => {
expect(friendlyFetchError(new TypeError("Failed to fetch"))).toMatch(
/Could not connect/,
);
});
test("other errors keep their message", () => {
expect(friendlyFetchError(new Error("Nope"))).toBe("Nope");
expect(friendlyFetchError("weird")).toBe("An unexpected error occurred.");
});
});