""" 班级管理模块(class-management)全功能 Web 测试脚本 使用 Playwright 对所有角色的班级管理模块访问与功能进行测试 覆盖模块: - 班级管理(admin) (/admin/school/classes) - SCHOOL_MANAGE 权限(admin) - 年级班级管理 (/management/grade/classes) - GRADE_MANAGE 权限(grade_head/teaching_head) - 教师班级 (/teacher/classes) - CLASS_READ 权限(teacher) - 教师我的班级 (/teacher/classes/my) - CLASS_READ 权限(teacher) 覆盖角色: - admin(完整 CRUD on /admin/school/classes) - teacher(可查看 /teacher/classes/my,作为 grade_head 可访问 /management/grade/classes) - student(应被拒绝访问管理页面) - parent(应被拒绝访问管理页面) 结果输出:webtest/class-management_v1.md 与 webtest/class-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 = "class-management" # 测试账号 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、EXAM_CREATE,可访问所有班级管理路由 # teacher: 拥有 EXAM_CREATE 和 GRADE_MANAGE(作为 grade_head),可访问 /management/* 和 /teacher/* # student/parent: 都不能访问管理路由和教师路由 ROLE_EXPECTATIONS = { "admin": { "admin_classes": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True}, "management_classes": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True}, "teacher_classes": {"can_access": True, "can_create": False, "can_edit": False, "can_delete": False}, "teacher_my_classes": {"can_access": True, "can_create": False, "can_edit": False, "can_delete": False}, }, "teacher": { "admin_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, "management_classes": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True}, "teacher_classes": {"can_access": True, "can_create": False, "can_edit": False, "can_delete": False}, "teacher_my_classes": {"can_access": True, "can_create": False, "can_edit": False, "can_delete": False}, }, "student": { "admin_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, "management_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, "teacher_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, "teacher_my_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, }, "parent": { "admin_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, "management_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, "teacher_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, "teacher_my_classes": {"can_access": False, "expected_redirect_reason": "forbidden"}, }, } # 班级管理模块路由 CLASS_ROUTES = { "admin_classes": "/admin/school/classes", "management_classes": "/management/grade/classes", "teacher_classes": "/teacher/classes", "teacher_my_classes": "/teacher/classes/my", } 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": "班级管理 (Class Management)", "version": VERSION, "base_url": BASE_URL, "covered_submodules": ["admin-classes", "management-classes", "teacher-classes", "teacher-my-classes"], "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_admin_classes_crud(page, role: str) -> dict: """测试 admin 班级管理 CRUD(仅 admin)""" result = { "feature": "admin 班级 CRUD", "role": role, "operations": [], "status": "unknown", "errors": [], } if role != "admin": result["status"] = "skipped" result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 admin 班级 CRUD 测试") return result route = CLASS_ROUTES["admin_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_management_classes_crud(page, role: str) -> dict: """测试年级班级管理 CRUD(仅 teacher/grade_head)""" 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 = CLASS_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_teacher_classes_view(page, role: str) -> dict: """测试教师班级查看(admin 和 teacher)""" result = { "feature": "教师班级查看", "role": role, "operations": [], "status": "unknown", "errors": [], } if role not in ("admin", "teacher"): result["status"] = "skipped" result["errors"].append(f"{role} 无 CLASS_READ 权限,跳过教师班级查看测试") return result route = CLASS_ROUTES["teacher_my_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. 页面加载 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. 班级卡片或列表存在 list_check = {"name": "班级卡片/列表存在", "passed": False, "detail": ""} try: # 教师我的班级页面通常显示班级卡片 cards = page.locator('[class*="card"], [class*="grid"]').count() table = page.locator("table").count() empty_state = page.locator('[class*="empty"]').count() if cards > 0 or table > 0 or empty_state > 0: list_check["passed"] = True list_check["detail"] = f"找到卡片/表格/空状态(cards: {cards}, tables: {table}, empty: {empty_state})" else: list_check["detail"] = "未找到班级卡片或列表" except Exception as e: list_check["detail"] = f"异常: {e}" result["operations"].append(list_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": [], "view_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 CLASS_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_admin_classes_crud(page, role)) role_result["crud_tests"].append(test_management_classes_crud(page, role)) # 测试教师班级查看 role_result["view_tests"].append(test_teacher_classes_view(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 for view in role_data.get("view_tests", []): if view["status"] == "skipped": continue total += 1 if view["status"] == "passed": passed += 1 elif view["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 | student | parent |") lines.append("|--------|------|--------|-------|---------|---------|--------|") lines.append("| 班级管理(admin) | /admin/school/classes | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |") lines.append("| 年级班级管理 | /management/grade/classes | GRADE_MANAGE | ✅ 完整CRUD | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 |") lines.append("| 教师班级 | /teacher/classes | EXAM_CREATE | ✅ 查看 | ✅ 查看 | ❌ 拒绝 | ❌ 拒绝 |") lines.append("| 教师我的班级 | /teacher/classes/my | EXAM_CREATE | ✅ 查看 | ✅ 查看 | ❌ 拒绝 | ❌ 拒绝 |") 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("") 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("view_tests"): lines.append("#### 查看功能测试") lines.append("") lines.append("| 状态 | 功能 | 操作 | 详情 |") lines.append("|------|------|------|------|") for view in role_data["view_tests"]: if view["status"] == "skipped": lines.append(f"| ⏭️ | {view['feature']} | 跳过 | {view['errors'][0] if view['errors'] else '-'} |") continue status_icon = "✅" if view["status"] == "passed" else "⚠️" if view["status"] == "warning" else "❌" for op in view.get("operations", []): op_icon = "✅" if op["passed"] else "❌" lines.append(f"| {status_icon} | {view['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 view in role_data.get("view_tests", []): if view["status"] == "failed": failed_items.append({"role": role, "type": "查看", "detail": view}) 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/classes(班级 CRUD)") lines.append("- teacher(兼 grade_head)角色可以访问 /management/grade/classes(班级 CRUD)和 /teacher/classes/my(查看)") 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()