Files
iofficeai--aionui/tests/unit/renderer/hooks/guidModelSelector.dom.test.tsx
2026-07-13 13:20:22 +08:00

211 lines
6.9 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import { fireEvent, render, screen, within } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import GuidModelSelector from '@/renderer/pages/guid/components/GuidModelSelector';
vi.mock('@/renderer/hooks/agent/useModelProviderList', () => ({
useProvidersQuery: () => ({ data: [] }),
}));
vi.mock('@/renderer/utils/model/agentLogo', () => ({
getModelDisplayLabel: ({
selectedLabel,
selected_value,
fallbackLabel,
}: {
selectedLabel?: string;
selected_value?: string | null;
fallbackLabel: string;
}) => selectedLabel || selected_value || fallbackLabel,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'common.defaultModel') return 'Default';
if (key === 'common.model') return 'Model';
if (key === 'conversation.welcome.modelSwitchNotSupported') return 'Model switch is not supported';
if (key === 'agent.thoughtLevel.label') return 'Thinking Level';
return key;
},
}),
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@icon-park/react', () => ({
Brain: () => <span aria-hidden='true'>brain</span>,
Down: () => <span aria-hidden='true'>v</span>,
Plus: () => <span aria-hidden='true'>+</span>,
Search: () => <span aria-hidden='true'>search</span>,
}));
vi.mock('@arco-design/web-react', () => {
const Menu = Object.assign(
({ children, className }: { children?: React.ReactNode; className?: string }) => (
<div data-testid='guid-model-menu' className={className}>
{children}
</div>
),
{
Item: ({
children,
className,
onClick,
}: {
children?: React.ReactNode;
className?: string;
onClick?: () => void;
}) => (
<div role='menuitem' className={className} onClick={onClick}>
{children}
</div>
),
ItemGroup: ({ children, title }: { children?: React.ReactNode; title?: React.ReactNode }) => (
<div role='group' aria-label={String(title)}>
<div>{title}</div>
{children}
</div>
),
// SubMenu renders both its title row and its children so tests can inspect both levels.
SubMenu: ({ children, title }: { children?: React.ReactNode; title?: React.ReactNode }) => (
<div role='group'>
<div data-testid='submenu-title'>{title}</div>
<div data-testid='submenu-body'>{children}</div>
</div>
),
}
);
return {
Button: ({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) => (
<button type='button' {...props}>
{children}
</button>
),
Dropdown: ({ children, droplist }: { children?: React.ReactNode; droplist?: React.ReactNode }) => (
<div>
{children}
{droplist}
</div>
),
Menu,
Tooltip: ({ children, content }: { children?: React.ReactNode; content?: React.ReactNode }) => (
<span data-tooltip-content={typeof content === 'string' ? content : undefined}>{children}</span>
),
};
});
describe('GuidModelSelector', () => {
const thoughtLevelOption = {
id: 'reasoning_effort',
category: 'thought_level',
currentValue: 'medium',
options: [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
],
};
it('shows ACP model descriptions in option tooltips', () => {
render(
<GuidModelSelector
isGeminiMode={false}
modelList={[]}
current_model={undefined}
setCurrentModel={vi.fn()}
currentAcpCachedModelInfo={{
current_model_id: 'default',
current_model_label: 'Default',
available_models: [
{
id: 'default',
label: 'Default',
description: 'Use the default model currently configured by the CLI',
},
],
}}
selectedAcpModel='default'
setSelectedAcpModel={vi.fn()}
/>
);
expect(screen.queryByText('Use the default model currently configured by the CLI')).not.toBeInTheDocument();
expect(
within(screen.getByTestId('guid-model-menu')).getByText('Default').closest('[data-tooltip-content]')
).toHaveAttribute('data-tooltip-content', 'Use the default model currently configured by the CLI');
});
it('splits ACP model and thought level into two submenus', () => {
const setSelectedAcpModel = vi.fn();
const onThoughtLevelSelect = vi.fn();
render(
<GuidModelSelector
isGeminiMode={false}
modelList={[]}
current_model={undefined}
setCurrentModel={vi.fn()}
currentAcpCachedModelInfo={{
current_model_id: 'gpt-5.3-codex',
current_model_label: 'gpt-5.3-codex',
available_models: [
{ id: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
{ id: 'gpt-5.4-codex', label: 'gpt-5.4-codex' },
],
}}
selectedAcpModel='gpt-5.3-codex'
setSelectedAcpModel={setSelectedAcpModel}
thoughtLevelOption={thoughtLevelOption}
onThoughtLevelSelect={onThoughtLevelSelect}
/>
);
expect(screen.getByText('gpt-5.3-codex · Medium')).toBeInTheDocument();
// First level: model submenu on top (shows current model), thought submenu below.
const titles = screen.getAllByTestId('submenu-title');
expect(titles[0]).toHaveTextContent('Model');
expect(titles[0]).toHaveTextContent('gpt-5.3-codex');
expect(titles[1]).toHaveTextContent('Thinking Level');
expect(titles[1]).toHaveTextContent('Medium');
// Second level: each submenu body holds the full option list.
const bodies = screen.getAllByTestId('submenu-body');
fireEvent.click(within(bodies[0]).getByText('gpt-5.4-codex'));
fireEvent.click(within(bodies[1]).getByText('High'));
expect(setSelectedAcpModel).toHaveBeenCalledWith('gpt-5.4-codex');
expect(onThoughtLevelSelect).toHaveBeenCalledWith('high');
});
it('does not add thought level options to the Aion CLI provider model menu', () => {
render(
<GuidModelSelector
isGeminiMode
modelList={[{ id: 'openai', name: 'OpenAI', enabled: true, models: ['gpt-5.3-codex'] } as any]}
current_model={{ id: 'openai', name: 'OpenAI', models: ['gpt-5.3-codex'], use_model: 'gpt-5.3-codex' } as any}
setCurrentModel={vi.fn()}
currentAcpCachedModelInfo={null}
selectedAcpModel={null}
setSelectedAcpModel={vi.fn()}
thoughtLevelOption={thoughtLevelOption}
onThoughtLevelSelect={vi.fn()}
/>
);
expect(screen.getAllByText('gpt-5.3-codex').length).toBeGreaterThan(0);
expect(screen.queryByText('Thinking Level')).not.toBeInTheDocument();
expect(screen.queryByText('Medium')).not.toBeInTheDocument();
});
});