test(chat): align turn presentation and deterministic clock fixtures

This commit is contained in:
imccyu committed 2026-09-22 00:45:46 +08:00
1 parent 99c0e8dcf6
commit 22cd2cd387
2 files changed
+179 -87

No files matched your search

@@ -408,14 +408,16 @@ export function chatSnapshotFixture(input: {
const controlAnchor = inTurn.find(candidate => candidate.kind === 'assistant-step'
|| candidate.kind === 'tool-call'
|| candidate.kind === 'model-retry')
if (controlAnchor === undefined) continue
const turn = turns.get(turnNumber)
const controlAnchorSeq = controlAnchor?.anchorSeq ?? turn?.start?.seq
if (controlAnchorSeq === undefined) continue
const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind))
?? controlAnchor
const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true
const candidate: TurnProcessSpec = {
turn: turnNumber,
controlAnchorSeq: controlAnchor.anchorSeq,
processStartSeq: processStart.anchorSeq,
controlAnchorSeq,
processStartSeq: processStart?.anchorSeq ?? controlAnchorSeq,
answerAnchorSeq: answer?.finalNode.seq ?? null,
answerStep: answer?.step ?? null,
inlineReasoning: answer !== undefined && inlineReasoning,
@@ -437,7 +439,6 @@ export function chatSnapshotFixture(input: {
? previousSpec
: candidate
dataStore.set('turn-process', spec)
const turn = turns.get(turnNumber)
if (turn !== undefined) {
nodes.push({
key: `fixture:turn-process:${String(turnNumber)}`,
@@ -71,6 +71,7 @@ const useResource = (() => ({ status: 'none' as const, value: undefined, failure
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
@@ -503,6 +504,17 @@ function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
return container.querySelector<HTMLButtonElement>('[data-turn-process]')
}
function withClock(time: number, run: () => void): void {
vi.useFakeTimers()
try {
vi.setSystemTime(time)
run()
} finally {
cleanup()
vi.useRealTimers()
}
}
function withSystemPrompt(
snapshot: ChatSnapshot,
builder = new ChatSnapshotBuilder(),
@@ -1424,7 +1436,7 @@ describe('ChatView', () => {
])
})
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => withClock(2_000, () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -1447,7 +1459,7 @@ describe('ChatView', () => {
text: 'later',
}
const h = makeHarness(
{ nodes: [assistant(1, 'working')] },
{ nodes: [assistant(1, 'working')], turnTimings: new Map([[1, { startTime: 0 }]]) },
{ testInbox: { 'next-turn': [queued], 'next-step': [pending] }, running: true },
)
const view = render(<h.ChatView {...h.props} />)
@@ -1459,6 +1471,7 @@ describe('ChatView', () => {
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时2秒')
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
@@ -1497,7 +1510,7 @@ describe('ChatView', () => {
// The branch action sends the real turn/end seq (the exact inclusive
// Host boundary), not the assistant node seq.
expect(h.forkAt).toHaveBeenCalledWith(3)
})
}))
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
const pending = {
@@ -1833,7 +1846,7 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
const toggle = view.getByRole('button', { name: '用时 4秒' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1')
expect(toggle.getAttribute('data-turn-process-messages')).toBe('1')
@@ -1859,17 +1872,17 @@ describe('ChatView', () => {
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
act(() => { h.set({ nodes: [user(1, 'question'), first] }) })
expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false')
expect(view.getByRole('button', { name: '用时 4秒' }).getAttribute('aria-expanded')).toBe('false')
expect(members[0]?.getAttribute('hidden')).toBeNull()
act(() => { h.set({
nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second],
}) })
const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
const renewedToggle = view.getByRole('button', { name: '用时 4秒' })
expect(renewedToggle.getAttribute('aria-expanded')).toBe('true')
expect(members[0]?.getAttribute('hidden')).toBeNull()
})
it('folds injected Context in place with the rest of the Turn process', () => {
it('omits injected Context while folding and revealing the visible Turn process', () => {
const h = makeHarness({
nodes: [
user(1, 'question'),
@@ -1885,15 +1898,18 @@ describe('ChatView', () => {
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
expect(members).toHaveLength(3)
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call'])
expect(contextRow).not.toBeNull()
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
fireEvent(contextRow!, new Event('beforematch'))
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
expect(members).toHaveLength(2)
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['assistant-step', 'tool-call'])
expect(contextRow).toBeNull()
expect(view.queryByText('runtime policy changed')).toBeNull()
expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
fireEvent(members[1]!, new Event('beforematch'))
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
expect(view.container.querySelector('[data-chat-flow-kind="context"]')).toBeNull()
})
it('omits the System prompt through Turn completion and process expansion', () => {
it('omits the System prompt through Turn completion and process expansion', () => withClock(4_000, () => {
const builder = new ChatSnapshotBuilder()
const initial = withSystemPrompt(chatSnapshotFixture({
nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
@@ -1901,7 +1917,8 @@ describe('ChatView', () => {
}), builder)
const h = makeHarness({ chat: initial }, { running: true })
const view = render(<h.ChatView {...h.props} />)
expect(renderedFlowKinds(view.container)).toEqual(['user', 'context'])
expect(renderedFlowKinds(view.container).filter(kind => kind !== 'turn-process')).toEqual(['user'])
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时4秒')
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
act(() => {
@@ -1918,7 +1935,7 @@ describe('ChatView', () => {
})
})
expect(renderedFlowKinds(view.container)).toEqual([
'user', 'turn-process', 'context', 'assistant-step',
'user', 'turn-process', 'assistant-step',
])
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
@@ -1940,38 +1957,41 @@ describe('ChatView', () => {
const toggle = turnProcessControl(view.container)!
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
expect(renderedFlowKinds(view.container)).toEqual([
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
'user', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail',
])
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step'])
expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['assistant-step'])
expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found'])
fireEvent.click(toggle)
expect(renderedFlowKinds(view.container)).toEqual([
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
'user', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail',
])
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
})
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null])
}))
it('folds Context under the fallback title when every summary count is zero', () => {
it('keeps the fallback title without a disclosure when only Context precedes the answer', () => {
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)],
turnEnds: new Map([[1, 4]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已思考' })
const toggle = view.getByRole('button', { name: '已完成工作' }) as HTMLButtonElement
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.disabled).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBeNull()
expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0')
expect(toggle.getAttribute('data-turn-process-messages')).toBe('0')
expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0')
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
expect(contextRow).toBeNull()
expect(view.container.querySelector('[data-turn-process-member]')).toBeNull()
expect(view.queryByText('runtime policy')).toBeNull()
fireEvent.click(toggle)
expect(contextRow?.getAttribute('hidden')).toBeNull()
expect(view.container.querySelector('[data-chat-flow-kind="context"]')).toBeNull()
expect(view.getByText('final answer').closest('[hidden]')).toBeNull()
})
it('keeps ordinary spacing when steering separates the process control from its answer', () => {
@@ -2008,7 +2028,7 @@ describe('ChatView', () => {
expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
})
it('keeps a live Turn expanded and folds it once at turn/end', () => {
it('keeps a live Turn expanded and folds it once at turn/end', () => withClock(3_000, () => {
const process = assistant(2, 'inspect', 1, 1)
const h = makeHarness({
nodes: [user(1, 'question'), process],
@@ -2017,8 +2037,12 @@ describe('ChatView', () => {
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(turnProcessControl(view.container)).toBeNull()
const liveToggle = view.getByRole('button', { name: '深度求索中,用时3秒' }) as HTMLButtonElement
expect(liveToggle.disabled).toBe(true)
expect(liveToggle.getAttribute('aria-expanded')).toBe('true')
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
fireEvent.click(liveToggle)
expect(processRow.getAttribute('hidden')).toBeNull()
act(() => {
h.set({
@@ -2029,9 +2053,17 @@ describe('ChatView', () => {
})
})
const toggle = turnProcessControl(view.container)!
expect(toggle).toBe(liveToggle)
expect(toggle.textContent).toBe('用时 5秒')
expect(toggle.disabled).toBe(false)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
})
fireEvent.click(toggle)
act(() => { vi.advanceTimersByTime(1_000) })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.textContent).toBe('用时 5秒')
expect(processRow.getAttribute('hidden')).toBeNull()
}))
it('preserves whole-Turn folding and manual expansion across work-details modes', () => {
const process = assistant(2, 'inspect', 1, 1)
@@ -2066,23 +2098,28 @@ describe('ChatView', () => {
const final = {
...assistant(3, 'final answer', 1, 1),
blocks: [
{ kind: 'reasoning' as const, text: 'private analysis' },
{ kind: 'reasoning' as const, text: 'private analysis\nCheck the final decision' },
{ kind: 'text' as const, text: 'final answer' },
],
}
const h = makeHarness({
nodes: [user(1, 'question'), final],
turnEnds: new Map([[1, 4]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已思考' })
const toggle = view.getByRole('button', { name: '已完成工作' })
const reasoning = view.container.querySelector<HTMLElement>('[data-turn-process-inline]')
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(reasoning?.getAttribute('hidden')).toBe('until-found')
expect(view.getByText('final answer')).toBeTruthy()
fireEvent.click(toggle)
expect(view.getByText('private analysis')).toBeTruthy()
const think = within(reasoning!).getByRole('button', { name: /^思考/ })
expect(think.getAttribute('aria-expanded')).toBe('false')
expect(reasoning?.getAttribute('hidden')).toBeNull()
expect(view.queryByText(/Check the final decision/)).toBeNull()
fireEvent.click(think)
expect(think.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check the final decision/)).toBeTruthy()
})
it('folds a completed Turn even while the reader is away from the tail', () => {
@@ -2109,64 +2146,69 @@ describe('ChatView', () => {
})
it('folds when the process controller first appears off-tail', () => {
const process = reasoningAssistant(2, 'inspect the repository', 1, 1)
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
nodes: [user(1, 'question'), process],
running: true,
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(turnProcessControl(view.container)).toBeNull()
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
const processRow = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:assistant:2"]')!
expect(processRow.getAttribute('hidden')).toBeNull()
readerScroll(scroller, 100)
act(() => { h.set({
nodes: [
user(1, 'question'),
context(2, 'runtime policy', 1),
assistant(3, 'final answer', 1, 1),
process,
assistant(3, 'final answer', 1, 2),
],
running: false,
turnEnds: new Map([[1, 4]]),
}) })
const toggle = turnProcessControl(view.container)!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
expect(toggle.textContent).toBe('已完成工作')
expect(view.container.querySelector('[data-chat-node-key="fixture:assistant:2"]')).toBe(processRow)
expect(processRow.getAttribute('hidden')).toBe('until-found')
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
it('keeps a focused process row visible when a live Turn completes', () => {
it('keeps a focused process row visible when a live Turn completes', () => withClock(2_000, () => {
const process = reasoningAssistant(2, 'inspect the repository', 1, 1)
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
nodes: [user(1, 'question'), process],
running: true,
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const contextToggle = view.getByRole('button', { name: '上下文注入' })
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
contextToggle.focus()
expect(document.activeElement).toBe(contextToggle)
const processRow = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:assistant:2"]')!
const thinkToggle = within(processRow).getByRole('button', { name: /^思考/ })
thinkToggle.focus()
expect(document.activeElement).toBe(thinkToggle)
act(() => { h.set({
nodes: [
user(1, 'question'),
context(2, 'runtime policy', 1),
assistant(3, 'final answer', 1, 1),
process,
assistant(3, 'final answer', 1, 2),
],
running: false,
turnEnds: new Map([[1, 4]]),
}) })
const processToggle = turnProcessControl(view.container)!
expect(processToggle.getAttribute('aria-expanded')).toBe('true')
expect(contextRow?.getAttribute('hidden')).toBeNull()
expect(document.activeElement).toBe(contextToggle)
expect(processRow.getAttribute('hidden')).toBeNull()
expect(document.activeElement).toBe(thinkToggle)
fireEvent.click(processToggle)
expect(document.activeElement).toBe(processToggle)
expect(processToggle.getAttribute('aria-expanded')).toBe('false')
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
})
expect(processRow.getAttribute('hidden')).toBe('until-found')
}))
it('folds a recorded Turn end independently of remaining history or a loaded start', () => {
const h = makeHarness({
@@ -2376,7 +2418,12 @@ describe('ChatView', () => {
})
const h = makeHarness({ chat: partial, hasMore: true })
const view = render(<h.ChatView {...h.props} />)
expect(turnProcessControl(view.container)).toBeNull()
const toggle = turnProcessControl(view.container)!
const processRow = view.getByText('working').closest('[data-chat-flow-kind="assistant-step"]')!
expect(toggle.textContent).toBe('用时 5秒')
expect(toggle.disabled).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBeNull()
expect(processRow.hasAttribute('hidden')).toBe(false)
const beforeKeys = partial.locations.getTurn(1)
const completeSpec = { ...partialSpec, processStartSeq: 2 }
@@ -2389,7 +2436,7 @@ describe('ChatView', () => {
expect(complete.nodes).toBe(partial.nodes)
expect(complete.locations.getTurn(1)).not.toBe(beforeKeys)
expect(complete.order.map(key => complete.nodes.get(key)?.kind)).toEqual([
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
'user', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail',
])
act(() => {
@@ -2397,7 +2444,11 @@ describe('ChatView', () => {
builder.publish()
h.set({ chat: complete, hasMore: false })
})
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.disabled).toBe(false)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
expect(view.getByText('final answer').closest('[hidden]')).toBeNull()
})
it('keeps a manual expansion when the reader returns from another view', () => {
@@ -2518,7 +2569,10 @@ describe('ChatView', () => {
expect(dialog.textContent).toContain('缓存命中49.4%')
expect(dialog.textContent).toContain('未缓存输入5,060 tok')
fireEvent.keyDown(document, { key: 'Escape' })
expect(view.queryByRole('button', { name: /用时/ })).toBeNull()
expect(view.queryByRole('dialog')).toBeNull()
const footer = view.container.querySelector<HTMLElement>('[data-turn-tail="1"]')!
expect(within(footer).queryByRole('button', { name: /用时/ })).toBeNull()
expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒')
act(() => { h.setPerformanceUsage('compact') })
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
act(() => { h.setPerformanceUsage('detailed') })
@@ -2537,7 +2591,9 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 20]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByRole('button', { name: /用时/ })).toBeNull()
const footer = view.container.querySelector<HTMLElement>('[data-turn-tail="1"]')!
expect(within(footer).queryByRole('button', { name: /用时/ })).toBeNull()
expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒')
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
})
@@ -2606,17 +2662,18 @@ describe('ChatView', () => {
nodes: [user(1, 'question'), assistant(2, 'answer')],
turnEnds: new Map([[1, 4]]),
})
const chat = {
...base,
locations: {
getTurn: (turn: number) => turn === 1
? [...base.locations.getTurn(turn), 'fixture:steering:later']
: base.locations.getTurn(turn),
getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
},
const later: ChatNode<'steering'> = {
key: 'fixture:steering:3', id: '3', target: 'chat', kind: 'steering',
anchorSeq: 3, visibility: 'visible',
location: { kind: 'turn', turn: base.timeline.turns.get(1)! },
data: steering(3, 'change direction', 1),
}
const builder = new ChatSnapshotBuilder()
const chat = builder.replace({ nodes: [...base.nodes.values(), later], timeline: base.timeline })
const h = makeHarness({}, {}, chat)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('change direction')).toBeTruthy()
expect(chat.locations.getTurn(1)).toContain(later.key)
const branch = view.getByRole('button', { name: '在新对话中分支' })
expect(branch.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(branch)
@@ -2722,13 +2779,19 @@ describe('ChatView', () => {
expect(rowRenders).toBe(afterMount)
})
it('hands running calls to a live Tool group', () => {
const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true })
it('hands running calls to a live Tool group', () => withClock(2_000, () => {
const h = makeHarness({
runningCalls: [runningCall('r1')],
turnTimings: new Map([[2, { startTime: 0 }]]),
}, { running: true })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
expect(view.getByRole('status').textContent).toBe('深度求索中...')
})
expect(view.getByRole('status').textContent).toBe('深度求索中')
const toggle = view.getByRole('button', { name: '深度求索中,用时2秒' }) as HTMLButtonElement
expect(toggle.disabled).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
}))
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
const mounted = vi.fn()
@@ -2777,18 +2840,23 @@ describe('ChatView', () => {
expect(unmounted).not.toHaveBeenCalled()
})
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
const startTime = Date.now() - 125_000
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => withClock(126_000, () => {
const startTime = 1_000
const trigger = { ...userInTurn(1, 'go', 1), time: 120_000 }
const h = makeHarness(
{ nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
{ running: true },
)
const view = render(<h.ChatView {...h.props} />)
// Freshly mounted (as after a reload) yet already past the 15s gate.
const status = view.getByRole('status')
expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
const toggle = view.getByRole('button', { name: '深度求索中,用时2分5秒' }) as HTMLButtonElement
expect(toggle).toBe(turnProcessControl(view.container))
expect(toggle.disabled).toBe(true)
expect(status.textContent).toBe('深度求索中')
expect(status.getAttribute('aria-live')).toBe('polite')
expect(status.getAttribute('aria-atomic')).toBe('true')
expect(toggle.closest('[aria-live]')).toBeNull()
expect(view.container.querySelector('[data-chat-flow-kind="assistant-step"]')).toBeNull()
act(() => {
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
id: 'steering-occurrence' as never,
@@ -2797,19 +2865,42 @@ describe('ChatView', () => {
role: 'user', source: { kind: 'user' },
}] } })
})
expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
})
expect(toggle.textContent).toBe('深度求索中,用时2分5秒')
act(() => { vi.advanceTimersByTime(2_000) })
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
expect(view.getByRole('status')).toBe(status)
expect(status.textContent).toBe('深度求索中')
act(() => {
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [] } })
h.setChat({ nodes: [trigger, { ...steering(2, 'also', 1), time: 128_000 }] })
})
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
expect(view.getByText('also').closest('[data-pending-steering]')).toBeNull()
act(() => { vi.advanceTimersByTime(1_000) })
expect(toggle.textContent).toBe('深度求索中,用时2分8秒')
expect(status.textContent).toBe('深度求索中')
view.unmount()
expect(vi.getTimerCount()).toBe(0)
}))
it('the running clock reads hours once the turn passes an hour', () => {
const startTime = Date.now() - 3_903_000
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
it('the running clock reads hours once the turn passes an hour', () => withClock(3_600_000, () => {
const startTime = 1_000
const trigger = { ...userInTurn(1, 'go', 1), time: startTime + 1 }
const h = makeHarness(
{ nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
{ running: true },
)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByRole('status').textContent).toMatch(/^深度求索中\.\.\.1小时05分0\d秒$/)
})
const toggle = view.getByRole('button', { name: '深度求索中,用时59分59秒' })
act(() => { vi.advanceTimersByTime(1_000) })
expect(toggle.textContent).toBe('深度求索中,用时1小时00分0秒')
act(() => { vi.advanceTimersByTime(303_000) })
expect(toggle.textContent).toBe('深度求索中,用时1小时05分3秒')
expect(view.getByRole('status').textContent).toBe('深度求索中')
view.unmount()
expect(vi.getTimerCount()).toBe(0)
}))
it('hands each ordered root call to the keyed business-node slot', () => {
const block = toolResult(3, 'a')