From 4534df7e7a6fbf2ca4e72c0288b392ef2cbb7eba Mon Sep 17 00:00:00 2001 From: sinohqb Date: Mon, 24 Aug 2026 05:54:28 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E4=BA=94=E4=B8=AA=E9=9B=B6=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=BB=84=E4=BB=B6=E8=A1=A5=E5=85=A8=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=88Phase=204.23=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 锁定 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 空格。 --- .../intelligent_eval/ConfigSnapshots.test.tsx | 116 +++++++++++++++ .../intelligent_eval/DecisionProcess.test.tsx | 90 ++++++++++++ .../intelligent_eval/EvalOverview.test.tsx | 129 ++++++++++++++++ .../intelligent_eval/EvalReport.test.tsx | 139 ++++++++++++++++++ .../TaskQueueMonitor.test.tsx | 126 ++++++++++++++++ 5 files changed, 600 insertions(+) create mode 100644 frontend/web/src/components/intelligent_eval/ConfigSnapshots.test.tsx create mode 100644 frontend/web/src/components/intelligent_eval/DecisionProcess.test.tsx create mode 100644 frontend/web/src/components/intelligent_eval/EvalOverview.test.tsx create mode 100644 frontend/web/src/components/intelligent_eval/EvalReport.test.tsx create mode 100644 frontend/web/src/components/intelligent_eval/TaskQueueMonitor.test.tsx diff --git a/frontend/web/src/components/intelligent_eval/ConfigSnapshots.test.tsx b/frontend/web/src/components/intelligent_eval/ConfigSnapshots.test.tsx new file mode 100644 index 0000000..5597dfa --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/ConfigSnapshots.test.tsx @@ -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() + + 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() + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/frontend/web/src/components/intelligent_eval/DecisionProcess.test.tsx b/frontend/web/src/components/intelligent_eval/DecisionProcess.test.tsx new file mode 100644 index 0000000..50adc37 --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/DecisionProcess.test.tsx @@ -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 { + 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 = { phase: 'loading', value: null, error: null } + render( {}} />) + + expect(screen.getByText('加载中...')).toBeInTheDocument() + }) + + it('shows empty state when there are no logs', () => { + render( {}} />) + + expect(screen.getAllByText('暂无决策日志').length).toBeGreaterThanOrEqual(1) + }) + + it('renders log reasons in the timeline and the table', () => { + render( {}} />) + + 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( {}} />) + + 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() + + fireEvent.click(screen.getByText('刷新')) + expect(onReload).toHaveBeenCalledTimes(1) + }) + + it('exports logs as JSON and skips export when empty', () => { + const { unmount } = render( + {}} />, + ) + fireEvent.click(screen.getByText(/导\s*出/)) + expect(downloadJson).toHaveBeenCalledWith(expect.any(Array), 'decision-logs-eval-123.json') + unmount() + + render( {}} />) + fireEvent.click(screen.getByText(/导\s*出/)) + expect(downloadJson).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/web/src/components/intelligent_eval/EvalOverview.test.tsx b/frontend/web/src/components/intelligent_eval/EvalOverview.test.tsx new file mode 100644 index 0000000..1b9a603 --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/EvalOverview.test.tsx @@ -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 { + 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 { + 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() + + 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() + + expect(screen.getByText(/OpenClaw 正在规划/)).toBeInTheDocument() + expect(screen.queryByText('粗计划')).not.toBeInTheDocument() + }) + + it('shows rejection alert when plan was sent back', () => { + render() + + 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() + + expect(screen.getByText('粗计划')).toBeInTheDocument() + expect(screen.getByText('服务态度')).toBeInTheDocument() + expect(screen.getByText('专业度')).toBeInTheDocument() + expect(screen.getByText('新客户')).toBeInTheDocument() + // 目标与 persona 在同一个 div 里(新客户 · 咨询价保) + 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( + , + ) + expect(screen.getByText(/会话进度(1\/1)/)).toBeInTheDocument() + expect(screen.getByText('老客户')).toBeInTheDocument() + expect(screen.getByText('完成退货')).toBeInTheDocument() + expect(screen.getByText('售后')).toBeInTheDocument() + unmount() + + render() + expect(screen.queryByText(/会话进度(/)).not.toBeInTheDocument() + }) + + it('shows seeds JSON when provided and a dash when empty', () => { + const { unmount } = render( + , + ) + expect(screen.getByText(/怎么退货/)).toBeInTheDocument() + unmount() + + render() + expect(screen.getByText('已显示全部内容')).toBeInTheDocument() + }) +}) diff --git a/frontend/web/src/components/intelligent_eval/EvalReport.test.tsx b/frontend/web/src/components/intelligent_eval/EvalReport.test.tsx new file mode 100644 index 0000000..15da14a --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/EvalReport.test.tsx @@ -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 { + 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 { + 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() + + expect(screen.getByText('暂无报告')).toBeInTheDocument() + expect(screen.getByText('暂无会话')).toBeInTheDocument() + }) + + it('renders summary cards, dimension scores, findings, highlights and recommendations', () => { + render() + + 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() + + 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() + + fireEvent.click(screen.getByText(/导出 Markdown/)) + await waitFor(() => expect(downloadReportMarkdown).toHaveBeenCalledWith('eval-1')) + }) +}) diff --git a/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.test.tsx b/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.test.tsx new file mode 100644 index 0000000..3bc7735 --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.test.tsx @@ -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 { + 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() + + 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() + + 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() + + 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() + + 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() + + 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) + }) +})