model-select.spec.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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-client-connection/client'
  5. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  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('prompts for a selection when the current model is no longer advertised', () => {
  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: '选择模型' })
  119. expect(trigger.textContent).toContain('选择模型')
  120. fireEvent.click(trigger)
  121. expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
  122. fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
  123. expect(screen.queryByText('removed-model')).toBeNull()
  124. expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
  125. })
  126. it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
  127. const groups = [{
  128. id: 'deepseek-official',
  129. name: 'DeepSeek',
  130. models: [
  131. { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
  132. { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
  133. ],
  134. }]
  135. const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
  136. const select = vi.fn(async () => {
  137. directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' }))
  138. return false
  139. })
  140. render(<ModelSelect
  141. locked={false}
  142. available
  143. directory={directory}
  144. load={vi.fn()}
  145. select={select}
  146. t={t}
  147. />)
  148. fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
  149. fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
  150. fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
  151. const toast = await screen.findByRole('alert')
  152. expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images')
  153. // The selection failure does not render the in-menu load strip (no Retry).
  154. expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
  155. })
  156. it('renders no Agent-bound control for an addressed subagent session', () => {
  157. const load = vi.fn()
  158. render(<ModelSelect
  159. locked={false}
  160. available={false}
  161. directory={createSnapshotStore(state())}
  162. load={load}
  163. select={vi.fn().mockResolvedValue(false)}
  164. t={t}
  165. />)
  166. expect(screen.queryByRole('button')).toBeNull()
  167. expect(load).not.toHaveBeenCalled()
  168. })
  169. })