cleanup(client): extract Tool presentation into ui-tool
This commit is contained in:
149
packages/client/ui-tool/tests/assembly-surfaces.spec.tsx
Normal file
149
packages/client/ui-tool/tests/assembly-surfaces.spec.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Tool assembly acceptance through the real ui-conversation host. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
const TODOS: TodoItem[] = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
|
||||
const todoResult = (seq: number): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
|
||||
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
|
||||
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
|
||||
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
||||
function AppRoot({ renderSlot }: AppRootProps) {
|
||||
return <>{renderSlot('conversation', {})}</>
|
||||
}
|
||||
|
||||
const LAYOUT_CHILDREN = {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
} as const
|
||||
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
snapshot: { nodes },
|
||||
session: {
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
},
|
||||
})
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
|
||||
await runtime.mount({ inject: [...injectTool], apply: applyTool })
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
|
||||
const runtime = await bench([todoResult(3)])
|
||||
// The dock strip reads the host-computed 'todos' projection.
|
||||
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed toolview registration took the row (summary derived from args).
|
||||
const row = view.container.querySelector('[data-tool="todo_write"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
|
||||
|
||||
// The plan strip sits in the input dock, fed by the projection
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
|
||||
// Next turn retires the standing plan (host pushes null): the strip
|
||||
// clears while the historical row stays in the flow.
|
||||
await runtime.flush()
|
||||
runtime.sessions.behavior(SID).projections.set('todos', null)
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||
})
|
||||
expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal card assembly', () => {
|
||||
it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
|
||||
const runtime = await bench([
|
||||
bashResult(3, 'c-keyed'),
|
||||
// An unregistered tool with terminal views: GenericToolCard fallback.
|
||||
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
|
||||
])
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
|
||||
const keyedRow = view.container.querySelector('[data-sample="bash"]')
|
||||
const keyed = keyedRow?.parentElement
|
||||
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(keyedRow!)
|
||||
await waitFor(() => {
|
||||
expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
|
||||
// Fallback row: same unified expand interaction.
|
||||
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
||||
expect(fallback).not.toBeNull()
|
||||
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
||||
fireEvent.click(fallback!.querySelector('[data-expandable]')!)
|
||||
await waitFor(() => {
|
||||
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user