"""审计一览表生成脚本 — 解析模块单元测试

覆盖：AuditEntry、parse_audit_file、classify_module、scan_audit_dir
"""

import datetime
import os
import textwrap
from pathlib import Path

import pytest

from scripts.gen_audit_dashboard import (
    AuditEntry,
    MODULE_MAP,
    VALID_MODULES,
    classify_module,
    parse_audit_file,
    scan_audit_dir,
)


# ---------------------------------------------------------------------------
# classify_module
# ---------------------------------------------------------------------------

class TestClassifyModule:
    """classify_module 应将文件路径映射到正确的功能模块"""

    @pytest.mark.parametrize(
        "path, expected",
        [
            ("api/recording_client.py", "API 层"),
            ("tasks/ods/ods_task.py", "ODS 层"),
            ("tasks/dwd/dwd_load_task.py", "DWD 层"),
            ("tasks/dws/base_dws_task.py", "DWS 层"),
            ("tasks/index/wbi.py", "指数算法"),
            ("loaders/fact_loader.py", "数据装载"),
            ("database/migrations/001.sql", "数据库"),
            ("orchestration/task_registry.py", "调度"),
            ("config/defaults.py", "配置"),
            ("cli/main.py", "CLI"),
            ("models/parser.py", "模型"),
            ("scd/scd2.py", "SCD2"),
            ("docs/README.md", "文档"),
            ("scripts/gen_audit_dashboard.py", "脚本工具"),
            ("tests/unit/test_foo.py", "测试"),
            ("quality/checker.py", "质量校验"),
            ("gui/main.py", "GUI"),
            ("utils/logging_utils.py", "工具库"),
        ],
    )
    def test_known_prefixes(self, path, expected):
        assert classify_module(path) == expected

    def test_unknown_path_returns_other(self):
        assert classify_module("README.md") == "其他"
        assert classify_module(".kiro/steering/foo.md") == "其他"

    def test_normalizes_backslash(self):
        """Windows 反斜杠路径也能正确分类"""
        assert classify_module("tasks\\dws\\base.py") == "DWS 层"

    def test_strips_leading_dot_slash(self):
        assert classify_module("./api/foo.py") == "API 层"

    def test_result_always_in_valid_modules(self):
        """任何输入的返回值都应在 VALID_MODULES 内"""
        for path in ["", "x", "api/", "unknown/deep/path.py"]:
            assert classify_module(path) in VALID_MODULES

    def test_longest_prefix_wins(self):
        """tasks/ods 应优先匹配 ODS 层，而非泛化的 tasks/ 前缀"""
        # MODULE_MAP 中没有 "tasks/" 泛前缀，但 tasks/ods 应匹配 ODS 层
        assert classify_module("tasks/ods/foo.py") == "ODS 层"
        assert classify_module("tasks/dwd/bar.py") == "DWD 层"


# ---------------------------------------------------------------------------
# parse_audit_file — 使用临时文件
# ---------------------------------------------------------------------------

# 标准审计文件内容模板
_STANDARD_AUDIT = textwrap.dedent("""\
    # 审计记录：测试变更

    - 日期：2026-03-01（Asia/Shanghai）

    ## 直接原因
    测试用例

    ## 修改文件清单

    | 文件 | 变更类型 | 说明 |
    |------|----------|------|
    | `api/client.py` | 修改 | 测试 |
    | `tasks/dws/foo.py` | 新增 | 测试 |

    ## 风险与回滚
    - 风险：极低。纯测试变更
""")


class TestParseAuditFile:

    def test_standard_file(self, tmp_path):
        f = tmp_path / "2026-03-01__test-change.md"
        f.write_text(_STANDARD_AUDIT, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert entry.date == "2026-03-01"
        assert entry.slug == "test-change"
        assert entry.title == "审计记录：测试变更"
        assert entry.filename == "2026-03-01__test-change.md"
        assert "api/client.py" in entry.changed_files
        assert "tasks/dws/foo.py" in entry.changed_files
        assert "API 层" in entry.modules
        assert "DWS 层" in entry.modules
        assert entry.risk_level == "极低"

    def test_missing_title_uses_slug(self, tmp_path):
        """缺少一级标题时用 slug 兜底"""
        content = "没有标题的文件\n\n一些内容\n"
        f = tmp_path / "2026-01-01__no-title.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert entry.title == "no-title"

    def test_missing_file_list_section(self, tmp_path):
        """缺少文件清单章节 → 空列表，模块为 {"其他"}"""
        content = "# 标题\n\n没有文件清单\n"
        f = tmp_path / "2026-01-01__no-files.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert entry.changed_files == []
        assert entry.modules == {"其他"}

    def test_invalid_filename_returns_none(self, tmp_path):
        """文件名不符合格式 → 返回 None"""
        f = tmp_path / "invalid-name.md"
        f.write_text("# Title\n", encoding="utf-8")
        assert parse_audit_file(f) is None

    def test_non_md_gitkeep_returns_none(self, tmp_path):
        f = tmp_path / ".gitkeep"
        f.write_text("", encoding="utf-8")
        assert parse_audit_file(f) is None

    def test_list_format_file_section(self, tmp_path):
        """列表格式的文件清单也能正确解析"""
        content = textwrap.dedent("""\
            # 测试

            ## 文件清单（Files changed）
            - docs/api-reference/summary/foo.md
            - scripts/gen_api_docs.py
            - tasks/base_task.py（补 AI_CHANGELOG）
        """)
        f = tmp_path / "2026-02-01__list-format.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert len(entry.changed_files) == 3

    def test_risk_from_metadata_header(self, tmp_path):
        """从头部元数据行提取风险等级"""
        content = textwrap.dedent("""\
            # 测试
            - 日期：2026-01-01
            - 风险等级：低（纯文档重组）

            ## 直接原因
            测试
        """)
        f = tmp_path / "2026-01-01__meta-risk.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry.risk_level == "低"

    def test_change_type_bugfix(self, tmp_path):
        content = "# bugfix 修复\n\n修复了一个 bug\n"
        f = tmp_path / "2026-01-01__fix.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry.change_type == "bugfix"

    def test_change_type_doc(self, tmp_path):
        content = "# 纯文档变更\n\n无逻辑改动\n"
        f = tmp_path / "2026-01-01__doc.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry.change_type == "文档"

    def test_arrow_path_extraction(self, tmp_path):
        """含 → 的移动行应提取源和目标路径"""
        content = textwrap.dedent("""\
            # 测试

            ## 变更摘要

            ### 文件移动
            - `docs/index/algo.md` → `docs/database/DWS/algo.md`
        """)
        f = tmp_path / "2026-01-01__arrow.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert "docs/index/algo.md" in entry.changed_files
        assert "docs/database/DWS/algo.md" in entry.changed_files


# ---------------------------------------------------------------------------
# scan_audit_dir
# ---------------------------------------------------------------------------

class TestScanAuditDir:

    def test_empty_dir(self, tmp_path):
        assert scan_audit_dir(tmp_path) == []

    def test_nonexistent_dir(self):
        assert scan_audit_dir("nonexistent_dir_xyz") == []

    def test_sorts_by_date_descending(self, tmp_path):
        for name in [
            "2026-01-01__first.md",
            "2026-03-01__third.md",
            "2026-02-01__second.md",
        ]:
            (tmp_path / name).write_text("# Title\n", encoding="utf-8")

        entries = scan_audit_dir(tmp_path)
        dates = [e.date for e in entries]
        assert dates == ["2026-03-01", "2026-02-01", "2026-01-01"]

    def test_skips_non_md_files(self, tmp_path):
        (tmp_path / "2026-01-01__valid.md").write_text("# OK\n", encoding="utf-8")
        (tmp_path / ".gitkeep").write_text("", encoding="utf-8")
        (tmp_path / "notes.txt").write_text("text", encoding="utf-8")

        entries = scan_audit_dir(tmp_path)
        assert len(entries) == 1

    def test_skips_invalid_filenames(self, tmp_path):
        (tmp_path / "2026-01-01__valid.md").write_text("# OK\n", encoding="utf-8")
        (tmp_path / "bad-name.md").write_text("# Bad\n", encoding="utf-8")

        entries = scan_audit_dir(tmp_path)
        assert len(entries) == 1
        assert entries[0].slug == "valid"


# ---------------------------------------------------------------------------
# 真实审计文件集成测试（仅在项目目录中运行时有效）
# ---------------------------------------------------------------------------

_REAL_AUDIT_DIR = Path("docs/audit/changes")


@pytest.mark.skipif(
    not _REAL_AUDIT_DIR.is_dir(),
    reason="真实审计目录不存在（非项目根目录运行）",
)
class TestRealAuditFiles:

    def test_parses_all_real_files(self):
        entries = scan_audit_dir(_REAL_AUDIT_DIR)
        assert len(entries) > 0, "应至少解析出一条审计记录"

    def test_all_modules_valid(self):
        entries = scan_audit_dir(_REAL_AUDIT_DIR)
        for e in entries:
            for m in e.modules:
                assert m in VALID_MODULES, (
                    f"模块 {m!r} 不在 VALID_MODULES 中 (文件: {e.filename})"
                )

    def test_dates_descending(self):
        entries = scan_audit_dir(_REAL_AUDIT_DIR)
        dates = [e.date for e in entries]
        assert dates == sorted(dates, reverse=True)


# ---------------------------------------------------------------------------
# 属性测试 — Property 1: 审计记录解析-渲染完整性
# Feature: docs-optimization, Property 1: 审计记录解析-渲染完整性
# Validates: Requirements 2.1, 2.2
# ---------------------------------------------------------------------------

from hypothesis import given, settings, assume
from hypothesis import strategies as st


# --- 生成策略 ---

# 合法日期策略：YYYY-MM-DD 格式
_date_st = st.dates(
    min_value=__import__("datetime").date(2020, 1, 1),
    max_value=__import__("datetime").date(2030, 12, 31),
).map(lambda d: d.isoformat())

# slug 策略：小写字母+数字+连字符，长度 2~30
_slug_st = st.from_regex(r"[a-z][a-z0-9\-]{1,29}", fullmatch=True)

# 标题策略：非空中文/英文混合文本
_title_st = st.text(
    alphabet=st.sampled_from(
        list("审计记录变更修复新增重构清理测试文档优化迁移合并")
        + list("abcdefghijklmnopqrstuvwxyz ")
    ),
    min_size=2,
    max_size=40,
).map(lambda s: s.strip() or "默认标题")

# 文件路径策略：从已知前缀中选取，确保模块分类有意义
_KNOWN_PREFIXES = [
    "api/", "tasks/ods/", "tasks/dwd/", "tasks/dws/", "tasks/index/",
    "loaders/", "database/migrations/", "orchestration/", "config/",
    "cli/", "models/", "scd/", "docs/", "scripts/", "tests/unit/",
    "quality/", "gui/", "utils/",
]

_file_path_st = st.tuples(
    st.sampled_from(_KNOWN_PREFIXES),
    st.from_regex(r"[a-z_]{1,15}\.(py|sql|md)", fullmatch=True),
).map(lambda t: t[0] + t[1])

# 风险等级策略
_risk_st = st.sampled_from(["极低", "低", "中", "高"])

# 变更类型关键词策略（用于在内容中嵌入，让 _infer_change_type 能推断）
_change_kw_st = st.sampled_from(["bugfix", "修复", "重构", "清理", "纯文档", "功能新增"])


def _build_audit_md(title: str, date: str, files: list[str], risk: str, change_kw: str) -> str:
    """根据参数构造一份格式合规的审计 Markdown 内容。"""
    lines = [
        f"# {title}",
        "",
        f"- 日期：{date}（Asia/Shanghai）",
        f"- 风险等级：{risk}",
        "",
        "## 直接原因",
        f"本次变更为 {change_kw} 类型操作",
        "",
        "## 修改文件清单",
        "",
        "| 文件 | 变更类型 | 说明 |",
        "|------|----------|------|",
    ]
    for fp in files:
        lines.append(f"| `{fp}` | 修改 | 自动生成 |")
    lines.append("")
    lines.append("## 风险与回滚")
    lines.append(f"- 风险：{risk}。自动生成的测试内容")
    return "\n".join(lines)


class TestProperty1AuditParseCompleteness:
    """Property 1: 审计记录解析-渲染完整性

    对于任何格式合规的审计 Markdown 文件，parse_audit_file 解析后
    应返回包含所有必要字段（date、title、change_type、modules、filename）
    且各字段非空的 AuditEntry。

    **Validates: Requirements 2.1, 2.2**
    """

    @given(
        date=_date_st,
        slug=_slug_st,
        title=_title_st,
        files=st.lists(_file_path_st, min_size=1, max_size=8),
        risk=_risk_st,
        change_kw=_change_kw_st,
    )
    @settings(max_examples=150)
    def test_parsed_entry_has_all_required_fields(
        self, tmp_path_factory, date, slug, title, files, risk, change_kw
    ):
        """解析格式合规的审计文件后，AuditEntry 的所有必要字段均非空。"""
        # 构造临时文件
        tmp_dir = tmp_path_factory.mktemp("audit")
        filename = f"{date}__{slug}.md"
        md_content = _build_audit_md(title, date, files, risk, change_kw)
        filepath = tmp_dir / filename
        filepath.write_text(md_content, encoding="utf-8")

        entry = parse_audit_file(filepath)

        # 核心断言：解析成功且所有必要字段非空
        assert entry is not None, f"格式合规的文件应能成功解析：{filename}"
        assert entry.date, "date 字段不应为空"
        assert entry.title, "title 字段不应为空"
        assert entry.filename, "filename 字段不应为空"
        assert entry.change_type, "change_type 字段不应为空"
        assert len(entry.modules) > 0, "modules 集合不应为空"

        # 日期应与文件名中的日期一致
        assert entry.date == date

        # filename 应与实际文件名一致
        assert entry.filename == filename

        # modules 中的每个值都应在 VALID_MODULES 内
        for mod in entry.modules:
            assert mod in VALID_MODULES, f"模块 {mod!r} 不在 VALID_MODULES 中"

    @given(
        date=_date_st,
        slug=_slug_st,
        title=_title_st,
        files=st.lists(_file_path_st, min_size=1, max_size=5),
        risk=_risk_st,
        change_kw=_change_kw_st,
    )
    @settings(max_examples=150)
    def test_parsed_files_match_input(
        self, tmp_path_factory, date, slug, title, files, risk, change_kw
    ):
        """解析后的 changed_files 应包含输入的所有文件路径。"""
        tmp_dir = tmp_path_factory.mktemp("audit")
        filename = f"{date}__{slug}.md"
        md_content = _build_audit_md(title, date, files, risk, change_kw)
        filepath = tmp_dir / filename
        filepath.write_text(md_content, encoding="utf-8")

        entry = parse_audit_file(filepath)
        assert entry is not None

        # 每个输入文件路径都应出现在解析结果中
        for fp in files:
            assert fp in entry.changed_files, (
                f"文件 {fp!r} 应出现在 changed_files 中，"
                f"实际结果：{entry.changed_files}"
            )


# ---------------------------------------------------------------------------
# 属性测试 — Property 2: 文件路径模块分类正确性
# Feature: docs-optimization, Property 2: 文件路径模块分类正确性
# Validates: Requirements 2.3
# ---------------------------------------------------------------------------


# --- 文件路径生成策略 ---

# 已知前缀路径：确保覆盖 MODULE_MAP 中所有前缀
_known_prefix_path_st = st.tuples(
    st.sampled_from(list(MODULE_MAP.keys())),
    st.from_regex(r"[a-z_]{1,20}\.(py|sql|md|txt|json)", fullmatch=True),
).map(lambda t: t[0] + t[1])

# 完全随机路径：任意 Unicode 字符串（含空串、特殊字符等）
_random_path_st = st.text(min_size=0, max_size=200)

# 带反斜杠的 Windows 风格路径
_backslash_path_st = st.tuples(
    st.sampled_from(list(MODULE_MAP.keys())),
    st.from_regex(r"[a-z_]{1,15}\.(py|md)", fullmatch=True),
).map(lambda t: t[0].replace("/", "\\") + t[1])

# 带前导 ./ 的相对路径
_dotslash_path_st = st.tuples(
    st.sampled_from(list(MODULE_MAP.keys())),
    st.from_regex(r"[a-z_]{1,15}\.(py|md)", fullmatch=True),
).map(lambda t: "./" + t[0] + t[1])

# 深层嵌套路径
_deep_nested_st = st.tuples(
    st.sampled_from(list(MODULE_MAP.keys())),
    st.lists(
        st.from_regex(r"[a-z]{1,8}", fullmatch=True),
        min_size=1, max_size=5,
    ),
    st.from_regex(r"[a-z_]{1,10}\.(py|md|sql)", fullmatch=True),
).map(lambda t: t[0] + "/".join(t[1]) + "/" + t[2])

# 未知前缀路径（不以任何已知前缀开头）
_unknown_prefix_st = st.tuples(
    st.sampled_from(["README.md", ".kiro/foo.md", "setup.py", "Makefile",
                     "pyproject.toml", ".env", "unknown/deep/path.py",
                     "random_dir/file.txt", ".github/workflows/ci.yml"]),
).map(lambda t: t[0])

# 混合策略：从以上所有策略中随机选取
_any_filepath_st = st.one_of(
    _known_prefix_path_st,
    _random_path_st,
    _backslash_path_st,
    _dotslash_path_st,
    _deep_nested_st,
    _unknown_prefix_st,
)


class TestProperty2ModuleClassification:
    """Property 2: 文件路径模块分类正确性

    对于任意文件路径字符串，classify_module 的返回值
    应始终属于预定义的 VALID_MODULES 集合。

    **Validates: Requirements 2.3**
    """

    @given(filepath=_any_filepath_st)
    @settings(max_examples=200)
    def test_classify_always_returns_valid_module(self, filepath: str):
        """任意文件路径的分类结果都在 VALID_MODULES 内。"""
        result = classify_module(filepath)
        assert result in VALID_MODULES, (
            f"classify_module({filepath!r}) 返回 {result!r}，"
            f"不在 VALID_MODULES 中"
        )

    @given(filepath=_known_prefix_path_st)
    @settings(max_examples=150)
    def test_known_prefix_never_returns_other(self, filepath: str):
        """以已知前缀开头的路径不应返回 '其他'。"""
        result = classify_module(filepath)
        assert result in VALID_MODULES, (
            f"classify_module({filepath!r}) 返回 {result!r}，"
            f"不在 VALID_MODULES 中"
        )
        assert result != "其他", (
            f"已知前缀路径 {filepath!r} 不应分类为 '其他'，"
            f"实际返回 {result!r}"
        )

    @given(filepath=_unknown_prefix_st)
    @settings(max_examples=50)
    def test_unknown_prefix_returns_other(self, filepath: str):
        """不匹配任何已知前缀的路径应返回 '其他'。"""
        result = classify_module(filepath)
        assert result == "其他", (
            f"未知前缀路径 {filepath!r} 应分类为 '其他'，"
            f"实际返回 {result!r}"
        )

    @given(filepath=_backslash_path_st)
    @settings(max_examples=100)
    def test_backslash_paths_classified_correctly(self, filepath: str):
        """Windows 反斜杠路径应正确归类（非 '其他'）。"""
        result = classify_module(filepath)
        assert result in VALID_MODULES
        assert result != "其他", (
            f"反斜杠路径 {filepath!r} 应正确分类，"
            f"实际返回 '其他'"
        )

    @given(filepath=_dotslash_path_st)
    @settings(max_examples=100)
    def test_dotslash_paths_classified_correctly(self, filepath: str):
        """前导 ./ 的路径应正确归类（非 '其他'）。"""
        result = classify_module(filepath)
        assert result in VALID_MODULES
        assert result != "其他", (
            f"前导 ./ 路径 {filepath!r} 应正确分类，"
            f"实际返回 '其他'"
        )

# ---------------------------------------------------------------------------
# 渲染函数测试
# ---------------------------------------------------------------------------

from scripts.gen_audit_dashboard import (
    render_timeline_table,
    render_module_index,
    render_dashboard,
)


def _make_entry(**overrides) -> AuditEntry:
    """构造测试用 AuditEntry，支持字段覆盖。"""
    defaults = dict(
        date="2026-01-15",
        slug="test-change",
        title="测试变更",
        filename="2026-01-15__test-change.md",
        changed_files=["api/client.py"],
        modules={"API 层"},
        risk_level="低",
        change_type="功能",
    )
    defaults.update(overrides)
    return AuditEntry(**defaults)


class TestRenderTimelineTable:
    """render_timeline_table 单元测试"""

    def test_empty_entries(self):
        """空列表返回暂无审计记录提示"""
        result = render_timeline_table([])
        assert "暂无审计记录" in result

    def test_single_entry(self):
        """单条记录生成正确的表格行"""
        entry = _make_entry()
        result = render_timeline_table([entry])
        assert "| 日期 |" in result
        assert "2026-01-15" in result
        assert "测试变更" in result
        assert "API 层" in result
        assert "低" in result
        assert "[链接](changes/2026-01-15__test-change.md)" in result

    def test_multiple_entries_order_preserved(self):
        """多条记录保持输入顺序（调用方负责排序）"""
        e1 = _make_entry(date="2026-02-01", title="后")
        e2 = _make_entry(date="2026-01-01", title="前")
        result = render_timeline_table([e1, e2])
        pos_e1 = result.index("2026-02-01")
        pos_e2 = result.index("2026-01-01")
        assert pos_e1 < pos_e2

    def test_multiple_modules_joined(self):
        """多个模块用逗号分隔并排序"""
        entry = _make_entry(modules={"文档", "API 层"})
        result = render_timeline_table([entry])
        assert "API 层, 文档" in result

    def test_table_header_present(self):
        """表格包含表头和分隔行"""
        result = render_timeline_table([_make_entry()])
        assert "|------|" in result
        assert "需求摘要" in result
        assert "变更类型" in result


class TestRenderModuleIndex:
    """render_module_index 单元测试"""

    def test_empty_entries(self):
        """空列表返回暂无审计记录提示"""
        result = render_module_index([])
        assert "暂无审计记录" in result

    def test_single_module(self):
        """单模块生成一个三级标题章节"""
        entry = _make_entry(modules={"API 层"})
        result = render_module_index([entry])
        assert "### API 层" in result
        assert "2026-01-15" in result
        # 模块索引表格不含"影响模块"列
        lines = result.strip().splitlines()
        header = [l for l in lines if l.startswith("| 日期")]
        assert header
        assert "影响模块" not in header[0]

    def test_multiple_modules_sorted(self):
        """多模块按字母序排列"""
        e1 = _make_entry(modules={"文档"})
        e2 = _make_entry(modules={"API 层"}, date="2026-02-01")
        result = render_module_index([e1, e2])
        pos_api = result.index("### API 层")
        pos_doc = result.index("### 文档")
        assert pos_api < pos_doc

    def test_entry_appears_in_multiple_modules(self):
        """一条记录影响多个模块时，在每个模块章节中都出现"""
        entry = _make_entry(modules={"API 层", "文档"})
        result = render_module_index([entry])
        assert "### API 层" in result
        assert "### 文档" in result
        # 两个章节都包含该记录的链接
        assert result.count("[链接](changes/2026-01-15__test-change.md)") == 2

    def test_link_format(self):
        """详情列链接格式正确"""
        entry = _make_entry(filename="2026-03-01__my-slug.md")
        result = render_module_index([entry])
        assert "[链接](changes/2026-03-01__my-slug.md)" in result


class TestRenderDashboard:
    """render_dashboard 单元测试"""

    def test_empty_entries(self):
        """空列表生成包含提示的完整文档"""
        result = render_dashboard([])
        assert "# 审计一览表" in result
        assert "自动生成于" in result
        assert "暂无审计记录" in result

    def test_contains_both_views(self):
        """完整文档包含时间线和模块索引两个章节"""
        entry = _make_entry()
        result = render_dashboard([entry])
        assert "## 时间线视图" in result
        assert "## 模块索引" in result

    def test_contains_header_and_timestamp(self):
        """文档包含标题和生成时间戳"""
        result = render_dashboard([_make_entry()])
        assert "# 审计一览表" in result
        assert "自动生成于" in result
        assert "请勿手动编辑" in result

    def test_timeline_before_module_index(self):
        """时间线视图在模块索引之前"""
        result = render_dashboard([_make_entry()])
        pos_timeline = result.index("## 时间线视图")
        pos_module = result.index("## 模块索引")
        assert pos_timeline < pos_module


# ---------------------------------------------------------------------------
# Property 3: 审计条目时间倒序排列
# Feature: docs-optimization, Property 3: 审计条目时间倒序排列
# ---------------------------------------------------------------------------


class TestProperty3AuditEntriesDescendingOrder:
    """属性测试：审计条目经排序后日期严格非递增。

    **Validates: Requirements 2.4**
    """

    @given(
        dates=st.lists(
            st.dates(
                min_value=datetime.date(2020, 1, 1),
                max_value=datetime.date(2030, 12, 31),
            ),
            min_size=0,
            max_size=30,
        )
    )
    @settings(max_examples=150)
    def test_sorted_entries_dates_non_increasing(self, dates):
        """任意日期列表构造的 AuditEntry，经 scan_audit_dir 同款排序后，
        日期序列应为非递增（即每个条目的日期 >= 其后续条目的日期）。"""
        # 构造 AuditEntry 列表，日期格式与 scan_audit_dir 一致（YYYY-MM-DD 字符串）
        entries = [
            AuditEntry(
                date=d.isoformat(),
                slug=f"entry-{i}",
                title=f"条目 {i}",
                filename=f"{d.isoformat()}__entry-{i}.md",
            )
            for i, d in enumerate(dates)
        ]

        # 使用与 scan_audit_dir 完全相同的排序逻辑
        entries.sort(key=lambda e: e.date, reverse=True)

        # 验证非递增序
        for i in range(len(entries) - 1):
            assert entries[i].date >= entries[i + 1].date, (
                f"位置 {i} 的日期 {entries[i].date} "
                f"不应小于位置 {i+1} 的日期 {entries[i+1].date}"
            )

    @given(
        dates=st.lists(
            st.dates(
                min_value=datetime.date(2020, 1, 1),
                max_value=datetime.date(2030, 12, 31),
            ),
            min_size=2,
            max_size=30,
        )
    )
    @settings(max_examples=150)
    def test_first_entry_has_latest_date(self, dates):
        """排序后第一个条目的日期应等于输入中的最大日期。"""
        entries = [
            AuditEntry(
                date=d.isoformat(),
                slug=f"entry-{i}",
                title=f"条目 {i}",
                filename=f"{d.isoformat()}__entry-{i}.md",
            )
            for i, d in enumerate(dates)
        ]

        entries.sort(key=lambda e: e.date, reverse=True)

        expected_max = max(d.isoformat() for d in dates)
        assert entries[0].date == expected_max


# ---------------------------------------------------------------------------
# 补充边界情况测试 — Task 4.6
# 覆盖：空内容、缺少风险章节、变更类型推断分支、同日期排序
# Requirements: 2.1, 2.3
# ---------------------------------------------------------------------------


class TestParseAuditFileEdgeCases:
    """parse_audit_file 的补充边界情况"""

    def test_empty_content(self, tmp_path):
        """完全空文件 → 标题用 slug 兜底，文件清单为空，模块为 {"其他"}"""
        f = tmp_path / "2026-01-01__empty.md"
        f.write_text("", encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert entry.title == "empty"
        assert entry.changed_files == []
        assert entry.modules == {"其他"}

    def test_whitespace_only_content(self, tmp_path):
        """仅含空白字符 → 同空文件处理"""
        f = tmp_path / "2026-01-01__blank.md"
        f.write_text("   \n\n  \n", encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert entry.title == "blank"
        assert entry.changed_files == []

    def test_missing_risk_section_returns_unknown(self, tmp_path):
        """无任何风险相关内容 → 风险等级为"未知" """
        content = "# 简单标题\n\n一些普通内容，没有提到任何等级信息。\n"
        f = tmp_path / "2026-01-01__no-risk.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert entry.risk_level == "未知"

    def test_change_type_refactor(self, tmp_path):
        """含"重构"关键词 → 变更类型为"重构" """
        content = "# 代码重构\n\n对模块进行了重构优化\n"
        f = tmp_path / "2026-01-01__refactor.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry.change_type == "重构"

    def test_change_type_cleanup(self, tmp_path):
        """含"清理"关键词 → 变更类型为"清理" """
        content = "# 遗留代码清理\n\n清理了废弃文件\n"
        f = tmp_path / "2026-01-01__cleanup.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry.change_type == "清理"

    def test_change_type_default_function(self, tmp_path):
        """无任何变更类型关键词 → 默认为"功能" """
        content = "# 新增能力\n\n增加了一个全新的处理流程\n"
        f = tmp_path / "2026-01-01__feature.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry.change_type == "功能"

    def test_file_section_with_empty_table(self, tmp_path):
        """文件清单章节存在但表格无数据行 → 空列表"""
        content = textwrap.dedent("""\
            # 测试

            ## 修改文件清单

            | 文件 | 变更类型 | 说明 |
            |------|----------|------|

            ## 风险与回滚
            无
        """)
        f = tmp_path / "2026-01-01__empty-table.md"
        f.write_text(content, encoding="utf-8")

        entry = parse_audit_file(f)
        assert entry is not None
        assert entry.changed_files == []
        assert entry.modules == {"其他"}


class TestScanAuditDirEdgeCases:
    """scan_audit_dir 的补充边界情况"""

    def test_dir_with_only_invalid_files(self, tmp_path):
        """目录中全是无效文件 → 返回空列表"""
        (tmp_path / "README.md").write_text("# 说明\n", encoding="utf-8")
        (tmp_path / ".gitkeep").write_text("", encoding="utf-8")
        (tmp_path / "notes.txt").write_text("备注", encoding="utf-8")

        entries = scan_audit_dir(tmp_path)
        assert entries == []

    def test_same_date_multiple_files(self, tmp_path):
        """同日期多个文件 → 全部解析，日期相同"""
        for slug in ["alpha", "beta", "gamma"]:
            f = tmp_path / f"2026-03-01__{slug}.md"
            f.write_text(f"# {slug} 变更\n", encoding="utf-8")

        entries = scan_audit_dir(tmp_path)
        assert len(entries) == 3
        assert all(e.date == "2026-03-01" for e in entries)
