Files
WiseGrade/frontend/tests/chat.spec.ts
T

127 lines
8.6 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
async function mockApi(page: Page) {
const calls: string[] = [];
const history: Record<number, any> = {
7: { id: 7, title: '历史会话', course_version_id: 1, batch_id: null, project_id: null,
scope_locked: true, messages: [{ id: 1, role: 'user', content: '旧问题' }, { id: 2, role: 'assistant',
content: '# 标题\n\n段落 [安全链接](https://example.com) [危险链接](javascript:alert(1)) <img src=x onerror=alert(1)>\n\n> 引用\n\n- 列表\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```js\nconst x = 1;\n```',
citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }],
tool_calls: [{ id: 'call-one', name: 'list_courses', status: 'completed', arguments: { course_version_id: 1 }, result: '返回 1 条记录' }] }],
actions: [], runs: [{ id: 1, status: 'completed', tool_calls: [] }] },
};
let nextId = 8;
await page.route('**/api/**', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.slice(4);
const method = request.method();
calls.push(`${method} ${path}`);
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
if (path === '/conversations' && method === 'GET') return json(Object.values(history).map(d => ({ id: d.id, title: d.title, course_version_id: d.course_version_id, batch_id: d.batch_id, project_id: d.project_id, running: d.runs[0]?.status === 'running' })));
if (path === '/conversations/first-message' && method === 'POST') {
const body = request.postDataJSON(); const id = nextId++;
history[id] = { id, title: body.content, course_version_id: body.course_version_id || null, batch_id: body.batch_id || null, project_id: body.project_id || null,
scope_locked: true, messages: [{ id: id * 10, role: 'user', content: body.content }], actions: [], runs: [{ id, status: 'running', tool_calls: [{ id: `call-${id}`, name: 'list_courses', status: 'running', arguments: {} }] }] };
return json({ conversation_id: id, run_id: id });
}
const match = path.match(/^\/conversations\/(\d+)(.*)$/);
if (match) {
const id = Number(match[1]); const tail = match[2];
if (!history[id]) return json({ detail: '会话不存在' }, 404);
if (!tail && method === 'GET') return json(history[id]);
if (!tail && method === 'DELETE') { if (history[id].runs[0]?.status === 'running') return json({ detail: '会话正在运行' }, 409); delete history[id]; return json({ deleted: id }); }
if (tail === '/scope' && method === 'PUT') { Object.assign(history[id], request.postDataJSON()); return json(history[id]); }
if (tail === '/messages/start' && method === 'POST') { const body = request.postDataJSON(); history[id].messages.push({ id: id * 10 + history[id].messages.length, role: 'user', content: body.content }); history[id].runs.unshift({ id: id + 100, status: 'running', tool_calls: [] }); return json({ conversation_id: id, run_id: id + 100 }); }
}
if (path === '/courses') return json([{ id: 1 }]);
if (path === '/courses/1/versions') return json([{ id: 1, version: 1, title: '课程一' }, { id: 2, version: 2, title: '课程二' }]);
if (path === '/scoring-batches') return json([{ id: 4, course_version_id: 2, status: 'created', project_ids: [5] }]);
if (path === '/projects') return json([{ id: 5, course_version_id: 2, student_no: '1', name: '测试' }]);
if (path === '/index-tasks') return json([]);
return json({ detail: 'mock not found' }, 404);
});
return { calls, history };
}
test('blank draft stays local, scope works, history switches and deletion confirms', async ({ page }) => {
const state = await mockApi(page);
await page.goto('/?page=chat');
await expect(page.getByText('新会话草稿')).toBeVisible();
await expect(page.getByText('历史会话')).toBeVisible();
expect(state.calls.filter(c => c.startsWith('POST /conversations'))).toHaveLength(0);
await page.locator('.chat-scope .ant-select').nth(1).click();
await page.getByText('#4 · created').click();
await expect(page.locator('.chat-scope .ant-select').first()).toContainText('课程二');
await page.locator('.chat-scope .ant-select').nth(2).click();
await page.getByText('1 测试').click();
await expect(page.locator('.chat-scope .ant-select').nth(2)).toContainText('1 测试');
await page.locator('.chat-scope .ant-select').first().hover();
await page.locator('.chat-scope .ant-select').first().locator('.ant-select-clear').click();
await expect(page.locator('.chat-scope .ant-select').nth(2)).not.toContainText('1 测试');
await page.getByRole('button', { name: /新\s*建/ }).click();
await expect(page.locator('.chat-scope .ant-select').first()).not.toContainText('课程二');
expect(state.calls.filter(c => c.startsWith('POST /conversations'))).toHaveLength(0);
await page.getByText('历史会话').click();
await expect(page.getByText('范围已锁定')).toBeVisible();
await page.getByRole('button', { name: /^删\s*除$/ }).click();
await expect(page.getByText('删除会话?')).toBeVisible();
await page.getByRole('button', { name: /取\s*消/ }).click();
await expect(page.getByText('历史会话').first()).toBeVisible();
await page.getByRole('button', { name: /^删\s*除$/ }).click();
await page.getByRole('button', { name: /删\s*除\s*会\s*话/ }).click();
await expect(page.getByText('新会话草稿')).toBeVisible();
expect(state.calls).toContain('DELETE /conversations/7');
await page.reload();
await expect(page.getByText('新会话草稿')).toBeVisible();
});
test('markdown, citations, tool folding and narrow layout are safe', async ({ page }) => {
await mockApi(page);
await page.goto('/?page=chat');
await page.getByText('历史会话').click();
await expect(page.getByRole('heading', { name: '标题' })).toBeVisible();
await expect(page.locator('.chat-markdown table')).toBeVisible();
await expect(page.locator('.chat-markdown blockquote')).toBeVisible();
await expect(page.locator('.chat-markdown pre')).toBeVisible();
await expect(page.locator('.chat-markdown img')).toHaveCount(0);
await expect(page.getByText('危险链接')).not.toHaveAttribute('href');
await expect(page.getByText('引用 · 1')).toBeVisible();
await expect(page.getByText('资料.pdf')).toHaveCount(1);
await expect(page.locator('.tool-detail')).not.toBeVisible();
await page.locator('.chat-tool summary').click();
await expect(page.locator('.tool-detail')).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.ant-layout-sider')).toHaveClass(/ant-layout-sider-zero-width/);
await expect(page.locator('.chat-scope .ant-select').first()).toBeVisible();
await page.screenshot({ path: 'test-results/mobile-chat.png', fullPage: true });
const width = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth);
expect(width).toBeLessThanOrEqual(2);
});
test('switching sessions leaves runs independent and updates each status', async ({ page }) => {
const state = await mockApi(page);
await page.goto('/?page=chat');
await page.getByPlaceholder(/例如/).fill('甲');
await page.getByRole('button', { name: /发\s*送/ }).click();
await expect(page.getByText('甲', { exact: true })).toBeVisible();
await expect(page.getByText('生成中').first()).toBeVisible();
await page.getByRole('button', { name: /^删\s*除$/ }).last().click();
await page.getByRole('button', { name: /删\s*除\s*会\s*话/ }).click();
await expect(page.getByText('会话正在运行')).toBeVisible();
await page.getByRole('button', { name: /取\s*消/ }).click();
await page.getByText('历史会话').click();
await expect(page.getByText('旧问题')).toBeVisible();
await page.getByRole('button', { name: /新\s*建/ }).click();
await page.getByPlaceholder(/例如/).fill('乙');
await page.getByRole('button', { name: /发\s*送/ }).click();
await expect(page.getByText('乙', { exact: true })).toBeVisible();
await expect(page.getByText('生成中').first()).toBeVisible();
expect(state.history[8].runs[0].status).toBe('running');
state.history[8].runs[0].status = 'completed';
state.history[8].messages.push({ id: 82, role: 'assistant', content: '回答甲', citations: [], tool_calls: [] });
await page.getByText('甲', { exact: true }).first().click();
await expect(page.getByText('回答甲')).toBeVisible();
expect(state.history[9].runs[0].status).toBe('running');
expect(state.calls.filter(c => c === 'POST /conversations/first-message')).toHaveLength(2);
});