mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-06 19:31:15 -07:00
Migrate core render util to use xterm.js as part of the rendering loop. (#19044)
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import {
|
||||
BaseSelectionList,
|
||||
type BaseSelectionListProps,
|
||||
@@ -42,7 +41,7 @@ describe('BaseSelectionList', () => {
|
||||
];
|
||||
|
||||
// Helper to render the component with default props
|
||||
const renderComponent = (
|
||||
const renderComponent = async (
|
||||
props: Partial<
|
||||
BaseSelectionListProps<
|
||||
string,
|
||||
@@ -74,7 +73,9 @@ describe('BaseSelectionList', () => {
|
||||
...props,
|
||||
};
|
||||
|
||||
return renderWithProviders(<BaseSelectionList {...defaultProps} />);
|
||||
const result = renderWithProviders(<BaseSelectionList {...defaultProps} />);
|
||||
await result.waitUntilReady();
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -82,8 +83,8 @@ describe('BaseSelectionList', () => {
|
||||
});
|
||||
|
||||
describe('Rendering and Structure', () => {
|
||||
it('should render all items using the renderItem prop', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
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');
|
||||
@@ -91,32 +92,39 @@ describe('BaseSelectionList', () => {
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledTimes(3);
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the selection indicator (● or space) and layout', () => {
|
||||
const { lastFrame } = renderComponent({}, 0);
|
||||
it('should render the selection indicator (● or space) and layout', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent({}, 0);
|
||||
const output = lastFrame();
|
||||
|
||||
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
|
||||
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', () => {
|
||||
const { lastFrame } = renderComponent({ items: [] });
|
||||
it('should handle an empty list gracefully', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent({ items: [] });
|
||||
expect(mockRenderItem).not.toHaveBeenCalled();
|
||||
expect(lastFrame()).toBe('');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSelectionList Integration', () => {
|
||||
it('should pass props correctly to useSelectionList', () => {
|
||||
it('should pass props correctly to useSelectionList', async () => {
|
||||
const initialIndex = 1;
|
||||
const isFocused = false;
|
||||
const showNumbers = false;
|
||||
|
||||
renderComponent({ initialIndex, isFocused, showNumbers });
|
||||
const { unmount } = await renderComponent({
|
||||
initialIndex,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
});
|
||||
|
||||
expect(useSelectionList).toHaveBeenCalledWith({
|
||||
items,
|
||||
@@ -127,10 +135,11 @@ describe('BaseSelectionList', () => {
|
||||
showNumbers,
|
||||
wrapAround: true,
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use the activeIndex returned by the hook', () => {
|
||||
renderComponent({}, 2); // Active index is C
|
||||
it('should use the activeIndex returned by the hook', async () => {
|
||||
const { unmount } = await renderComponent({}, 2); // Active index is C
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[0],
|
||||
@@ -140,12 +149,13 @@ describe('BaseSelectionList', () => {
|
||||
items[2],
|
||||
expect.objectContaining({ isSelected: true }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and Colors', () => {
|
||||
it('should apply success color to the selected item', () => {
|
||||
renderComponent({}, 0); // Item A selected
|
||||
it('should apply success color to the selected item', async () => {
|
||||
const { unmount } = await renderComponent({}, 0); // Item A selected
|
||||
|
||||
// Check renderItem context colors against the mocked theme
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
@@ -156,10 +166,11 @@ describe('BaseSelectionList', () => {
|
||||
isSelected: true,
|
||||
}),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should apply primary color to unselected, enabled items', () => {
|
||||
renderComponent({}, 0); // Item A selected, Item C unselected/enabled
|
||||
it('should apply primary color to unselected, enabled items', async () => {
|
||||
const { unmount } = await renderComponent({}, 0); // Item A selected, Item C unselected/enabled
|
||||
|
||||
// Check renderItem context colors for Item C
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
@@ -170,10 +181,11 @@ describe('BaseSelectionList', () => {
|
||||
isSelected: false,
|
||||
}),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should apply secondary color to disabled items (when not selected)', () => {
|
||||
renderComponent({}, 0); // Item A selected, Item B disabled
|
||||
it('should apply secondary color to disabled items (when not selected)', async () => {
|
||||
const { unmount } = await renderComponent({}, 0); // Item A selected, Item B disabled
|
||||
|
||||
// Check renderItem context colors for Item B
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
@@ -184,11 +196,12 @@ describe('BaseSelectionList', () => {
|
||||
isSelected: false,
|
||||
}),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should apply success color to disabled items if they are selected', () => {
|
||||
it('should apply success color to disabled items if they are selected', async () => {
|
||||
// The component should visually reflect the selection even if the item is disabled.
|
||||
renderComponent({}, 1); // Item B (disabled) selected
|
||||
const { unmount } = await renderComponent({}, 1); // Item B (disabled) selected
|
||||
|
||||
// Check renderItem context colors for Item B
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
@@ -199,29 +212,34 @@ describe('BaseSelectionList', () => {
|
||||
isSelected: true,
|
||||
}),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Numbering (showNumbers)', () => {
|
||||
it('should show numbers by default with correct formatting', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
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', () => {
|
||||
const { lastFrame } = renderComponent({ showNumbers: false });
|
||||
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', () => {
|
||||
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}`,
|
||||
@@ -229,7 +247,7 @@ describe('BaseSelectionList', () => {
|
||||
}));
|
||||
|
||||
// We must increase maxItemsToShow (default 10) to see the 10th item and beyond
|
||||
const { lastFrame } = renderComponent({
|
||||
const { lastFrame, unmount } = await renderComponent({
|
||||
items: longList,
|
||||
maxItemsToShow: 15,
|
||||
});
|
||||
@@ -239,10 +257,11 @@ describe('BaseSelectionList', () => {
|
||||
// The implementation uses padStart, resulting in " 1." and "10.".
|
||||
expect(output).toContain(' 1.');
|
||||
expect(output).toContain('10.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should apply secondary color to numbers if showNumbers is false (internal logic check)', () => {
|
||||
renderComponent({ showNumbers: false }, 0);
|
||||
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],
|
||||
@@ -252,6 +271,7 @@ describe('BaseSelectionList', () => {
|
||||
numberColor: mockTheme.text.secondary,
|
||||
}),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,7 +283,7 @@ describe('BaseSelectionList', () => {
|
||||
}));
|
||||
const MAX_ITEMS = 3;
|
||||
|
||||
const renderScrollableList = (initialActiveIndex: number = 0) => {
|
||||
const renderScrollableList = async (initialActiveIndex: number = 0) => {
|
||||
// Define the props used for the initial render and subsequent rerenders
|
||||
const componentProps: BaseSelectionListProps<
|
||||
string,
|
||||
@@ -287,9 +307,9 @@ describe('BaseSelectionList', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { rerender, lastFrame } = renderWithProviders(
|
||||
<BaseSelectionList {...componentProps} />,
|
||||
);
|
||||
const { rerender, lastFrame, waitUntilReady, unmount } =
|
||||
renderWithProviders(<BaseSelectionList {...componentProps} />);
|
||||
await waitUntilReady();
|
||||
|
||||
// Function to simulate the activeIndex changing over time
|
||||
const updateActiveIndex = async (newIndex: number) => {
|
||||
@@ -299,80 +319,76 @@ describe('BaseSelectionList', () => {
|
||||
});
|
||||
|
||||
rerender(<BaseSelectionList {...componentProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toBeTruthy();
|
||||
});
|
||||
await waitUntilReady();
|
||||
};
|
||||
|
||||
return { updateActiveIndex, lastFrame };
|
||||
return { updateActiveIndex, lastFrame, unmount };
|
||||
};
|
||||
|
||||
it('should only show maxItemsToShow items initially', () => {
|
||||
const { lastFrame } = renderScrollableList(0);
|
||||
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 } = renderScrollableList(0);
|
||||
const { updateActiveIndex, lastFrame, unmount } =
|
||||
await renderScrollableList(0);
|
||||
|
||||
// Move to index 3 (Item 4). Should trigger scroll.
|
||||
// New visible window should be Items 2, 3, 4 (scroll offset 1).
|
||||
await updateActiveIndex(3);
|
||||
|
||||
await waitFor(() => {
|
||||
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');
|
||||
});
|
||||
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 } = renderScrollableList(0);
|
||||
const { updateActiveIndex, lastFrame, unmount } =
|
||||
await renderScrollableList(0);
|
||||
|
||||
await updateActiveIndex(4);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Item 3'); // Should see items 3, 4, 5
|
||||
expect(output).toContain('Item 5');
|
||||
expect(output).not.toContain('Item 2');
|
||||
});
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('Item 3'); // Should see items 3, 4, 5
|
||||
expect(output).toContain('Item 5');
|
||||
expect(output).not.toContain('Item 2');
|
||||
|
||||
// Now test scrolling up: move to index 1 (Item 2)
|
||||
// This should trigger scroll up to show items 2, 3, 4
|
||||
await updateActiveIndex(1);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Item 2');
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
|
||||
});
|
||||
output = lastFrame();
|
||||
expect(output).toContain('Item 2');
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should pin the scroll offset to the end if selection starts near the end', async () => {
|
||||
// List length 10. Max items 3. Active index 9 (last item).
|
||||
// Scroll offset should be 10 - 3 = 7.
|
||||
// Visible items: 8, 9, 10.
|
||||
const { lastFrame } = renderScrollableList(9);
|
||||
const { lastFrame, unmount } = await renderScrollableList(9);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Item 10');
|
||||
expect(output).toContain('Item 8');
|
||||
expect(output).not.toContain('Item 7');
|
||||
});
|
||||
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 } = renderScrollableList(0);
|
||||
const { updateActiveIndex, lastFrame, unmount } =
|
||||
await renderScrollableList(0);
|
||||
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
expect(lastFrame()).toContain('Item 3');
|
||||
@@ -382,23 +398,21 @@ describe('BaseSelectionList', () => {
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
await updateActiveIndex(3); // Should trigger scroll
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Item 2');
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).not.toContain('Item 1');
|
||||
});
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('Item 2');
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).not.toContain('Item 1');
|
||||
|
||||
await updateActiveIndex(5); // Scroll further
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).toContain('Item 6');
|
||||
expect(output).not.toContain('Item 3');
|
||||
});
|
||||
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', () => {
|
||||
renderScrollableList(1); // activeIndex 1 = Item 2
|
||||
it('should correctly identify the selected item within the visible window', async () => {
|
||||
const { unmount } = await renderScrollableList(1); // activeIndex 1 = Item 2
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledTimes(MAX_ITEMS);
|
||||
|
||||
@@ -411,28 +425,28 @@ describe('BaseSelectionList', () => {
|
||||
expect.objectContaining({ value: 'Item 2' }),
|
||||
expect.objectContaining({ isSelected: true }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should correctly identify the selected item when scrolled (high index)', async () => {
|
||||
renderScrollableList(5);
|
||||
const { unmount } = await renderScrollableList(5);
|
||||
|
||||
await waitFor(() => {
|
||||
// Item 6 (index 5) should be selected
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 6' }),
|
||||
expect.objectContaining({ isSelected: true }),
|
||||
);
|
||||
// Item 6 (index 5) should be selected
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 6' }),
|
||||
expect.objectContaining({ isSelected: true }),
|
||||
);
|
||||
|
||||
// Item 4 (index 3) should not be selected
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 4' }),
|
||||
expect.objectContaining({ isSelected: false }),
|
||||
);
|
||||
});
|
||||
// Item 4 (index 3) should not be selected
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 4' }),
|
||||
expect.objectContaining({ isSelected: false }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle maxItemsToShow larger than the list length', () => {
|
||||
const { lastFrame } = renderComponent(
|
||||
it('should handle maxItemsToShow larger than the list length', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent(
|
||||
{ items: longList, maxItemsToShow: 15 },
|
||||
0,
|
||||
);
|
||||
@@ -442,6 +456,7 @@ describe('BaseSelectionList', () => {
|
||||
expect(output).toContain('Item 1');
|
||||
expect(output).toContain('Item 10');
|
||||
expect(mockRenderItem).toHaveBeenCalledTimes(10);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -453,8 +468,8 @@ describe('BaseSelectionList', () => {
|
||||
}));
|
||||
const MAX_ITEMS = 3;
|
||||
|
||||
it('should not show arrows by default', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
it('should not show arrows by default', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent({
|
||||
items: longList,
|
||||
maxItemsToShow: MAX_ITEMS,
|
||||
});
|
||||
@@ -462,10 +477,11 @@ describe('BaseSelectionList', () => {
|
||||
|
||||
expect(output).not.toContain('▲');
|
||||
expect(output).not.toContain('▼');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show arrows with correct colors when enabled (at the top)', async () => {
|
||||
const { lastFrame } = renderComponent(
|
||||
const { lastFrame, unmount } = await renderComponent(
|
||||
{
|
||||
items: longList,
|
||||
maxItemsToShow: MAX_ITEMS,
|
||||
@@ -474,41 +490,39 @@ describe('BaseSelectionList', () => {
|
||||
0,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show arrows and correct items when scrolled to the middle', async () => {
|
||||
const { lastFrame } = renderComponent(
|
||||
const { lastFrame, unmount } = await renderComponent(
|
||||
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
|
||||
5,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show arrows and correct items when scrolled to the end', async () => {
|
||||
const { lastFrame } = renderComponent(
|
||||
const { lastFrame, unmount } = await renderComponent(
|
||||
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
|
||||
9,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not show arrows when list fits entirely', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
it('should not show arrows when list fits entirely', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
showScrollArrows: true,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('BaseSettingsDialog', () => {
|
||||
mockOnScopeChange = vi.fn();
|
||||
});
|
||||
|
||||
const renderDialog = (props: Partial<BaseSettingsDialogProps> = {}) => {
|
||||
const renderDialog = async (props: Partial<BaseSettingsDialogProps> = {}) => {
|
||||
const defaultProps: BaseSettingsDialogProps = {
|
||||
title: 'Test Settings',
|
||||
items: createMockItems(),
|
||||
@@ -115,80 +115,94 @@ describe('BaseSettingsDialog', () => {
|
||||
...props,
|
||||
};
|
||||
|
||||
return render(
|
||||
const result = render(
|
||||
<KeypressProvider>
|
||||
<BaseSettingsDialog {...defaultProps} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
await result.waitUntilReady();
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the dialog with title', () => {
|
||||
const { lastFrame } = renderDialog();
|
||||
it('should render the dialog with title', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog();
|
||||
expect(lastFrame()).toContain('Test Settings');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render all items', () => {
|
||||
const { lastFrame } = renderDialog();
|
||||
it('should render all items', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog();
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('Boolean Setting');
|
||||
expect(frame).toContain('String Setting');
|
||||
expect(frame).toContain('Number Setting');
|
||||
expect(frame).toContain('Enum Setting');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render help text with Ctrl+L for reset', () => {
|
||||
const { lastFrame } = renderDialog();
|
||||
it('should render help text with Ctrl+L for reset', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog();
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('Use Enter to select');
|
||||
expect(frame).toContain('Ctrl+L to reset');
|
||||
expect(frame).toContain('Tab to change focus');
|
||||
expect(frame).toContain('Esc to close');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render scope selector when showScopeSelector is true', () => {
|
||||
const { lastFrame } = renderDialog({
|
||||
it('should render scope selector when showScopeSelector is true', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
showScopeSelector: true,
|
||||
onScopeChange: mockOnScopeChange,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Apply To');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render scope selector when showScopeSelector is false', () => {
|
||||
const { lastFrame } = renderDialog({
|
||||
it('should not render scope selector when showScopeSelector is false', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
showScopeSelector: false,
|
||||
});
|
||||
|
||||
expect(lastFrame()).not.toContain('Apply To');
|
||||
expect(lastFrame({ allowEmpty: true })).not.toContain('Apply To');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render footer content when provided', () => {
|
||||
const { lastFrame } = renderDialog({
|
||||
it('should render footer content when provided', async () => {
|
||||
const { lastFrame, unmount } = await renderDialog({
|
||||
footerContent: <Text>Custom Footer</Text>,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Custom Footer');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should close dialog on Escape', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog();
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ESCAPE);
|
||||
});
|
||||
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should navigate down with arrow key', async () => {
|
||||
const { lastFrame, stdin } = renderDialog();
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderDialog();
|
||||
|
||||
// Initially first item is active (indicated by bullet point)
|
||||
const initialFrame = lastFrame();
|
||||
@@ -198,6 +212,7 @@ describe('BaseSettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Navigation should move to next item
|
||||
await waitFor(() => {
|
||||
@@ -205,59 +220,70 @@ describe('BaseSettingsDialog', () => {
|
||||
// The active indicator should now be on a different row
|
||||
expect(frame).toContain('String Setting');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should navigate up with arrow key', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog();
|
||||
|
||||
// Press down then up
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.UP_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should be back at first item
|
||||
await waitFor(() => {
|
||||
// First item should be active again
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should wrap around when navigating past last item', async () => {
|
||||
const items = createMockItems(2); // Only 2 items
|
||||
const { stdin } = renderDialog({ items });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({ items });
|
||||
|
||||
// Press down twice to go past the last item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should wrap to first item - verify no crash
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should wrap around when navigating before first item', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog();
|
||||
|
||||
// Press up at first item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.UP_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should wrap to last item - verify no crash
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should switch focus with Tab when scope selector is shown', async () => {
|
||||
const { lastFrame, stdin } = renderDialog({
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
showScopeSelector: true,
|
||||
onScopeChange: mockOnScopeChange,
|
||||
});
|
||||
@@ -269,46 +295,54 @@ describe('BaseSettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.TAB);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('> Apply To');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling and resizing list (search filtering)', () => {
|
||||
it('should preserve focus on the active item if it remains in the filtered list', async () => {
|
||||
const items = createMockItems(5); // items 0 to 4
|
||||
const { rerender, stdin, lastFrame } = renderDialog({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
});
|
||||
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
|
||||
await renderDialog({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
});
|
||||
|
||||
// Move focus down to item 2 ("Number Setting")
|
||||
// Separate acts needed so React state updates between keypresses
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Rerender with a filtered list where "Number Setting" is now at index 1
|
||||
const filteredItems = [items[0], items[2], items[4]];
|
||||
rerender(
|
||||
<KeypressProvider>
|
||||
<BaseSettingsDialog
|
||||
title="Test Settings"
|
||||
items={filteredItems}
|
||||
selectedScope={SettingScope.User}
|
||||
maxItemsToShow={5}
|
||||
onItemToggle={mockOnItemToggle}
|
||||
onEditCommit={mockOnEditCommit}
|
||||
onItemClear={mockOnItemClear}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<KeypressProvider>
|
||||
<BaseSettingsDialog
|
||||
title="Test Settings"
|
||||
items={filteredItems}
|
||||
selectedScope={SettingScope.User}
|
||||
maxItemsToShow={5}
|
||||
onItemToggle={mockOnItemToggle}
|
||||
onEditCommit={mockOnEditCommit}
|
||||
onItemClear={mockOnItemClear}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Verify the dialog hasn't crashed and the items are displayed
|
||||
await waitFor(() => {
|
||||
@@ -324,43 +358,51 @@ describe('BaseSettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset focus to the top if the active item is filtered out', async () => {
|
||||
const items = createMockItems(5);
|
||||
const { rerender, stdin, lastFrame } = renderDialog({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
});
|
||||
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
|
||||
await renderDialog({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
});
|
||||
|
||||
// Move focus down to item 2 ("Number Setting")
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Rerender with a filtered list that EXCLUDES "Number Setting"
|
||||
const filteredItems = [items[0], items[1]];
|
||||
rerender(
|
||||
<KeypressProvider>
|
||||
<BaseSettingsDialog
|
||||
title="Test Settings"
|
||||
items={filteredItems}
|
||||
selectedScope={SettingScope.User}
|
||||
maxItemsToShow={5}
|
||||
onItemToggle={mockOnItemToggle}
|
||||
onEditCommit={mockOnEditCommit}
|
||||
onItemClear={mockOnItemClear}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<KeypressProvider>
|
||||
<BaseSettingsDialog
|
||||
title="Test Settings"
|
||||
items={filteredItems}
|
||||
selectedScope={SettingScope.User}
|
||||
maxItemsToShow={5}
|
||||
onItemToggle={mockOnItemToggle}
|
||||
onEditCommit={mockOnEditCommit}
|
||||
onItemClear={mockOnItemClear}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
@@ -372,6 +414,7 @@ describe('BaseSettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemToggle).toHaveBeenCalledWith(
|
||||
@@ -379,17 +422,19 @@ describe('BaseSettingsDialog', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('item interactions', () => {
|
||||
it('should call onItemToggle for boolean items on Enter', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog();
|
||||
|
||||
// Press Enter on first item (boolean)
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemToggle).toHaveBeenCalledWith(
|
||||
@@ -397,18 +442,22 @@ describe('BaseSettingsDialog', () => {
|
||||
expect.objectContaining({ type: 'boolean' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onItemToggle for enum items on Enter', async () => {
|
||||
const items = createMockItems(4);
|
||||
// Move enum to first position
|
||||
const enumItem = items.find((i) => i.type === 'enum')!;
|
||||
const { stdin } = renderDialog({ items: [enumItem] });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [enumItem],
|
||||
});
|
||||
|
||||
// Press Enter on enum item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemToggle).toHaveBeenCalledWith(
|
||||
@@ -416,17 +465,21 @@ describe('BaseSettingsDialog', () => {
|
||||
expect.objectContaining({ type: 'enum' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should enter edit mode for string items on Enter', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { lastFrame, stdin } = renderDialog({ items: [stringItem] });
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [stringItem],
|
||||
});
|
||||
|
||||
// Press Enter to start editing
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should show the edit buffer with cursor
|
||||
await waitFor(() => {
|
||||
@@ -434,32 +487,38 @@ describe('BaseSettingsDialog', () => {
|
||||
// In edit mode, the value should be displayed (possibly with cursor)
|
||||
expect(frame).toContain('test-value');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should enter edit mode for number items on Enter', async () => {
|
||||
const items = createMockItems(4);
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { lastFrame, stdin } = renderDialog({ items: [numberItem] });
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [numberItem],
|
||||
});
|
||||
|
||||
// Press Enter to start editing
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should show the edit buffer
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('42');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onItemClear on Ctrl+L', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog();
|
||||
|
||||
// Press Ctrl+L to reset
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.CTRL_L);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemClear).toHaveBeenCalledWith(
|
||||
@@ -467,6 +526,7 @@ describe('BaseSettingsDialog', () => {
|
||||
expect.objectContaining({ type: 'boolean' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,22 +534,27 @@ describe('BaseSettingsDialog', () => {
|
||||
it('should commit edit on Enter', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem] });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [stringItem],
|
||||
});
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Type some characters
|
||||
await act(async () => {
|
||||
stdin.write('x');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Commit with Enter
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
@@ -498,100 +563,127 @@ describe('BaseSettingsDialog', () => {
|
||||
expect.objectContaining({ type: 'string' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should commit edit on Escape', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem] });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [stringItem],
|
||||
});
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Commit with Escape
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ESCAPE);
|
||||
});
|
||||
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should commit edit and navigate on Down arrow', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [stringItem, numberItem],
|
||||
});
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Press Down to commit and navigate
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should commit edit and navigate on Up arrow', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [stringItem, numberItem],
|
||||
});
|
||||
|
||||
// Navigate to second item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Press Up to commit and navigate
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.UP_ARROW);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow number input for number fields', async () => {
|
||||
const items = createMockItems(4);
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [numberItem] });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [numberItem],
|
||||
});
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Type numbers one at a time
|
||||
await act(async () => {
|
||||
stdin.write('1');
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('2');
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('3');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Commit
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
@@ -600,24 +692,29 @@ describe('BaseSettingsDialog', () => {
|
||||
expect.objectContaining({ type: 'number' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should support quick number entry for number fields', async () => {
|
||||
const items = createMockItems(4);
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [numberItem] });
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
items: [numberItem],
|
||||
});
|
||||
|
||||
// Type a number directly (without Enter first)
|
||||
await act(async () => {
|
||||
stdin.write('5');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should start editing with that number
|
||||
await waitFor(() => {
|
||||
await waitFor(async () => {
|
||||
// Commit to verify
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -627,13 +724,14 @@ describe('BaseSettingsDialog', () => {
|
||||
expect.objectContaining({ type: 'number' }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom key handling', () => {
|
||||
it('should call onKeyPress and respect its return value', async () => {
|
||||
const customKeyHandler = vi.fn().mockReturnValue(true);
|
||||
const { stdin } = renderDialog({
|
||||
const { stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
onKeyPress: customKeyHandler,
|
||||
});
|
||||
|
||||
@@ -641,6 +739,7 @@ describe('BaseSettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write('r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(customKeyHandler).toHaveBeenCalled();
|
||||
@@ -648,12 +747,13 @@ describe('BaseSettingsDialog', () => {
|
||||
|
||||
// Since handler returned true, default behavior should be blocked
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus management', () => {
|
||||
it('should keep focus on settings when scope selector is hidden', async () => {
|
||||
const { lastFrame, stdin } = renderDialog({
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
|
||||
showScopeSelector: false,
|
||||
});
|
||||
|
||||
@@ -661,11 +761,13 @@ describe('BaseSettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.TAB);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
// Should still show settings as focused
|
||||
expect(lastFrame()).toContain('> Test Settings');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('DescriptiveRadioButtonSelect', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const renderComponent = (
|
||||
const renderComponent = async (
|
||||
props: Partial<DescriptiveRadioButtonSelectProps<string>> = {},
|
||||
) => {
|
||||
const defaultProps: DescriptiveRadioButtonSelectProps<string> = {
|
||||
@@ -69,22 +69,25 @@ describe('DescriptiveRadioButtonSelect', () => {
|
||||
onSelect: mockOnSelect,
|
||||
...props,
|
||||
};
|
||||
return renderWithProviders(
|
||||
const result = renderWithProviders(
|
||||
<DescriptiveRadioButtonSelect {...defaultProps} />,
|
||||
);
|
||||
await result.waitUntilReady();
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly with default props', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
it('should render correctly with default props', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render correctly with custom props', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
it('should render correctly with custom props', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent({
|
||||
initialIndex: 1,
|
||||
isFocused: false,
|
||||
showScrollArrows: true,
|
||||
@@ -93,5 +96,6 @@ describe('DescriptiveRadioButtonSelect', () => {
|
||||
onHighlight: mockOnHighlight,
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { EnumSelector } from './EnumSelector.js';
|
||||
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { act } from 'react';
|
||||
|
||||
const LANGUAGE_OPTIONS: readonly SettingEnumOption[] = [
|
||||
{ label: 'English', value: 'en' },
|
||||
@@ -23,130 +24,152 @@ const NUMERIC_OPTIONS: readonly SettingEnumOption[] = [
|
||||
];
|
||||
|
||||
describe('<EnumSelector />', () => {
|
||||
it('renders with string options and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders with string options and matches snapshot', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders with numeric options and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders with numeric options and matches snapshot', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={NUMERIC_OPTIONS}
|
||||
currentValue={2}
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders inactive state and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders inactive state and matches snapshot', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="zh"
|
||||
isActive={false}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders with single option and matches snapshot', () => {
|
||||
it('renders with single option and matches snapshot', async () => {
|
||||
const singleOption: readonly SettingEnumOption[] = [
|
||||
{ label: 'Only Option', value: 'only' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={singleOption}
|
||||
currentValue="only"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing when no options are provided', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders nothing when no options are provided', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={[]}
|
||||
currentValue=""
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles currentValue not found in options', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('handles currentValue not found in options', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="invalid"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// Should default to first option
|
||||
expect(lastFrame()).toContain('English');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('updates when currentValue changes externally', () => {
|
||||
const { rerender, lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
it('updates when currentValue changes externally', async () => {
|
||||
const { rerender, lastFrame, waitUntilReady, unmount } =
|
||||
renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('English');
|
||||
|
||||
rerender(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="zh"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="zh"
|
||||
isActive={true}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('中文 (简体)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows navigation arrows when multiple options available', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('shows navigation arrows when multiple options available', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('←');
|
||||
expect(lastFrame()).toContain('→');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides navigation arrows when single option available', () => {
|
||||
it('hides navigation arrows when single option available', async () => {
|
||||
const singleOption: readonly SettingEnumOption[] = [
|
||||
{ label: 'Only Option', value: 'only' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={singleOption}
|
||||
currentValue="only"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
onValueChange={async () => {}}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('←');
|
||||
expect(lastFrame()).not.toContain('→');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,8 @@ describe('ExpandableText', () => {
|
||||
const color = 'white';
|
||||
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
|
||||
|
||||
it('renders plain label when no match (short label)', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
it('renders plain label when no match (short label)', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ExpandableText
|
||||
label="simple command"
|
||||
userInput=""
|
||||
@@ -23,13 +23,14 @@ describe('ExpandableText', () => {
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('truncates long label when collapsed and no match', () => {
|
||||
it('truncates long label when collapsed and no match', async () => {
|
||||
const long = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
@@ -37,6 +38,7 @@ describe('ExpandableText', () => {
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
@@ -45,9 +47,9 @@ describe('ExpandableText', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows full long label when expanded and no match', () => {
|
||||
it('shows full long label when expanded and no match', async () => {
|
||||
const long = 'y'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
@@ -55,6 +57,7 @@ describe('ExpandableText', () => {
|
||||
isExpanded={true}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.length).toBe(long.length);
|
||||
@@ -62,11 +65,11 @@ describe('ExpandableText', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('highlights matched substring when expanded (text only visible)', () => {
|
||||
it('highlights matched substring when expanded (text only visible)', async () => {
|
||||
const label = 'run: git commit -m "feat: add search"';
|
||||
const userInput = 'commit';
|
||||
const matchedIndex = label.indexOf(userInput);
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={userInput}
|
||||
@@ -76,18 +79,19 @@ describe('ExpandableText', () => {
|
||||
/>,
|
||||
100,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain(chalk.inverse(userInput));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('creates centered window around match when collapsed', () => {
|
||||
it('creates centered window around match when collapsed', async () => {
|
||||
const prefix = 'cd_/very/long/path/that/keeps/going/'.repeat(3);
|
||||
const core = 'search-here';
|
||||
const suffix = '/and/then/some/more/components/'.repeat(3);
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
@@ -97,6 +101,7 @@ describe('ExpandableText', () => {
|
||||
/>,
|
||||
100,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.includes(core)).toBe(true);
|
||||
@@ -106,13 +111,13 @@ describe('ExpandableText', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('truncates match itself when match is very long', () => {
|
||||
it('truncates match itself when match is very long', async () => {
|
||||
const prefix = 'find ';
|
||||
const core = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const suffix = ' in this text';
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ExpandableText
|
||||
label={label}
|
||||
userInput={core}
|
||||
@@ -121,6 +126,7 @@ describe('ExpandableText', () => {
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.includes('...')).toBe(true);
|
||||
@@ -131,10 +137,10 @@ describe('ExpandableText', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('respects custom maxWidth', () => {
|
||||
it('respects custom maxWidth', async () => {
|
||||
const customWidth = 50;
|
||||
const long = 'z'.repeat(100);
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ExpandableText
|
||||
label={long}
|
||||
userInput=""
|
||||
@@ -143,6 +149,7 @@ describe('ExpandableText', () => {
|
||||
maxWidth={customWidth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
|
||||
@@ -28,12 +28,13 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
it('renders standard background and blocks when not iTerm2', async () => {
|
||||
vi.mocked(isITerm2).mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
<Text>Content</Text>
|
||||
</HalfLinePaddedBox>,
|
||||
{ width: 10 },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -43,12 +44,13 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
it('renders iTerm2-specific blocks when iTerm2 is detected', async () => {
|
||||
vi.mocked(isITerm2).mockReturnValue(true);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
<Text>Content</Text>
|
||||
</HalfLinePaddedBox>,
|
||||
{ width: 10 },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -56,7 +58,7 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
});
|
||||
|
||||
it('renders nothing when useBackgroundColor is false', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor="blue"
|
||||
backgroundOpacity={0.5}
|
||||
@@ -66,6 +68,7 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
</HalfLinePaddedBox>,
|
||||
{ width: 10 },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -75,12 +78,13 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
it('renders nothing when screen reader is enabled', async () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
<Text>Content</Text>
|
||||
</HalfLinePaddedBox>,
|
||||
{ width: 10 },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { render, renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { MaxSizedBox } from './MaxSizedBox.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
@@ -14,7 +13,7 @@ import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<MaxSizedBox />', () => {
|
||||
it('renders children without truncation when they fit', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<Box>
|
||||
@@ -23,13 +22,14 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() => expect(lastFrame()).toContain('Hello, World!'));
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Hello, World!');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides lines when content exceeds maxHeight', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||
<Box flexDirection="column">
|
||||
@@ -40,15 +40,14 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('... first 2 lines hidden ...'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 2 lines hidden ...');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||
<Box flexDirection="column">
|
||||
@@ -59,15 +58,14 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('... last 2 lines hidden ...'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... last 2 lines hidden ...');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows plural "lines" when more than one line is hidden', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||
<Box flexDirection="column">
|
||||
@@ -78,15 +76,14 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('... first 2 lines hidden ...'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 2 lines hidden ...');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows singular "line" when exactly one line is hidden', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={1}>
|
||||
<Box flexDirection="column">
|
||||
@@ -95,15 +92,14 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('... first 1 line hidden ...'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 1 line hidden ...');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('accounts for additionalHiddenLinesCount', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
|
||||
<Box flexDirection="column">
|
||||
@@ -114,15 +110,14 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('... first 7 lines hidden ...'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 7 lines hidden ...');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('wraps text that exceeds maxWidth', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={10} maxHeight={5}>
|
||||
<Box>
|
||||
@@ -132,13 +127,14 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).toContain('This is a'));
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('This is a');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not truncate when maxHeight is undefined', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
|
||||
<Box flexDirection="column">
|
||||
@@ -148,25 +144,25 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() => expect(lastFrame()).toContain('Line 1'));
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders an empty box for empty children', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// Use waitFor to ensure ResizeObserver has a chance to run
|
||||
await waitFor(() => expect(lastFrame()).toBeDefined());
|
||||
expect(lastFrame()?.trim()).equals('');
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles React.Fragment as a child', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<Box flexDirection="column">
|
||||
@@ -179,7 +175,8 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitFor(() => expect(lastFrame()).toContain('Line 1 from Fragment'));
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Line 1 from Fragment');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -189,7 +186,7 @@ describe('<MaxSizedBox />', () => {
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="top">
|
||||
<Box>
|
||||
@@ -199,9 +196,8 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('... first 21 lines hidden ...'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 21 lines hidden ...');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -211,7 +207,7 @@ describe('<MaxSizedBox />', () => {
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
const { lastFrame, unmount } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
|
||||
<Box>
|
||||
@@ -221,9 +217,8 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('... last 21 lines hidden ...'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... last 21 lines hidden ...');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -233,7 +228,7 @@ describe('<MaxSizedBox />', () => {
|
||||
{ length: 20 },
|
||||
(_, i) => `- Step ${i + 1}: Do something important`,
|
||||
).join('\n');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<MaxSizedBox maxWidth={80} maxHeight={5} overflowDirection="bottom">
|
||||
<MarkdownDisplay
|
||||
text={`## Plan\n\n${markdownContent}`}
|
||||
@@ -244,14 +239,16 @@ describe('<MaxSizedBox />', () => {
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).toContain('... last'));
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... last');
|
||||
|
||||
const frame = lastFrame()!;
|
||||
const lines = frame.split('\n');
|
||||
const frame = lastFrame();
|
||||
const lines = frame.trim().split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// The last line should only contain the hidden indicator, no leaked content
|
||||
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,50 +37,56 @@ describe('<Scrollable />', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders children', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Hello World</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Hello World');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders multiple children', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toContain('Line 2');
|
||||
expect(lastFrame()).toContain('Line 3');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('matches snapshot', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('updates scroll position correctly when scrollBy is called multiple times in the same tick', () => {
|
||||
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(
|
||||
(entry, isActive) => {
|
||||
async (entry, isActive) => {
|
||||
if (isActive) {
|
||||
capturedEntry = entry as ScrollProviderModule.ScrollableEntry;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<Scrollable hasFocus={true} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
@@ -94,6 +100,7 @@ describe('<Scrollable />', () => {
|
||||
<Text>Line 10</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(capturedEntry).toBeDefined();
|
||||
|
||||
@@ -105,17 +112,18 @@ describe('<Scrollable />', () => {
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
|
||||
|
||||
// Call scrollBy multiple times (upwards) in the same tick
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
capturedEntry!.scrollBy(-1);
|
||||
capturedEntry!.scrollBy(-1);
|
||||
});
|
||||
// Should have moved up by 2
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
capturedEntry!.scrollBy(-2);
|
||||
});
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('keypress handling', () => {
|
||||
@@ -169,21 +177,22 @@ describe('<Scrollable />', () => {
|
||||
|
||||
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
|
||||
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
|
||||
(entry, isActive) => {
|
||||
async (entry, isActive) => {
|
||||
if (isActive) {
|
||||
capturedEntry = entry as ScrollProviderModule.ScrollableEntry;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { stdin } = renderWithProviders(
|
||||
const { stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Scrollable hasFocus={true} height={5}>
|
||||
<Text>Content</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Ensure initial state using existing scrollBy method
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
// Reset to top first, then scroll to desired start position
|
||||
capturedEntry!.scrollBy(-100);
|
||||
if (initialScrollTop > 0) {
|
||||
@@ -194,13 +203,15 @@ describe('<Scrollable />', () => {
|
||||
initialScrollTop,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
stdin.write(keySequence);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(capturedEntry!.getScrollState().scrollTop).toBe(
|
||||
expectedScrollTop,
|
||||
);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, act } from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ScrollableList, type ScrollableListRef } from './ScrollableList.js';
|
||||
import { ScrollProvider } from '../../contexts/ScrollProvider.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { MouseProvider } from '../../contexts/MouseContext.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
@@ -133,10 +133,19 @@ const TestComponent = ({
|
||||
);
|
||||
};
|
||||
describe('ScrollableList Demo Behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should scroll to bottom when new items are added and stop when scrolled up', async () => {
|
||||
let addItem: (() => void) | undefined;
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
let lastFrame: () => string | undefined;
|
||||
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
|
||||
let waitUntilReady: () => Promise<void>;
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
|
||||
@@ -146,14 +155,17 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
onAddItem={(add) => {
|
||||
addItem = add;
|
||||
}}
|
||||
onRef={(ref) => {
|
||||
onRef={async (ref) => {
|
||||
listRef = ref;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
lastFrame = result.lastFrame;
|
||||
waitUntilReady = result.waitUntilReady;
|
||||
});
|
||||
|
||||
await waitUntilReady!();
|
||||
|
||||
// Initial render should show Item 1000
|
||||
expect(lastFrame!()).toContain('Item 1000');
|
||||
expect(lastFrame!()).toContain('Count: 1000');
|
||||
@@ -162,6 +174,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
addItem?.();
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('Count: 1001');
|
||||
});
|
||||
@@ -172,6 +186,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
addItem?.();
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('Count: 1002');
|
||||
});
|
||||
@@ -182,14 +198,14 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
listRef?.scrollBy(-5);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
// Add item 1003 - should NOT be visible because we scrolled up
|
||||
await act(async () => {
|
||||
addItem?.();
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('Count: 1003');
|
||||
});
|
||||
@@ -249,12 +265,16 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
};
|
||||
|
||||
let lastFrame: () => string | undefined;
|
||||
let waitUntilReady: () => Promise<void>;
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(<StickyTestComponent />);
|
||||
lastFrame = result.lastFrame;
|
||||
waitUntilReady = result.waitUntilReady;
|
||||
});
|
||||
|
||||
await waitUntilReady!();
|
||||
|
||||
// Initially at top, should see Normal Item 1
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('[Normal] Item 1');
|
||||
@@ -265,6 +285,7 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
listRef?.scrollBy(2);
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
// Now Item 1 should be stuck
|
||||
await waitFor(() => {
|
||||
@@ -278,6 +299,7 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
listRef?.scrollTo(10);
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
|
||||
@@ -287,6 +309,7 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
listRef?.scrollTo(0);
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
// Should be normal again
|
||||
await waitFor(() => {
|
||||
@@ -302,8 +325,9 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should handle scroll keys correctly', async () => {
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
let lastFrame: () => string | undefined;
|
||||
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
|
||||
let stdin: { write: (data: string) => void };
|
||||
let waitUntilReady: () => Promise<void>;
|
||||
|
||||
const items = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: String(i),
|
||||
@@ -334,8 +358,11 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
);
|
||||
lastFrame = result.lastFrame;
|
||||
stdin = result.stdin;
|
||||
waitUntilReady = result.waitUntilReady;
|
||||
});
|
||||
|
||||
await waitUntilReady!();
|
||||
|
||||
// Initial state
|
||||
expect(lastFrame!()).toContain('Item 0');
|
||||
expect(listRef).toBeDefined();
|
||||
@@ -345,6 +372,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[b');
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -353,6 +382,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[a');
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
|
||||
});
|
||||
@@ -361,6 +392,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[6~');
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
// Height is 10, so should scroll ~10 units
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThanOrEqual(9);
|
||||
@@ -370,6 +403,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[5~');
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2);
|
||||
});
|
||||
@@ -378,6 +413,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[1;5F');
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
// Total 50 items, height 10. Max scroll ~40.
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
|
||||
@@ -387,11 +424,15 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[1;5H');
|
||||
});
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Let the scrollbar fade out animation finish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||
result.unmount();
|
||||
});
|
||||
});
|
||||
@@ -400,7 +441,8 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
describe('Width Prop', () => {
|
||||
it('should apply the width prop to the container', async () => {
|
||||
const items = [{ id: '1', title: 'Item 1' }];
|
||||
let lastFrame: () => string | undefined;
|
||||
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
|
||||
let waitUntilReady: () => Promise<void>;
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
@@ -423,8 +465,11 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
</MouseProvider>,
|
||||
);
|
||||
lastFrame = result.lastFrame;
|
||||
waitUntilReady = result.waitUntilReady;
|
||||
});
|
||||
|
||||
await waitUntilReady!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
});
|
||||
|
||||
@@ -108,7 +108,10 @@ function ScrollableList<T>(
|
||||
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||
|
||||
const smoothScrollTo = useCallback(
|
||||
(targetScrollTop: number, duration: number = 200) => {
|
||||
(
|
||||
targetScrollTop: number,
|
||||
duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200,
|
||||
) => {
|
||||
stopSmoothScroll();
|
||||
|
||||
const scrollState = virtualizedListRef.current?.getScrollState() ?? {
|
||||
|
||||
@@ -65,8 +65,9 @@ describe('SearchableList', () => {
|
||||
);
|
||||
};
|
||||
|
||||
it('should render all items initially', () => {
|
||||
const { lastFrame } = renderList();
|
||||
it('should render all items initially', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderList();
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
|
||||
// Check for title
|
||||
|
||||
@@ -30,11 +30,12 @@ describe('<SectionHeader />', () => {
|
||||
title: 'Narrow Container',
|
||||
width: 25,
|
||||
},
|
||||
])('$description', ({ title, width }) => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
])('$description', async ({ title, width }) => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<SectionHeader title={title} />,
|
||||
{ width },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
|
||||
@@ -16,90 +16,105 @@ const MOCK_TABS: Tab[] = [
|
||||
|
||||
describe('TabHeader', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders null for single tab', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders null for single tab', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={[{ key: '0', header: 'Only Tab' }]}
|
||||
currentIndex={0}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders all tab headers', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders all tab headers', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Tab 1');
|
||||
expect(frame).toContain('Tab 2');
|
||||
expect(frame).toContain('Tab 3');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders separators between tabs', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('renders separators between tabs', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should have 2 separators for 3 tabs
|
||||
const separatorCount = (frame?.match(/│/g) || []).length;
|
||||
expect(separatorCount).toBe(2);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrows', () => {
|
||||
it('shows arrows by default', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('shows arrows by default', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('←');
|
||||
expect(frame).toContain('→');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides arrows when showArrows is false', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('hides arrows when showArrows is false', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} showArrows={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('←');
|
||||
expect(frame).not.toContain('→');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status icons', () => {
|
||||
it('shows status icons by default', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('shows status icons by default', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Default uncompleted icon is □
|
||||
expect(frame).toContain('□');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides status icons when showStatusIcons is false', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('hides status icons when showStatusIcons is false', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} showStatusIcons={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('□');
|
||||
expect(frame).not.toContain('✓');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows checkmark for completed tabs', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
it('shows checkmark for completed tabs', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={MOCK_TABS}
|
||||
currentIndex={0}
|
||||
completedIndices={new Set([0, 2])}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should have 2 checkmarks and 1 box
|
||||
const checkmarkCount = (frame?.match(/✓/g) || []).length;
|
||||
@@ -107,62 +122,71 @@ describe('TabHeader', () => {
|
||||
expect(checkmarkCount).toBe(2);
|
||||
expect(boxCount).toBe(1);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows special icon for special tabs', () => {
|
||||
it('shows special icon for special tabs', async () => {
|
||||
const tabsWithSpecial: Tab[] = [
|
||||
{ key: '0', header: 'Tab 1' },
|
||||
{ key: '1', header: 'Review', isSpecial: true },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={tabsWithSpecial} currentIndex={0} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Special tab shows ≡ icon
|
||||
expect(frame).toContain('≡');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('uses tab statusIcon when provided', () => {
|
||||
it('uses tab statusIcon when provided', async () => {
|
||||
const tabsWithCustomIcon: Tab[] = [
|
||||
{ key: '0', header: 'Tab 1', statusIcon: '★' },
|
||||
{ key: '1', header: 'Tab 2' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={tabsWithCustomIcon} currentIndex={0} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('★');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('uses custom renderStatusIcon when provided', () => {
|
||||
it('uses custom renderStatusIcon when provided', async () => {
|
||||
const renderStatusIcon = () => '•';
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={MOCK_TABS}
|
||||
currentIndex={0}
|
||||
renderStatusIcon={renderStatusIcon}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
const bulletCount = (frame?.match(/•/g) || []).length;
|
||||
expect(bulletCount).toBe(3);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('falls back to default when renderStatusIcon returns undefined', () => {
|
||||
it('falls back to default when renderStatusIcon returns undefined', async () => {
|
||||
const renderStatusIcon = () => undefined;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={MOCK_TABS}
|
||||
currentIndex={0}
|
||||
renderStatusIcon={renderStatusIcon}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('□');
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
|
||||
@@ -114,7 +116,7 @@ describe('TextInput', () => {
|
||||
mockedUseTextBuffer.mockReturnValue(mockBuffer);
|
||||
});
|
||||
|
||||
it('renders with an initial value', () => {
|
||||
it('renders with an initial value', async () => {
|
||||
const buffer = {
|
||||
text: 'test',
|
||||
lines: ['test'],
|
||||
@@ -124,17 +126,19 @@ describe('TextInput', () => {
|
||||
handleInput: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TextInput
|
||||
buffer={buffer as unknown as TextBuffer}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('test');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders a placeholder', () => {
|
||||
it('renders a placeholder', async () => {
|
||||
const buffer = {
|
||||
text: '',
|
||||
lines: [''],
|
||||
@@ -144,7 +148,7 @@ describe('TextInput', () => {
|
||||
handleInput: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TextInput
|
||||
buffer={buffer as unknown as TextBuffer}
|
||||
placeholder="testing"
|
||||
@@ -152,23 +156,29 @@ describe('TextInput', () => {
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('testing');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles character input', () => {
|
||||
render(
|
||||
it('handles character input', async () => {
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: 'a',
|
||||
await act(async () => {
|
||||
keypressHandler({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: 'a',
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
|
||||
name: 'a',
|
||||
@@ -179,23 +189,28 @@ describe('TextInput', () => {
|
||||
sequence: 'a',
|
||||
});
|
||||
expect(mockBuffer.text).toBe('a');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles backspace', () => {
|
||||
it('handles backspace', async () => {
|
||||
mockBuffer.setText('test');
|
||||
render(
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'backspace',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
await act(async () => {
|
||||
keypressHandler({
|
||||
name: 'backspace',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
|
||||
name: 'backspace',
|
||||
@@ -206,99 +221,126 @@ describe('TextInput', () => {
|
||||
sequence: '',
|
||||
});
|
||||
expect(mockBuffer.text).toBe('tes');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles left arrow', () => {
|
||||
it('handles left arrow', async () => {
|
||||
mockBuffer.setText('test');
|
||||
render(
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'left',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
await act(async () => {
|
||||
keypressHandler({
|
||||
name: 'left',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Cursor moves from end to before 't'
|
||||
expect(mockBuffer.visualCursor[1]).toBe(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles right arrow', () => {
|
||||
it('handles right arrow', async () => {
|
||||
mockBuffer.setText('test');
|
||||
mockBuffer.visualCursor[1] = 2; // Set initial cursor for right arrow test
|
||||
render(
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'right',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
await act(async () => {
|
||||
keypressHandler({
|
||||
name: 'right',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(mockBuffer.visualCursor[1]).toBe(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onSubmit on return', () => {
|
||||
it('calls onSubmit on return', async () => {
|
||||
mockBuffer.setText('test');
|
||||
render(
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'return',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
await act(async () => {
|
||||
keypressHandler({
|
||||
name: 'return',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('test');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onCancel on escape', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onCancel={onCancel} onSubmit={onSubmit} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'escape',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
await act(async () => {
|
||||
keypressHandler({
|
||||
name: 'escape',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
});
|
||||
});
|
||||
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders the input value', () => {
|
||||
it('renders the input value', async () => {
|
||||
mockBuffer.setText('secret');
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('secret');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not show cursor when not focused', () => {
|
||||
it('does not show cursor when not focused', async () => {
|
||||
mockBuffer.setText('test');
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TextInput
|
||||
buffer={mockBuffer}
|
||||
focus={false}
|
||||
@@ -306,18 +348,22 @@ describe('TextInput', () => {
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('\u001b[7m'); // Inverse video chalk
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders multiple lines when text wraps', () => {
|
||||
it('renders multiple lines when text wraps', async () => {
|
||||
mockBuffer.text = 'line1\nline2';
|
||||
mockBuffer.viewportVisualLines = ['line1', 'line2'];
|
||||
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('line1');
|
||||
expect(lastFrame()).toContain('line2');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,8 +24,6 @@ vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe('<VirtualizedList />', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
@@ -60,7 +58,7 @@ describe('<VirtualizedList />', () => {
|
||||
])(
|
||||
'renders only visible items ($name)',
|
||||
async ({ initialScrollIndex, visible, notVisible }) => {
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
@@ -71,9 +69,7 @@ describe('<VirtualizedList />', () => {
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const frame = lastFrame();
|
||||
visible.forEach((item) => {
|
||||
@@ -83,11 +79,12 @@ describe('<VirtualizedList />', () => {
|
||||
expect(frame).not.toContain(item);
|
||||
});
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('sticks to bottom when new items added', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
@@ -98,38 +95,37 @@ describe('<VirtualizedList />', () => {
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Item 99');
|
||||
|
||||
// Add items
|
||||
const newData = [...longData, 'Item 100', 'Item 101'];
|
||||
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 act(async () => {
|
||||
await delay(0);
|
||||
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 } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
@@ -140,20 +136,19 @@ describe('<VirtualizedList />', () => {
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Item 0');
|
||||
|
||||
// Scroll to bottom via ref
|
||||
await act(async () => {
|
||||
ref.current?.scrollToEnd();
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Item 99');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -184,7 +179,7 @@ describe('<VirtualizedList />', () => {
|
||||
(_, i) => `Item ${i}`,
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Box height={20} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={veryLongData}
|
||||
@@ -197,13 +192,12 @@ describe('<VirtualizedList />', () => {
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -267,10 +261,8 @@ describe('<VirtualizedList />', () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const { lastFrame } = render(<TestComponent />);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<TestComponent />);
|
||||
await waitUntilReady();
|
||||
|
||||
// Initially, only Item 0 (height 10) fills the 10px viewport
|
||||
expect(lastFrame()).toContain('Item 0');
|
||||
@@ -279,13 +271,14 @@ describe('<VirtualizedList />', () => {
|
||||
// Shrink Item 0 to 1px via context
|
||||
await act(async () => {
|
||||
setHeightFn(1);
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
|
||||
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 () => {
|
||||
@@ -299,7 +292,7 @@ describe('<VirtualizedList />', () => {
|
||||
);
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
render(
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
@@ -310,26 +303,25 @@ describe('<VirtualizedList />', () => {
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollBy(1);
|
||||
ref.current?.scrollBy(1);
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollBy(2);
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(4);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
@@ -340,7 +332,7 @@ describe('<VirtualizedList />', () => {
|
||||
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
@@ -355,9 +347,7 @@ describe('<VirtualizedList />', () => {
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Item 50 should be visible
|
||||
expect(lastFrame()).toContain('Item 50');
|
||||
@@ -365,5 +355,6 @@ describe('<VirtualizedList />', () => {
|
||||
expect(lastFrame()).toContain('Item 59');
|
||||
// But far away items should not be (ensures we are actually scrolled)
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show arrows when list fits entirely 1`] = `
|
||||
"● 1. Item A
|
||||
2. Item B
|
||||
3. Item C"
|
||||
3. Item C
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
|
||||
@@ -11,7 +12,8 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro
|
||||
8. Item 8
|
||||
9. Item 9
|
||||
● 10. Item 10
|
||||
▼"
|
||||
▼
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
|
||||
@@ -19,7 +21,8 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
● 6. Item 6
|
||||
▼"
|
||||
▼
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
|
||||
@@ -27,5 +30,6 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro
|
||||
● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
▼"
|
||||
▼
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -6,7 +6,8 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
|
||||
● 2. Bar Title
|
||||
This is Bar.
|
||||
3. Baz Title
|
||||
This is Baz."
|
||||
This is Baz.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
|
||||
@@ -15,5 +16,6 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with default pro
|
||||
Bar Title
|
||||
This is Bar.
|
||||
Baz Title
|
||||
This is Baz."
|
||||
This is Baz.
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `"← 中文 (简体) →"`;
|
||||
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `
|
||||
"← 中文 (简体) →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `"← Medium →"`;
|
||||
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `
|
||||
"← Medium →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `" Only Option"`;
|
||||
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `
|
||||
" Only Option
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `"← English →"`;
|
||||
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `
|
||||
"← English →
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -2,26 +2,39 @@
|
||||
|
||||
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
|
||||
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||
components//and/then/some/more/components//and/..."
|
||||
components//and/then/some/more/components//and/...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `
|
||||
"run: git commit -m "feat: add search"
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `
|
||||
"simple command
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||
exports[`ExpandableText > respects custom maxWidth 1`] = `
|
||||
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
|
||||
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
exports[`<HalfLinePaddedBox /> > renders iTerm2-specific blocks when iTerm2 is detected 1`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄
|
||||
Content
|
||||
▀▀▀▀▀▀▀▀▀▀"
|
||||
▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders nothing when screen reader is enabled 1`] = `"Content"`;
|
||||
exports[`<HalfLinePaddedBox /> > renders nothing when screen reader is enabled 1`] = `
|
||||
"Content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is false 1`] = `"Content"`;
|
||||
exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is false 1`] = `
|
||||
"Content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when not iTerm2 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀
|
||||
Content
|
||||
▄▄▄▄▄▄▄▄▄▄"
|
||||
▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
exports[`<MaxSizedBox /> > accounts for additionalHiddenLinesCount 1`] = `
|
||||
"... first 7 lines hidden ...
|
||||
Line 3"
|
||||
Line 3
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > clips a long single text child from the bottom 1`] = `
|
||||
@@ -15,7 +16,8 @@ Line 6
|
||||
Line 7
|
||||
Line 8
|
||||
Line 9
|
||||
... last 21 lines hidden ..."
|
||||
... last 21 lines hidden ...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > clips a long single text child from the top 1`] = `
|
||||
@@ -28,7 +30,8 @@ Line 26
|
||||
Line 27
|
||||
Line 28
|
||||
Line 29
|
||||
Line 30"
|
||||
Line 30
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bottom overflow 1`] = `
|
||||
@@ -36,44 +39,55 @@ exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bot
|
||||
|
||||
- Step 1: Do something important
|
||||
- Step 2: Do something important
|
||||
... last 18 lines hidden ..."
|
||||
... last 18 lines hidden ...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > does not truncate when maxHeight is undefined 1`] = `
|
||||
"Line 1
|
||||
Line 2"
|
||||
Line 2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > handles React.Fragment as a child 1`] = `
|
||||
"Line 1 from Fragment
|
||||
Line 2 from Fragment
|
||||
Line 3 direct child"
|
||||
Line 3 direct child
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > hides lines at the end when content exceeds maxHeight and overflowDirection is bottom 1`] = `
|
||||
"Line 1
|
||||
... last 2 lines hidden ..."
|
||||
... last 2 lines hidden ...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > hides lines when content exceeds maxHeight 1`] = `
|
||||
"... first 2 lines hidden ...
|
||||
Line 3"
|
||||
Line 3
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`] = `"Hello, World!"`;
|
||||
exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`] = `
|
||||
"Hello, World!
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > shows plural "lines" when more than one line is hidden 1`] = `
|
||||
"... first 2 lines hidden ...
|
||||
Line 3"
|
||||
Line 3
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > shows singular "line" when exactly one line is hidden 1`] = `
|
||||
"... first 1 line hidden ...
|
||||
Line 1"
|
||||
Line 1
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > wraps text that exceeds maxWidth 1`] = `
|
||||
"This is a
|
||||
long line
|
||||
of text"
|
||||
of text
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -4,6 +4,5 @@ exports[`<Scrollable /> > matches snapshot 1`] = `
|
||||
"Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `"── Narrow Container ─────"`;
|
||||
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `
|
||||
"── Narrow Container ─────
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `"── Very Long Hea… ──"`;
|
||||
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
|
||||
"── Very Long Hea… ──
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `"── My Header ───────────────────────────"`;
|
||||
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `
|
||||
"── My Header ───────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -20,7 +20,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│Item 3 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
|
||||
@@ -43,7 +44,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│Item 503 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 999) 1`] = `
|
||||
@@ -66,7 +68,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ █│
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('scrolled to bottom') 1`] = `
|
||||
@@ -79,7 +82,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > renders only vis
|
||||
│Item 97 │
|
||||
│Item 98 │
|
||||
│Item 99 █│
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('top') 1`] = `
|
||||
@@ -92,5 +96,6 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > renders only vis
|
||||
│Item 5 │
|
||||
│Item 6 │
|
||||
│Item 7 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user