Files
NextEdu/tests/webapp/student_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

508 lines
19 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 对学生端所有页面进行功能测试
"""
import json
import os
from datetime import datetime
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
STUDENT_EMAIL = "student_g1c1_1@xiaoxue.edu.cn"
STUDENT_PASSWORD = "123456"
# 学生端所有路由(来自 src/modules/layout/config/navigation.ts 及 src/app/(dashboard)/student/ 目录)
STUDENT_ROUTES = {
"Dashboard": ["/student/dashboard"],
"My Learning - Courses": ["/student/learning/courses"],
"My Learning - Assignments": ["/student/learning/assignments"],
"My Learning - Textbooks": ["/student/learning/textbooks"],
"Schedule": ["/student/schedule"],
"My Grades": ["/student/grades"],
"Attendance": ["/student/attendance"],
"Diagnostic": ["/student/diagnostic"],
"Electives": ["/student/elective"],
"Announcements": ["/announcements"],
"Messages": [
"/messages",
"/messages/compose",
],
"Profile": ["/profile"],
"Settings": [
"/settings",
"/settings/security",
],
"Common Dashboard": ["/dashboard"],
}
# 详情页发现规则:从列表页提取详情链接
DETAIL_DISCOVERIES = {
"Assignment Detail": ("/student/learning/assignments", "/student/learning/assignments/"),
"Textbook Detail": ("/student/learning/textbooks", "/student/learning/textbooks/"),
}
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"test_target": "学生端 (Student)",
"base_url": BASE_URL,
"student_email": STUDENT_EMAIL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"pages": {},
"console_errors": [],
"navigation_issues": [],
}
def login(page):
"""登录学生账号"""
print("\n>>> 登录学生账号...")
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
# 检查是否已登录
if "/student" in page.url or page.url.rstrip("/").endswith("/dashboard"):
print(" 已登录,跳过登录步骤")
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(STUDENT_EMAIL)
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
password_input.fill(STUDENT_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=15000)
page.wait_for_timeout(2000)
print(f" 登录后 URL: {page.url}")
# 验证登录成功
if "/login" in page.url:
print(" ❌ 登录后仍在登录页")
return False
return True
except Exception as e:
print(f" ❌ 登录失败: {e}")
return False
def test_page(page, route, category):
"""测试单个页面"""
url = f"{BASE_URL}{route}"
page_result = {
"url": url,
"category": category,
"status": "unknown",
"http_status": None,
"redirect_url": None,
"final_url": None,
"errors": [],
"warnings": [],
"title": None,
"content_length": 0,
}
print(f"\n 测试: {category} - {route}")
# 收集控制台错误
console_errors = []
def on_console(msg):
if msg.type == "error":
console_errors.append(msg.text)
page.on("console", on_console)
# 收集页面错误
def on_page_error(err):
console_errors.append(f"[PAGE_ERROR] {err}")
page.on("pageerror", on_page_error)
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
page_result["http_status"] = http_status
page_result["final_url"] = final_url
# 获取页面标题
try:
page_result["title"] = page.title()
except Exception:
pass
# 检查是否重定向
if final_url.rstrip("/") != url.rstrip("/"):
page_result["redirect_url"] = final_url
# 检查 HTTP 状态
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 页面")
else:
page_result["status"] = "passed"
# 检查页面是否有错误提示
try:
error_elements = page.locator('[role="alert"], .error, .text-destructive, .text-red-500').all()
for et in error_elements[:3]:
try:
text = et.text_content()
if text and text.strip():
page_result["warnings"].append(f"页面错误提示: {text.strip()[:150]}")
except Exception:
pass
except Exception:
pass
# 检查页面是否为空(无主要内容)
body_text = page.locator("body").text_content() or ""
page_result["content_length"] = len(body_text.strip())
if len(body_text.strip()) < 50:
page_result["warnings"].append("页面内容过少,可能为空")
# 收集控制台错误
if console_errors:
page_result["errors"].extend(console_errors[:5])
# 打印状态
status_icon = "" if page_result["status"] == "passed" else "⚠️" if page_result["status"] == "warning" else ""
print(f" {status_icon} {page_result['status']} (HTTP {http_status})")
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_page_error)
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(1000)
# 查找匹配的链接
links = page.locator(f'a[href*="{link_pattern}"]').all()
detail_urls = []
seen = set()
for link in links[:5]: # 最多取5个
href = link.get_attribute("href")
if href and link_pattern in href and href != list_route:
# 标准化URL
if not href.startswith("/"):
href = "/" + href
# 排除列表页本身
if href.rstrip("/") == list_route.rstrip("/"):
continue
if href not in seen:
seen.add(href)
detail_urls.append(href)
return detail_urls
except Exception as e:
print(f" 发现详情页链接失败: {e}")
return []
def run_all_tests():
"""运行所有测试"""
global results
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 STUDENT_ROUTES.items():
for route in routes:
page_result = test_page(page, route, category)
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[:2]:
route = detail_url
if not route.startswith("/"):
route = "/" + route
page_result = test_page(page, route, f"{name}")
key = route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
else:
print(f"\n {name}: 未发现详情页链接")
# 汇总
passed = sum(1 for pg in results["pages"].values() if pg["status"] == "passed")
failed = sum(1 for pg in results["pages"].values() if pg["status"] == "failed")
warnings = sum(1 for pg in results["pages"].values() if pg["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测试报告"""
report_lines = []
report_lines.append("# 学生端 Web 功能测试报告")
report_lines.append("")
report_lines.append(f"> 测试日期:{results['test_date']}")
report_lines.append(f"> 测试范围:所有学生端页面功能")
report_lines.append(f"> 测试工具Playwright + Chromium (headless)")
report_lines.append(f"> 测试账号:{results['student_email']}")
report_lines.append(f"> Base URL{results['base_url']}")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append("## 一、测试概览")
report_lines.append("")
s = results["summary"]
report_lines.append("| 指标 | 数值 |")
report_lines.append("|------|------|")
report_lines.append(f"| 总测试页面数 | {s['total']} |")
report_lines.append(f"| 通过 | {s['passed']} |")
report_lines.append(f"| 失败 | {s['failed']} |")
report_lines.append(f"| 警告 | {s['warnings']} |")
report_lines.append(f"| 通过率 | {s['passed']/s['total']*100:.1f}% |" if s['total'] > 0 else "| 通过率 | N/A |")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append("## 二、页面测试详情")
report_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 sorted(by_category.keys()):
pages = by_category[category]
report_lines.append(f"### {category}")
report_lines.append("")
report_lines.append("| 状态 | URL | HTTP状态 | 结果 | 备注 |")
report_lines.append("|------|-----|----------|------|------|")
for pg in pages:
status_icon = "" if pg["status"] == "passed" else "⚠️" if pg["status"] == "warning" else ""
notes = []
if pg.get("redirect_url"):
notes.append(f"重定向到: `{pg['redirect_url']}`")
if pg.get("errors"):
# 截断并替换换行符,避免破坏 markdown 表格
err_short = "; ".join(pg["errors"][:2]).replace("\n", " ").replace("\r", " ")
if len(err_short) > 200:
err_short = err_short[:200] + "..."
notes.append(f"错误: {err_short}")
if pg.get("warnings"):
warn_short = "; ".join(pg["warnings"][:2]).replace("\n", " ").replace("\r", " ")
if len(warn_short) > 200:
warn_short = warn_short[:200] + "..."
notes.append(f"警告: {warn_short}")
note_str = "<br>".join(notes) if notes else "-"
# 简化URL显示
short_url = pg["url"].replace(BASE_URL, "")
report_lines.append(
f"| {status_icon} | `{short_url}` | {pg['http_status'] or '-'} | {pg['status']} | {note_str} |"
)
report_lines.append("")
# 失败页面汇总
failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"]
if failed_pages:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 三、失败页面详情")
report_lines.append("")
for key, pg in failed_pages:
report_lines.append(f"### ❌ `{pg['url']}`")
report_lines.append("")
report_lines.append(f"- **分类**: {pg['category']}")
report_lines.append(f"- **HTTP状态**: {pg['http_status']}")
if pg.get("redirect_url"):
report_lines.append(f"- **重定向**: `{pg['redirect_url']}`")
if pg.get("errors"):
report_lines.append(f"- **错误信息**:")
for err in pg["errors"]:
report_lines.append(f" - {err}")
report_lines.append("")
# 警告页面
warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"]
next_section_num = 4
if warning_pages:
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## 四、警告页面")
report_lines.append("")
for key, pg in warning_pages:
report_lines.append(f"### ⚠️ `{pg['url']}`")
report_lines.append("")
report_lines.append(f"- **分类**: {pg['category']}")
if pg.get("warnings"):
for w in pg["warnings"]:
report_lines.append(f" - {w}")
report_lines.append("")
next_section_num = 5
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## {'' if next_section_num == 4 else ''}、发现的问题分析")
report_lines.append("")
report_lines.append("根据测试结果,发现以下问题:")
report_lines.append("")
# 收集所有有错误的页面(包括 passed 但有错误的)
issue_num = 1
for key, pg in results["pages"].items():
if pg.get("errors"):
report_lines.append(f"### 问题 {issue_num}: {pg['category']} - `{pg['url'].replace(BASE_URL, '')}`")
report_lines.append("")
report_lines.append(f"- **状态**: {pg['status']} (HTTP {pg['http_status']})")
report_lines.append(f"- **错误信息**:")
for err in pg["errors"]:
# 截断过长的错误信息
err_clean = err.replace("\n", " ").replace("\r", " ")
if len(err_clean) > 300:
err_clean = err_clean[:300] + "..."
report_lines.append(f" - {err_clean}")
report_lines.append("")
issue_num += 1
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## {'' if next_section_num == 4 else ''}、测试覆盖范围")
report_lines.append("")
report_lines.append("本次测试覆盖学生端以下功能模块:")
report_lines.append("")
report_lines.append("| 模块 | 路由 | 说明 |")
report_lines.append("|------|------|------|")
report_lines.append("| Dashboard | `/student/dashboard` | 学生仪表盘 |")
report_lines.append("| My Learning - Courses | `/student/learning/courses` | 我的课程 |")
report_lines.append("| My Learning - Assignments | `/student/learning/assignments` | 作业列表 |")
report_lines.append("| My Learning - Assignment Detail | `/student/learning/assignments/[id]` | 作业详情/作答 |")
report_lines.append("| My Learning - Textbooks | `/student/learning/textbooks` | 教材列表 |")
report_lines.append("| My Learning - Textbook Detail | `/student/learning/textbooks/[id]` | 教材阅读 |")
report_lines.append("| Schedule | `/student/schedule` | 课表 |")
report_lines.append("| My Grades | `/student/grades` | 我的成绩 |")
report_lines.append("| Attendance | `/student/attendance` | 考勤 |")
report_lines.append("| Diagnostic | `/student/diagnostic` | 学情诊断 |")
report_lines.append("| Electives | `/student/elective` | 选课中心 |")
report_lines.append("| Announcements | `/announcements` | 公告 |")
report_lines.append("| Messages | `/messages` | 消息列表 |")
report_lines.append("| Messages - Compose | `/messages/compose` | 写消息 |")
report_lines.append("| Profile | `/profile` | 个人资料 |")
report_lines.append("| Settings | `/settings` | 设置 |")
report_lines.append("| Settings - Security | `/settings/security` | 安全设置 |")
report_lines.append("| Common Dashboard | `/dashboard` | 通用仪表盘(角色跳转) |")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append(f"*报告自动生成于 {results['test_date']}*")
return "\n".join(report_lines)
def main():
# 运行测试
run_all_tests()
# 生成报告
report = generate_report()
# 写入文件
output_dir = os.path.join(os.path.dirname(__file__), "..", "..", "bugs")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "student_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.replace(".md", ".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()