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

231 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test';
const projects = [
{ id: 11, course_version_id: 1, student_no: '9000000001', name: '学生甲', topic: '真实报告样例', missing: ['stage1'], parse_errors: [] },
{ id: 12, course_version_id: 1, student_no: '9000000002', name: '学生乙', topic: '完整模拟资料', missing: [], parse_errors: [], materials: [
{ id: 121, file_name: '最终报告.pdf', category: 'final_report', parse_status: 'ready' },
{ id: 122, file_name: '汇报PPT.pdf', category: 'presentation', parse_status: 'ready' },
] },
{ id: 13, course_version_id: 2, student_no: '9000000003', name: '学生丙', topic: '另一课程项目', missing: [], parse_errors: [] },
];
async function setup(page: Page) {
const creates: any[] = [], scoreReads: string[] = [];
const batches: any[] = [
{ id: 71, course_version_id: 1, rubric_version_id: 1, course_version: 1, rubric_version: 1,
course_title: '初始课程版本', criterion_count: 22, project_ids: [11], status: 'created',
progress_done: 0, progress_total: 0, error: '' },
];
await page.route('**/api/**', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api/, '');
let data: any = [];
if (path === '/courses') data = [{ id: 1, name: '课程甲' }, { id: 2, name: '课程乙' }];
else if (path === '/courses/1/versions') data = [{ id: 1, version: 1, title: '初始课程版本', published: true }];
else if (path === '/courses/2/versions') data = [{ id: 2, version: 1, title: '其他课程版本', published: true }];
else if (path === '/course-versions/1') data = { id: 1, course_id: 1, version: 1, title: '初始课程版本', published: true };
else if (path === '/course-versions/1/rubrics') data = [{ id: 1, version: 1, published: true, criteria: Array(22).fill({}) }];
else if (path === '/course-versions/2/rubrics') data = [{ id: 2, version: 1, published: true, criteria: Array(22).fill({}) }];
else if (path === '/projects') data = projects;
else if (path === '/import/scan') data = { projects: [{ relative_dir: 'test/student', student_no: '9000000002',
name: '学生乙', topic: '完整模拟资料', status: 'recognized', missing: [], warnings: [],
files: [{ relative_path: 'test/student/最终报告.pdf', category: 'final_report', status: 'recognized', sha256: 'a'.repeat(64) }] }] };
else if (path === '/import/confirm') data = { project_ids: [12], index_task_ids: [1] };
else if (path === '/scoring-batches') {
if (request.method() === 'POST') {
const body = request.postDataJSON(); creates.push(body);
const batch = { ...batches[0], id: 72, project_ids: body.project_ids };
batches.unshift(batch); data = { id: 72, status: 'created' };
} else data = batches;
} else if (path.startsWith('/projects/') && path.endsWith('/scores')) {
scoreReads.push(request.url());
data = { project_id: 11, student_no: '9000000001', name: '学生甲', topic: '真实报告样例',
totals: { overall: null }, rows: [], facts: {} };
}
await route.fulfill({ json: data });
});
return { creates, scoreReads };
}
async function choose(page: Page, label: string, option: string | RegExp) {
const selector = page.getByRole('combobox', { name: label, exact: true });
await selector.focus();
await selector.press('ArrowDown');
await page.locator('.ant-select-dropdown:visible').getByText(option, { exact: typeof option === 'string' }).click();
}
test('batch list identifies its students instead of showing only rule v1', async ({ page }) => {
await setup(page); await page.goto('/?page=batches');
const row = page.getByRole('row').filter({ hasText: '#71' });
await expect(row).toContainText('学生甲');
await expect(row).toContainText('9000000001');
await expect(row).not.toContainText('学生乙');
});
test('choose only one of two same-course projects when creating a batch', async ({ page }) => {
const state = await setup(page); await page.goto('/?page=batches');
await choose(page, '课程', '课程甲');
await choose(page, '课程版本', /初始课程版本/);
await choose(page, '评分规则', /22项/);
await expect(page.getByRole('button', { name: '创建批次', exact: true })).toBeDisabled();
await choose(page, '选择资料', /学生乙/);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: '创建批次', exact: true }).click();
await expect.poll(() => state.creates.length).toBe(1);
expect(state.creates[0]).toEqual({ course_version_id: 1, rubric_version_id: 1, project_ids: [12] });
await expect(page.getByRole('row').filter({ hasText: '#72' })).toContainText('学生乙');
});
test('changing course clears previous rules and selected projects', async ({ page }) => {
await setup(page); await page.goto('/?page=batches');
await choose(page, '课程', '课程甲');
await choose(page, '课程版本', /初始课程版本/);
await choose(page, '评分规则', /22项/);
await choose(page, '选择资料', /学生乙/);
await page.keyboard.press('Escape');
await choose(page, '课程', '课程乙');
await expect(page.getByRole('button', { name: '创建批次', exact: true })).toBeDisabled();
await expect(page.getByText('已选择 0 个项目', { exact: true })).toBeVisible();
});
test('score selector excludes projects outside the fixed batch membership', async ({ page }) => {
const state = await setup(page); await page.goto('/?page=scores&batch_id=71&project_id=12');
const selector = page.getByRole('combobox', { name: '学生项目', exact: true });
await selector.focus();
await selector.press('ArrowDown');
await expect(page.locator('.ant-select-dropdown:visible').getByText(/学生甲/)).toBeVisible();
await expect(page.locator('.ant-select-dropdown:visible').getByText(/学生乙/)).toHaveCount(0);
expect(state.scoreReads.every(url => !url.includes('/projects/12/'))).toBe(true);
});
test('material selection follows the rule and displays its PDF filenames', async ({ page }) => {
const state = await setup(page);
await page.goto('/?page=batches&course_version_id=1&project_ids=12');
await expect(page.getByText('已选择 1 个项目', { exact: true })).toBeVisible();
await expect(page.getByText('最终报告.pdf', { exact: true })).toBeVisible();
await expect(page.getByText('汇报PPT.pdf', { exact: true })).toBeVisible();
await choose(page, '评分规则', /22项/);
await page.getByRole('button', { name: '创建批次', exact: true }).click();
await expect.poll(() => state.creates.length).toBe(1);
expect(state.creates[0].project_ids).toEqual([12]);
});
test('after confirming import the grading link selects only the imported materials', async ({ page }) => {
await setup(page); await page.goto('/?page=import');
await page.getByRole('button', { name: '扫描此目录', exact: true }).click();
await choose(page, '导入课程', '课程甲');
await choose(page, '导入课程版本', /初始课程版本/);
await page.getByRole('button', { name: '确认导入', exact: true }).click();
await expect(page.getByRole('button', { name: '查看已导入资料', exact: true })).toBeVisible();
await page.getByRole('button', { name: '去评分', exact: true }).click();
await expect(page).toHaveURL(/page=batches.*project_ids=12/);
await expect(page.getByText('已选择 1 个项目', { exact: true })).toBeVisible();
await expect(page.getByText('最终报告.pdf', { exact: true })).toBeVisible();
});
test('empty scan explains the problem and cannot confirm an empty import', async ({ page }) => {
await setup(page);
await page.route('**/api/import/scan', route => route.fulfill({ json: { projects: [] } }));
await page.goto('/?page=import');
await page.getByRole('button', { name: '扫描此目录', exact: true }).click();
await expect(page.getByText('未找到可导入资料', { exact: true })).toBeVisible();
await choose(page, '导入课程', '课程甲');
await choose(page, '导入课程版本', /初始课程版本/);
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
});
test('failed scoring shows the network error and retry guidance on the page', async ({ page }) => {
await setup(page);
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
rows: [{ criterion: { criterion_id: 'stage1.problem', category: 'stage1', name: '需求分析', max_score: 25 },
status: 'failed', error: 'ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理',
evidence: [], missing_evidence: [], evidence_gaps: [] }],
} }));
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('当前项目有 1 项评分失败', { exact: true })).toBeVisible();
await expect(page.getByText(/ConnectError:无法连接模型端点/)).toBeVisible();
await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible();
});
test('batch progress exposes failures separately from teacher review', async ({ page }) => {
await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
id: 71, course_version_id: 1, project_ids: [11], status: 'partial',
progress_done: 22, progress_total: 22, needs_review: 2, failed: 1, unscorable: 0,
}] }));
await page.goto('/?page=batches');
const row = page.getByRole('row').filter({ hasText: '#71' });
await expect(row).toContainText('评分失败 1');
await expect(row).toContainText('待复核 2');
await expect(row).toContainText('处理结束,仍有未评分项');
});
test('output validation and insufficient evidence are not presented as network failures', async ({ page }) => {
await setup(page);
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
rows: [
{ criterion: { criterion_id: 'stage2.experiment', category: 'stage2', name: '实验方案', max_score: 20 },
status: 'failed', error: '模型证据未关联该片段的检索目标', evidence: [] },
{ criterion: { criterion_id: 'stage3.plan', category: 'stage3', name: '下一阶段计划', max_score: 10 },
status: 'needs_review', error: '模型认为现有正文证据不足以判断该项,转教师复核。',
evidence: [], missing_evidence: ['计划'], evidence_gaps: [], model_comment: '当前片段不足。' },
],
} }));
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('模型输出校验失败', { exact: true })).toBeVisible();
await expect(page.getByText('模型调用失败', { exact: true })).toHaveCount(0);
await expect(page.getByText(/输出校验失败可重试/)).toBeVisible();
await page.getByRole('row').filter({ hasText: '下一阶段计划' }).getByRole('button', { name: '查看证据/复核' }).click();
await expect(page.getByText('本项证据不足以自动评分,需要教师复核', { exact: true })).toBeVisible();
await expect(page.getByText(/缺少或尚不足以支撑判断的目标:计划/)).toBeVisible();
});
test('score filters isolate actionable rows without hiding zero grades or changing data', async ({ page }) => {
await setup(page);
const writes: string[] = [];
page.on('request', request => { if (request.method() !== 'GET') writes.push(request.url()); });
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
rows: ['scored', 'reviewed', 'needs_review', 'failed', 'unscorable'].map((status, i) => ({
criterion: { criterion_id: `stage1.${i}`, category: 'stage1', name: `测试项目${i}`, max_score: 20 },
status, model_score: status === 'scored' ? 0 : null, teacher_score: status === 'reviewed' ? 0 : null,
final_score: ['scored', 'reviewed'].includes(status) ? 0 : null,
error: status === 'failed' ? '模型证据未能匹配检索原文' : '', evidence: [],
})),
} }));
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('已评分 1 项 · 教师已复核 1 项 · 待处理 3 项', { exact: true })).toBeVisible();
const rows = page.getByRole('row').filter({ hasText: '测试项目' });
await expect(rows).toHaveCount(5);
for (const [option, count, name] of [
['仅待处理(3)', 3, '测试项目2'], ['仅待教师复核(1)', 1, '测试项目2'],
['仅评分失败(1)', 1, '测试项目3'], ['仅材料异常(1)', 1, '测试项目4'],
['全部评分项(5)', 5, '测试项目0'],
] as const) {
await page.keyboard.press('Escape');
await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
await choose(page, '评分项筛选', option);
await expect(rows).toHaveCount(count);
await expect(page.getByRole('row').filter({ hasText: name })).toBeVisible();
}
await expect(page.getByRole('row').filter({ hasText: '测试项目0' })).toContainText('0 / 20');
expect(writes).toEqual([]);
});
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
const state = await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
id: 71, course_version_id: 1, project_ids: [11, 12], status: 'partial',
}] }));
await page.route('**/api/projects', async route => {
await new Promise(resolve => setTimeout(resolve, 400));
await route.fulfill({ json: projects });
});
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect.poll(() => state.scoreReads.some(url => url.includes('/projects/11/scores'))).toBe(true);
await expect(page.getByText('9000000001 学生甲 · 真实报告样例', { exact: true })).toBeVisible();
});