A test file under packages/client now says which face it covers:
`*.client.spec.{ts,tsx}` and its `*.client.{ts,tsx}` helpers belong to the
Client aggregate, `*.host.spec.ts` to the host aggregate. The carrier's four
node-half specs take the Host suffix.
The two suffixes are mutually exclusive, so each aggregate excludes the
other's and both keep one broad test glob: `exclude` wins over `include`, and
`packages/client/**` no longer has to be excluded wholesale from the host
program with per-file `files` entries carved back out of it. A Host-face spec
that reaches only Host source therefore needs no cross-face project
reference, which the split-project rule rejects.
vitest still discovers every file through `**/*.spec.{ts,tsx}`.
118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render } from '@testing-library/react'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
|
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
|
let nextAnimationFrameId = 1
|
|
let animationFrames = new Map<number, FrameRequestCallback>()
|
|
|
|
function flushAnimationFrames(count: number): void {
|
|
for (let index = 0; index < count; index += 1) {
|
|
const callbacks = [...animationFrames.values()]
|
|
animationFrames.clear()
|
|
for (const callback of callbacks) callback(index)
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
nextAnimationFrameId = 1
|
|
animationFrames = new Map()
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
const id = nextAnimationFrameId
|
|
nextAnimationFrameId += 1
|
|
animationFrames.set(id, callback)
|
|
return id
|
|
})
|
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
|
animationFrames.delete(id)
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
const t = makeTranslate(zh, commonZh)
|
|
|
|
describe('ReasoningRow', () => {
|
|
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
|
|
const view = render(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
|
|
streaming
|
|
/>,
|
|
)
|
|
expect(view.getByText('运行中')).toBeTruthy()
|
|
const summary = view.getByText('Newest reasoning tokens')
|
|
Object.defineProperties(summary, {
|
|
scrollWidth: { configurable: true, value: 300 },
|
|
clientWidth: { configurable: true, value: 100 },
|
|
})
|
|
|
|
view.rerender(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
|
|
streaming
|
|
/>,
|
|
)
|
|
expect(summary.scrollLeft).toBe(0)
|
|
flushAnimationFrames(2)
|
|
expect(summary.scrollLeft).toBe(0)
|
|
flushAnimationFrames(1)
|
|
expect(summary.scrollLeft).toBe(200)
|
|
expect(summary.getAttribute('data-follow-end')).toBe('true')
|
|
|
|
view.rerender(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
|
|
streaming={false}
|
|
/>,
|
|
)
|
|
flushAnimationFrames(3)
|
|
expect(view.getByText('Inspect the session')).toBeTruthy()
|
|
expect(view.queryByText('运行中')).toBeNull()
|
|
expect(summary.scrollLeft).toBe(0)
|
|
expect(summary.hasAttribute('data-follow-end')).toBe(false)
|
|
})
|
|
|
|
it('expands from either Think or the reasoning summary', () => {
|
|
const view = render(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
|
streaming={false}
|
|
/>,
|
|
)
|
|
const row = view.getByRole('button')
|
|
|
|
fireEvent.click(view.getByText('Inspect the session'))
|
|
expect(row.getAttribute('aria-expanded')).toBe('true')
|
|
expect(view.getByText(/Check persistence/)).toBeTruthy()
|
|
|
|
fireEvent.click(view.getByText('Think'))
|
|
expect(row.getAttribute('aria-expanded')).toBe('false')
|
|
})
|
|
|
|
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
|
|
const view = render(
|
|
<AssistantMarkdown
|
|
t={t}
|
|
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
|
streaming={false}
|
|
/>,
|
|
)
|
|
fireEvent.click(view.getByText('Think'))
|
|
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
|
|
expect(view.queryByText('IN')).toBeNull()
|
|
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
|
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
|
|
})
|
|
})
|