import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import type { ComponentProps } from "react"; import { TableControls, formatDuration } from "./TableControls"; import { TooltipProvider } from "../ui/Tooltip"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useNotificationStore } from "../../stores/notificationStore"; import * as exportData from "../../lib/exportData"; import type { ViewerTab } from "../../stores/dbViewerStore"; const columns = [ { name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false, }, ]; function makeTab(overrides: Partial = {}): ViewerTab { return { id: "tab-1", schema: "public", table: "users", page: 1, pageSize: 50, loading: false, error: null, data: { columns, rows: [[1]], total_rows: 1, page: 1, page_size: 50 }, filterRules: [], sortRules: [], hiddenColumns: [], smartSortApplied: true, tabType: "table", ...overrides, }; } function seed(tabs: ViewerTab[], activeTabId: string) { useDbViewerStore.getState().reset(); useDbViewerStore.setState({ tabs, activeTabId }); } function renderControls( props: Partial> = {}, ) { return render( {}} onRefresh={() => {}} filterRules={[]} onFilterChange={() => {}} sortRules={[]} onSortChange={() => {}} selectedCount={0} selectedRows={[]} onClearSelection={() => {}} {...props} /> , ); } describe("formatDuration", () => { it("formats milliseconds with two decimals", () => { expect(formatDuration(15)).toBe("15.00ms"); }); it("formats seconds with one decimal once past a second", () => { expect(formatDuration(1500)).toBe("1.5s"); expect(formatDuration(3200)).toBe("3.2s"); }); it("formats minutes for long-running queries", () => { expect(formatDuration(90000)).toBe("1.5m"); }); it("returns an empty string when there is no timing", () => { expect(formatDuration(null)).toBe(""); expect(formatDuration(undefined)).toBe(""); }); }); describe("TableControls query variant", () => { it("shows Export, Re-run, and Columns on the left", () => { seed([makeTab({ tabType: "query" })], "tab-1"); renderControls({ variant: "query" }); expect(screen.getByLabelText(/export/i)).toBeInTheDocument(); expect(screen.getByLabelText(/re-run query/i)).toBeInTheDocument(); expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument(); }); it("hides table-only controls and the queue", () => { seed([makeTab({ tabType: "query" })], "tab-1"); renderControls({ variant: "query" }); expect(screen.queryByLabelText(/insert row/i)).toBeNull(); expect(screen.queryByLabelText(/auto-refresh/i)).toBeNull(); expect(screen.queryByLabelText(/column filters/i)).toBeNull(); expect(screen.queryByLabelText(/sort rules/i)).toBeNull(); expect(screen.queryByRole("button", { name: /action queue/i })).toBeNull(); }); it("shows the execution time from the result with a clock", () => { seed( [ makeTab({ tabType: "query", data: { columns, rows: [], total_rows: 0, page: 1, page_size: 50, execution_time_ms: 15, }, }), ], "tab-1", ); renderControls({ variant: "query" }); expect(screen.getByLabelText(/execution time/i)).toBeInTheDocument(); expect(screen.getByText("15.00ms")).toBeInTheDocument(); }); it("keeps the row count and pagination", () => { seed( [ makeTab({ tabType: "query", data: { columns, rows: [[1]], total_rows: 42, page: 1, page_size: 50, execution_time_ms: 15, }, }), ], "tab-1", ); renderControls({ variant: "query" }); expect(screen.getByText(/of 42/)).toBeInTheDocument(); expect(screen.getByLabelText(/next page/i)).toBeInTheDocument(); }); it("table variant has no queue button (moved to tab bar) and no execution time", () => { seed( [ makeTab({ data: { columns, rows: [[1]], total_rows: 1, page: 1, page_size: 50, execution_time_ms: 15, }, }), ], "tab-1", ); renderControls({}); expect( screen.queryByRole("button", { name: /action queue/i }), ).toBeNull(); expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument(); expect(screen.queryByText("15.00ms")).toBeNull(); }); }); describe("TableControls", () => { beforeEach(() => { useDbViewerStore.getState().reset(); vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); it("calls onRefresh when the refresh button is clicked", () => { seed([makeTab()], "tab-1"); const onRefresh = vi.fn(); renderControls({ onRefresh }); fireEvent.click(screen.getByLabelText(/refresh table/i)); expect(onRefresh).toHaveBeenCalledTimes(1); }); it("does not spin the refresh icon or show the pulse when idle", () => { seed([makeTab()], "tab-1"); const { container } = renderControls(); expect(container.querySelector(".animate-spin")).toBeNull(); expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument(); }); it("spins the refresh icon and shows the pulse overlay while the tab is loading", () => { seed([makeTab({ loading: true })], "tab-1"); const { container } = renderControls(); expect(container.querySelector(".animate-spin")).not.toBeNull(); expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument(); }); it("shows the refreshing indicators when auto-refresh fires and clears them when done", () => { vi.useFakeTimers(); seed([makeTab()], "tab-1"); const onRefresh = vi.fn(() => { useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === "tab-1" ? { ...t, loading: true } : t, ), })); }); const { container } = renderControls({ onRefresh, defaultRefreshRate: 5000, }); expect(container.querySelector(".animate-spin")).toBeNull(); expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument(); act(() => { vi.advanceTimersByTime(5000); }); expect(onRefresh).toHaveBeenCalledTimes(1); expect(container.querySelector(".animate-spin")).not.toBeNull(); expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument(); // Once the fetch completes the indicators disappear act(() => { useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === "tab-1" ? { ...t, loading: false } : t, ), })); }); expect(container.querySelector(".animate-spin")).toBeNull(); expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument(); }); it("waits for an in-flight refresh to complete before restarting the timer", () => { vi.useFakeTimers(); seed([makeTab()], "tab-1"); const onRefresh = vi.fn(() => { useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === "tab-1" ? { ...t, loading: true } : t, ), })); }); renderControls({ onRefresh, defaultRefreshRate: 5000 }); // First interval fires the refresh act(() => { vi.advanceTimersByTime(5000); }); expect(onRefresh).toHaveBeenCalledTimes(1); // While the refresh is still in flight, the timer must NOT fire again act(() => { vi.advanceTimersByTime(15000); }); expect(onRefresh).toHaveBeenCalledTimes(1); // Once the refresh completes, a fresh countdown starts act(() => { useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === "tab-1" ? { ...t, loading: false } : t, ), })); }); act(() => { vi.advanceTimersByTime(5000); }); expect(onRefresh).toHaveBeenCalledTimes(2); }); it("defers auto-refresh when the user switches tabs (resets the timer)", () => { vi.useFakeTimers(); const onRefresh = vi.fn(); seed([makeTab(), makeTab({ id: "tab-2", table: "orders" })], "tab-1"); renderControls({ onRefresh, defaultRefreshRate: 5000 }); // Not yet a full interval act(() => { vi.advanceTimersByTime(4000); }); expect(onRefresh).not.toHaveBeenCalled(); // User switches to another tab → countdown restarts act(() => { useDbViewerStore.setState({ activeTabId: "tab-2" }); }); act(() => { vi.advanceTimersByTime(4000); }); expect(onRefresh).not.toHaveBeenCalled(); // Full interval after the switch finally fires act(() => { vi.advanceTimersByTime(1000); }); expect(onRefresh).toHaveBeenCalledTimes(1); }); it("hides the Insert Row button for a materialized view", () => { seed([makeTab()], "tab-1"); renderControls({ isMatview: true }); expect(screen.queryByLabelText(/insert row/i)).toBeNull(); }); it("renders the FilterBuilder inside the filter popover", () => { seed([makeTab()], "tab-1"); renderControls(); fireEvent.click(screen.getByLabelText(/column filters/i)); expect( screen.getByText("Drop columns here to add filters"), ).toBeInTheDocument(); }); it("export dropdown includes the Excel option", () => { seed([makeTab()], "tab-1"); renderControls(); fireEvent.click(screen.getByLabelText(/export/i)); expect(screen.getByText("JSON")).toBeInTheDocument(); expect(screen.getByText("CSV")).toBeInTheDocument(); expect(screen.getByText("SQL")).toBeInTheDocument(); expect(screen.getByText("Markdown")).toBeInTheDocument(); expect(screen.getByText("Excel")).toBeInTheDocument(); }); it("notifies after a successful export", () => { seed([makeTab()], "tab-1"); useNotificationStore.getState().notifications.length = 0; vi.spyOn(exportData, "exportData").mockImplementation(() => {}); renderControls(); fireEvent.click(screen.getByLabelText(/export/i)); fireEvent.click(screen.getByText("Excel")); const st = useNotificationStore.getState(); expect( st.notifications.some((n) => n.message.toLowerCase().includes("exported")), ).toBe(true); }); });