Merge remote-tracking branch 'github/master' into feat/web-queue-steer-all
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
@@ -301,6 +301,20 @@ describe('ToolRow', () => {
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
|
||||
const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
|
||||
const summary = view.getByText('List files')
|
||||
const suffix = view.getByText('+2')
|
||||
// Separate spans: .summary truncates, the suffix must not travel inside it.
|
||||
expect(summary.contains(suffix)).toBe(false)
|
||||
view.unmount()
|
||||
// The failure line replaces the summary wholesale, so the suffix goes with it.
|
||||
const failed = render(
|
||||
<ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />,
|
||||
)
|
||||
expect(failed.queryByText('+2')).toBeNull()
|
||||
})
|
||||
|
||||
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, shared ToolRow
|
||||
* state dots and leading expansion).
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
|
||||
* including several `in_progress` at once, collapse), its TodoDock adapter
|
||||
* (selects the plan off the session snapshot and follows changes), the row's
|
||||
* plan summary (counts plus the two halves of the active summary — the named
|
||||
* task and the `+N` count that parallel work adds, kept apart so the row never
|
||||
* ellipsizes the count away), and the todo_write toolview row (progress summary
|
||||
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
|
||||
* leading expansion).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -17,6 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
|
||||
import { NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
||||
@@ -32,6 +36,49 @@ const LIST: TodoItem[] = [
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
/** A parallel plan: three tasks running at once (concurrent subagents). */
|
||||
const PARALLEL: TodoItem[] = [
|
||||
{ content: '搭骨架', status: 'completed' },
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '读源码', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
describe('planSummary', () => {
|
||||
it('counts done/total and names the single active item with no extra count', () => {
|
||||
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('reports the extra active count separately when several items are in progress', () => {
|
||||
// Parallel work marks several: naming one and hiding the rest would lose
|
||||
// them, and the count stays unjoined so the row cannot ellipsize it.
|
||||
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
|
||||
})
|
||||
|
||||
it('has no hint when nothing is in progress', () => {
|
||||
expect(planSummary([{ content: '都完了', status: 'completed' }]))
|
||||
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('has no hint when the first active item carries no usable content (model JSON)', () => {
|
||||
// Unvalidated args: a missing, mistyped, empty, or whitespace-only content
|
||||
// yields no hint — and no orphan count, even with a second active item to
|
||||
// count. Whitespace-only is the tool's own rejection rule (trimmed
|
||||
// non-empty), and a rejected call keeps its args verbatim.
|
||||
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('is empty-safe', () => {
|
||||
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
||||
@@ -80,6 +127,18 @@ describe('TodoPanel', () => {
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('marks every parallel active item, and counts them all in the header', () => {
|
||||
render(<TodoPanel todos={PARALLEL} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
// The old unconditional cap made this list unreachable: three items carry
|
||||
// the in-progress glyph at once, and the header counts all three.
|
||||
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
|
||||
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
|
||||
expect(screen.getByText('跑后台构建')).toBeTruthy()
|
||||
expect(screen.getByText('读源码')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an all-completed list collapses the summary to the done count alone', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
@@ -145,12 +204,30 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the extra active count outside the ellipsized summary text', () => {
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
|
||||
const text = screen.getByText('1/5 已完成 · 写组件')
|
||||
const extra = screen.getByText('+2')
|
||||
// Separate spans: .summary truncates, the count must not travel inside it.
|
||||
expect(text.contains(extra)).toBe(false)
|
||||
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
|
||||
})
|
||||
|
||||
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(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
|
||||
// planSummary yields activeContent null here, but the counts are known good,
|
||||
// so the row drops only the active clause — `?? model.summary` never runs.
|
||||
const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
|
||||
expect(screen.getByText('1/2 已完成')).toBeTruthy()
|
||||
expect(container.textContent).not.toContain('+')
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible through the shared row states', () => {
|
||||
// A running call (no result yet) carries the running state (row sweep).
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The one-line contract of the ToolRow summary line as CSS text. jsdom has no
|
||||
* layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans
|
||||
* exist but not whether a narrow row still fits on one line; these read the
|
||||
* declarations the layout depends on.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8')
|
||||
/** Declarations only: the sheet's prose names the properties it explains. */
|
||||
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
function declarations(selector: string): string[] {
|
||||
// Anchored at a rule boundary: an unanchored match would silently read a
|
||||
// compound rule that merely contains the selector (`.root:hover .summarySuffix`)
|
||||
// if one ever lands above the base rule.
|
||||
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText)
|
||||
if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`)
|
||||
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('ToolRow.module.css summary line', () => {
|
||||
it('keeps the summary suffix on one line and unshrunk', () => {
|
||||
// `flex: none` stops the box shrinking, not the text wrapping: without
|
||||
// `nowrap`, a row too narrow for title + separator + suffix wraps the `+n`
|
||||
// onto a second line — the exact case the slot exists to survive.
|
||||
expect(declarations('.summarySuffix')).toEqual(expect.arrayContaining([
|
||||
'flex: none',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
})
|
||||
|
||||
it('leaves the truncation to the summary text alone', () => {
|
||||
// The suffix must never ellipsize: a clipped count reads as a smaller
|
||||
// number rather than as missing information.
|
||||
expect(declarations('.summary')).toEqual(expect.arrayContaining([
|
||||
'overflow: hidden',
|
||||
'text-overflow: ellipsis',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis']))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user