cba0948604
# Conflicts: # .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml # .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml # apps/cli/src/web.ts # apps/web/tests/session-title.snapshot.ts # apps/web/tests/smoke-fixture.e2e.ts # packages/client/connection/src/client/api.ts # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.module.css # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/skeleton-branches.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/host/apiproxy/README.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.schema.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/host/runtime/README.md # packages/llm/llm-deepseek/README.md # scripts/translation-pairing.manifest.json # tsconfig.client.json
221 lines
9.1 KiB
TypeScript
221 lines
9.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* Provider-grouped selector behavior: compact model-only trigger, grouped
|
|
* radio menu, retry/error states, successful and failed selection, outside
|
|
* dismissal, and keyboard focus navigation.
|
|
*/
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
ConversationSnapshot, ModelSelectionSnapshot,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
|
import type { ModelSelectorProps } from '../src/client/contract.ts'
|
|
import { ModelSelector } from '../src/client/ModelSelector.tsx'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const ready: ModelSelectionSnapshot = {
|
|
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
|
groups: [
|
|
{
|
|
id: 'deepseek',
|
|
name: 'DeepSeek',
|
|
models: [
|
|
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
|
|
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
|
|
],
|
|
},
|
|
{
|
|
id: 'openai',
|
|
name: 'OpenAI',
|
|
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
|
},
|
|
],
|
|
failures: [],
|
|
status: 'ready',
|
|
error: null,
|
|
}
|
|
|
|
function setup(
|
|
selection: ModelSelectionSnapshot = ready,
|
|
removed = false,
|
|
intent: ConversationSnapshot['intent'] = null,
|
|
) {
|
|
let current = { modelSelection: selection, removed, intent } as unknown as ConversationSnapshot
|
|
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
|
|
selector(current)) as ModelSelectorProps['useSession']
|
|
const refreshModels = vi.fn()
|
|
const retryModelOperation = vi.fn(() => Promise.resolve(false))
|
|
const selectModel = vi.fn((_target: ModelTarget) => Promise.resolve(true))
|
|
const props: ModelSelectorProps = {
|
|
sessionId: 'selector-session' as never,
|
|
useSession,
|
|
useSessions: ((selector: (snapshot: never) => unknown) =>
|
|
selector({} as never)) as ModelSelectorProps['useSessions'],
|
|
useWorkspaces: ((selector: (snapshot: never) => unknown) =>
|
|
selector({} as never)) as ModelSelectorProps['useWorkspaces'],
|
|
refreshModels,
|
|
retryModelOperation,
|
|
selectModel,
|
|
}
|
|
const view = render(<ModelSelector {...props} />)
|
|
return {
|
|
view,
|
|
refreshModels,
|
|
retryModelOperation,
|
|
selectModel,
|
|
update(
|
|
next: ModelSelectionSnapshot,
|
|
nextRemoved = removed,
|
|
nextIntent: ConversationSnapshot['intent'] = intent,
|
|
) {
|
|
current = {
|
|
modelSelection: next,
|
|
removed: nextRemoved,
|
|
intent: nextIntent,
|
|
} as unknown as ConversationSnapshot
|
|
view.rerender(<ModelSelector {...props} />)
|
|
},
|
|
}
|
|
}
|
|
|
|
function trigger(): HTMLButtonElement {
|
|
return screen.getByRole('button', { name: /选择模型,当前/ })
|
|
}
|
|
|
|
describe('model selector', () => {
|
|
it('shows the catalog name, opens upward into provider groups, and marks the current radio item', () => {
|
|
const { refreshModels } = setup()
|
|
expect(refreshModels).toHaveBeenCalledTimes(1)
|
|
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
|
|
expect(trigger().textContent).not.toContain('deepseek/')
|
|
expect(trigger().title).toBe('DeepSeek-V4-Flash')
|
|
|
|
fireEvent.click(trigger())
|
|
expect(refreshModels).toHaveBeenCalledTimes(2)
|
|
expect(screen.getByRole('menu', { name: '模型' })).toBeTruthy()
|
|
expect(screen.getAllByRole('group')).toHaveLength(2)
|
|
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
|
expect(screen.getByText('OpenAI')).toBeTruthy()
|
|
const rows = screen.getAllByRole('menuitemradio')
|
|
expect(rows.map(row => row.querySelector('[class*="modelName"]')?.textContent))
|
|
.toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'GPT-5'])
|
|
expect(rows[0]?.getAttribute('aria-checked')).toBe('true')
|
|
expect(rows[1]?.getAttribute('aria-checked')).toBe('false')
|
|
expect(rows.some(row => row.textContent?.includes('deepseek/deepseek'))).toBe(false)
|
|
})
|
|
|
|
it('keeps the menu open on failure, closes after success, and closes current selection without an RPC', async () => {
|
|
const { selectModel } = setup()
|
|
selectModel.mockResolvedValueOnce(false)
|
|
fireEvent.click(trigger())
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
|
|
await waitFor(() => { expect(selectModel).toHaveBeenCalledWith({ provider: 'deepseek', model: 'deepseek-v4-pro' }) })
|
|
expect(screen.getByRole('menu')).toBeTruthy()
|
|
|
|
selectModel.mockResolvedValueOnce(true)
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
|
|
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
|
|
|
fireEvent.click(trigger())
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Flash/ }))
|
|
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
|
expect(selectModel).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('renders loading, empty, partial-provider, operation-error, and unavailable-current states with retries', () => {
|
|
const error = { code: 'internal' as const, message: 'wire down', details: {} }
|
|
const { refreshModels, retryModelOperation, update } = setup({
|
|
...ready,
|
|
current: { provider: 'missing', model: 'private-preview-with-a-very-long-name' },
|
|
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
|
|
status: 'error',
|
|
error,
|
|
})
|
|
expect(trigger().textContent).toBe('private-preview-with-a-very-long-name')
|
|
fireEvent.click(trigger())
|
|
expect(screen.getByText(/模型操作失败:wire down/)).toBeTruthy()
|
|
expect(screen.getByText(/Offline 加载失败:catalog down/)).toBeTruthy()
|
|
expect(screen.getByText(/当前提供方 missing 未注册/)).toBeTruthy()
|
|
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[0]!)
|
|
expect(retryModelOperation).toHaveBeenCalledTimes(1)
|
|
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[1]!)
|
|
expect(refreshModels).toHaveBeenCalledTimes(3)
|
|
|
|
update({ current: null, groups: [], failures: [], status: 'loading', error: null })
|
|
expect(screen.getByText('正在刷新模型列表…')).toBeTruthy()
|
|
update({ current: null, groups: [], failures: [], status: 'ready', error: null })
|
|
expect(screen.getByText('没有可用的模型。')).toBeTruthy()
|
|
})
|
|
|
|
it('closes after retrying a failed selection successfully', async () => {
|
|
const { retryModelOperation } = setup({
|
|
...ready,
|
|
status: 'error',
|
|
error: {
|
|
code: 'model-unavailable',
|
|
message: 'temporary failure',
|
|
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
|
},
|
|
})
|
|
retryModelOperation.mockResolvedValueOnce(true)
|
|
fireEvent.click(trigger())
|
|
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
|
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
|
})
|
|
|
|
it('supports Arrow/Home/End/Escape navigation and restores focus to the trigger', async () => {
|
|
setup()
|
|
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
|
|
await waitFor(() => {
|
|
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
|
|
})
|
|
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' })
|
|
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[1])
|
|
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
|
|
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
|
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
|
|
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
|
|
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp' })
|
|
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
|
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Escape' })
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('menu')).toBeNull()
|
|
expect(document.activeElement).toBe(trigger())
|
|
})
|
|
})
|
|
|
|
it('opens ArrowUp on the last option, disables rows while selecting, and dismisses outside', async () => {
|
|
const { update } = setup()
|
|
fireEvent.keyDown(trigger(), { key: 'ArrowUp' })
|
|
await waitFor(() => {
|
|
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
|
})
|
|
update({ ...ready, status: 'selecting' })
|
|
expect(screen.getByRole('menu').getAttribute('aria-busy')).toBe('true')
|
|
expect(screen.getAllByRole('menuitemradio').every(row => (row as HTMLButtonElement).disabled)).toBe(true)
|
|
fireEvent.mouseDown(document.body)
|
|
expect(screen.queryByRole('menu')).toBeNull()
|
|
})
|
|
|
|
it('disables the trigger only when the session is removed', () => {
|
|
setup(ready, true)
|
|
expect(trigger().disabled).toBe(true)
|
|
})
|
|
|
|
it('waits for a frontend Session Intent to publish before rendering or loading models', () => {
|
|
const b = setup(ready, false, {
|
|
target: { kind: 'workspace-intent' },
|
|
phase: 'connecting',
|
|
})
|
|
expect(screen.queryByRole('button', { name: /选择模型,当前/ })).toBeNull()
|
|
expect(b.refreshModels).not.toHaveBeenCalled()
|
|
|
|
b.update(ready, false, null)
|
|
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
|
|
expect(b.refreshModels).toHaveBeenCalledOnce()
|
|
})
|
|
})
|