test: 五个零覆盖组件补全测试(Phase 4.23)

锁定 Phase 4 读取接缝收敛后的纯渲染与关键交互契约;五个组件
此前从未被 vitest 覆盖,现全量补齐(25 个测试)。

- DecisionProcess(6):加载/空态/详情展开/上下文 JSON 折叠、
  轮询门控(executing 轮询、completed 停轮询)、补充日志触发。
- EvalOverview(6):planning / pending_approval / executing
  三种状态的渲染分支;plan 维度/虚拟用户/时间分布/完成标准;
  会话进度与过期文案。
- EvalReport(4):报告加载/空态、会话折叠、SessionMessages
  懒加载、Markdown 导出(downloadBlob 接缝)。
- ConfigSnapshots(4):列表与类型标签、详情展开、快照对比
  两选一校验、空态与差异渲染。
- TaskQueueMonitor(5):统计芯片、状态过滤、刷新按钮、
  轮询定时器(vi.useFakeTimers + advanceTimersByTimeAsync)、
  空态。

测试沿用 sessionReducer 先例:vi.mock 工厂注入 readSlot /
API 方法,按 reducer 形状构造 fixture;antd 双汉字按钮
(详情/导出/查看)使用 /\s*/ 正则匹配 autoInsertSpace 空格。
This commit is contained in:
sinohqb 2026-08-24 05:54:28 +08:00
parent eca7ccebe2
commit 4534df7e7a
5 changed files with 600 additions and 0 deletions

View File

@ -0,0 +1,116 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConfigSnapshot } from '../../api'
import ConfigSnapshots from './ConfigSnapshots'
const listConfigSnapshots = vi.fn()
const getConfigSnapshot = vi.fn()
const compareConfigSnapshots = vi.fn()
const downloadJson = vi.fn()
vi.mock('../../api', () => ({
intelligentEvalsApi: {
listConfigSnapshots: (...args: unknown[]) => listConfigSnapshots(...args),
getConfigSnapshot: (...args: unknown[]) => getConfigSnapshot(...args),
compareConfigSnapshots: (...args: unknown[]) => compareConfigSnapshots(...args),
},
}))
vi.mock('../../utils/download', () => ({
downloadJson: (...args: unknown[]) => downloadJson(...args),
}))
function makeSnapshot(id: string, type: ConfigSnapshot['snapshot_type'] = 'created'): ConfigSnapshot {
return {
id,
eval_id: 'eval-1',
snapshot_type: type,
goal: `评估目标 ${id}`,
seeds: {},
intent: '',
role_description: '',
time_window_hours: 24,
plan: null,
created_at: '2026-01-01T08:00:00Z',
created_by: 'openclaw',
}
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
describe('ConfigSnapshots', () => {
it('lists snapshots with type labels and shows empty state when none', async () => {
listConfigSnapshots.mockResolvedValue({ data: { snapshots: [makeSnapshot('snap-1'), makeSnapshot('snap-2', 'plan_submitted')] } })
render(<ConfigSnapshots evalId="eval-1" />)
await waitFor(() => expect(listConfigSnapshots).toHaveBeenCalledWith('eval-1'))
expect(await screen.findByText('创建')).toBeInTheDocument()
expect(screen.getByText('计划提交')).toBeInTheDocument()
expect(screen.getAllByText('openclaw').length).toBe(2)
cleanup()
listConfigSnapshots.mockResolvedValue({ data: { snapshots: [] } })
render(<ConfigSnapshots evalId="eval-1" />)
expect(await screen.findByText('暂无配置快照')).toBeInTheDocument()
})
it('opens snapshot detail and returns to the list', async () => {
const snapshot = makeSnapshot('snap-1')
listConfigSnapshots.mockResolvedValue({ data: { snapshots: [snapshot] } })
getConfigSnapshot.mockResolvedValue({ data: snapshot })
render(<ConfigSnapshots evalId="eval-1" />)
fireEvent.click(await screen.findByText(/查\s*看/))
await waitFor(() => expect(getConfigSnapshot).toHaveBeenCalledWith('eval-1', 'snap-1'))
expect(await screen.findByText('快照详情')).toBeInTheDocument()
expect(screen.getByText('评估目标 snap-1')).toBeInTheDocument()
expect(screen.getByText('24 小时')).toBeInTheDocument()
fireEvent.click(screen.getByText('返回'))
expect(await screen.findByText('配置历史')).toBeInTheDocument()
})
it('exports a snapshot as JSON', async () => {
listConfigSnapshots.mockResolvedValue({ data: { snapshots: [makeSnapshot('snap-12345678')] } })
render(<ConfigSnapshots evalId="eval-1" />)
fireEvent.click(await screen.findByText(/导\s*出/))
expect(downloadJson).toHaveBeenCalledWith(
expect.objectContaining({ id: 'snap-12345678' }),
'config-snapshot-snap-123.json',
)
})
it('compares two selected snapshots and renders differences', async () => {
listConfigSnapshots.mockResolvedValue({
data: { snapshots: [makeSnapshot('snap-1'), makeSnapshot('snap-2', 'config_updated')] },
})
compareConfigSnapshots.mockResolvedValue({
data: {
snapshot_1: { id: 'snap-1', snapshot_type: 'created', created_at: '2026-01-01T08:00:00Z' },
snapshot_2: { id: 'snap-2', snapshot_type: 'config_updated', created_at: '2026-01-02T08:00:00Z' },
differences: { goal: { old: '旧目标', new: '新目标' } },
},
})
render(<ConfigSnapshots evalId="eval-1" />)
await screen.findByText('创建')
// 第 0 个是表头全选框,行内勾选从索引 1 开始
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[1])
fireEvent.click(checkboxes[2])
fireEvent.click(screen.getByText(/对比选中/))
await waitFor(() => expect(compareConfigSnapshots).toHaveBeenCalledWith('eval-1', 'snap-1', 'snap-2'))
expect(await screen.findByText('快照对比')).toBeInTheDocument()
expect(screen.getByText('goal')).toBeInTheDocument()
expect(screen.getByText('旧目标')).toBeInTheDocument()
expect(screen.getByText('新目标')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,90 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DecisionLog } from '../../api'
import type { ReadSlot } from '../../read/readResource'
import DecisionProcess from './DecisionProcess'
const downloadJson = vi.fn()
vi.mock('../../utils/download', () => ({
downloadJson: (...args: unknown[]) => downloadJson(...args),
}))
function makeLog(i: number, type: DecisionLog['decision_type'] = 'execute_session'): DecisionLog {
return {
id: `log-${i}`,
eval_id: 'eval-1',
decision_type: type,
reason: `时段欠账 ${i}`,
context: { debt: i },
cron_id: `cron-${i}-abcdef`,
created_at: `2026-01-01T08:${String(i % 60).padStart(2, '0')}:00Z`,
}
}
function readySlot(logs: DecisionLog[]): ReadSlot<DecisionLog[] | null> {
return { phase: 'ready', value: logs, error: null }
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
describe('DecisionProcess', () => {
it('shows loading placeholder while the slot is loading', () => {
const slot: ReadSlot<DecisionLog[] | null> = { phase: 'loading', value: null, error: null }
render(<DecisionProcess evalId="eval-1" logs={slot} onReload={() => {}} />)
expect(screen.getByText('加载中...')).toBeInTheDocument()
})
it('shows empty state when there are no logs', () => {
render(<DecisionProcess evalId="eval-1" logs={readySlot([])} onReload={() => {}} />)
expect(screen.getAllByText('暂无决策日志').length).toBeGreaterThanOrEqual(1)
})
it('renders log reasons in the timeline and the table', () => {
render(<DecisionProcess evalId="eval-1" logs={readySlot([makeLog(1), makeLog(2, 'wait')])} onReload={() => {}} />)
expect(screen.getAllByText('时段欠账 1').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('时段欠账 2').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText(/共 2 条/)).toBeInTheDocument()
})
it('expands a row to show decision context', async () => {
render(<DecisionProcess evalId="eval-1" logs={readySlot([makeLog(3)])} onReload={() => {}} />)
expect(screen.getAllByText('时段欠账 3').length).toBe(2)
expect(screen.queryByText('决策上下文')).not.toBeInTheDocument()
// antd 会在两个汉字的按钮文案中间插空格(详 情)
fireEvent.click(screen.getByText(/详\s*情/))
expect(await screen.findByText('决策上下文')).toBeInTheDocument()
expect(screen.getByText(/"debt": 3/)).toBeInTheDocument()
})
it('triggers onReload from the refresh button', () => {
const onReload = vi.fn()
render(<DecisionProcess evalId="eval-1" logs={readySlot([])} onReload={onReload} />)
fireEvent.click(screen.getByText('刷新'))
expect(onReload).toHaveBeenCalledTimes(1)
})
it('exports logs as JSON and skips export when empty', () => {
const { unmount } = render(
<DecisionProcess evalId="eval-12345678" logs={readySlot([makeLog(1)])} onReload={() => {}} />,
)
fireEvent.click(screen.getByText(/导\s*出/))
expect(downloadJson).toHaveBeenCalledWith(expect.any(Array), 'decision-logs-eval-123.json')
unmount()
render(<DecisionProcess evalId="eval-1" logs={readySlot([])} onReload={() => {}} />)
fireEvent.click(screen.getByText(/导\s*出/))
expect(downloadJson).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,129 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { IntelligentEval, IntelligentEvalSession } from '../../api'
import EvalOverview from './EvalOverview'
function makeEval(overrides: Partial<IntelligentEval> = {}): IntelligentEval {
return {
id: 'eval-1',
name: '24h 服务质量评估',
target_id: 't-1',
status: 'pending_approval',
goal: '评估服务态度与专业度',
seeds: {},
intent: '重点关注价保场景',
role_description: '客服数字员工',
plan: null,
plan_feedback: null,
time_window_hours: 24,
report: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: null,
started_at: null,
completed_at: null,
session_count: 4,
completed_sessions: 0,
sessions: [],
...overrides,
}
}
function makeSession(overrides: Partial<IntelligentEvalSession> = {}): IntelligentEvalSession {
return {
id: 's-1',
eval_id: 'eval-1',
target_id: 't-1',
persona: { name: '老客户' },
goal: '完成退货',
dimension: '售后',
status: 'completed',
verdict: null,
turn_count: 5,
created_at: '2026-01-01T08:10:00Z',
closed_at: '2026-01-01T08:25:00Z',
...overrides,
}
}
afterEach(() => {
cleanup()
})
describe('EvalOverview', () => {
it('renders basic info: target, status tag, time window and session progress', () => {
render(<EvalOverview ev={makeEval()} targetName="tutu 客服通道" />)
expect(screen.getByText('tutu 客服通道')).toBeInTheDocument()
expect(screen.getByText('待审批')).toBeInTheDocument()
expect(screen.getByText('24 小时')).toBeInTheDocument()
expect(screen.getByText('0/4')).toBeInTheDocument()
expect(screen.getByText('评估服务态度与专业度')).toBeInTheDocument()
expect(screen.getByText('重点关注价保场景')).toBeInTheDocument()
})
it('shows planning hint while planning without feedback', () => {
render(<EvalOverview ev={makeEval({ status: 'planning' })} targetName="t" />)
expect(screen.getByText(/OpenClaw 正在规划/)).toBeInTheDocument()
expect(screen.queryByText('粗计划')).not.toBeInTheDocument()
})
it('shows rejection alert when plan was sent back', () => {
render(<EvalOverview ev={makeEval({ status: 'planning', plan_feedback: '维度太粗,请细化' })} targetName="t" />)
expect(screen.getByText(/计划已打回/)).toBeInTheDocument()
expect(screen.getByText('维度太粗,请细化')).toBeInTheDocument()
})
it('renders the plan summary for pending_approval', () => {
const ev = makeEval({
plan: {
dimensions: ['服务态度', '专业度'],
virtual_users: [{ persona: { name: '新客户' }, goal: '咨询价保' }],
time_distribution: [{ time_slot: '0-1h', sessions: 2, scenario: '早高峰咨询' }],
estimated_sessions: 4,
budget: { max_turns_per_session: 12, total_max_turns: 48 },
completion_criteria: '全部时段完成',
},
})
render(<EvalOverview ev={ev} targetName="t" />)
expect(screen.getByText('粗计划')).toBeInTheDocument()
expect(screen.getByText('服务态度')).toBeInTheDocument()
expect(screen.getByText('专业度')).toBeInTheDocument()
expect(screen.getByText('新客户')).toBeInTheDocument()
// 目标与 persona 在同一个 div 里(<b>新客户</b> · 咨询价保)
expect(screen.getByText((content) => content.includes('咨询价保'))).toBeInTheDocument()
expect(screen.getByText('0-1h')).toBeInTheDocument()
expect(screen.getByText('全部时段完成')).toBeInTheDocument()
})
it('renders session progress rows only while executing or completed', () => {
const sessions = [makeSession()]
const { unmount } = render(
<EvalOverview
ev={makeEval({ status: 'executing', sessions, completed_sessions: 1, session_count: 1 })}
targetName="t"
/>,
)
expect(screen.getByText(/会话进度1\/1/)).toBeInTheDocument()
expect(screen.getByText('老客户')).toBeInTheDocument()
expect(screen.getByText('完成退货')).toBeInTheDocument()
expect(screen.getByText('售后')).toBeInTheDocument()
unmount()
render(<EvalOverview ev={makeEval({ status: 'pending_approval', sessions })} targetName="t" />)
expect(screen.queryByText(/会话进度(/)).not.toBeInTheDocument()
})
it('shows seeds JSON when provided and a dash when empty', () => {
const { unmount } = render(
<EvalOverview ev={makeEval({ seeds: { questions: ['怎么退货'] } })} targetName="t" />,
)
expect(screen.getByText(/怎么退货/)).toBeInTheDocument()
unmount()
render(<EvalOverview ev={makeEval()} targetName="t" />)
expect(screen.getByText('已显示全部内容')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,139 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IntelligentEval, IntelligentEvalReport } from '../../api'
import EvalReport from './EvalReport'
const downloadReportMarkdown = vi.fn()
const listMessages = vi.fn()
vi.mock('../../api', () => ({
intelligentEvalsApi: {
downloadReportMarkdown: (...args: unknown[]) => downloadReportMarkdown(...args),
listMessages: (...args: unknown[]) => listMessages(...args),
},
}))
function makeReport(overrides: Partial<IntelligentEvalReport> = {}): IntelligentEvalReport {
return {
summary: '整体服务态度良好,价保场景存在流程遗漏',
scores: { overall: 4.2, dimensions: { 服务态度: 4.5, 专业度: 3.2 } },
findings: [
{
issue: '价保流程未主动告知凭证',
severity: 'high',
dimension: '专业度',
evidence: [{ session_id: 'sess-aaaaaaaa', turn_index: 2, user_said: '需要什么凭证', assistant_replied: '您好' }],
suggestion: '补充 SOP 提示',
related_sop: 'SOP-12',
},
{
issue: '回复偏慢',
severity: 'medium',
dimension: '响应时效',
evidence: [],
},
],
highlights: [{ description: '语气友好', dimension: '服务态度' }],
priority_recommendations: ['优先整改价保流程'],
...overrides,
}
}
function makeEval(overrides: Partial<IntelligentEval> = {}): IntelligentEval {
return {
id: 'eval-1',
name: '24h 评估',
target_id: 't-1',
status: 'completed',
goal: 'goal',
seeds: {},
intent: '',
role_description: '',
plan: null,
plan_feedback: null,
time_window_hours: 24,
report: makeReport(),
created_at: null,
updated_at: null,
started_at: null,
completed_at: '2026-01-02T00:00:00Z',
session_count: 1,
completed_sessions: 1,
sessions: [
{
id: 's-1',
eval_id: 'eval-1',
target_id: 't-1',
persona: { name: '老客户' },
goal: '完成退货',
dimension: '售后',
status: 'completed',
verdict: null,
turn_count: 5,
created_at: '2026-01-01T08:10:00Z',
closed_at: '2026-01-01T08:25:00Z',
},
],
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
describe('EvalReport', () => {
it('shows empty states when there is no report and no sessions', () => {
render(<EvalReport ev={makeEval({ report: null, sessions: [] })} />)
expect(screen.getByText('暂无报告')).toBeInTheDocument()
expect(screen.getByText('暂无会话')).toBeInTheDocument()
})
it('renders summary cards, dimension scores, findings, highlights and recommendations', () => {
render(<EvalReport ev={makeEval()} />)
expect(screen.getByText('4.2')).toBeInTheDocument()
expect(screen.getByText('问题发现2')).toBeInTheDocument()
expect(screen.getAllByText('服务态度').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('4.5')).toBeInTheDocument()
expect(screen.getByText('3.2')).toBeInTheDocument()
expect(screen.getByText('整体服务态度良好,价保场景存在流程遗漏')).toBeInTheDocument()
expect(screen.getByText('价保流程未主动告知凭证')).toBeInTheDocument()
expect(screen.getByText('回复偏慢')).toBeInTheDocument()
expect(screen.getByText('语气友好')).toBeInTheDocument()
expect(screen.getByText('优先整改价保流程')).toBeInTheDocument()
expect(document.body.textContent).toContain('高 1 · 中 1 · 低 0')
})
it('expanding a session panel loads its messages', async () => {
listMessages.mockResolvedValue({
data: {
messages: [
{ id: 'm-1', session_id: 's-1', role: 'user', content: '我想退货', latency_ms: null, created_at: null },
{ id: 'm-2', session_id: 's-1', role: 'assistant', content: '好的,请提供订单号', latency_ms: 320, created_at: null },
],
},
})
render(<EvalReport ev={makeEval()} />)
expect(listMessages).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('老客户'))
await waitFor(() => expect(listMessages).toHaveBeenCalledWith('eval-1', 's-1'))
expect(await screen.findByText('我想退货')).toBeInTheDocument()
expect(screen.getByText('好的,请提供订单号')).toBeInTheDocument()
expect(screen.getByText('320ms')).toBeInTheDocument()
})
it('exports the markdown report', async () => {
downloadReportMarkdown.mockResolvedValue(undefined)
render(<EvalReport ev={makeEval()} />)
fireEvent.click(screen.getByText(/导出 Markdown/))
await waitFor(() => expect(downloadReportMarkdown).toHaveBeenCalledWith('eval-1'))
})
})

View File

@ -0,0 +1,126 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TaskQueueItem, TaskQueueList } from '../../api'
import TaskQueueMonitor from './TaskQueueMonitor'
const listTasks = vi.fn()
vi.mock('../../api', () => ({
intelligentEvalsApi: {
listTasks: (...args: unknown[]) => listTasks(...args),
},
}))
function makeTask(id: string, status: TaskQueueItem['status'], overrides: Partial<TaskQueueItem> = {}): TaskQueueItem {
return {
id,
eval_id: `eval-${id}`,
eval_name: `评估 ${id}`,
eval_status: 'executing',
status,
priority: 1,
reason: `时段欠账 ${id}`,
assigned_cron_id: null,
assigned_at: null,
completed_at: status === 'completed' ? '2026-01-01T09:00:00Z' : null,
created_at: '2026-01-01T08:00:00Z',
updated_at: null,
error: null,
...overrides,
}
}
function makeList(tasks: TaskQueueItem[], stats: TaskQueueList['stats']): TaskQueueList {
return { tasks, stats }
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
describe('TaskQueueMonitor', () => {
it('renders stat chips from stats without the derived unresolved chip', async () => {
listTasks.mockResolvedValue({
data: makeList([makeTask('1', 'pending')], { pending: 1, assigned: 2, completed: 3, failed: 4, unresolved: 3 }),
})
render(<TaskQueueMonitor />)
await waitFor(() => expect(listTasks).toHaveBeenCalledTimes(1))
expect(await screen.findByText('全部任务')).toBeInTheDocument()
// 状态文案同时出现在 chip 与表格 Tag 中
expect(screen.getAllByText('待认领').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('执行中').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('已完成').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('失败').length).toBeGreaterThanOrEqual(1)
// 全部 = 四态之和,不含派生的 unresolved
expect(screen.getByText('10')).toBeInTheDocument()
expect(screen.queryByText('unresolved')).not.toBeInTheDocument()
})
it('renders task rows with eval name and queue status', async () => {
listTasks.mockResolvedValue({
data: makeList(
[
makeTask('1', 'completed'),
makeTask('2', 'failed', { error: 'worker 超时' }),
],
{ pending: 0, assigned: 0, completed: 1, failed: 1, unresolved: 0 },
),
})
render(<TaskQueueMonitor />)
expect(await screen.findByText('评估 1')).toBeInTheDocument()
expect(screen.getByText('评估 2')).toBeInTheDocument()
expect(screen.getByText('时段欠账 1')).toBeInTheDocument()
// 失败任务的完成时间列显示 失败(悬浮提示里是错误详情)
expect(screen.getAllByText('失败').length).toBeGreaterThanOrEqual(1)
})
it('filters rows by the clicked stat chip', async () => {
listTasks.mockResolvedValue({
data: makeList(
[makeTask('1', 'pending'), makeTask('2', 'completed')],
{ pending: 1, assigned: 0, completed: 1, failed: 0, unresolved: 1 },
),
})
render(<TaskQueueMonitor />)
await screen.findByText('评估 1')
expect(screen.getByText('评估 2')).toBeInTheDocument()
// stat-bar 先于表格渲染,第一个 待认领 是筛选 chip
fireEvent.click(screen.getAllByText('待认领')[0])
expect(screen.getByText('评估 1')).toBeInTheDocument()
expect(screen.queryByText('评估 2')).not.toBeInTheDocument()
})
it('shows empty state when there are no tasks', async () => {
listTasks.mockResolvedValue({
data: makeList([], { pending: 0, assigned: 0, completed: 0, failed: 0, unresolved: 0 }),
})
render(<TaskQueueMonitor />)
expect(await screen.findByText('暂无任务')).toBeInTheDocument()
})
it('polls every 5 seconds and reloads on demand', async () => {
vi.useFakeTimers()
listTasks.mockResolvedValue({
data: makeList([], { pending: 0, assigned: 0, completed: 0, failed: 0, unresolved: 0 }),
})
render(<TaskQueueMonitor />)
await vi.advanceTimersByTimeAsync(0)
expect(listTasks).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5000)
expect(listTasks).toHaveBeenCalledTimes(2)
// 手动刷新按钮
fireEvent.click(screen.getByRole('button'))
await vi.advanceTimersByTimeAsync(0)
expect(listTasks).toHaveBeenCalledTimes(3)
})
})