| |
| |
| |
| |
| |
|
|
| import { renderWithProviders } from '../../../test-utils/render.js'; |
| import { Scrollable } from './Scrollable.js'; |
| import { Text, Box } from 'ink'; |
| import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| import * as ScrollProviderModule from '../../contexts/ScrollProvider.js'; |
| import { act } from 'react'; |
| import { waitFor } from '../../../test-utils/async.js'; |
|
|
| vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({ |
| useAnimatedScrollbar: ( |
| hasFocus: boolean, |
| scrollBy: (delta: number) => void, |
| ) => ({ |
| scrollbarColor: 'white', |
| flashScrollbar: vi.fn(), |
| scrollByWithAnimation: scrollBy, |
| }), |
| })); |
|
|
| describe('<Scrollable />', () => { |
| beforeEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| it('renders children', async () => { |
| const { lastFrame, unmount } = await renderWithProviders( |
| <Scrollable hasFocus={false} height={5}> |
| <Text>Hello World</Text> |
| </Scrollable>, |
| ); |
| expect(lastFrame()).toContain('Hello World'); |
| unmount(); |
| }); |
|
|
| it('renders multiple children', async () => { |
| const { lastFrame, unmount } = await renderWithProviders( |
| <Scrollable hasFocus={false} height={5}> |
| <Text>Line 1</Text> |
| <Text>Line 2</Text> |
| <Text>Line 3</Text> |
| </Scrollable>, |
| ); |
| expect(lastFrame()).toContain('Line 1'); |
| expect(lastFrame()).toContain('Line 2'); |
| expect(lastFrame()).toContain('Line 3'); |
| unmount(); |
| }); |
|
|
| it('matches snapshot', async () => { |
| const { lastFrame, unmount } = await renderWithProviders( |
| <Scrollable hasFocus={false} height={5}> |
| <Text>Line 1</Text> |
| <Text>Line 2</Text> |
| <Text>Line 3</Text> |
| </Scrollable>, |
| ); |
| expect(lastFrame()).toMatchSnapshot(); |
| unmount(); |
| }); |
|
|
| it('updates scroll position correctly when scrollBy is called multiple times in the same tick', async () => { |
| let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined; |
| vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation( |
| async (entry, isActive) => { |
| if (isActive) { |
| capturedEntry = entry as ScrollProviderModule.ScrollableEntry; |
| } |
| }, |
| ); |
|
|
| const { unmount } = await renderWithProviders( |
| <Scrollable hasFocus={true} height={5}> |
| <Text>Line 1</Text> |
| <Text>Line 2</Text> |
| <Text>Line 3</Text> |
| <Text>Line 4</Text> |
| <Text>Line 5</Text> |
| <Text>Line 6</Text> |
| <Text>Line 7</Text> |
| <Text>Line 8</Text> |
| <Text>Line 9</Text> |
| <Text>Line 10</Text> |
| </Scrollable>, |
| ); |
|
|
| expect(capturedEntry).toBeDefined(); |
|
|
| if (!capturedEntry) { |
| throw new Error('capturedEntry is undefined'); |
| } |
|
|
| |
| expect(capturedEntry.getScrollState().scrollTop).toBe(0); |
|
|
| |
| unmount(); |
| const { unmount: unmount2 } = await renderWithProviders( |
| <Scrollable hasFocus={true} height={5} scrollToBottom={true}> |
| <Text>Line 1</Text> |
| <Text>Line 2</Text> |
| <Text>Line 3</Text> |
| <Text>Line 4</Text> |
| <Text>Line 5</Text> |
| <Text>Line 6</Text> |
| <Text>Line 7</Text> |
| <Text>Line 8</Text> |
| <Text>Line 9</Text> |
| <Text>Line 10</Text> |
| </Scrollable>, |
| ); |
| await waitFor(() => { |
| expect(capturedEntry?.getScrollState().scrollTop).toBe(5); |
| }); |
|
|
| |
| await act(async () => { |
| capturedEntry?.scrollBy(-1); |
| capturedEntry?.scrollBy(-1); |
| }); |
| |
| await waitFor(() => { |
| expect(capturedEntry?.getScrollState().scrollTop).toBe(3); |
| }); |
|
|
| await act(async () => { |
| capturedEntry?.scrollBy(-2); |
| }); |
| await waitFor(() => { |
| expect(capturedEntry?.getScrollState().scrollTop).toBe(1); |
| }); |
| unmount2(); |
| }); |
|
|
| describe('keypress handling', () => { |
| it.each([ |
| { |
| name: 'scrolls down when overflow exists and not at bottom', |
| initialScrollTop: 0, |
| scrollHeight: 10, |
| keySequence: '\u001B[1;2B', |
| expectedScrollTop: 1, |
| }, |
| { |
| name: 'scrolls up when overflow exists and not at top', |
| initialScrollTop: 2, |
| scrollHeight: 10, |
| keySequence: '\u001B[1;2A', |
| expectedScrollTop: 1, |
| }, |
| { |
| name: 'does not scroll up when at top (allows event to bubble)', |
| initialScrollTop: 0, |
| scrollHeight: 10, |
| keySequence: '\u001B[1;2A', |
| expectedScrollTop: 0, |
| }, |
| { |
| name: 'does not scroll down when at bottom (allows event to bubble)', |
| initialScrollTop: 5, |
| scrollHeight: 10, |
| keySequence: '\u001B[1;2B', |
| expectedScrollTop: 5, |
| }, |
| { |
| name: 'does not scroll when content fits (allows event to bubble)', |
| initialScrollTop: 0, |
| scrollHeight: 5, |
| keySequence: '\u001B[1;2B', |
| expectedScrollTop: 0, |
| }, |
| ])( |
| '$name', |
| async ({ |
| initialScrollTop, |
| scrollHeight, |
| keySequence, |
| expectedScrollTop, |
| }) => { |
| let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined; |
| vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation( |
| async (entry, isActive) => { |
| if (isActive) { |
| capturedEntry = entry as ScrollProviderModule.ScrollableEntry; |
| } |
| }, |
| ); |
|
|
| const { stdin, unmount, waitUntilReady } = await renderWithProviders( |
| <Scrollable hasFocus={true} height={5}> |
| <Box height={scrollHeight}> |
| <Text>Content</Text> |
| </Box> |
| </Scrollable>, |
| ); |
|
|
| |
| await act(async () => { |
| |
| capturedEntry!.scrollBy(-100); |
| if (initialScrollTop > 0) { |
| capturedEntry!.scrollBy(initialScrollTop); |
| } |
| }); |
| expect(capturedEntry!.getScrollState().scrollTop).toBe( |
| initialScrollTop, |
| ); |
|
|
| await act(async () => { |
| stdin.write(keySequence); |
| }); |
| await waitUntilReady(); |
|
|
| expect(capturedEntry!.getScrollState().scrollTop).toBe( |
| expectedScrollTop, |
| ); |
| unmount(); |
| }, |
| ); |
| }); |
| }); |
|
|