// @vitest-environment jsdom /** * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status * rows, collapse with active hint) and the todo_write toolview row (progress * summary from args, generic fallback on malformed JSON, error badge). */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { hookOf } from './hook.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' // Export discipline: packages/client/AGENTS.md. import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx' import { TodoPanel } from '../src/client/skeleton/TodoPanel.tsx' afterEach(cleanup) function sessionWith(todos: readonly TodoItem[]) { const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos }) return { store, useSession: hookOf(store) as unknown as UseSession } } const LIST: TodoItem[] = [ { content: '搭骨架', status: 'completed' }, { content: '写组件', status: 'in_progress' }, { content: '补测试', status: 'pending' }, ] describe('TodoPanel', () => { it('renders nothing while the list is empty, appears when todos land', () => { const { store, useSession } = sessionWith([]) render() expect(screen.queryByTestId('todo-panel')).toBeNull() act(() => { store.set({ todos: LIST }) }) expect(screen.getByTestId('todo-panel')).toBeTruthy() }) it('shows progress, one row per item with its status, and strikes done items', () => { const { useSession } = sessionWith(LIST) render() expect(screen.getByText('1/3')).toBeTruthy() const items = screen.getAllByRole('listitem') expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() expect(screen.getByText('写组件')).toBeTruthy() }) it('collapse hides the list and surfaces the active item in the header; expand restores', () => { const { useSession } = sessionWith(LIST) render() const header = screen.getByRole('button', { expanded: true }) fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() // Collapsed header carries the in-progress content as the one-line hint. expect(screen.getByText('写组件')).toBeTruthy() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(screen.getAllByRole('listitem')).toHaveLength(3) }) it('collapsed header omits the hint when nothing is in progress', () => { const { useSession } = sessionWith([{ content: '都完了', status: 'completed' }]) render() fireEvent.click(screen.getByRole('button', { expanded: true })) expect(screen.queryByText('都完了')).toBeNull() expect(screen.getByText('1/1')).toBeTruthy() }) }) const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({ kind: 'tool-result', seq: 10, callId: 'c1', call: { name: 'todo_write', argsRaw }, content: [], isError: false, callView: null, resultView: null, ...over, }) function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps { return { callId: 'c1', toolName: 'todo_write', block, openDetails, sessionId: 's1', useSessions: () => undefined, } as unknown as ToolRowProps } describe('TodoRow', () => { const ARGS = JSON.stringify({ todos: LIST }) it('summarizes counts and the active item from the call args', () => { render() expect(screen.getByText('更新任务清单')).toBeTruthy() expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy() }) it('omits the active clause when no item is in progress and reads running-call args', () => { const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] }) render() expect(screen.getByText('1/1 已完成')).toBeTruthy() }) it('falls back to the generic summary on malformed args and flags errors', () => { render() expect(screen.getByText('failed')).toBeTruthy() // Generic others summary: " · ". expect(screen.getByText('todo_write · not json')).toBeTruthy() }) it('falls back when parsed args carry no todos array, and click opens details', () => { const openDetails = vi.fn() render() expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy() fireEvent.click(screen.getByText('更新任务清单')) expect(openDetails).toHaveBeenCalledTimes(1) }) it('window-truncated result (call head lost) falls back to the callId summary', () => { render() expect(screen.getByText('todo_write · c1')).toBeTruthy() }) it('todoToolview is a plain registrant riding the conversation load-order seam', () => { expect(todoToolview.name).toBe('todo-toolview') expect(todoToolview.inject).toEqual(['slots', 'conversation']) const register = vi.fn() todoToolview.apply({ slots: { register } } as never) expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow) }) })