Files
deepseek-harness/packages/client/ui-trajectory/tests/layout.spec.tsx
T
2026-07-24 14:36:44 +08:00

207 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
/**
* Trajectory turn chrome and layout fold: expand blocks, usage on Message,
* tool own-duration, group wall-span descriptions, in-flight rows.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
afterEach(cleanup)
describe('TrajectoryTurnHeader', () => {
it('renders Turn N and the four metric column labels', () => {
render(<TrajectoryTurnHeader turn={1} />)
expect(screen.getByText('Turn 1')).toBeTruthy()
expect(screen.getByText('Input')).toBeTruthy()
expect(screen.getByText('Output')).toBeTruthy()
expect(screen.getByText('Think')).toBeTruthy()
expect(screen.getByText('Time')).toBeTruthy()
})
})
describe('TrajectoryGroupHeader', () => {
it('renders title and optional description', () => {
render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
expect(screen.getByText('Step 1')).toBeTruthy()
expect(screen.getByText('2.2s skill')).toBeTruthy()
})
it('omits the description node when absent', () => {
const { container } = render(<TrajectoryGroupHeader title="Message" />)
expect(screen.getByText('Message')).toBeTruthy()
expect(container.querySelectorAll('span')).toHaveLength(1)
})
})
describe('TrajectoryTurn', () => {
it('wraps a sticky header and body children', () => {
render(
<TrajectoryTurn turn={3}>
<TrajectoryGroupHeader title="Message" description="49s" />
</TrajectoryTurn>,
)
expect(screen.getByText('Turn 3')).toBeTruthy()
expect(screen.getByText('Message')).toBeTruthy()
expect(screen.getByText('49s')).toBeTruthy()
})
})
describe('deriveTrajectoryLayout', () => {
it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
{
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
blocks: [
{ kind: 'reasoning', text: 'thinking…' },
{ kind: 'text', text: 'I will run bash' },
{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
],
usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
},
{
kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
expect(kinds).toEqual(['user', 'message', 'tool'])
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
expect(message).toMatchObject({
input: 10, output: 20, think: 5, timeSeconds: 5,
})
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
expect(tool?.text).toBe('bash · {"command":"ls"}')
expect(tool?.timeSeconds).toBe(1.3)
})
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
nodes: [] as unknown as ConversationSnapshot['nodes'],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null,
}],
})
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
})
})
it('omits duration when node times are missing instead of rendering NaN', () => {
const nodes = [
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
{
kind: 'assistant', seq: 2, turn: 1, step: 1,
blocks: [
{ kind: 'reasoning', text: '…' },
{ kind: 'text', text: 'ok' },
],
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
})
it('builds a wall-span step description with a tool histogram', () => {
const nodes = [
{
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
blocks: [
{ kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
{ kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
],
},
{
kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
content: [], isError: false, callView: null, resultView: null,
},
{
kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
})
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
blocks: [{ kind: 'text', text: 'ok1' }],
},
{ kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
{
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
blocks: [{ kind: 'text', text: 'ok2' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns.map((t) => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
})
it('keeps usage on the fallback Message row when assistant has no text block', () => {
const nodes = [
{
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
blocks: [{ kind: 'reasoning', text: '…' }],
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3,
})
})
it('advances the duration cursor over context nodes', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
},
{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
content: [], isError: false, callView: null, resultView: null,
},
{
kind: 'context', seq: 4, time: 9_000,
content: [{ type: 'text', text: 'extra' }], source: null,
},
{
kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap((g) => g.cells)
.find((c) => c.kind === 'message' && c.text === 'done')
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
})
})