Files
NextEdu/webtest/textbooks_test.py
SpecialX d884c6d513
Some checks failed
CI / scheduled-backup (push) Failing after 36s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
test: update and add E2E, integration, visual, and webapp tests
- Update E2E tests: announcements, auth, auth-business-flow, full-route-regression, grades, navigation, smoke-auth, teacher-web-test

- Update integration tests: api-ai-chat, api-onboarding-complete, api-onboarding-status, proxy-guard, integration setup

- Update visual regression tests: admin-dashboard, homepage, student-dashboard, teacher-dashboard, visual config, helpers

- Update webapp tests: admin, parent, student full tests and debug scripts

- Add new webapp tests: announcements_messages, settings_profile, debug scripts

- Add webtest directory with test plans, screenshots, and diagnostic scripts
2026-06-23 17:39:40 +08:00

1511 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
教材模块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"# 教材模块textbooksWeb 测试报告 {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"教材模块textbooksWeb 测试 - {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()