- 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
1660 lines
63 KiB
Python
1660 lines
63 KiB
Python
"""
|
||
设置和个人信息模块 - 全功能 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 离开 /login(signIn 是异步的,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())
|