Files
stack/apps/web/src/components/search/__tests__/SearchInput.test.tsx
Jason Woltje 3cb6eb7f8b feat(#67): implement search UI with filters and shortcuts
Implements comprehensive search interface for knowledge base:

Components:
- SearchInput: Debounced search with Cmd+K (Ctrl+K) shortcut
- SearchResults: Main results view with highlighted snippets
- SearchFilters: Sidebar for filtering by status and tags
- Search page: Full search experience at /knowledge/search

Features:
- Search-as-you-type with 300ms debounce
- HTML snippet highlighting (using <mark> from API)
- Tag and status filters with PDA-friendly language
- Keyboard shortcuts (Cmd+K/Ctrl+K to open, Escape to clear)
- No results state with helpful suggestions
- Loading states
- Visual status indicators (🟢 Active, 🔵 Scheduled, etc.)

Navigation:
- Added search button to header with keyboard hint
- Global Cmd+K shortcut redirects to search page
- Added "Knowledge" link to main navigation

Infrastructure:
- Updated Input component to support forwardRef for proper ref handling
- Comprehensive test coverage (100% on main components)
- All tests passing (339 passed)
- TypeScript strict mode compliant
- ESLint compliant

Fixes #67

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:50:25 -06:00

129 lines
3.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { SearchInput } from "../SearchInput";
describe("SearchInput", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("should render search input field", () => {
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByPlaceholderText(/search/i);
expect(input).toBeInTheDocument();
});
it("should call onSearch after debounce delay", () => {
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
const input = screen.getByPlaceholderText(/search/i);
fireEvent.change(input, { target: { value: "test query" } });
// Should not call immediately
expect(onSearch).not.toHaveBeenCalled();
// Fast-forward time
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith("test query");
});
it("should debounce multiple keystrokes", () => {
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
const input = screen.getByPlaceholderText(/search/i);
fireEvent.change(input, { target: { value: "a" } });
vi.advanceTimersByTime(100);
fireEvent.change(input, { target: { value: "ab" } });
vi.advanceTimersByTime(100);
fireEvent.change(input, { target: { value: "abc" } });
vi.advanceTimersByTime(100);
// Should only call once after final delay
expect(onSearch).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onSearch).toHaveBeenCalledWith("abc");
});
it("should focus input when Cmd+K is pressed", () => {
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByPlaceholderText(/search/i);
// Simulate Cmd+K
fireEvent.keyDown(document, { key: "k", metaKey: true });
expect(document.activeElement).toBe(input);
});
it("should focus input when Ctrl+K is pressed on Windows/Linux", () => {
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByPlaceholderText(/search/i);
// Simulate Ctrl+K
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
expect(document.activeElement).toBe(input);
});
it("should clear input when Escape is pressed", () => {
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByPlaceholderText(/search/i);
fireEvent.change(input, { target: { value: "test query" } });
expect((input as HTMLInputElement).value).toBe("test query");
fireEvent.keyDown(input, { key: "Escape" });
expect((input as HTMLInputElement).value).toBe("");
});
it("should display initial value", () => {
render(<SearchInput onSearch={vi.fn()} initialValue="initial query" />);
const input = screen.getByPlaceholderText(/search/i);
expect((input as HTMLInputElement).value).toBe("initial query");
});
it("should show loading indicator when isLoading is true", () => {
render(<SearchInput onSearch={vi.fn()} isLoading={true} />);
const loader = screen.getByTestId("search-loading");
expect(loader).toBeInTheDocument();
});
it("should not call onSearch for empty strings", () => {
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
const input = screen.getByPlaceholderText(/search/i);
fireEvent.change(input, { target: { value: " " } });
vi.advanceTimersByTime(300);
expect(onSearch).not.toHaveBeenCalled();
});
it("should show keyboard shortcut hint", () => {
render(<SearchInput onSearch={vi.fn()} />);
const hint = screen.getByText(/⌘K|Ctrl\+K/i);
expect(hint).toBeInTheDocument();
});
});