| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; |
| import { act } from 'react'; |
| import { renderWithProviders } from '../../../test-utils/render.js'; |
| import { |
| BaseSelectionList, |
| type BaseSelectionListProps, |
| type RenderItemContext, |
| } from './BaseSelectionList.js'; |
| import { useSelectionList } from '../../hooks/useSelectionList.js'; |
| import { Text } from 'ink'; |
| import type { theme } from '../../semantic-colors.js'; |
| import { useMouseClick } from '../../hooks/useMouseClick.js'; |
|
|
| vi.mock('../../hooks/useSelectionList.js'); |
| vi.mock('../../hooks/useMouseClick.js'); |
|
|
| const mockTheme = { |
| text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' }, |
| ui: { focus: 'COLOR_FOCUS' }, |
| background: { focus: 'COLOR_FOCUS_BG' }, |
| } as typeof theme; |
|
|
| vi.mock('../../semantic-colors.js', () => ({ |
| theme: { |
| text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' }, |
| ui: { focus: 'COLOR_FOCUS' }, |
| background: { focus: 'COLOR_FOCUS_BG' }, |
| }, |
| })); |
|
|
| describe('BaseSelectionList', () => { |
| const mockOnSelect = vi.fn(); |
| const mockOnHighlight = vi.fn(); |
| const mockRenderItem = vi.fn(); |
| const mockSetActiveIndex = vi.fn(); |
|
|
| const items = [ |
| { value: 'A', label: 'Item A', key: 'A' }, |
| { value: 'B', label: 'Item B', disabled: true, key: 'B' }, |
| { value: 'C', label: 'Item C', key: 'C' }, |
| ]; |
|
|
| |
| const renderComponent = async ( |
| props: Partial< |
| BaseSelectionListProps< |
| string, |
| { value: string; label: string; disabled?: boolean; key: string } |
| > |
| > = {}, |
| activeIndex: number = 0, |
| ) => { |
| vi.mocked(useSelectionList).mockReturnValue({ |
| activeIndex, |
| setActiveIndex: mockSetActiveIndex, |
| }); |
|
|
| mockRenderItem.mockImplementation( |
| ( |
| item: { value: string; label: string; disabled?: boolean; key: string }, |
| context: RenderItemContext, |
| ) => <Text color={context.titleColor}>{item.label}</Text>, |
| ); |
|
|
| const defaultProps: BaseSelectionListProps< |
| string, |
| { value: string; label: string; disabled?: boolean; key: string } |
| > = { |
| items, |
| onSelect: mockOnSelect, |
| onHighlight: mockOnHighlight, |
| renderItem: mockRenderItem, |
| ...props, |
| }; |
|
|
| const result = await renderWithProviders( |
| <BaseSelectionList {...defaultProps} />, |
| ); |
| return result; |
| }; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| }); |
|
|
| describe('Rendering and Structure', () => { |
| it('should render all items using the renderItem prop', async () => { |
| const { lastFrame, unmount } = await renderComponent(); |
|
|
| expect(lastFrame()).toContain('Item A'); |
| expect(lastFrame()).toContain('Item B'); |
| expect(lastFrame()).toContain('Item C'); |
|
|
| expect(mockRenderItem).toHaveBeenCalledTimes(3); |
| expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object)); |
| unmount(); |
| }); |
|
|
| it('should render the selection indicator (● or space) and layout', async () => { |
| const { lastFrame, unmount } = await renderComponent({}, 0); |
| const output = lastFrame(); |
|
|
| |
| expect(output).toMatch(/●\s+1\.\s+Item A/); |
| expect(output).toMatch(/\s+2\.\s+Item B/); |
| expect(output).toMatch(/\s+3\.\s+Item C/); |
| unmount(); |
| }); |
|
|
| it('should handle an empty list gracefully', async () => { |
| const { lastFrame, unmount } = await renderComponent({ items: [] }); |
| expect(mockRenderItem).not.toHaveBeenCalled(); |
| expect(lastFrame({ allowEmpty: true })).toBe(''); |
| unmount(); |
| }); |
| }); |
|
|
| describe('useSelectionList Integration', () => { |
| it('should pass props correctly to useSelectionList', async () => { |
| const initialIndex = 1; |
| const isFocused = false; |
| const showNumbers = false; |
|
|
| const { unmount } = await renderComponent({ |
| initialIndex, |
| isFocused, |
| showNumbers, |
| }); |
|
|
| expect(useSelectionList).toHaveBeenCalledWith({ |
| items, |
| initialIndex, |
| onSelect: mockOnSelect, |
| onHighlight: mockOnHighlight, |
| isFocused, |
| showNumbers, |
| wrapAround: true, |
| }); |
| unmount(); |
| }); |
|
|
| it('should use the activeIndex returned by the hook', async () => { |
| const { unmount } = await renderComponent({}, 2); |
|
|
| expect(mockRenderItem).toHaveBeenCalledWith( |
| items[0], |
| expect.objectContaining({ isSelected: false }), |
| ); |
| expect(mockRenderItem).toHaveBeenCalledWith( |
| items[2], |
| expect.objectContaining({ isSelected: true }), |
| ); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Styling and Colors', () => { |
| it('should apply success color to the selected item', async () => { |
| const { unmount } = await renderComponent({}, 0); |
|
|
| |
| expect(mockRenderItem).toHaveBeenCalledWith( |
| items[0], |
| expect.objectContaining({ |
| titleColor: mockTheme.ui.focus, |
| numberColor: mockTheme.ui.focus, |
| isSelected: true, |
| }), |
| ); |
| unmount(); |
| }); |
|
|
| it('should apply primary color to unselected, enabled items', async () => { |
| const { unmount } = await renderComponent({}, 0); |
|
|
| |
| expect(mockRenderItem).toHaveBeenCalledWith( |
| items[2], |
| expect.objectContaining({ |
| titleColor: mockTheme.text.primary, |
| numberColor: mockTheme.text.primary, |
| isSelected: false, |
| }), |
| ); |
| unmount(); |
| }); |
|
|
| it('should apply secondary color to disabled items (when not selected)', async () => { |
| const { unmount } = await renderComponent({}, 0); |
|
|
| |
| expect(mockRenderItem).toHaveBeenCalledWith( |
| items[1], |
| expect.objectContaining({ |
| titleColor: mockTheme.text.secondary, |
| numberColor: mockTheme.text.secondary, |
| isSelected: false, |
| }), |
| ); |
| unmount(); |
| }); |
|
|
| it('should apply success color to disabled items if they are selected', async () => { |
| |
| const { unmount } = await renderComponent({}, 1); |
|
|
| |
| expect(mockRenderItem).toHaveBeenCalledWith( |
| items[1], |
| expect.objectContaining({ |
| titleColor: mockTheme.ui.focus, |
| numberColor: mockTheme.ui.focus, |
| isSelected: true, |
| }), |
| ); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Numbering (showNumbers)', () => { |
| it('should show numbers by default with correct formatting', async () => { |
| const { lastFrame, unmount } = await renderComponent(); |
| const output = lastFrame(); |
|
|
| expect(output).toContain('1.'); |
| expect(output).toContain('2.'); |
| expect(output).toContain('3.'); |
| unmount(); |
| }); |
|
|
| it('should hide numbers when showNumbers is false', async () => { |
| const { lastFrame, unmount } = await renderComponent({ |
| showNumbers: false, |
| }); |
| const output = lastFrame(); |
|
|
| expect(output).not.toContain('1.'); |
| expect(output).not.toContain('2.'); |
| expect(output).not.toContain('3.'); |
| unmount(); |
| }); |
|
|
| it('should apply correct padding for alignment in long lists', async () => { |
| const longList = Array.from({ length: 15 }, (_, i) => ({ |
| value: `Item ${i + 1}`, |
| label: `Item ${i + 1}`, |
| key: `Item ${i + 1}`, |
| })); |
|
|
| |
| const { lastFrame, unmount } = await renderComponent({ |
| items: longList, |
| maxItemsToShow: 15, |
| }); |
| const output = lastFrame(); |
|
|
| |
| |
| expect(output).toContain(' 1.'); |
| expect(output).toContain('10.'); |
| unmount(); |
| }); |
|
|
| it('should apply secondary color to numbers if showNumbers is false (internal logic check)', async () => { |
| const { unmount } = await renderComponent({ showNumbers: false }, 0); |
|
|
| expect(mockRenderItem).toHaveBeenCalledWith( |
| items[0], |
| expect.objectContaining({ |
| isSelected: true, |
| titleColor: mockTheme.ui.focus, |
| numberColor: mockTheme.text.secondary, |
| }), |
| ); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Scrolling and Pagination (maxItemsToShow)', () => { |
| const longList = Array.from({ length: 10 }, (_, i) => ({ |
| value: `Item ${i + 1}`, |
| label: `Item ${i + 1}`, |
| key: `Item ${i + 1}`, |
| })); |
| const MAX_ITEMS = 3; |
|
|
| const renderScrollableList = async (initialActiveIndex: number = 0) => { |
| |
| const componentProps: BaseSelectionListProps< |
| string, |
| { value: string; label: string; key: string } |
| > = { |
| items: longList, |
| maxItemsToShow: MAX_ITEMS, |
| onSelect: mockOnSelect, |
| onHighlight: mockOnHighlight, |
| renderItem: mockRenderItem, |
| }; |
|
|
| vi.mocked(useSelectionList).mockReturnValue({ |
| activeIndex: initialActiveIndex, |
| setActiveIndex: vi.fn(), |
| }); |
|
|
| mockRenderItem.mockImplementation( |
| (item: (typeof longList)[0], context: RenderItemContext) => ( |
| <Text color={context.titleColor}>{item.label}</Text> |
| ), |
| ); |
|
|
| const { rerender, lastFrame, waitUntilReady, unmount } = |
| await renderWithProviders(<BaseSelectionList {...componentProps} />); |
|
|
| |
| const updateActiveIndex = async (newIndex: number) => { |
| vi.mocked(useSelectionList).mockReturnValue({ |
| activeIndex: newIndex, |
| setActiveIndex: vi.fn(), |
| }); |
|
|
| rerender(<BaseSelectionList {...componentProps} />); |
| await waitUntilReady(); |
| }; |
|
|
| return { updateActiveIndex, lastFrame, unmount }; |
| }; |
|
|
| it('should only show maxItemsToShow items initially', async () => { |
| const { lastFrame, unmount } = await renderScrollableList(0); |
| const output = lastFrame(); |
|
|
| expect(output).toContain('Item 1'); |
| expect(output).toContain('Item 3'); |
| expect(output).not.toContain('Item 4'); |
| unmount(); |
| }); |
|
|
| it('should scroll down when activeIndex moves beyond the visible window', async () => { |
| const { updateActiveIndex, lastFrame, unmount } = |
| await renderScrollableList(0); |
|
|
| |
| |
| await updateActiveIndex(3); |
|
|
| const output = lastFrame(); |
| expect(output).not.toContain('Item 1'); |
| expect(output).toContain('Item 2'); |
| expect(output).toContain('Item 4'); |
| expect(output).not.toContain('Item 5'); |
| unmount(); |
| }); |
|
|
| it('should scroll up when activeIndex moves before the visible window', async () => { |
| const { updateActiveIndex, lastFrame, unmount } = |
| await renderScrollableList(0); |
|
|
| await updateActiveIndex(4); |
|
|
| let output = lastFrame(); |
| expect(output).toContain('Item 3'); |
| expect(output).toContain('Item 5'); |
| expect(output).not.toContain('Item 2'); |
|
|
| |
| |
| await updateActiveIndex(1); |
|
|
| output = lastFrame(); |
| expect(output).toContain('Item 2'); |
| expect(output).toContain('Item 4'); |
| expect(output).not.toContain('Item 5'); |
| unmount(); |
| }); |
|
|
| it('should pin the scroll offset to the end if selection starts near the end', async () => { |
| |
| |
| |
| const { lastFrame, unmount } = await renderScrollableList(9); |
|
|
| const output = lastFrame(); |
| expect(output).toContain('Item 10'); |
| expect(output).toContain('Item 8'); |
| expect(output).not.toContain('Item 7'); |
| unmount(); |
| }); |
|
|
| it('should handle dynamic scrolling through multiple activeIndex changes', async () => { |
| const { updateActiveIndex, lastFrame, unmount } = |
| await renderScrollableList(0); |
|
|
| expect(lastFrame()).toContain('Item 1'); |
| expect(lastFrame()).toContain('Item 3'); |
|
|
| |
| await updateActiveIndex(2); |
| expect(lastFrame()).toContain('Item 1'); |
|
|
| await updateActiveIndex(3); |
| let output = lastFrame(); |
| expect(output).toContain('Item 2'); |
| expect(output).toContain('Item 4'); |
| expect(output).not.toContain('Item 1'); |
|
|
| await updateActiveIndex(5); |
| output = lastFrame(); |
| expect(output).toContain('Item 4'); |
| expect(output).toContain('Item 6'); |
| expect(output).not.toContain('Item 3'); |
| unmount(); |
| }); |
|
|
| it('should correctly identify the selected item within the visible window', async () => { |
| const { unmount } = await renderScrollableList(1); |
|
|
| expect(mockRenderItem).toHaveBeenCalledTimes(MAX_ITEMS); |
|
|
| expect(mockRenderItem).toHaveBeenCalledWith( |
| expect.objectContaining({ value: 'Item 1' }), |
| expect.objectContaining({ isSelected: false }), |
| ); |
|
|
| expect(mockRenderItem).toHaveBeenCalledWith( |
| expect.objectContaining({ value: 'Item 2' }), |
| expect.objectContaining({ isSelected: true }), |
| ); |
| unmount(); |
| }); |
|
|
| it('should correctly identify the selected item when scrolled (high index)', async () => { |
| const { unmount } = await renderScrollableList(5); |
|
|
| |
| expect(mockRenderItem).toHaveBeenCalledWith( |
| expect.objectContaining({ value: 'Item 6' }), |
| expect.objectContaining({ isSelected: true }), |
| ); |
|
|
| |
| expect(mockRenderItem).toHaveBeenCalledWith( |
| expect.objectContaining({ value: 'Item 4' }), |
| expect.objectContaining({ isSelected: false }), |
| ); |
| unmount(); |
| }); |
|
|
| it('should correctly calculate scroll offset during the initial render phase', async () => { |
| |
| |
| |
| const { unmount } = await renderScrollableList(9); |
|
|
| const renderedItemValues = mockRenderItem.mock.calls.map( |
| (call) => call[0].value, |
| ); |
|
|
| |
| |
| expect(renderedItemValues).not.toContain('Item 1'); |
|
|
| |
| expect(renderedItemValues).toContain('Item 8'); |
| expect(renderedItemValues).toContain('Item 9'); |
| expect(renderedItemValues).toContain('Item 10'); |
|
|
| unmount(); |
| }); |
|
|
| it('should handle maxItemsToShow larger than the list length', async () => { |
| const { lastFrame, unmount } = await renderComponent( |
| { items: longList, maxItemsToShow: 15 }, |
| 0, |
| ); |
| const output = lastFrame(); |
|
|
| |
| expect(output).toContain('Item 1'); |
| expect(output).toContain('Item 10'); |
| expect(mockRenderItem).toHaveBeenCalledTimes(10); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Mouse Interaction', () => { |
| it('should register mouse click handler for each item', async () => { |
| const { unmount } = await renderComponent(); |
|
|
| |
| expect(useMouseClick).toHaveBeenCalledTimes(3); |
| unmount(); |
| }); |
|
|
| it('should update activeIndex on first click and call onSelect on second click', async () => { |
| const { unmount, waitUntilReady } = await renderComponent(); |
| await waitUntilReady(); |
|
|
| |
| |
| |
|
|
| |
| const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1]; |
|
|
| |
| act(() => { |
| mouseClickHandler(); |
| }); |
|
|
| expect(mockSetActiveIndex).toHaveBeenCalledWith(2); |
| expect(mockOnSelect).not.toHaveBeenCalled(); |
|
|
| |
| |
| await renderComponent({}, 2); |
|
|
| |
| |
| const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1]; |
|
|
| |
| act(() => { |
| updatedMouseClickHandler(); |
| }); |
|
|
| expect(mockOnSelect).toHaveBeenCalledWith('C'); |
| unmount(); |
| }); |
|
|
| it('should not call onSelect when a disabled item is clicked', async () => { |
| const { unmount, waitUntilReady } = await renderComponent(); |
| await waitUntilReady(); |
|
|
| |
| const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1]; |
|
|
| act(() => { |
| mouseClickHandler(); |
| }); |
|
|
| expect(mockSetActiveIndex).not.toHaveBeenCalled(); |
| expect(mockOnSelect).not.toHaveBeenCalled(); |
| unmount(); |
| }); |
|
|
| it('should pass isActive: isFocused to useMouseClick', async () => { |
| const { unmount } = await renderComponent({ isFocused: false }); |
|
|
| expect(useMouseClick).toHaveBeenCalledWith( |
| expect.any(Object), |
| expect.any(Function), |
| { isActive: false }, |
| ); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Scroll Arrows (showScrollArrows)', () => { |
| const longList = Array.from({ length: 10 }, (_, i) => ({ |
| value: `Item ${i + 1}`, |
| label: `Item ${i + 1}`, |
| key: `Item ${i + 1}`, |
| })); |
| const MAX_ITEMS = 3; |
|
|
| it('should not show arrows by default', async () => { |
| const { lastFrame, unmount } = await renderComponent({ |
| items: longList, |
| maxItemsToShow: MAX_ITEMS, |
| }); |
| const output = lastFrame(); |
|
|
| expect(output).not.toContain('▲'); |
| expect(output).not.toContain('▼'); |
| unmount(); |
| }); |
|
|
| it('should show arrows with correct colors when enabled (at the top)', async () => { |
| const { lastFrame, unmount } = await renderComponent( |
| { |
| items: longList, |
| maxItemsToShow: MAX_ITEMS, |
| showScrollArrows: true, |
| }, |
| 0, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| unmount(); |
| }); |
|
|
| it('should show arrows and correct items when scrolled to the middle', async () => { |
| const { lastFrame, unmount } = await renderComponent( |
| { items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true }, |
| 5, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| unmount(); |
| }); |
|
|
| it('should show arrows and correct items when scrolled to the end', async () => { |
| const { lastFrame, unmount } = await renderComponent( |
| { items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true }, |
| 9, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| unmount(); |
| }); |
|
|
| it('should not show arrows when list fits entirely', async () => { |
| const { lastFrame, unmount } = await renderComponent({ |
| items, |
| maxItemsToShow: 5, |
| showScrollArrows: true, |
| }); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| unmount(); |
| }); |
| }); |
| }); |
|
|