todo-panel.spec.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. // @vitest-environment jsdom
  2. /**
  3. * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
  4. * rows, collapse), its TodoDock adapter (selects the plan off the session
  5. * snapshot and follows changes), and the todo_write toolview row (progress
  6. * summary from args, generic fallback on malformed JSON, error badge,
  7. * keyboard activation).
  8. */
  9. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  12. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
  14. import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
  15. // Export discipline: packages/client/AGENTS.md.
  16. import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
  17. import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
  18. import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
  19. afterEach(cleanup)
  20. const LIST: TodoItem[] = [
  21. { content: '搭骨架', status: 'completed' },
  22. { content: '写组件', status: 'in_progress' },
  23. { content: '补测试', status: 'pending' },
  24. ]
  25. describe('TodoPanel', () => {
  26. it('renders nothing while the list is empty', () => {
  27. const { container } = render(<TodoPanel todos={[]} />)
  28. expect(container.innerHTML).toBe('')
  29. })
  30. it('shows progress, one row per item with its status glyph', () => {
  31. render(<TodoPanel todos={LIST} />)
  32. expect(screen.getByTestId('todo-panel')).toBeTruthy()
  33. expect(screen.getByText('To-dos')).toBeTruthy()
  34. expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
  35. const items = screen.getAllByRole('listitem')
  36. expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
  37. expect(screen.getByText('搭骨架')).toBeTruthy()
  38. expect(screen.getByText('写组件')).toBeTruthy()
  39. // Each status row carries an SVG glyph (not a text bullet).
  40. expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
  41. })
  42. it('collapse hides the list; expand restores; header keeps the count summary', () => {
  43. render(<TodoPanel todos={LIST} />)
  44. const header = screen.getByRole('button', { expanded: true })
  45. fireEvent.click(header)
  46. expect(screen.queryByRole('list')).toBeNull()
  47. // Collapsed header is title + progress only (no in-progress content hint).
  48. expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
  49. expect(screen.queryByText('写组件')).toBeNull()
  50. fireEvent.click(screen.getByRole('button', { expanded: false }))
  51. expect(screen.getAllByRole('listitem')).toHaveLength(3)
  52. })
  53. it('collapsed header still shows zero in-progress when nothing is active', () => {
  54. render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
  55. fireEvent.click(screen.getByRole('button', { expanded: true }))
  56. expect(screen.queryByText('都完了')).toBeNull()
  57. expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
  58. })
  59. })
  60. /** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */
  61. function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps {
  62. return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps
  63. }
  64. describe('TodoDock', () => {
  65. it('selects the plan off the session snapshot and follows later writes', () => {
  66. const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] })
  67. render(<TodoDock {...dockProps(store)} />)
  68. expect(screen.queryByTestId('todo-panel')).toBeNull()
  69. act(() => { store.set({ todos: LIST }) })
  70. expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
  71. // A rollback to the empty list retires the strip (the panel owns no data).
  72. act(() => { store.set({ todos: [] }) })
  73. expect(screen.queryByTestId('todo-panel')).toBeNull()
  74. })
  75. it('ships the registrant plugin shape (list entry above the queue rows)', () => {
  76. expect(todoDockEntry.name).toBe('conversation-todo-dock')
  77. expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
  78. const register = vi.fn()
  79. todoDockEntry.apply({ slots: { register } } as never)
  80. expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
  81. })
  82. })
  83. const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
  84. kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
  85. call: { name: 'todo_write', argsRaw },
  86. content: [], isError: false, callView: null, resultView: null, ...over,
  87. })
  88. function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
  89. return {
  90. callId: 'c1', toolName: 'todo_write', block,
  91. openDetails,
  92. sessionId: 's1',
  93. useSessions: () => undefined,
  94. } as unknown as ToolRowProps
  95. }
  96. describe('TodoRow', () => {
  97. const ARGS = JSON.stringify({ todos: LIST })
  98. it('summarizes counts and the active item from the call args', () => {
  99. render(<TodoRow {...rowProps(resultNode(ARGS))} />)
  100. expect(screen.getByText('更新任务清单')).toBeTruthy()
  101. expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
  102. })
  103. it('omits the active clause when no item is in progress and reads running-call args', () => {
  104. const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
  105. render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
  106. expect(screen.getByText('1/1 已完成')).toBeTruthy()
  107. })
  108. it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
  109. // A running call (no result yet) shows the ongoing dot, never the ok badge.
  110. const args = JSON.stringify({ todos: LIST })
  111. const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
  112. expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
  113. expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
  114. running.unmount()
  115. // A cancelled call wrote no todo/write: the row must not read as a completed update.
  116. const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
  117. expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
  118. expect(stopped.getByText('已中断')).toBeTruthy()
  119. })
  120. it('falls back to the generic summary on malformed args and flags errors', () => {
  121. render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
  122. expect(screen.getByText('failed')).toBeTruthy()
  123. // Generic others summary: "<tool> · <raw>".
  124. expect(screen.getByText('todo_write · not json')).toBeTruthy()
  125. })
  126. it('falls back when parsed args carry no todos array, and click opens details', () => {
  127. const openDetails = vi.fn()
  128. render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
  129. expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
  130. fireEvent.click(screen.getByText('更新任务清单'))
  131. expect(openDetails).toHaveBeenCalledTimes(1)
  132. })
  133. it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
  134. const openDetails = vi.fn()
  135. render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
  136. const row = screen.getByRole('button')
  137. expect(row.getAttribute('tabindex')).toBe('0')
  138. fireEvent.keyDown(row, { key: 'Enter' })
  139. fireEvent.keyDown(row, { key: ' ' })
  140. expect(openDetails).toHaveBeenCalledTimes(2)
  141. // Space must not also scroll the flow: the handler claims the event.
  142. expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
  143. fireEvent.keyDown(row, { key: 'a' })
  144. fireEvent.keyDown(row, { key: 'ArrowDown' })
  145. expect(openDetails).toHaveBeenCalledTimes(3)
  146. })
  147. it.each([
  148. { label: 'null root', argsRaw: 'null' },
  149. { label: 'non-object root', argsRaw: '42' },
  150. { label: 'null items', argsRaw: '{"todos":[null]}' },
  151. ])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
  152. render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
  153. // No throw, and the generic others summary carries the raw args verbatim.
  154. expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
  155. })
  156. it('window-truncated result (call head lost) falls back to the callId summary', () => {
  157. render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
  158. expect(screen.getByText('todo_write · c1')).toBeTruthy()
  159. })
  160. it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
  161. expect(todoToolview.name).toBe('todo-toolview')
  162. expect(todoToolview.inject).toEqual(['slots', 'conversation'])
  163. const register = vi.fn()
  164. todoToolview.apply({ slots: { register } } as never)
  165. expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
  166. })
  167. })