question-composer.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  4. import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
  5. import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
  6. import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
  7. import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
  8. import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
  9. import { PendingQuestion } from '../src/client/contract/slots.ts'
  10. import {
  11. QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
  12. } from '../src/client/QuestionComposer.tsx'
  13. afterEach(cleanup)
  14. const SID = 's1' as SessionId
  15. /** Framework standard-kit stubs: the composer consumes none of them, the
  16. * composed props type mandates their delivery (framework hooks are plain
  17. * stubs per the client testing discipline). */
  18. const kit = {
  19. sessionId: SID,
  20. useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
  21. useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
  22. }
  23. const QUESTIONS = [
  24. {
  25. id: 'profile', header: '偏好', question: '选择候选人类型',
  26. detail: '按当前空缺岗位的优先级选择。',
  27. options: [
  28. { label: '工程落地型 (Recommended)', description: '优先工程交付。' },
  29. { label: '研究潜力型', description: '优先研究能力。' },
  30. ],
  31. },
  32. {
  33. id: 'detail', question: '补充你的要求',
  34. },
  35. {
  36. id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
  37. options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
  38. },
  39. ]
  40. /** Carrier fixture: a real PendingWait over a scripted respond carrier. */
  41. function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
  42. const carrier = new PendingWait(
  43. 'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
  44. return { carrier, respond }
  45. }
  46. /** The client-response envelope respond must have received for an answer batch. */
  47. function answeredEnvelope(rpcId: string, answers: object[]) {
  48. return {
  49. type: 'client-response', rpcId: RpcId(rpcId),
  50. result: { ok: true, value: { sessionId: SID, answer: { answers } } },
  51. }
  52. }
  53. describe('QuestionComposer', () => {
  54. it('collects single, custom, and multi-select answers before one batch submit', () => {
  55. const { carrier, respond } = wait()
  56. render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
  57. expect(screen.getByText('1 / 3')).toBeTruthy()
  58. expect(screen.getByText('推荐')).toBeTruthy()
  59. expect(screen.getByText('工程落地型')).toBeTruthy()
  60. expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
  61. fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
  62. expect(respond).not.toHaveBeenCalled()
  63. fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
  64. expect(screen.getByText('2 / 3')).toBeTruthy()
  65. // detail is per-question: the second question carries none.
  66. expect(screen.queryByText('按当前空缺岗位的优先级选择。')).toBeNull()
  67. expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
  68. const custom = screen.getByPlaceholderText('输入你的答案')
  69. fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
  70. fireEvent.keyDown(custom, { key: 'Enter' })
  71. expect(screen.getByText('3 / 3')).toBeTruthy()
  72. expect(screen.getByText('选择重要信号')).toBeTruthy()
  73. expect(screen.getByText('可多选')).toBeTruthy()
  74. expect(screen.queryByText('(可多选)')).toBeNull()
  75. fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
  76. fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
  77. fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
  78. fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
  79. fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
  80. // The domain face encoded the whole batch into one carrier envelope.
  81. expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
  82. { id: 'profile', selected: ['工程落地型 (Recommended)'] },
  83. { id: 'detail', selected: [], custom: '要能独立排查线上问题' },
  84. { id: 'signals', selected: ['系统设计', '代码质量'] },
  85. ]))
  86. expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
  87. })
  88. it('skips individual questions without discarding earlier answers', () => {
  89. const { carrier, respond } = wait()
  90. render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
  91. expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
  92. fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
  93. expect(screen.getByText('2 / 3')).toBeTruthy()
  94. fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
  95. expect(screen.getByText('3 / 3')).toBeTruthy()
  96. fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
  97. expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
  98. { id: 'profile', selected: ['研究潜力型'] },
  99. { id: 'detail', selected: [] },
  100. { id: 'signals', selected: [] },
  101. ]))
  102. })
  103. it('keeps IME Enter inside the custom input until composition finishes', () => {
  104. const { carrier, respond } = wait()
  105. render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
  106. fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
  107. const custom = screen.getByPlaceholderText('输入你的答案')
  108. fireEvent.change(custom, { target: { value: '中文输入' } })
  109. fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
  110. expect(screen.getByText('2 / 3')).toBeTruthy()
  111. expect(respond).not.toHaveBeenCalled()
  112. fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
  113. expect(screen.getByText('2 / 3')).toBeTruthy()
  114. expect(respond).not.toHaveBeenCalled()
  115. fireEvent.keyDown(custom, { key: 'Enter' })
  116. expect(screen.getByText('3 / 3')).toBeTruthy()
  117. })
  118. it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
  119. const { carrier, respond } = wait()
  120. render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
  121. fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
  122. expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
  123. fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
  124. const emptyCustom = screen.getByPlaceholderText('输入你的答案')
  125. fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
  126. expect(screen.getByText('2 / 3')).toBeTruthy()
  127. fireEvent.keyDown(emptyCustom, { key: 'Enter' })
  128. expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
  129. fireEvent.click(screen.getByLabelText('下一题'))
  130. fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
  131. fireEvent.click(screen.getByRole('button', { name: '提交' }))
  132. expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
  133. expect(screen.getByText('2 / 3')).toBeTruthy()
  134. fireEvent.click(screen.getByLabelText('上一题'))
  135. expect(screen.getByText('1 / 3')).toBeTruthy()
  136. expect(respond).not.toHaveBeenCalled()
  137. })
  138. it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
  139. const respond = vi.fn()
  140. .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
  141. .mockRejectedValueOnce(new Error('第二次取消失败'))
  142. const { carrier } = wait('question-1', respond)
  143. render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
  144. // Receipt rejection surfaces through the domain face's thrown message.
  145. fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
  146. expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
  147. expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
  148. fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
  149. expect(await screen.findByText('第二次取消失败')).toBeTruthy()
  150. })
  151. it('surfaces transport rejection and resets local drafts for a different request', async () => {
  152. const respond = vi.fn()
  153. .mockRejectedValueOnce(new Error('网络中断'))
  154. .mockRejectedValueOnce('字符串错误')
  155. const first = wait('first', respond)
  156. const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
  157. fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
  158. expect(screen.getByText('2 / 3')).toBeTruthy()
  159. const second = wait('second', respond)
  160. view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
  161. expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
  162. fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
  163. const custom = screen.getByPlaceholderText('输入你的答案')
  164. fireEvent.change(custom, { target: { value: 'x' } })
  165. fireEvent.keyDown(custom, { key: 'Enter' })
  166. fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
  167. fireEvent.click(screen.getByRole('button', { name: '提交' }))
  168. expect(await screen.findByText('网络中断')).toBeTruthy()
  169. expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
  170. fireEvent.click(screen.getByRole('button', { name: '提交' }))
  171. expect(await screen.findByText('字符串错误')).toBeTruthy()
  172. })
  173. it('same-key carrier replacement (baseline replay) keeps drafts', () => {
  174. const first = wait('same-id')
  175. const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
  176. fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
  177. expect(screen.getByText('2 / 3')).toBeTruthy()
  178. // Replay mints a NEW carrier for the same request; same key = no remount.
  179. const replayed = wait('same-id')
  180. view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
  181. expect(screen.getByText('2 / 3')).toBeTruthy()
  182. })
  183. })
  184. describe('PendingQuestion domain face', () => {
  185. it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
  186. const respond = vi.fn()
  187. .mockResolvedValueOnce({ accepted: true })
  188. .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
  189. const question = new PendingQuestion(wait('rq', respond).carrier)
  190. const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
  191. await expect(question.answer(batch)).resolves.toBeUndefined()
  192. expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
  193. await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
  194. })
  195. it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
  196. const respond = vi.fn()
  197. .mockResolvedValueOnce({ accepted: true })
  198. .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
  199. const question = new PendingQuestion(wait('rc', respond).carrier)
  200. await expect(question.cancel()).resolves.toBeUndefined()
  201. expect(respond).toHaveBeenCalledWith({
  202. type: 'client-response', rpcId: RpcId('rc'),
  203. result: {
  204. ok: false,
  205. error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
  206. },
  207. })
  208. await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
  209. })
  210. it('forwards key and questions from the carrier', () => {
  211. const question = new PendingQuestion(wait('rk').carrier)
  212. expect(question.key).toBe('q:rk')
  213. expect(question.questions).toBe(wait('rk').carrier.payload.questions)
  214. })
  215. })
  216. describe('parseRecommendedLabel', () => {
  217. it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
  218. expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
  219. expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
  220. expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
  221. expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
  222. })
  223. })
  224. describe('parseQuestionTitle', () => {
  225. it('removes Chinese and ASCII multi-select suffixes', () => {
  226. expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
  227. expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
  228. expect(parseQuestionTitle('选择信号')).toBe('选择信号')
  229. })
  230. })