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