Files

323 lines
12 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 / requests / pandas),CI 与本地均能直接 `python tests/run_tests.py` 跑
- 不依赖 sqlite3(v1.0.1 骨架尚未引入 DB schema,全部测试在纯函数层)
- 覆盖:util.constants 版本对齐 + SKILL.md 元数据对齐 + 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
from contextlib import redirect_stdout
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:
import traceback
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_skill_md_brands_alignment() -> tuple[bool, str]:
"""SKILL.md metadata.openclaw.brands == constants.BRANDS(多平台发布对齐)"""
from util.constants import BRANDS
skill_md = (_REPO_ROOT / "SKILL.md").read_text(encoding="utf-8")
# 在 SKILL.md 里找 brands: 后的列表内容
m = re.search(
r'(?ms)^\s*brands:\s*(?:#[^\n]*\n)?(?P<list>(?:\s*-\s*[a-z]+\s*[^\n]*\n)+)',
skill_md,
)
if not m:
return False, "SKILL.md 缺 metadata.openclaw.brands 列表"
listed = re.findall(r'-\s*([a-z0-9_-]+)', m.group("list"))
listed_tuple = tuple(listed)
if listed_tuple != BRANDS:
return False, f"SKILL.md brands={listed_tuple} != BRANDS={BRANDS}"
return True, f"brands={','.join(BRANDS)}"
# ---------- util.constants 自身一致性 ----------
def test_constants_required_keys_present() -> tuple[bool, str]:
"""util.constants 必须含 SKILL_SLUG/SKILL_VERSION/PLATFORM_KIT_MIN_VERSION/BRANDS"""
from util import constants
missing = []
for key in ("SKILL_SLUG", "SKILL_VERSION", "PLATFORM_KIT_MIN_VERSION",
"BRANDS", "CANDIDATE_STATUSES", "REVIEW_CONCLUSIONS",
"RISK_DIMENSIONS", "RISK_LEVEL_LABELS"):
if not hasattr(constants, key):
missing.append(key)
if missing:
return False, f"util.constants 缺字段: {', '.join(missing)}"
return True, "全部必需字段已声明"
def test_constants_candidate_statuses_complete() -> tuple[bool, str]:
"""CANDIDATE_STATUSES 必须覆盖 REQUIREMENTS 0.3 节业务行生命周期的 5 个状态"""
from util.constants import CANDIDATE_STATUSES
required = ("待审", "已标注", "已复核", "已驳回", "已撤回")
missing = [s for s in required if s not in CANDIDATE_STATUSES]
if missing:
return False, f"CANDIDATE_STATUSES 缺: {', '.join(missing)}"
if len(CANDIDATE_STATUSES) != len(required):
return False, f"CANDIDATE_STATUSES 多了非预期项: {CANDIDATE_STATUSES}"
return True, f"5 个状态齐全: {' / '.join(CANDIDATE_STATUSES)}"
def test_constants_review_conclusions_complete() -> tuple[bool, str]:
"""REVIEW_CONCLUSIONS 必须含保留 / 取消 / 调减"""
from util.constants import REVIEW_CONCLUSIONS
required = ("保留", "取消", "调减")
missing = [s for s in required if s not in REVIEW_CONCLUSIONS]
if missing:
return False, f"REVIEW_CONCLUSIONS 缺: {', '.join(missing)}"
return True, f"{len(REVIEW_CONCLUSIONS)} 个结论齐全"
def test_constants_risk_dimensions_complete() -> tuple[bool, str]:
"""RISK_DIMENSIONS 必须含 REQUIREMENTS 列举的 4 个检测维度"""
from util.constants import RISK_DIMENSIONS
required = (
"income_vs_card_consumption",
"comprehensive_vs_score_rank",
"cumulative_aid_amount",
"duplicate_aid_history",
)
missing = [s for s in required if s not in RISK_DIMENSIONS]
if missing:
return False, f"RISK_DIMENSIONS 缺: {', '.join(missing)}"
return True, f"{len(RISK_DIMENSIONS)} 个检测维度齐全"
def test_constants_brands_match_skill_md() -> tuple[bool, str]:
"""BRANDS 至少含 jiangchang + huashang(v1.0.2 多平台发布声明)"""
from util.constants import BRANDS
for need in ("jiangchang", "huashang"):
if need not in BRANDS:
return False, f"BRANDS 缺 {need!r},当前 {BRANDS}"
return True, f"BRANDS={list(BRANDS)}"
# ---------- main.py CLI 入口 ----------
def test_main_commands_registered() -> tuple[bool, str]:
"""scripts/main.py COMMANDS 必须注册全部 REQUIREMENTS 0.1 节列出的能力"""
from main import COMMANDS
required = (
"health", "version", "config-path", "init-db",
"verify-candidate", "batch-verify-class",
"mark-needs-review", "submit-review-result", "render-html",
"sync-card-data", "sync-gdp-data",
)
missing = [c for c in required if c not in COMMANDS]
if missing:
return False, f"COMMANDS 缺: {', '.join(missing)}"
return True, f"{len(COMMANDS)} 个命令已注册"
def test_main_version_returns_zero() -> tuple[bool, str]:
"""version() 命令应返回 0"""
from main import version
buf = io.StringIO()
with redirect_stdout(buf):
rc = version()
if rc != 0:
return False, f"version() 返回 {rc}, want 0"
if not buf.getvalue().strip():
return False, "version() 输出为空"
return True, f"输出={buf.getvalue().strip()!r}"
def test_main_version_matches_skill_version() -> tuple[bool, str]:
"""version() 输出应与 SKILL_VERSION 对齐(避免硬编码漂移)"""
from main import version
from util.constants import SKILL_VERSION
buf = io.StringIO()
with redirect_stdout(buf):
version()
out = buf.getvalue().strip()
if SKILL_VERSION not in out:
return False, f"version() 输出 {out!r} 不含 SKILL_VERSION={SKILL_VERSION}"
return True, f"version() 含 {SKILL_VERSION}"
def test_main_health_returns_zero() -> tuple[bool, str]:
"""health() 应返回 0 且输出非空"""
from main import health
buf = io.StringIO()
with redirect_stdout(buf):
rc = health()
if rc != 0:
return False, f"health() 返回 {rc}, want 0"
if not buf.getvalue().strip():
return False, "health() 输出为空"
return True, "health() 返回 0 且有输出"
def test_main_dispatch_unknown_command_returns_1() -> tuple[bool, str]:
"""main(['not-a-real-command']) 应返回 1 并打印 usage"""
import main as m
buf = io.StringIO()
with redirect_stdout(buf):
rc = m.main(["not-a-real-command"])
if rc != 1:
return False, f"main(['not-a-real-command']) = {rc}, want 1"
if "Usage" not in buf.getvalue():
return False, f"应打印 usage,实际 {buf.getvalue()!r}"
return True, "未知命令 → 1 + Usage"
def test_main_dispatch_known_command() -> tuple[bool, str]:
"""main(['health']) 应路由到 health() 并返回 0"""
import main as m
buf = io.StringIO()
with redirect_stdout(buf):
rc = m.main(["health"])
if rc != 0:
return False, f"main(['health']) = {rc}, want 0"
if "health" not in buf.getvalue().lower():
return False, f"health() 输出应含 'health',实际 {buf.getvalue()!r}"
return True, "main(['health']) 路由正确"
def test_main_dispatch_no_args_returns_1() -> tuple[bool, str]:
"""main([]) 应返回 1(无命令)"""
import main as m
buf = io.StringIO()
with redirect_stdout(buf):
rc = m.main([])
if rc != 1:
return False, f"main([]) = {rc}, want 1"
return True, "无命令 → 1"
# ---------- 入口 ----------
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("=== verify-scholarship-fraud-sas 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/skill_md_brands_alignment", test_skill_md_brands_alignment)
_run("constants/required_keys_present", test_constants_required_keys_present)
_run("constants/candidate_statuses_complete", test_constants_candidate_statuses_complete)
_run("constants/review_conclusions_complete", test_constants_review_conclusions_complete)
_run("constants/risk_dimensions_complete", test_constants_risk_dimensions_complete)
_run("constants/brands_match_skill_md", test_constants_brands_match_skill_md)
_run("main/commands_registered", test_main_commands_registered)
_run("main/version_returns_zero", test_main_version_returns_zero)
_run("main/version_matches_skill_version", test_main_version_matches_skill_version)
_run("main/health_returns_zero", test_main_health_returns_zero)
_run("main/dispatch_unknown_command", test_main_dispatch_unknown_command_returns_1)
_run("main/dispatch_known_command", test_main_dispatch_known_command)
_run("main/dispatch_no_args", test_main_dispatch_no_args_returns_1)
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())