| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676 |
- // @vitest-environment jsdom
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
- import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
- import type { SessionId } from '@deepseek-ai/dsh-session/types'
- import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
- import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
- import type { RowDragProps } from '../src/client/rows/Rows.tsx'
- import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
- import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
- import { zh } from '../src/client/locales.ts'
- afterEach(cleanup)
- const t = makeTranslate(zh, commonZh) as never
- const sid = (id: string) => id as SessionId
- const wid = (id: string) => id as WorkspaceId
- /** Half detection reads the row rect; jsdom rects are all-zero by default. */
- function stubRect(row: HTMLElement): void {
- row.getBoundingClientRect = () => ({
- top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
- x: 0, y: 100, toJSON: () => ({}),
- })
- }
- function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
- return {
- start: vi.fn(), active: false, marker: null,
- hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
- ...overrides,
- }
- }
- /** Install the async browser clipboard and restore its prior host shape. */
- function installClipboard(writeText: (text: string) => Promise<void>): () => void {
- const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
- Object.defineProperty(navigator, 'clipboard', {
- configurable: true,
- value: { writeText },
- })
- return () => {
- if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
- else Object.defineProperty(navigator, 'clipboard', prior)
- }
- }
- const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() }
- /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
- function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
- const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
- Object.defineProperty(event, 'clientY', { value: clientY })
- Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
- fireEvent(row, event)
- }
- describe('workspace browser rows', () => {
- it('omits only an empty leading status slot in the hierarchy-free flat list', () => {
- const idle: SessionNode = {
- id: sid('flat'), title: 'Flat Session', blank: false, running: false,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- const view = render(<SessionNodeItem node={idle} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
- const title = screen.getByText('Flat Session')
- expect(title.previousElementSibling).toBeNull()
- view.rerender(<SessionNodeItem node={{ ...idle, running: true }} currentId={undefined} now={0}
- onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
- expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy()
- })
- it('renders a selected content-search row and opens only its session', () => {
- const onOpen = vi.fn()
- const result: SearchResultNode = {
- id: sid('result'),
- title: 'Result title',
- workspace: 'Workspace context',
- running: true,
- runningSubagentCount: 0,
- completed: false,
- hasActiveSchedule: false,
- snippet: 'matching message excerpt',
- }
- render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />)
- const row = screen.getByRole('treeitem')
- expect(row.getAttribute('aria-selected')).toBe('true')
- expect(screen.getByText('Workspace context')).toBeTruthy()
- expect(screen.getByText('matching message excerpt')).toBeTruthy()
- expect(row.querySelector('[data-state="ongoing"]')).toBeTruthy()
- expect(screen.getByText('进行中')).toBeTruthy()
- expect(row.hasAttribute('draggable')).toBe(false)
- fireEvent.click(row)
- expect(onOpen).toHaveBeenCalledWith(result.id)
- })
- it('keeps the active-Schedule marker after a search title and inside the row action', () => {
- const onOpen = vi.fn()
- const result: SearchResultNode = {
- id: sid('scheduled-result'), title: 'Scheduled result', workspace: 'Project',
- running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: true,
- }
- render(<SearchResultItem result={result} currentId={undefined} onOpen={onOpen} t={t} />)
- const row = screen.getByRole('treeitem')
- const title = screen.getByText('Scheduled result')
- const indicator = screen.getByRole('img', { name: '有活动定时任务' })
- expect(title.nextElementSibling).toBe(indicator)
- expect(indicator.getAttribute('title')).toBe('有活动定时任务')
- expect(indicator.getAttribute('tabindex')).toBeNull()
- expect(row.querySelectorAll('button')).toHaveLength(0)
- fireEvent.click(indicator)
- expect(onOpen).toHaveBeenCalledWith(result.id)
- })
- it.each([
- ['approval', '等待审批'],
- ['plan-review', '计划待审'],
- ['question', '等待回答'],
- ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
- const result: SearchResultNode = {
- id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
- pendingInteraction, running: true, runningSubagentCount: 0, completed: false,
- hasActiveSchedule: false,
- }
- render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
- const row = screen.getByRole('treeitem')
- expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
- expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
- expect(screen.getByText(label)).toBeTruthy()
- })
- it('renders an active Workspace and keeps its create action separate from toggling', () => {
- const onToggle = vi.fn()
- const onCreate = vi.fn()
- const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
- sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
- }
- render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
- expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
- fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
- expect(onCreate).toHaveBeenCalledOnce()
- expect(onToggle).not.toHaveBeenCalled()
- fireEvent.click(screen.getByText('Project'))
- expect(onToggle).toHaveBeenCalledOnce()
- })
- it('renders and opens a selected running Session row', () => {
- const node: SessionNode = {
- id: sid('session'), title: 'Session', blank: false, running: true,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- const onOpen = vi.fn()
- render(
- <SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
- )
- const row = screen.getByRole('treeitem')
- expect(row.getAttribute('aria-selected')).toBe('true')
- expect(row.hasAttribute('aria-expanded')).toBe(false)
- expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
- fireEvent.click(row)
- expect(onOpen).toHaveBeenCalledWith(node.id)
- })
- it('reveals a clipped session title by scrolling it while the row is hovered', () => {
- const node: SessionNode = {
- id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false,
- running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(
- <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
- )
- const row = screen.getByRole('treeitem')
- const title = screen.getByText(node.title)
- // jsdom lays out nothing: the title's clipped geometry is stated outright.
- const geometry = (scrollWidth: number, clientWidth: number): void => {
- Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true })
- Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true })
- }
- geometry(320, 180)
- fireEvent.pointerEnter(row)
- expect(title.scrollLeft).toBe(140)
- fireEvent.pointerLeave(row)
- expect(title.scrollLeft).toBe(0)
- // A title that fits has no scroll range: hovering leaves it at its start.
- geometry(180, 180)
- fireEvent.pointerEnter(row)
- expect(title.scrollLeft).toBe(0)
- })
- it('returns a revealed title to its start in one step', () => {
- const node: SessionNode = {
- id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false,
- running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(
- <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
- )
- const row = screen.getByRole('treeitem')
- const title = screen.getByText(node.title)
- const scrollTo = vi.fn()
- Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true })
- fireEvent.pointerEnter(row)
- fireEvent.pointerLeave(row)
- // Browsers receive the explicit instant behavior that the stylesheet's
- // smooth scroll-behavior would otherwise override.
- expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' })
- })
- it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => {
- const onOpen = vi.fn()
- const node: SessionNode = {
- id: sid('scheduled-session'), title: 'Scheduled Session', blank: false, running: false,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: true, updatedAt: 0,
- }
- const view = render(
- <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
- )
- const assertIndicator = (): HTMLElement => {
- const title = screen.getByText('Scheduled Session')
- const time = screen.getByText('刚刚')
- const indicator = screen.getByRole('img', { name: '有活动定时任务' })
- expect(title.nextElementSibling).toBe(indicator)
- expect(indicator.nextElementSibling).toBe(time)
- expect(indicator.getAttribute('title')).toBe('有活动定时任务')
- expect(indicator.getAttribute('tabindex')).toBeNull()
- return indicator
- }
- fireEvent.click(assertIndicator())
- expect(onOpen).toHaveBeenCalledWith(node.id)
- view.rerender(
- <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />,
- )
- assertIndicator()
- })
- it('shows the green done dot only on a finished, unviewed session (live activity wins the slot)', () => {
- const renderRow = (over: Partial<SessionNode>) => render(
- <SessionNodeItem
- node={{
- id: sid('s1'), title: 'One', blank: false, running: false,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, ...over,
- }}
- currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
- />,
- )
- const stateDot = (view: ReturnType<typeof renderRow>) =>
- view.container.querySelector('[data-state]')
- // No completion reminder, not running: no state dot at all.
- const plain = renderRow({})
- expect(stateDot(plain)).toBeNull()
- plain.unmount()
- // Completed while unviewed: the green done dot.
- const done = renderRow({ completed: true })
- expect(done.container.querySelector('[data-state="done"]')).not.toBeNull()
- done.unmount()
- // Running wins the slot: the animated ongoing dot, no done dot.
- const running = renderRow({ completed: true, running: true })
- expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
- expect(running.container.querySelector('[data-state="done"]')).toBeNull()
- running.unmount()
- // Descendant activity also wins until the last running descendant stops.
- const delegated = renderRow({ completed: true, runningSubagentCount: 1 })
- expect(delegated.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
- expect(delegated.container.querySelector('[data-state="done"]')).toBeNull()
- })
- it('shows descendant activity without describing an idle parent as running', () => {
- vi.useFakeTimers()
- try {
- const node: SessionNode = {
- id: sid('owner'), title: 'Delegating', blank: false, running: false,
- runningSubagentCount: 2, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- const row = screen.getByRole('treeitem')
- expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull()
- expect(screen.getByText('2 个子代理运行中')).toBeTruthy()
- expect(screen.queryByText('进行中')).toBeNull()
- fireEvent.pointerEnter(row.parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getAllByText('2 个子代理运行中')).toHaveLength(2)
- } finally {
- vi.useRealTimers()
- }
- })
- it('keeps descendant activity secondary while the parent is running', () => {
- vi.useFakeTimers()
- try {
- const node: SessionNode = {
- id: sid('owner'), title: 'Delegating', blank: false, running: true,
- runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- const row = screen.getByRole('treeitem')
- expect(row.querySelectorAll('[data-state="ongoing"]')).toHaveLength(1)
- expect(screen.getByText('进行中')).toBeTruthy()
- expect(screen.getByText('1 个子代理运行中')).toBeTruthy()
- fireEvent.pointerEnter(row.parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getAllByText('进行中')).toHaveLength(2)
- expect(screen.getAllByText('1 个子代理运行中')).toHaveLength(2)
- } finally {
- vi.useRealTimers()
- }
- })
- it('keeps child activity as a secondary status while user attention is primary', () => {
- const node: SessionNode = {
- id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question',
- running: false, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- const row = screen.getByRole('treeitem')
- expect(row.querySelector('[data-state="warning"]')).not.toBeNull()
- expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
- expect(screen.getByText('等待回答')).toBeTruthy()
- expect(screen.getByText('1 个子代理运行中')).toBeTruthy()
- })
- it('shows the green done dot on a finished search result row', () => {
- render(<SearchResultItem
- result={{
- id: sid('result'), title: 'Done', workspace: 'Workspace', running: false,
- runningSubagentCount: 0, completed: true, hasActiveSchedule: false,
- }}
- currentId={undefined} onOpen={vi.fn()} t={t}
- />)
- expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
- })
- it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
- const onRename = vi.fn()
- const onDelete = vi.fn()
- const onToggle = vi.fn()
- const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
- sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
- }
- render(<ProjectRowItem
- group={group} onToggle={onToggle} onCreate={vi.fn()}
- actions={{ rename: onRename, delete: onDelete }} t={t}
- />)
- fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
- // Opening the menu neither toggles the group nor renames yet.
- expect(onToggle).not.toHaveBeenCalled()
- expect(screen.getByRole('menuitem', { name: '删除工作区' }).className).toMatch(/danger/)
- fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
- expect(onRename).toHaveBeenCalledOnce()
- expect(screen.queryByRole('menu')).toBeNull()
- fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
- fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
- expect(screen.queryByRole('menu')).toBeNull()
- expect(onRename).toHaveBeenCalledOnce()
- expect(onDelete).toHaveBeenCalledOnce()
- // Escape closes without selecting (Menu onClose path).
- fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
- fireEvent.keyDown(document, { key: 'Escape' })
- expect(screen.queryByRole('menu')).toBeNull()
- })
- it('workspace hover card shows its details and copies the full directory path', async () => {
- vi.useFakeTimers()
- const writeText = vi.fn(async () => {})
- const restoreClipboard = installClipboard(writeText)
- try {
- const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
- sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
- }
- render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
- fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- // Card body: full title + cwd + absolute creation time.
- expect(screen.getAllByText('Project')).toHaveLength(2)
- expect(screen.getByText('/projects/project')).toBeTruthy()
- expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
- await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) })
- expect(writeText).toHaveBeenCalledWith('/projects/project')
- expect(screen.getByRole('status').textContent).toBe('已复制')
- } finally {
- restoreClipboard()
- vi.useRealTimers()
- }
- })
- it('workspace hover card shows a POSIX home descendant as ~ and still copies the full path', async () => {
- vi.useFakeTimers()
- const writeText = vi.fn(async () => {})
- const restoreClipboard = installClipboard(writeText)
- try {
- const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: '/home/u/Documents/project', createdAt: 0, label: 'Project',
- sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
- }
- render(<ProjectRowItem group={group} home="/home/u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
- fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getByText('~/Documents/project')).toBeTruthy()
- expect(screen.queryByText('/home/u/Documents/project')).toBeNull()
- await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /home/u/Documents/project' })) })
- expect(writeText).toHaveBeenCalledWith('/home/u/Documents/project')
- } finally {
- restoreClipboard()
- vi.useRealTimers()
- }
- })
- it('workspace hover card without a directory omits the path and copy action', async () => {
- vi.useFakeTimers()
- try {
- const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: undefined, createdAt: 0, label: 'Project',
- sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
- }
- render(<ProjectRowItem group={group} home="/home/u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
- fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getAllByText('Project')).toHaveLength(2)
- expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
- expect(screen.queryByRole('button', { name: /^复制:/ })).toBeNull()
- } finally {
- vi.useRealTimers()
- }
- })
- it('workspace hover card leaves a Windows path verbatim', async () => {
- vi.useFakeTimers()
- try {
- const group: GroupNode = {
- key: 'project', workspaceId: wid('project'), cwd: 'C:\\Users\\u\\project', createdAt: 0, label: 'Project',
- sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
- }
- render(<ProjectRowItem group={group} home="C:\\Users\\u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
- fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getByText('C:\\Users\\u\\project')).toBeTruthy()
- } finally {
- vi.useRealTimers()
- }
- })
- it('ungrouped bucket renders no workspace menu', () => {
- const group: GroupNode = {
- key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
- sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
- }
- render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
- expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
- })
- it('blank New Session rows carry no menu, no time label, and no hover-card time', () => {
- vi.useFakeTimers()
- try {
- const node: SessionNode = {
- id: sid('s-blank'), title: 'ignored', blank: true, running: false,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- // The placeholder has no content yet: no row verbs, no "now" stamp.
- expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull()
- expect(screen.queryByText('刚刚')).toBeNull()
- // The hover card keeps title + status but drops the timestamp line.
- const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
- fireEvent.pointerEnter(wrapper)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
- expect(screen.getByText('空闲')).toBeTruthy()
- expect(screen.queryByText('刚刚')).toBeNull()
- expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull()
- } finally {
- vi.useRealTimers()
- }
- })
- it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
- const onOpen = vi.fn()
- const onRename = vi.fn()
- const onFork = vi.fn()
- const onArchive = vi.fn()
- const node: SessionNode = {
- id: sid('s1'), title: 'One', blank: false, running: false,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
- onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
- fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
- expect(onOpen).not.toHaveBeenCalled()
- // Archive is not destructive (log and accounting slot remain): no danger styling.
- expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
- // Rename dispatches with the current display title (dialog prefill).
- fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
- expect(screen.queryByRole('menu')).toBeNull()
- expect(onRename).toHaveBeenCalledWith(node.id, 'One')
- expect(onOpen).not.toHaveBeenCalled()
- fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
- fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
- expect(onFork).toHaveBeenCalledWith(node.id)
- // Archive dispatches without opening the session.
- fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
- fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
- expect(onArchive).toHaveBeenCalledWith(node.id)
- expect(onRename).toHaveBeenCalledOnce()
- expect(onOpen).not.toHaveBeenCalled()
- // Escape closes without selecting (Menu onClose path).
- fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
- fireEvent.keyDown(document, { key: 'Escape' })
- expect(screen.queryByRole('menu')).toBeNull()
- })
- it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
- vi.useFakeTimers()
- try {
- const node: SessionNode = {
- id: sid('s1'), title: 'Hovered', blank: false, running: true,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
- fireEvent.pointerEnter(wrapper)
- act(() => { vi.advanceTimersByTime(500) })
- // Card body: full title + relative time + running status.
- expect(screen.getAllByText('Hovered')).toHaveLength(2)
- expect(screen.getByText('1分钟前')).toBeTruthy()
- expect(screen.getAllByText('进行中')).toHaveLength(2)
- fireEvent.pointerLeave(wrapper)
- // Menu open (disabled=true) suppresses the card for the same hover.
- fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
- fireEvent.pointerEnter(wrapper)
- act(() => { vi.advanceTimersByTime(1000) })
- expect(screen.queryByText('1分钟前')).toBeNull()
- } finally {
- vi.useRealTimers()
- }
- })
- it.each([
- ['approval', '等待审批'],
- ['plan-review', '计划待审'],
- ['question', '等待回答'],
- ] as const)('shows %s as warning ahead of the running state', (pendingInteraction, label) => {
- vi.useFakeTimers()
- try {
- const node: SessionNode = {
- id: sid(pendingInteraction), title: 'Needs input', blank: false,
- pendingInteraction, running: true, runningSubagentCount: 0, completed: false,
- hasActiveSchedule: false, updatedAt: 0,
- }
- const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- const row = screen.getByRole('treeitem')
- expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
- expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
- expect(screen.getByText(label)).toBeTruthy()
- view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
- onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy()
- fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getAllByText(label)).toHaveLength(2)
- expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
- } finally {
- vi.useRealTimers()
- }
- })
- it('idle hover card shows the Idle status line', () => {
- vi.useFakeTimers()
- try {
- const node: SessionNode = {
- id: sid('s1'), title: 'Quiet', blank: false, running: false,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getByText('空闲')).toBeTruthy()
- expect(screen.getAllByText('刚刚')).toHaveLength(2)
- } finally {
- vi.useRealTimers()
- }
- })
- it('completed hover card shows the Completed status line', () => {
- vi.useFakeTimers()
- try {
- const node: SessionNode = {
- id: sid('s1'), title: 'Done', blank: false, running: false,
- runningSubagentCount: 0, completed: true, hasActiveSchedule: false, updatedAt: 0,
- }
- render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
- fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- // Row's visually-hidden reminder label plus the hover card's status line.
- expect(screen.getAllByText('已完成')).toHaveLength(2)
- } finally {
- vi.useRealTimers()
- }
- })
- it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
- const node: SessionNode = {
- id: sid('s1'), title: 'Drag me', blank: false, running: false,
- runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
- }
- const inactive = dragProps()
- const { rerender } = render(
- <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
- )
- const row = screen.getByRole('treeitem')
- stubRect(row)
- expect(row.getAttribute('draggable')).toBe('true')
- fireEvent.dragStart(row, { dataTransfer })
- expect(inactive.start).toHaveBeenCalledOnce()
- // Inactive drag: hover and drop are rejected.
- fireEvent.dragOver(row, { dataTransfer })
- fireEvent.drop(row, { dataTransfer })
- expect(inactive.hover).not.toHaveBeenCalled()
- expect(inactive.drop).not.toHaveBeenCalled()
- fireEvent.dragEnd(row)
- expect(inactive.end).toHaveBeenCalledOnce()
- const active = dragProps({ active: true, marker: 'before' })
- rerender(
- <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
- )
- stubRect(screen.getByRole('treeitem'))
- // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
- fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
- expect(active.hover).toHaveBeenCalledWith('before')
- fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
- expect(active.hover).toHaveBeenCalledWith('after')
- fireDrag(screen.getByRole('treeitem'), 'drop', 130)
- expect(active.drop).toHaveBeenCalledWith('after')
- const after = dragProps({ active: true, marker: 'after' })
- rerender(
- <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
- onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
- )
- expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
- })
- })
|