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', status_label: '模型连接失败', error: '模型请求或JSON校验失败(ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理),已重试2次', 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('模型连接失败', { exact: true })).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('saving the last review refreshes batch status and keeps the active filter', async ({ page }) => { await setup(page); let saved = false; const saves: any[] = []; await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{ id: 71, course_version_id: 1, project_ids: [11], status: saved ? 'completed' : 'review_required', }] })); await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: { student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: saved ? 0 : null }, facts: {}, rows: [{ criterion: { criterion_id: 'stage3.plan', category: 'stage3', name: '下一阶段计划', max_score: 10 }, score_id: 501, status: saved ? 'reviewed' : 'needs_review', final_score: saved ? 0 : null, teacher_score: saved ? 0 : null, teacher_comment: saved ? '已核对' : null, evidence: [], missing_evidence: ['计划'], evidence_gaps: [], model_comment: '需要教师核对' }], } })); await page.route('**/api/criterion-scores/501/review', route => { saves.push(route.request().postDataJSON()); saved = true; return route.fulfill({ json: { id: 501, status: 'reviewed', teacher_score: 0, final_score: 0 } }); }); await page.goto('/?page=scores&batch_id=71&project_id=11'); await choose(page, '评分项筛选', '仅待教师复核(1)'); await page.keyboard.press('Escape'); await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); await page.getByRole('button', { name: '查看证据/复核' }).click(); const modal = page.getByRole('dialog'); await modal.getByRole('spinbutton', { name: '教师评分' }).fill('0'); await modal.getByRole('textbox', { name: '教师评语' }).fill('已核对'); await modal.getByRole('button', { name: '保存教师复核', exact: true }).click(); await expect.poll(() => saves.length).toBe(1); expect(saves[0].score).toBe(0); await expect(page.getByText('批次 #71 · 已完成', { exact: true })).toBeVisible(); await expect(page.getByText('仅待教师复核(0)', { exact: true })).toBeVisible(); await expect(page.getByText('当前筛选下没有评分项', { exact: true })).toBeVisible(); await expect(page.getByText('已评分 0 项 · 教师已复核 1 项 · 待处理 0 项', { exact: true })).toBeVisible(); }); test('manual score refresh ignores late responses for a previously selected student', async ({ page }) => { await setup(page); let held = false, release: (() => void) | undefined; let firstReads = 0; await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{ id: 71, course_version_id: 1, project_ids: [11, 12], status: 'completed', }] })); await page.route('**/api/projects/*/scores?*', async route => { const isFirst = route.request().url().includes('/projects/11/'); if (isFirst && ++firstReads > 1) { held = true; await new Promise(resolve => { release = resolve; }); } await route.fulfill({ json: { student_no: isFirst ? '9000000001' : '9000000002', name: isFirst ? '学生甲' : '学生乙', topic: isFirst ? '项目甲' : '项目乙', totals: {}, rows: [], facts: {}, } }); }); await page.goto('/?page=scores&batch_id=71&project_id=11'); await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toBeVisible(); await page.getByRole('button', { name: '刷新评分', exact: true }).click(); await expect.poll(() => held).toBe(true); await choose(page, '学生项目', /学生乙/); await page.keyboard.press('Escape'); await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible(); const late = page.waitForResponse(response => response.url().includes('/projects/11/scores')); release!(); await late; await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible(); await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toHaveCount(0); }); 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(); });