File size: 5,542 Bytes
52efc7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | /**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
SearchableList,
type SearchableListProps,
type SearchListState,
type GenericListItem,
} from './SearchableList.js';
import { useTextBuffer } from './text-buffer.js';
const useMockSearch = (props: {
items: GenericListItem[];
initialQuery?: string;
onSearch?: (query: string) => void;
}): SearchListState<GenericListItem> => {
const { onSearch, items, initialQuery = '' } = props;
const [text, setText] = React.useState(initialQuery);
const filteredItems = React.useMemo(
() =>
items.filter((item: GenericListItem) =>
item.label.toLowerCase().includes(text.toLowerCase()),
),
[items, text],
);
React.useEffect(() => {
onSearch?.(text);
}, [text, onSearch]);
const searchBuffer = useTextBuffer({
initialText: text,
onChange: setText,
viewport: { width: 100, height: 1 },
singleLine: true,
});
return {
filteredItems,
searchBuffer,
searchQuery: text,
setSearchQuery: setText,
maxLabelWidth: 10,
};
};
const mockItems: GenericListItem[] = [
{
key: 'item-1',
label: 'Item One',
description: 'Description for item one',
},
{
key: 'item-2',
label: 'Item Two',
description: 'Description for item two',
},
{
key: 'item-3',
label: 'Item Three',
description: 'Description for item three',
},
];
describe('SearchableList', () => {
let mockOnSelect: ReturnType<typeof vi.fn>;
let mockOnClose: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockOnSelect = vi.fn();
mockOnClose = vi.fn();
});
const renderList = async (
props: Partial<SearchableListProps<GenericListItem>> = {},
) => {
const defaultProps: SearchableListProps<GenericListItem> = {
title: 'Test List',
items: mockItems,
onSelect: mockOnSelect,
onClose: mockOnClose,
useSearch: useMockSearch,
...props,
};
return renderWithProviders(<SearchableList {...defaultProps} />);
};
it('should render all items initially', async () => {
const { lastFrame } = await renderList();
const frame = lastFrame();
expect(frame).toContain('Test List');
expect(frame).toContain('Item One');
expect(frame).toContain('Item Two');
expect(frame).toContain('Item Three');
expect(frame).toContain('Description for item one');
});
it('should reset selection to top when items change if resetSelectionOnItemsChange is true', async () => {
const { lastFrame, stdin } = await renderList({
resetSelectionOnItemsChange: true,
});
await React.act(async () => {
stdin.write('\u001B[B'); // Down arrow
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('● Item Two');
});
expect(lastFrame()).toMatchSnapshot();
await React.act(async () => {
stdin.write('One');
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Item One');
expect(frame).not.toContain('Item Two');
});
expect(lastFrame()).toMatchSnapshot();
await React.act(async () => {
// Backspace "One" (3 chars)
stdin.write('\u007F\u007F\u007F');
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Item Two');
expect(frame).toContain('● Item One');
expect(frame).not.toContain('● Item Two');
});
expect(lastFrame()).toMatchSnapshot();
});
it('should filter items based on search query', async () => {
const { lastFrame, stdin } = await renderList();
await React.act(async () => {
stdin.write('Two');
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Item Two');
expect(frame).not.toContain('Item One');
expect(frame).not.toContain('Item Three');
});
});
it('should show "No items found." when no items match', async () => {
const { lastFrame, stdin } = await renderList();
await React.act(async () => {
stdin.write('xyz123');
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('No items found.');
});
});
it('should handle selection with Enter', async () => {
const { stdin } = await renderList();
await React.act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]);
});
});
it('should handle navigation and selection', async () => {
const { stdin } = await renderList();
await React.act(async () => {
stdin.write('\u001B[B'); // Down arrow
});
await React.act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]);
});
});
it('should handle close with Esc', async () => {
const { stdin } = await renderList();
await React.act(async () => {
stdin.write('\u001B'); // Esc
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should match snapshot', async () => {
const { lastFrame } = await renderList();
expect(lastFrame()).toMatchSnapshot();
});
});
|