model-select.client.spec.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
  5. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  6. import type { ComponentProps } from 'react'
  7. import type { ModelDirectoryState } from '../src/client/directory.ts'
  8. import { ModelSelect } from '../src/client/ModelSelect.tsx'
  9. import { zh } from '../src/client/locales.ts'
  10. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  11. // The seat's key domain is model ∪ common; the stub mirrors the real lookup
  12. // chain: package dictionary, then common vocabulary, then the key.
  13. const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
  14. const template = (zh as Record<string, string>)[key]
  15. ?? (commonZh as Record<string, string>)[key]
  16. ?? key
  17. return params === undefined
  18. ? template
  19. : template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
  20. }
  21. const reasoning = {
  22. efforts: [
  23. { id: 'off', name: 'Off' },
  24. { id: 'high', name: 'High' },
  25. { id: 'max', name: 'Max', description: 'Largest budget' },
  26. ],
  27. defaultEffort: 'high',
  28. }
  29. function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
  30. return {
  31. current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  32. routable: true,
  33. groups: [{
  34. id: 'deepseek-official',
  35. name: 'DeepSeek',
  36. models: [{
  37. id: 'deepseek-v4-flash',
  38. name: 'DeepSeek-V4-Flash',
  39. description: 'Fast catalog description',
  40. reasoning,
  41. }],
  42. }],
  43. failures: [],
  44. status: 'ready',
  45. error: null,
  46. ...overrides,
  47. }
  48. }
  49. afterEach(cleanup)
  50. describe('ModelSelect reasoning effort', () => {
  51. it('renders effort names without descriptions and submits the effort as part of the session selection', async () => {
  52. const directory = createSnapshotStore<ModelDirectoryState>(state())
  53. const select = vi.fn(async (selection: ModelSelection) => {
  54. directory.set(state({ current: selection }))
  55. return true
  56. })
  57. render(<ModelSelect
  58. locked={false}
  59. available
  60. directory={directory}
  61. load={vi.fn()}
  62. select={select}
  63. t={t}
  64. />)
  65. const trigger = screen.getByRole('button', {
  66. name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
  67. })
  68. fireEvent.click(trigger)
  69. fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
  70. expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
  71. .toEqual(['Off', 'High', 'Max'])
  72. expect(screen.queryByText('Largest budget')).toBeNull()
  73. fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
  74. await waitFor(() => {
  75. expect(select).toHaveBeenCalledWith({
  76. provider: 'deepseek-official',
  77. model: 'deepseek-v4-flash',
  78. reasoningEffort: 'max',
  79. })
  80. expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash,推理等级 Max')
  81. })
  82. })
  83. it('offers provider default only when the adapter does not configure a model default', () => {
  84. const directory = createSnapshotStore(state({
  85. groups: [{
  86. id: 'provider',
  87. name: 'Provider',
  88. models: [{
  89. id: 'model',
  90. name: 'Model',
  91. reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] },
  92. }],
  93. }],
  94. current: { provider: 'provider', model: 'model' },
  95. }))
  96. render(<ModelSelect
  97. locked={false}
  98. available
  99. directory={directory}
  100. load={vi.fn()}
  101. select={vi.fn().mockResolvedValue(true)}
  102. t={t}
  103. />)
  104. fireEvent.click(screen.getByRole('button', {
  105. name: '选择模型,当前 Model,推理等级 Default',
  106. }))
  107. fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
  108. expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
  109. .toEqual(['Default', 'Standard'])
  110. })
  111. it('shows the durable model id when the catalog has no matching display name', () => {
  112. const directory = createSnapshotStore(state({
  113. current: { provider: 'deepseek-official', model: 'removed-model' },
  114. }))
  115. const select = vi.fn().mockResolvedValue(true)
  116. render(<ModelSelect
  117. locked={false}
  118. available
  119. directory={directory}
  120. load={vi.fn()}
  121. select={select}
  122. t={t}
  123. />)
  124. const trigger = screen.getByRole('button', { name: '选择模型,当前 deepseek-official/removed-model' })
  125. expect(trigger.textContent).toContain('deepseek-official/removed-model')
  126. fireEvent.click(trigger)
  127. expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
  128. fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
  129. expect(screen.queryByRole('menuitemradio', { name: 'removed-model' })).toBeNull()
  130. expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
  131. expect(screen.queryByText('Fast catalog description')).toBeNull()
  132. })
  133. it('shows loading until the catalog and Session projection are both ready', async () => {
  134. const directory = createSnapshotStore<ModelDirectoryState>(state({
  135. current: null,
  136. routable: null,
  137. groups: [],
  138. status: 'loading',
  139. }))
  140. render(<ModelSelect
  141. locked={false}
  142. available
  143. directory={directory}
  144. load={vi.fn()}
  145. select={vi.fn().mockResolvedValue(true)}
  146. t={t}
  147. />)
  148. expect(screen.getByRole('button', { name: '正在加载模型…' }).textContent)
  149. .toContain('正在加载模型…')
  150. directory.set(state())
  151. await waitFor(() => {
  152. expect(screen.getByRole('button', {
  153. name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
  154. })).toBeTruthy()
  155. })
  156. })
  157. it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
  158. const groups = [{
  159. id: 'deepseek-official',
  160. name: 'DeepSeek',
  161. models: [
  162. { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
  163. { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
  164. ],
  165. }]
  166. const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
  167. const select = vi.fn(async () => {
  168. directory.set(state({ groups, status: 'error', error: 'session/model-unavailable: session already contains images' }))
  169. return false
  170. })
  171. render(<ModelSelect
  172. locked={false}
  173. available
  174. directory={directory}
  175. load={vi.fn()}
  176. select={select}
  177. t={t}
  178. />)
  179. fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
  180. fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
  181. fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
  182. const toast = await screen.findByRole('alert')
  183. expect(toast.textContent).toContain('模型操作失败:session/model-unavailable: session already contains images')
  184. // The selection failure does not render the in-menu load strip (no Retry).
  185. expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
  186. })
  187. it('portals the placed menu card to body and closes only on truly-outside mousedown', () => {
  188. const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
  189. const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
  190. Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
  191. Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
  192. try {
  193. const { container } = render(<ModelSelect
  194. locked={false}
  195. available
  196. directory={createSnapshotStore(state())}
  197. load={vi.fn()}
  198. select={vi.fn().mockResolvedValue(true)}
  199. t={t}
  200. />)
  201. const trigger = screen.getByRole('button', { name: /选择模型/ })
  202. fireEvent.click(trigger)
  203. const menu = screen.getByRole('menu')
  204. // Outside the composer subtree — column overflow clips cannot crop it.
  205. expect(container.contains(menu)).toBe(false)
  206. expect(menu.parentElement).toBe(document.body)
  207. // jsdom anchor rects are all zero, so the measured 200x300 card clamps
  208. // to the 12px viewport margin on both axes.
  209. expect(menu.style.left).toBe('12px')
  210. expect(menu.style.top).toBe('12px')
  211. // Interactions inside the trigger subtree or the portaled card stay open.
  212. fireEvent.mouseDown(menu)
  213. fireEvent.mouseDown(trigger)
  214. fireEvent.blur(trigger, { relatedTarget: menu })
  215. expect(screen.getByRole('menu')).toBeTruthy()
  216. fireEvent.mouseDown(document.body)
  217. expect(screen.queryByRole('menu')).toBeNull()
  218. } finally {
  219. Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
  220. Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
  221. }
  222. })
  223. it('renders no Agent-bound control for an addressed subagent session', () => {
  224. const load = vi.fn()
  225. render(<ModelSelect
  226. locked={false}
  227. available={false}
  228. directory={createSnapshotStore(state())}
  229. load={load}
  230. select={vi.fn().mockResolvedValue(false)}
  231. t={t}
  232. />)
  233. expect(screen.queryByRole('button')).toBeNull()
  234. expect(load).not.toHaveBeenCalled()
  235. })
  236. })