- 新增 L2考核 track(7维标准/100分):选题难度赋分cap、功能完整性地板线、合格判定 - 人才测评整合进 L2考核:移除 L2/L3 两级认定与 question_id 机制 - 新增 l2-topics 选题元数据服务与 config/l2-topics.json(11命题题+自选题) - 新增查重初筛 plagiarism-detect(MD5精确比对+归一化相似度+提交行为,仅告警) - 修复 L2 维度 DIM_FILE_FILTERS 缺失导致 AI 协作记录证据漏喂 - e2e:修复 Windows spawn、DB 隔离,L2 用例 27 项全绿
456 lines
21 KiB
TypeScript
456 lines
21 KiB
TypeScript
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<string> {
|
||
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<string> {
|
||
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<string> {
|
||
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<void> {
|
||
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<any> {
|
||
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('启动条目');
|
||
});
|
||
});
|