mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
131 lines
7.3 KiB
TypeScript
131 lines
7.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, type ReactNode } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AttentionFeed, AttentionItem } from "@paperclipai/shared";
|
|
import { CoordinatorSidebar } from "./CoordinatorSidebar";
|
|
import { TooltipProvider } from "./ui/tooltip";
|
|
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
const fixture = vi.hoisted(() => ({ attention: vi.fn(), collapsed: false, userId: "reviewer" }));
|
|
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
|
|
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: [] }, session: { isSuccess: true },
|
|
}) }));
|
|
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: [] }) }));
|
|
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
|
|
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) }));
|
|
vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention } }));
|
|
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
|
|
vi.mock("./SidebarCompanyMenu", () => ({ SidebarCompanyMenu: () => null }));
|
|
vi.mock("@/lib/router", () => ({
|
|
useLocation: () => ({ pathname: "/COO/decisions" }),
|
|
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a>,
|
|
NavLink: ({ to, children, className, state: _state, end: _end, ...props }: {
|
|
to: string; children: ReactNode; className?: string | ((state: { isActive: boolean }) => string); state?: unknown; end?: boolean;
|
|
}) => <a href={to} className={typeof className === "function" ? className({ isActive: false }) : className} {...props}>{children}</a>,
|
|
}));
|
|
|
|
const pending = (id: string) => ({ id, sourceKind: "issue_thread_interaction", subject: { id, kind: "interaction", status: "pending" },
|
|
createdAt: "2026-10-20T12:00:00Z", activityAt: "2026-10-20T12:00:00Z" }) as AttentionItem;
|
|
function feed(items: AttentionItem[]): AttentionFeed {
|
|
return { companyId: "company", generatedAt: "2026-10-27T12:00:00Z", totalCount: items.length, deskBadgeCount: 0,
|
|
nextCursor: null, countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items };
|
|
}
|
|
|
|
let root: Root, container: HTMLDivElement, client: QueryClient;
|
|
beforeEach(() => {
|
|
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer";
|
|
fixture.attention.mockResolvedValue(feed([pending("question"), pending("review")]));
|
|
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
|
|
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
});
|
|
|
|
describe("Coordinator pinned pages", () => {
|
|
const pinnedPages = () => container.querySelector('[aria-label="Pinned pages"]');
|
|
async function click(label: string) {
|
|
const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
|
|
expect(button).not.toBeNull();
|
|
await act(async () => button!.click());
|
|
await flush();
|
|
}
|
|
|
|
it("pins from More without navigating, restores after remount, and unpins from the sidebar", async () => {
|
|
await renderSidebar();
|
|
expect(pinnedPages()).toBeNull();
|
|
await click("More workspace pages");
|
|
expect(document.activeElement).toBe(document.querySelector('[data-slot="popover-content"]'));
|
|
await click("Pin Projects");
|
|
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
|
expect(pinnedPages()?.querySelector('a[href="/projects"]')?.textContent).toBe("Projects");
|
|
await act(async () => root.unmount());
|
|
root = createRoot(container);
|
|
await renderSidebar();
|
|
expect(pinnedPages()?.querySelector('a[href="/projects"]')).not.toBeNull();
|
|
await click("Unpin Projects");
|
|
expect(pinnedPages()).toBeNull();
|
|
});
|
|
|
|
it("keeps pins personal and only renders known destinations", async () => {
|
|
localStorage.setItem("paperclip.coordinator.pinned-nav.v1:reviewer", JSON.stringify(["/projects", "/unknown", "https://example.com"]));
|
|
await renderSidebar();
|
|
expect(pinnedPages()?.querySelectorAll("a")).toHaveLength(1);
|
|
fixture.userId = "other-user";
|
|
await renderSidebar();
|
|
expect(pinnedPages()).toBeNull();
|
|
fixture.userId = "reviewer";
|
|
await renderSidebar();
|
|
expect(pinnedPages()?.textContent).toContain("Projects");
|
|
});
|
|
|
|
it("reflects pins changed in another browser tab", async () => {
|
|
await renderSidebar();
|
|
const key = "paperclip.coordinator.pinned-nav.v1:reviewer";
|
|
await act(async () => {
|
|
localStorage.setItem(key, JSON.stringify(["/agents/all"]));
|
|
window.dispatchEvent(new StorageEvent("storage", { key }));
|
|
});
|
|
expect(pinnedPages()?.querySelector('a[href="/agents/all"]')?.textContent).toBe("Agents");
|
|
});
|
|
});
|
|
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
|
|
async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); }
|
|
async function renderSidebar() {
|
|
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><CoordinatorSidebar /></TooltipProvider></QueryClientProvider>));
|
|
await flush();
|
|
}
|
|
const decisionLink = () => container.querySelector('a[href="/decisions"]')!;
|
|
|
|
describe("Coordinator Needs You badge", () => {
|
|
it("counts every canonical outstanding decision even when none arrived today", async () => {
|
|
// Personal page filters and completed receipts are separate cache entries.
|
|
client.setQueryData([...coordinatorAttentionQueryKey("company"), { search: "no match" }], feed([]));
|
|
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }] });
|
|
await renderSidebar();
|
|
expect(fixture.attention).toHaveBeenCalledWith("company", { all: true });
|
|
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("2");
|
|
});
|
|
|
|
it("updates after canonical attention refreshes and removes the badge once every decision is resolved", async () => {
|
|
await renderSidebar();
|
|
fixture.attention.mockResolvedValue(feed([pending("review")]));
|
|
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
|
|
await flush();
|
|
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("1");
|
|
fixture.attention.mockResolvedValue(feed([]));
|
|
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }, { id: "accepted-review" }] });
|
|
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
|
|
await flush();
|
|
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
|
|
expect(decisionLink().textContent).toBe("Needs You");
|
|
});
|
|
|
|
it("announces the full outstanding count when the sidebar is collapsed", async () => {
|
|
fixture.collapsed = true;
|
|
await renderSidebar();
|
|
expect(decisionLink().getAttribute("aria-label")).toBe("Needs You, 2 decisions");
|
|
});
|
|
});
|