Files
sap-cli-skill/tests/unit/test_commands.py
T
吴让宇 c5905a5b1e refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)
方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档,
sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。

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

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

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

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

698 tests OK;editable 安装与 CLI 入口经临时 venv 实测通过。
docs/RELEASING.md 重写为单源开发流程。
2026-09-11 00:40:15 +08:00

601 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""commands/ 模块单元测试
运行: 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)