Files
multica/packages/views/layout/app-sidebar.test.tsx
Bohan Jiang 451c46c43f refactor(usage): rename Dashboard → Usage + dynamic per-agent leaderboard (#2511)
The page added in #2462 lived at `/{slug}/dashboard` and was titled
"Dashboard", which collides with the conventional meaning ("personal
landing surface") and doesn't tell new users what the page is for. Its
actual contents — token spend, cost, run time, task counts — map cleanly
onto the OpenAI / Anthropic / Vercel "Usage" surface, so rename to that.

Renames (user-visible)
- Route: `/{slug}/dashboard` → `/{slug}/usage` (web App Router + desktop
  memory router)
- Sidebar entry: label "Dashboard" / "看板" → "Usage" / "用量", icon
  LayoutDashboard → BarChart3 (page header icon swapped in sync)
- Page title in en/zh-Hans
- Reserved-slugs: add `usage` to workspace route segments group;
  `dashboard` stays reserved in the marketing group (back-compat against
  workspace slug collisions + keeps the name free for a future Home page)
- i18n namespace `dashboard` → `usage` across resources-types.ts,
  locales/index.ts, and the moved JSON files
- WORKSPACE_ROUTE_SEGMENTS in editor link-handler
- paths.workspace(slug).dashboard() → .usage(), with matching test
  expectation updates

Per-agent leaderboard polish (`packages/views/dashboard/components/
dashboard-page.tsx`)
- Card title "Cost & run time by agent" → "Leaderboard" with a 4-way
  Segmented control: Tokens / Cost / Time / Tasks
- Active metric drives row order, progress-bar width, and the
  emphasised column header / cell — keeping ranking, visual quantity,
  and column emphasis in lockstep so users always see what's being
  measured
- Default sort = Tokens (most universally meaningful; Cost still one
  click away)
- Project filter dropdown:
  - Show ProjectIcon next to the selected project + each list item;
    FolderKanban as the "All projects" fallback (matches ProjectPicker
    language)
  - alignItemWithTrigger={false} so "All projects" doesn't get pushed
    above the trigger and clipped when the header sits at the top of
    the viewport (was the root cause of "can't re-select All projects"
    once a project was selected)
  - max-h-72 to cap the dropdown when workspaces accrue many projects;
    matches the runtime-detail Select precedent
- Folder name `packages/views/dashboard/*` and `DashboardPage`
  component name intentionally left in place — user-visible rename
  only, no broad code refactor.

Old `/dashboard` routes are not redirected because the page only landed
in #2462 (a few days ago); no real users, external links, or
desktop-tab persistence have settled on it yet.
2026-05-13 14:07:53 +08:00

151 lines
7.7 KiB
TypeScript

import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@multica/core/api";
import { AppSidebar } from "./app-sidebar";
const { detail, deletePin, pins } = vi.hoisted(() => ({
detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } },
deletePin: vi.fn(),
pins: {
current: [
{
id: "pin-1",
workspace_id: "ws-1",
user_id: "user-1",
item_type: "issue" as const,
item_id: "issue-1",
position: 0,
created_at: "2026-05-06T00:00:00Z",
},
],
},
}));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PointerSensor: vi.fn(),
closestCenter: vi.fn(),
useSensor: vi.fn(),
useSensors: vi.fn(),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn() }),
verticalListSortingStrategy: vi.fn(),
}));
vi.mock("@dnd-kit/utilities", () => ({ CSS: { Transform: { toString: () => undefined } } }));
vi.mock("@multica/ui/components/ui/sidebar", () => ({
Sidebar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarFooter: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarGroupContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarHeader: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarMenuButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarRail: () => null,
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: () => null,
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
}));
vi.mock("@multica/ui/components/ui/collapsible", () => ({
Collapsible: ({ children }: { children: React.ReactNode }) => <>{children}</>,
CollapsibleContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
CollapsibleTrigger: () => <button type="button" />,
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
}));
vi.mock("./help-launcher", () => ({ HelpLauncher: () => null }));
vi.mock("../auth", () => ({ useLogout: () => vi.fn() }));
vi.mock("../issues/components/status-icon", () => ({ StatusIcon: () => <span /> }));
vi.mock("../navigation", () => ({
AppLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useNavigation: () => ({ pathname: "/acme/issues", push: vi.fn() }),
}));
vi.mock("../projects/components/project-icon", () => ({ ProjectIcon: () => <span /> }));
vi.mock("../workspace/workspace-avatar", () => ({ WorkspaceAvatar: () => <span /> }));
vi.mock("@multica/ui/components/common/actor-avatar", () => ({ ActorAvatar: () => <span /> }));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => selector({ user: { id: "user-1" } }),
}));
vi.mock("@multica/core/paths", () => ({
paths: { workspace: (slug: string) => ({ issues: () => `/${slug}/issues` }) },
useCurrentWorkspace: () => ({ id: "ws-1", name: "Acme", slug: "acme" }),
useWorkspacePaths: () => ({
inbox: () => "/acme/inbox",
myIssues: () => "/acme/my-issues",
issues: () => "/acme/issues",
projects: () => "/acme/projects",
autopilots: () => "/acme/autopilots",
agents: () => "/acme/agents",
usage: () => "/acme/usage",
runtimes: () => "/acme/runtimes",
skills: () => "/acme/skills",
settings: () => "/acme/settings",
issueDetail: (id: string) => `/acme/issues/${id}`,
projectDetail: (id: string) => `/acme/projects/${id}`,
}),
}));
vi.mock("@multica/core/api", async (importOriginal) => ({ ...(await importOriginal<typeof import("@multica/core/api")>()), api: {} }));
vi.mock("@multica/core/inbox/queries", () => ({ deduplicateInboxItems: (items: unknown[]) => items, inboxKeys: { list: () => ["inbox"] } }));
vi.mock("@multica/core/issues/queries", () => ({ issueDetailOptions: () => ({ queryKey: ["issue"] }) }));
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({ useCreateModeStore: { getState: () => ({ lastMode: "agent" }) } }));
vi.mock("@multica/core/issues/stores/draft-store", () => ({ useIssueDraftStore: () => false }));
vi.mock("@multica/core/modals", () => ({ useModalStore: { getState: () => ({ modal: null, open: vi.fn() }) } }));
vi.mock("@multica/core/pins/mutations", () => ({ useDeletePin: () => ({ mutate: deletePin }), useReorderPins: () => ({ mutate: vi.fn() }) }));
vi.mock("@multica/core/pins/queries", () => ({ pinListOptions: () => ({ queryKey: ["pins"] }) }));
vi.mock("@multica/core/projects/queries", () => ({ projectDetailOptions: () => ({ queryKey: ["project"] }) }));
vi.mock("@multica/core/runtimes/hooks", () => ({ useMyRuntimesNeedUpdate: () => false }));
vi.mock("@multica/core/workspace/queries", () => ({
myInvitationListOptions: () => ({ queryKey: ["invitations"] }),
workspaceKeys: { myInvitations: () => ["invitations"] },
workspaceListOptions: () => ({ queryKey: ["workspaces"] }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => ({
...(await importOriginal<typeof import("@tanstack/react-query")>()),
useMutation: () => ({ isPending: false, mutate: vi.fn() }),
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (queryKey[0] === "pins") return { data: pins.current };
if (queryKey[0] === "issue") return detail.current;
return { data: [] };
},
useQueryClient: () => ({ fetchQuery: vi.fn(), invalidateQueries: vi.fn() }),
}));
describe("PinRow", () => {
beforeEach(() => {
deletePin.mockReset();
detail.current = { isPending: false, isError: false, data: null, error: null };
});
it("unpins missing details", async () => {
detail.current = { isPending: false, isError: true, data: null, error: new ApiError("missing", 404, "Not Found") };
render(<AppSidebar />);
await waitFor(() => expect(deletePin).toHaveBeenCalledTimes(1));
});
it("ignores non-404 errors", async () => {
detail.current = { isPending: false, isError: true, data: null, error: new ApiError("error", 500, "Server Error") };
render(<AppSidebar />);
await waitFor(() => expect(deletePin).not.toHaveBeenCalled());
});
it("renders loaded details", async () => {
detail.current = { isPending: false, isError: false, data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" }, error: null };
render(<AppSidebar />);
expect(await screen.findByText("MUL-123 Keep this pin")).toBeInTheDocument();
});
});