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

1660 lines
63 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 测试脚本
测试范围:
1. /profile 页面(所有角色)
2. /settings 页面所有角色5 个标签页:通用/通知/外观/安全/AI
3. /admin/settings 页面(仅管理员)
测试角色:
- admin: admin@xiaoxue.edu.cn
- teacher: t_chinese_1@xiaoxue.edu.cn
- student: student_g1c1_1@xiaoxue.edu.cn
- parent: parent_g1c1_1@xiaoxue.edu.cn
测试功能:
- 页面加载与渲染
- 个人资料表单ProfileSettingsForm
- 通知偏好表单NotificationPreferencesForm
- 外观偏好ThemePreferencesCard
- 修改密码PasswordChangeForm
- 安全中心SecurityCenterCard
- 头像上传AvatarUpload
- 管理员系统设置AdminSettingsView
- AI 服务商配置AiProviderSettingsCard
- 学生/教师概览ProfileStudentOverview / ProfileTeacherOverview
"""
import json
import os
import sys
import time
import traceback
from datetime import datetime
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
# 测试账号
TEST_ACCOUNTS = {
"admin": {
"email": "admin@xiaoxue.edu.cn",
"password": "123456",
"label": "管理员",
"dashboard": "/admin/dashboard",
},
"teacher": {
"email": "t_chinese_1@xiaoxue.edu.cn",
"password": "123456",
"label": "教师",
"dashboard": "/teacher/dashboard",
},
"student": {
"email": "student_g1c1_1@xiaoxue.edu.cn",
"password": "123456",
"label": "学生",
"dashboard": "/student/dashboard",
},
"parent": {
"email": "parent_g1c1_1@xiaoxue.edu.cn",
"password": "123456",
"label": "家长",
"dashboard": "/parent/dashboard",
},
}
# 全局测试结果
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"test_target": "设置和个人信息模块 (Settings & Profile)",
"base_url": BASE_URL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"roles": {},
"failures": [],
}
# 当前测试上下文
_current_role = None
_current_test_category = None
def _record(test_name, status, details="", errors=None, screenshots=None):
"""记录一条测试结果"""
results["summary"]["total"] += 1
if status == "passed":
results["summary"]["passed"] += 1
elif status == "failed":
results["summary"]["failed"] += 1
results["failures"].append({
"role": _current_role,
"category": _current_test_category,
"test": test_name,
"details": details,
"errors": errors or [],
})
elif status == "warning":
results["summary"]["warnings"] += 1
role_data = results["roles"].setdefault(_current_role, {
"label": TEST_ACCOUNTS.get(_current_role, {}).get("label", _current_role),
"categories": {},
})
cat_data = role_data["categories"].setdefault(_current_test_category, {
"tests": [],
"passed": 0,
"failed": 0,
"warnings": 0,
})
cat_data["tests"].append({
"name": test_name,
"status": status,
"details": details,
"errors": errors or [],
})
if status == "passed":
cat_data["passed"] += 1
elif status == "failed":
cat_data["failed"] += 1
elif status == "warning":
cat_data["warnings"] += 1
icon = {"passed": "", "failed": "", "warning": "⚠️"}[status]
print(f" {icon} [{_current_role}/{_current_test_category}] {test_name}: {status}"
+ (f" - {details[:120]}" if details else ""))
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 login(page, role_key):
"""登录指定角色"""
account = TEST_ACCOUNTS[role_key]
print(f"\n>>> 登录 {account['label']} ({account['email']})...")
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
# 已登录检查
if account["dashboard"] in page.url:
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.wait_for(state="visible", timeout=5000)
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.wait_for(state="visible", timeout=5000)
password_input.fill(account["password"])
# 登录按钮 - 表单内第一个 button 元素(默认 type=submit
login_btn = page.locator('form button').first
if login_btn.count() == 0:
login_btn = page.locator('button:has-text("Sign In with Email")').first
if login_btn.count() == 0:
login_btn = page.locator('button:has-text("Sign In")').first
if login_btn.count() == 0:
login_btn = page.locator('button:has-text("登录")').first
login_btn.click()
# 等待 URL 离开 /loginsignIn 是异步的router.push 后才跳转)
try:
page.wait_for_url(lambda url: "/login" not in url, timeout=15000)
except PlaywrightTimeout:
print(f" ❌ 登录超时,仍在: {page.url}")
return False
page.wait_for_timeout(2000)
if _is_login_redirect(page.url):
print(f" ❌ 登录失败,仍在登录页: {page.url}")
return False
print(f" 登录成功URL: {page.url}")
# 如果被重定向到 /onboarding完成引导流程
if "/onboarding" in page.url:
print(" 检测到未完成引导,开始完成 onboarding...")
if not complete_onboarding(page, role_key):
print(" ❌ Onboarding 完成失败")
return False
print(f" Onboarding 完成URL: {page.url}")
return True
except Exception as e:
print(f" ❌ 登录异常: {e}")
return False
def complete_onboarding(page, role_key):
"""完成 onboarding 引导流程"""
try:
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# Step 0: 角色确认 - 点击 "下一步"
next_btn = page.locator("button:has-text('下一步')").first
if next_btn.count() == 0:
next_btn = page.locator("button:has-text('Next')").first
if next_btn.count() == 0:
print(" 未找到 '下一步' 按钮(可能已在其他步骤)")
else:
next_btn.click()
page.wait_for_timeout(1000)
# Step 1: 基础信息 - 填写姓名和电话
name_input = page.locator('#onb_name').first
if name_input.count() > 0:
account = TEST_ACCOUNTS[role_key]
# 从邮箱前缀提取用户名
default_name = account["email"].split("@")[0]
name_input.fill(default_name)
phone_input = page.locator('#onb_phone').first
if phone_input.count() > 0:
phone_input.fill("13800000000")
# 点击 "下一步"
next_btn = page.locator("button:has-text('下一步')").first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(1000)
# Step 2: 角色信息
if role_key == "parent":
# 家长需要绑定子女 - 填写子女信息
child_email_input = page.locator('#onb_child_email_0').first
if child_email_input.count() > 0:
child_email_input.fill("student_g1c1_1@xiaoxue.edu.cn")
child_birth_input = page.locator('#onb_child_birth_0').first
if child_birth_input.count() > 0:
# 从 seed.ts: new Date(`2018-01-15`) for G1C1_1
child_birth_input.fill("2018-01-15")
child_phone_input = page.locator('#onb_child_phone_0').first
if child_phone_input.count() > 0:
# 学生的 guardianPhone 是 13800000001但绑定需要学生的 phone
# seed 中学生没有 phone 字段,这里尝试用 guardianPhone 后4位
child_phone_input.fill("0001")
# 点击 "下一步"
next_btn = page.locator("button:has-text('下一步')").first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(1500)
else:
# 非家长角色可以跳过 Step 2
skip_btn = page.locator("button:has-text('跳过')").first
if skip_btn.count() > 0:
skip_btn.click()
page.wait_for_timeout(1000)
else:
# 如果没有跳过按钮,尝试点击下一步
next_btn = page.locator("button:has-text('下一步')").first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(1000)
# Step 3 (or Step 2 for admin): 完成 - 点击 "完成"
finish_btn = page.locator("button:has-text('完成')").first
if finish_btn.count() == 0:
finish_btn = page.locator("button:has-text('Finish')").first
if finish_btn.count() == 0:
print(" 未找到 '完成' 按钮")
return False
finish_btn.click()
# 等待跳转到 dashboard
try:
page.wait_for_url(lambda url: "/onboarding" not in url, timeout=15000)
except PlaywrightTimeout:
print(f" Onboarding 完成后未跳转URL: {page.url}")
# 检查是否有错误 toast
error_toast = page.locator("[data-sonner-toast]:has-text('失败')")
if error_toast.count() > 0:
toast_text = error_toast.first.text_content() or ""
print(f" Onboarding 错误: {toast_text[:100]}")
return False
page.wait_for_timeout(2000)
return True
except Exception as e:
print(f" Onboarding 异常: {e}")
return False
def logout(page):
"""退出登录"""
try:
page.goto(f"{BASE_URL}/login", timeout=15000)
page.wait_for_load_state("networkidle", timeout=10000)
# 清除 cookies
page.context.clear_cookies()
page.wait_for_timeout(500)
except Exception:
pass
def safe_click(page, selector, timeout=5000):
"""安全点击元素"""
try:
el = page.locator(selector).first
el.wait_for(state="visible", timeout=timeout)
el.click(timeout=timeout)
return True
except Exception:
return False
def safe_fill(page, selector, value, timeout=5000):
"""安全填充输入框"""
try:
el = page.locator(selector).first
el.wait_for(state="visible", timeout=timeout)
el.fill(value, timeout=timeout)
return True
except Exception:
return False
def element_exists(page, selector, timeout=3000):
"""检查元素是否存在"""
try:
page.locator(selector).first.wait_for(state="visible", timeout=timeout)
return True
except Exception:
return False
def wait_for_tab_content(page, content_marker, timeout=10000):
"""等待标签页内容加载完成(骨架屏消失,实际内容出现)
Args:
page: Playwright page
content_marker: 内容标记文本(如 "保存修改")或 CSS 选择器
timeout: 超时时间(毫秒)
"""
try:
# 等待内容出现
page.locator(f"text={content_marker}").first.wait_for(state="visible", timeout=timeout)
return True
except Exception:
# 检查是否仍在加载(骨架屏)
skeleton = page.locator(".animate-pulse, [class*='skeleton']")
if skeleton.count() > 0:
# 等待更长时间
try:
page.locator(f"text={content_marker}").first.wait_for(state="visible", timeout=10000)
return True
except Exception:
return False
# 检查是否 ErrorBoundary 显示错误
body_text = page.locator("body").text_content() or ""
if "加载失败" in body_text or "load failed" in body_text.lower():
return False
return False
# ============================================================
# 测试用例 - /profile 页面
# ============================================================
def test_profile_page_loads(page, role_key):
"""测试 /profile 页面加载"""
global _current_test_category
_current_test_category = "Profile 页面"
try:
response = page.goto(f"{BASE_URL}/profile", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
http_status = response.status if response else None
if http_status and http_status >= 400:
_record("页面加载", "failed", f"HTTP {http_status}")
return
if _is_login_redirect(page.url):
_record("页面加载", "failed", "重定向到登录页")
return
# 检查页面标题 - "个人资料"
body_text = page.locator("body").text_content() or ""
if "个人资料" not in body_text and "Profile" not in body_text:
_record("页面加载", "failed", "页面缺少 '个人资料' 标题")
return
_record("页面加载", "passed", f"HTTP {http_status}")
except Exception as e:
_record("页面加载", "failed", str(e))
def test_profile_personal_info_card(page, role_key):
"""测试个人信息卡片显示"""
global _current_test_category
_current_test_category = "Profile 页面"
try:
# 检查 "个人信息" 卡片
body_text = page.locator("body").text_content() or ""
if "个人信息" not in body_text:
_record("个人信息卡片", "failed", "未找到 '个人信息' 卡片")
return
# 检查关键字段
required_fields = ["姓名", "性别", "电话", "地址"]
missing = [f for f in required_fields if f not in body_text]
if missing:
_record("个人信息卡片", "failed", f"缺少字段: {missing}")
return
_record("个人信息卡片", "passed", "所有字段显示正常")
except Exception as e:
_record("个人信息卡片", "failed", str(e))
def test_profile_account_info_card(page, role_key):
"""测试账户信息卡片显示"""
global _current_test_category
_current_test_category = "Profile 页面"
try:
body_text = page.locator("body").text_content() or ""
if "账户信息" not in body_text:
_record("账户信息卡片", "failed", "未找到 '账户信息' 卡片")
return
required_fields = ["邮箱", "角色", "注册时间"]
missing = [f for f in required_fields if f not in body_text]
if missing:
_record("账户信息卡片", "failed", f"缺少字段: {missing}")
return
# 检查邮箱格式
if "@" not in body_text:
_record("账户信息卡片", "warning", "未检测到邮箱格式")
_record("账户信息卡片", "passed", "所有字段显示正常")
except Exception as e:
_record("账户信息卡片", "failed", str(e))
def test_profile_avatar_upload_component(page, role_key):
"""测试头像上传组件显示"""
global _current_test_category
_current_test_category = "Profile 页面"
try:
# 头像上传按钮 - "上传头像"
upload_btn = page.locator("button:has-text('上传头像')")
if upload_btn.count() == 0:
# 尝试英文
upload_btn = page.locator("button:has-text('Upload')")
if upload_btn.count() == 0:
_record("头像上传组件", "failed", "未找到 '上传头像' 按钮")
return
# 检查提示文字
body_text = page.locator("body").text_content() or ""
if "JPG" not in body_text and "2MB" not in body_text:
_record("头像上传组件", "warning", "未找到文件格式/大小提示")
_record("头像上传组件", "passed", "组件渲染正常")
except Exception as e:
_record("头像上传组件", "failed", str(e))
def test_profile_role_overview(page, role_key):
"""测试角色专属概览(学生/教师)"""
global _current_test_category
_current_test_category = "Profile 页面"
try:
body_text = page.locator("body").text_content() or ""
if role_key == "student":
if "学生概览" not in body_text:
_record("学生概览", "failed", "未找到 '学生概览' 区块")
return
_record("学生概览", "passed", "学生概览区块显示")
elif role_key == "teacher":
if "教师概览" not in body_text:
_record("教师概览", "failed", "未找到 '教师概览' 区块")
return
# 检查 "任教科目" / "任教班级"
if "任教科目" not in body_text and "任教班级" not in body_text:
_record("教师概览", "warning", "未找到任教科目/班级字段")
else:
_record("教师概览", "passed", "教师概览区块显示")
else:
_record("角色概览", "passed", f"{role_key} 角色无专属概览,跳过")
except Exception as e:
_record("角色概览", "failed", str(e))
def test_profile_edit_link(page, role_key):
"""测试 '编辑资料' 链接跳转"""
global _current_test_category
_current_test_category = "Profile 页面"
try:
edit_link = page.locator("a:has-text('编辑资料')")
if edit_link.count() == 0:
edit_link = page.locator("a:has-text('Edit')")
if edit_link.count() == 0:
_record("编辑资料链接", "failed", "未找到 '编辑资料' 链接")
return
href = edit_link.get_attribute("href")
if href != "/settings":
_record("编辑资料链接", "warning", f"链接 href 异常: {href}")
else:
_record("编辑资料链接", "passed", f"链接指向 {href}")
except Exception as e:
_record("编辑资料链接", "failed", str(e))
# ============================================================
# 测试用例 - /settings 页面
# ============================================================
def test_settings_page_loads(page, role_key):
"""测试 /settings 页面加载"""
global _current_test_category
_current_test_category = "Settings 页面"
try:
response = page.goto(f"{BASE_URL}/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
http_status = response.status if response else None
if http_status and http_status >= 400:
_record("页面加载", "failed", f"HTTP {http_status}")
return
if _is_login_redirect(page.url):
_record("页面加载", "failed", "重定向到登录页")
return
body_text = page.locator("body").text_content() or ""
if "设置" not in body_text and "Settings" not in body_text:
_record("页面加载", "failed", "页面缺少 '设置' 标题")
return
_record("页面加载", "passed", f"HTTP {http_status}")
except Exception as e:
_record("页面加载", "failed", str(e))
def test_settings_tabs_visible(page, role_key):
"""测试设置页标签页显示"""
global _current_test_category
_current_test_category = "Settings 页面"
try:
# 4 个标准标签页(所有角色可见)
standard_tabs = ["通用", "通知", "外观", "安全"]
body_text = page.locator("body").text_content() or ""
missing = [t for t in standard_tabs if t not in body_text]
if missing:
_record("标准标签页", "failed", f"缺少标签页: {missing}")
return
# AI 标签页仅管理员可见
if role_key == "admin":
if "AI" not in body_text:
_record("AI 标签页", "failed", "管理员未见 AI 标签页")
return
_record("标签页显示", "passed", "所有标签页显示正常")
except Exception as e:
_record("标签页显示", "failed", str(e))
def test_settings_general_tab_profile_form(page, role_key):
"""测试通用标签页 - 个人资料表单"""
global _current_test_category
_current_test_category = "Settings - 通用标签页"
try:
# 确保在通用标签页
page.goto(f"{BASE_URL}/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 检查 "个人信息" 表单标题
body_text = page.locator("body").text_content() or ""
if "个人信息" not in body_text and "个人资料" not in body_text:
_record("个人资料表单显示", "failed", "未找到个人资料表单")
return
# 检查表单字段
required_fields = ["姓名", "邮箱", "电话", "性别", "年龄", "地址"]
missing = [f for f in required_fields if f not in body_text]
if missing:
_record("个人资料表单字段", "failed", f"缺少字段: {missing}")
return
# 检查 "保存修改" 按钮
save_btn = page.locator("button:has-text('保存修改')")
if save_btn.count() == 0:
save_btn = page.locator("button[type='submit']:has-text('Save')")
if save_btn.count() == 0:
_record("保存按钮", "failed", "未找到 '保存修改' 按钮")
return
# 检查邮箱字段是否禁用
email_input = page.locator('input[name="email"]')
if email_input.count() > 0:
is_disabled = email_input.is_disabled()
if not is_disabled:
_record("邮箱字段禁用", "warning", "邮箱字段未禁用")
else:
_record("邮箱字段禁用", "passed", "邮箱字段已禁用")
_record("个人资料表单显示", "passed", "表单字段完整")
except Exception as e:
_record("个人资料表单显示", "failed", str(e))
def test_settings_general_tab_profile_update(page, role_key):
"""测试通用标签页 - 个人资料表单提交"""
global _current_test_category
_current_test_category = "Settings - 通用标签页"
try:
page.goto(f"{BASE_URL}/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 修改姓名字段(添加测试标记后恢复)
name_input = page.locator('input[name="name"]').first
name_input.wait_for(state="visible", timeout=5000)
original_name = name_input.input_value()
test_name = original_name + "_test" if not original_name.endswith("_test") else original_name.replace("_test", "")
name_input.fill(test_name)
# 点击保存
save_btn = page.locator("button:has-text('保存修改')").first
save_btn.click()
# 等待 toast 出现
page.wait_for_timeout(3000)
# 检查是否出现成功 toast
success_toast = page.locator("[data-sonner-toast]:has-text('成功')")
if success_toast.count() == 0:
success_toast = page.locator("[data-sonner-toast]:has-text('success')")
if success_toast.count() == 0:
# 检查失败 toast
fail_toast = page.locator("[data-sonner-toast]:has-text('失败')")
if fail_toast.count() > 0:
toast_text = fail_toast.first.text_content() or ""
_record("个人资料表单提交", "failed", f"保存失败: {toast_text[:100]}")
# 恢复原始姓名
name_input.fill(original_name)
return
_record("个人资料表单提交", "warning", "未检测到成功/失败 toast")
else:
_record("个人资料表单提交", "passed", "保存成功")
# 恢复原始姓名
name_input.fill(original_name)
save_btn = page.locator("button:has-text('保存修改')").first
save_btn.click()
page.wait_for_timeout(2000)
except Exception as e:
_record("个人资料表单提交", "failed", str(e))
def test_settings_general_tab_quick_links(page, role_key):
"""测试通用标签页 - 角色快捷链接"""
global _current_test_category
_current_test_category = "Settings - 通用标签页"
try:
page.goto(f"{BASE_URL}/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
if role_key == "admin":
# 管理员无快捷链接
_record("角色快捷链接", "passed", "管理员无快捷链接(符合预期)")
elif role_key == "teacher":
if "快捷链接" not in body_text:
_record("角色快捷链接", "failed", "未找到 '快捷链接' 区块")
return
if "仪表盘" not in body_text or "教材" not in body_text:
_record("角色快捷链接", "warning", "教师快捷链接不完整")
else:
_record("角色快捷链接", "passed", "教师快捷链接显示")
elif role_key == "student":
if "快捷链接" not in body_text:
_record("角色快捷链接", "failed", "未找到 '快捷链接' 区块")
return
_record("角色快捷链接", "passed", "学生快捷链接显示")
elif role_key == "parent":
if "快捷链接" not in body_text:
_record("角色快捷链接", "failed", "未找到 '快捷链接' 区块")
return
_record("角色快捷链接", "passed", "家长快捷链接显示")
except Exception as e:
_record("角色快捷链接", "failed", str(e))
def test_settings_notifications_tab(page, role_key):
"""测试通知标签页"""
global _current_test_category
_current_test_category = "Settings - 通知标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=notifications", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 检查通知偏好标题
if "通知偏好" not in body_text:
_record("通知偏好显示", "failed", "未找到 '通知偏好' 标题")
return
# 检查渠道
channels = ["推送通知", "邮件", "短信"]
missing = [c for c in channels if c not in body_text]
if missing:
_record("通知渠道显示", "failed", f"缺少渠道: {missing}")
return
# 检查类别
categories = ["消息", "公告", "作业", "成绩", "考勤"]
missing = [c for c in categories if c not in body_text]
if missing:
_record("通知类别显示", "failed", f"缺少类别: {missing}")
return
# 检查免打扰时段
if "免打扰时段" not in body_text:
_record("免打扰时段显示", "failed", "未找到 '免打扰时段' 区块")
return
# 检查保存按钮状态dirty 检测)
save_btn = page.locator("button:has-text('保存偏好')").first
if save_btn.count() == 0:
_record("保存按钮显示", "failed", "未找到 '保存偏好' 按钮")
return
# 初始状态应禁用dirty 检测)
is_disabled = save_btn.is_disabled()
if not is_disabled:
_record("保存按钮 dirty 检测", "warning", "初始状态保存按钮未禁用")
else:
_record("保存按钮 dirty 检测", "passed", "初始状态保存按钮已禁用")
_record("通知偏好显示", "passed", "所有区块显示正常")
except Exception as e:
_record("通知偏好显示", "failed", str(e))
def test_settings_notifications_toggle(page, role_key):
"""测试通知标签页 - 切换开关"""
global _current_test_category
_current_test_category = "Settings - 通知标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=notifications", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 找到第一个 Switch 并切换
switches = page.locator('[role="switch"]')
if switches.count() == 0:
_record("切换通知开关", "failed", "未找到任何开关")
return
first_switch = switches.first
initial_state = first_switch.get_attribute("aria-checked") == "true"
first_switch.click()
page.wait_for_timeout(500)
new_state = first_switch.get_attribute("aria-checked") == "true"
if new_state == initial_state:
_record("切换通知开关", "failed", "开关状态未变化")
return
# 检查保存按钮是否启用
save_btn = page.locator("button:has-text('保存偏好')").first
if save_btn.is_disabled():
_record("切换通知开关", "warning", "切换后保存按钮仍禁用")
else:
_record("切换通知开关", "passed", "开关切换成功,保存按钮启用")
# 恢复初始状态
first_switch.click()
page.wait_for_timeout(500)
except Exception as e:
_record("切换通知开关", "failed", str(e))
def test_settings_appearance_tab(page, role_key):
"""测试外观标签页"""
global _current_test_category
_current_test_category = "Settings - 外观标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=appearance", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 检查主题标题
if "主题" not in body_text:
_record("主题卡片显示", "failed", "未找到 '主题' 标题")
return
# 检查主题选项
themes = ["跟随系统", "浅色", "深色"]
missing = [t for t in themes if t not in body_text]
if missing:
_record("主题选项显示", "failed", f"缺少主题选项: {missing}")
return
# 检查语言切换
if "语言" not in body_text:
_record("语言切换显示", "failed", "未找到 '语言' 区块")
return
_record("外观偏好显示", "passed", "主题和语言切换显示正常")
except Exception as e:
_record("外观偏好显示", "failed", str(e))
def test_settings_appearance_theme_switch(page, role_key):
"""测试外观标签页 - 主题切换"""
global _current_test_category
_current_test_category = "Settings - 外观标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=appearance", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 点击主题选择器(使用 #theme 定位,避免误选语言切换器)
# ThemePreferencesCard 中 SelectTrigger 的 id="theme"
theme_select = page.locator('#theme').first
if theme_select.count() == 0:
_record("主题切换", "failed", "未找到主题选择器 (#theme)")
return
# #theme 是 SelectTrigger点击它打开下拉
theme_select.click()
# 等待 "深色" 选项出现(确保所有选项已渲染)
try:
page.locator('[role="option"]:has-text("深色"), [role="option"]:has-text("Dark")').first.wait_for(
state="visible", timeout=8000
)
except Exception:
page.wait_for_timeout(2000)
# 检查下拉选项
options = page.locator('[role="option"]')
option_count = options.count()
if option_count < 3:
# 再等待一次
page.wait_for_timeout(1500)
option_count = options.count()
if option_count < 3:
_record("主题切换", "failed", f"主题选项数量异常: {option_count}")
return
# 选择 "深色"
dark_option = page.locator('[role="option"]:has-text("深色")')
if dark_option.count() == 0:
dark_option = page.locator('[role="option"]:has-text("Dark")')
if dark_option.count() == 0:
_record("主题切换", "warning", "未找到 '深色' 选项")
return
dark_option.click()
page.wait_for_timeout(1000)
# 检查 html class 是否包含 dark
html_class = page.locator("html").get_attribute("class") or ""
if "dark" not in html_class:
_record("主题切换", "warning", "切换后 html 未添加 dark class")
else:
_record("主题切换", "passed", "深色主题切换成功")
# 切回系统主题
theme_select.click()
page.locator('[role="option"]').first.wait_for(state="visible", timeout=5000)
system_option = page.locator('[role="option"]:has-text("跟随系统")')
if system_option.count() == 0:
system_option = page.locator('[role="option"]:has-text("System")')
if system_option.count() > 0:
system_option.click()
page.wait_for_timeout(500)
except Exception as e:
_record("主题切换", "failed", str(e))
def test_settings_security_tab(page, role_key):
"""测试安全标签页"""
global _current_test_category
_current_test_category = "Settings - 安全标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 检查修改密码表单
if "修改密码" not in body_text:
_record("修改密码表单显示", "failed", "未找到 '修改密码' 标题")
return
# 检查密码字段
required_fields = ["当前密码", "新密码", "确认新密码"]
missing = [f for f in required_fields if f not in body_text]
if missing:
_record("密码字段显示", "failed", f"缺少字段: {missing}")
return
# 检查安全中心
if "安全中心" not in body_text:
_record("安全中心显示", "failed", "未找到 '安全中心' 区块")
return
# 检查 2FA 区块
if "两步验证" not in body_text and "2FA" not in body_text:
_record("2FA 区块显示", "failed", "未找到 2FA 区块")
return
# 检查 "即将推出" 提示2FA 应为禁用状态)
if "即将推出" not in body_text and "coming soon" not in body_text.lower():
_record("2FA 禁用状态", "warning", "未找到 '即将推出' 提示")
# 检查最近登录
if "最近登录" not in body_text:
_record("最近登录显示", "failed", "未找到 '最近登录' 区块")
return
# 检查会话区块
if "会话" not in body_text:
_record("会话区块显示", "failed", "未找到 '会话' 区块")
return
# 检查 "退出登录" 按钮
signout_btn = page.locator("button:has-text('退出登录')")
if signout_btn.count() == 0:
_record("退出登录按钮", "failed", "未找到 '退出登录' 按钮")
return
_record("安全标签页显示", "passed", "所有区块显示正常")
except Exception as e:
_record("安全标签页显示", "failed", str(e))
def test_settings_security_2fa_disabled(page, role_key):
"""测试安全标签页 - 2FA 开关禁用状态"""
global _current_test_category
_current_test_category = "Settings - 安全标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
# 找到 2FA 的 Switch第一个 Switch 在 2FA 区块)
switches = page.locator('[role="switch"]')
if switches.count() == 0:
_record("2FA 开关禁用", "failed", "未找到任何开关")
return
# 2FA 开关应为禁用状态
first_switch = switches.first
is_disabled = first_switch.is_disabled()
if not is_disabled:
_record("2FA 开关禁用", "failed", "2FA 开关未禁用(应为 '即将推出' 状态)")
return
_record("2FA 开关禁用", "passed", "2FA 开关已禁用('即将推出' 状态)")
except Exception as e:
_record("2FA 开关禁用", "failed", str(e))
def test_settings_security_password_validation(page, role_key):
"""测试安全标签页 - 密码修改表单验证"""
global _current_test_category
_current_test_category = "Settings - 安全标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 填写错误的当前密码
current_input = page.locator('input[name="currentPassword"]').first
current_input.wait_for(state="visible", timeout=5000)
current_input.fill("wrongpassword")
new_input = page.locator('input[name="newPassword"]').first
new_input.fill("NewPass123!")
confirm_input = page.locator('input[name="confirmPassword"]').first
confirm_input.fill("NewPass123!")
# 点击更新密码
submit_btn = page.locator("button:has-text('更新密码')").first
submit_btn.click()
# 等待 toast
page.wait_for_timeout(3000)
# 应该出现失败 toast当前密码错误
fail_toast = page.locator("[data-sonner-toast]:has-text('失败')")
if fail_toast.count() == 0:
fail_toast = page.locator("[data-sonner-toast]:has-text('错误')")
if fail_toast.count() == 0:
fail_toast = page.locator("[data-sonner-toast]:has-text('incorrect')")
if fail_toast.count() == 0:
_record("密码修改验证", "warning", "未检测到失败 toast可能密码错误未触发")
else:
_record("密码修改验证", "passed", "错误密码触发失败提示")
except Exception as e:
_record("密码修改验证", "failed", str(e))
def test_settings_security_revoke_sessions_button(page, role_key):
"""测试安全标签页 - 登出其他会话按钮"""
global _current_test_category
_current_test_category = "Settings - 安全标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
# 检查 "登出所有其他会话" 按钮
revoke_btn = page.locator("button:has-text('登出所有其他会话')")
if revoke_btn.count() == 0:
# 可能没有登录历史记录,按钮不显示
body_text = page.locator("body").text_content() or ""
if "暂无登录记录" in body_text or "empty" in body_text.lower():
_record("登出其他会话按钮", "passed", "无登录记录,按钮未显示(符合预期)")
return
_record("登出其他会话按钮", "warning", "未找到 '登出所有其他会话' 按钮")
return
_record("登出其他会话按钮", "passed", "按钮显示正常")
except Exception as e:
_record("登出其他会话按钮", "failed", str(e))
def test_settings_security_password_strength(page, role_key):
"""测试安全标签页 - 密码强度指示器"""
global _current_test_category
_current_test_category = "Settings - 安全标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
new_input = page.locator('input[name="newPassword"]').first
new_input.wait_for(state="visible", timeout=5000)
# 输入弱密码
new_input.fill("123")
page.wait_for_timeout(500)
body_text = page.locator("body").text_content() or ""
if "" not in body_text and "Weak" not in body_text:
_record("密码强度指示器", "warning", "弱密码未显示 '' 标签")
else:
# 输入强密码
new_input.fill("StrongP@ssw0rd2024!")
page.wait_for_timeout(500)
body_text = page.locator("body").text_content() or ""
if "" not in body_text and "Strong" not in body_text:
_record("密码强度指示器", "warning", "强密码未显示 '' 标签")
else:
_record("密码强度指示器", "passed", "强度指示器工作正常")
return
_record("密码强度指示器", "passed", "强度指示器显示")
except Exception as e:
_record("密码强度指示器", "failed", str(e))
def test_settings_ai_tab_admin_only(page, role_key):
"""测试 AI 标签页 - 仅管理员可见"""
global _current_test_category
_current_test_category = "Settings - AI 标签页"
try:
page.goto(f"{BASE_URL}/settings?tab=ai", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
if role_key == "admin":
# 管理员应能看到 AI 服务商配置
# 等待 AI 卡片标题出现CardTitle 渲染 "AI 服务商"
try:
page.locator('text=AI 服务商').first.wait_for(state="visible", timeout=10000)
_record("AI 标签页显示", "passed", "管理员可见 AI 服务商配置")
except Exception:
# 也检查英文
try:
page.locator('text=AI Provider').first.wait_for(state="visible", timeout=3000)
_record("AI 标签页显示", "passed", "管理员可见 AI 服务商配置")
except Exception:
_record("AI 标签页显示", "failed", "管理员未见 AI 服务商配置")
return
else:
# 非管理员应回退到 general 标签页(不应看到 AI 内容)
# 检查可见的 tabpanel 内容
visible_panel = page.locator('[role="tabpanel"]:visible').text_content() or ""
if "AI 服务商" in visible_panel:
_record("AI 标签页权限", "failed", f"{role_key} 角色不应能访问 AI 标签页")
return
# 也检查 AI tab trigger 是否不存在
ai_trigger = page.locator('[role="tab"][value="ai"]')
if ai_trigger.count() > 0:
_record("AI 标签页权限", "failed", f"{role_key} 角色不应看到 AI 标签页按钮")
return
_record("AI 标签页权限", "passed", f"{role_key} 角色无法访问 AI 标签页(符合预期)")
except Exception as e:
_record("AI 标签页显示", "failed", str(e))
# ============================================================
# 测试用例 - /admin/settings 页面
# ============================================================
def test_admin_settings_page_loads(page, role_key):
"""测试 /admin/settings 页面加载"""
global _current_test_category
_current_test_category = "Admin Settings 页面"
try:
response = page.goto(f"{BASE_URL}/admin/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
http_status = response.status if response else None
if role_key != "admin":
# 非管理员应被拒绝访问
if _is_login_redirect(page.url) or (http_status and http_status >= 400):
_record("权限控制", "passed", f"{role_key} 角色被拒绝访问(符合预期)")
return
# 检查是否显示 403/404
body_text = page.locator("body").text_content() or ""
if "403" in body_text or "Forbidden" in body_text or "无权限" in body_text:
_record("权限控制", "passed", f"{role_key} 角色被拒绝访问")
return
_record("权限控制", "warning", f"{role_key} 角色访问结果未明确: HTTP {http_status}, URL: {page.url}")
return
# 管理员应能访问
if http_status and http_status >= 400:
_record("页面加载", "failed", f"HTTP {http_status}")
return
if _is_login_redirect(page.url):
_record("页面加载", "failed", "重定向到登录页")
return
body_text = page.locator("body").text_content() or ""
if "系统设置" not in body_text:
_record("页面加载", "failed", "未找到 '系统设置' 标题")
return
_record("页面加载", "passed", f"HTTP {http_status}")
except Exception as e:
_record("页面加载", "failed", str(e))
def test_admin_settings_cards_display(page, role_key):
"""测试管理员系统设置 - 4 个卡片显示"""
global _current_test_category
_current_test_category = "Admin Settings 页面"
if role_key != "admin":
_record("卡片显示", "passed", f"{role_key} 角色跳过")
return
try:
page.goto(f"{BASE_URL}/admin/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
body_text = page.locator("body").text_content() or ""
required_cards = ["学校信息", "安全策略", "文件上传", "通知配置"]
missing = [c for c in required_cards if c not in body_text]
if missing:
_record("卡片显示", "failed", f"缺少卡片: {missing}")
return
# 检查学校信息字段
school_fields = ["学校名称", "学校代码", "联系电话", "联系邮箱", "学校地址", "学校简介"]
missing = [f for f in school_fields if f not in body_text]
if missing:
_record("学校信息字段", "failed", f"缺少字段: {missing}")
return
# 检查安全策略字段
security_fields = ["密码最小长度", "会话超时", "密码必须包含特殊字符", "密码必须包含大写字母", "首次登录强制修改密码"]
missing = [f for f in security_fields if f not in body_text]
if missing:
_record("安全策略字段", "failed", f"缺少字段: {missing}")
return
# 检查文件上传字段
upload_fields = ["单文件最大大小", "允许的文件类型"]
missing = [f for f in upload_fields if f not in body_text]
if missing:
_record("文件上传字段", "failed", f"缺少字段: {missing}")
return
# 检查通知配置字段
notify_fields = ["新用户注册通知管理员", "课表变更通知教师", "公告发布通知目标用户"]
missing = [f for f in notify_fields if f not in body_text]
if missing:
_record("通知配置字段", "failed", f"缺少字段: {missing}")
return
_record("卡片显示", "passed", "4 个卡片及所有字段显示正常")
except Exception as e:
_record("卡片显示", "failed", str(e))
def test_admin_settings_dirty_detection(page, role_key):
"""测试管理员系统设置 - dirty 检测"""
global _current_test_category
_current_test_category = "Admin Settings 页面"
if role_key != "admin":
_record("dirty 检测", "passed", f"{role_key} 角色跳过")
return
try:
page.goto(f"{BASE_URL}/admin/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
# 初始状态保存按钮应禁用
save_btn = page.locator("button:has-text('保存设置')").first
if save_btn.count() == 0:
save_btn = page.locator("button[type='submit']").first
if save_btn.count() == 0:
_record("dirty 检测", "failed", "未找到保存按钮")
return
is_disabled = save_btn.is_disabled()
if not is_disabled:
_record("dirty 检测", "warning", "初始状态保存按钮未禁用")
else:
# 修改一个字段
school_name_input = page.locator('#school-name').first
if school_name_input.count() == 0:
_record("dirty 检测", "failed", "未找到学校名称输入框")
return
original_value = school_name_input.input_value()
school_name_input.fill(original_value + "_test")
page.wait_for_timeout(500)
# 保存按钮应启用
is_disabled = save_btn.is_disabled()
if is_disabled:
_record("dirty 检测", "failed", "修改后保存按钮仍禁用")
else:
# 重置按钮也应启用
reset_btn = page.locator("button:has-text('重置')").first
if reset_btn.count() > 0 and not reset_btn.is_disabled():
# 点击重置
reset_btn.click()
page.wait_for_timeout(500)
# 保存按钮应再次禁用
if save_btn.is_disabled():
_record("dirty 检测", "passed", "dirty 检测和重置功能正常")
else:
_record("dirty 检测", "warning", "重置后保存按钮未禁用")
else:
_record("dirty 检测", "passed", "dirty 检测正常(重置按钮未启用)")
# 恢复原始值
school_name_input.fill(original_value)
page.wait_for_timeout(500)
except Exception as e:
_record("dirty 检测", "failed", str(e))
def test_admin_settings_save_and_reset(page, role_key):
"""测试管理员系统设置 - 保存和重置"""
global _current_test_category
_current_test_category = "Admin Settings 页面"
if role_key != "admin":
_record("保存和重置", "passed", f"{role_key} 角色跳过")
return
try:
page.goto(f"{BASE_URL}/admin/settings", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
# 修改学校名称
school_name_input = page.locator('#school-name').first
if school_name_input.count() == 0:
_record("保存和重置", "failed", "未找到学校名称输入框")
return
original_value = school_name_input.input_value()
test_value = f"测试学校_{int(time.time())}"
school_name_input.fill(test_value)
page.wait_for_timeout(500)
# 点击保存
save_btn = page.locator("button:has-text('保存设置')").first
save_btn.click()
# 等待 toast
page.wait_for_timeout(3000)
# 检查成功 toast
success_toast = page.locator("[data-sonner-toast]:has-text('保存成功')")
if success_toast.count() == 0:
success_toast = page.locator("[data-sonner-toast]:has-text('success')")
if success_toast.count() == 0:
fail_toast = page.locator("[data-sonner-toast]:has-text('失败')")
if fail_toast.count() > 0:
toast_text = fail_toast.first.text_content() or ""
_record("保存和重置", "failed", f"保存失败: {toast_text[:100]}")
school_name_input.fill(original_value)
return
_record("保存和重置", "warning", "未检测到 toast")
else:
_record("保存和重置", "passed", "保存成功")
# 恢复原始值
school_name_input.fill(original_value)
page.wait_for_timeout(500)
save_btn = page.locator("button:has-text('保存设置')").first
save_btn.click()
page.wait_for_timeout(2000)
except Exception as e:
_record("保存和重置", "failed", str(e))
# ============================================================
# 主测试流程
# ============================================================
def run_tests_for_role(page, role_key):
"""运行指定角色的所有测试"""
global _current_role
_current_role = role_key
print(f"\n{'='*60}")
print(f"测试角色: {TEST_ACCOUNTS[role_key]['label']} ({role_key})")
print(f"{'='*60}")
# 登录
if not login(page, role_key):
print(f"{role_key} 登录失败,跳过该角色所有测试")
global _current_test_category
_current_test_category = "登录"
_record("登录", "failed", f"{role_key} 登录失败")
return
# Profile 页面测试
print(f"\n--- Profile 页面测试 ---")
test_profile_page_loads(page, role_key)
test_profile_personal_info_card(page, role_key)
test_profile_account_info_card(page, role_key)
test_profile_avatar_upload_component(page, role_key)
test_profile_role_overview(page, role_key)
test_profile_edit_link(page, role_key)
# Settings 页面测试
print(f"\n--- Settings 页面测试 ---")
test_settings_page_loads(page, role_key)
test_settings_tabs_visible(page, role_key)
# 通用标签页
print(f"\n--- Settings - 通用标签页 ---")
test_settings_general_tab_profile_form(page, role_key)
test_settings_general_tab_profile_update(page, role_key)
test_settings_general_tab_quick_links(page, role_key)
# 通知标签页
print(f"\n--- Settings - 通知标签页 ---")
test_settings_notifications_tab(page, role_key)
test_settings_notifications_toggle(page, role_key)
# 外观标签页
print(f"\n--- Settings - 外观标签页 ---")
test_settings_appearance_tab(page, role_key)
test_settings_appearance_theme_switch(page, role_key)
# 安全标签页
print(f"\n--- Settings - 安全标签页 ---")
test_settings_security_tab(page, role_key)
test_settings_security_2fa_disabled(page, role_key)
test_settings_security_password_validation(page, role_key)
test_settings_security_password_strength(page, role_key)
test_settings_security_revoke_sessions_button(page, role_key)
# AI 标签页
print(f"\n--- Settings - AI 标签页 ---")
test_settings_ai_tab_admin_only(page, role_key)
# Admin Settings 页面(仅管理员)
print(f"\n--- Admin Settings 页面测试 ---")
test_admin_settings_page_loads(page, role_key)
test_admin_settings_cards_display(page, role_key)
test_admin_settings_dirty_detection(page, role_key)
test_admin_settings_save_and_reset(page, role_key)
# 退出登录
logout(page)
def generate_report():
"""生成 Markdown 测试报告"""
report = []
report.append("# 设置和个人信息模块 Web 功能测试报告")
report.append("")
report.append(f"> 测试日期:{results['test_date']}")
report.append(f"> 测试范围:设置和个人信息模块(`/profile`、`/settings`、`/admin/settings`")
report.append(f"> 测试工具Playwright + Chromium (headless)")
report.append(f"> Base URL{results['base_url']}")
report.append(f"> 测试账号:")
for key, acc in TEST_ACCOUNTS.items():
report.append(f"> - {acc['label']}{key}{acc['email']}")
report.append("")
report.append("---")
report.append("")
# 测试概览
report.append("## 一、测试概览")
report.append("")
s = results["summary"]
report.append("| 指标 | 数值 |")
report.append("|------|------|")
report.append(f"| 总测试用例数 | {s['total']} |")
report.append(f"| 通过 | {s['passed']} |")
report.append(f"| 失败 | {s['failed']} |")
report.append(f"| 警告 | {s['warnings']} |")
pass_rate = (s['passed'] / s['total'] * 100) if s['total'] > 0 else 0
report.append(f"| 通过率 | {pass_rate:.1f}% |")
report.append("")
report.append("---")
report.append("")
# 按角色分组
report.append("## 二、按角色测试结果")
report.append("")
report.append("| 角色 | 总数 | 通过 | 失败 | 警告 | 通过率 |")
report.append("|------|------|------|------|------|--------|")
for role_key, role_data in results["roles"].items():
total = sum(c["passed"] + c["failed"] + c["warnings"] for c in role_data["categories"].values())
passed = sum(c["passed"] for c in role_data["categories"].values())
failed = sum(c["failed"] for c in role_data["categories"].values())
warnings = sum(c["warnings"] for c in role_data["categories"].values())
rate = (passed / total * 100) if total > 0 else 0
report.append(f"| {role_data['label']}{role_key} | {total} | {passed} | {failed} | {warnings} | {rate:.1f}% |")
report.append("")
report.append("---")
report.append("")
# 详细测试结果
report.append("## 三、详细测试结果")
report.append("")
for role_key, role_data in results["roles"].items():
report.append(f"### {role_data['label']}{role_key}")
report.append("")
for category, cat_data in role_data["categories"].items():
report.append(f"#### {category}")
report.append("")
report.append("| 状态 | 测试用例 | 详情 |")
report.append("|------|----------|------|")
for test in cat_data["tests"]:
icon = {"passed": "", "failed": "", "warning": "⚠️"}[test["status"]]
details = test["details"].replace("|", "\\|") if test["details"] else "-"
if test["errors"]:
err_str = "; ".join(test["errors"][:2]).replace("|", "\\|")
details = f"{details}<br>错误: {err_str}" if details != "-" else f"错误: {err_str}"
report.append(f"| {icon} | {test['name']} | {details} |")
report.append("")
report.append("---")
report.append("")
# 失败用例汇总
if results["failures"]:
report.append("## 四、失败用例汇总")
report.append("")
for f in results["failures"]:
report.append(f"### ❌ [{f['role']}/{f['category']}] {f['test']}")
report.append("")
report.append(f"- **详情**: {f['details']}")
if f["errors"]:
report.append(f"- **错误**:")
for err in f["errors"]:
report.append(f" - {err}")
report.append("")
report.append("---")
report.append("")
# 改进建议
report.append("## 五、测试结论与改进建议")
report.append("")
if results["summary"]["failed"] == 0:
report.append("### ✅ 测试通过")
report.append("")
report.append("设置和个人信息模块在所有 4 个角色(管理员/教师/学生/家长)下的所有功能均正常工作。")
else:
report.append(f"### ⚠️ 发现 {results['summary']['failed']} 个失败用例")
report.append("")
report.append("需要修复的失败用例详见上文 '失败用例汇总' 章节。")
report.append("")
report.append("### 测试覆盖范围")
report.append("")
report.append("- **Profile 页面**`/profile`):页面加载、个人信息卡片、账户信息卡片、头像上传组件、角色概览、编辑资料链接")
report.append("- **Settings 页面**`/settings`5 个标签页(通用/通知/外观/安全/AI")
report.append(" - 通用:个人资料表单显示与提交、角色快捷链接")
report.append(" - 通知:通知渠道/类别/免打扰时段显示、开关切换、dirty 检测")
report.append(" - 外观:主题切换、语言切换")
report.append(" - 安全修改密码表单、密码强度指示器、密码验证、2FA 禁用状态、最近登录、登出其他会话、退出登录")
report.append(" - AI管理员可见其他角色不可见")
report.append("- **Admin Settings 页面**`/admin/settings`4 个卡片显示、dirty 检测、保存与重置、权限控制")
report.append("")
report.append("---")
report.append("")
report.append(f"*报告自动生成于 {results['test_date']}*")
return "\n".join(report)
def main():
"""主测试函数"""
print("=" * 60)
print("设置和个人信息模块 - 全功能 Web 测试")
print(f"测试时间: {results['test_date']}")
print(f"Base URL: {BASE_URL}")
print("=" * 60)
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()
# 收集控制台错误
console_errors = []
def on_console(msg):
try:
if msg.type == "error":
text = msg.text[:200]
console_errors.append(text)
except Exception:
pass
page.on("console", on_console)
# 按角色顺序测试
for role_key in ["admin", "teacher", "student", "parent"]:
try:
run_tests_for_role(page, role_key)
except Exception as e:
print(f"\n{role_key} 测试过程中断: {e}")
traceback.print_exc()
# 清理 cookies 以便继续下一个角色
try:
page.context.clear_cookies()
except Exception:
pass
browser.close()
# 生成报告
print("\n\n" + "=" * 60)
print("生成测试报告...")
print("=" * 60)
report = generate_report()
# 写入文件
output_dir = os.path.join(os.path.dirname(__file__), "..", "..", "webtest")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "settings_v1.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}")
# 打印汇总
s = results["summary"]
print(f"\n{'='*60}")
print(f"测试完成: 总计 {s['total']}, 通过 {s['passed']}, 失败 {s['failed']}, 警告 {s['warnings']}")
print(f"通过率: {(s['passed']/s['total']*100) if s['total'] > 0 else 0:.1f}%")
print(f"{'='*60}")
return 0 if s["failed"] == 0 else 1
if __name__ == "__main__":
sys.exit(main())