Files
NextEdu/webtest/elective_test.py
SpecialX d884c6d513
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
test: update and add E2E, integration, visual, and webapp tests
- 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
2026-06-23 17:39:40 +08:00

806 lines
31 KiB
Python
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.
"""
选修课模块全功能 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()