- 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
1511 lines
65 KiB
Python
1511 lines
65 KiB
Python
"""
|
||
教材模块(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()
|