795 lines
33 KiB
TypeScript
795 lines
33 KiB
TypeScript
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
|
||
|
||
const PASSWORD = 'test123';
|
||
const UNIQUE = Date.now().toString(36);
|
||
const BASE = 'http://localhost:3002/api';
|
||
let apiCtx: APIRequestContext;
|
||
|
||
// 赛道必选后项目创建自动带入赛道默认标准;若测试只用自定义标准,需先清空自动标准,
|
||
// 让条目创建时 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}` },
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── Helper: login via UI, then create API context ───
|
||
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('/');
|
||
}
|
||
|
||
// ─── Helper: api call via page.request ───
|
||
async function api(page: Page, method: string, path: string, body?: any) {
|
||
const res = await page.request.fetch(`http://localhost:3002${path}`, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
data: body,
|
||
});
|
||
return { status: res.status(), data: await res.json() };
|
||
}
|
||
|
||
// ══════════════════════════════════════
|
||
// 1. 认证测试
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('认证', () => {
|
||
test('1.1 未登录跳转到登录页', async ({ page }) => {
|
||
await page.goto('/');
|
||
await page.waitForURL('/login');
|
||
expect(page.url()).toContain('/login');
|
||
});
|
||
|
||
test('1.2 空密码按钮禁用', async ({ page }) => {
|
||
await page.goto('/login');
|
||
await expect(page.locator('button[type="submit"]')).toBeDisabled();
|
||
});
|
||
|
||
test('1.3 错误密码显示错误', async ({ page }) => {
|
||
await page.goto('/login');
|
||
await page.fill('input[type="password"]', 'wrongpassword');
|
||
await page.click('button[type="submit"]');
|
||
await expect(page.locator('.error')).toContainText('密码错误');
|
||
});
|
||
|
||
test('1.4 正确密码登录成功', async ({ page }) => {
|
||
await login(page);
|
||
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
|
||
});
|
||
|
||
test('1.5 token 持久化(刷新后仍登录)', async ({ page }) => {
|
||
await login(page);
|
||
await page.reload();
|
||
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
|
||
});
|
||
|
||
test('1.6 退出登录', async ({ page }) => {
|
||
await login(page);
|
||
await page.click('.sidebar-header button:has-text("退出")');
|
||
await page.waitForURL('/login');
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 2. 侧边栏 & 项目
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('侧边栏 & 项目', () => {
|
||
test.beforeEach(async ({ page }) => { await login(page); });
|
||
|
||
test('2.1 空项目列表显示"新建项目"按钮', async ({ page }) => {
|
||
await expect(page.locator('.btn-new-project')).toHaveText('+ 新建项目');
|
||
});
|
||
|
||
test('2.2 取消创建项目', async ({ page }) => {
|
||
await page.click('.btn-new-project');
|
||
await expect(page.locator('.new-project-form input')).toBeVisible();
|
||
await page.click('.new-project-actions button:has-text("取消")');
|
||
await expect(page.locator('.btn-new-project')).toBeVisible();
|
||
});
|
||
|
||
test('2.3 创建项目', async ({ page }) => {
|
||
await page.click('.btn-new-project');
|
||
await page.fill('.new-project-form input', `测试项目-${UNIQUE}`);
|
||
await page.selectOption('.new-project-form select', '赛道一');
|
||
await page.click('.new-project-actions button:first-child');
|
||
await page.waitForURL(/\/project\//);
|
||
await expect(page.locator('.project-view h2')).toContainText(`测试项目-${UNIQUE}`);
|
||
});
|
||
|
||
test('2.4 项目统计显示', async ({ page }) => {
|
||
await page.goto('/');
|
||
const first = page.locator('.project-stats').first();
|
||
await expect(first).toBeVisible();
|
||
});
|
||
|
||
test('2.5 项目激活高亮', async ({ page }) => {
|
||
await page.click('.project-item:first-child');
|
||
await expect(page.locator('.project-item.active')).toBeVisible();
|
||
});
|
||
|
||
test('2.6 仪表盘显示', async ({ page }) => {
|
||
await page.goto('/');
|
||
await expect(page.locator('.dashboard h1')).toContainText('仪表盘');
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 3. 评审标准
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('评审标准', () => {
|
||
let pid = '';
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
data: { name: `标准测试-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
expect(pr.status()).toBe(200);
|
||
pid = (await pr.json()).id;
|
||
await apiClearStandards(request, token, pid);
|
||
});
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("标准")');
|
||
});
|
||
|
||
test('3.1 空标准显示提示', async ({ page }) => {
|
||
await expect(page.locator('.empty')).toContainText('暂无评审标准');
|
||
});
|
||
|
||
test('3.2 上传标准 - 显示表单', async ({ page }) => {
|
||
await page.click('.section-header button:has-text("上传标准")');
|
||
await expect(page.locator('.standard-form')).toBeVisible();
|
||
});
|
||
|
||
test('3.3 上传标准 - 无效格式', async ({ page }) => {
|
||
await page.click('.section-header button:has-text("上传标准")');
|
||
await page.fill('.standard-form input:first-child', '无效标准');
|
||
await page.fill('.standard-form textarea', '没有格式的普通文本');
|
||
await page.click('.form-actions button:has-text("保存")');
|
||
await expect(page.locator('.standard-form')).toBeVisible();
|
||
});
|
||
|
||
test('3.4 上传标准 - 总分超过上限(150)被拒', async ({ page }) => {
|
||
await page.click('.section-header button:has-text("上传标准")');
|
||
await page.fill('.standard-form input:first-child', '超分标准');
|
||
await page.fill('.standard-form textarea', '## 维度一(100分)\n内容\n## 维度二(60分)\n内容');
|
||
await page.click('.form-actions button:has-text("保存")');
|
||
// 被拒绝 → 表单保持打开
|
||
await expect(page.locator('.standard-form')).toBeVisible();
|
||
});
|
||
|
||
test('3.5 上传标准 - 正常创建(含分类标签)', async ({ page }) => {
|
||
await page.click('.section-header button:has-text("上传标准")');
|
||
await page.fill('.standard-form input:first-child', '算法标准');
|
||
await page.fill('.standard-form input:nth-child(2)', '算法');
|
||
await page.fill('.standard-form textarea',
|
||
'## 代码质量(30分)\n代码整洁度\n## 架构设计(40分)\n模块化\n## 测试覆盖(30分)\n覆盖率');
|
||
await page.click('.form-actions button:has-text("保存")');
|
||
await expect(page.locator('.standard-card')).toHaveCount(1);
|
||
await expect(page.locator('.standard-card')).toContainText('算法标准');
|
||
await expect(page.locator('.standard-card')).toContainText('算法');
|
||
await expect(page.locator('.standard-card')).toContainText('代码质量(30分)');
|
||
await expect(page.locator('.standard-card')).toContainText('架构设计(40分)');
|
||
});
|
||
|
||
test('3.6 标准数量显示', async ({ page }) => {
|
||
await expect(page.locator('.section-header h3')).toContainText('评审标准 (1)');
|
||
});
|
||
|
||
test('3.7 删除标准', async ({ page }) => {
|
||
page.on('dialog', d => d.accept());
|
||
await page.click('.btn-danger');
|
||
await expect(page.locator('.empty')).toContainText('暂无评审标准');
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 4. 条目管理
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('条目管理', () => {
|
||
let pid = '';
|
||
const entryTitle = `选手A-${UNIQUE}`;
|
||
const repoUrl = `https://example.com/${UNIQUE}.git`;
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST', headers: auth, data: { name: `条目测试-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
pid = (await pr.json()).id;
|
||
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
|
||
method: 'POST', headers: auth,
|
||
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
|
||
});
|
||
});
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("条目")');
|
||
});
|
||
|
||
test('4.1 空条目显示提示', async ({ page }) => {
|
||
await expect(page.locator('.empty-row')).toContainText('暂无条目');
|
||
});
|
||
|
||
test('4.2 筛选器存在', async ({ page }) => {
|
||
await expect(page.locator('.filter-select')).toBeVisible();
|
||
await expect(page.locator('.search-input')).toBeVisible();
|
||
});
|
||
|
||
test('4.3 全部筛选选项', async ({ page }) => {
|
||
const opts = await page.locator('.filter-select option').allTextContents();
|
||
expect(opts).toContain('全部');
|
||
expect(opts).toContain('待评审');
|
||
expect(opts).toContain('排队中');
|
||
expect(opts).toContain('克隆中');
|
||
expect(opts).toContain('分析中');
|
||
expect(opts).toContain('已完成');
|
||
expect(opts).toContain('已修正');
|
||
expect(opts).toContain('克隆失败');
|
||
expect(opts).toContain('分析失败');
|
||
expect(opts).toContain('失败');
|
||
});
|
||
|
||
test('4.4 通过API创建条目 → 页面可见', async ({ page, request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
data: { title: entryTitle, repo_url: repoUrl, participant: '张三', difficulty: '★★★' },
|
||
});
|
||
await page.reload();
|
||
await expect(page.locator('.entry-table')).toContainText(entryTitle);
|
||
});
|
||
|
||
test('4.5 条目列表显示字段', async ({ page }) => {
|
||
await expect(page.locator('.entry-table')).toContainText('张三');
|
||
await expect(page.locator('.entry-table')).toContainText('待评审');
|
||
await expect(page.locator('.entry-table')).toContainText('-');
|
||
});
|
||
|
||
test('4.6 "启动"按钮存在', async ({ page }) => {
|
||
await expect(page.locator('.btn-action:has-text("启动")')).toBeVisible();
|
||
});
|
||
|
||
test('4.7 启动条目', async ({ page }) => {
|
||
await page.click('.entry-table .btn-action:has-text("启动")');
|
||
await expect(page.locator('.entry-table .badge').first()).toBeVisible();
|
||
});
|
||
|
||
test('4.8 取消评审', async ({ page, request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||
|
||
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
|
||
method: 'POST', headers: auth,
|
||
data: { title: `取消测试-${UNIQUE}`, repo_url: `https://cancel-${UNIQUE}.git` },
|
||
});
|
||
const eid = (await er.json()).id;
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
|
||
method: 'POST', headers: auth,
|
||
});
|
||
|
||
await page.reload();
|
||
await expect(page.locator('.entry-table')).toContainText('取消测试');
|
||
await expect(page.locator('.entry-table .badge').first()).toBeVisible();
|
||
});
|
||
|
||
test('4.9 搜索功能', async ({ page }) => {
|
||
await page.fill('.search-input', '选手A');
|
||
await page.waitForTimeout(500);
|
||
await page.press('.search-input', 'Enter');
|
||
await expect(page.locator('.entry-table')).toContainText('选手A', { timeout: 10000 });
|
||
});
|
||
|
||
test('4.10 状态筛选', async ({ page }) => {
|
||
await page.selectOption('.filter-select', 'pending');
|
||
await page.waitForTimeout(500);
|
||
const badges = await page.locator('.entry-table .badge').allTextContents();
|
||
const statusTexts = badges.filter(b => !['赛道一', '新規', '修正'].includes(b.trim()));
|
||
for (const b of statusTexts) {
|
||
expect(b.trim()).toBe('待评审');
|
||
}
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 5. 批量导入
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('批量导入', () => {
|
||
let pid = '';
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST', headers: auth, data: { name: `导入测试-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
pid = (await pr.json()).id;
|
||
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
|
||
method: 'POST', headers: auth,
|
||
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
|
||
});
|
||
});
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("条目")');
|
||
});
|
||
|
||
test('5.1 打开导入面板', async ({ page }) => {
|
||
await page.click('.btn-secondary:has-text("导入")');
|
||
await expect(page.locator('.import-panel')).toBeVisible();
|
||
});
|
||
|
||
test('5.2 批量导入 CSV 格式', async ({ page }) => {
|
||
await page.click('.btn-secondary:has-text("导入")');
|
||
const csv = `title,repo_url,participant,difficulty
|
||
小明作品,https://xiaoming-${UNIQUE}.git,小明,★★
|
||
小红作品,https://xiaohong-${UNIQUE}.git,小红,★★★`;
|
||
await page.fill('.import-panel textarea', csv);
|
||
await page.click('.import-panel .form-actions button:first-child');
|
||
await expect(page.locator('.import-result')).toContainText('成功 2 条');
|
||
});
|
||
|
||
test('5.3 导入结果显示错误行', async ({ page }) => {
|
||
await page.click('.btn-secondary:has-text("导入")');
|
||
const csv = `title,repo_url
|
||
,https://empty-title.git
|
||
有效,https://valid-${UNIQUE}.git`;
|
||
await page.fill('.import-panel textarea', csv);
|
||
await page.click('.import-panel .form-actions button:first-child');
|
||
await expect(page.locator('.import-result')).toContainText('成功 1 条');
|
||
await expect(page.locator('.import-result')).toContainText('失败 1 条');
|
||
await expect(page.locator('.error-row')).toContainText('标题为空');
|
||
});
|
||
|
||
test('5.4 表格显示导入的条目', async ({ page }) => {
|
||
await page.waitForTimeout(500);
|
||
await expect(page.locator('.section-header h3')).not.toContainText('(0)');
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 6. 详情面板
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('详情面板', () => {
|
||
let pid = '';
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST', headers: auth, data: { name: `详情测试-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
pid = (await pr.json()).id;
|
||
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
|
||
method: 'POST', headers: auth,
|
||
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
|
||
});
|
||
|
||
// Create an entry and mark it as review_done via report
|
||
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
|
||
method: 'POST', headers: auth,
|
||
data: { title: `详情条目-${UNIQUE}`, repo_url: `https://detail-${UNIQUE}.git` },
|
||
});
|
||
const eid = (await er.json()).id;
|
||
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
|
||
method: 'PUT', headers: auth,
|
||
data: { dimensions: [
|
||
{ name: '代码质量', score: 40, maxScore: 50, comment: '代码整洁' },
|
||
{ name: '架构设计', score: 35, maxScore: 50, comment: '合理' },
|
||
]},
|
||
});
|
||
});
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("条目")');
|
||
});
|
||
|
||
test('6.1 点击标题打开详情', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
await expect(page.locator('.detail-panel')).toBeVisible();
|
||
await expect(page.locator('.detail-panel h3')).toContainText(`详情条目-${UNIQUE}`);
|
||
});
|
||
|
||
test('6.2 详情面板显示元信息', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
await expect(page.locator('.detail-meta')).toContainText(`https://detail-${UNIQUE}.git`);
|
||
});
|
||
|
||
test('6.3 详情面板显示维度', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
await expect(page.locator('.detail-dims')).toBeVisible();
|
||
const rows = await page.locator('.detail-dims tbody tr').count();
|
||
expect(rows).toBe(2);
|
||
});
|
||
|
||
test('6.4 评分可编辑', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
const input = page.locator('.score-input').first();
|
||
await input.fill('45');
|
||
await expect(input).toHaveValue('45');
|
||
});
|
||
|
||
test('6.5 评语可编辑', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
// 评语为点击后进入编辑态(textarea.comment-input)
|
||
await page.locator('.suggestion-cell').first().click();
|
||
const comment = page.locator('.comment-input').first();
|
||
await comment.fill('修改后评语');
|
||
await expect(comment).toHaveValue('修改后评语');
|
||
});
|
||
|
||
test('6.6 保存修正按钮存在', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
await expect(page.locator('.btn-primary:has-text("保存修正")')).toBeVisible();
|
||
});
|
||
|
||
test('6.7 关闭详情面板', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
await expect(page.locator('.detail-panel')).toBeVisible();
|
||
await page.click('.btn-close');
|
||
await expect(page.locator('.detail-panel')).not.toBeVisible();
|
||
});
|
||
|
||
test('6.8 点击遮罩关闭详情', async ({ page }) => {
|
||
await page.click('.title-cell');
|
||
await page.waitForTimeout(300);
|
||
await page.click('.detail-overlay', { position: { x: 10, y: 10 } });
|
||
await expect(page.locator('.detail-panel')).not.toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 7. 汇总视图
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('汇总视图', () => {
|
||
let pid = '';
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST', headers: auth, data: { name: `汇总测试-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
pid = (await pr.json()).id;
|
||
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
|
||
method: 'POST', headers: auth,
|
||
data: { name: '标准1', category_tag: '赛道一',
|
||
content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
|
||
});
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
|
||
method: 'POST', headers: auth,
|
||
data: { title: `选手${i}-${UNIQUE}`, repo_url: `https://p${i}-${UNIQUE}.git`,
|
||
participant: `参赛者${i}`, category_tag: '赛道一', difficulty: i === 0 ? '★★' : '★★★' },
|
||
});
|
||
const eid = (await er.json()).id;
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
|
||
method: 'PUT', headers: auth,
|
||
data: { dimensions: [
|
||
{ name: '代码质量', score: 40 + i * 5, maxScore: 50, comment: 'ok' },
|
||
{ name: '架构设计', score: 30 + i * 5, maxScore: 50, comment: 'ok' },
|
||
]},
|
||
});
|
||
}
|
||
});
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("汇总")');
|
||
});
|
||
|
||
test('7.1 汇总页面显示标题', async ({ page }) => {
|
||
await expect(page.locator('h3')).toContainText('汇总排名');
|
||
});
|
||
|
||
test('7.2 按分类显示排名', async ({ page }) => {
|
||
await expect(page.locator('.summary-section')).toBeVisible();
|
||
await expect(page.locator('.summary-section h4')).toContainText('赛道一');
|
||
});
|
||
|
||
test('7.3 排名表格中显示选手', async ({ page }) => {
|
||
await expect(page.locator('.summary-section')).toContainText('参赛者0');
|
||
await expect(page.locator('.summary-section')).toContainText('参赛者1');
|
||
await expect(page.locator('.summary-section')).toContainText('参赛者2');
|
||
});
|
||
|
||
test('7.4 排名按分数降序', async ({ page }) => {
|
||
const scores = await page.locator('.summary-section .entry-table td:nth-child(4)').allTextContents();
|
||
const nums = scores.map(s => parseInt(s));
|
||
for (let i = 1; i < nums.length; i++) {
|
||
expect(nums[i]).toBeLessThanOrEqual(nums[i - 1]);
|
||
}
|
||
});
|
||
|
||
test('7.5 排名序号 #1 #2 #3', async ({ page }) => {
|
||
const ranks = await page.locator('.rank').allTextContents();
|
||
expect(ranks).toEqual(['#1', '#2', '#3']);
|
||
});
|
||
|
||
test('7.6 参赛者合格判定区域', async ({ page }) => {
|
||
await expect(page.locator('h4:has-text("参赛者合格判定")')).toBeVisible();
|
||
});
|
||
|
||
test('7.7 无参赛者时显示空状态', async ({ page, request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
data: { name: `空汇总-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
const emptyPid = (await pr.json()).id;
|
||
|
||
await login(page);
|
||
await page.goto(`/project/${emptyPid}`);
|
||
await page.click('.tab:has-text("汇总")');
|
||
await expect(page.locator('h3')).toContainText('汇总排名');
|
||
await expect(page.locator('.summary-section')).toHaveCount(0);
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 8. 异常与临界值测试
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('异常与临界值', () => {
|
||
let pid = '';
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST', headers: auth, data: { name: `异常测试-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
pid = (await pr.json()).id;
|
||
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
|
||
method: 'POST', headers: auth,
|
||
data: { name: '标准', content: '## 代码质量(100分)\n质量' },
|
||
});
|
||
});
|
||
|
||
test('8.1 导航到不存在的项目', async ({ page }) => {
|
||
await login(page);
|
||
// navigating to non-existent project should show error or fallback
|
||
await page.goto('/project/non-existent-id');
|
||
// Should show something that indicates error or at least not crash
|
||
const body = await page.locator('.project-view').textContent();
|
||
expect(body).toBeTruthy();
|
||
});
|
||
|
||
test('8.2 空标题创建条目被阻止(通过API验证)', async ({ page, request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
data: { repo_url: 'https://no-title.git' },
|
||
});
|
||
expect(er.status()).toBe(400);
|
||
});
|
||
|
||
test('8.3 删除标准前确认弹窗', async ({ page, request }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("标准")');
|
||
|
||
// Create a standard to delete
|
||
await page.click('.section-header button:has-text("上传标准")');
|
||
await page.fill('.standard-form input:first-child', '待删除标准');
|
||
await page.fill('.standard-form textarea', '## 维度(100分)\n内容');
|
||
await page.click('.form-actions button:has-text("保存")');
|
||
await page.waitForTimeout(300);
|
||
|
||
let dialogSeen = false;
|
||
page.on('dialog', d => { dialogSeen = true; d.accept(); });
|
||
await page.locator('.btn-danger').first().click();
|
||
await page.waitForTimeout(300);
|
||
expect(dialogSeen).toBe(true);
|
||
});
|
||
|
||
test('8.4 导入空CSV', async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("条目")');
|
||
await page.click('.btn-secondary:has-text("导入")');
|
||
await page.fill('.import-panel textarea', '');
|
||
await page.click('.import-panel .form-actions button:first-child');
|
||
await expect(page.locator('.import-panel')).toBeVisible();
|
||
});
|
||
|
||
test('8.5 同一个条目无法两次启动', async ({ page, request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||
|
||
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
|
||
method: 'POST', headers: auth,
|
||
data: { title: `双启动-${UNIQUE}`, repo_url: `https://dual-${UNIQUE}.git` },
|
||
});
|
||
const eid = (await er.json()).id;
|
||
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
|
||
method: 'POST', headers: auth,
|
||
});
|
||
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("条目")');
|
||
await page.reload();
|
||
await expect(page.locator('.btn-action').first()).toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 9. UI 一致性
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('UI 一致性', () => {
|
||
let pid = '';
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const r = await request.post('http://localhost:3002/api/auth/login', {
|
||
data: { password: PASSWORD },
|
||
});
|
||
const { token } = await r.json();
|
||
const pr = await request.fetch('http://localhost:3002/api/projects', {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
data: { name: `UI测试-${UNIQUE}`, track: '赛道一' },
|
||
});
|
||
pid = (await pr.json()).id;
|
||
});
|
||
|
||
test('9.1 四个Tab都存在', async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await expect(page.locator('.tab')).toHaveText(['标准', '条目', '成果物', '汇总']);
|
||
});
|
||
|
||
test('9.2 Tab切换工作', async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
await page.click('.tab:has-text("标准")');
|
||
await expect(page.locator('.tab.active')).toContainText('标准');
|
||
await page.click('.tab:has-text("条目")');
|
||
await expect(page.locator('.tab.active')).toContainText('条目');
|
||
await page.click('.tab:has-text("汇总")');
|
||
await expect(page.locator('.tab.active')).toContainText('汇总');
|
||
});
|
||
|
||
test('9.3 项目统计显示所有项', async ({ page }) => {
|
||
await login(page);
|
||
await page.goto(`/project/${pid}`);
|
||
const meta = await page.locator('.project-meta').textContent();
|
||
expect(meta).toContain('总计');
|
||
expect(meta).toContain('✓');
|
||
expect(meta).toContain('▶');
|
||
expect(meta).toContain('✕');
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════
|
||
// 10. 完整用户流程
|
||
// ══════════════════════════════════════
|
||
|
||
test.describe('完整用户流程', () => {
|
||
test('完整流程:创建项目→标准→条目→查看汇总', async ({ page }) => {
|
||
await login(page);
|
||
|
||
// Step 1: Create project
|
||
await page.click('.btn-new-project');
|
||
await page.fill('.new-project-form input', `全流程-${UNIQUE}`);
|
||
await page.selectOption('.new-project-form select', '赛道一');
|
||
await page.click('.new-project-actions button:first-child');
|
||
await page.waitForURL(/\/project\//);
|
||
|
||
// 清掉赛道自动标准,保证下方断言 `.standard-card` 唯一(上传的「全流程标准」)
|
||
const pid10 = page.url().split('/project/')[1];
|
||
const loginR = await page.request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
|
||
const { token: tok10 } = await loginR.json();
|
||
const listS = await page.request.get(`http://localhost:3002/api/projects/${pid10}/standards`, { headers: { Authorization: `Bearer ${tok10}` } });
|
||
const stds10 = await listS.json();
|
||
for (const s of stds10) {
|
||
await page.request.delete(`http://localhost:3002/api/projects/${pid10}/standards/${s.id}`, { headers: { Authorization: `Bearer ${tok10}` } });
|
||
}
|
||
|
||
// Step 2: Switch to standards tab, create standard
|
||
await page.click('.tab:has-text("标准")');
|
||
await page.click('.section-header button:has-text("上传标准")');
|
||
await page.fill('.standard-form input:first-child', '全流程标准');
|
||
await page.fill('.standard-form textarea',
|
||
'## 功能完整性(40分)\n功能完整\n## 代码质量(30分)\n代码整洁\n## 文档(30分)\n文档齐全');
|
||
await page.click('.form-actions button:has-text("保存")');
|
||
await expect(page.locator('.standard-card')).toContainText('全流程标准');
|
||
|
||
// Step 3: Switch to entries tab, batch import
|
||
await page.click('.tab:has-text("条目")');
|
||
await page.click('.btn-secondary:has-text("导入")');
|
||
const csv = `title,repo_url,participant,difficulty
|
||
选手1,https://p1-${UNIQUE}.git,张三,★★
|
||
选手2,https://p2-${UNIQUE}.git,李四,★★★`;
|
||
await page.fill('.import-panel textarea', csv);
|
||
await page.click('.import-panel .form-actions button:first-child');
|
||
await expect(page.locator('.import-result')).toContainText('成功 2 条');
|
||
|
||
// Step 4: Verify entries visible
|
||
await page.waitForTimeout(500);
|
||
await expect(page.locator('.section-header h3')).toContainText('(2)');
|
||
|
||
// Step 5: Switch to summary
|
||
await page.click('.tab:has-text("汇总")');
|
||
// Since entries haven't been reviewed, summary should show categories
|
||
await page.waitForTimeout(500);
|
||
const body = await page.locator('.tab-content').textContent();
|
||
expect(body).toBeTruthy();
|
||
});
|
||
});
|