Files
L2keka/web/e2e/hardcode-fixes.spec.ts
T

454 lines
20 KiB
TypeScript
Raw 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. question_id 分组过滤(快照只含 common + 所选 Qn
// ══════════════════════════════════════════════════════════════════
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 人才测评自动生成含「功能完整性」标准', 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);
const dims = standards[0].dimensions;
expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 40)).toBe(true);
// 共通维度合计 100(L2 及格线 = 60)
const commonTotal = dims.filter((d: any) => (d.group || 'common') === 'common').reduce((s: number, d: any) => s + d.maxScore, 0);
expect(commonTotal).toBe(100);
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. 人才测评:question_id 分组过滤 + 功能完整性
// ══════════════════════════════════════
test.describe('人才测评 question_id 分组', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `人才Q-${UNIQUE}`, '人才测评');
});
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://noq-${UNIQUE}.git` },
});
expect(r.status()).toBe(400);
});
test('4.2 Q2 条目快照包含 common 与 [Q2] 维度(完整标准,评审时按题目过滤)', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q2选手', { question_id: 'Q2' });
const dims = JSON.parse(entry.standard_snapshot);
const groups = new Set(dims.map((d: any) => d.group || 'common'));
expect(groups.has('common')).toBe(true);
expect(groups.has('Q2')).toBe(true);
// 快照保留完整标准(含其他题目维度),评审时按 question_id 过滤
expect(groups.has('Q3')).toBe(true);
// 功能完整性在 common 组且是构建封顶维度
const fnDim = dims.find((d: any) => d.name === '功能完整性');
expect(fnDim).toBeDefined();
expect(fnDim.maxScore).toBe(40);
});
test('4.3 pass_line = 共通维度 60% = 60', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q1选手', { question_id: 'Q1' });
expect(entry.pass_line).toBe(60);
});
});
// ══════════════════════════════════════
// 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('启动条目');
});
});