Files
NextEdu/webtest/lesson-preparation_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

947 lines
38 KiB
Python
Raw Permalink 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.
"""
备课模块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-preparationWeb 测试报告 {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-preparationWeb 测试 - {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()