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
+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()