import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { Bubble } from '@ant-design/x'; import { afterEach, describe, expect, it } from 'vitest'; import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer'; import { AiMessageContent } from './AiMessageContent'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types'; let container: HTMLDivElement | null = null; let root: ReturnType | null = null; afterEach(async () => { if (root) await act(async () => root?.unmount()); container?.remove(); root = null; container = null; }); describe('AI chat bubble rendering', () => { it('maps conversation run statuses to list labels', () => { expect(conversationStatusMeta('running')).toEqual({ label: '生成中', color: 'processing' }); expect(conversationStatusMeta('done')).toEqual({ label: '已完成', color: 'success' }); expect(conversationStatusMeta('error')).toEqual({ label: '失败', color: 'error' }); expect(conversationStatusMeta('stopped')).toEqual({ label: '已停止', color: 'default' }); }); it('renders a structured user message instead of passing the object to React', async () => { const message: AiChatMessage = { role: 'user', content: '查询今天的系统概览', reasoningContent: '', toolRuns: [], attachments: [], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render(
{content.content}
, }, ]} />, ); }); expect(container.textContent).toContain('查询今天的系统概览'); }); it('renders an A2UI form and submits normalized values', async () => { let submitted: Record | null = null; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render( { submitted = values; }} />, ); }); expect(container.textContent).toContain('新增学生'); const input = container.querySelector('input#name') as HTMLInputElement | null; expect(input).not.toBeNull(); if (input) { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; setter?.call(input, '张三'); input.dispatchEvent(new Event('input', { bubbles: true })); } const submitButton = container.querySelector('button[type="submit"]') as HTMLButtonElement | null; expect(submitButton).not.toBeNull(); await act(async () => { submitButton?.click(); }); expect(submitted).toEqual({ name: '张三' }); expect(container.textContent).toContain('已提交'); }); it('renders an A2UI review card and submits via the confirm button', async () => { let submittedId: string | null = null; const review: AiReviewSchema = { id: 'review-1', title: '开学导入', summary: '来自报名 Excel', status: 'pending', sections: [ { key: 'students', type: 'students', title: '学生', kind: 'table', columns: [ { key: 'name', title: '姓名' }, { key: 'phone', title: '手机号' }, ], rows: [ { name: '张三', phone: '13800138000' }, { name: '李四', phone: '13900139000' }, ], issues: [], }, ], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render( { submittedId = reviewId; }} />, ); }); expect(container.textContent).toContain('开学导入'); expect(container.textContent).toContain('确认导入本步'); expect(container.textContent).toContain('全部确认并入库'); const button = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.includes('全部确认并入库'), ) as HTMLButtonElement | undefined; expect(button).not.toBeNull(); await act(async () => { button?.click(); }); const confirmButton = Array.from(document.body.querySelectorAll('button')).find( (item) => item.textContent?.trim() === '确认导入', ) as HTMLButtonElement | undefined; expect(confirmButton).toBeDefined(); await act(async () => { confirmButton?.click(); await new Promise((resolve) => setTimeout(resolve, 50)); }); expect(submittedId).toBe('review-1'); }); it('renders grouped sheets and confirms one type group via Popconfirm', async () => { let submittedGroup: { reviewId: string; type: string } | null = null; const review: AiReviewSchema = { id: 'review-2', title: '入住分表', status: 'pending', sections: [ { key: 'checkins_girls_4', type: 'checkins', title: '四人间女', kind: 'table', columns: [ { key: 'name', title: '姓名' }, { key: 'roomNumber', title: '宿舍号' }, ], rows: [{ name: '张三', roomNumber: '4-401' }], issues: [], }, { key: 'checkins_boys_4', type: 'checkins', title: '四人间男', kind: 'table', columns: [ { key: 'name', title: '姓名' }, { key: 'roomNumber', title: '宿舍号' }, ], rows: [{ name: '李四', roomNumber: '4-402' }], issues: [], }, ], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render( undefined} onConfirmGroup={(_, reviewId, type) => { submittedGroup = { reviewId, type }; }} />, ); }); expect(container.textContent).toContain('入住记录 · 共 2 张表'); expect(container.textContent).toContain('确认本组 2 张表'); const groupButton = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.includes('确认本组 2 张表'), ) as HTMLButtonElement | undefined; expect(groupButton).not.toBeNull(); await act(async () => { groupButton?.click(); }); const confirmButton = Array.from(document.body.querySelectorAll('button')).find( (item) => item.textContent?.trim() === '确认导入', ) as HTMLButtonElement | undefined; expect(confirmButton).toBeDefined(); await act(async () => { confirmButton?.click(); await new Promise((resolve) => setTimeout(resolve, 50)); }); expect(submittedGroup).toEqual({ reviewId: 'review-2', type: 'checkins' }); }); it('renders legacy review sections missing type by inferring from key', async () => { const review: AiReviewSchema = { id: 'review-3', title: '旧数据预览', status: 'pending', sections: [ { key: 'checkins_legacy', title: '旧入住表', kind: 'table', columns: [{ key: 'name', title: '姓名' }], rows: [{ name: '张三' }], issues: [], }, ], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render( undefined} />); }); expect(container.textContent).toContain('入住记录 · 共 1 张表'); expect(container.textContent).toContain('旧入住表'); }); it('renders an expired review card with table content but disabled actions', async () => { let confirmed = false; const review: AiReviewSchema = { id: 'review-4', title: '已被替代的预览', status: 'expired', sections: [ { key: 'checkins_old', type: 'checkins', title: '旧入住表', kind: 'table', columns: [{ key: 'name', title: '姓名' }], rows: [{ name: '张三' }], issues: [], }, ], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render( { confirmed = true; }} onConfirmStep={() => { confirmed = true; }} />, ); }); expect(container.textContent).toContain('已失效'); expect(container.textContent).toContain('已被新的预览替代'); expect(container.textContent).toContain('张三'); expect(container.textContent).not.toContain('全部确认并入库'); const disabledButton = Array.from(container.querySelectorAll('button')).find( (item) => item.textContent?.trim() === '已失效', ) as HTMLButtonElement | undefined; expect(disabledButton).toBeDefined(); expect(disabledButton?.disabled).toBe(true); await act(async () => { disabledButton?.click(); }); expect(confirmed).toBe(false); }); it('renders an A2UI chart card with title and chart container', async () => { const chart: AiChartSchema = { id: 'chart-1', title: '各班级人数', chartType: 'bar', columns: [ { key: 'className', title: '班级' }, { key: 'count', title: '人数' }, ], rows: [ { className: '一班', count: 20 }, { className: '二班', count: 15 }, ], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render(); }); expect(container.textContent).toContain('各班级人数'); expect(container.textContent).toContain('柱状图'); expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); expect(container.querySelector('.ai-chat-chart-card__download')).not.toBeNull(); }); it('renders charts persisted on an assistant message', async () => { const message: AiChatMessage = { role: 'assistant', content: '这是学生性别比例图', reasoningContent: '', toolRuns: [], attachments: [], charts: [ { id: 'chart-9', title: '学生性别比例', chartType: 'pie', columns: [ { key: 'gender', title: '性别' }, { key: 'count', title: '人数' }, ], rows: [ { gender: '男', count: 2 }, { gender: '女', count: 0 }, { gender: '未填写', count: 61 }, ], }, ], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render(); }); expect(container.textContent).toContain('学生性别比例'); expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); }); it('renders source references from assistant message metadata', async () => { const message: AiChatMessage = { role: 'assistant', content: '这是基于你上传的名单整理的入住统计。', reasoningContent: '', toolRuns: [], attachments: [], metadata: { a2uiSources: [ { title: '26暑期文化课宿舍.xlsx', url: '/api/ai/chat/attachments/7', description: 'excel' }, ], }, }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render(); }); expect(container.textContent).toContain('引用来源'); expect(container.textContent).toContain('26暑期文化课宿舍.xlsx'); }); it('renders an import preflight card from assistant message metadata', async () => { const message: AiChatMessage = { role: 'assistant', content: '这是预检结果', reasoningContent: '', toolRuns: [], attachments: [], metadata: { a2uiImportPreflight: { verdict: 'needs_input', stages: [ { stepKey: 'students', label: '学生档案', sheetNames: ['学生'], total: 2, create: 1, update: 1, error: 0, skip: 0, mapping: { name: '姓名' }, missingRequired: [], }, ], blocks: [{ code: 'unknown_organization', label: '未知校区', stepKeys: ['students'], message: '校区不存在', count: 1 }], questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], nextSteps: [ { key: 'students-next', label: '分班 / 排课 / 入住', description: '学生档案导入完成后可继续分班、排课或入住。', after: ['students'], }, ], }, }, }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render(); }); expect(container.textContent).toContain('Excel 导入预检'); expect(container.textContent).toContain('需要确认'); expect(container.textContent).toContain('学生档案'); expect(container.textContent).toContain('新建 1'); expect(container.textContent).toContain('更新 1'); expect(container.textContent).toContain('未知校区'); expect(container.textContent).toContain('分班 / 排课 / 入住'); }); it('renders preflight card, form Q&A and opens the import wizard', async () => { let openedRunId: string | null = null; const message: AiChatMessage = { role: 'assistant', content: '请先确认导入策略,再打开向导。', reasoningContent: '', toolRuns: [], attachments: [], metadata: { a2uiImportPreflight: { verdict: 'ready', stages: [ { stepKey: 'students', label: '学生档案', sheetNames: ['学生'], total: 1, create: 1, update: 0, error: 0, skip: 0, mapping: { name: '姓名' }, missingRequired: [], }, ], blocks: [], questions: [], nextSteps: [], }, a2uiImportWizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [{ name: '学生', headers: ['姓名'], rowCount: 1 }], steps: [{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }], }, }, forms: [ { id: 'form-1', title: '确认导入策略', submitLabel: '确认', fields: [ { name: 'duplicatePolicy', label: '重复行处理', type: 'select', options: [ { label: '标记为错误', value: 'error' }, { label: '跳过重复行', value: 'skip' }, ], }, ], }, ], }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render( { openedRunId = runId; }} />, ); }); expect(container.textContent).toContain('Excel 导入预检'); expect(container.textContent).toContain('确认导入策略'); const wizardButton = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.includes('打开导入向导'), ) as HTMLButtonElement | undefined; expect(wizardButton).toBeDefined(); await act(async () => { wizardButton?.click(); }); expect(openedRunId).toBe('run-1'); }); it('renders model retrying hint while waiting for the upstream retry', async () => { const message: AiChatMessage = { role: 'assistant', content: '', reasoningContent: '', toolRuns: [], attachments: [], retrying: { attempt: 2, maxRetries: 3, reason: '上游返回 503' }, }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render(); }); expect(container.textContent).toContain('正在自动重试(第 2 / 3 次)'); expect(container.textContent).toContain('上游返回 503'); }); it.each([ ['area', '面积图', [{ key: 'month', title: '月份' }, { key: 'amount', title: '金额' }], [ { month: '1月', amount: 100 }, { month: '2月', amount: 150 }, ]], ['scatter', '散点图', [ { key: 'room', title: '宿舍' }, { key: 'capacity', title: '容量' }, { key: 'occupied', title: '入住人数' }, ], [ { room: '1-101', capacity: 4, occupied: 3 }, { room: '1-102', capacity: 6, occupied: 5 }, ]], ['radar', '雷达图', [ { key: 'className', title: '班级' }, { key: 'attendance', title: '考勤' }, { key: 'score', title: '成绩' }, ], [ { className: '一班', attendance: 90, score: 85 }, { className: '二班', attendance: 80, score: 92 }, ]], ['gauge', '仪表盘', [ { key: 'metric', title: '指标' }, { key: 'value', title: '数值' }, { key: 'max', title: '最大值' }, ], [ { metric: '入住率', value: 82, max: 100 }, ]], ['funnel', '漏斗图', [ { key: 'stage', title: '阶段' }, { key: 'count', title: '人数' }, ], [ { stage: '咨询', count: 100 }, { stage: '报名', count: 60 }, ]], ])('渲染 %s 图表卡片', async (chartType, label, columns, rows) => { const chart: AiChartSchema = { id: `chart-${chartType}`, title: `${label}示例`, chartType: chartType as AiChartSchema['chartType'], columns, rows, }; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); await act(async () => { root?.render(); }); expect(container.textContent).toContain(`${label}示例`); expect(container.textContent).toContain(label); expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); }); });