Files
deepseek-harness/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
T
Yif 5813c14dee fix(client): portal composer menus over the sidebar and float toasts near the top
ModelSelect renders its dropdown through a body portal with viewport-clamped
fixed placement, so the panel keeps its full width instead of being cut by the
sidebar edge. The preset seat passes Menu an anchor class with an icon-only
min-width floor and moves its label ellipsis onto a dedicated span, keeping the
chip's overflow degradation breakpoint-free. Toasts move to the viewport top
and shrink to fit their text.
2026-09-04 20:19:42 +08:00

255 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ComponentProps } from 'react'
import type { ModelDirectoryState } from '../src/client/directory.ts'
import { ModelSelect } from '../src/client/ModelSelect.tsx'
import { zh } from '../src/client/locales.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// The seat's key domain is model common; the stub mirrors the real lookup
// chain: package dictionary, then common vocabulary, then the key.
const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
const template = (zh as Record<string, string>)[key]
?? (commonZh as Record<string, string>)[key]
?? key
return params === undefined
? template
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
}
const reasoning = {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max', description: 'Largest budget' },
],
defaultEffort: 'high',
}
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
return {
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
routable: true,
groups: [{
id: 'deepseek-official',
name: 'DeepSeek',
models: [{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: 'Fast catalog description',
reasoning,
}],
}],
failures: [],
status: 'ready',
error: null,
...overrides,
}
}
afterEach(cleanup)
describe('ModelSelect reasoning effort', () => {
it('renders effort names without descriptions and submits the effort as part of the session selection', async () => {
const directory = createSnapshotStore<ModelDirectoryState>(state())
const select = vi.fn(async (selection: ModelSelection) => {
directory.set(state({ current: selection }))
return true
})
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
const trigger = screen.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
})
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Off', 'High', 'Max'])
expect(screen.queryByText('Largest budget')).toBeNull()
fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
await waitFor(() => {
expect(select).toHaveBeenCalledWith({
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
})
expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash,推理等级 Max')
})
})
it('offers provider default only when the adapter does not configure a model default', () => {
const directory = createSnapshotStore(state({
groups: [{
id: 'provider',
name: 'Provider',
models: [{
id: 'model',
name: 'Model',
reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] },
}],
}],
current: { provider: 'provider', model: 'model' },
}))
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
fireEvent.click(screen.getByRole('button', {
name: '选择模型,当前 Model,推理等级 Default',
}))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Default', 'Standard'])
})
it('shows the durable model id when the catalog has no matching display name', () => {
const directory = createSnapshotStore(state({
current: { provider: 'deepseek-official', model: 'removed-model' },
}))
const select = vi.fn().mockResolvedValue(true)
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
const trigger = screen.getByRole('button', { name: '选择模型,当前 deepseek-official/removed-model' })
expect(trigger.textContent).toContain('deepseek-official/removed-model')
fireEvent.click(trigger)
expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
expect(screen.queryByRole('menuitemradio', { name: 'removed-model' })).toBeNull()
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
expect(screen.queryByText('Fast catalog description')).toBeNull()
})
it('shows loading until the catalog and Session projection are both ready', async () => {
const directory = createSnapshotStore<ModelDirectoryState>(state({
current: null,
routable: null,
groups: [],
status: 'loading',
}))
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
expect(screen.getByRole('button', { name: '正在加载模型…' }).textContent)
.toContain('正在加载模型…')
directory.set(state())
await waitFor(() => {
expect(screen.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
})).toBeTruthy()
})
})
it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
const groups = [{
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
],
}]
const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
const select = vi.fn(async () => {
directory.set(state({ groups, status: 'error', error: 'session/model-unavailable: session already contains images' }))
return false
})
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
const toast = await screen.findByRole('alert')
expect(toast.textContent).toContain('模型操作失败:session/model-unavailable: session already contains images')
// The selection failure does not render the in-menu load strip (no Retry).
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
})
it('portals the placed menu card to body and closes only on truly-outside mousedown', () => {
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
try {
const { container } = render(<ModelSelect
locked={false}
available
directory={createSnapshotStore(state())}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
// Outside the composer subtree — column overflow clips cannot crop it.
expect(container.contains(menu)).toBe(false)
expect(menu.parentElement).toBe(document.body)
// jsdom anchor rects are all zero, so the measured 200x300 card clamps
// to the 12px viewport margin on both axes.
expect(menu.style.left).toBe('12px')
expect(menu.style.top).toBe('12px')
// Interactions inside the trigger subtree or the portaled card stay open.
fireEvent.mouseDown(menu)
fireEvent.mouseDown(trigger)
fireEvent.blur(trigger, { relatedTarget: menu })
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.mouseDown(document.body)
expect(screen.queryByRole('menu')).toBeNull()
} finally {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
}
})
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect
locked={false}
available={false}
directory={createSnapshotStore(state())}
load={load}
select={vi.fn().mockResolvedValue(false)}
t={t}
/>)
expect(screen.queryByRole('button')).toBeNull()
expect(load).not.toHaveBeenCalled()
})
})