test: update and add E2E, integration, visual, and webapp tests
Some checks failed
CI / scheduled-backup (push) Failing after 36s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

- Update E2E tests: announcements, auth, auth-business-flow, full-route-regression, grades, navigation, smoke-auth, teacher-web-test

- Update integration tests: api-ai-chat, api-onboarding-complete, api-onboarding-status, proxy-guard, integration setup

- Update visual regression tests: admin-dashboard, homepage, student-dashboard, teacher-dashboard, visual config, helpers

- Update webapp tests: admin, parent, student full tests and debug scripts

- Add new webapp tests: announcements_messages, settings_profile, debug scripts

- Add webtest directory with test plans, screenshots, and diagnostic scripts
This commit is contained in:
SpecialX
2026-06-23 17:39:40 +08:00
parent f40ce0f560
commit d884c6d513
183 changed files with 19006 additions and 0 deletions

805
webtest/elective_test.py Normal file
View File

@@ -0,0 +1,805 @@
"""
选修课模块全功能 Web 测试脚本
使用 Playwright 对所有角色的选修课页面与核心交互进行功能测试
覆盖admin / teacher / student 三种角色parent 无选修课权限)
结果输出webtest/elective_0.1.0.md 与 webtest/elective_0.1.0.json
"""
import json
import re
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
VERSION = "0.1.0"
# 测试账号(来自 scripts/seed.ts
TEST_ACCOUNTS = {
"admin": {"email": "admin@xiaoxue.edu.cn", "password": "123456", "expected_path": "/admin/dashboard"},
"teacher": {"email": "t_chinese_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/teacher/dashboard"},
"student": {"email": "student_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/student/dashboard"},
"parent": {"email": "parent_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/parent/dashboard"},
}
# 各角色选修课页面路由(基于 src/modules/layout/config/navigation.ts 与源码目录)
ELECTIVE_ROUTES = {
"admin": [
{"route": "/admin/elective", "category": "选修课程列表", "expected_keys": ["选修课程"]},
{"route": "/admin/elective/create", "category": "创建选修课程", "expected_keys": ["创建课程"]},
],
"teacher": [
{"route": "/teacher/elective", "category": "我的选修课", "expected_keys": ["我的选修课"]},
],
"student": [
{"route": "/student/elective", "category": "选课中心", "expected_keys": ["选课中心"]},
],
}
# 跨角色访问保护测试:每个角色不应能访问其他角色的选修课页
# 注意admin 拥有所有权限,可以访问所有路由,因此不测试 admin 的跨角色访问
CROSS_ROLE_FORBIDDEN = {
"teacher": ["/admin/elective", "/student/elective"],
"student": ["/admin/elective", "/teacher/elective"],
"parent": ["/admin/elective", "/teacher/elective", "/student/elective"],
}
PROJECT_ROOT = Path(__file__).resolve().parents[1]
WEBTEST_DIR = PROJECT_ROOT / "webtest"
SCREENSHOT_DIR = WEBTEST_DIR / "screenshots" / "elective"
WEBTEST_DIR.mkdir(parents=True, exist_ok=True)
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"module": "选修课 (Elective)",
"version": VERSION,
"base_url": BASE_URL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"roles": {},
"cross_role_tests": [],
"interactions": [],
"console_errors_global": [],
}
def _is_login_redirect(url: str) -> bool:
"""精确判断 URL 是否为登录页重定向"""
parsed = urlparse(url)
path = parsed.path.rstrip("/")
if path.endswith("/login"):
return True
query = parse_qs(parsed.query)
callback = query.get("callbackUrl", [""])[0]
if callback and "/login" in callback:
return True
return False
def login(page, role: str) -> bool:
"""登录指定角色账号"""
account = TEST_ACCOUNTS[role]
print(f"\n>>> 登录 {role} 账号 ({account['email']})...")
# 先清除 cookies 确保干净状态
page.context.clear_cookies()
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
current_path = urlparse(page.url).path
if current_path.startswith(account["expected_path"]) or current_path.endswith("/dashboard"):
print(f" 已登录,跳过登录步骤 (URL: {page.url})")
return True
try:
# 填写邮箱
email_input = page.locator('input[name="email"]')
if email_input.count() == 0:
email_input = page.locator('input[type="email"]')
email_input.fill(account["email"])
# 填写密码
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
password_input.fill(account["password"])
# 点击登录按钮 - shadcn Button 不显式设置 type="submit"
# 按钮文本是 "Sign In with Email"
login_btn = page.get_by_role("button", name="Sign In with Email")
if login_btn.count() == 0:
login_btn = page.locator("form button").filter(has_text="Sign In")
if login_btn.count() == 0:
# 最后兜底:表单内第一个 button
login_btn = page.locator("form button").first
print(f" 找到登录按钮: {login_btn.count()}")
login_btn.first.click()
# 登录表单使用 signIn(redirect: false) + router.push
# 需要等待 URL 变化,而不只是 networkidle
try:
page.wait_for_url(lambda url: "/login" not in url, timeout=20000)
except PlaywrightTimeout:
# 如果 URL 没变,可能登录失败
pass
page.wait_for_timeout(1500)
print(f" 登录后 URL: {page.url}")
if "/login" in page.url:
# 重试一次
print(f" ⚠️ 首次登录失败,重试...")
page.context.clear_cookies()
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
email_input = page.locator('input[name="email"]')
if email_input.count() == 0:
email_input = page.locator('input[type="email"]')
email_input.fill(account["email"])
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
password_input.fill(account["password"])
login_btn = page.get_by_role("button", name="Sign In with Email")
if login_btn.count() == 0:
login_btn = page.locator("form button").filter(has_text="Sign In")
if login_btn.count() == 0:
login_btn = page.locator("form button").first
login_btn.first.click()
try:
page.wait_for_url(lambda url: "/login" not in url, timeout=20000)
except PlaywrightTimeout:
pass
page.wait_for_timeout(1500)
print(f" 重试后 URL: {page.url}")
if "/login" in page.url:
print(f"{role} 登录失败")
return False
return True
except Exception as e:
print(f"{role} 登录异常: {e}")
return False
def logout(page):
"""退出当前登录状态"""
page.context.clear_cookies()
print(" 已清除 cookies 退出登录")
def collect_console_errors(page):
"""附加控制台错误收集器"""
errors = []
def on_console(msg):
if msg.type == "error":
text = msg.text
if "favicon" in text.lower() or "Download the React DevTools" in text:
return
errors.append(text)
page.on("console", on_console)
return errors, on_console
def test_role_elective(page, role: str) -> dict:
"""测试单个角色的选修课页面"""
routes = ELECTIVE_ROUTES.get(role, [])
role_result = {
"role": role,
"login_success": False,
"pages": [],
"interactions": [],
"errors": [],
"warnings": [],
}
print(f"\n=== 测试 {role} 选修课模块 ===")
if not login(page, role):
role_result["errors"].append(f"{role} 登录失败")
return role_result
role_result["login_success"] = True
console_errors, on_console = collect_console_errors(page)
# 测试每个页面
for route_info in routes:
route = route_info["route"]
category = route_info["category"]
expected_keys = route_info["expected_keys"]
url = f"{BASE_URL}{route}"
page_result = {
"url": url,
"route": route,
"category": category,
"status": "unknown",
"http_status": None,
"final_url": None,
"checks": [],
"errors": [],
"warnings": [],
"console_errors": [],
"screenshot": None,
}
print(f"\n 测试: {category} - {route}")
try:
response = page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
http_status = response.status if response else None
final_url = page.url
page_result["http_status"] = http_status
page_result["final_url"] = final_url
# 截图
safe_name = re.sub(r"[^\w\-]", "_", route.strip("/"))
shot_path = SCREENSHOT_DIR / f"{role}_{safe_name}.png"
try:
page.screenshot(path=str(shot_path), full_page=True)
page_result["screenshot"] = str(shot_path.relative_to(PROJECT_ROOT))
except Exception as e:
page_result["warnings"].append(f"截图失败: {e}")
body_text = page.locator("body").text_content() or ""
# HTTP 状态检查
if http_status and http_status >= 500:
page_result["status"] = "failed"
page_result["errors"].append(f"HTTP {http_status} 服务器错误")
elif http_status and http_status >= 400:
page_result["status"] = "failed"
page_result["errors"].append(f"HTTP {http_status} 客户端错误")
elif _is_login_redirect(final_url):
page_result["status"] = "failed"
page_result["errors"].append("重定向到登录页 - 认证失败")
elif urlparse(final_url).path != route:
page_result["status"] = "warning"
page_result["warnings"].append(f"重定向到 {final_url}(预期 {route}")
elif len(body_text.strip()) < 50:
page_result["status"] = "warning"
page_result["warnings"].append(f"页面内容过少({len(body_text.strip())} 字符)")
else:
page_result["status"] = "passed"
# 检查页面标题关键词
title_matched = any(key in body_text for key in expected_keys)
page_result["checks"].append({
"name": f"标题关键词匹配 ({'/'.join(expected_keys)})",
"passed": title_matched,
"detail": f"实际匹配: {title_matched}"
})
if not title_matched:
page_result["warnings"].append(f"未找到标题关键词: {expected_keys}")
# 检查页面错误提示
error_alerts = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600').all()
for alert in error_alerts[:3]:
try:
text = (alert.text_content() or "").strip()[:150]
if text:
page_result["warnings"].append(f"页面告警文本: {text}")
except Exception:
pass
# 收集控制台错误
if console_errors:
page_result["console_errors"] = console_errors[:5]
if page_result["status"] == "passed":
page_result["status"] = "warning"
page_result["warnings"].append(f"控制台错误 {len(console_errors)}")
status_icon = {"passed": "", "warning": "⚠️", "failed": ""}.get(page_result["status"], "")
print(f" {status_icon} {page_result['status']} (HTTP {http_status}) -> {final_url}")
except PlaywrightTimeout:
page_result["status"] = "failed"
page_result["errors"].append("页面加载超时 (30s)")
print(f" ❌ TIMEOUT")
except Exception as e:
page_result["status"] = "failed"
page_result["errors"].append(str(e)[:200])
print(f" ❌ ERROR: {str(e)[:100]}")
role_result["pages"].append(page_result)
# 测试角色特定交互
role_result["interactions"] = test_role_interactions(page, role)
# 收集全局控制台错误
if console_errors:
for err in console_errors[:10]:
results["console_errors_global"].append({"role": role, "error": err})
try:
page.remove_listener("console", on_console)
except Exception:
pass
return role_result
def test_role_interactions(page, role: str) -> list:
"""测试角色特定的交互功能"""
interactions = []
if role == "admin":
# 测试管理员选修课列表交互
print("\n >>> 测试交互: 管理员选修课列表")
try:
page.goto(f"{BASE_URL}/admin/elective", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 检查"创建课程"按钮
create_link = page.locator('a[href="/admin/elective/create"]').count() > 0
interactions.append({
"name": "管理员选修课 - 创建课程按钮存在",
"passed": create_link,
"detail": ""
})
# 检查是否有选修课卡片或空状态
has_content = "暂无选修课程" in body_text or "选修课程" in body_text
interactions.append({
"name": "管理员选修课 - 内容显示(卡片或空状态)",
"passed": has_content,
"detail": ""
})
except Exception as e:
interactions.append({
"name": "管理员选修课列表交互测试",
"passed": False,
"detail": f"异常: {str(e)[:200]}"
})
# 测试创建选修课表单
print("\n >>> 测试交互: 创建选修课表单")
try:
page.goto(f"{BASE_URL}/admin/elective/create", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 检查表单字段
name_input = page.locator('input[name="name"], #name').count() > 0
interactions.append({
"name": "创建选修课 - 课程名称输入框",
"passed": name_input,
"detail": ""
})
# 检查选课模式选择器
mode_select = page.locator('select[name="selectionMode"], [role="combobox"]').count() > 0
interactions.append({
"name": "创建选修课 - 选课模式选择器",
"passed": mode_select,
"detail": ""
})
# 检查容量输入框
capacity_input = page.locator('input[name="capacity"], #capacity, input[type="number"]').count() > 0
interactions.append({
"name": "创建选修课 - 容量输入框",
"passed": capacity_input,
"detail": ""
})
# 检查提交按钮
submit_btn = page.locator('button[type="submit"]').count() > 0
interactions.append({
"name": "创建选修课 - 提交按钮",
"passed": submit_btn,
"detail": ""
})
except Exception as e:
interactions.append({
"name": "创建选修课表单交互测试",
"passed": False,
"detail": f"异常: {str(e)[:200]}"
})
elif role == "teacher":
# 测试教师选修课列表
print("\n >>> 测试交互: 教师选修课列表")
try:
page.goto(f"{BASE_URL}/teacher/elective", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 检查"创建课程"按钮(教师也有 canManage 权限)
create_link = page.locator('a[href="/admin/elective/create"]').count() > 0
interactions.append({
"name": "教师选修课 - 创建课程按钮存在",
"passed": create_link,
"detail": ""
})
# 检查是否有选修课卡片或空状态
has_content = "暂无选修课程" in body_text or "我的选修课" in body_text
interactions.append({
"name": "教师选修课 - 内容显示(卡片或空状态)",
"passed": has_content,
"detail": ""
})
except Exception as e:
interactions.append({
"name": "教师选修课列表交互测试",
"passed": False,
"detail": f"异常: {str(e)[:200]}"
})
elif role == "student":
# 测试学生选课中心
print("\n >>> 测试交互: 学生选课中心")
try:
page.goto(f"{BASE_URL}/student/elective", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 检查"我的选课"区域
has_my_selections = "我的选课" in body_text or "My Selections" in body_text
interactions.append({
"name": "学生选课 - 我的选课区域",
"passed": has_my_selections,
"detail": ""
})
# 检查"可选课程"区域
has_available = "可选课程" in body_text or "Available" in body_text
interactions.append({
"name": "学生选课 - 可选课程区域",
"passed": has_available,
"detail": ""
})
# 检查筛选器
has_filter = page.locator('input[type="search"], input[placeholder*="搜索"], input[name="q"]').count() > 0
interactions.append({
"name": "学生选课 - 搜索筛选器",
"passed": has_filter,
"detail": ""
})
except Exception as e:
interactions.append({
"name": "学生选课中心交互测试",
"passed": False,
"detail": f"异常: {str(e)[:200]}"
})
return interactions
def test_cross_role_access(page, role: str) -> list:
"""测试跨角色访问保护"""
forbidden_routes = CROSS_ROLE_FORBIDDEN[role]
test_results = []
print(f"\n=== 测试 {role} 跨角色访问保护 ===")
for route in forbidden_routes:
result = {
"role": role,
"forbidden_route": route,
"final_url": None,
"passed": False,
"error": None,
}
try:
page.goto(f"{BASE_URL}{route}", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
final_url = page.url
final_path = urlparse(final_url).path
result["final_url"] = final_url
if _is_login_redirect(final_url):
result["passed"] = True
result["error"] = "重定向到登录页(拒绝访问)"
print(f"{route} -> 登录页(拒绝)")
elif final_path.startswith(TEST_ACCOUNTS[role]["expected_path"]):
result["passed"] = True
result["error"] = f"重定向回 {final_path}(拒绝访问)"
print(f"{route} -> {final_path}(拒绝)")
elif final_path == route:
result["passed"] = False
result["error"] = "成功访问禁止路由(权限漏洞)"
print(f"{route} -> 直接访问(权限漏洞)")
elif "/403" in final_url or "/401" in final_url or "/unauthorized" in final_url:
result["passed"] = True
result["error"] = "重定向到未授权页"
print(f"{route} -> 未授权页(拒绝)")
else:
result["passed"] = True
result["error"] = f"重定向到 {final_path}(拒绝访问)"
print(f"{route} -> {final_path}(拒绝)")
except PlaywrightTimeout:
result["error"] = "页面加载超时"
result["passed"] = True
print(f" ⚠️ {route} -> 超时")
except Exception as e:
result["error"] = f"异常: {str(e)[:200]}"
result["passed"] = True
print(f" ⚠️ {route} -> 异常")
test_results.append(result)
return test_results
def run_all_tests():
"""运行所有测试"""
global results
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
locale="zh-CN",
ignore_https_errors=True,
)
page = context.new_page()
for role in ["admin", "teacher", "student", "parent"]:
logout(page)
role_result = test_role_elective(page, role)
results["roles"][role] = role_result
# admin 拥有所有权限,可以访问所有路由,跳过跨角色测试
if role in CROSS_ROLE_FORBIDDEN:
cross_role_results = test_cross_role_access(page, role)
results["cross_role_tests"].extend(cross_role_results)
# 汇总
total = 0
passed = 0
failed = 0
warnings = 0
for role, r in results["roles"].items():
for page_result in r.get("pages", []):
total += 1
if page_result["status"] == "passed":
passed += 1
elif page_result["status"] == "warning":
warnings += 1
else:
failed += 1
for interaction in r.get("interactions", []):
total += 1
if interaction["passed"]:
passed += 1
else:
failed += 1
for r in results["cross_role_tests"]:
total += 1
if r["passed"]:
passed += 1
else:
failed += 1
results["summary"]["total"] = total
results["summary"]["passed"] = passed
results["summary"]["failed"] = failed
results["summary"]["warnings"] = warnings
print(f"\n{'='*60}")
print(f"测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}")
print(f"{'='*60}")
browser.close()
def generate_report() -> str:
"""生成 Markdown 测试报告"""
lines = []
lines.append("# 选修课模块 Web 功能测试报告")
lines.append("")
lines.append(f"> 模块:选修课 (Elective)")
lines.append(f"> 版本:{results['version']}")
lines.append(f"> 测试日期:{results['test_date']}")
lines.append(f"> 测试工具Playwright + Chromium (headless)")
lines.append(f"> Base URL{results['base_url']}")
lines.append(f"> 测试范围admin / teacher / student / parent 四角色选修课页面 + 跨角色访问保护 + 关键交互")
lines.append("")
lines.append("---")
lines.append("")
# 测试概览
lines.append("## 一、测试概览")
lines.append("")
s = results["summary"]
pass_rate = (s["passed"] / s["total"] * 100) if s["total"] > 0 else 0
lines.append("| 指标 | 数值 |")
lines.append("|------|------|")
lines.append(f"| 总测试项 | {s['total']} |")
lines.append(f"| 通过 | {s['passed']} |")
lines.append(f"| 失败 | {s['failed']} |")
lines.append(f"| 警告 | {s['warnings']} |")
lines.append(f"| 通过率 | {pass_rate:.1f}% |")
lines.append("")
lines.append("---")
lines.append("")
# 测试账号
lines.append("## 二、测试账号")
lines.append("")
lines.append("| 角色 | 邮箱 | 预期仪表盘路径 |")
lines.append("|------|------|----------------|")
for role, acc in TEST_ACCOUNTS.items():
lines.append(f"| {role} | `{acc['email']}` | `{acc['expected_path']}` |")
lines.append("")
lines.append("---")
lines.append("")
# 各角色测试详情
lines.append("## 三、各角色测试详情")
lines.append("")
for role in ["admin", "teacher", "student", "parent"]:
r = results["roles"].get(role, {})
if not r:
continue
lines.append(f"### {role.upper()} 选修课模块")
lines.append("")
lines.append(f"- **登录**: {'✅ 成功' if r.get('login_success') else '❌ 失败'}")
if r.get("errors"):
lines.append(f"- **错误**:")
for err in r["errors"]:
lines.append(f" - {err}")
if r.get("warnings"):
lines.append(f"- **警告**:")
for w in r["warnings"]:
lines.append(f" - {w}")
lines.append("")
# 页面测试详情
if r.get("pages"):
lines.append("#### 页面测试")
lines.append("")
lines.append("| 状态 | 类别 | 路由 | HTTP | 最终 URL | 错误 | 警告 |")
lines.append("|------|------|------|------|----------|------|------|")
for p in r["pages"]:
icon = {"passed": "", "warning": "⚠️", "failed": ""}.get(p["status"], "")
errs = "; ".join(p.get("errors", []))[:80] or "-"
warns = "; ".join(p.get("warnings", []))[:80] or "-"
lines.append(f"| {icon} | {p['category']} | `{p['route']}` | {p.get('http_status', '-')} | `{p.get('final_url', '-')}` | {errs} | {warns} |")
lines.append("")
# 检查项明细
for p in r["pages"]:
if p.get("checks"):
lines.append(f"##### 检查项明细 - {p['category']}")
lines.append("")
lines.append("| 状态 | 检查项 | 详情 |")
lines.append("|------|--------|------|")
for c in p["checks"]:
icon = "" if c["passed"] else ""
lines.append(f"| {icon} | {c['name']} | {c.get('detail', '')} |")
lines.append("")
# 交互测试
if r.get("interactions"):
lines.append("#### 交互测试")
lines.append("")
lines.append("| 状态 | 交互项 | 详情 |")
lines.append("|------|--------|------|")
for it in r["interactions"]:
icon = "" if it["passed"] else ""
lines.append(f"| {icon} | {it['name']} | {it.get('detail', '')} |")
lines.append("")
lines.append("---")
lines.append("")
# 跨角色访问保护测试
lines.append("## 四、跨角色访问保护测试")
lines.append("")
lines.append("| 角色 | 禁止路由 | 实际 URL | 结果 | 说明 |")
lines.append("|------|----------|----------|------|------|")
for r in results["cross_role_tests"]:
icon = "" if r["passed"] else ""
actual_path = urlparse(r.get("final_url", "")).path or "-"
lines.append(f"| {r['role']} | `{r['forbidden_route']}` | `{actual_path}` | {icon} {'拒绝' if r['passed'] else '通过'} | {r.get('error', '-') or '-'} |")
lines.append("")
lines.append("---")
lines.append("")
# 失败项汇总
failed_items = []
for role, r in results["roles"].items():
for p in r.get("pages", []):
if p["status"] == "failed":
failed_items.append(("页面测试", role, p.get("route", ""), p.get("errors", [])))
for it in r.get("interactions", []):
if not it["passed"]:
failed_items.append(("交互测试", role, it["name"], [it.get("detail", "")]))
for r in results["cross_role_tests"]:
if not r["passed"]:
failed_items.append(("跨角色保护", r["role"], r.get("forbidden_route", ""), [r.get("error", "")]))
if failed_items:
lines.append("## 五、失败项汇总")
lines.append("")
lines.append("| 类别 | 角色 | 路径/项 | 错误 |")
lines.append("|------|------|---------|------|")
for cat, role, item, errs in failed_items:
err_str = "; ".join(errs[:2]) if errs else "-"
lines.append(f"| {cat} | {role} | `{item}` | {err_str} |")
lines.append("")
lines.append("---")
lines.append("")
# 结论与建议
lines.append("## 六、测试结论与改进建议")
lines.append("")
if s["failed"] == 0:
lines.append("✅ **选修课模块所有测试通过**,各角色选修课页面功能正常,权限保护有效。")
else:
lines.append(f"❌ **{s['failed']} 项测试失败**,需修复以下问题:")
lines.append("")
for cat, role, item, errs in failed_items:
lines.append(f"- **{cat} - {role}** (`{item}`): {'; '.join(errs[:2]) if errs else '未知错误'}")
lines.append("")
lines.append("### 改进建议")
lines.append("")
lines.append("1. **认证与权限**:失败页面中若出现重定向至 /login需检查会话过期策略与权限校验逻辑。")
lines.append("2. **HTTP 5xx 错误**:服务端错误需检查 Server Action 数据访问层与数据库连接。")
lines.append("3. **页面内容为空**:检查数据查询条件与渲染逻辑,确认数据源是否返回预期结果。")
lines.append("4. **控制台错误**:浏览器控制台报错需检查前端组件渲染与 API 调用。")
lines.append("5. **跨角色访问**:若出现权限漏洞,需检查 `requirePermission()` 调用与角色-权限映射。")
lines.append("6. **资源归属校验**update/delete/select/drop/lottery Action 必须校验资源归属,防止越权操作。")
lines.append("")
lines.append("---")
lines.append("")
lines.append(f"*报告自动生成于 {results['test_date']}*")
return "\n".join(lines)
def main():
run_all_tests()
report = generate_report()
output_path = WEBTEST_DIR / f"elective_{VERSION}.md"
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {output_path}")
json_path = output_path.parent / f"elective_{VERSION}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2, default=str)
print(f"📄 JSON 数据已写入: {json_path}")
if __name__ == "__main__":
main()