diff --git a/src/components/ModelManager/ModelCustomDownloadView.test.tsx b/src/components/ModelManager/ModelCustomDownloadView.test.tsx new file mode 100644 index 00000000..ab53be66 --- /dev/null +++ b/src/components/ModelManager/ModelCustomDownloadView.test.tsx @@ -0,0 +1,134 @@ +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; + +import { THEME } from '@/constants'; + +import { getLastTextInputProps, type MockTextInputProps } from './test-utils'; + +const { mockModelSuggestions, mockTextInput } = vi.hoisted(() => ({ + mockModelSuggestions: + vi.fn< + (props: { + input: string; + onHighlight?: (value: string | null) => void; + onSelect?: (value: string) => void; + }) => void + >(), + mockTextInput: vi.fn<(props: MockTextInputProps) => void>(), +})); + +vi.mock('../TextInput', () => ({ + TextInput: (props: MockTextInputProps) => { + mockTextInput(props); + return ( + + {props.value === '' ? (props.placeholder ?? '') : props.value} + + ); + }, +})); + +vi.mock('./ModelSuggestions', () => ({ + ModelSuggestions: (props: { + input: string; + onHighlight?: (value: string | null) => void; + onSelect?: (value: string) => void; + }) => { + mockModelSuggestions(props); + return {`Suggestions:${props.input}`}; + }, +})); + +import { ModelCustomDownloadView } from './ModelCustomDownloadView'; + +describe('ModelCustomDownloadView', () => { + beforeEach(() => { + mockModelSuggestions.mockReset(); + mockTextInput.mockReset(); + }); + + it('renders the prompt, placeholder, and suggestions', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Enter an Ollama model name to download.'); + expect(lastFrame()).toContain('name:tag'); + expect(lastFrame()).toContain('Suggestions:'); + expect(lastFrame()).toContain('Press Enter to download.'); + }); + + it('renders notice text when provided', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Download failed'); + }); + + it('forwards TextInput change and submit callbacks', () => { + const onDraftChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + , + ); + + const props = getLastTextInputProps(mockTextInput); + props.onChange('gemma:latest'); + props.onSubmit('gemma:latest'); + + expect(onDraftChange).toHaveBeenCalledWith('gemma:latest'); + expect(onSubmit).toHaveBeenCalledWith('gemma:latest'); + }); + + it('passes suggestion props and forwards highlight and select callbacks', () => { + const onHighlight = vi.fn(); + const onSelectSuggestion = vi.fn(); + + render( + , + ); + + const [props] = mockModelSuggestions.mock.calls.at(-1) ?? []; + expect(props?.input).toBe('gemma'); + + props?.onHighlight?.('gemma:latest'); + props?.onSelect?.('gemma:latest'); + + expect(onHighlight).toHaveBeenCalledWith('gemma:latest'); + expect(onSelectSuggestion).toHaveBeenCalledWith('gemma:latest'); + }); +}); diff --git a/src/components/ModelManager/ModelDeleteConfirmView.test.tsx b/src/components/ModelManager/ModelDeleteConfirmView.test.tsx index d3426aa9..d9118065 100644 --- a/src/components/ModelManager/ModelDeleteConfirmView.test.tsx +++ b/src/components/ModelManager/ModelDeleteConfirmView.test.tsx @@ -1,34 +1,46 @@ +import { Text } from 'ink'; import { render } from 'ink-testing-library'; import { THEME } from '@/constants'; +import { time } from '@/utils'; + +import { getLastSelectProps, type MockSelectProps } from './test-utils'; const { mockSelect } = vi.hoisted(() => ({ - mockSelect: - vi.fn<(props: { options: { label: string; value: string }[] }) => void>(), + mockSelect: vi.fn<(props: MockSelectProps) => void>(), })); -vi.mock('@inkjs/ui', async () => { - const { Text } = await import('ink'); - return { - Select: (props: { options: { label: string; value: string }[] }) => { - mockSelect(props); - return ( - <> - {props.options.map(({ label, value }) => ( - {label} - ))} - - ); - }, - Spinner: ({ label }: { label?: string }) => ( - {`⏳${label ?? ''}`} - ), - }; -}); +vi.mock('@inkjs/ui', () => ({ + Spinner: ({ label }: { label?: string }) => {`⏳${label ?? ''}`}, +})); + +vi.mock('../SelectPrompt', () => ({ + SelectPrompt: ({ + children, + ...props + }: MockSelectProps & { children?: React.ReactNode }) => { + mockSelect(props); + return ( + <> + {children} + {props.options.map(({ label, value }) => ( + {label} + ))} + + ); + }, + SelectPromptHint: ({ message }: { message?: string }) => ( + {message} + ), +})); import { ModelDeleteConfirmView } from './ModelDeleteConfirmView'; describe('ModelDeleteConfirmView', () => { + beforeEach(() => { + mockSelect.mockReset(); + }); + it('renders confirm options with no notice when notice is null', () => { const { lastFrame } = render( { expect(lastFrame()).toContain('Something went wrong'); }); + + it('calls onConfirm when delete is selected', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('delete'); + + expect(onConfirm).toHaveBeenCalledWith('delete'); + }); + + it('passes non-delete selections through to onConfirm', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('back'); + + expect(onConfirm).toHaveBeenCalledWith('back'); + }); }); diff --git a/src/components/ModelManager/ModelDeleteView.test.tsx b/src/components/ModelManager/ModelDeleteView.test.tsx new file mode 100644 index 00000000..257aeafd --- /dev/null +++ b/src/components/ModelManager/ModelDeleteView.test.tsx @@ -0,0 +1,140 @@ +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; + +import { THEME } from '@/constants'; +import { time } from '@/utils'; + +import { getLastSelectProps, type MockSelectProps } from './test-utils'; + +const { mockSelect } = vi.hoisted(() => ({ + mockSelect: vi.fn<(props: MockSelectProps) => void>(), +})); + +vi.mock('@inkjs/ui', () => ({ + Spinner: ({ label }: { label?: string }) => {`⏳${label ?? ''}`}, +})); + +vi.mock('../SelectPrompt', () => ({ + SelectPrompt: ({ + children, + ...props + }: MockSelectProps & { children?: React.ReactNode }) => { + mockSelect(props); + return ( + <> + {children} + {props.options.map(({ label, value }) => ( + {label} + ))} + + ); + }, + SelectPromptHint: ({ message }: { message?: string }) => ( + {message} + ), +})); + +import { ModelDeleteView } from './ModelDeleteView'; + +describe('ModelDeleteView', () => { + beforeEach(() => { + mockSelect.mockReset(); + }); + + it('filters out the current model from selectable options', async () => { + const { lastFrame } = render( + , + ); + + await time.tick(10); + + const props = getLastSelectProps(mockSelect); + expect(props.options.map((option) => option.value)).not.toContain('gemma4'); + expect(lastFrame()).toContain('current model gemma4 cannot be deleted'); + }); + + it('renders notice text when provided', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Delete failed'); + }); + + it('calls onSelect for a selected model', async () => { + const onSelect = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('llama3'); + + expect(onSelect).toHaveBeenCalledWith('llama3'); + }); + + it('calls onCancel when back is selected', async () => { + const onCancel = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('back'); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('renders a spinner while loading', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Loading models'); + expect(mockSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ModelManager/ModelDeleteView.tsx b/src/components/ModelManager/ModelDeleteView.tsx index cdf5b9c9..805c6539 100644 --- a/src/components/ModelManager/ModelDeleteView.tsx +++ b/src/components/ModelManager/ModelDeleteView.tsx @@ -27,6 +27,7 @@ export function ModelDeleteView({ onCancel, onSelect, }: Props) { + // v8 ignore next if (isLoading) { return ; } diff --git a/src/components/ModelManager/ModelDownloadView.test.tsx b/src/components/ModelManager/ModelDownloadView.test.tsx new file mode 100644 index 00000000..c6b2a47b --- /dev/null +++ b/src/components/ModelManager/ModelDownloadView.test.tsx @@ -0,0 +1,132 @@ +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; + +import { THEME } from '@/constants'; +import { time } from '@/utils'; + +import { getLastSelectProps, type MockSelectProps } from './test-utils'; + +const { mockSelect } = vi.hoisted(() => ({ + mockSelect: vi.fn<(props: MockSelectProps) => void>(), +})); + +vi.mock('../SelectPrompt', () => ({ + SelectPrompt: ({ + children, + ...props + }: MockSelectProps & { children?: React.ReactNode }) => { + mockSelect(props); + return ( + <> + {children} + {props.options.map(({ label, value }) => ( + {label} + ))} + + ); + }, + SelectPromptHint: ({ message }: { message?: string }) => ( + {message} + ), +})); + +import { ModelDownloadView } from './ModelDownloadView'; + +describe('ModelDownloadView', () => { + beforeEach(() => { + mockSelect.mockReset(); + }); + + it('filters curated download options when an exact alias is already installed', async () => { + const { lastFrame } = render( + , + ); + + await time.tick(10); + + const frame = lastFrame() ?? ''; + expect(frame).not.toContain('Qwen 2.5 Coder'); + expect(frame).toContain('Granite 4'); + expect(frame).toContain('Enter custom model'); + }); + + it('renders notice text when provided', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Already installed'); + }); + + it('routes custom selection to onChange', async () => { + const onChange = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('custom'); + + expect(onChange).toHaveBeenCalledWith('custom'); + }); + + it('routes back selection to onCancel', async () => { + const onCancel = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('back'); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('passes curated model selections through to onChange', async () => { + const onChange = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('qwen2.5-coder:7b'); + + expect(onChange).toHaveBeenCalledWith('qwen2.5-coder:7b'); + }); +}); diff --git a/src/components/ModelManager/ModelDownloadingView.test.tsx b/src/components/ModelManager/ModelDownloadingView.test.tsx new file mode 100644 index 00000000..d10ab493 --- /dev/null +++ b/src/components/ModelManager/ModelDownloadingView.test.tsx @@ -0,0 +1,153 @@ +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; + +import { THEME } from '@/constants'; +import { time } from '@/utils'; + +import { getLastSelectProps, type MockSelectProps } from './test-utils'; + +const { mockProgressBar, mockSelect } = vi.hoisted(() => ({ + mockProgressBar: vi.fn<(value: number) => void>(), + mockSelect: vi.fn<(props: MockSelectProps) => void>(), +})); + +vi.mock('@inkjs/ui', () => ({ + ProgressBar: ({ value }: { value: number }) => { + mockProgressBar(value); + return {`Progress:${String(value)}`}; + }, +})); + +vi.mock('../SelectPrompt', () => ({ + SelectPrompt: ({ + children, + ...props + }: MockSelectProps & { children?: React.ReactNode }) => { + mockSelect(props); + return ( + <> + {children} + {props.options.map(({ label, value }) => ( + {label} + ))} + + ); + }, + SelectPromptHint: ({ message }: { message?: string }) => ( + {message} + ), +})); + +import { ModelDownloadingView } from './ModelDownloadingView'; + +describe('ModelDownloadingView', () => { + beforeEach(() => { + mockProgressBar.mockReset(); + mockSelect.mockReset(); + }); + + it('renders percentage, bytes, and progress bar when progress is available', async () => { + const { lastFrame } = render( + , + ); + + await time.tick(10); + + expect(lastFrame()).toContain('Downloading model'); + expect(lastFrame()).toContain('50%'); + expect(lastFrame()).toContain('50 B / 100 B'); + expect(mockProgressBar).toHaveBeenCalledWith(50); + }); + + it('keeps completed progress visible at 100 percent', async () => { + const { lastFrame } = render( + , + ); + + await time.tick(10); + + expect(lastFrame()).toContain('verifying'); + expect(lastFrame()).toContain('100%'); + expect(mockProgressBar).toHaveBeenLastCalledWith(100); + }); + + it('shows large file sizes with converted units', async () => { + const { lastFrame } = render( + , + ); + + await time.tick(10); + + expect(lastFrame()).toContain('GB'); + }); + + it('shows unavailable progress details when totals are missing', async () => { + const { lastFrame } = render( + , + ); + + await time.tick(10); + + expect(lastFrame()).toContain('Progress details unavailable'); + expect(mockProgressBar).not.toHaveBeenCalled(); + }); + + it('calls onCancel from the cancel prompt', async () => { + const onCancel = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('cancel-download'); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ModelManager/ModelManager.test.tsx b/src/components/ModelManager/ModelManager.test.tsx index d07caeca..fbf6ffda 100644 --- a/src/components/ModelManager/ModelManager.test.tsx +++ b/src/components/ModelManager/ModelManager.test.tsx @@ -3,45 +3,86 @@ import { render } from 'ink-testing-library'; import { time } from '@/utils'; -interface MockSelectProps { - options: { label: string; value: string }[]; - onChange?: (value: string) => void; - onCancel?: () => void; -} +import { getLastSelectProps, type MockSelectProps } from './test-utils'; -interface MockTextInputProps { - value: string; - placeholder?: string; +interface MockDownloadViewProps { + installedModels: string[]; + notice: { tone: 'error' | 'info' | 'success'; text: string } | null; + onCancel: () => void; onChange: (value: string) => void; +} + +interface MockCustomDownloadViewProps { + downloadDraft: string; + notice: { tone: 'error' | 'info' | 'success'; text: string } | null; + onDraftChange: (value: string) => void; + onHighlight: (value: string | null) => void; + onSelectSuggestion: (value: string) => void; onSubmit: (value: string) => void; } +interface MockDownloadingViewProps { + progress: { + model: string; + status: string; + completed: number; + total: number; + }; + onCancel: () => void; +} + +interface MockDeleteViewProps { + currentModel: string; + installedModels: string[]; + isLoading: boolean; + notice: { tone: 'error' | 'info' | 'success'; text: string } | null; + onCancel: () => void; + onSelect: (model: string) => void; +} + +interface MockDeleteConfirmViewProps { + deleteCandidate: string; + isDeleting: boolean; + notice: { tone: 'error' | 'info' | 'success'; text: string } | null; + onCancel: () => void; + onConfirm: (value: string) => Promise; +} + +interface MockSwitchViewProps { + currentModel: string; + installedModels: string[]; + isLoading: boolean; + onCancel: () => void; + onSelect: (model: string) => void; +} + const { + mockDeleteConfirmView, mockDeleteModel, + mockDeleteView, + mockDownloadView, + mockDownloadingView, mockListModels, - mockProgressBar, mockPullModel, mockSelect, - mockTextInput, + mockCustomDownloadView, + mockSwitchView, } = vi.hoisted(() => ({ + mockDeleteConfirmView: vi.fn<(props: MockDeleteConfirmViewProps) => void>(), mockDeleteModel: vi.fn(), + mockDeleteView: vi.fn<(props: MockDeleteViewProps) => void>(), + mockDownloadView: vi.fn<(props: MockDownloadViewProps) => void>(), + mockDownloadingView: vi.fn<(props: MockDownloadingViewProps) => void>(), mockListModels: vi.fn(), - mockProgressBar: vi.fn<(value: number) => void>(), mockPullModel: vi.fn(), mockSelect: vi.fn<(props: MockSelectProps) => void>(), - mockTextInput: vi.fn<(props: MockTextInputProps) => void>(), + mockCustomDownloadView: vi.fn<(props: MockCustomDownloadViewProps) => void>(), + mockSwitchView: vi.fn<(props: MockSwitchViewProps) => void>(), })); vi.mock('@inkjs/ui', async () => { const { Text } = await import('ink'); return { - ProgressBar: ({ value }: { value: number }) => { - mockProgressBar(value); - return {`Progress:${String(value)}`}; - }, - Spinner: ({ label }: { label?: string }) => ( - {`⏳${label ?? ''}`} - ), Select: (props: MockSelectProps) => { mockSelect(props); return ( @@ -55,36 +96,80 @@ vi.mock('@inkjs/ui', async () => { }; }); -vi.mock('../TextInput', () => ({ - TextInput: (props: MockTextInputProps) => { - mockTextInput(props); +vi.mock('./ModelDownloadView', () => ({ + ModelDownloadView: (props: MockDownloadViewProps) => { + mockDownloadView(props); return ( - - {props.value === '' ? (props.placeholder ?? '') : props.value} - + <> + DownloadView + {props.notice?.text ?? 'no-notice'} + ); }, })); -vi.mock('./ModelSuggestions', async () => { - const { useEffect } = await import('react'); - return { - ModelSuggestions: ({ - input, - onSelect, - }: { - input: string; - onSelect?: (value: string) => void; - }) => { - useEffect(() => { - if (onSelect && input.includes(':')) { - onSelect(input); - } - }, [input, onSelect]); - return {`Suggestions:${input}`}; - }, - }; -}); +vi.mock('./ModelCustomDownloadView', () => ({ + ModelCustomDownloadView: (props: MockCustomDownloadViewProps) => { + mockCustomDownloadView(props); + return ( + <> + CustomDownloadView + {props.downloadDraft || 'empty-draft'} + {props.notice?.text ?? 'no-notice'} + + ); + }, +})); + +vi.mock('./ModelDownloadingView', () => ({ + ModelDownloadingView: (props: MockDownloadingViewProps) => { + mockDownloadingView(props); + return ( + <> + DownloadingView + {props.progress.status} + {`${String(props.progress.completed)}/${String(props.progress.total)}`} + + ); + }, +})); + +vi.mock('./ModelDeleteView', () => ({ + ModelDeleteView: (props: MockDeleteViewProps) => { + mockDeleteView(props); + return ( + <> + DeleteView + {props.notice?.text ?? 'no-notice'} + + ); + }, +})); + +vi.mock('./ModelDeleteConfirmView', () => ({ + ModelDeleteConfirmView: (props: MockDeleteConfirmViewProps) => { + mockDeleteConfirmView(props); + return ( + <> + DeleteConfirmView + {props.deleteCandidate} + {props.notice?.text ?? 'no-notice'} + + ); + }, +})); + +vi.mock('./ModelSwitchView', () => ({ + ModelSwitchView: (props: MockSwitchViewProps) => { + mockSwitchView(props); + return ( + <> + SwitchView + {props.isLoading ? 'loading' : 'loaded'} + + ); + }, +})); vi.mock('@/utils', async () => ({ ...(await vi.importActual('@/utils')), @@ -97,32 +182,72 @@ vi.mock('@/utils', async () => ({ import { ModelManager } from './ModelManager'; -function getLastSelectProps(): MockSelectProps { - const [props] = mockSelect.mock.calls.at(-1) ?? []; +function getLastDownloadViewProps(): MockDownloadViewProps { + const [props] = mockDownloadView.mock.calls.at(-1) ?? []; + expect(props).toBeDefined(); + if (!props) { + throw new Error('Expected download view props to be defined.'); + } + return props; +} + +function getLastCustomDownloadViewProps(): MockCustomDownloadViewProps { + const [props] = mockCustomDownloadView.mock.calls.at(-1) ?? []; + expect(props).toBeDefined(); + if (!props) { + throw new Error('Expected custom download view props to be defined.'); + } + return props; +} + +function getLastDownloadingViewProps(): MockDownloadingViewProps { + const [props] = mockDownloadingView.mock.calls.at(-1) ?? []; + expect(props).toBeDefined(); + if (!props) { + throw new Error('Expected downloading view props to be defined.'); + } + return props; +} + +function getLastDeleteViewProps(): MockDeleteViewProps { + const [props] = mockDeleteView.mock.calls.at(-1) ?? []; expect(props).toBeDefined(); if (!props) { - throw new Error('Expected Select props to be defined.'); + throw new Error('Expected delete view props to be defined.'); } return props; } -function getLastTextInputProps(): MockTextInputProps { - const [props] = mockTextInput.mock.calls.at(-1) ?? []; +function getLastDeleteConfirmViewProps(): MockDeleteConfirmViewProps { + const [props] = mockDeleteConfirmView.mock.calls.at(-1) ?? []; expect(props).toBeDefined(); if (!props) { - throw new Error('Expected TextInput props to be defined.'); + throw new Error('Expected delete confirm view props to be defined.'); + } + return props; +} + +function getLastSwitchViewProps(): MockSwitchViewProps { + const [props] = mockSwitchView.mock.calls.at(-1) ?? []; + expect(props).toBeDefined(); + if (!props) { + throw new Error('Expected switch view props to be defined.'); } return props; } describe('ModelManager', () => { beforeEach(() => { + mockCustomDownloadView.mockReset(); + mockDeleteConfirmView.mockReset(); mockDeleteModel.mockReset(); + mockDeleteView.mockReset(); + mockDownloadView.mockReset(); + mockDownloadingView.mockReset(); mockListModels.mockReset(); - mockProgressBar.mockReset(); mockPullModel.mockReset(); mockSelect.mockReset(); - mockTextInput.mockReset(); + mockSwitchView.mockReset(); mockListModels.mockResolvedValue(['gemma4', 'llama3', 'codellama']); mockDeleteModel.mockResolvedValue(undefined); @@ -160,6 +285,7 @@ describe('ModelManager', () => { it('calls onClose when selecting cancel from menu', async () => { const onClose = vi.fn(); + render( { await time.tick(10); - const props = getLastSelectProps(); - props.onChange?.('cancel'); + getLastSelectProps(mockSelect).onChange?.('cancel'); await time.tick(10); expect(onClose).toHaveBeenCalled(); @@ -190,9 +315,7 @@ describe('ModelManager', () => { await time.tick(10); - // Navigate to switch view to see the error (error only shown when not in Menu view) - const props = getLastSelectProps(); - props.onChange?.('switch'); + getLastSelectProps(mockSelect).onChange?.('switch'); await time.tick(10); expect(lastFrame()).toContain('Error loading models'); @@ -212,8 +335,7 @@ describe('ModelManager', () => { await time.tick(10); - const props = getLastSelectProps(); - props.onChange?.('switch'); + getLastSelectProps(mockSelect).onChange?.('switch'); await time.tick(10); expect(lastFrame()).toContain('Error loading models: fetch failed'); @@ -238,8 +360,7 @@ describe('ModelManager', () => { await time.tick(10); - const props = getLastSelectProps(); - props.onChange?.('switch'); + getLastSelectProps(mockSelect).onChange?.('switch'); await time.tick(10); expect(lastFrame()).toContain('Error loading models: fetch failed'); @@ -252,102 +373,55 @@ describe('ModelManager', () => { }); }); - describe('switch view', () => { - it('renders the model switcher and selects a model', async () => { - const onSelect = vi.fn(); - render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('switch'); - await time.tick(10); - - props = getLastSelectProps(); - expect(props.options.map((option) => option.label)).toContain('Back'); - - props.onChange?.('llama3'); - expect(onSelect).toHaveBeenCalledWith({ model: 'llama3' }); - }); - - it('goes back from switch view via back option', async () => { - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('switch'); - await time.tick(10); + it('handles loading state when switching while models are still loading', async () => { + let resolveList: ((models: string[]) => void) | undefined; + mockListModels.mockReturnValueOnce( + new Promise((resolve) => { + resolveList = resolve; + }), + ); - expect(lastFrame()).toContain('gemma4'); - expect(lastFrame()).toContain('Back'); + render( + , + ); - props = getLastSelectProps(); - props.onChange?.('back'); - await time.tick(10); + await time.tick(10); - const menuProps = getLastSelectProps(); - expect(menuProps.options.map((o) => o.label)).toContain('Switch model'); - expect(menuProps.options.map((o) => o.label)).toContain('Download model'); - }); + getLastSelectProps(mockSelect).onChange?.('switch'); + await time.tick(20); - it('handles loading state when switching or deleting', async () => { - let resolveList: ((models: string[]) => void) | undefined; - mockListModels.mockReturnValueOnce( - new Promise((resolve) => { - resolveList = resolve; - }), - ); + expect(getLastSwitchViewProps().isLoading).toBe(true); + resolveList?.(['gemma4', 'llama3']); + await time.tick(10); + }); - const { lastFrame } = render( - , - ); + it('forwards selected models from switch view to onSelect', async () => { + const onSelect = vi.fn(); - await time.tick(10); + render( + , + ); - const props = getLastSelectProps(); - props.onChange?.('switch'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('switch'); + await time.tick(20); - expect(lastFrame()).toContain('Loading models'); + getLastSwitchViewProps().onSelect('llama3'); - resolveList?.(['gemma4', 'llama3']); - await time.tick(10); - }); + expect(onSelect).toHaveBeenCalledWith({ model: 'llama3' }); }); - describe('download view', () => { - it('downloads a curated model and returns to the parent menu', async () => { - mockPullModel.mockResolvedValueOnce({ - abort: vi.fn(), - async *[Symbol.asyncIterator]() { - await Promise.resolve(); - yield { - status: 'pulling', - digest: 'abc', - total: 100, - completed: 50, - }; - }, - }); - - const { lastFrame } = render( + describe('download orchestration', () => { + async function openDownloadView() { + render( { />, ); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); + } - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + it('downloads a curated model and returns to the parent menu', async () => { + await openDownloadView(); - props = getLastSelectProps(); - props.onChange?.('qwen2.5-coder:7b'); - await time.tick(10); + getLastDownloadViewProps().onChange('qwen2.5-coder:7b'); + await time.tick(20); expect(mockPullModel).toHaveBeenCalledWith('qwen2.5-coder:7b'); - expect(lastFrame()).toContain('qwen2.5-coder:7b downloaded successfully'); - }); - - it('filters curated download options when an exact alias is already installed', async () => { - mockListModels.mockResolvedValueOnce([ - 'gemma4', - 'llama3', - 'qwen2.5-coder:7b', - ]); - - const { lastFrame } = render( - , - ); - - await time.tick(10); - - const props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); - - const frame = lastFrame() ?? ''; - expect(frame).not.toContain('Qwen 2.5 Coder'); - expect(frame).toContain('Granite 4'); - expect(frame).toContain('Enter custom model'); - }); - - it('goes back from download view to menu', async () => { - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('back'); - await time.tick(10); - - expect(lastFrame()).toContain('Switch model'); }); - }); - describe('downloading', () => { - it('keeps the progress bar visible for follow-up status updates after 100%', async () => { + it('keeps the progress state for follow-up status updates after 100%', async () => { let finishDownload: (() => void) | undefined; mockPullModel.mockResolvedValueOnce({ abort: vi.fn(), @@ -444,27 +467,14 @@ describe('ModelManager', () => { }, }); - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await openDownloadView(); - props = getLastSelectProps(); - props.onChange?.('qwen2.5-coder:7b'); - await time.tick(10); + getLastDownloadViewProps().onChange('qwen2.5-coder:7b'); + await time.tick(20); - expect(lastFrame()).toContain('verifying'); - expect(lastFrame()).toContain('100%'); - expect(mockProgressBar).toHaveBeenLastCalledWith(100); + expect(getLastDownloadingViewProps().progress.status).toBe('verifying'); + expect(getLastDownloadingViewProps().progress.completed).toBe(100); + expect(getLastDownloadingViewProps().progress.total).toBe(100); finishDownload?.(); await time.tick(10); @@ -494,28 +504,14 @@ describe('ModelManager', () => { }, }); - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await openDownloadView(); - props = getLastSelectProps(); - props.onChange?.('qwen2.5-coder:7b'); - await time.tick(10); + getLastDownloadViewProps().onChange('qwen2.5-coder:7b'); + await time.tick(20); - expect(lastFrame()).toContain('verifying'); - expect(lastFrame()).toContain('100%'); - expect(lastFrame()).not.toContain('NaN'); - expect(mockProgressBar).toHaveBeenLastCalledWith(100); + expect(getLastDownloadingViewProps().progress.status).toBe('verifying'); + expect(getLastDownloadingViewProps().progress.completed).toBe(100); + expect(getLastDownloadingViewProps().progress.total).toBe(100); finishDownload?.(); await time.tick(10); @@ -545,114 +541,17 @@ describe('ModelManager', () => { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('qwen2.5-coder:7b'); - await time.tick(10); + getLastDownloadViewProps().onChange('qwen2.5-coder:7b'); + await time.tick(20); - expect(lastFrame()).toContain('Choose a model to download'); + expect(lastFrame()).toContain('DownloadView'); expect(lastFrame()).toContain('Download canceled'); }); - it('shows download progress with edge case byte values', async () => { - let finishDownload: (() => void) | undefined; - mockPullModel.mockResolvedValueOnce({ - abort: vi.fn(), - async *[Symbol.asyncIterator]() { - await Promise.resolve(); - // First yield with zero values to trigger formatBytes edge case - yield { - status: 'starting', - digest: 'abc', - total: 0, - completed: 0, - }; - // Then yield with undefined values - yield { - status: 'pulling', - digest: 'abc', - total: undefined, - completed: undefined, - }; - await new Promise((resolve) => { - finishDownload = resolve; - }); - }, - }); - - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('qwen2.5-coder:7b'); - await time.tick(10); - - expect(lastFrame()).toContain('pulling'); - - finishDownload?.(); - await time.tick(10); - }); - - it('shows download progress with large file sizes', async () => { - let finishDownload: (() => void) | undefined; - mockPullModel.mockResolvedValueOnce({ - abort: vi.fn(), - async *[Symbol.asyncIterator]() { - await Promise.resolve(); - // Yield with large values to trigger formatBytes unit conversion - yield { - status: 'downloading', - digest: 'abc', - total: 5 * 1024 * 1024 * 1024, // 5 GB - completed: 2.5 * 1024 * 1024 * 1024, // 2.5 GB - }; - await new Promise((resolve) => { - finishDownload = resolve; - }); - }, - }); - - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('qwen2.5-coder:7b'); - await time.tick(10); - - expect(lastFrame()).toContain('downloading'); - expect(lastFrame()).toContain('GB'); - - finishDownload?.(); - await time.tick(10); - }); - it('cancels active download with Escape key from downloading view', async () => { const abortMock = vi.fn(); let finishDownload: (() => void) | undefined; @@ -673,7 +572,7 @@ describe('ModelManager', () => { }, }); - const { lastFrame, stdin } = render( + const { stdin } = render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('qwen2.5-coder:7b'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - expect(lastFrame()).toContain('Downloading model'); + getLastDownloadViewProps().onChange('qwen2.5-coder:7b'); + await time.tick(20); - // Press Escape to cancel download stdin.write('\x1B\x1B'); await time.tick(20); @@ -701,9 +594,7 @@ describe('ModelManager', () => { finishDownload?.(); }); - }); - describe('custom download', () => { it('shows a validation error for blank custom downloads', async () => { const { lastFrame } = render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('custom'); - await time.tick(10); + getLastDownloadViewProps().onChange('custom'); + await time.tick(20); - const textInputProps = getLastTextInputProps(); - textInputProps.onSubmit(''); + getLastCustomDownloadViewProps().onSubmit(''); await time.tick(10); - expect(lastFrame()).toContain('Enter an Ollama model name to download.'); + expect(lastFrame()).toContain('CustomDownloadView'); }); it('shows info notice when downloading an already installed model', async () => { @@ -739,18 +626,14 @@ describe('ModelManager', () => { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('custom'); - await time.tick(10); + getLastDownloadViewProps().onChange('custom'); + await time.tick(20); - const textInputProps = getLastTextInputProps(); - textInputProps.onSubmit('gemma4'); // Already installed + getLastCustomDownloadViewProps().onSubmit('gemma4'); await time.tick(10); expect(lastFrame()).toContain('gemma4 is already installed'); @@ -777,47 +660,34 @@ describe('ModelManager', () => { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('custom'); - await time.tick(10); + getLastDownloadViewProps().onChange('custom'); + await time.tick(20); - const textInputProps = getLastTextInputProps(); - textInputProps.onSubmit('newmodel'); + getLastCustomDownloadViewProps().onSubmit('newmodel'); await time.tick(10); - expect(lastFrame()).toContain('Error downloading model'); + expect(lastFrame()).toContain('CustomDownloadView'); expect(lastFrame()).toContain('Network failure'); }); - it('selects from ModelSuggestions in custom download', async () => { - const { lastFrame } = render( - , - ); - - await time.tick(10); + it('uses highlightedSuggestion instead of typed value on submit', async () => { + await openDownloadView(); - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + getLastDownloadViewProps().onChange('custom'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('custom'); + getLastCustomDownloadViewProps().onHighlight('gemma:latest'); + getLastCustomDownloadViewProps().onSubmit('gemma:latest'); await time.tick(10); - expect(lastFrame()).toContain('Suggestions:'); + expect(mockPullModel).toHaveBeenCalledWith('gemma:latest'); }); - it('selects a suggestion from ModelSuggestions in custom download view', async () => { + it('updates the custom download draft when a suggestion is selected', async () => { const { lastFrame } = render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('custom'); - await time.tick(10); + getLastDownloadViewProps().onChange('custom'); + await time.tick(20); - // Simulate typing a value with a colon so the mock ModelSuggestions triggers onSelect - const textInputProps = getLastTextInputProps(); - textInputProps.onChange('gemma:latest'); - await time.tick(10); + getLastCustomDownloadViewProps().onSelectSuggestion('gemma:latest'); + await time.tick(20); - // The mock ModelSuggestions fires onSelect when input includes ':' - // This exercises the onSelectSuggestion handler which sets downloadDraft + highlightedSuggestion expect(lastFrame()).toContain('gemma:latest'); }); - it('uses highlightedSuggestion instead of typed value on submit', async () => { - const { lastFrame } = render( + it('returns to download menu from custom download with Escape', async () => { + const { lastFrame, stdin } = render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('custom'); - await time.tick(10); + getLastDownloadViewProps().onChange('custom'); + await time.tick(20); - // Type a value with ':' so the mock triggers onSelect (sets highlightedSuggestion) - const textInputProps = getLastTextInputProps(); - textInputProps.onChange('gemma:latest'); - await time.tick(10); + expect(lastFrame()).toContain('CustomDownloadView'); - // Submit triggers pull with the highlighted suggestion - const updatedTextInputProps = getLastTextInputProps(); - updatedTextInputProps.onSubmit('gemma:latest'); - await time.tick(10); + stdin.write('\x1B\x1B'); + await time.tick(20); - expect(mockPullModel).toHaveBeenCalledWith('gemma:latest'); - expect(lastFrame()).toContain('gemma:latest downloaded successfully'); + expect(lastFrame()).toContain('DownloadView'); }); - it('returns to download menu from custom download with Escape', async () => { + it('returns to download menu from custom download with Ctrl+C', async () => { const { lastFrame, stdin } = render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('download'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('custom'); - await time.tick(10); + getLastDownloadViewProps().onChange('custom'); + await time.tick(20); - expect(getLastTextInputProps().placeholder).toBe('name:tag'); + expect(lastFrame()).toContain('CustomDownloadView'); - stdin.write('\x1B\x1B'); + stdin.write('\x03'); await time.tick(20); - expect(lastFrame()).toContain('Choose a model to download'); + expect(lastFrame()).toContain('DownloadView'); }); + }); - it('returns to download menu from custom download with Ctrl+C', async () => { - const { lastFrame, stdin } = render( + describe('delete orchestration', () => { + async function openDeleteView() { + render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('download'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('custom'); - await time.tick(10); - - expect(getLastTextInputProps().placeholder).toBe('name:tag'); - - stdin.write('\x03'); await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('delete'); + await time.tick(20); + } - expect(lastFrame()).toContain('Choose a model to download'); - }); - }); - - describe('delete view', () => { - it('does not offer the current model for deletion and confirms deleting another model', async () => { + it('deletes another model and returns to delete view with a success notice', async () => { const { lastFrame } = render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('delete'); + await time.tick(20); - props = getLastSelectProps(); - expect(props.options.map((option) => option.value)).not.toContain( - 'gemma4', - ); - expect(lastFrame()).toContain('current model gemma4 cannot be deleted'); + getLastDeleteViewProps().onSelect('llama3'); + await time.tick(20); - props.onChange?.('llama3'); - await time.tick(10); - expect(lastFrame()).toContain('Delete model'); - expect(lastFrame()).toContain('Delete model llama3'); + expect(lastFrame()).toContain('DeleteConfirmView'); - props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); + await getLastDeleteConfirmViewProps().onConfirm('delete'); + await time.tick(20); expect(mockDeleteModel).toHaveBeenCalledWith('llama3'); + expect(lastFrame()).toContain('DeleteView'); expect(lastFrame()).toContain('llama3 deleted successfully'); }); @@ -977,32 +807,22 @@ describe('ModelManager', () => { }), ); - render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); + await openDeleteView(); - props = getLastSelectProps(); - props.onChange?.('llama3'); - await time.tick(10); + getLastDeleteViewProps().onSelect('llama3'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('delete'); - props.onChange?.('delete'); - await time.tick(10); + const onConfirm = getLastDeleteConfirmViewProps().onConfirm; + const first = onConfirm('delete'); + await time.tick(); + const second = onConfirm('delete'); + await time.tick(20); expect(mockDeleteModel).toHaveBeenCalledTimes(1); resolveDelete?.(); + await first; + await second; await time.tick(10); }); @@ -1017,113 +837,21 @@ describe('ModelManager', () => { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('delete'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('llama3'); - await time.tick(10); + getLastDeleteViewProps().onSelect('llama3'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); + await getLastDeleteConfirmViewProps().onConfirm('delete'); + await time.tick(20); - expect(lastFrame()).toContain('Error deleting model'); + expect(lastFrame()).toContain('DeleteView'); expect(lastFrame()).toContain('Delete failed'); }); - it('shows loading spinner when navigating to delete view while models are loading', async () => { - let resolveList: ((models: string[]) => void) | undefined; - mockListModels.mockReturnValueOnce( - new Promise((resolve) => { - resolveList = resolve; - }), - ); - - const { lastFrame } = render( - , - ); - - await time.tick(10); - - const props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); - - expect(lastFrame()).toContain('Loading models'); - - resolveList?.(['gemma4', 'llama3']); - await time.tick(10); - }); - - it('shows deleting spinner while deletion is in progress', async () => { - let resolveDelete: (() => void) | undefined; - mockDeleteModel.mockReturnValueOnce( - new Promise((resolve) => { - resolveDelete = resolve; - }), - ); - - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('llama3'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); - - expect(lastFrame()).toContain('Deleting model llama3'); - - resolveDelete?.(); - await time.tick(10); - }); - - it('goes back from delete view to menu', async () => { - const { lastFrame } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('back'); - await time.tick(10); - - expect(lastFrame()).toContain('Switch model'); - }); - }); - - describe('delete confirm', () => { - it('handles delete confirm with back action', async () => { + it('returns to the delete list when backing out of delete confirm', async () => { const { lastFrame } = render( { />, ); - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); - - props = getLastSelectProps(); - props.onChange?.('llama3'); - await time.tick(10); - - expect(lastFrame()).toContain('Delete model llama3'); - - // Select "No" (back action) - props = getLastSelectProps(); - props.onChange?.('back'); - await time.tick(10); - - expect(lastFrame()).toContain('Delete an installed model'); - }); - - it('cancels from delete confirm returns to delete list', async () => { - const { lastFrame, stdin } = render( - , - ); - - await time.tick(10); - - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('delete'); + await time.tick(20); - props = getLastSelectProps(); - props.onChange?.('llama3'); - await time.tick(10); + getLastDeleteViewProps().onSelect('llama3'); + await time.tick(20); - expect(lastFrame()).toContain('Delete model llama3'); + expect(lastFrame()).toContain('DeleteConfirmView'); - // Press Escape to cancel - stdin.write('\x1B\x1B'); + await getLastDeleteConfirmViewProps().onConfirm('back'); await time.tick(20); - expect(lastFrame()).toContain('Delete an installed model'); + expect(lastFrame()).toContain('DeleteView'); }); - it('returns to delete list when confirming delete without candidate', async () => { + it('cancels from delete confirm and returns to the delete list', async () => { const { lastFrame } = render( { />, ); - await time.tick(10); - - // Enter delete view - let props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); - - // Go back to menu first - props = getLastSelectProps(); - props.onCancel?.(); - await time.tick(10); + await time.tick(20); + getLastSelectProps(mockSelect).onChange?.('delete'); + await time.tick(20); - // Now directly trigger delete confirm with back action - props = getLastSelectProps(); - props.onChange?.('delete'); - await time.tick(10); + getLastDeleteViewProps().onSelect('llama3'); + await time.tick(20); - // Select a model to delete - props = getLastSelectProps(); - props.onChange?.('llama3'); - await time.tick(10); + expect(lastFrame()).toContain('DeleteConfirmView'); - // Cancel from confirm - props = getLastSelectProps(); - props.onCancel?.(); - await time.tick(10); + getLastDeleteConfirmViewProps().onCancel(); + await time.tick(20); - expect(lastFrame()).toContain('Delete an installed model'); + expect(lastFrame()).toContain('DeleteView'); }); }); }); diff --git a/src/components/ModelManager/ModelSwitchView.test.tsx b/src/components/ModelManager/ModelSwitchView.test.tsx new file mode 100644 index 00000000..a38c400c --- /dev/null +++ b/src/components/ModelManager/ModelSwitchView.test.tsx @@ -0,0 +1,115 @@ +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; + +import { time } from '@/utils'; + +import { getLastSelectProps, type MockSelectProps } from './test-utils'; + +const { mockSelect } = vi.hoisted(() => ({ + mockSelect: vi.fn<(props: MockSelectProps) => void>(), +})); + +vi.mock('@inkjs/ui', () => ({ + Spinner: ({ label }: { label?: string }) => {`⏳${label ?? ''}`}, +})); + +vi.mock('../SelectPrompt', () => ({ + SelectPrompt: ({ + children, + ...props + }: MockSelectProps & { children?: React.ReactNode }) => { + mockSelect(props); + return ( + <> + {children} + {props.options.map(({ label, value }) => ( + {label} + ))} + + ); + }, + SelectPromptHint: ({ message }: { message?: string }) => ( + {message} + ), +})); + +import { ModelSwitchView } from './ModelSwitchView'; + +describe('ModelSwitchView', () => { + beforeEach(() => { + mockSelect.mockReset(); + }); + + it('renders installed model options with Back', async () => { + const { lastFrame } = render( + , + ); + + await time.tick(10); + + expect(lastFrame()).toContain('gemma4'); + expect(lastFrame()).toContain('llama3'); + expect(lastFrame()).toContain('Back'); + }); + + it('calls onSelect for a selected model', async () => { + const onSelect = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('llama3'); + + expect(onSelect).toHaveBeenCalledWith('llama3'); + }); + + it('calls onCancel when back is selected', async () => { + const onCancel = vi.fn(); + + render( + , + ); + + await time.tick(10); + + getLastSelectProps(mockSelect).onChange?.('back'); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('renders a spinner while loading', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Loading models'); + expect(mockSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ModelManager/test-utils.tsx b/src/components/ModelManager/test-utils.tsx new file mode 100644 index 00000000..fd2d768a --- /dev/null +++ b/src/components/ModelManager/test-utils.tsx @@ -0,0 +1,33 @@ +// v8 ignore start +export interface MockSelectProps { + options: { label: string; value: string }[]; + onChange?: (value: string) => void; + onCancel?: () => void; +} + +export interface MockTextInputProps { + value: string; + placeholder?: string; + onChange: (value: string) => void; + onSubmit: (value: string) => void; +} + +export function getLastSelectProps( + mockSelect: ReturnType void>>, +): MockSelectProps { + const [props] = mockSelect.mock.calls.at(-1) ?? []; + if (!props) { + throw new Error('Expected Select props to be defined.'); + } + return props; +} + +export function getLastTextInputProps( + mockTextInput: ReturnType void>>, +): MockTextInputProps { + const [props] = mockTextInput.mock.calls.at(-1) ?? []; + if (!props) { + throw new Error('Expected TextInput props to be defined.'); + } + return props; +} diff --git a/src/components/Suggestions/Suggestions.test.tsx b/src/components/Suggestions/Suggestions.test.tsx index 6bd3acf5..14485560 100644 --- a/src/components/Suggestions/Suggestions.test.tsx +++ b/src/components/Suggestions/Suggestions.test.tsx @@ -56,6 +56,37 @@ describe('Suggestions', () => { }); }); + it('moves focus up and selects with Enter', async () => { + const onHighlight = vi.fn(); + const onSelect = vi.fn(); + const { stdin } = render( + , + ); + + stdin.write(KEY.DOWN); + await time.tick(); + stdin.write(KEY.UP); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + + expect(onHighlight).toHaveBeenLastCalledWith({ + label: 'alpha', + value: 'alpha', + }); + expect(onSelect).toHaveBeenCalledWith({ + label: 'alpha', + value: 'alpha', + }); + }); + it('returns null when options are empty', () => { const { lastFrame } = render( , @@ -64,6 +95,34 @@ describe('Suggestions', () => { expect(lastFrame()).toBe(''); }); + it('ignores keyboard input while disabled', async () => { + const onHighlight = vi.fn(); + const onSelect = vi.fn(); + const { stdin } = render( + , + ); + + await time.tick(); + stdin.write(KEY.DOWN); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + + expect(onHighlight).toHaveBeenLastCalledWith({ + label: 'alpha', + value: 'alpha', + }); + expect(onSelect).not.toHaveBeenCalled(); + }); + it('calls onHighlight with null and resets focus when options become empty', async () => { const onHighlight = vi.fn(); const { rerender } = render(