model-select.spec.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  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 { ModelTarget } 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. groups: [{
  33. id: 'deepseek-official',
  34. name: 'DeepSeek',
  35. models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }],
  36. }],
  37. failures: [],
  38. status: 'ready',
  39. error: null,
  40. ...overrides,
  41. }
  42. }
  43. afterEach(cleanup)
  44. describe('ModelSelect reasoning effort', () => {
  45. it('renders adapter metadata and submits the effort as part of the session target', async () => {
  46. const directory = createSnapshotStore<ModelDirectoryState>(state())
  47. const select = vi.fn(async (target: ModelTarget) => {
  48. directory.set(state({ current: target }))
  49. return true
  50. })
  51. render(<ModelSelect
  52. locked={false}
  53. directory={directory}
  54. load={vi.fn()}
  55. select={select}
  56. t={t}
  57. />)
  58. const trigger = screen.getByRole('button', {
  59. name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
  60. })
  61. fireEvent.click(trigger)
  62. fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
  63. expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
  64. .toEqual(['Off', 'High', 'MaxLargest budget'])
  65. fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
  66. await waitFor(() => {
  67. expect(select).toHaveBeenCalledWith({
  68. provider: 'deepseek-official',
  69. model: 'deepseek-v4-flash',
  70. reasoningEffort: 'max',
  71. })
  72. expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash,推理等级 Max')
  73. })
  74. })
  75. it('offers provider default only when the adapter does not configure a model default', () => {
  76. const directory = createSnapshotStore(state({
  77. groups: [{
  78. id: 'provider',
  79. name: 'Provider',
  80. models: [{
  81. id: 'model',
  82. name: 'Model',
  83. reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] },
  84. }],
  85. }],
  86. current: { provider: 'provider', model: 'model' },
  87. }))
  88. render(<ModelSelect
  89. locked={false}
  90. directory={directory}
  91. load={vi.fn()}
  92. select={vi.fn().mockResolvedValue(true)}
  93. t={t}
  94. />)
  95. fireEvent.click(screen.getByRole('button', {
  96. name: '选择模型,当前 Model,推理等级 Default',
  97. }))
  98. fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
  99. expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
  100. .toEqual(['Default', 'Standard'])
  101. })
  102. it('prompts for a new selection when the current target is no longer advertised', () => {
  103. const directory = createSnapshotStore(state({
  104. current: { provider: 'deepseek-official', model: 'removed-model' },
  105. }))
  106. const select = vi.fn().mockResolvedValue(true)
  107. render(<ModelSelect
  108. locked={false}
  109. directory={directory}
  110. load={vi.fn()}
  111. select={select}
  112. t={t}
  113. />)
  114. const trigger = screen.getByRole('button', { name: '选择模型' })
  115. expect(trigger.textContent).toContain('选择模型')
  116. fireEvent.click(trigger)
  117. expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
  118. fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
  119. expect(screen.queryByText('removed-model')).toBeNull()
  120. expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
  121. })
  122. })