ebe33392ca
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.
165 lines
9.7 KiB
TypeScript
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();
|
|
});
|