test: add comprehensive frontend tests for components, stores, and utils
Full test coverage for the frontend implementation using Vitest and Testing Library. Tests are organized by concern with shared fixtures. Component tests: - AppShell.test.tsx: Navigation tabs, view switching, batch mode overlay - FocusCard.test.tsx: Rendering, action buttons, keyboard shortcuts, empty state - QueueView.test.tsx: Item display, focus promotion, empty state - QueueItem.test.tsx: Type badges, click handling - QueueSummary.test.tsx: Count display by type - QuickCapture.test.tsx: Modal behavior, form submission, error states - BatchMode.test.tsx: Progress tracking, item advancement, completion - App.test.tsx: Updated for AppShell integration Store tests: - focus-store.test.ts: Item management, act(), setFocus(), reorderQueue() - nav-store.test.ts: View switching - capture-store.test.ts: Open/close, submission states - batch-store.test.ts: Batch lifecycle, status tracking, derived counts Library tests: - types.test.ts: Type guards, staleness computation - transform.test.ts: Lore data transformation, priority ordering - format.test.ts: IID formatting for MRs vs issues E2E tests (app.spec.ts): - Navigation flow - Focus card interactions - Queue management - Quick capture flow Test infrastructure: - fixtures.ts: makeFocusItem() factory - tauri-plugin-shell.ts: Mock for @tauri-apps/plugin-shell - Updated tauri-api.ts mock with new commands - vitest.config.ts: Path aliases, jsdom environment - playwright.config.ts: Removed webServer (run separately) - package.json: Added @tauri-apps/plugin-shell dependency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "@/App";
|
||||
import { useFocusStore } from "@/stores/focus-store";
|
||||
import { useNavStore } from "@/stores/nav-store";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the main heading", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("Mission Control")).toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
useNavStore.setState({ activeView: "focus" });
|
||||
useFocusStore.setState({
|
||||
current: null,
|
||||
queue: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the tagline", () => {
|
||||
it("renders the app shell with navigation", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(
|
||||
screen.getByText("What should you be doing right now?")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Focus")).toBeInTheDocument();
|
||||
expect(screen.getByText("Queue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the focus placeholder", () => {
|
||||
it("renders the focus view with empty state by default", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText(/all clear/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText("THE ONE THING will appear here")
|
||||
).toBeInTheDocument();
|
||||
it("shows loading state", () => {
|
||||
useFocusStore.setState({ isLoading: true });
|
||||
render(<App />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error state", () => {
|
||||
useFocusStore.setState({ error: "Connection failed" });
|
||||
render(<App />);
|
||||
expect(screen.getByText("Connection failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
104
tests/components/AppShell.test.tsx
Normal file
104
tests/components/AppShell.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useNavStore } from "@/stores/nav-store";
|
||||
import { useFocusStore } from "@/stores/focus-store";
|
||||
import { useCaptureStore } from "@/stores/capture-store";
|
||||
import { simulateEvent, resetMocks } from "../mocks/tauri-api";
|
||||
import { makeFocusItem } from "../helpers/fixtures";
|
||||
|
||||
describe("AppShell", () => {
|
||||
beforeEach(() => {
|
||||
useNavStore.setState({ activeView: "focus" });
|
||||
useFocusStore.setState({
|
||||
current: null,
|
||||
queue: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
useCaptureStore.setState({
|
||||
isOpen: false,
|
||||
isSubmitting: false,
|
||||
lastCapturedId: null,
|
||||
error: null,
|
||||
});
|
||||
resetMocks();
|
||||
});
|
||||
|
||||
it("renders navigation tabs", () => {
|
||||
render(<AppShell />);
|
||||
expect(screen.getByText("Focus")).toBeInTheDocument();
|
||||
expect(screen.getByText("Queue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Focus view by default", () => {
|
||||
render(<AppShell />);
|
||||
expect(screen.getByText(/all clear/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Queue view when Queue tab is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppShell />);
|
||||
|
||||
await user.click(screen.getByText("Queue"));
|
||||
expect(await screen.findByText(/no items/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Inbox placeholder", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppShell />);
|
||||
|
||||
await user.click(screen.getByText("Inbox"));
|
||||
expect(await screen.findByText(/coming in Phase 4b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows queue count badge when items exist", () => {
|
||||
useFocusStore.setState({
|
||||
current: makeFocusItem({ id: "a" }),
|
||||
queue: [makeFocusItem({ id: "b" }), makeFocusItem({ id: "c" })],
|
||||
});
|
||||
|
||||
render(<AppShell />);
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens quick capture overlay on global shortcut event", async () => {
|
||||
render(<AppShell />);
|
||||
|
||||
act(() => {
|
||||
simulateEvent("global-shortcut-triggered", "quick-capture");
|
||||
});
|
||||
|
||||
expect(useCaptureStore.getState().isOpen).toBe(true);
|
||||
expect(screen.getByPlaceholderText("Capture a thought...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking queue item sets focus and switches to focus view", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
useFocusStore.setState({
|
||||
current: makeFocusItem({ id: "current", title: "Current" }),
|
||||
queue: [
|
||||
makeFocusItem({
|
||||
id: "target",
|
||||
type: "issue",
|
||||
title: "Target item",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<AppShell />);
|
||||
|
||||
// Navigate to queue and wait for transition
|
||||
await user.click(screen.getByText("Queue"));
|
||||
const targetItem = await screen.findByText("Target item");
|
||||
// Click on the target item
|
||||
await user.click(targetItem);
|
||||
|
||||
// Should switch back to focus view with the target as current
|
||||
expect(useFocusStore.getState().current?.id).toBe("target");
|
||||
expect(useNavStore.getState().activeView).toBe("focus");
|
||||
});
|
||||
});
|
||||
189
tests/components/BatchMode.test.tsx
Normal file
189
tests/components/BatchMode.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BatchMode } from "@/components/BatchMode";
|
||||
import { useBatchStore } from "@/stores/batch-store";
|
||||
import { makeFocusItem } from "../helpers/fixtures";
|
||||
|
||||
describe("BatchMode", () => {
|
||||
const onOpenUrl = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useBatchStore.getState().exitBatch();
|
||||
});
|
||||
|
||||
function startBatchWith(count: number) {
|
||||
const items = Array.from({ length: count }, (_, i) =>
|
||||
makeFocusItem({
|
||||
id: `r${i + 1}`,
|
||||
type: "mr_review",
|
||||
title: `Review MR !${900 + i}`,
|
||||
iid: 900 + i,
|
||||
})
|
||||
);
|
||||
useBatchStore.getState().startBatch(items, "CODE REVIEWS");
|
||||
}
|
||||
|
||||
describe("rendering", () => {
|
||||
it("shows the batch label", () => {
|
||||
startBatchWith(3);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
expect(screen.getByText(/CODE REVIEWS/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows progress (1 of N)", () => {
|
||||
startBatchWith(4);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
expect(screen.getByText(/1 of 4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the current item title", () => {
|
||||
startBatchWith(3);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
expect(screen.getByText("Review MR !900")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows item metadata", () => {
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
// Metadata line contains IID and project
|
||||
const metaLine = screen.getByText((_content, el) => {
|
||||
return (
|
||||
el?.tagName === "P" &&
|
||||
Boolean(el.textContent?.includes("!900")) &&
|
||||
Boolean(el.textContent?.includes("platform/core"))
|
||||
);
|
||||
});
|
||||
expect(metaLine).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows action buttons", () => {
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /open in gl/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /done/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /skip/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows ESC to exit hint", () => {
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
expect(screen.getByText(/ESC to exit/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("actions", () => {
|
||||
it("Open in GL calls onOpenUrl with current item URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open in gl/i })
|
||||
);
|
||||
expect(onOpenUrl).toHaveBeenCalledWith(
|
||||
"https://gitlab.com/platform/core/-/merge_requests/847"
|
||||
);
|
||||
});
|
||||
|
||||
it("Done marks item and advances to next", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(3);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /done/i }));
|
||||
|
||||
expect(screen.getByText(/2 of 3/)).toBeInTheDocument();
|
||||
// Wait for AnimatePresence to swap items
|
||||
expect(await screen.findByText("Review MR !901")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Skip marks item and advances to next", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(3);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skip/i }));
|
||||
|
||||
expect(screen.getByText(/2 of 3/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard shortcuts", () => {
|
||||
it("Cmd+D triggers Done", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.keyboard("{Meta>}d{/Meta}");
|
||||
|
||||
expect(screen.getByText(/2 of 2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Cmd+S triggers Skip", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.keyboard("{Meta>}s{/Meta}");
|
||||
|
||||
expect(screen.getByText(/2 of 2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Escape exits batch mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onExit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completion", () => {
|
||||
it("shows celebration when all items are processed", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(2);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /done/i }));
|
||||
await user.click(screen.getByRole("button", { name: /done/i }));
|
||||
|
||||
expect(screen.getByText(/all done/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2.*completed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows completed and skipped counts in celebration", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(3);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /done/i }));
|
||||
await user.click(screen.getByRole("button", { name: /skip/i }));
|
||||
await user.click(screen.getByRole("button", { name: /done/i }));
|
||||
|
||||
expect(screen.getByText(/2.*completed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1.*skipped/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("celebration has a button to exit", async () => {
|
||||
const user = userEvent.setup();
|
||||
startBatchWith(1);
|
||||
render(<BatchMode onOpenUrl={onOpenUrl} onExit={onExit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /done/i }));
|
||||
|
||||
const exitBtn = screen.getByRole("button", { name: /back to focus/i });
|
||||
await user.click(exitBtn);
|
||||
expect(onExit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
154
tests/components/FocusCard.test.tsx
Normal file
154
tests/components/FocusCard.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FocusCard } from "@/components/FocusCard";
|
||||
import type { FocusItem } from "@/lib/types";
|
||||
import { makeFocusItem as makeItem } from "../helpers/fixtures";
|
||||
|
||||
/** FocusCard tests use a richer default with context quote and requestedBy set. */
|
||||
function makeFocusItem(overrides: Partial<FocusItem> = {}): FocusItem {
|
||||
return makeItem({
|
||||
contextQuote: "Can you take a look? I need this for the release tomorrow",
|
||||
requestedBy: "sarah",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("FocusCard", () => {
|
||||
const onStart = vi.fn();
|
||||
const onDefer1h = vi.fn();
|
||||
const onDeferTomorrow = vi.fn();
|
||||
const onSkip = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderCard(item: FocusItem = makeFocusItem()) {
|
||||
return render(
|
||||
<FocusCard
|
||||
item={item}
|
||||
onStart={onStart}
|
||||
onDefer1h={onDefer1h}
|
||||
onDeferTomorrow={onDeferTomorrow}
|
||||
onSkip={onSkip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("rendering", () => {
|
||||
it("displays the item title", () => {
|
||||
renderCard();
|
||||
expect(
|
||||
screen.getByText("Fix authentication token refresh logic")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the type badge", () => {
|
||||
renderCard();
|
||||
expect(screen.getByText(/MR REVIEW/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the project path and IID", () => {
|
||||
renderCard();
|
||||
expect(screen.getByText(/!847/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/platform\/core/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the context quote when present", () => {
|
||||
renderCard();
|
||||
expect(
|
||||
screen.getByText(/Can you take a look/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays who requested attention", () => {
|
||||
renderCard();
|
||||
expect(screen.getByText(/@sarah/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides context section when no quote", () => {
|
||||
renderCard(makeFocusItem({ contextQuote: null, requestedBy: null }));
|
||||
expect(screen.queryByText(/@/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows issue type badge for issues", () => {
|
||||
renderCard(makeFocusItem({ type: "issue", iid: 42 }));
|
||||
expect(screen.getByText(/ISSUE/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/#42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows authored MR badge", () => {
|
||||
renderCard(makeFocusItem({ type: "mr_authored" }));
|
||||
expect(screen.getByText(/MR AUTHORED/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("action buttons", () => {
|
||||
it("calls onStart when Start button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /start/i }));
|
||||
expect(onStart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onDefer1h when 1 hour button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /1 hour/i }));
|
||||
expect(onDefer1h).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onDeferTomorrow when Tomorrow button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /tomorrow/i }));
|
||||
expect(onDeferTomorrow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onSkip when Skip button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skip/i }));
|
||||
expect(onSkip).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard shortcuts", () => {
|
||||
it("triggers Start on Enter key", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onStart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("triggers Skip on Cmd+S", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.keyboard("{Meta>}s{/Meta}");
|
||||
expect(onSkip).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FocusCard empty state", () => {
|
||||
it("shows empty state message when no item", () => {
|
||||
render(
|
||||
<FocusCard
|
||||
item={null}
|
||||
onStart={vi.fn()}
|
||||
onDefer1h={vi.fn()}
|
||||
onDeferTomorrow={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/all clear/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
100
tests/components/QueueItem.test.tsx
Normal file
100
tests/components/QueueItem.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueueItem } from "@/components/QueueItem";
|
||||
import { makeFocusItem } from "../helpers/fixtures";
|
||||
|
||||
describe("QueueItem", () => {
|
||||
it("renders the item title", () => {
|
||||
render(<QueueItem item={makeFocusItem()} onClick={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByText("Fix authentication token refresh logic")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the type badge", () => {
|
||||
render(<QueueItem item={makeFocusItem()} onClick={vi.fn()} />);
|
||||
expect(screen.getByText(/MR REVIEW/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the IID with correct prefix for MRs", () => {
|
||||
render(<QueueItem item={makeFocusItem()} onClick={vi.fn()} />);
|
||||
expect(screen.getByText(/!847/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the IID with # prefix for issues", () => {
|
||||
render(
|
||||
<QueueItem
|
||||
item={makeFocusItem({ type: "issue", iid: 42 })}
|
||||
onClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/#42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the project name", () => {
|
||||
render(<QueueItem item={makeFocusItem()} onClick={vi.fn()} />);
|
||||
expect(screen.getByText(/platform\/core/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const item = makeFocusItem({ id: "test-click" });
|
||||
|
||||
render(<QueueItem item={item} onClick={onClick} />);
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
expect(onClick).toHaveBeenCalledWith("test-click");
|
||||
});
|
||||
|
||||
it("shows staleness color for fresh items", () => {
|
||||
const freshItem = makeFocusItem({
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
const { container } = render(
|
||||
<QueueItem item={freshItem} onClick={vi.fn()} />
|
||||
);
|
||||
// Fresh items should have green indicator
|
||||
expect(container.querySelector("[data-staleness='fresh']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows staleness color for urgent items", () => {
|
||||
const oldItem = makeFocusItem({
|
||||
updatedAt: new Date(
|
||||
Date.now() - 10 * 24 * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
});
|
||||
const { container } = render(
|
||||
<QueueItem item={oldItem} onClick={vi.fn()} />
|
||||
);
|
||||
expect(container.querySelector("[data-staleness='urgent']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows requestedBy when present", () => {
|
||||
render(
|
||||
<QueueItem
|
||||
item={makeFocusItem({ requestedBy: "alice" })}
|
||||
onClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/@alice/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("staleness indicator has accessible label for fresh items", () => {
|
||||
const freshItem = makeFocusItem({
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
render(<QueueItem item={freshItem} onClick={vi.fn()} />);
|
||||
expect(screen.getByRole("img", { name: /updated recently/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("staleness indicator has accessible label for urgent items", () => {
|
||||
const oldItem = makeFocusItem({
|
||||
updatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
render(<QueueItem item={oldItem} onClick={vi.fn()} />);
|
||||
expect(screen.getByRole("img", { name: /needs attention/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
40
tests/components/QueueSummary.test.tsx
Normal file
40
tests/components/QueueSummary.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueueSummary } from "@/components/QueueSummary";
|
||||
import { makeFocusItem } from "../helpers/fixtures";
|
||||
|
||||
describe("QueueSummary", () => {
|
||||
it("shows empty queue message when no items", () => {
|
||||
render(<QueueSummary queue={[]} />);
|
||||
expect(screen.getByText(/queue is empty/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows review count", () => {
|
||||
const queue = [
|
||||
makeFocusItem({ id: "a", type: "mr_review" }),
|
||||
makeFocusItem({ id: "b", type: "mr_review" }),
|
||||
];
|
||||
render(<QueueSummary queue={queue} />);
|
||||
expect(screen.getByText(/2 reviews/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows singular for 1 item", () => {
|
||||
const queue = [makeFocusItem({ id: "a", type: "issue" })];
|
||||
render(<QueueSummary queue={queue} />);
|
||||
expect(screen.getByText(/1 issue(?!s)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows mixed counts separated by dots", () => {
|
||||
const queue = [
|
||||
makeFocusItem({ id: "a", type: "mr_review" }),
|
||||
makeFocusItem({ id: "b", type: "issue" }),
|
||||
makeFocusItem({ id: "c", type: "issue" }),
|
||||
makeFocusItem({ id: "d", type: "manual" }),
|
||||
];
|
||||
render(<QueueSummary queue={queue} />);
|
||||
const text = screen.getByText(/Queue:/);
|
||||
expect(text.textContent).toContain("1 review");
|
||||
expect(text.textContent).toContain("2 issues");
|
||||
expect(text.textContent).toContain("1 task");
|
||||
});
|
||||
});
|
||||
116
tests/components/QueueView.test.tsx
Normal file
116
tests/components/QueueView.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueueView } from "@/components/QueueView";
|
||||
import { useFocusStore } from "@/stores/focus-store";
|
||||
import { makeFocusItem } from "../helpers/fixtures";
|
||||
|
||||
describe("QueueView", () => {
|
||||
beforeEach(() => {
|
||||
useFocusStore.setState({
|
||||
current: null,
|
||||
queue: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no items", () => {
|
||||
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
||||
expect(screen.getByText(/no items/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups items by type with section headers", () => {
|
||||
useFocusStore.setState({
|
||||
current: makeFocusItem({ id: "current" }),
|
||||
queue: [
|
||||
makeFocusItem({ id: "r1", type: "mr_review", title: "Review A" }),
|
||||
makeFocusItem({ id: "r2", type: "mr_review", title: "Review B" }),
|
||||
makeFocusItem({ id: "i1", type: "issue", title: "Issue A" }),
|
||||
makeFocusItem({
|
||||
id: "m1",
|
||||
type: "mr_authored",
|
||||
title: "My MR",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/REVIEWS/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ISSUES/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AUTHORED MRS/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows item count in section headers", () => {
|
||||
useFocusStore.setState({
|
||||
current: makeFocusItem({ id: "current", type: "issue" }),
|
||||
queue: [
|
||||
makeFocusItem({ id: "r1", type: "mr_review" }),
|
||||
makeFocusItem({ id: "r2", type: "mr_review" }),
|
||||
makeFocusItem({ id: "i1", type: "issue" }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
||||
|
||||
// Text is split across elements, so use a function matcher
|
||||
const reviewsHeader = screen.getByText((_content, element) => {
|
||||
return element?.tagName === "H2" && element.textContent === "REVIEWS (2)";
|
||||
});
|
||||
expect(reviewsHeader).toBeInTheDocument();
|
||||
|
||||
const issuesHeader = screen.getByText((_content, element) => {
|
||||
return element?.tagName === "H2" && element.textContent === "ISSUES (2)";
|
||||
});
|
||||
expect(issuesHeader).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes current focus item in the list", () => {
|
||||
useFocusStore.setState({
|
||||
current: makeFocusItem({
|
||||
id: "focused",
|
||||
type: "mr_review",
|
||||
title: "Focused item",
|
||||
}),
|
||||
queue: [
|
||||
makeFocusItem({ id: "q1", type: "issue", title: "Queued item" }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Focused item")).toBeInTheDocument();
|
||||
expect(screen.getByText("Queued item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSetFocus when an item is clicked", async () => {
|
||||
const onSetFocus = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
useFocusStore.setState({
|
||||
current: makeFocusItem({ id: "current" }),
|
||||
queue: [
|
||||
makeFocusItem({ id: "target", type: "issue", title: "Click me" }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<QueueView onSetFocus={onSetFocus} onSwitchToFocus={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText("Click me"));
|
||||
expect(onSetFocus).toHaveBeenCalledWith("target");
|
||||
});
|
||||
|
||||
it("marks the current focus item visually", () => {
|
||||
useFocusStore.setState({
|
||||
current: makeFocusItem({ id: "focused", title: "Current focus" }),
|
||||
queue: [],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-focused='true']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
182
tests/components/QuickCapture.test.tsx
Normal file
182
tests/components/QuickCapture.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QuickCapture } from "@/components/QuickCapture";
|
||||
import { useCaptureStore } from "@/stores/capture-store";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
|
||||
describe("QuickCapture", () => {
|
||||
beforeEach(() => {
|
||||
useCaptureStore.setState({
|
||||
isOpen: true,
|
||||
isSubmitting: false,
|
||||
lastCapturedId: null,
|
||||
error: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
useCaptureStore.setState({ isOpen: false });
|
||||
const { container } = render(<QuickCapture />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders overlay when open", () => {
|
||||
render(<QuickCapture />);
|
||||
expect(screen.getByPlaceholderText("Capture a thought...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-focuses the input", () => {
|
||||
render(<QuickCapture />);
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
|
||||
it("shows a submit button", () => {
|
||||
render(<QuickCapture />);
|
||||
expect(screen.getByRole("button", { name: /capture/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("submission", () => {
|
||||
it("calls quick_capture on Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, "Fix the login bug{enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith("quick_capture", {
|
||||
title: "Fix the login bug",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("calls quick_capture on button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, "New feature idea");
|
||||
await user.click(screen.getByRole("button", { name: /capture/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith("quick_capture", {
|
||||
title: "New feature idea",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit empty input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, "{enter}");
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith("quick_capture", expect.anything());
|
||||
});
|
||||
|
||||
it("does not submit whitespace-only input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, " {enter}");
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith("quick_capture", expect.anything());
|
||||
});
|
||||
|
||||
it("closes overlay on successful capture", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, "Quick thought{enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useCaptureStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error on failed capture", async () => {
|
||||
(invoke as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
code: "BEADS_UNAVAILABLE",
|
||||
message: "br CLI not found",
|
||||
recoverable: true,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, "Doomed thought{enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables input during submission", async () => {
|
||||
// Make invoke hang
|
||||
(invoke as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, "Slow thought{enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismissal", () => {
|
||||
it("closes on Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(useCaptureStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("closes on backdrop click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
// Click the backdrop (the outer overlay div)
|
||||
const backdrop = screen.getByTestId("capture-backdrop");
|
||||
await user.click(backdrop);
|
||||
|
||||
expect(useCaptureStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error display", () => {
|
||||
it("shows error message from store", () => {
|
||||
useCaptureStore.setState({ error: "br CLI not found" });
|
||||
render(<QuickCapture />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("br CLI not found");
|
||||
});
|
||||
|
||||
it("clears error when user types", async () => {
|
||||
useCaptureStore.setState({ error: "previous error" });
|
||||
const user = userEvent.setup();
|
||||
render(<QuickCapture />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Capture a thought...");
|
||||
await user.type(input, "a");
|
||||
|
||||
expect(useCaptureStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user