diff --git a/canvas/src/components/WorkspaceUsage.tsx b/canvas/src/components/WorkspaceUsage.tsx index f09b6932..5ef629d4 100644 --- a/canvas/src/components/WorkspaceUsage.tsx +++ b/canvas/src/components/WorkspaceUsage.tsx @@ -1,15 +1,49 @@ 'use client'; -// WorkspaceUsage — Usage panel for a single workspace. -// Currently renders placeholder stat rows. -// TODO: fetch GET /workspaces/:id/metrics when #593 lands and replace -// placeholder values with real token/cost data from the response. +import { useState, useEffect } from "react"; +import { api } from "@/lib/api"; export interface WorkspaceUsageProps { workspaceId: string; } -export function WorkspaceUsage({ workspaceId: _workspaceId }: WorkspaceUsageProps) { +interface WorkspaceMetrics { + input_tokens: number; + output_tokens: number; + total_calls: number; + estimated_cost_usd: string; + period_start: string; + period_end: string; +} + +export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) { + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let ignore = false; + setLoading(true); + setError(null); + + api + .get(`/workspaces/${workspaceId}/metrics`) + .then((data) => { + if (!ignore) setMetrics(data); + }) + .catch((e) => { + if (!ignore) + setError(e instanceof Error ? e.message : "Failed to load metrics"); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + + return () => { + ignore = true; + }; + }, [workspaceId]); + return (
Usage - - pending #593 - + {!loading && metrics && ( + + {formatPeriod(metrics.period_start, metrics.period_end)} + + )}
- {/* Placeholder stat rows — will be replaced with live data once #593 lands */}
- - - + {loading ? ( + <> + + + + + ) : error ? ( +

+ {error} +

+ ) : metrics ? ( + <> + + + + + ) : null}
); } +function formatPeriod(start: string, end: string): string { + const fmt = (s: string) => + new Date(s).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + return `${fmt(start)} – ${fmt(end)}`; +} + +function SkeletonRow() { + return ( +
+
+
+
+ ); +} + function StatRow({ label, value, diff --git a/canvas/src/components/__tests__/WorkspaceUsage.test.tsx b/canvas/src/components/__tests__/WorkspaceUsage.test.tsx index af9facc6..d40deac8 100644 --- a/canvas/src/components/__tests__/WorkspaceUsage.test.tsx +++ b/canvas/src/components/__tests__/WorkspaceUsage.test.tsx @@ -1,75 +1,148 @@ // @vitest-environment jsdom -import { describe, it, expect, afterEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; + +// Mock api before importing the component +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn(), + }, +})); + +import { api } from "@/lib/api"; import { WorkspaceUsage } from "../WorkspaceUsage"; +const mockGet = vi.mocked(api.get); + +const METRICS_RESPONSE = { + input_tokens: 12345, + output_tokens: 678, + total_calls: 42, + estimated_cost_usd: "0.123456", + period_start: "2026-04-17T00:00:00Z", + period_end: "2026-04-18T00:00:00Z", +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + afterEach(() => { cleanup(); }); describe("WorkspaceUsage", () => { - it("renders without crashing", () => { - const { container } = render( - - ); + it("renders the outer container without crashing", () => { + // Keep fetch pending so we can check initial state + mockGet.mockReturnValue(new Promise(() => {})); + const { container } = render(); expect(container.firstChild).toBeTruthy(); }); it("renders the Usage heading", () => { - render(); + mockGet.mockReturnValue(new Promise(() => {})); + render(); expect(screen.getByText("Usage")).toBeTruthy(); }); - it("renders the pending #593 badge", () => { - render(); - const badge = screen.getByTestId("usage-pending-badge"); - expect(badge).toBeTruthy(); - expect(badge.textContent).toBe("pending #593"); + it("shows skeleton rows while loading", () => { + mockGet.mockReturnValue(new Promise(() => {})); + render(); + const skeletons = screen.getAllByTestId("usage-skeleton-row"); + expect(skeletons.length).toBe(3); }); - it("renders the outer container and stats container", () => { - render(); - expect(screen.getByTestId("workspace-usage")).toBeTruthy(); + it("calls GET /workspaces/:id/metrics with the correct workspaceId", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue(METRICS_RESPONSE as any); + render(); + await waitFor(() => expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-abc-123/metrics")); + }); + + it("displays input tokens formatted with toLocaleString after load", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue(METRICS_RESPONSE as any); + render(); + await waitFor(() => { + const row = screen.getByTestId("usage-input-tokens"); + expect(row).toBeTruthy(); + // 12345 formatted — locale-dependent but always has digits + "tokens" + expect(row.textContent).toContain("tokens"); + expect(row.textContent).toContain("12"); + }); + }); + + it("displays output tokens formatted with toLocaleString after load", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue(METRICS_RESPONSE as any); + render(); + await waitFor(() => { + const row = screen.getByTestId("usage-output-tokens"); + expect(row).toBeTruthy(); + expect(row.textContent).toContain("tokens"); + expect(row.textContent).toContain("678"); + }); + }); + + it("displays estimated cost formatted as $X.XXXXXX after load", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue(METRICS_RESPONSE as any); + render(); + await waitFor(() => { + const row = screen.getByTestId("usage-estimated-cost"); + expect(row).toBeTruthy(); + expect(row.textContent).toBe("Estimated cost$0.123456"); + }); + }); + + it("shows the stat rows and hides skeletons after successful load", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue(METRICS_RESPONSE as any); + render(); + await waitFor(() => { + expect(screen.queryAllByTestId("usage-skeleton-row").length).toBe(0); + expect(screen.getByTestId("usage-input-tokens")).toBeTruthy(); + expect(screen.getByTestId("usage-output-tokens")).toBeTruthy(); + expect(screen.getByTestId("usage-estimated-cost")).toBeTruthy(); + }); + }); + + it("shows error message when fetch fails", async () => { + mockGet.mockRejectedValue(new Error("API GET /workspaces/ws-1/metrics: 403 Forbidden")); + render(); + await waitFor(() => { + const err = screen.getByTestId("usage-error"); + expect(err).toBeTruthy(); + expect(err.textContent).toContain("403"); + }); + }); + + it("does not show stat rows on error", async () => { + mockGet.mockRejectedValue(new Error("network error")); + render(); + await waitFor(() => { + expect(screen.queryByTestId("usage-input-tokens")).toBeNull(); + expect(screen.queryByTestId("usage-output-tokens")).toBeNull(); + expect(screen.queryByTestId("usage-estimated-cost")).toBeNull(); + }); + }); + + it("re-fetches when workspaceId prop changes", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue(METRICS_RESPONSE as any); + const { rerender } = render(); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1)); + + rerender(); + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2); + expect(mockGet).toHaveBeenLastCalledWith("/workspaces/ws-2/metrics"); + }); + }); + + it("renders the usage-stats container in all states", () => { + mockGet.mockReturnValue(new Promise(() => {})); + render(); expect(screen.getByTestId("usage-stats")).toBeTruthy(); }); - - it("renders Input tokens row with placeholder dash", () => { - render(); - const row = screen.getByTestId("usage-input-tokens"); - expect(row).toBeTruthy(); - expect(row.textContent).toContain("Input tokens"); - expect(row.textContent).toContain("—"); - }); - - it("renders Output tokens row with placeholder dash", () => { - render(); - const row = screen.getByTestId("usage-output-tokens"); - expect(row).toBeTruthy(); - expect(row.textContent).toContain("Output tokens"); - expect(row.textContent).toContain("—"); - }); - - it("renders Estimated cost row with placeholder dash", () => { - render(); - const row = screen.getByTestId("usage-estimated-cost"); - expect(row).toBeTruthy(); - expect(row.textContent).toContain("Estimated cost"); - expect(row.textContent).toContain("—"); - }); - - it("accepts any workspaceId without throwing", () => { - const ids = ["", "ws-abc", "00000000-0000-0000-0000-000000000000"]; - for (const id of ids) { - const { unmount } = render(); - expect(screen.getByTestId("workspace-usage")).toBeTruthy(); - unmount(); - } - }); - - it("does not display live token counts or dollar amounts", () => { - render(); - const stats = screen.getByTestId("usage-stats"); - // Placeholder state must not contain any digit sequences - expect(stats.textContent).not.toMatch(/\d+/); - }); }); diff --git a/canvas/src/components/__tests__/ZoomShortcut.test.tsx b/canvas/src/components/__tests__/ZoomShortcut.test.tsx index b1521fe6..6b227c0f 100644 --- a/canvas/src/components/__tests__/ZoomShortcut.test.tsx +++ b/canvas/src/components/__tests__/ZoomShortcut.test.tsx @@ -6,6 +6,24 @@ import React from "react"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// vi.mock is hoisted to module top level by Vitest regardless of where it appears +// in the source. Placing it here explicitly matches that runtime behaviour and +// silences the "not at top level" warning (closes #632). +vi.mock("../../../store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn(() => null), + { + getState: () => ({ + selectedNodeId: null, + nodes: [], + contextMenu: null, + closeContextMenu: vi.fn(), + selectNode: vi.fn(), + }), + } + ), +})); + afterEach(() => cleanup()); // ─── Z key handler unit tests (no React needed) ───────────────────────────── @@ -25,22 +43,6 @@ describe("Z key → molecule:zoom-to-team", () => { }); it("does NOT fire when no node is selected", () => { - // Simulate store: no selection - vi.mock("../../../store/canvas", () => ({ - useCanvasStore: Object.assign( - vi.fn(() => null), - { - getState: () => ({ - selectedNodeId: null, - nodes: [], - contextMenu: null, - closeContextMenu: vi.fn(), - selectNode: vi.fn(), - }), - } - ), - })); - fireEvent.keyDown(window, { key: "Z" }); expect(dispatchedEvents).toHaveLength(0); });