queue-dock.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. // @vitest-environment jsdom
  2. /**
  3. * QueueDock rendering and operations: authoritative rows, inline editing,
  4. * collapse state, removal, failure notices, and live retirement.
  5. */
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
  8. import { useSyncExternalStore } from 'react'
  9. import type {
  10. ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
  11. } from '@deepseek-ai/dsh-client-runtime/client'
  12. import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
  13. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  14. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  15. import type { QueueItemId } from '../src/client/contract/queue.ts'
  16. import type { InputState } from '../src/client/input/contract.ts'
  17. import { zh } from '../src/client/locales.ts'
  18. import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
  19. afterEach(cleanup)
  20. const SID = 's1' as SessionId
  21. const iid = (id: string): QueueItemId => id as QueueItemId
  22. function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
  23. return { id: iid(id), preview, text }
  24. }
  25. function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
  26. return {
  27. sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
  28. pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
  29. hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
  30. }
  31. }
  32. /** Minimal live source backing the useSession stub. */
  33. function liveSession(initial: ConversationSnapshot) {
  34. let snapshot = initial
  35. const listeners = new Set<() => void>()
  36. const useSession: SnapshotSelectorHook<ConversationSnapshot> = selector =>
  37. useSyncExternalStore(
  38. (listener) => {
  39. listeners.add(listener)
  40. return () => listeners.delete(listener)
  41. },
  42. () => selector(snapshot),
  43. )
  44. return {
  45. useSession,
  46. push(next: ConversationSnapshot): void {
  47. snapshot = next
  48. for (const listener of [...listeners]) listener()
  49. },
  50. }
  51. }
  52. const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
  53. // Standard locale seat stub mirroring the real ns → common → key chain.
  54. const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
  55. function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
  56. return {
  57. sessionId: SID,
  58. t,
  59. useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
  60. useWorkspaces: (() => { throw new Error('unused') }) as never,
  61. useProjection: (() => undefined) as never,
  62. useInput: (() => { throw new Error('unused') }) as never,
  63. inputActions: { setDraft: () => {}, submit: () => {} } as never,
  64. session: snapshot,
  65. input: INPUT_STATE,
  66. updateQueue: vi.fn(() => Promise.resolve()),
  67. notify: vi.fn(),
  68. ...injected,
  69. }
  70. }
  71. describe('QueueDock', () => {
  72. it('renders null while the queue is empty', () => {
  73. const snap = snapshotWith([])
  74. const source = liveSession(snap)
  75. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  76. expect(container.innerHTML).toBe('')
  77. })
  78. it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
  79. const single = snapshotWith([row('i-1', 'one')])
  80. const source = liveSession(single)
  81. const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
  82. expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
  83. expect(view.getByText('one')).toBeTruthy()
  84. act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
  85. const header = view.getByRole('button', { name: '2 条排队消息' })
  86. expect(header.getAttribute('aria-expanded')).toBe('false')
  87. expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
  88. expect(view.queryByText('one')).toBeNull()
  89. expect(view.queryByText('two')).toBeNull()
  90. fireEvent.click(header)
  91. expect(header.getAttribute('aria-expanded')).toBe('true')
  92. expect(view.getByText('one')).toBeTruthy()
  93. expect(view.getByText('two')).toBeTruthy()
  94. fireEvent.click(header)
  95. expect(header.getAttribute('aria-expanded')).toBe('false')
  96. expect(view.queryByText('one')).toBeNull()
  97. })
  98. it('keeps an active single-row editor visible when another item arrives', () => {
  99. const single = snapshotWith([row('i-edit', 'before')])
  100. const source = liveSession(single)
  101. const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
  102. fireEvent.click(view.getByLabelText('编辑排队消息'))
  103. fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
  104. act(() => {
  105. source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
  106. })
  107. const header = view.getByRole('button', { name: '2 条排队消息' })
  108. expect(header).toHaveProperty('disabled', true)
  109. expect(header.getAttribute('aria-expanded')).toBe('true')
  110. expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
  111. expect(view.getByText('second')).toBeTruthy()
  112. fireEvent.click(view.getByLabelText('取消编辑'))
  113. expect(header).toHaveProperty('disabled', false)
  114. expect(header.getAttribute('aria-expanded')).toBe('false')
  115. expect(view.queryByText('second')).toBeNull()
  116. })
  117. it('keeps an in-flight row action visible when another item arrives', async () => {
  118. const single = snapshotWith([row('i-remove', 'remove me')])
  119. const source = liveSession(single)
  120. let finishUpdate: (() => void) | undefined
  121. const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
  122. const view = render(
  123. <QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
  124. )
  125. fireEvent.click(view.getByLabelText('删除排队消息'))
  126. act(() => {
  127. source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
  128. })
  129. const header = view.getByRole('button', { name: '2 条排队消息' })
  130. expect(header).toHaveProperty('disabled', true)
  131. expect(header.getAttribute('aria-expanded')).toBe('true')
  132. expect(view.getByText('remove me')).toBeTruthy()
  133. expect(view.getByText('second')).toBeTruthy()
  134. act(() => { finishUpdate?.() })
  135. await waitFor(() => {
  136. expect(header).toHaveProperty('disabled', false)
  137. expect(header.getAttribute('aria-expanded')).toBe('false')
  138. })
  139. })
  140. it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
  141. const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
  142. const source = liveSession(first)
  143. const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
  144. fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
  145. expect(view.getByText('one')).toBeTruthy()
  146. act(() => { source.push(snapshotWith([])) })
  147. expect(view.container.innerHTML).toBe('')
  148. act(() => {
  149. source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
  150. })
  151. const header = view.getByRole('button', { name: '2 条排队消息' })
  152. expect(header.getAttribute('aria-expanded')).toBe('false')
  153. expect(view.queryByText('three')).toBeNull()
  154. })
  155. it('renders active actions and disables editing for mixed-content rows', () => {
  156. const snap = snapshotWith([
  157. row('i-1', '第一条排队消息'),
  158. row('i-2', null, 'image [image]'),
  159. ])
  160. const source = liveSession(snap)
  161. const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  162. fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
  163. expect([...container.querySelectorAll('li')].map(item => item.textContent))
  164. .toEqual(['第一条排队消息', 'image [image]'])
  165. expect(container.querySelectorAll('button')).toHaveLength(5)
  166. expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
  167. expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
  168. expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
  169. expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
  170. expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
  171. expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
  172. .toBe('包含非文本内容,暂不支持编辑')
  173. })
  174. it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
  175. const snap = snapshotWith([row('i-edit', 'before')])
  176. const source = liveSession(snap)
  177. const updateQueue = vi.fn(() => Promise.resolve())
  178. const { getByLabelText, queryByLabelText } = render(
  179. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  180. )
  181. fireEvent.click(getByLabelText('编辑排队消息'))
  182. const editor = getByLabelText('编辑排队消息') as HTMLInputElement
  183. expect(getByLabelText('保存排队消息')).toBeTruthy()
  184. expect(getByLabelText('取消编辑')).toBeTruthy()
  185. expect(queryByLabelText('删除排队消息')).toBeNull()
  186. fireEvent.change(editor, { target: { value: 'after' } })
  187. fireEvent.keyDown(editor, { key: 'Enter' })
  188. await waitFor(() => {
  189. expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
  190. kind: 'edit',
  191. content: [{ type: 'text', text: 'after' }],
  192. })
  193. })
  194. })
  195. it('cancels an edit by button or Escape without mutating the queue', () => {
  196. const snap = snapshotWith([row('i-edit', 'before')])
  197. const source = liveSession(snap)
  198. const updateQueue = vi.fn(() => Promise.resolve())
  199. const { getByLabelText, getByText } = render(
  200. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  201. )
  202. fireEvent.click(getByLabelText('编辑排队消息'))
  203. fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
  204. fireEvent.click(getByLabelText('取消编辑'))
  205. expect(getByText('before')).toBeTruthy()
  206. fireEvent.click(getByLabelText('编辑排队消息'))
  207. fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
  208. expect(getByText('before')).toBeTruthy()
  209. expect(updateQueue).not.toHaveBeenCalled()
  210. })
  211. it('keeps editing during IME composition and disables a blank save', () => {
  212. const snap = snapshotWith([row('i-edit', 'before')])
  213. const source = liveSession(snap)
  214. const updateQueue = vi.fn(() => Promise.resolve())
  215. const { getByLabelText } = render(
  216. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  217. )
  218. fireEvent.click(getByLabelText('编辑排队消息'))
  219. const editor = getByLabelText('编辑排队消息')
  220. fireEvent.change(editor, { target: { value: ' ' } })
  221. expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
  222. fireEvent.change(editor, { target: { value: '输入中' } })
  223. fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
  224. expect(updateQueue).not.toHaveBeenCalled()
  225. expect(getByLabelText('编辑排队消息')).toBeTruthy()
  226. })
  227. it('removes the addressed row', async () => {
  228. const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
  229. const source = liveSession(snap)
  230. const updateQueue = vi.fn(() => Promise.resolve())
  231. const { getAllByLabelText, getByRole } = render(
  232. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  233. )
  234. fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
  235. fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
  236. await waitFor(() => {
  237. expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
  238. })
  239. })
  240. it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
  241. const snap = snapshotWith([row('i-race', 'pending')])
  242. const source = liveSession(snap)
  243. const notify = vi.fn()
  244. const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
  245. const { getByLabelText, getByText } = render(
  246. <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
  247. )
  248. fireEvent.click(getByLabelText('删除排队消息'))
  249. await waitFor(() => {
  250. expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
  251. })
  252. expect(getByText('pending')).toBeTruthy()
  253. })
  254. it('follows authoritative retirement back to null', () => {
  255. const snap = snapshotWith([row('i-1', '在场')])
  256. const source = liveSession(snap)
  257. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  258. expect(container.textContent).toContain('在场')
  259. act(() => { source.push(snapshotWith([])) })
  260. expect(container.innerHTML).toBe('')
  261. })
  262. it('registers as the terminal composer-context entry', () => {
  263. expect(queueDockEntry.name).toBe('conversation-queue-dock')
  264. expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
  265. const register = vi.fn()
  266. queueDockEntry.apply({ slots: { register } } as never)
  267. expect(register).toHaveBeenCalledWith(
  268. expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
  269. QueueDock,
  270. )
  271. })
  272. })