Files
sap-cli-skill/tests/unit/test_clone.py
T
吴让宇 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

323 lines
12 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.
"""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)