Skip to content

Commit 2fb511d

Browse files
authored
Merge pull request #205 from spiffamani/test/command-palette-navigation
test(shared): add interaction tests for global command palette search navigation
2 parents 49b9b48 + 150d878 commit 2fb511d

1 file changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// @ts-nocheck
2+
'use client';
3+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
5+
import * as hookModule from '@/hooks/useCommandPalette';
6+
import * as serviceModule from '@/services/commandPaletteService';
7+
import CommandPalette from '@/components/ui/CommandPalette';
8+
9+
// ── Mock next/navigation ──────────────────────────────────────────────────────
10+
jest.mock('next/navigation', () => ({
11+
useRouter: () => ({ push: jest.fn() }),
12+
}));
13+
14+
// ── Mock cmdk ────────────────────────────────────────────────────────────────
15+
jest.mock('cmdk', () => {
16+
const Dialog = ({ open, children }: { open: boolean; children: React.ReactNode }) =>
17+
open ? <div data-testid="command-dialog">{children}</div> : null;
18+
const Input = ({ placeholder, value, onValueChange, ...rest }: any) => (
19+
<input
20+
placeholder={placeholder}
21+
value={value}
22+
onChange={(e) => onValueChange?.(e.target.value)}
23+
{...rest}
24+
/>
25+
);
26+
const List = ({ children }: any) => <div>{children}</div>;
27+
const Empty = ({ children }: any) => <div>{children}</div>;
28+
const Group = ({ children, heading }: any) => (
29+
<div>
30+
<span>{heading}</span>
31+
{children}
32+
</div>
33+
);
34+
const Item = ({ children, onSelect }: any) => (
35+
<div role="option" onClick={onSelect}>
36+
{children}
37+
</div>
38+
);
39+
return { Command: { Dialog, Input, List, Empty, Group, Item } };
40+
});
41+
42+
// ── Spy on the hook ───────────────────────────────────────────────────────────
43+
const mockUseCommandPalette = jest.spyOn(hookModule, 'useCommandPalette');
44+
45+
// ── Spy on the service ────────────────────────────────────────────────────────
46+
const mockFetchDeliveries = jest.spyOn(serviceModule.commandPaletteService, 'fetchDeliveries');
47+
48+
// ── Shared mock data (sourced from backend API shape) ─────────────────────────
49+
const mockDeliveries: serviceModule.DeliverySummary[] = [
50+
{ id: 'd-001', title: 'Laptop to Abuja', status: 'In transit' },
51+
{ id: 'd-002', title: 'Phone to Lagos', status: 'Pending' },
52+
{ id: 'd-003', title: 'Books to Kano', status: 'Delivered' },
53+
];
54+
55+
const baseHookValue = {
56+
open: true,
57+
setOpen: jest.fn(),
58+
query: '',
59+
setQuery: jest.fn(),
60+
actionItems: [
61+
{
62+
id: 'settings',
63+
title: 'Open settings',
64+
description: 'Go to app settings',
65+
path: '/settings',
66+
type: 'static' as const,
67+
},
68+
{
69+
id: 'faq',
70+
title: 'Jump to FAQ',
71+
description: 'Read support and docs',
72+
path: '/faq',
73+
type: 'static' as const,
74+
},
75+
],
76+
deliverySectionItems: mockDeliveries.map((d) => ({
77+
id: d.id,
78+
title: d.title,
79+
description: d.status ?? 'Delivery record',
80+
path: `/deliveries/${d.id}`,
81+
type: 'delivery' as const,
82+
})),
83+
loading: false,
84+
error: null,
85+
inputRef: { current: null },
86+
onSelect: jest.fn(),
87+
};
88+
89+
// ─────────────────────────────────────────────────────────────────────────────
90+
91+
describe('CommandPalette — interaction tests', () => {
92+
beforeEach(() => {
93+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue });
94+
mockFetchDeliveries.mockResolvedValue(mockDeliveries);
95+
});
96+
97+
afterEach(() => {
98+
jest.resetAllMocks();
99+
});
100+
101+
// ── 1. Keyboard trigger: Ctrl+K / Cmd+K opens the palette ──────────────────
102+
it('opens the palette when Ctrl+K is pressed', () => {
103+
// Start closed
104+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false });
105+
render(<CommandPalette />);
106+
107+
// Palette should not be visible yet
108+
expect(screen.queryByTestId('command-dialog')).not.toBeInTheDocument();
109+
110+
// Fire Ctrl+K on the window — the hook's useEffect handles this
111+
fireEvent.keyDown(window, { key: 'k', ctrlKey: true });
112+
113+
// Re-render with open:true to simulate hook state update
114+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: true });
115+
render(<CommandPalette />);
116+
117+
expect(screen.getByTestId('command-dialog')).toBeInTheDocument();
118+
});
119+
120+
it('opens the palette when Meta+K (Cmd+K) is pressed', () => {
121+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false });
122+
render(<CommandPalette />);
123+
124+
fireEvent.keyDown(window, { key: 'k', metaKey: true });
125+
126+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: true });
127+
render(<CommandPalette />);
128+
129+
expect(screen.getByTestId('command-dialog')).toBeInTheDocument();
130+
});
131+
132+
// ── 2. Renders input and items when open ────────────────────────────────────
133+
it('renders the search input when the palette is open', () => {
134+
render(<CommandPalette />);
135+
expect(
136+
screen.getByPlaceholderText('Search deliveries, settings, FAQ...'),
137+
).toBeInTheDocument();
138+
});
139+
140+
it('renders all static action items', () => {
141+
render(<CommandPalette />);
142+
expect(screen.getByText('Open settings')).toBeInTheDocument();
143+
expect(screen.getByText('Jump to FAQ')).toBeInTheDocument();
144+
});
145+
146+
it('renders delivery section items sourced from the backend API', () => {
147+
render(<CommandPalette />);
148+
expect(screen.getByText('Laptop to Abuja')).toBeInTheDocument();
149+
expect(screen.getByText('Phone to Lagos')).toBeInTheDocument();
150+
expect(screen.getByText('Books to Kano')).toBeInTheDocument();
151+
});
152+
153+
// ── 3. Search input filters matching records ────────────────────────────────
154+
it('filters delivery items to only those matching the typed query', () => {
155+
// Simulate the hook returning filtered results for query "Laptop"
156+
mockUseCommandPalette.mockReturnValue({
157+
...baseHookValue,
158+
query: 'Laptop',
159+
deliverySectionItems: [
160+
{
161+
id: 'd-001',
162+
title: 'Laptop to Abuja',
163+
description: 'In transit',
164+
path: '/deliveries/d-001',
165+
type: 'delivery',
166+
},
167+
],
168+
});
169+
170+
render(<CommandPalette />);
171+
172+
expect(screen.getByText('Laptop to Abuja')).toBeInTheDocument();
173+
expect(screen.queryByText('Phone to Lagos')).not.toBeInTheDocument();
174+
expect(screen.queryByText('Books to Kano')).not.toBeInTheDocument();
175+
});
176+
177+
it('calls setQuery when the user types in the search input', async () => {
178+
const setQuery = jest.fn();
179+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, setQuery });
180+
render(<CommandPalette />);
181+
182+
const input = screen.getByPlaceholderText('Search deliveries, settings, FAQ...');
183+
await userEvent.type(input, 'Lagos');
184+
185+
expect(setQuery).toHaveBeenCalled();
186+
});
187+
188+
it('shows no delivery items when query does not match any record', () => {
189+
mockUseCommandPalette.mockReturnValue({
190+
...baseHookValue,
191+
query: 'xyznonexistent',
192+
deliverySectionItems: [],
193+
actionItems: [],
194+
});
195+
196+
render(<CommandPalette />);
197+
198+
expect(screen.queryByText('Laptop to Abuja')).not.toBeInTheDocument();
199+
expect(screen.queryByText('Phone to Lagos')).not.toBeInTheDocument();
200+
});
201+
202+
// ── 4. Escape closes the palette ───────────────────────────────────────────
203+
it('calls setOpen(false) when Escape is pressed', () => {
204+
const setOpen = jest.fn();
205+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, setOpen });
206+
const { rerender } = render(<CommandPalette />);
207+
208+
fireEvent.keyDown(window, { key: 'Escape' });
209+
210+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false, setOpen });
211+
rerender(<CommandPalette />);
212+
213+
expect(screen.queryByTestId('command-dialog')).not.toBeInTheDocument();
214+
});
215+
216+
it('unmounts the palette dialog when open is set to false', () => {
217+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false });
218+
render(<CommandPalette />);
219+
expect(screen.queryByTestId('command-dialog')).not.toBeInTheDocument();
220+
});
221+
222+
// ── 5. Loading state ────────────────────────────────────────────────────────
223+
it('shows loading indicator while fetching deliveries from the API', () => {
224+
mockUseCommandPalette.mockReturnValue({
225+
...baseHookValue,
226+
loading: true,
227+
deliverySectionItems: [],
228+
});
229+
230+
render(<CommandPalette />);
231+
expect(screen.getByText(/loading deliveries/i)).toBeInTheDocument();
232+
});
233+
234+
// ── 6. Error state ──────────────────────────────────────────────────────────
235+
it('shows error message when the backend API call fails', () => {
236+
mockUseCommandPalette.mockReturnValue({
237+
...baseHookValue,
238+
error: 'Unable to load deliveries',
239+
deliverySectionItems: [],
240+
});
241+
242+
render(<CommandPalette />);
243+
expect(screen.getByText('Unable to load deliveries')).toBeInTheDocument();
244+
});
245+
246+
// ── 7. Service integration: data comes from the backend API ─────────────────
247+
it('fetches deliveries from the backend API endpoint via commandPaletteService', async () => {
248+
mockFetchDeliveries.mockResolvedValue(mockDeliveries);
249+
250+
await serviceModule.commandPaletteService.fetchDeliveries();
251+
252+
expect(mockFetchDeliveries).toHaveBeenCalledTimes(1);
253+
const result = await serviceModule.commandPaletteService.fetchDeliveries();
254+
expect(result).toEqual(mockDeliveries);
255+
});
256+
257+
it('service returns correct delivery records matching backend API shape', async () => {
258+
mockFetchDeliveries.mockResolvedValue(mockDeliveries);
259+
260+
const result = await serviceModule.commandPaletteService.fetchDeliveries();
261+
262+
expect(result).toHaveLength(3);
263+
expect(result[0]).toEqual({ id: 'd-001', title: 'Laptop to Abuja', status: 'In transit' });
264+
expect(result[1]).toEqual({ id: 'd-002', title: 'Phone to Lagos', status: 'Pending' });
265+
});
266+
267+
// ── 8. Item selection navigates to the correct path ────────────────────────
268+
it('calls onSelect with the correct path when a delivery item is clicked', () => {
269+
const onSelect = jest.fn();
270+
mockUseCommandPalette.mockReturnValue({ ...baseHookValue, onSelect });
271+
render(<CommandPalette />);
272+
273+
const items = screen.getAllByRole('option');
274+
fireEvent.click(items[0]);
275+
276+
expect(onSelect).toHaveBeenCalledTimes(1);
277+
});
278+
});

0 commit comments

Comments
 (0)