# -*- coding: utf-8 -*- """ 属性测试 — classify 完整性 Feature: repo-audit, Property 1: classify 完整性 Validates: Requirements 1.2, 1.3 对于任意 FileEntry,classify 函数返回的 InventoryItem 的 category 字段 应属于 Category 枚举,disposition 字段应属于 Disposition 枚举, 且 description 字段为非空字符串。 """ from __future__ import annotations import string from hypothesis import given, settings from hypothesis import strategies as st from scripts.audit import Category, Disposition, FileEntry, InventoryItem from scripts.audit.inventory_analyzer import classify # --------------------------------------------------------------------------- # 生成器策略 # --------------------------------------------------------------------------- # 常见文件扩展名(含空扩展名表示无扩展名的情况) _EXTENSIONS = st.sampled_from([ "", ".py", ".sql", ".md", ".txt", ".json", ".csv", ".xlsx", ".bat", ".sh", ".ps1", ".lnk", ".rar", ".log", ".ini", ".cfg", ".toml", ".yaml", ".yml", ".html", ".css", ".js", ]) # 路径片段:字母数字加常见特殊字符 _PATH_CHARS = string.ascii_letters + string.digits + "_-." _path_segment = st.text( alphabet=_PATH_CHARS, min_size=1, max_size=20, ) # 生成 1~4 层目录深度的相对路径 _rel_path = st.lists( _path_segment, min_size=1, max_size=4, ).map(lambda parts: "/".join(parts)) def _file_entry_strategy() -> st.SearchStrategy[FileEntry]: """生成随机 FileEntry 的 hypothesis 策略。 覆盖各种扩展名、目录层级、大小和布尔标志组合。 """ return st.builds( FileEntry, rel_path=_rel_path, is_dir=st.booleans(), size_bytes=st.integers(min_value=0, max_value=10_000_000), extension=_EXTENSIONS, is_empty_dir=st.booleans(), ) # --------------------------------------------------------------------------- # Property 1: classify 完整性 # --------------------------------------------------------------------------- @given(entry=_file_entry_strategy()) @settings(max_examples=100) def test_classify_completeness(entry: FileEntry) -> None: """Property 1: classify 完整性 Feature: repo-audit, Property 1: classify 完整性 Validates: Requirements 1.2, 1.3 对于任意 FileEntry,classify 返回的 InventoryItem 应满足: - category 属于 Category 枚举 - disposition 属于 Disposition 枚举 - description 为非空字符串 """ result = classify(entry) # 返回类型正确 assert isinstance(result, InventoryItem), ( f"classify 应返回 InventoryItem,实际返回 {type(result)}" ) # category 属于 Category 枚举 assert isinstance(result.category, Category), ( f"category 应为 Category 枚举成员,实际为 {result.category!r}" ) # disposition 属于 Disposition 枚举 assert isinstance(result.disposition, Disposition), ( f"disposition 应为 Disposition 枚举成员,实际为 {result.disposition!r}" ) # description 为非空字符串 assert isinstance(result.description, str) and len(result.description) > 0, ( f"description 应为非空字符串,实际为 {result.description!r}" ) # --------------------------------------------------------------------------- # 辅助:高优先级目录前缀(用于在低优先级属性测试中排除) # --------------------------------------------------------------------------- _HIGH_PRIORITY_PREFIXES = ("tmp/", "logs/", "export/") # 安全的顶层目录名(不会触发高优先级规则) _SAFE_TOP_DIRS = st.sampled_from([ "src", "lib", "data", "misc", "vendor", "tools", "archive", "assets", "resources", "contrib", "extras", ]) # 非 .lnk/.rar 的扩展名 _SAFE_EXTENSIONS = st.sampled_from([ "", ".py", ".sql", ".md", ".txt", ".json", ".csv", ".xlsx", ".bat", ".sh", ".ps1", ".log", ".ini", ".cfg", ".toml", ".yaml", ".yml", ".html", ".css", ".js", ]) def _safe_rel_path() -> st.SearchStrategy[str]: """生成不以高优先级目录开头的相对路径。""" return st.builds( lambda top, rest: f"{top}/{rest}" if rest else top, top=_SAFE_TOP_DIRS, rest=st.lists(_path_segment, min_size=0, max_size=3).map( lambda parts: "/".join(parts) if parts else "" ), ) # --------------------------------------------------------------------------- # Property 3: 空目录标记为候选删除 # --------------------------------------------------------------------------- @given(data=st.data()) @settings(max_examples=100) def test_empty_dir_candidate_delete(data: st.DataObject) -> None: """Property 3: 空目录标记为候选删除 Feature: repo-audit, Property 3: 空目录标记为候选删除 Validates: Requirements 1.5 对于任意 is_empty_dir=True 的 FileEntry(排除 tmp/、logs/、reports/、 export/ 开头和 .lnk/.rar 扩展名),classify 返回的 disposition 应为 Disposition.CANDIDATE_DELETE。 """ rel_path = data.draw(_safe_rel_path()) ext = data.draw(_SAFE_EXTENSIONS) entry = FileEntry( rel_path=rel_path, is_dir=True, size_bytes=0, extension=ext, is_empty_dir=True, ) result = classify(entry) assert result.disposition == Disposition.CANDIDATE_DELETE, ( f"空目录 '{entry.rel_path}' 应标记为候选删除," f"实际为 {result.disposition.value}" ) # --------------------------------------------------------------------------- # Property 4: .lnk/.rar 文件标记为候选删除 # --------------------------------------------------------------------------- @given(data=st.data()) @settings(max_examples=100) def test_lnk_rar_candidate_delete(data: st.DataObject) -> None: """Property 4: .lnk/.rar 文件标记为候选删除 Feature: repo-audit, Property 4: .lnk/.rar 文件标记为候选删除 Validates: Requirements 1.6 对于任意扩展名为 .lnk 或 .rar 的 FileEntry(排除 tmp/、logs/、 reports/、export/ 开头,且 is_empty_dir=False),classify 返回的 disposition 应为 Disposition.CANDIDATE_DELETE。 """ rel_path = data.draw(_safe_rel_path()) ext = data.draw(st.sampled_from([".lnk", ".rar"])) entry = FileEntry( rel_path=rel_path, is_dir=False, size_bytes=data.draw(st.integers(min_value=0, max_value=10_000_000)), extension=ext, is_empty_dir=False, ) result = classify(entry) assert result.disposition == Disposition.CANDIDATE_DELETE, ( f"文件 '{entry.rel_path}' (ext={ext}) 应标记为候选删除," f"实际为 {result.disposition.value}" ) # --------------------------------------------------------------------------- # Property 5: tmp/ 下文件处置范围 # --------------------------------------------------------------------------- _TMP_EXTENSIONS = st.sampled_from([ "", ".py", ".sql", ".md", ".txt", ".json", ".csv", ".xlsx", ".bat", ".sh", ".ps1", ".lnk", ".rar", ".log", ".ini", ".cfg", ".toml", ".yaml", ".yml", ".html", ".css", ".js", ".tmp", ".bak", ]) def _tmp_rel_path() -> st.SearchStrategy[str]: """生成以 tmp/ 开头的相对路径。""" return st.builds( lambda rest: f"tmp/{rest}", rest=st.lists(_path_segment, min_size=1, max_size=3).map( lambda parts: "/".join(parts) ), ) @given(data=st.data()) @settings(max_examples=100) def test_tmp_disposition_range(data: st.DataObject) -> None: """Property 5: tmp/ 下文件处置范围 Feature: repo-audit, Property 5: tmp/ 下文件处置范围 Validates: Requirements 1.7 对于任意 rel_path 以 tmp/ 开头的 FileEntry,classify 返回的 disposition 应为 CANDIDATE_DELETE 或 CANDIDATE_ARCHIVE 之一。 """ rel_path = data.draw(_tmp_rel_path()) ext = data.draw(_TMP_EXTENSIONS) entry = FileEntry( rel_path=rel_path, is_dir=data.draw(st.booleans()), size_bytes=data.draw(st.integers(min_value=0, max_value=10_000_000)), extension=ext, is_empty_dir=data.draw(st.booleans()), ) result = classify(entry) allowed = {Disposition.CANDIDATE_DELETE, Disposition.CANDIDATE_ARCHIVE} assert result.disposition in allowed, ( f"tmp/ 下文件 '{entry.rel_path}' 的处置应为候选删除或候选归档," f"实际为 {result.disposition.value}" ) # --------------------------------------------------------------------------- # Property 6: 运行时产出目录标记为候选归档 # --------------------------------------------------------------------------- _RUNTIME_DIRS = st.sampled_from(["logs", "export"]) # 排除 __init__.py 的文件名 _NON_INIT_BASENAME = st.text( alphabet=_PATH_CHARS, min_size=1, max_size=20, ).filter(lambda s: s != "__init__.py") def _runtime_output_rel_path() -> st.SearchStrategy[str]: """生成以 logs/、reports/ 或 export/ 开头的相对路径,basename 不是 __init__.py。""" return st.builds( lambda top, mid, name: ( f"{top}/{'/'.join(mid)}/{name}" if mid else f"{top}/{name}" ), top=_RUNTIME_DIRS, mid=st.lists(_path_segment, min_size=0, max_size=2), name=_NON_INIT_BASENAME, ) @given(data=st.data()) @settings(max_examples=100) def test_runtime_output_candidate_archive(data: st.DataObject) -> None: """Property 6: 运行时产出目录标记为候选归档 Feature: repo-audit, Property 6: 运行时产出目录标记为候选归档 Validates: Requirements 1.8 对于任意 rel_path 以 logs/ 或 export/ 开头且非 __init__.py 的 FileEntry,classify 返回的 disposition 应为 CANDIDATE_ARCHIVE。 需求 1.8 仅覆盖 logs/ 和 export/ 目录(不含 reports/)。 """ rel_path = data.draw(_runtime_output_rel_path()) ext = data.draw(_EXTENSIONS) entry = FileEntry( rel_path=rel_path, is_dir=data.draw(st.booleans()), size_bytes=data.draw(st.integers(min_value=0, max_value=10_000_000)), extension=ext, is_empty_dir=data.draw(st.booleans()), ) result = classify(entry) assert result.disposition == Disposition.CANDIDATE_ARCHIVE, ( f"运行时产出 '{entry.rel_path}' 应标记为候选归档," f"实际为 {result.disposition.value}" )