方向反转:此前 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 重写为单源开发流程。
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""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)
|
||
# 即使失败也关闭源 session(finally 块)
|
||
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)
|