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()
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()
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>)
render()
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>)
render()
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>)
render()
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()
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(
,
)
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')
})
})