import { test, expect, type Page, type APIRequestContext } from '@playwright/test'; // ══════════════════════════════════════════════════════════════════ // 硬编码修正 E2E 测试套件 // 覆盖对象: // 1. 模板维度可声明「文件关键词」→ DIM_FILE_FILTERS 硬编码仅兑底 // 2. 模板 content 成为评审指南第一来源(评审要点完整保留并透传) // 3. L2考核「功能完整性」构建封顶维度识别(isBuildRelatedDim) // 4. 阈值收拢命名常量后 pass_line / 及格线行为回归 // 5. L2考核 选题(selected_topic)与难度赋分 cap // ══════════════════════════════════════════════════════════════════ const PASSWORD = 'test123'; const UNIQUE = Date.now().toString(36); let entrySeq = 0; // 保证同 describe 内多条目的 repo_url 不撞车(中文标题消毒后 slug 可能为空) const BASE = 'http://localhost:3002/api'; // ─── Helpers ─── async function login(page: Page) { await page.goto('/login'); await page.fill('input[type="password"]', PASSWORD); await page.click('button[type="submit"]'); await page.waitForURL('/'); } async function apiLogin(request: APIRequestContext): Promise { const r = await request.post(`${BASE}/auth/login`, { data: { password: PASSWORD } }); const { token } = await r.json(); return token; } async function apiCreateProject(request: APIRequestContext, token: string, name: string, track = ''): Promise { const r = await request.fetch(`${BASE}/projects`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { name, track }, }); expect(r.status()).toBe(200); return (await r.json()).id; } async function apiCreateStandard(request: APIRequestContext, token: string, pid: string, content: string, category_tag = ''): Promise { const r = await request.fetch(`${BASE}/projects/${pid}/standards`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { name: `标准-${UNIQUE}`, content, category_tag, max_score: 150 }, }); expect(r.status()).toBe(200); return (await r.json()).id; } // 赛道必选后项目创建自动带入赛道默认标准;若测试只用自定义标准,需先清空自动标准, // 让条目创建时 resolveStandard 回退到用户上传的标准(category_tag='') async function apiClearStandards(request: APIRequestContext, token: string, pid: string): Promise { const list = await request.fetch(`${BASE}/projects/${pid}/standards`, { headers: { Authorization: `Bearer ${token}` } }); const standards = await list.json(); for (const s of standards) { await request.fetch(`${BASE}/projects/${pid}/standards/${s.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); } } async function apiCreateEntry(request: APIRequestContext, token: string, pid: string, title: string, extra: any = {}): Promise { const r = await request.fetch(`${BASE}/projects/${pid}/entries`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { title, repo_url: `https://e2e-${UNIQUE}-${entrySeq++}-${title.replace(/[^\w]/g, '')}.git`, ...extra }, }); expect(r.status()).toBe(200); return r.json(); } // ══════════════════════════════════════ // 1. 项目创建与赛道自动标准 // ══════════════════════════════════════ test.describe('项目创建与赛道自动标准', () => { test('1.1 缺赛道被拒绝(赛道必选)', async ({ request }) => { const token = await apiLogin(request); const r = await request.fetch(`${BASE}/projects`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { name: `无赛道-${UNIQUE}` }, }); expect(r.status()).toBe(400); expect((await r.json()).error).toContain('赛道'); }); test('1.2 赛道二自动生成 8 维度标准', async ({ request }) => { const token = await apiLogin(request); const pid = await apiCreateProject(request, token, `赛道二-${UNIQUE}`, '赛道二'); const r = await request.fetch(`${BASE}/projects/${pid}/standards`, { headers: { Authorization: `Bearer ${token}` }, }); const standards = await r.json(); expect(standards).toHaveLength(1); expect(standards[0].category_tag).toBe('赛道二'); expect(standards[0].dimensions).toHaveLength(8); const names = standards[0].dimensions.map((d: any) => d.name); expect(names).toContain('提效设计合理性'); expect(names).toContain('提效幅度'); expect(names).toContain('稳定性与易用性'); await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); }); test('1.3 L2考核自动生成 7 维标准(功能完整性 30)', async ({ request }) => { const token = await apiLogin(request); const pid = await apiCreateProject(request, token, `L2-${UNIQUE}`, 'L2考核'); const r = await request.fetch(`${BASE}/projects/${pid}/standards`, { headers: { Authorization: `Bearer ${token}` }, }); const standards = await r.json(); expect(standards).toHaveLength(1); const dims = standards[0].dimensions; expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 30)).toBe(true); // 7 维合计 100(L2 合格线 = 60) const total = dims.reduce((s: number, d: any) => s + d.maxScore, 0); expect(total).toBe(100); expect(dims).toHaveLength(7); await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); }); }); // ══════════════════════════════════════ // 2. 标准上传:文件关键词解析 // ══════════════════════════════════════ test.describe('标准上传:文件关键词(fileKeywords)', () => { let pid = ''; let token = ''; test.beforeAll(async ({ request }) => { token = await apiLogin(request); pid = await apiCreateProject(request, token, `关键词-${UNIQUE}`, '赛道一'); }); test.afterAll(async ({ request }) => { await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); }); test('2.1 半角冒号 `文件关键词: a,b` 解析成功,content 剥离关键词行', async ({ request }) => { const content = [ '## 提效幅度(10分)', '检查以下4项', '1. 对比数据(3分)', '2. 可验证(2分)', '文件关键词: data,report,benchmark', ].join('\n'); const r = await request.fetch(`${BASE}/projects/${pid}/standards`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { name: '半角标准', content, max_score: 150 }, }); expect(r.status()).toBe(200); const std = await r.json(); expect(std.dimensions).toHaveLength(1); expect(std.dimensions[0].fileKeywords).toBe('data,report,benchmark'); expect(std.dimensions[0].content).not.toContain('文件关键词'); expect(std.dimensions[0].content).toContain('对比数据'); }); test('2.2 全角冒号 `文件关键词:a,b` 同样解析', async ({ request }) => { const content = '## 架构审查(20分)\n架构分层合理性\n文件关键词:design,arch,spec'; const r = await request.fetch(`${BASE}/projects/${pid}/standards`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { name: '全角标准', content, max_score: 150 }, }); expect(r.status()).toBe(200); const std = await r.json(); expect(std.dimensions[0].fileKeywords).toBe('design,arch,spec'); expect(std.dimensions[0].content).not.toContain('文件关键词'); }); test('2.3 无关键词维度 fileKeywords 为 undefined', async ({ request }) => { const content = '## 代码质量(50分)\n代码整洁、可读性\n## 测试覆盖(50分)\n覆盖率'; const r = await request.fetch(`${BASE}/projects/${pid}/standards`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { name: '无关键词标准', content, max_score: 150 }, }); expect(r.status()).toBe(200); const std = await r.json(); expect(std.dimensions[0].fileKeywords).toBeUndefined(); expect(std.dimensions[1].fileKeywords).toBeUndefined(); }); test('2.4 文件关键词不改变标准总分校验', async ({ request }) => { const content = '## A(100分)\n说明\n文件关键词: a\n## B(60分)\n说明'; const r = await request.fetch(`${BASE}/projects/${pid}/standards`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { name: '超分标准', content, max_score: 150 }, }); expect(r.status()).toBe(400); }); test('2.5 UI 上传带关键词的标准 → 维度详情显示 content 不含关键词行', async ({ page }) => { await login(page); await page.goto(`/project/${pid}`); await page.click('.tab:has-text("标准")'); await page.click('.section-header button:has-text("上传标准")'); await page.fill('.standard-form input:first-child', 'UI关键词标准'); await page.fill('.standard-form textarea', '## 提效幅度(10分)\n检查以下4项\n1. 对比数据(3分)\n文件关键词: data,report'); await page.click('.form-actions button:has-text("保存")'); const card = page.locator('.standard-card', { hasText: 'UI关键词标准' }); await expect(card).toBeVisible(); await card.locator('summary').first().click(); const detailText = await card.textContent(); expect(detailText).toContain('对比数据'); expect(detailText).not.toContain('文件关键词'); }); test('2.6 textarea placeholder 提示文件关键词语法', async ({ page }) => { await login(page); await page.goto(`/project/${pid}`); await page.click('.tab:has-text("标准")'); await page.click('.section-header button:has-text("上传标准")'); const placeholder = await page.locator('.standard-form textarea').getAttribute('placeholder'); expect(placeholder).toContain('文件关键词'); }); }); // ══════════════════════════════════════ // 3. 条目快照:fileKeywords 与 content 透传 // ══════════════════════════════════════ test.describe('条目快照透传', () => { let pid = ''; let token = ''; test.beforeAll(async ({ request }) => { token = await apiLogin(request); pid = await apiCreateProject(request, token, `快照-${UNIQUE}`, '赛道一'); await apiClearStandards(request, token, pid); await apiCreateStandard(request, token, pid, '## 提效幅度(10分)\n检查以下4项\n1. 对比数据(3分)\n文件关键词: data,report,benchmark\n## 演示与文档(5分)\n文档齐全'); }); test.afterAll(async ({ request }) => { await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); }); test('3.1 条目 standard_snapshot 携带 fileKeywords 且 content 保留评审要点', async ({ request }) => { const entry = await apiCreateEntry(request, token, pid, '快照条目'); const dims = JSON.parse(entry.standard_snapshot); expect(dims).toHaveLength(2); const dim0 = dims.find((d: any) => d.name === '提效幅度'); expect(dim0.fileKeywords).toBe('data,report,benchmark'); expect(dim0.content).toContain('对比数据'); expect(dim0.content).not.toContain('文件关键词'); const dim1 = dims.find((d: any) => d.name === '演示与文档'); expect(dim1.fileKeywords).toBeUndefined(); expect(dim1.content).toContain('文档齐全'); }); test('3.2 该维度评审时文件将被关键词过滤(评审提示含评审要点)', async ({ request }) => { const entry = await apiCreateEntry(request, token, pid, '快照条目2'); const dims = JSON.parse(entry.standard_snapshot); const dim0 = dims.find((d: any) => d.name === '提效幅度'); // content 是发送给子 Agent 的评审指南来源(模板优先于硬编码) expect(dim0.content.length).toBeGreaterThan(5); }); }); // ══════════════════════════════════════ // 4. L2考核:选题(selected_topic)+ 难度赋分 cap // ══════════════════════════════════════ test.describe('L2考核 选题与 cap', () => { let pid = ''; let token = ''; test.beforeAll(async ({ request }) => { token = await apiLogin(request); pid = await apiCreateProject(request, token, `L2Q-${UNIQUE}`, 'L2考核'); }); test.afterAll(async ({ request }) => { await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); }); test('4.1 无效选题被拒绝(400)', async ({ request }) => { const r = await request.fetch(`${BASE}/projects/${pid}/entries`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { title: '无效选题', repo_url: `https://noid-${UNIQUE}.git`, selected_topic: '99' }, }); expect(r.status()).toBe(400); }); test('4.2 命题 11(★★)→ cap=100;自选题 → cap=100', async ({ request }) => { const e1 = await apiCreateEntry(request, token, pid, '题11选手', { selected_topic: '11' }); expect(e1.selected_topic).toBe('11'); expect(e1.max_score_cap).toBe(100); const e2 = await apiCreateEntry(request, token, pid, '自选选手', { selected_topic: 'self' }); expect(e2.max_score_cap).toBe(100); }); test('4.3 命题 03(★★★★)→ cap=110(难度赋分封顶)', async ({ request }) => { const entry = await apiCreateEntry(request, token, pid, '题03选手', { selected_topic: '03' }); expect(entry.max_score_cap).toBe(110); }); test('4.4 快照为 7 维标准(全 common,功能完整性 30)', async ({ request }) => { const entry = await apiCreateEntry(request, token, pid, '快照选手', { selected_topic: '01' }); const dims = JSON.parse(entry.standard_snapshot); expect(dims).toHaveLength(7); expect(dims.every((d: any) => (d.group || 'common') === 'common')).toBe(true); const fnDim = dims.find((d: any) => d.name === '功能完整性'); expect(fnDim.maxScore).toBe(30); }); }); // ══════════════════════════════════════ // 5. 赛道二 pass_line 与评审展示 // ══════════════════════════════════════ test.describe('赛道二 及格线与评审展示', () => { let pid = ''; let token = ''; test.beforeAll(async ({ request }) => { token = await apiLogin(request); pid = await apiCreateProject(request, token, `赛道二P-${UNIQUE}`, '赛道二'); }); test.afterAll(async ({ request }) => { await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); }); test('5.1 赛道二 pass_line = 总分 60% = 60', async ({ request }) => { const entry = await apiCreateEntry(request, token, pid, '赛道二选手'); expect(entry.pass_line).toBe(60); }); test('5.2 force-review 后详情面板显示 8 维度与总分', async ({ page, request }) => { const entry = await apiCreateEntry(request, token, pid, '展示选手'); const dims = JSON.parse(entry.standard_snapshot); const reviewDims = dims.map((d: any) => ({ name: d.name, score: Math.round(d.maxScore * 0.5), maxScore: d.maxScore, comment: `评审 ${d.name}`, })); await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/force-review`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { dimensions: reviewDims }, }); await login(page); await page.goto(`/project/${pid}`); await page.click('.tab:has-text("条目")'); await page.click('.title-cell'); await expect(page.locator('.detail-panel')).toBeVisible(); await expect(page.locator('.detail-dims')).toBeVisible(); const rows = await page.locator('.detail-dims tbody tr').count(); expect(rows).toBe(8); const detailText = await page.locator('.detail-panel').textContent(); expect(detailText).toContain('提效设计合理性'); expect(detailText).toContain('提效幅度'); }); test('5.3 汇总页显示总分与通过判定', async ({ page, request }) => { const entry = await apiCreateEntry(request, token, pid, '汇总选手'); const dims = JSON.parse(entry.standard_snapshot); const reviewDims = dims.map((d: any) => ({ name: d.name, score: d.maxScore, maxScore: d.maxScore, comment: '满分' })); await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/force-review`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { dimensions: reviewDims }, }); await login(page); await page.goto(`/project/${pid}`); await page.click('.tab:has-text("汇总")'); await expect(page.locator('.summary-section')).toContainText('汇总选手'); await expect(page.locator('.summary-section')).toContainText('100'); }); }); // ══════════════════════════════════════ // 6. 异常与临界值 // ══════════════════════════════════════ test.describe('异常与临界值', () => { let pid = ''; let token = ''; test.beforeAll(async ({ request }) => { token = await apiLogin(request); pid = await apiCreateProject(request, token, `异常-${UNIQUE}`, '赛道一'); await apiCreateStandard(request, token, pid, '## 代码质量(100分)\n质量'); }); test.afterAll(async ({ request }) => { await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); }); test('6.1 缺 repo_url 创建条目被拒(400)', async ({ request }) => { const r = await request.fetch(`${BASE}/projects/${pid}/entries`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { title: '无仓库' }, }); expect(r.status()).toBe(400); }); test('6.2 非法服务地址被拒(localhost 内网)', async ({ request }) => { const r = await request.fetch(`${BASE}/projects/${pid}/entries`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { title: '非法地址', repo_url: `https://svc-${UNIQUE}.git`, service_url: 'http://localhost:3000' }, }); expect(r.status()).toBe(400); }); test('6.3 相同 repo_url 重复创建被拒(唯一约束)', async ({ request }) => { const dupUrl = `https://dup-${UNIQUE}.git`; await apiCreateEntry(request, token, pid, '首建', { repo_url: dupUrl }); const r = await request.fetch(`${BASE}/projects/${pid}/entries`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, data: { title: '重复', repo_url: dupUrl }, }); expect(r.status()).toBe(409); }); test('6.4 未评审条目启动后被系统处理(不崩溃)', async ({ page, request }) => { const entry = await apiCreateEntry(request, token, pid, '启动条目'); const r = await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/start`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }); expect(r.status()).toBe(200); await login(page); await page.goto(`/project/${pid}`); await page.click('.tab:has-text("条目")'); await expect(page.locator('.entry-table')).toContainText('启动条目'); }); });