v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,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";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
dumpFileUrl,
|
||||
dumpFileVersion,
|
||||
dumpThumbnailUrl,
|
||||
} from "../../../utils/urls.ts";
|
||||
} from "../../../utils/shared/urls.ts";
|
||||
|
||||
describe("FilePreview", () => {
|
||||
const session = makeSession();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
@@ -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", () => {
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
41
src/test/components/layout/DumpFab.test.tsx
Normal file
41
src/test/components/layout/DumpFab.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -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>);
|
||||
@@ -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", () => {
|
||||
@@ -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>);
|
||||
@@ -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", () => {
|
||||
@@ -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", () => {
|
||||
@@ -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", () => {
|
||||
@@ -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", () => {
|
||||
123
src/test/contexts/auth/AuthProvider.test.tsx
Normal file
123
src/test/contexts/auth/AuthProvider.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
39
src/test/contexts/category/CategoriesProvider.test.tsx
Normal file
39
src/test/contexts/category/CategoriesProvider.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
56
src/test/contexts/chat/ChatProvider.test.tsx
Normal file
56
src/test/contexts/chat/ChatProvider.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
71
src/test/contexts/social/FollowProvider.test.tsx
Normal file
71
src/test/contexts/social/FollowProvider.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
46
src/test/contexts/theme/ThemeProvider.test.tsx
Normal file
46
src/test/contexts/theme/ThemeProvider.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -2,12 +2,12 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { AuthContext } from "../../contexts/AuthContext.ts";
|
||||
import type { AuthResponse } from "../../model.ts";
|
||||
import { useAuth, useRequiredAuth } from "../../hooks/useAuth.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
|
||||
import { calls, route } from "../fetch.ts";
|
||||
import { makeSession, makeToken, storeSession } from "../fixtures.ts";
|
||||
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
|
||||
import type { AuthResponse } from "../../../model.ts";
|
||||
import { useAuth, useRequiredAuth } from "../../../hooks/auth/useAuth.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
|
||||
import { calls, route } from "../../fetch.ts";
|
||||
import { makeSession, makeToken, storeSession } from "../../fixtures.ts";
|
||||
|
||||
function wrapperFor(initial: AuthResponse | null) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
@@ -1,377 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthContext } from "../../contexts/AuthContext.ts";
|
||||
import { useUserDumpFeed } from "../../hooks/useUserDumpFeed.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.head.querySelectorAll("script").forEach((s) => s.remove());
|
||||
});
|
||||
|
||||
describe("usePlayRichContent", () => {
|
||||
// The Bandcamp mode is read from a <meta> tag once, at module load.
|
||||
async function load(mode: "embed" | "native") {
|
||||
document.head.innerHTML = `<meta name="bandcamp-player" content="${mode}">`;
|
||||
vi.resetModules();
|
||||
const hookMod = await import("../../hooks/usePlayRichContent.ts");
|
||||
const ctxMod = await import("../../contexts/PlayerContext.ts");
|
||||
const play = vi.fn();
|
||||
const playQueue = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ctxMod.PlayerContext.Provider
|
||||
value={{ ...ctxMod.defaultPlayerValue, play, playQueue }}
|
||||
>
|
||||
{children}
|
||||
</ctxMod.PlayerContext.Provider>
|
||||
);
|
||||
const hook = renderHook(hookMod.usePlayRichContent, { wrapper });
|
||||
return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent };
|
||||
}
|
||||
afterEach(() => {
|
||||
document.head.innerHTML = "";
|
||||
});
|
||||
|
||||
const yt = {
|
||||
type: "youtube",
|
||||
url: "https://youtu.be/x",
|
||||
title: "Vid",
|
||||
embedUrl: "https://www.youtube.com/embed/x",
|
||||
thumbnailUrl: "https://i.ytimg.com/x.jpg",
|
||||
siteName: "YouTube",
|
||||
};
|
||||
const bc = {
|
||||
type: "bandcamp",
|
||||
url: "https://a.bandcamp.com/album/x",
|
||||
title: "Album",
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
|
||||
};
|
||||
|
||||
test("embeds play in the iframe", async () => {
|
||||
const { hook, play } = await load("embed");
|
||||
let started!: boolean;
|
||||
await act(async () => {
|
||||
started = await hook.result.current.playRichContent(yt, "/dumps/1");
|
||||
});
|
||||
expect(started).toBe(true);
|
||||
expect(play).toHaveBeenCalledWith({
|
||||
kind: "embed",
|
||||
embedUrl: yt.embedUrl,
|
||||
title: "Vid",
|
||||
type: "youtube",
|
||||
dumpHref: "/dumps/1",
|
||||
artworkUrl: yt.thumbnailUrl,
|
||||
subtitle: "YouTube",
|
||||
});
|
||||
});
|
||||
|
||||
test("nothing to embed: not started", async () => {
|
||||
const { hook, play, canPlay } = await load("embed");
|
||||
const bare = { type: "link", url: "https://x" };
|
||||
expect(canPlay(bare)).toBe(false);
|
||||
let started = true;
|
||||
await act(async () => {
|
||||
started = await hook.result.current.playRichContent(bare);
|
||||
});
|
||||
expect(started).toBe(false);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("native Bandcamp plays a resolved queue", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => ({
|
||||
sourceUrl: bc.url,
|
||||
itemType: "album",
|
||||
resolvedAt: 1,
|
||||
tracks: [{
|
||||
index: 0,
|
||||
title: "T",
|
||||
streamUrl: "https://s/0",
|
||||
streamable: true,
|
||||
capped: false,
|
||||
}],
|
||||
}));
|
||||
const { hook, playQueue, canPlay } = await load("native");
|
||||
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
|
||||
await act(() => hook.result.current.playRichContent(bc));
|
||||
expect(playQueue).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })],
|
||||
0,
|
||||
);
|
||||
expect(hook.result.current.pending).toBe(false);
|
||||
});
|
||||
|
||||
test("native Bandcamp that can't resolve falls back to the embed", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
route("GET /api/bandcamp/tracks", () => fail("x", 500));
|
||||
const { hook, play, playQueue } = await load("native");
|
||||
await act(() => hook.result.current.playRichContent(bc));
|
||||
expect(playQueue).not.toHaveBeenCalled();
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "embed" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown mode reads as embed", async () => {
|
||||
const { canPlay } = await load("bogus" as "embed");
|
||||
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUserDumpFeed", () => {
|
||||
const ann = makeUser({ username: "ann" });
|
||||
function setup(
|
||||
opts: { session?: ReturnType<typeof makeSession> | null } = {},
|
||||
) {
|
||||
const session = opts.session ?? null;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [authResponse, setAuthResponse] = useState(session);
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
return renderHook(
|
||||
({ username }: { username?: string }) =>
|
||||
useUserDumpFeed(username, "dumps", "cache-key"),
|
||||
{
|
||||
initialProps: { username: "ann" as string | undefined },
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("loads the profile and the first page", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
|
||||
const session = makeSession();
|
||||
const { result } = setup({ session });
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.profileUser.username).toBe("ann");
|
||||
expect(s.items[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(s.hasMore).toBe(true);
|
||||
const [req] = calls("GET /api/users/:u/dumps");
|
||||
expect(req.url.searchParams.get("page")).toBe("1");
|
||||
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
});
|
||||
|
||||
test("an unknown user is an error", async () => {
|
||||
route("GET /api/users/:u", () => fail("User not found", 404));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("error"));
|
||||
});
|
||||
|
||||
test("an offline load says so", async () => {
|
||||
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result } = setup();
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringMatching(/Could not connect/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolling to the sentinel loads the next page", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeDump({ id: "a" })], true)
|
||||
: page([makeDump({ id: "b" })], false),
|
||||
);
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.report(true));
|
||||
await waitFor(() => {
|
||||
const s = result.current.state;
|
||||
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.hasMore).toBe(false);
|
||||
expect(s.page).toBe(2);
|
||||
});
|
||||
|
||||
test("changing user resets to loading", async () => {
|
||||
route(
|
||||
"GET /api/users/:u",
|
||||
(req) => wire(makeUser({ username: req.params.u })),
|
||||
);
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result, rerender } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
rerender({ username: "bob" });
|
||||
expect(result.current.state.status).toBe("loading");
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
});
|
||||
|
||||
test("setItems edits the loaded list", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
|
||||
);
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
act(() => result.current.setItems((prev) => prev.slice(1)));
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.items.map((d) => d.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useYouTubePlaylist", () => {
|
||||
type Events = {
|
||||
onReady?: () => void;
|
||||
onStateChange?: (e: { data: number }) => void;
|
||||
};
|
||||
function installYT(ids = ["a", "b", "c"]) {
|
||||
const player = {
|
||||
events: {} as Events,
|
||||
index: 0,
|
||||
playVideo: vi.fn(),
|
||||
playVideoAt: vi.fn(),
|
||||
nextVideo: vi.fn(),
|
||||
previousVideo: vi.fn(),
|
||||
getPlaylist: () => ids,
|
||||
getPlaylistIndex() {
|
||||
return this.index;
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
// Forwards to `player`, which the tests inspect and steer.
|
||||
class Player {
|
||||
constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
|
||||
player.events = opts.events;
|
||||
}
|
||||
playVideo = player.playVideo;
|
||||
playVideoAt = player.playVideoAt;
|
||||
nextVideo = player.nextVideo;
|
||||
previousVideo = player.previousVideo;
|
||||
getPlaylist = player.getPlaylist;
|
||||
getPlaylistIndex = () => player.index;
|
||||
destroy = player.destroy;
|
||||
}
|
||||
window.YT = { Player };
|
||||
return player;
|
||||
}
|
||||
|
||||
// The IFrame API loads once per document and is cached in the module.
|
||||
async function setup(embedUrl: string | null) {
|
||||
vi.resetModules();
|
||||
const { useYouTubePlaylist } = await import(
|
||||
"../../hooks/useYouTubePlaylist.ts"
|
||||
);
|
||||
return renderHook(({ url }) => {
|
||||
const ref = useRef<HTMLIFrameElement | null>(
|
||||
document.createElement("iframe"),
|
||||
);
|
||||
return useYouTubePlaylist(url, ref);
|
||||
}, { initialProps: { url: embedUrl } });
|
||||
}
|
||||
|
||||
test("titles from the server, order and position from the player", async () => {
|
||||
// The API script loads once per document; later tests find YT ready.
|
||||
const player = installYT();
|
||||
route("GET /api/youtube/playlist", () => ({
|
||||
tracks: [
|
||||
{ videoId: "a", title: "A", duration: 10 },
|
||||
{ videoId: "c", title: "C", duration: 30 },
|
||||
],
|
||||
}));
|
||||
const { result } = await setup(
|
||||
"https://www.youtube.com/embed/videoseries?list=PL1",
|
||||
);
|
||||
await waitFor(() => expect(result.current.rows).toHaveLength(2));
|
||||
expect(result.current.ready).toBe(false);
|
||||
await waitFor(() => expect(player.events.onReady).toBeDefined());
|
||||
player.index = 1;
|
||||
act(() => player.events.onReady!());
|
||||
expect(result.current.ready).toBe(true);
|
||||
expect(result.current.index).toBe(1);
|
||||
// The player's list wins, matched to the titles by id.
|
||||
expect(result.current.rows).toEqual([
|
||||
{ videoId: "a", title: "A", duration: 10 },
|
||||
{ videoId: "b", title: undefined, duration: undefined },
|
||||
{ videoId: "c", title: "C", duration: 30 },
|
||||
]);
|
||||
act(() => result.current.playAt(2));
|
||||
act(() => result.current.next());
|
||||
act(() => result.current.previous());
|
||||
expect(player.playVideoAt).toHaveBeenCalledWith(2);
|
||||
expect(player.nextVideo).toHaveBeenCalled();
|
||||
expect(player.previousVideo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
|
||||
const player = installYT();
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
await setup("https://www.youtube.com/embed/x?list=PL2");
|
||||
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
act(() => player.events.onStateChange!({ data: 2 }));
|
||||
}
|
||||
expect(player.playVideo).toHaveBeenCalledTimes(3);
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
act(() => player.events.onStateChange!({ data: 2 }));
|
||||
expect(player.playVideo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("no list in the URL: no rows, no requests", async () => {
|
||||
const { result } = await setup("https://www.youtube.com/embed/x");
|
||||
expect(result.current.rows).toEqual([]);
|
||||
expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a new embed drops the old list; unmount destroys the player", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const player = installYT();
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
const { result, rerender, unmount } = await setup(
|
||||
"https://www.youtube.com/embed/x?list=PL3",
|
||||
);
|
||||
await waitFor(() => expect(result.current.rows).toHaveLength(1));
|
||||
await waitFor(() => expect(player.events.onReady).toBeDefined());
|
||||
route("GET /api/youtube/playlist", () => fail("x", 500));
|
||||
rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
|
||||
expect(result.current.rows).toEqual([]);
|
||||
expect(result.current.index).toBe(-1);
|
||||
unmount();
|
||||
expect(player.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
85
src/test/hooks/dump/useDumpCardLink.test.tsx
Normal file
85
src/test/hooks/dump/useDumpCardLink.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { useDumpCardLink } from "../../../hooks/dump/useDumpCardLink.ts";
|
||||
import { isDumpVisited } from "../../../utils/shared/visited.ts";
|
||||
|
||||
describe("useDumpCardLink", () => {
|
||||
function Card() {
|
||||
const link = useDumpCardLink({ id: "d1", slug: "hello" });
|
||||
return (
|
||||
<div data-testid="card" {...link.cardProps}>
|
||||
<a href="/elsewhere">inner link</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
|
||||
}}
|
||||
>
|
||||
play
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function setup() {
|
||||
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Card />} />
|
||||
<Route path="/dumps/:id" element={<p>dump page</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
return { open };
|
||||
}
|
||||
|
||||
test("a click navigates and marks visited", () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByTestId("card"));
|
||||
expect(screen.getByText("dump page")).toBeInTheDocument();
|
||||
expect(isDumpVisited("d1")).toBe(true);
|
||||
});
|
||||
|
||||
test("ctrl/cmd/shift-click opens a new tab", () => {
|
||||
const { open } = setup();
|
||||
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
|
||||
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
|
||||
});
|
||||
|
||||
test("a middle click opens a new tab, except over a link", () => {
|
||||
const { open } = setup();
|
||||
const mouseDown = new MouseEvent("mousedown", {
|
||||
button: 1,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
screen.getByTestId("card").dispatchEvent(mouseDown);
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
fireEvent(
|
||||
screen.getByTestId("card"),
|
||||
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(
|
||||
screen.getByText("inner link"),
|
||||
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(
|
||||
screen.getByTestId("card"),
|
||||
new MouseEvent("auxclick", { button: 2, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a modified click on the play button opens the dump instead of playing", () => {
|
||||
const { open } = setup();
|
||||
const play = screen.getByText("play");
|
||||
fireEvent.click(play, { metaKey: true });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(play.dataset.played).toBeUndefined();
|
||||
fireEvent.click(play);
|
||||
expect(play.dataset.played).toBe("1");
|
||||
});
|
||||
});
|
||||
110
src/test/hooks/dump/useDumpListSync.test.tsx
Normal file
110
src/test/hooks/dump/useDumpListSync.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
defaultWSValue,
|
||||
WSContext,
|
||||
type WSContextValue,
|
||||
} from "../../../contexts/ws/WSContext.ts";
|
||||
import type { Dump } from "../../../model.ts";
|
||||
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
|
||||
import { makeDump } from "../../fixtures.ts";
|
||||
|
||||
/** A WS context whose value a test can push new events into. */
|
||||
function liveWS() {
|
||||
let push: (patch: Partial<WSContextValue>) => void = () => {};
|
||||
const base = defaultWSValue;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [value, setValue] = useState(base);
|
||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
||||
}
|
||||
return {
|
||||
Wrapper,
|
||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
||||
};
|
||||
}
|
||||
|
||||
function useList<T>(initial: T[]) {
|
||||
const [items, setItems] = useState(initial);
|
||||
return { items, setItems };
|
||||
}
|
||||
|
||||
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
||||
|
||||
describe("useDumpListSync", () => {
|
||||
function setup(
|
||||
initial: Dump[],
|
||||
opts?: Parameters<typeof useDumpListSync>[1],
|
||||
) {
|
||||
const ws = liveWS();
|
||||
const hook = renderHook(() => {
|
||||
const list = useList(initial);
|
||||
useDumpListSync(list.setItems, opts);
|
||||
return list;
|
||||
}, { wrapper: ws.Wrapper });
|
||||
return { ...ws, hook };
|
||||
}
|
||||
|
||||
test("drops deleted dumps", () => {
|
||||
const { hook, emit } = setup([
|
||||
makeDump({ id: "a" }),
|
||||
makeDump({ id: "b" }),
|
||||
]);
|
||||
emit({ deletedDumpIds: new Set(["a"]) });
|
||||
expect(ids(hook.result.current.items)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("updates a dump in place", () => {
|
||||
const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
|
||||
emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
|
||||
expect(hook.result.current.items[0].title).toBe("new");
|
||||
});
|
||||
|
||||
test("a dump gone private leaves others' lists but not the owner's", () => {
|
||||
const a = makeDump({ id: "a" });
|
||||
const viewer = setup([a]);
|
||||
viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
|
||||
expect(viewer.hook.result.current.items).toEqual([]);
|
||||
|
||||
const owner = setup([a], { isOwner: true });
|
||||
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
|
||||
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
test("re-inserts only the owner's dumps, and only when asked", () => {
|
||||
const mine = makeDump({ id: "m", userId: "me" });
|
||||
const theirs = makeDump({ id: "t", userId: "them" });
|
||||
|
||||
const none = setup([]);
|
||||
none.emit({ lastDumpEvent: mine });
|
||||
expect(none.hook.result.current.items).toEqual([]);
|
||||
|
||||
const owned = setup([], { ownerId: "me" });
|
||||
owned.emit({ lastDumpEvent: theirs });
|
||||
expect(owned.hook.result.current.items).toEqual([]);
|
||||
owned.emit({ lastDumpEvent: mine });
|
||||
expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
|
||||
|
||||
const skip = setup([], { ownerId: "me", skipReinsert: true });
|
||||
skip.emit({ lastDumpEvent: mine });
|
||||
expect(skip.hook.result.current.items).toEqual([]);
|
||||
|
||||
const privateOne = setup([], { ownerId: "me" });
|
||||
privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
|
||||
expect(privateOne.hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("comment events move the count, never below zero; edits don't", () => {
|
||||
const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
|
||||
emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
|
||||
expect(hook.result.current.items[0].commentCount).toBe(1);
|
||||
emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
|
||||
expect(hook.result.current.items[0].commentCount).toBe(1);
|
||||
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
|
||||
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
|
||||
expect(hook.result.current.items[0].commentCount).toBe(0);
|
||||
emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
|
||||
expect(hook.result.current.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
134
src/test/hooks/dump/useUserDumpFeed.test.tsx
Normal file
134
src/test/hooks/dump/useUserDumpFeed.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
|
||||
import { useUserDumpFeed } from "../../../hooks/dump/useUserDumpFeed.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeDump, makeSession, makeUser, page, wire } from "../../fixtures.ts";
|
||||
import { FakeIntersectionObserver } from "../../setup.ts";
|
||||
|
||||
describe("useUserDumpFeed", () => {
|
||||
const ann = makeUser({ username: "ann" });
|
||||
function setup(
|
||||
opts: { session?: ReturnType<typeof makeSession> | null } = {},
|
||||
) {
|
||||
const session = opts.session ?? null;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [authResponse, setAuthResponse] = useState(session);
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
return renderHook(
|
||||
({ username }: { username?: string }) =>
|
||||
useUserDumpFeed(username, "dumps", "cache-key"),
|
||||
{
|
||||
initialProps: { username: "ann" as string | undefined },
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("loads the profile and the first page", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
|
||||
const session = makeSession();
|
||||
const { result } = setup({ session });
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.profileUser.username).toBe("ann");
|
||||
expect(s.items[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(s.hasMore).toBe(true);
|
||||
const [req] = calls("GET /api/users/:u/dumps");
|
||||
expect(req.url.searchParams.get("page")).toBe("1");
|
||||
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
});
|
||||
|
||||
test("an unknown user is an error", async () => {
|
||||
route("GET /api/users/:u", () => fail("User not found", 404));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("error"));
|
||||
});
|
||||
|
||||
test("an offline load says so", async () => {
|
||||
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result } = setup();
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringMatching(/Could not connect/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolling to the sentinel loads the next page", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeDump({ id: "a" })], true)
|
||||
: page([makeDump({ id: "b" })], false),
|
||||
);
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.report(true));
|
||||
await waitFor(() => {
|
||||
const s = result.current.state;
|
||||
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.hasMore).toBe(false);
|
||||
expect(s.page).toBe(2);
|
||||
});
|
||||
|
||||
test("changing user resets to loading", async () => {
|
||||
route(
|
||||
"GET /api/users/:u",
|
||||
(req) => wire(makeUser({ username: req.params.u })),
|
||||
);
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result, rerender } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
rerender({ username: "bob" });
|
||||
expect(result.current.state.status).toBe("loading");
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
});
|
||||
|
||||
test("setItems edits the loaded list", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
|
||||
);
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
act(() => result.current.setItems((prev) => prev.slice(1)));
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.items.map((d) => d.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
89
src/test/hooks/editor/useEmojiTrigger.test.tsx
Normal file
89
src/test/hooks/editor/useEmojiTrigger.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useEmojiTrigger } from "../../../hooks/editor/useEmojiTrigger.ts";
|
||||
|
||||
describe("useEmojiTrigger", () => {
|
||||
function setup() {
|
||||
const replace = vi.fn();
|
||||
const hook = renderHook(() => useEmojiTrigger(replace));
|
||||
const type = (value: string, cursor = value.length) =>
|
||||
act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
|
||||
return { replace, hook, type };
|
||||
}
|
||||
|
||||
test("opens inline on ':' and two letters", () => {
|
||||
const { hook, type } = setup();
|
||||
type("hello :s");
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
type("hello :sm");
|
||||
expect(hook.result.current.emojiMode).toBe("inline");
|
||||
expect(hook.result.current.emojiQuery).toBe("sm");
|
||||
});
|
||||
|
||||
test("emoticons, times and labels don't trigger", () => {
|
||||
const { hook, type } = setup();
|
||||
for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
|
||||
type(text);
|
||||
expect(hook.result.current.emojiMode, text).toBe("closed");
|
||||
}
|
||||
});
|
||||
|
||||
test("Unicode names work", () => {
|
||||
const { hook, type } = setup();
|
||||
type(":cœur");
|
||||
expect(hook.result.current.emojiQuery).toBe("cœur");
|
||||
});
|
||||
|
||||
test("not inside code", () => {
|
||||
const { hook, type } = setup();
|
||||
type("`:smile");
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("closes when the caret leaves the query", () => {
|
||||
const { hook, type } = setup();
|
||||
type(":smile");
|
||||
act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("a caret move alone never opens it", () => {
|
||||
const { hook } = setup();
|
||||
act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("an inline pick replaces the query and adds a space", () => {
|
||||
const { hook, type, replace } = setup();
|
||||
type("hi :smi");
|
||||
act(() => hook.result.current.selectEmoji("😄"));
|
||||
expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("browse mode inserts at the caret and ignores typing", () => {
|
||||
const { hook, type, replace } = setup();
|
||||
act(() => hook.result.current.openEmojiBrowser(2, 4));
|
||||
type("ab");
|
||||
expect(hook.result.current.emojiMode).toBe("browse");
|
||||
act(() => hook.result.current.setEmojiQuery("cat"));
|
||||
expect(hook.result.current.emojiQuery).toBe("cat");
|
||||
act(() => hook.result.current.selectEmoji("🐱"));
|
||||
expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
|
||||
});
|
||||
|
||||
test("selecting or querying while closed does nothing", () => {
|
||||
const { hook, replace } = setup();
|
||||
act(() => hook.result.current.setEmojiQuery("x"));
|
||||
act(() => hook.result.current.selectEmoji("😀"));
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(hook.result.current.emojiQuery).toBe("");
|
||||
});
|
||||
|
||||
test("closeEmoji", () => {
|
||||
const { hook, type } = setup();
|
||||
type(":smile");
|
||||
act(() => hook.result.current.closeEmoji());
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
|
||||
import {
|
||||
type MentionKey,
|
||||
useMentionAutocomplete,
|
||||
} from "../../hooks/useMentionAutocomplete.ts";
|
||||
import { calls, deferred, route } from "../fetch.ts";
|
||||
} from "../../../hooks/editor/useMentionAutocomplete.ts";
|
||||
import { calls, deferred, route } from "../../fetch.ts";
|
||||
|
||||
function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
|
||||
return {
|
||||
@@ -20,92 +19,6 @@ function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
|
||||
};
|
||||
}
|
||||
|
||||
describe("useEmojiTrigger", () => {
|
||||
function setup() {
|
||||
const replace = vi.fn();
|
||||
const hook = renderHook(() => useEmojiTrigger(replace));
|
||||
const type = (value: string, cursor = value.length) =>
|
||||
act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
|
||||
return { replace, hook, type };
|
||||
}
|
||||
|
||||
test("opens inline on ':' and two letters", () => {
|
||||
const { hook, type } = setup();
|
||||
type("hello :s");
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
type("hello :sm");
|
||||
expect(hook.result.current.emojiMode).toBe("inline");
|
||||
expect(hook.result.current.emojiQuery).toBe("sm");
|
||||
});
|
||||
|
||||
test("emoticons, times and labels don't trigger", () => {
|
||||
const { hook, type } = setup();
|
||||
for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
|
||||
type(text);
|
||||
expect(hook.result.current.emojiMode, text).toBe("closed");
|
||||
}
|
||||
});
|
||||
|
||||
test("Unicode names work", () => {
|
||||
const { hook, type } = setup();
|
||||
type(":cœur");
|
||||
expect(hook.result.current.emojiQuery).toBe("cœur");
|
||||
});
|
||||
|
||||
test("not inside code", () => {
|
||||
const { hook, type } = setup();
|
||||
type("`:smile");
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("closes when the caret leaves the query", () => {
|
||||
const { hook, type } = setup();
|
||||
type(":smile");
|
||||
act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("a caret move alone never opens it", () => {
|
||||
const { hook } = setup();
|
||||
act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("an inline pick replaces the query and adds a space", () => {
|
||||
const { hook, type, replace } = setup();
|
||||
type("hi :smi");
|
||||
act(() => hook.result.current.selectEmoji("😄"));
|
||||
expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
|
||||
test("browse mode inserts at the caret and ignores typing", () => {
|
||||
const { hook, type, replace } = setup();
|
||||
act(() => hook.result.current.openEmojiBrowser(2, 4));
|
||||
type("ab");
|
||||
expect(hook.result.current.emojiMode).toBe("browse");
|
||||
act(() => hook.result.current.setEmojiQuery("cat"));
|
||||
expect(hook.result.current.emojiQuery).toBe("cat");
|
||||
act(() => hook.result.current.selectEmoji("🐱"));
|
||||
expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
|
||||
});
|
||||
|
||||
test("selecting or querying while closed does nothing", () => {
|
||||
const { hook, replace } = setup();
|
||||
act(() => hook.result.current.setEmojiQuery("x"));
|
||||
act(() => hook.result.current.selectEmoji("😀"));
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(hook.result.current.emojiQuery).toBe("");
|
||||
});
|
||||
|
||||
test("closeEmoji", () => {
|
||||
const { hook, type } = setup();
|
||||
type(":smile");
|
||||
act(() => hook.result.current.closeEmoji());
|
||||
expect(hook.result.current.emojiMode).toBe("closed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMentionAutocomplete", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
15
src/test/hooks/feed/useDefaultFeedTab.test.tsx
Normal file
15
src/test/hooks/feed/useDefaultFeedTab.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useDefaultFeedTab } from "../../../hooks/feed/useDefaultFeedTab.ts";
|
||||
|
||||
describe("useDefaultFeedTab", () => {
|
||||
test("validates and persists", () => {
|
||||
localStorage.setItem("default-feed-tab", "bogus");
|
||||
const { result } = renderHook(useDefaultFeedTab);
|
||||
expect(result.current[0]).toBe("journal");
|
||||
act(() => result.current[1]("new"));
|
||||
expect(result.current[0]).toBe("new");
|
||||
expect(localStorage.getItem("default-feed-tab")).toBe("new");
|
||||
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
|
||||
});
|
||||
});
|
||||
55
src/test/hooks/feed/useFading.test.tsx
Normal file
55
src/test/hooks/feed/useFading.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useFading } from "../../../hooks/feed/useFading.ts";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useFading", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
expect(result.current.fading).toEqual({ a: "cooldown" });
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(result.current.fading).toEqual({ a: "dismissing" });
|
||||
act(() => vi.advanceTimersByTime(350));
|
||||
expect(result.current.fading).toEqual({});
|
||||
});
|
||||
|
||||
test("starting twice is one animation", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(result.current.fading).toEqual({ a: "dismissing" });
|
||||
});
|
||||
|
||||
test("cancel during cooldown and during dismissal", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => result.current.cancelFading("a"));
|
||||
expect(result.current.fading).toEqual({});
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
expect(result.current.fading).toEqual({});
|
||||
|
||||
act(() => result.current.startFading("b"));
|
||||
act(() => vi.advanceTimersByTime(2100));
|
||||
act(() => result.current.cancelFading("b"));
|
||||
expect(result.current.fading).toEqual({});
|
||||
// Cancelled means it can start over.
|
||||
act(() => result.current.startFading("b"));
|
||||
expect(result.current.fading).toEqual({ b: "cooldown" });
|
||||
});
|
||||
|
||||
test("cancelAll", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => {
|
||||
result.current.startFading("a");
|
||||
result.current.startFading("b");
|
||||
});
|
||||
act(() => result.current.cancelAll());
|
||||
expect(result.current.fading).toEqual({});
|
||||
});
|
||||
});
|
||||
97
src/test/hooks/feed/useFeedCache.test.tsx
Normal file
97
src/test/hooks/feed/useFeedCache.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from "react-router";
|
||||
import { useFeedCache } from "../../../hooks/feed/useFeedCache.ts";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useFeedCache", () => {
|
||||
// A MemoryRouter's first entry is a POP, as after Back.
|
||||
const wrapper = () => ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
);
|
||||
|
||||
test("restores a saved feed on a back/forward (POP) visit", () => {
|
||||
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
|
||||
const second = renderHook(
|
||||
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
|
||||
{ wrapper: wrapper() },
|
||||
);
|
||||
expect(second.result.current.cached).toMatchObject({
|
||||
items: [{ n: 10 }],
|
||||
page: 2,
|
||||
hasMore: true,
|
||||
scrollY: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test("an expired entry is ignored", () => {
|
||||
vi.useFakeTimers();
|
||||
const first = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([], 1, false, 0));
|
||||
vi.advanceTimersByTime(11 * 60 * 1000);
|
||||
const second = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
expect(second.result.current.cached).toBeNull();
|
||||
});
|
||||
|
||||
test("a fresh (PUSH) visit ignores the cache", () => {
|
||||
sessionStorage.setItem(
|
||||
"k",
|
||||
JSON.stringify({
|
||||
items: [1],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
scrollY: 0,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
function Probe() {
|
||||
const { cached } = useFeedCache("k", (x) => x);
|
||||
return <p>{cached ? "cached" : "fresh"}</p>;
|
||||
}
|
||||
function Home() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button type="button" onClick={() => navigate("/feed")}>
|
||||
go
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/feed" element={<Probe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("go"));
|
||||
expect(screen.getByText("fresh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
|
||||
sessionStorage.setItem("k", "{");
|
||||
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
expect(result.current.cached).toBeNull();
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
115
src/test/hooks/feed/useInfiniteScroll.test.tsx
Normal file
115
src/test/hooks/feed/useInfiniteScroll.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useInfiniteScroll } from "../../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { FakeIntersectionObserver } from "../../setup.ts";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useInfiniteScroll", () => {
|
||||
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
|
||||
const ref = useInfiniteScroll(onMore, enabled);
|
||||
return <div ref={ref} data-testid="sentinel" />;
|
||||
}
|
||||
const intersect = (isIntersecting: boolean) => {
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.report(isIntersecting));
|
||||
};
|
||||
|
||||
test("loads more when the sentinel shows, only while enabled", () => {
|
||||
const onMore = vi.fn();
|
||||
const { rerender } = render(<Feed onMore={onMore} enabled />);
|
||||
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
|
||||
intersect(false);
|
||||
expect(onMore).not.toHaveBeenCalled();
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
rerender(<Feed onMore={onMore} enabled={false} />);
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A load that fails re-enables the feed with the sentinel still in view: a
|
||||
// browser's new observer reports it at once, and without a pause that's a
|
||||
// request loop as fast as the request can fail.
|
||||
test("a sentinel that stays in view waits longer each time", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
render(<Feed onMore={onMore} enabled />);
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
// Reported again right after each load, as a new observer does: how long
|
||||
// until the next one?
|
||||
const delays: number[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const before = onMore.mock.calls.length;
|
||||
intersect(true);
|
||||
let waited = 0;
|
||||
while (onMore.mock.calls.length === before) {
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
waited += 100;
|
||||
}
|
||||
delays.push(waited);
|
||||
}
|
||||
expect(delays).toEqual([
|
||||
1000,
|
||||
2000,
|
||||
4000,
|
||||
8000,
|
||||
16000,
|
||||
30000,
|
||||
30000,
|
||||
30000,
|
||||
]);
|
||||
});
|
||||
|
||||
test("re-rendering doesn't restart the wait", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
const { rerender } = render(<Feed onMore={onMore} enabled />);
|
||||
intersect(true);
|
||||
intersect(true);
|
||||
act(() => vi.advanceTimersByTime(600));
|
||||
// A new onLoadMore (the feed's state changed) remakes the observer.
|
||||
rerender(<Feed onMore={() => onMore()} enabled />);
|
||||
intersect(true);
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("leaving the view, or new items pushing it down, asks at once again", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
render(<Feed onMore={onMore} enabled />);
|
||||
const at = (top: number) => {
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.report(true, top));
|
||||
};
|
||||
at(500);
|
||||
at(500);
|
||||
expect(onMore).toHaveBeenCalledTimes(1); // waiting
|
||||
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
|
||||
at(500);
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
at(500);
|
||||
at(900); // the page grew: that load worked
|
||||
expect(onMore).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("a pending retry is dropped when the feed is disabled or unmounted", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
const { rerender, unmount } = render(<Feed onMore={onMore} enabled />);
|
||||
intersect(true);
|
||||
intersect(true);
|
||||
rerender(<Feed onMore={onMore} enabled={false} />);
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
rerender(<Feed onMore={onMore} enabled />);
|
||||
intersect(true); // long overdue: at once
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
intersect(true);
|
||||
unmount();
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
91
src/test/hooks/feed/usePositionAwareSync.test.tsx
Normal file
91
src/test/hooks/feed/usePositionAwareSync.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
defaultWSValue,
|
||||
WSContext,
|
||||
type WSContextValue,
|
||||
} from "../../../contexts/ws/WSContext.ts";
|
||||
import type { Dump, Playlist } from "../../../model.ts";
|
||||
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
|
||||
import { usePositionAwareSync } from "../../../hooks/feed/usePositionAwareSync.ts";
|
||||
import { makeDump, makePlaylist } from "../../fixtures.ts";
|
||||
|
||||
/** A WS context whose value a test can push new events into. */
|
||||
function liveWS() {
|
||||
let push: (patch: Partial<WSContextValue>) => void = () => {};
|
||||
const base = defaultWSValue;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [value, setValue] = useState(base);
|
||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
||||
}
|
||||
return {
|
||||
Wrapper,
|
||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
||||
};
|
||||
}
|
||||
|
||||
function useList<T>(initial: T[]) {
|
||||
const [items, setItems] = useState(initial);
|
||||
return { items, setItems };
|
||||
}
|
||||
|
||||
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
||||
|
||||
describe("usePositionAwareSync", () => {
|
||||
test("a dump that goes private and back returns to its old place", () => {
|
||||
const ws = liveWS();
|
||||
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
|
||||
// Wire the pair exactly as UserPublicProfile does.
|
||||
const both = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const [event, setEvent] = useState<Dump | null>(null);
|
||||
usePositionAwareSync(
|
||||
items,
|
||||
setItems,
|
||||
event,
|
||||
(d) => d.isPrivate,
|
||||
(d) => !d.isPrivate,
|
||||
);
|
||||
useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
|
||||
return { items, setEvent };
|
||||
}, { wrapper: ws.Wrapper });
|
||||
|
||||
const b = initial[1];
|
||||
act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
|
||||
ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
|
||||
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
|
||||
|
||||
act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
|
||||
ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
|
||||
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("a position is only used once, and clamps to the list end", () => {
|
||||
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
|
||||
const hook = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const [event, setEvent] = useState<Playlist | null>(null);
|
||||
usePositionAwareSync(
|
||||
items,
|
||||
setItems,
|
||||
event,
|
||||
(p) => !p.isPublic,
|
||||
(p) => p.isPublic,
|
||||
);
|
||||
return { items, setItems, setEvent };
|
||||
});
|
||||
const c = initial[2];
|
||||
act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
|
||||
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
|
||||
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
|
||||
// Nothing saved any more: a second public event is left alone.
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
|
||||
act(() =>
|
||||
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
|
||||
);
|
||||
expect(ids(hook.result.current.items)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
52
src/test/hooks/feed/useScrollSave.test.tsx
Normal file
52
src/test/hooks/feed/useScrollSave.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useScrollSave } from "../../../hooks/feed/useScrollSave.ts";
|
||||
|
||||
describe("useScrollSave", () => {
|
||||
const scrollTo = (y: number) => {
|
||||
Object.defineProperty(globalThis, "scrollY", {
|
||||
value: y,
|
||||
configurable: true,
|
||||
});
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
};
|
||||
|
||||
test("saves the last position when the page is left, not while scrolling", () => {
|
||||
const onSave = vi.fn();
|
||||
const { unmount } = renderHook(() => useScrollSave(true, onSave));
|
||||
scrollTo(100);
|
||||
scrollTo(250);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
// The next page has already scrolled back up by the time this unmounts.
|
||||
Object.defineProperty(globalThis, "scrollY", {
|
||||
value: 0,
|
||||
configurable: true,
|
||||
});
|
||||
unmount();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
|
||||
});
|
||||
|
||||
test("saves on pagehide, with the latest callback", () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ cb }) => useScrollSave(true, cb),
|
||||
{ initialProps: { cb: first } },
|
||||
);
|
||||
scrollTo(40);
|
||||
rerender({ cb: second });
|
||||
globalThis.dispatchEvent(new Event("pagehide"));
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledExactlyOnceWith(40);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("nothing is tracked or saved while disabled", () => {
|
||||
const onSave = vi.fn();
|
||||
const { unmount } = renderHook(() => useScrollSave(false, onSave));
|
||||
scrollTo(10);
|
||||
globalThis.dispatchEvent(new Event("pagehide"));
|
||||
unmount();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,615 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router";
|
||||
import { useFading } from "../../hooks/useFading.ts";
|
||||
import { useFeedCache } from "../../hooks/useFeedCache.ts";
|
||||
import { useTabParam } from "../../hooks/useTabParam.ts";
|
||||
import {
|
||||
setTitleBadge,
|
||||
SITE_NAME,
|
||||
useDocumentTitle,
|
||||
} from "../../hooks/useDocumentTitle.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
|
||||
import { useScrollLock } from "../../hooks/useScrollLock.ts";
|
||||
import { useScrollSave } from "../../hooks/useScrollSave.ts";
|
||||
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
|
||||
import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts";
|
||||
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
|
||||
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
|
||||
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
|
||||
import { isDumpVisited } from "../../utils/visited.ts";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useFading", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
expect(result.current.fading).toEqual({ a: "cooldown" });
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(result.current.fading).toEqual({ a: "dismissing" });
|
||||
act(() => vi.advanceTimersByTime(350));
|
||||
expect(result.current.fading).toEqual({});
|
||||
});
|
||||
|
||||
test("starting twice is one animation", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(result.current.fading).toEqual({ a: "dismissing" });
|
||||
});
|
||||
|
||||
test("cancel during cooldown and during dismissal", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => result.current.cancelFading("a"));
|
||||
expect(result.current.fading).toEqual({});
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
expect(result.current.fading).toEqual({});
|
||||
|
||||
act(() => result.current.startFading("b"));
|
||||
act(() => vi.advanceTimersByTime(2100));
|
||||
act(() => result.current.cancelFading("b"));
|
||||
expect(result.current.fading).toEqual({});
|
||||
// Cancelled means it can start over.
|
||||
act(() => result.current.startFading("b"));
|
||||
expect(result.current.fading).toEqual({ b: "cooldown" });
|
||||
});
|
||||
|
||||
test("cancelAll", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => {
|
||||
result.current.startFading("a");
|
||||
result.current.startFading("b");
|
||||
});
|
||||
act(() => result.current.cancelAll());
|
||||
expect(result.current.fading).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFeedCache", () => {
|
||||
// A MemoryRouter's first entry is a POP, as after Back.
|
||||
const wrapper = () => ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
);
|
||||
|
||||
test("restores a saved feed on a back/forward (POP) visit", () => {
|
||||
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
|
||||
const second = renderHook(
|
||||
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
|
||||
{ wrapper: wrapper() },
|
||||
);
|
||||
expect(second.result.current.cached).toMatchObject({
|
||||
items: [{ n: 10 }],
|
||||
page: 2,
|
||||
hasMore: true,
|
||||
scrollY: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test("an expired entry is ignored", () => {
|
||||
vi.useFakeTimers();
|
||||
const first = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([], 1, false, 0));
|
||||
vi.advanceTimersByTime(11 * 60 * 1000);
|
||||
const second = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
expect(second.result.current.cached).toBeNull();
|
||||
});
|
||||
|
||||
test("a fresh (PUSH) visit ignores the cache", () => {
|
||||
sessionStorage.setItem(
|
||||
"k",
|
||||
JSON.stringify({
|
||||
items: [1],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
scrollY: 0,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
function Probe() {
|
||||
const { cached } = useFeedCache("k", (x) => x);
|
||||
return <p>{cached ? "cached" : "fresh"}</p>;
|
||||
}
|
||||
function Home() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button type="button" onClick={() => navigate("/feed")}>
|
||||
go
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/feed" element={<Probe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("go"));
|
||||
expect(screen.getByText("fresh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
|
||||
sessionStorage.setItem("k", "{");
|
||||
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
expect(result.current.cached).toBeNull();
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTabParam", () => {
|
||||
const TABS = ["dumps", "playlists", "votes"] as const;
|
||||
function setup(path: string, state?: unknown) {
|
||||
let loc!: ReturnType<typeof useLocation>;
|
||||
const hook = renderHook(() => {
|
||||
loc = useLocation();
|
||||
return useTabParam(TABS, "dumps");
|
||||
}, {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter
|
||||
initialEntries={[{
|
||||
pathname: path.split("?")[0],
|
||||
search: path.includes("?") ? "?" + path.split("?")[1] : "",
|
||||
state,
|
||||
}]}
|
||||
>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
return { hook, loc: () => loc };
|
||||
}
|
||||
|
||||
test("reads the tab after /~/", () => {
|
||||
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
|
||||
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
|
||||
});
|
||||
|
||||
test("the default tab collapses to the base path", () => {
|
||||
const { hook, loc } = setup("/users/ann/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/users/ann");
|
||||
act(() => hook.result.current[1]("playlists"));
|
||||
expect(loc().pathname).toBe("/users/ann/~/playlists");
|
||||
});
|
||||
|
||||
test("at the root the default stays explicit", () => {
|
||||
const { hook, loc } = setup("/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/~/dumps");
|
||||
});
|
||||
|
||||
test("keeps the query string and the entry's state", () => {
|
||||
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
|
||||
act(() => hook.result.current[1]("votes"));
|
||||
expect(loc().pathname).toBe("/search/~/votes");
|
||||
expect(loc().search).toBe("?q=cats");
|
||||
expect(loc().state).toEqual({ back: "/x" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDocumentTitle", () => {
|
||||
test("composes page, site name and unread badge", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ title }: { title: string | null | undefined }) =>
|
||||
useDocumentTitle(title),
|
||||
{ initialProps: { title: "Cats" as string | null | undefined } },
|
||||
);
|
||||
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(3));
|
||||
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(150));
|
||||
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
|
||||
rerender({ title: null });
|
||||
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(0));
|
||||
expect(document.title).toBe(SITE_NAME);
|
||||
// undefined leaves it be
|
||||
rerender({ title: undefined });
|
||||
expect(document.title).toBe(SITE_NAME);
|
||||
});
|
||||
|
||||
test("the site name defaults to gerbeur when unsubstituted", () => {
|
||||
expect(SITE_NAME).toBe("gerbeur");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useInfiniteScroll", () => {
|
||||
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
|
||||
const ref = useInfiniteScroll(onMore, enabled);
|
||||
return <div ref={ref} data-testid="sentinel" />;
|
||||
}
|
||||
const intersect = (isIntersecting: boolean) => {
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.report(isIntersecting));
|
||||
};
|
||||
|
||||
test("loads more when the sentinel shows, only while enabled", () => {
|
||||
const onMore = vi.fn();
|
||||
const { rerender } = render(<Feed onMore={onMore} enabled />);
|
||||
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
|
||||
intersect(false);
|
||||
expect(onMore).not.toHaveBeenCalled();
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
rerender(<Feed onMore={onMore} enabled={false} />);
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A load that fails re-enables the feed with the sentinel still in view: a
|
||||
// browser's new observer reports it at once, and without a pause that's a
|
||||
// request loop as fast as the request can fail.
|
||||
test("a sentinel that stays in view waits longer each time", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
render(<Feed onMore={onMore} enabled />);
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
// Reported again right after each load, as a new observer does: how long
|
||||
// until the next one?
|
||||
const delays: number[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const before = onMore.mock.calls.length;
|
||||
intersect(true);
|
||||
let waited = 0;
|
||||
while (onMore.mock.calls.length === before) {
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
waited += 100;
|
||||
}
|
||||
delays.push(waited);
|
||||
}
|
||||
expect(delays).toEqual([
|
||||
1000,
|
||||
2000,
|
||||
4000,
|
||||
8000,
|
||||
16000,
|
||||
30000,
|
||||
30000,
|
||||
30000,
|
||||
]);
|
||||
});
|
||||
|
||||
test("re-rendering doesn't restart the wait", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
const { rerender } = render(<Feed onMore={onMore} enabled />);
|
||||
intersect(true);
|
||||
intersect(true);
|
||||
act(() => vi.advanceTimersByTime(600));
|
||||
// A new onLoadMore (the feed's state changed) remakes the observer.
|
||||
rerender(<Feed onMore={() => onMore()} enabled />);
|
||||
intersect(true);
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("leaving the view, or new items pushing it down, asks at once again", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
render(<Feed onMore={onMore} enabled />);
|
||||
const at = (top: number) => {
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.report(true, top));
|
||||
};
|
||||
at(500);
|
||||
at(500);
|
||||
expect(onMore).toHaveBeenCalledTimes(1); // waiting
|
||||
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
|
||||
at(500);
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
at(500);
|
||||
at(900); // the page grew: that load worked
|
||||
expect(onMore).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("a pending retry is dropped when the feed is disabled or unmounted", () => {
|
||||
vi.useFakeTimers();
|
||||
const onMore = vi.fn();
|
||||
const { rerender, unmount } = render(<Feed onMore={onMore} enabled />);
|
||||
intersect(true);
|
||||
intersect(true);
|
||||
rerender(<Feed onMore={onMore} enabled={false} />);
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
rerender(<Feed onMore={onMore} enabled />);
|
||||
intersect(true); // long overdue: at once
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
intersect(true);
|
||||
unmount();
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useScrollLock", () => {
|
||||
function Lock() {
|
||||
useScrollLock();
|
||||
return null;
|
||||
}
|
||||
test("counts nested locks", () => {
|
||||
const root = document.documentElement;
|
||||
const a = render(<Lock />);
|
||||
const b = render(<Lock />);
|
||||
expect(root.style.overflowY).toBe("hidden");
|
||||
a.unmount();
|
||||
expect(root.style.overflowY).toBe("hidden");
|
||||
b.unmount();
|
||||
expect(root.style.overflowY).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useScrollSave", () => {
|
||||
const scrollTo = (y: number) => {
|
||||
Object.defineProperty(globalThis, "scrollY", {
|
||||
value: y,
|
||||
configurable: true,
|
||||
});
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
};
|
||||
|
||||
test("saves the last position when the page is left, not while scrolling", () => {
|
||||
const onSave = vi.fn();
|
||||
const { unmount } = renderHook(() => useScrollSave(true, onSave));
|
||||
scrollTo(100);
|
||||
scrollTo(250);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
// The next page has already scrolled back up by the time this unmounts.
|
||||
Object.defineProperty(globalThis, "scrollY", {
|
||||
value: 0,
|
||||
configurable: true,
|
||||
});
|
||||
unmount();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
|
||||
});
|
||||
|
||||
test("saves on pagehide, with the latest callback", () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ cb }) => useScrollSave(true, cb),
|
||||
{ initialProps: { cb: first } },
|
||||
);
|
||||
scrollTo(40);
|
||||
rerender({ cb: second });
|
||||
globalThis.dispatchEvent(new Event("pagehide"));
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledExactlyOnceWith(40);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("nothing is tracked or saved while disabled", () => {
|
||||
const onSave = vi.fn();
|
||||
const { unmount } = renderHook(() => useScrollSave(false, onSave));
|
||||
scrollTo(10);
|
||||
globalThis.dispatchEvent(new Event("pagehide"));
|
||||
unmount();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stored preferences", () => {
|
||||
test("useDefaultFeedTab validates and persists", () => {
|
||||
localStorage.setItem("default-feed-tab", "bogus");
|
||||
const { result } = renderHook(useDefaultFeedTab);
|
||||
expect(result.current[0]).toBe("journal");
|
||||
act(() => result.current[1]("new"));
|
||||
expect(result.current[0]).toBe("new");
|
||||
expect(localStorage.getItem("default-feed-tab")).toBe("new");
|
||||
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
|
||||
});
|
||||
|
||||
test("useSoundsEnabled", () => {
|
||||
const { result } = renderHook(useSoundsEnabled);
|
||||
expect(result.current[0]).toBe(true);
|
||||
act(() => result.current[1](false));
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(localStorage.getItem("sounds")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDumpCardLink", () => {
|
||||
function Card() {
|
||||
const link = useDumpCardLink({ id: "d1", slug: "hello" });
|
||||
return (
|
||||
<div data-testid="card" {...link.cardProps}>
|
||||
<a href="/elsewhere">inner link</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
|
||||
}}
|
||||
>
|
||||
play
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function setup() {
|
||||
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Card />} />
|
||||
<Route path="/dumps/:id" element={<p>dump page</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
return { open };
|
||||
}
|
||||
|
||||
test("a click navigates and marks visited", () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByTestId("card"));
|
||||
expect(screen.getByText("dump page")).toBeInTheDocument();
|
||||
expect(isDumpVisited("d1")).toBe(true);
|
||||
});
|
||||
|
||||
test("ctrl/cmd/shift-click opens a new tab", () => {
|
||||
const { open } = setup();
|
||||
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
|
||||
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
|
||||
});
|
||||
|
||||
test("a middle click opens a new tab, except over a link", () => {
|
||||
const { open } = setup();
|
||||
const mouseDown = new MouseEvent("mousedown", {
|
||||
button: 1,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
screen.getByTestId("card").dispatchEvent(mouseDown);
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
fireEvent(
|
||||
screen.getByTestId("card"),
|
||||
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(
|
||||
screen.getByText("inner link"),
|
||||
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(
|
||||
screen.getByTestId("card"),
|
||||
new MouseEvent("auxclick", { button: 2, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a modified click on the play button opens the dump instead of playing", () => {
|
||||
const { open } = setup();
|
||||
const play = screen.getByText("play");
|
||||
fireEvent.click(play, { metaKey: true });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(play.dataset.played).toBeUndefined();
|
||||
fireEvent.click(play);
|
||||
expect(play.dataset.played).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAnchoredFloat", () => {
|
||||
function Float({ open, top }: { open: boolean; top: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const style = useAnchoredFloat(ref, open, {
|
||||
width: 300,
|
||||
chromeHeight: 40,
|
||||
minContentHeight: 100,
|
||||
maxContentHeight: 400,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={(el) => {
|
||||
ref.current = el;
|
||||
if (el) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
top,
|
||||
bottom: top + 20,
|
||||
left: 900,
|
||||
right: 1000,
|
||||
width: 100,
|
||||
height: 20,
|
||||
}) as DOMRect;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<output data-testid="style">{JSON.stringify(style)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("innerWidth", 1024);
|
||||
vi.stubGlobal("innerHeight", 768);
|
||||
});
|
||||
|
||||
test("null while closed", () => {
|
||||
render(<Float open={false} top={100} />);
|
||||
expect(styleOf()).toBeNull();
|
||||
});
|
||||
|
||||
test("opens below an anchor near the top, clamped into the viewport", () => {
|
||||
render(<Float open top={100} />);
|
||||
const s = styleOf();
|
||||
expect(s.position).toBe("fixed");
|
||||
expect(s.top).toBe(124);
|
||||
expect(s.bottom).toBe("auto");
|
||||
expect(s.left).toBe(1024 - 300 - 8);
|
||||
expect(s["--float-max-h"]).toBe("400px");
|
||||
});
|
||||
|
||||
test("flips above an anchor near the bottom", () => {
|
||||
render(<Float open top={700} />);
|
||||
const s = styleOf();
|
||||
expect(s.bottom).toBe(768 - 700 + 4);
|
||||
expect(s.top).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHeightTransition", () => {
|
||||
test("animates from the old height to the new one when watch changes", () => {
|
||||
const animate = vi.spyOn(Element.prototype, "animate");
|
||||
let height = 100;
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
|
||||
() => ({ height }) as DOMRect,
|
||||
);
|
||||
function Box({ open }: { open: boolean }) {
|
||||
const ref = useHeightTransition<HTMLDivElement>(open);
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
const { rerender } = render(<Box open={false} />);
|
||||
height = 250;
|
||||
rerender(<Box open />);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
expect(animate.mock.calls[0][0]).toEqual([
|
||||
expect.objectContaining({ height: "100px" }),
|
||||
expect.objectContaining({ height: "250px" }),
|
||||
]);
|
||||
|
||||
// No change in height, no animation.
|
||||
rerender(<Box open={false} />);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
112
src/test/hooks/player/usePlayRichContent.test.tsx
Normal file
112
src/test/hooks/player/usePlayRichContent.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { fail, route } from "../../fetch.ts";
|
||||
|
||||
describe("usePlayRichContent", () => {
|
||||
// The Bandcamp mode is read from a <meta> tag once, at module load.
|
||||
async function load(mode: "embed" | "native") {
|
||||
document.head.innerHTML = `<meta name="bandcamp-player" content="${mode}">`;
|
||||
vi.resetModules();
|
||||
const hookMod = await import("../../../hooks/player/usePlayRichContent.ts");
|
||||
const ctxMod = await import("../../../contexts/player/PlayerContext.ts");
|
||||
const play = vi.fn();
|
||||
const playQueue = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ctxMod.PlayerContext.Provider
|
||||
value={{ ...ctxMod.defaultPlayerValue, play, playQueue }}
|
||||
>
|
||||
{children}
|
||||
</ctxMod.PlayerContext.Provider>
|
||||
);
|
||||
const hook = renderHook(hookMod.usePlayRichContent, { wrapper });
|
||||
return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent };
|
||||
}
|
||||
afterEach(() => {
|
||||
document.head.innerHTML = "";
|
||||
});
|
||||
|
||||
const yt = {
|
||||
type: "youtube",
|
||||
url: "https://youtu.be/x",
|
||||
title: "Vid",
|
||||
embedUrl: "https://www.youtube.com/embed/x",
|
||||
thumbnailUrl: "https://i.ytimg.com/x.jpg",
|
||||
siteName: "YouTube",
|
||||
};
|
||||
const bc = {
|
||||
type: "bandcamp",
|
||||
url: "https://a.bandcamp.com/album/x",
|
||||
title: "Album",
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
|
||||
};
|
||||
|
||||
test("embeds play in the iframe", async () => {
|
||||
const { hook, play } = await load("embed");
|
||||
let started!: boolean;
|
||||
await act(async () => {
|
||||
started = await hook.result.current.playRichContent(yt, "/dumps/1");
|
||||
});
|
||||
expect(started).toBe(true);
|
||||
expect(play).toHaveBeenCalledWith({
|
||||
kind: "embed",
|
||||
embedUrl: yt.embedUrl,
|
||||
title: "Vid",
|
||||
type: "youtube",
|
||||
dumpHref: "/dumps/1",
|
||||
artworkUrl: yt.thumbnailUrl,
|
||||
subtitle: "YouTube",
|
||||
});
|
||||
});
|
||||
|
||||
test("nothing to embed: not started", async () => {
|
||||
const { hook, play, canPlay } = await load("embed");
|
||||
const bare = { type: "link", url: "https://x" };
|
||||
expect(canPlay(bare)).toBe(false);
|
||||
let started = true;
|
||||
await act(async () => {
|
||||
started = await hook.result.current.playRichContent(bare);
|
||||
});
|
||||
expect(started).toBe(false);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("native Bandcamp plays a resolved queue", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => ({
|
||||
sourceUrl: bc.url,
|
||||
itemType: "album",
|
||||
resolvedAt: 1,
|
||||
tracks: [{
|
||||
index: 0,
|
||||
title: "T",
|
||||
streamUrl: "https://s/0",
|
||||
streamable: true,
|
||||
capped: false,
|
||||
}],
|
||||
}));
|
||||
const { hook, playQueue, canPlay } = await load("native");
|
||||
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
|
||||
await act(() => hook.result.current.playRichContent(bc));
|
||||
expect(playQueue).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })],
|
||||
0,
|
||||
);
|
||||
expect(hook.result.current.pending).toBe(false);
|
||||
});
|
||||
|
||||
test("native Bandcamp that can't resolve falls back to the embed", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
route("GET /api/bandcamp/tracks", () => fail("x", 500));
|
||||
const { hook, play, playQueue } = await load("native");
|
||||
await act(() => hook.result.current.playRichContent(bc));
|
||||
expect(playQueue).not.toHaveBeenCalled();
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "embed" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown mode reads as embed", async () => {
|
||||
const { canPlay } = await load("bogus" as "embed");
|
||||
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false);
|
||||
});
|
||||
});
|
||||
139
src/test/hooks/player/useYouTubePlaylist.test.tsx
Normal file
139
src/test/hooks/player/useYouTubePlaylist.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.head.querySelectorAll("script").forEach((s) => s.remove());
|
||||
});
|
||||
|
||||
describe("useYouTubePlaylist", () => {
|
||||
type Events = {
|
||||
onReady?: () => void;
|
||||
onStateChange?: (e: { data: number }) => void;
|
||||
};
|
||||
function installYT(ids = ["a", "b", "c"]) {
|
||||
const player = {
|
||||
events: {} as Events,
|
||||
index: 0,
|
||||
playVideo: vi.fn(),
|
||||
playVideoAt: vi.fn(),
|
||||
nextVideo: vi.fn(),
|
||||
previousVideo: vi.fn(),
|
||||
getPlaylist: () => ids,
|
||||
getPlaylistIndex() {
|
||||
return this.index;
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
// Forwards to `player`, which the tests inspect and steer.
|
||||
class Player {
|
||||
constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
|
||||
player.events = opts.events;
|
||||
}
|
||||
playVideo = player.playVideo;
|
||||
playVideoAt = player.playVideoAt;
|
||||
nextVideo = player.nextVideo;
|
||||
previousVideo = player.previousVideo;
|
||||
getPlaylist = player.getPlaylist;
|
||||
getPlaylistIndex = () => player.index;
|
||||
destroy = player.destroy;
|
||||
}
|
||||
window.YT = { Player };
|
||||
return player;
|
||||
}
|
||||
|
||||
// The IFrame API loads once per document and is cached in the module.
|
||||
async function setup(embedUrl: string | null) {
|
||||
vi.resetModules();
|
||||
const { useYouTubePlaylist } = await import(
|
||||
"../../../hooks/player/useYouTubePlaylist.ts"
|
||||
);
|
||||
return renderHook(({ url }) => {
|
||||
const ref = useRef<HTMLIFrameElement | null>(
|
||||
document.createElement("iframe"),
|
||||
);
|
||||
return useYouTubePlaylist(url, ref);
|
||||
}, { initialProps: { url: embedUrl } });
|
||||
}
|
||||
|
||||
test("titles from the server, order and position from the player", async () => {
|
||||
// The API script loads once per document; later tests find YT ready.
|
||||
const player = installYT();
|
||||
route("GET /api/youtube/playlist", () => ({
|
||||
tracks: [
|
||||
{ videoId: "a", title: "A", duration: 10 },
|
||||
{ videoId: "c", title: "C", duration: 30 },
|
||||
],
|
||||
}));
|
||||
const { result } = await setup(
|
||||
"https://www.youtube.com/embed/videoseries?list=PL1",
|
||||
);
|
||||
await waitFor(() => expect(result.current.rows).toHaveLength(2));
|
||||
expect(result.current.ready).toBe(false);
|
||||
await waitFor(() => expect(player.events.onReady).toBeDefined());
|
||||
player.index = 1;
|
||||
act(() => player.events.onReady!());
|
||||
expect(result.current.ready).toBe(true);
|
||||
expect(result.current.index).toBe(1);
|
||||
// The player's list wins, matched to the titles by id.
|
||||
expect(result.current.rows).toEqual([
|
||||
{ videoId: "a", title: "A", duration: 10 },
|
||||
{ videoId: "b", title: undefined, duration: undefined },
|
||||
{ videoId: "c", title: "C", duration: 30 },
|
||||
]);
|
||||
act(() => result.current.playAt(2));
|
||||
act(() => result.current.next());
|
||||
act(() => result.current.previous());
|
||||
expect(player.playVideoAt).toHaveBeenCalledWith(2);
|
||||
expect(player.nextVideo).toHaveBeenCalled();
|
||||
expect(player.previousVideo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
|
||||
const player = installYT();
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
await setup("https://www.youtube.com/embed/x?list=PL2");
|
||||
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
act(() => player.events.onStateChange!({ data: 2 }));
|
||||
}
|
||||
expect(player.playVideo).toHaveBeenCalledTimes(3);
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
act(() => player.events.onStateChange!({ data: 2 }));
|
||||
expect(player.playVideo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("no list in the URL: no rows, no requests", async () => {
|
||||
const { result } = await setup("https://www.youtube.com/embed/x");
|
||||
expect(result.current.rows).toEqual([]);
|
||||
expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a new embed drops the old list; unmount destroys the player", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const player = installYT();
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
const { result, rerender, unmount } = await setup(
|
||||
"https://www.youtube.com/embed/x?list=PL3",
|
||||
);
|
||||
await waitFor(() => expect(result.current.rows).toHaveLength(1));
|
||||
await waitFor(() => expect(player.events.onReady).toBeDefined());
|
||||
route("GET /api/youtube/playlist", () => fail("x", 500));
|
||||
rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
|
||||
expect(result.current.rows).toEqual([]);
|
||||
expect(result.current.index).toBe(-1);
|
||||
unmount();
|
||||
expect(player.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
165
src/test/hooks/playlist/usePlaylistListSync.test.tsx
Normal file
165
src/test/hooks/playlist/usePlaylistListSync.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
defaultWSValue,
|
||||
WSContext,
|
||||
type WSContextValue,
|
||||
} from "../../../contexts/ws/WSContext.ts";
|
||||
import type { Playlist } from "../../../model.ts";
|
||||
import { usePlaylistListSync } from "../../../hooks/playlist/usePlaylistListSync.ts";
|
||||
import { makePlaylist } from "../../fixtures.ts";
|
||||
|
||||
/** A WS context whose value a test can push new events into. */
|
||||
function liveWS() {
|
||||
let push: (patch: Partial<WSContextValue>) => void = () => {};
|
||||
const base = defaultWSValue;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [value, setValue] = useState(base);
|
||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
||||
}
|
||||
return {
|
||||
Wrapper,
|
||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
||||
};
|
||||
}
|
||||
|
||||
function useList<T>(initial: T[]) {
|
||||
const [items, setItems] = useState(initial);
|
||||
return { items, setItems };
|
||||
}
|
||||
|
||||
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
||||
|
||||
describe("usePlaylistListSync", () => {
|
||||
function setup(
|
||||
initial: Playlist[],
|
||||
opts?: Parameters<typeof usePlaylistListSync>[1],
|
||||
) {
|
||||
const ws = liveWS();
|
||||
const hook = renderHook(() => {
|
||||
const list = useList(initial);
|
||||
usePlaylistListSync(list.setItems, opts);
|
||||
return list;
|
||||
}, { wrapper: ws.Wrapper });
|
||||
return { ...ws, hook };
|
||||
}
|
||||
|
||||
test("prepends a created playlist once", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
|
||||
const p = makePlaylist({ id: "b" });
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
|
||||
});
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "created",
|
||||
playlistId: "b",
|
||||
playlist: { ...p },
|
||||
},
|
||||
});
|
||||
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
test("created: filtered by owner, privacy and noNewEntries", () => {
|
||||
const p = makePlaylist({ id: "p", userId: "them" });
|
||||
const byOwner = setup([], { ownerId: "me" });
|
||||
byOwner.emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(byOwner.hook.result.current.items).toEqual([]);
|
||||
|
||||
const priv = setup([]);
|
||||
priv.emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "created",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, isPublic: false },
|
||||
},
|
||||
});
|
||||
expect(priv.hook.result.current.items).toEqual([]);
|
||||
|
||||
const followed = setup([], { noNewEntries: true });
|
||||
followed.emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(followed.hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("updated: in place, removed when private, re-added when public", () => {
|
||||
const p = makePlaylist({ id: "p", title: "one" });
|
||||
const { hook, emit } = setup([p]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, title: "two" },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].title).toBe("two");
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, isPublic: false },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, title: "back" },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].title).toBe("back");
|
||||
});
|
||||
|
||||
test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
|
||||
const p = makePlaylist({ id: "p" });
|
||||
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
|
||||
const { hook, emit } = setup([], opts);
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("deleted and deletedPlaylistIds both remove", () => {
|
||||
const { hook, emit } = setup([
|
||||
makePlaylist({ id: "a" }),
|
||||
makePlaylist({ id: "b" }),
|
||||
makePlaylist({ id: "c" }),
|
||||
]);
|
||||
emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
|
||||
emit({ deletedPlaylistIds: new Set(["b"]) });
|
||||
expect(ids(hook.result.current.items)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
test("dumps_updated sets the count", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "dumps_updated",
|
||||
playlistId: "a",
|
||||
dumpIds: ["x", "y", "z"],
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].dumpCount).toBe(3);
|
||||
});
|
||||
|
||||
test("dumps_updated counts the members this viewer can't see too", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "dumps_updated",
|
||||
playlistId: "a",
|
||||
dumpIds: ["x", "y"],
|
||||
dumpCount: 4,
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].dumpCount).toBe(4);
|
||||
});
|
||||
});
|
||||
65
src/test/hooks/shared/useAnchoredFloat.test.tsx
Normal file
65
src/test/hooks/shared/useAnchoredFloat.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { useAnchoredFloat } from "../../../hooks/shared/useAnchoredFloat.ts";
|
||||
|
||||
describe("useAnchoredFloat", () => {
|
||||
function Float({ open, top }: { open: boolean; top: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const style = useAnchoredFloat(ref, open, {
|
||||
width: 300,
|
||||
chromeHeight: 40,
|
||||
minContentHeight: 100,
|
||||
maxContentHeight: 400,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={(el) => {
|
||||
ref.current = el;
|
||||
if (el) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
top,
|
||||
bottom: top + 20,
|
||||
left: 900,
|
||||
right: 1000,
|
||||
width: 100,
|
||||
height: 20,
|
||||
}) as DOMRect;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<output data-testid="style">{JSON.stringify(style)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("innerWidth", 1024);
|
||||
vi.stubGlobal("innerHeight", 768);
|
||||
});
|
||||
|
||||
test("null while closed", () => {
|
||||
render(<Float open={false} top={100} />);
|
||||
expect(styleOf()).toBeNull();
|
||||
});
|
||||
|
||||
test("opens below an anchor near the top, clamped into the viewport", () => {
|
||||
render(<Float open top={100} />);
|
||||
const s = styleOf();
|
||||
expect(s.position).toBe("fixed");
|
||||
expect(s.top).toBe(124);
|
||||
expect(s.bottom).toBe("auto");
|
||||
expect(s.left).toBe(1024 - 300 - 8);
|
||||
expect(s["--float-max-h"]).toBe("400px");
|
||||
});
|
||||
|
||||
test("flips above an anchor near the bottom", () => {
|
||||
render(<Float open top={700} />);
|
||||
const s = styleOf();
|
||||
expect(s.bottom).toBe(768 - 700 + 4);
|
||||
expect(s.top).toBe("auto");
|
||||
});
|
||||
});
|
||||
33
src/test/hooks/shared/useDocumentTitle.test.tsx
Normal file
33
src/test/hooks/shared/useDocumentTitle.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import {
|
||||
setTitleBadge,
|
||||
SITE_NAME,
|
||||
useDocumentTitle,
|
||||
} from "../../../hooks/shared/useDocumentTitle.ts";
|
||||
|
||||
describe("useDocumentTitle", () => {
|
||||
test("composes page, site name and unread badge", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ title }: { title: string | null | undefined }) =>
|
||||
useDocumentTitle(title),
|
||||
{ initialProps: { title: "Cats" as string | null | undefined } },
|
||||
);
|
||||
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(3));
|
||||
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(150));
|
||||
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
|
||||
rerender({ title: null });
|
||||
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(0));
|
||||
expect(document.title).toBe(SITE_NAME);
|
||||
// undefined leaves it be
|
||||
rerender({ title: undefined });
|
||||
expect(document.title).toBe(SITE_NAME);
|
||||
});
|
||||
|
||||
test("the site name defaults to gerbeur when unsubstituted", () => {
|
||||
expect(SITE_NAME).toBe("gerbeur");
|
||||
});
|
||||
});
|
||||
29
src/test/hooks/shared/useHeightTransition.test.tsx
Normal file
29
src/test/hooks/shared/useHeightTransition.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { useHeightTransition } from "../../../hooks/shared/useHeightTransition.ts";
|
||||
|
||||
describe("useHeightTransition", () => {
|
||||
test("animates from the old height to the new one when watch changes", () => {
|
||||
const animate = vi.spyOn(Element.prototype, "animate");
|
||||
let height = 100;
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
|
||||
() => ({ height }) as DOMRect,
|
||||
);
|
||||
function Box({ open }: { open: boolean }) {
|
||||
const ref = useHeightTransition<HTMLDivElement>(open);
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
const { rerender } = render(<Box open={false} />);
|
||||
height = 250;
|
||||
rerender(<Box open />);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
expect(animate.mock.calls[0][0]).toEqual([
|
||||
expect.objectContaining({ height: "100px" }),
|
||||
expect.objectContaining({ height: "250px" }),
|
||||
]);
|
||||
|
||||
// No change in height, no animation.
|
||||
rerender(<Box open={false} />);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
20
src/test/hooks/shared/useScrollLock.test.tsx
Normal file
20
src/test/hooks/shared/useScrollLock.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { useScrollLock } from "../../../hooks/shared/useScrollLock.ts";
|
||||
|
||||
describe("useScrollLock", () => {
|
||||
function Lock() {
|
||||
useScrollLock();
|
||||
return null;
|
||||
}
|
||||
test("counts nested locks", () => {
|
||||
const root = document.documentElement;
|
||||
const a = render(<Lock />);
|
||||
const b = render(<Lock />);
|
||||
expect(root.style.overflowY).toBe("hidden");
|
||||
a.unmount();
|
||||
expect(root.style.overflowY).toBe("hidden");
|
||||
b.unmount();
|
||||
expect(root.style.overflowY).toBe("");
|
||||
});
|
||||
});
|
||||
13
src/test/hooks/shared/useSoundsEnabled.test.tsx
Normal file
13
src/test/hooks/shared/useSoundsEnabled.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useSoundsEnabled } from "../../../hooks/shared/useSoundsEnabled.ts";
|
||||
|
||||
describe("useSoundsEnabled", () => {
|
||||
test("defaults on and persists", () => {
|
||||
const { result } = renderHook(useSoundsEnabled);
|
||||
expect(result.current[0]).toBe(true);
|
||||
act(() => result.current[1](false));
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(localStorage.getItem("sounds")).toBe("off");
|
||||
});
|
||||
});
|
||||
61
src/test/hooks/shared/useTabParam.test.tsx
Normal file
61
src/test/hooks/shared/useTabParam.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router";
|
||||
import { useTabParam } from "../../../hooks/shared/useTabParam.ts";
|
||||
|
||||
describe("useTabParam", () => {
|
||||
const TABS = ["dumps", "playlists", "votes"] as const;
|
||||
function setup(path: string, state?: unknown) {
|
||||
let loc!: ReturnType<typeof useLocation>;
|
||||
const hook = renderHook(() => {
|
||||
loc = useLocation();
|
||||
return useTabParam(TABS, "dumps");
|
||||
}, {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter
|
||||
initialEntries={[{
|
||||
pathname: path.split("?")[0],
|
||||
search: path.includes("?") ? "?" + path.split("?")[1] : "",
|
||||
state,
|
||||
}]}
|
||||
>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
return { hook, loc: () => loc };
|
||||
}
|
||||
|
||||
test("reads the tab after /~/", () => {
|
||||
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
|
||||
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
|
||||
});
|
||||
|
||||
test("the default tab collapses to the base path", () => {
|
||||
const { hook, loc } = setup("/users/ann/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/users/ann");
|
||||
act(() => hook.result.current[1]("playlists"));
|
||||
expect(loc().pathname).toBe("/users/ann/~/playlists");
|
||||
});
|
||||
|
||||
test("at the root the default stays explicit", () => {
|
||||
const { hook, loc } = setup("/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/~/dumps");
|
||||
});
|
||||
|
||||
test("keeps the query string and the entry's state", () => {
|
||||
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
|
||||
act(() => hook.result.current[1]("votes"));
|
||||
expect(loc().pathname).toBe("/search/~/votes");
|
||||
expect(loc().search).toBe("?q=cats");
|
||||
expect(loc().state).toEqual({ back: "/x" });
|
||||
});
|
||||
});
|
||||
@@ -1,301 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
defaultWSValue,
|
||||
WSContext,
|
||||
type WSContextValue,
|
||||
} from "../../contexts/WSContext.ts";
|
||||
import type { Dump, Playlist } from "../../model.ts";
|
||||
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
|
||||
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
|
||||
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
|
||||
import { makeDump, makePlaylist } from "../fixtures.ts";
|
||||
|
||||
/** A WS context whose value a test can push new events into. */
|
||||
function liveWS() {
|
||||
let push: (patch: Partial<WSContextValue>) => void = () => {};
|
||||
const base = defaultWSValue;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [value, setValue] = useState(base);
|
||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
||||
}
|
||||
return {
|
||||
Wrapper,
|
||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
||||
};
|
||||
}
|
||||
|
||||
function useList<T>(initial: T[]) {
|
||||
const [items, setItems] = useState(initial);
|
||||
return { items, setItems };
|
||||
}
|
||||
|
||||
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
||||
|
||||
describe("useDumpListSync", () => {
|
||||
function setup(
|
||||
initial: Dump[],
|
||||
opts?: Parameters<typeof useDumpListSync>[1],
|
||||
) {
|
||||
const ws = liveWS();
|
||||
const hook = renderHook(() => {
|
||||
const list = useList(initial);
|
||||
useDumpListSync(list.setItems, opts);
|
||||
return list;
|
||||
}, { wrapper: ws.Wrapper });
|
||||
return { ...ws, hook };
|
||||
}
|
||||
|
||||
test("drops deleted dumps", () => {
|
||||
const { hook, emit } = setup([
|
||||
makeDump({ id: "a" }),
|
||||
makeDump({ id: "b" }),
|
||||
]);
|
||||
emit({ deletedDumpIds: new Set(["a"]) });
|
||||
expect(ids(hook.result.current.items)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("updates a dump in place", () => {
|
||||
const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
|
||||
emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
|
||||
expect(hook.result.current.items[0].title).toBe("new");
|
||||
});
|
||||
|
||||
test("a dump gone private leaves others' lists but not the owner's", () => {
|
||||
const a = makeDump({ id: "a" });
|
||||
const viewer = setup([a]);
|
||||
viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
|
||||
expect(viewer.hook.result.current.items).toEqual([]);
|
||||
|
||||
const owner = setup([a], { isOwner: true });
|
||||
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
|
||||
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
test("re-inserts only the owner's dumps, and only when asked", () => {
|
||||
const mine = makeDump({ id: "m", userId: "me" });
|
||||
const theirs = makeDump({ id: "t", userId: "them" });
|
||||
|
||||
const none = setup([]);
|
||||
none.emit({ lastDumpEvent: mine });
|
||||
expect(none.hook.result.current.items).toEqual([]);
|
||||
|
||||
const owned = setup([], { ownerId: "me" });
|
||||
owned.emit({ lastDumpEvent: theirs });
|
||||
expect(owned.hook.result.current.items).toEqual([]);
|
||||
owned.emit({ lastDumpEvent: mine });
|
||||
expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
|
||||
|
||||
const skip = setup([], { ownerId: "me", skipReinsert: true });
|
||||
skip.emit({ lastDumpEvent: mine });
|
||||
expect(skip.hook.result.current.items).toEqual([]);
|
||||
|
||||
const privateOne = setup([], { ownerId: "me" });
|
||||
privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
|
||||
expect(privateOne.hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("comment events move the count, never below zero; edits don't", () => {
|
||||
const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
|
||||
emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
|
||||
expect(hook.result.current.items[0].commentCount).toBe(1);
|
||||
emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
|
||||
expect(hook.result.current.items[0].commentCount).toBe(1);
|
||||
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
|
||||
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
|
||||
expect(hook.result.current.items[0].commentCount).toBe(0);
|
||||
emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
|
||||
expect(hook.result.current.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePlaylistListSync", () => {
|
||||
function setup(
|
||||
initial: Playlist[],
|
||||
opts?: Parameters<typeof usePlaylistListSync>[1],
|
||||
) {
|
||||
const ws = liveWS();
|
||||
const hook = renderHook(() => {
|
||||
const list = useList(initial);
|
||||
usePlaylistListSync(list.setItems, opts);
|
||||
return list;
|
||||
}, { wrapper: ws.Wrapper });
|
||||
return { ...ws, hook };
|
||||
}
|
||||
|
||||
test("prepends a created playlist once", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
|
||||
const p = makePlaylist({ id: "b" });
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
|
||||
});
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "created",
|
||||
playlistId: "b",
|
||||
playlist: { ...p },
|
||||
},
|
||||
});
|
||||
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
test("created: filtered by owner, privacy and noNewEntries", () => {
|
||||
const p = makePlaylist({ id: "p", userId: "them" });
|
||||
const byOwner = setup([], { ownerId: "me" });
|
||||
byOwner.emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(byOwner.hook.result.current.items).toEqual([]);
|
||||
|
||||
const priv = setup([]);
|
||||
priv.emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "created",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, isPublic: false },
|
||||
},
|
||||
});
|
||||
expect(priv.hook.result.current.items).toEqual([]);
|
||||
|
||||
const followed = setup([], { noNewEntries: true });
|
||||
followed.emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(followed.hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("updated: in place, removed when private, re-added when public", () => {
|
||||
const p = makePlaylist({ id: "p", title: "one" });
|
||||
const { hook, emit } = setup([p]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, title: "two" },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].title).toBe("two");
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, isPublic: false },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, title: "back" },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].title).toBe("back");
|
||||
});
|
||||
|
||||
test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
|
||||
const p = makePlaylist({ id: "p" });
|
||||
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
|
||||
const { hook, emit } = setup([], opts);
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("deleted and deletedPlaylistIds both remove", () => {
|
||||
const { hook, emit } = setup([
|
||||
makePlaylist({ id: "a" }),
|
||||
makePlaylist({ id: "b" }),
|
||||
makePlaylist({ id: "c" }),
|
||||
]);
|
||||
emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
|
||||
emit({ deletedPlaylistIds: new Set(["b"]) });
|
||||
expect(ids(hook.result.current.items)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
test("dumps_updated sets the count", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "dumps_updated",
|
||||
playlistId: "a",
|
||||
dumpIds: ["x", "y", "z"],
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].dumpCount).toBe(3);
|
||||
});
|
||||
|
||||
test("dumps_updated counts the members this viewer can't see too", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "dumps_updated",
|
||||
playlistId: "a",
|
||||
dumpIds: ["x", "y"],
|
||||
dumpCount: 4,
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].dumpCount).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePositionAwareSync", () => {
|
||||
test("a dump that goes private and back returns to its old place", () => {
|
||||
const ws = liveWS();
|
||||
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
|
||||
// Wire the pair exactly as UserPublicProfile does.
|
||||
const both = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const [event, setEvent] = useState<Dump | null>(null);
|
||||
usePositionAwareSync(
|
||||
items,
|
||||
setItems,
|
||||
event,
|
||||
(d) => d.isPrivate,
|
||||
(d) => !d.isPrivate,
|
||||
);
|
||||
useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
|
||||
return { items, setEvent };
|
||||
}, { wrapper: ws.Wrapper });
|
||||
|
||||
const b = initial[1];
|
||||
act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
|
||||
ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
|
||||
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
|
||||
|
||||
act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
|
||||
ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
|
||||
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("a position is only used once, and clamps to the list end", () => {
|
||||
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
|
||||
const hook = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const [event, setEvent] = useState<Playlist | null>(null);
|
||||
usePositionAwareSync(
|
||||
items,
|
||||
setItems,
|
||||
event,
|
||||
(p) => !p.isPublic,
|
||||
(p) => p.isPublic,
|
||||
);
|
||||
return { items, setItems, setEvent };
|
||||
});
|
||||
const c = initial[2];
|
||||
act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
|
||||
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
|
||||
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
|
||||
// Nothing saved any more: a second public event is left alone.
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
|
||||
act(() =>
|
||||
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
|
||||
);
|
||||
expect(ids(hook.result.current.items)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
deserializePlaylistMembership,
|
||||
deserializePlaylistWithDumps,
|
||||
parseAPIResponse,
|
||||
} from "../../model.ts";
|
||||
} from "../model.ts";
|
||||
|
||||
describe("parseAPIResponse", () => {
|
||||
test("accepts either envelope", () => {
|
||||
16
src/test/pages/NotFound.test.tsx
Normal file
16
src/test/pages/NotFound.test.tsx
Normal 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",
|
||||
"/",
|
||||
);
|
||||
});
|
||||
});
|
||||
75
src/test/pages/ResetPassword.test.tsx
Normal file
75
src/test/pages/ResetPassword.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
25
src/test/pages/RestrictedGuest.test.tsx
Normal file
25
src/test/pages/RestrictedGuest.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
34
src/test/pages/RestrictedLoggedIn.test.tsx
Normal file
34
src/test/pages/RestrictedLoggedIn.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
123
src/test/pages/UserLogin.test.tsx
Normal file
123
src/test/pages/UserLogin.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
92
src/test/pages/UserRegister.test.tsx
Normal file
92
src/test/pages/UserRegister.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
89
src/test/pages/UserUpvoted.test.tsx
Normal file
89
src/test/pages/UserUpvoted.test.tsx
Normal 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",
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
"/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
17
src/test/utils/auth/permissions.test.ts
Normal file
17
src/test/utils/auth/permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
src/test/utils/auth/storage.test.ts
Normal file
74
src/test/utils/auth/storage.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
47
src/test/utils/chat/text.test.ts
Normal file
47
src/test/utils/chat/text.test.ts
Normal 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("", "Image")).toBe("cat");
|
||||
expect(markdownToPlainText("", "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 ).toBe(
|
||||
"pic alt",
|
||||
);
|
||||
expect(markdownToPlainText("pic ).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");
|
||||
});
|
||||
});
|
||||
78
src/test/utils/dump/draft.test.ts
Normal file
78
src/test/utils/dump/draft.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
src/test/utils/editor/text.test.ts
Normal file
49
src/test/utils/editor/text.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
src/test/utils/feed/hotScore.test.ts
Normal file
15
src/test/utils/feed/hotScore.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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" })))
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
102
src/test/utils/player/bandcamp.test.ts
Normal file
102
src/test/utils/player/bandcamp.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
14
src/test/utils/player/duration.test.ts
Normal file
14
src/test/utils/player/duration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
103
src/test/utils/player/streamSources.test.ts
Normal file
103
src/test/utils/player/streamSources.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
33
src/test/utils/player/waveform.test.ts
Normal file
33
src/test/utils/player/waveform.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
43
src/test/utils/player/youtube.test.ts
Normal file
43
src/test/utils/player/youtube.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
14
src/test/utils/shared/api.test.ts
Normal file
14
src/test/utils/shared/api.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
14
src/test/utils/shared/charCount.test.ts
Normal file
14
src/test/utils/shared/charCount.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
src/test/utils/shared/format.test.ts
Normal file
27
src/test/utils/shared/format.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
46
src/test/utils/shared/proxyImage.test.ts
Normal file
46
src/test/utils/shared/proxyImage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
src/test/utils/shared/relativeTime.test.ts
Normal file
34
src/test/utils/shared/relativeTime.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 = () =>
|
||||
43
src/test/utils/shared/thumbnailTint.test.ts
Normal file
43
src/test/utils/shared/thumbnailTint.test.ts
Normal 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("?");
|
||||
});
|
||||
});
|
||||
46
src/test/utils/shared/urls.test.ts
Normal file
46
src/test/utils/shared/urls.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
32
src/test/utils/shared/visited.test.ts
Normal file
32
src/test/utils/shared/visited.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("", "Image")).toBe("cat");
|
||||
expect(markdownToPlainText("", "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 ).toBe(
|
||||
"pic alt",
|
||||
);
|
||||
expect(markdownToPlainText("pic ).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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user