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(