166 lines
5.3 KiB
Python
166 lines
5.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
属性测试 — 清单渲染完整性与分类分组
|
||
|
||
Feature: repo-audit
|
||
- Property 2: 清单渲染完整性
|
||
- Property 8: 清单按分类分组
|
||
|
||
Validates: Requirements 1.4, 1.10
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import string
|
||
|
||
from hypothesis import given, settings
|
||
from hypothesis import strategies as st
|
||
|
||
from scripts.audit import Category, Disposition, InventoryItem
|
||
from scripts.audit.inventory_analyzer import render_inventory_report
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 生成器策略
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_PATH_CHARS = string.ascii_letters + string.digits + "_-."
|
||
|
||
_path_segment = st.text(
|
||
alphabet=_PATH_CHARS,
|
||
min_size=1,
|
||
max_size=15,
|
||
)
|
||
|
||
# 随机相对路径(1~3 层)
|
||
_rel_path = st.lists(
|
||
_path_segment,
|
||
min_size=1,
|
||
max_size=3,
|
||
).map(lambda parts: "/".join(parts))
|
||
|
||
# 随机非空描述(不含管道符和换行符,避免破坏 Markdown 表格解析)
|
||
_description = st.text(
|
||
alphabet=st.characters(
|
||
whitelist_categories=("L", "N", "P", "S", "Z"),
|
||
blacklist_characters="|\n\r",
|
||
),
|
||
min_size=1,
|
||
max_size=40,
|
||
)
|
||
|
||
|
||
def _inventory_item_strategy() -> st.SearchStrategy[InventoryItem]:
|
||
"""生成随机 InventoryItem 的 hypothesis 策略。"""
|
||
return st.builds(
|
||
InventoryItem,
|
||
rel_path=_rel_path,
|
||
category=st.sampled_from(list(Category)),
|
||
disposition=st.sampled_from(list(Disposition)),
|
||
description=_description,
|
||
)
|
||
|
||
|
||
# 生成 0~20 个 InventoryItem 的列表
|
||
_inventory_list = st.lists(
|
||
_inventory_item_strategy(),
|
||
min_size=0,
|
||
max_size=20,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 2: 清单渲染完整性
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@given(items=_inventory_list)
|
||
@settings(max_examples=100)
|
||
def test_render_inventory_completeness(items: list[InventoryItem]) -> None:
|
||
"""Property 2: 清单渲染完整性
|
||
|
||
Feature: repo-audit, Property 2: 清单渲染完整性
|
||
Validates: Requirements 1.4
|
||
|
||
对于任意 InventoryItem 列表,render_inventory_report 生成的 Markdown 中,
|
||
每个条目的 rel_path、category.value、disposition.value 和 description
|
||
四个字段都应出现在输出文本中。
|
||
"""
|
||
report = render_inventory_report(items, "/tmp/test-repo")
|
||
|
||
for item in items:
|
||
# rel_path 出现在表格行中
|
||
assert item.rel_path in report, (
|
||
f"rel_path '{item.rel_path}' 未出现在报告中"
|
||
)
|
||
# category.value 出现在分组标题中
|
||
assert item.category.value in report, (
|
||
f"category '{item.category.value}' 未出现在报告中"
|
||
)
|
||
# disposition.value 出现在表格行中
|
||
assert item.disposition.value in report, (
|
||
f"disposition '{item.disposition.value}' 未出现在报告中"
|
||
)
|
||
# description 出现在表格行中
|
||
assert item.description in report, (
|
||
f"description '{item.description}' 未出现在报告中"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 8: 清单按分类分组
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@given(items=_inventory_list)
|
||
@settings(max_examples=100)
|
||
def test_render_inventory_grouped_by_category(items: list[InventoryItem]) -> None:
|
||
"""Property 8: 清单按分类分组
|
||
|
||
Feature: repo-audit, Property 8: 清单按分类分组
|
||
Validates: Requirements 1.10
|
||
|
||
对于任意 InventoryItem 列表,render_inventory_report 生成的 Markdown 中,
|
||
同一 Category 的条目应连续出现(不应被其他 Category 的条目打断)。
|
||
"""
|
||
report = render_inventory_report(items, "/tmp/test-repo")
|
||
|
||
if not items:
|
||
return # 空列表无需验证
|
||
|
||
# 从报告中按行提取条目对应的 category 顺序
|
||
# 表格行格式: | `{rel_path}` | {disposition} | {description} |
|
||
# 分组标题格式: ## {category.value}
|
||
lines = report.split("\n")
|
||
|
||
# 收集每个分组标题下的条目,按出现顺序记录 category
|
||
categories_in_order: list[Category] = []
|
||
current_category: Category | None = None
|
||
|
||
# 建立 category.value -> Category 的映射
|
||
value_to_cat = {c.value: c for c in Category}
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
# 检测分组标题 "## {category.value}"
|
||
if stripped.startswith("## ") and stripped[3:] in value_to_cat:
|
||
current_category = value_to_cat[stripped[3:]]
|
||
continue
|
||
# 检测表格数据行(跳过表头和分隔行)
|
||
if (
|
||
current_category is not None
|
||
and stripped.startswith("| `")
|
||
and not stripped.startswith("| 相对路径")
|
||
and not stripped.startswith("|---")
|
||
):
|
||
categories_in_order.append(current_category)
|
||
|
||
# 验证同一 Category 的条目连续出现
|
||
seen: set[Category] = set()
|
||
prev: Category | None = None
|
||
for cat in categories_in_order:
|
||
if cat != prev:
|
||
assert cat not in seen, (
|
||
f"Category '{cat.value}' 的条目不连续——"
|
||
f"在其他分类条目之后再次出现"
|
||
)
|
||
seen.add(cat)
|
||
prev = cat
|