""" 教材模块(textbooks)全功能 Web 测试脚本 使用 Playwright 对所有角色的教材模块访问与功能进行测试 覆盖: - admin(管理员:有 TEXTBOOK_* 全权限,但导航无教材入口;测试 /teacher/textbooks 访问) - teacher(教师:完整 CRUD + 章节 + 知识点 + 知识图谱 + 设置/删除) - student(学生:只读访问 /student/learning/textbooks,按年级过滤,无写操作按钮) - parent(家长:有 TEXTBOOK_READ 权限但无路由入口;应被拒绝访问 /teacher/textbooks) 结果输出:webtest/textbooks_v1.md 与 webtest/textbooks_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 = "textbooks" # 测试账号(来自 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"}, } # 教材模块路由 TEXTBOOK_ROUTES = { "teacher_list": "/teacher/textbooks", "student_list": "/student/learning/textbooks", } 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": "教材 (textbooks)", "version": VERSION, "base_url": BASE_URL, "summary": { "total": 0, "passed": 0, "failed": 0, "warnings": 0, }, "roles": {}, "teacher_features": {}, "student_features": {}, "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']})...") for attempt in range(3): try: page.goto(f"{BASE_URL}/login", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) 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 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=20000) page.wait_for_timeout(2000) print(f" 登录后 URL: {page.url}") if "/login" in page.url: print(f" ⚠️ {role} 登录失败 (尝试 {attempt + 1}/3)") if attempt < 2: page.context.clear_cookies() page.wait_for_timeout(2000) continue return False return True except Exception as e: print(f" ⚠️ {role} 登录异常 (尝试 {attempt + 1}/3): {e}") if attempt < 2: page.wait_for_timeout(2000) continue return False return False def logout(page): """退出当前登录状态""" page.context.clear_cookies() print(" 已清除 cookies 退出登录") def collect_console_errors(page, role: str = "current"): """附加控制台错误收集器""" 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": role, "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, "teacher_list_page": {"status": "unknown", "http_status": None, "final_url": None, "errors": [], "warnings": []}, "student_list_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 # 访问教师端教材列表页 /teacher/textbooks url = f"{BASE_URL}/teacher/textbooks" console_errors, on_console = collect_console_errors(page, 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 role_result["teacher_list_page"]["http_status"] = http_status role_result["teacher_list_page"]["final_url"] = final_url screenshot_name = f"access_{role}_teacher_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["teacher_list_page"]["status"] = "failed" role_result["teacher_list_page"]["errors"].append("重定向到登录页") elif _is_forbidden_redirect(final_url): # 对于 student/parent 这是预期行为 if role in ("student", "parent"): role_result["teacher_list_page"]["status"] = "passed" role_result["checks"].append({ "name": f"{role} 无权限访问教师教材页被正确拦截", "passed": True, "detail": f"重定向到 {final_url}" }) else: role_result["teacher_list_page"]["status"] = "failed" role_result["teacher_list_page"]["errors"].append(f"权限不足被重定向到 {final_url}") elif http_status and http_status >= 500: role_result["teacher_list_page"]["status"] = "failed" role_result["teacher_list_page"]["errors"].append(f"HTTP {http_status}") elif http_status and http_status >= 400: role_result["teacher_list_page"]["status"] = "failed" role_result["teacher_list_page"]["errors"].append(f"HTTP {http_status}") else: # 成功访问 if role in ("admin", "teacher"): role_result["teacher_list_page"]["status"] = "passed" role_result["checks"].append({ "name": f"{role} 有权访问教师教材列表页", "passed": True, "detail": f"HTTP {http_status}" }) else: role_result["teacher_list_page"]["status"] = "failed" role_result["teacher_list_page"]["errors"].append("无权限用户却成功访问了教师教材列表页") if console_errors: role_result["teacher_list_page"]["warnings"].append(f"控制台错误 {len(console_errors)} 条") if role_result["teacher_list_page"]["status"] == "passed": role_result["teacher_list_page"]["status"] = "warning" except PlaywrightTimeout: role_result["teacher_list_page"]["status"] = "failed" role_result["teacher_list_page"]["errors"].append("页面加载超时") except Exception as e: role_result["teacher_list_page"]["status"] = "failed" role_result["teacher_list_page"]["errors"].append(str(e)[:200]) finally: try: page.remove_listener("console", on_console) except Exception: pass # 访问学生端教材列表页 /student/learning/textbooks url = f"{BASE_URL}/student/learning/textbooks" console_errors, on_console = collect_console_errors(page, 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 role_result["student_list_page"]["http_status"] = http_status role_result["student_list_page"]["final_url"] = final_url screenshot_name = f"access_{role}_student_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["student_list_page"]["status"] = "failed" role_result["student_list_page"]["errors"].append("重定向到登录页") elif _is_forbidden_redirect(final_url): if role in ("admin", "teacher", "parent"): # admin/teacher/parent 访问学生端教材页被拦截是合理的 role_result["student_list_page"]["status"] = "passed" role_result["checks"].append({ "name": f"{role} 访问学生端教材页被拦截(符合预期)", "passed": True, "detail": f"重定向到 {final_url}" }) else: role_result["student_list_page"]["status"] = "failed" role_result["student_list_page"]["errors"].append(f"权限不足被重定向到 {final_url}") elif http_status and http_status >= 500: role_result["student_list_page"]["status"] = "failed" role_result["student_list_page"]["errors"].append(f"HTTP {http_status}") elif http_status and http_status >= 400: role_result["student_list_page"]["status"] = "failed" role_result["student_list_page"]["errors"].append(f"HTTP {http_status}") else: # 成功访问 if role == "student": role_result["student_list_page"]["status"] = "passed" role_result["checks"].append({ "name": "学生有权访问学生端教材列表页", "passed": True, "detail": f"HTTP {http_status}" }) else: # 其他角色访问学生端教材页:可能被重定向到自己的 dashboard,也算通过 role_result["student_list_page"]["status"] = "passed" role_result["checks"].append({ "name": f"{role} 访问学生端教材页(重定向到自己的 dashboard)", "passed": True, "detail": f"final_url={final_url}" }) if console_errors: role_result["student_list_page"]["warnings"].append(f"控制台错误 {len(console_errors)} 条") if role_result["student_list_page"]["status"] == "passed": role_result["student_list_page"]["status"] = "warning" except PlaywrightTimeout: role_result["student_list_page"]["status"] = "failed" role_result["student_list_page"]["errors"].append("页面加载超时") except Exception as e: role_result["student_list_page"]["status"] = "failed" role_result["student_list_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: """测试教师对教材模块的完整功能:列表、筛选、新建、详情、章节、知识点、设置、删除""" feature_result = { "role": "teacher", "features": {}, "screenshots": [], "errors": [], "warnings": [], "created_textbook_id": None, } print("\n=== 测试教师教材模块完整功能 ===") # ---- 1. 列表页功能 ---- print("\n>>> [1/8] 测试列表页功能...") list_feature = {"name": "列表页", "status": "unknown", "checks": [], "errors": []} try: page.goto(f"{BASE_URL}/teacher/textbooks", 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 = ["教材", "Textbook", "textbook"] title_match = any(k in body_text for k in title_keys) list_feature["checks"].append({ "name": "页面标题包含'教材'/'Textbook'", "passed": title_match, "detail": f"body 长度 {len(body_text)}" }) # 检查"新建教材"按钮 new_btn = page.locator('button').filter(has_text=re.compile(r"新建教材|新建|Add|Create")) 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/textbooks/"]').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/8] 测试筛选功能...") filter_feature = {"name": "筛选", "status": "unknown", "checks": [], "errors": []} try: # 测试搜索筛选 search_input = page.locator('input[placeholder*="搜索"], input[placeholder*="search"]').first if search_input.count() > 0: search_input.fill("数学") page.wait_for_timeout(1500) # 等待 URL 更新和页面刷新 page.wait_for_load_state("networkidle", timeout=10000) # 检查 URL 是否包含搜索参数 current_url = page.url has_search_param = "q=" in current_url or "q=数学" in current_url or "q=%E6%95%B0" in current_url filter_feature["checks"].append({ "name": "搜索筛选触发 URL 参数更新", "passed": has_search_param, "detail": f"URL={current_url}" }) # 清除搜索 search_input.fill("") page.keyboard.press("Escape") page.wait_for_timeout(1000) else: filter_feature["checks"].append({ "name": "搜索输入框存在", "passed": False, "detail": "未找到搜索输入框" }) # 测试学科筛选 subject_select = page.locator('button[role="combobox"]').first if subject_select.count() > 0: subject_select.click() page.wait_for_timeout(500) # 检查下拉选项是否出现 options = page.locator('[role="option"]').count() filter_feature["checks"].append({ "name": "学科筛选下拉选项出现", "passed": options > 0, "detail": f"找到 {options} 个选项" }) # 关闭下拉 page.keyboard.press("Escape") page.wait_for_timeout(500) else: filter_feature["checks"].append({ "name": "学科筛选下拉存在", "passed": False, "detail": "未找到学科筛选下拉" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_filter.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_filter.png") filter_feature["status"] = "passed" print(" ✅ 筛选功能测试通过") except Exception as e: filter_feature["status"] = "failed" filter_feature["errors"].append(str(e)[:200]) print(f" ❌ 筛选功能测试失败: {e}") feature_result["features"]["filter"] = filter_feature # ---- 3. 新建教材 ---- print("\n>>> [3/8] 测试新建教材...") create_feature = {"name": "新建教材", "status": "unknown", "checks": [], "errors": []} created_textbook_id = None try: page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 点击"新建教材"按钮 new_btn = page.locator('button').filter(has_text=re.compile(r"新建教材|新建|Add|Create")) if new_btn.count() > 0: new_btn.first.click() page.wait_for_timeout(1000) # 检查对话框是否出现 dialog = page.locator('[role="dialog"]') create_feature["checks"].append({ "name": "新建教材对话框打开", "passed": dialog.count() > 0, "detail": f"找到 {dialog.count()} 个对话框" }) if dialog.count() > 0: # 填写表单 title_input = dialog.locator('input[name="title"]') title_input.fill("【自动化测试】数学教材") # 选择学科 subject_select = dialog.locator('button[role="combobox"]').first if subject_select.count() > 0: subject_select.click() page.wait_for_timeout(500) # 选择第一个选项(数学) first_option = page.locator('[role="option"]').first if first_option.count() > 0: first_option.click() page.wait_for_timeout(500) # 选择年级 grade_select = dialog.locator('button[role="combobox"]').nth(1) if grade_select.count() > 0: grade_select.click() page.wait_for_timeout(500) first_grade = page.locator('[role="option"]').first if first_grade.count() > 0: first_grade.click() page.wait_for_timeout(500) # 填写出版社 publisher_input = dialog.locator('input[name="publisher"]') if publisher_input.count() > 0: publisher_input.fill("测试出版社") page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_create_form.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_create_form.png") # 提交表单 submit_btn = dialog.locator('button[type="submit"]') if submit_btn.count() == 0: submit_btn = dialog.locator('button').filter(has_text=re.compile(r"保存|Save|提交|Submit")) submit_btn.click() # 等待表单提交和页面刷新 page.wait_for_load_state("networkidle", timeout=20000) page.wait_for_timeout(2000) # 检查是否成功创建(toast 消息或列表中存在新教材) body_text = page.locator("body").text_content() or "" success_toast = "成功" in body_text or "success" in body_text.lower() new_textbook_in_list = "【自动化测试】数学教材" in body_text create_feature["checks"].append({ "name": "教材创建成功(toast 或列表显示)", "passed": success_toast or new_textbook_in_list, "detail": f"toast={success_toast}, in_list={new_textbook_in_list}" }) # 查找新创建的教材链接 textbook_links = page.locator('a[href*="/teacher/textbooks/"]') if textbook_links.count() > 0: first_href = textbook_links.first.get_attribute("href") or "" # 提取 textbookId match = re.search(r'/teacher/textbooks/([^/]+)', first_href) if match: created_textbook_id = match.group(1) feature_result["created_textbook_id"] = created_textbook_id create_feature["status"] = "passed" if (success_toast or new_textbook_in_list) else "failed" print(f" ✅ 新建教材成功 (textbookId={created_textbook_id})" if created_textbook_id else " ⚠️ 新建教材结果未知") else: create_feature["status"] = "failed" create_feature["errors"].append("未找到'新建教材'按钮") print(" ❌ 未找到'新建教材'按钮") except Exception as e: create_feature["status"] = "failed" create_feature["errors"].append(str(e)[:200]) print(f" ❌ 新建教材失败: {e}") feature_result["features"]["create"] = create_feature # ---- 4. 教材详情页 ---- print("\n>>> [4/8] 测试教材详情页...") detail_feature = {"name": "教材详情页", "status": "unknown", "checks": [], "errors": []} test_textbook_id = created_textbook_id if not test_textbook_id: # 尝试从列表中找到第一个教材 try: page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) textbook_links = page.locator('a[href*="/teacher/textbooks/"]') if textbook_links.count() > 0: first_href = textbook_links.first.get_attribute("href") or "" match = re.search(r'/teacher/textbooks/([^/]+)', first_href) if match: test_textbook_id = match.group(1) print(f" 使用已有教材 ID: {test_textbook_id}") except Exception: pass if test_textbook_id: try: url = f"{BASE_URL}/teacher/textbooks/{test_textbook_id}" response = page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) http_status = response.status if response else None detail_feature["checks"].append({ "name": "详情页 HTTP 200", "passed": http_status == 200, "detail": f"HTTP {http_status}" }) # 检查返回按钮 back_btn = page.locator('a[href*="/teacher/textbooks"]').filter(has_text=re.compile(r"返回|Back")) detail_feature["checks"].append({ "name": "存在返回按钮", "passed": back_btn.count() > 0, "detail": f"找到 {back_btn.count()} 个" }) # 检查设置按钮 settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings")) detail_feature["checks"].append({ "name": "存在设置按钮", "passed": settings_btn.count() > 0, "detail": f"找到 {settings_btn.count()} 个" }) # 检查 Tab 切换 tabs = page.locator('[role="tab"]') tab_count = tabs.count() detail_feature["checks"].append({ "name": "存在 Tab 切换(章节/知识点/图谱)", "passed": tab_count >= 2, "detail": f"找到 {tab_count} 个 Tab" }) # 检查章节侧边栏 chapter_items = page.locator('[class*="chapter"], [data-testid*="chapter"], .react-flow__node') body_text = page.locator("body").text_content() or "" has_chapter_ui = chapter_items.count() > 0 or "章节" in body_text or "Chapter" in body_text detail_feature["checks"].append({ "name": "章节侧边栏渲染", "passed": has_chapter_ui, "detail": f"找到 {chapter_items.count()} 个章节元素" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_detail.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_detail.png") detail_feature["status"] = "passed" print(" ✅ 教材详情页测试通过") except Exception as e: detail_feature["status"] = "failed" detail_feature["errors"].append(str(e)[:200]) print(f" ❌ 教材详情页测试失败: {e}") else: detail_feature["status"] = "skipped" detail_feature["errors"].append("无可用教材 ID") print(" ⏭️ 跳过详情页测试(无教材)") feature_result["features"]["detail"] = detail_feature # ---- 5. 章节功能 ---- print("\n>>> [5/8] 测试章节功能...") chapter_feature = {"name": "章节功能", "status": "unknown", "checks": [], "errors": []} if test_textbook_id: try: # 确保在详情页 page.goto(f"{BASE_URL}/teacher/textbooks/{test_textbook_id}", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 检查新建章节按钮(在章节侧边栏中) add_chapter_btn = page.locator('button[aria-label*="新建章节"], button[aria-label*="add"], button[aria-label*="创建"]').first chapter_feature["checks"].append({ "name": "存在新建章节按钮", "passed": add_chapter_btn.count() > 0, "detail": f"找到 {add_chapter_btn.count()} 个" }) # 检查章节列表项 chapter_items = page.locator('[class*="chapter"]') chapter_count = chapter_items.count() chapter_feature["checks"].append({ "name": "章节列表渲染", "passed": chapter_count >= 0, # 0 个也算通过(新教材) "detail": f"找到 {chapter_count} 个章节元素" }) # 检查内容面板 content_panel = page.locator('h2, [class*="content"]') chapter_feature["checks"].append({ "name": "内容面板渲染", "passed": content_panel.count() > 0, "detail": f"找到 {content_panel.count()} 个内容元素" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_chapter.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_chapter.png") chapter_feature["status"] = "passed" print(" ✅ 章节功能测试通过") except Exception as e: chapter_feature["status"] = "failed" chapter_feature["errors"].append(str(e)[:200]) print(f" ❌ 章节功能测试失败: {e}") else: chapter_feature["status"] = "skipped" chapter_feature["errors"].append("无可用教材 ID") print(" ⏭️ 跳过章节功能测试") feature_result["features"]["chapter"] = chapter_feature # ---- 6. 知识图谱 Tab ---- print("\n>>> [6/8] 测试知识图谱 Tab...") graph_feature = {"name": "知识图谱", "status": "unknown", "checks": [], "errors": []} if test_textbook_id: try: # 确保在详情页 page.goto(f"{BASE_URL}/teacher/textbooks/{test_textbook_id}", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 先选择一个章节(图谱 Tab 在未选章节时是 disabled) chapter_links = page.locator('[class*="chapter"], [data-testid*="chapter"], button:has-text("章"), a:has-text("章")') if chapter_links.count() > 0: chapter_links.first.click() page.wait_for_timeout(1500) print(f" 已选择第一个章节") # 点击"图谱" Tab graph_tab = page.locator('[role="tab"]').filter(has_text=re.compile(r"图谱|Graph")) if graph_tab.count() > 0: # 检查是否 disabled is_disabled = graph_tab.first.get_attribute("disabled") if is_disabled is not None: graph_feature["checks"].append({ "name": "图谱 Tab 可点击", "passed": False, "detail": f"图谱 Tab 处于 disabled 状态(可能无章节可选)" }) graph_feature["status"] = "warning" print(" ⚠️ 图谱 Tab 处于 disabled 状态") else: graph_tab.first.click() page.wait_for_timeout(1500) # 检查图谱渲染(React Flow 或 SVG) graph_canvas = page.locator('.react-flow, svg, [class*="graph"]') graph_feature["checks"].append({ "name": "知识图谱画布渲染", "passed": graph_canvas.count() > 0, "detail": f"找到 {graph_canvas.count()} 个图谱元素" }) page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_graph.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_graph.png") graph_feature["status"] = "passed" print(" ✅ 知识图谱测试通过") else: graph_feature["checks"].append({ "name": "图谱 Tab 存在", "passed": False, "detail": "未找到图谱 Tab" }) graph_feature["status"] = "warning" print(" ⚠️ 未找到图谱 Tab") except Exception as e: graph_feature["status"] = "failed" graph_feature["errors"].append(str(e)[:200]) print(f" ❌ 知识图谱测试失败: {e}") else: graph_feature["status"] = "skipped" graph_feature["errors"].append("无可用教材 ID") print(" ⏭️ 跳过知识图谱测试") feature_result["features"]["graph"] = graph_feature # ---- 7. 设置对话框(编辑教材)---- print("\n>>> [7/8] 测试设置对话框...") settings_feature = {"name": "设置对话框", "status": "unknown", "checks": [], "errors": []} if test_textbook_id: try: # 确保在详情页 page.goto(f"{BASE_URL}/teacher/textbooks/{test_textbook_id}", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 点击设置按钮 settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings")) if settings_btn.count() > 0: settings_btn.first.click() page.wait_for_timeout(1000) # 检查对话框 dialog = page.locator('[role="dialog"]') settings_feature["checks"].append({ "name": "设置对话框打开", "passed": dialog.count() > 0, "detail": f"找到 {dialog.count()} 个对话框" }) if dialog.count() > 0: # 检查表单字段 title_input = dialog.locator('input[name="title"]') settings_feature["checks"].append({ "name": "标题输入框存在且有默认值", "passed": title_input.count() > 0, "detail": f"找到 {title_input.count()} 个" }) # 检查删除按钮 delete_btn = dialog.locator('button').filter(has_text=re.compile(r"删除|Delete")) settings_feature["checks"].append({ "name": "存在删除按钮", "passed": delete_btn.count() > 0, "detail": f"找到 {delete_btn.count()} 个" }) # 修改标题 if title_input.count() > 0: title_input.fill("【自动化测试】数学教材-已修改") page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_settings.png"), full_page=True) feature_result["screenshots"].append("teacher_feature_settings.png") # 保存修改 save_btn = dialog.locator('button[type="submit"]') if save_btn.count() == 0: save_btn = dialog.locator('button').filter(has_text=re.compile(r"保存|Save")) if save_btn.count() > 0: save_btn.click() page.wait_for_timeout(2000) page.wait_for_load_state("networkidle", timeout=10000) settings_feature["status"] = "passed" print(" ✅ 设置对话框测试通过") else: settings_feature["status"] = "failed" settings_feature["errors"].append("未找到设置按钮") print(" ❌ 未找到设置按钮") except Exception as e: settings_feature["status"] = "failed" settings_feature["errors"].append(str(e)[:200]) print(f" ❌ 设置对话框测试失败: {e}") else: settings_feature["status"] = "skipped" settings_feature["errors"].append("无可用教材 ID") print(" ⏭️ 跳过设置对话框测试") feature_result["features"]["settings"] = settings_feature # ---- 8. 删除教材(清理测试数据)---- print("\n>>> [8/8] 测试删除教材(清理)...") delete_feature = {"name": "删除教材", "status": "unknown", "checks": [], "errors": []} if created_textbook_id: try: # 确保在详情页 page.goto(f"{BASE_URL}/teacher/textbooks/{created_textbook_id}", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 点击设置按钮 settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings")) if settings_btn.count() > 0: settings_btn.first.click() page.wait_for_timeout(1000) dialog = page.locator('[role="dialog"]') if dialog.count() > 0: # 点击删除按钮 delete_btn = dialog.locator('button').filter(has_text=re.compile(r"删除教材|Delete")) if delete_btn.count() > 0: delete_btn.first.click() page.wait_for_timeout(1000) # 确认删除(AlertDialog) alert_dialog = page.locator('[role="alertdialog"]') if alert_dialog.count() > 0: confirm_btn = alert_dialog.locator('button').filter(has_text=re.compile(r"删除|Delete|确认|Confirm")) if confirm_btn.count() > 0: confirm_btn.first.click() page.wait_for_timeout(2000) page.wait_for_load_state("networkidle", timeout=10000) # 检查是否跳转回列表页 final_url = page.url delete_feature["checks"].append({ "name": "删除后跳转回列表页", "passed": "/teacher/textbooks" in final_url and f"/{created_textbook_id}" not in final_url, "detail": f"URL={final_url}" }) delete_feature["status"] = "passed" print(" ✅ 删除教材测试通过") else: delete_feature["status"] = "failed" delete_feature["errors"].append("未找到确认删除按钮") else: delete_feature["status"] = "warning" delete_feature["errors"].append("未出现确认对话框") else: delete_feature["status"] = "failed" delete_feature["errors"].append("未找到删除按钮") else: delete_feature["status"] = "failed" delete_feature["errors"].append("设置对话框未打开") else: delete_feature["status"] = "skipped" delete_feature["errors"].append("未找到设置按钮(可能教材已被删除)") except Exception as e: delete_feature["status"] = "failed" delete_feature["errors"].append(str(e)[:200]) print(f" ❌ 删除教材测试失败: {e}") else: delete_feature["status"] = "skipped" delete_feature["errors"].append("无创建的教材,跳过删除测试") print(" ⏭️ 跳过删除教材测试(无创建的教材)") feature_result["features"]["delete"] = delete_feature return feature_result # ============ 学生完整功能测试 ============ def test_student_full_features(page) -> dict: """测试学生对教材模块的完整功能:列表、筛选、详情、只读""" feature_result = { "role": "student", "features": {}, "screenshots": [], "errors": [], "warnings": [], } print("\n=== 测试学生教材模块完整功能 ===") # ---- 1. 列表页功能 ---- print("\n>>> [1/4] 测试学生列表页功能...") list_feature = {"name": "学生列表页", "status": "unknown", "checks": [], "errors": []} try: page.goto(f"{BASE_URL}/student/learning/textbooks", 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 = ["教材", "Textbook", "textbook"] title_match = any(k in body_text for k in title_keys) list_feature["checks"].append({ "name": "页面标题包含'教材'/'Textbook'", "passed": title_match, "detail": f"body 长度 {len(body_text)}" }) # 检查"新建教材"按钮(学生端不应有) new_btn = page.locator('button').filter(has_text=re.compile(r"新建教材|新建|Add|Create")) new_btn_count = new_btn.count() list_feature["checks"].append({ "name": "学生端无'新建教材'按钮", "passed": new_btn_count == 0, "detail": f"找到 {new_btn_count} 个(应为 0)" }) # 检查筛选器 filter_inputs = page.locator('input, select, button[role="combobox"]').count() list_feature["checks"].append({ "name": "存在筛选器", "passed": filter_inputs > 0, "detail": f"找到 {filter_inputs} 个筛选元素" }) # 检查教材卡片 cards = page.locator('a[href*="/student/learning/textbooks/"]').count() list_feature["checks"].append({ "name": "教材卡片或空状态", "passed": True, "detail": f"找到 {cards} 个教材链接" }) page.screenshot(path=str(SCREENSHOT_DIR / "student_feature_list.png"), full_page=True) feature_result["screenshots"].append("student_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/4] 测试学生筛选功能...") filter_feature = {"name": "学生筛选", "status": "unknown", "checks": [], "errors": []} try: # 测试搜索筛选 search_input = page.locator('input[placeholder*="搜索"], input[placeholder*="search"]').first if search_input.count() > 0: search_input.fill("数学") page.wait_for_timeout(1500) page.wait_for_load_state("networkidle", timeout=10000) current_url = page.url has_search_param = "q=" in current_url filter_feature["checks"].append({ "name": "搜索筛选触发 URL 参数更新", "passed": has_search_param, "detail": f"URL={current_url}" }) search_input.fill("") page.keyboard.press("Escape") page.wait_for_timeout(1000) else: filter_feature["checks"].append({ "name": "搜索输入框存在", "passed": False, "detail": "未找到搜索输入框" }) filter_feature["status"] = "passed" print(" ✅ 学生筛选功能测试通过") except Exception as e: filter_feature["status"] = "failed" filter_feature["errors"].append(str(e)[:200]) print(f" ❌ 学生筛选功能测试失败: {e}") feature_result["features"]["filter"] = filter_feature # ---- 3. 教材详情页 ---- print("\n>>> [3/4] 测试学生教材详情页...") detail_feature = {"name": "学生详情页", "status": "unknown", "checks": [], "errors": []} test_textbook_id = None try: page.goto(f"{BASE_URL}/student/learning/textbooks", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) textbook_links = page.locator('a[href*="/student/learning/textbooks/"]') if textbook_links.count() > 0: first_href = textbook_links.first.get_attribute("href") or "" match = re.search(r'/student/learning/textbooks/([^/]+)', first_href) if match: test_textbook_id = match.group(1) except Exception: pass if test_textbook_id: try: url = f"{BASE_URL}/student/learning/textbooks/{test_textbook_id}" response = page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) http_status = response.status if response else None detail_feature["checks"].append({ "name": "详情页 HTTP 200", "passed": http_status == 200, "detail": f"HTTP {http_status}" }) # 检查章节侧边栏 chapter_items = page.locator('[class*="chapter"], [data-testid*="chapter"]') body_text = page.locator("body").text_content() or "" has_chapter_ui = chapter_items.count() > 0 or "章节" in body_text or "Chapter" in body_text detail_feature["checks"].append({ "name": "章节侧边栏渲染", "passed": has_chapter_ui, "detail": f"找到 {chapter_items.count()} 个章节元素" }) # 检查 Tab 切换 tabs = page.locator('[role="tab"]') tab_count = tabs.count() detail_feature["checks"].append({ "name": "存在 Tab 切换", "passed": tab_count >= 2, "detail": f"找到 {tab_count} 个 Tab" }) # 检查无编辑按钮(学生端只读) edit_btn = page.locator('button').filter(has_text=re.compile(r"编辑内容|Edit Content|编辑|Edit")) edit_btn_count = edit_btn.count() detail_feature["checks"].append({ "name": "学生端无编辑按钮", "passed": edit_btn_count == 0, "detail": f"找到 {edit_btn_count} 个(应为 0)" }) # 检查无设置按钮 settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings")) settings_btn_count = settings_btn.count() detail_feature["checks"].append({ "name": "学生端无设置按钮", "passed": settings_btn_count == 0, "detail": f"找到 {settings_btn_count} 个(应为 0)" }) page.screenshot(path=str(SCREENSHOT_DIR / "student_feature_detail.png"), full_page=True) feature_result["screenshots"].append("student_feature_detail.png") detail_feature["status"] = "passed" print(" ✅ 学生详情页测试通过") except Exception as e: detail_feature["status"] = "failed" detail_feature["errors"].append(str(e)[:200]) print(f" ❌ 学生详情页测试失败: {e}") else: detail_feature["status"] = "skipped" detail_feature["errors"].append("无可用教材 ID") print(" ⏭️ 跳过学生详情页测试(无教材)") feature_result["features"]["detail"] = detail_feature # ---- 4. 知识图谱 Tab ---- print("\n>>> [4/4] 测试学生知识图谱 Tab...") graph_feature = {"name": "学生知识图谱", "status": "unknown", "checks": [], "errors": []} if test_textbook_id: try: page.goto(f"{BASE_URL}/student/learning/textbooks/{test_textbook_id}", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 先选择一个章节(图谱 Tab 在未选章节时是 disabled) chapter_links = page.locator('[class*="chapter"], [data-testid*="chapter"], button:has-text("章"), a:has-text("章")') if chapter_links.count() > 0: chapter_links.first.click() page.wait_for_timeout(1500) print(f" 已选择第一个章节") graph_tab = page.locator('[role="tab"]').filter(has_text=re.compile(r"图谱|Graph")) if graph_tab.count() > 0: is_disabled = graph_tab.first.get_attribute("disabled") if is_disabled is not None: graph_feature["status"] = "warning" graph_feature["errors"].append("图谱 Tab 处于 disabled 状态") print(" ⚠️ 图谱 Tab 处于 disabled 状态") else: graph_tab.first.click() page.wait_for_timeout(1500) graph_canvas = page.locator('.react-flow, svg, [class*="graph"]') graph_feature["checks"].append({ "name": "知识图谱画布渲染", "passed": graph_canvas.count() > 0, "detail": f"找到 {graph_canvas.count()} 个图谱元素" }) page.screenshot(path=str(SCREENSHOT_DIR / "student_feature_graph.png"), full_page=True) feature_result["screenshots"].append("student_feature_graph.png") graph_feature["status"] = "passed" print(" ✅ 学生知识图谱测试通过") else: graph_feature["status"] = "warning" graph_feature["errors"].append("未找到图谱 Tab") print(" ⚠️ 未找到图谱 Tab") except Exception as e: graph_feature["status"] = "failed" graph_feature["errors"].append(str(e)[:200]) print(f" ❌ 学生知识图谱测试失败: {e}") else: graph_feature["status"] = "skipped" graph_feature["errors"].append("无可用教材 ID") print(" ⏭️ 跳过学生知识图谱测试") feature_result["features"]["graph"] = graph_feature return feature_result # ============ 主流程 ============ def update_summary(role_result: dict): """根据角色测试结果更新汇总""" results["summary"]["total"] += 1 statuses = [] if "teacher_list_page" in role_result: statuses.append(role_result["teacher_list_page"]["status"]) if "student_list_page" in role_result: statuses.append(role_result["student_list_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"# 教材模块(textbooks)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("- **路由覆盖**:") md.append(" - `/teacher/textbooks`(教师列表页)") md.append(" - `/teacher/textbooks/[id]`(教师详情页)") md.append(" - `/student/learning/textbooks`(学生列表页)") md.append(" - `/student/learning/textbooks/[id]`(学生详情页)") md.append("- **功能覆盖**:") md.append(" - 教师:列表查看、筛选(搜索/学科/年级)、新建教材、详情页、章节侧边栏、知识图谱、设置对话框、删除教材") md.append(" - 学生:列表查看、筛选、详情页(只读)、知识图谱(只读)") md.append("- **权限测试**:") md.append(" - student/parent 应被拒绝访问 `/teacher/textbooks`") md.append(" - 学生端不应显示新建/编辑/删除按钮") 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 "❌" teacher_status = info.get("teacher_list_page", {}).get("status", "unknown") student_status = info.get("student_list_page", {}).get("status", "unknown") teacher_icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(teacher_status, "❓") student_icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(student_status, "❓") note = "" if role in ("student", "parent"): note = "教师端应被拒绝" elif role == "admin": note = "有权限访问教师端" elif role == "teacher": note = "完整功能" md.append(f"| {role} | {login_ok} | {teacher_icon} | {student_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("") student_features = results.get("student_features", {}) if student_features.get("features"): md.append("| 功能 | 状态 | 检查项数 | 通过数 | 错误 |") md.append("|------|------|----------|--------|------|") for key, f in student_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 student_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('role', '')}] `{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}`") if student_features.get("screenshots"): for shot in student_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(): teacher_err = info.get("teacher_list_page", {}).get("errors", []) student_err = info.get("student_list_page", {}).get("errors", []) if teacher_err or student_err: md.append(f"### {role}") for e in teacher_err: md.append(f"- 教师列表页:{e}") for e in student_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("") if student_features.get("features"): for key, f in student_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"教材模块(textbooks)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( viewport={"width": 1440, "height": 900}, locale="zh-CN", ignore_https_errors=True, ) 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( viewport={"width": 1440, "height": 900}, locale="zh-CN", ignore_https_errors=True, ) page = context.new_page() try: if not login(page, "teacher"): print("❌ 教师登录失败,跳过完整功能测试") results["teacher_features"] = {"features": {}, "errors": ["教师登录失败"]} results["summary"]["failed"] += 1 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() # ---- 学生完整功能测试 ---- context = browser.new_context( viewport={"width": 1440, "height": 900}, locale="zh-CN", ignore_https_errors=True, ) page = context.new_page() try: if not login(page, "student"): print("❌ 学生登录失败,跳过完整功能测试") results["student_features"] = {"features": {}, "errors": ["学生登录失败"]} results["summary"]["failed"] += 1 else: student_features = test_student_full_features(page) results["student_features"] = student_features feature_failed = sum(1 for f in student_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["student_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"textbooks_{VERSION}.md" md_path.write_text(md_report, encoding="utf-8") print(f"✅ Markdown 报告: {md_path}") json_path = WEBTEST_DIR / f"textbooks_{VERSION}.json" json_path.write_text(json.dumps(results, ensure_ascii=False, indent=2, default=str), 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" 控制台错误: {len(results['console_errors_global'])}") if __name__ == "__main__": main()