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:
@@ -0,0 +1,169 @@
|
||||
"""Tests for sapcli.commands.activate — activate command."""
|
||||
import argparse
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sapcli.commands.activate import cmd_activate
|
||||
|
||||
|
||||
def _make_client():
|
||||
"""创建完整 mock 的 ADTClient。"""
|
||||
client = MagicMock()
|
||||
client.host = "https://sap.example.com"
|
||||
client.sap_client = "100"
|
||||
client.csrf_token = "test-csrf-token"
|
||||
client.user = "TESTUSER"
|
||||
client.session = MagicMock()
|
||||
client._headers.return_value = {
|
||||
"content-type": "application/xml",
|
||||
"x-csrf-token": "test-csrf-token",
|
||||
}
|
||||
return client
|
||||
|
||||
|
||||
class TestCmdActivateSingle(unittest.TestCase):
|
||||
"""单对象激活测试。"""
|
||||
|
||||
def test_activate_success(self):
|
||||
"""激活成功,无消息。"""
|
||||
client = _make_client()
|
||||
client.activate.return_value = (True, [])
|
||||
args = argparse.Namespace(
|
||||
name="ZTEST", type="report",
|
||||
names=None, types=None, corr_nr=None, config=None,
|
||||
)
|
||||
cmd_activate(args, client)
|
||||
client.activate.assert_called_once_with(
|
||||
"ZTEST", "/sap/bc/adt/programs/programs/ztest", None,
|
||||
)
|
||||
|
||||
def test_activate_success_with_corr_nr(self):
|
||||
"""带传输请求号激活。"""
|
||||
client = _make_client()
|
||||
client.activate.return_value = (True, [])
|
||||
args = argparse.Namespace(
|
||||
name="ZTEST", type="class",
|
||||
names=None, types=None, corr_nr="DEVK901362", config=None,
|
||||
)
|
||||
cmd_activate(args, client)
|
||||
client.activate.assert_called_once_with(
|
||||
"ZTEST", "/sap/bc/adt/oo/classes/ztest", "DEVK901362",
|
||||
)
|
||||
|
||||
def test_activate_with_errors(self):
|
||||
"""激活失败,返回错误消息。"""
|
||||
client = _make_client()
|
||||
client.activate.return_value = (
|
||||
False,
|
||||
[{"type": "E", "line": "10", "text": "Syntax error", "href": ""}],
|
||||
)
|
||||
args = argparse.Namespace(
|
||||
name="ZMY_CLASS", type="class",
|
||||
names=None, types=None, corr_nr=None, config=None,
|
||||
)
|
||||
cmd_activate(args, client)
|
||||
client.activate.assert_called_once()
|
||||
|
||||
def test_activate_with_warnings(self):
|
||||
"""激活成功但有警告。"""
|
||||
client = _make_client()
|
||||
client.activate.return_value = (
|
||||
True,
|
||||
[{"type": "W", "line": "5", "text": "Unused variable", "href": ""}],
|
||||
)
|
||||
args = argparse.Namespace(
|
||||
name="ZTEST", type="report",
|
||||
names=None, types=None, corr_nr=None, config=None,
|
||||
)
|
||||
cmd_activate(args, client) # 不抛异常
|
||||
|
||||
def test_activate_exception(self):
|
||||
"""activate 抛出异常时优雅处理。"""
|
||||
client = _make_client()
|
||||
client.activate.side_effect = Exception("HTTP 500")
|
||||
args = argparse.Namespace(
|
||||
name="ZTEST", type="report",
|
||||
names=None, types=None, corr_nr=None, config=None,
|
||||
)
|
||||
cmd_activate(args, client) # 不抛异常,打印错误
|
||||
|
||||
|
||||
class TestCmdActivateBatch(unittest.TestCase):
|
||||
"""批量激活测试。"""
|
||||
|
||||
def test_batch_activate_all_success(self):
|
||||
"""批量激活全部成功。"""
|
||||
client = _make_client()
|
||||
client.activate.return_value = (True, [])
|
||||
args = argparse.Namespace(
|
||||
name=None, type=None,
|
||||
names="ZCLS1,ZCLS2,ZCLS3",
|
||||
types="class,class,class",
|
||||
corr_nr=None, config=None,
|
||||
)
|
||||
cmd_activate(args, client)
|
||||
self.assertEqual(client.activate.call_count, 3)
|
||||
|
||||
def test_batch_activate_with_corr_nr(self):
|
||||
"""批量激活带传输请求号。"""
|
||||
client = _make_client()
|
||||
client.activate.return_value = (True, [])
|
||||
args = argparse.Namespace(
|
||||
name=None, type=None,
|
||||
names="ZCLS1,ZREP1",
|
||||
types="class,report",
|
||||
corr_nr="DEVK901368", config=None,
|
||||
)
|
||||
cmd_activate(args, client)
|
||||
calls = client.activate.call_args_list
|
||||
self.assertEqual(len(calls), 2)
|
||||
# 验证都传了 corr_nr
|
||||
for call in calls:
|
||||
self.assertEqual(call[0][2], "DEVK901368")
|
||||
|
||||
def test_batch_activate_mismatch_count(self):
|
||||
"""names 和 types 数量不匹配时报错。"""
|
||||
client = _make_client()
|
||||
args = argparse.Namespace(
|
||||
name=None, type=None,
|
||||
names="ZCLS1,ZCLS2",
|
||||
types="class",
|
||||
corr_nr=None, config=None,
|
||||
)
|
||||
cmd_activate(args, client)
|
||||
client.activate.assert_not_called()
|
||||
|
||||
def test_batch_activate_partial_failure(self):
|
||||
"""批量激活部分失败。"""
|
||||
client = _make_client()
|
||||
client.activate.side_effect = [
|
||||
(True, []),
|
||||
(False, [{"type": "E", "line": "1", "text": "Error", "href": ""}]),
|
||||
]
|
||||
args = argparse.Namespace(
|
||||
name=None, type=None,
|
||||
names="ZCLS1,ZCLS2",
|
||||
types="class,class",
|
||||
corr_nr=None, config=None,
|
||||
)
|
||||
cmd_activate(args, client)
|
||||
self.assertEqual(client.activate.call_count, 2)
|
||||
|
||||
|
||||
class TestCmdActivateNoneMode(unittest.TestCase):
|
||||
"""既没有 --name 也没有 --names 的情况。"""
|
||||
|
||||
def test_no_name_no_names(self):
|
||||
"""没有提供 name 或 names 时,访问 args.name 应为 None,触发 AttributeError。"""
|
||||
client = _make_client()
|
||||
args = argparse.Namespace(
|
||||
name=None, type=None,
|
||||
names=None, types=None, corr_nr=None, config=None,
|
||||
)
|
||||
# name=None 传给 parse_object_name 会失败
|
||||
with self.assertRaises((TypeError, AttributeError, ValueError)):
|
||||
cmd_activate(args, client)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,327 @@
|
||||
"""app.py main() 和 config_cmd.py 单元测试。
|
||||
|
||||
运行: python tests/unit/test_app_config.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets"))
|
||||
|
||||
from sapcli.cli.app import main
|
||||
from sapcli.commands.config_cmd import cmd_config
|
||||
from sapcli.exceptions import ConfigError, SapCliError
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 辅助:构造一个假的 SAPConfig
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
def _fake_config():
|
||||
"""返回一个 (SAPConfig, loaded_from) 元组。"""
|
||||
from sapcli.config import SAPConfig
|
||||
cfg = SAPConfig(host="sap.example.com", client="100", user="TESTUSER", password="secret123")
|
||||
return cfg, "/tmp/fake_config.ini"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# app.py main() 测试
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
class TestMainNoCommand(unittest.TestCase):
|
||||
"""无子命令时 print_help + exit(1)。"""
|
||||
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_no_command(self, mock_enc, mock_log, mock_ssl):
|
||||
with patch("sys.argv", ["sap-cli"]):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
|
||||
class TestMainConfigShow(unittest.TestCase):
|
||||
"""config 命令 → cmd_config 被调用。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_config")
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_config_show(self, mock_enc, mock_log, mock_ssl, mock_cmd_config):
|
||||
with patch("sys.argv", ["sap-cli", "config", "show"]):
|
||||
main()
|
||||
mock_cmd_config.assert_called_once()
|
||||
# 第一个位置参数是 args (Namespace)
|
||||
args_passed = mock_cmd_config.call_args[0][0]
|
||||
self.assertEqual(args_passed.command, "config")
|
||||
|
||||
|
||||
class TestMainAuthLogin(unittest.TestCase):
|
||||
"""auth login → cmd_auth_login 被调用。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_auth_login")
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_auth_login(self, mock_enc, mock_log, mock_ssl, mock_login):
|
||||
with patch("sys.argv", ["sap-cli", "auth", "login"]):
|
||||
main()
|
||||
mock_login.assert_called_once()
|
||||
|
||||
|
||||
class TestMainAuthLogout(unittest.TestCase):
|
||||
"""auth logout → cmd_auth_logout 被调用。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_auth_logout")
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_auth_logout(self, mock_enc, mock_log, mock_ssl, mock_logout):
|
||||
with patch("sys.argv", ["sap-cli", "auth", "logout"]):
|
||||
main()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
|
||||
class TestMainAuthStatus(unittest.TestCase):
|
||||
"""auth status (无子命令) → cmd_auth_status 被调用。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_auth_status")
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_auth_status(self, mock_enc, mock_log, mock_ssl, mock_status):
|
||||
with patch("sys.argv", ["sap-cli", "auth", "status"]):
|
||||
main()
|
||||
mock_status.assert_called_once()
|
||||
|
||||
|
||||
class TestMainConfigError(unittest.TestCase):
|
||||
"""load_config 抛 ConfigError → exit(1)。"""
|
||||
|
||||
@patch("sapcli.cli.app.load_config", side_effect=ConfigError("配置缺失"))
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_config_error(self, mock_enc, mock_log, mock_ssl, mock_load):
|
||||
with patch("sys.argv", ["sap-cli", "download", "--name", "ZT", "--type", "report", "--path", "./out"]):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
|
||||
class TestMainDownloadSuccess(unittest.TestCase):
|
||||
"""download 命令全链路 mock:load_config → ADTClient → login → cmd_download。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_download")
|
||||
@patch("sapcli.cli.app.ADTClient")
|
||||
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_download_success(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
|
||||
mock_client = MagicMock()
|
||||
mock_adt_cls.return_value = mock_client
|
||||
|
||||
with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]):
|
||||
main()
|
||||
|
||||
# 验证 ADTClient 被正确构造
|
||||
mock_adt_cls.assert_called_once_with(
|
||||
"sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=False
|
||||
)
|
||||
# 验证 login 被调用
|
||||
mock_client.login.assert_called_once()
|
||||
# 验证 cmd_download 被调用
|
||||
mock_download.assert_called_once()
|
||||
# cmd_download 的第二个参数应该是 mock_client
|
||||
self.assertIs(mock_download.call_args[0][1], mock_client)
|
||||
|
||||
|
||||
class TestMainSapCliError(unittest.TestCase):
|
||||
"""handler 抛 SapCliError → exit(1)。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_download", side_effect=SapCliError("下载失败"))
|
||||
@patch("sapcli.cli.app.ADTClient")
|
||||
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_sap_cli_error(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
|
||||
mock_adt_cls.return_value = MagicMock()
|
||||
|
||||
with patch("sys.argv", ["sap-cli", "download", "--name", "ZTEST", "--type", "report", "--path", "./out"]):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
|
||||
class TestMainVerifySslFlag(unittest.TestCase):
|
||||
"""--verify-ssl 参数传递给 ADTClient。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_download")
|
||||
@patch("sapcli.cli.app.ADTClient")
|
||||
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_verify_ssl_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
|
||||
mock_adt_cls.return_value = MagicMock()
|
||||
|
||||
with patch("sys.argv", [
|
||||
"sap-cli", "--verify-ssl",
|
||||
"download", "--name", "ZTEST", "--type", "report", "--path", "./out",
|
||||
]):
|
||||
main()
|
||||
|
||||
# verify_ssl 应为 True
|
||||
mock_adt_cls.assert_called_once_with(
|
||||
"sap.example.com", "100", "TESTUSER", "secret123", verify_ssl=True
|
||||
)
|
||||
|
||||
|
||||
class TestMainProfileFlag(unittest.TestCase):
|
||||
"""--profile 参数传递给 load_config。"""
|
||||
|
||||
@patch("sapcli.cli.app.cmd_download")
|
||||
@patch("sapcli.cli.app.ADTClient")
|
||||
@patch("sapcli.cli.app.load_config", return_value=_fake_config())
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
def test_main_profile_flag(self, mock_enc, mock_log, mock_ssl, mock_load, mock_adt_cls, mock_download):
|
||||
mock_adt_cls.return_value = MagicMock()
|
||||
|
||||
with patch("sys.argv", [
|
||||
"sap-cli", "--profile", "DEV",
|
||||
"download", "--name", "ZTEST", "--type", "report", "--path", "./out",
|
||||
]):
|
||||
main()
|
||||
|
||||
# load_config 应收到 profile="DEV"
|
||||
mock_load.assert_called_once()
|
||||
# load_config(config_path, profile=...)
|
||||
# parser 中 --config 的 dest 是 "config",--profile 的 dest 是 "profile"
|
||||
# load_config 调用签名: load_config(args.config, profile=getattr(args, "profile", None))
|
||||
call_kwargs = mock_load.call_args
|
||||
self.assertEqual(call_kwargs[0][0], None) # args.config = None
|
||||
# 第二个参数是 profile
|
||||
# load_config 被调用为 load_config(args.config, profile=args.profile)
|
||||
# 所以要看 kwargs 或位置参数
|
||||
if call_kwargs[1]:
|
||||
self.assertEqual(call_kwargs[1].get("profile"), "DEV")
|
||||
else:
|
||||
# 可能是位置参数方式传递
|
||||
self.assertEqual(call_kwargs[0][1], "DEV")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# config_cmd.py 测试
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
class TestConfigShowDefault(unittest.TestCase):
|
||||
"""config show → 显示默认配置。"""
|
||||
|
||||
# _config_show 内部用 from sapcli.config import load_config,所以 patch 源模块
|
||||
@patch("sapcli.config.load_config", return_value=_fake_config())
|
||||
def test_config_show_default(self, mock_load):
|
||||
import argparse
|
||||
args = argparse.Namespace(config=None, config_action="show")
|
||||
# 捕获 stdout
|
||||
with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
|
||||
cmd_config(args)
|
||||
|
||||
output = mock_out.getvalue()
|
||||
self.assertIn("sap-cli 当前配置", output)
|
||||
self.assertIn("sap.example.com", output)
|
||||
self.assertIn("100", output)
|
||||
self.assertIn("TESTUSER", output)
|
||||
self.assertIn("***", output)
|
||||
|
||||
|
||||
class TestConfigListProfiles(unittest.TestCase):
|
||||
"""config list-profiles → 显示多 profile。"""
|
||||
|
||||
def test_config_list_profiles(self):
|
||||
import argparse
|
||||
import configparser
|
||||
|
||||
# 创建一个临时配置文件包含多个 section
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f:
|
||||
parser = configparser.ConfigParser()
|
||||
parser.add_section("SAP")
|
||||
parser.set("SAP", "host", "sap.example.com")
|
||||
parser.add_section("DEV")
|
||||
parser.set("DEV", "host", "dev.sap.example.com")
|
||||
parser.write(f)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
args = argparse.Namespace(config=tmp_path, config_action="list-profiles")
|
||||
with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
|
||||
cmd_config(args)
|
||||
|
||||
output = mock_out.getvalue()
|
||||
self.assertIn("sap-cli 可用 profile", output)
|
||||
self.assertIn("SAP", output)
|
||||
self.assertIn("DEV", output)
|
||||
self.assertIn("(默认)", output) # SAP section 是默认的
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
class TestConfigSetWritesFile(unittest.TestCase):
|
||||
"""config set → 写入配置文件。"""
|
||||
|
||||
def test_config_set_writes_file(self):
|
||||
import argparse
|
||||
import configparser
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".ini", delete=False, encoding="utf-8") as f:
|
||||
f.write("")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
args = argparse.Namespace(
|
||||
config=tmp_path,
|
||||
config_action="set",
|
||||
key="host",
|
||||
value="newhost.example.com",
|
||||
profile=None,
|
||||
)
|
||||
with patch("sys.stdout", new_callable=io.StringIO):
|
||||
cmd_config(args)
|
||||
|
||||
# 验证文件已写入
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(tmp_path, encoding="utf-8")
|
||||
self.assertTrue(parser.has_section("SAP"))
|
||||
self.assertEqual(parser.get("SAP", "host"), "newhost.example.com")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
class TestConfigSetInvalidKey(unittest.TestCase):
|
||||
"""config set 无效 key → 报错。"""
|
||||
|
||||
def test_config_set_invalid_key(self):
|
||||
import argparse
|
||||
|
||||
args = argparse.Namespace(
|
||||
config=None,
|
||||
config_action="set",
|
||||
key="invalid_key",
|
||||
value="some_value",
|
||||
profile=None,
|
||||
)
|
||||
with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
|
||||
cmd_config(args)
|
||||
|
||||
output = mock_out.getvalue()
|
||||
self.assertIn("不支持的配置项", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,733 @@
|
||||
"""batch.py / analyze.py 深入单元测试 — 失败/边界场景。
|
||||
|
||||
运行: python tests/unit/test_batch_analyze.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets"))
|
||||
|
||||
from sapcli.manifest import Manifest, ManifestEntry
|
||||
|
||||
|
||||
# ─── Helpers ───
|
||||
|
||||
def _make_client():
|
||||
"""创建完整 mock 的 ADTClient。"""
|
||||
client = MagicMock()
|
||||
client.host = "https://sap.example.com"
|
||||
client.sap_client = "100"
|
||||
client.csrf_token = "test-csrf-token"
|
||||
client.user = "TESTUSER"
|
||||
client.session = MagicMock()
|
||||
return client
|
||||
|
||||
|
||||
def _args(**kwargs):
|
||||
defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
def _write_manifest(td, entries, version=1):
|
||||
"""在 td 下写 manifest.json。entries: list[ManifestEntry]。"""
|
||||
data = {
|
||||
"version": version,
|
||||
"objects": {e.name: e.to_dict() for e in entries},
|
||||
}
|
||||
path = os.path.join(td, "manifest.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
return path
|
||||
|
||||
|
||||
def _make_entry(name="ZTEST", type_="report", file_="reports/ztest.abap",
|
||||
status="not_exists", corr_nr=None, depends_on=None,
|
||||
last_sync=None, last_sync_result="pending"):
|
||||
return ManifestEntry(
|
||||
name=name,
|
||||
type=type_,
|
||||
file=file_,
|
||||
system_status=status,
|
||||
corr_nr=corr_nr,
|
||||
depends_on=depends_on or [],
|
||||
last_sync=last_sync,
|
||||
last_sync_result=last_sync_result,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# batch.py — cmd_init
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdInitEmptyDir(unittest.TestCase):
|
||||
"""cmd_init 无 .abap 文件的空目录。"""
|
||||
|
||||
def test_no_abap_files_returns_early(self):
|
||||
from sapcli.commands.batch import cmd_init
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 空目录,没有任何子目录或 .abap 文件
|
||||
cmd_init(_args(path=td), client)
|
||||
# 不应创建 manifest.json
|
||||
self.assertFalse(os.path.isfile(os.path.join(td, "manifest.json")))
|
||||
# client 不应被调用
|
||||
client.get_object_status.assert_not_called()
|
||||
|
||||
def test_no_abap_files_with_subdirs(self):
|
||||
"""有标准子目录但无 .abap 文件。"""
|
||||
from sapcli.commands.batch import cmd_init
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
for d in ("reports", "classes", "interfaces"):
|
||||
os.makedirs(os.path.join(td, d))
|
||||
cmd_init(_args(path=td), client)
|
||||
self.assertFalse(os.path.isfile(os.path.join(td, "manifest.json")))
|
||||
client.get_object_status.assert_not_called()
|
||||
|
||||
|
||||
class TestCmdInitDirNotExists(unittest.TestCase):
|
||||
"""cmd_init 目录不存在。"""
|
||||
|
||||
def test_nonexistent_dir_raises_config_error(self):
|
||||
from sapcli.commands.batch import cmd_init
|
||||
from sapcli.exceptions import ConfigError
|
||||
client = _make_client()
|
||||
with self.assertRaises(ConfigError) as ctx:
|
||||
cmd_init(_args(path="/nonexistent/path/xyz"), client)
|
||||
self.assertIn("不存在", str(ctx.exception))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# batch.py — cmd_sync_all
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdSyncAllNoManifest(unittest.TestCase):
|
||||
"""cmd_sync_all 无清单文件报错。"""
|
||||
|
||||
def test_missing_manifest_raises_file_not_found(self):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 不创建 manifest.json
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
cmd_sync_all(_args(path=td), client)
|
||||
|
||||
|
||||
class TestCmdSyncAllDryRun(unittest.TestCase):
|
||||
"""cmd_sync_all dry-run 模式:只打印计划,不实际同步。"""
|
||||
|
||||
def test_dry_run_does_not_sync(self):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 创建源文件
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
src_file = os.path.join(report_dir, "ztest.abap")
|
||||
with open(src_file, "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
|
||||
entry = _make_entry(status="inactive", last_sync_result="failed")
|
||||
_write_manifest(td, [entry])
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_sync_all(_args(path=td, dry_run=True), client)
|
||||
|
||||
# _sync_single 不应被调用
|
||||
# 检查打印了 dry-run 标题
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("dry-run", printed)
|
||||
|
||||
def test_dry_run_shows_pending_objects(self):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
with open(os.path.join(report_dir, "zother.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT zother.")
|
||||
|
||||
e1 = _make_entry(name="ZTEST", file_="reports/ztest.abap", status="not_exists")
|
||||
e2 = _make_entry(name="ZOTHER", file_="reports/zother.abap", status="inactive")
|
||||
_write_manifest(td, [e1, e2])
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_sync_all(_args(path=td, dry_run=True), client)
|
||||
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("ZTEST", printed)
|
||||
self.assertIn("ZOTHER", printed)
|
||||
|
||||
|
||||
class TestCmdSyncAllFailFast(unittest.TestCase):
|
||||
"""cmd_sync_all 有对象同步失败(fail-fast)。"""
|
||||
|
||||
@patch("sapcli.commands.batch._sync_single")
|
||||
def test_fail_fast_stops_on_first_error(self, mock_sync):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
mock_sync.return_value = (False, "同步失败: 语法错误", None)
|
||||
client = _make_client()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
|
||||
e1 = _make_entry(name="ZFIRST", file_="reports/zfirst.abap", status="inactive")
|
||||
e2 = _make_entry(name="ZSECOND", file_="reports/zsecond.abap", status="inactive")
|
||||
_write_manifest(td, [e1, e2])
|
||||
|
||||
for fname in ("zfirst.abap", "zsecond.abap"):
|
||||
with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_sync_all(_args(path=td, fail_fast=True), client)
|
||||
|
||||
# fail_fast → 第一个失败后 break,_sync_single 最多调用 1 次
|
||||
self.assertLessEqual(mock_sync.call_count, 1)
|
||||
|
||||
@patch("sapcli.commands.batch._sync_single")
|
||||
def test_no_fail_fast_continues_on_error(self, mock_sync):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
# 第一次失败,第二次成功
|
||||
mock_sync.side_effect = [
|
||||
(False, "同步失败", None),
|
||||
(True, None, "DEVK001"),
|
||||
]
|
||||
client = _make_client()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
|
||||
e1 = _make_entry(name="ZFIRST", file_="reports/zfirst.abap", status="inactive")
|
||||
e2 = _make_entry(name="ZSECOND", file_="reports/zsecond.abap", status="inactive")
|
||||
_write_manifest(td, [e1, e2])
|
||||
|
||||
for fname in ("zfirst.abap", "zsecond.abap"):
|
||||
with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_sync_all(_args(path=td, fail_fast=False), client)
|
||||
|
||||
# 没有 fail_fast → 继续同步第二个
|
||||
self.assertEqual(mock_sync.call_count, 2)
|
||||
|
||||
@patch("sapcli.commands.batch._sync_single")
|
||||
def test_cascading_skip_on_dep_failure(self, mock_sync):
|
||||
"""依赖失败时,后续依赖它的对象被级联跳过。"""
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
mock_sync.return_value = (False, "同步失败", None)
|
||||
client = _make_client()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
|
||||
e1 = _make_entry(name="ZBASE", file_="reports/zbase.abap", status="inactive")
|
||||
e2 = _make_entry(name="ZCHILD", file_="reports/zchild.abap", status="inactive",
|
||||
depends_on=["ZBASE"])
|
||||
_write_manifest(td, [e1, e2])
|
||||
|
||||
for fname in ("zbase.abap", "zchild.abap"):
|
||||
with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_sync_all(_args(path=td, fail_fast=False), client)
|
||||
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
# ZCHILD 应被跳过(依赖失败)
|
||||
self.assertIn("ZCHILD", printed)
|
||||
self.assertIn("跳过", printed)
|
||||
|
||||
|
||||
class TestCmdSyncAllMissingFile(unittest.TestCase):
|
||||
"""cmd_sync_all 清单中对象对应的本地文件缺失。"""
|
||||
|
||||
@patch("sapcli.commands.batch._sync_single")
|
||||
def test_missing_file_is_skipped(self, mock_sync):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
mock_sync.return_value = (True, None, "DEVK001")
|
||||
client = _make_client()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 不创建源文件 → 清单中 file 指向不存在的文件
|
||||
e1 = _make_entry(name="ZMISSING", file_="reports/zmissing.abap", status="inactive")
|
||||
_write_manifest(td, [e1])
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_sync_all(_args(path=td), client)
|
||||
|
||||
# _sync_single 不应被调用(文件缺失,对象被跳过)
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
class TestCmdSyncAllAlreadySynced(unittest.TestCase):
|
||||
"""cmd_sync_all 跳过已同步且激活的对象。"""
|
||||
|
||||
@patch("sapcli.commands.batch._sync_single")
|
||||
def test_active_success_is_skipped(self, mock_sync):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
client = _make_client()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "zactive.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT zactive.")
|
||||
|
||||
e1 = _make_entry(
|
||||
name="ZACTIVE", file_="reports/zactive.abap",
|
||||
status="active", last_sync_result="success",
|
||||
last_sync="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
_write_manifest(td, [e1])
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_sync_all(_args(path=td), client)
|
||||
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
class TestCmdSyncAllCyclicDependency(unittest.TestCase):
|
||||
"""cmd_sync_all 拓扑排序检测到循环依赖时抛异常。"""
|
||||
|
||||
def test_cyclic_raises(self):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
from sapcli.exceptions import CyclicDependencyError
|
||||
client = _make_client()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
for fname in ("za.abap", "zb.abap"):
|
||||
with open(os.path.join(report_dir, fname), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
|
||||
e1 = _make_entry(name="ZA", file_="reports/za.abap", status="inactive",
|
||||
depends_on=["ZB"])
|
||||
e2 = _make_entry(name="ZB", file_="reports/zb.abap", status="inactive",
|
||||
depends_on=["ZA"])
|
||||
_write_manifest(td, [e1, e2])
|
||||
|
||||
with self.assertRaises(CyclicDependencyError):
|
||||
cmd_sync_all(_args(path=td), client)
|
||||
|
||||
|
||||
class TestCmdSyncAllNotExistsAutoCreate(unittest.TestCase):
|
||||
"""cmd_sync_all 对象不存在时自动创建,创建失败则 fail-fast 或 continue。"""
|
||||
|
||||
def test_auto_create_failure_fail_fast(self):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
client = _make_client()
|
||||
client.create_object.side_effect = Exception("SAP 连接超时")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "znew.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT znew.")
|
||||
|
||||
e1 = _make_entry(name="ZNEW", file_="reports/znew.abap", status="not_exists")
|
||||
_write_manifest(td, [e1])
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_sync_all(_args(path=td, fail_fast=True), client)
|
||||
|
||||
client.create_object.assert_called_once()
|
||||
|
||||
def test_auto_create_failure_continue(self):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
client = _make_client()
|
||||
client.create_object.side_effect = Exception("SAP 连接超时")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "znew.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT znew.")
|
||||
|
||||
e1 = _make_entry(name="ZNEW", file_="reports/znew.abap", status="not_exists")
|
||||
_write_manifest(td, [e1])
|
||||
|
||||
with patch("builtins.print"):
|
||||
# 不应抛出异常,只是标记为 failed
|
||||
cmd_sync_all(_args(path=td, fail_fast=False), client)
|
||||
|
||||
# 验证 manifest 被更新了(即使失败也保存)
|
||||
self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json")))
|
||||
|
||||
|
||||
class TestCmdSyncAllFunctionAutoGroup(unittest.TestCase):
|
||||
"""cmd_sync_all function 类型自动创建函数组。"""
|
||||
|
||||
def test_auto_creates_function_group(self):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
client = _make_client()
|
||||
client.function_group_exists.return_value = False
|
||||
client.create_function_group.return_value = True
|
||||
# create_object for the function module itself → succeed
|
||||
client.create_object.return_value = ("/uri/zgrp/zfunc", "/uri/zgrp/zfunc/source/main")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
func_dir = os.path.join(td, "functions", "zgrp")
|
||||
os.makedirs(func_dir)
|
||||
with open(os.path.join(func_dir, "zfunc.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("FUNCTION zfunc.")
|
||||
|
||||
e1 = _make_entry(
|
||||
name="ZGRP/ZFUNC", type_="function",
|
||||
file_="functions/zgrp/zfunc.abap", status="not_exists",
|
||||
)
|
||||
_write_manifest(td, [e1])
|
||||
|
||||
with patch("sapcli.commands.batch._sync_single") as mock_sync, \
|
||||
patch("builtins.print"):
|
||||
# _sync_single succeeds
|
||||
mock_sync.return_value = (True, None, "DEVK001")
|
||||
cmd_sync_all(_args(path=td), client)
|
||||
|
||||
client.create_function_group.assert_called_once_with("ZGRP")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# batch.py — cmd_init SAP 查询失败
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdInitSAPQueryFailure(unittest.TestCase):
|
||||
"""cmd_init 查询 SAP 状态失败时使用 fallback 状态。"""
|
||||
|
||||
def test_query_failure_defaults_to_not_exists(self):
|
||||
from sapcli.commands.batch import cmd_init
|
||||
client = _make_client()
|
||||
client.get_object_status.side_effect = Exception("网络超时")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_init(_args(path=td), client)
|
||||
|
||||
# 应仍然创建 manifest.json
|
||||
manifest_path = os.path.join(td, "manifest.json")
|
||||
self.assertTrue(os.path.isfile(manifest_path))
|
||||
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# 失败时 fallback 到 not_exists
|
||||
self.assertEqual(data["objects"]["ZTEST"]["system_status"], "not_exists")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# batch.py — cmd_refresh 查询失败
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdRefreshQueryFailure(unittest.TestCase):
|
||||
"""cmd_refresh 单个对象查询失败时跳过。"""
|
||||
|
||||
def test_query_failure_skips_entry(self):
|
||||
from sapcli.commands.batch import cmd_refresh
|
||||
client = _make_client()
|
||||
client.get_object_status.side_effect = Exception("SAP 不可用")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
entry = _make_entry(status="active")
|
||||
_write_manifest(td, [entry])
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_refresh(_args(path=td), client)
|
||||
|
||||
# 不抛异常,只跳过失败的对象
|
||||
client.get_object_status.assert_called()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# analyze.py — cmd_analyze
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdAnalyzeDirNotExists(unittest.TestCase):
|
||||
"""cmd_analyze 目录不存在。"""
|
||||
|
||||
def test_nonexistent_dir_prints_error(self):
|
||||
from sapcli.commands.analyze import cmd_analyze
|
||||
client = _make_client()
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_analyze(_args(path="/nonexistent/path/xyz"), client)
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("不存在", printed)
|
||||
|
||||
|
||||
class TestCmdAnalyzeNoAbapFiles(unittest.TestCase):
|
||||
"""cmd_analyze 项目无 .abap 文件(空项目)。"""
|
||||
|
||||
def test_empty_project_no_crash(self):
|
||||
from sapcli.commands.analyze import cmd_analyze
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_analyze(_args(path=td), client)
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("未找到", printed)
|
||||
|
||||
|
||||
class TestCmdAnalyzeNoDependencies(unittest.TestCase):
|
||||
"""cmd_analyze 项目中 .abap 文件无外部依赖。"""
|
||||
|
||||
def test_no_z_deps_shows_no_dependencies(self):
|
||||
from sapcli.commands.analyze import cmd_analyze
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "zsimple.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT zsimple.\nWRITE: / 'hello'.\n")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_analyze(_args(path=td), client)
|
||||
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("无外部依赖", printed)
|
||||
|
||||
|
||||
class TestCmdAnalyzeWithDependencies(unittest.TestCase):
|
||||
"""cmd_analyze 正确检测 Z/Y 开头的外部依赖。"""
|
||||
|
||||
def test_detects_type_ref_to(self):
|
||||
from sapcli.commands.analyze import cmd_analyze, _analyze_dependencies
|
||||
source = "DATA: lo_obj TYPE REF TO ZCL_MY_CLASS."
|
||||
deps = _analyze_dependencies(source)
|
||||
self.assertIn("ZCL_MY_CLASS", deps)
|
||||
|
||||
def test_detects_call_function(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
source = "CALL FUNCTION 'Z_MY_FUNC'."
|
||||
deps = _analyze_dependencies(source)
|
||||
self.assertIn("Z_MY_FUNC", deps)
|
||||
|
||||
def test_detects_create_object(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
source = "CREATE OBJECT lo_obj TYPE ZCL_FACTORY."
|
||||
deps = _analyze_dependencies(source)
|
||||
self.assertIn("ZCL_FACTORY", deps)
|
||||
|
||||
def test_detects_type_reference(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
source = "DATA: lv_val TYPE ZCUSTOM_TYPE."
|
||||
deps = _analyze_dependencies(source)
|
||||
self.assertIn("ZCUSTOM_TYPE", deps)
|
||||
|
||||
def test_ignores_builtin_types(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
source = "DATA: lv_str TYPE STRING.\nDATA: lv_int TYPE INT4."
|
||||
deps = _analyze_dependencies(source)
|
||||
self.assertEqual(deps, [])
|
||||
|
||||
def test_ignores_short_names(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
source = "DATA: lv_val TYPE AB."
|
||||
deps = _analyze_dependencies(source)
|
||||
self.assertEqual(deps, [])
|
||||
|
||||
def test_detects_y_prefix(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
source = "DATA: lo TYPE REF TO YCL_SERVICE."
|
||||
deps = _analyze_dependencies(source)
|
||||
self.assertIn("YCL_SERVICE", deps)
|
||||
|
||||
def test_multiple_deps_deduplicated(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
source = (
|
||||
"DATA: lo1 TYPE REF TO ZCL_A.\n"
|
||||
"DATA: lo2 TYPE REF TO ZCL_A.\n"
|
||||
"CALL FUNCTION 'ZCL_A'.\n"
|
||||
)
|
||||
deps = _analyze_dependencies(source)
|
||||
# ZCL_A 只出现一次
|
||||
self.assertEqual(deps.count("ZCL_A"), 1)
|
||||
|
||||
|
||||
class TestCmdAnalyzeFileIntegration(unittest.TestCase):
|
||||
"""cmd_analyze 完整文件分析集成测试。"""
|
||||
|
||||
def test_analyze_with_deps(self):
|
||||
from sapcli.commands.analyze import cmd_analyze
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "zrpt.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT zrpt.\nDATA: lo TYPE REF TO ZCL_HELPER.\n")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_analyze(_args(path=td), client)
|
||||
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("ZCL_HELPER", printed)
|
||||
|
||||
def test_analyze_multiple_files(self):
|
||||
from sapcli.commands.analyze import cmd_analyze
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
# 文件 1: 有依赖
|
||||
with open(os.path.join(report_dir, "za.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT za.\nDATA lo TYPE REF TO ZCL_DEP.\n")
|
||||
# 文件 2: 无依赖
|
||||
with open(os.path.join(report_dir, "zb.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT zb.\nWRITE: / 'hello'.\n")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_analyze(_args(path=td), client)
|
||||
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("2", printed) # 2 个文件
|
||||
self.assertIn("ZCL_DEP", printed)
|
||||
|
||||
|
||||
class TestCmdAnalyzeMissingFiles(unittest.TestCase):
|
||||
"""cmd_analyze 处理缺少文件的对象。"""
|
||||
|
||||
def test_analyze_only_reads_existing_files(self):
|
||||
"""cmd_analyze 通过 os.walk 扫描,只读存在的 .abap 文件,不会因 manifest 中
|
||||
引用的缺失文件而崩溃。"""
|
||||
from sapcli.commands.analyze import cmd_analyze
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 创建一个有文件的对象和一个空目录
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "zexists.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT zexists.\n")
|
||||
|
||||
# 只分析到实际存在的文件
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_analyze(_args(path=td), client)
|
||||
|
||||
printed = "\n".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("1", printed) # 1 个文件
|
||||
|
||||
|
||||
class TestCmdAnalyzeEmptySource(unittest.TestCase):
|
||||
"""_analyze_dependencies 输入空源码。"""
|
||||
|
||||
def test_empty_source_no_deps(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
deps = _analyze_dependencies("")
|
||||
self.assertEqual(deps, [])
|
||||
|
||||
def test_whitespace_only_no_deps(self):
|
||||
from sapcli.commands.analyze import _analyze_dependencies
|
||||
deps = _analyze_dependencies(" \n \n ")
|
||||
self.assertEqual(deps, [])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# sorter.py — 环形依赖检测(补充)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestTopologicalSortCyclic(unittest.TestCase):
|
||||
"""topological_sort 检测循环依赖。"""
|
||||
|
||||
def test_simple_two_node_cycle(self):
|
||||
from sapcli.sorter import topological_sort
|
||||
from sapcli.exceptions import CyclicDependencyError
|
||||
|
||||
e1 = _make_entry(name="ZA", depends_on=["ZB"])
|
||||
e2 = _make_entry(name="ZB", depends_on=["ZA"])
|
||||
with self.assertRaises(CyclicDependencyError) as ctx:
|
||||
topological_sort([e1, e2])
|
||||
self.assertTrue(len(ctx.exception.cycle_members) > 0)
|
||||
|
||||
def test_three_node_cycle(self):
|
||||
from sapcli.sorter import topological_sort
|
||||
from sapcli.exceptions import CyclicDependencyError
|
||||
|
||||
e1 = _make_entry(name="ZA", depends_on=["ZC"])
|
||||
e2 = _make_entry(name="ZB", depends_on=["ZA"])
|
||||
e3 = _make_entry(name="ZC", depends_on=["ZB"])
|
||||
with self.assertRaises(CyclicDependencyError):
|
||||
topological_sort([e1, e2, e3])
|
||||
|
||||
def test_self_dependency(self):
|
||||
from sapcli.sorter import topological_sort
|
||||
from sapcli.exceptions import CyclicDependencyError
|
||||
|
||||
e1 = _make_entry(name="ZA", depends_on=["ZA"])
|
||||
with self.assertRaises(CyclicDependencyError):
|
||||
topological_sort([e1])
|
||||
|
||||
def test_no_deps_returns_all(self):
|
||||
from sapcli.sorter import topological_sort
|
||||
|
||||
e1 = _make_entry(name="ZA")
|
||||
e2 = _make_entry(name="ZB")
|
||||
result = topological_sort([e1, e2])
|
||||
names = [e.name for e in result]
|
||||
self.assertEqual(set(names), {"ZA", "ZB"})
|
||||
|
||||
def test_external_dep_ignored(self):
|
||||
"""depends_on 引用不在列表中的外部对象,不报错。"""
|
||||
from sapcli.sorter import topological_sort
|
||||
|
||||
e1 = _make_entry(name="ZA", depends_on=["Z_EXTERNAL"])
|
||||
result = topological_sort([e1])
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
def test_empty_list_returns_empty(self):
|
||||
from sapcli.sorter import topological_sort
|
||||
self.assertEqual(topological_sort([]), [])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# batch.py — cmd_sync_all 成功路径
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdSyncAllSuccess(unittest.TestCase):
|
||||
"""cmd_sync_all 正常同步成功。"""
|
||||
|
||||
@patch("sapcli.commands.batch._sync_single")
|
||||
def test_sync_updates_manifest(self, mock_sync):
|
||||
from sapcli.commands.batch import cmd_sync_all
|
||||
mock_sync.return_value = (True, None, "DEVK999")
|
||||
client = _make_client()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
|
||||
e1 = _make_entry(status="inactive", last_sync_result="pending")
|
||||
_write_manifest(td, [e1])
|
||||
|
||||
with patch("builtins.print"):
|
||||
cmd_sync_all(_args(path=td), client)
|
||||
|
||||
# 验证 manifest 更新
|
||||
with open(os.path.join(td, "manifest.json"), "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.assertEqual(data["objects"]["ZTEST"]["system_status"], "active")
|
||||
self.assertEqual(data["objects"]["ZTEST"]["last_sync_result"], "success")
|
||||
self.assertEqual(data["objects"]["ZTEST"]["corr_nr"], "DEVK999")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""CLI 层(parser.py + app.py)单元测试。
|
||||
|
||||
运行: python tests/unit/test_cli.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets"))
|
||||
|
||||
from sapcli.cli.parser import build_parser
|
||||
from sapcli.types import all_type_keys
|
||||
|
||||
|
||||
class TestParserBuild(unittest.TestCase):
|
||||
"""test_parser_build: build_parser() 返回 ArgumentParser"""
|
||||
|
||||
def test_returns_argument_parser(self):
|
||||
import argparse
|
||||
parser = build_parser()
|
||||
self.assertIsInstance(parser, argparse.ArgumentParser)
|
||||
|
||||
def test_parser_has_description(self):
|
||||
parser = build_parser()
|
||||
self.assertIn("sap-cli", parser.description)
|
||||
|
||||
|
||||
class TestParserDownload(unittest.TestCase):
|
||||
"""test_parser_download: 解析 download 命令"""
|
||||
|
||||
def test_download_full_args(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"download", "--name", "ZTEST", "--type", "report", "--path", "./out",
|
||||
])
|
||||
self.assertEqual(args.command, "download")
|
||||
self.assertEqual(args.name, "ZTEST")
|
||||
self.assertEqual(args.type, "report")
|
||||
self.assertEqual(args.path, "./out")
|
||||
|
||||
def test_download_class_type(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"download", "--name", "ZCL_TEST", "--type", "class", "--path", "./out",
|
||||
])
|
||||
self.assertEqual(args.command, "download")
|
||||
self.assertEqual(args.type, "class")
|
||||
|
||||
|
||||
class TestParserSyncSingle(unittest.TestCase):
|
||||
"""test_parser_sync_single: 解析 sync 单对象模式"""
|
||||
|
||||
def test_sync_single(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"sync", "--name", "ZTEST", "--type", "report", "--path", "./file.abap",
|
||||
])
|
||||
self.assertEqual(args.command, "sync")
|
||||
self.assertEqual(args.name, "ZTEST")
|
||||
self.assertEqual(args.type, "report")
|
||||
self.assertEqual(args.path, "./file.abap")
|
||||
self.assertFalse(getattr(args, "all", False))
|
||||
|
||||
|
||||
class TestParserSyncAll(unittest.TestCase):
|
||||
"""test_parser_sync_all: 解析 sync --all 批量模式"""
|
||||
|
||||
def test_sync_all(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"sync", "--all", "--path", "./project",
|
||||
])
|
||||
self.assertEqual(args.command, "sync")
|
||||
self.assertTrue(args.all)
|
||||
self.assertEqual(args.path, "./project")
|
||||
self.assertIsNone(args.name)
|
||||
self.assertIsNone(args.type)
|
||||
|
||||
def test_sync_all_dry_run(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"sync", "--all", "--path", "./project", "--dry-run",
|
||||
])
|
||||
self.assertTrue(args.all)
|
||||
self.assertTrue(args.dry_run)
|
||||
|
||||
|
||||
class TestParserCreate(unittest.TestCase):
|
||||
"""test_parser_create: 解析 create 命令"""
|
||||
|
||||
def test_create_full_args(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"create", "--name", "ZTEST", "--type", "report",
|
||||
"--description", "test", "--package", "$TMP",
|
||||
])
|
||||
self.assertEqual(args.command, "create")
|
||||
self.assertEqual(args.name, "ZTEST")
|
||||
self.assertEqual(args.type, "report")
|
||||
self.assertEqual(args.description, "test")
|
||||
self.assertEqual(args.package, "$TMP")
|
||||
|
||||
def test_create_default_package(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"create", "--name", "ZTEST", "--type", "report",
|
||||
])
|
||||
self.assertEqual(args.package, "$TMP")
|
||||
self.assertIsNone(args.description)
|
||||
|
||||
|
||||
class TestParserNoCommand(unittest.TestCase):
|
||||
"""test_parser_no_command: 无命令时 args.command 为 None"""
|
||||
|
||||
def test_no_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([])
|
||||
self.assertIsNone(args.command)
|
||||
|
||||
|
||||
class TestParserConfig(unittest.TestCase):
|
||||
"""test_parser_config: 解析 config 子命令"""
|
||||
|
||||
def test_config_show(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["config", "show"])
|
||||
self.assertEqual(args.command, "config")
|
||||
self.assertEqual(args.config_action, "show")
|
||||
|
||||
def test_config_set(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["config", "set", "host", "http://sap:8000"])
|
||||
self.assertEqual(args.command, "config")
|
||||
self.assertEqual(args.config_action, "set")
|
||||
self.assertEqual(args.key, "host")
|
||||
self.assertEqual(args.value, "http://sap:8000")
|
||||
|
||||
|
||||
class TestParserAuth(unittest.TestCase):
|
||||
"""test_parser_auth: 解析 auth 子命令"""
|
||||
|
||||
def test_auth_login(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["auth", "login"])
|
||||
self.assertEqual(args.command, "auth")
|
||||
self.assertEqual(args.auth_action, "login")
|
||||
|
||||
def test_auth_logout(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["auth", "logout"])
|
||||
self.assertEqual(args.command, "auth")
|
||||
self.assertEqual(args.auth_action, "logout")
|
||||
|
||||
def test_auth_status(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["auth", "status"])
|
||||
self.assertEqual(args.command, "auth")
|
||||
self.assertEqual(args.auth_action, "status")
|
||||
|
||||
|
||||
class TestParserTransport(unittest.TestCase):
|
||||
"""test_parser_transport: 解析 transport 子命令"""
|
||||
|
||||
def test_transport_info(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["transport", "info", "--corr_nr", "DEVK001"])
|
||||
self.assertEqual(args.command, "transport")
|
||||
self.assertEqual(args.transport_action, "info")
|
||||
self.assertEqual(args.corr_nr, "DEVK001")
|
||||
|
||||
def test_transport_list(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["transport", "list"])
|
||||
self.assertEqual(args.command, "transport")
|
||||
self.assertEqual(args.transport_action, "list")
|
||||
|
||||
|
||||
class TestParserInvalidType(unittest.TestCase):
|
||||
"""test_parser_invalid_type: --type invalid 应报错"""
|
||||
|
||||
def test_invalid_type_download(self):
|
||||
parser = build_parser()
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args([
|
||||
"download", "--name", "ZTEST", "--type", "invalid", "--path", "./out",
|
||||
])
|
||||
|
||||
def test_invalid_type_create(self):
|
||||
parser = build_parser()
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args([
|
||||
"create", "--name", "ZTEST", "--type", "invalid",
|
||||
])
|
||||
|
||||
|
||||
class TestParserScaffold(unittest.TestCase):
|
||||
"""test_parser_scaffold: 解析 scaffold 命令"""
|
||||
|
||||
def test_scaffold_with_template(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"scaffold", "--name", "ZTEST", "--template", "alv-report",
|
||||
])
|
||||
self.assertEqual(args.command, "scaffold")
|
||||
self.assertEqual(args.name, "ZTEST")
|
||||
self.assertEqual(args.template, "alv-report")
|
||||
|
||||
def test_scaffold_invalid_template(self):
|
||||
parser = build_parser()
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args([
|
||||
"scaffold", "--name", "ZTEST", "--template", "nonexistent",
|
||||
])
|
||||
|
||||
|
||||
class TestMainNoCommandExits(unittest.TestCase):
|
||||
"""test_main_no_command_exits: main() 无参数时 exit code 1"""
|
||||
|
||||
@patch("sapcli.cli.app.sys.argv", ["sap-cli"])
|
||||
@patch("sapcli.cli.app._setup_encoding")
|
||||
@patch("sapcli.cli.app._setup_logging")
|
||||
@patch("sapcli.cli.app._suppress_ssl_warnings")
|
||||
def test_main_no_command_exits(self, mock_ssl, mock_log, mock_enc):
|
||||
from sapcli.cli.app import main
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
|
||||
class TestParserEdgeCases(unittest.TestCase):
|
||||
"""补充边界情况测试。"""
|
||||
|
||||
def test_all_type_keys_accepted(self):
|
||||
"""确保 all_type_keys() 返回的每个类型都能被 parser 接受。"""
|
||||
parser = build_parser()
|
||||
for t in all_type_keys():
|
||||
args = parser.parse_args([
|
||||
"download", "--name", "ZTEST", "--type", t, "--path", "./out",
|
||||
])
|
||||
self.assertEqual(args.type, t, f"type={t} 应被 parser 接受")
|
||||
|
||||
def test_sync_fail_fast(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"sync", "--name", "ZTEST", "--type", "report",
|
||||
"--path", "./file.abap", "--fail-fast",
|
||||
])
|
||||
self.assertTrue(args.fail_fast)
|
||||
|
||||
def test_delete_with_path(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"delete", "--name", "ZTEST", "--type", "report", "--path", "./project",
|
||||
])
|
||||
self.assertEqual(args.command, "delete")
|
||||
self.assertEqual(args.path, "./project")
|
||||
|
||||
def test_info_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"info", "--name", "ZCL_TEST", "--type", "class",
|
||||
])
|
||||
self.assertEqual(args.command, "info")
|
||||
self.assertEqual(args.name, "ZCL_TEST")
|
||||
|
||||
def test_init_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["init", "--path", "./project"])
|
||||
self.assertEqual(args.command, "init")
|
||||
self.assertEqual(args.path, "./project")
|
||||
|
||||
def test_refresh_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["refresh", "--path", "./project"])
|
||||
self.assertEqual(args.command, "refresh")
|
||||
|
||||
def test_list_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"list", "--type", "report", "--prefix", "Z*",
|
||||
])
|
||||
self.assertEqual(args.command, "list")
|
||||
self.assertEqual(args.prefix, "Z*")
|
||||
|
||||
def test_whereused_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"whereused", "--name", "ZTEST", "--type", "report",
|
||||
])
|
||||
self.assertEqual(args.command, "whereused")
|
||||
|
||||
def test_search_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["search", "--query", "CALL FUNCTION"])
|
||||
self.assertEqual(args.command, "search")
|
||||
self.assertEqual(args.query, "CALL FUNCTION")
|
||||
|
||||
def test_check_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"check", "--name", "ZTEST", "--type", "report",
|
||||
])
|
||||
self.assertEqual(args.command, "check")
|
||||
|
||||
def test_format_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"format", "--name", "ZTEST", "--type", "report",
|
||||
])
|
||||
self.assertEqual(args.command, "format")
|
||||
|
||||
def test_diff_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"diff", "--name", "ZTEST", "--type", "report", "--path", "./ztest.abap",
|
||||
])
|
||||
self.assertEqual(args.command, "diff")
|
||||
|
||||
def test_analyze_command(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["analyze", "--path", "./project"])
|
||||
self.assertEqual(args.command, "analyze")
|
||||
|
||||
def test_package_create(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"package", "create", "--name", "ZMY_PKG", "--description", "test pkg",
|
||||
])
|
||||
self.assertEqual(args.command, "package")
|
||||
self.assertEqual(args.package_action, "create")
|
||||
self.assertEqual(args.name, "ZMY_PKG")
|
||||
|
||||
def test_cds_download(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"cds", "download", "--name", "ZMY_CDS", "--path", "./out",
|
||||
])
|
||||
self.assertEqual(args.command, "cds")
|
||||
self.assertEqual(args.cds_action, "download")
|
||||
|
||||
def test_transport_release(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"transport", "release", "--corr_nr", "DEVK001",
|
||||
])
|
||||
self.assertEqual(args.transport_action, "release")
|
||||
|
||||
def test_transport_objects(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"transport", "objects", "--corr_nr", "DEVK001",
|
||||
])
|
||||
self.assertEqual(args.transport_action, "objects")
|
||||
|
||||
def test_transport_create(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"transport", "create", "--description", "CEM BIP 传输请求",
|
||||
])
|
||||
self.assertEqual(args.transport_action, "create")
|
||||
self.assertEqual(args.description, "CEM BIP 传输请求")
|
||||
|
||||
def test_global_config_option(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"--config", "./my_config.ini",
|
||||
"download", "--name", "ZTEST", "--type", "report", "--path", "./out",
|
||||
])
|
||||
self.assertEqual(args.config, "./my_config.ini")
|
||||
|
||||
def test_global_profile_option(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args([
|
||||
"--profile", "DEV",
|
||||
"download", "--name", "ZTEST", "--type", "report", "--path", "./out",
|
||||
])
|
||||
self.assertEqual(args.profile, "DEV")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
# 即使失败也关闭源 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)
|
||||
@@ -0,0 +1,600 @@
|
||||
"""commands/ 模块单元测试
|
||||
|
||||
运行: python tests/unit/test_commands.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "assets"))
|
||||
|
||||
from sapcli.client import ADTClient
|
||||
|
||||
|
||||
def _mock_resp(status_code=200, text="", content=b"", headers=None):
|
||||
r = MagicMock()
|
||||
r.status_code = status_code
|
||||
r.text = text
|
||||
r.content = content
|
||||
r.headers = headers or {}
|
||||
r.raise_for_status = MagicMock()
|
||||
return r
|
||||
|
||||
|
||||
def _make_client():
|
||||
"""创建完整 mock 的 ADTClient。"""
|
||||
client = MagicMock()
|
||||
client.host = "https://sap.example.com"
|
||||
client.sap_client = "100"
|
||||
client.csrf_token = "test-csrf-token"
|
||||
client.user = "TESTUSER"
|
||||
# client.session 用于直接 HTTP 调用
|
||||
client.session = MagicMock()
|
||||
client._headers.return_value = {
|
||||
"content-type": "application/xml",
|
||||
"x-csrf-token": "test-csrf-token",
|
||||
}
|
||||
return client
|
||||
|
||||
|
||||
def _args(**kwargs):
|
||||
defaults = {"type": "report", "name": "ZTEST", "path": ".", "config": None}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# crud.py — download
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdDownload(unittest.TestCase):
|
||||
|
||||
def test_download_report_success(self):
|
||||
from sapcli.commands.crud import cmd_download
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = True
|
||||
client.get_source.return_value = "REPORT ztest.\nWRITE: / 'hello'."
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_download(_args(path=td), client)
|
||||
self.assertTrue(os.path.isfile(os.path.join(td, "ztest.abap")))
|
||||
|
||||
def test_download_object_not_found(self):
|
||||
from sapcli.commands.crud import cmd_download
|
||||
from sapcli.exceptions import ObjectNotFoundError
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = False
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
with self.assertRaises(ObjectNotFoundError):
|
||||
cmd_download(_args(path=td), client)
|
||||
|
||||
def test_download_creates_dir(self):
|
||||
from sapcli.commands.crud import cmd_download
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = True
|
||||
client.get_source.return_value = "REPORT ztest."
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
subdir = os.path.join(td, "sub", "dir")
|
||||
cmd_download(_args(path=subdir), client)
|
||||
self.assertTrue(os.path.isfile(os.path.join(subdir, "ztest.abap")))
|
||||
|
||||
def test_download_class(self):
|
||||
from sapcli.commands.crud import cmd_download
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = True
|
||||
client.get_source.return_value = "CLASS zcl_test DEFINITION."
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_download(_args(type="class", name="ZCL_TEST", path=td), client)
|
||||
self.assertTrue(os.path.isfile(os.path.join(td, "zcl_test.abap")))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# crud.py — sync
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdSync(unittest.TestCase):
|
||||
|
||||
def test_sync_report_success(self):
|
||||
from sapcli.commands.crud import cmd_sync
|
||||
client = _make_client()
|
||||
client.lock.return_value = ("lh_1", "DEVK001")
|
||||
client.set_source.return_value = True
|
||||
client.unlock.return_value = True
|
||||
client.activate.return_value = (True, [])
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.\nWRITE: / 'hello'.")
|
||||
tmpfile = f.name
|
||||
try:
|
||||
cmd_sync(_args(path=tmpfile, project_path=None), client)
|
||||
client.lock.assert_called_once()
|
||||
client.set_source.assert_called_once()
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
def test_sync_file_not_found(self):
|
||||
from sapcli.commands.crud import cmd_sync
|
||||
from sapcli.exceptions import ConfigError
|
||||
client = _make_client()
|
||||
with self.assertRaises(ConfigError):
|
||||
cmd_sync(_args(path="/nonexistent/file.abap"), client)
|
||||
|
||||
def test_sync_with_check(self):
|
||||
from sapcli.commands.crud import cmd_sync
|
||||
client = _make_client()
|
||||
client.lock.return_value = ("lh_1", "DEVK001")
|
||||
client.set_source.return_value = True
|
||||
client.unlock.return_value = True
|
||||
client.activate.return_value = (True, [])
|
||||
client.syntax_check.return_value = (True, [])
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
tmpfile = f.name
|
||||
try:
|
||||
cmd_sync(_args(path=tmpfile, check=True, project_path=None), client)
|
||||
client.syntax_check.assert_called_once()
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# crud.py — info(直接用 client.session.get)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
_INFO_XML = (
|
||||
b'<?xml version="1.0"?>'
|
||||
b'<adtcore:object xmlns:adtcore="http://www.sap.com/adt/core"'
|
||||
b' adtcore:name="ZTEST" adtcore:type="PROG/P"'
|
||||
b' adtcore:version="active" adtcore:description="Test report"'
|
||||
b' adtcore:masterLanguage="EN"/>'
|
||||
)
|
||||
|
||||
class TestCmdInfo(unittest.TestCase):
|
||||
|
||||
def test_info_success(self):
|
||||
from sapcli.commands.crud import cmd_info
|
||||
client = _make_client()
|
||||
client.session.get.return_value = _mock_resp(200, content=_INFO_XML)
|
||||
cmd_info(_args(), client)
|
||||
client.session.get.assert_called()
|
||||
|
||||
def test_info_not_exists(self):
|
||||
from sapcli.commands.crud import cmd_info
|
||||
from sapcli.exceptions import ObjectNotFoundError
|
||||
client = _make_client()
|
||||
client.session.get.return_value = _mock_resp(404)
|
||||
with self.assertRaises(ObjectNotFoundError):
|
||||
cmd_info(_args(), client)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# crud.py — delete
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdDelete(unittest.TestCase):
|
||||
|
||||
@patch("builtins.input", return_value="yes")
|
||||
@patch("sapcli.commands.crud.Manifest")
|
||||
def test_delete_success(self, MockManifest, mock_input):
|
||||
from sapcli.commands.crud import cmd_delete
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = True
|
||||
client.delete_object.return_value = (True, "")
|
||||
cmd_delete(_args(), client)
|
||||
client.delete_object.assert_called_once()
|
||||
|
||||
def test_delete_failure(self):
|
||||
from sapcli.commands.crud import cmd_delete
|
||||
from sapcli.exceptions import DeleteError
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = True
|
||||
client.delete_object.side_effect = DeleteError("删除失败")
|
||||
with self.assertRaises(DeleteError), patch("builtins.input", return_value="yes"):
|
||||
cmd_delete(_args(), client)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# crud.py — create
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdCreate(unittest.TestCase):
|
||||
|
||||
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
|
||||
@patch("sapcli.commands.crud.Manifest")
|
||||
def test_create_report_no_source(self, MockManifest, mock_transport):
|
||||
from sapcli.commands.crud import cmd_create
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = False
|
||||
client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main")
|
||||
cmd_create(_args(source=None, description="Test", package="$TMP", corr_nr=None), client)
|
||||
client.create_object.assert_called_once()
|
||||
|
||||
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
|
||||
@patch("sapcli.commands.crud.Manifest")
|
||||
def test_create_with_source_file(self, MockManifest, mock_transport):
|
||||
from sapcli.commands.crud import cmd_create
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = False
|
||||
client.create_object.return_value = ("/uri/ztest", "/uri/ztest/source/main")
|
||||
client.lock.return_value = ("lh_1", "DEVK001")
|
||||
client.set_source.return_value = True
|
||||
client.unlock.return_value = True
|
||||
client.activate.return_value = (True, [])
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
tmpfile = f.name
|
||||
try:
|
||||
cmd_create(_args(source=tmpfile, description="Test", package="$TMP", corr_nr=None), client)
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
@patch("sapcli.commands.crud._select_transport_request", return_value="DEVK001")
|
||||
@patch("sapcli.commands.crud.Manifest")
|
||||
def test_create_ddic_type(self, MockManifest, mock_transport):
|
||||
from sapcli.commands.crud import cmd_create
|
||||
client = _make_client()
|
||||
client.object_exists.return_value = False
|
||||
client.create_ddic_object.return_value = ("/uri/ztest_dom", "/uri/ztest_dom/source/main")
|
||||
cmd_create(_args(type="domain", name="ZTEST_DOM", source=None, description="Domain", package="$TMP", corr_nr=None), client)
|
||||
client.create_ddic_object.assert_called_once()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# crud.py — print_source_preview
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestPrintSourcePreview(unittest.TestCase):
|
||||
|
||||
def test_preview_short(self):
|
||||
from sapcli.commands.crud import print_source_preview
|
||||
with patch("builtins.print"):
|
||||
print_source_preview("REPORT ztest.\nWRITE: / 'hello'.")
|
||||
|
||||
def test_preview_long(self):
|
||||
from sapcli.commands.crud import print_source_preview
|
||||
long_src = "\n".join(f"LINE {i}" for i in range(100))
|
||||
with patch("builtins.print"):
|
||||
print_source_preview(long_src, max_lines=5)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# batch.py — init
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdInit(unittest.TestCase):
|
||||
|
||||
def test_init_empty_dir_no_crash(self):
|
||||
"""空目录不崩溃(不扫描到 .abap 文件则提前返回)。"""
|
||||
from sapcli.commands.batch import cmd_init
|
||||
client = _make_client()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client)
|
||||
# 空目录无 .abap 文件 → 不创建 manifest → 不崩溃即可
|
||||
|
||||
def test_init_with_abap_file(self):
|
||||
"""有 .abap 文件时,初始化应创建 manifest。"""
|
||||
from sapcli.commands.batch import cmd_init
|
||||
client = _make_client()
|
||||
client.get_object_status.return_value = {"exists": True, "status": "active", "corr_nr": None}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
report_dir = os.path.join(td, "reports")
|
||||
os.makedirs(report_dir)
|
||||
with open(os.path.join(report_dir, "ztest.abap"), "w", encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
cmd_init(argparse.Namespace(name="ZTEST", type="report", path=td, config=None), client)
|
||||
self.assertTrue(os.path.isfile(os.path.join(td, "manifest.json")))
|
||||
|
||||
|
||||
class TestCmdRefresh(unittest.TestCase):
|
||||
|
||||
def test_refresh_success(self):
|
||||
from sapcli.commands.batch import cmd_refresh
|
||||
from sapcli.manifest import Manifest, ManifestEntry
|
||||
client = _make_client()
|
||||
client.get_object_status.return_value = {
|
||||
"exists": True, "status": "active", "corr_nr": None
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 直接写 manifest.json 文件,供 Manifest.load() 读取
|
||||
entry = ManifestEntry(
|
||||
name="ZTEST", type="report",
|
||||
file="reports/ztest.abap",
|
||||
system_status="not_exists",
|
||||
)
|
||||
import json
|
||||
manifest_data = {
|
||||
"version": 2,
|
||||
"objects": {"ZTEST": entry.to_dict()},
|
||||
}
|
||||
manifest_path = os.path.join(td, "manifest.json")
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest_data, f)
|
||||
os.makedirs(os.path.join(td, "reports"))
|
||||
cmd_refresh(argparse.Namespace(path=td, config=None), client)
|
||||
client.get_object_status.assert_called()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# cds.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdCds(unittest.TestCase):
|
||||
|
||||
def test_cds_download(self):
|
||||
from sapcli.commands.cds import cmd_cds
|
||||
client = _make_client()
|
||||
client.get_cds_source.return_value = "define view Z_TEST as select from mara { mara.matnr };"
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_cds(argparse.Namespace(cds_action="download", name="Z_TEST", path=td, config=None), client)
|
||||
client.get_cds_source.assert_called_once()
|
||||
|
||||
def test_cds_create(self):
|
||||
from sapcli.commands.cds import cmd_cds
|
||||
client = _make_client()
|
||||
client.create_cds.return_value = ("/uri/z_test", "/uri/z_test/source/main")
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_cds(argparse.Namespace(
|
||||
cds_action="create", name="Z_TEST", description="Test CDS",
|
||||
source=None, path=td, config=None
|
||||
), client)
|
||||
client.create_cds.assert_called_once()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# config_cmd.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdConfig(unittest.TestCase):
|
||||
|
||||
def test_config_show(self):
|
||||
from sapcli.commands.config_cmd import cmd_config
|
||||
with patch("sapcli.commands.config_cmd._config_show"):
|
||||
cmd_config(argparse.Namespace(config_action="show", config=None))
|
||||
|
||||
def test_config_list_profiles(self):
|
||||
from sapcli.commands.config_cmd import cmd_config
|
||||
with patch("sapcli.commands.config_cmd._config_list_profiles"):
|
||||
cmd_config(argparse.Namespace(config_action="list-profiles", config=None))
|
||||
|
||||
def test_config_set(self):
|
||||
from sapcli.commands.config_cmd import cmd_config
|
||||
with patch("sapcli.commands.config_cmd._config_set"):
|
||||
cmd_config(argparse.Namespace(config_action="set", key="host", value="http://new:8000", config=None))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# diff_cmd.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdDiff(unittest.TestCase):
|
||||
|
||||
def test_diff_success(self):
|
||||
from sapcli.commands.diff_cmd import cmd_diff
|
||||
client = _make_client()
|
||||
client.read_source_for_diff.return_value = "REPORT zremote."
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
|
||||
f.write("REPORT zlocal.")
|
||||
tmpfile = f.name
|
||||
try:
|
||||
cmd_diff(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
|
||||
client.read_source_for_diff.assert_called_once()
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# package_cmd.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdPackage(unittest.TestCase):
|
||||
|
||||
def test_package_info(self):
|
||||
from sapcli.commands.package_cmd import cmd_package
|
||||
client = _make_client()
|
||||
client.get_package_info.return_value = {"name": "Z_TEST", "description": "Test"}
|
||||
cmd_package(argparse.Namespace(package_action="info", name="Z_TEST", config=None), client)
|
||||
client.get_package_info.assert_called_once()
|
||||
|
||||
def test_package_create(self):
|
||||
from sapcli.commands.package_cmd import cmd_package
|
||||
client = _make_client()
|
||||
client.create_package.return_value = True
|
||||
cmd_package(argparse.Namespace(package_action="create", name="Z_TEST_PKG", description="Test", superpackage=None, config=None), client)
|
||||
client.create_package.assert_called_once()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# quality.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdCheck(unittest.TestCase):
|
||||
|
||||
def test_check_clean(self):
|
||||
from sapcli.commands.quality import cmd_check
|
||||
client = _make_client()
|
||||
client.atc_check.return_value = (True, [])
|
||||
cmd_check(_args(), client)
|
||||
client.atc_check.assert_called_once()
|
||||
|
||||
def test_check_with_errors(self):
|
||||
from sapcli.commands.quality import cmd_check
|
||||
client = _make_client()
|
||||
client.atc_check.return_value = (False, [{"type": "E", "line": "1", "text": "Error"}])
|
||||
cmd_check(_args(), client) # 打印错误但不抛异常
|
||||
|
||||
|
||||
class TestCmdFormat(unittest.TestCase):
|
||||
|
||||
def test_format_success(self):
|
||||
from sapcli.commands.quality import cmd_format
|
||||
client = _make_client()
|
||||
client.pretty_print.return_value = "FORMATTED CODE"
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
tmpfile = f.name
|
||||
try:
|
||||
cmd_format(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
|
||||
client.pretty_print.assert_called_once()
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# search.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdList(unittest.TestCase):
|
||||
|
||||
def test_list_found(self):
|
||||
from sapcli.commands.search import cmd_list
|
||||
client = _make_client()
|
||||
client.list_objects.return_value = [{"name": "Z_TEST", "type": "PROG/P", "package": "$TMP"}]
|
||||
cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client)
|
||||
client.list_objects.assert_called_once()
|
||||
|
||||
def test_list_empty(self):
|
||||
from sapcli.commands.search import cmd_list
|
||||
client = _make_client()
|
||||
client.list_objects.return_value = []
|
||||
cmd_list(argparse.Namespace(type="report", package=None, prefix=None, config=None), client)
|
||||
|
||||
|
||||
class TestCmdWhereUsed(unittest.TestCase):
|
||||
|
||||
def test_whereused_found(self):
|
||||
from sapcli.commands.search import cmd_whereused
|
||||
client = _make_client()
|
||||
client.where_used.return_value = [{"name": "Z_USER", "type": "PROG/P"}]
|
||||
cmd_whereused(_args(), client)
|
||||
client.where_used.assert_called_once()
|
||||
|
||||
|
||||
class TestCmdSearch(unittest.TestCase):
|
||||
|
||||
def test_search_found(self):
|
||||
from sapcli.commands.search import cmd_search
|
||||
client = _make_client()
|
||||
client.search_code.return_value = [{"name": "Z_RESULT", "type": "PROG/P"}]
|
||||
cmd_search(argparse.Namespace(query="SELECT", type=None, config=None), client)
|
||||
client.search_code.assert_called_once()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# transport.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdTransport(unittest.TestCase):
|
||||
|
||||
def test_transport_list(self):
|
||||
from sapcli.commands.transport import cmd_transport
|
||||
client = _make_client()
|
||||
client.list_transport_requests.return_value = [
|
||||
{"number": "DEVK001", "description": "Test", "owner": "TESTUSER", "status": "D"}
|
||||
]
|
||||
cmd_transport(argparse.Namespace(transport_action="list", config=None), client)
|
||||
client.list_transport_requests.assert_called_once()
|
||||
|
||||
def test_transport_info(self):
|
||||
from sapcli.commands.transport import cmd_transport
|
||||
client = _make_client()
|
||||
client.transport_info.return_value = {"number": "DEVK001", "description": "Test", "status": "D"}
|
||||
cmd_transport(argparse.Namespace(transport_action="info", number="DEVK001", corr_nr="DEVK001", config=None), client)
|
||||
client.transport_info.assert_called_once()
|
||||
|
||||
def test_transport_release(self):
|
||||
from sapcli.commands.transport import cmd_transport
|
||||
client = _make_client()
|
||||
client.transport_release.return_value = True
|
||||
with patch("builtins.input", return_value="yes"):
|
||||
cmd_transport(argparse.Namespace(transport_action="release", number="DEVK001", corr_nr="DEVK001", config=None), client)
|
||||
client.transport_release.assert_called_once()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# analyze.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdAnalyze(unittest.TestCase):
|
||||
|
||||
def test_analyze_success(self):
|
||||
from sapcli.commands.analyze import cmd_analyze
|
||||
client = _make_client()
|
||||
client.get_source.return_value = "REPORT ztest.\nCALL FUNCTION 'BAPI_TEST'."
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".abap", delete=False, encoding="utf-8") as f:
|
||||
f.write("REPORT ztest.")
|
||||
tmpfile = f.name
|
||||
try:
|
||||
cmd_analyze(argparse.Namespace(name="ZTEST", type="report", path=tmpfile, config=None), client)
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# scaffold.py
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdScaffold(unittest.TestCase):
|
||||
|
||||
def test_scaffold_no_template_lists(self):
|
||||
from sapcli.commands.scaffold import cmd_scaffold
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_scaffold(argparse.Namespace(template=None, name="ZTEST", path=".", config=None, package="$TMP"), None)
|
||||
self.assertTrue(mock_print.called)
|
||||
|
||||
def test_scaffold_alv_report(self):
|
||||
from sapcli.commands.scaffold import cmd_scaffold
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_scaffold(argparse.Namespace(template="alv-report", name="ZALV_TEST", path=td, config=None, package="$TMP"), None)
|
||||
self.assertTrue(len(os.listdir(td)) > 0)
|
||||
|
||||
def test_scaffold_bapi_wrapper(self):
|
||||
from sapcli.commands.scaffold import cmd_scaffold
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_scaffold(argparse.Namespace(template="bapi-wrapper", name="ZBAPI_TEST", path=td, config=None, package="$TMP"), None)
|
||||
self.assertTrue(len(os.listdir(td)) > 0)
|
||||
|
||||
def test_scaffold_interface_class(self):
|
||||
from sapcli.commands.scaffold import cmd_scaffold
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_scaffold(argparse.Namespace(template="interface-class", name="ZIF_TEST", path=td, config=None, package="$TMP"), None)
|
||||
self.assertTrue(len(os.listdir(td)) > 0)
|
||||
|
||||
def test_scaffold_data_model(self):
|
||||
from sapcli.commands.scaffold import cmd_scaffold
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cmd_scaffold(argparse.Namespace(template="data-model", name="ZMODEL", path=td, config=None, package="$TMP"), None)
|
||||
self.assertTrue(len(os.listdir(td)) > 0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# auth.py — 需 mock getpass + load_config
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestAuth(unittest.TestCase):
|
||||
|
||||
@patch("sapcli.auth.getpass")
|
||||
@patch("sapcli.auth.set_password", return_value=True)
|
||||
@patch("sapcli.config.load_config")
|
||||
def test_cmd_auth_login(self, mock_load, mock_set, mock_getpass):
|
||||
from sapcli.auth import cmd_auth_login
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.host = "http://sap:8000"
|
||||
mock_cfg.client = "100"
|
||||
mock_cfg.user = "TESTUSER"
|
||||
mock_load.return_value = (mock_cfg, None)
|
||||
mock_getpass.getpass.return_value = "SECRET"
|
||||
cmd_auth_login(argparse.Namespace(config=None))
|
||||
mock_set.assert_called_once()
|
||||
|
||||
def test_cmd_auth_status_no_keyring(self):
|
||||
from sapcli.auth import cmd_auth_status
|
||||
with patch("sapcli.auth._KEYRING_AVAILABLE", False):
|
||||
cmd_auth_status(argparse.Namespace()) # 不抛异常
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
"""commands/enhancement.py 单元测试 — cmd_enhancement。
|
||||
|
||||
运行: python tests/unit/test_enhancement.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 = {"type": "class", "name": "ZCL_TEST"}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
class TestCmdEnhancement(unittest.TestCase):
|
||||
"""cmd_enhancement 查询对象的增强实现。"""
|
||||
|
||||
def test_object_not_found_raises(self):
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
from sapcli.exceptions import ObjectNotFoundError
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = False
|
||||
|
||||
with self.assertRaises(ObjectNotFoundError) as ctx:
|
||||
cmd_enhancement(_args(), client)
|
||||
self.assertIn("ZCL_TEST", str(ctx.exception))
|
||||
|
||||
def test_api_error_prints_failure_and_returns(self):
|
||||
"""get_enhancements 抛异常时打印失败并返回。"""
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_enhancements.side_effect = Exception("network error")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_enhancement(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("失败", printed)
|
||||
|
||||
def test_no_enhancements(self):
|
||||
"""无增强实现时打印提示。"""
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_enhancements.return_value = []
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_enhancement(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("未找到增强实现", printed)
|
||||
|
||||
def test_with_enhancements_and_full_elements(self):
|
||||
"""增强实现含完整字段(enhanced_name、elements、mode、replacing)。"""
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_enhancements.return_value = [
|
||||
{
|
||||
"name": "ZENH_TEST",
|
||||
"type": "ENHO",
|
||||
"enhanced_name": "ZCL_TEST",
|
||||
"enhanced_type": "CLAS/OC",
|
||||
"elements": [
|
||||
{
|
||||
"type": "METHOD", "name": "HELLO",
|
||||
"mode": "overwrite", "replacing": "ZCL_TEST=>HELLO",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_enhancement(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("ZENH_TEST", printed)
|
||||
self.assertIn("ENHO", printed)
|
||||
self.assertIn("Enhanced Object", printed)
|
||||
self.assertIn("ZCL_TEST", printed)
|
||||
self.assertIn("METHOD: HELLO", printed)
|
||||
self.assertIn("Mode: overwrite", printed)
|
||||
self.assertIn("Replacing: ZCL_TEST=>HELLO", printed)
|
||||
self.assertIn("Element 1", printed)
|
||||
|
||||
def test_element_label_name_only(self):
|
||||
"""element 只有 name 时 label 为 name。"""
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_enhancements.return_value = [
|
||||
{
|
||||
"name": "ZENH1", "elements": [{"name": "ONLY_NAME"}],
|
||||
},
|
||||
]
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_enhancement(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("ONLY_NAME", printed)
|
||||
|
||||
def test_element_label_type_only(self):
|
||||
"""element 只有 type 时 label 为 type。"""
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_enhancements.return_value = [
|
||||
{
|
||||
"name": "ZENH1", "elements": [{"type": "ONLY_TYPE"}],
|
||||
},
|
||||
]
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_enhancement(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("ONLY_TYPE", printed)
|
||||
|
||||
def test_enhancement_minimal_fields(self):
|
||||
"""增强实现仅含 name(无 type/enhanced_name/elements)。"""
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_enhancements.return_value = [{"name": "ZENH_MIN"}]
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_enhancement(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("ZENH_MIN", printed)
|
||||
# 默认 type 为 ENHO
|
||||
self.assertIn("ENHO", printed)
|
||||
# 无 enhanced_name 时不打印 Enhanced Object 行
|
||||
self.assertNotIn("Enhanced Object", printed)
|
||||
|
||||
def test_multiple_enhancements_count(self):
|
||||
"""多个增强实现时打印数量。"""
|
||||
from sapcli.commands.enhancement import cmd_enhancement
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_enhancements.return_value = [
|
||||
{"name": "ZENH1", "elements": []},
|
||||
{"name": "ZENH2", "elements": []},
|
||||
]
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_enhancement(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("2", printed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""commands/history.py 单元测试 — cmd_history + _format_date。
|
||||
|
||||
运行: python tests/unit/test_history.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 = {"type": "class", "name": "ZCL_TEST"}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# _format_date
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestFormatDate(unittest.TestCase):
|
||||
"""_format_date 将 ISO 日期转为可读格式。"""
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
from sapcli.commands.history import _format_date
|
||||
self.assertEqual(_format_date(""), "")
|
||||
|
||||
def test_iso_datetime(self):
|
||||
from sapcli.commands.history import _format_date
|
||||
self.assertEqual(_format_date("2026-06-15T10:30:00"), "2026-06-15 10:30:00")
|
||||
|
||||
def test_iso_with_z_suffix(self):
|
||||
from sapcli.commands.history import _format_date
|
||||
self.assertEqual(_format_date("2026-06-15T10:30:00Z"), "2026-06-15 10:30:00")
|
||||
|
||||
def test_none_value(self):
|
||||
from sapcli.commands.history import _format_date
|
||||
self.assertEqual(_format_date(None), "")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# cmd_history
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdHistory(unittest.TestCase):
|
||||
"""cmd_history 查询对象版本历史。"""
|
||||
|
||||
def test_object_not_found_raises(self):
|
||||
from sapcli.commands.history import cmd_history
|
||||
from sapcli.exceptions import ObjectNotFoundError
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = False
|
||||
|
||||
with self.assertRaises(ObjectNotFoundError) as ctx:
|
||||
cmd_history(_args(), client)
|
||||
self.assertIn("ZCL_TEST", str(ctx.exception))
|
||||
|
||||
def test_api_error_prints_failure_and_returns(self):
|
||||
"""get_object_versions 抛异常时打印失败并返回。"""
|
||||
from sapcli.commands.history import cmd_history
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_object_versions.side_effect = Exception("server down")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_history(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("失败", printed)
|
||||
|
||||
def test_no_versions(self):
|
||||
"""无版本历史时打印提示。"""
|
||||
from sapcli.commands.history import cmd_history
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_object_versions.return_value = []
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_history(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("未找到版本历史", printed)
|
||||
|
||||
def test_with_versions_prints_table(self):
|
||||
"""有版本时打印版本表格。"""
|
||||
from sapcli.commands.history import cmd_history
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_object_versions.return_value = [
|
||||
{
|
||||
"version": "0001", "author": "ALICE",
|
||||
"date": "2026-06-15T10:30:00", "versionTitle": "initial import",
|
||||
},
|
||||
{
|
||||
"version": "", "author": "BOB",
|
||||
"date": "", "versionTitle": "current",
|
||||
},
|
||||
]
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_history(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("0001", printed)
|
||||
self.assertIn("ALICE", printed)
|
||||
self.assertIn("initial import", printed)
|
||||
# version 为空时回退到 "active"
|
||||
self.assertIn("active", printed)
|
||||
self.assertIn("2", printed) # 找到 2 个版本
|
||||
|
||||
def test_versions_missing_version_key(self):
|
||||
"""版本记录缺 version 键时回退到 active。"""
|
||||
from sapcli.commands.history import cmd_history
|
||||
|
||||
client = MagicMock()
|
||||
client.object_exists.return_value = True
|
||||
client.get_object_versions.return_value = [
|
||||
{"author": "CAROL", "date": "2026-01-01T00:00:00", "versionTitle": "v1"},
|
||||
]
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_history(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("active", printed)
|
||||
self.assertIn("CAROL", printed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,898 @@
|
||||
"""散落模块(config / manifest / scanner / sorter / auth)单元测试。
|
||||
|
||||
运行: python tests/unit/test_modules.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets"))
|
||||
|
||||
from sapcli.config import SAPConfig, _find_config_file, list_profiles, load_config
|
||||
from sapcli.exceptions import CyclicDependencyError, ConfigError
|
||||
from sapcli.manifest import (
|
||||
CURRENT_VERSION,
|
||||
MANIFEST_FILENAME,
|
||||
Manifest,
|
||||
ManifestEntry,
|
||||
init_manifest,
|
||||
)
|
||||
from sapcli.scanner import DIRECTORY_TYPE_MAP, ScannedObject, scan_project
|
||||
from sapcli.sorter import TYPE_PRIORITY, topological_sort
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# config.py
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSAPConfig(unittest.TestCase):
|
||||
"""SAPConfig dataclass 创建与校验。"""
|
||||
|
||||
def test_create_valid(self):
|
||||
cfg = SAPConfig(host="myhost", client="100", user="USER1", password="pass")
|
||||
self.assertEqual(cfg.host, "myhost")
|
||||
self.assertEqual(cfg.client, "100")
|
||||
self.assertEqual(cfg.user, "USER1")
|
||||
self.assertEqual(cfg.password, "pass")
|
||||
|
||||
def test_frozen(self):
|
||||
cfg = SAPConfig(host="h", client="100", user="u", password="p")
|
||||
with self.assertRaises(AttributeError):
|
||||
cfg.host = "other" # type: ignore[misc]
|
||||
|
||||
def test_missing_host_raises(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
SAPConfig(host="", client="100", user="u", password="p")
|
||||
|
||||
def test_missing_user_raises(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
SAPConfig(host="h", client="100", user="", password="p")
|
||||
|
||||
def test_missing_password_raises(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
SAPConfig(host="h", client="100", user="u", password="")
|
||||
|
||||
def test_missing_multiple_raises(self):
|
||||
with self.assertRaises(ConfigError) as ctx:
|
||||
SAPConfig(host="", client="100", user="", password="")
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("host", msg)
|
||||
self.assertIn("user", msg)
|
||||
|
||||
|
||||
class TestFindConfigFile(unittest.TestCase):
|
||||
"""_find_config_file 搜索路径逻辑。"""
|
||||
|
||||
def test_returns_none_when_no_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch("os.getcwd", return_value=tmp):
|
||||
result = _find_config_file()
|
||||
if result is not None:
|
||||
self.assertTrue(os.path.isfile(result))
|
||||
|
||||
def test_finds_file_in_cwd(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg_path = os.path.join(tmp, "config.ini")
|
||||
with open(cfg_path, "w") as f:
|
||||
f.write("[SAP]\nhost=example.com\n")
|
||||
with patch("os.getcwd", return_value=tmp):
|
||||
result = _find_config_file()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result, cfg_path)
|
||||
|
||||
|
||||
def _write_tmp_ini(content: str) -> str:
|
||||
"""写临时 .ini 文件,返回路径(文件已关闭,可在 Windows 上删除)。"""
|
||||
fd, path = tempfile.mkstemp(suffix=".ini")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
|
||||
class TestListProfiles(unittest.TestCase):
|
||||
"""list_profiles 从 config.ini 读取 sections。"""
|
||||
|
||||
def test_reads_sections(self):
|
||||
path = _write_tmp_ini("[SAP]\nhost=h1\n[DEV]\nhost=h2\n[PRD]\nhost=h3\n")
|
||||
try:
|
||||
profiles = list_profiles(config_path=path)
|
||||
self.assertIn("SAP", profiles)
|
||||
self.assertIn("DEV", profiles)
|
||||
self.assertIn("PRD", profiles)
|
||||
self.assertEqual(len(profiles), 3)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_empty_file_returns_empty(self):
|
||||
path = _write_tmp_ini("")
|
||||
try:
|
||||
profiles = list_profiles(config_path=path)
|
||||
self.assertEqual(profiles, [])
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_no_config_path_no_file(self):
|
||||
with patch("sapcli.config._find_config_file", return_value=None):
|
||||
profiles = list_profiles(config_path=None)
|
||||
self.assertEqual(profiles, [])
|
||||
|
||||
|
||||
class TestLoadConfig(unittest.TestCase):
|
||||
"""load_config 从文件和环境变量读取。"""
|
||||
|
||||
@staticmethod
|
||||
def _write_config(path: str, section: str = "SAP", **kwargs: str) -> None:
|
||||
import configparser
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg[section] = kwargs
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
cfg.write(f)
|
||||
|
||||
def _clean_env(self):
|
||||
return {k: v for k, v in os.environ.items()
|
||||
if k not in ("SAP_HOST", "SAP_CLIENT", "SAP_USER", "SAP_PASSWORD")}
|
||||
|
||||
def test_load_from_file(self):
|
||||
fd, path = tempfile.mkstemp(suffix=".ini")
|
||||
os.close(fd)
|
||||
try:
|
||||
self._write_config(path, host="myhost.com", client="200",
|
||||
user="MYUSER", password="secret")
|
||||
with patch.dict(os.environ, self._clean_env(), clear=True):
|
||||
cfg, loaded_from = load_config(config_path=path)
|
||||
self.assertEqual(cfg.host, "myhost.com")
|
||||
self.assertEqual(cfg.client, "200")
|
||||
self.assertEqual(cfg.user, "MYUSER")
|
||||
self.assertIsNotNone(loaded_from)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_env_overrides_file(self):
|
||||
fd, path = tempfile.mkstemp(suffix=".ini")
|
||||
os.close(fd)
|
||||
try:
|
||||
self._write_config(path, host="file-host", client="100",
|
||||
user="file-user", password="file-pass")
|
||||
env = self._clean_env()
|
||||
env["SAP_HOST"] = "env-host"
|
||||
env["SAP_USER"] = "env-user"
|
||||
env["SAP_PASSWORD"] = "env-pass"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
cfg, _ = load_config(config_path=path)
|
||||
self.assertEqual(cfg.host, "env-host")
|
||||
self.assertEqual(cfg.user, "env-user")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_missing_host_raises_config_error(self):
|
||||
fd, path = tempfile.mkstemp(suffix=".ini")
|
||||
os.close(fd)
|
||||
try:
|
||||
self._write_config(path, client="100", user="u", password="p")
|
||||
with patch.dict(os.environ, self._clean_env(), clear=True):
|
||||
with self.assertRaises(ConfigError):
|
||||
load_config(config_path=path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_profile_parameter(self):
|
||||
fd, path = tempfile.mkstemp(suffix=".ini")
|
||||
os.close(fd)
|
||||
try:
|
||||
self._write_config(path, section="DEV",
|
||||
host="dev-host", client="400",
|
||||
user="DEVUSER", password="devpass")
|
||||
with patch.dict(os.environ, self._clean_env(), clear=True):
|
||||
cfg, _ = load_config(config_path=path, profile="DEV")
|
||||
self.assertEqual(cfg.host, "dev-host")
|
||||
self.assertEqual(cfg.client, "400")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# manifest.py
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManifestEntry(unittest.TestCase):
|
||||
"""ManifestEntry 序列化 / 反序列化。"""
|
||||
|
||||
def test_to_dict_defaults(self):
|
||||
entry = ManifestEntry(name="ZFOO", type="report", file="reports/zfoo.abap")
|
||||
d = entry.to_dict()
|
||||
self.assertEqual(d["type"], "report")
|
||||
self.assertEqual(d["file"], "reports/zfoo.abap")
|
||||
self.assertEqual(d["system_status"], "not_exists")
|
||||
self.assertIsNone(d["corr_nr"])
|
||||
self.assertEqual(d["depends_on"], [])
|
||||
self.assertIsNone(d["last_sync"])
|
||||
self.assertEqual(d["last_sync_result"], "pending")
|
||||
|
||||
def test_to_dict_full(self):
|
||||
entry = ManifestEntry(
|
||||
name="ZBAR",
|
||||
type="class",
|
||||
file="classes/zbar.abap",
|
||||
system_status="active",
|
||||
corr_nr="NR001",
|
||||
depends_on=["ZFOO"],
|
||||
last_sync="2026-01-01T00:00:00",
|
||||
last_sync_result="success",
|
||||
)
|
||||
d = entry.to_dict()
|
||||
self.assertEqual(d["system_status"], "active")
|
||||
self.assertEqual(d["corr_nr"], "NR001")
|
||||
self.assertEqual(d["depends_on"], ["ZFOO"])
|
||||
self.assertEqual(d["last_sync"], "2026-01-01T00:00:00")
|
||||
self.assertEqual(d["last_sync_result"], "success")
|
||||
|
||||
def test_from_dict_roundtrip(self):
|
||||
original = ManifestEntry(
|
||||
name="ZRT",
|
||||
type="table",
|
||||
file="tables/zrt.abap",
|
||||
system_status="inactive",
|
||||
corr_nr="C002",
|
||||
depends_on=["ZA", "ZB"],
|
||||
last_sync="2026-06-01T12:00:00",
|
||||
last_sync_result="failed",
|
||||
)
|
||||
d = original.to_dict()
|
||||
restored = ManifestEntry.from_dict("ZRT", d)
|
||||
self.assertEqual(restored.name, original.name)
|
||||
self.assertEqual(restored.type, original.type)
|
||||
self.assertEqual(restored.file, original.file)
|
||||
self.assertEqual(restored.system_status, original.system_status)
|
||||
self.assertEqual(restored.corr_nr, original.corr_nr)
|
||||
self.assertEqual(restored.depends_on, original.depends_on)
|
||||
self.assertEqual(restored.last_sync, original.last_sync)
|
||||
self.assertEqual(restored.last_sync_result, original.last_sync_result)
|
||||
|
||||
def test_from_dict_defaults(self):
|
||||
entry = ManifestEntry.from_dict("ZNEW", {"type": "domain", "file": "domains/znew.abap"})
|
||||
self.assertEqual(entry.name, "ZNEW")
|
||||
self.assertEqual(entry.system_status, "not_exists")
|
||||
self.assertIsNone(entry.corr_nr)
|
||||
self.assertEqual(entry.depends_on, [])
|
||||
self.assertIsNone(entry.last_sync)
|
||||
self.assertEqual(entry.last_sync_result, "pending")
|
||||
|
||||
|
||||
class TestManifest(unittest.TestCase):
|
||||
"""Manifest load/save 往返和 CRUD。"""
|
||||
|
||||
def test_save_and_load_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manifest = Manifest(
|
||||
version=CURRENT_VERSION,
|
||||
last_init="2026-01-01T00:00:00",
|
||||
last_refresh=None,
|
||||
objects={
|
||||
"ZFOO": ManifestEntry(
|
||||
name="ZFOO", type="report", file="reports/zfoo.abap",
|
||||
system_status="active", last_sync_result="success",
|
||||
),
|
||||
"ZBAR": ManifestEntry(
|
||||
name="ZBAR", type="class", file="classes/zbar.abap",
|
||||
depends_on=["ZFOO"],
|
||||
),
|
||||
},
|
||||
_project_path=tmp,
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
filepath = os.path.join(tmp, MANIFEST_FILENAME)
|
||||
self.assertTrue(os.path.isfile(filepath))
|
||||
|
||||
loaded = Manifest.load(tmp)
|
||||
self.assertEqual(loaded.version, CURRENT_VERSION)
|
||||
self.assertEqual(len(loaded.objects), 2)
|
||||
self.assertIn("ZFOO", loaded.objects)
|
||||
self.assertIn("ZBAR", loaded.objects)
|
||||
self.assertEqual(loaded.objects["ZFOO"].type, "report")
|
||||
self.assertEqual(loaded.objects["ZBAR"].depends_on, ["ZFOO"])
|
||||
|
||||
def test_load_missing_file_raises(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
Manifest.load(tmp)
|
||||
|
||||
def test_upsert_and_remove(self):
|
||||
m = Manifest()
|
||||
entry = ManifestEntry(name="Z1", type="report", file="reports/z1.abap")
|
||||
m.upsert(entry)
|
||||
self.assertIn("Z1", m.objects)
|
||||
self.assertEqual(m.get("Z1").type, "report")
|
||||
|
||||
entry2 = ManifestEntry(name="Z1", type="class", file="classes/z1.abap")
|
||||
m.upsert(entry2)
|
||||
self.assertEqual(m.get("Z1").type, "class")
|
||||
|
||||
self.assertTrue(m.remove("Z1"))
|
||||
self.assertIsNone(m.get("Z1"))
|
||||
self.assertFalse(m.remove("Z1"))
|
||||
|
||||
def test_pending_objects(self):
|
||||
m = Manifest()
|
||||
m.upsert(ManifestEntry(name="A", type="report", file="r/a.abap",
|
||||
system_status="active", last_sync_result="success"))
|
||||
m.upsert(ManifestEntry(name="B", type="class", file="c/b.abap",
|
||||
system_status="not_exists", last_sync_result="pending"))
|
||||
m.upsert(ManifestEntry(name="C", type="table", file="t/c.abap",
|
||||
system_status="active", last_sync_result="failed"))
|
||||
pending = m.pending_objects()
|
||||
names = [e.name for e in pending]
|
||||
self.assertIn("B", names)
|
||||
self.assertIn("C", names)
|
||||
self.assertNotIn("A", names)
|
||||
|
||||
def test_active_up_to_date_objects(self):
|
||||
m = Manifest()
|
||||
m.upsert(ManifestEntry(name="A", type="report", file="r/a.abap",
|
||||
system_status="active", last_sync_result="success"))
|
||||
m.upsert(ManifestEntry(name="B", type="class", file="c/b.abap",
|
||||
system_status="not_exists", last_sync_result="pending"))
|
||||
active = m.active_up_to_date_objects()
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0].name, "A")
|
||||
|
||||
def test_file_path(self):
|
||||
m = Manifest(_project_path="/project")
|
||||
entry = ManifestEntry(name="Z1", type="report", file="reports/z1.abap")
|
||||
fp = m.file_path(entry)
|
||||
expected = os.path.join("/project", "reports", "z1.abap")
|
||||
self.assertEqual(fp, expected)
|
||||
|
||||
|
||||
class TestInitManifest(unittest.TestCase):
|
||||
"""init_manifest 创建空清单。"""
|
||||
|
||||
def test_creates_manifest_with_init_time(self):
|
||||
m = init_manifest("/tmp/fake")
|
||||
self.assertEqual(m.version, CURRENT_VERSION)
|
||||
self.assertIsNotNone(m.last_init)
|
||||
self.assertEqual(len(m.objects), 0)
|
||||
self.assertEqual(m._project_path, "/tmp/fake")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# scanner.py
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestScanProject(unittest.TestCase):
|
||||
"""scan_project 目录扫描。"""
|
||||
|
||||
def _make_project(self, tmp: str) -> None:
|
||||
"""创建模拟项目目录结构。"""
|
||||
# reports/zrpt001.abap
|
||||
os.makedirs(os.path.join(tmp, "reports"))
|
||||
with open(os.path.join(tmp, "reports", "zrpt001.abap"), "w") as f:
|
||||
f.write("REPORT zrpt001.\n")
|
||||
# reports/.hidden.abap — 应被跳过
|
||||
with open(os.path.join(tmp, "reports", ".hidden.abap"), "w") as f:
|
||||
f.write("REPORT hidden.\n")
|
||||
# reports/notes.txt — 非 .abap 应被跳过
|
||||
with open(os.path.join(tmp, "reports", "notes.txt"), "w") as f:
|
||||
f.write("notes\n")
|
||||
|
||||
# classes/zcl_demo.abap
|
||||
os.makedirs(os.path.join(tmp, "classes"))
|
||||
with open(os.path.join(tmp, "classes", "zcl_demo.abap"), "w") as f:
|
||||
f.write("CLASS zcl_demo DEFINITION.\n")
|
||||
|
||||
# functions/zgroup/zfunc1.abap
|
||||
os.makedirs(os.path.join(tmp, "functions", "zgroup"))
|
||||
with open(os.path.join(tmp, "functions", "zgroup", "zfunc1.abap"), "w") as f:
|
||||
f.write("FUNCTION zfunc1.\n")
|
||||
# functions/zgroup/zfunc2.abap
|
||||
with open(os.path.join(tmp, "functions", "zgroup", "zfunc2.abap"), "w") as f:
|
||||
f.write("FUNCTION zfunc2.\n")
|
||||
|
||||
# functions/.hidden_grp/func.abap — 隐藏目录应被跳过
|
||||
os.makedirs(os.path.join(tmp, "functions", ".hidden_grp"))
|
||||
with open(os.path.join(tmp, "functions", ".hidden_grp", "func.abap"), "w") as f:
|
||||
f.write("FUNCTION func.\n")
|
||||
|
||||
# domains/zdom_test.abap
|
||||
os.makedirs(os.path.join(tmp, "domains"))
|
||||
with open(os.path.join(tmp, "domains", "zdom_test.abap"), "w") as f:
|
||||
f.write("DOMAIN zdom_test.\n")
|
||||
|
||||
def test_scan_finds_all_objects(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._make_project(tmp)
|
||||
results = scan_project(tmp)
|
||||
names = {r.name for r in results}
|
||||
|
||||
self.assertIn("ZRPT001", names)
|
||||
self.assertNotIn("HIDDEN", names)
|
||||
self.assertIn("ZCL_DEMO", names)
|
||||
self.assertIn("ZGROUP/ZFUNC1", names)
|
||||
self.assertIn("ZGROUP/ZFUNC2", names)
|
||||
self.assertNotIn("HIDDEN_GRP/FUNC", names)
|
||||
self.assertIn("ZDOM_TEST", names)
|
||||
|
||||
def test_scan_types(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._make_project(tmp)
|
||||
results = scan_project(tmp)
|
||||
type_map = {r.name: r.type for r in results}
|
||||
|
||||
self.assertEqual(type_map.get("ZRPT001"), "report")
|
||||
self.assertEqual(type_map.get("ZCL_DEMO"), "class")
|
||||
self.assertEqual(type_map.get("ZGROUP/ZFUNC1"), "function")
|
||||
self.assertEqual(type_map.get("ZGROUP/ZFUNC2"), "function")
|
||||
self.assertEqual(type_map.get("ZDOM_TEST"), "domain")
|
||||
|
||||
def test_scan_file_paths(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._make_project(tmp)
|
||||
results = scan_project(tmp)
|
||||
file_map = {r.name: r.file for r in results}
|
||||
|
||||
self.assertEqual(file_map["ZRPT001"], "reports/zrpt001.abap")
|
||||
self.assertEqual(file_map["ZCL_DEMO"], "classes/zcl_demo.abap")
|
||||
self.assertEqual(file_map["ZGROUP/ZFUNC1"], "functions/zgroup/zfunc1.abap")
|
||||
self.assertEqual(file_map["ZDOM_TEST"], "domains/zdom_test.abap")
|
||||
|
||||
def test_scan_empty_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
results = scan_project(tmp)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
def test_scanned_object_dataclass(self):
|
||||
obj = ScannedObject(name="ZTEST", type="report", file="reports/ztest.abap")
|
||||
self.assertEqual(obj.name, "ZTEST")
|
||||
self.assertEqual(obj.type, "report")
|
||||
self.assertEqual(obj.file, "reports/ztest.abap")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# sorter.py
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTopologicalSort(unittest.TestCase):
|
||||
"""topological_sort 按类型优先级和依赖排序。"""
|
||||
|
||||
def _entry(self, name: str, obj_type: str, depends_on: list[str] | None = None) -> ManifestEntry:
|
||||
return ManifestEntry(
|
||||
name=name, type=obj_type, file=f"{obj_type}s/{name.lower()}.abap",
|
||||
depends_on=depends_on or [],
|
||||
)
|
||||
|
||||
def test_empty_list(self):
|
||||
self.assertEqual(topological_sort([]), [])
|
||||
|
||||
def test_sorts_by_type_priority(self):
|
||||
"""无依赖时,按 TYPE_PRIORITY 数值从小到大排序。"""
|
||||
objs = [
|
||||
self._entry("ZCL1", "class"),
|
||||
self._entry("ZDOM1", "domain"),
|
||||
self._entry("ZRPT1", "report"),
|
||||
self._entry("ZTAB1", "table"),
|
||||
]
|
||||
result = topological_sort(objs)
|
||||
names = [o.name for o in result]
|
||||
self.assertEqual(names, ["ZDOM1", "ZTAB1", "ZCL1", "ZRPT1"])
|
||||
|
||||
def test_respects_depends_on(self):
|
||||
"""有依赖时,被依赖的对象排在前面。"""
|
||||
objs = [
|
||||
self._entry("ZRPT1", "report", depends_on=["ZCL1"]),
|
||||
self._entry("ZCL1", "class", depends_on=["ZTAB1"]),
|
||||
self._entry("ZTAB1", "table"),
|
||||
]
|
||||
result = topological_sort(objs)
|
||||
names = [o.name for o in result]
|
||||
idx_tab = names.index("ZTAB1")
|
||||
idx_cl = names.index("ZCL1")
|
||||
idx_rpt = names.index("ZRPT1")
|
||||
self.assertLess(idx_tab, idx_cl)
|
||||
self.assertLess(idx_cl, idx_rpt)
|
||||
|
||||
def test_cyclic_dependency_raises(self):
|
||||
"""循环依赖应抛出 CyclicDependencyError。"""
|
||||
objs = [
|
||||
self._entry("A", "report", depends_on=["B"]),
|
||||
self._entry("B", "class", depends_on=["A"]),
|
||||
]
|
||||
with self.assertRaises(CyclicDependencyError):
|
||||
topological_sort(objs)
|
||||
|
||||
def test_external_dependency_ignored(self):
|
||||
"""depends_on 引用不在列表中的对象时,忽略该依赖。"""
|
||||
objs = [
|
||||
self._entry("ZRPT1", "report", depends_on=["EXTERNAL"]),
|
||||
]
|
||||
result = topological_sort(objs)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].name, "ZRPT1")
|
||||
|
||||
def test_unknown_type_gets_default_priority(self):
|
||||
"""未注册类型应获得默认优先级 (90)。"""
|
||||
objs = [
|
||||
self._entry("ZUNKNOWN", "weird_type"),
|
||||
self._entry("ZRPT1", "report"),
|
||||
]
|
||||
result = topological_sort(objs)
|
||||
names = [o.name for o in result]
|
||||
self.assertEqual(names, ["ZRPT1", "ZUNKNOWN"])
|
||||
|
||||
def test_diamond_dependency(self):
|
||||
"""菱形依赖: A → B, A → C, B → D, C → D。D 最先。"""
|
||||
objs = [
|
||||
self._entry("A", "report", depends_on=["B", "C"]),
|
||||
self._entry("B", "class", depends_on=["D"]),
|
||||
self._entry("C", "function", depends_on=["D"]),
|
||||
self._entry("D", "domain"),
|
||||
]
|
||||
result = topological_sort(objs)
|
||||
names = [o.name for o in result]
|
||||
idx_d = names.index("D")
|
||||
idx_b = names.index("B")
|
||||
idx_c = names.index("C")
|
||||
idx_a = names.index("A")
|
||||
self.assertLess(idx_d, idx_b)
|
||||
self.assertLess(idx_d, idx_c)
|
||||
self.assertLess(idx_b, idx_a)
|
||||
self.assertLess(idx_c, idx_a)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# auth.py
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestServiceName(unittest.TestCase):
|
||||
"""_service_name 格式。"""
|
||||
|
||||
def test_format(self):
|
||||
from sapcli.auth import _service_name
|
||||
self.assertEqual(_service_name("myhost.com", "100"), "sap-cli:myhost.com:100")
|
||||
|
||||
def test_format_empty_client(self):
|
||||
from sapcli.auth import _service_name
|
||||
self.assertEqual(_service_name("host", ""), "sap-cli:host:")
|
||||
|
||||
|
||||
class _AuthTestBase(unittest.TestCase):
|
||||
"""auth 测试基类,mock keyring 模块。"""
|
||||
|
||||
def _patch_keyring(self):
|
||||
"""将 sapcli.auth / sapcli.password 的 _keyring / _KEYRING_AVAILABLE 替换为 mock。"""
|
||||
import sapcli.auth as auth_mod
|
||||
import sapcli.password as pwd_mod
|
||||
|
||||
self.mock_kr = MagicMock()
|
||||
# mock keyring module with errors attribute
|
||||
self.mock_kr_mod = MagicMock()
|
||||
self.mock_kr_mod.errors.PasswordDeleteError = type("PasswordDeleteError", (Exception,), {})
|
||||
|
||||
# 保存原始值以便恢复
|
||||
self._orig_keyring = getattr(auth_mod, "_keyring", None)
|
||||
self._orig_available = getattr(auth_mod, "_KEYRING_AVAILABLE", None)
|
||||
self._orig_kr_mod = getattr(auth_mod, "_keyring_mod", None)
|
||||
self._had_kr_mod = hasattr(auth_mod, "_keyring_mod")
|
||||
|
||||
self._orig_pwd_keyring = getattr(pwd_mod, "_keyring", None)
|
||||
self._orig_pwd_available = getattr(pwd_mod, "_KEYRING_AVAILABLE", None)
|
||||
self._orig_pwd_kr_mod = getattr(pwd_mod, "_keyring_mod", None)
|
||||
self._had_pwd_kr_mod = hasattr(pwd_mod, "_keyring_mod")
|
||||
|
||||
# auth 模块
|
||||
auth_mod._keyring = self.mock_kr
|
||||
auth_mod._KEYRING_AVAILABLE = True
|
||||
auth_mod._keyring_mod = self.mock_kr_mod
|
||||
|
||||
# password 模块(resolve_password / get_password 实际所在)
|
||||
pwd_mod._keyring = self.mock_kr
|
||||
pwd_mod._KEYRING_AVAILABLE = True
|
||||
pwd_mod._keyring_mod = self.mock_kr_mod
|
||||
|
||||
self.addCleanup(self._restore_keyring)
|
||||
|
||||
def _restore_keyring(self):
|
||||
import sapcli.auth as auth_mod
|
||||
import sapcli.password as pwd_mod
|
||||
auth_mod._keyring = self._orig_keyring
|
||||
auth_mod._KEYRING_AVAILABLE = self._orig_available
|
||||
if self._had_kr_mod:
|
||||
auth_mod._keyring_mod = self._orig_kr_mod
|
||||
elif hasattr(auth_mod, "_keyring_mod"):
|
||||
del auth_mod._keyring_mod
|
||||
|
||||
pwd_mod._keyring = self._orig_pwd_keyring
|
||||
pwd_mod._KEYRING_AVAILABLE = self._orig_pwd_available
|
||||
if self._had_pwd_kr_mod:
|
||||
pwd_mod._keyring_mod = self._orig_pwd_kr_mod
|
||||
elif hasattr(pwd_mod, "_keyring_mod"):
|
||||
del pwd_mod._keyring_mod
|
||||
|
||||
|
||||
class TestGetPassword(_AuthTestBase):
|
||||
"""get_password 从 keyring 读取密码。"""
|
||||
|
||||
def setUp(self):
|
||||
self._patch_keyring()
|
||||
|
||||
def test_returns_password(self):
|
||||
from sapcli.auth import get_password
|
||||
self.mock_kr.get_password.return_value = "secret123"
|
||||
result = get_password("host", "100", "user1")
|
||||
self.assertEqual(result, "secret123")
|
||||
self.mock_kr.get_password.assert_called_once_with("sap-cli:host:100", "user1")
|
||||
|
||||
def test_returns_none_on_exception(self):
|
||||
from sapcli.auth import get_password
|
||||
self.mock_kr.get_password.side_effect = Exception("boom")
|
||||
result = get_password("host", "100", "user1")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_when_unavailable(self):
|
||||
from sapcli.auth import get_password
|
||||
with patch("sapcli.password._KEYRING_AVAILABLE", False):
|
||||
result = get_password("host", "100", "user1")
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestSetPassword(_AuthTestBase):
|
||||
"""set_password 存储密码到 keyring。"""
|
||||
|
||||
def setUp(self):
|
||||
self._patch_keyring()
|
||||
|
||||
def test_success(self):
|
||||
from sapcli.auth import set_password
|
||||
result = set_password("host", "100", "user1", "pass")
|
||||
self.assertTrue(result)
|
||||
self.mock_kr.set_password.assert_called_once_with("sap-cli:host:100", "user1", "pass")
|
||||
|
||||
def test_failure_returns_false(self):
|
||||
from sapcli.auth import set_password
|
||||
self.mock_kr.set_password.side_effect = Exception("fail")
|
||||
result = set_password("host", "100", "user1", "pass")
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_unavailable_returns_false(self):
|
||||
from sapcli.auth import set_password
|
||||
with patch("sapcli.auth._KEYRING_AVAILABLE", False):
|
||||
result = set_password("host", "100", "user1", "pass")
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
class TestDeletePassword(_AuthTestBase):
|
||||
"""delete_password 从 keyring 删除密码。"""
|
||||
|
||||
def setUp(self):
|
||||
self._patch_keyring()
|
||||
|
||||
def test_success(self):
|
||||
from sapcli.auth import delete_password
|
||||
result = delete_password("host", "100", "user1")
|
||||
self.assertTrue(result)
|
||||
self.mock_kr.delete_password.assert_called_once_with("sap-cli:host:100", "user1")
|
||||
|
||||
def test_failure_returns_false(self):
|
||||
from sapcli.auth import delete_password
|
||||
self.mock_kr.delete_password.side_effect = Exception("fail")
|
||||
result = delete_password("host", "100", "user1")
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_unavailable_returns_false(self):
|
||||
from sapcli.auth import delete_password
|
||||
with patch("sapcli.auth._KEYRING_AVAILABLE", False):
|
||||
result = delete_password("host", "100", "user1")
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
class TestResolvePassword(_AuthTestBase):
|
||||
"""resolve_password 按优先级解析密码。"""
|
||||
|
||||
def setUp(self):
|
||||
self._patch_keyring()
|
||||
|
||||
def test_env_password_takes_priority(self):
|
||||
from sapcli.auth import resolve_password
|
||||
self.mock_kr.get_password.return_value = "kr_pass"
|
||||
result = resolve_password("h", "100", "u", config_password="cfg_pass", env_password="env_pass")
|
||||
self.assertEqual(result, "env_pass")
|
||||
|
||||
def test_keyring_over_config(self):
|
||||
from sapcli.auth import resolve_password
|
||||
self.mock_kr.get_password.return_value = "kr_pass"
|
||||
result = resolve_password("h", "100", "u", config_password="cfg_pass", env_password="")
|
||||
self.assertEqual(result, "kr_pass")
|
||||
|
||||
def test_config_as_fallback(self):
|
||||
from sapcli.auth import resolve_password
|
||||
self.mock_kr.get_password.return_value = None
|
||||
result = resolve_password("h", "100", "u", config_password="cfg_pass", env_password="")
|
||||
self.assertEqual(result, "cfg_pass")
|
||||
|
||||
def test_all_empty(self):
|
||||
from sapcli.auth import resolve_password
|
||||
self.mock_kr.get_password.return_value = None
|
||||
result = resolve_password("h", "100", "u", config_password="", env_password="")
|
||||
self.assertEqual(result, "")
|
||||
|
||||
|
||||
class TestCmdAuthStatus(unittest.TestCase):
|
||||
"""cmd_auth_status 输出。"""
|
||||
|
||||
def test_prints_keyring_available(self):
|
||||
from sapcli.auth import cmd_auth_status
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.__class__ = type("TestBackend", (), {})
|
||||
with patch("sapcli.auth._KEYRING_AVAILABLE", True), \
|
||||
patch("sapcli.auth._keyring") as mock_kr:
|
||||
mock_kr.get_keyring.return_value = mock_backend
|
||||
with patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
cmd_auth_status(argparse.Namespace())
|
||||
output = out.getvalue()
|
||||
self.assertIn("keyring", output)
|
||||
self.assertIn("可用", output)
|
||||
|
||||
def test_prints_keyring_unavailable(self):
|
||||
from sapcli.auth import cmd_auth_status
|
||||
with patch("sapcli.auth._KEYRING_AVAILABLE", False):
|
||||
with patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
cmd_auth_status(argparse.Namespace())
|
||||
output = out.getvalue()
|
||||
self.assertIn("keyring", output)
|
||||
self.assertIn("不可用", output)
|
||||
|
||||
|
||||
class TestCmdAuthLogin(unittest.TestCase):
|
||||
"""cmd_auth_login 交互式登录。"""
|
||||
|
||||
def test_login_stores_password(self):
|
||||
from sapcli.auth import cmd_auth_login
|
||||
|
||||
fake_cfg = SAPConfig(host="h", client="100", user="u", password="p")
|
||||
args = argparse.Namespace(config=None)
|
||||
|
||||
with patch("sapcli.config.load_config", return_value=(fake_cfg, "/fake")), \
|
||||
patch("sapcli.auth.getpass") as mock_gp_mod, \
|
||||
patch("sapcli.auth.set_password", return_value=True) as mock_sp, \
|
||||
patch("sys.stdout", new_callable=io.StringIO):
|
||||
|
||||
mock_gp_mod.getpass.return_value = "mypassword"
|
||||
cmd_auth_login(args)
|
||||
|
||||
mock_sp.assert_called_once_with("h", "100", "u", "mypassword")
|
||||
|
||||
def test_login_empty_password_aborts(self):
|
||||
from sapcli.auth import cmd_auth_login
|
||||
|
||||
fake_cfg = SAPConfig(host="h", client="100", user="u", password="p")
|
||||
|
||||
with patch("sapcli.config.load_config", return_value=(fake_cfg, "/fake")), \
|
||||
patch("sapcli.auth.getpass.getpass", return_value=""), \
|
||||
patch("sapcli.auth.set_password") as mock_sp, \
|
||||
patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
|
||||
cmd_auth_login(argparse.Namespace(config=None))
|
||||
# 不应调用 set_password
|
||||
mock_sp.assert_not_called()
|
||||
self.assertIn("不能为空", out.getvalue())
|
||||
|
||||
|
||||
class TestCmdAuthLogout(unittest.TestCase):
|
||||
"""cmd_auth_logout 删除密码。"""
|
||||
|
||||
def test_logout_deletes_password(self):
|
||||
from sapcli.auth import cmd_auth_logout
|
||||
|
||||
fake_cfg = SAPConfig(host="h", client="100", user="u", password="p")
|
||||
|
||||
with patch("sapcli.config.load_config", return_value=(fake_cfg, "/fake")), \
|
||||
patch("sapcli.auth.delete_password", return_value=True) as mock_dp, \
|
||||
patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||
|
||||
cmd_auth_logout(argparse.Namespace(config=None))
|
||||
|
||||
mock_dp.assert_called_once_with("h", "100", "u")
|
||||
self.assertIn("删除", out.getvalue())
|
||||
|
||||
class TestScanProjectThreeLayer(unittest.TestCase):
|
||||
"""scan_project 三层布局(src/<开发包>/<对象类型>/)+ 单复数目录名。"""
|
||||
|
||||
def _make_project(self, tmp: str) -> None:
|
||||
"""src/TMP/<类型>/ 三层布局。"""
|
||||
for sub in (
|
||||
"src/TMP/report", "src/TMP/class", "src/TMP/interface",
|
||||
"src/TMP/domain", "src/TMP/structure", "src/TMP/tabletype",
|
||||
):
|
||||
os.makedirs(os.path.join(tmp, *sub.split("/")))
|
||||
|
||||
def put(rel: str, body: str) -> None:
|
||||
with open(os.path.join(tmp, *rel.split("/")), "w") as f:
|
||||
f.write(body)
|
||||
|
||||
put("src/TMP/report/zrpt001.abap", "REPORT zrpt001.\n")
|
||||
put("src/TMP/report/notes.txt", "skip me\n") # 非 .abap 跳过
|
||||
put("src/TMP/class/zcl_demo.abap", "CLASS zcl_demo DEFINITION.\n")
|
||||
put("src/TMP/interface/zif_demo.abap", "INTERFACE zif_demo.\n")
|
||||
put("src/TMP/domain/zdom_demo.abap", "DOMAIN zdom_demo.\n")
|
||||
put("src/TMP/structure/zst_demo.abap", "STRUCTURE zst_demo.\n")
|
||||
put("src/TMP/tabletype/ztt_demo.abap", "TABLETYPE ztt_demo.\n")
|
||||
|
||||
def _make_funcs(self, tmp: str) -> None:
|
||||
"""函数模块两种命名。"""
|
||||
os.makedirs(os.path.join(tmp, "src", "TMP", "function", "zfg_one"))
|
||||
with open(os.path.join(tmp, "src", "TMP", "function", "zfg_one", "zfm_a.abap"), "w") as f:
|
||||
f.write("FUNCTION zfm_a.\n")
|
||||
with open(os.path.join(tmp, "src", "TMP", "function", "zfg_two.fugr.zfm_b.abap"), "w") as f:
|
||||
f.write("FUNCTION zfm_b.\n")
|
||||
|
||||
def test_three_layer_finds_objects(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._make_project(tmp)
|
||||
results = scan_project(tmp)
|
||||
by_name = {r.name: r for r in results}
|
||||
|
||||
self.assertIn("ZRPT001", by_name)
|
||||
self.assertNotIn("NOTES", by_name)
|
||||
self.assertIn("ZCL_DEMO", by_name)
|
||||
self.assertIn("ZIF_DEMO", by_name)
|
||||
self.assertIn("ZDOM_DEMO", by_name)
|
||||
self.assertIn("ZST_DEMO", by_name)
|
||||
self.assertIn("ZTT_DEMO", by_name)
|
||||
|
||||
# 类型识别正确(按目录名,而非文件名)
|
||||
self.assertEqual(by_name["ZCL_DEMO"].type, "class")
|
||||
self.assertEqual(by_name["ZST_DEMO"].type, "structure")
|
||||
self.assertEqual(by_name["ZTT_DEMO"].type, "tabletype")
|
||||
# 相对项目根路径保留三层
|
||||
self.assertEqual(by_name["ZCL_DEMO"].file, "src/TMP/class/zcl_demo.abap")
|
||||
|
||||
def test_function_two_naming_styles(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._make_funcs(tmp)
|
||||
names = {r.name for r in scan_project(tmp)}
|
||||
self.assertIn("ZFG_ONE/ZFM_A", names) # 子目录式
|
||||
self.assertIn("ZFG_TWO/ZFM_B", names) # ADT 式 <fugr>.fugr.<fm>
|
||||
|
||||
def test_plural_and_case_insensitive(self):
|
||||
"""classes/ 与 class/ 等价,目录名大小写不敏感。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, "src", "TMP", "CLASSES"))
|
||||
with open(os.path.join(tmp, "src", "TMP", "CLASSES", "zcl_x.abap"), "w") as f:
|
||||
f.write("CLASS zcl_x DEFINITION.\n")
|
||||
res = scan_project(tmp)
|
||||
self.assertEqual([(r.name, r.type) for r in res], [("ZCL_X", "class")])
|
||||
|
||||
def test_backward_compatible_flat_layout(self):
|
||||
"""旧的扁平布局仍可扫描。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, "reports"))
|
||||
with open(os.path.join(tmp, "reports", "zold.abap"), "w") as f:
|
||||
f.write("REPORT zold.\n")
|
||||
os.makedirs(os.path.join(tmp, "classes"))
|
||||
with open(os.path.join(tmp, "classes", "zcl_old.abap"), "w") as f:
|
||||
f.write("CLASS zcl_old DEFINITION.\n")
|
||||
names = {r.name for r in scan_project(tmp)}
|
||||
self.assertIn("ZOLD", names)
|
||||
self.assertIn("ZCL_OLD", names)
|
||||
|
||||
def test_no_duplicate_when_both_layouts(self):
|
||||
"""同一对象在两处出现只保留一次。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, "src", "TMP", "report"))
|
||||
os.makedirs(os.path.join(tmp, "reports"))
|
||||
for d in ("src/TMP/report", "reports"):
|
||||
with open(os.path.join(tmp, *d.split("/"), "zdup.abap"), "w") as f:
|
||||
f.write("REPORT zdup.\n")
|
||||
res = scan_project(tmp)
|
||||
self.assertEqual(len([r for r in res if r.name == "ZDUP"]), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""commands/program_run.py 单元测试 — cmd_run_program。
|
||||
|
||||
运行: python tests/unit/test_program_run.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": "zprogram"}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
class TestCmdRunProgram(unittest.TestCase):
|
||||
"""cmd_run_program 远程执行 ABAP 程序。"""
|
||||
|
||||
def test_output_present_is_printed(self):
|
||||
"""程序返回非空输出时打印输出内容。"""
|
||||
from sapcli.commands.program_run import cmd_run_program
|
||||
|
||||
client = MagicMock()
|
||||
client.run_program.return_value = " Hello ABAP \n"
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_run_program(_args(name="zsetup"), client)
|
||||
|
||||
# 程序名应大写后传给 client
|
||||
client.run_program.assert_called_once_with("ZSETUP")
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("Hello ABAP", printed)
|
||||
|
||||
def test_output_trailing_newlines_rstripped(self):
|
||||
"""输出尾随空白被 rstrip 去掉。"""
|
||||
from sapcli.commands.program_run import cmd_run_program
|
||||
|
||||
client = MagicMock()
|
||||
client.run_program.return_value = "line1\nline2\n\n\n"
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_run_program(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("line2", printed)
|
||||
|
||||
def test_empty_output_prints_no_output(self):
|
||||
"""程序返回纯空白输出时打印「无输出」。"""
|
||||
from sapcli.commands.program_run import cmd_run_program
|
||||
|
||||
client = MagicMock()
|
||||
client.run_program.return_value = " \n "
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_run_program(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("无输出", printed)
|
||||
|
||||
def test_truly_empty_string_prints_no_output(self):
|
||||
"""程序返回空字符串时打印「无输出」。"""
|
||||
from sapcli.commands.program_run import cmd_run_program
|
||||
|
||||
client = MagicMock()
|
||||
client.run_program.return_value = ""
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_run_program(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("无输出", printed)
|
||||
|
||||
def test_name_uppercased_before_call(self):
|
||||
"""小写程序名被 .upper() 后传给 client。"""
|
||||
from sapcli.commands.program_run import cmd_run_program
|
||||
|
||||
client = MagicMock()
|
||||
client.run_program.return_value = ""
|
||||
|
||||
cmd_run_program(_args(name="ztest_prog"), client)
|
||||
|
||||
client.run_program.assert_called_once_with("ZTEST_PROG")
|
||||
|
||||
def test_info_message_printed(self):
|
||||
"""执行前打印「远程执行程序」提示。"""
|
||||
from sapcli.commands.program_run import cmd_run_program
|
||||
|
||||
client = MagicMock()
|
||||
client.run_program.return_value = "ok"
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_run_program(_args(name="zprog"), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("ZPROG", printed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""仓库不变量守卫(唯一源)。
|
||||
|
||||
本仓 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 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()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""commands/unit_test.py 单元测试 — cmd_unit_test + _parse_duration。
|
||||
|
||||
运行: python tests/unit/test_unit_test.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 = {"type": "class", "name": "ZCL_TEST"}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# _parse_duration
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestParseDuration(unittest.TestCase):
|
||||
"""_parse_duration 安全解析方法耗时。"""
|
||||
|
||||
def test_valid_float_string(self):
|
||||
from sapcli.commands.unit_test import _parse_duration
|
||||
self.assertEqual(_parse_duration("1.5"), 1.5)
|
||||
self.assertEqual(_parse_duration("0.25"), 0.25)
|
||||
self.assertEqual(_parse_duration("3"), 3.0)
|
||||
|
||||
def test_invalid_string_returns_zero(self):
|
||||
from sapcli.commands.unit_test import _parse_duration
|
||||
self.assertEqual(_parse_duration("abc"), 0.0)
|
||||
|
||||
def test_none_returns_zero(self):
|
||||
from sapcli.commands.unit_test import _parse_duration
|
||||
self.assertEqual(_parse_duration(None), 0.0)
|
||||
|
||||
def test_empty_string_returns_zero(self):
|
||||
from sapcli.commands.unit_test import _parse_duration
|
||||
self.assertEqual(_parse_duration(""), 0.0)
|
||||
|
||||
def test_zero_string(self):
|
||||
from sapcli.commands.unit_test import _parse_duration
|
||||
self.assertEqual(_parse_duration("0"), 0.0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# cmd_unit_test
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TestCmdUnitTest(unittest.TestCase):
|
||||
"""cmd_unit_test 执行 ABAP Unit 测试并汇总结果。"""
|
||||
|
||||
def test_api_error_prints_failure_and_returns(self):
|
||||
"""run_unit_test 抛异常时打印失败提示并返回(不抛)。"""
|
||||
from sapcli.commands.unit_test import cmd_unit_test
|
||||
|
||||
client = MagicMock()
|
||||
client.run_unit_test.side_effect = Exception("connection refused")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_unit_test(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("失败", printed)
|
||||
|
||||
def test_no_test_classes(self):
|
||||
"""无测试类时打印提示。"""
|
||||
from sapcli.commands.unit_test import cmd_unit_test
|
||||
|
||||
client = MagicMock()
|
||||
client.run_unit_test.return_value = {"summary": {}, "classes": []}
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_unit_test(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("未找到测试类", printed)
|
||||
|
||||
def test_passed_class(self):
|
||||
"""全部通过时打印 PASSED 和方法数。"""
|
||||
from sapcli.commands.unit_test import cmd_unit_test
|
||||
|
||||
client = MagicMock()
|
||||
client.run_unit_test.return_value = {
|
||||
"summary": {
|
||||
"tests": "2", "failures": "0", "errors": "0", "skipped": "0",
|
||||
},
|
||||
"classes": [
|
||||
{
|
||||
"name": "ZCL_TEST",
|
||||
"methods": [
|
||||
{"name": "test1", "duration": "1.5"},
|
||||
{"name": "test2", "duration": "0.5"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_unit_test(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("PASSED", printed)
|
||||
self.assertIn("2 methods", printed)
|
||||
# 合计行
|
||||
self.assertIn("合计", printed)
|
||||
|
||||
def test_failed_class_with_alert_and_line(self):
|
||||
"""方法失败时打印 FAILED、方法名、行号、告警内容。"""
|
||||
from sapcli.commands.unit_test import cmd_unit_test
|
||||
|
||||
client = MagicMock()
|
||||
client.run_unit_test.return_value = {
|
||||
"summary": {
|
||||
"tests": "1", "failures": "1", "errors": "0", "skipped": "0",
|
||||
},
|
||||
"classes": [
|
||||
{
|
||||
"name": "ZCL_TEST",
|
||||
"methods": [
|
||||
{
|
||||
"name": "test_fail", "duration": "0.1",
|
||||
"alert": "Assertion failed", "line": "42",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_unit_test(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("FAILED", printed)
|
||||
self.assertIn("test_fail", printed)
|
||||
self.assertIn("42", printed)
|
||||
self.assertIn("Assertion failed", printed)
|
||||
self.assertIn("at line 42", printed)
|
||||
|
||||
def test_failed_without_line_omits_line_info(self):
|
||||
"""失败方法无 line 时不打印 at line。"""
|
||||
from sapcli.commands.unit_test import cmd_unit_test
|
||||
|
||||
client = MagicMock()
|
||||
client.run_unit_test.return_value = {
|
||||
"summary": {},
|
||||
"classes": [
|
||||
{
|
||||
"name": "ZCL_TEST",
|
||||
"methods": [{"name": "test_x", "alert": "boom"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_unit_test(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("FAILED", printed)
|
||||
self.assertIn("boom", printed)
|
||||
self.assertNotIn("at line", printed)
|
||||
|
||||
def test_class_without_name_falls_back_to_display(self):
|
||||
"""class name 为空时回退到 parsed.display_name。"""
|
||||
from sapcli.commands.unit_test import cmd_unit_test
|
||||
|
||||
client = MagicMock()
|
||||
client.run_unit_test.return_value = {
|
||||
"summary": {},
|
||||
"classes": [
|
||||
{"name": None, "methods": [{"name": "test1"}]},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_unit_test(_args(name="ZCL_FOO"), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
self.assertIn("ZCL_FOO", printed)
|
||||
|
||||
def test_summary_defaults_when_missing(self):
|
||||
"""summary 缺字段时使用默认值(? / 0)。"""
|
||||
from sapcli.commands.unit_test import cmd_unit_test
|
||||
|
||||
client = MagicMock()
|
||||
client.run_unit_test.return_value = {
|
||||
"summary": {},
|
||||
"classes": [{"name": "ZCL", "methods": [{"name": "t1"}]}],
|
||||
}
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
cmd_unit_test(_args(), client)
|
||||
|
||||
printed = " ".join(str(c) for c, _ in mock_print.call_args_list)
|
||||
# tests 默认 "?"
|
||||
self.assertIn("?", printed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user