Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/components/ModelManager/ModelCustomDownloadView.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text>
{props.value === '' ? (props.placeholder ?? '') : props.value}
</Text>
);
},
}));

vi.mock('./ModelSuggestions', () => ({
ModelSuggestions: (props: {
input: string;
onHighlight?: (value: string | null) => void;
onSelect?: (value: string) => void;
}) => {
mockModelSuggestions(props);
return <Text>{`Suggestions:${props.input}`}</Text>;
},
}));

import { ModelCustomDownloadView } from './ModelCustomDownloadView';

describe('ModelCustomDownloadView', () => {
beforeEach(() => {
mockModelSuggestions.mockReset();
mockTextInput.mockReset();
});

it('renders the prompt, placeholder, and suggestions', () => {
const { lastFrame } = render(
<ModelCustomDownloadView
downloadDraft=""
notice={null}
theme={THEME.getTheme()}
onDraftChange={vi.fn()}
onHighlight={vi.fn()}
onSelectSuggestion={vi.fn()}
onSubmit={vi.fn()}
/>,
);

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(
<ModelCustomDownloadView
downloadDraft="gemma4"
notice={{ tone: 'error', text: 'Download failed' }}
theme={THEME.getTheme()}
onDraftChange={vi.fn()}
onHighlight={vi.fn()}
onSelectSuggestion={vi.fn()}
onSubmit={vi.fn()}
/>,
);

expect(lastFrame()).toContain('Download failed');
});

it('forwards TextInput change and submit callbacks', () => {
const onDraftChange = vi.fn();
const onSubmit = vi.fn();

render(
<ModelCustomDownloadView
downloadDraft=""
notice={null}
theme={THEME.getTheme()}
onDraftChange={onDraftChange}
onHighlight={vi.fn()}
onSelectSuggestion={vi.fn()}
onSubmit={onSubmit}
/>,
);

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(
<ModelCustomDownloadView
downloadDraft="gemma"
notice={null}
theme={THEME.getTheme()}
onDraftChange={vi.fn()}
onHighlight={onHighlight}
onSelectSuggestion={onSelectSuggestion}
onSubmit={vi.fn()}
/>,
);

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');
});
});
94 changes: 74 additions & 20 deletions src/components/ModelManager/ModelDeleteConfirmView.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Text key={value}>{label}</Text>
))}
</>
);
},
Spinner: ({ label }: { label?: string }) => (
<Text>{`⏳${label ?? ''}`}</Text>
),
};
});
vi.mock('@inkjs/ui', () => ({
Spinner: ({ label }: { label?: string }) => <Text>{`⏳${label ?? ''}`}</Text>,
}));

vi.mock('../SelectPrompt', () => ({
SelectPrompt: ({
children,
...props
}: MockSelectProps & { children?: React.ReactNode }) => {
mockSelect(props);
return (
<>
{children}
{props.options.map(({ label, value }) => (
<Text key={value}>{label}</Text>
))}
</>
);
},
SelectPromptHint: ({ message }: { message?: string }) => (
<Text>{message}</Text>
),
}));

import { ModelDeleteConfirmView } from './ModelDeleteConfirmView';

describe('ModelDeleteConfirmView', () => {
beforeEach(() => {
mockSelect.mockReset();
});

it('renders confirm options with no notice when notice is null', () => {
const { lastFrame } = render(
<ModelDeleteConfirmView
Expand Down Expand Up @@ -74,4 +86,46 @@ describe('ModelDeleteConfirmView', () => {

expect(lastFrame()).toContain('Something went wrong');
});

it('calls onConfirm when delete is selected', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined);

render(
<ModelDeleteConfirmView
deleteCandidate="llama3"
isDeleting={false}
notice={null}
theme={THEME.getTheme()}
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
);

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(
<ModelDeleteConfirmView
deleteCandidate="llama3"
isDeleting={false}
notice={null}
theme={THEME.getTheme()}
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
);

await time.tick(10);

getLastSelectProps(mockSelect).onChange?.('back');

expect(onConfirm).toHaveBeenCalledWith('back');
});
});
140 changes: 140 additions & 0 deletions src/components/ModelManager/ModelDeleteView.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <Text>{`⏳${label ?? ''}`}</Text>,
}));

vi.mock('../SelectPrompt', () => ({
SelectPrompt: ({
children,
...props
}: MockSelectProps & { children?: React.ReactNode }) => {
mockSelect(props);
return (
<>
{children}
{props.options.map(({ label, value }) => (
<Text key={value}>{label}</Text>
))}
</>
);
},
SelectPromptHint: ({ message }: { message?: string }) => (
<Text>{message}</Text>
),
}));

import { ModelDeleteView } from './ModelDeleteView';

describe('ModelDeleteView', () => {
beforeEach(() => {
mockSelect.mockReset();
});

it('filters out the current model from selectable options', async () => {
const { lastFrame } = render(
<ModelDeleteView
currentModel="gemma4"
installedModels={['gemma4', 'llama3', 'codellama']}
isLoading={false}
notice={null}
theme={THEME.getTheme()}
onCancel={vi.fn()}
onSelect={vi.fn()}
/>,
);

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(
<ModelDeleteView
currentModel="gemma4"
installedModels={['gemma4', 'llama3']}
isLoading={false}
notice={{ tone: 'error', text: 'Delete failed' }}
theme={THEME.getTheme()}
onCancel={vi.fn()}
onSelect={vi.fn()}
/>,
);

expect(lastFrame()).toContain('Delete failed');
});

it('calls onSelect for a selected model', async () => {
const onSelect = vi.fn();

render(
<ModelDeleteView
currentModel="gemma4"
installedModels={['gemma4', 'llama3']}
isLoading={false}
notice={null}
theme={THEME.getTheme()}
onCancel={vi.fn()}
onSelect={onSelect}
/>,
);

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(
<ModelDeleteView
currentModel="gemma4"
installedModels={['gemma4', 'llama3']}
isLoading={false}
notice={null}
theme={THEME.getTheme()}
onCancel={onCancel}
onSelect={vi.fn()}
/>,
);

await time.tick(10);

getLastSelectProps(mockSelect).onChange?.('back');

expect(onCancel).toHaveBeenCalled();
});

it('renders a spinner while loading', () => {
const { lastFrame } = render(
<ModelDeleteView
currentModel="gemma4"
installedModels={[]}
isLoading
notice={null}
theme={THEME.getTheme()}
onCancel={vi.fn()}
onSelect={vi.fn()}
/>,
);

expect(lastFrame()).toContain('Loading models');
expect(mockSelect).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions src/components/ModelManager/ModelDeleteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function ModelDeleteView({
onCancel,
onSelect,
}: Props) {
// v8 ignore next
if (isLoading) {
return <Spinner label="Loading models..." />;
}
Expand Down
Loading