cc-haha/desktop/src/components/controls/ModelSelector.test.tsx
程序员阿江(Relakkes) 1f9af7e578 feat: scope desktop reasoning effort to sessions
Desktop reasoning effort now follows the selected session runtime instead of the General settings surface. Session metadata records provider, model, and effort so reconnects and prewarm restarts preserve the per-session runtime, while the default effort is max for new sessions.

Constraint: Existing transcript and localStorage runtime data must remain readable without a required migration.
Rejected: Keep effort as a General setting | it made one session change leak into every other session.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce global desktop effort controls without proving multi-session isolation.
Tested: bun run check:server
Tested: bun run check:desktop
Tested: bun run check:native
Tested: bun run check:policy
Tested: bun run check:coverage
Tested: cd desktop && bun run test -- src/components/controls/ModelSelector.test.tsx src/stores/chatStore.test.ts
Tested: bun test src/server/__tests__/settings.test.ts --test-name-pattern "effort"
Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "runtime"
Not-tested: Live provider smoke for domestic providers without local credentials.
2026-05-31 17:42:30 +08:00

326 lines
9.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { ModelSelector } from './ModelSelector'
import { useChatStore } from '../../stores/chatStore'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useProviderStore } from '../../stores/providerStore'
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { OPENAI_OFFICIAL_PROVIDER_ID } from '../../constants/openaiOfficialProvider'
import type { ModelInfo } from '../../types/settings'
const MODELS: ModelInfo[] = [
{ id: 'alpha', name: 'Alpha', description: 'Fast model', context: '128k' },
{ id: 'beta', name: 'Beta', description: 'Careful model', context: '200k' },
]
async function clickByRole(name: RegExp | string) {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name }))
await Promise.resolve()
})
}
afterEach(() => {
cleanup()
useSettingsStore.setState(useSettingsStore.getInitialState(), true)
useProviderStore.setState(useProviderStore.getInitialState(), true)
useSessionRuntimeStore.setState(useSessionRuntimeStore.getInitialState(), true)
useChatStore.setState(useChatStore.getInitialState(), true)
useHahaOAuthStore.setState(useHahaOAuthStore.getInitialState(), true)
useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true)
})
// Prevent real API calls from fetchStatus on mount
beforeEach(() => {
useHahaOAuthStore.setState({ fetchStatus: async () => {} })
useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} })
})
describe('ModelSelector', () => {
it('uses controlled model selection without mutating settings directly', async () => {
const onChange = vi.fn()
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
})
render(<ModelSelector value="alpha" onChange={onChange} />)
await clickByRole(/alpha/i)
await clickByRole(/Beta/)
expect(onChange).toHaveBeenCalledWith('beta')
})
it('routes uncontrolled model changes through settings actions', async () => {
const setModel = vi.fn(async () => {})
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
effortLevel: 'max',
setModel,
})
render(<ModelSelector />)
await clickByRole(/alpha/i)
await clickByRole(/Beta/)
expect(setModel).toHaveBeenCalledWith('beta')
})
it('selects provider-scoped runtime models and mirrors session selections', async () => {
const setSessionRuntime = vi.fn()
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
activeProviderName: 'Provider A',
})
useProviderStore.setState({
providers: [{
id: 'provider-a',
presetId: 'custom',
name: 'Provider A',
apiKey: '***',
baseUrl: 'https://api.example.com',
apiFormat: 'anthropic',
models: {
main: 'provider-main',
haiku: 'provider-fast',
sonnet: 'provider-main',
opus: '',
},
}],
activeId: 'provider-a',
hasLoadedProviders: true,
isLoading: true,
})
useChatStore.setState({
setSessionRuntime,
} as Partial<ReturnType<typeof useChatStore.getState>>)
render(<ModelSelector runtimeKey="session-1" />)
await clickByRole(/alpha/i)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /provider-fast/ }))
await Promise.resolve()
})
expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({
providerId: 'provider-a',
modelId: 'provider-fast',
effortLevel: 'max',
})
expect(setSessionRuntime).toHaveBeenCalledWith('session-1', {
providerId: 'provider-a',
modelId: 'provider-fast',
effortLevel: 'max',
})
})
it('keeps runtime effort scoped to the selected session', async () => {
const setSessionRuntime = vi.fn()
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
activeProviderName: 'Provider A',
effortLevel: 'max',
})
useProviderStore.setState({
providers: [{
id: 'provider-a',
presetId: 'custom',
name: 'Provider A',
apiKey: '***',
baseUrl: 'https://api.example.com',
apiFormat: 'anthropic',
models: {
main: 'provider-main',
haiku: 'provider-fast',
sonnet: 'provider-main',
opus: '',
},
}],
activeId: 'provider-a',
hasLoadedProviders: true,
isLoading: true,
})
useSessionRuntimeStore.getState().setSelection('session-2', {
providerId: 'provider-a',
modelId: 'provider-main',
effortLevel: 'max',
})
useChatStore.setState({
setSessionRuntime,
} as Partial<ReturnType<typeof useChatStore.getState>>)
render(<ModelSelector runtimeKey="session-1" />)
await clickByRole(/alpha/i)
await clickByRole(/^High$/)
expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({
providerId: 'provider-a',
modelId: 'alpha',
effortLevel: 'high',
})
expect(useSessionRuntimeStore.getState().selections['session-2']).toEqual({
providerId: 'provider-a',
modelId: 'provider-main',
effortLevel: 'max',
})
expect(setSessionRuntime).toHaveBeenCalledWith('session-1', {
providerId: 'provider-a',
modelId: 'alpha',
effortLevel: 'high',
})
expect(useSettingsStore.getState().effortLevel).toBe('max')
})
it('uses the ChatGPT Official catalog when that built-in provider is active', async () => {
const openAIModels: ModelInfo[] = [
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
description: 'Best for coding and agentic work',
context: '',
},
{
id: 'gpt-5.5',
name: 'GPT-5.5',
description: 'Latest general-purpose model',
context: '',
},
]
const setSessionRuntime = vi.fn()
useHahaOpenAIOAuthStore.setState({
status: { loggedIn: true, expiresAt: null, email: null, accountId: null },
fetchStatus: async () => {},
})
useSettingsStore.setState({
locale: 'en',
availableModels: openAIModels,
currentModel: openAIModels[0],
activeProviderName: 'ChatGPT Official',
})
useProviderStore.setState({
providers: [],
activeId: OPENAI_OFFICIAL_PROVIDER_ID,
hasLoadedProviders: true,
isLoading: true,
})
useChatStore.setState({
setSessionRuntime,
} as Partial<ReturnType<typeof useChatStore.getState>>)
render(<ModelSelector runtimeKey="session-openai" />)
await clickByRole(/GPT-5\.3 Codex/i)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /GPT-5\.5/ }))
await Promise.resolve()
})
expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({
providerId: OPENAI_OFFICIAL_PROVIDER_ID,
modelId: 'gpt-5.5',
effortLevel: 'max',
})
expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', {
providerId: OPENAI_OFFICIAL_PROVIDER_ID,
modelId: 'gpt-5.5',
effortLevel: 'max',
})
})
it('hides official provider sections when OAuth is not logged in', async () => {
useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
activeProviderName: 'Provider A',
})
useProviderStore.setState({
providers: [{
id: 'provider-a',
presetId: 'custom',
name: 'Provider A',
apiKey: '***',
baseUrl: 'https://api.example.com',
apiFormat: 'anthropic',
models: {
main: 'provider-main',
haiku: '',
sonnet: '',
opus: '',
},
}],
activeId: 'provider-a',
hasLoadedProviders: true,
isLoading: true,
})
render(<ModelSelector runtimeKey="session-hide" />)
await clickByRole(/alpha/i)
const dropdown = screen.getByTestId('model-selector-dropdown')
expect(dropdown.textContent).not.toContain('Claude Official')
expect(dropdown.textContent).not.toContain('ChatGPT Official')
expect(dropdown.textContent).toContain('Provider A')
})
it('portals the dropdown outside clipping containers and positions it below the trigger', async () => {
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
})
const { container } = render(
<div data-testid="scroll-container" className="overflow-hidden">
<ModelSelector value="alpha" onChange={vi.fn()} />
</div>,
)
const trigger = screen.getByRole('button', { name: /alpha/i })
Object.defineProperty(trigger.parentElement, 'getBoundingClientRect', {
configurable: true,
value: () => ({
top: 120,
right: 520,
bottom: 150,
left: 240,
width: 280,
height: 30,
x: 240,
y: 120,
toJSON: () => {},
}),
})
await act(async () => {
fireEvent.click(trigger)
await Promise.resolve()
})
const dropdown = screen.getByTestId('model-selector-dropdown')
expect(container.contains(dropdown)).toBe(false)
expect(document.body.contains(dropdown)).toBe(true)
expect(dropdown.className).toContain('fixed')
expect(dropdown.style.top).toBe('158px')
expect(dropdown.style.left).toBe('160px')
expect(dropdown.style.width).toBe('360px')
})
})