Files
sap-cli-skill/tests/unit/test_repo_guards.py
T
吴让宇 c06e350f6d
CI / test (3.10) (push) Waiting to run
CI / test (3.11) (push) Waiting to run
CI / test (3.12) (push) Waiting to run
feat: bgRFC/FM 支撑能力 + ADT 后缀剥离与 CLAS 激活修复(B1 配套)
新增能力:
- remote-enable 子命令:设置函数模块处理类型为远程启用(bgRFC/RFC 执行体必需)。
  实现要点均按实机验证:PUT fmodule:processingType="rfc"(枚举值仅小写 rfc 有效,
  remoteEnabled/remote/RFC 等均 400);lockHandle 走 query 参数(放 header 报
  ParameterNotFound);PUT 后 GET 复核回显;DDIC 结构参数才可远程启用(类本地类型
  报"针对 RFC 不允许使用类或接口的类型")。
- create --type functiongroup 透传 --package / --corr_nr(此前 package 被静默写成 $TMP,
  导致函数组无法归目标包)。

修复:
- activate 请求体补重复引用:ADT CLAS 激活端点对 objectReferences 只有 1 个引用时
  返回 HTTP 200 + 空 body 且不执行激活;引用数 ≥2 才真正激活(同对象重复亦可)。
- scanner 剥离 ADT 文件后缀(xxx.clas.abap 解析错误)。

守卫测试:
- tests/unit/test_bgrfc_support.py(17 例):函数组 package 透传、processingType 助手、
  remote-enable 命令(含 lockHandle 走 query/小写 rfc/错误透传/复核失配判失败)、
  函数 URI 组装不得用组名冒充模块名。
- test_activate.py 增加激活请求体引用数守卫;test_repo_guards.py 增加后缀剥离守卫。

测试:tests/unit 676 + tests/test_sapcli.py 69 = 745 全绿
(PYTHONPATH=assets python -m unittest discover -s tests/unit -t tests)
2026-09-15 22:51:35 +08:00

254 lines
11 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.
"""仓库不变量守卫(唯一源)。
本仓 sap-cli-skill 自 2026-09-11 起是 sap-cli 工具与技能文档的**唯一来源**
(原 sap-cli 源码仓已归档)。本测试守住几条「静默失效」类的仓库不变量:
- `SKILL.md` 必须记录 parser 的**全部** CLI 命令(文档与代码不得脱节)
- 开发铁律 1-5 必须是真实小节标题(不能只靠正文交叉引用)
- 示例不得出现违反铁律 5 的 `--path ./src` 写法
- `references/` 三份规则齐备且含关键规则
- `VERSION` 与 `sapcli.__version__` 一致
历史背景:文档与代码曾双向脱节(SKILL.md 只写 9 个命令而 parser 有 31 个),
铁律 5 的目录规范也曾只写在文档里、工具却不支持。守卫把这类问题挡在提交前。
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import unittest
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
ASSETS = PROJECT_ROOT / "assets"
sys.path.insert(0, str(ASSETS))
SKILL_MD = PROJECT_ROOT / "SKILL.md"
REFERENCES = PROJECT_ROOT / "references"
VERSION_FILE = PROJECT_ROOT / "VERSION"
# 开发铁律必须在 SKILL.md 里
IRON_RULE_MARKERS = ["⛔ 开发铁律", "铁律 1", "铁律 2", "铁律 3", "铁律 4", "铁律 5"]
# 每份规则文件必须含的关键规则标记
RULE_CONTENT_MARKERS: dict[str, list[str]] = {
"sap-tool-constraints.md": ["规则 7", "规则 8"],
"abap-coding-rules.md": ["E04", "E05"],
"error-handling.md": [],
}
class TestSkillDocStaysInSync(unittest.TestCase):
"""SKILL.md 是唯一源文档,必须与真实 CLI 保持一致。"""
def _text(self) -> str:
return SKILL_MD.read_text(encoding="utf-8")
def test_skill_md_exists(self):
self.assertTrue(SKILL_MD.is_file(), f"缺少 SKILL.md: {SKILL_MD}")
def test_every_cli_command_is_documented(self):
"""parser 里的每个命令都必须在 SKILL.md 里有记录。"""
from sapcli.cli.parser import build_parser
parser = build_parser()
subs = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)]
self.assertTrue(subs, "解析器未找到子命令")
commands = sorted(subs[0].choices.keys())
text = self._text()
undocumented = [
cmd for cmd in commands
if not re.search(r"`" + re.escape(cmd) + r"(?=[ `/-])", text)
]
self.assertFalse(
undocumented,
f"这些 CLI 命令在 SKILL.md 里没有记录(文档与代码脱节): {undocumented}",
)
def test_iron_rules_are_real_headings(self):
"""铁律 1-5 必须是真实小节标题,不能只靠别处的交叉引用蒙过标记检查。
实测缺口:把「### 铁律 5」改成「### 铁律五」时,纯字符串标记检查仍
通过——因为正文多处出现「见铁律 5」。标题级断言才拦得住。
"""
text = self._text()
missing = [
n for n in range(1, 6)
if not re.search(rf"^#{{2,4}}\s*铁律\s*{n}\b", text, re.MULTILINE)
]
self.assertFalse(missing, f"SKILL.md 缺少这些铁律的小节标题: {missing}")
def test_iron_rule_5_documents_tmp_layer(self):
text = self._text()
self.assertIn("铁律 5", text)
self.assertIn("src/TMP", text, "铁律 5 必须写明本地开发包层固定为 TMP")
def test_no_path_violating_iron_rule_5(self):
"""示例不得出现 `--path ./src`(铁律 5 要求指到对象类型目录)。
仅放行「规则说明本身」的行——它们用反例讲铁律 5,会同时出现
「不合格」或「平铺」字样。
"""
violations = []
for line in self._text().splitlines():
if re.search(r"--path\s+\./src(?!/TMP)", line):
if "不合格" not in line and "平铺" not in line:
violations.append(line.strip())
self.assertFalse(
violations,
"SKILL.md 示例违反铁律 5--path 必须指到 src/TMP/<对象类型>:\n "
+ "\n ".join(violations),
)
class TestScannerSupportsAdtFilenames(unittest.TestCase):
"""扫描器必须支持 ADT/abapGit 风格文件名(`<name>.clas.abap`)。
背景:项目仓命名规范(如 ABAP_Integrated《命名规范》§4)要求 ADT 风格文件名,
而扫描器曾只剥 `.abap`,把 `zcl_demo.clas.abap` 解析成对象名 `ZCL_DEMO.CLAS`——
对象名带伪后缀,`sync/refresh/diff --all` 全部落空。目录规范只写在文档、
工具却不支持的同类问题,用守卫挡住。
"""
def test_every_type_with_adt_suffix_maps_to_known_type(self):
from sapcli.scanner import ADT_TYPE_SUFFIX, TYPE_DIR_LOOKUP
known = set(TYPE_DIR_LOOKUP.values())
unknown = [t for t in ADT_TYPE_SUFFIX if t not in known]
self.assertFalse(unknown, f"ADT 后缀表引用了未注册的对象类型: {unknown}")
def test_adt_suffix_stripped_for_all_registered_types(self):
from sapcli.scanner import ADT_TYPE_SUFFIX, _strip_adt_suffix
for obj_type, suffix in ADT_TYPE_SUFFIX.items():
name = f"zobj_{obj_type.lower()}"
self.assertEqual(
_strip_adt_suffix(f"{name}.{suffix}", obj_type), name,
f"{obj_type} 的 ADT 后缀 .{suffix} 未被剥离",
)
def test_plain_filename_unaffected(self):
from sapcli.scanner import ADT_TYPE_SUFFIX, _strip_adt_suffix
for obj_type, suffix in ADT_TYPE_SUFFIX.items():
name = f"zobj_{obj_type.lower()}"
self.assertEqual(_strip_adt_suffix(name, obj_type), name)
# 其他类型的后缀不得被误剥
self.assertEqual(_strip_adt_suffix(f"{name}.{suffix}", "unknown_type"), f"{name}.{suffix}")
class TestReferencesAreComplete(unittest.TestCase):
"""references/ 是规则的正本,必须齐备。"""
def test_reference_files_exist(self):
for name in RULE_CONTENT_MARKERS:
self.assertTrue(
(REFERENCES / name).is_file(),
f"缺少规则文件: references/{name}",
)
def test_rule_contents_keep_key_rules(self):
for name, markers in RULE_CONTENT_MARKERS.items():
path = REFERENCES / name
self.assertTrue(path.is_file(), f"缺少规则文件: {path}")
text = path.read_text(encoding="utf-8")
for marker in markers:
self.assertIn(marker, text, f"{name} 缺少关键规则「{marker}」")
class TestRepoLayout(unittest.TestCase):
"""唯一源仓库的关键结构必须存在(归档旧仓后这里就是唯一的家)。"""
def test_ci_workflow_present(self):
ci = PROJECT_ROOT / ".github" / "workflows" / "ci.yml"
self.assertTrue(ci.is_file(), f"缺少 CI 工作流: {ci}")
def test_ci_targets_assets_layout(self):
"""CI 必须按 assets/ 布局安装与统计覆盖率(曾因 pyproject 在 assets/ 下踩坑)。"""
text = (PROJECT_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
self.assertIn("./assets[dev]", text, "CI 未按 assets/ 布局安装依赖")
self.assertIn("assets/sapcli/*", text, "CI 覆盖率统计路径未指向 assets/sapcli")
def test_no_obsolete_pack_pipeline(self):
"""打包流程已废弃——存在即说明有回退或残留。"""
self.assertFalse((PROJECT_ROOT / "scripts" / "pack_skill.py").exists(),
"pack_skill.py 已废弃,不应出现在唯一源仓库")
self.assertFalse((PROJECT_ROOT / "skill-src").exists(),
"skill-src/ 已废弃(模板与 SKILL.md 会形成双源)")
class TestRuleCopiesInSync(unittest.TestCase):
"""references/ 是规则正本,.claude/rules/ 是供 Claude Code 读的副本。
两处内容必须一致——同一份规则存两份迟早漂移(本仓已因双份来源吃过亏)。
改规则时两处都要改,只改一处这里会红。
"""
def test_claude_rules_match_references(self):
claude_rules = PROJECT_ROOT / ".claude" / "rules"
if not claude_rules.is_dir():
self.skipTest("本机无 .claude/rules/(可选目录)")
shared = sorted(p.name for p in REFERENCES.glob("*.md"))
self.assertTrue(shared, "references/ 下没有规则文件")
for name in shared:
rc = claude_rules / name
if not rc.exists():
continue # 只比对两边都有的文件
self.assertEqual(
rc.read_text(encoding="utf-8"),
(REFERENCES / name).read_text(encoding="utf-8"),
f"规则副本漂移: .claude/rules/{name} 与 references/{name} 内容不一致",
)
def test_no_extra_claude_rule(self):
"""references/ 有的规则,.claude/rules/ 不应缺失(否则 Claude Code 读到旧规则集)。"""
claude_rules = PROJECT_ROOT / ".claude" / "rules"
if not claude_rules.is_dir():
self.skipTest("本机无 .claude/rules/")
missing = [
p.name for p in REFERENCES.glob("*.md")
if not (claude_rules / p.name).exists()
]
self.assertFalse(missing, f".claude/rules/ 缺少这些规则副本: {missing}")
class TestVersionConsistency(unittest.TestCase):
"""VERSION 文件与代码里的版本号必须一致(曾出现 2.3.0 vs 2.5.1 漂移)。"""
def test_version_file_matches_package(self):
import sapcli
file_version = VERSION_FILE.read_text(encoding="utf-8").strip()
self.assertEqual(
file_version, sapcli.__version__,
f"VERSION({file_version}) 与 sapcli.__version__({sapcli.__version__}) 不一致",
)
def test_version_file_is_parseable(self):
v = VERSION_FILE.read_text(encoding="utf-8").strip()
self.assertRegex(v, r"^\d+\.\d+(\.\d+)?$", f"VERSION 格式异常: {v!r}")
def test_readme_version_matches(self):
"""README 里写的版本号也要一致(README 是纯文本,最容易忘改)。"""
import sapcli
text = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8")
m = re.search(r"当前版本\*\*:\s*(\S+)", text)
self.assertIsNotNone(m, "README 找不到「当前版本」行")
self.assertEqual(m.group(1), sapcli.__version__,
f"README 版本({m.group(1)}) 与包版本({sapcli.__version__}) 不一致")
def test_pyproject_version_is_dynamic_from_package(self):
"""pyproject 的版本必须动态取自 sapcli.__version__,避免两处手改。"""
text = (ASSETS / "pyproject.toml").read_text(encoding="utf-8")
self.assertIn('version = {attr = "sapcli.__version__"}', text)
if __name__ == "__main__":
unittest.main()