Files
NextEdu/tests/webapp/teacher_full_test.py
SpecialX 978d9a8309
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
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
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
2026-06-22 01:06:16 +08:00

767 lines
29 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.
"""
教师端全功能 Web 测试脚本(综合版)
使用 Playwright 对教师端所有页面与核心交互进行功能测试
结果输出bugs/teacher_web_test.md
"""
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
TEACHER_PASSWORD = "123456"
PROJECT_ROOT = Path(__file__).resolve().parents[2]
BUGS_DIR = PROJECT_ROOT / "bugs"
SCREENSHOT_DIR = PROJECT_ROOT / "tests" / "webapp" / "screenshots"
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
# 教师端所有路由(依据 src/modules/layout/config/navigation.ts 与源码目录)
TEACHER_ROUTES = {
"Dashboard": ["/teacher/dashboard"],
"Textbooks": ["/teacher/textbooks"],
"Exams": [
"/teacher/exams",
"/teacher/exams/all",
"/teacher/exams/create",
],
"Homework": [
"/teacher/homework",
"/teacher/homework/assignments",
"/teacher/homework/assignments/create",
"/teacher/homework/submissions",
],
"Grades": [
"/teacher/grades",
"/teacher/grades/entry",
"/teacher/grades/stats",
"/teacher/grades/analytics",
],
"Question Bank": ["/teacher/questions"],
"Class Management": [
"/teacher/classes",
"/teacher/classes/my",
"/teacher/classes/students",
"/teacher/classes/schedule",
],
"Course Plans": ["/teacher/course-plans"],
"Lesson Plans": [
"/teacher/lesson-plans",
"/teacher/lesson-plans/new",
],
"Attendance": [
"/teacher/attendance",
"/teacher/attendance/sheet",
"/teacher/attendance/stats",
],
"Schedule Changes": ["/teacher/schedule-changes"],
"Diagnostic": ["/teacher/diagnostic"],
"Electives": ["/teacher/elective"],
"Management": [
"/management/grade/classes",
"/management/grade/insights",
],
"Announcements": ["/announcements"],
"Messages": ["/messages", "/messages/compose"],
"Profile & Settings": ["/profile", "/settings", "/settings/security"],
}
# 详情页发现规则:(列表页, 链接前缀)
DETAIL_DISCOVERIES = {
"Textbook Detail": ("/teacher/textbooks", "/teacher/textbooks/"),
"Class Detail": ("/teacher/classes/my", "/teacher/classes/my/"),
"Course Plan Detail": ("/teacher/course-plans", "/teacher/course-plans/"),
"Lesson Plan Edit": ("/teacher/lesson-plans", "/teacher/lesson-plans/"),
"Exam Detail": ("/teacher/exams/all", "/teacher/exams/"),
"Homework Assignment Detail": ("/teacher/homework/assignments", "/teacher/homework/assignments/"),
}
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"test_target": "教师端 (Teacher)",
"base_url": BASE_URL,
"teacher_email": TEACHER_EMAIL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"pages": {},
"interactions": [],
"console_errors_global": [],
"navigation_issues": [],
}
# ============ 工具函数 ============
def safe_text(locator, max_len=200):
try:
text = locator.text_content()
return (text or "").strip()[:max_len]
except Exception:
return ""
def login(page):
"""登录教师账号"""
print("\n>>> 登录教师账号...")
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
if "/login" not in page.url:
print(f" 已登录,当前 URL: {page.url}")
return True
try:
page.locator('input[name="email"]').fill(TEACHER_EMAIL)
page.locator('input[name="password"]').fill(TEACHER_PASSWORD)
page.get_by_role("button", name=re.compile(r"Sign In with Email", re.I)).click()
page.wait_for_load_state("networkidle", timeout=20000)
page.wait_for_timeout(2000)
if "/login" in page.url:
print(f" ❌ 登录后仍在登录页: {page.url}")
return False
print(f" ✅ 登录成功,跳转至: {page.url}")
return True
except Exception as e:
print(f" ❌ 登录异常: {e}")
return False
def test_page(page, route, category, take_screenshot=False):
"""测试单个页面HTTP 状态、重定向、控制台错误、空内容检测"""
url = f"{BASE_URL}{route}"
page_result = {
"url": url,
"route": route,
"category": category,
"status": "unknown",
"http_status": None,
"final_url": None,
"redirect_url": None,
"errors": [],
"warnings": [],
"console_errors": [],
"title": "",
"body_length": 0,
"screenshot": None,
}
print(f"\n 测试: {category} - {route}")
console_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
console_errors.append(text)
results["console_errors_global"].append({"route": route, "error": text})
def on_pageerror(err):
page_result["errors"].append(f"PageError: {str(err)[:200]}")
page.on("console", on_console)
page.on("pageerror", on_pageerror)
try:
response = page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
http_status = response.status if response else None
final_url = page.url
page_result["http_status"] = http_status
page_result["final_url"] = final_url
page_result["title"] = safe_text(page.locator("title"))
body_text = safe_text(page.locator("body"), 5000)
page_result["body_length"] = len(body_text)
if final_url.rstrip("/") != url.rstrip("/"):
page_result["redirect_url"] = final_url
# 状态判定
if http_status and http_status >= 500:
page_result["status"] = "failed"
page_result["errors"].append(f"HTTP {http_status} 服务器错误")
elif http_status and http_status >= 400:
page_result["status"] = "warning"
page_result["warnings"].append(f"HTTP {http_status} 客户端错误")
elif final_url and "/login" in final_url:
page_result["status"] = "failed"
page_result["errors"].append("重定向到登录页(认证失败或会话过期)")
elif final_url and "/500" in final_url:
page_result["status"] = "failed"
page_result["errors"].append("重定向到 500 错误页")
elif final_url and "/404" in final_url:
page_result["status"] = "warning"
page_result["warnings"].append("重定向到 404 页面")
elif page_result["body_length"] < 50:
page_result["status"] = "warning"
page_result["warnings"].append("页面内容过少(可能渲染失败)")
else:
page_result["status"] = "passed"
# 检查页面错误提示
error_locs = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600').all()
for et in error_locs[:3]:
text = safe_text(et, 150)
if text and "error" not in text.lower()[:0]: # 保留所有 alert 文本
page_result["warnings"].append(f"页面告警文本: {text}")
# 收集控制台错误
if console_errors:
page_result["console_errors"] = console_errors[:5]
if page_result["status"] == "passed":
page_result["status"] = "warning"
page_result["warnings"].append(f"控制台错误 {len(console_errors)}")
# 截图
if take_screenshot or page_result["status"] != "passed":
safe_name = re.sub(r"[^\w\-]", "_", route.strip("/"))
shot_path = SCREENSHOT_DIR / f"{safe_name}.png"
try:
page.screenshot(path=str(shot_path), full_page=True)
page_result["screenshot"] = str(shot_path.relative_to(PROJECT_ROOT))
except Exception as e:
page_result["warnings"].append(f"截图失败: {e}")
status_icon = {"passed": "", "warning": "⚠️", "failed": ""}.get(page_result["status"], "")
print(f" {status_icon} {page_result['status']} (HTTP {http_status}) -> {final_url}")
except PlaywrightTimeout:
page_result["status"] = "failed"
page_result["errors"].append("页面加载超时 (30s)")
print(f" ❌ TIMEOUT")
except Exception as e:
page_result["status"] = "failed"
page_result["errors"].append(str(e)[:200])
print(f" ❌ ERROR: {str(e)[:100]}")
finally:
try:
page.remove_listener("console", on_console)
page.remove_listener("pageerror", on_pageerror)
except Exception:
pass
return page_result
def discover_detail_links(page, list_route, link_pattern):
"""从列表页发现详情页链接"""
try:
url = f"{BASE_URL}{list_route}"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
links = page.locator(f'a[href*="{link_pattern}"]').all()
detail_urls = []
seen = set()
for link in links[:5]:
try:
href = link.get_attribute("href")
except Exception:
continue
if not href or href == list_route:
continue
if link_pattern in href and href not in seen:
seen.add(href)
detail_urls.append(href)
return detail_urls[:2]
except Exception as e:
print(f" 发现详情页链接失败 ({list_route}): {e}")
return []
def test_interactions(page):
"""测试关键交互功能"""
interactions = []
# 1. 仪表盘快捷操作按钮
print("\n>>> 测试交互: 仪表盘快捷操作")
try:
page.goto(f"{BASE_URL}/teacher/dashboard", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
# 检查仪表盘是否有快捷操作按钮
quick_action_btns = page.locator('a[href*="/teacher/"], button').all()
clickable_count = 0
for btn in quick_action_btns[:10]:
try:
if btn.is_visible():
clickable_count += 1
except Exception:
continue
interactions.append({
"name": "仪表盘快捷操作可见性",
"status": "passed" if clickable_count > 0 else "warning",
"detail": f"可见可点击元素 {clickable_count}",
})
print(f" ✅ 仪表盘可见元素 {clickable_count}")
except Exception as e:
interactions.append({
"name": "仪表盘快捷操作可见性",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 仪表盘交互测试失败: {e}")
# 2. 教材详情页 - 检查章节侧边栏
print("\n>>> 测试交互: 教材详情页章节侧边栏")
try:
page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
textbook_links = page.locator('a[href*="/teacher/textbooks/"]').all()
if textbook_links:
first_href = textbook_links[0].get_attribute("href")
if first_href:
if not first_href.startswith("/"):
first_href = "/" + first_href
page.goto(f"{BASE_URL}{first_href}", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
# 检查章节列表
chapter_locs = page.locator('[data-testid*="chapter"], [class*="chapter"], li, a').all()
interactions.append({
"name": "教材详情页加载",
"status": "passed",
"detail": f"教材 {first_href} 加载成功,发现 {len(chapter_locs)} 个潜在章节元素",
})
print(f" ✅ 教材详情页加载成功")
else:
interactions.append({
"name": "教材详情页加载",
"status": "warning",
"detail": "未发现教材链接(可能数据库无教材数据)",
})
print(f" ⚠️ 未发现教材链接")
except Exception as e:
interactions.append({
"name": "教材详情页加载",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 教材详情页测试失败: {e}")
# 3. 创建考试页面 - 表单元素检查
print("\n>>> 测试交互: 创建考试表单")
try:
page.goto(f"{BASE_URL}/teacher/exams/create", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
form_inputs = page.locator('input, textarea, select, button[type="submit"]').all()
interactions.append({
"name": "创建考试表单元素",
"status": "passed" if len(form_inputs) > 0 else "warning",
"detail": f"发现 {len(form_inputs)} 个表单元素",
})
print(f" ✅ 创建考试表单 {len(form_inputs)} 个元素")
except Exception as e:
interactions.append({
"name": "创建考试表单元素",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 创建考试表单测试失败: {e}")
# 4. 题库页面 - 筛选与表格
print("\n>>> 测试交互: 题库页面表格")
try:
page.goto(f"{BASE_URL}/teacher/questions", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
table_rows = page.locator('tbody tr, [role="row"]').all()
filter_inputs = page.locator('input[placeholder*="搜索"], input[placeholder*="筛选"], select').all()
interactions.append({
"name": "题库表格与筛选",
"status": "passed" if len(table_rows) >= 0 else "warning",
"detail": f"表格行 {len(table_rows)} 个,筛选器 {len(filter_inputs)}",
})
print(f" ✅ 题库表格行 {len(table_rows)}")
except Exception as e:
interactions.append({
"name": "题库表格与筛选",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 题库表格测试失败: {e}")
# 5. 作业创建页面
print("\n>>> 测试交互: 创建作业表单")
try:
page.goto(f"{BASE_URL}/teacher/homework/assignments/create", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
form_elements = page.locator('input, textarea, select, button').all()
interactions.append({
"name": "创建作业表单",
"status": "passed" if len(form_elements) > 2 else "warning",
"detail": f"发现 {len(form_elements)} 个表单元素",
})
print(f" ✅ 创建作业表单 {len(form_elements)} 个元素")
except Exception as e:
interactions.append({
"name": "创建作业表单",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 创建作业表单测试失败: {e}")
# 6. 备课新建页面
print("\n>>> 测试交互: 新建备课")
try:
page.goto(f"{BASE_URL}/teacher/lesson-plans/new", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
form_elements = page.locator('input, textarea, select, button, [contenteditable="true"]').all()
interactions.append({
"name": "新建备课表单",
"status": "passed" if len(form_elements) > 0 else "warning",
"detail": f"发现 {len(form_elements)} 个表单/编辑元素",
})
print(f" ✅ 新建备课 {len(form_elements)} 个元素")
except Exception as e:
interactions.append({
"name": "新建备课表单",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 新建备课测试失败: {e}")
# 7. 侧边栏导航
print("\n>>> 测试交互: 侧边栏导航")
try:
sidebar_links = page.locator('nav a, aside a, [data-sidebar] a').all()
interactions.append({
"name": "侧边栏导航链接",
"status": "passed" if len(sidebar_links) > 3 else "warning",
"detail": f"发现 {len(sidebar_links)} 个侧边栏链接",
})
print(f" ✅ 侧边栏 {len(sidebar_links)} 个链接")
except Exception as e:
interactions.append({
"name": "侧边栏导航链接",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 侧边栏测试失败: {e}")
# 8. 消息撰写
print("\n>>> 测试交互: 消息撰写页")
try:
page.goto(f"{BASE_URL}/messages/compose", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
form_elements = page.locator('input, textarea, select, button').all()
interactions.append({
"name": "消息撰写表单",
"status": "passed" if len(form_elements) > 0 else "warning",
"detail": f"发现 {len(form_elements)} 个表单元素",
})
print(f" ✅ 消息撰写 {len(form_elements)} 个元素")
except Exception as e:
interactions.append({
"name": "消息撰写表单",
"status": "failed",
"detail": str(e)[:200],
})
print(f" ❌ 消息撰写测试失败: {e}")
return interactions
# ============ 主流程 ============
def run_all_tests():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
locale="zh-CN",
ignore_https_errors=True,
)
page = context.new_page()
if not login(page):
print("❌ 登录失败,终止测试")
browser.close()
return
total = 0
# 测试所有主路由
for category, routes in TEACHER_ROUTES.items():
for route in routes:
page_result = test_page(page, route, category, take_screenshot=False)
key = route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
# 发现并测试详情页
print("\n\n>>> 发现详情页链接...")
for name, (list_url, pattern) in DETAIL_DISCOVERIES.items():
detail_urls = discover_detail_links(page, list_url, pattern)
if detail_urls:
print(f"\n {name}: 发现 {len(detail_urls)} 个详情页")
for detail_url in detail_urls[:1]: # 每类只测第一个
route = detail_url if detail_url.startswith("/") else "/" + detail_url
page_result = test_page(page, route, f"{name}")
key = route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
else:
print(f"\n {name}: 未发现详情页链接(可能无数据)")
results["navigation_issues"].append({
"category": name,
"list_url": list_url,
"issue": "未发现详情页链接",
})
# 交互测试
print("\n\n>>> 开始交互功能测试...")
results["interactions"] = test_interactions(page)
# 汇总
passed = sum(1 for p in results["pages"].values() if p["status"] == "passed")
failed = sum(1 for p in results["pages"].values() if p["status"] == "failed")
warnings = sum(1 for p in results["pages"].values() if p["status"] == "warning")
results["summary"]["total"] = total
results["summary"]["passed"] = passed
results["summary"]["failed"] = failed
results["summary"]["warnings"] = warnings
print(f"\n\n{'='*60}")
print(f"测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}")
print(f"{'='*60}")
browser.close()
def generate_report():
"""生成 Markdown 测试报告"""
lines = []
lines.append("# 教师端 Web 功能测试报告")
lines.append("")
lines.append(f"> 测试日期:{results['test_date']}")
lines.append(f"> 测试范围:教师端所有页面与核心交互功能")
lines.append(f"> 测试工具Playwright + Chromium (headless)")
lines.append(f"> 测试账号:`{results['teacher_email']}`")
lines.append(f"> 基础 URL`{results['base_url']}`")
lines.append(f"> 测试依据:`src/modules/layout/config/navigation.ts`、`src/app/(dashboard)/teacher/`")
lines.append("")
lines.append("---")
lines.append("")
# 一、测试概览
lines.append("## 一、测试概览")
lines.append("")
s = results["summary"]
pass_rate = (s["passed"] / s["total"] * 100) if s["total"] > 0 else 0
lines.append("| 指标 | 数值 |")
lines.append("|------|------|")
lines.append(f"| 总测试页面数 | {s['total']} |")
lines.append(f"| 通过 ✅ | {s['passed']} |")
lines.append(f"| 警告 ⚠️ | {s['warnings']} |")
lines.append(f"| 失败 ❌ | {s['failed']} |")
lines.append(f"| 通过率 | {pass_rate:.1f}% |")
lines.append(f"| 交互测试数 | {len(results['interactions'])} |")
lines.append(f"| 全局控制台错误 | {len(results['console_errors_global'])} |")
lines.append("")
lines.append("---")
lines.append("")
# 二、页面测试详情
lines.append("## 二、页面测试详情(按模块分组)")
lines.append("")
by_category = {}
for key, page_result in results["pages"].items():
cat = page_result.get("category", "Other")
by_category.setdefault(cat, []).append(page_result)
for category in by_category.keys():
pages = by_category[category]
lines.append(f"### {category}")
lines.append("")
lines.append("| 状态 | 路由 | HTTP | 最终 URL | 备注 |")
lines.append("|------|------|------|----------|------|")
for p in pages:
status_icon = {"passed": "", "warning": "⚠️", "failed": ""}.get(p["status"], "")
notes = []
if p.get("redirect_url"):
notes.append(f"重定向: `{p['redirect_url']}`")
if p.get("errors"):
notes.append(f"错误: {'; '.join(p['errors'][:2])}")
if p.get("warnings"):
notes.append(f"警告: {'; '.join(p['warnings'][:2])}")
if p.get("console_errors"):
notes.append(f"控制台错误: {len(p['console_errors'])}")
note_str = "<br>".join(notes) if notes else "-"
short_url = p["route"]
final_url = (p.get("final_url") or "").replace(BASE_URL, "") or short_url
lines.append(
f"| {status_icon} | `{short_url}` | {p['http_status'] or '-'} | `{final_url}` | {note_str} |"
)
lines.append("")
# 三、交互测试详情
lines.append("---")
lines.append("")
lines.append("## 三、交互功能测试详情")
lines.append("")
if results["interactions"]:
lines.append("| 状态 | 交互项 | 详情 |")
lines.append("|------|--------|------|")
for it in results["interactions"]:
status_icon = {"passed": "", "warning": "⚠️", "failed": ""}.get(it["status"], "")
lines.append(f"| {status_icon} | {it['name']} | {it['detail']} |")
lines.append("")
else:
lines.append("无交互测试数据。")
lines.append("")
# 四、失败页面详情
failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"]
if failed_pages:
lines.append("---")
lines.append("")
lines.append("## 四、失败页面详情(需修复)")
lines.append("")
for key, p in failed_pages:
lines.append(f"### ❌ `{p['route']}`")
lines.append("")
lines.append(f"- **分类**: {p['category']}")
lines.append(f"- **HTTP 状态**: {p['http_status']}")
lines.append(f"- **最终 URL**: `{p.get('final_url', '-')}`")
if p.get("redirect_url"):
lines.append(f"- **重定向到**: `{p['redirect_url']}`")
if p.get("errors"):
lines.append(f"- **错误信息**:")
for err in p["errors"]:
lines.append(f" - {err}")
if p.get("console_errors"):
lines.append(f"- **控制台错误**:")
for ce in p["console_errors"]:
lines.append(f" - `{ce}`")
if p.get("screenshot"):
lines.append(f"- **截图**: `{p['screenshot']}`")
lines.append("")
# 五、警告页面详情
warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"]
if warning_pages:
lines.append("---")
lines.append("")
lines.append("## 五、警告页面详情(建议复查)")
lines.append("")
for key, p in warning_pages:
lines.append(f"### ⚠️ `{p['route']}`")
lines.append("")
lines.append(f"- **分类**: {p['category']}")
lines.append(f"- **HTTP 状态**: {p['http_status']}")
if p.get("warnings"):
for w in p["warnings"]:
lines.append(f" - {w}")
if p.get("console_errors"):
lines.append(f"- **控制台错误**:")
for ce in p["console_errors"]:
lines.append(f" - `{ce}`")
lines.append("")
# 六、全局控制台错误
if results["console_errors_global"]:
lines.append("---")
lines.append("")
lines.append("## 六、全局控制台错误汇总")
lines.append("")
lines.append("| 路由 | 错误信息 |")
lines.append("|------|----------|")
for ce in results["console_errors_global"][:30]:
err_text = ce["error"][:200].replace("|", "\\|")
lines.append(f"| `{ce['route']}` | `{err_text}` |")
lines.append("")
# 七、导航问题
if results["navigation_issues"]:
lines.append("---")
lines.append("")
lines.append("## 七、导航发现问题")
lines.append("")
for ni in results["navigation_issues"]:
lines.append(f"- **{ni['category']}** (`{ni['list_url']}`): {ni['issue']}")
lines.append("")
# 八、测试结论与建议
lines.append("---")
lines.append("")
lines.append("## 八、测试结论与建议")
lines.append("")
if s["failed"] == 0 and s["warnings"] == 0:
lines.append("✅ **教师端所有页面与交互功能测试全部通过**,未发现严重问题。")
elif s["failed"] == 0:
lines.append(f"⚠️ **教师端测试未发现失败页面**,但有 {s['warnings']} 个警告需复查。")
else:
lines.append(f"❌ **教师端测试发现 {s['failed']} 个失败页面**,需优先修复。")
lines.append("")
lines.append("### 建议后续动作")
lines.append("")
lines.append("1. 优先修复「失败页面详情」中列出的所有 P0 问题HTTP 5xx、重定向到登录页等")
lines.append("2. 复查「警告页面详情」中的页面,确认是否为数据缺失或非关键告警")
lines.append("3. 控制台错误如涉及 Next.js 运行时或服务端异常,应排查 Server Action 与 data-access 层")
lines.append("4. 对于未发现详情页链接的模块,建议先在种子数据中补充对应记录再回归测试")
lines.append("")
lines.append("---")
lines.append("")
lines.append(f"*报告自动生成于 {results['test_date']} by webapp-testing skill*")
return "\n".join(lines)
def main():
run_all_tests()
report = generate_report()
BUGS_DIR.mkdir(parents=True, exist_ok=True)
output_path = BUGS_DIR / "teacher_web_test.md"
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {output_path}")
# 同时输出 JSON 便于调试
json_path = output_path.with_suffix(".json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2, default=str)
print(f"📄 JSON 数据已写入: {json_path}")
if __name__ == "__main__":
main()