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:
parent
eca7ccebe2
commit
4534df7e7a
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
139
frontend/web/src/components/intelligent_eval/EvalReport.test.tsx
Normal file
139
frontend/web/src/components/intelligent_eval/EvalReport.test.tsx
Normal 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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user