""" 学校管理模块(school-management)全功能 Web 测试脚本 使用 Playwright 对所有角色的学校管理模块访问与功能进行测试 覆盖模块: - 学校管理 (/admin/school/schools) - SCHOOL_MANAGE 权限 - 院系管理 (/admin/school/departments) - SCHOOL_MANAGE 权限 - 学年管理 (/admin/school/academic-year) - SCHOOL_MANAGE 权限 覆盖角色: - admin(完整 CRUD) - teacher(应被拒绝访问 /admin/* 路径) - student(应被拒绝访问 /admin/* 路径) - parent(应被拒绝访问 /admin/* 路径) 结果输出:webtest/school-management_v1.md 与 webtest/school-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 = "school-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: 完整访问;其他角色:应被重定向到自己的 dashboard(带 reason=forbidden) ROLE_EXPECTATIONS = { "admin": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True}, "teacher": {"can_access": False, "expected_redirect_reason": "forbidden"}, "student": {"can_access": False, "expected_redirect_reason": "forbidden"}, "parent": {"can_access": False, "expected_redirect_reason": "forbidden"}, } # 学校管理模块路由 SCHOOL_ROUTES = { "schools_list": "/admin/school/schools", "departments_list": "/admin/school/departments", "academic_year_list": "/admin/school/academic-year", } 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": "学校管理 (School Management)", "version": VERSION, "base_url": BASE_URL, "covered_submodules": ["schools", "departments", "academic-year"], "summary": { "total": 0, "passed": 0, "failed": 0, "warnings": 0, }, "roles": {}, "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 _is_forbidden_redirect(url: str) -> bool: """判断是否因权限不足被重定向(带 from/reason=forbidden 参数)""" parsed = urlparse(url) query = parse_qs(parsed.query) return query.get("reason", [""])[0] == "forbidden" def safe_text(locator, max_len=500): try: text = locator.text_content() return (text or "").strip()[:max_len] except Exception: return "" 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: """完成家长 onboarding 流程""" 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) # Step 0: 角色选择 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) # Step 1: 通用信息 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) # Step 2: 跳过 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) # Step 3: 完成 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] 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: # 预期不能访问 - 应该被重定向到 dashboard 且带 reason=forbidden 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_schools_crud(page, role: str) -> dict: """测试学校管理 CRUD(仅 admin)""" result = { "feature": "学校 CRUD", "role": role, "operations": [], "status": "unknown", "errors": [], } if role != "admin": result["status"] = "skipped" result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 CRUD 测试") return result route = SCHOOL_ROUTES["schools_list"] 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: # 查找"新建"按钮(i18n key: schools.new 或类似) new_btn = page.locator('button:has-text("New"), button:has-text("新建"), button:has-text("Create"), button:has-text("新增")').first if new_btn.count() == 0: # 尝试查找带 Plus 图标的按钮 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')}") code_input = page.locator('input[name="code"]').first if code_input.count() > 0: code_input.fill(f"TEST_{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. 检查编辑入口(不实际编辑) edit_check = {"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) # 检查下拉菜单是否有编辑选项 edit_item = page.locator('[role="menuitem"]:has-text("Edit"), [role="menuitem"]:has-text("编辑")').first if edit_item.count() > 0: edit_check["passed"] = True edit_check["detail"] = "编辑菜单项存在" else: edit_check["detail"] = "未找到编辑菜单项" # 关闭菜单 page.keyboard.press("Escape") page.wait_for_timeout(300) else: # 可能是空列表,没有数据行 edit_check["detail"] = "无数据行,跳过编辑入口检查" edit_check["passed"] = True # 不算失败 except Exception as e: edit_check["detail"] = f"异常: {e}" result["operations"].append(edit_check) # 4. 检查删除入口 delete_check = {"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) delete_item = page.locator('[role="menuitem"]:has-text("Delete"), [role="menuitem"]:has-text("删除")').first if delete_item.count() > 0: delete_check["passed"] = True delete_check["detail"] = "删除菜单项存在" else: delete_check["detail"] = "未找到删除菜单项" page.keyboard.press("Escape") page.wait_for_timeout(300) else: delete_check["detail"] = "无数据行,跳过删除入口检查" delete_check["passed"] = True except Exception as e: delete_check["detail"] = f"异常: {e}" result["operations"].append(delete_check) # 汇总 passed_ops = sum(1 for op in result["operations"] if op["passed"]) total_ops = len(result["operations"]) if passed_ops == total_ops: 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_departments_crud(page, role: str) -> dict: """测试院系管理 CRUD(仅 admin)""" result = { "feature": "院系 CRUD", "role": role, "operations": [], "status": "unknown", "errors": [], } if role != "admin": result["status"] = "skipped" result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 CRUD 测试") return result route = SCHOOL_ROUTES["departments_list"] 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) item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{"编辑" if action_text == "Edit" else "删除"}")').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_academic_year_crud(page, role: str) -> dict: """测试学年管理 CRUD(仅 admin)""" result = { "feature": "学年 CRUD", "role": role, "operations": [], "status": "unknown", "errors": [], } if role != "admin": result["status"] = "skipped" result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 CRUD 测试") return result route = SCHOOL_ROUTES["academic_year_list"] 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')}") # 检查日期输入 start_input = page.locator('input[name="startDate"], input[type="date"]').nth(0) end_input = page.locator('input[name="endDate"], input[type="date"]').nth(1) if start_input.count() > 0: start_input.fill("2026-09-01") if end_input.count() > 0: end_input.fill("2027-06-30") 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) item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{"编辑" if action_text == "Edit" else "删除"}")').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_role(page, role: str) -> dict: """测试单个角色的学校管理模块""" role_result = { "role": role, "login_success": False, "access_tests": [], "crud_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 # 家长账号需要完成 onboarding 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 SCHOOL_ROUTES.items(): access_result = test_page_access(page, role, route_key, route) role_result["access_tests"].append(access_result) # 测试 CRUD(仅 admin) role_result["crud_tests"].append(test_schools_crud(page, role)) role_result["crud_tests"].append(test_departments_crud(page, role)) role_result["crud_tests"].append(test_academic_year_crud(page, role)) # 收集控制台错误 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 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: """生成 Markdown 测试报告""" 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 | student | parent |") lines.append("|--------|------|--------|-------|---------|---------|--------|") lines.append("| 学校管理 | /admin/school/schools | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |") lines.append("| 院系管理 | /admin/school/departments | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |") lines.append("| 学年管理 | /admin/school/academic-year | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |") 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 = "
".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("") # CRUD 测试 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("") 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}) 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 角色可以完整访问学校管理、院系管理、学年管理功能(列表、创建、编辑、删除入口均可用)") lines.append("- teacher / 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 数据 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()