Files
detect-policy-conflict-gs/tests/run_tests.py

367 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""默认测试套件:制度文件冲突检测与合规审查。
设计原则:
- 仅用标准库(不依赖 pytest / jinja2),CI 与本地均能直接 `python tests/run_tests.py` 跑
- 覆盖:
- util.constants 版本/标识对齐(SKILL.md / CHANGELOG.md / constants.py)
- util.conflict_classifier 纯函数(classify_conflict_severity / normalize_policy_text / parse_policy_draft_id)
- scripts.main CLI 命令注册表与基础调用(占位骨架可正常 dispatch)
- 退出码:0 = 全部通过,1 = 有失败
- -v 开关:详细输出每个用例的状态
release.ps1 在打 tag 前会自动跑 `python tests/run_tests.py -v`,任何一个
test_* 返回 (False, msg) 即 abort 发布。
"""
from __future__ import annotations
import argparse
import io
import re
import sys
import traceback
from contextlib import redirect_stdout, redirect_stderr
from pathlib import Path
from typing import Any, Callable
# 让 scripts/ 下的模块可导入
_REPO_ROOT = Path(__file__).resolve().parent.parent
_SCRIPTS_DIR = _REPO_ROOT / "scripts"
sys.path.insert(0, str(_SCRIPTS_DIR))
# ---------- 测试基础设施 ----------
# 测试结果收集器
_RESULTS: list[tuple[str, bool, str]] = []
_VERBOSE = False
def _run(name: str, fn: Callable[[], tuple[bool, str]]) -> None:
"""运行单个 test_*,记录结果。"""
try:
ok, msg = fn()
except Exception as e: # 测试本身抛异常也算失败
tb = traceback.format_exc(limit=3)
ok, msg = False, f"exception: {e}\n{tb}"
_RESULTS.append((name, ok, msg))
if _VERBOSE or not ok:
marker = "PASS" if ok else "FAIL"
print(f" [{marker}] {name}: {msg}", flush=True)
# ---------- util.constants 版本对齐(POLICY-RELEASE-001) ----------
def test_skill_md_version_alignment() -> tuple[bool, str]:
"""POLICY-RELEASE-001:SKILL.md version == SKILL_VERSION == CHANGELOG 顶部版本"""
from util.constants import SKILL_VERSION
skill_md = (_REPO_ROOT / "SKILL.md").read_text(encoding="utf-8")
m = re.search(r'(?m)^version:\s*["\']?(\d+\.\d+\.\d+)', skill_md)
if not m:
return False, "SKILL.md 缺顶层 version: X.Y.Z"
md_ver = m.group(1)
if md_ver != SKILL_VERSION:
return False, f"SKILL.md={md_ver} != SKILL_VERSION={SKILL_VERSION}"
# CHANGELOG 顶部应有 ## X.Y.Z
cl = (_REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
cl_m = re.search(r'(?m)^##\s+(\d+\.\d+\.\d+)', cl)
if not cl_m:
return False, "CHANGELOG.md 缺顶层 ## X.Y.Z"
cl_ver = cl_m.group(1)
if cl_ver != SKILL_VERSION:
return False, f"CHANGELOG 顶部={cl_ver} != SKILL_VERSION={SKILL_VERSION}"
return True, f"SKILL.md={md_ver} == CHANGELOG={cl_ver} == SKILL_VERSION={SKILL_VERSION}"
def test_skill_md_slug_alignment() -> tuple[bool, str]:
"""SKILL.md metadata.openclaw.slug == SKILL_SLUG"""
from util.constants import SKILL_SLUG
skill_md = (_REPO_ROOT / "SKILL.md").read_text(encoding="utf-8")
m = re.search(r'(?m)^\s*slug:\s*["\']?([a-z0-9_-]+)', skill_md)
if not m:
return False, "SKILL.md 缺 metadata.openclaw.slug"
md_slug = m.group(1)
if md_slug != SKILL_SLUG:
return False, f"SKILL.md slug={md_slug} != SKILL_SLUG={SKILL_SLUG}"
return True, f"slug={md_slug}"
def test_constants_policy_draft_statuses() -> tuple[bool, str]:
"""送审稿状态机:5 状态枚举完整 + 顺序正确"""
from util.constants import POLICY_DRAFT_STATUSES
expected = ("可送审", "审查中", "已通过", "冲突中", "驳回")
if POLICY_DRAFT_STATUSES != expected:
return False, f"POLICY_DRAFT_STATUSES={POLICY_DRAFT_STATUSES} != {expected}"
return True, f"{len(POLICY_DRAFT_STATUSES)} 状态按生命周期顺序"
def test_constants_conflict_types() -> tuple[bool, str]:
"""冲突类型枚举:含条款冲突/程序不合规/重复发文/用词歧义"""
from util.constants import CONFLICT_TYPES, CONFLICT_SEVERITY_LEVELS, CONFLICT_SEVERITY_LABELS
required = {"条款直接冲突", "程序不合规", "疑似重复发文", "用词歧义"}
missing = required - set(CONFLICT_TYPES)
if missing:
return False, f"CONFLICT_TYPES 缺少: {sorted(missing)}"
if CONFLICT_SEVERITY_LEVELS != ("low", "medium", "high"):
return False, f"CONFLICT_SEVERITY_LEVELS={CONFLICT_SEVERITY_LEVELS}"
if set(CONFLICT_SEVERITY_LABELS.keys()) != set(CONFLICT_SEVERITY_LEVELS):
return False, (
f"CONFLICT_SEVERITY_LABELS keys={set(CONFLICT_SEVERITY_LABELS.keys())} "
f"!= {set(CONFLICT_SEVERITY_LEVELS)}"
)
return True, f"{len(CONFLICT_TYPES)} 冲突类型 + {len(CONFLICT_SEVERITY_LEVELS)} 等级"
# ---------- util.conflict_classifier 纯函数 ----------
def test_classify_conflict_severity_thresholds() -> tuple[bool, str]:
"""classify_conflict_severity 高/中/低 三档边界"""
from util.conflict_classifier import classify_conflict_severity
t = (0.85, 0.92)
cases = [
(1.00, "high"), # 完全相同
(0.95, "high"), # 高于 high_min
(0.92, "high"), # 边界 =
(0.91, "medium"), # 边界下
(0.88, "medium"), # 中等
(0.85, "medium"), # 边界 =
(0.84, "low"), # 低于 medium_min
(0.50, "low"), # 明显不相关
(0.00, "low"), # 完全不同
]
for sim, want in cases:
got = classify_conflict_severity(sim, t)
if got != want:
return False, f"classify_conflict_severity({sim}) = {got}, want {want}"
return True, "9 边界 + 越界场景"
def test_classify_conflict_severity_custom_thresholds() -> tuple[bool, str]:
"""classify_conflict_severity 自定义阈值"""
from util.conflict_classifier import classify_conflict_severity
# 收紧阈值
if classify_conflict_severity(0.90, thresholds=(0.80, 0.90)) != "high":
return False, "自定义阈值 high 边界错"
if classify_conflict_severity(0.85, thresholds=(0.80, 0.90)) != "medium":
return False, "自定义阈值 medium 错"
if classify_conflict_severity(0.79, thresholds=(0.80, 0.90)) != "low":
return False, "自定义阈值 low 错"
return True, "自定义阈值 high/medium/low"
def test_normalize_policy_text_fullwidth() -> tuple[bool, str]:
"""全角数字/字母/空格转半角 + 合并连续空白"""
from util.conflict_classifier import normalize_policy_text
cases = [
("第一章 总 则", "第一章 总 则"),
("第 1 条 总则", "第 1 条 总则"),
("Article 1:Definition", "Article 1:Definition"),
("", ""),
(" 多个 空白 ", "多个 空白"),
("无变化", "无变化"),
("条款ABC123", "条款ABC123"),
]
for inp, want in cases:
got = normalize_policy_text(inp)
if got != want:
return False, f"normalize_policy_text({inp!r}) = {got!r}, want {want!r}"
return True, "全角转半角 + 空白合并 + 边界"
def test_normalize_policy_text_none() -> tuple[bool, str]:
"""None 输入返回空字符串(不抛异常)"""
from util.conflict_classifier import normalize_policy_text
if normalize_policy_text(None) != "":
return False, "None 应返回空字符串"
return True, "None → \"\""
def test_parse_policy_draft_id() -> tuple[bool, str]:
"""parse_policy_draft_id 去空白 + 校验非空"""
from util.conflict_classifier import parse_policy_draft_id
if parse_policy_draft_id("D-001") != "D-001":
return False, "正常 ID 应原样返回"
if parse_policy_draft_id(" D-002 ") != "D-002":
return False, "前后空白应被 strip"
if parse_policy_draft_id(123) != "123":
return False, "数字应转字符串"
# 空值校验
raised = False
try:
parse_policy_draft_id("")
except ValueError:
raised = True
if not raised:
return False, "空字符串应抛 ValueError"
raised = False
try:
parse_policy_draft_id(" ")
except ValueError:
raised = True
if not raised:
return False, "全空白字符串应抛 ValueError"
raised = False
try:
parse_policy_draft_id(None)
except ValueError:
raised = True
if not raised:
return False, "None 应抛 ValueError"
return True, "正常/空白 strip/数字/空/全空白/None 6 场景"
# ---------- scripts.main CLI 命令注册 ----------
def test_main_commands_registered() -> tuple[bool, str]:
"""scripts/main.py COMMANDS 字典含全部 9 个命令"""
from main import COMMANDS
expected = {
"health",
"version",
"config-path",
"init-db",
"audit-policy-draft",
"recheck-conflict-clause",
"export-review-report",
"mark-policy-status",
"rescan-pending-drafts",
}
missing = expected - set(COMMANDS.keys())
if missing:
return False, f"COMMANDS 缺: {sorted(missing)}"
extra = set(COMMANDS.keys()) - expected
if extra:
return False, f"COMMANDS 多余: {sorted(extra)}"
return True, f"{len(COMMANDS)} 个命令全部注册"
def test_main_version_command_prints_skill_version() -> tuple[bool, str]:
"""`python main.py version` 应输出 SKILL_VERSION"""
from util.constants import SKILL_VERSION
import main as cli_main
buf = io.StringIO()
with redirect_stdout(buf):
rc = cli_main.version()
output = buf.getvalue()
if rc != 0:
return False, f"version() 返回 {rc}, want 0"
if SKILL_VERSION not in output:
return False, f"version() 输出不含 {SKILL_VERSION}: {output!r}"
return True, f"输出含 SKILL_VERSION={SKILL_VERSION}"
def test_main_dispatch_unknown_command() -> tuple[bool, str]:
"""main() 对未知命令应返回 1 并打印 usage"""
import main as cli_main
buf_out = io.StringIO()
buf_err = io.StringIO()
with redirect_stdout(buf_out), redirect_stderr(buf_err):
rc = cli_main.main(["not-a-real-command"])
if rc == 0:
return False, f"未知命令应返回非 0, got {rc}"
if "Usage:" not in buf_out.getvalue():
return False, f"未知命令应打印 Usage, got {buf_out.getvalue()!r}"
return True, f"未知命令 → rc={rc} + Usage 输出"
def test_main_dispatch_audit_policy_draft() -> tuple[bool, str]:
"""`audit-policy-draft D-001` 占位实现应 echo draft_id"""
import main as cli_main
buf = io.StringIO()
with redirect_stdout(buf):
rc = cli_main.main(["audit-policy-draft", "D-001"])
output = buf.getvalue()
if rc != 0:
return False, f"audit-policy-draft 返回 {rc}, want 0"
if "D-001" not in output:
return False, f"输出应含 D-001, got {output!r}"
return True, "audit-policy-draft 占位 echo 正常"
def test_main_mark_policy_status_validates_enum() -> tuple[bool, str]:
"""`mark-policy-status` 未知状态应返回 1(不允许静默接受)"""
import main as cli_main
buf_out = io.StringIO()
buf_err = io.StringIO()
with redirect_stdout(buf_out), redirect_stderr(buf_err):
rc = cli_main.mark_policy_status("D-001", "非法状态")
if rc == 0:
return False, "非法状态应返回非 0"
if "ERROR" not in buf_err.getvalue():
return False, f"应输出 ERROR 提示, got stderr={buf_err.getvalue()!r}"
# 合法状态应返回 0
buf_ok = io.StringIO()
with redirect_stdout(buf_ok):
rc_ok = cli_main.mark_policy_status("D-002", "审查中")
if rc_ok != 0:
return False, f"合法状态应返回 0, got {rc_ok}"
return True, "非法状态 → 非 0 + ERROR;合法状态 → 0"
# ---------- 入口 ----------
def main() -> int:
parser = argparse.ArgumentParser(description="默认 release 测试套件")
parser.add_argument("-v", "--verbose", action="store_true",
help="详细输出每个用例结果")
args = parser.parse_args()
global _VERBOSE
_VERBOSE = args.verbose
print("=== detect-policy-conflict-gs release tests ===", flush=True)
_run("constants/skill_md_version_alignment", test_skill_md_version_alignment)
_run("constants/skill_md_slug_alignment", test_skill_md_slug_alignment)
_run("constants/policy_draft_statuses", test_constants_policy_draft_statuses)
_run("constants/conflict_types", test_constants_conflict_types)
_run("conflict_classifier/classify_thresholds", test_classify_conflict_severity_thresholds)
_run("conflict_classifier/classify_custom_thresholds", test_classify_conflict_severity_custom_thresholds)
_run("conflict_classifier/normalize_fullwidth", test_normalize_policy_text_fullwidth)
_run("conflict_classifier/normalize_none", test_normalize_policy_text_none)
_run("conflict_classifier/parse_policy_draft_id", test_parse_policy_draft_id)
_run("main/commands_registered", test_main_commands_registered)
_run("main/version_prints_skill_version", test_main_version_command_prints_skill_version)
_run("main/dispatch_unknown_command", test_main_dispatch_unknown_command)
_run("main/dispatch_audit_policy_draft", test_main_dispatch_audit_policy_draft)
_run("main/mark_policy_status_validates", test_main_mark_policy_status_validates_enum)
total = len(_RESULTS)
passed = sum(1 for _, ok, _ in _RESULTS if ok)
failed = total - passed
print()
print(f"=== Summary: {passed}/{total} passed, {failed} failed ===", flush=True)
if failed:
print("\nFailures:")
for name, ok, msg in _RESULTS:
if not ok:
print(f" - {name}: {msg}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())