231 lines
14 KiB
TypeScript
231 lines
14 KiB
TypeScript
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();
|
||
});
|