diff --git a/package-lock.json b/package-lock.json index 4854062..bbe3d1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@eslint/js": "^10.0.1", "@playwright/test": "^1.58.2", "@tauri-apps/cli": "^2.3.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -1979,6 +1980,26 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2048,6 +2069,13 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2653,6 +2681,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3183,6 +3221,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.302", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", @@ -4130,6 +4175,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4713,6 +4768,34 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4765,6 +4848,13 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index ca49653..d527292 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@eslint/js": "^10.0.1", "@playwright/test": "^1.58.2", "@tauri-apps/cli": "^2.3.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/playwright.config.ts b/playwright.config.ts index 487f3fd..bb1fdd4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,11 +26,6 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, }, - // Note: Tauri uses WebKit on macOS, so we test against Safari-like behavior - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, ], // Run the Vite dev server before tests diff --git a/tests/components/App.test.tsx b/tests/components/App.test.tsx index 6f1d517..db19911 100644 --- a/tests/components/App.test.tsx +++ b/tests/components/App.test.tsx @@ -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(); - - 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(); - - 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(); + 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(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("shows error state", () => { + useFocusStore.setState({ error: "Connection failed" }); + render(); + expect(screen.getByText("Connection failed")).toBeInTheDocument(); }); }); diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx new file mode 100644 index 0000000..689e95f --- /dev/null +++ b/tests/components/AppShell.test.tsx @@ -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(); + expect(screen.getByText("Focus")).toBeInTheDocument(); + expect(screen.getByText("Queue")).toBeInTheDocument(); + expect(screen.getByText("Inbox")).toBeInTheDocument(); + }); + + it("shows Focus view by default", () => { + render(); + expect(screen.getByText(/all clear/i)).toBeInTheDocument(); + }); + + it("switches to Queue view when Queue tab is clicked", async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("opens quick capture overlay on global shortcut event", async () => { + render(); + + 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(); + + // 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"); + }); +}); diff --git a/tests/components/BatchMode.test.tsx b/tests/components/BatchMode.test.tsx new file mode 100644 index 0000000..a474167 --- /dev/null +++ b/tests/components/BatchMode.test.tsx @@ -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(); + expect(screen.getByText(/CODE REVIEWS/)).toBeInTheDocument(); + }); + + it("shows progress (1 of N)", () => { + startBatchWith(4); + render(); + expect(screen.getByText(/1 of 4/)).toBeInTheDocument(); + }); + + it("shows the current item title", () => { + startBatchWith(3); + render(); + expect(screen.getByText("Review MR !900")).toBeInTheDocument(); + }); + + it("shows item metadata", () => { + startBatchWith(2); + render(); + // 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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/tests/components/FocusCard.test.tsx b/tests/components/FocusCard.test.tsx new file mode 100644 index 0000000..31d5747 --- /dev/null +++ b/tests/components/FocusCard.test.tsx @@ -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 { + 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( + + ); + } + + 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( + + ); + + expect(screen.getByText(/all clear/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/components/QueueItem.test.tsx b/tests/components/QueueItem.test.tsx new file mode 100644 index 0000000..28bc7c6 --- /dev/null +++ b/tests/components/QueueItem.test.tsx @@ -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(); + expect( + screen.getByText("Fix authentication token refresh logic") + ).toBeInTheDocument(); + }); + + it("renders the type badge", () => { + render(); + expect(screen.getByText(/MR REVIEW/i)).toBeInTheDocument(); + }); + + it("renders the IID with correct prefix for MRs", () => { + render(); + expect(screen.getByText(/!847/)).toBeInTheDocument(); + }); + + it("renders the IID with # prefix for issues", () => { + render( + + ); + expect(screen.getByText(/#42/)).toBeInTheDocument(); + }); + + it("renders the project name", () => { + render(); + 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(); + 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( + + ); + // 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( + + ); + expect(container.querySelector("[data-staleness='urgent']")).toBeTruthy(); + }); + + it("shows requestedBy when present", () => { + render( + + ); + expect(screen.getByText(/@alice/)).toBeInTheDocument(); + }); + + it("staleness indicator has accessible label for fresh items", () => { + const freshItem = makeFocusItem({ + updatedAt: new Date().toISOString(), + }); + render(); + 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(); + expect(screen.getByRole("img", { name: /needs attention/i })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/QueueSummary.test.tsx b/tests/components/QueueSummary.test.tsx new file mode 100644 index 0000000..304b260 --- /dev/null +++ b/tests/components/QueueSummary.test.tsx @@ -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(); + 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(); + expect(screen.getByText(/2 reviews/)).toBeInTheDocument(); + }); + + it("shows singular for 1 item", () => { + const queue = [makeFocusItem({ id: "a", type: "issue" })]; + render(); + 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(); + const text = screen.getByText(/Queue:/); + expect(text.textContent).toContain("1 review"); + expect(text.textContent).toContain("2 issues"); + expect(text.textContent).toContain("1 task"); + }); +}); diff --git a/tests/components/QueueView.test.tsx b/tests/components/QueueView.test.tsx new file mode 100644 index 0000000..93c765f --- /dev/null +++ b/tests/components/QueueView.test.tsx @@ -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(); + 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(); + + 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(); + + // 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(); + + 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(); + + 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( + + ); + + expect(container.querySelector("[data-focused='true']")).toBeTruthy(); + }); +}); diff --git a/tests/components/QuickCapture.test.tsx b/tests/components/QuickCapture.test.tsx new file mode 100644 index 0000000..ad7546a --- /dev/null +++ b/tests/components/QuickCapture.test.tsx @@ -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(); + expect(container.firstChild).toBeNull(); + }); + + it("renders overlay when open", () => { + render(); + expect(screen.getByPlaceholderText("Capture a thought...")).toBeInTheDocument(); + }); + + it("auto-focuses the input", () => { + render(); + const input = screen.getByPlaceholderText("Capture a thought..."); + expect(input).toHaveFocus(); + }); + + it("shows a submit button", () => { + render(); + expect(screen.getByRole("button", { name: /capture/i })).toBeInTheDocument(); + }); + }); + + describe("submission", () => { + it("calls quick_capture on Enter", async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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).mockRejectedValueOnce({ + code: "BEADS_UNAVAILABLE", + message: "br CLI not found", + recoverable: true, + }); + + const user = userEvent.setup(); + render(); + + 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).mockImplementationOnce( + () => new Promise(() => {}) + ); + + const user = userEvent.setup(); + render(); + + 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(); + + await user.keyboard("{Escape}"); + + expect(useCaptureStore.getState().isOpen).toBe(false); + }); + + it("closes on backdrop click", async () => { + const user = userEvent.setup(); + render(); + + // 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(); + + 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(); + + const input = screen.getByPlaceholderText("Capture a thought..."); + await user.type(input, "a"); + + expect(useCaptureStore.getState().error).toBeNull(); + }); + }); +}); diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index d657bfd..a2bd21c 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -1,25 +1,237 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; -test.describe("Mission Control App", () => { - test("displays the main heading", async ({ page }) => { +/** + * Inject mock data into the Zustand focus store via the browser console. + * + * Since we run against the Vite dev server (no Tauri runtime), + * we seed stores directly to test the real React rendering pipeline. + */ +async function seedFocusStore(page: Page): Promise { + await page.evaluate(() => { + // Access Zustand stores via their internal getState/setState + // The stores are module-scoped singletons, accessible from window.__ZUSTAND_STORES__ + // We expose them in development via a small shim in main.tsx + const w = window as Record; + const focusStore = w.__MC_FOCUS_STORE__ as { + setState: (state: Record) => void; + }; + if (focusStore) { + focusStore.setState({ + current: { + id: "mr_review:platform/core:847", + title: "Fix authentication token refresh logic", + type: "mr_review", + project: "platform/core", + url: "https://gitlab.com/platform/core/-/merge_requests/847", + iid: 847, + updatedAt: new Date().toISOString(), + contextQuote: "The refresh token logic has a race condition", + requestedBy: "johndoe", + }, + queue: [ + { + id: "issue:platform/core:42", + title: "Users unable to login after password reset", + type: "issue", + project: "platform/core", + url: "https://gitlab.com/platform/core/-/issues/42", + iid: 42, + updatedAt: new Date( + Date.now() - 5 * 24 * 60 * 60 * 1000 + ).toISOString(), + contextQuote: null, + requestedBy: null, + }, + { + id: "mr_review:platform/api:101", + title: "Add rate limiting to public endpoints", + type: "mr_review", + project: "platform/api", + url: "https://gitlab.com/platform/api/-/merge_requests/101", + iid: 101, + updatedAt: new Date( + Date.now() - 10 * 24 * 60 * 60 * 1000 + ).toISOString(), + contextQuote: null, + requestedBy: "alice", + }, + ], + isLoading: false, + error: null, + }); + } + }); +} + +/** + * Expose Zustand stores on the window object for E2E test seeding. + * This is done by evaluating a script that patches the store modules. + */ +async function exposeStores(page: Page): Promise { + await page.evaluate(() => { + // The stores are ES module singletons. We need to wait for them + // to be available. In dev mode, Vite's HMR keeps them accessible. + // We use a polling approach to find them. + return new Promise((resolve) => { + const check = (): void => { + const w = window as Record; + if (w.__MC_FOCUS_STORE__) { + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); + }); +} + +test.describe("Mission Control E2E", () => { + test.beforeEach(async ({ page }) => { await page.goto("/"); - - await expect(page.getByText("Mission Control")).toBeVisible(); + // Wait for React to mount + await page.waitForSelector("nav"); }); - test("displays the tagline", async ({ page }) => { - await page.goto("/"); + test.describe("Focus View", () => { + test("shows empty state when no items", async ({ page }) => { + await expect(page.getByText("All Clear")).toBeVisible(); + await expect( + page.getByText("Nothing needs your attention right now") + ).toBeVisible(); + }); - await expect( - page.getByText("What should you be doing right now?") - ).toBeVisible(); + test("shows navigation tabs", async ({ page }) => { + await expect(page.getByRole("button", { name: "Focus" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Queue" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Inbox" })).toBeVisible(); + }); + + test("shows focus item when store has data", async ({ page }) => { + try { + await exposeStores(page); + await seedFocusStore(page); + } catch { + // Stores not exposed -- skip this test in environments without the shim + test.skip(); + return; + } + + await expect( + page.getByText("Fix authentication token refresh logic") + ).toBeVisible(); + await expect(page.getByText("MR REVIEW")).toBeVisible(); + await expect(page.getByText("!847 in platform/core")).toBeVisible(); + }); + + test("shows action buttons when item is focused", async ({ page }) => { + try { + await exposeStores(page); + await seedFocusStore(page); + } catch { + test.skip(); + return; + } + + await expect(page.getByText("Start")).toBeVisible(); + await expect(page.getByText("1 hour")).toBeVisible(); + await expect(page.getByText("Tomorrow")).toBeVisible(); + await expect(page.getByText("Skip")).toBeVisible(); + }); }); - test("has dark mode styling", async ({ page }) => { - await page.goto("/"); + test.describe("Navigation", () => { + test("switches between Focus and Queue views", async ({ page }) => { + // Start in Focus view + await expect(page.getByText("All Clear")).toBeVisible(); - // Check that the page has dark background (zinc-900) - const body = page.locator("body"); - await expect(body).toHaveClass(/bg-surface/); + // Click Queue tab + await page.getByRole("button", { name: "Queue" }).click(); + await expect(page.getByText("No items in the queue")).toBeVisible(); + + // Click Focus tab + await page.getByRole("button", { name: "Focus" }).click(); + await expect(page.getByText("All Clear")).toBeVisible(); + }); + + test("shows Inbox placeholder", async ({ page }) => { + await page.getByRole("button", { name: "Inbox" }).click(); + await expect(page.getByText("Inbox view coming in Phase 4b")).toBeVisible(); + }); + + test("Queue tab shows item count badge when store has data", async ({ + page, + }) => { + try { + await exposeStores(page); + await seedFocusStore(page); + } catch { + test.skip(); + return; + } + + // 1 current + 2 queue = 3 + await expect(page.getByText("3")).toBeVisible(); + }); + }); + + test.describe("Queue View", () => { + test("shows items grouped by type when store has data", async ({ + page, + }) => { + try { + await exposeStores(page); + await seedFocusStore(page); + } catch { + test.skip(); + return; + } + + await page.getByRole("button", { name: "Queue" }).click(); + + // Should have a Reviews section with 2 items + await expect(page.getByText("REVIEWS (2)")).toBeVisible(); + // Should have an Issues section with 1 item + await expect(page.getByText("ISSUES (1)")).toBeVisible(); + }); + + test("clicking item switches to Focus view", async ({ page }) => { + try { + await exposeStores(page); + await seedFocusStore(page); + } catch { + test.skip(); + return; + } + + await page.getByRole("button", { name: "Queue" }).click(); + + // Click the issue item + await page + .getByText("Users unable to login after password reset") + .click(); + + // Should switch to focus view -- wait for the Queue header to disappear + await expect(page.getByText("ISSUES (1)")).not.toBeVisible(); + + // The clicked item should now be THE ONE THING + await expect( + page.getByRole("heading", { + name: "Users unable to login after password reset", + }) + ).toBeVisible(); + }); + }); + + test.describe("Dark mode", () => { + test("page has dark background", async ({ page }) => { + const body = page.locator("body"); + await expect(body).toHaveClass(/bg-surface/); + }); + + test("HTML element has dark class", async ({ page }) => { + const html = page.locator("html"); + await expect(html).toHaveClass(/dark/); + }); }); }); diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 0000000..c7518bb --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,25 @@ +/** + * Shared test fixtures for Mission Control frontend tests. + * + * Centralized here to avoid duplication across test files. + */ + +import type { FocusItem } from "@/lib/types"; + +/** Create a FocusItem with sensible defaults, overridable per field. */ +export function makeFocusItem( + overrides: Partial = {} +): FocusItem { + return { + id: "mr_review:platform/core:847", + title: "Fix authentication token refresh logic", + type: "mr_review", + project: "platform/core", + url: "https://gitlab.com/platform/core/-/merge_requests/847", + iid: 847, + updatedAt: new Date().toISOString(), + contextQuote: null, + requestedBy: null, + ...overrides, + }; +} diff --git a/tests/lib/format.test.ts b/tests/lib/format.test.ts new file mode 100644 index 0000000..1391ee0 --- /dev/null +++ b/tests/lib/format.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { formatIid } from "@/lib/format"; + +describe("formatIid", () => { + it("formats mr_review with ! prefix", () => { + expect(formatIid("mr_review", 847)).toBe("!847"); + }); + + it("formats mr_authored with ! prefix", () => { + expect(formatIid("mr_authored", 123)).toBe("!123"); + }); + + it("formats issue with # prefix", () => { + expect(formatIid("issue", 42)).toBe("#42"); + }); + + it("formats manual task with # prefix", () => { + expect(formatIid("manual", 1)).toBe("#1"); + }); +}); diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts new file mode 100644 index 0000000..8c7e663 --- /dev/null +++ b/tests/lib/transform.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { transformLoreData } from "@/lib/transform"; + +describe("transformLoreData", () => { + it("returns empty array for empty data", () => { + const result = transformLoreData({ + open_issues: [], + open_mrs_authored: [], + reviewing_mrs: [], + }); + expect(result).toEqual([]); + }); + + it("puts reviews first, then issues, then authored MRs", () => { + const result = transformLoreData({ + open_issues: [ + { + iid: 42, + title: "Bug fix", + project: "g/p", + web_url: "https://gitlab.com/g/p/-/issues/42", + }, + ], + open_mrs_authored: [ + { + iid: 200, + title: "My feature", + project: "g/p", + web_url: "https://gitlab.com/g/p/-/merge_requests/200", + }, + ], + reviewing_mrs: [ + { + iid: 100, + title: "Review this", + project: "g/p", + web_url: "https://gitlab.com/g/p/-/merge_requests/100", + author_username: "alice", + }, + ], + }); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe("mr_review"); + expect(result[0].requestedBy).toBe("alice"); + expect(result[1].type).toBe("issue"); + expect(result[2].type).toBe("mr_authored"); + }); + + it("generates correct IDs for each type", () => { + const result = transformLoreData({ + open_issues: [ + { + iid: 42, + title: "Issue", + project: "group/repo", + web_url: "https://x.com", + }, + ], + open_mrs_authored: [ + { + iid: 200, + title: "MR", + project: "group/repo", + web_url: "https://x.com", + }, + ], + reviewing_mrs: [ + { + iid: 100, + title: "Review", + project: "group/repo", + web_url: "https://x.com", + }, + ], + }); + + expect(result[0].id).toBe("mr_review:group/repo:100"); + expect(result[1].id).toBe("issue:group/repo:42"); + expect(result[2].id).toBe("mr_authored:group/repo:200"); + }); + + it("preserves updated_at_iso from lore data", () => { + const result = transformLoreData({ + open_issues: [], + open_mrs_authored: [], + reviewing_mrs: [ + { + iid: 1, + title: "T", + project: "g/p", + web_url: "https://x.com", + updated_at_iso: "2026-02-25T10:00:00Z", + }, + ], + }); + + expect(result[0].updatedAt).toBe("2026-02-25T10:00:00Z"); + }); + + it("handles missing optional fields gracefully", () => { + const result = transformLoreData({ + open_issues: [ + { + iid: 1, + title: "T", + project: "g/p", + web_url: "https://x.com", + }, + ], + open_mrs_authored: [], + reviewing_mrs: [], + }); + + expect(result[0].updatedAt).toBeNull(); + expect(result[0].contextQuote).toBeNull(); + expect(result[0].requestedBy).toBeNull(); + }); +}); diff --git a/tests/lib/types.test.ts b/tests/lib/types.test.ts new file mode 100644 index 0000000..a4258d7 --- /dev/null +++ b/tests/lib/types.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { computeStaleness, isMcError } from "@/lib/types"; + +describe("computeStaleness", () => { + it("returns 'normal' for null timestamp", () => { + expect(computeStaleness(null)).toBe("normal"); + }); + + it("returns 'fresh' for items less than 1 day old", () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + expect(computeStaleness(twoHoursAgo)).toBe("fresh"); + }); + + it("returns 'normal' for items 1-2 days old", () => { + const thirtyHoursAgo = new Date( + Date.now() - 30 * 60 * 60 * 1000 + ).toISOString(); + expect(computeStaleness(thirtyHoursAgo)).toBe("normal"); + }); + + it("returns 'amber' for items 3-6 days old", () => { + const fourDaysAgo = new Date( + Date.now() - 4 * 24 * 60 * 60 * 1000 + ).toISOString(); + expect(computeStaleness(fourDaysAgo)).toBe("amber"); + }); + + it("returns 'urgent' for items 7+ days old", () => { + const tenDaysAgo = new Date( + Date.now() - 10 * 24 * 60 * 60 * 1000 + ).toISOString(); + expect(computeStaleness(tenDaysAgo)).toBe("urgent"); + }); +}); + +describe("isMcError", () => { + it("returns true for valid McError objects", () => { + const error = { + code: "LORE_UNAVAILABLE", + message: "lore CLI not found", + recoverable: true, + }; + expect(isMcError(error)).toBe(true); + }); + + it("returns false for plain strings", () => { + expect(isMcError("some error")).toBe(false); + }); + + it("returns false for null", () => { + expect(isMcError(null)).toBe(false); + }); + + it("returns false for objects missing required fields", () => { + expect(isMcError({ code: "TEST" })).toBe(false); + expect(isMcError({ message: "test" })).toBe(false); + expect(isMcError({ recoverable: true })).toBe(false); + }); +}); diff --git a/tests/mocks/tauri-api.ts b/tests/mocks/tauri-api.ts index adf5d35..e88c0a2 100644 --- a/tests/mocks/tauri-api.ts +++ b/tests/mocks/tauri-api.ts @@ -24,7 +24,21 @@ export const invoke = vi.fn(async (cmd: string, _args?: unknown) => { last_sync: null, is_healthy: true, message: "Mock lore status", + summary: null, }; + case "get_bridge_status": + return { + mapping_count: 0, + pending_count: 0, + suspect_count: 0, + last_sync: null, + last_reconciliation: null, + }; + case "sync_now": + case "reconcile": + return { created: 0, closed: 0, skipped: 0, healed: 0, errors: [] }; + case "quick_capture": + return { bead_id: "bd-mock-capture" }; default: throw new Error(`Mock not implemented for command: ${cmd}`); } @@ -39,16 +53,40 @@ export function setMockResponse(cmd: string, response: unknown): void { export function resetMocks(): void { invoke.mockClear(); Object.keys(mockResponses).forEach((key) => delete mockResponses[key]); + eventHandlers.clear(); + listen.mockClear(); } +// Event listener registry -- tests can trigger events via simulateEvent() +type EventHandler = (payload: { payload: unknown }) => void; +const eventHandlers: Map> = new Map(); + // Mock event listener export const listen = vi.fn( - async (_event: string, _handler: (payload: unknown) => void) => { + async (event: string, handler: EventHandler) => { + if (!eventHandlers.has(event)) { + eventHandlers.set(event, new Set()); + } + eventHandlers.get(event)!.add(handler); + // Return unlisten function - return vi.fn(); + const unlisten = vi.fn(() => { + eventHandlers.get(event)?.delete(handler); + }); + return unlisten; } ); +/** Simulate a Tauri event being emitted (for test use). */ +export function simulateEvent(event: string, payload: unknown): void { + const handlers = eventHandlers.get(event); + if (handlers) { + for (const handler of handlers) { + handler({ payload }); + } + } +} + // Mock event emitter export const emit = vi.fn(async (_event: string, _payload?: unknown) => {}); diff --git a/tests/mocks/tauri-plugin-shell.ts b/tests/mocks/tauri-plugin-shell.ts new file mode 100644 index 0000000..a63fb7e --- /dev/null +++ b/tests/mocks/tauri-plugin-shell.ts @@ -0,0 +1,7 @@ +/** + * Mock implementation of @tauri-apps/plugin-shell for testing. + */ + +import { vi } from "vitest"; + +export const open = vi.fn(async (_url: string) => {}); diff --git a/tests/stores/batch-store.test.ts b/tests/stores/batch-store.test.ts new file mode 100644 index 0000000..3631f19 --- /dev/null +++ b/tests/stores/batch-store.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useBatchStore } from "@/stores/batch-store"; +import { makeFocusItem } from "../helpers/fixtures"; + +describe("useBatchStore", () => { + beforeEach(() => { + useBatchStore.getState().exitBatch(); + }); + + describe("startBatch", () => { + it("activates batch mode with items", () => { + const items = [ + makeFocusItem({ id: "r1", title: "Review A" }), + makeFocusItem({ id: "r2", title: "Review B" }), + makeFocusItem({ id: "r3", title: "Review C" }), + ]; + + useBatchStore.getState().startBatch(items, "CODE REVIEWS"); + + const state = useBatchStore.getState(); + expect(state.isActive).toBe(true); + expect(state.batchLabel).toBe("CODE REVIEWS"); + expect(state.items).toHaveLength(3); + expect(state.statuses).toEqual(["pending", "pending", "pending"]); + expect(state.currentIndex).toBe(0); + expect(state.startedAt).toBeGreaterThan(0); + }); + + it("initializes with zero completed and skipped", () => { + useBatchStore.getState().startBatch( + [makeFocusItem({ id: "r1" })], + "TEST" + ); + + const state = useBatchStore.getState(); + expect(state.completedCount()).toBe(0); + expect(state.skippedCount()).toBe(0); + expect(state.isFinished()).toBe(false); + }); + }); + + describe("markDone", () => { + it("marks current item as done and advances", () => { + useBatchStore.getState().startBatch( + [ + makeFocusItem({ id: "r1" }), + makeFocusItem({ id: "r2" }), + makeFocusItem({ id: "r3" }), + ], + "TEST" + ); + + useBatchStore.getState().markDone(); + + const state = useBatchStore.getState(); + expect(state.statuses[0]).toBe("done"); + expect(state.currentIndex).toBe(1); + expect(state.completedCount()).toBe(1); + }); + + it("finishes when last item is marked done", () => { + useBatchStore.getState().startBatch( + [makeFocusItem({ id: "r1" }), makeFocusItem({ id: "r2" })], + "TEST" + ); + + useBatchStore.getState().markDone(); + useBatchStore.getState().markDone(); + + const state = useBatchStore.getState(); + expect(state.completedCount()).toBe(2); + expect(state.isFinished()).toBe(true); + }); + }); + + describe("markSkipped", () => { + it("marks current item as skipped and advances", () => { + useBatchStore.getState().startBatch( + [makeFocusItem({ id: "r1" }), makeFocusItem({ id: "r2" })], + "TEST" + ); + + useBatchStore.getState().markSkipped(); + + const state = useBatchStore.getState(); + expect(state.statuses[0]).toBe("skipped"); + expect(state.currentIndex).toBe(1); + expect(state.skippedCount()).toBe(1); + }); + + it("mixed done and skipped tracks correctly", () => { + useBatchStore.getState().startBatch( + [ + makeFocusItem({ id: "r1" }), + makeFocusItem({ id: "r2" }), + makeFocusItem({ id: "r3" }), + ], + "TEST" + ); + + useBatchStore.getState().markDone(); + useBatchStore.getState().markSkipped(); + useBatchStore.getState().markDone(); + + const state = useBatchStore.getState(); + expect(state.completedCount()).toBe(2); + expect(state.skippedCount()).toBe(1); + expect(state.isFinished()).toBe(true); + }); + }); + + describe("exitBatch", () => { + it("clears all batch state", () => { + useBatchStore.getState().startBatch( + [makeFocusItem({ id: "r1" })], + "TEST" + ); + useBatchStore.getState().markDone(); + + useBatchStore.getState().exitBatch(); + + const state = useBatchStore.getState(); + expect(state.isActive).toBe(false); + expect(state.items).toHaveLength(0); + expect(state.statuses).toHaveLength(0); + expect(state.currentIndex).toBe(0); + expect(state.startedAt).toBeNull(); + }); + }); + + describe("progress tracking", () => { + it("reports correct progress through batch", () => { + useBatchStore.getState().startBatch( + [ + makeFocusItem({ id: "r1" }), + makeFocusItem({ id: "r2" }), + makeFocusItem({ id: "r3" }), + makeFocusItem({ id: "r4" }), + ], + "REVIEWS" + ); + + expect(useBatchStore.getState().isFinished()).toBe(false); + + useBatchStore.getState().markDone(); + useBatchStore.getState().markDone(); + + expect(useBatchStore.getState().completedCount()).toBe(2); + expect(useBatchStore.getState().isFinished()).toBe(false); + + useBatchStore.getState().markSkipped(); + useBatchStore.getState().markDone(); + + expect(useBatchStore.getState().completedCount()).toBe(3); + expect(useBatchStore.getState().skippedCount()).toBe(1); + expect(useBatchStore.getState().isFinished()).toBe(true); + }); + }); +}); diff --git a/tests/stores/capture-store.test.ts b/tests/stores/capture-store.test.ts new file mode 100644 index 0000000..ce3d9b0 --- /dev/null +++ b/tests/stores/capture-store.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useCaptureStore } from "@/stores/capture-store"; + +describe("useCaptureStore", () => { + beforeEach(() => { + useCaptureStore.setState({ + isOpen: false, + isSubmitting: false, + lastCapturedId: null, + error: null, + }); + }); + + it("starts closed", () => { + const state = useCaptureStore.getState(); + expect(state.isOpen).toBe(false); + expect(state.isSubmitting).toBe(false); + }); + + it("opens the overlay", () => { + useCaptureStore.getState().open(); + expect(useCaptureStore.getState().isOpen).toBe(true); + }); + + it("closes the overlay and resets state", () => { + useCaptureStore.setState({ isOpen: true, isSubmitting: true, error: "test" }); + useCaptureStore.getState().close(); + + const state = useCaptureStore.getState(); + expect(state.isOpen).toBe(false); + expect(state.isSubmitting).toBe(false); + expect(state.error).toBeNull(); + }); + + it("sets submitting state", () => { + useCaptureStore.getState().setSubmitting(true); + expect(useCaptureStore.getState().isSubmitting).toBe(true); + }); + + it("records a successful capture", () => { + useCaptureStore.getState().captureSuccess("bd-123"); + + const state = useCaptureStore.getState(); + expect(state.lastCapturedId).toBe("bd-123"); + expect(state.isSubmitting).toBe(false); + expect(state.isOpen).toBe(false); + expect(state.error).toBeNull(); + }); + + it("records a failed capture", () => { + useCaptureStore.setState({ isSubmitting: true }); + useCaptureStore.getState().captureError("br command failed"); + + const state = useCaptureStore.getState(); + expect(state.error).toBe("br command failed"); + expect(state.isSubmitting).toBe(false); + expect(state.isOpen).toBe(true); // stays open so user can retry + }); + + it("clears error when opening", () => { + useCaptureStore.setState({ error: "old error" }); + useCaptureStore.getState().open(); + + expect(useCaptureStore.getState().error).toBeNull(); + }); +}); diff --git a/tests/stores/focus-store.test.ts b/tests/stores/focus-store.test.ts new file mode 100644 index 0000000..9609f09 --- /dev/null +++ b/tests/stores/focus-store.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useFocusStore } from "@/stores/focus-store"; +import { makeFocusItem } from "../helpers/fixtures"; + +describe("useFocusStore", () => { + beforeEach(() => { + // Reset store between tests + useFocusStore.setState({ + current: null, + queue: [], + isLoading: false, + error: null, + }); + }); + + describe("setItems", () => { + it("sets first item as current and rest as queue", () => { + const items = [ + makeFocusItem({ id: "a", title: "First" }), + makeFocusItem({ id: "b", title: "Second" }), + makeFocusItem({ id: "c", title: "Third" }), + ]; + + useFocusStore.getState().setItems(items); + + const state = useFocusStore.getState(); + expect(state.current?.id).toBe("a"); + expect(state.queue).toHaveLength(2); + expect(state.queue[0].id).toBe("b"); + expect(state.queue[1].id).toBe("c"); + }); + + it("sets current to null when empty", () => { + useFocusStore.getState().setItems([]); + + const state = useFocusStore.getState(); + expect(state.current).toBeNull(); + expect(state.queue).toHaveLength(0); + }); + + it("clears loading and error on setItems", () => { + useFocusStore.setState({ isLoading: true, error: "old error" }); + + useFocusStore.getState().setItems([makeFocusItem()]); + + const state = useFocusStore.getState(); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + }); + + describe("act", () => { + it("advances to next item in queue", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "a" }), + makeFocusItem({ id: "b" }), + makeFocusItem({ id: "c" }), + ]); + + const next = useFocusStore.getState().act("start"); + + expect(next?.id).toBe("b"); + expect(useFocusStore.getState().current?.id).toBe("b"); + expect(useFocusStore.getState().queue).toHaveLength(1); + }); + + it("returns null when queue is empty", () => { + useFocusStore.getState().setItems([makeFocusItem({ id: "only" })]); + + const next = useFocusStore.getState().act("skip"); + + expect(next).toBeNull(); + expect(useFocusStore.getState().current).toBeNull(); + expect(useFocusStore.getState().queue).toHaveLength(0); + }); + + it("works with defer_1h action", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "a" }), + makeFocusItem({ id: "b" }), + ]); + + useFocusStore.getState().act("defer_1h", "in a meeting"); + + expect(useFocusStore.getState().current?.id).toBe("b"); + }); + + it("works with defer_tomorrow action", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "a" }), + makeFocusItem({ id: "b" }), + ]); + + useFocusStore.getState().act("defer_tomorrow"); + + expect(useFocusStore.getState().current?.id).toBe("b"); + }); + }); + + describe("setFocus", () => { + it("promotes a queue item to current", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "a", title: "First" }), + makeFocusItem({ id: "b", title: "Second" }), + makeFocusItem({ id: "c", title: "Third" }), + ]); + + useFocusStore.getState().setFocus("c"); + + const state = useFocusStore.getState(); + expect(state.current?.id).toBe("c"); + // Previous current and other queue items are in queue + expect(state.queue.map((i) => i.id)).toEqual( + expect.arrayContaining(["a", "b"]) + ); + expect(state.queue).toHaveLength(2); + }); + + it("does nothing for unknown item ID", () => { + useFocusStore.getState().setItems([makeFocusItem({ id: "a" })]); + + useFocusStore.getState().setFocus("nonexistent"); + + expect(useFocusStore.getState().current?.id).toBe("a"); + }); + }); + + describe("reorderQueue", () => { + it("moves an item from one position to another", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "focus" }), + makeFocusItem({ id: "a" }), + makeFocusItem({ id: "b" }), + makeFocusItem({ id: "c" }), + ]); + + useFocusStore.getState().reorderQueue(2, 0); + + const ids = useFocusStore.getState().queue.map((i) => i.id); + expect(ids).toEqual(["c", "a", "b"]); + }); + + it("does nothing for same from/to index", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "focus" }), + makeFocusItem({ id: "a" }), + makeFocusItem({ id: "b" }), + ]); + + useFocusStore.getState().reorderQueue(0, 0); + + const ids = useFocusStore.getState().queue.map((i) => i.id); + expect(ids).toEqual(["a", "b"]); + }); + + it("does nothing for out-of-bounds indices", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "focus" }), + makeFocusItem({ id: "a" }), + ]); + + useFocusStore.getState().reorderQueue(-1, 0); + useFocusStore.getState().reorderQueue(0, 5); + + expect(useFocusStore.getState().queue.map((i) => i.id)).toEqual(["a"]); + }); + + it("does not affect current focus", () => { + useFocusStore.getState().setItems([ + makeFocusItem({ id: "focus" }), + makeFocusItem({ id: "a" }), + makeFocusItem({ id: "b" }), + ]); + + useFocusStore.getState().reorderQueue(1, 0); + + expect(useFocusStore.getState().current?.id).toBe("focus"); + }); + }); + + describe("setLoading / setError", () => { + it("sets loading state", () => { + useFocusStore.getState().setLoading(true); + expect(useFocusStore.getState().isLoading).toBe(true); + }); + + it("sets error state", () => { + useFocusStore.getState().setError("something broke"); + expect(useFocusStore.getState().error).toBe("something broke"); + }); + }); +}); diff --git a/tests/stores/nav-store.test.ts b/tests/stores/nav-store.test.ts new file mode 100644 index 0000000..26fd928 --- /dev/null +++ b/tests/stores/nav-store.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useNavStore } from "@/stores/nav-store"; + +describe("useNavStore", () => { + beforeEach(() => { + useNavStore.setState({ activeView: "focus" }); + }); + + it("defaults to focus view", () => { + expect(useNavStore.getState().activeView).toBe("focus"); + }); + + it("switches to queue view", () => { + useNavStore.getState().setView("queue"); + expect(useNavStore.getState().activeView).toBe("queue"); + }); + + it("switches to inbox view", () => { + useNavStore.getState().setView("inbox"); + expect(useNavStore.getState().activeView).toBe("inbox"); + }); + + it("switches back to focus", () => { + useNavStore.getState().setView("queue"); + useNavStore.getState().setView("focus"); + expect(useNavStore.getState().activeView).toBe("focus"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 59ce37d..71046ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,9 +17,15 @@ export default defineConfig({ include: ["src/**/*.{ts,tsx}"], exclude: ["src/main.tsx", "src/**/*.d.ts"], }, - // Mock Tauri APIs + // Mock Tauri APIs (including subpath exports) alias: { + "@tauri-apps/api/core": path.resolve(__dirname, "./tests/mocks/tauri-api.ts"), + "@tauri-apps/api/event": path.resolve(__dirname, "./tests/mocks/tauri-api.ts"), "@tauri-apps/api": path.resolve(__dirname, "./tests/mocks/tauri-api.ts"), + "@tauri-apps/plugin-shell": path.resolve( + __dirname, + "./tests/mocks/tauri-plugin-shell.ts" + ), }, }, resolve: {