// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client' import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx' import { zh } from '../src/client/locales.ts' // Live rows render `now - startedAt`, so every assertion needs a pinned clock. beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(START) }) afterEach(() => { cleanup() vi.useRealTimers() vi.restoreAllMocks() }) const SESSION = 'session' as SessionId const START = 1_700_000_000_000 const t: TaskListActionProps['t'] = makeTranslate(zh) function task(over: Partial = {}): TaskView { return { id: 'bash-1' as TaskView['id'], kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: START, ...over, } } function props(tasks: readonly TaskView[] | undefined): TaskListActionProps { const state = { ids: [SESSION], byId: {}, current: SESSION, phase: 'ready', subagentsByParent: {}, tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks }, currentAddress: undefined, } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { return select(state) } return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps } /** * Rows in render order as `[kind, label, status, duration]`. Adjacent spans * carry no whitespace between them, so the cells are read one element at a * time rather than split out of a flattened string. */ function rowCells(): string[][] { return within(screen.getByRole('list', { name: zh['list.aria'] })) .getAllByRole('listitem') .map(row => [...row.children] .map(cell => cell.textContent ?? '') .filter(text => text !== '')) } describe('TaskListAction visibility', () => { it('renders nothing while the session has no tasks', () => { const { container } = render() expect(container.innerHTML).toBe('') }) it('counts only live tasks, and falls back to the total when none are live', () => { const { rerender } = render() expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() rerender() expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() }) it('closes and unmounts when the last task disappears while the list is open', () => { const { container, rerender } = render() fireEvent.click(screen.getByRole('button')) expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() rerender() expect(container.innerHTML).toBe('') }) }) describe('TaskListAction rows', () => { it('orders live tasks by start, then settled tasks newest-first', () => { render() fireEvent.click(screen.getByRole('button')) expect(rowCells()).toEqual([ ['bash', 'earlier live', '运行中', '0秒'], ['bash', 'later live', '运行中', '0秒'], ['bash', 'new done', '已失败', '9秒'], ['bash', 'old done', '已完成', '1秒'], ]) }) it('breaks a settled tie on start order so map iteration never decides it', () => { render() fireEvent.click(screen.getByRole('button')) expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second']) }) it('prefers the producer detail over the generic status word', () => { render() fireEvent.click(screen.getByRole('button')) expect(rowCells()[0]).toContain('signal: SIGTERM') }) it('renders every status word, including the stopping transition', () => { render() fireEvent.click(screen.getByRole('button')) const words = rowCells().map(cells => cells[2]) expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败'])) }) }) describe('TaskListAction duration', () => { it('advances a live row once per second and freezes a settled one', () => { vi.setSystemTime(START + 1_000) render() fireEvent.click(screen.getByRole('button')) expect(rowCells()[0]).toContain('1秒') expect(rowCells()[1]).toContain('4秒') act(() => { vi.advanceTimersByTime(2_000) }) expect(rowCells()[0]).toContain('3秒') expect(rowCells()[1]).toContain('4秒') }) it('widens to minutes and then hours, and never shows a negative figure', () => { render() fireEvent.click(screen.getByRole('button')) expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒']) }) it('runs no clock while the list is closed', () => { const interval = vi.spyOn(globalThis, 'setInterval') render() expect(interval).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button')) expect(interval).toHaveBeenCalledTimes(1) }) it('runs no clock for an open list holding only settled tasks', () => { const interval = vi.spyOn(globalThis, 'setInterval') render() fireEvent.click(screen.getByRole('button')) expect(interval).not.toHaveBeenCalled() }) }) describe('TaskListAction dismissal', () => { it('closes on Escape and returns focus to the trigger', () => { render() const trigger = screen.getByRole('button') fireEvent.click(trigger) expect(trigger.getAttribute('aria-expanded')).toBe('true') fireEvent.keyDown(trigger, { key: 'Escape' }) expect(trigger.getAttribute('aria-expanded')).toBe('false') expect(document.activeElement).toBe(trigger) }) it('ignores other keys and a closed-list Escape', () => { render() const trigger = screen.getByRole('button') fireEvent.keyDown(trigger, { key: 'Escape' }) expect(trigger.getAttribute('aria-expanded')).toBe('false') fireEvent.click(trigger) fireEvent.keyDown(trigger, { key: 'ArrowDown' }) expect(trigger.getAttribute('aria-expanded')).toBe('true') }) it('closes on an outside pointer press but not on one inside', () => { render() const trigger = screen.getByRole('button') fireEvent.click(trigger) fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] })) expect(trigger.getAttribute('aria-expanded')).toBe('true') fireEvent.pointerDown(document.body) expect(trigger.getAttribute('aria-expanded')).toBe('false') }) }) describe('TaskListAction wire tolerance', () => { it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => { // `finishedAt` is optional on the wire; the Host always sets it, so this // covers a producer or carrier that ever stops doing so. render() fireEvent.click(screen.getByRole('button')) expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([ ['finished', '3秒'], ['no finish', '0秒'], ]) }) it('falls back to start order when neither settled task carries a finish time', () => { render() fireEvent.click(screen.getByRole('button')) expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier']) }) })