// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// user IconActions, StatsLine no-cache join,
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
// with the keyed-slot machinery specs since the tool ring dissolved into
// renderSlot.)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-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 {
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
} from '../src/client/chat/message-chrome.ts'
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
describe('MessageItem arms', () => {
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
render(
,
)
expect(screen.getByText('14:24')).toBeTruthy()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const exec = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: exec,
})
render(
,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(exec).toHaveBeenCalledWith('copy')
})
it('user copy stays quiet when execCommand throws or is absent', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
render(
,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
const view = render(
,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
const ctxView = render(
,
)
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
expect(ctxView.container.querySelector('svg')).not.toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
)
fireEvent.keyDown(disclosure, { key: ' ' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
})
it('context preserves the bounded JSON truncation contract', () => {
const view = render(
,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
.toMatch(/… 已截断,共 \d+ 字符$/)
})
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
,
)
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
it('a compaction marker discloses its summary and never shows the framed checkpoint', () => {
const view = render(
,
)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText(/保留的事实/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByRole('heading', { name: '摘要标题' })).toBeTruthy()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a marker whose provenance fell outside the window is not expandable', () => {
const view = render()
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row).toHaveProperty('disabled', true)
expect(row.getAttribute('aria-expanded')).toBeNull()
expect(view.getByText('压缩摘要不可用')).toBeTruthy()
fireEvent.click(row) // a disabled control stays collapsed
expect(row.getAttribute('aria-expanded')).toBeNull()
})
it('collapses retry details behind the durable model retry status', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const view = render(
,
)
const details = view.container.querySelector('details')
const summary = view.container.querySelector('summary')
expect(details?.open).toBe(false)
expect(details?.dataset.active).toBe('true')
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s')
expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms')
expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
act(() => { vi.advanceTimersByTime(1_100) })
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s')
act(() => { vi.advanceTimersByTime(1_000) })
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
view.rerender(
,
)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s')
if (summary === null) throw new Error('retry summary missing')
fireEvent.click(summary)
expect(details?.open).toBe(true)
view.rerender(
,
)
expect(details?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s')
view.rerender(
,
)
expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s')
view.rerender(
,
)
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s')
})
it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const node = {
kind: 'model-retry',
seq: 5,
time: 10_000,
retryState: 'scheduled',
turn: 1,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 5_000,
failure: { code: 'TRANSPORT', message: '连接被重置' },
} as const
const view = render()
expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
act(() => { vi.advanceTimersByTime(4_200) })
view.rerender()
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
})
})
describe('formatMessageClock', () => {
const now = new Date(2026, 6, 29, 10, 0).getTime()
it('keeps HH:mm on the same calendar day', () => {
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24')
})
it('prefixes month and day across days in the same year', () => {
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24')
})
it('prefixes year, month, and day across years', () => {
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05')
})
it('arms the next local midnight from an in-day instant', () => {
const noon = new Date(2026, 6, 29, 12, 0).getTime()
expect(startOfLocalDay(noon)).toBe(new Date(2026, 6, 29).getTime())
expect(msUntilNextLocalMidnight(noon)).toBe(12 * 3_600_000)
})
})
describe('useCalendarDay boundary refresh', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('widens a same-day user clock after local midnight', () => {
const dayStart = new Date(2026, 6, 29, 23, 50).getTime()
vi.setSystemTime(dayStart)
const time = new Date(2026, 6, 29, 14, 24).getTime()
render(
,
)
expect(screen.getByText('14:24')).toBeTruthy()
act(() => {
vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1)
})
expect(screen.getByText('7月29日 14:24')).toBeTruthy()
})
})
describe('small branch tails', () => {
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
,
)
expect(view.getByText('one-liner')).toBeTruthy()
})
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const settled = render(
,
)
expect(settled.getByText('14:24')).toBeTruthy()
expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
fireEvent.click(settled.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('answer body')
settled.unmount()
const thinkOnly = render(
,
)
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
expect(thinkOnly.queryByText('14:24')).toBeNull()
thinkOnly.unmount()
const streaming = render(
,
)
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
expect(streaming.queryByText('14:24')).toBeNull()
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// cacheHitPct is null only when input+cacheRead are both zero (pure
// output accounting) — any input makes it a real 0%.
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
,
)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
})
})