mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
Migrate core render util to use xterm.js as part of the rendering loop. (#19044)
This commit is contained in:
@@ -41,9 +41,11 @@ export function useAnimatedScrollbar(
|
||||
debugState.debugNumAnimatedComponents++;
|
||||
isAnimatingRef.current = true;
|
||||
|
||||
const fadeInDuration = 200;
|
||||
const visibleDuration = 1000;
|
||||
const fadeOutDuration = 300;
|
||||
const isTest =
|
||||
typeof process !== 'undefined' && process.env['NODE_ENV'] === 'test';
|
||||
const fadeInDuration = isTest ? 0 : 200;
|
||||
const visibleDuration = isTest ? 0 : 1000;
|
||||
const fadeOutDuration = isTest ? 0 : 300;
|
||||
|
||||
const focusedColor = theme.text.secondary;
|
||||
const unfocusedColor = theme.ui.dark;
|
||||
@@ -53,9 +55,17 @@ export function useAnimatedScrollbar(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTest) {
|
||||
setScrollbarColor(unfocusedColor);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: Fade In
|
||||
let start = Date.now();
|
||||
const animateFadeIn = () => {
|
||||
if (!isAnimatingRef.current) return;
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
const progress = Math.max(0, Math.min(elapsed / fadeInDuration, 1));
|
||||
|
||||
@@ -72,6 +82,8 @@ export function useAnimatedScrollbar(
|
||||
// Phase 3: Fade Out
|
||||
start = Date.now();
|
||||
const animateFadeOut = () => {
|
||||
if (!isAnimatingRef.current) return;
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
const progress = Math.max(
|
||||
0,
|
||||
|
||||
@@ -11,9 +11,13 @@ import { getBoundingBox, type DOMElement } from 'ink';
|
||||
import type React from 'react';
|
||||
|
||||
// Mock ink
|
||||
vi.mock('ink', async () => ({
|
||||
getBoundingBox: vi.fn(),
|
||||
}));
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
getBoundingBox: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock MouseContext
|
||||
const mockUseMouse = vi.fn();
|
||||
@@ -31,7 +35,7 @@ describe('useMouseClick', () => {
|
||||
containerRef = { current: {} as DOMElement };
|
||||
});
|
||||
|
||||
it('should call handler with relative coordinates when click is inside bounds', () => {
|
||||
it('should call handler with relative coordinates when click is inside bounds', async () => {
|
||||
vi.mocked(getBoundingBox).mockReturnValue({
|
||||
x: 10,
|
||||
y: 5,
|
||||
@@ -39,7 +43,10 @@ describe('useMouseClick', () => {
|
||||
height: 10,
|
||||
} as unknown as ReturnType<typeof getBoundingBox>);
|
||||
|
||||
renderHook(() => useMouseClick(containerRef, handler));
|
||||
const { unmount, waitUntilReady } = renderHook(() =>
|
||||
useMouseClick(containerRef, handler),
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Get the callback registered with useMouse
|
||||
expect(mockUseMouse).toHaveBeenCalled();
|
||||
@@ -56,9 +63,10 @@ describe('useMouseClick', () => {
|
||||
5,
|
||||
2,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not call handler when click is outside bounds', () => {
|
||||
it('should not call handler when click is outside bounds', async () => {
|
||||
vi.mocked(getBoundingBox).mockReturnValue({
|
||||
x: 10,
|
||||
y: 5,
|
||||
@@ -66,11 +74,15 @@ describe('useMouseClick', () => {
|
||||
height: 10,
|
||||
} as unknown as ReturnType<typeof getBoundingBox>);
|
||||
|
||||
renderHook(() => useMouseClick(containerRef, handler));
|
||||
const { unmount, waitUntilReady } = renderHook(() =>
|
||||
useMouseClick(containerRef, handler),
|
||||
);
|
||||
await waitUntilReady();
|
||||
const callback = mockUseMouse.mock.calls[0][0];
|
||||
|
||||
// Click outside: x=5 (col 6), y=7 (row 8) -> left of box
|
||||
callback({ name: 'left-press', col: 6, row: 8 });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import React, { act } from 'react';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { Text } from 'ink';
|
||||
import {
|
||||
@@ -42,118 +42,133 @@ describe('usePhraseCycler', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with an empty string when not active and not waiting', () => {
|
||||
it('should initialize with an empty string when not active and not waiting', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={false} isWaiting={false} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show "Waiting for user confirmation..." when isWaiting is true', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
rerender(<TestComponent isActive={true} isWaiting={true} />);
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
rerender(<TestComponent isActive={true} isWaiting={true} />);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame().trim()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show interactive shell waiting message immediately when isInteractiveShellWaiting is true', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
isInteractiveShellWaiting={true}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
isInteractiveShellWaiting={true}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame().trim()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prioritize interactive shell waiting over normal waiting immediately', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={true} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().trim()).toMatchSnapshot();
|
||||
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={true}
|
||||
isInteractiveShellWaiting={true}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={true}
|
||||
isInteractiveShellWaiting={true}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().trim()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not cycle phrases if isActive is false and not waiting', async () => {
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={false} isWaiting={false} />,
|
||||
);
|
||||
const initialPhrase = lastFrame();
|
||||
await waitUntilReady();
|
||||
const initialPhrase = lastFrame({ allowEmpty: true }).trim();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS * 2);
|
||||
});
|
||||
expect(lastFrame()).toBe(initialPhrase);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame({ allowEmpty: true }).trim()).toBe(initialPhrase);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show a tip on first activation, then a witty phrase', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.99); // Subsequent phrases are witty
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Initial phrase on first activation should be a tip
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(INFORMATIVE_TIPS).toContain(lastFrame());
|
||||
expect(INFORMATIVE_TIPS).toContain(lastFrame().trim());
|
||||
|
||||
// After the first interval, it should be a witty phrase
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cycle through phrases when isActive is true and not waiting', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
// Initial phrase on first activation will be a tip, not necessarily from witty phrases
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
// First activation shows a tip, so we can't guarantee it's in WITTY_LOADING_PHRASES
|
||||
await waitUntilReady();
|
||||
// Initial phrase on first activation will be a tip
|
||||
|
||||
// After the first interval, it should follow the random pattern (witty phrases due to mock)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset to a phrase when isActive becomes true after being false', async () => {
|
||||
@@ -168,65 +183,81 @@ describe('usePhraseCycler', () => {
|
||||
return val;
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = render(
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent
|
||||
isActive={false}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Activate -> On first activation will show tip on initial call, then first interval will use first mock value for 'Phrase A'
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after initial state -> callCount 0 -> 'Phrase A'
|
||||
});
|
||||
expect(customPhrases).toContain(lastFrame()); // Should be one of the custom phrases
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
|
||||
// Second interval -> callCount 1 -> returns 0.99 -> 'Phrase B'
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
expect(customPhrases).toContain(lastFrame()); // Should be one of the custom phrases
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
|
||||
// Deactivate -> resets to undefined (empty string in output)
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={false}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={false}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// The phrase should be empty after reset
|
||||
expect(lastFrame()).toBe('');
|
||||
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
|
||||
|
||||
// Activate again -> this will show a tip on first activation, then cycle from where mock is
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after re-activation -> should contain phrase
|
||||
});
|
||||
expect(customPhrases).toContain(lastFrame()); // Should be one of the custom phrases
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear phrase interval on unmount when active', () => {
|
||||
const { unmount } = render(
|
||||
it('should clear phrase interval on unmount when active', async () => {
|
||||
const { unmount, waitUntilReady } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalledOnce();
|
||||
@@ -236,96 +267,132 @@ describe('usePhraseCycler', () => {
|
||||
const customPhrases = ['Custom Phrase 1', 'Custom Phrase 2'];
|
||||
const randomMock = vi.spyOn(Math, 'random');
|
||||
|
||||
const { lastFrame, rerender } = render(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
let setStateExternally:
|
||||
| React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
isActive: boolean;
|
||||
customPhrases?: string[];
|
||||
}>
|
||||
>
|
||||
| undefined;
|
||||
|
||||
const StatefulWrapper = () => {
|
||||
const [config, setConfig] = React.useState<{
|
||||
isActive: boolean;
|
||||
customPhrases?: string[];
|
||||
}>({
|
||||
isActive: true,
|
||||
customPhrases,
|
||||
});
|
||||
setStateExternally = setConfig;
|
||||
return (
|
||||
<TestComponent
|
||||
isActive={config.isActive}
|
||||
isWaiting={false}
|
||||
customPhrases={config.customPhrases}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<StatefulWrapper />);
|
||||
await waitUntilReady();
|
||||
|
||||
// After first interval, it should use custom phrases
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
randomMock.mockReturnValue(0);
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
isActive: true,
|
||||
customPhrases,
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
expect(customPhrases).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
|
||||
|
||||
randomMock.mockReturnValue(0.99);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
expect(customPhrases).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
|
||||
|
||||
// Test fallback to default phrases.
|
||||
randomMock.mockRestore();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5); // Always witty
|
||||
|
||||
rerender(
|
||||
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
|
||||
);
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
isActive: true,
|
||||
customPhrases: [],
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Wait for first cycle
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should fall back to witty phrases if custom phrases are an empty array', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0); // First activation will be a tip
|
||||
});
|
||||
// First activation shows a tip, so we can't guarantee it's in WITTY_LOADING_PHRASES
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Next phrase after tip
|
||||
});
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset phrase when transitioning from waiting to active', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
const { lastFrame, rerender } = render(
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0); // First activation will be a tip
|
||||
});
|
||||
// First activation shows a tip, so we can't guarantee it's in WITTY_LOADING_PHRASES
|
||||
await waitUntilReady();
|
||||
|
||||
// Cycle to a different phrase (should be witty due to mock)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
|
||||
// Go to waiting state
|
||||
rerender(<TestComponent isActive={false} isWaiting={true} />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
rerender(<TestComponent isActive={false} isWaiting={true} />);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().trim()).toMatchSnapshot();
|
||||
|
||||
// Go back to active cycling - should pick a phrase based on the logic (witty due to mock)
|
||||
rerender(<TestComponent isActive={true} isWaiting={false} />);
|
||||
await act(async () => {
|
||||
rerender(<TestComponent isActive={true} isWaiting={false} />);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Skip the tip and get next phrase
|
||||
});
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,11 @@ describe('useSelectionList', () => {
|
||||
hookResult = useSelectionList(props);
|
||||
return null;
|
||||
}
|
||||
const { rerender, unmount } = render(<TestComponent {...initialProps} />);
|
||||
const { rerender, unmount, waitUntilReady } = render(
|
||||
<TestComponent {...initialProps} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
@@ -95,11 +99,15 @@ describe('useSelectionList', () => {
|
||||
},
|
||||
},
|
||||
rerender: async (newProps: Partial<typeof initialProps>) => {
|
||||
rerender(<TestComponent {...initialProps} {...newProps} />);
|
||||
await act(async () => {
|
||||
rerender(<TestComponent {...initialProps} {...newProps} />);
|
||||
});
|
||||
await waitUntilReady();
|
||||
},
|
||||
unmount: async () => {
|
||||
unmount();
|
||||
},
|
||||
waitUntilReady,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -184,32 +192,36 @@ describe('useSelectionList', () => {
|
||||
|
||||
describe('Keyboard Navigation (Up/Down/J/K)', () => {
|
||||
it('should move down with "j" and "down" keys, skipping disabled items', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
pressKey('j');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should move up with "k" and "up" keys, skipping disabled items', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
initialIndex: 3,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
pressKey('k');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
pressKey('up');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should ignore navigation keys when shift is pressed', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
initialIndex: 2, // Start at middle item 'C'
|
||||
onSelect: mockOnSelect,
|
||||
@@ -218,54 +230,63 @@ describe('useSelectionList', () => {
|
||||
|
||||
// Shift+Down / Shift+J should not move down
|
||||
pressKey('down', undefined, { shift: true });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
pressKey('j', undefined, { shift: true });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
// Shift+Up / Shift+K should not move up
|
||||
pressKey('up', undefined, { shift: true });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
pressKey('k', undefined, { shift: true });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
// Verify normal navigation still works
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should wrap navigation correctly', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
initialIndex: items.length - 1,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
|
||||
pressKey('up');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should call onHighlight when index changes', async () => {
|
||||
await renderSelectionListHook({
|
||||
const { waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
});
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(mockOnHighlight).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnHighlight).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('should not move or call onHighlight if navigation results in the same index (e.g., single item)', async () => {
|
||||
const singleItem = [{ value: 'A', key: 'A' }];
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: singleItem,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
});
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
expect(mockOnHighlight).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -275,13 +296,14 @@ describe('useSelectionList', () => {
|
||||
{ value: 'A', disabled: true, key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
];
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: allDisabled,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
});
|
||||
const initialIndex = result.current.activeIndex;
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(initialIndex);
|
||||
expect(mockOnHighlight).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -289,21 +311,23 @@ describe('useSelectionList', () => {
|
||||
|
||||
describe('Wrapping (wrapAround)', () => {
|
||||
it('should wrap by default (wrapAround=true)', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
initialIndex: items.length - 1,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
|
||||
pressKey('up');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should not wrap when wrapAround is false', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
initialIndex: items.length - 1,
|
||||
onSelect: mockOnSelect,
|
||||
@@ -311,43 +335,49 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(3); // Should stay at bottom
|
||||
|
||||
act(() => result.current.setActiveIndex(0));
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
pressKey('up');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0); // Should stay at top
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selection (Enter)', () => {
|
||||
it('should call onSelect when "return" is pressed on enabled item', async () => {
|
||||
await renderSelectionListHook({
|
||||
const { waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
initialIndex: 2,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
pressKey('return');
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('should not call onSelect if the active item is disabled', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
|
||||
act(() => result.current.setActiveIndex(1));
|
||||
await waitUntilReady();
|
||||
|
||||
pressKey('return');
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Navigation Robustness (Rapid Input)', () => {
|
||||
it('should handle rapid navigation and selection robustly (avoiding stale state)', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items, // A, B(disabled), C, D. Initial index 0 (A).
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
@@ -375,14 +405,17 @@ describe('useSelectionList', () => {
|
||||
act(() => {
|
||||
press('down');
|
||||
});
|
||||
await waitUntilReady();
|
||||
// 2. Press Down again. Should move 2 (C) -> 3 (D).
|
||||
act(() => {
|
||||
press('down');
|
||||
});
|
||||
await waitUntilReady();
|
||||
// 3. Press Enter. Should select D.
|
||||
act(() => {
|
||||
press('return');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
|
||||
@@ -396,7 +429,7 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should handle ultra-rapid input (multiple presses in single act) without stale state', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items, // A, B(disabled), C, D. Initial index 0 (A).
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
@@ -426,6 +459,7 @@ describe('useSelectionList', () => {
|
||||
press('down'); // Should move 2 (C) -> 3 (D)
|
||||
press('return'); // Should select D
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
|
||||
@@ -437,12 +471,13 @@ describe('useSelectionList', () => {
|
||||
|
||||
describe('Focus Management (isFocused)', () => {
|
||||
it('should activate the keypress handler when focused (default) and items exist', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(activeKeypressHandler).not.toBeNull();
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
});
|
||||
|
||||
@@ -467,17 +502,19 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should activate/deactivate when isFocused prop changes', async () => {
|
||||
const { result, rerender } = await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
isFocused: false,
|
||||
});
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
isFocused: false,
|
||||
});
|
||||
|
||||
expect(activeKeypressHandler).toBeNull();
|
||||
|
||||
await rerender({ isFocused: true });
|
||||
expect(activeKeypressHandler).not.toBeNull();
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
await rerender({ isFocused: false });
|
||||
@@ -504,23 +541,25 @@ describe('useSelectionList', () => {
|
||||
const pressNumber = (num: string) => pressKey(num, num);
|
||||
|
||||
it('should not respond to numbers if showNumbers is false (default)', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: shortList,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should select item immediately if the number cannot be extended (unambiguous)', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: shortList,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
showNumbers: true,
|
||||
});
|
||||
pressNumber('3');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
expect(mockOnHighlight).toHaveBeenCalledWith('C');
|
||||
@@ -530,7 +569,7 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should highlight and wait for timeout if the number can be extended (ambiguous)', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: longList,
|
||||
initialIndex: 1, // Start at index 1 so pressing "1" (index 0) causes a change
|
||||
onSelect: mockOnSelect,
|
||||
@@ -539,6 +578,7 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
expect(mockOnHighlight).toHaveBeenCalledWith('Item 1');
|
||||
@@ -546,25 +586,28 @@ describe('useSelectionList', () => {
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('Item 1');
|
||||
});
|
||||
|
||||
it('should handle multi-digit input correctly', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: longList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
pressNumber('2');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(11);
|
||||
|
||||
@@ -573,30 +616,33 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should reset buffer if input becomes invalid (out of bounds)', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: shortList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressNumber('5');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
pressNumber('3');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('should allow "0" as subsequent digit, but ignore as first digit', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: longList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressNumber('0');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
// Timer should be running to clear the '0' input buffer
|
||||
@@ -604,33 +650,37 @@ describe('useSelectionList', () => {
|
||||
|
||||
// Press '1', then '0' (Item 10, index 9)
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
pressNumber('0');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(9);
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('Item 10');
|
||||
});
|
||||
|
||||
it('should clear the initial "0" input after timeout', async () => {
|
||||
await renderSelectionListHook({
|
||||
const { waitUntilReady } = await renderSelectionListHook({
|
||||
items: longList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressNumber('0');
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
act(() => vi.advanceTimersByTime(1000)); // Timeout the '0' input
|
||||
await waitUntilReady();
|
||||
await act(async () => vi.advanceTimersByTime(1000)); // Timeout the '0' input
|
||||
await waitUntilReady();
|
||||
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled(); // Should be waiting for second digit
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
act(() => vi.advanceTimersByTime(1000)); // Timeout '1'
|
||||
await act(async () => vi.advanceTimersByTime(1000)); // Timeout '1'
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('Item 1');
|
||||
});
|
||||
|
||||
it('should highlight but not select a disabled item (immediate selection case)', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: shortList, // B (index 1, number 2) is disabled
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
@@ -638,6 +688,7 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
pressNumber('2');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(1);
|
||||
expect(mockOnHighlight).toHaveBeenCalledWith('B');
|
||||
@@ -653,61 +704,69 @@ describe('useSelectionList', () => {
|
||||
...longList.slice(1),
|
||||
];
|
||||
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: disabledAmbiguousList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should not select after timeout
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear the number buffer if a non-numeric key (e.g., navigation) is pressed', async () => {
|
||||
const { result } = await renderSelectionListHook({
|
||||
const { result, waitUntilReady } = await renderSelectionListHook({
|
||||
items: longList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.activeIndex).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
pressNumber('3');
|
||||
await waitUntilReady();
|
||||
// Should select '3', not '13'
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('should clear the number buffer if "return" is pressed', async () => {
|
||||
await renderSelectionListHook({
|
||||
const { waitUntilReady } = await renderSelectionListHook({
|
||||
items: longList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressNumber('1');
|
||||
await waitUntilReady();
|
||||
|
||||
pressKey('return');
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -727,14 +786,16 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should respect a new initialIndex even after user interaction', async () => {
|
||||
const { result, rerender } = await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
initialIndex: 0,
|
||||
});
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
initialIndex: 0,
|
||||
});
|
||||
|
||||
// User navigates, changing the active index
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
// The component re-renders with a new initial index
|
||||
@@ -828,18 +889,20 @@ describe('useSelectionList', () => {
|
||||
{ value: 'D', key: 'D' },
|
||||
];
|
||||
|
||||
const { result, rerender } = await renderSelectionListHook({
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
initialIndex: 2,
|
||||
items: initialItems,
|
||||
});
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
initialIndex: 2,
|
||||
items: initialItems,
|
||||
});
|
||||
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.setActiveIndex(3);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
|
||||
mockOnHighlight.mockClear();
|
||||
@@ -932,12 +995,14 @@ describe('useSelectionList', () => {
|
||||
{ value: 'C', key: 'C' },
|
||||
];
|
||||
|
||||
const { result, rerender } = await renderSelectionListHook({
|
||||
onSelect: mockOnSelect,
|
||||
items: initialItems,
|
||||
});
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
onSelect: mockOnSelect,
|
||||
items: initialItems,
|
||||
});
|
||||
|
||||
pressKey('down');
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(1);
|
||||
|
||||
const newItems = [
|
||||
@@ -974,10 +1039,17 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const { rerender } = render(<TestComponent {...initialProps} />);
|
||||
const { rerender, waitUntilReady } = render(
|
||||
<TestComponent {...initialProps} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
return {
|
||||
rerender: async (newProps: Partial<typeof initialProps>) => {
|
||||
rerender(<TestComponent {...initialProps} {...newProps} />);
|
||||
await act(async () => {
|
||||
rerender(<TestComponent {...initialProps} {...newProps} />);
|
||||
});
|
||||
await waitUntilReady();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1013,28 +1085,32 @@ describe('useSelectionList', () => {
|
||||
(_, i) => ({ value: `Item ${i + 1}`, key: `Item ${i + 1}` }),
|
||||
);
|
||||
|
||||
const { unmount } = await renderSelectionListHook({
|
||||
const { unmount, waitUntilReady } = await renderSelectionListHook({
|
||||
items: longList,
|
||||
onSelect: mockOnSelect,
|
||||
showNumbers: true,
|
||||
});
|
||||
|
||||
pressKey('1', '1');
|
||||
await waitUntilReady();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
await unmount();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
// No waitUntilReady here as component is unmounted
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,17 @@ const mockUnsubscribe = vi.fn();
|
||||
const mockHandleThemeSelect = vi.fn();
|
||||
const mockQueryTerminalBackground = vi.fn();
|
||||
|
||||
vi.mock('ink', async () => ({
|
||||
useStdout: () => ({
|
||||
stdout: {
|
||||
write: mockWrite,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
useStdout: () => ({
|
||||
stdout: {
|
||||
write: mockWrite,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/TerminalContext.js', () => ({
|
||||
useTerminalContext: () => ({
|
||||
@@ -47,8 +51,9 @@ vi.mock('../contexts/SettingsContext.js', () => ({
|
||||
useSettings: () => mockSettings,
|
||||
}));
|
||||
|
||||
vi.mock('../themes/theme-manager.js', async () => {
|
||||
const actual = await vi.importActual('../themes/theme-manager.js');
|
||||
vi.mock('../themes/theme-manager.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../themes/theme-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
themeManager: {
|
||||
@@ -91,36 +96,46 @@ describe('useTerminalTheme', () => {
|
||||
});
|
||||
|
||||
it('should subscribe to terminal background events on mount', () => {
|
||||
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
|
||||
expect(mockSubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe on unmount', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalTheme(mockHandleThemeSelect, config, vi.fn()),
|
||||
);
|
||||
expect(mockSubscribe).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should unsubscribe on unmount', async () => {
|
||||
const { unmount, waitUntilReady } = renderHook(() =>
|
||||
useTerminalTheme(mockHandleThemeSelect, config, vi.fn()),
|
||||
);
|
||||
await waitUntilReady();
|
||||
unmount();
|
||||
expect(mockUnsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should poll for terminal background', () => {
|
||||
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalTheme(mockHandleThemeSelect, config, vi.fn()),
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(mockQueryTerminalBackground).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not poll if terminal background is undefined at startup', () => {
|
||||
it('should not poll if terminal background is undefined at startup', async () => {
|
||||
config.getTerminalBackground = vi.fn().mockReturnValue(undefined);
|
||||
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalTheme(mockHandleThemeSelect, config, vi.fn()),
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(mockQueryTerminalBackground).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should switch to light theme when background is light and not call refreshStatic directly', () => {
|
||||
const refreshStatic = vi.fn();
|
||||
renderHook(() =>
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalTheme(mockHandleThemeSelect, config, refreshStatic),
|
||||
);
|
||||
|
||||
@@ -135,6 +150,7 @@ describe('useTerminalTheme', () => {
|
||||
'default-light',
|
||||
expect.anything(),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should switch to dark theme when background is dark', () => {
|
||||
@@ -143,7 +159,7 @@ describe('useTerminalTheme', () => {
|
||||
config.setTerminalBackground('#ffffff');
|
||||
|
||||
const refreshStatic = vi.fn();
|
||||
renderHook(() =>
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalTheme(mockHandleThemeSelect, config, refreshStatic),
|
||||
);
|
||||
|
||||
@@ -160,6 +176,7 @@ describe('useTerminalTheme', () => {
|
||||
);
|
||||
|
||||
mockSettings.merged.ui.theme = 'default';
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not update config or call refreshStatic on repeated identical background reports', () => {
|
||||
@@ -181,11 +198,14 @@ describe('useTerminalTheme', () => {
|
||||
|
||||
it('should not switch theme if autoThemeSwitching is disabled', () => {
|
||||
mockSettings.merged.ui.autoThemeSwitching = false;
|
||||
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalTheme(mockHandleThemeSelect, config, vi.fn()),
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(mockQueryTerminalBackground).not.toHaveBeenCalled();
|
||||
|
||||
mockSettings.merged.ui.autoThemeSwitching = true;
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user