- Install recharts for data visualization - Add Usage nav item to sidebar navigation - Create telemetry API service with data fetching functions - Build dashboard page with summary cards, charts, and time range selector - Token usage line chart, cost breakdown bar chart, task outcome pie chart - Loading and empty states handled - Responsive layout with PDA-friendly design - Add unit tests (14 tests passing) Refs #375 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
import UsagePage from "./page";
|
|
|
|
// ─── Component Prop Types ────────────────────────────────────────────
|
|
|
|
interface ChildrenProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
interface StyledChildrenProps extends ChildrenProps {
|
|
className?: string;
|
|
}
|
|
|
|
// ─── Mocks ───────────────────────────────────────────────────────────
|
|
|
|
// Mock @/components/ui/card — @mosaic/ui can't be resolved in vitest
|
|
vi.mock("@/components/ui/card", () => ({
|
|
Card: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
|
<div className={className}>{children}</div>
|
|
),
|
|
CardHeader: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
|
|
CardContent: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
|
<div className={className}>{children}</div>
|
|
),
|
|
CardFooter: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
|
|
CardTitle: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
|
<h3 className={className}>{children}</h3>
|
|
),
|
|
CardDescription: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
|
<p className={className}>{children}</p>
|
|
),
|
|
}));
|
|
|
|
// Mock recharts — jsdom has no SVG layout engine, so we render stubs
|
|
vi.mock("recharts", () => ({
|
|
LineChart: ({ children }: ChildrenProps): React.JSX.Element => (
|
|
<div data-testid="recharts-line-chart">{children}</div>
|
|
),
|
|
Line: (): React.JSX.Element => <div />,
|
|
BarChart: ({ children }: ChildrenProps): React.JSX.Element => (
|
|
<div data-testid="recharts-bar-chart">{children}</div>
|
|
),
|
|
Bar: (): React.JSX.Element => <div />,
|
|
PieChart: ({ children }: ChildrenProps): React.JSX.Element => (
|
|
<div data-testid="recharts-pie-chart">{children}</div>
|
|
),
|
|
Pie: (): React.JSX.Element => <div />,
|
|
Cell: (): React.JSX.Element => <div />,
|
|
XAxis: (): React.JSX.Element => <div />,
|
|
YAxis: (): React.JSX.Element => <div />,
|
|
CartesianGrid: (): React.JSX.Element => <div />,
|
|
Tooltip: (): React.JSX.Element => <div />,
|
|
ResponsiveContainer: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
|
|
Legend: (): React.JSX.Element => <div />,
|
|
}));
|
|
|
|
// Mock the telemetry API module
|
|
vi.mock("@/lib/api/telemetry", () => ({
|
|
fetchUsageSummary: vi.fn(),
|
|
fetchTokenUsage: vi.fn(),
|
|
fetchCostBreakdown: vi.fn(),
|
|
fetchTaskOutcomes: vi.fn(),
|
|
}));
|
|
|
|
// Import mocked modules after vi.mock
|
|
import {
|
|
fetchUsageSummary,
|
|
fetchTokenUsage,
|
|
fetchCostBreakdown,
|
|
fetchTaskOutcomes,
|
|
} from "@/lib/api/telemetry";
|
|
|
|
// ─── Test Data ───────────────────────────────────────────────────────
|
|
|
|
const mockSummary = {
|
|
totalTokens: 245800,
|
|
totalCost: 3.42,
|
|
taskCount: 47,
|
|
avgQualityGatePassRate: 0.87,
|
|
};
|
|
|
|
const mockTokenUsage = [
|
|
{ date: "2026-02-08", inputTokens: 10000, outputTokens: 5000, totalTokens: 15000 },
|
|
{ date: "2026-02-09", inputTokens: 12000, outputTokens: 6000, totalTokens: 18000 },
|
|
];
|
|
|
|
const mockCostBreakdown = [
|
|
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
|
|
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
|
|
];
|
|
|
|
const mockTaskOutcomes = [
|
|
{ outcome: "Success", count: 312, color: "#6EBF8B" },
|
|
{ outcome: "Partial", count: 48, color: "#F5C862" },
|
|
];
|
|
|
|
function setupMocks(overrides?: { empty?: boolean; error?: boolean }): void {
|
|
if (overrides?.error) {
|
|
vi.mocked(fetchUsageSummary).mockRejectedValue(new Error("Network error"));
|
|
vi.mocked(fetchTokenUsage).mockRejectedValue(new Error("Network error"));
|
|
vi.mocked(fetchCostBreakdown).mockRejectedValue(new Error("Network error"));
|
|
vi.mocked(fetchTaskOutcomes).mockRejectedValue(new Error("Network error"));
|
|
return;
|
|
}
|
|
|
|
const summary = overrides?.empty ? { ...mockSummary, taskCount: 0 } : mockSummary;
|
|
|
|
vi.mocked(fetchUsageSummary).mockResolvedValue(summary);
|
|
vi.mocked(fetchTokenUsage).mockResolvedValue(mockTokenUsage);
|
|
vi.mocked(fetchCostBreakdown).mockResolvedValue(mockCostBreakdown);
|
|
vi.mocked(fetchTaskOutcomes).mockResolvedValue(mockTaskOutcomes);
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────
|
|
|
|
describe("UsagePage", (): void => {
|
|
beforeEach((): void => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should render the page title and subtitle", (): void => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Usage");
|
|
expect(screen.getByText("Token usage and cost overview")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should have proper layout structure", (): void => {
|
|
setupMocks();
|
|
const { container } = render(<UsagePage />);
|
|
const main = container.querySelector("main");
|
|
expect(main).toBeInTheDocument();
|
|
});
|
|
|
|
it("should show loading skeleton initially", (): void => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render summary cards after loading", async (): Promise<void> => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
|
});
|
|
|
|
// Check summary card values
|
|
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
|
|
expect(screen.getByText("245.8K")).toBeInTheDocument();
|
|
expect(screen.getByText("Estimated Cost")).toBeInTheDocument();
|
|
expect(screen.getByText("$3.42")).toBeInTheDocument();
|
|
expect(screen.getByText("Task Count")).toBeInTheDocument();
|
|
expect(screen.getByText("47")).toBeInTheDocument();
|
|
expect(screen.getByText("Quality Gate Pass Rate")).toBeInTheDocument();
|
|
expect(screen.getByText("87.0%")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render all chart sections after loading", async (): Promise<void> => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByTestId("token-usage-chart")).toBeInTheDocument();
|
|
expect(screen.getByTestId("cost-breakdown-chart")).toBeInTheDocument();
|
|
expect(screen.getByTestId("task-outcomes-chart")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should render the time range selector with three options", (): void => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
expect(screen.getByText("7 Days")).toBeInTheDocument();
|
|
expect(screen.getByText("30 Days")).toBeInTheDocument();
|
|
expect(screen.getByText("90 Days")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should have 30 Days selected by default", (): void => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
const button30d = screen.getByText("30 Days");
|
|
expect(button30d).toHaveAttribute("aria-pressed", "true");
|
|
});
|
|
|
|
it("should change time range when a different option is clicked", async (): Promise<void> => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
// Wait for initial load
|
|
await waitFor((): void => {
|
|
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
|
});
|
|
|
|
// Click 7 Days
|
|
const button7d = screen.getByText("7 Days");
|
|
fireEvent.click(button7d);
|
|
|
|
expect(button7d).toHaveAttribute("aria-pressed", "true");
|
|
expect(screen.getByText("30 Days")).toHaveAttribute("aria-pressed", "false");
|
|
});
|
|
|
|
it("should refetch data when time range changes", async (): Promise<void> => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
// Wait for initial load (30d default)
|
|
await waitFor((): void => {
|
|
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
|
});
|
|
|
|
// Initial call was with "30d"
|
|
expect(fetchUsageSummary).toHaveBeenCalledWith("30d");
|
|
|
|
// Change to 7d
|
|
fireEvent.click(screen.getByText("7 Days"));
|
|
|
|
await waitFor((): void => {
|
|
expect(fetchUsageSummary).toHaveBeenCalledWith("7d");
|
|
});
|
|
});
|
|
|
|
it("should show empty state when no tasks exist", async (): Promise<void> => {
|
|
setupMocks({ empty: true });
|
|
render(<UsagePage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("No usage data yet")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should show error state on fetch failure", async (): Promise<void> => {
|
|
setupMocks({ error: true });
|
|
render(<UsagePage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("Try again")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should retry loading when Try again button is clicked after error", async (): Promise<void> => {
|
|
setupMocks({ error: true });
|
|
render(<UsagePage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByText("Try again")).toBeInTheDocument();
|
|
});
|
|
|
|
// Now set up success mocks and click retry
|
|
setupMocks();
|
|
fireEvent.click(screen.getByText("Try again"));
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should display chart section titles", async (): Promise<void> => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument();
|
|
expect(screen.getByText("Cost by Model")).toBeInTheDocument();
|
|
expect(screen.getByText("Task Outcomes")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should render recharts components within chart containers", async (): Promise<void> => {
|
|
setupMocks();
|
|
render(<UsagePage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByTestId("recharts-line-chart")).toBeInTheDocument();
|
|
expect(screen.getByTestId("recharts-bar-chart")).toBeInTheDocument();
|
|
expect(screen.getByTestId("recharts-pie-chart")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|