Files
NextEdu/webtest/grade-management_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

910 lines
36 KiB
Python
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.
"""
年级管理模块grade-management全功能 Web 测试脚本
使用 Playwright 对所有角色的年级管理模块访问与功能进行测试
覆盖模块:
- 年级管理 (/admin/school/grades) - SCHOOL_MANAGE 权限admin
- 年级洞察 (/admin/school/grades/insights) - SCHOOL_MANAGE 权限admin
- 年级班级管理 (/management/grade/classes) - GRADE_MANAGE 权限grade_head/teaching_head
- 年级洞察 (/management/grade/insights) - GRADE_RECORD_READ 权限teacher/grade_head/teaching_head
覆盖角色:
- admin完整 CRUD
- teacher同时也是 grade_head可访问 /management/* 但不能访问 /admin/school/grades
- student应被拒绝访问
- parent应被拒绝访问
结果输出webtest/grade-management_v1.md 与 webtest/grade-management_v1.json
"""
import json
import os
import re
import sys
import time
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 = "v1"
MODULE_NAME = "grade-management"
# 测试账号(来自 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"},
}
# 各角色对年级管理模块的预期访问行为
# admin: 同时拥有 SCHOOL_MANAGE 和 GRADE_MANAGE可访问所有年级管理路由
# teacher (同时是 grade_head): 可访问 /management/* (GRADE_MANAGE),但不能访问 /admin/school/grades
# student/parent: 都不能访问
ROLE_EXPECTATIONS = {
"admin": {
"admin_grades": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True},
"admin_insights": {"can_access": True},
"management_classes": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True},
"management_insights": {"can_access": True},
},
"teacher": {
# teacher 也是 grade_headT_C1 是一年级组长),有 GRADE_MANAGE 权限
"admin_grades": {"can_access": False, "expected_redirect_reason": "forbidden"},
"admin_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
"management_classes": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True},
"management_insights": {"can_access": True},
},
"student": {
"admin_grades": {"can_access": False, "expected_redirect_reason": "forbidden"},
"admin_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
"management_classes": {"can_access": False, "expected_redirect_reason": "forbidden"},
"management_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
},
"parent": {
"admin_grades": {"can_access": False, "expected_redirect_reason": "forbidden"},
"admin_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
"management_classes": {"can_access": False, "expected_redirect_reason": "forbidden"},
"management_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
},
}
# 年级管理模块路由
GRADE_ROUTES = {
"admin_grades": "/admin/school/grades",
"admin_insights": "/admin/school/grades/insights",
"management_classes": "/management/grade/classes",
"management_insights": "/management/grade/insights",
}
PROJECT_ROOT = Path(__file__).resolve().parents[1]
WEBTEST_DIR = PROJECT_ROOT / "webtest"
SCREENSHOT_DIR = WEBTEST_DIR / "screenshots" / MODULE_NAME
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": "年级管理 (Grade Management)",
"version": VERSION,
"base_url": BASE_URL,
"covered_submodules": ["admin-grades", "admin-insights", "management-classes", "management-insights"],
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"roles": {},
"console_errors_global": [],
}
def _is_login_redirect(url: str) -> bool:
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 _is_forbidden_redirect(url: str) -> bool:
parsed = urlparse(url)
query = parse_qs(parsed.query)
return query.get("reason", [""])[0] == "forbidden"
def login(page, role: str) -> bool:
account = TEST_ACCOUNTS[role]
print(f"\n>>> 登录 {role} 账号 ({account['email']})...")
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
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"])
login_btn = page.locator('button[type="submit"]')
if login_btn.count() == 0:
login_btn = page.get_by_role("button", name="Sign In with Email")
if login_btn.count() == 0:
login_btn = page.locator("button").filter(has_text="Sign In")
if login_btn.count() == 0:
login_btn = page.locator("button").filter(has_text="登录")
login_btn.click()
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
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 complete_parent_onboarding(page, role: str) -> bool:
print(f" >>> 完成 {role} onboarding...")
try:
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
if "/onboarding" not in page.url:
return True
page.wait_for_selector('[role="dialog"]', timeout=10000)
page.wait_for_timeout(500)
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(500)
name_input = page.locator('input[id="onb_name"]')
if name_input.count() == 0:
name_input = page.locator('input').nth(0)
if name_input.count() > 0:
name_input.fill("测试家长")
phone_input = page.locator('input[id="onb_phone"]')
if phone_input.count() == 0:
phone_input = page.locator('input').nth(1)
if phone_input.count() > 0:
phone_input.fill("13800000000")
address_input = page.locator('input[id="onb_address"]')
if address_input.count() == 0:
address_input = page.locator('input').nth(2)
if address_input.count() > 0:
address_input.fill("测试地址")
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(500)
skip_btn = page.locator('button:has-text("跳过"), button:has-text("Skip")').first
if skip_btn.count() > 0:
skip_btn.click()
page.wait_for_timeout(500)
else:
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(500)
finish_btn = page.locator('button:has-text("完成"), button:has-text("Finish")').first
if finish_btn.count() > 0:
finish_btn.click()
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
return "/onboarding" not in page.url
except Exception as e:
print(f" onboarding 异常: {e}")
return False
def collect_console_errors(page):
errors = []
def on_console(msg):
if msg.type == "error":
errors.append(msg.text)
page.on("console", on_console)
return errors, on_console
def test_page_access(page, role: str, route_key: str, route: str) -> dict:
url = f"{BASE_URL}{route}"
expectation = ROLE_EXPECTATIONS[role][route_key]
result = {
"route_key": route_key,
"url": url,
"expected_access": expectation["can_access"],
"http_status": None,
"final_url": None,
"status": "unknown",
"errors": [],
"warnings": [],
"checks": [],
}
print(f"\n 测试访问: {route_key} ({route}) as {role}")
try:
response = page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
http_status = response.status if response else None
final_url = page.url
result["http_status"] = http_status
result["final_url"] = final_url
screenshot_name = f"{route_key}_{role}.png"
try:
page.screenshot(path=str(SCREENSHOT_DIR / screenshot_name), full_page=True)
except Exception:
pass
if http_status and http_status >= 500:
result["status"] = "failed"
result["errors"].append(f"HTTP {http_status} 服务器错误")
elif _is_login_redirect(final_url):
result["status"] = "failed"
result["errors"].append("重定向到登录页 - 认证失败")
elif expectation["can_access"]:
if urlparse(final_url).path == route:
result["status"] = "passed"
result["checks"].append({"name": "页面正常加载", "passed": True})
else:
result["status"] = "warning"
result["warnings"].append(f"重定向到 {final_url}(预期 {route}")
else:
if _is_forbidden_redirect(final_url):
result["status"] = "passed"
result["checks"].append({
"name": "权限拒绝重定向",
"passed": True,
"detail": f"正确重定向到 {final_url}"
})
elif urlparse(final_url).path != route:
result["status"] = "passed"
result["checks"].append({
"name": "权限拒绝重定向",
"passed": True,
"detail": f"重定向到 {final_url}"
})
else:
result["status"] = "failed"
result["errors"].append(f"无权限角色 {role} 不应能访问 {route}")
if result["status"] == "passed" and expectation["can_access"]:
body_text = page.locator("body").text_content() or ""
if len(body_text.strip()) < 50:
result["warnings"].append("页面内容过少(<50 字符)")
except PlaywrightTimeout:
result["status"] = "failed"
result["errors"].append("页面加载超时 (30s)")
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"异常: {str(e)[:200]}")
status_icon = "" if result["status"] == "passed" else "⚠️" if result["status"] == "warning" else ""
print(f" {status_icon} {result['status']} (HTTP {result['http_status']})")
return result
def test_grades_crud(page, role: str) -> dict:
"""测试年级管理 CRUD仅 admin 在 /admin/school/grades"""
result = {
"feature": "年级 CRUD (admin)",
"role": role,
"operations": [],
"status": "unknown",
"errors": [],
}
if role != "admin":
result["status"] = "skipped"
result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 admin 年级 CRUD 测试")
return result
route = GRADE_ROUTES["admin_grades"]
url = f"{BASE_URL}{route}"
try:
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 列表加载
list_check = {"name": "列表加载", "passed": False, "detail": ""}
try:
table = page.locator("table")
empty_state = page.locator('[class*="empty"]')
if table.count() > 0 or empty_state.count() > 0:
list_check["passed"] = True
list_check["detail"] = "列表/空状态正常显示"
else:
list_check["detail"] = "未找到表格或空状态"
except Exception as e:
list_check["detail"] = f"异常: {e}"
result["operations"].append(list_check)
# 2. 筛选器检查
filter_check = {"name": "筛选器存在", "passed": False, "detail": ""}
try:
# 年级管理页面有搜索、学校筛选、组长筛选、排序
select_count = page.locator('button[role="combobox"]').count()
search_input = page.locator('input[placeholder*="搜索"], input[placeholder*="search"]').count()
if select_count > 0 or search_input > 0:
filter_check["passed"] = True
filter_check["detail"] = f"找到筛选器select: {select_count}, search: {search_input}"
else:
filter_check["detail"] = "未找到筛选器"
except Exception as e:
filter_check["detail"] = f"异常: {e}"
result["operations"].append(filter_check)
# 3. 打开创建对话框
create_check = {"name": "打开创建对话框", "passed": False, "detail": ""}
try:
new_btn = page.locator('button:has-text("New"), button:has-text("新建"), button:has-text("Create"), button:has-text("新增")').first
if new_btn.count() == 0:
new_btn = page.locator('button:has(svg[class*="lucide-plus"])').first
if new_btn.count() > 0:
new_btn.click()
page.wait_for_timeout(1000)
dialog = page.locator('[role="dialog"]')
if dialog.count() > 0:
create_check["passed"] = True
create_check["detail"] = "创建对话框已打开"
# 检查表单字段
name_input = page.locator('input[name="name"]').first
if name_input.count() > 0:
name_input.fill(f"测试年级_{datetime.now().strftime('%H%M%S')}")
cancel_btn = page.locator('button:has-text("Cancel"), button:has-text("取消")').first
if cancel_btn.count() > 0:
cancel_btn.click()
page.wait_for_timeout(500)
else:
create_check["detail"] = "对话框未打开"
else:
create_check["detail"] = "未找到新建按钮"
except Exception as e:
create_check["detail"] = f"异常: {e}"
result["operations"].append(create_check)
# 4. 编辑/删除入口
for action_name, action_text in [("编辑入口存在", "Edit"), ("删除入口存在", "Delete"), ("洞察入口存在", "Insights")]:
check = {"name": action_name, "passed": False, "detail": ""}
try:
menu_btn = page.locator('button:has(svg[class*="lucide-more-horizontal"])').first
if menu_btn.count() > 0:
menu_btn.click()
page.wait_for_timeout(500)
cn_text = "编辑" if action_text == "Edit" else "删除" if action_text == "Delete" else "洞察"
item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{cn_text}")').first
if item.count() > 0:
check["passed"] = True
check["detail"] = f"{action_text} 菜单项存在"
else:
check["detail"] = f"未找到 {action_text} 菜单项"
page.keyboard.press("Escape")
page.wait_for_timeout(300)
else:
check["detail"] = "无数据行,跳过"
check["passed"] = True
except Exception as e:
check["detail"] = f"异常: {e}"
result["operations"].append(check)
passed_ops = sum(1 for op in result["operations"] if op["passed"])
if passed_ops == len(result["operations"]):
result["status"] = "passed"
elif passed_ops > 0:
result["status"] = "warning"
else:
result["status"] = "failed"
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"CRUD 测试异常: {str(e)[:200]}")
return result
def test_management_classes_crud(page, role: str) -> dict:
"""测试年级班级管理 CRUDadmin 和 teacher/grade_head 在 /management/grade/classes"""
result = {
"feature": "年级班级 CRUD (grade_head)",
"role": role,
"operations": [],
"status": "unknown",
"errors": [],
}
if role not in ("admin", "teacher"):
result["status"] = "skipped"
result["errors"].append(f"{role} 无 GRADE_MANAGE 权限,跳过 management 班级 CRUD 测试")
return result
route = GRADE_ROUTES["management_classes"]
url = f"{BASE_URL}{route}"
try:
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 列表加载
list_check = {"name": "列表加载", "passed": False, "detail": ""}
try:
table = page.locator("table")
empty_state = page.locator('[class*="empty"]')
if table.count() > 0 or empty_state.count() > 0:
list_check["passed"] = True
list_check["detail"] = "列表/空状态正常显示"
else:
list_check["detail"] = "未找到表格或空状态"
except Exception as e:
list_check["detail"] = f"异常: {e}"
result["operations"].append(list_check)
# 2. 打开创建对话框
create_check = {"name": "打开创建对话框", "passed": False, "detail": ""}
try:
new_btn = page.locator('button:has-text("New"), button:has-text("新建"), button:has-text("Create"), button:has-text("新增")').first
if new_btn.count() == 0:
new_btn = page.locator('button:has(svg[class*="lucide-plus"])').first
if new_btn.count() > 0:
new_btn.click()
page.wait_for_timeout(1000)
dialog = page.locator('[role="dialog"]')
if dialog.count() > 0:
create_check["passed"] = True
create_check["detail"] = "创建对话框已打开"
name_input = page.locator('input[name="name"]').first
if name_input.count() > 0:
name_input.fill(f"测试班级_{datetime.now().strftime('%H%M%S')}")
cancel_btn = page.locator('button:has-text("Cancel"), button:has-text("取消")').first
if cancel_btn.count() > 0:
cancel_btn.click()
page.wait_for_timeout(500)
else:
create_check["detail"] = "对话框未打开"
else:
create_check["detail"] = "未找到新建按钮"
except Exception as e:
create_check["detail"] = f"异常: {e}"
result["operations"].append(create_check)
# 3. 编辑/删除入口
for action_name, action_text in [("编辑入口存在", "Edit"), ("删除入口存在", "Delete")]:
check = {"name": action_name, "passed": False, "detail": ""}
try:
menu_btn = page.locator('button:has(svg[class*="lucide-more-horizontal"])').first
if menu_btn.count() > 0:
menu_btn.click()
page.wait_for_timeout(500)
cn_text = "编辑" if action_text == "Edit" else "删除"
item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{cn_text}")').first
if item.count() > 0:
check["passed"] = True
check["detail"] = f"{action_text} 菜单项存在"
else:
check["detail"] = f"未找到 {action_text} 菜单项"
page.keyboard.press("Escape")
page.wait_for_timeout(300)
else:
check["detail"] = "无数据行,跳过"
check["passed"] = True
except Exception as e:
check["detail"] = f"异常: {e}"
result["operations"].append(check)
passed_ops = sum(1 for op in result["operations"] if op["passed"])
if passed_ops == len(result["operations"]):
result["status"] = "passed"
elif passed_ops > 0:
result["status"] = "warning"
else:
result["status"] = "failed"
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"CRUD 测试异常: {str(e)[:200]}")
return result
def test_insights_page(page, role: str, route_key: str) -> dict:
"""测试洞察页面(年级洞察)"""
result = {
"feature": f"洞察页面 ({route_key})",
"role": role,
"operations": [],
"status": "unknown",
"errors": [],
}
expectation = ROLE_EXPECTATIONS[role][route_key]
if not expectation["can_access"]:
result["status"] = "skipped"
result["errors"].append(f"{role} 无访问权限,跳过洞察页面测试")
return result
route = GRADE_ROUTES[route_key]
url = f"{BASE_URL}{route}"
try:
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 页面加载
load_check = {"name": "页面加载", "passed": False, "detail": ""}
try:
body_text = page.locator("body").text_content() or ""
if len(body_text.strip()) > 50:
load_check["passed"] = True
load_check["detail"] = f"页面内容长度 {len(body_text.strip())}"
else:
load_check["detail"] = "页面内容过少"
except Exception as e:
load_check["detail"] = f"异常: {e}"
result["operations"].append(load_check)
# 2. 筛选器存在
filter_check = {"name": "年级筛选器存在", "passed": False, "detail": ""}
try:
select = page.locator('select[name="gradeId"]')
if select.count() > 0:
filter_check["passed"] = True
filter_check["detail"] = "找到年级筛选 select"
else:
filter_check["detail"] = "未找到年级筛选 select"
except Exception as e:
filter_check["detail"] = f"异常: {e}"
result["operations"].append(filter_check)
passed_ops = sum(1 for op in result["operations"] if op["passed"])
if passed_ops == len(result["operations"]):
result["status"] = "passed"
elif passed_ops > 0:
result["status"] = "warning"
else:
result["status"] = "failed"
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"洞察页面测试异常: {str(e)[:200]}")
return result
def test_role(page, role: str) -> dict:
role_result = {
"role": role,
"login_success": False,
"access_tests": [],
"crud_tests": [],
"insights_tests": [],
"errors": [],
"warnings": [],
}
print(f"\n{'='*60}")
print(f"=== 测试角色: {role} ===")
print(f"{'='*60}")
if not login(page, role):
role_result["errors"].append(f"{role} 登录失败")
return role_result
role_result["login_success"] = True
if role == "parent" and "/onboarding" in page.url:
if not complete_parent_onboarding(page, role):
role_result["warnings"].append("onboarding 未完成")
console_errors, on_console = collect_console_errors(page)
# 测试每个路由的访问权限
for route_key, route in GRADE_ROUTES.items():
access_result = test_page_access(page, role, route_key, route)
role_result["access_tests"].append(access_result)
# 测试 CRUD
role_result["crud_tests"].append(test_grades_crud(page, role))
role_result["crud_tests"].append(test_management_classes_crud(page, role))
# 测试洞察页面
role_result["insights_tests"].append(test_insights_page(page, role, "admin_insights"))
role_result["insights_tests"].append(test_insights_page(page, role, "management_insights"))
if console_errors:
role_result["warnings"].extend([f"控制台错误: {e[:200]}" for e in console_errors[:5]])
results["console_errors_global"].extend([{f"role": role, "error": e} for e in console_errors[:5]])
try:
page.remove_listener("console", on_console)
except Exception:
pass
logout(page)
return role_result
def run_all_tests():
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"]:
role_result = test_role(page, role)
results["roles"][role] = role_result
browser.close()
total = 0
passed = 0
failed = 0
warnings = 0
for role, role_data in results["roles"].items():
for access in role_data.get("access_tests", []):
total += 1
if access["status"] == "passed":
passed += 1
elif access["status"] == "warning":
warnings += 1
else:
failed += 1
for crud in role_data.get("crud_tests", []):
if crud["status"] == "skipped":
continue
total += 1
if crud["status"] == "passed":
passed += 1
elif crud["status"] == "warning":
warnings += 1
else:
failed += 1
for ins in role_data.get("insights_tests", []):
if ins["status"] == "skipped":
continue
total += 1
if ins["status"] == "passed":
passed += 1
elif ins["status"] == "warning":
warnings += 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}")
def generate_report() -> str:
lines = []
lines.append("# 年级管理模块 Web 功能测试报告")
lines.append("")
lines.append(f"> 测试日期:{results['test_date']}")
lines.append(f"> 模块:{results['module']}")
lines.append(f"> 版本:{results['version']}")
lines.append(f"> 测试工具Playwright + Chromium (headless)")
lines.append(f"> Base URL{results['base_url']}")
lines.append(f"> 覆盖子模块:{', '.join(results['covered_submodules'])}")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## 一、测试概览")
lines.append("")
s = results["summary"]
lines.append("| 指标 | 数值 |")
lines.append("|------|------|")
lines.append(f"| 总测试项 | {s['total']} |")
lines.append(f"| 通过 | {s['passed']} |")
lines.append(f"| 失败 | {s['failed']} |")
lines.append(f"| 警告 | {s['warnings']} |")
if s["total"] > 0:
lines.append(f"| 通过率 | {s['passed']/s['total']*100:.1f}% |")
else:
lines.append("| 通过率 | N/A |")
lines.append("")
lines.append("### 测试覆盖")
lines.append("")
lines.append("| 子模块 | 路由 | 权限点 | admin | teacher(grade_head) | student | parent |")
lines.append("|--------|------|--------|-------|---------------------|---------|--------|")
lines.append("| 年级管理(admin) | /admin/school/grades | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |")
lines.append("| 年级洞察(admin) | /admin/school/grades/insights | SCHOOL_MANAGE | ✅ 查看 | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |")
lines.append("| 年级班级管理 | /management/grade/classes | GRADE_MANAGE | ✅ 完整CRUD | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 |")
lines.append("| 年级洞察(teacher) | /management/grade/insights | GRADE_RECORD_READ | ✅ 查看 | ✅ 查看 | ❌ 拒绝 | ❌ 拒绝 |")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## 二、各角色测试详情")
lines.append("")
for role, role_data in results["roles"].items():
lines.append(f"### 角色:{role}")
lines.append("")
lines.append(f"- **登录状态**: {'✅ 成功' if role_data['login_success'] else '❌ 失败'}")
if role_data.get("errors"):
for err in role_data["errors"]:
lines.append(f"- **错误**: {err}")
if role_data.get("warnings"):
for w in role_data["warnings"]:
lines.append(f"- **警告**: {w}")
lines.append("")
if role_data.get("access_tests"):
lines.append("#### 访问权限测试")
lines.append("")
lines.append("| 状态 | 子模块 | 路由 | HTTP | 最终 URL | 备注 |")
lines.append("|------|--------|------|------|----------|------|")
for access in role_data["access_tests"]:
status_icon = "" if access["status"] == "passed" else "⚠️" if access["status"] == "warning" else ""
short_url = (access.get("final_url") or "").replace(BASE_URL, "")[:80]
notes = []
if access.get("errors"):
notes.append(f"错误: {'; '.join(access['errors'][:2])}")
if access.get("warnings"):
notes.append(f"警告: {'; '.join(access['warnings'][:2])}")
note_str = "<br>".join(notes) if notes else "-"
route = access["route_key"]
lines.append(f"| {status_icon} | {route} | `{access['url'].replace(BASE_URL, '')}` | {access['http_status'] or '-'} | `{short_url}` | {note_str} |")
lines.append("")
if role_data.get("crud_tests"):
lines.append("#### CRUD 功能测试")
lines.append("")
lines.append("| 状态 | 功能 | 操作 | 详情 |")
lines.append("|------|------|------|------|")
for crud in role_data["crud_tests"]:
if crud["status"] == "skipped":
lines.append(f"| ⏭️ | {crud['feature']} | 跳过 | {crud['errors'][0] if crud['errors'] else '-'} |")
continue
status_icon = "" if crud["status"] == "passed" else "⚠️" if crud["status"] == "warning" else ""
for op in crud.get("operations", []):
op_icon = "" if op["passed"] else ""
lines.append(f"| {status_icon} | {crud['feature']} | {op_icon} {op['name']} | {op['detail']} |")
lines.append("")
if role_data.get("insights_tests"):
lines.append("#### 洞察页面测试")
lines.append("")
lines.append("| 状态 | 功能 | 操作 | 详情 |")
lines.append("|------|------|------|------|")
for ins in role_data["insights_tests"]:
if ins["status"] == "skipped":
lines.append(f"| ⏭️ | {ins['feature']} | 跳过 | {ins['errors'][0] if ins['errors'] else '-'} |")
continue
status_icon = "" if ins["status"] == "passed" else "⚠️" if ins["status"] == "warning" else ""
for op in ins.get("operations", []):
op_icon = "" if op["passed"] else ""
lines.append(f"| {status_icon} | {ins['feature']} | {op_icon} {op['name']} | {op['detail']} |")
lines.append("")
lines.append("---")
lines.append("")
failed_items = []
for role, role_data in results["roles"].items():
for access in role_data.get("access_tests", []):
if access["status"] == "failed":
failed_items.append({"role": role, "type": "访问", "detail": access})
for crud in role_data.get("crud_tests", []):
if crud["status"] == "failed":
failed_items.append({"role": role, "type": "CRUD", "detail": crud})
for ins in role_data.get("insights_tests", []):
if ins["status"] == "failed":
failed_items.append({"role": role, "type": "洞察", "detail": ins})
if failed_items:
lines.append("## 三、失败项详情")
lines.append("")
for item in failed_items:
d = item["detail"]
lines.append(f"### ❌ [{item['role']}] {item['type']}: {d.get('feature', d.get('route_key', ''))}")
lines.append("")
if "url" in d:
lines.append(f"- **URL**: {d['url']}")
if "errors" in d and d["errors"]:
for err in d["errors"]:
lines.append(f"- **错误**: {err}")
if "operations" in d:
for op in d["operations"]:
if not op["passed"]:
lines.append(f"- **操作失败**: {op['name']} - {op['detail']}")
lines.append("")
if results.get("console_errors_global"):
lines.append("## 四、控制台错误汇总")
lines.append("")
for err in results["console_errors_global"][:20]:
lines.append(f"- **[{err['role']}]** {err['error'][:200]}")
lines.append("")
lines.append("## 五、测试结论")
lines.append("")
if results["summary"]["failed"] == 0:
lines.append("✅ **所有测试通过**:年级管理模块在所有用户角色下均按预期工作。")
lines.append("")
lines.append("- admin 角色可以完整访问 /admin/school/grades年级 CRUD + 洞察)")
lines.append("- teacher兼 grade_head角色可以访问 /management/grade/classes 和 /management/grade/insights")
lines.append("- student / parent 角色被正确重定向到各自仪表盘(带 `reason=forbidden` 参数),权限隔离正常")
else:
lines.append(f"❌ **{results['summary']['failed']} 项测试失败**:请查看上方失败项详情并修复。")
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"{MODULE_NAME}_{VERSION}.md"
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {output_path}")
json_path = WEBTEST_DIR / f"{MODULE_NAME}_{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()