refactor(web): move the produced-files row into its own plugin package
ui-conversation now owns only the conversation.chat.turnTail hole; the row, its derivation, and its copy live in @deepseek-ai/dsh-client-ui-deliverables, composed in or out by one cordis.yml line.
This commit is contained in:
183
packages/client/ui-deliverables/tests/produced-files.spec.tsx
Normal file
183
packages/client/ui-deliverables/tests/produced-files.spec.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ui-deliverables browser half: the derivation contract of
|
||||
* `producedForClosing` over finalized snapshot nodes, the row's rendering
|
||||
* and opener wiring, and the plugin registrations' fiber-teardown removal
|
||||
* (HMR safety) against the real SlotsService.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import { producedForClosing } from '../src/client/turn-deliverables.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user',
|
||||
seq,
|
||||
time: seq * 1000,
|
||||
content: [{ type: 'text', text }] as never,
|
||||
source: null,
|
||||
})
|
||||
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'write'),
|
||||
callView: {
|
||||
card: 'diff', title: `Write ${paths[0] ?? ''}`,
|
||||
diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
|
||||
locations: paths.map(path => ({ path })),
|
||||
},
|
||||
})
|
||||
|
||||
describe('producedForClosing derivation', () => {
|
||||
it('attributes each turn’s written files to the assistant that closes it', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'build it'),
|
||||
assistant(2, 'writing', 1),
|
||||
wrote(3, 'a', 'out/index.html'),
|
||||
// Same file touched twice in one turn is one deliverable, in first-seen order.
|
||||
wrote(4, 'b', 'out/app.css', 'out/index.html'),
|
||||
// A read is not a deliverable; a failed write has no file to open.
|
||||
{ ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } },
|
||||
{ ...wrote(6, 'd', 'out/broken.html'), isError: true },
|
||||
assistant(7, 'done', 1),
|
||||
user(8, 'again'),
|
||||
assistant(9, 'second turn', 2),
|
||||
]
|
||||
expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css'])
|
||||
// A turn that produced nothing yields the empty list, and so does an
|
||||
// anchor the window does not contain.
|
||||
expect(producedForClosing(nodes, 9)).toEqual([])
|
||||
expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([])
|
||||
expect(producedForClosing(nodes, 999)).toEqual([])
|
||||
})
|
||||
|
||||
it('counts a generic edit and never spills across the turn boundary', () => {
|
||||
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'str_replace_editor'),
|
||||
// str_replace_editor's insert mutates behind a generic card, so the
|
||||
// discriminant is the render intent, not the card shape alone.
|
||||
callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] },
|
||||
})
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'insert a line'),
|
||||
inserted(2, 'i', 'notes.md'),
|
||||
assistant(3, 'inserted', 1),
|
||||
// Turn 2 mutates and then ends with no content text (interrupted, or its
|
||||
// last text preceded the tool): its paths must not ride into turn 3.
|
||||
user(4, 'now rewrite it'),
|
||||
wrote(5, 'w', 'leaked.txt'),
|
||||
user(6, 'and again'),
|
||||
wrote(7, 'w2', 'notes.md'),
|
||||
assistant(8, 'done', 3),
|
||||
]
|
||||
expect(producedForClosing(nodes, 3)).toEqual(['notes.md'])
|
||||
// Turn 3 lists only its own file — and the dedup set did not suppress the
|
||||
// rewrite of a path an earlier turn already touched.
|
||||
expect(producedForClosing(nodes, 8)).toEqual(['notes.md'])
|
||||
expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt')
|
||||
})
|
||||
|
||||
it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'go'),
|
||||
// A turnless surface node neither tracks nor resets the boundary.
|
||||
{ kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null },
|
||||
wrote(2, 'w', 'turn-one.txt'),
|
||||
// A view-less result (window truncation) and cards without locations
|
||||
// contribute nothing rather than crashing the walk.
|
||||
toolResult(3, 'plain'),
|
||||
{ ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } },
|
||||
{ ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } },
|
||||
assistant(6, 'mid narration', 1),
|
||||
// Turn number advances with no user message in the window (truncated
|
||||
// history): the accumulator must reset all the same.
|
||||
assistant(7, 'closing', 2),
|
||||
]
|
||||
expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt'])
|
||||
expect(producedForClosing(nodes, 7)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProducedFiles row', () => {
|
||||
const t = makeTranslate(zh)
|
||||
|
||||
it('renders capped chips with the full path reachable and opens one on click', () => {
|
||||
// Seven files: six chips plus an explicit remainder — the row bounds what
|
||||
// it shows and says so rather than dropping the rest silently.
|
||||
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)]
|
||||
const view = render(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />)
|
||||
expect(view.getByText('产物')).toBeTruthy()
|
||||
// Chips carry the basename; the full path stays reachable as the title.
|
||||
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
|
||||
expect(chip.textContent).toBe('a.html')
|
||||
expect(chip.getAttribute('title')).toBe('deep/a.html')
|
||||
expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
|
||||
expect(view.getByText('还有 1 个')).toBeTruthy()
|
||||
fireEvent.click(chip)
|
||||
expect(openFile).toHaveBeenCalledWith('deep/a.html')
|
||||
})
|
||||
|
||||
it('a turn that produced nothing renders no row at all', () => {
|
||||
const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)]
|
||||
const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />)
|
||||
expect(view.container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('package shells', () => {
|
||||
it('the node half mounts inert and the invariant companion registers ownership', async () => {
|
||||
// The node half is deliberately inert; mounting it must simply not throw.
|
||||
applyNode()
|
||||
const registered: string[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('invariants')
|
||||
ctx.set('invariants', {
|
||||
register: (pkg: string) => { registered.push(pkg); return () => {} },
|
||||
} as never)
|
||||
const dispose = await applyInvariant(ctx)
|
||||
expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables'])
|
||||
expect(dispose).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin registration', () => {
|
||||
it('registers the tail entry and fiber disposal removes it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
// The owning view's child declaration, stood up by a bench root entry.
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user