v4: the GeoCities marquee scrolls the community feed, what the site's members are doing — it crawled three canned lines, a welcome, "best viewed in Netscape Navigator" and "under construction", which said nothing of the site. The feed is a list any style may show (CommunityFeed.tsx), which GeoCities lays out along its marquee: who is online, the week's dumps, upvotes, comments and chat messages, linking to /community, its most upvoted dump, then the 12 latest things done in public, newest first, each ending on how long ago it was: a public dump posted, a public playlist created, a comment or an upvote on a public dump, someone joining and who invited them. Every name and title links to its page. A dump has one comment line and one upvote line at most, its latest, so that a thread or a popular dump doesn't fill the feed. GET /api/community/feed serves it, signed in only (community-service.ts): the events are read from the tables on each request, the week's figures are those /community already keeps, as of its last refresh. A signed-out visitor has no marquee. CommunityFeedProvider holds the feed around every page, so that it outlives the header each page renders anew, fetches it only while a component shows it, and fetches it again two seconds after the socket last told of a dump, a comment, a vote, a playlist, a rename, a deletion or a reconnection: the socket's messages don't carry a feed's lines, and the server's query stays the one place that decides what the feed holds. Someone joining comes with no message, and shows at the next of those. The crawl goes at 50 pixels a second however much the feed holds, Marquee.tsx measuring one copy for the stylesheet to work the loop's length out from, where a fixed 36 seconds would have sped up with every line. A loop shows what the feed held when it began: text that changed mid-crawl would jump, so what came meanwhile waits for the next loop, which also brings the times up to date. The next page's marquee carries on from where the last one's had got to, rather than from the start. The marquee was hidden from assistive technology as decoration; its first copy is now a labelled list of links, the second, which the seamless loop needs, hidden and out of the tab order. A link that takes the focus stops the crawl and the line scrolls to it, the pointer pauses it as before, and with reduced motion the line shows once, still, follows the feed as it changes and scrolls by hand. The sentences a notification also says, "posted", "upvoted", "commented on", share its translations. The tests' DOM gets an AnimationEvent before React loads (src/test/before-react.ts): finding none, React listened for the animation events under their WebKit names, and a test's "animationiteration" reached no handler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8
src/test/before-react.ts
Normal file
8
src/test/before-react.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Runs before every test file, and before setup.ts, whose imports load React:
|
||||
// what jsdom leaves out that React looks for as it loads.
|
||||
|
||||
// Finding no AnimationEvent, React listens for the animation events under
|
||||
// their old WebKit names, and `fireEvent.animationIteration` reaches no
|
||||
// handler.
|
||||
(globalThis as { AnimationEvent?: unknown }).AnimationEvent ??=
|
||||
class AnimationEvent extends Event {};
|
||||
105
src/test/components/layout/Marquee.test.tsx
Normal file
105
src/test/components/layout/Marquee.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import type { CommunityFeed } from "../../../../shared/community.ts";
|
||||
import { Marquee } from "../../../components/layout/Marquee.tsx";
|
||||
import { CommunityFeedContext } from "../../../contexts/social/CommunityFeedContext.ts";
|
||||
import { ThemeContext } from "../../../contexts/theme/ThemeContext.ts";
|
||||
import type { StyleName } from "../../../../shared/theme.ts";
|
||||
import { makeCommunityFeed, makeSession } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const joined = (username: string) =>
|
||||
makeCommunityFeed({
|
||||
events: [{
|
||||
id: `user_joined:${username}`,
|
||||
at: new Date(),
|
||||
type: "user_joined",
|
||||
username,
|
||||
}],
|
||||
});
|
||||
|
||||
function marquee(feed: CommunityFeed | null, style: StyleName = "geocities") {
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
style,
|
||||
colorScheme: "light",
|
||||
setStyle() {},
|
||||
setColorScheme() {},
|
||||
}}
|
||||
>
|
||||
<CommunityFeedContext.Provider value={{ feed, watch: () => () => {} }}>
|
||||
<Marquee />
|
||||
</CommunityFeedContext.Provider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const session = makeSession({ username: "me" });
|
||||
const track = () => document.querySelector(".site-marquee-track");
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("Marquee", () => {
|
||||
test("draws nothing in another style, nor for a visitor", () => {
|
||||
const band = () => document.querySelector(".site-marquee");
|
||||
const { rerender, unmount } = renderWithContexts(
|
||||
marquee(joined("ann"), "smooth"),
|
||||
{ session },
|
||||
);
|
||||
expect(band()).toBeNull();
|
||||
rerender(marquee(joined("ann")));
|
||||
expect(band()).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderWithContexts(marquee(joined("ann")));
|
||||
expect(band()).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty band until the feed is loaded, then the feed twice over", () => {
|
||||
const { rerender } = renderWithContexts(marquee(null), { session });
|
||||
expect(document.querySelector(".site-marquee")).toBeEmptyDOMElement();
|
||||
|
||||
rerender(marquee(joined("ann")));
|
||||
// The second copy is the loop's: neither read out nor tabbed to.
|
||||
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
|
||||
const [first, second] = [...document.querySelectorAll("a[href='/@ann']")];
|
||||
expect(first).not.toHaveAttribute("tabindex");
|
||||
expect(second).toHaveAttribute("tabindex", "-1");
|
||||
expect(second.closest("[aria-hidden='true']")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("what changes mid-crawl waits for the next loop", () => {
|
||||
const { rerender } = renderWithContexts(marquee(joined("ann")), {
|
||||
session,
|
||||
ws: { onlineUsers: [] },
|
||||
});
|
||||
rerender(marquee(joined("bob")));
|
||||
expect(screen.getByRole("link", { name: "ann" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "bob" })).toBeNull();
|
||||
|
||||
const before = track()!;
|
||||
fireEvent.animationIteration(before);
|
||||
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "ann" })).toBeNull();
|
||||
// A new loop is a new track, whose animation starts over.
|
||||
expect(track()).not.toBe(before);
|
||||
});
|
||||
|
||||
test("with reduced motion, the line shows once and follows the feed", () => {
|
||||
vi.spyOn(globalThis, "matchMedia").mockImplementation((query) =>
|
||||
({
|
||||
matches: query.includes("reduce"),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}) as unknown as MediaQueryList
|
||||
);
|
||||
const { rerender } = renderWithContexts(marquee(joined("ann")), {
|
||||
session,
|
||||
});
|
||||
expect(document.querySelectorAll("a[href='/@ann']")).toHaveLength(1);
|
||||
rerender(marquee(joined("bob")));
|
||||
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
118
src/test/components/social/CommunityFeed.test.tsx
Normal file
118
src/test/components/social/CommunityFeed.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { CommunityFeed } from "../../../components/social/CommunityFeed.tsx";
|
||||
import { makeCommunityFeed } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const at = new Date(Date.now() - 2 * 3_600_000);
|
||||
const hit = { title: "Big hit", slug: "big-hit", ownerUsername: "ann" };
|
||||
const online = (...names: string[]) =>
|
||||
names.map((username) => ({ userId: username, username, hasAvatar: false }));
|
||||
|
||||
describe("CommunityFeed", () => {
|
||||
test("a line for who is online, the week and its top dump, then one per event", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed
|
||||
online={online("ann", "bob")}
|
||||
feed={makeCommunityFeed({
|
||||
week: {
|
||||
totals: { dumps: 1284, upvotes: 1, comments: 0 },
|
||||
chatMessages: 3,
|
||||
top: hit,
|
||||
},
|
||||
events: [
|
||||
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
|
||||
{ id: "b", at, type: "dump_upvoted", username: "bob", dump: hit },
|
||||
{
|
||||
id: "c",
|
||||
at,
|
||||
type: "dump_commented",
|
||||
username: "cy",
|
||||
dump: hit,
|
||||
commentId: "c9",
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
at,
|
||||
type: "playlist_created",
|
||||
username: "dee",
|
||||
playlist: { title: "Mix", slug: "mix", ownerUsername: "dee" },
|
||||
},
|
||||
{
|
||||
id: "e",
|
||||
at,
|
||||
type: "user_joined",
|
||||
username: "eve",
|
||||
invitedByUsername: "ann",
|
||||
},
|
||||
{ id: "f", at, type: "user_joined", username: "fay" },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const lines = within(screen.getByRole("list", { name: "Community" }))
|
||||
.getAllByRole("listitem").map((li) => li.textContent);
|
||||
expect(lines).toEqual([
|
||||
"Online now: ann, bob",
|
||||
// A figure at zero is left out.
|
||||
"This week: 1,284 dumps, 1 upvote, 3 chat messages",
|
||||
"Top this week: Big hit",
|
||||
"ann posted Big hit 2 hours ago",
|
||||
"bob upvoted Big hit 2 hours ago",
|
||||
"cy commented on Big hit 2 hours ago",
|
||||
"dee created the playlist Mix 2 hours ago",
|
||||
"eve joined, invited by ann 2 hours ago",
|
||||
"fay joined 2 hours ago",
|
||||
]);
|
||||
|
||||
const href = (name: string, n = 0) =>
|
||||
screen.getAllByRole("link", { name })[n].getAttribute("href");
|
||||
expect(href("bob")).toBe("/@bob");
|
||||
expect(href("1,284 dumps, 1 upvote, 3 chat messages")).toBe("/community");
|
||||
expect(href("Big hit")).toBe("/@ann/dumps/big-hit");
|
||||
// The comment's line leads to the comment.
|
||||
expect(href("Big hit", 3)).toBe("/@ann/dumps/big-hit#comment-c9");
|
||||
expect(href("Mix")).toBe("/@dee/playlists/mix");
|
||||
});
|
||||
|
||||
test("a line with nothing to say is left out", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed online={[]} feed={makeCommunityFeed()} />,
|
||||
);
|
||||
expect(screen.queryAllByRole("listitem")).toEqual([]);
|
||||
});
|
||||
|
||||
test("past eight online, the others are counted", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed
|
||||
online={online("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")}
|
||||
feed={makeCommunityFeed()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("listitem")).toHaveTextContent(
|
||||
"Online now: a, b, c, d, e, f, g, h +2",
|
||||
);
|
||||
});
|
||||
|
||||
test("tabIndex goes to every link", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed
|
||||
tabIndex={-1}
|
||||
online={online("ann")}
|
||||
feed={makeCommunityFeed({
|
||||
week: {
|
||||
totals: { dumps: 1, upvotes: 0, comments: 0 },
|
||||
chatMessages: 0,
|
||||
top: hit,
|
||||
},
|
||||
events: [
|
||||
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(5);
|
||||
for (const link of links) expect(link).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
138
src/test/contexts/social/CommunityFeedProvider.test.tsx
Normal file
138
src/test/contexts/social/CommunityFeedProvider.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { CommunityFeedProvider } from "../../../contexts/social/CommunityFeedProvider.tsx";
|
||||
import { useCommunityFeed } from "../../../hooks/social/useCommunityFeed.ts";
|
||||
import { calls, route } from "../../fetch.ts";
|
||||
import {
|
||||
makeCommunityFeed,
|
||||
makeDump,
|
||||
makeSession,
|
||||
storeSession,
|
||||
wire,
|
||||
} from "../../fixtures.ts";
|
||||
import { liveWS } from "../../live.tsx";
|
||||
|
||||
const FEED = "GET /api/community/feed";
|
||||
const joined = (username: string) =>
|
||||
wire(makeCommunityFeed({
|
||||
events: [{
|
||||
id: `user_joined:${username}`,
|
||||
at: new Date("2026-10-01T00:00:00Z"),
|
||||
type: "user_joined",
|
||||
username,
|
||||
}],
|
||||
}));
|
||||
|
||||
function setup({ signedIn = true } = {}) {
|
||||
if (signedIn) {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
}
|
||||
const ws = liveWS();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AuthProvider>
|
||||
<ws.Wrapper>
|
||||
<CommunityFeedProvider>{children}</CommunityFeedProvider>
|
||||
</ws.Wrapper>
|
||||
</AuthProvider>
|
||||
);
|
||||
return { ws, wrapper };
|
||||
}
|
||||
|
||||
describe("CommunityFeedProvider", () => {
|
||||
test("loads the feed for a component that shows it", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { wrapper } = setup();
|
||||
const { result } = renderHook(useCommunityFeed, { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current!.events[0].username).toBe("ann");
|
||||
expect(result.current!.events[0].at).toEqual(
|
||||
new Date("2026-10-01T00:00:00Z"),
|
||||
);
|
||||
expect(calls(FEED)[0].headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
});
|
||||
|
||||
test("asks for nothing while no component shows it", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { wrapper: Wrapper } = setup();
|
||||
render(<Wrapper>a page that shows no feed</Wrapper>);
|
||||
await act(async () => {});
|
||||
expect(calls(FEED)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a visitor has no feed, and never asks", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { wrapper } = setup({ signedIn: false });
|
||||
const { result } = renderHook(useCommunityFeed, { wrapper });
|
||||
await act(async () => {});
|
||||
expect(result.current).toBeNull();
|
||||
expect(calls(FEED)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("fetches it again once the socket's changes have settled", async () => {
|
||||
let name = "ann";
|
||||
route(FEED, () => joined(name));
|
||||
const { ws, wrapper } = setup();
|
||||
const { result } = renderHook(useCommunityFeed, { wrapper });
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
name = "bob";
|
||||
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
|
||||
act(() => vi.advanceTimersByTime(1500));
|
||||
ws.emit("dump", { type: "created", dump: makeDump() });
|
||||
act(() => vi.advanceTimersByTime(1500));
|
||||
// The second change put the fetch off.
|
||||
expect(calls(FEED)).toHaveLength(1);
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
expect(calls(FEED)).toHaveLength(2);
|
||||
|
||||
// What changes no line of the feed fetches nothing.
|
||||
ws.emit("comment", { type: "updated", dumpId: "d" });
|
||||
ws.emit("playlist", { type: "dumps_updated", playlistId: "p" });
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
expect(calls(FEED)).toHaveLength(2);
|
||||
|
||||
// A deleted dump and a reconnection do.
|
||||
ws.set({ deletedDumpIds: new Set(["d"]) });
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(calls(FEED)).toHaveLength(3);
|
||||
ws.set({ connectionEpoch: 1 });
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(calls(FEED)).toHaveLength(4);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await waitFor(() => expect(result.current!.events[0].username).toBe("bob"));
|
||||
});
|
||||
|
||||
test("a change that came while nothing showed the feed is caught up with", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { ws, wrapper } = setup();
|
||||
// The provider stays, as it does under the pages: the consumer comes and
|
||||
// goes.
|
||||
const Show = () => {
|
||||
useCommunityFeed();
|
||||
return null;
|
||||
};
|
||||
const view = render(<Show />, { wrapper });
|
||||
await waitFor(() => expect(calls(FEED)).toHaveLength(1));
|
||||
view.rerender(null);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
expect(calls(FEED)).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
view.rerender(<Show />);
|
||||
await waitFor(() => expect(calls(FEED)).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { Session } from "../utils/auth/storage.ts";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
import type { CommunityFeed } from "../../shared/community.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { Notification } from "../../shared/notification.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
@@ -142,6 +143,17 @@ export function makeNotification(
|
||||
};
|
||||
}
|
||||
|
||||
/** A feed with nothing in it: a test gives it the lines it reads. */
|
||||
export function makeCommunityFeed(
|
||||
over: Partial<CommunityFeed> = {},
|
||||
): CommunityFeed {
|
||||
return {
|
||||
events: [],
|
||||
week: { totals: { dumps: 0, upvotes: 0, comments: 0 }, chatMessages: 0 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A value as it crosses the wire: dates become ISO strings. */
|
||||
export function wire<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type FollowContextValue,
|
||||
} from "../contexts/social/FollowContext.ts";
|
||||
import { FollowProvider } from "../contexts/social/FollowProvider.tsx";
|
||||
import { CommunityFeedProvider } from "../contexts/social/CommunityFeedProvider.tsx";
|
||||
import {
|
||||
CategoriesContext,
|
||||
type CategoriesContextValue,
|
||||
@@ -264,20 +265,22 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
|
||||
<PlayerProvider>
|
||||
<WSProvider>
|
||||
<FollowProvider>
|
||||
<CategoriesProvider>
|
||||
<MemoryRouter
|
||||
initialEntries={entries}
|
||||
initialIndex={entries.length - 1}
|
||||
>
|
||||
<ChatProvider>
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
{extraRoutes}
|
||||
</Routes>
|
||||
<LocationProbe />
|
||||
</ChatProvider>
|
||||
</MemoryRouter>
|
||||
</CategoriesProvider>
|
||||
<CommunityFeedProvider>
|
||||
<CategoriesProvider>
|
||||
<MemoryRouter
|
||||
initialEntries={entries}
|
||||
initialIndex={entries.length - 1}
|
||||
>
|
||||
<ChatProvider>
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
{extraRoutes}
|
||||
</Routes>
|
||||
<LocationProbe />
|
||||
</ChatProvider>
|
||||
</MemoryRouter>
|
||||
</CategoriesProvider>
|
||||
</CommunityFeedProvider>
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
</PlayerProvider>
|
||||
|
||||
Reference in New Issue
Block a user