import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { UsageIndicator } from "./UsageIndicator"; import * as useUsageDataModule from "../hooks/useUsageData"; import type { ProviderUsage } from "../api"; // Mock the useUsageData hook vi.mock("../hooks/useUsageData", () => ({ useUsageData: vi.fn(), })); const mockUseUsageData = vi.mocked(useUsageDataModule.useUsageData); describe("UsageIndicator", () => { const mockOnClose = vi.fn(); const mockRefresh = vi.fn(); const mockProviders: ProviderUsage[] = [ { name: "Anthropic", icon: "๐Ÿ…ฐ๏ธ", status: "ok", plan: "Pro", email: "user@example.com", windows: [ { label: "Session (5h)", percentUsed: 45, percentLeft: 55, resetText: "resets in 2h 15m", resetMs: 8100000, }, { label: "Weekly", percentUsed: 30, percentLeft: 70, resetText: "resets in 3d", resetMs: 259200000, }, ], }, { name: "OpenAI", icon: "๐Ÿค–", status: "ok", windows: [ { label: "Hourly", percentUsed: 75, percentLeft: 25, resetText: "resets in 45m", resetMs: 2700000, }, ], }, { name: "Google", icon: "๐Ÿ”", status: "no-auth", windows: [], }, ]; beforeEach(() => { vi.clearAllMocks(); }); it("renders nothing when isOpen is false", () => { mockUseUsageData.mockReturnValue({ providers: [], loading: false, error: null, lastUpdated: null, refresh: mockRefresh, }); const { container } = render(); expect(container.firstChild).toBeNull(); }); it("renders modal when isOpen is true", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); expect(screen.getByTestId("usage-modal")).toBeInTheDocument(); expect(screen.getByText("Usage")).toBeInTheDocument(); }); it("renders provider cards with correct data", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); // Check provider names are rendered expect(screen.getByText("Anthropic")).toBeInTheDocument(); expect(screen.getByText("OpenAI")).toBeInTheDocument(); expect(screen.getByText("Google")).toBeInTheDocument(); // Check status badges expect(screen.getAllByText("Connected").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Not configured")).toBeInTheDocument(); // Check usage windows expect(screen.getByText("Session (5h)")).toBeInTheDocument(); expect(screen.getByText("Weekly")).toBeInTheDocument(); expect(screen.getByText("Hourly")).toBeInTheDocument(); }); it("shows loading skeleton when loading", () => { mockUseUsageData.mockReturnValue({ providers: [], loading: true, error: null, lastUpdated: null, refresh: mockRefresh, }); render(); // Should show skeleton elements expect(document.querySelector(".usage-skeleton")).toBeInTheDocument(); }); it("shows error state when there is an error", () => { mockUseUsageData.mockReturnValue({ providers: [], loading: false, error: "Failed to fetch usage data", lastUpdated: null, refresh: mockRefresh, }); render(); expect(screen.getByText("Failed to load usage data")).toBeInTheDocument(); expect(screen.getByText("Failed to fetch usage data")).toBeInTheDocument(); expect(screen.getByText("Retry")).toBeInTheDocument(); }); it("shows empty state when no providers", () => { mockUseUsageData.mockReturnValue({ providers: [], loading: false, error: null, lastUpdated: null, refresh: mockRefresh, }); render(); expect(screen.getByText("No AI providers configured")).toBeInTheDocument(); expect( screen.getByText("Configure authentication in Settings to see usage data.") ).toBeInTheDocument(); }); it("calls refresh when refresh button clicked", async () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const refreshBtn = screen.getByTestId("usage-refresh-btn"); fireEvent.click(refreshBtn); expect(mockRefresh).toHaveBeenCalledTimes(1); }); it("calls onClose when close button clicked", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const closeBtn = screen.getByTestId("usage-modal-close"); fireEvent.click(closeBtn); expect(mockOnClose).toHaveBeenCalledTimes(1); }); it("calls onClose when overlay is clicked", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const overlay = screen.getByTestId("usage-modal-overlay"); fireEvent.click(overlay); expect(mockOnClose).toHaveBeenCalledTimes(1); }); it("calls onClose when Escape key is pressed", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); fireEvent.keyDown(document, { key: "Escape" }); expect(mockOnClose).toHaveBeenCalledTimes(1); }); it("renders progress bars with correct color classes", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Low Usage", percentUsed: 45, percentLeft: 55, resetText: "1h" }, { label: "Medium Usage", percentUsed: 75, percentLeft: 25, resetText: "2h" }, { label: "High Usage", percentUsed: 95, percentLeft: 5, resetText: "3h" }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); // Check progress bars exist with correct widths const progressBars = document.querySelectorAll(".usage-progress-fill"); expect(progressBars.length).toBe(3); // Check color classes are applied expect(document.querySelector(".usage-progress-fill--low")).toBeInTheDocument(); expect(document.querySelector(".usage-progress-fill--medium")).toBeInTheDocument(); expect(document.querySelector(".usage-progress-fill--high")).toBeInTheDocument(); }); it("disables refresh button when loading", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: true, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const refreshBtn = screen.getByTestId("usage-refresh-btn"); expect(refreshBtn).toBeDisabled(); }); it("passes autoRefresh option based on isOpen prop", () => { mockUseUsageData.mockReturnValue({ providers: [], loading: false, error: null, lastUpdated: null, refresh: mockRefresh, }); // Reset mock before testing mockUseUsageData.mockClear(); // When isOpen is true, autoRefresh should be true const { unmount } = render(); expect(mockUseUsageData).toHaveBeenCalledWith({ autoRefresh: true }); unmount(); // Reset mock mockUseUsageData.mockClear(); // When isOpen is false, autoRefresh should be false to prevent polling render(); // The hook is called even when isOpen is false because hooks must be called // unconditionally at the top level in React expect(mockUseUsageData).toHaveBeenCalledWith({ autoRefresh: false }); }); it("renders provider error messages", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "ErrorProvider", icon: "โŒ", status: "error", error: "Auth expired โ€” run 'claude' to re-login", windows: [], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); expect(screen.getByText("Error")).toBeInTheDocument(); expect(screen.getByText("Auth expired โ€” run 'claude' to re-login")).toBeInTheDocument(); }); it("shows last updated timestamp", () => { const lastUpdated = new Date("2024-01-15T10:30:00"); mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated, refresh: mockRefresh, }); render(); expect(screen.getByText(/Last updated:/)).toBeInTheDocument(); expect(screen.getByText(/10:30:00/)).toBeInTheDocument(); }); it("renders usage windows with correct percentage text", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Session", percentUsed: 45, percentLeft: 55, resetText: "resets in 2h" }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); expect(screen.getByText("45% used")).toBeInTheDocument(); expect(screen.getByText("55% left")).toBeInTheDocument(); expect(screen.getByText("resets in 2h")).toBeInTheDocument(); }); // View mode toggle tests it("renders view mode toggle buttons with correct initial state", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const usedBtn = screen.getByTestId("usage-view-toggle-used"); const remainingBtn = screen.getByTestId("usage-view-toggle-remaining"); expect(usedBtn).toBeInTheDocument(); expect(remainingBtn).toBeInTheDocument(); expect(usedBtn).toHaveClass("active"); expect(remainingBtn).not.toHaveClass("active"); }); it("switches view mode when toggle buttons are clicked", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Session", percentUsed: 45, percentLeft: 55, resetText: "resets in 2h" }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const usedBtn = screen.getByTestId("usage-view-toggle-used"); const remainingBtn = screen.getByTestId("usage-view-toggle-remaining"); // Initially shows "used" view expect(screen.getByText("45% used")).toBeInTheDocument(); // Click remaining button fireEvent.click(remainingBtn); // Now should show "remaining" view expect(remainingBtn).toHaveClass("active"); expect(usedBtn).not.toHaveClass("active"); expect(screen.getByText("55% remaining")).toBeInTheDocument(); expect(screen.getByText("45% used")).toBeInTheDocument(); // Footer text // Click back to used fireEvent.click(usedBtn); expect(usedBtn).toHaveClass("active"); expect(remainingBtn).not.toHaveClass("active"); expect(screen.getByText("45% used")).toBeInTheDocument(); }); it("reads view mode from localStorage on mount", () => { // Set localStorage to 'remaining' before rendering localStorage.setItem("kb-usage-view-mode", "remaining"); mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Session", percentUsed: 45, percentLeft: 55, resetText: "resets in 2h" }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const usedBtn = screen.getByTestId("usage-view-toggle-used"); const remainingBtn = screen.getByTestId("usage-view-toggle-remaining"); // Should initialize to 'remaining' from localStorage expect(remainingBtn).toHaveClass("active"); expect(usedBtn).not.toHaveClass("active"); expect(screen.getByText("55% remaining")).toBeInTheDocument(); // Clean up localStorage.removeItem("kb-usage-view-mode"); }); it("persists view mode to localStorage when changed", () => { mockUseUsageData.mockReturnValue({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const remainingBtn = screen.getByTestId("usage-view-toggle-remaining"); // Click remaining button fireEvent.click(remainingBtn); // Should save to localStorage expect(localStorage.getItem("kb-usage-view-mode")).toBe("remaining"); // Clean up localStorage.removeItem("kb-usage-view-mode"); }); // ProviderIcon integration tests it("renders SVG provider icons instead of emoji", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "Anthropic", icon: "๐Ÿ…ฐ๏ธ", status: "ok", windows: [] }, { name: "OpenAI", icon: "๐Ÿค–", status: "ok", windows: [] }, { name: "Google", icon: "๐Ÿ”", status: "ok", windows: [] }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); // Should render SVG icons with correct provider data attributes expect(document.querySelector('[data-provider="anthropic"]')).toBeInTheDocument(); expect(document.querySelector('[data-provider="openai"]')).toBeInTheDocument(); expect(document.querySelector('[data-provider="google"]')).toBeInTheDocument(); }); it("maps Claude provider to anthropic icon", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "Claude", icon: "๐Ÿ…ฐ๏ธ", status: "ok", windows: [] }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); expect(document.querySelector('[data-provider="anthropic"]')).toBeInTheDocument(); expect(document.querySelector("svg[aria-label='Anthropic']")).toBeInTheDocument(); }); it("maps Codex provider to openai icon", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "Codex", icon: "๐Ÿค–", status: "ok", windows: [] }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); expect(document.querySelector('[data-provider="openai"]')).toBeInTheDocument(); expect(document.querySelector("svg[aria-label='OpenAI']")).toBeInTheDocument(); }); it("maps Gemini provider to google icon", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "Gemini", icon: "๐Ÿ”", status: "ok", windows: [] }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); expect(document.querySelector('[data-provider="google"]')).toBeInTheDocument(); expect(document.querySelector("svg[aria-label='Google Gemini']")).toBeInTheDocument(); }); // Pace indicator tests it("renders pace marker for weekly windows with timing data", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 30, percentLeft: 70, resetText: "resets in 3d", resetMs: 259200000, // 3 days remaining windowDurationMs: 604800000, // 7 days total pace: { status: "behind", percentElapsed: 57, message: "27% under pace", }, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const paceMarker = document.querySelector('[data-testid="pace-marker"]'); expect(paceMarker).toBeInTheDocument(); }); it("does not render pace marker for non-weekly windows (Session, Hourly)", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Session (5h)", percentUsed: 45, percentLeft: 55, resetText: "resets in 2h", resetMs: 7200000, windowDurationMs: 18000000, }, { label: "Hourly", percentUsed: 60, percentLeft: 40, resetText: "resets in 30m", resetMs: 1800000, windowDurationMs: 3600000, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const paceMarkers = document.querySelectorAll('[data-testid="pace-marker"]'); expect(paceMarkers.length).toBe(0); }); it("does not render pace marker when pace is undefined", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 30, percentLeft: 70, resetText: "resets in 3d", // No pace field }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const paceMarker = document.querySelector('[data-testid="pace-marker"]'); expect(paceMarker).not.toBeInTheDocument(); }); it("shows 'ahead of pace' text when usage exceeds elapsed time by >5%", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 70, // 70% used percentLeft: 30, resetText: "resets in 3.5d", resetMs: 302400000, // 3.5 days remaining out of 7 windowDurationMs: 604800000, // 7 days total pace: { status: "ahead", percentElapsed: 50, message: "20% over pace", }, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const paceRow = screen.getByTestId("pace-row"); expect(paceRow).toHaveTextContent(/over pace/); expect(paceRow).toHaveTextContent("20%"); }); it("shows 'behind pace' text when usage is under elapsed time by >5%", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 20, // 20% used percentLeft: 80, resetText: "resets in 3.5d", resetMs: 302400000, // 3.5 days remaining out of 7 windowDurationMs: 604800000, // 7 days total pace: { status: "behind", percentElapsed: 50, message: "30% under pace", }, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const paceRow = screen.getByTestId("pace-row"); expect(paceRow).toHaveTextContent(/under pace/); expect(paceRow).toHaveTextContent("30%"); }); it("shows 'on pace' text when usage is within 5% of elapsed time", () => { mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 52, // 52% used percentLeft: 48, resetText: "resets in 3.5d", resetMs: 302400000, // 3.5 days remaining out of 7 windowDurationMs: 604800000, // 7 days total pace: { status: "on-track", percentElapsed: 50, message: "On pace with time elapsed", }, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); const paceRow = screen.getByTestId("pace-row"); expect(paceRow).toHaveTextContent(/On pace/); }); it("pace marker position inverts correctly when switching to remaining mode", () => { // Mock provider with weekly window mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 30, percentLeft: 70, resetText: "resets in 3.5d", resetMs: 302400000, // 50% elapsed windowDurationMs: 604800000, pace: { status: "behind", percentElapsed: 50, message: "20% under pace", }, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); // In used mode: marker at 50% let paceMarker = document.querySelector('[data-testid="pace-marker"]') as HTMLElement; expect(paceMarker).toBeInTheDocument(); expect(paceMarker.style.left).toBe("50%"); // Switch to remaining mode const remainingBtn = screen.getByTestId("usage-view-toggle-remaining"); fireEvent.click(remainingBtn); // In remaining mode: marker at 100 - 50 = 50% (same in this case since it's 50/50) paceMarker = document.querySelector('[data-testid="pace-marker"]') as HTMLElement; expect(paceMarker.style.left).toBe("50%"); }); it("pace percentage text uses backend message directly", () => { // Clear localStorage to ensure fresh 'used' mode localStorage.removeItem("kb-usage-view-mode"); // Setup: 70% used (ahead of pace), 30% remaining mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 70, // 70% used percentLeft: 30, resetText: "resets in 3.5d", resetMs: 302400000, // 50% elapsed windowDurationMs: 604800000, pace: { status: "ahead", percentElapsed: 50, message: "20% over pace", }, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); // In used mode: ahead of pace (70% used vs 50% elapsed) let paceRow = screen.getByTestId("pace-row"); expect(paceRow).toHaveTextContent(/over pace/); // Switch to remaining mode - message stays the same (from backend) const remainingBtn = screen.getByTestId("usage-view-toggle-remaining"); fireEvent.click(remainingBtn); // The message comes from backend, so it doesn't change based on view mode paceRow = screen.getByTestId("pace-row"); expect(paceRow).toHaveTextContent("20% over pace"); }); it("verifies pace appears for weekly windows with valid backend timing data", () => { // This test verifies the full data flow: backend calculates pace when both // resetMs and windowDurationMs are present, frontend displays the indicator mockUseUsageData.mockReturnValue({ providers: [ { name: "TestProvider", icon: "๐Ÿงช", status: "ok", windows: [ { label: "Weekly", percentUsed: 40, percentLeft: 60, resetText: "resets in 4d", resetMs: 345600000, // 4 days remaining windowDurationMs: 604800000, // 7 days total // Pace should be calculated by backend and included in response pace: { status: "behind", percentElapsed: 43, message: "3% under pace", }, }, ], }, ], loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh, }); render(); // Pace marker should be rendered const paceMarker = document.querySelector('[data-testid="pace-marker"]'); expect(paceMarker).toBeInTheDocument(); // Pace row should show status const paceRow = screen.getByTestId("pace-row"); expect(paceRow).toBeInTheDocument(); expect(paceRow).toHaveTextContent("under pace"); }); });