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(); });