Files
aerospaceboy ebe33392ca fix: improve import, evidence indexing and material-scoped grading
Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
2026-10-14 13:52:56 +08:00

165 lines
9.7 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('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();
});