test(chat): cover work detail modes and preparation subscriptions
This commit is contained in:
1 parent
d6fb1351b3
commit
ac77cefba7
11 files changed
+217
-55
No files matched your search
@@ -16,13 +16,14 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
|
||||
? node.location.turn
|
||||
: undefined
|
||||
const [now, setNow] = useState(Date.now)
|
||||
const ticking = turn?.status === 'open'
|
||||
const ticking = turn?.status === 'open' && turn.start !== undefined
|
||||
useEffect(() => {
|
||||
if (!ticking) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [ticking])
|
||||
if (turn?.start === undefined && turn?.status !== 'closed') return null
|
||||
const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node)
|
||||
const running = turn?.status === 'open'
|
||||
const reason = turn?.end?.data.reason.kind
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('Chat apply wiring', () => {
|
||||
.find(entry => entry.options.id === 'transcript-view')!
|
||||
const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
|
||||
|
||||
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
|
||||
expect(face.hooks.transcriptView.getSnapshot()).toBe('standard')
|
||||
face.setTranscriptView('detailed')
|
||||
expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed')
|
||||
expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed')
|
||||
|
||||
@@ -681,7 +681,7 @@ describe('ChatView', () => {
|
||||
expect(parent?.tagName).toBe('DIV')
|
||||
fireEvent.change(input, { target: { value: 'retained local input' } })
|
||||
const observe = vi.spyOn(ResizeObserver.prototype, 'observe')
|
||||
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
|
||||
for (const mode of ['standard', 'detailed', 'compact'] as const) {
|
||||
act(() => { h.setTranscriptView(mode) })
|
||||
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
|
||||
expect(input.value).toBe('retained local input')
|
||||
@@ -690,7 +690,7 @@ describe('ChatView', () => {
|
||||
}
|
||||
fireEvent.click(groupHeader)
|
||||
expect(groupHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
act(() => { h.setTranscriptView('expanded') })
|
||||
act(() => { h.setTranscriptView('detailed') })
|
||||
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
|
||||
act(() => { h.setTranscriptView('compact') })
|
||||
expect(view.queryByRole('textbox', { name: inside.key })).toBeNull()
|
||||
@@ -737,10 +737,10 @@ describe('ChatView', () => {
|
||||
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([true, true, true])
|
||||
fireEvent.click(headers[0]!)
|
||||
|
||||
for (const mode of ['detailed', 'expanded', 'compact', 'expanded'] as const) {
|
||||
for (const mode of ['standard', 'detailed', 'compact', 'detailed'] as const) {
|
||||
act(() => { h.setTranscriptView(mode) })
|
||||
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'expanded'])
|
||||
expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'expanded'])
|
||||
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'detailed'])
|
||||
expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'detailed'])
|
||||
expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots)
|
||||
}
|
||||
|
||||
@@ -753,6 +753,16 @@ describe('ChatView', () => {
|
||||
expect(bodies[2]!.hasAttribute('hidden')).toBe(true)
|
||||
expect(bodies[0]!.hasAttribute('hidden')).toBe(false)
|
||||
expect(bodies[1]!.hasAttribute('hidden')).toBe(true)
|
||||
|
||||
act(() => { h.setTranscriptView('verbose') })
|
||||
expect(roots.every(root => root.closest('[hidden]') === null)).toBe(true)
|
||||
expect(headers.every(header => header.closest('[hidden]') !== null)).toBe(true)
|
||||
expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true)
|
||||
expect(control.disabled).toBe(true)
|
||||
expect(control.textContent).toContain(h.props.t('message.turnProcess.took', { duration: formatRunDuration(3_000, h.props.t) }))
|
||||
fireEvent.click(control)
|
||||
expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true)
|
||||
expect([...view.container.querySelectorAll<HTMLElement>('[data-chat-group-key]')]).toEqual(roots)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -918,7 +928,7 @@ describe('ChatView', () => {
|
||||
expect(headers).toHaveLength(40)
|
||||
const titles = headers.map(header => header.textContent)
|
||||
|
||||
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
|
||||
for (const mode of ['standard', 'detailed', 'compact'] as const) {
|
||||
translate.mockClear()
|
||||
act(() => { h.setTranscriptView(mode) })
|
||||
expect(translate.mock.calls.filter(([key]) => key === 'message.stepProcess.thinking')).toHaveLength(0)
|
||||
@@ -936,7 +946,7 @@ describe('ChatView', () => {
|
||||
groupStore.publish()
|
||||
})
|
||||
translate.mockClear()
|
||||
act(() => { h.setTranscriptView('detailed') })
|
||||
act(() => { h.setTranscriptView('standard') })
|
||||
expect(translate.mock.calls.filter(([key]) => key.startsWith('message.stepProcess.'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -1319,9 +1329,9 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ mode: 'compact', anchor: 'group' }, { mode: 'detailed', anchor: 'group' },
|
||||
{ mode: 'expanded', anchor: 'group' },
|
||||
{ mode: 'compact', anchor: 'node' }, { mode: 'detailed', anchor: 'node' }, { mode: 'expanded', anchor: 'node' },
|
||||
{ mode: 'compact', anchor: 'group' }, { mode: 'standard', anchor: 'group' },
|
||||
{ mode: 'detailed', anchor: 'group' },
|
||||
{ mode: 'compact', anchor: 'node' }, { mode: 'standard', anchor: 'node' }, { mode: 'detailed', anchor: 'node' },
|
||||
] as const)(
|
||||
'keeps the first visible item in place when paging inserts a steering boundary ($mode, $anchor)', ({ mode, anchor }) => {
|
||||
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
|
||||
@@ -1387,14 +1397,14 @@ describe('ChatView', () => {
|
||||
|
||||
it.each([
|
||||
{ mode: 'compact', hasProcess: false, hasSteering: false },
|
||||
{ mode: 'standard', hasProcess: false, hasSteering: false },
|
||||
{ mode: 'detailed', hasProcess: false, hasSteering: false },
|
||||
{ mode: 'expanded', hasProcess: false, hasSteering: false },
|
||||
{ mode: 'compact', hasProcess: true, hasSteering: false },
|
||||
{ mode: 'standard', hasProcess: true, hasSteering: false },
|
||||
{ mode: 'detailed', hasProcess: true, hasSteering: false },
|
||||
{ mode: 'expanded', hasProcess: true, hasSteering: false },
|
||||
{ mode: 'compact', hasProcess: false, hasSteering: true },
|
||||
{ mode: 'standard', hasProcess: false, hasSteering: true },
|
||||
{ mode: 'detailed', hasProcess: false, hasSteering: true },
|
||||
{ mode: 'expanded', hasProcess: false, hasSteering: true },
|
||||
] as const)('keeps the loaded message anchored when paging exposes steering inside a closed Turn ($mode, process=$hasProcess, steer=$hasSteering)', ({ mode, hasProcess, hasSteering }) => {
|
||||
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
|
||||
const initial = {
|
||||
@@ -1716,7 +1726,7 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByText('即发即显')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['compact', 'detailed', 'expanded'] as const)(
|
||||
it.each(['compact', 'standard', 'detailed'] as const)(
|
||||
'keeps the opening echo above a new Turn title and steering below it (%s)', (mode) => {
|
||||
const opening = {
|
||||
requestId: 'opening' as never, placement: 'transcript' as const,
|
||||
@@ -1825,7 +1835,7 @@ describe('ChatView', () => {
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each(['compact', 'detailed', 'expanded'] as const)(
|
||||
it.each(['compact', 'standard', 'detailed'] as const)(
|
||||
'hides an admitted local steer while its Inbox claim projection is delayed (%s)', (mode) => {
|
||||
const pending: InboxState['next-step'] = ['first', 'second'].map(text => ({
|
||||
id: text as never, role: 'user', source: { kind: 'user', rpcId: text as never },
|
||||
@@ -2345,7 +2355,7 @@ describe('ChatView', () => {
|
||||
expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['compact', 'detailed', 'expanded'] as const)(
|
||||
it.each(['compact', 'standard', 'detailed'] as const)(
|
||||
'preserves steering-separated process groups in %s mode after Turn completion', (mode) => {
|
||||
const nodes = [
|
||||
userInTurn(1, 'question', 1),
|
||||
@@ -2389,7 +2399,7 @@ describe('ChatView', () => {
|
||||
view.getByText('second direction').closest('[data-chat-flow-kind]'),
|
||||
roots[2], view.getByText('final answer').closest('[data-chat-flow-kind]'),
|
||||
])
|
||||
for (const next of ['compact', 'detailed', 'expanded', mode] as const) {
|
||||
for (const next of ['compact', 'standard', 'detailed', mode] as const) {
|
||||
act(() => { h.setTranscriptView(next) })
|
||||
expect(visibleOrder()).toEqual(order)
|
||||
expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots)
|
||||
@@ -2476,14 +2486,14 @@ describe('ChatView', () => {
|
||||
expect(processRow.getAttribute('hidden')).toBe('until-found')
|
||||
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
|
||||
for (const mode of ['standard', 'detailed', 'compact'] as const) {
|
||||
act(() => { h.setTranscriptView(mode) })
|
||||
expect(turnProcessControl(view.container)).toBe(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(processRow.getAttribute('hidden')).toBe('until-found')
|
||||
}
|
||||
fireEvent.click(toggle)
|
||||
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
|
||||
for (const mode of ['standard', 'detailed', 'compact'] as const) {
|
||||
act(() => { h.setTranscriptView(mode) })
|
||||
expect(turnProcessControl(view.container)).toBe(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
@@ -2674,7 +2684,7 @@ describe('ChatView', () => {
|
||||
const toggle = view.getByRole('button', { name: h.props.t('message.turnProcess.worked') })
|
||||
let row = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:tool:partial"]')!
|
||||
let group = view.container.querySelector<HTMLElement>('[data-chat-group-key]')
|
||||
for (const mode of ['compact', 'detailed', 'expanded'] as const) {
|
||||
for (const mode of ['compact', 'standard', 'detailed'] as const) {
|
||||
act(() => { h.setTranscriptView(mode) })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(row.getAttribute('hidden')).toBe('until-found')
|
||||
@@ -2689,7 +2699,7 @@ describe('ChatView', () => {
|
||||
const beforePageGroup = group
|
||||
const groupToggle = group?.querySelector<HTMLButtonElement>('[data-process-activity]')
|
||||
if (grouped && manualOpen) {
|
||||
act(() => { h.setTranscriptView('detailed') })
|
||||
act(() => { h.setTranscriptView('standard') })
|
||||
fireEvent.click(groupToggle!)
|
||||
expect(groupToggle?.getAttribute('aria-expanded')).toBe('true')
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@ describe('ui-chat Host settings', () => {
|
||||
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' })
|
||||
await configuration.update({ transcriptView: 'normal' })
|
||||
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' })
|
||||
await expect(configuration.update({ transcriptView: 'dense' })).rejects.toThrow()
|
||||
for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) {
|
||||
await configuration.update({ transcriptView: mode })
|
||||
expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode })
|
||||
}
|
||||
for (const mode of ['dense', '', 42, false, {}, null, undefined]) {
|
||||
await configuration.update({ transcriptView: mode })
|
||||
expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: 'standard' })
|
||||
}
|
||||
await configuration.update({ performanceUsage: 'compact' })
|
||||
expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' })
|
||||
await expect(configuration.update({ performanceUsage: 'hidden' })).rejects.toThrow()
|
||||
|
||||
@@ -10,12 +10,13 @@ afterEach(cleanup)
|
||||
|
||||
describe('Chat presentation policy', () => {
|
||||
it.each([
|
||||
['compact', 'collapsed', false, false],
|
||||
['detailed', 'collapsed', true, true],
|
||||
['expanded', 'none', true, true],
|
||||
] as const)('maps %s to stable presentation capabilities', (mode, stepGrouping, settledReasoningPreview, liveProcessDetail) => {
|
||||
['compact', true, 'collapsed', false, false],
|
||||
['standard', true, 'collapsed', true, true],
|
||||
['detailed', true, 'history', true, true],
|
||||
['verbose', false, 'none', true, false],
|
||||
] as const)('maps %s to stable presentation capabilities', (mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail) => {
|
||||
const policy = presentationPolicyFor(mode)
|
||||
expect(policy).toEqual({ mode, foldCompletedTurns: true, stepGrouping, settledReasoningPreview, liveProcessDetail })
|
||||
expect(policy).toEqual({ mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail })
|
||||
expect(presentationPolicyFor(mode)).toBe(policy)
|
||||
})
|
||||
|
||||
@@ -29,7 +30,7 @@ describe('Chat presentation policy', () => {
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed'))
|
||||
dispose()
|
||||
mode.set('expanded')
|
||||
mode.set('verbose')
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -54,7 +55,7 @@ describe('Chat presentation policy', () => {
|
||||
act(() => { mode.set('detailed') })
|
||||
expect(foldRender).toHaveBeenCalledTimes(1)
|
||||
expect(previewRender).toHaveBeenCalledTimes(2)
|
||||
act(() => { mode.set('expanded') })
|
||||
act(() => { mode.set('standard') })
|
||||
expect(foldRender).toHaveBeenCalledTimes(1)
|
||||
expect(previewRender).toHaveBeenCalledTimes(2)
|
||||
act(() => { mode.set('compact') })
|
||||
|
||||
@@ -45,14 +45,14 @@ describe('ReasoningRow', () => {
|
||||
it('retains summaries and expanded Markdown when the work-details mode changes', () => {
|
||||
const reset = createSnapshotStore(0)
|
||||
const useDisclosure = bindDisclosure(reset)
|
||||
const mode = createSnapshotStore<TranscriptViewMode>('detailed')
|
||||
const mode = createSnapshotStore<TranscriptViewMode>('standard')
|
||||
const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode))
|
||||
const view = render(<ReasoningRow useDisclosure={useDisclosure}
|
||||
text={'First line\n\nDetailed body'} running={false} usePresentation={usePresentation} t={t} />)
|
||||
const root = view.container.querySelector('[data-variant="think"]')!
|
||||
const summary = view.getByText('First line')
|
||||
const toggle = view.getByRole('button')
|
||||
for (const next of ['compact', 'detailed', 'expanded', 'compact'] as const) {
|
||||
for (const next of ['compact', 'standard', 'detailed', 'verbose', 'compact'] as const) {
|
||||
act(() => { mode.set(next) })
|
||||
expect(view.getByText('First line')).toBe(summary)
|
||||
expect(root.hasAttribute('data-preview')).toBe(next !== 'compact')
|
||||
@@ -60,7 +60,7 @@ describe('ReasoningRow', () => {
|
||||
}
|
||||
fireEvent.click(toggle)
|
||||
const body = view.getByText('Detailed body')
|
||||
for (const next of ['detailed', 'expanded', 'compact'] as const) {
|
||||
for (const next of ['standard', 'detailed', 'verbose', 'compact'] as const) {
|
||||
act(() => { mode.set(next) })
|
||||
expect(view.getByText('Detailed body')).toBe(body)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ChatSettings } from '../src/chat-settings.ts'
|
||||
import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
|
||||
|
||||
describe('TranscriptViewPolicy', () => {
|
||||
it('defaults to Compact and publishes explicit choices before persistence settles', () => {
|
||||
it('defaults to Standard and publishes explicit choices before persistence settles', () => {
|
||||
const host = stubConfigForm<ChatSettings>()
|
||||
const observed: string[] = []
|
||||
let current = (): string => 'unconstructed'
|
||||
@@ -19,20 +19,20 @@ describe('TranscriptViewPolicy', () => {
|
||||
const policy = new TranscriptViewPolicy(scope)
|
||||
current = () => policy.mode.getSnapshot()
|
||||
|
||||
expect(policy.mode.getSnapshot()).toBe('compact')
|
||||
expect(policy.mode.getSnapshot()).toBe('standard')
|
||||
policy.setMode('detailed')
|
||||
expect(policy.mode.getSnapshot()).toBe('detailed')
|
||||
expect(observed).toEqual(['transcriptView=detailed:detailed'])
|
||||
expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed')
|
||||
})
|
||||
|
||||
it('adopts Host state, reads the legacy value as Detailed, and ignores identical writes', () => {
|
||||
it('adopts Host state, reads normal as Standard, and ignores identical writes', () => {
|
||||
const host = stubConfigForm<ChatSettings>()
|
||||
const policy = new TranscriptViewPolicy(host.scope)
|
||||
|
||||
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
|
||||
expect(policy.mode.getSnapshot()).toBe('detailed')
|
||||
policy.setMode('detailed')
|
||||
expect(policy.mode.getSnapshot()).toBe('standard')
|
||||
policy.setMode('standard')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
|
||||
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 })
|
||||
@@ -42,7 +42,17 @@ describe('TranscriptViewPolicy', () => {
|
||||
it('adopts an accepted section standing at construction', () => {
|
||||
const host = stubConfigForm<ChatSettings>()
|
||||
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true })
|
||||
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded')
|
||||
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('detailed')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['compact', 'standard', 'detailed', 'verbose'] as const)('preserves an explicit %s setting without migration writes', (mode) => {
|
||||
const host = stubConfigForm<ChatSettings>()
|
||||
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true })
|
||||
const policy = new TranscriptViewPolicy(host.scope)
|
||||
expect(policy.mode.getSnapshot()).toBe(mode)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
policy.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function noPendingInteraction() {
|
||||
// The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
|
||||
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
|
||||
|
||||
function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typeof zh = en) {
|
||||
function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | typeof zh = en) {
|
||||
const source = createSnapshotStore<TranscriptViewMode>(mode)
|
||||
const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) })
|
||||
const props: TranscriptViewRowProps = {
|
||||
@@ -53,20 +53,22 @@ function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typ
|
||||
}
|
||||
|
||||
describe('TranscriptViewRow', () => {
|
||||
it('explains the preference and shows Compact by default', () => {
|
||||
it('explains the preference and shows Standard by default', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Work details')).toBeDefined()
|
||||
expect(screen.getByText('Controls how turns and steps expand by default')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByText('Choose how much detail to show for tool calls')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /Standard/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['compact', 'Compact'],
|
||||
['detailed', 'Detailed'],
|
||||
['expanded', 'Expanded'],
|
||||
['verbose', 'Verbose'],
|
||||
] as const)('selects %s and follows the mirrored value', (mode, label) => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Compact/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Standard/ }))
|
||||
expect(screen.queryByRole('menuitem', { name: 'Normal' })).toBeNull()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Expanded' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: label }))
|
||||
expect(b.setTranscriptView).toHaveBeenCalledWith(mode)
|
||||
const trigger = screen.getByRole('button', { name: label })
|
||||
@@ -76,21 +78,25 @@ describe('TranscriptViewRow', () => {
|
||||
expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows all three work-detail values in Chinese', () => {
|
||||
it('shows all four work-detail values in Chinese', () => {
|
||||
const b = mount('compact', zh)
|
||||
expect(screen.getByText('工作过程展示')).toBeDefined()
|
||||
expect(screen.getByText('工作步骤展示')).toBeDefined()
|
||||
expect(screen.getByText('选择希望看到多少工具调用细节')).toBeDefined()
|
||||
fireEvent.click(screen.getByRole('button', { name: '简洁' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '标准' }))
|
||||
expect(b.setTranscriptView).toHaveBeenLastCalledWith('standard')
|
||||
fireEvent.click(screen.getByRole('button', { name: '标准' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '详细' }))
|
||||
expect(b.setTranscriptView).toHaveBeenLastCalledWith('detailed')
|
||||
fireEvent.click(screen.getByRole('button', { name: '详细' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '完全展开' }))
|
||||
expect(b.setTranscriptView).toHaveBeenLastCalledWith('expanded')
|
||||
expect(b.setTranscriptView).toHaveBeenLastCalledWith('verbose')
|
||||
expect(screen.getByRole('button', { name: '完全展开' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns focus before publishing a mode change without refocusing after the menu closes', async () => {
|
||||
const b = mount()
|
||||
const trigger = screen.getByRole('button', { name: /Compact/ })
|
||||
const trigger = screen.getByRole('button', { name: /Standard/ })
|
||||
fireEvent.click(trigger)
|
||||
const item = screen.getByRole('menuitem', { name: 'Detailed' })
|
||||
item.focus()
|
||||
@@ -173,7 +179,7 @@ describe('PerformanceUsageRow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Detailed' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Compact' }))
|
||||
expect(setPerformanceUsage).toHaveBeenCalledWith('compact')
|
||||
expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(2)
|
||||
expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(1)
|
||||
expect(b.setTranscriptView).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -207,6 +207,7 @@ describe('FileMutationRow diff card', () => {
|
||||
})
|
||||
|
||||
const rowProps = (block: StartedToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
|
||||
useToolCallArgumentsPartial: () => { throw new Error('dispatched calls must not subscribe to preparing arguments') },
|
||||
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), cwd: '/w/app',
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
t,
|
||||
|
||||
@@ -27,7 +27,7 @@ function preparation(name: string): Props {
|
||||
return {
|
||||
phase: 'preparing', callId: 'call', toolName: name,
|
||||
block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] },
|
||||
t: makeTranslate(en, common), useDisclosure, openFile: vi.fn(), loadImage: vi.fn(),
|
||||
t: makeTranslate(en, common), useDisclosure, useToolCallArgumentsPartial: vi.fn(() => ''), openFile: vi.fn(), loadImage: vi.fn(),
|
||||
useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null),
|
||||
} as Props
|
||||
}
|
||||
@@ -53,16 +53,19 @@ describe('argument-free tool preparation', () => {
|
||||
expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the common write row mounted as complete arguments and a result arrive', () => {
|
||||
it('replaces preparation with the dispatched row and retains that row through the result', () => {
|
||||
const props = preparation('write')
|
||||
const view = render(<FileMutationRow {...props} />)
|
||||
const row = view.container.querySelector('[data-tool="write"]')
|
||||
const preparingRow = view.container.querySelector('[data-tool="write"]')
|
||||
vi.mocked(props.useToolCallArgumentsPartial).mockClear()
|
||||
const started: StartedToolCall = {
|
||||
phase: 'start', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [],
|
||||
argsRaw: '{"file_path":"hello.txt","content":"hello"}',
|
||||
}
|
||||
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
|
||||
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
|
||||
const row = view.container.querySelector('[data-tool="write"]')
|
||||
expect(row).not.toBe(preparingRow)
|
||||
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
|
||||
expect(view.getByText('hello.txt')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
const result: ToolResultNode = {
|
||||
@@ -71,6 +74,7 @@ describe('argument-free tool preparation', () => {
|
||||
}
|
||||
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
|
||||
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
|
||||
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Call-scoped preparation subscriptions and file-mutation progress. */
|
||||
import { useMemo } from 'react'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { AssistantChatData, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import type { ToolCallHookContext } from '../src/client/contract/slots.ts'
|
||||
import { bindToolCallArgumentsPartial } from '../src/client/tool/tool-call-arguments-partial.ts'
|
||||
import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const standard = {} as Parameters<typeof bindToolCallArgumentsPartial>[0]
|
||||
const data = (first: string, second = ''): AssistantChatData => ({
|
||||
status: 'running', turn: 1, step: 1, time: 1,
|
||||
blocks: [
|
||||
{ kind: 'text', text: 'Preparing changes' },
|
||||
{ kind: 'tool-call', callId: 'first', name: 'write', argsRaw: first },
|
||||
{ kind: 'tool-call', callId: 'second', name: 'edit', argsRaw: second },
|
||||
],
|
||||
})
|
||||
|
||||
function source(initial: AssistantChatData | undefined) {
|
||||
const store = createSnapshotStore<AssistantChatData | undefined>(initial)
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
set: store.set,
|
||||
listeners,
|
||||
getSnapshot: store.getSnapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
const dispose = store.subscribe(listener)
|
||||
return () => { listeners.delete(listener); dispose() }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function Probe({ context, label, rendered }: { context: ToolCallHookContext; label: string; rendered: () => void }) {
|
||||
const usePartial = useMemo(() => bindToolCallArgumentsPartial(standard, context), [context])
|
||||
const raw = usePartial()
|
||||
rendered()
|
||||
return <output aria-label={label}>{raw}</output>
|
||||
}
|
||||
|
||||
describe('tool argument prefix Hook', () => {
|
||||
it('does not subscribe until used and refreshes only the call whose prefix changed', () => {
|
||||
const assistant = source(data('{', 'second prefix'))
|
||||
const first = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
|
||||
expect(assistant.listeners.size).toBe(0)
|
||||
const firstRendered = vi.fn()
|
||||
const secondRendered = vi.fn()
|
||||
const firstContext = { assistant, callId: 'first' }
|
||||
const secondContext = { assistant, callId: 'second' }
|
||||
const view = render(<>
|
||||
<Probe context={firstContext} label="first" rendered={firstRendered} />
|
||||
<Probe context={secondContext} label="second" rendered={secondRendered} />
|
||||
</>)
|
||||
expect(first).toBeTypeOf('function')
|
||||
expect(assistant.listeners.size).toBe(2)
|
||||
firstRendered.mockClear()
|
||||
secondRendered.mockClear()
|
||||
act(() => { assistant.set(data('{"file_path":', 'second prefix')) })
|
||||
expect(view.getByLabelText('first').textContent).toBe('{"file_path":')
|
||||
expect(firstRendered).toHaveBeenCalled()
|
||||
expect(secondRendered).not.toHaveBeenCalled()
|
||||
firstRendered.mockClear()
|
||||
act(() => { assistant.set(data('{"file_path":', 'more second prefix')) })
|
||||
expect(firstRendered).not.toHaveBeenCalled()
|
||||
expect(view.getByLabelText('second').textContent).toBe('more second prefix')
|
||||
view.unmount()
|
||||
expect(assistant.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('handles missing sources and calls and detaches a replaced Step source', () => {
|
||||
const old = source(undefined)
|
||||
const next = source(data('new step'))
|
||||
const rendered = vi.fn()
|
||||
const view = render(<Probe context={{ assistant: old, callId: 'missing' }} label="raw" rendered={rendered} />)
|
||||
expect(view.getByLabelText('raw').textContent).toBe('')
|
||||
act(() => { old.set(data('unrelated')) })
|
||||
expect(view.getByLabelText('raw').textContent).toBe('')
|
||||
view.rerender(<Probe context={{ assistant: next, callId: 'first' }} label="raw" rendered={rendered} />)
|
||||
expect(old.listeners.size).toBe(0)
|
||||
expect(next.listeners.size).toBe(1)
|
||||
expect(view.getByLabelText('raw').textContent).toBe('new step')
|
||||
view.rerender(<Probe context={{ assistant: undefined, callId: 'first' }} label="raw" rendered={rendered} />)
|
||||
expect(next.listeners.size).toBe(0)
|
||||
expect(view.getByLabelText('raw').textContent).toBe('')
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['write', en, 'Preparing content'], ['edit', zh, '正在准备内容'],
|
||||
] as const)('%s subscribes only during preparation and rounds raw length upward', (toolName, dictionary, label) => {
|
||||
const assistant = source(data(''))
|
||||
const usePartial = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
|
||||
const props = {
|
||||
phase: 'preparing', toolName, callId: 'first', useToolCallArgumentsPartial: usePartial,
|
||||
block: { phase: 'preparing', callId: 'first', name: toolName, turn: 1, step: 1, time: 1, subCalls: [] },
|
||||
useDisclosure, openFile: vi.fn(), loadImage: vi.fn(), t: makeTranslate(dictionary),
|
||||
} as Parameters<typeof FileMutationRow>[0]
|
||||
const view = render(<FileMutationRow {...props} />)
|
||||
for (const [length, kilobytes] of [[0, 0], [1, 1], [1024, 1], [1025, 2], [12 * 1024, 12]]) {
|
||||
act(() => { assistant.set(data('中'.repeat(length!))) })
|
||||
expect(view.getByText(`${label} ${kilobytes}KB`)).toBeTruthy()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
}
|
||||
expect(assistant.listeners.size).toBe(1)
|
||||
const started = { phase: 'start' as const, callId: 'first', name: toolName, turn: 1, step: 1, time: 2, subCalls: [], argsRaw: '{"file_path":"file.txt","content":"hello"}' }
|
||||
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
|
||||
expect(assistant.listeners.size).toBe(0)
|
||||
expect(view.getByText('file.txt')).toBeTruthy()
|
||||
const result: ToolResultNode = { kind: 'tool-result', callId: 'first', seq: 3, time: 3, callTime: 2, call: { name: toolName, argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [] }
|
||||
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
|
||||
expect(assistant.listeners.size).toBe(0)
|
||||
expect(view.getByText('file.txt')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user