"""
教材模块（textbooks）全功能 Web 测试脚本
使用 Playwright 对所有角色的教材模块访问与功能进行测试

覆盖：
- admin（管理员：有 TEXTBOOK_* 全权限，但导航无教材入口；测试 /teacher/textbooks 访问）
- teacher（教师：完整 CRUD + 章节 + 知识点 + 知识图谱 + 设置/删除）
- student（学生：只读访问 /student/learning/textbooks，按年级过滤，无写操作按钮）
- parent（家长：有 TEXTBOOK_READ 权限但无路由入口；应被拒绝访问 /teacher/textbooks）

结果输出：webtest/textbooks_v1.md 与 webtest/textbooks_v1.json
"""
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

BASE_URL = "http://localhost:3000"
VERSION = "v1"
MODULE_NAME = "textbooks"

# 测试账号（来自 scripts/seed.ts）
TEST_ACCOUNTS = {
    "admin": {"email": "admin@xiaoxue.edu.cn", "password": "123456", "expected_path": "/admin/dashboard"},
    "teacher": {"email": "t_chinese_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/teacher/dashboard"},
    "student": {"email": "student_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/student/dashboard"},
    "parent": {"email": "parent_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/parent/dashboard"},
}

# 教材模块路由
TEXTBOOK_ROUTES = {
    "teacher_list": "/teacher/textbooks",
    "student_list": "/student/learning/textbooks",
}

PROJECT_ROOT = Path(__file__).resolve().parents[1]
WEBTEST_DIR = PROJECT_ROOT / "webtest"
SCREENSHOT_DIR = WEBTEST_DIR / "screenshots" / MODULE_NAME
WEBTEST_DIR.mkdir(parents=True, exist_ok=True)
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)

results = {
    "test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    "module": "教材 (textbooks)",
    "version": VERSION,
    "base_url": BASE_URL,
    "summary": {
        "total": 0,
        "passed": 0,
        "failed": 0,
        "warnings": 0,
    },
    "roles": {},
    "teacher_features": {},
    "student_features": {},
    "console_errors_global": [],
}


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 _is_forbidden_redirect(url: str) -> bool:
    """判断是否因权限不足被重定向（带 from/reason=forbidden 参数）"""
    parsed = urlparse(url)
    query = parse_qs(parsed.query)
    return query.get("reason", [""])[0] == "forbidden"


def safe_text(locator, max_len=500):
    try:
        text = locator.text_content()
        return (text or "").strip()[:max_len]
    except Exception:
        return ""


def login(page, role: str) -> bool:
    """登录指定角色账号（带重试）"""
    account = TEST_ACCOUNTS[role]
    print(f"\n>>> 登录 {role} 账号 ({account['email']})...")

    for attempt in range(3):
        try:
            page.goto(f"{BASE_URL}/login", timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(1000)

            current_path = urlparse(page.url).path
            if current_path.startswith(account["expected_path"]) or current_path.endswith("/dashboard"):
                print(f"    已登录，跳过登录步骤 (URL: {page.url})")
                return True

            email_input = page.locator('input[name="email"]')
            if email_input.count() == 0:
                email_input = page.locator('input[type="email"]')
            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.fill(account["password"])

            login_btn = page.locator('button[type="submit"]')
            if login_btn.count() == 0:
                login_btn = page.get_by_role("button", name="Sign In with Email")
            if login_btn.count() == 0:
                login_btn = page.locator("button").filter(has_text="Sign In")
            if login_btn.count() == 0:
                login_btn = page.locator("button").filter(has_text="登录")
            login_btn.click()

            page.wait_for_load_state("networkidle", timeout=20000)
            page.wait_for_timeout(2000)

            print(f"    登录后 URL: {page.url}")
            if "/login" in page.url:
                print(f"    ⚠️ {role} 登录失败 (尝试 {attempt + 1}/3)")
                if attempt < 2:
                    page.context.clear_cookies()
                    page.wait_for_timeout(2000)
                    continue
                return False
            return True
        except Exception as e:
            print(f"    ⚠️ {role} 登录异常 (尝试 {attempt + 1}/3): {e}")
            if attempt < 2:
                page.wait_for_timeout(2000)
                continue
            return False
    return False


def logout(page):
    """退出当前登录状态"""
    page.context.clear_cookies()
    print("    已清除 cookies 退出登录")


def collect_console_errors(page, role: str = "current"):
    """附加控制台错误收集器"""
    errors = []

    def on_console(msg):
        if msg.type == "error":
            text = msg.text
            if "favicon" in text.lower() or "Download the React DevTools" in text:
                return
            errors.append(text)
            results["console_errors_global"].append({"role": role, "error": text})

    page.on("console", on_console)
    return errors, on_console


# ============ 角色访问权限测试 ============

def test_role_access(page, role: str) -> dict:
    """测试单个角色对教材模块的访问权限"""
    role_result = {
        "role": role,
        "login_success": False,
        "teacher_list_page": {"status": "unknown", "http_status": None, "final_url": None, "errors": [], "warnings": []},
        "student_list_page": {"status": "unknown", "http_status": None, "final_url": None, "errors": [], "warnings": []},
        "checks": [],
        "screenshots": [],
    }

    print(f"\n=== 测试 {role} 访问教材模块 ===")

    if not login(page, role):
        role_result["errors"] = [f"{role} 登录失败"]
        return role_result
    role_result["login_success"] = True

    # 访问教师端教材列表页 /teacher/textbooks
    url = f"{BASE_URL}/teacher/textbooks"
    console_errors, on_console = collect_console_errors(page, role)
    try:
        response = page.goto(url, timeout=30000)
        page.wait_for_load_state("networkidle", timeout=15000)
        page.wait_for_timeout(1500)

        http_status = response.status if response else None
        final_url = page.url
        role_result["teacher_list_page"]["http_status"] = http_status
        role_result["teacher_list_page"]["final_url"] = final_url

        screenshot_name = f"access_{role}_teacher_list.png"
        page.screenshot(path=str(SCREENSHOT_DIR / screenshot_name), full_page=True)
        role_result["screenshots"].append(screenshot_name)

        # 判定状态
        if _is_login_redirect(final_url):
            role_result["teacher_list_page"]["status"] = "failed"
            role_result["teacher_list_page"]["errors"].append("重定向到登录页")
        elif _is_forbidden_redirect(final_url):
            # 对于 student/parent 这是预期行为
            if role in ("student", "parent"):
                role_result["teacher_list_page"]["status"] = "passed"
                role_result["checks"].append({
                    "name": f"{role} 无权限访问教师教材页被正确拦截",
                    "passed": True,
                    "detail": f"重定向到 {final_url}"
                })
            else:
                role_result["teacher_list_page"]["status"] = "failed"
                role_result["teacher_list_page"]["errors"].append(f"权限不足被重定向到 {final_url}")
        elif http_status and http_status >= 500:
            role_result["teacher_list_page"]["status"] = "failed"
            role_result["teacher_list_page"]["errors"].append(f"HTTP {http_status}")
        elif http_status and http_status >= 400:
            role_result["teacher_list_page"]["status"] = "failed"
            role_result["teacher_list_page"]["errors"].append(f"HTTP {http_status}")
        else:
            # 成功访问
            if role in ("admin", "teacher"):
                role_result["teacher_list_page"]["status"] = "passed"
                role_result["checks"].append({
                    "name": f"{role} 有权访问教师教材列表页",
                    "passed": True,
                    "detail": f"HTTP {http_status}"
                })
            else:
                role_result["teacher_list_page"]["status"] = "failed"
                role_result["teacher_list_page"]["errors"].append("无权限用户却成功访问了教师教材列表页")

        if console_errors:
            role_result["teacher_list_page"]["warnings"].append(f"控制台错误 {len(console_errors)} 条")
            if role_result["teacher_list_page"]["status"] == "passed":
                role_result["teacher_list_page"]["status"] = "warning"

    except PlaywrightTimeout:
        role_result["teacher_list_page"]["status"] = "failed"
        role_result["teacher_list_page"]["errors"].append("页面加载超时")
    except Exception as e:
        role_result["teacher_list_page"]["status"] = "failed"
        role_result["teacher_list_page"]["errors"].append(str(e)[:200])
    finally:
        try:
            page.remove_listener("console", on_console)
        except Exception:
            pass

    # 访问学生端教材列表页 /student/learning/textbooks
    url = f"{BASE_URL}/student/learning/textbooks"
    console_errors, on_console = collect_console_errors(page, role)
    try:
        response = page.goto(url, timeout=30000)
        page.wait_for_load_state("networkidle", timeout=15000)
        page.wait_for_timeout(1500)

        http_status = response.status if response else None
        final_url = page.url
        role_result["student_list_page"]["http_status"] = http_status
        role_result["student_list_page"]["final_url"] = final_url

        screenshot_name = f"access_{role}_student_list.png"
        page.screenshot(path=str(SCREENSHOT_DIR / screenshot_name), full_page=True)
        role_result["screenshots"].append(screenshot_name)

        if _is_login_redirect(final_url):
            role_result["student_list_page"]["status"] = "failed"
            role_result["student_list_page"]["errors"].append("重定向到登录页")
        elif _is_forbidden_redirect(final_url):
            if role in ("admin", "teacher", "parent"):
                # admin/teacher/parent 访问学生端教材页被拦截是合理的
                role_result["student_list_page"]["status"] = "passed"
                role_result["checks"].append({
                    "name": f"{role} 访问学生端教材页被拦截（符合预期）",
                    "passed": True,
                    "detail": f"重定向到 {final_url}"
                })
            else:
                role_result["student_list_page"]["status"] = "failed"
                role_result["student_list_page"]["errors"].append(f"权限不足被重定向到 {final_url}")
        elif http_status and http_status >= 500:
            role_result["student_list_page"]["status"] = "failed"
            role_result["student_list_page"]["errors"].append(f"HTTP {http_status}")
        elif http_status and http_status >= 400:
            role_result["student_list_page"]["status"] = "failed"
            role_result["student_list_page"]["errors"].append(f"HTTP {http_status}")
        else:
            # 成功访问
            if role == "student":
                role_result["student_list_page"]["status"] = "passed"
                role_result["checks"].append({
                    "name": "学生有权访问学生端教材列表页",
                    "passed": True,
                    "detail": f"HTTP {http_status}"
                })
            else:
                # 其他角色访问学生端教材页：可能被重定向到自己的 dashboard，也算通过
                role_result["student_list_page"]["status"] = "passed"
                role_result["checks"].append({
                    "name": f"{role} 访问学生端教材页（重定向到自己的 dashboard）",
                    "passed": True,
                    "detail": f"final_url={final_url}"
                })

        if console_errors:
            role_result["student_list_page"]["warnings"].append(f"控制台错误 {len(console_errors)} 条")
            if role_result["student_list_page"]["status"] == "passed":
                role_result["student_list_page"]["status"] = "warning"

    except PlaywrightTimeout:
        role_result["student_list_page"]["status"] = "failed"
        role_result["student_list_page"]["errors"].append("页面加载超时")
    except Exception as e:
        role_result["student_list_page"]["status"] = "failed"
        role_result["student_list_page"]["errors"].append(str(e)[:200])
    finally:
        try:
            page.remove_listener("console", on_console)
        except Exception:
            pass

    return role_result


# ============ 教师完整功能测试 ============

def test_teacher_full_features(page) -> dict:
    """测试教师对教材模块的完整功能：列表、筛选、新建、详情、章节、知识点、设置、删除"""
    feature_result = {
        "role": "teacher",
        "features": {},
        "screenshots": [],
        "errors": [],
        "warnings": [],
        "created_textbook_id": None,
    }

    print("\n=== 测试教师教材模块完整功能 ===")

    # ---- 1. 列表页功能 ----
    print("\n>>> [1/8] 测试列表页功能...")
    list_feature = {"name": "列表页", "status": "unknown", "checks": [], "errors": []}
    try:
        page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000)
        page.wait_for_load_state("networkidle", timeout=15000)
        page.wait_for_timeout(1500)

        # 检查标题
        body_text = page.locator("body").text_content() or ""
        title_keys = ["教材", "Textbook", "textbook"]
        title_match = any(k in body_text for k in title_keys)
        list_feature["checks"].append({
            "name": "页面标题包含'教材'/'Textbook'",
            "passed": title_match,
            "detail": f"body 长度 {len(body_text)}"
        })

        # 检查"新建教材"按钮
        new_btn = page.locator('button').filter(has_text=re.compile(r"新建教材|新建|Add|Create"))
        new_btn_count = new_btn.count()
        list_feature["checks"].append({
            "name": "存在'新建教材'按钮",
            "passed": new_btn_count > 0,
            "detail": f"找到 {new_btn_count} 个"
        })

        # 检查筛选器
        filter_inputs = page.locator('input, select').count()
        list_feature["checks"].append({
            "name": "存在筛选器",
            "passed": filter_inputs > 0,
            "detail": f"找到 {filter_inputs} 个 input/select"
        })

        # 检查教材卡片
        cards = page.locator('a[href*="/teacher/textbooks/"]').count()
        list_feature["checks"].append({
            "name": "教材卡片或空状态",
            "passed": True,
            "detail": f"找到 {cards} 个教材链接"
        })

        page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_list.png"), full_page=True)
        feature_result["screenshots"].append("teacher_feature_list.png")

        list_feature["status"] = "passed"
        print("    ✅ 列表页测试通过")
    except Exception as e:
        list_feature["status"] = "failed"
        list_feature["errors"].append(str(e)[:200])
        print(f"    ❌ 列表页测试失败: {e}")

    feature_result["features"]["list"] = list_feature

    # ---- 2. 筛选功能 ----
    print("\n>>> [2/8] 测试筛选功能...")
    filter_feature = {"name": "筛选", "status": "unknown", "checks": [], "errors": []}
    try:
        # 测试搜索筛选
        search_input = page.locator('input[placeholder*="搜索"], input[placeholder*="search"]').first
        if search_input.count() > 0:
            search_input.fill("数学")
            page.wait_for_timeout(1500)  # 等待 URL 更新和页面刷新
            page.wait_for_load_state("networkidle", timeout=10000)

            # 检查 URL 是否包含搜索参数
            current_url = page.url
            has_search_param = "q=" in current_url or "q=数学" in current_url or "q=%E6%95%B0" in current_url
            filter_feature["checks"].append({
                "name": "搜索筛选触发 URL 参数更新",
                "passed": has_search_param,
                "detail": f"URL={current_url}"
            })

            # 清除搜索
            search_input.fill("")
            page.keyboard.press("Escape")
            page.wait_for_timeout(1000)
        else:
            filter_feature["checks"].append({
                "name": "搜索输入框存在",
                "passed": False,
                "detail": "未找到搜索输入框"
            })

        # 测试学科筛选
        subject_select = page.locator('button[role="combobox"]').first
        if subject_select.count() > 0:
            subject_select.click()
            page.wait_for_timeout(500)
            # 检查下拉选项是否出现
            options = page.locator('[role="option"]').count()
            filter_feature["checks"].append({
                "name": "学科筛选下拉选项出现",
                "passed": options > 0,
                "detail": f"找到 {options} 个选项"
            })
            # 关闭下拉
            page.keyboard.press("Escape")
            page.wait_for_timeout(500)
        else:
            filter_feature["checks"].append({
                "name": "学科筛选下拉存在",
                "passed": False,
                "detail": "未找到学科筛选下拉"
            })

        page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_filter.png"), full_page=True)
        feature_result["screenshots"].append("teacher_feature_filter.png")

        filter_feature["status"] = "passed"
        print("    ✅ 筛选功能测试通过")
    except Exception as e:
        filter_feature["status"] = "failed"
        filter_feature["errors"].append(str(e)[:200])
        print(f"    ❌ 筛选功能测试失败: {e}")

    feature_result["features"]["filter"] = filter_feature

    # ---- 3. 新建教材 ----
    print("\n>>> [3/8] 测试新建教材...")
    create_feature = {"name": "新建教材", "status": "unknown", "checks": [], "errors": []}
    created_textbook_id = None
    try:
        page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000)
        page.wait_for_load_state("networkidle", timeout=15000)
        page.wait_for_timeout(1500)

        # 点击"新建教材"按钮
        new_btn = page.locator('button').filter(has_text=re.compile(r"新建教材|新建|Add|Create"))
        if new_btn.count() > 0:
            new_btn.first.click()
            page.wait_for_timeout(1000)

            # 检查对话框是否出现
            dialog = page.locator('[role="dialog"]')
            create_feature["checks"].append({
                "name": "新建教材对话框打开",
                "passed": dialog.count() > 0,
                "detail": f"找到 {dialog.count()} 个对话框"
            })

            if dialog.count() > 0:
                # 填写表单
                title_input = dialog.locator('input[name="title"]')
                title_input.fill("【自动化测试】数学教材")

                # 选择学科
                subject_select = dialog.locator('button[role="combobox"]').first
                if subject_select.count() > 0:
                    subject_select.click()
                    page.wait_for_timeout(500)
                    # 选择第一个选项（数学）
                    first_option = page.locator('[role="option"]').first
                    if first_option.count() > 0:
                        first_option.click()
                        page.wait_for_timeout(500)

                # 选择年级
                grade_select = dialog.locator('button[role="combobox"]').nth(1)
                if grade_select.count() > 0:
                    grade_select.click()
                    page.wait_for_timeout(500)
                    first_grade = page.locator('[role="option"]').first
                    if first_grade.count() > 0:
                        first_grade.click()
                        page.wait_for_timeout(500)

                # 填写出版社
                publisher_input = dialog.locator('input[name="publisher"]')
                if publisher_input.count() > 0:
                    publisher_input.fill("测试出版社")

                page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_create_form.png"), full_page=True)
                feature_result["screenshots"].append("teacher_feature_create_form.png")

                # 提交表单
                submit_btn = dialog.locator('button[type="submit"]')
                if submit_btn.count() == 0:
                    submit_btn = dialog.locator('button').filter(has_text=re.compile(r"保存|Save|提交|Submit"))
                submit_btn.click()

                # 等待表单提交和页面刷新
                page.wait_for_load_state("networkidle", timeout=20000)
                page.wait_for_timeout(2000)

                # 检查是否成功创建（toast 消息或列表中存在新教材）
                body_text = page.locator("body").text_content() or ""
                success_toast = "成功" in body_text or "success" in body_text.lower()
                new_textbook_in_list = "【自动化测试】数学教材" in body_text

                create_feature["checks"].append({
                    "name": "教材创建成功（toast 或列表显示）",
                    "passed": success_toast or new_textbook_in_list,
                    "detail": f"toast={success_toast}, in_list={new_textbook_in_list}"
                })

                # 查找新创建的教材链接
                textbook_links = page.locator('a[href*="/teacher/textbooks/"]')
                if textbook_links.count() > 0:
                    first_href = textbook_links.first.get_attribute("href") or ""
                    # 提取 textbookId
                    match = re.search(r'/teacher/textbooks/([^/]+)', first_href)
                    if match:
                        created_textbook_id = match.group(1)
                        feature_result["created_textbook_id"] = created_textbook_id

                create_feature["status"] = "passed" if (success_toast or new_textbook_in_list) else "failed"
                print(f"    ✅ 新建教材成功 (textbookId={created_textbook_id})" if created_textbook_id else "    ⚠️ 新建教材结果未知")
        else:
            create_feature["status"] = "failed"
            create_feature["errors"].append("未找到'新建教材'按钮")
            print("    ❌ 未找到'新建教材'按钮")
    except Exception as e:
        create_feature["status"] = "failed"
        create_feature["errors"].append(str(e)[:200])
        print(f"    ❌ 新建教材失败: {e}")

    feature_result["features"]["create"] = create_feature

    # ---- 4. 教材详情页 ----
    print("\n>>> [4/8] 测试教材详情页...")
    detail_feature = {"name": "教材详情页", "status": "unknown", "checks": [], "errors": []}
    test_textbook_id = created_textbook_id
    if not test_textbook_id:
        # 尝试从列表中找到第一个教材
        try:
            page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(1500)
            textbook_links = page.locator('a[href*="/teacher/textbooks/"]')
            if textbook_links.count() > 0:
                first_href = textbook_links.first.get_attribute("href") or ""
                match = re.search(r'/teacher/textbooks/([^/]+)', first_href)
                if match:
                    test_textbook_id = match.group(1)
                    print(f"    使用已有教材 ID: {test_textbook_id}")
        except Exception:
            pass

    if test_textbook_id:
        try:
            url = f"{BASE_URL}/teacher/textbooks/{test_textbook_id}"
            response = page.goto(url, timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(2000)

            http_status = response.status if response else None
            detail_feature["checks"].append({
                "name": "详情页 HTTP 200",
                "passed": http_status == 200,
                "detail": f"HTTP {http_status}"
            })

            # 检查返回按钮
            back_btn = page.locator('a[href*="/teacher/textbooks"]').filter(has_text=re.compile(r"返回|Back"))
            detail_feature["checks"].append({
                "name": "存在返回按钮",
                "passed": back_btn.count() > 0,
                "detail": f"找到 {back_btn.count()} 个"
            })

            # 检查设置按钮
            settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings"))
            detail_feature["checks"].append({
                "name": "存在设置按钮",
                "passed": settings_btn.count() > 0,
                "detail": f"找到 {settings_btn.count()} 个"
            })

            # 检查 Tab 切换
            tabs = page.locator('[role="tab"]')
            tab_count = tabs.count()
            detail_feature["checks"].append({
                "name": "存在 Tab 切换（章节/知识点/图谱）",
                "passed": tab_count >= 2,
                "detail": f"找到 {tab_count} 个 Tab"
            })

            # 检查章节侧边栏
            chapter_items = page.locator('[class*="chapter"], [data-testid*="chapter"], .react-flow__node')
            body_text = page.locator("body").text_content() or ""
            has_chapter_ui = chapter_items.count() > 0 or "章节" in body_text or "Chapter" in body_text
            detail_feature["checks"].append({
                "name": "章节侧边栏渲染",
                "passed": has_chapter_ui,
                "detail": f"找到 {chapter_items.count()} 个章节元素"
            })

            page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_detail.png"), full_page=True)
            feature_result["screenshots"].append("teacher_feature_detail.png")

            detail_feature["status"] = "passed"
            print("    ✅ 教材详情页测试通过")
        except Exception as e:
            detail_feature["status"] = "failed"
            detail_feature["errors"].append(str(e)[:200])
            print(f"    ❌ 教材详情页测试失败: {e}")
    else:
        detail_feature["status"] = "skipped"
        detail_feature["errors"].append("无可用教材 ID")
        print("    ⏭️  跳过详情页测试（无教材）")

    feature_result["features"]["detail"] = detail_feature

    # ---- 5. 章节功能 ----
    print("\n>>> [5/8] 测试章节功能...")
    chapter_feature = {"name": "章节功能", "status": "unknown", "checks": [], "errors": []}
    if test_textbook_id:
        try:
            # 确保在详情页
            page.goto(f"{BASE_URL}/teacher/textbooks/{test_textbook_id}", timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(2000)

            # 检查新建章节按钮（在章节侧边栏中）
            add_chapter_btn = page.locator('button[aria-label*="新建章节"], button[aria-label*="add"], button[aria-label*="创建"]').first
            chapter_feature["checks"].append({
                "name": "存在新建章节按钮",
                "passed": add_chapter_btn.count() > 0,
                "detail": f"找到 {add_chapter_btn.count()} 个"
            })

            # 检查章节列表项
            chapter_items = page.locator('[class*="chapter"]')
            chapter_count = chapter_items.count()
            chapter_feature["checks"].append({
                "name": "章节列表渲染",
                "passed": chapter_count >= 0,  # 0 个也算通过（新教材）
                "detail": f"找到 {chapter_count} 个章节元素"
            })

            # 检查内容面板
            content_panel = page.locator('h2, [class*="content"]')
            chapter_feature["checks"].append({
                "name": "内容面板渲染",
                "passed": content_panel.count() > 0,
                "detail": f"找到 {content_panel.count()} 个内容元素"
            })

            page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_chapter.png"), full_page=True)
            feature_result["screenshots"].append("teacher_feature_chapter.png")

            chapter_feature["status"] = "passed"
            print("    ✅ 章节功能测试通过")
        except Exception as e:
            chapter_feature["status"] = "failed"
            chapter_feature["errors"].append(str(e)[:200])
            print(f"    ❌ 章节功能测试失败: {e}")
    else:
        chapter_feature["status"] = "skipped"
        chapter_feature["errors"].append("无可用教材 ID")
        print("    ⏭️  跳过章节功能测试")

    feature_result["features"]["chapter"] = chapter_feature

    # ---- 6. 知识图谱 Tab ----
    print("\n>>> [6/8] 测试知识图谱 Tab...")
    graph_feature = {"name": "知识图谱", "status": "unknown", "checks": [], "errors": []}
    if test_textbook_id:
        try:
            # 确保在详情页
            page.goto(f"{BASE_URL}/teacher/textbooks/{test_textbook_id}", timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(2000)

            # 先选择一个章节（图谱 Tab 在未选章节时是 disabled）
            chapter_links = page.locator('[class*="chapter"], [data-testid*="chapter"], button:has-text("章"), a:has-text("章")')
            if chapter_links.count() > 0:
                chapter_links.first.click()
                page.wait_for_timeout(1500)
                print(f"    已选择第一个章节")

            # 点击"图谱" Tab
            graph_tab = page.locator('[role="tab"]').filter(has_text=re.compile(r"图谱|Graph"))
            if graph_tab.count() > 0:
                # 检查是否 disabled
                is_disabled = graph_tab.first.get_attribute("disabled")
                if is_disabled is not None:
                    graph_feature["checks"].append({
                        "name": "图谱 Tab 可点击",
                        "passed": False,
                        "detail": f"图谱 Tab 处于 disabled 状态（可能无章节可选）"
                    })
                    graph_feature["status"] = "warning"
                    print("    ⚠️ 图谱 Tab 处于 disabled 状态")
                else:
                    graph_tab.first.click()
                    page.wait_for_timeout(1500)

                    # 检查图谱渲染（React Flow 或 SVG）
                    graph_canvas = page.locator('.react-flow, svg, [class*="graph"]')
                    graph_feature["checks"].append({
                        "name": "知识图谱画布渲染",
                        "passed": graph_canvas.count() > 0,
                        "detail": f"找到 {graph_canvas.count()} 个图谱元素"
                    })

                    page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_graph.png"), full_page=True)
                    feature_result["screenshots"].append("teacher_feature_graph.png")

                    graph_feature["status"] = "passed"
                    print("    ✅ 知识图谱测试通过")
            else:
                graph_feature["checks"].append({
                    "name": "图谱 Tab 存在",
                    "passed": False,
                    "detail": "未找到图谱 Tab"
                })
                graph_feature["status"] = "warning"
                print("    ⚠️ 未找到图谱 Tab")
        except Exception as e:
            graph_feature["status"] = "failed"
            graph_feature["errors"].append(str(e)[:200])
            print(f"    ❌ 知识图谱测试失败: {e}")
    else:
        graph_feature["status"] = "skipped"
        graph_feature["errors"].append("无可用教材 ID")
        print("    ⏭️  跳过知识图谱测试")

    feature_result["features"]["graph"] = graph_feature

    # ---- 7. 设置对话框（编辑教材）----
    print("\n>>> [7/8] 测试设置对话框...")
    settings_feature = {"name": "设置对话框", "status": "unknown", "checks": [], "errors": []}
    if test_textbook_id:
        try:
            # 确保在详情页
            page.goto(f"{BASE_URL}/teacher/textbooks/{test_textbook_id}", timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(2000)

            # 点击设置按钮
            settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings"))
            if settings_btn.count() > 0:
                settings_btn.first.click()
                page.wait_for_timeout(1000)

                # 检查对话框
                dialog = page.locator('[role="dialog"]')
                settings_feature["checks"].append({
                    "name": "设置对话框打开",
                    "passed": dialog.count() > 0,
                    "detail": f"找到 {dialog.count()} 个对话框"
                })

                if dialog.count() > 0:
                    # 检查表单字段
                    title_input = dialog.locator('input[name="title"]')
                    settings_feature["checks"].append({
                        "name": "标题输入框存在且有默认值",
                        "passed": title_input.count() > 0,
                        "detail": f"找到 {title_input.count()} 个"
                    })

                    # 检查删除按钮
                    delete_btn = dialog.locator('button').filter(has_text=re.compile(r"删除|Delete"))
                    settings_feature["checks"].append({
                        "name": "存在删除按钮",
                        "passed": delete_btn.count() > 0,
                        "detail": f"找到 {delete_btn.count()} 个"
                    })

                    # 修改标题
                    if title_input.count() > 0:
                        title_input.fill("【自动化测试】数学教材-已修改")

                    page.screenshot(path=str(SCREENSHOT_DIR / "teacher_feature_settings.png"), full_page=True)
                    feature_result["screenshots"].append("teacher_feature_settings.png")

                    # 保存修改
                    save_btn = dialog.locator('button[type="submit"]')
                    if save_btn.count() == 0:
                        save_btn = dialog.locator('button').filter(has_text=re.compile(r"保存|Save"))
                    if save_btn.count() > 0:
                        save_btn.click()
                        page.wait_for_timeout(2000)
                        page.wait_for_load_state("networkidle", timeout=10000)

                    settings_feature["status"] = "passed"
                    print("    ✅ 设置对话框测试通过")
            else:
                settings_feature["status"] = "failed"
                settings_feature["errors"].append("未找到设置按钮")
                print("    ❌ 未找到设置按钮")
        except Exception as e:
            settings_feature["status"] = "failed"
            settings_feature["errors"].append(str(e)[:200])
            print(f"    ❌ 设置对话框测试失败: {e}")
    else:
        settings_feature["status"] = "skipped"
        settings_feature["errors"].append("无可用教材 ID")
        print("    ⏭️  跳过设置对话框测试")

    feature_result["features"]["settings"] = settings_feature

    # ---- 8. 删除教材（清理测试数据）----
    print("\n>>> [8/8] 测试删除教材（清理）...")
    delete_feature = {"name": "删除教材", "status": "unknown", "checks": [], "errors": []}
    if created_textbook_id:
        try:
            # 确保在详情页
            page.goto(f"{BASE_URL}/teacher/textbooks/{created_textbook_id}", timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(2000)

            # 点击设置按钮
            settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings"))
            if settings_btn.count() > 0:
                settings_btn.first.click()
                page.wait_for_timeout(1000)

                dialog = page.locator('[role="dialog"]')
                if dialog.count() > 0:
                    # 点击删除按钮
                    delete_btn = dialog.locator('button').filter(has_text=re.compile(r"删除教材|Delete"))
                    if delete_btn.count() > 0:
                        delete_btn.first.click()
                        page.wait_for_timeout(1000)

                        # 确认删除（AlertDialog）
                        alert_dialog = page.locator('[role="alertdialog"]')
                        if alert_dialog.count() > 0:
                            confirm_btn = alert_dialog.locator('button').filter(has_text=re.compile(r"删除|Delete|确认|Confirm"))
                            if confirm_btn.count() > 0:
                                confirm_btn.first.click()
                                page.wait_for_timeout(2000)
                                page.wait_for_load_state("networkidle", timeout=10000)

                                # 检查是否跳转回列表页
                                final_url = page.url
                                delete_feature["checks"].append({
                                    "name": "删除后跳转回列表页",
                                    "passed": "/teacher/textbooks" in final_url and f"/{created_textbook_id}" not in final_url,
                                    "detail": f"URL={final_url}"
                                })

                                delete_feature["status"] = "passed"
                                print("    ✅ 删除教材测试通过")
                            else:
                                delete_feature["status"] = "failed"
                                delete_feature["errors"].append("未找到确认删除按钮")
                        else:
                            delete_feature["status"] = "warning"
                            delete_feature["errors"].append("未出现确认对话框")
                    else:
                        delete_feature["status"] = "failed"
                        delete_feature["errors"].append("未找到删除按钮")
                else:
                    delete_feature["status"] = "failed"
                    delete_feature["errors"].append("设置对话框未打开")
            else:
                delete_feature["status"] = "skipped"
                delete_feature["errors"].append("未找到设置按钮（可能教材已被删除）")
        except Exception as e:
            delete_feature["status"] = "failed"
            delete_feature["errors"].append(str(e)[:200])
            print(f"    ❌ 删除教材测试失败: {e}")
    else:
        delete_feature["status"] = "skipped"
        delete_feature["errors"].append("无创建的教材，跳过删除测试")
        print("    ⏭️  跳过删除教材测试（无创建的教材）")

    feature_result["features"]["delete"] = delete_feature

    return feature_result


# ============ 学生完整功能测试 ============

def test_student_full_features(page) -> dict:
    """测试学生对教材模块的完整功能：列表、筛选、详情、只读"""
    feature_result = {
        "role": "student",
        "features": {},
        "screenshots": [],
        "errors": [],
        "warnings": [],
    }

    print("\n=== 测试学生教材模块完整功能 ===")

    # ---- 1. 列表页功能 ----
    print("\n>>> [1/4] 测试学生列表页功能...")
    list_feature = {"name": "学生列表页", "status": "unknown", "checks": [], "errors": []}
    try:
        page.goto(f"{BASE_URL}/student/learning/textbooks", timeout=30000)
        page.wait_for_load_state("networkidle", timeout=15000)
        page.wait_for_timeout(1500)

        # 检查标题
        body_text = page.locator("body").text_content() or ""
        title_keys = ["教材", "Textbook", "textbook"]
        title_match = any(k in body_text for k in title_keys)
        list_feature["checks"].append({
            "name": "页面标题包含'教材'/'Textbook'",
            "passed": title_match,
            "detail": f"body 长度 {len(body_text)}"
        })

        # 检查"新建教材"按钮（学生端不应有）
        new_btn = page.locator('button').filter(has_text=re.compile(r"新建教材|新建|Add|Create"))
        new_btn_count = new_btn.count()
        list_feature["checks"].append({
            "name": "学生端无'新建教材'按钮",
            "passed": new_btn_count == 0,
            "detail": f"找到 {new_btn_count} 个（应为 0）"
        })

        # 检查筛选器
        filter_inputs = page.locator('input, select, button[role="combobox"]').count()
        list_feature["checks"].append({
            "name": "存在筛选器",
            "passed": filter_inputs > 0,
            "detail": f"找到 {filter_inputs} 个筛选元素"
        })

        # 检查教材卡片
        cards = page.locator('a[href*="/student/learning/textbooks/"]').count()
        list_feature["checks"].append({
            "name": "教材卡片或空状态",
            "passed": True,
            "detail": f"找到 {cards} 个教材链接"
        })

        page.screenshot(path=str(SCREENSHOT_DIR / "student_feature_list.png"), full_page=True)
        feature_result["screenshots"].append("student_feature_list.png")

        list_feature["status"] = "passed"
        print("    ✅ 学生列表页测试通过")
    except Exception as e:
        list_feature["status"] = "failed"
        list_feature["errors"].append(str(e)[:200])
        print(f"    ❌ 学生列表页测试失败: {e}")

    feature_result["features"]["list"] = list_feature

    # ---- 2. 筛选功能 ----
    print("\n>>> [2/4] 测试学生筛选功能...")
    filter_feature = {"name": "学生筛选", "status": "unknown", "checks": [], "errors": []}
    try:
        # 测试搜索筛选
        search_input = page.locator('input[placeholder*="搜索"], input[placeholder*="search"]').first
        if search_input.count() > 0:
            search_input.fill("数学")
            page.wait_for_timeout(1500)
            page.wait_for_load_state("networkidle", timeout=10000)

            current_url = page.url
            has_search_param = "q=" in current_url
            filter_feature["checks"].append({
                "name": "搜索筛选触发 URL 参数更新",
                "passed": has_search_param,
                "detail": f"URL={current_url}"
            })

            search_input.fill("")
            page.keyboard.press("Escape")
            page.wait_for_timeout(1000)
        else:
            filter_feature["checks"].append({
                "name": "搜索输入框存在",
                "passed": False,
                "detail": "未找到搜索输入框"
            })

        filter_feature["status"] = "passed"
        print("    ✅ 学生筛选功能测试通过")
    except Exception as e:
        filter_feature["status"] = "failed"
        filter_feature["errors"].append(str(e)[:200])
        print(f"    ❌ 学生筛选功能测试失败: {e}")

    feature_result["features"]["filter"] = filter_feature

    # ---- 3. 教材详情页 ----
    print("\n>>> [3/4] 测试学生教材详情页...")
    detail_feature = {"name": "学生详情页", "status": "unknown", "checks": [], "errors": []}
    test_textbook_id = None
    try:
        page.goto(f"{BASE_URL}/student/learning/textbooks", timeout=30000)
        page.wait_for_load_state("networkidle", timeout=15000)
        page.wait_for_timeout(1500)
        textbook_links = page.locator('a[href*="/student/learning/textbooks/"]')
        if textbook_links.count() > 0:
            first_href = textbook_links.first.get_attribute("href") or ""
            match = re.search(r'/student/learning/textbooks/([^/]+)', first_href)
            if match:
                test_textbook_id = match.group(1)
    except Exception:
        pass

    if test_textbook_id:
        try:
            url = f"{BASE_URL}/student/learning/textbooks/{test_textbook_id}"
            response = page.goto(url, timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(2000)

            http_status = response.status if response else None
            detail_feature["checks"].append({
                "name": "详情页 HTTP 200",
                "passed": http_status == 200,
                "detail": f"HTTP {http_status}"
            })

            # 检查章节侧边栏
            chapter_items = page.locator('[class*="chapter"], [data-testid*="chapter"]')
            body_text = page.locator("body").text_content() or ""
            has_chapter_ui = chapter_items.count() > 0 or "章节" in body_text or "Chapter" in body_text
            detail_feature["checks"].append({
                "name": "章节侧边栏渲染",
                "passed": has_chapter_ui,
                "detail": f"找到 {chapter_items.count()} 个章节元素"
            })

            # 检查 Tab 切换
            tabs = page.locator('[role="tab"]')
            tab_count = tabs.count()
            detail_feature["checks"].append({
                "name": "存在 Tab 切换",
                "passed": tab_count >= 2,
                "detail": f"找到 {tab_count} 个 Tab"
            })

            # 检查无编辑按钮（学生端只读）
            edit_btn = page.locator('button').filter(has_text=re.compile(r"编辑内容|Edit Content|编辑|Edit"))
            edit_btn_count = edit_btn.count()
            detail_feature["checks"].append({
                "name": "学生端无编辑按钮",
                "passed": edit_btn_count == 0,
                "detail": f"找到 {edit_btn_count} 个（应为 0）"
            })

            # 检查无设置按钮
            settings_btn = page.locator('button').filter(has_text=re.compile(r"设置|Settings"))
            settings_btn_count = settings_btn.count()
            detail_feature["checks"].append({
                "name": "学生端无设置按钮",
                "passed": settings_btn_count == 0,
                "detail": f"找到 {settings_btn_count} 个（应为 0）"
            })

            page.screenshot(path=str(SCREENSHOT_DIR / "student_feature_detail.png"), full_page=True)
            feature_result["screenshots"].append("student_feature_detail.png")

            detail_feature["status"] = "passed"
            print("    ✅ 学生详情页测试通过")
        except Exception as e:
            detail_feature["status"] = "failed"
            detail_feature["errors"].append(str(e)[:200])
            print(f"    ❌ 学生详情页测试失败: {e}")
    else:
        detail_feature["status"] = "skipped"
        detail_feature["errors"].append("无可用教材 ID")
        print("    ⏭️  跳过学生详情页测试（无教材）")

    feature_result["features"]["detail"] = detail_feature

    # ---- 4. 知识图谱 Tab ----
    print("\n>>> [4/4] 测试学生知识图谱 Tab...")
    graph_feature = {"name": "学生知识图谱", "status": "unknown", "checks": [], "errors": []}
    if test_textbook_id:
        try:
            page.goto(f"{BASE_URL}/student/learning/textbooks/{test_textbook_id}", timeout=30000)
            page.wait_for_load_state("networkidle", timeout=15000)
            page.wait_for_timeout(2000)

            # 先选择一个章节（图谱 Tab 在未选章节时是 disabled）
            chapter_links = page.locator('[class*="chapter"], [data-testid*="chapter"], button:has-text("章"), a:has-text("章")')
            if chapter_links.count() > 0:
                chapter_links.first.click()
                page.wait_for_timeout(1500)
                print(f"    已选择第一个章节")

            graph_tab = page.locator('[role="tab"]').filter(has_text=re.compile(r"图谱|Graph"))
            if graph_tab.count() > 0:
                is_disabled = graph_tab.first.get_attribute("disabled")
                if is_disabled is not None:
                    graph_feature["status"] = "warning"
                    graph_feature["errors"].append("图谱 Tab 处于 disabled 状态")
                    print("    ⚠️ 图谱 Tab 处于 disabled 状态")
                else:
                    graph_tab.first.click()
                    page.wait_for_timeout(1500)

                    graph_canvas = page.locator('.react-flow, svg, [class*="graph"]')
                    graph_feature["checks"].append({
                        "name": "知识图谱画布渲染",
                        "passed": graph_canvas.count() > 0,
                        "detail": f"找到 {graph_canvas.count()} 个图谱元素"
                    })

                    page.screenshot(path=str(SCREENSHOT_DIR / "student_feature_graph.png"), full_page=True)
                    feature_result["screenshots"].append("student_feature_graph.png")

                    graph_feature["status"] = "passed"
                    print("    ✅ 学生知识图谱测试通过")
            else:
                graph_feature["status"] = "warning"
                graph_feature["errors"].append("未找到图谱 Tab")
                print("    ⚠️ 未找到图谱 Tab")
        except Exception as e:
            graph_feature["status"] = "failed"
            graph_feature["errors"].append(str(e)[:200])
            print(f"    ❌ 学生知识图谱测试失败: {e}")
    else:
        graph_feature["status"] = "skipped"
        graph_feature["errors"].append("无可用教材 ID")
        print("    ⏭️  跳过学生知识图谱测试")

    feature_result["features"]["graph"] = graph_feature

    return feature_result


# ============ 主流程 ============

def update_summary(role_result: dict):
    """根据角色测试结果更新汇总"""
    results["summary"]["total"] += 1
    statuses = []
    if "teacher_list_page" in role_result:
        statuses.append(role_result["teacher_list_page"]["status"])
    if "student_list_page" in role_result:
        statuses.append(role_result["student_list_page"]["status"])
    if "features" in role_result:
        for f in role_result["features"].values():
            statuses.append(f.get("status", "unknown"))

    if any(s == "failed" for s in statuses):
        results["summary"]["failed"] += 1
    elif any(s == "warning" for s in statuses):
        results["summary"]["warnings"] += 1
    elif any(s == "passed" for s in statuses):
        results["summary"]["passed"] += 1


def generate_markdown_report() -> str:
    """生成 Markdown 测试报告"""
    md = []
    md.append(f"# 教材模块（textbooks）Web 测试报告 {VERSION}")
    md.append("")
    md.append(f"> 测试日期：{results['test_date']}")
    md.append(f"> 模块：{results['module']}")
    md.append(f"> Base URL：{results['base_url']}")
    md.append(f"> 测试方式：Playwright 自动化测试")
    md.append("")
    md.append("---")
    md.append("")
    md.append("## 一、测试概览")
    md.append("")
    s = results["summary"]
    md.append(f"| 指标 | 数值 |")
    md.append(f"|------|------|")
    md.append(f"| 测试角色总数 | {s['total']} |")
    md.append(f"| 通过 | {s['passed']} |")
    md.append(f"| 失败 | {s['failed']} |")
    md.append(f"| 警告 | {s['warnings']} |")
    md.append("")
    md.append("### 测试覆盖范围")
    md.append("")
    md.append("- **角色覆盖**：admin / teacher / student / parent")
    md.append("- **路由覆盖**：")
    md.append("  - `/teacher/textbooks`（教师列表页）")
    md.append("  - `/teacher/textbooks/[id]`（教师详情页）")
    md.append("  - `/student/learning/textbooks`（学生列表页）")
    md.append("  - `/student/learning/textbooks/[id]`（学生详情页）")
    md.append("- **功能覆盖**：")
    md.append("  - 教师：列表查看、筛选（搜索/学科/年级）、新建教材、详情页、章节侧边栏、知识图谱、设置对话框、删除教材")
    md.append("  - 学生：列表查看、筛选、详情页（只读）、知识图谱（只读）")
    md.append("- **权限测试**：")
    md.append("  - student/parent 应被拒绝访问 `/teacher/textbooks`")
    md.append("  - 学生端不应显示新建/编辑/删除按钮")
    md.append("")
    md.append("---")
    md.append("")
    md.append("## 二、角色访问权限测试")
    md.append("")
    md.append("| 角色 | 登录 | 教师列表页 | 学生列表页 | 备注 |")
    md.append("|------|------|-----------|-----------|------|")
    for role, info in results["roles"].items():
        login_ok = "✅" if info.get("login_success") else "❌"
        teacher_status = info.get("teacher_list_page", {}).get("status", "unknown")
        student_status = info.get("student_list_page", {}).get("status", "unknown")
        teacher_icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(teacher_status, "❓")
        student_icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(student_status, "❓")
        note = ""
        if role in ("student", "parent"):
            note = "教师端应被拒绝"
        elif role == "admin":
            note = "有权限访问教师端"
        elif role == "teacher":
            note = "完整功能"
        md.append(f"| {role} | {login_ok} | {teacher_icon} | {student_icon} | {note} |")
    md.append("")
    md.append("---")
    md.append("")
    md.append("## 三、教师完整功能测试详情")
    md.append("")
    teacher_features = results.get("teacher_features", {})
    if teacher_features.get("features"):
        md.append("| 功能 | 状态 | 检查项数 | 通过数 | 错误 |")
        md.append("|------|------|----------|--------|------|")
        for key, f in teacher_features["features"].items():
            status = f.get("status", "unknown")
            icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(status, "❓")
            checks = f.get("checks", [])
            passed = sum(1 for c in checks if c.get("passed"))
            errors = "; ".join(f.get("errors", [])) if f.get("errors") else "-"
            md.append(f"| {f.get('name', key)} | {icon} | {len(checks)} | {passed} | {errors} |")
        md.append("")

        md.append("### 详细检查项")
        md.append("")
        for key, f in teacher_features["features"].items():
            md.append(f"#### {f.get('name', key)}")
            md.append("")
            md.append(f"- **状态**：{f.get('status', 'unknown')}")
            if f.get("checks"):
                md.append("- **检查项**：")
                for c in f["checks"]:
                    icon = "✅" if c.get("passed") else "❌"
                    md.append(f"  - {icon} {c['name']}（{c.get('detail', '')}）")
            if f.get("errors"):
                md.append("- **错误**：")
                for e in f["errors"]:
                    md.append(f"  - {e}")
            md.append("")
    else:
        md.append("教师功能测试未执行或失败。")
        md.append("")
    md.append("---")
    md.append("")
    md.append("## 四、学生完整功能测试详情")
    md.append("")
    student_features = results.get("student_features", {})
    if student_features.get("features"):
        md.append("| 功能 | 状态 | 检查项数 | 通过数 | 错误 |")
        md.append("|------|------|----------|--------|------|")
        for key, f in student_features["features"].items():
            status = f.get("status", "unknown")
            icon = {"passed": "✅", "failed": "❌", "warning": "⚠️", "unknown": "❓", "skipped": "⏭️"}.get(status, "❓")
            checks = f.get("checks", [])
            passed = sum(1 for c in checks if c.get("passed"))
            errors = "; ".join(f.get("errors", [])) if f.get("errors") else "-"
            md.append(f"| {f.get('name', key)} | {icon} | {len(checks)} | {passed} | {errors} |")
        md.append("")

        md.append("### 详细检查项")
        md.append("")
        for key, f in student_features["features"].items():
            md.append(f"#### {f.get('name', key)}")
            md.append("")
            md.append(f"- **状态**：{f.get('status', 'unknown')}")
            if f.get("checks"):
                md.append("- **检查项**：")
                for c in f["checks"]:
                    icon = "✅" if c.get("passed") else "❌"
                    md.append(f"  - {icon} {c['name']}（{c.get('detail', '')}）")
            if f.get("errors"):
                md.append("- **错误**：")
                for e in f["errors"]:
                    md.append(f"  - {e}")
            md.append("")
    else:
        md.append("学生功能测试未执行或失败。")
        md.append("")
    md.append("---")
    md.append("")
    md.append("## 五、控制台错误汇总")
    md.append("")
    if results["console_errors_global"]:
        md.append(f"共收集到 {len(results['console_errors_global'])} 条控制台错误：")
        md.append("")
        for err in results["console_errors_global"][:20]:
            md.append(f"- [{err.get('role', '')}] `{err.get('error', '')[:200]}`")
        if len(results["console_errors_global"]) > 20:
            md.append(f"- ... 还有 {len(results['console_errors_global']) - 20} 条")
    else:
        md.append("✅ 无控制台错误")
    md.append("")
    md.append("---")
    md.append("")
    md.append("## 六、测试截图")
    md.append("")
    md.append(f"截图保存在 `webtest/screenshots/{MODULE_NAME}/` 目录下：")
    md.append("")
    for role, info in results["roles"].items():
        for shot in info.get("screenshots", []):
            md.append(f"- `{shot}`")
    if teacher_features.get("screenshots"):
        for shot in teacher_features["screenshots"]:
            md.append(f"- `{shot}`")
    if student_features.get("screenshots"):
        for shot in student_features["screenshots"]:
            md.append(f"- `{shot}`")
    md.append("")
    md.append("---")
    md.append("")
    md.append("## 七、测试结论")
    md.append("")
    s = results["summary"]
    if s["failed"] == 0 and s["warnings"] == 0:
        md.append("✅ **所有测试通过**。教材模块在所有角色下功能正常。")
    elif s["failed"] == 0:
        md.append(f"⚠️ **测试通过但有 {s['warnings']} 个警告**。建议检查警告项。")
    else:
        md.append(f"❌ **{s['failed']} 个测试失败**。需要修复以下问题：")
        md.append("")
        for role, info in results["roles"].items():
            teacher_err = info.get("teacher_list_page", {}).get("errors", [])
            student_err = info.get("student_list_page", {}).get("errors", [])
            if teacher_err or student_err:
                md.append(f"### {role}")
                for e in teacher_err:
                    md.append(f"- 教师列表页：{e}")
                for e in student_err:
                    md.append(f"- 学生列表页：{e}")
                md.append("")
        if teacher_features.get("features"):
            for key, f in teacher_features["features"].items():
                if f.get("status") == "failed" and f.get("errors"):
                    md.append(f"### 教师功能 - {f.get('name', key)}")
                    for e in f["errors"]:
                        md.append(f"- {e}")
                    md.append("")
        if student_features.get("features"):
            for key, f in student_features["features"].items():
                if f.get("status") == "failed" and f.get("errors"):
                    md.append(f"### 学生功能 - {f.get('name', key)}")
                    for e in f["errors"]:
                        md.append(f"- {e}")
                    md.append("")
    md.append("")
    md.append("---")
    md.append("")
    md.append("## 八、附录：测试账号")
    md.append("")
    md.append("| 角色 | 邮箱 | 预期路径 |")
    md.append("|------|------|----------|")
    for role, acc in TEST_ACCOUNTS.items():
        md.append(f"| {role} | {acc['email']} | {acc['expected_path']} |")
    md.append("")

    return "\n".join(md)


def main():
    print("=" * 60)
    print(f"教材模块（textbooks）Web 测试 - {VERSION}")
    print("=" * 60)

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)

        # ---- 测试 4 个角色的访问权限 ----
        for role in ["admin", "teacher", "student", "parent"]:
            context = browser.new_context(
                viewport={"width": 1440, "height": 900},
                locale="zh-CN",
                ignore_https_errors=True,
            )
            page = context.new_page()
            try:
                role_result = test_role_access(page, role)
                results["roles"][role] = role_result
                update_summary(role_result)
            except Exception as e:
                print(f"❌ {role} 测试异常: {e}")
                results["roles"][role] = {
                    "role": role,
                    "login_success": False,
                    "errors": [str(e)[:200]],
                }
                results["summary"]["total"] += 1
                results["summary"]["failed"] += 1
            finally:
                context.close()

        # ---- 教师完整功能测试 ----
        context = browser.new_context(
            viewport={"width": 1440, "height": 900},
            locale="zh-CN",
            ignore_https_errors=True,
        )
        page = context.new_page()
        try:
            if not login(page, "teacher"):
                print("❌ 教师登录失败，跳过完整功能测试")
                results["teacher_features"] = {"features": {}, "errors": ["教师登录失败"]}
                results["summary"]["failed"] += 1
            else:
                teacher_features = test_teacher_full_features(page)
                results["teacher_features"] = teacher_features
                feature_failed = sum(1 for f in teacher_features.get("features", {}).values() if f.get("status") == "failed")
                if feature_failed > 0:
                    results["summary"]["failed"] += feature_failed
        except Exception as e:
            print(f"❌ 教师功能测试异常: {e}")
            results["teacher_features"] = {"features": {}, "errors": [str(e)[:200]]}
            results["summary"]["failed"] += 1
        finally:
            context.close()

        # ---- 学生完整功能测试 ----
        context = browser.new_context(
            viewport={"width": 1440, "height": 900},
            locale="zh-CN",
            ignore_https_errors=True,
        )
        page = context.new_page()
        try:
            if not login(page, "student"):
                print("❌ 学生登录失败，跳过完整功能测试")
                results["student_features"] = {"features": {}, "errors": ["学生登录失败"]}
                results["summary"]["failed"] += 1
            else:
                student_features = test_student_full_features(page)
                results["student_features"] = student_features
                feature_failed = sum(1 for f in student_features.get("features", {}).values() if f.get("status") == "failed")
                if feature_failed > 0:
                    results["summary"]["failed"] += feature_failed
        except Exception as e:
            print(f"❌ 学生功能测试异常: {e}")
            results["student_features"] = {"features": {}, "errors": [str(e)[:200]]}
            results["summary"]["failed"] += 1
        finally:
            context.close()

        browser.close()

    # 生成报告
    print("\n" + "=" * 60)
    print("生成测试报告...")
    print("=" * 60)

    md_report = generate_markdown_report()
    md_path = WEBTEST_DIR / f"textbooks_{VERSION}.md"
    md_path.write_text(md_report, encoding="utf-8")
    print(f"✅ Markdown 报告: {md_path}")

    json_path = WEBTEST_DIR / f"textbooks_{VERSION}.json"
    json_path.write_text(json.dumps(results, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
    print(f"✅ JSON 报告: {json_path}")

    # 打印汇总
    print("\n" + "=" * 60)
    print("测试汇总")
    print("=" * 60)
    s = results["summary"]
    print(f"  总计: {s['total']}")
    print(f"  通过: {s['passed']}")
    print(f"  失败: {s['failed']}")
    print(f"  警告: {s['warnings']}")
    print(f"  控制台错误: {len(results['console_errors_global'])}")


if __name__ == "__main__":
    main()
