model-select.client.spec.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  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: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }],
  37. }],
  38. failures: [],
  39. status: 'ready',
  40. error: null,
  41. ...overrides,
  42. }
  43. }
  44. afterEach(cleanup)
  45. describe('ModelSelect reasoning effort', () => {
  46. it('renders adapter metadata and submits the effort as part of the session selection', async () => {
  47. const directory = createSnapshotStore<ModelDirectoryState>(state())
  48. const select = vi.fn(async (selection: ModelSelection) => {
  49. directory.set(state({ current: selection }))
  50. return true
  51. })
  52. render(<ModelSelect
  53. locked={false}
  54. available
  55. directory={directory}
  56. load={vi.fn()}
  57. select={select}
  58. t={t}
  59. />)
  60. const trigger = screen.getByRole('button', {
  61. name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
  62. })
  63. fireEvent.click(trigger)
  64. fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
  65. expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
  66. .toEqual(['Off', 'High', 'MaxLargest budget'])
  67. fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
  68. await waitFor(() => {
  69. expect(select).toHaveBeenCalledWith({
  70. provider: 'deepseek-official',
  71. model: 'deepseek-v4-flash',
  72. reasoningEffort: 'max',
  73. })
  74. expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash,推理等级 Max')
  75. })
  76. })
  77. it('offers provider default only when the adapter does not configure a model default', () => {
  78. const directory = createSnapshotStore(state({
  79. groups: [{
  80. id: 'provider',
  81. name: 'Provider',
  82. models: [{
  83. id: 'model',
  84. name: 'Model',
  85. reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] },
  86. }],
  87. }],
  88. current: { provider: 'provider', model: 'model' },
  89. }))
  90. render(<ModelSelect
  91. locked={false}
  92. available
  93. directory={directory}
  94. load={vi.fn()}
  95. select={vi.fn().mockResolvedValue(true)}
  96. t={t}
  97. />)
  98. fireEvent.click(screen.getByRole('button', {
  99. name: '选择模型,当前 Model,推理等级 Default',
  100. }))
  101. fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
  102. expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
  103. .toEqual(['Default', 'Standard'])
  104. })
  105. it('shows the durable model id when the catalog has no matching display name', () => {
  106. const directory = createSnapshotStore(state({
  107. current: { provider: 'deepseek-official', model: 'removed-model' },
  108. }))
  109. const select = vi.fn().mockResolvedValue(true)
  110. render(<ModelSelect
  111. locked={false}
  112. available
  113. directory={directory}
  114. load={vi.fn()}
  115. select={select}
  116. t={t}
  117. />)
  118. const trigger = screen.getByRole('button', { name: '选择模型,当前 deepseek-official/removed-model' })
  119. expect(trigger.textContent).toContain('deepseek-official/removed-model')
  120. fireEvent.click(trigger)
  121. expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
  122. fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
  123. expect(screen.queryByRole('menuitemradio', { name: 'removed-model' })).toBeNull()
  124. expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
  125. })
  126. it('shows loading until the catalog and Session projection are both ready', async () => {
  127. const directory = createSnapshotStore<ModelDirectoryState>(state({
  128. current: null,
  129. routable: null,
  130. groups: [],
  131. status: 'loading',
  132. }))
  133. render(<ModelSelect
  134. locked={false}
  135. available
  136. directory={directory}
  137. load={vi.fn()}
  138. select={vi.fn().mockResolvedValue(true)}
  139. t={t}
  140. />)
  141. expect(screen.getByRole('button', { name: '正在加载模型…' }).textContent)
  142. .toContain('正在加载模型…')
  143. directory.set(state())
  144. await waitFor(() => {
  145. expect(screen.getByRole('button', {
  146. name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
  147. })).toBeTruthy()
  148. })
  149. })
  150. it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
  151. const groups = [{
  152. id: 'deepseek-official',
  153. name: 'DeepSeek',
  154. models: [
  155. { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
  156. { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
  157. ],
  158. }]
  159. const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
  160. const select = vi.fn(async () => {
  161. directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' }))
  162. return false
  163. })
  164. render(<ModelSelect
  165. locked={false}
  166. available
  167. directory={directory}
  168. load={vi.fn()}
  169. select={select}
  170. t={t}
  171. />)
  172. fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
  173. fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
  174. fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
  175. const toast = await screen.findByRole('alert')
  176. expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images')
  177. // The selection failure does not render the in-menu load strip (no Retry).
  178. expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
  179. })
  180. it('renders no Agent-bound control for an addressed subagent session', () => {
  181. const load = vi.fn()
  182. render(<ModelSelect
  183. locked={false}
  184. available={false}
  185. directory={createSnapshotStore(state())}
  186. load={load}
  187. select={vi.fn().mockResolvedValue(false)}
  188. t={t}
  189. />)
  190. expect(screen.queryByRole('button')).toBeNull()
  191. expect(load).not.toHaveBeenCalled()
  192. })
  193. })