486 lines
17 KiB
Python
486 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
属性测试 — 报告输出属性
|
||
|
||
Feature: repo-audit
|
||
- Property 13: 统计摘要一致性
|
||
- Property 14: 报告头部元信息
|
||
- Property 15: 写操作仅限 docs/audit/
|
||
|
||
Validates: Requirements 4.2, 4.5, 4.6, 4.7, 5.2
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import string
|
||
from pathlib import Path
|
||
|
||
from hypothesis import given, settings, assume
|
||
from hypothesis import strategies as st
|
||
|
||
from scripts.audit import (
|
||
AlignmentIssue,
|
||
Category,
|
||
Disposition,
|
||
DocMapping,
|
||
FlowNode,
|
||
InventoryItem,
|
||
)
|
||
from scripts.audit.inventory_analyzer import render_inventory_report
|
||
from scripts.audit.flow_analyzer import render_flow_report
|
||
from scripts.audit.doc_alignment_analyzer import render_alignment_report
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 共享生成器策略
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_PATH_CHARS = string.ascii_letters + string.digits + "_-."
|
||
|
||
_path_segment = st.text(
|
||
alphabet=_PATH_CHARS,
|
||
min_size=1,
|
||
max_size=12,
|
||
)
|
||
|
||
_rel_path = st.lists(
|
||
_path_segment,
|
||
min_size=1,
|
||
max_size=3,
|
||
).map(lambda parts: "/".join(parts))
|
||
|
||
_safe_text = st.text(
|
||
alphabet=st.characters(
|
||
whitelist_categories=("L", "N", "P", "S", "Z"),
|
||
blacklist_characters="|\n\r",
|
||
),
|
||
min_size=1,
|
||
max_size=30,
|
||
)
|
||
|
||
_repo_root_str = st.text(
|
||
alphabet=string.ascii_letters + string.digits + "/_-.",
|
||
min_size=3,
|
||
max_size=40,
|
||
).map(lambda s: "/" + s.lstrip("/"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# InventoryItem 生成器
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _inventory_item_st() -> st.SearchStrategy[InventoryItem]:
|
||
return st.builds(
|
||
InventoryItem,
|
||
rel_path=_rel_path,
|
||
category=st.sampled_from(list(Category)),
|
||
disposition=st.sampled_from(list(Disposition)),
|
||
description=_safe_text,
|
||
)
|
||
|
||
|
||
_inventory_list = st.lists(_inventory_item_st(), min_size=0, max_size=20)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FlowNode 生成器(限制深度和宽度)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _flow_node_st(max_depth: int = 2) -> st.SearchStrategy[FlowNode]:
|
||
"""生成随机 FlowNode 树,限制深度避免爆炸。"""
|
||
if max_depth <= 0:
|
||
return st.builds(
|
||
FlowNode,
|
||
name=_path_segment,
|
||
source_file=_rel_path,
|
||
node_type=st.sampled_from(["entry", "module", "class", "function"]),
|
||
children=st.just([]),
|
||
)
|
||
return st.builds(
|
||
FlowNode,
|
||
name=_path_segment,
|
||
source_file=_rel_path,
|
||
node_type=st.sampled_from(["entry", "module", "class", "function"]),
|
||
children=st.lists(
|
||
_flow_node_st(max_depth - 1),
|
||
min_size=0,
|
||
max_size=3,
|
||
),
|
||
)
|
||
|
||
|
||
_flow_tree_list = st.lists(_flow_node_st(), min_size=0, max_size=5)
|
||
_orphan_list = st.lists(_rel_path, min_size=0, max_size=10)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DocMapping / AlignmentIssue 生成器
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_issue_type_st = st.sampled_from(["stale", "conflict", "missing"])
|
||
|
||
|
||
def _alignment_issue_st() -> st.SearchStrategy[AlignmentIssue]:
|
||
return st.builds(
|
||
AlignmentIssue,
|
||
doc_path=_rel_path,
|
||
issue_type=_issue_type_st,
|
||
description=_safe_text,
|
||
related_code=_rel_path,
|
||
)
|
||
|
||
|
||
def _doc_mapping_st() -> st.SearchStrategy[DocMapping]:
|
||
return st.builds(
|
||
DocMapping,
|
||
doc_path=_rel_path,
|
||
doc_topic=_safe_text,
|
||
related_code=st.lists(_rel_path, min_size=0, max_size=5),
|
||
status=st.sampled_from(["aligned", "stale", "conflict", "orphan"]),
|
||
)
|
||
|
||
|
||
_mapping_list = st.lists(_doc_mapping_st(), min_size=0, max_size=15)
|
||
_issue_list = st.lists(_alignment_issue_st(), min_size=0, max_size=15)
|
||
|
||
|
||
# ===========================================================================
|
||
# Property 13: 统计摘要一致性
|
||
# ===========================================================================
|
||
|
||
|
||
class TestProperty13SummaryConsistency:
|
||
"""Property 13: 统计摘要一致性
|
||
|
||
Feature: repo-audit, Property 13: 统计摘要一致性
|
||
Validates: Requirements 4.5, 4.6, 4.7
|
||
|
||
对于任意报告的统计摘要,各分类/标签的计数之和应等于对应条目列表的总长度。
|
||
"""
|
||
|
||
# --- 13a: render_inventory_report 的分类计数之和 = 列表长度 ---
|
||
|
||
@given(items=_inventory_list)
|
||
@settings(max_examples=100)
|
||
def test_inventory_category_counts_sum(
|
||
self, items: list[InventoryItem]
|
||
) -> None:
|
||
"""Feature: repo-audit, Property 13: 统计摘要一致性
|
||
Validates: Requirements 4.5
|
||
|
||
render_inventory_report 统计摘要中各用途分类的计数之和应等于条目总数。
|
||
"""
|
||
report = render_inventory_report(items, "/tmp/repo")
|
||
|
||
# 定位"按用途分类"表格,提取各行数字并求和
|
||
cat_sum = _extract_summary_total(report, "按用途分类")
|
||
assert cat_sum == len(items), (
|
||
f"分类计数之和 {cat_sum} != 条目总数 {len(items)}"
|
||
)
|
||
|
||
# --- 13b: render_inventory_report 的处置标签计数之和 = 列表长度 ---
|
||
|
||
@given(items=_inventory_list)
|
||
@settings(max_examples=100)
|
||
def test_inventory_disposition_counts_sum(
|
||
self, items: list[InventoryItem]
|
||
) -> None:
|
||
"""Feature: repo-audit, Property 13: 统计摘要一致性
|
||
Validates: Requirements 4.5
|
||
|
||
render_inventory_report 统计摘要中各处置标签的计数之和应等于条目总数。
|
||
"""
|
||
report = render_inventory_report(items, "/tmp/repo")
|
||
|
||
disp_sum = _extract_summary_total(report, "按处置标签")
|
||
assert disp_sum == len(items), (
|
||
f"处置标签计数之和 {disp_sum} != 条目总数 {len(items)}"
|
||
)
|
||
|
||
# --- 13c: render_flow_report 的孤立模块数量 = orphans 列表长度 ---
|
||
|
||
@given(trees=_flow_tree_list, orphans=_orphan_list)
|
||
@settings(max_examples=100)
|
||
def test_flow_orphan_count_matches(
|
||
self, trees: list[FlowNode], orphans: list[str]
|
||
) -> None:
|
||
"""Feature: repo-audit, Property 13: 统计摘要一致性
|
||
Validates: Requirements 4.6
|
||
|
||
render_flow_report 统计摘要中的孤立模块数量应等于 orphans 列表长度。
|
||
"""
|
||
report = render_flow_report(trees, orphans, "/tmp/repo")
|
||
|
||
# 从统计摘要表格中提取"孤立模块"行的数字
|
||
orphan_count = _extract_flow_stat(report, "孤立模块")
|
||
assert orphan_count == len(orphans), (
|
||
f"报告中孤立模块数 {orphan_count} != orphans 列表长度 {len(orphans)}"
|
||
)
|
||
|
||
# --- 13d: render_alignment_report 的 issue 类型计数一致 ---
|
||
|
||
@given(mappings=_mapping_list, issues=_issue_list)
|
||
@settings(max_examples=100)
|
||
def test_alignment_issue_counts_match(
|
||
self, mappings: list[DocMapping], issues: list[AlignmentIssue]
|
||
) -> None:
|
||
"""Feature: repo-audit, Property 13: 统计摘要一致性
|
||
Validates: Requirements 4.7
|
||
|
||
render_alignment_report 统计摘要中过期/冲突/缺失点计数应与
|
||
issues 列表中对应类型的实际数量一致。
|
||
"""
|
||
report = render_alignment_report(mappings, issues, "/tmp/repo")
|
||
|
||
expected_stale = sum(1 for i in issues if i.issue_type == "stale")
|
||
expected_conflict = sum(1 for i in issues if i.issue_type == "conflict")
|
||
expected_missing = sum(1 for i in issues if i.issue_type == "missing")
|
||
|
||
actual_stale = _extract_alignment_stat(report, "过期点数量")
|
||
actual_conflict = _extract_alignment_stat(report, "冲突点数量")
|
||
actual_missing = _extract_alignment_stat(report, "缺失点数量")
|
||
|
||
assert actual_stale == expected_stale, (
|
||
f"过期点: 报告 {actual_stale} != 实际 {expected_stale}"
|
||
)
|
||
assert actual_conflict == expected_conflict, (
|
||
f"冲突点: 报告 {actual_conflict} != 实际 {expected_conflict}"
|
||
)
|
||
assert actual_missing == expected_missing, (
|
||
f"缺失点: 报告 {actual_missing} != 实际 {expected_missing}"
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# Property 14: 报告头部元信息
|
||
# ===========================================================================
|
||
|
||
|
||
class TestProperty14ReportHeader:
|
||
"""Property 14: 报告头部元信息
|
||
|
||
Feature: repo-audit, Property 14: 报告头部元信息
|
||
Validates: Requirements 4.2
|
||
|
||
对于任意报告输出,头部应包含一个符合 ISO 格式的时间戳字符串和仓库根目录路径字符串。
|
||
"""
|
||
|
||
_ISO_TS_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
|
||
|
||
@given(items=_inventory_list, repo_root=_repo_root_str)
|
||
@settings(max_examples=100)
|
||
def test_inventory_report_header(
|
||
self, items: list[InventoryItem], repo_root: str
|
||
) -> None:
|
||
"""Feature: repo-audit, Property 14: 报告头部元信息
|
||
Validates: Requirements 4.2
|
||
|
||
render_inventory_report 头部应包含 ISO 时间戳和仓库路径。
|
||
"""
|
||
report = render_inventory_report(items, repo_root)
|
||
header = report[:500]
|
||
|
||
assert self._ISO_TS_RE.search(header), (
|
||
"inventory 报告头部缺少 ISO 格式时间戳"
|
||
)
|
||
assert repo_root in header, (
|
||
f"inventory 报告头部缺少仓库路径 '{repo_root}'"
|
||
)
|
||
|
||
@given(trees=_flow_tree_list, orphans=_orphan_list, repo_root=_repo_root_str)
|
||
@settings(max_examples=100)
|
||
def test_flow_report_header(
|
||
self, trees: list[FlowNode], orphans: list[str], repo_root: str
|
||
) -> None:
|
||
"""Feature: repo-audit, Property 14: 报告头部元信息
|
||
Validates: Requirements 4.2
|
||
|
||
render_flow_report 头部应包含 ISO 时间戳和仓库路径。
|
||
"""
|
||
report = render_flow_report(trees, orphans, repo_root)
|
||
header = report[:500]
|
||
|
||
assert self._ISO_TS_RE.search(header), (
|
||
"flow 报告头部缺少 ISO 格式时间戳"
|
||
)
|
||
assert repo_root in header, (
|
||
f"flow 报告头部缺少仓库路径 '{repo_root}'"
|
||
)
|
||
|
||
@given(mappings=_mapping_list, issues=_issue_list, repo_root=_repo_root_str)
|
||
@settings(max_examples=100)
|
||
def test_alignment_report_header(
|
||
self, mappings: list[DocMapping], issues: list[AlignmentIssue], repo_root: str
|
||
) -> None:
|
||
"""Feature: repo-audit, Property 14: 报告头部元信息
|
||
Validates: Requirements 4.2
|
||
|
||
render_alignment_report 头部应包含 ISO 时间戳和仓库路径。
|
||
"""
|
||
report = render_alignment_report(mappings, issues, repo_root)
|
||
header = report[:500]
|
||
|
||
assert self._ISO_TS_RE.search(header), (
|
||
"alignment 报告头部缺少 ISO 格式时间戳"
|
||
)
|
||
assert repo_root in header, (
|
||
f"alignment 报告头部缺少仓库路径 '{repo_root}'"
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# Property 15: 写操作仅限 docs/audit/
|
||
# ===========================================================================
|
||
|
||
|
||
class TestProperty15WritesOnlyDocsAudit:
|
||
"""Property 15: 写操作仅限 docs/audit/
|
||
|
||
Feature: repo-audit, Property 15: 写操作仅限 docs/audit/
|
||
Validates: Requirements 5.2
|
||
|
||
对于任意审计执行过程,所有文件写操作的目标路径应以 docs/audit/ 为前缀。
|
||
由于需要实际文件系统,使用较少迭代。
|
||
"""
|
||
|
||
@staticmethod
|
||
def _make_minimal_repo(base: Path, variant: int) -> Path:
|
||
"""构造最小仓库结构,variant 控制变体以增加多样性。"""
|
||
repo = base / f"repo_{variant}"
|
||
repo.mkdir()
|
||
|
||
# 必需的 cli 入口
|
||
cli_dir = repo / "cli"
|
||
cli_dir.mkdir()
|
||
(cli_dir / "__init__.py").write_text("", encoding="utf-8")
|
||
(cli_dir / "main.py").write_text(
|
||
"# -*- coding: utf-8 -*-\ndef main(): pass\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
# config 目录
|
||
config_dir = repo / "config"
|
||
config_dir.mkdir()
|
||
(config_dir / "__init__.py").write_text("", encoding="utf-8")
|
||
|
||
# docs 目录
|
||
docs_dir = repo / "docs"
|
||
docs_dir.mkdir()
|
||
|
||
# 根据 variant 添加不同的额外文件
|
||
if variant % 3 == 0:
|
||
(repo / "README.md").write_text("# 项目\n", encoding="utf-8")
|
||
if variant % 3 == 1:
|
||
scripts_dir = repo / "scripts"
|
||
scripts_dir.mkdir()
|
||
(scripts_dir / "__init__.py").write_text("", encoding="utf-8")
|
||
if variant % 3 == 2:
|
||
(docs_dir / "notes.md").write_text("# 笔记\n", encoding="utf-8")
|
||
|
||
return repo
|
||
|
||
@staticmethod
|
||
def _snapshot_files(repo: Path) -> dict[str, float]:
|
||
"""记录仓库中所有文件的 mtime 快照(排除 docs/audit/)。"""
|
||
snap: dict[str, float] = {}
|
||
for p in repo.rglob("*"):
|
||
if p.is_file():
|
||
rel = p.relative_to(repo).as_posix()
|
||
if not rel.startswith("docs/audit"):
|
||
snap[rel] = p.stat().st_mtime
|
||
return snap
|
||
|
||
@given(variant=st.integers(min_value=0, max_value=9))
|
||
@settings(max_examples=10)
|
||
def test_writes_only_under_docs_audit(self, variant: int) -> None:
|
||
"""Feature: repo-audit, Property 15: 写操作仅限 docs/audit/
|
||
Validates: Requirements 5.2
|
||
|
||
运行 run_audit 后,docs/audit/ 外不应有新文件被创建。
|
||
docs/audit/ 下应有报告文件。
|
||
"""
|
||
import tempfile
|
||
from scripts.audit.run_audit import run_audit
|
||
|
||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||
tmp_path = Path(tmp_dir)
|
||
repo = self._make_minimal_repo(tmp_path, variant)
|
||
before_snap = self._snapshot_files(repo)
|
||
|
||
run_audit(repo)
|
||
|
||
# 验证 docs/audit/ 下有新文件
|
||
audit_dir = repo / "docs" / "audit"
|
||
assert audit_dir.is_dir(), "docs/audit/ 目录未创建"
|
||
audit_files = list(audit_dir.iterdir())
|
||
assert len(audit_files) > 0, "docs/audit/ 下无报告文件"
|
||
|
||
# 验证 docs/audit/ 外无新文件
|
||
for p in repo.rglob("*"):
|
||
if p.is_file():
|
||
rel = p.relative_to(repo).as_posix()
|
||
if rel.startswith("docs/audit"):
|
||
continue
|
||
assert rel in before_snap, (
|
||
f"docs/audit/ 外出现了新文件: {rel}"
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# 辅助函数 — 从报告文本中提取统计数字
|
||
# ===========================================================================
|
||
|
||
def _extract_summary_total(report: str, section_name: str) -> int:
|
||
"""从 inventory 报告的统计摘要中提取指定分区的数字之和。
|
||
|
||
查找 "### {section_name}" 下的 Markdown 表格,
|
||
累加每行最后一列的数字(排除合计行)。
|
||
"""
|
||
lines = report.split("\n")
|
||
in_section = False
|
||
total = 0
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if stripped == f"### {section_name}":
|
||
in_section = True
|
||
continue
|
||
if in_section and stripped.startswith("###"):
|
||
# 进入下一个子节
|
||
break
|
||
if in_section and stripped.startswith("|") and "**合计**" not in stripped:
|
||
# 跳过表头和分隔行
|
||
if stripped.startswith("| 用途分类") or stripped.startswith("| 处置标签"):
|
||
continue
|
||
if stripped.startswith("|---"):
|
||
continue
|
||
# 提取最后一列的数字
|
||
cells = [c.strip() for c in stripped.split("|") if c.strip()]
|
||
if cells:
|
||
try:
|
||
total += int(cells[-1])
|
||
except ValueError:
|
||
pass
|
||
|
||
return total
|
||
|
||
|
||
def _extract_flow_stat(report: str, label: str) -> int:
|
||
"""从 flow 报告统计摘要表格中提取指定指标的数字。"""
|
||
# 匹配 "| 孤立模块 | 5 |" 格式
|
||
pattern = re.compile(rf"\|\s*{re.escape(label)}\s*\|\s*(\d+)\s*\|")
|
||
m = pattern.search(report)
|
||
return int(m.group(1)) if m else -1
|
||
|
||
|
||
def _extract_alignment_stat(report: str, label: str) -> int:
|
||
"""从 alignment 报告统计摘要中提取指定指标的数字。
|
||
|
||
匹配 "- 过期点数量:3" 格式。
|
||
"""
|
||
# 兼容全角/半角冒号
|
||
pattern = re.compile(rf"{re.escape(label)}[::]\s*(\d+)")
|
||
m = pattern.search(report)
|
||
return int(m.group(1)) if m else -1
|