feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
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
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
主要变更: - 新增 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)
This commit is contained in:
416
tests/e2e/teacher-web-test.spec.ts
Normal file
416
tests/e2e/teacher-web-test.spec.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 教师端全功能 Web 测试
|
||||
* 测试范围:教师端所有页面路由
|
||||
*/
|
||||
import { expect, test } from "@playwright/test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
|
||||
const TEACHER_PASSWORD = "123456"
|
||||
|
||||
// 教师端所有路由
|
||||
const TEACHER_ROUTES: { category: string; routes: string[] }[] = [
|
||||
{
|
||||
category: "Dashboard",
|
||||
routes: ["/teacher/dashboard"],
|
||||
},
|
||||
{
|
||||
category: "Textbooks",
|
||||
routes: ["/teacher/textbooks"],
|
||||
},
|
||||
{
|
||||
category: "Exams",
|
||||
routes: [
|
||||
"/teacher/exams",
|
||||
"/teacher/exams/all",
|
||||
"/teacher/exams/create",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Homework",
|
||||
routes: [
|
||||
"/teacher/homework",
|
||||
"/teacher/homework/assignments",
|
||||
"/teacher/homework/assignments/create",
|
||||
"/teacher/homework/submissions",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Grades",
|
||||
routes: [
|
||||
"/teacher/grades",
|
||||
"/teacher/grades/entry",
|
||||
"/teacher/grades/stats",
|
||||
"/teacher/grades/analytics",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Question Bank",
|
||||
routes: ["/teacher/questions"],
|
||||
},
|
||||
{
|
||||
category: "Class Management",
|
||||
routes: [
|
||||
"/teacher/classes",
|
||||
"/teacher/classes/my",
|
||||
"/teacher/classes/students",
|
||||
"/teacher/classes/schedule",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Course Plans",
|
||||
routes: ["/teacher/course-plans"],
|
||||
},
|
||||
{
|
||||
category: "Lesson Plans",
|
||||
routes: [
|
||||
"/teacher/lesson-plans",
|
||||
"/teacher/lesson-plans/new",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Attendance",
|
||||
routes: [
|
||||
"/teacher/attendance",
|
||||
"/teacher/attendance/sheet",
|
||||
"/teacher/attendance/stats",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Schedule Changes",
|
||||
routes: ["/teacher/schedule-changes"],
|
||||
},
|
||||
{
|
||||
category: "Diagnostic",
|
||||
routes: ["/teacher/diagnostic"],
|
||||
},
|
||||
{
|
||||
category: "Electives",
|
||||
routes: ["/teacher/elective"],
|
||||
},
|
||||
{
|
||||
category: "Management",
|
||||
routes: [
|
||||
"/management/grade/classes",
|
||||
"/management/grade/insights",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Announcements",
|
||||
routes: ["/announcements"],
|
||||
},
|
||||
{
|
||||
category: "Messages",
|
||||
routes: ["/messages"],
|
||||
},
|
||||
]
|
||||
|
||||
// 需要登录后从列表页抓取链接的详情页模式
|
||||
const DETAIL_PATTERNS: { category: string; listRoute: string; linkPattern: string }[] = [
|
||||
{ category: "Textbooks Detail", listRoute: "/teacher/textbooks", linkPattern: "/teacher/textbooks/" },
|
||||
{ category: "Classes Detail", listRoute: "/teacher/classes/my", linkPattern: "/teacher/classes/my/" },
|
||||
{ category: "Course Plans Detail", listRoute: "/teacher/course-plans", linkPattern: "/teacher/course-plans/" },
|
||||
{ category: "Lesson Plans Detail", listRoute: "/teacher/lesson-plans", linkPattern: "/teacher/lesson-plans/" },
|
||||
]
|
||||
|
||||
interface TestResult {
|
||||
url: string
|
||||
category: string
|
||||
status: "passed" | "failed" | "warning"
|
||||
httpStatus: number | null
|
||||
finalUrl: string
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
const allResults: TestResult[] = []
|
||||
|
||||
function addResult(result: TestResult): void {
|
||||
allResults.push(result)
|
||||
}
|
||||
|
||||
function generateReport(): string {
|
||||
const lines: string[] = []
|
||||
const passed = allResults.filter((r) => r.status === "passed").length
|
||||
const failed = allResults.filter((r) => r.status === "failed").length
|
||||
const warnings = allResults.filter((r) => r.status === "warning").length
|
||||
const total = allResults.length
|
||||
|
||||
lines.push("# 教师端 Web 功能测试报告 (v2)")
|
||||
lines.push("")
|
||||
lines.push(`> 测试日期:${new Date().toISOString().replace("T", " ").slice(0, 19)}`)
|
||||
lines.push("> 测试范围:所有教师端页面功能")
|
||||
lines.push("> 测试工具:Playwright + Chromium")
|
||||
lines.push(`> 测试账号:${TEACHER_EMAIL}`)
|
||||
lines.push("")
|
||||
lines.push("---")
|
||||
lines.push("")
|
||||
lines.push("## 一、测试概览")
|
||||
lines.push("")
|
||||
lines.push("| 指标 | 数值 |")
|
||||
lines.push("|------|------|")
|
||||
lines.push(`| 总测试页面数 | ${total} |`)
|
||||
lines.push(`| ✅ 通过 | ${passed} |`)
|
||||
lines.push(`| ❌ 失败 | ${failed} |`)
|
||||
lines.push(`| ⚠️ 警告 | ${warnings} |`)
|
||||
lines.push(`| 通过率 | ${total > 0 ? ((passed / total) * 100).toFixed(1) : "N/A"}% |`)
|
||||
lines.push("")
|
||||
lines.push("---")
|
||||
lines.push("")
|
||||
lines.push("## 二、页面测试详情")
|
||||
lines.push("")
|
||||
|
||||
// 按类别分组
|
||||
const byCategory = new Map<string, TestResult[]>()
|
||||
for (const r of allResults) {
|
||||
const cat = r.category
|
||||
if (!byCategory.has(cat)) byCategory.set(cat, [])
|
||||
byCategory.get(cat)!.push(r)
|
||||
}
|
||||
|
||||
for (const [category, results] of byCategory) {
|
||||
lines.push(`### ${category}`)
|
||||
lines.push("")
|
||||
lines.push("| 页面 | HTTP状态 | 结果 | 备注 |")
|
||||
lines.push("|------|----------|------|------|")
|
||||
|
||||
for (const r of results) {
|
||||
const icon = r.status === "passed" ? "✅" : r.status === "warning" ? "⚠️" : "❌"
|
||||
const notes: string[] = []
|
||||
if (r.finalUrl !== r.url) notes.push(`重定向: ${r.finalUrl}`)
|
||||
if (r.errors.length > 0) notes.push(`错误: ${r.errors.slice(0, 2).join("; ")}`)
|
||||
if (r.warnings.length > 0) notes.push(`警告: ${r.warnings.slice(0, 2).join("; ")}`)
|
||||
const noteStr = notes.length > 0 ? notes.join("<br>") : "-"
|
||||
|
||||
lines.push(`| ${icon} \`${r.url}\` | ${r.httpStatus ?? "-"} | ${r.status} | ${noteStr} |`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
// 失败详情
|
||||
const failedResults = allResults.filter((r) => r.status === "failed")
|
||||
if (failedResults.length > 0) {
|
||||
lines.push("---")
|
||||
lines.push("")
|
||||
lines.push("## 三、失败页面详情")
|
||||
lines.push("")
|
||||
for (const r of failedResults) {
|
||||
lines.push(`### ❌ \`${r.url}\``)
|
||||
lines.push("")
|
||||
lines.push(`- **分类**: ${r.category}`)
|
||||
lines.push(`- **HTTP状态**: ${r.httpStatus ?? "-"}`)
|
||||
if (r.finalUrl !== r.url) lines.push(`- **重定向**: ${r.finalUrl}`)
|
||||
if (r.errors.length > 0) {
|
||||
lines.push("- **错误信息**:")
|
||||
for (const e of r.errors) lines.push(` - ${e}`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
|
||||
// 警告详情
|
||||
const warningResults = allResults.filter((r) => r.status === "warning")
|
||||
if (warningResults.length > 0) {
|
||||
lines.push("---")
|
||||
lines.push("")
|
||||
lines.push("## 四、警告页面")
|
||||
lines.push("")
|
||||
for (const r of warningResults) {
|
||||
lines.push(`### ⚠️ \`${r.url}\``)
|
||||
lines.push("")
|
||||
lines.push(`- **分类**: ${r.category}`)
|
||||
if (r.warnings.length > 0) {
|
||||
for (const w of r.warnings) lines.push(` - ${w}`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("---")
|
||||
lines.push("")
|
||||
lines.push(`*报告自动生成于 ${new Date().toISOString().replace("T", " ").slice(0, 19)}*`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
test.describe("Teacher Full Web Test", () => {
|
||||
test.setTimeout(300000) // 5 minutes total
|
||||
|
||||
test("login and test all teacher pages", async ({ page }) => {
|
||||
// ====== Step 1: Login ======
|
||||
console.log("\n>>> 登录教师账号...")
|
||||
await page.goto("/login")
|
||||
await page.waitForLoadState("networkidle")
|
||||
|
||||
// 填表登录
|
||||
const emailInput = page.locator('input[name="email"]')
|
||||
if ((await emailInput.count()) === 0) {
|
||||
await page.locator('input[type="email"]').first().waitFor({ state: "visible", timeout: 5000 })
|
||||
}
|
||||
await page.locator('input[type="email"], input[name="email"]').first().fill(TEACHER_EMAIL)
|
||||
await page.locator('input[type="password"], input[name="password"]').first().fill(TEACHER_PASSWORD)
|
||||
|
||||
// 点击登录
|
||||
const submitBtn = page.locator('button[type="submit"]')
|
||||
if ((await submitBtn.count()) > 0) {
|
||||
await submitBtn.first().click()
|
||||
} else {
|
||||
await page.getByRole("button", { name: /Sign In|登录/i }).click()
|
||||
}
|
||||
await page.waitForLoadState("networkidle")
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
console.log(`登录后 URL: ${page.url()}`)
|
||||
expect(page.url()).not.toContain("/login")
|
||||
|
||||
// ====== Step 2: Test all routes ======
|
||||
for (const { category, routes } of TEACHER_ROUTES) {
|
||||
for (const route of routes) {
|
||||
console.log(`\n 测试: ${category} - ${route}`)
|
||||
const result = await testSinglePage(page, route, category)
|
||||
addResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Step 3: Discover and test detail pages ======
|
||||
console.log("\n\n>>> 发现详情页链接...")
|
||||
for (const { category, listRoute, linkPattern } of DETAIL_PATTERNS) {
|
||||
const detailUrls = await discoverDetailLinks(page, listRoute, linkPattern)
|
||||
if (detailUrls.length > 0) {
|
||||
console.log(`\n ${category}: ${detailUrls.length} 个详情页`)
|
||||
for (const detailUrl of detailUrls.slice(0, 2)) {
|
||||
const result = await testSinglePage(page, detailUrl, `${category}`)
|
||||
addResult(result)
|
||||
}
|
||||
} else {
|
||||
console.log(`\n ${category}: 未发现详情页链接`)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Step 4: Generate report ======
|
||||
const report = generateReport()
|
||||
const bugsDir = path.resolve(__dirname, "..", "..", "bugs")
|
||||
fs.mkdirSync(bugsDir, { recursive: true })
|
||||
const outputPath = path.join(bugsDir, "教师端_web_test2.md")
|
||||
fs.writeFileSync(outputPath, report, "utf-8")
|
||||
|
||||
console.log(`\n\n${"=".repeat(60)}`)
|
||||
const passed = allResults.filter((r) => r.status === "passed").length
|
||||
const failed = allResults.filter((r) => r.status === "failed").length
|
||||
console.log(`测试完成: 总计 ${allResults.length}, 通过 ${passed}, 失败 ${failed}`)
|
||||
console.log(`报告已写入: ${outputPath}`)
|
||||
console.log(`${"=".repeat(60)}`)
|
||||
})
|
||||
})
|
||||
|
||||
async function testSinglePage(
|
||||
page: import("@playwright/test").Page,
|
||||
route: string,
|
||||
category: string,
|
||||
): Promise<TestResult> {
|
||||
const result: TestResult = {
|
||||
url: route,
|
||||
category,
|
||||
status: "unknown",
|
||||
httpStatus: null,
|
||||
finalUrl: route,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const consoleErrors: string[] = []
|
||||
const onConsole = (msg: import("@playwright/test").ConsoleMessage) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text())
|
||||
}
|
||||
}
|
||||
page.on("console", onConsole)
|
||||
|
||||
try {
|
||||
const response = await page.goto(route, { timeout: 25000 })
|
||||
await page.waitForLoadState("networkidle", { timeout: 15000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
result.httpStatus = response?.status() ?? null
|
||||
result.finalUrl = page.url()
|
||||
|
||||
const httpStatus = result.httpStatus
|
||||
const finalUrl = result.finalUrl
|
||||
|
||||
if (httpStatus && httpStatus >= 500) {
|
||||
result.status = "failed"
|
||||
result.errors.push(`HTTP ${httpStatus} error`)
|
||||
} else if (httpStatus && httpStatus >= 400) {
|
||||
result.status = "warning"
|
||||
result.warnings.push(`HTTP ${httpStatus} error`)
|
||||
} else if (finalUrl.includes("/login")) {
|
||||
result.status = "failed"
|
||||
result.errors.push("Redirected to login - auth issue")
|
||||
} else if (finalUrl.includes("/error") || finalUrl.includes("/500")) {
|
||||
result.status = "failed"
|
||||
result.errors.push("Redirected to error page")
|
||||
} else {
|
||||
result.status = "passed"
|
||||
}
|
||||
|
||||
// 检查页面错误提示
|
||||
const errorElements = page.locator('[role="alert"], .text-destructive, .text-red-500')
|
||||
const errorCount = await errorElements.count()
|
||||
if (errorCount > 0) {
|
||||
for (let i = 0; i < Math.min(errorCount, 3); i++) {
|
||||
const text = await errorElements.nth(i).textContent()
|
||||
if (text && text.trim()) {
|
||||
result.warnings.push(`Error text: ${text.trim().slice(0, 100)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查页面是否为空
|
||||
const bodyText = (await page.locator("body").textContent()) ?? ""
|
||||
if (bodyText.trim().length < 50) {
|
||||
result.warnings.push("Page appears empty")
|
||||
}
|
||||
|
||||
if (consoleErrors.length > 0) {
|
||||
result.errors.push(...consoleErrors.slice(0, 5))
|
||||
}
|
||||
|
||||
const icon = result.status === "passed" ? "✅" : result.status === "warning" ? "⚠️" : "❌"
|
||||
console.log(` ${icon} ${result.status} (HTTP ${result.httpStatus})`)
|
||||
} catch (e) {
|
||||
result.status = "failed"
|
||||
result.errors.push(`Exception: ${String(e).slice(0, 200)}`)
|
||||
console.log(` ❌ ERROR: ${String(e).slice(0, 100)}`)
|
||||
} finally {
|
||||
page.removeListener("console", onConsole)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function discoverDetailLinks(
|
||||
page: import("@playwright/test").Page,
|
||||
listRoute: string,
|
||||
linkPattern: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
await page.goto(listRoute, { timeout: 25000 })
|
||||
await page.waitForLoadState("networkidle", { timeout: 15000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const links = page.locator(`a[href*="${linkPattern}"]`)
|
||||
const count = await links.count()
|
||||
const detailUrls: string[] = []
|
||||
|
||||
for (let i = 0; i < Math.min(count, 3); i++) {
|
||||
const href = await links.nth(i).getAttribute("href")
|
||||
if (href && href !== listRoute && href.includes(linkPattern)) {
|
||||
detailUrls.push(href)
|
||||
}
|
||||
}
|
||||
return detailUrls
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user