"""
设置和个人信息模块 - 全功能 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())
