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 重写为单源开发流程。
This commit is contained in:
吴让宇
2026-09-11 00:40:15 +08:00
parent e786742bcb
commit c5905a5b1e
104 changed files with 20744 additions and 10 deletions
+322
View File
@@ -0,0 +1,322 @@
"""commands/clone.py 单元测试 — cmd_clone + _make_profile_client。
clone 命令从一个系统下载对象,上传到另一个系统。client 参数被忽略;
源/目标客户端分别按 --from / --to profile 构建。
运行: python tests/unit/test_clone.py
"""
from __future__ import annotations
import argparse
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets"))
def _args(**kwargs):
defaults = {
"name": "ZTEST",
"type": "report",
"from_profile": "DEV",
"to_profile": "QA",
"corr_nr": None,
"config": None,
"verify_ssl": False,
}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
# ═══════════════════════════════════════════
# _make_profile_client
# ═══════════════════════════════════════════
class TestMakeProfileClient(unittest.TestCase):
"""_make_profile_client 按 profile 加载配置并建立已登录的 ADT 客户端。"""
@patch("sapcli.commands.clone.ADTClient")
@patch("sapcli.commands.clone.load_config")
def test_builds_and_logs_in(self, mock_load, mock_client_cls):
from sapcli.commands.clone import _make_profile_client
sap_cfg = MagicMock()
sap_cfg.host = "h"
sap_cfg.client = "100"
sap_cfg.user = "u"
sap_cfg.password = "p"
mock_load.return_value = (sap_cfg, "DEV")
inner = MagicMock()
mock_client_cls.return_value = inner
result = _make_profile_client("/path.ini", "DEV", verify_ssl=True)
mock_load.assert_called_once_with("/path.ini", profile="DEV")
mock_client_cls.assert_called_once_with(
"h", "100", "u", "p", verify_ssl=True,
)
inner.login.assert_called_once()
self.assertIs(result, inner)
@patch("sapcli.commands.clone.ADTClient")
@patch("sapcli.commands.clone.load_config")
def test_default_verify_ssl_false(self, mock_load, mock_client_cls):
from sapcli.commands.clone import _make_profile_client
mock_load.return_value = (MagicMock(), "QA")
mock_client_cls.return_value = MagicMock()
_make_profile_client(None, "QA")
# 未传 verify_ssl 时默认 False
self.assertEqual(mock_client_cls.call_args[1].get("verify_ssl"), False)
# ═══════════════════════════════════════════
# cmd_clone
# ═══════════════════════════════════════════
class TestCmdClone(unittest.TestCase):
"""cmd_clone 跨系统克隆对象。"""
def test_no_source_type_raises_invalid_name(self):
"""functiongroup 无源码 → InvalidNameError。"""
from sapcli.commands.clone import cmd_clone
from sapcli.exceptions import InvalidNameError
with self.assertRaises(InvalidNameError):
cmd_clone(_args(type="functiongroup", name="ZFG"), MagicMock())
@patch("sapcli.commands.clone._make_profile_client")
def test_source_connection_failure_raises_config_error(self, mock_make):
"""源系统连接失败 → ConfigError。"""
from sapcli.commands.clone import cmd_clone
from sapcli.exceptions import ConfigError
mock_make.side_effect = ConnectionError("host unreachable")
with patch("builtins.print"):
with self.assertRaises(ConfigError) as ctx:
cmd_clone(_args(), None)
self.assertIn("源系统", str(ctx.exception))
@patch("sapcli.commands.clone._make_profile_client")
def test_source_object_not_found_raises(self, mock_make):
"""源对象不存在 → ObjectNotFoundError,且关闭源 session。"""
from sapcli.commands.clone import cmd_clone
from sapcli.exceptions import ObjectNotFoundError
src_client = MagicMock()
src_client.object_exists.return_value = False
mock_make.return_value = src_client
with patch("builtins.print"):
with self.assertRaises(ObjectNotFoundError):
cmd_clone(_args(), None)
# 即使失败也关闭源 sessionfinally 块)
src_client.session.close.assert_called_once()
@patch("sapcli.commands.clone._sync_single")
@patch("sapcli.commands.clone._make_profile_client")
def test_success_target_exists_normalizes_source(self, mock_make, mock_sync):
"""目标对象已存在 → 同步成功;源码 \r\n 归一化为 \n 并写入临时文件。"""
from sapcli.commands.clone import cmd_clone
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest.\r\nWRITE 'hi'.\r\n"
tgt_client = MagicMock()
tgt_client.object_exists.return_value = True
mock_make.side_effect = [src_client, tgt_client]
captured = {}
def fake_sync(name, obj_type, path, client, corr_nr=None):
with open(path, "r", encoding="utf-8") as f:
captured["content"] = f.read()
captured["client"] = client
captured["corr_nr"] = corr_nr
return (True, None, corr_nr or "DEVK001")
mock_sync.side_effect = fake_sync
with patch("builtins.print"):
cmd_clone(_args(corr_nr="DEVK901362"), None)
# 源码换行归一化
self.assertEqual(captured["content"], "REPORT ztest.\nWRITE 'hi'.\n")
# _sync_single 收到的是目标客户端
self.assertIs(captured["client"], tgt_client)
self.assertEqual(captured["corr_nr"], "DEVK901362")
# 两个 session 都关闭
src_client.session.close.assert_called_once()
tgt_client.session.close.assert_called_once()
@patch("sapcli.commands.clone._sync_single")
@patch("sapcli.commands.clone._make_profile_client")
def test_success_target_not_exists(self, mock_make, mock_sync):
"""目标对象不存在时打印「将创建并同步」并继续。"""
from sapcli.commands.clone import cmd_clone
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest."
tgt_client = MagicMock()
tgt_client.object_exists.return_value = False
mock_make.side_effect = [src_client, tgt_client]
mock_sync.return_value = (True, None, None)
with patch("builtins.print") as mock_print:
cmd_clone(_args(), None)
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
self.assertIn("将创建并同步", printed)
@patch("sapcli.commands.clone._sync_single")
@patch("sapcli.commands.clone._make_profile_client")
def test_sync_failure_raises_sapcli_error(self, mock_make, mock_sync):
"""_sync_single 返回失败 → SapCliError。"""
from sapcli.commands.clone import cmd_clone
from sapcli.exceptions import SapCliError
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest."
tgt_client = MagicMock()
tgt_client.object_exists.return_value = True
mock_make.side_effect = [src_client, tgt_client]
mock_sync.return_value = (False, "激活失败: 行3", None)
with patch("builtins.print"):
with self.assertRaises(SapCliError) as ctx:
cmd_clone(_args(), None)
self.assertIn("激活失败", str(ctx.exception))
tgt_client.session.close.assert_called_once()
@patch("sapcli.commands.clone._sync_single")
@patch("sapcli.commands.clone._make_profile_client")
def test_sync_failure_with_empty_error_uses_default(self, mock_make, mock_sync):
"""_sync_single 返回失败但 error_msg 为空 → 使用默认消息。"""
from sapcli.commands.clone import cmd_clone
from sapcli.exceptions import SapCliError
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest."
tgt_client = MagicMock()
tgt_client.object_exists.return_value = True
mock_make.side_effect = [src_client, tgt_client]
mock_sync.return_value = (False, "", None)
with patch("builtins.print"):
with self.assertRaises(SapCliError) as ctx:
cmd_clone(_args(name="ZMYCLS", type="class"), None)
self.assertIn("ZMYCLS", str(ctx.exception))
@patch("sapcli.commands.clone._make_profile_client")
def test_target_connection_failure_raises_config_error(self, mock_make):
"""目标系统连接失败 → ConfigError(源已成功连接并下载)。"""
from sapcli.commands.clone import cmd_clone
from sapcli.exceptions import ConfigError
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest."
mock_make.side_effect = [src_client, ConnectionError("target down")]
with patch("builtins.print"):
with self.assertRaises(ConfigError) as ctx:
cmd_clone(_args(), None)
self.assertIn("目标系统", str(ctx.exception))
# 源 session 已关闭
src_client.session.close.assert_called_once()
@patch("sapcli.commands.clone._sync_single")
@patch("sapcli.commands.clone._make_profile_client")
def test_no_corr_nr_omits_transport_line(self, mock_make, mock_sync):
"""未指定 corr_nr 时不打印「目标传输请求」行。"""
from sapcli.commands.clone import cmd_clone
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest."
tgt_client = MagicMock()
tgt_client.object_exists.return_value = True
mock_make.side_effect = [src_client, tgt_client]
mock_sync.return_value = (True, None, None)
with patch("builtins.print") as mock_print:
cmd_clone(_args(corr_nr=None), None)
# 同步成功;actual_corr 为 None 时不打印目标传输请求
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
self.assertIn("克隆完成", printed)
@patch("sapcli.commands.clone._sync_single")
@patch("sapcli.commands.clone._make_profile_client")
def test_temp_file_cleaned_up(self, mock_make, mock_sync):
"""同步成功后临时文件被删除。"""
from sapcli.commands.clone import cmd_clone
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest."
tgt_client = MagicMock()
tgt_client.object_exists.return_value = True
mock_make.side_effect = [src_client, tgt_client]
captured = {}
def fake_sync(name, obj_type, path, client, corr_nr=None):
captured["path"] = path
return (True, None, "DEVK001")
mock_sync.side_effect = fake_sync
with patch("builtins.print"):
cmd_clone(_args(corr_nr="DEVK001"), None)
# 同步返回后,临时文件应已删除
self.assertFalse(os.path.isfile(captured["path"]))
@patch("sapcli.commands.clone._make_profile_client")
@patch("sapcli.commands.clone._sync_single")
@patch("sapcli.commands.clone.os.unlink", side_effect=OSError("busy"))
def test_temp_file_unlink_error_swallowed(self, mock_unlink, mock_sync, mock_make):
"""os.unlink 抛 OSError 时被吞掉,不影响主流程。"""
from sapcli.commands.clone import cmd_clone
src_client = MagicMock()
src_client.object_exists.return_value = True
src_client.get_source.return_value = "REPORT ztest."
tgt_client = MagicMock()
tgt_client.object_exists.return_value = True
mock_make.side_effect = [src_client, tgt_client]
mock_sync.return_value = (True, None, "DEVK001")
# 不应抛异常(OSError 在 finally 中被吞)
with patch("builtins.print"):
cmd_clone(_args(corr_nr="DEVK001"), None)
mock_unlink.assert_called_once()
if __name__ == "__main__":
unittest.main(verbosity=2)