476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
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<typeof createRoot> | 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(
|
|
<Bubble.List
|
|
role={aiBubbleRoles}
|
|
items={[
|
|
{
|
|
key: 'user-1',
|
|
role: 'user',
|
|
status: 'local',
|
|
content: message,
|
|
contentRender: (content: AiChatMessage) => <div>{content.content}</div>,
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain('查询今天的系统概览');
|
|
});
|
|
|
|
it('renders an A2UI form and submits normalized values', async () => {
|
|
let submitted: Record<string, unknown> | null = null;
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(
|
|
<DynamicForm
|
|
form={{
|
|
id: 'form-1',
|
|
title: '新增学生',
|
|
submitLabel: '提交创建',
|
|
fields: [
|
|
{ name: 'name', label: '姓名', type: 'input', required: true },
|
|
{ name: 'studentNo', label: '学号', type: 'input' },
|
|
],
|
|
}}
|
|
onSubmit={(values) => {
|
|
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(
|
|
<DynamicReview
|
|
review={review}
|
|
onSubmit={(reviewId) => {
|
|
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(
|
|
<DynamicReview
|
|
review={review}
|
|
onSubmit={() => 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(<DynamicReview review={review} onSubmit={() => 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(
|
|
<DynamicReview
|
|
review={review}
|
|
onSubmit={() => {
|
|
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(<DynamicChart chart={chart} />);
|
|
});
|
|
|
|
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(<AiMessageContent message={message} />);
|
|
});
|
|
|
|
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(<AiMessageContent message={message} />);
|
|
});
|
|
|
|
expect(container.textContent).toContain('引用来源');
|
|
expect(container.textContent).toContain('26暑期文化课宿舍.xlsx');
|
|
});
|
|
|
|
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(<AiMessageContent message={message} />);
|
|
});
|
|
|
|
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(<DynamicChart chart={chart} />);
|
|
});
|
|
|
|
expect(container.textContent).toContain(`${label}示例`);
|
|
expect(container.textContent).toContain(label);
|
|
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
|
|
});
|
|
});
|