Files
check-course-syllabus-aa/tests/run_tests.py

440 lines
18 KiB
Python
Raw 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 / python-docx),CI 与本地均能直接
`python tests/run_tests.py` 跑
- v1.0.1 仍为骨架版,业务实现细节待后续补全;本套件聚焦:
- util.constants 版本对齐(POLICY-RELEASE-001)
- util.score_util 纯函数(clamp / weighted_total / classify_audit_status /
parse_score_threshold / parse_ideology_keywords / count_keyword_hits)
- scripts/main.py CLI 占位命令(health / version / config-path / init-db /
audit-syllabus / batch-audit-syllabus / render-html / sync-norm-library)
- 退出码:0 = 全部通过,1 = 有失败
- -v 开关:详细输出每个用例的状态
release.ps1 在打 tag 前会自动跑 `python tests/run_tests.py -v`,任何一个
test_* 返回 (False, msg) 即 abort 发布。
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import 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_*,记录结果。"""
import traceback
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_threshold_defaults() -> tuple[bool, str]:
"""util.constants 中的阈值常量符合 SPEC §6 输入要求(默认 85 / 60 / 60 秒)"""
from util.constants import (
DEFAULT_AUDIT_TIMEOUT_SEC,
DEFAULT_MATRIX_MATCH_THRESHOLD,
DEFAULT_SCORE_MODIFY_THRESHOLD,
DEFAULT_SCORE_PASS_THRESHOLD,
DEFAULT_WEIGHT_FORMAT,
DEFAULT_WEIGHT_IDEOLOGY,
DEFAULT_WEIGHT_MATRIX,
)
if DEFAULT_SCORE_PASS_THRESHOLD != 85:
return False, f"DEFAULT_SCORE_PASS_THRESHOLD={DEFAULT_SCORE_PASS_THRESHOLD}, want 85"
if DEFAULT_SCORE_MODIFY_THRESHOLD != 60:
return False, f"DEFAULT_SCORE_MODIFY_THRESHOLD={DEFAULT_SCORE_MODIFY_THRESHOLD}, want 60"
if DEFAULT_AUDIT_TIMEOUT_SEC != 60:
return False, f"DEFAULT_AUDIT_TIMEOUT_SEC={DEFAULT_AUDIT_TIMEOUT_SEC}, want 60"
if DEFAULT_MATRIX_MATCH_THRESHOLD != 0.7:
return False, f"DEFAULT_MATRIX_MATCH_THRESHOLD={DEFAULT_MATRIX_MATCH_THRESHOLD}, want 0.7"
w_sum = DEFAULT_WEIGHT_FORMAT + DEFAULT_WEIGHT_MATRIX + DEFAULT_WEIGHT_IDEOLOGY
if abs(w_sum - 1.0) > 1e-9:
return False, f"三项权重和={w_sum}, want 1.0"
return True, "PASS=85 / MODIFY=60 / TIMEOUT=60 / MATRIX=0.7 / 权重和=1.0"
# ---------- util.score_util 纯函数 ----------
def test_clamp_score() -> tuple[bool, str]:
from util.score_util import clamp_score
cases = [
(50, 50.0),
(-10, 0.0),
(150, 100.0),
(0, 0.0),
(100, 100.0),
(99.5, 99.5),
("abc", 0.0), # 非法字符串 → 0
(None, 0.0), # None → 0
("85.5", 85.5), # 数字字符串
]
for inp, want in cases:
got = clamp_score(inp)
if got != want:
return False, f"clamp_score({inp!r}) = {got}, want {want}"
return True, "9 场景(正常 / 越界 / 非法 / None / 数字字符串)"
def test_weighted_total_default() -> tuple[bool, str]:
"""默认权重 (0.3 / 0.4 / 0.3):90 / 80 / 70 → 0.3*90+0.4*80+0.3*70 = 80"""
from util.score_util import weighted_total
got = weighted_total(90, 80, 70)
want = 80.0
if got != want:
return False, f"weighted_total(90,80,70) = {got}, want {want}"
return True, "默认权重 (0.3/0.4/0.3) 加权求和 = 80.0"
def test_weighted_total_overrides() -> tuple[bool, str]:
"""自定义权重 (0.5 / 0.3 / 0.2):100 / 50 / 50 → 50+15+10 = 75"""
from util.score_util import weighted_total
got = weighted_total(100, 50, 50, weights={"format": 0.5, "matrix": 0.3, "ideology": 0.2})
if got != 75.0:
return False, f"weighted_total 自定义权重 = {got}, want 75.0"
return True, "自定义权重求和 = 75.0"
def test_weighted_total_clamps_overshoot() -> tuple[bool, str]:
"""超界输入会被裁剪:999 / 999 / 999 → 100*1.0 = 100"""
from util.score_util import weighted_total
got = weighted_total(999, 999, 999)
if got != 100.0:
return False, f"weighted_total 超界 = {got}, want 100.0"
return True, "三项均超 100 → 裁剪到 100"
def test_classify_audit_status_pass() -> tuple[bool, str]:
"""score >= pass_thr → pass"""
from util.score_util import classify_audit_status
# 边界 85 == 85 → pass
if classify_audit_status(85, 85, 60) != "pass":
return False, "85/85/60 应为 pass"
if classify_audit_status(100, 85, 60) != "pass":
return False, "100 应为 pass"
return True, "≥ pass_thr → pass(边界 + 远超)"
def test_classify_audit_status_modify() -> tuple[bool, str]:
"""modify_thr ≤ score < pass_thr → modify"""
from util.score_util import classify_audit_status
if classify_audit_status(60, 85, 60) != "modify":
return False, "60(modify 边界)应为 modify"
if classify_audit_status(80, 85, 60) != "modify":
return False, "80 应为 modify"
if classify_audit_status(84.99, 85, 60) != "modify":
return False, "84.99 应为 modify"
return True, "modify_thr ≤ score < pass_thr → modify(边界 + 中段)"
def test_classify_audit_status_rewrite() -> tuple[bool, str]:
"""score < modify_thr → rewrite"""
from util.score_util import classify_audit_status
if classify_audit_status(0, 85, 60) != "rewrite":
return False, "0 应为 rewrite"
if classify_audit_status(59.99, 85, 60) != "rewrite":
return False, "59.99 应为 rewrite"
if classify_audit_status(-10, 85, 60) != "rewrite":
return False, "负分应为 rewrite"
return True, "< modify_thr → rewrite(边界 / 负数 / 0)"
def test_classify_audit_status_swapped_thresholds() -> tuple[bool, str]:
"""pass_thr < modify_thr 时防御性交换(保证函数依然正确)"""
from util.score_util import classify_audit_status
# 传入颠倒阈值:pass=60, modify=85 → 应被视作 pass=85, modify=60
# 70 ≥ 60 但 < 85 → modify
if classify_audit_status(70, 60, 85) != "modify":
return False, "颠倒阈值 70 应为 modify(防御性交换后)"
if classify_audit_status(90, 60, 85) != "pass":
return False, "颠倒阈值 90 应为 pass"
return True, "pass_thr < modify_thr 防御性交换后正确"
def test_parse_score_threshold() -> tuple[bool, str]:
from util.score_util import parse_score_threshold
cases = [
("85", 85.0),
("60", 60.0),
(" 75 ", 75.0), # 带空白
("", 85.0), # 空 → default
(None, 85.0), # None → default
("abc", 85.0), # 非法 → default
("150", 85.0), # 超界 → default
("-5", 85.0), # 负数 → default
]
for inp, want in cases:
got = parse_score_threshold(inp, default=85.0)
if got != want:
return False, f"parse_score_threshold({inp!r}) = {got}, want {want}"
return True, "8 场景(合法 / 空白 / 空 / None / 非法 / 超界 / 负数)"
def test_parse_ideology_keywords_default() -> tuple[bool, str]:
"""空 / None → 默认关键词列表(与 .env.example 一致:家国情怀 / 科学伦理 / 工匠精神 / 社会责任)"""
from util.score_util import parse_ideology_keywords
default = ["家国情怀", "科学伦理", "工匠精神", "社会责任"]
got_empty = parse_ideology_keywords("", default)
got_none = parse_ideology_keywords(None, default)
if got_empty != default:
return False, f"空字符串应回退 default,实测 {got_empty}"
if got_none != default:
return False, f"None 应回退 default,实测 {got_none}"
return True, "空 / None → 默认关键词"
def test_parse_ideology_keywords_json() -> tuple[bool, str]:
"""JSON 数组字符串正常解析"""
from util.score_util import parse_ideology_keywords
default = ["家国情怀"]
s = '["家国情怀", "科学伦理", "工匠精神"]'
got = parse_ideology_keywords(s, default)
if got != ["家国情怀", "科学伦理", "工匠精神"]:
return False, f"JSON 解析结果 {got}"
return True, 'JSON 数组 → ["家国情怀","科学伦理","工匠精神"]'
def test_parse_ideology_keywords_invalid() -> tuple[bool, str]:
"""非法 JSON / 非 list / 全空元素 → 回退 default"""
from util.score_util import parse_ideology_keywords
default = ["家国情怀", "科学伦理"]
cases = [
"garbage", # 非法 JSON
'{"a":1}', # 不是 list
"[1, 2, 3]", # 元素不是 str
"[null, '', ' ']", # 全空 str
'[" ", "\t"]', # 仅空白
]
for s in cases:
got = parse_ideology_keywords(s, default)
if got != default:
return False, f"parse_ideology_keywords({s!r}) = {got}, want default"
return True, "5 场景(非法 JSON / 非 list / 非 str / 全空)→ default"
def test_count_keyword_hits_basic() -> tuple[bool, str]:
"""统计思政关键词在文本中的命中次数"""
from util.score_util import count_keyword_hits
text = "本课程培养学生家国情怀,强调工匠精神与社会责任"
keywords = ["家国情怀", "科学伦理", "工匠精神", "社会责任"]
got = count_keyword_hits(text, keywords)
want = {"家国情怀": 1, "工匠精神": 1, "社会责任": 1}
if got != want:
return False, f"count_keyword_hits = {got}, want {want}"
return True, "3 命中(家国情怀/工匠精神/社会责任),科学伦理 0"
def test_count_keyword_hits_repeats() -> tuple[bool, str]:
"""同一关键词多次出现应累计"""
from util.score_util import count_keyword_hits
text = "家国情怀 家国情怀 家国情怀。工匠精神。"
keywords = ["家国情怀", "工匠精神"]
got = count_keyword_hits(text, keywords)
if got.get("家国情怀") != 3:
return False, f"家国情怀命中={got.get('家国情怀')}, want 3"
if got.get("工匠精神") != 1:
return False, f"工匠精神命中={got.get('工匠精神')}, want 1"
return True, "多次出现累计(3 / 1)"
def test_count_keyword_hits_edge_cases() -> tuple[bool, str]:
"""边界:空文本 / 空关键词列表 / 非 str 输入 → 返回空 dict,不报错"""
from util.score_util import count_keyword_hits
if count_keyword_hits("", ["家国情怀"]) != {}:
return False, "空文本应返回 {}"
if count_keyword_hits("任何文本", []) != {}:
return False, "空关键词应返回 {}"
if count_keyword_hits(None, ["家国情怀"]) != {}:
return False, "非 str 文本应返回 {}"
if count_keyword_hits("家国情怀", [None, "", "家国情怀"]) != {"家国情怀": 1}:
return False, "关键词中含 None / 空串应被跳过"
return True, "空文本 / 空关键词 / 非 str / 关键词含 None — 全部安全"
# ---------- scripts.main CLI 占位命令 ----------
def test_cli_command_set() -> tuple[bool, str]:
"""main.py COMMANDS 应包含 SPEC §0.1 的全部入口"""
import importlib
main_mod = importlib.import_module("main")
COMMANDS = getattr(main_mod, "COMMANDS", {})
expected = {
"health", "version", "config-path", "init-db",
"audit-syllabus", "batch-audit-syllabus",
"render-html", "sync-norm-library",
}
missing = expected - set(COMMANDS.keys())
if missing:
return False, f"COMMANDS 缺入口:{sorted(missing)}"
return True, f"8 入口齐全({len(expected)}/{len(COMMANDS)})"
def test_cli_health_returns_zero() -> tuple[bool, str]:
"""`python scripts/main.py health` 应返回退出码 0"""
import subprocess
proc = subprocess.run(
[sys.executable, str(_SCRIPTS_DIR / "main.py"), "health"],
capture_output=True, text=True, timeout=15,
)
if proc.returncode != 0:
return False, (
f"exit={proc.returncode} stderr={proc.stderr.strip()[:200]} "
f"stdout={proc.stdout.strip()[:200]}"
)
return True, "exit=0"
def test_cli_version_prints_skill_version() -> tuple[bool, str]:
"""`python scripts/main.py version` 应在 stdout 输出 SKILL_VERSION"""
import subprocess
from util.constants import SKILL_VERSION
proc = subprocess.run(
[sys.executable, str(_SCRIPTS_DIR / "main.py"), "version"],
capture_output=True, text=True, timeout=15,
)
out = proc.stdout + proc.stderr
if proc.returncode != 0:
return False, f"exit={proc.returncode} out={out[:200]}"
if SKILL_VERSION not in out:
return False, f"stdout 未含 SKILL_VERSION={SKILL_VERSION},实测={out.strip()[:200]}"
return True, f"stdout 含 SKILL_VERSION={SKILL_VERSION}"
def test_cli_unknown_command_returns_nonzero() -> tuple[bool, str]:
"""未知命令应返回非 0 退出码 + 列出可用命令"""
import subprocess
proc = subprocess.run(
[sys.executable, str(_SCRIPTS_DIR / "main.py"), "no-such-cmd"],
capture_output=True, text=True, timeout=15,
)
if proc.returncode == 0:
return False, "未知命令不应返回 0"
out = proc.stdout + proc.stderr
if "health" not in out and "audit-syllabus" not in out:
return False, f"未知命令应打印可用命令列表,实测={out[:200]}"
return True, "未知命令 → 非 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("=== check-course-syllabus-aa 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/threshold_defaults", test_constants_threshold_defaults)
_run("util/clamp_score", test_clamp_score)
_run("util/weighted_total_default", test_weighted_total_default)
_run("util/weighted_total_overrides", test_weighted_total_overrides)
_run("util/weighted_total_clamps_overshoot", test_weighted_total_clamps_overshoot)
_run("util/classify_audit_status_pass", test_classify_audit_status_pass)
_run("util/classify_audit_status_modify", test_classify_audit_status_modify)
_run("util/classify_audit_status_rewrite", test_classify_audit_status_rewrite)
_run("util/classify_audit_status_swapped", test_classify_audit_status_swapped_thresholds)
_run("util/parse_score_threshold", test_parse_score_threshold)
_run("util/parse_ideology_keywords_default", test_parse_ideology_keywords_default)
_run("util/parse_ideology_keywords_json", test_parse_ideology_keywords_json)
_run("util/parse_ideology_keywords_invalid", test_parse_ideology_keywords_invalid)
_run("util/count_keyword_hits_basic", test_count_keyword_hits_basic)
_run("util/count_keyword_hits_repeats", test_count_keyword_hits_repeats)
_run("util/count_keyword_hits_edge_cases", test_count_keyword_hits_edge_cases)
_run("cli/command_set", test_cli_command_set)
_run("cli/health_returns_zero", test_cli_health_returns_zero)
_run("cli/version_prints_skill_version", test_cli_version_prints_skill_version)
_run("cli/unknown_command_nonzero", test_cli_unknown_command_returns_nonzero)
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())