| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- // @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()
- })
- })
|