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>
129 lines
3.8 KiB
TypeScript
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();
|
|
});
|
|
});
|