Files
吴让宇 c5905a5b1e refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)
方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档,
sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。

迁移(来自 sap-cli,共 104 文件):
- tests/           692 例测试(15 个文件的内联 sys.path 改指 assets/)
- openspec/        SDD 规格与归档变更(42 文件)
- docs/            开发文档与 ADT 原理(含 dev/CLAUDE.md、AGENTS.md)
- .claude/         rules 副本 + settings.json(供 Claude Code)
- .github/ .hermes/ .pre-commit-config.yaml .editorconfig CLAUDE.md
- scripts/ 保持仅 setup.py(pack_skill.py 已随旧仓归档,不迁)

修复(迁移暴露的真实缺陷):
- assets/pyproject.toml 的 build-backend 写作 `setuptools.backends._legacy:_Backend`,
  该模块在 setuptools 中不存在 → `pip install -e` 从来装不上。改为 build_meta。
  实测:临时 venv 安装成功,sap-cli --help 正常列出 31 个命令
- pyproject readme 指向不存在的 assets/README.md(editable 安装会失败)→ 改内联文本
- pyproject urls 改指 sap-cli-skill

机制调整:
- .github/workflows/ci.yml 适配 assets/ 布局;顶部注明该工作流仅 GitHub 执行,
  本仓在 Gitee 不会自动跑
- pre-commit 增本地测试门禁(Gitee 上真正生效的那道)
- .gitignore 合并旧仓完整规则(保留 log/ 下 md 知识库入库,只忽略运行日志)
- 大文件上限 100KB→1MB(架构图 512KB)

守卫测试 tests/unit/test_repo_guards.py(10 → 18 例):
- SKILL.md 须记录 parser 全部 CLI 命令 / 铁律 1-5 须为真实小节标题 / 示例不得违反铁律 5
- references/ 规则齐备;.claude/rules 与 references 必须一致(实测抓到一次真实漂移)
- VERSION == sapcli.__version__ == README 版本
- 仓内不得再出现 pack_skill.py / skill-src(防废弃流程回潮)

698 tests OK;editable 安装与 CLI 入口经临时 venv 实测通过。
docs/RELEASING.md 重写为单源开发流程。
2026-09-11 00:40:15 +08:00

333 lines
12 KiB
Markdown

"""
sap-cli 端到端测试 — 使用真实项目目录 + mock SAP
验证完整批量同步流程:
init → dry-run → sync --all → refresh
运行方式:
python test/script/test_e2e.py
"""
from __future__ import annotations
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
from unittest.mock import MagicMock
# Windows UTF-8
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
E2E_PROJECT = os.path.join(PROJECT_ROOT, "test", "e2e_project")
class TestE2EInit(unittest.TestCase):
"""端到端: init 命令。"""
def setUp(self):
# 复制测试项目到临时目录,避免污染原始文件
self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_")
shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True)
self.client = self._mock_client()
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _mock_client(self):
client = MagicMock()
client.get_object_status.return_value = {
"exists": True, "status": "active", "corr_nr": "DEVK901362"
}
return client
def test_init_generates_manifest(self):
"""init 正确生成清单,包含所有扫描到的对象。"""
import argparse
from sapcli.commands import cmd_init
from sapcli.manifest import Manifest
args = argparse.Namespace(path=self.tmpdir)
cmd_init(args, self.client)
manifest = Manifest.load(self.tmpdir)
# 验证扫描到的对象
self.assertEqual(len(manifest.objects), 6)
self.assertIn("ZHELLO", manifest.objects)
self.assertIn("ZSAPILOT_OBJ", manifest.objects)
self.assertIn("ZCL_SAPILOT_CONSTANTS", manifest.objects)
self.assertIn("ZCL_SAPILOT_HTTP_HANDLER", manifest.objects)
self.assertIn("ZCL_SAPILOT_OP_ADMIN", manifest.objects)
self.assertIn("ZGROUP/Z_MY_FUNC", manifest.objects)
# 验证类型映射
self.assertEqual(manifest.get("ZHELLO").type, "report")
self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").type, "class")
self.assertEqual(manifest.get("ZGROUP/Z_MY_FUNC").type, "function")
# 验证相对路径
self.assertEqual(manifest.get("ZHELLO").file, "reports/zhello.abap")
self.assertEqual(manifest.get("ZGROUP/Z_MY_FUNC").file, "functions/zgroup/z_my_func.abap")
def test_init_manifest_json_structure(self):
"""init 生成的 manifest.json 结构正确。"""
import argparse
from sapcli.commands import cmd_init
args = argparse.Namespace(path=self.tmpdir)
cmd_init(args, self.client)
with open(os.path.join(self.tmpdir, "manifest.json"), "r", encoding="utf-8") as f:
data = json.load(f)
self.assertEqual(data["version"], 1)
self.assertIsNotNone(data["last_init"])
self.assertIsNone(data["last_refresh"])
self.assertEqual(len(data["objects"]), 6)
# 验证单个对象结构
zhello = data["objects"]["ZHELLO"]
self.assertEqual(zhello["type"], "report")
self.assertEqual(zhello["file"], "reports/zhello.abap")
self.assertEqual(zhello["system_status"], "active")
self.assertEqual(zhello["corr_nr"], "DEVK901362")
self.assertEqual(zhello["depends_on"], [])
self.assertIsNone(zhello["last_sync"])
self.assertEqual(zhello["last_sync_result"], "pending")
def test_init_mixed_statuses(self):
"""init 正确处理混合 SAP 状态(存在/不存在/未激活)。"""
import argparse
from sapcli.commands import cmd_init
from sapcli.manifest import Manifest
def mock_status(uri):
if "zhello" in uri:
return {"exists": True, "status": "active", "corr_nr": "DEVK901362"}
elif "zsapilot_obj" in uri:
return {"exists": True, "status": "inactive", "corr_nr": None}
else:
return {"exists": False, "status": "not_exists", "corr_nr": None}
self.client.get_object_status.side_effect = mock_status
args = argparse.Namespace(path=self.tmpdir)
cmd_init(args, self.client)
manifest = Manifest.load(self.tmpdir)
self.assertEqual(manifest.get("ZHELLO").system_status, "active")
self.assertEqual(manifest.get("ZHELLO").corr_nr, "DEVK901362")
self.assertEqual(manifest.get("ZSAPILOT_OBJ").system_status, "inactive")
self.assertIsNone(manifest.get("ZSAPILOT_OBJ").corr_nr)
self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").system_status, "not_exists")
class TestE2ESyncAll(unittest.TestCase):
"""端到端: sync --all 命令。"""
def setUp(self):
self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_")
shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True)
self.client = self._mock_client()
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _mock_client(self):
client = MagicMock()
client.object_exists.return_value = True
client.lock.return_value = ("handle", "DEVK901362")
client.unlock.return_value = True
client.set_source.return_value = True
client.syntax_check.return_value = (True, [])
client.activate.return_value = (True, [])
client.get_object_status.return_value = {
"exists": True, "status": "active", "corr_nr": "DEVK901362"
}
return client
def _create_manifest(self):
"""先执行 init 创建清单。"""
import argparse
from sapcli.commands import cmd_init
args = argparse.Namespace(path=self.tmpdir)
cmd_init(args, self.client)
def test_dry_run_no_side_effects(self):
"""dry-run 不修改清单状态。"""
import argparse
from sapcli.commands import cmd_sync_all
from sapcli.manifest import Manifest
self._create_manifest()
args = argparse.Namespace(path=self.tmpdir, dry_run=True, fail_fast=False)
cmd_sync_all(args, self.client)
manifest = Manifest.load(self.tmpdir)
# 所有对象应保持 pending 状态
for entry in manifest.objects.values():
self.assertEqual(entry.last_sync_result, "pending")
# activate 不应被调用
self.client.activate.assert_not_called()
def test_sync_all_updates_manifest(self):
"""sync --all 成功后所有对象标记为 success。"""
import argparse
from sapcli.commands import cmd_sync_all
from sapcli.manifest import Manifest
self._create_manifest()
args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False)
cmd_sync_all(args, self.client)
manifest = Manifest.load(self.tmpdir)
for name, entry in manifest.objects.items():
self.assertEqual(entry.system_status, "active", f"{name} 应为 active")
self.assertEqual(entry.last_sync_result, "success", f"{name} 应为 success")
self.assertIsNotNone(entry.last_sync, f"{name} 应有 last_sync")
def test_sync_all_respects_execution_order(self):
"""sync --all 按类型优先级执行。"""
import argparse
from sapcli.commands import cmd_sync_all
self._create_manifest()
args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False)
cmd_sync_all(args, self.client)
# 验证 activate 的调用顺序
calls = self.client.activate.call_args_list
names = [call[0][0] for call in calls] # 第一个位置参数是 name
# interface 应在 class 前,class 应在 report 前
class_idx = next((i for i, n in enumerate(names) if "ZCL_SAPILOT" in n), None)
report_idx = next((i for i, n in enumerate(names) if "ZHELLO" in n or "ZSAPILOT" in n), None)
if class_idx is not None and report_idx is not None:
self.assertLess(class_idx, report_idx, "class 应在 report 之前执行")
def test_sync_all_with_dependency_failure(self):
"""sync --all 某对象失败时依赖它的对象被跳过。"""
import argparse
from sapcli.commands import cmd_sync_all
from sapcli.manifest import Manifest, ManifestEntry, init_manifest
# 手动创建带依赖的清单
m = init_manifest(self.tmpdir)
m.upsert(ManifestEntry(
name="ZCL_SAPILOT_CONSTANTS", type="class",
file="classes/zcl_sapilot_constants.abap",
system_status="inactive", last_sync_result="pending",
))
m.upsert(ManifestEntry(
name="ZSAPILOT_OBJ", type="report",
file="reports/zsapilot_obj.abap",
system_status="inactive", last_sync_result="pending",
depends_on=["ZCL_SAPILOT_CONSTANTS"],
))
m.save()
# 让 class 的 activate 失败
def mock_activate(name, uri, corr_nr=None):
if "zcl_sapilot_constants" in name.lower():
return (False, [{"type": "E", "line": "1", "text": "test error", "href": ""}])
return (True, [])
self.client.activate.side_effect = mock_activate
args = argparse.Namespace(path=self.tmpdir, dry_run=False, fail_fast=False)
cmd_sync_all(args, self.client)
manifest = Manifest.load(self.tmpdir)
self.assertEqual(manifest.get("ZCL_SAPILOT_CONSTANTS").last_sync_result, "failed")
self.assertEqual(manifest.get("ZSAPILOT_OBJ").last_sync_result, "skipped")
class TestE2ERefresh(unittest.TestCase):
"""端到端: refresh 命令。"""
def setUp(self):
self.tmpdir = tempfile.mkdtemp(prefix="sapcli_e2e_")
shutil.copytree(E2E_PROJECT, self.tmpdir, dirs_exist_ok=True)
self.client = MagicMock()
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_refresh_preserves_sync_history(self):
"""refresh 只更新 system_status 和 corr_nr,不动 last_sync。"""
import argparse
from sapcli.commands import cmd_refresh
from sapcli.manifest import Manifest, ManifestEntry, init_manifest
# 创建带同步历史的清单
m = init_manifest(self.tmpdir)
m.upsert(ManifestEntry(
name="ZCL_SAPILOT_CONSTANTS", type="class",
file="classes/zcl_sapilot_constants.abap",
system_status="active", corr_nr="DEVK901362",
last_sync="2026-01-01T00:00:00", last_sync_result="success",
))
m.save()
# SAP 端状态变更
self.client.get_object_status.return_value = {
"exists": True, "status": "inactive", "corr_nr": "DEVK901363"
}
args = argparse.Namespace(path=self.tmpdir)
cmd_refresh(args, self.client)
manifest = Manifest.load(self.tmpdir)
entry = manifest.get("ZCL_SAPILOT_CONSTANTS")
# 状态已更新
self.assertEqual(entry.system_status, "inactive")
self.assertEqual(entry.corr_nr, "DEVK901363")
# 同步历史不变
self.assertEqual(entry.last_sync, "2026-01-01T00:00:00")
self.assertEqual(entry.last_sync_result, "success")
# ════════════════════════════════════════════════════════════════
# 运行
# ════════════════════════════════════════════════════════════════
if __name__ == "__main__":
print("=" * 60)
print(" sap-cli 端到端测试 — 真实项目目录 + mock SAP")
print("=" * 60)
print(f" 测试项目: {E2E_PROJECT}")
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for cls in [TestE2EInit, TestE2ESyncAll, TestE2ERefresh]:
suite.addTests(loader.loadTestsFromTestCase(cls))
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
print("\n" + "=" * 60)
if result.wasSuccessful():
print(f" ✓ 全部通过! ({result.testsRun} 个测试)")
else:
print(f" ✗ 失败: {len(result.failures)} 错误: {len(result.errors)}")
print("=" * 60)
sys.exit(0 if result.wasSuccessful() else 1)