""" 备课模块(lesson-preparation)全功能 Web 测试脚本 使用 Playwright 对所有角色的备课模块访问与功能进行测试 覆盖: - admin(只读访问 /teacher/lesson-plans) - teacher(完整 CRUD + 节点编辑器 + 版本管理 + 模板 + 复制 + 归档) - student(应被拒绝访问 /teacher/lesson-plans) - parent(应被拒绝访问 /teacher/lesson-plans) 结果输出:webtest/lesson-preparation_v1.md 与 webtest/lesson-preparation_v1.json """ import json import os import re import sys 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 = "lesson-preparation" # 测试账号(来自 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"}, } # 备课模块路由 LESSON_PLAN_ROUTES = { "list": "/teacher/lesson-plans", "new": "/teacher/lesson-plans/new", } 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": "备课 (lesson-preparation)", "version": VERSION, "base_url": BASE_URL, "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() # 等待 URL 离开 /login(客户端导航,不能用 networkidle) try: page.wait_for_url(lambda url: "/login" not in url, timeout=20000) except PlaywrightTimeout: pass page.wait_for_timeout(3000) print(f" 登录后 URL: {page.url}") if "/login" in page.url: print(f" ❌ {role} 登录失败") return False # parent 账号可能被重定向到 onboarding if "/onboarding" in page.url: print(f" ⚠️ {role} 需要完成 onboarding,跳过该账号测试") 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) results["console_errors_global"].append({"role": "current", "error": text}) page.on("console", on_console) return errors, on_console # ============ 角色访问权限测试 ============ def test_role_access(page, role: str) -> dict: """测试单个角色对备课模块的访问权限""" role_result = { "role": role, "login_success": False, "list_page": {"status": "unknown", "http_status": None, "final_url": None, "errors": [], "warnings": []}, "new_page": {"status": "unknown", "http_status": None, "final_url": None, "errors": [], "warnings": []}, "checks": [], "screenshots": [], } print(f"\n=== 测试 {role} 访问备课模块 ===") if not login(page, role): role_result["errors"] = [f"{role} 登录失败"] return role_result role_result["login_success"] = True # 访问列表页 url = f"{BASE_URL}/teacher/lesson-plans" console_errors, on_console = collect_console_errors(page) 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 role_result["list_page"]["http_status"] = http_status role_result["list_page"]["final_url"] = final_url # 截图 screenshot_name = f"access_{role}_list.png" page.screenshot(path=str(SCREENSHOT_DIR / screenshot_name), full_page=True) role_result["screenshots"].append(screenshot_name) # 判定状态 if _is_login_redirect(final_url): role_result["list_page"]["status"] = "failed" role_result["list_page"]["errors"].append("重定向到登录页") elif _is_forbidden_redirect(final_url): # 对于 student/parent 这是预期行为 if role in ("student", "parent"): role_result["list_page"]["status"] = "passed" role_result["checks"].append({ "name": "无权限访问被正确拦截", "passed": True, "detail": f"重定向到 {final_url}" }) else: role_result["list_page"]["status"] = "failed" role_result["list_page"]["errors"].append(f"权限不足被重定向到 {final_url}") elif http_status and http_status >= 500: role_result["list_page"]["status"] = "failed" role_result["list_page"]["errors"].append(f"HTTP {http_status}") elif http_status and http_status >= 400: role_result["list_page"]["status"] = "failed" role_result["list_page"]["errors"].append(f"HTTP {http_status}") else: # 成功访问 if role in ("admin", "teacher"): role_result["list_page"]["status"] = "passed" role_result["checks"].append({ "name": "有权访问备课列表页", "passed": True, "detail": f"HTTP {http_status}" }) else: role_result["list_page"]["status"] = "failed" role_result["list_page"]["errors"].append("无权限用户却成功访问了备课列表页") if console_errors: role_result["list_page"]["warnings"].append(f"控制台错误 {len(console_errors)} 条") if role_result["list_page"]["status"] == "passed": role_result["list_page"]["status"] = "warning" except PlaywrightTimeout: role_result["list_page"]["status"] = "failed" role_result["list_page"]["errors"].append("页面加载超时") except Exception as e: role_result["list_page"]["status"] = "failed" role_result["list_page"]["errors"].append(str(e)[:200]) finally: try: page.remove_listener("console", on_console) except Exception: pass # 访问新建页(仅对 admin/teacher 测试) if role in ("admin", "teacher") and role_result["list_page"]["status"] in ("passed", "warning"): url = f"{BASE_URL}/teacher/lesson-plans/new" console_errors, on_console = collect_console_errors(page) 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 role_result["new_page"]["http_status"] = http_status role_result["new_page"]["final_url"] = final_url screenshot_name = f"access_{role}_new.png" page.screenshot(path=str(SCREENSHOT_DIR / screenshot_name), full_page=True) role_result["screenshots"].append(screenshot_name) if _is_login_redirect(final_url) or _is_forbidden_redirect(final_url): role_result["new_page"]["status"] = "failed" role_result["new_page"]["errors"].append(f"重定向到 {final_url}") elif http_status and http_status >= 400: role_result["new_page"]["status"] = "failed" role_result["new_page"]["errors"].append(f"HTTP {http_status}") else: role_result["new_page"]["status"] = "passed" if console_errors: role_result["new_page"]["warnings"].append(f"控制台错误 {len(console_errors)} 条") except Exception as e: role_result["new_page"]["status"] = "failed" role_result["new_page"]["errors"].append(str(e)[:200]) finally: try: page.remove_listener("console", on_console) except Exception: pass return role_result # ============ 教师完整功能测试 ============ def test_teacher_full_features(page: dict) -> dict: """测试教师对备课模块的完整功能:列表、新建、编辑、版本、复制、归档""" feature_result = { "role": "teacher", "features": {}, "screenshots": [], "errors": [], "warnings": [], } print("\n=== 测试教师备课模块完整功能 ===") # ---- 1. 列表页功能 ---- print("\n>>> [1/6] 测试列表页功能...") list_feature = {"name": "列表页", "status": "unknown", "checks": [], "errors": []} try: page.goto(f"{BASE_URL}/teacher/lesson-plans", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 检查标题 body_text = page.locator("body").text_content() or "" title_keys = ["备课", "Lesson Plan", "课案"] title_match = any(k in body_text for k in title_keys) list_feature["checks"].append({ "name": "页面标题包含'备课'/'Lesson Plan'", "passed": title_match, "detail": f"body 长度 {len(body_text)}" }) # 检查"新建"按钮 new_btn = page.locator('a[href="/teacher/lesson-plans/new"]') new_btn_count = new_btn.count() list_feature["checks"].append({ "name": "存在'新建课案'按钮", "passed": new_btn_count > 0, "detail": f"找到 {new_btn_count} 个" }) # 检查筛选器 filter_inputs = page.locator('input, select').count() list_feature["checks"].append({ "name": "存在筛选器", "passed": filter_inputs > 0, "detail": f"找到 {filter_inputs} 个 input/select" }) # 检查课案卡片(如果已有数据) cards = page.locator('a[href*="/teacher/lesson-plans/"]').count() list_feature["checks"].append({ "name": "课案卡片或空状态", "passed": True, "detail": f"找到 {cards} 个课案链接" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_list.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_list.png") list_feature["status"] = "passed" print(" ✅ 列表页测试通过") except Exception as e: list_feature["status"] = "failed" list_feature["errors"].append(str(e)[:200]) print(f" ❌ 列表页测试失败: {e}") feature_result["features"]["list"] = list_feature # ---- 2. 新建课案 ---- print("\n>>> [2/6] 测试新建课案...") create_feature = {"name": "新建课案", "status": "unknown", "checks": [], "errors": []} created_plan_id = None try: page.goto(f"{BASE_URL}/teacher/lesson-plans/new", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 检查模板选择器 template_buttons = page.locator('button[type="button"]') template_count = template_buttons.count() create_feature["checks"].append({ "name": "存在模板选择按钮", "passed": template_count > 0, "detail": f"找到 {template_count} 个按钮" }) # 输入标题 - 使用更精确的选择器(required 属性的 input) title_input = page.locator('input[required]') if title_input.count() == 0: title_input = page.locator('input').first title_input.fill("【测试】自动化测试课案") page.wait_for_timeout(300) # 选择第一个模板(tpl_regular)- 使用更精确的选择器 # 模板按钮有 text-left 类名和 border-2 template_btn = page.locator('button[type="button"][class*="text-left"]').first if template_btn.count() == 0: # 回退:选择所有 type=button 且不是其他功能的按钮 template_btn = template_buttons.first template_btn.click() page.wait_for_timeout(500) # 验证模板已选中(按钮应有 border-primary 类) selected_btn = page.locator('button[type="button"][class*="border-primary"]') create_feature["checks"].append({ "name": "模板选中状态可视化", "passed": selected_btn.count() > 0, "detail": f"选中 {selected_btn.count()} 个" }) # 点击创建按钮 submit_btn = page.locator('button[type="submit"]') if submit_btn.count() == 0: submit_btn = page.locator('button').filter(has_text=re.compile(r"创建|Create|新建")) # 等待按钮可用 try: page.wait_for_selector('button[type="submit"]:not([disabled])', timeout=5000) except PlaywrightTimeout: pass submit_btn.click() # 等待跳转到编辑页 page.wait_for_timeout(2000) try: page.wait_for_url(re.compile(r'/teacher/lesson-plans/[^/]+/edit'), timeout=20000) except PlaywrightTimeout: page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) final_url = page.url # 提取 planId match = re.search(r'/teacher/lesson-plans/([^/]+)/edit', final_url) if match: created_plan_id = match.group(1) create_feature["checks"].append({ "name": "创建后跳转到编辑页", "passed": True, "detail": f"planId={created_plan_id}" }) else: create_feature["checks"].append({ "name": "创建后跳转到编辑页", "passed": False, "detail": f"URL={final_url}" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_create.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_create.png") create_feature["status"] = "passed" if created_plan_id else "failed" print(f" ✅ 新建课案成功 (planId={created_plan_id})" if created_plan_id else " ❌ 新建课案失败") except Exception as e: create_feature["status"] = "failed" create_feature["errors"].append(str(e)[:200]) print(f" ❌ 新建课案失败: {e}") feature_result["features"]["create"] = create_feature # ---- 3. 编辑器功能(节点图) ---- print("\n>>> [3/6] 测试编辑器功能...") editor_feature = {"name": "编辑器", "status": "unknown", "checks": [], "errors": []} if created_plan_id: try: # 已经在编辑页 page.wait_for_timeout(1500) # 检查标题输入框 title_input = page.locator('input').first title_value = title_input.input_value() if title_input.count() > 0 else "" editor_feature["checks"].append({ "name": "标题输入框存在且有值", "passed": bool(title_value), "detail": f"标题='{title_value}'" }) # 检查 React Flow 节点画布 rf_nodes = page.locator('.react-flow__node').count() editor_feature["checks"].append({ "name": "React Flow 节点渲染", "passed": rf_nodes > 0, "detail": f"渲染 {rf_nodes} 个节点" }) # 检查 React Flow 边 rf_edges = page.locator('.react-flow__edge').count() editor_feature["checks"].append({ "name": "React Flow 边渲染", "passed": rf_edges > 0 or rf_nodes <= 1, "detail": f"渲染 {rf_edges} 条边" }) # 检查工具栏按钮 save_btn = page.locator('button').filter(has_text=re.compile(r"保存|Save")) version_btn = page.locator('button').filter(has_text=re.compile(r"版本|History|Version")) add_node_btn = page.locator('button').filter(has_text=re.compile(r"添加|Add|节点|Node")) editor_feature["checks"].append({ "name": "存在保存版本按钮", "passed": save_btn.count() > 0, "detail": f"找到 {save_btn.count()} 个" }) editor_feature["checks"].append({ "name": "存在版本历史按钮", "passed": version_btn.count() > 0, "detail": f"找到 {version_btn.count()} 个" }) editor_feature["checks"].append({ "name": "存在添加节点按钮", "passed": add_node_btn.count() > 0, "detail": f"找到 {add_node_btn.count()} 个" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_editor.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_editor.png") editor_feature["status"] = "passed" print(f" ✅ 编辑器测试通过 ({rf_nodes} 节点, {rf_edges} 边)") except Exception as e: editor_feature["status"] = "failed" editor_feature["errors"].append(str(e)[:200]) print(f" ❌ 编辑器测试失败: {e}") else: editor_feature["status"] = "skipped" editor_feature["errors"].append("无创建的课案,跳过编辑器测试") print(" ⏭️ 跳过编辑器测试(无创建的课案)") feature_result["features"]["editor"] = editor_feature # ---- 4. 节点选中与侧边面板 ---- print("\n>>> [4/6] 测试节点选中与侧边面板...") panel_feature = {"name": "节点选中与侧边面板", "status": "unknown", "checks": [], "errors": []} if created_plan_id and editor_feature["status"] == "passed": try: # 点击第一个节点 first_node = page.locator('.react-flow__node').first if first_node.count() > 0: first_node.click() page.wait_for_timeout(1000) # 检查侧边面板是否出现 panel = page.locator('aside, [class*="panel"], [class*="sidebar"]').count() # 也检查具体的 NodeEditPanel(通常宽度 420px) panel_text = page.locator('body').text_content() or "" # 检查是否有 Block 编辑相关元素 has_block_editor = ( page.locator('textarea, [contenteditable="true"], input[type="text"]').count() > 0 ) panel_feature["checks"].append({ "name": "点击节点后出现侧边面板", "passed": has_block_editor, "detail": f"找到编辑元素: {has_block_editor}" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_panel.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_panel.png") panel_feature["status"] = "passed" if has_block_editor else "failed" print(f" ✅ 侧边面板测试 {'通过' if has_block_editor else '失败'}") else: panel_feature["status"] = "skipped" panel_feature["errors"].append("无节点可点击") print(" ⏭️ 跳过侧边面板测试(无节点)") except Exception as e: panel_feature["status"] = "failed" panel_feature["errors"].append(str(e)[:200]) print(f" ❌ 侧边面板测试失败: {e}") else: panel_feature["status"] = "skipped" panel_feature["errors"].append("前置条件不满足") print(" ⏭️ 跳过侧边面板测试") feature_result["features"]["panel"] = panel_feature # ---- 5. 版本历史 ---- print("\n>>> [5/6] 测试版本历史...") version_feature = {"name": "版本历史", "status": "unknown", "checks": [], "errors": []} if created_plan_id: try: # 点击版本历史按钮 version_btn = page.locator('button').filter(has_text=re.compile(r"版本|History|Version")) if version_btn.count() > 0: version_btn.first.click() page.wait_for_timeout(1500) # 检查抽屉是否打开 drawer = page.locator('[class*="drawer"], [class*="sheet"], [role="dialog"]').count() version_feature["checks"].append({ "name": "版本历史抽屉打开", "passed": drawer > 0, "detail": f"找到 {drawer} 个 dialog/drawer" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_versions.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_versions.png") # 关闭抽屉(按 Escape) page.keyboard.press("Escape") page.wait_for_timeout(800) version_feature["status"] = "passed" print(" ✅ 版本历史测试通过") else: version_feature["status"] = "failed" version_feature["errors"].append("未找到版本历史按钮") print(" ❌ 未找到版本历史按钮") except Exception as e: version_feature["status"] = "failed" version_feature["errors"].append(str(e)[:200]) print(f" ❌ 版本历史测试失败: {e}") else: version_feature["status"] = "skipped" print(" ⏭️ 跳过版本历史测试") feature_result["features"]["version"] = version_feature # ---- 6. 复制与归档(在列表页测试)---- print("\n>>> [6/6] 测试复制与归档...") duplicate_archive_feature = {"name": "复制与归档", "status": "unknown", "checks": [], "errors": []} try: # 回到列表页 page.goto(f"{BASE_URL}/teacher/lesson-plans", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 查找课案卡片 cards = page.locator('a[href*="/teacher/lesson-plans/"]').count() duplicate_archive_feature["checks"].append({ "name": "列表页存在课案卡片", "passed": cards > 0, "detail": f"找到 {cards} 个" }) if cards > 0: # 检查复制按钮 duplicate_btn = page.locator('button').filter(has_text=re.compile(r"复制|Duplicate")) duplicate_archive_feature["checks"].append({ "name": "存在复制按钮", "passed": duplicate_btn.count() > 0, "detail": f"找到 {duplicate_btn.count()} 个" }) # 检查归档按钮 archive_btn = page.locator('button').filter(has_text=re.compile(r"归档|Archive")) duplicate_archive_feature["checks"].append({ "name": "存在归档按钮", "passed": archive_btn.count() > 0, "detail": f"找到 {archive_btn.count()} 个" }) # 测试复制功能 if duplicate_btn.count() > 0: initial_cards = page.locator('a[href*="/teacher/lesson-plans/"]').count() duplicate_btn.first.click() page.wait_for_timeout(2000) page.wait_for_load_state("networkidle", timeout=10000) page.wait_for_timeout(1000) after_cards = page.locator('a[href*="/teacher/lesson-plans/"]').count() duplicate_archive_feature["checks"].append({ "name": "复制后课案数量增加", "passed": after_cards > initial_cards, "detail": f"复制前 {initial_cards} → 复制后 {after_cards}" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_duplicate.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_duplicate.png") duplicate_archive_feature["status"] = "passed" print(" ✅ 复制与归档测试通过") else: duplicate_archive_feature["status"] = "warning" duplicate_archive_feature["errors"].append("列表页无课案卡片") print(" ⚠️ 列表页无课案卡片") except Exception as e: duplicate_archive_feature["status"] = "failed" duplicate_archive_feature["errors"].append(str(e)[:200]) print(f" ❌ 复制与归档测试失败: {e}") feature_result["features"]["duplicate_archive"] = duplicate_archive_feature return feature_result # ============ 主流程 ============ def update_summary(role_result: dict): """根据角色测试结果更新汇总""" results["summary"]["total"] += 1 statuses = [] if "list_page" in role_result: statuses.append(role_result["list_page"]["status"]) if "new_page" in role_result: statuses.append(role_result["new_page"]["status"]) if "features" in role_result: for f in role_result["features"].values(): statuses.append(f.get("status", "unknown")) if any(s == "failed" for s in statuses): results["summary"]["failed"] += 1 elif any(s == "warning" for s in statuses): results["summary"]["warnings"] += 1 elif any(s == "passed" for s in statuses): results["summary"]["passed"] += 1 def generate_markdown_report() -> str: """生成 Markdown 测试报告""" md = [] md.append(f"# 备课模块(lesson-preparation)Web 测试报告 {VERSION}") md.append("") md.append(f"> 测试日期:{results['test_date']}") md.append(f"> 模块:{results['module']}") md.append(f"> Base URL:{results['base_url']}") md.append(f"> 测试方式:Playwright 自动化测试") md.append("") md.append("---") md.append("") md.append("## 一、测试概览") md.append("") s = results["summary"] md.append(f"| 指标 | 数值 |") md.append(f"|------|------|") md.append(f"| 测试角色总数 | {s['total']} |") md.append(f"| 通过 | {s['passed']} |") md.append(f"| 失败 | {s['failed']} |") md.append(f"| 警告 | {s['warnings']} |") md.append("") md.append("### 测试覆盖范围") md.append("") md.append("- **角色覆盖**:admin / teacher / student / parent") md.append("- **路由覆盖**:`/teacher/lesson-plans`(列表)、`/teacher/lesson-plans/new`(新建)、`/teacher/lesson-plans/[planId]/edit`(编辑)") md.append("- **功能覆盖**:列表查看、模板选择、新建课案、节点图画布、侧边面板、版本历史、复制、归档") md.append("- **权限测试**:student/parent 应被拒绝访问 `/teacher/*` 路由") md.append("") md.append("---") md.append("") md.append("## 二、角色访问权限测试") md.append("") md.append("| 角色 | 登录 | 列表页 | 新建页 | 备注 |") md.append("|------|------|--------|--------|------|") for role, info in results["roles"].items(): login_ok = "✅" if info.get("login_success") else "❌" list_status = info.get("list_page", {}).get("status", "unknown") new_status = info.get("new_page", {}).get("status", "unknown") list_icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(list_status, "❓") new_icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(new_status, "❓") note = "" if role in ("student", "parent"): note = "应被拒绝(重定向到自己的 dashboard)" elif role == "admin": note = "只读访问" elif role == "teacher": note = "完整功能" md.append(f"| {role} | {login_ok} | {list_icon} | {new_icon} | {note} |") md.append("") md.append("---") md.append("") md.append("## 三、教师完整功能测试详情") md.append("") teacher_features = results.get("teacher_features", {}) if teacher_features.get("features"): md.append("| 功能 | 状态 | 检查项数 | 通过数 | 错误 |") md.append("|------|------|----------|--------|------|") for key, f in teacher_features["features"].items(): status = f.get("status", "unknown") icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(status, "❓") checks = f.get("checks", []) passed = sum(1 for c in checks if c.get("passed")) errors = "; ".join(f.get("errors", [])) if f.get("errors") else "-" md.append(f"| {f.get('name', key)} | {icon} | {len(checks)} | {passed} | {errors} |") md.append("") md.append("### 详细检查项") md.append("") for key, f in teacher_features["features"].items(): md.append(f"#### {f.get('name', key)}") md.append("") md.append(f"- **状态**:{f.get('status', 'unknown')}") if f.get("checks"): md.append("- **检查项**:") for c in f["checks"]: icon = "✅" if c.get("passed") else "❌" md.append(f" - {icon} {c['name']}({c.get('detail', '')})") if f.get("errors"): md.append("- **错误**:") for e in f["errors"]: md.append(f" - {e}") md.append("") else: md.append("教师功能测试未执行或失败。") md.append("") md.append("---") md.append("") md.append("## 四、控制台错误汇总") md.append("") if results["console_errors_global"]: md.append(f"共收集到 {len(results['console_errors_global'])} 条控制台错误:") md.append("") for err in results["console_errors_global"][:20]: md.append(f"- `{err.get('error', '')[:200]}`") if len(results["console_errors_global"]) > 20: md.append(f"- ... 还有 {len(results['console_errors_global']) - 20} 条") else: md.append("✅ 无控制台错误") md.append("") md.append("---") md.append("") md.append("## 五、测试截图") md.append("") md.append(f"截图保存在 `webtest/screenshots/{MODULE_NAME}/` 目录下:") md.append("") for role, info in results["roles"].items(): for shot in info.get("screenshots", []): md.append(f"- `{shot}`") if teacher_features.get("screenshots"): for shot in teacher_features["screenshots"]: md.append(f"- `{shot}`") md.append("") md.append("---") md.append("") md.append("## 六、测试结论") md.append("") s = results["summary"] if s["failed"] == 0 and s["warnings"] == 0: md.append("✅ **所有测试通过**。备课模块在所有角色下功能正常。") elif s["failed"] == 0: md.append(f"⚠️ **测试通过但有 {s['warnings']} 个警告**。建议检查警告项。") else: md.append(f"❌ **{s['failed']} 个测试失败**。需要修复以下问题:") md.append("") for role, info in results["roles"].items(): list_err = info.get("list_page", {}).get("errors", []) new_err = info.get("new_page", {}).get("errors", []) if list_err or new_err: md.append(f"### {role}") for e in list_err: md.append(f"- 列表页:{e}") for e in new_err: md.append(f"- 新建页:{e}") md.append("") if teacher_features.get("features"): for key, f in teacher_features["features"].items(): if f.get("status") == "failed" and f.get("errors"): md.append(f"### 教师功能 - {f.get('name', key)}") for e in f["errors"]: md.append(f"- {e}") md.append("") md.append("") md.append("---") md.append("") md.append("## 七、附录:测试账号") md.append("") md.append("| 角色 | 邮箱 | 预期路径 |") md.append("|------|------|----------|") for role, acc in TEST_ACCOUNTS.items(): md.append(f"| {role} | {acc['email']} | {acc['expected_path']} |") md.append("") return "\n".join(md) def main(): print("=" * 60) print(f"备课模块(lesson-preparation)Web 测试 - {VERSION}") print("=" * 60) with sync_playwright() as p: browser = p.chromium.launch(headless=True) # ---- 测试 4 个角色的访问权限 ---- for role in ["admin", "teacher", "student", "parent"]: context = browser.new_context() page = context.new_page() try: role_result = test_role_access(page, role) results["roles"][role] = role_result update_summary(role_result) except Exception as e: print(f"❌ {role} 测试异常: {e}") results["roles"][role] = { "role": role, "login_success": False, "errors": [str(e)[:200]], } results["summary"]["total"] += 1 results["summary"]["failed"] += 1 finally: context.close() # ---- 教师完整功能测试 ---- context = browser.new_context() page = context.new_page() try: if not login(page, "teacher"): print("❌ 教师登录失败,跳过完整功能测试") results["teacher_features"] = {"features": {}, "errors": ["教师登录失败"]} else: teacher_features = test_teacher_full_features(page) results["teacher_features"] = teacher_features # 教师功能测试结果也计入汇总 feature_failed = sum(1 for f in teacher_features.get("features", {}).values() if f.get("status") == "failed") if feature_failed > 0: results["summary"]["failed"] += feature_failed except Exception as e: print(f"❌ 教师功能测试异常: {e}") results["teacher_features"] = {"features": {}, "errors": [str(e)[:200]]} results["summary"]["failed"] += 1 finally: context.close() browser.close() # 生成报告 print("\n" + "=" * 60) print("生成测试报告...") print("=" * 60) md_report = generate_markdown_report() md_path = WEBTEST_DIR / f"{MODULE_NAME}_{VERSION}.md" md_path.write_text(md_report, encoding="utf-8") print(f"✅ Markdown 报告: {md_path}") json_path = WEBTEST_DIR / f"{MODULE_NAME}_{VERSION}.json" json_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") print(f"✅ JSON 报告: {json_path}") # 打印汇总 print("\n" + "=" * 60) print("测试汇总") print("=" * 60) s = results["summary"] print(f" 总计: {s['total']}") print(f" 通过: {s['passed']}") print(f" 失败: {s['failed']}") print(f" 警告: {s['warnings']}") print(f"\n详细报告: {md_path}") # 失败时返回非零退出码 if s["failed"] > 0: sys.exit(1) if __name__ == "__main__": main()