| |
| |
| |
| |
| |
|
|
| import { render } from '../../../test-utils/render.js'; |
| import { waitFor } from '../../../test-utils/async.js'; |
| import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js'; |
| import { Text, Box } from 'ink'; |
| import { |
| createRef, |
| act, |
| useEffect, |
| createContext, |
| useContext, |
| useState, |
| } from 'react'; |
| import { describe, it, expect, vi, beforeEach } from 'vitest'; |
|
|
| describe('<VirtualizedList />', () => { |
| const keyExtractor = (item: string) => item; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| }); |
|
|
| describe('with 10px height and 100 items', () => { |
| const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); |
| |
| |
| const itemHeight = 1; |
| const renderItem1px = ({ item }: { item: string }) => ( |
| <Box height={itemHeight}> |
| <Text>{item}</Text> |
| </Box> |
| ); |
|
|
| it.each([ |
| { |
| name: 'top', |
| initialScrollIndex: undefined, |
| visible: ['Item 0', 'Item 7'], |
| notVisible: ['Item 8', 'Item 15', 'Item 50', 'Item 99'], |
| }, |
| { |
| name: 'scrolled to bottom', |
| initialScrollIndex: 99, |
| visible: ['Item 99', 'Item 92'], |
| notVisible: ['Item 91', 'Item 85', 'Item 50', 'Item 0'], |
| }, |
| ])( |
| 'renders only visible items ($name)', |
| async ({ initialScrollIndex, visible, notVisible }) => { |
| const { lastFrame, unmount } = await render( |
| <Box height={10} width={100} borderStyle="round"> |
| <VirtualizedList |
| data={longData} |
| renderItem={renderItem1px} |
| keyExtractor={keyExtractor} |
| estimatedItemHeight={() => itemHeight} |
| initialScrollIndex={initialScrollIndex} |
| /> |
| </Box>, |
| ); |
|
|
| const output = lastFrame(); |
| visible.forEach((item) => { |
| expect(output).toContain(item); |
| }); |
| notVisible.forEach((item) => { |
| expect(output).not.toContain(item); |
| }); |
| expect(output).toMatchSnapshot(); |
| unmount(); |
| }, |
| ); |
|
|
| it('sticks to bottom when new items added', async () => { |
| const { lastFrame, rerender, waitUntilReady, unmount } = await render( |
| <Box height={10} width={100} borderStyle="round"> |
| <VirtualizedList |
| data={longData} |
| renderItem={renderItem1px} |
| keyExtractor={keyExtractor} |
| estimatedItemHeight={() => itemHeight} |
| initialScrollIndex={99} |
| /> |
| </Box>, |
| ); |
|
|
| expect(lastFrame()).toContain('Item 99'); |
|
|
| |
| const newData = [...longData, 'Item 100', 'Item 101']; |
| await act(async () => { |
| rerender( |
| <Box height={10} width={100} borderStyle="round"> |
| <VirtualizedList |
| data={newData} |
| renderItem={renderItem1px} |
| keyExtractor={keyExtractor} |
| estimatedItemHeight={() => itemHeight} |
| // We don't need to pass initialScrollIndex again for it to stick, |
| // but passing it doesn't hurt. The component should auto-stick because it was at bottom. |
| /> |
| </Box>, |
| ); |
| }); |
| await waitUntilReady(); |
|
|
| const frame = lastFrame(); |
| expect(frame).toContain('Item 101'); |
| expect(frame).not.toContain('Item 0'); |
| unmount(); |
| }); |
|
|
| it('scrolls down to show new items when requested via ref', async () => { |
| const ref = createRef<VirtualizedListRef<string>>(); |
| const { lastFrame, waitUntilReady, unmount } = await render( |
| <Box height={10} width={100} borderStyle="round"> |
| <VirtualizedList |
| ref={ref} |
| data={longData} |
| renderItem={renderItem1px} |
| keyExtractor={keyExtractor} |
| estimatedItemHeight={() => itemHeight} |
| /> |
| </Box>, |
| ); |
|
|
| expect(lastFrame()).toContain('Item 0'); |
|
|
| |
| await act(async () => { |
| ref.current?.scrollToEnd(); |
| }); |
| await waitUntilReady(); |
|
|
| const frame = lastFrame(); |
| expect(frame).toContain('Item 99'); |
| unmount(); |
| }); |
|
|
| it.each([ |
| { initialScrollIndex: 0, expectedMountedCount: 5 }, |
| { initialScrollIndex: 500, expectedMountedCount: 6 }, |
| { initialScrollIndex: 999, expectedMountedCount: 5 }, |
| ])( |
| 'mounts only visible items with 1000 items and 10px height (scroll: $initialScrollIndex)', |
| async ({ initialScrollIndex, expectedMountedCount }) => { |
| let mountedCount = 0; |
| const tallItemHeight = 5; |
| const ItemWithEffect = ({ item }: { item: string }) => { |
| useEffect(() => { |
| mountedCount++; |
| return () => { |
| mountedCount--; |
| }; |
| }, []); |
| return ( |
| <Box height={tallItemHeight}> |
| <Text>{item}</Text> |
| </Box> |
| ); |
| }; |
|
|
| const veryLongData = Array.from( |
| { length: 1000 }, |
| (_, i) => `Item ${i}`, |
| ); |
|
|
| const { lastFrame, unmount } = await render( |
| <Box height={20} width={100} borderStyle="round"> |
| <VirtualizedList |
| data={veryLongData} |
| renderItem={({ item }) => ( |
| <ItemWithEffect key={item} item={item} /> |
| )} |
| keyExtractor={keyExtractor} |
| estimatedItemHeight={() => tallItemHeight} |
| initialScrollIndex={initialScrollIndex} |
| /> |
| </Box>, |
| ); |
|
|
| const frame = lastFrame(); |
| expect(mountedCount).toBe(expectedMountedCount); |
| expect(frame).toMatchSnapshot(); |
| unmount(); |
| }, |
| ); |
| }); |
|
|
| it('renders more items when a visible item shrinks via context update', async () => { |
| const SizeContext = createContext<{ |
| firstItemHeight: number; |
| setFirstItemHeight: (h: number) => void; |
| }>({ |
| firstItemHeight: 10, |
| setFirstItemHeight: () => {}, |
| }); |
|
|
| const items = Array.from({ length: 20 }, (_, i) => ({ |
| id: `Item ${i}`, |
| })); |
|
|
| const ItemWithContext = ({ |
| item, |
| index, |
| }: { |
| item: { id: string }; |
| index: number; |
| }) => { |
| const { firstItemHeight } = useContext(SizeContext); |
| const height = index === 0 ? firstItemHeight : 1; |
| return ( |
| <Box height={height}> |
| <Text>{item.id}</Text> |
| </Box> |
| ); |
| }; |
|
|
| const TestComponent = () => { |
| const [firstItemHeight, setFirstItemHeight] = useState(10); |
| return ( |
| <SizeContext.Provider value={{ firstItemHeight, setFirstItemHeight }}> |
| <Box height={10} width={100}> |
| <VirtualizedList |
| data={items} |
| renderItem={({ item, index }) => ( |
| <ItemWithContext item={item} index={index} /> |
| )} |
| keyExtractor={(item) => item.id} |
| estimatedItemHeight={() => 1} |
| /> |
| </Box> |
| {/* Expose setter for testing */} |
| <TestControl setFirstItemHeight={setFirstItemHeight} /> |
| </SizeContext.Provider> |
| ); |
| }; |
|
|
| let setHeightFn: (h: number) => void = () => {}; |
| const TestControl = ({ |
| setFirstItemHeight, |
| }: { |
| setFirstItemHeight: (h: number) => void; |
| }) => { |
| setHeightFn = setFirstItemHeight; |
| return null; |
| }; |
|
|
| const { lastFrame, unmount, waitUntilReady } = await render( |
| <TestComponent />, |
| ); |
|
|
| |
| expect(lastFrame()).toContain('Item 0'); |
| expect(lastFrame()).not.toContain('Item 1'); |
|
|
| |
| await act(async () => { |
| setHeightFn(1); |
| }); |
| await waitUntilReady(); |
|
|
| |
| await waitFor(() => { |
| expect(lastFrame()).toContain('Item 0'); |
| expect(lastFrame()).toContain('Item 1'); |
| expect(lastFrame()).toContain('Item 9'); |
| }); |
| unmount(); |
| }); |
|
|
| it('updates scroll position correctly when scrollBy is called multiple times in the same tick', async () => { |
| const ref = createRef<VirtualizedListRef<string>>(); |
| const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); |
| const itemHeight = 1; |
| const renderItem1px = ({ item }: { item: string }) => ( |
| <Box height={itemHeight}> |
| <Text>{item}</Text> |
| </Box> |
| ); |
| const keyExtractor = (item: string) => item; |
|
|
| const { unmount, waitUntilReady } = await render( |
| <Box height={10} width={100} borderStyle="round"> |
| <VirtualizedList |
| ref={ref} |
| data={longData} |
| renderItem={renderItem1px} |
| keyExtractor={keyExtractor} |
| estimatedItemHeight={() => itemHeight} |
| /> |
| </Box>, |
| ); |
|
|
| expect(ref.current?.getScrollState().scrollTop).toBe(0); |
|
|
| await act(async () => { |
| ref.current?.scrollBy(1); |
| ref.current?.scrollBy(1); |
| }); |
| await waitUntilReady(); |
|
|
| expect(ref.current?.getScrollState().scrollTop).toBe(2); |
|
|
| await act(async () => { |
| ref.current?.scrollBy(2); |
| }); |
| await waitUntilReady(); |
|
|
| expect(ref.current?.getScrollState().scrollTop).toBe(4); |
| unmount(); |
| }); |
|
|
| it('renders correctly in copyModeEnabled when scrolled', async () => { |
| const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); |
| |
| const { lastFrame, unmount } = await render( |
| <Box height={10} width={100}> |
| <VirtualizedList |
| data={longData} |
| renderItem={({ item }) => ( |
| <Box height={1}> |
| <Text>{item}</Text> |
| </Box> |
| )} |
| keyExtractor={(item) => item} |
| estimatedItemHeight={() => 1} |
| initialScrollIndex={50} |
| copyModeEnabled={true} |
| /> |
| </Box>, |
| ); |
|
|
| |
| expect(lastFrame()).toContain('Item 50'); |
| |
| expect(lastFrame()).toContain('Item 59'); |
| |
| expect(lastFrame()).not.toContain('Item 0'); |
| unmount(); |
| }); |
| }); |
|
|