refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)

方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档,
sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。

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

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

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

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

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