Files
L2keka/web/e2e/hardcode-fixes.spec.ts
hangshuo652 6a2419d4a4 L2考核落地:人才测评整合为L2考核track,新增查重初筛与e2e验证
- 新增 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 项全绿
2026-08-23 20:58:56 +08:00

456 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 维合计 100L2 合格线 = 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 = '## A100分)\n说明\n文件关键词: a\n## B60分)\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('启动条目');
});
});