Files
buzz-sheet/components/comments/comment-board.test.tsx
T

149 lines
8.5 KiB
TypeScript

// @vitest-environment happy-dom
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommentItem, CommentPage } from "@/lib/comments/types";
const events = vi.hoisted(() => ({ refresh: undefined as undefined | ((changed: boolean) => Promise<unknown>) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("./use-comment-events", () => ({ useCommentEvents: (_scope: unknown, refresh: (changed: boolean) => Promise<unknown>) => { events.refresh = refresh; } }));
vi.mock("./comment-card", () => ({ CommentCard: ({ item, defaultExpanded, focusReplyId, onChange }: {
item: CommentItem; defaultExpanded: boolean; focusReplyId?: string; onChange: () => Promise<unknown>;
}) => {
const [expanded] = useState(defaultExpanded);
return <article id={`comment-${item.id}`} data-expanded={expanded} data-focus-reply={focusReplyId}>{item.text}<button onClick={() => void onChange()}>Update {item.id}</button></article>;
} }));
import { CommentBoard } from "./comment-board";
const target = "guide:test";
const selectedId = "00000000-0000-4000-8000-000000000001";
const replyId = "00000000-0000-4000-8000-000000000002";
const secondId = "00000000-0000-4000-8000-000000000003";
function comment(id: string, text = id): CommentItem {
return {
id, rootId: null, replyToId: null, replyToName: null,
authorName: "Author", authorImage: null, authorAdmin: false, text, images: [], version: 1,
createdAt: "2026-10-08T12:00:00Z", editedAt: "2026-10-08T12:00:00Z",
hidden: false, ownHidden: false, deleted: false, hearted: false, likes: 0, reaction: 0,
replyCount: 1, hasReplies: true, canEdit: false, canInteract: true, canModerate: false,
target, targetName: "Test", targetImage: null, href: "/test/comment",
};
}
function page(items: CommentItem[], nextCursor: string | null = null): CommentPage { return { items, nextCursor, viewer: null }; }
let container: HTMLDivElement;
let root: Root;
let inbox: CommentPage;
let nextPage: CommentPage;
let selected: CommentItem;
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
window.history.replaceState(null, "", `/test/comment?reply=${replyId}#comment-${selectedId}`);
selected = comment(selectedId, "Selected conversation");
inbox = page([comment("newer", "Newer conversation")]);
nextPage = page([]);
fetchMock.mockReset().mockImplementation(async (input) => {
const url = new URL(String(input), "https://guide.sudloh.com");
return Response.json(url.pathname === "/api/comments" ? url.searchParams.has("cursor") ? nextPage : inbox : { item: selected, viewer: null });
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals();
});
async function mount(initial = inbox) { await act(async () => root.render(<CommentBoard target={target} initial={initial} />)); }
function cards() { return [...container.querySelectorAll("article[id]")].map(item => item.id); }
function singleRequests() { return fetchMock.mock.calls.filter(([url]) => String(url) === `/api/comments/${selectedId}`); }
async function click(label: string) {
const button = [...container.querySelectorAll("button")].find(item => item.textContent?.trim() === label || item.getAttribute("aria-label") === label);
expect(button).toBeDefined(); await act(async () => button!.click());
}
describe("linked public comment conversations", () => {
it("moves a loaded root first, expands it, and passes the exact reply to focus without another lookup", async () => {
await mount(page([...inbox.items, selected]));
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer"]);
const card = container.querySelector(`#comment-${selectedId}`);
expect(card?.getAttribute("data-expanded")).toBe("true");
expect(card?.getAttribute("data-focus-reply")).toBe(replyId);
expect(singleRequests()).toHaveLength(0);
expect(container.textContent).toContain("ความคิดเห็น");
expect(container.textContent).not.toContain("ปิดการสนทนา");
expect(container.querySelector('section[aria-label="การสนทนาที่เลือก"]')).toBeNull();
});
it("loads an older root and deduplicates it when more comments include it", async () => {
await mount(page(inbox.items, "next-page"));
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer"]);
nextPage = page([comment(selectedId, "Updated page content"), comment("oldest")]);
await click("ดูความคิดเห็นเพิ่มเติม");
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer", "comment-oldest"]);
expect(container.textContent).toContain("Updated page content");
expect(singleRequests()).toHaveLength(1);
});
it("refreshes older roots after interactions, manual refresh, and live changes", async () => {
await mount();
selected = comment(selectedId, "After interaction"); await click(`Update ${selectedId}`);
expect(container.textContent).toContain("After interaction");
selected = comment(selectedId, "After refresh"); await click("รีเฟรชความคิดเห็น");
expect(container.textContent).toContain("After refresh");
selected = comment(selectedId, "After live change"); await act(async () => { await events.refresh!(true); });
expect(container.textContent).toContain("After live change");
});
it("clears the selected conversation and reply when sorting while preserving other URL parameters", async () => {
window.history.replaceState(null, "", `/stygian/comment?schedule=123&reply=${replyId}#comment-${selectedId}`);
await mount(); inbox = page([comment("sorted")]); await click("ล่าสุด");
expect(cards()).toEqual(["comment-sorted"]);
expect(window.location.hash).toBe("");
expect(window.location.search).toBe("?schedule=123");
expect(singleRequests()).toHaveLength(1);
});
it("ignores an old lookup when the hash points to a different root", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockImplementationOnce(() => new Promise<Response>(done => { resolve = done; }));
await mount();
const signal = singleRequests()[0][1]?.signal;
selected = comment(secondId, "Second selected conversation");
await act(async () => {
window.history.replaceState(null, "", `/test/comment#comment-${secondId}`);
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(signal?.aborted).toBe(true);
await act(async () => resolve(Response.json({ item: comment(selectedId), viewer: null })));
expect(cards()).toEqual([`comment-${secondId}`, "comment-newer"]);
expect(container.querySelector(`#comment-${secondId}`)?.hasAttribute("data-focus-reply")).toBe(false);
});
it.each(["missing", "wrong target", "reply"])("shows an inline error for a %s root and supports retry through refresh", async (failure) => {
if (failure === "missing") fetchMock.mockResolvedValueOnce(Response.json({ error: "comment-not-found" }, { status: 404 }));
else selected = { ...selected, ...(failure === "reply" ? { rootId: secondId } : { target: "guide:other" }) };
await mount();
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(cards()).toEqual(["comment-newer"]);
selected = comment(selectedId); await click("รีเฟรชความคิดเห็น");
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer"]);
});
it("keeps loading feedback without claiming the board is empty", async () => {
fetchMock.mockImplementationOnce(() => new Promise<Response>(() => {}));
await mount(page([]));
expect(container.querySelector('[role="status"]')).not.toBeNull();
expect(container.textContent).not.toContain("ยังไม่มีความคิดเห็น");
});
it("preserves normal order without a comment hash", async () => {
window.history.replaceState(null, "", "/test/comment");
await mount(page([...inbox.items, selected]));
expect(cards()).toEqual(["comment-newer", `comment-${selectedId}`]);
expect(singleRequests()).toHaveLength(0);
expect(container.querySelector(`#comment-${selectedId}`)?.getAttribute("data-expanded")).toBe("false");
});
});